(chore) remove outdated code (#1765)
* optimize room typing members hook * remove unused code - WIP * remove old code from initMatrix * remove twemojify function * remove old sanitize util * delete old markdown util * delete Math atom component * uninstall unused dependencies * remove old notification system * decrypt message in inbox notification center and fix refresh in background * improve notification --------- Co-authored-by: Krishan <33421343+kfiven@users.noreply.github.com>
This commit is contained in:
parent
60e022035f
commit
4f09e6bbb5
144 changed files with 1166 additions and 15332 deletions
|
|
@ -2,17 +2,13 @@ import React from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import './Avatar.scss';
|
||||
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import Text from '../text/Text';
|
||||
import RawIcon from '../system-icons/RawIcon';
|
||||
|
||||
import ImageBrokenSVG from '../../../../public/res/svg/image-broken.svg';
|
||||
import { avatarInitials } from '../../../util/common';
|
||||
|
||||
const Avatar = React.forwardRef(({
|
||||
text, bgColor, iconSrc, iconColor, imageSrc, size,
|
||||
}, ref) => {
|
||||
const Avatar = React.forwardRef(({ text, bgColor, iconSrc, iconColor, imageSrc, size }, ref) => {
|
||||
let textSize = 's1';
|
||||
if (size === 'large') textSize = 'h1';
|
||||
if (size === 'small') textSize = 'b1';
|
||||
|
|
@ -20,34 +16,34 @@ const Avatar = React.forwardRef(({
|
|||
|
||||
return (
|
||||
<div ref={ref} className={`avatar-container avatar-container__${size} noselect`}>
|
||||
{
|
||||
imageSrc !== null
|
||||
? (
|
||||
<img
|
||||
draggable="false"
|
||||
src={imageSrc}
|
||||
onLoad={(e) => { e.target.style.backgroundColor = 'transparent'; }}
|
||||
onError={(e) => { e.target.src = ImageBrokenSVG; }}
|
||||
alt=""
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<span
|
||||
style={{ backgroundColor: iconSrc === null ? bgColor : 'transparent' }}
|
||||
className={`avatar__border${iconSrc !== null ? '--active' : ''}`}
|
||||
>
|
||||
{
|
||||
iconSrc !== null
|
||||
? <RawIcon size={size} src={iconSrc} color={iconColor} />
|
||||
: text !== null && (
|
||||
<Text variant={textSize} primary>
|
||||
{twemojify(avatarInitials(text))}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{imageSrc !== null ? (
|
||||
<img
|
||||
draggable="false"
|
||||
src={imageSrc}
|
||||
onLoad={(e) => {
|
||||
e.target.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.target.src = ImageBrokenSVG;
|
||||
}}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
style={{ backgroundColor: iconSrc === null ? bgColor : 'transparent' }}
|
||||
className={`avatar__border${iconSrc !== null ? '--active' : ''}`}
|
||||
>
|
||||
{iconSrc !== null ? (
|
||||
<RawIcon size={size} src={iconSrc} color={iconColor} />
|
||||
) : (
|
||||
text !== null && (
|
||||
<Text variant={textSize} primary>
|
||||
{avatarInitials(text)}
|
||||
</Text>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './Math.scss';
|
||||
|
||||
import katex from 'katex';
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
import 'katex/dist/contrib/copy-tex';
|
||||
|
||||
const Math = React.memo(({
|
||||
content, throwOnError, errorColor, displayMode,
|
||||
}) => {
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
katex.render(content, ref.current, { throwOnError, errorColor, displayMode });
|
||||
}, [content, throwOnError, errorColor, displayMode]);
|
||||
|
||||
return <span ref={ref} />;
|
||||
});
|
||||
Math.defaultProps = {
|
||||
throwOnError: null,
|
||||
errorColor: null,
|
||||
displayMode: null,
|
||||
};
|
||||
Math.propTypes = {
|
||||
content: PropTypes.string.isRequired,
|
||||
throwOnError: PropTypes.bool,
|
||||
errorColor: PropTypes.string,
|
||||
displayMode: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Math;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
.katex-display {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import React, {
|
|||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { EventType, IContent, MsgType, Room } from 'matrix-js-sdk';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
|
|
@ -56,7 +56,6 @@ import {
|
|||
} from '../../components/editor';
|
||||
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { TUploadContent, encryptFile, getImageInfo, getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
|
||||
import { useFilePicker } from '../../hooks/useFilePicker';
|
||||
|
|
@ -95,6 +94,7 @@ import {
|
|||
} from './msgContent';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import {
|
||||
getAllParents,
|
||||
getMemberDisplayName,
|
||||
parseReplyBody,
|
||||
parseReplyFormattedBody,
|
||||
|
|
@ -107,6 +107,7 @@ import { Command, SHRUG, useCommands } from '../../hooks/useCommands';
|
|||
import { mobileOrTablet } from '../../utils/user-agent';
|
||||
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
|
||||
import { ReplyLayout } from '../../components/message';
|
||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||
|
||||
interface RoomInputProps {
|
||||
editor: Editor;
|
||||
|
|
@ -121,6 +122,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||
const commands = useCommands(mx, room);
|
||||
const emojiBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
|
||||
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
|
||||
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
|
||||
|
|
@ -133,13 +135,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
|
||||
|
||||
const imagePackRooms: Room[] = useMemo(() => {
|
||||
const allParentSpaces = [roomId, ...(initMatrix.roomList?.getAllParentSpaces(roomId) ?? [])];
|
||||
const allParentSpaces = [roomId].concat(Array.from(getAllParents(roomToParents, roomId)));
|
||||
return allParentSpaces.reduce<Room[]>((list, rId) => {
|
||||
const r = mx.getRoom(rId);
|
||||
if (r) list.push(r);
|
||||
return list;
|
||||
}, []);
|
||||
}, [mx, roomId]);
|
||||
}, [mx, roomId, roomToParents]);
|
||||
|
||||
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||
const [autocompleteQuery, setAutocompleteQuery] =
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import classNames from 'classnames';
|
|||
import { ReactEditor } from 'slate-react';
|
||||
import { Editor } from 'slate';
|
||||
import to from 'await-to-js';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
|
|
@ -74,6 +74,7 @@ import { getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser
|
|||
import {
|
||||
canEditEvent,
|
||||
decryptAllTimelineEvent,
|
||||
getAllParents,
|
||||
getEditedEvent,
|
||||
getEventReactions,
|
||||
getLatestEditableEvt,
|
||||
|
|
@ -103,14 +104,15 @@ import { createMentionElement, isEmptyEditor, moveCursor } from '../../component
|
|||
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||
import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import cons from '../../../client/state/cons';
|
||||
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||
import { Image } from '../../components/media';
|
||||
import { ImageViewer } from '../../components/image-viewer';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||
import { useRoomUnread } from '../../state/hooks/unread';
|
||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
|
||||
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
|
||||
({ position, className, ...props }, ref) => (
|
||||
|
|
@ -444,18 +446,19 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel);
|
||||
const [editId, setEditId] = useState<string>();
|
||||
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||
|
||||
const imagePackRooms: Room[] = useMemo(() => {
|
||||
const allParentSpaces = [
|
||||
room.roomId,
|
||||
...(initMatrix.roomList?.getAllParentSpaces(room.roomId) ?? []),
|
||||
];
|
||||
const allParentSpaces = [room.roomId].concat(
|
||||
Array.from(getAllParents(roomToParents, room.roomId))
|
||||
);
|
||||
return allParentSpaces.reduce<Room[]>((list, rId) => {
|
||||
const r = mx.getRoom(rId);
|
||||
if (r) list.push(r);
|
||||
return list;
|
||||
}, []);
|
||||
}, [mx, room]);
|
||||
}, [mx, room, roomToParents]);
|
||||
|
||||
const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
|
||||
const readUptoEventIdRef = useRef<string>();
|
||||
|
|
@ -794,15 +797,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
|
||||
// Remove unreadInfo on mark as read
|
||||
useEffect(() => {
|
||||
const handleFullRead = (rId: string) => {
|
||||
if (rId !== room.roomId) return;
|
||||
if (!unread) {
|
||||
setUnreadInfo(undefined);
|
||||
};
|
||||
initMatrix.notifications?.on(cons.events.notifications.FULL_READ, handleFullRead);
|
||||
return () => {
|
||||
initMatrix.notifications?.removeListener(cons.events.notifications.FULL_READ, handleFullRead);
|
||||
};
|
||||
}, [room]);
|
||||
}
|
||||
}, [unread]);
|
||||
|
||||
// scroll out of view msg editor in view.
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
/* eslint-disable import/prefer-default-export */
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import initMatrix from '../../client/initMatrix';
|
||||
import cons from '../../client/state/cons';
|
||||
|
||||
export function useCategorizedSpaces() {
|
||||
const { accountData } = initMatrix;
|
||||
const [categorizedSpaces, setCategorizedSpaces] = useState([...accountData.categorizedSpaces]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCategorizedSpaces = () => {
|
||||
setCategorizedSpaces([...accountData.categorizedSpaces]);
|
||||
};
|
||||
accountData.on(cons.events.accountData.CATEGORIZE_SPACE_UPDATED, handleCategorizedSpaces);
|
||||
return () => {
|
||||
accountData.removeListener(
|
||||
cons.events.accountData.CATEGORIZE_SPACE_UPDATED,
|
||||
handleCategorizedSpaces,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return [categorizedSpaces];
|
||||
}
|
||||
|
|
@ -114,12 +114,12 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
|||
description: 'Leave current room.',
|
||||
exe: async (payload) => {
|
||||
if (payload.trim() === '') {
|
||||
roomActions.leave(room.roomId);
|
||||
mx.leave(room.roomId);
|
||||
return;
|
||||
}
|
||||
const rawIds = payload.split(' ');
|
||||
const roomIds = rawIds.filter((id) => isRoomId(id));
|
||||
roomIds.map((id) => roomActions.leave(id));
|
||||
roomIds.map((id) => mx.leave(id));
|
||||
},
|
||||
},
|
||||
[Command.Invite]: {
|
||||
|
|
|
|||
11
src/app/hooks/usePreviousValue.ts
Normal file
11
src/app/hooks/usePreviousValue.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export const usePreviousValue = <T>(currentValue: T, initialValue: T) => {
|
||||
const valueRef = useRef(initialValue);
|
||||
|
||||
useEffect(() => {
|
||||
valueRef.current = currentValue;
|
||||
}, [currentValue]);
|
||||
|
||||
return valueRef.current;
|
||||
};
|
||||
|
|
@ -1,10 +1,26 @@
|
|||
import { useAtomValue } from 'jotai';
|
||||
import { useMemo } from 'react';
|
||||
import { roomIdToTypingMembersAtom, selectRoomTypingMembersAtom } from '../state/typingMembers';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
IRoomIdToTypingMembers,
|
||||
TypingReceipt,
|
||||
roomIdToTypingMembersAtom,
|
||||
} from '../state/typingMembers';
|
||||
|
||||
const typingReceiptEqual = (a: TypingReceipt, b: TypingReceipt): boolean =>
|
||||
a.userId === b.userId && a.ts === b.ts;
|
||||
|
||||
const equalTypingMembers = (x: TypingReceipt[], y: TypingReceipt[]): boolean => {
|
||||
if (x.length !== y.length) return false;
|
||||
return x.every((a, i) => typingReceiptEqual(a, y[i]));
|
||||
};
|
||||
|
||||
export const useRoomTypingMember = (roomId: string) => {
|
||||
const typing = useAtomValue(
|
||||
useMemo(() => selectRoomTypingMembersAtom(roomId, roomIdToTypingMembersAtom), [roomId])
|
||||
const selector = useCallback(
|
||||
(roomToTyping: IRoomIdToTypingMembers) => roomToTyping.get(roomId) ?? [],
|
||||
[roomId]
|
||||
);
|
||||
|
||||
const typing = useAtomValue(selectAtom(roomIdToTypingMembersAtom, selector, equalTypingMembers));
|
||||
return typing;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
/* eslint-disable import/prefer-default-export */
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import cons from '../../client/state/cons';
|
||||
import navigation from '../../client/state/navigation';
|
||||
|
||||
export function useSelectedSpace() {
|
||||
const [spaceId, setSpaceId] = useState(navigation.selectedSpaceId);
|
||||
|
||||
useEffect(() => {
|
||||
const onSpaceSelected = (roomId) => {
|
||||
setSpaceId(roomId);
|
||||
};
|
||||
navigation.on(cons.events.navigation.SPACE_SELECTED, onSpaceSelected);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.SPACE_SELECTED, onSpaceSelected);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return [spaceId];
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
/* eslint-disable import/prefer-default-export */
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import cons from '../../client/state/cons';
|
||||
import navigation from '../../client/state/navigation';
|
||||
|
||||
export function useSelectedTab() {
|
||||
const [selectedTab, setSelectedTab] = useState(navigation.selectedTab);
|
||||
|
||||
useEffect(() => {
|
||||
const onTabSelected = (tabId) => {
|
||||
setSelectedTab(tabId);
|
||||
};
|
||||
navigation.on(cons.events.navigation.TAB_SELECTED, onTabSelected);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.TAB_SELECTED, onTabSelected);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return [selectedTab];
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
/* eslint-disable import/prefer-default-export */
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import initMatrix from '../../client/initMatrix';
|
||||
import cons from '../../client/state/cons';
|
||||
|
||||
export function useSpaceShortcut() {
|
||||
const { accountData } = initMatrix;
|
||||
const [spaceShortcut, setSpaceShortcut] = useState([...accountData.spaceShortcut]);
|
||||
|
||||
useEffect(() => {
|
||||
const onSpaceShortcutUpdated = () => {
|
||||
setSpaceShortcut([...accountData.spaceShortcut]);
|
||||
};
|
||||
accountData.on(cons.events.accountData.SPACE_SHORTCUT_UPDATED, onSpaceShortcutUpdated);
|
||||
return () => {
|
||||
accountData.removeListener(
|
||||
cons.events.accountData.SPACE_SHORTCUT_UPDATED,
|
||||
onSpaceShortcutUpdated,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return [spaceShortcut];
|
||||
}
|
||||
|
|
@ -2,16 +2,21 @@ import React from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import './Dialog.scss';
|
||||
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Header, { TitleWrapper } from '../../atoms/header/Header';
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
import RawModal from '../../atoms/modal/RawModal';
|
||||
|
||||
function Dialog({
|
||||
className, isOpen, title, onAfterOpen, onAfterClose,
|
||||
contentOptions, onRequestClose, closeFromOutside, children,
|
||||
className,
|
||||
isOpen,
|
||||
title,
|
||||
onAfterOpen,
|
||||
onAfterClose,
|
||||
contentOptions,
|
||||
onRequestClose,
|
||||
closeFromOutside,
|
||||
children,
|
||||
invisibleScroll,
|
||||
}) {
|
||||
return (
|
||||
|
|
@ -28,19 +33,19 @@ function Dialog({
|
|||
<div className="dialog__content">
|
||||
<Header>
|
||||
<TitleWrapper>
|
||||
{
|
||||
typeof title === 'string'
|
||||
? <Text variant="h2" weight="medium" primary>{twemojify(title)}</Text>
|
||||
: title
|
||||
}
|
||||
{typeof title === 'string' ? (
|
||||
<Text variant="h2" weight="medium" primary>
|
||||
{title}
|
||||
</Text>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</TitleWrapper>
|
||||
{contentOptions}
|
||||
</Header>
|
||||
<div className="dialog__content__wrapper">
|
||||
<ScrollView autoHide={!invisibleScroll} invisible={invisibleScroll}>
|
||||
<div className="dialog__content-container">
|
||||
{children}
|
||||
</div>
|
||||
<div className="dialog__content-container">{children}</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
/* eslint-disable react/prop-types */
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './FollowingMembers.scss';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import { openReadReceipts } from '../../../client/action/navigation';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
|
||||
|
||||
import { getUsersActionJsx } from '../../organisms/room/common';
|
||||
|
||||
function FollowingMembers({ roomTimeline }) {
|
||||
const [followingMembers, setFollowingMembers] = useState([]);
|
||||
const { roomId } = roomTimeline;
|
||||
const mx = initMatrix.matrixClient;
|
||||
const myUserId = mx.getUserId();
|
||||
|
||||
useEffect(() => {
|
||||
const updateFollowingMembers = () => {
|
||||
setFollowingMembers(roomTimeline.getLiveReaders());
|
||||
};
|
||||
const updateOnEvent = (event, room) => {
|
||||
if (room.roomId !== roomId) return;
|
||||
setFollowingMembers(roomTimeline.getLiveReaders());
|
||||
};
|
||||
updateFollowingMembers();
|
||||
roomTimeline.on(cons.events.roomTimeline.LIVE_RECEIPT, updateFollowingMembers);
|
||||
mx.on('Room.timeline', updateOnEvent);
|
||||
return () => {
|
||||
roomTimeline.removeListener(cons.events.roomTimeline.LIVE_RECEIPT, updateFollowingMembers);
|
||||
mx.removeListener('Room.timeline', updateOnEvent);
|
||||
};
|
||||
}, [roomTimeline, roomId]);
|
||||
|
||||
const filteredM = followingMembers.filter((userId) => userId !== myUserId);
|
||||
|
||||
return (
|
||||
filteredM.length !== 0 && (
|
||||
<button
|
||||
className="following-members"
|
||||
onClick={() => openReadReceipts(roomId, followingMembers)}
|
||||
type="button"
|
||||
>
|
||||
<RawIcon size="extra-small" src={TickMarkIC} />
|
||||
<Text variant="b2">
|
||||
{getUsersActionJsx(roomId, filteredM, 'following the conversation.')}
|
||||
</Text>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
FollowingMembers.propTypes = {
|
||||
roomTimeline: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
export default FollowingMembers;
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
@use '../../partials/text';
|
||||
|
||||
.following-members {
|
||||
width: 100%;
|
||||
padding: 0 var(--sp-normal);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
& .ic-raw {
|
||||
min-width: var(--ic-extra-small);
|
||||
opacity: 0.4;
|
||||
margin: 0 var(--sp-extra-tight);
|
||||
}
|
||||
& .text {
|
||||
@extend .cp-txt__ellipsis;
|
||||
color: var(--tc-surface-low);
|
||||
b {
|
||||
color: var(--tc-surface-normal);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--bg-surface-hover);
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--bg-surface-active);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './ImageLightbox.scss';
|
||||
import FileSaver from 'file-saver';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import RawModal from '../../atoms/modal/RawModal';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
|
||||
import DownloadSVG from '../../../../public/res/ic/outlined/download.svg';
|
||||
import ExternalSVG from '../../../../public/res/ic/outlined/external.svg';
|
||||
|
||||
function ImageLightbox({
|
||||
url, alt, isOpen, onRequestClose,
|
||||
}) {
|
||||
const handleDownload = () => {
|
||||
FileSaver.saveAs(url, alt);
|
||||
};
|
||||
|
||||
return (
|
||||
<RawModal
|
||||
className="image-lightbox__modal"
|
||||
overlayClassName="image-lightbox__overlay"
|
||||
isOpen={isOpen}
|
||||
onRequestClose={onRequestClose}
|
||||
size="large"
|
||||
>
|
||||
<div className="image-lightbox__header">
|
||||
<Text variant="b2" weight="medium">{alt}</Text>
|
||||
<IconButton onClick={() => window.open(url)} size="small" src={ExternalSVG} />
|
||||
<IconButton onClick={handleDownload} size="small" src={DownloadSVG} />
|
||||
</div>
|
||||
<div className="image-lightbox__content">
|
||||
<img src={url} alt={alt} />
|
||||
</div>
|
||||
</RawModal>
|
||||
);
|
||||
}
|
||||
|
||||
ImageLightbox.propTypes = {
|
||||
url: PropTypes.string.isRequired,
|
||||
alt: PropTypes.string.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onRequestClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ImageLightbox;
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/text';
|
||||
|
||||
.image-lightbox__modal {
|
||||
box-shadow: none;
|
||||
width: unset;
|
||||
gap: var(--sp-normal);
|
||||
|
||||
border-radius: 0;
|
||||
pointer-events: none;
|
||||
|
||||
& .text {
|
||||
color: white;
|
||||
}
|
||||
& .ic-raw {
|
||||
background-color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.image-lightbox__overlay {
|
||||
background-color: var(--bg-overlay-low);
|
||||
}
|
||||
|
||||
|
||||
.image-lightbox__header > *,
|
||||
.image-lightbox__content > * {
|
||||
pointer-events: all;
|
||||
}
|
||||
.image-lightbox__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& > .text {
|
||||
@extend .cp-fx__item-one;
|
||||
@extend .cp-txt__ellipsis;
|
||||
}
|
||||
}
|
||||
.image-lightbox__content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
max-height: 80vh;
|
||||
|
||||
& img {
|
||||
background-color: var(--bg-surface-low);
|
||||
object-fit: contain;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: var(--bo-radius);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,366 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './Media.scss';
|
||||
|
||||
import encrypt from 'browser-encrypt-attachment';
|
||||
|
||||
import { BlurhashCanvas } from 'react-blurhash';
|
||||
import Text from '../../atoms/text/Text';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
import ImageLightbox from '../image-lightbox/ImageLightbox';
|
||||
|
||||
import DownloadSVG from '../../../../public/res/ic/outlined/download.svg';
|
||||
import ExternalSVG from '../../../../public/res/ic/outlined/external.svg';
|
||||
import PlaySVG from '../../../../public/res/ic/outlined/play.svg';
|
||||
|
||||
import { getBlobSafeMimeType } from '../../../util/mimetypes';
|
||||
|
||||
async function getDecryptedBlob(response, type, decryptData) {
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const dataArray = await encrypt.decryptAttachment(arrayBuffer, decryptData);
|
||||
const blob = new Blob([dataArray], { type: getBlobSafeMimeType(type) });
|
||||
return blob;
|
||||
}
|
||||
|
||||
async function getUrl(link, type, decryptData) {
|
||||
try {
|
||||
const response = await fetch(link, { method: 'GET' });
|
||||
if (decryptData !== null) {
|
||||
return URL.createObjectURL(await getDecryptedBlob(response, type, decryptData));
|
||||
}
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
} catch (e) {
|
||||
return link;
|
||||
}
|
||||
}
|
||||
|
||||
function getNativeHeight(width, height, maxWidth = 296) {
|
||||
const scale = maxWidth / width;
|
||||
return scale * height;
|
||||
}
|
||||
|
||||
function FileHeader({
|
||||
name, link, external,
|
||||
file, type,
|
||||
}) {
|
||||
const [url, setUrl] = useState(null);
|
||||
|
||||
async function getFile() {
|
||||
const myUrl = await getUrl(link, type, file);
|
||||
setUrl(myUrl);
|
||||
}
|
||||
|
||||
async function handleDownload(e) {
|
||||
if (file !== null && url === null) {
|
||||
e.preventDefault();
|
||||
await getFile();
|
||||
e.target.click();
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="file-header">
|
||||
<Text className="file-name" variant="b3">{name}</Text>
|
||||
{ link !== null && (
|
||||
<>
|
||||
{
|
||||
external && (
|
||||
<IconButton
|
||||
size="extra-small"
|
||||
tooltip="Open in new tab"
|
||||
src={ExternalSVG}
|
||||
onClick={() => window.open(url || link)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<a href={url || link} download={name} target="_blank" rel="noreferrer">
|
||||
<IconButton
|
||||
size="extra-small"
|
||||
tooltip="Download"
|
||||
src={DownloadSVG}
|
||||
onClick={handleDownload}
|
||||
/>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
FileHeader.defaultProps = {
|
||||
external: false,
|
||||
file: null,
|
||||
link: null,
|
||||
};
|
||||
FileHeader.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
link: PropTypes.string,
|
||||
external: PropTypes.bool,
|
||||
file: PropTypes.shape({}),
|
||||
type: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
function File({
|
||||
name, link, file, type,
|
||||
}) {
|
||||
return (
|
||||
<div className="file-container">
|
||||
<FileHeader name={name} link={link} file={file} type={type} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File.defaultProps = {
|
||||
file: null,
|
||||
type: '',
|
||||
};
|
||||
File.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
link: PropTypes.string.isRequired,
|
||||
type: PropTypes.string,
|
||||
file: PropTypes.shape({}),
|
||||
};
|
||||
|
||||
function Image({
|
||||
name, width, height, link, file, type, blurhash,
|
||||
}) {
|
||||
const [url, setUrl] = useState(null);
|
||||
const [blur, setBlur] = useState(true);
|
||||
const [lightbox, setLightbox] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let unmounted = false;
|
||||
async function fetchUrl() {
|
||||
const myUrl = await getUrl(link, type, file);
|
||||
if (unmounted) return;
|
||||
setUrl(myUrl);
|
||||
}
|
||||
fetchUrl();
|
||||
return () => {
|
||||
unmounted = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleLightbox = () => {
|
||||
if (!url) return;
|
||||
setLightbox(!lightbox);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="file-container">
|
||||
<div
|
||||
style={{ height: width !== null ? getNativeHeight(width, height) : 'unset' }}
|
||||
className="image-container"
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
onClick={toggleLightbox}
|
||||
onKeyDown={toggleLightbox}
|
||||
>
|
||||
{ blurhash && blur && <BlurhashCanvas hash={blurhash} punch={1} />}
|
||||
{ url !== null && (
|
||||
<img
|
||||
style={{ display: blur ? 'none' : 'unset' }}
|
||||
onLoad={() => setBlur(false)}
|
||||
src={url || link}
|
||||
alt={name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{url && (
|
||||
<ImageLightbox
|
||||
url={url}
|
||||
alt={name}
|
||||
isOpen={lightbox}
|
||||
onRequestClose={toggleLightbox}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Image.defaultProps = {
|
||||
file: null,
|
||||
width: null,
|
||||
height: null,
|
||||
type: '',
|
||||
blurhash: '',
|
||||
};
|
||||
Image.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
link: PropTypes.string.isRequired,
|
||||
file: PropTypes.shape({}),
|
||||
type: PropTypes.string,
|
||||
blurhash: PropTypes.string,
|
||||
};
|
||||
|
||||
function Sticker({
|
||||
name, height, width, link, file, type,
|
||||
}) {
|
||||
const [url, setUrl] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let unmounted = false;
|
||||
async function fetchUrl() {
|
||||
const myUrl = await getUrl(link, type, file);
|
||||
if (unmounted) return;
|
||||
setUrl(myUrl);
|
||||
}
|
||||
fetchUrl();
|
||||
return () => {
|
||||
unmounted = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="sticker-container" style={{ height: width !== null ? getNativeHeight(width, height, 128) : 'unset' }}>
|
||||
{ url !== null && <img src={url || link} title={name} alt={name} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Sticker.defaultProps = {
|
||||
file: null,
|
||||
type: '',
|
||||
width: null,
|
||||
height: null,
|
||||
};
|
||||
Sticker.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
link: PropTypes.string.isRequired,
|
||||
file: PropTypes.shape({}),
|
||||
type: PropTypes.string,
|
||||
};
|
||||
|
||||
function Audio({
|
||||
name, link, type, file,
|
||||
}) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [url, setUrl] = useState(null);
|
||||
|
||||
async function loadAudio() {
|
||||
const myUrl = await getUrl(link, type, file);
|
||||
setUrl(myUrl);
|
||||
setIsLoading(false);
|
||||
}
|
||||
function handlePlayAudio() {
|
||||
setIsLoading(true);
|
||||
loadAudio();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="file-container">
|
||||
<FileHeader name={name} link={file !== null ? url : url || link} type={type} external />
|
||||
<div className="audio-container">
|
||||
{ url === null && isLoading && <Spinner size="small" /> }
|
||||
{ url === null && !isLoading && <IconButton onClick={handlePlayAudio} tooltip="Play audio" src={PlaySVG} />}
|
||||
{ url !== null && (
|
||||
/* eslint-disable-next-line jsx-a11y/media-has-caption */
|
||||
<audio autoPlay controls>
|
||||
<source src={url} type={getBlobSafeMimeType(type)} />
|
||||
</audio>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Audio.defaultProps = {
|
||||
file: null,
|
||||
type: '',
|
||||
};
|
||||
Audio.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
link: PropTypes.string.isRequired,
|
||||
type: PropTypes.string,
|
||||
file: PropTypes.shape({}),
|
||||
};
|
||||
|
||||
function Video({
|
||||
name, link, thumbnail, thumbnailFile, thumbnailType,
|
||||
width, height, file, type, blurhash,
|
||||
}) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [url, setUrl] = useState(null);
|
||||
const [thumbUrl, setThumbUrl] = useState(null);
|
||||
const [blur, setBlur] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let unmounted = false;
|
||||
async function fetchUrl() {
|
||||
const myThumbUrl = await getUrl(thumbnail, thumbnailType, thumbnailFile);
|
||||
if (unmounted) return;
|
||||
setThumbUrl(myThumbUrl);
|
||||
}
|
||||
if (thumbnail !== null) fetchUrl();
|
||||
return () => {
|
||||
unmounted = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadVideo = async () => {
|
||||
const myUrl = await getUrl(link, type, file);
|
||||
setUrl(myUrl);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handlePlayVideo = () => {
|
||||
setIsLoading(true);
|
||||
loadVideo();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="file-container">
|
||||
<FileHeader name={name} link={file !== null ? url : url || link} type={type} external />
|
||||
<div
|
||||
style={{
|
||||
height: width !== null ? getNativeHeight(width, height) : 'unset',
|
||||
}}
|
||||
className="video-container"
|
||||
>
|
||||
{ url === null ? (
|
||||
<>
|
||||
{ blurhash && blur && <BlurhashCanvas hash={blurhash} punch={1} />}
|
||||
{ thumbUrl !== null && (
|
||||
<img style={{ display: blur ? 'none' : 'unset' }} src={thumbUrl} onLoad={() => setBlur(false)} alt={name} />
|
||||
)}
|
||||
{isLoading && <Spinner size="small" />}
|
||||
{!isLoading && <IconButton onClick={handlePlayVideo} tooltip="Play video" src={PlaySVG} />}
|
||||
</>
|
||||
) : (
|
||||
/* eslint-disable-next-line jsx-a11y/media-has-caption */
|
||||
<video autoPlay controls poster={thumbUrl}>
|
||||
<source src={url} type={getBlobSafeMimeType(type)} />
|
||||
</video>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Video.defaultProps = {
|
||||
width: null,
|
||||
height: null,
|
||||
file: null,
|
||||
thumbnail: null,
|
||||
thumbnailType: null,
|
||||
thumbnailFile: null,
|
||||
type: '',
|
||||
blurhash: null,
|
||||
};
|
||||
Video.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
link: PropTypes.string.isRequired,
|
||||
thumbnail: PropTypes.string,
|
||||
thumbnailFile: PropTypes.shape({}),
|
||||
thumbnailType: PropTypes.string,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
file: PropTypes.shape({}),
|
||||
type: PropTypes.string,
|
||||
blurhash: PropTypes.string,
|
||||
};
|
||||
|
||||
export {
|
||||
File, Image, Sticker, Audio, Video,
|
||||
};
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
@use '../../partials/text';
|
||||
|
||||
.file-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--sp-ultra-tight) var(--sp-tight);
|
||||
min-height: 42px;
|
||||
|
||||
& .file-name {
|
||||
@extend .cp-txt__ellipsis;
|
||||
flex: 1;
|
||||
color: var(--tc-surface-low);
|
||||
}
|
||||
|
||||
& a {
|
||||
line-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.file-container {
|
||||
--media-max-width: 296px;
|
||||
|
||||
background-color: var(--bg-surface-hover);
|
||||
border-radius: calc(var(--bo-radius) / 2);
|
||||
overflow: hidden;
|
||||
max-width: var(--media-max-width);
|
||||
white-space: initial;
|
||||
}
|
||||
|
||||
.sticker-container {
|
||||
display: inline-flex;
|
||||
max-width: 128px;
|
||||
width: 100%;
|
||||
& img {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.image-container,
|
||||
.video-container,
|
||||
.audio-container {
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.image-container,
|
||||
.video-container {
|
||||
& img,
|
||||
& canvas {
|
||||
max-width: unset !important;
|
||||
width: 100% !important;
|
||||
height: 100%;
|
||||
border-radius: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
.image-container {
|
||||
max-height: 460px;
|
||||
img {
|
||||
cursor: pointer;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.video-container {
|
||||
position: relative;
|
||||
& .ic-btn-surface {
|
||||
background-color: var(--bg-surface-low);
|
||||
}
|
||||
& .ic-btn-surface,
|
||||
& .donut-spinner {
|
||||
position: absolute;
|
||||
}
|
||||
video {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.audio-container {
|
||||
audio {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,853 +0,0 @@
|
|||
/* eslint-disable react/prop-types */
|
||||
import React, {
|
||||
useState, useEffect, useCallback, useRef,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './Message.scss';
|
||||
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import {
|
||||
getUsername, getUsernameOfRoomMember, parseReply, trimHTMLReply,
|
||||
} from '../../../util/matrixUtil';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { getEventCords } from '../../../util/common';
|
||||
import { redactEvent, sendReaction } from '../../../client/action/roomTimeline';
|
||||
import {
|
||||
openEmojiBoard, openProfileViewer, openReadReceipts, openViewSource, replyTo,
|
||||
} from '../../../client/action/navigation';
|
||||
import { sanitizeCustomHtml } from '../../../util/sanitize';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import Tooltip from '../../atoms/tooltip/Tooltip';
|
||||
import Input from '../../atoms/input/Input';
|
||||
import Avatar from '../../atoms/avatar/Avatar';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Time from '../../atoms/time/Time';
|
||||
import ContextMenu, { MenuHeader, MenuItem, MenuBorder } from '../../atoms/context-menu/ContextMenu';
|
||||
import * as Media from '../media/Media';
|
||||
|
||||
import ReplyArrowIC from '../../../../public/res/ic/outlined/reply-arrow.svg';
|
||||
import EmojiAddIC from '../../../../public/res/ic/outlined/emoji-add.svg';
|
||||
import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
|
||||
import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
|
||||
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
|
||||
import CmdIC from '../../../../public/res/ic/outlined/cmd.svg';
|
||||
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
|
||||
|
||||
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
|
||||
import { getBlobSafeMimeType } from '../../../util/mimetypes';
|
||||
import { html, plain } from '../../../util/markdown';
|
||||
|
||||
function PlaceholderMessage() {
|
||||
return (
|
||||
<div className="ph-msg">
|
||||
<div className="ph-msg__avatar-container">
|
||||
<div className="ph-msg__avatar" />
|
||||
</div>
|
||||
<div className="ph-msg__main-container">
|
||||
<div className="ph-msg__header" />
|
||||
<div className="ph-msg__body">
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MessageAvatar = React.memo(({
|
||||
roomId, avatarSrc, userId, username,
|
||||
}) => (
|
||||
<div className="message__avatar-container">
|
||||
<button type="button" onClick={() => openProfileViewer(userId, roomId)}>
|
||||
<Avatar imageSrc={avatarSrc} text={username} bgColor={colorMXID(userId)} size="small" />
|
||||
</button>
|
||||
</div>
|
||||
));
|
||||
|
||||
const MessageHeader = React.memo(({
|
||||
userId, username, timestamp, fullTime,
|
||||
}) => (
|
||||
<div className="message__header">
|
||||
<Text
|
||||
style={{ color: colorMXID(userId) }}
|
||||
className="message__profile"
|
||||
variant="b1"
|
||||
weight="medium"
|
||||
span
|
||||
>
|
||||
<span>{twemojify(username)}</span>
|
||||
<span>{twemojify(userId)}</span>
|
||||
</Text>
|
||||
<div className="message__time">
|
||||
<Text variant="b3">
|
||||
<Time timestamp={timestamp} fullTime={fullTime} />
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
MessageHeader.defaultProps = {
|
||||
fullTime: false,
|
||||
};
|
||||
MessageHeader.propTypes = {
|
||||
userId: PropTypes.string.isRequired,
|
||||
username: PropTypes.string.isRequired,
|
||||
timestamp: PropTypes.number.isRequired,
|
||||
fullTime: PropTypes.bool,
|
||||
};
|
||||
|
||||
function MessageReply({ name, color, body }) {
|
||||
return (
|
||||
<div className="message__reply">
|
||||
<Text variant="b2">
|
||||
<RawIcon color={color} size="extra-small" src={ReplyArrowIC} />
|
||||
<span style={{ color }}>{twemojify(name)}</span>
|
||||
{' '}
|
||||
{twemojify(body)}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
MessageReply.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
color: PropTypes.string.isRequired,
|
||||
body: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
const MessageReplyWrapper = React.memo(({ roomTimeline, eventId }) => {
|
||||
const [reply, setReply] = useState(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const timelineSet = roomTimeline.getUnfilteredTimelineSet();
|
||||
const loadReply = async () => {
|
||||
try {
|
||||
const eTimeline = await mx.getEventTimeline(timelineSet, eventId);
|
||||
await roomTimeline.decryptAllEventsOfTimeline(eTimeline);
|
||||
|
||||
let mEvent = eTimeline.getTimelineSet().findEventById(eventId);
|
||||
const editedList = roomTimeline.editedTimeline.get(mEvent.getId());
|
||||
if (editedList) {
|
||||
mEvent = editedList[editedList.length - 1];
|
||||
}
|
||||
|
||||
const rawBody = mEvent.getContent().body;
|
||||
const username = getUsernameOfRoomMember(mEvent.sender);
|
||||
|
||||
if (isMountedRef.current === false) return;
|
||||
const fallbackBody = mEvent.isRedacted() ? '*** This message has been deleted ***' : '*** Unable to load reply ***';
|
||||
let parsedBody = parseReply(rawBody)?.body ?? rawBody ?? fallbackBody;
|
||||
if (editedList && parsedBody.startsWith(' * ')) {
|
||||
parsedBody = parsedBody.slice(3);
|
||||
}
|
||||
|
||||
setReply({
|
||||
to: username,
|
||||
color: colorMXID(mEvent.getSender()),
|
||||
body: parsedBody,
|
||||
event: mEvent,
|
||||
});
|
||||
} catch {
|
||||
setReply({
|
||||
to: '** Unknown user **',
|
||||
color: 'var(--tc-danger-normal)',
|
||||
body: '*** Unable to load reply ***',
|
||||
event: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
loadReply();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const focusReply = (ev) => {
|
||||
if (!ev.key || ev.key === ' ' || ev.key === 'Enter') {
|
||||
if (ev.key) ev.preventDefault();
|
||||
if (reply?.event === null) return;
|
||||
if (reply?.event.isRedacted()) return;
|
||||
roomTimeline.loadEventTimeline(eventId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="message__reply-wrapper"
|
||||
onClick={focusReply}
|
||||
onKeyDown={focusReply}
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
>
|
||||
{reply !== null && <MessageReply name={reply.to} color={reply.color} body={reply.body} />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
MessageReplyWrapper.propTypes = {
|
||||
roomTimeline: PropTypes.shape({}).isRequired,
|
||||
eventId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
const MessageBody = React.memo(({
|
||||
senderName,
|
||||
body,
|
||||
isCustomHTML,
|
||||
isEdited,
|
||||
msgType,
|
||||
}) => {
|
||||
// if body is not string it is a React element.
|
||||
if (typeof body !== 'string') return <div className="message__body">{body}</div>;
|
||||
|
||||
let content = null;
|
||||
if (isCustomHTML) {
|
||||
try {
|
||||
content = twemojify(
|
||||
sanitizeCustomHtml(initMatrix.matrixClient, body),
|
||||
undefined,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
} catch {
|
||||
console.error('Malformed custom html: ', body);
|
||||
content = twemojify(body, undefined);
|
||||
}
|
||||
} else {
|
||||
content = twemojify(body, undefined, true);
|
||||
}
|
||||
|
||||
// Determine if this message should render with large emojis
|
||||
// Criteria:
|
||||
// - Contains only emoji
|
||||
// - Contains no more than 10 emoji
|
||||
let emojiOnly = false;
|
||||
if (content.type === 'img') {
|
||||
// If this messages contains only a single (inline) image
|
||||
emojiOnly = true;
|
||||
} else if (content.constructor.name === 'Array') {
|
||||
// Otherwise, it might be an array of images / texb
|
||||
|
||||
// Count the number of emojis
|
||||
const nEmojis = content.filter((e) => e.type === 'img').length;
|
||||
|
||||
// Make sure there's no text besides whitespace and variation selector U+FE0F
|
||||
if (nEmojis <= 10 && content.every((element) => (
|
||||
(typeof element === 'object' && element.type === 'img')
|
||||
|| (typeof element === 'string' && /^[\s\ufe0f]*$/g.test(element))
|
||||
))) {
|
||||
emojiOnly = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCustomHTML) {
|
||||
// If this is a plaintext message, wrap it in a <p> element (automatically applying
|
||||
// white-space: pre-wrap) in order to preserve newlines
|
||||
content = (<p className="message__body-plain">{content}</p>);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="message__body">
|
||||
<div dir="auto" className={`text ${emojiOnly ? 'text-h1' : 'text-b1'}`}>
|
||||
{ msgType === 'm.emote' && (
|
||||
<>
|
||||
{'* '}
|
||||
{twemojify(senderName)}
|
||||
{' '}
|
||||
</>
|
||||
)}
|
||||
{ content }
|
||||
</div>
|
||||
{ isEdited && <Text className="message__body-edited" variant="b3">(edited)</Text>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
MessageBody.defaultProps = {
|
||||
isCustomHTML: false,
|
||||
isEdited: false,
|
||||
msgType: null,
|
||||
};
|
||||
MessageBody.propTypes = {
|
||||
senderName: PropTypes.string.isRequired,
|
||||
body: PropTypes.node.isRequired,
|
||||
isCustomHTML: PropTypes.bool,
|
||||
isEdited: PropTypes.bool,
|
||||
msgType: PropTypes.string,
|
||||
};
|
||||
|
||||
function MessageEdit({ body, onSave, onCancel }) {
|
||||
const editInputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// makes the cursor end up at the end of the line instead of the beginning
|
||||
editInputRef.current.value = '';
|
||||
editInputRef.current.value = body;
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && e.shiftKey === false) {
|
||||
e.preventDefault();
|
||||
onSave(editInputRef.current.value, body);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="message__edit" onSubmit={(e) => { e.preventDefault(); onSave(editInputRef.current.value, body); }}>
|
||||
<Input
|
||||
forwardRef={editInputRef}
|
||||
onKeyDown={handleKeyDown}
|
||||
value={body}
|
||||
placeholder="Edit message"
|
||||
required
|
||||
resizable
|
||||
autoFocus
|
||||
/>
|
||||
<div className="message__edit-btns">
|
||||
<Button type="submit" variant="primary">Save</Button>
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
MessageEdit.propTypes = {
|
||||
body: PropTypes.string.isRequired,
|
||||
onSave: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function getMyEmojiEvent(emojiKey, eventId, roomTimeline) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const rEvents = roomTimeline.reactionTimeline.get(eventId);
|
||||
let rEvent = null;
|
||||
rEvents?.find((rE) => {
|
||||
if (rE.getRelation() === null) return false;
|
||||
if (rE.getRelation().key === emojiKey && rE.getSender() === mx.getUserId()) {
|
||||
rEvent = rE;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return rEvent;
|
||||
}
|
||||
|
||||
function toggleEmoji(roomId, eventId, emojiKey, shortcode, roomTimeline) {
|
||||
const myAlreadyReactEvent = getMyEmojiEvent(emojiKey, eventId, roomTimeline);
|
||||
if (myAlreadyReactEvent) {
|
||||
const rId = myAlreadyReactEvent.getId();
|
||||
if (rId.startsWith('~')) return;
|
||||
redactEvent(roomId, rId);
|
||||
return;
|
||||
}
|
||||
sendReaction(roomId, eventId, emojiKey, shortcode);
|
||||
}
|
||||
|
||||
function pickEmoji(e, roomId, eventId, roomTimeline) {
|
||||
openEmojiBoard(getEventCords(e), (emoji) => {
|
||||
toggleEmoji(roomId, eventId, emoji.mxc ?? emoji.unicode, emoji.shortcodes[0], roomTimeline);
|
||||
e.target.click();
|
||||
});
|
||||
}
|
||||
|
||||
function genReactionMsg(userIds, reaction, shortcode) {
|
||||
return (
|
||||
<>
|
||||
{userIds.map((userId, index) => (
|
||||
<React.Fragment key={userId}>
|
||||
{twemojify(getUsername(userId))}
|
||||
{index < userIds.length - 1 && (
|
||||
<span style={{ opacity: '.6' }}>
|
||||
{index === userIds.length - 2 ? ' and ' : ', '}
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<span style={{ opacity: '.6' }}>{' reacted with '}</span>
|
||||
{twemojify(shortcode ? `:${shortcode}:` : reaction, { className: 'react-emoji' })}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageReaction({
|
||||
reaction, shortcode, count, users, isActive, onClick,
|
||||
}) {
|
||||
let customEmojiUrl = null;
|
||||
if (reaction.match(/^mxc:\/\/\S+$/)) {
|
||||
customEmojiUrl = initMatrix.matrixClient.mxcUrlToHttp(reaction);
|
||||
}
|
||||
return (
|
||||
<Tooltip
|
||||
className="msg__reaction-tooltip"
|
||||
content={<Text variant="b2">{users.length > 0 ? genReactionMsg(users, reaction, shortcode) : 'Unable to load who has reacted'}</Text>}
|
||||
>
|
||||
<button
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
className={`msg__reaction${isActive ? ' msg__reaction--active' : ''}`}
|
||||
>
|
||||
{
|
||||
customEmojiUrl
|
||||
? <img className="react-emoji" draggable="false" alt={shortcode ?? reaction} src={customEmojiUrl} />
|
||||
: twemojify(reaction, { className: 'react-emoji' })
|
||||
}
|
||||
<Text variant="b3" className="msg__reaction-count">{count}</Text>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
MessageReaction.defaultProps = {
|
||||
shortcode: undefined,
|
||||
};
|
||||
MessageReaction.propTypes = {
|
||||
reaction: PropTypes.node.isRequired,
|
||||
shortcode: PropTypes.string,
|
||||
count: PropTypes.number.isRequired,
|
||||
users: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
isActive: PropTypes.bool.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function MessageReactionGroup({ roomTimeline, mEvent }) {
|
||||
const { roomId, room, reactionTimeline } = roomTimeline;
|
||||
const mx = initMatrix.matrixClient;
|
||||
const reactions = {};
|
||||
const canSendReaction = room.currentState.maySendEvent('m.reaction', mx.getUserId());
|
||||
|
||||
const eventReactions = reactionTimeline.get(mEvent.getId());
|
||||
const addReaction = (key, shortcode, count, senderId, isActive) => {
|
||||
let reaction = reactions[key];
|
||||
if (reaction === undefined) {
|
||||
reaction = {
|
||||
count: 0,
|
||||
users: [],
|
||||
isActive: false,
|
||||
};
|
||||
}
|
||||
if (shortcode) reaction.shortcode = shortcode;
|
||||
if (count) {
|
||||
reaction.count = count;
|
||||
} else {
|
||||
reaction.users.push(senderId);
|
||||
reaction.count = reaction.users.length;
|
||||
if (isActive) reaction.isActive = isActive;
|
||||
}
|
||||
|
||||
reactions[key] = reaction;
|
||||
};
|
||||
if (eventReactions) {
|
||||
eventReactions.forEach((rEvent) => {
|
||||
if (rEvent.getRelation() === null) return;
|
||||
const reaction = rEvent.getRelation();
|
||||
const senderId = rEvent.getSender();
|
||||
const { shortcode } = rEvent.getContent();
|
||||
const isActive = senderId === mx.getUserId();
|
||||
|
||||
addReaction(reaction.key, shortcode, undefined, senderId, isActive);
|
||||
});
|
||||
} else {
|
||||
// Use aggregated reactions
|
||||
const aggregatedReaction = mEvent.getServerAggregatedRelation('m.annotation')?.chunk;
|
||||
if (!aggregatedReaction) return null;
|
||||
aggregatedReaction.forEach((reaction) => {
|
||||
if (reaction.type !== 'm.reaction') return;
|
||||
addReaction(reaction.key, undefined, reaction.count, undefined, false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="message__reactions text text-b3 noselect">
|
||||
{
|
||||
Object.keys(reactions).map((key) => (
|
||||
<MessageReaction
|
||||
key={key}
|
||||
reaction={key}
|
||||
shortcode={reactions[key].shortcode}
|
||||
count={reactions[key].count}
|
||||
users={reactions[key].users}
|
||||
isActive={reactions[key].isActive}
|
||||
onClick={() => {
|
||||
toggleEmoji(roomId, mEvent.getId(), key, reactions[key].shortcode, roomTimeline);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
{canSendReaction && (
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
pickEmoji(e, roomId, mEvent.getId(), roomTimeline);
|
||||
}}
|
||||
src={EmojiAddIC}
|
||||
size="extra-small"
|
||||
tooltip="Add reaction"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
MessageReactionGroup.propTypes = {
|
||||
roomTimeline: PropTypes.shape({}).isRequired,
|
||||
mEvent: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
function isMedia(mE) {
|
||||
return (
|
||||
mE.getContent()?.msgtype === 'm.file'
|
||||
|| mE.getContent()?.msgtype === 'm.image'
|
||||
|| mE.getContent()?.msgtype === 'm.audio'
|
||||
|| mE.getContent()?.msgtype === 'm.video'
|
||||
|| mE.getType() === 'm.sticker'
|
||||
);
|
||||
}
|
||||
|
||||
// if editedTimeline has mEventId then pass editedMEvent else pass mEvent to openViewSource
|
||||
function handleOpenViewSource(mEvent, roomTimeline) {
|
||||
const eventId = mEvent.getId();
|
||||
const { editedTimeline } = roomTimeline ?? {};
|
||||
let editedMEvent;
|
||||
if (editedTimeline?.has(eventId)) {
|
||||
const editedList = editedTimeline.get(eventId);
|
||||
editedMEvent = editedList[editedList.length - 1];
|
||||
}
|
||||
openViewSource(editedMEvent !== undefined ? editedMEvent : mEvent);
|
||||
}
|
||||
|
||||
const MessageOptions = React.memo(({
|
||||
roomTimeline, mEvent, edit, reply,
|
||||
}) => {
|
||||
const { roomId, room } = roomTimeline;
|
||||
const mx = initMatrix.matrixClient;
|
||||
const senderId = mEvent.getSender();
|
||||
|
||||
const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel;
|
||||
const canIRedact = room.currentState.hasSufficientPowerLevelFor('redact', myPowerlevel);
|
||||
const canSendReaction = room.currentState.maySendEvent('m.reaction', mx.getUserId());
|
||||
|
||||
return (
|
||||
<div className="message__options">
|
||||
{canSendReaction && (
|
||||
<IconButton
|
||||
onClick={(e) => pickEmoji(e, roomId, mEvent.getId(), roomTimeline)}
|
||||
src={EmojiAddIC}
|
||||
size="extra-small"
|
||||
tooltip="Add reaction"
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={() => reply()}
|
||||
src={ReplyArrowIC}
|
||||
size="extra-small"
|
||||
tooltip="Reply"
|
||||
/>
|
||||
{(senderId === mx.getUserId() && !isMedia(mEvent)) && (
|
||||
<IconButton
|
||||
onClick={() => edit(true)}
|
||||
src={PencilIC}
|
||||
size="extra-small"
|
||||
tooltip="Edit"
|
||||
/>
|
||||
)}
|
||||
<ContextMenu
|
||||
content={() => (
|
||||
<>
|
||||
<MenuHeader>Options</MenuHeader>
|
||||
<MenuItem
|
||||
iconSrc={TickMarkIC}
|
||||
onClick={() => openReadReceipts(roomId, roomTimeline.getEventReaders(mEvent))}
|
||||
>
|
||||
Read receipts
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
iconSrc={CmdIC}
|
||||
onClick={() => handleOpenViewSource(mEvent, roomTimeline)}
|
||||
>
|
||||
View source
|
||||
</MenuItem>
|
||||
{(canIRedact || senderId === mx.getUserId()) && (
|
||||
<>
|
||||
<MenuBorder />
|
||||
<MenuItem
|
||||
variant="danger"
|
||||
iconSrc={BinIC}
|
||||
onClick={async () => {
|
||||
const isConfirmed = await confirmDialog(
|
||||
'Delete message',
|
||||
'Are you sure that you want to delete this message?',
|
||||
'Delete',
|
||||
'danger',
|
||||
);
|
||||
if (!isConfirmed) return;
|
||||
redactEvent(roomId, mEvent.getId());
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
render={(toggleMenu) => (
|
||||
<IconButton
|
||||
onClick={toggleMenu}
|
||||
src={VerticalMenuIC}
|
||||
size="extra-small"
|
||||
tooltip="Options"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
MessageOptions.propTypes = {
|
||||
roomTimeline: PropTypes.shape({}).isRequired,
|
||||
mEvent: PropTypes.shape({}).isRequired,
|
||||
edit: PropTypes.func.isRequired,
|
||||
reply: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function genMediaContent(mE) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const mContent = mE.getContent();
|
||||
if (!mContent || !mContent.body) return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
|
||||
|
||||
let mediaMXC = mContent?.url;
|
||||
const isEncryptedFile = typeof mediaMXC === 'undefined';
|
||||
if (isEncryptedFile) mediaMXC = mContent?.file?.url;
|
||||
|
||||
let thumbnailMXC = mContent?.info?.thumbnail_url;
|
||||
|
||||
if (typeof mediaMXC === 'undefined' || mediaMXC === '') return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
|
||||
|
||||
let msgType = mE.getContent()?.msgtype;
|
||||
const safeMimetype = getBlobSafeMimeType(mContent.info?.mimetype);
|
||||
if (mE.getType() === 'm.sticker') {
|
||||
msgType = 'm.sticker';
|
||||
} else if (safeMimetype === 'application/octet-stream') {
|
||||
msgType = 'm.file';
|
||||
}
|
||||
|
||||
const blurhash = mContent?.info?.['xyz.amorgan.blurhash'];
|
||||
|
||||
switch (msgType) {
|
||||
case 'm.file':
|
||||
return (
|
||||
<Media.File
|
||||
name={mContent.body}
|
||||
link={mx.mxcUrlToHttp(mediaMXC)}
|
||||
type={mContent.info?.mimetype}
|
||||
file={mContent.file || null}
|
||||
/>
|
||||
);
|
||||
case 'm.image':
|
||||
return (
|
||||
<Media.Image
|
||||
name={mContent.body}
|
||||
width={typeof mContent.info?.w === 'number' ? mContent.info?.w : null}
|
||||
height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null}
|
||||
link={mx.mxcUrlToHttp(mediaMXC)}
|
||||
file={isEncryptedFile ? mContent.file : null}
|
||||
type={mContent.info?.mimetype}
|
||||
blurhash={blurhash}
|
||||
/>
|
||||
);
|
||||
case 'm.sticker':
|
||||
return (
|
||||
<Media.Sticker
|
||||
name={mContent.body}
|
||||
width={typeof mContent.info?.w === 'number' ? mContent.info?.w : null}
|
||||
height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null}
|
||||
link={mx.mxcUrlToHttp(mediaMXC)}
|
||||
file={isEncryptedFile ? mContent.file : null}
|
||||
type={mContent.info?.mimetype}
|
||||
/>
|
||||
);
|
||||
case 'm.audio':
|
||||
return (
|
||||
<Media.Audio
|
||||
name={mContent.body}
|
||||
link={mx.mxcUrlToHttp(mediaMXC)}
|
||||
type={mContent.info?.mimetype}
|
||||
file={mContent.file || null}
|
||||
/>
|
||||
);
|
||||
case 'm.video':
|
||||
if (typeof thumbnailMXC === 'undefined') {
|
||||
thumbnailMXC = mContent.info?.thumbnail_file?.url || null;
|
||||
}
|
||||
return (
|
||||
<Media.Video
|
||||
name={mContent.body}
|
||||
link={mx.mxcUrlToHttp(mediaMXC)}
|
||||
thumbnail={thumbnailMXC === null ? null : mx.mxcUrlToHttp(thumbnailMXC)}
|
||||
thumbnailFile={isEncryptedFile ? mContent.info?.thumbnail_file : null}
|
||||
thumbnailType={mContent.info?.thumbnail_info?.mimetype || null}
|
||||
width={typeof mContent.info?.w === 'number' ? mContent.info?.w : null}
|
||||
height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null}
|
||||
file={isEncryptedFile ? mContent.file : null}
|
||||
type={mContent.info?.mimetype}
|
||||
blurhash={blurhash}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
|
||||
}
|
||||
}
|
||||
|
||||
function getEditedBody(editedMEvent) {
|
||||
const newContent = editedMEvent.getContent()['m.new_content'];
|
||||
if (typeof newContent === 'undefined') return [null, false, null];
|
||||
|
||||
const isCustomHTML = newContent.format === 'org.matrix.custom.html';
|
||||
const parsedContent = parseReply(newContent.body);
|
||||
if (parsedContent === null) {
|
||||
return [newContent.body, isCustomHTML, newContent.formatted_body ?? null];
|
||||
}
|
||||
return [parsedContent.body, isCustomHTML, newContent.formatted_body ?? null];
|
||||
}
|
||||
|
||||
function Message({
|
||||
mEvent, isBodyOnly, roomTimeline,
|
||||
focus, fullTime, isEdit, setEdit, cancelEdit,
|
||||
}) {
|
||||
const roomId = mEvent.getRoomId();
|
||||
const { editedTimeline, reactionTimeline } = roomTimeline ?? {};
|
||||
|
||||
const className = ['message', (isBodyOnly ? 'message--body-only' : 'message--full')];
|
||||
if (focus) className.push('message--focus');
|
||||
const content = mEvent.getContent();
|
||||
const eventId = mEvent.getId();
|
||||
const msgType = content?.msgtype;
|
||||
const senderId = mEvent.getSender();
|
||||
let { body } = content;
|
||||
const username = mEvent.sender ? getUsernameOfRoomMember(mEvent.sender) : getUsername(senderId);
|
||||
const avatarSrc = mEvent.sender?.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop') ?? null;
|
||||
let isCustomHTML = content.format === 'org.matrix.custom.html';
|
||||
let customHTML = isCustomHTML ? content.formatted_body : null;
|
||||
|
||||
const edit = useCallback(() => {
|
||||
setEdit(eventId);
|
||||
}, []);
|
||||
const reply = useCallback(() => {
|
||||
replyTo(senderId, mEvent.getId(), body, customHTML);
|
||||
}, [body, customHTML]);
|
||||
|
||||
if (msgType === 'm.emote') className.push('message--type-emote');
|
||||
|
||||
const isEdited = roomTimeline ? editedTimeline.has(eventId) : false;
|
||||
const haveReactions = roomTimeline
|
||||
? reactionTimeline.has(eventId) || !!mEvent.getServerAggregatedRelation('m.annotation')
|
||||
: false;
|
||||
const isReply = !!mEvent.replyEventId;
|
||||
|
||||
if (isEdited) {
|
||||
const editedList = editedTimeline.get(eventId);
|
||||
const editedMEvent = editedList[editedList.length - 1];
|
||||
[body, isCustomHTML, customHTML] = getEditedBody(editedMEvent);
|
||||
}
|
||||
|
||||
if (isReply) {
|
||||
body = parseReply(body)?.body ?? body;
|
||||
customHTML = trimHTMLReply(customHTML);
|
||||
}
|
||||
|
||||
if (typeof body !== 'string') body = '';
|
||||
|
||||
return (
|
||||
<div className={className.join(' ')}>
|
||||
{
|
||||
isBodyOnly
|
||||
? <div className="message__avatar-container" />
|
||||
: (
|
||||
<MessageAvatar
|
||||
roomId={roomId}
|
||||
avatarSrc={avatarSrc}
|
||||
userId={senderId}
|
||||
username={username}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<div className="message__main-container">
|
||||
{!isBodyOnly && (
|
||||
<MessageHeader
|
||||
userId={senderId}
|
||||
username={username}
|
||||
timestamp={mEvent.getTs()}
|
||||
fullTime={fullTime}
|
||||
/>
|
||||
)}
|
||||
{roomTimeline && isReply && (
|
||||
<MessageReplyWrapper
|
||||
roomTimeline={roomTimeline}
|
||||
eventId={mEvent.replyEventId}
|
||||
/>
|
||||
)}
|
||||
{!isEdit && (
|
||||
<MessageBody
|
||||
senderName={username}
|
||||
isCustomHTML={isCustomHTML}
|
||||
body={isMedia(mEvent) ? genMediaContent(mEvent) : customHTML ?? body}
|
||||
msgType={msgType}
|
||||
isEdited={isEdited}
|
||||
/>
|
||||
)}
|
||||
{isEdit && (
|
||||
<MessageEdit
|
||||
body={(customHTML
|
||||
? html(customHTML, { kind: 'edit', onlyPlain: true }).plain
|
||||
: plain(body, { kind: 'edit', onlyPlain: true }).plain)}
|
||||
onSave={(newBody, oldBody) => {
|
||||
if (newBody !== oldBody) {
|
||||
initMatrix.roomsInput.sendEditedMessage(roomId, mEvent, newBody);
|
||||
}
|
||||
cancelEdit();
|
||||
}}
|
||||
onCancel={cancelEdit}
|
||||
/>
|
||||
)}
|
||||
{haveReactions && (
|
||||
<MessageReactionGroup roomTimeline={roomTimeline} mEvent={mEvent} />
|
||||
)}
|
||||
{roomTimeline && !isEdit && (
|
||||
<MessageOptions
|
||||
roomTimeline={roomTimeline}
|
||||
mEvent={mEvent}
|
||||
edit={edit}
|
||||
reply={reply}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Message.defaultProps = {
|
||||
isBodyOnly: false,
|
||||
focus: false,
|
||||
roomTimeline: null,
|
||||
fullTime: false,
|
||||
isEdit: false,
|
||||
setEdit: null,
|
||||
cancelEdit: null,
|
||||
};
|
||||
Message.propTypes = {
|
||||
mEvent: PropTypes.shape({}).isRequired,
|
||||
isBodyOnly: PropTypes.bool,
|
||||
roomTimeline: PropTypes.shape({}),
|
||||
focus: PropTypes.bool,
|
||||
fullTime: PropTypes.bool,
|
||||
isEdit: PropTypes.bool,
|
||||
setEdit: PropTypes.func,
|
||||
cancelEdit: PropTypes.func,
|
||||
};
|
||||
|
||||
export { Message, MessageReply, PlaceholderMessage };
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue