Compare commits

...

2 commits

Author SHA1 Message Date
3e07ca4c58
refactor: use twemoji as img 2024-01-16 09:35:38 +09:00
13b9c4c0a8
refactor: remove unused files 2024-01-16 08:28:10 +09:00
59 changed files with 78 additions and 5103 deletions

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "twemoji"]
path = twemoji
url = https://github.com/jdecked/twemoji

76
package-lock.json generated
View file

@ -24,8 +24,8 @@
"dateformat": "5.0.3",
"dayjs": "1.11.10",
"domhandler": "5.0.3",
"emojibase": "15.3.0",
"emojibase-data": "15.3.0",
"emojibase": "15.2.0",
"emojibase-data": "15.2.0",
"file-saver": "2.0.5",
"flux": "4.0.3",
"focus-trap-react": "10.0.2",
@ -61,7 +61,6 @@
"slate-history": "0.93.0",
"slate-react": "0.98.4",
"tippy.js": "6.3.7",
"twemoji": "14.0.2",
"ua-parser-js": "1.0.35"
},
"devDependencies": {
@ -4076,18 +4075,18 @@
"dev": true
},
"node_modules/emojibase": {
"version": "15.3.0",
"resolved": "https://registry.npmjs.org/emojibase/-/emojibase-15.3.0.tgz",
"integrity": "sha512-lFdQb14hoznwDh1xDoOFzkPdeeexmbuJdhUUjDaJZf/+jK+6Z3HkI5hWOemWfEdaMxtTujc1vNRx9DKtdtiOXA==",
"version": "15.2.0",
"resolved": "https://registry.npmjs.org/emojibase/-/emojibase-15.2.0.tgz",
"integrity": "sha512-gB6rIVtyJPersQvAo4nOGYPeILMhlcfZdiwCWVeRAtkJ7sm0tExZETGyLhrTQcHvZQhDEYI1vlCeqUhn5gZkQA==",
"funding": {
"type": "ko-fi",
"url": "https://ko-fi.com/milesjohnson"
}
},
"node_modules/emojibase-data": {
"version": "15.3.0",
"resolved": "https://registry.npmjs.org/emojibase-data/-/emojibase-data-15.3.0.tgz",
"integrity": "sha512-cYsGq57W8wOd1CqQCjmtP1DpmlTVvJY+Um5UobWUQuTnqb8cO2cuqrxJxSIqxLcYZ3rtialT5kivoWigszdslg==",
"version": "15.2.0",
"resolved": "https://registry.npmjs.org/emojibase-data/-/emojibase-data-15.2.0.tgz",
"integrity": "sha512-hDiw4ugxnI4pcVQO+73NlKx6aZP/A+BAPfDgK/3A83RVbHZa0Ut6GHpd5r5XUV9G7BZhKejlIRuxhXialpbt6Q==",
"funding": {
"type": "ko-fi",
"url": "https://ko-fi.com/milesjohnson"
@ -4988,27 +4987,6 @@
"react": ">=16.8.0"
}
},
"node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/fs-extra/node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/fs-minipass": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
@ -5227,7 +5205,8 @@
"node_modules/graceful-fs": {
"version": "4.2.10",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
"integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="
"integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==",
"dev": true
},
"node_modules/grapheme-splitter": {
"version": "1.0.4",
@ -5870,17 +5849,6 @@
"node": ">=6"
}
},
"node_modules/jsonfile": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-5.0.0.tgz",
"integrity": "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==",
"dependencies": {
"universalify": "^0.1.2"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz",
@ -7686,22 +7654,6 @@
"typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta"
}
},
"node_modules/twemoji": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/twemoji/-/twemoji-14.0.2.tgz",
"integrity": "sha512-BzOoXIe1QVdmsUmZ54xbEH+8AgtOKUiG53zO5vVP2iUu6h5u9lN15NcuS6te4OY96qx0H7JK9vjjl9WQbkTRuA==",
"dependencies": {
"fs-extra": "^8.0.1",
"jsonfile": "^5.0.0",
"twemoji-parser": "14.0.0",
"universalify": "^0.1.2"
}
},
"node_modules/twemoji-parser": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-14.0.0.tgz",
"integrity": "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA=="
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -7777,14 +7729,6 @@
"resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz",
"integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg=="
},
"node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/update-browserslist-db": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz",

View file

@ -19,60 +19,16 @@
"author": "Ajay Bura",
"license": "AGPL-3.0-only",
"dependencies": {
"@fontsource/inter": "4.5.14",
"@khanacademy/simple-markdown": "0.8.6",
"@matrix-org/olm": "3.2.14",
"@tanstack/react-virtual": "3.0.0-beta.54",
"@tippyjs/react": "4.2.6",
"@vanilla-extract/css": "1.9.3",
"@vanilla-extract/recipes": "0.3.0",
"@vanilla-extract/vite-plugin": "3.7.1",
"await-to-js": "3.0.0",
"blurhash": "2.0.4",
"browser-encrypt-attachment": "0.3.0",
"classnames": "2.3.2",
"dateformat": "5.0.3",
"dayjs": "1.11.10",
"domhandler": "5.0.3",
"emojibase": "15.3.0",
"emojibase-data": "15.3.0",
"file-saver": "2.0.5",
"flux": "4.0.3",
"focus-trap-react": "10.0.2",
"folds": "1.5.0",
"formik": "2.2.9",
"html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0",
"immer": "9.0.16",
"is-hotkey": "0.2.0",
"jotai": "1.12.0",
"katex": "0.16.4",
"linkify-html": "4.0.2",
"linkify-react": "4.1.1",
"emojibase": "15.2.0",
"linkifyjs": "4.0.2",
"matrix-js-sdk": "24.1.0",
"millify": "6.1.0",
"pdfjs-dist": "3.10.111",
"prismjs": "1.29.0",
"prop-types": "15.8.1",
"react": "17.0.2",
"react-aria": "3.29.1",
"react-autosize-textarea": "7.1.0",
"react-blurhash": "0.2.0",
"react-dnd": "15.1.2",
"react-dnd-html5-backend": "15.1.3",
"react-dom": "17.0.2",
"react-error-boundary": "4.0.10",
"react-google-recaptcha": "2.1.0",
"react-modal": "3.16.1",
"react-range": "1.8.14",
"sanitize-html": "2.8.0",
"slate": "0.94.1",
"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"
"slate": "0.94.1"
},
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
@ -103,4 +59,4 @@
"vite": "4.3.9",
"vite-plugin-static-copy": "0.13.0"
}
}
}

View file

@ -1,28 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import './Divider.scss';
import Text from '../text/Text';
function Divider({ text, variant, align }) {
const dividerClass = ` divider--${variant} divider--${align}`;
return (
<div className={`divider${dividerClass}`}>
{text !== null && <Text className="divider__text" variant="b3" weight="bold">{text}</Text>}
</div>
);
}
Divider.defaultProps = {
text: null,
variant: 'surface',
align: 'center',
};
Divider.propTypes = {
text: PropTypes.string,
variant: PropTypes.oneOf(['surface', 'primary', 'positive', 'caution', 'danger']),
align: PropTypes.oneOf(['left', 'center', 'right']),
};
export default Divider;

View file

@ -1,33 +0,0 @@
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import './Math.scss';
import katex from 'katex';
import 'katex/dist/katex.min.css';
import 'katex/dist/contrib/copy-tex';
const Math = React.memo(({
content, throwOnError, errorColor, displayMode,
}) => {
const ref = useRef(null);
useEffect(() => {
katex.render(content, ref.current, { throwOnError, errorColor, displayMode });
}, [content, throwOnError, errorColor, displayMode]);
return <span ref={ref} />;
});
Math.defaultProps = {
throwOnError: null,
errorColor: null,
displayMode: null,
};
Math.propTypes = {
content: PropTypes.string.isRequired,
throwOnError: PropTypes.bool,
errorColor: PropTypes.string,
displayMode: PropTypes.bool,
};
export default Math;

View file

@ -1,82 +0,0 @@
import React, { useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
config,
Icon,
IconButton,
Icons,
Line,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
} from 'folds';
import { CustomEditor, useEditor } from './Editor';
import { Toolbar } from './Toolbar';
export function EditorPreview() {
const [open, setOpen] = useState(false);
const editor = useEditor();
const [toolbar, setToolbar] = useState(false);
return (
<>
<IconButton variant="SurfaceVariant" onClick={() => setOpen(!open)}>
<Icon src={Icons.BlockQuote} />
</IconButton>
<Overlay open={open} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setOpen(false),
clickOutsideDeactivates: true,
}}
>
<Modal size="500">
<div style={{ padding: config.space.S400 }}>
<CustomEditor
editor={editor}
placeholder="Send a message..."
before={
<IconButton variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.PlusCircle} />
</IconButton>
}
after={
<>
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
onClick={() => setToolbar(!toolbar)}
aria-pressed={toolbar}
>
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
</IconButton>
<IconButton variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.Smile} />
</IconButton>
<IconButton variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.Send} />
</IconButton>
</>
}
bottom={
toolbar && (
<div>
<Line variant="SurfaceVariant" size="300" />
<Toolbar />
</div>
)
}
/>
</div>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
</>
);
}

View file

@ -11,6 +11,7 @@ import {
import * as css from '../../styles/CustomHtml.css';
import { CommandElement, EmoticonElement, LinkElement, MentionElement } from './slate';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getEmojiUrl, isUsingTwemoji } from '../../plugins/emoji';
import { getBeginCommand } from './utils';
import { BlockType } from './types';
@ -94,7 +95,11 @@ function RenderEmoticonElement({
alt={element.shortcode}
/>
) : (
element.key
isUsingTwemoji() ? <img
className={css.EmoticonImg}
src={getEmojiUrl(element.key)}
alt={element.shortcode}
/> : element.key
)}
{children}
</span>

View file

@ -126,6 +126,15 @@ export const CustomEmojiImg = style([
},
]);
export const EmoticonImg = style([
DefaultReset,
{
width: toRem(32),
height: toRem(32),
objectFit: 'contain',
},
]);
export const StickerImg = style([
DefaultReset,
{

View file

@ -34,7 +34,15 @@ import { MatrixClient, Room } from 'matrix-js-sdk';
import { atom, useAtomValue, useSetAtom } from 'jotai';
import * as css from './EmojiBoard.css';
import { EmojiGroupId, IEmoji, IEmojiGroup, emojiGroups, emojis } from '../../plugins/emoji';
import {
EmojiGroupId,
IEmoji,
IEmojiGroup,
emojiGroups,
emojis,
getEmojiUrl,
isUsingTwemoji,
} from '../../plugins/emoji';
import { IEmojiGroupLabels, useEmojiGroupLabels } from './useEmojiGroupLabels';
import { IEmojiGroupIcons, useEmojiGroupIcons } from './useEmojiGroupIcons';
import { preventScrollWithArrowKey } from '../../utils/keyboard';
@ -439,9 +447,13 @@ export function RecentEmojiGroup({
type={EmojiType.Emoji}
data={emoji.unicode}
shortcode={emoji.shortcode}
>
{emoji.unicode}
</EmojiItem>
>{
isUsingTwemoji() ? <img
className={css.EmoticonImg}
src={getEmojiUrl(emoji.unicode)}
alt={emoji.shortcode}
/> : emoji.unicode
}</EmojiItem>
))}
</EmojiGroup>
);

View file

@ -1,111 +0,0 @@
import { style } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { color, config, DefaultReset, toRem } from 'folds';
export const Sidebar = style([
DefaultReset,
{
width: toRem(66),
backgroundColor: color.Background.Container,
borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
display: 'flex',
flexDirection: 'column',
color: color.Background.OnContainer,
},
]);
export const SidebarStack = style([
DefaultReset,
{
width: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
gap: config.space.S300,
padding: `${config.space.S300} 0`,
},
]);
const PUSH_X = 2;
export const SidebarAvatarBox = recipe({
base: [
DefaultReset,
{
display: 'flex',
alignItems: 'center',
position: 'relative',
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
selectors: {
'&:hover': {
transform: `translateX(${toRem(PUSH_X)})`,
},
'&::before': {
content: '',
display: 'none',
position: 'absolute',
left: toRem(-11.5 - PUSH_X),
width: toRem(3 + PUSH_X),
height: toRem(16),
borderRadius: `0 ${toRem(4)} ${toRem(4)} 0`,
background: 'CurrentColor',
transition: 'height 200ms linear',
},
'&:hover::before': {
display: 'block',
width: toRem(3),
},
},
},
],
variants: {
active: {
true: {
selectors: {
'&::before': {
display: 'block',
height: toRem(24),
},
'&:hover::before': {
width: toRem(3 + PUSH_X),
},
},
},
},
},
});
export type SidebarAvatarBoxVariants = RecipeVariants<typeof SidebarAvatarBox>;
export const SidebarBadgeBox = recipe({
base: [
DefaultReset,
{
position: 'absolute',
zIndex: 1,
},
],
variants: {
hasCount: {
true: {
top: toRem(-6),
right: toRem(-6),
},
false: {
top: toRem(-2),
right: toRem(-2),
},
},
},
defaultVariants: {
hasCount: false,
},
});
export type SidebarBadgeBoxVariants = RecipeVariants<typeof SidebarBadgeBox>;
export const SidebarBadgeOutline = style({
boxShadow: `0 0 0 ${config.borderWidth.B500} ${color.Background.Container}`,
});

View file

@ -1,8 +0,0 @@
import classNames from 'classnames';
import { as } from 'folds';
import React from 'react';
import * as css from './Sidebar.css';
export const Sidebar = as<'div'>(({ as: AsSidebar = 'div', className, ...props }, ref) => (
<AsSidebar className={classNames(css.Sidebar, className)} {...props} ref={ref} />
));

View file

@ -1,75 +0,0 @@
import classNames from 'classnames';
import { as, Avatar, Box, color, config, Text, Tooltip, TooltipProvider } from 'folds';
import React, { forwardRef, MouseEventHandler, ReactNode } from 'react';
import * as css from './Sidebar.css';
const SidebarAvatarBox = as<'div', css.SidebarAvatarBoxVariants>(
({ as: AsSidebarAvatarBox = 'div', className, active, ...props }, ref) => (
<AsSidebarAvatarBox
className={classNames(css.SidebarAvatarBox({ active }), className)}
{...props}
ref={ref}
/>
)
);
export const SidebarAvatar = forwardRef<
HTMLDivElement,
css.SidebarAvatarBoxVariants &
css.SidebarBadgeBoxVariants & {
outlined?: boolean;
avatarChildren: ReactNode;
tooltip: ReactNode | string;
notificationBadge?: (badgeClassName: string) => ReactNode;
onClick?: MouseEventHandler<HTMLButtonElement>;
onContextMenu?: MouseEventHandler<HTMLButtonElement>;
}
>(
(
{
active,
hasCount,
outlined,
avatarChildren,
tooltip,
notificationBadge,
onClick,
onContextMenu,
},
ref
) => (
<SidebarAvatarBox active={active} ref={ref}>
<TooltipProvider
delay={0}
position="Right"
tooltip={
<Tooltip>
<Text size="T300">{tooltip}</Text>
</Tooltip>
}
>
{(avRef) => (
<Avatar
ref={avRef}
as="button"
onClick={onClick}
onContextMenu={onContextMenu}
style={{
border: outlined
? `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`
: undefined,
cursor: 'pointer',
}}
>
{avatarChildren}
</Avatar>
)}
</TooltipProvider>
{notificationBadge && (
<Box className={css.SidebarBadgeBox({ hasCount })}>
{notificationBadge(css.SidebarBadgeOutline)}
</Box>
)}
</SidebarAvatarBox>
)
);

View file

@ -1,21 +0,0 @@
import React, { ReactNode } from 'react';
import { Box, Scroll } from 'folds';
type SidebarContentProps = {
scrollable: ReactNode;
sticky: ReactNode;
};
export function SidebarContent({ scrollable, sticky }: SidebarContentProps) {
return (
<>
<Box direction="Column" grow="Yes">
<Scroll variant="Background" size="0">
{scrollable}
</Scroll>
</Box>
<Box direction="Column" shrink="No">
{sticky}
</Box>
</>
);
}

View file

@ -1,10 +0,0 @@
import React from 'react';
import classNames from 'classnames';
import { as } from 'folds';
import * as css from './Sidebar.css';
export const SidebarStack = as<'div'>(
({ as: AsSidebarStack = 'div', className, ...props }, ref) => (
<AsSidebarStack className={classNames(css.SidebarStack, className)} {...props} ref={ref} />
)
);

View file

@ -1,13 +0,0 @@
import React from 'react';
import { Line, toRem } from 'folds';
export function SidebarStackSeparator() {
return (
<Line
role="separator"
style={{ width: toRem(24), margin: '0 auto' }}
variant="Background"
size="300"
/>
);
}

View file

@ -1,5 +0,0 @@
export * from './Sidebar';
export * from './SidebarAvatar';
export * from './SidebarContent';
export * from './SidebarStack';
export * from './SidebarStackSeparator';

View file

@ -1,9 +0,0 @@
import { useReducer } from 'react';
const reducer = (prevCount: number): number => prevCount + 1;
export const useForceUpdate = (): [number, () => void] => {
const [state, dispatch] = useReducer<typeof reducer>(reducer, 0);
return [state, dispatch];
};

View file

@ -1,28 +0,0 @@
import { useCallback, useMemo } from 'react';
import { Room } from 'matrix-js-sdk';
import { StateEvent } from '../../types/matrix/room';
import { useForceUpdate } from './useForceUpdate';
import { useStateEventCallback } from './useStateEventCallback';
import { getStateEvents } from '../utils/room';
export const useStateEvents = (room: Room, eventType: StateEvent) => {
const [updateCount, forceUpdate] = useForceUpdate();
useStateEventCallback(
room.client,
useCallback(
(event) => {
if (event.getRoomId() === room.roomId && event.getType() === eventType) {
forceUpdate();
}
},
[room, eventType, forceUpdate]
)
);
return useMemo(
() => getStateEvents(room, eventType),
// eslint-disable-next-line react-hooks/exhaustive-deps
[room, eventType, updateCount]
);
};

View file

@ -1,61 +0,0 @@
/* eslint-disable react/prop-types */
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import './FollowingMembers.scss';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import { openReadReceipts } from '../../../client/action/navigation';
import Text from '../../atoms/text/Text';
import RawIcon from '../../atoms/system-icons/RawIcon';
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
import { getUsersActionJsx } from '../../organisms/room/common';
function FollowingMembers({ roomTimeline }) {
const [followingMembers, setFollowingMembers] = useState([]);
const { roomId } = roomTimeline;
const mx = initMatrix.matrixClient;
const myUserId = mx.getUserId();
useEffect(() => {
const updateFollowingMembers = () => {
setFollowingMembers(roomTimeline.getLiveReaders());
};
const updateOnEvent = (event, room) => {
if (room.roomId !== roomId) return;
setFollowingMembers(roomTimeline.getLiveReaders());
};
updateFollowingMembers();
roomTimeline.on(cons.events.roomTimeline.LIVE_RECEIPT, updateFollowingMembers);
mx.on('Room.timeline', updateOnEvent);
return () => {
roomTimeline.removeListener(cons.events.roomTimeline.LIVE_RECEIPT, updateFollowingMembers);
mx.removeListener('Room.timeline', updateOnEvent);
};
}, [roomTimeline, roomId]);
const filteredM = followingMembers.filter((userId) => userId !== myUserId);
return (
filteredM.length !== 0 && (
<button
className="following-members"
onClick={() => openReadReceipts(roomId, followingMembers)}
type="button"
>
<RawIcon size="extra-small" src={TickMarkIC} />
<Text variant="b2">
{getUsersActionJsx(roomId, filteredM, 'following the conversation.')}
</Text>
</button>
)
);
}
FollowingMembers.propTypes = {
roomTimeline: PropTypes.shape({}).isRequired,
};
export default FollowingMembers;

View file

@ -1,78 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import './TimelineChange.scss';
import Text from '../../atoms/text/Text';
import RawIcon from '../../atoms/system-icons/RawIcon';
import Time from '../../atoms/time/Time';
import JoinArraowIC from '../../../../public/res/ic/outlined/join-arrow.svg';
import LeaveArraowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
import InviteArraowIC from '../../../../public/res/ic/outlined/invite-arrow.svg';
import InviteCancelArraowIC from '../../../../public/res/ic/outlined/invite-cancel-arrow.svg';
import UserIC from '../../../../public/res/ic/outlined/user.svg';
function TimelineChange({
variant, content, timestamp, onClick,
}) {
let iconSrc;
switch (variant) {
case 'join':
iconSrc = JoinArraowIC;
break;
case 'leave':
iconSrc = LeaveArraowIC;
break;
case 'invite':
iconSrc = InviteArraowIC;
break;
case 'invite-cancel':
iconSrc = InviteCancelArraowIC;
break;
case 'avatar':
iconSrc = UserIC;
break;
default:
iconSrc = JoinArraowIC;
break;
}
return (
<button style={{ cursor: onClick === null ? 'default' : 'pointer' }} onClick={onClick} type="button" className="timeline-change">
<div className="timeline-change__avatar-container">
<RawIcon src={iconSrc} size="extra-small" />
</div>
<div className="timeline-change__content">
<Text variant="b2">
{content}
</Text>
</div>
<div className="timeline-change__time">
<Text variant="b3">
<Time timestamp={timestamp} />
</Text>
</div>
</button>
);
}
TimelineChange.defaultProps = {
variant: 'other',
onClick: null,
};
TimelineChange.propTypes = {
variant: PropTypes.oneOf([
'join', 'leave', 'invite',
'invite-cancel', 'avatar', 'other',
]),
content: PropTypes.oneOfType([
PropTypes.string,
PropTypes.node,
]).isRequired,
timestamp: PropTypes.number.isRequired,
onClick: PropTypes.func,
};
export default TimelineChange;

View file

@ -1,42 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import './RoomIntro.scss';
import colorMXID from '../../../util/colorMXID';
import Text from '../../atoms/text/Text';
import Avatar from '../../atoms/avatar/Avatar';
function RoomIntro({
roomId, avatarSrc, name, heading, desc, time,
}) {
return (
<div className="room-intro">
<Avatar imageSrc={avatarSrc} text={name} bgColor={colorMXID(roomId)} size="large" />
<div className="room-intro__content">
<Text className="room-intro__name" variant="h1" weight="medium" primary>{heading}</Text>
<Text className="room-intro__desc" variant="b1">{desc}</Text>
{ time !== null && <Text className="room-intro__time" variant="b3">{time}</Text>}
</div>
</div>
);
}
RoomIntro.defaultProps = {
avatarSrc: null,
time: null,
};
RoomIntro.propTypes = {
roomId: PropTypes.string.isRequired,
avatarSrc: PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool,
]),
name: PropTypes.string.isRequired,
heading: PropTypes.node.isRequired,
desc: PropTypes.node.isRequired,
time: PropTypes.node,
};
export default RoomIntro;

View file

@ -1,356 +0,0 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import './EmojiBoard.scss';
import parse from 'html-react-parser';
import twemoji from 'twemoji';
import { emojiGroups, emojis } from './emoji';
import { getRelevantPacks } from './custom-emoji';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
import AsyncSearch from '../../../util/AsyncSearch';
import { addRecentEmoji, getRecentEmojis } from './recent';
import { TWEMOJI_BASE_URL } from '../../../util/twemojify';
import Text from '../../atoms/text/Text';
import RawIcon from '../../atoms/system-icons/RawIcon';
import IconButton from '../../atoms/button/IconButton';
import Input from '../../atoms/input/Input';
import ScrollView from '../../atoms/scroll/ScrollView';
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
import RecentClockIC from '../../../../public/res/ic/outlined/recent-clock.svg';
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
import DogIC from '../../../../public/res/ic/outlined/dog.svg';
import CupIC from '../../../../public/res/ic/outlined/cup.svg';
import BallIC from '../../../../public/res/ic/outlined/ball.svg';
import PhotoIC from '../../../../public/res/ic/outlined/photo.svg';
import BulbIC from '../../../../public/res/ic/outlined/bulb.svg';
import PeaceIC from '../../../../public/res/ic/outlined/peace.svg';
import FlagIC from '../../../../public/res/ic/outlined/flag.svg';
const ROW_EMOJIS_COUNT = 7;
const EmojiGroup = React.memo(({ name, groupEmojis }) => {
function getEmojiBoard() {
const emojiBoard = [];
const totalEmojis = groupEmojis.length;
for (let r = 0; r < totalEmojis; r += ROW_EMOJIS_COUNT) {
const emojiRow = [];
for (let c = r; c < r + ROW_EMOJIS_COUNT; c += 1) {
const emojiIndex = c;
if (emojiIndex >= totalEmojis) break;
const emoji = groupEmojis[emojiIndex];
emojiRow.push(
<span key={emojiIndex}>
{emoji.hexcode ? (
// This is a unicode emoji, and should be rendered with twemoji
parse(
twemoji.parse(emoji.unicode, {
attributes: () => ({
unicode: emoji.unicode,
shortcodes: emoji.shortcodes?.toString(),
hexcode: emoji.hexcode,
loading: 'lazy',
}),
base: TWEMOJI_BASE_URL,
})
)
) : (
// This is a custom emoji, and should be render as an mxc
<img
className="emoji"
draggable="false"
loading="lazy"
alt={emoji.shortcode}
unicode={`:${emoji.shortcode}:`}
shortcodes={emoji.shortcode}
src={initMatrix.matrixClient.mxcUrlToHttp(emoji.mxc)}
data-mx-emoticon={emoji.mxc}
/>
)}
</span>
);
}
emojiBoard.push(
<div key={r} className="emoji-row">
{emojiRow}
</div>
);
}
return emojiBoard;
}
return (
<div className="emoji-group">
<Text className="emoji-group__header" variant="b2" weight="bold">
{name}
</Text>
{groupEmojis.length !== 0 && <div className="emoji-set noselect">{getEmojiBoard()}</div>}
</div>
);
});
EmojiGroup.propTypes = {
name: PropTypes.string.isRequired,
groupEmojis: PropTypes.arrayOf(
PropTypes.shape({
length: PropTypes.number,
unicode: PropTypes.string,
hexcode: PropTypes.string,
mxc: PropTypes.string,
shortcode: PropTypes.string,
shortcodes: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
})
).isRequired,
};
const asyncSearch = new AsyncSearch();
asyncSearch.setup(emojis, { keys: ['shortcode'], isContain: true, limit: 40 });
function SearchedEmoji() {
const [searchedEmojis, setSearchedEmojis] = useState(null);
function handleSearchEmoji(resultEmojis, term) {
if (term === '' || resultEmojis.length === 0) {
if (term === '') setSearchedEmojis(null);
else setSearchedEmojis({ emojis: [] });
return;
}
setSearchedEmojis({ emojis: resultEmojis });
}
useEffect(() => {
asyncSearch.on(asyncSearch.RESULT_SENT, handleSearchEmoji);
return () => {
asyncSearch.removeListener(asyncSearch.RESULT_SENT, handleSearchEmoji);
};
}, []);
if (searchedEmojis === null) return false;
return (
<EmojiGroup
key="-1"
name={searchedEmojis.emojis.length === 0 ? 'No search result found' : 'Search results'}
groupEmojis={searchedEmojis.emojis}
/>
);
}
function EmojiBoard({ onSelect, searchRef }) {
const scrollEmojisRef = useRef(null);
const emojiInfo = useRef(null);
function isTargetNotEmoji(target) {
return target.classList.contains('emoji') === false;
}
function getEmojiDataFromTarget(target) {
const unicode = target.getAttribute('unicode');
const hexcode = target.getAttribute('hexcode');
const mxc = target.getAttribute('data-mx-emoticon');
let shortcodes = target.getAttribute('shortcodes');
if (typeof shortcodes === 'undefined') shortcodes = undefined;
else shortcodes = shortcodes.split(',');
return {
unicode,
hexcode,
shortcodes,
mxc,
};
}
function selectEmoji(e) {
if (isTargetNotEmoji(e.target)) return;
const emoji = getEmojiDataFromTarget(e.target);
onSelect(emoji);
if (emoji.hexcode) addRecentEmoji(emoji.unicode);
}
function setEmojiInfo(emoji) {
const infoEmoji = emojiInfo.current.firstElementChild.firstElementChild;
const infoShortcode = emojiInfo.current.lastElementChild;
infoEmoji.src = emoji.src;
infoEmoji.alt = emoji.unicode;
infoShortcode.textContent = `:${emoji.shortcode}:`;
}
function hoverEmoji(e) {
if (isTargetNotEmoji(e.target)) return;
const emoji = e.target;
const { shortcodes, unicode } = getEmojiDataFromTarget(emoji);
const { src } = e.target;
if (typeof shortcodes === 'undefined') {
searchRef.current.placeholder = 'Search';
setEmojiInfo({
unicode: '🙂',
shortcode: 'slight_smile',
src: 'https://twemoji.maxcdn.com/v/13.1.0/72x72/1f642.png',
});
return;
}
if (searchRef.current.placeholder === shortcodes[0]) return;
searchRef.current.setAttribute('placeholder', shortcodes[0]);
setEmojiInfo({ shortcode: shortcodes[0], src, unicode });
}
function handleSearchChange() {
const term = searchRef.current.value;
asyncSearch.search(term);
scrollEmojisRef.current.scrollTop = 0;
}
const [availableEmojis, setAvailableEmojis] = useState([]);
const [recentEmojis, setRecentEmojis] = useState([]);
const recentOffset = recentEmojis.length > 0 ? 1 : 0;
useEffect(() => {
const updateAvailableEmoji = (selectedRoomId) => {
if (!selectedRoomId) {
setAvailableEmojis([]);
return;
}
const mx = initMatrix.matrixClient;
const room = mx.getRoom(selectedRoomId);
const parentIds = initMatrix.roomList.getAllParentSpaces(room.roomId);
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
if (room) {
const packs = getRelevantPacks(room.client, [room, ...parentRooms]).filter(
(pack) => pack.getEmojis().length !== 0
);
// Set an index for each pack so that we know where to jump when the user uses the nav
for (let i = 0; i < packs.length; i += 1) {
packs[i].packIndex = i;
}
setAvailableEmojis(packs);
}
};
const onOpen = () => {
searchRef.current.value = '';
handleSearchChange();
// only update when board is getting opened to prevent shifting UI
setRecentEmojis(getRecentEmojis(3 * ROW_EMOJIS_COUNT));
};
navigation.on(cons.events.navigation.ROOM_SELECTED, updateAvailableEmoji);
navigation.on(cons.events.navigation.EMOJIBOARD_OPENED, onOpen);
return () => {
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, updateAvailableEmoji);
navigation.removeListener(cons.events.navigation.EMOJIBOARD_OPENED, onOpen);
};
}, []);
function openGroup(groupOrder) {
let tabIndex = groupOrder;
const $emojiContent = scrollEmojisRef.current.firstElementChild;
const groupCount = $emojiContent.childElementCount;
if (groupCount > emojiGroups.length) {
tabIndex += groupCount - emojiGroups.length - availableEmojis.length - recentOffset;
}
$emojiContent.children[tabIndex].scrollIntoView();
}
return (
<div id="emoji-board" className="emoji-board">
<ScrollView invisible>
<div className="emoji-board__nav">
{recentEmojis.length > 0 && (
<IconButton
onClick={() => openGroup(0)}
src={RecentClockIC}
tooltip="Recent"
tooltipPlacement="left"
/>
)}
<div className="emoji-board__nav-custom">
{availableEmojis.map((pack) => {
const src = initMatrix.matrixClient.mxcUrlToHttp(
pack.avatarUrl ?? pack.getEmojis()[0].mxc
);
return (
<IconButton
onClick={() => openGroup(recentOffset + pack.packIndex)}
src={src}
key={pack.packIndex}
tooltip={pack.displayName ?? 'Unknown'}
tooltipPlacement="left"
isImage
/>
);
})}
</div>
<div className="emoji-board__nav-twemoji">
{[
[0, EmojiIC, 'Smilies'],
[1, DogIC, 'Animals'],
[2, CupIC, 'Food'],
[3, BallIC, 'Activities'],
[4, PhotoIC, 'Travel'],
[5, BulbIC, 'Objects'],
[6, PeaceIC, 'Symbols'],
[7, FlagIC, 'Flags'],
].map(([indx, ico, name]) => (
<IconButton
onClick={() => openGroup(recentOffset + availableEmojis.length + indx)}
key={indx}
src={ico}
tooltip={name}
tooltipPlacement="left"
/>
))}
</div>
</div>
</ScrollView>
<div className="emoji-board__content">
<div className="emoji-board__content__search">
<RawIcon size="small" src={SearchIC} />
<Input onChange={handleSearchChange} forwardRef={searchRef} placeholder="Search" />
</div>
<div className="emoji-board__content__emojis">
<ScrollView ref={scrollEmojisRef} autoHide>
<div onMouseMove={hoverEmoji} onClick={selectEmoji}>
<SearchedEmoji />
{recentEmojis.length > 0 && (
<EmojiGroup name="Recently used" groupEmojis={recentEmojis} />
)}
{availableEmojis.map((pack) => (
<EmojiGroup
name={pack.displayName ?? 'Unknown'}
key={pack.packIndex}
groupEmojis={pack.getEmojis()}
className="custom-emoji-group"
/>
))}
{emojiGroups.map((group) => (
<EmojiGroup key={group.name} name={group.name} groupEmojis={group.emojis} />
))}
</div>
</ScrollView>
</div>
<div ref={emojiInfo} className="emoji-board__content__info">
<div>{parse(twemoji.parse('🙂', { base: TWEMOJI_BASE_URL }))}</div>
<Text>:slight_smile:</Text>
</div>
</div>
</div>
);
}
EmojiBoard.propTypes = {
onSelect: PropTypes.func.isRequired,
searchRef: PropTypes.shape({}).isRequired,
};
export default EmojiBoard;

View file

@ -1,78 +0,0 @@
import React, { useEffect, useRef } from 'react';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
import settings from '../../../client/state/settings';
import ContextMenu from '../../atoms/context-menu/ContextMenu';
import EmojiBoard from './EmojiBoard';
let requestCallback = null;
let isEmojiBoardVisible = false;
function EmojiBoardOpener() {
const openerRef = useRef(null);
const searchRef = useRef(null);
function openEmojiBoard(cords, requestEmojiCallback) {
if (requestCallback !== null || isEmojiBoardVisible) {
requestCallback = null;
if (cords.detail === 0) openerRef.current.click();
return;
}
openerRef.current.style.transform = `translate(${cords.x}px, ${cords.y}px)`;
requestCallback = requestEmojiCallback;
openerRef.current.click();
}
function afterEmojiBoardToggle(isVisible) {
isEmojiBoardVisible = isVisible;
if (isVisible) {
if (!settings.isTouchScreenDevice) searchRef.current.focus();
} else {
setTimeout(() => {
if (!isEmojiBoardVisible) requestCallback = null;
}, 500);
}
}
function addEmoji(emoji) {
requestCallback(emoji);
}
useEffect(() => {
navigation.on(cons.events.navigation.EMOJIBOARD_OPENED, openEmojiBoard);
return () => {
navigation.removeListener(cons.events.navigation.EMOJIBOARD_OPENED, openEmojiBoard);
};
}, []);
return (
<ContextMenu
content={(
<EmojiBoard onSelect={addEmoji} searchRef={searchRef} />
)}
afterToggle={afterEmojiBoardToggle}
render={(toggleMenu) => (
<input
ref={openerRef}
onClick={toggleMenu}
type="button"
style={{
width: '32px',
height: '32px',
backgroundColor: 'transparent',
position: 'absolute',
top: 0,
left: 0,
padding: 0,
border: 'none',
visibility: 'hidden',
}}
/>
)}
/>
);
}
export default EmojiBoardOpener;

View file

@ -1,36 +0,0 @@
import initMatrix from '../../../client/initMatrix';
import { emojis } from './emoji';
const eventType = 'io.element.recent_emoji';
function getRecentEmojisRaw() {
return initMatrix.matrixClient.getAccountData(eventType)?.getContent().recent_emoji ?? [];
}
export function getRecentEmojis(limit) {
const res = [];
getRecentEmojisRaw()
.sort((a, b) => b[1] - a[1])
.find(([unicode]) => {
const emoji = emojis.find((e) => e.unicode === unicode);
if (emoji) return res.push(emoji) >= limit;
return false;
});
return res;
}
export function addRecentEmoji(unicode) {
const recent = getRecentEmojisRaw();
const i = recent.findIndex(([u]) => u === unicode);
let entry;
if (i < 0) {
entry = [unicode, 1];
} else {
[entry] = recent.splice(i, 1);
entry[1] += 1;
}
recent.unshift(entry);
initMatrix.matrixClient.setAccountData(eventType, {
recent_emoji: recent.slice(0, 100),
});
}

View file

@ -1,125 +0,0 @@
import React from 'react';
import { Icon, Icons, Badge, AvatarFallback, Text } from 'folds';
import { useAtom } from 'jotai';
import {
Sidebar,
SidebarContent,
SidebarStackSeparator,
SidebarStack,
SidebarAvatar,
} from '../../components/sidebar';
import { selectedTabAtom, SidebarTab } from '../../state/selectedTab';
export function Sidebar1() {
const [selectedTab, setSelectedTab] = useAtom(selectedTabAtom);
return (
<Sidebar>
<SidebarContent
scrollable={
<>
<SidebarStack>
<SidebarAvatar
active={selectedTab === SidebarTab.Home}
outlined
tooltip="Home"
avatarChildren={<Icon src={Icons.Home} filled />}
onClick={() => setSelectedTab(SidebarTab.Home)}
/>
<SidebarAvatar
active={selectedTab === SidebarTab.People}
outlined
tooltip="People"
avatarChildren={<Icon src={Icons.User} />}
onClick={() => setSelectedTab(SidebarTab.People)}
/>
</SidebarStack>
<SidebarStackSeparator />
<SidebarStack>
<SidebarAvatar
tooltip="Space A"
notificationBadge={(badgeClassName) => (
<Badge
className={badgeClassName}
size="200"
variant="Secondary"
fill="Solid"
radii="Pill"
/>
)}
avatarChildren={
<AvatarFallback
style={{
backgroundColor: 'red',
color: 'white',
}}
>
<Text size="T500">B</Text>
</AvatarFallback>
}
/>
<SidebarAvatar
tooltip="Space B"
hasCount
notificationBadge={(badgeClassName) => (
<Badge className={badgeClassName} radii="Pill" fill="Solid" variant="Secondary">
<Text size="L400">64</Text>
</Badge>
)}
avatarChildren={
<AvatarFallback
style={{
backgroundColor: 'green',
color: 'white',
}}
>
<Text size="T500">C</Text>
</AvatarFallback>
}
/>
</SidebarStack>
<SidebarStackSeparator />
<SidebarStack>
<SidebarAvatar
outlined
tooltip="Explore Community"
avatarChildren={<Icon src={Icons.Explore} />}
/>
<SidebarAvatar
outlined
tooltip="Create Space"
avatarChildren={<Icon src={Icons.Plus} />}
/>
</SidebarStack>
</>
}
sticky={
<>
<SidebarStackSeparator />
<SidebarStack>
<SidebarAvatar
outlined
tooltip="Search"
avatarChildren={<Icon src={Icons.Search} />}
/>
<SidebarAvatar
tooltip="User Settings"
avatarChildren={
<AvatarFallback
style={{
backgroundColor: 'blue',
color: 'white',
}}
>
<Text size="T500">A</Text>
</AvatarFallback>
}
/>
</SidebarStack>
</>
}
/>
</Sidebar>
);
}

View file

@ -1,35 +0,0 @@
class EventLimit {
constructor() {
this._from = 0;
this.SMALLEST_EVT_HEIGHT = 32;
this.PAGES_COUNT = 4;
}
get maxEvents() {
return Math.round(document.body.clientHeight / this.SMALLEST_EVT_HEIGHT) * this.PAGES_COUNT;
}
get from() {
return this._from;
}
get length() {
return this._from + this.maxEvents;
}
setFrom(from) {
this._from = from < 0 ? 0 : from;
}
paginate(backwards, limit, timelineLength) {
this._from = backwards ? this._from - limit : this._from + limit;
if (!backwards && this.length > timelineLength) {
this._from = timelineLength - this.maxEvents;
}
if (this._from < 0) this._from = 0;
}
}
export default EventLimit;

View file

@ -1,215 +0,0 @@
import React, {
useState, useEffect, useCallback, useRef,
} from 'react';
import PropTypes from 'prop-types';
import './PeopleDrawer.scss';
import initMatrix from '../../../client/initMatrix';
import { getPowerLabel, getUsernameOfRoomMember } from '../../../util/matrixUtil';
import colorMXID from '../../../util/colorMXID';
import { openInviteUser, openProfileViewer } from '../../../client/action/navigation';
import AsyncSearch from '../../../util/AsyncSearch';
import { memberByAtoZ, memberByPowerLevel } from '../../../util/sort';
import Text from '../../atoms/text/Text';
import Header, { TitleWrapper } from '../../atoms/header/Header';
import RawIcon from '../../atoms/system-icons/RawIcon';
import IconButton from '../../atoms/button/IconButton';
import Button from '../../atoms/button/Button';
import ScrollView from '../../atoms/scroll/ScrollView';
import Input from '../../atoms/input/Input';
import SegmentedControl from '../../atoms/segmented-controls/SegmentedControls';
import PeopleSelector from '../../molecules/people-selector/PeopleSelector';
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
function simplyfiMembers(members) {
const mx = initMatrix.matrixClient;
return members.map((member) => ({
userId: member.userId,
name: getUsernameOfRoomMember(member),
username: member.userId.slice(1, member.userId.indexOf(':')),
avatarSrc: member.getAvatarUrl(mx.baseUrl, 24, 24, 'crop'),
peopleRole: getPowerLabel(member.powerLevel),
powerLevel: members.powerLevel,
}));
}
const asyncSearch = new AsyncSearch();
function PeopleDrawer({ roomId }) {
const PER_PAGE_MEMBER = 50;
const mx = initMatrix.matrixClient;
const room = mx.getRoom(roomId);
const canInvite = room?.canInvite(mx.getUserId());
const [itemCount, setItemCount] = useState(PER_PAGE_MEMBER);
const [membership, setMembership] = useState('join');
const [memberList, setMemberList] = useState([]);
const [searchedMembers, setSearchedMembers] = useState(null);
const searchRef = useRef(null);
const getMembersWithMembership = useCallback(
(mship) => room.getMembersWithMembership(mship),
[roomId, membership],
);
function loadMorePeople() {
setItemCount(itemCount + PER_PAGE_MEMBER);
}
function handleSearchData(data) {
// NOTICE: data is passed as object property
// because react sucks at handling state update with array.
setSearchedMembers({ data });
setItemCount(PER_PAGE_MEMBER);
}
function handleSearch(e) {
const term = e.target.value;
if (term === '' || term === undefined) {
searchRef.current.value = '';
searchRef.current.focus();
setSearchedMembers(null);
setItemCount(PER_PAGE_MEMBER);
} else asyncSearch.search(term);
}
useEffect(() => {
asyncSearch.setup(memberList, {
keys: ['name', 'username', 'userId'],
limit: PER_PAGE_MEMBER,
});
}, [memberList]);
useEffect(() => {
let isLoadingMembers = false;
let isRoomChanged = false;
const updateMemberList = (event) => {
if (isLoadingMembers) return;
if (event && event?.getRoomId() !== roomId) return;
setMemberList(
simplyfiMembers(
getMembersWithMembership(membership)
.sort(memberByAtoZ).sort(memberByPowerLevel),
),
);
};
searchRef.current.value = '';
updateMemberList();
isLoadingMembers = true;
room.loadMembersIfNeeded().then(() => {
isLoadingMembers = false;
if (isRoomChanged) return;
updateMemberList();
});
asyncSearch.on(asyncSearch.RESULT_SENT, handleSearchData);
mx.on('RoomMember.membership', updateMemberList);
mx.on('RoomMember.powerLevel', updateMemberList);
return () => {
isRoomChanged = true;
setMemberList([]);
setSearchedMembers(null);
setItemCount(PER_PAGE_MEMBER);
asyncSearch.removeListener(asyncSearch.RESULT_SENT, handleSearchData);
mx.removeListener('RoomMember.membership', updateMemberList);
mx.removeListener('RoomMember.powerLevel', updateMemberList);
};
}, [roomId, membership]);
useEffect(() => {
setMembership('join');
}, [roomId]);
const mList = searchedMembers !== null ? searchedMembers.data : memberList.slice(0, itemCount);
return (
<div className="people-drawer">
<Header>
<TitleWrapper>
<Text variant="s1" primary>
People
<Text className="people-drawer__member-count" variant="b3">{`${room.getJoinedMemberCount()} members`}</Text>
</Text>
</TitleWrapper>
<IconButton onClick={() => openInviteUser(roomId)} tooltip="Invite" src={AddUserIC} disabled={!canInvite} />
</Header>
<div className="people-drawer__content-wrapper">
<div className="people-drawer__scrollable">
<ScrollView autoHide>
<div className="people-drawer__content">
<SegmentedControl
selected={
(() => {
const getSegmentIndex = {
join: 0,
invite: 1,
ban: 2,
};
return getSegmentIndex[membership];
})()
}
segments={[{ text: 'Joined' }, { text: 'Invited' }, { text: 'Banned' }]}
onSelect={(index) => {
const selectSegment = [
() => setMembership('join'),
() => setMembership('invite'),
() => setMembership('ban'),
];
selectSegment[index]?.();
}}
/>
{
mList.map((member) => (
<PeopleSelector
key={member.userId}
onClick={() => openProfileViewer(member.userId, roomId)}
avatarSrc={member.avatarSrc}
name={member.name}
color={colorMXID(member.userId)}
peopleRole={member.peopleRole}
/>
))
}
{
(searchedMembers?.data.length === 0 || memberList.length === 0)
&& (
<div className="people-drawer__noresult">
<Text variant="b2">No results found!</Text>
</div>
)
}
<div className="people-drawer__load-more">
{
mList.length !== 0
&& memberList.length > itemCount
&& searchedMembers === null
&& (
<Button onClick={loadMorePeople}>View more</Button>
)
}
</div>
</div>
</ScrollView>
</div>
<div className="people-drawer__sticky">
<form onSubmit={(e) => e.preventDefault()} className="people-search">
<RawIcon size="small" src={SearchIC} />
<Input forwardRef={searchRef} type="text" onChange={handleSearch} placeholder="Search" required />
{
searchedMembers !== null
&& <IconButton onClick={handleSearch} size="small" src={CrossIC} />
}
</form>
</div>
</div>
</div>
);
}
PeopleDrawer.propTypes = {
roomId: PropTypes.string.isRequired,
};
export default PeopleDrawer;

View file

@ -1,297 +0,0 @@
/* eslint-disable react/prop-types */
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import './RoomViewCmdBar.scss';
import parse from 'html-react-parser';
import twemoji from 'twemoji';
import { twemojify, TWEMOJI_BASE_URL } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix';
import { getEmojiForCompletion } from '../emoji-board/custom-emoji';
import AsyncSearch from '../../../util/AsyncSearch';
import Text from '../../atoms/text/Text';
import ScrollView from '../../atoms/scroll/ScrollView';
import FollowingMembers from '../../molecules/following-members/FollowingMembers';
import { addRecentEmoji, getRecentEmojis } from '../emoji-board/recent';
import commands from './commands';
function CmdItem({ onClick, children }) {
return (
<button className="cmd-item" onClick={onClick} type="button">
{children}
</button>
);
}
CmdItem.propTypes = {
onClick: PropTypes.func.isRequired,
children: PropTypes.node.isRequired,
};
function renderSuggestions({ prefix, option, suggestions }, fireCmd) {
function renderCmdSuggestions(cmdPrefix, cmds) {
const cmdOptString = typeof option === 'string' ? `/${option}` : '/?';
return cmds.map((cmd) => (
<CmdItem
key={cmd}
onClick={() => {
fireCmd({
prefix: cmdPrefix,
option,
result: commands[cmd],
});
}}
>
<Text variant="b2">{`${cmd}${cmd.isOptions ? cmdOptString : ''}`}</Text>
</CmdItem>
));
}
function renderEmojiSuggestion(emPrefix, emos) {
const mx = initMatrix.matrixClient;
// Renders a small Twemoji
function renderTwemoji(emoji) {
return parse(
twemoji.parse(emoji.unicode, {
attributes: () => ({
unicode: emoji.unicode,
shortcodes: emoji.shortcodes?.toString(),
}),
base: TWEMOJI_BASE_URL,
})
);
}
// Render a custom emoji
function renderCustomEmoji(emoji) {
return (
<img
className="emoji"
src={mx.mxcUrlToHttp(emoji.mxc)}
data-mx-emoticon=""
alt={`:${emoji.shortcode}:`}
/>
);
}
// Dynamically render either a custom emoji or twemoji based on what the input is
function renderEmoji(emoji) {
if (emoji.mxc) {
return renderCustomEmoji(emoji);
}
return renderTwemoji(emoji);
}
return emos.map((emoji) => (
<CmdItem
key={emoji.shortcode}
onClick={() =>
fireCmd({
prefix: emPrefix,
result: emoji,
})
}
>
<Text variant="b1">{renderEmoji(emoji)}</Text>
<Text variant="b2">{`:${emoji.shortcode}:`}</Text>
</CmdItem>
));
}
function renderNameSuggestion(namePrefix, members) {
return members.map((member) => (
<CmdItem
key={member.userId}
onClick={() => {
fireCmd({
prefix: namePrefix,
result: member,
});
}}
>
<Text variant="b2">{twemojify(member.name)}</Text>
</CmdItem>
));
}
const cmd = {
'/': (cmds) => renderCmdSuggestions(prefix, cmds),
':': (emos) => renderEmojiSuggestion(prefix, emos),
'@': (members) => renderNameSuggestion(prefix, members),
};
return cmd[prefix]?.(suggestions);
}
const asyncSearch = new AsyncSearch();
let cmdPrefix;
let cmdOption;
function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
const [cmd, setCmd] = useState(null);
function displaySuggestions(suggestions) {
if (suggestions.length === 0) {
setCmd({ prefix: cmd?.prefix || cmdPrefix, error: 'No suggestion found.' });
viewEvent.emit('cmd_error');
return;
}
setCmd({ prefix: cmd?.prefix || cmdPrefix, suggestions, option: cmdOption });
}
function processCmd(prefix, slug) {
let searchTerm = slug;
cmdOption = undefined;
cmdPrefix = prefix;
if (prefix === '/') {
const cmdSlugParts = slug.split('/');
[searchTerm, cmdOption] = cmdSlugParts;
}
if (prefix === ':') {
if (searchTerm.length <= 3) {
if (searchTerm.match(/^[-]?(\))$/)) searchTerm = 'smile';
else if (searchTerm.match(/^[-]?(s|S)$/)) searchTerm = 'confused';
else if (searchTerm.match(/^[-]?(o|O|0)$/)) searchTerm = 'astonished';
else if (searchTerm.match(/^[-]?(\|)$/)) searchTerm = 'neutral_face';
else if (searchTerm.match(/^[-]?(d|D)$/)) searchTerm = 'grin';
else if (searchTerm.match(/^[-]?(\/)$/)) searchTerm = 'frown';
else if (searchTerm.match(/^[-]?(p|P)$/)) searchTerm = 'stuck_out_tongue';
else if (searchTerm.match(/^'[-]?(\()$/)) searchTerm = 'cry';
else if (searchTerm.match(/^[-]?(x|X)$/)) searchTerm = 'dizzy_face';
else if (searchTerm.match(/^[-]?(\()$/)) searchTerm = 'pleading_face';
else if (searchTerm.match(/^[-]?(\$)$/)) searchTerm = 'money';
else if (searchTerm.match(/^(<3)$/)) searchTerm = 'heart';
else if (searchTerm.match(/^(c|ca|cat)$/)) searchTerm = '_cat';
}
}
asyncSearch.search(searchTerm);
}
function activateCmd(prefix) {
cmdPrefix = prefix;
cmdPrefix = undefined;
const mx = initMatrix.matrixClient;
const setupSearch = {
'/': () => {
asyncSearch.setup(Object.keys(commands), { isContain: true });
setCmd({ prefix, suggestions: Object.keys(commands) });
},
':': () => {
const parentIds = initMatrix.roomList.getAllParentSpaces(roomId);
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
const emojis = getEmojiForCompletion(mx, [mx.getRoom(roomId), ...parentRooms]);
const recentEmoji = getRecentEmojis(20);
asyncSearch.setup(emojis, { keys: ['shortcode'], isContain: true, limit: 20 });
setCmd({
prefix,
suggestions: recentEmoji.length > 0 ? recentEmoji : emojis.slice(26, 46),
});
},
'@': () => {
const members = mx
.getRoom(roomId)
.getJoinedMembers()
.map((member) => ({
name: member.name,
userId: member.userId.slice(1),
}));
asyncSearch.setup(members, { keys: ['name', 'userId'], limit: 20 });
const endIndex = members.length > 20 ? 20 : members.length;
setCmd({ prefix, suggestions: members.slice(0, endIndex) });
},
};
setupSearch[prefix]?.();
}
function deactivateCmd() {
setCmd(null);
cmdOption = undefined;
cmdPrefix = undefined;
}
function fireCmd(myCmd) {
if (myCmd.prefix === '/') {
viewEvent.emit('cmd_fired', {
replace: `/${myCmd.result.name}`,
});
}
if (myCmd.prefix === ':') {
if (!myCmd.result.mxc) addRecentEmoji(myCmd.result.unicode);
viewEvent.emit('cmd_fired', {
replace: myCmd.result.mxc ? `:${myCmd.result.shortcode}: ` : myCmd.result.unicode,
});
}
if (myCmd.prefix === '@') {
viewEvent.emit('cmd_fired', {
replace: `@${myCmd.result.userId}`,
});
}
deactivateCmd();
}
function listenKeyboard(event) {
const { activeElement } = document;
const lastCmdItem = document.activeElement.parentNode.lastElementChild;
if (event.key === 'Escape') {
if (activeElement.className !== 'cmd-item') return;
viewEvent.emit('focus_msg_input');
}
if (event.key === 'Tab') {
if (lastCmdItem.className !== 'cmd-item') return;
if (lastCmdItem !== activeElement) return;
if (event.shiftKey) return;
viewEvent.emit('focus_msg_input');
event.preventDefault();
}
}
useEffect(() => {
viewEvent.on('cmd_activate', activateCmd);
viewEvent.on('cmd_deactivate', deactivateCmd);
return () => {
deactivateCmd();
viewEvent.removeListener('cmd_activate', activateCmd);
viewEvent.removeListener('cmd_deactivate', deactivateCmd);
};
}, [roomId]);
useEffect(() => {
if (cmd !== null) document.body.addEventListener('keydown', listenKeyboard);
viewEvent.on('cmd_process', processCmd);
asyncSearch.on(asyncSearch.RESULT_SENT, displaySuggestions);
return () => {
if (cmd !== null) document.body.removeEventListener('keydown', listenKeyboard);
viewEvent.removeListener('cmd_process', processCmd);
asyncSearch.removeListener(asyncSearch.RESULT_SENT, displaySuggestions);
};
}, [cmd]);
const isError = typeof cmd?.error === 'string';
if (cmd === null || isError) {
return (
<div className="cmd-bar">
<FollowingMembers roomTimeline={roomTimeline} />
</div>
);
}
return (
<div className="cmd-bar">
<div className="cmd-bar__info">
<Text variant="b3">TAB</Text>
</div>
<div className="cmd-bar__content">
<ScrollView horizontal vertical={false} invisible>
<div className="cmd-bar__content-suggestions">{renderSuggestions(cmd, fireCmd)}</div>
</ScrollView>
</div>
</div>
);
}
RoomViewCmdBar.propTypes = {
roomId: PropTypes.string.isRequired,
roomTimeline: PropTypes.shape({}).isRequired,
viewEvent: PropTypes.shape({}).isRequired,
};
export default RoomViewCmdBar;

View file

@ -1,57 +0,0 @@
@use '../../partials/flex';
@use '../../partials/text';
@use '../../partials/dir';
.cmd-bar {
--cmd-bar-height: 28px;
min-height: var(--cmd-bar-height);
display: flex;
&__info {
display: flex;
width: 40px;
@include dir.side(margin, 14px, 10px);
& > * {
margin: auto;
}
}
&__content {
@extend .cp-fx__item-one;
display: flex;
&-suggestions {
height: 100%;
white-space: nowrap;
display: flex;
align-items: center;
& > .text {
@extend .cp-txt__ellipsis;
}
}
}
}
.cmd-item {
--cmd-item-bar: inset 0 -2px 0 0 var(--bg-caution);
height: 100%;
@include dir.side(margin, 0, var(--sp-extra-tight));
padding: 0 var(--sp-extra-tight);
border-radius: var(--bo-radius) var(--bo-radius) 0 0;
cursor: pointer;
display: inline-flex;
align-items: center;
&:hover {
background-color: var(--bg-caution-hover);
}
&:focus {
background-color: var(--bg-caution-active);
box-shadow: var(--cmd-item-bar);
border-bottom: 2px solid transparent;
outline: none;
}
}

View file

@ -1,644 +0,0 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable react/prop-types */
import React, {
useState, useEffect, useLayoutEffect, useCallback, useRef,
} from 'react';
import PropTypes from 'prop-types';
import './RoomViewContent.scss';
import dateFormat from 'dateformat';
import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
import { openProfileViewer } from '../../../client/action/navigation';
import { diffMinutes, isInSameDay, Throttle } from '../../../util/common';
import { markAsRead } from '../../../client/action/notifications';
import Divider from '../../atoms/divider/Divider';
import ScrollView from '../../atoms/scroll/ScrollView';
import { Message, PlaceholderMessage } from '../../molecules/message/Message';
import RoomIntro from '../../molecules/room-intro/RoomIntro';
import TimelineChange from '../../molecules/message/TimelineChange';
import { useStore } from '../../hooks/useStore';
import { useForceUpdate } from '../../hooks/useForceUpdate';
import { parseTimelineChange } from './common';
import TimelineScroll from './TimelineScroll';
import EventLimit from './EventLimit';
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
const PAG_LIMIT = 30;
const MAX_MSG_DIFF_MINUTES = 5;
const PLACEHOLDER_COUNT = 2;
const PLACEHOLDERS_HEIGHT = 96 * PLACEHOLDER_COUNT;
const SCROLL_TRIGGER_POS = PLACEHOLDERS_HEIGHT * 4;
function loadingMsgPlaceholders(key, count = 2) {
const pl = [];
const genPlaceholders = () => {
for (let i = 0; i < count; i += 1) {
pl.push(<PlaceholderMessage key={`placeholder-${i}${key}`} />);
}
return pl;
};
return (
<React.Fragment key={`placeholder-container${key}`}>
{genPlaceholders()}
</React.Fragment>
);
}
function RoomIntroContainer({ event, timeline }) {
const [, nameForceUpdate] = useForceUpdate();
const mx = initMatrix.matrixClient;
const { roomList } = initMatrix;
const { room } = timeline;
const roomTopic = room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
const isDM = roomList.directs.has(timeline.roomId);
let avatarSrc = room.getAvatarUrl(mx.baseUrl, 80, 80, 'crop');
avatarSrc = isDM ? room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 80, 80, 'crop') : avatarSrc;
const heading = isDM ? room.name : `Welcome to ${room.name}`;
const topic = twemojify(roomTopic || '', undefined, true);
const nameJsx = twemojify(room.name);
const desc = isDM
? (
<>
This is the beginning of your direct message history with @
<b>{nameJsx}</b>
{'. '}
{topic}
</>
)
: (
<>
{'This is the beginning of the '}
<b>{nameJsx}</b>
{' room. '}
{topic}
</>
);
useEffect(() => {
const handleUpdate = () => nameForceUpdate();
roomList.on(cons.events.roomList.ROOM_PROFILE_UPDATED, handleUpdate);
return () => {
roomList.removeListener(cons.events.roomList.ROOM_PROFILE_UPDATED, handleUpdate);
};
}, []);
return (
<RoomIntro
roomId={timeline.roomId}
avatarSrc={avatarSrc}
name={room.name}
heading={twemojify(heading)}
desc={desc}
time={event ? `Created at ${dateFormat(event.getDate(), 'dd mmmm yyyy, hh:MM TT')}` : null}
/>
);
}
function handleOnClickCapture(e) {
const { target, nativeEvent } = e;
const userId = target.getAttribute('data-mx-pill');
if (userId) {
const roomId = navigation.selectedRoomId;
openProfileViewer(userId, roomId);
}
const spoiler = nativeEvent.composedPath().find((el) => el?.hasAttribute?.('data-mx-spoiler'));
if (spoiler) {
if (!spoiler.classList.contains('data-mx-spoiler--visible')) e.preventDefault();
spoiler.classList.toggle('data-mx-spoiler--visible');
}
}
function renderEvent(
roomTimeline,
mEvent,
prevMEvent,
isFocus,
isEdit,
setEdit,
cancelEdit,
) {
const isBodyOnly = (prevMEvent !== null
&& prevMEvent.getSender() === mEvent.getSender()
&& prevMEvent.getType() !== 'm.room.member'
&& prevMEvent.getType() !== 'm.room.create'
&& diffMinutes(mEvent.getDate(), prevMEvent.getDate()) <= MAX_MSG_DIFF_MINUTES
);
const timestamp = mEvent.getTs();
if (mEvent.getType() === 'm.room.member') {
const timelineChange = parseTimelineChange(mEvent);
if (timelineChange === null) return <div key={mEvent.getId()} />;
return (
<TimelineChange
key={mEvent.getId()}
variant={timelineChange.variant}
content={timelineChange.content}
timestamp={timestamp}
/>
);
}
return (
<Message
key={mEvent.getId()}
mEvent={mEvent}
isBodyOnly={isBodyOnly}
roomTimeline={roomTimeline}
focus={isFocus}
fullTime={false}
isEdit={isEdit}
setEdit={setEdit}
cancelEdit={cancelEdit}
/>
);
}
function useTimeline(roomTimeline, eventId, readUptoEvtStore, eventLimitRef) {
const [timelineInfo, setTimelineInfo] = useState(null);
const setEventTimeline = async (eId) => {
if (typeof eId === 'string') {
const isLoaded = await roomTimeline.loadEventTimeline(eId);
if (isLoaded) return;
// if eventTimeline failed to load,
// we will load live timeline as fallback.
}
roomTimeline.loadLiveTimeline();
};
useEffect(() => {
const limit = eventLimitRef.current;
const initTimeline = (eId) => {
// NOTICE: eId can be id of readUpto, reply or specific event.
// readUpTo: when user click jump to unread message button.
// reply: when user click reply from timeline.
// specific event when user open a link of event. behave same as ^^^^
const readUpToId = roomTimeline.getReadUpToEventId();
let focusEventIndex = -1;
const isSpecificEvent = eId && eId !== readUpToId;
if (isSpecificEvent) {
focusEventIndex = roomTimeline.getEventIndex(eId);
}
if (!readUptoEvtStore.getItem() && roomTimeline.hasEventInTimeline(readUpToId)) {
// either opening live timeline or jump to unread.
readUptoEvtStore.setItem(roomTimeline.findEventByIdInTimelineSet(readUpToId));
}
if (readUptoEvtStore.getItem() && !isSpecificEvent) {
focusEventIndex = roomTimeline.getUnreadEventIndex(readUptoEvtStore.getItem().getId());
}
if (focusEventIndex > -1) {
limit.setFrom(focusEventIndex - Math.round(limit.maxEvents / 2));
} else {
limit.setFrom(roomTimeline.timeline.length - limit.maxEvents);
}
setTimelineInfo({ focusEventId: isSpecificEvent ? eId : null });
};
roomTimeline.on(cons.events.roomTimeline.READY, initTimeline);
setEventTimeline(eventId);
return () => {
roomTimeline.removeListener(cons.events.roomTimeline.READY, initTimeline);
limit.setFrom(0);
};
}, [roomTimeline, eventId]);
return timelineInfo;
}
function usePaginate(
roomTimeline,
readUptoEvtStore,
forceUpdateLimit,
timelineScrollRef,
eventLimitRef,
) {
const [info, setInfo] = useState(null);
useEffect(() => {
const handlePaginatedFromServer = (backwards, loaded) => {
const limit = eventLimitRef.current;
if (loaded === 0) return;
if (!readUptoEvtStore.getItem()) {
const readUpToId = roomTimeline.getReadUpToEventId();
readUptoEvtStore.setItem(roomTimeline.findEventByIdInTimelineSet(readUpToId));
}
limit.paginate(backwards, PAG_LIMIT, roomTimeline.timeline.length);
setTimeout(() => setInfo({
backwards,
loaded,
}));
};
roomTimeline.on(cons.events.roomTimeline.PAGINATED, handlePaginatedFromServer);
return () => {
roomTimeline.removeListener(cons.events.roomTimeline.PAGINATED, handlePaginatedFromServer);
};
}, [roomTimeline]);
const autoPaginate = useCallback(async () => {
const timelineScroll = timelineScrollRef.current;
const limit = eventLimitRef.current;
if (roomTimeline.isOngoingPagination) return;
const tLength = roomTimeline.timeline.length;
if (timelineScroll.bottom < SCROLL_TRIGGER_POS) {
if (limit.length < tLength) {
// paginate from memory
limit.paginate(false, PAG_LIMIT, tLength);
forceUpdateLimit();
} else if (roomTimeline.canPaginateForward()) {
// paginate from server.
await roomTimeline.paginateTimeline(false, PAG_LIMIT);
return;
}
}
if (timelineScroll.top < SCROLL_TRIGGER_POS) {
if (limit.from > 0) {
// paginate from memory
limit.paginate(true, PAG_LIMIT, tLength);
forceUpdateLimit();
} else if (roomTimeline.canPaginateBackward()) {
// paginate from server.
await roomTimeline.paginateTimeline(true, PAG_LIMIT);
}
}
}, [roomTimeline]);
return [info, autoPaginate];
}
function useHandleScroll(
roomTimeline,
autoPaginate,
readUptoEvtStore,
forceUpdateLimit,
timelineScrollRef,
eventLimitRef,
) {
const handleScroll = useCallback(() => {
const timelineScroll = timelineScrollRef.current;
const limit = eventLimitRef.current;
requestAnimationFrame(() => {
// emit event to toggle scrollToBottom button visibility
const isAtBottom = (
timelineScroll.bottom < 16 && !roomTimeline.canPaginateForward()
&& limit.length >= roomTimeline.timeline.length
);
roomTimeline.emit(cons.events.roomTimeline.AT_BOTTOM, isAtBottom);
if (isAtBottom && readUptoEvtStore.getItem()) {
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
}
});
autoPaginate();
}, [roomTimeline]);
const handleScrollToLive = useCallback(() => {
const timelineScroll = timelineScrollRef.current;
const limit = eventLimitRef.current;
if (readUptoEvtStore.getItem()) {
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
}
if (roomTimeline.isServingLiveTimeline()) {
limit.setFrom(roomTimeline.timeline.length - limit.maxEvents);
timelineScroll.scrollToBottom();
forceUpdateLimit();
return;
}
roomTimeline.loadLiveTimeline();
}, [roomTimeline]);
return [handleScroll, handleScrollToLive];
}
function useEventArrive(roomTimeline, readUptoEvtStore, timelineScrollRef, eventLimitRef) {
const myUserId = initMatrix.matrixClient.getUserId();
const [newEvent, setEvent] = useState(null);
useEffect(() => {
const timelineScroll = timelineScrollRef.current;
const limit = eventLimitRef.current;
const trySendReadReceipt = (event) => {
if (myUserId === event.getSender()) {
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
return;
}
const readUpToEvent = readUptoEvtStore.getItem();
const readUpToId = roomTimeline.getReadUpToEventId();
const isUnread = readUpToEvent ? readUpToEvent?.getId() === readUpToId : true;
if (isUnread === false) {
if (document.visibilityState === 'visible' && timelineScroll.bottom < 16) {
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
} else {
readUptoEvtStore.setItem(roomTimeline.findEventByIdInTimelineSet(readUpToId));
}
return;
}
const { timeline } = roomTimeline;
const unreadMsgIsLast = timeline[timeline.length - 2].getId() === readUpToId;
if (unreadMsgIsLast) {
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
}
};
const handleEvent = (event) => {
const tLength = roomTimeline.timeline.length;
const isViewingLive = roomTimeline.isServingLiveTimeline() && limit.length >= tLength - 1;
const isAttached = timelineScroll.bottom < SCROLL_TRIGGER_POS;
if (isViewingLive && isAttached && document.hasFocus()) {
limit.setFrom(tLength - limit.maxEvents);
trySendReadReceipt(event);
setEvent(event);
return;
}
const isRelates = (event.getType() === 'm.reaction' || event.getRelation()?.rel_type === 'm.replace');
if (isRelates) {
setEvent(event);
return;
}
if (isViewingLive) {
// This stateUpdate will help to put the
// loading msg placeholder at bottom
setEvent(event);
}
};
const handleEventRedact = (event) => setEvent(event);
roomTimeline.on(cons.events.roomTimeline.EVENT, handleEvent);
roomTimeline.on(cons.events.roomTimeline.EVENT_REDACTED, handleEventRedact);
return () => {
roomTimeline.removeListener(cons.events.roomTimeline.EVENT, handleEvent);
roomTimeline.removeListener(cons.events.roomTimeline.EVENT_REDACTED, handleEventRedact);
};
}, [roomTimeline]);
return newEvent;
}
let jumpToItemIndex = -1;
function RoomViewContent({ roomInputRef, eventId, roomTimeline }) {
const [throttle] = useState(new Throttle());
const timelineSVRef = useRef(null);
const timelineScrollRef = useRef(null);
const eventLimitRef = useRef(null);
const [editEventId, setEditEventId] = useState(null);
const cancelEdit = () => setEditEventId(null);
const readUptoEvtStore = useStore(roomTimeline);
const [onLimitUpdate, forceUpdateLimit] = useForceUpdate();
const timelineInfo = useTimeline(roomTimeline, eventId, readUptoEvtStore, eventLimitRef);
const [paginateInfo, autoPaginate] = usePaginate(
roomTimeline,
readUptoEvtStore,
forceUpdateLimit,
timelineScrollRef,
eventLimitRef,
);
const [handleScroll, handleScrollToLive] = useHandleScroll(
roomTimeline,
autoPaginate,
readUptoEvtStore,
forceUpdateLimit,
timelineScrollRef,
eventLimitRef,
);
const newEvent = useEventArrive(roomTimeline, readUptoEvtStore, timelineScrollRef, eventLimitRef);
const { timeline } = roomTimeline;
useLayoutEffect(() => {
if (!roomTimeline.initialized) {
timelineScrollRef.current = new TimelineScroll(timelineSVRef.current);
eventLimitRef.current = new EventLimit();
}
});
// when active timeline changes
useEffect(() => {
if (!roomTimeline.initialized) return undefined;
const timelineScroll = timelineScrollRef.current;
if (timeline.length > 0) {
if (jumpToItemIndex === -1) {
timelineScroll.scrollToBottom();
} else {
timelineScroll.scrollToIndex(jumpToItemIndex, 80);
}
if (timelineScroll.bottom < 16 && !roomTimeline.canPaginateForward()) {
const readUpToId = roomTimeline.getReadUpToEventId();
if (readUptoEvtStore.getItem()?.getId() === readUpToId || readUpToId === null) {
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
}
}
jumpToItemIndex = -1;
}
autoPaginate();
roomTimeline.on(cons.events.roomTimeline.SCROLL_TO_LIVE, handleScrollToLive);
return () => {
if (timelineSVRef.current === null) return;
roomTimeline.removeListener(cons.events.roomTimeline.SCROLL_TO_LIVE, handleScrollToLive);
};
}, [timelineInfo]);
// when paginating from server
useEffect(() => {
if (!roomTimeline.initialized) return;
const timelineScroll = timelineScrollRef.current;
timelineScroll.tryRestoringScroll();
autoPaginate();
}, [paginateInfo]);
// when paginating locally
useEffect(() => {
if (!roomTimeline.initialized) return;
const timelineScroll = timelineScrollRef.current;
timelineScroll.tryRestoringScroll();
}, [onLimitUpdate]);
useEffect(() => {
const timelineScroll = timelineScrollRef.current;
if (!roomTimeline.initialized) return;
if (timelineScroll.bottom < 16 && !roomTimeline.canPaginateForward() && document.visibilityState === 'visible') {
timelineScroll.scrollToBottom();
} else {
timelineScroll.tryRestoringScroll();
}
}, [newEvent]);
useResizeObserver(
useCallback((entries) => {
if (!roomInputRef.current) return;
const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries);
if (!editorBaseEntry) return;
const timelineScroll = timelineScrollRef.current;
if (!roomTimeline.initialized) return;
if (timelineScroll.bottom < 40 && !roomTimeline.canPaginateForward() && document.visibilityState === 'visible') {
timelineScroll.scrollToBottom();
}
}, [roomInputRef]),
useCallback(() => roomInputRef.current, [roomInputRef]),
);
const listenKeyboard = useCallback((event) => {
if (event.ctrlKey || event.altKey || event.metaKey) return;
if (event.key !== 'ArrowUp') return;
if (navigation.isRawModalVisible) return;
if (document.activeElement.id !== 'message-textarea') return;
if (document.activeElement.value !== '') return;
const {
timeline: tl, activeTimeline, liveTimeline, matrixClient: mx,
} = roomTimeline;
const limit = eventLimitRef.current;
if (activeTimeline !== liveTimeline) return;
if (tl.length > limit.length) return;
const mTypes = ['m.text'];
for (let i = tl.length - 1; i >= 0; i -= 1) {
const mE = tl[i];
if (
mE.getSender() === mx.getUserId()
&& mE.getType() === 'm.room.message'
&& mTypes.includes(mE.getContent()?.msgtype)
) {
setEditEventId(mE.getId());
return;
}
}
}, [roomTimeline]);
useEffect(() => {
document.body.addEventListener('keydown', listenKeyboard);
return () => {
document.body.removeEventListener('keydown', listenKeyboard);
};
}, [listenKeyboard]);
const handleTimelineScroll = (event) => {
const timelineScroll = timelineScrollRef.current;
if (!event.target) return;
throttle._(() => {
const backwards = timelineScroll?.calcScroll();
if (typeof backwards !== 'boolean') return;
handleScroll(backwards);
}, 200)();
};
const renderTimeline = () => {
const tl = [];
const limit = eventLimitRef.current;
let itemCountIndex = 0;
jumpToItemIndex = -1;
const readUptoEvent = readUptoEvtStore.getItem();
let unreadDivider = false;
if (roomTimeline.canPaginateBackward() || limit.from > 0) {
tl.push(loadingMsgPlaceholders(1, PLACEHOLDER_COUNT));
itemCountIndex += PLACEHOLDER_COUNT;
}
for (let i = limit.from; i < limit.length; i += 1) {
if (i >= timeline.length) break;
const mEvent = timeline[i];
const prevMEvent = timeline[i - 1] ?? null;
if (i === 0 && !roomTimeline.canPaginateBackward()) {
if (mEvent.getType() === 'm.room.create') {
tl.push(
<RoomIntroContainer key={mEvent.getId()} event={mEvent} timeline={roomTimeline} />,
);
itemCountIndex += 1;
// eslint-disable-next-line no-continue
continue;
} else {
tl.push(<RoomIntroContainer key="room-intro" event={null} timeline={roomTimeline} />);
itemCountIndex += 1;
}
}
let isNewEvent = false;
if (!unreadDivider) {
unreadDivider = (readUptoEvent
&& prevMEvent?.getTs() <= readUptoEvent.getTs()
&& readUptoEvent.getTs() < mEvent.getTs());
if (unreadDivider) {
isNewEvent = true;
tl.push(<Divider key={`new-${mEvent.getId()}`} variant="positive" text="New messages" />);
itemCountIndex += 1;
if (jumpToItemIndex === -1) jumpToItemIndex = itemCountIndex;
}
}
const dayDivider = prevMEvent && !isInSameDay(mEvent.getDate(), prevMEvent.getDate());
if (dayDivider) {
tl.push(<Divider key={`divider-${mEvent.getId()}`} text={`${dateFormat(mEvent.getDate(), 'mmmm dd, yyyy')}`} />);
itemCountIndex += 1;
}
const focusId = timelineInfo.focusEventId;
const isFocus = focusId === mEvent.getId();
if (isFocus) jumpToItemIndex = itemCountIndex;
tl.push(renderEvent(
roomTimeline,
mEvent,
isNewEvent ? null : prevMEvent,
isFocus,
editEventId === mEvent.getId(),
setEditEventId,
cancelEdit,
));
itemCountIndex += 1;
}
if (roomTimeline.canPaginateForward() || limit.length < timeline.length) {
tl.push(loadingMsgPlaceholders(2, PLACEHOLDER_COUNT));
}
return tl;
};
return (
<ScrollView onScroll={handleTimelineScroll} ref={timelineSVRef} autoHide>
<div className="room-view__content" onClick={handleOnClickCapture}>
<div className="timeline__wrapper">
{ roomTimeline.initialized ? renderTimeline() : loadingMsgPlaceholders('loading', 3) }
</div>
</div>
</ScrollView>
);
}
RoomViewContent.defaultProps = {
eventId: null,
};
RoomViewContent.propTypes = {
eventId: PropTypes.string,
roomTimeline: PropTypes.shape({}).isRequired,
roomInputRef: PropTypes.shape({
current: PropTypes.shape({})
}).isRequired
};
export default RoomViewContent;

View file

@ -1,125 +0,0 @@
/* eslint-disable react/prop-types */
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import './RoomViewFloating.scss';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import { markAsRead } from '../../../client/action/notifications';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import MessageIC from '../../../../public/res/ic/outlined/message.svg';
import MessageUnreadIC from '../../../../public/res/ic/outlined/message-unread.svg';
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
import { getUsersActionJsx } from './common';
function useJumpToEvent(roomTimeline) {
const [eventId, setEventId] = useState(null);
const jumpToEvent = () => {
roomTimeline.loadEventTimeline(eventId);
};
const cancelJumpToEvent = () => {
markAsRead(roomTimeline.roomId);
setEventId(null);
};
useEffect(() => {
const readEventId = roomTimeline.getReadUpToEventId();
// we only show "Jump to unread" btn only if the event is not in timeline.
// if event is in timeline
// we will automatically open the timeline from that event position
if (!readEventId?.startsWith('~') && !roomTimeline.hasEventInTimeline(readEventId)) {
setEventId(readEventId);
}
const { notifications } = initMatrix;
const handleMarkAsRead = () => setEventId(null);
notifications.on(cons.events.notifications.FULL_READ, handleMarkAsRead);
return () => {
notifications.removeListener(cons.events.notifications.FULL_READ, handleMarkAsRead);
setEventId(null);
};
}, [roomTimeline]);
return [!!eventId, jumpToEvent, cancelJumpToEvent];
}
function useTypingMembers(roomTimeline) {
const [typingMembers, setTypingMembers] = useState(new Set());
const updateTyping = (members) => {
const mx = initMatrix.matrixClient;
members.delete(mx.getUserId());
setTypingMembers(members);
};
useEffect(() => {
setTypingMembers(new Set());
roomTimeline.on(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
return () => {
roomTimeline?.removeListener(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
};
}, [roomTimeline]);
return [typingMembers];
}
function useScrollToBottom(roomTimeline) {
const [isAtBottom, setIsAtBottom] = useState(true);
const handleAtBottom = (atBottom) => setIsAtBottom(atBottom);
useEffect(() => {
setIsAtBottom(true);
roomTimeline.on(cons.events.roomTimeline.AT_BOTTOM, handleAtBottom);
return () => roomTimeline.removeListener(cons.events.roomTimeline.AT_BOTTOM, handleAtBottom);
}, [roomTimeline]);
return [isAtBottom, setIsAtBottom];
}
function RoomViewFloating({
roomId, roomTimeline,
}) {
const [isJumpToEvent, jumpToEvent, cancelJumpToEvent] = useJumpToEvent(roomTimeline);
const [typingMembers] = useTypingMembers(roomTimeline);
const [isAtBottom, setIsAtBottom] = useScrollToBottom(roomTimeline);
const handleScrollToBottom = () => {
roomTimeline.emit(cons.events.roomTimeline.SCROLL_TO_LIVE);
setIsAtBottom(true);
};
return (
<>
<div className={`room-view__unread ${isJumpToEvent ? 'room-view__unread--open' : ''}`}>
<Button iconSrc={MessageUnreadIC} onClick={jumpToEvent} variant="primary">
<Text variant="b3" weight="medium">Jump to unread messages</Text>
</Button>
<Button iconSrc={TickMarkIC} onClick={cancelJumpToEvent} variant="primary">
<Text variant="b3" weight="bold">Mark as read</Text>
</Button>
</div>
<div className={`room-view__typing${typingMembers.size > 0 ? ' room-view__typing--open' : ''}`}>
<div className="bouncing-loader"><div /></div>
<Text variant="b2">{getUsersActionJsx(roomId, [...typingMembers], 'typing...')}</Text>
</div>
<div className={`room-view__STB${isAtBottom ? '' : ' room-view__STB--open'}`}>
<Button iconSrc={MessageIC} onClick={handleScrollToBottom}>
<Text variant="b3" weight="medium">Jump to latest</Text>
</Button>
</div>
</>
);
}
RoomViewFloating.propTypes = {
roomId: PropTypes.string.isRequired,
roomTimeline: PropTypes.shape({}).isRequired,
};
export default RoomViewFloating;

View file

@ -1,491 +0,0 @@
/* eslint-disable react/prop-types */
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import './RoomViewInput.scss';
import TextareaAutosize from 'react-autosize-textarea';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import settings from '../../../client/state/settings';
import { openEmojiBoard, openReusableContextMenu } from '../../../client/action/navigation';
import navigation from '../../../client/state/navigation';
import { bytesToSize, getEventCords } from '../../../util/common';
import { getUsername } from '../../../util/matrixUtil';
import colorMXID from '../../../util/colorMXID';
import Text from '../../atoms/text/Text';
import RawIcon from '../../atoms/system-icons/RawIcon';
import IconButton from '../../atoms/button/IconButton';
import ScrollView from '../../atoms/scroll/ScrollView';
import { MessageReply } from '../../molecules/message/Message';
import StickerBoard from '../sticker-board/StickerBoard';
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg';
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
import SendIC from '../../../../public/res/ic/outlined/send.svg';
import StickerIC from '../../../../public/res/ic/outlined/sticker.svg';
import ShieldIC from '../../../../public/res/ic/outlined/shield.svg';
import VLCIC from '../../../../public/res/ic/outlined/vlc.svg';
import VolumeFullIC from '../../../../public/res/ic/outlined/volume-full.svg';
import FileIC from '../../../../public/res/ic/outlined/file.svg';
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
import commands from './commands';
const CMD_REGEX = /(^\/|:|@)(\S*)$/;
let isTyping = false;
let isCmdActivated = false;
let cmdCursorPos = null;
function RoomViewInput({
roomId, roomTimeline, viewEvent,
}) {
const [attachment, setAttachment] = useState(null);
const [replyTo, setReplyTo] = useState(null);
const textAreaRef = useRef(null);
const inputBaseRef = useRef(null);
const uploadInputRef = useRef(null);
const uploadProgressRef = useRef(null);
const rightOptionsRef = useRef(null);
const TYPING_TIMEOUT = 5000;
const mx = initMatrix.matrixClient;
const { roomsInput } = initMatrix;
function requestFocusInput() {
if (textAreaRef === null) return;
textAreaRef.current.focus();
}
useEffect(() => {
roomsInput.on(cons.events.roomsInput.ATTACHMENT_SET, setAttachment);
viewEvent.on('focus_msg_input', requestFocusInput);
return () => {
roomsInput.removeListener(cons.events.roomsInput.ATTACHMENT_SET, setAttachment);
viewEvent.removeListener('focus_msg_input', requestFocusInput);
};
}, []);
const sendIsTyping = (isT) => {
mx.sendTyping(roomId, isT, isT ? TYPING_TIMEOUT : undefined);
isTyping = isT;
if (isT === true) {
setTimeout(() => {
if (isTyping) sendIsTyping(false);
}, TYPING_TIMEOUT);
}
};
function uploadingProgress(myRoomId, { loaded, total }) {
if (myRoomId !== roomId) return;
const progressPer = Math.round((loaded * 100) / total);
uploadProgressRef.current.textContent = `Uploading: ${bytesToSize(loaded)}/${bytesToSize(total)} (${progressPer}%)`;
inputBaseRef.current.style.backgroundImage = `linear-gradient(90deg, var(--bg-surface-hover) ${progressPer}%, var(--bg-surface-low) ${progressPer}%)`;
}
function clearAttachment(myRoomId) {
if (roomId !== myRoomId) return;
setAttachment(null);
inputBaseRef.current.style.backgroundImage = 'unset';
uploadInputRef.current.value = null;
}
function rightOptionsA11Y(A11Y) {
const rightOptions = rightOptionsRef.current.children;
for (let index = 0; index < rightOptions.length; index += 1) {
rightOptions[index].tabIndex = A11Y ? 0 : -1;
}
}
function activateCmd(prefix) {
isCmdActivated = true;
rightOptionsA11Y(false);
viewEvent.emit('cmd_activate', prefix);
}
function deactivateCmd() {
isCmdActivated = false;
cmdCursorPos = null;
rightOptionsA11Y(true);
}
function deactivateCmdAndEmit() {
deactivateCmd();
viewEvent.emit('cmd_deactivate');
}
function setCursorPosition(pos) {
setTimeout(() => {
textAreaRef.current.focus();
textAreaRef.current.setSelectionRange(pos, pos);
}, 0);
}
function replaceCmdWith(msg, cursor, replacement) {
if (msg === null) return null;
const targetInput = msg.slice(0, cursor);
const cmdParts = targetInput.match(CMD_REGEX);
const leadingInput = msg.slice(0, cmdParts.index);
if (replacement.length > 0) setCursorPosition(leadingInput.length + replacement.length);
return leadingInput + replacement + msg.slice(cursor);
}
function firedCmd(cmdData) {
const msg = textAreaRef.current.value;
textAreaRef.current.value = replaceCmdWith(
msg,
cmdCursorPos,
typeof cmdData?.replace !== 'undefined' ? cmdData.replace : '',
);
deactivateCmd();
}
function focusInput() {
if (settings.isTouchScreenDevice) return;
textAreaRef.current.focus();
}
function setUpReply(userId, eventId, body, formattedBody) {
setReplyTo({ userId, eventId, body });
roomsInput.setReplyTo(roomId, {
userId, eventId, body, formattedBody,
});
focusInput();
}
useEffect(() => {
roomsInput.on(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress);
roomsInput.on(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment);
roomsInput.on(cons.events.roomsInput.FILE_UPLOADED, clearAttachment);
viewEvent.on('cmd_fired', firedCmd);
navigation.on(cons.events.navigation.REPLY_TO_CLICKED, setUpReply);
if (textAreaRef?.current !== null) {
isTyping = false;
textAreaRef.current.value = roomsInput.getMessage(roomId);
setAttachment(roomsInput.getAttachment(roomId));
setReplyTo(roomsInput.getReplyTo(roomId));
}
return () => {
roomsInput.removeListener(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress);
roomsInput.removeListener(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment);
roomsInput.removeListener(cons.events.roomsInput.FILE_UPLOADED, clearAttachment);
viewEvent.removeListener('cmd_fired', firedCmd);
navigation.removeListener(cons.events.navigation.REPLY_TO_CLICKED, setUpReply);
if (isCmdActivated) deactivateCmd();
if (textAreaRef?.current === null) return;
const msg = textAreaRef.current.value;
textAreaRef.current.style.height = 'unset';
inputBaseRef.current.style.backgroundImage = 'unset';
if (msg.trim() === '') {
roomsInput.setMessage(roomId, '');
return;
}
roomsInput.setMessage(roomId, msg);
};
}, [roomId]);
const sendBody = async (body, options) => {
const opt = options ?? {};
if (!opt.msgType) opt.msgType = 'm.text';
if (typeof opt.autoMarkdown !== 'boolean') opt.autoMarkdown = true;
if (roomsInput.isSending(roomId)) return;
sendIsTyping(false);
roomsInput.setMessage(roomId, body);
if (attachment !== null) {
roomsInput.setAttachment(roomId, attachment);
}
textAreaRef.current.disabled = true;
textAreaRef.current.style.cursor = 'not-allowed';
await roomsInput.sendInput(roomId, opt);
textAreaRef.current.disabled = false;
textAreaRef.current.style.cursor = 'unset';
focusInput();
textAreaRef.current.value = roomsInput.getMessage(roomId);
textAreaRef.current.style.height = 'unset';
if (replyTo !== null) setReplyTo(null);
};
/** Return true if a command was executed. */
const processCommand = async (cmdBody) => {
const spaceIndex = cmdBody.indexOf(' ');
const cmdName = cmdBody.slice(1, spaceIndex > -1 ? spaceIndex : undefined);
const cmdData = spaceIndex > -1 ? cmdBody.slice(spaceIndex + 1) : '';
if (!commands[cmdName]) {
const sendAsMessage = await confirmDialog('Invalid Command', `"${cmdName}" is not a valid command. Did you mean to send this as a message?`, 'Send as message');
if (sendAsMessage) {
sendBody(cmdBody);
return true;
}
return false;
}
if (['me', 'shrug', 'plain'].includes(cmdName)) {
commands[cmdName].exe(roomId, cmdData, sendBody);
return true;
}
commands[cmdName].exe(roomId, cmdData);
return true;
};
const sendMessage = async () => {
requestAnimationFrame(() => deactivateCmdAndEmit());
const msgBody = textAreaRef.current.value.trim();
if (msgBody.startsWith('/')) {
const executed = await processCommand(msgBody.trim());
if (executed) {
textAreaRef.current.value = '';
textAreaRef.current.style.height = 'unset';
}
return;
}
if (msgBody === '' && attachment === null) return;
sendBody(msgBody);
};
const handleSendSticker = async (data) => {
roomsInput.sendSticker(roomId, data);
};
function processTyping(msg) {
const isEmptyMsg = msg === '';
if (isEmptyMsg && isTyping) {
sendIsTyping(false);
return;
}
if (!isEmptyMsg && !isTyping) {
sendIsTyping(true);
}
}
function getCursorPosition() {
return textAreaRef.current.selectionStart;
}
function recognizeCmd(rawInput) {
const cursor = getCursorPosition();
const targetInput = rawInput.slice(0, cursor);
const cmdParts = targetInput.match(CMD_REGEX);
if (cmdParts === null) {
if (isCmdActivated) deactivateCmdAndEmit();
return;
}
const cmdPrefix = cmdParts[1];
const cmdSlug = cmdParts[2];
if (cmdPrefix === ':') {
// skip emoji autofill command if link is suspected.
const checkForLink = targetInput.slice(0, cmdParts.index);
if (checkForLink.match(/(http|https|mailto|matrix|ircs|irc)$/)) {
deactivateCmdAndEmit();
return;
}
}
cmdCursorPos = cursor;
if (cmdSlug === '') {
activateCmd(cmdPrefix);
return;
}
if (!isCmdActivated) activateCmd(cmdPrefix);
viewEvent.emit('cmd_process', cmdPrefix, cmdSlug);
}
const handleMsgTyping = (e) => {
const msg = e.target.value;
recognizeCmd(e.target.value);
if (!isCmdActivated) processTyping(msg);
};
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
roomsInput.cancelReplyTo(roomId);
setReplyTo(null);
}
if (e.key === 'Enter' && e.shiftKey === false) {
e.preventDefault();
sendMessage();
}
};
const handlePaste = (e) => {
if (e.clipboardData === false) {
return;
}
if (e.clipboardData.items === undefined) {
return;
}
for (let i = 0; i < e.clipboardData.items.length; i += 1) {
const item = e.clipboardData.items[i];
if (item.type.indexOf('image') !== -1) {
const image = item.getAsFile();
if (attachment === null) {
setAttachment(image);
if (image !== null) {
roomsInput.setAttachment(roomId, image);
return;
}
} else {
return;
}
}
}
};
function addEmoji(emoji) {
textAreaRef.current.value += emoji.unicode;
textAreaRef.current.focus();
}
const handleUploadClick = () => {
if (attachment === null) uploadInputRef.current.click();
else {
roomsInput.cancelAttachment(roomId);
}
};
function uploadFileChange(e) {
const file = e.target.files.item(0);
setAttachment(file);
if (file !== null) roomsInput.setAttachment(roomId, file);
}
function renderInputs() {
const canISend = roomTimeline.room.currentState.maySendMessage(mx.getUserId());
const tombstoneEvent = roomTimeline.room.currentState.getStateEvents('m.room.tombstone')[0];
if (!canISend || tombstoneEvent) {
return (
<Text className="room-input__alert">
{
tombstoneEvent
? tombstoneEvent.getContent()?.body ?? 'This room has been replaced and is no longer active.'
: 'You do not have permission to post to this room'
}
</Text>
);
}
return (
<>
<div className={`room-input__option-container${attachment === null ? '' : ' room-attachment__option'}`}>
<input onChange={uploadFileChange} style={{ display: 'none' }} ref={uploadInputRef} type="file" />
<IconButton onClick={handleUploadClick} tooltip={attachment === null ? 'Upload' : 'Cancel'} src={CirclePlusIC} />
</div>
<div ref={inputBaseRef} className="room-input__input-container">
{roomTimeline.isEncrypted() && <RawIcon size="extra-small" src={ShieldIC} />}
<ScrollView autoHide>
<Text className="room-input__textarea-wrapper">
<TextareaAutosize
dir="auto"
id="message-textarea"
ref={textAreaRef}
onChange={handleMsgTyping}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
placeholder="Send a message..."
/>
</Text>
</ScrollView>
</div>
<div ref={rightOptionsRef} className="room-input__option-container">
<IconButton
onClick={(e) => {
openReusableContextMenu(
'top',
(() => {
const cords = getEventCords(e);
cords.y -= 20;
return cords;
})(),
(closeMenu) => (
<StickerBoard
roomId={roomId}
onSelect={(data) => {
handleSendSticker(data);
closeMenu();
}}
/>
),
);
}}
tooltip="Sticker"
src={StickerIC}
/>
<IconButton
onClick={(e) => {
const cords = getEventCords(e);
cords.x += (document.dir === 'rtl' ? -80 : 80);
cords.y -= 250;
openEmojiBoard(cords, addEmoji);
}}
tooltip="Emoji"
src={EmojiIC}
/>
<IconButton onClick={sendMessage} tooltip="Send" src={SendIC} />
</div>
</>
);
}
function attachFile() {
const fileType = attachment.type.slice(0, attachment.type.indexOf('/'));
return (
<div className="room-attachment">
<div className={`room-attachment__preview${fileType !== 'image' ? ' room-attachment__icon' : ''}`}>
{fileType === 'image' && <img alt={attachment.name} src={URL.createObjectURL(attachment)} />}
{fileType === 'video' && <RawIcon src={VLCIC} />}
{fileType === 'audio' && <RawIcon src={VolumeFullIC} />}
{fileType !== 'image' && fileType !== 'video' && fileType !== 'audio' && <RawIcon src={FileIC} />}
</div>
<div className="room-attachment__info">
<Text variant="b1">{attachment.name}</Text>
<Text variant="b3"><span ref={uploadProgressRef}>{`size: ${bytesToSize(attachment.size)}`}</span></Text>
</div>
</div>
);
}
function attachReply() {
return (
<div className="room-reply">
<IconButton
onClick={() => {
roomsInput.cancelReplyTo(roomId);
setReplyTo(null);
}}
src={CrossIC}
tooltip="Cancel reply"
size="extra-small"
/>
<MessageReply
userId={replyTo.userId}
onKeyDown={handleKeyDown}
name={getUsername(replyTo.userId)}
color={colorMXID(replyTo.userId)}
body={replyTo.body}
/>
</div>
);
}
return (
<>
{ replyTo !== null && attachReply()}
{ attachment !== null && attachFile() }
<form className="room-input" onSubmit={(e) => { e.preventDefault(); }}>
{
renderInputs()
}
</form>
</>
);
}
RoomViewInput.propTypes = {
roomId: PropTypes.string.isRequired,
roomTimeline: PropTypes.shape({}).isRequired,
viewEvent: PropTypes.shape({}).isRequired,
};
export default RoomViewInput;

View file

@ -1,136 +0,0 @@
import { getScrollInfo } from '../../../util/common';
class TimelineScroll {
constructor(target) {
if (target === null) {
throw new Error('Can not initialize TimelineScroll, target HTMLElement in null');
}
this.scroll = target;
this.backwards = false;
this.inTopHalf = false;
this.isScrollable = false;
this.top = 0;
this.bottom = 0;
this.height = 0;
this.viewHeight = 0;
this.topMsg = null;
this.bottomMsg = null;
this.diff = 0;
}
scrollToBottom() {
const scrollInfo = getScrollInfo(this.scroll);
const maxScrollTop = scrollInfo.height - scrollInfo.viewHeight;
this._scrollTo(scrollInfo, maxScrollTop);
}
// use previous calc by this._updateTopBottomMsg() & this._calcDiff.
tryRestoringScroll() {
const scrollInfo = getScrollInfo(this.scroll);
let scrollTop = 0;
const ot = this.inTopHalf ? this.topMsg?.offsetTop : this.bottomMsg?.offsetTop;
if (!ot) scrollTop = Math.round(this.height - this.viewHeight);
else scrollTop = ot - this.diff;
this._scrollTo(scrollInfo, scrollTop);
}
scrollToIndex(index, offset = 0) {
const scrollInfo = getScrollInfo(this.scroll);
const msgs = this.scroll.lastElementChild.lastElementChild.children;
const offsetTop = msgs[index]?.offsetTop;
if (offsetTop === undefined) return;
// if msg is already in visible are we don't need to scroll to that
if (offsetTop > scrollInfo.top && offsetTop < (scrollInfo.top + scrollInfo.viewHeight)) return;
const to = offsetTop - offset;
this._scrollTo(scrollInfo, to);
}
_scrollTo(scrollInfo, scrollTop) {
this.scroll.scrollTop = scrollTop;
// browser emit 'onscroll' event only if the 'element.scrollTop' value changes.
// so here we flag that the upcoming 'onscroll' event is
// emitted as side effect of assigning 'this.scroll.scrollTop' above
// only if it's changes.
// by doing so we prevent this._updateCalc() from calc again.
if (scrollTop !== this.top) {
this.scrolledByCode = true;
}
const sInfo = { ...scrollInfo };
const maxScrollTop = scrollInfo.height - scrollInfo.viewHeight;
sInfo.top = (scrollTop > maxScrollTop) ? maxScrollTop : scrollTop;
this._updateCalc(sInfo);
}
// we maintain reference of top and bottom messages
// to restore the scroll position when
// messages gets removed from either end and added to other.
_updateTopBottomMsg() {
const msgs = this.scroll.lastElementChild.lastElementChild.children;
const lMsgIndex = msgs.length - 1;
// TODO: classname 'ph-msg' prevent this class from being used
const PLACEHOLDER_COUNT = 2;
this.topMsg = msgs[0]?.className === 'ph-msg'
? msgs[PLACEHOLDER_COUNT]
: msgs[0];
this.bottomMsg = msgs[lMsgIndex]?.className === 'ph-msg'
? msgs[lMsgIndex - PLACEHOLDER_COUNT]
: msgs[lMsgIndex];
}
// we calculate the difference between first/last message and current scrollTop.
// if we are going above we calc diff between first and scrollTop
// else otherwise.
// NOTE: This will help to restore the scroll when msgs get's removed
// from one end and added to other end
_calcDiff(scrollInfo) {
if (!this.topMsg || !this.bottomMsg) return 0;
if (this.inTopHalf) {
return this.topMsg.offsetTop - scrollInfo.top;
}
return this.bottomMsg.offsetTop - scrollInfo.top;
}
_updateCalc(scrollInfo) {
const halfViewHeight = Math.round(scrollInfo.viewHeight / 2);
const scrollMiddle = scrollInfo.top + halfViewHeight;
const lastMiddle = this.top + halfViewHeight;
this.backwards = scrollMiddle < lastMiddle;
this.inTopHalf = scrollMiddle < scrollInfo.height / 2;
this.isScrollable = scrollInfo.isScrollable;
this.top = scrollInfo.top;
this.bottom = scrollInfo.height - (scrollInfo.top + scrollInfo.viewHeight);
this.height = scrollInfo.height;
this.viewHeight = scrollInfo.viewHeight;
this._updateTopBottomMsg();
this.diff = this._calcDiff(scrollInfo);
}
calcScroll() {
if (this.scrolledByCode) {
this.scrolledByCode = false;
return undefined;
}
const scrollInfo = getScrollInfo(this.scroll);
this._updateCalc(scrollInfo);
return this.backwards;
}
}
export default TimelineScroll;

View file

@ -1,220 +0,0 @@
import React from 'react';
import './commands.scss';
import initMatrix from '../../../client/initMatrix';
import * as roomActions from '../../../client/action/room';
import { hasDMWith, hasDevices } from '../../../util/matrixUtil';
import { selectRoom, openReusableDialog } from '../../../client/action/navigation';
import Text from '../../atoms/text/Text';
import SettingTile from '../../molecules/setting-tile/SettingTile';
const MXID_REG = /^@\S+:\S+$/;
const ROOM_ID_ALIAS_REG = /^(#|!)\S+:\S+$/;
const ROOM_ID_REG = /^!\S+:\S+$/;
const MXC_REG = /^mxc:\/\/\S+$/;
export function processMxidAndReason(data) {
let reason;
let idData = data;
const reasonMatch = data.match(/\s-r\s/);
if (reasonMatch) {
idData = data.slice(0, reasonMatch.index);
reason = data.slice(reasonMatch.index + reasonMatch[0].length);
if (reason.trim() === '') reason = undefined;
}
const rawIds = idData.split(' ');
const userIds = rawIds.filter((id) => id.match(MXID_REG));
return {
userIds,
reason,
};
}
const commands = {
me: {
name: 'me',
description: 'Display action',
exe: (roomId, data, onSuccess) => {
const body = data.trim();
if (body === '') return;
onSuccess(body, { msgType: 'm.emote' });
},
},
shrug: {
name: 'shrug',
description: 'Send ¯\\_(ツ)_/¯ as message',
exe: (roomId, data, onSuccess) => onSuccess(
`¯\\_(ツ)_/¯${data.trim() !== '' ? ` ${data}` : ''}`,
{ msgType: 'm.text' },
),
},
plain: {
name: 'plain',
description: 'Send plain text message',
exe: (roomId, data, onSuccess) => {
const body = data.trim();
if (body === '') return;
onSuccess(body, { msgType: 'm.text', autoMarkdown: false });
},
},
help: {
name: 'help',
description: 'View all commands',
// eslint-disable-next-line no-use-before-define
exe: () => openHelpDialog(),
},
startdm: {
name: 'startdm',
description: 'Start direct message with user. Example: /startdm userId1',
exe: async (roomId, data) => {
const mx = initMatrix.matrixClient;
const rawIds = data.split(' ');
const userIds = rawIds.filter((id) => id.match(MXID_REG) && id !== mx.getUserId());
if (userIds.length === 0) return;
if (userIds.length === 1) {
const dmRoomId = hasDMWith(userIds[0]);
if (dmRoomId) {
selectRoom(dmRoomId);
return;
}
}
const devices = await Promise.all(userIds.map(hasDevices));
const isEncrypt = devices.every((hasDevice) => hasDevice);
const result = await roomActions.createDM(userIds, isEncrypt);
selectRoom(result.room_id);
},
},
join: {
name: 'join',
description: 'Join room with address. Example: /join address1 address2',
exe: (roomId, data) => {
const rawIds = data.split(' ');
const roomIds = rawIds.filter((id) => id.match(ROOM_ID_ALIAS_REG));
roomIds.map((id) => roomActions.join(id));
},
},
leave: {
name: 'leave',
description: 'Leave current room.',
exe: (roomId, data) => {
if (data.trim() === '') {
roomActions.leave(roomId);
return;
}
const rawIds = data.split(' ');
const roomIds = rawIds.filter((id) => id.match(ROOM_ID_REG));
roomIds.map((id) => roomActions.leave(id));
},
},
invite: {
name: 'invite',
description: 'Invite user to room. Example: /invite userId1 userId2 [-r reason]',
exe: (roomId, data) => {
const { userIds, reason } = processMxidAndReason(data);
userIds.map((id) => roomActions.invite(roomId, id, reason));
},
},
disinvite: {
name: 'disinvite',
description: 'Disinvite user to room. Example: /disinvite userId1 userId2 [-r reason]',
exe: (roomId, data) => {
const { userIds, reason } = processMxidAndReason(data);
userIds.map((id) => roomActions.kick(roomId, id, reason));
},
},
kick: {
name: 'kick',
description: 'Kick user from room. Example: /kick userId1 userId2 [-r reason]',
exe: (roomId, data) => {
const { userIds, reason } = processMxidAndReason(data);
userIds.map((id) => roomActions.kick(roomId, id, reason));
},
},
ban: {
name: 'ban',
description: 'Ban user from room. Example: /ban userId1 userId2 [-r reason]',
exe: (roomId, data) => {
const { userIds, reason } = processMxidAndReason(data);
userIds.map((id) => roomActions.ban(roomId, id, reason));
},
},
unban: {
name: 'unban',
description: 'Unban user from room. Example: /unban userId1 userId2',
exe: (roomId, data) => {
const rawIds = data.split(' ');
const userIds = rawIds.filter((id) => id.match(MXID_REG));
userIds.map((id) => roomActions.unban(roomId, id));
},
},
ignore: {
name: 'ignore',
description: 'Ignore user. Example: /ignore userId1 userId2',
exe: (roomId, data) => {
const rawIds = data.split(' ');
const userIds = rawIds.filter((id) => id.match(MXID_REG));
if (userIds.length > 0) roomActions.ignore(userIds);
},
},
unignore: {
name: 'unignore',
description: 'Unignore user. Example: /unignore userId1 userId2',
exe: (roomId, data) => {
const rawIds = data.split(' ');
const userIds = rawIds.filter((id) => id.match(MXID_REG));
if (userIds.length > 0) roomActions.unignore(userIds);
},
},
myroomnick: {
name: 'myroomnick',
description: 'Change nick in current room.',
exe: (roomId, data) => {
const nick = data.trim();
if (nick === '') return;
roomActions.setMyRoomNick(roomId, nick);
},
},
myroomavatar: {
name: 'myroomavatar',
description: 'Change profile picture in current room. Example /myroomavatar mxc://xyzabc',
exe: (roomId, data) => {
if (data.match(MXC_REG)) {
roomActions.setMyRoomAvatar(roomId, data);
}
},
},
converttodm: {
name: 'converttodm',
description: 'Convert room to direct message',
exe: (roomId) => {
roomActions.convertToDm(roomId);
},
},
converttoroom: {
name: 'converttoroom',
description: 'Convert direct message to room',
exe: (roomId) => {
roomActions.convertToRoom(roomId);
},
},
};
function openHelpDialog() {
openReusableDialog(
<Text variant="s1" weight="medium">Commands</Text>,
() => (
<div className="commands-dialog">
{Object.keys(commands).map((cmdName) => (
<SettingTile
key={cmdName}
title={cmdName}
content={<Text variant="b3">{commands[cmdName].description}</Text>}
/>
))}
</div>
),
);
}
export default commands;

View file

@ -1,222 +0,0 @@
import React from 'react';
import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix';
import { getUsername, getUsernameOfRoomMember } from '../../../util/matrixUtil';
function getTimelineJSXMessages() {
return {
join(user) {
return (
<>
<b>{twemojify(user)}</b>
{' joined the room'}
</>
);
},
leave(user, reason) {
const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
return (
<>
<b>{twemojify(user)}</b>
{' left the room'}
{twemojify(reasonMsg)}
</>
);
},
invite(inviter, user) {
return (
<>
<b>{twemojify(inviter)}</b>
{' invited '}
<b>{twemojify(user)}</b>
</>
);
},
cancelInvite(inviter, user) {
return (
<>
<b>{twemojify(inviter)}</b>
{' canceled '}
<b>{twemojify(user)}</b>
{'\'s invite'}
</>
);
},
rejectInvite(user) {
return (
<>
<b>{twemojify(user)}</b>
{' rejected the invitation'}
</>
);
},
kick(actor, user, reason) {
const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
return (
<>
<b>{twemojify(actor)}</b>
{' kicked '}
<b>{twemojify(user)}</b>
{twemojify(reasonMsg)}
</>
);
},
ban(actor, user, reason) {
const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
return (
<>
<b>{twemojify(actor)}</b>
{' banned '}
<b>{twemojify(user)}</b>
{twemojify(reasonMsg)}
</>
);
},
unban(actor, user) {
return (
<>
<b>{twemojify(actor)}</b>
{' unbanned '}
<b>{twemojify(user)}</b>
</>
);
},
avatarSets(user) {
return (
<>
<b>{twemojify(user)}</b>
{' set a avatar'}
</>
);
},
avatarChanged(user) {
return (
<>
<b>{twemojify(user)}</b>
{' changed their avatar'}
</>
);
},
avatarRemoved(user) {
return (
<>
<b>{twemojify(user)}</b>
{' removed their avatar'}
</>
);
},
nameSets(user, newName) {
return (
<>
<b>{twemojify(user)}</b>
{' set display name to '}
<b>{twemojify(newName)}</b>
</>
);
},
nameChanged(user, newName) {
return (
<>
<b>{twemojify(user)}</b>
{' changed their display name to '}
<b>{twemojify(newName)}</b>
</>
);
},
nameRemoved(user, lastName) {
return (
<>
<b>{twemojify(user)}</b>
{' removed their display name '}
<b>{twemojify(lastName)}</b>
</>
);
},
};
}
function getUsersActionJsx(roomId, userIds, actionStr) {
const room = initMatrix.matrixClient.getRoom(roomId);
const getUserDisplayName = (userId) => {
if (room?.getMember(userId)) return getUsernameOfRoomMember(room.getMember(userId));
return getUsername(userId);
};
const getUserJSX = (userId) => <b>{twemojify(getUserDisplayName(userId))}</b>;
if (!Array.isArray(userIds)) return 'Idle';
if (userIds.length === 0) return 'Idle';
const MAX_VISIBLE_COUNT = 3;
const u1Jsx = getUserJSX(userIds[0]);
// eslint-disable-next-line react/jsx-one-expression-per-line
if (userIds.length === 1) return <>{u1Jsx} is {actionStr}</>;
const u2Jsx = getUserJSX(userIds[1]);
// eslint-disable-next-line react/jsx-one-expression-per-line
if (userIds.length === 2) return <>{u1Jsx} and {u2Jsx} are {actionStr}</>;
const u3Jsx = getUserJSX(userIds[2]);
if (userIds.length === 3) {
// eslint-disable-next-line react/jsx-one-expression-per-line
return <>{u1Jsx}, {u2Jsx} and {u3Jsx} are {actionStr}</>;
}
const othersCount = userIds.length - MAX_VISIBLE_COUNT;
// eslint-disable-next-line react/jsx-one-expression-per-line
return <>{u1Jsx}, {u2Jsx}, {u3Jsx} and {othersCount} others are {actionStr}</>;
}
function parseTimelineChange(mEvent) {
const tJSXMsgs = getTimelineJSXMessages();
const makeReturnObj = (variant, content) => ({
variant,
content,
});
const content = mEvent.getContent();
const prevContent = mEvent.getPrevContent();
const sender = mEvent.getSender();
const senderName = getUsername(sender);
const userName = getUsername(mEvent.getStateKey());
switch (content.membership) {
case 'invite': return makeReturnObj('invite', tJSXMsgs.invite(senderName, userName));
case 'ban': return makeReturnObj('leave', tJSXMsgs.ban(senderName, userName, content.reason));
case 'join':
if (prevContent.membership === 'join') {
if (content.displayname !== prevContent.displayname) {
if (typeof content.displayname === 'undefined') return makeReturnObj('avatar', tJSXMsgs.nameRemoved(sender, prevContent.displayname));
if (typeof prevContent.displayname === 'undefined') return makeReturnObj('avatar', tJSXMsgs.nameSets(sender, content.displayname));
return makeReturnObj('avatar', tJSXMsgs.nameChanged(prevContent.displayname, content.displayname));
}
if (content.avatar_url !== prevContent.avatar_url) {
if (typeof content.avatar_url === 'undefined') return makeReturnObj('avatar', tJSXMsgs.avatarRemoved(content.displayname));
if (typeof prevContent.avatar_url === 'undefined') return makeReturnObj('avatar', tJSXMsgs.avatarSets(content.displayname));
return makeReturnObj('avatar', tJSXMsgs.avatarChanged(content.displayname));
}
return null;
}
return makeReturnObj('join', tJSXMsgs.join(senderName));
case 'leave':
if (sender === mEvent.getStateKey()) {
switch (prevContent.membership) {
case 'invite': return makeReturnObj('invite-cancel', tJSXMsgs.rejectInvite(senderName));
default: return makeReturnObj('leave', tJSXMsgs.leave(senderName, content.reason));
}
}
switch (prevContent.membership) {
case 'invite': return makeReturnObj('invite-cancel', tJSXMsgs.cancelInvite(senderName, userName));
case 'ban': return makeReturnObj('other', tJSXMsgs.unban(senderName, userName));
// sender is not target and made the target leave,
// if not from invite/ban then this is a kick
default: return makeReturnObj('leave', tJSXMsgs.kick(senderName, userName, content.reason));
}
default: return null;
}
}
export {
getTimelineJSXMessages,
getUsersActionJsx,
parseTimelineChange,
};

View file

@ -1,115 +0,0 @@
/* 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;

View file

@ -112,3 +112,13 @@ emojisData.forEach((emoji) => {
emojis.push(em);
}
});
export const isUsingTwemoji = () =>
document.documentElement.style.getPropertyValue('--font-emoji') !== 'Twemoji_DISABLED';
const TWEMOJI_BASE_URL = '/twemoji'; // TODO
export function getEmojiUrl(char: string): string {
let codes = Array.from(char).flatMap(x => x.codePointAt(0)?.toString(16) ?? []);
if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f');
return `${TWEMOJI_BASE_URL}/${codes.join('-')}.svg`;
}

View file

@ -17,7 +17,7 @@ import * as css from '../styles/CustomHtml.css';
import { getMxIdLocalPart, getRoomWithCanonicalAlias } from '../utils/matrix';
import { getMemberDisplayName } from '../utils/room';
import { EMOJI_PATTERN, URL_NEG_LB } from '../utils/regex';
import { getHexcodeForEmoji, getShortcodeFor } from './emoji';
import { getHexcodeForEmoji, getShortcodeFor, getEmojiUrl, isUsingTwemoji } from './emoji';
import { findAndReplace } from '../utils/findAndReplace';
const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
@ -39,13 +39,21 @@ const textToEmojifyJSX = (text: string): (string | JSX.Element)[] =>
findAndReplace(
text,
EMOJI_REG_G,
(match, pushIndex) => (
<span key={pushIndex} className={css.EmoticonBase}>
<span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(match[0]))}>
{match[0]}
</span>
</span>
),
(match, pushIndex) => {
const char = match[0];
const className = css.Emoticon();
const title = getShortcodeFor(getHexcodeForEmoji(char));
return <span key={pushIndex} className={css.EmoticonBase}>{
isUsingTwemoji() ? <img
className={className}
title={title}
src={getEmojiUrl(char)}
/> : <span
className={className}
title={title}
>{match[0]}</span>
}</span>;
},
(txt) => txt
);

View file

@ -1,63 +0,0 @@
import { useAtomValue, WritableAtom } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { MatrixClient } from 'matrix-js-sdk';
import { useCallback } from 'react';
import { isDirectInvite, isRoom, isSpace, isUnsupportedRoom } from '../../utils/room';
import { compareRoomsEqual, RoomsAction } from '../utils';
import { MDirectAction } from '../mDirectList';
export const useSpaceInvites = (
mx: MatrixClient,
allInvitesAtom: WritableAtom<string[], RoomsAction>
) => {
const selector = useCallback(
(rooms: string[]) => rooms.filter((roomId) => isSpace(mx.getRoom(roomId))),
[mx]
);
return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual));
};
export const useRoomInvites = (
mx: MatrixClient,
allInvitesAtom: WritableAtom<string[], RoomsAction>,
mDirectAtom: WritableAtom<Set<string>, MDirectAction>
) => {
const mDirects = useAtomValue(mDirectAtom);
const selector = useCallback(
(rooms: string[]) =>
rooms.filter(
(roomId) =>
isRoom(mx.getRoom(roomId)) &&
!(mDirects.has(roomId) || isDirectInvite(mx.getRoom(roomId), mx.getUserId()))
),
[mx, mDirects]
);
return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual));
};
export const useDirectInvites = (
mx: MatrixClient,
allInvitesAtom: WritableAtom<string[], RoomsAction>,
mDirectAtom: WritableAtom<Set<string>, MDirectAction>
) => {
const mDirects = useAtomValue(mDirectAtom);
const selector = useCallback(
(rooms: string[]) =>
rooms.filter(
(roomId) => mDirects.has(roomId) || isDirectInvite(mx.getRoom(roomId), mx.getUserId())
),
[mx, mDirects]
);
return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual));
};
export const useUnsupportedInvites = (
mx: MatrixClient,
allInvitesAtom: WritableAtom<string[], RoomsAction>
) => {
const selector = useCallback(
(rooms: string[]) => rooms.filter((roomId) => isUnsupportedRoom(mx.getRoom(roomId))),
[mx]
);
return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual));
};

View file

@ -1,54 +0,0 @@
import { useAtomValue, WritableAtom } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { MatrixClient } from 'matrix-js-sdk';
import { useCallback } from 'react';
import { isRoom, isSpace, isUnsupportedRoom } from '../../utils/room';
import { compareRoomsEqual, RoomsAction } from '../utils';
import { MDirectAction } from '../mDirectList';
export const useSpaces = (mx: MatrixClient, allRoomsAtom: WritableAtom<string[], RoomsAction>) => {
const selector = useCallback(
(rooms: string[]) => rooms.filter((roomId) => isSpace(mx.getRoom(roomId))),
[mx]
);
return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual));
};
export const useRooms = (
mx: MatrixClient,
allRoomsAtom: WritableAtom<string[], RoomsAction>,
mDirectAtom: WritableAtom<Set<string>, MDirectAction>
) => {
const mDirects = useAtomValue(mDirectAtom);
const selector = useCallback(
(rooms: string[]) =>
rooms.filter((roomId) => isRoom(mx.getRoom(roomId)) && !mDirects.has(roomId)),
[mx, mDirects]
);
return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual));
};
export const useDirects = (
mx: MatrixClient,
allRoomsAtom: WritableAtom<string[], RoomsAction>,
mDirectAtom: WritableAtom<Set<string>, MDirectAction>
) => {
const mDirects = useAtomValue(mDirectAtom);
const selector = useCallback(
(rooms: string[]) =>
rooms.filter((roomId) => isRoom(mx.getRoom(roomId)) && mDirects.has(roomId)),
[mx, mDirects]
);
return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual));
};
export const useUnsupportedRooms = (
mx: MatrixClient,
allRoomsAtom: WritableAtom<string[], RoomsAction>
) => {
const selector = useCallback(
(rooms: string[]) => rooms.filter((roomId) => isUnsupportedRoom(mx.getRoom(roomId))),
[mx]
);
return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual));
};

View file

@ -1,16 +0,0 @@
import { MatrixClient } from 'matrix-js-sdk';
import { allInvitesAtom, useBindAllInvitesAtom } from '../inviteList';
import { allRoomsAtom, useBindAllRoomsAtom } from '../roomList';
import { mDirectAtom, useBindMDirectAtom } from '../mDirectList';
import { muteChangesAtom, mutedRoomsAtom, useBindMutedRoomsAtom } from '../mutedRoomList';
import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../roomToUnread';
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../roomToParents';
export const useBindAtoms = (mx: MatrixClient) => {
useBindMDirectAtom(mx, mDirectAtom);
useBindAllInvitesAtom(mx, allInvitesAtom);
useBindAllRoomsAtom(mx, allRoomsAtom);
useBindRoomToParentsAtom(mx, roomToParentsAtom);
useBindMutedRoomsAtom(mx, mutedRoomsAtom);
useBindRoomToUnreadAtom(mx, roomToUnreadAtom, muteChangesAtom);
};

View file

@ -1,32 +0,0 @@
import { atom, WritableAtom } from 'jotai';
import { MatrixClient } from 'matrix-js-sdk';
import { useMemo } from 'react';
import { Membership } from '../../types/matrix/room';
import { RoomsAction, useBindRoomsWithMembershipsAtom } from './utils';
const baseRoomsAtom = atom<string[]>([]);
export const allInvitesAtom = atom<string[], RoomsAction>(
(get) => get(baseRoomsAtom),
(get, set, action) => {
if (action.type === 'INITIALIZE') {
set(baseRoomsAtom, action.rooms);
return;
}
set(baseRoomsAtom, (ids) => {
const newIds = ids.filter((id) => id !== action.roomId);
if (action.type === 'PUT') newIds.push(action.roomId);
return newIds;
});
}
);
export const useBindAllInvitesAtom = (
mx: MatrixClient,
allRooms: WritableAtom<string[], RoomsAction>
) => {
useBindRoomsWithMembershipsAtom(
mx,
allRooms,
useMemo(() => [Membership.Invite], [])
);
};

View file

@ -1,47 +0,0 @@
import { atom, useSetAtom, WritableAtom } from 'jotai';
import { ClientEvent, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
import { useEffect } from 'react';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { getAccountData, getMDirects } from '../utils/room';
export type MDirectAction = {
type: 'INITIALIZE' | 'UPDATE';
rooms: Set<string>;
};
const baseMDirectAtom = atom(new Set<string>());
export const mDirectAtom = atom<Set<string>, MDirectAction>(
(get) => get(baseMDirectAtom),
(get, set, action) => {
set(baseMDirectAtom, action.rooms);
}
);
export const useBindMDirectAtom = (
mx: MatrixClient,
mDirect: WritableAtom<Set<string>, MDirectAction>
) => {
const setMDirect = useSetAtom(mDirect);
useEffect(() => {
const mDirectEvent = getAccountData(mx, AccountDataEvent.Direct);
if (mDirectEvent) {
setMDirect({
type: 'INITIALIZE',
rooms: getMDirects(mDirectEvent),
});
}
const handleAccountData = (event: MatrixEvent) => {
setMDirect({
type: 'UPDATE',
rooms: getMDirects(event),
});
};
mx.on(ClientEvent.AccountData, handleAccountData);
return () => {
mx.removeListener(ClientEvent.AccountData, handleAccountData);
};
}, [mx, setMDirect]);
};

View file

@ -1,101 +0,0 @@
import { atom, WritableAtom, useSetAtom } from 'jotai';
import { ClientEvent, IPushRule, IPushRules, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
import { useEffect } from 'react';
import { MuteChanges } from '../../types/matrix/room';
import { findMutedRule, isMutedRule } from '../utils/room';
export type MutedRoomsUpdate =
| {
type: 'INITIALIZE';
addRooms: string[];
}
| {
type: 'UPDATE';
addRooms: string[];
removeRooms: string[];
};
export const muteChangesAtom = atom<MuteChanges>({
added: [],
removed: [],
});
const baseMutedRoomsAtom = atom(new Set<string>());
export const mutedRoomsAtom = atom<Set<string>, MutedRoomsUpdate>(
(get) => get(baseMutedRoomsAtom),
(get, set, action) => {
const mutedRooms = new Set([...get(mutedRoomsAtom)]);
if (action.type === 'INITIALIZE') {
set(baseMutedRoomsAtom, new Set([...action.addRooms]));
set(muteChangesAtom, {
added: [...action.addRooms],
removed: [],
});
return;
}
if (action.type === 'UPDATE') {
action.removeRooms.forEach((roomId) => mutedRooms.delete(roomId));
action.addRooms.forEach((roomId) => mutedRooms.add(roomId));
set(baseMutedRoomsAtom, mutedRooms);
set(muteChangesAtom, {
added: [...action.addRooms],
removed: [...action.removeRooms],
});
}
}
);
export const useBindMutedRoomsAtom = (
mx: MatrixClient,
mutedAtom: WritableAtom<Set<string>, MutedRoomsUpdate>
) => {
const setMuted = useSetAtom(mutedAtom);
useEffect(() => {
const overrideRules = mx.getAccountData('m.push_rules')?.getContent<IPushRules>()
?.global?.override;
if (overrideRules) {
const mutedRooms = overrideRules.reduce<string[]>((rooms, rule) => {
if (isMutedRule(rule)) rooms.push(rule.rule_id);
return rooms;
}, []);
setMuted({
type: 'INITIALIZE',
addRooms: mutedRooms,
});
}
}, [mx, setMuted]);
useEffect(() => {
const handlePushRules = (mEvent: MatrixEvent, oldMEvent?: MatrixEvent) => {
if (mEvent.getType() === 'm.push_rules') {
const override = mEvent?.getContent()?.global?.override as IPushRule[] | undefined;
const oldOverride = oldMEvent?.getContent()?.global?.override as IPushRule[] | undefined;
if (!override || !oldOverride) return;
const isMuteToggled = (rule: IPushRule, otherOverride: IPushRule[]) => {
const roomId = rule.rule_id;
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));
setMuted({
type: 'UPDATE',
addRooms: mutedRules.map((rule) => rule.rule_id),
removeRooms: unMutedRules.map((rule) => rule.rule_id),
});
}
};
mx.on(ClientEvent.AccountData, handlePushRules);
return () => {
mx.removeListener(ClientEvent.AccountData, handlePushRules);
};
}, [mx, setMuted]);
};

View file

@ -1,31 +0,0 @@
import { atom, WritableAtom } from 'jotai';
import { MatrixClient } from 'matrix-js-sdk';
import { useMemo } from 'react';
import { Membership } from '../../types/matrix/room';
import { RoomsAction, useBindRoomsWithMembershipsAtom } from './utils';
const baseRoomsAtom = atom<string[]>([]);
export const allRoomsAtom = atom<string[], RoomsAction>(
(get) => get(baseRoomsAtom),
(get, set, action) => {
if (action.type === 'INITIALIZE') {
set(baseRoomsAtom, action.rooms);
return;
}
set(baseRoomsAtom, (ids) => {
const newIds = ids.filter((id) => id !== action.roomId);
if (action.type === 'PUT') newIds.push(action.roomId);
return newIds;
});
}
);
export const useBindAllRoomsAtom = (
mx: MatrixClient,
allRooms: WritableAtom<string[], RoomsAction>
) => {
useBindRoomsWithMembershipsAtom(
mx,
allRooms,
useMemo(() => [Membership.Join], [])
);
};

View file

@ -1,120 +0,0 @@
import produce from 'immer';
import { atom, useSetAtom, WritableAtom } from 'jotai';
import {
ClientEvent,
MatrixClient,
MatrixEvent,
Room,
RoomEvent,
RoomStateEvent,
} from 'matrix-js-sdk';
import { useEffect } from 'react';
import { Membership, RoomToParents, StateEvent } from '../../types/matrix/room';
import {
getRoomToParents,
getSpaceChildren,
isSpace,
isValidChild,
mapParentWithChildren,
} from '../utils/room';
export type RoomToParentsAction =
| {
type: 'INITIALIZE';
roomToParents: RoomToParents;
}
| {
type: 'PUT';
parent: string;
children: string[];
}
| {
type: 'DELETE';
roomId: string;
};
const baseRoomToParents = atom<RoomToParents>(new Map());
export const roomToParentsAtom = atom<RoomToParents, RoomToParentsAction>(
(get) => get(baseRoomToParents),
(get, set, action) => {
if (action.type === 'INITIALIZE') {
set(baseRoomToParents, action.roomToParents);
return;
}
if (action.type === 'PUT') {
set(
baseRoomToParents,
produce(get(baseRoomToParents), (draftRoomToParents) => {
mapParentWithChildren(draftRoomToParents, action.parent, action.children);
})
);
return;
}
if (action.type === 'DELETE') {
set(
baseRoomToParents,
produce(get(baseRoomToParents), (draftRoomToParents) => {
const noParentRooms: string[] = [];
draftRoomToParents.delete(action.roomId);
draftRoomToParents.forEach((parents, child) => {
parents.delete(action.roomId);
if (parents.size === 0) noParentRooms.push(child);
});
noParentRooms.forEach((room) => draftRoomToParents.delete(room));
})
);
}
}
);
export const useBindRoomToParentsAtom = (
mx: MatrixClient,
roomToParents: WritableAtom<RoomToParents, RoomToParentsAction>
) => {
const setRoomToParents = useSetAtom(roomToParents);
useEffect(() => {
setRoomToParents({ type: 'INITIALIZE', roomToParents: getRoomToParents(mx) });
const handleAddRoom = (room: Room) => {
if (isSpace(room) && room.getMyMembership() !== Membership.Invite) {
setRoomToParents({ type: 'PUT', parent: room.roomId, children: getSpaceChildren(room) });
}
};
const handleMembershipChange = (room: Room, membership: string) => {
if (isSpace(room) && membership === Membership.Join) {
setRoomToParents({ type: 'PUT', parent: room.roomId, children: getSpaceChildren(room) });
}
};
const handleStateChange = (mEvent: MatrixEvent) => {
if (mEvent.getType() === StateEvent.SpaceChild) {
const childId = mEvent.getStateKey();
const roomId = mEvent.getRoomId();
if (childId && roomId) {
if (isValidChild(mEvent)) {
setRoomToParents({ type: 'PUT', parent: roomId, children: [childId] });
} else {
setRoomToParents({ type: 'DELETE', roomId: childId });
}
}
}
};
const handleDeleteRoom = (roomId: string) => {
setRoomToParents({ type: 'DELETE', roomId });
};
mx.on(ClientEvent.Room, handleAddRoom);
mx.on(RoomEvent.MyMembership, handleMembershipChange);
mx.on(RoomStateEvent.Events, handleStateChange);
mx.on(ClientEvent.DeleteRoom, handleDeleteRoom);
return () => {
mx.removeListener(ClientEvent.Room, handleAddRoom);
mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
mx.removeListener(RoomStateEvent.Events, handleStateChange);
mx.removeListener(ClientEvent.DeleteRoom, handleDeleteRoom);
};
}, [mx, setRoomToParents]);
};

View file

@ -1,219 +0,0 @@
import produce from 'immer';
import { atom, useSetAtom, PrimitiveAtom, WritableAtom, useAtomValue } from 'jotai';
import { IRoomTimelineData, MatrixClient, MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
import { ReceiptContent, ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts';
import { useEffect } from 'react';
import {
MuteChanges,
Membership,
NotificationType,
RoomToUnread,
UnreadInfo,
} from '../../types/matrix/room';
import {
getAllParents,
getNotificationType,
getUnreadInfo,
getUnreadInfos,
isNotificationEvent,
roomHaveUnread,
} from '../utils/room';
import { roomToParentsAtom } from './roomToParents';
export type RoomToUnreadAction =
| {
type: 'RESET';
unreadInfos: UnreadInfo[];
}
| {
type: 'PUT';
unreadInfo: UnreadInfo;
}
| {
type: 'DELETE';
roomId: string;
};
const putUnreadInfo = (
roomToUnread: RoomToUnread,
allParents: Set<string>,
unreadInfo: UnreadInfo
) => {
const oldUnread = roomToUnread.get(unreadInfo.roomId) ?? { highlight: 0, total: 0, from: null };
roomToUnread.set(unreadInfo.roomId, {
highlight: unreadInfo.highlight,
total: unreadInfo.total,
from: null,
});
const newH = unreadInfo.highlight - oldUnread.highlight;
const newT = unreadInfo.total - oldUnread.total;
allParents.forEach((parentId) => {
const oldParentUnread = roomToUnread.get(parentId) ?? { highlight: 0, total: 0, from: null };
roomToUnread.set(parentId, {
highlight: (oldParentUnread.highlight += newH),
total: (oldParentUnread.total += newT),
from: new Set([...(oldParentUnread.from ?? []), unreadInfo.roomId]),
});
});
};
const deleteUnreadInfo = (roomToUnread: RoomToUnread, allParents: Set<string>, roomId: string) => {
const oldUnread = roomToUnread.get(roomId);
if (!oldUnread) return;
roomToUnread.delete(roomId);
allParents.forEach((parentId) => {
const oldParentUnread = roomToUnread.get(parentId);
if (!oldParentUnread) return;
const newFrom = new Set([...(oldParentUnread.from ?? roomId)]);
newFrom.delete(roomId);
if (newFrom.size === 0) {
roomToUnread.delete(parentId);
return;
}
roomToUnread.set(parentId, {
highlight: oldParentUnread.highlight - oldUnread.highlight,
total: oldParentUnread.total - oldUnread.total,
from: newFrom,
});
});
};
const baseRoomToUnread = atom<RoomToUnread>(new Map());
export const roomToUnreadAtom = atom<RoomToUnread, RoomToUnreadAction>(
(get) => get(baseRoomToUnread),
(get, set, action) => {
if (action.type === 'RESET') {
const draftRoomToUnread: RoomToUnread = new Map();
action.unreadInfos.forEach((unreadInfo) => {
putUnreadInfo(
draftRoomToUnread,
getAllParents(get(roomToParentsAtom), unreadInfo.roomId),
unreadInfo
);
});
set(baseRoomToUnread, draftRoomToUnread);
return;
}
if (action.type === 'PUT') {
set(
baseRoomToUnread,
produce(get(baseRoomToUnread), (draftRoomToUnread) =>
putUnreadInfo(
draftRoomToUnread,
getAllParents(get(roomToParentsAtom), action.unreadInfo.roomId),
action.unreadInfo
)
)
);
return;
}
if (action.type === 'DELETE' && get(baseRoomToUnread).has(action.roomId)) {
set(
baseRoomToUnread,
produce(get(baseRoomToUnread), (draftRoomToUnread) =>
deleteUnreadInfo(
draftRoomToUnread,
getAllParents(get(roomToParentsAtom), action.roomId),
action.roomId
)
)
);
}
}
);
export const useBindRoomToUnreadAtom = (
mx: MatrixClient,
unreadAtom: WritableAtom<RoomToUnread, RoomToUnreadAction>,
muteChangesAtom: PrimitiveAtom<MuteChanges>
) => {
const setUnreadAtom = useSetAtom(unreadAtom);
const muteChanges = useAtomValue(muteChangesAtom);
useEffect(() => {
setUnreadAtom({
type: 'RESET',
unreadInfos: getUnreadInfos(mx),
});
}, [mx, setUnreadAtom]);
useEffect(() => {
const handleTimelineEvent = (
mEvent: MatrixEvent,
room: Room | undefined,
toStartOfTimeline: boolean | undefined,
removed: boolean,
data: IRoomTimelineData
) => {
if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
setUnreadAtom({
type: 'DELETE',
roomId: room.roomId,
});
return;
}
if (mEvent.getSender() === mx.getUserId()) return;
setUnreadAtom({ type: 'PUT', unreadInfo: getUnreadInfo(room) });
};
mx.on(RoomEvent.Timeline, handleTimelineEvent);
return () => {
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
};
}, [mx, setUnreadAtom]);
useEffect(() => {
const handleReceipt = (mEvent: MatrixEvent, room: Room) => {
if (mEvent.getType() === 'm.receipt') {
const myUserId = mx.getUserId();
if (!myUserId) return;
if (room.isSpaceRoom()) return;
const content = mEvent.getContent<ReceiptContent>();
const isMyReceipt = Object.keys(content).find((eventId) =>
(Object.keys(content[eventId]) as ReceiptType[]).find(
(receiptType) => content[eventId][receiptType][myUserId]
)
);
if (isMyReceipt) {
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
}
}
};
mx.on(RoomEvent.Receipt, handleReceipt);
return () => {
mx.removeListener(RoomEvent.Receipt, handleReceipt);
};
}, [mx, setUnreadAtom]);
useEffect(() => {
muteChanges.removed.forEach((roomId) => {
const room = mx.getRoom(roomId);
if (!room) return;
if (!roomHaveUnread(mx, room)) return;
setUnreadAtom({ type: 'PUT', unreadInfo: getUnreadInfo(room) });
});
muteChanges.added.forEach((roomId) => {
setUnreadAtom({ type: 'DELETE', roomId });
});
}, [mx, setUnreadAtom, muteChanges]);
useEffect(() => {
const handleMembershipChange = (room: Room, membership: string) => {
if (membership !== Membership.Join) {
setUnreadAtom({
type: 'DELETE',
roomId: room.roomId,
});
}
};
mx.on(RoomEvent.MyMembership, handleMembershipChange);
return () => {
mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
};
}, [mx, setUnreadAtom]);
};

View file

@ -1,3 +0,0 @@
import { atom } from 'jotai';
export const selectedRoomAtom = atom<string | undefined>(undefined);

View file

@ -1,8 +0,0 @@
import { atom } from 'jotai';
export enum SidebarTab {
Home = 'Home',
People = 'People',
}
export const selectedTabAtom = atom<SidebarTab | string>(SidebarTab.Home);

View file

@ -1,34 +0,0 @@
import produce from 'immer';
import { atom } from 'jotai';
import { MatrixClient } from 'matrix-js-sdk';
type RoomInfo = {
roomId: string;
timestamp: number;
};
type TabToRoom = Map<string, RoomInfo>;
type TabToRoomAction = {
type: 'PUT';
tabInfo: { tabId: string; roomInfo: RoomInfo };
};
const baseTabToRoom = atom<TabToRoom>(new Map());
export const tabToRoomAtom = atom<TabToRoom, TabToRoomAction>(
(get) => get(baseTabToRoom),
(get, set, action) => {
if (action.type === 'PUT') {
set(
baseTabToRoom,
produce(get(baseTabToRoom), (draft) => {
draft.set(action.tabInfo.tabId, action.tabInfo.roomInfo);
})
);
}
}
);
export const useBindTabToRoomAtom = (mx: MatrixClient) => {
console.log(mx);
// TODO:
};

View file

@ -1,64 +0,0 @@
import { useSetAtom, WritableAtom } from 'jotai';
import { ClientEvent, MatrixClient, Room, RoomEvent } from 'matrix-js-sdk';
import { useEffect } from 'react';
import { Membership } from '../../types/matrix/room';
export type RoomsAction =
| {
type: 'INITIALIZE';
rooms: string[];
}
| {
type: 'PUT' | 'DELETE';
roomId: string;
};
export const useBindRoomsWithMembershipsAtom = (
mx: MatrixClient,
roomsAtom: WritableAtom<string[], RoomsAction>,
memberships: Membership[]
) => {
const setRoomsAtom = useSetAtom(roomsAtom);
useEffect(() => {
const satisfyMembership = (room: Room): boolean =>
!!memberships.find((membership) => membership === room.getMyMembership());
setRoomsAtom({
type: 'INITIALIZE',
rooms: mx
.getRooms()
.filter(satisfyMembership)
.map((room) => room.roomId),
});
const handleAddRoom = (room: Room) => {
if (satisfyMembership(room)) {
setRoomsAtom({ type: 'PUT', roomId: room.roomId });
}
};
const handleMembershipChange = (room: Room) => {
if (!satisfyMembership(room)) {
setRoomsAtom({ type: 'DELETE', roomId: room.roomId });
}
};
const handleDeleteRoom = (roomId: string) => {
setRoomsAtom({ type: 'DELETE', roomId });
};
mx.on(ClientEvent.Room, handleAddRoom);
mx.on(RoomEvent.MyMembership, handleMembershipChange);
mx.on(ClientEvent.DeleteRoom, handleDeleteRoom);
return () => {
mx.removeListener(ClientEvent.Room, handleAddRoom);
mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
mx.removeListener(ClientEvent.DeleteRoom, handleDeleteRoom);
};
}, [mx, memberships, setRoomsAtom]);
};
export const compareRoomsEqual = (a: string[], b: string[]) => {
if (a.length !== b.length) return false;
return a.every((roomId, roomIdIndex) => roomId === b[roomIdIndex]);
};

View file

@ -1,8 +0,0 @@
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 const disposable = <P extends unknown[], Q extends unknown[] = [], R = void>(
context: DisposableContext<P, Q, R>
) => context;

View file

@ -1,7 +0,0 @@
import { MatrixClient } from 'matrix-js-sdk';
import initMatrix from './initMatrix';
export const mx = (): MatrixClient => {
if (!initMatrix.matrixClient) console.error('Matrix client is used before initialization!');
return initMatrix.matrixClient!;
};

View file

@ -1,407 +0,0 @@
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;

View file

@ -1,12 +1,5 @@
@use './app/partials/screen';
@font-face {
font-family: Twemoji;
src: url('../public/font/Twemoji.Mozilla.v0.7.0+sup39.240115.woff2'),
url('../public/font/Twemoji.Mozilla.v0.7.0+sup39.240115.ttf');
font-display: swap;
}
:root {
/* background color | --bg-[background type]: value */
--bg-surface: #ffffff;

1
twemoji Submodule

@ -0,0 +1 @@
Subproject commit 310184baaae6f797e9ef2650bea35187242ffafb

View file

@ -33,6 +33,10 @@ const copyFiles = {
src: 'public/res/android',
dest: 'public/',
},
{
src: 'twemoji/assets/svg/*',
dest: 'twemoji/',
},
],
}