diff --git a/app/javascript/flavours/glitch/containers/media_container.jsx b/app/javascript/flavours/glitch/containers/media_container.jsx
index 88091f6013..bdf56c8185 100644
--- a/app/javascript/flavours/glitch/containers/media_container.jsx
+++ b/app/javascript/flavours/glitch/containers/media_container.jsx
@@ -10,7 +10,7 @@ import ModalRoot from 'flavours/glitch/components/modal_root';
import { Poll } from 'flavours/glitch/components/poll';
import { Audio } from 'flavours/glitch/features/audio';
import Card from 'flavours/glitch/features/status/components/card';
-import MediaModal from 'flavours/glitch/features/ui/components/media_modal';
+import { MediaModal } from 'flavours/glitch/features/ui/components/media_modal';
import { Video } from 'flavours/glitch/features/video';
import { IntlProvider } from 'flavours/glitch/locales';
import { createPollFromServerJSON } from 'flavours/glitch/models/poll';
diff --git a/app/javascript/flavours/glitch/features/ui/components/media_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/media_modal.jsx
deleted file mode 100644
index 1f59892d83..0000000000
--- a/app/javascript/flavours/glitch/features/ui/components/media_modal.jsx
+++ /dev/null
@@ -1,295 +0,0 @@
-import PropTypes from 'prop-types';
-
-import { defineMessages, injectIntl } from 'react-intl';
-
-import classNames from 'classnames';
-
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-import ReactSwipeableViews from 'react-swipeable-views';
-
-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 { 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 { disableSwiping } from 'flavours/glitch/initial_state';
-
-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' },
-});
-
-class MediaModal extends ImmutablePureComponent {
-
- static propTypes = {
- media: ImmutablePropTypes.list.isRequired,
- statusId: PropTypes.string,
- lang: PropTypes.string,
- index: PropTypes.number.isRequired,
- onClose: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- onChangeBackgroundColor: PropTypes.func.isRequired,
- currentTime: PropTypes.number,
- autoPlay: PropTypes.bool,
- volume: PropTypes.number,
- };
-
- state = {
- index: null,
- navigationHidden: false,
- zoomedIn: false,
- };
-
- handleZoomClick = () => {
- this.setState(prevState => ({
- zoomedIn: !prevState.zoomedIn,
- }));
- };
-
- handleZoomChange = (zoomedIn) => {
- this.setState({
- zoomedIn,
- });
- };
-
- handleSwipe = (index) => {
- this.setState({
- index: index % this.props.media.size,
- zoomedIn: false,
- });
- };
-
- handleTransitionEnd = () => {
- this.setState({
- zoomedIn: false,
- });
- };
-
- handleNextClick = () => {
- this.setState({
- index: (this.getIndex() + 1) % this.props.media.size,
- zoomedIn: false,
- });
- };
-
- handlePrevClick = () => {
- this.setState({
- index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size,
- zoomedIn: false,
- });
- };
-
- handleChangeIndex = (e) => {
- const index = Number(e.currentTarget.getAttribute('data-index'));
-
- this.setState({
- index: index % this.props.media.size,
- zoomedIn: false,
- });
- };
-
- handleKeyDown = (e) => {
- switch(e.key) {
- case 'ArrowLeft':
- this.handlePrevClick();
- e.preventDefault();
- e.stopPropagation();
- break;
- case 'ArrowRight':
- this.handleNextClick();
- e.preventDefault();
- e.stopPropagation();
- break;
- }
- };
-
- componentDidMount () {
- window.addEventListener('keydown', this.handleKeyDown, false);
-
- this._sendBackgroundColor();
- }
-
- componentDidUpdate (prevProps, prevState) {
- if (prevState.index !== this.state.index) {
- this._sendBackgroundColor();
- }
- }
-
- _sendBackgroundColor () {
- const { media, onChangeBackgroundColor } = this.props;
- const index = this.getIndex();
- const blurhash = media.getIn([index, 'blurhash']);
-
- if (blurhash) {
- const backgroundColor = getAverageFromBlurhash(blurhash);
- onChangeBackgroundColor(backgroundColor);
- }
- }
-
- componentWillUnmount () {
- window.removeEventListener('keydown', this.handleKeyDown);
-
- this.props.onChangeBackgroundColor(null);
- }
-
- getIndex () {
- return this.state.index !== null ? this.state.index : this.props.index;
- }
-
- handleToggleNavigation = () => {
- this.setState(prevState => ({
- navigationHidden: !prevState.navigationHidden,
- }));
- };
-
- setRef = c => {
- this.setState({
- viewportWidth: c?.clientWidth,
- viewportHeight: c?.clientHeight,
- });
- };
-
- render () {
- const { media, statusId, lang, intl, onClose } = this.props;
- const { navigationHidden, zoomedIn, viewportWidth, viewportHeight } = this.state;
-
- const index = this.getIndex();
-
- const leftNav = media.size > 1 && ;
- const rightNav = media.size > 1 && ;
-
- const content = media.map((image, idx) => {
- const width = image.getIn(['meta', 'original', 'width']) || null;
- const height = image.getIn(['meta', 'original', 'height']) || null;
- const description = image.getIn(['translation', 'description']) || image.get('description');
-
- if (image.get('type') === 'image') {
- return (
-
- );
- } else if (image.get('type') === 'video') {
- const { currentTime, autoPlay, volume } = this.props;
-
- return (
-
- );
- } else if (image.get('type') === 'gifv') {
- return (
-
- );
- }
-
- return null;
- }).toArray();
-
- // you can't use 100vh, because the viewport height is taller
- // than the visible part of the document in some mobile
- // browsers when it's address bar is visible.
- // https://developers.google.com/web/updates/2016/12/url-bar-resizing
- const swipeableViewsStyle = {
- width: '100%',
- height: '100%',
- };
-
- const containerStyle = {
- alignItems: 'center', // center vertically
- };
-
- const navigationClassName = classNames('media-modal__navigation', {
- 'media-modal__navigation--hidden': navigationHidden,
- });
-
- let pagination;
-
- if (media.size > 1) {
- pagination = media.map((item, i) => (
-
- ));
- }
-
- const currentMedia = media.get(index);
- const zoomable = currentMedia.get('type') === 'image' && (currentMedia.getIn(['meta', 'original', 'width']) > viewportWidth || currentMedia.getIn(['meta', 'original', 'height']) > viewportHeight);
-
- return (
-
-
-
- {content}
-
-
-
-
-
- {zoomable && }
-
-
-
- {leftNav}
- {rightNav}
-
-
- {pagination &&
}
- {statusId &&
}
-
-
-
- );
- }
-
-}
-
-export default injectIntl(MediaModal);
diff --git a/app/javascript/flavours/glitch/features/ui/components/media_modal.tsx b/app/javascript/flavours/glitch/features/ui/components/media_modal.tsx
new file mode 100644
index 0000000000..a8cdeeab94
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.tsx
@@ -0,0 +1,363 @@
+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;
+ statusId?: string;
+ lang?: string;
+ index: number;
+ onClose: () => void;
+ onChangeBackgroundColor: (color: RGB | null) => void;
+ currentTime?: number;
+ autoPlay?: boolean;
+ volume?: number;
+}
+
+export const MediaModal: FC = 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 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);
+ }
+ }
+ }, [currentMedia, onChangeBackgroundColor]);
+
+ const [viewportDimensions, setViewportDimensions] = useState<{
+ width: number;
+ height: number;
+ }>({ width: 0, height: 0 });
+ const handleRef: RefCallback = useCallback((ele) => {
+ if (ele?.clientWidth && ele.clientHeight) {
+ setViewportDimensions({
+ width: ele.clientWidth,
+ height: ele.clientHeight,
+ });
+ }
+ }, []);
+
+ const [zoomedIn, setZoomedIn] = useState(false);
+ 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 (
+
+ );
+ } else if (item.get('type') === 'video') {
+ return (
+
+ );
+ } else if (item.get('type') === 'gifv') {
+ return (
+
+ );
+ }
+
+ return null;
+ }),
+ [
+ autoPlay,
+ currentTime,
+ handleToggleNavigation,
+ handleZoomClick,
+ index,
+ lang,
+ media,
+ onClose,
+ volume,
+ zoomedIn,
+ ],
+ );
+
+ const intl = useIntl();
+
+ const leftNav = media.size > 1 && (
+
+ );
+ const rightNav = media.size > 1 && (
+
+ );
+
+ return (
+
+
+ {content}
+
+
+
+
+ {zoomable && (
+
+ )}
+
+
+
+ {leftNav}
+ {rightNav}
+
+
+
+ {statusId && (
+
+ )}
+
+
+
+ );
+ },
+);
+MediaModal.displayName = 'MediaModal';
+
+interface MediaPaginationProps {
+ itemsCount: number;
+ index: number;
+ onChangeIndex: (newIndex: number) => void;
+}
+
+const MediaPagination: FC = ({
+ itemsCount,
+ index,
+ onChangeIndex,
+}) => {
+ const handleChangeIndex = useCallback(
+ (curIndex: number) => {
+ return () => {
+ onChangeIndex(curIndex);
+ };
+ },
+ [onChangeIndex],
+ );
+
+ if (itemsCount <= 1) {
+ return null;
+ }
+
+ return (
+
+ {Array.from({ length: itemsCount }).map((_, i) => (
+
+ ))}
+
+ );
+};
diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx
index 4f2e63fb0d..6f9da911a7 100644
--- a/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx
+++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx
@@ -47,7 +47,7 @@ import DeprecatedSettingsModal from './deprecated_settings_modal';
import DoodleModal from './doodle_modal';
import { FavouriteModal } from './favourite_modal';
import { ImageModal } from './image_modal';
-import MediaModal from './media_modal';
+import { MediaModal } from './media_modal';
import { ModalPlaceholder } from './modal_placeholder';
import VideoModal from './video_modal';
import { VisibilityModal } from './visibility_modal';
diff --git a/app/javascript/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss
index 7ee36e2c3a..3fdc4dfcf0 100644
--- a/app/javascript/flavours/glitch/styles/components.scss
+++ b/app/javascript/flavours/glitch/styles/components.scss
@@ -2750,6 +2750,7 @@ a.account__display-name {
outline-offset: -1px;
border-radius: 8px;
touch-action: none;
+ user-select: none;
}
&--zoomed-in {
@@ -6333,6 +6334,7 @@ a.status-card {
width: 100%;
height: 100%;
position: relative;
+ touch-action: pan-y;
&__buttons {
position: absolute;
@@ -6368,11 +6370,17 @@ a.status-card {
}
.media-modal__closer {
+ display: flex;
position: absolute;
top: 0;
inset-inline-start: 0;
inset-inline-end: 0;
bottom: 0;
+
+ > div {
+ flex-shrink: 0;
+ overflow: auto;
+ }
}
.media-modal__navigation {