diff options
Diffstat (limited to 'app/javascript/flavours/glitch/features/ui')
57 files changed, 6633 insertions, 0 deletions
diff --git a/app/javascript/flavours/glitch/features/ui/components/actions_modal.js b/app/javascript/flavours/glitch/features/ui/components/actions_modal.js new file mode 100644 index 000000000..c6e3ee37c --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/actions_modal.js @@ -0,0 +1,91 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import StatusContent from 'flavours/glitch/components/status_content'; +import Avatar from 'flavours/glitch/components/avatar'; +import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp'; +import DisplayName from 'flavours/glitch/components/display_name'; +import classNames from 'classnames'; +import IconButton from 'flavours/glitch/components/icon_button'; + +export default class ActionsModal extends ImmutablePureComponent { + + static propTypes = { + status: ImmutablePropTypes.map, + onClick: PropTypes.func, + actions: PropTypes.arrayOf(PropTypes.shape({ + active: PropTypes.bool, + href: PropTypes.string, + icon: PropTypes.string, + meta: PropTypes.string, + name: PropTypes.string, + text: PropTypes.string, + })), + renderItemContents: PropTypes.func, + }; + + renderAction = (action, i) => { + if (action === null) { + return <li key={`sep-${i}`} className='dropdown-menu__separator' />; + } + + const { icon = null, text, meta = null, active = false, href = '#' } = action; + let contents = this.props.renderItemContents && this.props.renderItemContents(action, i); + + if (!contents) { + contents = ( + <React.Fragment> + {icon && <IconButton title={text} icon={icon} role='presentation' tabIndex='-1' inverted />} + <div> + <div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div> + <div>{meta}</div> + </div> + </React.Fragment> + ); + } + + return ( + <li key={`${text}-${i}`}> + <a href={href} target='_blank' rel='noopener noreferrer' onClick={this.props.onClick} data-index={i} className={classNames('link', { active })}> + {contents} + </a> + </li> + ); + }; + + render () { + const status = this.props.status && ( + <div className='status light'> + <div className='boost-modal__status-header'> + <div className='boost-modal__status-time'> + <a href={this.props.status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'> + <RelativeTimestamp timestamp={this.props.status.get('created_at')} /> + </a> + </div> + + <a href={this.props.status.getIn(['account', 'url'])} className='status__display-name' rel='noopener noreferrer'> + <div className='status__avatar'> + <Avatar account={this.props.status.get('account')} size={48} /> + </div> + + <DisplayName account={this.props.status.get('account')} /> + </a> + </div> + + <StatusContent status={this.props.status} /> + </div> + ); + + return ( + <div className='modal-root__modal actions-modal'> + {status} + + <ul className={classNames({ 'with-status': !!status })}> + {this.props.actions.map(this.renderAction)} + </ul> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/audio_modal.js b/app/javascript/flavours/glitch/features/ui/components/audio_modal.js new file mode 100644 index 000000000..fc98cc6af --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/audio_modal.js @@ -0,0 +1,58 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Audio from 'flavours/glitch/features/audio'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Footer from 'flavours/glitch/features/picture_in_picture/components/footer'; + +const mapStateToProps = (state, { statusId }) => ({ + accountStaticAvatar: state.getIn(['accounts', state.getIn(['statuses', statusId, 'account']), 'avatar_static']), +}); + +export default @connect(mapStateToProps) +class AudioModal extends ImmutablePureComponent { + + static propTypes = { + media: ImmutablePropTypes.map.isRequired, + statusId: PropTypes.string.isRequired, + accountStaticAvatar: PropTypes.string.isRequired, + options: PropTypes.shape({ + autoPlay: PropTypes.bool, + }), + onClose: PropTypes.func.isRequired, + onChangeBackgroundColor: PropTypes.func.isRequired, + }; + + static contextTypes = { + router: PropTypes.object, + }; + + render () { + const { media, accountStaticAvatar, statusId, onClose } = this.props; + const options = this.props.options || {}; + + return ( + <div className='modal-root__modal audio-modal'> + <div className='audio-modal__container'> + <Audio + src={media.get('url')} + alt={media.get('description')} + duration={media.getIn(['meta', 'original', 'duration'], 0)} + height={150} + poster={media.get('preview_url') || accountStaticAvatar} + backgroundColor={media.getIn(['meta', 'colors', 'background'])} + foregroundColor={media.getIn(['meta', 'colors', 'foreground'])} + accentColor={media.getIn(['meta', 'colors', 'accent'])} + autoPlay={options.autoPlay} + /> + </div> + + <div className='media-modal__overlay'> + {statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />} + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/block_modal.js b/app/javascript/flavours/glitch/features/ui/components/block_modal.js new file mode 100644 index 000000000..6c9d2043c --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/block_modal.js @@ -0,0 +1,103 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import { makeGetAccount } from '../../../selectors'; +import Button from '../../../components/button'; +import { closeModal } from '../../../actions/modal'; +import { blockAccount } from '../../../actions/accounts'; +import { initReport } from '../../../actions/reports'; + + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = state => ({ + account: getAccount(state, state.getIn(['blocks', 'new', 'account_id'])), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = dispatch => { + return { + onConfirm(account) { + dispatch(blockAccount(account.get('id'))); + }, + + onBlockAndReport(account) { + dispatch(blockAccount(account.get('id'))); + dispatch(initReport(account)); + }, + + onClose() { + dispatch(closeModal()); + }, + }; +}; + +export default @connect(makeMapStateToProps, mapDispatchToProps) +@injectIntl +class BlockModal extends React.PureComponent { + + static propTypes = { + account: PropTypes.object.isRequired, + onClose: PropTypes.func.isRequired, + onBlockAndReport: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + componentDidMount() { + this.button.focus(); + } + + handleClick = () => { + this.props.onClose(); + this.props.onConfirm(this.props.account); + }; + + handleSecondary = () => { + this.props.onClose(); + this.props.onBlockAndReport(this.props.account); + }; + + handleCancel = () => { + this.props.onClose(); + }; + + setRef = (c) => { + this.button = c; + }; + + render () { + const { account } = this.props; + + return ( + <div className='modal-root__modal block-modal'> + <div className='block-modal__container'> + <p> + <FormattedMessage + id='confirmations.block.message' + defaultMessage='Are you sure you want to block {name}?' + values={{ name: <strong>@{account.get('acct')}</strong> }} + /> + </p> + </div> + + <div className='block-modal__action-bar'> + <Button onClick={this.handleCancel} className='block-modal__cancel-button'> + <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' /> + </Button> + <Button onClick={this.handleSecondary} className='confirmation-modal__secondary-button'> + <FormattedMessage id='confirmations.block.block_and_report' defaultMessage='Block & Report' /> + </Button> + <Button onClick={this.handleClick} ref={this.setRef}> + <FormattedMessage id='confirmations.block.confirm' defaultMessage='Block' /> + </Button> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/boost_modal.js b/app/javascript/flavours/glitch/features/ui/components/boost_modal.js new file mode 100644 index 000000000..a65b84e20 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/boost_modal.js @@ -0,0 +1,139 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Button from 'flavours/glitch/components/button'; +import StatusContent from 'flavours/glitch/components/status_content'; +import Avatar from 'flavours/glitch/components/avatar'; +import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp'; +import DisplayName from 'flavours/glitch/components/display_name'; +import AttachmentList from 'flavours/glitch/components/attachment_list'; +import Icon from 'flavours/glitch/components/icon'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import PrivacyDropdown from 'flavours/glitch/features/compose/components/privacy_dropdown'; +import classNames from 'classnames'; +import { changeBoostPrivacy } from 'flavours/glitch/actions/boosts'; +import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon'; + +const messages = defineMessages({ + cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, + reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, +}); + +const mapStateToProps = state => { + return { + privacy: state.getIn(['boosts', 'new', 'privacy']), + }; +}; + +const mapDispatchToProps = dispatch => { + return { + onChangeBoostPrivacy(value) { + dispatch(changeBoostPrivacy(value)); + }, + }; +}; + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +class BoostModal extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + onReblog: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + missingMediaDescription: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + componentDidMount() { + this.button.focus(); + } + + handleReblog = () => { + this.props.onReblog(this.props.status, this.props.privacy); + this.props.onClose(); + }; + + handleAccountClick = (e) => { + if (e.button === 0) { + e.preventDefault(); + this.props.onClose(); + let state = { ...this.context.router.history.location.state }; + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`, state); + } + }; + + _findContainer = () => { + return document.getElementsByClassName('modal-root__container')[0]; + }; + + setRef = (c) => { + this.button = c; + }; + + render () { + const { status, missingMediaDescription, privacy, intl } = this.props; + const buttonText = status.get('reblogged') ? messages.cancel_reblog : messages.reblog; + + return ( + <div className='modal-root__modal boost-modal'> + <div className='boost-modal__container'> + <div className={classNames('status', `status-${status.get('visibility')}`, 'light')}> + <div className='boost-modal__status-header'> + <div className='boost-modal__status-time'> + <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'> + <VisibilityIcon visibility={status.get('visibility')} /> + <RelativeTimestamp timestamp={status.get('created_at')} /></a> + </div> + + <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'> + <div className='status__avatar'> + <Avatar account={status.get('account')} size={48} /> + </div> + + <DisplayName account={status.get('account')} /> + </a> + </div> + + <StatusContent status={status} /> + + {status.get('media_attachments').size > 0 && ( + <AttachmentList + compact + media={status.get('media_attachments')} + /> + )} + </div> + </div> + + <div className='boost-modal__action-bar'> + <div> + { missingMediaDescription ? + <FormattedMessage id='boost_modal.missing_description' defaultMessage='This toot contains some media without description' /> + : + <FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon id='retweet' /></span> }} /> + } + </div> + + {status.get('visibility') !== 'private' && !status.get('reblogged') && ( + <PrivacyDropdown + noDirect + value={privacy} + container={this._findContainer} + onChange={this.props.onChangeBoostPrivacy} + /> + )} + <Button text={intl.formatMessage(buttonText)} onClick={this.handleReblog} ref={this.setRef} /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/bundle.js b/app/javascript/flavours/glitch/features/ui/components/bundle.js new file mode 100644 index 000000000..27b13ecfe --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/bundle.js @@ -0,0 +1,107 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const emptyComponent = () => null; +const noop = () => { }; + +class Bundle extends React.Component { + + static propTypes = { + fetchComponent: PropTypes.func.isRequired, + loading: PropTypes.func, + error: PropTypes.func, + children: PropTypes.func.isRequired, + renderDelay: PropTypes.number, + onFetch: PropTypes.func, + onFetchSuccess: PropTypes.func, + onFetchFail: PropTypes.func, + }; + + static defaultProps = { + loading: emptyComponent, + error: emptyComponent, + renderDelay: 0, + onFetch: noop, + onFetchSuccess: noop, + onFetchFail: noop, + }; + + static cache = {}; + + state = { + mod: undefined, + forceRender: false, + }; + + componentWillMount() { + this.load(this.props); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.fetchComponent !== this.props.fetchComponent) { + this.load(nextProps); + } + } + + componentWillUnmount () { + if (this.timeout) { + clearTimeout(this.timeout); + } + } + + load = (props) => { + const { fetchComponent, onFetch, onFetchSuccess, onFetchFail, renderDelay } = props || this.props; + + if (fetchComponent === undefined) { + this.setState({ mod: null }); + return Promise.resolve(); + } + + onFetch(); + + if (Bundle.cache[fetchComponent.name]) { + const mod = Bundle.cache[fetchComponent.name]; + + this.setState({ mod: mod.default }); + onFetchSuccess(); + return Promise.resolve(); + } + + this.setState({ mod: undefined }); + + if (renderDelay !== 0) { + this.timestamp = new Date(); + this.timeout = setTimeout(() => this.setState({ forceRender: true }), renderDelay); + } + + return fetchComponent() + .then((mod) => { + Bundle.cache[fetchComponent.name] = mod; + this.setState({ mod: mod.default }); + onFetchSuccess(); + }) + .catch((error) => { + this.setState({ mod: null }); + onFetchFail(error); + }); + }; + + render() { + const { loading: Loading, error: Error, children, renderDelay } = this.props; + const { mod, forceRender } = this.state; + const elapsed = this.timestamp ? (new Date() - this.timestamp) : renderDelay; + + if (mod === undefined) { + return (elapsed >= renderDelay || forceRender) ? <Loading /> : null; + } + + if (mod === null) { + return <Error onRetry={this.load} />; + } + + return children(mod); + } + +} + +export default Bundle; diff --git a/app/javascript/flavours/glitch/features/ui/components/bundle_column_error.js b/app/javascript/flavours/glitch/features/ui/components/bundle_column_error.js new file mode 100644 index 000000000..88304dc36 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/bundle_column_error.js @@ -0,0 +1,162 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import Column from 'flavours/glitch/components/column'; +import Button from 'flavours/glitch/components/button'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; +import classNames from 'classnames'; +import { autoPlayGif } from 'flavours/glitch/initial_state'; + +class GIF extends React.PureComponent { + + static propTypes = { + src: PropTypes.string.isRequired, + staticSrc: PropTypes.string.isRequired, + className: PropTypes.string, + animate: PropTypes.bool, + }; + + static defaultProps = { + animate: autoPlayGif, + }; + + state = { + hovering: false, + }; + + handleMouseEnter = () => { + const { animate } = this.props; + + if (!animate) { + this.setState({ hovering: true }); + } + }; + + handleMouseLeave = () => { + const { animate } = this.props; + + if (!animate) { + this.setState({ hovering: false }); + } + }; + + render () { + const { src, staticSrc, className, animate } = this.props; + const { hovering } = this.state; + + return ( + <img + className={className} + src={(hovering || animate) ? src : staticSrc} + alt='' + role='presentation' + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + /> + ); + } + +} + +class CopyButton extends React.PureComponent { + + static propTypes = { + children: PropTypes.node.isRequired, + value: PropTypes.string.isRequired, + }; + + state = { + copied: false, + }; + + handleClick = () => { + const { value } = this.props; + navigator.clipboard.writeText(value); + this.setState({ copied: true }); + this.timeout = setTimeout(() => this.setState({ copied: false }), 700); + }; + + componentWillUnmount () { + if (this.timeout) clearTimeout(this.timeout); + } + + render () { + const { children } = this.props; + const { copied } = this.state; + + return ( + <Button onClick={this.handleClick} className={copied ? 'copied' : 'copyable'}>{copied ? <FormattedMessage id='copypaste.copied' defaultMessage='Copied' /> : children}</Button> + ); + } + +} + +export default @injectIntl +class BundleColumnError extends React.PureComponent { + + static propTypes = { + errorType: PropTypes.oneOf(['routing', 'network', 'error']), + onRetry: PropTypes.func, + intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, + stacktrace: PropTypes.string, + }; + + static defaultProps = { + errorType: 'routing', + }; + + handleRetry = () => { + const { onRetry } = this.props; + + if (onRetry) { + onRetry(); + } + }; + + render () { + const { errorType, multiColumn, stacktrace } = this.props; + + let title, body; + + switch(errorType) { + case 'routing': + title = <FormattedMessage id='bundle_column_error.routing.title' defaultMessage='404' />; + body = <FormattedMessage id='bundle_column_error.routing.body' defaultMessage='The requested page could not be found. Are you sure the URL in the address bar is correct?' />; + break; + case 'network': + title = <FormattedMessage id='bundle_column_error.network.title' defaultMessage='Network error' />; + body = <FormattedMessage id='bundle_column_error.network.body' defaultMessage='There was an error when trying to load this page. This could be due to a temporary problem with your internet connection or this server.' />; + break; + case 'error': + title = <FormattedMessage id='bundle_column_error.error.title' defaultMessage='Oh, no!' />; + body = <FormattedMessage id='bundle_column_error.error.body' defaultMessage='The requested page could not be rendered. It could be due to a bug in our code, or a browser compatibility issue.' />; + break; + } + + return ( + <Column bindToDocument={!multiColumn}> + <div className='error-column'> + <GIF src='/oops.gif' staticSrc='/oops.png' className='error-column__image' /> + + <div className='error-column__message'> + <h1>{title}</h1> + <p>{body}</p> + + <div className='error-column__message__actions'> + {errorType === 'network' && <Button onClick={this.handleRetry}><FormattedMessage id='bundle_column_error.retry' defaultMessage='Try again' /></Button>} + {errorType === 'error' && <CopyButton value={stacktrace}><FormattedMessage id='bundle_column_error.copy_stacktrace' defaultMessage='Copy error report' /></CopyButton>} + <Link to='/' className={classNames('button', { 'button-tertiary': errorType !== 'routing' })}><FormattedMessage id='bundle_column_error.return' defaultMessage='Go back home' /></Link> + </div> + </div> + </div> + + <Helmet> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/bundle_modal_error.js b/app/javascript/flavours/glitch/features/ui/components/bundle_modal_error.js new file mode 100644 index 000000000..b79105450 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/bundle_modal_error.js @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; + +import IconButton from 'flavours/glitch/components/icon_button'; + +const messages = defineMessages({ + error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this component.' }, + retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' }, + close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' }, +}); + +class BundleModalError extends React.Component { + + static propTypes = { + onRetry: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleRetry = () => { + this.props.onRetry(); + }; + + render () { + const { onClose, intl: { formatMessage } } = this.props; + + // Keep the markup in sync with <ModalLoading /> + // (make sure they have the same dimensions) + return ( + <div className='modal-root__modal error-modal'> + <div className='error-modal__body'> + <IconButton title={formatMessage(messages.retry)} icon='refresh' onClick={this.handleRetry} size={64} /> + {formatMessage(messages.error)} + </div> + + <div className='error-modal__footer'> + <div> + <button + onClick={onClose} + className='error-modal__nav onboarding-modal__skip' + > + {formatMessage(messages.close)} + </button> + </div> + </div> + </div> + ); + } + +} + +export default injectIntl(BundleModalError); diff --git a/app/javascript/flavours/glitch/features/ui/components/column.js b/app/javascript/flavours/glitch/features/ui/components/column.js new file mode 100644 index 000000000..cc2abc43a --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/column.js @@ -0,0 +1,75 @@ +import React from 'react'; +import ColumnHeader from './column_header'; +import PropTypes from 'prop-types'; +import { debounce } from 'lodash'; +import { scrollTop } from 'flavours/glitch/scroll'; +import { isMobile } from 'flavours/glitch/is_mobile'; + +export default class Column extends React.PureComponent { + + static propTypes = { + heading: PropTypes.string, + icon: PropTypes.string, + children: PropTypes.node, + active: PropTypes.bool, + hideHeadingOnMobile: PropTypes.bool, + name: PropTypes.string, + bindToDocument: PropTypes.bool, + }; + + handleHeaderClick = () => { + const scrollable = this.props.bindToDocument ? document.scrollingElement : this.node.querySelector('.scrollable'); + + if (!scrollable) { + return; + } + + this._interruptScrollAnimation = scrollTop(scrollable); + }; + + scrollTop () { + const scrollable = this.props.bindToDocument ? document.scrollingElement : this.node.querySelector('.scrollable'); + + if (!scrollable) { + return; + } + + this._interruptScrollAnimation = scrollTop(scrollable); + } + + + handleScroll = debounce(() => { + if (typeof this._interruptScrollAnimation !== 'undefined') { + this._interruptScrollAnimation(); + } + }, 200); + + setRef = (c) => { + this.node = c; + }; + + render () { + const { heading, icon, children, active, hideHeadingOnMobile, name } = this.props; + + const showHeading = heading && (!hideHeadingOnMobile || (hideHeadingOnMobile && !isMobile(window.innerWidth))); + + const columnHeaderId = showHeading && heading.replace(/ /g, '-'); + const header = showHeading && ( + <ColumnHeader icon={icon} active={active} type={heading} onClick={this.handleHeaderClick} columnHeaderId={columnHeaderId} /> + ); + return ( + <div + ref={this.setRef} + role='region' + data-column={name} + aria-labelledby={columnHeaderId} + className='column' + onScroll={this.handleScroll} + > + {header} + {children} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/column_header.js b/app/javascript/flavours/glitch/features/ui/components/column_header.js new file mode 100644 index 000000000..151476f8b --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/column_header.js @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import Icon from 'flavours/glitch/components/icon'; + +export default class ColumnHeader extends React.PureComponent { + + static propTypes = { + icon: PropTypes.string, + type: PropTypes.string, + active: PropTypes.bool, + onClick: PropTypes.func, + columnHeaderId: PropTypes.string, + }; + + handleClick = () => { + this.props.onClick(); + }; + + render () { + const { icon, type, active, columnHeaderId } = this.props; + let iconElement = ''; + + if (icon) { + iconElement = <Icon id={icon} fixedWidth className='column-header__icon' />; + } + + return ( + <h1 className={classNames('column-header', { active })} id={columnHeaderId || null}> + <button onClick={this.handleClick}> + {iconElement} + {type} + </button> + </h1> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/column_link.js b/app/javascript/flavours/glitch/features/ui/components/column_link.js new file mode 100644 index 000000000..dcdac077f --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/column_link.js @@ -0,0 +1,55 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { NavLink } from 'react-router-dom'; +import Icon from 'flavours/glitch/components/icon'; +import classNames from 'classnames'; + +const ColumnLink = ({ icon, text, to, onClick, href, method, badge, transparent, ...other }) => { + const className = classNames('column-link', { 'column-link--transparent': transparent }); + const badgeElement = typeof badge !== 'undefined' ? <span className='column-link__badge'>{badge}</span> : null; + const iconElement = typeof icon === 'string' ? <Icon id={icon} fixedWidth className='column-link__icon' /> : icon; + + if (href) { + return ( + <a href={href} className={className} data-method={method} title={text} {...other}> + {iconElement} + <span>{text}</span> + {badgeElement} + </a> + ); + } else if (to) { + return ( + <NavLink to={to} className={className} title={text} {...other}> + {iconElement} + <span>{text}</span> + {badgeElement} + </NavLink> + ); + } else { + const handleOnClick = (e) => { + e.preventDefault(); + e.stopPropagation(); + return onClick(e); + }; + return ( + <a href='#' onClick={onClick && handleOnClick} className={className} title={text} {...other} tabIndex='0'> + {iconElement} + <span>{text}</span> + {badgeElement} + </a> + ); + } +}; + +ColumnLink.propTypes = { + icon: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, + text: PropTypes.string.isRequired, + to: PropTypes.string, + onClick: PropTypes.func, + href: PropTypes.string, + method: PropTypes.string, + badge: PropTypes.node, + transparent: PropTypes.bool, +}; + +export default ColumnLink; diff --git a/app/javascript/flavours/glitch/features/ui/components/column_loading.js b/app/javascript/flavours/glitch/features/ui/components/column_loading.js new file mode 100644 index 000000000..b07385397 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/column_loading.js @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Column from 'flavours/glitch/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +export default class ColumnLoading extends ImmutablePureComponent { + + static propTypes = { + title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]), + icon: PropTypes.string, + multiColumn: PropTypes.bool, + }; + + static defaultProps = { + title: '', + icon: '', + }; + + render() { + let { title, icon, multiColumn } = this.props; + + return ( + <Column> + <ColumnHeader icon={icon} title={title} multiColumn={multiColumn} focusable={false} placeholder /> + <div className='scrollable' /> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/column_subheading.js b/app/javascript/flavours/glitch/features/ui/components/column_subheading.js new file mode 100644 index 000000000..8160c4aa3 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/column_subheading.js @@ -0,0 +1,16 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const ColumnSubheading = ({ text }) => { + return ( + <div className='column-subheading'> + {text} + </div> + ); +}; + +ColumnSubheading.propTypes = { + text: PropTypes.string.isRequired, +}; + +export default ColumnSubheading; diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.js b/app/javascript/flavours/glitch/features/ui/components/columns_area.js new file mode 100644 index 000000000..3b3b0d58f --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js @@ -0,0 +1,183 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import BundleContainer from '../containers/bundle_container'; +import ColumnLoading from './column_loading'; +import DrawerLoading from './drawer_loading'; +import BundleColumnError from './bundle_column_error'; +import { + Compose, + Notifications, + HomeTimeline, + CommunityTimeline, + PublicTimeline, + HashtagTimeline, + DirectTimeline, + FavouritedStatuses, + BookmarkedStatuses, + ListTimeline, + Directory, +} from '../../ui/util/async-components'; +import ComposePanel from './compose_panel'; +import NavigationPanel from './navigation_panel'; + +import { supportsPassiveEvents } from 'detect-passive-events'; +import { scrollRight } from 'flavours/glitch/scroll'; + +const componentMap = { + 'COMPOSE': Compose, + 'HOME': HomeTimeline, + 'NOTIFICATIONS': Notifications, + 'PUBLIC': PublicTimeline, + 'REMOTE': PublicTimeline, + 'COMMUNITY': CommunityTimeline, + 'HASHTAG': HashtagTimeline, + 'DIRECT': DirectTimeline, + 'FAVOURITES': FavouritedStatuses, + 'BOOKMARKS': BookmarkedStatuses, + 'LIST': ListTimeline, + 'DIRECTORY': Directory, +}; + +export default class ColumnsArea extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object.isRequired, + }; + + static propTypes = { + columns: ImmutablePropTypes.list.isRequired, + singleColumn: PropTypes.bool, + children: PropTypes.node, + navbarUnder: PropTypes.bool, + openSettings: PropTypes.func, + }; + + // Corresponds to (max-width: $no-gap-breakpoint + 285px - 1px) in SCSS + mediaQuery = 'matchMedia' in window && window.matchMedia('(max-width: 1174px)'); + + state = { + renderComposePanel: !(this.mediaQuery && this.mediaQuery.matches), + }; + + componentDidMount() { + if (!this.props.singleColumn) { + this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false); + } + + if (this.mediaQuery) { + if (this.mediaQuery.addEventListener) { + this.mediaQuery.addEventListener('change', this.handleLayoutChange); + } else { + this.mediaQuery.addListener(this.handleLayoutChange); + } + this.setState({ renderComposePanel: !this.mediaQuery.matches }); + } + + this.isRtlLayout = document.getElementsByTagName('body')[0].classList.contains('rtl'); + } + + componentWillUpdate(nextProps) { + if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) { + this.node.removeEventListener('wheel', this.handleWheel); + } + } + + componentDidUpdate(prevProps) { + if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) { + this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false); + } + } + + componentWillUnmount () { + if (!this.props.singleColumn) { + this.node.removeEventListener('wheel', this.handleWheel); + } + + if (this.mediaQuery) { + if (this.mediaQuery.removeEventListener) { + this.mediaQuery.removeEventListener('change', this.handleLayoutChange); + } else { + this.mediaQuery.removeListener(this.handleLayoutChange); + } + } + } + + handleChildrenContentChange() { + if (!this.props.singleColumn) { + const modifier = this.isRtlLayout ? -1 : 1; + this._interruptScrollAnimation = scrollRight(this.node, (this.node.scrollWidth - window.innerWidth) * modifier); + } + } + + handleLayoutChange = (e) => { + this.setState({ renderComposePanel: !e.matches }); + }; + + handleWheel = () => { + if (typeof this._interruptScrollAnimation !== 'function') { + return; + } + + this._interruptScrollAnimation(); + }; + + setRef = (node) => { + this.node = node; + }; + + renderLoading = columnId => () => { + return columnId === 'COMPOSE' ? <DrawerLoading /> : <ColumnLoading multiColumn />; + }; + + renderError = (props) => { + return <BundleColumnError multiColumn errorType='network' {...props} />; + }; + + render () { + const { columns, children, singleColumn, navbarUnder, openSettings } = this.props; + const { renderComposePanel } = this.state; + + if (singleColumn) { + return ( + <div className='columns-area__panels'> + <div className='columns-area__panels__pane columns-area__panels__pane--compositional'> + <div className='columns-area__panels__pane__inner'> + {renderComposePanel && <ComposePanel />} + </div> + </div> + + <div className='columns-area__panels__main'> + <div className='tabs-bar__wrapper'><div id='tabs-bar__portal' /></div> + <div className='columns-area columns-area--mobile'>{children}</div> + </div> + + <div className='columns-area__panels__pane columns-area__panels__pane--start columns-area__panels__pane--navigational'> + <div className='columns-area__panels__pane__inner'> + <NavigationPanel onOpenSettings={openSettings} /> + </div> + </div> + </div> + ); + } + + return ( + <div className='columns-area' ref={this.setRef}> + {columns.map(column => { + const params = column.get('params', null) === null ? null : column.get('params').toJS(); + const other = params && params.other ? params.other : {}; + + return ( + <BundleContainer key={column.get('uuid')} fetchComponent={componentMap[column.get('id')]} loading={this.renderLoading(column.get('id'))} error={this.renderError}> + {SpecificComponent => <SpecificComponent columnId={column.get('uuid')} params={params} multiColumn {...other} />} + </BundleContainer> + ); + })} + + {React.Children.map(children, child => React.cloneElement(child, { multiColumn: true }))} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/compare_history_modal.js b/app/javascript/flavours/glitch/features/ui/components/compare_history_modal.js new file mode 100644 index 000000000..baf7f25be --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/compare_history_modal.js @@ -0,0 +1,99 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; +import { FormattedMessage } from 'react-intl'; +import { closeModal } from 'flavours/glitch/actions/modal'; +import emojify from 'flavours/glitch/features/emoji/emoji'; +import escapeTextContentForBrowser from 'escape-html'; +import InlineAccount from 'flavours/glitch/components/inline_account'; +import IconButton from 'flavours/glitch/components/icon_button'; +import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp'; +import MediaAttachments from 'flavours/glitch/components/media_attachments'; + +const mapStateToProps = (state, { statusId }) => ({ + versions: state.getIn(['history', statusId, 'items']), +}); + +const mapDispatchToProps = dispatch => ({ + + onClose() { + dispatch(closeModal()); + }, + +}); + +export default @connect(mapStateToProps, mapDispatchToProps) +class CompareHistoryModal extends React.PureComponent { + + static propTypes = { + onClose: PropTypes.func.isRequired, + index: PropTypes.number.isRequired, + statusId: PropTypes.string.isRequired, + versions: ImmutablePropTypes.list.isRequired, + }; + + render () { + const { index, versions, onClose } = this.props; + const currentVersion = versions.get(index); + + const emojiMap = currentVersion.get('emojis').reduce((obj, emoji) => { + obj[`:${emoji.get('shortcode')}:`] = emoji.toJS(); + return obj; + }, {}); + + const content = { __html: emojify(currentVersion.get('content'), emojiMap) }; + const spoilerContent = { __html: emojify(escapeTextContentForBrowser(currentVersion.get('spoiler_text')), emojiMap) }; + + const formattedDate = <RelativeTimestamp timestamp={currentVersion.get('created_at')} short={false} />; + const formattedName = <InlineAccount accountId={currentVersion.get('account')} />; + + const label = currentVersion.get('original') ? ( + <FormattedMessage id='status.history.created' defaultMessage='{name} created {date}' values={{ name: formattedName, date: formattedDate }} /> + ) : ( + <FormattedMessage id='status.history.edited' defaultMessage='{name} edited {date}' values={{ name: formattedName, date: formattedDate }} /> + ); + + return ( + <div className='modal-root__modal compare-history-modal'> + <div className='report-modal__target'> + <IconButton className='report-modal__close' icon='times' onClick={onClose} size={20} /> + {label} + </div> + + <div className='compare-history-modal__container'> + <div className='status__content'> + {currentVersion.get('spoiler_text').length > 0 && ( + <React.Fragment> + <div className='translate' dangerouslySetInnerHTML={spoilerContent} /> + <hr /> + </React.Fragment> + )} + + <div className='status__content__text status__content__text--visible translate' dangerouslySetInnerHTML={content} /> + + {!!currentVersion.get('poll') && ( + <div className='poll'> + <ul> + {currentVersion.getIn(['poll', 'options']).map(option => ( + <li key={option.get('title')}> + <span className='poll__input disabled' /> + + <span + className='poll__option__text translate' + dangerouslySetInnerHTML={{ __html: emojify(escapeTextContentForBrowser(option.get('title')), emojiMap) }} + /> + </li> + ))} + </ul> + </div> + )} + + <MediaAttachments status={currentVersion} /> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/compose_panel.js b/app/javascript/flavours/glitch/features/ui/components/compose_panel.js new file mode 100644 index 000000000..34c194c99 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/compose_panel.js @@ -0,0 +1,58 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import SearchContainer from 'flavours/glitch/features/compose/containers/search_container'; +import ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container'; +import NavigationContainer from 'flavours/glitch/features/compose/containers/navigation_container'; +import LinkFooter from './link_footer'; +import ServerBanner from 'flavours/glitch/components/server_banner'; +import { mountCompose, unmountCompose } from 'flavours/glitch/actions/compose'; + +export default @connect() +class ComposePanel extends React.PureComponent { + + static contextTypes = { + identity: PropTypes.object.isRequired, + }; + + static propTypes = { + dispatch: PropTypes.func.isRequired, + }; + + componentDidMount () { + const { dispatch } = this.props; + dispatch(mountCompose()); + } + + componentWillUnmount () { + const { dispatch } = this.props; + dispatch(unmountCompose()); + } + + render() { + const { signedIn } = this.context.identity; + + return ( + <div className='compose-panel'> + <SearchContainer openInRoute /> + + {!signedIn && ( + <React.Fragment> + <ServerBanner /> + <div className='flex-spacer' /> + </React.Fragment> + )} + + {signedIn && ( + <React.Fragment> + <NavigationContainer /> + <ComposeFormContainer singleColumn /> + </React.Fragment> + )} + + <LinkFooter /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js b/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js new file mode 100644 index 000000000..94935de5d --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js @@ -0,0 +1,88 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import Button from 'flavours/glitch/components/button'; + +export default @injectIntl +class ConfirmationModal extends React.PureComponent { + + static propTypes = { + message: PropTypes.node.isRequired, + confirm: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + secondary: PropTypes.string, + onSecondary: PropTypes.func, + closeWhenConfirm: PropTypes.bool, + onDoNotAsk: PropTypes.func, + intl: PropTypes.object.isRequired, + }; + + static defaultProps = { + closeWhenConfirm: true, + }; + + componentDidMount() { + this.button.focus(); + } + + handleClick = () => { + if (this.props.closeWhenConfirm) { + this.props.onClose(); + } + this.props.onConfirm(); + if (this.props.onDoNotAsk && this.doNotAskCheckbox.checked) { + this.props.onDoNotAsk(); + } + }; + + handleSecondary = () => { + this.props.onClose(); + this.props.onSecondary(); + }; + + handleCancel = () => { + this.props.onClose(); + }; + + setRef = (c) => { + this.button = c; + }; + + setDoNotAskRef = (c) => { + this.doNotAskCheckbox = c; + }; + + render () { + const { message, confirm, secondary, onDoNotAsk } = this.props; + + return ( + <div className='modal-root__modal confirmation-modal'> + <div className='confirmation-modal__container'> + {message} + </div> + + <div> + { onDoNotAsk && ( + <div className='confirmation-modal__do_not_ask_again'> + <input type='checkbox' id='confirmation-modal__do_not_ask_again-checkbox' ref={this.setDoNotAskRef} /> + <label for='confirmation-modal__do_not_ask_again-checkbox'> + <FormattedMessage id='confirmation_modal.do_not_ask_again' defaultMessage='Do not ask for confirmation again' /> + </label> + </div> + )} + <div className='confirmation-modal__action-bar'> + <Button onClick={this.handleCancel} className='confirmation-modal__cancel-button'> + <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' /> + </Button> + {secondary !== undefined && ( + <Button text={secondary} onClick={this.handleSecondary} className='confirmation-modal__secondary-button' /> + )} + <Button text={confirm} onClick={this.handleClick} ref={this.setRef} /> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/deprecated_settings_modal.js b/app/javascript/flavours/glitch/features/ui/components/deprecated_settings_modal.js new file mode 100644 index 000000000..37f52b014 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/deprecated_settings_modal.js @@ -0,0 +1,86 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { preferenceLink } from 'flavours/glitch/utils/backend_links'; +import Button from 'flavours/glitch/components/button'; +import Icon from 'flavours/glitch/components/icon'; +import illustration from 'flavours/glitch/images/logo_warn_glitch.svg'; + +const messages = defineMessages({ + discardChanges: { id: 'confirmations.deprecated_settings.confirm', defaultMessage: 'Use Mastodon preferences' }, + user_setting_expand_spoilers: { id: 'settings.enable_content_warnings_auto_unfold', defaultMessage: 'Automatically unfold content-warnings' }, + user_setting_disable_swiping: { id: 'settings.swipe_to_change_columns', defaultMessage: 'Allow swiping to change columns (Mobile only)' }, +}); + +export default @injectIntl +class DeprecatedSettingsModal extends React.PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.list.isRequired, + onClose: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + componentDidMount() { + this.button.focus(); + } + + handleClick = () => { + this.props.onConfirm(); + this.props.onClose(); + }; + + setRef = (c) => { + this.button = c; + }; + + render () { + const { settings, intl } = this.props; + + return ( + <div className='modal-root__modal confirmation-modal'> + <div className='confirmation-modal__container'> + + <img src={illustration} className='modal-warning' alt='' /> + + <FormattedMessage + id='confirmations.deprecated_settings.message' + defaultMessage='Some of the glitch-soc device-specific {app_settings} you are using have been replaced by Mastodon {preferences} and will be overriden:' + values={{ + app_settings: ( + <strong className='deprecated-settings-label'> + <Icon id='cogs' /> <FormattedMessage id='navigation_bar.app_settings' defaultMessage='App settings' /> + </strong> + ), + preferences: ( + <strong className='deprecated-settings-label'> + <Icon id='cog' /> <FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /> + </strong> + ), + }} + /> + + <div className='deprecated-settings-info'> + <ul> + { settings.map((setting_name) => ( + <li> + <a href={preferenceLink(setting_name)}><FormattedMessage {...messages[setting_name]} /></a> + </li> + )) } + </ul> + </div> + </div> + + <div> + <div className='confirmation-modal__action-bar'> + <div /> + <Button text={intl.formatMessage(messages.discardChanges)} onClick={this.handleClick} ref={this.setRef} /> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/disabled_account_banner.js b/app/javascript/flavours/glitch/features/ui/components/disabled_account_banner.js new file mode 100644 index 000000000..35933bedb --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/disabled_account_banner.js @@ -0,0 +1,92 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { Link } from 'react-router-dom'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; +import { disabledAccountId, movedToAccountId, domain } from 'flavours/glitch/initial_state'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { logOut } from 'flavours/glitch/utils/log_out'; + +const messages = defineMessages({ + logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' }, + logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' }, +}); + +const mapStateToProps = (state) => ({ + disabledAcct: state.getIn(['accounts', disabledAccountId, 'acct']), + movedToAcct: movedToAccountId ? state.getIn(['accounts', movedToAccountId, 'acct']) : undefined, +}); + +const mapDispatchToProps = (dispatch, { intl }) => ({ + onLogout () { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.logoutMessage), + confirm: intl.formatMessage(messages.logoutConfirm), + closeWhenConfirm: false, + onConfirm: () => logOut(), + })); + }, +}); + +export default @injectIntl +@connect(mapStateToProps, mapDispatchToProps) +class DisabledAccountBanner extends React.PureComponent { + + static propTypes = { + disabledAcct: PropTypes.string.isRequired, + movedToAcct: PropTypes.string, + onLogout: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleLogOutClick = e => { + e.preventDefault(); + e.stopPropagation(); + + this.props.onLogout(); + + return false; + }; + + render () { + const { disabledAcct, movedToAcct } = this.props; + + const disabledAccountLink = ( + <Link to={`/@${disabledAcct}`}> + {disabledAcct}@{domain} + </Link> + ); + + return ( + <div className='sign-in-banner'> + <p> + {movedToAcct ? ( + <FormattedMessage + id='moved_to_account_banner.text' + defaultMessage='Your account {disabledAccount} is currently disabled because you moved to {movedToAccount}.' + values={{ + disabledAccount: disabledAccountLink, + movedToAccount: <Link to={`/@${movedToAcct}`}>{movedToAcct.includes('@') ? movedToAcct : `${movedToAcct}@${domain}`}</Link>, + }} + /> + ) : ( + <FormattedMessage + id='disabled_account_banner.text' + defaultMessage='Your account {disabledAccount} is currently disabled.' + values={{ + disabledAccount: disabledAccountLink, + }} + /> + )} + </p> + <a href='/auth/edit' className='button button--block'> + <FormattedMessage id='disabled_account_banner.account_settings' defaultMessage='Account settings' /> + </a> + <button type='button' className='button button--block button-tertiary' onClick={this.handleLogOutClick}> + <FormattedMessage id='confirmations.logout.confirm' defaultMessage='Log out' /> + </button> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/doodle_modal.js b/app/javascript/flavours/glitch/features/ui/components/doodle_modal.js new file mode 100644 index 000000000..c8ea33a0e --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/doodle_modal.js @@ -0,0 +1,614 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Button from 'flavours/glitch/components/button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Atrament from 'atrament'; // the doodling library +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { doodleSet, uploadCompose } from 'flavours/glitch/actions/compose'; +import IconButton from 'flavours/glitch/components/icon_button'; +import { debounce, mapValues } from 'lodash'; +import classNames from 'classnames'; + +// palette nicked from MyPaint, CC0 +const palette = [ + ['rgb( 0, 0, 0)', 'Black'], + ['rgb( 38, 38, 38)', 'Gray 15'], + ['rgb( 77, 77, 77)', 'Grey 30'], + ['rgb(128, 128, 128)', 'Grey 50'], + ['rgb(171, 171, 171)', 'Grey 67'], + ['rgb(217, 217, 217)', 'Grey 85'], + ['rgb(255, 255, 255)', 'White'], + ['rgb(128, 0, 0)', 'Maroon'], + ['rgb(209, 0, 0)', 'English-red'], + ['rgb(255, 54, 34)', 'Tomato'], + ['rgb(252, 60, 3)', 'Orange-red'], + ['rgb(255, 140, 105)', 'Salmon'], + ['rgb(252, 232, 32)', 'Cadium-yellow'], + ['rgb(243, 253, 37)', 'Lemon yellow'], + ['rgb(121, 5, 35)', 'Dark crimson'], + ['rgb(169, 32, 62)', 'Deep carmine'], + ['rgb(255, 140, 0)', 'Orange'], + ['rgb(255, 168, 18)', 'Dark tangerine'], + ['rgb(217, 144, 88)', 'Persian orange'], + ['rgb(194, 178, 128)', 'Sand'], + ['rgb(255, 229, 180)', 'Peach'], + ['rgb(100, 54, 46)', 'Bole'], + ['rgb(108, 41, 52)', 'Dark cordovan'], + ['rgb(163, 65, 44)', 'Chestnut'], + ['rgb(228, 136, 100)', 'Dark salmon'], + ['rgb(255, 195, 143)', 'Apricot'], + ['rgb(255, 219, 188)', 'Unbleached silk'], + ['rgb(242, 227, 198)', 'Straw'], + ['rgb( 53, 19, 13)', 'Bistre'], + ['rgb( 84, 42, 14)', 'Dark chocolate'], + ['rgb(102, 51, 43)', 'Burnt sienna'], + ['rgb(184, 66, 0)', 'Sienna'], + ['rgb(216, 153, 12)', 'Yellow ochre'], + ['rgb(210, 180, 140)', 'Tan'], + ['rgb(232, 204, 144)', 'Dark wheat'], + ['rgb( 0, 49, 83)', 'Prussian blue'], + ['rgb( 48, 69, 119)', 'Dark grey blue'], + ['rgb( 0, 71, 171)', 'Cobalt blue'], + ['rgb( 31, 117, 254)', 'Blue'], + ['rgb(120, 180, 255)', 'Bright french blue'], + ['rgb(171, 200, 255)', 'Bright steel blue'], + ['rgb(208, 231, 255)', 'Ice blue'], + ['rgb( 30, 51, 58)', 'Medium jungle green'], + ['rgb( 47, 79, 79)', 'Dark slate grey'], + ['rgb( 74, 104, 93)', 'Dark grullo green'], + ['rgb( 0, 128, 128)', 'Teal'], + ['rgb( 67, 170, 176)', 'Turquoise'], + ['rgb(109, 174, 199)', 'Cerulean frost'], + ['rgb(173, 217, 186)', 'Tiffany green'], + ['rgb( 22, 34, 29)', 'Gray-asparagus'], + ['rgb( 36, 48, 45)', 'Medium dark teal'], + ['rgb( 74, 104, 93)', 'Xanadu'], + ['rgb(119, 198, 121)', 'Mint'], + ['rgb(175, 205, 182)', 'Timberwolf'], + ['rgb(185, 245, 246)', 'Celeste'], + ['rgb(193, 255, 234)', 'Aquamarine'], + ['rgb( 29, 52, 35)', 'Cal Poly Pomona'], + ['rgb( 1, 68, 33)', 'Forest green'], + ['rgb( 42, 128, 0)', 'Napier green'], + ['rgb(128, 128, 0)', 'Olive'], + ['rgb( 65, 156, 105)', 'Sea green'], + ['rgb(189, 246, 29)', 'Green-yellow'], + ['rgb(231, 244, 134)', 'Bright chartreuse'], + ['rgb(138, 23, 137)', 'Purple'], + ['rgb( 78, 39, 138)', 'Violet'], + ['rgb(193, 75, 110)', 'Dark thulian pink'], + ['rgb(222, 49, 99)', 'Cerise'], + ['rgb(255, 20, 147)', 'Deep pink'], + ['rgb(255, 102, 204)', 'Rose pink'], + ['rgb(255, 203, 219)', 'Pink'], + ['rgb(255, 255, 255)', 'White'], + ['rgb(229, 17, 1)', 'RGB Red'], + ['rgb( 0, 255, 0)', 'RGB Green'], + ['rgb( 0, 0, 255)', 'RGB Blue'], + ['rgb( 0, 255, 255)', 'CMYK Cyan'], + ['rgb(255, 0, 255)', 'CMYK Magenta'], + ['rgb(255, 255, 0)', 'CMYK Yellow'], +]; + +// re-arrange to the right order for display +let palReordered = []; +for (let row = 0; row < 7; row++) { + for (let col = 0; col < 11; col++) { + palReordered.push(palette[col * 7 + row]); + } + palReordered.push(null); // null indicates a <br /> +} + +// Utility for converting base64 image to binary for upload +// https://stackoverflow.com/questions/35940290/how-to-convert-base64-string-to-javascript-file-object-like-as-from-file-input-f +function dataURLtoFile(dataurl, filename) { + let arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1], + bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n); + while(n--){ + u8arr[n] = bstr.charCodeAt(n); + } + return new File([u8arr], filename, { type: mime }); +} +/** Doodle canvas size options */ +const DOODLE_SIZES = { + normal: [500, 500, 'Square 500'], + tootbanner: [702, 330, 'Tootbanner'], + s640x480: [640, 480, '640×480 - 480p'], + s800x600: [800, 600, '800×600 - SVGA'], + s720x480: [720, 405, '720x405 - 16:9'], +}; + + +const mapStateToProps = state => ({ + options: state.getIn(['compose', 'doodle']), +}); + +const mapDispatchToProps = dispatch => ({ + /** Set options in the redux store */ + setOpt: (opts) => dispatch(doodleSet(opts)), + /** Submit doodle for upload */ + submit: (file) => dispatch(uploadCompose([file])), +}); + +/** + * Doodling dialog with drawing canvas + * + * Keyboard shortcuts: + * - Delete: Clear screen, fill with background color + * - Backspace, Ctrl+Z: Undo one step + * - Ctrl held while drawing: Use background color + * - Shift held while clicking screen: Use fill tool + * + * Palette: + * - Left mouse button: pick foreground + * - Ctrl + left mouse button: pick background + * - Right mouse button: pick background + */ +export default @connect(mapStateToProps, mapDispatchToProps) +class DoodleModal extends ImmutablePureComponent { + + static propTypes = { + options: ImmutablePropTypes.map, + onClose: PropTypes.func.isRequired, + setOpt: PropTypes.func.isRequired, + submit: PropTypes.func.isRequired, + }; + + //region Option getters/setters + + /** Foreground color */ + get fg () { + return this.props.options.get('fg'); + } + set fg (value) { + this.props.setOpt({ fg: value }); + } + + /** Background color */ + get bg () { + return this.props.options.get('bg'); + } + set bg (value) { + this.props.setOpt({ bg: value }); + } + + /** Swap Fg and Bg for drawing */ + get swapped () { + return this.props.options.get('swapped'); + } + set swapped (value) { + this.props.setOpt({ swapped: value }); + } + + /** Mode - 'draw' or 'fill' */ + get mode () { + return this.props.options.get('mode'); + } + set mode (value) { + this.props.setOpt({ mode: value }); + } + + /** Base line weight */ + get weight () { + return this.props.options.get('weight'); + } + set weight (value) { + this.props.setOpt({ weight: value }); + } + + /** Drawing opacity */ + get opacity () { + return this.props.options.get('opacity'); + } + set opacity (value) { + this.props.setOpt({ opacity: value }); + } + + /** Adaptive stroke - change width with speed */ + get adaptiveStroke () { + return this.props.options.get('adaptiveStroke'); + } + set adaptiveStroke (value) { + this.props.setOpt({ adaptiveStroke: value }); + } + + /** Smoothing (for mouse drawing) */ + get smoothing () { + return this.props.options.get('smoothing'); + } + set smoothing (value) { + this.props.setOpt({ smoothing: value }); + } + + /** Size preset */ + get size () { + return this.props.options.get('size'); + } + set size (value) { + this.props.setOpt({ size: value }); + } + + //endregion + + /** Key up handler */ + handleKeyUp = (e) => { + if (e.target.nodeName === 'INPUT') return; + + if (e.key === 'Delete') { + e.preventDefault(); + this.handleClearBtn(); + return; + } + + if (e.key === 'Backspace' || (e.key === 'z' && (e.ctrlKey || e.metaKey))) { + e.preventDefault(); + this.undo(); + } + + if (e.key === 'Control' || e.key === 'Meta') { + this.controlHeld = false; + this.swapped = false; + } + + if (e.key === 'Shift') { + this.shiftHeld = false; + this.mode = 'draw'; + } + }; + + /** Key down handler */ + handleKeyDown = (e) => { + if (e.key === 'Control' || e.key === 'Meta') { + this.controlHeld = true; + this.swapped = true; + } + + if (e.key === 'Shift') { + this.shiftHeld = true; + this.mode = 'fill'; + } + }; + + /** + * Component installed in the DOM, do some initial set-up + */ + componentDidMount () { + this.controlHeld = false; + this.shiftHeld = false; + this.swapped = false; + window.addEventListener('keyup', this.handleKeyUp, false); + window.addEventListener('keydown', this.handleKeyDown, false); + } + + /** + * Tear component down + */ + componentWillUnmount () { + window.removeEventListener('keyup', this.handleKeyUp, false); + window.removeEventListener('keydown', this.handleKeyDown, false); + if (this.sketcher) this.sketcher.destroy(); + } + + /** + * Set reference to the canvas element. + * This is called during component init + * + * @param elem - canvas element + */ + setCanvasRef = (elem) => { + this.canvas = elem; + if (elem) { + elem.addEventListener('dirty', () => { + this.saveUndo(); + this.sketcher._dirty = false; + }); + + elem.addEventListener('click', () => { + // sketcher bug - does not fire dirty on fill + if (this.mode === 'fill') { + this.saveUndo(); + } + }); + + // prevent context menu + elem.addEventListener('contextmenu', (e) => { + e.preventDefault(); + }); + + elem.addEventListener('mousedown', (e) => { + if (e.button === 2) { + this.swapped = true; + } + }); + + elem.addEventListener('mouseup', (e) => { + if (e.button === 2) { + this.swapped = this.controlHeld; + } + }); + + this.initSketcher(elem); + this.mode = 'draw'; // Reset mode - it's confusing if left at 'fill' + } + }; + + /** + * Set up the sketcher instance + * + * @param canvas - canvas element. Null if we're just resizing + */ + initSketcher (canvas = null) { + const sizepreset = DOODLE_SIZES[this.size]; + + if (this.sketcher) this.sketcher.destroy(); + this.sketcher = new Atrament(canvas || this.canvas, sizepreset[0], sizepreset[1]); + + if (canvas) { + this.ctx = this.sketcher.context; + this.updateSketcherSettings(); + } + + this.clearScreen(); + } + + /** + * Done button handler + */ + onDoneButton = () => { + const dataUrl = this.sketcher.toImage(); + const file = dataURLtoFile(dataUrl, 'doodle.png'); + this.props.submit(file); + this.props.onClose(); // close dialog + }; + + /** + * Cancel button handler + */ + onCancelButton = () => { + if (this.undos.length > 1 && !confirm('Discard doodle? All changes will be lost!')) { + return; + } + + this.props.onClose(); // close dialog + }; + + /** + * Update sketcher options based on state + */ + updateSketcherSettings () { + if (!this.sketcher) return; + + if (this.oldSize !== this.size) this.initSketcher(); + + this.sketcher.color = (this.swapped ? this.bg : this.fg); + this.sketcher.opacity = this.opacity; + this.sketcher.weight = this.weight; + this.sketcher.mode = this.mode; + this.sketcher.smoothing = this.smoothing; + this.sketcher.adaptiveStroke = this.adaptiveStroke; + + this.oldSize = this.size; + } + + /** + * Fill screen with background color + */ + clearScreen = () => { + this.ctx.fillStyle = this.bg; + this.ctx.fillRect(-1, -1, this.canvas.width+2, this.canvas.height+2); + this.undos = []; + + this.doSaveUndo(); + }; + + /** + * Undo one step + */ + undo = () => { + if (this.undos.length > 1) { + this.undos.pop(); + const buf = this.undos.pop(); + + this.sketcher.clear(); + this.ctx.putImageData(buf, 0, 0); + this.doSaveUndo(); + } + }; + + /** + * Save canvas content into the undo buffer immediately + */ + doSaveUndo = () => { + this.undos.push(this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height)); + }; + + /** + * Called on each canvas change. + * Saves canvas content to the undo buffer after some period of inactivity. + */ + saveUndo = debounce(() => { + this.doSaveUndo(); + }, 100); + + /** + * Palette left click. + * Selects Fg color (or Bg, if Control/Meta is held) + * + * @param e - event + */ + onPaletteClick = (e) => { + const c = e.target.dataset.color; + + if (this.controlHeld) { + this.bg = c; + } else { + this.fg = c; + } + + e.target.blur(); + e.preventDefault(); + }; + + /** + * Palette right click. + * Selects Bg color + * + * @param e - event + */ + onPaletteRClick = (e) => { + this.bg = e.target.dataset.color; + e.target.blur(); + e.preventDefault(); + }; + + /** + * Handle click on the Draw mode button + * + * @param e - event + */ + setModeDraw = (e) => { + this.mode = 'draw'; + e.target.blur(); + }; + + /** + * Handle click on the Fill mode button + * + * @param e - event + */ + setModeFill = (e) => { + this.mode = 'fill'; + e.target.blur(); + }; + + /** + * Handle click on Smooth checkbox + * + * @param e - event + */ + tglSmooth = (e) => { + this.smoothing = !this.smoothing; + e.target.blur(); + }; + + /** + * Handle click on Adaptive checkbox + * + * @param e - event + */ + tglAdaptive = (e) => { + this.adaptiveStroke = !this.adaptiveStroke; + e.target.blur(); + }; + + /** + * Handle change of the Weight input field + * + * @param e - event + */ + setWeight = (e) => { + this.weight = +e.target.value || 1; + }; + + /** + * Set size - clalback from the select box + * + * @param e - event + */ + changeSize = (e) => { + let newSize = e.target.value; + if (newSize === this.oldSize) return; + + if (this.undos.length > 1 && !confirm('Change canvas size? This will erase your current drawing!')) { + return; + } + + this.size = newSize; + }; + + handleClearBtn = () => { + if (this.undos.length > 1 && !confirm('Clear canvas? This will erase your current drawing!')) { + return; + } + + this.clearScreen(); + }; + + /** + * Render the component + */ + render () { + this.updateSketcherSettings(); + + return ( + <div className='modal-root__modal doodle-modal'> + <div className='doodle-modal__container'> + <canvas ref={this.setCanvasRef} /> + </div> + + <div className='doodle-modal__action-bar'> + <div className='doodle-toolbar'> + <Button text='Done' onClick={this.onDoneButton} /> + <Button text='Cancel' onClick={this.onCancelButton} /> + </div> + <div className='filler' /> + <div className='doodle-toolbar with-inputs'> + <div> + <label htmlFor='dd_smoothing'>Smoothing</label> + <span className='val'> + <input type='checkbox' id='dd_smoothing' onChange={this.tglSmooth} checked={this.smoothing} /> + </span> + </div> + <div> + <label htmlFor='dd_adaptive'>Adaptive</label> + <span className='val'> + <input type='checkbox' id='dd_adaptive' onChange={this.tglAdaptive} checked={this.adaptiveStroke} /> + </span> + </div> + <div> + <label htmlFor='dd_weight'>Weight</label> + <span className='val'> + <input type='number' min={1} id='dd_weight' value={this.weight} onChange={this.setWeight} /> + </span> + </div> + <div> + <select aria-label='Canvas size' onInput={this.changeSize} defaultValue={this.size}> + { Object.values(mapValues(DOODLE_SIZES, (val, k) => + <option key={k} value={k}>{val[2]}</option>, + )) } + </select> + </div> + </div> + <div className='doodle-toolbar'> + <IconButton icon='pencil' title='Draw' label='Draw' onClick={this.setModeDraw} size={18} active={this.mode === 'draw'} inverted /> + <IconButton icon='bath' title='Fill' label='Fill' onClick={this.setModeFill} size={18} active={this.mode === 'fill'} inverted /> + <IconButton icon='undo' title='Undo' label='Undo' onClick={this.undo} size={18} inverted /> + <IconButton icon='trash' title='Clear' label='Clear' onClick={this.handleClearBtn} size={18} inverted /> + </div> + <div className='doodle-palette'> + { + palReordered.map((c, i) => + c === null ? + <br key={i} /> : + <button + key={i} + style={{ backgroundColor: c[0] }} + onClick={this.onPaletteClick} + onContextMenu={this.onPaletteRClick} + data-color={c[0]} + title={c[1]} + className={classNames({ + 'foreground': this.fg === c[0], + 'background': this.bg === c[0], + })} + />, + ) + } + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/drawer_loading.js b/app/javascript/flavours/glitch/features/ui/components/drawer_loading.js new file mode 100644 index 000000000..08b0d2347 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/drawer_loading.js @@ -0,0 +1,11 @@ +import React from 'react'; + +const DrawerLoading = () => ( + <div className='drawer'> + <div className='drawer__pager'> + <div className='drawer__inner' /> + </div> + </div> +); + +export default DrawerLoading; diff --git a/app/javascript/flavours/glitch/features/ui/components/embed_modal.js b/app/javascript/flavours/glitch/features/ui/components/embed_modal.js new file mode 100644 index 000000000..92bfa79c4 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/embed_modal.js @@ -0,0 +1,97 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import api from 'flavours/glitch/api'; +import IconButton from 'flavours/glitch/components/icon_button'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); + +export default @injectIntl +class EmbedModal extends ImmutablePureComponent { + + static propTypes = { + url: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + onError: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + loading: false, + oembed: null, + }; + + componentDidMount () { + const { url } = this.props; + + this.setState({ loading: true }); + + api().post('/api/web/embed', { url }).then(res => { + this.setState({ loading: false, oembed: res.data }); + + const iframeDocument = this.iframe.contentWindow.document; + + iframeDocument.open(); + iframeDocument.write(res.data.html); + iframeDocument.close(); + + iframeDocument.body.style.margin = 0; + this.iframe.width = iframeDocument.body.scrollWidth; + this.iframe.height = iframeDocument.body.scrollHeight; + }).catch(error => { + this.props.onError(error); + }); + } + + setIframeRef = c => { + this.iframe = c; + }; + + handleTextareaClick = (e) => { + e.target.select(); + }; + + render () { + const { intl, onClose } = this.props; + const { oembed } = this.state; + + return ( + <div className='modal-root__modal report-modal embed-modal'> + <div className='report-modal__target'> + <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} /> + <FormattedMessage id='status.embed' defaultMessage='Embed' /> + </div> + + <div className='report-modal__container embed-modal__container' style={{ display: 'block' }}> + <p className='hint'> + <FormattedMessage id='embed.instructions' defaultMessage='Embed this status on your website by copying the code below.' /> + </p> + + <input + type='text' + className='embed-modal__html' + readOnly + value={oembed && oembed.html || ''} + onClick={this.handleTextareaClick} + /> + + <p className='hint'> + <FormattedMessage id='embed.preview' defaultMessage='Here is what it will look like:' /> + </p> + + <iframe + className='embed-modal__iframe' + frameBorder='0' + ref={this.setIframeRef} + sandbox='allow-same-origin' + title='preview' + /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js b/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js new file mode 100644 index 000000000..78cbfeb51 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js @@ -0,0 +1,101 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Button from 'flavours/glitch/components/button'; +import StatusContent from 'flavours/glitch/components/status_content'; +import Avatar from 'flavours/glitch/components/avatar'; +import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp'; +import DisplayName from 'flavours/glitch/components/display_name'; +import AttachmentList from 'flavours/glitch/components/attachment_list'; +import Icon from 'flavours/glitch/components/icon'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import classNames from 'classnames'; +import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon'; + +const messages = defineMessages({ + favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, +}); + +export default @injectIntl +class FavouriteModal extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + onFavourite: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + componentDidMount() { + this.button.focus(); + } + + handleFavourite = () => { + this.props.onFavourite(this.props.status); + this.props.onClose(); + }; + + handleAccountClick = (e) => { + if (e.button === 0) { + e.preventDefault(); + this.props.onClose(); + let state = { ...this.context.router.history.location.state }; + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`, state); + } + }; + + setRef = (c) => { + this.button = c; + }; + + render () { + const { status, intl } = this.props; + + return ( + <div className='modal-root__modal boost-modal'> + <div className='boost-modal__container'> + <div className={classNames('status', `status-${status.get('visibility')}`, 'light')}> + <div className='boost-modal__status-header'> + <div className='boost-modal__status-time'> + <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'> + <VisibilityIcon visibility={status.get('visibility')} /> + <RelativeTimestamp timestamp={status.get('created_at')} /> + </a> + </div> + + <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'> + <div className='status__avatar'> + <Avatar account={status.get('account')} size={48} /> + </div> + + <DisplayName account={status.get('account')} /> + + </a> + </div> + + <StatusContent status={status} /> + + {status.get('media_attachments').size > 0 && ( + <AttachmentList + compact + media={status.get('media_attachments')} + /> + )} + </div> + </div> + + <div className='boost-modal__action-bar'> + <div><FormattedMessage id='favourite_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon id='star' /></span> }} /></div> + <Button text={intl.formatMessage(messages.favourite)} onClick={this.handleFavourite} ref={this.setRef} /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/filter_modal.js b/app/javascript/flavours/glitch/features/ui/components/filter_modal.js new file mode 100644 index 000000000..d2482e733 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/filter_modal.js @@ -0,0 +1,134 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { fetchStatus } from 'flavours/glitch/actions/statuses'; +import { fetchFilters, createFilter, createFilterStatus } from 'flavours/glitch/actions/filters'; +import PropTypes from 'prop-types'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import IconButton from 'flavours/glitch/components/icon_button'; +import SelectFilter from 'flavours/glitch/features/filters/select_filter'; +import AddedToFilter from 'flavours/glitch/features/filters/added_to_filter'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); + +export default @connect(undefined) +@injectIntl +class FilterModal extends ImmutablePureComponent { + + static propTypes = { + statusId: PropTypes.string.isRequired, + contextType: PropTypes.string, + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + step: 'select', + filterId: null, + isSubmitting: false, + isSubmitted: false, + }; + + handleNewFilterSuccess = (result) => { + this.handleSelectFilter(result.id); + }; + + handleSuccess = () => { + const { dispatch, statusId } = this.props; + dispatch(fetchStatus(statusId, true)); + this.setState({ isSubmitting: false, isSubmitted: true, step: 'submitted' }); + }; + + handleFail = () => { + this.setState({ isSubmitting: false }); + }; + + handleNextStep = step => { + this.setState({ step }); + }; + + handleSelectFilter = (filterId) => { + const { dispatch, statusId } = this.props; + + this.setState({ isSubmitting: true, filterId }); + + dispatch(createFilterStatus({ + filter_id: filterId, + status_id: statusId, + }, this.handleSuccess, this.handleFail)); + }; + + handleNewFilter = (title) => { + const { dispatch } = this.props; + + this.setState({ isSubmitting: true }); + + dispatch(createFilter({ + title, + context: ['home', 'notifications', 'public', 'thread', 'account'], + action: 'warn', + }, this.handleNewFilterSuccess, this.handleFail)); + }; + + componentDidMount () { + const { dispatch } = this.props; + + dispatch(fetchFilters()); + } + + render () { + const { + intl, + statusId, + contextType, + onClose, + } = this.props; + + const { + step, + filterId, + } = this.state; + + let stepComponent; + + switch(step) { + case 'select': + stepComponent = ( + <SelectFilter + contextType={contextType} + onSelectFilter={this.handleSelectFilter} + onNewFilter={this.handleNewFilter} + /> + ); + break; + case 'create': + stepComponent = null; + break; + case 'submitted': + stepComponent = ( + <AddedToFilter + contextType={contextType} + filterId={filterId} + statusId={statusId} + onClose={onClose} + /> + ); + } + + return ( + <div className='modal-root__modal report-dialog-modal'> + <div className='report-modal__target'> + <IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} /> + <FormattedMessage id='filter_modal.title.status' defaultMessage='Filter a post' /> + </div> + + <div className='report-dialog-modal__container'> + {stepComponent} + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js new file mode 100644 index 000000000..e36657fab --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js @@ -0,0 +1,419 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; +import classNames from 'classnames'; +import { changeUploadCompose, uploadThumbnail, onChangeMediaDescription, onChangeMediaFocus } from 'flavours/glitch/actions/compose'; +import { getPointerPosition } from 'flavours/glitch/features/video'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; +import IconButton from 'flavours/glitch/components/icon_button'; +import Button from 'flavours/glitch/components/button'; +import Video from 'flavours/glitch/features/video'; +import Audio from 'flavours/glitch/features/audio'; +import Textarea from 'react-textarea-autosize'; +import UploadProgress from 'flavours/glitch/features/compose/components/upload_progress'; +import CharacterCounter from 'flavours/glitch/features/compose/components/character_counter'; +import { length } from 'stringz'; +import { Tesseract as fetchTesseract } from 'flavours/glitch/features/ui/util/async-components'; +import GIFV from 'flavours/glitch/components/gifv'; +import { me } from 'flavours/glitch/initial_state'; +// eslint-disable-next-line import/no-extraneous-dependencies +import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js'; +// eslint-disable-next-line import/extensions +import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js'; +import { assetHost } from 'flavours/glitch/utils/config'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, + apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' }, + applying: { id: 'upload_modal.applying', defaultMessage: 'Applying…' }, + placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' }, + chooseImage: { id: 'upload_modal.choose_image', defaultMessage: 'Choose image' }, + discardMessage: { id: 'confirmations.discard_edit_media.message', defaultMessage: 'You have unsaved changes to the media description or preview, discard them anyway?' }, + discardConfirm: { id: 'confirmations.discard_edit_media.confirm', defaultMessage: 'Discard' }, +}); + +const mapStateToProps = (state, { id }) => ({ + media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), + account: state.getIn(['accounts', me]), + isUploadingThumbnail: state.getIn(['compose', 'isUploadingThumbnail']), + description: state.getIn(['compose', 'media_modal', 'description']), + lang: state.getIn(['compose', 'language']), + focusX: state.getIn(['compose', 'media_modal', 'focusX']), + focusY: state.getIn(['compose', 'media_modal', 'focusY']), + dirty: state.getIn(['compose', 'media_modal', 'dirty']), + is_changing_upload: state.getIn(['compose', 'is_changing_upload']), +}); + +const mapDispatchToProps = (dispatch, { id }) => ({ + + onSave: (description, x, y) => { + dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` })); + }, + + onChangeDescription: (description) => { + dispatch(onChangeMediaDescription(description)); + }, + + onChangeFocus: (focusX, focusY) => { + dispatch(onChangeMediaFocus(focusX, focusY)); + }, + + onSelectThumbnail: files => { + dispatch(uploadThumbnail(id, files[0])); + }, + +}); + +const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******') + .replace(/\n/g, ' ') + .replace(/\*\*\*\*\*\*/g, '\n\n'); + +class ImageLoader extends React.PureComponent { + + static propTypes = { + src: PropTypes.string.isRequired, + width: PropTypes.number, + height: PropTypes.number, + }; + + state = { + loading: true, + }; + + componentDidMount() { + const image = new Image(); + image.addEventListener('load', () => this.setState({ loading: false })); + image.src = this.props.src; + } + + render () { + const { loading } = this.state; + + if (loading) { + return <canvas width={this.props.width} height={this.props.height} />; + } else { + return <img {...this.props} alt='' />; + } + } + +} + +export default @connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true }) +@(component => injectIntl(component, { withRef: true })) +class FocalPointModal extends ImmutablePureComponent { + + static propTypes = { + media: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.map.isRequired, + isUploadingThumbnail: PropTypes.bool, + onSave: PropTypes.func.isRequired, + onChangeDescription: PropTypes.func.isRequired, + onChangeFocus: PropTypes.func.isRequired, + onSelectThumbnail: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + dragging: false, + dirty: false, + progress: 0, + loading: true, + ocrStatus: '', + }; + + componentWillUnmount () { + document.removeEventListener('mousemove', this.handleMouseMove); + document.removeEventListener('mouseup', this.handleMouseUp); + } + + handleMouseDown = e => { + document.addEventListener('mousemove', this.handleMouseMove); + document.addEventListener('mouseup', this.handleMouseUp); + + this.updatePosition(e); + this.setState({ dragging: true }); + }; + + handleTouchStart = e => { + document.addEventListener('touchmove', this.handleMouseMove); + document.addEventListener('touchend', this.handleTouchEnd); + + this.updatePosition(e); + this.setState({ dragging: true }); + }; + + handleMouseMove = e => { + this.updatePosition(e); + }; + + handleMouseUp = () => { + document.removeEventListener('mousemove', this.handleMouseMove); + document.removeEventListener('mouseup', this.handleMouseUp); + + this.setState({ dragging: false }); + }; + + handleTouchEnd = () => { + document.removeEventListener('touchmove', this.handleMouseMove); + document.removeEventListener('touchend', this.handleTouchEnd); + + this.setState({ dragging: false }); + }; + + updatePosition = e => { + const { x, y } = getPointerPosition(this.node, e); + const focusX = (x - .5) * 2; + const focusY = (y - .5) * -2; + + this.props.onChangeFocus(focusX, focusY); + }; + + handleChange = e => { + this.props.onChangeDescription(e.target.value); + }; + + handleKeyDown = (e) => { + if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + e.stopPropagation(); + this.props.onChangeDescription(e.target.value); + this.handleSubmit(); + } + }; + + handleSubmit = () => { + this.props.onSave(this.props.description, this.props.focusX, this.props.focusY); + }; + + getCloseConfirmationMessage = () => { + const { intl, dirty } = this.props; + + if (dirty) { + return { + message: intl.formatMessage(messages.discardMessage), + confirm: intl.formatMessage(messages.discardConfirm), + }; + } else { + return null; + } + }; + + setRef = c => { + this.node = c; + }; + + handleTextDetection = () => { + this._detectText(); + }; + + _detectText = (refreshCache = false) => { + const { media } = this.props; + + this.setState({ detecting: true }); + + fetchTesseract().then(({ createWorker }) => { + const worker = createWorker({ + workerPath: tesseractWorkerPath, + corePath: tesseractCorePath, + langPath: `${assetHost}/ocr/lang-data/`, + logger: ({ status, progress }) => { + if (status === 'recognizing text') { + this.setState({ ocrStatus: 'detecting', progress }); + } else { + this.setState({ ocrStatus: 'preparing', progress }); + } + }, + cacheMethod: refreshCache ? 'refresh' : 'write', + }); + + let media_url = media.get('url'); + + if (window.URL && URL.createObjectURL) { + try { + media_url = URL.createObjectURL(media.get('file')); + } catch (error) { + console.error(error); + } + } + + return (async () => { + await worker.load(); + await worker.loadLanguage('eng'); + await worker.initialize('eng'); + const { data: { text } } = await worker.recognize(media_url); + this.setState({ detecting: false }); + this.props.onChangeDescription(removeExtraLineBreaks(text)); + await worker.terminate(); + })().catch((e) => { + if (refreshCache) { + throw e; + } else { + this._detectText(true); + } + }); + }).catch((e) => { + console.error(e); + this.setState({ detecting: false }); + }); + }; + + handleThumbnailChange = e => { + if (e.target.files.length > 0) { + this.props.onSelectThumbnail(e.target.files); + } + }; + + setFileInputRef = c => { + this.fileInput = c; + }; + + handleFileInputClick = () => { + this.fileInput.click(); + }; + + render () { + const { media, intl, account, onClose, isUploadingThumbnail, description, lang, focusX, focusY, dirty, is_changing_upload } = this.props; + const { dragging, detecting, progress, ocrStatus } = this.state; + const x = (focusX / 2) + .5; + const y = (focusY / -2) + .5; + + const width = media.getIn(['meta', 'original', 'width']) || null; + const height = media.getIn(['meta', 'original', 'height']) || null; + const focals = ['image', 'gifv'].includes(media.get('type')); + const thumbnailable = ['audio', 'video'].includes(media.get('type')); + + const previewRatio = 16/9; + const previewWidth = 200; + const previewHeight = previewWidth / previewRatio; + + let descriptionLabel = null; + + if (media.get('type') === 'audio') { + descriptionLabel = <FormattedMessage id='upload_form.audio_description' defaultMessage='Describe for people who are hard of hearing' />; + } else if (media.get('type') === 'video') { + descriptionLabel = <FormattedMessage id='upload_form.video_description' defaultMessage='Describe for people who are deaf, hard of hearing, blind or have low vision' />; + } else { + descriptionLabel = <FormattedMessage id='upload_form.description' defaultMessage='Describe for people who are blind or have low vision' />; + } + + let ocrMessage = ''; + if (ocrStatus === 'detecting') { + ocrMessage = <FormattedMessage id='upload_modal.analyzing_picture' defaultMessage='Analyzing picture…' />; + } else { + ocrMessage = <FormattedMessage id='upload_modal.preparing_ocr' defaultMessage='Preparing OCR…' />; + } + + return ( + <div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}> + <div className='report-modal__target'> + <IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} /> + <FormattedMessage id='upload_modal.edit_media' defaultMessage='Edit media' /> + </div> + + <div className='report-modal__container'> + <div className='report-modal__comment'> + {focals && <p><FormattedMessage id='upload_modal.hint' defaultMessage='Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.' /></p>} + + {thumbnailable && ( + <React.Fragment> + <label className='setting-text-label' htmlFor='upload-modal__thumbnail'><FormattedMessage id='upload_form.thumbnail' defaultMessage='Change thumbnail' /></label> + + <Button disabled={isUploadingThumbnail || !media.get('unattached')} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} /> + + <label> + <span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span> + + <input + id='upload-modal__thumbnail' + ref={this.setFileInputRef} + type='file' + accept='image/png,image/jpeg' + onChange={this.handleThumbnailChange} + style={{ display: 'none' }} + disabled={isUploadingThumbnail || is_changing_upload} + /> + </label> + + <hr className='setting-divider' /> + </React.Fragment> + )} + + <label className='setting-text-label' htmlFor='upload-modal__description'> + {descriptionLabel} + </label> + + <div className='setting-text__wrapper'> + <Textarea + id='upload-modal__description' + className='setting-text light' + value={detecting ? '…' : description} + lang={lang} + onChange={this.handleChange} + onKeyDown={this.handleKeyDown} + disabled={detecting || is_changing_upload} + autoFocus + /> + + <div className='setting-text__modifiers'> + <UploadProgress progress={progress * 100} active={detecting} icon='file-text-o' message={ocrMessage} /> + </div> + </div> + + <div className='setting-text__toolbar'> + <button disabled={detecting || media.get('type') !== 'image' || is_changing_upload} className='link-button' onClick={this.handleTextDetection}><FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' /></button> + <CharacterCounter max={1500} text={detecting ? '' : description} /> + </div> + + <Button disabled={!dirty || detecting || isUploadingThumbnail || length(description) > 1500 || is_changing_upload} text={intl.formatMessage(is_changing_upload ? messages.applying : messages.apply)} onClick={this.handleSubmit} /> + </div> + + <div className='focal-point-modal__content'> + {focals && ( + <div className={classNames('focal-point', { dragging })} ref={this.setRef} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart}> + {media.get('type') === 'image' && <ImageLoader src={media.get('url')} width={width} height={height} alt='' />} + {media.get('type') === 'gifv' && <GIFV src={media.get('url')} width={width} height={height} />} + + <div className='focal-point__preview'> + <strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong> + <div style={{ width: previewWidth, height: previewHeight, backgroundImage: `url(${media.get('preview_url')})`, backgroundSize: 'cover', backgroundPosition: `${x * 100}% ${y * 100}%` }} /> + </div> + + <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} /> + <div className='focal-point__overlay' /> + </div> + )} + + {media.get('type') === 'video' && ( + <Video + preview={media.get('preview_url')} + frameRate={media.getIn(['meta', 'original', 'frame_rate'])} + blurhash={media.get('blurhash')} + src={media.get('url')} + detailed + inline + editable + /> + )} + + {media.get('type') === 'audio' && ( + <Audio + src={media.get('url')} + duration={media.getIn(['meta', 'original', 'duration'], 0)} + height={150} + poster={media.get('preview_url') || account.get('avatar_static')} + backgroundColor={media.getIn(['meta', 'colors', 'background'])} + foregroundColor={media.getIn(['meta', 'colors', 'foreground'])} + accentColor={media.getIn(['meta', 'colors', 'accent'])} + editable + /> + )} + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/follow_requests_column_link.js b/app/javascript/flavours/glitch/features/ui/components/follow_requests_column_link.js new file mode 100644 index 000000000..301392a52 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/follow_requests_column_link.js @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { fetchFollowRequests } from 'flavours/glitch/actions/accounts'; +import { connect } from 'react-redux'; +import ColumnLink from 'flavours/glitch/features/ui/components/column_link'; +import IconWithBadge from 'flavours/glitch/components/icon_with_badge'; +import { List as ImmutableList } from 'immutable'; +import { injectIntl, defineMessages } from 'react-intl'; + +const messages = defineMessages({ + text: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, +}); + +const mapStateToProps = state => ({ + count: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size, +}); + +export default @injectIntl +@connect(mapStateToProps) +class FollowRequestsColumnLink extends React.Component { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + count: PropTypes.number.isRequired, + intl: PropTypes.object.isRequired, + }; + + componentDidMount () { + const { dispatch } = this.props; + + dispatch(fetchFollowRequests()); + } + + render () { + const { count, intl } = this.props; + + if (count === 0) { + return null; + } + + return ( + <ColumnLink + transparent + to='/follow_requests' + icon={<IconWithBadge className='column-link__icon' id='user-plus' count={count} />} + text={intl.formatMessage(messages.text)} + /> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/header.js b/app/javascript/flavours/glitch/features/ui/components/header.js new file mode 100644 index 000000000..d9ad94961 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/header.js @@ -0,0 +1,88 @@ +import React from 'react'; +import Logo from 'flavours/glitch/components/logo'; +import { Link, withRouter } from 'react-router-dom'; +import { FormattedMessage } from 'react-intl'; +import { registrationsOpen, me } from 'flavours/glitch/initial_state'; +import Avatar from 'flavours/glitch/components/avatar'; +import Permalink from 'flavours/glitch/components/permalink'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { openModal } from 'flavours/glitch/actions/modal'; + +const Account = connect(state => ({ + account: state.getIn(['accounts', me]), +}))(({ account }) => ( + <Permalink href={account.get('url')} to={`/@${account.get('acct')}`} title={account.get('acct')}> + <Avatar account={account} size={35} /> + </Permalink> +)); + +const mapDispatchToProps = (dispatch) => ({ + openClosedRegistrationsModal() { + dispatch(openModal('CLOSED_REGISTRATIONS')); + }, +}); + +export default @connect(null, mapDispatchToProps) +@withRouter +class Header extends React.PureComponent { + + static contextTypes = { + identity: PropTypes.object, + }; + + static propTypes = { + openClosedRegistrationsModal: PropTypes.func, + location: PropTypes.object, + }; + + render () { + const { signedIn } = this.context.identity; + const { location, openClosedRegistrationsModal } = this.props; + + let content; + + if (signedIn) { + content = ( + <> + {location.pathname !== '/publish' && <Link to='/publish' className='button'><FormattedMessage id='compose_form.publish_form' defaultMessage='Publish' /></Link>} + <Account /> + </> + ); + } else { + let signupButton; + + if (registrationsOpen) { + signupButton = ( + <a href='/auth/sign_up' className='button button-tertiary'> + <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' /> + </a> + ); + } else { + signupButton = ( + <button className='button button-tertiary' onClick={openClosedRegistrationsModal}> + <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' /> + </button> + ); + } + + content = ( + <> + <a href='/auth/sign_in' className='button'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a> + {signupButton} + </> + ); + } + + return ( + <div className='ui__header'> + <Link to='/' className='ui__header__logo'><Logo /></Link> + + <div className='ui__header__links'> + {content} + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/image_loader.js b/app/javascript/flavours/glitch/features/ui/components/image_loader.js new file mode 100644 index 000000000..92aeef5c4 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/image_loader.js @@ -0,0 +1,168 @@ +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React, { PureComponent } from 'react'; +import { LoadingBar } from 'react-redux-loading-bar'; +import ZoomableImage from './zoomable_image'; + +export default class ImageLoader extends PureComponent { + + static propTypes = { + alt: PropTypes.string, + src: PropTypes.string.isRequired, + previewSrc: PropTypes.string, + width: PropTypes.number, + height: PropTypes.number, + onClick: PropTypes.func, + zoomButtonHidden: PropTypes.bool, + }; + + static defaultProps = { + alt: '', + width: null, + height: null, + }; + + state = { + loading: true, + error: false, + width: null, + }; + + removers = []; + canvas = null; + + get canvasContext() { + if (!this.canvas) { + return null; + } + this._canvasContext = this._canvasContext || this.canvas.getContext('2d'); + return this._canvasContext; + } + + componentDidMount () { + this.loadImage(this.props); + } + + UNSAFE_componentWillReceiveProps (nextProps) { + if (this.props.src !== nextProps.src) { + this.loadImage(nextProps); + } + } + + componentWillUnmount () { + this.removeEventListeners(); + } + + loadImage (props) { + this.removeEventListeners(); + this.setState({ loading: true, error: false }); + Promise.all([ + props.previewSrc && this.loadPreviewCanvas(props), + this.hasSize() && this.loadOriginalImage(props), + ].filter(Boolean)) + .then(() => { + this.setState({ loading: false, error: false }); + this.clearPreviewCanvas(); + }) + .catch(() => this.setState({ loading: false, error: true })); + } + + loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => { + const image = new Image(); + const removeEventListeners = () => { + image.removeEventListener('error', handleError); + image.removeEventListener('load', handleLoad); + }; + const handleError = () => { + removeEventListeners(); + reject(); + }; + const handleLoad = () => { + removeEventListeners(); + this.canvasContext.drawImage(image, 0, 0, width, height); + resolve(); + }; + image.addEventListener('error', handleError); + image.addEventListener('load', handleLoad); + image.src = previewSrc; + this.removers.push(removeEventListeners); + }); + + clearPreviewCanvas () { + const { width, height } = this.canvas; + this.canvasContext.clearRect(0, 0, width, height); + } + + loadOriginalImage = ({ src }) => new Promise((resolve, reject) => { + const image = new Image(); + const removeEventListeners = () => { + image.removeEventListener('error', handleError); + image.removeEventListener('load', handleLoad); + }; + const handleError = () => { + removeEventListeners(); + reject(); + }; + const handleLoad = () => { + removeEventListeners(); + resolve(); + }; + image.addEventListener('error', handleError); + image.addEventListener('load', handleLoad); + image.src = src; + this.removers.push(removeEventListeners); + }); + + removeEventListeners () { + this.removers.forEach(listeners => listeners()); + this.removers = []; + } + + hasSize () { + const { width, height } = this.props; + return typeof width === 'number' && typeof height === 'number'; + } + + setCanvasRef = c => { + this.canvas = c; + if (c) this.setState({ width: c.offsetWidth }); + }; + + render () { + const { alt, src, width, height, onClick } = this.props; + const { loading } = this.state; + + const className = classNames('image-loader', { + 'image-loader--loading': loading, + 'image-loader--amorphous': !this.hasSize(), + }); + + return ( + <div className={className}> + {loading ? ( + <> + <div className='loading-bar__container' style={{ width: this.state.width || width }}> + <LoadingBar className='loading-bar' loading={1} /> + </div> + <canvas + className='image-loader__preview-canvas' + ref={this.setCanvasRef} + width={width} + height={height} + /> + </> + ) : ( + <ZoomableImage + alt={alt} + src={src} + onClick={onClick} + width={width} + height={height} + zoomButtonHidden={this.props.zoomButtonHidden} + /> + )} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/image_modal.js b/app/javascript/flavours/glitch/features/ui/components/image_modal.js new file mode 100644 index 000000000..a792b9be7 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/image_modal.js @@ -0,0 +1,59 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { defineMessages, injectIntl } from 'react-intl'; +import IconButton from 'flavours/glitch/components/icon_button'; +import ImageLoader from './image_loader'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); + +export default @injectIntl +class ImageModal extends React.PureComponent { + + static propTypes = { + src: PropTypes.string.isRequired, + alt: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + navigationHidden: false, + }; + + toggleNavigation = () => { + this.setState(prevState => ({ + navigationHidden: !prevState.navigationHidden, + })); + }; + + render () { + const { intl, src, alt, onClose } = this.props; + const { navigationHidden } = this.state; + + const navigationClassName = classNames('media-modal__navigation', { + 'media-modal__navigation--hidden': navigationHidden, + }); + + return ( + <div className='modal-root__modal media-modal'> + <div className='media-modal__closer' role='presentation' onClick={onClose} > + <ImageLoader + src={src} + width={400} + height={400} + alt={alt} + onClick={this.toggleNavigation} + /> + </div> + + <div className={navigationClassName}> + <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/link_footer.js b/app/javascript/flavours/glitch/features/ui/components/link_footer.js new file mode 100644 index 000000000..0d1d4040f --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/link_footer.js @@ -0,0 +1,102 @@ +import { connect } from 'react-redux'; +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; +import { Link } from 'react-router-dom'; +import { domain, version, source_url, statusPageUrl, profile_directory as profileDirectory } from 'flavours/glitch/initial_state'; +import { logOut } from 'flavours/glitch/utils/log_out'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { PERMISSION_INVITE_USERS } from 'flavours/glitch/permissions'; + +const messages = defineMessages({ + logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' }, + logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' }, +}); + +const mapDispatchToProps = (dispatch, { intl }) => ({ + onLogout () { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.logoutMessage), + confirm: intl.formatMessage(messages.logoutConfirm), + closeWhenConfirm: false, + onConfirm: () => logOut(), + })); + }, +}); + +export default @injectIntl +@connect(null, mapDispatchToProps) +class LinkFooter extends React.PureComponent { + + static contextTypes = { + identity: PropTypes.object, + }; + + static propTypes = { + onLogout: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleLogoutClick = e => { + e.preventDefault(); + e.stopPropagation(); + + this.props.onLogout(); + + return false; + }; + + render () { + const { signedIn, permissions } = this.context.identity; + + const canInvite = signedIn && ((permissions & PERMISSION_INVITE_USERS) === PERMISSION_INVITE_USERS); + const canProfileDirectory = profileDirectory; + + const DividingCircle = <span aria-hidden>{' · '}</span>; + + return ( + <div className='link-footer'> + <p> + <strong>{domain}</strong>: + {' '} + <Link to='/about'><FormattedMessage id='footer.about' defaultMessage='About' /></Link> + {statusPageUrl && ( + <> + {DividingCircle} + <a href={statusPageUrl} target='_blank' rel='noopener'><FormattedMessage id='footer.status' defaultMessage='Status' /></a> + </> + )} + {canInvite && ( + <> + {DividingCircle} + <a href='/invites' target='_blank'><FormattedMessage id='footer.invite' defaultMessage='Invite people' /></a> + </> + )} + {canProfileDirectory && ( + <> + {DividingCircle} + <Link to='/directory'><FormattedMessage id='footer.directory' defaultMessage='Profiles directory' /></Link> + </> + )} + {DividingCircle} + <Link to='/privacy-policy'><FormattedMessage id='footer.privacy_policy' defaultMessage='Privacy policy' /></Link> + </p> + + <p> + <strong>Mastodon</strong>: + {' '} + <a href='https://joinmastodon.org' target='_blank'><FormattedMessage id='footer.about' defaultMessage='About' /></a> + {DividingCircle} + <a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='footer.get_app' defaultMessage='Get the app' /></a> + {DividingCircle} + <Link to='/keyboard-shortcuts'><FormattedMessage id='footer.keyboard_shortcuts' defaultMessage='Keyboard shortcuts' /></Link> + {DividingCircle} + <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='footer.source_code' defaultMessage='View source code' /></a> + {DividingCircle} + v{version} + </p> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/list_panel.js b/app/javascript/flavours/glitch/features/ui/components/list_panel.js new file mode 100644 index 000000000..dff830065 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/list_panel.js @@ -0,0 +1,55 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { createSelector } from 'reselect'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; +import { withRouter } from 'react-router-dom'; +import { fetchLists } from 'flavours/glitch/actions/lists'; +import ColumnLink from './column_link'; + +const getOrderedLists = createSelector([state => state.get('lists')], lists => { + if (!lists) { + return lists; + } + + return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))).take(4); +}); + +const mapStateToProps = state => ({ + lists: getOrderedLists(state), +}); + +export default @withRouter +@connect(mapStateToProps) +class ListPanel extends ImmutablePureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + lists: ImmutablePropTypes.list, + }; + + componentDidMount () { + const { dispatch } = this.props; + dispatch(fetchLists()); + } + + render () { + const { lists } = this.props; + + if (!lists || lists.isEmpty()) { + return null; + } + + return ( + <div className='list-panel'> + <hr /> + + {lists.map(list => ( + <ColumnLink icon='list-ul' key={list.get('id')} strict text={list.get('title')} to={`/lists/${list.get('id')}`} transparent /> + ))} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/media_modal.js b/app/javascript/flavours/glitch/features/ui/components/media_modal.js new file mode 100644 index 000000000..24559264e --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.js @@ -0,0 +1,252 @@ +import React from 'react'; +import ReactSwipeableViews from 'react-swipeable-views'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Video from 'flavours/glitch/features/video'; +import classNames from 'classnames'; +import { defineMessages, injectIntl } from 'react-intl'; +import IconButton from 'flavours/glitch/components/icon_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImageLoader from './image_loader'; +import Icon from 'flavours/glitch/components/icon'; +import GIFV from 'flavours/glitch/components/gifv'; +import Footer from 'flavours/glitch/features/picture_in_picture/components/footer'; +import { getAverageFromBlurhash } from 'flavours/glitch/blurhash'; +import { disableSwiping } from 'flavours/glitch/initial_state'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, + previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, + next: { id: 'lightbox.next', defaultMessage: 'Next' }, +}); + +export default @injectIntl +class MediaModal extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + media: ImmutablePropTypes.list.isRequired, + statusId: PropTypes.string, + index: PropTypes.number.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + onChangeBackgroundColor: PropTypes.func.isRequired, + currentTime: PropTypes.number, + autoPlay: PropTypes.bool, + volume: PropTypes.number, + }; + + state = { + index: null, + navigationHidden: false, + zoomButtonHidden: false, + }; + + handleSwipe = (index) => { + this.setState({ index: index % this.props.media.size }); + }; + + handleTransitionEnd = () => { + this.setState({ + zoomButtonHidden: false, + }); + }; + + handleNextClick = () => { + this.setState({ + index: (this.getIndex() + 1) % this.props.media.size, + zoomButtonHidden: true, + }); + }; + + handlePrevClick = () => { + this.setState({ + index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size, + zoomButtonHidden: true, + }); + }; + + handleChangeIndex = (e) => { + const index = Number(e.currentTarget.getAttribute('data-index')); + + this.setState({ + index: index % this.props.media.size, + zoomButtonHidden: true, + }); + }; + + handleKeyDown = (e) => { + switch(e.key) { + case 'ArrowLeft': + this.handlePrevClick(); + e.preventDefault(); + e.stopPropagation(); + break; + case 'ArrowRight': + this.handleNextClick(); + e.preventDefault(); + e.stopPropagation(); + break; + } + }; + + componentDidMount () { + window.addEventListener('keydown', this.handleKeyDown, false); + this._sendBackgroundColor(); + } + + componentWillUnmount () { + window.removeEventListener('keydown', this.handleKeyDown); + this.props.onChangeBackgroundColor(null); + } + + getIndex () { + return this.state.index !== null ? this.state.index : this.props.index; + } + + toggleNavigation = () => { + this.setState(prevState => ({ + navigationHidden: !prevState.navigationHidden, + })); + }; + + componentDidUpdate (prevProps, prevState) { + if (prevState.index !== this.state.index) { + this._sendBackgroundColor(); + } + } + + _sendBackgroundColor () { + const { media, onChangeBackgroundColor } = this.props; + const index = this.getIndex(); + const blurhash = media.getIn([index, 'blurhash']); + + if (blurhash) { + const backgroundColor = getAverageFromBlurhash(blurhash); + onChangeBackgroundColor(backgroundColor); + } + } + + render () { + const { media, statusId, intl, onClose } = this.props; + const { navigationHidden } = this.state; + + const index = this.getIndex(); + + const leftNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><Icon id='chevron-left' fixedWidth /></button>; + const rightNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><Icon id='chevron-right' fixedWidth /></button>; + + const content = media.map((image) => { + const width = image.getIn(['meta', 'original', 'width']) || null; + const height = image.getIn(['meta', 'original', 'height']) || null; + + if (image.get('type') === 'image') { + return ( + <ImageLoader + previewSrc={image.get('preview_url')} + src={image.get('url')} + width={width} + height={height} + alt={image.get('description')} + key={image.get('url')} + onClick={this.toggleNavigation} + zoomButtonHidden={this.state.zoomButtonHidden} + /> + ); + } else if (image.get('type') === 'video') { + const { currentTime, autoPlay, volume } = this.props; + + return ( + <Video + preview={image.get('preview_url')} + blurhash={image.get('blurhash')} + src={image.get('url')} + width={image.get('width')} + height={image.get('height')} + frameRate={image.getIn(['meta', 'original', 'frame_rate'])} + currentTime={currentTime || 0} + autoPlay={autoPlay || false} + volume={volume || 1} + onCloseVideo={onClose} + detailed + alt={image.get('description')} + key={image.get('url')} + /> + ); + } else if (image.get('type') === 'gifv') { + return ( + <GIFV + src={image.get('url')} + width={width} + height={height} + key={image.get('preview_url')} + alt={image.get('description')} + onClick={this.toggleNavigation} + /> + ); + } + + return null; + }).toArray(); + + // you can't use 100vh, because the viewport height is taller + // than the visible part of the document in some mobile + // browsers when it's address bar is visible. + // https://developers.google.com/web/updates/2016/12/url-bar-resizing + const swipeableViewsStyle = { + width: '100%', + height: '100%', + }; + + const containerStyle = { + alignItems: 'center', // center vertically + }; + + const navigationClassName = classNames('media-modal__navigation', { + 'media-modal__navigation--hidden': navigationHidden, + }); + + let pagination; + + if (media.size > 1) { + pagination = media.map((item, i) => ( + <button key={i} className={classNames('media-modal__page-dot', { active: i === index })} data-index={i} onClick={this.handleChangeIndex}> + {i + 1} + </button> + )); + } + + return ( + <div className='modal-root__modal media-modal'> + <div className='media-modal__closer' role='presentation' onClick={onClose} > + <ReactSwipeableViews + style={swipeableViewsStyle} + containerStyle={containerStyle} + onChangeIndex={this.handleSwipe} + onTransitionEnd={this.handleTransitionEnd} + index={index} + disabled={disableSwiping} + > + {content} + </ReactSwipeableViews> + </div> + + <div className={navigationClassName}> + <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} /> + + {leftNav} + {rightNav} + + <div className='media-modal__overlay'> + {pagination && <ul className='media-modal__pagination'>{pagination}</ul>} + {statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />} + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_loading.js b/app/javascript/flavours/glitch/features/ui/components/modal_loading.js new file mode 100644 index 000000000..b1c322154 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/modal_loading.js @@ -0,0 +1,20 @@ +import React from 'react'; + +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; + +// Keep the markup in sync with <BundleModalError /> +// (make sure they have the same dimensions) +const ModalLoading = () => ( + <div className='modal-root__modal error-modal'> + <div className='error-modal__body'> + <LoadingIndicator /> + </div> + <div className='error-modal__footer'> + <div> + <button className='error-modal__nav onboarding-modal__skip' /> + </div> + </div> + </div> +); + +export default ModalLoading; diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_root.js b/app/javascript/flavours/glitch/features/ui/components/modal_root.js new file mode 100644 index 000000000..d04a2d53a --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.js @@ -0,0 +1,144 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { getScrollbarWidth } from 'flavours/glitch/utils/scrollbar'; +import Base from 'flavours/glitch/components/modal_root'; +import BundleContainer from '../containers/bundle_container'; +import BundleModalError from './bundle_modal_error'; +import ModalLoading from './modal_loading'; +import ActionsModal from './actions_modal'; +import MediaModal from './media_modal'; +import VideoModal from './video_modal'; +import BoostModal from './boost_modal'; +import FavouriteModal from './favourite_modal'; +import AudioModal from './audio_modal'; +import DoodleModal from './doodle_modal'; +import ConfirmationModal from './confirmation_modal'; +import FocalPointModal from './focal_point_modal'; +import DeprecatedSettingsModal from './deprecated_settings_modal'; +import ImageModal from './image_modal'; +import { + OnboardingModal, + MuteModal, + BlockModal, + ReportModal, + SettingsModal, + EmbedModal, + ListEditor, + ListAdder, + PinnedAccountsEditor, + CompareHistoryModal, + FilterModal, + InteractionModal, + SubscribedLanguagesModal, + ClosedRegistrationsModal, +} from 'flavours/glitch/features/ui/util/async-components'; +import { Helmet } from 'react-helmet'; + +const MODAL_COMPONENTS = { + 'MEDIA': () => Promise.resolve({ default: MediaModal }), + 'ONBOARDING': OnboardingModal, + 'VIDEO': () => Promise.resolve({ default: VideoModal }), + 'AUDIO': () => Promise.resolve({ default: AudioModal }), + 'IMAGE': () => Promise.resolve({ default: ImageModal }), + 'BOOST': () => Promise.resolve({ default: BoostModal }), + 'FAVOURITE': () => Promise.resolve({ default: FavouriteModal }), + 'DOODLE': () => Promise.resolve({ default: DoodleModal }), + 'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }), + 'MUTE': MuteModal, + 'BLOCK': BlockModal, + 'REPORT': ReportModal, + 'SETTINGS': SettingsModal, + 'DEPRECATED_SETTINGS': () => Promise.resolve({ default: DeprecatedSettingsModal }), + 'ACTIONS': () => Promise.resolve({ default: ActionsModal }), + 'EMBED': EmbedModal, + 'LIST_EDITOR': ListEditor, + 'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }), + 'LIST_ADDER': ListAdder, + 'PINNED_ACCOUNTS_EDITOR': PinnedAccountsEditor, + 'COMPARE_HISTORY': CompareHistoryModal, + 'FILTER': FilterModal, + 'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal, + 'INTERACTION': InteractionModal, + 'CLOSED_REGISTRATIONS': ClosedRegistrationsModal, +}; + +export default class ModalRoot extends React.PureComponent { + + static propTypes = { + type: PropTypes.string, + props: PropTypes.object, + onClose: PropTypes.func.isRequired, + ignoreFocus: PropTypes.bool, + }; + + state = { + backgroundColor: null, + }; + + componentDidUpdate () { + if (this.props.type) { + document.body.classList.add('with-modals--active'); + document.documentElement.style.marginRight = `${getScrollbarWidth()}px`; + } else { + document.body.classList.remove('with-modals--active'); + document.documentElement.style.marginRight = 0; + } + } + + setBackgroundColor = color => { + this.setState({ backgroundColor: color }); + }; + + renderLoading = modalId => () => { + return ['MEDIA', 'VIDEO', 'BOOST', 'FAVOURITE', 'DOODLE', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null; + }; + + renderError = (props) => { + const { onClose } = this.props; + + return <BundleModalError {...props} onClose={onClose} />; + }; + + handleClose = (ignoreFocus = false) => { + const { onClose } = this.props; + let message = null; + try { + message = this._modal?.getWrappedInstance?.().getCloseConfirmationMessage?.(); + } catch (_) { + // injectIntl defines `getWrappedInstance` but errors out if `withRef` + // isn't set. + // This would be much smoother with react-intl 3+ and `forwardRef`. + } + onClose(message, ignoreFocus); + }; + + setModalRef = (c) => { + this._modal = c; + }; + + // prevent closing of modal when clicking the overlay + noop = () => {}; + + render () { + const { type, props, ignoreFocus } = this.props; + const { backgroundColor } = this.state; + const visible = !!type; + + return ( + <Base backgroundColor={backgroundColor} onClose={props && props.noClose ? this.noop : this.handleClose} noEsc={props ? props.noEsc : false} ignoreFocus={ignoreFocus}> + {visible && ( + <> + <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}> + {(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={this.setModalRef} />} + </BundleContainer> + + <Helmet> + <meta name='robots' content='noindex' /> + </Helmet> + </> + )} + </Base> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/mute_modal.js b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js new file mode 100644 index 000000000..f8bb9a364 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js @@ -0,0 +1,140 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Toggle from 'react-toggle'; +import Button from 'flavours/glitch/components/button'; +import { closeModal } from 'flavours/glitch/actions/modal'; +import { muteAccount } from 'flavours/glitch/actions/accounts'; +import { toggleHideNotifications, changeMuteDuration } from 'flavours/glitch/actions/mutes'; + +const messages = defineMessages({ + minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' }, + hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' }, + days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' }, + indefinite: { id: 'mute_modal.indefinite', defaultMessage: 'Indefinite' }, +}); + +const mapStateToProps = state => { + return { + account: state.getIn(['mutes', 'new', 'account']), + notifications: state.getIn(['mutes', 'new', 'notifications']), + muteDuration: state.getIn(['mutes', 'new', 'duration']), + }; +}; + +const mapDispatchToProps = dispatch => { + return { + onConfirm(account, notifications, muteDuration) { + dispatch(muteAccount(account.get('id'), notifications, muteDuration)); + }, + + onClose() { + dispatch(closeModal()); + }, + + onToggleNotifications() { + dispatch(toggleHideNotifications()); + }, + + onChangeMuteDuration(e) { + dispatch(changeMuteDuration(e.target.value)); + }, + }; +}; + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +class MuteModal extends React.PureComponent { + + static propTypes = { + account: PropTypes.object.isRequired, + notifications: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + onToggleNotifications: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + muteDuration: PropTypes.number.isRequired, + onChangeMuteDuration: PropTypes.func.isRequired, + }; + + componentDidMount() { + this.button.focus(); + } + + handleClick = () => { + this.props.onClose(); + this.props.onConfirm(this.props.account, this.props.notifications, this.props.muteDuration); + }; + + handleCancel = () => { + this.props.onClose(); + }; + + setRef = (c) => { + this.button = c; + }; + + toggleNotifications = () => { + this.props.onToggleNotifications(); + }; + + changeMuteDuration = (e) => { + this.props.onChangeMuteDuration(e); + }; + + render () { + const { account, notifications, muteDuration, intl } = this.props; + + return ( + <div className='modal-root__modal mute-modal'> + <div className='mute-modal__container'> + <p> + <FormattedMessage + id='confirmations.mute.message' + defaultMessage='Are you sure you want to mute {name}?' + values={{ name: <strong>@{account.get('acct')}</strong> }} + /> + </p> + <p className='mute-modal__explanation'> + <FormattedMessage + id='confirmations.mute.explanation' + defaultMessage='This will hide posts from them and posts mentioning them, but it will still allow them to see your posts and follow you.' + /> + </p> + <div className='setting-toggle'> + <Toggle id='mute-modal__hide-notifications-checkbox' checked={notifications} onChange={this.toggleNotifications} /> + <label className='setting-toggle__label' htmlFor='mute-modal__hide-notifications-checkbox'> + <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' /> + </label> + </div> + <div> + <span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span> + + {/* eslint-disable-next-line jsx-a11y/no-onchange */} + <select value={muteDuration} onChange={this.changeMuteDuration}> + <option value={0}>{intl.formatMessage(messages.indefinite)}</option> + <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option> + <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option> + <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option> + <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option> + <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option> + <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option> + <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option> + </select> + </div> + </div> + + <div className='mute-modal__action-bar'> + <Button onClick={this.handleCancel} className='mute-modal__cancel-button'> + <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' /> + </Button> + <Button onClick={this.handleClick} ref={this.setRef}> + <FormattedMessage id='confirmations.mute.confirm' defaultMessage='Mute' /> + </Button> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js new file mode 100644 index 000000000..3b46c6eec --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js @@ -0,0 +1,104 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import { Link } from 'react-router-dom'; +import { timelinePreview, showTrends } from 'flavours/glitch/initial_state'; +import ColumnLink from 'flavours/glitch/features/ui/components/column_link'; +import DisabledAccountBanner from './disabled_account_banner'; +import FollowRequestsColumnLink from './follow_requests_column_link'; +import ListPanel from './list_panel'; +import NotificationsCounterIcon from './notifications_counter_icon'; +import SignInBanner from './sign_in_banner'; +import { preferencesLink, relationshipsLink } from 'flavours/glitch/utils/backend_links'; +import NavigationPortal from 'flavours/glitch/components/navigation_portal'; + +const messages = defineMessages({ + home: { id: 'tabs_bar.home', defaultMessage: 'Home' }, + notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' }, + explore: { id: 'explore.title', defaultMessage: 'Explore' }, + local: { id: 'tabs_bar.local_timeline', defaultMessage: 'Local' }, + federated: { id: 'tabs_bar.federated_timeline', defaultMessage: 'Federated' }, + direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' }, + favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, + bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, + lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, + preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + followsAndFollowers: { id: 'navigation_bar.follows_and_followers', defaultMessage: 'Follows and followers' }, + about: { id: 'navigation_bar.about', defaultMessage: 'About' }, + search: { id: 'navigation_bar.search', defaultMessage: 'Search' }, + app_settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, +}); + +export default @injectIntl +class NavigationPanel extends React.Component { + + static contextTypes = { + router: PropTypes.object.isRequired, + identity: PropTypes.object.isRequired, + }; + + static propTypes = { + onOpenSettings: PropTypes.func, + }; + + render() { + const { intl, onOpenSettings } = this.props; + const { signedIn, disabledAccountId } = this.context.identity; + + return ( + <div className='navigation-panel'> + {signedIn && ( + <React.Fragment> + <ColumnLink transparent to='/home' icon='home' text={intl.formatMessage(messages.home)} /> + <ColumnLink transparent to='/notifications' icon={<NotificationsCounterIcon className='column-link__icon' />} text={intl.formatMessage(messages.notifications)} /> + <FollowRequestsColumnLink /> + </React.Fragment> + )} + + {showTrends ? ( + <ColumnLink transparent to='/explore' icon='hashtag' text={intl.formatMessage(messages.explore)} /> + ) : ( + <ColumnLink transparent to='/search' icon='search' text={intl.formatMessage(messages.search)} /> + )} + + {(signedIn || timelinePreview) && ( + <> + <ColumnLink transparent to='/public/local' icon='users' text={intl.formatMessage(messages.local)} /> + <ColumnLink transparent exact to='/public' icon='globe' text={intl.formatMessage(messages.federated)} /> + </> + )} + + {!signedIn && ( + <div className='navigation-panel__sign-in-banner'> + <hr /> + { disabledAccountId ? <DisabledAccountBanner /> : <SignInBanner /> } + </div> + )} + + {signedIn && ( + <React.Fragment> + <ColumnLink transparent to='/conversations' icon='at' text={intl.formatMessage(messages.direct)} /> + <ColumnLink transparent to='/favourites' icon='star' text={intl.formatMessage(messages.favourites)} /> + <ColumnLink transparent to='/bookmarks' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} /> + <ColumnLink transparent to='/lists' icon='list-ul' text={intl.formatMessage(messages.lists)} /> + + <ListPanel /> + + <hr /> + + {!!preferencesLink && <ColumnLink transparent href={preferencesLink} icon='cog' text={intl.formatMessage(messages.preferences)} />} + <ColumnLink transparent onClick={onOpenSettings} icon='cogs' text={intl.formatMessage(messages.app_settings)} /> + </React.Fragment> + )} + + <div className='navigation-panel__legal'> + <hr /> + <ColumnLink transparent to='/about' icon='ellipsis-h' text={intl.formatMessage(messages.about)} /> + </div> + + <NavigationPortal /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/notifications_counter_icon.js b/app/javascript/flavours/glitch/features/ui/components/notifications_counter_icon.js new file mode 100644 index 000000000..6b52ef9b4 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/notifications_counter_icon.js @@ -0,0 +1,9 @@ +import { connect } from 'react-redux'; +import IconWithBadge from 'flavours/glitch/components/icon_with_badge'; + +const mapStateToProps = state => ({ + count: state.getIn(['local_settings', 'notifications', 'tab_badge']) ? state.getIn(['notifications', 'unread']) : 0, + id: 'bell', +}); + +export default connect(mapStateToProps)(IconWithBadge); diff --git a/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js b/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js new file mode 100644 index 000000000..d972fe3b5 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js @@ -0,0 +1,321 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ReactSwipeableViews from 'react-swipeable-views'; +import classNames from 'classnames'; +import Permalink from 'flavours/glitch/components/permalink'; +import ComposeForm from 'flavours/glitch/features/compose/components/compose_form'; +import DrawerAccount from 'flavours/glitch/features/compose/components/navigation_bar'; +import Search from 'flavours/glitch/features/compose/components/search'; +import ColumnHeader from './column_header'; +import { me, source_url } from 'flavours/glitch/initial_state'; + +const noop = () => { }; + +const messages = defineMessages({ + home_title: { id: 'column.home', defaultMessage: 'Home' }, + notifications_title: { id: 'column.notifications', defaultMessage: 'Notifications' }, + local_title: { id: 'column.community', defaultMessage: 'Local timeline' }, + federated_title: { id: 'column.public', defaultMessage: 'Federated timeline' }, +}); + +const PageOne = ({ acct, domain }) => ( + <div className='onboarding-modal__page onboarding-modal__page-one'> + <div style={{ flex: '0 0 auto' }}> + <div className='onboarding-modal__page-one__elephant-friend' /> + </div> + + <div> + <h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to {domain}!' values={{ domain }} /></h1> + <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='{domain} is an "instance" of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' values={{ domain }} /></p> + <p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>@{acct}@{domain}</strong> }} /></p> + </div> + </div> +); + +PageOne.propTypes = { + acct: PropTypes.string.isRequired, + domain: PropTypes.string.isRequired, +}; + +const PageTwo = ({ intl, myAccount }) => ( + <div className='onboarding-modal__page onboarding-modal__page-two'> + <div className='figure non-interactive'> + <div className='pseudo-drawer'> + <DrawerAccount account={myAccount} /> + <ComposeForm + privacy='public' + text='Awoo! #introductions' + spoilerText='' + suggestions={[]} + /> + </div> + </div> + + <p><FormattedMessage id='onboarding.page_two.compose' defaultMessage='Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.' /></p> + </div> +); + +PageTwo.propTypes = { + intl: PropTypes.object.isRequired, + myAccount: ImmutablePropTypes.map.isRequired, +}; + +const PageThree = ({ intl, myAccount }) => ( + <div className='onboarding-modal__page onboarding-modal__page-three'> + <div className='figure non-interactive'> + <Search + value='' + onChange={noop} + onSubmit={noop} + onClear={noop} + onShow={noop} + /> + + <div className='pseudo-drawer'> + <DrawerAccount account={myAccount} /> + </div> + </div> + + <p><FormattedMessage id='onboarding.page_three.search' defaultMessage='Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.' values={{ illustration: <Permalink to='/tag/illustration' href='/tags/illustration'>#illustration</Permalink>, introductions: <Permalink to='/tag/introductions' href='/tags/introductions'>#introductions</Permalink> }} /></p> + <p><FormattedMessage id='onboarding.page_three.profile' defaultMessage='Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.' /></p> + </div> +); + +PageThree.propTypes = { + intl: PropTypes.object.isRequired, + myAccount: ImmutablePropTypes.map.isRequired, +}; + +const PageFour = ({ domain, intl }) => ( + <div className='onboarding-modal__page onboarding-modal__page-four'> + <div className='onboarding-modal__page-four__columns'> + <div className='row'> + <div> + <div className='figure non-interactive'><ColumnHeader icon='home' type={intl.formatMessage(messages.home_title)} /></div> + <p><FormattedMessage id='onboarding.page_four.home' defaultMessage='The home timeline shows posts from people you follow.' /></p> + </div> + + <div> + <div className='figure non-interactive'><ColumnHeader icon='bell' type={intl.formatMessage(messages.notifications_title)} /></div> + <p><FormattedMessage id='onboarding.page_four.notifications' defaultMessage='The notifications column shows when someone interacts with you.' /></p> + </div> + </div> + + <div className='row'> + <div> + <div className='figure non-interactive' style={{ marginBottom: 0 }}><ColumnHeader icon='users' type={intl.formatMessage(messages.local_title)} /></div> + </div> + + <div> + <div className='figure non-interactive' style={{ marginBottom: 0 }}><ColumnHeader icon='globe' type={intl.formatMessage(messages.federated_title)} /></div> + </div> + </div> + + <p><FormattedMessage id='onboarding.page_five.public_timelines' defaultMessage='The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.' values={{ domain }} /></p> + </div> + </div> +); + +PageFour.propTypes = { + domain: PropTypes.string.isRequired, + intl: PropTypes.object.isRequired, +}; + +const PageSix = ({ admin, domain }) => { + let adminSection = ''; + + if (admin) { + adminSection = ( + <p> + <FormattedMessage id='onboarding.page_six.admin' defaultMessage="Your instance's admin is {admin}." values={{ admin: <Permalink href={admin.get('url')} to={`/@${admin.get('acct')}`}>@{admin.get('acct')}</Permalink> }} /> + <br /> + <FormattedMessage id='onboarding.page_six.read_guidelines' defaultMessage="Please read {domain}'s {guidelines}!" values={{ domain, guidelines: <a href='/about/more' target='_blank'><FormattedMessage id='onboarding.page_six.guidelines' defaultMessage='community guidelines' /></a> }} /> + </p> + ); + } + + return ( + <div className='onboarding-modal__page onboarding-modal__page-six'> + <h1><FormattedMessage id='onboarding.page_six.almost_done' defaultMessage='Almost done...' /></h1> + {adminSection} + <p> + <FormattedMessage + id='onboarding.page_six.github' + defaultMessage='{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}. Glitchsoc is fully compatible with all Mastodon apps and instances. Glitchsoc is free open-source software. You can report bugs, request features, or contribute to the code on {github}.' + values={{ + domain, + fork: <a href='https://en.wikipedia.org/wiki/Fork_(software_development)' target='_blank' rel='noopener'>fork</a>, + Mastodon: <a href='https://github.com/mastodon/mastodon' target='_blank' rel='noopener'>Mastodon</a>, + github: <a href={source_url} target='_blank' rel='noopener'>GitHub</a>, + }} + /> + </p> + <p><FormattedMessage id='onboarding.page_six.apps_available' defaultMessage='There are {apps} available for iOS, Android and other platforms.' values={{ domain, apps: <a href='https://joinmastodon.org/apps' target='_blank' rel='noopener'><FormattedMessage id='onboarding.page_six.various_app' defaultMessage='mobile apps' /></a> }} /></p> + <p><em><FormattedMessage id='onboarding.page_six.appetoot' defaultMessage='Bon Appetoot!' /></em></p> + </div> + ); +}; + +PageSix.propTypes = { + admin: ImmutablePropTypes.map, + domain: PropTypes.string.isRequired, +}; + +const mapStateToProps = state => ({ + myAccount: state.getIn(['accounts', me]), + admin: state.getIn(['accounts', state.getIn(['meta', 'admin'])]), + domain: state.getIn(['meta', 'domain']), +}); + +export default @connect(mapStateToProps) +@injectIntl +class OnboardingModal extends React.PureComponent { + + static propTypes = { + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + myAccount: ImmutablePropTypes.map.isRequired, + domain: PropTypes.string.isRequired, + admin: ImmutablePropTypes.map, + }; + + state = { + currentIndex: 0, + }; + + componentWillMount() { + const { myAccount, admin, domain, intl } = this.props; + this.pages = [ + <PageOne acct={myAccount.get('acct')} domain={domain} />, + <PageTwo myAccount={myAccount} intl={intl} />, + <PageThree myAccount={myAccount} intl={intl} />, + <PageFour domain={domain} intl={intl} />, + <PageSix admin={admin} domain={domain} />, + ]; + } + + componentDidMount() { + window.addEventListener('keyup', this.handleKeyUp); + } + + componentWillUnmount() { + window.addEventListener('keyup', this.handleKeyUp); + } + + handleSkip = (e) => { + e.preventDefault(); + this.props.onClose(); + }; + + handleDot = (e) => { + const i = Number(e.currentTarget.getAttribute('data-index')); + e.preventDefault(); + this.setState({ currentIndex: i }); + }; + + handlePrev = () => { + this.setState(({ currentIndex }) => ({ + currentIndex: Math.max(0, currentIndex - 1), + })); + }; + + handleNext = () => { + const { pages } = this; + this.setState(({ currentIndex }) => ({ + currentIndex: Math.min(currentIndex + 1, pages.length - 1), + })); + }; + + handleSwipe = (index) => { + this.setState({ currentIndex: index }); + }; + + handleKeyUp = ({ key }) => { + switch (key) { + case 'ArrowLeft': + this.handlePrev(); + break; + case 'ArrowRight': + this.handleNext(); + break; + } + }; + + handleClose = () => { + this.props.onClose(); + }; + + render () { + const { pages } = this; + const { currentIndex } = this.state; + const hasMore = currentIndex < pages.length - 1; + + const nextOrDoneBtn = hasMore ? ( + <button + onClick={this.handleNext} + className='onboarding-modal__nav onboarding-modal__next' + > + <FormattedMessage id='onboarding.next' defaultMessage='Next' /> + </button> + ) : ( + <button + onClick={this.handleClose} + className='onboarding-modal__nav onboarding-modal__done' + > + <FormattedMessage id='onboarding.done' defaultMessage='Done' /> + </button> + ); + + return ( + <div className='modal-root__modal onboarding-modal'> + <ReactSwipeableViews index={currentIndex} onChangeIndex={this.handleSwipe} className='onboarding-modal__pager'> + {pages.map((page, i) => { + const className = classNames('onboarding-modal__page__wrapper', { + 'onboarding-modal__page__wrapper--active': i === currentIndex, + }); + return ( + <div key={i} className={className}>{page}</div> + ); + })} + </ReactSwipeableViews> + + <div className='onboarding-modal__paginator'> + <div> + <button + onClick={this.handleSkip} + className='onboarding-modal__nav onboarding-modal__skip' + > + <FormattedMessage id='onboarding.skip' defaultMessage='Skip' /> + </button> + </div> + + <div className='onboarding-modal__dots'> + {pages.map((_, i) => { + const className = classNames('onboarding-modal__dot', { + active: i === currentIndex, + }); + return ( + <div + key={`dot-${i}`} + role='button' + tabIndex='0' + data-index={i} + onClick={this.handleDot} + className={className} + /> + ); + })} + </div> + + <div> + {nextOrDoneBtn} + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/report_modal.js b/app/javascript/flavours/glitch/features/ui/components/report_modal.js new file mode 100644 index 000000000..4d7f84bae --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/report_modal.js @@ -0,0 +1,221 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { submitReport } from 'flavours/glitch/actions/reports'; +import { expandAccountTimeline } from 'flavours/glitch/actions/timelines'; +import { fetchServer } from 'flavours/glitch/actions/server'; +import { fetchRelationships } from 'flavours/glitch/actions/accounts'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { makeGetAccount } from 'flavours/glitch/selectors'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import { OrderedSet } from 'immutable'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import IconButton from 'flavours/glitch/components/icon_button'; +import Category from 'flavours/glitch/features/report/category'; +import Statuses from 'flavours/glitch/features/report/statuses'; +import Rules from 'flavours/glitch/features/report/rules'; +import Comment from 'flavours/glitch/features/report/comment'; +import Thanks from 'flavours/glitch/features/report/thanks'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { accountId }) => ({ + account: getAccount(state, accountId), + }); + + return mapStateToProps; +}; + +export default @connect(makeMapStateToProps) +@injectIntl +class ReportModal extends ImmutablePureComponent { + + static propTypes = { + accountId: PropTypes.string.isRequired, + statusId: PropTypes.string, + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + account: ImmutablePropTypes.map.isRequired, + }; + + state = { + step: 'category', + selectedStatusIds: OrderedSet(this.props.statusId ? [this.props.statusId] : []), + comment: '', + category: null, + selectedRuleIds: OrderedSet(), + forward: true, + isSubmitting: false, + isSubmitted: false, + }; + + handleSubmit = () => { + const { dispatch, accountId } = this.props; + const { selectedStatusIds, comment, category, selectedRuleIds, forward } = this.state; + + this.setState({ isSubmitting: true }); + + dispatch(submitReport({ + account_id: accountId, + status_ids: selectedStatusIds.toArray(), + comment, + forward, + category, + rule_ids: selectedRuleIds.toArray(), + }, this.handleSuccess, this.handleFail)); + }; + + handleSuccess = () => { + this.setState({ isSubmitting: false, isSubmitted: true, step: 'thanks' }); + }; + + handleFail = () => { + this.setState({ isSubmitting: false }); + }; + + handleStatusToggle = (statusId, checked) => { + const { selectedStatusIds } = this.state; + + if (checked) { + this.setState({ selectedStatusIds: selectedStatusIds.add(statusId) }); + } else { + this.setState({ selectedStatusIds: selectedStatusIds.remove(statusId) }); + } + }; + + handleRuleToggle = (ruleId, checked) => { + const { selectedRuleIds } = this.state; + + if (checked) { + this.setState({ selectedRuleIds: selectedRuleIds.add(ruleId) }); + } else { + this.setState({ selectedRuleIds: selectedRuleIds.remove(ruleId) }); + } + }; + + handleChangeCategory = category => { + this.setState({ category }); + }; + + handleChangeComment = comment => { + this.setState({ comment }); + }; + + handleChangeForward = forward => { + this.setState({ forward }); + }; + + handleNextStep = step => { + this.setState({ step }); + }; + + componentDidMount () { + const { dispatch, accountId } = this.props; + + dispatch(fetchRelationships([accountId])); + dispatch(expandAccountTimeline(accountId, { withReplies: true })); + dispatch(fetchServer()); + } + + render () { + const { + accountId, + account, + intl, + onClose, + } = this.props; + + if (!account) { + return null; + } + + const { + step, + selectedStatusIds, + selectedRuleIds, + comment, + forward, + category, + isSubmitting, + isSubmitted, + } = this.state; + + const domain = account.get('acct').split('@')[1]; + const isRemote = !!domain; + + let stepComponent; + + switch(step) { + case 'category': + stepComponent = ( + <Category + onNextStep={this.handleNextStep} + startedFrom={this.props.statusId ? 'status' : 'account'} + category={category} + onChangeCategory={this.handleChangeCategory} + /> + ); + break; + case 'rules': + stepComponent = ( + <Rules + onNextStep={this.handleNextStep} + selectedRuleIds={selectedRuleIds} + onToggle={this.handleRuleToggle} + /> + ); + break; + case 'statuses': + stepComponent = ( + <Statuses + onNextStep={this.handleNextStep} + accountId={accountId} + selectedStatusIds={selectedStatusIds} + onToggle={this.handleStatusToggle} + /> + ); + break; + case 'comment': + stepComponent = ( + <Comment + onSubmit={this.handleSubmit} + isSubmitting={isSubmitting} + isRemote={isRemote} + comment={comment} + forward={forward} + domain={domain} + onChangeComment={this.handleChangeComment} + onChangeForward={this.handleChangeForward} + /> + ); + break; + case 'thanks': + stepComponent = ( + <Thanks + submitted={isSubmitted} + account={account} + onClose={onClose} + /> + ); + } + + return ( + <div className='modal-root__modal report-dialog-modal'> + <div className='report-modal__target'> + <IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} /> + <FormattedMessage id='report.target' defaultMessage='Report {target}' values={{ target: <strong>{account.get('acct')}</strong> }} /> + </div> + + <div className='report-dialog-modal__container'> + {stepComponent} + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/sign_in_banner.js b/app/javascript/flavours/glitch/features/ui/components/sign_in_banner.js new file mode 100644 index 000000000..c0d62aca0 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/sign_in_banner.js @@ -0,0 +1,40 @@ +import React, { useCallback } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { useDispatch } from 'react-redux'; +import { registrationsOpen } from 'flavours/glitch/initial_state'; +import { openModal } from 'flavours/glitch/actions/modal'; + +const SignInBanner = () => { + const dispatch = useDispatch(); + + const openClosedRegistrationsModal = useCallback( + () => dispatch(openModal('CLOSED_REGISTRATIONS')), + [dispatch], + ); + + let signupButton; + + if (registrationsOpen) { + signupButton = ( + <a href='/auth/sign_up' className='button button--block button-tertiary'> + <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' /> + </a> + ); + } else { + signupButton = ( + <button className='button button--block button-tertiary' onClick={openClosedRegistrationsModal}> + <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' /> + </button> + ); + } + + return ( + <div className='sign-in-banner'> + <p><FormattedMessage id='sign_in_banner.text' defaultMessage='Sign in to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.' /></p> + <a href='/auth/sign_in' className='button button--block'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a> + {signupButton} + </div> + ); +}; + +export default SignInBanner; diff --git a/app/javascript/flavours/glitch/features/ui/components/upload_area.js b/app/javascript/flavours/glitch/features/ui/components/upload_area.js new file mode 100644 index 000000000..0e07b67f8 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/upload_area.js @@ -0,0 +1,52 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Motion from '../../ui/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import { FormattedMessage } from 'react-intl'; + +export default class UploadArea extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + onClose: PropTypes.func, + }; + + handleKeyUp = (e) => { + const keyCode = e.keyCode; + if (this.props.active) { + switch(keyCode) { + case 27: + e.preventDefault(); + e.stopPropagation(); + this.props.onClose(); + break; + } + } + }; + + componentDidMount () { + window.addEventListener('keyup', this.handleKeyUp, false); + } + + componentWillUnmount () { + window.removeEventListener('keyup', this.handleKeyUp); + } + + render () { + const { active } = this.props; + + return ( + <Motion defaultStyle={{ backgroundOpacity: 0, backgroundScale: 0.95 }} style={{ backgroundOpacity: spring(active ? 1 : 0, { stiffness: 150, damping: 15 }), backgroundScale: spring(active ? 1 : 0.95, { stiffness: 200, damping: 3 }) }}> + {({ backgroundOpacity, backgroundScale }) => + (<div className='upload-area' style={{ visibility: active ? 'visible' : 'hidden', opacity: backgroundOpacity }}> + <div className='upload-area__drop'> + <div className='upload-area__background' style={{ transform: `scale(${backgroundScale})` }} /> + <div className='upload-area__content'><FormattedMessage id='upload_area.title' defaultMessage='Drag & drop to upload' /></div> + </div> + </div>) + } + </Motion> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/video_modal.js b/app/javascript/flavours/glitch/features/ui/components/video_modal.js new file mode 100644 index 000000000..90be11e4b --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/video_modal.js @@ -0,0 +1,66 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Video from 'flavours/glitch/features/video'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Footer from 'flavours/glitch/features/picture_in_picture/components/footer'; +import { getAverageFromBlurhash } from 'flavours/glitch/blurhash'; + +export default class VideoModal extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + media: ImmutablePropTypes.map.isRequired, + statusId: PropTypes.string, + options: PropTypes.shape({ + startTime: PropTypes.number, + autoPlay: PropTypes.bool, + defaultVolume: PropTypes.number, + }), + onClose: PropTypes.func.isRequired, + onChangeBackgroundColor: PropTypes.func.isRequired, + }; + + componentDidMount () { + const { media, onChangeBackgroundColor, onClose } = this.props; + + const backgroundColor = getAverageFromBlurhash(media.get('blurhash')); + + if (backgroundColor) { + onChangeBackgroundColor(backgroundColor); + } + } + + render () { + const { media, statusId, onClose } = this.props; + const options = this.props.options || {}; + + return ( + <div className='modal-root__modal video-modal'> + <div className='video-modal__container'> + <Video + preview={media.get('preview_url')} + frameRate={media.getIn(['meta', 'original', 'frame_rate'])} + blurhash={media.get('blurhash')} + src={media.get('url')} + currentTime={options.startTime} + autoPlay={options.autoPlay} + volume={options.defaultVolume} + onCloseVideo={onClose} + autoFocus + detailed + alt={media.get('description')} + /> + </div> + + <div className='media-modal__overlay'> + {statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />} + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js b/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js new file mode 100644 index 000000000..50b36b478 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js @@ -0,0 +1,450 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import IconButton from 'flavours/glitch/components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + compress: { id: 'lightbox.compress', defaultMessage: 'Compress image view box' }, + expand: { id: 'lightbox.expand', defaultMessage: 'Expand image view box' }, +}); + +const MIN_SCALE = 1; +const MAX_SCALE = 4; +const NAV_BAR_HEIGHT = 66; + +const getMidpoint = (p1, p2) => ({ + x: (p1.clientX + p2.clientX) / 2, + y: (p1.clientY + p2.clientY) / 2, +}); + +const getDistance = (p1, p2) => + Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2)); + +const clamp = (min, max, value) => Math.min(max, Math.max(min, value)); + +// Normalizing mousewheel speed across browsers +// copy from: https://github.com/facebookarchive/fixed-data-table/blob/master/src/vendor_upstream/dom/normalizeWheel.js +const normalizeWheel = event => { + // Reasonable defaults + const PIXEL_STEP = 10; + const LINE_HEIGHT = 40; + const PAGE_HEIGHT = 800; + + let sX = 0, + sY = 0, // spinX, spinY + pX = 0, + pY = 0; // pixelX, pixelY + + // Legacy + if ('detail' in event) { + sY = event.detail; + } + if ('wheelDelta' in event) { + sY = -event.wheelDelta / 120; + } + if ('wheelDeltaY' in event) { + sY = -event.wheelDeltaY / 120; + } + if ('wheelDeltaX' in event) { + sX = -event.wheelDeltaX / 120; + } + + // side scrolling on FF with DOMMouseScroll + if ('axis' in event && event.axis === event.HORIZONTAL_AXIS) { + sX = sY; + sY = 0; + } + + pX = sX * PIXEL_STEP; + pY = sY * PIXEL_STEP; + + if ('deltaY' in event) { + pY = event.deltaY; + } + if ('deltaX' in event) { + pX = event.deltaX; + } + + if ((pX || pY) && event.deltaMode) { + if (event.deltaMode === 1) { // delta in LINE units + pX *= LINE_HEIGHT; + pY *= LINE_HEIGHT; + } else { // delta in PAGE units + pX *= PAGE_HEIGHT; + pY *= PAGE_HEIGHT; + } + } + + // Fall-back if spin cannot be determined + if (pX && !sX) { + sX = (pX < 1) ? -1 : 1; + } + if (pY && !sY) { + sY = (pY < 1) ? -1 : 1; + } + + return { + spinX: sX, + spinY: sY, + pixelX: pX, + pixelY: pY, + }; +}; + +export default @injectIntl +class ZoomableImage extends React.PureComponent { + + static propTypes = { + alt: PropTypes.string, + src: PropTypes.string.isRequired, + width: PropTypes.number, + height: PropTypes.number, + onClick: PropTypes.func, + zoomButtonHidden: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + static defaultProps = { + alt: '', + width: null, + height: null, + }; + + state = { + scale: MIN_SCALE, + zoomMatrix: { + type: null, // 'width' 'height' + fullScreen: null, // bool + rate: null, // full screen scale rate + clientWidth: null, + clientHeight: null, + offsetWidth: null, + offsetHeight: null, + clientHeightFixed: null, + scrollTop: null, + scrollLeft: null, + translateX: null, + translateY: null, + }, + zoomState: 'expand', // 'expand' 'compress' + navigationHidden: false, + dragPosition: { top: 0, left: 0, x: 0, y: 0 }, + dragged: false, + lockScroll: { x: 0, y: 0 }, + lockTranslate: { x: 0, y: 0 }, + }; + + removers = []; + container = null; + image = null; + lastTouchEndTime = 0; + lastDistance = 0; + + componentDidMount () { + let handler = this.handleTouchStart; + this.container.addEventListener('touchstart', handler); + this.removers.push(() => this.container.removeEventListener('touchstart', handler)); + handler = this.handleTouchMove; + // on Chrome 56+, touch event listeners will default to passive + // https://www.chromestatus.com/features/5093566007214080 + this.container.addEventListener('touchmove', handler, { passive: false }); + this.removers.push(() => this.container.removeEventListener('touchend', handler)); + + handler = this.mouseDownHandler; + this.container.addEventListener('mousedown', handler); + this.removers.push(() => this.container.removeEventListener('mousedown', handler)); + + handler = this.mouseWheelHandler; + this.container.addEventListener('wheel', handler); + this.removers.push(() => this.container.removeEventListener('wheel', handler)); + // Old Chrome + this.container.addEventListener('mousewheel', handler); + this.removers.push(() => this.container.removeEventListener('mousewheel', handler)); + // Old Firefox + this.container.addEventListener('DOMMouseScroll', handler); + this.removers.push(() => this.container.removeEventListener('DOMMouseScroll', handler)); + + this.initZoomMatrix(); + } + + componentWillUnmount () { + this.removeEventListeners(); + } + + componentDidUpdate () { + this.setState({ zoomState: this.state.scale >= this.state.zoomMatrix.rate ? 'compress' : 'expand' }); + + if (this.state.scale === MIN_SCALE) { + this.container.style.removeProperty('cursor'); + } + } + + UNSAFE_componentWillReceiveProps () { + // reset when slide to next image + if (this.props.zoomButtonHidden) { + this.setState({ + scale: MIN_SCALE, + lockTranslate: { x: 0, y: 0 }, + }, () => { + this.container.scrollLeft = 0; + this.container.scrollTop = 0; + }); + } + } + + removeEventListeners () { + this.removers.forEach(listeners => listeners()); + this.removers = []; + } + + mouseWheelHandler = e => { + e.preventDefault(); + + const event = normalizeWheel(e); + + if (this.state.zoomMatrix.type === 'width') { + // full width, scroll vertical + this.container.scrollTop = Math.max(this.container.scrollTop + event.pixelY, this.state.lockScroll.y); + } else { + // full height, scroll horizontal + this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelY, this.state.lockScroll.x); + } + + // lock horizontal scroll + this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelX, this.state.lockScroll.x); + }; + + mouseDownHandler = e => { + this.container.style.cursor = 'grabbing'; + this.container.style.userSelect = 'none'; + + this.setState({ dragPosition: { + left: this.container.scrollLeft, + top: this.container.scrollTop, + // Get the current mouse position + x: e.clientX, + y: e.clientY, + } }); + + this.image.addEventListener('mousemove', this.mouseMoveHandler); + this.image.addEventListener('mouseup', this.mouseUpHandler); + }; + + mouseMoveHandler = e => { + const dx = e.clientX - this.state.dragPosition.x; + const dy = e.clientY - this.state.dragPosition.y; + + this.container.scrollLeft = Math.max(this.state.dragPosition.left - dx, this.state.lockScroll.x); + this.container.scrollTop = Math.max(this.state.dragPosition.top - dy, this.state.lockScroll.y); + + this.setState({ dragged: true }); + }; + + mouseUpHandler = () => { + this.container.style.cursor = 'grab'; + this.container.style.removeProperty('user-select'); + + this.image.removeEventListener('mousemove', this.mouseMoveHandler); + this.image.removeEventListener('mouseup', this.mouseUpHandler); + }; + + handleTouchStart = e => { + if (e.touches.length !== 2) return; + + this.lastDistance = getDistance(...e.touches); + }; + + handleTouchMove = e => { + const { scrollTop, scrollHeight, clientHeight } = this.container; + if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) { + // prevent propagating event to MediaModal + e.stopPropagation(); + return; + } + if (e.touches.length !== 2) return; + + e.preventDefault(); + e.stopPropagation(); + + const distance = getDistance(...e.touches); + const midpoint = getMidpoint(...e.touches); + const _MAX_SCALE = Math.max(MAX_SCALE, this.state.zoomMatrix.rate); + const scale = clamp(MIN_SCALE, _MAX_SCALE, this.state.scale * distance / this.lastDistance); + + this.zoom(scale, midpoint); + + this.lastMidpoint = midpoint; + this.lastDistance = distance; + }; + + zoom(nextScale, midpoint) { + const { scale, zoomMatrix } = this.state; + const { scrollLeft, scrollTop } = this.container; + + // math memo: + // x = (scrollLeft + midpoint.x) / scrollWidth + // x' = (nextScrollLeft + midpoint.x) / nextScrollWidth + // scrollWidth = clientWidth * scale + // scrollWidth' = clientWidth * nextScale + // Solve x = x' for nextScrollLeft + const nextScrollLeft = (scrollLeft + midpoint.x) * nextScale / scale - midpoint.x; + const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y; + + this.setState({ scale: nextScale }, () => { + this.container.scrollLeft = nextScrollLeft; + this.container.scrollTop = nextScrollTop; + // reset the translateX/Y constantly + if (nextScale < zoomMatrix.rate) { + this.setState({ + lockTranslate: { + x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)), + y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)), + }, + }); + } + }); + } + + handleClick = e => { + // don't propagate event to MediaModal + e.stopPropagation(); + const dragged = this.state.dragged; + this.setState({ dragged: false }); + if (dragged) return; + const handler = this.props.onClick; + if (handler) handler(); + this.setState({ navigationHidden: !this.state.navigationHidden }); + }; + + handleMouseDown = e => { + e.preventDefault(); + }; + + initZoomMatrix = () => { + const { width, height } = this.props; + const { clientWidth, clientHeight } = this.container; + const { offsetWidth, offsetHeight } = this.image; + const clientHeightFixed = clientHeight - NAV_BAR_HEIGHT; + + const type = width / height < clientWidth / clientHeightFixed ? 'width' : 'height'; + const fullScreen = type === 'width' ? width > clientWidth : height > clientHeightFixed; + const rate = type === 'width' ? Math.min(clientWidth, width) / offsetWidth : Math.min(clientHeightFixed, height) / offsetHeight; + const scrollTop = type === 'width' ? (clientHeight - offsetHeight) / 2 - NAV_BAR_HEIGHT : (clientHeightFixed - offsetHeight) / 2; + const scrollLeft = (clientWidth - offsetWidth) / 2; + const translateX = type === 'width' ? (width - offsetWidth) / (2 * rate) : 0; + const translateY = type === 'height' ? (height - offsetHeight) / (2 * rate) : 0; + + this.setState({ + zoomMatrix: { + type: type, + fullScreen: fullScreen, + rate: rate, + clientWidth: clientWidth, + clientHeight: clientHeight, + offsetWidth: offsetWidth, + offsetHeight: offsetHeight, + clientHeightFixed: clientHeightFixed, + scrollTop: scrollTop, + scrollLeft: scrollLeft, + translateX: translateX, + translateY: translateY, + }, + }); + }; + + handleZoomClick = e => { + e.preventDefault(); + e.stopPropagation(); + + const { scale, zoomMatrix } = this.state; + + if ( scale >= zoomMatrix.rate ) { + this.setState({ + scale: MIN_SCALE, + lockScroll: { + x: 0, + y: 0, + }, + lockTranslate: { + x: 0, + y: 0, + }, + }, () => { + this.container.scrollLeft = 0; + this.container.scrollTop = 0; + }); + } else { + this.setState({ + scale: zoomMatrix.rate, + lockScroll: { + x: zoomMatrix.scrollLeft, + y: zoomMatrix.scrollTop, + }, + lockTranslate: { + x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX, + y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY, + }, + }, () => { + this.container.scrollLeft = zoomMatrix.scrollLeft; + this.container.scrollTop = zoomMatrix.scrollTop; + }); + } + + this.container.style.cursor = 'grab'; + this.container.style.removeProperty('user-select'); + }; + + setContainerRef = c => { + this.container = c; + }; + + setImageRef = c => { + this.image = c; + }; + + render () { + const { alt, src, width, height, intl } = this.props; + const { scale, lockTranslate } = this.state; + const overflow = scale === MIN_SCALE ? 'hidden' : 'scroll'; + const zoomButtonShouldHide = this.state.navigationHidden || this.props.zoomButtonHidden || this.state.zoomMatrix.rate <= MIN_SCALE ? 'media-modal__zoom-button--hidden' : ''; + const zoomButtonTitle = this.state.zoomState === 'compress' ? intl.formatMessage(messages.compress) : intl.formatMessage(messages.expand); + + return ( + <React.Fragment> + <IconButton + className={`media-modal__zoom-button ${zoomButtonShouldHide}`} + title={zoomButtonTitle} + icon={this.state.zoomState} + onClick={this.handleZoomClick} + size={40} + style={{ + fontSize: '30px', /* Fontawesome's fa-compress fa-expand is larger than fa-close */ + }} + /> + <div + className='zoomable-image' + ref={this.setContainerRef} + style={{ overflow }} + > + <img + role='presentation' + ref={this.setImageRef} + alt={alt} + title={alt} + src={src} + width={width} + height={height} + style={{ + transform: `scale(${scale}) translate(-${lockTranslate.x}px, -${lockTranslate.y}px)`, + transformOrigin: '0 0', + }} + draggable={false} + onClick={this.handleClick} + onMouseDown={this.handleMouseDown} + /> + </div> + </React.Fragment> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/containers/bundle_container.js b/app/javascript/flavours/glitch/features/ui/containers/bundle_container.js new file mode 100644 index 000000000..c9086c9bc --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/containers/bundle_container.js @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; + +import Bundle from '../components/bundle'; + +import { fetchBundleRequest, fetchBundleSuccess, fetchBundleFail } from 'flavours/glitch/actions/bundles'; + +const mapDispatchToProps = dispatch => ({ + onFetch () { + dispatch(fetchBundleRequest()); + }, + onFetchSuccess () { + dispatch(fetchBundleSuccess()); + }, + onFetchFail (error) { + dispatch(fetchBundleFail(error)); + }, +}); + +export default connect(null, mapDispatchToProps)(Bundle); diff --git a/app/javascript/flavours/glitch/features/ui/containers/columns_area_container.js b/app/javascript/flavours/glitch/features/ui/containers/columns_area_container.js new file mode 100644 index 000000000..1107be740 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/containers/columns_area_container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import ColumnsArea from '../components/columns_area'; +import { openModal } from 'flavours/glitch/actions/modal'; + +const mapStateToProps = state => ({ + columns: state.getIn(['settings', 'columns']), +}); + +const mapDispatchToProps = dispatch => ({ + openSettings (e) { + e.preventDefault(); + e.stopPropagation(); + dispatch(openModal('SETTINGS', {})); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true })(ColumnsArea); diff --git a/app/javascript/flavours/glitch/features/ui/containers/loading_bar_container.js b/app/javascript/flavours/glitch/features/ui/containers/loading_bar_container.js new file mode 100644 index 000000000..63e994f92 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/containers/loading_bar_container.js @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import LoadingBar from 'react-redux-loading-bar'; + +const mapStateToProps = (state, ownProps) => ({ + loading: state.get('loadingBar')[ownProps.scope || 'default'], +}); + +export default connect(mapStateToProps)(LoadingBar.WrappedComponent); diff --git a/app/javascript/flavours/glitch/features/ui/containers/modal_container.js b/app/javascript/flavours/glitch/features/ui/containers/modal_container.js new file mode 100644 index 000000000..560c34f01 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/containers/modal_container.js @@ -0,0 +1,27 @@ +import { connect } from 'react-redux'; +import { openModal, closeModal } from 'flavours/glitch/actions/modal'; +import ModalRoot from '../components/modal_root'; + +const mapStateToProps = state => ({ + ignoreFocus: state.getIn(['modal', 'ignoreFocus']), + type: state.getIn(['modal', 'stack', 0, 'modalType'], null), + props: state.getIn(['modal', 'stack', 0, 'modalProps'], {}), +}); + +const mapDispatchToProps = dispatch => ({ + onClose (confirmationMessage, ignoreFocus = false) { + if (confirmationMessage) { + dispatch( + openModal('CONFIRM', { + message: confirmationMessage.message, + confirm: confirmationMessage.confirm, + onConfirm: () => dispatch(closeModal(undefined, { ignoreFocus })), + }), + ); + } else { + dispatch(closeModal(undefined, { ignoreFocus })); + } + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot); diff --git a/app/javascript/flavours/glitch/features/ui/containers/notifications_container.js b/app/javascript/flavours/glitch/features/ui/containers/notifications_container.js new file mode 100644 index 000000000..82278a3be --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/containers/notifications_container.js @@ -0,0 +1,29 @@ +import { injectIntl } from 'react-intl'; +import { connect } from 'react-redux'; +import { NotificationStack } from 'react-notification'; +import { dismissAlert } from 'flavours/glitch/actions/alerts'; +import { getAlerts } from 'flavours/glitch/selectors'; + +const mapStateToProps = (state, { intl }) => { + const notifications = getAlerts(state); + + notifications.forEach(notification => ['title', 'message'].forEach(key => { + const value = notification[key]; + + if (typeof value === 'object') { + notification[key] = intl.formatMessage(value, notification[`${key}_values`]); + } + })); + + return { notifications }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + onDismiss: alert => { + dispatch(dismissAlert(alert)); + }, + }; +}; + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationStack)); diff --git a/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js new file mode 100644 index 000000000..3cd0707f2 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js @@ -0,0 +1,86 @@ +import { connect } from 'react-redux'; +import StatusList from 'flavours/glitch/components/status_list'; +import { scrollTopTimeline, loadPending } from 'flavours/glitch/actions/timelines'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { createSelector } from 'reselect'; +import { debounce } from 'lodash'; +import { me } from 'flavours/glitch/initial_state'; + +const getRegex = createSelector([ + (state, { regex }) => regex, +], (rawRegex) => { + let regex = null; + + try { + regex = rawRegex && new RegExp(rawRegex.trim(), 'i'); + } catch (e) { + // Bad regex, don't affect filters + } + return regex; +}); + +const makeGetStatusIds = (pending = false) => createSelector([ + (state, { type }) => state.getIn(['settings', type], ImmutableMap()), + (state, { type }) => state.getIn(['timelines', type, pending ? 'pendingItems' : 'items'], ImmutableList()), + (state) => state.get('statuses'), + getRegex, +], (columnSettings, statusIds, statuses, regex) => { + return statusIds.filter(id => { + if (id === null) return true; + + const statusForId = statuses.get(id); + let showStatus = true; + + if (statusForId.get('account') === me) return true; + + if (columnSettings.getIn(['shows', 'reblog']) === false) { + showStatus = showStatus && statusForId.get('reblog') === null; + } + + if (columnSettings.getIn(['shows', 'reply']) === false) { + showStatus = showStatus && (statusForId.get('in_reply_to_id') === null || statusForId.get('in_reply_to_account_id') === me); + } + + if (columnSettings.getIn(['shows', 'direct']) === false) { + showStatus = showStatus && statusForId.get('visibility') !== 'direct'; + } + + if (showStatus && regex) { + const searchIndex = statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'search_index']) : statusForId.get('search_index'); + showStatus = !regex.test(searchIndex); + } + + return showStatus; + }); +}); + +const makeMapStateToProps = () => { + const getStatusIds = makeGetStatusIds(); + const getPendingStatusIds = makeGetStatusIds(true); + + const mapStateToProps = (state, { timelineId, regex }) => ({ + statusIds: getStatusIds(state, { type: timelineId, regex }), + isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true), + isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false), + hasMore: state.getIn(['timelines', timelineId, 'hasMore']), + numPending: getPendingStatusIds(state, { type: timelineId }).size, + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { timelineId }) => ({ + + onScrollToTop: debounce(() => { + dispatch(scrollTopTimeline(timelineId, true)); + }, 100), + + onScroll: debounce(() => { + dispatch(scrollTopTimeline(timelineId, false)); + }, 100), + + onLoadPending: () => dispatch(loadPending(timelineId)), + +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList); diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js new file mode 100644 index 000000000..9255e346e --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/index.js @@ -0,0 +1,684 @@ +import React from 'react'; +import NotificationsContainer from './containers/notifications_container'; +import PropTypes from 'prop-types'; +import LoadingBarContainer from './containers/loading_bar_container'; +import ModalContainer from './containers/modal_container'; +import { connect } from 'react-redux'; +import { Redirect, Route, withRouter } from 'react-router-dom'; +import { layoutFromWindow } from 'flavours/glitch/is_mobile'; +import { debounce } from 'lodash'; +import { uploadCompose, resetCompose, changeComposeSpoilerness } from 'flavours/glitch/actions/compose'; +import { expandHomeTimeline } from 'flavours/glitch/actions/timelines'; +import { expandNotifications, notificationsSetVisibility } from 'flavours/glitch/actions/notifications'; +import { fetchServer } from 'flavours/glitch/actions/server'; +import { clearHeight } from 'flavours/glitch/actions/height_cache'; +import { changeLayout } from 'flavours/glitch/actions/app'; +import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers'; +import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; +import BundleColumnError from './components/bundle_column_error'; +import UploadArea from './components/upload_area'; +import PermaLink from 'flavours/glitch/components/permalink'; +import ColumnsAreaContainer from './containers/columns_area_container'; +import classNames from 'classnames'; +import Favico from 'favico.js'; +import PictureInPicture from 'flavours/glitch/features/picture_in_picture'; +import { + Compose, + Status, + GettingStarted, + KeyboardShortcuts, + PublicTimeline, + CommunityTimeline, + AccountTimeline, + AccountGallery, + HomeTimeline, + Followers, + Following, + Reblogs, + Favourites, + DirectTimeline, + HashtagTimeline, + Notifications, + FollowRequests, + FavouritedStatuses, + BookmarkedStatuses, + FollowedTags, + ListTimeline, + Blocks, + DomainBlocks, + Mutes, + PinnedStatuses, + Lists, + GettingStartedMisc, + Directory, + Explore, + FollowRecommendations, + About, + PrivacyPolicy, +} from './util/async-components'; +import { HotKeys } from 'react-hotkeys'; +import initialState, { me, owner, singleUserMode, showTrends, trendsAsLanding } from '../../initial_state'; +import { closeOnboarding, INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import { Helmet } from 'react-helmet'; +import Header from './components/header'; + +// Dummy import, to make sure that <Status /> ends up in the application bundle. +// Without this it ends up in ~8 very commonly used bundles. +import '../../../glitch/components/status'; + +const messages = defineMessages({ + beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' }, +}); + +const mapStateToProps = state => ({ + layout: state.getIn(['meta', 'layout']), + hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0, + hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0, + canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4, + layout: state.getIn(['meta', 'layout']), + layout_local_setting: state.getIn(['local_settings', 'layout']), + isWide: state.getIn(['local_settings', 'stretch']), + navbarUnder: state.getIn(['local_settings', 'navbar_under']), + dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null, + unreadNotifications: state.getIn(['notifications', 'unread']), + showFaviconBadge: state.getIn(['local_settings', 'notifications', 'favicon_badge']), + hicolorPrivacyIcons: state.getIn(['local_settings', 'hicolor_privacy_icons']), + moved: state.getIn(['accounts', me, 'moved']) && state.getIn(['accounts', state.getIn(['accounts', me, 'moved'])]), + firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION, + username: state.getIn(['accounts', me, 'username']), +}); + +const keyMap = { + help: '?', + new: 'n', + search: 's', + forceNew: 'option+n', + toggleComposeSpoilers: 'option+x', + focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'], + reply: 'r', + favourite: 'f', + boost: 'b', + mention: 'm', + open: ['enter', 'o'], + openProfile: 'p', + moveDown: ['down', 'j'], + moveUp: ['up', 'k'], + back: 'backspace', + goToHome: 'g h', + goToNotifications: 'g n', + goToLocal: 'g l', + goToFederated: 'g t', + goToDirect: 'g d', + goToStart: 'g s', + goToFavourites: 'g f', + goToPinned: 'g p', + goToProfile: 'g u', + goToBlocked: 'g b', + goToMuted: 'g m', + goToRequests: 'g r', + toggleSpoiler: 'x', + bookmark: 'd', + toggleCollapse: 'shift+x', + toggleSensitive: 'h', + openMedia: 'e', +}; + +class SwitchingColumnsArea extends React.PureComponent { + + static contextTypes = { + identity: PropTypes.object, + }; + + static propTypes = { + children: PropTypes.node, + location: PropTypes.object, + navbarUnder: PropTypes.bool, + mobile: PropTypes.bool, + }; + + componentWillMount () { + if (this.props.mobile) { + document.body.classList.toggle('layout-single-column', true); + document.body.classList.toggle('layout-multiple-columns', false); + } else { + document.body.classList.toggle('layout-single-column', false); + document.body.classList.toggle('layout-multiple-columns', true); + } + } + + componentDidUpdate (prevProps) { + if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) { + this.node.handleChildrenContentChange(); + } + + if (prevProps.mobile !== this.props.mobile) { + document.body.classList.toggle('layout-single-column', this.props.mobile); + document.body.classList.toggle('layout-multiple-columns', !this.props.mobile); + } + } + + setRef = c => { + if (c) { + this.node = c; + } + }; + + render () { + const { children, mobile, navbarUnder } = this.props; + const { signedIn } = this.context.identity; + + let redirect; + + if (signedIn) { + if (mobile) { + redirect = <Redirect from='/' to='/home' exact />; + } else { + redirect = <Redirect from='/' to='/getting-started' exact />; + } + } else if (singleUserMode && owner && initialState?.accounts[owner]) { + redirect = <Redirect from='/' to={`/@${initialState.accounts[owner].username}`} exact />; + } else if (showTrends && trendsAsLanding) { + redirect = <Redirect from='/' to='/explore' exact />; + } else { + redirect = <Redirect from='/' to='/about' exact />; + } + + return ( + <ColumnsAreaContainer ref={this.setRef} singleColumn={mobile} navbarUnder={navbarUnder}> + <WrappedSwitch> + {redirect} + + <WrappedRoute path='/getting-started' component={GettingStarted} content={children} /> + <WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} /> + <WrappedRoute path='/about' component={About} content={children} /> + <WrappedRoute path='/privacy-policy' component={PrivacyPolicy} content={children} /> + + <WrappedRoute path={['/home', '/timelines/home']} component={HomeTimeline} content={children} /> + <WrappedRoute path={['/public', '/timelines/public']} exact component={PublicTimeline} content={children} /> + <WrappedRoute path={['/public/local', '/timelines/public/local']} exact component={CommunityTimeline} content={children} /> + <WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} /> + <WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} /> + <WrappedRoute path='/lists/:id' component={ListTimeline} content={children} /> + <WrappedRoute path='/notifications' component={Notifications} content={children} /> + <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} /> + + <WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} /> + <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} /> + + <WrappedRoute path='/start' component={FollowRecommendations} content={children} /> + <WrappedRoute path='/directory' component={Directory} content={children} /> + <WrappedRoute path={['/explore', '/search']} component={Explore} content={children} /> + <WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} /> + + <WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} /> + <WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} content={children} /> + <WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} /> + <WrappedRoute path={['/accounts/:id/followers', '/users/:acct/followers', '/@:acct/followers']} component={Followers} content={children} /> + <WrappedRoute path={['/accounts/:id/following', '/users/:acct/following', '/@:acct/following']} component={Following} content={children} /> + <WrappedRoute path={['/@:acct/media', '/accounts/:id/media']} component={AccountGallery} content={children} /> + <WrappedRoute path='/@:acct/:statusId' exact component={Status} content={children} /> + <WrappedRoute path='/@:acct/:statusId/reblogs' component={Reblogs} content={children} /> + <WrappedRoute path='/@:acct/:statusId/favourites' component={Favourites} content={children} /> + + {/* Legacy routes, cannot be easily factored with other routes because they share a param name */} + <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} /> + <WrappedRoute path='/timelines/list/:id' component={ListTimeline} content={children} /> + <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} /> + <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} /> + <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} /> + + <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} /> + <WrappedRoute path='/blocks' component={Blocks} content={children} /> + <WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} /> + <WrappedRoute path='/followed_tags' component={FollowedTags} content={children} /> + <WrappedRoute path='/mutes' component={Mutes} content={children} /> + <WrappedRoute path='/lists' component={Lists} content={children} /> + <WrappedRoute path='/getting-started-misc' component={GettingStartedMisc} content={children} /> + + <Route component={BundleColumnError} /> + </WrappedSwitch> + </ColumnsAreaContainer> + ); + } + +} + +export default @connect(mapStateToProps) +@injectIntl +@withRouter +class UI extends React.Component { + + static contextTypes = { + identity: PropTypes.object.isRequired, + }; + + static propTypes = { + dispatch: PropTypes.func.isRequired, + children: PropTypes.node, + layout_local_setting: PropTypes.string, + isWide: PropTypes.bool, + systemFontUi: PropTypes.bool, + navbarUnder: PropTypes.bool, + isComposing: PropTypes.bool, + hasComposingText: PropTypes.bool, + hasMediaAttachments: PropTypes.bool, + canUploadMore: PropTypes.bool, + match: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + history: PropTypes.object.isRequired, + intl: PropTypes.object.isRequired, + dropdownMenuIsOpen: PropTypes.bool, + unreadNotifications: PropTypes.number, + showFaviconBadge: PropTypes.bool, + moved: PropTypes.map, + layout: PropTypes.string.isRequired, + firstLaunch: PropTypes.bool, + username: PropTypes.string, + }; + + state = { + draggingOver: false, + }; + + handleBeforeUnload = (e) => { + const { intl, dispatch, hasComposingText, hasMediaAttachments } = this.props; + + dispatch(synchronouslySubmitMarkers()); + + if (hasComposingText || hasMediaAttachments) { + // Setting returnValue to any string causes confirmation dialog. + // Many browsers no longer display this text to users, + // but we set user-friendly message for other browsers, e.g. Edge. + e.returnValue = intl.formatMessage(messages.beforeUnload); + } + }; + + handleDragEnter = (e) => { + e.preventDefault(); + + if (!this.dragTargets) { + this.dragTargets = []; + } + + if (this.dragTargets.indexOf(e.target) === -1) { + this.dragTargets.push(e.target); + } + + if (e.dataTransfer && e.dataTransfer.types.includes('Files') && this.props.canUploadMore && this.context.identity.signedIn) { + this.setState({ draggingOver: true }); + } + }; + + handleDragOver = (e) => { + if (this.dataTransferIsText(e.dataTransfer)) return false; + e.preventDefault(); + e.stopPropagation(); + + try { + e.dataTransfer.dropEffect = 'copy'; + } catch (err) { + + } + + return false; + }; + + handleDrop = (e) => { + if (this.dataTransferIsText(e.dataTransfer)) return; + + e.preventDefault(); + + this.setState({ draggingOver: false }); + this.dragTargets = []; + + if (e.dataTransfer && e.dataTransfer.files.length >= 1 && this.props.canUploadMore && this.context.identity.signedIn) { + this.props.dispatch(uploadCompose(e.dataTransfer.files)); + } + }; + + handleDragLeave = (e) => { + e.preventDefault(); + e.stopPropagation(); + + this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el)); + + if (this.dragTargets.length > 0) { + return; + } + + this.setState({ draggingOver: false }); + }; + + dataTransferIsText = (dataTransfer) => { + return (dataTransfer && Array.from(dataTransfer.types).filter((type) => type === 'text/plain').length === 1); + }; + + closeUploadModal = () => { + this.setState({ draggingOver: false }); + }; + + handleServiceWorkerPostMessage = ({ data }) => { + if (data.type === 'navigate') { + this.props.history.push(data.path); + } else { + console.warn('Unknown message type:', data.type); + } + }; + + handleVisibilityChange = () => { + const visibility = !document[this.visibilityHiddenProp]; + this.props.dispatch(notificationsSetVisibility(visibility)); + if (visibility) { + this.props.dispatch(submitMarkers({ immediate: true })); + } + }; + + handleLayoutChange = debounce(() => { + this.props.dispatch(clearHeight()); // The cached heights are no longer accurate, invalidate + }, 500, { + trailing: true, + }); + + handleResize = () => { + const layout = layoutFromWindow(this.props.layout_local_setting); + + if (layout !== this.props.layout) { + this.handleLayoutChange.cancel(); + this.props.dispatch(changeLayout(layout)); + } else { + this.handleLayoutChange(); + } + }; + + componentDidMount () { + const { signedIn } = this.context.identity; + + window.addEventListener('beforeunload', this.handleBeforeUnload, false); + window.addEventListener('resize', this.handleResize, { passive: true }); + + document.addEventListener('dragenter', this.handleDragEnter, false); + document.addEventListener('dragover', this.handleDragOver, false); + document.addEventListener('drop', this.handleDrop, false); + document.addEventListener('dragleave', this.handleDragLeave, false); + document.addEventListener('dragend', this.handleDragEnd, false); + + if ('serviceWorker' in navigator) { + navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage); + } + + this.favicon = new Favico({ animation:'none' }); + + // On first launch, redirect to the follow recommendations page + if (signedIn && this.props.firstLaunch) { + this.context.router.history.replace('/start'); + this.props.dispatch(closeOnboarding()); + } + + if (signedIn) { + this.props.dispatch(fetchMarkers()); + this.props.dispatch(expandHomeTimeline()); + this.props.dispatch(expandNotifications()); + + setTimeout(() => this.props.dispatch(fetchServer()), 3000); + } + + this.hotkeys.__mousetrap__.stopCallback = (e, element) => { + return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName); + }; + + if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support + this.visibilityHiddenProp = 'hidden'; + this.visibilityChange = 'visibilitychange'; + } else if (typeof document.msHidden !== 'undefined') { + this.visibilityHiddenProp = 'msHidden'; + this.visibilityChange = 'msvisibilitychange'; + } else if (typeof document.webkitHidden !== 'undefined') { + this.visibilityHiddenProp = 'webkitHidden'; + this.visibilityChange = 'webkitvisibilitychange'; + } + + if (this.visibilityChange !== undefined) { + document.addEventListener(this.visibilityChange, this.handleVisibilityChange, false); + this.handleVisibilityChange(); + } + } + + componentWillReceiveProps (nextProps) { + if (nextProps.layout_local_setting !== this.props.layout_local_setting) { + const layout = layoutFromWindow(nextProps.layout_local_setting); + + if (layout !== this.props.layout) { + this.handleLayoutChange.cancel(); + this.props.dispatch(changeLayout(layout)); + } else { + this.handleLayoutChange(); + } + } + } + + componentDidUpdate (prevProps) { + if (this.props.unreadNotifications != prevProps.unreadNotifications || + this.props.showFaviconBadge != prevProps.showFaviconBadge) { + if (this.favicon) { + try { + this.favicon.badge(this.props.showFaviconBadge ? this.props.unreadNotifications : 0); + } catch (err) { + console.error(err); + } + } + } + } + + componentWillUnmount () { + if (this.visibilityChange !== undefined) { + document.removeEventListener(this.visibilityChange, this.handleVisibilityChange); + } + + window.removeEventListener('beforeunload', this.handleBeforeUnload); + window.removeEventListener('resize', this.handleResize); + + document.removeEventListener('dragenter', this.handleDragEnter); + document.removeEventListener('dragover', this.handleDragOver); + document.removeEventListener('drop', this.handleDrop); + document.removeEventListener('dragleave', this.handleDragLeave); + document.removeEventListener('dragend', this.handleDragEnd); + } + + setRef = c => { + this.node = c; + }; + + handleHotkeyNew = e => { + e.preventDefault(); + + const element = this.node.querySelector('.compose-form__autosuggest-wrapper textarea'); + + if (element) { + element.focus(); + } + }; + + handleHotkeySearch = e => { + e.preventDefault(); + + const element = this.node.querySelector('.search__input'); + + if (element) { + element.focus(); + } + }; + + handleHotkeyForceNew = e => { + this.handleHotkeyNew(e); + this.props.dispatch(resetCompose()); + }; + + handleHotkeyToggleComposeSpoilers = e => { + e.preventDefault(); + this.props.dispatch(changeComposeSpoilerness()); + }; + + handleHotkeyFocusColumn = e => { + const index = (e.key * 1) + 1; // First child is drawer, skip that + const column = this.node.querySelector(`.column:nth-child(${index})`); + if (!column) return; + const container = column.querySelector('.scrollable'); + + if (container) { + const status = container.querySelector('.focusable'); + + if (status) { + if (container.scrollTop > status.offsetTop) { + status.scrollIntoView(true); + } + status.focus(); + } + } + }; + + handleHotkeyBack = () => { + // if history is exhausted, or we would leave mastodon, just go to root. + if (window.history.state) { + this.props.history.goBack(); + } else { + this.props.history.push('/'); + } + }; + + setHotkeysRef = c => { + this.hotkeys = c; + }; + + handleHotkeyToggleHelp = () => { + if (this.props.location.pathname === '/keyboard-shortcuts') { + this.props.history.goBack(); + } else { + this.props.history.push('/keyboard-shortcuts'); + } + }; + + handleHotkeyGoToHome = () => { + this.props.history.push('/home'); + }; + + handleHotkeyGoToNotifications = () => { + this.props.history.push('/notifications'); + }; + + handleHotkeyGoToLocal = () => { + this.props.history.push('/public/local'); + }; + + handleHotkeyGoToFederated = () => { + this.props.history.push('/public'); + }; + + handleHotkeyGoToDirect = () => { + this.props.history.push('/conversations'); + }; + + handleHotkeyGoToStart = () => { + this.props.history.push('/getting-started'); + }; + + handleHotkeyGoToFavourites = () => { + this.props.history.push('/favourites'); + }; + + handleHotkeyGoToPinned = () => { + this.props.history.push('/pinned'); + }; + + handleHotkeyGoToProfile = () => { + this.props.history.push(`/@${this.props.username}`); + }; + + handleHotkeyGoToBlocked = () => { + this.props.history.push('/blocks'); + }; + + handleHotkeyGoToMuted = () => { + this.props.history.push('/mutes'); + }; + + handleHotkeyGoToRequests = () => { + this.props.history.push('/follow_requests'); + }; + + render () { + const { draggingOver } = this.state; + const { children, isWide, navbarUnder, location, dropdownMenuIsOpen, layout, moved } = this.props; + + const columnsClass = layout => { + switch (layout) { + case 'single': + return 'single-column'; + case 'multiple': + return 'multi-columns'; + default: + return 'auto-columns'; + } + }; + + const className = classNames('ui', columnsClass(layout), { + 'wide': isWide, + 'system-font': this.props.systemFontUi, + 'navbar-under': navbarUnder, + 'hicolor-privacy-icons': this.props.hicolorPrivacyIcons, + }); + + const handlers = { + help: this.handleHotkeyToggleHelp, + new: this.handleHotkeyNew, + search: this.handleHotkeySearch, + forceNew: this.handleHotkeyForceNew, + toggleComposeSpoilers: this.handleHotkeyToggleComposeSpoilers, + focusColumn: this.handleHotkeyFocusColumn, + back: this.handleHotkeyBack, + goToHome: this.handleHotkeyGoToHome, + goToNotifications: this.handleHotkeyGoToNotifications, + goToLocal: this.handleHotkeyGoToLocal, + goToFederated: this.handleHotkeyGoToFederated, + goToDirect: this.handleHotkeyGoToDirect, + goToStart: this.handleHotkeyGoToStart, + goToFavourites: this.handleHotkeyGoToFavourites, + goToPinned: this.handleHotkeyGoToPinned, + goToProfile: this.handleHotkeyGoToProfile, + goToBlocked: this.handleHotkeyGoToBlocked, + goToMuted: this.handleHotkeyGoToMuted, + goToRequests: this.handleHotkeyGoToRequests, + }; + + return ( + <HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef} attach={window} focused> + <div className={className} ref={this.setRef} style={{ pointerEvents: dropdownMenuIsOpen ? 'none' : null }}> + {moved && (<div className='flash-message alert'> + <FormattedMessage + id='moved_to_warning' + defaultMessage='This account is marked as moved to {moved_to_link}, and may thus not accept new follows.' + values={{ moved_to_link: ( + <PermaLink href={moved.get('url')} to={`/@${moved.get('acct')}`}> + @{moved.get('acct')} + </PermaLink> + ) }} + /> + </div>)} + + <Header /> + + <SwitchingColumnsArea location={location} mobile={layout === 'mobile' || layout === 'single-column'} navbarUnder={navbarUnder}> + {children} + </SwitchingColumnsArea> + + {layout !== 'mobile' && <PictureInPicture />} + <NotificationsContainer /> + <LoadingBarContainer className='loading-bar' /> + <ModalContainer /> + <UploadArea active={draggingOver} onClose={this.closeUploadModal} /> + </div> + </HotKeys> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/util/async-components.js b/app/javascript/flavours/glitch/features/ui/util/async-components.js new file mode 100644 index 000000000..03e501628 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/util/async-components.js @@ -0,0 +1,207 @@ +export function EmojiPicker () { + return import(/* webpackChunkName: "flavours/glitch/async/emoji_picker" */'flavours/glitch/features/emoji/emoji_picker'); +} + +export function Compose () { + return import(/* webpackChunkName: "flavours/glitch/async/compose" */'flavours/glitch/features/compose'); +} + +export function Notifications () { + return import(/* webpackChunkName: "flavours/glitch/async/notifications" */'flavours/glitch/features/notifications'); +} + +export function HomeTimeline () { + return import(/* webpackChunkName: "flavours/glitch/async/home_timeline" */'flavours/glitch/features/home_timeline'); +} + +export function PublicTimeline () { + return import(/* webpackChunkName: "flavours/glitch/async/public_timeline" */'flavours/glitch/features/public_timeline'); +} + +export function CommunityTimeline () { + return import(/* webpackChunkName: "flavours/glitch/async/community_timeline" */'flavours/glitch/features/community_timeline'); +} + +export function HashtagTimeline () { + return import(/* webpackChunkName: "flavours/glitch/async/hashtag_timeline" */'flavours/glitch/features/hashtag_timeline'); +} + +export function ListTimeline () { + return import(/* webpackChunkName: "flavours/glitch/async/list_timeline" */'flavours/glitch/features/list_timeline'); +} + +export function Lists () { + return import(/* webpackChunkName: "flavours/glitch/async/lists" */'flavours/glitch/features/lists'); +} + +export function ListEditor () { + return import(/* webpackChunkName: "flavours/glitch/async/list_editor" */'flavours/glitch/features/list_editor'); +} + +export function PinnedAccountsEditor () { + return import(/* webpackChunkName: "flavours/glitch/async/pinned_accounts_editor" */'flavours/glitch/features/pinned_accounts_editor'); +} + +export function DirectTimeline() { + return import(/* webpackChunkName: "flavours/glitch/async/direct_timeline" */'flavours/glitch/features/direct_timeline'); +} + +export function Status () { + return import(/* webpackChunkName: "flavours/glitch/async/status" */'flavours/glitch/features/status'); +} + +export function GettingStarted () { + return import(/* webpackChunkName: "flavours/glitch/async/getting_started" */'flavours/glitch/features/getting_started'); +} + +export function KeyboardShortcuts () { + return import(/* webpackChunkName: "flavours/glitch/async/keyboard_shortcuts" */'flavours/glitch/features/keyboard_shortcuts'); +} + +export function PinnedStatuses () { + return import(/* webpackChunkName: "flavours/glitch/async/pinned_statuses" */'flavours/glitch/features/pinned_statuses'); +} + +export function AccountTimeline () { + return import(/* webpackChunkName: "flavours/glitch/async/account_timeline" */'flavours/glitch/features/account_timeline'); +} + +export function AccountGallery () { + return import(/* webpackChunkName: "flavours/glitch/async/account_gallery" */'flavours/glitch/features/account_gallery'); +} + +export function Followers () { + return import(/* webpackChunkName: "flavours/glitch/async/followers" */'flavours/glitch/features/followers'); +} + +export function Following () { + return import(/* webpackChunkName: "flavours/glitch/async/following" */'flavours/glitch/features/following'); +} + +export function Reblogs () { + return import(/* webpackChunkName: "flavours/glitch/async/reblogs" */'flavours/glitch/features/reblogs'); +} + +export function Favourites () { + return import(/* webpackChunkName: "flavours/glitch/async/favourites" */'flavours/glitch/features/favourites'); +} + +export function FollowRequests () { + return import(/* webpackChunkName: "flavours/glitch/async/follow_requests" */'flavours/glitch/features/follow_requests'); +} + +export function GenericNotFound () { + return import(/* webpackChunkName: "flavours/glitch/async/generic_not_found" */'flavours/glitch/features/generic_not_found'); +} + +export function FavouritedStatuses () { + return import(/* webpackChunkName: "flavours/glitch/async/favourited_statuses" */'flavours/glitch/features/favourited_statuses'); +} + +export function FollowedTags () { + return import(/* webpackChunkName: "flavours/glitch/async/followed_tags" */'flavours/glitch/features/followed_tags'); +} + +export function BookmarkedStatuses () { + return import(/* webpackChunkName: "flavours/glitch/async/bookmarked_statuses" */'flavours/glitch/features/bookmarked_statuses'); +} + +export function Blocks () { + return import(/* webpackChunkName: "flavours/glitch/async/blocks" */'flavours/glitch/features/blocks'); +} + +export function DomainBlocks () { + return import(/* webpackChunkName: "flavours/glitch/async/domain_blocks" */'flavours/glitch/features/domain_blocks'); +} + +export function Mutes () { + return import(/* webpackChunkName: "flavours/glitch/async/mutes" */'flavours/glitch/features/mutes'); +} + +export function OnboardingModal () { + return import(/* webpackChunkName: "flavours/glitch/async/onboarding_modal" */'flavours/glitch/features/ui/components/onboarding_modal'); +} + +export function MuteModal () { + return import(/* webpackChunkName: "flavours/glitch/async/mute_modal" */'flavours/glitch/features/ui/components/mute_modal'); +} + +export function BlockModal () { + return import(/* webpackChunkName: "flavours/glitch/async/block_modal" */'flavours/glitch/features/ui/components/block_modal'); +} + +export function ReportModal () { + return import(/* webpackChunkName: "flavours/glitch/async/report_modal" */'flavours/glitch/features/ui/components/report_modal'); +} + +export function SettingsModal () { + return import(/* webpackChunkName: "flavours/glitch/async/settings_modal" */'flavours/glitch/features/local_settings'); +} + +export function MediaGallery () { + return import(/* webpackChunkName: "flavours/glitch/async/media_gallery" */'flavours/glitch/components/media_gallery'); +} + +export function Video () { + return import(/* webpackChunkName: "flavours/glitch/async/video" */'flavours/glitch/features/video'); +} + +export function Audio () { + return import(/* webpackChunkName: "features/glitch/async/audio" */'flavours/glitch/features/audio'); +} + +export function EmbedModal () { + return import(/* webpackChunkName: "flavours/glitch/async/embed_modal" */'flavours/glitch/features/ui/components/embed_modal'); +} + +export function GettingStartedMisc () { + return import(/* webpackChunkName: "flavours/glitch/async/getting_started_misc" */'flavours/glitch/features/getting_started_misc'); +} + +export function ListAdder () { + return import(/* webpackChunkName: "features/glitch/async/list_adder" */'flavours/glitch/features/list_adder'); +} + +export function Tesseract () { + return import(/*webpackChunkName: "tesseract" */'tesseract.js'); +} + +export function Directory () { + return import(/* webpackChunkName: "features/glitch/async/directory" */'flavours/glitch/features/directory'); +} + +export function FollowRecommendations () { + return import(/* webpackChunkName: "features/glitch/async/follow_recommendations" */'flavours/glitch/features/follow_recommendations'); +} + +export function CompareHistoryModal () { + return import(/*webpackChunkName: "flavours/glitch/async/compare_history_modal" */'flavours/glitch/features/ui/components/compare_history_modal'); +} + +export function FilterModal () { + return import(/*webpackChunkName: "flavours/glitch/async/filter_modal" */'flavours/glitch/features/ui/components/filter_modal'); +} + +export function Explore () { + return import(/* webpackChunkName: "flavours/glitch/async/explore" */'flavours/glitch/features/explore'); +} + +export function InteractionModal () { + return import(/*webpackChunkName: "flavours/glitch/async/modals/interaction_modal" */'flavours/glitch/features/interaction_modal'); +} + +export function SubscribedLanguagesModal () { + return import(/*webpackChunkName: "flavours/glitch/async/modals/subscribed_languages_modal" */'flavours/glitch/features/subscribed_languages_modal'); +} + +export function ClosedRegistrationsModal () { + return import(/*webpackChunkName: "flavours/glitch/async/modals/closed_registrations_modal" */'flavours/glitch/features/closed_registrations_modal'); +} + +export function About () { + return import(/*webpackChunkName: "features/glitch/async/about" */'flavours/glitch/features/about'); +} + +export function PrivacyPolicy () { + return import(/*webpackChunkName: "features/glitch/async/privacy_policy" */'flavours/glitch/features/privacy_policy'); +} diff --git a/app/javascript/flavours/glitch/features/ui/util/fullscreen.js b/app/javascript/flavours/glitch/features/ui/util/fullscreen.js new file mode 100644 index 000000000..cf5d0cf98 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/util/fullscreen.js @@ -0,0 +1,46 @@ +// 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 exitFullscreen = () => { + if (document.exitFullscreen) { + document.exitFullscreen(); + } else if (document.webkitExitFullscreen) { + document.webkitExitFullscreen(); + } else if (document.mozCancelFullScreen) { + document.mozCancelFullScreen(); + } +}; + +export const requestFullscreen = el => { + if (el.requestFullscreen) { + el.requestFullscreen(); + } else if (el.webkitRequestFullscreen) { + el.webkitRequestFullscreen(); + } else if (el.mozRequestFullScreen) { + el.mozRequestFullScreen(); + } +}; + +export const attachFullscreenListener = (listener) => { + if ('onfullscreenchange' in document) { + document.addEventListener('fullscreenchange', listener); + } else if ('onwebkitfullscreenchange' in document) { + document.addEventListener('webkitfullscreenchange', listener); + } else if ('onmozfullscreenchange' in document) { + document.addEventListener('mozfullscreenchange', listener); + } +}; + +export const detachFullscreenListener = (listener) => { + if ('onfullscreenchange' in document) { + document.removeEventListener('fullscreenchange', listener); + } else if ('onwebkitfullscreenchange' in document) { + document.removeEventListener('webkitfullscreenchange', listener); + } else if ('onmozfullscreenchange' in document) { + document.removeEventListener('mozfullscreenchange', listener); + } +}; diff --git a/app/javascript/flavours/glitch/features/ui/util/get_rect_from_entry.js b/app/javascript/flavours/glitch/features/ui/util/get_rect_from_entry.js new file mode 100644 index 000000000..c266cd7dc --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/util/get_rect_from_entry.js @@ -0,0 +1,21 @@ + +// Get the bounding client rect from an IntersectionObserver entry. +// This is to work around a bug in Chrome: https://crbug.com/737228 + +let hasBoundingRectBug; + +function getRectFromEntry(entry) { + if (typeof hasBoundingRectBug !== 'boolean') { + const boundingRect = entry.target.getBoundingClientRect(); + const observerRect = entry.boundingClientRect; + hasBoundingRectBug = boundingRect.height !== observerRect.height || + boundingRect.top !== observerRect.top || + boundingRect.width !== observerRect.width || + boundingRect.bottom !== observerRect.bottom || + boundingRect.left !== observerRect.left || + boundingRect.right !== observerRect.right; + } + return hasBoundingRectBug ? entry.target.getBoundingClientRect() : entry.boundingClientRect; +} + +export default getRectFromEntry; diff --git a/app/javascript/flavours/glitch/features/ui/util/intersection_observer_wrapper.js b/app/javascript/flavours/glitch/features/ui/util/intersection_observer_wrapper.js new file mode 100644 index 000000000..2b24c6583 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/util/intersection_observer_wrapper.js @@ -0,0 +1,57 @@ +// Wrapper for IntersectionObserver in order to make working with it +// a bit easier. We also follow this performance advice: +// "If you need to observe multiple elements, it is both possible and +// advised to observe multiple elements using the same IntersectionObserver +// instance by calling observe() multiple times." +// https://developers.google.com/web/updates/2016/04/intersectionobserver + +class IntersectionObserverWrapper { + + callbacks = {}; + observerBacklog = []; + observer = null; + + connect (options) { + const onIntersection = (entries) => { + entries.forEach(entry => { + const id = entry.target.getAttribute('data-id'); + if (this.callbacks[id]) { + this.callbacks[id](entry); + } + }); + }; + + this.observer = new IntersectionObserver(onIntersection, options); + this.observerBacklog.forEach(([ id, node, callback ]) => { + this.observe(id, node, callback); + }); + this.observerBacklog = null; + } + + observe (id, node, callback) { + if (!this.observer) { + this.observerBacklog.push([ id, node, callback ]); + } else { + this.callbacks[id] = callback; + this.observer.observe(node); + } + } + + unobserve (id, node) { + if (this.observer) { + delete this.callbacks[id]; + this.observer.unobserve(node); + } + } + + disconnect () { + if (this.observer) { + this.callbacks = {}; + this.observer.disconnect(); + this.observer = null; + } + } + +} + +export default IntersectionObserverWrapper; diff --git a/app/javascript/flavours/glitch/features/ui/util/optional_motion.js b/app/javascript/flavours/glitch/features/ui/util/optional_motion.js new file mode 100644 index 000000000..a7fbe6310 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/util/optional_motion.js @@ -0,0 +1,5 @@ +import { reduceMotion } from 'flavours/glitch/initial_state'; +import ReducedMotion from './reduced_motion'; +import Motion from 'react-motion/lib/Motion'; + +export default reduceMotion ? ReducedMotion : Motion; diff --git a/app/javascript/flavours/glitch/features/ui/util/react_router_helpers.js b/app/javascript/flavours/glitch/features/ui/util/react_router_helpers.js new file mode 100644 index 000000000..b1c952d87 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/util/react_router_helpers.js @@ -0,0 +1,101 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Switch, Route } from 'react-router-dom'; +import StackTrace from 'stacktrace-js'; +import ColumnLoading from 'flavours/glitch/features/ui/components/column_loading'; +import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error'; +import BundleContainer from 'flavours/glitch/features/ui/containers/bundle_container'; + +// Small wrapper to pass multiColumn to the route components +export class WrappedSwitch extends React.PureComponent { + + render () { + const { multiColumn, children } = this.props; + + return ( + <Switch> + {React.Children.map(children, child => React.cloneElement(child, { multiColumn }))} + </Switch> + ); + } + +} + +WrappedSwitch.propTypes = { + multiColumn: PropTypes.bool, + children: PropTypes.node, +}; + +// Small Wraper to extract the params from the route and pass +// them to the rendered component, together with the content to +// be rendered inside (the children) +export class WrappedRoute extends React.Component { + + static propTypes = { + component: PropTypes.func.isRequired, + content: PropTypes.node, + multiColumn: PropTypes.bool, + componentParams: PropTypes.object, + }; + + static defaultProps = { + componentParams: {}, + }; + + static getDerivedStateFromError () { + return { + hasError: true, + }; + } + + state = { + hasError: false, + stacktrace: '', + }; + + componentDidCatch (error) { + StackTrace.fromError(error).then(stackframes => { + this.setState({ stacktrace: error.toString() + '\n' + stackframes.map(frame => frame.toString()).join('\n') }); + }).catch(err => { + console.error(err); + }); + } + + renderComponent = ({ match }) => { + const { component, content, multiColumn, componentParams } = this.props; + const { hasError, stacktrace } = this.state; + + if (hasError) { + return ( + <BundleColumnError + stacktrace={stacktrace} + multiColumn={multiColumn} + errorType='error' + /> + ); + } + + return ( + <BundleContainer fetchComponent={component} loading={this.renderLoading} error={this.renderError}> + {Component => <Component params={match.params} multiColumn={multiColumn} {...componentParams}>{content}</Component>} + </BundleContainer> + ); + }; + + renderLoading = () => { + const { multiColumn } = this.props; + + return <ColumnLoading multiColumn={multiColumn} />; + }; + + renderError = (props) => { + return <BundleColumnError {...props} errorType='network' />; + }; + + render () { + const { component: Component, content, ...rest } = this.props; + + return <Route {...rest} render={this.renderComponent} />; + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/util/reduced_motion.js b/app/javascript/flavours/glitch/features/ui/util/reduced_motion.js new file mode 100644 index 000000000..1123b80ed --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/util/reduced_motion.js @@ -0,0 +1,44 @@ +// Like react-motion's Motion, but reduces all animations to cross-fades +// for the benefit of users with motion sickness. +import React from 'react'; +import Motion from 'react-motion/lib/Motion'; +import PropTypes from 'prop-types'; + +const stylesToKeep = ['opacity', 'backgroundOpacity']; + +const extractValue = (value) => { + // This is either an object with a "val" property or it's a number + return (typeof value === 'object' && value && 'val' in value) ? value.val : value; +}; + +class ReducedMotion extends React.Component { + + static propTypes = { + defaultStyle: PropTypes.object, + style: PropTypes.object, + children: PropTypes.func, + }; + + render() { + + const { style, defaultStyle, children } = this.props; + + Object.keys(style).forEach(key => { + if (stylesToKeep.includes(key)) { + return; + } + // If it's setting an x or height or scale or some other value, we need + // to preserve the end-state value without actually animating it + style[key] = defaultStyle[key] = extractValue(style[key]); + }); + + return ( + <Motion style={style} defaultStyle={defaultStyle}> + {children} + </Motion> + ); + } + +} + +export default ReducedMotion; diff --git a/app/javascript/flavours/glitch/features/ui/util/schedule_idle_task.js b/app/javascript/flavours/glitch/features/ui/util/schedule_idle_task.js new file mode 100644 index 000000000..b04d4a8ee --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/util/schedule_idle_task.js @@ -0,0 +1,29 @@ +// Wrapper to call requestIdleCallback() to schedule low-priority work. +// See https://developer.mozilla.org/en-US/docs/Web/API/Background_Tasks_API +// for a good breakdown of the concepts behind this. + +import Queue from 'tiny-queue'; + +const taskQueue = new Queue(); +let runningRequestIdleCallback = false; + +function runTasks(deadline) { + while (taskQueue.length && deadline.timeRemaining() > 0) { + taskQueue.shift()(); + } + if (taskQueue.length) { + requestIdleCallback(runTasks); + } else { + runningRequestIdleCallback = false; + } +} + +function scheduleIdleTask(task) { + taskQueue.push(task); + if (!runningRequestIdleCallback) { + runningRequestIdleCallback = true; + requestIdleCallback(runTasks); + } +} + +export default scheduleIdleTask; |