diff --git a/.changeset/fix-reply-preview-blank-bodies.md b/.changeset/fix-reply-preview-blank-bodies.md
new file mode 100644
index 000000000..55d8e3b11
--- /dev/null
+++ b/.changeset/fix-reply-preview-blank-bodies.md
@@ -0,0 +1,5 @@
+---
+default: patch
+---
+
+Fixed reply chips for deleted messages and media without captions showing `m.room.message` type instead of the event.
diff --git a/src/app/components/message/Reply.test.tsx b/src/app/components/message/Reply.test.tsx
index 9c7975a16..f837f84aa 100644
--- a/src/app/components/message/Reply.test.tsx
+++ b/src/app/components/message/Reply.test.tsx
@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
-import { Reply } from './Reply';
+import { EventType, MsgType } from '$types/matrix-sdk';
+import { Reply, replyPreviewBodyForTimelineEvent } from './Reply';
/* oxlint-disable typescript/no-explicit-any */
@@ -91,7 +92,94 @@ const createReplyEvent = (formattedBody: string) =>
getClearContent: () => ({}),
}) as any;
+describe('replyPreviewBodyForTimelineEvent', () => {
+ it('uses filename for image messages with an empty body', () => {
+ const { container } = render(
+
+ {replyPreviewBodyForTimelineEvent(
+ EventType.RoomMessage as string,
+ {
+ msgtype: MsgType.Image,
+ body: '',
+ filename: 'vacation.png',
+ },
+ false
+ )}
+
+ );
+ expect(container).toHaveTextContent('vacation.png');
+ });
+
+ it('falls back to Image when an image message has no body or filename', () => {
+ const { container } = render(
+
+ {replyPreviewBodyForTimelineEvent(
+ EventType.RoomMessage as string,
+ {
+ msgtype: MsgType.Image,
+ body: '',
+ },
+ false
+ )}
+
+ );
+ expect(container).toHaveTextContent('Image');
+ });
+
+ it('renders deleted content for redacted timeline messages', () => {
+ render(
+
+ {replyPreviewBodyForTimelineEvent(
+ EventType.RoomMessage as string,
+ { msgtype: MsgType.Text },
+ true
+ )}
+
+ );
+ expect(screen.getByText(/This message has been deleted/i)).toBeInTheDocument();
+ });
+
+ it('shows Sticker when a sticker event has no body', () => {
+ const { container } = render(
+
+ {replyPreviewBodyForTimelineEvent(EventType.Sticker as string, { body: '' }, false)}
+
+ );
+ expect(container).toHaveTextContent('Sticker');
+ });
+});
+
+const createImageReplyEvent = (filename?: string) =>
+ ({
+ getContent: () => ({
+ msgtype: MsgType.Image,
+ body: '',
+ ...(filename !== undefined ? { filename } : {}),
+ }),
+ getSender: () => '@alice:example.com',
+ getType: () => EventType.RoomMessage,
+ isRedacted: () => false,
+ isEncrypted: () => false,
+ isDecryptionFailure: () => false,
+ getClearContent: () => ({ msgtype: MsgType.Image }),
+ isState: () => false,
+ }) as any;
+
describe('Reply', () => {
+ it('shows an image filename in the reply chip when the body is blank', () => {
+ mockUseRoomEvent.mockReturnValue(createImageReplyEvent('screenshot.png'));
+
+ render(
+ undefined } as any}
+ replyEventId="$reply:example.com"
+ />
+ );
+
+ expect(screen.getByText('screenshot.png')).toBeInTheDocument();
+ expect(screen.queryByText(/state event/i)).not.toBeInTheDocument();
+ });
+
it('sanitizes formatted_body before trimming and parsing the reply preview', () => {
mockUseRoomEvent.mockReturnValue(
createReplyEvent(
diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx
index 86abbacb9..6672e5641 100644
--- a/src/app/components/message/Reply.tsx
+++ b/src/app/components/message/Reply.tsx
@@ -1,6 +1,7 @@
import type { IconSrc } from 'folds';
import { Box, Chip, Icon, Icons, Text, as, color, toRem } from 'folds';
import type { EventTimelineSet, IMentions, Room, SessionMembershipData } from '$types/matrix-sdk';
+import { EventType, MsgType } from '$types/matrix-sdk';
import type { MouseEventHandler, ReactNode } from 'react';
import { useCallback, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
@@ -37,11 +38,72 @@ import {
MessageBadEncryptedContent,
MessageBlockedContent,
MessageDeletedContent,
+ MessageEmptyContent,
MessageFailedContent,
+ MessageUnsupportedContent,
} from './content';
import * as css from './Reply.css';
import { LinePlaceholder } from './placeholder';
-import { EventType } from '$types/matrix-sdk';
+
+const ROOM_REPLY_TIMELINE_EVENT_TYPES = new Set([
+ EventType.RoomMessage as string,
+ EventType.RoomMessageEncrypted as string,
+ EventType.Sticker as string,
+]);
+
+const nonEmptyTrimmed = (v: unknown): string | undefined => {
+ if (typeof v !== 'string') return undefined;
+ const t = v.trim();
+ return t.length > 0 ? t : undefined;
+};
+
+export const replyPreviewBodyForTimelineEvent = (
+ eventType: string | undefined,
+ content: Record,
+ isRedacted: boolean
+): ReactNode | undefined => {
+ if (!eventType || !ROOM_REPLY_TIMELINE_EVENT_TYPES.has(eventType)) return undefined;
+ if (isRedacted) return ;
+
+ if (eventType === (EventType.Sticker as string)) {
+ const stickerBody = nonEmptyTrimmed(content.body);
+ if (stickerBody) return scaleSystemEmoji(stickerBody);
+ return 'Sticker';
+ }
+
+ const rawMsgtype = content.msgtype;
+ if (typeof rawMsgtype !== 'string') {
+ return ;
+ }
+ const msgtype = rawMsgtype as MsgType;
+
+ const trimmedBody = nonEmptyTrimmed(
+ typeof content.body === 'string' ? trimReplyFromBody(content.body) : ''
+ );
+ const filename = nonEmptyTrimmed(content.filename);
+ if (trimmedBody) return undefined;
+
+ const attachmentLabel = filename;
+
+ switch (msgtype) {
+ case MsgType.Image:
+ return attachmentLabel ?? 'Image';
+ case MsgType.Video:
+ return attachmentLabel ?? 'Video';
+ case MsgType.Audio:
+ return attachmentLabel ?? 'Audio';
+ case MsgType.File:
+ return attachmentLabel ?? 'Attachment';
+ case MsgType.Location:
+ return 'Location';
+ case MsgType.Text:
+ case MsgType.Emote:
+ case MsgType.Notice:
+ return ;
+ default:
+ return ;
+ }
+};
type ReplyLayoutProps = {
userColor?: string;
@@ -194,16 +256,23 @@ export const Reply = as<'div', ReplyProps>(
if (isFormattedReply && formattedBody !== '') {
const sanitizedHtml = sanitizeReplyFormattedPreview(formattedBody);
- const parserOpts = getReactCustomHtmlParser(mx, room.roomId, {
- settingsLinkBaseUrl,
- linkifyOpts: replyLinkifyOpts,
- useAuthentication,
- nicknames,
- handleMentionClick: mentionClickHandler,
- incomingInlineImagesDefaultHeight,
- incomingInlineImagesMaxHeight,
- });
- bodyJSX = parse(sanitizedHtml, parserOpts) as JSX.Element;
+ const textOnly = sanitizedHtml
+ .replaceAll(/
/gi, ' ')
+ .replaceAll(/<[^>]+>/g, '')
+ .replaceAll(/\s+/g, ' ')
+ .trim();
+ if (textOnly !== '') {
+ const parserOpts = getReactCustomHtmlParser(mx, room.roomId, {
+ settingsLinkBaseUrl,
+ linkifyOpts: replyLinkifyOpts,
+ useAuthentication,
+ nicknames,
+ handleMentionClick: mentionClickHandler,
+ incomingInlineImagesDefaultHeight,
+ incomingInlineImagesMaxHeight,
+ });
+ bodyJSX = parse(sanitizedHtml, parserOpts) as JSX.Element;
+ }
} else if (hasPlainTextReply) {
const strippedBody = trimReplyFromBody(body).replaceAll(/(?:\r\n|\r|\n)/g, ' ');
bodyJSX = scaleSystemEmoji(strippedBody);
@@ -252,15 +321,26 @@ export const Reply = as<'div', ReplyProps>(
`has not changed the pins`}
>
);
- } else if (Object.values(MessageEvent).every((v) => v !== eventType && !!eventType)) {
- image = Icons.Code;
- bodyJSX = (
- <>
- {' sent '}
- {eventType}
- {' state event'}
- >
+ } else if (replyEvent && eventType) {
+ const timelinePreview = replyPreviewBodyForTimelineEvent(
+ eventType,
+ replyEvent.getContent() as Record,
+ isRedacted
);
+ if (timelinePreview !== undefined) {
+ bodyJSX = timelinePreview;
+ } else if (replyEvent.isState()) {
+ image = Icons.Code;
+ bodyJSX = (
+ <>
+ {' sent '}
+ {eventType}
+ {' state event'}
+ >
+ );
+ } else {
+ bodyJSX = ;
+ }
}
let replyContent = bodyJSX;
if (isBlockedSender) {