diff --git a/app/soapbox/components/media_gallery.js b/app/soapbox/components/media_gallery.js
deleted file mode 100644
index 7bbbbd5b7..000000000
--- a/app/soapbox/components/media_gallery.js
+++ /dev/null
@@ -1,653 +0,0 @@
-import classNames from 'clsx';
-import { Map as ImmutableMap, is } from 'immutable';
-import PropTypes from 'prop-types';
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import { connect } from 'react-redux';
-
-import { getSettings } from 'soapbox/actions/settings';
-import Blurhash from 'soapbox/components/blurhash';
-import Icon from 'soapbox/components/icon';
-import StillImage from 'soapbox/components/still_image';
-import { MIMETYPE_ICONS } from 'soapbox/features/compose/components/upload';
-import { truncateFilename } from 'soapbox/utils/media';
-
-import { isIOS } from '../is_mobile';
-import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maximumAspectRatio } from '../utils/media_aspect_ratio';
-
-import { Button, Text } from './ui';
-
-const ATTACHMENT_LIMIT = 4;
-const MAX_FILENAME_LENGTH = 45;
-
-const messages = defineMessages({
- toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Hide' },
-});
-
-const mapStateToItemProps = state => ({
- autoPlayGif: getSettings(state).get('autoPlayGif'),
-});
-
-const withinLimits = aspectRatio => {
- return aspectRatio >= minimumAspectRatio && aspectRatio <= maximumAspectRatio;
-};
-
-const shouldLetterbox = attachment => {
- const aspectRatio = attachment.getIn(['meta', 'original', 'aspect']);
- if (!aspectRatio) return true;
-
- return !withinLimits(aspectRatio);
-};
-
-@connect(mapStateToItemProps)
-class Item extends React.PureComponent {
-
- static propTypes = {
- attachment: ImmutablePropTypes.record.isRequired,
- standalone: PropTypes.bool,
- index: PropTypes.number.isRequired,
- size: PropTypes.number.isRequired,
- onClick: PropTypes.func.isRequired,
- displayWidth: PropTypes.number,
- visible: PropTypes.bool.isRequired,
- dimensions: PropTypes.object,
- autoPlayGif: PropTypes.bool,
- last: PropTypes.bool,
- total: PropTypes.number,
- };
-
- static defaultProps = {
- standalone: false,
- index: 0,
- size: 1,
- };
-
- state = {
- loaded: false,
- };
-
- handleMouseEnter = (e) => {
- if (this.hoverToPlay()) {
- e.target.play();
- }
- }
-
- handleMouseLeave = (e) => {
- if (this.hoverToPlay()) {
- e.target.pause();
- e.target.currentTime = 0;
- }
- }
-
- hoverToPlay() {
- const { attachment, autoPlayGif } = this.props;
- return !autoPlayGif && attachment.get('type') === 'gifv';
- }
-
- handleClick = (e) => {
- const { index, onClick } = this.props;
-
- if (isIOS() && !e.target.autoPlay) {
- e.target.autoPlay = true;
- e.preventDefault();
- } else {
- if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
- if (this.hoverToPlay()) {
- e.target.pause();
- e.target.currentTime = 0;
- }
- e.preventDefault();
- onClick(index);
- }
- }
-
- e.stopPropagation();
- }
-
- handleImageLoad = () => {
- this.setState({ loaded: true });
- }
-
- handleVideoHover = ({ target: video }) => {
- video.playbackRate = 3.0;
- video.play();
- }
-
- handleVideoLeave = ({ target: video }) => {
- video.pause();
- video.currentTime = 0;
- }
-
- render() {
- const { attachment, standalone, visible, dimensions, autoPlayGif, last, total } = this.props;
-
- let width = 100;
- let height = '100%';
- let top = 'auto';
- let left = 'auto';
- let bottom = 'auto';
- let right = 'auto';
- let float = 'left';
- let position = 'relative';
-
- if (dimensions) {
- width = dimensions.w;
- height = dimensions.h;
- top = dimensions.t || 'auto';
- right = dimensions.r || 'auto';
- bottom = dimensions.b || 'auto';
- left = dimensions.l || 'auto';
- float = dimensions.float || 'left';
- position = dimensions.pos || 'relative';
- }
-
- let thumbnail = '';
-
- if (attachment.get('type') === 'unknown') {
- const filename = truncateFilename(attachment.get('remote_url'), MAX_FILENAME_LENGTH);
- const attachmentIcon = (
-
- );
-
- return (
-
- );
- } else if (attachment.get('type') === 'image') {
- const originalUrl = attachment.get('url');
- const letterboxed = shouldLetterbox(attachment);
-
- thumbnail = (
-
-
-
- );
- } else if (attachment.get('type') === 'gifv') {
- const conditionalAttributes = {};
- if (isIOS()) {
- conditionalAttributes.playsInline = '1';
- }
- if (autoPlayGif) {
- conditionalAttributes.autoPlay = '1';
- }
-
- thumbnail = (
-
-
-
- GIF
-
- );
- } else if (attachment.get('type') === 'audio') {
- const ext = attachment.get('url').split('.').pop().toUpperCase();
- thumbnail = (
-
-
- {ext}
-
- );
- } else if (attachment.get('type') === 'video') {
- const ext = attachment.get('url').split('.').pop().toUpperCase();
- thumbnail = (
-
-
- {ext}
-
- );
- }
-
- return (
-
- {last && total > ATTACHMENT_LIMIT && (
-
- +{total - ATTACHMENT_LIMIT + 1}
-
- )}
-
- {visible && thumbnail}
-
- );
- }
-
-}
-
-const mapStateToMediaGalleryProps = state => ({
- displayMedia: getSettings(state).get('displayMedia'),
-});
-
-export default @connect(mapStateToMediaGalleryProps)
-@injectIntl
-class MediaGallery extends React.PureComponent {
-
- static propTypes = {
- sensitive: PropTypes.bool,
- standalone: PropTypes.bool,
- media: ImmutablePropTypes.list.isRequired,
- size: PropTypes.object,
- height: PropTypes.number.isRequired,
- onOpenMedia: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- defaultWidth: PropTypes.number,
- cacheWidth: PropTypes.func,
- visible: PropTypes.bool,
- onToggleVisibility: PropTypes.func,
- displayMedia: PropTypes.string,
- compact: PropTypes.bool,
- };
-
- static defaultProps = {
- standalone: false,
- };
-
- state = {
- visible: this.props.visible !== undefined ? this.props.visible : (this.props.displayMedia !== 'hide_all' && !this.props.sensitive || this.props.displayMedia === 'show_all'),
- width: this.props.defaultWidth,
- };
-
- componentDidUpdate(prevProps) {
- const { media, visible, sensitive } = this.props;
- if (!is(media, prevProps.media) && visible === undefined) {
- this.setState({ visible: prevProps.displayMedia !== 'hide_all' && !sensitive || prevProps.displayMedia === 'show_all' });
- } else if (!is(visible, prevProps.visible) && visible !== undefined) {
- this.setState({ visible });
- }
- }
-
- handleOpen = (e) => {
- e.stopPropagation();
-
- if (this.props.onToggleVisibility) {
- this.props.onToggleVisibility();
- } else {
- this.setState({ visible: !this.state.visible });
- }
- }
-
- handleClick = (index) => {
- this.props.onOpenMedia(this.props.media, index);
- }
-
- handleRef = (node) => {
- if (node) {
- // offsetWidth triggers a layout, so only calculate when we need to
- if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
-
- this.setState({
- width: node.offsetWidth,
- });
- }
- }
-
- getSizeDataSingle = () => {
- const { media, defaultWidth } = this.props;
- const width = this.state.width || defaultWidth;
- const aspectRatio = media.getIn([0, 'meta', 'original', 'aspect']);
-
- const getHeight = () => {
- if (!aspectRatio) return width * 9 / 16;
- if (isPanoramic(aspectRatio)) return Math.floor(width / maximumAspectRatio);
- if (isPortrait(aspectRatio)) return Math.floor(width / minimumAspectRatio);
- return Math.floor(width / aspectRatio);
- };
-
- return ImmutableMap({
- style: { height: getHeight() },
- itemsDimensions: [],
- size: 1,
- width,
- });
- }
-
- getSizeDataMultiple = size => {
- const { media, defaultWidth } = this.props;
- const width = this.state.width || defaultWidth;
- const panoSize = Math.floor(width / maximumAspectRatio);
- const panoSize_px = `${Math.floor(width / maximumAspectRatio)}px`;
-
- const style = {};
- let itemsDimensions = [];
-
- const ratios = Array(size).fill().map((_, i) =>
- media.getIn([i, 'meta', 'original', 'aspect']),
- );
-
- const [ar1, ar2, ar3, ar4] = ratios;
-
- if (size === 2) {
- if (isPortrait(ar1) && isPortrait(ar2)) {
- style.height = width - (width / maximumAspectRatio);
- } else if (isPanoramic(ar1) && isPanoramic(ar2)) {
- style.height = panoSize * 2;
- } else if (
- (isPanoramic(ar1) && isPortrait(ar2)) ||
- (isPortrait(ar1) && isPanoramic(ar2)) ||
- (isPanoramic(ar1) && isNonConformingRatio(ar2)) ||
- (isNonConformingRatio(ar1) && isPanoramic(ar2))
- ) {
- style.height = (width * 0.6) + (width / maximumAspectRatio);
- } else {
- style.height = width / 2;
- }
-
- //
-
- if (isPortrait(ar1) && isPortrait(ar2)) {
- itemsDimensions = [
- { w: 50, h: '100%', r: '2px' },
- { w: 50, h: '100%', l: '2px' },
- ];
- } else if (isPanoramic(ar1) && isPanoramic(ar2)) {
- itemsDimensions = [
- { w: 100, h: panoSize_px, b: '2px' },
- { w: 100, h: panoSize_px, t: '2px' },
- ];
- } else if (
- (isPanoramic(ar1) && isPortrait(ar2)) ||
- (isPanoramic(ar1) && isNonConformingRatio(ar2))
- ) {
- itemsDimensions = [
- { w: 100, h: `${(width / maximumAspectRatio)}px`, b: '2px' },
- { w: 100, h: `${(width * 0.6)}px`, t: '2px' },
- ];
- } else if (
- (isPortrait(ar1) && isPanoramic(ar2)) ||
- (isNonConformingRatio(ar1) && isPanoramic(ar2))
- ) {
- itemsDimensions = [
- { w: 100, h: `${(width * 0.6)}px`, b: '2px' },
- { w: 100, h: `${(width / maximumAspectRatio)}px`, t: '2px' },
- ];
- } else {
- itemsDimensions = [
- { w: 50, h: '100%', r: '2px' },
- { w: 50, h: '100%', l: '2px' },
- ];
- }
- } else if (size === 3) {
- if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) {
- style.height = panoSize * 3;
- } else if (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3)) {
- style.height = Math.floor(width / minimumAspectRatio);
- } else {
- style.height = width;
- }
-
- //
-
- if (isPanoramic(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3)) {
- itemsDimensions = [
- { w: 100, h: '50%', b: '2px' },
- { w: 50, h: '50%', t: '2px', r: '2px' },
- { w: 50, h: '50%', t: '2px', l: '2px' },
- ];
- } else if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) {
- itemsDimensions = [
- { w: 100, h: panoSize_px, b: '4px' },
- { w: 100, h: panoSize_px },
- { w: 100, h: panoSize_px, t: '4px' },
- ];
- } else if (isPortrait(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3)) {
- itemsDimensions = [
- { w: 50, h: '100%', r: '2px' },
- { w: 50, h: '50%', b: '2px', l: '2px' },
- { w: 50, h: '50%', t: '2px', l: '2px' },
- ];
- } else if (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPortrait(ar3)) {
- itemsDimensions = [
- { w: 50, h: '50%', b: '2px', r: '2px' },
- { w: 50, h: '50%', l: '-2px', b: '-2px', pos: 'absolute', float: 'none' },
- { w: 50, h: '100%', r: '-2px', t: '0px', b: '0px', pos: 'absolute', float: 'none' },
- ];
- } else if (
- (isNonConformingRatio(ar1) && isPortrait(ar2) && isNonConformingRatio(ar3)) ||
- (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3))
- ) {
- itemsDimensions = [
- { w: 50, h: '50%', b: '2px', r: '2px' },
- { w: 50, h: '100%', l: '2px', float: 'right' },
- { w: 50, h: '50%', t: '2px', r: '2px' },
- ];
- } else if (
- (isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3)) ||
- (isPanoramic(ar1) && isPanoramic(ar2) && isPortrait(ar3))
- ) {
- itemsDimensions = [
- { w: 50, h: panoSize_px, b: '2px', r: '2px' },
- { w: 50, h: panoSize_px, b: '2px', l: '2px' },
- { w: 100, h: `${width - panoSize}px`, t: '2px' },
- ];
- } else if (
- (isNonConformingRatio(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) ||
- (isPortrait(ar1) && isPanoramic(ar2) && isPanoramic(ar3))
- ) {
- itemsDimensions = [
- { w: 100, h: `${width - panoSize}px`, b: '2px' },
- { w: 50, h: panoSize_px, t: '2px', r: '2px' },
- { w: 50, h: panoSize_px, t: '2px', l: '2px' },
- ];
- } else {
- itemsDimensions = [
- { w: 50, h: '50%', b: '2px', r: '2px' },
- { w: 50, h: '50%', b: '2px', l: '2px' },
- { w: 100, h: '50%', t: '2px' },
- ];
- }
- } else if (size >= 4) {
- if (
- (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3) && isPortrait(ar4)) ||
- (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3) && isNonConformingRatio(ar4)) ||
- (isPortrait(ar1) && isPortrait(ar2) && isNonConformingRatio(ar3) && isPortrait(ar4)) ||
- (isPortrait(ar1) && isNonConformingRatio(ar2) && isPortrait(ar3) && isPortrait(ar4)) ||
- (isNonConformingRatio(ar1) && isPortrait(ar2) && isPortrait(ar3) && isPortrait(ar4))
- ) {
- style.height = Math.floor(width / minimumAspectRatio);
- } else if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) {
- style.height = panoSize * 2;
- } else if (
- (isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) ||
- (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPanoramic(ar3) && isPanoramic(ar4))
- ) {
- style.height = panoSize + (width / 2);
- } else {
- style.height = width;
- }
-
- //
-
- if (isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) {
- itemsDimensions = [
- { w: 50, h: panoSize_px, b: '2px', r: '2px' },
- { w: 50, h: panoSize_px, b: '2px', l: '2px' },
- { w: 50, h: `${(width / 2)}px`, t: '2px', r: '2px' },
- { w: 50, h: `${(width / 2)}px`, t: '2px', l: '2px' },
- ];
- } else if (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) {
- itemsDimensions = [
- { w: 50, h: `${(width / 2)}px`, b: '2px', r: '2px' },
- { w: 50, h: `${(width / 2)}px`, b: '2px', l: '2px' },
- { w: 50, h: panoSize_px, t: '2px', r: '2px' },
- { w: 50, h: panoSize_px, t: '2px', l: '2px' },
- ];
- } else if (
- (isPortrait(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) ||
- (isPortrait(ar1) && isPanoramic(ar2) && isPanoramic(ar3) && isPanoramic(ar4))
- ) {
- itemsDimensions = [
- { w: 67, h: '100%', r: '2px' },
- { w: 33, h: '33%', b: '4px', l: '2px' },
- { w: 33, h: '33%', l: '2px' },
- { w: 33, h: '33%', t: '4px', l: '2px' },
- ];
- } else {
- itemsDimensions = [
- { w: 50, h: '50%', b: '2px', r: '2px' },
- { w: 50, h: '50%', b: '2px', l: '2px' },
- { w: 50, h: '50%', t: '2px', r: '2px' },
- { w: 50, h: '50%', t: '2px', l: '2px' },
- ];
- }
- }
-
- return ImmutableMap({
- style,
- itemsDimensions,
- size: size,
- width,
- });
-
- }
-
- getSizeData = size => {
- const { height, defaultWidth } = this.props;
- const width = this.state.width || defaultWidth;
-
- if (width) {
- if (size === 1) return this.getSizeDataSingle();
- if (size > 1) return this.getSizeDataMultiple(size);
- }
-
- // Default
- return ImmutableMap({
- style: { height },
- itemsDimensions: [],
- size,
- width,
- });
- }
-
- render() {
- const { media, intl, sensitive, compact } = this.props;
- const { visible } = this.state;
- const sizeData = this.getSizeData(media.size);
-
- const children = media.take(ATTACHMENT_LIMIT).map((attachment, i) => (
-
- ));
-
- let warning;
-
- if (sensitive) {
- warning = ;
- } else {
- warning = ;
- }
-
- return (
-
-
- {sensitive && (
- (visible || compact) ? (
-
- ) : (
-
e.stopPropagation()}
- className={
- classNames({
- 'bg-gray-800/75 cursor-default backdrop-blur-sm rounded-lg w-full h-full border-0 flex items-center justify-center': true,
- })
- }
- >
-
-
- {warning}
-
-
-
-
-
-
-
-
- )
- )}
-
-
- {children}
-
- );
- }
-
-}
diff --git a/app/soapbox/components/media_gallery.tsx b/app/soapbox/components/media_gallery.tsx
new file mode 100644
index 000000000..0744361a4
--- /dev/null
+++ b/app/soapbox/components/media_gallery.tsx
@@ -0,0 +1,648 @@
+import classNames from 'clsx';
+import React, { useState, useRef, useEffect } from 'react';
+import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
+
+import Blurhash from 'soapbox/components/blurhash';
+import Icon from 'soapbox/components/icon';
+import StillImage from 'soapbox/components/still_image';
+import { MIMETYPE_ICONS } from 'soapbox/features/compose/components/upload';
+import { useSettings } from 'soapbox/hooks';
+import { Attachment } from 'soapbox/types/entities';
+import { truncateFilename } from 'soapbox/utils/media';
+
+import { isIOS } from '../is_mobile';
+import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maximumAspectRatio } from '../utils/media_aspect_ratio';
+
+import { Button, Text } from './ui';
+
+import type { Property } from 'csstype';
+import type { List as ImmutableList } from 'immutable';
+
+const ATTACHMENT_LIMIT = 4;
+const MAX_FILENAME_LENGTH = 45;
+
+interface Dimensions {
+ w: Property.Width | number,
+ h: Property.Height | number,
+ t?: Property.Top,
+ r?: Property.Right,
+ b?: Property.Bottom,
+ l?: Property.Left,
+ float?: Property.Float,
+ pos?: Property.Position,
+}
+
+interface SizeData {
+ style: React.CSSProperties,
+ itemsDimensions: Dimensions[],
+ size: number,
+ width: number,
+}
+
+const messages = defineMessages({
+ toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Hide' },
+});
+
+const withinLimits = (aspectRatio: number) => {
+ return aspectRatio >= minimumAspectRatio && aspectRatio <= maximumAspectRatio;
+};
+
+const shouldLetterbox = (attachment: Attachment): boolean => {
+ const aspectRatio = attachment.getIn(['meta', 'original', 'aspect']) as number | undefined;
+ if (!aspectRatio) return true;
+
+ return !withinLimits(aspectRatio);
+};
+
+interface IItem {
+ attachment: Attachment,
+ standalone?: boolean,
+ index: number,
+ size: number,
+ onClick: (index: number) => void,
+ displayWidth?: number,
+ visible: boolean,
+ dimensions: Dimensions,
+ last?: boolean,
+ total: number,
+}
+
+const Item: React.FC = ({
+ attachment,
+ index,
+ onClick,
+ standalone = false,
+ visible,
+ dimensions,
+ last,
+ total,
+}) => {
+ const settings = useSettings();
+ const autoPlayGif = settings.get('autoPlayGif') === true;
+
+ const [loaded, setLoaded] = useState(false);
+
+ const handleMouseEnter: React.MouseEventHandler = ({ currentTarget: video }) => {
+ if (hoverToPlay()) {
+ video.play();
+ }
+ };
+
+ const handleMouseLeave: React.MouseEventHandler = ({ currentTarget: video }) => {
+ if (hoverToPlay()) {
+ video.pause();
+ video.currentTime = 0;
+ }
+ };
+
+ const hoverToPlay = () => {
+ return !autoPlayGif && attachment.type === 'gifv';
+ };
+
+ // FIXME: wtf?
+ const handleClick: React.MouseEventHandler = (e: any) => {
+ if (isIOS() && !e.target.autoPlay) {
+ e.target.autoPlay = true;
+ e.preventDefault();
+ } else {
+ if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ if (hoverToPlay()) {
+ e.target.pause();
+ e.target.currentTime = 0;
+ }
+ e.preventDefault();
+ onClick(index);
+ }
+ }
+
+ e.stopPropagation();
+ };
+
+ const handleImageLoad = () => {
+ setLoaded(true);
+ };
+
+ const handleVideoHover: React.MouseEventHandler = ({ currentTarget: video }) => {
+ video.playbackRate = 3.0;
+ video.play();
+ };
+
+ const handleVideoLeave: React.MouseEventHandler = ({ currentTarget: video }) => {
+ video.pause();
+ video.currentTime = 0;
+ };
+
+ let width: Dimensions['w'] = 100;
+ let height: Dimensions['h'] = '100%';
+ let top: Dimensions['t'] = 'auto';
+ let left: Dimensions['l'] = 'auto';
+ let bottom: Dimensions['b'] = 'auto';
+ let right: Dimensions['r'] = 'auto';
+ let float: Dimensions['float'] = 'left';
+ let position: Dimensions['pos'] = 'relative';
+
+ if (dimensions) {
+ width = dimensions.w;
+ height = dimensions.h;
+ top = dimensions.t || 'auto';
+ right = dimensions.r || 'auto';
+ bottom = dimensions.b || 'auto';
+ left = dimensions.l || 'auto';
+ float = dimensions.float || 'left';
+ position = dimensions.pos || 'relative';
+ }
+
+ let thumbnail: React.ReactNode = '';
+
+ if (attachment.type === 'unknown') {
+ const filename = truncateFilename(attachment.url, MAX_FILENAME_LENGTH);
+ const attachmentIcon = (
+
+ );
+
+ return (
+
+ );
+ } else if (attachment.type === 'image') {
+ const letterboxed = shouldLetterbox(attachment);
+
+ thumbnail = (
+
+
+
+ );
+ } else if (attachment.type === 'gifv') {
+ const conditionalAttributes: React.VideoHTMLAttributes = {};
+ if (isIOS()) {
+ conditionalAttributes.playsInline = true;
+ }
+ if (autoPlayGif) {
+ conditionalAttributes.autoPlay = true;
+ }
+
+ thumbnail = (
+
+
+
+ GIF
+
+ );
+ } else if (attachment.type === 'audio') {
+ const ext = attachment.url.split('.').pop()?.toUpperCase();
+ thumbnail = (
+
+
+ {ext}
+
+ );
+ } else if (attachment.type === 'video') {
+ const ext = attachment.url.split('.').pop()?.toUpperCase();
+ thumbnail = (
+
+
+ {ext}
+
+ );
+ }
+
+ return (
+
+ {last && total > ATTACHMENT_LIMIT && (
+
+ +{total - ATTACHMENT_LIMIT + 1}
+
+ )}
+
+ {visible && thumbnail}
+
+ );
+};
+
+interface IMediaGallery {
+ sensitive?: boolean,
+ media: ImmutableList,
+ size: number,
+ height: number,
+ onOpenMedia: (media: ImmutableList, index: number) => void,
+ defaultWidth: number,
+ cacheWidth: (width: number) => void,
+ visible?: boolean,
+ onToggleVisibility?: () => void,
+ displayMedia: string,
+ compact: boolean,
+}
+
+const MediaGallery: React.FC = (props) => {
+ const {
+ media,
+ sensitive = false,
+ defaultWidth,
+ onToggleVisibility,
+ onOpenMedia,
+ cacheWidth,
+ compact,
+ height,
+ } = props;
+
+ const intl = useIntl();
+
+ const settings = useSettings();
+ const displayMedia = settings.get('displayMedia') as string | undefined;
+
+ const [visible, setVisible] = useState(props.visible !== undefined ? props.visible : (displayMedia !== 'hide_all' && !sensitive || displayMedia === 'show_all'));
+ const [width, setWidth] = useState(defaultWidth);
+
+ const node = useRef(null);
+
+ // const componentDidUpdate = (prevProps) => {
+ // const { visible } = props;
+ // if (!is(media, prevProps.media) && visible === undefined) {
+ // this.setState({ visible: prevProps.displayMedia !== 'hide_all' && !sensitive || prevProps.displayMedia === 'show_all' });
+ // } else if (!is(visible, prevProps.visible) && visible !== undefined) {
+ // setVisible(visible);
+ // }
+ // };
+
+ const handleOpen: React.MouseEventHandler = (e) => {
+ e.stopPropagation();
+
+ if (onToggleVisibility) {
+ onToggleVisibility();
+ } else {
+ setVisible(!visible);
+ }
+ };
+
+ const handleClick = (index: number) => {
+ onOpenMedia(media, index);
+ };
+
+ useEffect(() => {
+ if (node.current) {
+ const { offsetWidth } = node.current;
+
+ if (cacheWidth) {
+ cacheWidth(offsetWidth);
+ }
+
+ setWidth(offsetWidth);
+ }
+ }, [node.current]);
+
+ const getSizeDataSingle = (): SizeData => {
+ const w = width || defaultWidth;
+ const aspectRatio = media.getIn([0, 'meta', 'original', 'aspect']) as number | undefined;
+
+ const getHeight = () => {
+ if (!aspectRatio) return w * 9 / 16;
+ if (isPanoramic(aspectRatio)) return Math.floor(w / maximumAspectRatio);
+ if (isPortrait(aspectRatio)) return Math.floor(w / minimumAspectRatio);
+ return Math.floor(w / aspectRatio);
+ };
+
+ return {
+ style: { height: getHeight() },
+ itemsDimensions: [],
+ size: 1,
+ width,
+ };
+ };
+
+ const getSizeDataMultiple = (size: number): SizeData => {
+ const w = width || defaultWidth;
+ const panoSize = Math.floor(w / maximumAspectRatio);
+ const panoSize_px = `${Math.floor(w / maximumAspectRatio)}px`;
+
+ const style: React.CSSProperties = {};
+ let itemsDimensions: Dimensions[] = [];
+
+ const ratios = Array(size).fill(null).map((_, i) =>
+ media.getIn([i, 'meta', 'original', 'aspect']) as number,
+ );
+
+ const [ar1, ar2, ar3, ar4] = ratios;
+
+ if (size === 2) {
+ if (isPortrait(ar1) && isPortrait(ar2)) {
+ style.height = w - (w / maximumAspectRatio);
+ } else if (isPanoramic(ar1) && isPanoramic(ar2)) {
+ style.height = panoSize * 2;
+ } else if (
+ (isPanoramic(ar1) && isPortrait(ar2)) ||
+ (isPortrait(ar1) && isPanoramic(ar2)) ||
+ (isPanoramic(ar1) && isNonConformingRatio(ar2)) ||
+ (isNonConformingRatio(ar1) && isPanoramic(ar2))
+ ) {
+ style.height = (w * 0.6) + (w / maximumAspectRatio);
+ } else {
+ style.height = w / 2;
+ }
+
+ if (isPortrait(ar1) && isPortrait(ar2)) {
+ itemsDimensions = [
+ { w: 50, h: '100%', r: '2px' },
+ { w: 50, h: '100%', l: '2px' },
+ ];
+ } else if (isPanoramic(ar1) && isPanoramic(ar2)) {
+ itemsDimensions = [
+ { w: 100, h: panoSize_px, b: '2px' },
+ { w: 100, h: panoSize_px, t: '2px' },
+ ];
+ } else if (
+ (isPanoramic(ar1) && isPortrait(ar2)) ||
+ (isPanoramic(ar1) && isNonConformingRatio(ar2))
+ ) {
+ itemsDimensions = [
+ { w: 100, h: `${(w / maximumAspectRatio)}px`, b: '2px' },
+ { w: 100, h: `${(w * 0.6)}px`, t: '2px' },
+ ];
+ } else if (
+ (isPortrait(ar1) && isPanoramic(ar2)) ||
+ (isNonConformingRatio(ar1) && isPanoramic(ar2))
+ ) {
+ itemsDimensions = [
+ { w: 100, h: `${(w * 0.6)}px`, b: '2px' },
+ { w: 100, h: `${(w / maximumAspectRatio)}px`, t: '2px' },
+ ];
+ } else {
+ itemsDimensions = [
+ { w: 50, h: '100%', r: '2px' },
+ { w: 50, h: '100%', l: '2px' },
+ ];
+ }
+ } else if (size === 3) {
+ if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) {
+ style.height = panoSize * 3;
+ } else if (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3)) {
+ style.height = Math.floor(w / minimumAspectRatio);
+ } else {
+ style.height = w;
+ }
+
+ if (isPanoramic(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3)) {
+ itemsDimensions = [
+ { w: 100, h: '50%', b: '2px' },
+ { w: 50, h: '50%', t: '2px', r: '2px' },
+ { w: 50, h: '50%', t: '2px', l: '2px' },
+ ];
+ } else if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) {
+ itemsDimensions = [
+ { w: 100, h: panoSize_px, b: '4px' },
+ { w: 100, h: panoSize_px },
+ { w: 100, h: panoSize_px, t: '4px' },
+ ];
+ } else if (isPortrait(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3)) {
+ itemsDimensions = [
+ { w: 50, h: '100%', r: '2px' },
+ { w: 50, h: '50%', b: '2px', l: '2px' },
+ { w: 50, h: '50%', t: '2px', l: '2px' },
+ ];
+ } else if (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPortrait(ar3)) {
+ itemsDimensions = [
+ { w: 50, h: '50%', b: '2px', r: '2px' },
+ { w: 50, h: '50%', l: '-2px', b: '-2px', pos: 'absolute', float: 'none' },
+ { w: 50, h: '100%', r: '-2px', t: '0px', b: '0px', pos: 'absolute', float: 'none' },
+ ];
+ } else if (
+ (isNonConformingRatio(ar1) && isPortrait(ar2) && isNonConformingRatio(ar3)) ||
+ (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3))
+ ) {
+ itemsDimensions = [
+ { w: 50, h: '50%', b: '2px', r: '2px' },
+ { w: 50, h: '100%', l: '2px', float: 'right' },
+ { w: 50, h: '50%', t: '2px', r: '2px' },
+ ];
+ } else if (
+ (isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3)) ||
+ (isPanoramic(ar1) && isPanoramic(ar2) && isPortrait(ar3))
+ ) {
+ itemsDimensions = [
+ { w: 50, h: panoSize_px, b: '2px', r: '2px' },
+ { w: 50, h: panoSize_px, b: '2px', l: '2px' },
+ { w: 100, h: `${w - panoSize}px`, t: '2px' },
+ ];
+ } else if (
+ (isNonConformingRatio(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) ||
+ (isPortrait(ar1) && isPanoramic(ar2) && isPanoramic(ar3))
+ ) {
+ itemsDimensions = [
+ { w: 100, h: `${w - panoSize}px`, b: '2px' },
+ { w: 50, h: panoSize_px, t: '2px', r: '2px' },
+ { w: 50, h: panoSize_px, t: '2px', l: '2px' },
+ ];
+ } else {
+ itemsDimensions = [
+ { w: 50, h: '50%', b: '2px', r: '2px' },
+ { w: 50, h: '50%', b: '2px', l: '2px' },
+ { w: 100, h: '50%', t: '2px' },
+ ];
+ }
+ } else if (size >= 4) {
+ if (
+ (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3) && isPortrait(ar4)) ||
+ (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3) && isNonConformingRatio(ar4)) ||
+ (isPortrait(ar1) && isPortrait(ar2) && isNonConformingRatio(ar3) && isPortrait(ar4)) ||
+ (isPortrait(ar1) && isNonConformingRatio(ar2) && isPortrait(ar3) && isPortrait(ar4)) ||
+ (isNonConformingRatio(ar1) && isPortrait(ar2) && isPortrait(ar3) && isPortrait(ar4))
+ ) {
+ style.height = Math.floor(w / minimumAspectRatio);
+ } else if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) {
+ style.height = panoSize * 2;
+ } else if (
+ (isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) ||
+ (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPanoramic(ar3) && isPanoramic(ar4))
+ ) {
+ style.height = panoSize + (w / 2);
+ } else {
+ style.height = w;
+ }
+
+ if (isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) {
+ itemsDimensions = [
+ { w: 50, h: panoSize_px, b: '2px', r: '2px' },
+ { w: 50, h: panoSize_px, b: '2px', l: '2px' },
+ { w: 50, h: `${(w / 2)}px`, t: '2px', r: '2px' },
+ { w: 50, h: `${(w / 2)}px`, t: '2px', l: '2px' },
+ ];
+ } else if (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) {
+ itemsDimensions = [
+ { w: 50, h: `${(w / 2)}px`, b: '2px', r: '2px' },
+ { w: 50, h: `${(w / 2)}px`, b: '2px', l: '2px' },
+ { w: 50, h: panoSize_px, t: '2px', r: '2px' },
+ { w: 50, h: panoSize_px, t: '2px', l: '2px' },
+ ];
+ } else if (
+ (isPortrait(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) ||
+ (isPortrait(ar1) && isPanoramic(ar2) && isPanoramic(ar3) && isPanoramic(ar4))
+ ) {
+ itemsDimensions = [
+ { w: 67, h: '100%', r: '2px' },
+ { w: 33, h: '33%', b: '4px', l: '2px' },
+ { w: 33, h: '33%', l: '2px' },
+ { w: 33, h: '33%', t: '4px', l: '2px' },
+ ];
+ } else {
+ itemsDimensions = [
+ { w: 50, h: '50%', b: '2px', r: '2px' },
+ { w: 50, h: '50%', b: '2px', l: '2px' },
+ { w: 50, h: '50%', t: '2px', r: '2px' },
+ { w: 50, h: '50%', t: '2px', l: '2px' },
+ ];
+ }
+ }
+
+ return {
+ style,
+ itemsDimensions,
+ size,
+ width: w,
+ };
+ };
+
+ const getSizeData = (size: number): Readonly => {
+ const w = width || defaultWidth;
+
+ if (w) {
+ if (size === 1) return getSizeDataSingle();
+ if (size > 1) return getSizeDataMultiple(size);
+ }
+
+ return {
+ style: { height },
+ itemsDimensions: [],
+ size,
+ width: w,
+ };
+ };
+
+ const sizeData: SizeData = getSizeData(media.size);
+
+ const children = media.take(ATTACHMENT_LIMIT).map((attachment, i) => (
+
+ ));
+
+ let warning;
+
+ if (sensitive) {
+ warning = ;
+ } else {
+ warning = ;
+ }
+
+ return (
+
+
+ {sensitive && (
+ (visible || compact) ? (
+
+ ) : (
+
e.stopPropagation()}
+ className={
+ classNames({
+ 'bg-gray-800/75 cursor-default backdrop-blur-sm rounded-lg w-full h-full border-0 flex items-center justify-center': true,
+ })
+ }
+ >
+
+
+ {warning}
+
+
+
+
+
+
+
+
+ )
+ )}
+
+
+ {children}
+
+ );
+};
+
+export default MediaGallery;
diff --git a/app/soapbox/normalizers/attachment.ts b/app/soapbox/normalizers/attachment.ts
index 23cce3686..f5e00135e 100644
--- a/app/soapbox/normalizers/attachment.ts
+++ b/app/soapbox/normalizers/attachment.ts
@@ -20,7 +20,7 @@ export const AttachmentRecord = ImmutableRecord({
meta: ImmutableMap(),
pleroma: ImmutableMap(),
preview_url: '',
- remote_url: null,
+ remote_url: null as string | null,
type: 'unknown',
url: '',