diff options
Diffstat (limited to 'app/javascript/mastodon/features/ui')
18 files changed, 1211 insertions, 0 deletions
diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.js b/app/javascript/mastodon/features/ui/components/boost_modal.js new file mode 100644 index 000000000..d6000fe4e --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/boost_modal.js @@ -0,0 +1,84 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import IconButton from '../../../components/icon_button'; +import Button from '../../../components/button'; +import StatusContent from '../../../components/status_content'; +import Avatar from '../../../components/avatar'; +import RelativeTimestamp from '../../../components/relative_timestamp'; +import DisplayName from '../../../components/display_name'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + reblog: { id: 'status.reblog', defaultMessage: 'Boost' } +}); + +class BoostModal extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleReblog = this.handleReblog.bind(this); + this.handleAccountClick = this.handleAccountClick.bind(this); + } + + handleReblog() { + this.props.onReblog(this.props.status); + this.props.onClose(); + } + + handleAccountClick (e) { + if (e.button === 0) { + e.preventDefault(); + this.props.onClose(); + this.context.router.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); + } + } + + render () { + const { status, intl, onClose } = this.props; + + return ( + <div className='modal-root__modal boost-modal'> + <div className='boost-modal__container'> + <div className='status 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'><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 src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} /> + </div> + + <DisplayName account={status.get('account')} /> + </a> + </div> + + <StatusContent status={status} /> + </div> + </div> + + <div className='boost-modal__action-bar'> + <div><FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <i className='fa fa-retweet' /></span> }} /></div> + <Button text={intl.formatMessage(messages.reblog)} onClick={this.handleReblog} /> + </div> + </div> + ); + } + +} + +BoostModal.contextTypes = { + router: PropTypes.object +}; + +BoostModal.propTypes = { + status: ImmutablePropTypes.map.isRequired, + onReblog: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(BoostModal); diff --git a/app/javascript/mastodon/features/ui/components/column.js b/app/javascript/mastodon/features/ui/components/column.js new file mode 100644 index 000000000..fcb197573 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/column.js @@ -0,0 +1,93 @@ +import React from 'react'; +import ColumnHeader from './column_header'; +import PropTypes from 'prop-types'; + +const easingOutQuint = (x, t, b, c, d) => c*((t=t/d-1)*t*t*t*t + 1) + b; + +const scrollTop = (node) => { + const startTime = Date.now(); + const offset = node.scrollTop; + const targetY = -offset; + const duration = 1000; + let interrupt = false; + + const step = () => { + const elapsed = Date.now() - startTime; + const percentage = elapsed / duration; + + if (percentage > 1 || interrupt) { + return; + } + + node.scrollTop = easingOutQuint(0, elapsed, offset, targetY, duration); + requestAnimationFrame(step); + }; + + step(); + + return () => { + interrupt = true; + }; +}; + +class Column extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleHeaderClick = this.handleHeaderClick.bind(this); + this.handleWheel = this.handleWheel.bind(this); + this.setRef = this.setRef.bind(this); + } + + handleHeaderClick () { + const scrollable = this.node.querySelector('.scrollable'); + if (!scrollable) { + return; + } + this._interruptScrollAnimation = scrollTop(scrollable); + } + + handleWheel () { + if (typeof this._interruptScrollAnimation !== 'undefined') { + this._interruptScrollAnimation(); + } + } + + setRef (c) { + this.node = c; + } + + render () { + const { heading, icon, children, active, hideHeadingOnMobile } = this.props; + + let columnHeaderId = null + let header = ''; + + if (heading) { + columnHeaderId = heading.replace(/ /g, '-') + header = <ColumnHeader icon={icon} active={active} type={heading} onClick={this.handleHeaderClick} hideOnMobile={hideHeadingOnMobile} columnHeaderId={columnHeaderId}/>; + } + return ( + <div + ref={this.setRef} + role='region' + aria-labelledby={columnHeaderId} + className='column' + onWheel={this.handleWheel}> + {header} + {children} + </div> + ); + } + +} + +Column.propTypes = { + heading: PropTypes.string, + icon: PropTypes.string, + children: PropTypes.node, + active: PropTypes.bool, + hideHeadingOnMobile: PropTypes.bool +}; + +export default Column; diff --git a/app/javascript/mastodon/features/ui/components/column_header.js b/app/javascript/mastodon/features/ui/components/column_header.js new file mode 100644 index 000000000..2701cd57d --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/column_header.js @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types' + +class ColumnHeader extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleClick = this.handleClick.bind(this); + } + + handleClick () { + this.props.onClick(); + } + + render () { + const { type, active, hideOnMobile, columnHeaderId } = this.props; + + let icon = ''; + + if (this.props.icon) { + icon = <i className={`fa fa-fw fa-${this.props.icon} column-header__icon`} />; + } + + return ( + <div role='button heading' tabIndex='0' className={`column-header ${active ? 'active' : ''} ${hideOnMobile ? 'hidden-on-mobile' : ''}`} onClick={this.handleClick} id={columnHeaderId || null}> + {icon} + {type} + </div> + ); + } + +} + +ColumnHeader.propTypes = { + icon: PropTypes.string, + type: PropTypes.string, + active: PropTypes.bool, + onClick: PropTypes.func, + hideOnMobile: PropTypes.bool, + columnHeaderId: PropTypes.string +}; + +export default ColumnHeader; diff --git a/app/javascript/mastodon/features/ui/components/column_link.js b/app/javascript/mastodon/features/ui/components/column_link.js new file mode 100644 index 000000000..cffe796ba --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/column_link.js @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router'; + +const ColumnLink = ({ icon, text, to, href, method, hideOnMobile }) => { + if (href) { + return ( + <a href={href} className={`column-link ${hideOnMobile ? 'hidden-on-mobile' : ''}`} data-method={method}> + <i className={`fa fa-fw fa-${icon} column-link__icon`} /> + {text} + </a> + ); + } else { + return ( + <Link to={to} className={`column-link ${hideOnMobile ? 'hidden-on-mobile' : ''}`}> + <i className={`fa fa-fw fa-${icon} column-link__icon`} /> + {text} + </Link> + ); + } +}; + +ColumnLink.propTypes = { + icon: PropTypes.string.isRequired, + text: PropTypes.string.isRequired, + to: PropTypes.string, + href: PropTypes.string, + method: PropTypes.string, + hideOnMobile: PropTypes.bool +}; + +export default ColumnLink; diff --git a/app/javascript/mastodon/features/ui/components/column_subheading.js b/app/javascript/mastodon/features/ui/components/column_subheading.js new file mode 100644 index 000000000..8160c4aa3 --- /dev/null +++ b/app/javascript/mastodon/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/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js new file mode 100644 index 000000000..05f9f3fb5 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -0,0 +1,20 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +class ColumnsArea extends React.PureComponent { + + render () { + return ( + <div className='columns-area'> + {this.props.children} + </div> + ); + } + +} + +ColumnsArea.propTypes = { + children: PropTypes.node +}; + +export default ColumnsArea; diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modal.js b/app/javascript/mastodon/features/ui/components/confirmation_modal.js new file mode 100644 index 000000000..499993207 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/confirmation_modal.js @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Button from '../../../components/button'; + +class ConfirmationModal extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleClick = this.handleClick.bind(this); + this.handleCancel = this.handleCancel.bind(this); + } + + handleClick () { + this.props.onClose(); + this.props.onConfirm(); + } + + handleCancel (e) { + e.preventDefault(); + this.props.onClose(); + } + + render () { + const { intl, message, confirm, onConfirm, onClose } = this.props; + + return ( + <div className='modal-root__modal confirmation-modal'> + <div className='confirmation-modal__container'> + {message} + </div> + + <div className='confirmation-modal__action-bar'> + <div><a href='#' onClick={this.handleCancel}><FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' /></a></div> + <Button text={confirm} onClick={this.handleClick} /> + </div> + </div> + ); + } + +} + +ConfirmationModal.propTypes = { + message: PropTypes.node.isRequired, + confirm: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(ConfirmationModal); diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js new file mode 100644 index 000000000..a8fb3858a --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/media_modal.js @@ -0,0 +1,103 @@ +import React from 'react'; +import LoadingIndicator from '../../../components/loading_indicator'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import ExtendedVideoPlayer from '../../../components/extended_video_player'; +import ImageLoader from 'react-imageloader'; +import { defineMessages, injectIntl } from 'react-intl'; +import IconButton from '../../../components/icon_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' } +}); + +class MediaModal extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.state = { + index: null + }; + this.handleNextClick = this.handleNextClick.bind(this); + this.handlePrevClick = this.handlePrevClick.bind(this); + this.handleKeyUp = this.handleKeyUp.bind(this); + } + + handleNextClick () { + this.setState({ index: (this.getIndex() + 1) % this.props.media.size}); + } + + handlePrevClick () { + this.setState({ index: (this.getIndex() - 1) % this.props.media.size}); + } + + handleKeyUp (e) { + switch(e.key) { + case 'ArrowLeft': + this.handlePrevClick(); + break; + case 'ArrowRight': + this.handleNextClick(); + break; + } + } + + componentDidMount () { + window.addEventListener('keyup', this.handleKeyUp, false); + } + + componentWillUnmount () { + window.removeEventListener('keyup', this.handleKeyUp); + } + + getIndex () { + return this.state.index !== null ? this.state.index : this.props.index; + } + + render () { + const { media, intl, onClose } = this.props; + + const index = this.getIndex(); + const attachment = media.get(index); + const url = attachment.get('url'); + + let leftNav, rightNav, content; + + leftNav = rightNav = content = ''; + + if (media.size > 1) { + leftNav = <div role='button' tabIndex='0' className='modal-container__nav modal-container__nav--left' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>; + rightNav = <div role='button' tabIndex='0' className='modal-container__nav modal-container__nav--right' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>; + } + + if (attachment.get('type') === 'image') { + content = <ImageLoader src={url} imgProps={{ style: { display: 'block' } }} />; + } else if (attachment.get('type') === 'gifv') { + content = <ExtendedVideoPlayer src={url} muted={true} controls={false} />; + } + + return ( + <div className='modal-root__modal media-modal'> + {leftNav} + + <div className='media-modal__content'> + <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} /> + {content} + </div> + + {rightNav} + </div> + ); + } + +} + +MediaModal.propTypes = { + media: ImmutablePropTypes.list.isRequired, + index: PropTypes.number.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(MediaModal); diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js new file mode 100644 index 000000000..5cde65907 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -0,0 +1,93 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import MediaModal from './media_modal'; +import OnboardingModal from './onboarding_modal'; +import VideoModal from './video_modal'; +import BoostModal from './boost_modal'; +import ConfirmationModal from './confirmation_modal'; +import { TransitionMotion, spring } from 'react-motion'; + +const MODAL_COMPONENTS = { + 'MEDIA': MediaModal, + 'ONBOARDING': OnboardingModal, + 'VIDEO': VideoModal, + 'BOOST': BoostModal, + 'CONFIRM': ConfirmationModal +}; + +class ModalRoot extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleKeyUp = this.handleKeyUp.bind(this); + } + + handleKeyUp (e) { + if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27) + && !!this.props.type) { + this.props.onClose(); + } + } + + componentDidMount () { + window.addEventListener('keyup', this.handleKeyUp, false); + } + + componentWillUnmount () { + window.removeEventListener('keyup', this.handleKeyUp); + } + + willEnter () { + return { opacity: 0, scale: 0.98 }; + } + + willLeave () { + return { opacity: spring(0), scale: spring(0.98) }; + } + + render () { + const { type, props, onClose } = this.props; + const items = []; + + if (!!type) { + items.push({ + key: type, + data: { type, props }, + style: { opacity: spring(1), scale: spring(1, { stiffness: 120, damping: 14 }) } + }); + } + + return ( + <TransitionMotion + styles={items} + willEnter={this.willEnter} + willLeave={this.willLeave}> + {interpolatedStyles => + <div className='modal-root'> + {interpolatedStyles.map(({ key, data: { type, props }, style }) => { + const SpecificComponent = MODAL_COMPONENTS[type]; + + return ( + <div key={key}> + <div role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity }} onClick={onClose} /> + <div className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}> + <SpecificComponent {...props} onClose={onClose} /> + </div> + </div> + ); + })} + </div> + } + </TransitionMotion> + ); + } + +} + +ModalRoot.propTypes = { + type: PropTypes.string, + props: PropTypes.object, + onClose: PropTypes.func.isRequired +}; + +export default ModalRoot; diff --git a/app/javascript/mastodon/features/ui/components/onboarding_modal.js b/app/javascript/mastodon/features/ui/components/onboarding_modal.js new file mode 100644 index 000000000..7cdd3527a --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/onboarding_modal.js @@ -0,0 +1,264 @@ +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 Permalink from '../../../components/permalink'; +import { TransitionMotion, spring } from 'react-motion'; +import ComposeForm from '../../compose/components/compose_form'; +import Search from '../../compose/components/search'; +import NavigationBar from '../../compose/components/navigation_bar'; +import ColumnHeader from './column_header'; +import Immutable from 'immutable'; + +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 Mastodon!' /></h1> + <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' /></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 = ({ me }) => ( + <div className='onboarding-modal__page onboarding-modal__page-two'> + <div className='figure non-interactive'> + <div className='pseudo-drawer'> + <NavigationBar account={me} /> + </div> + <ComposeForm + text='Awoo! #introductions' + suggestions={Immutable.List()} + mentionedDomains={[]} + spoiler={false} + onChange={() => {}} + onSubmit={() => {}} + onPaste={() => {}} + onPickEmoji={() => {}} + onChangeSpoilerText={() => {}} + onClearSuggestions={() => {}} + onFetchSuggestions={() => {}} + onSuggestionSelected={() => {}} + /> + </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 = { + me: ImmutablePropTypes.map.isRequired, +}; + +const PageThree = ({ me, domain }) => ( + <div className='onboarding-modal__page onboarding-modal__page-three'> + <div className='figure non-interactive'> + <Search + value='' + onChange={() => {}} + onSubmit={() => {}} + onClear={() => {}} + onShow={() => {}} + /> + + <div className='pseudo-drawer'> + <NavigationBar account={me} /> + </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='/timelines/tag/illustration' href='/tags/illustration'>#illustration</Permalink>, introductions: <Permalink to='/timelines/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 = { + me: ImmutablePropTypes.map.isRequired, + domain: PropTypes.string.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={`/accounts/${admin.get('id')}`}>@{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='Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.' values={{ github: <a href='https://github.com/tootsuite/mastodon' 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={{ apps: <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' 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 => ({ + me: state.getIn(['accounts', state.getIn(['meta', 'me'])]), + admin: state.getIn(['accounts', state.getIn(['meta', 'admin'])]), + domain: state.getIn(['meta', 'domain']) +}); + +class OnboardingModal extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.state = { + currentIndex: 0 + }; + this.handleSkip = this.handleSkip.bind(this); + this.handleDot = this.handleDot.bind(this); + this.handleNext = this.handleNext.bind(this); + } + + handleSkip (e) { + e.preventDefault(); + this.props.onClose(); + } + + handleDot (i, e) { + e.preventDefault(); + this.setState({ currentIndex: i }); + } + + handleNext (maxNum, e) { + e.preventDefault(); + + if (this.state.currentIndex < maxNum - 1) { + this.setState({ currentIndex: this.state.currentIndex + 1 }); + } else { + this.props.onClose(); + } + } + + render () { + const { me, admin, domain, intl } = this.props; + + const pages = [ + <PageOne acct={me.get('acct')} domain={domain} />, + <PageTwo me={me} />, + <PageThree me={me} domain={domain} />, + <PageFour domain={domain} intl={intl} />, + <PageSix admin={admin} domain={domain} /> + ]; + + const { currentIndex } = this.state; + const hasMore = currentIndex < pages.length - 1; + + let nextOrDoneBtn; + + if(hasMore) { + nextOrDoneBtn = <a href='#' onClick={this.handleNext.bind(null, pages.length)} className='onboarding-modal__nav onboarding-modal__next'><FormattedMessage id='onboarding.next' defaultMessage='Next' /></a>; + } else { + nextOrDoneBtn = <a href='#' onClick={this.handleNext.bind(null, pages.length)} className='onboarding-modal__nav onboarding-modal__done'><FormattedMessage id='onboarding.done' defaultMessage='Done' /></a>; + } + + const styles = pages.map((page, i) => ({ + key: `page-${i}`, + style: { opacity: spring(i === currentIndex ? 1 : 0) } + })); + + return ( + <div className='modal-root__modal onboarding-modal'> + <TransitionMotion styles={styles}> + {interpolatedStyles => + <div className='onboarding-modal__pager'> + {pages.map((page, i) => + <div key={`page-${i}`} style={{ opacity: interpolatedStyles[i].style.opacity, pointerEvents: i === currentIndex ? 'auto' : 'none' }}>{page}</div> + )} + </div> + } + </TransitionMotion> + + <div className='onboarding-modal__paginator'> + <div> + <a href='#' className='onboarding-modal__skip' onClick={this.handleSkip}><FormattedMessage id='onboarding.skip' defaultMessage='Skip' /></a> + </div> + + <div className='onboarding-modal__dots'> + {pages.map((_, i) => <div key={i} onClick={this.handleDot.bind(null, i)} className={`onboarding-modal__dot ${i === currentIndex ? 'active' : ''}`} />)} + </div> + + <div> + {nextOrDoneBtn} + </div> + </div> + </div> + ); + } + +} + +OnboardingModal.propTypes = { + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + me: ImmutablePropTypes.map.isRequired, + domain: PropTypes.string.isRequired, + admin: ImmutablePropTypes.map +} + +export default connect(mapStateToProps)(injectIntl(OnboardingModal)); diff --git a/app/javascript/mastodon/features/ui/components/tabs_bar.js b/app/javascript/mastodon/features/ui/components/tabs_bar.js new file mode 100644 index 000000000..b6a30bc11 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/tabs_bar.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { Link } from 'react-router'; +import { FormattedMessage } from 'react-intl'; + +class TabsBar extends React.Component { + + render () { + return ( + <div className='tabs-bar'> + <Link className='tabs-bar__link primary' activeClassName='active' to='/statuses/new'><i className='fa fa-fw fa-pencil' /><FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link> + <Link className='tabs-bar__link primary' activeClassName='active' to='/timelines/home'><i className='fa fa-fw fa-home' /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link> + <Link className='tabs-bar__link primary' activeClassName='active' to='/notifications'><i className='fa fa-fw fa-bell' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link> + + <Link className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public/local'><i className='fa fa-fw fa-users' /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></Link> + <Link className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public'><i className='fa fa-fw fa-globe' /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></Link> + + <Link className='tabs-bar__link primary' activeClassName='active' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link> + </div> + ); + } + +} + +export default TabsBar; diff --git a/app/javascript/mastodon/features/ui/components/upload_area.js b/app/javascript/mastodon/features/ui/components/upload_area.js new file mode 100644 index 000000000..c5710ee69 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/upload_area.js @@ -0,0 +1,60 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Motion, spring } from 'react-motion'; +import { FormattedMessage } from 'react-intl'; + +class UploadArea extends React.PureComponent { + + constructor (props, context) { + super(props, context); + + this.handleKeyUp = this.handleKeyUp.bind(this); + } + + handleKeyUp (e) { + e.preventDefault(); + e.stopPropagation(); + + const keyCode = e.keyCode + if (this.props.active) { + switch(keyCode) { + case 27: + 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: `translateZ(0) scale(${backgroundScale})` }} /> + <div className='upload-area__content'><FormattedMessage id='upload_area.title' defaultMessage='Drag & drop to upload' /></div> + </div> + </div> + } + </Motion> + ); + } + +} + +UploadArea.propTypes = { + active: PropTypes.bool, + onClose: PropTypes.func +}; + +export default UploadArea; diff --git a/app/javascript/mastodon/features/ui/components/video_modal.js b/app/javascript/mastodon/features/ui/components/video_modal.js new file mode 100644 index 000000000..8e2e4a533 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/video_modal.js @@ -0,0 +1,40 @@ +import React from 'react'; +import LoadingIndicator from '../../../components/loading_indicator'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import ExtendedVideoPlayer from '../../../components/extended_video_player'; +import { defineMessages, injectIntl } from 'react-intl'; +import IconButton from '../../../components/icon_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' } +}); + +class VideoModal extends ImmutablePureComponent { + + render () { + const { media, intl, time, onClose } = this.props; + + const url = media.get('url'); + + return ( + <div className='modal-root__modal media-modal'> + <div> + <div className='media-modal__close'><IconButton title={intl.formatMessage(messages.close)} icon='times' overlay onClick={onClose} /></div> + <ExtendedVideoPlayer src={url} muted={false} controls={true} time={time} /> + </div> + </div> + ); + } + +} + +VideoModal.propTypes = { + media: ImmutablePropTypes.map.isRequired, + time: PropTypes.number, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(VideoModal); diff --git a/app/javascript/mastodon/features/ui/containers/loading_bar_container.js b/app/javascript/mastodon/features/ui/containers/loading_bar_container.js new file mode 100644 index 000000000..6c4e73e38 --- /dev/null +++ b/app/javascript/mastodon/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) => ({ + loading: state.get('loadingBar') +}); + +export default connect(mapStateToProps)(LoadingBar.WrappedComponent); diff --git a/app/javascript/mastodon/features/ui/containers/modal_container.js b/app/javascript/mastodon/features/ui/containers/modal_container.js new file mode 100644 index 000000000..26d77818c --- /dev/null +++ b/app/javascript/mastodon/features/ui/containers/modal_container.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux'; +import { closeModal } from '../../../actions/modal'; +import ModalRoot from '../components/modal_root'; + +const mapStateToProps = state => ({ + type: state.get('modal').modalType, + props: state.get('modal').modalProps +}); + +const mapDispatchToProps = dispatch => ({ + onClose () { + dispatch(closeModal()); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot); diff --git a/app/javascript/mastodon/features/ui/containers/notifications_container.js b/app/javascript/mastodon/features/ui/containers/notifications_container.js new file mode 100644 index 000000000..529ebf6c8 --- /dev/null +++ b/app/javascript/mastodon/features/ui/containers/notifications_container.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import { NotificationStack } from 'react-notification'; +import { + dismissAlert, + clearAlerts +} from '../../../actions/alerts'; +import { getAlerts } from '../../../selectors'; + +const mapStateToProps = (state, props) => ({ + notifications: getAlerts(state) +}); + +const mapDispatchToProps = (dispatch) => { + return { + onDismiss: alert => { + dispatch(dismissAlert(alert)); + } + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(NotificationStack); diff --git a/app/javascript/mastodon/features/ui/containers/status_list_container.js b/app/javascript/mastodon/features/ui/containers/status_list_container.js new file mode 100644 index 000000000..1599000b5 --- /dev/null +++ b/app/javascript/mastodon/features/ui/containers/status_list_container.js @@ -0,0 +1,74 @@ +import { connect } from 'react-redux'; +import StatusList from '../../../components/status_list'; +import { expandTimeline, scrollTopTimeline } from '../../../actions/timelines'; +import Immutable from 'immutable'; +import { createSelector } from 'reselect'; +import { debounce } from 'react-decoration'; + +const makeGetStatusIds = () => createSelector([ + (state, { type }) => state.getIn(['settings', type], Immutable.Map()), + (state, { type }) => state.getIn(['timelines', type, 'items'], Immutable.List()), + (state) => state.get('statuses'), + (state) => state.getIn(['meta', 'me']) +], (columnSettings, statusIds, statuses, me) => statusIds.filter(id => { + const statusForId = statuses.get(id); + let showStatus = 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(['regex', 'body'], '').trim().length > 0) { + try { + if (showStatus) { + const regex = new RegExp(columnSettings.getIn(['regex', 'body']).trim(), 'i'); + showStatus = !regex.test(statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'unescaped_content']) : statusForId.get('unescaped_content')); + } + } catch(e) { + // Bad regex, don't affect filters + } + } + + return showStatus; +})); + +const makeMapStateToProps = () => { + const getStatusIds = makeGetStatusIds(); + + const mapStateToProps = (state, props) => ({ + scrollKey: props.scrollKey, + shouldUpdateScroll: props.shouldUpdateScroll, + statusIds: getStatusIds(state, props), + isLoading: state.getIn(['timelines', props.type, 'isLoading'], true), + isUnread: state.getIn(['timelines', props.type, 'unread']) > 0, + hasMore: !!state.getIn(['timelines', props.type, 'next']) + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { type, id }) => ({ + + @debounce(300, true) + onScrollToBottom () { + dispatch(scrollTopTimeline(type, false)); + dispatch(expandTimeline(type, id)); + }, + + @debounce(100) + onScrollToTop () { + dispatch(scrollTopTimeline(type, true)); + }, + + @debounce(100) + onScroll () { + dispatch(scrollTopTimeline(type, false)); + } + +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList); diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js new file mode 100644 index 000000000..d096cb882 --- /dev/null +++ b/app/javascript/mastodon/features/ui/index.js @@ -0,0 +1,169 @@ +import React from 'react'; +import ColumnsArea from './components/columns_area'; +import NotificationsContainer from './containers/notifications_container'; +import PropTypes from 'prop-types'; +import LoadingBarContainer from './containers/loading_bar_container'; +import HomeTimeline from '../home_timeline'; +import Compose from '../compose'; +import TabsBar from './components/tabs_bar'; +import ModalContainer from './containers/modal_container'; +import Notifications from '../notifications'; +import { connect } from 'react-redux'; +import { isMobile } from '../../is_mobile'; +import { debounce } from 'react-decoration'; +import { uploadCompose } from '../../actions/compose'; +import { refreshTimeline } from '../../actions/timelines'; +import { refreshNotifications } from '../../actions/notifications'; +import UploadArea from './components/upload_area'; + +const noOp = () => false; + +class UI extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.state = { + width: window.innerWidth, + draggingOver: false + }; + this.handleResize = this.handleResize.bind(this); + this.handleDragEnter = this.handleDragEnter.bind(this); + this.handleDragOver = this.handleDragOver.bind(this); + this.handleDrop = this.handleDrop.bind(this); + this.handleDragLeave = this.handleDragLeave.bind(this); + this.handleDragEnd = this.handleDragLeave.bind(this) + this.closeUploadModal = this.closeUploadModal.bind(this) + this.setRef = this.setRef.bind(this); + } + + @debounce(500) + handleResize () { + this.setState({ width: window.innerWidth }); + } + + 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.setState({ draggingOver: true }); + } + } + + handleDragOver (e) { + e.preventDefault(); + e.stopPropagation(); + + try { + e.dataTransfer.dropEffect = 'copy'; + } catch (err) { + + } + + return false; + } + + handleDrop (e) { + e.preventDefault(); + + this.setState({ draggingOver: false }); + + if (e.dataTransfer && e.dataTransfer.files.length === 1) { + 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 }); + } + + closeUploadModal() { + this.setState({ draggingOver: false }); + } + + componentWillMount () { + 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); + + this.props.dispatch(refreshTimeline('home')); + this.props.dispatch(refreshNotifications()); + } + + componentWillUnmount () { + 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; + } + + render () { + const { width, draggingOver } = this.state; + const { children } = this.props; + + let mountedColumns; + + if (isMobile(width)) { + mountedColumns = ( + <ColumnsArea> + {children} + </ColumnsArea> + ); + } else { + mountedColumns = ( + <ColumnsArea> + <Compose withHeader={true} /> + <HomeTimeline shouldUpdateScroll={noOp} /> + <Notifications shouldUpdateScroll={noOp} /> + <div style={{display: 'flex', flex: '1 1 auto', position: 'relative'}}>{children}</div> + </ColumnsArea> + ); + } + + return ( + <div className='ui' ref={this.setRef}> + <TabsBar /> + + {mountedColumns} + + <NotificationsContainer /> + <LoadingBarContainer className="loading-bar" /> + <ModalContainer /> + <UploadArea active={draggingOver} onClose={this.closeUploadModal} /> + </div> + ); + } + +} + +UI.propTypes = { + dispatch: PropTypes.func.isRequired, + children: PropTypes.node +}; + +export default connect()(UI); |