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
+3 -69
View File
@@ -17,7 +17,7 @@ import {
color,
config,
} from 'folds';
import { MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import { MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import classNames from 'classnames';
import { useNearViewport } from '../../hooks/useNearViewport';
@@ -26,7 +26,8 @@ import { usePan, Pan } from '../../hooks/usePan';
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
import { useDecryptedMediaUrl } from '../../hooks/useDecryptedMediaUrl';
import { getThumbMxc } from '../../utils/mediaThumb';
import { AudioContent, FileDownloadButton } from '../../components/message';
import { MediaControl } from '../../components/media';
import { getBlobSafeMimeType, mimeTypeToExt } from '../../utils/mimeTypes';
@@ -56,67 +57,6 @@ const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
// ── Decrypt hook ──────────────────────────────────────────────────────────────
type DecryptState = { status: 'loading' } | { status: 'ok'; url: string } | { status: 'error' };
export function useDecryptedMediaUrl(
mx: MatrixClient,
mxcUrl: string | undefined,
encInfo: IEncryptedFile | undefined,
useAuthentication: boolean,
mimeType?: string,
enabled = true,
): DecryptState {
const [state, setState] = useState<DecryptState>({ status: 'loading' });
const prevBlobUrl = useRef<string | null>(null);
useEffect(() => {
if (!enabled) return undefined;
if (!mxcUrl) {
setState({ status: 'error' });
return;
}
let cancelled = false;
setState({ status: 'loading' });
const run = async () => {
const httpUrl = mxcUrlToHttp(mx, mxcUrl, useAuthentication);
if (!httpUrl) throw new Error('bad url');
if (encInfo) {
const blob = await downloadEncryptedMedia(httpUrl, (buf) =>
decryptFile(buf, mimeType ?? 'application/octet-stream', encInfo),
);
const blobUrl = URL.createObjectURL(blob);
if (cancelled) {
URL.revokeObjectURL(blobUrl);
return;
}
if (prevBlobUrl.current) URL.revokeObjectURL(prevBlobUrl.current);
prevBlobUrl.current = blobUrl;
setState({ status: 'ok', url: blobUrl });
} else {
setState({ status: 'ok', url: httpUrl });
}
};
run().catch(() => {
if (!cancelled) setState({ status: 'error' });
});
return () => {
cancelled = true;
};
}, [mx, mxcUrl, encInfo, useAuthentication, mimeType, enabled]);
useEffect(
() => () => {
if (prevBlobUrl.current) URL.revokeObjectURL(prevBlobUrl.current);
},
[],
);
return state;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatBytes(bytes: number): string {
@@ -154,12 +94,6 @@ function getSenderName(room: Room, userId: string): string {
// the grid and the lightbox must use this so their positional indices stay in
// lockstep — otherwise a tile skipped for lack of a thumb would shift the
// lightbox and open the wrong media.
export function getThumbMxc(mEvent: MatrixEvent): string | undefined {
const c = mEvent.getContent();
const isEnc = !!c.file;
const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
return isEnc ? (info?.thumbnail_file?.url ?? c.file?.url) : (info?.thumbnail_url ?? c.url);
}
// ── Lightbox ──────────────────────────────────────────────────────────────────
+11 -3
View File
@@ -96,6 +96,7 @@ import {
} from '../../state/upload';
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
import { filesToUploadItems } from '../../utils/uploadItems';
import { ReplyMediaThumb, hasReplyMedia } from '../../components/message/ReplyMediaThumb';
import { fulfilledPromiseSettledResult } from '../../utils/common';
import { useSetting } from '../../state/hooks/settings';
import { useAlive } from '../../hooks/useAlive';
@@ -225,6 +226,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(draftKey));
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(draftKey));
const replyUserID = replyDraft?.userId;
// [Gitea #151] The quoted event, for a media thumbnail in the draft preview.
const replyDraftEvent = replyDraft ? room.findEventById(replyDraft.eventId) : undefined;
const powerLevelTags = usePowerLevelTags(room, powerLevels);
const creatorsTag = useRoomCreatorsTag();
@@ -1159,9 +1162,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</Text>
}
>
<Text size="T300" truncate>
{trimReplyFromBody(replyDraft.body)}
</Text>
<Box alignItems="Center" gap="200" style={{ minWidth: 0 }}>
{replyDraftEvent && hasReplyMedia(replyDraftEvent) && (
<ReplyMediaThumb mEvent={replyDraftEvent} />
)}
<Text size="T300" truncate>
{trimReplyFromBody(replyDraft.body)}
</Text>
</Box>
</ReplyLayout>
</Box>
</Box>
+2 -1
View File
@@ -93,7 +93,8 @@ import {
import { getLastEditDiff } from '../../utils/editDiff';
import { GroupCandidate, GroupPlan, planMediaGroups } from '../../utils/mediaGroups';
import { MediaGroupGrid, RegroupChip } from './message/MediaGroupGrid';
import { Lightbox, getThumbMxc, toLightboxItems } from './MediaGallery';
import { Lightbox, toLightboxItems } from './MediaGallery';
import { getThumbMxc } from '../../utils/mediaThumb';
import { useSetting } from '../../state/hooks/settings';
import { MessageLayout, settingsAtom } from '../../state/settings';
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
@@ -4,7 +4,8 @@ import { MatrixEvent, MsgType } from 'matrix-js-sdk';
import { BlurhashCanvas } from 'react-blurhash';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { getThumbMxc, useDecryptedMediaUrl } from '../MediaGallery';
import { useDecryptedMediaUrl } from '../../../hooks/useDecryptedMediaUrl';
import { getThumbMxc } from '../../../utils/mediaThumb';
import { validBlurHash } from '../../../utils/blurHash';
import { MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
import * as css from './MediaGroupGrid.css';