Merge remote-tracking branch 'soapbox/develop' into events-

This commit is contained in:
marcin mikołajczak
2022-10-06 00:01:39 +02:00
23 changed files with 1748 additions and 1752 deletions

View File

@ -8,6 +8,8 @@ import RegistrationForm from 'soapbox/features/auth_login/components/registratio
import { useAppDispatch, useAppSelector, useFeatures, useSoapboxConfig } from 'soapbox/hooks';
import { capitalize } from 'soapbox/utils/strings';
import './instance-description.css';
const LandingPage = () => {
const dispatch = useAppDispatch();
const features = useFeatures();

View File

@ -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;
}

View File

@ -6,16 +6,19 @@ import ReactSwipeableViews from 'react-swipeable-views';
import { endOnboarding } from 'soapbox/actions/onboarding';
import LandingGradient from 'soapbox/components/landing-gradient';
import { HStack } from 'soapbox/components/ui';
import { useFeatures } from 'soapbox/hooks';
import AvatarSelectionStep from './steps/avatar-selection-step';
import BioStep from './steps/bio-step';
import CompletedStep from './steps/completed-step';
import CoverPhotoSelectionStep from './steps/cover-photo-selection-step';
import DisplayNameStep from './steps/display-name-step';
import FediverseStep from './steps/fediverse-step';
import SuggestedAccountsStep from './steps/suggested-accounts-step';
const OnboardingWizard = () => {
const dispatch = useDispatch();
const features = useFeatures();
const [currentStep, setCurrentStep] = React.useState<number>(0);
@ -41,9 +44,14 @@ const OnboardingWizard = () => {
<BioStep onNext={handleNextStep} />,
<CoverPhotoSelectionStep onNext={handleNextStep} />,
<SuggestedAccountsStep onNext={handleNextStep} />,
<CompletedStep onComplete={handleComplete} />,
];
if (features.federating){
steps.push(<FediverseStep onNext={handleNextStep} />);
}
steps.push(<CompletedStep onComplete={handleComplete} />);
const handleKeyUp = ({ key }: KeyboardEvent): void => {
switch (key) {
case 'ArrowLeft':

View File

@ -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;

View File

@ -27,10 +27,8 @@ const ComposeModal: React.FC<IComposeModal> = ({ onClose }) => {
const { id: statusId, privacy, in_reply_to: inReplyTo, quote } = compose!;
const hasComposeContent = checkComposeContent(compose);
const onClickClose = () => {
if (hasComposeContent) {
if (checkComposeContent(compose)) {
dispatch(openModal('CONFIRM', {
icon: require('@tabler/icons/trash.svg'),
heading: statusId

View File

@ -1,31 +1,43 @@
// APIs for normalizing fullscreen operations. Note that Edge uses
// the WebKit-prefixed APIs currently (as of Edge 16).
export const isFullscreen = () => document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement;
export const isFullscreen = (): boolean => {
return Boolean(
document.fullscreenElement ||
// @ts-ignore
document.webkitFullscreenElement ||
// @ts-ignore
document.mozFullScreenElement,
);
};
export const exitFullscreen = () => {
export const exitFullscreen = (): void => {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
} else if ('webkitExitFullscreen' in document) {
// @ts-ignore
document.webkitExitFullscreen();
} else if (document.mozCancelFullScreen) {
} else if ('mozCancelFullScreen' in document) {
// @ts-ignore
document.mozCancelFullScreen();
}
};
export const requestFullscreen = el => {
export const requestFullscreen = (el: Element): void => {
if (el.requestFullscreen) {
el.requestFullscreen();
} else if (el.webkitRequestFullscreen) {
} else if ('webkitRequestFullscreen' in el) {
// @ts-ignore
el.webkitRequestFullscreen();
} else if (el.mozRequestFullScreen) {
} else if ('mozRequestFullScreen' in el) {
// @ts-ignore
el.mozRequestFullScreen();
}
};
export const attachFullscreenListener = (listener) => {
type FullscreenListener = (this: Document, ev: Event) => void;
export const attachFullscreenListener = (listener: FullscreenListener): void => {
if ('onfullscreenchange' in document) {
document.addEventListener('fullscreenchange', listener);
} else if ('onwebkitfullscreenchange' in document) {
@ -35,7 +47,7 @@ export const attachFullscreenListener = (listener) => {
}
};
export const detachFullscreenListener = (listener) => {
export const detachFullscreenListener = (listener: FullscreenListener): void => {
if ('onfullscreenchange' in document) {
document.removeEventListener('fullscreenchange', listener);
} else if ('onwebkitfullscreenchange' in document) {

View File

@ -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>
);
}
}

View File

@ -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;