diff options
author | Eugen Rochko <eugen@zeonfederated.com> | 2017-05-03 02:04:16 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-05-03 02:04:16 +0200 |
commit | f5bf5ebb82e3af420dcd23d602b1be6cc86838e1 (patch) | |
tree | 92eef08642a038cf44ccbc6d16a884293e7a0814 /app/javascript/mastodon/features | |
parent | 26bc5915727e0a0173c03cb49f5193dd612fb888 (diff) |
Replace sprockets/browserify with Webpack (#2617)
* Replace browserify with webpack * Add react-intl-translations-manager * Do not minify in development, add offline-plugin for ServiceWorker background cache updates * Adjust tests and dependencies * Fix production deployments * Fix tests * More optimizations * Improve travis cache for npm stuff * Re-run travis * Add back support for custom.scss as before * Remove offline-plugin and babili * Fix issue with Immutable.List().unshift(...values) not working as expected * Make travis load schema instead of running all migrations in sequence * Fix missing React import in WarningContainer. Optimize rendering performance by using ImmutablePureComponent instead of React.PureComponent. ImmutablePureComponent uses Immutable.is() to compare props. Replace dynamic callback bindings in <UI /> * Add react definitions to places that use JSX * Add Procfile.dev for running rails, webpack and streaming API at the same time
Diffstat (limited to 'app/javascript/mastodon/features')
86 files changed, 5364 insertions, 0 deletions
diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js new file mode 100644 index 000000000..069348050 --- /dev/null +++ b/app/javascript/mastodon/features/account/components/action_bar.js @@ -0,0 +1,93 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import DropdownMenu from '../../../components/dropdown_menu'; +import { Link } from 'react-router'; +import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl'; + +const messages = defineMessages({ + mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' }, + edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, + unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, + block: { id: 'account.block', defaultMessage: 'Block @{name}' }, + mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + report: { id: 'account.report', defaultMessage: 'Report @{name}' }, + disclaimer: { id: 'account.disclaimer', defaultMessage: 'This user is from another instance. This number may be larger.' } +}); + +class ActionBar extends React.PureComponent { + + render () { + const { account, me, intl } = this.props; + + let menu = []; + let extraInfo = ''; + + menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention }); + menu.push(null); + + if (account.get('id') === me) { + menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }); + } else { + if (account.getIn(['relationship', 'muting'])) { + menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute }); + } else { + menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute }); + } + + if (account.getIn(['relationship', 'blocking'])) { + menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock }); + } else { + menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock }); + } + + menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport }); + } + + if (account.get('acct') !== account.get('username')) { + extraInfo = <abbr title={intl.formatMessage(messages.disclaimer)}>*</abbr>; + } + + return ( + <div className='account__action-bar'> + <div className='account__action-bar-dropdown'> + <DropdownMenu items={menu} icon='bars' size={24} direction="right" /> + </div> + + <div className='account__action-bar-links'> + <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}> + <span><FormattedMessage id='account.posts' defaultMessage='Posts' /></span> + <strong><FormattedNumber value={account.get('statuses_count')} /> {extraInfo}</strong> + </Link> + + <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}> + <span><FormattedMessage id='account.follows' defaultMessage='Follows' /></span> + <strong><FormattedNumber value={account.get('following_count')} /> {extraInfo}</strong> + </Link> + + <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}> + <span><FormattedMessage id='account.followers' defaultMessage='Followers' /></span> + <strong><FormattedNumber value={account.get('followers_count')} /> {extraInfo}</strong> + </Link> + </div> + </div> + ); + } + +} + +ActionBar.propTypes = { + account: ImmutablePropTypes.map.isRequired, + me: PropTypes.number.isRequired, + onFollow: PropTypes.func, + onBlock: PropTypes.func.isRequired, + onMention: PropTypes.func.isRequired, + onReport: PropTypes.func.isRequired, + onMute: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(ActionBar); diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js new file mode 100644 index 000000000..fbaa5e9e6 --- /dev/null +++ b/app/javascript/mastodon/features/account/components/header.js @@ -0,0 +1,150 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import emojify from '../../../emoji'; +import escapeTextContentForBrowser from 'escape-html'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import IconButton from '../../../components/icon_button'; +import { Motion, spring } from 'react-motion'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' } +}); + +const makeMapStateToProps = () => { + const mapStateToProps = (state, props) => ({ + autoPlayGif: state.getIn(['meta', 'auto_play_gif']) + }); + + return mapStateToProps; +}; + +class Avatar extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + + this.state = { + isHovered: false + }; + + this.handleMouseOver = this.handleMouseOver.bind(this); + this.handleMouseOut = this.handleMouseOut.bind(this); + } + + handleMouseOver () { + if (this.state.isHovered) return; + this.setState({ isHovered: true }); + } + + handleMouseOut () { + if (!this.state.isHovered) return; + this.setState({ isHovered: false }); + } + + render () { + const { account, autoPlayGif } = this.props; + const { isHovered } = this.state; + + return ( + <Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}> + {({ radius }) => + <a + href={account.get('url')} + className='account__header__avatar' + target='_blank' + rel='noopener' + style={{ borderRadius: `${radius}px`, backgroundImage: `url(${autoPlayGif || isHovered ? account.get('avatar') : account.get('avatar_static')})` }} + onMouseOver={this.handleMouseOver} + onMouseOut={this.handleMouseOut} + onFocus={this.handleMouseOver} + onBlur={this.handleMouseOut} + /> + } + </Motion> + ); + } + +} + +Avatar.propTypes = { + account: ImmutablePropTypes.map.isRequired, + autoPlayGif: PropTypes.bool.isRequired +}; + +class Header extends ImmutablePureComponent { + + render () { + const { account, me, intl } = this.props; + + if (!account) { + return null; + } + + let displayName = account.get('display_name'); + let info = ''; + let actionBtn = ''; + let lockedIcon = ''; + + if (displayName.length === 0) { + displayName = account.get('username'); + } + + if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) { + info = <span className='account--follows-info' style={{ position: 'absolute', top: '10px', right: '10px', opacity: '0.7', display: 'inline-block', verticalAlign: 'top', background: 'rgba(0, 0, 0, 0.4)', textTransform: 'uppercase', fontSize: '11px', fontWeight: '500', padding: '4px', borderRadius: '4px' }}><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span> + } + + if (me !== account.get('id')) { + if (account.getIn(['relationship', 'requested'])) { + actionBtn = ( + <div style={{ position: 'absolute', top: '10px', left: '20px' }}> + <IconButton size={26} disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} /> + </div> + ); + } else if (!account.getIn(['relationship', 'blocking'])) { + actionBtn = ( + <div style={{ position: 'absolute', top: '10px', left: '20px' }}> + <IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} /> + </div> + ); + } + } + + if (account.get('locked')) { + lockedIcon = <i className='fa fa-lock' />; + } + + const content = { __html: emojify(account.get('note')) }; + const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; + + return ( + <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}> + <div style={{ padding: '20px 10px' }}> + <Avatar account={account} autoPlayGif={this.props.autoPlayGif} /> + + <span style={{ display: 'inline-block', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} /> + <span className='account__header__username' style={{ fontSize: '14px', fontWeight: '400', display: 'block', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span> + <div style={{ fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} /> + + {info} + {actionBtn} + </div> + </div> + ); + } + +} + +Header.propTypes = { + account: ImmutablePropTypes.map, + me: PropTypes.number.isRequired, + onFollow: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + autoPlayGif: PropTypes.bool.isRequired +}; + +export default connect(makeMapStateToProps)(injectIntl(Header)); diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js new file mode 100644 index 000000000..b4dca3a57 --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/components/header.js @@ -0,0 +1,83 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import InnerHeader from '../../account/components/header'; +import ActionBar from '../../account/components/action_bar'; +import MissingIndicator from '../../../components/missing_indicator'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +class Header extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleFollow = this.handleFollow.bind(this); + this.handleBlock = this.handleBlock.bind(this); + this.handleMention = this.handleMention.bind(this); + this.handleReport = this.handleReport.bind(this); + this.handleMute = this.handleMute.bind(this); + } + + handleFollow () { + this.props.onFollow(this.props.account); + } + + handleBlock () { + this.props.onBlock(this.props.account); + } + + handleMention () { + this.props.onMention(this.props.account, this.context.router); + } + + handleReport () { + this.props.onReport(this.props.account); + this.context.router.push('/report'); + } + + handleMute() { + this.props.onMute(this.props.account); + } + + render () { + const { account, me } = this.props; + + if (account === null) { + return <MissingIndicator />; + } + + return ( + <div className='account-timeline__header'> + <InnerHeader + account={account} + me={me} + onFollow={this.handleFollow} + /> + + <ActionBar + account={account} + me={me} + onBlock={this.handleBlock} + onMention={this.handleMention} + onReport={this.handleReport} + onMute={this.handleMute} + /> + </div> + ); + } +} + +Header.propTypes = { + account: ImmutablePropTypes.map, + me: PropTypes.number.isRequired, + onFollow: PropTypes.func.isRequired, + onBlock: PropTypes.func.isRequired, + onMention: PropTypes.func.isRequired, + onReport: PropTypes.func.isRequired, + onMute: PropTypes.func.isRequired +}; + +Header.contextTypes = { + router: PropTypes.object +}; + +export default Header; diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js new file mode 100644 index 000000000..50999d2e0 --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js @@ -0,0 +1,76 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { makeGetAccount } from '../../../selectors'; +import Header from '../components/header'; +import { + followAccount, + unfollowAccount, + blockAccount, + unblockAccount, + muteAccount, + unmuteAccount +} from '../../../actions/accounts'; +import { mentionCompose } from '../../../actions/compose'; +import { initReport } from '../../../actions/reports'; +import { openModal } from '../../../actions/modal'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +const messages = defineMessages({ + blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, + muteConfirm: { id: 'confirmations.mute.confirm', defaultMessage: 'Mute' } +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { accountId }) => ({ + account: getAccount(state, Number(accountId)), + me: state.getIn(['meta', 'me']) + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { intl }) => ({ + onFollow (account) { + if (account.getIn(['relationship', 'following'])) { + dispatch(unfollowAccount(account.get('id'))); + } else { + dispatch(followAccount(account.get('id'))); + } + }, + + onBlock (account) { + if (account.getIn(['relationship', 'blocking'])) { + dispatch(unblockAccount(account.get('id'))); + } else { + dispatch(openModal('CONFIRM', { + message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, + confirm: intl.formatMessage(messages.blockConfirm), + onConfirm: () => dispatch(blockAccount(account.get('id'))) + })); + } + }, + + onMention (account, router) { + dispatch(mentionCompose(account, router)); + }, + + onReport (account) { + dispatch(initReport(account)); + }, + + onMute (account) { + if (account.getIn(['relationship', 'muting'])) { + dispatch(unmuteAccount(account.get('id'))); + } else { + dispatch(openModal('CONFIRM', { + message: <FormattedMessage id='confirmations.mute.message' defaultMessage='Are you sure you want to mute {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, + confirm: intl.formatMessage(messages.muteConfirm), + onConfirm: () => dispatch(muteAccount(account.get('id'))) + })); + } + } +}); + +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header)); diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js new file mode 100644 index 000000000..fb76f4d2e --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/index.js @@ -0,0 +1,89 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { + fetchAccount, + fetchAccountTimeline, + expandAccountTimeline +} from '../../actions/accounts'; +import StatusList from '../../components/status_list'; +import LoadingIndicator from '../../components/loading_indicator'; +import Column from '../ui/components/column'; +import HeaderContainer from './containers/header_container'; +import ColumnBackButton from '../../components/column_back_button'; +import Immutable from 'immutable'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const mapStateToProps = (state, props) => ({ + statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'items'], Immutable.List()), + isLoading: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'isLoading']), + hasMore: !!state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'next']), + me: state.getIn(['meta', 'me']) +}); + +class AccountTimeline extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleScrollToBottom = this.handleScrollToBottom.bind(this); + } + + componentWillMount () { + this.props.dispatch(fetchAccount(Number(this.props.params.accountId))); + this.props.dispatch(fetchAccountTimeline(Number(this.props.params.accountId))); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { + this.props.dispatch(fetchAccount(Number(nextProps.params.accountId))); + this.props.dispatch(fetchAccountTimeline(Number(nextProps.params.accountId))); + } + } + + handleScrollToBottom () { + if (!this.props.isLoading && this.props.hasMore) { + this.props.dispatch(expandAccountTimeline(Number(this.props.params.accountId))); + } + } + + render () { + const { statusIds, isLoading, hasMore, me } = this.props; + + if (!statusIds && isLoading) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column> + <ColumnBackButton /> + + <StatusList + prepend={<HeaderContainer accountId={this.props.params.accountId} />} + scrollKey='account_timeline' + statusIds={statusIds} + isLoading={isLoading} + hasMore={hasMore} + me={me} + onScrollToBottom={this.handleScrollToBottom} + /> + </Column> + ); + } + +} + +AccountTimeline.propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list, + isLoading: PropTypes.bool, + hasMore: PropTypes.bool, + me: PropTypes.number.isRequired +}; + +export default connect(mapStateToProps)(AccountTimeline); diff --git a/app/javascript/mastodon/features/blocks/index.js b/app/javascript/mastodon/features/blocks/index.js new file mode 100644 index 000000000..e25d9b2b4 --- /dev/null +++ b/app/javascript/mastodon/features/blocks/index.js @@ -0,0 +1,74 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import LoadingIndicator from '../../components/loading_indicator'; +import { ScrollContainer } from 'react-router-scroll'; +import Column from '../ui/components/column'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import AccountContainer from '../../containers/account_container'; +import { fetchBlocks, expandBlocks } from '../../actions/blocks'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + heading: { id: 'column.blocks', defaultMessage: 'Blocked users' } +}); + +const mapStateToProps = state => ({ + accountIds: state.getIn(['user_lists', 'blocks', 'items']) +}); + +class Blocks extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleScroll = this.handleScroll.bind(this); + } + + componentWillMount () { + this.props.dispatch(fetchBlocks()); + } + + handleScroll (e) { + const { scrollTop, scrollHeight, clientHeight } = e.target; + + if (scrollTop === scrollHeight - clientHeight) { + this.props.dispatch(expandBlocks()); + } + } + + render () { + const { intl, accountIds } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column icon='ban' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + <ScrollContainer scrollKey='blocks'> + <div className='scrollable' onScroll={this.handleScroll}> + {accountIds.map(id => + <AccountContainer key={id} id={id} /> + )} + </div> + </ScrollContainer> + </Column> + ); + } +} + +Blocks.propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired +}; + +export default connect(mapStateToProps)(injectIntl(Blocks)); diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js new file mode 100644 index 000000000..883263631 --- /dev/null +++ b/app/javascript/mastodon/features/community_timeline/index.js @@ -0,0 +1,96 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from '../ui/containers/status_list_container'; +import Column from '../ui/components/column'; +import { + refreshTimeline, + updateTimeline, + deleteFromTimelines, + connectTimeline, + disconnectTimeline +} from '../../actions/timelines'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import createStream from '../../stream'; + +const messages = defineMessages({ + title: { id: 'column.community', defaultMessage: 'Local timeline' } +}); + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0, + streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']), + accessToken: state.getIn(['meta', 'access_token']) +}); + +let subscription; + +class CommunityTimeline extends React.PureComponent { + + componentDidMount () { + const { dispatch, streamingAPIBaseURL, accessToken } = this.props; + + dispatch(refreshTimeline('community')); + + if (typeof subscription !== 'undefined') { + return; + } + + subscription = createStream(streamingAPIBaseURL, accessToken, 'public:local', { + + connected () { + dispatch(connectTimeline('community')); + }, + + reconnected () { + dispatch(connectTimeline('community')); + }, + + disconnected () { + dispatch(disconnectTimeline('community')); + }, + + received (data) { + switch(data.event) { + case 'update': + dispatch(updateTimeline('community', JSON.parse(data.payload))); + break; + case 'delete': + dispatch(deleteFromTimelines(data.payload)); + break; + } + } + + }); + } + + componentWillUnmount () { + // if (typeof subscription !== 'undefined') { + // subscription.close(); + // subscription = null; + // } + } + + render () { + const { intl, hasUnread } = this.props; + + return ( + <Column icon='users' active={hasUnread} heading={intl.formatMessage(messages.title)}> + <ColumnBackButtonSlim /> + <StatusListContainer {...this.props} scrollKey='community_timeline' type='community' emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} /> + </Column> + ); + } + +} + +CommunityTimeline.propTypes = { + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + streamingAPIBaseURL: PropTypes.string.isRequired, + accessToken: PropTypes.string.isRequired, + hasUnread: PropTypes.bool +}; + +export default connect(mapStateToProps)(injectIntl(CommunityTimeline)); diff --git a/app/javascript/mastodon/features/compose/components/autosuggest_account.js b/app/javascript/mastodon/features/compose/components/autosuggest_account.js new file mode 100644 index 000000000..3d87c4649 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/autosuggest_account.js @@ -0,0 +1,26 @@ +import React from 'react'; +import Avatar from '../../../components/avatar'; +import DisplayName from '../../../components/display_name'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +class AutosuggestAccount extends ImmutablePureComponent { + + render () { + const { account } = this.props; + + return ( + <div className='autosuggest-account'> + <div className='autosuggest-account-icon'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={18} /></div> + <DisplayName account={account} /> + </div> + ); + } + +} + +AutosuggestAccount.propTypes = { + account: ImmutablePropTypes.map.isRequired +}; + +export default AutosuggestAccount; diff --git a/app/javascript/mastodon/features/compose/components/character_counter.js b/app/javascript/mastodon/features/compose/components/character_counter.js new file mode 100644 index 000000000..617f85cfe --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/character_counter.js @@ -0,0 +1,27 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { length } from 'stringz'; + +class CharacterCounter extends React.PureComponent { + + checkRemainingText (diff) { + if (diff < 0) { + return <span className='character-counter character-counter--over'>{diff}</span>; + } + return <span className='character-counter'>{diff}</span>; + } + + render () { + const diff = this.props.max - length(this.props.text); + + return this.checkRemainingText(diff); + } + +} + +CharacterCounter.propTypes = { + text: PropTypes.string.isRequired, + max: PropTypes.number.isRequired +} + +export default CharacterCounter; diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js new file mode 100644 index 000000000..0b9c097e3 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -0,0 +1,211 @@ +import React from 'react'; +import CharacterCounter from './character_counter'; +import Button from '../../../components/button'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import ReplyIndicatorContainer from '../containers/reply_indicator_container'; +import AutosuggestTextarea from '../../../components/autosuggest_textarea'; +import { debounce } from 'react-decoration'; +import UploadButtonContainer from '../containers/upload_button_container'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Toggle from 'react-toggle'; +import Collapsable from '../../../components/collapsable'; +import SpoilerButtonContainer from '../containers/spoiler_button_container'; +import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; +import SensitiveButtonContainer from '../containers/sensitive_button_container'; +import EmojiPickerDropdown from './emoji_picker_dropdown'; +import UploadFormContainer from '../containers/upload_form_container'; +import TextIconButton from './text_icon_button'; +import WarningContainer from '../containers/warning_container'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, + spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Content warning' }, + publish: { id: 'compose_form.publish', defaultMessage: 'Toot' } +}); + +class ComposeForm extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleChange = this.handleChange.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + this.onSuggestionsClearRequested = this.onSuggestionsClearRequested.bind(this); + this.onSuggestionsFetchRequested = this.onSuggestionsFetchRequested.bind(this); + this.onSuggestionSelected = this.onSuggestionSelected.bind(this); + this.handleChangeSpoilerText = this.handleChangeSpoilerText.bind(this); + this.setAutosuggestTextarea = this.setAutosuggestTextarea.bind(this); + this.handleEmojiPick = this.handleEmojiPick.bind(this); + } + + handleChange (e) { + this.props.onChange(e.target.value); + } + + handleKeyDown (e) { + if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + this.handleSubmit(); + } + } + + handleSubmit () { + this.autosuggestTextarea.reset(); + this.props.onSubmit(); + } + + onSuggestionsClearRequested () { + this.props.onClearSuggestions(); + } + + @debounce(500) + onSuggestionsFetchRequested (token) { + this.props.onFetchSuggestions(token); + } + + onSuggestionSelected (tokenStart, token, value) { + this._restoreCaret = null; + this.props.onSuggestionSelected(tokenStart, token, value); + } + + handleChangeSpoilerText (e) { + this.props.onChangeSpoilerText(e.target.value); + } + + componentWillReceiveProps (nextProps) { + // If this is the update where we've finished uploading, + // save the last caret position so we can restore it below! + if (!nextProps.is_uploading && this.props.is_uploading) { + this._restoreCaret = this.autosuggestTextarea.textarea.selectionStart; + } + } + + componentDidUpdate (prevProps) { + // This statement does several things: + // - If we're beginning a reply, and, + // - Replying to zero or one users, places the cursor at the end of the textbox. + // - Replying to more than one user, selects any usernames past the first; + // this provides a convenient shortcut to drop everyone else from the conversation. + // - If we've just finished uploading an image, and have a saved caret position, + // restores the cursor to that position after the text changes! + if (this.props.focusDate !== prevProps.focusDate || (prevProps.is_uploading && !this.props.is_uploading && typeof this._restoreCaret === 'number')) { + let selectionEnd, selectionStart; + + if (this.props.preselectDate !== prevProps.preselectDate) { + selectionEnd = this.props.text.length; + selectionStart = this.props.text.search(/\s/) + 1; + } else if (typeof this._restoreCaret === 'number') { + selectionStart = this._restoreCaret; + selectionEnd = this._restoreCaret; + } else { + selectionEnd = this.props.text.length; + selectionStart = selectionEnd; + } + + this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd); + this.autosuggestTextarea.textarea.focus(); + } + } + + setAutosuggestTextarea (c) { + this.autosuggestTextarea = c; + } + + handleEmojiPick (data) { + const position = this.autosuggestTextarea.textarea.selectionStart; + this._restoreCaret = position + data.shortname.length + 1; + this.props.onPickEmoji(position, data); + } + + render () { + const { intl, onPaste } = this.props; + const disabled = this.props.is_submitting; + const text = [this.props.spoiler_text, this.props.text].join(''); + + let publishText = ''; + let reply_to_other = false; + + if (this.props.privacy === 'private' || this.props.privacy === 'direct') { + publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>; + } else { + publishText = intl.formatMessage(messages.publish) + (this.props.privacy !== 'unlisted' ? '!' : ''); + } + + return ( + <div className='compose-form'> + <Collapsable isVisible={this.props.spoiler} fullHeight={50}> + <div className="spoiler-input"> + <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} type="text" className="spoiler-input__input" id='cw-spoiler-input'/> + </div> + </Collapsable> + + <WarningContainer /> + + <ReplyIndicatorContainer /> + + <div className='compose-form__autosuggest-wrapper'> + <AutosuggestTextarea + ref={this.setAutosuggestTextarea} + placeholder={intl.formatMessage(messages.placeholder)} + disabled={disabled} + value={this.props.text} + onChange={this.handleChange} + suggestions={this.props.suggestions} + onKeyDown={this.handleKeyDown} + onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} + onSuggestionsClearRequested={this.onSuggestionsClearRequested} + onSuggestionSelected={this.onSuggestionSelected} + onPaste={onPaste} + /> + + <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} /> + </div> + + <div className='compose-form__modifiers'> + <UploadFormContainer /> + </div> + + <div className='compose-form__buttons-wrapper'> + <div className='compose-form__buttons'> + <UploadButtonContainer /> + <PrivacyDropdownContainer /> + <SensitiveButtonContainer /> + <SpoilerButtonContainer /> + </div> + + <div className='compose-form__publish'> + <div className='character-counter__wrapper'><CharacterCounter max={500} text={text} /></div> + <div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={disabled || text.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "_").length > 500 || (text.length !==0 && text.trim().length === 0)} block /></div> + </div> + </div> + </div> + ); + } + +} + +ComposeForm.propTypes = { + intl: PropTypes.object.isRequired, + text: PropTypes.string.isRequired, + suggestion_token: PropTypes.string, + suggestions: ImmutablePropTypes.list, + spoiler: PropTypes.bool, + privacy: PropTypes.string, + spoiler_text: PropTypes.string, + focusDate: PropTypes.instanceOf(Date), + preselectDate: PropTypes.instanceOf(Date), + is_submitting: PropTypes.bool, + is_uploading: PropTypes.bool, + me: PropTypes.number, + onChange: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + onClearSuggestions: PropTypes.func.isRequired, + onFetchSuggestions: PropTypes.func.isRequired, + onSuggestionSelected: PropTypes.func.isRequired, + onChangeSpoilerText: PropTypes.func.isRequired, + onPaste: PropTypes.func.isRequired, + onPickEmoji: PropTypes.func.isRequired +}; + +export default injectIntl(ComposeForm); diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js new file mode 100644 index 000000000..3e0b290d6 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js @@ -0,0 +1,115 @@ +import React from 'react'; +import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown'; +import EmojiPicker from 'emojione-picker'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, + emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' }, + people: { id: 'emoji_button.people', defaultMessage: 'People' }, + nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' }, + food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' }, + activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' }, + travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' }, + objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' }, + symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' }, + flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' } +}); + +const settings = { + imageType: 'png', + sprites: false, + imagePathPNG: '/emoji/' +}; + +const dropdownStyle = { + position: 'absolute', + right: '5px', + top: '5px' +}; + +const dropdownTriggerStyle = { + display: 'block', + fontSize: '24px', + lineHeight: '24px', + marginLeft: '2px', + width: '24px' +} + +class EmojiPickerDropdown extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.setRef = this.setRef.bind(this); + this.handleChange = this.handleChange.bind(this); + } + + setRef (c) { + this.dropdown = c; + } + + handleChange (data) { + this.dropdown.hide(); + this.props.onPickEmoji(data); + } + + render () { + const { intl } = this.props; + + const categories = { + people: { + title: intl.formatMessage(messages.people), + emoji: 'smile', + }, + nature: { + title: intl.formatMessage(messages.nature), + emoji: 'hamster', + }, + food: { + title: intl.formatMessage(messages.food), + emoji: 'pizza', + }, + activity: { + title: intl.formatMessage(messages.activity), + emoji: 'soccer', + }, + travel: { + title: intl.formatMessage(messages.travel), + emoji: 'earth_americas', + }, + objects: { + title: intl.formatMessage(messages.objects), + emoji: 'bulb', + }, + symbols: { + title: intl.formatMessage(messages.symbols), + emoji: 'clock9', + }, + flags: { + title: intl.formatMessage(messages.flags), + emoji: 'flag_gb', + } + } + + return ( + <Dropdown ref={this.setRef} style={dropdownStyle}> + <DropdownTrigger className='emoji-button' title={intl.formatMessage(messages.emoji)} style={dropdownTriggerStyle}> + <img draggable="false" className="emojione" alt="🙂" src="/emoji/1f602.svg" /> + </DropdownTrigger> + + <DropdownContent className='dropdown__left'> + <EmojiPicker emojione={settings} onChange={this.handleChange} searchPlaceholder={intl.formatMessage(messages.emoji_search)} categories={categories} search={true} /> + </DropdownContent> + </Dropdown> + ); + } + +} + +EmojiPickerDropdown.propTypes = { + intl: PropTypes.object.isRequired, + onPickEmoji: PropTypes.func.isRequired +}; + +export default injectIntl(EmojiPickerDropdown); diff --git a/app/javascript/mastodon/features/compose/components/navigation_bar.js b/app/javascript/mastodon/features/compose/components/navigation_bar.js new file mode 100644 index 000000000..aec8f6153 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/navigation_bar.js @@ -0,0 +1,37 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Avatar from '../../../components/avatar'; +import IconButton from '../../../components/icon_button'; +import DisplayName from '../../../components/display_name'; +import Permalink from '../../../components/permalink'; +import { FormattedMessage } from 'react-intl'; +import { Link } from 'react-router'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +class NavigationBar extends ImmutablePureComponent { + + render () { + return ( + <div className='navigation-bar'> + <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}> + <Avatar src={this.props.account.get('avatar')} animate size={40} /> + </Permalink> + + <div className='navigation-bar__profile'> + <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}> + <strong className='navigation-bar__profile-account'>@{this.props.account.get('acct')}</strong> + </Permalink> + + <a href='/settings/profile' className='navigation-bar__profile-edit'><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a> + </div> + </div> + ); + } + +} + +NavigationBar.propTypes = { + account: ImmutablePropTypes.map.isRequired +}; + +export default NavigationBar; diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js new file mode 100644 index 000000000..b77d55f4d --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js @@ -0,0 +1,105 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, defineMessages } from 'react-intl'; +import IconButton from '../../../components/icon_button'; + +const messages = defineMessages({ + public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, + public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' }, + unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, + unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' }, + private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, + private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' }, + direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, + direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' }, + change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' } +}); + +const iconStyle = { + height: null, + lineHeight: '27px' +} + +class PrivacyDropdown extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.state = { + open: false + }; + this.handleToggle = this.handleToggle.bind(this); + this.handleClick = this.handleClick.bind(this); + this.onGlobalClick = this.onGlobalClick.bind(this); + this.setRef = this.setRef.bind(this); + } + + handleToggle () { + this.setState({ open: !this.state.open }); + } + + handleClick (value, e) { + e.preventDefault(); + this.setState({ open: false }); + this.props.onChange(value); + } + + onGlobalClick (e) { + if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) { + this.setState({ open: false }); + } + } + + componentDidMount () { + window.addEventListener('click', this.onGlobalClick); + window.addEventListener('touchstart', this.onGlobalClick); + } + + componentWillUnmount () { + window.removeEventListener('click', this.onGlobalClick); + window.removeEventListener('touchstart', this.onGlobalClick); + } + + setRef (c) { + this.node = c; + } + + render () { + const { value, onChange, intl } = this.props; + const { open } = this.state; + + const options = [ + { icon: 'globe', value: 'public', shortText: intl.formatMessage(messages.public_short), longText: intl.formatMessage(messages.public_long) }, + { icon: 'unlock-alt', value: 'unlisted', shortText: intl.formatMessage(messages.unlisted_short), longText: intl.formatMessage(messages.unlisted_long) }, + { icon: 'lock', value: 'private', shortText: intl.formatMessage(messages.private_short), longText: intl.formatMessage(messages.private_long) }, + { icon: 'envelope', value: 'direct', shortText: intl.formatMessage(messages.direct_short), longText: intl.formatMessage(messages.direct_long) } + ]; + + const valueOption = options.find(item => item.value === value); + + return ( + <div ref={this.setRef} className={`privacy-dropdown ${open ? 'active' : ''}`}> + <div className='privacy-dropdown__value'><IconButton className='privacy-dropdown__value-icon' icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} active={open} inverted onClick={this.handleToggle} style={iconStyle}/></div> + <div className='privacy-dropdown__dropdown'> + {options.map(item => + <div role='button' tabIndex='0' key={item.value} onClick={this.handleClick.bind(this, item.value)} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}> + <div className='privacy-dropdown__option__icon'><i className={`fa fa-fw fa-${item.icon}`} /></div> + <div className='privacy-dropdown__option__content'> + <strong>{item.shortText}</strong> + {item.longText} + </div> + </div> + )} + </div> + </div> + ); + } + +} + +PrivacyDropdown.propTypes = { + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(PrivacyDropdown); diff --git a/app/javascript/mastodon/features/compose/components/reply_indicator.js b/app/javascript/mastodon/features/compose/components/reply_indicator.js new file mode 100644 index 000000000..e53831b60 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/reply_indicator.js @@ -0,0 +1,71 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Avatar from '../../../components/avatar'; +import IconButton from '../../../components/icon_button'; +import DisplayName from '../../../components/display_name'; +import emojify from '../../../emoji'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' } +}); + +class ReplyIndicator extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleClick = this.handleClick.bind(this); + this.handleAccountClick = this.handleAccountClick.bind(this); + } + + handleClick () { + this.props.onCancel(); + } + + handleAccountClick (e) { + if (e.button === 0) { + e.preventDefault(); + this.context.router.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); + } + } + + render () { + const { status, intl } = this.props; + + if (!status) { + return null; + } + + const content = { __html: emojify(status.get('content')) }; + + return ( + <div className='reply-indicator'> + <div className='reply-indicator__header'> + <div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div> + + <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name'> + <div className='reply-indicator__display-avatar'><Avatar size={24} src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} /></div> + <DisplayName account={status.get('account')} /> + </a> + </div> + + <div className='reply-indicator__content' dangerouslySetInnerHTML={content} /> + </div> + ); + } + +} + +ReplyIndicator.contextTypes = { + router: PropTypes.object +}; + +ReplyIndicator.propTypes = { + status: ImmutablePropTypes.map, + onCancel: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(ReplyIndicator); diff --git a/app/javascript/mastodon/features/compose/components/search.js b/app/javascript/mastodon/features/compose/components/search.js new file mode 100644 index 000000000..61ae9ce23 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/search.js @@ -0,0 +1,82 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +const messages = defineMessages({ + placeholder: { id: 'search.placeholder', defaultMessage: 'Search' } +}); + +class Search extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleChange = this.handleChange.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.handleFocus = this.handleFocus.bind(this); + this.handleClear = this.handleClear.bind(this); + } + + handleChange (e) { + this.props.onChange(e.target.value); + } + + handleClear (e) { + e.preventDefault(); + + if (this.props.value.length > 0 || this.props.submitted) { + this.props.onClear(); + } + } + + handleKeyDown (e) { + if (e.key === 'Enter') { + e.preventDefault(); + this.props.onSubmit(); + } + } + + noop () { + + } + + handleFocus () { + this.props.onShow(); + } + + render () { + const { intl, value, submitted } = this.props; + const hasValue = value.length > 0 || submitted; + + return ( + <div className='search'> + <input + className='search__input' + type='text' + placeholder={intl.formatMessage(messages.placeholder)} + value={value} + onChange={this.handleChange} + onKeyUp={this.handleKeyDown} + onFocus={this.handleFocus} + /> + + <div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}> + <i className={`fa fa-search ${hasValue ? '' : 'active'}`} /> + <i aria-label={intl.formatMessage(messages.placeholder)} className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} /> + </div> + </div> + ); + } + +} + +Search.propTypes = { + value: PropTypes.string.isRequired, + submitted: PropTypes.bool, + onChange: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, + onShow: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(Search); diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js new file mode 100644 index 000000000..79e880f0a --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/search_results.js @@ -0,0 +1,67 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import AccountContainer from '../../../containers/account_container'; +import StatusContainer from '../../../containers/status_container'; +import { Link } from 'react-router'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +class SearchResults extends ImmutablePureComponent { + + render () { + const { results } = this.props; + + let accounts, statuses, hashtags; + let count = 0; + + if (results.get('accounts') && results.get('accounts').size > 0) { + count += results.get('accounts').size; + accounts = ( + <div className='search-results__section'> + {results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)} + </div> + ); + } + + if (results.get('statuses') && results.get('statuses').size > 0) { + count += results.get('statuses').size; + statuses = ( + <div className='search-results__section'> + {results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)} + </div> + ); + } + + if (results.get('hashtags') && results.get('hashtags').size > 0) { + count += results.get('hashtags').size; + hashtags = ( + <div className='search-results__section'> + {results.get('hashtags').map(hashtag => + <Link className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}> + #{hashtag} + </Link> + )} + </div> + ); + } + + return ( + <div className='search-results'> + <div className='search-results__header'> + <FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} /> + </div> + + {accounts} + {statuses} + {hashtags} + </div> + ); + } + +} + +SearchResults.propTypes = { + results: ImmutablePropTypes.map.isRequired +}; + +export default SearchResults; diff --git a/app/javascript/mastodon/features/compose/components/text_icon_button.js b/app/javascript/mastodon/features/compose/components/text_icon_button.js new file mode 100644 index 000000000..bcfa21090 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/text_icon_button.js @@ -0,0 +1,36 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +class TextIconButton extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleClick = this.handleClick.bind(this); + } + + handleClick (e) { + e.preventDefault(); + this.props.onClick(); + } + + render () { + const { label, title, active, ariaControls } = this.props; + + return ( + <button title={title} aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} aria-expanded={active} onClick={this.handleClick} aria-controls={ariaControls}> + {label} + </button> + ); + } + +} + +TextIconButton.propTypes = { + label: PropTypes.string.isRequired, + title: PropTypes.string, + active: PropTypes.bool, + onClick: PropTypes.func.isRequired, + ariaControls: PropTypes.string +}; + +export default TextIconButton; diff --git a/app/javascript/mastodon/features/compose/components/upload_button.js b/app/javascript/mastodon/features/compose/components/upload_button.js new file mode 100644 index 000000000..15ec2edd6 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/upload_button.js @@ -0,0 +1,61 @@ +import React from 'react'; +import IconButton from '../../../components/icon_button'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + upload: { id: 'upload_button.label', defaultMessage: 'Add media' } +}); + + +const iconStyle = { + height: null, + lineHeight: '27px' +} + +class UploadButton extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleChange = this.handleChange.bind(this); + this.handleClick = this.handleClick.bind(this); + this.setRef = this.setRef.bind(this); + } + + handleChange (e) { + if (e.target.files.length > 0) { + this.props.onSelectFile(e.target.files); + } + } + + handleClick () { + this.fileElement.click(); + } + + setRef (c) { + this.fileElement = c; + } + + render () { + + const { intl, resetFileKey, disabled } = this.props; + + return ( + <div className='compose-form__upload-button'> + <IconButton icon='camera' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle}/> + <input key={resetFileKey} ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={disabled} style={{ display: 'none' }} /> + </div> + ); + } + +} + +UploadButton.propTypes = { + disabled: PropTypes.bool, + onSelectFile: PropTypes.func.isRequired, + style: PropTypes.object, + resetFileKey: PropTypes.number, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(UploadButton); diff --git a/app/javascript/mastodon/features/compose/components/upload_form.js b/app/javascript/mastodon/features/compose/components/upload_form.js new file mode 100644 index 000000000..8e48538da --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/upload_form.js @@ -0,0 +1,46 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import IconButton from '../../../components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; +import UploadProgressContainer from '../containers/upload_progress_container'; +import { Motion, spring } from 'react-motion'; + +const messages = defineMessages({ + undo: { id: 'upload_form.undo', defaultMessage: 'Undo' } +}); + +class UploadForm extends React.PureComponent { + + render () { + const { intl, media } = this.props; + + const uploads = media.map(attachment => + <div className='compose-form__upload' key={attachment.get('id')}> + <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}> + {({ scale }) => + <div className='compose-form__upload-thumbnail' style={{ transform: `translateZ(0) scale(${scale})`, backgroundImage: `url(${attachment.get('preview_url')})` }}> + <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.props.onRemoveFile.bind(this, attachment.get('id'))} /> + </div> + } + </Motion> + </div> + ); + + return ( + <div className='compose-form__upload-wrapper'> + <UploadProgressContainer /> + <div className='compose-form__uploads-wrapper'>{uploads}</div> + </div> + ); + } + +} + +UploadForm.propTypes = { + media: ImmutablePropTypes.list.isRequired, + onRemoveFile: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(UploadForm); diff --git a/app/javascript/mastodon/features/compose/components/upload_progress.js b/app/javascript/mastodon/features/compose/components/upload_progress.js new file mode 100644 index 000000000..bb2932a55 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/upload_progress.js @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Motion, spring } from 'react-motion'; +import { FormattedMessage } from 'react-intl'; + +class UploadProgress extends React.PureComponent { + + render () { + const { active, progress } = this.props; + + if (!active) { + return null; + } + + return ( + <div className='upload-progress'> + <div className='upload-progress__icon'> + <i className='fa fa-upload' /> + </div> + + <div className='upload-progress__message'> + <FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' /> + + <div className='upload-progress__backdrop'> + <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}> + {({ width }) => + <div className='upload-progress__tracker' style={{ width: `${width}%` }} /> + } + </Motion> + </div> + </div> + </div> + ); + } + +} + +UploadProgress.propTypes = { + active: PropTypes.bool, + progress: PropTypes.number +}; + +export default UploadProgress; diff --git a/app/javascript/mastodon/features/compose/components/warning.js b/app/javascript/mastodon/features/compose/components/warning.js new file mode 100644 index 000000000..6ad00b691 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/warning.js @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +class Warning extends React.PureComponent { + + constructor (props) { + super(props); + } + + render () { + const { message } = this.props; + + return ( + <div className='compose-form__warning'> + {message} + </div> + ); + } + +} + +Warning.propTypes = { + message: PropTypes.node.isRequired +}; + +export default Warning; diff --git a/app/javascript/mastodon/features/compose/containers/autosuggest_account_container.js b/app/javascript/mastodon/features/compose/containers/autosuggest_account_container.js new file mode 100644 index 000000000..de76a364d --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/autosuggest_account_container.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import AutosuggestAccount from '../components/autosuggest_account'; +import { makeGetAccount } from '../../../selectors'; + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { id }) => ({ + account: getAccount(state, id) + }); + + return mapStateToProps; +}; + +export default connect(makeMapStateToProps)(AutosuggestAccount); diff --git a/app/javascript/mastodon/features/compose/containers/autosuggest_status_container.js b/app/javascript/mastodon/features/compose/containers/autosuggest_status_container.js new file mode 100644 index 000000000..ef46eb09c --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/autosuggest_status_container.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import AutosuggestStatus from '../components/autosuggest_status'; +import { makeGetStatus } from '../../../selectors'; + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, { id }) => ({ + status: getStatus(state, id) + }); + + return mapStateToProps; +}; + +export default connect(makeMapStateToProps)(AutosuggestStatus); diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js new file mode 100644 index 000000000..892183b83 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js @@ -0,0 +1,64 @@ +import { connect } from 'react-redux'; +import ComposeForm from '../components/compose_form'; +import { uploadCompose } from '../../../actions/compose'; +import { + changeCompose, + submitCompose, + clearComposeSuggestions, + fetchComposeSuggestions, + selectComposeSuggestion, + changeComposeSpoilerText, + insertEmojiCompose +} from '../../../actions/compose'; + +const mapStateToProps = state => ({ + text: state.getIn(['compose', 'text']), + suggestion_token: state.getIn(['compose', 'suggestion_token']), + suggestions: state.getIn(['compose', 'suggestions']), + spoiler: state.getIn(['compose', 'spoiler']), + spoiler_text: state.getIn(['compose', 'spoiler_text']), + privacy: state.getIn(['compose', 'privacy']), + focusDate: state.getIn(['compose', 'focusDate']), + preselectDate: state.getIn(['compose', 'preselectDate']), + is_submitting: state.getIn(['compose', 'is_submitting']), + is_uploading: state.getIn(['compose', 'is_uploading']), + me: state.getIn(['compose', 'me']) +}); + +const mapDispatchToProps = (dispatch) => ({ + + onChange (text) { + dispatch(changeCompose(text)); + }, + + onSubmit () { + dispatch(submitCompose()); + }, + + onClearSuggestions () { + dispatch(clearComposeSuggestions()); + }, + + onFetchSuggestions (token) { + dispatch(fetchComposeSuggestions(token)); + }, + + onSuggestionSelected (position, token, accountId) { + dispatch(selectComposeSuggestion(position, token, accountId)); + }, + + onChangeSpoilerText (checked) { + dispatch(changeComposeSpoilerText(checked)); + }, + + onPaste (files) { + dispatch(uploadCompose(files)); + }, + + onPickEmoji (position, data) { + dispatch(insertEmojiCompose(position, data)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm); diff --git a/app/javascript/mastodon/features/compose/containers/navigation_container.js b/app/javascript/mastodon/features/compose/containers/navigation_container.js new file mode 100644 index 000000000..0006608da --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/navigation_container.js @@ -0,0 +1,10 @@ +import { connect } from 'react-redux'; +import NavigationBar from '../components/navigation_bar'; + +const mapStateToProps = (state, props) => { + return { + account: state.getIn(['accounts', state.getIn(['meta', 'me'])]) + }; +}; + +export default connect(mapStateToProps)(NavigationBar); diff --git a/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js new file mode 100644 index 000000000..1eee8f84c --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import PrivacyDropdown from '../components/privacy_dropdown'; +import { changeComposeVisibility } from '../../../actions/compose'; + +const mapStateToProps = state => ({ + value: state.getIn(['compose', 'privacy']) +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (value) { + dispatch(changeComposeVisibility(value)); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown); diff --git a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js new file mode 100644 index 000000000..39b48f3b6 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { cancelReplyCompose } from '../../../actions/compose'; +import { makeGetStatus } from '../../../selectors'; +import ReplyIndicator from '../components/reply_indicator'; + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, props) => ({ + status: getStatus(state, state.getIn(['compose', 'in_reply_to'])), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = dispatch => ({ + + onCancel () { + dispatch(cancelReplyCompose()); + } + +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator); diff --git a/app/javascript/mastodon/features/compose/containers/search_container.js b/app/javascript/mastodon/features/compose/containers/search_container.js new file mode 100644 index 000000000..906c0c28c --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/search_container.js @@ -0,0 +1,35 @@ +import { connect } from 'react-redux'; +import { + changeSearch, + clearSearch, + submitSearch, + showSearch +} from '../../../actions/search'; +import Search from '../components/search'; + +const mapStateToProps = state => ({ + value: state.getIn(['search', 'value']), + submitted: state.getIn(['search', 'submitted']) +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (value) { + dispatch(changeSearch(value)); + }, + + onClear () { + dispatch(clearSearch()); + }, + + onSubmit () { + dispatch(submitSearch()); + }, + + onShow () { + dispatch(showSearch()); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Search); diff --git a/app/javascript/mastodon/features/compose/containers/search_results_container.js b/app/javascript/mastodon/features/compose/containers/search_results_container.js new file mode 100644 index 000000000..e5911fd38 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/search_results_container.js @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import SearchResults from '../components/search_results'; + +const mapStateToProps = state => ({ + results: state.getIn(['search', 'results']) +}); + +export default connect(mapStateToProps)(SearchResults); diff --git a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js new file mode 100644 index 000000000..78e40e048 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js @@ -0,0 +1,51 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import TextIconButton from '../components/text_icon_button'; +import { changeComposeSensitivity } from '../../../actions/compose'; +import { Motion, spring } from 'react-motion'; +import { injectIntl, defineMessages } from 'react-intl'; + +const messages = defineMessages({ + title: { id: 'compose_form.sensitive', defaultMessage: 'Mark media as sensitive' } +}); + +const mapStateToProps = state => ({ + visible: state.getIn(['compose', 'media_attachments']).size > 0, + active: state.getIn(['compose', 'sensitive']) +}); + +const mapDispatchToProps = dispatch => ({ + + onClick () { + dispatch(changeComposeSensitivity()); + } + +}); + +class SensitiveButton extends React.PureComponent { + + render () { + const { visible, active, onClick, intl } = this.props; + + return ( + <Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}> + {({ scale }) => + <div style={{ display: visible ? 'block' : 'none', transform: `translateZ(0) scale(${scale})` }}> + <TextIconButton onClick={onClick} label='NSFW' title={intl.formatMessage(messages.title)} active={active} /> + </div> + } + </Motion> + ); + } + +} + +SensitiveButton.propTypes = { + visible: PropTypes.bool, + active: PropTypes.bool, + onClick: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton)); diff --git a/app/javascript/mastodon/features/compose/containers/spoiler_button_container.js b/app/javascript/mastodon/features/compose/containers/spoiler_button_container.js new file mode 100644 index 000000000..b1c80fe19 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/spoiler_button_container.js @@ -0,0 +1,25 @@ +import { connect } from 'react-redux'; +import TextIconButton from '../components/text_icon_button'; +import { changeComposeSpoilerness } from '../../../actions/compose'; +import { injectIntl, defineMessages } from 'react-intl'; + +const messages = defineMessages({ + title: { id: 'compose_form.spoiler', defaultMessage: 'Hide text behind warning' } +}); + +const mapStateToProps = (state, { intl }) => ({ + label: 'CW', + title: intl.formatMessage(messages.title), + active: state.getIn(['compose', 'spoiler']), + ariaControls: 'cw-spoiler-input' +}); + +const mapDispatchToProps = dispatch => ({ + + onClick () { + dispatch(changeComposeSpoilerness()); + } + +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TextIconButton)); diff --git a/app/javascript/mastodon/features/compose/containers/upload_button_container.js b/app/javascript/mastodon/features/compose/containers/upload_button_container.js new file mode 100644 index 000000000..78e5312f5 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/upload_button_container.js @@ -0,0 +1,18 @@ +import { connect } from 'react-redux'; +import UploadButton from '../components/upload_button'; +import { uploadCompose } from '../../../actions/compose'; + +const mapStateToProps = state => ({ + disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')), + resetFileKey: state.getIn(['compose', 'resetFileKey']) +}); + +const mapDispatchToProps = dispatch => ({ + + onSelectFile (files) { + dispatch(uploadCompose(files)); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(UploadButton); diff --git a/app/javascript/mastodon/features/compose/containers/upload_form_container.js b/app/javascript/mastodon/features/compose/containers/upload_form_container.js new file mode 100644 index 000000000..a6a202e17 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/upload_form_container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import UploadForm from '../components/upload_form'; +import { undoUploadCompose } from '../../../actions/compose'; + +const mapStateToProps = (state, props) => ({ + media: state.getIn(['compose', 'media_attachments']), +}); + +const mapDispatchToProps = dispatch => ({ + + onRemoveFile (media_id) { + dispatch(undoUploadCompose(media_id)); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(UploadForm); diff --git a/app/javascript/mastodon/features/compose/containers/upload_progress_container.js b/app/javascript/mastodon/features/compose/containers/upload_progress_container.js new file mode 100644 index 000000000..b0f1d4d19 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/upload_progress_container.js @@ -0,0 +1,9 @@ +import { connect } from 'react-redux'; +import UploadProgress from '../components/upload_progress'; + +const mapStateToProps = (state, props) => ({ + active: state.getIn(['compose', 'is_uploading']), + progress: state.getIn(['compose', 'progress']) +}); + +export default connect(mapStateToProps)(UploadProgress); diff --git a/app/javascript/mastodon/features/compose/containers/warning_container.js b/app/javascript/mastodon/features/compose/containers/warning_container.js new file mode 100644 index 000000000..bf5e6a5f8 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/warning_container.js @@ -0,0 +1,49 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import Warning from '../components/warning'; +import { createSelector } from 'reselect'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +const getMentionedUsernames = createSelector(state => state.getIn(['compose', 'text']), text => text.match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig)); + +const getMentionedDomains = createSelector(getMentionedUsernames, mentionedUsernamesWithDomains => { + return mentionedUsernamesWithDomains !== null ? [...new Set(mentionedUsernamesWithDomains.map(item => item.split('@')[2]))] : []; +}); + +const mapStateToProps = state => { + const mentionedUsernames = getMentionedUsernames(state); + const mentionedUsernamesWithDomains = getMentionedDomains(state); + + return { + needsLeakWarning: (state.getIn(['compose', 'privacy']) === 'private' || state.getIn(['compose', 'privacy']) === 'direct') && mentionedUsernames !== null, + mentionedDomains: mentionedUsernamesWithDomains, + needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', state.getIn(['meta', 'me']), 'locked']) + }; +}; + +const WarningWrapper = ({ needsLeakWarning, needsLockWarning, mentionedDomains }) => { + if (needsLockWarning) { + return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />; + } else if (needsLeakWarning) { + return ( + <Warning + message={<FormattedMessage + id='compose_form.privacy_disclaimer' + defaultMessage='Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.' + values={{ domains: <strong>{mentionedDomains.join(', ')}</strong>, domainsCount: mentionedDomains.length }} + />} + /> + ); + } + + return null; +}; + +WarningWrapper.propTypes = { + needsLeakWarning: PropTypes.bool, + needsLockWarning: PropTypes.bool, + mentionedDomains: PropTypes.array.isRequired, +}; + +export default connect(mapStateToProps)(WarningWrapper); diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js new file mode 100644 index 000000000..68d779c6c --- /dev/null +++ b/app/javascript/mastodon/features/compose/index.js @@ -0,0 +1,86 @@ +import React from 'react'; +import ComposeFormContainer from './containers/compose_form_container'; +import UploadFormContainer from './containers/upload_form_container'; +import NavigationContainer from './containers/navigation_container'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { mountCompose, unmountCompose } from '../../actions/compose'; +import { Link } from 'react-router'; +import { injectIntl, defineMessages } from 'react-intl'; +import SearchContainer from './containers/search_container'; +import { Motion, spring } from 'react-motion'; +import SearchResultsContainer from './containers/search_results_container'; + +const messages = defineMessages({ + start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, + public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' }, + community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, + preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' } +}); + +const mapStateToProps = state => ({ + showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) +}); + +class Compose extends React.PureComponent { + + componentDidMount () { + this.props.dispatch(mountCompose()); + } + + componentWillUnmount () { + this.props.dispatch(unmountCompose()); + } + + render () { + const { withHeader, showSearch, intl } = this.props; + + let header = ''; + + if (withHeader) { + header = ( + <div className='drawer__header'> + <Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)}><i role="img" aria-label={intl.formatMessage(messages.start)} className='fa fa-fw fa-asterisk' /></Link> + <Link to='/timelines/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)}><i role="img" aria-label={intl.formatMessage(messages.community)} className='fa fa-fw fa-users' /></Link> + <Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)}><i role="img" aria-label={intl.formatMessage(messages.public)} className='fa fa-fw fa-globe' /></Link> + <a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)}><i role="img" aria-label={intl.formatMessage(messages.preferences)} className='fa fa-fw fa-cog' /></a> + <a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)}><i role="img" aria-label={intl.formatMessage(messages.logout)} className='fa fa-fw fa-sign-out' /></a> + </div> + ); + } + + return ( + <div className='drawer'> + {header} + + <SearchContainer /> + + <div className='drawer__pager'> + <div className='drawer__inner'> + <NavigationContainer /> + <ComposeFormContainer /> + </div> + + <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}> + {({ x }) => + <div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}> + <SearchResultsContainer /> + </div> + } + </Motion> + </div> + </div> + ); + } + +} + +Compose.propTypes = { + dispatch: PropTypes.func.isRequired, + withHeader: PropTypes.bool, + showSearch: PropTypes.bool, + intl: PropTypes.object.isRequired +}; + +export default connect(mapStateToProps)(injectIntl(Compose)); diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js new file mode 100644 index 000000000..995f61f17 --- /dev/null +++ b/app/javascript/mastodon/features/favourited_statuses/index.js @@ -0,0 +1,67 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from '../../components/loading_indicator'; +import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites'; +import Column from '../ui/components/column'; +import StatusList from '../../components/status_list'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + heading: { id: 'column.favourites', defaultMessage: 'Favourites' } +}); + +const mapStateToProps = state => ({ + statusIds: state.getIn(['status_lists', 'favourites', 'items']), + loaded: state.getIn(['status_lists', 'favourites', 'loaded']), + me: state.getIn(['meta', 'me']) +}); + +class Favourites extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleScrollToBottom = this.handleScrollToBottom.bind(this); + } + + componentWillMount () { + this.props.dispatch(fetchFavouritedStatuses()); + } + + handleScrollToBottom () { + this.props.dispatch(expandFavouritedStatuses()); + } + + render () { + const { statusIds, loaded, intl, me } = this.props; + + if (!loaded) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column icon='star' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + <StatusList {...this.props} scrollKey='favourited_statuses' onScrollToBottom={this.handleScrollToBottom} /> + </Column> + ); + } + +} + +Favourites.propTypes = { + dispatch: PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + loaded: PropTypes.bool, + intl: PropTypes.object.isRequired, + me: PropTypes.number.isRequired +}; + +export default connect(mapStateToProps)(injectIntl(Favourites)); diff --git a/app/javascript/mastodon/features/favourites/index.js b/app/javascript/mastodon/features/favourites/index.js new file mode 100644 index 000000000..c916aa176 --- /dev/null +++ b/app/javascript/mastodon/features/favourites/index.js @@ -0,0 +1,61 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from '../../components/loading_indicator'; +import { fetchFavourites } from '../../actions/interactions'; +import { ScrollContainer } from 'react-router-scroll'; +import AccountContainer from '../../containers/account_container'; +import Column from '../ui/components/column'; +import ColumnBackButton from '../../components/column_back_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'favourited_by', Number(props.params.statusId)]) +}); + +class Favourites extends ImmutablePureComponent { + + componentWillMount () { + this.props.dispatch(fetchFavourites(Number(this.props.params.statusId))); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) { + this.props.dispatch(fetchFavourites(Number(nextProps.params.statusId))); + } + } + + render () { + const { accountIds } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column> + <ColumnBackButton /> + + <ScrollContainer scrollKey='favourites'> + <div className='scrollable'> + {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} + </div> + </ScrollContainer> + </Column> + ); + } + +} + +Favourites.propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list +}; + +export default connect(mapStateToProps)(Favourites); diff --git a/app/javascript/mastodon/features/follow_requests/components/account_authorize.js b/app/javascript/mastodon/features/follow_requests/components/account_authorize.js new file mode 100644 index 000000000..9fe464628 --- /dev/null +++ b/app/javascript/mastodon/features/follow_requests/components/account_authorize.js @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Permalink from '../../../components/permalink'; +import Avatar from '../../../components/avatar'; +import DisplayName from '../../../components/display_name'; +import emojify from '../../../emoji'; +import IconButton from '../../../components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' }, + reject: { id: 'follow_request.reject', defaultMessage: 'Reject' } +}); + +class AccountAuthorize extends ImmutablePureComponent { + + render () { + const { intl, account, onAuthorize, onReject } = this.props; + const content = { __html: emojify(account.get('note')) }; + + return ( + <div className='account-authorize__wrapper'> + <div className='account-authorize'> + <Permalink href={account.get('url')} to={`/accounts/${account.get('id')}`} className='detailed-status__display-name'> + <div className='account-authorize__avatar'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={48} /></div> + <DisplayName account={account} /> + </Permalink> + + <div className='account__header__content' dangerouslySetInnerHTML={content} /> + </div> + + <div className='account--panel'> + <div className='account--panel__button'><IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} /></div> + <div className='account--panel__button'><IconButton title={intl.formatMessage(messages.reject)} icon='times' onClick={onReject} /></div> + </div> + </div> + ); + } + +} + +AccountAuthorize.propTypes = { + account: ImmutablePropTypes.map.isRequired, + onAuthorize: PropTypes.func.isRequired, + onReject: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(AccountAuthorize); diff --git a/app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js b/app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js new file mode 100644 index 000000000..da1e5eaa1 --- /dev/null +++ b/app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js @@ -0,0 +1,26 @@ +import { connect } from 'react-redux'; +import { makeGetAccount } from '../../../selectors'; +import AccountAuthorize from '../components/account_authorize'; +import { authorizeFollowRequest, rejectFollowRequest } from '../../../actions/accounts'; + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, props) => ({ + account: getAccount(state, props.id) + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { id }) => ({ + onAuthorize (account) { + dispatch(authorizeFollowRequest(id)); + }, + + onReject (account) { + dispatch(rejectFollowRequest(id)); + } +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(AccountAuthorize); diff --git a/app/javascript/mastodon/features/follow_requests/index.js b/app/javascript/mastodon/features/follow_requests/index.js new file mode 100644 index 000000000..c88de48c0 --- /dev/null +++ b/app/javascript/mastodon/features/follow_requests/index.js @@ -0,0 +1,74 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from '../../components/loading_indicator'; +import { ScrollContainer } from 'react-router-scroll'; +import Column from '../ui/components/column'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import AccountAuthorizeContainer from './containers/account_authorize_container'; +import { fetchFollowRequests, expandFollowRequests } from '../../actions/accounts'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' } +}); + +const mapStateToProps = state => ({ + accountIds: state.getIn(['user_lists', 'follow_requests', 'items']) +}); + +class FollowRequests extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleScroll = this.handleScroll.bind(this); + } + + componentWillMount () { + this.props.dispatch(fetchFollowRequests()); + } + + handleScroll (e) { + const { scrollTop, scrollHeight, clientHeight } = e.target; + + if (scrollTop === scrollHeight - clientHeight) { + this.props.dispatch(expandFollowRequests()); + } + } + + render () { + const { intl, accountIds } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column icon='users' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + <ScrollContainer scrollKey='follow_requests'> + <div className='scrollable' onScroll={this.handleScroll}> + {accountIds.map(id => + <AccountAuthorizeContainer key={id} id={id} /> + )} + </div> + </ScrollContainer> + </Column> + ); + } +} + +FollowRequests.propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired +}; + +export default connect(mapStateToProps)(injectIntl(FollowRequests)); diff --git a/app/javascript/mastodon/features/followers/index.js b/app/javascript/mastodon/features/followers/index.js new file mode 100644 index 000000000..8a1105b55 --- /dev/null +++ b/app/javascript/mastodon/features/followers/index.js @@ -0,0 +1,92 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from '../../components/loading_indicator'; +import { + fetchAccount, + fetchFollowers, + expandFollowers +} from '../../actions/accounts'; +import { ScrollContainer } from 'react-router-scroll'; +import AccountContainer from '../../containers/account_container'; +import Column from '../ui/components/column'; +import HeaderContainer from '../account_timeline/containers/header_container'; +import LoadMore from '../../components/load_more'; +import ColumnBackButton from '../../components/column_back_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'followers', Number(props.params.accountId), 'items']) +}); + +class Followers extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleScroll = this.handleScroll.bind(this); + this.handleLoadMore = this.handleLoadMore.bind(this); + } + + componentWillMount () { + this.props.dispatch(fetchAccount(Number(this.props.params.accountId))); + this.props.dispatch(fetchFollowers(Number(this.props.params.accountId))); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { + this.props.dispatch(fetchAccount(Number(nextProps.params.accountId))); + this.props.dispatch(fetchFollowers(Number(nextProps.params.accountId))); + } + } + + handleScroll (e) { + const { scrollTop, scrollHeight, clientHeight } = e.target; + + if (scrollTop === scrollHeight - clientHeight) { + this.props.dispatch(expandFollowers(Number(this.props.params.accountId))); + } + } + + handleLoadMore (e) { + e.preventDefault(); + this.props.dispatch(expandFollowers(Number(this.props.params.accountId))); + } + + render () { + const { accountIds } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column> + <ColumnBackButton /> + + <ScrollContainer scrollKey='followers'> + <div className='scrollable' onScroll={this.handleScroll}> + <div className='followers'> + <HeaderContainer accountId={this.props.params.accountId} /> + {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} + <LoadMore onClick={this.handleLoadMore} /> + </div> + </div> + </ScrollContainer> + </Column> + ); + } + +} + +Followers.propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list +}; + +export default connect(mapStateToProps)(Followers); diff --git a/app/javascript/mastodon/features/following/index.js b/app/javascript/mastodon/features/following/index.js new file mode 100644 index 000000000..f181fe727 --- /dev/null +++ b/app/javascript/mastodon/features/following/index.js @@ -0,0 +1,92 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from '../../components/loading_indicator'; +import { + fetchAccount, + fetchFollowing, + expandFollowing +} from '../../actions/accounts'; +import { ScrollContainer } from 'react-router-scroll'; +import AccountContainer from '../../containers/account_container'; +import Column from '../ui/components/column'; +import HeaderContainer from '../account_timeline/containers/header_container'; +import LoadMore from '../../components/load_more'; +import ColumnBackButton from '../../components/column_back_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'following', Number(props.params.accountId), 'items']) +}); + +class Following extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleScroll = this.handleScroll.bind(this); + this.handleLoadMore = this.handleLoadMore.bind(this); + } + + componentWillMount () { + this.props.dispatch(fetchAccount(Number(this.props.params.accountId))); + this.props.dispatch(fetchFollowing(Number(this.props.params.accountId))); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { + this.props.dispatch(fetchAccount(Number(nextProps.params.accountId))); + this.props.dispatch(fetchFollowing(Number(nextProps.params.accountId))); + } + } + + handleScroll (e) { + const { scrollTop, scrollHeight, clientHeight } = e.target; + + if (scrollTop === scrollHeight - clientHeight) { + this.props.dispatch(expandFollowing(Number(this.props.params.accountId))); + } + } + + handleLoadMore (e) { + e.preventDefault(); + this.props.dispatch(expandFollowing(Number(this.props.params.accountId))); + } + + render () { + const { accountIds } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column> + <ColumnBackButton /> + + <ScrollContainer scrollKey='following'> + <div className='scrollable' onScroll={this.handleScroll}> + <div className='following'> + <HeaderContainer accountId={this.props.params.accountId} /> + {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} + <LoadMore onClick={this.handleLoadMore} /> + </div> + </div> + </ScrollContainer> + </Column> + ); + } + +} + +Following.propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list +}; + +export default connect(mapStateToProps)(Following); diff --git a/app/javascript/mastodon/features/generic_not_found/index.js b/app/javascript/mastodon/features/generic_not_found/index.js new file mode 100644 index 000000000..0290be47f --- /dev/null +++ b/app/javascript/mastodon/features/generic_not_found/index.js @@ -0,0 +1,11 @@ +import React from 'react'; +import Column from '../ui/components/column'; +import MissingIndicator from '../../components/missing_indicator'; + +const GenericNotFound = () => ( + <Column> + <MissingIndicator /> + </Column> +); + +export default GenericNotFound; diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js new file mode 100644 index 000000000..6bdff2fba --- /dev/null +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -0,0 +1,73 @@ +import React from 'react'; +import Column from '../ui/components/column'; +import ColumnLink from '../ui/components/column_link'; +import ColumnSubheading from '../ui/components/column_subheading'; +import { Link } from 'react-router'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, + public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' }, + navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation'}, + settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings'}, + community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, + preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, + sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, + favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, + blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, + mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, + info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' } +}); + +const mapStateToProps = state => ({ + me: state.getIn(['accounts', state.getIn(['meta', 'me'])]) +}); + +class GettingStarted extends ImmutablePureComponent { + + render () { + const { intl, me } = this.props; + + let followRequests = ''; + + if (me.get('locked')) { + followRequests = <ColumnLink icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />; + } + + return ( + <Column icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile={true}> + <div className='getting-started__wrapper'> + <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)}/> + <ColumnLink icon='users' hideOnMobile={true} text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' /> + <ColumnLink icon='globe' hideOnMobile={true} text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' /> + <ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' /> + {followRequests} + <ColumnLink icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' /> + <ColumnLink icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' /> + <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)}/> + <ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' /> + <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' /> + <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' /> + </div> + + <div className='scrollable optionally-scrollable' style={{ display: 'flex', flexDirection: 'column' }}> + <div className='static-content getting-started'> + <p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a>, apps: <a href="https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md" target="_blank"><FormattedMessage id='getting_started.apps' defaultMessage='Various apps are available' /></a> }} /></p> + </div> + </div> + </Column> + ); + } +} + +GettingStarted.propTypes = { + intl: PropTypes.object.isRequired, + me: ImmutablePropTypes.map.isRequired +}; + +export default connect(mapStateToProps)(injectIntl(GettingStarted)); diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js new file mode 100644 index 000000000..f5134decf --- /dev/null +++ b/app/javascript/mastodon/features/hashtag_timeline/index.js @@ -0,0 +1,90 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from '../ui/containers/status_list_container'; +import Column from '../ui/components/column'; +import { + refreshTimeline, + updateTimeline, + deleteFromTimelines +} from '../../actions/timelines'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import { FormattedMessage } from 'react-intl'; +import createStream from '../../stream'; + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'tag', 'unread']) > 0, + streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']), + accessToken: state.getIn(['meta', 'access_token']) +}); + +class HashtagTimeline extends React.PureComponent { + + _subscribe (dispatch, id) { + const { streamingAPIBaseURL, accessToken } = this.props; + + this.subscription = createStream(streamingAPIBaseURL, accessToken, `hashtag&tag=${id}`, { + + received (data) { + switch(data.event) { + case 'update': + dispatch(updateTimeline('tag', JSON.parse(data.payload))); + break; + case 'delete': + dispatch(deleteFromTimelines(data.payload)); + break; + } + } + + }); + } + + _unsubscribe () { + if (typeof this.subscription !== 'undefined') { + this.subscription.close(); + this.subscription = null; + } + } + + componentDidMount () { + const { dispatch } = this.props; + const { id } = this.props.params; + + dispatch(refreshTimeline('tag', id)); + this._subscribe(dispatch, id); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.params.id !== this.props.params.id) { + this.props.dispatch(refreshTimeline('tag', nextProps.params.id)); + this._unsubscribe(); + this._subscribe(this.props.dispatch, nextProps.params.id); + } + } + + componentWillUnmount () { + this._unsubscribe(); + } + + render () { + const { id, hasUnread } = this.props.params; + + return ( + <Column icon='hashtag' active={hasUnread} heading={id}> + <ColumnBackButtonSlim /> + <StatusListContainer scrollKey='hashtag_timeline' type='tag' id={id} emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} /> + </Column> + ); + } + +} + +HashtagTimeline.propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + streamingAPIBaseURL: PropTypes.string.isRequired, + accessToken: PropTypes.string.isRequired, + hasUnread: PropTypes.bool +}; + +export default connect(mapStateToProps)(HashtagTimeline); diff --git a/app/javascript/mastodon/features/home_timeline/components/column_settings.js b/app/javascript/mastodon/features/home_timeline/components/column_settings.js new file mode 100644 index 000000000..460221fc3 --- /dev/null +++ b/app/javascript/mastodon/features/home_timeline/components/column_settings.js @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnCollapsable from '../../../components/column_collapsable'; +import SettingToggle from '../../notifications/components/setting_toggle'; +import SettingText from './setting_text'; + +const messages = defineMessages({ + filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }, + settings: { id: 'home.settings', defaultMessage: 'Column settings' } +}); + +class ColumnSettings extends React.PureComponent { + + render () { + const { settings, onChange, onSave, intl } = this.props; + + return ( + <ColumnCollapsable icon='sliders' title={intl.formatMessage(messages.settings)} fullHeight={209} onCollapse={onSave}> + <div className='column-settings__outer'> + <span className='column-settings__section'><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span> + + <div className='column-settings__row'> + <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show boosts' />} /> + </div> + + <div className='column-settings__row'> + <SettingToggle settings={settings} settingKey={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} /> + </div> + + <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> + + <div className='column-settings__row'> + <SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> + </div> + </div> + </ColumnCollapsable> + ); + } + +} + +ColumnSettings.propTypes = { + settings: ImmutablePropTypes.map.isRequired, + onChange: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +} + +export default injectIntl(ColumnSettings); diff --git a/app/javascript/mastodon/features/home_timeline/components/setting_text.js b/app/javascript/mastodon/features/home_timeline/components/setting_text.js new file mode 100644 index 000000000..dfa2939b7 --- /dev/null +++ b/app/javascript/mastodon/features/home_timeline/components/setting_text.js @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +class SettingText extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleChange = this.handleChange.bind(this); + } + + handleChange (e) { + this.props.onChange(this.props.settingKey, e.target.value) + } + + render () { + const { settings, settingKey, label } = this.props; + + return ( + <input + className='setting-text' + value={settings.getIn(settingKey)} + onChange={this.handleChange} + placeholder={label} + /> + ); + } + +} + +SettingText.propTypes = { + settings: ImmutablePropTypes.map.isRequired, + settingKey: PropTypes.array.isRequired, + label: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired +}; + +export default SettingText; diff --git a/app/javascript/mastodon/features/home_timeline/containers/column_settings_container.js b/app/javascript/mastodon/features/home_timeline/containers/column_settings_container.js new file mode 100644 index 000000000..3b3ce19bc --- /dev/null +++ b/app/javascript/mastodon/features/home_timeline/containers/column_settings_container.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import ColumnSettings from '../components/column_settings'; +import { changeSetting, saveSettings } from '../../../actions/settings'; + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'home']) +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (key, checked) { + dispatch(changeSetting(['home', ...key], checked)); + }, + + onSave () { + dispatch(saveSettings()); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js new file mode 100644 index 000000000..d7c438122 --- /dev/null +++ b/app/javascript/mastodon/features/home_timeline/index.js @@ -0,0 +1,38 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from '../ui/containers/status_list_container'; +import Column from '../ui/components/column'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import { Link } from 'react-router'; + +const messages = defineMessages({ + title: { id: 'column.home', defaultMessage: 'Home' } +}); + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0 +}); + +class HomeTimeline extends React.PureComponent { + + render () { + const { intl, hasUnread } = this.props; + + return ( + <Column icon='home' active={hasUnread} heading={intl.formatMessage(messages.title)}> + <ColumnSettingsContainer /> + <StatusListContainer {...this.props} scrollKey='home_timeline' type='home' emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage="You aren't following anyone yet. Visit {public} or use search to get started and meet other users." values={{ public: <Link to='/timelines/public'><FormattedMessage id='empty_column.home.public_timeline' defaultMessage='the public timeline' /></Link> }} />} /> + </Column> + ); + } + +} + +HomeTimeline.propTypes = { + intl: PropTypes.object.isRequired, + hasUnread: PropTypes.bool +}; + +export default connect(mapStateToProps)(injectIntl(HomeTimeline)); diff --git a/app/javascript/mastodon/features/mutes/index.js b/app/javascript/mastodon/features/mutes/index.js new file mode 100644 index 000000000..884b3b3e7 --- /dev/null +++ b/app/javascript/mastodon/features/mutes/index.js @@ -0,0 +1,75 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from '../../components/loading_indicator'; +import { ScrollContainer } from 'react-router-scroll'; +import Column from '../ui/components/column'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import AccountContainer from '../../containers/account_container'; +import { fetchMutes, expandMutes } from '../../actions/mutes'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + heading: { id: 'column.mutes', defaultMessage: 'Muted users' } +}); + +const mapStateToProps = state => ({ + accountIds: state.getIn(['user_lists', 'mutes', 'items']) +}); + +class Mutes extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleScroll = this.handleScroll.bind(this); + } + + componentWillMount () { + this.props.dispatch(fetchMutes()); + } + + handleScroll (e) { + const { scrollTop, scrollHeight, clientHeight } = e.target; + + if (scrollTop === scrollHeight - clientHeight) { + this.props.dispatch(expandMutes()); + } + } + + render () { + const { intl, accountIds } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column icon='volume-off' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + <ScrollContainer scrollKey='mutes'> + <div className='scrollable mutes' onScroll={this.handleScroll}> + {accountIds.map(id => + <AccountContainer key={id} id={id} /> + )} + </div> + </ScrollContainer> + </Column> + ); + } + +} + +Mutes.propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired +}; + +export default connect(mapStateToProps)(injectIntl(Mutes)); diff --git a/app/javascript/mastodon/features/notifications/components/clear_column_button.js b/app/javascript/mastodon/features/notifications/components/clear_column_button.js new file mode 100644 index 000000000..a948bff46 --- /dev/null +++ b/app/javascript/mastodon/features/notifications/components/clear_column_button.js @@ -0,0 +1,27 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + clear: { id: 'notifications.clear', defaultMessage: 'Clear notifications' } +}); + +class ClearColumnButton extends React.Component { + + render () { + const { intl } = this.props; + + return ( + <div role='button' title={intl.formatMessage(messages.clear)} className='column-icon column-icon-clear' tabIndex='0' onClick={this.props.onClick}> + <i className='fa fa-eraser' /> + </div> + ); + } +} + +ClearColumnButton.propTypes = { + onClick: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(ClearColumnButton); diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js new file mode 100644 index 000000000..7d52b7dcd --- /dev/null +++ b/app/javascript/mastodon/features/notifications/components/column_settings.js @@ -0,0 +1,71 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnCollapsable from '../../../components/column_collapsable'; +import SettingToggle from './setting_toggle'; + +const messages = defineMessages({ + settings: { id: 'notifications.settings', defaultMessage: 'Column settings' } +}); + +class ColumnSettings extends React.PureComponent { + + render () { + const { settings, intl, onChange, onSave } = this.props; + + const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />; + const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />; + const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />; + + return ( + <ColumnCollapsable icon='sliders' title={intl.formatMessage(messages.settings)} fullHeight={616} onCollapse={onSave}> + <div className='column-settings__outer'> + <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> + + <div className='column-settings__row'> + <SettingToggle settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} /> + <SettingToggle settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} /> + <SettingToggle settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} /> + </div> + + <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> + + <div className='column-settings__row'> + <SettingToggle settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> + <SettingToggle settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} /> + <SettingToggle settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> + </div> + + <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> + + <div className='column-settings__row'> + <SettingToggle settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} /> + <SettingToggle settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} /> + <SettingToggle settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} /> + </div> + + <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> + + <div className='column-settings__row'> + <SettingToggle settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> + <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} /> + <SettingToggle settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> + </div> + </div> + </ColumnCollapsable> + ); + } + +} + +ColumnSettings.propTypes = { + settings: ImmutablePropTypes.map.isRequired, + onChange: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired + }).isRequired +}; + +export default injectIntl(ColumnSettings); diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js new file mode 100644 index 000000000..f54a65747 --- /dev/null +++ b/app/javascript/mastodon/features/notifications/components/notification.js @@ -0,0 +1,90 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import StatusContainer from '../../../containers/status_container'; +import AccountContainer from '../../../containers/account_container'; +import { FormattedMessage } from 'react-intl'; +import Permalink from '../../../components/permalink'; +import emojify from '../../../emoji'; +import escapeTextContentForBrowser from 'escape-html'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +class Notification extends ImmutablePureComponent { + + renderFollow (account, link) { + return ( + <div className='notification notification-follow'> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <i className='fa fa-fw fa-user-plus' /> + </div> + + <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} /> + </div> + + <AccountContainer id={account.get('id')} withNote={false} /> + </div> + ); + } + + renderMention (notification) { + return <StatusContainer id={notification.get('status')} />; + } + + renderFavourite (notification, link) { + return ( + <div className='notification notification-favourite'> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <i className='fa fa-fw fa-star star-icon'/> + </div> + + <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} /> + </div> + + <StatusContainer id={notification.get('status')} muted={true} /> + </div> + ); + } + + renderReblog (notification, link) { + return ( + <div className='notification notification-reblog'> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <i className='fa fa-fw fa-retweet' /> + </div> + + <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} /> + </div> + + <StatusContainer id={notification.get('status')} muted={true} /> + </div> + ); + } + + render () { // eslint-disable-line consistent-return + const { notification } = this.props; + const account = notification.get('account'); + const displayName = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username'); + const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; + const link = <Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHTML} />; + + switch(notification.get('type')) { + case 'follow': + return this.renderFollow(account, link); + case 'mention': + return this.renderMention(notification); + case 'favourite': + return this.renderFavourite(notification, link); + case 'reblog': + return this.renderReblog(notification, link); + } + } + +} + +Notification.propTypes = { + notification: ImmutablePropTypes.map.isRequired +}; + +export default Notification; diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.js b/app/javascript/mastodon/features/notifications/components/setting_toggle.js new file mode 100644 index 000000000..080804a40 --- /dev/null +++ b/app/javascript/mastodon/features/notifications/components/setting_toggle.js @@ -0,0 +1,21 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Toggle from 'react-toggle'; + +const SettingToggle = ({ settings, settingKey, label, onChange, htmlFor = '' }) => ( + <label htmlFor={htmlFor} className='setting-toggle__label'> + <Toggle checked={settings.getIn(settingKey)} onChange={(e) => onChange(settingKey, e.target.checked)} /> + <span className='setting-toggle'>{label}</span> + </label> +); + +SettingToggle.propTypes = { + settings: ImmutablePropTypes.map.isRequired, + settingKey: PropTypes.array.isRequired, + label: PropTypes.node.isRequired, + onChange: PropTypes.func.isRequired, + htmlFor: PropTypes.string +}; + +export default SettingToggle; diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js new file mode 100644 index 000000000..bc24c75e0 --- /dev/null +++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import ColumnSettings from '../components/column_settings'; +import { changeSetting, saveSettings } from '../../../actions/settings'; + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'notifications']) +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (key, checked) { + dispatch(changeSetting(['notifications', ...key], checked)); + }, + + onSave () { + dispatch(saveSettings()); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js new file mode 100644 index 000000000..4ca1b1b7b --- /dev/null +++ b/app/javascript/mastodon/features/notifications/containers/notification_container.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import { makeGetNotification } from '../../../selectors'; +import Notification from '../components/notification'; + +const makeMapStateToProps = () => { + const getNotification = makeGetNotification(); + + const mapStateToProps = (state, props) => ({ + notification: getNotification(state, props.notification, props.accountId) + }); + + return mapStateToProps; +}; + +export default connect(makeMapStateToProps)(Notification); diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js new file mode 100644 index 000000000..989013cc7 --- /dev/null +++ b/app/javascript/mastodon/features/notifications/index.js @@ -0,0 +1,143 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Column from '../ui/components/column'; +import { expandNotifications, clearNotifications, scrollTopNotifications } from '../../actions/notifications'; +import NotificationContainer from './containers/notification_container'; +import { ScrollContainer } from 'react-router-scroll'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import { createSelector } from 'reselect'; +import Immutable from 'immutable'; +import LoadMore from '../../components/load_more'; +import ClearColumnButton from './components/clear_column_button'; +import { openModal } from '../../actions/modal'; + +const messages = defineMessages({ + title: { id: 'column.notifications', defaultMessage: 'Notifications' }, + clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' }, + clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' } +}); + +const getNotifications = createSelector([ + state => Immutable.List(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()), + state => state.getIn(['notifications', 'items']) +], (excludedTypes, notifications) => notifications.filterNot(item => excludedTypes.includes(item.get('type')))); + +const mapStateToProps = state => ({ + notifications: getNotifications(state), + isLoading: state.getIn(['notifications', 'isLoading'], true), + isUnread: state.getIn(['notifications', 'unread']) > 0 +}); + +class Notifications extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleScroll = this.handleScroll.bind(this); + this.handleLoadMore = this.handleLoadMore.bind(this); + this.handleClear = this.handleClear.bind(this); + this.setRef = this.setRef.bind(this); + } + + handleScroll (e) { + const { scrollTop, scrollHeight, clientHeight } = e.target; + const offset = scrollHeight - scrollTop - clientHeight; + this._oldScrollPosition = scrollHeight - scrollTop; + + if (250 > offset && !this.props.isLoading) { + this.props.dispatch(expandNotifications()); + } else if (scrollTop < 100) { + this.props.dispatch(scrollTopNotifications(true)); + } else { + this.props.dispatch(scrollTopNotifications(false)); + } + } + + componentDidUpdate (prevProps) { + if (this.node.scrollTop > 0 && (prevProps.notifications.size < this.props.notifications.size && prevProps.notifications.first() !== this.props.notifications.first() && !!this._oldScrollPosition)) { + this.node.scrollTop = this.node.scrollHeight - this._oldScrollPosition; + } + } + + handleLoadMore (e) { + e.preventDefault(); + this.props.dispatch(expandNotifications()); + } + + handleClear () { + const { dispatch, intl } = this.props; + + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.clearMessage), + confirm: intl.formatMessage(messages.clearConfirm), + onConfirm: () => dispatch(clearNotifications()) + })); + } + + setRef (c) { + this.node = c; + } + + render () { + const { intl, notifications, shouldUpdateScroll, isLoading, isUnread } = this.props; + + let loadMore = ''; + let scrollableArea = ''; + let unread = ''; + + if (!isLoading && notifications.size > 0) { + loadMore = <LoadMore onClick={this.handleLoadMore} />; + } + + if (isUnread) { + unread = <div className='notifications__unread-indicator' />; + } + + if (isLoading || notifications.size > 0) { + scrollableArea = ( + <div className='scrollable' onScroll={this.handleScroll} ref={this.setRef}> + {unread} + + <div> + {notifications.map(item => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />)} + {loadMore} + </div> + </div> + ); + } else { + scrollableArea = ( + <div className='empty-column-indicator' ref={this.setRef}> + <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." /> + </div> + ); + } + + return ( + <Column icon='bell' active={isUnread} heading={intl.formatMessage(messages.title)}> + <ColumnSettingsContainer /> + <ClearColumnButton onClick={this.handleClear} /> + <ScrollContainer scrollKey='notifications' shouldUpdateScroll={shouldUpdateScroll}> + {scrollableArea} + </ScrollContainer> + </Column> + ); + } + +} + +Notifications.propTypes = { + notifications: ImmutablePropTypes.list.isRequired, + dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, + intl: PropTypes.object.isRequired, + isLoading: PropTypes.bool, + isUnread: PropTypes.bool +}; + +Notifications.defaultProps = { + trackScroll: true +}; + +export default connect(mapStateToProps)(injectIntl(Notifications)); diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js new file mode 100644 index 000000000..3b270c62f --- /dev/null +++ b/app/javascript/mastodon/features/public_timeline/index.js @@ -0,0 +1,96 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from '../ui/containers/status_list_container'; +import Column from '../ui/components/column'; +import { + refreshTimeline, + updateTimeline, + deleteFromTimelines, + connectTimeline, + disconnectTimeline +} from '../../actions/timelines'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import createStream from '../../stream'; + +const messages = defineMessages({ + title: { id: 'column.public', defaultMessage: 'Federated timeline' } +}); + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0, + streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']), + accessToken: state.getIn(['meta', 'access_token']) +}); + +let subscription; + +class PublicTimeline extends React.PureComponent { + + componentDidMount () { + const { dispatch, streamingAPIBaseURL, accessToken } = this.props; + + dispatch(refreshTimeline('public')); + + if (typeof subscription !== 'undefined') { + return; + } + + subscription = createStream(streamingAPIBaseURL, accessToken, 'public', { + + connected () { + dispatch(connectTimeline('public')); + }, + + reconnected () { + dispatch(connectTimeline('public')); + }, + + disconnected () { + dispatch(disconnectTimeline('public')); + }, + + received (data) { + switch(data.event) { + case 'update': + dispatch(updateTimeline('public', JSON.parse(data.payload))); + break; + case 'delete': + dispatch(deleteFromTimelines(data.payload)); + break; + } + } + + }); + } + + componentWillUnmount () { + // if (typeof subscription !== 'undefined') { + // subscription.close(); + // subscription = null; + // } + } + + render () { + const { intl, hasUnread } = this.props; + + return ( + <Column icon='globe' active={hasUnread} heading={intl.formatMessage(messages.title)}> + <ColumnBackButtonSlim /> + <StatusListContainer {...this.props} type='public' scrollKey='public_timeline' emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />} /> + </Column> + ); + } + +} + +PublicTimeline.propTypes = { + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + streamingAPIBaseURL: PropTypes.string.isRequired, + accessToken: PropTypes.string.isRequired, + hasUnread: PropTypes.bool +}; + +export default connect(mapStateToProps)(injectIntl(PublicTimeline)); diff --git a/app/javascript/mastodon/features/reblogs/index.js b/app/javascript/mastodon/features/reblogs/index.js new file mode 100644 index 000000000..48df8451d --- /dev/null +++ b/app/javascript/mastodon/features/reblogs/index.js @@ -0,0 +1,61 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from '../../components/loading_indicator'; +import { fetchReblogs } from '../../actions/interactions'; +import { ScrollContainer } from 'react-router-scroll'; +import AccountContainer from '../../containers/account_container'; +import Column from '../ui/components/column'; +import ColumnBackButton from '../../components/column_back_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'reblogged_by', Number(props.params.statusId)]) +}); + +class Reblogs extends ImmutablePureComponent { + + componentWillMount () { + this.props.dispatch(fetchReblogs(Number(this.props.params.statusId))); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) { + this.props.dispatch(fetchReblogs(Number(nextProps.params.statusId))); + } + } + + render () { + const { accountIds } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column> + <ColumnBackButton /> + + <ScrollContainer scrollKey='reblogs'> + <div className='scrollable reblogs'> + {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} + </div> + </ScrollContainer> + </Column> + ); + } + +} + +Reblogs.propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list +}; + +export default connect(mapStateToProps)(Reblogs); diff --git a/app/javascript/mastodon/features/report/components/status_check_box.js b/app/javascript/mastodon/features/report/components/status_check_box.js new file mode 100644 index 000000000..85f792479 --- /dev/null +++ b/app/javascript/mastodon/features/report/components/status_check_box.js @@ -0,0 +1,40 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import emojify from '../../../emoji'; +import Toggle from 'react-toggle'; + +class StatusCheckBox extends React.PureComponent { + + render () { + const { status, checked, onToggle, disabled } = this.props; + const content = { __html: emojify(status.get('content')) }; + + if (status.get('reblog')) { + return null; + } + + return ( + <div className='status-check-box'> + <div + className='status__content' + dangerouslySetInnerHTML={content} + /> + + <div className='status-check-box-toggle'> + <Toggle checked={checked} onChange={onToggle} disabled={disabled} /> + </div> + </div> + ); + } + +} + +StatusCheckBox.propTypes = { + status: ImmutablePropTypes.map.isRequired, + checked: PropTypes.bool, + onToggle: PropTypes.func.isRequired, + disabled: PropTypes.bool +}; + +export default StatusCheckBox; diff --git a/app/javascript/mastodon/features/report/containers/status_check_box_container.js b/app/javascript/mastodon/features/report/containers/status_check_box_container.js new file mode 100644 index 000000000..67ce9d9f3 --- /dev/null +++ b/app/javascript/mastodon/features/report/containers/status_check_box_container.js @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; +import StatusCheckBox from '../components/status_check_box'; +import { toggleStatusReport } from '../../../actions/reports'; +import Immutable from 'immutable'; + +const mapStateToProps = (state, { id }) => ({ + status: state.getIn(['statuses', id]), + checked: state.getIn(['reports', 'new', 'status_ids'], Immutable.Set()).includes(id) +}); + +const mapDispatchToProps = (dispatch, { id }) => ({ + + onToggle (e) { + dispatch(toggleStatusReport(id, e.target.checked)); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(StatusCheckBox); diff --git a/app/javascript/mastodon/features/report/index.js b/app/javascript/mastodon/features/report/index.js new file mode 100644 index 000000000..661fffe56 --- /dev/null +++ b/app/javascript/mastodon/features/report/index.js @@ -0,0 +1,131 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { cancelReport, changeReportComment, submitReport } from '../../actions/reports'; +import { fetchAccountTimeline } from '../../actions/accounts'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Column from '../ui/components/column'; +import Button from '../../components/button'; +import { makeGetAccount } from '../../selectors'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import StatusCheckBox from './containers/status_check_box_container'; +import Immutable from 'immutable'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; + +const messages = defineMessages({ + heading: { id: 'report.heading', defaultMessage: 'New report' }, + placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' }, + submit: { id: 'report.submit', defaultMessage: 'Submit' } +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = state => { + const accountId = state.getIn(['reports', 'new', 'account_id']); + + return { + isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']), + account: getAccount(state, accountId), + comment: state.getIn(['reports', 'new', 'comment']), + statusIds: Immutable.OrderedSet(state.getIn(['timelines', 'accounts_timelines', accountId, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])) + }; + }; + + return mapStateToProps; +}; + +class Report extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleCommentChange = this.handleCommentChange.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + } + + componentWillMount () { + if (!this.props.account) { + this.context.router.replace('/'); + } + } + + componentDidMount () { + if (!this.props.account) { + return; + } + + this.props.dispatch(fetchAccountTimeline(this.props.account.get('id'))); + } + + componentWillReceiveProps (nextProps) { + if (this.props.account !== nextProps.account && nextProps.account) { + this.props.dispatch(fetchAccountTimeline(nextProps.account.get('id'))); + } + } + + handleCommentChange (e) { + this.props.dispatch(changeReportComment(e.target.value)); + } + + handleSubmit () { + this.props.dispatch(submitReport()); + this.context.router.replace('/'); + } + + render () { + const { account, comment, intl, statusIds, isSubmitting } = this.props; + + if (!account) { + return null; + } + + return ( + <Column heading={intl.formatMessage(messages.heading)} icon='flag'> + <ColumnBackButtonSlim /> + + <div className='report scrollable'> + <div className='report__target'> + <FormattedMessage id='report.target' defaultMessage='Reporting' /> + <strong>{account.get('acct')}</strong> + </div> + + <div className='scrollable report__statuses'> + <div> + {statusIds.map(statusId => <StatusCheckBox id={statusId} key={statusId} disabled={isSubmitting} />)} + </div> + </div> + + <div className='report__textarea-wrapper'> + <textarea + className='report__textarea' + placeholder={intl.formatMessage(messages.placeholder)} + value={comment} + onChange={this.handleCommentChange} + disabled={isSubmitting} + /> + + <div className='report__submit'> + <div className='report__submit-button'><Button disabled={isSubmitting} text={intl.formatMessage(messages.submit)} onClick={this.handleSubmit} /></div> + </div> + </div> + </div> + </Column> + ); + } + +} + +Report.contextTypes = { + router: PropTypes.object +}; + +Report.propTypes = { + isSubmitting: PropTypes.bool, + account: ImmutablePropTypes.map, + statusIds: ImmutablePropTypes.orderedSet.isRequired, + comment: PropTypes.string.isRequired, + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default connect(makeMapStateToProps)(injectIntl(Report)); diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js new file mode 100644 index 000000000..384b47c8f --- /dev/null +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -0,0 +1,102 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import IconButton from '../../../components/icon_button'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import DropdownMenu from '../../../components/dropdown_menu'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + delete: { id: 'status.delete', defaultMessage: 'Delete' }, + mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, + reply: { id: 'status.reply', defaultMessage: 'Reply' }, + reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, + cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, + favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + report: { id: 'status.report', defaultMessage: 'Report @{name}' } +}); + +class ActionBar extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleReplyClick = this.handleReplyClick.bind(this); + this.handleReblogClick = this.handleReblogClick.bind(this); + this.handleFavouriteClick = this.handleFavouriteClick.bind(this); + this.handleDeleteClick = this.handleDeleteClick.bind(this); + this.handleMentionClick = this.handleMentionClick.bind(this); + this.handleReport = this.handleReport.bind(this); + } + + handleReplyClick () { + this.props.onReply(this.props.status); + } + + handleReblogClick (e) { + this.props.onReblog(this.props.status, e); + } + + handleFavouriteClick () { + this.props.onFavourite(this.props.status); + } + + handleDeleteClick () { + this.props.onDelete(this.props.status); + } + + handleMentionClick () { + this.props.onMention(this.props.status.get('account'), this.context.router); + } + + handleReport () { + this.props.onReport(this.props.status); + this.context.router.push('/report'); + } + + render () { + const { status, me, intl } = this.props; + + let menu = []; + + if (me === status.getIn(['account', 'id'])) { + menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); + } else { + menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); + menu.push(null); + menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); + } + + let reblogIcon = 'retweet'; + if (status.get('visibility') === 'direct') reblogIcon = 'envelope'; + else if (status.get('visibility') === 'private') reblogIcon = 'lock'; + + let reblog_disabled = (status.get('visibility') === 'direct' || status.get('visibility') === 'private'); + + return ( + <div className='detailed-status__action-bar'> + <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_id', null) === null ? 'reply' : 'reply-all'} onClick={this.handleReplyClick} /></div> + <div className='detailed-status__button'><IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div> + <div className='detailed-status__button'><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div> + <div className='detailed-status__button'><DropdownMenu size={18} icon='ellipsis-h' items={menu} direction="left" ariaLabel="More" /></div> + </div> + ); + } + +} + +ActionBar.contextTypes = { + router: PropTypes.object +}; + +ActionBar.propTypes = { + status: ImmutablePropTypes.map.isRequired, + onReply: PropTypes.func.isRequired, + onReblog: PropTypes.func.isRequired, + onFavourite: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired, + onMention: PropTypes.func.isRequired, + onReport: PropTypes.func, + me: PropTypes.number.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(ActionBar); diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js new file mode 100644 index 000000000..9e7d4f884 --- /dev/null +++ b/app/javascript/mastodon/features/status/components/card.js @@ -0,0 +1,96 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +const hostStyle = { + display: 'block', + marginTop: '5px', + fontSize: '13px' +}; + +const getHostname = url => { + const parser = document.createElement('a'); + parser.href = url; + return parser.hostname; +}; + +class Card extends React.PureComponent { + + renderLink () { + const { card } = this.props; + + let image = ''; + let provider = card.get('provider_name'); + + if (card.get('image')) { + image = ( + <div className='status-card__image'> + <img src={card.get('image')} alt={card.get('title')} className='status-card__image-image' /> + </div> + ); + } + + if (provider.length < 1) { + provider = getHostname(card.get('url')) + } + + return ( + <a href={card.get('url')} className='status-card' target='_blank' rel='noopener'> + {image} + + <div className='status-card__content'> + <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong> + <p className='status-card__description'>{(card.get('description') || '').substring(0, 50)}</p> + <span className='status-card__host' style={hostStyle}>{provider}</span> + </div> + </a> + ); + } + + renderPhoto () { + const { card } = this.props; + + return ( + <a href={card.get('url')} className='status-card-photo' target='_blank' rel='noopener'> + <img src={card.get('url')} alt={card.get('title')} width={card.get('width')} height={card.get('height')} /> + </a> + ); + } + + renderVideo () { + const { card } = this.props; + const content = { __html: card.get('html') }; + + return ( + <div + className='status-card-video' + dangerouslySetInnerHTML={content} + /> + ); + } + + render () { + const { card } = this.props; + + if (card === null) { + return null; + } + + switch(card.get('type')) { + case 'link': + return this.renderLink(); + case 'photo': + return this.renderPhoto(); + case 'video': + return this.renderVideo(); + case 'rich': + default: + return null; + } + } +} + +Card.propTypes = { + card: ImmutablePropTypes.map +}; + +export default Card; diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js new file mode 100644 index 000000000..913a186b9 --- /dev/null +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -0,0 +1,96 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Avatar from '../../../components/avatar'; +import DisplayName from '../../../components/display_name'; +import StatusContent from '../../../components/status_content'; +import MediaGallery from '../../../components/media_gallery'; +import VideoPlayer from '../../../components/video_player'; +import AttachmentList from '../../../components/attachment_list'; +import { Link } from 'react-router'; +import { FormattedDate, FormattedNumber } from 'react-intl'; +import CardContainer from '../containers/card_container'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +class DetailedStatus extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleAccountClick = this.handleAccountClick.bind(this); + } + + handleAccountClick (e) { + if (e.button === 0) { + e.preventDefault(); + this.context.router.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); + } + + e.stopPropagation(); + } + + render () { + const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status; + + let media = ''; + let applicationLink = ''; + + if (status.get('media_attachments').size > 0) { + if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { + media = <AttachmentList media={status.get('media_attachments')} />; + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={300} height={150} onOpenVideo={this.props.onOpenVideo} autoplay />; + } else { + media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />; + } + } else if (status.get('spoiler_text').length === 0) { + media = <CardContainer statusId={status.get('id')} />; + } + + if (status.get('application')) { + applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener'>{status.getIn(['application', 'name'])}</a></span>; + } + + return ( + <div className='detailed-status'> + <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'> + <div className='detailed-status__display-avatar'><Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} /></div> + <DisplayName account={status.get('account')} /> + </a> + + <StatusContent status={status} /> + + {media} + + <div className='detailed-status__meta'> + <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'> + <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /> + </a>{applicationLink} · <Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'> + <i className='fa fa-retweet' /> + <span className='detailed-status__reblogs'> + <FormattedNumber value={status.get('reblogs_count')} /> + </span> + </Link> · <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'> + <i className='fa fa-star' /> + <span className='detailed-status__favorites'> + <FormattedNumber value={status.get('favourites_count')} /> + </span> + </Link> + </div> + </div> + ); + } + +} + +DetailedStatus.contextTypes = { + router: PropTypes.object +}; + +DetailedStatus.propTypes = { + status: ImmutablePropTypes.map.isRequired, + onOpenMedia: PropTypes.func.isRequired, + onOpenVideo: PropTypes.func.isRequired, + autoPlayGif: PropTypes.bool, +}; + +export default DetailedStatus; diff --git a/app/javascript/mastodon/features/status/containers/card_container.js b/app/javascript/mastodon/features/status/containers/card_container.js new file mode 100644 index 000000000..5c8bfeec2 --- /dev/null +++ b/app/javascript/mastodon/features/status/containers/card_container.js @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import Card from '../components/card'; + +const mapStateToProps = (state, { statusId }) => ({ + card: state.getIn(['cards', statusId], null) +}); + +export default connect(mapStateToProps)(Card); diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js new file mode 100644 index 000000000..2e8c9e56a --- /dev/null +++ b/app/javascript/mastodon/features/status/index.js @@ -0,0 +1,199 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { fetchStatus } from '../../actions/statuses'; +import Immutable from 'immutable'; +import EmbeddedStatus from '../../components/status'; +import MissingIndicator from '../../components/missing_indicator'; +import DetailedStatus from './components/detailed_status'; +import ActionBar from './components/action_bar'; +import Column from '../ui/components/column'; +import { + favourite, + unfavourite, + reblog, + unreblog +} from '../../actions/interactions'; +import { + replyCompose, + mentionCompose +} from '../../actions/compose'; +import { deleteStatus } from '../../actions/statuses'; +import { initReport } from '../../actions/reports'; +import { + makeGetStatus, + getStatusAncestors, + getStatusDescendants +} from '../../selectors'; +import { ScrollContainer } from 'react-router-scroll'; +import ColumnBackButton from '../../components/column_back_button'; +import StatusContainer from '../../containers/status_container'; +import { openModal } from '../../actions/modal'; +import { isMobile } from '../../is_mobile' +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, + deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' } +}); + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, props) => ({ + status: getStatus(state, Number(props.params.statusId)), + ancestorsIds: state.getIn(['timelines', 'ancestors', Number(props.params.statusId)]), + descendantsIds: state.getIn(['timelines', 'descendants', Number(props.params.statusId)]), + me: state.getIn(['meta', 'me']), + boostModal: state.getIn(['meta', 'boost_modal']), + autoPlayGif: state.getIn(['meta', 'auto_play_gif']) + }); + + return mapStateToProps; +}; + +class Status extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleFavouriteClick = this.handleFavouriteClick.bind(this); + this.handleReplyClick = this.handleReplyClick.bind(this); + this.handleModalReblog = this.handleModalReblog.bind(this); + this.handleReblogClick = this.handleReblogClick.bind(this); + this.handleDeleteClick = this.handleDeleteClick.bind(this); + this.handleMentionClick = this.handleMentionClick.bind(this); + this.handleOpenMedia = this.handleOpenMedia.bind(this); + this.handleOpenVideo = this.handleOpenVideo.bind(this); + this.handleReport = this.handleReport.bind(this); + } + + componentWillMount () { + this.props.dispatch(fetchStatus(Number(this.props.params.statusId))); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) { + this.props.dispatch(fetchStatus(Number(nextProps.params.statusId))); + } + } + + handleFavouriteClick (status) { + if (status.get('favourited')) { + this.props.dispatch(unfavourite(status)); + } else { + this.props.dispatch(favourite(status)); + } + } + + handleReplyClick (status) { + this.props.dispatch(replyCompose(status, this.context.router)); + } + + handleModalReblog (status) { + this.props.dispatch(reblog(status)); + } + + handleReblogClick (status, e) { + if (status.get('reblogged')) { + this.props.dispatch(unreblog(status)); + } else { + if (e.shiftKey || !this.props.boostModal) { + this.handleModalReblog(status); + } else { + this.props.dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog })); + } + } + } + + handleDeleteClick (status) { + const { dispatch, intl } = this.props; + + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.deleteMessage), + confirm: intl.formatMessage(messages.deleteConfirm), + onConfirm: () => dispatch(deleteStatus(status.get('id'))) + })); + } + + handleMentionClick (account, router) { + this.props.dispatch(mentionCompose(account, router)); + } + + handleOpenMedia (media, index) { + this.props.dispatch(openModal('MEDIA', { media, index })); + } + + handleOpenVideo (media, time) { + this.props.dispatch(openModal('VIDEO', { media, time })); + } + + handleReport (status) { + this.props.dispatch(initReport(status.get('account'), status)); + } + + renderChildren (list) { + return list.map(id => <StatusContainer key={id} id={id} />); + } + + render () { + let ancestors, descendants; + const { status, ancestorsIds, descendantsIds, me, autoPlayGif } = this.props; + + if (status === null) { + return ( + <Column> + <ColumnBackButton /> + <MissingIndicator /> + </Column> + ); + } + + const account = status.get('account'); + + if (ancestorsIds && ancestorsIds.size > 0) { + ancestors = <div>{this.renderChildren(ancestorsIds)}</div>; + } + + if (descendantsIds && descendantsIds.size > 0) { + descendants = <div>{this.renderChildren(descendantsIds)}</div>; + } + + return ( + <Column> + <ColumnBackButton /> + + <ScrollContainer scrollKey='thread'> + <div className='scrollable detailed-status__wrapper'> + {ancestors} + + <DetailedStatus status={status} autoPlayGif={autoPlayGif} me={me} onOpenVideo={this.handleOpenVideo} onOpenMedia={this.handleOpenMedia} /> + <ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} onMention={this.handleMentionClick} onReport={this.handleReport} /> + + {descendants} + </div> + </ScrollContainer> + </Column> + ); + } + +} + +Status.contextTypes = { + router: PropTypes.object +}; + +Status.propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + status: ImmutablePropTypes.map, + ancestorsIds: ImmutablePropTypes.list, + descendantsIds: ImmutablePropTypes.list, + me: PropTypes.number, + boostModal: PropTypes.bool, + autoPlayGif: PropTypes.bool, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(connect(makeMapStateToProps)(Status)); 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); |