Files
cinny/src/app/features/room/message/MediaGroupGrid.tsx
T
jaredandClaude Opus 5 22d46a7922
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
feat(messages): reply quotes show a media thumbnail (#151)
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
2026-09-20 14:27:23 -04:00

150 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } from 'react';
import { Icon, Icons, Spinner, Text } from 'folds';
import { MatrixEvent, MsgType } from 'matrix-js-sdk';
import { BlurhashCanvas } from 'react-blurhash';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
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';
/** Column count for `n` tiles: 2 → 2, 3 → 3, 4 → 2×2, 5–6 → 3, 7+ → 4. */
export const gridColumns = (n: number): number => {
if (n <= 2) return 2;
if (n === 3) return 3;
if (n === 4) return 2;
if (n <= 6) return 3;
return 4;
};
/** "5 photos" / "2 videos" / "6 items". */
export const describeGroup = (events: MatrixEvent[]): string => {
const videos = events.filter((e) => e.getContent().msgtype === MsgType.Video).length;
const n = events.length;
if (videos === 0) return `${n} photos`;
if (videos === n) return `${n} videos`;
return `${n} items`;
};
function Cell({
mEvent,
load,
onOpen,
}: {
mEvent: MatrixEvent;
load: boolean;
onOpen: (eventId: string) => void;
}) {
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 blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME] as string | undefined);
const media = useDecryptedMediaUrl(mx, thumbMxc, encInfo, useAuthentication, mimeType, load);
const body = typeof content.body === 'string' ? content.body : '';
return (
<button
type="button"
className={css.Cell}
aria-label={body || (isVideo ? 'Video' : 'Image')}
onClick={() => onOpen(mEvent.getId() ?? '')}
>
{blurHash && media.status !== 'ok' && (
<BlurhashCanvas className={css.CellBlur} hash={blurHash} width={32} height={32} punch={1} />
)}
{load && media.status === 'loading' && <Spinner size="200" />}
{media.status === 'error' && <Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />}
{!load && !blurHash && <Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />}
{media.status === 'ok' && <img src={media.url} alt="" className={css.CellImg} />}
{isVideo && (
<span className={css.PlayBadge}>
<Icon src={Icons.Play} size="200" filled />
</span>
)}
</button>
);
}
type MediaGroupGridProps = {
events: MatrixEvent[];
mediaAutoLoad: boolean;
onOpen: (eventId: string) => void;
onShowSeparately: () => void;
};
/**
* [Gitea #137] Several consecutive image/video events from one sender shown
* as one grid. Each tile opens the room's shared lightbox at that event, so
* ←/→ walk through the group (and beyond). Purely a render-time grouping.
*/
export function MediaGroupGrid({
events,
mediaAutoLoad,
onOpen,
onShowSeparately,
}: MediaGroupGridProps) {
const [load, setLoad] = useState(mediaAutoLoad);
const columns = gridColumns(events.length);
return (
<div className={css.Wrap}>
<div
className={css.Grid}
role="group"
aria-label={describeGroup(events)}
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
onClick={load ? undefined : () => setLoad(true)}
>
{events.map((ev) => (
<Cell
key={ev.getId()}
mEvent={ev}
load={load}
onOpen={load ? onOpen : () => setLoad(true)}
/>
))}
</div>
<div className={css.Footer}>
<Text size="T200" priority="300">
{describeGroup(events)}
{!load && ' · tap to load'}
</Text>
<Text size="T200" priority="300">
·
</Text>
<Text
as="button"
size="T200"
priority="300"
className={css.FooterButton}
onClick={onShowSeparately}
>
Show separately
</Text>
</div>
</div>
);
}
/** Shown under the last of a gallery the user split up, to put it back together. */
export function RegroupChip({ onClick }: { onClick: () => void }) {
return (
<div className={css.Footer}>
<Text as="button" size="T200" priority="300" className={css.FooterButton} onClick={onClick}>
Show as gallery
</Text>
</div>
);
}