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>
This commit is contained in:
greentore 2024-08-15 16:52:32 +02:00 committed by GitHub
parent 7e7bee8f48
commit 830d05e217
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 140 additions and 85 deletions

View file

@ -5,6 +5,25 @@ export const ReplyBend = style({
flexShrink: 0, 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({ export const Reply = style({
marginBottom: toRem(1), marginBottom: toRem(1),
minWidth: 0, minWidth: 0,

View file

@ -1,7 +1,7 @@
import { Box, Icon, Icons, Text, as, color, toRem } from 'folds'; import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
import { EventTimelineSet, MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk'; import { EventTimelineSet, MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; 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 to from 'await-to-js';
import classNames from 'classnames'; import classNames from 'classnames';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
@ -22,6 +22,7 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
<Box <Box
className={classNames(css.Reply, className)} className={classNames(css.Reply, className)}
alignItems="Center" alignItems="Center"
alignSelf="Start"
gap="100" gap="100"
{...props} {...props}
ref={ref} 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 = { type ReplyProps = {
mx: MatrixClient; mx: MatrixClient;
room: Room; room: Room;
timelineSet?: EventTimelineSet; timelineSet?: EventTimelineSet | undefined;
eventId: string; 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>( const [replyEvent, setReplyEvent] = useState<MatrixEvent | null | undefined>(
timelineSet?.findEventById(eventId) timelineSet?.findEventById(replyEventId)
); );
const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []); const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []);
@ -62,7 +73,7 @@ export const Reply = as<'div', ReplyProps>(({ mx, room, timelineSet, eventId, ..
useEffect(() => { useEffect(() => {
let disposed = false; let disposed = false;
const loadEvent = async () => { 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); const mEvent = new MatrixEvent(evt);
if (disposed) return; if (disposed) return;
if (err) { if (err) {
@ -78,37 +89,43 @@ export const Reply = as<'div', ReplyProps>(({ mx, room, timelineSet, eventId, ..
return () => { return () => {
disposed = true; disposed = true;
}; };
}, [replyEvent, mx, room, eventId]); }, [replyEvent, mx, room, replyEventId]);
const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted'; const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted';
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody; const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
return ( return (
<ReplyLayout <Box direction="Column" {...props} ref={ref}>
userColor={sender ? colorMXID(sender) : undefined} {threadRootId && (
username={ <ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
sender && (
<Text size="T300" truncate>
<b>{getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}</b>
</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> <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>
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
</Text>
) : (
<LinePlaceholder
style={{
backgroundColor: color.SurfaceVariant.ContainerActive,
maxWidth: toRem(placeholderWidth),
width: '100%',
}}
/>
)}
</ReplyLayout>
</Box>
); );
}); });

View 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'); const eventId = evt.currentTarget.getAttribute('data-event-id');
if (!eventId) return; if (!eventId) return;
onOpen(room.roomId, eventId); onOpen(room.roomId, eventId);
@ -183,15 +183,16 @@ export function SearchResultGroup({
event.sender; event.sender;
const senderAvatarMxc = getMemberAvatarMxc(room, event.sender); const senderAvatarMxc = getMemberAvatarMxc(room, event.sender);
const relation = event.content['m.relates_to'];
const mainEventId = const mainEventId =
event.content['m.relates_to']?.rel_type === RelationType.Replace relation?.rel_type === RelationType.Replace ? relation.event_id : event.event_id;
? event.content['m.relates_to'].event_id
: event.event_id;
const getContent = (() => const getContent = (() =>
event.content['m.new_content'] ?? event.content) as GetContentCallback; 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 ( return (
<SequenceCard <SequenceCard
@ -240,11 +241,10 @@ export function SearchResultGroup({
</Box> </Box>
{replyEventId && ( {replyEventId && (
<Reply <Reply
as="button"
mx={mx} mx={mx}
room={room} room={room}
eventId={replyEventId} replyEventId={replyEventId}
data-event-id={replyEventId} threadRootId={threadRootId}
onClick={handleOpenClick} onClick={handleOpenClick}
/> />
)} )}

View file

@ -10,7 +10,7 @@ import React, {
} from 'react'; } from 'react';
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import { isKeyHotkey } from 'is-hotkey'; 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 { ReactEditor } from 'slate-react';
import { Transforms, Editor } from 'slate'; import { Transforms, Editor } from 'slate';
import { import {
@ -106,7 +106,7 @@ import { CommandAutocomplete } from './CommandAutocomplete';
import { Command, SHRUG, useCommands } from '../../hooks/useCommands'; import { Command, SHRUG, useCommands } from '../../hooks/useCommands';
import { mobileOrTablet } from '../../utils/user-agent'; import { mobileOrTablet } from '../../utils/user-agent';
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
import { ReplyLayout } from '../../components/message'; import { ReplyLayout, ThreadIndicator } from '../../components/message';
import { roomToParentsAtom } from '../../state/room/roomToParents'; import { roomToParentsAtom } from '../../state/room/roomToParents';
interface RoomInputProps { interface RoomInputProps {
@ -310,6 +310,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
event_id: replyDraft.eventId, 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); mx.sendMessage(roomId, content);
resetEditor(editor); resetEditor(editor);
@ -489,22 +494,25 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
> >
<Icon src={Icons.Cross} size="50" /> <Icon src={Icons.Cross} size="50" />
</IconButton> </IconButton>
<ReplyLayout <Box direction="Column">
userColor={colorMXID(replyDraft.userId)} {replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
username={ <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> <Text size="T300" truncate>
<b> {trimReplyFromBody(replyDraft.body)}
{getMemberDisplayName(room, replyDraft.userId) ??
getMxIdLocalPart(replyDraft.userId) ??
replyDraft.userId}
</b>
</Text> </Text>
} </ReplyLayout>
> </Box>
<Text size="T300" truncate>
{trimReplyFromBody(replyDraft.body)}
</Text>
</ReplyLayout>
</Box> </Box>
</div> </div>
) )

View file

@ -16,6 +16,7 @@ import {
EventTimeline, EventTimeline,
EventTimelineSet, EventTimelineSet,
EventTimelineSetHandlerMap, EventTimelineSetHandlerMap,
IContent,
IEncryptedFile, IEncryptedFile,
MatrixClient, MatrixClient,
MatrixEvent, MatrixEvent,
@ -837,13 +838,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
markAsRead(mx, room.roomId); markAsRead(mx, room.roomId);
}; };
const handleOpenReply: MouseEventHandler<HTMLButtonElement> = useCallback( const handleOpenReply: MouseEventHandler = useCallback(
async (evt) => { async (evt) => {
const replyId = evt.currentTarget.getAttribute('data-reply-id'); const targetId = evt.currentTarget.getAttribute('data-event-id');
if (typeof replyId !== 'string') return; if (!targetId) return;
const replyTimeline = getEventTimeline(room, replyId); const replyTimeline = getEventTimeline(room, targetId);
const absoluteIndex = const absoluteIndex =
replyTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, replyTimeline, replyId); replyTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, replyTimeline, targetId);
if (typeof absoluteIndex === 'number') { if (typeof absoluteIndex === 'number') {
scrollToItem(absoluteIndex, { scrollToItem(absoluteIndex, {
@ -858,7 +859,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}); });
} else { } else {
setTimeline(getEmptyTimeline()); setTimeline(getEmptyTimeline());
loadEventTimeline(replyId); loadEventTimeline(targetId);
} }
}, },
[room, timeline, scrollToItem, loadEventTimeline] [room, timeline, scrollToItem, loadEventTimeline]
@ -909,8 +910,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const replyEvt = room.findEventById(replyId); const replyEvt = room.findEventById(replyId);
if (!replyEvt) return; if (!replyEvt) return;
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet()); const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
const { body, formatted_body: formattedBody }: Record<string, string> = const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent(); const { body, formatted_body: formattedBody } = content;
const { 'm.relates_to': relation } = replyEvt.getOriginalContent();
const senderId = replyEvt.getSender(); const senderId = replyEvt.getSender();
if (senderId && typeof body === 'string') { if (senderId && typeof body === 'string') {
setReplyDraft({ setReplyDraft({
@ -918,6 +920,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
eventId: replyId, eventId: replyId,
body, body,
formattedBody, formattedBody,
relation,
}); });
setTimeout(() => ReactEditor.focus(editor), 100); setTimeout(() => ReactEditor.focus(editor), 100);
} }
@ -969,7 +972,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const reactionRelations = getEventReactions(timelineSet, mEventId); const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
const hasReactions = reactions && reactions.length > 0; const hasReactions = reactions && reactions.length > 0;
const { replyEventId } = mEvent; const { replyEventId, threadRootId } = mEvent;
const highlighted = focusItem?.index === item && focusItem.highlight; const highlighted = focusItem?.index === item && focusItem.highlight;
const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
@ -1004,12 +1007,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
reply={ reply={
replyEventId && ( replyEventId && (
<Reply <Reply
as="button"
mx={mx} mx={mx}
room={room} room={room}
timelineSet={timelineSet} timelineSet={timelineSet}
eventId={replyEventId} replyEventId={replyEventId}
data-reply-id={replyEventId} threadRootId={threadRootId}
onClick={handleOpenReply} onClick={handleOpenReply}
/> />
) )
@ -1050,7 +1052,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const reactionRelations = getEventReactions(timelineSet, mEventId); const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
const hasReactions = reactions && reactions.length > 0; const hasReactions = reactions && reactions.length > 0;
const { replyEventId } = mEvent; const { replyEventId, threadRootId } = mEvent;
const highlighted = focusItem?.index === item && focusItem.highlight; const highlighted = focusItem?.index === item && focusItem.highlight;
return ( return (
@ -1077,12 +1079,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
reply={ reply={
replyEventId && ( replyEventId && (
<Reply <Reply
as="button"
mx={mx} mx={mx}
room={room} room={room}
timelineSet={timelineSet} timelineSet={timelineSet}
eventId={replyEventId} replyEventId={replyEventId}
data-reply-id={replyEventId} threadRootId={threadRootId}
onClick={handleOpenReply} onClick={handleOpenReply}
/> />
) )

View file

@ -20,6 +20,7 @@ import {
IRoomEvent, IRoomEvent,
JoinRule, JoinRule,
Method, Method,
RelationType,
Room, Room,
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import { useVirtualizer } from '@tanstack/react-virtual'; 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'); const eventId = evt.currentTarget.getAttribute('data-event-id');
if (!eventId) return; if (!eventId) return;
onOpen(room.roomId, eventId); onOpen(room.roomId, eventId);
@ -403,7 +404,10 @@ function RoomNotificationsGroupComp({
const senderAvatarMxc = getMemberAvatarMxc(room, event.sender); const senderAvatarMxc = getMemberAvatarMxc(room, event.sender);
const getContent = (() => event.content) as GetContentCallback; 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 ( return (
<SequenceCard <SequenceCard
@ -452,11 +456,10 @@ function RoomNotificationsGroupComp({
</Box> </Box>
{replyEventId && ( {replyEventId && (
<Reply <Reply
as="button"
mx={mx} mx={mx}
room={room} room={room}
eventId={replyEventId} replyEventId={replyEventId}
data-event-id={replyEventId} threadRootId={threadRootId}
onClick={handleOpenClick} onClick={handleOpenClick}
/> />
)} )}

View file

@ -2,6 +2,7 @@ import { atom } from 'jotai';
import { atomFamily } from 'jotai/utils'; import { atomFamily } from 'jotai/utils';
import { Descendant } from 'slate'; import { Descendant } from 'slate';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { IEventRelation } from 'matrix-js-sdk';
import { TListAtom, createListAtom } from '../list'; import { TListAtom, createListAtom } from '../list';
import { createUploadAtomFamily } from '../upload'; import { createUploadAtomFamily } from '../upload';
import { TUploadContent } from '../../utils/matrix'; import { TUploadContent } from '../../utils/matrix';
@ -39,7 +40,8 @@ export type IReplyDraft = {
userId: string; userId: string;
eventId: string; eventId: string;
body: string; body: string;
formattedBody?: string; formattedBody?: string | undefined;
relation?: IEventRelation | undefined;
}; };
const createReplyDraftAtom = () => atom<IReplyDraft | undefined>(undefined); const createReplyDraftAtom = () => atom<IReplyDraft | undefined>(undefined);
export type TReplyDraftAtom = ReturnType<typeof createReplyDraftAtom>; export type TReplyDraftAtom = ReturnType<typeof createReplyDraftAtom>;

View file

@ -389,13 +389,18 @@ export const getEditedEvent = (
return edits && getLatestEdit(mEvent, edits.getRelations()); return edits && getLatestEdit(mEvent, edits.getRelations());
}; };
export const canEditEvent = (mx: MatrixClient, mEvent: MatrixEvent) => export const canEditEvent = (mx: MatrixClient, mEvent: MatrixEvent) => {
mEvent.getSender() === mx.getUserId() && const content = mEvent.getContent();
!mEvent.isRelation() && const relationType = content['m.relates_to']?.rel_type;
mEvent.getType() === MessageEvent.RoomMessage && return (
(mEvent.getContent().msgtype === MsgType.Text || mEvent.getSender() === mx.getUserId() &&
mEvent.getContent().msgtype === MsgType.Emote || (!relationType || relationType === RelationType.Thread) &&
mEvent.getContent().msgtype === MsgType.Notice); mEvent.getType() === MessageEvent.RoomMessage &&
(content.msgtype === MsgType.Text ||
content.msgtype === MsgType.Emote ||
content.msgtype === MsgType.Notice)
);
};
export const getLatestEditableEvt = ( export const getLatestEditableEvt = (
timeline: EventTimeline, timeline: EventTimeline,