Add basic `m.thread` support (#1349)
authorgreentore <117551249+greentore@users.noreply.github.com>
Thu, 15 Aug 2024 14:52:32 +0000 (16:52 +0200)
committerGitHub <noreply@github.com>
Thu, 15 Aug 2024 14:52:32 +0000 (20:22 +0530)
* 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
src/app/components/message/Reply.tsx
src/app/features/message-search/SearchResultGroup.tsx
src/app/features/room/RoomInput.tsx
src/app/features/room/RoomTimeline.tsx
src/app/pages/client/inbox/Notifications.tsx
src/app/state/room/roomInputDrafts.ts
src/app/utils/room.ts

index 014a2840a09ce647e85ec3f44f6094fe401d4beb..0679939143ea84ebe6ecd83107028701f54798c5 100644 (file)
@@ -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,
index 85383cdb5750a0c486801b22851cf062e2901c40..82a9d91989c8d3e4b8abe96ff7870b45de96aef6 100644 (file)
@@ -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>(
     <Box
       className={classNames(css.Reply, className)}
       alignItems="Center"
+      alignSelf="Start"
       gap="100"
       {...props}
       ref={ref}
@@ -37,16 +38,26 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
   )
 );
 
+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), []);
 
@@ -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 (
-    <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>
   );
 });
index 2b2a816a5777c9f8b1426edce28955fa53d3b075..84ba3a763a1328d4f29f7827f6eab5bc73fb6215 100644 (file)
@@ -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);
@@ -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
@@ -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}
                   />
                 )}
index 8375d2f75a79556b07016a92a62473f68a483a03..3c78ff3e6b3fe2a598291665286162b1626667c0 100644 (file)
@@ -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<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);
@@ -489,22 +494,25 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
                   >
                     <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>
             )
index 84ce8af185ee3a669b43682d5d8c45f964629285..01ba14f5c77990ef120f059e69885665c268c2c3 100644 (file)
@@ -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<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, {
@@ -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<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({
@@ -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 && (
                 <Reply
-                  as="button"
                   mx={mx}
                   room={room}
                   timelineSet={timelineSet}
-                  eventId={replyEventId}
-                  data-reply-id={replyEventId}
+                  replyEventId={replyEventId}
+                  threadRootId={threadRootId}
                   onClick={handleOpenReply}
                 />
               )
@@ -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 && (
                 <Reply
-                  as="button"
                   mx={mx}
                   room={room}
                   timelineSet={timelineSet}
-                  eventId={replyEventId}
-                  data-reply-id={replyEventId}
+                  replyEventId={replyEventId}
+                  threadRootId={threadRootId}
                   onClick={handleOpenReply}
                 />
               )
index 6a8160d868b848c509d54fda544214dd5d3c510d..aa8782161951900a199482697b8f221a7edfd776 100644 (file)
@@ -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<HTMLButtonElement> = (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 (
             <SequenceCard
@@ -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}
                   />
                 )}
index 60b42fdb7cd68a95cfbcd64f9462a5908c60b4c3..33bd06076bdb094821608df1824fa370e04a6a1b 100644 (file)
@@ -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<IReplyDraft | undefined>(undefined);
 export type TReplyDraftAtom = ReturnType<typeof createReplyDraftAtom>;
index 750dd6ca7ba81bb063658d9a9597fbd0f9fc92db..8cf33a8ffa6ed4b933c9c82a5497d91ad79306ec 100644 (file)
@@ -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,