environments/review-events-5jp5it/deployments/1372
commit
814dcfe2cd
@ -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 = (
|
||||
<Icon
|
||||
className='h-16 w-16 text-gray-800 dark:text-gray-200'
|
||||
src={MIMETYPE_ICONS[attachment.getIn(['pleroma', 'mime_type'])] || require('@tabler/icons/paperclip.svg')}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}>
|
||||
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}>
|
||||
<Blurhash hash={attachment.get('blurhash')} className='media-gallery__preview' />
|
||||
<span className='media-gallery__item__icons'>{attachmentIcon}</span>
|
||||
<span className='media-gallery__filename__label'>{filename}</span>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
} else if (attachment.get('type') === 'image') {
|
||||
const originalUrl = attachment.get('url');
|
||||
const letterboxed = shouldLetterbox(attachment);
|
||||
|
||||
thumbnail = (
|
||||
<a
|
||||
className={classNames('media-gallery__item-thumbnail', { letterboxed })}
|
||||
href={attachment.get('remote_url') || originalUrl}
|
||||
onClick={this.handleClick}
|
||||
target='_blank'
|
||||
>
|
||||
<StillImage src={originalUrl} alt={attachment.get('description')} />
|
||||
</a>
|
||||
);
|
||||
} else if (attachment.get('type') === 'gifv') {
|
||||
const conditionalAttributes = {};
|
||||
if (isIOS()) {
|
||||
conditionalAttributes.playsInline = '1';
|
||||
}
|
||||
if (autoPlayGif) {
|
||||
conditionalAttributes.autoPlay = '1';
|
||||
}
|
||||
|
||||
thumbnail = (
|
||||
<div className={classNames('media-gallery__gifv', { autoplay: autoPlayGif })}>
|
||||
<video
|
||||
className='media-gallery__item-gifv-thumbnail'
|
||||
aria-label={attachment.get('description')}
|
||||
title={attachment.get('description')}
|
||||
role='application'
|
||||
src={attachment.get('url')}
|
||||
onClick={this.handleClick}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
loop
|
||||
muted
|
||||
{...conditionalAttributes}
|
||||
/>
|
||||
|
||||
<span className='media-gallery__gifv__label'>GIF</span>
|
||||
</div>
|
||||
);
|
||||
} else if (attachment.get('type') === 'audio') {
|
||||
const ext = attachment.get('url').split('.').pop().toUpperCase();
|
||||
thumbnail = (
|
||||
<a
|
||||
className={classNames('media-gallery__item-thumbnail')}
|
||||
href={attachment.get('url')}
|
||||
onClick={this.handleClick}
|
||||
target='_blank'
|
||||
alt={attachment.get('description')}
|
||||
title={attachment.get('description')}
|
||||
>
|
||||
<span className='media-gallery__item__icons'><Icon src={require('@tabler/icons/volume.svg')} /></span>
|
||||
<span className='media-gallery__file-extension__label'>{ext}</span>
|
||||
</a>
|
||||
);
|
||||
} else if (attachment.get('type') === 'video') {
|
||||
const ext = attachment.get('url').split('.').pop().toUpperCase();
|
||||
thumbnail = (
|
||||
<a
|
||||
className={classNames('media-gallery__item-thumbnail')}
|
||||
href={attachment.get('url')}
|
||||
onClick={this.handleClick}
|
||||
target='_blank'
|
||||
alt={attachment.get('description')}
|
||||
title={attachment.get('description')}
|
||||
>
|
||||
<video
|
||||
muted
|
||||
loop
|
||||
onMouseOver={this.handleVideoHover}
|
||||
onMouseOut={this.handleVideoLeave}
|
||||
>
|
||||
<source src={attachment.get('url')} />
|
||||
</video>
|
||||
<span className='media-gallery__file-extension__label'>{ext}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('media-gallery__item', `media-gallery__item--${attachment.get('type')}`, { standalone })} key={attachment.get('id')} style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}>
|
||||
{last && total > ATTACHMENT_LIMIT && (
|
||||
<div className='media-gallery__item-overflow'>
|
||||
+{total - ATTACHMENT_LIMIT + 1}
|
||||
</div>
|
||||
)}
|
||||
<Blurhash
|
||||
hash={attachment.get('blurhash')}
|
||||
className={classNames('media-gallery__preview', {
|
||||
'media-gallery__preview--hidden': visible && this.state.loaded,
|
||||
})}
|
||||
/>
|
||||
{visible && thumbnail}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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) => (
|
||||
<Item
|
||||
key={attachment.get('id')}
|
||||
onClick={this.handleClick}
|
||||
attachment={attachment}
|
||||
index={i}
|
||||
size={sizeData.get('size')}
|
||||
displayWidth={sizeData.get('width')}
|
||||
visible={visible}
|
||||
dimensions={sizeData.get('itemsDimensions')[i]}
|
||||
last={i === ATTACHMENT_LIMIT - 1}
|
||||
total={media.size}
|
||||
/>
|
||||
));
|
||||
|
||||
let warning;
|
||||
|
||||
if (sensitive) {
|
||||
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
|
||||
} else {
|
||||
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('media-gallery', { 'media-gallery--compact': compact })} style={sizeData.get('style')} ref={this.handleRef}>
|
||||
<div
|
||||
className={classNames({
|
||||
'absolute z-40': true,
|
||||
'inset-0': !visible && !compact,
|
||||
'left-1 top-1': visible || compact,
|
||||
})}
|
||||
>
|
||||
{sensitive && (
|
||||
(visible || compact) ? (
|
||||
<Button
|
||||
text={intl.formatMessage(messages.toggle_visible)}
|
||||
icon={visible ? require('@tabler/icons/eye-off.svg') : require('@tabler/icons/eye.svg')}
|
||||
onClick={this.handleOpen}
|
||||
theme='transparent'
|
||||
size='sm'
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
onClick={(e) => 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,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className='text-center w-3/4 mx-auto space-y-4'>
|
||||
<div className='space-y-1'>
|
||||
<Text theme='white' weight='semibold'>{warning}</Text>
|
||||
<Text size='sm'>
|
||||
<FormattedMessage id='status.sensitive_warning.subtitle' defaultMessage='This content may not be suitable for all audiences.' />
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
theme='outline'
|
||||
size='sm'
|
||||
icon={require('@tabler/icons/eye.svg')}
|
||||
onClick={this.handleOpen}
|
||||
>
|
||||
<FormattedMessage id='status.sensitive_warning.action' defaultMessage='Show content' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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<IItem> = ({
|
||||
attachment,
|
||||
index,
|
||||
onClick,
|
||||
standalone = false,
|
||||
visible,
|
||||
dimensions,
|
||||
last,
|
||||
total,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const autoPlayGif = settings.get('autoPlayGif') === true;
|
||||
|
||||
const handleMouseEnter: React.MouseEventHandler<HTMLVideoElement> = ({ currentTarget: video }) => {
|
||||
if (hoverToPlay()) {
|
||||
video.play();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave: React.MouseEventHandler<HTMLVideoElement> = ({ 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<HTMLVideoElement> = ({ currentTarget: video }) => {
|
||||
video.playbackRate = 3.0;
|
||||
video.play();
|
||||
};
|
||||
|
||||
const handleVideoLeave: React.MouseEventHandler<HTMLVideoElement> = ({ 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 = (
|
||||
<Icon
|
||||
className='h-16 w-16 text-gray-800 dark:text-gray-200'
|
||||
src={MIMETYPE_ICONS[attachment.getIn(['pleroma', 'mime_type']) as string] || require('@tabler/icons/paperclip.svg')}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classNames('media-gallery__item', { standalone })} key={attachment.id} style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}>
|
||||
<a className='media-gallery__item-thumbnail' href={attachment.url} target='_blank' style={{ cursor: 'pointer' }}>
|
||||
<Blurhash hash={attachment.blurhash} className='media-gallery__preview' />
|
||||
<span className='media-gallery__item__icons'>{attachmentIcon}</span>
|
||||
<span className='media-gallery__filename__label'>{filename}</span>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
} else if (attachment.type === 'image') {
|
||||
const letterboxed = shouldLetterbox(attachment);
|
||||
|
||||
thumbnail = (
|
||||
<a
|
||||
className={classNames('media-gallery__item-thumbnail', { letterboxed })}
|
||||
href={attachment.url}
|
||||
onClick={handleClick}
|
||||
target='_blank'
|
||||
>
|
||||
<StillImage src={attachment.url} alt={attachment.description} />
|
||||
</a>
|
||||
);
|
||||
} else if (attachment.type === 'gifv') {
|
||||
const conditionalAttributes: React.VideoHTMLAttributes<HTMLVideoElement> = {};
|
||||
if (isIOS()) {
|
||||
conditionalAttributes.playsInline = true;
|
||||
}
|
||||
if (autoPlayGif) {
|
||||
conditionalAttributes.autoPlay = true;
|
||||
}
|
||||
|
||||
thumbnail = (
|
||||
<div className={classNames('media-gallery__gifv', { autoplay: autoPlayGif })}>
|
||||
<video
|
||||
className='media-gallery__item-gifv-thumbnail'
|
||||
aria-label={attachment.description}
|
||||
title={attachment.description}
|
||||
role='application'
|
||||
src={attachment.url}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
loop
|
||||
muted
|
||||
{...conditionalAttributes}
|
||||
/>
|
||||
|
||||
<span className='media-gallery__gifv__label'>GIF</span>
|
||||
</div>
|
||||
);
|
||||
} else if (attachment.type === 'audio') {
|
||||
const ext = attachment.url.split('.').pop()?.toUpperCase();
|
||||
thumbnail = (
|
||||
<a
|
||||
className={classNames('media-gallery__item-thumbnail')}
|
||||
href={attachment.url}
|
||||
onClick={handleClick}
|
||||
target='_blank'
|
||||
title={attachment.description}
|
||||
>
|
||||
<span className='media-gallery__item__icons'><Icon src={require('@tabler/icons/volume.svg')} /></span>
|
||||
<span className='media-gallery__file-extension__label'>{ext}</span>
|
||||
</a>
|
||||
);
|
||||
} else if (attachment.type === 'video') {
|
||||
const ext = attachment.url.split('.').pop()?.toUpperCase();
|
||||
thumbnail = (
|
||||
<a
|
||||
className={classNames('media-gallery__item-thumbnail')}
|
||||
href={attachment.url}
|
||||
onClick={handleClick}
|
||||
target='_blank'
|
||||
title={attachment.description}
|
||||
>
|
||||
<video
|
||||
muted
|
||||
loop
|
||||
onMouseOver={handleVideoHover}
|
||||
onMouseOut={handleVideoLeave}
|
||||
>
|
||||
<source src={attachment.url} />
|
||||
</video>
|
||||
<span className='media-gallery__file-extension__label'>{ext}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('media-gallery__item', `media-gallery__item--${attachment.type}`, { standalone })} key={attachment.id} style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}>
|
||||
{last && total > ATTACHMENT_LIMIT && (
|
||||
<div className='media-gallery__item-overflow'>
|
||||
+{total - ATTACHMENT_LIMIT + 1}
|
||||
</div>
|
||||
)}
|
||||
<Blurhash
|
||||
hash={attachment.blurhash}
|
||||
className='media-gallery__preview'
|
||||
/>
|
||||
{visible && thumbnail}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IMediaGallery {
|
||||
sensitive?: boolean,
|
||||
media: ImmutableList<Attachment>,
|
||||
size: number,
|
||||
height: number,
|
||||
onOpenMedia: (media: ImmutableList<Attachment>, index: number) => void,
|
||||
defaultWidth: number,
|
||||
cacheWidth: (width: number) => void,
|
||||
visible?: boolean,
|
||||
onToggleVisibility?: () => void,
|
||||
displayMedia: string,
|
||||
compact: boolean,
|
||||
}
|
||||
|
||||
const MediaGallery: React.FC<IMediaGallery> = (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<boolean>(props.visible !== undefined ? props.visible : (displayMedia !== 'hide_all' && !sensitive || displayMedia === 'show_all'));
|
||||
const [width, setWidth] = useState<number>(defaultWidth);
|
||||
|
||||
const node = useRef<HTMLDivElement>(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<SizeData> => {
|
||||
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) => (
|
||||
<Item
|
||||
key={attachment.id}
|
||||
onClick={handleClick}
|
||||
attachment={attachment}
|
||||
index={i}
|
||||
size={sizeData.size}
|
||||
displayWidth={sizeData.width}
|
||||
visible={visible}
|
||||
dimensions={sizeData.itemsDimensions[i]}
|
||||
last={i === ATTACHMENT_LIMIT - 1}
|
||||
total={media.size}
|
||||
/>
|
||||
));
|
||||
|
||||
let warning;
|
||||
|
||||
if (sensitive) {
|
||||
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
|
||||
} else {
|
||||
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (node.current) {
|
||||
const { offsetWidth } = node.current;
|
||||
|
||||
if (cacheWidth) {
|
||||
cacheWidth(offsetWidth);
|
||||
}
|
||||
|
||||
setWidth(offsetWidth);
|
||||
}
|
||||
}, [node.current]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisible(!!props.visible);
|
||||
}, [props.visible]);
|
||||
|
||||
return (
|
||||
<div className={classNames('media-gallery', { 'media-gallery--compact': compact })} style={sizeData.style} ref={node}>
|
||||
<div
|
||||
className={classNames({
|
||||
'absolute z-40': true,
|
||||
'inset-0': !visible && !compact,
|
||||
'left-1 top-1': visible || compact,
|
||||
})}
|
||||
>
|
||||
{sensitive && (
|
||||
(visible || compact) ? (
|
||||
<Button
|
||||
text={intl.formatMessage(messages.toggle_visible)}
|
||||
icon={visible ? require('@tabler/icons/eye-off.svg') : require('@tabler/icons/eye.svg')}
|
||||
onClick={handleOpen}
|
||||
theme='transparent'
|
||||
size='sm'
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
onClick={(e) => 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,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className='text-center w-3/4 mx-auto space-y-4'>
|
||||
<div className='space-y-1'>
|
||||
<Text theme='white' weight='semibold'>{warning}</Text>
|
||||
<Text size='sm'>
|
||||
<FormattedMessage id='status.sensitive_warning.subtitle' defaultMessage='This content may not be suitable for all audiences.' />
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
theme='outline'
|
||||
size='sm'
|
||||
icon={require('@tabler/icons/eye.svg')}
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<FormattedMessage id='status.sensitive_warning.action' defaultMessage='Show content' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaGallery;
|
@ -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 ? <FormattedMessage id='confirmations.cancel_editing.heading' defaultMessage='Cancel post editing' /> : <FormattedMessage id='confirmations.delete.heading' defaultMessage='Delete post' />,
|
||||
message: isEditing ? <FormattedMessage id='confirmations.cancel_editing.message' defaultMessage='Are you sure you want to cancel editing this post? All changes will be lost.' /> : <FormattedMessage id='confirmations.delete.message' defaultMessage='Are you sure you want to delete this post?' />,
|
||||
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 (
|
||||
<div className='z-50 transition-all' ref={this.setRef} style={{ opacity: 0 }} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={this.setRef}
|
||||
className={classNames({
|
||||
'fixed top-0 left-0 z-[100] w-full h-full overflow-x-hidden overflow-y-auto': true,
|
||||
'pointer-events-none': !visible,
|
||||
})}
|
||||
style={{ opacity: revealed ? 1 : 0 }}
|
||||
>
|
||||
<div
|
||||
role='presentation'
|
||||
id='modal-overlay'
|
||||
className='fixed inset-0 bg-gray-500/90 dark:bg-gray-700/90'
|
||||
onClick={this.handleOnClose}
|
||||
/>
|
||||
|
||||
<div
|
||||
role='dialog'
|
||||
className={classNames({
|
||||
'my-2 mx-auto relative pointer-events-none flex items-center': true,
|
||||
'p-4 md:p-0': type !== 'MEDIA',
|
||||
})}
|
||||
style={{ minHeight: 'calc(100% - 3.5rem)' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ModalRoot));
|
@ -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<typeof ReducerCompose>) => {
|
||||
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<IModalRoot> = ({ children, onCancel, onClose, type }) => {
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [revealed, setRevealed] = useState(!!children);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const activeElement = useRef<HTMLDivElement | null>(revealed ? document.activeElement as HTMLDivElement | null : null);
|
||||
const modalHistoryKey = useRef<number>();
|
||||
const unlistenHistory = useRef<UnregisterCallback>();
|
||||
|
||||
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 ? <FormattedMessage id='confirmations.cancel_editing.heading' defaultMessage='Cancel post editing' /> : <FormattedMessage id='confirmations.delete.heading' defaultMessage='Delete post' />,
|
||||
message: isEditing ? <FormattedMessage id='confirmations.cancel_editing.message' defaultMessage='Are you sure you want to cancel editing this post? All changes will be lost.' /> : <FormattedMessage id='confirmations.delete.message' defaultMessage='Are you sure you want to delete this post?' />,
|
||||
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 (
|
||||
<div className='z-50 transition-all' ref={ref} style={{ opacity: 0 }} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames({
|
||||
'fixed top-0 left-0 z-[100] w-full h-full overflow-x-hidden overflow-y-auto': true,
|
||||
'pointer-events-none': !visible,
|
||||
})}
|
||||
style={{ opacity: revealed ? 1 : 0 }}
|
||||
>
|
||||
<div
|
||||
role='presentation'
|
||||
id='modal-overlay'
|
||||
className='fixed inset-0 bg-gray-500/90 dark:bg-gray-700/90'
|
||||
onClick={handleOnClose}
|
||||
/>
|
||||
|
||||
<div
|
||||
role='dialog'
|
||||
className={classNames({
|
||||
'my-2 mx-auto relative pointer-events-none flex items-center': true,
|
||||
'p-4 md:p-0': type !== 'MEDIA',
|
||||
})}
|
||||
style={{ minHeight: 'calc(100% - 3.5rem)' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalRoot;
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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 (
|
||||
<Card variant='rounded' size='xl'>
|
||||
<CardBody>
|
||||
<Stack space={2}>
|
||||
<Icon strokeWidth={1} src={require('@tabler/icons/affiliate.svg')} className='w-16 h-16 mx-auto text-primary-600 dark:text-primary-400' />
|
||||
|
||||
<Text size='2xl' weight='bold'>
|
||||
<FormattedMessage
|
||||
id='onboarding.fediverse.title'
|
||||
defaultMessage='{siteTitle} is just one part of the Fediverse'
|
||||
values={{
|
||||
siteTitle,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Stack space={4}>
|
||||
<div className='border-b border-gray-200 dark:border-gray-800 border-solid pb-2 sm:pb-5'>
|
||||
<Stack space={4}>
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage
|
||||
id='onboarding.fediverse.message'
|
||||
defaultMessage='The Fediverse is a social network made up of thousands of diverse and independently-run social media sites (aka "servers"). You can follow users — and like, repost, and reply to posts — from most other Fediverse servers, because they can communicate with {siteTitle}.'
|
||||
values={{
|
||||
siteTitle,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage
|
||||
id='onboarding.fediverse.trailer'
|
||||
defaultMessage='Because it is distributed and anyone can run their own server, the Fediverse is resilient and open. If you choose to join another server or set up your own, you can interact with the same people and continue on the same social graph.'
|
||||
/>
|
||||
</Text>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<div className='bg-primary-50 dark:bg-gray-800 rounded-lg text-center p-4'>
|
||||
<Account account={account} />
|
||||
</div>
|
||||
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage
|
||||
id='onboarding.fediverse.its_you'
|
||||
defaultMessage='This is you! Other people can follow you from other servers by using your full @-handle.'
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage
|
||||
id='onboarding.fediverse.other_instances'
|
||||
defaultMessage='When browsing your timeline, pay attention to the full username after the second @ symbol to know which server a post is from.'
|
||||
/>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<div className='pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
|
||||
<Stack justifyContent='center' space={2}>
|
||||
<Button
|
||||
block
|
||||
theme='primary'
|
||||
onClick={onNext}
|
||||
>
|
||||
<FormattedMessage id='onboarding.fediverse.next' defaultMessage='Next' />
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default FediverseStep;
|
@ -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 = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
|
||||
} else {
|
||||
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role='menuitem'
|
||||
className={classNames('video-player', { 'video-player--inactive': !revealed, detailed, 'video-player--inline': inline && !fullscreen, fullscreen })}
|
||||
style={playerStyle}
|
||||
ref={this.setPlayerRef}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
onClick={this.handleClickRoot}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Blurhash
|
||||
hash={blurhash}
|
||||
className={classNames('media-gallery__preview', {
|
||||
'media-gallery__preview--hidden': revealed,
|
||||
})}
|
||||
/>
|
||||
|
||||
{revealed && <video
|
||||
ref={this.setVideoRef}
|
||||
src={src}
|
||||
// preload={this.getPreload()}
|
||||
loop
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
aria-label={alt}
|
||||
title={alt}
|
||||
width={width}
|
||||
height={height || DEFAULT_HEIGHT}
|
||||
volume={volume}
|
||||
onClick={this.togglePlay}
|
||||
onKeyDown={this.handleVideoKeyDown}
|
||||
onPlay={this.handlePlay}
|
||||
onPause={this.handlePause}
|
||||
onTimeUpdate={this.handleTimeUpdate}
|
||||
onLoadedData={this.handleLoadedData}
|
||||
onProgress={this.handleProgress}
|
||||
onVolumeChange={this.handleVolumeChange}
|
||||
/>}
|
||||
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--hidden': !sensitive || revealed })}>
|
||||
<button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
|
||||
<span className='spoiler-button__overlay__label'>{warning}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={classNames('video-player__controls', { active: paused || hovered })}>
|
||||
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
|
||||
<div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
|
||||
<div className='video-player__seek__progress' style={{ width: `${progress}%` }} />
|
||||
|
||||
<span
|
||||
className={classNames('video-player__seek__handle', { active: dragging })}
|
||||
tabIndex='0'
|
||||
style={{ left: `${progress}%` }}
|
||||
onKeyDown={this.handleVideoKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='video-player__buttons-bar'>
|
||||
<div className='video-player__buttons left'>
|
||||
<button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} className='player-button' onClick={this.togglePlay} autoFocus={detailed}><Icon src={paused ? require('@tabler/icons/player-play.svg') : require('@tabler/icons/player-pause.svg')} /></button>
|
||||
<button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon src={muted ? require('@tabler/icons/volume-3.svg') : require('@tabler/icons/volume.svg')} /></button>
|
||||
|
||||
<div className={classNames('video-player__volume', { active: this.state.hovered })} onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
|
||||
<div className='video-player__volume__current' style={{ width: `${volume * 100}%` }} />
|
||||
<span
|
||||
className={classNames('video-player__volume__handle')}
|
||||
tabIndex='0'
|
||||
style={{ left: `${volume * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(detailed || fullscreen) && (
|
||||
<span>
|
||||
<span className='video-player__time-current'>{formatTime(currentTime)}</span>
|
||||
<span className='video-player__time-sep'>/</span>
|
||||
<span className='video-player__time-total'>{formatTime(duration)}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{link && <span className='video-player__link'>{link}</span>}
|
||||
</div>
|
||||
|
||||
<div className='video-player__buttons right'>
|
||||
{(sensitive && !onCloseVideo) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon src={require('@tabler/icons/eye-off.svg')} /></button>}
|
||||
<button type='button' title={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} className='player-button' onClick={this.toggleFullscreen}><Icon src={fullscreen ? require('@tabler/icons/arrows-minimize.svg') : require('@tabler/icons/arrows-maximize.svg')} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,636 @@
|
||||
import classNames from 'clsx';
|
||||
import debounce from 'lodash/debounce';
|
||||
import throttle from 'lodash/throttle';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import Blurhash from 'soapbox/components/blurhash';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { useSettings } from 'soapbox/hooks';
|
||||
import { isPanoramic, isPortrait, minimumAspectRatio, maximumAspectRatio } from 'soapbox/utils/media_aspect_ratio';
|
||||
|
||||
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
|
||||
|
||||
import type { Attachment } from 'soapbox/types/entities';
|
||||
|
||||
const DEFAULT_HEIGHT = 300;
|
||||
|
||||
type Position = { x: number, y: number };
|
||||
|
||||
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: number): string => {
|
||||
let hours: number | string = Math.floor(secondsNum / 3600);
|
||||
let minutes: number | string = Math.floor((secondsNum - (hours * 3600)) / 60);
|
||||
let seconds: number | string = 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: HTMLElement) => {
|
||||
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: HTMLElement, event: MouseEvent & TouchEvent): 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;
|
||||
}
|
||||
|
||||
return {
|
||||
y: Math.max(0, Math.min(1, (pageY - boxY) / boxH)),
|
||||
x: Math.max(0, Math.min(1, (pageX - boxX) / boxW)),
|
||||
};
|
||||
};
|
||||
|
||||
export const fileNameFromURL = (str: string) => {
|
||||
const url = new URL(str);
|
||||
const pathname = url.pathname;
|
||||
const index = pathname.lastIndexOf('/');
|
||||
|
||||
return pathname.substring(index + 1);
|
||||
};
|
||||
|
||||
interface IVideo {
|
||||
preview?: string,
|
||||
src: string,
|
||||
alt?: string,
|
||||
width?: number,
|
||||
height?: number,
|
||||
sensitive?: boolean,
|
||||
startTime?: number,
|
||||
onOpenVideo?: (attachment: Attachment, time: number) => void,
|
||||
onCloseVideo?: () => void,
|
||||
detailed?: boolean,
|
||||
inline?: boolean,
|
||||
cacheWidth?: (width: number) => void,
|
||||
visible?: boolean,
|
||||
onToggleVisibility?: () => void,
|
||||
blurhash?: string,
|
||||
link?: React.ReactNode,
|
||||
aspectRatio?: number,
|
||||
displayMedia?: string,
|
||||
}
|
||||
|
||||
const Video: React.FC<IVideo> = ({
|
||||
width,
|
||||
visible = false,
|
||||
sensitive = false,
|
||||
detailed = false,
|
||||
cacheWidth,
|
||||
onToggleVisibility,
|
||||
startTime,
|
||||
src,
|
||||
height,
|
||||
alt,
|
||||
onCloseVideo,
|
||||
inline,
|
||||
aspectRatio = 16 / 9,
|
||||
link,
|
||||
blurhash,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const displayMedia = settings.get('displayMedia') as string | undefined;
|
||||
|
||||
const player = useRef<HTMLDivElement>(null);
|
||||
const video = useRef<HTMLVideoElement>(null);
|
||||
const seek = useRef<HTMLDivElement>(null);
|
||||
const slider = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volume, setVolume] = useState(0.5);
|
||||
const [paused, setPaused] = useState(true);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [containerWidth, setContainerWidth] = useState(width);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [revealed, setRevealed] = useState<boolean>(visible !== undefined ? visible : (displayMedia !== 'hide_all' && !sensitive || displayMedia === 'show_all'));
|
||||
const [buffer, setBuffer] = useState(0);
|
||||
|
||||
const setDimensions = () => {
|
||||
if (player.current) {
|
||||
const { offsetWidth } = player.current;
|
||||
|
||||
if (cacheWidth) {
|
||||
cacheWidth(offsetWidth);
|
||||
}
|
||||
|
||||
setContainerWidth(offsetWidth);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setDimensions();
|
||||
}, [player.current]);
|
||||
|
||||
useEffect(() => {
|
||||
if (video.current) {
|
||||
setVolume(video.current.volume);
|
||||
setMuted(video.current.muted);
|
||||
}
|
||||
}, [video.current]);
|
||||
|
||||
const handleClickRoot: React.MouseEventHandler = e => e.stopPropagation();
|
||||
|
||||
const handlePlay = () => {
|
||||
setPaused(false);
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
setPaused(true);
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (video.current) {
|
||||
setCurrentTime(Math.floor(video.current.currentTime));
|
||||
setDuration(Math.floor(video.current.duration));
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeMouseDown: React.MouseEventHandler = e => {
|
||||
document.addEventListener('mousemove', handleMouseVolSlide, true);
|
||||
document.addEventListener('mouseup', handleVolumeMouseUp, true);
|
||||
document.addEventListener('touchmove', handleMouseVolSlide, true);
|
||||
document.addEventListener('touchend', handleVolumeMouseUp, true);
|
||||
|
||||
handleMouseVolSlide(e);
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleVolumeMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseVolSlide, true);
|
||||
document.removeEventListener('mouseup', handleVolumeMouseUp, true);
|
||||
document.removeEventListener('touchmove', handleMouseVolSlide, true);
|
||||
document.removeEventListener('touchend', handleVolumeMouseUp, true);
|
||||
};
|
||||
|
||||
const handleMouseVolSlide = throttle(e => {
|
||||
if (slider.current) {
|
||||
const { x } = getPointerPosition(slider.current, e);
|
||||
|
||||
if (!isNaN(x)) {
|
||||
let slideamt = x;
|
||||
|
||||
if (x > 1) {
|
||||
slideamt = 1;
|
||||
} else if (x < 0) {
|
||||
slideamt = 0;
|
||||
}
|
||||
|
||||
if (video.current) {
|
||||
video.current.volume = slideamt;
|
||||
}
|
||||
|
||||
setVolume(slideamt);
|
||||
}
|
||||
}
|
||||
}, 60);
|
||||
|
||||
const handleMouseDown: React.MouseEventHandler = e => {
|
||||
document.addEventListener('mousemove', handleMouseMove, true);
|
||||
document.addEventListener('mouseup', handleMouseUp, true);
|
||||
document.addEventListener('touchmove', handleMouseMove, true);
|
||||
document.addEventListener('touchend', handleMouseUp, true);
|
||||
|
||||
setDragging(true);
|
||||
video.current?.pause();
|
||||
handleMouseMove(e);
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove, true);
|
||||
document.removeEventListener('mouseup', handleMouseUp, true);
|
||||
document.removeEventListener('touchmove', handleMouseMove, true);
|
||||
document.removeEventListener('touchend', handleMouseUp, true);
|
||||
|
||||
setDragging(false);
|
||||
video.current?.play();
|
||||
};
|
||||
|
||||
const handleMouseMove = throttle(e => {
|
||||
if (seek.current && video.current) {
|
||||
const { x } = getPointerPosition(seek.current, e);
|
||||
const currentTime = Math.floor(video.current.duration * x);
|
||||
|
||||
if (!isNaN(currentTime)) {
|
||||
video.current.currentTime = currentTime;
|
||||
setCurrentTime(currentTime);
|
||||
}
|
||||
}
|
||||
}, 60);
|
||||
|
||||
const seekBy = (time: number) => {
|
||||
if (video.current) {
|
||||
const currentTime = video.current.currentTime + time;
|
||||
|
||||
if (!isNaN(currentTime)) {
|
||||
setCurrentTime(currentTime);
|
||||
video.current.currentTime = currentTime;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleVideoKeyDown: React.KeyboardEventHandler = 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();
|
||||
togglePlay();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler = e => {
|
||||
const frameTime = 1 / 25;
|
||||
|
||||
switch (e.key) {
|
||||
case 'k':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
togglePlay();
|
||||
break;
|
||||
case 'm':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleMute();
|
||||
break;
|
||||
case 'f':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleFullscreen();
|
||||
break;
|
||||
case 'j':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
seekBy(-10);
|
||||
break;
|
||||
case 'l':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
seekBy(10);
|
||||
break;
|
||||
case ',':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
seekBy(-frameTime);
|
||||
break;
|
||||
case '.':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
seekBy(frameTime);
|
||||
break;
|
||||
}
|
||||
|
||||
// If we are in fullscreen mode, we don't want any hotkeys
|
||||
// interacting with the UI that's not visible
|
||||
|
||||
if (fullscreen) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
exitFullscreen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlay = (e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
|
||||
setPaused(!paused);
|
||||
|
||||
if (paused) {
|
||||
video.current?.play();
|
||||
} else {
|
||||
video.current?.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (isFullscreen()) {
|
||||
exitFullscreen();
|
||||
} else if (player.current) {
|
||||
requestFullscreen(player.current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = useCallback(debounce(() => {
|
||||
setDimensions();
|
||||
}, 250, {
|
||||
trailing: true,
|
||||
}), [player.current, cacheWidth]);
|
||||
|
||||
const handleScroll = useCallback(throttle(() => {
|
||||
if (!video.current) return;
|
||||
|
||||
const { top, height } = video.current.getBoundingClientRect();
|
||||
const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
|
||||
|
||||
if (!paused && !inView) {
|
||||
setPaused(true);
|
||||
video.current.pause();
|
||||
}
|
||||
}, 150, { trailing: true }), [video.current, paused]);
|
||||
|
||||
const handleFullscreenChange = useCallback(() => {
|
||||
setFullscreen(isFullscreen());
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHovered(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHovered(false);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
if (video.current) {
|
||||
const muted = !video.current.muted;
|
||||
setMuted(!muted);
|
||||
video.current.muted = muted;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleReveal: React.MouseEventHandler = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (onToggleVisibility) {
|
||||
onToggleVisibility();
|
||||
} else {
|
||||
setRevealed(!revealed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadedData = () => {
|
||||
if (video.current && startTime) {
|
||||
video.current.currentTime = startTime;
|
||||
video.current.play();
|
||||
}
|
||||
};
|
||||
|
||||
const handleProgress = () => {
|
||||
if (video.current && video.current.buffered.length > 0) {
|
||||
setBuffer(video.current.buffered.end(0) / video.current.duration * 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeChange = () => {
|
||||
if (video.current) {
|
||||
setVolume(video.current.volume);
|
||||
setMuted(video.current.muted);
|
||||
}
|
||||
};
|
||||
|
||||
const progress = (currentTime / duration) * 100;
|
||||
const playerStyle: React.CSSProperties = {};
|
||||
|
||||
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 = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
|
||||
} else {
|
||||
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange, true);
|
||||
document.addEventListener('webkitfullscreenchange', handleFullscreenChange, true);
|
||||
document.addEventListener('mozfullscreenchange', handleFullscreenChange, true);
|
||||
document.addEventListener('MSFullscreenChange', handleFullscreenChange, true);
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
window.addEventListener('resize', handleResize, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange, true);
|
||||
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange, true);
|
||||
document.removeEventListener('mozfullscreenchange', handleFullscreenChange, true);
|
||||
document.removeEventListener('MSFullscreenChange', handleFullscreenChange, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setRevealed(true);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!revealed) {
|
||||
video.current?.pause();
|
||||
}
|
||||
}, [revealed]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role='menuitem'
|
||||
className={classNames('video-player', { 'video-player--inactive': !revealed, detailed, 'video-player--inline': inline && !fullscreen, fullscreen })}
|
||||
style={playerStyle}
|
||||
ref={player}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleClickRoot}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Blurhash
|
||||
hash={blurhash}
|
||||
className={classNames('media-gallery__preview', {
|
||||
'media-gallery__preview--hidden': revealed,
|
||||
})}
|
||||
/>
|
||||
|
||||
{revealed && (
|
||||
<video
|
||||
ref={video}
|
||||
src={src}
|
||||
loop
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
aria-label={alt}
|
||||
title={alt}
|
||||
width={width}
|
||||
height={height || DEFAULT_HEIGHT}
|
||||
onClick={togglePlay}
|
||||
onKeyDown={handleVideoKeyDown}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedData={handleLoadedData}
|
||||
onProgress={handleProgress}
|
||||
onVolumeChange={handleVolumeChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--hidden': !sensitive || revealed })}>
|
||||
<button type='button' className='spoiler-button__overlay' onClick={toggleReveal}>
|
||||
<span className='spoiler-button__overlay__label'>{warning}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={classNames('video-player__controls', { active: paused || hovered })}>
|
||||
<div className='video-player__seek' onMouseDown={handleMouseDown} ref={seek}>
|
||||
<div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
|
||||
<div className='video-player__seek__progress' style={{ width: `${progress}%` }} />
|
||||
|
||||
<span
|
||||
className={classNames('video-player__seek__handle', { active: dragging })}
|
||||
tabIndex={0}
|
||||
style={{ left: `${progress}%` }}
|
||||
onKeyDown={handleVideoKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='video-player__buttons-bar'>
|
||||
<div className='video-player__buttons left'>
|
||||
<button
|
||||
type='button'
|
||||
title={intl.formatMessage(paused ? messages.play : messages.pause)}
|
||||
aria-label={intl.formatMessage(paused ? messages.play : messages.pause)}
|
||||
className='player-button'
|
||||
onClick={togglePlay}
|
||||
autoFocus={detailed}
|
||||
>
|
||||
<Icon src={paused ? require('@tabler/icons/player-play.svg') : require('@tabler/icons/player-pause.svg')} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type='button'
|
||||
title={intl.formatMessage(muted ? messages.unmute : messages.mute)}
|
||||
aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)}
|
||||
className='player-button'
|
||||
onClick={toggleMute}
|
||||
>
|
||||
<Icon src={muted ? require('@tabler/icons/volume-3.svg') : require('@tabler/icons/volume.svg')} />
|
||||
</button>
|
||||
|
||||
<div className={classNames('video-player__volume', { active: hovered })} onMouseDown={handleVolumeMouseDown} ref={slider}>
|
||||
<div className='video-player__volume__current' style={{ width: `${volume * 100}%` }} />
|
||||
<span
|
||||
className={classNames('video-player__volume__handle')}
|
||||
tabIndex={0}
|
||||
style={{ left: `${volume * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(detailed || fullscreen) && (
|
||||
<span>
|
||||
<span className='video-player__time-current'>{formatTime(currentTime)}</span>
|
||||
<span className='video-player__time-sep'>/</span>
|
||||
<span className='video-player__time-total'>{formatTime(duration)}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{link && (
|
||||
<span className='video-player__link'>{link}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='video-player__buttons right'>
|
||||
{(sensitive && !onCloseVideo) && (
|
||||
<button
|
||||
type='button'
|
||||
title={intl.formatMessage(messages.hide)}
|
||||
aria-label={intl.formatMessage(messages.hide)}
|
||||
className='player-button'
|
||||
onClick={toggleReveal}
|
||||
>
|
||||
<Icon src={require('@tabler/icons/eye-off.svg')} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type='button'
|
||||
title={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)}
|
||||
aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)}
|
||||
className='player-button'
|
||||
onClick={toggleFullscreen}
|
||||
>
|
||||
<Icon src={fullscreen ? require('@tabler/icons/arrows-minimize.svg') : require('@tabler/icons/arrows-maximize.svg')} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Video;
|
Loading…
Reference in new issue