feat(messages): reply quotes show a media thumbnail (#151)
CI / Build & Quality Checks (push) Successful in 1m46s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 11s
CI / Playwright smoke (e2e) (push) Successful in 9m44s

A reply to an image, video or sticker used to quote just the filename.
The quote (timeline) and the composer's reply-draft preview now carry a
36 px thumbnail from the event's own thumbnail, decrypted for E2EE media
via the same hook the gallery uses — never the full-size file. Clicking
still jumps to the original.

useDecryptedMediaUrl and getThumbMxc moved out of MediaGallery into
hooks/ and utils/ so components/message can use them without a cycle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-20 14:27:23 -04:00
co-authored by Claude Opus 5
parent be2c202543
commit 22d46a7922
8 changed files with 175 additions and 77 deletions
+7 -3
View File
@@ -8,6 +8,7 @@ import { getMxIdLocalPart } from '../../utils/matrix';
import { LinePlaceholder } from './placeholder';
import { randomNumberBetween } from '../../utils/common';
import * as css from './Reply.css';
import { ReplyMediaThumb, hasReplyMedia } from './ReplyMediaThumb';
import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content';
import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
import { useRoomEvent } from '../../hooks/useRoomEvent';
@@ -143,9 +144,12 @@ export const Reply = as<'div', ReplyProps>(
<i>Original message not available</i>
</Text>
) : (
<Text size="T300" truncate>
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
</Text>
<Box alignItems="Center" gap="200" style={{ minWidth: 0 }}>
{hasReplyMedia(replyEvent) && <ReplyMediaThumb mEvent={replyEvent} />}
<Text size="T300" truncate>
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
</Text>
</Box>
)}
</ReplyLayout>
</Box>
@@ -0,0 +1,75 @@
import React from 'react';
import { Icon, Icons, config, toRem } from 'folds';
import { MatrixEvent, MsgType } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useDecryptedMediaUrl } from '../../hooks/useDecryptedMediaUrl';
import { getThumbMxc } from '../../utils/mediaThumb';
import { MessageEvent } from '../../../types/matrix/room';
const SIZE = 36;
/** Whether a reply quote for this event should carry a thumbnail. */
export const hasReplyMedia = (mEvent: MatrixEvent | null | undefined): boolean => {
if (!mEvent || mEvent.isRedacted()) return false;
if (mEvent.getType() === MessageEvent.Sticker) return true;
const msgtype = mEvent.getContent().msgtype;
return msgtype === MsgType.Image || msgtype === MsgType.Video;
};
/**
* [Gitea #151] 36 px thumbnail in a reply quote for an image/video/sticker.
* Uses the event's own thumbnail (decrypting it for E2EE media), never the
* full-size file.
*/
export function ReplyMediaThumb({ mEvent }: { mEvent: MatrixEvent }) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const content = mEvent.getContent();
const isVideo = content.msgtype === MsgType.Video;
const thumbMxc = getThumbMxc(mEvent);
const info = content.info as Record<string, unknown> | undefined;
const encInfo = content.file
? ((info?.thumbnail_file as typeof content.file | undefined) ?? content.file)
: undefined;
const mimeType =
(info?.thumbnail_info as { mimetype?: string } | undefined)?.mimetype ??
(info?.mimetype as string | undefined);
const media = useDecryptedMediaUrl(mx, thumbMxc, encInfo, useAuthentication, mimeType);
return (
<span
aria-hidden
style={{
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: toRem(SIZE),
height: toRem(SIZE),
flexShrink: 0,
borderRadius: config.radii.R300,
overflow: 'hidden',
background: 'rgba(127, 127, 127, 0.15)',
}}
>
{media.status === 'ok' ? (
<img
src={media.url}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<Icon size="100" src={isVideo ? Icons.Play : Icons.Photo} />
)}
{isVideo && media.status === 'ok' && (
<Icon
size="50"
src={Icons.Play}
filled
style={{ position: 'absolute', color: 'white', filter: 'drop-shadow(0 0 2px black)' }}
/>
)}
</span>
);
}