mirror of
https://github.com/cinnyapp/cinny.git
synced 2024-11-20 06:49:52 +01:00
(chore) remove outdated code (#1765)
* optimize room typing members hook * remove unused code - WIP * remove old code from initMatrix * remove twemojify function * remove old sanitize util * delete old markdown util * delete Math atom component * uninstall unused dependencies * remove old notification system * decrypt message in inbox notification center and fix refresh in background * improve notification --------- Co-authored-by: Krishan <33421343+kfiven@users.noreply.github.com>
This commit is contained in:
parent
60e022035f
commit
4f09e6bbb5
147 changed files with 1164 additions and 15330 deletions
|
@ -90,12 +90,6 @@
|
||||||
window.global ||= window;
|
window.global ||= window;
|
||||||
</script>
|
</script>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<audio id="notificationSound">
|
|
||||||
<source src="./public/sound/notification.ogg" type="audio/ogg" />
|
|
||||||
</audio>
|
|
||||||
<audio id="inviteSound">
|
|
||||||
<source src="./public/sound/invite.ogg" type="audio/ogg" />
|
|
||||||
</audio>
|
|
||||||
<script type="module" src="./src/index.tsx"></script>
|
<script type="module" src="./src/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
188
package-lock.json
generated
188
package-lock.json
generated
|
@ -13,7 +13,6 @@
|
||||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||||
"@fontsource/inter": "4.5.14",
|
"@fontsource/inter": "4.5.14",
|
||||||
"@khanacademy/simple-markdown": "0.8.6",
|
|
||||||
"@matrix-org/olm": "3.2.14",
|
"@matrix-org/olm": "3.2.14",
|
||||||
"@tanstack/react-query": "5.24.1",
|
"@tanstack/react-query": "5.24.1",
|
||||||
"@tanstack/react-query-devtools": "5.24.1",
|
"@tanstack/react-query-devtools": "5.24.1",
|
||||||
|
@ -41,8 +40,6 @@
|
||||||
"immer": "9.0.16",
|
"immer": "9.0.16",
|
||||||
"is-hotkey": "0.2.0",
|
"is-hotkey": "0.2.0",
|
||||||
"jotai": "2.6.0",
|
"jotai": "2.6.0",
|
||||||
"katex": "0.16.10",
|
|
||||||
"linkify-html": "4.0.2",
|
|
||||||
"linkify-react": "4.1.1",
|
"linkify-react": "4.1.1",
|
||||||
"linkifyjs": "4.0.2",
|
"linkifyjs": "4.0.2",
|
||||||
"matrix-js-sdk": "29.1.0",
|
"matrix-js-sdk": "29.1.0",
|
||||||
|
@ -54,8 +51,6 @@
|
||||||
"react-aria": "3.29.1",
|
"react-aria": "3.29.1",
|
||||||
"react-autosize-textarea": "7.1.0",
|
"react-autosize-textarea": "7.1.0",
|
||||||
"react-blurhash": "0.2.0",
|
"react-blurhash": "0.2.0",
|
||||||
"react-dnd": "16.0.1",
|
|
||||||
"react-dnd-html5-backend": "16.0.1",
|
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-error-boundary": "4.0.10",
|
"react-error-boundary": "4.0.10",
|
||||||
"react-google-recaptcha": "2.1.0",
|
"react-google-recaptcha": "2.1.0",
|
||||||
|
@ -67,7 +62,6 @@
|
||||||
"slate-history": "0.93.0",
|
"slate-history": "0.93.0",
|
||||||
"slate-react": "0.98.4",
|
"slate-react": "0.98.4",
|
||||||
"tippy.js": "6.3.7",
|
"tippy.js": "6.3.7",
|
||||||
"twemoji": "14.0.2",
|
|
||||||
"ua-parser-js": "1.0.35"
|
"ua-parser-js": "1.0.35"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -1109,18 +1103,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
|
||||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||||
},
|
},
|
||||||
"node_modules/@khanacademy/simple-markdown": {
|
|
||||||
"version": "0.8.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@khanacademy/simple-markdown/-/simple-markdown-0.8.6.tgz",
|
|
||||||
"integrity": "sha512-mAUlR9lchzfqunR89pFvNI51jQKsMpJeWYsYWw0DQcUXczn/T/V6510utgvm7X0N3zN87j1SvuKk8cMbl9IAFw==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/react": ">=16.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "16.14.0",
|
|
||||||
"react-dom": "16.14.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@mapbox/node-pre-gyp": {
|
"node_modules/@mapbox/node-pre-gyp": {
|
||||||
"version": "1.0.11",
|
"version": "1.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
|
||||||
|
@ -1942,21 +1924,6 @@
|
||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-dnd/asap": {
|
|
||||||
"version": "5.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
|
|
||||||
"integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A=="
|
|
||||||
},
|
|
||||||
"node_modules/@react-dnd/invariant": {
|
|
||||||
"version": "4.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz",
|
|
||||||
"integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw=="
|
|
||||||
},
|
|
||||||
"node_modules/@react-dnd/shallowequal": {
|
|
||||||
"version": "4.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz",
|
|
||||||
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA=="
|
|
||||||
},
|
|
||||||
"node_modules/@react-stately/calendar": {
|
"node_modules/@react-stately/calendar": {
|
||||||
"version": "3.4.1",
|
"version": "3.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@react-stately/calendar/-/calendar-3.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@react-stately/calendar/-/calendar-3.4.1.tgz",
|
||||||
|
@ -3307,12 +3274,14 @@
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.5",
|
"version": "15.7.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
|
||||||
"integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w=="
|
"integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==",
|
||||||
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.2.39",
|
"version": "18.2.39",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.39.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.39.tgz",
|
||||||
"integrity": "sha512-Oiw+ppED6IremMInLV4HXGbfbG6GyziY3kqAwJYOR0PNbkYDmLWQA3a95EhdSmamsvbkJN96ZNN+YD+fGjzSBA==",
|
"integrity": "sha512-Oiw+ppED6IremMInLV4HXGbfbG6GyziY3kqAwJYOR0PNbkYDmLWQA3a95EhdSmamsvbkJN96ZNN+YD+fGjzSBA==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"@types/scheduler": "*",
|
"@types/scheduler": "*",
|
||||||
|
@ -3354,7 +3323,8 @@
|
||||||
"node_modules/@types/scheduler": {
|
"node_modules/@types/scheduler": {
|
||||||
"version": "0.16.2",
|
"version": "0.16.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
|
||||||
"integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew=="
|
"integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==",
|
||||||
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/semver": {
|
"node_modules/@types/semver": {
|
||||||
"version": "7.3.13",
|
"version": "7.3.13",
|
||||||
|
@ -4419,14 +4389,6 @@
|
||||||
"color-support": "bin.js"
|
"color-support": "bin.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/commander": {
|
|
||||||
"version": "8.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
|
||||||
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/compute-scroll-into-view": {
|
"node_modules/compute-scroll-into-view": {
|
||||||
"version": "1.0.20",
|
"version": "1.0.20",
|
||||||
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
|
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
|
||||||
|
@ -4723,16 +4685,6 @@
|
||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dnd-core": {
|
|
||||||
"version": "16.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
|
|
||||||
"integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==",
|
|
||||||
"dependencies": {
|
|
||||||
"@react-dnd/asap": "^5.0.1",
|
|
||||||
"@react-dnd/invariant": "^4.0.1",
|
|
||||||
"redux": "^4.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/doctrine": {
|
"node_modules/doctrine": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||||
|
@ -5559,7 +5511,8 @@
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.2.12",
|
"version": "3.2.12",
|
||||||
|
@ -5796,27 +5749,6 @@
|
||||||
"react": ">=16.8.0"
|
"react": ">=16.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fs-extra": {
|
|
||||||
"version": "8.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
|
||||||
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
|
|
||||||
"dependencies": {
|
|
||||||
"graceful-fs": "^4.2.0",
|
|
||||||
"jsonfile": "^4.0.0",
|
|
||||||
"universalify": "^0.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6 <7 || >=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fs-extra/node_modules/jsonfile": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
|
||||||
"optionalDependencies": {
|
|
||||||
"graceful-fs": "^4.1.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fs-minipass": {
|
"node_modules/fs-minipass": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
|
||||||
|
@ -6060,7 +5992,8 @@
|
||||||
"node_modules/graceful-fs": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.10",
|
"version": "4.2.10",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
|
||||||
"integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="
|
"integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==",
|
||||||
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/grapheme-splitter": {
|
"node_modules/grapheme-splitter": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
|
@ -6749,17 +6682,6 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jsonfile": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==",
|
|
||||||
"dependencies": {
|
|
||||||
"universalify": "^0.1.2"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"graceful-fs": "^4.1.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jsx-ast-utils": {
|
"node_modules/jsx-ast-utils": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz",
|
||||||
|
@ -6778,21 +6700,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
|
||||||
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
|
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
|
||||||
},
|
},
|
||||||
"node_modules/katex": {
|
|
||||||
"version": "0.16.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.10.tgz",
|
|
||||||
"integrity": "sha512-ZiqaC04tp2O5utMsl2TEZTXxa6WSC4yo0fv5ML++D3QZv/vx2Mct0mTlRx3O+uUkjfuAgOkzsCmq5MiUEsDDdA==",
|
|
||||||
"funding": [
|
|
||||||
"https://opencollective.com/katex",
|
|
||||||
"https://github.com/sponsors/katex"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
|
||||||
"commander": "^8.3.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"katex": "cli.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/language-subtag-registry": {
|
"node_modules/language-subtag-registry": {
|
||||||
"version": "0.3.22",
|
"version": "0.3.22",
|
||||||
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz",
|
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz",
|
||||||
|
@ -6854,14 +6761,6 @@
|
||||||
"node": ">= 4.0.0"
|
"node": ">= 4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/linkify-html": {
|
|
||||||
"version": "4.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/linkify-html/-/linkify-html-4.0.2.tgz",
|
|
||||||
"integrity": "sha512-YcN3tsyutK2Y/uSuoG0zne8FQdoqzrAgNU5ko0DWE7M2oQ3ms4z/202f2W4TvRm9uxKdrsWAullfynANLaVMqw==",
|
|
||||||
"peerDependencies": {
|
|
||||||
"linkifyjs": "^4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/linkify-react": {
|
"node_modules/linkify-react": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/linkify-react/-/linkify-react-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/linkify-react/-/linkify-react-4.1.1.tgz",
|
||||||
|
@ -7766,43 +7665,6 @@
|
||||||
"react": ">=15"
|
"react": ">=15"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dnd": {
|
|
||||||
"version": "16.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
|
|
||||||
"integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
|
|
||||||
"dependencies": {
|
|
||||||
"@react-dnd/invariant": "^4.0.1",
|
|
||||||
"@react-dnd/shallowequal": "^4.0.1",
|
|
||||||
"dnd-core": "^16.0.1",
|
|
||||||
"fast-deep-equal": "^3.1.3",
|
|
||||||
"hoist-non-react-statics": "^3.3.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/hoist-non-react-statics": ">= 3.3.1",
|
|
||||||
"@types/node": ">= 12",
|
|
||||||
"@types/react": ">= 16",
|
|
||||||
"react": ">= 16.14"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/hoist-non-react-statics": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@types/node": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-dnd-html5-backend": {
|
|
||||||
"version": "16.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
|
|
||||||
"integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
|
|
||||||
"dependencies": {
|
|
||||||
"dnd-core": "^16.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.2.0",
|
"version": "18.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
||||||
|
@ -7950,14 +7812,6 @@
|
||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/redux": {
|
|
||||||
"version": "4.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
|
|
||||||
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.9.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/regexp.prototype.flags": {
|
"node_modules/regexp.prototype.flags": {
|
||||||
"version": "1.5.2",
|
"version": "1.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz",
|
||||||
|
@ -8711,22 +8565,6 @@
|
||||||
"typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta"
|
"typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/twemoji": {
|
|
||||||
"version": "14.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/twemoji/-/twemoji-14.0.2.tgz",
|
|
||||||
"integrity": "sha512-BzOoXIe1QVdmsUmZ54xbEH+8AgtOKUiG53zO5vVP2iUu6h5u9lN15NcuS6te4OY96qx0H7JK9vjjl9WQbkTRuA==",
|
|
||||||
"dependencies": {
|
|
||||||
"fs-extra": "^8.0.1",
|
|
||||||
"jsonfile": "^5.0.0",
|
|
||||||
"twemoji-parser": "14.0.0",
|
|
||||||
"universalify": "^0.1.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/twemoji-parser": {
|
|
||||||
"version": "14.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-14.0.0.tgz",
|
|
||||||
"integrity": "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA=="
|
|
||||||
},
|
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
@ -8875,14 +8713,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz",
|
||||||
"integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg=="
|
"integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg=="
|
||||||
},
|
},
|
||||||
"node_modules/universalify": {
|
|
||||||
"version": "0.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
|
||||||
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.0.13",
|
"version": "1.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",
|
||||||
|
|
|
@ -24,7 +24,6 @@
|
||||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||||
"@fontsource/inter": "4.5.14",
|
"@fontsource/inter": "4.5.14",
|
||||||
"@khanacademy/simple-markdown": "0.8.6",
|
|
||||||
"@matrix-org/olm": "3.2.14",
|
"@matrix-org/olm": "3.2.14",
|
||||||
"@tanstack/react-query": "5.24.1",
|
"@tanstack/react-query": "5.24.1",
|
||||||
"@tanstack/react-query-devtools": "5.24.1",
|
"@tanstack/react-query-devtools": "5.24.1",
|
||||||
|
@ -52,8 +51,6 @@
|
||||||
"immer": "9.0.16",
|
"immer": "9.0.16",
|
||||||
"is-hotkey": "0.2.0",
|
"is-hotkey": "0.2.0",
|
||||||
"jotai": "2.6.0",
|
"jotai": "2.6.0",
|
||||||
"katex": "0.16.10",
|
|
||||||
"linkify-html": "4.0.2",
|
|
||||||
"linkify-react": "4.1.1",
|
"linkify-react": "4.1.1",
|
||||||
"linkifyjs": "4.0.2",
|
"linkifyjs": "4.0.2",
|
||||||
"matrix-js-sdk": "29.1.0",
|
"matrix-js-sdk": "29.1.0",
|
||||||
|
@ -65,8 +62,6 @@
|
||||||
"react-aria": "3.29.1",
|
"react-aria": "3.29.1",
|
||||||
"react-autosize-textarea": "7.1.0",
|
"react-autosize-textarea": "7.1.0",
|
||||||
"react-blurhash": "0.2.0",
|
"react-blurhash": "0.2.0",
|
||||||
"react-dnd": "16.0.1",
|
|
||||||
"react-dnd-html5-backend": "16.0.1",
|
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-error-boundary": "4.0.10",
|
"react-error-boundary": "4.0.10",
|
||||||
"react-google-recaptcha": "2.1.0",
|
"react-google-recaptcha": "2.1.0",
|
||||||
|
@ -78,7 +73,6 @@
|
||||||
"slate-history": "0.93.0",
|
"slate-history": "0.93.0",
|
||||||
"slate-react": "0.98.4",
|
"slate-react": "0.98.4",
|
||||||
"tippy.js": "6.3.7",
|
"tippy.js": "6.3.7",
|
||||||
"twemoji": "14.0.2",
|
|
||||||
"ua-parser-js": "1.0.35"
|
"ua-parser-js": "1.0.35"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
@ -2,17 +2,13 @@ import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import './Avatar.scss';
|
import './Avatar.scss';
|
||||||
|
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import Text from '../text/Text';
|
import Text from '../text/Text';
|
||||||
import RawIcon from '../system-icons/RawIcon';
|
import RawIcon from '../system-icons/RawIcon';
|
||||||
|
|
||||||
import ImageBrokenSVG from '../../../../public/res/svg/image-broken.svg';
|
import ImageBrokenSVG from '../../../../public/res/svg/image-broken.svg';
|
||||||
import { avatarInitials } from '../../../util/common';
|
import { avatarInitials } from '../../../util/common';
|
||||||
|
|
||||||
const Avatar = React.forwardRef(({
|
const Avatar = React.forwardRef(({ text, bgColor, iconSrc, iconColor, imageSrc, size }, ref) => {
|
||||||
text, bgColor, iconSrc, iconColor, imageSrc, size,
|
|
||||||
}, ref) => {
|
|
||||||
let textSize = 's1';
|
let textSize = 's1';
|
||||||
if (size === 'large') textSize = 'h1';
|
if (size === 'large') textSize = 'h1';
|
||||||
if (size === 'small') textSize = 'b1';
|
if (size === 'small') textSize = 'b1';
|
||||||
|
@ -20,34 +16,34 @@ const Avatar = React.forwardRef(({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={`avatar-container avatar-container__${size} noselect`}>
|
<div ref={ref} className={`avatar-container avatar-container__${size} noselect`}>
|
||||||
{
|
{imageSrc !== null ? (
|
||||||
imageSrc !== null
|
<img
|
||||||
? (
|
draggable="false"
|
||||||
<img
|
src={imageSrc}
|
||||||
draggable="false"
|
onLoad={(e) => {
|
||||||
src={imageSrc}
|
e.target.style.backgroundColor = 'transparent';
|
||||||
onLoad={(e) => { e.target.style.backgroundColor = 'transparent'; }}
|
}}
|
||||||
onError={(e) => { e.target.src = ImageBrokenSVG; }}
|
onError={(e) => {
|
||||||
alt=""
|
e.target.src = ImageBrokenSVG;
|
||||||
/>
|
}}
|
||||||
)
|
alt=""
|
||||||
: (
|
/>
|
||||||
<span
|
) : (
|
||||||
style={{ backgroundColor: iconSrc === null ? bgColor : 'transparent' }}
|
<span
|
||||||
className={`avatar__border${iconSrc !== null ? '--active' : ''}`}
|
style={{ backgroundColor: iconSrc === null ? bgColor : 'transparent' }}
|
||||||
>
|
className={`avatar__border${iconSrc !== null ? '--active' : ''}`}
|
||||||
{
|
>
|
||||||
iconSrc !== null
|
{iconSrc !== null ? (
|
||||||
? <RawIcon size={size} src={iconSrc} color={iconColor} />
|
<RawIcon size={size} src={iconSrc} color={iconColor} />
|
||||||
: text !== null && (
|
) : (
|
||||||
<Text variant={textSize} primary>
|
text !== null && (
|
||||||
{twemojify(avatarInitials(text))}
|
<Text variant={textSize} primary>
|
||||||
</Text>
|
{avatarInitials(text)}
|
||||||
)
|
</Text>
|
||||||
}
|
)
|
||||||
</span>
|
)}
|
||||||
)
|
</span>
|
||||||
}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,33 +0,0 @@
|
||||||
import React, { useEffect, useRef } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './Math.scss';
|
|
||||||
|
|
||||||
import katex from 'katex';
|
|
||||||
import 'katex/dist/katex.min.css';
|
|
||||||
|
|
||||||
import 'katex/dist/contrib/copy-tex';
|
|
||||||
|
|
||||||
const Math = React.memo(({
|
|
||||||
content, throwOnError, errorColor, displayMode,
|
|
||||||
}) => {
|
|
||||||
const ref = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
katex.render(content, ref.current, { throwOnError, errorColor, displayMode });
|
|
||||||
}, [content, throwOnError, errorColor, displayMode]);
|
|
||||||
|
|
||||||
return <span ref={ref} />;
|
|
||||||
});
|
|
||||||
Math.defaultProps = {
|
|
||||||
throwOnError: null,
|
|
||||||
errorColor: null,
|
|
||||||
displayMode: null,
|
|
||||||
};
|
|
||||||
Math.propTypes = {
|
|
||||||
content: PropTypes.string.isRequired,
|
|
||||||
throwOnError: PropTypes.bool,
|
|
||||||
errorColor: PropTypes.string,
|
|
||||||
displayMode: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Math;
|
|
|
@ -1,3 +0,0 @@
|
||||||
.katex-display {
|
|
||||||
margin: 0 !important;
|
|
||||||
}
|
|
|
@ -8,7 +8,7 @@ import React, {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { EventType, IContent, MsgType, Room } from 'matrix-js-sdk';
|
import { EventType, IContent, MsgType, Room } from 'matrix-js-sdk';
|
||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
|
@ -56,7 +56,6 @@ import {
|
||||||
} from '../../components/editor';
|
} from '../../components/editor';
|
||||||
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import { TUploadContent, encryptFile, getImageInfo, getMxIdLocalPart } from '../../utils/matrix';
|
import { TUploadContent, encryptFile, getImageInfo, getMxIdLocalPart } from '../../utils/matrix';
|
||||||
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
|
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
|
||||||
import { useFilePicker } from '../../hooks/useFilePicker';
|
import { useFilePicker } from '../../hooks/useFilePicker';
|
||||||
|
@ -95,6 +94,7 @@ import {
|
||||||
} from './msgContent';
|
} from './msgContent';
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
import {
|
import {
|
||||||
|
getAllParents,
|
||||||
getMemberDisplayName,
|
getMemberDisplayName,
|
||||||
parseReplyBody,
|
parseReplyBody,
|
||||||
parseReplyFormattedBody,
|
parseReplyFormattedBody,
|
||||||
|
@ -107,6 +107,7 @@ import { Command, SHRUG, useCommands } from '../../hooks/useCommands';
|
||||||
import { mobileOrTablet } from '../../utils/user-agent';
|
import { mobileOrTablet } from '../../utils/user-agent';
|
||||||
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
|
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
|
||||||
import { ReplyLayout } from '../../components/message';
|
import { ReplyLayout } from '../../components/message';
|
||||||
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
|
|
||||||
interface RoomInputProps {
|
interface RoomInputProps {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
|
@ -121,6 +122,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||||
const commands = useCommands(mx, room);
|
const commands = useCommands(mx, room);
|
||||||
const emojiBtnRef = useRef<HTMLButtonElement>(null);
|
const emojiBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
|
|
||||||
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
|
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
|
||||||
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
|
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
|
||||||
|
@ -133,13 +135,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
|
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
|
||||||
|
|
||||||
const imagePackRooms: Room[] = useMemo(() => {
|
const imagePackRooms: Room[] = useMemo(() => {
|
||||||
const allParentSpaces = [roomId, ...(initMatrix.roomList?.getAllParentSpaces(roomId) ?? [])];
|
const allParentSpaces = [roomId].concat(Array.from(getAllParents(roomToParents, roomId)));
|
||||||
return allParentSpaces.reduce<Room[]>((list, rId) => {
|
return allParentSpaces.reduce<Room[]>((list, rId) => {
|
||||||
const r = mx.getRoom(rId);
|
const r = mx.getRoom(rId);
|
||||||
if (r) list.push(r);
|
if (r) list.push(r);
|
||||||
return list;
|
return list;
|
||||||
}, []);
|
}, []);
|
||||||
}, [mx, roomId]);
|
}, [mx, roomId, roomToParents]);
|
||||||
|
|
||||||
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||||
const [autocompleteQuery, setAutocompleteQuery] =
|
const [autocompleteQuery, setAutocompleteQuery] =
|
||||||
|
|
|
@ -28,7 +28,7 @@ import classNames from 'classnames';
|
||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
import { Editor } from 'slate';
|
import { Editor } from 'slate';
|
||||||
import to from 'await-to-js';
|
import to from 'await-to-js';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
|
@ -74,6 +74,7 @@ import { getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser
|
||||||
import {
|
import {
|
||||||
canEditEvent,
|
canEditEvent,
|
||||||
decryptAllTimelineEvent,
|
decryptAllTimelineEvent,
|
||||||
|
getAllParents,
|
||||||
getEditedEvent,
|
getEditedEvent,
|
||||||
getEventReactions,
|
getEventReactions,
|
||||||
getLatestEditableEvt,
|
getLatestEditableEvt,
|
||||||
|
@ -103,14 +104,15 @@ import { createMentionElement, isEmptyEditor, moveCursor } from '../../component
|
||||||
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||||
import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
||||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||||
import { Image } from '../../components/media';
|
import { Image } from '../../components/media';
|
||||||
import { ImageViewer } from '../../components/image-viewer';
|
import { ImageViewer } from '../../components/image-viewer';
|
||||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
|
import { useRoomUnread } from '../../state/hooks/unread';
|
||||||
|
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||||
|
|
||||||
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
|
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
|
||||||
({ position, className, ...props }, ref) => (
|
({ position, className, ...props }, ref) => (
|
||||||
|
@ -444,18 +446,19 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel);
|
const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel);
|
||||||
const [editId, setEditId] = useState<string>();
|
const [editId, setEditId] = useState<string>();
|
||||||
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||||
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
|
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||||
|
|
||||||
const imagePackRooms: Room[] = useMemo(() => {
|
const imagePackRooms: Room[] = useMemo(() => {
|
||||||
const allParentSpaces = [
|
const allParentSpaces = [room.roomId].concat(
|
||||||
room.roomId,
|
Array.from(getAllParents(roomToParents, room.roomId))
|
||||||
...(initMatrix.roomList?.getAllParentSpaces(room.roomId) ?? []),
|
);
|
||||||
];
|
|
||||||
return allParentSpaces.reduce<Room[]>((list, rId) => {
|
return allParentSpaces.reduce<Room[]>((list, rId) => {
|
||||||
const r = mx.getRoom(rId);
|
const r = mx.getRoom(rId);
|
||||||
if (r) list.push(r);
|
if (r) list.push(r);
|
||||||
return list;
|
return list;
|
||||||
}, []);
|
}, []);
|
||||||
}, [mx, room]);
|
}, [mx, room, roomToParents]);
|
||||||
|
|
||||||
const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
|
const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
|
||||||
const readUptoEventIdRef = useRef<string>();
|
const readUptoEventIdRef = useRef<string>();
|
||||||
|
@ -794,15 +797,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
|
|
||||||
// Remove unreadInfo on mark as read
|
// Remove unreadInfo on mark as read
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleFullRead = (rId: string) => {
|
if (!unread) {
|
||||||
if (rId !== room.roomId) return;
|
|
||||||
setUnreadInfo(undefined);
|
setUnreadInfo(undefined);
|
||||||
};
|
}
|
||||||
initMatrix.notifications?.on(cons.events.notifications.FULL_READ, handleFullRead);
|
}, [unread]);
|
||||||
return () => {
|
|
||||||
initMatrix.notifications?.removeListener(cons.events.notifications.FULL_READ, handleFullRead);
|
|
||||||
};
|
|
||||||
}, [room]);
|
|
||||||
|
|
||||||
// scroll out of view msg editor in view.
|
// scroll out of view msg editor in view.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
@ -1,25 +0,0 @@
|
||||||
/* eslint-disable import/prefer-default-export */
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
import initMatrix from '../../client/initMatrix';
|
|
||||||
import cons from '../../client/state/cons';
|
|
||||||
|
|
||||||
export function useCategorizedSpaces() {
|
|
||||||
const { accountData } = initMatrix;
|
|
||||||
const [categorizedSpaces, setCategorizedSpaces] = useState([...accountData.categorizedSpaces]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleCategorizedSpaces = () => {
|
|
||||||
setCategorizedSpaces([...accountData.categorizedSpaces]);
|
|
||||||
};
|
|
||||||
accountData.on(cons.events.accountData.CATEGORIZE_SPACE_UPDATED, handleCategorizedSpaces);
|
|
||||||
return () => {
|
|
||||||
accountData.removeListener(
|
|
||||||
cons.events.accountData.CATEGORIZE_SPACE_UPDATED,
|
|
||||||
handleCategorizedSpaces,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return [categorizedSpaces];
|
|
||||||
}
|
|
|
@ -114,12 +114,12 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
||||||
description: 'Leave current room.',
|
description: 'Leave current room.',
|
||||||
exe: async (payload) => {
|
exe: async (payload) => {
|
||||||
if (payload.trim() === '') {
|
if (payload.trim() === '') {
|
||||||
roomActions.leave(room.roomId);
|
mx.leave(room.roomId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rawIds = payload.split(' ');
|
const rawIds = payload.split(' ');
|
||||||
const roomIds = rawIds.filter((id) => isRoomId(id));
|
const roomIds = rawIds.filter((id) => isRoomId(id));
|
||||||
roomIds.map((id) => roomActions.leave(id));
|
roomIds.map((id) => mx.leave(id));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[Command.Invite]: {
|
[Command.Invite]: {
|
||||||
|
|
11
src/app/hooks/usePreviousValue.ts
Normal file
11
src/app/hooks/usePreviousValue.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export const usePreviousValue = <T>(currentValue: T, initialValue: T) => {
|
||||||
|
const valueRef = useRef(initialValue);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
valueRef.current = currentValue;
|
||||||
|
}, [currentValue]);
|
||||||
|
|
||||||
|
return valueRef.current;
|
||||||
|
};
|
|
@ -1,10 +1,26 @@
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { useMemo } from 'react';
|
import { selectAtom } from 'jotai/utils';
|
||||||
import { roomIdToTypingMembersAtom, selectRoomTypingMembersAtom } from '../state/typingMembers';
|
import { useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
IRoomIdToTypingMembers,
|
||||||
|
TypingReceipt,
|
||||||
|
roomIdToTypingMembersAtom,
|
||||||
|
} from '../state/typingMembers';
|
||||||
|
|
||||||
|
const typingReceiptEqual = (a: TypingReceipt, b: TypingReceipt): boolean =>
|
||||||
|
a.userId === b.userId && a.ts === b.ts;
|
||||||
|
|
||||||
|
const equalTypingMembers = (x: TypingReceipt[], y: TypingReceipt[]): boolean => {
|
||||||
|
if (x.length !== y.length) return false;
|
||||||
|
return x.every((a, i) => typingReceiptEqual(a, y[i]));
|
||||||
|
};
|
||||||
|
|
||||||
export const useRoomTypingMember = (roomId: string) => {
|
export const useRoomTypingMember = (roomId: string) => {
|
||||||
const typing = useAtomValue(
|
const selector = useCallback(
|
||||||
useMemo(() => selectRoomTypingMembersAtom(roomId, roomIdToTypingMembersAtom), [roomId])
|
(roomToTyping: IRoomIdToTypingMembers) => roomToTyping.get(roomId) ?? [],
|
||||||
|
[roomId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const typing = useAtomValue(selectAtom(roomIdToTypingMembersAtom, selector, equalTypingMembers));
|
||||||
return typing;
|
return typing;
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,21 +0,0 @@
|
||||||
/* eslint-disable import/prefer-default-export */
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
import cons from '../../client/state/cons';
|
|
||||||
import navigation from '../../client/state/navigation';
|
|
||||||
|
|
||||||
export function useSelectedSpace() {
|
|
||||||
const [spaceId, setSpaceId] = useState(navigation.selectedSpaceId);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onSpaceSelected = (roomId) => {
|
|
||||||
setSpaceId(roomId);
|
|
||||||
};
|
|
||||||
navigation.on(cons.events.navigation.SPACE_SELECTED, onSpaceSelected);
|
|
||||||
return () => {
|
|
||||||
navigation.removeListener(cons.events.navigation.SPACE_SELECTED, onSpaceSelected);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return [spaceId];
|
|
||||||
}
|
|
|
@ -1,21 +0,0 @@
|
||||||
/* eslint-disable import/prefer-default-export */
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
import cons from '../../client/state/cons';
|
|
||||||
import navigation from '../../client/state/navigation';
|
|
||||||
|
|
||||||
export function useSelectedTab() {
|
|
||||||
const [selectedTab, setSelectedTab] = useState(navigation.selectedTab);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onTabSelected = (tabId) => {
|
|
||||||
setSelectedTab(tabId);
|
|
||||||
};
|
|
||||||
navigation.on(cons.events.navigation.TAB_SELECTED, onTabSelected);
|
|
||||||
return () => {
|
|
||||||
navigation.removeListener(cons.events.navigation.TAB_SELECTED, onTabSelected);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return [selectedTab];
|
|
||||||
}
|
|
|
@ -1,25 +0,0 @@
|
||||||
/* eslint-disable import/prefer-default-export */
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
import initMatrix from '../../client/initMatrix';
|
|
||||||
import cons from '../../client/state/cons';
|
|
||||||
|
|
||||||
export function useSpaceShortcut() {
|
|
||||||
const { accountData } = initMatrix;
|
|
||||||
const [spaceShortcut, setSpaceShortcut] = useState([...accountData.spaceShortcut]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onSpaceShortcutUpdated = () => {
|
|
||||||
setSpaceShortcut([...accountData.spaceShortcut]);
|
|
||||||
};
|
|
||||||
accountData.on(cons.events.accountData.SPACE_SHORTCUT_UPDATED, onSpaceShortcutUpdated);
|
|
||||||
return () => {
|
|
||||||
accountData.removeListener(
|
|
||||||
cons.events.accountData.SPACE_SHORTCUT_UPDATED,
|
|
||||||
onSpaceShortcutUpdated,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return [spaceShortcut];
|
|
||||||
}
|
|
|
@ -2,16 +2,21 @@ import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import './Dialog.scss';
|
import './Dialog.scss';
|
||||||
|
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
import Header, { TitleWrapper } from '../../atoms/header/Header';
|
import Header, { TitleWrapper } from '../../atoms/header/Header';
|
||||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||||
import RawModal from '../../atoms/modal/RawModal';
|
import RawModal from '../../atoms/modal/RawModal';
|
||||||
|
|
||||||
function Dialog({
|
function Dialog({
|
||||||
className, isOpen, title, onAfterOpen, onAfterClose,
|
className,
|
||||||
contentOptions, onRequestClose, closeFromOutside, children,
|
isOpen,
|
||||||
|
title,
|
||||||
|
onAfterOpen,
|
||||||
|
onAfterClose,
|
||||||
|
contentOptions,
|
||||||
|
onRequestClose,
|
||||||
|
closeFromOutside,
|
||||||
|
children,
|
||||||
invisibleScroll,
|
invisibleScroll,
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
@ -28,19 +33,19 @@ function Dialog({
|
||||||
<div className="dialog__content">
|
<div className="dialog__content">
|
||||||
<Header>
|
<Header>
|
||||||
<TitleWrapper>
|
<TitleWrapper>
|
||||||
{
|
{typeof title === 'string' ? (
|
||||||
typeof title === 'string'
|
<Text variant="h2" weight="medium" primary>
|
||||||
? <Text variant="h2" weight="medium" primary>{twemojify(title)}</Text>
|
{title}
|
||||||
: title
|
</Text>
|
||||||
}
|
) : (
|
||||||
|
title
|
||||||
|
)}
|
||||||
</TitleWrapper>
|
</TitleWrapper>
|
||||||
{contentOptions}
|
{contentOptions}
|
||||||
</Header>
|
</Header>
|
||||||
<div className="dialog__content__wrapper">
|
<div className="dialog__content__wrapper">
|
||||||
<ScrollView autoHide={!invisibleScroll} invisible={invisibleScroll}>
|
<ScrollView autoHide={!invisibleScroll} invisible={invisibleScroll}>
|
||||||
<div className="dialog__content-container">
|
<div className="dialog__content-container">{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,61 +0,0 @@
|
||||||
/* eslint-disable react/prop-types */
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './FollowingMembers.scss';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import { openReadReceipts } from '../../../client/action/navigation';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
|
||||||
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
|
|
||||||
|
|
||||||
import { getUsersActionJsx } from '../../organisms/room/common';
|
|
||||||
|
|
||||||
function FollowingMembers({ roomTimeline }) {
|
|
||||||
const [followingMembers, setFollowingMembers] = useState([]);
|
|
||||||
const { roomId } = roomTimeline;
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const myUserId = mx.getUserId();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const updateFollowingMembers = () => {
|
|
||||||
setFollowingMembers(roomTimeline.getLiveReaders());
|
|
||||||
};
|
|
||||||
const updateOnEvent = (event, room) => {
|
|
||||||
if (room.roomId !== roomId) return;
|
|
||||||
setFollowingMembers(roomTimeline.getLiveReaders());
|
|
||||||
};
|
|
||||||
updateFollowingMembers();
|
|
||||||
roomTimeline.on(cons.events.roomTimeline.LIVE_RECEIPT, updateFollowingMembers);
|
|
||||||
mx.on('Room.timeline', updateOnEvent);
|
|
||||||
return () => {
|
|
||||||
roomTimeline.removeListener(cons.events.roomTimeline.LIVE_RECEIPT, updateFollowingMembers);
|
|
||||||
mx.removeListener('Room.timeline', updateOnEvent);
|
|
||||||
};
|
|
||||||
}, [roomTimeline, roomId]);
|
|
||||||
|
|
||||||
const filteredM = followingMembers.filter((userId) => userId !== myUserId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
filteredM.length !== 0 && (
|
|
||||||
<button
|
|
||||||
className="following-members"
|
|
||||||
onClick={() => openReadReceipts(roomId, followingMembers)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<RawIcon size="extra-small" src={TickMarkIC} />
|
|
||||||
<Text variant="b2">
|
|
||||||
{getUsersActionJsx(roomId, filteredM, 'following the conversation.')}
|
|
||||||
</Text>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
FollowingMembers.propTypes = {
|
|
||||||
roomTimeline: PropTypes.shape({}).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FollowingMembers;
|
|
|
@ -1,31 +0,0 @@
|
||||||
@use '../../partials/text';
|
|
||||||
|
|
||||||
.following-members {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0 var(--sp-normal);
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
align-items: center;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
& .ic-raw {
|
|
||||||
min-width: var(--ic-extra-small);
|
|
||||||
opacity: 0.4;
|
|
||||||
margin: 0 var(--sp-extra-tight);
|
|
||||||
}
|
|
||||||
& .text {
|
|
||||||
@extend .cp-txt__ellipsis;
|
|
||||||
color: var(--tc-surface-low);
|
|
||||||
b {
|
|
||||||
color: var(--tc-surface-normal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus {
|
|
||||||
background-color: var(--bg-surface-hover);
|
|
||||||
}
|
|
||||||
&:active {
|
|
||||||
background-color: var(--bg-surface-active);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,47 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './ImageLightbox.scss';
|
|
||||||
import FileSaver from 'file-saver';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import RawModal from '../../atoms/modal/RawModal';
|
|
||||||
import IconButton from '../../atoms/button/IconButton';
|
|
||||||
|
|
||||||
import DownloadSVG from '../../../../public/res/ic/outlined/download.svg';
|
|
||||||
import ExternalSVG from '../../../../public/res/ic/outlined/external.svg';
|
|
||||||
|
|
||||||
function ImageLightbox({
|
|
||||||
url, alt, isOpen, onRequestClose,
|
|
||||||
}) {
|
|
||||||
const handleDownload = () => {
|
|
||||||
FileSaver.saveAs(url, alt);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RawModal
|
|
||||||
className="image-lightbox__modal"
|
|
||||||
overlayClassName="image-lightbox__overlay"
|
|
||||||
isOpen={isOpen}
|
|
||||||
onRequestClose={onRequestClose}
|
|
||||||
size="large"
|
|
||||||
>
|
|
||||||
<div className="image-lightbox__header">
|
|
||||||
<Text variant="b2" weight="medium">{alt}</Text>
|
|
||||||
<IconButton onClick={() => window.open(url)} size="small" src={ExternalSVG} />
|
|
||||||
<IconButton onClick={handleDownload} size="small" src={DownloadSVG} />
|
|
||||||
</div>
|
|
||||||
<div className="image-lightbox__content">
|
|
||||||
<img src={url} alt={alt} />
|
|
||||||
</div>
|
|
||||||
</RawModal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ImageLightbox.propTypes = {
|
|
||||||
url: PropTypes.string.isRequired,
|
|
||||||
alt: PropTypes.string.isRequired,
|
|
||||||
isOpen: PropTypes.bool.isRequired,
|
|
||||||
onRequestClose: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ImageLightbox;
|
|
|
@ -1,50 +0,0 @@
|
||||||
@use '../../partials/flex';
|
|
||||||
@use '../../partials/text';
|
|
||||||
|
|
||||||
.image-lightbox__modal {
|
|
||||||
box-shadow: none;
|
|
||||||
width: unset;
|
|
||||||
gap: var(--sp-normal);
|
|
||||||
|
|
||||||
border-radius: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
& .text {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
& .ic-raw {
|
|
||||||
background-color: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-lightbox__overlay {
|
|
||||||
background-color: var(--bg-overlay-low);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.image-lightbox__header > *,
|
|
||||||
.image-lightbox__content > * {
|
|
||||||
pointer-events: all;
|
|
||||||
}
|
|
||||||
.image-lightbox__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
& > .text {
|
|
||||||
@extend .cp-fx__item-one;
|
|
||||||
@extend .cp-txt__ellipsis;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.image-lightbox__content {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
max-height: 80vh;
|
|
||||||
|
|
||||||
& img {
|
|
||||||
background-color: var(--bg-surface-low);
|
|
||||||
object-fit: contain;
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
border-radius: var(--bo-radius);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,366 +0,0 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './Media.scss';
|
|
||||||
|
|
||||||
import encrypt from 'browser-encrypt-attachment';
|
|
||||||
|
|
||||||
import { BlurhashCanvas } from 'react-blurhash';
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import IconButton from '../../atoms/button/IconButton';
|
|
||||||
import Spinner from '../../atoms/spinner/Spinner';
|
|
||||||
import ImageLightbox from '../image-lightbox/ImageLightbox';
|
|
||||||
|
|
||||||
import DownloadSVG from '../../../../public/res/ic/outlined/download.svg';
|
|
||||||
import ExternalSVG from '../../../../public/res/ic/outlined/external.svg';
|
|
||||||
import PlaySVG from '../../../../public/res/ic/outlined/play.svg';
|
|
||||||
|
|
||||||
import { getBlobSafeMimeType } from '../../../util/mimetypes';
|
|
||||||
|
|
||||||
async function getDecryptedBlob(response, type, decryptData) {
|
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
|
||||||
const dataArray = await encrypt.decryptAttachment(arrayBuffer, decryptData);
|
|
||||||
const blob = new Blob([dataArray], { type: getBlobSafeMimeType(type) });
|
|
||||||
return blob;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getUrl(link, type, decryptData) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(link, { method: 'GET' });
|
|
||||||
if (decryptData !== null) {
|
|
||||||
return URL.createObjectURL(await getDecryptedBlob(response, type, decryptData));
|
|
||||||
}
|
|
||||||
const blob = await response.blob();
|
|
||||||
return URL.createObjectURL(blob);
|
|
||||||
} catch (e) {
|
|
||||||
return link;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNativeHeight(width, height, maxWidth = 296) {
|
|
||||||
const scale = maxWidth / width;
|
|
||||||
return scale * height;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FileHeader({
|
|
||||||
name, link, external,
|
|
||||||
file, type,
|
|
||||||
}) {
|
|
||||||
const [url, setUrl] = useState(null);
|
|
||||||
|
|
||||||
async function getFile() {
|
|
||||||
const myUrl = await getUrl(link, type, file);
|
|
||||||
setUrl(myUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDownload(e) {
|
|
||||||
if (file !== null && url === null) {
|
|
||||||
e.preventDefault();
|
|
||||||
await getFile();
|
|
||||||
e.target.click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="file-header">
|
|
||||||
<Text className="file-name" variant="b3">{name}</Text>
|
|
||||||
{ link !== null && (
|
|
||||||
<>
|
|
||||||
{
|
|
||||||
external && (
|
|
||||||
<IconButton
|
|
||||||
size="extra-small"
|
|
||||||
tooltip="Open in new tab"
|
|
||||||
src={ExternalSVG}
|
|
||||||
onClick={() => window.open(url || link)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
<a href={url || link} download={name} target="_blank" rel="noreferrer">
|
|
||||||
<IconButton
|
|
||||||
size="extra-small"
|
|
||||||
tooltip="Download"
|
|
||||||
src={DownloadSVG}
|
|
||||||
onClick={handleDownload}
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
FileHeader.defaultProps = {
|
|
||||||
external: false,
|
|
||||||
file: null,
|
|
||||||
link: null,
|
|
||||||
};
|
|
||||||
FileHeader.propTypes = {
|
|
||||||
name: PropTypes.string.isRequired,
|
|
||||||
link: PropTypes.string,
|
|
||||||
external: PropTypes.bool,
|
|
||||||
file: PropTypes.shape({}),
|
|
||||||
type: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
function File({
|
|
||||||
name, link, file, type,
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="file-container">
|
|
||||||
<FileHeader name={name} link={link} file={file} type={type} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File.defaultProps = {
|
|
||||||
file: null,
|
|
||||||
type: '',
|
|
||||||
};
|
|
||||||
File.propTypes = {
|
|
||||||
name: PropTypes.string.isRequired,
|
|
||||||
link: PropTypes.string.isRequired,
|
|
||||||
type: PropTypes.string,
|
|
||||||
file: PropTypes.shape({}),
|
|
||||||
};
|
|
||||||
|
|
||||||
function Image({
|
|
||||||
name, width, height, link, file, type, blurhash,
|
|
||||||
}) {
|
|
||||||
const [url, setUrl] = useState(null);
|
|
||||||
const [blur, setBlur] = useState(true);
|
|
||||||
const [lightbox, setLightbox] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let unmounted = false;
|
|
||||||
async function fetchUrl() {
|
|
||||||
const myUrl = await getUrl(link, type, file);
|
|
||||||
if (unmounted) return;
|
|
||||||
setUrl(myUrl);
|
|
||||||
}
|
|
||||||
fetchUrl();
|
|
||||||
return () => {
|
|
||||||
unmounted = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleLightbox = () => {
|
|
||||||
if (!url) return;
|
|
||||||
setLightbox(!lightbox);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="file-container">
|
|
||||||
<div
|
|
||||||
style={{ height: width !== null ? getNativeHeight(width, height) : 'unset' }}
|
|
||||||
className="image-container"
|
|
||||||
role="button"
|
|
||||||
tabIndex="0"
|
|
||||||
onClick={toggleLightbox}
|
|
||||||
onKeyDown={toggleLightbox}
|
|
||||||
>
|
|
||||||
{ blurhash && blur && <BlurhashCanvas hash={blurhash} punch={1} />}
|
|
||||||
{ url !== null && (
|
|
||||||
<img
|
|
||||||
style={{ display: blur ? 'none' : 'unset' }}
|
|
||||||
onLoad={() => setBlur(false)}
|
|
||||||
src={url || link}
|
|
||||||
alt={name}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{url && (
|
|
||||||
<ImageLightbox
|
|
||||||
url={url}
|
|
||||||
alt={name}
|
|
||||||
isOpen={lightbox}
|
|
||||||
onRequestClose={toggleLightbox}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Image.defaultProps = {
|
|
||||||
file: null,
|
|
||||||
width: null,
|
|
||||||
height: null,
|
|
||||||
type: '',
|
|
||||||
blurhash: '',
|
|
||||||
};
|
|
||||||
Image.propTypes = {
|
|
||||||
name: PropTypes.string.isRequired,
|
|
||||||
width: PropTypes.number,
|
|
||||||
height: PropTypes.number,
|
|
||||||
link: PropTypes.string.isRequired,
|
|
||||||
file: PropTypes.shape({}),
|
|
||||||
type: PropTypes.string,
|
|
||||||
blurhash: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
function Sticker({
|
|
||||||
name, height, width, link, file, type,
|
|
||||||
}) {
|
|
||||||
const [url, setUrl] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let unmounted = false;
|
|
||||||
async function fetchUrl() {
|
|
||||||
const myUrl = await getUrl(link, type, file);
|
|
||||||
if (unmounted) return;
|
|
||||||
setUrl(myUrl);
|
|
||||||
}
|
|
||||||
fetchUrl();
|
|
||||||
return () => {
|
|
||||||
unmounted = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="sticker-container" style={{ height: width !== null ? getNativeHeight(width, height, 128) : 'unset' }}>
|
|
||||||
{ url !== null && <img src={url || link} title={name} alt={name} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Sticker.defaultProps = {
|
|
||||||
file: null,
|
|
||||||
type: '',
|
|
||||||
width: null,
|
|
||||||
height: null,
|
|
||||||
};
|
|
||||||
Sticker.propTypes = {
|
|
||||||
name: PropTypes.string.isRequired,
|
|
||||||
width: PropTypes.number,
|
|
||||||
height: PropTypes.number,
|
|
||||||
link: PropTypes.string.isRequired,
|
|
||||||
file: PropTypes.shape({}),
|
|
||||||
type: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
function Audio({
|
|
||||||
name, link, type, file,
|
|
||||||
}) {
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [url, setUrl] = useState(null);
|
|
||||||
|
|
||||||
async function loadAudio() {
|
|
||||||
const myUrl = await getUrl(link, type, file);
|
|
||||||
setUrl(myUrl);
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
function handlePlayAudio() {
|
|
||||||
setIsLoading(true);
|
|
||||||
loadAudio();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="file-container">
|
|
||||||
<FileHeader name={name} link={file !== null ? url : url || link} type={type} external />
|
|
||||||
<div className="audio-container">
|
|
||||||
{ url === null && isLoading && <Spinner size="small" /> }
|
|
||||||
{ url === null && !isLoading && <IconButton onClick={handlePlayAudio} tooltip="Play audio" src={PlaySVG} />}
|
|
||||||
{ url !== null && (
|
|
||||||
/* eslint-disable-next-line jsx-a11y/media-has-caption */
|
|
||||||
<audio autoPlay controls>
|
|
||||||
<source src={url} type={getBlobSafeMimeType(type)} />
|
|
||||||
</audio>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Audio.defaultProps = {
|
|
||||||
file: null,
|
|
||||||
type: '',
|
|
||||||
};
|
|
||||||
Audio.propTypes = {
|
|
||||||
name: PropTypes.string.isRequired,
|
|
||||||
link: PropTypes.string.isRequired,
|
|
||||||
type: PropTypes.string,
|
|
||||||
file: PropTypes.shape({}),
|
|
||||||
};
|
|
||||||
|
|
||||||
function Video({
|
|
||||||
name, link, thumbnail, thumbnailFile, thumbnailType,
|
|
||||||
width, height, file, type, blurhash,
|
|
||||||
}) {
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [url, setUrl] = useState(null);
|
|
||||||
const [thumbUrl, setThumbUrl] = useState(null);
|
|
||||||
const [blur, setBlur] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let unmounted = false;
|
|
||||||
async function fetchUrl() {
|
|
||||||
const myThumbUrl = await getUrl(thumbnail, thumbnailType, thumbnailFile);
|
|
||||||
if (unmounted) return;
|
|
||||||
setThumbUrl(myThumbUrl);
|
|
||||||
}
|
|
||||||
if (thumbnail !== null) fetchUrl();
|
|
||||||
return () => {
|
|
||||||
unmounted = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadVideo = async () => {
|
|
||||||
const myUrl = await getUrl(link, type, file);
|
|
||||||
setUrl(myUrl);
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePlayVideo = () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
loadVideo();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="file-container">
|
|
||||||
<FileHeader name={name} link={file !== null ? url : url || link} type={type} external />
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: width !== null ? getNativeHeight(width, height) : 'unset',
|
|
||||||
}}
|
|
||||||
className="video-container"
|
|
||||||
>
|
|
||||||
{ url === null ? (
|
|
||||||
<>
|
|
||||||
{ blurhash && blur && <BlurhashCanvas hash={blurhash} punch={1} />}
|
|
||||||
{ thumbUrl !== null && (
|
|
||||||
<img style={{ display: blur ? 'none' : 'unset' }} src={thumbUrl} onLoad={() => setBlur(false)} alt={name} />
|
|
||||||
)}
|
|
||||||
{isLoading && <Spinner size="small" />}
|
|
||||||
{!isLoading && <IconButton onClick={handlePlayVideo} tooltip="Play video" src={PlaySVG} />}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
/* eslint-disable-next-line jsx-a11y/media-has-caption */
|
|
||||||
<video autoPlay controls poster={thumbUrl}>
|
|
||||||
<source src={url} type={getBlobSafeMimeType(type)} />
|
|
||||||
</video>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Video.defaultProps = {
|
|
||||||
width: null,
|
|
||||||
height: null,
|
|
||||||
file: null,
|
|
||||||
thumbnail: null,
|
|
||||||
thumbnailType: null,
|
|
||||||
thumbnailFile: null,
|
|
||||||
type: '',
|
|
||||||
blurhash: null,
|
|
||||||
};
|
|
||||||
Video.propTypes = {
|
|
||||||
name: PropTypes.string.isRequired,
|
|
||||||
link: PropTypes.string.isRequired,
|
|
||||||
thumbnail: PropTypes.string,
|
|
||||||
thumbnailFile: PropTypes.shape({}),
|
|
||||||
thumbnailType: PropTypes.string,
|
|
||||||
width: PropTypes.number,
|
|
||||||
height: PropTypes.number,
|
|
||||||
file: PropTypes.shape({}),
|
|
||||||
type: PropTypes.string,
|
|
||||||
blurhash: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
export {
|
|
||||||
File, Image, Sticker, Audio, Video,
|
|
||||||
};
|
|
|
@ -1,90 +0,0 @@
|
||||||
@use '../../partials/text';
|
|
||||||
|
|
||||||
.file-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--sp-ultra-tight) var(--sp-tight);
|
|
||||||
min-height: 42px;
|
|
||||||
|
|
||||||
& .file-name {
|
|
||||||
@extend .cp-txt__ellipsis;
|
|
||||||
flex: 1;
|
|
||||||
color: var(--tc-surface-low);
|
|
||||||
}
|
|
||||||
|
|
||||||
& a {
|
|
||||||
line-height: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-container {
|
|
||||||
--media-max-width: 296px;
|
|
||||||
|
|
||||||
background-color: var(--bg-surface-hover);
|
|
||||||
border-radius: calc(var(--bo-radius) / 2);
|
|
||||||
overflow: hidden;
|
|
||||||
max-width: var(--media-max-width);
|
|
||||||
white-space: initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sticker-container {
|
|
||||||
display: inline-flex;
|
|
||||||
max-width: 128px;
|
|
||||||
width: 100%;
|
|
||||||
& img {
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-container,
|
|
||||||
.video-container,
|
|
||||||
.audio-container {
|
|
||||||
font-size: 0;
|
|
||||||
line-height: 0;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-size: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-container,
|
|
||||||
.video-container {
|
|
||||||
& img,
|
|
||||||
& canvas {
|
|
||||||
max-width: unset !important;
|
|
||||||
width: 100% !important;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.image-container {
|
|
||||||
max-height: 460px;
|
|
||||||
img {
|
|
||||||
cursor: pointer;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-container {
|
|
||||||
position: relative;
|
|
||||||
& .ic-btn-surface {
|
|
||||||
background-color: var(--bg-surface-low);
|
|
||||||
}
|
|
||||||
& .ic-btn-surface,
|
|
||||||
& .donut-spinner {
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
video {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.audio-container {
|
|
||||||
audio {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,853 +0,0 @@
|
||||||
/* eslint-disable react/prop-types */
|
|
||||||
import React, {
|
|
||||||
useState, useEffect, useCallback, useRef,
|
|
||||||
} from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './Message.scss';
|
|
||||||
|
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import {
|
|
||||||
getUsername, getUsernameOfRoomMember, parseReply, trimHTMLReply,
|
|
||||||
} from '../../../util/matrixUtil';
|
|
||||||
import colorMXID from '../../../util/colorMXID';
|
|
||||||
import { getEventCords } from '../../../util/common';
|
|
||||||
import { redactEvent, sendReaction } from '../../../client/action/roomTimeline';
|
|
||||||
import {
|
|
||||||
openEmojiBoard, openProfileViewer, openReadReceipts, openViewSource, replyTo,
|
|
||||||
} from '../../../client/action/navigation';
|
|
||||||
import { sanitizeCustomHtml } from '../../../util/sanitize';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
|
||||||
import Button from '../../atoms/button/Button';
|
|
||||||
import Tooltip from '../../atoms/tooltip/Tooltip';
|
|
||||||
import Input from '../../atoms/input/Input';
|
|
||||||
import Avatar from '../../atoms/avatar/Avatar';
|
|
||||||
import IconButton from '../../atoms/button/IconButton';
|
|
||||||
import Time from '../../atoms/time/Time';
|
|
||||||
import ContextMenu, { MenuHeader, MenuItem, MenuBorder } from '../../atoms/context-menu/ContextMenu';
|
|
||||||
import * as Media from '../media/Media';
|
|
||||||
|
|
||||||
import ReplyArrowIC from '../../../../public/res/ic/outlined/reply-arrow.svg';
|
|
||||||
import EmojiAddIC from '../../../../public/res/ic/outlined/emoji-add.svg';
|
|
||||||
import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
|
|
||||||
import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
|
|
||||||
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
|
|
||||||
import CmdIC from '../../../../public/res/ic/outlined/cmd.svg';
|
|
||||||
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
|
|
||||||
|
|
||||||
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
|
|
||||||
import { getBlobSafeMimeType } from '../../../util/mimetypes';
|
|
||||||
import { html, plain } from '../../../util/markdown';
|
|
||||||
|
|
||||||
function PlaceholderMessage() {
|
|
||||||
return (
|
|
||||||
<div className="ph-msg">
|
|
||||||
<div className="ph-msg__avatar-container">
|
|
||||||
<div className="ph-msg__avatar" />
|
|
||||||
</div>
|
|
||||||
<div className="ph-msg__main-container">
|
|
||||||
<div className="ph-msg__header" />
|
|
||||||
<div className="ph-msg__body">
|
|
||||||
<div />
|
|
||||||
<div />
|
|
||||||
<div />
|
|
||||||
<div />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const MessageAvatar = React.memo(({
|
|
||||||
roomId, avatarSrc, userId, username,
|
|
||||||
}) => (
|
|
||||||
<div className="message__avatar-container">
|
|
||||||
<button type="button" onClick={() => openProfileViewer(userId, roomId)}>
|
|
||||||
<Avatar imageSrc={avatarSrc} text={username} bgColor={colorMXID(userId)} size="small" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
));
|
|
||||||
|
|
||||||
const MessageHeader = React.memo(({
|
|
||||||
userId, username, timestamp, fullTime,
|
|
||||||
}) => (
|
|
||||||
<div className="message__header">
|
|
||||||
<Text
|
|
||||||
style={{ color: colorMXID(userId) }}
|
|
||||||
className="message__profile"
|
|
||||||
variant="b1"
|
|
||||||
weight="medium"
|
|
||||||
span
|
|
||||||
>
|
|
||||||
<span>{twemojify(username)}</span>
|
|
||||||
<span>{twemojify(userId)}</span>
|
|
||||||
</Text>
|
|
||||||
<div className="message__time">
|
|
||||||
<Text variant="b3">
|
|
||||||
<Time timestamp={timestamp} fullTime={fullTime} />
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
));
|
|
||||||
MessageHeader.defaultProps = {
|
|
||||||
fullTime: false,
|
|
||||||
};
|
|
||||||
MessageHeader.propTypes = {
|
|
||||||
userId: PropTypes.string.isRequired,
|
|
||||||
username: PropTypes.string.isRequired,
|
|
||||||
timestamp: PropTypes.number.isRequired,
|
|
||||||
fullTime: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
function MessageReply({ name, color, body }) {
|
|
||||||
return (
|
|
||||||
<div className="message__reply">
|
|
||||||
<Text variant="b2">
|
|
||||||
<RawIcon color={color} size="extra-small" src={ReplyArrowIC} />
|
|
||||||
<span style={{ color }}>{twemojify(name)}</span>
|
|
||||||
{' '}
|
|
||||||
{twemojify(body)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
MessageReply.propTypes = {
|
|
||||||
name: PropTypes.string.isRequired,
|
|
||||||
color: PropTypes.string.isRequired,
|
|
||||||
body: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MessageReplyWrapper = React.memo(({ roomTimeline, eventId }) => {
|
|
||||||
const [reply, setReply] = useState(null);
|
|
||||||
const isMountedRef = useRef(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const timelineSet = roomTimeline.getUnfilteredTimelineSet();
|
|
||||||
const loadReply = async () => {
|
|
||||||
try {
|
|
||||||
const eTimeline = await mx.getEventTimeline(timelineSet, eventId);
|
|
||||||
await roomTimeline.decryptAllEventsOfTimeline(eTimeline);
|
|
||||||
|
|
||||||
let mEvent = eTimeline.getTimelineSet().findEventById(eventId);
|
|
||||||
const editedList = roomTimeline.editedTimeline.get(mEvent.getId());
|
|
||||||
if (editedList) {
|
|
||||||
mEvent = editedList[editedList.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawBody = mEvent.getContent().body;
|
|
||||||
const username = getUsernameOfRoomMember(mEvent.sender);
|
|
||||||
|
|
||||||
if (isMountedRef.current === false) return;
|
|
||||||
const fallbackBody = mEvent.isRedacted() ? '*** This message has been deleted ***' : '*** Unable to load reply ***';
|
|
||||||
let parsedBody = parseReply(rawBody)?.body ?? rawBody ?? fallbackBody;
|
|
||||||
if (editedList && parsedBody.startsWith(' * ')) {
|
|
||||||
parsedBody = parsedBody.slice(3);
|
|
||||||
}
|
|
||||||
|
|
||||||
setReply({
|
|
||||||
to: username,
|
|
||||||
color: colorMXID(mEvent.getSender()),
|
|
||||||
body: parsedBody,
|
|
||||||
event: mEvent,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
setReply({
|
|
||||||
to: '** Unknown user **',
|
|
||||||
color: 'var(--tc-danger-normal)',
|
|
||||||
body: '*** Unable to load reply ***',
|
|
||||||
event: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadReply();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMountedRef.current = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const focusReply = (ev) => {
|
|
||||||
if (!ev.key || ev.key === ' ' || ev.key === 'Enter') {
|
|
||||||
if (ev.key) ev.preventDefault();
|
|
||||||
if (reply?.event === null) return;
|
|
||||||
if (reply?.event.isRedacted()) return;
|
|
||||||
roomTimeline.loadEventTimeline(eventId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="message__reply-wrapper"
|
|
||||||
onClick={focusReply}
|
|
||||||
onKeyDown={focusReply}
|
|
||||||
role="button"
|
|
||||||
tabIndex="0"
|
|
||||||
>
|
|
||||||
{reply !== null && <MessageReply name={reply.to} color={reply.color} body={reply.body} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
MessageReplyWrapper.propTypes = {
|
|
||||||
roomTimeline: PropTypes.shape({}).isRequired,
|
|
||||||
eventId: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MessageBody = React.memo(({
|
|
||||||
senderName,
|
|
||||||
body,
|
|
||||||
isCustomHTML,
|
|
||||||
isEdited,
|
|
||||||
msgType,
|
|
||||||
}) => {
|
|
||||||
// if body is not string it is a React element.
|
|
||||||
if (typeof body !== 'string') return <div className="message__body">{body}</div>;
|
|
||||||
|
|
||||||
let content = null;
|
|
||||||
if (isCustomHTML) {
|
|
||||||
try {
|
|
||||||
content = twemojify(
|
|
||||||
sanitizeCustomHtml(initMatrix.matrixClient, body),
|
|
||||||
undefined,
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
console.error('Malformed custom html: ', body);
|
|
||||||
content = twemojify(body, undefined);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
content = twemojify(body, undefined, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if this message should render with large emojis
|
|
||||||
// Criteria:
|
|
||||||
// - Contains only emoji
|
|
||||||
// - Contains no more than 10 emoji
|
|
||||||
let emojiOnly = false;
|
|
||||||
if (content.type === 'img') {
|
|
||||||
// If this messages contains only a single (inline) image
|
|
||||||
emojiOnly = true;
|
|
||||||
} else if (content.constructor.name === 'Array') {
|
|
||||||
// Otherwise, it might be an array of images / texb
|
|
||||||
|
|
||||||
// Count the number of emojis
|
|
||||||
const nEmojis = content.filter((e) => e.type === 'img').length;
|
|
||||||
|
|
||||||
// Make sure there's no text besides whitespace and variation selector U+FE0F
|
|
||||||
if (nEmojis <= 10 && content.every((element) => (
|
|
||||||
(typeof element === 'object' && element.type === 'img')
|
|
||||||
|| (typeof element === 'string' && /^[\s\ufe0f]*$/g.test(element))
|
|
||||||
))) {
|
|
||||||
emojiOnly = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isCustomHTML) {
|
|
||||||
// If this is a plaintext message, wrap it in a <p> element (automatically applying
|
|
||||||
// white-space: pre-wrap) in order to preserve newlines
|
|
||||||
content = (<p className="message__body-plain">{content}</p>);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="message__body">
|
|
||||||
<div dir="auto" className={`text ${emojiOnly ? 'text-h1' : 'text-b1'}`}>
|
|
||||||
{ msgType === 'm.emote' && (
|
|
||||||
<>
|
|
||||||
{'* '}
|
|
||||||
{twemojify(senderName)}
|
|
||||||
{' '}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{ content }
|
|
||||||
</div>
|
|
||||||
{ isEdited && <Text className="message__body-edited" variant="b3">(edited)</Text>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
MessageBody.defaultProps = {
|
|
||||||
isCustomHTML: false,
|
|
||||||
isEdited: false,
|
|
||||||
msgType: null,
|
|
||||||
};
|
|
||||||
MessageBody.propTypes = {
|
|
||||||
senderName: PropTypes.string.isRequired,
|
|
||||||
body: PropTypes.node.isRequired,
|
|
||||||
isCustomHTML: PropTypes.bool,
|
|
||||||
isEdited: PropTypes.bool,
|
|
||||||
msgType: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
function MessageEdit({ body, onSave, onCancel }) {
|
|
||||||
const editInputRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// makes the cursor end up at the end of the line instead of the beginning
|
|
||||||
editInputRef.current.value = '';
|
|
||||||
editInputRef.current.value = body;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
onCancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.key === 'Enter' && e.shiftKey === false) {
|
|
||||||
e.preventDefault();
|
|
||||||
onSave(editInputRef.current.value, body);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form className="message__edit" onSubmit={(e) => { e.preventDefault(); onSave(editInputRef.current.value, body); }}>
|
|
||||||
<Input
|
|
||||||
forwardRef={editInputRef}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
value={body}
|
|
||||||
placeholder="Edit message"
|
|
||||||
required
|
|
||||||
resizable
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div className="message__edit-btns">
|
|
||||||
<Button type="submit" variant="primary">Save</Button>
|
|
||||||
<Button onClick={onCancel}>Cancel</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
MessageEdit.propTypes = {
|
|
||||||
body: PropTypes.string.isRequired,
|
|
||||||
onSave: PropTypes.func.isRequired,
|
|
||||||
onCancel: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
function getMyEmojiEvent(emojiKey, eventId, roomTimeline) {
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const rEvents = roomTimeline.reactionTimeline.get(eventId);
|
|
||||||
let rEvent = null;
|
|
||||||
rEvents?.find((rE) => {
|
|
||||||
if (rE.getRelation() === null) return false;
|
|
||||||
if (rE.getRelation().key === emojiKey && rE.getSender() === mx.getUserId()) {
|
|
||||||
rEvent = rE;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
return rEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleEmoji(roomId, eventId, emojiKey, shortcode, roomTimeline) {
|
|
||||||
const myAlreadyReactEvent = getMyEmojiEvent(emojiKey, eventId, roomTimeline);
|
|
||||||
if (myAlreadyReactEvent) {
|
|
||||||
const rId = myAlreadyReactEvent.getId();
|
|
||||||
if (rId.startsWith('~')) return;
|
|
||||||
redactEvent(roomId, rId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sendReaction(roomId, eventId, emojiKey, shortcode);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickEmoji(e, roomId, eventId, roomTimeline) {
|
|
||||||
openEmojiBoard(getEventCords(e), (emoji) => {
|
|
||||||
toggleEmoji(roomId, eventId, emoji.mxc ?? emoji.unicode, emoji.shortcodes[0], roomTimeline);
|
|
||||||
e.target.click();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function genReactionMsg(userIds, reaction, shortcode) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{userIds.map((userId, index) => (
|
|
||||||
<React.Fragment key={userId}>
|
|
||||||
{twemojify(getUsername(userId))}
|
|
||||||
{index < userIds.length - 1 && (
|
|
||||||
<span style={{ opacity: '.6' }}>
|
|
||||||
{index === userIds.length - 2 ? ' and ' : ', '}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
<span style={{ opacity: '.6' }}>{' reacted with '}</span>
|
|
||||||
{twemojify(shortcode ? `:${shortcode}:` : reaction, { className: 'react-emoji' })}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MessageReaction({
|
|
||||||
reaction, shortcode, count, users, isActive, onClick,
|
|
||||||
}) {
|
|
||||||
let customEmojiUrl = null;
|
|
||||||
if (reaction.match(/^mxc:\/\/\S+$/)) {
|
|
||||||
customEmojiUrl = initMatrix.matrixClient.mxcUrlToHttp(reaction);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
className="msg__reaction-tooltip"
|
|
||||||
content={<Text variant="b2">{users.length > 0 ? genReactionMsg(users, reaction, shortcode) : 'Unable to load who has reacted'}</Text>}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
type="button"
|
|
||||||
className={`msg__reaction${isActive ? ' msg__reaction--active' : ''}`}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
customEmojiUrl
|
|
||||||
? <img className="react-emoji" draggable="false" alt={shortcode ?? reaction} src={customEmojiUrl} />
|
|
||||||
: twemojify(reaction, { className: 'react-emoji' })
|
|
||||||
}
|
|
||||||
<Text variant="b3" className="msg__reaction-count">{count}</Text>
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
MessageReaction.defaultProps = {
|
|
||||||
shortcode: undefined,
|
|
||||||
};
|
|
||||||
MessageReaction.propTypes = {
|
|
||||||
reaction: PropTypes.node.isRequired,
|
|
||||||
shortcode: PropTypes.string,
|
|
||||||
count: PropTypes.number.isRequired,
|
|
||||||
users: PropTypes.arrayOf(PropTypes.string).isRequired,
|
|
||||||
isActive: PropTypes.bool.isRequired,
|
|
||||||
onClick: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
function MessageReactionGroup({ roomTimeline, mEvent }) {
|
|
||||||
const { roomId, room, reactionTimeline } = roomTimeline;
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const reactions = {};
|
|
||||||
const canSendReaction = room.currentState.maySendEvent('m.reaction', mx.getUserId());
|
|
||||||
|
|
||||||
const eventReactions = reactionTimeline.get(mEvent.getId());
|
|
||||||
const addReaction = (key, shortcode, count, senderId, isActive) => {
|
|
||||||
let reaction = reactions[key];
|
|
||||||
if (reaction === undefined) {
|
|
||||||
reaction = {
|
|
||||||
count: 0,
|
|
||||||
users: [],
|
|
||||||
isActive: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (shortcode) reaction.shortcode = shortcode;
|
|
||||||
if (count) {
|
|
||||||
reaction.count = count;
|
|
||||||
} else {
|
|
||||||
reaction.users.push(senderId);
|
|
||||||
reaction.count = reaction.users.length;
|
|
||||||
if (isActive) reaction.isActive = isActive;
|
|
||||||
}
|
|
||||||
|
|
||||||
reactions[key] = reaction;
|
|
||||||
};
|
|
||||||
if (eventReactions) {
|
|
||||||
eventReactions.forEach((rEvent) => {
|
|
||||||
if (rEvent.getRelation() === null) return;
|
|
||||||
const reaction = rEvent.getRelation();
|
|
||||||
const senderId = rEvent.getSender();
|
|
||||||
const { shortcode } = rEvent.getContent();
|
|
||||||
const isActive = senderId === mx.getUserId();
|
|
||||||
|
|
||||||
addReaction(reaction.key, shortcode, undefined, senderId, isActive);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Use aggregated reactions
|
|
||||||
const aggregatedReaction = mEvent.getServerAggregatedRelation('m.annotation')?.chunk;
|
|
||||||
if (!aggregatedReaction) return null;
|
|
||||||
aggregatedReaction.forEach((reaction) => {
|
|
||||||
if (reaction.type !== 'm.reaction') return;
|
|
||||||
addReaction(reaction.key, undefined, reaction.count, undefined, false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="message__reactions text text-b3 noselect">
|
|
||||||
{
|
|
||||||
Object.keys(reactions).map((key) => (
|
|
||||||
<MessageReaction
|
|
||||||
key={key}
|
|
||||||
reaction={key}
|
|
||||||
shortcode={reactions[key].shortcode}
|
|
||||||
count={reactions[key].count}
|
|
||||||
users={reactions[key].users}
|
|
||||||
isActive={reactions[key].isActive}
|
|
||||||
onClick={() => {
|
|
||||||
toggleEmoji(roomId, mEvent.getId(), key, reactions[key].shortcode, roomTimeline);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
{canSendReaction && (
|
|
||||||
<IconButton
|
|
||||||
onClick={(e) => {
|
|
||||||
pickEmoji(e, roomId, mEvent.getId(), roomTimeline);
|
|
||||||
}}
|
|
||||||
src={EmojiAddIC}
|
|
||||||
size="extra-small"
|
|
||||||
tooltip="Add reaction"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
MessageReactionGroup.propTypes = {
|
|
||||||
roomTimeline: PropTypes.shape({}).isRequired,
|
|
||||||
mEvent: PropTypes.shape({}).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
function isMedia(mE) {
|
|
||||||
return (
|
|
||||||
mE.getContent()?.msgtype === 'm.file'
|
|
||||||
|| mE.getContent()?.msgtype === 'm.image'
|
|
||||||
|| mE.getContent()?.msgtype === 'm.audio'
|
|
||||||
|| mE.getContent()?.msgtype === 'm.video'
|
|
||||||
|| mE.getType() === 'm.sticker'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// if editedTimeline has mEventId then pass editedMEvent else pass mEvent to openViewSource
|
|
||||||
function handleOpenViewSource(mEvent, roomTimeline) {
|
|
||||||
const eventId = mEvent.getId();
|
|
||||||
const { editedTimeline } = roomTimeline ?? {};
|
|
||||||
let editedMEvent;
|
|
||||||
if (editedTimeline?.has(eventId)) {
|
|
||||||
const editedList = editedTimeline.get(eventId);
|
|
||||||
editedMEvent = editedList[editedList.length - 1];
|
|
||||||
}
|
|
||||||
openViewSource(editedMEvent !== undefined ? editedMEvent : mEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
const MessageOptions = React.memo(({
|
|
||||||
roomTimeline, mEvent, edit, reply,
|
|
||||||
}) => {
|
|
||||||
const { roomId, room } = roomTimeline;
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const senderId = mEvent.getSender();
|
|
||||||
|
|
||||||
const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel;
|
|
||||||
const canIRedact = room.currentState.hasSufficientPowerLevelFor('redact', myPowerlevel);
|
|
||||||
const canSendReaction = room.currentState.maySendEvent('m.reaction', mx.getUserId());
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="message__options">
|
|
||||||
{canSendReaction && (
|
|
||||||
<IconButton
|
|
||||||
onClick={(e) => pickEmoji(e, roomId, mEvent.getId(), roomTimeline)}
|
|
||||||
src={EmojiAddIC}
|
|
||||||
size="extra-small"
|
|
||||||
tooltip="Add reaction"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<IconButton
|
|
||||||
onClick={() => reply()}
|
|
||||||
src={ReplyArrowIC}
|
|
||||||
size="extra-small"
|
|
||||||
tooltip="Reply"
|
|
||||||
/>
|
|
||||||
{(senderId === mx.getUserId() && !isMedia(mEvent)) && (
|
|
||||||
<IconButton
|
|
||||||
onClick={() => edit(true)}
|
|
||||||
src={PencilIC}
|
|
||||||
size="extra-small"
|
|
||||||
tooltip="Edit"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ContextMenu
|
|
||||||
content={() => (
|
|
||||||
<>
|
|
||||||
<MenuHeader>Options</MenuHeader>
|
|
||||||
<MenuItem
|
|
||||||
iconSrc={TickMarkIC}
|
|
||||||
onClick={() => openReadReceipts(roomId, roomTimeline.getEventReaders(mEvent))}
|
|
||||||
>
|
|
||||||
Read receipts
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
iconSrc={CmdIC}
|
|
||||||
onClick={() => handleOpenViewSource(mEvent, roomTimeline)}
|
|
||||||
>
|
|
||||||
View source
|
|
||||||
</MenuItem>
|
|
||||||
{(canIRedact || senderId === mx.getUserId()) && (
|
|
||||||
<>
|
|
||||||
<MenuBorder />
|
|
||||||
<MenuItem
|
|
||||||
variant="danger"
|
|
||||||
iconSrc={BinIC}
|
|
||||||
onClick={async () => {
|
|
||||||
const isConfirmed = await confirmDialog(
|
|
||||||
'Delete message',
|
|
||||||
'Are you sure that you want to delete this message?',
|
|
||||||
'Delete',
|
|
||||||
'danger',
|
|
||||||
);
|
|
||||||
if (!isConfirmed) return;
|
|
||||||
redactEvent(roomId, mEvent.getId());
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</MenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
render={(toggleMenu) => (
|
|
||||||
<IconButton
|
|
||||||
onClick={toggleMenu}
|
|
||||||
src={VerticalMenuIC}
|
|
||||||
size="extra-small"
|
|
||||||
tooltip="Options"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
MessageOptions.propTypes = {
|
|
||||||
roomTimeline: PropTypes.shape({}).isRequired,
|
|
||||||
mEvent: PropTypes.shape({}).isRequired,
|
|
||||||
edit: PropTypes.func.isRequired,
|
|
||||||
reply: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
function genMediaContent(mE) {
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const mContent = mE.getContent();
|
|
||||||
if (!mContent || !mContent.body) return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
|
|
||||||
|
|
||||||
let mediaMXC = mContent?.url;
|
|
||||||
const isEncryptedFile = typeof mediaMXC === 'undefined';
|
|
||||||
if (isEncryptedFile) mediaMXC = mContent?.file?.url;
|
|
||||||
|
|
||||||
let thumbnailMXC = mContent?.info?.thumbnail_url;
|
|
||||||
|
|
||||||
if (typeof mediaMXC === 'undefined' || mediaMXC === '') return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
|
|
||||||
|
|
||||||
let msgType = mE.getContent()?.msgtype;
|
|
||||||
const safeMimetype = getBlobSafeMimeType(mContent.info?.mimetype);
|
|
||||||
if (mE.getType() === 'm.sticker') {
|
|
||||||
msgType = 'm.sticker';
|
|
||||||
} else if (safeMimetype === 'application/octet-stream') {
|
|
||||||
msgType = 'm.file';
|
|
||||||
}
|
|
||||||
|
|
||||||
const blurhash = mContent?.info?.['xyz.amorgan.blurhash'];
|
|
||||||
|
|
||||||
switch (msgType) {
|
|
||||||
case 'm.file':
|
|
||||||
return (
|
|
||||||
<Media.File
|
|
||||||
name={mContent.body}
|
|
||||||
link={mx.mxcUrlToHttp(mediaMXC)}
|
|
||||||
type={mContent.info?.mimetype}
|
|
||||||
file={mContent.file || null}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'm.image':
|
|
||||||
return (
|
|
||||||
<Media.Image
|
|
||||||
name={mContent.body}
|
|
||||||
width={typeof mContent.info?.w === 'number' ? mContent.info?.w : null}
|
|
||||||
height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null}
|
|
||||||
link={mx.mxcUrlToHttp(mediaMXC)}
|
|
||||||
file={isEncryptedFile ? mContent.file : null}
|
|
||||||
type={mContent.info?.mimetype}
|
|
||||||
blurhash={blurhash}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'm.sticker':
|
|
||||||
return (
|
|
||||||
<Media.Sticker
|
|
||||||
name={mContent.body}
|
|
||||||
width={typeof mContent.info?.w === 'number' ? mContent.info?.w : null}
|
|
||||||
height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null}
|
|
||||||
link={mx.mxcUrlToHttp(mediaMXC)}
|
|
||||||
file={isEncryptedFile ? mContent.file : null}
|
|
||||||
type={mContent.info?.mimetype}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'm.audio':
|
|
||||||
return (
|
|
||||||
<Media.Audio
|
|
||||||
name={mContent.body}
|
|
||||||
link={mx.mxcUrlToHttp(mediaMXC)}
|
|
||||||
type={mContent.info?.mimetype}
|
|
||||||
file={mContent.file || null}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'm.video':
|
|
||||||
if (typeof thumbnailMXC === 'undefined') {
|
|
||||||
thumbnailMXC = mContent.info?.thumbnail_file?.url || null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Media.Video
|
|
||||||
name={mContent.body}
|
|
||||||
link={mx.mxcUrlToHttp(mediaMXC)}
|
|
||||||
thumbnail={thumbnailMXC === null ? null : mx.mxcUrlToHttp(thumbnailMXC)}
|
|
||||||
thumbnailFile={isEncryptedFile ? mContent.info?.thumbnail_file : null}
|
|
||||||
thumbnailType={mContent.info?.thumbnail_info?.mimetype || null}
|
|
||||||
width={typeof mContent.info?.w === 'number' ? mContent.info?.w : null}
|
|
||||||
height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null}
|
|
||||||
file={isEncryptedFile ? mContent.file : null}
|
|
||||||
type={mContent.info?.mimetype}
|
|
||||||
blurhash={blurhash}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEditedBody(editedMEvent) {
|
|
||||||
const newContent = editedMEvent.getContent()['m.new_content'];
|
|
||||||
if (typeof newContent === 'undefined') return [null, false, null];
|
|
||||||
|
|
||||||
const isCustomHTML = newContent.format === 'org.matrix.custom.html';
|
|
||||||
const parsedContent = parseReply(newContent.body);
|
|
||||||
if (parsedContent === null) {
|
|
||||||
return [newContent.body, isCustomHTML, newContent.formatted_body ?? null];
|
|
||||||
}
|
|
||||||
return [parsedContent.body, isCustomHTML, newContent.formatted_body ?? null];
|
|
||||||
}
|
|
||||||
|
|
||||||
function Message({
|
|
||||||
mEvent, isBodyOnly, roomTimeline,
|
|
||||||
focus, fullTime, isEdit, setEdit, cancelEdit,
|
|
||||||
}) {
|
|
||||||
const roomId = mEvent.getRoomId();
|
|
||||||
const { editedTimeline, reactionTimeline } = roomTimeline ?? {};
|
|
||||||
|
|
||||||
const className = ['message', (isBodyOnly ? 'message--body-only' : 'message--full')];
|
|
||||||
if (focus) className.push('message--focus');
|
|
||||||
const content = mEvent.getContent();
|
|
||||||
const eventId = mEvent.getId();
|
|
||||||
const msgType = content?.msgtype;
|
|
||||||
const senderId = mEvent.getSender();
|
|
||||||
let { body } = content;
|
|
||||||
const username = mEvent.sender ? getUsernameOfRoomMember(mEvent.sender) : getUsername(senderId);
|
|
||||||
const avatarSrc = mEvent.sender?.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop') ?? null;
|
|
||||||
let isCustomHTML = content.format === 'org.matrix.custom.html';
|
|
||||||
let customHTML = isCustomHTML ? content.formatted_body : null;
|
|
||||||
|
|
||||||
const edit = useCallback(() => {
|
|
||||||
setEdit(eventId);
|
|
||||||
}, []);
|
|
||||||
const reply = useCallback(() => {
|
|
||||||
replyTo(senderId, mEvent.getId(), body, customHTML);
|
|
||||||
}, [body, customHTML]);
|
|
||||||
|
|
||||||
if (msgType === 'm.emote') className.push('message--type-emote');
|
|
||||||
|
|
||||||
const isEdited = roomTimeline ? editedTimeline.has(eventId) : false;
|
|
||||||
const haveReactions = roomTimeline
|
|
||||||
? reactionTimeline.has(eventId) || !!mEvent.getServerAggregatedRelation('m.annotation')
|
|
||||||
: false;
|
|
||||||
const isReply = !!mEvent.replyEventId;
|
|
||||||
|
|
||||||
if (isEdited) {
|
|
||||||
const editedList = editedTimeline.get(eventId);
|
|
||||||
const editedMEvent = editedList[editedList.length - 1];
|
|
||||||
[body, isCustomHTML, customHTML] = getEditedBody(editedMEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isReply) {
|
|
||||||
body = parseReply(body)?.body ?? body;
|
|
||||||
customHTML = trimHTMLReply(customHTML);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof body !== 'string') body = '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className.join(' ')}>
|
|
||||||
{
|
|
||||||
isBodyOnly
|
|
||||||
? <div className="message__avatar-container" />
|
|
||||||
: (
|
|
||||||
<MessageAvatar
|
|
||||||
roomId={roomId}
|
|
||||||
avatarSrc={avatarSrc}
|
|
||||||
userId={senderId}
|
|
||||||
username={username}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
<div className="message__main-container">
|
|
||||||
{!isBodyOnly && (
|
|
||||||
<MessageHeader
|
|
||||||
userId={senderId}
|
|
||||||
username={username}
|
|
||||||
timestamp={mEvent.getTs()}
|
|
||||||
fullTime={fullTime}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{roomTimeline && isReply && (
|
|
||||||
<MessageReplyWrapper
|
|
||||||
roomTimeline={roomTimeline}
|
|
||||||
eventId={mEvent.replyEventId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!isEdit && (
|
|
||||||
<MessageBody
|
|
||||||
senderName={username}
|
|
||||||
isCustomHTML={isCustomHTML}
|
|
||||||
body={isMedia(mEvent) ? genMediaContent(mEvent) : customHTML ?? body}
|
|
||||||
msgType={msgType}
|
|
||||||
isEdited={isEdited}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isEdit && (
|
|
||||||
<MessageEdit
|
|
||||||
body={(customHTML
|
|
||||||
? html(customHTML, { kind: 'edit', onlyPlain: true }).plain
|
|
||||||
: plain(body, { kind: 'edit', onlyPlain: true }).plain)}
|
|
||||||
onSave={(newBody, oldBody) => {
|
|
||||||
if (newBody !== oldBody) {
|
|
||||||
initMatrix.roomsInput.sendEditedMessage(roomId, mEvent, newBody);
|
|
||||||
}
|
|
||||||
cancelEdit();
|
|
||||||
}}
|
|
||||||
onCancel={cancelEdit}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{haveReactions && (
|
|
||||||
<MessageReactionGroup roomTimeline={roomTimeline} mEvent={mEvent} />
|
|
||||||
)}
|
|
||||||
{roomTimeline && !isEdit && (
|
|
||||||
<MessageOptions
|
|
||||||
roomTimeline={roomTimeline}
|
|
||||||
mEvent={mEvent}
|
|
||||||
edit={edit}
|
|
||||||
reply={reply}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Message.defaultProps = {
|
|
||||||
isBodyOnly: false,
|
|
||||||
focus: false,
|
|
||||||
roomTimeline: null,
|
|
||||||
fullTime: false,
|
|
||||||
isEdit: false,
|
|
||||||
setEdit: null,
|
|
||||||
cancelEdit: null,
|
|
||||||
};
|
|
||||||
Message.propTypes = {
|
|
||||||
mEvent: PropTypes.shape({}).isRequired,
|
|
||||||
isBodyOnly: PropTypes.bool,
|
|
||||||
roomTimeline: PropTypes.shape({}),
|
|
||||||
focus: PropTypes.bool,
|
|
||||||
fullTime: PropTypes.bool,
|
|
||||||
isEdit: PropTypes.bool,
|
|
||||||
setEdit: PropTypes.func,
|
|
||||||
cancelEdit: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
export { Message, MessageReply, PlaceholderMessage };
|
|
|
@ -1,479 +0,0 @@
|
||||||
@use '../../atoms/scroll/scrollbar';
|
|
||||||
@use '../../partials/text';
|
|
||||||
@use '../../partials/dir';
|
|
||||||
@use '../../partials/screen';
|
|
||||||
|
|
||||||
.message,
|
|
||||||
.ph-msg {
|
|
||||||
padding: var(--sp-ultra-tight);
|
|
||||||
@include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--bg-surface-hover);
|
|
||||||
& .message__options {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__avatar-container {
|
|
||||||
padding-top: 6px;
|
|
||||||
@include dir.side(margin, 0, var(--sp-tight));
|
|
||||||
|
|
||||||
& .avatar-container {
|
|
||||||
transition: transform 200ms var(--fluid-push);
|
|
||||||
&:hover {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& button {
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__main-container {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
|
||||||
&--full + &--full,
|
|
||||||
&--body-only + &--full,
|
|
||||||
& + .timeline-change,
|
|
||||||
.timeline-change + & {
|
|
||||||
margin-top: var(--sp-normal);
|
|
||||||
}
|
|
||||||
&__avatar-container {
|
|
||||||
width: var(--av-small);
|
|
||||||
}
|
|
||||||
&--focus {
|
|
||||||
--ltr: inset 2px 0 0 var(--bg-caution);
|
|
||||||
--rtl: inset -2px 0 0 var(--bg-caution);
|
|
||||||
@include dir.prop(box-shadow, var(--ltr), var(--rtl));
|
|
||||||
background-color: var(--bg-caution-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ph-msg {
|
|
||||||
&__avatar {
|
|
||||||
width: var(--av-small);
|
|
||||||
height: var(--av-small);
|
|
||||||
background-color: var(--bg-surface-hover);
|
|
||||||
border-radius: var(--bo-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__header,
|
|
||||||
&__body > div {
|
|
||||||
margin: var(--sp-ultra-tight);
|
|
||||||
@include dir.side(margin, 0, var(--sp-extra-tight));
|
|
||||||
height: var(--fs-b1);
|
|
||||||
width: 100%;
|
|
||||||
max-width: 100px;
|
|
||||||
background-color: var(--bg-surface-hover);
|
|
||||||
border-radius: calc(var(--bo-radius) / 2);
|
|
||||||
}
|
|
||||||
&__body {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
&__body > div:nth-child(1n) {
|
|
||||||
max-width: 10%;
|
|
||||||
}
|
|
||||||
&__body > div:nth-child(2n) {
|
|
||||||
max-width: 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.message__reply,
|
|
||||||
.message__body,
|
|
||||||
.message__body__wrapper,
|
|
||||||
.message__edit,
|
|
||||||
.message__reactions {
|
|
||||||
max-width: calc(100% - 88px);
|
|
||||||
min-width: 0;
|
|
||||||
@include screen.smallerThan(tabletBreakpoint) {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.message__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
|
|
||||||
& .message__profile {
|
|
||||||
min-width: 0;
|
|
||||||
color: var(--tc-surface-high);
|
|
||||||
@include dir.side(margin, 0, var(--sp-tight));
|
|
||||||
|
|
||||||
& > span {
|
|
||||||
@extend .cp-txt__ellipsis;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
& > span:last-child {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
&:hover {
|
|
||||||
& > span:first-child {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
& > span:last-child {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& .message__time {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
& > .text {
|
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--tc-surface-low);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.message__reply {
|
|
||||||
&-wrapper {
|
|
||||||
min-height: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
&:empty {
|
|
||||||
border-radius: calc(var(--bo-radius) / 2);
|
|
||||||
background-color: var(--bg-surface-hover);
|
|
||||||
max-width: 200px;
|
|
||||||
cursor: auto;
|
|
||||||
}
|
|
||||||
&:hover .text {
|
|
||||||
color: var(--tc-surface-high);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.text {
|
|
||||||
@extend .cp-txt__ellipsis;
|
|
||||||
color: var(--tc-surface-low);
|
|
||||||
}
|
|
||||||
.ic-raw {
|
|
||||||
width: 16px;
|
|
||||||
height: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.message__body {
|
|
||||||
word-break: break-word;
|
|
||||||
|
|
||||||
& > .text > .message__body-plain {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
& a {
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
& > .text > a {
|
|
||||||
white-space: initial !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > .text > p + p {
|
|
||||||
margin-top: var(--sp-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
& span[data-mx-pill] {
|
|
||||||
background-color: hsla(0, 0%, 64%, 0.15);
|
|
||||||
padding: 0 2px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: var(--fw-medium);
|
|
||||||
&:hover {
|
|
||||||
background-color: hsla(0, 0%, 64%, 0.3);
|
|
||||||
color: var(--tc-surface-high);
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-mx-ping] {
|
|
||||||
background-color: var(--bg-ping);
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--bg-ping-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& span[data-mx-spoiler] {
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: rgba(124, 124, 124, 0.5);
|
|
||||||
color: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
-webkit-touch-callout: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-khtml-user-select: none;
|
|
||||||
-moz-user-select: none;
|
|
||||||
-ms-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
& > * {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-mx-spoiler--visible {
|
|
||||||
background-color: var(--bg-surface-active) !important;
|
|
||||||
color: inherit !important;
|
|
||||||
user-select: initial !important;
|
|
||||||
& > * {
|
|
||||||
opacity: inherit !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&-edited {
|
|
||||||
color: var(--tc-surface-low);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.message__edit {
|
|
||||||
padding: var(--sp-extra-tight) 0;
|
|
||||||
&-btns button {
|
|
||||||
margin: var(--sp-tight) 0 0 0;
|
|
||||||
padding: var(--sp-ultra-tight) var(--sp-tight);
|
|
||||||
min-width: 0;
|
|
||||||
@include dir.side(margin, 0, var(--sp-tight));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.message__reactions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
& .ic-btn-surface {
|
|
||||||
display: none;
|
|
||||||
padding: var(--sp-ultra-tight);
|
|
||||||
margin-top: var(--sp-extra-tight);
|
|
||||||
}
|
|
||||||
&:hover .ic-btn-surface {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.msg__reaction {
|
|
||||||
margin: var(--sp-extra-tight) 0 0 0;
|
|
||||||
@include dir.side(margin, 0, var(--sp-extra-tight));
|
|
||||||
padding: 0 var(--sp-ultra-tight);
|
|
||||||
min-height: 26px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--tc-surface-normal);
|
|
||||||
background-color: var(--bg-surface-low);
|
|
||||||
border: 1px solid var(--bg-surface-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
& .react-emoji {
|
|
||||||
height: 16px;
|
|
||||||
margin: 2px;
|
|
||||||
}
|
|
||||||
&-count {
|
|
||||||
margin: 0 var(--sp-ultra-tight);
|
|
||||||
color: var(--tc-surface-normal);
|
|
||||||
}
|
|
||||||
&-tooltip .react-emoji {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
margin: 0 var(--sp-ultra-tight);
|
|
||||||
margin-bottom: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--bg-surface-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&:active {
|
|
||||||
background-color: var(--bg-surface-active);
|
|
||||||
}
|
|
||||||
|
|
||||||
&--active {
|
|
||||||
background-color: var(--bg-caution-active);
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--bg-caution-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&:active {
|
|
||||||
background-color: var(--bg-caution-active);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.message__options {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
@include dir.prop(right, 60px, unset);
|
|
||||||
@include dir.prop(left, unset, 60px);
|
|
||||||
|
|
||||||
z-index: 99;
|
|
||||||
transform: translateY(-100%);
|
|
||||||
|
|
||||||
border-radius: var(--bo-radius);
|
|
||||||
box-shadow: var(--bs-surface-border);
|
|
||||||
background-color: var(--bg-surface-low);
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
// markdown formating
|
|
||||||
.message__body {
|
|
||||||
& h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6 {
|
|
||||||
margin: 0;
|
|
||||||
margin-bottom: var(--sp-ultra-tight);
|
|
||||||
font-weight: var(--fw-medium);
|
|
||||||
&:first-child {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
& h1,
|
|
||||||
& h2 {
|
|
||||||
color: var(--tc-surface-high);
|
|
||||||
margin-top: var(--sp-normal);
|
|
||||||
font-size: var(--fs-h2);
|
|
||||||
line-height: var(--lh-h2);
|
|
||||||
letter-spacing: var(--ls-h2);
|
|
||||||
}
|
|
||||||
& h3,
|
|
||||||
& h4 {
|
|
||||||
color: var(--tc-surface-high);
|
|
||||||
margin-top: var(--sp-tight);
|
|
||||||
font-size: var(--fs-s1);
|
|
||||||
line-height: var(--lh-s1);
|
|
||||||
letter-spacing: var(--ls-s1);
|
|
||||||
}
|
|
||||||
& h5,
|
|
||||||
& h6 {
|
|
||||||
color: var(--tc-surface-high);
|
|
||||||
margin-top: var(--sp-extra-tight);
|
|
||||||
font-size: var(--fs-b1);
|
|
||||||
line-height: var(--lh-b1);
|
|
||||||
letter-spacing: var(--ls-b1);
|
|
||||||
}
|
|
||||||
& hr {
|
|
||||||
border-color: var(--bg-divider);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text img {
|
|
||||||
margin: var(--sp-ultra-tight) 0;
|
|
||||||
max-width: 296px;
|
|
||||||
border-radius: calc(var(--bo-radius) / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
& p,
|
|
||||||
& pre,
|
|
||||||
& blockquote {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
& pre,
|
|
||||||
& blockquote {
|
|
||||||
margin: var(--sp-ultra-tight) 0;
|
|
||||||
padding: var(--sp-extra-tight);
|
|
||||||
background-color: var(--bg-surface-hover) !important;
|
|
||||||
border-radius: calc(var(--bo-radius) / 2);
|
|
||||||
}
|
|
||||||
& pre {
|
|
||||||
div {
|
|
||||||
background: none !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
}
|
|
||||||
span {
|
|
||||||
background: none !important;
|
|
||||||
}
|
|
||||||
.linenumber {
|
|
||||||
min-width: 2.25em !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
& code {
|
|
||||||
padding: 0 !important;
|
|
||||||
color: var(--tc-code) !important;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
@include scrollbar.scroll;
|
|
||||||
@include scrollbar.scroll__h;
|
|
||||||
@include scrollbar.scroll--auto-hide;
|
|
||||||
}
|
|
||||||
& pre {
|
|
||||||
width: fit-content;
|
|
||||||
max-width: 100%;
|
|
||||||
@include scrollbar.scroll;
|
|
||||||
@include scrollbar.scroll__h;
|
|
||||||
@include scrollbar.scroll--auto-hide;
|
|
||||||
& code {
|
|
||||||
color: var(--tc-surface-normal) !important;
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
& blockquote {
|
|
||||||
width: fit-content;
|
|
||||||
max-width: 100%;
|
|
||||||
@include dir.side(border, 4px solid var(--bg-surface-active), 0);
|
|
||||||
white-space: initial !important;
|
|
||||||
|
|
||||||
& > * {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
& ul,
|
|
||||||
& ol {
|
|
||||||
margin: var(--sp-ultra-tight) 0;
|
|
||||||
@include dir.side(padding, 24px, 0);
|
|
||||||
white-space: initial !important;
|
|
||||||
}
|
|
||||||
& ul.contains-task-list {
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
& table {
|
|
||||||
display: inline-block;
|
|
||||||
max-width: 100%;
|
|
||||||
white-space: normal !important;
|
|
||||||
background-color: var(--bg-surface-hover);
|
|
||||||
border-radius: calc(var(--bo-radius) / 2);
|
|
||||||
border-spacing: 0;
|
|
||||||
border: 1px solid var(--bg-surface-border);
|
|
||||||
@include scrollbar.scroll;
|
|
||||||
@include scrollbar.scroll__h;
|
|
||||||
@include scrollbar.scroll--auto-hide;
|
|
||||||
|
|
||||||
& td,
|
|
||||||
& th {
|
|
||||||
padding: var(--sp-extra-tight);
|
|
||||||
border: 1px solid var(--bg-surface-border);
|
|
||||||
border-width: 0 1px 1px 0;
|
|
||||||
white-space: pre;
|
|
||||||
&:last-child {
|
|
||||||
border-width: 0;
|
|
||||||
border-bottom-width: 1px;
|
|
||||||
[dir='rtl'] & {
|
|
||||||
border-width: 0 1px 1px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
[dir='rtl'] &:first-child {
|
|
||||||
border-width: 0;
|
|
||||||
border-bottom-width: 1px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
& tbody tr:nth-child(2n + 1) {
|
|
||||||
background-color: var(--bg-surface-hover);
|
|
||||||
}
|
|
||||||
& tr:last-child td {
|
|
||||||
border-bottom-width: 0px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.message.message--type-emote {
|
|
||||||
.message__body {
|
|
||||||
font-style: italic;
|
|
||||||
|
|
||||||
// Remove blockness of first `<p>` so that markdown emotes stay on one line.
|
|
||||||
p:first-of-type {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,78 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './TimelineChange.scss';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
|
||||||
import Time from '../../atoms/time/Time';
|
|
||||||
|
|
||||||
import JoinArraowIC from '../../../../public/res/ic/outlined/join-arrow.svg';
|
|
||||||
import LeaveArraowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
|
|
||||||
import InviteArraowIC from '../../../../public/res/ic/outlined/invite-arrow.svg';
|
|
||||||
import InviteCancelArraowIC from '../../../../public/res/ic/outlined/invite-cancel-arrow.svg';
|
|
||||||
import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
|
||||||
|
|
||||||
function TimelineChange({
|
|
||||||
variant, content, timestamp, onClick,
|
|
||||||
}) {
|
|
||||||
let iconSrc;
|
|
||||||
|
|
||||||
switch (variant) {
|
|
||||||
case 'join':
|
|
||||||
iconSrc = JoinArraowIC;
|
|
||||||
break;
|
|
||||||
case 'leave':
|
|
||||||
iconSrc = LeaveArraowIC;
|
|
||||||
break;
|
|
||||||
case 'invite':
|
|
||||||
iconSrc = InviteArraowIC;
|
|
||||||
break;
|
|
||||||
case 'invite-cancel':
|
|
||||||
iconSrc = InviteCancelArraowIC;
|
|
||||||
break;
|
|
||||||
case 'avatar':
|
|
||||||
iconSrc = UserIC;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
iconSrc = JoinArraowIC;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button style={{ cursor: onClick === null ? 'default' : 'pointer' }} onClick={onClick} type="button" className="timeline-change">
|
|
||||||
<div className="timeline-change__avatar-container">
|
|
||||||
<RawIcon src={iconSrc} size="extra-small" />
|
|
||||||
</div>
|
|
||||||
<div className="timeline-change__content">
|
|
||||||
<Text variant="b2">
|
|
||||||
{content}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<div className="timeline-change__time">
|
|
||||||
<Text variant="b3">
|
|
||||||
<Time timestamp={timestamp} />
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
TimelineChange.defaultProps = {
|
|
||||||
variant: 'other',
|
|
||||||
onClick: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
TimelineChange.propTypes = {
|
|
||||||
variant: PropTypes.oneOf([
|
|
||||||
'join', 'leave', 'invite',
|
|
||||||
'invite-cancel', 'avatar', 'other',
|
|
||||||
]),
|
|
||||||
content: PropTypes.oneOfType([
|
|
||||||
PropTypes.string,
|
|
||||||
PropTypes.node,
|
|
||||||
]).isRequired,
|
|
||||||
timestamp: PropTypes.number.isRequired,
|
|
||||||
onClick: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TimelineChange;
|
|
|
@ -1,37 +0,0 @@
|
||||||
@use '../../partials/dir';
|
|
||||||
|
|
||||||
.timeline-change {
|
|
||||||
padding: var(--sp-ultra-tight);
|
|
||||||
@include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--bg-surface-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__avatar-container {
|
|
||||||
width: var(--av-small);
|
|
||||||
display: inline-flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
opacity: 0.38;
|
|
||||||
.ic-raw {
|
|
||||||
background-color: var(--tc-surface-low);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& .text {
|
|
||||||
color: var(--tc-surface-low);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__content {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
|
|
||||||
margin: 0 var(--sp-tight);
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -2,16 +2,12 @@ import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import './PeopleSelector.scss';
|
import './PeopleSelector.scss';
|
||||||
|
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import { blurOnBubbling } from '../../atoms/button/script';
|
import { blurOnBubbling } from '../../atoms/button/script';
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
import Avatar from '../../atoms/avatar/Avatar';
|
import Avatar from '../../atoms/avatar/Avatar';
|
||||||
|
|
||||||
function PeopleSelector({
|
function PeopleSelector({ avatarSrc, name, color, peopleRole, onClick }) {
|
||||||
avatarSrc, name, color, peopleRole, onClick,
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="people-selector__container">
|
<div className="people-selector__container">
|
||||||
<button
|
<button
|
||||||
|
@ -21,8 +17,14 @@ function PeopleSelector({
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Avatar imageSrc={avatarSrc} text={name} bgColor={color} size="extra-small" />
|
<Avatar imageSrc={avatarSrc} text={name} bgColor={color} size="extra-small" />
|
||||||
<Text className="people-selector__name" variant="b1">{twemojify(name)}</Text>
|
<Text className="people-selector__name" variant="b1">
|
||||||
{peopleRole !== null && <Text className="people-selector__role" variant="b3">{peopleRole}</Text>}
|
{name}
|
||||||
|
</Text>
|
||||||
|
{peopleRole !== null && (
|
||||||
|
<Text className="people-selector__role" variant="b3">
|
||||||
|
{peopleRole}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -2,8 +2,6 @@ import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import './PopupWindow.scss';
|
import './PopupWindow.scss';
|
||||||
|
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
import IconButton from '../../atoms/button/IconButton';
|
import IconButton from '../../atoms/button/IconButton';
|
||||||
import { MenuItem } from '../../atoms/context-menu/ContextMenu';
|
import { MenuItem } from '../../atoms/context-menu/ContextMenu';
|
||||||
|
@ -13,19 +11,11 @@ import RawModal from '../../atoms/modal/RawModal';
|
||||||
|
|
||||||
import ChevronLeftIC from '../../../../public/res/ic/outlined/chevron-left.svg';
|
import ChevronLeftIC from '../../../../public/res/ic/outlined/chevron-left.svg';
|
||||||
|
|
||||||
function PWContentSelector({
|
function PWContentSelector({ selected, variant, iconSrc, type, onClick, children }) {
|
||||||
selected, variant, iconSrc,
|
|
||||||
type, onClick, children,
|
|
||||||
}) {
|
|
||||||
const pwcsClass = selected ? ' pw-content-selector--selected' : '';
|
const pwcsClass = selected ? ' pw-content-selector--selected' : '';
|
||||||
return (
|
return (
|
||||||
<div className={`pw-content-selector${pwcsClass}`}>
|
<div className={`pw-content-selector${pwcsClass}`}>
|
||||||
<MenuItem
|
<MenuItem variant={variant} iconSrc={iconSrc} type={type} onClick={onClick}>
|
||||||
variant={variant}
|
|
||||||
iconSrc={iconSrc}
|
|
||||||
type={type}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</div>
|
</div>
|
||||||
|
@ -49,9 +39,16 @@ PWContentSelector.propTypes = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function PopupWindow({
|
function PopupWindow({
|
||||||
className, isOpen, title, contentTitle,
|
className,
|
||||||
drawer, drawerOptions, contentOptions,
|
isOpen,
|
||||||
onAfterClose, onRequestClose, children,
|
title,
|
||||||
|
contentTitle,
|
||||||
|
drawer,
|
||||||
|
drawerOptions,
|
||||||
|
contentOptions,
|
||||||
|
onAfterClose,
|
||||||
|
onRequestClose,
|
||||||
|
children,
|
||||||
}) {
|
}) {
|
||||||
const haveDrawer = drawer !== null;
|
const haveDrawer = drawer !== null;
|
||||||
const cTitle = contentTitle !== null ? contentTitle : title;
|
const cTitle = contentTitle !== null ? contentTitle : title;
|
||||||
|
@ -69,21 +66,26 @@ function PopupWindow({
|
||||||
{haveDrawer && (
|
{haveDrawer && (
|
||||||
<div className="pw__drawer">
|
<div className="pw__drawer">
|
||||||
<Header>
|
<Header>
|
||||||
<IconButton size="small" src={ChevronLeftIC} onClick={onRequestClose} tooltip="Back" />
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
src={ChevronLeftIC}
|
||||||
|
onClick={onRequestClose}
|
||||||
|
tooltip="Back"
|
||||||
|
/>
|
||||||
<TitleWrapper>
|
<TitleWrapper>
|
||||||
{
|
{typeof title === 'string' ? (
|
||||||
typeof title === 'string'
|
<Text variant="s1" weight="medium" primary>
|
||||||
? <Text variant="s1" weight="medium" primary>{twemojify(title)}</Text>
|
{title}
|
||||||
: title
|
</Text>
|
||||||
}
|
) : (
|
||||||
|
title
|
||||||
|
)}
|
||||||
</TitleWrapper>
|
</TitleWrapper>
|
||||||
{drawerOptions}
|
{drawerOptions}
|
||||||
</Header>
|
</Header>
|
||||||
<div className="pw__drawer__content__wrapper">
|
<div className="pw__drawer__content__wrapper">
|
||||||
<ScrollView invisible>
|
<ScrollView invisible>
|
||||||
<div className="pw__drawer__content">
|
<div className="pw__drawer__content">{drawer}</div>
|
||||||
{drawer}
|
|
||||||
</div>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -91,19 +93,19 @@ function PopupWindow({
|
||||||
<div className="pw__content">
|
<div className="pw__content">
|
||||||
<Header>
|
<Header>
|
||||||
<TitleWrapper>
|
<TitleWrapper>
|
||||||
{
|
{typeof cTitle === 'string' ? (
|
||||||
typeof cTitle === 'string'
|
<Text variant="h2" weight="medium" primary>
|
||||||
? <Text variant="h2" weight="medium" primary>{twemojify(cTitle)}</Text>
|
{cTitle}
|
||||||
: cTitle
|
</Text>
|
||||||
}
|
) : (
|
||||||
|
cTitle
|
||||||
|
)}
|
||||||
</TitleWrapper>
|
</TitleWrapper>
|
||||||
{contentOptions}
|
{contentOptions}
|
||||||
</Header>
|
</Header>
|
||||||
<div className="pw__content__wrapper">
|
<div className="pw__content__wrapper">
|
||||||
<ScrollView autoHide>
|
<ScrollView autoHide>
|
||||||
<div className="pw__content-container">
|
<div className="pw__content-container">{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -13,28 +13,33 @@ import BellIC from '../../../../public/res/ic/outlined/bell.svg';
|
||||||
import BellRingIC from '../../../../public/res/ic/outlined/bell-ring.svg';
|
import BellRingIC from '../../../../public/res/ic/outlined/bell-ring.svg';
|
||||||
import BellPingIC from '../../../../public/res/ic/outlined/bell-ping.svg';
|
import BellPingIC from '../../../../public/res/ic/outlined/bell-ping.svg';
|
||||||
import BellOffIC from '../../../../public/res/ic/outlined/bell-off.svg';
|
import BellOffIC from '../../../../public/res/ic/outlined/bell-off.svg';
|
||||||
|
import { getNotificationType } from '../../utils/room';
|
||||||
|
|
||||||
const items = [{
|
const items = [
|
||||||
iconSrc: BellIC,
|
{
|
||||||
text: 'Global',
|
iconSrc: BellIC,
|
||||||
type: cons.notifs.DEFAULT,
|
text: 'Global',
|
||||||
}, {
|
type: cons.notifs.DEFAULT,
|
||||||
iconSrc: BellRingIC,
|
},
|
||||||
text: 'All messages',
|
{
|
||||||
type: cons.notifs.ALL_MESSAGES,
|
iconSrc: BellRingIC,
|
||||||
}, {
|
text: 'All messages',
|
||||||
iconSrc: BellPingIC,
|
type: cons.notifs.ALL_MESSAGES,
|
||||||
text: 'Mentions & Keywords',
|
},
|
||||||
type: cons.notifs.MENTIONS_AND_KEYWORDS,
|
{
|
||||||
}, {
|
iconSrc: BellPingIC,
|
||||||
iconSrc: BellOffIC,
|
text: 'Mentions & Keywords',
|
||||||
text: 'Mute',
|
type: cons.notifs.MENTIONS_AND_KEYWORDS,
|
||||||
type: cons.notifs.MUTE,
|
},
|
||||||
}];
|
{
|
||||||
|
iconSrc: BellOffIC,
|
||||||
|
text: 'Mute',
|
||||||
|
type: cons.notifs.MUTE,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function setRoomNotifType(roomId, newType) {
|
function setRoomNotifType(roomId, newType) {
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
const { notifications } = initMatrix;
|
|
||||||
let roomPushRule;
|
let roomPushRule;
|
||||||
try {
|
try {
|
||||||
roomPushRule = mx.getRoomPushRule('global', roomId);
|
roomPushRule = mx.getRoomPushRule('global', roomId);
|
||||||
|
@ -47,22 +52,22 @@ function setRoomNotifType(roomId, newType) {
|
||||||
if (roomPushRule) {
|
if (roomPushRule) {
|
||||||
promises.push(mx.deletePushRule('global', 'room', roomPushRule.rule_id));
|
promises.push(mx.deletePushRule('global', 'room', roomPushRule.rule_id));
|
||||||
}
|
}
|
||||||
promises.push(mx.addPushRule('global', 'override', roomId, {
|
promises.push(
|
||||||
conditions: [
|
mx.addPushRule('global', 'override', roomId, {
|
||||||
{
|
conditions: [
|
||||||
kind: 'event_match',
|
{
|
||||||
key: 'room_id',
|
kind: 'event_match',
|
||||||
pattern: roomId,
|
key: 'room_id',
|
||||||
},
|
pattern: roomId,
|
||||||
],
|
},
|
||||||
actions: [
|
],
|
||||||
'dont_notify',
|
actions: ['dont_notify'],
|
||||||
],
|
})
|
||||||
}));
|
);
|
||||||
return promises;
|
return promises;
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldState = notifications.getNotiType(roomId);
|
const oldState = getNotificationType(mx, roomId);
|
||||||
if (oldState === cons.notifs.MUTE) {
|
if (oldState === cons.notifs.MUTE) {
|
||||||
promises.push(mx.deletePushRule('global', 'override', roomId));
|
promises.push(mx.deletePushRule('global', 'override', roomId));
|
||||||
}
|
}
|
||||||
|
@ -75,25 +80,27 @@ function setRoomNotifType(roomId, newType) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newType === cons.notifs.MENTIONS_AND_KEYWORDS) {
|
if (newType === cons.notifs.MENTIONS_AND_KEYWORDS) {
|
||||||
promises.push(mx.addPushRule('global', 'room', roomId, {
|
promises.push(
|
||||||
actions: [
|
mx.addPushRule('global', 'room', roomId, {
|
||||||
'dont_notify',
|
actions: ['dont_notify'],
|
||||||
],
|
})
|
||||||
}));
|
);
|
||||||
promises.push(mx.setPushRuleEnabled('global', 'room', roomId, true));
|
promises.push(mx.setPushRuleEnabled('global', 'room', roomId, true));
|
||||||
return Promise.all(promises);
|
return Promise.all(promises);
|
||||||
}
|
}
|
||||||
|
|
||||||
// cons.notifs.ALL_MESSAGES
|
// cons.notifs.ALL_MESSAGES
|
||||||
promises.push(mx.addPushRule('global', 'room', roomId, {
|
promises.push(
|
||||||
actions: [
|
mx.addPushRule('global', 'room', roomId, {
|
||||||
'notify',
|
actions: [
|
||||||
{
|
'notify',
|
||||||
set_tweak: 'sound',
|
{
|
||||||
value: 'default',
|
set_tweak: 'sound',
|
||||||
},
|
value: 'default',
|
||||||
],
|
},
|
||||||
}));
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
promises.push(mx.setPushRuleEnabled('global', 'room', roomId, true));
|
promises.push(mx.setPushRuleEnabled('global', 'room', roomId, true));
|
||||||
|
|
||||||
|
@ -101,17 +108,20 @@ function setRoomNotifType(roomId, newType) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function useNotifications(roomId) {
|
function useNotifications(roomId) {
|
||||||
const { notifications } = initMatrix;
|
const mx = initMatrix.matrixClient;
|
||||||
const [activeType, setActiveType] = useState(notifications.getNotiType(roomId));
|
const [activeType, setActiveType] = useState(getNotificationType(mx, roomId));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveType(notifications.getNotiType(roomId));
|
setActiveType(getNotificationType(mx, roomId));
|
||||||
}, [roomId]);
|
}, [mx, roomId]);
|
||||||
|
|
||||||
const setNotification = useCallback((item) => {
|
const setNotification = useCallback(
|
||||||
if (item.type === activeType.type) return;
|
(item) => {
|
||||||
setActiveType(item.type);
|
if (item.type === activeType.type) return;
|
||||||
setRoomNotifType(roomId, item.type);
|
setActiveType(item.type);
|
||||||
}, [activeType, roomId]);
|
setRoomNotifType(roomId, item.type);
|
||||||
|
},
|
||||||
|
[activeType, roomId]
|
||||||
|
);
|
||||||
return [activeType, setNotification];
|
return [activeType, setNotification];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -120,21 +130,19 @@ function RoomNotification({ roomId }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="room-notification">
|
<div className="room-notification">
|
||||||
{
|
{items.map((item) => (
|
||||||
items.map((item) => (
|
<MenuItem
|
||||||
<MenuItem
|
variant={activeType === item.type ? 'positive' : 'surface'}
|
||||||
variant={activeType === item.type ? 'positive' : 'surface'}
|
key={item.type}
|
||||||
key={item.type}
|
iconSrc={item.iconSrc}
|
||||||
iconSrc={item.iconSrc}
|
onClick={() => setNotification(item)}
|
||||||
onClick={() => setNotification(item)}
|
>
|
||||||
>
|
<Text varient="b1">
|
||||||
<Text varient="b1">
|
<span>{item.text}</span>
|
||||||
<span>{item.text}</span>
|
<RadioButton isActive={activeType === item.type} />
|
||||||
<RadioButton isActive={activeType === item.type} />
|
</Text>
|
||||||
</Text>
|
</MenuItem>
|
||||||
</MenuItem>
|
))}
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,73 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import { openInviteUser } from '../../../client/action/navigation';
|
|
||||||
import * as roomActions from '../../../client/action/room';
|
|
||||||
import { markAsRead } from '../../../client/action/notifications';
|
|
||||||
|
|
||||||
import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
|
|
||||||
import RoomNotification from '../room-notification/RoomNotification';
|
|
||||||
|
|
||||||
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
|
|
||||||
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
|
|
||||||
import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
|
|
||||||
|
|
||||||
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
|
|
||||||
|
|
||||||
function RoomOptions({ roomId, afterOptionSelect }) {
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const room = mx.getRoom(roomId);
|
|
||||||
const canInvite = room?.canInvite(mx.getUserId());
|
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
|
||||||
markAsRead(roomId);
|
|
||||||
afterOptionSelect();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInviteClick = () => {
|
|
||||||
openInviteUser(roomId);
|
|
||||||
afterOptionSelect();
|
|
||||||
};
|
|
||||||
const handleLeaveClick = async () => {
|
|
||||||
afterOptionSelect();
|
|
||||||
const isConfirmed = await confirmDialog(
|
|
||||||
'Leave room',
|
|
||||||
`Are you sure that you want to leave "${room.name}" room?`,
|
|
||||||
'Leave',
|
|
||||||
'danger',
|
|
||||||
);
|
|
||||||
if (!isConfirmed) return;
|
|
||||||
roomActions.leave(roomId);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ maxWidth: '256px' }}>
|
|
||||||
<MenuHeader>{twemojify(`Options for ${initMatrix.matrixClient.getRoom(roomId)?.name}`)}</MenuHeader>
|
|
||||||
<MenuItem iconSrc={TickMarkIC} onClick={handleMarkAsRead}>Mark as read</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
iconSrc={AddUserIC}
|
|
||||||
onClick={handleInviteClick}
|
|
||||||
disabled={!canInvite}
|
|
||||||
>
|
|
||||||
Invite
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem iconSrc={LeaveArrowIC} variant="danger" onClick={handleLeaveClick}>Leave</MenuItem>
|
|
||||||
<MenuHeader>Notification</MenuHeader>
|
|
||||||
<RoomNotification roomId={roomId} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
RoomOptions.defaultProps = {
|
|
||||||
afterOptionSelect: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
RoomOptions.propTypes = {
|
|
||||||
roomId: PropTypes.string.isRequired,
|
|
||||||
afterOptionSelect: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RoomOptions;
|
|
|
@ -1,9 +1,9 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import Linkify from 'linkify-react';
|
||||||
import './RoomProfile.scss';
|
import './RoomProfile.scss';
|
||||||
|
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import cons from '../../../client/state/cons';
|
import cons from '../../../client/state/cons';
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
|
@ -20,6 +20,8 @@ import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
|
||||||
import { useStore } from '../../hooks/useStore';
|
import { useStore } from '../../hooks/useStore';
|
||||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||||
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
|
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
|
||||||
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
|
import { LINKIFY_OPTS } from '../../plugins/react-custom-html-parser';
|
||||||
|
|
||||||
function RoomProfile({ roomId }) {
|
function RoomProfile({ roomId }) {
|
||||||
const isMountStore = useStore();
|
const isMountStore = useStore();
|
||||||
|
@ -31,9 +33,12 @@ function RoomProfile({ roomId }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
const isDM = initMatrix.roomList.directs.has(roomId);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
const isDM = mDirects.has(roomId);
|
||||||
let avatarSrc = mx.getRoom(roomId).getAvatarUrl(mx.baseUrl, 36, 36, 'crop');
|
let avatarSrc = mx.getRoom(roomId).getAvatarUrl(mx.baseUrl, 36, 36, 'crop');
|
||||||
avatarSrc = isDM ? mx.getRoom(roomId).getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 36, 36, 'crop') : avatarSrc;
|
avatarSrc = isDM
|
||||||
|
? mx.getRoom(roomId).getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 36, 36, 'crop')
|
||||||
|
: avatarSrc;
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
const { currentState } = room;
|
const { currentState } = room;
|
||||||
const roomName = room.name;
|
const roomName = room.name;
|
||||||
|
@ -47,15 +52,14 @@ function RoomProfile({ roomId }) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isMountStore.setItem(true);
|
isMountStore.setItem(true);
|
||||||
const { roomList } = initMatrix;
|
const handleStateEvent = (mEvent) => {
|
||||||
const handleProfileUpdate = (rId) => {
|
if (mEvent.event.room_id !== roomId) return;
|
||||||
if (roomId !== rId) return;
|
|
||||||
forceUpdate();
|
forceUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
roomList.on(cons.events.roomList.ROOM_PROFILE_UPDATED, handleProfileUpdate);
|
mx.on('RoomState.events', handleStateEvent);
|
||||||
return () => {
|
return () => {
|
||||||
roomList.removeListener(cons.events.roomList.ROOM_PROFILE_UPDATED, handleProfileUpdate);
|
mx.removeListener('RoomState.events', handleStateEvent);
|
||||||
isMountStore.setItem(false);
|
isMountStore.setItem(false);
|
||||||
setStatus({
|
setStatus({
|
||||||
msg: null,
|
msg: null,
|
||||||
|
@ -122,7 +126,7 @@ function RoomProfile({ roomId }) {
|
||||||
'Remove avatar',
|
'Remove avatar',
|
||||||
'Are you sure that you want to remove room avatar?',
|
'Are you sure that you want to remove room avatar?',
|
||||||
'Remove',
|
'Remove',
|
||||||
'caution',
|
'caution'
|
||||||
);
|
);
|
||||||
if (isConfirmed) {
|
if (isConfirmed) {
|
||||||
await mx.sendStateEvent(roomId, 'm.room.avatar', { url }, '');
|
await mx.sendStateEvent(roomId, 'm.room.avatar', { url }, '');
|
||||||
|
@ -132,15 +136,45 @@ function RoomProfile({ roomId }) {
|
||||||
|
|
||||||
const renderEditNameAndTopic = () => (
|
const renderEditNameAndTopic = () => (
|
||||||
<form className="room-profile__edit-form" onSubmit={handleOnSubmit}>
|
<form className="room-profile__edit-form" onSubmit={handleOnSubmit}>
|
||||||
{canChangeName && <Input value={roomName} name="room-name" disabled={status.type === cons.status.IN_FLIGHT} label="Name" />}
|
{canChangeName && (
|
||||||
{canChangeTopic && <Input value={roomTopic} name="room-topic" disabled={status.type === cons.status.IN_FLIGHT} minHeight={100} resizable label="Topic" />}
|
<Input
|
||||||
{(!canChangeName || !canChangeTopic) && <Text variant="b3">{`You have permission to change ${room.isSpaceRoom() ? 'space' : 'room'} ${canChangeName ? 'name' : 'topic'} only.`}</Text>}
|
value={roomName}
|
||||||
{ status.type === cons.status.IN_FLIGHT && <Text variant="b2">{status.msg}</Text>}
|
name="room-name"
|
||||||
{ status.type === cons.status.SUCCESS && <Text style={{ color: 'var(--tc-positive-high)' }} variant="b2">{status.msg}</Text>}
|
disabled={status.type === cons.status.IN_FLIGHT}
|
||||||
{ status.type === cons.status.ERROR && <Text style={{ color: 'var(--tc-danger-high)' }} variant="b2">{status.msg}</Text>}
|
label="Name"
|
||||||
{ status.type !== cons.status.IN_FLIGHT && (
|
/>
|
||||||
|
)}
|
||||||
|
{canChangeTopic && (
|
||||||
|
<Input
|
||||||
|
value={roomTopic}
|
||||||
|
name="room-topic"
|
||||||
|
disabled={status.type === cons.status.IN_FLIGHT}
|
||||||
|
minHeight={100}
|
||||||
|
resizable
|
||||||
|
label="Topic"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(!canChangeName || !canChangeTopic) && (
|
||||||
|
<Text variant="b3">{`You have permission to change ${
|
||||||
|
room.isSpaceRoom() ? 'space' : 'room'
|
||||||
|
} ${canChangeName ? 'name' : 'topic'} only.`}</Text>
|
||||||
|
)}
|
||||||
|
{status.type === cons.status.IN_FLIGHT && <Text variant="b2">{status.msg}</Text>}
|
||||||
|
{status.type === cons.status.SUCCESS && (
|
||||||
|
<Text style={{ color: 'var(--tc-positive-high)' }} variant="b2">
|
||||||
|
{status.msg}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{status.type === cons.status.ERROR && (
|
||||||
|
<Text style={{ color: 'var(--tc-danger-high)' }} variant="b2">
|
||||||
|
{status.msg}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{status.type !== cons.status.IN_FLIGHT && (
|
||||||
<div>
|
<div>
|
||||||
<Button type="submit" variant="primary">Save</Button>
|
<Button type="submit" variant="primary">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
<Button onClick={handleCancelEditing}>Cancel</Button>
|
<Button onClick={handleCancelEditing}>Cancel</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -148,10 +182,15 @@ function RoomProfile({ roomId }) {
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderNameAndTopic = () => (
|
const renderNameAndTopic = () => (
|
||||||
<div className="room-profile__display" style={{ marginBottom: avatarSrc && canChangeAvatar ? '24px' : '0' }}>
|
<div
|
||||||
|
className="room-profile__display"
|
||||||
|
style={{ marginBottom: avatarSrc && canChangeAvatar ? '24px' : '0' }}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<Text variant="h2" weight="medium" primary>{twemojify(roomName)}</Text>
|
<Text variant="h2" weight="medium" primary>
|
||||||
{ (canChangeName || canChangeTopic) && (
|
{roomName}
|
||||||
|
</Text>
|
||||||
|
{(canChangeName || canChangeTopic) && (
|
||||||
<IconButton
|
<IconButton
|
||||||
src={PencilIC}
|
src={PencilIC}
|
||||||
size="extra-small"
|
size="extra-small"
|
||||||
|
@ -161,15 +200,21 @@ function RoomProfile({ roomId }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Text variant="b3">{room.getCanonicalAlias() || room.roomId}</Text>
|
<Text variant="b3">{room.getCanonicalAlias() || room.roomId}</Text>
|
||||||
{roomTopic && <Text variant="b2">{twemojify(roomTopic, undefined, true)}</Text>}
|
{roomTopic && (
|
||||||
|
<Text variant="b2">
|
||||||
|
<Linkify options={LINKIFY_OPTS}>{roomTopic}</Linkify>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="room-profile">
|
<div className="room-profile">
|
||||||
<div className="room-profile__content">
|
<div className="room-profile__content">
|
||||||
{ !canChangeAvatar && <Avatar imageSrc={avatarSrc} text={roomName} bgColor={colorMXID(roomId)} size="large" />}
|
{!canChangeAvatar && (
|
||||||
{ canChangeAvatar && (
|
<Avatar imageSrc={avatarSrc} text={roomName} bgColor={colorMXID(roomId)} size="large" />
|
||||||
|
)}
|
||||||
|
{canChangeAvatar && (
|
||||||
<ImageUpload
|
<ImageUpload
|
||||||
text={roomName}
|
text={roomName}
|
||||||
bgColor={colorMXID(roomId)}
|
bgColor={colorMXID(roomId)}
|
||||||
|
|
|
@ -1,201 +0,0 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './RoomSearch.scss';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import { selectRoom } from '../../../client/action/navigation';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
|
||||||
import Button from '../../atoms/button/Button';
|
|
||||||
import Input from '../../atoms/input/Input';
|
|
||||||
import Spinner from '../../atoms/spinner/Spinner';
|
|
||||||
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
|
|
||||||
import { Message } from '../message/Message';
|
|
||||||
|
|
||||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
|
||||||
|
|
||||||
import { useStore } from '../../hooks/useStore';
|
|
||||||
|
|
||||||
const roomIdToBackup = new Map();
|
|
||||||
|
|
||||||
function useRoomSearch(roomId) {
|
|
||||||
const [searchData, setSearchData] = useState(roomIdToBackup.get(roomId) ?? null);
|
|
||||||
const [status, setStatus] = useState({
|
|
||||||
type: cons.status.PRE_FLIGHT,
|
|
||||||
term: null,
|
|
||||||
});
|
|
||||||
const mountStore = useStore(roomId);
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
mountStore.setItem(true)
|
|
||||||
}, [roomId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (searchData?.results?.length > 0) {
|
|
||||||
roomIdToBackup.set(roomId, searchData);
|
|
||||||
} else {
|
|
||||||
roomIdToBackup.delete(roomId);
|
|
||||||
}
|
|
||||||
}, [searchData]);
|
|
||||||
|
|
||||||
const search = async (term) => {
|
|
||||||
setSearchData(null);
|
|
||||||
if (term === '') {
|
|
||||||
setStatus({ type: cons.status.PRE_FLIGHT, term: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setStatus({ type: cons.status.IN_FLIGHT, term });
|
|
||||||
const body = {
|
|
||||||
search_categories: {
|
|
||||||
room_events: {
|
|
||||||
search_term: term,
|
|
||||||
filter: {
|
|
||||||
limit: 10,
|
|
||||||
rooms: [roomId],
|
|
||||||
},
|
|
||||||
order_by: 'recent',
|
|
||||||
event_context: {
|
|
||||||
before_limit: 0,
|
|
||||||
after_limit: 0,
|
|
||||||
include_profile: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const res = await mx.search({ body });
|
|
||||||
const data = mx.processRoomEventsSearch({
|
|
||||||
_query: body,
|
|
||||||
results: [],
|
|
||||||
highlights: [],
|
|
||||||
}, res);
|
|
||||||
if (!mountStore.getItem()) return;
|
|
||||||
setStatus({ type: cons.status.SUCCESS, term });
|
|
||||||
setSearchData(data);
|
|
||||||
if (!mountStore.getItem()) return;
|
|
||||||
} catch (error) {
|
|
||||||
setSearchData(null);
|
|
||||||
setStatus({ type: cons.status.ERROR, term });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const paginate = async () => {
|
|
||||||
if (searchData === null) return;
|
|
||||||
const term = searchData._query.search_categories.room_events.search_term;
|
|
||||||
|
|
||||||
setStatus({ type: cons.status.IN_FLIGHT, term });
|
|
||||||
try {
|
|
||||||
const data = await mx.backPaginateRoomEventsSearch(searchData);
|
|
||||||
if (!mountStore.getItem()) return;
|
|
||||||
setStatus({ type: cons.status.SUCCESS, term });
|
|
||||||
setSearchData(data);
|
|
||||||
} catch (error) {
|
|
||||||
if (!mountStore.getItem()) return;
|
|
||||||
setSearchData(null);
|
|
||||||
setStatus({ type: cons.status.ERROR, term });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return [searchData, search, paginate, status];
|
|
||||||
}
|
|
||||||
|
|
||||||
function RoomSearch({ roomId }) {
|
|
||||||
const [searchData, search, paginate, status] = useRoomSearch(roomId);
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const isRoomEncrypted = mx.isRoomEncrypted(roomId);
|
|
||||||
const searchTerm = searchData?._query.search_categories.room_events.search_term ?? '';
|
|
||||||
|
|
||||||
const handleSearch = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (isRoomEncrypted) return;
|
|
||||||
const searchTermInput = e.target.elements['room-search-input'];
|
|
||||||
const term = searchTermInput.value.trim();
|
|
||||||
|
|
||||||
search(term);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderTimeline = (timeline) => (
|
|
||||||
<div className="room-search__result-item" key={timeline[0].getId()}>
|
|
||||||
{ timeline.map((mEvent) => {
|
|
||||||
const id = mEvent.getId();
|
|
||||||
return (
|
|
||||||
<React.Fragment key={id}>
|
|
||||||
<Message
|
|
||||||
mEvent={mEvent}
|
|
||||||
isBodyOnly={false}
|
|
||||||
fullTime
|
|
||||||
/>
|
|
||||||
<Button onClick={() => selectRoom(roomId, id)}>View</Button>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="room-search">
|
|
||||||
<form className="room-search__form" onSubmit={handleSearch}>
|
|
||||||
<MenuHeader>Room search</MenuHeader>
|
|
||||||
<div>
|
|
||||||
<Input
|
|
||||||
placeholder="Search for keywords"
|
|
||||||
name="room-search-input"
|
|
||||||
disabled={isRoomEncrypted}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<Button iconSrc={SearchIC} variant="primary" type="submit">Search</Button>
|
|
||||||
</div>
|
|
||||||
{searchData?.results.length > 0 && (
|
|
||||||
<Text>{`${searchData.count} results for "${searchTerm}"`}</Text>
|
|
||||||
)}
|
|
||||||
{!isRoomEncrypted && searchData === null && (
|
|
||||||
<div className="room-search__help">
|
|
||||||
{status.type === cons.status.IN_FLIGHT && <Spinner />}
|
|
||||||
{status.type === cons.status.IN_FLIGHT && <Text>Searching room messages...</Text>}
|
|
||||||
{status.type === cons.status.PRE_FLIGHT && <RawIcon src={SearchIC} size="large" />}
|
|
||||||
{status.type === cons.status.PRE_FLIGHT && <Text>Search room messages</Text>}
|
|
||||||
{status.type === cons.status.ERROR && <Text>Failed to search messages</Text>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isRoomEncrypted && searchData?.results.length === 0 && (
|
|
||||||
<div className="room-search__help">
|
|
||||||
<Text>No results found</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isRoomEncrypted && (
|
|
||||||
<div className="room-search__help">
|
|
||||||
<Text>Search does not work in encrypted room</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
{searchData?.results.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="room-search__content">
|
|
||||||
{searchData.results.map((searchResult) => {
|
|
||||||
const { timeline } = searchResult.context;
|
|
||||||
return renderTimeline(timeline);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{searchData?.next_batch && (
|
|
||||||
<div className="room-search__more">
|
|
||||||
{status.type !== cons.status.IN_FLIGHT && (
|
|
||||||
<Button onClick={paginate}>Load more</Button>
|
|
||||||
)}
|
|
||||||
{status.type === cons.status.IN_FLIGHT && <Spinner />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
RoomSearch.propTypes = {
|
|
||||||
roomId: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RoomSearch;
|
|
|
@ -1,62 +0,0 @@
|
||||||
@use '../../partials/flex';
|
|
||||||
@use '../../partials/dir';
|
|
||||||
|
|
||||||
.room-search {
|
|
||||||
&__form {
|
|
||||||
& div:nth-child(2) {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
padding: var(--sp-normal);;
|
|
||||||
|
|
||||||
& .input-container {
|
|
||||||
@extend .cp-fx__item-one;
|
|
||||||
@include dir.side(margin, 0, var(--sp-normal));
|
|
||||||
}
|
|
||||||
& button {
|
|
||||||
height: 46px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
& .context-menu__header {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
& > .text {
|
|
||||||
padding: 0 var(--sp-normal) var(--sp-tight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__help {
|
|
||||||
height: 248px;
|
|
||||||
@extend .cp-fx__column--c-c;
|
|
||||||
|
|
||||||
& .ic-raw {
|
|
||||||
opacity: .5;
|
|
||||||
}
|
|
||||||
.text {
|
|
||||||
margin-top: var(--sp-normal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&__more {
|
|
||||||
margin-bottom: var(--sp-normal);
|
|
||||||
@extend .cp-fx__row--c-c;
|
|
||||||
button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&__result-item {
|
|
||||||
padding: var(--sp-tight) var(--sp-normal);
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
|
|
||||||
.message {
|
|
||||||
@include dir.side(margin, 0, var(--sp-normal));
|
|
||||||
@extend .cp-fx__item-one;
|
|
||||||
padding: 0;
|
|
||||||
&:hover {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
& .message__time {
|
|
||||||
flex: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -2,7 +2,6 @@ import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import './RoomSelector.scss';
|
import './RoomSelector.scss';
|
||||||
|
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
|
@ -11,8 +10,13 @@ import NotificationBadge from '../../atoms/badge/NotificationBadge';
|
||||||
import { blurOnBubbling } from '../../atoms/button/script';
|
import { blurOnBubbling } from '../../atoms/button/script';
|
||||||
|
|
||||||
function RoomSelectorWrapper({
|
function RoomSelectorWrapper({
|
||||||
isSelected, isMuted, isUnread, onClick,
|
isSelected,
|
||||||
content, options, onContextMenu,
|
isMuted,
|
||||||
|
isUnread,
|
||||||
|
onClick,
|
||||||
|
content,
|
||||||
|
options,
|
||||||
|
onContextMenu,
|
||||||
}) {
|
}) {
|
||||||
const classes = ['room-selector'];
|
const classes = ['room-selector'];
|
||||||
if (isMuted) classes.push('room-selector--muted');
|
if (isMuted) classes.push('room-selector--muted');
|
||||||
|
@ -50,16 +54,26 @@ RoomSelectorWrapper.propTypes = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function RoomSelector({
|
function RoomSelector({
|
||||||
name, parentName, roomId, imageSrc, iconSrc,
|
name,
|
||||||
isSelected, isMuted, isUnread, notificationCount, isAlert,
|
parentName,
|
||||||
options, onClick, onContextMenu,
|
roomId,
|
||||||
|
imageSrc,
|
||||||
|
iconSrc,
|
||||||
|
isSelected,
|
||||||
|
isMuted,
|
||||||
|
isUnread,
|
||||||
|
notificationCount,
|
||||||
|
isAlert,
|
||||||
|
options,
|
||||||
|
onClick,
|
||||||
|
onContextMenu,
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<RoomSelectorWrapper
|
<RoomSelectorWrapper
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
isMuted={isMuted}
|
isMuted={isMuted}
|
||||||
isUnread={isUnread}
|
isUnread={isUnread}
|
||||||
content={(
|
content={
|
||||||
<>
|
<>
|
||||||
<Avatar
|
<Avatar
|
||||||
text={name}
|
text={name}
|
||||||
|
@ -70,22 +84,22 @@ function RoomSelector({
|
||||||
size="extra-small"
|
size="extra-small"
|
||||||
/>
|
/>
|
||||||
<Text variant="b1" weight={isUnread ? 'medium' : 'normal'}>
|
<Text variant="b1" weight={isUnread ? 'medium' : 'normal'}>
|
||||||
{twemojify(name)}
|
{name}
|
||||||
{parentName && (
|
{parentName && (
|
||||||
<Text variant="b3" span>
|
<Text variant="b3" span>
|
||||||
{' — '}
|
{' — '}
|
||||||
{twemojify(parentName)}
|
{parentName}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
{ isUnread && (
|
{isUnread && (
|
||||||
<NotificationBadge
|
<NotificationBadge
|
||||||
alert={isAlert}
|
alert={isAlert}
|
||||||
content={notificationCount !== 0 ? notificationCount : null}
|
content={notificationCount !== 0 ? notificationCount : null}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
}
|
||||||
options={options}
|
options={options}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
|
@ -110,10 +124,7 @@ RoomSelector.propTypes = {
|
||||||
isSelected: PropTypes.bool,
|
isSelected: PropTypes.bool,
|
||||||
isMuted: PropTypes.bool,
|
isMuted: PropTypes.bool,
|
||||||
isUnread: PropTypes.bool.isRequired,
|
isUnread: PropTypes.bool.isRequired,
|
||||||
notificationCount: PropTypes.oneOfType([
|
notificationCount: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
|
||||||
PropTypes.string,
|
|
||||||
PropTypes.number,
|
|
||||||
]).isRequired,
|
|
||||||
isAlert: PropTypes.bool.isRequired,
|
isAlert: PropTypes.bool.isRequired,
|
||||||
options: PropTypes.node,
|
options: PropTypes.node,
|
||||||
onClick: PropTypes.func.isRequired,
|
onClick: PropTypes.func.isRequired,
|
||||||
|
|
|
@ -2,46 +2,35 @@ import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import './RoomTile.scss';
|
import './RoomTile.scss';
|
||||||
|
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
import Avatar from '../../atoms/avatar/Avatar';
|
import Avatar from '../../atoms/avatar/Avatar';
|
||||||
|
|
||||||
function RoomTile({
|
function RoomTile({ avatarSrc, name, id, inviterName, memberCount, desc, options }) {
|
||||||
avatarSrc, name, id,
|
|
||||||
inviterName, memberCount, desc, options,
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="room-tile">
|
<div className="room-tile">
|
||||||
<div className="room-tile__avatar">
|
<div className="room-tile__avatar">
|
||||||
<Avatar
|
<Avatar imageSrc={avatarSrc} bgColor={colorMXID(id)} text={name} />
|
||||||
imageSrc={avatarSrc}
|
|
||||||
bgColor={colorMXID(id)}
|
|
||||||
text={name}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="room-tile__content">
|
<div className="room-tile__content">
|
||||||
<Text variant="s1">{twemojify(name)}</Text>
|
<Text variant="s1">{name}</Text>
|
||||||
<Text variant="b3">
|
<Text variant="b3">
|
||||||
{
|
{inviterName !== null
|
||||||
inviterName !== null
|
? `Invited by ${inviterName} to ${id}${
|
||||||
? `Invited by ${inviterName} to ${id}${memberCount === null ? '' : ` • ${memberCount} members`}`
|
memberCount === null ? '' : ` • ${memberCount} members`
|
||||||
: id + (memberCount === null ? '' : ` • ${memberCount} members`)
|
}`
|
||||||
}
|
: id + (memberCount === null ? '' : ` • ${memberCount} members`)}
|
||||||
</Text>
|
</Text>
|
||||||
{
|
{desc !== null && typeof desc === 'string' ? (
|
||||||
desc !== null && (typeof desc === 'string')
|
<Text className="room-tile__content__desc" variant="b2">
|
||||||
? <Text className="room-tile__content__desc" variant="b2">{twemojify(desc, undefined, true)}</Text>
|
{desc}
|
||||||
: desc
|
</Text>
|
||||||
}
|
) : (
|
||||||
|
desc
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{ options !== null && (
|
{options !== null && <div className="room-tile__options">{options}</div>}
|
||||||
<div className="room-tile__options">
|
|
||||||
{options}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -58,10 +47,7 @@ RoomTile.propTypes = {
|
||||||
name: PropTypes.string.isRequired,
|
name: PropTypes.string.isRequired,
|
||||||
id: PropTypes.string.isRequired,
|
id: PropTypes.string.isRequired,
|
||||||
inviterName: PropTypes.string,
|
inviterName: PropTypes.string,
|
||||||
memberCount: PropTypes.oneOfType([
|
memberCount: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||||
PropTypes.string,
|
|
||||||
PropTypes.number,
|
|
||||||
]),
|
|
||||||
desc: PropTypes.node,
|
desc: PropTypes.node,
|
||||||
options: PropTypes.node,
|
options: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,55 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './SidebarAvatar.scss';
|
|
||||||
|
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import Tooltip from '../../atoms/tooltip/Tooltip';
|
|
||||||
import { blurOnBubbling } from '../../atoms/button/script';
|
|
||||||
|
|
||||||
const SidebarAvatar = React.forwardRef(({
|
|
||||||
className, tooltip, active, onClick,
|
|
||||||
onContextMenu, avatar, notificationBadge,
|
|
||||||
}, ref) => {
|
|
||||||
const classes = ['sidebar-avatar'];
|
|
||||||
if (active) classes.push('sidebar-avatar--active');
|
|
||||||
if (className) classes.push(className);
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
content={<Text variant="b1">{twemojify(tooltip)}</Text>}
|
|
||||||
placement="right"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
ref={ref}
|
|
||||||
className={classes.join(' ')}
|
|
||||||
type="button"
|
|
||||||
onMouseUp={(e) => blurOnBubbling(e, '.sidebar-avatar')}
|
|
||||||
onClick={onClick}
|
|
||||||
onContextMenu={onContextMenu}
|
|
||||||
>
|
|
||||||
{avatar}
|
|
||||||
{notificationBadge}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
SidebarAvatar.defaultProps = {
|
|
||||||
className: null,
|
|
||||||
active: false,
|
|
||||||
onClick: null,
|
|
||||||
onContextMenu: null,
|
|
||||||
notificationBadge: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
SidebarAvatar.propTypes = {
|
|
||||||
className: PropTypes.string,
|
|
||||||
tooltip: PropTypes.string.isRequired,
|
|
||||||
active: PropTypes.bool,
|
|
||||||
onClick: PropTypes.func,
|
|
||||||
onContextMenu: PropTypes.func,
|
|
||||||
avatar: PropTypes.node.isRequired,
|
|
||||||
notificationBadge: PropTypes.node,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SidebarAvatar;
|
|
|
@ -1,64 +0,0 @@
|
||||||
@use '../../partials/dir';
|
|
||||||
|
|
||||||
.sidebar-avatar {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
& .notification-badge {
|
|
||||||
position: absolute;
|
|
||||||
@include dir.prop(left, unset, 0);
|
|
||||||
@include dir.prop(right, 0, unset);
|
|
||||||
top: 0;
|
|
||||||
box-shadow: 0 0 0 2px var(--bg-surface-low);
|
|
||||||
@include dir.prop(transform, translate(20%, -20%), translate(-20%, -20%));
|
|
||||||
|
|
||||||
margin: 0 !important;
|
|
||||||
}
|
|
||||||
& .avatar-container,
|
|
||||||
& .notification-badge {
|
|
||||||
transition: transform 200ms var(--fluid-push);
|
|
||||||
}
|
|
||||||
&:hover .avatar-container {
|
|
||||||
@include dir.prop(transform, translateX(4px), translateX(-4px));
|
|
||||||
}
|
|
||||||
&:hover .notification-badge {
|
|
||||||
--ltr: translate(calc(20% + 4px), -20%);
|
|
||||||
--rtl: translate(calc(-20% - 4px), -20%);
|
|
||||||
@include dir.prop(transform, var(--ltr), var(--rtl));
|
|
||||||
}
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
&:active .avatar-container {
|
|
||||||
box-shadow: var(--bs-surface-outline);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover::before,
|
|
||||||
&:focus::before,
|
|
||||||
&--active::before {
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
@include dir.prop(left, -11px, unset);
|
|
||||||
@include dir.prop(right, unset, -11px);
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
|
|
||||||
width: 3px;
|
|
||||||
height: 12px;
|
|
||||||
background-color: var(--tc-surface-high);
|
|
||||||
@include dir.prop(border-radius, 0 4px 4px 0, 4px 0 0 4px);
|
|
||||||
transition: height 200ms linear;
|
|
||||||
}
|
|
||||||
&--active:hover::before,
|
|
||||||
&--active:focus::before,
|
|
||||||
&--active::before {
|
|
||||||
height: 28px;
|
|
||||||
}
|
|
||||||
&--active .avatar-container {
|
|
||||||
background-color: var(--bg-surface);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +1,8 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
import './SpaceAddExisting.scss';
|
import './SpaceAddExisting.scss';
|
||||||
|
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import cons from '../../../client/state/cons';
|
import cons from '../../../client/state/cons';
|
||||||
import navigation from '../../../client/state/navigation';
|
import navigation from '../../../client/state/navigation';
|
||||||
|
@ -24,6 +23,9 @@ import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||||
|
|
||||||
import { useStore } from '../../hooks/useStore';
|
import { useStore } from '../../hooks/useStore';
|
||||||
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
|
import { useDirects, useRooms, useSpaces } from '../../state/hooks/roomList';
|
||||||
|
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||||
|
|
||||||
function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
|
function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
|
||||||
const mountStore = useStore(roomId);
|
const mountStore = useStore(roomId);
|
||||||
|
@ -33,7 +35,10 @@ function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
|
||||||
const [selected, setSelected] = useState([]);
|
const [selected, setSelected] = useState([]);
|
||||||
const [searchIds, setSearchIds] = useState(null);
|
const [searchIds, setSearchIds] = useState(null);
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
const { spaces, rooms, directs, roomIdToParents } = initMatrix.roomList;
|
const roomIdToParents = useAtomValue(roomToParentsAtom);
|
||||||
|
const spaces = useSpaces(mx, allRoomsAtom);
|
||||||
|
const rooms = useRooms(mx, allRoomsAtom);
|
||||||
|
const directs = useDirects(mx, allRoomsAtom);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const roomIds = onlySpaces ? [...spaces] : [...rooms, ...directs];
|
const roomIds = onlySpaces ? [...spaces] : [...rooms, ...directs];
|
||||||
|
@ -217,7 +222,7 @@ function SpaceAddExisting() {
|
||||||
className="space-add-existing"
|
className="space-add-existing"
|
||||||
title={
|
title={
|
||||||
<Text variant="s1" weight="medium" primary>
|
<Text variant="s1" weight="medium" primary>
|
||||||
{room && twemojify(room.name)}
|
{room && room.name}
|
||||||
<span style={{ color: 'var(--tc-surface-low)' }}>
|
<span style={{ color: 'var(--tc-surface-low)' }}>
|
||||||
{' '}
|
{' '}
|
||||||
— add existing {data?.spaces ? 'spaces' : 'rooms'}
|
— add existing {data?.spaces ? 'spaces' : 'rooms'}
|
||||||
|
|
|
@ -1,128 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import { openSpaceSettings, openSpaceManage, openInviteUser } from '../../../client/action/navigation';
|
|
||||||
import { markAsRead } from '../../../client/action/notifications';
|
|
||||||
import { leave } from '../../../client/action/room';
|
|
||||||
import {
|
|
||||||
createSpaceShortcut,
|
|
||||||
deleteSpaceShortcut,
|
|
||||||
categorizeSpace,
|
|
||||||
unCategorizeSpace,
|
|
||||||
} from '../../../client/action/accountData';
|
|
||||||
|
|
||||||
import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
|
|
||||||
|
|
||||||
import CategoryIC from '../../../../public/res/ic/outlined/category.svg';
|
|
||||||
import CategoryFilledIC from '../../../../public/res/ic/filled/category.svg';
|
|
||||||
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
|
|
||||||
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
|
|
||||||
import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
|
|
||||||
import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg';
|
|
||||||
import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
|
|
||||||
import PinIC from '../../../../public/res/ic/outlined/pin.svg';
|
|
||||||
import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
|
|
||||||
|
|
||||||
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
|
|
||||||
|
|
||||||
function SpaceOptions({ roomId, afterOptionSelect }) {
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const { roomList } = initMatrix;
|
|
||||||
const room = mx.getRoom(roomId);
|
|
||||||
const canInvite = room?.canInvite(mx.getUserId());
|
|
||||||
const isPinned = initMatrix.accountData.spaceShortcut.has(roomId);
|
|
||||||
const isCategorized = initMatrix.accountData.categorizedSpaces.has(roomId);
|
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
|
||||||
const spaceChildren = roomList.getCategorizedSpaces([roomId]);
|
|
||||||
spaceChildren?.forEach((childIds) => {
|
|
||||||
childIds?.forEach((childId) => {
|
|
||||||
markAsRead(childId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
afterOptionSelect();
|
|
||||||
};
|
|
||||||
const handleInviteClick = () => {
|
|
||||||
openInviteUser(roomId);
|
|
||||||
afterOptionSelect();
|
|
||||||
};
|
|
||||||
const handlePinClick = () => {
|
|
||||||
if (isPinned) deleteSpaceShortcut(roomId);
|
|
||||||
else createSpaceShortcut(roomId);
|
|
||||||
afterOptionSelect();
|
|
||||||
};
|
|
||||||
const handleCategorizeClick = () => {
|
|
||||||
if (isCategorized) unCategorizeSpace(roomId);
|
|
||||||
else categorizeSpace(roomId);
|
|
||||||
afterOptionSelect();
|
|
||||||
};
|
|
||||||
const handleSettingsClick = () => {
|
|
||||||
openSpaceSettings(roomId);
|
|
||||||
afterOptionSelect();
|
|
||||||
};
|
|
||||||
const handleManageRoom = () => {
|
|
||||||
openSpaceManage(roomId);
|
|
||||||
afterOptionSelect();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLeaveClick = async () => {
|
|
||||||
afterOptionSelect();
|
|
||||||
const isConfirmed = await confirmDialog(
|
|
||||||
'Leave space',
|
|
||||||
`Are you sure that you want to leave "${room.name}" space?`,
|
|
||||||
'Leave',
|
|
||||||
'danger',
|
|
||||||
);
|
|
||||||
if (!isConfirmed) return;
|
|
||||||
leave(roomId);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ maxWidth: 'calc(var(--navigation-drawer-width) - var(--sp-normal))' }}>
|
|
||||||
<MenuHeader>{twemojify(`Options for ${initMatrix.matrixClient.getRoom(roomId)?.name}`)}</MenuHeader>
|
|
||||||
<MenuItem iconSrc={TickMarkIC} onClick={handleMarkAsRead}>Mark as read</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
onClick={handleCategorizeClick}
|
|
||||||
iconSrc={isCategorized ? CategoryFilledIC : CategoryIC}
|
|
||||||
>
|
|
||||||
{isCategorized ? 'Uncategorize subspaces' : 'Categorize subspaces'}
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
onClick={handlePinClick}
|
|
||||||
iconSrc={isPinned ? PinFilledIC : PinIC}
|
|
||||||
>
|
|
||||||
{isPinned ? 'Unpin from sidebar' : 'Pin to sidebar'}
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
iconSrc={AddUserIC}
|
|
||||||
onClick={handleInviteClick}
|
|
||||||
disabled={!canInvite}
|
|
||||||
>
|
|
||||||
Invite
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem onClick={handleManageRoom} iconSrc={HashSearchIC}>Manage rooms</MenuItem>
|
|
||||||
<MenuItem onClick={handleSettingsClick} iconSrc={SettingsIC}>Settings</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
variant="danger"
|
|
||||||
onClick={handleLeaveClick}
|
|
||||||
iconSrc={LeaveArrowIC}
|
|
||||||
>
|
|
||||||
Leave
|
|
||||||
</MenuItem>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
SpaceOptions.defaultProps = {
|
|
||||||
afterOptionSelect: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
SpaceOptions.propTypes = {
|
|
||||||
roomId: PropTypes.string.isRequired,
|
|
||||||
afterOptionSelect: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SpaceOptions;
|
|
|
@ -1,41 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './SSOButtons.scss';
|
|
||||||
|
|
||||||
import { createTemporaryClient, startSsoLogin } from '../../../client/action/auth';
|
|
||||||
|
|
||||||
import Button from '../../atoms/button/Button';
|
|
||||||
|
|
||||||
function SSOButtons({ type, identityProviders, baseUrl }) {
|
|
||||||
const tempClient = createTemporaryClient(baseUrl);
|
|
||||||
function handleClick(id) {
|
|
||||||
startSsoLogin(baseUrl, type, id);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="sso-buttons">
|
|
||||||
{identityProviders
|
|
||||||
.sort((idp, idp2) => {
|
|
||||||
if (typeof idp.icon !== 'string') return -1;
|
|
||||||
return idp.name.toLowerCase() > idp2.name.toLowerCase() ? 1 : -1;
|
|
||||||
})
|
|
||||||
.map((idp) => (
|
|
||||||
idp.icon
|
|
||||||
? (
|
|
||||||
<button key={idp.id} type="button" className="sso-btn" onClick={() => handleClick(idp.id)}>
|
|
||||||
<img className="sso-btn__img" src={tempClient.mxcUrlToHttp(idp.icon)} alt={idp.name} />
|
|
||||||
</button>
|
|
||||||
) : <Button key={idp.id} className="sso-btn__text-only" onClick={() => handleClick(idp.id)}>{`Login with ${idp.name}`}</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
SSOButtons.propTypes = {
|
|
||||||
identityProviders: PropTypes.arrayOf(
|
|
||||||
PropTypes.shape({}),
|
|
||||||
).isRequired,
|
|
||||||
baseUrl: PropTypes.string.isRequired,
|
|
||||||
type: PropTypes.oneOf(['sso', 'cas']).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SSOButtons;
|
|
|
@ -1,25 +0,0 @@
|
||||||
.sso-buttons {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sso-btn {
|
|
||||||
margin: var(--sp-tight);
|
|
||||||
display: inline-flex;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&__img {
|
|
||||||
height: var(--av-small);
|
|
||||||
width: var(--av-small);
|
|
||||||
}
|
|
||||||
&__text-only {
|
|
||||||
margin-top: var(--sp-normal);
|
|
||||||
flex-basis: 100%;
|
|
||||||
& .text {
|
|
||||||
color: var(--tc-link);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -2,11 +2,10 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import './CreateRoom.scss';
|
import './CreateRoom.scss';
|
||||||
|
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import cons from '../../../client/state/cons';
|
import cons from '../../../client/state/cons';
|
||||||
import navigation from '../../../client/state/navigation';
|
import navigation from '../../../client/state/navigation';
|
||||||
import { selectRoom, openReusableContextMenu } from '../../../client/action/navigation';
|
import { openReusableContextMenu } from '../../../client/action/navigation';
|
||||||
import * as roomActions from '../../../client/action/room';
|
import * as roomActions from '../../../client/action/room';
|
||||||
import { isRoomAliasAvailable, getIdServer } from '../../../util/matrixUtil';
|
import { isRoomAliasAvailable, getIdServer } from '../../../util/matrixUtil';
|
||||||
import { getEventCords } from '../../../util/common';
|
import { getEventCords } from '../../../util/common';
|
||||||
|
@ -32,12 +31,14 @@ import SpaceLockIC from '../../../../public/res/ic/outlined/space-lock.svg';
|
||||||
import SpaceGlobeIC from '../../../../public/res/ic/outlined/space-globe.svg';
|
import SpaceGlobeIC from '../../../../public/res/ic/outlined/space-globe.svg';
|
||||||
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
|
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
|
||||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||||
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
|
|
||||||
function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
|
function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
|
||||||
const [joinRule, setJoinRule] = useState(parentId ? 'restricted' : 'invite');
|
const [joinRule, setJoinRule] = useState(parentId ? 'restricted' : 'invite');
|
||||||
const [isEncrypted, setIsEncrypted] = useState(true);
|
const [isEncrypted, setIsEncrypted] = useState(true);
|
||||||
const [isCreatingRoom, setIsCreatingRoom] = useState(false);
|
const [isCreatingRoom, setIsCreatingRoom] = useState(false);
|
||||||
const [creatingError, setCreatingError] = useState(null);
|
const [creatingError, setCreatingError] = useState(null);
|
||||||
|
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||||
|
|
||||||
const [isValidAddress, setIsValidAddress] = useState(null);
|
const [isValidAddress, setIsValidAddress] = useState(null);
|
||||||
const [addressValue, setAddressValue] = useState(undefined);
|
const [addressValue, setAddressValue] = useState(undefined);
|
||||||
|
@ -48,25 +49,6 @@ function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
const userHs = getIdServer(mx.getUserId());
|
const userHs = getIdServer(mx.getUserId());
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const { roomList } = initMatrix;
|
|
||||||
const onCreated = (roomId) => {
|
|
||||||
setIsCreatingRoom(false);
|
|
||||||
setCreatingError(null);
|
|
||||||
setIsValidAddress(null);
|
|
||||||
setAddressValue(undefined);
|
|
||||||
|
|
||||||
if (!mx.getRoom(roomId)?.isSpaceRoom()) {
|
|
||||||
selectRoom(roomId);
|
|
||||||
}
|
|
||||||
onRequestClose();
|
|
||||||
};
|
|
||||||
roomList.on(cons.events.roomList.ROOM_CREATED, onCreated);
|
|
||||||
return () => {
|
|
||||||
roomList.removeListener(cons.events.roomList.ROOM_CREATED, onCreated);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit = async (evt) => {
|
const handleSubmit = async (evt) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
const { target } = evt;
|
const { target } = evt;
|
||||||
|
@ -87,16 +69,26 @@ function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
|
||||||
const powerLevel = roleIndex === 1 ? 101 : undefined;
|
const powerLevel = roleIndex === 1 ? 101 : undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await roomActions.createRoom({
|
const data = await roomActions.createRoom({
|
||||||
name,
|
name,
|
||||||
topic,
|
topic,
|
||||||
joinRule,
|
joinRule,
|
||||||
alias: roomAlias,
|
alias: roomAlias,
|
||||||
isEncrypted: (isSpace || joinRule === 'public') ? false : isEncrypted,
|
isEncrypted: isSpace || joinRule === 'public' ? false : isEncrypted,
|
||||||
powerLevel,
|
powerLevel,
|
||||||
isSpace,
|
isSpace,
|
||||||
parentId,
|
parentId,
|
||||||
});
|
});
|
||||||
|
setIsCreatingRoom(false);
|
||||||
|
setCreatingError(null);
|
||||||
|
setIsValidAddress(null);
|
||||||
|
setAddressValue(undefined);
|
||||||
|
onRequestClose();
|
||||||
|
if (isSpace) {
|
||||||
|
navigateSpace(data.room_id);
|
||||||
|
} else {
|
||||||
|
navigateRoom(data.room_id);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.message === 'M_UNKNOWN: Invalid characters in room alias') {
|
if (e.message === 'M_UNKNOWN: Invalid characters in room alias') {
|
||||||
setCreatingError('ERROR: Invalid characters in address');
|
setCreatingError('ERROR: Invalid characters in address');
|
||||||
|
@ -131,36 +123,35 @@ function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
|
||||||
|
|
||||||
const joinRules = ['invite', 'restricted', 'public'];
|
const joinRules = ['invite', 'restricted', 'public'];
|
||||||
const joinRuleShortText = ['Private', 'Restricted', 'Public'];
|
const joinRuleShortText = ['Private', 'Restricted', 'Public'];
|
||||||
const joinRuleText = ['Private (invite only)', 'Restricted (space member can join)', 'Public (anyone can join)'];
|
const joinRuleText = [
|
||||||
|
'Private (invite only)',
|
||||||
|
'Restricted (space member can join)',
|
||||||
|
'Public (anyone can join)',
|
||||||
|
];
|
||||||
const jrRoomIC = [HashLockIC, HashIC, HashGlobeIC];
|
const jrRoomIC = [HashLockIC, HashIC, HashGlobeIC];
|
||||||
const jrSpaceIC = [SpaceLockIC, SpaceIC, SpaceGlobeIC];
|
const jrSpaceIC = [SpaceLockIC, SpaceIC, SpaceGlobeIC];
|
||||||
const handleJoinRule = (evt) => {
|
const handleJoinRule = (evt) => {
|
||||||
openReusableContextMenu(
|
openReusableContextMenu('bottom', getEventCords(evt, '.btn-surface'), (closeMenu) => (
|
||||||
'bottom',
|
<>
|
||||||
getEventCords(evt, '.btn-surface'),
|
<MenuHeader>Visibility (who can join)</MenuHeader>
|
||||||
(closeMenu) => (
|
{joinRules.map((rule) => (
|
||||||
<>
|
<MenuItem
|
||||||
<MenuHeader>Visibility (who can join)</MenuHeader>
|
key={rule}
|
||||||
{
|
variant={rule === joinRule ? 'positive' : 'surface'}
|
||||||
joinRules.map((rule) => (
|
iconSrc={
|
||||||
<MenuItem
|
isSpace ? jrSpaceIC[joinRules.indexOf(rule)] : jrRoomIC[joinRules.indexOf(rule)]
|
||||||
key={rule}
|
}
|
||||||
variant={rule === joinRule ? 'positive' : 'surface'}
|
onClick={() => {
|
||||||
iconSrc={
|
closeMenu();
|
||||||
isSpace
|
setJoinRule(rule);
|
||||||
? jrSpaceIC[joinRules.indexOf(rule)]
|
}}
|
||||||
: jrRoomIC[joinRules.indexOf(rule)]
|
disabled={!parentId && rule === 'restricted'}
|
||||||
}
|
>
|
||||||
onClick={() => { closeMenu(); setJoinRule(rule); }}
|
{joinRuleText[joinRules.indexOf(rule)]}
|
||||||
disabled={!parentId && rule === 'restricted'}
|
</MenuItem>
|
||||||
>
|
))}
|
||||||
{ joinRuleText[joinRules.indexOf(rule)] }
|
</>
|
||||||
</MenuItem>
|
));
|
||||||
))
|
|
||||||
}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -168,50 +159,64 @@ function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
|
||||||
<form className="create-room__form" onSubmit={handleSubmit}>
|
<form className="create-room__form" onSubmit={handleSubmit}>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Visibility"
|
title="Visibility"
|
||||||
options={(
|
options={
|
||||||
<Button onClick={handleJoinRule} iconSrc={ChevronBottomIC}>
|
<Button onClick={handleJoinRule} iconSrc={ChevronBottomIC}>
|
||||||
{joinRuleShortText[joinRules.indexOf(joinRule)]}
|
{joinRuleShortText[joinRules.indexOf(joinRule)]}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
}
|
||||||
content={<Text variant="b3">{`Select who can join this ${isSpace ? 'space' : 'room'}.`}</Text>}
|
content={
|
||||||
|
<Text variant="b3">{`Select who can join this ${isSpace ? 'space' : 'room'}.`}</Text>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{joinRule === 'public' && (
|
{joinRule === 'public' && (
|
||||||
<div>
|
<div>
|
||||||
<Text className="create-room__address__label" variant="b2">{isSpace ? 'Space address' : 'Room address'}</Text>
|
<Text className="create-room__address__label" variant="b2">
|
||||||
|
{isSpace ? 'Space address' : 'Room address'}
|
||||||
|
</Text>
|
||||||
<div className="create-room__address">
|
<div className="create-room__address">
|
||||||
<Text variant="b1">#</Text>
|
<Text variant="b1">#</Text>
|
||||||
<Input
|
<Input
|
||||||
value={addressValue}
|
value={addressValue}
|
||||||
onChange={validateAddress}
|
onChange={validateAddress}
|
||||||
state={(isValidAddress === false) ? 'error' : 'normal'}
|
state={isValidAddress === false ? 'error' : 'normal'}
|
||||||
forwardRef={addressRef}
|
forwardRef={addressRef}
|
||||||
placeholder="my_address"
|
placeholder="my_address"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Text variant="b1">{`:${userHs}`}</Text>
|
<Text variant="b1">{`:${userHs}`}</Text>
|
||||||
</div>
|
</div>
|
||||||
{isValidAddress === false && <Text className="create-room__address__tip" variant="b3"><span style={{ color: 'var(--bg-danger)' }}>{`#${addressValue}:${userHs} is already in use`}</span></Text>}
|
{isValidAddress === false && (
|
||||||
|
<Text className="create-room__address__tip" variant="b3">
|
||||||
|
<span
|
||||||
|
style={{ color: 'var(--bg-danger)' }}
|
||||||
|
>{`#${addressValue}:${userHs} is already in use`}</span>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isSpace && joinRule !== 'public' && (
|
{!isSpace && joinRule !== 'public' && (
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Enable end-to-end encryption"
|
title="Enable end-to-end encryption"
|
||||||
options={<Toggle isActive={isEncrypted} onToggle={setIsEncrypted} />}
|
options={<Toggle isActive={isEncrypted} onToggle={setIsEncrypted} />}
|
||||||
content={<Text variant="b3">You can’t disable this later. Bridges & most bots won’t work yet.</Text>}
|
content={
|
||||||
|
<Text variant="b3">
|
||||||
|
You can’t disable this later. Bridges & most bots won’t work yet.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Select your role"
|
title="Select your role"
|
||||||
options={(
|
options={
|
||||||
<SegmentControl
|
<SegmentControl
|
||||||
selected={roleIndex}
|
selected={roleIndex}
|
||||||
segments={[{ text: 'Admin' }, { text: 'Founder' }]}
|
segments={[{ text: 'Admin' }, { text: 'Founder' }]}
|
||||||
onSelect={setRoleIndex}
|
onSelect={setRoleIndex}
|
||||||
/>
|
/>
|
||||||
)}
|
}
|
||||||
content={(
|
content={
|
||||||
<Text variant="b3">Selecting Admin sets 100 power level whereas Founder sets 101.</Text>
|
<Text variant="b3">Selecting Admin sets 100 power level whereas Founder sets 101.</Text>
|
||||||
)}
|
}
|
||||||
/>
|
/>
|
||||||
<Input name="topic" minHeight={174} resizable label="Topic (optional)" />
|
<Input name="topic" minHeight={174} resizable label="Topic (optional)" />
|
||||||
<div className="create-room__name-wrapper">
|
<div className="create-room__name-wrapper">
|
||||||
|
@ -231,7 +236,11 @@ function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
|
||||||
<Text>{`Creating ${isSpace ? 'space' : 'room'}...`}</Text>
|
<Text>{`Creating ${isSpace ? 'space' : 'room'}...`}</Text>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{typeof creatingError === 'string' && <Text className="create-room__error" variant="b3">{creatingError}</Text>}
|
{typeof creatingError === 'string' && (
|
||||||
|
<Text className="create-room__error" variant="b3">
|
||||||
|
{creatingError}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -275,27 +284,22 @@ function CreateRoom() {
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
isOpen={create !== null}
|
isOpen={create !== null}
|
||||||
title={(
|
title={
|
||||||
<Text variant="s1" weight="medium" primary>
|
<Text variant="s1" weight="medium" primary>
|
||||||
{parentId ? twemojify(room.name) : 'Home'}
|
{parentId ? room.name : 'Home'}
|
||||||
<span style={{ color: 'var(--tc-surface-low)' }}>
|
<span style={{ color: 'var(--tc-surface-low)' }}>
|
||||||
{` — create ${isSpace ? 'space' : 'room'}`}
|
{` — create ${isSpace ? 'space' : 'room'}`}
|
||||||
</span>
|
</span>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
}
|
||||||
contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
|
contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
|
||||||
onRequestClose={onRequestClose}
|
onRequestClose={onRequestClose}
|
||||||
>
|
>
|
||||||
{
|
{create ? (
|
||||||
create
|
<CreateRoomContent isSpace={isSpace} parentId={parentId} onRequestClose={onRequestClose} />
|
||||||
? (
|
) : (
|
||||||
<CreateRoomContent
|
<div />
|
||||||
isSpace={isSpace}
|
)}
|
||||||
parentId={parentId}
|
|
||||||
onRequestClose={onRequestClose}
|
|
||||||
/>
|
|
||||||
) : <div />
|
|
||||||
}
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,356 +0,0 @@
|
||||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
|
||||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './EmojiBoard.scss';
|
|
||||||
|
|
||||||
import parse from 'html-react-parser';
|
|
||||||
import twemoji from 'twemoji';
|
|
||||||
import { emojiGroups, emojis } from './emoji';
|
|
||||||
import { getRelevantPacks } from './custom-emoji';
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import navigation from '../../../client/state/navigation';
|
|
||||||
import AsyncSearch from '../../../util/AsyncSearch';
|
|
||||||
import { addRecentEmoji, getRecentEmojis } from './recent';
|
|
||||||
import { TWEMOJI_BASE_URL } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
|
||||||
import IconButton from '../../atoms/button/IconButton';
|
|
||||||
import Input from '../../atoms/input/Input';
|
|
||||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
|
||||||
|
|
||||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
|
||||||
import RecentClockIC from '../../../../public/res/ic/outlined/recent-clock.svg';
|
|
||||||
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
|
|
||||||
import DogIC from '../../../../public/res/ic/outlined/dog.svg';
|
|
||||||
import CupIC from '../../../../public/res/ic/outlined/cup.svg';
|
|
||||||
import BallIC from '../../../../public/res/ic/outlined/ball.svg';
|
|
||||||
import PhotoIC from '../../../../public/res/ic/outlined/photo.svg';
|
|
||||||
import BulbIC from '../../../../public/res/ic/outlined/bulb.svg';
|
|
||||||
import PeaceIC from '../../../../public/res/ic/outlined/peace.svg';
|
|
||||||
import FlagIC from '../../../../public/res/ic/outlined/flag.svg';
|
|
||||||
|
|
||||||
const ROW_EMOJIS_COUNT = 7;
|
|
||||||
|
|
||||||
const EmojiGroup = React.memo(({ name, groupEmojis }) => {
|
|
||||||
function getEmojiBoard() {
|
|
||||||
const emojiBoard = [];
|
|
||||||
const totalEmojis = groupEmojis.length;
|
|
||||||
|
|
||||||
for (let r = 0; r < totalEmojis; r += ROW_EMOJIS_COUNT) {
|
|
||||||
const emojiRow = [];
|
|
||||||
for (let c = r; c < r + ROW_EMOJIS_COUNT; c += 1) {
|
|
||||||
const emojiIndex = c;
|
|
||||||
if (emojiIndex >= totalEmojis) break;
|
|
||||||
const emoji = groupEmojis[emojiIndex];
|
|
||||||
emojiRow.push(
|
|
||||||
<span key={emojiIndex}>
|
|
||||||
{emoji.hexcode ? (
|
|
||||||
// This is a unicode emoji, and should be rendered with twemoji
|
|
||||||
parse(
|
|
||||||
twemoji.parse(emoji.unicode, {
|
|
||||||
attributes: () => ({
|
|
||||||
unicode: emoji.unicode,
|
|
||||||
shortcodes: emoji.shortcodes?.toString(),
|
|
||||||
hexcode: emoji.hexcode,
|
|
||||||
loading: 'lazy',
|
|
||||||
}),
|
|
||||||
base: TWEMOJI_BASE_URL,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
// This is a custom emoji, and should be render as an mxc
|
|
||||||
<img
|
|
||||||
className="emoji"
|
|
||||||
draggable="false"
|
|
||||||
loading="lazy"
|
|
||||||
alt={emoji.shortcode}
|
|
||||||
unicode={`:${emoji.shortcode}:`}
|
|
||||||
shortcodes={emoji.shortcode}
|
|
||||||
src={initMatrix.matrixClient.mxcUrlToHttp(emoji.mxc)}
|
|
||||||
data-mx-emoticon={emoji.mxc}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
emojiBoard.push(
|
|
||||||
<div key={r} className="emoji-row">
|
|
||||||
{emojiRow}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return emojiBoard;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="emoji-group">
|
|
||||||
<Text className="emoji-group__header" variant="b2" weight="bold">
|
|
||||||
{name}
|
|
||||||
</Text>
|
|
||||||
{groupEmojis.length !== 0 && <div className="emoji-set noselect">{getEmojiBoard()}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
EmojiGroup.propTypes = {
|
|
||||||
name: PropTypes.string.isRequired,
|
|
||||||
groupEmojis: PropTypes.arrayOf(
|
|
||||||
PropTypes.shape({
|
|
||||||
length: PropTypes.number,
|
|
||||||
unicode: PropTypes.string,
|
|
||||||
hexcode: PropTypes.string,
|
|
||||||
mxc: PropTypes.string,
|
|
||||||
shortcode: PropTypes.string,
|
|
||||||
shortcodes: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
|
|
||||||
})
|
|
||||||
).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
const asyncSearch = new AsyncSearch();
|
|
||||||
asyncSearch.setup(emojis, { keys: ['shortcode'], isContain: true, limit: 40 });
|
|
||||||
function SearchedEmoji() {
|
|
||||||
const [searchedEmojis, setSearchedEmojis] = useState(null);
|
|
||||||
|
|
||||||
function handleSearchEmoji(resultEmojis, term) {
|
|
||||||
if (term === '' || resultEmojis.length === 0) {
|
|
||||||
if (term === '') setSearchedEmojis(null);
|
|
||||||
else setSearchedEmojis({ emojis: [] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSearchedEmojis({ emojis: resultEmojis });
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
asyncSearch.on(asyncSearch.RESULT_SENT, handleSearchEmoji);
|
|
||||||
return () => {
|
|
||||||
asyncSearch.removeListener(asyncSearch.RESULT_SENT, handleSearchEmoji);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (searchedEmojis === null) return false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EmojiGroup
|
|
||||||
key="-1"
|
|
||||||
name={searchedEmojis.emojis.length === 0 ? 'No search result found' : 'Search results'}
|
|
||||||
groupEmojis={searchedEmojis.emojis}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmojiBoard({ onSelect, searchRef }) {
|
|
||||||
const scrollEmojisRef = useRef(null);
|
|
||||||
const emojiInfo = useRef(null);
|
|
||||||
|
|
||||||
function isTargetNotEmoji(target) {
|
|
||||||
return target.classList.contains('emoji') === false;
|
|
||||||
}
|
|
||||||
function getEmojiDataFromTarget(target) {
|
|
||||||
const unicode = target.getAttribute('unicode');
|
|
||||||
const hexcode = target.getAttribute('hexcode');
|
|
||||||
const mxc = target.getAttribute('data-mx-emoticon');
|
|
||||||
let shortcodes = target.getAttribute('shortcodes');
|
|
||||||
if (typeof shortcodes === 'undefined') shortcodes = undefined;
|
|
||||||
else shortcodes = shortcodes.split(',');
|
|
||||||
return {
|
|
||||||
unicode,
|
|
||||||
hexcode,
|
|
||||||
shortcodes,
|
|
||||||
mxc,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectEmoji(e) {
|
|
||||||
if (isTargetNotEmoji(e.target)) return;
|
|
||||||
|
|
||||||
const emoji = getEmojiDataFromTarget(e.target);
|
|
||||||
onSelect(emoji);
|
|
||||||
if (emoji.hexcode) addRecentEmoji(emoji.unicode);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setEmojiInfo(emoji) {
|
|
||||||
const infoEmoji = emojiInfo.current.firstElementChild.firstElementChild;
|
|
||||||
const infoShortcode = emojiInfo.current.lastElementChild;
|
|
||||||
|
|
||||||
infoEmoji.src = emoji.src;
|
|
||||||
infoEmoji.alt = emoji.unicode;
|
|
||||||
infoShortcode.textContent = `:${emoji.shortcode}:`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hoverEmoji(e) {
|
|
||||||
if (isTargetNotEmoji(e.target)) return;
|
|
||||||
|
|
||||||
const emoji = e.target;
|
|
||||||
const { shortcodes, unicode } = getEmojiDataFromTarget(emoji);
|
|
||||||
const { src } = e.target;
|
|
||||||
|
|
||||||
if (typeof shortcodes === 'undefined') {
|
|
||||||
searchRef.current.placeholder = 'Search';
|
|
||||||
setEmojiInfo({
|
|
||||||
unicode: '🙂',
|
|
||||||
shortcode: 'slight_smile',
|
|
||||||
src: 'https://twemoji.maxcdn.com/v/13.1.0/72x72/1f642.png',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (searchRef.current.placeholder === shortcodes[0]) return;
|
|
||||||
searchRef.current.setAttribute('placeholder', shortcodes[0]);
|
|
||||||
setEmojiInfo({ shortcode: shortcodes[0], src, unicode });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSearchChange() {
|
|
||||||
const term = searchRef.current.value;
|
|
||||||
asyncSearch.search(term);
|
|
||||||
scrollEmojisRef.current.scrollTop = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [availableEmojis, setAvailableEmojis] = useState([]);
|
|
||||||
const [recentEmojis, setRecentEmojis] = useState([]);
|
|
||||||
|
|
||||||
const recentOffset = recentEmojis.length > 0 ? 1 : 0;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const updateAvailableEmoji = (selectedRoomId) => {
|
|
||||||
if (!selectedRoomId) {
|
|
||||||
setAvailableEmojis([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const room = mx.getRoom(selectedRoomId);
|
|
||||||
const parentIds = initMatrix.roomList.getAllParentSpaces(room.roomId);
|
|
||||||
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
|
|
||||||
if (room) {
|
|
||||||
const packs = getRelevantPacks(room.client, [room, ...parentRooms]).filter(
|
|
||||||
(pack) => pack.getEmojis().length !== 0
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set an index for each pack so that we know where to jump when the user uses the nav
|
|
||||||
for (let i = 0; i < packs.length; i += 1) {
|
|
||||||
packs[i].packIndex = i;
|
|
||||||
}
|
|
||||||
setAvailableEmojis(packs);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onOpen = () => {
|
|
||||||
searchRef.current.value = '';
|
|
||||||
handleSearchChange();
|
|
||||||
|
|
||||||
// only update when board is getting opened to prevent shifting UI
|
|
||||||
setRecentEmojis(getRecentEmojis(3 * ROW_EMOJIS_COUNT));
|
|
||||||
};
|
|
||||||
|
|
||||||
navigation.on(cons.events.navigation.ROOM_SELECTED, updateAvailableEmoji);
|
|
||||||
navigation.on(cons.events.navigation.EMOJIBOARD_OPENED, onOpen);
|
|
||||||
return () => {
|
|
||||||
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, updateAvailableEmoji);
|
|
||||||
navigation.removeListener(cons.events.navigation.EMOJIBOARD_OPENED, onOpen);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function openGroup(groupOrder) {
|
|
||||||
let tabIndex = groupOrder;
|
|
||||||
const $emojiContent = scrollEmojisRef.current.firstElementChild;
|
|
||||||
const groupCount = $emojiContent.childElementCount;
|
|
||||||
if (groupCount > emojiGroups.length) {
|
|
||||||
tabIndex += groupCount - emojiGroups.length - availableEmojis.length - recentOffset;
|
|
||||||
}
|
|
||||||
$emojiContent.children[tabIndex].scrollIntoView();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div id="emoji-board" className="emoji-board">
|
|
||||||
<ScrollView invisible>
|
|
||||||
<div className="emoji-board__nav">
|
|
||||||
{recentEmojis.length > 0 && (
|
|
||||||
<IconButton
|
|
||||||
onClick={() => openGroup(0)}
|
|
||||||
src={RecentClockIC}
|
|
||||||
tooltip="Recent"
|
|
||||||
tooltipPlacement="left"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="emoji-board__nav-custom">
|
|
||||||
{availableEmojis.map((pack) => {
|
|
||||||
const src = initMatrix.matrixClient.mxcUrlToHttp(
|
|
||||||
pack.avatarUrl ?? pack.getEmojis()[0].mxc
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<IconButton
|
|
||||||
onClick={() => openGroup(recentOffset + pack.packIndex)}
|
|
||||||
src={src}
|
|
||||||
key={pack.packIndex}
|
|
||||||
tooltip={pack.displayName ?? 'Unknown'}
|
|
||||||
tooltipPlacement="left"
|
|
||||||
isImage
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="emoji-board__nav-twemoji">
|
|
||||||
{[
|
|
||||||
[0, EmojiIC, 'Smilies'],
|
|
||||||
[1, DogIC, 'Animals'],
|
|
||||||
[2, CupIC, 'Food'],
|
|
||||||
[3, BallIC, 'Activities'],
|
|
||||||
[4, PhotoIC, 'Travel'],
|
|
||||||
[5, BulbIC, 'Objects'],
|
|
||||||
[6, PeaceIC, 'Symbols'],
|
|
||||||
[7, FlagIC, 'Flags'],
|
|
||||||
].map(([indx, ico, name]) => (
|
|
||||||
<IconButton
|
|
||||||
onClick={() => openGroup(recentOffset + availableEmojis.length + indx)}
|
|
||||||
key={indx}
|
|
||||||
src={ico}
|
|
||||||
tooltip={name}
|
|
||||||
tooltipPlacement="left"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ScrollView>
|
|
||||||
<div className="emoji-board__content">
|
|
||||||
<div className="emoji-board__content__search">
|
|
||||||
<RawIcon size="small" src={SearchIC} />
|
|
||||||
<Input onChange={handleSearchChange} forwardRef={searchRef} placeholder="Search" />
|
|
||||||
</div>
|
|
||||||
<div className="emoji-board__content__emojis">
|
|
||||||
<ScrollView ref={scrollEmojisRef} autoHide>
|
|
||||||
<div onMouseMove={hoverEmoji} onClick={selectEmoji}>
|
|
||||||
<SearchedEmoji />
|
|
||||||
{recentEmojis.length > 0 && (
|
|
||||||
<EmojiGroup name="Recently used" groupEmojis={recentEmojis} />
|
|
||||||
)}
|
|
||||||
{availableEmojis.map((pack) => (
|
|
||||||
<EmojiGroup
|
|
||||||
name={pack.displayName ?? 'Unknown'}
|
|
||||||
key={pack.packIndex}
|
|
||||||
groupEmojis={pack.getEmojis()}
|
|
||||||
className="custom-emoji-group"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{emojiGroups.map((group) => (
|
|
||||||
<EmojiGroup key={group.name} name={group.name} groupEmojis={group.emojis} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollView>
|
|
||||||
</div>
|
|
||||||
<div ref={emojiInfo} className="emoji-board__content__info">
|
|
||||||
<div>{parse(twemoji.parse('🙂', { base: TWEMOJI_BASE_URL }))}</div>
|
|
||||||
<Text>:slight_smile:</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
EmojiBoard.propTypes = {
|
|
||||||
onSelect: PropTypes.func.isRequired,
|
|
||||||
searchRef: PropTypes.shape({}).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EmojiBoard;
|
|
|
@ -1,137 +0,0 @@
|
||||||
@use '../../partials/flex';
|
|
||||||
@use '../../partials/text';
|
|
||||||
@use '../../partials/dir';
|
|
||||||
|
|
||||||
.emoji-board {
|
|
||||||
--emoji-board-height: 390px;
|
|
||||||
--emoji-board-width: 286px;
|
|
||||||
display: flex;
|
|
||||||
max-width: 90vw;
|
|
||||||
max-height: 90vh;
|
|
||||||
|
|
||||||
&__content {
|
|
||||||
@extend .cp-fx__item-one;
|
|
||||||
@extend .cp-fx__column;
|
|
||||||
height: var(--emoji-board-height);
|
|
||||||
width: var(--emoji-board-width);
|
|
||||||
}
|
|
||||||
& > .scrollbar {
|
|
||||||
width: initial;
|
|
||||||
height: var(--emoji-board-height);
|
|
||||||
}
|
|
||||||
&__nav {
|
|
||||||
@extend .cp-fx__column;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
min-height: 100%;
|
|
||||||
padding: 4px 6px;
|
|
||||||
@include dir.side(border, none, 1px solid var(--bg-surface-border));
|
|
||||||
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
& .ic-btn-surface {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&__nav-custom,
|
|
||||||
&__nav-twemoji {
|
|
||||||
@extend .cp-fx__column;
|
|
||||||
}
|
|
||||||
&__nav-twemoji {
|
|
||||||
background-color: var(--bg-surface);
|
|
||||||
position: sticky;
|
|
||||||
bottom: -70%;
|
|
||||||
z-index: 999;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji-board__content__search {
|
|
||||||
padding: var(--sp-extra-tight);
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
& .ic-raw {
|
|
||||||
position: absolute;
|
|
||||||
@include dir.prop(left, var(--sp-normal), unset);
|
|
||||||
@include dir.prop(right, unset, var(--sp-normal));
|
|
||||||
top: var(--sp-normal);
|
|
||||||
transform: translateY(1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
& .input-container {
|
|
||||||
& .input {
|
|
||||||
min-width: 100%;
|
|
||||||
width: 0;
|
|
||||||
padding: var(--sp-extra-tight) 36px;
|
|
||||||
border-radius: calc(var(--bo-radius) / 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.emoji-board__content__emojis {
|
|
||||||
@extend .cp-fx__item-one;
|
|
||||||
@extend .cp-fx__column;
|
|
||||||
}
|
|
||||||
.emoji-board__content__info {
|
|
||||||
margin: 0 var(--sp-extra-tight);
|
|
||||||
padding: var(--sp-tight) var(--sp-extra-tight);
|
|
||||||
border-top: 1px solid var(--bg-surface-border);
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
& > div:first-child {
|
|
||||||
line-height: 0;
|
|
||||||
.emoji {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
& > p:last-child {
|
|
||||||
@extend .cp-fx__item-one;
|
|
||||||
@extend .cp-txt__ellipsis;
|
|
||||||
margin: 0 var(--sp-tight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji-row {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji-group {
|
|
||||||
--emoji-padding: 6px;
|
|
||||||
position: relative;
|
|
||||||
margin-bottom: var(--sp-normal);
|
|
||||||
|
|
||||||
&__header {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 99;
|
|
||||||
background-color: var(--bg-surface);
|
|
||||||
|
|
||||||
@include dir.side(margin, var(--sp-extra-tight), 0);
|
|
||||||
padding: var(--sp-extra-tight) var(--sp-ultra-tight);
|
|
||||||
text-transform: uppercase;
|
|
||||||
box-shadow: 0 -4px 0 0 var(--bg-surface);
|
|
||||||
border-bottom: 1px solid var(--bg-surface-border);
|
|
||||||
}
|
|
||||||
& .emoji-set {
|
|
||||||
--left-margin: calc(var(--sp-normal) - var(--emoji-padding));
|
|
||||||
--right-margin: calc(var(--sp-extra-tight) - var(--emoji-padding));
|
|
||||||
margin: var(--sp-extra-tight);
|
|
||||||
@include dir.side(margin, var(--left-margin), var(--right-margin));
|
|
||||||
}
|
|
||||||
& .emoji {
|
|
||||||
max-width: 38px;
|
|
||||||
max-height: 38px;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
object-fit: contain;
|
|
||||||
padding: var(--emoji-padding);
|
|
||||||
cursor: pointer;
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--bg-surface-hover);
|
|
||||||
border-radius: var(--bo-radius);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,78 +0,0 @@
|
||||||
import React, { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import navigation from '../../../client/state/navigation';
|
|
||||||
import settings from '../../../client/state/settings';
|
|
||||||
|
|
||||||
import ContextMenu from '../../atoms/context-menu/ContextMenu';
|
|
||||||
import EmojiBoard from './EmojiBoard';
|
|
||||||
|
|
||||||
let requestCallback = null;
|
|
||||||
let isEmojiBoardVisible = false;
|
|
||||||
function EmojiBoardOpener() {
|
|
||||||
const openerRef = useRef(null);
|
|
||||||
const searchRef = useRef(null);
|
|
||||||
|
|
||||||
function openEmojiBoard(cords, requestEmojiCallback) {
|
|
||||||
if (requestCallback !== null || isEmojiBoardVisible) {
|
|
||||||
requestCallback = null;
|
|
||||||
if (cords.detail === 0) openerRef.current.click();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
openerRef.current.style.transform = `translate(${cords.x}px, ${cords.y}px)`;
|
|
||||||
requestCallback = requestEmojiCallback;
|
|
||||||
openerRef.current.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
function afterEmojiBoardToggle(isVisible) {
|
|
||||||
isEmojiBoardVisible = isVisible;
|
|
||||||
if (isVisible) {
|
|
||||||
if (!settings.isTouchScreenDevice) searchRef.current.focus();
|
|
||||||
} else {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!isEmojiBoardVisible) requestCallback = null;
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addEmoji(emoji) {
|
|
||||||
requestCallback(emoji);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.on(cons.events.navigation.EMOJIBOARD_OPENED, openEmojiBoard);
|
|
||||||
return () => {
|
|
||||||
navigation.removeListener(cons.events.navigation.EMOJIBOARD_OPENED, openEmojiBoard);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextMenu
|
|
||||||
content={(
|
|
||||||
<EmojiBoard onSelect={addEmoji} searchRef={searchRef} />
|
|
||||||
)}
|
|
||||||
afterToggle={afterEmojiBoardToggle}
|
|
||||||
render={(toggleMenu) => (
|
|
||||||
<input
|
|
||||||
ref={openerRef}
|
|
||||||
onClick={toggleMenu}
|
|
||||||
type="button"
|
|
||||||
style={{
|
|
||||||
width: '32px',
|
|
||||||
height: '32px',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
padding: 0,
|
|
||||||
border: 'none',
|
|
||||||
visibility: 'hidden',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default EmojiBoardOpener;
|
|
|
@ -1,8 +1,6 @@
|
||||||
import { emojis } from './emoji';
|
|
||||||
|
|
||||||
// https://github.com/Sorunome/matrix-doc/blob/soru/emotes/proposals/2545-emotes.md
|
// https://github.com/Sorunome/matrix-doc/blob/soru/emotes/proposals/2545-emotes.md
|
||||||
|
|
||||||
class ImagePack {
|
export class ImagePack {
|
||||||
static parsePack(eventId, packContent) {
|
static parsePack(eventId, packContent) {
|
||||||
if (!eventId || typeof packContent?.images !== 'object') {
|
if (!eventId || typeof packContent?.images !== 'object') {
|
||||||
return null;
|
return null;
|
||||||
|
@ -141,127 +139,4 @@ class ImagePack {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGlobalImagePacks(mx) {
|
|
||||||
const globalContent = mx.getAccountData('im.ponies.emote_rooms')?.getContent();
|
|
||||||
if (typeof globalContent !== 'object') return [];
|
|
||||||
|
|
||||||
const { rooms } = globalContent;
|
|
||||||
if (typeof rooms !== 'object') return [];
|
|
||||||
|
|
||||||
const roomIds = Object.keys(rooms);
|
|
||||||
|
|
||||||
const packs = roomIds.flatMap((roomId) => {
|
|
||||||
if (typeof rooms[roomId] !== 'object') return [];
|
|
||||||
const room = mx.getRoom(roomId);
|
|
||||||
if (!room) return [];
|
|
||||||
const stateKeys = Object.keys(rooms[roomId]);
|
|
||||||
|
|
||||||
return stateKeys.map((stateKey) => {
|
|
||||||
const data = room.currentState.getStateEvents('im.ponies.room_emotes', stateKey);
|
|
||||||
const pack = ImagePack.parsePack(data?.getId(), data?.getContent());
|
|
||||||
if (pack) {
|
|
||||||
pack.displayName ??= room.name;
|
|
||||||
pack.avatarUrl ??= room.getMxcAvatarUrl();
|
|
||||||
}
|
|
||||||
return pack;
|
|
||||||
}).filter((pack) => pack !== null);
|
|
||||||
});
|
|
||||||
|
|
||||||
return packs;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserImagePack(mx) {
|
|
||||||
const accountDataEmoji = mx.getAccountData('im.ponies.user_emotes');
|
|
||||||
if (!accountDataEmoji) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userImagePack = ImagePack.parsePack(mx.getUserId(), accountDataEmoji.event.content);
|
|
||||||
if (userImagePack) userImagePack.displayName ??= 'Personal Emoji';
|
|
||||||
return userImagePack;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRoomImagePacks(room) {
|
|
||||||
const dataEvents = room.currentState.getStateEvents('im.ponies.room_emotes');
|
|
||||||
|
|
||||||
return dataEvents
|
|
||||||
.map((data) => {
|
|
||||||
const pack = ImagePack.parsePack(data?.getId(), data?.getContent());
|
|
||||||
if (pack) {
|
|
||||||
pack.displayName ??= room.name;
|
|
||||||
pack.avatarUrl ??= room.getMxcAvatarUrl();
|
|
||||||
}
|
|
||||||
return pack;
|
|
||||||
})
|
|
||||||
.filter((pack) => pack !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {MatrixClient} mx Provide if you want to include user personal/global pack
|
|
||||||
* @param {Room[]} rooms Provide rooms if you want to include rooms pack
|
|
||||||
* @returns {ImagePack[]} packs
|
|
||||||
*/
|
|
||||||
function getRelevantPacks(mx, rooms) {
|
|
||||||
const userPack = mx ? getUserImagePack(mx) : [];
|
|
||||||
const globalPacks = mx ? getGlobalImagePacks(mx) : [];
|
|
||||||
const globalPackIds = new Set(globalPacks.map((pack) => pack.id));
|
|
||||||
const roomsPack = rooms?.flatMap(getRoomImagePacks) ?? [];
|
|
||||||
|
|
||||||
return [].concat(
|
|
||||||
userPack ?? [],
|
|
||||||
globalPacks,
|
|
||||||
roomsPack.filter((pack) => !globalPackIds.has(pack.id)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getShortcodeToEmoji(mx, rooms) {
|
|
||||||
const allEmoji = new Map();
|
|
||||||
|
|
||||||
emojis.forEach((emoji) => {
|
|
||||||
if (Array.isArray(emoji.shortcodes)) {
|
|
||||||
emoji.shortcodes.forEach((shortcode) => {
|
|
||||||
allEmoji.set(shortcode, emoji);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
allEmoji.set(emoji.shortcodes, emoji);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
getRelevantPacks(mx, rooms)
|
|
||||||
.flatMap((pack) => pack.getEmojis())
|
|
||||||
.forEach((emoji) => {
|
|
||||||
allEmoji.set(emoji.shortcode, emoji);
|
|
||||||
});
|
|
||||||
|
|
||||||
return allEmoji;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getShortcodeToCustomEmoji(room) {
|
|
||||||
const allEmoji = new Map();
|
|
||||||
|
|
||||||
getRelevantPacks(room.client, [room])
|
|
||||||
.flatMap((pack) => pack.getEmojis())
|
|
||||||
.forEach((emoji) => {
|
|
||||||
allEmoji.set(emoji.shortcode, emoji);
|
|
||||||
});
|
|
||||||
|
|
||||||
return allEmoji;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEmojiForCompletion(mx, rooms) {
|
|
||||||
const allEmoji = new Map();
|
|
||||||
getRelevantPacks(mx, rooms)
|
|
||||||
.flatMap((pack) => pack.getEmojis())
|
|
||||||
.forEach((emoji) => {
|
|
||||||
allEmoji.set(emoji.shortcode, emoji);
|
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(allEmoji.values()).concat(emojis.filter((e) => !allEmoji.has(e.shortcode)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
ImagePack,
|
|
||||||
getUserImagePack, getGlobalImagePacks, getRoomImagePacks,
|
|
||||||
getShortcodeToEmoji, getShortcodeToCustomEmoji,
|
|
||||||
getRelevantPacks, getEmojiForCompletion,
|
|
||||||
};
|
|
||||||
|
|
|
@ -1,69 +0,0 @@
|
||||||
import emojisData from 'emojibase-data/en/compact.json';
|
|
||||||
import joypixels from 'emojibase-data/en/shortcodes/joypixels.json';
|
|
||||||
import emojibase from 'emojibase-data/en/shortcodes/emojibase.json';
|
|
||||||
|
|
||||||
const emojiGroups = [{
|
|
||||||
name: 'Smileys & people',
|
|
||||||
order: 0,
|
|
||||||
emojis: [],
|
|
||||||
}, {
|
|
||||||
name: 'Animals & nature',
|
|
||||||
order: 1,
|
|
||||||
emojis: [],
|
|
||||||
}, {
|
|
||||||
name: 'Food & drinks',
|
|
||||||
order: 2,
|
|
||||||
emojis: [],
|
|
||||||
}, {
|
|
||||||
name: 'Activity',
|
|
||||||
order: 3,
|
|
||||||
emojis: [],
|
|
||||||
}, {
|
|
||||||
name: 'Travel & places',
|
|
||||||
order: 4,
|
|
||||||
emojis: [],
|
|
||||||
}, {
|
|
||||||
name: 'Objects',
|
|
||||||
order: 5,
|
|
||||||
emojis: [],
|
|
||||||
}, {
|
|
||||||
name: 'Symbols',
|
|
||||||
order: 6,
|
|
||||||
emojis: [],
|
|
||||||
}, {
|
|
||||||
name: 'Flags',
|
|
||||||
order: 7,
|
|
||||||
emojis: [],
|
|
||||||
}];
|
|
||||||
Object.freeze(emojiGroups);
|
|
||||||
|
|
||||||
function addEmoji(emoji, order) {
|
|
||||||
emojiGroups[order].emojis.push(emoji);
|
|
||||||
}
|
|
||||||
function addToGroup(emoji) {
|
|
||||||
if (emoji.group === 0 || emoji.group === 1) addEmoji(emoji, 0);
|
|
||||||
else if (emoji.group === 3) addEmoji(emoji, 1);
|
|
||||||
else if (emoji.group === 4) addEmoji(emoji, 2);
|
|
||||||
else if (emoji.group === 6) addEmoji(emoji, 3);
|
|
||||||
else if (emoji.group === 5) addEmoji(emoji, 4);
|
|
||||||
else if (emoji.group === 7) addEmoji(emoji, 5);
|
|
||||||
else if (emoji.group === 8 || typeof emoji.group === 'undefined') addEmoji(emoji, 6);
|
|
||||||
else if (emoji.group === 9) addEmoji(emoji, 7);
|
|
||||||
}
|
|
||||||
|
|
||||||
const emojis = [];
|
|
||||||
emojisData.forEach((emoji) => {
|
|
||||||
const myShortCodes = joypixels[emoji.hexcode] || emojibase[emoji.hexcode];
|
|
||||||
if (!myShortCodes) return;
|
|
||||||
const em = {
|
|
||||||
...emoji,
|
|
||||||
shortcode: Array.isArray(myShortCodes) ? myShortCodes[0] : myShortCodes,
|
|
||||||
shortcodes: myShortCodes,
|
|
||||||
};
|
|
||||||
addToGroup(em);
|
|
||||||
emojis.push(em);
|
|
||||||
});
|
|
||||||
|
|
||||||
export {
|
|
||||||
emojis, emojiGroups,
|
|
||||||
};
|
|
|
@ -1,36 +0,0 @@
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import { emojis } from './emoji';
|
|
||||||
|
|
||||||
const eventType = 'io.element.recent_emoji';
|
|
||||||
|
|
||||||
function getRecentEmojisRaw() {
|
|
||||||
return initMatrix.matrixClient.getAccountData(eventType)?.getContent().recent_emoji ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRecentEmojis(limit) {
|
|
||||||
const res = [];
|
|
||||||
getRecentEmojisRaw()
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.find(([unicode]) => {
|
|
||||||
const emoji = emojis.find((e) => e.unicode === unicode);
|
|
||||||
if (emoji) return res.push(emoji) >= limit;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addRecentEmoji(unicode) {
|
|
||||||
const recent = getRecentEmojisRaw();
|
|
||||||
const i = recent.findIndex(([u]) => u === unicode);
|
|
||||||
let entry;
|
|
||||||
if (i < 0) {
|
|
||||||
entry = [unicode, 1];
|
|
||||||
} else {
|
|
||||||
[entry] = recent.splice(i, 1);
|
|
||||||
entry[1] += 1;
|
|
||||||
}
|
|
||||||
recent.unshift(entry);
|
|
||||||
initMatrix.matrixClient.setAccountData(eventType, {
|
|
||||||
recent_emoji: recent.slice(0, 100),
|
|
||||||
});
|
|
||||||
}
|
|
|
@ -2,7 +2,6 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import './EmojiVerification.scss';
|
import './EmojiVerification.scss';
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import cons from '../../../client/state/cons';
|
import cons from '../../../client/state/cons';
|
||||||
|
@ -30,8 +29,9 @@ function EmojiVerificationContent({ data, requestClose }) {
|
||||||
|
|
||||||
const beginVerification = async () => {
|
const beginVerification = async () => {
|
||||||
if (
|
if (
|
||||||
isCrossVerified(mx.deviceId)
|
isCrossVerified(mx.deviceId) &&
|
||||||
&& (mx.getCrossSigningId() === null || await mx.crypto.crossSigningInfo.isStoredInKeyCache('self_signing') === false)
|
(mx.getCrossSigningId() === null ||
|
||||||
|
(await mx.crypto.crossSigningInfo.isStoredInKeyCache('self_signing')) === false)
|
||||||
) {
|
) {
|
||||||
if (!hasPrivateKey(getDefaultSSKey())) {
|
if (!hasPrivateKey(getDefaultSSKey())) {
|
||||||
const keyData = await accessSecretStorage('Emoji verification');
|
const keyData = await accessSecretStorage('Emoji verification');
|
||||||
|
@ -106,16 +106,20 @@ function EmojiVerificationContent({ data, requestClose }) {
|
||||||
{sas.sas.emoji.map((emoji, i) => (
|
{sas.sas.emoji.map((emoji, i) => (
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
<div className="emoji-verification__emoji-block" key={`${emoji[1]}-${i}`}>
|
<div className="emoji-verification__emoji-block" key={`${emoji[1]}-${i}`}>
|
||||||
<Text variant="h1">{twemojify(emoji[0])}</Text>
|
<Text variant="h1">{emoji[0]}</Text>
|
||||||
<Text>{emoji[1]}</Text>
|
<Text>{emoji[1]}</Text>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="emoji-verification__buttons">
|
<div className="emoji-verification__buttons">
|
||||||
{process ? renderWait() : (
|
{process ? (
|
||||||
|
renderWait()
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button variant="primary" onClick={sasConfirm}>They match</Button>
|
<Button variant="primary" onClick={sasConfirm}>
|
||||||
<Button onClick={sasMismatch}>{'They don\'t match'}</Button>
|
They match
|
||||||
|
</Button>
|
||||||
|
<Button onClick={sasMismatch}>No match</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -127,9 +131,7 @@ function EmojiVerificationContent({ data, requestClose }) {
|
||||||
return (
|
return (
|
||||||
<div className="emoji-verification__content">
|
<div className="emoji-verification__content">
|
||||||
<Text>Please accept the request from other device.</Text>
|
<Text>Please accept the request from other device.</Text>
|
||||||
<div className="emoji-verification__buttons">
|
<div className="emoji-verification__buttons">{renderWait()}</div>
|
||||||
{renderWait()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -138,11 +140,13 @@ function EmojiVerificationContent({ data, requestClose }) {
|
||||||
<div className="emoji-verification__content">
|
<div className="emoji-verification__content">
|
||||||
<Text>Click accept to start the verification process.</Text>
|
<Text>Click accept to start the verification process.</Text>
|
||||||
<div className="emoji-verification__buttons">
|
<div className="emoji-verification__buttons">
|
||||||
{
|
{process ? (
|
||||||
process
|
renderWait()
|
||||||
? renderWait()
|
) : (
|
||||||
: <Button variant="primary" onClick={beginVerification}>Accept</Button>
|
<Button variant="primary" onClick={beginVerification}>
|
||||||
}
|
Accept
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -180,19 +184,19 @@ function EmojiVerification() {
|
||||||
<Dialog
|
<Dialog
|
||||||
isOpen={data !== null}
|
isOpen={data !== null}
|
||||||
className="emoji-verification"
|
className="emoji-verification"
|
||||||
title={(
|
title={
|
||||||
<Text variant="s1" weight="medium" primary>
|
<Text variant="s1" weight="medium" primary>
|
||||||
Emoji verification
|
Emoji verification
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
}
|
||||||
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
|
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
|
||||||
onRequestClose={requestClose}
|
onRequestClose={requestClose}
|
||||||
>
|
>
|
||||||
{
|
{data !== null ? (
|
||||||
data !== null
|
<EmojiVerificationContent data={data} requestClose={requestClose} />
|
||||||
? <EmojiVerificationContent data={data} requestClose={requestClose} />
|
) : (
|
||||||
: <div />
|
<div />
|
||||||
}
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,145 +0,0 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './InviteList.scss';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import * as roomActions from '../../../client/action/room';
|
|
||||||
import { selectRoom, selectTab } from '../../../client/action/navigation';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import Button from '../../atoms/button/Button';
|
|
||||||
import IconButton from '../../atoms/button/IconButton';
|
|
||||||
import Spinner from '../../atoms/spinner/Spinner';
|
|
||||||
import PopupWindow from '../../molecules/popup-window/PopupWindow';
|
|
||||||
import RoomTile from '../../molecules/room-tile/RoomTile';
|
|
||||||
|
|
||||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
|
||||||
|
|
||||||
function InviteList({ isOpen, onRequestClose }) {
|
|
||||||
const [procInvite, changeProcInvite] = useState(new Set());
|
|
||||||
|
|
||||||
function acceptInvite(roomId, isDM) {
|
|
||||||
procInvite.add(roomId);
|
|
||||||
changeProcInvite(new Set(Array.from(procInvite)));
|
|
||||||
roomActions.join(roomId, isDM);
|
|
||||||
}
|
|
||||||
function rejectInvite(roomId, isDM) {
|
|
||||||
procInvite.add(roomId);
|
|
||||||
changeProcInvite(new Set(Array.from(procInvite)));
|
|
||||||
roomActions.leave(roomId, isDM);
|
|
||||||
}
|
|
||||||
function updateInviteList(roomId) {
|
|
||||||
if (procInvite.has(roomId)) procInvite.delete(roomId);
|
|
||||||
changeProcInvite(new Set(Array.from(procInvite)));
|
|
||||||
|
|
||||||
const rl = initMatrix.roomList;
|
|
||||||
const totalInvites = rl.inviteDirects.size + rl.inviteRooms.size + rl.inviteSpaces.size;
|
|
||||||
const room = initMatrix.matrixClient.getRoom(roomId);
|
|
||||||
const isRejected = room === null || room?.getMyMembership() !== 'join';
|
|
||||||
if (!isRejected) {
|
|
||||||
if (room.isSpaceRoom()) selectTab(roomId);
|
|
||||||
else selectRoom(roomId);
|
|
||||||
onRequestClose();
|
|
||||||
}
|
|
||||||
if (totalInvites === 0) onRequestClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
initMatrix.roomList.on(cons.events.roomList.INVITELIST_UPDATED, updateInviteList);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
initMatrix.roomList.removeListener(cons.events.roomList.INVITELIST_UPDATED, updateInviteList);
|
|
||||||
};
|
|
||||||
}, [procInvite]);
|
|
||||||
|
|
||||||
function renderRoomTile(roomId) {
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const myRoom = mx.getRoom(roomId);
|
|
||||||
if (!myRoom) return null;
|
|
||||||
const roomName = myRoom.name;
|
|
||||||
let roomAlias = myRoom.getCanonicalAlias();
|
|
||||||
if (!roomAlias) roomAlias = myRoom.roomId;
|
|
||||||
const inviterName = myRoom.getMember(mx.getUserId())?.events?.member?.getSender?.() ?? '';
|
|
||||||
return (
|
|
||||||
<RoomTile
|
|
||||||
key={myRoom.roomId}
|
|
||||||
name={roomName}
|
|
||||||
avatarSrc={initMatrix.matrixClient.getRoom(roomId).getAvatarUrl(initMatrix.matrixClient.baseUrl, 42, 42, 'crop')}
|
|
||||||
id={roomAlias}
|
|
||||||
inviterName={inviterName}
|
|
||||||
options={
|
|
||||||
procInvite.has(myRoom.roomId)
|
|
||||||
? (<Spinner size="small" />)
|
|
||||||
: (
|
|
||||||
<div className="invite-btn__container">
|
|
||||||
<Button onClick={() => rejectInvite(myRoom.roomId)}>Reject</Button>
|
|
||||||
<Button onClick={() => acceptInvite(myRoom.roomId)} variant="primary">Accept</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PopupWindow
|
|
||||||
isOpen={isOpen}
|
|
||||||
title="Invites"
|
|
||||||
contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
|
|
||||||
onRequestClose={onRequestClose}
|
|
||||||
>
|
|
||||||
<div className="invites-content">
|
|
||||||
{ initMatrix.roomList.inviteDirects.size !== 0 && (
|
|
||||||
<div className="invites-content__subheading">
|
|
||||||
<Text variant="b3" weight="bold">Direct Messages</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{
|
|
||||||
Array.from(initMatrix.roomList.inviteDirects).map((roomId) => {
|
|
||||||
const myRoom = initMatrix.matrixClient.getRoom(roomId);
|
|
||||||
if (myRoom === null) return null;
|
|
||||||
const roomName = myRoom.name;
|
|
||||||
return (
|
|
||||||
<RoomTile
|
|
||||||
key={myRoom.roomId}
|
|
||||||
name={roomName}
|
|
||||||
id={myRoom.getDMInviter() || roomId}
|
|
||||||
options={
|
|
||||||
procInvite.has(myRoom.roomId)
|
|
||||||
? (<Spinner size="small" />)
|
|
||||||
: (
|
|
||||||
<div className="invite-btn__container">
|
|
||||||
<Button onClick={() => rejectInvite(myRoom.roomId, true)}>Reject</Button>
|
|
||||||
<Button onClick={() => acceptInvite(myRoom.roomId, true)} variant="primary">Accept</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
{ initMatrix.roomList.inviteSpaces.size !== 0 && (
|
|
||||||
<div className="invites-content__subheading">
|
|
||||||
<Text variant="b3" weight="bold">Spaces</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{ Array.from(initMatrix.roomList.inviteSpaces).map(renderRoomTile) }
|
|
||||||
|
|
||||||
{ initMatrix.roomList.inviteRooms.size !== 0 && (
|
|
||||||
<div className="invites-content__subheading">
|
|
||||||
<Text variant="b3" weight="bold">Rooms</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{ Array.from(initMatrix.roomList.inviteRooms).map(renderRoomTile) }
|
|
||||||
</div>
|
|
||||||
</PopupWindow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
InviteList.propTypes = {
|
|
||||||
isOpen: PropTypes.bool.isRequired,
|
|
||||||
onRequestClose: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default InviteList;
|
|
|
@ -1,26 +0,0 @@
|
||||||
@use '../../partials/dir';
|
|
||||||
|
|
||||||
.invites-content {
|
|
||||||
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
|
|
||||||
|
|
||||||
&__subheading {
|
|
||||||
margin-top: var(--sp-extra-loose);
|
|
||||||
|
|
||||||
& .text {
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
&:first-child {
|
|
||||||
margin-top: var(--sp-tight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& .room-tile {
|
|
||||||
margin-top: var(--sp-normal);
|
|
||||||
&__options {
|
|
||||||
align-self: flex-end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
& .invite-btn__container .btn-surface {
|
|
||||||
@include dir.side(margin, 0, var(--sp-normal));
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -3,10 +3,8 @@ import PropTypes from 'prop-types';
|
||||||
import './InviteUser.scss';
|
import './InviteUser.scss';
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import * as roomActions from '../../../client/action/room';
|
import * as roomActions from '../../../client/action/room';
|
||||||
import { selectRoom } from '../../../client/action/navigation';
|
import { hasDevices } from '../../../util/matrixUtil';
|
||||||
import { hasDMWith, hasDevices } from '../../../util/matrixUtil';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
import Button from '../../atoms/button/Button';
|
import Button from '../../atoms/button/Button';
|
||||||
|
@ -18,10 +16,10 @@ import RoomTile from '../../molecules/room-tile/RoomTile';
|
||||||
|
|
||||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||||
import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
||||||
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
|
import { getDMRoomFor } from '../../utils/matrix';
|
||||||
|
|
||||||
function InviteUser({
|
function InviteUser({ isOpen, roomId, searchTerm, onRequestClose }) {
|
||||||
isOpen, roomId, searchTerm, onRequestClose,
|
|
||||||
}) {
|
|
||||||
const [isSearching, updateIsSearching] = useState(false);
|
const [isSearching, updateIsSearching] = useState(false);
|
||||||
const [searchQuery, updateSearchQuery] = useState({});
|
const [searchQuery, updateSearchQuery] = useState({});
|
||||||
const [users, updateUsers] = useState([]);
|
const [users, updateUsers] = useState([]);
|
||||||
|
@ -37,6 +35,7 @@ function InviteUser({
|
||||||
const usernameRef = useRef(null);
|
const usernameRef = useRef(null);
|
||||||
|
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
|
const { navigateRoom } = useRoomNavigate();
|
||||||
|
|
||||||
function getMapCopy(myMap) {
|
function getMapCopy(myMap) {
|
||||||
const newMap = new Map();
|
const newMap = new Map();
|
||||||
|
@ -76,11 +75,13 @@ function InviteUser({
|
||||||
if (isInputUserId) {
|
if (isInputUserId) {
|
||||||
try {
|
try {
|
||||||
const result = await mx.getProfileInfo(inputUsername);
|
const result = await mx.getProfileInfo(inputUsername);
|
||||||
updateUsers([{
|
updateUsers([
|
||||||
user_id: inputUsername,
|
{
|
||||||
display_name: result.displayname,
|
user_id: inputUsername,
|
||||||
avatar_url: result.avatar_url,
|
display_name: result.displayname,
|
||||||
}]);
|
avatar_url: result.avatar_url,
|
||||||
|
},
|
||||||
|
]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
updateSearchQuery({ error: `${inputUsername} not found!` });
|
updateSearchQuery({ error: `${inputUsername} not found!` });
|
||||||
}
|
}
|
||||||
|
@ -105,9 +106,9 @@ function InviteUser({
|
||||||
|
|
||||||
async function createDM(userId) {
|
async function createDM(userId) {
|
||||||
if (mx.getUserId() === userId) return;
|
if (mx.getUserId() === userId) return;
|
||||||
const dmRoomId = hasDMWith(userId);
|
const dmRoomId = getDMRoomFor(mx, userId)?.roomId;
|
||||||
if (dmRoomId) {
|
if (dmRoomId) {
|
||||||
selectRoom(dmRoomId);
|
navigateRoom(dmRoomId);
|
||||||
onRequestClose();
|
onRequestClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -120,6 +121,7 @@ function InviteUser({
|
||||||
const result = await roomActions.createDM(userId, await hasDevices(userId));
|
const result = await roomActions.createDM(userId, await hasDevices(userId));
|
||||||
roomIdToUserId.set(result.room_id, userId);
|
roomIdToUserId.set(result.room_id, userId);
|
||||||
updateRoomIdToUserId(getMapCopy(roomIdToUserId));
|
updateRoomIdToUserId(getMapCopy(roomIdToUserId));
|
||||||
|
onDMCreated(result.room_id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
deleteUserFromProc(userId);
|
deleteUserFromProc(userId);
|
||||||
if (typeof e.message === 'string') procUserError.set(userId, e.message);
|
if (typeof e.message === 'string') procUserError.set(userId, e.message);
|
||||||
|
@ -150,7 +152,13 @@ function InviteUser({
|
||||||
|
|
||||||
function renderUserList() {
|
function renderUserList() {
|
||||||
const renderOptions = (userId) => {
|
const renderOptions = (userId) => {
|
||||||
const messageJSX = (message, isPositive) => <Text variant="b2"><span style={{ color: isPositive ? 'var(--bg-positive)' : 'var(--bg-negative)' }}>{message}</span></Text>;
|
const messageJSX = (message, isPositive) => (
|
||||||
|
<Text variant="b2">
|
||||||
|
<span style={{ color: isPositive ? 'var(--bg-positive)' : 'var(--bg-negative)' }}>
|
||||||
|
{message}
|
||||||
|
</span>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
if (mx.getUserId() === userId) return null;
|
if (mx.getUserId() === userId) return null;
|
||||||
if (procUsers.has(userId)) {
|
if (procUsers.has(userId)) {
|
||||||
|
@ -158,7 +166,16 @@ function InviteUser({
|
||||||
}
|
}
|
||||||
if (createdDM.has(userId)) {
|
if (createdDM.has(userId)) {
|
||||||
// eslint-disable-next-line max-len
|
// eslint-disable-next-line max-len
|
||||||
return <Button onClick={() => { selectRoom(createdDM.get(userId)); onRequestClose(); }}>Open</Button>;
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
navigateRoom(createdDM.get(userId));
|
||||||
|
onRequestClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (invitedUserIds.has(userId)) {
|
if (invitedUserIds.has(userId)) {
|
||||||
return messageJSX('Invited', true);
|
return messageJSX('Invited', true);
|
||||||
|
@ -178,13 +195,23 @@ function InviteUser({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (typeof roomId === 'string')
|
return typeof roomId === 'string' ? (
|
||||||
? <Button onClick={() => inviteToRoom(userId)} variant="primary">Invite</Button>
|
<Button onClick={() => inviteToRoom(userId)} variant="primary">
|
||||||
: <Button onClick={() => createDM(userId)} variant="primary">Message</Button>;
|
Invite
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={() => createDM(userId)} variant="primary">
|
||||||
|
Message
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
const renderError = (userId) => {
|
const renderError = (userId) => {
|
||||||
if (!procUserError.has(userId)) return null;
|
if (!procUserError.has(userId)) return null;
|
||||||
return <Text variant="b2"><span style={{ color: 'var(--bg-danger)' }}>{procUserError.get(userId)}</span></Text>;
|
return (
|
||||||
|
<Text variant="b2">
|
||||||
|
<span style={{ color: 'var(--bg-danger)' }}>{procUserError.get(userId)}</span>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return users.map((user) => {
|
return users.map((user) => {
|
||||||
|
@ -193,7 +220,11 @@ function InviteUser({
|
||||||
return (
|
return (
|
||||||
<RoomTile
|
<RoomTile
|
||||||
key={userId}
|
key={userId}
|
||||||
avatarSrc={typeof user.avatar_url === 'string' ? mx.mxcUrlToHttp(user.avatar_url, 42, 42, 'crop') : null}
|
avatarSrc={
|
||||||
|
typeof user.avatar_url === 'string'
|
||||||
|
? mx.mxcUrlToHttp(user.avatar_url, 42, 42, 'crop')
|
||||||
|
: null
|
||||||
|
}
|
||||||
name={name}
|
name={name}
|
||||||
id={userId}
|
id={userId}
|
||||||
options={renderOptions(userId)}
|
options={renderOptions(userId)}
|
||||||
|
@ -217,48 +248,43 @@ function InviteUser({
|
||||||
};
|
};
|
||||||
}, [isOpen, searchTerm]);
|
}, [isOpen, searchTerm]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
initMatrix.roomList.on(cons.events.roomList.ROOM_CREATED, onDMCreated);
|
|
||||||
return () => {
|
|
||||||
initMatrix.roomList.removeListener(cons.events.roomList.ROOM_CREATED, onDMCreated);
|
|
||||||
};
|
|
||||||
}, [isOpen, procUsers, createdDM, roomIdToUserId]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PopupWindow
|
<PopupWindow
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
title={(typeof roomId === 'string' ? `Invite to ${mx.getRoom(roomId).name}` : 'Direct message')}
|
title={typeof roomId === 'string' ? `Invite to ${mx.getRoom(roomId).name}` : 'Direct message'}
|
||||||
contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
|
contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
|
||||||
onRequestClose={onRequestClose}
|
onRequestClose={onRequestClose}
|
||||||
>
|
>
|
||||||
<div className="invite-user">
|
<div className="invite-user">
|
||||||
<form className="invite-user__form" onSubmit={(e) => { e.preventDefault(); searchUser(usernameRef.current.value); }}>
|
<form
|
||||||
|
className="invite-user__form"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
searchUser(usernameRef.current.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Input value={searchTerm} forwardRef={usernameRef} label="Name or userId" />
|
<Input value={searchTerm} forwardRef={usernameRef} label="Name or userId" />
|
||||||
<Button disabled={isSearching} iconSrc={UserIC} variant="primary" type="submit">Search</Button>
|
<Button disabled={isSearching} iconSrc={UserIC} variant="primary" type="submit">
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<div className="invite-user__search-status">
|
<div className="invite-user__search-status">
|
||||||
{
|
{typeof searchQuery.username !== 'undefined' && isSearching && (
|
||||||
typeof searchQuery.username !== 'undefined' && isSearching && (
|
<div className="flex--center">
|
||||||
<div className="flex--center">
|
<Spinner size="small" />
|
||||||
<Spinner size="small" />
|
<Text variant="b2">{`Searching for user "${searchQuery.username}"...`}</Text>
|
||||||
<Text variant="b2">{`Searching for user "${searchQuery.username}"...`}</Text>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)
|
{typeof searchQuery.username !== 'undefined' && !isSearching && (
|
||||||
}
|
<Text variant="b2">{`Search result for user "${searchQuery.username}"`}</Text>
|
||||||
{
|
)}
|
||||||
typeof searchQuery.username !== 'undefined' && !isSearching && (
|
{searchQuery.error && (
|
||||||
<Text variant="b2">{`Search result for user "${searchQuery.username}"`}</Text>
|
<Text className="invite-user__search-error" variant="b2">
|
||||||
)
|
{searchQuery.error}
|
||||||
}
|
</Text>
|
||||||
{
|
)}
|
||||||
searchQuery.error && <Text className="invite-user__search-error" variant="b2">{searchQuery.error}</Text>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
{ users.length !== 0 && (
|
{users.length !== 0 && <div className="invite-user__content">{renderUserList()}</div>}
|
||||||
<div className="invite-user__content">
|
|
||||||
{renderUserList()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</PopupWindow>
|
</PopupWindow>
|
||||||
);
|
);
|
||||||
|
|
|
@ -6,7 +6,6 @@ import initMatrix from '../../../client/initMatrix';
|
||||||
import cons from '../../../client/state/cons';
|
import cons from '../../../client/state/cons';
|
||||||
import navigation from '../../../client/state/navigation';
|
import navigation from '../../../client/state/navigation';
|
||||||
import { join } from '../../../client/action/room';
|
import { join } from '../../../client/action/room';
|
||||||
import { selectRoom, selectTab } from '../../../client/action/navigation';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
import IconButton from '../../atoms/button/IconButton';
|
import IconButton from '../../atoms/button/IconButton';
|
||||||
|
@ -18,36 +17,24 @@ import Dialog from '../../molecules/dialog/Dialog';
|
||||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||||
|
|
||||||
import { useStore } from '../../hooks/useStore';
|
import { useStore } from '../../hooks/useStore';
|
||||||
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
|
|
||||||
const ALIAS_OR_ID_REG = /^[#|!].+:.+\..+$/;
|
const ALIAS_OR_ID_REG = /^[#|!].+:.+\..+$/;
|
||||||
|
|
||||||
function JoinAliasContent({ term, requestClose }) {
|
function JoinAliasContent({ term, requestClose }) {
|
||||||
const [process, setProcess] = useState(false);
|
const [process, setProcess] = useState(false);
|
||||||
const [error, setError] = useState(undefined);
|
const [error, setError] = useState(undefined);
|
||||||
const [lastJoinId, setLastJoinId] = useState(undefined);
|
|
||||||
|
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
const mountStore = useStore();
|
const mountStore = useStore();
|
||||||
|
|
||||||
|
const { navigateRoom } = useRoomNavigate();
|
||||||
|
|
||||||
const openRoom = (roomId) => {
|
const openRoom = (roomId) => {
|
||||||
const room = mx.getRoom(roomId);
|
navigateRoom(roomId);
|
||||||
if (!room) return;
|
|
||||||
if (room.isSpaceRoom()) selectTab(roomId);
|
|
||||||
else selectRoom(roomId);
|
|
||||||
requestClose();
|
requestClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleJoin = (roomId) => {
|
|
||||||
if (lastJoinId !== roomId) return;
|
|
||||||
openRoom(roomId);
|
|
||||||
};
|
|
||||||
initMatrix.roomList.on(cons.events.roomList.ROOM_JOINED, handleJoin);
|
|
||||||
return () => {
|
|
||||||
initMatrix.roomList.removeListener(cons.events.roomList.ROOM_JOINED, handleJoin);
|
|
||||||
};
|
|
||||||
}, [lastJoinId]);
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
mountStore.setItem(true);
|
mountStore.setItem(true);
|
||||||
|
@ -70,13 +57,14 @@ function JoinAliasContent({ term, requestClose }) {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!mountStore.getItem()) return;
|
if (!mountStore.getItem()) return;
|
||||||
setProcess(false);
|
setProcess(false);
|
||||||
setError(`Unable to find room/space with ${alias}. Either room/space is private or doesn't exist.`);
|
setError(
|
||||||
|
`Unable to find room/space with ${alias}. Either room/space is private or doesn't exist.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const roomId = await join(alias, false, via);
|
const roomId = await join(alias, false, via);
|
||||||
if (!mountStore.getItem()) return;
|
if (!mountStore.getItem()) return;
|
||||||
setLastJoinId(roomId);
|
|
||||||
openRoom(roomId);
|
openRoom(roomId);
|
||||||
} catch {
|
} catch {
|
||||||
if (!mountStore.getItem()) return;
|
if (!mountStore.getItem()) return;
|
||||||
|
@ -87,24 +75,23 @@ function JoinAliasContent({ term, requestClose }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="join-alias" onSubmit={handleSubmit}>
|
<form className="join-alias" onSubmit={handleSubmit}>
|
||||||
<Input
|
<Input label="Address" value={term} name="alias" required />
|
||||||
label="Address"
|
{error && (
|
||||||
value={term}
|
<Text className="join-alias__error" variant="b3">
|
||||||
name="alias"
|
{error}
|
||||||
required
|
</Text>
|
||||||
/>
|
)}
|
||||||
{error && <Text className="join-alias__error" variant="b3">{error}</Text>}
|
|
||||||
<div className="join-alias__btn">
|
<div className="join-alias__btn">
|
||||||
{
|
{process ? (
|
||||||
process
|
<>
|
||||||
? (
|
<Spinner size="small" />
|
||||||
<>
|
<Text>{process}</Text>
|
||||||
<Spinner size="small" />
|
</>
|
||||||
<Text>{process}</Text>
|
) : (
|
||||||
</>
|
<Button variant="primary" type="submit">
|
||||||
)
|
Join
|
||||||
: <Button variant="primary" type="submit">Join</Button>
|
</Button>
|
||||||
}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
@ -141,13 +128,15 @@ function JoinAlias() {
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
isOpen={data !== null}
|
isOpen={data !== null}
|
||||||
title={(
|
title={
|
||||||
<Text variant="s1" weight="medium" primary>Join with address</Text>
|
<Text variant="s1" weight="medium" primary>
|
||||||
)}
|
Join with address
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
|
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
|
||||||
onRequestClose={requestClose}
|
onRequestClose={requestClose}
|
||||||
>
|
>
|
||||||
{ data ? <JoinAliasContent term={data.term} requestClose={requestClose} /> : <div /> }
|
{data ? <JoinAliasContent term={data.term} requestClose={requestClose} /> : <div />}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,71 +0,0 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import navigation from '../../../client/state/navigation';
|
|
||||||
import Postie from '../../../util/Postie';
|
|
||||||
import { roomIdByActivity } from '../../../util/sort';
|
|
||||||
|
|
||||||
import RoomsCategory from './RoomsCategory';
|
|
||||||
|
|
||||||
const drawerPostie = new Postie();
|
|
||||||
function Directs({ size }) {
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const { roomList, notifications } = initMatrix;
|
|
||||||
const [directIds, setDirectIds] = useState([]);
|
|
||||||
|
|
||||||
useEffect(() => setDirectIds([...roomList.directs].sort(roomIdByActivity)), [size]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleTimeline = (event, room, toStartOfTimeline, removed, data) => {
|
|
||||||
if (!roomList.directs.has(room.roomId)) return;
|
|
||||||
if (!data.liveEvent) return;
|
|
||||||
if (directIds[0] === room.roomId) return;
|
|
||||||
const newDirectIds = [room.roomId];
|
|
||||||
directIds.forEach((id) => {
|
|
||||||
if (id === room.roomId) return;
|
|
||||||
newDirectIds.push(id);
|
|
||||||
});
|
|
||||||
setDirectIds(newDirectIds);
|
|
||||||
};
|
|
||||||
mx.on('Room.timeline', handleTimeline);
|
|
||||||
return () => {
|
|
||||||
mx.removeListener('Room.timeline', handleTimeline);
|
|
||||||
};
|
|
||||||
}, [directIds]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const selectorChanged = (selectedRoomId, prevSelectedRoomId) => {
|
|
||||||
if (!drawerPostie.hasTopic('selector-change')) return;
|
|
||||||
const addresses = [];
|
|
||||||
if (drawerPostie.hasSubscriber('selector-change', selectedRoomId)) addresses.push(selectedRoomId);
|
|
||||||
if (drawerPostie.hasSubscriber('selector-change', prevSelectedRoomId)) addresses.push(prevSelectedRoomId);
|
|
||||||
if (addresses.length === 0) return;
|
|
||||||
drawerPostie.post('selector-change', addresses, selectedRoomId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const notiChanged = (roomId, total, prevTotal) => {
|
|
||||||
if (total === prevTotal) return;
|
|
||||||
if (drawerPostie.hasTopicAndSubscriber('unread-change', roomId)) {
|
|
||||||
drawerPostie.post('unread-change', roomId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
navigation.on(cons.events.navigation.ROOM_SELECTED, selectorChanged);
|
|
||||||
notifications.on(cons.events.notifications.NOTI_CHANGED, notiChanged);
|
|
||||||
notifications.on(cons.events.notifications.MUTE_TOGGLED, notiChanged);
|
|
||||||
return () => {
|
|
||||||
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, selectorChanged);
|
|
||||||
notifications.removeListener(cons.events.notifications.NOTI_CHANGED, notiChanged);
|
|
||||||
notifications.removeListener(cons.events.notifications.MUTE_TOGGLED, notiChanged);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return <RoomsCategory name="People" hideHeader roomIds={directIds} drawerPostie={drawerPostie} />;
|
|
||||||
}
|
|
||||||
Directs.propTypes = {
|
|
||||||
size: PropTypes.number.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Directs;
|
|
|
@ -1,93 +0,0 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import './Drawer.scss';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import navigation from '../../../client/state/navigation';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
|
||||||
|
|
||||||
import DrawerHeader from './DrawerHeader';
|
|
||||||
import DrawerBreadcrumb from './DrawerBreadcrumb';
|
|
||||||
import Home from './Home';
|
|
||||||
import Directs from './Directs';
|
|
||||||
|
|
||||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
|
||||||
import { useSelectedTab } from '../../hooks/useSelectedTab';
|
|
||||||
import { useSelectedSpace } from '../../hooks/useSelectedSpace';
|
|
||||||
|
|
||||||
function useSystemState() {
|
|
||||||
const [systemState, setSystemState] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleSystemState = (state) => {
|
|
||||||
if (state === 'ERROR' || state === 'RECONNECTING' || state === 'STOPPED') {
|
|
||||||
setSystemState({ status: 'Connection lost!' });
|
|
||||||
}
|
|
||||||
if (systemState !== null) setSystemState(null);
|
|
||||||
};
|
|
||||||
initMatrix.matrixClient.on('sync', handleSystemState);
|
|
||||||
return () => {
|
|
||||||
initMatrix.matrixClient.removeListener('sync', handleSystemState);
|
|
||||||
};
|
|
||||||
}, [systemState]);
|
|
||||||
|
|
||||||
return [systemState];
|
|
||||||
}
|
|
||||||
|
|
||||||
function Drawer() {
|
|
||||||
const [systemState] = useSystemState();
|
|
||||||
const [selectedTab] = useSelectedTab();
|
|
||||||
const [spaceId] = useSelectedSpace();
|
|
||||||
const [, forceUpdate] = useForceUpdate();
|
|
||||||
const scrollRef = useRef(null);
|
|
||||||
const { roomList } = initMatrix;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleUpdate = () => {
|
|
||||||
forceUpdate();
|
|
||||||
};
|
|
||||||
roomList.on(cons.events.roomList.ROOMLIST_UPDATED, handleUpdate);
|
|
||||||
return () => {
|
|
||||||
roomList.removeListener(cons.events.roomList.ROOMLIST_UPDATED, handleUpdate);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (scrollRef.current) {
|
|
||||||
scrollRef.current.scrollTop = 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [selectedTab]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="drawer">
|
|
||||||
<DrawerHeader selectedTab={selectedTab} spaceId={spaceId} />
|
|
||||||
<div className="drawer__content-wrapper">
|
|
||||||
{navigation.selectedSpacePath.length > 1 && selectedTab !== cons.tabs.DIRECTS && (
|
|
||||||
<DrawerBreadcrumb spaceId={spaceId} />
|
|
||||||
)}
|
|
||||||
<div className="rooms__wrapper">
|
|
||||||
<ScrollView ref={scrollRef} autoHide>
|
|
||||||
<div className="rooms-container">
|
|
||||||
{selectedTab !== cons.tabs.DIRECTS ? (
|
|
||||||
<Home spaceId={spaceId} />
|
|
||||||
) : (
|
|
||||||
<Directs size={roomList.directs.size} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollView>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{systemState !== null && (
|
|
||||||
<div className="drawer__state">
|
|
||||||
<Text>{systemState.status}</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Drawer;
|
|
|
@ -1,56 +0,0 @@
|
||||||
@use '../../partials/flex';
|
|
||||||
@use '../../partials/dir';
|
|
||||||
|
|
||||||
.drawer {
|
|
||||||
@extend .cp-fx__column;
|
|
||||||
@extend .cp-fx__item-one;
|
|
||||||
min-width: 0;
|
|
||||||
@include dir.side(border,
|
|
||||||
none,
|
|
||||||
1px solid var(--bg-surface-border),
|
|
||||||
);
|
|
||||||
|
|
||||||
& .header {
|
|
||||||
padding: var(--sp-extra-tight);
|
|
||||||
& > .header__title-wrapper {
|
|
||||||
@include dir.side(margin, var(--sp-ultra-tight), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__content-wrapper {
|
|
||||||
@extend .cp-fx__item-one;
|
|
||||||
@extend .cp-fx__column;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__state {
|
|
||||||
padding: var(--sp-extra-tight);
|
|
||||||
border-top: 1px solid var(--bg-surface-border);
|
|
||||||
@extend .cp-fx__row--c-c;
|
|
||||||
|
|
||||||
& .text {
|
|
||||||
color: var(--tc-danger-high);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.rooms__wrapper {
|
|
||||||
@extend .cp-fx__item-one;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rooms-container {
|
|
||||||
padding-bottom: var(--sp-extra-loose);
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
z-index: 99;
|
|
||||||
content: '';
|
|
||||||
display: inline-block;
|
|
||||||
width: 100%;
|
|
||||||
height: 8px;
|
|
||||||
background-image: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
var(--bg-surface-low),
|
|
||||||
var(--bg-surface-low-transparent));
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,142 +0,0 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './DrawerBreadcrumb.scss';
|
|
||||||
|
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import { selectTab, selectSpace } from '../../../client/action/navigation';
|
|
||||||
import navigation from '../../../client/state/navigation';
|
|
||||||
import { abbreviateNumber } from '../../../util/common';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
|
||||||
import Button from '../../atoms/button/Button';
|
|
||||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
|
||||||
import NotificationBadge from '../../atoms/badge/NotificationBadge';
|
|
||||||
|
|
||||||
import ChevronRightIC from '../../../../public/res/ic/outlined/chevron-right.svg';
|
|
||||||
|
|
||||||
function DrawerBreadcrumb({ spaceId }) {
|
|
||||||
const [, forceUpdate] = useState({});
|
|
||||||
const scrollRef = useRef(null);
|
|
||||||
const { roomList, notifications, accountData } = initMatrix;
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const spacePath = navigation.selectedSpacePath;
|
|
||||||
|
|
||||||
function onNotiChanged(roomId, total, prevTotal) {
|
|
||||||
if (total === prevTotal) return;
|
|
||||||
if (navigation.selectedSpacePath.includes(roomId)) {
|
|
||||||
forceUpdate({});
|
|
||||||
}
|
|
||||||
if (navigation.selectedSpacePath[0] === cons.tabs.HOME) {
|
|
||||||
if (!roomList.isOrphan(roomId)) return;
|
|
||||||
if (roomList.directs.has(roomId)) return;
|
|
||||||
forceUpdate({});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (scrollRef?.current === null) return;
|
|
||||||
scrollRef.current.scrollLeft = scrollRef.current.scrollWidth;
|
|
||||||
});
|
|
||||||
notifications.on(cons.events.notifications.NOTI_CHANGED, onNotiChanged);
|
|
||||||
return () => {
|
|
||||||
notifications.removeListener(cons.events.notifications.NOTI_CHANGED, onNotiChanged);
|
|
||||||
};
|
|
||||||
}, [spaceId]);
|
|
||||||
|
|
||||||
function getHomeNotiExcept(childId) {
|
|
||||||
const orphans = roomList.getOrphans()
|
|
||||||
.filter((id) => (id !== childId))
|
|
||||||
.filter((id) => !accountData.spaceShortcut.has(id));
|
|
||||||
|
|
||||||
let noti = null;
|
|
||||||
|
|
||||||
orphans.forEach((roomId) => {
|
|
||||||
if (!notifications.hasNoti(roomId)) return;
|
|
||||||
if (noti === null) noti = { total: 0, highlight: 0 };
|
|
||||||
const childNoti = notifications.getNoti(roomId);
|
|
||||||
noti.total += childNoti.total;
|
|
||||||
noti.highlight += childNoti.highlight;
|
|
||||||
});
|
|
||||||
|
|
||||||
return noti;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNotiExcept(roomId, childId) {
|
|
||||||
if (!notifications.hasNoti(roomId)) return null;
|
|
||||||
|
|
||||||
const noti = notifications.getNoti(roomId);
|
|
||||||
if (!notifications.hasNoti(childId)) return noti;
|
|
||||||
if (noti.from === null) return noti;
|
|
||||||
|
|
||||||
const childNoti = notifications.getNoti(childId);
|
|
||||||
|
|
||||||
let noOther = true;
|
|
||||||
let total = 0;
|
|
||||||
let highlight = 0;
|
|
||||||
noti.from.forEach((fromId) => {
|
|
||||||
if (childNoti.from.has(fromId)) return;
|
|
||||||
noOther = false;
|
|
||||||
const fromNoti = notifications.getNoti(fromId);
|
|
||||||
total += fromNoti.total;
|
|
||||||
highlight += fromNoti.highlight;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (noOther) return null;
|
|
||||||
return { total, highlight };
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="drawer-breadcrumb__wrapper">
|
|
||||||
<ScrollView ref={scrollRef} horizontal vertical={false} invisible>
|
|
||||||
<div className="drawer-breadcrumb">
|
|
||||||
{
|
|
||||||
spacePath.map((id, index) => {
|
|
||||||
const noti = (id !== cons.tabs.HOME && index < spacePath.length)
|
|
||||||
? getNotiExcept(id, (index === spacePath.length - 1) ? null : spacePath[index + 1])
|
|
||||||
: getHomeNotiExcept((index === spacePath.length - 1) ? null : spacePath[index + 1]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment
|
|
||||||
key={id}
|
|
||||||
>
|
|
||||||
{ index !== 0 && <RawIcon size="extra-small" src={ChevronRightIC} />}
|
|
||||||
<Button
|
|
||||||
className={index === spacePath.length - 1 ? 'drawer-breadcrumb__btn--selected' : ''}
|
|
||||||
onClick={() => {
|
|
||||||
if (id === cons.tabs.HOME) selectTab(id);
|
|
||||||
else selectSpace(id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text variant="b2">{id === cons.tabs.HOME ? 'Home' : twemojify(mx.getRoom(id).name)}</Text>
|
|
||||||
{ noti !== null && (
|
|
||||||
<NotificationBadge
|
|
||||||
alert={noti.highlight !== 0}
|
|
||||||
content={noti.total > 0 ? abbreviateNumber(noti.total) : null}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
<div style={{ width: 'var(--sp-extra-tight)', height: '100%' }} />
|
|
||||||
</div>
|
|
||||||
</ScrollView>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
DrawerBreadcrumb.defaultProps = {
|
|
||||||
spaceId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
DrawerBreadcrumb.propTypes = {
|
|
||||||
spaceId: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DrawerBreadcrumb;
|
|
|
@ -1,66 +0,0 @@
|
||||||
@use '../../partials/text';
|
|
||||||
@use '../../partials/dir';
|
|
||||||
|
|
||||||
.drawer-breadcrumb__wrapper {
|
|
||||||
height: var(--header-height);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-breadcrumb {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 100%;
|
|
||||||
margin: 0 var(--sp-extra-tight);
|
|
||||||
|
|
||||||
&::before,
|
|
||||||
&::after {
|
|
||||||
flex-shrink: 0;
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
z-index: 99;
|
|
||||||
|
|
||||||
content: '';
|
|
||||||
display: inline-block;
|
|
||||||
min-width: 8px;
|
|
||||||
width: 8px;
|
|
||||||
height: 100%;
|
|
||||||
background-image: linear-gradient(
|
|
||||||
to right,
|
|
||||||
var(--bg-surface-low-transparent),
|
|
||||||
var(--bg-surface-low)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
&::before {
|
|
||||||
left: 0;
|
|
||||||
right: unset;
|
|
||||||
background-image: linear-gradient(
|
|
||||||
to left,
|
|
||||||
var(--bg-surface-low-transparent),
|
|
||||||
var(--bg-surface-low)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
& > * {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > .btn-surface {
|
|
||||||
min-width: 0;
|
|
||||||
padding: var(--sp-extra-tight) 10px;
|
|
||||||
white-space: nowrap;
|
|
||||||
box-shadow: none;
|
|
||||||
& p {
|
|
||||||
@extend .cp-txt__ellipsis;
|
|
||||||
max-width: 86px;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .notification-badge {
|
|
||||||
@include dir.side(margin, var(--sp-extra-tight), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__btn--selected {
|
|
||||||
box-shadow: var(--bs-surface-border) !important;
|
|
||||||
background-color: var(--bg-surface);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,159 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './DrawerHeader.scss';
|
|
||||||
|
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import {
|
|
||||||
openPublicRooms, openCreateRoom, openSpaceManage, openJoinAlias,
|
|
||||||
openSpaceAddExisting, openInviteUser, openReusableContextMenu,
|
|
||||||
} from '../../../client/action/navigation';
|
|
||||||
import { getEventCords } from '../../../util/common';
|
|
||||||
|
|
||||||
import { blurOnBubbling } from '../../atoms/button/script';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
|
||||||
import Header, { TitleWrapper } from '../../atoms/header/Header';
|
|
||||||
import IconButton from '../../atoms/button/IconButton';
|
|
||||||
import { MenuItem, MenuHeader } from '../../atoms/context-menu/ContextMenu';
|
|
||||||
import SpaceOptions from '../../molecules/space-options/SpaceOptions';
|
|
||||||
|
|
||||||
import PlusIC from '../../../../public/res/ic/outlined/plus.svg';
|
|
||||||
import HashPlusIC from '../../../../public/res/ic/outlined/hash-plus.svg';
|
|
||||||
import HashGlobeIC from '../../../../public/res/ic/outlined/hash-globe.svg';
|
|
||||||
import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg';
|
|
||||||
import SpacePlusIC from '../../../../public/res/ic/outlined/space-plus.svg';
|
|
||||||
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
|
|
||||||
|
|
||||||
export function HomeSpaceOptions({ spaceId, afterOptionSelect }) {
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const room = mx.getRoom(spaceId);
|
|
||||||
const canManage = room
|
|
||||||
? room.currentState.maySendStateEvent('m.space.child', mx.getUserId())
|
|
||||||
: true;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<MenuHeader>Add rooms or spaces</MenuHeader>
|
|
||||||
<MenuItem
|
|
||||||
iconSrc={SpacePlusIC}
|
|
||||||
onClick={() => { afterOptionSelect(); openCreateRoom(true, spaceId); }}
|
|
||||||
disabled={!canManage}
|
|
||||||
>
|
|
||||||
Create new space
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
iconSrc={HashPlusIC}
|
|
||||||
onClick={() => { afterOptionSelect(); openCreateRoom(false, spaceId); }}
|
|
||||||
disabled={!canManage}
|
|
||||||
>
|
|
||||||
Create new room
|
|
||||||
</MenuItem>
|
|
||||||
{ !spaceId && (
|
|
||||||
<MenuItem
|
|
||||||
iconSrc={HashGlobeIC}
|
|
||||||
onClick={() => { afterOptionSelect(); openPublicRooms(); }}
|
|
||||||
>
|
|
||||||
Explore public rooms
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
{ !spaceId && (
|
|
||||||
<MenuItem
|
|
||||||
iconSrc={PlusIC}
|
|
||||||
onClick={() => { afterOptionSelect(); openJoinAlias(); }}
|
|
||||||
>
|
|
||||||
Join with address
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
{ spaceId && (
|
|
||||||
<MenuItem
|
|
||||||
iconSrc={PlusIC}
|
|
||||||
onClick={() => { afterOptionSelect(); openSpaceAddExisting(spaceId); }}
|
|
||||||
disabled={!canManage}
|
|
||||||
>
|
|
||||||
Add existing
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
{ spaceId && (
|
|
||||||
<MenuItem
|
|
||||||
onClick={() => { afterOptionSelect(); openSpaceManage(spaceId); }}
|
|
||||||
iconSrc={HashSearchIC}
|
|
||||||
>
|
|
||||||
Manage rooms
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
HomeSpaceOptions.defaultProps = {
|
|
||||||
spaceId: null,
|
|
||||||
};
|
|
||||||
HomeSpaceOptions.propTypes = {
|
|
||||||
spaceId: PropTypes.string,
|
|
||||||
afterOptionSelect: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
function DrawerHeader({ selectedTab, spaceId }) {
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const tabName = selectedTab !== cons.tabs.DIRECTS ? 'Home' : 'Direct messages';
|
|
||||||
|
|
||||||
const isDMTab = selectedTab === cons.tabs.DIRECTS;
|
|
||||||
const room = mx.getRoom(spaceId);
|
|
||||||
const spaceName = isDMTab ? null : (room?.name || null);
|
|
||||||
|
|
||||||
const openSpaceOptions = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
openReusableContextMenu(
|
|
||||||
'bottom',
|
|
||||||
getEventCords(e, '.header'),
|
|
||||||
(closeMenu) => <SpaceOptions roomId={spaceId} afterOptionSelect={closeMenu} />,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openHomeSpaceOptions = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
openReusableContextMenu(
|
|
||||||
'right',
|
|
||||||
getEventCords(e, '.ic-btn'),
|
|
||||||
(closeMenu) => <HomeSpaceOptions spaceId={spaceId} afterOptionSelect={closeMenu} />,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Header>
|
|
||||||
{spaceName ? (
|
|
||||||
<button
|
|
||||||
className="drawer-header__btn"
|
|
||||||
onClick={openSpaceOptions}
|
|
||||||
type="button"
|
|
||||||
onMouseUp={(e) => blurOnBubbling(e, '.drawer-header__btn')}
|
|
||||||
>
|
|
||||||
<TitleWrapper>
|
|
||||||
<Text variant="s1" weight="medium" primary>{twemojify(spaceName)}</Text>
|
|
||||||
</TitleWrapper>
|
|
||||||
<RawIcon size="small" src={ChevronBottomIC} />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<TitleWrapper>
|
|
||||||
<Text variant="s1" weight="medium" primary>{tabName}</Text>
|
|
||||||
</TitleWrapper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ isDMTab && <IconButton onClick={() => openInviteUser()} tooltip="Start DM" src={PlusIC} size="small" /> }
|
|
||||||
{ !isDMTab && <IconButton onClick={openHomeSpaceOptions} tooltip="Add rooms/spaces" src={PlusIC} size="small" /> }
|
|
||||||
</Header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
DrawerHeader.defaultProps = {
|
|
||||||
spaceId: null,
|
|
||||||
};
|
|
||||||
DrawerHeader.propTypes = {
|
|
||||||
selectedTab: PropTypes.string.isRequired,
|
|
||||||
spaceId: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DrawerHeader;
|
|
|
@ -1,28 +0,0 @@
|
||||||
@use '../../partials/flex';
|
|
||||||
@use '../../partials/dir';
|
|
||||||
|
|
||||||
.drawer-header__btn {
|
|
||||||
min-width: 0;
|
|
||||||
@extend .cp-fx__row--s-c;
|
|
||||||
@include dir.side(margin, 0, auto);
|
|
||||||
padding: var(--sp-ultra-tight);
|
|
||||||
border-radius: calc(var(--bo-radius) / 2);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
& .header__title-wrapper {
|
|
||||||
@include dir.side(margin, 0, var(--sp-extra-tight));
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover:hover) {
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--bg-surface-hover);
|
|
||||||
box-shadow: var(--bs-surface-outline);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&:focus,
|
|
||||||
&:active {
|
|
||||||
background-color: var(--bg-surface-active);
|
|
||||||
box-shadow: var(--bs-surface-outline);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,112 +0,0 @@
|
||||||
import React, { useEffect } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import navigation from '../../../client/state/navigation';
|
|
||||||
import Postie from '../../../util/Postie';
|
|
||||||
import { roomIdByActivity, roomIdByAtoZ } from '../../../util/sort';
|
|
||||||
|
|
||||||
import RoomsCategory from './RoomsCategory';
|
|
||||||
|
|
||||||
import { useCategorizedSpaces } from '../../hooks/useCategorizedSpaces';
|
|
||||||
|
|
||||||
const drawerPostie = new Postie();
|
|
||||||
function Home({ spaceId }) {
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const { roomList, notifications, accountData } = initMatrix;
|
|
||||||
const { spaces, rooms, directs } = roomList;
|
|
||||||
useCategorizedSpaces();
|
|
||||||
const isCategorized = accountData.categorizedSpaces.has(spaceId);
|
|
||||||
|
|
||||||
let categories = null;
|
|
||||||
let spaceIds = [];
|
|
||||||
let roomIds = [];
|
|
||||||
let directIds = [];
|
|
||||||
|
|
||||||
if (spaceId) {
|
|
||||||
const spaceChildIds = roomList.getSpaceChildren(spaceId) ?? [];
|
|
||||||
spaceIds = spaceChildIds.filter((roomId) => spaces.has(roomId));
|
|
||||||
roomIds = spaceChildIds.filter((roomId) => rooms.has(roomId));
|
|
||||||
directIds = spaceChildIds.filter((roomId) => directs.has(roomId));
|
|
||||||
} else {
|
|
||||||
spaceIds = roomList.getOrphanSpaces().filter((id) => !accountData.spaceShortcut.has(id));
|
|
||||||
roomIds = roomList.getOrphanRooms();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCategorized) {
|
|
||||||
categories = roomList.getCategorizedSpaces(spaceIds);
|
|
||||||
categories.delete(spaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const selectorChanged = (selectedRoomId, prevSelectedRoomId) => {
|
|
||||||
if (!drawerPostie.hasTopic('selector-change')) return;
|
|
||||||
const addresses = [];
|
|
||||||
if (drawerPostie.hasSubscriber('selector-change', selectedRoomId)) addresses.push(selectedRoomId);
|
|
||||||
if (drawerPostie.hasSubscriber('selector-change', prevSelectedRoomId)) addresses.push(prevSelectedRoomId);
|
|
||||||
if (addresses.length === 0) return;
|
|
||||||
drawerPostie.post('selector-change', addresses, selectedRoomId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const notiChanged = (roomId, total, prevTotal) => {
|
|
||||||
if (total === prevTotal) return;
|
|
||||||
if (drawerPostie.hasTopicAndSubscriber('unread-change', roomId)) {
|
|
||||||
drawerPostie.post('unread-change', roomId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
navigation.on(cons.events.navigation.ROOM_SELECTED, selectorChanged);
|
|
||||||
notifications.on(cons.events.notifications.NOTI_CHANGED, notiChanged);
|
|
||||||
notifications.on(cons.events.notifications.MUTE_TOGGLED, notiChanged);
|
|
||||||
return () => {
|
|
||||||
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, selectorChanged);
|
|
||||||
notifications.removeListener(cons.events.notifications.NOTI_CHANGED, notiChanged);
|
|
||||||
notifications.removeListener(cons.events.notifications.MUTE_TOGGLED, notiChanged);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{ !isCategorized && spaceIds.length !== 0 && (
|
|
||||||
<RoomsCategory name="Spaces" roomIds={spaceIds.sort(roomIdByAtoZ)} drawerPostie={drawerPostie} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ roomIds.length !== 0 && (
|
|
||||||
<RoomsCategory name="Rooms" roomIds={roomIds.sort(roomIdByAtoZ)} drawerPostie={drawerPostie} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ directIds.length !== 0 && (
|
|
||||||
<RoomsCategory name="People" roomIds={directIds.sort(roomIdByActivity)} drawerPostie={drawerPostie} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ isCategorized && [...categories.keys()].sort(roomIdByAtoZ).map((catId) => {
|
|
||||||
const rms = [];
|
|
||||||
const dms = [];
|
|
||||||
categories.get(catId).forEach((id) => {
|
|
||||||
if (directs.has(id)) dms.push(id);
|
|
||||||
else rms.push(id);
|
|
||||||
});
|
|
||||||
rms.sort(roomIdByAtoZ);
|
|
||||||
dms.sort(roomIdByActivity);
|
|
||||||
return (
|
|
||||||
<RoomsCategory
|
|
||||||
key={catId}
|
|
||||||
spaceId={catId}
|
|
||||||
name={mx.getRoom(catId).name}
|
|
||||||
roomIds={rms.concat(dms)}
|
|
||||||
drawerPostie={drawerPostie}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Home.defaultProps = {
|
|
||||||
spaceId: null,
|
|
||||||
};
|
|
||||||
Home.propTypes = {
|
|
||||||
spaceId: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Home;
|
|
|
@ -1,16 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import './Navigation.scss';
|
|
||||||
|
|
||||||
import SideBar from './SideBar';
|
|
||||||
import Drawer from './Drawer';
|
|
||||||
|
|
||||||
function Navigation() {
|
|
||||||
return (
|
|
||||||
<div className="navigation">
|
|
||||||
<SideBar />
|
|
||||||
<Drawer />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Navigation;
|
|
|
@ -1,7 +0,0 @@
|
||||||
.navigation {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-color: var(--bg-surface-low);
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
}
|
|
|
@ -1,92 +0,0 @@
|
||||||
import React, { useState } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './RoomsCategory.scss';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import { selectSpace, selectRoom, openReusableContextMenu } from '../../../client/action/navigation';
|
|
||||||
import { getEventCords } from '../../../util/common';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
|
||||||
import IconButton from '../../atoms/button/IconButton';
|
|
||||||
import Selector from './Selector';
|
|
||||||
import SpaceOptions from '../../molecules/space-options/SpaceOptions';
|
|
||||||
import { HomeSpaceOptions } from './DrawerHeader';
|
|
||||||
|
|
||||||
import PlusIC from '../../../../public/res/ic/outlined/plus.svg';
|
|
||||||
import HorizontalMenuIC from '../../../../public/res/ic/outlined/horizontal-menu.svg';
|
|
||||||
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
|
|
||||||
import ChevronRightIC from '../../../../public/res/ic/outlined/chevron-right.svg';
|
|
||||||
|
|
||||||
function RoomsCategory({
|
|
||||||
spaceId, name, hideHeader, roomIds, drawerPostie,
|
|
||||||
}) {
|
|
||||||
const { spaces, directs } = initMatrix.roomList;
|
|
||||||
const [isOpen, setIsOpen] = useState(true);
|
|
||||||
|
|
||||||
const openSpaceOptions = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
openReusableContextMenu(
|
|
||||||
'bottom',
|
|
||||||
getEventCords(e, '.header'),
|
|
||||||
(closeMenu) => <SpaceOptions roomId={spaceId} afterOptionSelect={closeMenu} />,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openHomeSpaceOptions = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
openReusableContextMenu(
|
|
||||||
'right',
|
|
||||||
getEventCords(e, '.ic-btn'),
|
|
||||||
(closeMenu) => <HomeSpaceOptions spaceId={spaceId} afterOptionSelect={closeMenu} />,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderSelector = (roomId) => {
|
|
||||||
const isSpace = spaces.has(roomId);
|
|
||||||
const isDM = directs.has(roomId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Selector
|
|
||||||
key={roomId}
|
|
||||||
roomId={roomId}
|
|
||||||
isDM={isDM}
|
|
||||||
drawerPostie={drawerPostie}
|
|
||||||
onClick={() => (isSpace ? selectSpace(roomId) : selectRoom(roomId))}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="room-category">
|
|
||||||
{!hideHeader && (
|
|
||||||
<div className="room-category__header">
|
|
||||||
<button className="room-category__toggle" onClick={() => setIsOpen(!isOpen)} type="button">
|
|
||||||
<RawIcon src={isOpen ? ChevronBottomIC : ChevronRightIC} size="extra-small" />
|
|
||||||
<Text className="cat-header" variant="b3" weight="medium">{name}</Text>
|
|
||||||
</button>
|
|
||||||
{spaceId && <IconButton onClick={openSpaceOptions} tooltip="Space options" src={HorizontalMenuIC} size="extra-small" />}
|
|
||||||
{spaceId && <IconButton onClick={openHomeSpaceOptions} tooltip="Add rooms/spaces" src={PlusIC} size="extra-small" />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(isOpen || hideHeader) && (
|
|
||||||
<div className="room-category__content">
|
|
||||||
{roomIds.map(renderSelector)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
RoomsCategory.defaultProps = {
|
|
||||||
spaceId: null,
|
|
||||||
hideHeader: false,
|
|
||||||
};
|
|
||||||
RoomsCategory.propTypes = {
|
|
||||||
spaceId: PropTypes.string,
|
|
||||||
name: PropTypes.string.isRequired,
|
|
||||||
hideHeader: PropTypes.bool,
|
|
||||||
roomIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
|
||||||
drawerPostie: PropTypes.shape({}).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RoomsCategory;
|
|
|
@ -1,54 +0,0 @@
|
||||||
@use '../../partials/flex';
|
|
||||||
@use '../../partials/dir';
|
|
||||||
@use '../../partials/text';
|
|
||||||
|
|
||||||
.room-category {
|
|
||||||
&__header,
|
|
||||||
&__toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
&__header {
|
|
||||||
margin-top: var(--sp-extra-tight);
|
|
||||||
|
|
||||||
& .ic-btn {
|
|
||||||
padding: var(--sp-ultra-tight);
|
|
||||||
border-radius: 4px;
|
|
||||||
@include dir.side(margin, 0, 5px);
|
|
||||||
& .ic-raw {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
background-color: var(--ic-surface-low);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&__toggle {
|
|
||||||
@extend .cp-fx__item-one;
|
|
||||||
padding: var(--sp-extra-tight) var(--sp-tight);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
& .ic-raw {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
background-color: var(--ic-surface-low);
|
|
||||||
@include dir.side(margin, 0, var(--sp-ultra-tight));
|
|
||||||
}
|
|
||||||
& .text {
|
|
||||||
text-transform: uppercase;
|
|
||||||
@extend .cp-txt__ellipsis;
|
|
||||||
}
|
|
||||||
&:hover .text {
|
|
||||||
color: var(--tc-surface-normal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__content:first-child {
|
|
||||||
margin-top: var(--sp-extra-tight);
|
|
||||||
}
|
|
||||||
|
|
||||||
& .room-selector {
|
|
||||||
width: calc(100% - var(--sp-extra-tight));
|
|
||||||
@include dir.side(margin, auto, 0);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,93 +0,0 @@
|
||||||
/* eslint-disable react/prop-types */
|
|
||||||
import React, { useEffect } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import navigation from '../../../client/state/navigation';
|
|
||||||
import { openReusableContextMenu } from '../../../client/action/navigation';
|
|
||||||
import { getEventCords, abbreviateNumber } from '../../../util/common';
|
|
||||||
import { joinRuleToIconSrc } from '../../../util/matrixUtil';
|
|
||||||
|
|
||||||
import IconButton from '../../atoms/button/IconButton';
|
|
||||||
import RoomSelector from '../../molecules/room-selector/RoomSelector';
|
|
||||||
import RoomOptions from '../../molecules/room-options/RoomOptions';
|
|
||||||
import SpaceOptions from '../../molecules/space-options/SpaceOptions';
|
|
||||||
|
|
||||||
import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
|
|
||||||
|
|
||||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
|
||||||
|
|
||||||
function Selector({
|
|
||||||
roomId, isDM, drawerPostie, onClick,
|
|
||||||
}) {
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const noti = initMatrix.notifications;
|
|
||||||
const room = mx.getRoom(roomId);
|
|
||||||
|
|
||||||
let imageSrc = room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
|
|
||||||
if (imageSrc === null) imageSrc = room.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
|
|
||||||
|
|
||||||
const isMuted = noti.getNotiType(roomId) === cons.notifs.MUTE;
|
|
||||||
|
|
||||||
const [, forceUpdate] = useForceUpdate();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unSub1 = drawerPostie.subscribe('selector-change', roomId, forceUpdate);
|
|
||||||
const unSub2 = drawerPostie.subscribe('unread-change', roomId, forceUpdate);
|
|
||||||
return () => {
|
|
||||||
unSub1();
|
|
||||||
unSub2();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const openOptions = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
openReusableContextMenu(
|
|
||||||
'right',
|
|
||||||
getEventCords(e, '.room-selector'),
|
|
||||||
room.isSpaceRoom()
|
|
||||||
? (closeMenu) => <SpaceOptions roomId={roomId} afterOptionSelect={closeMenu} />
|
|
||||||
: (closeMenu) => <RoomOptions roomId={roomId} afterOptionSelect={closeMenu} />,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RoomSelector
|
|
||||||
key={roomId}
|
|
||||||
name={room.name}
|
|
||||||
roomId={roomId}
|
|
||||||
imageSrc={isDM ? imageSrc : null}
|
|
||||||
iconSrc={isDM ? null : joinRuleToIconSrc(room.getJoinRule(), room.isSpaceRoom())}
|
|
||||||
isSelected={navigation.selectedRoomId === roomId}
|
|
||||||
isMuted={isMuted}
|
|
||||||
isUnread={!isMuted && noti.hasNoti(roomId)}
|
|
||||||
notificationCount={abbreviateNumber(noti.getTotalNoti(roomId))}
|
|
||||||
isAlert={noti.getHighlightNoti(roomId) !== 0}
|
|
||||||
onClick={onClick}
|
|
||||||
onContextMenu={openOptions}
|
|
||||||
options={(
|
|
||||||
<IconButton
|
|
||||||
size="extra-small"
|
|
||||||
tooltip="Options"
|
|
||||||
tooltipPlacement="right"
|
|
||||||
src={VerticalMenuIC}
|
|
||||||
onClick={openOptions}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Selector.defaultProps = {
|
|
||||||
isDM: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
Selector.propTypes = {
|
|
||||||
roomId: PropTypes.string.isRequired,
|
|
||||||
isDM: PropTypes.bool,
|
|
||||||
drawerPostie: PropTypes.shape({}).isRequired,
|
|
||||||
onClick: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Selector;
|
|
|
@ -1,390 +0,0 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './SideBar.scss';
|
|
||||||
|
|
||||||
import { DndProvider, useDrag, useDrop } from 'react-dnd';
|
|
||||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import colorMXID from '../../../util/colorMXID';
|
|
||||||
import {
|
|
||||||
selectTab, openShortcutSpaces, openInviteList,
|
|
||||||
openSearch, openSettings, openReusableContextMenu,
|
|
||||||
} from '../../../client/action/navigation';
|
|
||||||
import { moveSpaceShortcut } from '../../../client/action/accountData';
|
|
||||||
import { abbreviateNumber, getEventCords } from '../../../util/common';
|
|
||||||
import { isCrossVerified } from '../../../util/matrixUtil';
|
|
||||||
|
|
||||||
import Avatar from '../../atoms/avatar/Avatar';
|
|
||||||
import NotificationBadge from '../../atoms/badge/NotificationBadge';
|
|
||||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
|
||||||
import SidebarAvatar from '../../molecules/sidebar-avatar/SidebarAvatar';
|
|
||||||
import SpaceOptions from '../../molecules/space-options/SpaceOptions';
|
|
||||||
|
|
||||||
import HomeIC from '../../../../public/res/ic/outlined/home.svg';
|
|
||||||
import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
|
||||||
import AddPinIC from '../../../../public/res/ic/outlined/add-pin.svg';
|
|
||||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
|
||||||
import InviteIC from '../../../../public/res/ic/outlined/invite.svg';
|
|
||||||
import ShieldUserIC from '../../../../public/res/ic/outlined/shield-user.svg';
|
|
||||||
|
|
||||||
import { useSelectedTab } from '../../hooks/useSelectedTab';
|
|
||||||
import { useDeviceList } from '../../hooks/useDeviceList';
|
|
||||||
|
|
||||||
import { tabText as settingTabText } from '../settings/Settings';
|
|
||||||
|
|
||||||
function useNotificationUpdate() {
|
|
||||||
const { notifications } = initMatrix;
|
|
||||||
const [, forceUpdate] = useState({});
|
|
||||||
useEffect(() => {
|
|
||||||
function onNotificationChanged(roomId, total, prevTotal) {
|
|
||||||
if (total === prevTotal) return;
|
|
||||||
forceUpdate({});
|
|
||||||
}
|
|
||||||
notifications.on(cons.events.notifications.NOTI_CHANGED, onNotificationChanged);
|
|
||||||
return () => {
|
|
||||||
notifications.removeListener(cons.events.notifications.NOTI_CHANGED, onNotificationChanged);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProfileAvatarMenu() {
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const [profile, setProfile] = useState({
|
|
||||||
avatarUrl: null,
|
|
||||||
displayName: mx.getUser(mx.getUserId()).displayName,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const user = mx.getUser(mx.getUserId());
|
|
||||||
const setNewProfile = (avatarUrl, displayName) => setProfile({
|
|
||||||
avatarUrl: avatarUrl || null,
|
|
||||||
displayName: displayName || profile.displayName,
|
|
||||||
});
|
|
||||||
const onAvatarChange = (event, myUser) => {
|
|
||||||
setNewProfile(myUser.avatarUrl, myUser.displayName);
|
|
||||||
};
|
|
||||||
mx.getProfileInfo(mx.getUserId()).then((info) => {
|
|
||||||
setNewProfile(info.avatar_url, info.displayname);
|
|
||||||
});
|
|
||||||
user.on('User.avatarUrl', onAvatarChange);
|
|
||||||
return () => {
|
|
||||||
user.removeListener('User.avatarUrl', onAvatarChange);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarAvatar
|
|
||||||
onClick={openSettings}
|
|
||||||
tooltip="Settings"
|
|
||||||
avatar={(
|
|
||||||
<Avatar
|
|
||||||
text={profile.displayName}
|
|
||||||
bgColor={colorMXID(mx.getUserId())}
|
|
||||||
size="normal"
|
|
||||||
imageSrc={profile.avatarUrl !== null ? mx.mxcUrlToHttp(profile.avatarUrl, 42, 42, 'crop') : null}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CrossSigninAlert() {
|
|
||||||
const deviceList = useDeviceList();
|
|
||||||
const unverified = deviceList?.filter((device) => isCrossVerified(device.device_id) === false);
|
|
||||||
|
|
||||||
if (!unverified?.length) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarAvatar
|
|
||||||
className="sidebar__cross-signin-alert"
|
|
||||||
tooltip={`${unverified.length} unverified sessions`}
|
|
||||||
onClick={() => openSettings(settingTabText.SECURITY)}
|
|
||||||
avatar={<Avatar iconSrc={ShieldUserIC} iconColor="var(--ic-danger-normal)" size="normal" />}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FeaturedTab() {
|
|
||||||
const { roomList, accountData, notifications } = initMatrix;
|
|
||||||
const [selectedTab] = useSelectedTab();
|
|
||||||
useNotificationUpdate();
|
|
||||||
|
|
||||||
function getHomeNoti() {
|
|
||||||
const orphans = roomList.getOrphans();
|
|
||||||
let noti = null;
|
|
||||||
|
|
||||||
orphans.forEach((roomId) => {
|
|
||||||
if (accountData.spaceShortcut.has(roomId)) return;
|
|
||||||
if (!notifications.hasNoti(roomId)) return;
|
|
||||||
if (noti === null) noti = { total: 0, highlight: 0 };
|
|
||||||
const childNoti = notifications.getNoti(roomId);
|
|
||||||
noti.total += childNoti.total;
|
|
||||||
noti.highlight += childNoti.highlight;
|
|
||||||
});
|
|
||||||
|
|
||||||
return noti;
|
|
||||||
}
|
|
||||||
function getDMsNoti() {
|
|
||||||
if (roomList.directs.size === 0) return null;
|
|
||||||
let noti = null;
|
|
||||||
|
|
||||||
[...roomList.directs].forEach((roomId) => {
|
|
||||||
if (!notifications.hasNoti(roomId)) return;
|
|
||||||
if (noti === null) noti = { total: 0, highlight: 0 };
|
|
||||||
const childNoti = notifications.getNoti(roomId);
|
|
||||||
noti.total += childNoti.total;
|
|
||||||
noti.highlight += childNoti.highlight;
|
|
||||||
});
|
|
||||||
|
|
||||||
return noti;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dmsNoti = getDMsNoti();
|
|
||||||
const homeNoti = getHomeNoti();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SidebarAvatar
|
|
||||||
tooltip="Home"
|
|
||||||
active={selectedTab === cons.tabs.HOME}
|
|
||||||
onClick={() => selectTab(cons.tabs.HOME)}
|
|
||||||
avatar={<Avatar iconSrc={HomeIC} size="normal" />}
|
|
||||||
notificationBadge={homeNoti ? (
|
|
||||||
<NotificationBadge
|
|
||||||
alert={homeNoti?.highlight > 0}
|
|
||||||
content={abbreviateNumber(homeNoti.total) || null}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
/>
|
|
||||||
<SidebarAvatar
|
|
||||||
tooltip="People"
|
|
||||||
active={selectedTab === cons.tabs.DIRECTS}
|
|
||||||
onClick={() => selectTab(cons.tabs.DIRECTS)}
|
|
||||||
avatar={<Avatar iconSrc={UserIC} size="normal" />}
|
|
||||||
notificationBadge={dmsNoti ? (
|
|
||||||
<NotificationBadge
|
|
||||||
alert={dmsNoti?.highlight > 0}
|
|
||||||
content={abbreviateNumber(dmsNoti.total) || null}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DraggableSpaceShortcut({
|
|
||||||
isActive, spaceId, index, moveShortcut, onDrop,
|
|
||||||
}) {
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const { notifications } = initMatrix;
|
|
||||||
const room = mx.getRoom(spaceId);
|
|
||||||
const shortcutRef = useRef(null);
|
|
||||||
const avatarRef = useRef(null);
|
|
||||||
|
|
||||||
const openSpaceOptions = (e, sId) => {
|
|
||||||
e.preventDefault();
|
|
||||||
openReusableContextMenu(
|
|
||||||
'right',
|
|
||||||
getEventCords(e, '.sidebar-avatar'),
|
|
||||||
(closeMenu) => <SpaceOptions roomId={sId} afterOptionSelect={closeMenu} />,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [, drop] = useDrop({
|
|
||||||
accept: 'SPACE_SHORTCUT',
|
|
||||||
collect(monitor) {
|
|
||||||
return {
|
|
||||||
handlerId: monitor.getHandlerId(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
drop(item) {
|
|
||||||
onDrop(item.index, item.spaceId);
|
|
||||||
},
|
|
||||||
hover(item, monitor) {
|
|
||||||
if (!shortcutRef.current) return;
|
|
||||||
|
|
||||||
const dragIndex = item.index;
|
|
||||||
const hoverIndex = index;
|
|
||||||
if (dragIndex === hoverIndex) return;
|
|
||||||
|
|
||||||
const hoverBoundingRect = shortcutRef.current?.getBoundingClientRect();
|
|
||||||
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
|
||||||
const clientOffset = monitor.getClientOffset();
|
|
||||||
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
|
|
||||||
|
|
||||||
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
moveShortcut(dragIndex, hoverIndex);
|
|
||||||
// eslint-disable-next-line no-param-reassign
|
|
||||||
item.index = hoverIndex;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const [{ isDragging }, drag] = useDrag({
|
|
||||||
type: 'SPACE_SHORTCUT',
|
|
||||||
item: () => ({ spaceId, index }),
|
|
||||||
collect: (monitor) => ({
|
|
||||||
isDragging: monitor.isDragging(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
drag(avatarRef);
|
|
||||||
drop(shortcutRef);
|
|
||||||
|
|
||||||
if (shortcutRef.current) {
|
|
||||||
if (isDragging) shortcutRef.current.style.opacity = 0;
|
|
||||||
else shortcutRef.current.style.opacity = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarAvatar
|
|
||||||
ref={shortcutRef}
|
|
||||||
active={isActive}
|
|
||||||
tooltip={room.name}
|
|
||||||
onClick={() => selectTab(spaceId)}
|
|
||||||
onContextMenu={(e) => openSpaceOptions(e, spaceId)}
|
|
||||||
avatar={(
|
|
||||||
<Avatar
|
|
||||||
ref={avatarRef}
|
|
||||||
text={room.name}
|
|
||||||
bgColor={colorMXID(room.roomId)}
|
|
||||||
size="normal"
|
|
||||||
imageSrc={room.getAvatarUrl(initMatrix.matrixClient.baseUrl, 42, 42, 'crop') || null}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
notificationBadge={notifications.hasNoti(spaceId) ? (
|
|
||||||
<NotificationBadge
|
|
||||||
alert={notifications.getHighlightNoti(spaceId) > 0}
|
|
||||||
content={abbreviateNumber(notifications.getTotalNoti(spaceId)) || null}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
DraggableSpaceShortcut.propTypes = {
|
|
||||||
spaceId: PropTypes.string.isRequired,
|
|
||||||
isActive: PropTypes.bool.isRequired,
|
|
||||||
index: PropTypes.number.isRequired,
|
|
||||||
moveShortcut: PropTypes.func.isRequired,
|
|
||||||
onDrop: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
function SpaceShortcut() {
|
|
||||||
const { accountData } = initMatrix;
|
|
||||||
const [selectedTab] = useSelectedTab();
|
|
||||||
useNotificationUpdate();
|
|
||||||
const [spaceShortcut, setSpaceShortcut] = useState([...accountData.spaceShortcut]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleShortcut = () => setSpaceShortcut([...accountData.spaceShortcut]);
|
|
||||||
accountData.on(cons.events.accountData.SPACE_SHORTCUT_UPDATED, handleShortcut);
|
|
||||||
return () => {
|
|
||||||
accountData.removeListener(cons.events.accountData.SPACE_SHORTCUT_UPDATED, handleShortcut);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const moveShortcut = (dragIndex, hoverIndex) => {
|
|
||||||
const dragSpaceId = spaceShortcut[dragIndex];
|
|
||||||
const newShortcuts = [...spaceShortcut];
|
|
||||||
newShortcuts.splice(dragIndex, 1);
|
|
||||||
newShortcuts.splice(hoverIndex, 0, dragSpaceId);
|
|
||||||
setSpaceShortcut(newShortcuts);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (dragIndex, dragSpaceId) => {
|
|
||||||
if ([...accountData.spaceShortcut][dragIndex] === dragSpaceId) return;
|
|
||||||
moveSpaceShortcut(dragSpaceId, dragIndex);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndProvider backend={HTML5Backend}>
|
|
||||||
{
|
|
||||||
spaceShortcut.map((shortcut, index) => (
|
|
||||||
<DraggableSpaceShortcut
|
|
||||||
key={shortcut}
|
|
||||||
index={index}
|
|
||||||
spaceId={shortcut}
|
|
||||||
isActive={selectedTab === shortcut}
|
|
||||||
moveShortcut={moveShortcut}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</DndProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useTotalInvites() {
|
|
||||||
const { roomList } = initMatrix;
|
|
||||||
const totalInviteCount = () => roomList.inviteRooms.size
|
|
||||||
+ roomList.inviteSpaces.size
|
|
||||||
+ roomList.inviteDirects.size;
|
|
||||||
const [totalInvites, updateTotalInvites] = useState(totalInviteCount());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onInviteListChange = () => {
|
|
||||||
updateTotalInvites(totalInviteCount());
|
|
||||||
};
|
|
||||||
roomList.on(cons.events.roomList.INVITELIST_UPDATED, onInviteListChange);
|
|
||||||
return () => {
|
|
||||||
roomList.removeListener(cons.events.roomList.INVITELIST_UPDATED, onInviteListChange);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return [totalInvites];
|
|
||||||
}
|
|
||||||
|
|
||||||
function SideBar() {
|
|
||||||
const [totalInvites] = useTotalInvites();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="sidebar">
|
|
||||||
<div className="sidebar__scrollable">
|
|
||||||
<ScrollView invisible>
|
|
||||||
<div className="scrollable-content">
|
|
||||||
<div className="featured-container">
|
|
||||||
<FeaturedTab />
|
|
||||||
</div>
|
|
||||||
<div className="sidebar-divider" />
|
|
||||||
<div className="space-container">
|
|
||||||
<SpaceShortcut />
|
|
||||||
<SidebarAvatar
|
|
||||||
tooltip="Pin spaces"
|
|
||||||
onClick={() => openShortcutSpaces()}
|
|
||||||
avatar={<Avatar iconSrc={AddPinIC} size="normal" />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ScrollView>
|
|
||||||
</div>
|
|
||||||
<div className="sidebar__sticky">
|
|
||||||
<div className="sidebar-divider" />
|
|
||||||
<div className="sticky-container">
|
|
||||||
<SidebarAvatar
|
|
||||||
tooltip="Search"
|
|
||||||
onClick={() => openSearch()}
|
|
||||||
avatar={<Avatar iconSrc={SearchIC} size="normal" />}
|
|
||||||
/>
|
|
||||||
{ totalInvites !== 0 && (
|
|
||||||
<SidebarAvatar
|
|
||||||
tooltip="Invites"
|
|
||||||
onClick={() => openInviteList()}
|
|
||||||
avatar={<Avatar iconSrc={InviteIC} size="normal" />}
|
|
||||||
notificationBadge={<NotificationBadge alert content={totalInvites} />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<CrossSigninAlert />
|
|
||||||
<ProfileAvatarMenu />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SideBar;
|
|
|
@ -1,75 +0,0 @@
|
||||||
@use '../../partials/flex';
|
|
||||||
@use '../../partials/dir';
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
@extend .cp-fx__column;
|
|
||||||
|
|
||||||
width: var(--navigation-sidebar-width);
|
|
||||||
height: 100%;
|
|
||||||
background-color: var(--bg-surface-extra-low);
|
|
||||||
@include dir.side(border, none, 1px solid var(--bg-surface-border));
|
|
||||||
|
|
||||||
&__scrollable,
|
|
||||||
&__sticky {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__scrollable {
|
|
||||||
@extend .cp-fx__item-one;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollable-content {
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 8px;
|
|
||||||
|
|
||||||
background: transparent;
|
|
||||||
background-image: linear-gradient(
|
|
||||||
to top,
|
|
||||||
var(--bg-surface-extra-low),
|
|
||||||
var(--bg-surface-extra-low-transparent)
|
|
||||||
);
|
|
||||||
position: sticky;
|
|
||||||
bottom: -1px;
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-container,
|
|
||||||
.space-container,
|
|
||||||
.sticky-container {
|
|
||||||
@extend .cp-fx__column--c-c;
|
|
||||||
|
|
||||||
padding: var(--sp-ultra-tight) 0;
|
|
||||||
|
|
||||||
& > .sidebar-avatar,
|
|
||||||
& > .avatar-container {
|
|
||||||
margin: calc(var(--sp-tight) / 2) 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sidebar-divider {
|
|
||||||
margin: auto;
|
|
||||||
width: 24px;
|
|
||||||
height: 1px;
|
|
||||||
background-color: var(--bg-surface-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar__cross-signin-alert .avatar-container {
|
|
||||||
box-shadow: var(--bs-danger-border);
|
|
||||||
animation-name: pushRight;
|
|
||||||
animation-duration: 400ms;
|
|
||||||
animation-iteration-count: 30;
|
|
||||||
animation-direction: alternate;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pushRight {
|
|
||||||
from {
|
|
||||||
transform: translateX(4px) scale(1);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateX(0) scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +1,5 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
|
@ -22,7 +21,9 @@ function ProfileEditor({ userId }) {
|
||||||
const user = mx.getUser(mx.getUserId());
|
const user = mx.getUser(mx.getUserId());
|
||||||
|
|
||||||
const displayNameRef = useRef(null);
|
const displayNameRef = useRef(null);
|
||||||
const [avatarSrc, setAvatarSrc] = useState(user.avatarUrl ? mx.mxcUrlToHttp(user.avatarUrl, 80, 80, 'crop') : null);
|
const [avatarSrc, setAvatarSrc] = useState(
|
||||||
|
user.avatarUrl ? mx.mxcUrlToHttp(user.avatarUrl, 80, 80, 'crop') : null
|
||||||
|
);
|
||||||
const [username, setUsername] = useState(user.displayName);
|
const [username, setUsername] = useState(user.displayName);
|
||||||
const [disabled, setDisabled] = useState(true);
|
const [disabled, setDisabled] = useState(true);
|
||||||
|
|
||||||
|
@ -44,7 +45,7 @@ function ProfileEditor({ userId }) {
|
||||||
'Remove avatar',
|
'Remove avatar',
|
||||||
'Are you sure that you want to remove avatar?',
|
'Are you sure that you want to remove avatar?',
|
||||||
'Remove',
|
'Remove',
|
||||||
'caution',
|
'caution'
|
||||||
);
|
);
|
||||||
if (isConfirmed) {
|
if (isConfirmed) {
|
||||||
mx.setAvatarUrl('');
|
mx.setAvatarUrl('');
|
||||||
|
@ -79,7 +80,10 @@ function ProfileEditor({ userId }) {
|
||||||
<form
|
<form
|
||||||
className="profile-editor__form"
|
className="profile-editor__form"
|
||||||
style={{ marginBottom: avatarSrc ? '24px' : '0' }}
|
style={{ marginBottom: avatarSrc ? '24px' : '0' }}
|
||||||
onSubmit={(e) => { e.preventDefault(); saveDisplayName(); }}
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
saveDisplayName();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
label={`Display name of ${mx.getUserId()}`}
|
label={`Display name of ${mx.getUserId()}`}
|
||||||
|
@ -87,7 +91,9 @@ function ProfileEditor({ userId }) {
|
||||||
value={mx.getUser(mx.getUserId()).displayName}
|
value={mx.getUser(mx.getUserId()).displayName}
|
||||||
forwardRef={displayNameRef}
|
forwardRef={displayNameRef}
|
||||||
/>
|
/>
|
||||||
<Button variant="primary" type="submit" disabled={disabled}>Save</Button>
|
<Button variant="primary" type="submit" disabled={disabled}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
<Button onClick={cancelDisplayNameChanges}>Cancel</Button>
|
<Button onClick={cancelDisplayNameChanges}>Cancel</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
@ -95,7 +101,9 @@ function ProfileEditor({ userId }) {
|
||||||
const renderInfo = () => (
|
const renderInfo = () => (
|
||||||
<div className="profile-editor__info" style={{ marginBottom: avatarSrc ? '24px' : '0' }}>
|
<div className="profile-editor__info" style={{ marginBottom: avatarSrc ? '24px' : '0' }}>
|
||||||
<div>
|
<div>
|
||||||
<Text variant="h2" primary weight="medium">{twemojify(username) ?? userId}</Text>
|
<Text variant="h2" primary weight="medium">
|
||||||
|
{username ?? userId}
|
||||||
|
</Text>
|
||||||
<IconButton
|
<IconButton
|
||||||
src={PencilIC}
|
src={PencilIC}
|
||||||
size="extra-small"
|
size="extra-small"
|
||||||
|
@ -116,9 +124,7 @@ function ProfileEditor({ userId }) {
|
||||||
onUpload={handleAvatarUpload}
|
onUpload={handleAvatarUpload}
|
||||||
onRequestRemove={() => handleAvatarUpload(null)}
|
onRequestRemove={() => handleAvatarUpload(null)}
|
||||||
/>
|
/>
|
||||||
{
|
{isEditing ? renderForm() : renderInfo()}
|
||||||
isEditing ? renderForm() : renderInfo()
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,16 +2,17 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import './ProfileViewer.scss';
|
import './ProfileViewer.scss';
|
||||||
|
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import cons from '../../../client/state/cons';
|
import cons from '../../../client/state/cons';
|
||||||
import navigation from '../../../client/state/navigation';
|
import navigation from '../../../client/state/navigation';
|
||||||
import { selectRoom, openReusableContextMenu } from '../../../client/action/navigation';
|
import { openReusableContextMenu } from '../../../client/action/navigation';
|
||||||
import * as roomActions from '../../../client/action/room';
|
import * as roomActions from '../../../client/action/room';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getUsername, getUsernameOfRoomMember, getPowerLabel, hasDMWith, hasDevices,
|
getUsername,
|
||||||
|
getUsernameOfRoomMember,
|
||||||
|
getPowerLabel,
|
||||||
|
hasDevices,
|
||||||
} from '../../../util/matrixUtil';
|
} from '../../../util/matrixUtil';
|
||||||
import { getEventCords } from '../../../util/common';
|
import { getEventCords } from '../../../util/common';
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
|
@ -33,26 +34,24 @@ import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||||
|
|
||||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||||
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
||||||
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
|
import { getDMRoomFor } from '../../utils/matrix';
|
||||||
|
|
||||||
function ModerationTools({
|
function ModerationTools({ roomId, userId }) {
|
||||||
roomId, userId,
|
|
||||||
}) {
|
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
const roomMember = room.getMember(userId);
|
const roomMember = room.getMember(userId);
|
||||||
|
|
||||||
const myPowerLevel = room.getMember(mx.getUserId())?.powerLevel || 0;
|
const myPowerLevel = room.getMember(mx.getUserId())?.powerLevel || 0;
|
||||||
const powerLevel = roomMember?.powerLevel || 0;
|
const powerLevel = roomMember?.powerLevel || 0;
|
||||||
const canIKick = (
|
const canIKick =
|
||||||
roomMember?.membership === 'join'
|
roomMember?.membership === 'join' &&
|
||||||
&& room.currentState.hasSufficientPowerLevelFor('kick', myPowerLevel)
|
room.currentState.hasSufficientPowerLevelFor('kick', myPowerLevel) &&
|
||||||
&& powerLevel < myPowerLevel
|
powerLevel < myPowerLevel;
|
||||||
);
|
const canIBan =
|
||||||
const canIBan = (
|
['join', 'leave'].includes(roomMember?.membership) &&
|
||||||
['join', 'leave'].includes(roomMember?.membership)
|
room.currentState.hasSufficientPowerLevelFor('ban', myPowerLevel) &&
|
||||||
&& room.currentState.hasSufficientPowerLevelFor('ban', myPowerLevel)
|
powerLevel < myPowerLevel;
|
||||||
&& powerLevel < myPowerLevel
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleKick = (e) => {
|
const handleKick = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -120,13 +119,14 @@ function SessionInfo({ userId }) {
|
||||||
<div className="session-info__chips">
|
<div className="session-info__chips">
|
||||||
{devices === null && <Text variant="b2">Loading sessions...</Text>}
|
{devices === null && <Text variant="b2">Loading sessions...</Text>}
|
||||||
{devices?.length === 0 && <Text variant="b2">No session found.</Text>}
|
{devices?.length === 0 && <Text variant="b2">No session found.</Text>}
|
||||||
{devices !== null && (devices.map((device) => (
|
{devices !== null &&
|
||||||
<Chip
|
devices.map((device) => (
|
||||||
key={device.deviceId}
|
<Chip
|
||||||
iconSrc={ShieldEmptyIC}
|
key={device.deviceId}
|
||||||
text={device.getDisplayName() || device.deviceId}
|
iconSrc={ShieldEmptyIC}
|
||||||
/>
|
text={device.getDisplayName() || device.deviceId}
|
||||||
)))}
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -137,7 +137,11 @@ function SessionInfo({ userId }) {
|
||||||
onClick={() => setIsVisible(!isVisible)}
|
onClick={() => setIsVisible(!isVisible)}
|
||||||
iconSrc={isVisible ? ChevronBottomIC : ChevronRightIC}
|
iconSrc={isVisible ? ChevronBottomIC : ChevronRightIC}
|
||||||
>
|
>
|
||||||
<Text variant="b2">{`View ${devices?.length > 0 ? `${devices.length} ${devices.length == 1 ? 'session' : 'sessions'}` : 'sessions'}`}</Text>
|
<Text variant="b2">{`View ${
|
||||||
|
devices?.length > 0
|
||||||
|
? `${devices.length} ${devices.length == 1 ? 'session' : 'sessions'}`
|
||||||
|
: 'sessions'
|
||||||
|
}`}</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{renderSessionChips()}
|
{renderSessionChips()}
|
||||||
</div>
|
</div>
|
||||||
|
@ -155,6 +159,7 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
|
||||||
|
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
|
const { navigateRoom } = useRoomNavigate();
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
const member = room.getMember(userId);
|
const member = room.getMember(userId);
|
||||||
const isInvitable = member?.membership !== 'join' && member?.membership !== 'ban';
|
const isInvitable = member?.membership !== 'join' && member?.membership !== 'ban';
|
||||||
|
@ -164,25 +169,18 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
|
||||||
|
|
||||||
const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel || 0;
|
const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel || 0;
|
||||||
const userPL = room.getMember(userId)?.powerLevel || 0;
|
const userPL = room.getMember(userId)?.powerLevel || 0;
|
||||||
const canIKick = room.currentState.hasSufficientPowerLevelFor('kick', myPowerlevel) && userPL < myPowerlevel;
|
const canIKick =
|
||||||
|
room.currentState.hasSufficientPowerLevelFor('kick', myPowerlevel) && userPL < myPowerlevel;
|
||||||
|
|
||||||
const isBanned = member?.membership === 'ban';
|
const isBanned = member?.membership === 'ban';
|
||||||
|
|
||||||
const onCreated = (dmRoomId) => {
|
const onCreated = (dmRoomId) => {
|
||||||
if (isMountedRef.current === false) return;
|
if (isMountedRef.current === false) return;
|
||||||
setIsCreatingDM(false);
|
setIsCreatingDM(false);
|
||||||
selectRoom(dmRoomId);
|
navigateRoom(dmRoomId);
|
||||||
onRequestClose();
|
onRequestClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const { roomList } = initMatrix;
|
|
||||||
roomList.on(cons.events.roomList.ROOM_CREATED, onCreated);
|
|
||||||
return () => {
|
|
||||||
isMountedRef.current = false;
|
|
||||||
roomList.removeListener(cons.events.roomList.ROOM_CREATED, onCreated);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsUserIgnored(initMatrix.matrixClient.isUserIgnored(userId));
|
setIsUserIgnored(initMatrix.matrixClient.isUserIgnored(userId));
|
||||||
setIsIgnoring(false);
|
setIsIgnoring(false);
|
||||||
|
@ -191,9 +189,9 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
|
||||||
|
|
||||||
const openDM = async () => {
|
const openDM = async () => {
|
||||||
// Check and open if user already have a DM with userId.
|
// Check and open if user already have a DM with userId.
|
||||||
const dmRoomId = hasDMWith(userId);
|
const dmRoomId = getDMRoomFor(mx, userId)?.roomId;
|
||||||
if (dmRoomId) {
|
if (dmRoomId) {
|
||||||
selectRoom(dmRoomId);
|
navigateRoom(dmRoomId);
|
||||||
onRequestClose();
|
onRequestClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -201,7 +199,8 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
|
||||||
// Create new DM
|
// Create new DM
|
||||||
try {
|
try {
|
||||||
setIsCreatingDM(true);
|
setIsCreatingDM(true);
|
||||||
await roomActions.createDM(userId, await hasDevices(userId));
|
const result = await roomActions.createDM(userId, await hasDevices(userId));
|
||||||
|
onCreated(result.room_id);
|
||||||
} catch {
|
} catch {
|
||||||
if (isMountedRef.current === false) return;
|
if (isMountedRef.current === false) return;
|
||||||
setIsCreatingDM(false);
|
setIsCreatingDM(false);
|
||||||
|
@ -246,31 +245,19 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="profile-viewer__buttons">
|
<div className="profile-viewer__buttons">
|
||||||
<Button
|
<Button variant="primary" onClick={openDM} disabled={isCreatingDM}>
|
||||||
variant="primary"
|
|
||||||
onClick={openDM}
|
|
||||||
disabled={isCreatingDM}
|
|
||||||
>
|
|
||||||
{isCreatingDM ? 'Creating room...' : 'Message'}
|
{isCreatingDM ? 'Creating room...' : 'Message'}
|
||||||
</Button>
|
</Button>
|
||||||
{ isBanned && canIKick && (
|
{isBanned && canIKick && (
|
||||||
<Button
|
<Button variant="positive" onClick={() => roomActions.unban(roomId, userId)}>
|
||||||
variant="positive"
|
|
||||||
onClick={() => roomActions.unban(roomId, userId)}
|
|
||||||
>
|
|
||||||
Unban
|
Unban
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{ (isInvited ? canIKick : room.canInvite(mx.getUserId())) && isInvitable && (
|
{(isInvited ? canIKick : room.canInvite(mx.getUserId())) && isInvitable && (
|
||||||
<Button
|
<Button onClick={toggleInvite} disabled={isInviting}>
|
||||||
onClick={toggleInvite}
|
{isInvited
|
||||||
disabled={isInviting}
|
? `${isInviting ? 'Disinviting...' : 'Disinvite'}`
|
||||||
>
|
: `${isInviting ? 'Inviting...' : 'Invite'}`}
|
||||||
{
|
|
||||||
isInvited
|
|
||||||
? `${isInviting ? 'Disinviting...' : 'Disinvite'}`
|
|
||||||
: `${isInviting ? 'Inviting...' : 'Invite'}`
|
|
||||||
}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
@ -278,11 +265,9 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
|
||||||
onClick={toggleIgnore}
|
onClick={toggleIgnore}
|
||||||
disabled={isIgnoring}
|
disabled={isIgnoring}
|
||||||
>
|
>
|
||||||
{
|
{isUserIgnored
|
||||||
isUserIgnored
|
? `${isIgnoring ? 'Unignoring...' : 'Unignore'}`
|
||||||
? `${isIgnoring ? 'Unignoring...' : 'Unignore'}`
|
: `${isIgnoring ? 'Ignoring...' : 'Ignore'}`}
|
||||||
: `${isIgnoring ? 'Ignoring...' : 'Ignore'}`
|
|
||||||
}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -326,8 +311,8 @@ function useRerenderOnProfileChange(roomId, userId) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleProfileChange = (mEvent, member) => {
|
const handleProfileChange = (mEvent, member) => {
|
||||||
if (
|
if (
|
||||||
mEvent.getRoomId() === roomId
|
mEvent.getRoomId() === roomId &&
|
||||||
&& (member.userId === userId || member.userId === mx.getUserId())
|
(member.userId === userId || member.userId === mx.getUserId())
|
||||||
) {
|
) {
|
||||||
forceUpdate();
|
forceUpdate();
|
||||||
}
|
}
|
||||||
|
@ -352,20 +337,22 @@ function ProfileViewer() {
|
||||||
const roomMember = room.getMember(userId);
|
const roomMember = room.getMember(userId);
|
||||||
const username = roomMember ? getUsernameOfRoomMember(roomMember) : getUsername(userId);
|
const username = roomMember ? getUsernameOfRoomMember(roomMember) : getUsername(userId);
|
||||||
const avatarMxc = roomMember?.getMxcAvatarUrl?.() || mx.getUser(userId)?.avatarUrl;
|
const avatarMxc = roomMember?.getMxcAvatarUrl?.() || mx.getUser(userId)?.avatarUrl;
|
||||||
const avatarUrl = (avatarMxc && avatarMxc !== 'null') ? mx.mxcUrlToHttp(avatarMxc, 80, 80, 'crop') : null;
|
const avatarUrl =
|
||||||
|
avatarMxc && avatarMxc !== 'null' ? mx.mxcUrlToHttp(avatarMxc, 80, 80, 'crop') : null;
|
||||||
|
|
||||||
const powerLevel = roomMember?.powerLevel || 0;
|
const powerLevel = roomMember?.powerLevel || 0;
|
||||||
const myPowerLevel = room.getMember(mx.getUserId())?.powerLevel || 0;
|
const myPowerLevel = room.getMember(mx.getUserId())?.powerLevel || 0;
|
||||||
|
|
||||||
const canChangeRole = (
|
const canChangeRole =
|
||||||
room.currentState.maySendEvent('m.room.power_levels', mx.getUserId())
|
room.currentState.maySendEvent('m.room.power_levels', mx.getUserId()) &&
|
||||||
&& (powerLevel < myPowerLevel || userId === mx.getUserId())
|
(powerLevel < myPowerLevel || userId === mx.getUserId());
|
||||||
);
|
|
||||||
|
|
||||||
const handleChangePowerLevel = async (newPowerLevel) => {
|
const handleChangePowerLevel = async (newPowerLevel) => {
|
||||||
if (newPowerLevel === powerLevel) return;
|
if (newPowerLevel === powerLevel) return;
|
||||||
const SHARED_POWER_MSG = 'You will not be able to undo this change as you are promoting the user to have the same power level as yourself. Are you sure?';
|
const SHARED_POWER_MSG =
|
||||||
const DEMOTING_MYSELF_MSG = 'You will not be able to undo this change as you are demoting yourself. Are you sure?';
|
'You will not be able to undo this change as you are promoting the user to have the same power level as yourself. Are you sure?';
|
||||||
|
const DEMOTING_MYSELF_MSG =
|
||||||
|
'You will not be able to undo this change as you are demoting yourself. Are you sure?';
|
||||||
|
|
||||||
const isSharedPower = newPowerLevel === myPowerLevel;
|
const isSharedPower = newPowerLevel === myPowerLevel;
|
||||||
const isDemotingMyself = userId === mx.getUserId();
|
const isDemotingMyself = userId === mx.getUserId();
|
||||||
|
@ -374,7 +361,7 @@ function ProfileViewer() {
|
||||||
'Change power level',
|
'Change power level',
|
||||||
isSharedPower ? SHARED_POWER_MSG : DEMOTING_MYSELF_MSG,
|
isSharedPower ? SHARED_POWER_MSG : DEMOTING_MYSELF_MSG,
|
||||||
'Change',
|
'Change',
|
||||||
'caution',
|
'caution'
|
||||||
);
|
);
|
||||||
if (!isConfirmed) return;
|
if (!isConfirmed) return;
|
||||||
roomActions.setPowerLevel(roomId, userId, newPowerLevel);
|
roomActions.setPowerLevel(roomId, userId, newPowerLevel);
|
||||||
|
@ -384,20 +371,16 @@ function ProfileViewer() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePowerSelector = (e) => {
|
const handlePowerSelector = (e) => {
|
||||||
openReusableContextMenu(
|
openReusableContextMenu('bottom', getEventCords(e, '.btn-surface'), (closeMenu) => (
|
||||||
'bottom',
|
<PowerLevelSelector
|
||||||
getEventCords(e, '.btn-surface'),
|
value={powerLevel}
|
||||||
(closeMenu) => (
|
max={myPowerLevel}
|
||||||
<PowerLevelSelector
|
onSelect={(pl) => {
|
||||||
value={powerLevel}
|
closeMenu();
|
||||||
max={myPowerLevel}
|
handleChangePowerLevel(pl);
|
||||||
onSelect={(pl) => {
|
}}
|
||||||
closeMenu();
|
/>
|
||||||
handleChangePowerLevel(pl);
|
));
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -405,8 +388,10 @@ function ProfileViewer() {
|
||||||
<div className="profile-viewer__user">
|
<div className="profile-viewer__user">
|
||||||
<Avatar imageSrc={avatarUrl} text={username} bgColor={colorMXID(userId)} size="large" />
|
<Avatar imageSrc={avatarUrl} text={username} bgColor={colorMXID(userId)} size="large" />
|
||||||
<div className="profile-viewer__user__info">
|
<div className="profile-viewer__user__info">
|
||||||
<Text variant="s1" weight="medium">{twemojify(username)}</Text>
|
<Text variant="s1" weight="medium">
|
||||||
<Text variant="b2">{twemojify(userId)}</Text>
|
{username}
|
||||||
|
</Text>
|
||||||
|
<Text variant="b2">{userId}</Text>
|
||||||
</div>
|
</div>
|
||||||
<div className="profile-viewer__user__role">
|
<div className="profile-viewer__user__role">
|
||||||
<Text variant="b3">Role</Text>
|
<Text variant="b3">Role</Text>
|
||||||
|
@ -420,7 +405,7 @@ function ProfileViewer() {
|
||||||
</div>
|
</div>
|
||||||
<ModerationTools roomId={roomId} userId={userId} />
|
<ModerationTools roomId={roomId} userId={userId} />
|
||||||
<SessionInfo userId={userId} />
|
<SessionInfo userId={userId} />
|
||||||
{ userId !== mx.getUserId() && (
|
{userId !== mx.getUserId() && (
|
||||||
<ProfileFooter roomId={roomId} userId={userId} onRequestClose={closeDialog} />
|
<ProfileFooter roomId={roomId} userId={userId} onRequestClose={closeDialog} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,295 +0,0 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './PublicRooms.scss';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import { selectRoom, selectTab } from '../../../client/action/navigation';
|
|
||||||
import * as roomActions from '../../../client/action/room';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import Button from '../../atoms/button/Button';
|
|
||||||
import IconButton from '../../atoms/button/IconButton';
|
|
||||||
import Spinner from '../../atoms/spinner/Spinner';
|
|
||||||
import Input from '../../atoms/input/Input';
|
|
||||||
import PopupWindow from '../../molecules/popup-window/PopupWindow';
|
|
||||||
import RoomTile from '../../molecules/room-tile/RoomTile';
|
|
||||||
|
|
||||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
|
||||||
import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg';
|
|
||||||
|
|
||||||
const SEARCH_LIMIT = 20;
|
|
||||||
|
|
||||||
function TryJoinWithAlias({ alias, onRequestClose }) {
|
|
||||||
const [status, setStatus] = useState({
|
|
||||||
isJoining: false,
|
|
||||||
error: null,
|
|
||||||
roomId: null,
|
|
||||||
tempRoomId: null,
|
|
||||||
});
|
|
||||||
function handleOnRoomAdded(roomId) {
|
|
||||||
if (status.tempRoomId !== null && status.tempRoomId !== roomId) return;
|
|
||||||
setStatus({
|
|
||||||
isJoining: false, error: null, roomId, tempRoomId: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
initMatrix.roomList.on(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
|
|
||||||
return () => {
|
|
||||||
initMatrix.roomList.removeListener(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
|
|
||||||
};
|
|
||||||
}, [status]);
|
|
||||||
|
|
||||||
async function joinWithAlias() {
|
|
||||||
setStatus({
|
|
||||||
isJoining: true, error: null, roomId: null, tempRoomId: null,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const roomId = await roomActions.join(alias, false);
|
|
||||||
setStatus({
|
|
||||||
isJoining: true, error: null, roomId: null, tempRoomId: roomId,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
setStatus({
|
|
||||||
isJoining: false,
|
|
||||||
error: `Unable to join ${alias}. Either room is private or doesn't exist.`,
|
|
||||||
roomId: null,
|
|
||||||
tempRoomId: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="try-join-with-alias">
|
|
||||||
{status.roomId === null && !status.isJoining && status.error === null && (
|
|
||||||
<Button onClick={() => joinWithAlias()}>{`Try joining ${alias}`}</Button>
|
|
||||||
)}
|
|
||||||
{status.isJoining && (
|
|
||||||
<>
|
|
||||||
<Spinner size="small" />
|
|
||||||
<Text>{`Joining ${alias}...`}</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{status.roomId !== null && (
|
|
||||||
<Button onClick={() => { onRequestClose(); selectRoom(status.roomId); }}>Open</Button>
|
|
||||||
)}
|
|
||||||
{status.error !== null && <Text variant="b2"><span style={{ color: 'var(--bg-danger)' }}>{status.error}</span></Text>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
TryJoinWithAlias.propTypes = {
|
|
||||||
alias: PropTypes.string.isRequired,
|
|
||||||
onRequestClose: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
function PublicRooms({ isOpen, searchTerm, onRequestClose }) {
|
|
||||||
const [isSearching, updateIsSearching] = useState(false);
|
|
||||||
const [isViewMore, updateIsViewMore] = useState(false);
|
|
||||||
const [publicRooms, updatePublicRooms] = useState([]);
|
|
||||||
const [nextBatch, updateNextBatch] = useState(undefined);
|
|
||||||
const [searchQuery, updateSearchQuery] = useState({});
|
|
||||||
const [joiningRooms, updateJoiningRooms] = useState(new Set());
|
|
||||||
|
|
||||||
const roomNameRef = useRef(null);
|
|
||||||
const hsRef = useRef(null);
|
|
||||||
const userId = initMatrix.matrixClient.getUserId();
|
|
||||||
|
|
||||||
async function searchRooms(viewMore) {
|
|
||||||
let inputRoomName = roomNameRef?.current?.value || searchTerm;
|
|
||||||
let isInputAlias = false;
|
|
||||||
if (typeof inputRoomName === 'string') {
|
|
||||||
isInputAlias = inputRoomName[0] === '#' && inputRoomName.indexOf(':') > 1;
|
|
||||||
}
|
|
||||||
const hsFromAlias = (isInputAlias) ? inputRoomName.slice(inputRoomName.indexOf(':') + 1) : null;
|
|
||||||
let inputHs = hsFromAlias || hsRef?.current?.value;
|
|
||||||
|
|
||||||
if (typeof inputHs !== 'string') inputHs = userId.slice(userId.indexOf(':') + 1);
|
|
||||||
if (typeof inputRoomName !== 'string') inputRoomName = '';
|
|
||||||
|
|
||||||
if (isSearching) return;
|
|
||||||
if (viewMore !== true
|
|
||||||
&& inputRoomName === searchQuery.name
|
|
||||||
&& inputHs === searchQuery.homeserver
|
|
||||||
) return;
|
|
||||||
|
|
||||||
updateSearchQuery({
|
|
||||||
name: inputRoomName,
|
|
||||||
homeserver: inputHs,
|
|
||||||
});
|
|
||||||
if (isViewMore !== viewMore) updateIsViewMore(viewMore);
|
|
||||||
updateIsSearching(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await initMatrix.matrixClient.publicRooms({
|
|
||||||
server: inputHs,
|
|
||||||
limit: SEARCH_LIMIT,
|
|
||||||
since: viewMore ? nextBatch : undefined,
|
|
||||||
include_all_networks: true,
|
|
||||||
filter: {
|
|
||||||
generic_search_term: inputRoomName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalRooms = viewMore ? publicRooms.concat(result.chunk) : result.chunk;
|
|
||||||
updatePublicRooms(totalRooms);
|
|
||||||
updateNextBatch(result.next_batch);
|
|
||||||
updateIsSearching(false);
|
|
||||||
updateIsViewMore(false);
|
|
||||||
if (totalRooms.length === 0) {
|
|
||||||
updateSearchQuery({
|
|
||||||
error: inputRoomName === ''
|
|
||||||
? `No public rooms on ${inputHs}`
|
|
||||||
: `No result found for "${inputRoomName}" on ${inputHs}`,
|
|
||||||
alias: isInputAlias ? inputRoomName : null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
updatePublicRooms([]);
|
|
||||||
let err = 'Something went wrong!';
|
|
||||||
if (e?.httpStatus >= 400 && e?.httpStatus < 500) {
|
|
||||||
err = e.message;
|
|
||||||
}
|
|
||||||
updateSearchQuery({
|
|
||||||
error: err,
|
|
||||||
alias: isInputAlias ? inputRoomName : null,
|
|
||||||
});
|
|
||||||
updateIsSearching(false);
|
|
||||||
updateNextBatch(undefined);
|
|
||||||
updateIsViewMore(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) searchRooms();
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
function handleOnRoomAdded(roomId) {
|
|
||||||
if (joiningRooms.has(roomId)) {
|
|
||||||
joiningRooms.delete(roomId);
|
|
||||||
updateJoiningRooms(new Set(Array.from(joiningRooms)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
useEffect(() => {
|
|
||||||
initMatrix.roomList.on(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
|
|
||||||
return () => {
|
|
||||||
initMatrix.roomList.removeListener(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
|
|
||||||
};
|
|
||||||
}, [joiningRooms]);
|
|
||||||
|
|
||||||
function handleViewRoom(roomId) {
|
|
||||||
const room = initMatrix.matrixClient.getRoom(roomId);
|
|
||||||
if (room.isSpaceRoom()) selectTab(roomId);
|
|
||||||
else selectRoom(roomId);
|
|
||||||
onRequestClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
function joinRoom(roomIdOrAlias) {
|
|
||||||
joiningRooms.add(roomIdOrAlias);
|
|
||||||
updateJoiningRooms(new Set(Array.from(joiningRooms)));
|
|
||||||
roomActions.join(roomIdOrAlias, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRoomList(rooms) {
|
|
||||||
return rooms.map((room) => {
|
|
||||||
const alias = typeof room.canonical_alias === 'string' ? room.canonical_alias : room.room_id;
|
|
||||||
const name = typeof room.name === 'string' ? room.name : alias;
|
|
||||||
const isJoined = initMatrix.matrixClient.getRoom(room.room_id)?.getMyMembership() === 'join';
|
|
||||||
return (
|
|
||||||
<RoomTile
|
|
||||||
key={room.room_id}
|
|
||||||
avatarSrc={typeof room.avatar_url === 'string' ? initMatrix.matrixClient.mxcUrlToHttp(room.avatar_url, 42, 42, 'crop') : null}
|
|
||||||
name={name}
|
|
||||||
id={alias}
|
|
||||||
memberCount={room.num_joined_members}
|
|
||||||
desc={typeof room.topic === 'string' ? room.topic : null}
|
|
||||||
options={(
|
|
||||||
<>
|
|
||||||
{isJoined && <Button onClick={() => handleViewRoom(room.room_id)}>Open</Button>}
|
|
||||||
{!isJoined && (joiningRooms.has(room.room_id) ? <Spinner size="small" /> : <Button onClick={() => joinRoom(room.aliases?.[0] || room.room_id)} variant="primary">Join</Button>)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PopupWindow
|
|
||||||
isOpen={isOpen}
|
|
||||||
title="Public rooms"
|
|
||||||
contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
|
|
||||||
onRequestClose={onRequestClose}
|
|
||||||
>
|
|
||||||
<div className="public-rooms">
|
|
||||||
<form className="public-rooms__form" onSubmit={(e) => { e.preventDefault(); searchRooms(); }}>
|
|
||||||
<div className="public-rooms__input-wrapper">
|
|
||||||
<Input value={searchTerm} forwardRef={roomNameRef} label="Room name or alias" />
|
|
||||||
<Input forwardRef={hsRef} value={userId.slice(userId.indexOf(':') + 1)} label="Homeserver" required />
|
|
||||||
</div>
|
|
||||||
<Button disabled={isSearching} iconSrc={HashSearchIC} variant="primary" type="submit">Search</Button>
|
|
||||||
</form>
|
|
||||||
<div className="public-rooms__search-status">
|
|
||||||
{
|
|
||||||
typeof searchQuery.name !== 'undefined' && isSearching && (
|
|
||||||
searchQuery.name === ''
|
|
||||||
? (
|
|
||||||
<div className="flex--center">
|
|
||||||
<Spinner size="small" />
|
|
||||||
<Text variant="b2">{`Loading public rooms from ${searchQuery.homeserver}...`}</Text>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<div className="flex--center">
|
|
||||||
<Spinner size="small" />
|
|
||||||
<Text variant="b2">{`Searching for "${searchQuery.name}" on ${searchQuery.homeserver}...`}</Text>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
{
|
|
||||||
typeof searchQuery.name !== 'undefined' && !isSearching && (
|
|
||||||
searchQuery.name === ''
|
|
||||||
? <Text variant="b2">{`Public rooms on ${searchQuery.homeserver}.`}</Text>
|
|
||||||
: <Text variant="b2">{`Search result for "${searchQuery.name}" on ${searchQuery.homeserver}.`}</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
{ searchQuery.error && (
|
|
||||||
<>
|
|
||||||
<Text className="public-rooms__search-error" variant="b2">{searchQuery.error}</Text>
|
|
||||||
{typeof searchQuery.alias === 'string' && (
|
|
||||||
<TryJoinWithAlias onRequestClose={onRequestClose} alias={searchQuery.alias} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{ publicRooms.length !== 0 && (
|
|
||||||
<div className="public-rooms__content">
|
|
||||||
{ renderRoomList(publicRooms) }
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{ publicRooms.length !== 0 && publicRooms.length % SEARCH_LIMIT === 0 && (
|
|
||||||
<div className="public-rooms__view-more">
|
|
||||||
{ isViewMore !== true && (
|
|
||||||
<Button onClick={() => searchRooms(true)}>View more</Button>
|
|
||||||
)}
|
|
||||||
{ isViewMore && <Spinner /> }
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PopupWindow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
PublicRooms.defaultProps = {
|
|
||||||
searchTerm: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
PublicRooms.propTypes = {
|
|
||||||
isOpen: PropTypes.bool.isRequired,
|
|
||||||
searchTerm: PropTypes.string,
|
|
||||||
onRequestClose: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PublicRooms;
|
|
|
@ -1,85 +0,0 @@
|
||||||
@use '../../partials/dir';
|
|
||||||
|
|
||||||
.public-rooms {
|
|
||||||
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
|
|
||||||
margin-top: var(--sp-extra-tight);
|
|
||||||
|
|
||||||
&__form {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
|
|
||||||
& .btn-primary {
|
|
||||||
padding: {
|
|
||||||
top: 11px;
|
|
||||||
bottom: 11px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&__input-wrapper {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
@include dir.side(margin, 0, var(--sp-normal));
|
|
||||||
|
|
||||||
& > div:first-child {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
|
|
||||||
& .input {
|
|
||||||
@include dir.prop(border-radius,
|
|
||||||
var(--bo-radius) 0 0 var(--bo-radius),
|
|
||||||
0 var(--bo-radius) var(--bo-radius) 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& > div:last-child .input {
|
|
||||||
width: 120px;
|
|
||||||
@include dir.prop(border-left-width, 0, 1px);
|
|
||||||
@include dir.prop(border-right-width, 1px, 0);
|
|
||||||
@include dir.prop(border-radius,
|
|
||||||
0 var(--bo-radius) var(--bo-radius) 0,
|
|
||||||
var(--bo-radius) 0 0 var(--bo-radius),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__search-status {
|
|
||||||
margin-top: var(--sp-extra-loose);
|
|
||||||
margin-bottom: var(--sp-tight);
|
|
||||||
& .donut-spinner {
|
|
||||||
margin: 0 var(--sp-tight);
|
|
||||||
}
|
|
||||||
|
|
||||||
.try-join-with-alias {
|
|
||||||
margin-top: var(--sp-normal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&__search-error {
|
|
||||||
color: var(--bg-danger);
|
|
||||||
}
|
|
||||||
&__content {
|
|
||||||
border-top: 1px solid var(--bg-surface-border);
|
|
||||||
}
|
|
||||||
&__view-more {
|
|
||||||
margin-top: var(--sp-loose);
|
|
||||||
@include dir.side(margin, calc(var(--av-normal) + var(--sp-normal)), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
& .room-tile {
|
|
||||||
margin-top: var(--sp-normal);
|
|
||||||
&__options {
|
|
||||||
align-self: flex-end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.try-join-with-alias {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
& >.text:nth-child(2) {
|
|
||||||
margin: 0 var(--sp-normal);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,11 +1,8 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import ReadReceipts from '../read-receipts/ReadReceipts';
|
|
||||||
import ProfileViewer from '../profile-viewer/ProfileViewer';
|
import ProfileViewer from '../profile-viewer/ProfileViewer';
|
||||||
import ShortcutSpaces from '../shortcut-spaces/ShortcutSpaces';
|
|
||||||
import SpaceAddExisting from '../../molecules/space-add-existing/SpaceAddExisting';
|
import SpaceAddExisting from '../../molecules/space-add-existing/SpaceAddExisting';
|
||||||
import Search from '../search/Search';
|
import Search from '../search/Search';
|
||||||
import ViewSource from '../view-source/ViewSource';
|
|
||||||
import CreateRoom from '../create-room/CreateRoom';
|
import CreateRoom from '../create-room/CreateRoom';
|
||||||
import JoinAlias from '../join-alias/JoinAlias';
|
import JoinAlias from '../join-alias/JoinAlias';
|
||||||
import EmojiVerification from '../emoji-verification/EmojiVerification';
|
import EmojiVerification from '../emoji-verification/EmojiVerification';
|
||||||
|
@ -15,10 +12,7 @@ import ReusableDialog from '../../molecules/dialog/ReusableDialog';
|
||||||
function Dialogs() {
|
function Dialogs() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ReadReceipts />
|
|
||||||
<ViewSource />
|
|
||||||
<ProfileViewer />
|
<ProfileViewer />
|
||||||
<ShortcutSpaces />
|
|
||||||
<CreateRoom />
|
<CreateRoom />
|
||||||
<JoinAlias />
|
<JoinAlias />
|
||||||
<SpaceAddExisting />
|
<SpaceAddExisting />
|
||||||
|
|
|
@ -3,35 +3,18 @@ import React, { useState, useEffect } from 'react';
|
||||||
import cons from '../../../client/state/cons';
|
import cons from '../../../client/state/cons';
|
||||||
import navigation from '../../../client/state/navigation';
|
import navigation from '../../../client/state/navigation';
|
||||||
|
|
||||||
import InviteList from '../invite-list/InviteList';
|
|
||||||
import PublicRooms from '../public-rooms/PublicRooms';
|
|
||||||
import InviteUser from '../invite-user/InviteUser';
|
import InviteUser from '../invite-user/InviteUser';
|
||||||
import Settings from '../settings/Settings';
|
import Settings from '../settings/Settings';
|
||||||
import SpaceSettings from '../space-settings/SpaceSettings';
|
import SpaceSettings from '../space-settings/SpaceSettings';
|
||||||
import SpaceManage from '../space-manage/SpaceManage';
|
|
||||||
import RoomSettings from '../room/RoomSettings';
|
import RoomSettings from '../room/RoomSettings';
|
||||||
|
|
||||||
function Windows() {
|
function Windows() {
|
||||||
const [isInviteList, changeInviteList] = useState(false);
|
|
||||||
const [publicRooms, changePublicRooms] = useState({
|
|
||||||
isOpen: false,
|
|
||||||
searchTerm: undefined,
|
|
||||||
});
|
|
||||||
const [inviteUser, changeInviteUser] = useState({
|
const [inviteUser, changeInviteUser] = useState({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
roomId: undefined,
|
roomId: undefined,
|
||||||
term: undefined,
|
term: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
function openInviteList() {
|
|
||||||
changeInviteList(true);
|
|
||||||
}
|
|
||||||
function openPublicRooms(searchTerm) {
|
|
||||||
changePublicRooms({
|
|
||||||
isOpen: true,
|
|
||||||
searchTerm,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function openInviteUser(roomId, searchTerm) {
|
function openInviteUser(roomId, searchTerm) {
|
||||||
changeInviteUser({
|
changeInviteUser({
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
|
@ -41,24 +24,14 @@ function Windows() {
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.on(cons.events.navigation.INVITE_LIST_OPENED, openInviteList);
|
|
||||||
navigation.on(cons.events.navigation.PUBLIC_ROOMS_OPENED, openPublicRooms);
|
|
||||||
navigation.on(cons.events.navigation.INVITE_USER_OPENED, openInviteUser);
|
navigation.on(cons.events.navigation.INVITE_USER_OPENED, openInviteUser);
|
||||||
return () => {
|
return () => {
|
||||||
navigation.removeListener(cons.events.navigation.INVITE_LIST_OPENED, openInviteList);
|
|
||||||
navigation.removeListener(cons.events.navigation.PUBLIC_ROOMS_OPENED, openPublicRooms);
|
|
||||||
navigation.removeListener(cons.events.navigation.INVITE_USER_OPENED, openInviteUser);
|
navigation.removeListener(cons.events.navigation.INVITE_USER_OPENED, openInviteUser);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<InviteList isOpen={isInviteList} onRequestClose={() => changeInviteList(false)} />
|
|
||||||
<PublicRooms
|
|
||||||
isOpen={publicRooms.isOpen}
|
|
||||||
searchTerm={publicRooms.searchTerm}
|
|
||||||
onRequestClose={() => changePublicRooms({ isOpen: false, searchTerm: undefined })}
|
|
||||||
/>
|
|
||||||
<InviteUser
|
<InviteUser
|
||||||
isOpen={inviteUser.isOpen}
|
isOpen={inviteUser.isOpen}
|
||||||
roomId={inviteUser.roomId}
|
roomId={inviteUser.roomId}
|
||||||
|
@ -68,7 +41,6 @@ function Windows() {
|
||||||
<Settings />
|
<Settings />
|
||||||
<SpaceSettings />
|
<SpaceSettings />
|
||||||
<RoomSettings />
|
<RoomSettings />
|
||||||
<SpaceManage />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,76 +0,0 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import navigation from '../../../client/state/navigation';
|
|
||||||
import { getUsername, getUsernameOfRoomMember } from '../../../util/matrixUtil';
|
|
||||||
import colorMXID from '../../../util/colorMXID';
|
|
||||||
|
|
||||||
import IconButton from '../../atoms/button/IconButton';
|
|
||||||
import PeopleSelector from '../../molecules/people-selector/PeopleSelector';
|
|
||||||
import Dialog from '../../molecules/dialog/Dialog';
|
|
||||||
|
|
||||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
|
||||||
import { openProfileViewer } from '../../../client/action/navigation';
|
|
||||||
|
|
||||||
function ReadReceipts() {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [readers, setReaders] = useState([]);
|
|
||||||
const [roomId, setRoomId] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadReadReceipts = (rId, userIds) => {
|
|
||||||
setReaders(userIds);
|
|
||||||
setRoomId(rId);
|
|
||||||
setIsOpen(true);
|
|
||||||
};
|
|
||||||
navigation.on(cons.events.navigation.READRECEIPTS_OPENED, loadReadReceipts);
|
|
||||||
return () => {
|
|
||||||
navigation.removeListener(cons.events.navigation.READRECEIPTS_OPENED, loadReadReceipts);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAfterClose = () => {
|
|
||||||
setReaders([]);
|
|
||||||
setRoomId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
function renderPeople(userId) {
|
|
||||||
const room = initMatrix.matrixClient.getRoom(roomId);
|
|
||||||
const member = room.getMember(userId);
|
|
||||||
const getUserDisplayName = () => {
|
|
||||||
if (room?.getMember(userId)) return getUsernameOfRoomMember(room.getMember(userId));
|
|
||||||
return getUsername(userId);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<PeopleSelector
|
|
||||||
key={userId}
|
|
||||||
onClick={() => {
|
|
||||||
setIsOpen(false);
|
|
||||||
openProfileViewer(userId, roomId);
|
|
||||||
}}
|
|
||||||
avatarSrc={member?.getAvatarUrl(initMatrix.matrixClient.baseUrl, 24, 24, 'crop')}
|
|
||||||
name={getUserDisplayName(userId)}
|
|
||||||
color={colorMXID(userId)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
isOpen={isOpen}
|
|
||||||
title="Seen by"
|
|
||||||
onAfterClose={handleAfterClose}
|
|
||||||
onRequestClose={() => setIsOpen(false)}
|
|
||||||
contentOptions={<IconButton src={CrossIC} onClick={() => setIsOpen(false)} tooltip="Close" />}
|
|
||||||
>
|
|
||||||
<div style={{ marginTop: 'var(--sp-tight)', marginBottom: 'var(--sp-extra-loose)' }}>
|
|
||||||
{
|
|
||||||
readers.map(renderPeople)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ReadReceipts;
|
|
|
@ -1,35 +0,0 @@
|
||||||
class EventLimit {
|
|
||||||
constructor() {
|
|
||||||
this._from = 0;
|
|
||||||
|
|
||||||
this.SMALLEST_EVT_HEIGHT = 32;
|
|
||||||
this.PAGES_COUNT = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
get maxEvents() {
|
|
||||||
return Math.round(document.body.clientHeight / this.SMALLEST_EVT_HEIGHT) * this.PAGES_COUNT;
|
|
||||||
}
|
|
||||||
|
|
||||||
get from() {
|
|
||||||
return this._from;
|
|
||||||
}
|
|
||||||
|
|
||||||
get length() {
|
|
||||||
return this._from + this.maxEvents;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFrom(from) {
|
|
||||||
this._from = from < 0 ? 0 : from;
|
|
||||||
}
|
|
||||||
|
|
||||||
paginate(backwards, limit, timelineLength) {
|
|
||||||
this._from = backwards ? this._from - limit : this._from + limit;
|
|
||||||
|
|
||||||
if (!backwards && this.length > timelineLength) {
|
|
||||||
this._from = timelineLength - this.maxEvents;
|
|
||||||
}
|
|
||||||
if (this._from < 0) this._from = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default EventLimit;
|
|
|
@ -1,215 +0,0 @@
|
||||||
import React, {
|
|
||||||
useState, useEffect, useCallback, useRef,
|
|
||||||
} from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './PeopleDrawer.scss';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import { getPowerLabel, getUsernameOfRoomMember } from '../../../util/matrixUtil';
|
|
||||||
import colorMXID from '../../../util/colorMXID';
|
|
||||||
import { openInviteUser, openProfileViewer } from '../../../client/action/navigation';
|
|
||||||
import AsyncSearch from '../../../util/AsyncSearch';
|
|
||||||
import { memberByAtoZ, memberByPowerLevel } from '../../../util/sort';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import Header, { TitleWrapper } from '../../atoms/header/Header';
|
|
||||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
|
||||||
import IconButton from '../../atoms/button/IconButton';
|
|
||||||
import Button from '../../atoms/button/Button';
|
|
||||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
|
||||||
import Input from '../../atoms/input/Input';
|
|
||||||
import SegmentedControl from '../../atoms/segmented-controls/SegmentedControls';
|
|
||||||
import PeopleSelector from '../../molecules/people-selector/PeopleSelector';
|
|
||||||
|
|
||||||
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
|
|
||||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
|
||||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
|
||||||
|
|
||||||
function simplyfiMembers(members) {
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
return members.map((member) => ({
|
|
||||||
userId: member.userId,
|
|
||||||
name: getUsernameOfRoomMember(member),
|
|
||||||
username: member.userId.slice(1, member.userId.indexOf(':')),
|
|
||||||
avatarSrc: member.getAvatarUrl(mx.baseUrl, 24, 24, 'crop'),
|
|
||||||
peopleRole: getPowerLabel(member.powerLevel),
|
|
||||||
powerLevel: members.powerLevel,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const asyncSearch = new AsyncSearch();
|
|
||||||
function PeopleDrawer({ roomId }) {
|
|
||||||
const PER_PAGE_MEMBER = 50;
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const room = mx.getRoom(roomId);
|
|
||||||
const canInvite = room?.canInvite(mx.getUserId());
|
|
||||||
|
|
||||||
const [itemCount, setItemCount] = useState(PER_PAGE_MEMBER);
|
|
||||||
const [membership, setMembership] = useState('join');
|
|
||||||
const [memberList, setMemberList] = useState([]);
|
|
||||||
const [searchedMembers, setSearchedMembers] = useState(null);
|
|
||||||
const searchRef = useRef(null);
|
|
||||||
|
|
||||||
const getMembersWithMembership = useCallback(
|
|
||||||
(mship) => room.getMembersWithMembership(mship),
|
|
||||||
[roomId, membership],
|
|
||||||
);
|
|
||||||
|
|
||||||
function loadMorePeople() {
|
|
||||||
setItemCount(itemCount + PER_PAGE_MEMBER);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSearchData(data) {
|
|
||||||
// NOTICE: data is passed as object property
|
|
||||||
// because react sucks at handling state update with array.
|
|
||||||
setSearchedMembers({ data });
|
|
||||||
setItemCount(PER_PAGE_MEMBER);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSearch(e) {
|
|
||||||
const term = e.target.value;
|
|
||||||
if (term === '' || term === undefined) {
|
|
||||||
searchRef.current.value = '';
|
|
||||||
searchRef.current.focus();
|
|
||||||
setSearchedMembers(null);
|
|
||||||
setItemCount(PER_PAGE_MEMBER);
|
|
||||||
} else asyncSearch.search(term);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
asyncSearch.setup(memberList, {
|
|
||||||
keys: ['name', 'username', 'userId'],
|
|
||||||
limit: PER_PAGE_MEMBER,
|
|
||||||
});
|
|
||||||
}, [memberList]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isLoadingMembers = false;
|
|
||||||
let isRoomChanged = false;
|
|
||||||
const updateMemberList = (event) => {
|
|
||||||
if (isLoadingMembers) return;
|
|
||||||
if (event && event?.getRoomId() !== roomId) return;
|
|
||||||
setMemberList(
|
|
||||||
simplyfiMembers(
|
|
||||||
getMembersWithMembership(membership)
|
|
||||||
.sort(memberByAtoZ).sort(memberByPowerLevel),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
searchRef.current.value = '';
|
|
||||||
updateMemberList();
|
|
||||||
isLoadingMembers = true;
|
|
||||||
room.loadMembersIfNeeded().then(() => {
|
|
||||||
isLoadingMembers = false;
|
|
||||||
if (isRoomChanged) return;
|
|
||||||
updateMemberList();
|
|
||||||
});
|
|
||||||
|
|
||||||
asyncSearch.on(asyncSearch.RESULT_SENT, handleSearchData);
|
|
||||||
mx.on('RoomMember.membership', updateMemberList);
|
|
||||||
mx.on('RoomMember.powerLevel', updateMemberList);
|
|
||||||
return () => {
|
|
||||||
isRoomChanged = true;
|
|
||||||
setMemberList([]);
|
|
||||||
setSearchedMembers(null);
|
|
||||||
setItemCount(PER_PAGE_MEMBER);
|
|
||||||
asyncSearch.removeListener(asyncSearch.RESULT_SENT, handleSearchData);
|
|
||||||
mx.removeListener('RoomMember.membership', updateMemberList);
|
|
||||||
mx.removeListener('RoomMember.powerLevel', updateMemberList);
|
|
||||||
};
|
|
||||||
}, [roomId, membership]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMembership('join');
|
|
||||||
}, [roomId]);
|
|
||||||
|
|
||||||
const mList = searchedMembers !== null ? searchedMembers.data : memberList.slice(0, itemCount);
|
|
||||||
return (
|
|
||||||
<div className="people-drawer">
|
|
||||||
<Header>
|
|
||||||
<TitleWrapper>
|
|
||||||
<Text variant="s1" primary>
|
|
||||||
People
|
|
||||||
<Text className="people-drawer__member-count" variant="b3">{`${room.getJoinedMemberCount()} members`}</Text>
|
|
||||||
</Text>
|
|
||||||
</TitleWrapper>
|
|
||||||
<IconButton onClick={() => openInviteUser(roomId)} tooltip="Invite" src={AddUserIC} disabled={!canInvite} />
|
|
||||||
</Header>
|
|
||||||
<div className="people-drawer__content-wrapper">
|
|
||||||
<div className="people-drawer__scrollable">
|
|
||||||
<ScrollView autoHide>
|
|
||||||
<div className="people-drawer__content">
|
|
||||||
<SegmentedControl
|
|
||||||
selected={
|
|
||||||
(() => {
|
|
||||||
const getSegmentIndex = {
|
|
||||||
join: 0,
|
|
||||||
invite: 1,
|
|
||||||
ban: 2,
|
|
||||||
};
|
|
||||||
return getSegmentIndex[membership];
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
segments={[{ text: 'Joined' }, { text: 'Invited' }, { text: 'Banned' }]}
|
|
||||||
onSelect={(index) => {
|
|
||||||
const selectSegment = [
|
|
||||||
() => setMembership('join'),
|
|
||||||
() => setMembership('invite'),
|
|
||||||
() => setMembership('ban'),
|
|
||||||
];
|
|
||||||
selectSegment[index]?.();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{
|
|
||||||
mList.map((member) => (
|
|
||||||
<PeopleSelector
|
|
||||||
key={member.userId}
|
|
||||||
onClick={() => openProfileViewer(member.userId, roomId)}
|
|
||||||
avatarSrc={member.avatarSrc}
|
|
||||||
name={member.name}
|
|
||||||
color={colorMXID(member.userId)}
|
|
||||||
peopleRole={member.peopleRole}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
{
|
|
||||||
(searchedMembers?.data.length === 0 || memberList.length === 0)
|
|
||||||
&& (
|
|
||||||
<div className="people-drawer__noresult">
|
|
||||||
<Text variant="b2">No results found!</Text>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
<div className="people-drawer__load-more">
|
|
||||||
{
|
|
||||||
mList.length !== 0
|
|
||||||
&& memberList.length > itemCount
|
|
||||||
&& searchedMembers === null
|
|
||||||
&& (
|
|
||||||
<Button onClick={loadMorePeople}>View more</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ScrollView>
|
|
||||||
</div>
|
|
||||||
<div className="people-drawer__sticky">
|
|
||||||
<form onSubmit={(e) => e.preventDefault()} className="people-search">
|
|
||||||
<RawIcon size="small" src={SearchIC} />
|
|
||||||
<Input forwardRef={searchRef} type="text" onChange={handleSearch} placeholder="Search" required />
|
|
||||||
{
|
|
||||||
searchedMembers !== null
|
|
||||||
&& <IconButton onClick={handleSearch} size="small" src={CrossIC} />
|
|
||||||
}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
PeopleDrawer.propTypes = {
|
|
||||||
roomId: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PeopleDrawer;
|
|
|
@ -1,93 +0,0 @@
|
||||||
@use '../../partials/flex';
|
|
||||||
@use '../../partials/dir';
|
|
||||||
|
|
||||||
.people-drawer {
|
|
||||||
@extend .cp-fx__column;
|
|
||||||
width: var(--people-drawer-width);
|
|
||||||
background-color: var(--bg-surface-low);
|
|
||||||
@include dir.side(border, 1px solid var(--bg-surface-border), none);
|
|
||||||
|
|
||||||
&__member-count {
|
|
||||||
color: var(--tc-surface-low);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__content-wrapper {
|
|
||||||
@extend .cp-fx__item-one;
|
|
||||||
@extend .cp-fx__column;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__scrollable {
|
|
||||||
@extend .cp-fx__item-one;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__noresult {
|
|
||||||
padding: var(--sp-extra-tight) var(--sp-normal);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__sticky {
|
|
||||||
& .people-search {
|
|
||||||
--search-input-height: 40px;
|
|
||||||
min-height: var(--search-input-height);
|
|
||||||
|
|
||||||
margin: 0 var(--sp-extra-tight);
|
|
||||||
|
|
||||||
position: relative;
|
|
||||||
bottom: var(--sp-normal);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
& > .ic-raw,
|
|
||||||
& > .ic-btn {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 99;
|
|
||||||
}
|
|
||||||
& > .ic-raw {
|
|
||||||
@include dir.prop(left, var(--sp-tight), unset);
|
|
||||||
@include dir.prop(right, unset, var(--sp-tight));
|
|
||||||
}
|
|
||||||
& > .ic-btn {
|
|
||||||
@include dir.prop(right, 2px, unset);
|
|
||||||
@include dir.prop(left, unset, 2px);
|
|
||||||
}
|
|
||||||
& .input-container {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
& .input {
|
|
||||||
padding: 0 44px;
|
|
||||||
height: var(--search-input-height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.people-drawer__content {
|
|
||||||
padding-top: var(--sp-extra-tight);
|
|
||||||
padding-bottom: calc(2 * var(--sp-normal));
|
|
||||||
|
|
||||||
& .people-selector {
|
|
||||||
padding: var(--sp-extra-tight);
|
|
||||||
border-radius: var(--bo-radius);
|
|
||||||
&__container {
|
|
||||||
@include dir.side(margin, var(--sp-extra-tight), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& .segmented-controls {
|
|
||||||
display: flex;
|
|
||||||
margin-bottom: var(--sp-extra-tight);
|
|
||||||
@include dir.side(margin, var(--sp-extra-tight), 0);
|
|
||||||
}
|
|
||||||
& .segment-btn {
|
|
||||||
flex: 1;
|
|
||||||
padding: var(--sp-ultra-tight) 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.people-drawer__load-more {
|
|
||||||
padding: var(--sp-normal) 0 0;
|
|
||||||
@include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
|
|
||||||
|
|
||||||
& .btn-surface {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
@use '../../partials/flex';
|
|
||||||
@use '../../partials/screen';
|
|
||||||
|
|
||||||
.room {
|
|
||||||
@extend .cp-fx__row;
|
|
||||||
height: 100%;
|
|
||||||
flex-grow: 1;
|
|
||||||
|
|
||||||
&__content {
|
|
||||||
@extend .cp-fx__item-one;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.room .people-drawer {
|
|
||||||
@include screen.smallerThan(tabletBreakpoint) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -5,7 +5,6 @@ import './RoomSettings.scss';
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import cons from '../../../client/state/cons';
|
import cons from '../../../client/state/cons';
|
||||||
import navigation from '../../../client/state/navigation';
|
import navigation from '../../../client/state/navigation';
|
||||||
import * as roomActions from '../../../client/action/room';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
import Tabs from '../../atoms/tabs/Tabs';
|
import Tabs from '../../atoms/tabs/Tabs';
|
||||||
|
@ -86,7 +85,7 @@ function GeneralSettings({ roomId }) {
|
||||||
'danger'
|
'danger'
|
||||||
);
|
);
|
||||||
if (!isConfirmed) return;
|
if (!isConfirmed) return;
|
||||||
roomActions.leave(roomId);
|
mx.leave(roomId);
|
||||||
}}
|
}}
|
||||||
iconSrc={LeaveArrowIC}
|
iconSrc={LeaveArrowIC}
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,46 +0,0 @@
|
||||||
@use '../../partials/flex';
|
|
||||||
@use '../../partials/screen';
|
|
||||||
@use '../../partials/dir';
|
|
||||||
|
|
||||||
.room-view {
|
|
||||||
@extend .cp-fx__column;
|
|
||||||
background-color: var(--bg-surface);
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
z-index: 999;
|
|
||||||
box-shadow: none;
|
|
||||||
|
|
||||||
transition: transform 200ms var(--fluid-slide-down);
|
|
||||||
|
|
||||||
&--dropped {
|
|
||||||
transform: translateY(calc(100% - var(--header-height)));
|
|
||||||
border-radius: var(--bo-radius) var(--bo-radius) 0 0;
|
|
||||||
box-shadow: var(--bs-popup);
|
|
||||||
}
|
|
||||||
|
|
||||||
& .header {
|
|
||||||
@include screen.smallerThan(mobileBreakpoint) {
|
|
||||||
padding: 0 var(--sp-tight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__content-wrapper {
|
|
||||||
@extend .cp-fx__item-one;
|
|
||||||
@extend .cp-fx__column;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__scrollable {
|
|
||||||
@extend .cp-fx__item-one;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__sticky {
|
|
||||||
position: relative;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
}
|
|
||||||
&__editor {
|
|
||||||
padding: 0 var(--sp-normal);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,297 +0,0 @@
|
||||||
/* eslint-disable react/prop-types */
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './RoomViewCmdBar.scss';
|
|
||||||
import parse from 'html-react-parser';
|
|
||||||
import twemoji from 'twemoji';
|
|
||||||
|
|
||||||
import { twemojify, TWEMOJI_BASE_URL } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import { getEmojiForCompletion } from '../emoji-board/custom-emoji';
|
|
||||||
import AsyncSearch from '../../../util/AsyncSearch';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
|
||||||
import FollowingMembers from '../../molecules/following-members/FollowingMembers';
|
|
||||||
import { addRecentEmoji, getRecentEmojis } from '../emoji-board/recent';
|
|
||||||
import commands from './commands';
|
|
||||||
|
|
||||||
function CmdItem({ onClick, children }) {
|
|
||||||
return (
|
|
||||||
<button className="cmd-item" onClick={onClick} type="button">
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
CmdItem.propTypes = {
|
|
||||||
onClick: PropTypes.func.isRequired,
|
|
||||||
children: PropTypes.node.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
function renderSuggestions({ prefix, option, suggestions }, fireCmd) {
|
|
||||||
function renderCmdSuggestions(cmdPrefix, cmds) {
|
|
||||||
const cmdOptString = typeof option === 'string' ? `/${option}` : '/?';
|
|
||||||
return cmds.map((cmd) => (
|
|
||||||
<CmdItem
|
|
||||||
key={cmd}
|
|
||||||
onClick={() => {
|
|
||||||
fireCmd({
|
|
||||||
prefix: cmdPrefix,
|
|
||||||
option,
|
|
||||||
result: commands[cmd],
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text variant="b2">{`${cmd}${cmd.isOptions ? cmdOptString : ''}`}</Text>
|
|
||||||
</CmdItem>
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEmojiSuggestion(emPrefix, emos) {
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
|
|
||||||
// Renders a small Twemoji
|
|
||||||
function renderTwemoji(emoji) {
|
|
||||||
return parse(
|
|
||||||
twemoji.parse(emoji.unicode, {
|
|
||||||
attributes: () => ({
|
|
||||||
unicode: emoji.unicode,
|
|
||||||
shortcodes: emoji.shortcodes?.toString(),
|
|
||||||
}),
|
|
||||||
base: TWEMOJI_BASE_URL,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render a custom emoji
|
|
||||||
function renderCustomEmoji(emoji) {
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
className="emoji"
|
|
||||||
src={mx.mxcUrlToHttp(emoji.mxc)}
|
|
||||||
data-mx-emoticon=""
|
|
||||||
alt={`:${emoji.shortcode}:`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dynamically render either a custom emoji or twemoji based on what the input is
|
|
||||||
function renderEmoji(emoji) {
|
|
||||||
if (emoji.mxc) {
|
|
||||||
return renderCustomEmoji(emoji);
|
|
||||||
}
|
|
||||||
return renderTwemoji(emoji);
|
|
||||||
}
|
|
||||||
|
|
||||||
return emos.map((emoji) => (
|
|
||||||
<CmdItem
|
|
||||||
key={emoji.shortcode}
|
|
||||||
onClick={() =>
|
|
||||||
fireCmd({
|
|
||||||
prefix: emPrefix,
|
|
||||||
result: emoji,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text variant="b1">{renderEmoji(emoji)}</Text>
|
|
||||||
<Text variant="b2">{`:${emoji.shortcode}:`}</Text>
|
|
||||||
</CmdItem>
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderNameSuggestion(namePrefix, members) {
|
|
||||||
return members.map((member) => (
|
|
||||||
<CmdItem
|
|
||||||
key={member.userId}
|
|
||||||
onClick={() => {
|
|
||||||
fireCmd({
|
|
||||||
prefix: namePrefix,
|
|
||||||
result: member,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text variant="b2">{twemojify(member.name)}</Text>
|
|
||||||
</CmdItem>
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
const cmd = {
|
|
||||||
'/': (cmds) => renderCmdSuggestions(prefix, cmds),
|
|
||||||
':': (emos) => renderEmojiSuggestion(prefix, emos),
|
|
||||||
'@': (members) => renderNameSuggestion(prefix, members),
|
|
||||||
};
|
|
||||||
return cmd[prefix]?.(suggestions);
|
|
||||||
}
|
|
||||||
|
|
||||||
const asyncSearch = new AsyncSearch();
|
|
||||||
let cmdPrefix;
|
|
||||||
let cmdOption;
|
|
||||||
function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
|
|
||||||
const [cmd, setCmd] = useState(null);
|
|
||||||
|
|
||||||
function displaySuggestions(suggestions) {
|
|
||||||
if (suggestions.length === 0) {
|
|
||||||
setCmd({ prefix: cmd?.prefix || cmdPrefix, error: 'No suggestion found.' });
|
|
||||||
viewEvent.emit('cmd_error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCmd({ prefix: cmd?.prefix || cmdPrefix, suggestions, option: cmdOption });
|
|
||||||
}
|
|
||||||
|
|
||||||
function processCmd(prefix, slug) {
|
|
||||||
let searchTerm = slug;
|
|
||||||
cmdOption = undefined;
|
|
||||||
cmdPrefix = prefix;
|
|
||||||
if (prefix === '/') {
|
|
||||||
const cmdSlugParts = slug.split('/');
|
|
||||||
[searchTerm, cmdOption] = cmdSlugParts;
|
|
||||||
}
|
|
||||||
if (prefix === ':') {
|
|
||||||
if (searchTerm.length <= 3) {
|
|
||||||
if (searchTerm.match(/^[-]?(\))$/)) searchTerm = 'smile';
|
|
||||||
else if (searchTerm.match(/^[-]?(s|S)$/)) searchTerm = 'confused';
|
|
||||||
else if (searchTerm.match(/^[-]?(o|O|0)$/)) searchTerm = 'astonished';
|
|
||||||
else if (searchTerm.match(/^[-]?(\|)$/)) searchTerm = 'neutral_face';
|
|
||||||
else if (searchTerm.match(/^[-]?(d|D)$/)) searchTerm = 'grin';
|
|
||||||
else if (searchTerm.match(/^[-]?(\/)$/)) searchTerm = 'frown';
|
|
||||||
else if (searchTerm.match(/^[-]?(p|P)$/)) searchTerm = 'stuck_out_tongue';
|
|
||||||
else if (searchTerm.match(/^'[-]?(\()$/)) searchTerm = 'cry';
|
|
||||||
else if (searchTerm.match(/^[-]?(x|X)$/)) searchTerm = 'dizzy_face';
|
|
||||||
else if (searchTerm.match(/^[-]?(\()$/)) searchTerm = 'pleading_face';
|
|
||||||
else if (searchTerm.match(/^[-]?(\$)$/)) searchTerm = 'money';
|
|
||||||
else if (searchTerm.match(/^(<3)$/)) searchTerm = 'heart';
|
|
||||||
else if (searchTerm.match(/^(c|ca|cat)$/)) searchTerm = '_cat';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
asyncSearch.search(searchTerm);
|
|
||||||
}
|
|
||||||
function activateCmd(prefix) {
|
|
||||||
cmdPrefix = prefix;
|
|
||||||
cmdPrefix = undefined;
|
|
||||||
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const setupSearch = {
|
|
||||||
'/': () => {
|
|
||||||
asyncSearch.setup(Object.keys(commands), { isContain: true });
|
|
||||||
setCmd({ prefix, suggestions: Object.keys(commands) });
|
|
||||||
},
|
|
||||||
':': () => {
|
|
||||||
const parentIds = initMatrix.roomList.getAllParentSpaces(roomId);
|
|
||||||
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
|
|
||||||
const emojis = getEmojiForCompletion(mx, [mx.getRoom(roomId), ...parentRooms]);
|
|
||||||
const recentEmoji = getRecentEmojis(20);
|
|
||||||
asyncSearch.setup(emojis, { keys: ['shortcode'], isContain: true, limit: 20 });
|
|
||||||
setCmd({
|
|
||||||
prefix,
|
|
||||||
suggestions: recentEmoji.length > 0 ? recentEmoji : emojis.slice(26, 46),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
'@': () => {
|
|
||||||
const members = mx
|
|
||||||
.getRoom(roomId)
|
|
||||||
.getJoinedMembers()
|
|
||||||
.map((member) => ({
|
|
||||||
name: member.name,
|
|
||||||
userId: member.userId.slice(1),
|
|
||||||
}));
|
|
||||||
asyncSearch.setup(members, { keys: ['name', 'userId'], limit: 20 });
|
|
||||||
const endIndex = members.length > 20 ? 20 : members.length;
|
|
||||||
setCmd({ prefix, suggestions: members.slice(0, endIndex) });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
setupSearch[prefix]?.();
|
|
||||||
}
|
|
||||||
function deactivateCmd() {
|
|
||||||
setCmd(null);
|
|
||||||
cmdOption = undefined;
|
|
||||||
cmdPrefix = undefined;
|
|
||||||
}
|
|
||||||
function fireCmd(myCmd) {
|
|
||||||
if (myCmd.prefix === '/') {
|
|
||||||
viewEvent.emit('cmd_fired', {
|
|
||||||
replace: `/${myCmd.result.name}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (myCmd.prefix === ':') {
|
|
||||||
if (!myCmd.result.mxc) addRecentEmoji(myCmd.result.unicode);
|
|
||||||
viewEvent.emit('cmd_fired', {
|
|
||||||
replace: myCmd.result.mxc ? `:${myCmd.result.shortcode}: ` : myCmd.result.unicode,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (myCmd.prefix === '@') {
|
|
||||||
viewEvent.emit('cmd_fired', {
|
|
||||||
replace: `@${myCmd.result.userId}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
deactivateCmd();
|
|
||||||
}
|
|
||||||
|
|
||||||
function listenKeyboard(event) {
|
|
||||||
const { activeElement } = document;
|
|
||||||
const lastCmdItem = document.activeElement.parentNode.lastElementChild;
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
if (activeElement.className !== 'cmd-item') return;
|
|
||||||
viewEvent.emit('focus_msg_input');
|
|
||||||
}
|
|
||||||
if (event.key === 'Tab') {
|
|
||||||
if (lastCmdItem.className !== 'cmd-item') return;
|
|
||||||
if (lastCmdItem !== activeElement) return;
|
|
||||||
if (event.shiftKey) return;
|
|
||||||
viewEvent.emit('focus_msg_input');
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
viewEvent.on('cmd_activate', activateCmd);
|
|
||||||
viewEvent.on('cmd_deactivate', deactivateCmd);
|
|
||||||
return () => {
|
|
||||||
deactivateCmd();
|
|
||||||
viewEvent.removeListener('cmd_activate', activateCmd);
|
|
||||||
viewEvent.removeListener('cmd_deactivate', deactivateCmd);
|
|
||||||
};
|
|
||||||
}, [roomId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (cmd !== null) document.body.addEventListener('keydown', listenKeyboard);
|
|
||||||
viewEvent.on('cmd_process', processCmd);
|
|
||||||
asyncSearch.on(asyncSearch.RESULT_SENT, displaySuggestions);
|
|
||||||
return () => {
|
|
||||||
if (cmd !== null) document.body.removeEventListener('keydown', listenKeyboard);
|
|
||||||
|
|
||||||
viewEvent.removeListener('cmd_process', processCmd);
|
|
||||||
asyncSearch.removeListener(asyncSearch.RESULT_SENT, displaySuggestions);
|
|
||||||
};
|
|
||||||
}, [cmd]);
|
|
||||||
|
|
||||||
const isError = typeof cmd?.error === 'string';
|
|
||||||
if (cmd === null || isError) {
|
|
||||||
return (
|
|
||||||
<div className="cmd-bar">
|
|
||||||
<FollowingMembers roomTimeline={roomTimeline} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="cmd-bar">
|
|
||||||
<div className="cmd-bar__info">
|
|
||||||
<Text variant="b3">TAB</Text>
|
|
||||||
</div>
|
|
||||||
<div className="cmd-bar__content">
|
|
||||||
<ScrollView horizontal vertical={false} invisible>
|
|
||||||
<div className="cmd-bar__content-suggestions">{renderSuggestions(cmd, fireCmd)}</div>
|
|
||||||
</ScrollView>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
RoomViewCmdBar.propTypes = {
|
|
||||||
roomId: PropTypes.string.isRequired,
|
|
||||||
roomTimeline: PropTypes.shape({}).isRequired,
|
|
||||||
viewEvent: PropTypes.shape({}).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RoomViewCmdBar;
|
|
|
@ -1,57 +0,0 @@
|
||||||
@use '../../partials/flex';
|
|
||||||
@use '../../partials/text';
|
|
||||||
@use '../../partials/dir';
|
|
||||||
|
|
||||||
.cmd-bar {
|
|
||||||
--cmd-bar-height: 28px;
|
|
||||||
min-height: var(--cmd-bar-height);
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
&__info {
|
|
||||||
display: flex;
|
|
||||||
width: 40px;
|
|
||||||
@include dir.side(margin, 14px, 10px);
|
|
||||||
|
|
||||||
& > * {
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__content {
|
|
||||||
@extend .cp-fx__item-one;
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
&-suggestions {
|
|
||||||
height: 100%;
|
|
||||||
white-space: nowrap;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
& > .text {
|
|
||||||
@extend .cp-txt__ellipsis;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmd-item {
|
|
||||||
--cmd-item-bar: inset 0 -2px 0 0 var(--bg-caution);
|
|
||||||
height: 100%;
|
|
||||||
@include dir.side(margin, 0, var(--sp-extra-tight));
|
|
||||||
padding: 0 var(--sp-extra-tight);
|
|
||||||
border-radius: var(--bo-radius) var(--bo-radius) 0 0;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--bg-caution-hover);
|
|
||||||
}
|
|
||||||
&:focus {
|
|
||||||
background-color: var(--bg-caution-active);
|
|
||||||
box-shadow: var(--cmd-item-bar);
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,644 +0,0 @@
|
||||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
|
||||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
|
||||||
/* eslint-disable react/prop-types */
|
|
||||||
import React, {
|
|
||||||
useState, useEffect, useLayoutEffect, useCallback, useRef,
|
|
||||||
} from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './RoomViewContent.scss';
|
|
||||||
|
|
||||||
import dateFormat from 'dateformat';
|
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import navigation from '../../../client/state/navigation';
|
|
||||||
import { openProfileViewer } from '../../../client/action/navigation';
|
|
||||||
import { diffMinutes, isInSameDay, Throttle } from '../../../util/common';
|
|
||||||
import { markAsRead } from '../../../client/action/notifications';
|
|
||||||
|
|
||||||
import Divider from '../../atoms/divider/Divider';
|
|
||||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
|
||||||
import { Message, PlaceholderMessage } from '../../molecules/message/Message';
|
|
||||||
import RoomIntro from '../../molecules/room-intro/RoomIntro';
|
|
||||||
import TimelineChange from '../../molecules/message/TimelineChange';
|
|
||||||
|
|
||||||
import { useStore } from '../../hooks/useStore';
|
|
||||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
|
||||||
import { parseTimelineChange } from './common';
|
|
||||||
import TimelineScroll from './TimelineScroll';
|
|
||||||
import EventLimit from './EventLimit';
|
|
||||||
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
|
||||||
|
|
||||||
const PAG_LIMIT = 30;
|
|
||||||
const MAX_MSG_DIFF_MINUTES = 5;
|
|
||||||
const PLACEHOLDER_COUNT = 2;
|
|
||||||
const PLACEHOLDERS_HEIGHT = 96 * PLACEHOLDER_COUNT;
|
|
||||||
const SCROLL_TRIGGER_POS = PLACEHOLDERS_HEIGHT * 4;
|
|
||||||
|
|
||||||
function loadingMsgPlaceholders(key, count = 2) {
|
|
||||||
const pl = [];
|
|
||||||
const genPlaceholders = () => {
|
|
||||||
for (let i = 0; i < count; i += 1) {
|
|
||||||
pl.push(<PlaceholderMessage key={`placeholder-${i}${key}`} />);
|
|
||||||
}
|
|
||||||
return pl;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment key={`placeholder-container${key}`}>
|
|
||||||
{genPlaceholders()}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RoomIntroContainer({ event, timeline }) {
|
|
||||||
const [, nameForceUpdate] = useForceUpdate();
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const { roomList } = initMatrix;
|
|
||||||
const { room } = timeline;
|
|
||||||
const roomTopic = room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
|
|
||||||
const isDM = roomList.directs.has(timeline.roomId);
|
|
||||||
let avatarSrc = room.getAvatarUrl(mx.baseUrl, 80, 80, 'crop');
|
|
||||||
avatarSrc = isDM ? room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 80, 80, 'crop') : avatarSrc;
|
|
||||||
|
|
||||||
const heading = isDM ? room.name : `Welcome to ${room.name}`;
|
|
||||||
const topic = twemojify(roomTopic || '', undefined, true);
|
|
||||||
const nameJsx = twemojify(room.name);
|
|
||||||
const desc = isDM
|
|
||||||
? (
|
|
||||||
<>
|
|
||||||
This is the beginning of your direct message history with @
|
|
||||||
<b>{nameJsx}</b>
|
|
||||||
{'. '}
|
|
||||||
{topic}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<>
|
|
||||||
{'This is the beginning of the '}
|
|
||||||
<b>{nameJsx}</b>
|
|
||||||
{' room. '}
|
|
||||||
{topic}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleUpdate = () => nameForceUpdate();
|
|
||||||
|
|
||||||
roomList.on(cons.events.roomList.ROOM_PROFILE_UPDATED, handleUpdate);
|
|
||||||
return () => {
|
|
||||||
roomList.removeListener(cons.events.roomList.ROOM_PROFILE_UPDATED, handleUpdate);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RoomIntro
|
|
||||||
roomId={timeline.roomId}
|
|
||||||
avatarSrc={avatarSrc}
|
|
||||||
name={room.name}
|
|
||||||
heading={twemojify(heading)}
|
|
||||||
desc={desc}
|
|
||||||
time={event ? `Created at ${dateFormat(event.getDate(), 'dd mmmm yyyy, hh:MM TT')}` : null}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleOnClickCapture(e) {
|
|
||||||
const { target, nativeEvent } = e;
|
|
||||||
|
|
||||||
const userId = target.getAttribute('data-mx-pill');
|
|
||||||
if (userId) {
|
|
||||||
const roomId = navigation.selectedRoomId;
|
|
||||||
openProfileViewer(userId, roomId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const spoiler = nativeEvent.composedPath().find((el) => el?.hasAttribute?.('data-mx-spoiler'));
|
|
||||||
if (spoiler) {
|
|
||||||
if (!spoiler.classList.contains('data-mx-spoiler--visible')) e.preventDefault();
|
|
||||||
spoiler.classList.toggle('data-mx-spoiler--visible');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEvent(
|
|
||||||
roomTimeline,
|
|
||||||
mEvent,
|
|
||||||
prevMEvent,
|
|
||||||
isFocus,
|
|
||||||
isEdit,
|
|
||||||
setEdit,
|
|
||||||
cancelEdit,
|
|
||||||
) {
|
|
||||||
const isBodyOnly = (prevMEvent !== null
|
|
||||||
&& prevMEvent.getSender() === mEvent.getSender()
|
|
||||||
&& prevMEvent.getType() !== 'm.room.member'
|
|
||||||
&& prevMEvent.getType() !== 'm.room.create'
|
|
||||||
&& diffMinutes(mEvent.getDate(), prevMEvent.getDate()) <= MAX_MSG_DIFF_MINUTES
|
|
||||||
);
|
|
||||||
const timestamp = mEvent.getTs();
|
|
||||||
|
|
||||||
if (mEvent.getType() === 'm.room.member') {
|
|
||||||
const timelineChange = parseTimelineChange(mEvent);
|
|
||||||
if (timelineChange === null) return <div key={mEvent.getId()} />;
|
|
||||||
return (
|
|
||||||
<TimelineChange
|
|
||||||
key={mEvent.getId()}
|
|
||||||
variant={timelineChange.variant}
|
|
||||||
content={timelineChange.content}
|
|
||||||
timestamp={timestamp}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Message
|
|
||||||
key={mEvent.getId()}
|
|
||||||
mEvent={mEvent}
|
|
||||||
isBodyOnly={isBodyOnly}
|
|
||||||
roomTimeline={roomTimeline}
|
|
||||||
focus={isFocus}
|
|
||||||
fullTime={false}
|
|
||||||
isEdit={isEdit}
|
|
||||||
setEdit={setEdit}
|
|
||||||
cancelEdit={cancelEdit}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useTimeline(roomTimeline, eventId, readUptoEvtStore, eventLimitRef) {
|
|
||||||
const [timelineInfo, setTimelineInfo] = useState(null);
|
|
||||||
|
|
||||||
const setEventTimeline = async (eId) => {
|
|
||||||
if (typeof eId === 'string') {
|
|
||||||
const isLoaded = await roomTimeline.loadEventTimeline(eId);
|
|
||||||
if (isLoaded) return;
|
|
||||||
// if eventTimeline failed to load,
|
|
||||||
// we will load live timeline as fallback.
|
|
||||||
}
|
|
||||||
roomTimeline.loadLiveTimeline();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const limit = eventLimitRef.current;
|
|
||||||
const initTimeline = (eId) => {
|
|
||||||
// NOTICE: eId can be id of readUpto, reply or specific event.
|
|
||||||
// readUpTo: when user click jump to unread message button.
|
|
||||||
// reply: when user click reply from timeline.
|
|
||||||
// specific event when user open a link of event. behave same as ^^^^
|
|
||||||
const readUpToId = roomTimeline.getReadUpToEventId();
|
|
||||||
let focusEventIndex = -1;
|
|
||||||
const isSpecificEvent = eId && eId !== readUpToId;
|
|
||||||
|
|
||||||
if (isSpecificEvent) {
|
|
||||||
focusEventIndex = roomTimeline.getEventIndex(eId);
|
|
||||||
}
|
|
||||||
if (!readUptoEvtStore.getItem() && roomTimeline.hasEventInTimeline(readUpToId)) {
|
|
||||||
// either opening live timeline or jump to unread.
|
|
||||||
readUptoEvtStore.setItem(roomTimeline.findEventByIdInTimelineSet(readUpToId));
|
|
||||||
}
|
|
||||||
if (readUptoEvtStore.getItem() && !isSpecificEvent) {
|
|
||||||
focusEventIndex = roomTimeline.getUnreadEventIndex(readUptoEvtStore.getItem().getId());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (focusEventIndex > -1) {
|
|
||||||
limit.setFrom(focusEventIndex - Math.round(limit.maxEvents / 2));
|
|
||||||
} else {
|
|
||||||
limit.setFrom(roomTimeline.timeline.length - limit.maxEvents);
|
|
||||||
}
|
|
||||||
setTimelineInfo({ focusEventId: isSpecificEvent ? eId : null });
|
|
||||||
};
|
|
||||||
|
|
||||||
roomTimeline.on(cons.events.roomTimeline.READY, initTimeline);
|
|
||||||
setEventTimeline(eventId);
|
|
||||||
return () => {
|
|
||||||
roomTimeline.removeListener(cons.events.roomTimeline.READY, initTimeline);
|
|
||||||
limit.setFrom(0);
|
|
||||||
};
|
|
||||||
}, [roomTimeline, eventId]);
|
|
||||||
|
|
||||||
return timelineInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
function usePaginate(
|
|
||||||
roomTimeline,
|
|
||||||
readUptoEvtStore,
|
|
||||||
forceUpdateLimit,
|
|
||||||
timelineScrollRef,
|
|
||||||
eventLimitRef,
|
|
||||||
) {
|
|
||||||
const [info, setInfo] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handlePaginatedFromServer = (backwards, loaded) => {
|
|
||||||
const limit = eventLimitRef.current;
|
|
||||||
if (loaded === 0) return;
|
|
||||||
if (!readUptoEvtStore.getItem()) {
|
|
||||||
const readUpToId = roomTimeline.getReadUpToEventId();
|
|
||||||
readUptoEvtStore.setItem(roomTimeline.findEventByIdInTimelineSet(readUpToId));
|
|
||||||
}
|
|
||||||
limit.paginate(backwards, PAG_LIMIT, roomTimeline.timeline.length);
|
|
||||||
setTimeout(() => setInfo({
|
|
||||||
backwards,
|
|
||||||
loaded,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
roomTimeline.on(cons.events.roomTimeline.PAGINATED, handlePaginatedFromServer);
|
|
||||||
return () => {
|
|
||||||
roomTimeline.removeListener(cons.events.roomTimeline.PAGINATED, handlePaginatedFromServer);
|
|
||||||
};
|
|
||||||
}, [roomTimeline]);
|
|
||||||
|
|
||||||
const autoPaginate = useCallback(async () => {
|
|
||||||
const timelineScroll = timelineScrollRef.current;
|
|
||||||
const limit = eventLimitRef.current;
|
|
||||||
if (roomTimeline.isOngoingPagination) return;
|
|
||||||
const tLength = roomTimeline.timeline.length;
|
|
||||||
|
|
||||||
if (timelineScroll.bottom < SCROLL_TRIGGER_POS) {
|
|
||||||
if (limit.length < tLength) {
|
|
||||||
// paginate from memory
|
|
||||||
limit.paginate(false, PAG_LIMIT, tLength);
|
|
||||||
forceUpdateLimit();
|
|
||||||
} else if (roomTimeline.canPaginateForward()) {
|
|
||||||
// paginate from server.
|
|
||||||
await roomTimeline.paginateTimeline(false, PAG_LIMIT);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (timelineScroll.top < SCROLL_TRIGGER_POS) {
|
|
||||||
if (limit.from > 0) {
|
|
||||||
// paginate from memory
|
|
||||||
limit.paginate(true, PAG_LIMIT, tLength);
|
|
||||||
forceUpdateLimit();
|
|
||||||
} else if (roomTimeline.canPaginateBackward()) {
|
|
||||||
// paginate from server.
|
|
||||||
await roomTimeline.paginateTimeline(true, PAG_LIMIT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [roomTimeline]);
|
|
||||||
|
|
||||||
return [info, autoPaginate];
|
|
||||||
}
|
|
||||||
|
|
||||||
function useHandleScroll(
|
|
||||||
roomTimeline,
|
|
||||||
autoPaginate,
|
|
||||||
readUptoEvtStore,
|
|
||||||
forceUpdateLimit,
|
|
||||||
timelineScrollRef,
|
|
||||||
eventLimitRef,
|
|
||||||
) {
|
|
||||||
const handleScroll = useCallback(() => {
|
|
||||||
const timelineScroll = timelineScrollRef.current;
|
|
||||||
const limit = eventLimitRef.current;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
// emit event to toggle scrollToBottom button visibility
|
|
||||||
const isAtBottom = (
|
|
||||||
timelineScroll.bottom < 16 && !roomTimeline.canPaginateForward()
|
|
||||||
&& limit.length >= roomTimeline.timeline.length
|
|
||||||
);
|
|
||||||
roomTimeline.emit(cons.events.roomTimeline.AT_BOTTOM, isAtBottom);
|
|
||||||
if (isAtBottom && readUptoEvtStore.getItem()) {
|
|
||||||
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
autoPaginate();
|
|
||||||
}, [roomTimeline]);
|
|
||||||
|
|
||||||
const handleScrollToLive = useCallback(() => {
|
|
||||||
const timelineScroll = timelineScrollRef.current;
|
|
||||||
const limit = eventLimitRef.current;
|
|
||||||
if (readUptoEvtStore.getItem()) {
|
|
||||||
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
|
|
||||||
}
|
|
||||||
if (roomTimeline.isServingLiveTimeline()) {
|
|
||||||
limit.setFrom(roomTimeline.timeline.length - limit.maxEvents);
|
|
||||||
timelineScroll.scrollToBottom();
|
|
||||||
forceUpdateLimit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
roomTimeline.loadLiveTimeline();
|
|
||||||
}, [roomTimeline]);
|
|
||||||
|
|
||||||
return [handleScroll, handleScrollToLive];
|
|
||||||
}
|
|
||||||
|
|
||||||
function useEventArrive(roomTimeline, readUptoEvtStore, timelineScrollRef, eventLimitRef) {
|
|
||||||
const myUserId = initMatrix.matrixClient.getUserId();
|
|
||||||
const [newEvent, setEvent] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timelineScroll = timelineScrollRef.current;
|
|
||||||
const limit = eventLimitRef.current;
|
|
||||||
const trySendReadReceipt = (event) => {
|
|
||||||
if (myUserId === event.getSender()) {
|
|
||||||
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const readUpToEvent = readUptoEvtStore.getItem();
|
|
||||||
const readUpToId = roomTimeline.getReadUpToEventId();
|
|
||||||
const isUnread = readUpToEvent ? readUpToEvent?.getId() === readUpToId : true;
|
|
||||||
|
|
||||||
if (isUnread === false) {
|
|
||||||
if (document.visibilityState === 'visible' && timelineScroll.bottom < 16) {
|
|
||||||
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
|
|
||||||
} else {
|
|
||||||
readUptoEvtStore.setItem(roomTimeline.findEventByIdInTimelineSet(readUpToId));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { timeline } = roomTimeline;
|
|
||||||
const unreadMsgIsLast = timeline[timeline.length - 2].getId() === readUpToId;
|
|
||||||
if (unreadMsgIsLast) {
|
|
||||||
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEvent = (event) => {
|
|
||||||
const tLength = roomTimeline.timeline.length;
|
|
||||||
const isViewingLive = roomTimeline.isServingLiveTimeline() && limit.length >= tLength - 1;
|
|
||||||
const isAttached = timelineScroll.bottom < SCROLL_TRIGGER_POS;
|
|
||||||
|
|
||||||
if (isViewingLive && isAttached && document.hasFocus()) {
|
|
||||||
limit.setFrom(tLength - limit.maxEvents);
|
|
||||||
trySendReadReceipt(event);
|
|
||||||
setEvent(event);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const isRelates = (event.getType() === 'm.reaction' || event.getRelation()?.rel_type === 'm.replace');
|
|
||||||
if (isRelates) {
|
|
||||||
setEvent(event);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isViewingLive) {
|
|
||||||
// This stateUpdate will help to put the
|
|
||||||
// loading msg placeholder at bottom
|
|
||||||
setEvent(event);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEventRedact = (event) => setEvent(event);
|
|
||||||
|
|
||||||
roomTimeline.on(cons.events.roomTimeline.EVENT, handleEvent);
|
|
||||||
roomTimeline.on(cons.events.roomTimeline.EVENT_REDACTED, handleEventRedact);
|
|
||||||
return () => {
|
|
||||||
roomTimeline.removeListener(cons.events.roomTimeline.EVENT, handleEvent);
|
|
||||||
roomTimeline.removeListener(cons.events.roomTimeline.EVENT_REDACTED, handleEventRedact);
|
|
||||||
};
|
|
||||||
}, [roomTimeline]);
|
|
||||||
|
|
||||||
return newEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
let jumpToItemIndex = -1;
|
|
||||||
|
|
||||||
function RoomViewContent({ roomInputRef, eventId, roomTimeline }) {
|
|
||||||
const [throttle] = useState(new Throttle());
|
|
||||||
|
|
||||||
const timelineSVRef = useRef(null);
|
|
||||||
const timelineScrollRef = useRef(null);
|
|
||||||
const eventLimitRef = useRef(null);
|
|
||||||
const [editEventId, setEditEventId] = useState(null);
|
|
||||||
const cancelEdit = () => setEditEventId(null);
|
|
||||||
|
|
||||||
const readUptoEvtStore = useStore(roomTimeline);
|
|
||||||
const [onLimitUpdate, forceUpdateLimit] = useForceUpdate();
|
|
||||||
|
|
||||||
const timelineInfo = useTimeline(roomTimeline, eventId, readUptoEvtStore, eventLimitRef);
|
|
||||||
const [paginateInfo, autoPaginate] = usePaginate(
|
|
||||||
roomTimeline,
|
|
||||||
readUptoEvtStore,
|
|
||||||
forceUpdateLimit,
|
|
||||||
timelineScrollRef,
|
|
||||||
eventLimitRef,
|
|
||||||
);
|
|
||||||
const [handleScroll, handleScrollToLive] = useHandleScroll(
|
|
||||||
roomTimeline,
|
|
||||||
autoPaginate,
|
|
||||||
readUptoEvtStore,
|
|
||||||
forceUpdateLimit,
|
|
||||||
timelineScrollRef,
|
|
||||||
eventLimitRef,
|
|
||||||
);
|
|
||||||
const newEvent = useEventArrive(roomTimeline, readUptoEvtStore, timelineScrollRef, eventLimitRef);
|
|
||||||
|
|
||||||
const { timeline } = roomTimeline;
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!roomTimeline.initialized) {
|
|
||||||
timelineScrollRef.current = new TimelineScroll(timelineSVRef.current);
|
|
||||||
eventLimitRef.current = new EventLimit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// when active timeline changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!roomTimeline.initialized) return undefined;
|
|
||||||
const timelineScroll = timelineScrollRef.current;
|
|
||||||
|
|
||||||
if (timeline.length > 0) {
|
|
||||||
if (jumpToItemIndex === -1) {
|
|
||||||
timelineScroll.scrollToBottom();
|
|
||||||
} else {
|
|
||||||
timelineScroll.scrollToIndex(jumpToItemIndex, 80);
|
|
||||||
}
|
|
||||||
if (timelineScroll.bottom < 16 && !roomTimeline.canPaginateForward()) {
|
|
||||||
const readUpToId = roomTimeline.getReadUpToEventId();
|
|
||||||
if (readUptoEvtStore.getItem()?.getId() === readUpToId || readUpToId === null) {
|
|
||||||
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
jumpToItemIndex = -1;
|
|
||||||
}
|
|
||||||
autoPaginate();
|
|
||||||
|
|
||||||
roomTimeline.on(cons.events.roomTimeline.SCROLL_TO_LIVE, handleScrollToLive);
|
|
||||||
return () => {
|
|
||||||
if (timelineSVRef.current === null) return;
|
|
||||||
roomTimeline.removeListener(cons.events.roomTimeline.SCROLL_TO_LIVE, handleScrollToLive);
|
|
||||||
};
|
|
||||||
}, [timelineInfo]);
|
|
||||||
|
|
||||||
// when paginating from server
|
|
||||||
useEffect(() => {
|
|
||||||
if (!roomTimeline.initialized) return;
|
|
||||||
const timelineScroll = timelineScrollRef.current;
|
|
||||||
timelineScroll.tryRestoringScroll();
|
|
||||||
autoPaginate();
|
|
||||||
}, [paginateInfo]);
|
|
||||||
|
|
||||||
// when paginating locally
|
|
||||||
useEffect(() => {
|
|
||||||
if (!roomTimeline.initialized) return;
|
|
||||||
const timelineScroll = timelineScrollRef.current;
|
|
||||||
timelineScroll.tryRestoringScroll();
|
|
||||||
}, [onLimitUpdate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timelineScroll = timelineScrollRef.current;
|
|
||||||
if (!roomTimeline.initialized) return;
|
|
||||||
if (timelineScroll.bottom < 16 && !roomTimeline.canPaginateForward() && document.visibilityState === 'visible') {
|
|
||||||
timelineScroll.scrollToBottom();
|
|
||||||
} else {
|
|
||||||
timelineScroll.tryRestoringScroll();
|
|
||||||
}
|
|
||||||
}, [newEvent]);
|
|
||||||
|
|
||||||
useResizeObserver(
|
|
||||||
useCallback((entries) => {
|
|
||||||
if (!roomInputRef.current) return;
|
|
||||||
const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries);
|
|
||||||
if (!editorBaseEntry) return;
|
|
||||||
|
|
||||||
const timelineScroll = timelineScrollRef.current;
|
|
||||||
if (!roomTimeline.initialized) return;
|
|
||||||
if (timelineScroll.bottom < 40 && !roomTimeline.canPaginateForward() && document.visibilityState === 'visible') {
|
|
||||||
timelineScroll.scrollToBottom();
|
|
||||||
}
|
|
||||||
}, [roomInputRef]),
|
|
||||||
useCallback(() => roomInputRef.current, [roomInputRef]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const listenKeyboard = useCallback((event) => {
|
|
||||||
if (event.ctrlKey || event.altKey || event.metaKey) return;
|
|
||||||
if (event.key !== 'ArrowUp') return;
|
|
||||||
if (navigation.isRawModalVisible) return;
|
|
||||||
|
|
||||||
if (document.activeElement.id !== 'message-textarea') return;
|
|
||||||
if (document.activeElement.value !== '') return;
|
|
||||||
|
|
||||||
const {
|
|
||||||
timeline: tl, activeTimeline, liveTimeline, matrixClient: mx,
|
|
||||||
} = roomTimeline;
|
|
||||||
const limit = eventLimitRef.current;
|
|
||||||
if (activeTimeline !== liveTimeline) return;
|
|
||||||
if (tl.length > limit.length) return;
|
|
||||||
|
|
||||||
const mTypes = ['m.text'];
|
|
||||||
for (let i = tl.length - 1; i >= 0; i -= 1) {
|
|
||||||
const mE = tl[i];
|
|
||||||
if (
|
|
||||||
mE.getSender() === mx.getUserId()
|
|
||||||
&& mE.getType() === 'm.room.message'
|
|
||||||
&& mTypes.includes(mE.getContent()?.msgtype)
|
|
||||||
) {
|
|
||||||
setEditEventId(mE.getId());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [roomTimeline]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.body.addEventListener('keydown', listenKeyboard);
|
|
||||||
return () => {
|
|
||||||
document.body.removeEventListener('keydown', listenKeyboard);
|
|
||||||
};
|
|
||||||
}, [listenKeyboard]);
|
|
||||||
|
|
||||||
const handleTimelineScroll = (event) => {
|
|
||||||
const timelineScroll = timelineScrollRef.current;
|
|
||||||
if (!event.target) return;
|
|
||||||
|
|
||||||
throttle._(() => {
|
|
||||||
const backwards = timelineScroll?.calcScroll();
|
|
||||||
if (typeof backwards !== 'boolean') return;
|
|
||||||
handleScroll(backwards);
|
|
||||||
}, 200)();
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderTimeline = () => {
|
|
||||||
const tl = [];
|
|
||||||
const limit = eventLimitRef.current;
|
|
||||||
|
|
||||||
let itemCountIndex = 0;
|
|
||||||
jumpToItemIndex = -1;
|
|
||||||
const readUptoEvent = readUptoEvtStore.getItem();
|
|
||||||
let unreadDivider = false;
|
|
||||||
|
|
||||||
if (roomTimeline.canPaginateBackward() || limit.from > 0) {
|
|
||||||
tl.push(loadingMsgPlaceholders(1, PLACEHOLDER_COUNT));
|
|
||||||
itemCountIndex += PLACEHOLDER_COUNT;
|
|
||||||
}
|
|
||||||
for (let i = limit.from; i < limit.length; i += 1) {
|
|
||||||
if (i >= timeline.length) break;
|
|
||||||
const mEvent = timeline[i];
|
|
||||||
const prevMEvent = timeline[i - 1] ?? null;
|
|
||||||
|
|
||||||
if (i === 0 && !roomTimeline.canPaginateBackward()) {
|
|
||||||
if (mEvent.getType() === 'm.room.create') {
|
|
||||||
tl.push(
|
|
||||||
<RoomIntroContainer key={mEvent.getId()} event={mEvent} timeline={roomTimeline} />,
|
|
||||||
);
|
|
||||||
itemCountIndex += 1;
|
|
||||||
// eslint-disable-next-line no-continue
|
|
||||||
continue;
|
|
||||||
} else {
|
|
||||||
tl.push(<RoomIntroContainer key="room-intro" event={null} timeline={roomTimeline} />);
|
|
||||||
itemCountIndex += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let isNewEvent = false;
|
|
||||||
if (!unreadDivider) {
|
|
||||||
unreadDivider = (readUptoEvent
|
|
||||||
&& prevMEvent?.getTs() <= readUptoEvent.getTs()
|
|
||||||
&& readUptoEvent.getTs() < mEvent.getTs());
|
|
||||||
if (unreadDivider) {
|
|
||||||
isNewEvent = true;
|
|
||||||
tl.push(<Divider key={`new-${mEvent.getId()}`} variant="positive" text="New messages" />);
|
|
||||||
itemCountIndex += 1;
|
|
||||||
if (jumpToItemIndex === -1) jumpToItemIndex = itemCountIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const dayDivider = prevMEvent && !isInSameDay(mEvent.getDate(), prevMEvent.getDate());
|
|
||||||
if (dayDivider) {
|
|
||||||
tl.push(<Divider key={`divider-${mEvent.getId()}`} text={`${dateFormat(mEvent.getDate(), 'mmmm dd, yyyy')}`} />);
|
|
||||||
itemCountIndex += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const focusId = timelineInfo.focusEventId;
|
|
||||||
const isFocus = focusId === mEvent.getId();
|
|
||||||
if (isFocus) jumpToItemIndex = itemCountIndex;
|
|
||||||
|
|
||||||
tl.push(renderEvent(
|
|
||||||
roomTimeline,
|
|
||||||
mEvent,
|
|
||||||
isNewEvent ? null : prevMEvent,
|
|
||||||
isFocus,
|
|
||||||
editEventId === mEvent.getId(),
|
|
||||||
setEditEventId,
|
|
||||||
cancelEdit,
|
|
||||||
));
|
|
||||||
itemCountIndex += 1;
|
|
||||||
}
|
|
||||||
if (roomTimeline.canPaginateForward() || limit.length < timeline.length) {
|
|
||||||
tl.push(loadingMsgPlaceholders(2, PLACEHOLDER_COUNT));
|
|
||||||
}
|
|
||||||
|
|
||||||
return tl;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView onScroll={handleTimelineScroll} ref={timelineSVRef} autoHide>
|
|
||||||
<div className="room-view__content" onClick={handleOnClickCapture}>
|
|
||||||
<div className="timeline__wrapper">
|
|
||||||
{ roomTimeline.initialized ? renderTimeline() : loadingMsgPlaceholders('loading', 3) }
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
RoomViewContent.defaultProps = {
|
|
||||||
eventId: null,
|
|
||||||
};
|
|
||||||
RoomViewContent.propTypes = {
|
|
||||||
eventId: PropTypes.string,
|
|
||||||
roomTimeline: PropTypes.shape({}).isRequired,
|
|
||||||
roomInputRef: PropTypes.shape({
|
|
||||||
current: PropTypes.shape({})
|
|
||||||
}).isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RoomViewContent;
|
|
|
@ -1,30 +0,0 @@
|
||||||
@use '../../partials/dir';
|
|
||||||
|
|
||||||
.room-view__content {
|
|
||||||
min-height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-end;
|
|
||||||
|
|
||||||
& .timeline__wrapper {
|
|
||||||
--typing-noti-height: 28px;
|
|
||||||
min-height: 0;
|
|
||||||
min-width: 0;
|
|
||||||
padding-bottom: var(--typing-noti-height);
|
|
||||||
|
|
||||||
& .message,
|
|
||||||
& .ph-msg,
|
|
||||||
& .timeline-change {
|
|
||||||
@include dir.prop(border-radius,
|
|
||||||
0 var(--bo-radius) var(--bo-radius) 0,
|
|
||||||
var(--bo-radius) 0 0 var(--bo-radius),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
& > .divider {
|
|
||||||
margin: var(--sp-extra-tight);
|
|
||||||
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
|
|
||||||
@include dir.side(padding, calc(var(--av-small) + var(--sp-tight)), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,125 +0,0 @@
|
||||||
/* eslint-disable react/prop-types */
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './RoomViewFloating.scss';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import { markAsRead } from '../../../client/action/notifications';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import Button from '../../atoms/button/Button';
|
|
||||||
|
|
||||||
import MessageIC from '../../../../public/res/ic/outlined/message.svg';
|
|
||||||
import MessageUnreadIC from '../../../../public/res/ic/outlined/message-unread.svg';
|
|
||||||
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
|
|
||||||
|
|
||||||
import { getUsersActionJsx } from './common';
|
|
||||||
|
|
||||||
function useJumpToEvent(roomTimeline) {
|
|
||||||
const [eventId, setEventId] = useState(null);
|
|
||||||
|
|
||||||
const jumpToEvent = () => {
|
|
||||||
roomTimeline.loadEventTimeline(eventId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelJumpToEvent = () => {
|
|
||||||
markAsRead(roomTimeline.roomId);
|
|
||||||
setEventId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const readEventId = roomTimeline.getReadUpToEventId();
|
|
||||||
// we only show "Jump to unread" btn only if the event is not in timeline.
|
|
||||||
// if event is in timeline
|
|
||||||
// we will automatically open the timeline from that event position
|
|
||||||
if (!readEventId?.startsWith('~') && !roomTimeline.hasEventInTimeline(readEventId)) {
|
|
||||||
setEventId(readEventId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { notifications } = initMatrix;
|
|
||||||
const handleMarkAsRead = () => setEventId(null);
|
|
||||||
notifications.on(cons.events.notifications.FULL_READ, handleMarkAsRead);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
notifications.removeListener(cons.events.notifications.FULL_READ, handleMarkAsRead);
|
|
||||||
setEventId(null);
|
|
||||||
};
|
|
||||||
}, [roomTimeline]);
|
|
||||||
|
|
||||||
return [!!eventId, jumpToEvent, cancelJumpToEvent];
|
|
||||||
}
|
|
||||||
|
|
||||||
function useTypingMembers(roomTimeline) {
|
|
||||||
const [typingMembers, setTypingMembers] = useState(new Set());
|
|
||||||
|
|
||||||
const updateTyping = (members) => {
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
members.delete(mx.getUserId());
|
|
||||||
setTypingMembers(members);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTypingMembers(new Set());
|
|
||||||
roomTimeline.on(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
|
|
||||||
return () => {
|
|
||||||
roomTimeline?.removeListener(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
|
|
||||||
};
|
|
||||||
}, [roomTimeline]);
|
|
||||||
|
|
||||||
return [typingMembers];
|
|
||||||
}
|
|
||||||
|
|
||||||
function useScrollToBottom(roomTimeline) {
|
|
||||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
||||||
const handleAtBottom = (atBottom) => setIsAtBottom(atBottom);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsAtBottom(true);
|
|
||||||
roomTimeline.on(cons.events.roomTimeline.AT_BOTTOM, handleAtBottom);
|
|
||||||
return () => roomTimeline.removeListener(cons.events.roomTimeline.AT_BOTTOM, handleAtBottom);
|
|
||||||
}, [roomTimeline]);
|
|
||||||
|
|
||||||
return [isAtBottom, setIsAtBottom];
|
|
||||||
}
|
|
||||||
|
|
||||||
function RoomViewFloating({
|
|
||||||
roomId, roomTimeline,
|
|
||||||
}) {
|
|
||||||
const [isJumpToEvent, jumpToEvent, cancelJumpToEvent] = useJumpToEvent(roomTimeline);
|
|
||||||
const [typingMembers] = useTypingMembers(roomTimeline);
|
|
||||||
const [isAtBottom, setIsAtBottom] = useScrollToBottom(roomTimeline);
|
|
||||||
|
|
||||||
const handleScrollToBottom = () => {
|
|
||||||
roomTimeline.emit(cons.events.roomTimeline.SCROLL_TO_LIVE);
|
|
||||||
setIsAtBottom(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={`room-view__unread ${isJumpToEvent ? 'room-view__unread--open' : ''}`}>
|
|
||||||
<Button iconSrc={MessageUnreadIC} onClick={jumpToEvent} variant="primary">
|
|
||||||
<Text variant="b3" weight="medium">Jump to unread messages</Text>
|
|
||||||
</Button>
|
|
||||||
<Button iconSrc={TickMarkIC} onClick={cancelJumpToEvent} variant="primary">
|
|
||||||
<Text variant="b3" weight="bold">Mark as read</Text>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className={`room-view__typing${typingMembers.size > 0 ? ' room-view__typing--open' : ''}`}>
|
|
||||||
<div className="bouncing-loader"><div /></div>
|
|
||||||
<Text variant="b2">{getUsersActionJsx(roomId, [...typingMembers], 'typing...')}</Text>
|
|
||||||
</div>
|
|
||||||
<div className={`room-view__STB${isAtBottom ? '' : ' room-view__STB--open'}`}>
|
|
||||||
<Button iconSrc={MessageIC} onClick={handleScrollToBottom}>
|
|
||||||
<Text variant="b3" weight="medium">Jump to latest</Text>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
RoomViewFloating.propTypes = {
|
|
||||||
roomId: PropTypes.string.isRequired,
|
|
||||||
roomTimeline: PropTypes.shape({}).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RoomViewFloating;
|
|
|
@ -1,125 +0,0 @@
|
||||||
@use '../../partials/flex';
|
|
||||||
@use '../../partials/text';
|
|
||||||
@use '../../partials/dir';
|
|
||||||
|
|
||||||
.room-view {
|
|
||||||
&__typing {
|
|
||||||
display: flex;
|
|
||||||
padding: var(--sp-ultra-tight) var(--sp-normal);
|
|
||||||
background: var(--bg-surface);
|
|
||||||
transition: transform 200ms ease-in-out;
|
|
||||||
|
|
||||||
& b {
|
|
||||||
color: var(--tc-surface-high);
|
|
||||||
}
|
|
||||||
|
|
||||||
& .text {
|
|
||||||
@extend .cp-txt__ellipsis;
|
|
||||||
@extend .cp-fx__item-one;
|
|
||||||
|
|
||||||
margin: 0 var(--sp-tight);
|
|
||||||
}
|
|
||||||
|
|
||||||
&--open {
|
|
||||||
transform: translateY(-99%);
|
|
||||||
box-shadow: 0 4px 0 0 var(--bg-surface);
|
|
||||||
& .bouncing-loader {
|
|
||||||
& > *,
|
|
||||||
&::after,
|
|
||||||
&::before {
|
|
||||||
animation: bouncing-loader 0.6s infinite alternate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bouncing-loader {
|
|
||||||
transform: translateY(2px);
|
|
||||||
margin: 0 calc(var(--sp-ultra-tight) / 2);
|
|
||||||
}
|
|
||||||
.bouncing-loader > div,
|
|
||||||
.bouncing-loader::before,
|
|
||||||
.bouncing-loader::after {
|
|
||||||
display: inline-block;
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
background: var(--tc-surface-high);
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.bouncing-loader::before,
|
|
||||||
.bouncing-loader::after {
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
.bouncing-loader > div {
|
|
||||||
margin: 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bouncing-loader > div {
|
|
||||||
animation-delay: 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bouncing-loader::after {
|
|
||||||
animation-delay: 0.4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bouncing-loader {
|
|
||||||
to {
|
|
||||||
opacity: 0.1;
|
|
||||||
transform: translate3d(0, -4px, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__STB,
|
|
||||||
&__unread {
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: var(--bg-surface-low);
|
|
||||||
border-radius: var(--bo-radius);
|
|
||||||
|
|
||||||
& button {
|
|
||||||
justify-content: flex-start;
|
|
||||||
border-radius: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
padding: 6px var(--sp-tight);
|
|
||||||
& .ic-raw {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__STB {
|
|
||||||
position: absolute;
|
|
||||||
@include dir.prop(left, 50%, unset);
|
|
||||||
@include dir.prop(right, unset, 50%);
|
|
||||||
bottom: 0;
|
|
||||||
box-shadow: var(--bs-surface-border);
|
|
||||||
transition: transform 200ms ease-in-out;
|
|
||||||
transform: translate(-50%, 100%);
|
|
||||||
|
|
||||||
&--open {
|
|
||||||
transform: translate(-50%, -28px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__unread {
|
|
||||||
position: absolute;
|
|
||||||
top: var(--sp-extra-tight);
|
|
||||||
@include dir.prop(left, var(--sp-normal), unset);
|
|
||||||
@include dir.prop(right, unset, var(--sp-normal));
|
|
||||||
z-index: 999;
|
|
||||||
|
|
||||||
display: none;
|
|
||||||
width: calc(100% - var(--sp-extra-loose));
|
|
||||||
box-shadow: 0 0 2px 0 rgba(0, 0, 0, 20%);
|
|
||||||
|
|
||||||
&--open {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
& button:first-child {
|
|
||||||
@extend .cp-fx__item-one;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,132 +0,0 @@
|
||||||
import React, { useEffect, useRef } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './RoomViewHeader.scss';
|
|
||||||
|
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
import { blurOnBubbling } from '../../atoms/button/script';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import navigation from '../../../client/state/navigation';
|
|
||||||
import {
|
|
||||||
toggleRoomSettings,
|
|
||||||
openReusableContextMenu,
|
|
||||||
openNavigation,
|
|
||||||
} from '../../../client/action/navigation';
|
|
||||||
import colorMXID from '../../../util/colorMXID';
|
|
||||||
import { getEventCords } from '../../../util/common';
|
|
||||||
|
|
||||||
import { tabText } from './RoomSettings';
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
|
||||||
import IconButton from '../../atoms/button/IconButton';
|
|
||||||
import Header, { TitleWrapper } from '../../atoms/header/Header';
|
|
||||||
import Avatar from '../../atoms/avatar/Avatar';
|
|
||||||
import RoomOptions from '../../molecules/room-options/RoomOptions';
|
|
||||||
|
|
||||||
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
|
|
||||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
|
||||||
import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
|
||||||
import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
|
|
||||||
import BackArrowIC from '../../../../public/res/ic/outlined/chevron-left.svg';
|
|
||||||
|
|
||||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
|
||||||
import { useSetSetting } from '../../state/hooks/settings';
|
|
||||||
import { settingsAtom } from '../../state/settings';
|
|
||||||
|
|
||||||
function RoomViewHeader({ roomId }) {
|
|
||||||
const [, forceUpdate] = useForceUpdate();
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const isDM = initMatrix.roomList.directs.has(roomId);
|
|
||||||
const room = mx.getRoom(roomId);
|
|
||||||
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
|
|
||||||
let avatarSrc = room.getAvatarUrl(mx.baseUrl, 36, 36, 'crop');
|
|
||||||
avatarSrc = isDM
|
|
||||||
? room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 36, 36, 'crop')
|
|
||||||
: avatarSrc;
|
|
||||||
const roomName = room.name;
|
|
||||||
|
|
||||||
const roomHeaderBtnRef = useRef(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const settingsToggle = (isVisibile) => {
|
|
||||||
const rawIcon = roomHeaderBtnRef.current.lastElementChild;
|
|
||||||
rawIcon.style.transform = isVisibile ? 'rotateX(180deg)' : 'rotateX(0deg)';
|
|
||||||
};
|
|
||||||
navigation.on(cons.events.navigation.ROOM_SETTINGS_TOGGLED, settingsToggle);
|
|
||||||
return () => {
|
|
||||||
navigation.removeListener(cons.events.navigation.ROOM_SETTINGS_TOGGLED, settingsToggle);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const { roomList } = initMatrix;
|
|
||||||
const handleProfileUpdate = (rId) => {
|
|
||||||
if (roomId !== rId) return;
|
|
||||||
forceUpdate();
|
|
||||||
};
|
|
||||||
|
|
||||||
roomList.on(cons.events.roomList.ROOM_PROFILE_UPDATED, handleProfileUpdate);
|
|
||||||
return () => {
|
|
||||||
roomList.removeListener(cons.events.roomList.ROOM_PROFILE_UPDATED, handleProfileUpdate);
|
|
||||||
};
|
|
||||||
}, [roomId]);
|
|
||||||
|
|
||||||
const openRoomOptions = (e) => {
|
|
||||||
openReusableContextMenu('bottom', getEventCords(e, '.ic-btn'), (closeMenu) => (
|
|
||||||
<RoomOptions roomId={roomId} afterOptionSelect={closeMenu} />
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Header>
|
|
||||||
<IconButton
|
|
||||||
src={BackArrowIC}
|
|
||||||
className="room-header__back-btn"
|
|
||||||
tooltip="Return to navigation"
|
|
||||||
onClick={() => openNavigation()}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
ref={roomHeaderBtnRef}
|
|
||||||
className="room-header__btn"
|
|
||||||
onClick={() => toggleRoomSettings()}
|
|
||||||
type="button"
|
|
||||||
onMouseUp={(e) => blurOnBubbling(e, '.room-header__btn')}
|
|
||||||
>
|
|
||||||
<Avatar imageSrc={avatarSrc} text={roomName} bgColor={colorMXID(roomId)} size="small" />
|
|
||||||
<TitleWrapper>
|
|
||||||
<Text variant="h2" weight="medium" primary>
|
|
||||||
{twemojify(roomName)}
|
|
||||||
</Text>
|
|
||||||
</TitleWrapper>
|
|
||||||
<RawIcon src={ChevronBottomIC} />
|
|
||||||
</button>
|
|
||||||
{mx.isRoomEncrypted(roomId) === false && (
|
|
||||||
<IconButton
|
|
||||||
onClick={() => toggleRoomSettings(tabText.SEARCH)}
|
|
||||||
tooltip="Search"
|
|
||||||
src={SearchIC}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<IconButton
|
|
||||||
className="room-header__drawer-btn"
|
|
||||||
onClick={() => {
|
|
||||||
setPeopleDrawer((t) => !t);
|
|
||||||
}}
|
|
||||||
tooltip="People"
|
|
||||||
src={UserIC}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
className="room-header__members-btn"
|
|
||||||
onClick={() => toggleRoomSettings(tabText.MEMBERS)}
|
|
||||||
tooltip="Members"
|
|
||||||
src={UserIC}
|
|
||||||
/>
|
|
||||||
<IconButton onClick={openRoomOptions} tooltip="Options" src={VerticalMenuIC} />
|
|
||||||
</Header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
RoomViewHeader.propTypes = {
|
|
||||||
roomId: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RoomViewHeader;
|
|
|
@ -1,47 +0,0 @@
|
||||||
@use '../../partials/flex';
|
|
||||||
@use '../../partials/dir';
|
|
||||||
@use '../../partials/screen';
|
|
||||||
|
|
||||||
.room-header__btn {
|
|
||||||
min-width: 0;
|
|
||||||
@extend .cp-fx__row--s-c;
|
|
||||||
@include dir.side(margin, 0, auto);
|
|
||||||
border-radius: var(--bo-radius);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
& .ic-raw {
|
|
||||||
@include dir.side(margin, 0, var(--sp-extra-tight));
|
|
||||||
transition: transform 200ms ease-in-out;
|
|
||||||
}
|
|
||||||
@media (hover:hover) {
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--bg-surface-hover);
|
|
||||||
box-shadow: var(--bs-surface-outline);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&:focus,
|
|
||||||
&:active {
|
|
||||||
background-color: var(--bg-surface-active);
|
|
||||||
box-shadow: var(--bs-surface-outline);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-header__drawer-btn {
|
|
||||||
@include screen.smallerThan(tabletBreakpoint) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.room-header__members-btn {
|
|
||||||
@include screen.biggerThan(tabletBreakpoint) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-header__back-btn {
|
|
||||||
@include dir.side(margin, 0, var(--sp-tight));
|
|
||||||
|
|
||||||
@include screen.biggerThan(mobileBreakpoint) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,491 +0,0 @@
|
||||||
/* eslint-disable react/prop-types */
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './RoomViewInput.scss';
|
|
||||||
|
|
||||||
import TextareaAutosize from 'react-autosize-textarea';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import settings from '../../../client/state/settings';
|
|
||||||
import { openEmojiBoard, openReusableContextMenu } from '../../../client/action/navigation';
|
|
||||||
import navigation from '../../../client/state/navigation';
|
|
||||||
import { bytesToSize, getEventCords } from '../../../util/common';
|
|
||||||
import { getUsername } from '../../../util/matrixUtil';
|
|
||||||
import colorMXID from '../../../util/colorMXID';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
|
||||||
import IconButton from '../../atoms/button/IconButton';
|
|
||||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
|
||||||
import { MessageReply } from '../../molecules/message/Message';
|
|
||||||
|
|
||||||
import StickerBoard from '../sticker-board/StickerBoard';
|
|
||||||
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
|
||||||
|
|
||||||
import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg';
|
|
||||||
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
|
|
||||||
import SendIC from '../../../../public/res/ic/outlined/send.svg';
|
|
||||||
import StickerIC from '../../../../public/res/ic/outlined/sticker.svg';
|
|
||||||
import ShieldIC from '../../../../public/res/ic/outlined/shield.svg';
|
|
||||||
import VLCIC from '../../../../public/res/ic/outlined/vlc.svg';
|
|
||||||
import VolumeFullIC from '../../../../public/res/ic/outlined/volume-full.svg';
|
|
||||||
import FileIC from '../../../../public/res/ic/outlined/file.svg';
|
|
||||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
|
||||||
|
|
||||||
import commands from './commands';
|
|
||||||
|
|
||||||
const CMD_REGEX = /(^\/|:|@)(\S*)$/;
|
|
||||||
let isTyping = false;
|
|
||||||
let isCmdActivated = false;
|
|
||||||
let cmdCursorPos = null;
|
|
||||||
function RoomViewInput({
|
|
||||||
roomId, roomTimeline, viewEvent,
|
|
||||||
}) {
|
|
||||||
const [attachment, setAttachment] = useState(null);
|
|
||||||
const [replyTo, setReplyTo] = useState(null);
|
|
||||||
|
|
||||||
const textAreaRef = useRef(null);
|
|
||||||
const inputBaseRef = useRef(null);
|
|
||||||
const uploadInputRef = useRef(null);
|
|
||||||
const uploadProgressRef = useRef(null);
|
|
||||||
const rightOptionsRef = useRef(null);
|
|
||||||
|
|
||||||
const TYPING_TIMEOUT = 5000;
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const { roomsInput } = initMatrix;
|
|
||||||
|
|
||||||
function requestFocusInput() {
|
|
||||||
if (textAreaRef === null) return;
|
|
||||||
textAreaRef.current.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
roomsInput.on(cons.events.roomsInput.ATTACHMENT_SET, setAttachment);
|
|
||||||
viewEvent.on('focus_msg_input', requestFocusInput);
|
|
||||||
return () => {
|
|
||||||
roomsInput.removeListener(cons.events.roomsInput.ATTACHMENT_SET, setAttachment);
|
|
||||||
viewEvent.removeListener('focus_msg_input', requestFocusInput);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const sendIsTyping = (isT) => {
|
|
||||||
mx.sendTyping(roomId, isT, isT ? TYPING_TIMEOUT : undefined);
|
|
||||||
isTyping = isT;
|
|
||||||
|
|
||||||
if (isT === true) {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (isTyping) sendIsTyping(false);
|
|
||||||
}, TYPING_TIMEOUT);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function uploadingProgress(myRoomId, { loaded, total }) {
|
|
||||||
if (myRoomId !== roomId) return;
|
|
||||||
const progressPer = Math.round((loaded * 100) / total);
|
|
||||||
uploadProgressRef.current.textContent = `Uploading: ${bytesToSize(loaded)}/${bytesToSize(total)} (${progressPer}%)`;
|
|
||||||
inputBaseRef.current.style.backgroundImage = `linear-gradient(90deg, var(--bg-surface-hover) ${progressPer}%, var(--bg-surface-low) ${progressPer}%)`;
|
|
||||||
}
|
|
||||||
function clearAttachment(myRoomId) {
|
|
||||||
if (roomId !== myRoomId) return;
|
|
||||||
setAttachment(null);
|
|
||||||
inputBaseRef.current.style.backgroundImage = 'unset';
|
|
||||||
uploadInputRef.current.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rightOptionsA11Y(A11Y) {
|
|
||||||
const rightOptions = rightOptionsRef.current.children;
|
|
||||||
for (let index = 0; index < rightOptions.length; index += 1) {
|
|
||||||
rightOptions[index].tabIndex = A11Y ? 0 : -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function activateCmd(prefix) {
|
|
||||||
isCmdActivated = true;
|
|
||||||
rightOptionsA11Y(false);
|
|
||||||
viewEvent.emit('cmd_activate', prefix);
|
|
||||||
}
|
|
||||||
function deactivateCmd() {
|
|
||||||
isCmdActivated = false;
|
|
||||||
cmdCursorPos = null;
|
|
||||||
rightOptionsA11Y(true);
|
|
||||||
}
|
|
||||||
function deactivateCmdAndEmit() {
|
|
||||||
deactivateCmd();
|
|
||||||
viewEvent.emit('cmd_deactivate');
|
|
||||||
}
|
|
||||||
function setCursorPosition(pos) {
|
|
||||||
setTimeout(() => {
|
|
||||||
textAreaRef.current.focus();
|
|
||||||
textAreaRef.current.setSelectionRange(pos, pos);
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
function replaceCmdWith(msg, cursor, replacement) {
|
|
||||||
if (msg === null) return null;
|
|
||||||
const targetInput = msg.slice(0, cursor);
|
|
||||||
const cmdParts = targetInput.match(CMD_REGEX);
|
|
||||||
const leadingInput = msg.slice(0, cmdParts.index);
|
|
||||||
if (replacement.length > 0) setCursorPosition(leadingInput.length + replacement.length);
|
|
||||||
return leadingInput + replacement + msg.slice(cursor);
|
|
||||||
}
|
|
||||||
function firedCmd(cmdData) {
|
|
||||||
const msg = textAreaRef.current.value;
|
|
||||||
textAreaRef.current.value = replaceCmdWith(
|
|
||||||
msg,
|
|
||||||
cmdCursorPos,
|
|
||||||
typeof cmdData?.replace !== 'undefined' ? cmdData.replace : '',
|
|
||||||
);
|
|
||||||
deactivateCmd();
|
|
||||||
}
|
|
||||||
|
|
||||||
function focusInput() {
|
|
||||||
if (settings.isTouchScreenDevice) return;
|
|
||||||
textAreaRef.current.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setUpReply(userId, eventId, body, formattedBody) {
|
|
||||||
setReplyTo({ userId, eventId, body });
|
|
||||||
roomsInput.setReplyTo(roomId, {
|
|
||||||
userId, eventId, body, formattedBody,
|
|
||||||
});
|
|
||||||
focusInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
roomsInput.on(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress);
|
|
||||||
roomsInput.on(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment);
|
|
||||||
roomsInput.on(cons.events.roomsInput.FILE_UPLOADED, clearAttachment);
|
|
||||||
viewEvent.on('cmd_fired', firedCmd);
|
|
||||||
navigation.on(cons.events.navigation.REPLY_TO_CLICKED, setUpReply);
|
|
||||||
if (textAreaRef?.current !== null) {
|
|
||||||
isTyping = false;
|
|
||||||
textAreaRef.current.value = roomsInput.getMessage(roomId);
|
|
||||||
setAttachment(roomsInput.getAttachment(roomId));
|
|
||||||
setReplyTo(roomsInput.getReplyTo(roomId));
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
roomsInput.removeListener(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress);
|
|
||||||
roomsInput.removeListener(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment);
|
|
||||||
roomsInput.removeListener(cons.events.roomsInput.FILE_UPLOADED, clearAttachment);
|
|
||||||
viewEvent.removeListener('cmd_fired', firedCmd);
|
|
||||||
navigation.removeListener(cons.events.navigation.REPLY_TO_CLICKED, setUpReply);
|
|
||||||
if (isCmdActivated) deactivateCmd();
|
|
||||||
if (textAreaRef?.current === null) return;
|
|
||||||
|
|
||||||
const msg = textAreaRef.current.value;
|
|
||||||
textAreaRef.current.style.height = 'unset';
|
|
||||||
inputBaseRef.current.style.backgroundImage = 'unset';
|
|
||||||
if (msg.trim() === '') {
|
|
||||||
roomsInput.setMessage(roomId, '');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
roomsInput.setMessage(roomId, msg);
|
|
||||||
};
|
|
||||||
}, [roomId]);
|
|
||||||
|
|
||||||
const sendBody = async (body, options) => {
|
|
||||||
const opt = options ?? {};
|
|
||||||
if (!opt.msgType) opt.msgType = 'm.text';
|
|
||||||
if (typeof opt.autoMarkdown !== 'boolean') opt.autoMarkdown = true;
|
|
||||||
if (roomsInput.isSending(roomId)) return;
|
|
||||||
sendIsTyping(false);
|
|
||||||
|
|
||||||
roomsInput.setMessage(roomId, body);
|
|
||||||
if (attachment !== null) {
|
|
||||||
roomsInput.setAttachment(roomId, attachment);
|
|
||||||
}
|
|
||||||
textAreaRef.current.disabled = true;
|
|
||||||
textAreaRef.current.style.cursor = 'not-allowed';
|
|
||||||
await roomsInput.sendInput(roomId, opt);
|
|
||||||
textAreaRef.current.disabled = false;
|
|
||||||
textAreaRef.current.style.cursor = 'unset';
|
|
||||||
focusInput();
|
|
||||||
|
|
||||||
textAreaRef.current.value = roomsInput.getMessage(roomId);
|
|
||||||
textAreaRef.current.style.height = 'unset';
|
|
||||||
if (replyTo !== null) setReplyTo(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Return true if a command was executed. */
|
|
||||||
const processCommand = async (cmdBody) => {
|
|
||||||
const spaceIndex = cmdBody.indexOf(' ');
|
|
||||||
const cmdName = cmdBody.slice(1, spaceIndex > -1 ? spaceIndex : undefined);
|
|
||||||
const cmdData = spaceIndex > -1 ? cmdBody.slice(spaceIndex + 1) : '';
|
|
||||||
if (!commands[cmdName]) {
|
|
||||||
const sendAsMessage = await confirmDialog('Invalid Command', `"${cmdName}" is not a valid command. Did you mean to send this as a message?`, 'Send as message');
|
|
||||||
if (sendAsMessage) {
|
|
||||||
sendBody(cmdBody);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (['me', 'shrug', 'plain'].includes(cmdName)) {
|
|
||||||
commands[cmdName].exe(roomId, cmdData, sendBody);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
commands[cmdName].exe(roomId, cmdData);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendMessage = async () => {
|
|
||||||
requestAnimationFrame(() => deactivateCmdAndEmit());
|
|
||||||
const msgBody = textAreaRef.current.value.trim();
|
|
||||||
if (msgBody.startsWith('/')) {
|
|
||||||
const executed = await processCommand(msgBody.trim());
|
|
||||||
if (executed) {
|
|
||||||
textAreaRef.current.value = '';
|
|
||||||
textAreaRef.current.style.height = 'unset';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (msgBody === '' && attachment === null) return;
|
|
||||||
sendBody(msgBody);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendSticker = async (data) => {
|
|
||||||
roomsInput.sendSticker(roomId, data);
|
|
||||||
};
|
|
||||||
|
|
||||||
function processTyping(msg) {
|
|
||||||
const isEmptyMsg = msg === '';
|
|
||||||
|
|
||||||
if (isEmptyMsg && isTyping) {
|
|
||||||
sendIsTyping(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isEmptyMsg && !isTyping) {
|
|
||||||
sendIsTyping(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCursorPosition() {
|
|
||||||
return textAreaRef.current.selectionStart;
|
|
||||||
}
|
|
||||||
|
|
||||||
function recognizeCmd(rawInput) {
|
|
||||||
const cursor = getCursorPosition();
|
|
||||||
const targetInput = rawInput.slice(0, cursor);
|
|
||||||
|
|
||||||
const cmdParts = targetInput.match(CMD_REGEX);
|
|
||||||
if (cmdParts === null) {
|
|
||||||
if (isCmdActivated) deactivateCmdAndEmit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cmdPrefix = cmdParts[1];
|
|
||||||
const cmdSlug = cmdParts[2];
|
|
||||||
|
|
||||||
if (cmdPrefix === ':') {
|
|
||||||
// skip emoji autofill command if link is suspected.
|
|
||||||
const checkForLink = targetInput.slice(0, cmdParts.index);
|
|
||||||
if (checkForLink.match(/(http|https|mailto|matrix|ircs|irc)$/)) {
|
|
||||||
deactivateCmdAndEmit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cmdCursorPos = cursor;
|
|
||||||
if (cmdSlug === '') {
|
|
||||||
activateCmd(cmdPrefix);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isCmdActivated) activateCmd(cmdPrefix);
|
|
||||||
viewEvent.emit('cmd_process', cmdPrefix, cmdSlug);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMsgTyping = (e) => {
|
|
||||||
const msg = e.target.value;
|
|
||||||
recognizeCmd(e.target.value);
|
|
||||||
if (!isCmdActivated) processTyping(msg);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
roomsInput.cancelReplyTo(roomId);
|
|
||||||
setReplyTo(null);
|
|
||||||
}
|
|
||||||
if (e.key === 'Enter' && e.shiftKey === false) {
|
|
||||||
e.preventDefault();
|
|
||||||
sendMessage();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePaste = (e) => {
|
|
||||||
if (e.clipboardData === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.clipboardData.items === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < e.clipboardData.items.length; i += 1) {
|
|
||||||
const item = e.clipboardData.items[i];
|
|
||||||
if (item.type.indexOf('image') !== -1) {
|
|
||||||
const image = item.getAsFile();
|
|
||||||
if (attachment === null) {
|
|
||||||
setAttachment(image);
|
|
||||||
if (image !== null) {
|
|
||||||
roomsInput.setAttachment(roomId, image);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function addEmoji(emoji) {
|
|
||||||
textAreaRef.current.value += emoji.unicode;
|
|
||||||
textAreaRef.current.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUploadClick = () => {
|
|
||||||
if (attachment === null) uploadInputRef.current.click();
|
|
||||||
else {
|
|
||||||
roomsInput.cancelAttachment(roomId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
function uploadFileChange(e) {
|
|
||||||
const file = e.target.files.item(0);
|
|
||||||
setAttachment(file);
|
|
||||||
if (file !== null) roomsInput.setAttachment(roomId, file);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderInputs() {
|
|
||||||
const canISend = roomTimeline.room.currentState.maySendMessage(mx.getUserId());
|
|
||||||
const tombstoneEvent = roomTimeline.room.currentState.getStateEvents('m.room.tombstone')[0];
|
|
||||||
if (!canISend || tombstoneEvent) {
|
|
||||||
return (
|
|
||||||
<Text className="room-input__alert">
|
|
||||||
{
|
|
||||||
tombstoneEvent
|
|
||||||
? tombstoneEvent.getContent()?.body ?? 'This room has been replaced and is no longer active.'
|
|
||||||
: 'You do not have permission to post to this room'
|
|
||||||
}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={`room-input__option-container${attachment === null ? '' : ' room-attachment__option'}`}>
|
|
||||||
<input onChange={uploadFileChange} style={{ display: 'none' }} ref={uploadInputRef} type="file" />
|
|
||||||
<IconButton onClick={handleUploadClick} tooltip={attachment === null ? 'Upload' : 'Cancel'} src={CirclePlusIC} />
|
|
||||||
</div>
|
|
||||||
<div ref={inputBaseRef} className="room-input__input-container">
|
|
||||||
{roomTimeline.isEncrypted() && <RawIcon size="extra-small" src={ShieldIC} />}
|
|
||||||
<ScrollView autoHide>
|
|
||||||
<Text className="room-input__textarea-wrapper">
|
|
||||||
<TextareaAutosize
|
|
||||||
dir="auto"
|
|
||||||
id="message-textarea"
|
|
||||||
ref={textAreaRef}
|
|
||||||
onChange={handleMsgTyping}
|
|
||||||
onPaste={handlePaste}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Send a message..."
|
|
||||||
/>
|
|
||||||
</Text>
|
|
||||||
</ScrollView>
|
|
||||||
</div>
|
|
||||||
<div ref={rightOptionsRef} className="room-input__option-container">
|
|
||||||
<IconButton
|
|
||||||
onClick={(e) => {
|
|
||||||
openReusableContextMenu(
|
|
||||||
'top',
|
|
||||||
(() => {
|
|
||||||
const cords = getEventCords(e);
|
|
||||||
cords.y -= 20;
|
|
||||||
return cords;
|
|
||||||
})(),
|
|
||||||
(closeMenu) => (
|
|
||||||
<StickerBoard
|
|
||||||
roomId={roomId}
|
|
||||||
onSelect={(data) => {
|
|
||||||
handleSendSticker(data);
|
|
||||||
closeMenu();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
tooltip="Sticker"
|
|
||||||
src={StickerIC}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
onClick={(e) => {
|
|
||||||
const cords = getEventCords(e);
|
|
||||||
cords.x += (document.dir === 'rtl' ? -80 : 80);
|
|
||||||
cords.y -= 250;
|
|
||||||
openEmojiBoard(cords, addEmoji);
|
|
||||||
}}
|
|
||||||
tooltip="Emoji"
|
|
||||||
src={EmojiIC}
|
|
||||||
/>
|
|
||||||
<IconButton onClick={sendMessage} tooltip="Send" src={SendIC} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachFile() {
|
|
||||||
const fileType = attachment.type.slice(0, attachment.type.indexOf('/'));
|
|
||||||
return (
|
|
||||||
<div className="room-attachment">
|
|
||||||
<div className={`room-attachment__preview${fileType !== 'image' ? ' room-attachment__icon' : ''}`}>
|
|
||||||
{fileType === 'image' && <img alt={attachment.name} src={URL.createObjectURL(attachment)} />}
|
|
||||||
{fileType === 'video' && <RawIcon src={VLCIC} />}
|
|
||||||
{fileType === 'audio' && <RawIcon src={VolumeFullIC} />}
|
|
||||||
{fileType !== 'image' && fileType !== 'video' && fileType !== 'audio' && <RawIcon src={FileIC} />}
|
|
||||||
</div>
|
|
||||||
<div className="room-attachment__info">
|
|
||||||
<Text variant="b1">{attachment.name}</Text>
|
|
||||||
<Text variant="b3"><span ref={uploadProgressRef}>{`size: ${bytesToSize(attachment.size)}`}</span></Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachReply() {
|
|
||||||
return (
|
|
||||||
<div className="room-reply">
|
|
||||||
<IconButton
|
|
||||||
onClick={() => {
|
|
||||||
roomsInput.cancelReplyTo(roomId);
|
|
||||||
setReplyTo(null);
|
|
||||||
}}
|
|
||||||
src={CrossIC}
|
|
||||||
tooltip="Cancel reply"
|
|
||||||
size="extra-small"
|
|
||||||
/>
|
|
||||||
<MessageReply
|
|
||||||
userId={replyTo.userId}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
name={getUsername(replyTo.userId)}
|
|
||||||
color={colorMXID(replyTo.userId)}
|
|
||||||
body={replyTo.body}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{ replyTo !== null && attachReply()}
|
|
||||||
{ attachment !== null && attachFile() }
|
|
||||||
<form className="room-input" onSubmit={(e) => { e.preventDefault(); }}>
|
|
||||||
{
|
|
||||||
renderInputs()
|
|
||||||
}
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
RoomViewInput.propTypes = {
|
|
||||||
roomId: PropTypes.string.isRequired,
|
|
||||||
roomTimeline: PropTypes.shape({}).isRequired,
|
|
||||||
viewEvent: PropTypes.shape({}).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RoomViewInput;
|
|
|
@ -1,108 +0,0 @@
|
||||||
@use '../../partials/dir';
|
|
||||||
|
|
||||||
.room-input {
|
|
||||||
padding: var(--sp-extra-tight) calc(var(--sp-normal) - 2px);
|
|
||||||
display: flex;
|
|
||||||
min-height: 56px;
|
|
||||||
|
|
||||||
&__alert {
|
|
||||||
margin: auto;
|
|
||||||
padding: 0 var(--sp-tight);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__input-container {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
margin: 0 calc(var(--sp-tight) - 2px);
|
|
||||||
background-color: var(--bg-surface-low);
|
|
||||||
box-shadow: var(--bs-surface-border);
|
|
||||||
border-radius: var(--bo-radius);
|
|
||||||
|
|
||||||
& > .ic-raw {
|
|
||||||
transform: scale(0.8);
|
|
||||||
margin: 0 var(--sp-extra-tight);
|
|
||||||
}
|
|
||||||
|
|
||||||
& .scrollbar {
|
|
||||||
max-height: 50vh;
|
|
||||||
flex: 1;
|
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
@include dir.side(margin, var(--sp-tight), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__textarea-wrapper {
|
|
||||||
min-height: 40px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
& textarea {
|
|
||||||
resize: none;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 100%;
|
|
||||||
padding: var(--sp-ultra-tight) 0;
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: var(--tc-surface-low);
|
|
||||||
}
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-attachment {
|
|
||||||
--side-spacing: calc(var(--sp-normal) + var(--av-small) + var(--sp-tight));
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
@include dir.side(margin, var(--side-spacing), 0);
|
|
||||||
margin-top: var(--sp-extra-tight);
|
|
||||||
line-height: 0;
|
|
||||||
|
|
||||||
&__preview > img {
|
|
||||||
max-height: 40px;
|
|
||||||
border-radius: var(--bo-radius);
|
|
||||||
max-width: 150px;
|
|
||||||
}
|
|
||||||
&__icon {
|
|
||||||
padding: var(--sp-extra-tight);
|
|
||||||
background-color: var(--bg-surface-low);
|
|
||||||
box-shadow: var(--bs-surface-border);
|
|
||||||
border-radius: var(--bo-radius);
|
|
||||||
}
|
|
||||||
&__info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
margin: 0 var(--sp-tight);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__option button {
|
|
||||||
transition: transform 200ms ease-in-out;
|
|
||||||
transform: translateY(-48px);
|
|
||||||
& .ic-raw {
|
|
||||||
transition: transform 200ms ease-in-out;
|
|
||||||
transform: rotate(45deg);
|
|
||||||
background-color: var(--bg-caution);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-reply {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
background-color: var(--bg-surface-low);
|
|
||||||
border-bottom: 1px solid var(--bg-surface-border);
|
|
||||||
|
|
||||||
& .ic-btn-surface {
|
|
||||||
@include dir.side(margin, 17px, 13px);
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,136 +0,0 @@
|
||||||
import { getScrollInfo } from '../../../util/common';
|
|
||||||
|
|
||||||
class TimelineScroll {
|
|
||||||
constructor(target) {
|
|
||||||
if (target === null) {
|
|
||||||
throw new Error('Can not initialize TimelineScroll, target HTMLElement in null');
|
|
||||||
}
|
|
||||||
this.scroll = target;
|
|
||||||
|
|
||||||
this.backwards = false;
|
|
||||||
this.inTopHalf = false;
|
|
||||||
|
|
||||||
this.isScrollable = false;
|
|
||||||
this.top = 0;
|
|
||||||
this.bottom = 0;
|
|
||||||
this.height = 0;
|
|
||||||
this.viewHeight = 0;
|
|
||||||
|
|
||||||
this.topMsg = null;
|
|
||||||
this.bottomMsg = null;
|
|
||||||
this.diff = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollToBottom() {
|
|
||||||
const scrollInfo = getScrollInfo(this.scroll);
|
|
||||||
const maxScrollTop = scrollInfo.height - scrollInfo.viewHeight;
|
|
||||||
|
|
||||||
this._scrollTo(scrollInfo, maxScrollTop);
|
|
||||||
}
|
|
||||||
|
|
||||||
// use previous calc by this._updateTopBottomMsg() & this._calcDiff.
|
|
||||||
tryRestoringScroll() {
|
|
||||||
const scrollInfo = getScrollInfo(this.scroll);
|
|
||||||
|
|
||||||
let scrollTop = 0;
|
|
||||||
const ot = this.inTopHalf ? this.topMsg?.offsetTop : this.bottomMsg?.offsetTop;
|
|
||||||
if (!ot) scrollTop = Math.round(this.height - this.viewHeight);
|
|
||||||
else scrollTop = ot - this.diff;
|
|
||||||
|
|
||||||
this._scrollTo(scrollInfo, scrollTop);
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollToIndex(index, offset = 0) {
|
|
||||||
const scrollInfo = getScrollInfo(this.scroll);
|
|
||||||
const msgs = this.scroll.lastElementChild.lastElementChild.children;
|
|
||||||
const offsetTop = msgs[index]?.offsetTop;
|
|
||||||
|
|
||||||
if (offsetTop === undefined) return;
|
|
||||||
// if msg is already in visible are we don't need to scroll to that
|
|
||||||
if (offsetTop > scrollInfo.top && offsetTop < (scrollInfo.top + scrollInfo.viewHeight)) return;
|
|
||||||
const to = offsetTop - offset;
|
|
||||||
|
|
||||||
this._scrollTo(scrollInfo, to);
|
|
||||||
}
|
|
||||||
|
|
||||||
_scrollTo(scrollInfo, scrollTop) {
|
|
||||||
this.scroll.scrollTop = scrollTop;
|
|
||||||
|
|
||||||
// browser emit 'onscroll' event only if the 'element.scrollTop' value changes.
|
|
||||||
// so here we flag that the upcoming 'onscroll' event is
|
|
||||||
// emitted as side effect of assigning 'this.scroll.scrollTop' above
|
|
||||||
// only if it's changes.
|
|
||||||
// by doing so we prevent this._updateCalc() from calc again.
|
|
||||||
if (scrollTop !== this.top) {
|
|
||||||
this.scrolledByCode = true;
|
|
||||||
}
|
|
||||||
const sInfo = { ...scrollInfo };
|
|
||||||
|
|
||||||
const maxScrollTop = scrollInfo.height - scrollInfo.viewHeight;
|
|
||||||
|
|
||||||
sInfo.top = (scrollTop > maxScrollTop) ? maxScrollTop : scrollTop;
|
|
||||||
this._updateCalc(sInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
// we maintain reference of top and bottom messages
|
|
||||||
// to restore the scroll position when
|
|
||||||
// messages gets removed from either end and added to other.
|
|
||||||
_updateTopBottomMsg() {
|
|
||||||
const msgs = this.scroll.lastElementChild.lastElementChild.children;
|
|
||||||
const lMsgIndex = msgs.length - 1;
|
|
||||||
|
|
||||||
// TODO: classname 'ph-msg' prevent this class from being used
|
|
||||||
const PLACEHOLDER_COUNT = 2;
|
|
||||||
this.topMsg = msgs[0]?.className === 'ph-msg'
|
|
||||||
? msgs[PLACEHOLDER_COUNT]
|
|
||||||
: msgs[0];
|
|
||||||
this.bottomMsg = msgs[lMsgIndex]?.className === 'ph-msg'
|
|
||||||
? msgs[lMsgIndex - PLACEHOLDER_COUNT]
|
|
||||||
: msgs[lMsgIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
// we calculate the difference between first/last message and current scrollTop.
|
|
||||||
// if we are going above we calc diff between first and scrollTop
|
|
||||||
// else otherwise.
|
|
||||||
// NOTE: This will help to restore the scroll when msgs get's removed
|
|
||||||
// from one end and added to other end
|
|
||||||
_calcDiff(scrollInfo) {
|
|
||||||
if (!this.topMsg || !this.bottomMsg) return 0;
|
|
||||||
if (this.inTopHalf) {
|
|
||||||
return this.topMsg.offsetTop - scrollInfo.top;
|
|
||||||
}
|
|
||||||
return this.bottomMsg.offsetTop - scrollInfo.top;
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateCalc(scrollInfo) {
|
|
||||||
const halfViewHeight = Math.round(scrollInfo.viewHeight / 2);
|
|
||||||
const scrollMiddle = scrollInfo.top + halfViewHeight;
|
|
||||||
const lastMiddle = this.top + halfViewHeight;
|
|
||||||
|
|
||||||
this.backwards = scrollMiddle < lastMiddle;
|
|
||||||
this.inTopHalf = scrollMiddle < scrollInfo.height / 2;
|
|
||||||
|
|
||||||
this.isScrollable = scrollInfo.isScrollable;
|
|
||||||
this.top = scrollInfo.top;
|
|
||||||
this.bottom = scrollInfo.height - (scrollInfo.top + scrollInfo.viewHeight);
|
|
||||||
this.height = scrollInfo.height;
|
|
||||||
this.viewHeight = scrollInfo.viewHeight;
|
|
||||||
|
|
||||||
this._updateTopBottomMsg();
|
|
||||||
this.diff = this._calcDiff(scrollInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
calcScroll() {
|
|
||||||
if (this.scrolledByCode) {
|
|
||||||
this.scrolledByCode = false;
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollInfo = getScrollInfo(this.scroll);
|
|
||||||
this._updateCalc(scrollInfo);
|
|
||||||
|
|
||||||
return this.backwards;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TimelineScroll;
|
|
|
@ -1,220 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import './commands.scss';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import * as roomActions from '../../../client/action/room';
|
|
||||||
import { hasDMWith, hasDevices } from '../../../util/matrixUtil';
|
|
||||||
import { selectRoom, openReusableDialog } from '../../../client/action/navigation';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import SettingTile from '../../molecules/setting-tile/SettingTile';
|
|
||||||
|
|
||||||
const MXID_REG = /^@\S+:\S+$/;
|
|
||||||
const ROOM_ID_ALIAS_REG = /^(#|!)\S+:\S+$/;
|
|
||||||
const ROOM_ID_REG = /^!\S+:\S+$/;
|
|
||||||
const MXC_REG = /^mxc:\/\/\S+$/;
|
|
||||||
|
|
||||||
export function processMxidAndReason(data) {
|
|
||||||
let reason;
|
|
||||||
let idData = data;
|
|
||||||
const reasonMatch = data.match(/\s-r\s/);
|
|
||||||
if (reasonMatch) {
|
|
||||||
idData = data.slice(0, reasonMatch.index);
|
|
||||||
reason = data.slice(reasonMatch.index + reasonMatch[0].length);
|
|
||||||
if (reason.trim() === '') reason = undefined;
|
|
||||||
}
|
|
||||||
const rawIds = idData.split(' ');
|
|
||||||
const userIds = rawIds.filter((id) => id.match(MXID_REG));
|
|
||||||
return {
|
|
||||||
userIds,
|
|
||||||
reason,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const commands = {
|
|
||||||
me: {
|
|
||||||
name: 'me',
|
|
||||||
description: 'Display action',
|
|
||||||
exe: (roomId, data, onSuccess) => {
|
|
||||||
const body = data.trim();
|
|
||||||
if (body === '') return;
|
|
||||||
onSuccess(body, { msgType: 'm.emote' });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
shrug: {
|
|
||||||
name: 'shrug',
|
|
||||||
description: 'Send ¯\\_(ツ)_/¯ as message',
|
|
||||||
exe: (roomId, data, onSuccess) => onSuccess(
|
|
||||||
`¯\\_(ツ)_/¯${data.trim() !== '' ? ` ${data}` : ''}`,
|
|
||||||
{ msgType: 'm.text' },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
plain: {
|
|
||||||
name: 'plain',
|
|
||||||
description: 'Send plain text message',
|
|
||||||
exe: (roomId, data, onSuccess) => {
|
|
||||||
const body = data.trim();
|
|
||||||
if (body === '') return;
|
|
||||||
onSuccess(body, { msgType: 'm.text', autoMarkdown: false });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
help: {
|
|
||||||
name: 'help',
|
|
||||||
description: 'View all commands',
|
|
||||||
// eslint-disable-next-line no-use-before-define
|
|
||||||
exe: () => openHelpDialog(),
|
|
||||||
},
|
|
||||||
startdm: {
|
|
||||||
name: 'startdm',
|
|
||||||
description: 'Start direct message with user. Example: /startdm userId1',
|
|
||||||
exe: async (roomId, data) => {
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const rawIds = data.split(' ');
|
|
||||||
const userIds = rawIds.filter((id) => id.match(MXID_REG) && id !== mx.getUserId());
|
|
||||||
if (userIds.length === 0) return;
|
|
||||||
if (userIds.length === 1) {
|
|
||||||
const dmRoomId = hasDMWith(userIds[0]);
|
|
||||||
if (dmRoomId) {
|
|
||||||
selectRoom(dmRoomId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const devices = await Promise.all(userIds.map(hasDevices));
|
|
||||||
const isEncrypt = devices.every((hasDevice) => hasDevice);
|
|
||||||
const result = await roomActions.createDM(userIds, isEncrypt);
|
|
||||||
selectRoom(result.room_id);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
join: {
|
|
||||||
name: 'join',
|
|
||||||
description: 'Join room with address. Example: /join address1 address2',
|
|
||||||
exe: (roomId, data) => {
|
|
||||||
const rawIds = data.split(' ');
|
|
||||||
const roomIds = rawIds.filter((id) => id.match(ROOM_ID_ALIAS_REG));
|
|
||||||
roomIds.map((id) => roomActions.join(id));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
leave: {
|
|
||||||
name: 'leave',
|
|
||||||
description: 'Leave current room.',
|
|
||||||
exe: (roomId, data) => {
|
|
||||||
if (data.trim() === '') {
|
|
||||||
roomActions.leave(roomId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rawIds = data.split(' ');
|
|
||||||
const roomIds = rawIds.filter((id) => id.match(ROOM_ID_REG));
|
|
||||||
roomIds.map((id) => roomActions.leave(id));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
invite: {
|
|
||||||
name: 'invite',
|
|
||||||
description: 'Invite user to room. Example: /invite userId1 userId2 [-r reason]',
|
|
||||||
exe: (roomId, data) => {
|
|
||||||
const { userIds, reason } = processMxidAndReason(data);
|
|
||||||
userIds.map((id) => roomActions.invite(roomId, id, reason));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
disinvite: {
|
|
||||||
name: 'disinvite',
|
|
||||||
description: 'Disinvite user to room. Example: /disinvite userId1 userId2 [-r reason]',
|
|
||||||
exe: (roomId, data) => {
|
|
||||||
const { userIds, reason } = processMxidAndReason(data);
|
|
||||||
userIds.map((id) => roomActions.kick(roomId, id, reason));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
kick: {
|
|
||||||
name: 'kick',
|
|
||||||
description: 'Kick user from room. Example: /kick userId1 userId2 [-r reason]',
|
|
||||||
exe: (roomId, data) => {
|
|
||||||
const { userIds, reason } = processMxidAndReason(data);
|
|
||||||
userIds.map((id) => roomActions.kick(roomId, id, reason));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ban: {
|
|
||||||
name: 'ban',
|
|
||||||
description: 'Ban user from room. Example: /ban userId1 userId2 [-r reason]',
|
|
||||||
exe: (roomId, data) => {
|
|
||||||
const { userIds, reason } = processMxidAndReason(data);
|
|
||||||
userIds.map((id) => roomActions.ban(roomId, id, reason));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
unban: {
|
|
||||||
name: 'unban',
|
|
||||||
description: 'Unban user from room. Example: /unban userId1 userId2',
|
|
||||||
exe: (roomId, data) => {
|
|
||||||
const rawIds = data.split(' ');
|
|
||||||
const userIds = rawIds.filter((id) => id.match(MXID_REG));
|
|
||||||
userIds.map((id) => roomActions.unban(roomId, id));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ignore: {
|
|
||||||
name: 'ignore',
|
|
||||||
description: 'Ignore user. Example: /ignore userId1 userId2',
|
|
||||||
exe: (roomId, data) => {
|
|
||||||
const rawIds = data.split(' ');
|
|
||||||
const userIds = rawIds.filter((id) => id.match(MXID_REG));
|
|
||||||
if (userIds.length > 0) roomActions.ignore(userIds);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
unignore: {
|
|
||||||
name: 'unignore',
|
|
||||||
description: 'Unignore user. Example: /unignore userId1 userId2',
|
|
||||||
exe: (roomId, data) => {
|
|
||||||
const rawIds = data.split(' ');
|
|
||||||
const userIds = rawIds.filter((id) => id.match(MXID_REG));
|
|
||||||
if (userIds.length > 0) roomActions.unignore(userIds);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
myroomnick: {
|
|
||||||
name: 'myroomnick',
|
|
||||||
description: 'Change nick in current room.',
|
|
||||||
exe: (roomId, data) => {
|
|
||||||
const nick = data.trim();
|
|
||||||
if (nick === '') return;
|
|
||||||
roomActions.setMyRoomNick(roomId, nick);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
myroomavatar: {
|
|
||||||
name: 'myroomavatar',
|
|
||||||
description: 'Change profile picture in current room. Example /myroomavatar mxc://xyzabc',
|
|
||||||
exe: (roomId, data) => {
|
|
||||||
if (data.match(MXC_REG)) {
|
|
||||||
roomActions.setMyRoomAvatar(roomId, data);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
converttodm: {
|
|
||||||
name: 'converttodm',
|
|
||||||
description: 'Convert room to direct message',
|
|
||||||
exe: (roomId) => {
|
|
||||||
roomActions.convertToDm(roomId);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
converttoroom: {
|
|
||||||
name: 'converttoroom',
|
|
||||||
description: 'Convert direct message to room',
|
|
||||||
exe: (roomId) => {
|
|
||||||
roomActions.convertToRoom(roomId);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function openHelpDialog() {
|
|
||||||
openReusableDialog(
|
|
||||||
<Text variant="s1" weight="medium">Commands</Text>,
|
|
||||||
() => (
|
|
||||||
<div className="commands-dialog">
|
|
||||||
{Object.keys(commands).map((cmdName) => (
|
|
||||||
<SettingTile
|
|
||||||
key={cmdName}
|
|
||||||
title={cmdName}
|
|
||||||
content={<Text variant="b3">{commands[cmdName].description}</Text>}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default commands;
|
|
|
@ -1,10 +0,0 @@
|
||||||
.commands-dialog {
|
|
||||||
& > * {
|
|
||||||
padding: var(--sp-tight) var(--sp-normal);
|
|
||||||
border-bottom: 1px solid var(--bg-surface-border);
|
|
||||||
&:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
margin-bottom: var(--sp-extra-loose);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,222 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import { getUsername, getUsernameOfRoomMember } from '../../../util/matrixUtil';
|
|
||||||
|
|
||||||
function getTimelineJSXMessages() {
|
|
||||||
return {
|
|
||||||
join(user) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<b>{twemojify(user)}</b>
|
|
||||||
{' joined the room'}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
leave(user, reason) {
|
|
||||||
const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<b>{twemojify(user)}</b>
|
|
||||||
{' left the room'}
|
|
||||||
{twemojify(reasonMsg)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
invite(inviter, user) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<b>{twemojify(inviter)}</b>
|
|
||||||
{' invited '}
|
|
||||||
<b>{twemojify(user)}</b>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cancelInvite(inviter, user) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<b>{twemojify(inviter)}</b>
|
|
||||||
{' canceled '}
|
|
||||||
<b>{twemojify(user)}</b>
|
|
||||||
{'\'s invite'}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
rejectInvite(user) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<b>{twemojify(user)}</b>
|
|
||||||
{' rejected the invitation'}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
kick(actor, user, reason) {
|
|
||||||
const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<b>{twemojify(actor)}</b>
|
|
||||||
{' kicked '}
|
|
||||||
<b>{twemojify(user)}</b>
|
|
||||||
{twemojify(reasonMsg)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
ban(actor, user, reason) {
|
|
||||||
const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<b>{twemojify(actor)}</b>
|
|
||||||
{' banned '}
|
|
||||||
<b>{twemojify(user)}</b>
|
|
||||||
{twemojify(reasonMsg)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
unban(actor, user) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<b>{twemojify(actor)}</b>
|
|
||||||
{' unbanned '}
|
|
||||||
<b>{twemojify(user)}</b>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
avatarSets(user) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<b>{twemojify(user)}</b>
|
|
||||||
{' set a avatar'}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
avatarChanged(user) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<b>{twemojify(user)}</b>
|
|
||||||
{' changed their avatar'}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
avatarRemoved(user) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<b>{twemojify(user)}</b>
|
|
||||||
{' removed their avatar'}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
nameSets(user, newName) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<b>{twemojify(user)}</b>
|
|
||||||
{' set display name to '}
|
|
||||||
<b>{twemojify(newName)}</b>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
nameChanged(user, newName) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<b>{twemojify(user)}</b>
|
|
||||||
{' changed their display name to '}
|
|
||||||
<b>{twemojify(newName)}</b>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
nameRemoved(user, lastName) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<b>{twemojify(user)}</b>
|
|
||||||
{' removed their display name '}
|
|
||||||
<b>{twemojify(lastName)}</b>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUsersActionJsx(roomId, userIds, actionStr) {
|
|
||||||
const room = initMatrix.matrixClient.getRoom(roomId);
|
|
||||||
const getUserDisplayName = (userId) => {
|
|
||||||
if (room?.getMember(userId)) return getUsernameOfRoomMember(room.getMember(userId));
|
|
||||||
return getUsername(userId);
|
|
||||||
};
|
|
||||||
const getUserJSX = (userId) => <b>{twemojify(getUserDisplayName(userId))}</b>;
|
|
||||||
if (!Array.isArray(userIds)) return 'Idle';
|
|
||||||
if (userIds.length === 0) return 'Idle';
|
|
||||||
const MAX_VISIBLE_COUNT = 3;
|
|
||||||
|
|
||||||
const u1Jsx = getUserJSX(userIds[0]);
|
|
||||||
// eslint-disable-next-line react/jsx-one-expression-per-line
|
|
||||||
if (userIds.length === 1) return <>{u1Jsx} is {actionStr}</>;
|
|
||||||
|
|
||||||
const u2Jsx = getUserJSX(userIds[1]);
|
|
||||||
// eslint-disable-next-line react/jsx-one-expression-per-line
|
|
||||||
if (userIds.length === 2) return <>{u1Jsx} and {u2Jsx} are {actionStr}</>;
|
|
||||||
|
|
||||||
const u3Jsx = getUserJSX(userIds[2]);
|
|
||||||
if (userIds.length === 3) {
|
|
||||||
// eslint-disable-next-line react/jsx-one-expression-per-line
|
|
||||||
return <>{u1Jsx}, {u2Jsx} and {u3Jsx} are {actionStr}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const othersCount = userIds.length - MAX_VISIBLE_COUNT;
|
|
||||||
// eslint-disable-next-line react/jsx-one-expression-per-line
|
|
||||||
return <>{u1Jsx}, {u2Jsx}, {u3Jsx} and {othersCount} others are {actionStr}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTimelineChange(mEvent) {
|
|
||||||
const tJSXMsgs = getTimelineJSXMessages();
|
|
||||||
const makeReturnObj = (variant, content) => ({
|
|
||||||
variant,
|
|
||||||
content,
|
|
||||||
});
|
|
||||||
const content = mEvent.getContent();
|
|
||||||
const prevContent = mEvent.getPrevContent();
|
|
||||||
const sender = mEvent.getSender();
|
|
||||||
const senderName = getUsername(sender);
|
|
||||||
const userName = getUsername(mEvent.getStateKey());
|
|
||||||
|
|
||||||
switch (content.membership) {
|
|
||||||
case 'invite': return makeReturnObj('invite', tJSXMsgs.invite(senderName, userName));
|
|
||||||
case 'ban': return makeReturnObj('leave', tJSXMsgs.ban(senderName, userName, content.reason));
|
|
||||||
case 'join':
|
|
||||||
if (prevContent.membership === 'join') {
|
|
||||||
if (content.displayname !== prevContent.displayname) {
|
|
||||||
if (typeof content.displayname === 'undefined') return makeReturnObj('avatar', tJSXMsgs.nameRemoved(sender, prevContent.displayname));
|
|
||||||
if (typeof prevContent.displayname === 'undefined') return makeReturnObj('avatar', tJSXMsgs.nameSets(sender, content.displayname));
|
|
||||||
return makeReturnObj('avatar', tJSXMsgs.nameChanged(prevContent.displayname, content.displayname));
|
|
||||||
}
|
|
||||||
if (content.avatar_url !== prevContent.avatar_url) {
|
|
||||||
if (typeof content.avatar_url === 'undefined') return makeReturnObj('avatar', tJSXMsgs.avatarRemoved(content.displayname));
|
|
||||||
if (typeof prevContent.avatar_url === 'undefined') return makeReturnObj('avatar', tJSXMsgs.avatarSets(content.displayname));
|
|
||||||
return makeReturnObj('avatar', tJSXMsgs.avatarChanged(content.displayname));
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return makeReturnObj('join', tJSXMsgs.join(senderName));
|
|
||||||
case 'leave':
|
|
||||||
if (sender === mEvent.getStateKey()) {
|
|
||||||
switch (prevContent.membership) {
|
|
||||||
case 'invite': return makeReturnObj('invite-cancel', tJSXMsgs.rejectInvite(senderName));
|
|
||||||
default: return makeReturnObj('leave', tJSXMsgs.leave(senderName, content.reason));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch (prevContent.membership) {
|
|
||||||
case 'invite': return makeReturnObj('invite-cancel', tJSXMsgs.cancelInvite(senderName, userName));
|
|
||||||
case 'ban': return makeReturnObj('other', tJSXMsgs.unban(senderName, userName));
|
|
||||||
// sender is not target and made the target leave,
|
|
||||||
// if not from invite/ban then this is a kick
|
|
||||||
default: return makeReturnObj('leave', tJSXMsgs.kick(senderName, userName, content.reason));
|
|
||||||
}
|
|
||||||
default: return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
getTimelineJSXMessages,
|
|
||||||
getUsersActionJsx,
|
|
||||||
parseTimelineChange,
|
|
||||||
};
|
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
import './Search.scss';
|
import './Search.scss';
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
|
@ -19,6 +20,11 @@ import RoomSelector from '../../molecules/room-selector/RoomSelector';
|
||||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
|
import { useDirects, useRooms, useSpaces } from '../../state/hooks/roomList';
|
||||||
|
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||||
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
|
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||||
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
|
|
||||||
function useVisiblityToggle(setResult) {
|
function useVisiblityToggle(setResult) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
@ -48,9 +54,8 @@ function useVisiblityToggle(setResult) {
|
||||||
return [isOpen, requestClose];
|
return [isOpen, requestClose];
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapRoomIds(roomIds) {
|
function mapRoomIds(roomIds, directs, roomIdToParents) {
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
const { directs, roomIdToParents } = initMatrix.roomList;
|
|
||||||
|
|
||||||
return roomIds.map((roomId) => {
|
return roomIds.map((roomId) => {
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
|
@ -62,7 +67,7 @@ function mapRoomIds(roomIds) {
|
||||||
|
|
||||||
let type = 'room';
|
let type = 'room';
|
||||||
if (room.isSpaceRoom()) type = 'space';
|
if (room.isSpaceRoom()) type = 'space';
|
||||||
else if (directs.has(roomId)) type = 'direct';
|
else if (directs.includes(roomId)) type = 'direct';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type,
|
type,
|
||||||
|
@ -81,6 +86,12 @@ function Search() {
|
||||||
const searchRef = useRef(null);
|
const searchRef = useRef(null);
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||||
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
const spaces = useSpaces(mx, allRoomsAtom);
|
||||||
|
const rooms = useRooms(mx, allRoomsAtom, mDirects);
|
||||||
|
const directs = useDirects(mx, allRoomsAtom, mDirects);
|
||||||
|
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||||
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
|
|
||||||
const handleSearchResults = (chunk, term) => {
|
const handleSearchResults = (chunk, term) => {
|
||||||
setResult({
|
setResult({
|
||||||
|
@ -97,7 +108,6 @@ function Search() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { spaces, rooms, directs } = initMatrix.roomList;
|
|
||||||
let ids = null;
|
let ids = null;
|
||||||
|
|
||||||
if (prefix) {
|
if (prefix) {
|
||||||
|
@ -109,15 +119,15 @@ function Search() {
|
||||||
}
|
}
|
||||||
|
|
||||||
ids.sort(roomIdByActivity);
|
ids.sort(roomIdByActivity);
|
||||||
const mappedIds = mapRoomIds(ids);
|
const mappedIds = mapRoomIds(ids, directs, roomToParents);
|
||||||
asyncSearch.setup(mappedIds, { keys: 'name', isContain: true, limit: 20 });
|
asyncSearch.setup(mappedIds, { keys: 'name', isContain: true, limit: 20 });
|
||||||
if (prefix) handleSearchResults(mappedIds, prefix);
|
if (prefix) handleSearchResults(mappedIds, prefix);
|
||||||
else asyncSearch.search(term);
|
else asyncSearch.search(term);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadRecentRooms = () => {
|
const loadRecentRooms = () => {
|
||||||
const { recentRooms } = navigation;
|
const recentRooms = [];
|
||||||
handleSearchResults(mapRoomIds(recentRooms).reverse());
|
handleSearchResults(mapRoomIds(recentRooms, directs, roomToParents).reverse());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAfterOpen = () => {
|
const handleAfterOpen = () => {
|
||||||
|
@ -169,7 +179,6 @@ function Search() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const noti = initMatrix.notifications;
|
|
||||||
const renderRoomSelector = (item) => {
|
const renderRoomSelector = (item) => {
|
||||||
let imageSrc = null;
|
let imageSrc = null;
|
||||||
let iconSrc = null;
|
let iconSrc = null;
|
||||||
|
@ -188,9 +197,9 @@ function Search() {
|
||||||
roomId={item.roomId}
|
roomId={item.roomId}
|
||||||
imageSrc={imageSrc}
|
imageSrc={imageSrc}
|
||||||
iconSrc={iconSrc}
|
iconSrc={iconSrc}
|
||||||
isUnread={noti.hasNoti(item.roomId)}
|
isUnread={roomToUnread.has(item.roomId)}
|
||||||
notificationCount={noti.getTotalNoti(item.roomId)}
|
notificationCount={roomToUnread.get(item.roomId)?.total ?? 0}
|
||||||
isAlert={noti.getHighlightNoti(item.roomId) > 0}
|
isAlert={roomToUnread.get(item.roomId)?.highlight > 0}
|
||||||
onClick={() => openItem(item.roomId, item.type)}
|
onClick={() => openItem(item.roomId, item.type)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -3,7 +3,6 @@ import React, { useState } from 'react';
|
||||||
import './CrossSigning.scss';
|
import './CrossSigning.scss';
|
||||||
import FileSaver from 'file-saver';
|
import FileSaver from 'file-saver';
|
||||||
import { Formik } from 'formik';
|
import { Formik } from 'formik';
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import { openReusableDialog } from '../../../client/action/navigation';
|
import { openReusableDialog } from '../../../client/action/navigation';
|
||||||
|
@ -22,15 +21,17 @@ import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
|
||||||
const failedDialog = () => {
|
const failedDialog = () => {
|
||||||
const renderFailure = (requestClose) => (
|
const renderFailure = (requestClose) => (
|
||||||
<div className="cross-signing__failure">
|
<div className="cross-signing__failure">
|
||||||
<Text variant="h1">{twemojify('❌')}</Text>
|
<Text variant="h1">❌</Text>
|
||||||
<Text weight="medium">Failed to setup cross signing. Please try again.</Text>
|
<Text weight="medium">Failed to setup cross signing. Please try again.</Text>
|
||||||
<Button onClick={requestClose}>Close</Button>
|
<Button onClick={requestClose}>Close</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
openReusableDialog(
|
openReusableDialog(
|
||||||
<Text variant="s1" weight="medium">Setup cross signing</Text>,
|
<Text variant="s1" weight="medium">
|
||||||
renderFailure,
|
Setup cross signing
|
||||||
|
</Text>,
|
||||||
|
renderFailure
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -48,11 +49,11 @@ const securityKeyDialog = (key) => {
|
||||||
const renderSecurityKey = () => (
|
const renderSecurityKey = () => (
|
||||||
<div className="cross-signing__key">
|
<div className="cross-signing__key">
|
||||||
<Text weight="medium">Please save this security key somewhere safe.</Text>
|
<Text weight="medium">Please save this security key somewhere safe.</Text>
|
||||||
<Text className="cross-signing__key-text">
|
<Text className="cross-signing__key-text">{key.encodedPrivateKey}</Text>
|
||||||
{key.encodedPrivateKey}
|
|
||||||
</Text>
|
|
||||||
<div className="cross-signing__key-btn">
|
<div className="cross-signing__key-btn">
|
||||||
<Button variant="primary" onClick={() => copyKey(key)}>Copy</Button>
|
<Button variant="primary" onClick={() => copyKey(key)}>
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
<Button onClick={() => downloadKey(key)}>Download</Button>
|
<Button onClick={() => downloadKey(key)}>Download</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -62,8 +63,10 @@ const securityKeyDialog = (key) => {
|
||||||
downloadKey();
|
downloadKey();
|
||||||
|
|
||||||
openReusableDialog(
|
openReusableDialog(
|
||||||
<Text variant="s1" weight="medium">Security Key</Text>,
|
<Text variant="s1" weight="medium">
|
||||||
() => renderSecurityKey(),
|
Security Key
|
||||||
|
</Text>,
|
||||||
|
() => renderSecurityKey()
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -112,7 +115,7 @@ function CrossSigningSetup() {
|
||||||
errors.phrase = 'Phrase must contain 8-127 characters with no space.';
|
errors.phrase = 'Phrase must contain 8-127 characters with no space.';
|
||||||
}
|
}
|
||||||
if (values.confirmPhrase.length > 0 && values.confirmPhrase !== values.phrase) {
|
if (values.confirmPhrase.length > 0 && values.confirmPhrase !== values.phrase) {
|
||||||
errors.confirmPhrase = 'Phrase don\'t match.';
|
errors.confirmPhrase = "Phrase don't match.";
|
||||||
}
|
}
|
||||||
return errors;
|
return errors;
|
||||||
};
|
};
|
||||||
|
@ -121,10 +124,14 @@ function CrossSigningSetup() {
|
||||||
<div className="cross-signing__setup">
|
<div className="cross-signing__setup">
|
||||||
<div className="cross-signing__setup-entry">
|
<div className="cross-signing__setup-entry">
|
||||||
<Text>
|
<Text>
|
||||||
We will generate a <b>Security Key</b>,
|
We will generate a <b>Security Key</b>, which you can use to manage messages backup and
|
||||||
which you can use to manage messages backup and session verification.
|
session verification.
|
||||||
</Text>
|
</Text>
|
||||||
{genWithPhrase !== false && <Button variant="primary" onClick={() => setup()} disabled={genWithPhrase !== undefined}>Generate Key</Button>}
|
{genWithPhrase !== false && (
|
||||||
|
<Button variant="primary" onClick={() => setup()} disabled={genWithPhrase !== undefined}>
|
||||||
|
Generate Key
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{genWithPhrase === false && <Spinner size="small" />}
|
{genWithPhrase === false && <Spinner size="small" />}
|
||||||
</div>
|
</div>
|
||||||
<Text className="cross-signing__setup-divider">OR</Text>
|
<Text className="cross-signing__setup-divider">OR</Text>
|
||||||
|
@ -133,9 +140,7 @@ function CrossSigningSetup() {
|
||||||
onSubmit={(values) => setup(values.phrase)}
|
onSubmit={(values) => setup(values.phrase)}
|
||||||
validate={validator}
|
validate={validator}
|
||||||
>
|
>
|
||||||
{({
|
{({ values, errors, handleChange, handleSubmit }) => (
|
||||||
values, errors, handleChange, handleSubmit,
|
|
||||||
}) => (
|
|
||||||
<form
|
<form
|
||||||
className="cross-signing__setup-entry"
|
className="cross-signing__setup-entry"
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
@ -143,8 +148,8 @@ function CrossSigningSetup() {
|
||||||
>
|
>
|
||||||
<Text>
|
<Text>
|
||||||
Alternatively you can also set a <b>Security Phrase </b>
|
Alternatively you can also set a <b>Security Phrase </b>
|
||||||
so you don't have to remember long Security Key,
|
so you don't have to remember long Security Key, and optionally save the Key as
|
||||||
and optionally save the Key as backup.
|
backup.
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
name="phrase"
|
name="phrase"
|
||||||
|
@ -155,7 +160,11 @@ function CrossSigningSetup() {
|
||||||
required
|
required
|
||||||
disabled={genWithPhrase !== undefined}
|
disabled={genWithPhrase !== undefined}
|
||||||
/>
|
/>
|
||||||
{errors.phrase && <Text variant="b3" className="cross-signing__error">{errors.phrase}</Text>}
|
{errors.phrase && (
|
||||||
|
<Text variant="b3" className="cross-signing__error">
|
||||||
|
{errors.phrase}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Input
|
<Input
|
||||||
name="confirmPhrase"
|
name="confirmPhrase"
|
||||||
value={values.confirmPhrase}
|
value={values.confirmPhrase}
|
||||||
|
@ -165,8 +174,16 @@ function CrossSigningSetup() {
|
||||||
required
|
required
|
||||||
disabled={genWithPhrase !== undefined}
|
disabled={genWithPhrase !== undefined}
|
||||||
/>
|
/>
|
||||||
{errors.confirmPhrase && <Text variant="b3" className="cross-signing__error">{errors.confirmPhrase}</Text>}
|
{errors.confirmPhrase && (
|
||||||
{genWithPhrase !== true && <Button variant="primary" type="submit" disabled={genWithPhrase !== undefined}>Set Phrase & Generate Key</Button>}
|
<Text variant="b3" className="cross-signing__error">
|
||||||
|
{errors.confirmPhrase}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{genWithPhrase !== true && (
|
||||||
|
<Button variant="primary" type="submit" disabled={genWithPhrase !== undefined}>
|
||||||
|
Set Phrase & Generate Key
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{genWithPhrase === true && <Spinner size="small" />}
|
{genWithPhrase === true && <Spinner size="small" />}
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
@ -177,31 +194,36 @@ function CrossSigningSetup() {
|
||||||
|
|
||||||
const setupDialog = () => {
|
const setupDialog = () => {
|
||||||
openReusableDialog(
|
openReusableDialog(
|
||||||
<Text variant="s1" weight="medium">Setup cross signing</Text>,
|
<Text variant="s1" weight="medium">
|
||||||
() => <CrossSigningSetup />,
|
Setup cross signing
|
||||||
|
</Text>,
|
||||||
|
() => <CrossSigningSetup />
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function CrossSigningReset() {
|
function CrossSigningReset() {
|
||||||
return (
|
return (
|
||||||
<div className="cross-signing__reset">
|
<div className="cross-signing__reset">
|
||||||
<Text variant="h1">{twemojify('✋🧑🚒🤚')}</Text>
|
<Text variant="h1">✋🧑🚒🤚</Text>
|
||||||
<Text weight="medium">Resetting cross-signing keys is permanent.</Text>
|
<Text weight="medium">Resetting cross-signing keys is permanent.</Text>
|
||||||
<Text>
|
<Text>
|
||||||
Anyone you have verified with will see security alerts and your message backup will be lost.
|
Anyone you have verified with will see security alerts and your message backup will be lost.
|
||||||
You almost certainly do not want to do this,
|
You almost certainly do not want to do this, unless you have lost <b>Security Key</b> or{' '}
|
||||||
unless you have lost <b>Security Key</b> or <b>Phrase</b> and
|
<b>Phrase</b> and every session you can cross-sign from.
|
||||||
every session you can cross-sign from.
|
|
||||||
</Text>
|
</Text>
|
||||||
<Button variant="danger" onClick={setupDialog}>Reset</Button>
|
<Button variant="danger" onClick={setupDialog}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetDialog = () => {
|
const resetDialog = () => {
|
||||||
openReusableDialog(
|
openReusableDialog(
|
||||||
<Text variant="s1" weight="medium">Reset cross signing</Text>,
|
<Text variant="s1" weight="medium">
|
||||||
() => <CrossSigningReset />,
|
Reset cross signing
|
||||||
|
</Text>,
|
||||||
|
() => <CrossSigningReset />
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -210,12 +232,23 @@ function CrossSignin() {
|
||||||
return (
|
return (
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Cross signing"
|
title="Cross signing"
|
||||||
content={<Text variant="b3">Setup to verify and keep track of all your sessions. Also required to backup encrypted message.</Text>}
|
content={
|
||||||
options={(
|
<Text variant="b3">
|
||||||
isCSEnabled
|
Setup to verify and keep track of all your sessions. Also required to backup encrypted
|
||||||
? <Button variant="danger" onClick={resetDialog}>Reset</Button>
|
message.
|
||||||
: <Button variant="primary" onClick={setupDialog}>Setup</Button>
|
</Text>
|
||||||
)}
|
}
|
||||||
|
options={
|
||||||
|
isCSEnabled ? (
|
||||||
|
<Button variant="danger" onClick={resetDialog}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="primary" onClick={setupDialog}>
|
||||||
|
Setup
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,6 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import './KeyBackup.scss';
|
import './KeyBackup.scss';
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import { openReusableDialog } from '../../../client/action/navigation';
|
import { openReusableDialog } from '../../../client/action/navigation';
|
||||||
|
@ -34,10 +33,7 @@ function CreateKeyBackupDialog({ keyData }) {
|
||||||
let info;
|
let info;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
info = await mx.prepareKeyBackupVersion(
|
info = await mx.prepareKeyBackupVersion(null, { secureSecretStorage: true });
|
||||||
null,
|
|
||||||
{ secureSecretStorage: true },
|
|
||||||
);
|
|
||||||
info = await mx.createKeyBackupVersion(info);
|
info = await mx.createKeyBackupVersion(info);
|
||||||
await mx.scheduleAllGroupSessionsForBackup();
|
await mx.scheduleAllGroupSessionsForBackup();
|
||||||
if (!mountStore.getItem()) return;
|
if (!mountStore.getItem()) return;
|
||||||
|
@ -65,7 +61,7 @@ function CreateKeyBackupDialog({ keyData }) {
|
||||||
)}
|
)}
|
||||||
{done === true && (
|
{done === true && (
|
||||||
<>
|
<>
|
||||||
<Text variant="h1">{twemojify('✅')}</Text>
|
<Text variant="h1">✅</Text>
|
||||||
<Text>Successfully created backup</Text>
|
<Text>Successfully created backup</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -104,12 +100,9 @@ function RestoreKeyBackupDialog({ keyData }) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const backupInfo = await mx.getKeyBackupVersion();
|
const backupInfo = await mx.getKeyBackupVersion();
|
||||||
const info = await mx.restoreKeyBackupWithSecretStorage(
|
const info = await mx.restoreKeyBackupWithSecretStorage(backupInfo, undefined, undefined, {
|
||||||
backupInfo,
|
progressCallback,
|
||||||
undefined,
|
});
|
||||||
undefined,
|
|
||||||
{ progressCallback },
|
|
||||||
);
|
|
||||||
if (!mountStore.getItem()) return;
|
if (!mountStore.getItem()) return;
|
||||||
setStatus({ done: `Successfully restored backup keys (${info.imported}/${info.total}).` });
|
setStatus({ done: `Successfully restored backup keys (${info.imported}/${info.total}).` });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -138,7 +131,7 @@ function RestoreKeyBackupDialog({ keyData }) {
|
||||||
)}
|
)}
|
||||||
{status.done && (
|
{status.done && (
|
||||||
<>
|
<>
|
||||||
<Text variant="h1">{twemojify('✅')}</Text>
|
<Text variant="h1">✅</Text>
|
||||||
<Text>{status.done}</Text>
|
<Text>{status.done}</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -176,14 +169,16 @@ function DeleteKeyBackupDialog({ requestClose }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="key-backup__delete">
|
<div className="key-backup__delete">
|
||||||
<Text variant="h1">{twemojify('🗑')}</Text>
|
<Text variant="h1">🗑</Text>
|
||||||
<Text weight="medium">Deleting key backup is permanent.</Text>
|
<Text weight="medium">Deleting key backup is permanent.</Text>
|
||||||
<Text>All encrypted messages keys stored on server will be deleted.</Text>
|
<Text>All encrypted messages keys stored on server will be deleted.</Text>
|
||||||
{
|
{isDeleting ? (
|
||||||
isDeleting
|
<Spinner size="small" />
|
||||||
? <Spinner size="small" />
|
) : (
|
||||||
: <Button variant="danger" onClick={deleteBackup}>Delete</Button>
|
<Button variant="danger" onClick={deleteBackup}>
|
||||||
}
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -224,9 +219,11 @@ function KeyBackup() {
|
||||||
if (keyData === null) return;
|
if (keyData === null) return;
|
||||||
|
|
||||||
openReusableDialog(
|
openReusableDialog(
|
||||||
<Text variant="s1" weight="medium">Create Key Backup</Text>,
|
<Text variant="s1" weight="medium">
|
||||||
|
Create Key Backup
|
||||||
|
</Text>,
|
||||||
() => <CreateKeyBackupDialog keyData={keyData} />,
|
() => <CreateKeyBackupDialog keyData={keyData} />,
|
||||||
() => fetchKeyBackupVersion(),
|
() => fetchKeyBackupVersion()
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -235,29 +232,44 @@ function KeyBackup() {
|
||||||
if (keyData === null) return;
|
if (keyData === null) return;
|
||||||
|
|
||||||
openReusableDialog(
|
openReusableDialog(
|
||||||
<Text variant="s1" weight="medium">Restore Key Backup</Text>,
|
<Text variant="s1" weight="medium">
|
||||||
() => <RestoreKeyBackupDialog keyData={keyData} />,
|
Restore Key Backup
|
||||||
|
</Text>,
|
||||||
|
() => <RestoreKeyBackupDialog keyData={keyData} />
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDeleteKeyBackup = () => openReusableDialog(
|
const openDeleteKeyBackup = () =>
|
||||||
<Text variant="s1" weight="medium">Delete Key Backup</Text>,
|
openReusableDialog(
|
||||||
(requestClose) => (
|
<Text variant="s1" weight="medium">
|
||||||
<DeleteKeyBackupDialog
|
Delete Key Backup
|
||||||
requestClose={(isDone) => {
|
</Text>,
|
||||||
if (isDone) setKeyBackup(null);
|
(requestClose) => (
|
||||||
requestClose();
|
<DeleteKeyBackupDialog
|
||||||
}}
|
requestClose={(isDone) => {
|
||||||
/>
|
if (isDone) setKeyBackup(null);
|
||||||
),
|
requestClose();
|
||||||
);
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const renderOptions = () => {
|
const renderOptions = () => {
|
||||||
if (keyBackup === undefined) return <Spinner size="small" />;
|
if (keyBackup === undefined) return <Spinner size="small" />;
|
||||||
if (keyBackup === null) return <Button variant="primary" onClick={openCreateKeyBackup}>Create Backup</Button>;
|
if (keyBackup === null)
|
||||||
|
return (
|
||||||
|
<Button variant="primary" onClick={openCreateKeyBackup}>
|
||||||
|
Create Backup
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<IconButton src={DownloadIC} variant="positive" onClick={openRestoreKeyBackup} tooltip="Restore backup" />
|
<IconButton
|
||||||
|
src={DownloadIC}
|
||||||
|
variant="positive"
|
||||||
|
onClick={openRestoreKeyBackup}
|
||||||
|
tooltip="Restore backup"
|
||||||
|
/>
|
||||||
<IconButton src={BinIC} onClick={openDeleteKeyBackup} tooltip="Delete backup" />
|
<IconButton src={BinIC} onClick={openDeleteKeyBackup} tooltip="Delete backup" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -266,9 +278,12 @@ function KeyBackup() {
|
||||||
return (
|
return (
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Encrypted messages backup"
|
title="Encrypted messages backup"
|
||||||
content={(
|
content={
|
||||||
<>
|
<>
|
||||||
<Text variant="b3">Online backup your encrypted messages keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Security Key.</Text>
|
<Text variant="b3">
|
||||||
|
Online backup your encrypted messages keys with your account data in case you lose
|
||||||
|
access to your sessions. Your keys will be secured with a unique Security Key.
|
||||||
|
</Text>
|
||||||
{!isCSEnabled && (
|
{!isCSEnabled && (
|
||||||
<InfoCard
|
<InfoCard
|
||||||
style={{ marginTop: 'var(--sp-ultra-tight)' }}
|
style={{ marginTop: 'var(--sp-ultra-tight)' }}
|
||||||
|
@ -279,7 +294,7 @@ function KeyBackup() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
}
|
||||||
options={isCSEnabled ? renderOptions() : null}
|
options={isCSEnabled ? renderOptions() : null}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,169 +0,0 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import './ShortcutSpaces.scss';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import navigation from '../../../client/state/navigation';
|
|
||||||
import { createSpaceShortcut, deleteSpaceShortcut } from '../../../client/action/accountData';
|
|
||||||
import { joinRuleToIconSrc } from '../../../util/matrixUtil';
|
|
||||||
import { roomIdByAtoZ } from '../../../util/sort';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import Button from '../../atoms/button/Button';
|
|
||||||
import IconButton from '../../atoms/button/IconButton';
|
|
||||||
import Checkbox from '../../atoms/button/Checkbox';
|
|
||||||
import Spinner from '../../atoms/spinner/Spinner';
|
|
||||||
import RoomSelector from '../../molecules/room-selector/RoomSelector';
|
|
||||||
import Dialog from '../../molecules/dialog/Dialog';
|
|
||||||
|
|
||||||
import PinIC from '../../../../public/res/ic/outlined/pin.svg';
|
|
||||||
import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
|
|
||||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
|
||||||
|
|
||||||
import { useSpaceShortcut } from '../../hooks/useSpaceShortcut';
|
|
||||||
|
|
||||||
function ShortcutSpacesContent() {
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const { spaces, roomIdToParents } = initMatrix.roomList;
|
|
||||||
|
|
||||||
const [spaceShortcut] = useSpaceShortcut();
|
|
||||||
const spaceWithoutShortcut = [...spaces].filter(
|
|
||||||
(spaceId) => !spaceShortcut.includes(spaceId),
|
|
||||||
).sort(roomIdByAtoZ);
|
|
||||||
|
|
||||||
const [process, setProcess] = useState(null);
|
|
||||||
const [selected, setSelected] = useState([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (process !== null) {
|
|
||||||
setProcess(null);
|
|
||||||
setSelected([]);
|
|
||||||
}
|
|
||||||
}, [spaceShortcut]);
|
|
||||||
|
|
||||||
const toggleSelection = (sId) => {
|
|
||||||
if (process !== null) return;
|
|
||||||
const newSelected = [...selected];
|
|
||||||
const selectedIndex = newSelected.indexOf(sId);
|
|
||||||
|
|
||||||
if (selectedIndex > -1) {
|
|
||||||
newSelected.splice(selectedIndex, 1);
|
|
||||||
setSelected(newSelected);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
newSelected.push(sId);
|
|
||||||
setSelected(newSelected);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAdd = () => {
|
|
||||||
setProcess(`Pinning ${selected.length} spaces...`);
|
|
||||||
createSpaceShortcut(selected);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderSpace = (spaceId, isShortcut) => {
|
|
||||||
const room = mx.getRoom(spaceId);
|
|
||||||
if (!room) return null;
|
|
||||||
|
|
||||||
const parentSet = roomIdToParents.get(spaceId);
|
|
||||||
const parentNames = parentSet
|
|
||||||
? [...parentSet].map((parentId) => mx.getRoom(parentId).name)
|
|
||||||
: undefined;
|
|
||||||
const parents = parentNames ? parentNames.join(', ') : null;
|
|
||||||
|
|
||||||
const toggleSelected = () => toggleSelection(spaceId);
|
|
||||||
const deleteShortcut = () => deleteSpaceShortcut(spaceId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RoomSelector
|
|
||||||
key={spaceId}
|
|
||||||
name={room.name}
|
|
||||||
parentName={parents}
|
|
||||||
roomId={spaceId}
|
|
||||||
imageSrc={null}
|
|
||||||
iconSrc={joinRuleToIconSrc(room.getJoinRule(), true)}
|
|
||||||
isUnread={false}
|
|
||||||
notificationCount={0}
|
|
||||||
isAlert={false}
|
|
||||||
onClick={isShortcut ? deleteShortcut : toggleSelected}
|
|
||||||
options={isShortcut ? (
|
|
||||||
<IconButton
|
|
||||||
src={isShortcut ? PinFilledIC : PinIC}
|
|
||||||
size="small"
|
|
||||||
onClick={deleteShortcut}
|
|
||||||
disabled={process !== null}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Checkbox
|
|
||||||
isActive={selected.includes(spaceId)}
|
|
||||||
variant="positive"
|
|
||||||
onToggle={toggleSelected}
|
|
||||||
tabIndex={-1}
|
|
||||||
disabled={process !== null}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Text className="shortcut-spaces__header" variant="b3" weight="bold">Pinned spaces</Text>
|
|
||||||
{spaceShortcut.length === 0 && <Text>No pinned spaces</Text>}
|
|
||||||
{spaceShortcut.map((spaceId) => renderSpace(spaceId, true))}
|
|
||||||
<Text className="shortcut-spaces__header" variant="b3" weight="bold">Unpinned spaces</Text>
|
|
||||||
{spaceWithoutShortcut.length === 0 && <Text>No unpinned spaces</Text>}
|
|
||||||
{spaceWithoutShortcut.map((spaceId) => renderSpace(spaceId, false))}
|
|
||||||
{selected.length !== 0 && (
|
|
||||||
<div className="shortcut-spaces__footer">
|
|
||||||
{process && <Spinner size="small" />}
|
|
||||||
<Text weight="medium">{process || `${selected.length} spaces selected`}</Text>
|
|
||||||
{ !process && (
|
|
||||||
<Button onClick={handleAdd} variant="primary">Pin</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useVisibilityToggle() {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleOpen = () => setIsOpen(true);
|
|
||||||
navigation.on(cons.events.navigation.SHORTCUT_SPACES_OPENED, handleOpen);
|
|
||||||
return () => {
|
|
||||||
navigation.removeListener(cons.events.navigation.SHORTCUT_SPACES_OPENED, handleOpen);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const requestClose = () => setIsOpen(false);
|
|
||||||
|
|
||||||
return [isOpen, requestClose];
|
|
||||||
}
|
|
||||||
|
|
||||||
function ShortcutSpaces() {
|
|
||||||
const [isOpen, requestClose] = useVisibilityToggle();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
isOpen={isOpen}
|
|
||||||
className="shortcut-spaces"
|
|
||||||
title={(
|
|
||||||
<Text variant="s1" weight="medium" primary>
|
|
||||||
Pin spaces
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
|
|
||||||
onRequestClose={requestClose}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
isOpen
|
|
||||||
? <ShortcutSpacesContent />
|
|
||||||
: <div />
|
|
||||||
}
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ShortcutSpaces;
|
|
|
@ -1,52 +0,0 @@
|
||||||
@use '../../partials/dir';
|
|
||||||
@use '../../partials/flex';
|
|
||||||
|
|
||||||
.shortcut-spaces {
|
|
||||||
height: 100%;
|
|
||||||
.dialog__content-container {
|
|
||||||
padding: 0;
|
|
||||||
padding-bottom: 80px;
|
|
||||||
@include dir.side(padding, var(--sp-extra-tight), 0);
|
|
||||||
|
|
||||||
& > .text-b1 {
|
|
||||||
padding: 0 var(--sp-extra-tight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__header {
|
|
||||||
margin-top: var(--sp-extra-tight);
|
|
||||||
padding: var(--sp-extra-tight);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-selector {
|
|
||||||
margin: 0 var(--sp-extra-tight);
|
|
||||||
}
|
|
||||||
.room-selector__options {
|
|
||||||
display: flex;
|
|
||||||
.checkbox {
|
|
||||||
margin: 0 6px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__footer {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--sp-normal);
|
|
||||||
background-color: var(--bg-surface);
|
|
||||||
border-top: 1px solid var(--bg-surface-border);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
& > .text {
|
|
||||||
@extend .cp-fx__item-one;
|
|
||||||
padding: 0 var(--sp-tight);
|
|
||||||
}
|
|
||||||
|
|
||||||
& > button {
|
|
||||||
@include dir.side(margin, var(--sp-normal), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue