Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-reply-preview-blank-bodies.md
Original file line number Diff line number Diff line change
@@ -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.
90 changes: 89 additions & 1 deletion src/app/components/message/Reply.test.tsx
Original file line number Diff line number Diff line change
@@ -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 */

Expand Down Expand Up @@ -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(
<span>
{replyPreviewBodyForTimelineEvent(
EventType.RoomMessage as string,
{
msgtype: MsgType.Image,
body: '',
filename: 'vacation.png',
},
false
)}
</span>
);
expect(container).toHaveTextContent('vacation.png');
});

it('falls back to Image when an image message has no body or filename', () => {
const { container } = render(
<span>
{replyPreviewBodyForTimelineEvent(
EventType.RoomMessage as string,
{
msgtype: MsgType.Image,
body: '',
},
false
)}
</span>
);
expect(container).toHaveTextContent('Image');
});

it('renders deleted content for redacted timeline messages', () => {
render(
<span>
{replyPreviewBodyForTimelineEvent(
EventType.RoomMessage as string,
{ msgtype: MsgType.Text },
true
)}
</span>
);
expect(screen.getByText(/This message has been deleted/i)).toBeInTheDocument();
});

it('shows Sticker when a sticker event has no body', () => {
const { container } = render(
<span>
{replyPreviewBodyForTimelineEvent(EventType.Sticker as string, { body: '' }, false)}
</span>
);
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(
<Reply
room={{ roomId: '!room:example.com', getMember: () => 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(
Expand Down
118 changes: 99 additions & 19 deletions src/app/components/message/Reply.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string>([
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<string, unknown>,
isRedacted: boolean
): ReactNode | undefined => {
if (!eventType || !ROOM_REPLY_TIMELINE_EVENT_TYPES.has(eventType)) return undefined;
if (isRedacted) return <MessageDeletedContent />;

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 <MessageUnsupportedContent />;
}
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 <MessageEmptyContent />;
default:
return <MessageUnsupportedContent />;
}
};

type ReplyLayoutProps = {
userColor?: string;
Expand Down Expand Up @@ -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(/<br\s*\/?>/gi, ' ')
.replaceAll(/<[^>]+>/g, '')
Comment thread
7w1 marked this conversation as resolved.
Dismissed
.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);
Expand Down Expand Up @@ -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 '}
<code className={customHtmlCss.Code}>{eventType}</code>
{' state event'}
</>
} else if (replyEvent && eventType) {
const timelinePreview = replyPreviewBodyForTimelineEvent(
eventType,
replyEvent.getContent() as Record<string, unknown>,
isRedacted
);
if (timelinePreview !== undefined) {
bodyJSX = timelinePreview;
} else if (replyEvent.isState()) {
image = Icons.Code;
bodyJSX = (
<>
{' sent '}
<code className={customHtmlCss.Code}>{eventType}</code>
{' state event'}
</>
);
} else {
bodyJSX = <MessageUnsupportedContent />;
}
}
let replyContent = bodyJSX;
if (isBlockedSender) {
Expand Down
Loading