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>
"@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",
"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",
"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",
"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": {
"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",
"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",
"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": "*",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"@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",
"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",
"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",
"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": {
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';
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>
);
});
+++ /dev/null
-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;
+++ /dev/null
-.katex-display {
- margin: 0 !important;
-}
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';
} 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';
} from './msgContent';
import colorMXID from '../../../util/colorMXID';
import {
+ getAllParents,
getMemberDisplayName,
parseReplyBody,
parseReplyFormattedBody,
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;
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));
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] =
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,
import {
canEditEvent,
decryptAllTimelineEvent,
+ getAllParents,
getEditedEvent,
getEventReactions,
getLatestEditableEvt,
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) => (
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>();
// 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(() => {
+++ /dev/null
-/* 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];
-}
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]: {
--- /dev/null
+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;
+};
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;
};
+++ /dev/null
-/* 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];
-}
+++ /dev/null
-/* 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];
-}
+++ /dev/null
-/* 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];
-}
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 (
<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>
+++ /dev/null
-/* 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;
+++ /dev/null
-@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);
- }
-}
\ No newline at end of file
+++ /dev/null
-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;
+++ /dev/null
-@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);
- }
-}
\ No newline at end of file
+++ /dev/null
-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,
-};
+++ /dev/null
-@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%;
- }
-}
+++ /dev/null
-/* 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 };
+++ /dev/null
-@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;
- }
- }
-}
+++ /dev/null
-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;
+++ /dev/null
-@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;
- }
-}
\ No newline at end of file
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
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>
);
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';
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>
};
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;
{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>
<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>
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';
-
-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,
-}];
+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,
+ },
+];
function setRoomNotifType(roomId, newType) {
const mx = initMatrix.matrixClient;
- const { notifications } = initMatrix;
let roomPushRule;
try {
roomPushRule = mx.getRoomPushRule('global', roomId);
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));
}
}
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));
}
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]);
-
- const setNotification = useCallback((item) => {
- if (item.type === activeType.type) return;
- setActiveType(item.type);
- setRoomNotifType(roomId, item.type);
- }, [activeType, 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]
+ );
return [activeType, setNotification];
}
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>
);
}
+++ /dev/null
-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;
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';
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();
});
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;
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,
'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 }, '');
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>
)}
);
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"
)}
</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)}
+++ /dev/null
-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;
+++ /dev/null
-@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;
- }
- }
- }
-}
\ No newline at end of file
import PropTypes from 'prop-types';
import './RoomSelector.scss';
-import { twemojify } from '../../../util/twemojify';
import colorMXID from '../../../util/colorMXID';
import Text from '../../atoms/text/Text';
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');
};
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}
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}
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,
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>
);
}
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,
};
+++ /dev/null
-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;
+++ /dev/null
-@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);
- }
-}
\ No newline at end of file
-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';
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);
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];
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'}
+++ /dev/null
-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;
+++ /dev/null
-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;
+++ /dev/null
-.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);
- }
- }
-}
\ No newline at end of file
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';
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);
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;
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');
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 (
<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 can’t disable this later. Bridges & most bots won’t work yet.</Text>}
+ content={
+ <Text variant="b3">
+ You can’t disable this later. Bridges & most bots won’t work yet.
+ </Text>
+ }
/>
)}
<SettingTile
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">
<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>
);
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>
);
}
+++ /dev/null
-/* 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;
+++ /dev/null
-@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);
- }
- }
-}
\ No newline at end of file
+++ /dev/null
-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;
-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;
}
}
-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,
-};
+++ /dev/null
-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,
-};
+++ /dev/null
-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),
- });
-}
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';
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');
{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>
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>
);
}
<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>
);
<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>
);
}
+++ /dev/null
-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;
+++ /dev/null
-@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));
- }
-}
\ No newline at end of file
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';
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([]);
const usernameRef = useRef(null);
const mx = initMatrix.matrixClient;
+ const { navigateRoom } = useRoomNavigate();
function getMapCopy(myMap) {
const newMap = new Map();
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!` });
}
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;
}
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);
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)) {
}
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);
}
}
}
- 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) => {
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)}
};
}, [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>
);
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';
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);
} 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;
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>
);
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>
);
}
+++ /dev/null
-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;
+++ /dev/null
-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;
+++ /dev/null
-@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));
- }
-}
+++ /dev/null
-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;
+++ /dev/null
-@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);
- }
-}
\ No newline at end of file
+++ /dev/null
-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;
+++ /dev/null
-@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;
- }
-}
\ No newline at end of file
+++ /dev/null
-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;
+++ /dev/null
-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;
+++ /dev/null
-.navigation {
- width: 100%;
- height: 100%;
- background-color: var(--bg-surface-low);
-
- display: flex;
-}
\ No newline at end of file
+++ /dev/null
-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;
+++ /dev/null
-@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);
- }
-}
\ No newline at end of file
+++ /dev/null
-/* 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;
+++ /dev/null
-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;
+++ /dev/null
-@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);
- }
-}
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';
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);
'Remove avatar',
'Are you sure that you want to remove avatar?',
'Remove',
- 'caution',
+ 'caution'
);
if (isConfirmed) {
mx.setAvatarUrl('');
<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()}`}
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>
);
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"
onUpload={handleAvatarUpload}
onRequestRemove={() => handleAvatarUpload(null)}
/>
- {
- isEditing ? renderForm() : renderInfo()
- }
+ {isEditing ? renderForm() : renderInfo()}
</div>
);
}
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';
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();
<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>
);
}
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>
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';
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);
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;
}
// 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);
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
onClick={toggleIgnore}
disabled={isIgnoring}
>
- {
- isUserIgnored
- ? `${isIgnoring ? 'Unignoring...' : 'Unignore'}`
- : `${isIgnoring ? 'Ignoring...' : 'Ignore'}`
- }
+ {isUserIgnored
+ ? `${isIgnoring ? 'Unignoring...' : 'Unignore'}`
+ : `${isIgnoring ? 'Ignoring...' : 'Ignore'}`}
</Button>
</div>
);
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();
}
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();
'Change power level',
isSharedPower ? SHARED_POWER_MSG : DEMOTING_MYSELF_MSG,
'Change',
- 'caution',
+ 'caution'
);
if (!isConfirmed) return;
roomActions.setPowerLevel(roomId, userId, newPowerLevel);
};
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 (
<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>
</div>
<ModerationTools roomId={roomId} userId={userId} />
<SessionInfo userId={userId} />
- { userId !== mx.getUserId() && (
+ {userId !== mx.getUserId() && (
<ProfileFooter roomId={roomId} userId={userId} onRequestClose={closeDialog} />
)}
</div>
+++ /dev/null
-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;
+++ /dev/null
-@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);
- }
-}
\ No newline at end of file
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';
function Dialogs() {
return (
<>
- <ReadReceipts />
- <ViewSource />
<ProfileViewer />
- <ShortcutSpaces />
<CreateRoom />
<JoinAlias />
<SpaceAddExisting />
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,
}
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}
<Settings />
<SpaceSettings />
<RoomSettings />
- <SpaceManage />
</>
);
}
+++ /dev/null
-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;
+++ /dev/null
-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;
+++ /dev/null
-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;
+++ /dev/null
-@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%;
- }
-}
\ No newline at end of file
+++ /dev/null
-@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;
- }
-}
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';
'danger'
);
if (!isConfirmed) return;
- roomActions.leave(roomId);
+ mx.leave(roomId);
}}
iconSrc={LeaveArrowIC}
>
+++ /dev/null
-@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);
- }
-}
+++ /dev/null
-/* 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;
+++ /dev/null
-@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;
- }
-}
\ No newline at end of file
+++ /dev/null
-/* 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;
+++ /dev/null
-@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);
- }
- }
-}
\ No newline at end of file
+++ /dev/null
-/* 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;
+++ /dev/null
- @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;
- }
- }
-}
\ No newline at end of file
+++ /dev/null
-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;
+++ /dev/null
-@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;
- }
-}
+++ /dev/null
-/* 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;
+++ /dev/null
-@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;
- }
-}
\ No newline at end of file
+++ /dev/null
-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;
+++ /dev/null
-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;
+++ /dev/null
-.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);
- }
- }
-}
\ No newline at end of file
+++ /dev/null
-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,
-};
import React, { useState, useEffect, useRef } from 'react';
+import { useAtomValue } from 'jotai';
import './Search.scss';
import initMatrix from '../../../client/initMatrix';
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);
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);
let type = 'room';
if (room.isSpaceRoom()) type = 'space';
- else if (directs.has(roomId)) type = 'direct';
+ else if (directs.includes(roomId)) type = 'direct';
return {
type,
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({
return;
}
- const { spaces, rooms, directs } = initMatrix.roomList;
let ids = null;
if (prefix) {
}
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 = () => {
}
};
- const noti = initMatrix.notifications;
const renderRoomSelector = (item) => {
let imageSrc = null;
let iconSrc = null;
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)}
/>
);
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';
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
);
};
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>
downloadKey();
openReusableDialog(
- <Text variant="s1" weight="medium">Security Key</Text>,
- () => renderSecurityKey(),
+ <Text variant="s1" weight="medium">
+ Security Key
+ </Text>,
+ () => renderSecurityKey()
);
};
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;
};
<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>
onSubmit={(values) => setup(values.phrase)}
validate={validator}
>
- {({
- values, errors, handleChange, handleSubmit,
- }) => (
+ {({ values, errors, handleChange, handleSubmit }) => (
<form
className="cross-signing__setup-entry"
onSubmit={handleSubmit}
>
<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"
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}
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>
)}
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.
+ 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.
</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 />
);
};
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>
+ )
+ }
/>
);
}
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';
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;
)}
{done === true && (
<>
- <Text variant="h1">{twemojify('✅')}</Text>
+ <Text variant="h1">✅</Text>
<Text>Successfully created backup</Text>
</>
)}
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) {
)}
{status.done && (
<>
- <Text variant="h1">{twemojify('✅')}</Text>
+ <Text variant="h1">✅</Text>
<Text>{status.done}</Text>
</>
)}
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>
);
}
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()
);
};
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" />
</>
);
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)' }}
/>
)}
</>
- )}
+ }
options={isCSEnabled ? renderOptions() : null}
/>
);
+++ /dev/null
-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;
+++ /dev/null
-@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);
- }
- }
-}
+++ /dev/null
-/* eslint-disable react/prop-types */
-import React, { useState, useEffect } from 'react';
-import PropTypes from 'prop-types';
-import './SpaceManage.scss';
-
-import { twemojify } from '../../../util/twemojify';
-
-import initMatrix from '../../../client/initMatrix';
-import cons from '../../../client/state/cons';
-import navigation from '../../../client/state/navigation';
-import colorMXID from '../../../util/colorMXID';
-import { selectRoom, selectTab } from '../../../client/action/navigation';
-import RoomsHierarchy from '../../../client/state/RoomsHierarchy';
-import { joinRuleToIconSrc } from '../../../util/matrixUtil';
-import { join } from '../../../client/action/room';
-import { Debounce } from '../../../util/common';
-
-import Text from '../../atoms/text/Text';
-import RawIcon from '../../atoms/system-icons/RawIcon';
-import Button from '../../atoms/button/Button';
-import IconButton from '../../atoms/button/IconButton';
-import Checkbox from '../../atoms/button/Checkbox';
-import Avatar from '../../atoms/avatar/Avatar';
-import Spinner from '../../atoms/spinner/Spinner';
-import ScrollView from '../../atoms/scroll/ScrollView';
-import PopupWindow from '../../molecules/popup-window/PopupWindow';
-
-import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
-import ChevronRightIC from '../../../../public/res/ic/outlined/chevron-right.svg';
-import InfoIC from '../../../../public/res/ic/outlined/info.svg';
-
-import { useForceUpdate } from '../../hooks/useForceUpdate';
-import { useStore } from '../../hooks/useStore';
-
-function SpaceManageBreadcrumb({ path, onSelect }) {
- return (
- <div className="space-manage-breadcrumb__wrapper">
- <ScrollView horizontal vertical={false} invisible>
- <div className="space-manage-breadcrumb">
- {
- path.map((item, index) => (
- <React.Fragment key={item.roomId}>
- {index > 0 && <RawIcon size="extra-small" src={ChevronRightIC} />}
- <Button onClick={() => onSelect(item.roomId, item.name)}>
- <Text variant="b2">{twemojify(item.name)}</Text>
- </Button>
- </React.Fragment>
- ))
- }
- </div>
- </ScrollView>
- </div>
- );
-}
-SpaceManageBreadcrumb.propTypes = {
- path: PropTypes.arrayOf(PropTypes.exact({
- roomId: PropTypes.string,
- name: PropTypes.string,
- })).isRequired,
- onSelect: PropTypes.func.isRequired,
-};
-
-function SpaceManageItem({
- parentId, roomInfo, onSpaceClick, requestClose,
- isSelected, onSelect, roomHierarchy,
-}) {
- const [isExpand, setIsExpand] = useState(false);
- const [isJoining, setIsJoining] = useState(false);
-
- const { directs } = initMatrix.roomList;
- const mx = initMatrix.matrixClient;
- const parentRoom = mx.getRoom(parentId);
- const isSpace = roomInfo.room_type === 'm.space';
- const roomId = roomInfo.room_id;
- const canManage = parentRoom?.currentState.maySendStateEvent('m.space.child', mx.getUserId()) || false;
- const isSuggested = parentRoom?.currentState.getStateEvents('m.space.child', roomId)?.getContent().suggested === true;
-
- const room = mx.getRoom(roomId);
- const isJoined = !!(room?.getMyMembership() === 'join' || null);
- const name = room?.name || roomInfo.name || roomInfo.canonical_alias || roomId;
- let imageSrc = mx.mxcUrlToHttp(roomInfo.avatar_url, 24, 24, 'crop') || null;
- if (!imageSrc && room) {
- imageSrc = room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
- if (imageSrc === null) imageSrc = room.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
- }
- const isDM = directs.has(roomId);
-
- const handleOpen = () => {
- if (isSpace) selectTab(roomId);
- else selectRoom(roomId);
- requestClose();
- };
- const handleJoin = () => {
- const viaSet = roomHierarchy.viaMap.get(roomId);
- const via = viaSet ? [...viaSet] : undefined;
- join(roomId, false, via);
- setIsJoining(true);
- };
-
- const roomAvatarJSX = (
- <Avatar
- text={name}
- bgColor={colorMXID(roomId)}
- imageSrc={isDM ? imageSrc : null}
- iconColor="var(--ic-surface-low)"
- iconSrc={
- isDM
- ? null
- : joinRuleToIconSrc((roomInfo.join_rules || roomInfo.join_rule), isSpace)
- }
- size="extra-small"
- />
- );
- const roomNameJSX = (
- <Text>
- {twemojify(name)}
- <Text variant="b3" span>{` • ${roomInfo.num_joined_members} members`}</Text>
- </Text>
- );
-
- const expandBtnJsx = (
- <IconButton
- variant={isExpand ? 'primary' : 'surface'}
- size="extra-small"
- src={InfoIC}
- tooltip="Topic"
- tooltipPlacement="top"
- onClick={() => setIsExpand(!isExpand)}
- />
- );
-
- return (
- <div
- className={`space-manage-item${isSpace ? '--space' : ''}`}
- >
- <div>
- {canManage && <Checkbox isActive={isSelected} onToggle={() => onSelect(roomId)} variant="positive" />}
- <button
- className="space-manage-item__btn"
- onClick={isSpace ? () => onSpaceClick(roomId, name) : null}
- type="button"
- >
- {roomAvatarJSX}
- {roomNameJSX}
- {isSuggested && <Text variant="b2">Suggested</Text>}
- </button>
- {roomInfo.topic && expandBtnJsx}
- {
- isJoined
- ? <Button onClick={handleOpen}>Open</Button>
- : <Button variant="primary" onClick={handleJoin} disabled={isJoining}>{isJoining ? 'Joining...' : 'Join'}</Button>
- }
- </div>
- {isExpand && roomInfo.topic && <Text variant="b2">{twemojify(roomInfo.topic, undefined, true)}</Text>}
- </div>
- );
-}
-SpaceManageItem.propTypes = {
- parentId: PropTypes.string.isRequired,
- roomHierarchy: PropTypes.shape({}).isRequired,
- roomInfo: PropTypes.shape({}).isRequired,
- onSpaceClick: PropTypes.func.isRequired,
- requestClose: PropTypes.func.isRequired,
- isSelected: PropTypes.bool.isRequired,
- onSelect: PropTypes.func.isRequired,
-};
-
-function SpaceManageFooter({ parentId, selected }) {
- const [process, setProcess] = useState(null);
- const mx = initMatrix.matrixClient;
- const room = mx.getRoom(parentId);
- const { currentState } = room;
-
- const allSuggested = selected.every((roomId) => {
- const sEvent = currentState.getStateEvents('m.space.child', roomId);
- return !!sEvent?.getContent()?.suggested;
- });
-
- const handleRemove = () => {
- setProcess(`Removing ${selected.length} items`);
- selected.forEach((roomId) => {
- mx.sendStateEvent(parentId, 'm.space.child', {}, roomId);
- });
- };
-
- const handleToggleSuggested = (isMark) => {
- if (isMark) setProcess(`Marking as suggested ${selected.length} items`);
- else setProcess(`Marking as not suggested ${selected.length} items`);
- selected.forEach((roomId) => {
- const sEvent = room.currentState.getStateEvents('m.space.child', roomId);
- if (!sEvent) return;
- const content = { ...sEvent.getContent() };
- if (isMark && content.suggested) return;
- if (!isMark && !content.suggested) return;
- content.suggested = isMark;
- mx.sendStateEvent(parentId, 'm.space.child', content, roomId);
- });
- };
-
- return (
- <div className="space-manage__footer">
- {process && <Spinner size="small" />}
- <Text weight="medium">{process || `${selected.length} item selected`}</Text>
- { !process && (
- <>
- <Button onClick={handleRemove} variant="danger">Remove</Button>
- <Button
- onClick={() => handleToggleSuggested(!allSuggested)}
- variant={allSuggested ? 'surface' : 'primary'}
- >
- {allSuggested ? 'Mark as not suggested' : 'Mark as suggested'}
- </Button>
- </>
- )}
- </div>
- );
-}
-SpaceManageFooter.propTypes = {
- parentId: PropTypes.string.isRequired,
- selected: PropTypes.arrayOf(PropTypes.string).isRequired,
-};
-
-function useSpacePath(roomId) {
- const mx = initMatrix.matrixClient;
- const room = mx.getRoom(roomId);
- const [spacePath, setSpacePath] = useState([{ roomId, name: room.name }]);
-
- const addPathItem = (rId, name) => {
- const newPath = [...spacePath];
- const itemIndex = newPath.findIndex((item) => item.roomId === rId);
- if (itemIndex < 0) {
- newPath.push({ roomId: rId, name });
- setSpacePath(newPath);
- return;
- }
- newPath.splice(itemIndex + 1);
- setSpacePath(newPath);
- };
-
- return [spacePath, addPathItem];
-}
-
-function useUpdateOnJoin(roomId) {
- const [, forceUpdate] = useForceUpdate();
- const { roomList } = initMatrix;
-
- useEffect(() => {
- const handleRoomList = () => forceUpdate();
-
- roomList.on(cons.events.roomList.ROOM_JOINED, handleRoomList);
- roomList.on(cons.events.roomList.ROOM_LEAVED, handleRoomList);
- return () => {
- roomList.removeListener(cons.events.roomList.ROOM_JOINED, handleRoomList);
- roomList.removeListener(cons.events.roomList.ROOM_LEAVED, handleRoomList);
- };
- }, [roomId]);
-}
-
-function useChildUpdate(roomId, roomsHierarchy) {
- const [, forceUpdate] = useForceUpdate();
- const [debounce] = useState(new Debounce());
- const mx = initMatrix.matrixClient;
-
- useEffect(() => {
- let isMounted = true;
- const handleStateEvent = (event) => {
- if (event.getRoomId() !== roomId) return;
- if (event.getType() !== 'm.space.child') return;
-
- debounce._(() => {
- if (!isMounted) return;
- roomsHierarchy.removeHierarchy(roomId);
- forceUpdate();
- }, 500)();
- };
- mx.on('RoomState.events', handleStateEvent);
- return () => {
- isMounted = false;
- mx.removeListener('RoomState.events', handleStateEvent);
- };
- }, [roomId, roomsHierarchy]);
-}
-
-function SpaceManageContent({ roomId, requestClose }) {
- const mx = initMatrix.matrixClient;
- useUpdateOnJoin(roomId);
- const [, forceUpdate] = useForceUpdate();
- const [roomsHierarchy] = useState(new RoomsHierarchy(mx, 30));
- const [spacePath, addPathItem] = useSpacePath(roomId);
- const [isLoading, setIsLoading] = useState(true);
- const [selected, setSelected] = useState([]);
- const mountStore = useStore();
- const currentPath = spacePath[spacePath.length - 1];
- useChildUpdate(currentPath.roomId, roomsHierarchy);
-
- const currentHierarchy = roomsHierarchy.getHierarchy(currentPath.roomId);
-
- useEffect(() => {
- mountStore.setItem(true);
- return () => {
- mountStore.setItem(false);
- };
- }, [roomId]);
-
- useEffect(() => {
- setSelected([]);
- }, [spacePath]);
-
- const handleSelected = (selectedRoomId) => {
- const newSelected = [...selected];
- const selectedIndex = newSelected.indexOf(selectedRoomId);
-
- if (selectedIndex > -1) {
- newSelected.splice(selectedIndex, 1);
- setSelected(newSelected);
- return;
- }
- newSelected.push(selectedRoomId);
- setSelected(newSelected);
- };
-
- const loadRoomHierarchy = async () => {
- if (!roomsHierarchy.canLoadMore(currentPath.roomId)) return;
- if (!roomsHierarchy.getHierarchy(currentPath.roomId)) setSelected([]);
- setIsLoading(true);
- try {
- await roomsHierarchy.load(currentPath.roomId);
- if (!mountStore.getItem()) return;
- setIsLoading(false);
- forceUpdate();
- } catch {
- if (!mountStore.getItem()) return;
- setIsLoading(false);
- forceUpdate();
- }
- };
-
- if (!currentHierarchy) loadRoomHierarchy();
- return (
- <div className="space-manage__content">
- {spacePath.length > 1 && (
- <SpaceManageBreadcrumb path={spacePath} onSelect={addPathItem} />
- )}
- <Text variant="b3" weight="bold">Rooms and spaces</Text>
- <div className="space-manage__content-items">
- {!isLoading && currentHierarchy?.rooms?.length === 1 && (
- <Text>
- Either the space contains private rooms or you need to join space to view it's rooms.
- </Text>
- )}
- {currentHierarchy && (currentHierarchy.rooms?.map((roomInfo) => (
- roomInfo.room_id === currentPath.roomId
- ? null
- : (
- <SpaceManageItem
- key={roomInfo.room_id}
- isSelected={selected.includes(roomInfo.room_id)}
- roomHierarchy={currentHierarchy}
- parentId={currentPath.roomId}
- roomInfo={roomInfo}
- onSpaceClick={addPathItem}
- requestClose={requestClose}
- onSelect={handleSelected}
- />
- )
- )))}
- {!currentHierarchy && <Text>loading...</Text>}
- </div>
- {currentHierarchy?.canLoadMore && !isLoading && (
- <Button onClick={loadRoomHierarchy}>Load more</Button>
- )}
- {isLoading && (
- <div className="space-manage__content-loading">
- <Spinner size="small" />
- <Text>Loading rooms...</Text>
- </div>
- )}
- {selected.length > 0 && (
- <SpaceManageFooter parentId={currentPath.roomId} selected={selected} />
- )}
- </div>
- );
-}
-SpaceManageContent.propTypes = {
- roomId: PropTypes.string.isRequired,
- requestClose: PropTypes.func.isRequired,
-};
-
-function useWindowToggle() {
- const [roomId, setRoomId] = useState(null);
-
- useEffect(() => {
- const openSpaceManage = (rId) => {
- setRoomId(rId);
- };
- navigation.on(cons.events.navigation.SPACE_MANAGE_OPENED, openSpaceManage);
- return () => {
- navigation.removeListener(cons.events.navigation.SPACE_MANAGE_OPENED, openSpaceManage);
- };
- }, []);
-
- const requestClose = () => setRoomId(null);
-
- return [roomId, requestClose];
-}
-function SpaceManage() {
- const mx = initMatrix.matrixClient;
- const [roomId, requestClose] = useWindowToggle();
- const room = mx.getRoom(roomId);
-
- return (
- <PopupWindow
- isOpen={roomId !== null}
- className="space-manage"
- title={(
- <Text variant="s1" weight="medium" primary>
- {roomId && twemojify(room.name)}
- <span style={{ color: 'var(--tc-surface-low)' }}> — manage rooms</span>
- </Text>
- )}
- contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
- onRequestClose={requestClose}
- >
- {
- roomId
- ? <SpaceManageContent roomId={roomId} requestClose={requestClose} />
- : <div />
- }
- </PopupWindow>
- );
-}
-
-export default SpaceManage;
+++ /dev/null
-@use '../../partials/text';
-@use '../../partials/dir';
-@use '../../partials/flex';
-
-.space-manage {
- & .pw__content-wrapper {
- position: relative;
- }
- & .pw__content-container {
- padding-top: 0;
- padding-bottom: 73px;
- }
-}
-
-.space-manage__content {
- margin-bottom: var(--sp-extra-loose);
-
- & > .text {
- margin-top: var(--sp-extra-tight);
- padding: var(--sp-extra-tight) var(--sp-normal);
- text-transform: uppercase;
- }
-
- &-items {
- @include dir.side(padding, var(--sp-extra-tight), 0);
- & > .text:first-child {
- padding: var(--sp-extra-tight);
- }
- }
-
- & > button {
- margin: var(--sp-normal);
- }
-
- &-loading {
- padding: var(--sp-loose);
- display: flex;
- justify-content: center;
- align-items: center;
- & .text {
- margin: 0 var(--sp-normal);
- }
- }
-}
-.space-manage-breadcrumb {
- display: flex;
- align-items: center;
- height: 100%;
- margin: 0 var(--sp-extra-tight);
-
- &__wrapper {
- height: var(--header-height);
- position: sticky;
- top: 0;
- z-index: 99;
- background-color: var(--bg-surface);
- }
- & > * {
- 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: 200px;
- }
- &:last-child {
- box-shadow: var(--bs-surface-border) !important;
- background-color: var(--bg-surface);
- }
- }
-
-}
-
-.space-manage-item {
- margin: var(--sp-ultra-tight) var(--sp-extra-tight);
- padding: 0 var(--sp-extra-tight);
- border-radius: var(--bo-radius);
-
- & > div {
- min-height: 40px;
- display: flex;
- align-items: center;
- }
-
- &--space {
- @extend .space-manage-item;
- & .space-manage-item__btn {
- cursor: pointer;
- }
- }
-
- &:hover {
- background-color: var(--bg-surface-hover);
- }
-
- & .checkbox {
- @include dir.side(margin, 0, var(--sp-tight));
- }
-
-
- &__btn {
- @extend .cp-fx__item-one;
- display: flex;
- align-items: center;
-
- & .avatar__border--active {
- box-shadow: none;
- }
- & > .text-b1 {
- @extend .cp-fx__item-one;
- @extend .cp-txt__ellipsis;
- min-width: 0;
- margin: 0 var(--sp-extra-tight);
- }
- & > .text-b2 {
- margin: 0 var(--sp-extra-tight);
- padding: 1px var(--sp-ultra-tight);
- color: var(--bg-positive);
- box-shadow: var(--bs-positive-border);
- border-radius: 4px;
- }
- }
-
- & .ic-btn {
- padding: 7px;
- @include dir.side(margin, 0, var(--sp-extra-tight));
- opacity: 0.7;
- }
-
- & .btn-surface,
- & .btn-primary {
- padding: var(--sp-ultra-tight) var(--sp-extra-tight);
- min-width: 60px;
- }
-
- & > .text {
- padding: 32px;
- padding-top: 0;
- padding-bottom: var(--sp-normal);
- white-space: pre-wrap;
- }
-}
-
-.space-manage__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);
- }
-}
\ No newline at end of file
import PropTypes from 'prop-types';
import './SpaceSettings.scss';
-import { twemojify } from '../../../util/twemojify';
-
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
-import { leave } from '../../../client/action/room';
import Text from '../../atoms/text/Text';
import IconButton from '../../atoms/button/IconButton';
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
const tabText = {
GENERAL: 'General',
function GeneralSettings({ roomId }) {
const roomName = initMatrix.matrixClient.getRoom(roomId)?.name;
+ const mx = useMatrixClient();
return (
<>
'Leave',
'danger'
);
- if (isConfirmed) leave(roomId);
+ if (isConfirmed) mx.leave(roomId);
}}
iconSrc={LeaveArrowIC}
>
className="space-settings"
title={
<Text variant="s1" weight="medium" primary>
- {isOpen && twemojify(room.name)}
+ {isOpen && room.name}
<span style={{ color: 'var(--tc-surface-low)' }}> — space settings</span>
</Text>
}
+++ /dev/null
-/* eslint-disable jsx-a11y/click-events-have-key-events */
-/* eslint-disable jsx-a11y/no-static-element-interactions */
-import React, { useRef } from 'react';
-import PropTypes from 'prop-types';
-import './StickerBoard.scss';
-
-import initMatrix from '../../../client/initMatrix';
-import { getRelevantPacks } from '../emoji-board/custom-emoji';
-
-import Text from '../../atoms/text/Text';
-import ScrollView from '../../atoms/scroll/ScrollView';
-import IconButton from '../../atoms/button/IconButton';
-
-function StickerBoard({ roomId, onSelect }) {
- const mx = initMatrix.matrixClient;
- const room = mx.getRoom(roomId);
- const scrollRef = useRef(null);
-
- const parentIds = initMatrix.roomList.getAllParentSpaces(room.roomId);
- const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
-
- const packs = getRelevantPacks(
- mx,
- [room, ...parentRooms],
- ).filter((pack) => pack.getStickers().length !== 0);
-
- function isTargetNotSticker(target) {
- return target.classList.contains('sticker-board__sticker') === false;
- }
- function getStickerData(target) {
- const mxc = target.getAttribute('data-mx-sticker');
- const body = target.getAttribute('title');
- const httpUrl = target.getAttribute('src');
- return { mxc, body, httpUrl };
- }
- const handleOnSelect = (e) => {
- if (isTargetNotSticker(e.target)) return;
-
- const stickerData = getStickerData(e.target);
- onSelect(stickerData);
- };
-
- const openGroup = (groupIndex) => {
- const scrollContent = scrollRef.current.firstElementChild;
- scrollContent.children[groupIndex].scrollIntoView();
- };
-
- const renderPack = (pack) => (
- <div className="sticker-board__pack" key={pack.id}>
- <Text className="sticker-board__pack-header" variant="b2" weight="bold">{pack.displayName ?? 'Unknown'}</Text>
- <div className="sticker-board__pack-items">
- {pack.getStickers().map((sticker) => (
- <img
- key={sticker.shortcode}
- className="sticker-board__sticker"
- src={mx.mxcUrlToHttp(sticker.mxc)}
- alt={sticker.shortcode}
- title={sticker.body ?? sticker.shortcode}
- data-mx-sticker={sticker.mxc}
- loading="lazy"
- />
- ))}
- </div>
- </div>
- );
-
- return (
- <div className="sticker-board">
- {packs.length > 0 && (
- <ScrollView invisible>
- <div className="sticker-board__sidebar">
- {packs.map((pack, index) => {
- const src = mx.mxcUrlToHttp(pack.avatarUrl ?? pack.getStickers()[0].mxc);
- return (
- <IconButton
- key={pack.id}
- onClick={() => openGroup(index)}
- src={src}
- tooltip={pack.displayName || 'Unknown'}
- tooltipPlacement="left"
- isImage
- />
- );
- })}
- </div>
- </ScrollView>
- )}
- <div className="sticker-board__container">
- <ScrollView autoHide ref={scrollRef}>
- <div
- onClick={handleOnSelect}
- className="sticker-board__content"
- >
- {
- packs.length > 0
- ? packs.map(renderPack)
- : (
- <div className="sticker-board__empty">
- <Text>There is no sticker pack.</Text>
- </div>
- )
- }
- </div>
- </ScrollView>
- </div>
- <div />
- </div>
- );
-}
-StickerBoard.propTypes = {
- roomId: PropTypes.string.isRequired,
- onSelect: PropTypes.func.isRequired,
-};
-
-export default StickerBoard;
+++ /dev/null
-@use '../../partials/dir';
-
-.sticker-board {
- --sticker-board-height: 390px;
- --sticker-board-width: 286px;
- display: flex;
- height: var(--sticker-board-height);
- display: flex;
-
- & > .scrollbar {
- width: initial;
- height: var(--sticker-board-height);
- }
-
- &__sidebar {
- display: flex;
- flex-direction: column;
- min-height: 100%;
- padding: 4px 6px;
- @include dir.side(border, none, 1px solid var(--bg-surface-border));
- }
-
- &__container {
- flex-grow: 1;
- min-width: 0;
- width: var(--sticker-board-width);
- display: flex;
- }
-
- &__content {
- min-height: 100%;
- }
-
- &__pack {
- margin-bottom: var(--sp-normal);
- position: relative;
-
- &-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);
- }
- &-items {
- margin: var(--sp-tight);
- @include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
- display: flex;
- flex-wrap: wrap;
- gap: var(--sp-normal) var(--sp-tight);
-
- img {
- width: 76px;
- height: 76px;
- object-fit: contain;
- cursor: pointer;
- }
- }
- }
-
- &__empty {
- width: 100%;
- height: var(--sticker-board-height);
- display: flex;
- justify-content: center;
- align-items: center;
- text-align: center;
- }
-}
\ No newline at end of file
+++ /dev/null
-import React, { useEffect, useState } from 'react';
-import PropTypes from 'prop-types';
-import './ViewSource.scss';
-
-import cons from '../../../client/state/cons';
-import navigation from '../../../client/state/navigation';
-
-import IconButton from '../../atoms/button/IconButton';
-import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
-import ScrollView from '../../atoms/scroll/ScrollView';
-import PopupWindow from '../../molecules/popup-window/PopupWindow';
-
-import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
-
-function ViewSourceBlock({ title, json }) {
- return (
- <div className="view-source__card">
- <MenuHeader>{title}</MenuHeader>
- <ScrollView horizontal vertical={false} autoHide>
- <pre className="text text-b1">
- <code className="language-json">
- {JSON.stringify(json, null, 2)}
- </code>
- </pre>
- </ScrollView>
- </div>
- );
-}
-ViewSourceBlock.propTypes = {
- title: PropTypes.string.isRequired,
- json: PropTypes.shape({}).isRequired,
-};
-
-function ViewSource() {
- const [isOpen, setIsOpen] = useState(false);
- const [event, setEvent] = useState(null);
-
- useEffect(() => {
- const loadViewSource = (e) => {
- setEvent(e);
- setIsOpen(true);
- };
- navigation.on(cons.events.navigation.VIEWSOURCE_OPENED, loadViewSource);
- return () => {
- navigation.removeListener(cons.events.navigation.VIEWSOURCE_OPENED, loadViewSource);
- };
- }, []);
-
- const handleAfterClose = () => {
- setEvent(null);
- };
-
- const renderViewSource = () => (
- <div className="view-source">
- {event.isEncrypted() && <ViewSourceBlock title="Decrypted source" json={event.getEffectiveEvent()} />}
- <ViewSourceBlock title="Original source" json={event.event} />
- </div>
- );
-
- return (
- <PopupWindow
- isOpen={isOpen}
- title="View source"
- onAfterClose={handleAfterClose}
- onRequestClose={() => setIsOpen(false)}
- contentOptions={<IconButton src={CrossIC} onClick={() => setIsOpen(false)} tooltip="Close" />}
- >
- {event ? renderViewSource() : <div />}
- </PopupWindow>
- );
-}
-
-export default ViewSource;
+++ /dev/null
-@use '../../partials/dir';
-
-.view-source {
- @include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
-
- & pre {
- padding: var(--sp-extra-tight);
- white-space: pre-wrap;
- word-break: break-all;
- }
-
- &__card {
- margin: var(--sp-normal) 0;
- background-color: var(--bg-surface-hover);
- border-radius: var(--bo-radius);
- box-shadow: var(--bs-surface-border);
- overflow: hidden;
- }
-}
+++ /dev/null
-import React from 'react';
-import './Welcome.scss';
-
-import Text from '../../atoms/text/Text';
-
-import CinnySvg from '../../../../public/res/svg/cinny.svg';
-
-function Welcome() {
- return (
- <div className="app-welcome flex--center">
- <div>
- <img className="app-welcome__logo noselect" src={CinnySvg} alt="Cinny logo" />
- <Text className="app-welcome__heading" variant="h1" weight="medium" primary>Welcome to Cinny</Text>
- <Text className="app-welcome__subheading" variant="s1">Yet another matrix client</Text>
- </div>
- </div>
- );
-}
-
-export default Welcome;
+++ /dev/null
-@use '../../partials/flex';
-
-.app-welcome {
- width: 100%;
- height: 100%;
- background-color: var(--bg-surface);
-
- & > div {
- @extend .cp-fx__column--c-c;
- max-width: 600px;
- }
- &__logo {
- width: 64px;
- height: 64px;
- }
- &__heading {
- margin: var(--sp-extra-loose) 0 var(--sp-tight);
- color: var(--tc-surface-high);
- }
- &__subheading {
- color: var(--tc-surface-normal);
- }
-}
\ No newline at end of file
import { ScreenSize } from '../hooks/useScreenSize';
import { MobileFriendlyPageNav, MobileFriendlyClientNav } from './MobileFriendly';
import { ClientInitStorageAtom } from './client/ClientInitStorageAtom';
+import { ClientNonUIFeatures } from './client/ClientNonUIFeatures';
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
const { hashRouter } = clientConfig;
<ClientRoot>
<ClientInitStorageAtom>
<ClientBindAtoms>
- <ClientLayout
- nav={
- <MobileFriendlyClientNav>
- <SidebarNav />
- </MobileFriendlyClientNav>
- }
- >
- <Outlet />
- </ClientLayout>
+ <ClientNonUIFeatures>
+ <ClientLayout
+ nav={
+ <MobileFriendlyClientNav>
+ <SidebarNav />
+ </MobileFriendlyClientNav>
+ }
+ >
+ <Outlet />
+ </ClientLayout>
+ </ClientNonUIFeatures>
</ClientBindAtoms>
</ClientInitStorageAtom>
</ClientRoot>
--- /dev/null
+import { useAtomValue } from 'jotai';
+import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
+import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
+import LogoSVG from '../../../../public/res/svg/cinny.svg';
+import LogoUnreadSVG from '../../../../public/res/svg/cinny-unread.svg';
+import LogoHighlightSVG from '../../../../public/res/svg/cinny-highlight.svg';
+import { setFavicon } from '../../utils/dom';
+import { useSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
+import { allInvitesAtom } from '../../state/room-list/inviteList';
+import { usePreviousValue } from '../../hooks/usePreviousValue';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { getInboxInvitesPath, getInboxNotificationsPath } from '../pathUtils';
+import {
+ getMemberDisplayName,
+ getNotificationType,
+ getUnreadInfo,
+ isNotificationEvent,
+} from '../../utils/room';
+import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
+import { getMxIdLocalPart } from '../../utils/matrix';
+import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
+import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
+
+function FaviconUpdater() {
+ const roomToUnread = useAtomValue(roomToUnreadAtom);
+
+ useEffect(() => {
+ if (roomToUnread.size === 0) {
+ setFavicon(LogoSVG);
+ } else {
+ const highlight = Array.from(roomToUnread.entries()).find(
+ ([, unread]) => unread.highlight > 0
+ );
+
+ setFavicon(highlight ? LogoHighlightSVG : LogoUnreadSVG);
+ }
+ }, [roomToUnread]);
+
+ return null;
+}
+
+function InviteNotifications() {
+ const audioRef = useRef<HTMLAudioElement>(null);
+ const invites = useAtomValue(allInvitesAtom);
+ const perviousInviteLen = usePreviousValue(invites.length, 0);
+ const mx = useMatrixClient();
+
+ const navigate = useNavigate();
+ const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
+
+ const notify = useCallback(
+ (count: number) => {
+ const noti = new window.Notification('Invitation', {
+ icon: LogoSVG,
+ badge: LogoSVG,
+ body: `You have ${count} new invitation request.`,
+ silent: true,
+ });
+
+ noti.onclick = () => {
+ if (!window.closed) navigate(getInboxInvitesPath());
+ noti.close();
+ };
+ },
+ [navigate]
+ );
+
+ const playSound = useCallback(() => {
+ const audioElement = audioRef.current;
+ audioElement?.play();
+ }, []);
+
+ useEffect(() => {
+ if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') {
+ if (Notification.permission === 'granted') {
+ notify(invites.length - perviousInviteLen);
+ }
+
+ if (notificationSound) {
+ playSound();
+ }
+ }
+ }, [mx, invites, perviousInviteLen, notificationSound, notify, playSound]);
+
+ return (
+ // eslint-disable-next-line jsx-a11y/media-has-caption
+ <audio ref={audioRef} style={{ display: 'none' }}>
+ <source src="../../../../public/sound/invite.ogg" type="audio/ogg" />
+ </audio>
+ );
+}
+
+function MessageNotifications() {
+ const audioRef = useRef<HTMLAudioElement>(null);
+ const notifRef = useRef<Notification>();
+ const unreadCacheRef = useRef<Map<string, UnreadInfo>>(new Map());
+ const mx = useMatrixClient();
+ const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
+ const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
+
+ const navigate = useNavigate();
+ const notificationSelected = useInboxNotificationsSelected();
+ const selectedRoomId = useSelectedRoom();
+
+ const notify = useCallback(
+ ({
+ roomName,
+ roomAvatar,
+ username,
+ }: {
+ roomName: string;
+ roomAvatar?: string;
+ username: string;
+ roomId: string;
+ eventId: string;
+ }) => {
+ const noti = new window.Notification(roomName, {
+ icon: roomAvatar,
+ badge: roomAvatar,
+ body: `New inbox notification from ${username}`,
+ silent: true,
+ });
+
+ noti.onclick = () => {
+ if (!window.closed) navigate(getInboxNotificationsPath());
+ noti.close();
+ notifRef.current = undefined;
+ };
+
+ notifRef.current?.close();
+ notifRef.current = noti;
+ },
+ [navigate]
+ );
+
+ const playSound = useCallback(() => {
+ const audioElement = audioRef.current;
+ audioElement?.play();
+ }, []);
+
+ useEffect(() => {
+ const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = (
+ mEvent,
+ room,
+ toStartOfTimeline,
+ removed,
+ data
+ ) => {
+ if (
+ mx.getSyncState() !== 'SYNCING' ||
+ selectedRoomId === room?.roomId ||
+ notificationSelected ||
+ !room ||
+ !data.liveEvent ||
+ room.isSpaceRoom() ||
+ !isNotificationEvent(mEvent) ||
+ getNotificationType(mx, room.roomId) === NotificationType.Mute
+ )
+ return;
+
+ const sender = mEvent.getSender();
+ const eventId = mEvent.getId();
+ if (!sender || !eventId || mEvent.getSender() === mx.getUserId()) return;
+ const unreadInfo = getUnreadInfo(room);
+ const cachedUnreadInfo = unreadCacheRef.current.get(room.roomId);
+ unreadCacheRef.current.set(room.roomId, unreadInfo);
+
+ if (
+ cachedUnreadInfo &&
+ unreadEqual(unreadInfoToUnread(cachedUnreadInfo), unreadInfoToUnread(unreadInfo))
+ ) {
+ return;
+ }
+
+ if (showNotifications && Notification.permission === 'granted') {
+ const avatarMxc =
+ room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
+ notify({
+ roomName: room.name ?? 'Unknown',
+ roomAvatar: avatarMxc
+ ? mx.mxcUrlToHttp(avatarMxc, 96, 96, 'crop') ?? undefined
+ : undefined,
+ username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender,
+ roomId: room.roomId,
+ eventId,
+ });
+ }
+
+ if (notificationSound) {
+ playSound();
+ }
+ };
+ mx.on(RoomEvent.Timeline, handleTimelineEvent);
+ return () => {
+ mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
+ };
+ }, [
+ mx,
+ notificationSound,
+ notificationSelected,
+ showNotifications,
+ playSound,
+ notify,
+ selectedRoomId,
+ ]);
+
+ return (
+ // eslint-disable-next-line jsx-a11y/media-has-caption
+ <audio ref={audioRef} style={{ display: 'none' }}>
+ <source src="../../../../public/sound/notification.ogg" type="audio/ogg" />
+ </audio>
+ );
+}
+
+type ClientNonUIFeaturesProps = {
+ children: ReactNode;
+};
+
+export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
+ return (
+ <>
+ <FaviconUpdater />
+ <InviteNotifications />
+ <MessageNotifications />
+ {children}
+ </>
+ );
+}
import React, { ReactNode, useEffect, useState } from 'react';
import initMatrix from '../../../client/initMatrix';
import { initHotkeys } from '../../../client/event/hotkeys';
-import { initRoomListListener } from '../../../client/event/roomList';
import { getSecret } from '../../../client/state/auth';
import { SplashScreen } from '../../components/splash-screen';
import { CapabilitiesAndMediaConfigLoader } from '../../components/CapabilitiesAndMediaConfigLoader';
useEffect(() => {
const handleStart = () => {
initHotkeys();
- initRoomListListener(initMatrix.roomList);
setLoading(false);
};
initMatrix.once('init_loading_finished', handleStart);
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { SequenceCard } from '../../../components/sequence-card';
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
-import { getMemberAvatarMxc, getMemberDisplayName, getRoomAvatarUrl } from '../../../utils/room';
+import {
+ getEditedEvent,
+ getMemberAvatarMxc,
+ getMemberDisplayName,
+ getRoomAvatarUrl,
+} from '../../../utils/room';
import { ScrollTopContainer } from '../../../components/scroll-top-container';
import { useInterval } from '../../../hooks/useInterval';
import {
AvatarBase,
ImageContent,
MSticker,
+ MessageNotDecryptedContent,
+ MessageUnsupportedContent,
ModernLayout,
RedactedContent,
Reply,
import { ContainerColor } from '../../../styles/ContainerColor.css';
import { VirtualTile } from '../../../components/virtualizer';
import { UserAvatar } from '../../../components/user-avatar';
+import { EncryptedContent } from '../../../features/room/message';
type RoomNotificationsGroup = {
roomId: string;
/>
);
},
+ [MessageEvent.RoomMessageEncrypted]: (evt, displayName) => {
+ const evtTimeline = room.getTimelineForEvent(evt.event_id);
+
+ const mEvent = evtTimeline?.getEvents().find((e) => e.getId() === evt.event_id);
+
+ if (!mEvent || !evtTimeline) {
+ return (
+ <Box grow="Yes" direction="Column">
+ <Text size="T400" priority="300">
+ <code className={customHtmlCss.Code}>{evt.type}</code>
+ {' event'}
+ </Text>
+ </Box>
+ );
+ }
+
+ return (
+ <EncryptedContent mEvent={mEvent}>
+ {() => {
+ if (mEvent.isRedacted()) return <RedactedContent />;
+ if (mEvent.getType() === MessageEvent.Sticker)
+ return (
+ <MSticker
+ content={mEvent.getContent()}
+ renderImageContent={(props) => (
+ <ImageContent
+ {...props}
+ autoPlay={mediaAutoLoad}
+ renderImage={(p) => <Image {...p} loading="lazy" />}
+ renderViewer={(p) => <ImageViewer {...p} />}
+ />
+ )}
+ />
+ );
+ if (mEvent.getType() === MessageEvent.RoomMessage) {
+ const editedEvent = getEditedEvent(
+ evt.event_id,
+ mEvent,
+ evtTimeline.getTimelineSet()
+ );
+ const getContent = (() =>
+ editedEvent?.getContent()['m.new_content'] ??
+ mEvent.getContent()) as GetContentCallback;
+
+ return (
+ <RenderMessageContent
+ displayName={displayName}
+ msgType={mEvent.getContent().msgtype ?? ''}
+ ts={mEvent.getTs()}
+ edited={!!editedEvent}
+ getContent={getContent}
+ mediaAutoLoad={mediaAutoLoad}
+ urlPreview={urlPreview}
+ htmlReactParserOptions={htmlReactParserOptions}
+ />
+ );
+ }
+ if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
+ return (
+ <Text>
+ <MessageNotDecryptedContent />
+ </Text>
+ );
+ return (
+ <Text>
+ <MessageUnsupportedContent />
+ </Text>
+ );
+ }}
+ </EncryptedContent>
+ );
+ },
[MessageEvent.Sticker]: (event, displayName, getContent) => {
if (event.unsigned?.redacted_because) {
return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
[searchParams]
);
-const DEFAULT_REFRESH_MS = 10000;
+const DEFAULT_REFRESH_MS = 7000;
export function Notifications() {
const mx = useMatrixClient();
useInterval(
useCallback(() => {
- if (document.hasFocus()) {
- silentReloadTimeline();
- }
+ silentReloadTimeline();
}, [silentReloadTimeline]),
refreshIntervalTime
);
roomId: string;
};
-const unreadInfoToUnread = (unreadInfo: UnreadInfo): Unread => ({
+export const unreadInfoToUnread = (unreadInfo: UnreadInfo): Unread => ({
highlight: unreadInfo.highlight,
total: unreadInfo.total,
from: null,
import produce from 'immer';
import { atom, useSetAtom } from 'jotai';
-import { selectAtom } from 'jotai/utils';
import { MatrixClient, RoomMemberEvent, RoomMemberEventHandlerMap } from 'matrix-js-sdk';
import { useEffect } from 'react';
};
}, [mx, setTypingMembers]);
};
-
-export const selectRoomTypingMembersAtom = (
- roomId: string,
- typingMembersAtom: typeof roomIdToTypingMembersAtom
-) => selectAtom(typingMembersAtom, (atoms) => atoms.get(roomId) ?? []);
+++ /dev/null
-/* eslint-disable react/prop-types */
-import React, { useState, useEffect, useRef } from 'react';
-import PropTypes from 'prop-types';
-import './Auth.scss';
-import ReCAPTCHA from 'react-google-recaptcha';
-import { Formik } from 'formik';
-
-import * as auth from '../../../client/action/auth';
-import cons from '../../../client/state/cons';
-import { Debounce, getUrlPrams } from '../../../util/common';
-import { getBaseUrl } from '../../../util/matrixUtil';
-
-import Text from '../../atoms/text/Text';
-import Button from '../../atoms/button/Button';
-import IconButton from '../../atoms/button/IconButton';
-import Input from '../../atoms/input/Input';
-import Spinner from '../../atoms/spinner/Spinner';
-import ScrollView from '../../atoms/scroll/ScrollView';
-import Header, { TitleWrapper } from '../../atoms/header/Header';
-import Avatar from '../../atoms/avatar/Avatar';
-import ContextMenu, { MenuItem, MenuHeader } from '../../atoms/context-menu/ContextMenu';
-
-import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
-import EyeIC from '../../../../public/res/ic/outlined/eye.svg';
-import EyeBlindIC from '../../../../public/res/ic/outlined/eye-blind.svg';
-import CinnySvg from '../../../../public/res/svg/cinny.svg';
-import SSOButtons from '../../molecules/sso-buttons/SSOButtons';
-
-const LOCALPART_SIGNUP_REGEX = /^[a-z0-9_\-.=/]+$/;
-const BAD_LOCALPART_ERROR = 'Username can only contain characters a-z, 0-9, or \'=_-./\'';
-const USER_ID_TOO_LONG_ERROR = 'Your user ID, including the hostname, can\'t be more than 255 characters long.';
-
-const PASSWORD_STRENGHT_REGEX = /^(?=.*\d)(?=.*[A-Z])(?=.*[a-z])(?=.*[^\w\d\s:])([^\s]){8,127}$/;
-const BAD_PASSWORD_ERROR = 'Password must contain at least 1 lowercase, 1 uppercase, 1 number, 1 non-alphanumeric character, 8-127 characters with no space.';
-const CONFIRM_PASSWORD_ERROR = 'Passwords don\'t match.';
-
-const EMAIL_REGEX = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i;
-const BAD_EMAIL_ERROR = 'Invalid email address';
-
-function isValidInput(value, regex) {
- if (typeof regex === 'string') return regex === value;
- return regex.test(value);
-}
-function normalizeUsername(rawUsername) {
- const noLeadingAt = rawUsername.indexOf('@') === 0 ? rawUsername.substr(1) : rawUsername;
- return noLeadingAt.trim();
-}
-
-let searchingHs = null;
-function Homeserver({ onChange }) {
- const [hs, setHs] = useState(null);
- const [debounce] = useState(new Debounce());
- const [process, setProcess] = useState({ isLoading: true, message: 'Loading homeserver list...' });
- const hsRef = useRef();
-
- const setupHsConfig = async (servername) => {
- setProcess({ isLoading: true, message: 'Looking for homeserver...' });
- let baseUrl = null;
- baseUrl = await getBaseUrl(servername);
-
- if (searchingHs !== servername) return;
- setProcess({ isLoading: true, message: `Connecting to ${baseUrl}...` });
- const tempClient = auth.createTemporaryClient(baseUrl);
-
- Promise.allSettled([tempClient.loginFlows(), tempClient.register()])
- .then((values) => {
- const loginFlow = values[0].status === 'fulfilled' ? values[0]?.value : undefined;
- const registerFlow = values[1].status === 'rejected' ? values[1]?.reason?.data : undefined;
- if (loginFlow === undefined || registerFlow === undefined) throw new Error();
-
- if (searchingHs !== servername) return;
- onChange({ baseUrl, login: loginFlow, register: registerFlow });
- setProcess({ isLoading: false });
- }).catch(() => {
- if (searchingHs !== servername) return;
- onChange(null);
- setProcess({ isLoading: false, error: 'Unable to connect. Please check your input.' });
- });
- };
-
- useEffect(() => {
- onChange(null);
- if (hs === null || hs?.selected.trim() === '') return;
- searchingHs = hs.selected;
- setupHsConfig(hs.selected);
- }, [hs]);
-
- useEffect(async () => {
- const link = window.location.href;
- const configFileUrl = `${link}${link[link.length - 1] === '/' ? '' : '/'}config.json`;
- try {
- const result = await (await fetch(configFileUrl, { method: 'GET' })).json();
- const selectedHs = result?.defaultHomeserver;
- const hsList = result?.homeserverList;
- const allowCustom = result?.allowCustomHomeservers ?? true;
- if (!hsList?.length > 0 || selectedHs < 0 || selectedHs >= hsList?.length) {
- throw new Error();
- }
- setHs({ selected: hsList[selectedHs], list: hsList, allowCustom });
- } catch {
- setHs({ selected: 'matrix.org', list: ['matrix.org'], allowCustom: true });
- }
- }, []);
-
- const handleHsInput = (e) => {
- const { value } = e.target;
- setProcess({ isLoading: false });
- debounce._(async () => {
- setHs({ ...hs, selected: value.trim() });
- }, 700)();
- };
-
- return (
- <>
- <div className="homeserver-form">
- <Input
- name="homeserver"
- onChange={handleHsInput}
- value={hs?.selected}
- forwardRef={hsRef}
- label="Homeserver"
- disabled={hs === null || !hs.allowCustom}
- />
- <ContextMenu
- placement="right"
- content={(hideMenu) => (
- <>
- <MenuHeader>Homeserver list</MenuHeader>
- {
- hs?.list.map((hsName) => (
- <MenuItem
- key={hsName}
- onClick={() => {
- hideMenu();
- hsRef.current.value = hsName;
- setHs({ ...hs, selected: hsName });
- }}
- >
- {hsName}
- </MenuItem>
- ))
- }
- </>
- )}
- render={(toggleMenu) => <IconButton onClick={toggleMenu} src={ChevronBottomIC} />}
- />
- </div>
- {process.error !== undefined && <Text className="homeserver-form__error" variant="b3">{process.error}</Text>}
- {process.isLoading && (
- <div className="homeserver-form__status flex--center">
- <Spinner size="small" />
- <Text variant="b2">{process.message}</Text>
- </div>
- )}
- </>
- );
-}
-Homeserver.propTypes = {
- onChange: PropTypes.func.isRequired,
-};
-
-function Login({ loginFlow, baseUrl }) {
- const [typeIndex, setTypeIndex] = useState(0);
- const [passVisible, setPassVisible] = useState(false);
- const loginTypes = ['Username', 'Email'];
- const isPassword = loginFlow?.filter((flow) => flow.type === 'm.login.password')[0];
- const ssoProviders = loginFlow?.filter((flow) => flow.type === 'm.login.sso')[0];
-
- const initialValues = {
- username: '', password: '', email: '', other: '',
- };
-
- const validator = (values) => {
- const errors = {};
- if (typeIndex === 1 && values.email.length > 0 && !isValidInput(values.email, EMAIL_REGEX)) {
- errors.email = BAD_EMAIL_ERROR;
- }
- return errors;
- };
- const submitter = async (values, actions) => {
- let userBaseUrl = baseUrl;
- let { username } = values;
- const mxIdMatch = username.match(/^@(.+):(.+\..+)$/);
- if (typeIndex === 0 && mxIdMatch) {
- [, username, userBaseUrl] = mxIdMatch;
- userBaseUrl = await getBaseUrl(userBaseUrl);
- }
-
- return auth.login(
- userBaseUrl,
- typeIndex === 0 ? normalizeUsername(username) : undefined,
- typeIndex === 1 ? values.email : undefined,
- values.password,
- ).then(() => {
- actions.setSubmitting(true);
- window.location.reload();
- }).catch((error) => {
- let msg = error.message;
- if (msg === 'Unknown message') msg = 'Please check your credentials';
- actions.setErrors({
- password: msg === 'Invalid password' ? msg : undefined,
- other: msg !== 'Invalid password' ? msg : undefined,
- });
- actions.setSubmitting(false);
- });
- };
-
- return (
- <>
- <div className="auth-form__heading">
- <Text variant="h2" weight="medium">Login</Text>
- {isPassword && (
- <ContextMenu
- placement="right"
- content={(hideMenu) => (
- loginTypes.map((type, index) => (
- <MenuItem
- key={type}
- onClick={() => {
- hideMenu();
- setTypeIndex(index);
- }}
- >
- {type}
- </MenuItem>
- ))
- )}
- render={(toggleMenu) => (
- <Button onClick={toggleMenu} iconSrc={ChevronBottomIC}>
- {loginTypes[typeIndex]}
- </Button>
- )}
- />
- )}
- </div>
- {isPassword && (
- <Formik
- initialValues={initialValues}
- onSubmit={submitter}
- validate={validator}
- >
- {({
- values, errors, handleChange, handleSubmit, isSubmitting,
- }) => (
- <>
- {isSubmitting && <LoadingScreen message="Login in progress..." />}
- <form className="auth-form" onSubmit={handleSubmit}>
- {typeIndex === 0 && <Input values={values.username} name="username" onChange={handleChange} label="Username" type="username" required />}
- {errors.username && <Text className="auth-form__error" variant="b3">{errors.username}</Text>}
- {typeIndex === 1 && <Input values={values.email} name="email" onChange={handleChange} label="Email" type="email" required />}
- {errors.email && <Text className="auth-form__error" variant="b3">{errors.email}</Text>}
- <div className="auth-form__pass-eye-wrapper">
- <Input values={values.password} name="password" onChange={handleChange} label="Password" type={passVisible ? 'text' : 'password'} required />
- <IconButton onClick={() => setPassVisible(!passVisible)} src={passVisible ? EyeIC : EyeBlindIC} size="extra-small" />
- </div>
- {errors.password && <Text className="auth-form__error" variant="b3">{errors.password}</Text>}
- {errors.other && <Text className="auth-form__error" variant="b3">{errors.other}</Text>}
- <div className="auth-form__btns">
- <Button variant="primary" type="submit" disabled={isSubmitting}>Login</Button>
- </div>
- </form>
- </>
- )}
- </Formik>
- )}
- {ssoProviders && isPassword && <Text className="sso__divider">OR</Text>}
- {ssoProviders && (
- <SSOButtons
- type="sso"
- identityProviders={ssoProviders.identity_providers}
- baseUrl={baseUrl}
- />
- )}
- </>
- );
-}
-Login.propTypes = {
- loginFlow: PropTypes.arrayOf(
- PropTypes.shape({}),
- ).isRequired,
- baseUrl: PropTypes.string.isRequired,
-};
-
-let sid;
-let clientSecret;
-function Register({ registerInfo, loginFlow, baseUrl }) {
- const [process, setProcess] = useState({});
- const [passVisible, setPassVisible] = useState(false);
- const [cPassVisible, setCPassVisible] = useState(false);
- const formRef = useRef();
-
- const ssoProviders = loginFlow?.filter((flow) => flow.type === 'm.login.sso')[0];
- const isDisabled = registerInfo.errcode !== undefined;
- const { flows, params, session } = registerInfo;
-
- let isEmail = false;
- let isEmailRequired = true;
- let isRecaptcha = false;
- let isTerms = false;
- let isDummy = false;
-
- flows?.forEach((flow) => {
- if (isEmailRequired && flow.stages.indexOf('m.login.email.identity') === -1) isEmailRequired = false;
- if (!isEmail) isEmail = flow.stages.indexOf('m.login.email.identity') > -1;
- if (!isRecaptcha) isRecaptcha = flow.stages.indexOf('m.login.recaptcha') > -1;
- if (!isTerms) isTerms = flow.stages.indexOf('m.login.terms') > -1;
- if (!isDummy) isDummy = flow.stages.indexOf('m.login.dummy') > -1;
- });
-
- const initialValues = {
- username: '', password: '', confirmPassword: '', email: '', other: '',
- };
-
- const validator = (values) => {
- const errors = {};
- if (values.username.list > 255) errors.username = USER_ID_TOO_LONG_ERROR;
- if (values.username.length > 0 && !isValidInput(values.username, LOCALPART_SIGNUP_REGEX)) {
- errors.username = BAD_LOCALPART_ERROR;
- }
- if (values.password.length > 0 && !isValidInput(values.password, PASSWORD_STRENGHT_REGEX)) {
- errors.password = BAD_PASSWORD_ERROR;
- }
- if (values.confirmPassword.length > 0
- && !isValidInput(values.confirmPassword, values.password)) {
- errors.confirmPassword = CONFIRM_PASSWORD_ERROR;
- }
- if (values.email.length > 0 && !isValidInput(values.email, EMAIL_REGEX)) {
- errors.email = BAD_EMAIL_ERROR;
- }
- return errors;
- };
- const submitter = (values, actions) => {
- const tempClient = auth.createTemporaryClient(baseUrl);
- clientSecret = tempClient.generateClientSecret();
- return tempClient.isUsernameAvailable(values.username)
- .then(async (isAvail) => {
- if (!isAvail) {
- actions.setErrors({ username: 'Username is already taken' });
- actions.setSubmitting(false);
- return;
- }
- if (isEmail && values.email.length > 0) {
- const result = await auth.verifyEmail(baseUrl, values.email, clientSecret, 1);
- if (result.errcode) {
- if (result.errcode === 'M_THREEPID_IN_USE') actions.setErrors({ email: result.error });
- else actions.setErrors({ others: result.error || result.message });
- actions.setSubmitting(false);
- return;
- }
- sid = result.sid;
- }
- setProcess({ type: 'processing', message: 'Registration in progress....' });
- actions.setSubmitting(false);
- }).catch((err) => {
- const msg = err.message || err.error;
- if (['M_USER_IN_USE', 'M_INVALID_USERNAME', 'M_EXCLUSIVE'].indexOf(err.errcode) > -1) {
- actions.setErrors({ username: err.errcode === 'M_USER_IN_USE' ? 'Username is already taken' : msg });
- } else if (msg) actions.setErrors({ other: msg });
-
- actions.setSubmitting(false);
- });
- };
-
- const refreshWindow = () => window.location.reload();
-
- const getInputs = () => {
- const f = formRef.current;
- return [f.username.value, f.password.value, f?.email?.value];
- };
-
- useEffect(() => {
- if (process.type !== 'processing') return;
- const asyncProcess = async () => {
- const [username, password, email] = getInputs();
- const d = await auth.completeRegisterStage(baseUrl, username, password, { session });
-
- if (isRecaptcha && !d.completed.includes('m.login.recaptcha')) {
- const sitekey = params['m.login.recaptcha'].public_key;
- setProcess({ type: 'm.login.recaptcha', sitekey });
- return;
- }
- if (isTerms && !d.completed.includes('m.login.terms')) {
- const pp = params['m.login.terms'].policies.privacy_policy;
- const url = pp?.en.url || pp[Object.keys(pp)[0]].url;
- setProcess({ type: 'm.login.terms', url });
- return;
- }
- if (isEmail && email.length > 0) {
- setProcess({ type: 'm.login.email.identity', email });
- return;
- }
- if (isDummy) {
- const data = await auth.completeRegisterStage(baseUrl, username, password, {
- type: 'm.login.dummy',
- session,
- });
- if (data.done) refreshWindow();
- }
- };
- asyncProcess();
- }, [process]);
-
- const handleRecaptcha = async (value) => {
- if (typeof value !== 'string') return;
- const [username, password] = getInputs();
- const d = await auth.completeRegisterStage(baseUrl, username, password, {
- type: 'm.login.recaptcha',
- response: value,
- session,
- });
- if (d.done) refreshWindow();
- else setProcess({ type: 'processing', message: 'Registration in progress...' });
- };
- const handleTerms = async () => {
- const [username, password] = getInputs();
- const d = await auth.completeRegisterStage(baseUrl, username, password, {
- type: 'm.login.terms',
- session,
- });
- if (d.done) refreshWindow();
- else setProcess({ type: 'processing', message: 'Registration in progress...' });
- };
- const handleEmailVerify = async () => {
- const [username, password] = getInputs();
- const d = await auth.completeRegisterStage(baseUrl, username, password, {
- type: 'm.login.email.identity',
- threepidCreds: { sid, client_secret: clientSecret },
- threepid_creds: { sid, client_secret: clientSecret },
- session,
- });
- if (d.done) refreshWindow();
- else setProcess({ type: 'processing', message: 'Registration in progress...' });
- };
-
- return (
- <>
- {process.type === 'processing' && <LoadingScreen message={process.message} />}
- {process.type === 'm.login.recaptcha' && <Recaptcha message="Please check the box below to proceed." sitekey={process.sitekey} onChange={handleRecaptcha} />}
- {process.type === 'm.login.terms' && <Terms url={process.url} onSubmit={handleTerms} />}
- {process.type === 'm.login.email.identity' && <EmailVerify email={process.email} onContinue={handleEmailVerify} />}
- <div className="auth-form__heading">
- {!isDisabled && <Text variant="h2" weight="medium">Register</Text>}
- {isDisabled && <Text className="auth-form__error">{registerInfo.error}</Text>}
- </div>
- {!isDisabled && (
- <Formik
- initialValues={initialValues}
- onSubmit={submitter}
- validate={validator}
- >
- {({
- values, errors, handleChange, handleSubmit, isSubmitting,
- }) => (
- <>
- {process.type === undefined && isSubmitting && <LoadingScreen message="Registration in progress..." />}
- <form className="auth-form" ref={formRef} onSubmit={handleSubmit}>
- <Input values={values.username} name="username" onChange={handleChange} label="Username" type="username" required />
- {errors.username && <Text className="auth-form__error" variant="b3">{errors.username}</Text>}
- <div className="auth-form__pass-eye-wrapper">
- <Input values={values.password} name="password" onChange={handleChange} label="Password" type={passVisible ? 'text' : 'password'} required />
- <IconButton onClick={() => setPassVisible(!passVisible)} src={passVisible ? EyeIC : EyeBlindIC} size="extra-small" />
- </div>
- {errors.password && <Text className="auth-form__error" variant="b3">{errors.password}</Text>}
- <div className="auth-form__pass-eye-wrapper">
- <Input values={values.confirmPassword} name="confirmPassword" onChange={handleChange} label="Confirm password" type={cPassVisible ? 'text' : 'password'} required />
- <IconButton onClick={() => setCPassVisible(!cPassVisible)} src={cPassVisible ? EyeIC : EyeBlindIC} size="extra-small" />
- </div>
- {errors.confirmPassword && <Text className="auth-form__error" variant="b3">{errors.confirmPassword}</Text>}
- {isEmail && <Input values={values.email} name="email" onChange={handleChange} label={`Email${isEmailRequired ? '' : ' (optional)'}`} type="email" required={isEmailRequired} />}
- {errors.email && <Text className="auth-form__error" variant="b3">{errors.email}</Text>}
- {errors.other && <Text className="auth-form__error" variant="b3">{errors.other}</Text>}
- <div className="auth-form__btns">
- <Button variant="primary" type="submit" disabled={isSubmitting}>Register</Button>
- </div>
- </form>
- </>
- )}
- </Formik>
- )}
- {isDisabled && ssoProviders && (
- <SSOButtons
- type="sso"
- identityProviders={ssoProviders.identity_providers}
- baseUrl={baseUrl}
- />
- )}
- </>
- );
-}
-Register.propTypes = {
- registerInfo: PropTypes.shape({}).isRequired,
- loginFlow: PropTypes.arrayOf(
- PropTypes.shape({}),
- ).isRequired,
- baseUrl: PropTypes.string.isRequired,
-};
-
-function AuthCard() {
- const [hsConfig, setHsConfig] = useState(null);
- const [type, setType] = useState('login');
-
- const handleHsChange = (info) => {
- console.log(info);
- setHsConfig(info);
- };
-
- return (
- <>
- <Homeserver onChange={handleHsChange} />
- { hsConfig !== null && (
- type === 'login'
- ? <Login loginFlow={hsConfig.login.flows} baseUrl={hsConfig.baseUrl} />
- : (
- <Register
- registerInfo={hsConfig.register}
- loginFlow={hsConfig.login.flows}
- baseUrl={hsConfig.baseUrl}
- />
- )
- )}
- { hsConfig !== null && (
- <Text variant="b2" className="auth-card__switch flex--center">
- {`${(type === 'login' ? 'Don\'t have' : 'Already have')} an account?`}
- <button
- type="button"
- style={{ color: 'var(--tc-link)', cursor: 'pointer', margin: '0 var(--sp-ultra-tight)' }}
- onClick={() => setType((type === 'login') ? 'register' : 'login')}
- >
- { type === 'login' ? ' Register' : ' Login' }
- </button>
- </Text>
- )}
- </>
- );
-}
-
-function Auth() {
- const [loginToken, setLoginToken] = useState(getUrlPrams('loginToken'));
-
- useEffect(async () => {
- if (!loginToken) return;
- if (localStorage.getItem(cons.secretKey.BASE_URL) === undefined) {
- setLoginToken(null);
- return;
- }
- const baseUrl = localStorage.getItem(cons.secretKey.BASE_URL);
- try {
- await auth.loginWithToken(baseUrl, loginToken);
-
- const { href } = window.location;
- window.location.replace(href.slice(0, href.indexOf('?')));
- } catch {
- setLoginToken(null);
- }
- }, []);
-
- return (
- <ScrollView invisible>
- <div className="auth__base">
- <div className="auth__wrapper">
- {loginToken && <LoadingScreen message="Redirecting..." />}
- {!loginToken && (
- <div className="auth-card">
- <Header>
- <Avatar size="extra-small" imageSrc={CinnySvg} />
- <TitleWrapper>
- <Text variant="h2" weight="medium">Cinny</Text>
- </TitleWrapper>
- </Header>
- <div className="auth-card__content">
- <AuthCard />
- </div>
- </div>
- )}
- </div>
-
- <div className="auth-footer">
- <Text variant="b2">
- <a href="https://cinny.in" target="_blank" rel="noreferrer">About</a>
- </Text>
- <Text variant="b2">
- <a href="https://github.com/ajbura/cinny/releases" target="_blank" rel="noreferrer">{`v${cons.version}`}</a>
- </Text>
- <Text variant="b2">
- <a href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">Twitter</a>
- </Text>
- <Text variant="b2">
- <a href="https://matrix.org" target="_blank" rel="noreferrer">Powered by Matrix</a>
- </Text>
- </div>
- </div>
- </ScrollView>
- );
-}
-
-function LoadingScreen({ message }) {
- return (
- <ProcessWrapper>
- <Spinner />
- <div style={{ marginTop: 'var(--sp-normal)' }}>
- <Text variant="b1">{message}</Text>
- </div>
- </ProcessWrapper>
- );
-}
-LoadingScreen.propTypes = {
- message: PropTypes.string.isRequired,
-};
-
-function Recaptcha({ message, sitekey, onChange }) {
- return (
- <ProcessWrapper>
- <div style={{ marginBottom: 'var(--sp-normal)' }}>
- <Text variant="s1" weight="medium">{message}</Text>
- </div>
- <ReCAPTCHA sitekey={sitekey} onChange={onChange} />
- </ProcessWrapper>
- );
-}
-Recaptcha.propTypes = {
- message: PropTypes.string.isRequired,
- sitekey: PropTypes.string.isRequired,
- onChange: PropTypes.func.isRequired,
-};
-
-function Terms({ url, onSubmit }) {
- return (
- <ProcessWrapper>
- <form onSubmit={(e) => { e.preventDefault(); onSubmit(); }}>
- <div style={{ margin: 'var(--sp-normal)', maxWidth: '450px' }}>
- <Text variant="h2" weight="medium">Agree with terms</Text>
- <div style={{ marginBottom: 'var(--sp-normal)' }} />
- <Text variant="b1">In order to complete registration, you need to agree to the terms and conditions.</Text>
- <div style={{ display: 'flex', alignItems: 'center', margin: 'var(--sp-normal) 0' }}>
- <input style={{ marginRight: '8px' }} id="termsCheckbox" type="checkbox" required />
- <Text variant="b1">
- {'I accept '}
- <a style={{ cursor: 'pointer' }} href={url} rel="noreferrer" target="_blank">Terms and Conditions</a>
- </Text>
- </div>
- <Button id="termsBtn" type="submit" variant="primary">Submit</Button>
- </div>
- </form>
- </ProcessWrapper>
- );
-}
-Terms.propTypes = {
- url: PropTypes.string.isRequired,
- onSubmit: PropTypes.func.isRequired,
-};
-
-function EmailVerify({ email, onContinue }) {
- return (
- <ProcessWrapper>
- <div style={{ margin: 'var(--sp-normal)', maxWidth: '450px' }}>
- <Text variant="h2" weight="medium">Verify email</Text>
- <div style={{ margin: 'var(--sp-normal) 0' }}>
- <Text variant="b1">
- {'Please check your email '}
- <b>{`(${email})`}</b>
- {' and validate before continuing further.'}
- </Text>
- </div>
- <Button variant="primary" onClick={onContinue}>Continue</Button>
- </div>
- </ProcessWrapper>
- );
-}
-EmailVerify.propTypes = {
- email: PropTypes.string.isRequired,
-};
-
-function ProcessWrapper({ children }) {
- return (
- <div className="process-wrapper">
- {children}
- </div>
- );
-}
-ProcessWrapper.propTypes = {
- children: PropTypes.node.isRequired,
-};
-
-export default Auth;
+++ /dev/null
-@use '../../partials/flex';
-@use '../../partials/dir';
-
-.auth__base {
- --pattern-size: 48px;
- min-height: 100%;
- background-color: var(--bg-surface-low);
-
- background-image: radial-gradient(rgba(0, 0, 0, 6%) 2px, rgba(0, 0, 0, 0%) 2px);
- background-size: var(--pattern-size) var(--pattern-size);
-
- display: flex;
- flex-direction: column;
-}
-.auth__wrapper {
- flex: 1;
- padding: var(--sp-loose);
- padding-bottom: 0;
- display: flex;
- justify-content: center;
- align-items: flex-start;
-}
-.auth-footer {
- padding: var(--sp-normal) 0;
- display: flex;
- justify-content: center;
- align-items: center;
-
- & > *:nth-child(2n) {
- margin: 0 var(--sp-loose);
- }
- & a {
- color: var(--tc-surface-normal);
- &:hover { text-decoration: underline; }
- }
-}
-.auth-card {
- width: 462px;
- background-color: var(--bg-surface);
- border-radius: var(--bo-radius);
- box-shadow: var(--bs-popup);
- overflow: hidden;
-
- &__content {
- padding: var(--sp-extra-loose) calc(var(--sp-normal) + var(--sp-extra-loose));
- }
- &__switch {
- margin-top: var(--sp-loose) !important;
- }
-}
-
-.homeserver-form,
-.auth-form__heading {
- & .context-menu__item .text {
- margin: 0 !important;
- }
-}
-
-.homeserver-form {
- display: flex;
- margin-bottom: var(--sp-extra-tight);
- align-items: flex-end;
- & > .input-container {
- flex: 1;
- & .input {
- background-color: var(--bg-surface);
- @include dir.prop(border-right-width, 0, 1px);
- @include dir.prop(border-left-width, 1px, 0 );
- @include dir.prop(border-radius,
- var(--bo-radius) 0 0 var(--bo-radius),
- 0 var(--bo-radius) var(--bo-radius) 0,
- );
- }
- }
- & .ic-btn {
- height: 46px;
- border: 1px solid var(--bg-surface-border);
- @include dir.prop(border-radius,
- 0 var(--bo-radius) var(--bo-radius) 0,
- var(--bo-radius) 0 0 var(--bo-radius),
- );
- }
-
- &__status {
- margin-top: var(--sp-normal);
- & .donut-spinner {
- min-width: 28px;
- }
- & .text {
- margin: 0 var(--sp-tight);
- }
- }
- &__error {
- margin-bottom: var(--sp-normal) !important;
- color: var(--tc-danger-normal) !important;
- }
-}
-
-.auth-form {
- & > .input-container,
- &__pass-eye-wrapper {
- margin: var(--sp-tight) 0 var(--sp-ultra-tight);
- }
-
- &__heading {
- display: flex;
- justify-content: space-between;
- margin-top: calc(var(--sp-extra-loose) + var(--sp-tight));
- }
-
- &__pass-eye-wrapper {
- position: relative;
- & .ic-btn {
- position: absolute;
- @include dir.prop(right, 6px, unset);
- @include dir.prop(left, unset, 6px );
- bottom: 6px;
- border-radius: 4px;
- }
- & input {
- @include dir.side(padding, var(--sp-normal), 46px);
- }
- }
-
- &__btns {
- padding-top: var(--sp-loose);
- margin-bottom: var(--sp-extra-loose);
- display: flex;
- justify-content: flex-end;
- }
-
- &__error {
- color: var(--tc-danger-normal) !important;
- }
-}
-.sso__divider {
- margin-bottom: var(--sp-tight);
- display: flex;
- align-items: center;
-
- &::before,
- &::after {
- flex: 1;
- content: '';
- margin: var(--sp-tight);
- border-bottom: 1px solid var(--bg-surface-border);
- }
-}
-
-@media (max-width: 462px) {
- .auth__wrapper {
- padding: var(--sp-tight);
- }
- .auth-card {
- &__content {
- padding: var(--sp-loose) var(--sp-normal);
- }
- }
-}
-
-.process-wrapper {
- @extend .cp-fx__column--c-c;
-
- min-height: 100%;
- width: 100%;
- background-color: var(--bg-surface-low);
- opacity: .96;
-
- position: fixed;
- top: 0;
- left: 0;
- z-index: 999;
-}
\ No newline at end of file
+++ /dev/null
-import React, { useEffect, useRef } from 'react';
-import './Client.scss';
-
-import ReusableContextMenu from '../../atoms/context-menu/ReusableContextMenu';
-import Windows from '../../organisms/pw/Windows';
-import Dialogs from '../../organisms/pw/Dialogs';
-
-import navigation from '../../../client/state/navigation';
-import cons from '../../../client/state/cons';
-
-import { ClientContent } from './ClientContent';
-import { useSetting } from '../../state/hooks/settings';
-import { settingsAtom } from '../../state/settings';
-
-function SystemEmojiFeature() {
- const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
-
- if (twitterEmoji) {
- document.documentElement.style.setProperty('--font-emoji', 'Twemoji');
- } else {
- document.documentElement.style.setProperty('--font-emoji', 'Twemoji_DISABLED');
- }
-
- return null;
-}
-
-function Client() {
- const classNameHidden = 'client__item-hidden';
-
- const navWrapperRef = useRef(null);
- const roomWrapperRef = useRef(null);
-
- function onRoomSelected() {
- navWrapperRef.current?.classList.add(classNameHidden);
- roomWrapperRef.current?.classList.remove(classNameHidden);
- }
- function onNavigationSelected() {
- navWrapperRef.current?.classList.remove(classNameHidden);
- roomWrapperRef.current?.classList.add(classNameHidden);
- }
-
- useEffect(() => {
- navigation.on(cons.events.navigation.ROOM_SELECTED, onRoomSelected);
- navigation.on(cons.events.navigation.NAVIGATION_OPENED, onNavigationSelected);
-
- return () => {
- navigation.removeListener(cons.events.navigation.ROOM_SELECTED, onRoomSelected);
- navigation.removeListener(cons.events.navigation.NAVIGATION_OPENED, onNavigationSelected);
- };
- }, []);
-
- return (
- <div className="client-container">
- {/* <div className="navigation__wrapper" ref={navWrapperRef}>
- <Navigation />
- </div> */}
- <div className={`room__wrapper ${classNameHidden}`} ref={roomWrapperRef}>
- <ClientContent />
- </div>
- <Windows />
- <Dialogs />
- <ReusableContextMenu />
- <SystemEmojiFeature />
- </div>
- );
-}
-
-export default Client;
+++ /dev/null
-@use '../../partials/screen';
-
-.client-container {
- display: flex;
- height: 100%;
- flex-grow: 1;
-}
-
-.navigation__wrapper {
- width: var(--navigation-width);
-
- @include screen.smallerThan(mobileBreakpoint) {
- width: 100%;
- }
-}
-
-.room__wrapper {
- flex: 1;
- min-width: 0;
-}
-
-@include screen.smallerThan(mobileBreakpoint) {
- .client__item-hidden {
- display: none;
- }
-}
-
-.loading-display {
- position: absolute;
- top: 0;
- left: 0;
- width: 100vw;
- height: 100%;
-
- display: flex;
- flex-direction: column;
- justify-content: center;
- align-items: center;
-}
-.loading__message {
- margin-top: var(--sp-normal);
- max-width: 350px;
- text-align: center;
-}
-.loading__appname {
- position: absolute;
- bottom: var(--sp-normal);
-}
-.loading__menu {
- position: absolute;
- top: var(--sp-extra-tight);
- right: var(--sp-extra-tight);
- cursor: pointer;
- .context-menu__item .text {
- margin: 0 !important;
- }
-}
+++ /dev/null
-import React, { useState, useEffect } from 'react';
-
-import initMatrix from '../../../client/initMatrix';
-import cons from '../../../client/state/cons';
-import navigation from '../../../client/state/navigation';
-import { openNavigation } from '../../../client/action/navigation';
-
-import Welcome from '../../organisms/welcome/Welcome';
-import { RoomBaseView } from '../../features/room/Room';
-
-export function ClientContent() {
- const [roomInfo, setRoomInfo] = useState({
- room: null,
- eventId: null,
- });
-
- const mx = initMatrix.matrixClient;
-
- useEffect(() => {
- const handleRoomSelected = (rId, pRoomId, eId) => {
- roomInfo.roomTimeline?.removeInternalListeners();
- const r = mx.getRoom(rId);
- if (r) {
- setRoomInfo({
- room: r,
- eventId: eId ?? null,
- });
- } else {
- setRoomInfo({
- room: null,
- eventId: null,
- });
- }
- };
-
- navigation.on(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
- return () => {
- navigation.removeListener(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
- };
- }, [roomInfo, mx]);
-
- const { room, eventId } = roomInfo;
- if (!room) {
- setTimeout(() => openNavigation());
- return <Welcome />;
- }
-
- return <RoomBaseView room={room} eventId={eventId} />;
-}
-export type DisposeCallback<Q extends unknown[] = [], R = void> = (...args: Q) => R;
-export type DisposableContext<P extends unknown[] = [], Q extends unknown[] = [], R = void> = (
- ...args: P
-) => DisposeCallback<Q, R>;
+export type DisposeCallback<DisposeArgs extends unknown[] = [], DisposeReturn = void> = (
+ ...args: DisposeArgs
+) => DisposeReturn;
+export type DisposableContext<
+ DisposableArgs extends unknown[] = [],
+ DisposeArgs extends unknown[] = [],
+ DisposeReturn = void
+> = (...args: DisposableArgs) => DisposeCallback<DisposeArgs, DisposeReturn>;
-export const disposable = <P extends unknown[], Q extends unknown[] = [], R = void>(
- context: DisposableContext<P, Q, R>
+export const disposable = <
+ DisposableArgs extends unknown[],
+ DisposeArgs extends unknown[] = [],
+ DisposeReturn = void
+>(
+ context: DisposableContext<DisposableArgs, DisposeArgs, DisposeReturn>
) => context;
copyInput.remove();
}
};
+
+export const setFavicon = (url: string): void => {
+ const favicon = document.querySelector('#favicon');
+ if (!favicon) return;
+ favicon.setAttribute('href', url);
+};
+++ /dev/null
-import appDispatcher from '../dispatcher';
-import cons from '../state/cons';
-
-/**
- * @param {string | string[]} roomId - room id or array of them to add into shortcuts
- */
-export function createSpaceShortcut(roomId) {
- appDispatcher.dispatch({
- type: cons.actions.accountData.CREATE_SPACE_SHORTCUT,
- roomId,
- });
-}
-
-export function deleteSpaceShortcut(roomId) {
- appDispatcher.dispatch({
- type: cons.actions.accountData.DELETE_SPACE_SHORTCUT,
- roomId,
- });
-}
-
-export function moveSpaceShortcut(roomId, toIndex) {
- appDispatcher.dispatch({
- type: cons.actions.accountData.MOVE_SPACE_SHORTCUTS,
- roomId,
- toIndex,
- });
-}
-
-export function categorizeSpace(roomId) {
- appDispatcher.dispatch({
- type: cons.actions.accountData.CATEGORIZE_SPACE,
- roomId,
- });
-}
-
-export function unCategorizeSpace(roomId) {
- appDispatcher.dispatch({
- type: cons.actions.accountData.UNCATEGORIZE_SPACE,
- roomId,
- });
-}
+++ /dev/null
-import * as sdk from 'matrix-js-sdk';
-import cons from '../state/cons';
-
-function updateLocalStore(accessToken, deviceId, userId, baseUrl) {
- localStorage.setItem(cons.secretKey.ACCESS_TOKEN, accessToken);
- localStorage.setItem(cons.secretKey.DEVICE_ID, deviceId);
- localStorage.setItem(cons.secretKey.USER_ID, userId);
- localStorage.setItem(cons.secretKey.BASE_URL, baseUrl);
-}
-
-function createTemporaryClient(baseUrl) {
- return sdk.createClient({ baseUrl });
-}
-
-async function startSsoLogin(baseUrl, type, idpId) {
- const client = createTemporaryClient(baseUrl);
- localStorage.setItem(cons.secretKey.BASE_URL, client.baseUrl);
- window.location.href = client.getSsoLoginUrl(window.location.href, type, idpId);
-}
-
-async function login(baseUrl, username, email, password) {
- const identifier = {};
- if (username) {
- identifier.type = 'm.id.user';
- identifier.user = username;
- } else if (email) {
- identifier.type = 'm.id.thirdparty';
- identifier.medium = 'email';
- identifier.address = email;
- } else throw new Error('Bad Input');
-
- const client = createTemporaryClient(baseUrl);
- const res = await client.login('m.login.password', {
- identifier,
- password,
- initial_device_display_name: cons.DEVICE_DISPLAY_NAME,
- });
-
- const myBaseUrl = res?.well_known?.['m.homeserver']?.base_url || client.baseUrl;
- updateLocalStore(res.access_token, res.device_id, res.user_id, myBaseUrl);
-}
-
-async function loginWithToken(baseUrl, token) {
- const client = createTemporaryClient(baseUrl);
-
- const res = await client.login('m.login.token', {
- token,
- initial_device_display_name: cons.DEVICE_DISPLAY_NAME,
- });
-
- const myBaseUrl = res?.well_known?.['m.homeserver']?.base_url || client.baseUrl;
- updateLocalStore(res.access_token, res.device_id, res.user_id, myBaseUrl);
-}
-
-// eslint-disable-next-line camelcase
-async function verifyEmail(baseUrl, email, client_secret, send_attempt, next_link) {
- const res = await fetch(`${baseUrl}/_matrix/client/r0/register/email/requestToken`, {
- method: 'POST',
- body: JSON.stringify({
- email, client_secret, send_attempt, next_link,
- }),
- headers: {
- 'Content-Type': 'application/json; charset=utf-8',
- },
- credentials: 'same-origin',
- });
- const data = await res.json();
- return data;
-}
-
-async function completeRegisterStage(
- baseUrl, username, password, auth,
-) {
- const tempClient = createTemporaryClient(baseUrl);
-
- try {
- const result = await tempClient.registerRequest({
- username,
- password,
- auth,
- initial_device_display_name: cons.DEVICE_DISPLAY_NAME,
- });
- const data = { completed: result.completed || [] };
- if (result.access_token) {
- data.done = true;
- updateLocalStore(result.access_token, result.device_id, result.user_id, baseUrl);
- }
- return data;
- } catch (e) {
- const result = e.data;
- const data = { completed: result.completed || [] };
- if (result.access_token) {
- data.done = true;
- updateLocalStore(result.access_token, result.device_id, result.user_id, baseUrl);
- }
- return data;
- }
-}
-
-export {
- updateLocalStore, createTemporaryClient, login, verifyEmail,
- loginWithToken, startSsoLogin,
- completeRegisterStage,
-};
--- /dev/null
+import cons from '../state/cons';
+
+export function updateLocalStore(
+ accessToken: string,
+ deviceId: string,
+ userId: string,
+ baseUrl: string
+) {
+ localStorage.setItem(cons.secretKey.ACCESS_TOKEN, accessToken);
+ localStorage.setItem(cons.secretKey.DEVICE_ID, deviceId);
+ localStorage.setItem(cons.secretKey.USER_ID, userId);
+ localStorage.setItem(cons.secretKey.BASE_URL, baseUrl);
+}
import appDispatcher from '../dispatcher';
import cons from '../state/cons';
-export function selectTab(tabId) {
- appDispatcher.dispatch({
- type: cons.actions.navigation.SELECT_TAB,
- tabId,
- });
-}
-
-export function selectSpace(roomId) {
- appDispatcher.dispatch({
- type: cons.actions.navigation.SELECT_SPACE,
- roomId,
- });
-}
-
-export function selectRoom(roomId, eventId) {
- appDispatcher.dispatch({
- type: cons.actions.navigation.SELECT_ROOM,
- roomId,
- eventId,
- });
-}
-
-// Open navigation on compact screen sizes
-export function openNavigation() {
- appDispatcher.dispatch({
- type: cons.actions.navigation.OPEN_NAVIGATION,
- });
-}
-
export function openSpaceSettings(roomId, tabText) {
appDispatcher.dispatch({
type: cons.actions.navigation.OPEN_SPACE_SETTINGS,
});
}
-export function openSpaceManage(roomId) {
- appDispatcher.dispatch({
- type: cons.actions.navigation.OPEN_SPACE_MANAGE,
- roomId,
- });
-}
-
export function openSpaceAddExisting(roomId, spaces = false) {
appDispatcher.dispatch({
type: cons.actions.navigation.OPEN_SPACE_ADDEXISTING,
});
}
-export function openShortcutSpaces() {
- appDispatcher.dispatch({
- type: cons.actions.navigation.OPEN_SHORTCUT_SPACES,
- });
-}
-
-export function openInviteList() {
- appDispatcher.dispatch({
- type: cons.actions.navigation.OPEN_INVITE_LIST,
- });
-}
-
-export function openPublicRooms(searchTerm) {
- appDispatcher.dispatch({
- type: cons.actions.navigation.OPEN_PUBLIC_ROOMS,
- searchTerm,
- });
-}
export function openCreateRoom(isSpace = false, parentId = null) {
appDispatcher.dispatch({
});
}
-export function openEmojiBoard(cords, requestEmojiCallback) {
- appDispatcher.dispatch({
- type: cons.actions.navigation.OPEN_EMOJIBOARD,
- cords,
- requestEmojiCallback,
- });
-}
-
-export function openReadReceipts(roomId, userIds) {
- appDispatcher.dispatch({
- type: cons.actions.navigation.OPEN_READRECEIPTS,
- roomId,
- userIds,
- });
-}
-
-export function openViewSource(event) {
- appDispatcher.dispatch({
- type: cons.actions.navigation.OPEN_VIEWSOURCE,
- event,
- });
-}
-
-export function replyTo(userId, eventId, body, formattedBody) {
- appDispatcher.dispatch({
- type: cons.actions.navigation.CLICK_REPLY_TO,
- userId,
- eventId,
- body,
- formattedBody,
- });
-}
-
export function openSearch(term) {
appDispatcher.dispatch({
type: cons.actions.navigation.OPEN_SEARCH,
const mx = initMatrix.matrixClient;
const room = mx.getRoom(roomId);
if (!room) return;
- initMatrix.notifications.deleteNoti(roomId);
const timeline = room.getLiveTimeline().getEvents();
const readEventId = room.getEventReadUpTo(mx.getUserId());
const targetUserId = guessDMRoomTargetId(mx.getRoom(resultRoom.roomId), mx.getUserId());
await addRoomToMDirect(resultRoom.roomId, targetUserId);
}
- appDispatcher.dispatch({
- type: cons.actions.room.JOIN,
- roomId: resultRoom.roomId,
- isDM,
- });
return resultRoom.roomId;
} catch (e) {
throw new Error(e);
}
}
-/**
- *
- * @param {string} roomId
- * @param {boolean} isDM
- */
-async function leave(roomId) {
- const mx = initMatrix.matrixClient;
- const isDM = initMatrix.roomList.directs.has(roomId);
- try {
- await mx.leave(roomId);
- appDispatcher.dispatch({
- type: cons.actions.room.LEAVE,
- roomId,
- isDM,
- });
- } catch {
- console.error('Unable to leave room.');
- }
-}
-
async function create(options, isDM = false) {
const mx = initMatrix.matrixClient;
try {
if (isDM && typeof options.invite?.[0] === 'string') {
await addRoomToMDirect(result.room_id, options.invite[0]);
}
- appDispatcher.dispatch({
- type: cons.actions.room.CREATE,
- roomId: result.room_id,
- isDM,
- });
return result;
} catch (e) {
const errcodes = ['M_UNKNOWN', 'M_BAD_JSON', 'M_ROOM_IN_USE', 'M_INVALID_ROOM_STATE', 'M_UNSUPPORTED_ROOM_VERSION'];
export {
convertToDm,
convertToRoom,
- join, leave,
+ join,
createDM, createRoom,
invite, kick, ban, unban,
ignore, unignore,
+++ /dev/null
-import initMatrix from '../initMatrix';
-
-async function redactEvent(roomId, eventId, reason) {
- const mx = initMatrix.matrixClient;
-
- try {
- await mx.redactEvent(roomId, eventId, undefined, typeof reason === 'undefined' ? undefined : { reason });
- return true;
- } catch (e) {
- throw new Error(e);
- }
-}
-
-async function sendReaction(roomId, toEventId, reaction, shortcode) {
- const mx = initMatrix.matrixClient;
- const content = {
- 'm.relates_to': {
- event_id: toEventId,
- key: reaction,
- rel_type: 'm.annotation',
- },
- };
- if (typeof shortcode === 'string') content.shortcode = shortcode;
- try {
- await mx.sendEvent(roomId, 'm.reaction', content);
- } catch (e) {
- throw new Error(e);
- }
-}
-
-export {
- redactEvent,
- sendReaction,
-};
import { openSearch } from '../action/navigation';
import navigation from '../state/navigation';
-import { markAsRead } from '../action/notifications';
-
-function shouldFocusMessageField(code) {
- // do not focus on F keys
- if (/^F\d+$/.test(code)) return false;
-
- // do not focus on numlock/scroll lock
- if (
- code.metaKey
- || code.startsWith('OS')
- || code.startsWith('Meta')
- || code.startsWith('Shift')
- || code.startsWith('Alt')
- || code.startsWith('Control')
- || code.startsWith('Arrow')
- || code === 'Tab'
- || code === 'Space'
- || code === 'Enter'
- || code === 'NumLock'
- || code === 'ScrollLock'
- ) {
- return false;
- }
-
- return true;
-}
function listenKeyboard(event) {
// Ctrl/Cmd +
if (navigation.isRawModalVisible) return;
openSearch();
}
-
- // focus message field on paste
- if (event.key === 'v') {
- if (navigation.isRawModalVisible) return;
- const msgTextarea = document.getElementById('message-textarea');
- const { activeElement } = document;
- if (activeElement !== msgTextarea
- && ['input', 'textarea'].includes(activeElement.tagName.toLowerCase())
- ) return;
- msgTextarea?.focus();
- }
- }
-
- if (!event.ctrlKey && !event.altKey && !event.metaKey) {
- if (navigation.isRawModalVisible) return;
-
- if (event.key === 'Escape') {
- if (navigation.selectedRoomId) {
- markAsRead(navigation.selectedRoomId);
- return;
- }
- }
-
- if (['input', 'textarea'].includes(document.activeElement.tagName.toLowerCase())) {
- return;
- }
-
- // focus the text field on most keypresses
- if (shouldFocusMessageField(event.code)) {
- // press any key to focus and type in message field
- const msgTextarea = document.getElementById('message-textarea');
- msgTextarea?.focus();
- }
}
}
+++ /dev/null
-import cons from '../state/cons';
-import navigation from '../state/navigation';
-import { selectTab, selectSpace, selectRoom } from '../action/navigation';
-
-function initRoomListListener(roomList) {
- const listenRoomLeave = (roomId) => {
- const parents = roomList.roomIdToParents.get(roomId);
-
- if (parents) {
- [...parents].forEach((pId) => {
- const data = navigation.spaceToRoom.get(pId);
- if (data?.roomId === roomId) {
- navigation.spaceToRoom.delete(pId);
- }
- });
- }
-
- if (navigation.selectedRoomId === roomId) {
- selectRoom(null);
- }
-
- if (navigation.selectedSpacePath.includes(roomId)) {
- const idIndex = navigation.selectedSpacePath.indexOf(roomId);
- if (idIndex === 0) selectTab(cons.tabs.HOME);
- else selectSpace(navigation.selectedSpacePath[idIndex - 1]);
- }
-
- navigation.removeRecentRoom(roomId);
- };
-
- roomList.on(cons.events.roomList.ROOM_LEAVED, listenRoomLeave);
- return () => {
- roomList.removeListener(cons.events.roomList.ROOM_LEAVED, listenRoomLeave);
- };
-}
-
-// eslint-disable-next-line import/prefer-default-export
-export { initRoomListListener };
import { logger } from 'matrix-js-sdk/lib/logger';
import { getSecret } from './state/auth';
-import RoomList from './state/RoomList';
-import AccountData from './state/AccountData';
-import RoomsInput from './state/RoomsInput';
-import Notifications from './state/Notifications';
import { cryptoCallbacks } from './state/secretStorageKeys';
-import navigation from './state/navigation';
global.Olm = Olm;
}
class InitMatrix extends EventEmitter {
- constructor() {
- super();
-
- navigation.initMatrix = this;
- }
-
async init() {
if (this.matrixClient || this.initializing) {
console.warn('Client is already initialized!')
PREPARED: (prevState) => {
console.log('PREPARED state');
console.log('Previous state: ', prevState);
- // TODO: remove global.initMatrix at end
global.initMatrix = this;
if (prevState === null) {
- this.roomList = new RoomList(this.matrixClient);
- this.accountData = new AccountData(this.roomList);
- this.roomsInput = new RoomsInput(this.matrixClient, this.roomList);
- this.notifications = new Notifications(this.roomList);
this.emit('init_loading_finished');
- this.notifications._initNoti();
- } else {
- this.notifications?._initNoti();
}
},
RECONNECTING: () => {
+++ /dev/null
-import EventEmitter from 'events';
-import appDispatcher from '../dispatcher';
-import cons from './cons';
-
-class AccountData extends EventEmitter {
- constructor(roomList) {
- super();
-
- this.matrixClient = roomList.matrixClient;
- this.roomList = roomList;
- this.spaces = roomList.spaces;
-
- this.spaceShortcut = new Set();
- this._populateSpaceShortcut();
-
- this.categorizedSpaces = new Set();
- this._populateCategorizedSpaces();
-
- this._listenEvents();
-
- appDispatcher.register(this.accountActions.bind(this));
- }
-
- _getAccountData() {
- return this.matrixClient.getAccountData(cons.IN_CINNY_SPACES)?.getContent() || {};
- }
-
- _populateSpaceShortcut() {
- this.spaceShortcut.clear();
- const spacesContent = this._getAccountData();
-
- if (spacesContent?.shortcut === undefined) return;
-
- spacesContent.shortcut.forEach((shortcut) => {
- if (this.spaces.has(shortcut)) this.spaceShortcut.add(shortcut);
- });
- if (spacesContent.shortcut.length !== this.spaceShortcut.size) {
- // update shortcut list from account data if shortcut space doesn't exist.
- // TODO: we can wait for sync to complete or else we may end up removing valid space id
- this._updateSpaceShortcutData([...this.spaceShortcut]);
- }
- }
-
- _updateSpaceShortcutData(shortcutList) {
- const spaceContent = this._getAccountData();
- spaceContent.shortcut = shortcutList;
- this.matrixClient.setAccountData(cons.IN_CINNY_SPACES, spaceContent);
- }
-
- _populateCategorizedSpaces() {
- this.categorizedSpaces.clear();
- const spaceContent = this._getAccountData();
-
- if (spaceContent?.categorized === undefined) return;
-
- spaceContent.categorized.forEach((spaceId) => {
- if (this.spaces.has(spaceId)) this.categorizedSpaces.add(spaceId);
- });
- if (spaceContent.categorized.length !== this.categorizedSpaces.size) {
- // TODO: we can wait for sync to complete or else we may end up removing valid space id
- this._updateCategorizedSpacesData([...this.categorizedSpaces]);
- }
- }
-
- _updateCategorizedSpacesData(categorizedSpaceList) {
- const spaceContent = this._getAccountData();
- spaceContent.categorized = categorizedSpaceList;
- this.matrixClient.setAccountData(cons.IN_CINNY_SPACES, spaceContent);
- }
-
- accountActions(action) {
- const actions = {
- [cons.actions.accountData.CREATE_SPACE_SHORTCUT]: () => {
- const addRoomId = (id) => {
- if (this.spaceShortcut.has(id)) return;
- this.spaceShortcut.add(id);
- };
- if (Array.isArray(action.roomId)) {
- action.roomId.forEach(addRoomId);
- } else {
- addRoomId(action.roomId);
- }
- this._updateSpaceShortcutData([...this.spaceShortcut]);
- this.emit(cons.events.accountData.SPACE_SHORTCUT_UPDATED, action.roomId);
- },
- [cons.actions.accountData.DELETE_SPACE_SHORTCUT]: () => {
- if (!this.spaceShortcut.has(action.roomId)) return;
- this.spaceShortcut.delete(action.roomId);
- this._updateSpaceShortcutData([...this.spaceShortcut]);
- this.emit(cons.events.accountData.SPACE_SHORTCUT_UPDATED, action.roomId);
- },
- [cons.actions.accountData.MOVE_SPACE_SHORTCUTS]: () => {
- const { roomId, toIndex } = action;
- if (!this.spaceShortcut.has(roomId)) return;
- this.spaceShortcut.delete(roomId);
- const ssList = [...this.spaceShortcut];
- if (toIndex >= ssList.length) ssList.push(roomId);
- else ssList.splice(toIndex, 0, roomId);
- this.spaceShortcut = new Set(ssList);
- this._updateSpaceShortcutData(ssList);
- this.emit(cons.events.accountData.SPACE_SHORTCUT_UPDATED, roomId);
- },
- [cons.actions.accountData.CATEGORIZE_SPACE]: () => {
- if (this.categorizedSpaces.has(action.roomId)) return;
- this.categorizedSpaces.add(action.roomId);
- this._updateCategorizedSpacesData([...this.categorizedSpaces]);
- this.emit(cons.events.accountData.CATEGORIZE_SPACE_UPDATED, action.roomId);
- },
- [cons.actions.accountData.UNCATEGORIZE_SPACE]: () => {
- if (!this.categorizedSpaces.has(action.roomId)) return;
- this.categorizedSpaces.delete(action.roomId);
- this._updateCategorizedSpacesData([...this.categorizedSpaces]);
- this.emit(cons.events.accountData.CATEGORIZE_SPACE_UPDATED, action.roomId);
- },
- };
- actions[action.type]?.();
- }
-
- _listenEvents() {
- this.matrixClient.on('accountData', (event) => {
- if (event.getType() !== cons.IN_CINNY_SPACES) return;
- this._populateSpaceShortcut();
- this.emit(cons.events.accountData.SPACE_SHORTCUT_UPDATED);
- this._populateCategorizedSpaces();
- this.emit(cons.events.accountData.CATEGORIZE_SPACE_UPDATED);
- });
-
- this.roomList.on(cons.events.roomList.ROOM_LEAVED, (roomId) => {
- if (this.spaceShortcut.has(roomId)) {
- // if deleted space has shortcut remove it.
- this.spaceShortcut.delete(roomId);
- this._updateSpaceShortcutData([...this.spaceShortcut]);
- this.emit(cons.events.accountData.SPACE_SHORTCUT_UPDATED, roomId);
- }
- if (this.categorizedSpaces.has(roomId)) {
- this.categorizedSpaces.delete(roomId);
- this._updateCategorizedSpacesData([...this.categorizedSpaces]);
- this.emit(cons.events.accountData.CATEGORIZE_SPACE_UPDATED, roomId);
- }
- });
- }
-}
-
-export default AccountData;
+++ /dev/null
-import EventEmitter from 'events';
-import renderAvatar from '../../app/atoms/avatar/render';
-import { cssColorMXID } from '../../util/colorMXID';
-import { selectRoom } from '../action/navigation';
-import cons from './cons';
-import navigation from './navigation';
-import settings from './settings';
-import { setFavicon } from '../../util/common';
-
-import LogoSVG from '../../../public/res/svg/cinny.svg';
-import LogoUnreadSVG from '../../../public/res/svg/cinny-unread.svg';
-import LogoHighlightSVG from '../../../public/res/svg/cinny-highlight.svg';
-import { html, plain } from '../../util/markdown';
-
-function isNotifEvent(mEvent) {
- const eType = mEvent.getType();
- if (!cons.supportEventTypes.includes(eType)) return false;
- if (eType === 'm.room.member') return false;
-
- if (mEvent.isRedacted()) return false;
- if (mEvent.getRelation()?.rel_type === 'm.replace') return false;
-
- return true;
-}
-
-function isMutedRule(rule) {
- return rule.actions[0] === 'dont_notify' && rule.conditions[0].kind === 'event_match';
-}
-
-function findMutedRule(overrideRules, roomId) {
- return overrideRules.find((rule) => (
- rule.rule_id === roomId
- && isMutedRule(rule)
- ));
-}
-
-class Notifications extends EventEmitter {
- constructor(roomList) {
- super();
-
- this.initialized = false;
- this.favicon = LogoSVG;
- this.matrixClient = roomList.matrixClient;
- this.roomList = roomList;
-
- this.roomIdToNoti = new Map();
- this.roomIdToPopupNotis = new Map();
- this.eventIdToPopupNoti = new Map();
-
- // this._initNoti();
- this._listenEvents();
-
- // Ask for permission by default after loading
- window.Notification?.requestPermission();
- }
-
- async _initNoti() {
- this.initialized = false;
- this.roomIdToNoti = new Map();
-
- const addNoti = (roomId) => {
- const room = this.matrixClient.getRoom(roomId);
- if (this.getNotiType(room.roomId) === cons.notifs.MUTE) return;
- if (this.doesRoomHaveUnread(room) === false) return;
-
- const total = room.getUnreadNotificationCount('total');
- const highlight = room.getUnreadNotificationCount('highlight');
- this._setNoti(room.roomId, total ?? 0, highlight ?? 0);
- };
- [...this.roomList.rooms].forEach(addNoti);
- [...this.roomList.directs].forEach(addNoti);
-
- this.initialized = true;
- this._updateFavicon();
- }
-
- doesRoomHaveUnread(room) {
- const userId = this.matrixClient.getUserId();
- const readUpToId = room.getEventReadUpTo(userId);
- const liveEvents = room.getLiveTimeline().getEvents();
-
- if (liveEvents[liveEvents.length - 1]?.getSender() === userId) {
- return false;
- }
-
- for (let i = liveEvents.length - 1; i >= 0; i -= 1) {
- const event = liveEvents[i];
- if (event.getId() === readUpToId) return false;
- if (isNotifEvent(event)) return true;
- }
- return true;
- }
-
- getNotiType(roomId) {
- const mx = this.matrixClient;
- let pushRule;
- try {
- pushRule = mx.getRoomPushRule('global', roomId);
- } catch {
- pushRule = undefined;
- }
-
- if (pushRule === undefined) {
- const overrideRules = mx.getAccountData('m.push_rules')?.getContent()?.global?.override;
- if (overrideRules === undefined) return cons.notifs.DEFAULT;
-
- const isMuted = findMutedRule(overrideRules, roomId);
-
- return isMuted ? cons.notifs.MUTE : cons.notifs.DEFAULT;
- }
- if (pushRule.actions[0] === 'notify') return cons.notifs.ALL_MESSAGES;
- return cons.notifs.MENTIONS_AND_KEYWORDS;
- }
-
- getNoti(roomId) {
- return this.roomIdToNoti.get(roomId) || { total: 0, highlight: 0, from: null };
- }
-
- getTotalNoti(roomId) {
- const { total } = this.getNoti(roomId);
- return total;
- }
-
- getHighlightNoti(roomId) {
- const { highlight } = this.getNoti(roomId);
- return highlight;
- }
-
- getFromNoti(roomId) {
- const { from } = this.getNoti(roomId);
- return from;
- }
-
- hasNoti(roomId) {
- return this.roomIdToNoti.has(roomId);
- }
-
- deleteNoti(roomId) {
- if (this.hasNoti(roomId)) {
- const noti = this.getNoti(roomId);
- this._deleteNoti(roomId, noti.total, noti.highlight);
- }
- }
-
- async _updateFavicon() {
- if (!this.initialized) return;
- let unread = false;
- let highlight = false;
- [...this.roomIdToNoti.values()].find((noti) => {
- if (!unread) {
- unread = noti.total > 0 || noti.highlight > 0;
- }
- highlight = noti.highlight > 0;
- if (unread && highlight) return true;
- return false;
- });
- let newFavicon = LogoSVG;
- if (unread && !highlight) {
- newFavicon = LogoUnreadSVG;
- }
- if (unread && highlight) {
- newFavicon = LogoHighlightSVG;
- }
- if (newFavicon === this.favicon) return;
- this.favicon = newFavicon;
- setFavicon(this.favicon);
- }
-
- _setNoti(roomId, total, highlight) {
- const addNoti = (id, t, h, fromId) => {
- const prevTotal = this.roomIdToNoti.get(id)?.total ?? null;
- const noti = this.getNoti(id);
-
- noti.total += t;
- noti.highlight += h;
-
- if (fromId) {
- if (noti.from === null) noti.from = new Set();
- noti.from.add(fromId);
- }
- this.roomIdToNoti.set(id, noti);
- this.emit(cons.events.notifications.NOTI_CHANGED, id, noti.total, prevTotal);
- };
-
- const noti = this.getNoti(roomId);
- const addT = (highlight > total ? highlight : total) - noti.total;
- const addH = highlight - noti.highlight;
- if (addT < 0 || addH < 0) return;
-
- addNoti(roomId, addT, addH);
- const allParentSpaces = this.roomList.getAllParentSpaces(roomId);
- allParentSpaces.forEach((spaceId) => {
- addNoti(spaceId, addT, addH, roomId);
- });
- this._updateFavicon();
- }
-
- _deleteNoti(roomId, total, highlight) {
- const removeNoti = (id, t, h, fromId) => {
- if (this.roomIdToNoti.has(id) === false) return;
-
- const noti = this.getNoti(id);
- const prevTotal = noti.total;
- noti.total -= t;
- noti.highlight -= h;
- if (noti.total < 0) {
- noti.total = 0;
- noti.highlight = 0;
- }
- if (fromId && noti.from !== null) {
- if (!this.hasNoti(fromId)) noti.from.delete(fromId);
- }
- if (noti.from === null || noti.from.size === 0) {
- this.roomIdToNoti.delete(id);
- this.emit(cons.events.notifications.FULL_READ, id);
- this.emit(cons.events.notifications.NOTI_CHANGED, id, null, prevTotal);
- } else {
- this.roomIdToNoti.set(id, noti);
- this.emit(cons.events.notifications.NOTI_CHANGED, id, noti.total, prevTotal);
- }
- };
-
- removeNoti(roomId, total, highlight);
- const allParentSpaces = this.roomList.getAllParentSpaces(roomId);
- allParentSpaces.forEach((spaceId) => {
- removeNoti(spaceId, total, highlight, roomId);
- });
- this._updateFavicon();
- }
-
- async _displayPopupNoti(mEvent, room) {
- if (!settings.showNotifications && !settings.isNotificationSounds) return;
-
- const actions = this.matrixClient.getPushActionsForEvent(mEvent);
- if (!actions?.notify) return;
-
- if (navigation.selectedRoomId === room.roomId && document.hasFocus()) return;
-
- if (mEvent.isEncrypted()) {
- await mEvent.attemptDecryption(this.matrixClient.crypto);
- }
-
- if (settings.showNotifications) {
- let title;
- if (!mEvent.sender || room.name === mEvent.sender.name) {
- title = room.name;
- } else if (mEvent.sender) {
- title = `${mEvent.sender.name} (${room.name})`;
- }
-
- const iconSize = 36;
- const icon = await renderAvatar({
- text: mEvent.sender.name,
- bgColor: cssColorMXID(mEvent.getSender()),
- imageSrc: mEvent.sender?.getAvatarUrl(this.matrixClient.baseUrl, iconSize, iconSize, 'crop'),
- size: iconSize,
- borderRadius: 8,
- scale: 8,
- });
-
- const content = mEvent.getContent();
-
- const state = { kind: 'notification', onlyPlain: true };
- let body;
- if (content.format === 'org.matrix.custom.html') {
- body = html(content.formatted_body, state);
- } else {
- body = plain(content.body, state);
- }
-
- const noti = new window.Notification(title, {
- body: body.plain,
- icon,
- tag: mEvent.getId(),
- silent: settings.isNotificationSounds,
- });
- if (settings.isNotificationSounds) {
- noti.onshow = () => this._playNotiSound();
- }
- noti.onclick = () => selectRoom(room.roomId, mEvent.getId());
-
- this.eventIdToPopupNoti.set(mEvent.getId(), noti);
- if (this.roomIdToPopupNotis.has(room.roomId)) {
- this.roomIdToPopupNotis.get(room.roomId).push(noti);
- } else {
- this.roomIdToPopupNotis.set(room.roomId, [noti]);
- }
- } else {
- this._playNotiSound();
- }
- }
-
- _deletePopupNoti(eventId) {
- this.eventIdToPopupNoti.get(eventId)?.close();
- this.eventIdToPopupNoti.delete(eventId);
- }
-
- _deletePopupRoomNotis(roomId) {
- this.roomIdToPopupNotis.get(roomId)?.forEach((n) => {
- this.eventIdToPopupNoti.delete(n.tag);
- n.close();
- });
- this.roomIdToPopupNotis.delete(roomId);
- }
-
- _playNotiSound() {
- if (!this._notiAudio) {
- this._notiAudio = document.getElementById('notificationSound');
- }
- this._notiAudio.play();
- }
-
- _playInviteSound() {
- if (!this._inviteAudio) {
- this._inviteAudio = document.getElementById('inviteSound');
- }
- this._inviteAudio.play();
- }
-
- _listenEvents() {
- this.matrixClient.on('Room.timeline', (mEvent, room) => {
- if (mEvent.isRedaction()) this._deletePopupNoti(mEvent.event.redacts);
-
- if (room.isSpaceRoom()) return;
- if (!isNotifEvent(mEvent)) return;
-
- const liveEvents = room.getLiveTimeline().getEvents();
-
- const lastTimelineEvent = liveEvents[liveEvents.length - 1];
- if (lastTimelineEvent.getId() !== mEvent.getId()) return;
- if (mEvent.getSender() === this.matrixClient.getUserId()) return;
-
- const total = room.getUnreadNotificationCount('total');
- const highlight = room.getUnreadNotificationCount('highlight');
-
- if (this.getNotiType(room.roomId) === cons.notifs.MUTE) {
- this.deleteNoti(room.roomId, total ?? 0, highlight ?? 0);
- return;
- }
-
- this._setNoti(room.roomId, total ?? 0, highlight ?? 0);
-
- if (this.matrixClient.getSyncState() === 'SYNCING') {
- this._displayPopupNoti(mEvent, room);
- }
- });
-
- this.matrixClient.on('accountData', (mEvent, oldMEvent) => {
- if (mEvent.getType() === 'm.push_rules') {
- const override = mEvent?.getContent()?.global?.override;
- const oldOverride = oldMEvent?.getContent()?.global?.override;
- if (!override || !oldOverride) return;
-
- const isMuteToggled = (rule, otherOverride) => {
- const roomId = rule.rule_id;
- const room = this.matrixClient.getRoom(roomId);
- if (room === null) return false;
- if (room.isSpaceRoom()) return false;
-
- const isMuted = isMutedRule(rule);
- if (!isMuted) return false;
- const isOtherMuted = findMutedRule(otherOverride, roomId);
- if (isOtherMuted) return false;
- return true;
- };
-
- const mutedRules = override.filter((rule) => isMuteToggled(rule, oldOverride));
- const unMutedRules = oldOverride.filter((rule) => isMuteToggled(rule, override));
-
- mutedRules.forEach((rule) => {
- this.emit(cons.events.notifications.MUTE_TOGGLED, rule.rule_id, true);
- this.deleteNoti(rule.rule_id);
- });
- unMutedRules.forEach((rule) => {
- this.emit(cons.events.notifications.MUTE_TOGGLED, rule.rule_id, false);
- const room = this.matrixClient.getRoom(rule.rule_id);
- if (!this.doesRoomHaveUnread(room)) return;
- const total = room.getUnreadNotificationCount('total');
- const highlight = room.getUnreadNotificationCount('highlight');
- this._setNoti(room.roomId, total ?? 0, highlight ?? 0);
- });
- }
- });
-
- this.matrixClient.on('Room.receipt', (mEvent, room) => {
- if (mEvent.getType() !== 'm.receipt' || room.isSpaceRoom()) return;
- const content = mEvent.getContent();
- const userId = this.matrixClient.getUserId();
-
- Object.keys(content).forEach((eventId) => {
- Object.entries(content[eventId]).forEach(([receiptType, receipt]) => {
- if (!cons.supportReceiptTypes.includes(receiptType)) return;
- if (Object.keys(receipt || {}).includes(userId)) {
- this.deleteNoti(room.roomId);
- this._deletePopupRoomNotis(room.roomId);
- }
- });
- });
- });
-
- this.matrixClient.on('Room.myMembership', (room, membership) => {
- if (membership === 'leave' && this.hasNoti(room.roomId)) {
- this.deleteNoti(room.roomId);
- }
- if (membership === 'invite') {
- this._playInviteSound();
- }
- });
- }
-}
-
-export default Notifications;
+++ /dev/null
-import EventEmitter from 'events';
-import appDispatcher from '../dispatcher';
-import cons from './cons';
-
-function isMEventSpaceChild(mEvent) {
- return mEvent.getType() === 'm.space.child' && Object.keys(mEvent.getContent()).length > 0;
-}
-
-/**
- * @param {() => boolean} callback if return true wait will over else callback will be called again.
- * @param {number} timeout timeout to callback
- * @param {number} maxTry maximum callback try > 0. -1 means no limit
- */
-async function waitFor(callback, timeout = 400, maxTry = -1) {
- if (maxTry === 0) return false;
- const isOver = async () => new Promise((resolve) => {
- setTimeout(() => resolve(callback()), timeout);
- });
-
- if (await isOver()) return true;
- return waitFor(callback, timeout, maxTry - 1);
-}
-
-class RoomList extends EventEmitter {
- constructor(matrixClient) {
- super();
- this.matrixClient = matrixClient;
- this.mDirects = this.getMDirects();
-
- // Contains roomId to parent spaces roomId mapping of all spaces children.
- // No matter if you have joined those children rooms or not.
- this.roomIdToParents = new Map();
-
- this.inviteDirects = new Set();
- this.inviteSpaces = new Set();
- this.inviteRooms = new Set();
-
- this.directs = new Set();
- this.spaces = new Set();
- this.rooms = new Set();
-
- this.processingRooms = new Map();
-
- this._populateRooms();
- this._listenEvents();
-
- appDispatcher.register(this.roomActions.bind(this));
- }
-
- isOrphan(roomId) {
- return !this.roomIdToParents.has(roomId);
- }
-
- getOrphanSpaces() {
- return [...this.spaces].filter((roomId) => !this.roomIdToParents.has(roomId));
- }
-
- getOrphanRooms() {
- return [...this.rooms].filter((roomId) => !this.roomIdToParents.has(roomId));
- }
-
- getOrphans() {
- const rooms = [...this.spaces].concat([...this.rooms]);
- return rooms.filter((roomId) => !this.roomIdToParents.has(roomId));
- }
-
- getSpaceChildren(roomId) {
- const space = this.matrixClient.getRoom(roomId);
- if (space === null) return null;
- const mSpaceChild = space?.currentState.getStateEvents('m.space.child');
-
- const children = [];
- mSpaceChild.forEach((mEvent) => {
- const childId = mEvent.event.state_key;
- if (isMEventSpaceChild(mEvent)) children.push(childId);
- });
- return children;
- }
-
- getCategorizedSpaces(spaceIds) {
- const categorized = new Map();
-
- const categorizeSpace = (spaceId) => {
- if (categorized.has(spaceId)) return;
- const mappedChild = new Set();
- categorized.set(spaceId, mappedChild);
-
- const child = this.getSpaceChildren(spaceId);
-
- child.forEach((childId) => {
- const room = this.matrixClient.getRoom(childId);
- if (room === null || room.getMyMembership() !== 'join') return;
- if (room.isSpaceRoom()) categorizeSpace(childId);
- else mappedChild.add(childId);
- });
- };
- spaceIds.forEach(categorizeSpace);
-
- return categorized;
- }
-
- addToRoomIdToParents(roomId, parentRoomId) {
- if (!this.roomIdToParents.has(roomId)) {
- this.roomIdToParents.set(roomId, new Set());
- }
- const parents = this.roomIdToParents.get(roomId);
- parents.add(parentRoomId);
- }
-
- removeFromRoomIdToParents(roomId, parentRoomId) {
- if (!this.roomIdToParents.has(roomId)) return;
- const parents = this.roomIdToParents.get(roomId);
- parents.delete(parentRoomId);
- if (parents.size === 0) this.roomIdToParents.delete(roomId);
- }
-
- getAllParentSpaces(roomId) {
- const allParents = new Set();
-
- const addAllParentIds = (rId) => {
- if (allParents.has(rId)) return;
- allParents.add(rId);
-
- const parents = this.roomIdToParents.get(rId);
- if (parents === undefined) return;
-
- parents.forEach((id) => addAllParentIds(id));
- };
- addAllParentIds(roomId);
- allParents.delete(roomId);
- return allParents;
- }
-
- addToSpaces(roomId) {
- this.spaces.add(roomId);
-
- const allParentSpaces = this.getAllParentSpaces(roomId);
- const spaceChildren = this.getSpaceChildren(roomId);
- spaceChildren?.forEach((childId) => {
- if (allParentSpaces.has(childId)) return;
- this.addToRoomIdToParents(childId, roomId);
- });
- }
-
- deleteFromSpaces(roomId) {
- this.spaces.delete(roomId);
-
- const spaceChildren = this.getSpaceChildren(roomId);
- spaceChildren?.forEach((childId) => {
- this.removeFromRoomIdToParents(childId, roomId);
- });
- }
-
- roomActions(action) {
- const addRoom = (roomId, isDM) => {
- const myRoom = this.matrixClient.getRoom(roomId);
- if (myRoom === null) return false;
-
- if (isDM) this.directs.add(roomId);
- else if (myRoom.isSpaceRoom()) this.addToSpaces(roomId);
- else this.rooms.add(roomId);
- return true;
- };
- const actions = {
- [cons.actions.room.JOIN]: () => {
- if (addRoom(action.roomId, action.isDM)) {
- setTimeout(() => {
- this.emit(cons.events.roomList.ROOM_JOINED, action.roomId);
- this.emit(cons.events.roomList.ROOMLIST_UPDATED);
- }, 100);
- } else {
- this.processingRooms.set(action.roomId, {
- roomId: action.roomId,
- isDM: action.isDM,
- task: 'JOIN',
- });
- }
- },
- [cons.actions.room.CREATE]: () => {
- if (addRoom(action.roomId, action.isDM)) {
- setTimeout(() => {
- this.emit(cons.events.roomList.ROOM_CREATED, action.roomId);
- this.emit(cons.events.roomList.ROOM_JOINED, action.roomId);
- this.emit(cons.events.roomList.ROOMLIST_UPDATED);
- }, 100);
- } else {
- this.processingRooms.set(action.roomId, {
- roomId: action.roomId,
- isDM: action.isDM,
- task: 'CREATE',
- });
- }
- },
- };
- actions[action.type]?.();
- }
-
- getMDirects() {
- const mDirectsId = new Set();
- const mDirect = this.matrixClient
- .getAccountData('m.direct')
- ?.getContent();
-
- if (typeof mDirect === 'undefined') return mDirectsId;
-
- Object.keys(mDirect).forEach((direct) => {
- mDirect[direct].forEach((directId) => mDirectsId.add(directId));
- });
-
- return mDirectsId;
- }
-
- _populateRooms() {
- this.directs.clear();
- this.roomIdToParents.clear();
- this.spaces.clear();
- this.rooms.clear();
- this.inviteDirects.clear();
- this.inviteSpaces.clear();
- this.inviteRooms.clear();
- this.matrixClient.getRooms().forEach((room) => {
- const { roomId } = room;
-
- if (room.getMyMembership() === 'invite') {
- if (this._isDMInvite(room)) this.inviteDirects.add(roomId);
- else if (room.isSpaceRoom()) this.inviteSpaces.add(roomId);
- else this.inviteRooms.add(roomId);
- return;
- }
-
- if (room.getMyMembership() !== 'join') return;
-
- if (this.mDirects.has(roomId)) this.directs.add(roomId);
- else if (room.isSpaceRoom()) this.addToSpaces(roomId);
- else this.rooms.add(roomId);
- });
- }
-
- _isDMInvite(room) {
- if (this.mDirects.has(room.roomId)) return true;
- const me = room.getMember(this.matrixClient.getUserId());
- const myEventContent = me.events.member.getContent();
- return myEventContent.membership === 'invite' && myEventContent.is_direct;
- }
-
- _listenEvents() {
- // Update roomList when m.direct changes
- this.matrixClient.on('accountData', (event) => {
- if (event.getType() !== 'm.direct') return;
-
- const latestMDirects = this.getMDirects();
-
- latestMDirects.forEach((directId) => {
- if (this.mDirects.has(directId)) return;
- this.mDirects.add(directId);
-
- const myRoom = this.matrixClient.getRoom(directId);
- if (myRoom === null) return;
- if (myRoom.getMyMembership() === 'join') {
- this.directs.add(directId);
- this.rooms.delete(directId);
- this.emit(cons.events.roomList.ROOMLIST_UPDATED);
- }
- });
-
- [...this.directs].forEach((directId) => {
- if (latestMDirects.has(directId)) return;
- this.mDirects.delete(directId);
-
- const myRoom = this.matrixClient.getRoom(directId);
- if (myRoom === null) return;
- if (myRoom.getMyMembership() === 'join') {
- this.directs.delete(directId);
- this.rooms.add(directId);
- this.emit(cons.events.roomList.ROOMLIST_UPDATED);
- }
- });
- });
-
- this.matrixClient.on('Room.name', (room) => {
- this.emit(cons.events.roomList.ROOMLIST_UPDATED);
- this.emit(cons.events.roomList.ROOM_PROFILE_UPDATED, room.roomId);
- });
-
- this.matrixClient.on('RoomState.events', (mEvent, state) => {
- if (mEvent.getType() === 'm.space.child') {
- const roomId = mEvent.event.room_id;
- const childId = mEvent.event.state_key;
- if (isMEventSpaceChild(mEvent)) {
- const allParentSpaces = this.getAllParentSpaces(roomId);
- // only add if it doesn't make a cycle
- if (!allParentSpaces.has(childId)) {
- this.addToRoomIdToParents(childId, roomId);
- }
- } else {
- this.removeFromRoomIdToParents(childId, roomId);
- }
- this.emit(cons.events.roomList.ROOMLIST_UPDATED);
- return;
- }
- if (mEvent.getType() === 'm.room.join_rules') {
- this.emit(cons.events.roomList.ROOMLIST_UPDATED);
- return;
- }
- if (['m.room.avatar', 'm.room.topic'].includes(mEvent.getType())) {
- if (mEvent.getType() === 'm.room.avatar') {
- this.emit(cons.events.roomList.ROOMLIST_UPDATED);
- }
- this.emit(cons.events.roomList.ROOM_PROFILE_UPDATED, state.roomId);
- }
- });
-
- this.matrixClient.on('Room.myMembership', async (room, membership, prevMembership) => {
- // room => prevMembership = null | invite | join | leave | kick | ban | unban
- // room => membership = invite | join | leave | kick | ban | unban
- const { roomId } = room;
- const isRoomReady = () => this.matrixClient.getRoom(roomId) !== null;
- if (['join', 'invite'].includes(membership) && isRoomReady() === false) {
- if (await waitFor(isRoomReady, 200, 100) === false) return;
- }
-
- if (membership === 'unban') return;
-
- if (membership === 'invite') {
- if (this._isDMInvite(room)) this.inviteDirects.add(roomId);
- else if (room.isSpaceRoom()) this.inviteSpaces.add(roomId);
- else this.inviteRooms.add(roomId);
-
- this.emit(cons.events.roomList.INVITELIST_UPDATED, roomId);
- return;
- }
-
- if (prevMembership === 'invite') {
- if (this.inviteDirects.has(roomId)) this.inviteDirects.delete(roomId);
- else if (this.inviteSpaces.has(roomId)) this.inviteSpaces.delete(roomId);
- else this.inviteRooms.delete(roomId);
-
- this.emit(cons.events.roomList.INVITELIST_UPDATED, roomId);
- }
-
- if (['leave', 'kick', 'ban'].includes(membership)) {
- if (this.directs.has(roomId)) this.directs.delete(roomId);
- else if (this.spaces.has(roomId)) this.deleteFromSpaces(roomId);
- else this.rooms.delete(roomId);
- this.emit(cons.events.roomList.ROOM_LEAVED, roomId);
- this.emit(cons.events.roomList.ROOMLIST_UPDATED);
- return;
- }
-
- // when user create room/DM OR accept room/dm invite from this client.
- // we will update this.rooms/this.directs with user action
- if (membership === 'join' && this.processingRooms.has(roomId)) {
- const procRoomInfo = this.processingRooms.get(roomId);
-
- if (procRoomInfo.isDM) this.directs.add(roomId);
- else if (room.isSpaceRoom()) this.addToSpaces(roomId);
- else this.rooms.add(roomId);
-
- if (procRoomInfo.task === 'CREATE') this.emit(cons.events.roomList.ROOM_CREATED, roomId);
- this.emit(cons.events.roomList.ROOM_JOINED, roomId);
- this.emit(cons.events.roomList.ROOMLIST_UPDATED);
-
- this.processingRooms.delete(roomId);
- return;
- }
-
- if (this.mDirects.has(roomId) && membership === 'join') {
- this.directs.add(roomId);
- this.emit(cons.events.roomList.ROOM_JOINED, roomId);
- this.emit(cons.events.roomList.ROOMLIST_UPDATED);
- return;
- }
-
- if (membership === 'join') {
- if (room.isSpaceRoom()) this.addToSpaces(roomId);
- else this.rooms.add(roomId);
- this.emit(cons.events.roomList.ROOM_JOINED, roomId);
- this.emit(cons.events.roomList.ROOMLIST_UPDATED);
- }
- });
- }
-}
-export default RoomList;
+++ /dev/null
-import EventEmitter from 'events';
-import initMatrix from '../initMatrix';
-import cons from './cons';
-
-import settings from './settings';
-
-function isEdited(mEvent) {
- return mEvent.getRelation()?.rel_type === 'm.replace';
-}
-
-function isReaction(mEvent) {
- return mEvent.getType() === 'm.reaction';
-}
-
-function hideMemberEvents(mEvent) {
- const content = mEvent.getContent();
- const prevContent = mEvent.getPrevContent();
- const { membership } = content;
- if (settings.hideMembershipEvents) {
- if (membership === 'invite' || membership === 'ban' || membership === 'leave') return true;
- if (prevContent.membership !== 'join') return true;
- }
- if (settings.hideNickAvatarEvents) {
- if (membership === 'join' && prevContent.membership === 'join') return true;
- }
- return false;
-}
-
-function getRelateToId(mEvent) {
- const relation = mEvent.getRelation();
- return relation && relation.event_id;
-}
-
-function addToMap(myMap, mEvent) {
- const relateToId = getRelateToId(mEvent);
- if (relateToId === null) return null;
- const mEventId = mEvent.getId();
-
- if (typeof myMap.get(relateToId) === 'undefined') myMap.set(relateToId, []);
- const mEvents = myMap.get(relateToId);
- if (mEvents.find((ev) => ev.getId() === mEventId)) return mEvent;
- mEvents.push(mEvent);
- return mEvent;
-}
-
-function getFirstLinkedTimeline(timeline) {
- let tm = timeline;
- while (tm.prevTimeline) {
- tm = tm.prevTimeline;
- }
- return tm;
-}
-function getLastLinkedTimeline(timeline) {
- let tm = timeline;
- while (tm.nextTimeline) {
- tm = tm.nextTimeline;
- }
- return tm;
-}
-
-function iterateLinkedTimelines(timeline, backwards, callback) {
- let tm = timeline;
- while (tm) {
- callback(tm);
- if (backwards) tm = tm.prevTimeline;
- else tm = tm.nextTimeline;
- }
-}
-
-function isTimelineLinked(tm1, tm2) {
- let tm = getFirstLinkedTimeline(tm1);
- while (tm) {
- if (tm === tm2) return true;
- tm = tm.nextTimeline;
- }
- return false;
-}
-
-class RoomTimeline extends EventEmitter {
- constructor(roomId) {
- super();
- // These are local timelines
- this.timeline = [];
- this.editedTimeline = new Map();
- this.reactionTimeline = new Map();
- this.typingMembers = new Set();
-
- this.matrixClient = initMatrix.matrixClient;
- this.roomId = roomId;
- this.room = this.matrixClient.getRoom(roomId);
-
- this.liveTimeline = this.room.getLiveTimeline();
- this.activeTimeline = this.liveTimeline;
-
- this.isOngoingPagination = false;
- this.ongoingDecryptionCount = 0;
- this.initialized = false;
-
- setTimeout(() => this.room.loadMembersIfNeeded());
-
- // TODO: remove below line
- window.selectedRoom = this;
- }
-
- isServingLiveTimeline() {
- return getLastLinkedTimeline(this.activeTimeline) === this.liveTimeline;
- }
-
- canPaginateBackward() {
- if (this.timeline[0]?.getType() === 'm.room.create') return false;
- const tm = getFirstLinkedTimeline(this.activeTimeline);
- return tm.getPaginationToken('b') !== null;
- }
-
- canPaginateForward() {
- return !this.isServingLiveTimeline();
- }
-
- isEncrypted() {
- return this.matrixClient.isRoomEncrypted(this.roomId);
- }
-
- clearLocalTimelines() {
- this.timeline = [];
- }
-
- addToTimeline(mEvent) {
- if (mEvent.getType() === 'm.room.member' && hideMemberEvents(mEvent)) {
- return;
- }
- if (mEvent.isRedacted()) return;
- if (isReaction(mEvent)) {
- addToMap(this.reactionTimeline, mEvent);
- return;
- }
- if (!cons.supportEventTypes.includes(mEvent.getType())) return;
- if (isEdited(mEvent)) {
- addToMap(this.editedTimeline, mEvent);
- return;
- }
- this.timeline.push(mEvent);
- }
-
- _populateAllLinkedEvents(timeline) {
- const firstTimeline = getFirstLinkedTimeline(timeline);
- iterateLinkedTimelines(firstTimeline, false, (tm) => {
- tm.getEvents().forEach((mEvent) => this.addToTimeline(mEvent));
- });
- }
-
- _populateTimelines() {
- this.clearLocalTimelines();
- this._populateAllLinkedEvents(this.activeTimeline);
- }
-
- async _reset() {
- if (this.isEncrypted()) await this.decryptAllEventsOfTimeline(this.activeTimeline);
- this._populateTimelines();
- if (!this.initialized) {
- this.initialized = true;
- this._listenEvents();
- }
- }
-
- async loadLiveTimeline() {
- this.activeTimeline = this.liveTimeline;
- await this._reset();
- this.emit(cons.events.roomTimeline.READY, null);
- return true;
- }
-
- async loadEventTimeline(eventId) {
- // we use first unfiltered EventTimelineSet for room pagination.
- const timelineSet = this.getUnfilteredTimelineSet();
- try {
- const eventTimeline = await this.matrixClient.getEventTimeline(timelineSet, eventId);
- this.activeTimeline = eventTimeline;
- await this._reset();
- this.emit(cons.events.roomTimeline.READY, eventId);
- return true;
- } catch {
- return false;
- }
- }
-
- async paginateTimeline(backwards = false, limit = 30) {
- if (this.initialized === false) return false;
- if (this.isOngoingPagination) return false;
-
- this.isOngoingPagination = true;
-
- const timelineToPaginate = backwards
- ? getFirstLinkedTimeline(this.activeTimeline)
- : getLastLinkedTimeline(this.activeTimeline);
-
- if (timelineToPaginate.getPaginationToken(backwards ? 'b' : 'f') === null) {
- this.emit(cons.events.roomTimeline.PAGINATED, backwards, 0);
- this.isOngoingPagination = false;
- return false;
- }
-
- const oldSize = this.timeline.length;
- try {
- await this.matrixClient.paginateEventTimeline(timelineToPaginate, { backwards, limit });
-
- if (this.isEncrypted()) await this.decryptAllEventsOfTimeline(this.activeTimeline);
- this._populateTimelines();
-
- const loaded = this.timeline.length - oldSize;
- this.emit(cons.events.roomTimeline.PAGINATED, backwards, loaded);
- this.isOngoingPagination = false;
- return true;
- } catch {
- this.emit(cons.events.roomTimeline.PAGINATED, backwards, 0);
- this.isOngoingPagination = false;
- return false;
- }
- }
-
- decryptAllEventsOfTimeline(eventTimeline) {
- const decryptionPromises = eventTimeline
- .getEvents()
- .filter((event) => event.isEncrypted() && !event.clearEvent)
- .reverse()
- .map((event) => event.attemptDecryption(this.matrixClient.crypto, { isRetry: true }));
-
- return Promise.allSettled(decryptionPromises);
- }
-
- hasEventInTimeline(eventId, timeline = this.activeTimeline) {
- const timelineSet = this.getUnfilteredTimelineSet();
- const eventTimeline = timelineSet.getTimelineForEvent(eventId);
- if (!eventTimeline) return false;
- return isTimelineLinked(eventTimeline, timeline);
- }
-
- getUnfilteredTimelineSet() {
- return this.room.getUnfilteredTimelineSet();
- }
-
- getEventReaders(mEvent) {
- const liveEvents = this.liveTimeline.getEvents();
- const readers = [];
- if (!mEvent) return [];
-
- for (let i = liveEvents.length - 1; i >= 0; i -= 1) {
- readers.splice(readers.length, 0, ...this.room.getUsersReadUpTo(liveEvents[i]));
- if (mEvent === liveEvents[i]) break;
- }
-
- return [...new Set(readers)];
- }
-
- getLiveReaders() {
- const liveEvents = this.liveTimeline.getEvents();
- const getLatestVisibleEvent = () => {
- for (let i = liveEvents.length - 1; i >= 0; i -= 1) {
- const mEvent = liveEvents[i];
- if (mEvent.getType() === 'm.room.member' && hideMemberEvents(mEvent)) {
- // eslint-disable-next-line no-continue
- continue;
- }
- if (!mEvent.isRedacted()
- && !isReaction(mEvent)
- && !isEdited(mEvent)
- && cons.supportEventTypes.includes(mEvent.getType())
- ) return mEvent;
- }
- return liveEvents[liveEvents.length - 1];
- };
-
- return this.getEventReaders(getLatestVisibleEvent());
- }
-
- getUnreadEventIndex(readUpToEventId) {
- if (!this.hasEventInTimeline(readUpToEventId)) return -1;
-
- const readUpToEvent = this.findEventByIdInTimelineSet(readUpToEventId);
- if (!readUpToEvent) return -1;
- const rTs = readUpToEvent.getTs();
-
- const tLength = this.timeline.length;
-
- for (let i = 0; i < tLength; i += 1) {
- const mEvent = this.timeline[i];
- if (mEvent.getTs() > rTs) return i;
- }
- return -1;
- }
-
- getReadUpToEventId() {
- return this.room.getEventReadUpTo(this.matrixClient.getUserId());
- }
-
- getEventIndex(eventId) {
- return this.timeline.findIndex((mEvent) => mEvent.getId() === eventId);
- }
-
- findEventByIdInTimelineSet(eventId, eventTimelineSet = this.getUnfilteredTimelineSet()) {
- return eventTimelineSet.findEventById(eventId);
- }
-
- findEventById(eventId) {
- return this.timeline[this.getEventIndex(eventId)] ?? null;
- }
-
- deleteFromTimeline(eventId) {
- const i = this.getEventIndex(eventId);
- if (i === -1) return undefined;
- return this.timeline.splice(i, 1)[0];
- }
-
- _listenEvents() {
- this._listenRoomTimeline = (event, room, toStartOfTimeline, removed, data) => {
- if (room.roomId !== this.roomId) return;
- if (this.isOngoingPagination) return;
-
- // User is currently viewing the old events probably
- // no need to add new event and emit changes.
- // only add reactions and edited messages
- if (this.isServingLiveTimeline() === false) {
- if (!isReaction(event) && !isEdited(event)) return;
- }
-
- // We only process live events here
- if (!data.liveEvent) return;
-
- if (event.isEncrypted()) {
- // We will add this event after it is being decrypted.
- this.ongoingDecryptionCount += 1;
- return;
- }
-
- // FIXME: An unencrypted plain event can come
- // while previous event is still decrypting
- // and has not been added to timeline
- // causing unordered timeline view.
-
- this.addToTimeline(event);
- this.emit(cons.events.roomTimeline.EVENT, event);
- };
-
- this._listenDecryptEvent = (event) => {
- if (event.getRoomId() !== this.roomId) return;
- if (this.isOngoingPagination) return;
-
- // Not a live event.
- // so we don't need to process it here
- if (this.ongoingDecryptionCount === 0) return;
-
- if (this.ongoingDecryptionCount > 0) {
- this.ongoingDecryptionCount -= 1;
- }
- this.addToTimeline(event);
- this.emit(cons.events.roomTimeline.EVENT, event);
- };
-
- this._listenRedaction = (mEvent, room) => {
- if (room.roomId !== this.roomId) return;
- const rEvent = this.deleteFromTimeline(mEvent.event.redacts);
- this.editedTimeline.delete(mEvent.event.redacts);
- this.reactionTimeline.delete(mEvent.event.redacts);
- this.emit(cons.events.roomTimeline.EVENT_REDACTED, rEvent, mEvent);
- };
-
- this._listenTypingEvent = (event, member) => {
- if (member.roomId !== this.roomId) return;
-
- const isTyping = member.typing;
- if (isTyping) this.typingMembers.add(member.userId);
- else this.typingMembers.delete(member.userId);
- this.emit(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, new Set([...this.typingMembers]));
- };
- this._listenReciptEvent = (event, room) => {
- // we only process receipt for latest message here.
- if (room.roomId !== this.roomId) return;
- const receiptContent = event.getContent();
-
- const mEvents = this.liveTimeline.getEvents();
- const lastMEvent = mEvents[mEvents.length - 1];
- const lastEventId = lastMEvent.getId();
- const lastEventRecipt = receiptContent[lastEventId];
-
- if (typeof lastEventRecipt === 'undefined') return;
- if (lastEventRecipt['m.read']) {
- this.emit(cons.events.roomTimeline.LIVE_RECEIPT);
- }
- };
-
- this.matrixClient.on('Room.timeline', this._listenRoomTimeline);
- this.matrixClient.on('Room.redaction', this._listenRedaction);
- this.matrixClient.on('Event.decrypted', this._listenDecryptEvent);
- this.matrixClient.on('RoomMember.typing', this._listenTypingEvent);
- this.matrixClient.on('Room.receipt', this._listenReciptEvent);
- }
-
- removeInternalListeners() {
- if (!this.initialized) return;
- this.matrixClient.removeListener('Room.timeline', this._listenRoomTimeline);
- this.matrixClient.removeListener('Room.redaction', this._listenRedaction);
- this.matrixClient.removeListener('Event.decrypted', this._listenDecryptEvent);
- this.matrixClient.removeListener('RoomMember.typing', this._listenTypingEvent);
- this.matrixClient.removeListener('Room.receipt', this._listenReciptEvent);
- }
-}
-
-export default RoomTimeline;
+++ /dev/null
-import { RoomHierarchy } from 'matrix-js-sdk/lib/room-hierarchy';
-
-class RoomsHierarchy {
- constructor(matrixClient, limit = 20, maxDepth = 1, suggestedOnly = false) {
- this.matrixClient = matrixClient;
- this._maxDepth = maxDepth;
- this._suggestedOnly = suggestedOnly;
- this._limit = limit;
-
- this.roomIdToHierarchy = new Map();
- }
-
- getHierarchy(roomId) {
- return this.roomIdToHierarchy.get(roomId);
- }
-
- removeHierarchy(roomId) {
- return this.roomIdToHierarchy.delete(roomId);
- }
-
- canLoadMore(roomId) {
- const roomHierarchy = this.getHierarchy(roomId);
- if (!roomHierarchy) return true;
- return roomHierarchy.canLoadMore;
- }
-
- async load(roomId, limit = this._limit) {
- let roomHierarchy = this.getHierarchy(roomId);
-
- if (!roomHierarchy) {
- roomHierarchy = new RoomHierarchy(
- { roomId, client: this.matrixClient },
- limit,
- this._maxDepth,
- this._suggestedOnly,
- );
- this.roomIdToHierarchy.set(roomId, roomHierarchy);
- }
-
- try {
- await roomHierarchy.load(limit);
- return roomHierarchy.rooms;
- } catch {
- return roomHierarchy.rooms;
- }
- }
-}
-
-export default RoomsHierarchy;
+++ /dev/null
-import EventEmitter from 'events';
-import encrypt from 'browser-encrypt-attachment';
-import { encode } from 'blurhash';
-import { getShortcodeToEmoji } from '../../app/organisms/emoji-board/custom-emoji';
-import { getBlobSafeMimeType } from '../../util/mimetypes';
-import { sanitizeText } from '../../util/sanitize';
-import cons from './cons';
-import settings from './settings';
-import { markdown, plain } from '../../util/markdown';
-
-const blurhashField = 'xyz.amorgan.blurhash';
-
-function encodeBlurhash(img) {
- const canvas = document.createElement('canvas');
- canvas.width = 100;
- canvas.height = 100;
- const context = canvas.getContext('2d');
- context.drawImage(img, 0, 0, canvas.width, canvas.height);
- const data = context.getImageData(0, 0, canvas.width, canvas.height);
- return encode(data.data, data.width, data.height, 4, 4);
-}
-
-function loadImage(url) {
- return new Promise((resolve, reject) => {
- const img = new Image();
- img.onload = () => resolve(img);
- img.onerror = (err) => reject(err);
- img.src = url;
- });
-}
-
-function loadVideo(videoFile) {
- return new Promise((resolve, reject) => {
- const video = document.createElement('video');
- video.preload = 'metadata';
- video.playsInline = true;
- video.muted = true;
-
- const reader = new FileReader();
-
- reader.onload = (ev) => {
- // Wait until we have enough data to thumbnail the first frame.
- video.onloadeddata = async () => {
- resolve(video);
- video.pause();
- };
- video.onerror = (e) => {
- reject(e);
- };
-
- video.src = ev.target.result;
- video.load();
- video.play();
- };
- reader.onerror = (e) => {
- reject(e);
- };
- if (videoFile.type === 'video/quicktime') {
- const quicktimeVideoFile = new File([videoFile], videoFile.name, { type: 'video/mp4' });
- reader.readAsDataURL(quicktimeVideoFile);
- } else {
- reader.readAsDataURL(videoFile);
- }
- });
-}
-function getVideoThumbnail(video, width, height, mimeType) {
- return new Promise((resolve) => {
- const MAX_WIDTH = 800;
- const MAX_HEIGHT = 600;
- let targetWidth = width;
- let targetHeight = height;
- if (targetHeight > MAX_HEIGHT) {
- targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight));
- targetHeight = MAX_HEIGHT;
- }
- if (targetWidth > MAX_WIDTH) {
- targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth));
- targetWidth = MAX_WIDTH;
- }
-
- const canvas = document.createElement('canvas');
- canvas.width = targetWidth;
- canvas.height = targetHeight;
- const context = canvas.getContext('2d');
- context.drawImage(video, 0, 0, targetWidth, targetHeight);
-
- canvas.toBlob((thumbnail) => {
- resolve({
- thumbnail,
- info: {
- w: targetWidth,
- h: targetHeight,
- mimetype: thumbnail.type,
- size: thumbnail.size,
- },
- });
- }, mimeType);
- });
-}
-
-class RoomsInput extends EventEmitter {
- constructor(mx, roomList) {
- super();
-
- this.matrixClient = mx;
- this.roomList = roomList;
- this.roomIdToInput = new Map();
- }
-
- cleanEmptyEntry(roomId) {
- const input = this.getInput(roomId);
- const isEmpty = typeof input.attachment === 'undefined'
- && typeof input.replyTo === 'undefined'
- && (typeof input.message === 'undefined' || input.message === '');
- if (isEmpty) {
- this.roomIdToInput.delete(roomId);
- }
- }
-
- getInput(roomId) {
- return this.roomIdToInput.get(roomId) || {};
- }
-
- setMessage(roomId, message) {
- const input = this.getInput(roomId);
- input.message = message;
- this.roomIdToInput.set(roomId, input);
- if (message === '') this.cleanEmptyEntry(roomId);
- }
-
- getMessage(roomId) {
- const input = this.getInput(roomId);
- if (typeof input.message === 'undefined') return '';
- return input.message;
- }
-
- setReplyTo(roomId, replyTo) {
- const input = this.getInput(roomId);
- input.replyTo = replyTo;
- this.roomIdToInput.set(roomId, input);
- }
-
- getReplyTo(roomId) {
- const input = this.getInput(roomId);
- if (typeof input.replyTo === 'undefined') return null;
- return input.replyTo;
- }
-
- cancelReplyTo(roomId) {
- const input = this.getInput(roomId);
- if (typeof input.replyTo === 'undefined') return;
- delete input.replyTo;
- this.roomIdToInput.set(roomId, input);
- }
-
- setAttachment(roomId, file) {
- const input = this.getInput(roomId);
- input.attachment = {
- file,
- };
- this.roomIdToInput.set(roomId, input);
- }
-
- getAttachment(roomId) {
- const input = this.getInput(roomId);
- if (typeof input.attachment === 'undefined') return null;
- return input.attachment.file;
- }
-
- cancelAttachment(roomId) {
- const input = this.getInput(roomId);
- if (typeof input.attachment === 'undefined') return;
-
- const { uploadingPromise } = input.attachment;
-
- if (uploadingPromise) {
- this.matrixClient.cancelUpload(uploadingPromise);
- delete input.attachment.uploadingPromise;
- }
- delete input.attachment;
- delete input.isSending;
- this.roomIdToInput.set(roomId, input);
- this.emit(cons.events.roomsInput.ATTACHMENT_CANCELED, roomId);
- }
-
- isSending(roomId) {
- return this.roomIdToInput.get(roomId)?.isSending || false;
- }
-
- getContent(roomId, options, message, reply, edit) {
- const msgType = options?.msgType || 'm.text';
- const autoMarkdown = options?.autoMarkdown ?? true;
-
- const room = this.matrixClient.getRoom(roomId);
-
- const userNames = room.currentState.userIdsToDisplayNames;
- const parentIds = this.roomList.getAllParentSpaces(room.roomId);
- const parentRooms = [...parentIds].map((id) => this.matrixClient.getRoom(id));
- const emojis = getShortcodeToEmoji(this.matrixClient, [room, ...parentRooms]);
-
- const output = settings.isMarkdown && autoMarkdown ? markdown : plain;
- const body = output(message, { userNames, emojis });
-
- const content = {
- body: body.plain,
- msgtype: msgType,
- };
-
- if (!body.onlyPlain || reply) {
- content.format = 'org.matrix.custom.html';
- content.formatted_body = body.html;
- }
-
- if (edit) {
- content['m.new_content'] = { ...content };
- content['m.relates_to'] = {
- event_id: edit.getId(),
- rel_type: 'm.replace',
- };
-
- const isReply = edit.getWireContent()['m.relates_to']?.['m.in_reply_to'];
- if (isReply) {
- content.format = 'org.matrix.custom.html';
- content.formatted_body = body.html;
- }
-
- content.body = ` * ${content.body}`;
- if (content.formatted_body) content.formatted_body = ` * ${content.formatted_body}`;
-
- if (isReply) {
- const eBody = edit.getContent().body;
- const replyHead = eBody.substring(0, eBody.indexOf('\n\n'));
- if (replyHead) content.body = `${replyHead}\n\n${content.body}`;
-
- const eFBody = edit.getContent().formatted_body;
- const fReplyHead = eFBody.substring(0, eFBody.indexOf('</mx-reply>'));
- if (fReplyHead) content.formatted_body = `${fReplyHead}</mx-reply>${content.formatted_body}`;
- }
- }
-
- if (reply) {
- content['m.relates_to'] = {
- 'm.in_reply_to': {
- event_id: reply.eventId,
- },
- };
-
- content.body = `> <${reply.userId}> ${reply.body.replace(/\n/g, '\n> ')}\n\n${content.body}`;
-
- const replyToLink = `<a href="https://matrix.to/#/${encodeURIComponent(roomId)}/${encodeURIComponent(reply.eventId)}">In reply to</a>`;
- const userLink = `<a href="https://matrix.to/#/${encodeURIComponent(reply.userId)}">${sanitizeText(reply.userId)}</a>`;
- const fallback = `<mx-reply><blockquote>${replyToLink}${userLink}<br />${reply.formattedBody || sanitizeText(reply.body)}</blockquote></mx-reply>`;
- content.formatted_body = fallback + content.formatted_body;
- }
-
- return content;
- }
-
- async sendInput(roomId, options) {
- const input = this.getInput(roomId);
- input.isSending = true;
- this.roomIdToInput.set(roomId, input);
- if (input.attachment) {
- await this.sendFile(roomId, input.attachment.file);
- if (!this.isSending(roomId)) return;
- }
-
- if (this.getMessage(roomId).trim() !== '') {
- const content = this.getContent(roomId, options, input.message, input.replyTo);
- this.matrixClient.sendMessage(roomId, content);
- }
-
- if (this.isSending(roomId)) this.roomIdToInput.delete(roomId);
- this.emit(cons.events.roomsInput.MESSAGE_SENT, roomId);
- }
-
- async sendSticker(roomId, data) {
- const { mxc: url, body, httpUrl } = data;
- const info = {};
-
- const img = new Image();
- img.src = httpUrl;
-
- try {
- const res = await fetch(httpUrl);
- const blob = await res.blob();
- info.w = img.width;
- info.h = img.height;
- info.mimetype = blob.type;
- info.size = blob.size;
- info.thumbnail_info = { ...info };
- info.thumbnail_url = url;
- } catch {
- // send sticker without info
- }
-
- this.matrixClient.sendEvent(roomId, 'm.sticker', {
- body,
- url,
- info,
- });
- this.emit(cons.events.roomsInput.MESSAGE_SENT, roomId);
- }
-
- async sendFile(roomId, file) {
- const fileType = getBlobSafeMimeType(file.type).slice(0, file.type.indexOf('/'));
- const info = {
- mimetype: file.type,
- size: file.size,
- };
- const content = { info };
- let uploadData = null;
-
- if (fileType === 'image') {
- const img = await loadImage(URL.createObjectURL(file));
-
- info.w = img.width;
- info.h = img.height;
- info[blurhashField] = encodeBlurhash(img);
-
- content.msgtype = 'm.image';
- content.body = file.name || 'Image';
- } else if (fileType === 'video') {
- content.msgtype = 'm.video';
- content.body = file.name || 'Video';
-
- try {
- const video = await loadVideo(file);
-
- info.w = video.videoWidth;
- info.h = video.videoHeight;
- info[blurhashField] = encodeBlurhash(video);
-
- const thumbnailData = await getVideoThumbnail(video, video.videoWidth, video.videoHeight, 'image/jpeg');
- const thumbnailUploadData = await this.uploadFile(roomId, thumbnailData.thumbnail);
- info.thumbnail_info = thumbnailData.info;
- if (this.matrixClient.isRoomEncrypted(roomId)) {
- info.thumbnail_file = thumbnailUploadData.file;
- } else {
- info.thumbnail_url = thumbnailUploadData.url;
- }
- } catch (e) {
- this.emit(cons.events.roomsInput.FILE_UPLOAD_CANCELED, roomId);
- return;
- }
- } else if (fileType === 'audio') {
- content.msgtype = 'm.audio';
- content.body = file.name || 'Audio';
- } else {
- content.msgtype = 'm.file';
- content.body = file.name || 'File';
- }
-
- try {
- uploadData = await this.uploadFile(roomId, file, (data) => {
- // data have two properties: data.loaded, data.total
- this.emit(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, roomId, data);
- });
- this.emit(cons.events.roomsInput.FILE_UPLOADED, roomId);
- } catch (e) {
- this.emit(cons.events.roomsInput.FILE_UPLOAD_CANCELED, roomId);
- return;
- }
- if (this.matrixClient.isRoomEncrypted(roomId)) {
- content.file = uploadData.file;
- await this.matrixClient.sendMessage(roomId, content);
- } else {
- content.url = uploadData.url;
- await this.matrixClient.sendMessage(roomId, content);
- }
- }
-
- async uploadFile(roomId, file, progressHandler) {
- const isEncryptedRoom = this.matrixClient.isRoomEncrypted(roomId);
-
- let encryptInfo = null;
- let encryptBlob = null;
-
- if (isEncryptedRoom) {
- const dataBuffer = await file.arrayBuffer();
- if (typeof this.getInput(roomId).attachment === 'undefined') throw new Error('Attachment canceled');
- const encryptedResult = await encrypt.encryptAttachment(dataBuffer);
- if (typeof this.getInput(roomId).attachment === 'undefined') throw new Error('Attachment canceled');
- encryptInfo = encryptedResult.info;
- encryptBlob = new Blob([encryptedResult.data]);
- }
-
- const uploadingPromise = this.matrixClient.uploadContent(isEncryptedRoom ? encryptBlob : file, {
- // don't send filename if room is encrypted.
- includeFilename: !isEncryptedRoom,
- progressHandler,
- });
-
- const input = this.getInput(roomId);
- input.attachment.uploadingPromise = uploadingPromise;
- this.roomIdToInput.set(roomId, input);
-
- const { content_uri: url } = await uploadingPromise;
-
- delete input.attachment.uploadingPromise;
- this.roomIdToInput.set(roomId, input);
-
- if (isEncryptedRoom) {
- encryptInfo.url = url;
- if (file.type) encryptInfo.mimetype = file.type;
- return { file: encryptInfo };
- }
- return { url };
- }
-
- async sendEditedMessage(roomId, mEvent, editedBody) {
- const content = this.getContent(
- roomId,
- { msgType: mEvent.getWireContent().msgtype },
- editedBody,
- null,
- mEvent,
- );
- this.matrixClient.sendMessage(roomId, content);
- }
-}
-
-export default RoomsInput;
},
DEVICE_DISPLAY_NAME: 'Cinny Web',
IN_CINNY_SPACES: 'in.cinny.spaces',
- tabs: {
- HOME: 'home',
- DIRECTS: 'dm',
- },
supportEventTypes: [
'm.room.create',
'm.room.message',
},
actions: {
navigation: {
- SELECT_TAB: 'SELECT_TAB',
- SELECT_SPACE: 'SELECT_SPACE',
- SELECT_ROOM: 'SELECT_ROOM',
OPEN_SPACE_SETTINGS: 'OPEN_SPACE_SETTINGS',
- OPEN_SPACE_MANAGE: 'OPEN_SPACE_MANAGE',
OPEN_SPACE_ADDEXISTING: 'OPEN_SPACE_ADDEXISTING',
TOGGLE_ROOM_SETTINGS: 'TOGGLE_ROOM_SETTINGS',
- OPEN_SHORTCUT_SPACES: 'OPEN_SHORTCUT_SPACES',
- OPEN_INVITE_LIST: 'OPEN_INVITE_LIST',
- OPEN_PUBLIC_ROOMS: 'OPEN_PUBLIC_ROOMS',
OPEN_CREATE_ROOM: 'OPEN_CREATE_ROOM',
OPEN_JOIN_ALIAS: 'OPEN_JOIN_ALIAS',
OPEN_INVITE_USER: 'OPEN_INVITE_USER',
OPEN_PROFILE_VIEWER: 'OPEN_PROFILE_VIEWER',
OPEN_SETTINGS: 'OPEN_SETTINGS',
- OPEN_EMOJIBOARD: 'OPEN_EMOJIBOARD',
- OPEN_READRECEIPTS: 'OPEN_READRECEIPTS',
- OPEN_VIEWSOURCE: 'OPEN_VIEWSOURCE',
- CLICK_REPLY_TO: 'CLICK_REPLY_TO',
OPEN_SEARCH: 'OPEN_SEARCH',
OPEN_REUSABLE_CONTEXT_MENU: 'OPEN_REUSABLE_CONTEXT_MENU',
- OPEN_NAVIGATION: 'OPEN_NAVIGATION',
OPEN_REUSABLE_DIALOG: 'OPEN_REUSABLE_DIALOG',
OPEN_EMOJI_VERIFICATION: 'OPEN_EMOJI_VERIFICATION',
},
- room: {
- JOIN: 'JOIN',
- LEAVE: 'LEAVE',
- CREATE: 'CREATE',
- },
- accountData: {
- CREATE_SPACE_SHORTCUT: 'CREATE_SPACE_SHORTCUT',
- DELETE_SPACE_SHORTCUT: 'DELETE_SPACE_SHORTCUT',
- MOVE_SPACE_SHORTCUTS: 'MOVE_SPACE_SHORTCUTS',
- CATEGORIZE_SPACE: 'CATEGORIZE_SPACE',
- UNCATEGORIZE_SPACE: 'UNCATEGORIZE_SPACE',
- },
settings: {
TOGGLE_SYSTEM_THEME: 'TOGGLE_SYSTEM_THEME',
TOGGLE_MARKDOWN: 'TOGGLE_MARKDOWN',
},
events: {
navigation: {
- TAB_SELECTED: 'TAB_SELECTED',
- SPACE_SELECTED: 'SPACE_SELECTED',
- ROOM_SELECTED: 'ROOM_SELECTED',
SPACE_SETTINGS_OPENED: 'SPACE_SETTINGS_OPENED',
- SPACE_MANAGE_OPENED: 'SPACE_MANAGE_OPENED',
SPACE_ADDEXISTING_OPENED: 'SPACE_ADDEXISTING_OPENED',
ROOM_SETTINGS_TOGGLED: 'ROOM_SETTINGS_TOGGLED',
- SHORTCUT_SPACES_OPENED: 'SHORTCUT_SPACES_OPENED',
- INVITE_LIST_OPENED: 'INVITE_LIST_OPENED',
- PUBLIC_ROOMS_OPENED: 'PUBLIC_ROOMS_OPENED',
CREATE_ROOM_OPENED: 'CREATE_ROOM_OPENED',
JOIN_ALIAS_OPENED: 'JOIN_ALIAS_OPENED',
INVITE_USER_OPENED: 'INVITE_USER_OPENED',
SETTINGS_OPENED: 'SETTINGS_OPENED',
- PROFILE_VIEWER_OPENED: 'PROFILE_VIEWER_OPENED',
- EMOJIBOARD_OPENED: 'EMOJIBOARD_OPENED',
- READRECEIPTS_OPENED: 'READRECEIPTS_OPENED',
- VIEWSOURCE_OPENED: 'VIEWSOURCE_OPENED',
- REPLY_TO_CLICKED: 'REPLY_TO_CLICKED',
SEARCH_OPENED: 'SEARCH_OPENED',
REUSABLE_CONTEXT_MENU_OPENED: 'REUSABLE_CONTEXT_MENU_OPENED',
- NAVIGATION_OPENED: 'NAVIGATION_OPENED',
REUSABLE_DIALOG_OPENED: 'REUSABLE_DIALOG_OPENED',
EMOJI_VERIFICATION_OPENED: 'EMOJI_VERIFICATION_OPENED',
},
- roomList: {
- ROOMLIST_UPDATED: 'ROOMLIST_UPDATED',
- INVITELIST_UPDATED: 'INVITELIST_UPDATED',
- ROOM_JOINED: 'ROOM_JOINED',
- ROOM_LEAVED: 'ROOM_LEAVED',
- ROOM_CREATED: 'ROOM_CREATED',
- ROOM_PROFILE_UPDATED: 'ROOM_PROFILE_UPDATED',
- },
- accountData: {
- SPACE_SHORTCUT_UPDATED: 'SPACE_SHORTCUT_UPDATED',
- CATEGORIZE_SPACE_UPDATED: 'CATEGORIZE_SPACE_UPDATED',
- },
notifications: {
NOTI_CHANGED: 'NOTI_CHANGED',
FULL_READ: 'FULL_READ',
MUTE_TOGGLED: 'MUTE_TOGGLED',
},
- roomTimeline: {
- READY: 'READY',
- EVENT: 'EVENT',
- PAGINATED: 'PAGINATED',
- TYPING_MEMBERS_UPDATED: 'TYPING_MEMBERS_UPDATED',
- LIVE_RECEIPT: 'LIVE_RECEIPT',
- EVENT_REDACTED: 'EVENT_REDACTED',
- AT_BOTTOM: 'AT_BOTTOM',
- SCROLL_TO_LIVE: 'SCROLL_TO_LIVE',
- },
- roomsInput: {
- MESSAGE_SENT: 'MESSAGE_SENT',
- ATTACHMENT_SET: 'ATTACHMENT_SET',
- FILE_UPLOADED: 'FILE_UPLOADED',
- UPLOAD_PROGRESS_CHANGES: 'UPLOAD_PROGRESS_CHANGES',
- FILE_UPLOAD_CANCELED: 'FILE_UPLOAD_CANCELED',
- ATTACHMENT_CANCELED: 'ATTACHMENT_CANCELED',
- },
settings: {
SYSTEM_THEME_TOGGLED: 'SYSTEM_THEME_TOGGLED',
MARKDOWN_TOGGLED: 'MARKDOWN_TOGGLED',
class Navigation extends EventEmitter {
constructor() {
super();
- // this will attached by initMatrix
- this.initMatrix = {};
-
- this.selectedTab = cons.tabs.HOME;
- this.selectedSpaceId = null;
- this.selectedSpacePath = [cons.tabs.HOME];
-
- this.selectedRoomId = null;
- this.recentRooms = [];
-
- this.spaceToRoom = new Map();
-
this.rawModelStack = [];
}
- _addToSpacePath(roomId, asRoot) {
- if (typeof roomId !== 'string') {
- this.selectedSpacePath = [cons.tabs.HOME];
- return;
- }
- if (asRoot) {
- this.selectedSpacePath = [roomId];
- return;
- }
- if (this.selectedSpacePath.includes(roomId)) {
- const spIndex = this.selectedSpacePath.indexOf(roomId);
- this.selectedSpacePath = this.selectedSpacePath.slice(0, spIndex + 1);
- return;
- }
- this.selectedSpacePath.push(roomId);
- }
-
- _mapRoomToSpace(roomId) {
- const { roomList, accountData } = this.initMatrix;
- if (
- this.selectedTab === cons.tabs.HOME
- && roomList.rooms.has(roomId)
- && !roomList.roomIdToParents.has(roomId)
- ) {
- this.spaceToRoom.set(cons.tabs.HOME, {
- roomId,
- timestamp: Date.now(),
- });
- return;
- }
- if (this.selectedTab === cons.tabs.DIRECTS && roomList.directs.has(roomId)) {
- this.spaceToRoom.set(cons.tabs.DIRECTS, {
- roomId,
- timestamp: Date.now(),
- });
- return;
- }
-
- const parents = roomList.roomIdToParents.get(roomId);
- if (!parents) return;
- if (parents.has(this.selectedSpaceId)) {
- this.spaceToRoom.set(this.selectedSpaceId, {
- roomId,
- timestamp: Date.now(),
- });
- } else if (accountData.categorizedSpaces.has(this.selectedSpaceId)) {
- const categories = roomList.getCategorizedSpaces([this.selectedSpaceId]);
- const parent = [...parents].find((pId) => categories.has(pId));
- if (parent) {
- this.spaceToRoom.set(parent, {
- roomId,
- timestamp: Date.now(),
- });
- }
- }
- }
-
- _selectRoom(roomId, eventId) {
- const prevSelectedRoomId = this.selectedRoomId;
- this.selectedRoomId = roomId;
- if (prevSelectedRoomId !== roomId) this._mapRoomToSpace(roomId);
- this.removeRecentRoom(prevSelectedRoomId);
- this.addRecentRoom(prevSelectedRoomId);
- this.removeRecentRoom(this.selectedRoomId);
- this.emit(
- cons.events.navigation.ROOM_SELECTED,
- this.selectedRoomId,
- prevSelectedRoomId,
- eventId,
- );
- }
-
- _selectTabWithRoom(roomId) {
- const { roomList, accountData } = this.initMatrix;
- const { categorizedSpaces } = accountData;
-
- if (roomList.isOrphan(roomId)) {
- if (roomList.directs.has(roomId)) {
- this._selectSpace(null, true, false);
- this._selectTab(cons.tabs.DIRECTS, false);
- return;
- }
- this._selectSpace(null, true, false);
- this._selectTab(cons.tabs.HOME, false);
- return;
- }
-
- const parents = roomList.roomIdToParents.get(roomId);
-
- if (parents.has(this.selectedSpaceId)) {
- return;
- }
-
- if (categorizedSpaces.has(this.selectedSpaceId)) {
- const categories = roomList.getCategorizedSpaces([this.selectedSpaceId]);
- if ([...parents].find((pId) => categories.has(pId))) {
- // No need to select tab
- // As one of parent is child of selected categorized space.
- return;
- }
- }
-
- const spaceInPath = [...this.selectedSpacePath].reverse().find((sId) => parents.has(sId));
- if (spaceInPath) {
- this._selectSpace(spaceInPath, false, false);
- return;
- }
-
- if (roomList.directs.has(roomId)) {
- this._selectSpace(null, true, false);
- this._selectTab(cons.tabs.DIRECTS, false);
- return;
- }
-
- if (parents.size > 0) {
- const sortedParents = [...parents].sort((p1, p2) => {
- const t1 = this.spaceToRoom.get(p1)?.timestamp ?? 0;
- const t2 = this.spaceToRoom.get(p2)?.timestamp ?? 0;
- return t2 - t1;
- });
- this._selectSpace(sortedParents[0], true, false);
- this._selectTab(sortedParents[0], false);
- }
- }
-
- _getLatestActiveRoomId(roomIds) {
- const mx = this.initMatrix.matrixClient;
-
- let ts = 0;
- let roomId = null;
- roomIds.forEach((childId) => {
- const room = mx.getRoom(childId);
- if (!room) return;
- const newTs = room.getLastActiveTimestamp();
- if (newTs > ts) {
- ts = newTs;
- roomId = childId;
- }
- });
- return roomId;
- }
-
- _getLatestSelectedRoomId(spaceIds) {
- let ts = 0;
- let roomId = null;
-
- spaceIds.forEach((sId) => {
- const data = this.spaceToRoom.get(sId);
- if (!data) return;
- const newTs = data.timestamp;
- if (newTs > ts) {
- ts = newTs;
- roomId = data.roomId;
- }
- });
- return roomId;
- }
-
- _selectTab(tabId, selectRoom = true) {
- this.selectedTab = tabId;
- if (selectRoom) this._selectRoomWithTab(this.selectedTab);
- this.emit(cons.events.navigation.TAB_SELECTED, this.selectedTab);
- }
-
- _selectSpace(roomId, asRoot, selectRoom = true) {
- this._addToSpacePath(roomId, asRoot);
- this.selectedSpaceId = roomId;
- if (!asRoot && selectRoom) this._selectRoomWithSpace(this.selectedSpaceId);
- this.emit(cons.events.navigation.SPACE_SELECTED, this.selectedSpaceId);
- }
-
- _selectRoomWithSpace(spaceId) {
- if (!spaceId) return;
- const { roomList, accountData, matrixClient } = this.initMatrix;
- const { categorizedSpaces } = accountData;
-
- const data = this.spaceToRoom.get(spaceId);
- if (data && !categorizedSpaces.has(spaceId)) {
- this._selectRoom(data.roomId);
- return;
- }
-
- const children = [];
-
- if (categorizedSpaces.has(spaceId)) {
- const categories = roomList.getCategorizedSpaces([spaceId]);
-
- const latestSelectedRoom = this._getLatestSelectedRoomId([...categories.keys()]);
-
- if (latestSelectedRoom) {
- this._selectRoom(latestSelectedRoom);
- return;
- }
-
- categories?.forEach((categoryId) => {
- categoryId?.forEach((childId) => {
- children.push(childId);
- });
- });
- } else {
- roomList.getSpaceChildren(spaceId).forEach((id) => {
- if (matrixClient.getRoom(id)?.isSpaceRoom() === false) {
- children.push(id);
- }
- });
- }
-
- if (!children) {
- this._selectRoom(null);
- return;
- }
-
- this._selectRoom(this._getLatestActiveRoomId(children));
- }
-
- _selectRoomWithTab(tabId) {
- const { roomList } = this.initMatrix;
- if (tabId === cons.tabs.HOME || tabId === cons.tabs.DIRECTS) {
- const data = this.spaceToRoom.get(tabId);
- if (data) {
- this._selectRoom(data.roomId);
- return;
- }
- const children = tabId === cons.tabs.HOME ? roomList.getOrphanRooms() : [...roomList.directs];
- this._selectRoom(this._getLatestActiveRoomId(children));
- return;
- }
- this._selectRoomWithSpace(tabId);
- }
-
- removeRecentRoom(roomId) {
- if (typeof roomId !== 'string') return;
- const roomIdIndex = this.recentRooms.indexOf(roomId);
- if (roomIdIndex >= 0) {
- this.recentRooms.splice(roomIdIndex, 1);
- }
- }
-
- addRecentRoom(roomId) {
- if (typeof roomId !== 'string') return;
-
- this.recentRooms.push(roomId);
- if (this.recentRooms.length > 10) {
- this.recentRooms.splice(0, 1);
- }
- }
-
get isRawModalVisible() {
return this.rawModelStack.length > 0;
}
navigate(action) {
const actions = {
- [cons.actions.navigation.SELECT_TAB]: () => {
- const roomId = (
- action.tabId !== cons.tabs.HOME && action.tabId !== cons.tabs.DIRECTS
- ) ? action.tabId : null;
-
- this._selectSpace(roomId, true);
- this._selectTab(action.tabId);
- },
- [cons.actions.navigation.SELECT_SPACE]: () => {
- this._selectSpace(action.roomId, false);
- },
- [cons.actions.navigation.SELECT_ROOM]: () => {
- if (action.roomId) this._selectTabWithRoom(action.roomId);
- this._selectRoom(action.roomId, action.eventId);
- },
[cons.actions.navigation.OPEN_SPACE_SETTINGS]: () => {
this.emit(cons.events.navigation.SPACE_SETTINGS_OPENED, action.roomId, action.tabText);
},
- [cons.actions.navigation.OPEN_SPACE_MANAGE]: () => {
- this.emit(cons.events.navigation.SPACE_MANAGE_OPENED, action.roomId);
- },
[cons.actions.navigation.OPEN_SPACE_ADDEXISTING]: () => {
this.emit(cons.events.navigation.SPACE_ADDEXISTING_OPENED, action.roomId, action.spaces);
},
action.tabText
);
},
- [cons.actions.navigation.OPEN_SHORTCUT_SPACES]: () => {
- this.emit(cons.events.navigation.SHORTCUT_SPACES_OPENED);
- },
- [cons.actions.navigation.OPEN_INVITE_LIST]: () => {
- this.emit(cons.events.navigation.INVITE_LIST_OPENED);
- },
- [cons.actions.navigation.OPEN_PUBLIC_ROOMS]: () => {
- this.emit(cons.events.navigation.PUBLIC_ROOMS_OPENED, action.searchTerm);
- },
[cons.actions.navigation.OPEN_CREATE_ROOM]: () => {
this.emit(
cons.events.navigation.CREATE_ROOM_OPENED,
[cons.actions.navigation.OPEN_SETTINGS]: () => {
this.emit(cons.events.navigation.SETTINGS_OPENED, action.tabText);
},
- [cons.actions.navigation.OPEN_NAVIGATION]: () => {
- this.emit(cons.events.navigation.NAVIGATION_OPENED);
- },
- [cons.actions.navigation.OPEN_EMOJIBOARD]: () => {
- this.emit(
- cons.events.navigation.EMOJIBOARD_OPENED,
- action.cords,
- action.requestEmojiCallback,
- );
- },
- [cons.actions.navigation.OPEN_READRECEIPTS]: () => {
- this.emit(
- cons.events.navigation.READRECEIPTS_OPENED,
- action.roomId,
- action.userIds,
- );
- },
- [cons.actions.navigation.OPEN_VIEWSOURCE]: () => {
- this.emit(
- cons.events.navigation.VIEWSOURCE_OPENED,
- action.event,
- );
- },
- [cons.actions.navigation.CLICK_REPLY_TO]: () => {
- this.emit(
- cons.events.navigation.REPLY_TO_CLICKED,
- action.userId,
- action.eventId,
- action.body,
- action.formattedBody,
- );
- },
[cons.actions.navigation.OPEN_SEARCH]: () => {
this.emit(
cons.events.navigation.SEARCH_OPENED,
+++ /dev/null
-class Postie {
- constructor() {
- this._topics = new Map();
- }
-
- _getSubscribers(topic) {
- const subscribers = this._topics.get(topic);
- if (subscribers === undefined) {
- throw new Error(`Topic:"${topic}" doesn't exist.`);
- }
- return subscribers;
- }
-
- _getInboxes(topic, address) {
- const subscribers = this._getSubscribers(topic);
- const inboxes = subscribers.get(address);
- if (inboxes === undefined) {
- throw new Error(`Inbox on topic:"${topic}" at address:"${address}" doesn't exist.`);
- }
- return inboxes;
- }
-
- hasTopic(topic) {
- return this._topics.get(topic) !== undefined;
- }
-
- hasSubscriber(topic, address) {
- const subscribers = this._getSubscribers(topic);
- return subscribers.get(address) !== undefined;
- }
-
- hasTopicAndSubscriber(topic, address) {
- return (this.hasTopic(topic))
- ? this.hasSubscriber(topic, address)
- : false;
- }
-
- /**
- * @param {string} topic - Subscription topic
- * @param {string} address - Address of subscriber
- * @param {function} inbox - The inbox function to receive post data
- */
- subscribe(topic, address, inbox) {
- if (typeof inbox !== 'function') {
- throw new TypeError('Inbox must be a function.');
- }
-
- if (this._topics.has(topic) === false) {
- this._topics.set(topic, new Map());
- }
- const subscribers = this._topics.get(topic);
-
- const inboxes = subscribers.get(address) ?? new Set();
- inboxes.add(inbox);
- subscribers.set(address, inboxes);
-
- return () => this.unsubscribe(topic, address, inbox);
- }
-
- unsubscribe(topic, address, inbox) {
- const subscribers = this._getSubscribers(topic);
- if (!subscribers) throw new Error(`Unable to unsubscribe. Topic: "${topic}" doesn't exist.`);
-
- const inboxes = subscribers.get(address);
- if (!inboxes) throw new Error(`Unable to unsubscribe. Subscriber on topic:"${topic}" at address:"${address}" doesn't exist`);
-
- if (!inboxes.delete(inbox)) throw new Error('Unable to unsubscribe. Inbox doesn\'t exist');
-
- if (inboxes.size === 0) subscribers.delete(address);
- if (subscribers.size === 0) this._topics.delete(topic);
- }
-
- /**
- * @param {string} topic - Subscription topic
- * @param {string|string[]} address - Address of subscriber
- * @param {*} data - Data to deliver to subscriber
- */
- post(topic, address, data) {
- const sendPost = (inboxes, addr) => {
- if (inboxes === undefined) {
- throw new Error(`Unable to post on topic:"${topic}" at address:"${addr}". Subscriber doesn't exist.`);
- }
- inboxes.forEach((inbox) => inbox(data));
- };
-
- if (typeof address === 'string') {
- sendPost(this._getInboxes(topic, address), address);
- return;
- }
- const subscribers = this._getSubscribers(topic);
- address.forEach((addr) => {
- sendPost(subscribers.get(addr), addr);
- });
- }
-}
-
-export default Postie;
// https://github.com/cloudrac3r/cadencegq/blob/master/pug/mxid.pug
-export function hashCode(str) {
+function hashCode(str) {
let hash = 0;
let i;
let chr;
+++ /dev/null
-/* eslint-disable no-param-reassign */
-/* eslint-disable no-use-before-define */
-import SimpleMarkdown from '@khanacademy/simple-markdown';
-import { idRegex, parseIdUri } from './common';
-
-const {
- defaultRules, parserFor, outputFor, anyScopeRegex, blockRegex, inlineRegex,
- sanitizeText, sanitizeUrl,
-} = SimpleMarkdown;
-
-function htmlTag(tagName, content, attributes, isClosed) {
- let s = '';
- Object.entries(attributes || {}).forEach(([k, v]) => {
- if (v !== undefined) {
- s += ` ${sanitizeText(k)}`;
- if (v !== null) s += `="${sanitizeText(v)}"`;
- }
- });
-
- s = `<${tagName}${s}>`;
-
- if (isClosed === false) {
- return s;
- }
- return `${s}${content}</${tagName}>`;
-}
-
-function mathHtml(wrap, node) {
- return htmlTag(wrap, htmlTag('code', sanitizeText(node.content)), { 'data-mx-maths': node.content });
-}
-
-const emojiRegex = /^:([\w-]+):/;
-
-const plainRules = {
- Array: {
- ...defaultRules.Array,
- plain: defaultRules.Array.html,
- },
- userMention: {
- order: defaultRules.em.order - 0.9,
- match: inlineRegex(idRegex('@', undefined, '^')),
- parse: (capture, _, state) => ({
- type: 'mention',
- content: state.userNames[capture[1]] ? `@${state.userNames[capture[1]]}` : capture[1],
- id: capture[1],
- }),
- },
- roomMention: {
- order: defaultRules.em.order - 0.8,
- match: inlineRegex(idRegex('#', undefined, '^')),
- parse: (capture) => ({ type: 'mention', content: capture[1], id: capture[1] }),
- },
- mention: {
- plain: (node, _, state) => (state.kind === 'edit' ? node.id : node.content),
- html: (node) => htmlTag('a', sanitizeText(node.content), {
- href: `https://matrix.to/#/${encodeURIComponent(node.id)}`,
- }),
- },
- emoji: {
- order: defaultRules.em.order - 0.1,
- match: (source, state) => {
- if (!state.inline) return null;
- const capture = emojiRegex.exec(source);
- if (!capture) return null;
- const emoji = state.emojis.get(capture[1]);
- if (emoji) return capture;
- return null;
- },
- parse: (capture, _, state) => ({ content: capture[1], emoji: state.emojis.get(capture[1]) }),
- plain: ({ emoji }) => (emoji.mxc
- ? `:${emoji.shortcode}:`
- : emoji.unicode),
- html: ({ emoji }) => (emoji.mxc
- ? htmlTag('img', null, {
- 'data-mx-emoticon': null,
- src: emoji.mxc,
- alt: `:${emoji.shortcode}:`,
- title: `:${emoji.shortcode}:`,
- height: 32,
- }, false)
- : emoji.unicode),
- },
- newline: {
- ...defaultRules.newline,
- plain: () => '\n',
- },
- paragraph: {
- ...defaultRules.paragraph,
- plain: (node, output, state) => `${output(node.content, state)}\n\n`,
- html: (node, output, state) => htmlTag('p', output(node.content, state)),
- },
- escape: {
- ...defaultRules.escape,
- plain: (node, output, state) => `\\${output(node.content, state)}`,
- },
- br: {
- ...defaultRules.br,
- match: anyScopeRegex(/^ *\n/),
- plain: () => '\n',
- },
- text: {
- ...defaultRules.text,
- match: anyScopeRegex(/^[\s\S]+?(?=[^0-9A-Za-z\s\u00c0-\uffff]| *\n|\w+:\S|$)/),
- plain: (node, _, state) => (state.kind === 'edit'
- ? node.content.replace(/(\*|_|!\[|\[|\|\||\$\$?)/g, '\\$1')
- : node.content),
- },
-};
-
-const markdownRules = {
- ...defaultRules,
- ...plainRules,
- heading: {
- ...defaultRules.heading,
- match: blockRegex(/^ *(#{1,6})([^\n:]*?(?: [^\n]*?)?)#* *(?:\n *)*\n/),
- plain: (node, output, state) => {
- const out = output(node.content, state);
- if (state.kind === 'edit' || state.kind === 'notification' || node.level > 2) {
- return `${'#'.repeat(node.level)} ${out}\n\n`;
- }
- return `${out}\n${(node.level === 1 ? '=' : '-').repeat(out.length)}\n\n`;
- },
- },
- hr: {
- ...defaultRules.hr,
- plain: () => '---\n\n',
- },
- codeBlock: {
- ...defaultRules.codeBlock,
- plain: (node) => `\`\`\`${node.lang || ''}\n${node.content}\n\`\`\`\n`,
- html: (node) => htmlTag('pre', htmlTag('code', sanitizeText(node.content), {
- class: node.lang ? `language-${node.lang}` : undefined,
- })),
- },
- fence: {
- ...defaultRules.fence,
- match: blockRegex(/^ *(`{3,}|~{3,}) *(?:(\S+) *)?\n([\s\S]+?)\n?\1 *(?:\n *)*\n/),
- },
- blockQuote: {
- ...defaultRules.blockQuote,
- plain: (node, output, state) => `> ${output(node.content, state).trim().replace(/\n/g, '\n> ')}\n\n`,
- },
- list: {
- ...defaultRules.list,
- plain: (node, output, state) => {
- const oldList = state._list;
- state._list = true;
-
- let items = node.items.map((item, i) => {
- const prefix = node.ordered ? `${node.start + i}. ` : '* ';
- return prefix + output(item, state).replace(/\n/g, `\n${' '.repeat(prefix.length)}`);
- }).join('\n');
-
- state._list = oldList;
-
- if (!state._list) {
- items += '\n\n';
- }
- return items;
- },
- },
- def: undefined,
- table: {
- ...defaultRules.table,
- plain: (node, output, state) => {
- const header = node.header.map((content) => output(content, state));
-
- const colWidth = node.align.map((align) => {
- switch (align) {
- case 'left':
- case 'right':
- return 2;
- case 'center':
- return 3;
- default:
- return 1;
- }
- });
- header.forEach((s, i) => {
- if (s.length > colWidth[i])colWidth[i] = s.length;
- });
-
- const cells = node.cells.map((row) => row.map((content, i) => {
- const s = output(content, state);
- if (colWidth[i] === undefined || s.length > colWidth[i]) {
- colWidth[i] = s.length;
- }
- return s;
- }));
-
- function pad(s, i) {
- switch (node.align[i]) {
- case 'right':
- return s.padStart(colWidth[i]);
- case 'center':
- return s
- .padStart(s.length + Math.floor((colWidth[i] - s.length) / 2))
- .padEnd(colWidth[i]);
- default:
- return s.padEnd(colWidth[i]);
- }
- }
-
- const line = colWidth.map((len, i) => {
- switch (node.align[i]) {
- case 'left':
- return `:${'-'.repeat(len - 1)}`;
- case 'center':
- return `:${'-'.repeat(len - 2)}:`;
- case 'right':
- return `${'-'.repeat(len - 1)}:`;
- default:
- return '-'.repeat(len);
- }
- });
-
- const table = [
- header.map(pad),
- line,
- ...cells.map((row) => row.map(pad))];
-
- return table.map((row) => `| ${row.join(' | ')} |\n`).join('');
- },
- },
- displayMath: {
- order: defaultRules.table.order + 0.1,
- match: blockRegex(/^ *\$\$ *\n?([\s\S]+?)\n?\$\$ *(?:\n *)*\n/),
- parse: (capture) => ({ content: capture[1] }),
- plain: (node) => (node.content.includes('\n')
- ? `$$\n${node.content}\n$$\n`
- : `$$${node.content}$$\n`),
- html: (node) => mathHtml('div', node),
- },
- shrug: {
- order: defaultRules.escape.order - 0.1,
- match: inlineRegex(/^¯\\_\(ツ\)_\/¯/),
- parse: (capture) => ({ type: 'text', content: capture[0] }),
- },
- tableSeparator: {
- ...defaultRules.tableSeparator,
- plain: () => ' | ',
- },
- link: {
- ...defaultRules.link,
- plain: (node, output, state) => {
- const out = output(node.content, state);
- const target = sanitizeUrl(node.target) || '';
- if (out !== target || node.title) {
- return `[${out}](${target}${node.title ? ` "${node.title}"` : ''})`;
- }
- return out;
- },
- html: (node, output, state) => {
- const out = output(node.content, state);
- const target = sanitizeUrl(node.target) || '';
- if (out !== target || node.title) {
- return htmlTag('a', out, {
- href: target,
- title: node.title,
- });
- }
- return target;
- },
- },
- image: {
- ...defaultRules.image,
- plain: (node) => ` || ''}${node.title ? ` "${node.title}"` : ''})`,
- html: (node) => htmlTag('img', '', {
- src: sanitizeUrl(node.target) || '',
- alt: node.alt,
- title: node.title,
- }, false),
- },
- reflink: undefined,
- refimage: undefined,
- em: {
- ...defaultRules.em,
- plain: (node, output, state) => `_${output(node.content, state)}_`,
- },
- strong: {
- ...defaultRules.strong,
- plain: (node, output, state) => `**${output(node.content, state)}**`,
- },
- u: {
- ...defaultRules.u,
- plain: (node, output, state) => `__${output(node.content, state)}__`,
- },
- del: {
- ...defaultRules.del,
- plain: (node, output, state) => `~~${output(node.content, state)}~~`,
- },
- inlineCode: {
- ...defaultRules.inlineCode,
- match: inlineRegex(/^(`+)([^\n]*?[^`\n])\1(?!`)/),
- plain: (node) => `\`${node.content}\``,
- },
- spoiler: {
- order: defaultRules.inlineCode.order + 0.1,
- match: inlineRegex(/^\|\|([\s\S]+?)\|\|(?:\(([\s\S]+?)\))?/),
- parse: (capture, parse, state) => ({
- content: parse(capture[1], state),
- reason: capture[2],
- }),
- plain: (node, output, state) => {
- const warning = `spoiler${node.reason ? `: ${node.reason}` : ''}`;
- switch (state.kind) {
- case 'edit':
- return `||${output(node.content, state)}||${node.reason ? `(${node.reason})` : ''}`;
- case 'notification':
- return `<${warning}>`;
- default:
- return `[${warning}](${output(node.content, state)})`;
- }
- },
- html: (node, output, state) => htmlTag(
- 'span',
- output(node.content, state),
- { 'data-mx-spoiler': node.reason || null },
- ),
- },
- inlineMath: {
- order: defaultRules.del.order + 0.2,
- match: inlineRegex(/^\$(\S[\s\S]+?\S|\S)\$(?!\d)/),
- parse: (capture) => ({ content: capture[1] }),
- plain: (node) => `$${node.content}$`,
- html: (node) => mathHtml('span', node),
- },
-};
-
-function mapElement(el) {
- switch (el.tagName) {
- case 'MX-REPLY':
- return [];
-
- case 'P':
- return [{ type: 'paragraph', content: mapChildren(el) }];
- case 'BR':
- return [{ type: 'br' }];
-
- case 'H1':
- case 'H2':
- case 'H3':
- case 'H4':
- case 'H5':
- case 'H6':
- return [{ type: 'heading', level: Number(el.tagName[1]), content: mapChildren(el) }];
- case 'HR':
- return [{ type: 'hr' }];
- case 'PRE': {
- let lang;
- if (el.firstChild) {
- Array.from(el.firstChild.classList).some((c) => {
- const langPrefix = 'language-';
- if (c.startsWith(langPrefix)) {
- lang = c.slice(langPrefix.length);
- return true;
- }
- return false;
- });
- }
- return [{ type: 'codeBlock', lang, content: el.innerText }];
- }
- case 'BLOCKQUOTE':
- return [{ type: 'blockQuote', content: mapChildren(el) }];
- case 'UL':
- return [{ type: 'list', items: Array.from(el.childNodes).map(mapNode) }];
- case 'OL':
- return [{
- type: 'list',
- ordered: true,
- start: Number(el.getAttribute('start')),
- items: Array.from(el.childNodes).map(mapNode),
- }];
- case 'TABLE': {
- const headerEl = Array.from(el.querySelector('thead > tr').childNodes);
- const align = headerEl.map((childE) => childE.style['text-align']);
- return [{
- type: 'table',
- header: headerEl.map(mapChildren),
- align,
- cells: Array.from(el.querySelectorAll('tbody > tr')).map((rowEl) => Array.from(rowEl.childNodes).map((childEl, i) => {
- if (align[i] === undefined) align[i] = childEl.style['text-align'];
- return mapChildren(childEl);
- })),
- }];
- }
- case 'A': {
- const href = el.getAttribute('href');
-
- const id = parseIdUri(href);
- if (id) return [{ type: 'mention', content: el.innerText, id }];
-
- return [{
- type: 'link',
- target: el.getAttribute('href'),
- title: el.getAttribute('title'),
- content: mapChildren(el),
- }];
- }
- case 'IMG': {
- const src = el.getAttribute('src');
- let title = el.getAttribute('title');
- if (el.hasAttribute('data-mx-emoticon')) {
- if (title.length > 2 && title.startsWith(':') && title.endsWith(':')) {
- title = title.slice(1, -1);
- }
- return [{
- type: 'emoji',
- content: title,
- emoji: {
- mxc: src,
- shortcode: title,
- },
- }];
- }
-
- return [{
- type: 'image',
- alt: el.getAttribute('alt'),
- target: src,
- title,
- }];
- }
- case 'EM':
- case 'I':
- return [{ type: 'em', content: mapChildren(el) }];
- case 'STRONG':
- case 'B':
- return [{ type: 'strong', content: mapChildren(el) }];
- case 'U':
- return [{ type: 'u', content: mapChildren(el) }];
- case 'DEL':
- case 'STRIKE':
- return [{ type: 'del', content: mapChildren(el) }];
- case 'CODE':
- return [{ type: 'inlineCode', content: el.innerText }];
-
- case 'DIV':
- if (el.hasAttribute('data-mx-maths')) {
- return [{ type: 'displayMath', content: el.getAttribute('data-mx-maths') }];
- }
- return mapChildren(el);
- case 'SPAN':
- if (el.hasAttribute('data-mx-spoiler')) {
- return [{ type: 'spoiler', reason: el.getAttribute('data-mx-spoiler'), content: mapChildren(el) }];
- }
- if (el.hasAttribute('data-mx-maths')) {
- return [{ type: 'inlineMath', content: el.getAttribute('data-mx-maths') }];
- }
- return mapChildren(el);
- default:
- return mapChildren(el);
- }
-}
-
-function mapNode(n) {
- switch (n.nodeType) {
- case Node.TEXT_NODE:
- return [{ type: 'text', content: n.textContent }];
- case Node.ELEMENT_NODE:
- return mapElement(n);
- default:
- return [];
- }
-}
-
-function mapChildren(n) {
- return Array.from(n.childNodes).reduce((ast, childN) => {
- ast.push(...mapNode(childN));
- return ast;
- }, []);
-}
-
-function render(content, state, plainOut, htmlOut) {
- let c = content;
- if (content.length === 1 && content[0].type === 'paragraph') {
- c = c[0].content;
- }
-
- const plainStr = plainOut(c, state).trim();
- if (state.onlyPlain) return { plain: plainStr };
-
- const htmlStr = htmlOut(c, state);
-
- const plainHtml = htmlStr.replace(/<br>/g, '\n').replace(/<\/p><p>/g, '\n\n').replace(/<\/?p>/g, '');
- const onlyPlain = sanitizeText(plainStr) === plainHtml;
-
- return {
- onlyPlain,
- plain: plainStr,
- html: htmlStr,
- };
-}
-
-const plainParser = parserFor(plainRules);
-const plainPlainOut = outputFor(plainRules, 'plain');
-const plainHtmlOut = outputFor(plainRules, 'html');
-
-const mdParser = parserFor(markdownRules);
-const mdPlainOut = outputFor(markdownRules, 'plain');
-const mdHtmlOut = outputFor(markdownRules, 'html');
-
-export function plain(source, state) {
- return render(plainParser(source, state), state, plainPlainOut, plainHtmlOut);
-}
-
-export function markdown(source, state) {
- return render(mdParser(source, state), state, mdPlainOut, mdHtmlOut);
-}
-
-export function html(source, state) {
- const el = document.createElement('template');
- el.innerHTML = source;
- return render(mapChildren(el.content), state, mdPlainOut, mdHtmlOut);
-}
return html.slice(i + suffix.length);
}
-export function hasDMWith(userId) {
- const mx = initMatrix.matrixClient;
- const directIds = [...initMatrix.roomList.directs];
-
- return directIds.find((roomId) => {
- const dRoom = mx.getRoom(roomId);
- const roomMembers = dRoom.getMembers();
- if (roomMembers.length <= 2 && dRoom.getMember(userId)) {
- return true;
- }
- return false;
- });
-}
-
export function joinRuleToIconSrc(joinRule, isSpace) {
return ({
restricted: () => (isSpace ? SpaceIC : HashIC),
+++ /dev/null
-// https://github.com/matrix-org/matrix-react-sdk/blob/cd15e08fc285da42134817cce50de8011809cd53/src/utils/blobs.ts
-export const ALLOWED_BLOB_MIMETYPES = [
- 'image/jpeg',
- 'image/gif',
- 'image/png',
- 'image/apng',
- 'image/webp',
- 'image/avif',
-
- 'video/mp4',
- 'video/webm',
- 'video/ogg',
- 'video/quicktime',
-
- 'audio/mp4',
- 'audio/webm',
- 'audio/aac',
- 'audio/mpeg',
- 'audio/ogg',
- 'audio/wave',
- 'audio/wav',
- 'audio/x-wav',
- 'audio/x-pn-wav',
- 'audio/flac',
- 'audio/x-flac',
-];
-
-export function getBlobSafeMimeType(mimetype) {
- if (typeof mimetype !== 'string') return 'application/octet-stream';
- const [type] = mimetype.split(';');
- if (!ALLOWED_BLOB_MIMETYPES.includes(type)) {
- return 'application/octet-stream';
- }
- // Required for Chromium browsers
- if (type === 'video/quicktime') {
- return 'video/mp4';
- }
- return type;
-}
+++ /dev/null
-import sanitizeHtml from 'sanitize-html';
-
-const MAX_TAG_NESTING = 100;
-let mx = null;
-
-const permittedHtmlTags = [
- 'font', 'del', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
- 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub',
- 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 's', 'code',
- 'hr', 'br', 'div', 'table', 'thead', 'tbody', 'tr', 'th',
- 'td', 'caption', 'pre', 'span', 'img', 'details', 'summary',
-];
-
-const urlSchemes = ['https', 'http', 'ftp', 'mailto', 'magnet'];
-
-const permittedTagToAttributes = {
- font: ['style', 'data-mx-bg-color', 'data-mx-color', 'color'],
- span: ['style', 'data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'data-mx-maths', 'data-mx-pill', 'data-mx-ping'],
- div: ['data-mx-maths'],
- a: ['name', 'target', 'href', 'rel'],
- img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'],
- ol: ['start'],
- code: ['class'],
-};
-
-function transformFontTag(tagName, attribs) {
- return {
- tagName,
- attribs: {
- ...attribs,
- style: `background-color: ${attribs['data-mx-bg-color']}; color: ${attribs['data-mx-color']}`,
- },
- };
-}
-
-function transformSpanTag(tagName, attribs) {
- return {
- tagName,
- attribs: {
- ...attribs,
- style: `background-color: ${attribs['data-mx-bg-color']}; color: ${attribs['data-mx-color']}`,
- },
- };
-}
-
-function transformATag(tagName, attribs) {
- const userLink = decodeURIComponent(attribs.href).match(/^https?:\/\/matrix.to\/#\/(@.+:.+)/);
- if (userLink !== null) {
- // convert user link to pill
- const userId = userLink[1];
- const pill = {
- tagName: 'span',
- attribs: {
- 'data-mx-pill': userId,
- },
- };
- if (userId === mx?.getUserId()) {
- pill.attribs['data-mx-ping'] = undefined;
- }
- return pill;
- }
-
- const rex = /[\u{1f300}-\u{1f5ff}\u{1f900}-\u{1f9ff}\u{1f600}-\u{1f64f}\u{1f680}-\u{1f6ff}\u{2600}-\u{26ff}\u{2700}-\u{27bf}\u{1f1e6}-\u{1f1ff}\u{1f191}-\u{1f251}\u{1f004}\u{1f0cf}\u{1f170}-\u{1f171}\u{1f17e}-\u{1f17f}\u{1f18e}\u{3030}\u{2b50}\u{2b55}\u{2934}-\u{2935}\u{2b05}-\u{2b07}\u{2b1b}-\u{2b1c}\u{3297}\u{3299}\u{303d}\u{00a9}\u{00ae}\u{2122}\u{23f3}\u{24c2}\u{23e9}-\u{23ef}\u{25b6}\u{23f8}-\u{23fa}]/ug;
- const newHref = attribs.href.replace(rex, (match) => `[e-${match.codePointAt(0).toString(16)}]`);
-
- return {
- tagName,
- attribs: {
- ...attribs,
- href: newHref,
- rel: 'noopener',
- target: '_blank',
- },
- };
-}
-
-function transformImgTag(tagName, attribs) {
- const { src } = attribs;
- if (src.startsWith('mxc://') === false) {
- return {
- tagName: 'a',
- attribs: {
- href: src,
- rel: 'noopener',
- target: '_blank',
- },
- text: attribs.alt || src,
- };
- }
- return {
- tagName,
- attribs: {
- ...attribs,
- src: mx?.mxcUrlToHttp(src),
- },
- };
-}
-
-export function sanitizeCustomHtml(matrixClient, body) {
- mx = matrixClient;
- return sanitizeHtml(body, {
- allowedTags: permittedHtmlTags,
- allowedAttributes: permittedTagToAttributes,
- disallowedTagsMode: 'discard',
- allowedSchemes: urlSchemes,
- allowedSchemesByTag: {
- a: urlSchemes,
- },
- allowedSchemesAppliedToAttributes: ['href'],
- allowProtocolRelative: false,
- allowedClasses: {
- code: ['language-*'],
- },
- allowedStyles: {
- '*': {
- color: [/^#(?:[0-9a-fA-F]{3}){1,2}$/],
- 'background-color': [/^#(?:[0-9a-fA-F]{3}){1,2}$/],
- },
- },
- transformTags: {
- font: transformFontTag,
- span: transformSpanTag,
- a: transformATag,
- img: transformImgTag,
- },
- nonTextTags: ['style', 'script', 'textarea', 'option', 'noscript', 'mx-reply'],
- nestingLimit: MAX_TAG_NESTING,
- });
-}
-
-export function sanitizeText(body) {
- const tagsToReplace = {
- '&': '&',
- '<': '<',
- '>': '>',
- '"': '"',
- "'": ''',
- };
- return body.replace(/[&<>'"]/g, (tag) => tagsToReplace[tag] || tag);
-}
+++ /dev/null
-/* eslint-disable import/prefer-default-export */
-import linkifyHtml from 'linkify-html';
-import parse from 'html-react-parser';
-import { sanitizeText } from './sanitize';
-
-export const TWEMOJI_BASE_URL = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/';
-
-/**
- * @param {string} text - text to twemojify
- * @param {object|undefined} opts - DEPRECATED - options for tweomoji.parse
- * @param {boolean} [linkify=false] - convert links to html tags (default: false)
- * @param {boolean} [sanitize=true] - sanitize html text (default: true)
- * @param {boolean} [maths=false] - DEPRECATED - render maths (default: false)
- * @returns React component
- */
-export function twemojify(text, opts, linkify = false, sanitize = true) {
- if (typeof text !== 'string') return text;
- let content = text;
-
- if (sanitize) {
- content = sanitizeText(content);
- }
-
- if (linkify) {
- content = linkifyHtml(content, {
- target: '_blank',
- rel: 'noreferrer noopener',
- });
- }
- return parse(content);
-}