port d88a79b456
to glitch-soc
Signed-off-by: Thibaut Girka <thib@sitedethib.com>
closed-social-glitch-2
@ -0,0 +1,38 @@ | |||
// @ts-check | |||
export const PICTURE_IN_PICTURE_DEPLOY = 'PICTURE_IN_PICTURE_DEPLOY'; | |||
export const PICTURE_IN_PICTURE_REMOVE = 'PICTURE_IN_PICTURE_REMOVE'; | |||
/** | |||
* @typedef MediaProps | |||
* @property {string} src | |||
* @property {boolean} muted | |||
* @property {number} volume | |||
* @property {number} currentTime | |||
* @property {string} poster | |||
* @property {string} backgroundColor | |||
* @property {string} foregroundColor | |||
* @property {string} accentColor | |||
*/ | |||
/** | |||
* @param {string} statusId | |||
* @param {string} accountId | |||
* @param {string} playerType | |||
* @param {MediaProps} props | |||
* @return {object} | |||
*/ | |||
export const deployPictureInPicture = (statusId, accountId, playerType, props) => ({ | |||
type: PICTURE_IN_PICTURE_DEPLOY, | |||
statusId, | |||
accountId, | |||
playerType, | |||
props, | |||
}); | |||
/* | |||
* @return {object} | |||
*/ | |||
export const removePictureInPicture = () => ({ | |||
type: PICTURE_IN_PICTURE_REMOVE, | |||
}); |
@ -0,0 +1,69 @@ | |||
import React from 'react'; | |||
import PropTypes from 'prop-types'; | |||
import Icon from 'flavours/glitch/components/icon'; | |||
import { removePictureInPicture } from 'flavours/glitch/actions/picture_in_picture'; | |||
import { connect } from 'react-redux'; | |||
import { debounce } from 'lodash'; | |||
import { FormattedMessage } from 'react-intl'; | |||
export default @connect() | |||
class PictureInPicturePlaceholder extends React.PureComponent { | |||
static propTypes = { | |||
width: PropTypes.number, | |||
dispatch: PropTypes.func.isRequired, | |||
}; | |||
state = { | |||
width: this.props.width, | |||
height: this.props.width && (this.props.width / (16/9)), | |||
}; | |||
handleClick = () => { | |||
const { dispatch } = this.props; | |||
dispatch(removePictureInPicture()); | |||
} | |||
setRef = c => { | |||
this.node = c; | |||
if (this.node) { | |||
this._setDimensions(); | |||
} | |||
} | |||
_setDimensions () { | |||
const width = this.node.offsetWidth; | |||
const height = width / (16/9); | |||
this.setState({ width, height }); | |||
} | |||
componentDidMount () { | |||
window.addEventListener('resize', this.handleResize, { passive: true }); | |||
} | |||
componentWillUnmount () { | |||
window.removeEventListener('resize', this.handleResize); | |||
} | |||
handleResize = debounce(() => { | |||
if (this.node) { | |||
this._setDimensions(); | |||
} | |||
}, 250, { | |||
trailing: true, | |||
}); | |||
render () { | |||
const { height } = this.state; | |||
return ( | |||
<div ref={this.setRef} className='picture-in-picture-placeholder' style={{ height }} role='button' tabIndex='0' onClick={this.handleClick}> | |||
<Icon id='window-restore' /> | |||
<FormattedMessage id='picture_in_picture.restore' defaultMessage='Put it back' /> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,137 @@ | |||
import React from 'react'; | |||
import { connect } from 'react-redux'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import PropTypes from 'prop-types'; | |||
import IconButton from 'flavours/glitch/components/icon_button'; | |||
import classNames from 'classnames'; | |||
import { me, boostModal } from 'flavours/glitch/util/initial_state'; | |||
import { defineMessages, injectIntl } from 'react-intl'; | |||
import { replyCompose } from 'flavours/glitch/actions/compose'; | |||
import { reblog, favourite, unreblog, unfavourite } from 'flavours/glitch/actions/interactions'; | |||
import { makeGetStatus } from 'flavours/glitch/selectors'; | |||
import { openModal } from 'flavours/glitch/actions/modal'; | |||
const messages = defineMessages({ | |||
reply: { id: 'status.reply', defaultMessage: 'Reply' }, | |||
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, | |||
reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, | |||
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' }, | |||
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, | |||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, | |||
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, | |||
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, | |||
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, | |||
}); | |||
const makeMapStateToProps = () => { | |||
const getStatus = makeGetStatus(); | |||
const mapStateToProps = (state, { statusId }) => ({ | |||
status: getStatus(state, { id: statusId }), | |||
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0, | |||
}); | |||
return mapStateToProps; | |||
}; | |||
export default @connect(makeMapStateToProps) | |||
@injectIntl | |||
class Footer extends ImmutablePureComponent { | |||
static contextTypes = { | |||
router: PropTypes.object, | |||
}; | |||
static propTypes = { | |||
statusId: PropTypes.string.isRequired, | |||
status: ImmutablePropTypes.map.isRequired, | |||
intl: PropTypes.object.isRequired, | |||
dispatch: PropTypes.func.isRequired, | |||
askReplyConfirmation: PropTypes.bool, | |||
}; | |||
_performReply = () => { | |||
const { dispatch, status } = this.props; | |||
dispatch(replyCompose(status, this.context.router.history)); | |||
}; | |||
handleReplyClick = () => { | |||
const { dispatch, askReplyConfirmation, intl } = this.props; | |||
if (askReplyConfirmation) { | |||
dispatch(openModal('CONFIRM', { | |||
message: intl.formatMessage(messages.replyMessage), | |||
confirm: intl.formatMessage(messages.replyConfirm), | |||
onConfirm: this._performReply, | |||
})); | |||
} else { | |||
this._performReply(); | |||
} | |||
}; | |||
handleFavouriteClick = () => { | |||
const { dispatch, status } = this.props; | |||
if (status.get('favourited')) { | |||
dispatch(unfavourite(status)); | |||
} else { | |||
dispatch(favourite(status)); | |||
} | |||
}; | |||
_performReblog = () => { | |||
const { dispatch, status } = this.props; | |||
dispatch(reblog(status)); | |||
} | |||
handleReblogClick = e => { | |||
const { dispatch, status } = this.props; | |||
if (status.get('reblogged')) { | |||
dispatch(unreblog(status)); | |||
} else if ((e && e.shiftKey) || !boostModal) { | |||
this._performReblog(); | |||
} else { | |||
dispatch(openModal('BOOST', { status, onReblog: this._performReblog })); | |||
} | |||
}; | |||
render () { | |||
const { status, intl } = this.props; | |||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); | |||
const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private'; | |||
let replyIcon, replyTitle; | |||
if (status.get('in_reply_to_id', null) === null) { | |||
replyIcon = 'reply'; | |||
replyTitle = intl.formatMessage(messages.reply); | |||
} else { | |||
replyIcon = 'reply-all'; | |||
replyTitle = intl.formatMessage(messages.replyAll); | |||
} | |||
let reblogTitle = ''; | |||
if (status.get('reblogged')) { | |||
reblogTitle = intl.formatMessage(messages.cancel_reblog_private); | |||
} else if (publicStatus) { | |||
reblogTitle = intl.formatMessage(messages.reblog); | |||
} else if (reblogPrivate) { | |||
reblogTitle = intl.formatMessage(messages.reblog_private); | |||
} else { | |||
reblogTitle = intl.formatMessage(messages.cannot_reblog); | |||
} | |||
return ( | |||
<div className='picture-in-picture__footer'> | |||
<IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount /> | |||
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} /> | |||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} /> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,40 @@ | |||
import React from 'react'; | |||
import { connect } from 'react-redux'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import PropTypes from 'prop-types'; | |||
import IconButton from 'flavours/glitch/components/icon_button'; | |||
import { Link } from 'react-router-dom'; | |||
import Avatar from 'flavours/glitch/components/avatar'; | |||
import DisplayName from 'flavours/glitch/components/display_name'; | |||
const mapStateToProps = (state, { accountId }) => ({ | |||
account: state.getIn(['accounts', accountId]), | |||
}); | |||
export default @connect(mapStateToProps) | |||
class Header extends ImmutablePureComponent { | |||
static propTypes = { | |||
accountId: PropTypes.string.isRequired, | |||
statusId: PropTypes.string.isRequired, | |||
account: ImmutablePropTypes.map.isRequired, | |||
onClose: PropTypes.func.isRequired, | |||
}; | |||
render () { | |||
const { account, statusId, onClose } = this.props; | |||
return ( | |||
<div className='picture-in-picture__header'> | |||
<Link to={`/statuses/${statusId}`} className='picture-in-picture__header__account'> | |||
<Avatar account={account} size={36} /> | |||
<DisplayName account={account} /> | |||
</Link> | |||
<IconButton icon='times' onClick={onClose} title='Close' /> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,85 @@ | |||
import React from 'react'; | |||
import { connect } from 'react-redux'; | |||
import PropTypes from 'prop-types'; | |||
import Video from 'flavours/glitch/features/video'; | |||
import Audio from 'flavours/glitch/features/audio'; | |||
import { removePictureInPicture } from 'flavours/glitch/actions/picture_in_picture'; | |||
import Header from './components/header'; | |||
import Footer from './components/footer'; | |||
const mapStateToProps = state => ({ | |||
...state.get('picture_in_picture'), | |||
}); | |||
export default @connect(mapStateToProps) | |||
class PictureInPicture extends React.Component { | |||
static propTypes = { | |||
statusId: PropTypes.string, | |||
accountId: PropTypes.string, | |||
type: PropTypes.string, | |||
src: PropTypes.string, | |||
muted: PropTypes.bool, | |||
volume: PropTypes.number, | |||
currentTime: PropTypes.number, | |||
poster: PropTypes.string, | |||
backgroundColor: PropTypes.string, | |||
foregroundColor: PropTypes.string, | |||
accentColor: PropTypes.string, | |||
dispatch: PropTypes.func.isRequired, | |||
}; | |||
handleClose = () => { | |||
const { dispatch } = this.props; | |||
dispatch(removePictureInPicture()); | |||
} | |||
render () { | |||
const { type, src, currentTime, accountId, statusId } = this.props; | |||
if (!currentTime) { | |||
return null; | |||
} | |||
let player; | |||
if (type === 'video') { | |||
player = ( | |||
<Video | |||
src={src} | |||
currentTime={this.props.currentTime} | |||
volume={this.props.volume} | |||
muted={this.props.muted} | |||
autoPlay | |||
inline | |||
alwaysVisible | |||
/> | |||
); | |||
} else if (type === 'audio') { | |||
player = ( | |||
<Audio | |||
src={src} | |||
currentTime={this.props.currentTime} | |||
volume={this.props.volume} | |||
muted={this.props.muted} | |||
poster={this.props.poster} | |||
backgroundColor={this.props.backgroundColor} | |||
foregroundColor={this.props.foregroundColor} | |||
accentColor={this.props.accentColor} | |||
autoPlay | |||
/> | |||
); | |||
} | |||
return ( | |||
<div className='picture-in-picture'> | |||
<Header accountId={accountId} statusId={statusId} onClose={this.handleClose} /> | |||
{player} | |||
<Footer statusId={statusId} /> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,22 @@ | |||
import { PICTURE_IN_PICTURE_DEPLOY, PICTURE_IN_PICTURE_REMOVE } from 'flavours/glitch/actions/picture_in_picture'; | |||
const initialState = { | |||
statusId: null, | |||
accountId: null, | |||
type: null, | |||
src: null, | |||
muted: false, | |||
volume: 0, | |||
currentTime: 0, | |||
}; | |||
export default function pictureInPicture(state = initialState, action) { | |||
switch(action.type) { | |||
case PICTURE_IN_PICTURE_DEPLOY: | |||
return { statusId: action.statusId, accountId: action.accountId, type: action.playerType, ...action.props }; | |||
case PICTURE_IN_PICTURE_REMOVE: | |||
return { ...initialState }; | |||
default: | |||
return state; | |||
} | |||
}; |