From 830d05e217fd6640c219d4eb419895ca8669e111 Mon Sep 17 00:00:00 2001
From: greentore <117551249+greentore@users.noreply.github.com>
Date: Thu, 15 Aug 2024 16:52:32 +0200
Subject: [PATCH] Add basic `m.thread` support (#1349)
* Add basic `m.thread` support
* Fix types
* Update to v4
* Fix auto formatting mess
* Add threaded reply indicators
* Fix reply overflow
* Fix replying to edited threaded replies
* Add thread indicator to room input
* Fix editing encrypted events
* Use `toRem` function for converting units
---------
Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com>
---
src/app/components/message/Reply.css.ts | 19 +++++
src/app/components/message/Reply.tsx | 81 +++++++++++--------
.../message-search/SearchResultGroup.tsx | 16 ++--
src/app/features/room/RoomInput.tsx | 40 +++++----
src/app/features/room/RoomTimeline.tsx | 33 ++++----
src/app/pages/client/inbox/Notifications.tsx | 13 +--
src/app/state/room/roomInputDrafts.ts | 4 +-
src/app/utils/room.ts | 19 +++--
8 files changed, 140 insertions(+), 85 deletions(-)
diff --git a/src/app/components/message/Reply.css.ts b/src/app/components/message/Reply.css.ts
index 014a284..0679939 100644
--- a/src/app/components/message/Reply.css.ts
+++ b/src/app/components/message/Reply.css.ts
@@ -5,6 +5,25 @@ export const ReplyBend = style({
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,
diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx
index 85383cd..82a9d91 100644
--- a/src/app/components/message/Reply.tsx
+++ b/src/app/components/message/Reply.tsx
@@ -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';
@@ -22,6 +22,7 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
(
)
);
+export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
+
+
+ Threaded reply
+
+));
+
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(
- timelineSet?.findEventById(eventId)
+ timelineSet?.findEventById(replyEventId)
);
const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []);
@@ -62,7 +73,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) {
@@ -78,37 +89,43 @@ 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 (
-
- {getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}
-
- )
- }
- {...props}
- ref={ref}
- >
- {replyEvent !== undefined ? (
-
- {badEncryption ? : bodyJSX}
-
- ) : (
-
+
+ {threadRootId && (
+
)}
-
+
+ {getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}
+
+ )
+ }
+ data-event-id={replyEventId}
+ onClick={onClick}
+ >
+ {replyEvent !== undefined ? (
+
+ {badEncryption ? : bodyJSX}
+
+ ) : (
+
+ )}
+
+
);
});
diff --git a/src/app/features/message-search/SearchResultGroup.tsx b/src/app/features/message-search/SearchResultGroup.tsx
index 2b2a816..84ba3a7 100644
--- a/src/app/features/message-search/SearchResultGroup.tsx
+++ b/src/app/features/message-search/SearchResultGroup.tsx
@@ -148,7 +148,7 @@ export function SearchResultGroup({
}
);
- const handleOpenClick: MouseEventHandler = (evt) => {
+ const handleOpenClick: MouseEventHandler = (evt) => {
const eventId = evt.currentTarget.getAttribute('data-event-id');
if (!eventId) return;
onOpen(room.roomId, eventId);
@@ -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 (
{replyEventId && (
)}
diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx
index 8375d2f..3c78ff3 100644
--- a/src/app/features/room/RoomInput.tsx
+++ b/src/app/features/room/RoomInput.tsx
@@ -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 {
@@ -106,7 +106,7 @@ import { CommandAutocomplete } from './CommandAutocomplete';
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 {
@@ -310,6 +310,11 @@ export const RoomInput = forwardRef(
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);
@@ -489,22 +494,25 @@ export const RoomInput = forwardRef(
>
-
+ {replyDraft.relation?.rel_type === RelationType.Thread && }
+
+
+ {getMemberDisplayName(room, replyDraft.userId) ??
+ getMxIdLocalPart(replyDraft.userId) ??
+ replyDraft.userId}
+
+
+ }
+ >
-
- {getMemberDisplayName(room, replyDraft.userId) ??
- getMxIdLocalPart(replyDraft.userId) ??
- replyDraft.userId}
-
+ {trimReplyFromBody(replyDraft.body)}
- }
- >
-
- {trimReplyFromBody(replyDraft.body)}
-
-
+
+
)
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index 84ce8af..01ba14f 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -16,6 +16,7 @@ import {
EventTimeline,
EventTimelineSet,
EventTimelineSetHandlerMap,
+ IContent,
IEncryptedFile,
MatrixClient,
MatrixEvent,
@@ -837,13 +838,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
markAsRead(mx, room.roomId);
};
- const handleOpenReply: MouseEventHandler = 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, {
@@ -858,7 +859,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
});
} else {
setTimeline(getEmptyTimeline());
- loadEventTimeline(replyId);
+ loadEventTimeline(targetId);
}
},
[room, timeline, scrollToItem, loadEventTimeline]
@@ -909,8 +910,9 @@ 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 =
- 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({
@@ -918,6 +920,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
eventId: replyId,
body,
formattedBody,
+ relation,
});
setTimeout(() => ReactEditor.focus(editor), 100);
}
@@ -969,7 +972,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);
@@ -1004,12 +1007,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
reply={
replyEventId && (
)
@@ -1050,7 +1052,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 (
@@ -1077,12 +1079,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
reply={
replyEventId && (
)
diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx
index 6a8160d..aa87821 100644
--- a/src/app/pages/client/inbox/Notifications.tsx
+++ b/src/app/pages/client/inbox/Notifications.tsx
@@ -20,6 +20,7 @@ import {
IRoomEvent,
JoinRule,
Method,
+ RelationType,
Room,
} from 'matrix-js-sdk';
import { useVirtualizer } from '@tanstack/react-virtual';
@@ -352,7 +353,7 @@ function RoomNotificationsGroupComp({
}
);
- const handleOpenClick: MouseEventHandler = (evt) => {
+ const handleOpenClick: MouseEventHandler = (evt) => {
const eventId = evt.currentTarget.getAttribute('data-event-id');
if (!eventId) return;
onOpen(room.roomId, eventId);
@@ -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 (
{replyEventId && (
)}
diff --git a/src/app/state/room/roomInputDrafts.ts b/src/app/state/room/roomInputDrafts.ts
index 60b42fd..33bd060 100644
--- a/src/app/state/room/roomInputDrafts.ts
+++ b/src/app/state/room/roomInputDrafts.ts
@@ -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';
@@ -39,7 +40,8 @@ export type IReplyDraft = {
userId: string;
eventId: string;
body: string;
- formattedBody?: string;
+ formattedBody?: string | undefined;
+ relation?: IEventRelation | undefined;
};
const createReplyDraftAtom = () => atom(undefined);
export type TReplyDraftAtom = ReturnType;
diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts
index 750dd6c..8cf33a8 100644
--- a/src/app/utils/room.ts
+++ b/src/app/utils/room.ts
@@ -389,13 +389,18 @@ export const getEditedEvent = (
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,