flexShrink: 0,
});
+export const ThreadIndicator = style({
+ opacity: config.opacity.P300,
+ gap: toRem(2),
+
+ selectors: {
+ 'button&': {
+ cursor: 'pointer',
+ },
+ ':hover&': {
+ opacity: config.opacity.P500,
+ },
+ },
+});
+
+export const ThreadIndicatorIcon = style({
+ width: toRem(14),
+ height: toRem(14),
+});
+
export const Reply = style({
marginBottom: toRem(1),
minWidth: 0,
import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
import { EventTimelineSet, MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
-import React, { ReactNode, useEffect, useMemo, useState } from 'react';
+import React, { MouseEventHandler, ReactNode, useEffect, useMemo, useState } from 'react';
import to from 'await-to-js';
import classNames from 'classnames';
import colorMXID from '../../../util/colorMXID';
<Box
className={classNames(css.Reply, className)}
alignItems="Center"
+ alignSelf="Start"
gap="100"
{...props}
ref={ref}
)
);
+export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
+ <Box className={css.ThreadIndicator} alignItems="Center" alignSelf="Start" {...props} ref={ref}>
+ <Icon className={css.ThreadIndicatorIcon} src={Icons.Message} />
+ <Text size="T200">Threaded reply</Text>
+ </Box>
+));
+
type ReplyProps = {
mx: MatrixClient;
room: Room;
- timelineSet?: EventTimelineSet;
- eventId: string;
+ timelineSet?: EventTimelineSet | undefined;
+ replyEventId: string;
+ threadRootId?: string | undefined;
+ onClick?: MouseEventHandler | undefined;
};
-export const Reply = as<'div', ReplyProps>(({ mx, room, timelineSet, eventId, ...props }, ref) => {
+export const Reply = as<'div', ReplyProps>((_, ref) => {
+ const { mx, room, timelineSet, replyEventId, threadRootId, onClick, ...props } = _;
const [replyEvent, setReplyEvent] = useState<MatrixEvent | null | undefined>(
- timelineSet?.findEventById(eventId)
+ timelineSet?.findEventById(replyEventId)
);
const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []);
useEffect(() => {
let disposed = false;
const loadEvent = async () => {
- const [err, evt] = await to(mx.fetchRoomEvent(room.roomId, eventId));
+ const [err, evt] = await to(mx.fetchRoomEvent(room.roomId, replyEventId));
const mEvent = new MatrixEvent(evt);
if (disposed) return;
if (err) {
return () => {
disposed = true;
};
- }, [replyEvent, mx, room, eventId]);
+ }, [replyEvent, mx, room, replyEventId]);
const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted';
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
return (
- <ReplyLayout
- userColor={sender ? colorMXID(sender) : undefined}
- username={
- sender && (
+ <Box direction="Column" {...props} ref={ref}>
+ {threadRootId && (
+ <ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
+ )}
+ <ReplyLayout
+ as="button"
+ userColor={sender ? colorMXID(sender) : undefined}
+ username={
+ sender && (
+ <Text size="T300" truncate>
+ <b>{getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}</b>
+ </Text>
+ )
+ }
+ data-event-id={replyEventId}
+ onClick={onClick}
+ >
+ {replyEvent !== undefined ? (
<Text size="T300" truncate>
- <b>{getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}</b>
+ {badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
</Text>
- )
- }
- {...props}
- ref={ref}
- >
- {replyEvent !== undefined ? (
- <Text size="T300" truncate>
- {badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
- </Text>
- ) : (
- <LinePlaceholder
- style={{
- backgroundColor: color.SurfaceVariant.ContainerActive,
- maxWidth: toRem(placeholderWidth),
- width: '100%',
- }}
- />
- )}
- </ReplyLayout>
+ ) : (
+ <LinePlaceholder
+ style={{
+ backgroundColor: color.SurfaceVariant.ContainerActive,
+ maxWidth: toRem(placeholderWidth),
+ width: '100%',
+ }}
+ />
+ )}
+ </ReplyLayout>
+ </Box>
);
});
}
);
- const handleOpenClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ const handleOpenClick: MouseEventHandler = (evt) => {
const eventId = evt.currentTarget.getAttribute('data-event-id');
if (!eventId) return;
onOpen(room.roomId, eventId);
event.sender;
const senderAvatarMxc = getMemberAvatarMxc(room, event.sender);
+ const relation = event.content['m.relates_to'];
const mainEventId =
- event.content['m.relates_to']?.rel_type === RelationType.Replace
- ? event.content['m.relates_to'].event_id
- : event.event_id;
+ relation?.rel_type === RelationType.Replace ? relation.event_id : event.event_id;
const getContent = (() =>
event.content['m.new_content'] ?? event.content) as GetContentCallback;
- const replyEventId = event.content['m.relates_to']?.['m.in_reply_to']?.event_id;
+ const replyEventId = relation?.['m.in_reply_to']?.event_id;
+ const threadRootId =
+ relation?.rel_type === RelationType.Thread ? relation.event_id : undefined;
return (
<SequenceCard
</Box>
{replyEventId && (
<Reply
- as="button"
mx={mx}
room={room}
- eventId={replyEventId}
- data-event-id={replyEventId}
+ replyEventId={replyEventId}
+ threadRootId={threadRootId}
onClick={handleOpenClick}
/>
)}
} from 'react';
import { useAtom, useAtomValue } from 'jotai';
import { isKeyHotkey } from 'is-hotkey';
-import { EventType, IContent, MsgType, Room } from 'matrix-js-sdk';
+import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
import { Transforms, Editor } from 'slate';
import {
import { Command, SHRUG, useCommands } from '../../hooks/useCommands';
import { mobileOrTablet } from '../../utils/user-agent';
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
-import { ReplyLayout } from '../../components/message';
+import { ReplyLayout, ThreadIndicator } from '../../components/message';
import { roomToParentsAtom } from '../../state/room/roomToParents';
interface RoomInputProps {
event_id: replyDraft.eventId,
},
};
+ if (replyDraft.relation?.rel_type === RelationType.Thread) {
+ content['m.relates_to'].event_id = replyDraft.relation.event_id;
+ content['m.relates_to'].rel_type = RelationType.Thread;
+ content['m.relates_to'].is_falling_back = false;
+ }
}
mx.sendMessage(roomId, content);
resetEditor(editor);
>
<Icon src={Icons.Cross} size="50" />
</IconButton>
- <ReplyLayout
- userColor={colorMXID(replyDraft.userId)}
- username={
+ <Box direction="Column">
+ {replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
+ <ReplyLayout
+ userColor={colorMXID(replyDraft.userId)}
+ username={
+ <Text size="T300" truncate>
+ <b>
+ {getMemberDisplayName(room, replyDraft.userId) ??
+ getMxIdLocalPart(replyDraft.userId) ??
+ replyDraft.userId}
+ </b>
+ </Text>
+ }
+ >
<Text size="T300" truncate>
- <b>
- {getMemberDisplayName(room, replyDraft.userId) ??
- getMxIdLocalPart(replyDraft.userId) ??
- replyDraft.userId}
- </b>
+ {trimReplyFromBody(replyDraft.body)}
</Text>
- }
- >
- <Text size="T300" truncate>
- {trimReplyFromBody(replyDraft.body)}
- </Text>
- </ReplyLayout>
+ </ReplyLayout>
+ </Box>
</Box>
</div>
)
EventTimeline,
EventTimelineSet,
EventTimelineSetHandlerMap,
+ IContent,
IEncryptedFile,
MatrixClient,
MatrixEvent,
markAsRead(mx, room.roomId);
};
- const handleOpenReply: MouseEventHandler<HTMLButtonElement> = useCallback(
+ const handleOpenReply: MouseEventHandler = useCallback(
async (evt) => {
- const replyId = evt.currentTarget.getAttribute('data-reply-id');
- if (typeof replyId !== 'string') return;
- const replyTimeline = getEventTimeline(room, replyId);
+ const targetId = evt.currentTarget.getAttribute('data-event-id');
+ if (!targetId) return;
+ const replyTimeline = getEventTimeline(room, targetId);
const absoluteIndex =
- replyTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, replyTimeline, replyId);
+ replyTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, replyTimeline, targetId);
if (typeof absoluteIndex === 'number') {
scrollToItem(absoluteIndex, {
});
} else {
setTimeline(getEmptyTimeline());
- loadEventTimeline(replyId);
+ loadEventTimeline(targetId);
}
},
[room, timeline, scrollToItem, loadEventTimeline]
const replyEvt = room.findEventById(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 content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
+ const { body, formatted_body: formattedBody } = content;
+ const { 'm.relates_to': relation } = replyEvt.getOriginalContent();
const senderId = replyEvt.getSender();
if (senderId && typeof body === 'string') {
setReplyDraft({
eventId: replyId,
body,
formattedBody,
+ relation,
});
setTimeout(() => ReactEditor.focus(editor), 100);
}
const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
const hasReactions = reactions && reactions.length > 0;
- const { replyEventId } = mEvent;
+ const { replyEventId, threadRootId } = mEvent;
const highlighted = focusItem?.index === item && focusItem.highlight;
const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
reply={
replyEventId && (
<Reply
- as="button"
mx={mx}
room={room}
timelineSet={timelineSet}
- eventId={replyEventId}
- data-reply-id={replyEventId}
+ replyEventId={replyEventId}
+ threadRootId={threadRootId}
onClick={handleOpenReply}
/>
)
const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
const hasReactions = reactions && reactions.length > 0;
- const { replyEventId } = mEvent;
+ const { replyEventId, threadRootId } = mEvent;
const highlighted = focusItem?.index === item && focusItem.highlight;
return (
reply={
replyEventId && (
<Reply
- as="button"
mx={mx}
room={room}
timelineSet={timelineSet}
- eventId={replyEventId}
- data-reply-id={replyEventId}
+ replyEventId={replyEventId}
+ threadRootId={threadRootId}
onClick={handleOpenReply}
/>
)
IRoomEvent,
JoinRule,
Method,
+ RelationType,
Room,
} from 'matrix-js-sdk';
import { useVirtualizer } from '@tanstack/react-virtual';
}
);
- const handleOpenClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ const handleOpenClick: MouseEventHandler = (evt) => {
const eventId = evt.currentTarget.getAttribute('data-event-id');
if (!eventId) return;
onOpen(room.roomId, eventId);
const senderAvatarMxc = getMemberAvatarMxc(room, event.sender);
const getContent = (() => event.content) as GetContentCallback;
- const replyEventId = event.content['m.relates_to']?.['m.in_reply_to']?.event_id;
+ const relation = event.content['m.relates_to'];
+ const replyEventId = relation?.['m.in_reply_to']?.event_id;
+ const threadRootId =
+ relation?.rel_type === RelationType.Thread ? relation.event_id : undefined;
return (
<SequenceCard
</Box>
{replyEventId && (
<Reply
- as="button"
mx={mx}
room={room}
- eventId={replyEventId}
- data-event-id={replyEventId}
+ replyEventId={replyEventId}
+ threadRootId={threadRootId}
onClick={handleOpenClick}
/>
)}
import { atomFamily } from 'jotai/utils';
import { Descendant } from 'slate';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import { IEventRelation } from 'matrix-js-sdk';
import { TListAtom, createListAtom } from '../list';
import { createUploadAtomFamily } from '../upload';
import { TUploadContent } from '../../utils/matrix';
userId: string;
eventId: string;
body: string;
- formattedBody?: string;
+ formattedBody?: string | undefined;
+ relation?: IEventRelation | undefined;
};
const createReplyDraftAtom = () => atom<IReplyDraft | undefined>(undefined);
export type TReplyDraftAtom = ReturnType<typeof createReplyDraftAtom>;
return edits && getLatestEdit(mEvent, edits.getRelations());
};
-export const canEditEvent = (mx: MatrixClient, mEvent: MatrixEvent) =>
- mEvent.getSender() === mx.getUserId() &&
- !mEvent.isRelation() &&
- mEvent.getType() === MessageEvent.RoomMessage &&
- (mEvent.getContent().msgtype === MsgType.Text ||
- mEvent.getContent().msgtype === MsgType.Emote ||
- mEvent.getContent().msgtype === MsgType.Notice);
+export const canEditEvent = (mx: MatrixClient, mEvent: MatrixEvent) => {
+ const content = mEvent.getContent();
+ const relationType = content['m.relates_to']?.rel_type;
+ return (
+ mEvent.getSender() === mx.getUserId() &&
+ (!relationType || relationType === RelationType.Thread) &&
+ mEvent.getType() === MessageEvent.RoomMessage &&
+ (content.msgtype === MsgType.Text ||
+ content.msgtype === MsgType.Emote ||
+ content.msgtype === MsgType.Notice)
+ );
+};
export const getLatestEditableEvt = (
timeline: EventTimeline,