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
19 changes: 19 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,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,
Expand Down
79 changes: 48 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 @@ -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), []);

Expand All @@ -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) {
Expand All @@ -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>
);
});
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
40 changes: 24 additions & 16 deletions 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 @@ -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 {
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 Expand Up @@ -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>
)
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 @@ -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, {
Expand All @@ -858,7 +859,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
});
} else {
setTimeline(getEmptyTimeline());
loadEventTimeline(replyId);
loadEventTimeline(targetId);
}
},
[room, timeline, scrollToItem, loadEventTimeline]
Expand Down Expand Up @@ -909,15 +910,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();
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({
userId: senderId,
eventId: replyId,
body,
formattedBody,
relation,
});
setTimeout(() => ReactEditor.focus(editor), 100);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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}
/>
)
Expand Down Expand Up @@ -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 (
Expand All @@ -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}
/>
)
Expand Down
Loading
Loading