Files
mastodon/app/javascript/flavours/glitch/features/ui/components/media_modal.tsx
Renaud Chaput 5b75667c03 [Glitch] Update to latest eslint-plugin-react-hooks
Port 9addad8ce5 to glitch-soc

Co-authored-by: diondiondion <mail@diondiondion.com>
Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-10 22:29:58 +01:00

370 lines
11 KiB
TypeScript

import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
import type { RefCallback, FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import type { List as ImmutableList } from 'immutable';
import { animated, useSpring } from '@react-spring/web';
import { useDrag } from '@use-gesture/react';
import type { MediaAttachment } from '@/flavours/glitch/models/status';
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import FitScreenIcon from '@/material-icons/400-24px/fit_screen.svg?react';
import ActualSizeIcon from '@/svg-icons/actual_size.svg?react';
import type { RGB } from 'flavours/glitch/blurhash';
import { getAverageFromBlurhash } from 'flavours/glitch/blurhash';
import { GIFV } from 'flavours/glitch/components/gifv';
import { Icon } from 'flavours/glitch/components/icon';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { Footer } from 'flavours/glitch/features/picture_in_picture/components/footer';
import { Video } from 'flavours/glitch/features/video';
import { ZoomableImage } from './zoomable_image';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
next: { id: 'lightbox.next', defaultMessage: 'Next' },
zoomIn: { id: 'lightbox.zoom_in', defaultMessage: 'Zoom to actual size' },
zoomOut: { id: 'lightbox.zoom_out', defaultMessage: 'Zoom to fit' },
});
interface MediaModalProps {
media: ImmutableList<MediaAttachment>;
statusId?: string;
lang?: string;
index: number;
onClose: () => void;
onChangeBackgroundColor: (color: RGB | null) => void;
currentTime?: number;
autoPlay?: boolean;
volume?: number;
}
export const MediaModal: FC<MediaModalProps> = forwardRef<
HTMLDivElement,
MediaModalProps
>(
(
{
media,
onClose,
index: startIndex,
lang,
currentTime,
autoPlay,
volume,
statusId,
onChangeBackgroundColor,
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- _ref is required to keep the ref forwarding working
_ref,
) => {
const [index, setIndex] = useState(startIndex);
const [zoomedIn, setZoomedIn] = useState(false);
const currentMedia = media.get(index);
const handleChangeIndex = useCallback(
(newIndex: number) => {
if (newIndex < 0) {
newIndex = media.size + newIndex;
}
setIndex(newIndex % media.size);
setZoomedIn(false);
},
[media.size],
);
const handlePrevClick = useCallback(() => {
handleChangeIndex(index - 1);
}, [handleChangeIndex, index]);
const handleNextClick = useCallback(() => {
handleChangeIndex(index + 1);
}, [handleChangeIndex, index]);
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'ArrowLeft') {
handlePrevClick();
event.preventDefault();
event.stopPropagation();
} else if (event.key === 'ArrowRight') {
handleNextClick();
event.preventDefault();
event.stopPropagation();
}
},
[handleNextClick, handlePrevClick],
);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown, false);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
useEffect(() => {
const blurhash = currentMedia?.get('blurhash') as string | undefined;
if (blurhash) {
const backgroundColor = getAverageFromBlurhash(blurhash);
if (backgroundColor) {
onChangeBackgroundColor(backgroundColor);
}
}
return () => {
onChangeBackgroundColor(null);
};
}, [currentMedia, onChangeBackgroundColor]);
const [viewportDimensions, setViewportDimensions] = useState<{
width: number;
height: number;
}>({ width: 0, height: 0 });
const handleRef: RefCallback<HTMLDivElement> = useCallback((ele) => {
if (ele?.clientWidth && ele.clientHeight) {
setViewportDimensions({
width: ele.clientWidth,
height: ele.clientHeight,
});
}
}, []);
const zoomable =
currentMedia?.get('type') === 'image' &&
((currentMedia.getIn(['meta', 'original', 'width']) as number) >
viewportDimensions.width ||
(currentMedia.getIn(['meta', 'original', 'height']) as number) >
viewportDimensions.height);
const handleZoomClick = useCallback(() => {
setZoomedIn((prev) => !prev);
}, []);
const wrapperStyles = useSpring({
x: `-${index * 100}%`,
});
const bind = useDrag(
({ swipe: [swipeX] }) => {
if (swipeX === 0) return;
handleChangeIndex(index + swipeX * -1); // Invert swipe as swiping left loads the next slide.
},
{ pointer: { capture: false } },
);
const [navigationHidden, setNavigationHidden] = useState(false);
const handleToggleNavigation = useCallback(() => {
setNavigationHidden((prev) => !prev);
}, []);
const content = useMemo(
() =>
media.map((item, idx) => {
const url = item.get('url') as string;
const blurhash = item.get('blurhash') as string;
const width = item.getIn(['meta', 'original', 'width'], 0) as number;
const height = item.getIn(
['meta', 'original', 'height'],
0,
) as number;
const description = item.getIn(
['translation', 'description'],
item.get('description'),
) as string;
if (item.get('type') === 'image') {
return (
<ZoomableImage
src={url}
blurhash={blurhash}
width={width}
height={height}
alt={description}
lang={lang}
key={url}
onClick={handleToggleNavigation}
onDoubleClick={handleZoomClick}
onClose={onClose}
onZoomChange={setZoomedIn}
zoomedIn={zoomedIn && idx === index}
/>
);
} else if (item.get('type') === 'video') {
return (
<Video
preview={item.get('preview_url') as string | undefined}
blurhash={blurhash}
src={url}
frameRate={
item.getIn(['meta', 'original', 'frame_rate']) as
| string
| undefined
}
aspectRatio={`${width} / ${height}`}
startTime={currentTime ?? 0}
startPlaying={autoPlay ?? false}
startVolume={volume ?? 1}
onCloseVideo={onClose}
detailed
alt={description}
lang={lang}
key={url}
/>
);
} else if (item.get('type') === 'gifv') {
return (
<GIFV
src={url}
key={url}
alt={description}
lang={lang}
onClick={handleToggleNavigation}
/>
);
}
return null;
}),
[
autoPlay,
currentTime,
handleToggleNavigation,
handleZoomClick,
index,
lang,
media,
onClose,
volume,
zoomedIn,
],
);
const intl = useIntl();
const leftNav = media.size > 1 && (
<button
tabIndex={0}
className='media-modal__nav media-modal__nav--prev'
onClick={handlePrevClick}
aria-label={intl.formatMessage(messages.previous)}
type='button'
>
<Icon id='chevron-left' icon={ChevronLeftIcon} />
</button>
);
const rightNav = media.size > 1 && (
<button
tabIndex={0}
className='media-modal__nav media-modal__nav--next'
onClick={handleNextClick}
aria-label={intl.formatMessage(messages.next)}
type='button'
>
<Icon id='chevron-right' icon={ChevronRightIcon} />
</button>
);
return (
<div
{...bind()}
className='modal-root__modal media-modal'
ref={handleRef}
>
<animated.div
style={wrapperStyles}
className='media-modal__closer'
role='presentation'
onClick={onClose}
>
{content}
</animated.div>
<div
className={classNames('media-modal__navigation', {
'media-modal__navigation--hidden': navigationHidden,
})}
>
<div className='media-modal__buttons'>
{zoomable && (
<IconButton
title={intl.formatMessage(
zoomedIn ? messages.zoomOut : messages.zoomIn,
)}
icon=''
iconComponent={zoomedIn ? FitScreenIcon : ActualSizeIcon}
onClick={handleZoomClick}
/>
)}
<IconButton
title={intl.formatMessage(messages.close)}
icon='times'
iconComponent={CloseIcon}
onClick={onClose}
/>
</div>
{leftNav}
{rightNav}
<div className='media-modal__overlay'>
<MediaPagination
itemsCount={media.size}
index={index}
onChangeIndex={handleChangeIndex}
/>
{statusId && (
<Footer statusId={statusId} withOpenButton onClose={onClose} />
)}
</div>
</div>
</div>
);
},
);
MediaModal.displayName = 'MediaModal';
interface MediaPaginationProps {
itemsCount: number;
index: number;
onChangeIndex: (newIndex: number) => void;
}
const MediaPagination: FC<MediaPaginationProps> = ({
itemsCount,
index,
onChangeIndex,
}) => {
const handleChangeIndex = useCallback(
(curIndex: number) => {
return () => {
onChangeIndex(curIndex);
};
},
[onChangeIndex],
);
if (itemsCount <= 1) {
return null;
}
return (
<ul className='media-modal__pagination'>
{Array.from({ length: itemsCount }).map((_, i) => (
<button
key={i}
className={classNames('media-modal__page-dot', {
active: i === index,
})}
onClick={handleChangeIndex(i)}
type='button'
>
{i + 1}
</button>
))}
</ul>
);
};