diff --git a/app/soapbox/__fixtures__/truthsocial-status-in-moderation.json b/app/soapbox/__fixtures__/truthsocial-status-in-moderation.json
index e9402876f..7613ebd93 100644
--- a/app/soapbox/__fixtures__/truthsocial-status-in-moderation.json
+++ b/app/soapbox/__fixtures__/truthsocial-status-in-moderation.json
@@ -82,4 +82,4 @@
"emojis": [],
"card": null,
"poll": null
-}
+}
\ No newline at end of file
diff --git a/app/soapbox/components/media_gallery.js b/app/soapbox/components/media_gallery.js
deleted file mode 100644
index 22ed2473d..000000000
--- a/app/soapbox/components/media_gallery.js
+++ /dev/null
@@ -1,693 +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 { getSoapboxConfig } from 'soapbox/actions/soapbox';
-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 => {
- const { links } = getSoapboxConfig(state);
-
- return {
- displayMedia: getSettings(state).get('displayMedia'),
- links,
- };
-};
-
-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,
- links: ImmutablePropTypes.map,
- };
-
- 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, inReview, links } = this.props;
- const { visible } = this.state;
- const sizeData = this.getSizeData(media.size);
-
- const children = media.take(ATTACHMENT_LIMIT).map((attachment, i) => (
-
- ));
-
- let warning, summary;
-
- if (sensitive) {
- warning = ;
- } else if (inReview) {
- warning = ;
- } else {
- warning = ;
- }
-
- if (inReview) {
- summary = ;
- } else {
- summary = ;
- }
-
- return (
-
-
- {(sensitive || inReview) && (
- (visible || compact) ? (
-
- ) : (
-
e.stopPropagation()}
- className={
- classNames({
- 'cursor-default backdrop-blur-sm rounded-lg w-full h-full border-0 flex items-center justify-center': true,
- 'bg-gray-800/75': !inReview,
- 'bg-danger-600/75': inReview,
- })
- }
- >
-
-
- {warning}
-
- {summary}
-
- {links.get('support') && (
- <>
- {' '}
-
-
-
- ),
- }}
- />
- >
- )}
-
-
-
-
-
-
- )
- )}
-
-
- {children}
-
- );
- }
-
-}
diff --git a/app/soapbox/components/media_gallery.tsx b/app/soapbox/components/media_gallery.tsx
new file mode 100644
index 000000000..dfb9c7df5
--- /dev/null
+++ b/app/soapbox/components/media_gallery.tsx
@@ -0,0 +1,635 @@
+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 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 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 handleOpen: React.MouseEventHandler = (e) => {
+ e.stopPropagation();
+
+ if (onToggleVisibility) {
+ onToggleVisibility();
+ } else {
+ setVisible(!visible);
+ }
+ };
+
+ const handleClick = (index: number) => {
+ onOpenMedia(media, index);
+ };
+
+ 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 = ;
+ }
+
+ useEffect(() => {
+ if (node.current) {
+ const { offsetWidth } = node.current;
+
+ if (cacheWidth) {
+ cacheWidth(offsetWidth);
+ }
+
+ setWidth(offsetWidth);
+ }
+ }, [node.current]);
+
+ useEffect(() => {
+ setVisible(!!props.visible);
+ }, [props.visible]);
+
+ 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/components/modal_root.js b/app/soapbox/components/modal_root.js
deleted file mode 100644
index 9190c37d4..000000000
--- a/app/soapbox/components/modal_root.js
+++ /dev/null
@@ -1,233 +0,0 @@
-import classNames from 'clsx';
-import { createBrowserHistory } from 'history';
-import PropTypes from 'prop-types';
-import React from 'react';
-import 'wicg-inert';
-import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
-import { connect } from 'react-redux';
-import { withRouter } from 'react-router-dom';
-
-import { cancelReplyCompose } from '../actions/compose';
-import { openModal, closeModal } from '../actions/modals';
-
-const messages = defineMessages({
- confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
-});
-
-export const checkComposeContent = compose => {
- return !!compose && [
- compose.text.length > 0,
- compose.spoiler_text.length > 0,
- compose.media_attachments.size > 0,
- compose.poll !== null,
- ].some(check => check === true);
-};
-
-const mapStateToProps = state => ({
- hasComposeContent: checkComposeContent(state.compose.get('compose-modal')),
- isEditing: state.compose.get('compose-modal')?.id !== null,
-});
-
-const mapDispatchToProps = (dispatch) => ({
- onOpenModal(type, opts) {
- dispatch(openModal(type, opts));
- },
- onCloseModal(type) {
- dispatch(closeModal(type));
- },
- onCancelReplyCompose() {
- dispatch(closeModal('COMPOSE'));
- dispatch(cancelReplyCompose());
- },
-});
-
-@withRouter
-class ModalRoot extends React.PureComponent {
-
- static propTypes = {
- children: PropTypes.node,
- onClose: PropTypes.func.isRequired,
- onOpenModal: PropTypes.func.isRequired,
- onCloseModal: PropTypes.func.isRequired,
- onCancelReplyCompose: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- hasComposeContent: PropTypes.bool,
- isEditing: PropTypes.bool,
- type: PropTypes.string,
- onCancel: PropTypes.func,
- history: PropTypes.object,
- };
-
- state = {
- revealed: !!this.props.children,
- };
-
- activeElement = this.state.revealed ? document.activeElement : null;
-
- handleKeyUp = (e) => {
- if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
- && !!this.props.children) {
- this.handleOnClose();
- }
- }
-
- handleOnClose = () => {
- const { onOpenModal, onCloseModal, hasComposeContent, isEditing, intl, type, onCancelReplyCompose } = this.props;
-
- if (hasComposeContent && type === 'COMPOSE') {
- onOpenModal('CONFIRM', {
- icon: require('@tabler/icons/trash.svg'),
- heading: isEditing ? : ,
- message: isEditing ? : ,
- confirm: intl.formatMessage(messages.confirm),
- onConfirm: () => onCancelReplyCompose(),
- onCancel: () => onCloseModal('CONFIRM'),
- });
- } else if (hasComposeContent && type === 'CONFIRM') {
- onCloseModal('CONFIRM');
- } else {
- this.props.onClose();
- }
- };
-
- handleKeyDown = (e) => {
- if (e.key === 'Tab') {
- const focusable = Array.from(this.node.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((x) => window.getComputedStyle(x).display !== 'none');
- const index = focusable.indexOf(e.target);
-
- let element;
-
- if (e.shiftKey) {
- element = focusable[index - 1] || focusable[focusable.length - 1];
- } else {
- element = focusable[index + 1] || focusable[0];
- }
-
- if (element) {
- element.focus();
- e.stopPropagation();
- e.preventDefault();
- }
- }
- }
-
- componentDidMount() {
- window.addEventListener('keyup', this.handleKeyUp, false);
- window.addEventListener('keydown', this.handleKeyDown, false);
- this.history = this.props.history || createBrowserHistory();
- }
-
- componentDidUpdate(prevProps) {
- if (!!this.props.children && !prevProps.children) {
- this.activeElement = document.activeElement;
- this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true));
-
- this._handleModalOpen();
- } else if (!prevProps.children) {
- this.setState({ revealed: false });
- }
-
- if (!this.props.children && !!prevProps.children) {
- this.activeElement.focus();
- this.activeElement = null;
- this.getSiblings().forEach(sibling => sibling.removeAttribute('inert'));
-
- this._handleModalClose(prevProps.type);
- }
-
- if (this.props.children) {
- requestAnimationFrame(() => {
- this.setState({ revealed: true });
- });
-
- this._ensureHistoryBuffer();
- }
- }
-
- componentWillUnmount() {
- window.removeEventListener('keyup', this.handleKeyUp);
- window.removeEventListener('keydown', this.handleKeyDown);
- }
-
- _handleModalOpen() {
- this._modalHistoryKey = Date.now();
- this.unlistenHistory = this.history.listen((_, action) => {
- if (action === 'POP') {
- this.handleOnClose();
-
- if (this.props.onCancel) this.props.onCancel();
- }
- });
- }
-
- _handleModalClose(type) {
- if (this.unlistenHistory) {
- this.unlistenHistory();
- }
- if (!['FAVOURITES', 'MENTIONS', 'REACTIONS', 'REBLOGS', 'MEDIA'].includes(type)) {
- const { state } = this.history.location;
- if (state && state.soapboxModalKey === this._modalHistoryKey) {
- this.history.goBack();
- }
- }
- }
-
- _ensureHistoryBuffer() {
- const { pathname, state } = this.history.location;
- if (!state || state.soapboxModalKey !== this._modalHistoryKey) {
- this.history.push(pathname, { ...state, soapboxModalKey: this._modalHistoryKey });
- }
- }
-
- getSiblings = () => {
- return Array(...this.node.parentElement.childNodes).filter(node => node !== this.node);
- }
-
- setRef = ref => {
- this.node = ref;
- }
-
- render() {
- const { children, type } = this.props;
- const { revealed } = this.state;
- const visible = !!children;
-
- if (!visible) {
- return (
-
- );
- }
-
- return (
-
- );
- }
-
-}
-
-export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ModalRoot));
diff --git a/app/soapbox/components/modal_root.tsx b/app/soapbox/components/modal_root.tsx
new file mode 100644
index 000000000..eabd450d0
--- /dev/null
+++ b/app/soapbox/components/modal_root.tsx
@@ -0,0 +1,213 @@
+import classNames from 'clsx';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import 'wicg-inert';
+import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
+import { useHistory } from 'react-router-dom';
+
+import { cancelReplyCompose } from 'soapbox/actions/compose';
+import { openModal, closeModal } from 'soapbox/actions/modals';
+import { useAppDispatch, useAppSelector, usePrevious } from 'soapbox/hooks';
+
+import type { UnregisterCallback } from 'history';
+import type { ReducerCompose } from 'soapbox/reducers/compose';
+
+const messages = defineMessages({
+ confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
+});
+
+export const checkComposeContent = (compose?: ReturnType) => {
+ return !!compose && [
+ compose.text.length > 0,
+ compose.spoiler_text.length > 0,
+ compose.media_attachments.size > 0,
+ compose.poll !== null,
+ ].some(check => check === true);
+};
+
+interface IModalRoot {
+ onCancel?: () => void,
+ onClose: (type?: string) => void,
+ type: string,
+}
+
+const ModalRoot: React.FC = ({ children, onCancel, onClose, type }) => {
+ const intl = useIntl();
+ const history = useHistory();
+ const dispatch = useAppDispatch();
+
+ const [revealed, setRevealed] = useState(!!children);
+
+ const ref = useRef(null);
+ const activeElement = useRef(revealed ? document.activeElement as HTMLDivElement | null : null);
+ const modalHistoryKey = useRef();
+ const unlistenHistory = useRef();
+
+ const prevChildren = usePrevious(children);
+ const prevType = usePrevious(type);
+
+ const isEditing = useAppSelector(state => state.compose.get('compose-modal')?.id !== null);
+
+ const handleKeyUp = useCallback((e) => {
+ if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
+ && !!children) {
+ handleOnClose();
+ }
+ }, []);
+
+ const handleOnClose = () => {
+ dispatch((_, getState) => {
+ const hasComposeContent = checkComposeContent(getState().compose.get('compose-modal'));
+
+ if (hasComposeContent && type === 'COMPOSE') {
+ dispatch(openModal('CONFIRM', {
+ icon: require('@tabler/icons/trash.svg'),
+ heading: isEditing ? : ,
+ message: isEditing ? : ,
+ confirm: intl.formatMessage(messages.confirm),
+ onConfirm: () => {
+ dispatch(closeModal('COMPOSE'));
+ dispatch(cancelReplyCompose());
+ },
+ onCancel: () => {
+ dispatch(closeModal('CONFIRM'));
+ },
+ }));
+ } else if (hasComposeContent && type === 'CONFIRM') {
+ dispatch(closeModal('CONFIRM'));
+ } else {
+ onClose();
+ }
+ });
+ };
+
+ const handleKeyDown = useCallback((e) => {
+ if (e.key === 'Tab') {
+ const focusable = Array.from(ref.current!.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((x) => window.getComputedStyle(x).display !== 'none');
+ const index = focusable.indexOf(e.target);
+
+ let element;
+
+ if (e.shiftKey) {
+ element = focusable[index - 1] || focusable[focusable.length - 1];
+ } else {
+ element = focusable[index + 1] || focusable[0];
+ }
+
+ if (element) {
+ (element as HTMLDivElement).focus();
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ }
+ }, []);
+
+ const handleModalOpen = () => {
+ modalHistoryKey.current = Date.now();
+ unlistenHistory.current = history.listen((_, action) => {
+ if (action === 'POP') {
+ handleOnClose();
+
+ if (onCancel) onCancel();
+ }
+ });
+ };
+
+ const handleModalClose = (type: string) => {
+ if (unlistenHistory.current) {
+ unlistenHistory.current();
+ }
+ if (!['FAVOURITES', 'MENTIONS', 'REACTIONS', 'REBLOGS', 'MEDIA'].includes(type)) {
+ const { state } = history.location;
+ if (state && (state as any).soapboxModalKey === modalHistoryKey.current) {
+ history.goBack();
+ }
+ }
+ };
+
+ const ensureHistoryBuffer = () => {
+ const { pathname, state } = history.location;
+ if (!state || (state as any).soapboxModalKey !== modalHistoryKey.current) {
+ history.push(pathname, { ...(state as any), soapboxModalKey: modalHistoryKey.current });
+ }
+ };
+
+ const getSiblings = () => {
+ return Array(...(ref.current!.parentElement!.childNodes as any as ChildNode[])).filter(node => node !== ref.current);
+ };
+
+ useEffect(() => {
+ window.addEventListener('keyup', handleKeyUp, false);
+ window.addEventListener('keydown', handleKeyDown, false);
+
+ return () => {
+ window.removeEventListener('keyup', handleKeyUp);
+ window.removeEventListener('keydown', handleKeyDown);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!!children && !prevChildren) {
+ activeElement.current = document.activeElement as HTMLDivElement;
+ getSiblings().forEach(sibling => (sibling as HTMLDivElement).setAttribute('inert', 'true'));
+
+ handleModalOpen();
+ } else if (!prevChildren) {
+ setRevealed(false);
+ }
+
+ if (!children && !!prevChildren) {
+ activeElement.current?.focus();
+ activeElement.current = null;
+ getSiblings().forEach(sibling => (sibling as HTMLDivElement).removeAttribute('inert'));
+
+ handleModalClose(prevType!);
+ }
+
+ if (children) {
+ requestAnimationFrame(() => {
+ setRevealed(true);
+ });
+
+ ensureHistoryBuffer();
+ }
+ });
+
+ const visible = !!children;
+
+ if (!visible) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+export default ModalRoot;
diff --git a/app/soapbox/components/status-content.css b/app/soapbox/components/status-content.css
new file mode 100644
index 000000000..7c1317d16
--- /dev/null
+++ b/app/soapbox/components/status-content.css
@@ -0,0 +1,77 @@
+.status-content p {
+ @apply mb-5 whitespace-pre-wrap;
+}
+
+.status-content p:last-child {
+ @apply mb-0.5;
+}
+
+.status-content a {
+ @apply text-primary-600 dark:text-accent-blue hover:underline;
+}
+
+.status-content strong {
+ @apply font-bold;
+}
+
+.status-content em {
+ @apply italic;
+}
+
+.status-content ul,
+.status-content ol {
+ @apply pl-10 mb-5;
+}
+
+.status-content ul {
+ @apply list-disc list-outside;
+}
+
+.status-content ol {
+ @apply list-decimal list-outside;
+}
+
+.status-content blockquote {
+ @apply py-1 pl-4 mb-5 border-l-4 border-solid border-gray-400 text-gray-500 dark:text-gray-400;
+}
+
+.status-content code {
+ @apply cursor-text font-mono;
+}
+
+.status-content p > code,
+.status-content pre {
+ @apply bg-gray-100 dark:bg-primary-800;
+}
+
+/* Inline code */
+.status-content p > code {
+ @apply py-0.5 px-1 rounded-sm;
+}
+
+/* Code block */
+.status-content pre {
+ @apply py-2 px-3 mb-5 leading-6 overflow-x-auto rounded-md break-all;
+}
+
+.status-content pre:last-child {
+ @apply mb-0;
+}
+
+/* Markdown images */
+.status-content img:not(.emojione):not([width][height]) {
+ @apply w-full h-72 object-contain rounded-lg overflow-hidden my-5 block;
+}
+
+/* User setting to underline links */
+body.underline-links .status-content a {
+ @apply underline;
+}
+
+.status-content .big-emoji img.emojione {
+ @apply inline w-9 h-9 p-1;
+}
+
+.status-content .status-link {
+ @apply hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue;
+}
diff --git a/app/soapbox/components/status-media.tsx b/app/soapbox/components/status-media.tsx
index d64d88e4b..2721d726b 100644
--- a/app/soapbox/components/status-media.tsx
+++ b/app/soapbox/components/status-media.tsx
@@ -144,7 +144,6 @@ const StatusMedia: React.FC = ({
= (props) => {
const accountAction = props.accountAction || reblogElement;
+ const inReview = status.visibility === 'self';
+
return (
= (props) => {
/>
-
+
+ {inReview ? (
+
+ ) : null}
+
{!group && actualStatus.group && (
Posted in {String(actualStatus.getIn(['group', 'title']))}
@@ -385,8 +396,8 @@ const Status: React.FC = (props) => {
)}
-
-
+
+
);
};
diff --git a/app/soapbox/components/status_content.tsx b/app/soapbox/components/status_content.tsx
index 2a8972315..7d771ec37 100644
--- a/app/soapbox/components/status_content.tsx
+++ b/app/soapbox/components/status_content.tsx
@@ -11,6 +11,7 @@ import { onlyEmoji as isOnlyEmoji } from 'soapbox/utils/rich_content';
import { isRtl } from '../rtl';
import Poll from './polls/poll';
+import './status-content.css';
import type { Status, Mention } from 'soapbox/types/entities';
@@ -28,7 +29,7 @@ interface IReadMoreButton {
/** Button to expand a truncated status (due to too much content) */
const ReadMoreButton: React.FC = ({ onClick }) => (
-