2026-09-20 00:48:13 -04:00
|
|
|
|
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';
|
2026-09-20 14:27:23 -04:00
|
|
|
|
import { useDecryptedMediaUrl } from '../../../hooks/useDecryptedMediaUrl';
|
|
|
|
|
|
import { getThumbMxc } from '../../../utils/mediaThumb';
|
2026-09-20 00:48:13 -04:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|