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) {