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 ( -
- - - {attachmentIcon} - {filename} - -
- ); - } 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 = ( -
-
- ); - } 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) ? ( - -
-
- ) - )} - - - {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 = ( +
+
+ ); + } 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) ? ( + +
+
+ ) + )} + + + {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_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 }) => ( - @@ -48,7 +49,7 @@ const SpoilerButton: React.FC = ({ onClick, hidden, tabIndex }) 'inline-block rounded-md px-1.5 py-0.5 ml-[0.5em]', 'text-gray-900 dark:text-gray-100', 'font-bold text-[11px] uppercase', - 'bg-primary-100 dark:bg-primary-900', + 'bg-primary-100 dark:bg-primary-800', 'hover:bg-primary-300 dark:hover:bg-primary-600', 'focus:bg-primary-200 dark:focus:bg-primary-600', 'hover:no-underline', @@ -212,15 +213,18 @@ const StatusContent: React.FC = ({ status, expanded = false, onE } const isHidden = onExpandedToggle ? !expanded : hidden; + const withSpoiler = status.spoiler_text.length > 0; + + const baseClassName = 'text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative focus:outline-none'; const content = { __html: parsedHtml }; const spoilerContent = { __html: status.spoilerHtml }; const directionStyle: React.CSSProperties = { direction: 'ltr' }; - const className = classNames('status__content', { - 'status__content--with-action': onClick, - 'status__content--with-spoiler': status.spoiler_text.length > 0, - 'status__content--collapsed': collapsed, - 'status__content--big': onlyEmoji, + const className = classNames(baseClassName, 'status-content', { + 'cursor-pointer': onClick, + 'whitespace-normal': withSpoiler, + 'max-h-[300px]': collapsed, + 'leading-normal big-emoji': onlyEmoji, }); if (isRtl(status.search_index)) { @@ -242,8 +246,10 @@ const StatusContent: React.FC = ({ status, expanded = false, onE
= ({ status, expanded = false, onE ref={node} tabIndex={0} key='content' - className={classNames('status__content', { - 'status__content--big': onlyEmoji, + className={classNames(baseClassName, 'status-content', { + 'leading-normal big-emoji': onlyEmoji, })} style={directionStyle} dangerouslySetInnerHTML={content} diff --git a/app/soapbox/features/landing_page/index.tsx b/app/soapbox/features/landing_page/index.tsx index 76e77bcfa..937e599c8 100644 --- a/app/soapbox/features/landing_page/index.tsx +++ b/app/soapbox/features/landing_page/index.tsx @@ -8,6 +8,8 @@ import RegistrationForm from 'soapbox/features/auth_login/components/registratio import { useAppDispatch, useAppSelector, useFeatures, useSoapboxConfig } from 'soapbox/hooks'; import { capitalize } from 'soapbox/utils/strings'; +import './instance-description.css'; + const LandingPage = () => { const dispatch = useAppDispatch(); const features = useFeatures(); diff --git a/app/soapbox/features/landing_page/instance-description.css b/app/soapbox/features/landing_page/instance-description.css new file mode 100644 index 000000000..f67097de0 --- /dev/null +++ b/app/soapbox/features/landing_page/instance-description.css @@ -0,0 +1,14 @@ +/* Instance HTML from the API. */ +.instance-description a { + @apply underline; +} + +.instance-description b, +.instance-description strong { + @apply font-bold; +} + +.instance-description i, +.instance-description em { + @apply italic; +} diff --git a/app/soapbox/features/onboarding/onboarding-wizard.tsx b/app/soapbox/features/onboarding/onboarding-wizard.tsx index 7b82e8862..ae3c3c74c 100644 --- a/app/soapbox/features/onboarding/onboarding-wizard.tsx +++ b/app/soapbox/features/onboarding/onboarding-wizard.tsx @@ -6,16 +6,19 @@ import ReactSwipeableViews from 'react-swipeable-views'; import { endOnboarding } from 'soapbox/actions/onboarding'; import LandingGradient from 'soapbox/components/landing-gradient'; import { HStack } from 'soapbox/components/ui'; +import { useFeatures } from 'soapbox/hooks'; import AvatarSelectionStep from './steps/avatar-selection-step'; import BioStep from './steps/bio-step'; import CompletedStep from './steps/completed-step'; import CoverPhotoSelectionStep from './steps/cover-photo-selection-step'; import DisplayNameStep from './steps/display-name-step'; +import FediverseStep from './steps/fediverse-step'; import SuggestedAccountsStep from './steps/suggested-accounts-step'; const OnboardingWizard = () => { const dispatch = useDispatch(); + const features = useFeatures(); const [currentStep, setCurrentStep] = React.useState(0); @@ -41,9 +44,14 @@ const OnboardingWizard = () => { , , , - , ]; + if (features.federating){ + steps.push(); + } + + steps.push(); + const handleKeyUp = ({ key }: KeyboardEvent): void => { switch (key) { case 'ArrowLeft': diff --git a/app/soapbox/features/onboarding/steps/fediverse-step.tsx b/app/soapbox/features/onboarding/steps/fediverse-step.tsx new file mode 100644 index 000000000..6dbb26620 --- /dev/null +++ b/app/soapbox/features/onboarding/steps/fediverse-step.tsx @@ -0,0 +1,88 @@ +import * as React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import Account from 'soapbox/components/account'; +import { Button, Card, CardBody, Icon, Stack, Text } from 'soapbox/components/ui'; +import { useAppSelector, useOwnAccount } from 'soapbox/hooks'; + +import type { Account as AccountEntity } from 'soapbox/types/entities'; + +const FediverseStep = ({ onNext }: { onNext: () => void }) => { + const siteTitle = useAppSelector((state) => state.instance.title); + const account = useOwnAccount() as AccountEntity; + + return ( + + + + + + + + + + +
+ + + + + + + + + +
+ +
+ +
+ + + + + + + + +
+
+ +
+ + + +
+
+
+ ); +}; + +export default FediverseStep; diff --git a/app/soapbox/features/ui/components/compose_modal.tsx b/app/soapbox/features/ui/components/compose_modal.tsx index 119bcd05f..5dc86766b 100644 --- a/app/soapbox/features/ui/components/compose_modal.tsx +++ b/app/soapbox/features/ui/components/compose_modal.tsx @@ -27,10 +27,8 @@ const ComposeModal: React.FC = ({ onClose }) => { const { id: statusId, privacy, in_reply_to: inReplyTo, quote } = compose!; - const hasComposeContent = checkComposeContent(compose); - const onClickClose = () => { - if (hasComposeContent) { + if (checkComposeContent(compose)) { dispatch(openModal('CONFIRM', { icon: require('@tabler/icons/trash.svg'), heading: statusId diff --git a/app/soapbox/features/ui/util/fullscreen.js b/app/soapbox/features/ui/util/fullscreen.ts similarity index 57% rename from app/soapbox/features/ui/util/fullscreen.js rename to app/soapbox/features/ui/util/fullscreen.ts index cf5d0cf98..5e13d68cc 100644 --- a/app/soapbox/features/ui/util/fullscreen.js +++ b/app/soapbox/features/ui/util/fullscreen.ts @@ -1,31 +1,43 @@ // APIs for normalizing fullscreen operations. Note that Edge uses // the WebKit-prefixed APIs currently (as of Edge 16). -export const isFullscreen = () => document.fullscreenElement || - document.webkitFullscreenElement || - document.mozFullScreenElement; +export const isFullscreen = (): boolean => { + return Boolean( + document.fullscreenElement || + // @ts-ignore + document.webkitFullscreenElement || + // @ts-ignore + document.mozFullScreenElement, + ); +}; -export const exitFullscreen = () => { +export const exitFullscreen = (): void => { if (document.exitFullscreen) { document.exitFullscreen(); - } else if (document.webkitExitFullscreen) { + } else if ('webkitExitFullscreen' in document) { + // @ts-ignore document.webkitExitFullscreen(); - } else if (document.mozCancelFullScreen) { + } else if ('mozCancelFullScreen' in document) { + // @ts-ignore document.mozCancelFullScreen(); } }; -export const requestFullscreen = el => { +export const requestFullscreen = (el: Element): void => { if (el.requestFullscreen) { el.requestFullscreen(); - } else if (el.webkitRequestFullscreen) { + } else if ('webkitRequestFullscreen' in el) { + // @ts-ignore el.webkitRequestFullscreen(); - } else if (el.mozRequestFullScreen) { + } else if ('mozRequestFullScreen' in el) { + // @ts-ignore el.mozRequestFullScreen(); } }; -export const attachFullscreenListener = (listener) => { +type FullscreenListener = (this: Document, ev: Event) => void; + +export const attachFullscreenListener = (listener: FullscreenListener): void => { if ('onfullscreenchange' in document) { document.addEventListener('fullscreenchange', listener); } else if ('onwebkitfullscreenchange' in document) { @@ -35,7 +47,7 @@ export const attachFullscreenListener = (listener) => { } }; -export const detachFullscreenListener = (listener) => { +export const detachFullscreenListener = (listener: FullscreenListener): void => { if ('onfullscreenchange' in document) { document.removeEventListener('fullscreenchange', listener); } else if ('onwebkitfullscreenchange' in document) { diff --git a/app/soapbox/features/video/index.js b/app/soapbox/features/video/index.js deleted file mode 100644 index 1d6b55ff6..000000000 --- a/app/soapbox/features/video/index.js +++ /dev/null @@ -1,625 +0,0 @@ -import classNames from 'clsx'; -import { fromJS, is } from 'immutable'; -import debounce from 'lodash/debounce'; -import throttle from 'lodash/throttle'; -import PropTypes from 'prop-types'; -import React from 'react'; -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 { isPanoramic, isPortrait, minimumAspectRatio, maximumAspectRatio } from 'soapbox/utils/media_aspect_ratio'; - -import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen'; - -const DEFAULT_HEIGHT = 300; - -const messages = defineMessages({ - play: { id: 'video.play', defaultMessage: 'Play' }, - pause: { id: 'video.pause', defaultMessage: 'Pause' }, - mute: { id: 'video.mute', defaultMessage: 'Mute sound' }, - unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' }, - hide: { id: 'video.hide', defaultMessage: 'Hide video' }, - expand: { id: 'video.expand', defaultMessage: 'Expand video' }, - close: { id: 'video.close', defaultMessage: 'Close video' }, - fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' }, - exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' }, -}); - -export const formatTime = secondsNum => { - let hours = Math.floor(secondsNum / 3600); - let minutes = Math.floor((secondsNum - (hours * 3600)) / 60); - let seconds = secondsNum - (hours * 3600) - (minutes * 60); - - if (hours < 10) hours = '0' + hours; - if (minutes < 10) minutes = '0' + minutes; - if (seconds < 10) seconds = '0' + seconds; - - return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}`; -}; - -export const findElementPosition = el => { - let box; - - if (el.getBoundingClientRect && el.parentNode) { - box = el.getBoundingClientRect(); - } - - if (!box) { - return { - left: 0, - top: 0, - }; - } - - const docEl = document.documentElement; - const body = document.body; - - const clientLeft = docEl.clientLeft || body.clientLeft || 0; - const scrollLeft = window.pageXOffset || body.scrollLeft; - const left = (box.left + scrollLeft) - clientLeft; - - const clientTop = docEl.clientTop || body.clientTop || 0; - const scrollTop = window.pageYOffset || body.scrollTop; - const top = (box.top + scrollTop) - clientTop; - - return { - left: Math.round(left), - top: Math.round(top), - }; -}; - -export const getPointerPosition = (el, event) => { - const position = {}; - const box = findElementPosition(el); - const boxW = el.offsetWidth; - const boxH = el.offsetHeight; - const boxY = box.top; - const boxX = box.left; - - let pageY = event.pageY; - let pageX = event.pageX; - - if (event.changedTouches) { - pageX = event.changedTouches[0].pageX; - pageY = event.changedTouches[0].pageY; - } - - position.y = Math.max(0, Math.min(1, (pageY - boxY) / boxH)); - position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW)); - - return position; -}; - -export const fileNameFromURL = str => { - const url = new URL(str); - const pathname = url.pathname; - const index = pathname.lastIndexOf('/'); - - return pathname.substring(index + 1); -}; - -const mapStateToProps = state => ({ - displayMedia: getSettings(state).get('displayMedia'), -}); - -export default @connect(mapStateToProps) -@injectIntl -class Video extends React.PureComponent { - - static propTypes = { - preview: PropTypes.string, - src: PropTypes.string.isRequired, - alt: PropTypes.string, - width: PropTypes.number, - height: PropTypes.number, - sensitive: PropTypes.bool, - startTime: PropTypes.number, - onOpenVideo: PropTypes.func, - onCloseVideo: PropTypes.func, - detailed: PropTypes.bool, - inline: PropTypes.bool, - cacheWidth: PropTypes.func, - visible: PropTypes.bool, - onToggleVisibility: PropTypes.func, - intl: PropTypes.object.isRequired, - blurhash: PropTypes.string, - link: PropTypes.node, - aspectRatio: PropTypes.number, - displayMedia: PropTypes.string, - }; - - state = { - currentTime: 0, - duration: 0, - volume: 0.5, - paused: true, - dragging: false, - containerWidth: this.props.width, - fullscreen: false, - hovered: false, - muted: false, - revealed: this.props.visible !== undefined ? this.props.visible : (this.props.displayMedia !== 'hide_all' && !this.props.sensitive || this.props.displayMedia === 'show_all'), - }; - - setPlayerRef = c => { - this.player = c; - - if (this.player) { - this._setDimensions(); - } - } - - _setDimensions() { - const width = this.player.offsetWidth; - - if (this.props.cacheWidth) { - this.props.cacheWidth(width); - } - - this.setState({ - containerWidth: width, - }); - } - - setVideoRef = c => { - this.video = c; - - if (this.video) { - this.setState({ volume: this.video.volume, muted: this.video.muted }); - } - } - - setSeekRef = c => { - this.seek = c; - } - - setVolumeRef = c => { - this.volume = c; - } - - handleClickRoot = e => e.stopPropagation(); - - handlePlay = () => { - this.setState({ paused: false }); - } - - handlePause = () => { - this.setState({ paused: true }); - } - - handleTimeUpdate = () => { - this.setState({ - currentTime: Math.floor(this.video.currentTime), - duration: Math.floor(this.video.duration), - }); - } - - handleVolumeMouseDown = e => { - document.addEventListener('mousemove', this.handleMouseVolSlide, true); - document.addEventListener('mouseup', this.handleVolumeMouseUp, true); - document.addEventListener('touchmove', this.handleMouseVolSlide, true); - document.addEventListener('touchend', this.handleVolumeMouseUp, true); - - this.handleMouseVolSlide(e); - - e.preventDefault(); - e.stopPropagation(); - } - - handleVolumeMouseUp = () => { - document.removeEventListener('mousemove', this.handleMouseVolSlide, true); - document.removeEventListener('mouseup', this.handleVolumeMouseUp, true); - document.removeEventListener('touchmove', this.handleMouseVolSlide, true); - document.removeEventListener('touchend', this.handleVolumeMouseUp, true); - } - - handleMouseVolSlide = throttle(e => { - const { x } = getPointerPosition(this.volume, e); - - if (!isNaN(x)) { - let slideamt = x; - - if (x > 1) { - slideamt = 1; - } else if (x < 0) { - slideamt = 0; - } - - this.video.volume = slideamt; - this.setState({ volume: slideamt }); - } - }, 60); - - handleMouseDown = e => { - document.addEventListener('mousemove', this.handleMouseMove, true); - document.addEventListener('mouseup', this.handleMouseUp, true); - document.addEventListener('touchmove', this.handleMouseMove, true); - document.addEventListener('touchend', this.handleMouseUp, true); - - this.setState({ dragging: true }); - this.video.pause(); - this.handleMouseMove(e); - - e.preventDefault(); - e.stopPropagation(); - } - - handleMouseUp = () => { - document.removeEventListener('mousemove', this.handleMouseMove, true); - document.removeEventListener('mouseup', this.handleMouseUp, true); - document.removeEventListener('touchmove', this.handleMouseMove, true); - document.removeEventListener('touchend', this.handleMouseUp, true); - - this.setState({ dragging: false }); - this.video.play(); - } - - handleMouseMove = throttle(e => { - const { x } = getPointerPosition(this.seek, e); - const currentTime = Math.floor(this.video.duration * x); - - if (!isNaN(currentTime)) { - this.video.currentTime = currentTime; - this.setState({ currentTime }); - } - }, 60); - - seekBy(time) { - const currentTime = this.video.currentTime + time; - - if (!isNaN(currentTime)) { - this.setState({ currentTime }, () => { - this.video.currentTime = currentTime; - }); - } - } - - handleVideoKeyDown = e => { - // On the video element or the seek bar, we can safely use the space bar - // for playback control because there are no buttons to press - - if (e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - this.togglePlay(); - } - } - - handleKeyDown = e => { - const frameTime = 1 / 25; - - switch (e.key) { - case 'k': - e.preventDefault(); - e.stopPropagation(); - this.togglePlay(); - break; - case 'm': - e.preventDefault(); - e.stopPropagation(); - this.toggleMute(); - break; - case 'f': - e.preventDefault(); - e.stopPropagation(); - this.toggleFullscreen(); - break; - case 'j': - e.preventDefault(); - e.stopPropagation(); - this.seekBy(-10); - break; - case 'l': - e.preventDefault(); - e.stopPropagation(); - this.seekBy(10); - break; - case ',': - e.preventDefault(); - e.stopPropagation(); - this.seekBy(-frameTime); - break; - case '.': - e.preventDefault(); - e.stopPropagation(); - this.seekBy(frameTime); - break; - } - - // If we are in fullscreen mode, we don't want any hotkeys - // interacting with the UI that's not visible - - if (this.state.fullscreen) { - e.preventDefault(); - e.stopPropagation(); - - if (e.key === 'Escape') { - exitFullscreen(); - } - } - } - - togglePlay = (e) => { - if (e) { - e.stopPropagation(); - } - - if (this.state.paused) { - this.setState({ paused: false }, () => this.video.play()); - } else { - this.setState({ paused: true }, () => this.video.pause()); - } - } - - toggleFullscreen = () => { - if (isFullscreen()) { - exitFullscreen(); - } else { - requestFullscreen(this.player); - } - } - - componentDidMount() { - document.addEventListener('fullscreenchange', this.handleFullscreenChange, true); - document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true); - document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true); - document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true); - - window.addEventListener('scroll', this.handleScroll); - window.addEventListener('resize', this.handleResize, { passive: true }); - } - - componentWillUnmount() { - window.removeEventListener('scroll', this.handleScroll); - window.removeEventListener('resize', this.handleResize); - - document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true); - document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true); - document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true); - document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true); - } - - componentDidUpdate(prevProps, prevState) { - const { visible } = this.props; - - if (!is(visible, prevProps.visible) && visible !== undefined) { - this.setState({ revealed: visible }); - } - - if (prevState.revealed && !this.state.revealed && this.video) { - this.video.pause(); - } - } - - handleResize = debounce(() => { - if (this.player) { - this._setDimensions(); - } - }, 250, { - trailing: true, - }); - - handleScroll = throttle(() => { - if (!this.video) { - return; - } - - const { top, height } = this.video.getBoundingClientRect(); - const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0); - - if (!this.state.paused && !inView) { - this.setState({ paused: true }, () => this.video.pause()); - } - }, 150, { trailing: true }) - - handleFullscreenChange = () => { - this.setState({ fullscreen: isFullscreen() }); - } - - handleMouseEnter = () => { - this.setState({ hovered: true }); - } - - handleMouseLeave = () => { - this.setState({ hovered: false }); - } - - toggleMute = () => { - const muted = !this.video.muted; - - this.setState({ muted }, () => { - this.video.muted = muted; - }); - } - - toggleReveal = (e) => { - e.stopPropagation(); - - if (this.props.onToggleVisibility) { - this.props.onToggleVisibility(); - } else { - this.setState({ revealed: !this.state.revealed }); - } - } - - handleLoadedData = () => { - if (this.props.startTime) { - this.video.currentTime = this.props.startTime; - this.video.play(); - } - } - - handleProgress = () => { - if (this.video.buffered.length > 0) { - this.setState({ buffer: this.video.buffered.end(0) / this.video.duration * 100 }); - } - } - - handleVolumeChange = () => { - this.setState({ volume: this.video.volume, muted: this.video.muted }); - } - - handleOpenVideo = () => { - const { src, preview, width, height, alt } = this.props; - - const media = fromJS({ - type: 'video', - url: src, - preview_url: preview, - description: alt, - width, - height, - }); - - this.video.pause(); - this.props.onOpenVideo(media, this.video.currentTime); - } - - handleCloseVideo = () => { - this.video.pause(); - this.props.onCloseVideo(); - } - - getPreload = () => { - const { startTime, detailed } = this.props; - const { dragging, fullscreen } = this.state; - - if (startTime || fullscreen || dragging) { - return 'auto'; - } else if (detailed) { - return 'metadata'; - } else { - return 'none'; - } - } - - render() { - const { src, inline, onCloseVideo, intl, alt, detailed, sensitive, link, aspectRatio, blurhash } = this.props; - const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; - const progress = (currentTime / duration) * 100; - const playerStyle = {}; - - let { width, height } = this.props; - - if (inline && containerWidth) { - width = containerWidth; - const minSize = containerWidth / (16 / 9); - - if (isPanoramic(aspectRatio)) { - height = Math.max(Math.floor(containerWidth / maximumAspectRatio), minSize); - } else if (isPortrait(aspectRatio)) { - height = Math.max(Math.floor(containerWidth / minimumAspectRatio), minSize); - } else { - height = Math.floor(containerWidth / aspectRatio); - } - - playerStyle.height = height || DEFAULT_HEIGHT; - } - - let warning; - - if (sensitive) { - warning = ; - } else { - warning = ; - } - - return ( -
- - - {revealed &&