diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx
index ba93fde20..4e76db621 100644
--- a/src/app/components/message/Reply.tsx
+++ b/src/app/components/message/Reply.tsx
@@ -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>(
Original message not available
) : (
-
- {badEncryption ? : bodyJSX}
-
+
+ {hasReplyMedia(replyEvent) && }
+
+ {badEncryption ? : bodyJSX}
+
+
)}
diff --git a/src/app/components/message/ReplyMediaThumb.tsx b/src/app/components/message/ReplyMediaThumb.tsx
new file mode 100644
index 000000000..75db4195d
--- /dev/null
+++ b/src/app/components/message/ReplyMediaThumb.tsx
@@ -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 | 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 (
+
+ {media.status === 'ok' ? (
+
+ ) : (
+
+ )}
+ {isVideo && media.status === 'ok' && (
+
+ )}
+
+ );
+}
diff --git a/src/app/features/room/MediaGallery.tsx b/src/app/features/room/MediaGallery.tsx
index 4d62f3e8d..ab53d5864 100644
--- a/src/app/features/room/MediaGallery.tsx
+++ b/src/app/features/room/MediaGallery.tsx
@@ -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 = {
// ── 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({ status: 'loading' });
- const prevBlobUrl = useRef(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 ──────────────────────────────────────────────────────────────────
diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx
index 36ec86659..5e2bbd140 100644
--- a/src/app/features/room/RoomInput.tsx
+++ b/src/app/features/room/RoomInput.tsx
@@ -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(
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(
}
>
-
- {trimReplyFromBody(replyDraft.body)}
-
+
+ {replyDraftEvent && hasReplyMedia(replyDraftEvent) && (
+
+ )}
+
+ {trimReplyFromBody(replyDraft.body)}
+
+
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index b0df974f2..1827bc1b6 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -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';
diff --git a/src/app/features/room/message/MediaGroupGrid.tsx b/src/app/features/room/message/MediaGroupGrid.tsx
index 9c9c54327..6c5f7a5a6 100644
--- a/src/app/features/room/message/MediaGroupGrid.tsx
+++ b/src/app/features/room/message/MediaGroupGrid.tsx
@@ -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';
diff --git a/src/app/hooks/useDecryptedMediaUrl.ts b/src/app/hooks/useDecryptedMediaUrl.ts
new file mode 100644
index 000000000..1b057e8fc
--- /dev/null
+++ b/src/app/hooks/useDecryptedMediaUrl.ts
@@ -0,0 +1,65 @@
+import { useEffect, useRef, useState } from 'react';
+import { MatrixClient } from 'matrix-js-sdk';
+import { IEncryptedFile } from '../../types/matrix/common';
+import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../utils/matrix';
+
+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({ status: 'loading' });
+ const prevBlobUrl = useRef(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;
+}
diff --git a/src/app/utils/mediaThumb.ts b/src/app/utils/mediaThumb.ts
new file mode 100644
index 000000000..039a26e40
--- /dev/null
+++ b/src/app/utils/mediaThumb.ts
@@ -0,0 +1,10 @@
+import { MatrixEvent } from 'matrix-js-sdk';
+import { IImageInfo, IThumbnailContent } from '../../types/matrix/common';
+
+/** The mxc to show as a thumbnail for an image/video event (thumbnail if present, else the file). */
+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);
+}