From f5bf5ebb82e3af420dcd23d602b1be6cc86838e1 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 3 May 2017 02:04:16 +0200 Subject: 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 * Add react definitions to places that use JSX * Add Procfile.dev for running rails, webpack and streaming API at the same time --- .../features/account/components/action_bar.js | 93 ++++++++ .../mastodon/features/account/components/header.js | 150 ++++++++++++ .../features/account_timeline/components/header.js | 83 +++++++ .../containers/header_container.js | 76 ++++++ .../mastodon/features/account_timeline/index.js | 89 +++++++ app/javascript/mastodon/features/blocks/index.js | 74 ++++++ .../mastodon/features/community_timeline/index.js | 96 ++++++++ .../compose/components/autosuggest_account.js | 26 ++ .../compose/components/character_counter.js | 27 +++ .../features/compose/components/compose_form.js | 211 ++++++++++++++++ .../compose/components/emoji_picker_dropdown.js | 115 +++++++++ .../features/compose/components/navigation_bar.js | 37 +++ .../compose/components/privacy_dropdown.js | 105 ++++++++ .../features/compose/components/reply_indicator.js | 71 ++++++ .../mastodon/features/compose/components/search.js | 82 +++++++ .../features/compose/components/search_results.js | 67 ++++++ .../compose/components/text_icon_button.js | 36 +++ .../features/compose/components/upload_button.js | 61 +++++ .../features/compose/components/upload_form.js | 46 ++++ .../features/compose/components/upload_progress.js | 43 ++++ .../features/compose/components/warning.js | 26 ++ .../containers/autosuggest_account_container.js | 15 ++ .../containers/autosuggest_status_container.js | 15 ++ .../compose/containers/compose_form_container.js | 64 +++++ .../compose/containers/navigation_container.js | 10 + .../containers/privacy_dropdown_container.js | 17 ++ .../containers/reply_indicator_container.js | 24 ++ .../compose/containers/search_container.js | 35 +++ .../compose/containers/search_results_container.js | 8 + .../containers/sensitive_button_container.js | 51 ++++ .../compose/containers/spoiler_button_container.js | 25 ++ .../compose/containers/upload_button_container.js | 18 ++ .../compose/containers/upload_form_container.js | 17 ++ .../containers/upload_progress_container.js | 9 + .../compose/containers/warning_container.js | 49 ++++ app/javascript/mastodon/features/compose/index.js | 86 +++++++ .../mastodon/features/favourited_statuses/index.js | 67 ++++++ .../mastodon/features/favourites/index.js | 61 +++++ .../components/account_authorize.js | 51 ++++ .../containers/account_authorize_container.js | 26 ++ .../mastodon/features/follow_requests/index.js | 74 ++++++ .../mastodon/features/followers/index.js | 92 +++++++ .../mastodon/features/following/index.js | 92 +++++++ .../mastodon/features/generic_not_found/index.js | 11 + .../mastodon/features/getting_started/index.js | 73 ++++++ .../mastodon/features/hashtag_timeline/index.js | 90 +++++++ .../home_timeline/components/column_settings.js | 51 ++++ .../home_timeline/components/setting_text.js | 38 +++ .../containers/column_settings_container.js | 21 ++ .../mastodon/features/home_timeline/index.js | 38 +++ app/javascript/mastodon/features/mutes/index.js | 75 ++++++ .../components/clear_column_button.js | 27 +++ .../notifications/components/column_settings.js | 71 ++++++ .../notifications/components/notification.js | 90 +++++++ .../notifications/components/setting_toggle.js | 21 ++ .../containers/column_settings_container.js | 21 ++ .../containers/notification_container.js | 15 ++ .../mastodon/features/notifications/index.js | 143 +++++++++++ .../mastodon/features/public_timeline/index.js | 96 ++++++++ app/javascript/mastodon/features/reblogs/index.js | 61 +++++ .../features/report/components/status_check_box.js | 40 ++++ .../containers/status_check_box_container.js | 19 ++ app/javascript/mastodon/features/report/index.js | 131 ++++++++++ .../features/status/components/action_bar.js | 102 ++++++++ .../mastodon/features/status/components/card.js | 96 ++++++++ .../features/status/components/detailed_status.js | 96 ++++++++ .../features/status/containers/card_container.js | 8 + app/javascript/mastodon/features/status/index.js | 199 ++++++++++++++++ .../mastodon/features/ui/components/boost_modal.js | 84 +++++++ .../mastodon/features/ui/components/column.js | 93 ++++++++ .../features/ui/components/column_header.js | 43 ++++ .../mastodon/features/ui/components/column_link.js | 32 +++ .../features/ui/components/column_subheading.js | 16 ++ .../features/ui/components/columns_area.js | 20 ++ .../features/ui/components/confirmation_modal.js | 51 ++++ .../mastodon/features/ui/components/media_modal.js | 103 ++++++++ .../mastodon/features/ui/components/modal_root.js | 93 ++++++++ .../features/ui/components/onboarding_modal.js | 264 +++++++++++++++++++++ .../mastodon/features/ui/components/tabs_bar.js | 24 ++ .../mastodon/features/ui/components/upload_area.js | 60 +++++ .../mastodon/features/ui/components/video_modal.js | 40 ++++ .../ui/containers/loading_bar_container.js | 8 + .../features/ui/containers/modal_container.js | 16 ++ .../ui/containers/notifications_container.js | 21 ++ .../ui/containers/status_list_container.js | 74 ++++++ app/javascript/mastodon/features/ui/index.js | 169 +++++++++++++ 86 files changed, 5364 insertions(+) create mode 100644 app/javascript/mastodon/features/account/components/action_bar.js create mode 100644 app/javascript/mastodon/features/account/components/header.js create mode 100644 app/javascript/mastodon/features/account_timeline/components/header.js create mode 100644 app/javascript/mastodon/features/account_timeline/containers/header_container.js create mode 100644 app/javascript/mastodon/features/account_timeline/index.js create mode 100644 app/javascript/mastodon/features/blocks/index.js create mode 100644 app/javascript/mastodon/features/community_timeline/index.js create mode 100644 app/javascript/mastodon/features/compose/components/autosuggest_account.js create mode 100644 app/javascript/mastodon/features/compose/components/character_counter.js create mode 100644 app/javascript/mastodon/features/compose/components/compose_form.js create mode 100644 app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js create mode 100644 app/javascript/mastodon/features/compose/components/navigation_bar.js create mode 100644 app/javascript/mastodon/features/compose/components/privacy_dropdown.js create mode 100644 app/javascript/mastodon/features/compose/components/reply_indicator.js create mode 100644 app/javascript/mastodon/features/compose/components/search.js create mode 100644 app/javascript/mastodon/features/compose/components/search_results.js create mode 100644 app/javascript/mastodon/features/compose/components/text_icon_button.js create mode 100644 app/javascript/mastodon/features/compose/components/upload_button.js create mode 100644 app/javascript/mastodon/features/compose/components/upload_form.js create mode 100644 app/javascript/mastodon/features/compose/components/upload_progress.js create mode 100644 app/javascript/mastodon/features/compose/components/warning.js create mode 100644 app/javascript/mastodon/features/compose/containers/autosuggest_account_container.js create mode 100644 app/javascript/mastodon/features/compose/containers/autosuggest_status_container.js create mode 100644 app/javascript/mastodon/features/compose/containers/compose_form_container.js create mode 100644 app/javascript/mastodon/features/compose/containers/navigation_container.js create mode 100644 app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js create mode 100644 app/javascript/mastodon/features/compose/containers/reply_indicator_container.js create mode 100644 app/javascript/mastodon/features/compose/containers/search_container.js create mode 100644 app/javascript/mastodon/features/compose/containers/search_results_container.js create mode 100644 app/javascript/mastodon/features/compose/containers/sensitive_button_container.js create mode 100644 app/javascript/mastodon/features/compose/containers/spoiler_button_container.js create mode 100644 app/javascript/mastodon/features/compose/containers/upload_button_container.js create mode 100644 app/javascript/mastodon/features/compose/containers/upload_form_container.js create mode 100644 app/javascript/mastodon/features/compose/containers/upload_progress_container.js create mode 100644 app/javascript/mastodon/features/compose/containers/warning_container.js create mode 100644 app/javascript/mastodon/features/compose/index.js create mode 100644 app/javascript/mastodon/features/favourited_statuses/index.js create mode 100644 app/javascript/mastodon/features/favourites/index.js create mode 100644 app/javascript/mastodon/features/follow_requests/components/account_authorize.js create mode 100644 app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js create mode 100644 app/javascript/mastodon/features/follow_requests/index.js create mode 100644 app/javascript/mastodon/features/followers/index.js create mode 100644 app/javascript/mastodon/features/following/index.js create mode 100644 app/javascript/mastodon/features/generic_not_found/index.js create mode 100644 app/javascript/mastodon/features/getting_started/index.js create mode 100644 app/javascript/mastodon/features/hashtag_timeline/index.js create mode 100644 app/javascript/mastodon/features/home_timeline/components/column_settings.js create mode 100644 app/javascript/mastodon/features/home_timeline/components/setting_text.js create mode 100644 app/javascript/mastodon/features/home_timeline/containers/column_settings_container.js create mode 100644 app/javascript/mastodon/features/home_timeline/index.js create mode 100644 app/javascript/mastodon/features/mutes/index.js create mode 100644 app/javascript/mastodon/features/notifications/components/clear_column_button.js create mode 100644 app/javascript/mastodon/features/notifications/components/column_settings.js create mode 100644 app/javascript/mastodon/features/notifications/components/notification.js create mode 100644 app/javascript/mastodon/features/notifications/components/setting_toggle.js create mode 100644 app/javascript/mastodon/features/notifications/containers/column_settings_container.js create mode 100644 app/javascript/mastodon/features/notifications/containers/notification_container.js create mode 100644 app/javascript/mastodon/features/notifications/index.js create mode 100644 app/javascript/mastodon/features/public_timeline/index.js create mode 100644 app/javascript/mastodon/features/reblogs/index.js create mode 100644 app/javascript/mastodon/features/report/components/status_check_box.js create mode 100644 app/javascript/mastodon/features/report/containers/status_check_box_container.js create mode 100644 app/javascript/mastodon/features/report/index.js create mode 100644 app/javascript/mastodon/features/status/components/action_bar.js create mode 100644 app/javascript/mastodon/features/status/components/card.js create mode 100644 app/javascript/mastodon/features/status/components/detailed_status.js create mode 100644 app/javascript/mastodon/features/status/containers/card_container.js create mode 100644 app/javascript/mastodon/features/status/index.js create mode 100644 app/javascript/mastodon/features/ui/components/boost_modal.js create mode 100644 app/javascript/mastodon/features/ui/components/column.js create mode 100644 app/javascript/mastodon/features/ui/components/column_header.js create mode 100644 app/javascript/mastodon/features/ui/components/column_link.js create mode 100644 app/javascript/mastodon/features/ui/components/column_subheading.js create mode 100644 app/javascript/mastodon/features/ui/components/columns_area.js create mode 100644 app/javascript/mastodon/features/ui/components/confirmation_modal.js create mode 100644 app/javascript/mastodon/features/ui/components/media_modal.js create mode 100644 app/javascript/mastodon/features/ui/components/modal_root.js create mode 100644 app/javascript/mastodon/features/ui/components/onboarding_modal.js create mode 100644 app/javascript/mastodon/features/ui/components/tabs_bar.js create mode 100644 app/javascript/mastodon/features/ui/components/upload_area.js create mode 100644 app/javascript/mastodon/features/ui/components/video_modal.js create mode 100644 app/javascript/mastodon/features/ui/containers/loading_bar_container.js create mode 100644 app/javascript/mastodon/features/ui/containers/modal_container.js create mode 100644 app/javascript/mastodon/features/ui/containers/notifications_container.js create mode 100644 app/javascript/mastodon/features/ui/containers/status_list_container.js create mode 100644 app/javascript/mastodon/features/ui/index.js (limited to 'app/javascript/mastodon/features') 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 = *; + } + + return ( +
+
+ +
+ +
+ + + {extraInfo} + + + + + {extraInfo} + + + + + {extraInfo} + +
+
+ ); + } + +} + +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 ( + + {({ radius }) => + + } + + ); + } + +} + +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 = + } + + if (me !== account.get('id')) { + if (account.getIn(['relationship', 'requested'])) { + actionBtn = ( +
+ +
+ ); + } else if (!account.getIn(['relationship', 'blocking'])) { + actionBtn = ( +
+ +
+ ); + } + } + + if (account.get('locked')) { + lockedIcon = ; + } + + const content = { __html: emojify(account.get('note')) }; + const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; + + return ( +
+
+ + + + @{account.get('acct')} {lockedIcon} +
+ + {info} + {actionBtn} +
+
+ ); + } + +} + +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 ; + } + + return ( +
+ + + +
+ ); + } +} + +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: @{account.get('acct')} }} />, + 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: @{account.get('acct')} }} />, + 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 ( + + + + ); + } + + return ( + + + + } + scrollKey='account_timeline' + statusIds={statusIds} + isLoading={isLoading} + hasMore={hasMore} + me={me} + onScrollToBottom={this.handleScrollToBottom} + /> + + ); + } + +} + +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 ( + + + + ); + } + + return ( + + + +
+ {accountIds.map(id => + + )} +
+
+
+ ); + } +} + +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 ( + + + } /> + + ); + } + +} + +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 ( +
+
+ +
+ ); + } + +} + +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 {diff}; + } + return {diff}; + } + + 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 = {intl.formatMessage(messages.publish)}; + } else { + publishText = intl.formatMessage(messages.publish) + (this.props.privacy !== 'unlisted' ? '!' : ''); + } + + return ( +
+ +
+ +
+
+ + + + + +
+ + + +
+ +
+ +
+ +
+
+ + + + +
+ +
+
+
+
+
+
+ ); + } + +} + +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 ( + + + 🙂 + + + + + + + ); + } + +} + +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 ( +
+ ); + } + +} + +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 ( +
+
+
+ {options.map(item => +
+
+
+ {item.shortText} + {item.longText} +
+
+ )} +
+
+ ); + } + +} + +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 ( +
+
+
+ + +
+ +
+
+ +
+
+ ); + } + +} + +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 ( +
+ + +
+ + +
+
+ ); + } + +} + +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 = ( +
+ {results.get('accounts').map(accountId => )} +
+ ); + } + + if (results.get('statuses') && results.get('statuses').size > 0) { + count += results.get('statuses').size; + statuses = ( +
+ {results.get('statuses').map(statusId => )} +
+ ); + } + + if (results.get('hashtags') && results.get('hashtags').size > 0) { + count += results.get('hashtags').size; + hashtags = ( +
+ {results.get('hashtags').map(hashtag => + + #{hashtag} + + )} +
+ ); + } + + return ( +
+
+ +
+ + {accounts} + {statuses} + {hashtags} +
+ ); + } + +} + +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 ( + + ); + } + +} + +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 ( +
+ + +
+ ); + } + +} + +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 => +
+ + {({ scale }) => +
+ +
+ } +
+
+ ); + + return ( +
+ +
{uploads}
+
+ ); + } + +} + +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 ( +
+
+ +
+ +
+ + +
+ + {({ width }) => +
+ } + +
+
+
+ ); + } + +} + +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 ( +
+ {message} +
+ ); + } + +} + +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 ( + + {({ scale }) => +
+ +
+ } +
+ ); + } + +} + +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 }} />} />; + } else if (needsLeakWarning) { + return ( + {mentionedDomains.join(', ')}, 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 = ( +
+ + + + + +
+ ); + } + + return ( +
+ {header} + + + +
+
+ + +
+ + + {({ x }) => +
+ +
+ } +
+
+
+ ); + } + +} + +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 ( + + + + ); + } + + return ( + + + + + ); + } + +} + +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 ( + + + + ); + } + + return ( + + + + +
+ {accountIds.map(id => )} +
+
+
+ ); + } + +} + +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 ( +
+
+ +
+ +
+ +
+
+ +
+
+
+
+
+ ); + } + +} + +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 ( + + + + ); + } + + return ( + + + +
+ {accountIds.map(id => + + )} +
+
+
+ ); + } +} + +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 ( + + + + ); + } + + return ( + + + + +
+
+ + {accountIds.map(id => )} + +
+
+
+
+ ); + } + +} + +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 ( + + + + ); + } + + return ( + + + + +
+
+ + {accountIds.map(id => )} + +
+
+
+
+ ); + } + +} + +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 = () => ( + + + +); + +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 = ; + } + + return ( + +
+ + + + + {followRequests} + + + + + + +
+ +
+
+

tootsuite/mastodon, apps: }} />

+
+
+
+ ); + } +} + +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 ( + + + } /> + + ); + } + +} + +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 ( + +
+ + +
+ } /> +
+ +
+ } /> +
+ + + +
+ +
+
+
+ ); + } + +} + +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 ( + + ); + } + +} + +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 ( + + + }} />} /> + + ); + } + +} + +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 ( + + + + ); + } + + return ( + + + +
+ {accountIds.map(id => + + )} +
+
+
+ ); + } + +} + +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 ( +
+ +
+ ); + } +} + +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 = ; + const showStr = ; + const soundStr = ; + + return ( + +
+ + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+
+
+ ); + } + +} + +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 ( +
+
+
+ +
+ + +
+ + +
+ ); + } + + renderMention (notification) { + return ; + } + + renderFavourite (notification, link) { + return ( +
+
+
+ +
+ + +
+ + +
+ ); + } + + renderReblog (notification, link) { + return ( +
+
+
+ +
+ + +
+ + +
+ ); + } + + 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 = ; + + 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 = '' }) => ( + +); + +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 = ; + } + + if (isUnread) { + unread =
; + } + + if (isLoading || notifications.size > 0) { + scrollableArea = ( +
+ {unread} + +
+ {notifications.map(item => )} + {loadMore} +
+
+ ); + } else { + scrollableArea = ( +
+ +
+ ); + } + + return ( + + + + + {scrollableArea} + + + ); + } + +} + +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 ( + + + } /> + + ); + } + +} + +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 ( + + + + ); + } + + return ( + + + + +
+ {accountIds.map(id => )} +
+
+
+ ); + } + +} + +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 ( +
+
+ +
+ +
+
+ ); + } + +} + +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 ( + + + +
+
+ + {account.get('acct')} +
+ +
+
+ {statusIds.map(statusId => )} +
+
+ +
+