Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add basic m.thread support #1349

Merged
merged 13 commits into from
Aug 15, 2024
17 changes: 17 additions & 0 deletions src/app/components/message/Reply.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ export const ReplyBend = style({
flexShrink: 0,
});

export const ThreadIndicator = style({
opacity: config.opacity.P300,
gap: '0.125rem',
cursor: 'pointer',

selectors: {
':hover&': {
opacity: config.opacity.P500,
},
},
});

export const ThreadIndicatorIcon = style({
width: '0.875rem',
height: '0.875rem',
greentore marked this conversation as resolved.
Show resolved Hide resolved
});

export const Reply = style({
marginBottom: toRem(1),
minWidth: 0,
Expand Down
82 changes: 51 additions & 31 deletions src/app/components/message/Reply.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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';
Expand All @@ -22,6 +22,7 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
<Box
className={classNames(css.Reply, className)}
alignItems="Center"
alignSelf="Start"
gap="100"
{...props}
ref={ref}
Expand All @@ -40,13 +41,16 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
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), []);

Expand All @@ -62,7 +66,7 @@ export const Reply = as<'div', ReplyProps>(({ mx, room, timelineSet, eventId, ..
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) {
Expand All @@ -78,37 +82,53 @@ export const Reply = as<'div', ReplyProps>(({ mx, room, timelineSet, eventId, ..
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" alignSelf="Start" {...props} ref={ref}>
{threadRootId && (
<Box
as="button"
className={css.ThreadIndicator}
alignItems="Center"
alignSelf="Start"
data-event-id={threadRootId}
onClick={onClick}
>
<Icon className={css.ThreadIndicatorIcon} src={Icons.Message} />
<Text size="T200">Threaded reply</Text>
</Box>
)}
<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>
);
});
16 changes: 8 additions & 8 deletions src/app/features/message-search/SearchResultGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export function SearchResultGroup({
}
);

const handleOpenClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
const handleOpenClick: MouseEventHandler = (evt) => {
const eventId = evt.currentTarget.getAttribute('data-event-id');
if (!eventId) return;
onOpen(room.roomId, eventId);
Expand Down Expand Up @@ -183,15 +183,16 @@ export function SearchResultGroup({
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
Expand Down Expand Up @@ -240,11 +241,10 @@ export function SearchResultGroup({
</Box>
{replyEventId && (
<Reply
as="button"
mx={mx}
room={room}
eventId={replyEventId}
data-event-id={replyEventId}
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenClick}
/>
)}
Expand Down
7 changes: 6 additions & 1 deletion src/app/features/room/RoomInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import React, {
} 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 {
Expand Down Expand Up @@ -310,6 +310,11 @@ export const RoomInput = forwardRef<HTMLDivElement, 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);
Expand Down
33 changes: 17 additions & 16 deletions src/app/features/room/RoomTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
EventTimeline,
EventTimelineSet,
EventTimelineSetHandlerMap,
IContent,
IEncryptedFile,
MatrixClient,
MatrixEvent,
Expand Down Expand Up @@ -836,13 +837,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
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, {
Expand All @@ -857,7 +858,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
});
} else {
setTimeline(getEmptyTimeline());
loadEventTimeline(replyId);
loadEventTimeline(targetId);
}
},
[room, timeline, scrollToItem, loadEventTimeline]
Expand Down Expand Up @@ -908,15 +909,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
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();
// TODO: replace with `RoomMessageEventContent` once matrix-js-sdk is updated.
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
const { body, formatted_body: formattedBody, 'm.relates_to': relation } = content;
const senderId = replyEvt.getSender();
if (senderId && typeof body === 'string') {
setReplyDraft({
userId: senderId,
eventId: replyId,
body,
formattedBody,
relation,
});
setTimeout(() => ReactEditor.focus(editor), 100);
}
Expand Down Expand Up @@ -967,7 +970,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
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);
Expand Down Expand Up @@ -1002,12 +1005,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
reply={
replyEventId && (
<Reply
as="button"
mx={mx}
room={room}
timelineSet={timelineSet}
eventId={replyEventId}
data-reply-id={replyEventId}
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenReply}
/>
)
Expand Down Expand Up @@ -1048,7 +1050,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
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 (
Expand All @@ -1075,12 +1077,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
reply={
replyEventId && (
<Reply
as="button"
mx={mx}
room={room}
timelineSet={timelineSet}
eventId={replyEventId}
data-reply-id={replyEventId}
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenReply}
/>
)
Expand Down
13 changes: 8 additions & 5 deletions src/app/pages/client/inbox/Notifications.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
IRoomEvent,
JoinRule,
Method,
RelationType,
Room,
} from 'matrix-js-sdk';
import { useVirtualizer } from '@tanstack/react-virtual';
Expand Down Expand Up @@ -352,7 +353,7 @@ function RoomNotificationsGroupComp({
}
);

const handleOpenClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
const handleOpenClick: MouseEventHandler = (evt) => {
const eventId = evt.currentTarget.getAttribute('data-event-id');
if (!eventId) return;
onOpen(room.roomId, eventId);
Expand Down Expand Up @@ -403,7 +404,10 @@ function RoomNotificationsGroupComp({
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
Expand Down Expand Up @@ -452,11 +456,10 @@ function RoomNotificationsGroupComp({
</Box>
{replyEventId && (
<Reply
as="button"
mx={mx}
room={room}
eventId={replyEventId}
data-event-id={replyEventId}
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenClick}
/>
)}
Expand Down
4 changes: 3 additions & 1 deletion src/app/state/room/roomInputDrafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { atom } from 'jotai';
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';
Expand Down Expand Up @@ -39,7 +40,8 @@ export type IReplyDraft = {
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>;
Expand Down
Loading
Loading