add reply, read receipt, source delete opt

This commit is contained in:
Ajay Bura 2023-09-29 16:54:42 +05:30
parent e91d6d80e1
commit b62f1cc1ca
15 changed files with 823 additions and 207 deletions

8
package-lock.json generated
View file

@ -29,7 +29,7 @@
"file-saver": "2.0.5",
"flux": "4.0.3",
"focus-trap-react": "10.0.2",
"folds": "1.4.1",
"folds": "1.5.0",
"formik": "2.2.9",
"html-react-parser": "4.2.0",
"immer": "9.0.16",
@ -3429,9 +3429,9 @@
}
},
"node_modules/folds": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/folds/-/folds-1.4.1.tgz",
"integrity": "sha512-VEgE4nUl9Xxv60Q+LeB1n5Wgy0NNKs4i+My6GzSSGI2d1P7xKJXlw4azPPOT0Lk8BSJqnu9eJCtO3elQQE60oQ==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/folds/-/folds-1.5.0.tgz",
"integrity": "sha512-1QNHzD57OxFZT5SOe0nWcrKQvWmfMRv1f5sTF8xhGtwx9rajjv36T9SwCcj9Fh58PbERqOdBiwvpdhu+BQTVjg==",
"peerDependencies": {
"@vanilla-extract/css": "^1.9.2",
"@vanilla-extract/recipes": "^0.3.0",

View file

@ -39,7 +39,7 @@
"file-saver": "2.0.5",
"flux": "4.0.3",
"focus-trap-react": "10.0.2",
"folds": "1.4.1",
"folds": "1.5.0",
"formik": "2.2.9",
"html-react-parser": "4.2.0",
"immer": "9.0.16",

View file

@ -20,7 +20,7 @@ export const Time = as<'span', TimeProps>(({ compact, ts, ...props }, ref) => {
}
return (
<Text style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}>
<Text as="time" style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}>
{time}
</Text>
);

View file

@ -26,3 +26,7 @@ export const EventBase = as<'div', css.EventBaseVariants>(
export const AvatarBase = as<'span'>(({ className, ...props }, ref) => (
<span className={classNames(css.AvatarBase, className)} {...props} ref={ref} />
));
export const Username = as<'span'>(({ as: AsUsername = 'span', className, ...props }, ref) => (
<AsUsername className={classNames(css.Username, className)} {...props} ref={ref} />
));

View file

@ -142,3 +142,12 @@ export const BubbleContent = style({
color: color.SurfaceVariant.OnContainer,
borderRadius: config.radii.R400,
});
export const Username = style({
cursor: 'pointer',
selectors: {
'&:hover, &:focus-visible': {
textDecoration: 'underline',
},
},
});

View file

@ -31,9 +31,9 @@ interface IPowerLevels {
notifications?: Record<string, number>;
}
export type GetPowerLevel = (userId: string) => void;
export type CanSend = (eventType: string | undefined, powerLevel: number) => void;
export type CanDoAction = (action: PowerLevelActions, powerLevel: number) => void;
export type GetPowerLevel = (userId: string) => number;
export type CanSend = (eventType: string | undefined, powerLevel: number) => boolean;
export type CanDoAction = (action: PowerLevelActions, powerLevel: number) => boolean;
export type PowerLevelsAPI = {
getPowerLevel: GetPowerLevel;

View file

@ -34,7 +34,6 @@ import to from 'await-to-js';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import {
CustomEditor,
useEditor,
Toolbar,
toMatrixCustomHTML,
toPlainText,
@ -101,13 +100,13 @@ import { sanitizeText } from '../../utils/sanitize';
import { useScreenSize } from '../../hooks/useScreenSize';
interface RoomInputProps {
editor: Editor;
roomViewRef: RefObject<HTMLElement>;
roomId: string;
}
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
({ roomViewRef, roomId }, ref) => {
({ editor, roomViewRef, roomId }, ref) => {
const mx = useMatrixClient();
const editor = useEditor();
const room = mx.getRoom(roomId);
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));

View file

@ -25,11 +25,11 @@ import {
} from 'matrix-js-sdk';
import parse, { HTMLReactParserOptions } from 'html-react-parser';
import classNames from 'classnames';
import { ReactEditor } from 'slate-react';
import { Editor } from 'slate';
import to from 'await-to-js';
import { useSetAtom } from 'jotai';
import {
Avatar,
AvatarFallback,
AvatarImage,
Badge,
Box,
Chip,
@ -46,16 +46,12 @@ import {
} from 'folds';
import Linkify from 'linkify-react';
import { decryptFile, getMxIdLocalPart, matrixEventByRecency } from '../../utils/matrix';
import colorMXID from '../../../util/colorMXID';
import { sanitizeCustomHtml } from '../../utils/sanitize';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
import { useAlive } from '../../hooks/useAlive';
import { scrollToBottom } from '../../utils/dom';
import {
ModernLayout,
CompactLayout,
BubbleLayout,
DefaultPlaceholder,
CompactPlaceholder,
Reply,
@ -70,15 +66,10 @@ import {
AttachmentContent,
AttachmentHeader,
EventBase,
AvatarBase,
Time,
} from '../../components/message';
import { LINKIFY_OPTS, getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser';
import {
decryptAllTimelineEvent,
getMemberAvatarMxc,
getMemberDisplayName,
} from '../../utils/room';
import { decryptAllTimelineEvent, getMemberDisplayName } from '../../utils/room';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { openProfileViewer } from '../../../client/action/navigation';
@ -96,6 +87,7 @@ import {
AudioContent,
Reactions,
EventContent,
Message,
} from './message';
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
import * as customHtmlCss from '../../styles/CustomHtml.css';
@ -110,6 +102,9 @@ import { useDebounce } from '../../hooks/useDebounce';
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
import * as css from './RoomTimeline.css';
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
import { createMentionElement, moveCursor } from '../../components/editor';
import { roomIdToReplyDraftAtomFamily } from '../../state/roomInputDrafts';
import { usePowerLevelsAPI } from '../../hooks/usePowerLevels';
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => (
@ -251,6 +246,7 @@ type RoomTimelineProps = {
room: Room;
eventId?: string;
roomInputRef: RefObject<HTMLElement>;
editor: Editor;
};
const PAGINATION_LIMIT = 80;
@ -434,12 +430,15 @@ const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
};
};
export function RoomTimeline({ room, eventId, roomInputRef }: RoomTimelineProps) {
export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
const mx = useMatrixClient();
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
const { canDoAction, getPowerLevel } = usePowerLevelsAPI();
const canRedact = canDoAction('redact', getPowerLevel(mx.getUserId() ?? ''));
const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
const readUptoEventIdRef = useRef<string>();
@ -695,7 +694,7 @@ export function RoomTimeline({ room, eventId, roomInputRef }: RoomTimelineProps)
setUnreadInfo(undefined);
};
const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
const handleOpenReply: MouseEventHandler<HTMLButtonElement> = useCallback(
async (evt) => {
const replyId = evt.currentTarget.getAttribute('data-reply-id');
if (typeof replyId !== 'string') return;
@ -723,13 +722,65 @@ export function RoomTimeline({ room, eventId, roomInputRef }: RoomTimelineProps)
[room, timeline, scrollToItem, loadEventTimeline, forceUpdate]
);
const handleAvatarClick: MouseEventHandler<HTMLButtonElement> = useCallback(
const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt) => {
const avatarId = evt.currentTarget.getAttribute('data-avatar-id');
openProfileViewer(avatarId, room.roomId);
evt.preventDefault();
const userId = evt.currentTarget.getAttribute('data-user-id');
if (!userId) {
console.warn('Button should have "data-user-id" attribute!');
return;
}
openProfileViewer(userId, room.roomId);
},
[room]
);
const handleUsernameClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt) => {
evt.preventDefault();
const userId = evt.currentTarget.getAttribute('data-user-id');
if (!userId) {
console.warn('Button should have "data-user-id" attribute!');
return;
}
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
editor.insertNode(
createMentionElement(
userId,
name.startsWith('@') ? name : `@${name}`,
userId === mx.getUserId()
)
);
ReactEditor.focus(editor);
moveCursor(editor);
},
[mx, room, editor]
);
const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt) => {
const replyId = evt.currentTarget.getAttribute('data-event-id');
if (!replyId) {
console.warn('Button should have "data-event-id" attribute!');
return;
}
const evtTimeline = room.getTimelineForEvent(replyId);
const replyEvt = evtTimeline?.getEvents().find((ev) => ev.getId() === replyId);
if (!replyEvt) return;
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
const { body, formatted_body: formattedBody }: Record<string, string> =
editedReply?.getContent()['m.new.content'] ?? replyEvt.getContent();
const senderId = replyEvt.getSender();
if (senderId && typeof body === 'string') {
setReplyDraft({
userId: senderId,
eventId: replyId,
body,
formattedBody,
});
}
},
[room, setReplyDraft]
);
const renderBody = (body: string, customBody?: string) => {
if (body === '') <MessageEmptyContent />;
@ -960,155 +1011,56 @@ export function RoomTimeline({ room, eventId, roomInputRef }: RoomTimelineProps)
const renderMatrixEvent = useMatrixEventRenderer<[number, EventTimelineSet, boolean]>({
renderRoomMessage: (mEventId, mEvent, item, timelineSet, collapse) => {
const reactions = getEventReactions(timelineSet, mEventId);
const { replyEventId } = mEvent;
// FIXME: Fix encrypted msg not returning body
const senderId = mEvent.getSender() ?? '';
const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
const headerJSX = !collapse && (
<Box
gap="300"
direction={messageLayout === 1 ? 'RowReverse' : 'Row'}
justifyContent="SpaceBetween"
alignItems="Baseline"
grow="Yes"
>
<Text
size={messageLayout === 2 ? 'T300' : 'T400'}
style={{ color: colorMXID(senderId) }}
truncate
>
<b>{senderDisplayName}</b>
</Text>
<Time ts={mEvent.getTs()} compact={messageLayout === 1} />
</Box>
);
const avatarJSX = !collapse && messageLayout !== 1 && (
<AvatarBase>
<Avatar size="300" data-avatar-id={senderId} onClick={handleAvatarClick}>
{senderAvatarMxc ? (
<AvatarImage
src={mx.mxcUrlToHttp(senderAvatarMxc, 48, 48, 'crop') ?? senderAvatarMxc}
/>
) : (
<AvatarFallback
style={{
background: colorMXID(senderId),
color: 'white',
}}
>
<Text size="H4">{senderDisplayName[0]}</Text>
</AvatarFallback>
)}
</Avatar>
</AvatarBase>
);
const msgContentJSX = (
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
{replyEventId && (
<Reply
as="button"
mx={mx}
room={room}
timelineSet={timelineSet}
eventId={replyEventId}
data-reply-id={replyEventId}
onClick={handleReplyClick}
/>
)}
{renderRoomMsgContent(mEventId, mEvent, timelineSet)}
{reactions && (
<Reactions
style={{
margin: `${config.space.S200} 0 ${messageLayout === 2 ? 0 : config.space.S100}`,
}}
room={room}
relations={reactions}
/>
)}
</Box>
);
return (
<MessageBase
<Message
key={mEvent.getId()}
data-message-item={item}
space={messageSpacing}
room={room}
mEvent={mEvent}
messageSpacing={messageSpacing}
messageLayout={messageLayout}
collapse={collapse}
highlight={highlighted}
canDelete={!mEvent.isRedacted() && (canRedact || mEvent.getSender() === mx.getUserId())}
onUserClick={handleUserClick}
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
reply={
replyEventId && (
<Reply
as="button"
mx={mx}
room={room}
timelineSet={timelineSet}
eventId={replyEventId}
data-reply-id={replyEventId}
onClick={handleOpenReply}
/>
)
}
reactions={
reactions && (
<Reactions
style={{
margin: `${config.space.S200} 0 ${messageLayout === 2 ? 0 : config.space.S100}`,
}}
room={room}
relations={reactions}
/>
)
}
>
{messageLayout === 1 && <CompactLayout before={headerJSX}>{msgContentJSX}</CompactLayout>}
{messageLayout === 2 && (
<BubbleLayout before={avatarJSX}>
{headerJSX}
{msgContentJSX}
</BubbleLayout>
)}
{messageLayout !== 1 && messageLayout !== 2 && (
<ModernLayout before={avatarJSX}>
{headerJSX}
{msgContentJSX}
</ModernLayout>
)}
</MessageBase>
{renderRoomMsgContent(mEventId, mEvent, timelineSet)}
</Message>
);
},
renderSticker: (mEventId, mEvent, item, timelineSet) => {
renderSticker: (mEventId, mEvent, item, timelineSet, collapse) => {
const reactions = getEventReactions(timelineSet, mEventId);
const senderId = mEvent.getSender() ?? '';
const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
const headerJSX = (
<Box
gap="300"
direction={messageLayout === 1 ? 'RowReverse' : 'Row'}
justifyContent="SpaceBetween"
alignItems="Baseline"
grow="Yes"
>
<Text
size={messageLayout === 2 ? 'T300' : 'T400'}
style={{ color: colorMXID(senderId) }}
truncate
>
<b>{senderDisplayName}</b>
</Text>
<Time ts={mEvent.getTs()} compact={messageLayout === 1} />
</Box>
);
const avatarJSX = messageLayout !== 1 && (
<AvatarBase>
<Avatar size="300" data-avatar-id={senderId} onClick={handleAvatarClick}>
{senderAvatarMxc ? (
<AvatarImage
src={mx.mxcUrlToHttp(senderAvatarMxc, 48, 48, 'crop') ?? senderAvatarMxc}
/>
) : (
<AvatarFallback
style={{
background: colorMXID(senderId),
color: 'white',
}}
>
<Text size="H4">{senderDisplayName[0]}</Text>
</AvatarFallback>
)}
</Avatar>
</AvatarBase>
);
const content = mEvent.getContent<IImageContent>();
const imgInfo = content?.info;
const mxcUrl = content.file?.url ?? content.url;
@ -1116,8 +1068,33 @@ export function RoomTimeline({ room, eventId, roomInputRef }: RoomTimelineProps)
return null;
}
const height = scaleYDimension(imgInfo.w || 152, 152, imgInfo.h || 152);
const msgContentJSX = (
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
return (
<Message
key={mEvent.getId()}
data-message-item={item}
room={room}
mEvent={mEvent}
messageSpacing={messageSpacing}
messageLayout={messageLayout}
collapse={collapse}
highlight={highlighted}
canDelete={!mEvent.isRedacted() && (canRedact || mEvent.getSender() === mx.getUserId())}
onUserClick={handleUserClick}
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
reactions={
reactions && (
<Reactions
style={{
margin: `${config.space.S200} 0 ${messageLayout === 2 ? 0 : config.space.S100}`,
}}
room={room}
relations={reactions}
/>
)
}
>
<AttachmentBox
style={{
height: toRem(height < 48 ? 48 : height),
@ -1133,40 +1110,7 @@ export function RoomTimeline({ room, eventId, roomInputRef }: RoomTimelineProps)
encInfo={content.file}
/>
</AttachmentBox>
{reactions && (
<Reactions
style={{
margin: `${config.space.S200} 0 ${messageLayout === 2 ? 0 : config.space.S100}`,
}}
room={room}
relations={reactions}
/>
)}
</Box>
);
return (
<MessageBase
key={mEvent.getId()}
data-message-item={item}
space={messageSpacing}
highlight={highlighted}
>
{messageLayout === 1 && <CompactLayout before={headerJSX}>{msgContentJSX}</CompactLayout>}
{messageLayout === 2 && (
<BubbleLayout before={avatarJSX}>
{headerJSX}
{msgContentJSX}
</BubbleLayout>
)}
{messageLayout !== 1 && messageLayout !== 2 && (
<ModernLayout before={avatarJSX}>
{headerJSX}
{msgContentJSX}
</ModernLayout>
)}
</MessageBase>
</Message>
);
},
renderRoomMember: (mEventId, mEvent, item) => {

View file

@ -18,6 +18,7 @@ import { RoomInputPlaceholder } from './RoomInputPlaceholder';
import { RoomTimeline } from './RoomTimeline';
import { RoomViewTyping } from './RoomViewTyping';
import { RoomViewFollowing } from './RoomViewFollowing';
import { useEditor } from '../../components/editor';
function RoomView({ room, eventId }) {
const roomInputRef = useRef(null);
@ -25,6 +26,7 @@ function RoomView({ room, eventId }) {
// eslint-disable-next-line react/prop-types
const { roomId } = room;
const editor = useEditor();
const mx = useMatrixClient();
const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
@ -58,7 +60,13 @@ function RoomView({ room, eventId }) {
<RoomViewHeader roomId={roomId} />
<div className="room-view__content-wrapper">
<div className="room-view__scrollable">
<RoomTimeline key={roomId} room={room} eventId={eventId} roomInputRef={roomInputRef} />
<RoomTimeline
key={roomId}
room={room}
eventId={eventId}
roomInputRef={roomInputRef}
editor={editor}
/>
<RoomViewTyping room={room} />
</div>
<div className="room-view__sticky">
@ -72,7 +80,12 @@ function RoomView({ room, eventId }) {
) : (
<>
{canMessage && (
<RoomInput roomId={roomId} roomViewRef={roomViewRef} ref={roomInputRef} />
<RoomInput
editor={editor}
roomId={roomId}
roomViewRef={roomViewRef}
ref={roomInputRef}
/>
)}
{!canMessage && (
<RoomInputPlaceholder

View file

@ -0,0 +1,610 @@
import {
Avatar,
AvatarFallback,
AvatarImage,
Box,
Button,
Dialog,
Header,
Icon,
IconButton,
Icons,
Input,
Line,
Menu,
MenuItem,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
PopOut,
Spinner,
Text,
as,
color,
config,
} from 'folds';
import React, {
FormEventHandler,
MouseEventHandler,
ReactNode,
useCallback,
useState,
} from 'react';
import FocusTrap from 'focus-trap-react';
import { MatrixEvent, Room } from 'matrix-js-sdk';
import classNames from 'classnames';
import {
AvatarBase,
BubbleLayout,
CompactLayout,
MessageBase,
ModernLayout,
Time,
Username,
} from '../../../components/message';
import colorMXID from '../../../../util/colorMXID';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../../utils/room';
import { getMxIdLocalPart } from '../../../utils/matrix';
import { MessageLayout, MessageSpacing } from '../../../state/settings';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import * as css from './styles.css';
import { EventReaders } from '../../../components/event-readers';
import { TextViewer } from '../../../components/text-viewer';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
type MessageQuickReactionsProps = {
onReaction: ReactionHandler;
};
export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
({ onReaction, ...props }, ref) => {
const mx = useMatrixClient();
const recentEmojis = useRecentEmoji(mx, 4);
if (recentEmojis.length === 0) return <span />;
return (
<>
<Box
style={{ padding: config.space.S200 }}
alignItems="Center"
justifyContent="Center"
gap="200"
{...props}
ref={ref}
>
{recentEmojis.map((emoji) => (
<IconButton
key={emoji.unicode}
className={css.MessageQuickReaction}
size="300"
variant="SurfaceVariant"
radii="Pill"
title={emoji.shortcode}
aria-label={emoji.shortcode}
onClick={() => onReaction(emoji.unicode, emoji.shortcode)}
>
<Text size="T500">{emoji.unicode}</Text>
</IconButton>
))}
</Box>
<Line size="300" />
</>
);
}
);
export const MessageReadReceiptItem = as<
'button',
{
room: Room;
eventId: string;
onClose?: () => void;
}
>(({ room, eventId, onClose, ...props }, ref) => {
const [open, setOpen] = useState(false);
const handleClose = () => {
setOpen(false);
onClose?.();
};
return (
<>
<Overlay open={open} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleClose,
clickOutsideDeactivates: true,
}}
>
<Modal variant="Surface" size="300">
<EventReaders room={room} eventId={eventId} requestClose={handleClose} />
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
<MenuItem
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
onClick={() => setOpen(true)}
{...props}
ref={ref}
aria-pressed={open}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Read Receipts
</Text>
</MenuItem>
</>
);
});
export const MessageSourceCodeItem = as<
'button',
{
mEvent: MatrixEvent;
onClose?: () => void;
}
>(({ mEvent, onClose, ...props }, ref) => {
const [open, setOpen] = useState(false);
const text = JSON.stringify(
mEvent.isEncrypted()
? {
[`<== DECRYPTED_EVENT ==>`]: mEvent.getEffectiveEvent(),
[`<== ENCRYPTED_EVENT ==>`]: mEvent.event,
}
: mEvent.event,
null,
2
);
const handleClose = () => {
setOpen(false);
onClose?.();
};
return (
<>
<Overlay open={open} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleClose,
clickOutsideDeactivates: true,
}}
>
<Modal variant="Surface" size="500">
<TextViewer
name="Source Code"
mimeType="application/json"
text={text}
requestClose={handleClose}
/>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
<MenuItem
size="300"
after={<Icon size="100" src={Icons.BlockCode} />}
radii="300"
onClick={() => setOpen(true)}
{...props}
ref={ref}
aria-pressed={open}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
View Source
</Text>
</MenuItem>
</>
);
});
export const MessageDeleteItem = as<
'button',
{
room: Room;
mEvent: MatrixEvent;
onClose?: () => void;
}
>(({ room, mEvent, onClose, ...props }, ref) => {
const mx = useMatrixClient();
const [open, setOpen] = useState(false);
const [deleteState, deleteMessage] = useAsyncCallback(
useCallback(
(eventId: string, reason?: string) =>
mx.redactEvent(room.roomId, eventId, undefined, reason ? { reason } : undefined),
[mx, room]
)
);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const eventId = mEvent.getId();
if (!eventId || deleteState.status === AsyncStatus.Loading) return;
const target = evt.target as HTMLFormElement | undefined;
const reasonInput = target?.reasonInput as HTMLInputElement | undefined;
const reason = reasonInput && reasonInput.value.trim();
deleteMessage(eventId, reason);
};
const handleClose = () => {
setOpen(false);
onClose?.();
};
return (
<>
<Overlay open={open} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleClose,
clickOutsideDeactivates: true,
}}
>
<Dialog variant="Surface">
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Delete Message</Text>
</Box>
<IconButton size="300" onClick={handleClose} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box
as="form"
onSubmit={handleSubmit}
style={{ padding: config.space.S400 }}
direction="Column"
gap="400"
>
<Text priority="400">
This action is irreversible! Are you sure that you want to delete this message?
</Text>
<Box direction="Column" gap="100">
<Text size="L400">
Reason{' '}
<Text as="span" size="T200">
(optional)
</Text>
</Text>
<Input name="reasonInput" variant="Background" outlined />
{deleteState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
Failed to delete message! Please try again.
</Text>
)}
</Box>
<Button
type="submit"
variant="Critical"
before={
deleteState.status === AsyncStatus.Loading ? (
<Spinner fill="Soft" variant="Critical" size="200" />
) : undefined
}
aria-disabled={deleteState.status === AsyncStatus.Loading}
>
<Text size="B400">
{deleteState.status === AsyncStatus.Loading ? 'Deleting...' : 'Delete'}
</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
<Button
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.Delete} />}
radii="300"
onClick={() => setOpen(true)}
{...props}
ref={ref}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Delete
</Text>
</Button>
</>
);
});
export type MessageProps = {
room: Room;
mEvent: MatrixEvent;
collapse: boolean;
highlight: boolean;
canDelete?: boolean;
messageLayout: MessageLayout;
messageSpacing: MessageSpacing;
onUserClick: MouseEventHandler<HTMLButtonElement>;
onUsernameClick: MouseEventHandler<HTMLButtonElement>;
onReplyClick: MouseEventHandler<HTMLButtonElement>;
reply?: ReactNode;
reactions?: ReactNode;
};
export const Message = as<'div', MessageProps>(
(
{
className,
room,
mEvent,
collapse,
highlight,
canDelete,
messageLayout,
messageSpacing,
onUserClick,
onUsernameClick,
onReplyClick,
reply,
reactions,
children,
...props
},
ref
) => {
const mx = useMatrixClient();
const senderId = mEvent.getSender() ?? '';
const [hover, setHover] = useState(false);
const [menu, setMenu] = useState(false);
const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
const headerJSX = !collapse && (
<Box
gap="300"
direction={messageLayout === 1 ? 'RowReverse' : 'Row'}
justifyContent="SpaceBetween"
alignItems="Baseline"
grow="Yes"
>
<Username
as="button"
style={{ color: colorMXID(senderId) }}
data-user-id={senderId}
onContextMenu={onUserClick}
onClick={onUsernameClick}
>
<Text as="span" size={messageLayout === 2 ? 'T300' : 'T400'} truncate>
<b>{senderDisplayName}</b>
</Text>
</Username>
<Box gap="100">
{messageLayout !== 1 && hover && (
<>
<Text as="span" size="T200" priority="300">
{senderId}
</Text>
<Text as="span" size="T200" priority="300">
|
</Text>
</>
)}
<Time ts={mEvent.getTs()} compact={messageLayout === 1} />
</Box>
</Box>
);
const avatarJSX = !collapse && messageLayout !== 1 && (
<AvatarBase>
<Avatar as="button" size="300" data-user-id={senderId} onClick={onUserClick}>
{senderAvatarMxc ? (
<AvatarImage
src={mx.mxcUrlToHttp(senderAvatarMxc, 48, 48, 'crop') ?? senderAvatarMxc}
/>
) : (
<AvatarFallback
style={{
background: colorMXID(senderId),
color: 'white',
}}
>
<Text size="H4">{senderDisplayName[0]}</Text>
</AvatarFallback>
)}
</Avatar>
</AvatarBase>
);
const msgContentJSX = (
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
{reply}
{children}
{reactions}
</Box>
);
const showOptions = () => setHover(true);
const hideOptions = () => setHover(false);
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
const tag = (evt.target as any).tagName;
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
evt.preventDefault();
setMenu(true);
};
const closeMenu = () => {
setMenu(false);
};
return (
<MessageBase
className={classNames(css.MessageBase, className)}
tabIndex={0}
space={messageSpacing}
collapse={collapse}
highlight={highlight}
{...props}
onMouseEnter={showOptions}
onMouseLeave={hideOptions}
ref={ref}
>
{(hover || menu) && (
<div className={css.MessageOptionsBase}>
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
<Box gap="100">
<IconButton variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.SmilePlus} size="100" />
</IconButton>
<IconButton
onClick={onReplyClick}
data-event-id={mEvent.getId()}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.ReplyArrow} size="100" />
</IconButton>
<PopOut
open={menu}
alignOffset={-5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenu(false),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
}}
>
<Menu {...props} ref={ref}>
<MessageQuickReactions
onReaction={(a, b) => {
alert(`Work in Progress! ${a}: ${b}`);
closeMenu();
}}
/>
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
<MenuItem
size="300"
after={<Icon size="100" src={Icons.SmilePlus} />}
radii="300"
onClick={() => alert('Work in Progress!')}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
Add Reaction
</Text>
</MenuItem>
<MenuItem
size="300"
after={<Icon size="100" src={Icons.ReplyArrow} />}
radii="300"
data-event-id={mEvent.getId()}
onClick={(evt: any) => {
onReplyClick(evt);
closeMenu();
}}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
Reply
</Text>
</MenuItem>
<MessageReadReceiptItem
room={room}
eventId={mEvent.getId() ?? ''}
onClose={closeMenu}
/>
<MessageSourceCodeItem mEvent={mEvent} onClose={closeMenu} />
</Box>
<Line size="300" />
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{canDelete && <MessageDeleteItem room={room} mEvent={mEvent} />}
<Button
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.Warning} />}
radii="300"
onClick={() => alert('Work in Progress!')}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
Report
</Text>
</Button>
</Box>
</Menu>
</FocusTrap>
}
>
{(targetRef) => (
<IconButton
ref={targetRef}
variant="SurfaceVariant"
size="300"
radii="300"
onClick={() => setMenu((v) => !v)}
aria-pressed={menu}
>
<Icon src={Icons.VerticalDots} size="100" />
</IconButton>
)}
</PopOut>
</Box>
</Menu>
</div>
)}
{messageLayout === 1 && (
<CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
{msgContentJSX}
</CompactLayout>
)}
{messageLayout === 2 && (
<BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}>
{headerJSX}
{msgContentJSX}
</BubbleLayout>
)}
{messageLayout !== 1 && messageLayout !== 2 && (
<ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
{headerJSX}
{msgContentJSX}
</ModernLayout>
)}
</MessageBase>
);
}
);

View file

@ -7,6 +7,7 @@ import { factoryEventSentBy } from '../../../utils/matrix';
import { Reaction, ReactionTooltipMsg } from '../../../components/message';
import { getReactionContent } from '../../../utils/room';
import { useRelations } from '../../../hooks/useRelations';
import { MessageEvent } from '../../../../types/matrix/room';
export const getEventReactions = (timelineSet: EventTimelineSet, eventId: string) =>
timelineSet.relations.getChildEventsForEvent(
@ -31,7 +32,7 @@ export const Reactions = as<'div', ReactionsProps>(({ room, relations, ...props
const { shortcode } = rEvent.getContent();
const toEventId = rEvent.getRelation()?.event_id;
if (typeof toEventId !== 'string') return;
mx.sendEvent(room.roomId, 'm.reaction', getReactionContent(toEventId, key, shortcode));
mx.sendEvent(room.roomId, MessageEvent.Reaction, getReactionContent(toEventId, key, shortcode));
};
return (

View file

@ -5,3 +5,4 @@ export * from './fileRenderer';
export * from './AudioContent';
export * from './Reactions';
export * from './EventContent';
export * from './Message';

View file

@ -1,5 +1,5 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, config } from 'folds';
import { DefaultReset, config, toRem } from 'folds';
export const RelativeBase = style([
DefaultReset,
@ -30,3 +30,35 @@ export const AbsoluteFooter = style([
right: config.space.S100,
},
]);
export const MessageBase = style({
position: 'relative',
});
export const MessageOptionsBase = style([
DefaultReset,
{
position: 'absolute',
top: toRem(-30),
right: 0,
zIndex: 1,
},
]);
export const MessageOptionsBar = style([
DefaultReset,
{
padding: config.space.S100,
},
]);
export const MessageQuickReaction = style({
minWidth: toRem(32),
});
export const MessageMenuGroup = style({
padding: config.space.S100,
});
export const MessageMenuItemText = style({
flexGrow: 1,
});

View file

@ -1,6 +1,8 @@
import { atom } from 'jotai';
const STORAGE_KEY = 'settings';
export type MessageSpacing = '0' | '100' | '200' | '300' | '400' | '500';
export type MessageLayout = 0 | 1 | 2;
export interface Settings {
themeIndex: number;
useSystemTheme: boolean;
@ -8,8 +10,8 @@ export interface Settings {
editorToolbar: boolean;
isPeopleDrawer: boolean;
messageLayout: 0 | 1 | 2;
messageSpacing: '0' | '100' | '200' | '300' | '400' | '500';
messageLayout: MessageLayout;
messageSpacing: MessageSpacing;
hideMembershipEvents: boolean;
hideNickAvatarEvents: boolean;

View file

@ -42,6 +42,7 @@ export enum MessageEvent {
RoomMessageEncrypted = 'm.room.encrypted',
Sticker = 'm.sticker',
RoomRedaction = 'm.room.redaction',
Reaction = 'm.reaction',
}
export enum RoomType {