(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:
Ajay Bura 2024-07-08 16:57:10 +05:30 committed by GitHub
parent 60e022035f
commit 4f09e6bbb5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
147 changed files with 1164 additions and 15330 deletions

View file

@ -90,12 +90,6 @@
window.global ||= window;
</script>
<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>
</body>
</html>

188
package-lock.json generated
View file

@ -13,7 +13,6 @@
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@fontsource/inter": "4.5.14",
"@khanacademy/simple-markdown": "0.8.6",
"@matrix-org/olm": "3.2.14",
"@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1",
@ -41,8 +40,6 @@
"immer": "9.0.16",
"is-hotkey": "0.2.0",
"jotai": "2.6.0",
"katex": "0.16.10",
"linkify-html": "4.0.2",
"linkify-react": "4.1.1",
"linkifyjs": "4.0.2",
"matrix-js-sdk": "29.1.0",
@ -54,8 +51,6 @@
"react-aria": "3.29.1",
"react-autosize-textarea": "7.1.0",
"react-blurhash": "0.2.0",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-dom": "18.2.0",
"react-error-boundary": "4.0.10",
"react-google-recaptcha": "2.1.0",
@ -67,7 +62,6 @@
"slate-history": "0.93.0",
"slate-react": "0.98.4",
"tippy.js": "6.3.7",
"twemoji": "14.0.2",
"ua-parser-js": "1.0.35"
},
"devDependencies": {
@ -1109,18 +1103,6 @@
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
"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": {
"version": "1.0.11",
"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"
}
},
"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": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@react-stately/calendar/-/calendar-3.4.1.tgz",
@ -3307,12 +3274,14 @@
"node_modules/@types/prop-types": {
"version": "15.7.5",
"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": {
"version": "18.2.39",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.39.tgz",
"integrity": "sha512-Oiw+ppED6IremMInLV4HXGbfbG6GyziY3kqAwJYOR0PNbkYDmLWQA3a95EhdSmamsvbkJN96ZNN+YD+fGjzSBA==",
"dev": true,
"dependencies": {
"@types/prop-types": "*",
"@types/scheduler": "*",
@ -3354,7 +3323,8 @@
"node_modules/@types/scheduler": {
"version": "0.16.2",
"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": {
"version": "7.3.13",
@ -4419,14 +4389,6 @@
"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": {
"version": "1.0.20",
"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"
}
},
"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": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
@ -5559,7 +5511,8 @@
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"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": {
"version": "3.2.12",
@ -5796,27 +5749,6 @@
"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": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
@ -6060,7 +5992,8 @@
"node_modules/graceful-fs": {
"version": "4.2.10",
"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": {
"version": "1.0.4",
@ -6749,17 +6682,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": {
"version": "3.3.3",
"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",
"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": {
"version": "0.3.22",
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz",
@ -6854,14 +6761,6 @@
"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": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/linkify-react/-/linkify-react-4.1.1.tgz",
@ -7766,43 +7665,6 @@
"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": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
@ -7950,14 +7812,6 @@
"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": {
"version": "1.5.2",
"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"
}
},
"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": {
"version": "0.4.0",
"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",
"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": {
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",

View file

@ -24,7 +24,6 @@
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@fontsource/inter": "4.5.14",
"@khanacademy/simple-markdown": "0.8.6",
"@matrix-org/olm": "3.2.14",
"@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1",
@ -52,8 +51,6 @@
"immer": "9.0.16",
"is-hotkey": "0.2.0",
"jotai": "2.6.0",
"katex": "0.16.10",
"linkify-html": "4.0.2",
"linkify-react": "4.1.1",
"linkifyjs": "4.0.2",
"matrix-js-sdk": "29.1.0",
@ -65,8 +62,6 @@
"react-aria": "3.29.1",
"react-autosize-textarea": "7.1.0",
"react-blurhash": "0.2.0",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-dom": "18.2.0",
"react-error-boundary": "4.0.10",
"react-google-recaptcha": "2.1.0",
@ -78,7 +73,6 @@
"slate-history": "0.93.0",
"slate-react": "0.98.4",
"tippy.js": "6.3.7",
"twemoji": "14.0.2",
"ua-parser-js": "1.0.35"
},
"devDependencies": {

View file

@ -2,17 +2,13 @@ import React from 'react';
import PropTypes from 'prop-types';
import './Avatar.scss';
import { twemojify } from '../../../util/twemojify';
import Text from '../text/Text';
import RawIcon from '../system-icons/RawIcon';
import ImageBrokenSVG from '../../../../public/res/svg/image-broken.svg';
import { avatarInitials } from '../../../util/common';
const Avatar = React.forwardRef(({
text, bgColor, iconSrc, iconColor, imageSrc, size,
}, ref) => {
const Avatar = React.forwardRef(({ text, bgColor, iconSrc, iconColor, imageSrc, size }, ref) => {
let textSize = 's1';
if (size === 'large') textSize = 'h1';
if (size === 'small') textSize = 'b1';
@ -20,34 +16,34 @@ const Avatar = React.forwardRef(({
return (
<div ref={ref} className={`avatar-container avatar-container__${size} noselect`}>
{
imageSrc !== null
? (
<img
draggable="false"
src={imageSrc}
onLoad={(e) => { e.target.style.backgroundColor = 'transparent'; }}
onError={(e) => { e.target.src = ImageBrokenSVG; }}
alt=""
/>
)
: (
<span
style={{ backgroundColor: iconSrc === null ? bgColor : 'transparent' }}
className={`avatar__border${iconSrc !== null ? '--active' : ''}`}
>
{
iconSrc !== null
? <RawIcon size={size} src={iconSrc} color={iconColor} />
: text !== null && (
<Text variant={textSize} primary>
{twemojify(avatarInitials(text))}
</Text>
)
}
</span>
)
}
{imageSrc !== null ? (
<img
draggable="false"
src={imageSrc}
onLoad={(e) => {
e.target.style.backgroundColor = 'transparent';
}}
onError={(e) => {
e.target.src = ImageBrokenSVG;
}}
alt=""
/>
) : (
<span
style={{ backgroundColor: iconSrc === null ? bgColor : 'transparent' }}
className={`avatar__border${iconSrc !== null ? '--active' : ''}`}
>
{iconSrc !== null ? (
<RawIcon size={size} src={iconSrc} color={iconColor} />
) : (
text !== null && (
<Text variant={textSize} primary>
{avatarInitials(text)}
</Text>
)
)}
</span>
)}
</div>
);
});

View file

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

View file

@ -1,3 +0,0 @@
.katex-display {
margin: 0 !important;
}

View file

@ -8,7 +8,7 @@ import React, {
useRef,
useState,
} from 'react';
import { useAtom } from 'jotai';
import { useAtom, useAtomValue } from 'jotai';
import { isKeyHotkey } from 'is-hotkey';
import { EventType, IContent, MsgType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
@ -56,7 +56,6 @@ import {
} from '../../components/editor';
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
import { UseStateProvider } from '../../components/UseStateProvider';
import initMatrix from '../../../client/initMatrix';
import { TUploadContent, encryptFile, getImageInfo, getMxIdLocalPart } from '../../utils/matrix';
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
import { useFilePicker } from '../../hooks/useFilePicker';
@ -95,6 +94,7 @@ import {
} from './msgContent';
import colorMXID from '../../../util/colorMXID';
import {
getAllParents,
getMemberDisplayName,
parseReplyBody,
parseReplyFormattedBody,
@ -107,6 +107,7 @@ import { Command, SHRUG, useCommands } from '../../hooks/useCommands';
import { mobileOrTablet } from '../../utils/user-agent';
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
import { ReplyLayout } from '../../components/message';
import { roomToParentsAtom } from '../../state/room/roomToParents';
interface RoomInputProps {
editor: Editor;
@ -121,6 +122,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
const commands = useCommands(mx, room);
const emojiBtnRef = useRef<HTMLButtonElement>(null);
const roomToParents = useAtomValue(roomToParentsAtom);
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
@ -133,13 +135,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
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) => {
const r = mx.getRoom(rId);
if (r) list.push(r);
return list;
}, []);
}, [mx, roomId]);
}, [mx, roomId, roomToParents]);
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [autocompleteQuery, setAutocompleteQuery] =

View file

@ -28,7 +28,7 @@ import classNames from 'classnames';
import { ReactEditor } from 'slate-react';
import { Editor } from 'slate';
import to from 'await-to-js';
import { useSetAtom } from 'jotai';
import { useAtomValue, useSetAtom } from 'jotai';
import {
Badge,
Box,
@ -74,6 +74,7 @@ import { getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser
import {
canEditEvent,
decryptAllTimelineEvent,
getAllParents,
getEditedEvent,
getEventReactions,
getLatestEditableEvt,
@ -103,14 +104,15 @@ import { createMentionElement, isEmptyEditor, moveCursor } from '../../component
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
import initMatrix from '../../../client/initMatrix';
import { useKeyDown } from '../../hooks/useKeyDown';
import cons from '../../../client/state/cons';
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
import { RenderMessageContent } from '../../components/RenderMessageContent';
import { Image } from '../../components/media';
import { ImageViewer } from '../../components/image-viewer';
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>(
({ position, className, ...props }, ref) => (
@ -444,18 +446,19 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel);
const [editId, setEditId] = useState<string>();
const { navigateRoom, navigateSpace } = useRoomNavigate();
const roomToParents = useAtomValue(roomToParentsAtom);
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const imagePackRooms: Room[] = useMemo(() => {
const allParentSpaces = [
room.roomId,
...(initMatrix.roomList?.getAllParentSpaces(room.roomId) ?? []),
];
const allParentSpaces = [room.roomId].concat(
Array.from(getAllParents(roomToParents, room.roomId))
);
return allParentSpaces.reduce<Room[]>((list, rId) => {
const r = mx.getRoom(rId);
if (r) list.push(r);
return list;
}, []);
}, [mx, room]);
}, [mx, room, roomToParents]);
const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
const readUptoEventIdRef = useRef<string>();
@ -794,15 +797,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// Remove unreadInfo on mark as read
useEffect(() => {
const handleFullRead = (rId: string) => {
if (rId !== room.roomId) return;
if (!unread) {
setUnreadInfo(undefined);
};
initMatrix.notifications?.on(cons.events.notifications.FULL_READ, handleFullRead);
return () => {
initMatrix.notifications?.removeListener(cons.events.notifications.FULL_READ, handleFullRead);
};
}, [room]);
}
}, [unread]);
// scroll out of view msg editor in view.
useEffect(() => {

View file

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

View file

@ -114,12 +114,12 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
description: 'Leave current room.',
exe: async (payload) => {
if (payload.trim() === '') {
roomActions.leave(room.roomId);
mx.leave(room.roomId);
return;
}
const rawIds = payload.split(' ');
const roomIds = rawIds.filter((id) => isRoomId(id));
roomIds.map((id) => roomActions.leave(id));
roomIds.map((id) => mx.leave(id));
},
},
[Command.Invite]: {

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

View file

@ -1,10 +1,26 @@
import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
import { roomIdToTypingMembersAtom, selectRoomTypingMembersAtom } from '../state/typingMembers';
import { selectAtom } from 'jotai/utils';
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) => {
const typing = useAtomValue(
useMemo(() => selectRoomTypingMembersAtom(roomId, roomIdToTypingMembersAtom), [roomId])
const selector = useCallback(
(roomToTyping: IRoomIdToTypingMembers) => roomToTyping.get(roomId) ?? [],
[roomId]
);
const typing = useAtomValue(selectAtom(roomIdToTypingMembersAtom, selector, equalTypingMembers));
return typing;
};

View file

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

View file

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

View file

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

View file

@ -2,16 +2,21 @@ import React from 'react';
import PropTypes from 'prop-types';
import './Dialog.scss';
import { twemojify } from '../../../util/twemojify';
import Text from '../../atoms/text/Text';
import Header, { TitleWrapper } from '../../atoms/header/Header';
import ScrollView from '../../atoms/scroll/ScrollView';
import RawModal from '../../atoms/modal/RawModal';
function Dialog({
className, isOpen, title, onAfterOpen, onAfterClose,
contentOptions, onRequestClose, closeFromOutside, children,
className,
isOpen,
title,
onAfterOpen,
onAfterClose,
contentOptions,
onRequestClose,
closeFromOutside,
children,
invisibleScroll,
}) {
return (
@ -28,19 +33,19 @@ function Dialog({
<div className="dialog__content">
<Header>
<TitleWrapper>
{
typeof title === 'string'
? <Text variant="h2" weight="medium" primary>{twemojify(title)}</Text>
: title
}
{typeof title === 'string' ? (
<Text variant="h2" weight="medium" primary>
{title}
</Text>
) : (
title
)}
</TitleWrapper>
{contentOptions}
</Header>
<div className="dialog__content__wrapper">
<ScrollView autoHide={!invisibleScroll} invisible={invisibleScroll}>
<div className="dialog__content-container">
{children}
</div>
<div className="dialog__content-container">{children}</div>
</ScrollView>
</div>
</div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,16 +2,12 @@ import React from 'react';
import PropTypes from 'prop-types';
import './PeopleSelector.scss';
import { twemojify } from '../../../util/twemojify';
import { blurOnBubbling } from '../../atoms/button/script';
import Text from '../../atoms/text/Text';
import Avatar from '../../atoms/avatar/Avatar';
function PeopleSelector({
avatarSrc, name, color, peopleRole, onClick,
}) {
function PeopleSelector({ avatarSrc, name, color, peopleRole, onClick }) {
return (
<div className="people-selector__container">
<button
@ -21,8 +17,14 @@ function PeopleSelector({
type="button"
>
<Avatar imageSrc={avatarSrc} text={name} bgColor={color} size="extra-small" />
<Text className="people-selector__name" variant="b1">{twemojify(name)}</Text>
{peopleRole !== null && <Text className="people-selector__role" variant="b3">{peopleRole}</Text>}
<Text className="people-selector__name" variant="b1">
{name}
</Text>
{peopleRole !== null && (
<Text className="people-selector__role" variant="b3">
{peopleRole}
</Text>
)}
</button>
</div>
);

View file

@ -2,8 +2,6 @@ import React from 'react';
import PropTypes from 'prop-types';
import './PopupWindow.scss';
import { twemojify } from '../../../util/twemojify';
import Text from '../../atoms/text/Text';
import IconButton from '../../atoms/button/IconButton';
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';
function PWContentSelector({
selected, variant, iconSrc,
type, onClick, children,
}) {
function PWContentSelector({ selected, variant, iconSrc, type, onClick, children }) {
const pwcsClass = selected ? ' pw-content-selector--selected' : '';
return (
<div className={`pw-content-selector${pwcsClass}`}>
<MenuItem
variant={variant}
iconSrc={iconSrc}
type={type}
onClick={onClick}
>
<MenuItem variant={variant} iconSrc={iconSrc} type={type} onClick={onClick}>
{children}
</MenuItem>
</div>
@ -49,9 +39,16 @@ PWContentSelector.propTypes = {
};
function PopupWindow({
className, isOpen, title, contentTitle,
drawer, drawerOptions, contentOptions,
onAfterClose, onRequestClose, children,
className,
isOpen,
title,
contentTitle,
drawer,
drawerOptions,
contentOptions,
onAfterClose,
onRequestClose,
children,
}) {
const haveDrawer = drawer !== null;
const cTitle = contentTitle !== null ? contentTitle : title;
@ -69,21 +66,26 @@ function PopupWindow({
{haveDrawer && (
<div className="pw__drawer">
<Header>
<IconButton size="small" src={ChevronLeftIC} onClick={onRequestClose} tooltip="Back" />
<IconButton
size="small"
src={ChevronLeftIC}
onClick={onRequestClose}
tooltip="Back"
/>
<TitleWrapper>
{
typeof title === 'string'
? <Text variant="s1" weight="medium" primary>{twemojify(title)}</Text>
: title
}
{typeof title === 'string' ? (
<Text variant="s1" weight="medium" primary>
{title}
</Text>
) : (
title
)}
</TitleWrapper>
{drawerOptions}
</Header>
<div className="pw__drawer__content__wrapper">
<ScrollView invisible>
<div className="pw__drawer__content">
{drawer}
</div>
<div className="pw__drawer__content">{drawer}</div>
</ScrollView>
</div>
</div>
@ -91,19 +93,19 @@ function PopupWindow({
<div className="pw__content">
<Header>
<TitleWrapper>
{
typeof cTitle === 'string'
? <Text variant="h2" weight="medium" primary>{twemojify(cTitle)}</Text>
: cTitle
}
{typeof cTitle === 'string' ? (
<Text variant="h2" weight="medium" primary>
{cTitle}
</Text>
) : (
cTitle
)}
</TitleWrapper>
{contentOptions}
</Header>
<div className="pw__content__wrapper">
<ScrollView autoHide>
<div className="pw__content-container">
{children}
</div>
<div className="pw__content-container">{children}</div>
</ScrollView>
</div>
</div>

View file

@ -13,28 +13,33 @@ import BellIC from '../../../../public/res/ic/outlined/bell.svg';
import BellRingIC from '../../../../public/res/ic/outlined/bell-ring.svg';
import BellPingIC from '../../../../public/res/ic/outlined/bell-ping.svg';
import BellOffIC from '../../../../public/res/ic/outlined/bell-off.svg';
import { getNotificationType } from '../../utils/room';
const items = [{
iconSrc: BellIC,
text: 'Global',
type: cons.notifs.DEFAULT,
}, {
iconSrc: BellRingIC,
text: 'All messages',
type: cons.notifs.ALL_MESSAGES,
}, {
iconSrc: BellPingIC,
text: 'Mentions & Keywords',
type: cons.notifs.MENTIONS_AND_KEYWORDS,
}, {
iconSrc: BellOffIC,
text: 'Mute',
type: cons.notifs.MUTE,
}];
const items = [
{
iconSrc: BellIC,
text: 'Global',
type: cons.notifs.DEFAULT,
},
{
iconSrc: BellRingIC,
text: 'All messages',
type: cons.notifs.ALL_MESSAGES,
},
{
iconSrc: BellPingIC,
text: 'Mentions & Keywords',
type: cons.notifs.MENTIONS_AND_KEYWORDS,
},
{
iconSrc: BellOffIC,
text: 'Mute',
type: cons.notifs.MUTE,
},
];
function setRoomNotifType(roomId, newType) {
const mx = initMatrix.matrixClient;
const { notifications } = initMatrix;
let roomPushRule;
try {
roomPushRule = mx.getRoomPushRule('global', roomId);
@ -47,22 +52,22 @@ function setRoomNotifType(roomId, newType) {
if (roomPushRule) {
promises.push(mx.deletePushRule('global', 'room', roomPushRule.rule_id));
}
promises.push(mx.addPushRule('global', 'override', roomId, {
conditions: [
{
kind: 'event_match',
key: 'room_id',
pattern: roomId,
},
],
actions: [
'dont_notify',
],
}));
promises.push(
mx.addPushRule('global', 'override', roomId, {
conditions: [
{
kind: 'event_match',
key: 'room_id',
pattern: roomId,
},
],
actions: ['dont_notify'],
})
);
return promises;
}
const oldState = notifications.getNotiType(roomId);
const oldState = getNotificationType(mx, roomId);
if (oldState === cons.notifs.MUTE) {
promises.push(mx.deletePushRule('global', 'override', roomId));
}
@ -75,25 +80,27 @@ function setRoomNotifType(roomId, newType) {
}
if (newType === cons.notifs.MENTIONS_AND_KEYWORDS) {
promises.push(mx.addPushRule('global', 'room', roomId, {
actions: [
'dont_notify',
],
}));
promises.push(
mx.addPushRule('global', 'room', roomId, {
actions: ['dont_notify'],
})
);
promises.push(mx.setPushRuleEnabled('global', 'room', roomId, true));
return Promise.all(promises);
}
// cons.notifs.ALL_MESSAGES
promises.push(mx.addPushRule('global', 'room', roomId, {
actions: [
'notify',
{
set_tweak: 'sound',
value: 'default',
},
],
}));
promises.push(
mx.addPushRule('global', 'room', roomId, {
actions: [
'notify',
{
set_tweak: 'sound',
value: 'default',
},
],
})
);
promises.push(mx.setPushRuleEnabled('global', 'room', roomId, true));
@ -101,17 +108,20 @@ function setRoomNotifType(roomId, newType) {
}
function useNotifications(roomId) {
const { notifications } = initMatrix;
const [activeType, setActiveType] = useState(notifications.getNotiType(roomId));
const mx = initMatrix.matrixClient;
const [activeType, setActiveType] = useState(getNotificationType(mx, roomId));
useEffect(() => {
setActiveType(notifications.getNotiType(roomId));
}, [roomId]);
setActiveType(getNotificationType(mx, roomId));
}, [mx, roomId]);
const setNotification = useCallback((item) => {
if (item.type === activeType.type) return;
setActiveType(item.type);
setRoomNotifType(roomId, item.type);
}, [activeType, roomId]);
const setNotification = useCallback(
(item) => {
if (item.type === activeType.type) return;
setActiveType(item.type);
setRoomNotifType(roomId, item.type);
},
[activeType, roomId]
);
return [activeType, setNotification];
}
@ -120,21 +130,19 @@ function RoomNotification({ roomId }) {
return (
<div className="room-notification">
{
items.map((item) => (
<MenuItem
variant={activeType === item.type ? 'positive' : 'surface'}
key={item.type}
iconSrc={item.iconSrc}
onClick={() => setNotification(item)}
>
<Text varient="b1">
<span>{item.text}</span>
<RadioButton isActive={activeType === item.type} />
</Text>
</MenuItem>
))
}
{items.map((item) => (
<MenuItem
variant={activeType === item.type ? 'positive' : 'surface'}
key={item.type}
iconSrc={item.iconSrc}
onClick={() => setNotification(item)}
>
<Text varient="b1">
<span>{item.text}</span>
<RadioButton isActive={activeType === item.type} />
</Text>
</MenuItem>
))}
</div>
);
}

View file

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

View file

@ -1,9 +1,9 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { useAtomValue } from 'jotai';
import Linkify from 'linkify-react';
import './RoomProfile.scss';
import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import colorMXID from '../../../util/colorMXID';
@ -20,6 +20,8 @@ import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
import { useStore } from '../../hooks/useStore';
import { useForceUpdate } from '../../hooks/useForceUpdate';
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
import { mDirectAtom } from '../../state/mDirectList';
import { LINKIFY_OPTS } from '../../plugins/react-custom-html-parser';
function RoomProfile({ roomId }) {
const isMountStore = useStore();
@ -31,9 +33,12 @@ function RoomProfile({ roomId }) {
});
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');
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 { currentState } = room;
const roomName = room.name;
@ -47,15 +52,14 @@ function RoomProfile({ roomId }) {
useEffect(() => {
isMountStore.setItem(true);
const { roomList } = initMatrix;
const handleProfileUpdate = (rId) => {
if (roomId !== rId) return;
const handleStateEvent = (mEvent) => {
if (mEvent.event.room_id !== roomId) return;
forceUpdate();
};
roomList.on(cons.events.roomList.ROOM_PROFILE_UPDATED, handleProfileUpdate);
mx.on('RoomState.events', handleStateEvent);
return () => {
roomList.removeListener(cons.events.roomList.ROOM_PROFILE_UPDATED, handleProfileUpdate);
mx.removeListener('RoomState.events', handleStateEvent);
isMountStore.setItem(false);
setStatus({
msg: null,
@ -122,7 +126,7 @@ function RoomProfile({ roomId }) {
'Remove avatar',
'Are you sure that you want to remove room avatar?',
'Remove',
'caution',
'caution'
);
if (isConfirmed) {
await mx.sendStateEvent(roomId, 'm.room.avatar', { url }, '');
@ -132,15 +136,45 @@ function RoomProfile({ roomId }) {
const renderEditNameAndTopic = () => (
<form className="room-profile__edit-form" onSubmit={handleOnSubmit}>
{canChangeName && <Input value={roomName} name="room-name" disabled={status.type === cons.status.IN_FLIGHT} label="Name" />}
{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 && (
{canChangeName && (
<Input
value={roomName}
name="room-name"
disabled={status.type === cons.status.IN_FLIGHT}
label="Name"
/>
)}
{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>
<Button type="submit" variant="primary">Save</Button>
<Button type="submit" variant="primary">
Save
</Button>
<Button onClick={handleCancelEditing}>Cancel</Button>
</div>
)}
@ -148,10 +182,15 @@ function RoomProfile({ roomId }) {
);
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>
<Text variant="h2" weight="medium" primary>{twemojify(roomName)}</Text>
{ (canChangeName || canChangeTopic) && (
<Text variant="h2" weight="medium" primary>
{roomName}
</Text>
{(canChangeName || canChangeTopic) && (
<IconButton
src={PencilIC}
size="extra-small"
@ -161,15 +200,21 @@ function RoomProfile({ roomId }) {
)}
</div>
<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>
);
return (
<div className="room-profile">
<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
text={roomName}
bgColor={colorMXID(roomId)}

View file

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

View file

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

View file

@ -2,7 +2,6 @@ import React from 'react';
import PropTypes from 'prop-types';
import './RoomSelector.scss';
import { twemojify } from '../../../util/twemojify';
import colorMXID from '../../../util/colorMXID';
import Text from '../../atoms/text/Text';
@ -11,8 +10,13 @@ import NotificationBadge from '../../atoms/badge/NotificationBadge';
import { blurOnBubbling } from '../../atoms/button/script';
function RoomSelectorWrapper({
isSelected, isMuted, isUnread, onClick,
content, options, onContextMenu,
isSelected,
isMuted,
isUnread,
onClick,
content,
options,
onContextMenu,
}) {
const classes = ['room-selector'];
if (isMuted) classes.push('room-selector--muted');
@ -50,16 +54,26 @@ RoomSelectorWrapper.propTypes = {
};
function RoomSelector({
name, parentName, roomId, imageSrc, iconSrc,
isSelected, isMuted, isUnread, notificationCount, isAlert,
options, onClick, onContextMenu,
name,
parentName,
roomId,
imageSrc,
iconSrc,
isSelected,
isMuted,
isUnread,
notificationCount,
isAlert,
options,
onClick,
onContextMenu,
}) {
return (
<RoomSelectorWrapper
isSelected={isSelected}
isMuted={isMuted}
isUnread={isUnread}
content={(
content={
<>
<Avatar
text={name}
@ -70,22 +84,22 @@ function RoomSelector({
size="extra-small"
/>
<Text variant="b1" weight={isUnread ? 'medium' : 'normal'}>
{twemojify(name)}
{name}
{parentName && (
<Text variant="b3" span>
{' — '}
{twemojify(parentName)}
{parentName}
</Text>
)}
</Text>
{ isUnread && (
{isUnread && (
<NotificationBadge
alert={isAlert}
content={notificationCount !== 0 ? notificationCount : null}
/>
)}
</>
)}
}
options={options}
onClick={onClick}
onContextMenu={onContextMenu}
@ -110,10 +124,7 @@ RoomSelector.propTypes = {
isSelected: PropTypes.bool,
isMuted: PropTypes.bool,
isUnread: PropTypes.bool.isRequired,
notificationCount: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]).isRequired,
notificationCount: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
isAlert: PropTypes.bool.isRequired,
options: PropTypes.node,
onClick: PropTypes.func.isRequired,

View file

@ -2,46 +2,35 @@ import React from 'react';
import PropTypes from 'prop-types';
import './RoomTile.scss';
import { twemojify } from '../../../util/twemojify';
import colorMXID from '../../../util/colorMXID';
import Text from '../../atoms/text/Text';
import Avatar from '../../atoms/avatar/Avatar';
function RoomTile({
avatarSrc, name, id,
inviterName, memberCount, desc, options,
}) {
function RoomTile({ avatarSrc, name, id, inviterName, memberCount, desc, options }) {
return (
<div className="room-tile">
<div className="room-tile__avatar">
<Avatar
imageSrc={avatarSrc}
bgColor={colorMXID(id)}
text={name}
/>
<Avatar imageSrc={avatarSrc} bgColor={colorMXID(id)} text={name} />
</div>
<div className="room-tile__content">
<Text variant="s1">{twemojify(name)}</Text>
<Text variant="s1">{name}</Text>
<Text variant="b3">
{
inviterName !== null
? `Invited by ${inviterName} to ${id}${memberCount === null ? '' : `${memberCount} members`}`
: id + (memberCount === null ? '' : `${memberCount} members`)
}
{inviterName !== null
? `Invited by ${inviterName} to ${id}${
memberCount === null ? '' : `${memberCount} members`
}`
: id + (memberCount === null ? '' : `${memberCount} members`)}
</Text>
{
desc !== null && (typeof desc === 'string')
? <Text className="room-tile__content__desc" variant="b2">{twemojify(desc, undefined, true)}</Text>
: desc
}
{desc !== null && typeof desc === 'string' ? (
<Text className="room-tile__content__desc" variant="b2">
{desc}
</Text>
) : (
desc
)}
</div>
{ options !== null && (
<div className="room-tile__options">
{options}
</div>
)}
{options !== null && <div className="room-tile__options">{options}</div>}
</div>
);
}
@ -58,10 +47,7 @@ RoomTile.propTypes = {
name: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
inviterName: PropTypes.string,
memberCount: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]),
memberCount: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
desc: PropTypes.node,
options: PropTypes.node,
};

View file

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

View file

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

View file

@ -1,9 +1,8 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { useAtomValue } from 'jotai';
import './SpaceAddExisting.scss';
import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
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 { 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 }) {
const mountStore = useStore(roomId);
@ -33,7 +35,10 @@ function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
const [selected, setSelected] = useState([]);
const [searchIds, setSearchIds] = useState(null);
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(() => {
const roomIds = onlySpaces ? [...spaces] : [...rooms, ...directs];
@ -217,7 +222,7 @@ function SpaceAddExisting() {
className="space-add-existing"
title={
<Text variant="s1" weight="medium" primary>
{room && twemojify(room.name)}
{room && room.name}
<span style={{ color: 'var(--tc-surface-low)' }}>
{' '}
add existing {data?.spaces ? 'spaces' : 'rooms'}

View file

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

View file

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

View file

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

View file

@ -2,11 +2,10 @@ import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import './CreateRoom.scss';
import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
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 { isRoomAliasAvailable, getIdServer } from '../../../util/matrixUtil';
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 ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
const [joinRule, setJoinRule] = useState(parentId ? 'restricted' : 'invite');
const [isEncrypted, setIsEncrypted] = useState(true);
const [isCreatingRoom, setIsCreatingRoom] = useState(false);
const [creatingError, setCreatingError] = useState(null);
const { navigateRoom, navigateSpace } = useRoomNavigate();
const [isValidAddress, setIsValidAddress] = useState(null);
const [addressValue, setAddressValue] = useState(undefined);
@ -48,25 +49,6 @@ function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
const mx = initMatrix.matrixClient;
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) => {
evt.preventDefault();
const { target } = evt;
@ -87,16 +69,26 @@ function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
const powerLevel = roleIndex === 1 ? 101 : undefined;
try {
await roomActions.createRoom({
const data = await roomActions.createRoom({
name,
topic,
joinRule,
alias: roomAlias,
isEncrypted: (isSpace || joinRule === 'public') ? false : isEncrypted,
isEncrypted: isSpace || joinRule === 'public' ? false : isEncrypted,
powerLevel,
isSpace,
parentId,
});
setIsCreatingRoom(false);
setCreatingError(null);
setIsValidAddress(null);
setAddressValue(undefined);
onRequestClose();
if (isSpace) {
navigateSpace(data.room_id);
} else {
navigateRoom(data.room_id);
}
} catch (e) {
if (e.message === 'M_UNKNOWN: Invalid characters in room alias') {
setCreatingError('ERROR: Invalid characters in address');
@ -131,36 +123,35 @@ function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
const joinRules = ['invite', '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 jrSpaceIC = [SpaceLockIC, SpaceIC, SpaceGlobeIC];
const handleJoinRule = (evt) => {
openReusableContextMenu(
'bottom',
getEventCords(evt, '.btn-surface'),
(closeMenu) => (
<>
<MenuHeader>Visibility (who can join)</MenuHeader>
{
joinRules.map((rule) => (
<MenuItem
key={rule}
variant={rule === joinRule ? 'positive' : 'surface'}
iconSrc={
isSpace
? jrSpaceIC[joinRules.indexOf(rule)]
: jrRoomIC[joinRules.indexOf(rule)]
}
onClick={() => { closeMenu(); setJoinRule(rule); }}
disabled={!parentId && rule === 'restricted'}
>
{ joinRuleText[joinRules.indexOf(rule)] }
</MenuItem>
))
}
</>
),
);
openReusableContextMenu('bottom', getEventCords(evt, '.btn-surface'), (closeMenu) => (
<>
<MenuHeader>Visibility (who can join)</MenuHeader>
{joinRules.map((rule) => (
<MenuItem
key={rule}
variant={rule === joinRule ? 'positive' : 'surface'}
iconSrc={
isSpace ? jrSpaceIC[joinRules.indexOf(rule)] : jrRoomIC[joinRules.indexOf(rule)]
}
onClick={() => {
closeMenu();
setJoinRule(rule);
}}
disabled={!parentId && rule === 'restricted'}
>
{joinRuleText[joinRules.indexOf(rule)]}
</MenuItem>
))}
</>
));
};
return (
@ -168,50 +159,64 @@ function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
<form className="create-room__form" onSubmit={handleSubmit}>
<SettingTile
title="Visibility"
options={(
options={
<Button onClick={handleJoinRule} iconSrc={ChevronBottomIC}>
{joinRuleShortText[joinRules.indexOf(joinRule)]}
</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' && (
<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">
<Text variant="b1">#</Text>
<Input
value={addressValue}
onChange={validateAddress}
state={(isValidAddress === false) ? 'error' : 'normal'}
state={isValidAddress === false ? 'error' : 'normal'}
forwardRef={addressRef}
placeholder="my_address"
required
/>
<Text variant="b1">{`:${userHs}`}</Text>
</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>
)}
{!isSpace && joinRule !== 'public' && (
<SettingTile
title="Enable end-to-end encryption"
options={<Toggle isActive={isEncrypted} onToggle={setIsEncrypted} />}
content={<Text variant="b3">You cant disable this later. Bridges & most bots wont work yet.</Text>}
content={
<Text variant="b3">
You cant disable this later. Bridges & most bots wont work yet.
</Text>
}
/>
)}
<SettingTile
title="Select your role"
options={(
options={
<SegmentControl
selected={roleIndex}
segments={[{ text: 'Admin' }, { text: 'Founder' }]}
onSelect={setRoleIndex}
/>
)}
content={(
}
content={
<Text variant="b3">Selecting Admin sets 100 power level whereas Founder sets 101.</Text>
)}
}
/>
<Input name="topic" minHeight={174} resizable label="Topic (optional)" />
<div className="create-room__name-wrapper">
@ -231,7 +236,11 @@ function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
<Text>{`Creating ${isSpace ? 'space' : 'room'}...`}</Text>
</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>
</div>
);
@ -275,27 +284,22 @@ function CreateRoom() {
return (
<Dialog
isOpen={create !== null}
title={(
title={
<Text variant="s1" weight="medium" primary>
{parentId ? twemojify(room.name) : 'Home'}
{parentId ? room.name : 'Home'}
<span style={{ color: 'var(--tc-surface-low)' }}>
{` — create ${isSpace ? 'space' : 'room'}`}
</span>
</Text>
)}
}
contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
onRequestClose={onRequestClose}
>
{
create
? (
<CreateRoomContent
isSpace={isSpace}
parentId={parentId}
onRequestClose={onRequestClose}
/>
) : <div />
}
{create ? (
<CreateRoomContent isSpace={isSpace} parentId={parentId} onRequestClose={onRequestClose} />
) : (
<div />
)}
</Dialog>
);
}

View file

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

View file

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

View file

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

View file

@ -1,8 +1,6 @@
import { emojis } from './emoji';
// https://github.com/Sorunome/matrix-doc/blob/soru/emotes/proposals/2545-emotes.md
class ImagePack {
export class ImagePack {
static parsePack(eventId, packContent) {
if (!eventId || typeof packContent?.images !== 'object') {
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,
};

View file

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

View file

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

View file

@ -2,7 +2,6 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import './EmojiVerification.scss';
import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
@ -30,8 +29,9 @@ function EmojiVerificationContent({ data, requestClose }) {
const beginVerification = async () => {
if (
isCrossVerified(mx.deviceId)
&& (mx.getCrossSigningId() === null || await mx.crypto.crossSigningInfo.isStoredInKeyCache('self_signing') === false)
isCrossVerified(mx.deviceId) &&
(mx.getCrossSigningId() === null ||
(await mx.crypto.crossSigningInfo.isStoredInKeyCache('self_signing')) === false)
) {
if (!hasPrivateKey(getDefaultSSKey())) {
const keyData = await accessSecretStorage('Emoji verification');
@ -106,16 +106,20 @@ function EmojiVerificationContent({ data, requestClose }) {
{sas.sas.emoji.map((emoji, i) => (
// eslint-disable-next-line react/no-array-index-key
<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>
</div>
))}
</div>
<div className="emoji-verification__buttons">
{process ? renderWait() : (
{process ? (
renderWait()
) : (
<>
<Button variant="primary" onClick={sasConfirm}>They match</Button>
<Button onClick={sasMismatch}>{'They don\'t match'}</Button>
<Button variant="primary" onClick={sasConfirm}>
They match
</Button>
<Button onClick={sasMismatch}>No match</Button>
</>
)}
</div>
@ -127,9 +131,7 @@ function EmojiVerificationContent({ data, requestClose }) {
return (
<div className="emoji-verification__content">
<Text>Please accept the request from other device.</Text>
<div className="emoji-verification__buttons">
{renderWait()}
</div>
<div className="emoji-verification__buttons">{renderWait()}</div>
</div>
);
}
@ -138,11 +140,13 @@ function EmojiVerificationContent({ data, requestClose }) {
<div className="emoji-verification__content">
<Text>Click accept to start the verification process.</Text>
<div className="emoji-verification__buttons">
{
process
? renderWait()
: <Button variant="primary" onClick={beginVerification}>Accept</Button>
}
{process ? (
renderWait()
) : (
<Button variant="primary" onClick={beginVerification}>
Accept
</Button>
)}
</div>
</div>
);
@ -180,19 +184,19 @@ function EmojiVerification() {
<Dialog
isOpen={data !== null}
className="emoji-verification"
title={(
title={
<Text variant="s1" weight="medium" primary>
Emoji verification
</Text>
)}
}
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
onRequestClose={requestClose}
>
{
data !== null
? <EmojiVerificationContent data={data} requestClose={requestClose} />
: <div />
}
{data !== null ? (
<EmojiVerificationContent data={data} requestClose={requestClose} />
) : (
<div />
)}
</Dialog>
);
}

View file

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

View file

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

View file

@ -3,10 +3,8 @@ import PropTypes from 'prop-types';
import './InviteUser.scss';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import * as roomActions from '../../../client/action/room';
import { selectRoom } from '../../../client/action/navigation';
import { hasDMWith, hasDevices } from '../../../util/matrixUtil';
import { hasDevices } from '../../../util/matrixUtil';
import Text from '../../atoms/text/Text';
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 UserIC from '../../../../public/res/ic/outlined/user.svg';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { getDMRoomFor } from '../../utils/matrix';
function InviteUser({
isOpen, roomId, searchTerm, onRequestClose,
}) {
function InviteUser({ isOpen, roomId, searchTerm, onRequestClose }) {
const [isSearching, updateIsSearching] = useState(false);
const [searchQuery, updateSearchQuery] = useState({});
const [users, updateUsers] = useState([]);
@ -37,6 +35,7 @@ function InviteUser({
const usernameRef = useRef(null);
const mx = initMatrix.matrixClient;
const { navigateRoom } = useRoomNavigate();
function getMapCopy(myMap) {
const newMap = new Map();
@ -76,11 +75,13 @@ function InviteUser({
if (isInputUserId) {
try {
const result = await mx.getProfileInfo(inputUsername);
updateUsers([{
user_id: inputUsername,
display_name: result.displayname,
avatar_url: result.avatar_url,
}]);
updateUsers([
{
user_id: inputUsername,
display_name: result.displayname,
avatar_url: result.avatar_url,
},
]);
} catch (e) {
updateSearchQuery({ error: `${inputUsername} not found!` });
}
@ -105,9 +106,9 @@ function InviteUser({
async function createDM(userId) {
if (mx.getUserId() === userId) return;
const dmRoomId = hasDMWith(userId);
const dmRoomId = getDMRoomFor(mx, userId)?.roomId;
if (dmRoomId) {
selectRoom(dmRoomId);
navigateRoom(dmRoomId);
onRequestClose();
return;
}
@ -120,6 +121,7 @@ function InviteUser({
const result = await roomActions.createDM(userId, await hasDevices(userId));
roomIdToUserId.set(result.room_id, userId);
updateRoomIdToUserId(getMapCopy(roomIdToUserId));
onDMCreated(result.room_id);
} catch (e) {
deleteUserFromProc(userId);
if (typeof e.message === 'string') procUserError.set(userId, e.message);
@ -150,7 +152,13 @@ function InviteUser({
function renderUserList() {
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 (procUsers.has(userId)) {
@ -158,7 +166,16 @@ function InviteUser({
}
if (createdDM.has(userId)) {
// 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)) {
return messageJSX('Invited', true);
@ -178,13 +195,23 @@ function InviteUser({
}
}
}
return (typeof roomId === 'string')
? <Button onClick={() => inviteToRoom(userId)} variant="primary">Invite</Button>
: <Button onClick={() => createDM(userId)} variant="primary">Message</Button>;
return typeof roomId === 'string' ? (
<Button onClick={() => inviteToRoom(userId)} variant="primary">
Invite
</Button>
) : (
<Button onClick={() => createDM(userId)} variant="primary">
Message
</Button>
);
};
const renderError = (userId) => {
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) => {
@ -193,7 +220,11 @@ function InviteUser({
return (
<RoomTile
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}
id={userId}
options={renderOptions(userId)}
@ -217,48 +248,43 @@ function InviteUser({
};
}, [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 (
<PopupWindow
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" />}
onRequestClose={onRequestClose}
>
<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" />
<Button disabled={isSearching} iconSrc={UserIC} variant="primary" type="submit">Search</Button>
<Button disabled={isSearching} iconSrc={UserIC} variant="primary" type="submit">
Search
</Button>
</form>
<div className="invite-user__search-status">
{
typeof searchQuery.username !== 'undefined' && isSearching && (
<div className="flex--center">
<Spinner size="small" />
<Text variant="b2">{`Searching for user "${searchQuery.username}"...`}</Text>
</div>
)
}
{
typeof searchQuery.username !== 'undefined' && !isSearching && (
<Text variant="b2">{`Search result for user "${searchQuery.username}"`}</Text>
)
}
{
searchQuery.error && <Text className="invite-user__search-error" variant="b2">{searchQuery.error}</Text>
}
{typeof searchQuery.username !== 'undefined' && isSearching && (
<div className="flex--center">
<Spinner size="small" />
<Text variant="b2">{`Searching for user "${searchQuery.username}"...`}</Text>
</div>
)}
{typeof searchQuery.username !== 'undefined' && !isSearching && (
<Text variant="b2">{`Search result for user "${searchQuery.username}"`}</Text>
)}
{searchQuery.error && (
<Text className="invite-user__search-error" variant="b2">
{searchQuery.error}
</Text>
)}
</div>
{ users.length !== 0 && (
<div className="invite-user__content">
{renderUserList()}
</div>
)}
{users.length !== 0 && <div className="invite-user__content">{renderUserList()}</div>}
</div>
</PopupWindow>
);

View file

@ -6,7 +6,6 @@ import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
import { join } from '../../../client/action/room';
import { selectRoom, selectTab } from '../../../client/action/navigation';
import Text from '../../atoms/text/Text';
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 { useStore } from '../../hooks/useStore';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
const ALIAS_OR_ID_REG = /^[#|!].+:.+\..+$/;
function JoinAliasContent({ term, requestClose }) {
const [process, setProcess] = useState(false);
const [error, setError] = useState(undefined);
const [lastJoinId, setLastJoinId] = useState(undefined);
const mx = initMatrix.matrixClient;
const mountStore = useStore();
const { navigateRoom } = useRoomNavigate();
const openRoom = (roomId) => {
const room = mx.getRoom(roomId);
if (!room) return;
if (room.isSpaceRoom()) selectTab(roomId);
else selectRoom(roomId);
navigateRoom(roomId);
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) => {
e.preventDefault();
mountStore.setItem(true);
@ -70,13 +57,14 @@ function JoinAliasContent({ term, requestClose }) {
} catch (err) {
if (!mountStore.getItem()) return;
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 {
const roomId = await join(alias, false, via);
if (!mountStore.getItem()) return;
setLastJoinId(roomId);
openRoom(roomId);
} catch {
if (!mountStore.getItem()) return;
@ -87,24 +75,23 @@ function JoinAliasContent({ term, requestClose }) {
return (
<form className="join-alias" onSubmit={handleSubmit}>
<Input
label="Address"
value={term}
name="alias"
required
/>
{error && <Text className="join-alias__error" variant="b3">{error}</Text>}
<Input label="Address" value={term} name="alias" required />
{error && (
<Text className="join-alias__error" variant="b3">
{error}
</Text>
)}
<div className="join-alias__btn">
{
process
? (
<>
<Spinner size="small" />
<Text>{process}</Text>
</>
)
: <Button variant="primary" type="submit">Join</Button>
}
{process ? (
<>
<Spinner size="small" />
<Text>{process}</Text>
</>
) : (
<Button variant="primary" type="submit">
Join
</Button>
)}
</div>
</form>
);
@ -141,13 +128,15 @@ function JoinAlias() {
return (
<Dialog
isOpen={data !== null}
title={(
<Text variant="s1" weight="medium" primary>Join with address</Text>
)}
title={
<Text variant="s1" weight="medium" primary>
Join with address
</Text>
}
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
onRequestClose={requestClose}
>
{ data ? <JoinAliasContent term={data.term} requestClose={requestClose} /> : <div /> }
{data ? <JoinAliasContent term={data.term} requestClose={requestClose} /> : <div />}
</Dialog>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +0,0 @@
.navigation {
width: 100%;
height: 100%;
background-color: var(--bg-surface-low);
display: flex;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix';
import colorMXID from '../../../util/colorMXID';
@ -22,7 +21,9 @@ function ProfileEditor({ userId }) {
const user = mx.getUser(mx.getUserId());
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 [disabled, setDisabled] = useState(true);
@ -44,7 +45,7 @@ function ProfileEditor({ userId }) {
'Remove avatar',
'Are you sure that you want to remove avatar?',
'Remove',
'caution',
'caution'
);
if (isConfirmed) {
mx.setAvatarUrl('');
@ -79,7 +80,10 @@ function ProfileEditor({ userId }) {
<form
className="profile-editor__form"
style={{ marginBottom: avatarSrc ? '24px' : '0' }}
onSubmit={(e) => { e.preventDefault(); saveDisplayName(); }}
onSubmit={(e) => {
e.preventDefault();
saveDisplayName();
}}
>
<Input
label={`Display name of ${mx.getUserId()}`}
@ -87,7 +91,9 @@ function ProfileEditor({ userId }) {
value={mx.getUser(mx.getUserId()).displayName}
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>
</form>
);
@ -95,7 +101,9 @@ function ProfileEditor({ userId }) {
const renderInfo = () => (
<div className="profile-editor__info" style={{ marginBottom: avatarSrc ? '24px' : '0' }}>
<div>
<Text variant="h2" primary weight="medium">{twemojify(username) ?? userId}</Text>
<Text variant="h2" primary weight="medium">
{username ?? userId}
</Text>
<IconButton
src={PencilIC}
size="extra-small"
@ -116,9 +124,7 @@ function ProfileEditor({ userId }) {
onUpload={handleAvatarUpload}
onRequestRemove={() => handleAvatarUpload(null)}
/>
{
isEditing ? renderForm() : renderInfo()
}
{isEditing ? renderForm() : renderInfo()}
</div>
);
}

View file

@ -2,16 +2,17 @@ import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import './ProfileViewer.scss';
import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
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 {
getUsername, getUsernameOfRoomMember, getPowerLabel, hasDMWith, hasDevices,
getUsername,
getUsernameOfRoomMember,
getPowerLabel,
hasDevices,
} from '../../../util/matrixUtil';
import { getEventCords } from '../../../util/common';
import colorMXID from '../../../util/colorMXID';
@ -33,26 +34,24 @@ import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
import { useForceUpdate } from '../../hooks/useForceUpdate';
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { getDMRoomFor } from '../../utils/matrix';
function ModerationTools({
roomId, userId,
}) {
function ModerationTools({ roomId, userId }) {
const mx = initMatrix.matrixClient;
const room = mx.getRoom(roomId);
const roomMember = room.getMember(userId);
const myPowerLevel = room.getMember(mx.getUserId())?.powerLevel || 0;
const powerLevel = roomMember?.powerLevel || 0;
const canIKick = (
roomMember?.membership === 'join'
&& room.currentState.hasSufficientPowerLevelFor('kick', myPowerLevel)
&& powerLevel < myPowerLevel
);
const canIBan = (
['join', 'leave'].includes(roomMember?.membership)
&& room.currentState.hasSufficientPowerLevelFor('ban', myPowerLevel)
&& powerLevel < myPowerLevel
);
const canIKick =
roomMember?.membership === 'join' &&
room.currentState.hasSufficientPowerLevelFor('kick', myPowerLevel) &&
powerLevel < myPowerLevel;
const canIBan =
['join', 'leave'].includes(roomMember?.membership) &&
room.currentState.hasSufficientPowerLevelFor('ban', myPowerLevel) &&
powerLevel < myPowerLevel;
const handleKick = (e) => {
e.preventDefault();
@ -120,13 +119,14 @@ function SessionInfo({ userId }) {
<div className="session-info__chips">
{devices === null && <Text variant="b2">Loading sessions...</Text>}
{devices?.length === 0 && <Text variant="b2">No session found.</Text>}
{devices !== null && (devices.map((device) => (
<Chip
key={device.deviceId}
iconSrc={ShieldEmptyIC}
text={device.getDisplayName() || device.deviceId}
/>
)))}
{devices !== null &&
devices.map((device) => (
<Chip
key={device.deviceId}
iconSrc={ShieldEmptyIC}
text={device.getDisplayName() || device.deviceId}
/>
))}
</div>
);
}
@ -137,7 +137,11 @@ function SessionInfo({ userId }) {
onClick={() => setIsVisible(!isVisible)}
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>
{renderSessionChips()}
</div>
@ -155,6 +159,7 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
const isMountedRef = useRef(true);
const mx = initMatrix.matrixClient;
const { navigateRoom } = useRoomNavigate();
const room = mx.getRoom(roomId);
const member = room.getMember(userId);
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 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 onCreated = (dmRoomId) => {
if (isMountedRef.current === false) return;
setIsCreatingDM(false);
selectRoom(dmRoomId);
navigateRoom(dmRoomId);
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(() => {
setIsUserIgnored(initMatrix.matrixClient.isUserIgnored(userId));
setIsIgnoring(false);
@ -191,9 +189,9 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
const openDM = async () => {
// Check and open if user already have a DM with userId.
const dmRoomId = hasDMWith(userId);
const dmRoomId = getDMRoomFor(mx, userId)?.roomId;
if (dmRoomId) {
selectRoom(dmRoomId);
navigateRoom(dmRoomId);
onRequestClose();
return;
}
@ -201,7 +199,8 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
// Create new DM
try {
setIsCreatingDM(true);
await roomActions.createDM(userId, await hasDevices(userId));
const result = await roomActions.createDM(userId, await hasDevices(userId));
onCreated(result.room_id);
} catch {
if (isMountedRef.current === false) return;
setIsCreatingDM(false);
@ -246,31 +245,19 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
return (
<div className="profile-viewer__buttons">
<Button
variant="primary"
onClick={openDM}
disabled={isCreatingDM}
>
<Button variant="primary" onClick={openDM} disabled={isCreatingDM}>
{isCreatingDM ? 'Creating room...' : 'Message'}
</Button>
{ isBanned && canIKick && (
<Button
variant="positive"
onClick={() => roomActions.unban(roomId, userId)}
>
{isBanned && canIKick && (
<Button variant="positive" onClick={() => roomActions.unban(roomId, userId)}>
Unban
</Button>
)}
{ (isInvited ? canIKick : room.canInvite(mx.getUserId())) && isInvitable && (
<Button
onClick={toggleInvite}
disabled={isInviting}
>
{
isInvited
? `${isInviting ? 'Disinviting...' : 'Disinvite'}`
: `${isInviting ? 'Inviting...' : 'Invite'}`
}
{(isInvited ? canIKick : room.canInvite(mx.getUserId())) && isInvitable && (
<Button onClick={toggleInvite} disabled={isInviting}>
{isInvited
? `${isInviting ? 'Disinviting...' : 'Disinvite'}`
: `${isInviting ? 'Inviting...' : 'Invite'}`}
</Button>
)}
<Button
@ -278,11 +265,9 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
onClick={toggleIgnore}
disabled={isIgnoring}
>
{
isUserIgnored
? `${isIgnoring ? 'Unignoring...' : 'Unignore'}`
: `${isIgnoring ? 'Ignoring...' : 'Ignore'}`
}
{isUserIgnored
? `${isIgnoring ? 'Unignoring...' : 'Unignore'}`
: `${isIgnoring ? 'Ignoring...' : 'Ignore'}`}
</Button>
</div>
);
@ -326,8 +311,8 @@ function useRerenderOnProfileChange(roomId, userId) {
useEffect(() => {
const handleProfileChange = (mEvent, member) => {
if (
mEvent.getRoomId() === roomId
&& (member.userId === userId || member.userId === mx.getUserId())
mEvent.getRoomId() === roomId &&
(member.userId === userId || member.userId === mx.getUserId())
) {
forceUpdate();
}
@ -352,20 +337,22 @@ function ProfileViewer() {
const roomMember = room.getMember(userId);
const username = roomMember ? getUsernameOfRoomMember(roomMember) : getUsername(userId);
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 myPowerLevel = room.getMember(mx.getUserId())?.powerLevel || 0;
const canChangeRole = (
room.currentState.maySendEvent('m.room.power_levels', mx.getUserId())
&& (powerLevel < myPowerLevel || userId === mx.getUserId())
);
const canChangeRole =
room.currentState.maySendEvent('m.room.power_levels', mx.getUserId()) &&
(powerLevel < myPowerLevel || userId === mx.getUserId());
const handleChangePowerLevel = async (newPowerLevel) => {
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 DEMOTING_MYSELF_MSG = 'You will not be able to undo this change as you are demoting yourself. Are you sure?';
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 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 isDemotingMyself = userId === mx.getUserId();
@ -374,7 +361,7 @@ function ProfileViewer() {
'Change power level',
isSharedPower ? SHARED_POWER_MSG : DEMOTING_MYSELF_MSG,
'Change',
'caution',
'caution'
);
if (!isConfirmed) return;
roomActions.setPowerLevel(roomId, userId, newPowerLevel);
@ -384,20 +371,16 @@ function ProfileViewer() {
};
const handlePowerSelector = (e) => {
openReusableContextMenu(
'bottom',
getEventCords(e, '.btn-surface'),
(closeMenu) => (
<PowerLevelSelector
value={powerLevel}
max={myPowerLevel}
onSelect={(pl) => {
closeMenu();
handleChangePowerLevel(pl);
}}
/>
),
);
openReusableContextMenu('bottom', getEventCords(e, '.btn-surface'), (closeMenu) => (
<PowerLevelSelector
value={powerLevel}
max={myPowerLevel}
onSelect={(pl) => {
closeMenu();
handleChangePowerLevel(pl);
}}
/>
));
};
return (
@ -405,8 +388,10 @@ function ProfileViewer() {
<div className="profile-viewer__user">
<Avatar imageSrc={avatarUrl} text={username} bgColor={colorMXID(userId)} size="large" />
<div className="profile-viewer__user__info">
<Text variant="s1" weight="medium">{twemojify(username)}</Text>
<Text variant="b2">{twemojify(userId)}</Text>
<Text variant="s1" weight="medium">
{username}
</Text>
<Text variant="b2">{userId}</Text>
</div>
<div className="profile-viewer__user__role">
<Text variant="b3">Role</Text>
@ -420,7 +405,7 @@ function ProfileViewer() {
</div>
<ModerationTools roomId={roomId} userId={userId} />
<SessionInfo userId={userId} />
{ userId !== mx.getUserId() && (
{userId !== mx.getUserId() && (
<ProfileFooter roomId={roomId} userId={userId} onRequestClose={closeDialog} />
)}
</div>

View file

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

View file

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

View file

@ -1,11 +1,8 @@
import React from 'react';
import ReadReceipts from '../read-receipts/ReadReceipts';
import ProfileViewer from '../profile-viewer/ProfileViewer';
import ShortcutSpaces from '../shortcut-spaces/ShortcutSpaces';
import SpaceAddExisting from '../../molecules/space-add-existing/SpaceAddExisting';
import Search from '../search/Search';
import ViewSource from '../view-source/ViewSource';
import CreateRoom from '../create-room/CreateRoom';
import JoinAlias from '../join-alias/JoinAlias';
import EmojiVerification from '../emoji-verification/EmojiVerification';
@ -15,10 +12,7 @@ import ReusableDialog from '../../molecules/dialog/ReusableDialog';
function Dialogs() {
return (
<>
<ReadReceipts />
<ViewSource />
<ProfileViewer />
<ShortcutSpaces />
<CreateRoom />
<JoinAlias />
<SpaceAddExisting />

View file

@ -3,35 +3,18 @@ import React, { useState, useEffect } from 'react';
import cons from '../../../client/state/cons';
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 Settings from '../settings/Settings';
import SpaceSettings from '../space-settings/SpaceSettings';
import SpaceManage from '../space-manage/SpaceManage';
import RoomSettings from '../room/RoomSettings';
function Windows() {
const [isInviteList, changeInviteList] = useState(false);
const [publicRooms, changePublicRooms] = useState({
isOpen: false,
searchTerm: undefined,
});
const [inviteUser, changeInviteUser] = useState({
isOpen: false,
roomId: undefined,
term: undefined,
});
function openInviteList() {
changeInviteList(true);
}
function openPublicRooms(searchTerm) {
changePublicRooms({
isOpen: true,
searchTerm,
});
}
function openInviteUser(roomId, searchTerm) {
changeInviteUser({
isOpen: true,
@ -41,24 +24,14 @@ function Windows() {
}
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);
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);
};
}, []);
return (
<>
<InviteList isOpen={isInviteList} onRequestClose={() => changeInviteList(false)} />
<PublicRooms
isOpen={publicRooms.isOpen}
searchTerm={publicRooms.searchTerm}
onRequestClose={() => changePublicRooms({ isOpen: false, searchTerm: undefined })}
/>
<InviteUser
isOpen={inviteUser.isOpen}
roomId={inviteUser.roomId}
@ -68,7 +41,6 @@ function Windows() {
<Settings />
<SpaceSettings />
<RoomSettings />
<SpaceManage />
</>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,7 +5,6 @@ import './RoomSettings.scss';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
import * as roomActions from '../../../client/action/room';
import Text from '../../atoms/text/Text';
import Tabs from '../../atoms/tabs/Tabs';
@ -86,7 +85,7 @@ function GeneralSettings({ roomId }) {
'danger'
);
if (!isConfirmed) return;
roomActions.leave(roomId);
mx.leave(roomId);
}}
iconSrc={LeaveArrowIC}
>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import { useAtomValue } from 'jotai';
import './Search.scss';
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 CrossIC from '../../../../public/res/ic/outlined/cross.svg';
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) {
const [isOpen, setIsOpen] = useState(false);
@ -48,9 +54,8 @@ function useVisiblityToggle(setResult) {
return [isOpen, requestClose];
}
function mapRoomIds(roomIds) {
function mapRoomIds(roomIds, directs, roomIdToParents) {
const mx = initMatrix.matrixClient;
const { directs, roomIdToParents } = initMatrix.roomList;
return roomIds.map((roomId) => {
const room = mx.getRoom(roomId);
@ -62,7 +67,7 @@ function mapRoomIds(roomIds) {
let type = 'room';
if (room.isSpaceRoom()) type = 'space';
else if (directs.has(roomId)) type = 'direct';
else if (directs.includes(roomId)) type = 'direct';
return {
type,
@ -81,6 +86,12 @@ function Search() {
const searchRef = useRef(null);
const mx = initMatrix.matrixClient;
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) => {
setResult({
@ -97,7 +108,6 @@ function Search() {
return;
}
const { spaces, rooms, directs } = initMatrix.roomList;
let ids = null;
if (prefix) {
@ -109,15 +119,15 @@ function Search() {
}
ids.sort(roomIdByActivity);
const mappedIds = mapRoomIds(ids);
const mappedIds = mapRoomIds(ids, directs, roomToParents);
asyncSearch.setup(mappedIds, { keys: 'name', isContain: true, limit: 20 });
if (prefix) handleSearchResults(mappedIds, prefix);
else asyncSearch.search(term);
};
const loadRecentRooms = () => {
const { recentRooms } = navigation;
handleSearchResults(mapRoomIds(recentRooms).reverse());
const recentRooms = [];
handleSearchResults(mapRoomIds(recentRooms, directs, roomToParents).reverse());
};
const handleAfterOpen = () => {
@ -169,7 +179,6 @@ function Search() {
}
};
const noti = initMatrix.notifications;
const renderRoomSelector = (item) => {
let imageSrc = null;
let iconSrc = null;
@ -188,9 +197,9 @@ function Search() {
roomId={item.roomId}
imageSrc={imageSrc}
iconSrc={iconSrc}
isUnread={noti.hasNoti(item.roomId)}
notificationCount={noti.getTotalNoti(item.roomId)}
isAlert={noti.getHighlightNoti(item.roomId) > 0}
isUnread={roomToUnread.has(item.roomId)}
notificationCount={roomToUnread.get(item.roomId)?.total ?? 0}
isAlert={roomToUnread.get(item.roomId)?.highlight > 0}
onClick={() => openItem(item.roomId, item.type)}
/>
);

View file

@ -3,7 +3,6 @@ import React, { useState } from 'react';
import './CrossSigning.scss';
import FileSaver from 'file-saver';
import { Formik } from 'formik';
import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix';
import { openReusableDialog } from '../../../client/action/navigation';
@ -22,15 +21,17 @@ import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
const failedDialog = () => {
const renderFailure = (requestClose) => (
<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>
<Button onClick={requestClose}>Close</Button>
</div>
);
openReusableDialog(
<Text variant="s1" weight="medium">Setup cross signing</Text>,
renderFailure,
<Text variant="s1" weight="medium">
Setup cross signing
</Text>,
renderFailure
);
};
@ -48,11 +49,11 @@ const securityKeyDialog = (key) => {
const renderSecurityKey = () => (
<div className="cross-signing__key">
<Text weight="medium">Please save this security key somewhere safe.</Text>
<Text className="cross-signing__key-text">
{key.encodedPrivateKey}
</Text>
<Text className="cross-signing__key-text">{key.encodedPrivateKey}</Text>
<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>
</div>
</div>
@ -62,8 +63,10 @@ const securityKeyDialog = (key) => {
downloadKey();
openReusableDialog(
<Text variant="s1" weight="medium">Security Key</Text>,
() => renderSecurityKey(),
<Text variant="s1" weight="medium">
Security Key
</Text>,
() => renderSecurityKey()
);
};
@ -112,7 +115,7 @@ function CrossSigningSetup() {
errors.phrase = 'Phrase must contain 8-127 characters with no space.';
}
if (values.confirmPhrase.length > 0 && values.confirmPhrase !== values.phrase) {
errors.confirmPhrase = 'Phrase don\'t match.';
errors.confirmPhrase = "Phrase don't match.";
}
return errors;
};
@ -121,10 +124,14 @@ function CrossSigningSetup() {
<div className="cross-signing__setup">
<div className="cross-signing__setup-entry">
<Text>
We will generate a <b>Security Key</b>,
which you can use to manage messages backup and session verification.
We will generate a <b>Security Key</b>, which you can use to manage messages backup and
session verification.
</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" />}
</div>
<Text className="cross-signing__setup-divider">OR</Text>
@ -133,9 +140,7 @@ function CrossSigningSetup() {
onSubmit={(values) => setup(values.phrase)}
validate={validator}
>
{({
values, errors, handleChange, handleSubmit,
}) => (
{({ values, errors, handleChange, handleSubmit }) => (
<form
className="cross-signing__setup-entry"
onSubmit={handleSubmit}
@ -143,8 +148,8 @@ function CrossSigningSetup() {
>
<Text>
Alternatively you can also set a <b>Security Phrase </b>
so you don't have to remember long Security Key,
and optionally save the Key as backup.
so you don't have to remember long Security Key, and optionally save the Key as
backup.
</Text>
<Input
name="phrase"
@ -155,7 +160,11 @@ function CrossSigningSetup() {
required
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
name="confirmPhrase"
value={values.confirmPhrase}
@ -165,8 +174,16 @@ function CrossSigningSetup() {
required
disabled={genWithPhrase !== undefined}
/>
{errors.confirmPhrase && <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>}
{errors.confirmPhrase && (
<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" />}
</form>
)}
@ -177,31 +194,36 @@ function CrossSigningSetup() {
const setupDialog = () => {
openReusableDialog(
<Text variant="s1" weight="medium">Setup cross signing</Text>,
() => <CrossSigningSetup />,
<Text variant="s1" weight="medium">
Setup cross signing
</Text>,
() => <CrossSigningSetup />
);
};
function CrossSigningReset() {
return (
<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>
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,
unless you have lost <b>Security Key</b> or <b>Phrase</b> and
every session you can cross-sign from.
You almost certainly do not want to do this, unless you have lost <b>Security Key</b> or{' '}
<b>Phrase</b> and every session you can cross-sign from.
</Text>
<Button variant="danger" onClick={setupDialog}>Reset</Button>
<Button variant="danger" onClick={setupDialog}>
Reset
</Button>
</div>
);
}
const resetDialog = () => {
openReusableDialog(
<Text variant="s1" weight="medium">Reset cross signing</Text>,
() => <CrossSigningReset />,
<Text variant="s1" weight="medium">
Reset cross signing
</Text>,
() => <CrossSigningReset />
);
};
@ -210,12 +232,23 @@ function CrossSignin() {
return (
<SettingTile
title="Cross signing"
content={<Text variant="b3">Setup to verify and keep track of all your sessions. Also required to backup encrypted message.</Text>}
options={(
isCSEnabled
? <Button variant="danger" onClick={resetDialog}>Reset</Button>
: <Button variant="primary" onClick={setupDialog}>Setup</Button>
)}
content={
<Text variant="b3">
Setup to verify and keep track of all your sessions. Also required to backup encrypted
message.
</Text>
}
options={
isCSEnabled ? (
<Button variant="danger" onClick={resetDialog}>
Reset
</Button>
) : (
<Button variant="primary" onClick={setupDialog}>
Setup
</Button>
)
}
/>
);
}

View file

@ -2,7 +2,6 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import './KeyBackup.scss';
import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix';
import { openReusableDialog } from '../../../client/action/navigation';
@ -34,10 +33,7 @@ function CreateKeyBackupDialog({ keyData }) {
let info;
try {
info = await mx.prepareKeyBackupVersion(
null,
{ secureSecretStorage: true },
);
info = await mx.prepareKeyBackupVersion(null, { secureSecretStorage: true });
info = await mx.createKeyBackupVersion(info);
await mx.scheduleAllGroupSessionsForBackup();
if (!mountStore.getItem()) return;
@ -65,7 +61,7 @@ function CreateKeyBackupDialog({ keyData }) {
)}
{done === true && (
<>
<Text variant="h1">{twemojify('✅')}</Text>
<Text variant="h1"></Text>
<Text>Successfully created backup</Text>
</>
)}
@ -104,12 +100,9 @@ function RestoreKeyBackupDialog({ keyData }) {
try {
const backupInfo = await mx.getKeyBackupVersion();
const info = await mx.restoreKeyBackupWithSecretStorage(
backupInfo,
undefined,
undefined,
{ progressCallback },
);
const info = await mx.restoreKeyBackupWithSecretStorage(backupInfo, undefined, undefined, {
progressCallback,
});
if (!mountStore.getItem()) return;
setStatus({ done: `Successfully restored backup keys (${info.imported}/${info.total}).` });
} catch (e) {
@ -138,7 +131,7 @@ function RestoreKeyBackupDialog({ keyData }) {
)}
{status.done && (
<>
<Text variant="h1">{twemojify('✅')}</Text>
<Text variant="h1"></Text>
<Text>{status.done}</Text>
</>
)}
@ -176,14 +169,16 @@ function DeleteKeyBackupDialog({ requestClose }) {
return (
<div className="key-backup__delete">
<Text variant="h1">{twemojify('🗑')}</Text>
<Text variant="h1">🗑</Text>
<Text weight="medium">Deleting key backup is permanent.</Text>
<Text>All encrypted messages keys stored on server will be deleted.</Text>
{
isDeleting
? <Spinner size="small" />
: <Button variant="danger" onClick={deleteBackup}>Delete</Button>
}
{isDeleting ? (
<Spinner size="small" />
) : (
<Button variant="danger" onClick={deleteBackup}>
Delete
</Button>
)}
</div>
);
}
@ -224,9 +219,11 @@ function KeyBackup() {
if (keyData === null) return;
openReusableDialog(
<Text variant="s1" weight="medium">Create Key Backup</Text>,
<Text variant="s1" weight="medium">
Create Key Backup
</Text>,
() => <CreateKeyBackupDialog keyData={keyData} />,
() => fetchKeyBackupVersion(),
() => fetchKeyBackupVersion()
);
};
@ -235,29 +232,44 @@ function KeyBackup() {
if (keyData === null) return;
openReusableDialog(
<Text variant="s1" weight="medium">Restore Key Backup</Text>,
() => <RestoreKeyBackupDialog keyData={keyData} />,
<Text variant="s1" weight="medium">
Restore Key Backup
</Text>,
() => <RestoreKeyBackupDialog keyData={keyData} />
);
};
const openDeleteKeyBackup = () => openReusableDialog(
<Text variant="s1" weight="medium">Delete Key Backup</Text>,
(requestClose) => (
<DeleteKeyBackupDialog
requestClose={(isDone) => {
if (isDone) setKeyBackup(null);
requestClose();
}}
/>
),
);
const openDeleteKeyBackup = () =>
openReusableDialog(
<Text variant="s1" weight="medium">
Delete Key Backup
</Text>,
(requestClose) => (
<DeleteKeyBackupDialog
requestClose={(isDone) => {
if (isDone) setKeyBackup(null);
requestClose();
}}
/>
)
);
const renderOptions = () => {
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 (
<>
<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" />
</>
);
@ -266,9 +278,12 @@ function KeyBackup() {
return (
<SettingTile
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 && (
<InfoCard
style={{ marginTop: 'var(--sp-ultra-tight)' }}
@ -279,7 +294,7 @@ function KeyBackup() {
/>
)}
</>
)}
}
options={isCSEnabled ? renderOptions() : null}
/>
);

View file

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

View file

@ -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