diff --git a/src/app/components/message/Reply.css.ts b/src/app/components/message/Reply.css.ts index 014a2840..06799391 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 85383cdb..82a9d919 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 2b2a816a..84ba3a76 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 8375d2f7..3c78ff3e 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 84ce8af1..01ba14f5 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 6a8160d8..aa878216 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 60b42fdb..33bd0607 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 750dd6ca..8cf33a8f 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,