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); +}