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
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:
@@ -8,6 +8,7 @@ import { getMxIdLocalPart } from '../../utils/matrix';
|
|||||||
import { LinePlaceholder } from './placeholder';
|
import { LinePlaceholder } from './placeholder';
|
||||||
import { randomNumberBetween } from '../../utils/common';
|
import { randomNumberBetween } from '../../utils/common';
|
||||||
import * as css from './Reply.css';
|
import * as css from './Reply.css';
|
||||||
|
import { ReplyMediaThumb, hasReplyMedia } from './ReplyMediaThumb';
|
||||||
import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content';
|
import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content';
|
||||||
import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
|
import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
|
||||||
import { useRoomEvent } from '../../hooks/useRoomEvent';
|
import { useRoomEvent } from '../../hooks/useRoomEvent';
|
||||||
@@ -143,9 +144,12 @@ export const Reply = as<'div', ReplyProps>(
|
|||||||
<i>Original message not available</i>
|
<i>Original message not available</i>
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text size="T300" truncate>
|
<Box alignItems="Center" gap="200" style={{ minWidth: 0 }}>
|
||||||
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
|
{hasReplyMedia(replyEvent) && <ReplyMediaThumb mEvent={replyEvent} />}
|
||||||
</Text>
|
<Text size="T300" truncate>
|
||||||
|
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
</ReplyLayout>
|
</ReplyLayout>
|
||||||
</Box>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
color,
|
color,
|
||||||
config,
|
config,
|
||||||
} from 'folds';
|
} 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 FocusTrap from 'focus-trap-react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useNearViewport } from '../../hooks/useNearViewport';
|
import { useNearViewport } from '../../hooks/useNearViewport';
|
||||||
@@ -26,7 +26,8 @@ import { usePan, Pan } from '../../hooks/usePan';
|
|||||||
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
|
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
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 { AudioContent, FileDownloadButton } from '../../components/message';
|
||||||
import { MediaControl } from '../../components/media';
|
import { MediaControl } from '../../components/media';
|
||||||
import { getBlobSafeMimeType, mimeTypeToExt } from '../../utils/mimeTypes';
|
import { getBlobSafeMimeType, mimeTypeToExt } from '../../utils/mimeTypes';
|
||||||
@@ -56,67 +57,6 @@ const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
|
|||||||
|
|
||||||
// ── Decrypt hook ──────────────────────────────────────────────────────────────
|
// ── 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 ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
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
|
// 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
|
// lockstep — otherwise a tile skipped for lack of a thumb would shift the
|
||||||
// lightbox and open the wrong media.
|
// 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 ──────────────────────────────────────────────────────────────────
|
// ── Lightbox ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ import {
|
|||||||
} from '../../state/upload';
|
} from '../../state/upload';
|
||||||
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
|
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
|
||||||
import { filesToUploadItems } from '../../utils/uploadItems';
|
import { filesToUploadItems } from '../../utils/uploadItems';
|
||||||
|
import { ReplyMediaThumb, hasReplyMedia } from '../../components/message/ReplyMediaThumb';
|
||||||
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { useAlive } from '../../hooks/useAlive';
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
@@ -225,6 +226,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(draftKey));
|
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(draftKey));
|
||||||
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(draftKey));
|
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(draftKey));
|
||||||
const replyUserID = replyDraft?.userId;
|
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 powerLevelTags = usePowerLevelTags(room, powerLevels);
|
||||||
const creatorsTag = useRoomCreatorsTag();
|
const creatorsTag = useRoomCreatorsTag();
|
||||||
@@ -1159,9 +1162,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text size="T300" truncate>
|
<Box alignItems="Center" gap="200" style={{ minWidth: 0 }}>
|
||||||
{trimReplyFromBody(replyDraft.body)}
|
{replyDraftEvent && hasReplyMedia(replyDraftEvent) && (
|
||||||
</Text>
|
<ReplyMediaThumb mEvent={replyDraftEvent} />
|
||||||
|
)}
|
||||||
|
<Text size="T300" truncate>
|
||||||
|
{trimReplyFromBody(replyDraft.body)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
</ReplyLayout>
|
</ReplyLayout>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -93,7 +93,8 @@ import {
|
|||||||
import { getLastEditDiff } from '../../utils/editDiff';
|
import { getLastEditDiff } from '../../utils/editDiff';
|
||||||
import { GroupCandidate, GroupPlan, planMediaGroups } from '../../utils/mediaGroups';
|
import { GroupCandidate, GroupPlan, planMediaGroups } from '../../utils/mediaGroups';
|
||||||
import { MediaGroupGrid, RegroupChip } from './message/MediaGroupGrid';
|
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 { useSetting } from '../../state/hooks/settings';
|
||||||
import { MessageLayout, settingsAtom } from '../../state/settings';
|
import { MessageLayout, settingsAtom } from '../../state/settings';
|
||||||
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { MatrixEvent, MsgType } from 'matrix-js-sdk';
|
|||||||
import { BlurhashCanvas } from 'react-blurhash';
|
import { BlurhashCanvas } from 'react-blurhash';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
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 { validBlurHash } from '../../../utils/blurHash';
|
||||||
import { MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
|
import { MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
|
||||||
import * as css from './MediaGroupGrid.css';
|
import * as css from './MediaGroupGrid.css';
|
||||||
|
|||||||
@@ -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<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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user