From 9a44faf4dd213f628112897402f828e3f2449d1b Mon Sep 17 00:00:00 2001 From: Shea Duma Date: Fri, 20 Mar 2026 05:53:50 +0200 Subject: [PATCH 1/8] basic replies for standard event types --- src/app/components/message/Reply.tsx | 49 ++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index 1e047e9e9..62794951b 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -1,11 +1,16 @@ -import { Box, Chip, Icon, Icons, Text, as, color, toRem } from 'folds'; -import { EventTimelineSet, Room } from '$types/matrix-sdk'; +import { Box, Chip, Icon, IconSrc, Icons, Text, as, color, toRem } from 'folds'; +import { EventTimelineSet, Room, SessionMembershipData } from '$types/matrix-sdk'; import { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import classNames from 'classnames'; import parse from 'html-react-parser'; import { useAtomValue } from 'jotai'; -import { getMemberDisplayName, trimReplyFromBody, trimReplyFromFormattedBody } from '$utils/room'; +import { + getMemberDisplayName, + isMembershipChanged, + trimReplyFromBody, + trimReplyFromFormattedBody, +} from '$utils/room'; import { getMxIdLocalPart } from '$utils/matrix'; import { randomNumberBetween } from '$utils/common'; import { @@ -19,6 +24,9 @@ import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { useIgnoredUsers } from '$hooks/useIgnoredUsers'; import { nicknamesAtom } from '$state/nicknames'; import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useMemberEventParser } from '$hooks/useMemberEventParser'; +import { StateEvent } from '$types/matrix/room'; +import { useTranslation } from 'react-i18next'; import { MessageBadEncryptedContent, MessageBlockedContent, @@ -31,9 +39,10 @@ import { LinePlaceholder } from './placeholder'; type ReplyLayoutProps = { userColor?: string; username?: ReactNode; + icon?: IconSrc; }; export const ReplyLayout = as<'div', ReplyLayoutProps>( - ({ username, userColor, className, children, ...props }, ref) => ( + ({ username, userColor, icon, className, children, ...props }, ref) => ( ( {...props} ref={ref} > - + + + {!!icon && } + {username} @@ -91,6 +103,11 @@ export const Reply = as<'div', ReplyProps>( const ignoredUsers = useIgnoredUsers(); const isBlockedSender = !!sender && ignoredUsers.includes(sender); + const eventStateType = replyEvent?.getType(); + const { t } = useTranslation(); + + const isMemberShipEvent = !!replyEvent && isMembershipChanged(replyEvent); + const parseMemberEvent = useMemberEventParser(); const { color: usernameColor, font: usernameFont } = useSableCosmetics(sender ?? '', room); const nicknames = useAtomValue(nicknamesAtom); @@ -116,6 +133,7 @@ export const Reply = as<'div', ReplyProps>( !replyEvent.getClearContent(); let bodyJSX: ReactNode = fallbackBody; + let image: IconSrc | undefined; if (format === 'org.matrix.custom.html' && formattedBody) { const strippedHtml = trimReplyFromFormattedBody(formattedBody) @@ -134,6 +152,23 @@ export const Reply = as<'div', ReplyProps>( } else if (body) { const strippedBody = trimReplyFromBody(body).replaceAll(/(?:\r\n|\r|\n)/g, ' '); bodyJSX = scaleSystemEmoji(strippedBody); + } else if (isMemberShipEvent) { + const parsedMemberEvent = parseMemberEvent(replyEvent); + image = parsedMemberEvent.icon; + bodyJSX = parsedMemberEvent.body; + } else if (eventStateType === StateEvent.RoomName) { + image = Icons.Hash; + bodyJSX = t('Organisms.RoomCommon.changed_room_name'); + } else if (eventStateType === StateEvent.RoomTopic) { + image = Icons.Hash; + bodyJSX = ' changed room topic'; + } else if (eventStateType === StateEvent.RoomAvatar) { + image = Icons.Hash; + bodyJSX = ' changed room avatar'; + } else if (!!replyEvent && eventStateType === StateEvent.GroupCallMemberPrefix) { + const callJoined = replyEvent.getContent().application; + image = callJoined ? Icons.Phone : Icons.PhoneDown; + bodyJSX = callJoined ? ' joined the call' : ' ended the call'; } return ( @@ -144,8 +179,10 @@ export const Reply = as<'div', ReplyProps>( {getMemberDisplayName(room, sender, nicknames) ?? getMxIdLocalPart(sender)} From e32df9be897960851b482f0727da06e9a7b6f9e2 Mon Sep 17 00:00:00 2001 From: Shea Duma Date: Fri, 20 Mar 2026 06:25:35 +0200 Subject: [PATCH 2/8] displays repliy event properly for regular messages --- src/app/components/message/Reply.tsx | 32 ++++++++++++++++------------ 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index 62794951b..8543ad5bd 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -5,12 +5,7 @@ import { useQueryClient } from '@tanstack/react-query'; import classNames from 'classnames'; import parse from 'html-react-parser'; import { useAtomValue } from 'jotai'; -import { - getMemberDisplayName, - isMembershipChanged, - trimReplyFromBody, - trimReplyFromFormattedBody, -} from '$utils/room'; +import { getMemberDisplayName, trimReplyFromBody, trimReplyFromFormattedBody } from '$utils/room'; import { getMxIdLocalPart } from '$utils/matrix'; import { randomNumberBetween } from '$utils/common'; import { @@ -27,6 +22,7 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { useMemberEventParser } from '$hooks/useMemberEventParser'; import { StateEvent } from '$types/matrix/room'; import { useTranslation } from 'react-i18next'; +import * as customHtmlCss from '$styles/CustomHtml.css'; import { MessageBadEncryptedContent, MessageBlockedContent, @@ -100,13 +96,12 @@ export const Reply = as<'div', ReplyProps>( const { body, formatted_body: formattedBody, format } = replyEvent?.getContent() ?? {}; const sender = replyEvent?.getSender(); + const eventType = replyEvent?.getType(); const ignoredUsers = useIgnoredUsers(); const isBlockedSender = !!sender && ignoredUsers.includes(sender); - const eventStateType = replyEvent?.getType(); const { t } = useTranslation(); - const isMemberShipEvent = !!replyEvent && isMembershipChanged(replyEvent); const parseMemberEvent = useMemberEventParser(); const { color: usernameColor, font: usernameFont } = useSableCosmetics(sender ?? '', room); @@ -152,23 +147,32 @@ export const Reply = as<'div', ReplyProps>( } else if (body) { const strippedBody = trimReplyFromBody(body).replaceAll(/(?:\r\n|\r|\n)/g, ' '); bodyJSX = scaleSystemEmoji(strippedBody); - } else if (isMemberShipEvent) { + } else if (eventType === StateEvent.RoomMember && !!replyEvent) { const parsedMemberEvent = parseMemberEvent(replyEvent); image = parsedMemberEvent.icon; bodyJSX = parsedMemberEvent.body; - } else if (eventStateType === StateEvent.RoomName) { + } else if (eventType === StateEvent.RoomName) { image = Icons.Hash; bodyJSX = t('Organisms.RoomCommon.changed_room_name'); - } else if (eventStateType === StateEvent.RoomTopic) { + } else if (eventType === StateEvent.RoomTopic) { image = Icons.Hash; bodyJSX = ' changed room topic'; - } else if (eventStateType === StateEvent.RoomAvatar) { + } else if (eventType === StateEvent.RoomAvatar) { image = Icons.Hash; bodyJSX = ' changed room avatar'; - } else if (!!replyEvent && eventStateType === StateEvent.GroupCallMemberPrefix) { + } else if (eventType === StateEvent.GroupCallMemberPrefix && !!replyEvent) { const callJoined = replyEvent.getContent().application; image = callJoined ? Icons.Phone : Icons.PhoneDown; bodyJSX = callJoined ? ' joined the call' : ' ended the call'; + } else if (eventType) { + image = Icons.Code; + bodyJSX = ( + <> + {' sent '} + {eventType} + {' state event'} + + ); } return ( @@ -182,7 +186,7 @@ export const Reply = as<'div', ReplyProps>( icon={image} username={ sender && - !isMemberShipEvent && ( + eventType !== StateEvent.RoomMember && ( {getMemberDisplayName(room, sender, nicknames) ?? getMxIdLocalPart(sender)} From 684407a524fd07a9c1f1daf2bc524801e14542b0 Mon Sep 17 00:00:00 2001 From: Shea Duma Date: Fri, 20 Mar 2026 06:38:41 +0200 Subject: [PATCH 3/8] changed the TimeInput's reply rendering logic rely on the proper function instead of doing its own thing --- src/app/features/room/RoomInput.tsx | 82 ++--------------------------- 1 file changed, 3 insertions(+), 79 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index ba0a5fd47..37c0c0d5f 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -3,7 +3,6 @@ import { KeyboardEventHandler, MouseEvent, RefObject, - ReactNode, useCallback, useEffect, useRef, @@ -44,13 +43,6 @@ import { toRem, } from 'folds'; -import parse from 'html-react-parser'; -import { - getReactCustomHtmlParser, - LINKIFY_OPTS, - scaleSystemEmoji, -} from '$plugins/react-custom-html-parser'; - import { useMatrixClient } from '$hooks/useMatrixClient'; import { AutocompletePrefix, @@ -83,7 +75,6 @@ import { TUploadContent, encryptFile, getImageInfo, - getMxIdLocalPart, mxcUrlToHttp, toggleReaction, } from '$utils/matrix'; @@ -113,23 +104,16 @@ import { safeFile } from '$utils/mimeTypes'; import { fulfilledPromiseSettledResult } from '$utils/common'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; -import { - getMemberDisplayName, - getMentionContent, - reactionOrEditEvent, - trimReplyFromBody, - trimReplyFromFormattedBody, -} from '$utils/room'; +import { getMentionContent, reactionOrEditEvent } from '$utils/room'; import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '$hooks/useCommands'; import { mobileOrTablet } from '$utils/user-agent'; import { useElementSizeObserver } from '$hooks/useElementSizeObserver'; -import { ReplyLayout, ThreadIndicator } from '$components/message'; +import { Reply, ThreadIndicator } from '$components/message'; import { roomToParentsAtom } from '$state/room/roomToParents'; import { nicknamesAtom } from '$state/nicknames'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { useImagePackRooms } from '$hooks/useImagePackRooms'; import { useComposingCheck } from '$hooks/useComposingCheck'; -import { useSableCosmetics } from '$hooks/useSableCosmetics'; import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; import FocusTrap from 'focus-trap-react'; @@ -275,12 +259,6 @@ export const RoomInput = forwardRef( const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(draftKey)); const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(draftKey)); - const replyUserID = replyDraft?.userId; - - const { color: replyUsernameColor, font: replyUsernameFont } = useSableCosmetics( - replyUserID ?? '', - room - ); const [uploadBoard, setUploadBoard] = useState(true); const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(draftKey)); @@ -373,45 +351,6 @@ export const RoomInput = forwardRef( ); const replyEvent = replyDraft ? room.findEventById(replyDraft.eventId) : undefined; - const { - body: replyBody, - formatted_body: replyFormattedBody, - format: replyFormat, - } = replyEvent?.getContent() ?? {}; - - // Prefer the live event content; fall back to what was snapshotted in the - // draft when the user hit Reply (the event may not be in SDK state if it - // was redacted or evicted, but the draft always carries the original body). - const htmlBody = - replyFormat === 'org.matrix.custom.html' ? replyFormattedBody : replyDraft?.formattedBody; - const plainBody = replyBody ?? replyDraft?.body; - - let replyBodyJSX: ReactNode = replyDraft ? trimReplyFromBody(replyDraft.body) : null; - - if (htmlBody) { - /** - * message with linebreaks, etc stripped - */ - const strippedHtml = trimReplyFromFormattedBody(htmlBody) - .replaceAll(//gi, ' ') - .replaceAll(/<\/p>\s*]*>/gi, ' ') - .replaceAll(/<\/?p[^>]*>/gi, '') - .replaceAll(/(?:\r\n|\r|\n)/g, ' ') - .trim(); - const parserOpts = getReactCustomHtmlParser(mx, roomId, { - linkifyOpts: LINKIFY_OPTS, - useAuthentication, - nicknames, - }); - replyBodyJSX = parse(strippedHtml, parserOpts); - } else if (plainBody) { - const strippedBody = trimReplyFromBody(plainBody).replaceAll(/(?:\r\n|\r|\n)/g, ' '); - replyBodyJSX = scaleSystemEmoji(strippedBody); - } - - // Seed the reply draft with the thread relation whenever we're in thread - // mode (e.g. on first render or when the thread root changes). We use the - // current user's ID as userId so that the mention logic skips it. useEffect(() => { if (!threadRootId) return; setReplyDraft((prev) => { @@ -1260,22 +1199,7 @@ export const RoomInput = forwardRef( {replyDraft.relation?.rel_type === RelationType.Thread && !threadRootId && ( )} - - - {getMemberDisplayName(room, replyDraft.userId, nicknames) ?? - getMxIdLocalPart(replyDraft.userId) ?? - replyDraft.userId} - - - } - > - - {replyBodyJSX} - - + Date: Fri, 20 Mar 2026 06:50:32 +0200 Subject: [PATCH 4/8] added reply option in the right click menu for events --- src/app/features/room/message/Message.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 88a4349ff..139937116 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -1332,6 +1332,25 @@ export const Event = as<'div', EventProps>( }} > + } + radii="300" + data-event-id={mEvent.getId()} + onClick={(evt: any) => { + onReplyClick(evt); + closeMenu(); + }} + > + + Reply + + {!hideReadReceipts && ( From d7ffd58b33227c873f362dc06d89410ce66b4b27 Mon Sep 17 00:00:00 2001 From: Shea Duma Date: Fri, 20 Mar 2026 06:52:23 +0200 Subject: [PATCH 5/8] added md file for the gods --- .changeset/styling-state-event-replies.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/styling-state-event-replies.md diff --git a/.changeset/styling-state-event-replies.md b/.changeset/styling-state-event-replies.md new file mode 100644 index 000000000..7f6b2a6c9 --- /dev/null +++ b/.changeset/styling-state-event-replies.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Added styling for replies to non-messages. From d8468daf438769a077f33ad5f3a355de821dd3ad Mon Sep 17 00:00:00 2001 From: Shea Duma Date: Fri, 20 Mar 2026 06:57:33 +0200 Subject: [PATCH 6/8] undeleted needed comment --- src/app/features/room/RoomInput.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 37c0c0d5f..607035a14 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -351,6 +351,10 @@ export const RoomInput = forwardRef( ); const replyEvent = replyDraft ? room.findEventById(replyDraft.eventId) : undefined; + + // Seed the reply draft with the thread relation whenever we're in thread + // mode (e.g. on first render or when the thread root changes). We use the + // current user's ID as userId so that the mention logic skips it. useEffect(() => { if (!threadRootId) return; setReplyDraft((prev) => { From 3646d9e277eb71cfc0a4a25159001e7cf2859748 Mon Sep 17 00:00:00 2001 From: Shea Duma Date: Fri, 20 Mar 2026 08:08:07 +0200 Subject: [PATCH 7/8] fixed bug around deleted encrypted messages showing as undecryptable due to the previewing overpowering the redact look --- src/app/components/message/Reply.tsx | 34 +++++++++++++++------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index 8543ad5bd..37cfd67e5 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -20,7 +20,7 @@ import { useIgnoredUsers } from '$hooks/useIgnoredUsers'; import { nicknamesAtom } from '$state/nicknames'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useMemberEventParser } from '$hooks/useMemberEventParser'; -import { StateEvent } from '$types/matrix/room'; +import { StateEvent, MessageEvent } from '$types/matrix/room'; import { useTranslation } from 'react-i18next'; import * as customHtmlCss from '$styles/CustomHtml.css'; import { @@ -50,7 +50,11 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>( {!!icon && } - + {username} @@ -101,6 +105,7 @@ export const Reply = as<'div', ReplyProps>( const ignoredUsers = useIgnoredUsers(); const isBlockedSender = !!sender && ignoredUsers.includes(sender); const { t } = useTranslation(); + const isRedacted = replyEvent?.isRedacted() === true; const parseMemberEvent = useMemberEventParser(); @@ -108,11 +113,7 @@ export const Reply = as<'div', ReplyProps>( const nicknames = useAtomValue(nicknamesAtom); const useAuthentication = useMediaAuthentication(); - const fallbackBody = replyEvent?.isRedacted() ? ( - - ) : ( - - ); + const fallbackBody = isRedacted ? : ; const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted'; @@ -164,7 +165,7 @@ export const Reply = as<'div', ReplyProps>( const callJoined = replyEvent.getContent().application; image = callJoined ? Icons.Phone : Icons.PhoneDown; bodyJSX = callJoined ? ' joined the call' : ' ended the call'; - } else if (eventType) { + } else if (Object.values(MessageEvent).every((v) => v !== eventType)) { image = Icons.Code; bodyJSX = ( <> @@ -174,7 +175,6 @@ export const Reply = as<'div', ReplyProps>( ); } - return ( {threadRootId && ( @@ -204,13 +204,15 @@ export const Reply = as<'div', ReplyProps>( })()} ) : ( - + (isRedacted && ) || ( + + ) )} {replyEvent === null && ( From 9e77fdb060add4f6c8f53181fb1be26101d7138f Mon Sep 17 00:00:00 2001 From: Shea Duma Date: Fri, 20 Mar 2026 08:11:40 +0200 Subject: [PATCH 8/8] reverted to colorful name look --- src/app/components/message/Reply.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index 37cfd67e5..16f9f191a 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -50,11 +50,7 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>( {!!icon && } - + {username}