Compare commits
15 commits
db4ac6a2c2
...
b54f356bb1
Author | SHA1 | Date | |
---|---|---|---|
b54f356bb1 | |||
b011a4413a | |||
36a5eb3c47 | |||
c3ba872553 | |||
580216f11a | |||
dd27cd6b5e | |||
658185a39f | |||
bb4763e509 | |||
d4174ac449 | |||
4e58dbda99 | |||
aca74157a1 | |||
4cb7d210b7 | |||
32585f45c3 | |||
15fc4aa50f | |||
77ab3fba1e |
58 changed files with 1306 additions and 6524 deletions
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
[submodule "twemoji"]
|
||||||
|
path = twemoji
|
||||||
|
url = https://github.com/jdecked/twemoji
|
|
@ -3,7 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
||||||
<title>Cinny</title>
|
<title>Cinny</title>
|
||||||
<meta name="name" content="Cinny" />
|
<meta name="name" content="Cinny" />
|
||||||
<meta name="author" content="Ajay Bura" />
|
<meta name="author" content="Ajay Bura" />
|
||||||
|
|
3456
package-lock.json
generated
3456
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -35,8 +35,8 @@
|
||||||
"dateformat": "5.0.3",
|
"dateformat": "5.0.3",
|
||||||
"dayjs": "1.11.10",
|
"dayjs": "1.11.10",
|
||||||
"domhandler": "5.0.3",
|
"domhandler": "5.0.3",
|
||||||
"emojibase": "6.1.0",
|
"emojibase": "15.2.0",
|
||||||
"emojibase-data": "7.0.1",
|
"emojibase-data": "15.2.0",
|
||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
"flux": "4.0.3",
|
"flux": "4.0.3",
|
||||||
"focus-trap-react": "10.0.2",
|
"focus-trap-react": "10.0.2",
|
||||||
|
@ -73,7 +73,6 @@
|
||||||
"slate-history": "0.93.0",
|
"slate-history": "0.93.0",
|
||||||
"slate-react": "0.98.4",
|
"slate-react": "0.98.4",
|
||||||
"tippy.js": "6.3.7",
|
"tippy.js": "6.3.7",
|
||||||
"twemoji": "14.0.2",
|
|
||||||
"ua-parser-js": "1.0.35"
|
"ua-parser-js": "1.0.35"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
Binary file not shown.
Binary file not shown.
|
@ -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;
|
|
|
@ -1,33 +0,0 @@
|
||||||
import React, { useEffect, useRef } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './Math.scss';
|
|
||||||
|
|
||||||
import katex from 'katex';
|
|
||||||
import 'katex/dist/katex.min.css';
|
|
||||||
|
|
||||||
import 'katex/dist/contrib/copy-tex';
|
|
||||||
|
|
||||||
const Math = React.memo(({
|
|
||||||
content, throwOnError, errorColor, displayMode,
|
|
||||||
}) => {
|
|
||||||
const ref = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
katex.render(content, ref.current, { throwOnError, errorColor, displayMode });
|
|
||||||
}, [content, throwOnError, errorColor, displayMode]);
|
|
||||||
|
|
||||||
return <span ref={ref} />;
|
|
||||||
});
|
|
||||||
Math.defaultProps = {
|
|
||||||
throwOnError: null,
|
|
||||||
errorColor: null,
|
|
||||||
displayMode: null,
|
|
||||||
};
|
|
||||||
Math.propTypes = {
|
|
||||||
content: PropTypes.string.isRequired,
|
|
||||||
throwOnError: PropTypes.bool,
|
|
||||||
errorColor: PropTypes.string,
|
|
||||||
displayMode: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Math;
|
|
|
@ -1,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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
import * as css from '../../styles/CustomHtml.css';
|
import * as css from '../../styles/CustomHtml.css';
|
||||||
import { CommandElement, EmoticonElement, LinkElement, MentionElement } from './slate';
|
import { CommandElement, EmoticonElement, LinkElement, MentionElement } from './slate';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { getEmojiUrl, isUsingTwemoji } from '../../plugins/emoji';
|
||||||
import { getBeginCommand } from './utils';
|
import { getBeginCommand } from './utils';
|
||||||
import { BlockType } from './types';
|
import { BlockType } from './types';
|
||||||
|
|
||||||
|
@ -94,7 +95,12 @@ function RenderEmoticonElement({
|
||||||
alt={element.shortcode}
|
alt={element.shortcode}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
element.key
|
isUsingTwemoji() ? <img
|
||||||
|
className={css.EmoticonImg}
|
||||||
|
src={getEmojiUrl(element.key)}
|
||||||
|
alt={element.key}
|
||||||
|
title={element.shortcode}
|
||||||
|
/> : element.key
|
||||||
)}
|
)}
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -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([
|
export const StickerImg = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
|
|
|
@ -34,7 +34,15 @@ import { MatrixClient, Room } from 'matrix-js-sdk';
|
||||||
import { atom, useAtomValue, useSetAtom } from 'jotai';
|
import { atom, useAtomValue, useSetAtom } from 'jotai';
|
||||||
|
|
||||||
import * as css from './EmojiBoard.css';
|
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 { IEmojiGroupLabels, useEmojiGroupLabels } from './useEmojiGroupLabels';
|
||||||
import { IEmojiGroupIcons, useEmojiGroupIcons } from './useEmojiGroupIcons';
|
import { IEmojiGroupIcons, useEmojiGroupIcons } from './useEmojiGroupIcons';
|
||||||
import { preventScrollWithArrowKey } from '../../utils/keyboard';
|
import { preventScrollWithArrowKey } from '../../utils/keyboard';
|
||||||
|
@ -270,6 +278,15 @@ export const EmojiGroup = as<
|
||||||
</Box>
|
</Box>
|
||||||
));
|
));
|
||||||
|
|
||||||
|
const NativeEmoji = (emoji: IEmoji) =>
|
||||||
|
isUsingTwemoji() ? <img
|
||||||
|
loading="lazy"
|
||||||
|
className={css.EmoticonImg}
|
||||||
|
src={getEmojiUrl(emoji.unicode)}
|
||||||
|
alt={emoji.unicode}
|
||||||
|
title={emoji.shortcode}
|
||||||
|
/> : emoji.unicode;
|
||||||
|
|
||||||
export function EmojiItem({
|
export function EmojiItem({
|
||||||
label,
|
label,
|
||||||
type,
|
type,
|
||||||
|
@ -440,7 +457,7 @@ export function RecentEmojiGroup({
|
||||||
data={emoji.unicode}
|
data={emoji.unicode}
|
||||||
shortcode={emoji.shortcode}
|
shortcode={emoji.shortcode}
|
||||||
>
|
>
|
||||||
{emoji.unicode}
|
{NativeEmoji(emoji)}
|
||||||
</EmojiItem>
|
</EmojiItem>
|
||||||
))}
|
))}
|
||||||
</EmojiGroup>
|
</EmojiGroup>
|
||||||
|
@ -472,7 +489,7 @@ export function SearchEmojiGroup({
|
||||||
data={emoji.unicode}
|
data={emoji.unicode}
|
||||||
shortcode={emoji.shortcode}
|
shortcode={emoji.shortcode}
|
||||||
>
|
>
|
||||||
{emoji.unicode}
|
{NativeEmoji(emoji)}
|
||||||
</EmojiItem>
|
</EmojiItem>
|
||||||
) : (
|
) : (
|
||||||
<EmojiItem
|
<EmojiItem
|
||||||
|
@ -595,7 +612,7 @@ export const NativeEmojiGroups = memo(
|
||||||
data={emoji.unicode}
|
data={emoji.unicode}
|
||||||
shortcode={emoji.shortcode}
|
shortcode={emoji.shortcode}
|
||||||
>
|
>
|
||||||
{emoji.unicode}
|
{NativeEmoji(emoji)}
|
||||||
</EmojiItem>
|
</EmojiItem>
|
||||||
))}
|
))}
|
||||||
</EmojiGroup>
|
</EmojiGroup>
|
||||||
|
@ -725,7 +742,16 @@ export function EmojiBoard({
|
||||||
const emojiInfo = getEmojiItemInfo(element);
|
const emojiInfo = getEmojiItemInfo(element);
|
||||||
if (!emojiInfo || !emojiPreviewTextRef.current) return;
|
if (!emojiInfo || !emojiPreviewTextRef.current) return;
|
||||||
if (emojiInfo.type === EmojiType.Emoji && emojiPreviewRef.current) {
|
if (emojiInfo.type === EmojiType.Emoji && emojiPreviewRef.current) {
|
||||||
emojiPreviewRef.current.textContent = emojiInfo.data;
|
if (isUsingTwemoji()) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.className = css.CustomEmojiImg;
|
||||||
|
img.setAttribute('src', getEmojiUrl(emojiInfo.data));
|
||||||
|
img.setAttribute('alt', emojiInfo.shortcode);
|
||||||
|
emojiPreviewRef.current.textContent = '';
|
||||||
|
emojiPreviewRef.current.appendChild(img);
|
||||||
|
} else {
|
||||||
|
emojiPreviewRef.current.textContent = emojiInfo.data;
|
||||||
|
}
|
||||||
} else if (emojiInfo.type === EmojiType.CustomEmoji && emojiPreviewRef.current) {
|
} else if (emojiInfo.type === EmojiType.CustomEmoji && emojiPreviewRef.current) {
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.className = css.CustomEmojiImg;
|
img.className = css.CustomEmojiImg;
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { Box, Text, as } from 'folds';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
|
import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
|
||||||
import * as css from './Reaction.css';
|
import * as css from './Reaction.css';
|
||||||
import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji';
|
import { getHexcodeForEmoji, getShortcodeFor, getEmojiUrl, isUsingTwemoji } from '../../plugins/emoji';
|
||||||
import { getMemberDisplayName } from '../../utils/room';
|
import { getMemberDisplayName } from '../../utils/room';
|
||||||
import { eventWithShortcode, getMxIdLocalPart } from '../../utils/matrix';
|
import { eventWithShortcode, getMxIdLocalPart } from '../../utils/matrix';
|
||||||
|
|
||||||
|
@ -31,6 +31,13 @@ export const Reaction = as<
|
||||||
src={mx.mxcUrlToHttp(reaction) ?? reaction}
|
src={mx.mxcUrlToHttp(reaction) ?? reaction}
|
||||||
alt={reaction}
|
alt={reaction}
|
||||||
/>
|
/>
|
||||||
|
) : isUsingTwemoji() ? (
|
||||||
|
<img
|
||||||
|
className={css.ReactionImg}
|
||||||
|
src={getEmojiUrl(reaction)}
|
||||||
|
alt={reaction}
|
||||||
|
title={getShortcodeFor(getHexcodeForEmoji(reaction))}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Text as="span" size="Inherit" truncate>
|
<Text as="span" size="Inherit" truncate>
|
||||||
{reaction}
|
{reaction}
|
||||||
|
|
|
@ -9,7 +9,7 @@ export const Attachment = recipe({
|
||||||
borderRadius: config.radii.R400,
|
borderRadius: config.radii.R400,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
width: toRem(400),
|
// width: toRem(400),
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
outlined: {
|
outlined: {
|
||||||
|
@ -31,7 +31,7 @@ export const AttachmentBox = style([
|
||||||
{
|
{
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
maxHeight: toRem(600),
|
maxHeight: toRem(600),
|
||||||
width: toRem(400),
|
// width: toRem(400),
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -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}`,
|
|
||||||
});
|
|
|
@ -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} />
|
|
||||||
));
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
);
|
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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} />
|
|
||||||
)
|
|
||||||
);
|
|
|
@ -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"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,5 +0,0 @@
|
||||||
export * from './Sidebar';
|
|
||||||
export * from './SidebarAvatar';
|
|
||||||
export * from './SidebarContent';
|
|
||||||
export * from './SidebarStack';
|
|
||||||
export * from './SidebarStackSeparator';
|
|
|
@ -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];
|
|
||||||
};
|
|
|
@ -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]
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,61 +0,0 @@
|
||||||
/* eslint-disable react/prop-types */
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './FollowingMembers.scss';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import { openReadReceipts } from '../../../client/action/navigation';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
|
||||||
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
|
|
||||||
|
|
||||||
import { getUsersActionJsx } from '../../organisms/room/common';
|
|
||||||
|
|
||||||
function FollowingMembers({ roomTimeline }) {
|
|
||||||
const [followingMembers, setFollowingMembers] = useState([]);
|
|
||||||
const { roomId } = roomTimeline;
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const myUserId = mx.getUserId();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const updateFollowingMembers = () => {
|
|
||||||
setFollowingMembers(roomTimeline.getLiveReaders());
|
|
||||||
};
|
|
||||||
const updateOnEvent = (event, room) => {
|
|
||||||
if (room.roomId !== roomId) return;
|
|
||||||
setFollowingMembers(roomTimeline.getLiveReaders());
|
|
||||||
};
|
|
||||||
updateFollowingMembers();
|
|
||||||
roomTimeline.on(cons.events.roomTimeline.LIVE_RECEIPT, updateFollowingMembers);
|
|
||||||
mx.on('Room.timeline', updateOnEvent);
|
|
||||||
return () => {
|
|
||||||
roomTimeline.removeListener(cons.events.roomTimeline.LIVE_RECEIPT, updateFollowingMembers);
|
|
||||||
mx.removeListener('Room.timeline', updateOnEvent);
|
|
||||||
};
|
|
||||||
}, [roomTimeline, roomId]);
|
|
||||||
|
|
||||||
const filteredM = followingMembers.filter((userId) => userId !== myUserId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
filteredM.length !== 0 && (
|
|
||||||
<button
|
|
||||||
className="following-members"
|
|
||||||
onClick={() => openReadReceipts(roomId, followingMembers)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<RawIcon size="extra-small" src={TickMarkIC} />
|
|
||||||
<Text variant="b2">
|
|
||||||
{getUsersActionJsx(roomId, filteredM, 'following the conversation.')}
|
|
||||||
</Text>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
FollowingMembers.propTypes = {
|
|
||||||
roomTimeline: PropTypes.shape({}).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FollowingMembers;
|
|
|
@ -1,78 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './TimelineChange.scss';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
|
||||||
import Time from '../../atoms/time/Time';
|
|
||||||
|
|
||||||
import JoinArraowIC from '../../../../public/res/ic/outlined/join-arrow.svg';
|
|
||||||
import LeaveArraowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
|
|
||||||
import InviteArraowIC from '../../../../public/res/ic/outlined/invite-arrow.svg';
|
|
||||||
import InviteCancelArraowIC from '../../../../public/res/ic/outlined/invite-cancel-arrow.svg';
|
|
||||||
import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
|
||||||
|
|
||||||
function TimelineChange({
|
|
||||||
variant, content, timestamp, onClick,
|
|
||||||
}) {
|
|
||||||
let iconSrc;
|
|
||||||
|
|
||||||
switch (variant) {
|
|
||||||
case 'join':
|
|
||||||
iconSrc = JoinArraowIC;
|
|
||||||
break;
|
|
||||||
case 'leave':
|
|
||||||
iconSrc = LeaveArraowIC;
|
|
||||||
break;
|
|
||||||
case 'invite':
|
|
||||||
iconSrc = InviteArraowIC;
|
|
||||||
break;
|
|
||||||
case 'invite-cancel':
|
|
||||||
iconSrc = InviteCancelArraowIC;
|
|
||||||
break;
|
|
||||||
case 'avatar':
|
|
||||||
iconSrc = UserIC;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
iconSrc = JoinArraowIC;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button style={{ cursor: onClick === null ? 'default' : 'pointer' }} onClick={onClick} type="button" className="timeline-change">
|
|
||||||
<div className="timeline-change__avatar-container">
|
|
||||||
<RawIcon src={iconSrc} size="extra-small" />
|
|
||||||
</div>
|
|
||||||
<div className="timeline-change__content">
|
|
||||||
<Text variant="b2">
|
|
||||||
{content}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<div className="timeline-change__time">
|
|
||||||
<Text variant="b3">
|
|
||||||
<Time timestamp={timestamp} />
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
TimelineChange.defaultProps = {
|
|
||||||
variant: 'other',
|
|
||||||
onClick: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
TimelineChange.propTypes = {
|
|
||||||
variant: PropTypes.oneOf([
|
|
||||||
'join', 'leave', 'invite',
|
|
||||||
'invite-cancel', 'avatar', 'other',
|
|
||||||
]),
|
|
||||||
content: PropTypes.oneOfType([
|
|
||||||
PropTypes.string,
|
|
||||||
PropTypes.node,
|
|
||||||
]).isRequired,
|
|
||||||
timestamp: PropTypes.number.isRequired,
|
|
||||||
onClick: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TimelineChange;
|
|
|
@ -1,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;
|
|
|
@ -1,356 +0,0 @@
|
||||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
|
||||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './EmojiBoard.scss';
|
|
||||||
|
|
||||||
import parse from 'html-react-parser';
|
|
||||||
import twemoji from 'twemoji';
|
|
||||||
import { emojiGroups, emojis } from './emoji';
|
|
||||||
import { getRelevantPacks } from './custom-emoji';
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import navigation from '../../../client/state/navigation';
|
|
||||||
import AsyncSearch from '../../../util/AsyncSearch';
|
|
||||||
import { addRecentEmoji, getRecentEmojis } from './recent';
|
|
||||||
import { TWEMOJI_BASE_URL } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
|
||||||
import IconButton from '../../atoms/button/IconButton';
|
|
||||||
import Input from '../../atoms/input/Input';
|
|
||||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
|
||||||
|
|
||||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
|
||||||
import RecentClockIC from '../../../../public/res/ic/outlined/recent-clock.svg';
|
|
||||||
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
|
|
||||||
import DogIC from '../../../../public/res/ic/outlined/dog.svg';
|
|
||||||
import CupIC from '../../../../public/res/ic/outlined/cup.svg';
|
|
||||||
import BallIC from '../../../../public/res/ic/outlined/ball.svg';
|
|
||||||
import PhotoIC from '../../../../public/res/ic/outlined/photo.svg';
|
|
||||||
import BulbIC from '../../../../public/res/ic/outlined/bulb.svg';
|
|
||||||
import PeaceIC from '../../../../public/res/ic/outlined/peace.svg';
|
|
||||||
import FlagIC from '../../../../public/res/ic/outlined/flag.svg';
|
|
||||||
|
|
||||||
const ROW_EMOJIS_COUNT = 7;
|
|
||||||
|
|
||||||
const EmojiGroup = React.memo(({ name, groupEmojis }) => {
|
|
||||||
function getEmojiBoard() {
|
|
||||||
const emojiBoard = [];
|
|
||||||
const totalEmojis = groupEmojis.length;
|
|
||||||
|
|
||||||
for (let r = 0; r < totalEmojis; r += ROW_EMOJIS_COUNT) {
|
|
||||||
const emojiRow = [];
|
|
||||||
for (let c = r; c < r + ROW_EMOJIS_COUNT; c += 1) {
|
|
||||||
const emojiIndex = c;
|
|
||||||
if (emojiIndex >= totalEmojis) break;
|
|
||||||
const emoji = groupEmojis[emojiIndex];
|
|
||||||
emojiRow.push(
|
|
||||||
<span key={emojiIndex}>
|
|
||||||
{emoji.hexcode ? (
|
|
||||||
// This is a unicode emoji, and should be rendered with twemoji
|
|
||||||
parse(
|
|
||||||
twemoji.parse(emoji.unicode, {
|
|
||||||
attributes: () => ({
|
|
||||||
unicode: emoji.unicode,
|
|
||||||
shortcodes: emoji.shortcodes?.toString(),
|
|
||||||
hexcode: emoji.hexcode,
|
|
||||||
loading: 'lazy',
|
|
||||||
}),
|
|
||||||
base: TWEMOJI_BASE_URL,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
// This is a custom emoji, and should be render as an mxc
|
|
||||||
<img
|
|
||||||
className="emoji"
|
|
||||||
draggable="false"
|
|
||||||
loading="lazy"
|
|
||||||
alt={emoji.shortcode}
|
|
||||||
unicode={`:${emoji.shortcode}:`}
|
|
||||||
shortcodes={emoji.shortcode}
|
|
||||||
src={initMatrix.matrixClient.mxcUrlToHttp(emoji.mxc)}
|
|
||||||
data-mx-emoticon={emoji.mxc}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
emojiBoard.push(
|
|
||||||
<div key={r} className="emoji-row">
|
|
||||||
{emojiRow}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return emojiBoard;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="emoji-group">
|
|
||||||
<Text className="emoji-group__header" variant="b2" weight="bold">
|
|
||||||
{name}
|
|
||||||
</Text>
|
|
||||||
{groupEmojis.length !== 0 && <div className="emoji-set noselect">{getEmojiBoard()}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
EmojiGroup.propTypes = {
|
|
||||||
name: PropTypes.string.isRequired,
|
|
||||||
groupEmojis: PropTypes.arrayOf(
|
|
||||||
PropTypes.shape({
|
|
||||||
length: PropTypes.number,
|
|
||||||
unicode: PropTypes.string,
|
|
||||||
hexcode: PropTypes.string,
|
|
||||||
mxc: PropTypes.string,
|
|
||||||
shortcode: PropTypes.string,
|
|
||||||
shortcodes: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
|
|
||||||
})
|
|
||||||
).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
const asyncSearch = new AsyncSearch();
|
|
||||||
asyncSearch.setup(emojis, { keys: ['shortcode'], isContain: true, limit: 40 });
|
|
||||||
function SearchedEmoji() {
|
|
||||||
const [searchedEmojis, setSearchedEmojis] = useState(null);
|
|
||||||
|
|
||||||
function handleSearchEmoji(resultEmojis, term) {
|
|
||||||
if (term === '' || resultEmojis.length === 0) {
|
|
||||||
if (term === '') setSearchedEmojis(null);
|
|
||||||
else setSearchedEmojis({ emojis: [] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSearchedEmojis({ emojis: resultEmojis });
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
asyncSearch.on(asyncSearch.RESULT_SENT, handleSearchEmoji);
|
|
||||||
return () => {
|
|
||||||
asyncSearch.removeListener(asyncSearch.RESULT_SENT, handleSearchEmoji);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (searchedEmojis === null) return false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EmojiGroup
|
|
||||||
key="-1"
|
|
||||||
name={searchedEmojis.emojis.length === 0 ? 'No search result found' : 'Search results'}
|
|
||||||
groupEmojis={searchedEmojis.emojis}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmojiBoard({ onSelect, searchRef }) {
|
|
||||||
const scrollEmojisRef = useRef(null);
|
|
||||||
const emojiInfo = useRef(null);
|
|
||||||
|
|
||||||
function isTargetNotEmoji(target) {
|
|
||||||
return target.classList.contains('emoji') === false;
|
|
||||||
}
|
|
||||||
function getEmojiDataFromTarget(target) {
|
|
||||||
const unicode = target.getAttribute('unicode');
|
|
||||||
const hexcode = target.getAttribute('hexcode');
|
|
||||||
const mxc = target.getAttribute('data-mx-emoticon');
|
|
||||||
let shortcodes = target.getAttribute('shortcodes');
|
|
||||||
if (typeof shortcodes === 'undefined') shortcodes = undefined;
|
|
||||||
else shortcodes = shortcodes.split(',');
|
|
||||||
return {
|
|
||||||
unicode,
|
|
||||||
hexcode,
|
|
||||||
shortcodes,
|
|
||||||
mxc,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectEmoji(e) {
|
|
||||||
if (isTargetNotEmoji(e.target)) return;
|
|
||||||
|
|
||||||
const emoji = getEmojiDataFromTarget(e.target);
|
|
||||||
onSelect(emoji);
|
|
||||||
if (emoji.hexcode) addRecentEmoji(emoji.unicode);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setEmojiInfo(emoji) {
|
|
||||||
const infoEmoji = emojiInfo.current.firstElementChild.firstElementChild;
|
|
||||||
const infoShortcode = emojiInfo.current.lastElementChild;
|
|
||||||
|
|
||||||
infoEmoji.src = emoji.src;
|
|
||||||
infoEmoji.alt = emoji.unicode;
|
|
||||||
infoShortcode.textContent = `:${emoji.shortcode}:`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hoverEmoji(e) {
|
|
||||||
if (isTargetNotEmoji(e.target)) return;
|
|
||||||
|
|
||||||
const emoji = e.target;
|
|
||||||
const { shortcodes, unicode } = getEmojiDataFromTarget(emoji);
|
|
||||||
const { src } = e.target;
|
|
||||||
|
|
||||||
if (typeof shortcodes === 'undefined') {
|
|
||||||
searchRef.current.placeholder = 'Search';
|
|
||||||
setEmojiInfo({
|
|
||||||
unicode: '🙂',
|
|
||||||
shortcode: 'slight_smile',
|
|
||||||
src: 'https://twemoji.maxcdn.com/v/13.1.0/72x72/1f642.png',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (searchRef.current.placeholder === shortcodes[0]) return;
|
|
||||||
searchRef.current.setAttribute('placeholder', shortcodes[0]);
|
|
||||||
setEmojiInfo({ shortcode: shortcodes[0], src, unicode });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSearchChange() {
|
|
||||||
const term = searchRef.current.value;
|
|
||||||
asyncSearch.search(term);
|
|
||||||
scrollEmojisRef.current.scrollTop = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [availableEmojis, setAvailableEmojis] = useState([]);
|
|
||||||
const [recentEmojis, setRecentEmojis] = useState([]);
|
|
||||||
|
|
||||||
const recentOffset = recentEmojis.length > 0 ? 1 : 0;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const updateAvailableEmoji = (selectedRoomId) => {
|
|
||||||
if (!selectedRoomId) {
|
|
||||||
setAvailableEmojis([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const room = mx.getRoom(selectedRoomId);
|
|
||||||
const parentIds = initMatrix.roomList.getAllParentSpaces(room.roomId);
|
|
||||||
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
|
|
||||||
if (room) {
|
|
||||||
const packs = getRelevantPacks(room.client, [room, ...parentRooms]).filter(
|
|
||||||
(pack) => pack.getEmojis().length !== 0
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set an index for each pack so that we know where to jump when the user uses the nav
|
|
||||||
for (let i = 0; i < packs.length; i += 1) {
|
|
||||||
packs[i].packIndex = i;
|
|
||||||
}
|
|
||||||
setAvailableEmojis(packs);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onOpen = () => {
|
|
||||||
searchRef.current.value = '';
|
|
||||||
handleSearchChange();
|
|
||||||
|
|
||||||
// only update when board is getting opened to prevent shifting UI
|
|
||||||
setRecentEmojis(getRecentEmojis(3 * ROW_EMOJIS_COUNT));
|
|
||||||
};
|
|
||||||
|
|
||||||
navigation.on(cons.events.navigation.ROOM_SELECTED, updateAvailableEmoji);
|
|
||||||
navigation.on(cons.events.navigation.EMOJIBOARD_OPENED, onOpen);
|
|
||||||
return () => {
|
|
||||||
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, updateAvailableEmoji);
|
|
||||||
navigation.removeListener(cons.events.navigation.EMOJIBOARD_OPENED, onOpen);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function openGroup(groupOrder) {
|
|
||||||
let tabIndex = groupOrder;
|
|
||||||
const $emojiContent = scrollEmojisRef.current.firstElementChild;
|
|
||||||
const groupCount = $emojiContent.childElementCount;
|
|
||||||
if (groupCount > emojiGroups.length) {
|
|
||||||
tabIndex += groupCount - emojiGroups.length - availableEmojis.length - recentOffset;
|
|
||||||
}
|
|
||||||
$emojiContent.children[tabIndex].scrollIntoView();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div id="emoji-board" className="emoji-board">
|
|
||||||
<ScrollView invisible>
|
|
||||||
<div className="emoji-board__nav">
|
|
||||||
{recentEmojis.length > 0 && (
|
|
||||||
<IconButton
|
|
||||||
onClick={() => openGroup(0)}
|
|
||||||
src={RecentClockIC}
|
|
||||||
tooltip="Recent"
|
|
||||||
tooltipPlacement="left"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="emoji-board__nav-custom">
|
|
||||||
{availableEmojis.map((pack) => {
|
|
||||||
const src = initMatrix.matrixClient.mxcUrlToHttp(
|
|
||||||
pack.avatarUrl ?? pack.getEmojis()[0].mxc
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<IconButton
|
|
||||||
onClick={() => openGroup(recentOffset + pack.packIndex)}
|
|
||||||
src={src}
|
|
||||||
key={pack.packIndex}
|
|
||||||
tooltip={pack.displayName ?? 'Unknown'}
|
|
||||||
tooltipPlacement="left"
|
|
||||||
isImage
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="emoji-board__nav-twemoji">
|
|
||||||
{[
|
|
||||||
[0, EmojiIC, 'Smilies'],
|
|
||||||
[1, DogIC, 'Animals'],
|
|
||||||
[2, CupIC, 'Food'],
|
|
||||||
[3, BallIC, 'Activities'],
|
|
||||||
[4, PhotoIC, 'Travel'],
|
|
||||||
[5, BulbIC, 'Objects'],
|
|
||||||
[6, PeaceIC, 'Symbols'],
|
|
||||||
[7, FlagIC, 'Flags'],
|
|
||||||
].map(([indx, ico, name]) => (
|
|
||||||
<IconButton
|
|
||||||
onClick={() => openGroup(recentOffset + availableEmojis.length + indx)}
|
|
||||||
key={indx}
|
|
||||||
src={ico}
|
|
||||||
tooltip={name}
|
|
||||||
tooltipPlacement="left"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ScrollView>
|
|
||||||
<div className="emoji-board__content">
|
|
||||||
<div className="emoji-board__content__search">
|
|
||||||
<RawIcon size="small" src={SearchIC} />
|
|
||||||
<Input onChange={handleSearchChange} forwardRef={searchRef} placeholder="Search" />
|
|
||||||
</div>
|
|
||||||
<div className="emoji-board__content__emojis">
|
|
||||||
<ScrollView ref={scrollEmojisRef} autoHide>
|
|
||||||
<div onMouseMove={hoverEmoji} onClick={selectEmoji}>
|
|
||||||
<SearchedEmoji />
|
|
||||||
{recentEmojis.length > 0 && (
|
|
||||||
<EmojiGroup name="Recently used" groupEmojis={recentEmojis} />
|
|
||||||
)}
|
|
||||||
{availableEmojis.map((pack) => (
|
|
||||||
<EmojiGroup
|
|
||||||
name={pack.displayName ?? 'Unknown'}
|
|
||||||
key={pack.packIndex}
|
|
||||||
groupEmojis={pack.getEmojis()}
|
|
||||||
className="custom-emoji-group"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{emojiGroups.map((group) => (
|
|
||||||
<EmojiGroup key={group.name} name={group.name} groupEmojis={group.emojis} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollView>
|
|
||||||
</div>
|
|
||||||
<div ref={emojiInfo} className="emoji-board__content__info">
|
|
||||||
<div>{parse(twemoji.parse('🙂', { base: TWEMOJI_BASE_URL }))}</div>
|
|
||||||
<Text>:slight_smile:</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
EmojiBoard.propTypes = {
|
|
||||||
onSelect: PropTypes.func.isRequired,
|
|
||||||
searchRef: PropTypes.shape({}).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EmojiBoard;
|
|
|
@ -1,78 +0,0 @@
|
||||||
import React, { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import navigation from '../../../client/state/navigation';
|
|
||||||
import settings from '../../../client/state/settings';
|
|
||||||
|
|
||||||
import ContextMenu from '../../atoms/context-menu/ContextMenu';
|
|
||||||
import EmojiBoard from './EmojiBoard';
|
|
||||||
|
|
||||||
let requestCallback = null;
|
|
||||||
let isEmojiBoardVisible = false;
|
|
||||||
function EmojiBoardOpener() {
|
|
||||||
const openerRef = useRef(null);
|
|
||||||
const searchRef = useRef(null);
|
|
||||||
|
|
||||||
function openEmojiBoard(cords, requestEmojiCallback) {
|
|
||||||
if (requestCallback !== null || isEmojiBoardVisible) {
|
|
||||||
requestCallback = null;
|
|
||||||
if (cords.detail === 0) openerRef.current.click();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
openerRef.current.style.transform = `translate(${cords.x}px, ${cords.y}px)`;
|
|
||||||
requestCallback = requestEmojiCallback;
|
|
||||||
openerRef.current.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
function afterEmojiBoardToggle(isVisible) {
|
|
||||||
isEmojiBoardVisible = isVisible;
|
|
||||||
if (isVisible) {
|
|
||||||
if (!settings.isTouchScreenDevice) searchRef.current.focus();
|
|
||||||
} else {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!isEmojiBoardVisible) requestCallback = null;
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addEmoji(emoji) {
|
|
||||||
requestCallback(emoji);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.on(cons.events.navigation.EMOJIBOARD_OPENED, openEmojiBoard);
|
|
||||||
return () => {
|
|
||||||
navigation.removeListener(cons.events.navigation.EMOJIBOARD_OPENED, openEmojiBoard);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextMenu
|
|
||||||
content={(
|
|
||||||
<EmojiBoard onSelect={addEmoji} searchRef={searchRef} />
|
|
||||||
)}
|
|
||||||
afterToggle={afterEmojiBoardToggle}
|
|
||||||
render={(toggleMenu) => (
|
|
||||||
<input
|
|
||||||
ref={openerRef}
|
|
||||||
onClick={toggleMenu}
|
|
||||||
type="button"
|
|
||||||
style={{
|
|
||||||
width: '32px',
|
|
||||||
height: '32px',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
padding: 0,
|
|
||||||
border: 'none',
|
|
||||||
visibility: 'hidden',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default EmojiBoardOpener;
|
|
|
@ -1,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),
|
|
||||||
});
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,35 +0,0 @@
|
||||||
class EventLimit {
|
|
||||||
constructor() {
|
|
||||||
this._from = 0;
|
|
||||||
|
|
||||||
this.SMALLEST_EVT_HEIGHT = 32;
|
|
||||||
this.PAGES_COUNT = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
get maxEvents() {
|
|
||||||
return Math.round(document.body.clientHeight / this.SMALLEST_EVT_HEIGHT) * this.PAGES_COUNT;
|
|
||||||
}
|
|
||||||
|
|
||||||
get from() {
|
|
||||||
return this._from;
|
|
||||||
}
|
|
||||||
|
|
||||||
get length() {
|
|
||||||
return this._from + this.maxEvents;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFrom(from) {
|
|
||||||
this._from = from < 0 ? 0 : from;
|
|
||||||
}
|
|
||||||
|
|
||||||
paginate(backwards, limit, timelineLength) {
|
|
||||||
this._from = backwards ? this._from - limit : this._from + limit;
|
|
||||||
|
|
||||||
if (!backwards && this.length > timelineLength) {
|
|
||||||
this._from = timelineLength - this.maxEvents;
|
|
||||||
}
|
|
||||||
if (this._from < 0) this._from = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default EventLimit;
|
|
|
@ -1,215 +0,0 @@
|
||||||
import React, {
|
|
||||||
useState, useEffect, useCallback, useRef,
|
|
||||||
} from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './PeopleDrawer.scss';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import { getPowerLabel, getUsernameOfRoomMember } from '../../../util/matrixUtil';
|
|
||||||
import colorMXID from '../../../util/colorMXID';
|
|
||||||
import { openInviteUser, openProfileViewer } from '../../../client/action/navigation';
|
|
||||||
import AsyncSearch from '../../../util/AsyncSearch';
|
|
||||||
import { memberByAtoZ, memberByPowerLevel } from '../../../util/sort';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import Header, { TitleWrapper } from '../../atoms/header/Header';
|
|
||||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
|
||||||
import IconButton from '../../atoms/button/IconButton';
|
|
||||||
import Button from '../../atoms/button/Button';
|
|
||||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
|
||||||
import Input from '../../atoms/input/Input';
|
|
||||||
import SegmentedControl from '../../atoms/segmented-controls/SegmentedControls';
|
|
||||||
import PeopleSelector from '../../molecules/people-selector/PeopleSelector';
|
|
||||||
|
|
||||||
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
|
|
||||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
|
||||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
|
||||||
|
|
||||||
function simplyfiMembers(members) {
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
return members.map((member) => ({
|
|
||||||
userId: member.userId,
|
|
||||||
name: getUsernameOfRoomMember(member),
|
|
||||||
username: member.userId.slice(1, member.userId.indexOf(':')),
|
|
||||||
avatarSrc: member.getAvatarUrl(mx.baseUrl, 24, 24, 'crop'),
|
|
||||||
peopleRole: getPowerLabel(member.powerLevel),
|
|
||||||
powerLevel: members.powerLevel,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const asyncSearch = new AsyncSearch();
|
|
||||||
function PeopleDrawer({ roomId }) {
|
|
||||||
const PER_PAGE_MEMBER = 50;
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const room = mx.getRoom(roomId);
|
|
||||||
const canInvite = room?.canInvite(mx.getUserId());
|
|
||||||
|
|
||||||
const [itemCount, setItemCount] = useState(PER_PAGE_MEMBER);
|
|
||||||
const [membership, setMembership] = useState('join');
|
|
||||||
const [memberList, setMemberList] = useState([]);
|
|
||||||
const [searchedMembers, setSearchedMembers] = useState(null);
|
|
||||||
const searchRef = useRef(null);
|
|
||||||
|
|
||||||
const getMembersWithMembership = useCallback(
|
|
||||||
(mship) => room.getMembersWithMembership(mship),
|
|
||||||
[roomId, membership],
|
|
||||||
);
|
|
||||||
|
|
||||||
function loadMorePeople() {
|
|
||||||
setItemCount(itemCount + PER_PAGE_MEMBER);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSearchData(data) {
|
|
||||||
// NOTICE: data is passed as object property
|
|
||||||
// because react sucks at handling state update with array.
|
|
||||||
setSearchedMembers({ data });
|
|
||||||
setItemCount(PER_PAGE_MEMBER);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSearch(e) {
|
|
||||||
const term = e.target.value;
|
|
||||||
if (term === '' || term === undefined) {
|
|
||||||
searchRef.current.value = '';
|
|
||||||
searchRef.current.focus();
|
|
||||||
setSearchedMembers(null);
|
|
||||||
setItemCount(PER_PAGE_MEMBER);
|
|
||||||
} else asyncSearch.search(term);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
asyncSearch.setup(memberList, {
|
|
||||||
keys: ['name', 'username', 'userId'],
|
|
||||||
limit: PER_PAGE_MEMBER,
|
|
||||||
});
|
|
||||||
}, [memberList]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isLoadingMembers = false;
|
|
||||||
let isRoomChanged = false;
|
|
||||||
const updateMemberList = (event) => {
|
|
||||||
if (isLoadingMembers) return;
|
|
||||||
if (event && event?.getRoomId() !== roomId) return;
|
|
||||||
setMemberList(
|
|
||||||
simplyfiMembers(
|
|
||||||
getMembersWithMembership(membership)
|
|
||||||
.sort(memberByAtoZ).sort(memberByPowerLevel),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
searchRef.current.value = '';
|
|
||||||
updateMemberList();
|
|
||||||
isLoadingMembers = true;
|
|
||||||
room.loadMembersIfNeeded().then(() => {
|
|
||||||
isLoadingMembers = false;
|
|
||||||
if (isRoomChanged) return;
|
|
||||||
updateMemberList();
|
|
||||||
});
|
|
||||||
|
|
||||||
asyncSearch.on(asyncSearch.RESULT_SENT, handleSearchData);
|
|
||||||
mx.on('RoomMember.membership', updateMemberList);
|
|
||||||
mx.on('RoomMember.powerLevel', updateMemberList);
|
|
||||||
return () => {
|
|
||||||
isRoomChanged = true;
|
|
||||||
setMemberList([]);
|
|
||||||
setSearchedMembers(null);
|
|
||||||
setItemCount(PER_PAGE_MEMBER);
|
|
||||||
asyncSearch.removeListener(asyncSearch.RESULT_SENT, handleSearchData);
|
|
||||||
mx.removeListener('RoomMember.membership', updateMemberList);
|
|
||||||
mx.removeListener('RoomMember.powerLevel', updateMemberList);
|
|
||||||
};
|
|
||||||
}, [roomId, membership]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMembership('join');
|
|
||||||
}, [roomId]);
|
|
||||||
|
|
||||||
const mList = searchedMembers !== null ? searchedMembers.data : memberList.slice(0, itemCount);
|
|
||||||
return (
|
|
||||||
<div className="people-drawer">
|
|
||||||
<Header>
|
|
||||||
<TitleWrapper>
|
|
||||||
<Text variant="s1" primary>
|
|
||||||
People
|
|
||||||
<Text className="people-drawer__member-count" variant="b3">{`${room.getJoinedMemberCount()} members`}</Text>
|
|
||||||
</Text>
|
|
||||||
</TitleWrapper>
|
|
||||||
<IconButton onClick={() => openInviteUser(roomId)} tooltip="Invite" src={AddUserIC} disabled={!canInvite} />
|
|
||||||
</Header>
|
|
||||||
<div className="people-drawer__content-wrapper">
|
|
||||||
<div className="people-drawer__scrollable">
|
|
||||||
<ScrollView autoHide>
|
|
||||||
<div className="people-drawer__content">
|
|
||||||
<SegmentedControl
|
|
||||||
selected={
|
|
||||||
(() => {
|
|
||||||
const getSegmentIndex = {
|
|
||||||
join: 0,
|
|
||||||
invite: 1,
|
|
||||||
ban: 2,
|
|
||||||
};
|
|
||||||
return getSegmentIndex[membership];
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
segments={[{ text: 'Joined' }, { text: 'Invited' }, { text: 'Banned' }]}
|
|
||||||
onSelect={(index) => {
|
|
||||||
const selectSegment = [
|
|
||||||
() => setMembership('join'),
|
|
||||||
() => setMembership('invite'),
|
|
||||||
() => setMembership('ban'),
|
|
||||||
];
|
|
||||||
selectSegment[index]?.();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{
|
|
||||||
mList.map((member) => (
|
|
||||||
<PeopleSelector
|
|
||||||
key={member.userId}
|
|
||||||
onClick={() => openProfileViewer(member.userId, roomId)}
|
|
||||||
avatarSrc={member.avatarSrc}
|
|
||||||
name={member.name}
|
|
||||||
color={colorMXID(member.userId)}
|
|
||||||
peopleRole={member.peopleRole}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
{
|
|
||||||
(searchedMembers?.data.length === 0 || memberList.length === 0)
|
|
||||||
&& (
|
|
||||||
<div className="people-drawer__noresult">
|
|
||||||
<Text variant="b2">No results found!</Text>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
<div className="people-drawer__load-more">
|
|
||||||
{
|
|
||||||
mList.length !== 0
|
|
||||||
&& memberList.length > itemCount
|
|
||||||
&& searchedMembers === null
|
|
||||||
&& (
|
|
||||||
<Button onClick={loadMorePeople}>View more</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ScrollView>
|
|
||||||
</div>
|
|
||||||
<div className="people-drawer__sticky">
|
|
||||||
<form onSubmit={(e) => e.preventDefault()} className="people-search">
|
|
||||||
<RawIcon size="small" src={SearchIC} />
|
|
||||||
<Input forwardRef={searchRef} type="text" onChange={handleSearch} placeholder="Search" required />
|
|
||||||
{
|
|
||||||
searchedMembers !== null
|
|
||||||
&& <IconButton onClick={handleSearch} size="small" src={CrossIC} />
|
|
||||||
}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
PeopleDrawer.propTypes = {
|
|
||||||
roomId: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PeopleDrawer;
|
|
|
@ -232,10 +232,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
});
|
});
|
||||||
handleCancelUpload(uploads);
|
handleCancelUpload(uploads);
|
||||||
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
||||||
contents.forEach((content) => mx.sendMessage(roomId, content));
|
for (const content of contents) {
|
||||||
|
await mx.sendMessage(roomId, content);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = useCallback(() => {
|
const submit = useCallback(async () => {
|
||||||
uploadBoardHandlers.current?.handleSend();
|
uploadBoardHandlers.current?.handleSend();
|
||||||
|
|
||||||
const commandName = getBeginCommand(editor);
|
const commandName = getBeginCommand(editor);
|
||||||
|
@ -304,7 +306,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
mx.sendMessage(roomId, content);
|
await mx.sendMessage(roomId, content);
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
setReplyDraft();
|
setReplyDraft();
|
||||||
|
|
|
@ -666,13 +666,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
// Stay at bottom when room editor resize
|
// Stay at bottom when room editor resize
|
||||||
useResizeObserver(
|
useResizeObserver(
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
let mounted = false;
|
// let mounted = false;
|
||||||
return (entries) => {
|
return (entries) => {
|
||||||
if (!mounted) {
|
// if (!mounted) {
|
||||||
// skip initial mounting call
|
// // skip initial mounting call
|
||||||
mounted = true;
|
// mounted = true;
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
if (!roomInputRef.current) return;
|
if (!roomInputRef.current) return;
|
||||||
const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries);
|
const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries);
|
||||||
const scrollElement = getScrollElement();
|
const scrollElement = getScrollElement();
|
||||||
|
@ -1100,13 +1100,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
if (typeof mxcUrl !== 'string') {
|
if (typeof mxcUrl !== 'string') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const height = scaleYDimension(imgInfo?.w || 400, 400, imgInfo?.h || 400);
|
// FIXME
|
||||||
|
const imgW = imgInfo?.w ?? 400;
|
||||||
|
const imgH = imgInfo?.h ?? 400;
|
||||||
|
const maxW = scrollRef.current?.querySelector(`[data-message-id]>div>div:nth-child(2)`)?.clientWidth ?? 400;
|
||||||
|
const height = imgW <= maxW ? imgH : scaleYDimension(imgW, maxW, imgH);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Attachment>
|
<Attachment>
|
||||||
<AttachmentBox
|
<AttachmentBox
|
||||||
style={{
|
style={{
|
||||||
height: toRem(height < 48 ? 48 : height),
|
height: toRem(Math.max(48, Math.min(480, height))),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ImageContent
|
<ImageContent
|
||||||
|
@ -1135,13 +1139,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400);
|
// FIXME
|
||||||
|
const videoW = videoInfo?.w ?? 400;
|
||||||
|
const videoH = videoInfo?.h ?? 400;
|
||||||
|
const maxW = scrollRef.current?.querySelector(`[data-message-id]>div>div:nth-child(2)`)?.clientWidth ?? 400;
|
||||||
|
const height = videoW <= maxW ? videoH : scaleYDimension(videoW, maxW, videoH);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Attachment>
|
<Attachment>
|
||||||
<AttachmentBox
|
<AttachmentBox
|
||||||
style={{
|
style={{
|
||||||
height: toRem(height < 48 ? 48 : height),
|
height: toRem(Math.max(48, Math.min(480, height))),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<VideoContent
|
<VideoContent
|
||||||
|
|
|
@ -1,297 +0,0 @@
|
||||||
/* eslint-disable react/prop-types */
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './RoomViewCmdBar.scss';
|
|
||||||
import parse from 'html-react-parser';
|
|
||||||
import twemoji from 'twemoji';
|
|
||||||
|
|
||||||
import { twemojify, TWEMOJI_BASE_URL } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import { getEmojiForCompletion } from '../emoji-board/custom-emoji';
|
|
||||||
import AsyncSearch from '../../../util/AsyncSearch';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
|
||||||
import FollowingMembers from '../../molecules/following-members/FollowingMembers';
|
|
||||||
import { addRecentEmoji, getRecentEmojis } from '../emoji-board/recent';
|
|
||||||
import commands from './commands';
|
|
||||||
|
|
||||||
function CmdItem({ onClick, children }) {
|
|
||||||
return (
|
|
||||||
<button className="cmd-item" onClick={onClick} type="button">
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
CmdItem.propTypes = {
|
|
||||||
onClick: PropTypes.func.isRequired,
|
|
||||||
children: PropTypes.node.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
function renderSuggestions({ prefix, option, suggestions }, fireCmd) {
|
|
||||||
function renderCmdSuggestions(cmdPrefix, cmds) {
|
|
||||||
const cmdOptString = typeof option === 'string' ? `/${option}` : '/?';
|
|
||||||
return cmds.map((cmd) => (
|
|
||||||
<CmdItem
|
|
||||||
key={cmd}
|
|
||||||
onClick={() => {
|
|
||||||
fireCmd({
|
|
||||||
prefix: cmdPrefix,
|
|
||||||
option,
|
|
||||||
result: commands[cmd],
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text variant="b2">{`${cmd}${cmd.isOptions ? cmdOptString : ''}`}</Text>
|
|
||||||
</CmdItem>
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEmojiSuggestion(emPrefix, emos) {
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
|
|
||||||
// Renders a small Twemoji
|
|
||||||
function renderTwemoji(emoji) {
|
|
||||||
return parse(
|
|
||||||
twemoji.parse(emoji.unicode, {
|
|
||||||
attributes: () => ({
|
|
||||||
unicode: emoji.unicode,
|
|
||||||
shortcodes: emoji.shortcodes?.toString(),
|
|
||||||
}),
|
|
||||||
base: TWEMOJI_BASE_URL,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render a custom emoji
|
|
||||||
function renderCustomEmoji(emoji) {
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
className="emoji"
|
|
||||||
src={mx.mxcUrlToHttp(emoji.mxc)}
|
|
||||||
data-mx-emoticon=""
|
|
||||||
alt={`:${emoji.shortcode}:`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dynamically render either a custom emoji or twemoji based on what the input is
|
|
||||||
function renderEmoji(emoji) {
|
|
||||||
if (emoji.mxc) {
|
|
||||||
return renderCustomEmoji(emoji);
|
|
||||||
}
|
|
||||||
return renderTwemoji(emoji);
|
|
||||||
}
|
|
||||||
|
|
||||||
return emos.map((emoji) => (
|
|
||||||
<CmdItem
|
|
||||||
key={emoji.shortcode}
|
|
||||||
onClick={() =>
|
|
||||||
fireCmd({
|
|
||||||
prefix: emPrefix,
|
|
||||||
result: emoji,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text variant="b1">{renderEmoji(emoji)}</Text>
|
|
||||||
<Text variant="b2">{`:${emoji.shortcode}:`}</Text>
|
|
||||||
</CmdItem>
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderNameSuggestion(namePrefix, members) {
|
|
||||||
return members.map((member) => (
|
|
||||||
<CmdItem
|
|
||||||
key={member.userId}
|
|
||||||
onClick={() => {
|
|
||||||
fireCmd({
|
|
||||||
prefix: namePrefix,
|
|
||||||
result: member,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text variant="b2">{twemojify(member.name)}</Text>
|
|
||||||
</CmdItem>
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
const cmd = {
|
|
||||||
'/': (cmds) => renderCmdSuggestions(prefix, cmds),
|
|
||||||
':': (emos) => renderEmojiSuggestion(prefix, emos),
|
|
||||||
'@': (members) => renderNameSuggestion(prefix, members),
|
|
||||||
};
|
|
||||||
return cmd[prefix]?.(suggestions);
|
|
||||||
}
|
|
||||||
|
|
||||||
const asyncSearch = new AsyncSearch();
|
|
||||||
let cmdPrefix;
|
|
||||||
let cmdOption;
|
|
||||||
function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
|
|
||||||
const [cmd, setCmd] = useState(null);
|
|
||||||
|
|
||||||
function displaySuggestions(suggestions) {
|
|
||||||
if (suggestions.length === 0) {
|
|
||||||
setCmd({ prefix: cmd?.prefix || cmdPrefix, error: 'No suggestion found.' });
|
|
||||||
viewEvent.emit('cmd_error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCmd({ prefix: cmd?.prefix || cmdPrefix, suggestions, option: cmdOption });
|
|
||||||
}
|
|
||||||
|
|
||||||
function processCmd(prefix, slug) {
|
|
||||||
let searchTerm = slug;
|
|
||||||
cmdOption = undefined;
|
|
||||||
cmdPrefix = prefix;
|
|
||||||
if (prefix === '/') {
|
|
||||||
const cmdSlugParts = slug.split('/');
|
|
||||||
[searchTerm, cmdOption] = cmdSlugParts;
|
|
||||||
}
|
|
||||||
if (prefix === ':') {
|
|
||||||
if (searchTerm.length <= 3) {
|
|
||||||
if (searchTerm.match(/^[-]?(\))$/)) searchTerm = 'smile';
|
|
||||||
else if (searchTerm.match(/^[-]?(s|S)$/)) searchTerm = 'confused';
|
|
||||||
else if (searchTerm.match(/^[-]?(o|O|0)$/)) searchTerm = 'astonished';
|
|
||||||
else if (searchTerm.match(/^[-]?(\|)$/)) searchTerm = 'neutral_face';
|
|
||||||
else if (searchTerm.match(/^[-]?(d|D)$/)) searchTerm = 'grin';
|
|
||||||
else if (searchTerm.match(/^[-]?(\/)$/)) searchTerm = 'frown';
|
|
||||||
else if (searchTerm.match(/^[-]?(p|P)$/)) searchTerm = 'stuck_out_tongue';
|
|
||||||
else if (searchTerm.match(/^'[-]?(\()$/)) searchTerm = 'cry';
|
|
||||||
else if (searchTerm.match(/^[-]?(x|X)$/)) searchTerm = 'dizzy_face';
|
|
||||||
else if (searchTerm.match(/^[-]?(\()$/)) searchTerm = 'pleading_face';
|
|
||||||
else if (searchTerm.match(/^[-]?(\$)$/)) searchTerm = 'money';
|
|
||||||
else if (searchTerm.match(/^(<3)$/)) searchTerm = 'heart';
|
|
||||||
else if (searchTerm.match(/^(c|ca|cat)$/)) searchTerm = '_cat';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
asyncSearch.search(searchTerm);
|
|
||||||
}
|
|
||||||
function activateCmd(prefix) {
|
|
||||||
cmdPrefix = prefix;
|
|
||||||
cmdPrefix = undefined;
|
|
||||||
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const setupSearch = {
|
|
||||||
'/': () => {
|
|
||||||
asyncSearch.setup(Object.keys(commands), { isContain: true });
|
|
||||||
setCmd({ prefix, suggestions: Object.keys(commands) });
|
|
||||||
},
|
|
||||||
':': () => {
|
|
||||||
const parentIds = initMatrix.roomList.getAllParentSpaces(roomId);
|
|
||||||
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
|
|
||||||
const emojis = getEmojiForCompletion(mx, [mx.getRoom(roomId), ...parentRooms]);
|
|
||||||
const recentEmoji = getRecentEmojis(20);
|
|
||||||
asyncSearch.setup(emojis, { keys: ['shortcode'], isContain: true, limit: 20 });
|
|
||||||
setCmd({
|
|
||||||
prefix,
|
|
||||||
suggestions: recentEmoji.length > 0 ? recentEmoji : emojis.slice(26, 46),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
'@': () => {
|
|
||||||
const members = mx
|
|
||||||
.getRoom(roomId)
|
|
||||||
.getJoinedMembers()
|
|
||||||
.map((member) => ({
|
|
||||||
name: member.name,
|
|
||||||
userId: member.userId.slice(1),
|
|
||||||
}));
|
|
||||||
asyncSearch.setup(members, { keys: ['name', 'userId'], limit: 20 });
|
|
||||||
const endIndex = members.length > 20 ? 20 : members.length;
|
|
||||||
setCmd({ prefix, suggestions: members.slice(0, endIndex) });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
setupSearch[prefix]?.();
|
|
||||||
}
|
|
||||||
function deactivateCmd() {
|
|
||||||
setCmd(null);
|
|
||||||
cmdOption = undefined;
|
|
||||||
cmdPrefix = undefined;
|
|
||||||
}
|
|
||||||
function fireCmd(myCmd) {
|
|
||||||
if (myCmd.prefix === '/') {
|
|
||||||
viewEvent.emit('cmd_fired', {
|
|
||||||
replace: `/${myCmd.result.name}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (myCmd.prefix === ':') {
|
|
||||||
if (!myCmd.result.mxc) addRecentEmoji(myCmd.result.unicode);
|
|
||||||
viewEvent.emit('cmd_fired', {
|
|
||||||
replace: myCmd.result.mxc ? `:${myCmd.result.shortcode}: ` : myCmd.result.unicode,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (myCmd.prefix === '@') {
|
|
||||||
viewEvent.emit('cmd_fired', {
|
|
||||||
replace: `@${myCmd.result.userId}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
deactivateCmd();
|
|
||||||
}
|
|
||||||
|
|
||||||
function listenKeyboard(event) {
|
|
||||||
const { activeElement } = document;
|
|
||||||
const lastCmdItem = document.activeElement.parentNode.lastElementChild;
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
if (activeElement.className !== 'cmd-item') return;
|
|
||||||
viewEvent.emit('focus_msg_input');
|
|
||||||
}
|
|
||||||
if (event.key === 'Tab') {
|
|
||||||
if (lastCmdItem.className !== 'cmd-item') return;
|
|
||||||
if (lastCmdItem !== activeElement) return;
|
|
||||||
if (event.shiftKey) return;
|
|
||||||
viewEvent.emit('focus_msg_input');
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
viewEvent.on('cmd_activate', activateCmd);
|
|
||||||
viewEvent.on('cmd_deactivate', deactivateCmd);
|
|
||||||
return () => {
|
|
||||||
deactivateCmd();
|
|
||||||
viewEvent.removeListener('cmd_activate', activateCmd);
|
|
||||||
viewEvent.removeListener('cmd_deactivate', deactivateCmd);
|
|
||||||
};
|
|
||||||
}, [roomId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (cmd !== null) document.body.addEventListener('keydown', listenKeyboard);
|
|
||||||
viewEvent.on('cmd_process', processCmd);
|
|
||||||
asyncSearch.on(asyncSearch.RESULT_SENT, displaySuggestions);
|
|
||||||
return () => {
|
|
||||||
if (cmd !== null) document.body.removeEventListener('keydown', listenKeyboard);
|
|
||||||
|
|
||||||
viewEvent.removeListener('cmd_process', processCmd);
|
|
||||||
asyncSearch.removeListener(asyncSearch.RESULT_SENT, displaySuggestions);
|
|
||||||
};
|
|
||||||
}, [cmd]);
|
|
||||||
|
|
||||||
const isError = typeof cmd?.error === 'string';
|
|
||||||
if (cmd === null || isError) {
|
|
||||||
return (
|
|
||||||
<div className="cmd-bar">
|
|
||||||
<FollowingMembers roomTimeline={roomTimeline} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="cmd-bar">
|
|
||||||
<div className="cmd-bar__info">
|
|
||||||
<Text variant="b3">TAB</Text>
|
|
||||||
</div>
|
|
||||||
<div className="cmd-bar__content">
|
|
||||||
<ScrollView horizontal vertical={false} invisible>
|
|
||||||
<div className="cmd-bar__content-suggestions">{renderSuggestions(cmd, fireCmd)}</div>
|
|
||||||
</ScrollView>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
RoomViewCmdBar.propTypes = {
|
|
||||||
roomId: PropTypes.string.isRequired,
|
|
||||||
roomTimeline: PropTypes.shape({}).isRequired,
|
|
||||||
viewEvent: PropTypes.shape({}).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RoomViewCmdBar;
|
|
|
@ -1,57 +0,0 @@
|
||||||
@use '../../partials/flex';
|
|
||||||
@use '../../partials/text';
|
|
||||||
@use '../../partials/dir';
|
|
||||||
|
|
||||||
.cmd-bar {
|
|
||||||
--cmd-bar-height: 28px;
|
|
||||||
min-height: var(--cmd-bar-height);
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
&__info {
|
|
||||||
display: flex;
|
|
||||||
width: 40px;
|
|
||||||
@include dir.side(margin, 14px, 10px);
|
|
||||||
|
|
||||||
& > * {
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__content {
|
|
||||||
@extend .cp-fx__item-one;
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
&-suggestions {
|
|
||||||
height: 100%;
|
|
||||||
white-space: nowrap;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
& > .text {
|
|
||||||
@extend .cp-txt__ellipsis;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmd-item {
|
|
||||||
--cmd-item-bar: inset 0 -2px 0 0 var(--bg-caution);
|
|
||||||
height: 100%;
|
|
||||||
@include dir.side(margin, 0, var(--sp-extra-tight));
|
|
||||||
padding: 0 var(--sp-extra-tight);
|
|
||||||
border-radius: var(--bo-radius) var(--bo-radius) 0 0;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--bg-caution-hover);
|
|
||||||
}
|
|
||||||
&:focus {
|
|
||||||
background-color: var(--bg-caution-active);
|
|
||||||
box-shadow: var(--cmd-item-bar);
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,644 +0,0 @@
|
||||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
|
||||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
|
||||||
/* eslint-disable react/prop-types */
|
|
||||||
import React, {
|
|
||||||
useState, useEffect, useLayoutEffect, useCallback, useRef,
|
|
||||||
} from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './RoomViewContent.scss';
|
|
||||||
|
|
||||||
import dateFormat from 'dateformat';
|
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import navigation from '../../../client/state/navigation';
|
|
||||||
import { openProfileViewer } from '../../../client/action/navigation';
|
|
||||||
import { diffMinutes, isInSameDay, Throttle } from '../../../util/common';
|
|
||||||
import { markAsRead } from '../../../client/action/notifications';
|
|
||||||
|
|
||||||
import Divider from '../../atoms/divider/Divider';
|
|
||||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
|
||||||
import { Message, PlaceholderMessage } from '../../molecules/message/Message';
|
|
||||||
import RoomIntro from '../../molecules/room-intro/RoomIntro';
|
|
||||||
import TimelineChange from '../../molecules/message/TimelineChange';
|
|
||||||
|
|
||||||
import { useStore } from '../../hooks/useStore';
|
|
||||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
|
||||||
import { parseTimelineChange } from './common';
|
|
||||||
import TimelineScroll from './TimelineScroll';
|
|
||||||
import EventLimit from './EventLimit';
|
|
||||||
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
|
||||||
|
|
||||||
const PAG_LIMIT = 30;
|
|
||||||
const MAX_MSG_DIFF_MINUTES = 5;
|
|
||||||
const PLACEHOLDER_COUNT = 2;
|
|
||||||
const PLACEHOLDERS_HEIGHT = 96 * PLACEHOLDER_COUNT;
|
|
||||||
const SCROLL_TRIGGER_POS = PLACEHOLDERS_HEIGHT * 4;
|
|
||||||
|
|
||||||
function loadingMsgPlaceholders(key, count = 2) {
|
|
||||||
const pl = [];
|
|
||||||
const genPlaceholders = () => {
|
|
||||||
for (let i = 0; i < count; i += 1) {
|
|
||||||
pl.push(<PlaceholderMessage key={`placeholder-${i}${key}`} />);
|
|
||||||
}
|
|
||||||
return pl;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment key={`placeholder-container${key}`}>
|
|
||||||
{genPlaceholders()}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RoomIntroContainer({ event, timeline }) {
|
|
||||||
const [, nameForceUpdate] = useForceUpdate();
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const { roomList } = initMatrix;
|
|
||||||
const { room } = timeline;
|
|
||||||
const roomTopic = room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
|
|
||||||
const isDM = roomList.directs.has(timeline.roomId);
|
|
||||||
let avatarSrc = room.getAvatarUrl(mx.baseUrl, 80, 80, 'crop');
|
|
||||||
avatarSrc = isDM ? room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 80, 80, 'crop') : avatarSrc;
|
|
||||||
|
|
||||||
const heading = isDM ? room.name : `Welcome to ${room.name}`;
|
|
||||||
const topic = twemojify(roomTopic || '', undefined, true);
|
|
||||||
const nameJsx = twemojify(room.name);
|
|
||||||
const desc = isDM
|
|
||||||
? (
|
|
||||||
<>
|
|
||||||
This is the beginning of your direct message history with @
|
|
||||||
<b>{nameJsx}</b>
|
|
||||||
{'. '}
|
|
||||||
{topic}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<>
|
|
||||||
{'This is the beginning of the '}
|
|
||||||
<b>{nameJsx}</b>
|
|
||||||
{' room. '}
|
|
||||||
{topic}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleUpdate = () => nameForceUpdate();
|
|
||||||
|
|
||||||
roomList.on(cons.events.roomList.ROOM_PROFILE_UPDATED, handleUpdate);
|
|
||||||
return () => {
|
|
||||||
roomList.removeListener(cons.events.roomList.ROOM_PROFILE_UPDATED, handleUpdate);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RoomIntro
|
|
||||||
roomId={timeline.roomId}
|
|
||||||
avatarSrc={avatarSrc}
|
|
||||||
name={room.name}
|
|
||||||
heading={twemojify(heading)}
|
|
||||||
desc={desc}
|
|
||||||
time={event ? `Created at ${dateFormat(event.getDate(), 'dd mmmm yyyy, hh:MM TT')}` : null}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleOnClickCapture(e) {
|
|
||||||
const { target, nativeEvent } = e;
|
|
||||||
|
|
||||||
const userId = target.getAttribute('data-mx-pill');
|
|
||||||
if (userId) {
|
|
||||||
const roomId = navigation.selectedRoomId;
|
|
||||||
openProfileViewer(userId, roomId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const spoiler = nativeEvent.composedPath().find((el) => el?.hasAttribute?.('data-mx-spoiler'));
|
|
||||||
if (spoiler) {
|
|
||||||
if (!spoiler.classList.contains('data-mx-spoiler--visible')) e.preventDefault();
|
|
||||||
spoiler.classList.toggle('data-mx-spoiler--visible');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEvent(
|
|
||||||
roomTimeline,
|
|
||||||
mEvent,
|
|
||||||
prevMEvent,
|
|
||||||
isFocus,
|
|
||||||
isEdit,
|
|
||||||
setEdit,
|
|
||||||
cancelEdit,
|
|
||||||
) {
|
|
||||||
const isBodyOnly = (prevMEvent !== null
|
|
||||||
&& prevMEvent.getSender() === mEvent.getSender()
|
|
||||||
&& prevMEvent.getType() !== 'm.room.member'
|
|
||||||
&& prevMEvent.getType() !== 'm.room.create'
|
|
||||||
&& diffMinutes(mEvent.getDate(), prevMEvent.getDate()) <= MAX_MSG_DIFF_MINUTES
|
|
||||||
);
|
|
||||||
const timestamp = mEvent.getTs();
|
|
||||||
|
|
||||||
if (mEvent.getType() === 'm.room.member') {
|
|
||||||
const timelineChange = parseTimelineChange(mEvent);
|
|
||||||
if (timelineChange === null) return <div key={mEvent.getId()} />;
|
|
||||||
return (
|
|
||||||
<TimelineChange
|
|
||||||
key={mEvent.getId()}
|
|
||||||
variant={timelineChange.variant}
|
|
||||||
content={timelineChange.content}
|
|
||||||
timestamp={timestamp}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Message
|
|
||||||
key={mEvent.getId()}
|
|
||||||
mEvent={mEvent}
|
|
||||||
isBodyOnly={isBodyOnly}
|
|
||||||
roomTimeline={roomTimeline}
|
|
||||||
focus={isFocus}
|
|
||||||
fullTime={false}
|
|
||||||
isEdit={isEdit}
|
|
||||||
setEdit={setEdit}
|
|
||||||
cancelEdit={cancelEdit}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useTimeline(roomTimeline, eventId, readUptoEvtStore, eventLimitRef) {
|
|
||||||
const [timelineInfo, setTimelineInfo] = useState(null);
|
|
||||||
|
|
||||||
const setEventTimeline = async (eId) => {
|
|
||||||
if (typeof eId === 'string') {
|
|
||||||
const isLoaded = await roomTimeline.loadEventTimeline(eId);
|
|
||||||
if (isLoaded) return;
|
|
||||||
// if eventTimeline failed to load,
|
|
||||||
// we will load live timeline as fallback.
|
|
||||||
}
|
|
||||||
roomTimeline.loadLiveTimeline();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const limit = eventLimitRef.current;
|
|
||||||
const initTimeline = (eId) => {
|
|
||||||
// NOTICE: eId can be id of readUpto, reply or specific event.
|
|
||||||
// readUpTo: when user click jump to unread message button.
|
|
||||||
// reply: when user click reply from timeline.
|
|
||||||
// specific event when user open a link of event. behave same as ^^^^
|
|
||||||
const readUpToId = roomTimeline.getReadUpToEventId();
|
|
||||||
let focusEventIndex = -1;
|
|
||||||
const isSpecificEvent = eId && eId !== readUpToId;
|
|
||||||
|
|
||||||
if (isSpecificEvent) {
|
|
||||||
focusEventIndex = roomTimeline.getEventIndex(eId);
|
|
||||||
}
|
|
||||||
if (!readUptoEvtStore.getItem() && roomTimeline.hasEventInTimeline(readUpToId)) {
|
|
||||||
// either opening live timeline or jump to unread.
|
|
||||||
readUptoEvtStore.setItem(roomTimeline.findEventByIdInTimelineSet(readUpToId));
|
|
||||||
}
|
|
||||||
if (readUptoEvtStore.getItem() && !isSpecificEvent) {
|
|
||||||
focusEventIndex = roomTimeline.getUnreadEventIndex(readUptoEvtStore.getItem().getId());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (focusEventIndex > -1) {
|
|
||||||
limit.setFrom(focusEventIndex - Math.round(limit.maxEvents / 2));
|
|
||||||
} else {
|
|
||||||
limit.setFrom(roomTimeline.timeline.length - limit.maxEvents);
|
|
||||||
}
|
|
||||||
setTimelineInfo({ focusEventId: isSpecificEvent ? eId : null });
|
|
||||||
};
|
|
||||||
|
|
||||||
roomTimeline.on(cons.events.roomTimeline.READY, initTimeline);
|
|
||||||
setEventTimeline(eventId);
|
|
||||||
return () => {
|
|
||||||
roomTimeline.removeListener(cons.events.roomTimeline.READY, initTimeline);
|
|
||||||
limit.setFrom(0);
|
|
||||||
};
|
|
||||||
}, [roomTimeline, eventId]);
|
|
||||||
|
|
||||||
return timelineInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
function usePaginate(
|
|
||||||
roomTimeline,
|
|
||||||
readUptoEvtStore,
|
|
||||||
forceUpdateLimit,
|
|
||||||
timelineScrollRef,
|
|
||||||
eventLimitRef,
|
|
||||||
) {
|
|
||||||
const [info, setInfo] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handlePaginatedFromServer = (backwards, loaded) => {
|
|
||||||
const limit = eventLimitRef.current;
|
|
||||||
if (loaded === 0) return;
|
|
||||||
if (!readUptoEvtStore.getItem()) {
|
|
||||||
const readUpToId = roomTimeline.getReadUpToEventId();
|
|
||||||
readUptoEvtStore.setItem(roomTimeline.findEventByIdInTimelineSet(readUpToId));
|
|
||||||
}
|
|
||||||
limit.paginate(backwards, PAG_LIMIT, roomTimeline.timeline.length);
|
|
||||||
setTimeout(() => setInfo({
|
|
||||||
backwards,
|
|
||||||
loaded,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
roomTimeline.on(cons.events.roomTimeline.PAGINATED, handlePaginatedFromServer);
|
|
||||||
return () => {
|
|
||||||
roomTimeline.removeListener(cons.events.roomTimeline.PAGINATED, handlePaginatedFromServer);
|
|
||||||
};
|
|
||||||
}, [roomTimeline]);
|
|
||||||
|
|
||||||
const autoPaginate = useCallback(async () => {
|
|
||||||
const timelineScroll = timelineScrollRef.current;
|
|
||||||
const limit = eventLimitRef.current;
|
|
||||||
if (roomTimeline.isOngoingPagination) return;
|
|
||||||
const tLength = roomTimeline.timeline.length;
|
|
||||||
|
|
||||||
if (timelineScroll.bottom < SCROLL_TRIGGER_POS) {
|
|
||||||
if (limit.length < tLength) {
|
|
||||||
// paginate from memory
|
|
||||||
limit.paginate(false, PAG_LIMIT, tLength);
|
|
||||||
forceUpdateLimit();
|
|
||||||
} else if (roomTimeline.canPaginateForward()) {
|
|
||||||
// paginate from server.
|
|
||||||
await roomTimeline.paginateTimeline(false, PAG_LIMIT);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (timelineScroll.top < SCROLL_TRIGGER_POS) {
|
|
||||||
if (limit.from > 0) {
|
|
||||||
// paginate from memory
|
|
||||||
limit.paginate(true, PAG_LIMIT, tLength);
|
|
||||||
forceUpdateLimit();
|
|
||||||
} else if (roomTimeline.canPaginateBackward()) {
|
|
||||||
// paginate from server.
|
|
||||||
await roomTimeline.paginateTimeline(true, PAG_LIMIT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [roomTimeline]);
|
|
||||||
|
|
||||||
return [info, autoPaginate];
|
|
||||||
}
|
|
||||||
|
|
||||||
function useHandleScroll(
|
|
||||||
roomTimeline,
|
|
||||||
autoPaginate,
|
|
||||||
readUptoEvtStore,
|
|
||||||
forceUpdateLimit,
|
|
||||||
timelineScrollRef,
|
|
||||||
eventLimitRef,
|
|
||||||
) {
|
|
||||||
const handleScroll = useCallback(() => {
|
|
||||||
const timelineScroll = timelineScrollRef.current;
|
|
||||||
const limit = eventLimitRef.current;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
// emit event to toggle scrollToBottom button visibility
|
|
||||||
const isAtBottom = (
|
|
||||||
timelineScroll.bottom < 16 && !roomTimeline.canPaginateForward()
|
|
||||||
&& limit.length >= roomTimeline.timeline.length
|
|
||||||
);
|
|
||||||
roomTimeline.emit(cons.events.roomTimeline.AT_BOTTOM, isAtBottom);
|
|
||||||
if (isAtBottom && readUptoEvtStore.getItem()) {
|
|
||||||
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
autoPaginate();
|
|
||||||
}, [roomTimeline]);
|
|
||||||
|
|
||||||
const handleScrollToLive = useCallback(() => {
|
|
||||||
const timelineScroll = timelineScrollRef.current;
|
|
||||||
const limit = eventLimitRef.current;
|
|
||||||
if (readUptoEvtStore.getItem()) {
|
|
||||||
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
|
|
||||||
}
|
|
||||||
if (roomTimeline.isServingLiveTimeline()) {
|
|
||||||
limit.setFrom(roomTimeline.timeline.length - limit.maxEvents);
|
|
||||||
timelineScroll.scrollToBottom();
|
|
||||||
forceUpdateLimit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
roomTimeline.loadLiveTimeline();
|
|
||||||
}, [roomTimeline]);
|
|
||||||
|
|
||||||
return [handleScroll, handleScrollToLive];
|
|
||||||
}
|
|
||||||
|
|
||||||
function useEventArrive(roomTimeline, readUptoEvtStore, timelineScrollRef, eventLimitRef) {
|
|
||||||
const myUserId = initMatrix.matrixClient.getUserId();
|
|
||||||
const [newEvent, setEvent] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timelineScroll = timelineScrollRef.current;
|
|
||||||
const limit = eventLimitRef.current;
|
|
||||||
const trySendReadReceipt = (event) => {
|
|
||||||
if (myUserId === event.getSender()) {
|
|
||||||
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const readUpToEvent = readUptoEvtStore.getItem();
|
|
||||||
const readUpToId = roomTimeline.getReadUpToEventId();
|
|
||||||
const isUnread = readUpToEvent ? readUpToEvent?.getId() === readUpToId : true;
|
|
||||||
|
|
||||||
if (isUnread === false) {
|
|
||||||
if (document.visibilityState === 'visible' && timelineScroll.bottom < 16) {
|
|
||||||
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
|
|
||||||
} else {
|
|
||||||
readUptoEvtStore.setItem(roomTimeline.findEventByIdInTimelineSet(readUpToId));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { timeline } = roomTimeline;
|
|
||||||
const unreadMsgIsLast = timeline[timeline.length - 2].getId() === readUpToId;
|
|
||||||
if (unreadMsgIsLast) {
|
|
||||||
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEvent = (event) => {
|
|
||||||
const tLength = roomTimeline.timeline.length;
|
|
||||||
const isViewingLive = roomTimeline.isServingLiveTimeline() && limit.length >= tLength - 1;
|
|
||||||
const isAttached = timelineScroll.bottom < SCROLL_TRIGGER_POS;
|
|
||||||
|
|
||||||
if (isViewingLive && isAttached && document.hasFocus()) {
|
|
||||||
limit.setFrom(tLength - limit.maxEvents);
|
|
||||||
trySendReadReceipt(event);
|
|
||||||
setEvent(event);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const isRelates = (event.getType() === 'm.reaction' || event.getRelation()?.rel_type === 'm.replace');
|
|
||||||
if (isRelates) {
|
|
||||||
setEvent(event);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isViewingLive) {
|
|
||||||
// This stateUpdate will help to put the
|
|
||||||
// loading msg placeholder at bottom
|
|
||||||
setEvent(event);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEventRedact = (event) => setEvent(event);
|
|
||||||
|
|
||||||
roomTimeline.on(cons.events.roomTimeline.EVENT, handleEvent);
|
|
||||||
roomTimeline.on(cons.events.roomTimeline.EVENT_REDACTED, handleEventRedact);
|
|
||||||
return () => {
|
|
||||||
roomTimeline.removeListener(cons.events.roomTimeline.EVENT, handleEvent);
|
|
||||||
roomTimeline.removeListener(cons.events.roomTimeline.EVENT_REDACTED, handleEventRedact);
|
|
||||||
};
|
|
||||||
}, [roomTimeline]);
|
|
||||||
|
|
||||||
return newEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
let jumpToItemIndex = -1;
|
|
||||||
|
|
||||||
function RoomViewContent({ roomInputRef, eventId, roomTimeline }) {
|
|
||||||
const [throttle] = useState(new Throttle());
|
|
||||||
|
|
||||||
const timelineSVRef = useRef(null);
|
|
||||||
const timelineScrollRef = useRef(null);
|
|
||||||
const eventLimitRef = useRef(null);
|
|
||||||
const [editEventId, setEditEventId] = useState(null);
|
|
||||||
const cancelEdit = () => setEditEventId(null);
|
|
||||||
|
|
||||||
const readUptoEvtStore = useStore(roomTimeline);
|
|
||||||
const [onLimitUpdate, forceUpdateLimit] = useForceUpdate();
|
|
||||||
|
|
||||||
const timelineInfo = useTimeline(roomTimeline, eventId, readUptoEvtStore, eventLimitRef);
|
|
||||||
const [paginateInfo, autoPaginate] = usePaginate(
|
|
||||||
roomTimeline,
|
|
||||||
readUptoEvtStore,
|
|
||||||
forceUpdateLimit,
|
|
||||||
timelineScrollRef,
|
|
||||||
eventLimitRef,
|
|
||||||
);
|
|
||||||
const [handleScroll, handleScrollToLive] = useHandleScroll(
|
|
||||||
roomTimeline,
|
|
||||||
autoPaginate,
|
|
||||||
readUptoEvtStore,
|
|
||||||
forceUpdateLimit,
|
|
||||||
timelineScrollRef,
|
|
||||||
eventLimitRef,
|
|
||||||
);
|
|
||||||
const newEvent = useEventArrive(roomTimeline, readUptoEvtStore, timelineScrollRef, eventLimitRef);
|
|
||||||
|
|
||||||
const { timeline } = roomTimeline;
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!roomTimeline.initialized) {
|
|
||||||
timelineScrollRef.current = new TimelineScroll(timelineSVRef.current);
|
|
||||||
eventLimitRef.current = new EventLimit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// when active timeline changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!roomTimeline.initialized) return undefined;
|
|
||||||
const timelineScroll = timelineScrollRef.current;
|
|
||||||
|
|
||||||
if (timeline.length > 0) {
|
|
||||||
if (jumpToItemIndex === -1) {
|
|
||||||
timelineScroll.scrollToBottom();
|
|
||||||
} else {
|
|
||||||
timelineScroll.scrollToIndex(jumpToItemIndex, 80);
|
|
||||||
}
|
|
||||||
if (timelineScroll.bottom < 16 && !roomTimeline.canPaginateForward()) {
|
|
||||||
const readUpToId = roomTimeline.getReadUpToEventId();
|
|
||||||
if (readUptoEvtStore.getItem()?.getId() === readUpToId || readUpToId === null) {
|
|
||||||
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
jumpToItemIndex = -1;
|
|
||||||
}
|
|
||||||
autoPaginate();
|
|
||||||
|
|
||||||
roomTimeline.on(cons.events.roomTimeline.SCROLL_TO_LIVE, handleScrollToLive);
|
|
||||||
return () => {
|
|
||||||
if (timelineSVRef.current === null) return;
|
|
||||||
roomTimeline.removeListener(cons.events.roomTimeline.SCROLL_TO_LIVE, handleScrollToLive);
|
|
||||||
};
|
|
||||||
}, [timelineInfo]);
|
|
||||||
|
|
||||||
// when paginating from server
|
|
||||||
useEffect(() => {
|
|
||||||
if (!roomTimeline.initialized) return;
|
|
||||||
const timelineScroll = timelineScrollRef.current;
|
|
||||||
timelineScroll.tryRestoringScroll();
|
|
||||||
autoPaginate();
|
|
||||||
}, [paginateInfo]);
|
|
||||||
|
|
||||||
// when paginating locally
|
|
||||||
useEffect(() => {
|
|
||||||
if (!roomTimeline.initialized) return;
|
|
||||||
const timelineScroll = timelineScrollRef.current;
|
|
||||||
timelineScroll.tryRestoringScroll();
|
|
||||||
}, [onLimitUpdate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timelineScroll = timelineScrollRef.current;
|
|
||||||
if (!roomTimeline.initialized) return;
|
|
||||||
if (timelineScroll.bottom < 16 && !roomTimeline.canPaginateForward() && document.visibilityState === 'visible') {
|
|
||||||
timelineScroll.scrollToBottom();
|
|
||||||
} else {
|
|
||||||
timelineScroll.tryRestoringScroll();
|
|
||||||
}
|
|
||||||
}, [newEvent]);
|
|
||||||
|
|
||||||
useResizeObserver(
|
|
||||||
useCallback((entries) => {
|
|
||||||
if (!roomInputRef.current) return;
|
|
||||||
const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries);
|
|
||||||
if (!editorBaseEntry) return;
|
|
||||||
|
|
||||||
const timelineScroll = timelineScrollRef.current;
|
|
||||||
if (!roomTimeline.initialized) return;
|
|
||||||
if (timelineScroll.bottom < 40 && !roomTimeline.canPaginateForward() && document.visibilityState === 'visible') {
|
|
||||||
timelineScroll.scrollToBottom();
|
|
||||||
}
|
|
||||||
}, [roomInputRef]),
|
|
||||||
useCallback(() => roomInputRef.current, [roomInputRef]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const listenKeyboard = useCallback((event) => {
|
|
||||||
if (event.ctrlKey || event.altKey || event.metaKey) return;
|
|
||||||
if (event.key !== 'ArrowUp') return;
|
|
||||||
if (navigation.isRawModalVisible) return;
|
|
||||||
|
|
||||||
if (document.activeElement.id !== 'message-textarea') return;
|
|
||||||
if (document.activeElement.value !== '') return;
|
|
||||||
|
|
||||||
const {
|
|
||||||
timeline: tl, activeTimeline, liveTimeline, matrixClient: mx,
|
|
||||||
} = roomTimeline;
|
|
||||||
const limit = eventLimitRef.current;
|
|
||||||
if (activeTimeline !== liveTimeline) return;
|
|
||||||
if (tl.length > limit.length) return;
|
|
||||||
|
|
||||||
const mTypes = ['m.text'];
|
|
||||||
for (let i = tl.length - 1; i >= 0; i -= 1) {
|
|
||||||
const mE = tl[i];
|
|
||||||
if (
|
|
||||||
mE.getSender() === mx.getUserId()
|
|
||||||
&& mE.getType() === 'm.room.message'
|
|
||||||
&& mTypes.includes(mE.getContent()?.msgtype)
|
|
||||||
) {
|
|
||||||
setEditEventId(mE.getId());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [roomTimeline]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.body.addEventListener('keydown', listenKeyboard);
|
|
||||||
return () => {
|
|
||||||
document.body.removeEventListener('keydown', listenKeyboard);
|
|
||||||
};
|
|
||||||
}, [listenKeyboard]);
|
|
||||||
|
|
||||||
const handleTimelineScroll = (event) => {
|
|
||||||
const timelineScroll = timelineScrollRef.current;
|
|
||||||
if (!event.target) return;
|
|
||||||
|
|
||||||
throttle._(() => {
|
|
||||||
const backwards = timelineScroll?.calcScroll();
|
|
||||||
if (typeof backwards !== 'boolean') return;
|
|
||||||
handleScroll(backwards);
|
|
||||||
}, 200)();
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderTimeline = () => {
|
|
||||||
const tl = [];
|
|
||||||
const limit = eventLimitRef.current;
|
|
||||||
|
|
||||||
let itemCountIndex = 0;
|
|
||||||
jumpToItemIndex = -1;
|
|
||||||
const readUptoEvent = readUptoEvtStore.getItem();
|
|
||||||
let unreadDivider = false;
|
|
||||||
|
|
||||||
if (roomTimeline.canPaginateBackward() || limit.from > 0) {
|
|
||||||
tl.push(loadingMsgPlaceholders(1, PLACEHOLDER_COUNT));
|
|
||||||
itemCountIndex += PLACEHOLDER_COUNT;
|
|
||||||
}
|
|
||||||
for (let i = limit.from; i < limit.length; i += 1) {
|
|
||||||
if (i >= timeline.length) break;
|
|
||||||
const mEvent = timeline[i];
|
|
||||||
const prevMEvent = timeline[i - 1] ?? null;
|
|
||||||
|
|
||||||
if (i === 0 && !roomTimeline.canPaginateBackward()) {
|
|
||||||
if (mEvent.getType() === 'm.room.create') {
|
|
||||||
tl.push(
|
|
||||||
<RoomIntroContainer key={mEvent.getId()} event={mEvent} timeline={roomTimeline} />,
|
|
||||||
);
|
|
||||||
itemCountIndex += 1;
|
|
||||||
// eslint-disable-next-line no-continue
|
|
||||||
continue;
|
|
||||||
} else {
|
|
||||||
tl.push(<RoomIntroContainer key="room-intro" event={null} timeline={roomTimeline} />);
|
|
||||||
itemCountIndex += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let isNewEvent = false;
|
|
||||||
if (!unreadDivider) {
|
|
||||||
unreadDivider = (readUptoEvent
|
|
||||||
&& prevMEvent?.getTs() <= readUptoEvent.getTs()
|
|
||||||
&& readUptoEvent.getTs() < mEvent.getTs());
|
|
||||||
if (unreadDivider) {
|
|
||||||
isNewEvent = true;
|
|
||||||
tl.push(<Divider key={`new-${mEvent.getId()}`} variant="positive" text="New messages" />);
|
|
||||||
itemCountIndex += 1;
|
|
||||||
if (jumpToItemIndex === -1) jumpToItemIndex = itemCountIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const dayDivider = prevMEvent && !isInSameDay(mEvent.getDate(), prevMEvent.getDate());
|
|
||||||
if (dayDivider) {
|
|
||||||
tl.push(<Divider key={`divider-${mEvent.getId()}`} text={`${dateFormat(mEvent.getDate(), 'mmmm dd, yyyy')}`} />);
|
|
||||||
itemCountIndex += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const focusId = timelineInfo.focusEventId;
|
|
||||||
const isFocus = focusId === mEvent.getId();
|
|
||||||
if (isFocus) jumpToItemIndex = itemCountIndex;
|
|
||||||
|
|
||||||
tl.push(renderEvent(
|
|
||||||
roomTimeline,
|
|
||||||
mEvent,
|
|
||||||
isNewEvent ? null : prevMEvent,
|
|
||||||
isFocus,
|
|
||||||
editEventId === mEvent.getId(),
|
|
||||||
setEditEventId,
|
|
||||||
cancelEdit,
|
|
||||||
));
|
|
||||||
itemCountIndex += 1;
|
|
||||||
}
|
|
||||||
if (roomTimeline.canPaginateForward() || limit.length < timeline.length) {
|
|
||||||
tl.push(loadingMsgPlaceholders(2, PLACEHOLDER_COUNT));
|
|
||||||
}
|
|
||||||
|
|
||||||
return tl;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView onScroll={handleTimelineScroll} ref={timelineSVRef} autoHide>
|
|
||||||
<div className="room-view__content" onClick={handleOnClickCapture}>
|
|
||||||
<div className="timeline__wrapper">
|
|
||||||
{ roomTimeline.initialized ? renderTimeline() : loadingMsgPlaceholders('loading', 3) }
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
RoomViewContent.defaultProps = {
|
|
||||||
eventId: null,
|
|
||||||
};
|
|
||||||
RoomViewContent.propTypes = {
|
|
||||||
eventId: PropTypes.string,
|
|
||||||
roomTimeline: PropTypes.shape({}).isRequired,
|
|
||||||
roomInputRef: PropTypes.shape({
|
|
||||||
current: PropTypes.shape({})
|
|
||||||
}).isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RoomViewContent;
|
|
|
@ -1,125 +0,0 @@
|
||||||
/* eslint-disable react/prop-types */
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './RoomViewFloating.scss';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import { markAsRead } from '../../../client/action/notifications';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import Button from '../../atoms/button/Button';
|
|
||||||
|
|
||||||
import MessageIC from '../../../../public/res/ic/outlined/message.svg';
|
|
||||||
import MessageUnreadIC from '../../../../public/res/ic/outlined/message-unread.svg';
|
|
||||||
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
|
|
||||||
|
|
||||||
import { getUsersActionJsx } from './common';
|
|
||||||
|
|
||||||
function useJumpToEvent(roomTimeline) {
|
|
||||||
const [eventId, setEventId] = useState(null);
|
|
||||||
|
|
||||||
const jumpToEvent = () => {
|
|
||||||
roomTimeline.loadEventTimeline(eventId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelJumpToEvent = () => {
|
|
||||||
markAsRead(roomTimeline.roomId);
|
|
||||||
setEventId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const readEventId = roomTimeline.getReadUpToEventId();
|
|
||||||
// we only show "Jump to unread" btn only if the event is not in timeline.
|
|
||||||
// if event is in timeline
|
|
||||||
// we will automatically open the timeline from that event position
|
|
||||||
if (!readEventId?.startsWith('~') && !roomTimeline.hasEventInTimeline(readEventId)) {
|
|
||||||
setEventId(readEventId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { notifications } = initMatrix;
|
|
||||||
const handleMarkAsRead = () => setEventId(null);
|
|
||||||
notifications.on(cons.events.notifications.FULL_READ, handleMarkAsRead);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
notifications.removeListener(cons.events.notifications.FULL_READ, handleMarkAsRead);
|
|
||||||
setEventId(null);
|
|
||||||
};
|
|
||||||
}, [roomTimeline]);
|
|
||||||
|
|
||||||
return [!!eventId, jumpToEvent, cancelJumpToEvent];
|
|
||||||
}
|
|
||||||
|
|
||||||
function useTypingMembers(roomTimeline) {
|
|
||||||
const [typingMembers, setTypingMembers] = useState(new Set());
|
|
||||||
|
|
||||||
const updateTyping = (members) => {
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
members.delete(mx.getUserId());
|
|
||||||
setTypingMembers(members);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTypingMembers(new Set());
|
|
||||||
roomTimeline.on(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
|
|
||||||
return () => {
|
|
||||||
roomTimeline?.removeListener(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
|
|
||||||
};
|
|
||||||
}, [roomTimeline]);
|
|
||||||
|
|
||||||
return [typingMembers];
|
|
||||||
}
|
|
||||||
|
|
||||||
function useScrollToBottom(roomTimeline) {
|
|
||||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
||||||
const handleAtBottom = (atBottom) => setIsAtBottom(atBottom);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsAtBottom(true);
|
|
||||||
roomTimeline.on(cons.events.roomTimeline.AT_BOTTOM, handleAtBottom);
|
|
||||||
return () => roomTimeline.removeListener(cons.events.roomTimeline.AT_BOTTOM, handleAtBottom);
|
|
||||||
}, [roomTimeline]);
|
|
||||||
|
|
||||||
return [isAtBottom, setIsAtBottom];
|
|
||||||
}
|
|
||||||
|
|
||||||
function RoomViewFloating({
|
|
||||||
roomId, roomTimeline,
|
|
||||||
}) {
|
|
||||||
const [isJumpToEvent, jumpToEvent, cancelJumpToEvent] = useJumpToEvent(roomTimeline);
|
|
||||||
const [typingMembers] = useTypingMembers(roomTimeline);
|
|
||||||
const [isAtBottom, setIsAtBottom] = useScrollToBottom(roomTimeline);
|
|
||||||
|
|
||||||
const handleScrollToBottom = () => {
|
|
||||||
roomTimeline.emit(cons.events.roomTimeline.SCROLL_TO_LIVE);
|
|
||||||
setIsAtBottom(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={`room-view__unread ${isJumpToEvent ? 'room-view__unread--open' : ''}`}>
|
|
||||||
<Button iconSrc={MessageUnreadIC} onClick={jumpToEvent} variant="primary">
|
|
||||||
<Text variant="b3" weight="medium">Jump to unread messages</Text>
|
|
||||||
</Button>
|
|
||||||
<Button iconSrc={TickMarkIC} onClick={cancelJumpToEvent} variant="primary">
|
|
||||||
<Text variant="b3" weight="bold">Mark as read</Text>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className={`room-view__typing${typingMembers.size > 0 ? ' room-view__typing--open' : ''}`}>
|
|
||||||
<div className="bouncing-loader"><div /></div>
|
|
||||||
<Text variant="b2">{getUsersActionJsx(roomId, [...typingMembers], 'typing...')}</Text>
|
|
||||||
</div>
|
|
||||||
<div className={`room-view__STB${isAtBottom ? '' : ' room-view__STB--open'}`}>
|
|
||||||
<Button iconSrc={MessageIC} onClick={handleScrollToBottom}>
|
|
||||||
<Text variant="b3" weight="medium">Jump to latest</Text>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
RoomViewFloating.propTypes = {
|
|
||||||
roomId: PropTypes.string.isRequired,
|
|
||||||
roomTimeline: PropTypes.shape({}).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RoomViewFloating;
|
|
|
@ -1,491 +0,0 @@
|
||||||
/* eslint-disable react/prop-types */
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './RoomViewInput.scss';
|
|
||||||
|
|
||||||
import TextareaAutosize from 'react-autosize-textarea';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import settings from '../../../client/state/settings';
|
|
||||||
import { openEmojiBoard, openReusableContextMenu } from '../../../client/action/navigation';
|
|
||||||
import navigation from '../../../client/state/navigation';
|
|
||||||
import { bytesToSize, getEventCords } from '../../../util/common';
|
|
||||||
import { getUsername } from '../../../util/matrixUtil';
|
|
||||||
import colorMXID from '../../../util/colorMXID';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
|
||||||
import IconButton from '../../atoms/button/IconButton';
|
|
||||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
|
||||||
import { MessageReply } from '../../molecules/message/Message';
|
|
||||||
|
|
||||||
import StickerBoard from '../sticker-board/StickerBoard';
|
|
||||||
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
|
||||||
|
|
||||||
import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg';
|
|
||||||
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
|
|
||||||
import SendIC from '../../../../public/res/ic/outlined/send.svg';
|
|
||||||
import StickerIC from '../../../../public/res/ic/outlined/sticker.svg';
|
|
||||||
import ShieldIC from '../../../../public/res/ic/outlined/shield.svg';
|
|
||||||
import VLCIC from '../../../../public/res/ic/outlined/vlc.svg';
|
|
||||||
import VolumeFullIC from '../../../../public/res/ic/outlined/volume-full.svg';
|
|
||||||
import FileIC from '../../../../public/res/ic/outlined/file.svg';
|
|
||||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
|
||||||
|
|
||||||
import commands from './commands';
|
|
||||||
|
|
||||||
const CMD_REGEX = /(^\/|:|@)(\S*)$/;
|
|
||||||
let isTyping = false;
|
|
||||||
let isCmdActivated = false;
|
|
||||||
let cmdCursorPos = null;
|
|
||||||
function RoomViewInput({
|
|
||||||
roomId, roomTimeline, viewEvent,
|
|
||||||
}) {
|
|
||||||
const [attachment, setAttachment] = useState(null);
|
|
||||||
const [replyTo, setReplyTo] = useState(null);
|
|
||||||
|
|
||||||
const textAreaRef = useRef(null);
|
|
||||||
const inputBaseRef = useRef(null);
|
|
||||||
const uploadInputRef = useRef(null);
|
|
||||||
const uploadProgressRef = useRef(null);
|
|
||||||
const rightOptionsRef = useRef(null);
|
|
||||||
|
|
||||||
const TYPING_TIMEOUT = 5000;
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const { roomsInput } = initMatrix;
|
|
||||||
|
|
||||||
function requestFocusInput() {
|
|
||||||
if (textAreaRef === null) return;
|
|
||||||
textAreaRef.current.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
roomsInput.on(cons.events.roomsInput.ATTACHMENT_SET, setAttachment);
|
|
||||||
viewEvent.on('focus_msg_input', requestFocusInput);
|
|
||||||
return () => {
|
|
||||||
roomsInput.removeListener(cons.events.roomsInput.ATTACHMENT_SET, setAttachment);
|
|
||||||
viewEvent.removeListener('focus_msg_input', requestFocusInput);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const sendIsTyping = (isT) => {
|
|
||||||
mx.sendTyping(roomId, isT, isT ? TYPING_TIMEOUT : undefined);
|
|
||||||
isTyping = isT;
|
|
||||||
|
|
||||||
if (isT === true) {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (isTyping) sendIsTyping(false);
|
|
||||||
}, TYPING_TIMEOUT);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function uploadingProgress(myRoomId, { loaded, total }) {
|
|
||||||
if (myRoomId !== roomId) return;
|
|
||||||
const progressPer = Math.round((loaded * 100) / total);
|
|
||||||
uploadProgressRef.current.textContent = `Uploading: ${bytesToSize(loaded)}/${bytesToSize(total)} (${progressPer}%)`;
|
|
||||||
inputBaseRef.current.style.backgroundImage = `linear-gradient(90deg, var(--bg-surface-hover) ${progressPer}%, var(--bg-surface-low) ${progressPer}%)`;
|
|
||||||
}
|
|
||||||
function clearAttachment(myRoomId) {
|
|
||||||
if (roomId !== myRoomId) return;
|
|
||||||
setAttachment(null);
|
|
||||||
inputBaseRef.current.style.backgroundImage = 'unset';
|
|
||||||
uploadInputRef.current.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rightOptionsA11Y(A11Y) {
|
|
||||||
const rightOptions = rightOptionsRef.current.children;
|
|
||||||
for (let index = 0; index < rightOptions.length; index += 1) {
|
|
||||||
rightOptions[index].tabIndex = A11Y ? 0 : -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function activateCmd(prefix) {
|
|
||||||
isCmdActivated = true;
|
|
||||||
rightOptionsA11Y(false);
|
|
||||||
viewEvent.emit('cmd_activate', prefix);
|
|
||||||
}
|
|
||||||
function deactivateCmd() {
|
|
||||||
isCmdActivated = false;
|
|
||||||
cmdCursorPos = null;
|
|
||||||
rightOptionsA11Y(true);
|
|
||||||
}
|
|
||||||
function deactivateCmdAndEmit() {
|
|
||||||
deactivateCmd();
|
|
||||||
viewEvent.emit('cmd_deactivate');
|
|
||||||
}
|
|
||||||
function setCursorPosition(pos) {
|
|
||||||
setTimeout(() => {
|
|
||||||
textAreaRef.current.focus();
|
|
||||||
textAreaRef.current.setSelectionRange(pos, pos);
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
function replaceCmdWith(msg, cursor, replacement) {
|
|
||||||
if (msg === null) return null;
|
|
||||||
const targetInput = msg.slice(0, cursor);
|
|
||||||
const cmdParts = targetInput.match(CMD_REGEX);
|
|
||||||
const leadingInput = msg.slice(0, cmdParts.index);
|
|
||||||
if (replacement.length > 0) setCursorPosition(leadingInput.length + replacement.length);
|
|
||||||
return leadingInput + replacement + msg.slice(cursor);
|
|
||||||
}
|
|
||||||
function firedCmd(cmdData) {
|
|
||||||
const msg = textAreaRef.current.value;
|
|
||||||
textAreaRef.current.value = replaceCmdWith(
|
|
||||||
msg,
|
|
||||||
cmdCursorPos,
|
|
||||||
typeof cmdData?.replace !== 'undefined' ? cmdData.replace : '',
|
|
||||||
);
|
|
||||||
deactivateCmd();
|
|
||||||
}
|
|
||||||
|
|
||||||
function focusInput() {
|
|
||||||
if (settings.isTouchScreenDevice) return;
|
|
||||||
textAreaRef.current.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setUpReply(userId, eventId, body, formattedBody) {
|
|
||||||
setReplyTo({ userId, eventId, body });
|
|
||||||
roomsInput.setReplyTo(roomId, {
|
|
||||||
userId, eventId, body, formattedBody,
|
|
||||||
});
|
|
||||||
focusInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
roomsInput.on(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress);
|
|
||||||
roomsInput.on(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment);
|
|
||||||
roomsInput.on(cons.events.roomsInput.FILE_UPLOADED, clearAttachment);
|
|
||||||
viewEvent.on('cmd_fired', firedCmd);
|
|
||||||
navigation.on(cons.events.navigation.REPLY_TO_CLICKED, setUpReply);
|
|
||||||
if (textAreaRef?.current !== null) {
|
|
||||||
isTyping = false;
|
|
||||||
textAreaRef.current.value = roomsInput.getMessage(roomId);
|
|
||||||
setAttachment(roomsInput.getAttachment(roomId));
|
|
||||||
setReplyTo(roomsInput.getReplyTo(roomId));
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
roomsInput.removeListener(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress);
|
|
||||||
roomsInput.removeListener(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment);
|
|
||||||
roomsInput.removeListener(cons.events.roomsInput.FILE_UPLOADED, clearAttachment);
|
|
||||||
viewEvent.removeListener('cmd_fired', firedCmd);
|
|
||||||
navigation.removeListener(cons.events.navigation.REPLY_TO_CLICKED, setUpReply);
|
|
||||||
if (isCmdActivated) deactivateCmd();
|
|
||||||
if (textAreaRef?.current === null) return;
|
|
||||||
|
|
||||||
const msg = textAreaRef.current.value;
|
|
||||||
textAreaRef.current.style.height = 'unset';
|
|
||||||
inputBaseRef.current.style.backgroundImage = 'unset';
|
|
||||||
if (msg.trim() === '') {
|
|
||||||
roomsInput.setMessage(roomId, '');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
roomsInput.setMessage(roomId, msg);
|
|
||||||
};
|
|
||||||
}, [roomId]);
|
|
||||||
|
|
||||||
const sendBody = async (body, options) => {
|
|
||||||
const opt = options ?? {};
|
|
||||||
if (!opt.msgType) opt.msgType = 'm.text';
|
|
||||||
if (typeof opt.autoMarkdown !== 'boolean') opt.autoMarkdown = true;
|
|
||||||
if (roomsInput.isSending(roomId)) return;
|
|
||||||
sendIsTyping(false);
|
|
||||||
|
|
||||||
roomsInput.setMessage(roomId, body);
|
|
||||||
if (attachment !== null) {
|
|
||||||
roomsInput.setAttachment(roomId, attachment);
|
|
||||||
}
|
|
||||||
textAreaRef.current.disabled = true;
|
|
||||||
textAreaRef.current.style.cursor = 'not-allowed';
|
|
||||||
await roomsInput.sendInput(roomId, opt);
|
|
||||||
textAreaRef.current.disabled = false;
|
|
||||||
textAreaRef.current.style.cursor = 'unset';
|
|
||||||
focusInput();
|
|
||||||
|
|
||||||
textAreaRef.current.value = roomsInput.getMessage(roomId);
|
|
||||||
textAreaRef.current.style.height = 'unset';
|
|
||||||
if (replyTo !== null) setReplyTo(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Return true if a command was executed. */
|
|
||||||
const processCommand = async (cmdBody) => {
|
|
||||||
const spaceIndex = cmdBody.indexOf(' ');
|
|
||||||
const cmdName = cmdBody.slice(1, spaceIndex > -1 ? spaceIndex : undefined);
|
|
||||||
const cmdData = spaceIndex > -1 ? cmdBody.slice(spaceIndex + 1) : '';
|
|
||||||
if (!commands[cmdName]) {
|
|
||||||
const sendAsMessage = await confirmDialog('Invalid Command', `"${cmdName}" is not a valid command. Did you mean to send this as a message?`, 'Send as message');
|
|
||||||
if (sendAsMessage) {
|
|
||||||
sendBody(cmdBody);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (['me', 'shrug', 'plain'].includes(cmdName)) {
|
|
||||||
commands[cmdName].exe(roomId, cmdData, sendBody);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
commands[cmdName].exe(roomId, cmdData);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendMessage = async () => {
|
|
||||||
requestAnimationFrame(() => deactivateCmdAndEmit());
|
|
||||||
const msgBody = textAreaRef.current.value.trim();
|
|
||||||
if (msgBody.startsWith('/')) {
|
|
||||||
const executed = await processCommand(msgBody.trim());
|
|
||||||
if (executed) {
|
|
||||||
textAreaRef.current.value = '';
|
|
||||||
textAreaRef.current.style.height = 'unset';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (msgBody === '' && attachment === null) return;
|
|
||||||
sendBody(msgBody);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendSticker = async (data) => {
|
|
||||||
roomsInput.sendSticker(roomId, data);
|
|
||||||
};
|
|
||||||
|
|
||||||
function processTyping(msg) {
|
|
||||||
const isEmptyMsg = msg === '';
|
|
||||||
|
|
||||||
if (isEmptyMsg && isTyping) {
|
|
||||||
sendIsTyping(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isEmptyMsg && !isTyping) {
|
|
||||||
sendIsTyping(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCursorPosition() {
|
|
||||||
return textAreaRef.current.selectionStart;
|
|
||||||
}
|
|
||||||
|
|
||||||
function recognizeCmd(rawInput) {
|
|
||||||
const cursor = getCursorPosition();
|
|
||||||
const targetInput = rawInput.slice(0, cursor);
|
|
||||||
|
|
||||||
const cmdParts = targetInput.match(CMD_REGEX);
|
|
||||||
if (cmdParts === null) {
|
|
||||||
if (isCmdActivated) deactivateCmdAndEmit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cmdPrefix = cmdParts[1];
|
|
||||||
const cmdSlug = cmdParts[2];
|
|
||||||
|
|
||||||
if (cmdPrefix === ':') {
|
|
||||||
// skip emoji autofill command if link is suspected.
|
|
||||||
const checkForLink = targetInput.slice(0, cmdParts.index);
|
|
||||||
if (checkForLink.match(/(http|https|mailto|matrix|ircs|irc)$/)) {
|
|
||||||
deactivateCmdAndEmit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cmdCursorPos = cursor;
|
|
||||||
if (cmdSlug === '') {
|
|
||||||
activateCmd(cmdPrefix);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isCmdActivated) activateCmd(cmdPrefix);
|
|
||||||
viewEvent.emit('cmd_process', cmdPrefix, cmdSlug);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMsgTyping = (e) => {
|
|
||||||
const msg = e.target.value;
|
|
||||||
recognizeCmd(e.target.value);
|
|
||||||
if (!isCmdActivated) processTyping(msg);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
roomsInput.cancelReplyTo(roomId);
|
|
||||||
setReplyTo(null);
|
|
||||||
}
|
|
||||||
if (e.key === 'Enter' && e.shiftKey === false) {
|
|
||||||
e.preventDefault();
|
|
||||||
sendMessage();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePaste = (e) => {
|
|
||||||
if (e.clipboardData === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.clipboardData.items === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < e.clipboardData.items.length; i += 1) {
|
|
||||||
const item = e.clipboardData.items[i];
|
|
||||||
if (item.type.indexOf('image') !== -1) {
|
|
||||||
const image = item.getAsFile();
|
|
||||||
if (attachment === null) {
|
|
||||||
setAttachment(image);
|
|
||||||
if (image !== null) {
|
|
||||||
roomsInput.setAttachment(roomId, image);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function addEmoji(emoji) {
|
|
||||||
textAreaRef.current.value += emoji.unicode;
|
|
||||||
textAreaRef.current.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUploadClick = () => {
|
|
||||||
if (attachment === null) uploadInputRef.current.click();
|
|
||||||
else {
|
|
||||||
roomsInput.cancelAttachment(roomId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
function uploadFileChange(e) {
|
|
||||||
const file = e.target.files.item(0);
|
|
||||||
setAttachment(file);
|
|
||||||
if (file !== null) roomsInput.setAttachment(roomId, file);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderInputs() {
|
|
||||||
const canISend = roomTimeline.room.currentState.maySendMessage(mx.getUserId());
|
|
||||||
const tombstoneEvent = roomTimeline.room.currentState.getStateEvents('m.room.tombstone')[0];
|
|
||||||
if (!canISend || tombstoneEvent) {
|
|
||||||
return (
|
|
||||||
<Text className="room-input__alert">
|
|
||||||
{
|
|
||||||
tombstoneEvent
|
|
||||||
? tombstoneEvent.getContent()?.body ?? 'This room has been replaced and is no longer active.'
|
|
||||||
: 'You do not have permission to post to this room'
|
|
||||||
}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={`room-input__option-container${attachment === null ? '' : ' room-attachment__option'}`}>
|
|
||||||
<input onChange={uploadFileChange} style={{ display: 'none' }} ref={uploadInputRef} type="file" />
|
|
||||||
<IconButton onClick={handleUploadClick} tooltip={attachment === null ? 'Upload' : 'Cancel'} src={CirclePlusIC} />
|
|
||||||
</div>
|
|
||||||
<div ref={inputBaseRef} className="room-input__input-container">
|
|
||||||
{roomTimeline.isEncrypted() && <RawIcon size="extra-small" src={ShieldIC} />}
|
|
||||||
<ScrollView autoHide>
|
|
||||||
<Text className="room-input__textarea-wrapper">
|
|
||||||
<TextareaAutosize
|
|
||||||
dir="auto"
|
|
||||||
id="message-textarea"
|
|
||||||
ref={textAreaRef}
|
|
||||||
onChange={handleMsgTyping}
|
|
||||||
onPaste={handlePaste}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Send a message..."
|
|
||||||
/>
|
|
||||||
</Text>
|
|
||||||
</ScrollView>
|
|
||||||
</div>
|
|
||||||
<div ref={rightOptionsRef} className="room-input__option-container">
|
|
||||||
<IconButton
|
|
||||||
onClick={(e) => {
|
|
||||||
openReusableContextMenu(
|
|
||||||
'top',
|
|
||||||
(() => {
|
|
||||||
const cords = getEventCords(e);
|
|
||||||
cords.y -= 20;
|
|
||||||
return cords;
|
|
||||||
})(),
|
|
||||||
(closeMenu) => (
|
|
||||||
<StickerBoard
|
|
||||||
roomId={roomId}
|
|
||||||
onSelect={(data) => {
|
|
||||||
handleSendSticker(data);
|
|
||||||
closeMenu();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
tooltip="Sticker"
|
|
||||||
src={StickerIC}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
onClick={(e) => {
|
|
||||||
const cords = getEventCords(e);
|
|
||||||
cords.x += (document.dir === 'rtl' ? -80 : 80);
|
|
||||||
cords.y -= 250;
|
|
||||||
openEmojiBoard(cords, addEmoji);
|
|
||||||
}}
|
|
||||||
tooltip="Emoji"
|
|
||||||
src={EmojiIC}
|
|
||||||
/>
|
|
||||||
<IconButton onClick={sendMessage} tooltip="Send" src={SendIC} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachFile() {
|
|
||||||
const fileType = attachment.type.slice(0, attachment.type.indexOf('/'));
|
|
||||||
return (
|
|
||||||
<div className="room-attachment">
|
|
||||||
<div className={`room-attachment__preview${fileType !== 'image' ? ' room-attachment__icon' : ''}`}>
|
|
||||||
{fileType === 'image' && <img alt={attachment.name} src={URL.createObjectURL(attachment)} />}
|
|
||||||
{fileType === 'video' && <RawIcon src={VLCIC} />}
|
|
||||||
{fileType === 'audio' && <RawIcon src={VolumeFullIC} />}
|
|
||||||
{fileType !== 'image' && fileType !== 'video' && fileType !== 'audio' && <RawIcon src={FileIC} />}
|
|
||||||
</div>
|
|
||||||
<div className="room-attachment__info">
|
|
||||||
<Text variant="b1">{attachment.name}</Text>
|
|
||||||
<Text variant="b3"><span ref={uploadProgressRef}>{`size: ${bytesToSize(attachment.size)}`}</span></Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachReply() {
|
|
||||||
return (
|
|
||||||
<div className="room-reply">
|
|
||||||
<IconButton
|
|
||||||
onClick={() => {
|
|
||||||
roomsInput.cancelReplyTo(roomId);
|
|
||||||
setReplyTo(null);
|
|
||||||
}}
|
|
||||||
src={CrossIC}
|
|
||||||
tooltip="Cancel reply"
|
|
||||||
size="extra-small"
|
|
||||||
/>
|
|
||||||
<MessageReply
|
|
||||||
userId={replyTo.userId}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
name={getUsername(replyTo.userId)}
|
|
||||||
color={colorMXID(replyTo.userId)}
|
|
||||||
body={replyTo.body}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{ replyTo !== null && attachReply()}
|
|
||||||
{ attachment !== null && attachFile() }
|
|
||||||
<form className="room-input" onSubmit={(e) => { e.preventDefault(); }}>
|
|
||||||
{
|
|
||||||
renderInputs()
|
|
||||||
}
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
RoomViewInput.propTypes = {
|
|
||||||
roomId: PropTypes.string.isRequired,
|
|
||||||
roomTimeline: PropTypes.shape({}).isRequired,
|
|
||||||
viewEvent: PropTypes.shape({}).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RoomViewInput;
|
|
|
@ -1,136 +0,0 @@
|
||||||
import { getScrollInfo } from '../../../util/common';
|
|
||||||
|
|
||||||
class TimelineScroll {
|
|
||||||
constructor(target) {
|
|
||||||
if (target === null) {
|
|
||||||
throw new Error('Can not initialize TimelineScroll, target HTMLElement in null');
|
|
||||||
}
|
|
||||||
this.scroll = target;
|
|
||||||
|
|
||||||
this.backwards = false;
|
|
||||||
this.inTopHalf = false;
|
|
||||||
|
|
||||||
this.isScrollable = false;
|
|
||||||
this.top = 0;
|
|
||||||
this.bottom = 0;
|
|
||||||
this.height = 0;
|
|
||||||
this.viewHeight = 0;
|
|
||||||
|
|
||||||
this.topMsg = null;
|
|
||||||
this.bottomMsg = null;
|
|
||||||
this.diff = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollToBottom() {
|
|
||||||
const scrollInfo = getScrollInfo(this.scroll);
|
|
||||||
const maxScrollTop = scrollInfo.height - scrollInfo.viewHeight;
|
|
||||||
|
|
||||||
this._scrollTo(scrollInfo, maxScrollTop);
|
|
||||||
}
|
|
||||||
|
|
||||||
// use previous calc by this._updateTopBottomMsg() & this._calcDiff.
|
|
||||||
tryRestoringScroll() {
|
|
||||||
const scrollInfo = getScrollInfo(this.scroll);
|
|
||||||
|
|
||||||
let scrollTop = 0;
|
|
||||||
const ot = this.inTopHalf ? this.topMsg?.offsetTop : this.bottomMsg?.offsetTop;
|
|
||||||
if (!ot) scrollTop = Math.round(this.height - this.viewHeight);
|
|
||||||
else scrollTop = ot - this.diff;
|
|
||||||
|
|
||||||
this._scrollTo(scrollInfo, scrollTop);
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollToIndex(index, offset = 0) {
|
|
||||||
const scrollInfo = getScrollInfo(this.scroll);
|
|
||||||
const msgs = this.scroll.lastElementChild.lastElementChild.children;
|
|
||||||
const offsetTop = msgs[index]?.offsetTop;
|
|
||||||
|
|
||||||
if (offsetTop === undefined) return;
|
|
||||||
// if msg is already in visible are we don't need to scroll to that
|
|
||||||
if (offsetTop > scrollInfo.top && offsetTop < (scrollInfo.top + scrollInfo.viewHeight)) return;
|
|
||||||
const to = offsetTop - offset;
|
|
||||||
|
|
||||||
this._scrollTo(scrollInfo, to);
|
|
||||||
}
|
|
||||||
|
|
||||||
_scrollTo(scrollInfo, scrollTop) {
|
|
||||||
this.scroll.scrollTop = scrollTop;
|
|
||||||
|
|
||||||
// browser emit 'onscroll' event only if the 'element.scrollTop' value changes.
|
|
||||||
// so here we flag that the upcoming 'onscroll' event is
|
|
||||||
// emitted as side effect of assigning 'this.scroll.scrollTop' above
|
|
||||||
// only if it's changes.
|
|
||||||
// by doing so we prevent this._updateCalc() from calc again.
|
|
||||||
if (scrollTop !== this.top) {
|
|
||||||
this.scrolledByCode = true;
|
|
||||||
}
|
|
||||||
const sInfo = { ...scrollInfo };
|
|
||||||
|
|
||||||
const maxScrollTop = scrollInfo.height - scrollInfo.viewHeight;
|
|
||||||
|
|
||||||
sInfo.top = (scrollTop > maxScrollTop) ? maxScrollTop : scrollTop;
|
|
||||||
this._updateCalc(sInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
// we maintain reference of top and bottom messages
|
|
||||||
// to restore the scroll position when
|
|
||||||
// messages gets removed from either end and added to other.
|
|
||||||
_updateTopBottomMsg() {
|
|
||||||
const msgs = this.scroll.lastElementChild.lastElementChild.children;
|
|
||||||
const lMsgIndex = msgs.length - 1;
|
|
||||||
|
|
||||||
// TODO: classname 'ph-msg' prevent this class from being used
|
|
||||||
const PLACEHOLDER_COUNT = 2;
|
|
||||||
this.topMsg = msgs[0]?.className === 'ph-msg'
|
|
||||||
? msgs[PLACEHOLDER_COUNT]
|
|
||||||
: msgs[0];
|
|
||||||
this.bottomMsg = msgs[lMsgIndex]?.className === 'ph-msg'
|
|
||||||
? msgs[lMsgIndex - PLACEHOLDER_COUNT]
|
|
||||||
: msgs[lMsgIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
// we calculate the difference between first/last message and current scrollTop.
|
|
||||||
// if we are going above we calc diff between first and scrollTop
|
|
||||||
// else otherwise.
|
|
||||||
// NOTE: This will help to restore the scroll when msgs get's removed
|
|
||||||
// from one end and added to other end
|
|
||||||
_calcDiff(scrollInfo) {
|
|
||||||
if (!this.topMsg || !this.bottomMsg) return 0;
|
|
||||||
if (this.inTopHalf) {
|
|
||||||
return this.topMsg.offsetTop - scrollInfo.top;
|
|
||||||
}
|
|
||||||
return this.bottomMsg.offsetTop - scrollInfo.top;
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateCalc(scrollInfo) {
|
|
||||||
const halfViewHeight = Math.round(scrollInfo.viewHeight / 2);
|
|
||||||
const scrollMiddle = scrollInfo.top + halfViewHeight;
|
|
||||||
const lastMiddle = this.top + halfViewHeight;
|
|
||||||
|
|
||||||
this.backwards = scrollMiddle < lastMiddle;
|
|
||||||
this.inTopHalf = scrollMiddle < scrollInfo.height / 2;
|
|
||||||
|
|
||||||
this.isScrollable = scrollInfo.isScrollable;
|
|
||||||
this.top = scrollInfo.top;
|
|
||||||
this.bottom = scrollInfo.height - (scrollInfo.top + scrollInfo.viewHeight);
|
|
||||||
this.height = scrollInfo.height;
|
|
||||||
this.viewHeight = scrollInfo.viewHeight;
|
|
||||||
|
|
||||||
this._updateTopBottomMsg();
|
|
||||||
this.diff = this._calcDiff(scrollInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
calcScroll() {
|
|
||||||
if (this.scrolledByCode) {
|
|
||||||
this.scrolledByCode = false;
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollInfo = getScrollInfo(this.scroll);
|
|
||||||
this._updateCalc(scrollInfo);
|
|
||||||
|
|
||||||
return this.backwards;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TimelineScroll;
|
|
|
@ -1,220 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import './commands.scss';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
import * as roomActions from '../../../client/action/room';
|
|
||||||
import { hasDMWith, hasDevices } from '../../../util/matrixUtil';
|
|
||||||
import { selectRoom, openReusableDialog } from '../../../client/action/navigation';
|
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
|
||||||
import SettingTile from '../../molecules/setting-tile/SettingTile';
|
|
||||||
|
|
||||||
const MXID_REG = /^@\S+:\S+$/;
|
|
||||||
const ROOM_ID_ALIAS_REG = /^(#|!)\S+:\S+$/;
|
|
||||||
const ROOM_ID_REG = /^!\S+:\S+$/;
|
|
||||||
const MXC_REG = /^mxc:\/\/\S+$/;
|
|
||||||
|
|
||||||
export function processMxidAndReason(data) {
|
|
||||||
let reason;
|
|
||||||
let idData = data;
|
|
||||||
const reasonMatch = data.match(/\s-r\s/);
|
|
||||||
if (reasonMatch) {
|
|
||||||
idData = data.slice(0, reasonMatch.index);
|
|
||||||
reason = data.slice(reasonMatch.index + reasonMatch[0].length);
|
|
||||||
if (reason.trim() === '') reason = undefined;
|
|
||||||
}
|
|
||||||
const rawIds = idData.split(' ');
|
|
||||||
const userIds = rawIds.filter((id) => id.match(MXID_REG));
|
|
||||||
return {
|
|
||||||
userIds,
|
|
||||||
reason,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const commands = {
|
|
||||||
me: {
|
|
||||||
name: 'me',
|
|
||||||
description: 'Display action',
|
|
||||||
exe: (roomId, data, onSuccess) => {
|
|
||||||
const body = data.trim();
|
|
||||||
if (body === '') return;
|
|
||||||
onSuccess(body, { msgType: 'm.emote' });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
shrug: {
|
|
||||||
name: 'shrug',
|
|
||||||
description: 'Send ¯\\_(ツ)_/¯ as message',
|
|
||||||
exe: (roomId, data, onSuccess) => onSuccess(
|
|
||||||
`¯\\_(ツ)_/¯${data.trim() !== '' ? ` ${data}` : ''}`,
|
|
||||||
{ msgType: 'm.text' },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
plain: {
|
|
||||||
name: 'plain',
|
|
||||||
description: 'Send plain text message',
|
|
||||||
exe: (roomId, data, onSuccess) => {
|
|
||||||
const body = data.trim();
|
|
||||||
if (body === '') return;
|
|
||||||
onSuccess(body, { msgType: 'm.text', autoMarkdown: false });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
help: {
|
|
||||||
name: 'help',
|
|
||||||
description: 'View all commands',
|
|
||||||
// eslint-disable-next-line no-use-before-define
|
|
||||||
exe: () => openHelpDialog(),
|
|
||||||
},
|
|
||||||
startdm: {
|
|
||||||
name: 'startdm',
|
|
||||||
description: 'Start direct message with user. Example: /startdm userId1',
|
|
||||||
exe: async (roomId, data) => {
|
|
||||||
const mx = initMatrix.matrixClient;
|
|
||||||
const rawIds = data.split(' ');
|
|
||||||
const userIds = rawIds.filter((id) => id.match(MXID_REG) && id !== mx.getUserId());
|
|
||||||
if (userIds.length === 0) return;
|
|
||||||
if (userIds.length === 1) {
|
|
||||||
const dmRoomId = hasDMWith(userIds[0]);
|
|
||||||
if (dmRoomId) {
|
|
||||||
selectRoom(dmRoomId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const devices = await Promise.all(userIds.map(hasDevices));
|
|
||||||
const isEncrypt = devices.every((hasDevice) => hasDevice);
|
|
||||||
const result = await roomActions.createDM(userIds, isEncrypt);
|
|
||||||
selectRoom(result.room_id);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
join: {
|
|
||||||
name: 'join',
|
|
||||||
description: 'Join room with address. Example: /join address1 address2',
|
|
||||||
exe: (roomId, data) => {
|
|
||||||
const rawIds = data.split(' ');
|
|
||||||
const roomIds = rawIds.filter((id) => id.match(ROOM_ID_ALIAS_REG));
|
|
||||||
roomIds.map((id) => roomActions.join(id));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
leave: {
|
|
||||||
name: 'leave',
|
|
||||||
description: 'Leave current room.',
|
|
||||||
exe: (roomId, data) => {
|
|
||||||
if (data.trim() === '') {
|
|
||||||
roomActions.leave(roomId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rawIds = data.split(' ');
|
|
||||||
const roomIds = rawIds.filter((id) => id.match(ROOM_ID_REG));
|
|
||||||
roomIds.map((id) => roomActions.leave(id));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
invite: {
|
|
||||||
name: 'invite',
|
|
||||||
description: 'Invite user to room. Example: /invite userId1 userId2 [-r reason]',
|
|
||||||
exe: (roomId, data) => {
|
|
||||||
const { userIds, reason } = processMxidAndReason(data);
|
|
||||||
userIds.map((id) => roomActions.invite(roomId, id, reason));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
disinvite: {
|
|
||||||
name: 'disinvite',
|
|
||||||
description: 'Disinvite user to room. Example: /disinvite userId1 userId2 [-r reason]',
|
|
||||||
exe: (roomId, data) => {
|
|
||||||
const { userIds, reason } = processMxidAndReason(data);
|
|
||||||
userIds.map((id) => roomActions.kick(roomId, id, reason));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
kick: {
|
|
||||||
name: 'kick',
|
|
||||||
description: 'Kick user from room. Example: /kick userId1 userId2 [-r reason]',
|
|
||||||
exe: (roomId, data) => {
|
|
||||||
const { userIds, reason } = processMxidAndReason(data);
|
|
||||||
userIds.map((id) => roomActions.kick(roomId, id, reason));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ban: {
|
|
||||||
name: 'ban',
|
|
||||||
description: 'Ban user from room. Example: /ban userId1 userId2 [-r reason]',
|
|
||||||
exe: (roomId, data) => {
|
|
||||||
const { userIds, reason } = processMxidAndReason(data);
|
|
||||||
userIds.map((id) => roomActions.ban(roomId, id, reason));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
unban: {
|
|
||||||
name: 'unban',
|
|
||||||
description: 'Unban user from room. Example: /unban userId1 userId2',
|
|
||||||
exe: (roomId, data) => {
|
|
||||||
const rawIds = data.split(' ');
|
|
||||||
const userIds = rawIds.filter((id) => id.match(MXID_REG));
|
|
||||||
userIds.map((id) => roomActions.unban(roomId, id));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ignore: {
|
|
||||||
name: 'ignore',
|
|
||||||
description: 'Ignore user. Example: /ignore userId1 userId2',
|
|
||||||
exe: (roomId, data) => {
|
|
||||||
const rawIds = data.split(' ');
|
|
||||||
const userIds = rawIds.filter((id) => id.match(MXID_REG));
|
|
||||||
if (userIds.length > 0) roomActions.ignore(userIds);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
unignore: {
|
|
||||||
name: 'unignore',
|
|
||||||
description: 'Unignore user. Example: /unignore userId1 userId2',
|
|
||||||
exe: (roomId, data) => {
|
|
||||||
const rawIds = data.split(' ');
|
|
||||||
const userIds = rawIds.filter((id) => id.match(MXID_REG));
|
|
||||||
if (userIds.length > 0) roomActions.unignore(userIds);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
myroomnick: {
|
|
||||||
name: 'myroomnick',
|
|
||||||
description: 'Change nick in current room.',
|
|
||||||
exe: (roomId, data) => {
|
|
||||||
const nick = data.trim();
|
|
||||||
if (nick === '') return;
|
|
||||||
roomActions.setMyRoomNick(roomId, nick);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
myroomavatar: {
|
|
||||||
name: 'myroomavatar',
|
|
||||||
description: 'Change profile picture in current room. Example /myroomavatar mxc://xyzabc',
|
|
||||||
exe: (roomId, data) => {
|
|
||||||
if (data.match(MXC_REG)) {
|
|
||||||
roomActions.setMyRoomAvatar(roomId, data);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
converttodm: {
|
|
||||||
name: 'converttodm',
|
|
||||||
description: 'Convert room to direct message',
|
|
||||||
exe: (roomId) => {
|
|
||||||
roomActions.convertToDm(roomId);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
converttoroom: {
|
|
||||||
name: 'converttoroom',
|
|
||||||
description: 'Convert direct message to room',
|
|
||||||
exe: (roomId) => {
|
|
||||||
roomActions.convertToRoom(roomId);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function openHelpDialog() {
|
|
||||||
openReusableDialog(
|
|
||||||
<Text variant="s1" weight="medium">Commands</Text>,
|
|
||||||
() => (
|
|
||||||
<div className="commands-dialog">
|
|
||||||
{Object.keys(commands).map((cmdName) => (
|
|
||||||
<SettingTile
|
|
||||||
key={cmdName}
|
|
||||||
title={cmdName}
|
|
||||||
content={<Text variant="b3">{commands[cmdName].description}</Text>}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default commands;
|
|
|
@ -1,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,
|
|
||||||
};
|
|
|
@ -121,7 +121,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{srcState.status === AsyncStatus.Success && (
|
{srcState.status === AsyncStatus.Success && (
|
||||||
<Box className={css.AbsoluteContainer}>
|
<Box className={css.AbsoluteContainer} style={{position: 'unset'}}>
|
||||||
<Image
|
<Image
|
||||||
alt={body}
|
alt={body}
|
||||||
title={body}
|
title={body}
|
||||||
|
|
|
@ -116,7 +116,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{srcState.status === AsyncStatus.Success && (
|
{srcState.status === AsyncStatus.Success && (
|
||||||
<Box className={css.AbsoluteContainer}>
|
<Box className={css.AbsoluteContainer} style={{position: 'unset'}}>
|
||||||
<Video
|
<Video
|
||||||
title={body}
|
title={body}
|
||||||
src={srcState.data}
|
src={srcState.data}
|
||||||
|
|
|
@ -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;
|
|
|
@ -112,3 +112,13 @@ emojisData.forEach((emoji) => {
|
||||||
emojis.push(em);
|
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`;
|
||||||
|
}
|
||||||
|
|
|
@ -17,7 +17,7 @@ import * as css from '../styles/CustomHtml.css';
|
||||||
import { getMxIdLocalPart, getRoomWithCanonicalAlias } from '../utils/matrix';
|
import { getMxIdLocalPart, getRoomWithCanonicalAlias } from '../utils/matrix';
|
||||||
import { getMemberDisplayName } from '../utils/room';
|
import { getMemberDisplayName } from '../utils/room';
|
||||||
import { EMOJI_PATTERN, URL_NEG_LB } from '../utils/regex';
|
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';
|
import { findAndReplace } from '../utils/findAndReplace';
|
||||||
|
|
||||||
const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
|
const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
|
||||||
|
@ -39,13 +39,22 @@ const textToEmojifyJSX = (text: string): (string | JSX.Element)[] =>
|
||||||
findAndReplace(
|
findAndReplace(
|
||||||
text,
|
text,
|
||||||
EMOJI_REG_G,
|
EMOJI_REG_G,
|
||||||
(match, pushIndex) => (
|
(match, pushIndex) => {
|
||||||
<span key={pushIndex} className={css.EmoticonBase}>
|
const char = match[0];
|
||||||
<span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(match[0]))}>
|
const className = css.Emoticon();
|
||||||
{match[0]}
|
const title = getShortcodeFor(getHexcodeForEmoji(char));
|
||||||
</span>
|
return <span key={pushIndex} className={css.EmoticonBase}>{
|
||||||
</span>
|
isUsingTwemoji() ? <img
|
||||||
),
|
className={className}
|
||||||
|
alt={char}
|
||||||
|
title={title}
|
||||||
|
src={getEmojiUrl(char)}
|
||||||
|
/> : <span
|
||||||
|
className={className}
|
||||||
|
title={title}
|
||||||
|
>{match[0]}</span>
|
||||||
|
}</span>;
|
||||||
|
},
|
||||||
(txt) => txt
|
(txt) => txt
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -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);
|
|
||||||
};
|
|
|
@ -1,3 +0,0 @@
|
||||||
import { atom } from 'jotai';
|
|
||||||
|
|
||||||
export const selectedRoomAtom = atom<string | undefined>(undefined);
|
|
|
@ -1,8 +0,0 @@
|
||||||
import { atom } from 'jotai';
|
|
||||||
|
|
||||||
export enum SidebarTab {
|
|
||||||
Home = 'Home',
|
|
||||||
People = 'People',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const selectedTabAtom = atom<SidebarTab | string>(SidebarTab.Home);
|
|
|
@ -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;
|
|
|
@ -22,5 +22,5 @@ export async function markAsRead(roomId) {
|
||||||
const latestEvent = getLatestValidEvent();
|
const latestEvent = getLatestValidEvent();
|
||||||
if (latestEvent === null) return;
|
if (latestEvent === null) return;
|
||||||
|
|
||||||
await mx.sendReadReceipt(latestEvent);
|
await mx.sendReadReceipt(latestEvent, 'm.read.private');
|
||||||
}
|
}
|
||||||
|
|
|
@ -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!;
|
|
||||||
};
|
|
|
@ -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;
|
|
|
@ -1,12 +1,5 @@
|
||||||
@use './app/partials/screen';
|
@use './app/partials/screen';
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: Twemoji;
|
|
||||||
src: url('../public/font/Twemoji.Mozilla.v.7.0.woff2'),
|
|
||||||
url('../public/font/Twemoji.Mozilla.v0.7.0.ttf');
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* background color | --bg-[background type]: value */
|
/* background color | --bg-[background type]: value */
|
||||||
--bg-surface: #ffffff;
|
--bg-surface: #ffffff;
|
||||||
|
|
|
@ -19,7 +19,9 @@ export function hashCode(str) {
|
||||||
|
|
||||||
export function cssColorMXID(userId) {
|
export function cssColorMXID(userId) {
|
||||||
const colorNumber = hashCode(userId) % 8;
|
const colorNumber = hashCode(userId) % 8;
|
||||||
return `--mx-uc-${colorNumber + 1}`;
|
// @user:a.b.c => -user-a_b_c
|
||||||
|
const escapedUserId = userId.replace(/[@:]/g, '-').replace(/[^\w-]/g, '_');
|
||||||
|
return `--mx-uc-${escapedUserId}, var(--mx-uc-${colorNumber + 1})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function colorMXID(userId) {
|
export default function colorMXID(userId) {
|
||||||
|
|
1
twemoji
Submodule
1
twemoji
Submodule
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 310184baaae6f797e9ef2650bea35187242ffafb
|
|
@ -35,6 +35,10 @@ const copyFiles = {
|
||||||
src: 'public/res/android',
|
src: 'public/res/android',
|
||||||
dest: 'public/',
|
dest: 'public/',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
src: 'twemoji/assets/svg/*',
|
||||||
|
dest: 'twemoji/',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue