From bc4fa6b198557a7f3989eb0865e2c77ac7451d29 Mon Sep 17 00:00:00 2001 From: kibigo! Date: Sun, 3 Dec 2017 23:26:40 -0800 Subject: Rename themes -> flavours ? ? --- .../features/account/components/action_bar.js | 145 +++++ .../glitch/features/account/components/header.js | 99 ++++ .../account_gallery/components/media_item.js | 39 ++ .../glitch/features/account_gallery/index.js | 111 ++++ .../features/account_timeline/components/header.js | 95 ++++ .../containers/header_container.js | 104 ++++ .../glitch/features/account_timeline/index.js | 77 +++ .../flavours/glitch/features/blocks/index.js | 70 +++ .../components/column_settings.js | 35 ++ .../containers/column_settings_container.js | 17 + .../glitch/features/community_timeline/index.js | 107 ++++ .../compose/components/advanced_options.js | 62 +++ .../compose/components/advanced_options_toggle.js | 35 ++ .../features/compose/components/attach_options.js | 131 +++++ .../compose/components/autosuggest_account.js | 24 + .../compose/components/character_counter.js | 25 + .../features/compose/components/compose_form.js | 286 ++++++++++ .../glitch/features/compose/components/dropdown.js | 77 +++ .../compose/components/emoji_picker_dropdown.js | 376 +++++++++++++ .../features/compose/components/navigation_bar.js | 38 ++ .../compose/components/privacy_dropdown.js | 200 +++++++ .../features/compose/components/reply_indicator.js | 63 +++ .../glitch/features/compose/components/search.js | 129 +++++ .../features/compose/components/search_results.js | 65 +++ .../compose/components/text_icon_button.js | 29 + .../glitch/features/compose/components/upload.js | 96 ++++ .../features/compose/components/upload_button.js | 77 +++ .../features/compose/components/upload_form.js | 29 + .../features/compose/components/upload_progress.js | 42 ++ .../glitch/features/compose/components/warning.js | 26 + .../containers/advanced_options_container.js | 20 + .../containers/autosuggest_account_container.js | 15 + .../compose/containers/compose_form_container.js | 71 +++ .../containers/emoji_picker_dropdown_container.js | 82 +++ .../compose/containers/navigation_container.js | 11 + .../containers/privacy_dropdown_container.js | 24 + .../containers/reply_indicator_container.js | 24 + .../compose/containers/search_container.js | 35 ++ .../compose/containers/search_results_container.js | 8 + .../containers/sensitive_button_container.js | 71 +++ .../compose/containers/spoiler_button_container.js | 25 + .../compose/containers/upload_button_container.js | 18 + .../compose/containers/upload_container.js | 21 + .../compose/containers/upload_form_container.js | 8 + .../containers/upload_progress_container.js | 9 + .../compose/containers/warning_container.js | 24 + .../flavours/glitch/features/compose/index.js | 126 +++++ .../containers/column_settings_container.js | 17 + .../glitch/features/direct_timeline/index.js | 107 ++++ .../glitch/features/favourited_statuses/index.js | 94 ++++ .../flavours/glitch/features/favourites/index.js | 60 ++ .../components/account_authorize.js | 49 ++ .../containers/account_authorize_container.js | 26 + .../glitch/features/follow_requests/index.js | 71 +++ .../flavours/glitch/features/followers/index.js | 93 ++++ .../flavours/glitch/features/following/index.js | 93 ++++ .../glitch/features/generic_not_found/index.js | 11 + .../glitch/features/getting_started/index.js | 145 +++++ .../glitch/features/hashtag_timeline/index.js | 118 ++++ .../home_timeline/components/column_settings.js | 46 ++ .../containers/column_settings_container.js | 21 + .../glitch/features/home_timeline/index.js | 90 +++ .../glitch/features/local_settings/index.js | 68 +++ .../features/local_settings/navigation/index.js | 74 +++ .../local_settings/navigation/item/index.js | 69 +++ .../local_settings/navigation/item/style.scss | 27 + .../features/local_settings/navigation/style.scss | 10 + .../glitch/features/local_settings/page/index.js | 212 +++++++ .../features/local_settings/page/item/index.js | 90 +++ .../features/local_settings/page/item/style.scss | 7 + .../glitch/features/local_settings/page/style.scss | 9 + .../glitch/features/local_settings/style.scss | 34 ++ .../flavours/glitch/features/mutes/index.js | 70 +++ .../components/clear_column_button.js | 17 + .../notifications/components/column_settings.js | 86 +++ .../features/notifications/components/follow.js | 97 ++++ .../notifications/components/notification.js | 88 +++ .../features/notifications/components/overlay.js | 57 ++ .../notifications/components/setting_toggle.js | 34 ++ .../containers/column_settings_container.js | 44 ++ .../containers/notification_container.js | 27 + .../notifications/containers/overlay_container.js | 18 + .../glitch/features/notifications/index.js | 193 +++++++ .../glitch/features/pinned_statuses/index.js | 59 ++ .../containers/column_settings_container.js | 17 + .../glitch/features/public_timeline/index.js | 107 ++++ .../flavours/glitch/features/reblogs/index.js | 60 ++ .../features/report/components/status_check_box.js | 37 ++ .../containers/status_check_box_container.js | 19 + .../glitch/features/standalone/compose/index.js | 20 + .../features/standalone/hashtag_timeline/index.js | 70 +++ .../features/standalone/public_timeline/index.js | 76 +++ .../features/status/components/action_bar.js | 129 +++++ .../glitch/features/status/components/card.js | 125 +++++ .../features/status/components/detailed_status.js | 128 +++++ .../features/status/containers/card_container.js | 8 + .../flavours/glitch/features/status/index.js | 343 ++++++++++++ .../glitch/features/ui/components/actions_modal.js | 74 +++ .../glitch/features/ui/components/boost_modal.js | 84 +++ .../glitch/features/ui/components/bundle.js | 102 ++++ .../features/ui/components/bundle_column_error.js | 44 ++ .../features/ui/components/bundle_modal_error.js | 53 ++ .../glitch/features/ui/components/column.js | 74 +++ .../glitch/features/ui/components/column_header.js | 35 ++ .../glitch/features/ui/components/column_link.js | 39 ++ .../features/ui/components/column_loading.js | 30 + .../features/ui/components/column_subheading.js | 16 + .../glitch/features/ui/components/columns_area.js | 174 ++++++ .../features/ui/components/confirmation_modal.js | 53 ++ .../glitch/features/ui/components/doodle_modal.js | 614 +++++++++++++++++++++ .../features/ui/components/drawer_loading.js | 11 + .../glitch/features/ui/components/embed_modal.js | 84 +++ .../glitch/features/ui/components/image_loader.js | 152 +++++ .../glitch/features/ui/components/media_modal.js | 126 +++++ .../glitch/features/ui/components/modal_loading.js | 20 + .../glitch/features/ui/components/modal_root.js | 131 +++++ .../glitch/features/ui/components/mute_modal.js | 105 ++++ .../features/ui/components/onboarding_modal.js | 323 +++++++++++ .../glitch/features/ui/components/report_modal.js | 105 ++++ .../glitch/features/ui/components/tabs_bar.js | 84 +++ .../glitch/features/ui/components/upload_area.js | 52 ++ .../glitch/features/ui/components/video_modal.js | 33 ++ .../features/ui/containers/bundle_container.js | 19 + .../ui/containers/columns_area_container.js | 8 + .../ui/containers/loading_bar_container.js | 8 + .../features/ui/containers/modal_container.js | 16 + .../ui/containers/notifications_container.js | 18 + .../ui/containers/status_list_container.js | 73 +++ .../flavours/glitch/features/ui/index.js | 442 +++++++++++++++ .../flavours/glitch/features/video/index.js | 288 ++++++++++ 130 files changed, 10361 insertions(+) create mode 100644 app/javascript/flavours/glitch/features/account/components/action_bar.js create mode 100644 app/javascript/flavours/glitch/features/account/components/header.js create mode 100644 app/javascript/flavours/glitch/features/account_gallery/components/media_item.js create mode 100644 app/javascript/flavours/glitch/features/account_gallery/index.js create mode 100644 app/javascript/flavours/glitch/features/account_timeline/components/header.js create mode 100644 app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js create mode 100644 app/javascript/flavours/glitch/features/account_timeline/index.js create mode 100644 app/javascript/flavours/glitch/features/blocks/index.js create mode 100644 app/javascript/flavours/glitch/features/community_timeline/components/column_settings.js create mode 100644 app/javascript/flavours/glitch/features/community_timeline/containers/column_settings_container.js create mode 100644 app/javascript/flavours/glitch/features/community_timeline/index.js create mode 100644 app/javascript/flavours/glitch/features/compose/components/advanced_options.js create mode 100644 app/javascript/flavours/glitch/features/compose/components/advanced_options_toggle.js create mode 100644 app/javascript/flavours/glitch/features/compose/components/attach_options.js create mode 100644 app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js create mode 100644 app/javascript/flavours/glitch/features/compose/components/character_counter.js create mode 100644 app/javascript/flavours/glitch/features/compose/components/compose_form.js create mode 100644 app/javascript/flavours/glitch/features/compose/components/dropdown.js create mode 100644 app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js create mode 100644 app/javascript/flavours/glitch/features/compose/components/navigation_bar.js create mode 100644 app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js create mode 100644 app/javascript/flavours/glitch/features/compose/components/reply_indicator.js create mode 100644 app/javascript/flavours/glitch/features/compose/components/search.js create mode 100644 app/javascript/flavours/glitch/features/compose/components/search_results.js create mode 100644 app/javascript/flavours/glitch/features/compose/components/text_icon_button.js create mode 100644 app/javascript/flavours/glitch/features/compose/components/upload.js create mode 100644 app/javascript/flavours/glitch/features/compose/components/upload_button.js create mode 100644 app/javascript/flavours/glitch/features/compose/components/upload_form.js create mode 100644 app/javascript/flavours/glitch/features/compose/components/upload_progress.js create mode 100644 app/javascript/flavours/glitch/features/compose/components/warning.js create mode 100644 app/javascript/flavours/glitch/features/compose/containers/advanced_options_container.js create mode 100644 app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js create mode 100644 app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js create mode 100644 app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js create mode 100644 app/javascript/flavours/glitch/features/compose/containers/navigation_container.js create mode 100644 app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js create mode 100644 app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js create mode 100644 app/javascript/flavours/glitch/features/compose/containers/search_container.js create mode 100644 app/javascript/flavours/glitch/features/compose/containers/search_results_container.js create mode 100644 app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js create mode 100644 app/javascript/flavours/glitch/features/compose/containers/spoiler_button_container.js create mode 100644 app/javascript/flavours/glitch/features/compose/containers/upload_button_container.js create mode 100644 app/javascript/flavours/glitch/features/compose/containers/upload_container.js create mode 100644 app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js create mode 100644 app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js create mode 100644 app/javascript/flavours/glitch/features/compose/containers/warning_container.js create mode 100644 app/javascript/flavours/glitch/features/compose/index.js create mode 100644 app/javascript/flavours/glitch/features/direct_timeline/containers/column_settings_container.js create mode 100644 app/javascript/flavours/glitch/features/direct_timeline/index.js create mode 100644 app/javascript/flavours/glitch/features/favourited_statuses/index.js create mode 100644 app/javascript/flavours/glitch/features/favourites/index.js create mode 100644 app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.js create mode 100644 app/javascript/flavours/glitch/features/follow_requests/containers/account_authorize_container.js create mode 100644 app/javascript/flavours/glitch/features/follow_requests/index.js create mode 100644 app/javascript/flavours/glitch/features/followers/index.js create mode 100644 app/javascript/flavours/glitch/features/following/index.js create mode 100644 app/javascript/flavours/glitch/features/generic_not_found/index.js create mode 100644 app/javascript/flavours/glitch/features/getting_started/index.js create mode 100644 app/javascript/flavours/glitch/features/hashtag_timeline/index.js create mode 100644 app/javascript/flavours/glitch/features/home_timeline/components/column_settings.js create mode 100644 app/javascript/flavours/glitch/features/home_timeline/containers/column_settings_container.js create mode 100644 app/javascript/flavours/glitch/features/home_timeline/index.js create mode 100644 app/javascript/flavours/glitch/features/local_settings/index.js create mode 100644 app/javascript/flavours/glitch/features/local_settings/navigation/index.js create mode 100644 app/javascript/flavours/glitch/features/local_settings/navigation/item/index.js create mode 100644 app/javascript/flavours/glitch/features/local_settings/navigation/item/style.scss create mode 100644 app/javascript/flavours/glitch/features/local_settings/navigation/style.scss create mode 100644 app/javascript/flavours/glitch/features/local_settings/page/index.js create mode 100644 app/javascript/flavours/glitch/features/local_settings/page/item/index.js create mode 100644 app/javascript/flavours/glitch/features/local_settings/page/item/style.scss create mode 100644 app/javascript/flavours/glitch/features/local_settings/page/style.scss create mode 100644 app/javascript/flavours/glitch/features/local_settings/style.scss create mode 100644 app/javascript/flavours/glitch/features/mutes/index.js create mode 100644 app/javascript/flavours/glitch/features/notifications/components/clear_column_button.js create mode 100644 app/javascript/flavours/glitch/features/notifications/components/column_settings.js create mode 100644 app/javascript/flavours/glitch/features/notifications/components/follow.js create mode 100644 app/javascript/flavours/glitch/features/notifications/components/notification.js create mode 100644 app/javascript/flavours/glitch/features/notifications/components/overlay.js create mode 100644 app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js create mode 100644 app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js create mode 100644 app/javascript/flavours/glitch/features/notifications/containers/notification_container.js create mode 100644 app/javascript/flavours/glitch/features/notifications/containers/overlay_container.js create mode 100644 app/javascript/flavours/glitch/features/notifications/index.js create mode 100644 app/javascript/flavours/glitch/features/pinned_statuses/index.js create mode 100644 app/javascript/flavours/glitch/features/public_timeline/containers/column_settings_container.js create mode 100644 app/javascript/flavours/glitch/features/public_timeline/index.js create mode 100644 app/javascript/flavours/glitch/features/reblogs/index.js create mode 100644 app/javascript/flavours/glitch/features/report/components/status_check_box.js create mode 100644 app/javascript/flavours/glitch/features/report/containers/status_check_box_container.js create mode 100644 app/javascript/flavours/glitch/features/standalone/compose/index.js create mode 100644 app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js create mode 100644 app/javascript/flavours/glitch/features/standalone/public_timeline/index.js create mode 100644 app/javascript/flavours/glitch/features/status/components/action_bar.js create mode 100644 app/javascript/flavours/glitch/features/status/components/card.js create mode 100644 app/javascript/flavours/glitch/features/status/components/detailed_status.js create mode 100644 app/javascript/flavours/glitch/features/status/containers/card_container.js create mode 100644 app/javascript/flavours/glitch/features/status/index.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/actions_modal.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/boost_modal.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/bundle.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/bundle_column_error.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/bundle_modal_error.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/column.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/column_header.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/column_link.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/column_loading.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/column_subheading.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/columns_area.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/doodle_modal.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/drawer_loading.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/embed_modal.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/image_loader.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/media_modal.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/modal_loading.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/modal_root.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/mute_modal.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/report_modal.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/tabs_bar.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/upload_area.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/video_modal.js create mode 100644 app/javascript/flavours/glitch/features/ui/containers/bundle_container.js create mode 100644 app/javascript/flavours/glitch/features/ui/containers/columns_area_container.js create mode 100644 app/javascript/flavours/glitch/features/ui/containers/loading_bar_container.js create mode 100644 app/javascript/flavours/glitch/features/ui/containers/modal_container.js create mode 100644 app/javascript/flavours/glitch/features/ui/containers/notifications_container.js create mode 100644 app/javascript/flavours/glitch/features/ui/containers/status_list_container.js create mode 100644 app/javascript/flavours/glitch/features/ui/index.js create mode 100644 app/javascript/flavours/glitch/features/video/index.js (limited to 'app/javascript/flavours/glitch/features') diff --git a/app/javascript/flavours/glitch/features/account/components/action_bar.js b/app/javascript/flavours/glitch/features/account/components/action_bar.js new file mode 100644 index 000000000..7c9c1af4e --- /dev/null +++ b/app/javascript/flavours/glitch/features/account/components/action_bar.js @@ -0,0 +1,145 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container'; +import { Link } from 'react-router-dom'; +import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl'; +import { me } from 'flavours/glitch/util/initial_state'; + +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}' }, + share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' }, + media: { id: 'account.media', defaultMessage: 'Media' }, + blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' }, + unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, + hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' }, + showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' }, +}); + +@injectIntl +export default class ActionBar extends React.PureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + onFollow: PropTypes.func, + onBlock: PropTypes.func.isRequired, + onMention: PropTypes.func.isRequired, + onReblogToggle: PropTypes.func.isRequired, + onReport: PropTypes.func.isRequired, + onMute: PropTypes.func.isRequired, + onBlockDomain: PropTypes.func.isRequired, + onUnblockDomain: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleShare = () => { + navigator.share({ + url: this.props.account.get('url'), + }); + } + + render () { + const { account, intl } = this.props; + + let menu = []; + let extraInfo = ''; + + menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention }); + if ('share' in navigator) { + menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare }); + } + menu.push(null); + menu.push({ text: intl.formatMessage(messages.media), to: `/accounts/${account.get('id')}/media` }); + menu.push(null); + + if (account.get('id') === me) { + menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }); + } else { + const following = account.getIn(['relationship', 'following']); + if (following) { + if (following.get('reblogs')) { + menu.push({ text: intl.formatMessage(messages.hideReblogs, { name: account.get('username') }), action: this.props.onReblogToggle }); + } else { + menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle }); + } + } + + 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')) { + const domain = account.get('acct').split('@')[1]; + + extraInfo = ( +
+ + {' '} + + + +
+ ); + + menu.push(null); + + if (account.getIn(['relationship', 'domain_blocking'])) { + menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.props.onUnblockDomain }); + } else { + menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.props.onBlockDomain }); + } + } + + return ( +
+ {extraInfo} + +
+
+ +
+ +
+ + + + + + + + + + + + + + +
+
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/account/components/header.js b/app/javascript/flavours/glitch/features/account/components/header.js new file mode 100644 index 000000000..f5ecaae71 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account/components/header.js @@ -0,0 +1,99 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import Avatar from 'flavours/glitch/components/avatar'; +import IconButton from 'flavours/glitch/components/icon_button'; + +import emojify from 'flavours/glitch/util/emoji'; +import { me } from 'flavours/glitch/util/initial_state'; +import { processBio } from 'flavours/glitch/util/bio_metadata'; + +const messages = defineMessages({ + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' }, +}); + +@injectIntl +export default class Header extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map, + onFollow: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render () { + const { account, intl } = this.props; + + if (!account) { + return null; + } + + let displayName = account.get('display_name_html'); + let info = ''; + let actionBtn = ''; + + 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 = ( +
+ +
+ ); + } + } + + const { text, metadata } = processBio(account.get('note')); + + return ( +
+
+
+ + + + @{account.get('acct')} {account.get('locked') ? : null} +
+ + {info} + {actionBtn} +
+
+ + {metadata.length && ( + + + {(() => { + let data = []; + for (let i = 0; i < metadata.length; i++) { + data.push( + + + + + ); + } + return data; + })()} + +
+ ) || null} +
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js new file mode 100644 index 000000000..e52d3b0bb --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js @@ -0,0 +1,39 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Permalink from 'flavours/glitch/components/permalink'; + +export default class MediaItem extends ImmutablePureComponent { + + static propTypes = { + media: ImmutablePropTypes.map.isRequired, + }; + + render () { + const { media } = this.props; + const status = media.get('status'); + + let content, style; + + if (media.get('type') === 'gifv') { + content = GIF; + } + + if (!status.get('sensitive')) { + style = { backgroundImage: `url(${media.get('preview_url')})` }; + } + + return ( +
+ + {content} + +
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/account_gallery/index.js b/app/javascript/flavours/glitch/features/account_gallery/index.js new file mode 100644 index 000000000..951e019e3 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_gallery/index.js @@ -0,0 +1,111 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { fetchAccount } from 'flavours/glitch/actions/accounts'; +import { refreshAccountMediaTimeline, expandAccountMediaTimeline } from 'flavours/glitch/actions/timelines'; +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; +import Column from 'flavours/glitch/features/ui/components/column'; +import ColumnBackButton from 'flavours/glitch/components/column_back_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { getAccountGallery } from 'flavours/glitch/selectors'; +import MediaItem from './components/media_item'; +import HeaderContainer from 'flavours/glitch/features/account_timeline/containers/header_container'; +import { FormattedMessage } from 'react-intl'; +import { ScrollContainer } from 'react-router-scroll-4'; +import LoadMore from 'flavours/glitch/components/load_more'; + +const mapStateToProps = (state, props) => ({ + medias: getAccountGallery(state, props.params.accountId), + isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']), + hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}:media`, 'next']), +}); + +@connect(mapStateToProps) +export default class AccountGallery extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + medias: ImmutablePropTypes.list.isRequired, + isLoading: PropTypes.bool, + hasMore: PropTypes.bool, + }; + + componentDidMount () { + this.props.dispatch(fetchAccount(this.props.params.accountId)); + this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId)); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { + this.props.dispatch(fetchAccount(nextProps.params.accountId)); + this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId)); + } + } + + handleScrollToBottom = () => { + if (this.props.hasMore) { + this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId)); + } + } + + handleScroll = (e) => { + const { scrollTop, scrollHeight, clientHeight } = e.target; + const offset = scrollHeight - scrollTop - clientHeight; + + if (150 > offset && !this.props.isLoading) { + this.handleScrollToBottom(); + } + } + + handleLoadMore = (e) => { + e.preventDefault(); + this.handleScrollToBottom(); + } + + render () { + const { medias, isLoading, hasMore } = this.props; + + let loadMore = null; + + if (!medias && isLoading) { + return ( + + + + ); + } + + if (!isLoading && medias.size > 0 && hasMore) { + loadMore = ; + } + + return ( + + + + +
+ + +
+ +
+ +
+ {medias.map(media => + + )} + {loadMore} +
+
+
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/header.js b/app/javascript/flavours/glitch/features/account_timeline/components/header.js new file mode 100644 index 000000000..4ad677fbe --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_timeline/components/header.js @@ -0,0 +1,95 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import InnerHeader from 'flavours/glitch/features/account/components/header'; +import ActionBar from 'flavours/glitch/features/account/components/action_bar'; +import MissingIndicator from 'flavours/glitch/components/missing_indicator'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +export default class Header extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map, + onFollow: PropTypes.func.isRequired, + onBlock: PropTypes.func.isRequired, + onMention: PropTypes.func.isRequired, + onReblogToggle: PropTypes.func.isRequired, + onReport: PropTypes.func.isRequired, + onMute: PropTypes.func.isRequired, + onBlockDomain: PropTypes.func.isRequired, + onUnblockDomain: PropTypes.func.isRequired, + }; + + static contextTypes = { + router: PropTypes.object, + }; + + handleFollow = () => { + this.props.onFollow(this.props.account); + } + + handleBlock = () => { + this.props.onBlock(this.props.account); + } + + handleMention = () => { + this.props.onMention(this.props.account, this.context.router.history); + } + + handleReport = () => { + this.props.onReport(this.props.account); + } + + handleReblogToggle = () => { + this.props.onReblogToggle(this.props.account); + } + + handleMute = () => { + this.props.onMute(this.props.account); + } + + handleBlockDomain = () => { + const domain = this.props.account.get('acct').split('@')[1]; + + if (!domain) return; + + this.props.onBlockDomain(domain, this.props.account.get('id')); + } + + handleUnblockDomain = () => { + const domain = this.props.account.get('acct').split('@')[1]; + + if (!domain) return; + + this.props.onUnblockDomain(domain, this.props.account.get('id')); + } + + render () { + const { account } = this.props; + + if (account === null) { + return ; + } + + return ( +
+ + + +
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js new file mode 100644 index 000000000..815a1a613 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js @@ -0,0 +1,104 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { makeGetAccount } from 'flavours/glitch/selectors'; +import Header from '../components/header'; +import { + followAccount, + unfollowAccount, + blockAccount, + unblockAccount, + unmuteAccount, +} from 'flavours/glitch/actions/accounts'; +import { mentionCompose } from 'flavours/glitch/actions/compose'; +import { initMuteModal } from 'flavours/glitch/actions/mutes'; +import { initReport } from 'flavours/glitch/actions/reports'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { blockDomain, unblockDomain } from 'flavours/glitch/actions/domain_blocks'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { unfollowModal } from 'flavours/glitch/util/initial_state'; + +const messages = defineMessages({ + unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, + blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, + blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { accountId }) => ({ + account: getAccount(state, accountId), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onFollow (account) { + if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { + if (unfollowModal) { + dispatch(openModal('CONFIRM', { + message: @{account.get('acct')} }} />, + confirm: intl.formatMessage(messages.unfollowConfirm), + onConfirm: () => dispatch(unfollowAccount(account.get('id'))), + })); + } else { + 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)); + }, + + onReblogToggle (account) { + if (account.getIn(['relationship', 'following', 'reblogs'])) { + dispatch(followAccount(account.get('id'), false)); + } else { + dispatch(followAccount(account.get('id'), true)); + } + }, + + onReport (account) { + dispatch(initReport(account)); + }, + + onMute (account) { + if (account.getIn(['relationship', 'muting'])) { + dispatch(unmuteAccount(account.get('id'))); + } else { + dispatch(initMuteModal(account)); + } + }, + + onBlockDomain (domain, accountId) { + dispatch(openModal('CONFIRM', { + message: {domain} }} />, + confirm: intl.formatMessage(messages.blockDomainConfirm), + onConfirm: () => dispatch(blockDomain(domain, accountId)), + })); + }, + + onUnblockDomain (domain, accountId) { + dispatch(unblockDomain(domain, accountId)); + }, + +}); + +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header)); diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.js b/app/javascript/flavours/glitch/features/account_timeline/index.js new file mode 100644 index 000000000..75dba5049 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_timeline/index.js @@ -0,0 +1,77 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { fetchAccount } from 'flavours/glitch/actions/accounts'; +import { refreshAccountTimeline, expandAccountTimeline } from 'flavours/glitch/actions/timelines'; +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 { List as ImmutableList } from 'immutable'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const mapStateToProps = (state, props) => ({ + statusIds: state.getIn(['timelines', `account:${props.params.accountId}`, 'items'], ImmutableList()), + isLoading: state.getIn(['timelines', `account:${props.params.accountId}`, 'isLoading']), + hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}`, 'next']), +}); + +@connect(mapStateToProps) +export default class AccountTimeline extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list, + isLoading: PropTypes.bool, + hasMore: PropTypes.bool, + }; + + componentWillMount () { + this.props.dispatch(fetchAccount(this.props.params.accountId)); + this.props.dispatch(refreshAccountTimeline(this.props.params.accountId)); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { + this.props.dispatch(fetchAccount(nextProps.params.accountId)); + this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId)); + } + } + + handleScrollToBottom = () => { + if (!this.props.isLoading && this.props.hasMore) { + this.props.dispatch(expandAccountTimeline(this.props.params.accountId)); + } + } + + render () { + const { statusIds, isLoading, hasMore } = this.props; + + if (!statusIds && isLoading) { + return ( + + + + ); + } + + return ( + + + + } + scrollKey='account_timeline' + statusIds={statusIds} + isLoading={isLoading} + hasMore={hasMore} + onScrollToBottom={this.handleScrollToBottom} + /> + + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/blocks/index.js b/app/javascript/flavours/glitch/features/blocks/index.js new file mode 100644 index 000000000..edd448921 --- /dev/null +++ b/app/javascript/flavours/glitch/features/blocks/index.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; +import { ScrollContainer } from 'react-router-scroll-4'; +import Column from 'flavours/glitch/features/ui/components/column'; +import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import { fetchBlocks, expandBlocks } from 'flavours/glitch/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']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class Blocks extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired, + }; + + 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 => + + )} +
+
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.js b/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.js new file mode 100644 index 000000000..6dc292ee5 --- /dev/null +++ b/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.js @@ -0,0 +1,35 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import SettingText from 'flavours/glitch/components/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' }, +}); + +@injectIntl +export default class ColumnSettings extends React.PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + onChange: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render () { + const { settings, onChange, intl } = this.props; + + return ( +
+ + +
+ +
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/community_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/community_timeline/containers/column_settings_container.js new file mode 100644 index 000000000..84234a836 --- /dev/null +++ b/app/javascript/flavours/glitch/features/community_timeline/containers/column_settings_container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import ColumnSettings from '../components/column_settings'; +import { changeSetting } from 'flavours/glitch/actions/settings'; + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'community']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (key, checked) { + dispatch(changeSetting(['community', ...key], checked)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/flavours/glitch/features/community_timeline/index.js b/app/javascript/flavours/glitch/features/community_timeline/index.js new file mode 100644 index 000000000..55355414f --- /dev/null +++ b/app/javascript/flavours/glitch/features/community_timeline/index.js @@ -0,0 +1,107 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; +import Column from 'flavours/glitch/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import { + refreshCommunityTimeline, + expandCommunityTimeline, +} from 'flavours/glitch/actions/timelines'; +import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import { connectCommunityStream } from 'flavours/glitch/actions/streaming'; + +const messages = defineMessages({ + title: { id: 'column.community', defaultMessage: 'Local timeline' }, +}); + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0, +}); + +@connect(mapStateToProps) +@injectIntl +export default class CommunityTimeline extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + columnId: PropTypes.string, + intl: PropTypes.object.isRequired, + hasUnread: PropTypes.bool, + multiColumn: PropTypes.bool, + }; + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('COMMUNITY', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + componentDidMount () { + const { dispatch } = this.props; + + dispatch(refreshCommunityTimeline()); + this.disconnect = dispatch(connectCommunityStream()); + } + + componentWillUnmount () { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = () => { + this.props.dispatch(expandCommunityTimeline()); + } + + render () { + const { intl, hasUnread, columnId, multiColumn } = this.props; + const pinned = !!columnId; + + return ( + + + + + + } + /> + + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/advanced_options.js b/app/javascript/flavours/glitch/features/compose/components/advanced_options.js new file mode 100644 index 000000000..045bad2e5 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/advanced_options.js @@ -0,0 +1,62 @@ +// Package imports. +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { injectIntl, defineMessages } from 'react-intl'; + +// Our imports. +import ComposeAdvancedOptionsToggle from './advanced_options_toggle'; +import ComposeDropdown from './dropdown'; + +const messages = defineMessages({ + local_only_short : + { id: 'advanced-options.local-only.short', defaultMessage: 'Local-only' }, + local_only_long : + { id: 'advanced-options.local-only.long', defaultMessage: 'Do not post to other instances' }, + advanced_options_icon_title : + { id: 'advanced_options.icon_title', defaultMessage: 'Advanced options' }, +}); + +@injectIntl +export default class ComposeAdvancedOptions extends React.PureComponent { + + static propTypes = { + values : ImmutablePropTypes.contains({ + do_not_federate : PropTypes.bool.isRequired, + }).isRequired, + onChange : PropTypes.func.isRequired, + intl : PropTypes.object.isRequired, + }; + + render () { + const { intl, values } = this.props; + const options = [ + { icon: 'wifi', shortText: messages.local_only_short, longText: messages.local_only_long, name: 'do_not_federate' }, + ]; + const anyEnabled = values.some((enabled) => enabled); + + const optionElems = options.map((option) => { + return ( + + ); + }); + + return ( + + {optionElems} + + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/advanced_options_toggle.js b/app/javascript/flavours/glitch/features/compose/components/advanced_options_toggle.js new file mode 100644 index 000000000..98b3b6a44 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/advanced_options_toggle.js @@ -0,0 +1,35 @@ +// Package imports. +import React from 'react'; +import PropTypes from 'prop-types'; +import Toggle from 'react-toggle'; + +export default class ComposeAdvancedOptionsToggle extends React.PureComponent { + + static propTypes = { + onChange: PropTypes.func.isRequired, + active: PropTypes.bool.isRequired, + name: PropTypes.string.isRequired, + shortText: PropTypes.string.isRequired, + longText: PropTypes.string.isRequired, + } + + onToggle = () => { + this.props.onChange(this.props.name); + } + + render() { + const { active, shortText, longText } = this.props; + return ( +
+
+ +
+
+ {shortText} + {longText} +
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/attach_options.js b/app/javascript/flavours/glitch/features/compose/components/attach_options.js new file mode 100644 index 000000000..6c7a1f55f --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/attach_options.js @@ -0,0 +1,131 @@ +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { injectIntl, defineMessages } from 'react-intl'; + +// Our imports // +import ComposeDropdown from './dropdown'; +import { uploadCompose } from 'flavours/glitch/actions/compose'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { openModal } from 'flavours/glitch/actions/modal'; + +const messages = defineMessages({ + upload : + { id: 'compose.attach.upload', defaultMessage: 'Upload a file' }, + doodle : + { id: 'compose.attach.doodle', defaultMessage: 'Draw something' }, + attach : + { id: 'compose.attach', defaultMessage: 'Attach...' }, +}); + +const mapStateToProps = state => ({ + // This horrible expression is copied from vanilla upload_button_container + 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']), + acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']), +}); + +const mapDispatchToProps = dispatch => ({ + onSelectFile (files) { + dispatch(uploadCompose(files)); + }, + onOpenDoodle () { + dispatch(openModal('DOODLE', { noEsc: true })); + }, +}); + +@injectIntl +@connect(mapStateToProps, mapDispatchToProps) +export default class ComposeAttachOptions extends ImmutablePureComponent { + + static propTypes = { + intl : PropTypes.object.isRequired, + resetFileKey: PropTypes.number, + acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired, + disabled: PropTypes.bool, + onSelectFile: PropTypes.func.isRequired, + onOpenDoodle: PropTypes.func.isRequired, + }; + + handleItemClick = bt => { + if (bt === 'upload') { + this.fileElement.click(); + } + + if (bt === 'doodle') { + this.props.onOpenDoodle(); + } + + this.dropdown.setState({ open: false }); + }; + + handleFileChange = (e) => { + if (e.target.files.length > 0) { + this.props.onSelectFile(e.target.files); + } + } + + setFileRef = (c) => { + this.fileElement = c; + } + + setDropdownRef = (c) => { + this.dropdown = c; + } + + render () { + const { intl, resetFileKey, disabled, acceptContentTypes } = this.props; + + const options = [ + { icon: 'cloud-upload', text: messages.upload, name: 'upload' }, + { icon: 'paint-brush', text: messages.doodle, name: 'doodle' }, + ]; + + const optionElems = options.map((item) => { + const hdl = () => this.handleItemClick(item.name); + return ( +
+
+ +
+ +
+ {intl.formatMessage(item.text)} +
+
+ ); + }); + + return ( +
+ + {optionElems} + + +
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js b/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js new file mode 100644 index 000000000..3d474af30 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js @@ -0,0 +1,24 @@ +import React from 'react'; +import Avatar from 'flavours/glitch/components/avatar'; +import DisplayName from 'flavours/glitch/components/display_name'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +export default class AutosuggestAccount extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + }; + + render () { + const { account } = this.props; + + return ( +
+
+ +
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/character_counter.js b/app/javascript/flavours/glitch/features/compose/components/character_counter.js new file mode 100644 index 000000000..0ecfc9141 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/character_counter.js @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { length } from 'stringz'; + +export default class CharacterCounter extends React.PureComponent { + + static propTypes = { + text: PropTypes.string.isRequired, + max: PropTypes.number.isRequired, + }; + + checkRemainingText (diff) { + if (diff < 0) { + return {diff}; + } + + return {diff}; + } + + render () { + const diff = this.props.max - length(this.props.text); + return this.checkRemainingText(diff); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js new file mode 100644 index 000000000..67ce935f4 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js @@ -0,0 +1,286 @@ +import React from 'react'; +import CharacterCounter from './character_counter'; +import Button from 'flavours/glitch/components/button'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import ReplyIndicatorContainer from '../containers/reply_indicator_container'; +import AutosuggestTextarea from 'flavours/glitch/components/autosuggest_textarea'; +import { defineMessages, injectIntl } from 'react-intl'; +import Collapsable from 'flavours/glitch/components/collapsable'; +import SpoilerButtonContainer from '../containers/spoiler_button_container'; +import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; +import ComposeAdvancedOptionsContainer from '../containers/advanced_options_container'; +import SensitiveButtonContainer from '../containers/sensitive_button_container'; +import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; +import UploadFormContainer from '../containers/upload_form_container'; +import WarningContainer from '../containers/warning_container'; +import { isMobile } from 'flavours/glitch/util/is_mobile'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { length } from 'stringz'; +import { countableText } from 'flavours/glitch/util/counter'; +import ComposeAttachOptions from './attach_options'; +import initialState from 'flavours/glitch/util/initial_state'; + +const maxChars = initialState.max_toot_chars; + +const messages = defineMessages({ + placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, + spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' }, + publish: { id: 'compose_form.publish', defaultMessage: 'Toot' }, + publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' }, +}); + +@injectIntl +export default class ComposeForm extends ImmutablePureComponent { + + static propTypes = { + intl: PropTypes.object.isRequired, + text: PropTypes.string.isRequired, + suggestion_token: PropTypes.string, + suggestions: ImmutablePropTypes.list, + spoiler: PropTypes.bool, + privacy: PropTypes.string, + advanced_options: ImmutablePropTypes.contains({ + do_not_federate: PropTypes.bool, + }), + spoiler_text: PropTypes.string, + focusDate: PropTypes.instanceOf(Date), + preselectDate: PropTypes.instanceOf(Date), + is_submitting: PropTypes.bool, + is_uploading: PropTypes.bool, + onChange: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + onClearSuggestions: PropTypes.func.isRequired, + onFetchSuggestions: PropTypes.func.isRequired, + onPrivacyChange: PropTypes.func.isRequired, + onSuggestionSelected: PropTypes.func.isRequired, + onChangeSpoilerText: PropTypes.func.isRequired, + onPaste: PropTypes.func.isRequired, + onPickEmoji: PropTypes.func.isRequired, + showSearch: PropTypes.bool, + settings : ImmutablePropTypes.map.isRequired, + }; + + static defaultProps = { + showSearch: false, + }; + + handleChange = (e) => { + this.props.onChange(e.target.value); + } + + handleKeyDown = (e) => { + if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + this.handleSubmit(); + } + } + + handleSubmit2 = () => { + this.props.onPrivacyChange(this.props.settings.get('side_arm')); + this.handleSubmit(); + } + + handleSubmit = () => { + if (this.props.text !== this.autosuggestTextarea.textarea.value) { + // Something changed the text inside the textarea (e.g. browser extensions like Grammarly) + // Update the state to match the current text + this.props.onChange(this.autosuggestTextarea.textarea.value); + } + + this.props.onSubmit(); + } + + onSuggestionsClearRequested = () => { + this.props.onClearSuggestions(); + } + + 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(); + } else if(prevProps.is_submitting && !this.props.is_submitting) { + this.autosuggestTextarea.textarea.focus(); + } + } + + setAutosuggestTextarea = (c) => { + this.autosuggestTextarea = c; + } + + handleEmojiPick = (data) => { + const position = this.autosuggestTextarea.textarea.selectionStart; + const emojiChar = data.native; + this._restoreCaret = position + emojiChar.length + 1; + this.props.onPickEmoji(position, data); + } + + render () { + const { intl, onPaste, showSearch } = this.props; + const disabled = this.props.is_submitting; + const maybeEye = (this.props.advanced_options && this.props.advanced_options.do_not_federate) ? ' 👁️' : ''; + const text = [this.props.spoiler_text, countableText(this.props.text), maybeEye].join(''); + + const secondaryVisibility = this.props.settings.get('side_arm'); + let showSideArm = secondaryVisibility !== 'none'; + + let publishText = ''; + let publishText2 = ''; + let title = ''; + let title2 = ''; + + const privacyIcons = { + none: '', + public: 'globe', + unlisted: 'unlock-alt', + private: 'lock', + direct: 'envelope', + }; + + title = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${this.props.privacy}.short` })}`; + + if (showSideArm) { + // Enhanced behavior with dual toot buttons + publishText = ( + + { + + }{intl.formatMessage(messages.publish)} + + ); + + title2 = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${secondaryVisibility}.short` })}`; + publishText2 = ( + + ); + } else { + // Original vanilla behavior - no icon if public or unlisted + if (this.props.privacy === 'private' || this.props.privacy === 'direct') { + publishText = {intl.formatMessage(messages.publish)}; + } else { + publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish); + } + } + + const submitDisabled = disabled || this.props.is_uploading || length(text) > maxChars || (text.length !== 0 && text.trim().length === 0); + + return ( +
+ +
+ +
+
+ + + + + +
+ + + +
+ +
+ +
+ +
+ + +
+ + + +
+ +
+
+
+ { + showSideArm ? +
+
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown.js b/app/javascript/flavours/glitch/features/compose/components/dropdown.js new file mode 100644 index 000000000..1b0000fb7 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.js @@ -0,0 +1,77 @@ +// Package imports. +import React from 'react'; +import PropTypes from 'prop-types'; + +// Our imports. +import IconButton from 'flavours/glitch/components/icon_button'; + +const iconStyle = { + height : null, + lineHeight : '27px', +}; + +export default class ComposeDropdown extends React.PureComponent { + + static propTypes = { + title: PropTypes.string.isRequired, + icon: PropTypes.string, + highlight: PropTypes.bool, + disabled: PropTypes.bool, + children: PropTypes.arrayOf(PropTypes.node).isRequired, + }; + + state = { + open: false, + }; + + 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); + } + + onToggleDropdown = () => { + if (this.props.disabled) return; + this.setState({ open: !this.state.open }); + }; + + setRef = (c) => { + this.node = c; + }; + + render () { + const { open } = this.state; + let { highlight, title, icon, disabled } = this.props; + + if (!icon) icon = 'ellipsis-h'; + + return ( +
+
+ +
+
+ {this.props.children} +
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js new file mode 100644 index 000000000..cf89f91d3 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js @@ -0,0 +1,376 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import { EmojiPicker as EmojiPickerAsync } from 'flavours/glitch/util/async-components'; +import Overlay from 'react-overlays/lib/Overlay'; +import classNames from 'classnames'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import detectPassiveEvents from 'detect-passive-events'; +import { buildCustomEmojis } from 'flavours/glitch/util/emoji'; + +const messages = defineMessages({ + emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, + emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' }, + emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emojos!! (╯°□°)╯︵ ┻━┻' }, + custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' }, + recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' }, + search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' }, + 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 assetHost = process.env.CDN_HOST || ''; +let EmojiPicker, Emoji; // load asynchronously + +const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`; +const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; + +const categoriesSort = [ + 'recent', + 'custom', + 'people', + 'nature', + 'foods', + 'activity', + 'places', + 'objects', + 'symbols', + 'flags', +]; + +class ModifierPickerMenu extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + onSelect: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + }; + + handleClick = e => { + this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.active) { + this.attachListeners(); + } else { + this.removeListeners(); + } + } + + componentWillUnmount () { + this.removeListeners(); + } + + handleDocumentClick = e => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); + } + } + + attachListeners () { + document.addEventListener('click', this.handleDocumentClick, false); + document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + removeListeners () { + document.removeEventListener('click', this.handleDocumentClick, false); + document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + setRef = c => { + this.node = c; + } + + render () { + const { active } = this.props; + + return ( +
+ + + + + + +
+ ); + } + +} + +class ModifierPicker extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + modifier: PropTypes.number, + onChange: PropTypes.func, + onClose: PropTypes.func, + onOpen: PropTypes.func, + }; + + handleClick = () => { + if (this.props.active) { + this.props.onClose(); + } else { + this.props.onOpen(); + } + } + + handleSelect = modifier => { + this.props.onChange(modifier); + this.props.onClose(); + } + + render () { + const { active, modifier } = this.props; + + return ( +
+ + +
+ ); + } + +} + +@injectIntl +class EmojiPickerMenu extends React.PureComponent { + + static propTypes = { + custom_emojis: ImmutablePropTypes.list, + frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string), + loading: PropTypes.bool, + onClose: PropTypes.func.isRequired, + onPick: PropTypes.func.isRequired, + style: PropTypes.object, + placement: PropTypes.string, + arrowOffsetLeft: PropTypes.string, + arrowOffsetTop: PropTypes.string, + intl: PropTypes.object.isRequired, + skinTone: PropTypes.number.isRequired, + onSkinTone: PropTypes.func.isRequired, + }; + + static defaultProps = { + style: {}, + loading: true, + placement: 'bottom', + frequentlyUsedEmojis: [], + }; + + state = { + modifierOpen: false, + }; + + handleDocumentClick = e => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); + } + } + + componentDidMount () { + document.addEventListener('click', this.handleDocumentClick, false); + document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + componentWillUnmount () { + document.removeEventListener('click', this.handleDocumentClick, false); + document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + setRef = c => { + this.node = c; + } + + getI18n = () => { + const { intl } = this.props; + + return { + search: intl.formatMessage(messages.emoji_search), + notfound: intl.formatMessage(messages.emoji_not_found), + categories: { + search: intl.formatMessage(messages.search_results), + recent: intl.formatMessage(messages.recent), + people: intl.formatMessage(messages.people), + nature: intl.formatMessage(messages.nature), + foods: intl.formatMessage(messages.food), + activity: intl.formatMessage(messages.activity), + places: intl.formatMessage(messages.travel), + objects: intl.formatMessage(messages.objects), + symbols: intl.formatMessage(messages.symbols), + flags: intl.formatMessage(messages.flags), + custom: intl.formatMessage(messages.custom), + }, + }; + } + + handleClick = emoji => { + if (!emoji.native) { + emoji.native = emoji.colons; + } + + this.props.onClose(); + this.props.onPick(emoji); + } + + handleModifierOpen = () => { + this.setState({ modifierOpen: true }); + } + + handleModifierClose = () => { + this.setState({ modifierOpen: false }); + } + + handleModifierChange = modifier => { + this.props.onSkinTone(modifier); + } + + render () { + const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props; + + if (loading) { + return
; + } + + const title = intl.formatMessage(messages.emoji); + const { modifierOpen } = this.state; + + return ( +
+ + + +
+ ); + } + +} + +@injectIntl +export default class EmojiPickerDropdown extends React.PureComponent { + + static propTypes = { + custom_emojis: ImmutablePropTypes.list, + frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string), + intl: PropTypes.object.isRequired, + onPickEmoji: PropTypes.func.isRequired, + onSkinTone: PropTypes.func.isRequired, + skinTone: PropTypes.number.isRequired, + }; + + state = { + active: false, + loading: false, + }; + + setRef = (c) => { + this.dropdown = c; + } + + onShowDropdown = () => { + this.setState({ active: true }); + + if (!EmojiPicker) { + this.setState({ loading: true }); + + EmojiPickerAsync().then(EmojiMart => { + EmojiPicker = EmojiMart.Picker; + Emoji = EmojiMart.Emoji; + + this.setState({ loading: false }); + }).catch(() => { + this.setState({ loading: false }); + }); + } + } + + onHideDropdown = () => { + this.setState({ active: false }); + } + + onToggle = (e) => { + if (!this.state.loading && (!e.key || e.key === 'Enter')) { + if (this.state.active) { + this.onHideDropdown(); + } else { + this.onShowDropdown(); + } + } + } + + handleKeyDown = e => { + if (e.key === 'Escape') { + this.onHideDropdown(); + } + } + + setTargetRef = c => { + this.target = c; + } + + findTarget = () => { + return this.target; + } + + render () { + const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props; + const title = intl.formatMessage(messages.emoji); + const { active, loading } = this.state; + + return ( +
+
+ 🙂 +
+ + + + +
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js new file mode 100644 index 000000000..1b6d74123 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Avatar from 'flavours/glitch/components/avatar'; +import IconButton from 'flavours/glitch/components/icon_button'; +import Permalink from 'flavours/glitch/components/permalink'; +import { FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +export default class NavigationBar extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + onClose: PropTypes.func.isRequired, + }; + + render () { + return ( +
+ + {this.props.account.get('acct')} + + + +
+ + @{this.props.account.get('acct')} + + + +
+ + +
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js new file mode 100644 index 000000000..90f062f8f --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js @@ -0,0 +1,200 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, defineMessages } from 'react-intl'; +import IconButton from 'flavours/glitch/components/icon_button'; +import Overlay from 'react-overlays/lib/Overlay'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import detectPassiveEvents from 'detect-passive-events'; +import classNames from 'classnames'; + +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 listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; + +class PrivacyDropdownMenu extends React.PureComponent { + + static propTypes = { + style: PropTypes.object, + items: PropTypes.array.isRequired, + value: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + }; + + handleDocumentClick = e => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); + } + } + + handleClick = e => { + if (e.key === 'Escape') { + this.props.onClose(); + } else if (!e.key || e.key === 'Enter') { + const value = e.currentTarget.getAttribute('data-index'); + + e.preventDefault(); + + this.props.onClose(); + this.props.onChange(value); + } + } + + componentDidMount () { + document.addEventListener('click', this.handleDocumentClick, false); + document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + componentWillUnmount () { + document.removeEventListener('click', this.handleDocumentClick, false); + document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + setRef = c => { + this.node = c; + } + + render () { + const { style, items, value } = this.props; + + return ( + + {({ opacity, scaleX, scaleY }) => ( +
+ {items.map(item => +
+
+ +
+ +
+ {item.text} + {item.meta} +
+
+ )} +
+ )} +
+ ); + } + +} + +@injectIntl +export default class PrivacyDropdown extends React.PureComponent { + + static propTypes = { + isUserTouching: PropTypes.func, + isModalOpen: PropTypes.bool.isRequired, + onModalOpen: PropTypes.func, + onModalClose: PropTypes.func, + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + open: false, + }; + + handleToggle = () => { + if (this.props.isUserTouching()) { + if (this.state.open) { + this.props.onModalClose(); + } else { + this.props.onModalOpen({ + actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })), + onClick: this.handleModalActionClick, + }); + } + } else { + this.setState({ open: !this.state.open }); + } + } + + handleModalActionClick = (e) => { + e.preventDefault(); + + const { value } = this.options[e.currentTarget.getAttribute('data-index')]; + + this.props.onModalClose(); + this.props.onChange(value); + } + + handleKeyDown = e => { + switch(e.key) { + case 'Enter': + this.handleToggle(); + break; + case 'Escape': + this.handleClose(); + break; + } + } + + handleClose = () => { + this.setState({ open: false }); + } + + handleChange = value => { + this.props.onChange(value); + } + + componentWillMount () { + const { intl: { formatMessage } } = this.props; + + this.options = [ + { icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) }, + { icon: 'unlock-alt', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) }, + { icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) }, + { icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) }, + ]; + } + + render () { + const { value, intl } = this.props; + const { open } = this.state; + + const valueOption = this.options.find(item => item.value === value); + + return ( +
+
+ +
+ + + + +
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js new file mode 100644 index 000000000..cb28e51be --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js @@ -0,0 +1,63 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Avatar from 'flavours/glitch/components/avatar'; +import IconButton from 'flavours/glitch/components/icon_button'; +import DisplayName from 'flavours/glitch/components/display_name'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }, +}); + +@injectIntl +export default class ReplyIndicator extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map, + onCancel: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleClick = () => { + this.props.onCancel(); + } + + handleAccountClick = (e) => { + if (e.button === 0) { + e.preventDefault(); + this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); + } + } + + render () { + const { status, intl } = this.props; + + if (!status) { + return null; + } + + const content = { __html: status.get('contentHtml') }; + + return ( +
+
+
+ + +
+ +
+
+ +
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/search.js b/app/javascript/flavours/glitch/features/compose/components/search.js new file mode 100644 index 000000000..1ce66b19d --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/search.js @@ -0,0 +1,129 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Overlay from 'react-overlays/lib/Overlay'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; + +const messages = defineMessages({ + placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, +}); + +class SearchPopout extends React.PureComponent { + + static propTypes = { + style: PropTypes.object, + }; + + render () { + const { style } = this.props; + + return ( +
+ + {({ opacity, scaleX, scaleY }) => ( +
+

+ +
    +
  • #example
  • +
  • @username@domain
  • +
  • URL
  • +
  • URL
  • +
+ + +
+ )} +
+
+ ); + } + +} + +@injectIntl +export default class Search extends React.PureComponent { + + static 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, + }; + + state = { + expanded: false, + }; + + 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(); + } else if (e.key === 'Escape') { + document.querySelector('.ui').parentElement.focus(); + } + } + + noop () { + + } + + handleFocus = () => { + this.setState({ expanded: true }); + this.props.onShow(); + } + + handleBlur = () => { + this.setState({ expanded: false }); + } + + render () { + const { intl, value, submitted } = this.props; + const { expanded } = this.state; + const hasValue = value.length > 0 || submitted; + + return ( +
+ + +
+ + +
+ + + + +
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/search_results.js b/app/javascript/flavours/glitch/features/compose/components/search_results.js new file mode 100644 index 000000000..2a4818d4e --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/search_results.js @@ -0,0 +1,65 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import StatusContainer from 'flavours/glitch/containers/status_container'; +import { Link } from 'react-router-dom'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +export default class SearchResults extends ImmutablePureComponent { + + static propTypes = { + results: ImmutablePropTypes.map.isRequired, + }; + + 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} +
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js new file mode 100644 index 000000000..9c8ffab1f --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default class TextIconButton extends React.PureComponent { + + static propTypes = { + label: PropTypes.string.isRequired, + title: PropTypes.string, + active: PropTypes.bool, + onClick: PropTypes.func.isRequired, + ariaControls: PropTypes.string, + }; + + handleClick = (e) => { + e.preventDefault(); + this.props.onClick(); + } + + render () { + const { label, title, active, ariaControls } = this.props; + + return ( + + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/upload.js b/app/javascript/flavours/glitch/features/compose/components/upload.js new file mode 100644 index 000000000..a1fc93234 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/upload.js @@ -0,0 +1,96 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import IconButton from 'flavours/glitch/components/icon_button'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { defineMessages, injectIntl } from 'react-intl'; +import classNames from 'classnames'; + +const messages = defineMessages({ + undo: { id: 'upload_form.undo', defaultMessage: 'Undo' }, + description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' }, +}); + +@injectIntl +export default class Upload extends ImmutablePureComponent { + + static propTypes = { + media: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + onUndo: PropTypes.func.isRequired, + onDescriptionChange: PropTypes.func.isRequired, + }; + + state = { + hovered: false, + focused: false, + dirtyDescription: null, + }; + + handleUndoClick = () => { + this.props.onUndo(this.props.media.get('id')); + } + + handleInputChange = e => { + this.setState({ dirtyDescription: e.target.value }); + } + + handleMouseEnter = () => { + this.setState({ hovered: true }); + } + + handleMouseLeave = () => { + this.setState({ hovered: false }); + } + + handleInputFocus = () => { + this.setState({ focused: true }); + } + + handleInputBlur = () => { + const { dirtyDescription } = this.state; + + this.setState({ focused: false, dirtyDescription: null }); + + if (dirtyDescription !== null) { + this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription); + } + } + + render () { + const { intl, media } = this.props; + const active = this.state.hovered || this.state.focused; + const description = this.state.dirtyDescription || media.get('description') || ''; + + return ( +
+ + {({ scale }) => ( +
+ + +
+ +
+
+ )} +
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_button.js b/app/javascript/flavours/glitch/features/compose/components/upload_button.js new file mode 100644 index 000000000..f06167a2a --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/upload_button.js @@ -0,0 +1,77 @@ +import React from 'react'; +import IconButton from 'flavours/glitch/components/icon_button'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +const messages = defineMessages({ + upload: { id: 'upload_button.label', defaultMessage: 'Add media' }, +}); + +const makeMapStateToProps = () => { + const mapStateToProps = state => ({ + acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']), + }); + + return mapStateToProps; +}; + +const iconStyle = { + height: null, + lineHeight: '27px', +}; + +@connect(makeMapStateToProps) +@injectIntl +export default class UploadButton extends ImmutablePureComponent { + + static propTypes = { + disabled: PropTypes.bool, + onSelectFile: PropTypes.func.isRequired, + style: PropTypes.object, + resetFileKey: PropTypes.number, + acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired, + intl: PropTypes.object.isRequired, + }; + + 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, acceptContentTypes } = this.props; + + return ( +
+ + +
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_form.js b/app/javascript/flavours/glitch/features/compose/components/upload_form.js new file mode 100644 index 000000000..b7f112205 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/upload_form.js @@ -0,0 +1,29 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import UploadProgressContainer from '../containers/upload_progress_container'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import UploadContainer from '../containers/upload_container'; + +export default class UploadForm extends ImmutablePureComponent { + + static propTypes = { + mediaIds: ImmutablePropTypes.list.isRequired, + }; + + render () { + const { mediaIds } = this.props; + + return ( +
+ + +
+ {mediaIds.map(id => ( + + ))} +
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_progress.js b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js new file mode 100644 index 000000000..2a3b8ceb4 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import { FormattedMessage } from 'react-intl'; + +export default class UploadProgress extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + progress: PropTypes.number, + }; + + render () { + const { active, progress } = this.props; + + if (!active) { + return null; + } + + return ( +
+
+ +
+ +
+ + +
+ + {({ width }) => +
+ } + +
+
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/warning.js b/app/javascript/flavours/glitch/features/compose/components/warning.js new file mode 100644 index 000000000..4962e76c8 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/warning.js @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; + +export default class Warning extends React.PureComponent { + + static propTypes = { + message: PropTypes.node.isRequired, + }; + + render () { + const { message } = this.props; + + return ( + + {({ opacity, scaleX, scaleY }) => ( +
+ {message} +
+ )} +
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/containers/advanced_options_container.js b/app/javascript/flavours/glitch/features/compose/containers/advanced_options_container.js new file mode 100644 index 000000000..da381568b --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/advanced_options_container.js @@ -0,0 +1,20 @@ +// Package imports. +import { connect } from 'react-redux'; + +// Our imports. +import { toggleComposeAdvancedOption } from 'flavours/glitch/actions/compose'; +import ComposeAdvancedOptions from '../components/advanced_options'; + +const mapStateToProps = state => ({ + values: state.getIn(['compose', 'advanced_options']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (option) { + dispatch(toggleComposeAdvancedOption(option)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ComposeAdvancedOptions); diff --git a/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js b/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js new file mode 100644 index 000000000..0e1c328fe --- /dev/null +++ b/app/javascript/flavours/glitch/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 'flavours/glitch/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/flavours/glitch/features/compose/containers/compose_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js new file mode 100644 index 000000000..e2e93e44b --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js @@ -0,0 +1,71 @@ +import { connect } from 'react-redux'; +import ComposeForm from '../components/compose_form'; +import { changeComposeVisibility, uploadCompose } from 'flavours/glitch/actions/compose'; +import { + changeCompose, + submitCompose, + clearComposeSuggestions, + fetchComposeSuggestions, + selectComposeSuggestion, + changeComposeSpoilerText, + insertEmojiCompose, +} from 'flavours/glitch/actions/compose'; + +const mapStateToProps = state => ({ + text: state.getIn(['compose', 'text']), + suggestion_token: state.getIn(['compose', 'suggestion_token']), + suggestions: state.getIn(['compose', 'suggestions']), + advanced_options: state.getIn(['compose', 'advanced_options']), + 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']), + showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), + settings: state.get('local_settings'), + filesAttached: state.getIn(['compose', 'media_attachments']).size > 0, +}); + +const mapDispatchToProps = (dispatch) => ({ + + onChange (text) { + dispatch(changeCompose(text)); + }, + + onPrivacyChange (value) { + dispatch(changeComposeVisibility(value)); + }, + + 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/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js b/app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js new file mode 100644 index 000000000..ba85edd87 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js @@ -0,0 +1,82 @@ +import { connect } from 'react-redux'; +import EmojiPickerDropdown from '../components/emoji_picker_dropdown'; +import { changeSetting } from 'flavours/glitch/actions/settings'; +import { createSelector } from 'reselect'; +import { Map as ImmutableMap } from 'immutable'; +import { useEmoji } from 'flavours/glitch/actions/emojis'; + +const perLine = 8; +const lines = 2; + +const DEFAULTS = [ + '+1', + 'grinning', + 'kissing_heart', + 'heart_eyes', + 'laughing', + 'stuck_out_tongue_winking_eye', + 'sweat_smile', + 'joy', + 'yum', + 'disappointed', + 'thinking_face', + 'weary', + 'sob', + 'sunglasses', + 'heart', + 'ok_hand', +]; + +const getFrequentlyUsedEmojis = createSelector([ + state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()), +], emojiCounters => { + let emojis = emojiCounters + .keySeq() + .sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b)) + .reverse() + .slice(0, perLine * lines) + .toArray(); + + if (emojis.length < DEFAULTS.length) { + emojis = emojis.concat(DEFAULTS.slice(0, DEFAULTS.length - emojis.length)); + } + + return emojis; +}); + +const getCustomEmojis = createSelector([ + state => state.get('custom_emojis'), +], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => { + const aShort = a.get('shortcode').toLowerCase(); + const bShort = b.get('shortcode').toLowerCase(); + + if (aShort < bShort) { + return -1; + } else if (aShort > bShort ) { + return 1; + } else { + return 0; + } +})); + +const mapStateToProps = state => ({ + custom_emojis: getCustomEmojis(state), + skinTone: state.getIn(['settings', 'skinTone']), + frequentlyUsedEmojis: getFrequentlyUsedEmojis(state), +}); + +const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({ + onSkinTone: skinTone => { + dispatch(changeSetting(['skinTone'], skinTone)); + }, + + onPickEmoji: emoji => { + dispatch(useEmoji(emoji)); + + if (onPickEmoji) { + onPickEmoji(emoji); + } + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown); diff --git a/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js b/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js new file mode 100644 index 000000000..eb630ffbb --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js @@ -0,0 +1,11 @@ +import { connect } from 'react-redux'; +import NavigationBar from '../components/navigation_bar'; +import { me } from 'flavours/glitch/util/initial_state'; + +const mapStateToProps = state => { + return { + account: state.getIn(['accounts', me]), + }; +}; + +export default connect(mapStateToProps)(NavigationBar); diff --git a/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js b/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js new file mode 100644 index 000000000..cb94fcc80 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import PrivacyDropdown from '../components/privacy_dropdown'; +import { changeComposeVisibility } from 'flavours/glitch/actions/compose'; +import { openModal, closeModal } from 'flavours/glitch/actions/modal'; +import { isUserTouching } from 'flavours/glitch/util/is_mobile'; + +const mapStateToProps = state => ({ + isModalOpen: state.get('modal').modalType === 'ACTIONS', + value: state.getIn(['compose', 'privacy']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (value) { + dispatch(changeComposeVisibility(value)); + }, + + isUserTouching, + onModalOpen: props => dispatch(openModal('ACTIONS', props)), + onModalClose: () => dispatch(closeModal()), + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown); diff --git a/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js b/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js new file mode 100644 index 000000000..a7c82d135 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { cancelReplyCompose } from 'flavours/glitch/actions/compose'; +import { makeGetStatus } from 'flavours/glitch/selectors'; +import ReplyIndicator from '../components/reply_indicator'; + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = state => ({ + 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/flavours/glitch/features/compose/containers/search_container.js b/app/javascript/flavours/glitch/features/compose/containers/search_container.js new file mode 100644 index 000000000..8f4bfcf08 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/search_container.js @@ -0,0 +1,35 @@ +import { connect } from 'react-redux'; +import { + changeSearch, + clearSearch, + submitSearch, + showSearch, +} from 'flavours/glitch/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/flavours/glitch/features/compose/containers/search_results_container.js b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js new file mode 100644 index 000000000..16d95d417 --- /dev/null +++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/compose/containers/sensitive_button_container.js b/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js new file mode 100644 index 000000000..cf6706c0e --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js @@ -0,0 +1,71 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import IconButton from 'flavours/glitch/components/icon_button'; +import { changeComposeSensitivity } from 'flavours/glitch/actions/compose'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +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']), + disabled: state.getIn(['compose', 'spoiler']), +}); + +const mapDispatchToProps = dispatch => ({ + + onClick () { + dispatch(changeComposeSensitivity()); + }, + +}); + +class SensitiveButton extends React.PureComponent { + + static propTypes = { + visible: PropTypes.bool, + active: PropTypes.bool, + disabled: PropTypes.bool, + onClick: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render () { + const { visible, active, disabled, onClick, intl } = this.props; + + return ( + + {({ scale }) => { + const icon = active ? 'eye-slash' : 'eye'; + const className = classNames('compose-form__sensitive-button', { + 'compose-form__sensitive-button--visible': visible, + }); + return ( +
+ +
+ ); + }} +
+ ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton)); diff --git a/app/javascript/flavours/glitch/features/compose/containers/spoiler_button_container.js b/app/javascript/flavours/glitch/features/compose/containers/spoiler_button_container.js new file mode 100644 index 000000000..d7b4246bc --- /dev/null +++ b/app/javascript/flavours/glitch/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 'flavours/glitch/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/flavours/glitch/features/compose/containers/upload_button_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_button_container.js new file mode 100644 index 000000000..4c1cb49e9 --- /dev/null +++ b/app/javascript/flavours/glitch/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 'flavours/glitch/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/flavours/glitch/features/compose/containers/upload_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js new file mode 100644 index 000000000..368038425 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import Upload from '../components/upload'; +import { undoUploadCompose, changeUploadCompose } from 'flavours/glitch/actions/compose'; + +const mapStateToProps = (state, { id }) => ({ + media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), +}); + +const mapDispatchToProps = dispatch => ({ + + onUndo: id => { + dispatch(undoUploadCompose(id)); + }, + + onDescriptionChange: (id, description) => { + dispatch(changeUploadCompose(id, description)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Upload); diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js new file mode 100644 index 000000000..a6798bf51 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import UploadForm from '../components/upload_form'; + +const mapStateToProps = state => ({ + mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')), +}); + +export default connect(mapStateToProps)(UploadForm); diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js new file mode 100644 index 000000000..0cfee96da --- /dev/null +++ b/app/javascript/flavours/glitch/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 => ({ + active: state.getIn(['compose', 'is_uploading']), + progress: state.getIn(['compose', 'progress']), +}); + +export default connect(mapStateToProps)(UploadProgress); diff --git a/app/javascript/flavours/glitch/features/compose/containers/warning_container.js b/app/javascript/flavours/glitch/features/compose/containers/warning_container.js new file mode 100644 index 000000000..f20c75b91 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/warning_container.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import Warning from '../components/warning'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { me } from 'flavours/glitch/util/initial_state'; + +const mapStateToProps = state => ({ + needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']), +}); + +const WarningWrapper = ({ needsLockWarning }) => { + if (needsLockWarning) { + return }} />} />; + } + + return null; +}; + +WarningWrapper.propTypes = { + needsLockWarning: PropTypes.bool, +}; + +export default connect(mapStateToProps)(WarningWrapper); diff --git a/app/javascript/flavours/glitch/features/compose/index.js b/app/javascript/flavours/glitch/features/compose/index.js new file mode 100644 index 000000000..02b97ad00 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/index.js @@ -0,0 +1,126 @@ +import React from 'react'; +import ComposeFormContainer from './containers/compose_form_container'; +import NavigationContainer from './containers/navigation_container'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; +import { mountCompose, unmountCompose } from 'flavours/glitch/actions/compose'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; +import { Link } from 'react-router-dom'; +import { injectIntl, defineMessages } from 'react-intl'; +import SearchContainer from './containers/search_container'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import SearchResultsContainer from './containers/search_results_container'; +import { changeComposing } from 'flavours/glitch/actions/compose'; + +const messages = defineMessages({ + start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, + home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' }, + notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' }, + public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' }, + community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, + settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, + logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, +}); + +const mapStateToProps = state => ({ + columns: state.getIn(['settings', 'columns']), + showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class Compose extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + columns: ImmutablePropTypes.list.isRequired, + multiColumn: PropTypes.bool, + showSearch: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + componentDidMount () { + this.props.dispatch(mountCompose()); + } + + componentWillUnmount () { + this.props.dispatch(unmountCompose()); + } + + onLayoutClick = (e) => { + const layout = e.currentTarget.getAttribute('data-mastodon-layout'); + this.props.dispatch(changeLocalSetting(['layout'], layout)); + e.preventDefault(); + } + + openSettings = () => { + this.props.dispatch(openModal('SETTINGS', {})); + } + + onFocus = () => { + this.props.dispatch(changeComposing(true)); + } + + onBlur = () => { + this.props.dispatch(changeComposing(false)); + } + + render () { + const { multiColumn, showSearch, intl } = this.props; + + let header = ''; + + if (multiColumn) { + const { columns } = this.props; + header = ( + + ); + } + + + + return ( +
+ {header} + + + +
+
+ + +
+ + + {({ x }) => +
+ +
+ } +
+
+ +
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/direct_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/direct_timeline/containers/column_settings_container.js new file mode 100644 index 000000000..d3e4b4216 --- /dev/null +++ b/app/javascript/flavours/glitch/features/direct_timeline/containers/column_settings_container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import ColumnSettings from 'flavours/glitch/features/community_timeline/components/column_settings'; +import { changeSetting } from 'flavours/glitch/actions/settings'; + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'direct']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (key, checked) { + dispatch(changeSetting(['direct', ...key], checked)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/flavours/glitch/features/direct_timeline/index.js b/app/javascript/flavours/glitch/features/direct_timeline/index.js new file mode 100644 index 000000000..81096c0ec --- /dev/null +++ b/app/javascript/flavours/glitch/features/direct_timeline/index.js @@ -0,0 +1,107 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; +import Column from 'flavours/glitch/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import { + refreshDirectTimeline, + expandDirectTimeline, +} from 'flavours/glitch/actions/timelines'; +import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import { connectDirectStream } from 'flavours/glitch/actions/streaming'; + +const messages = defineMessages({ + title: { id: 'column.direct', defaultMessage: 'Direct messages' }, +}); + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0, +}); + +@connect(mapStateToProps) +@injectIntl +export default class DirectTimeline extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + columnId: PropTypes.string, + intl: PropTypes.object.isRequired, + hasUnread: PropTypes.bool, + multiColumn: PropTypes.bool, + }; + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('DIRECT', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + componentDidMount () { + const { dispatch } = this.props; + + dispatch(refreshDirectTimeline()); + this.disconnect = dispatch(connectDirectStream()); + } + + componentWillUnmount () { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = () => { + this.props.dispatch(expandDirectTimeline()); + } + + render () { + const { intl, hasUnread, columnId, multiColumn } = this.props; + const pinned = !!columnId; + + return ( + + + + + + } + /> + + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/favourited_statuses/index.js b/app/javascript/flavours/glitch/features/favourited_statuses/index.js new file mode 100644 index 000000000..e20dda718 --- /dev/null +++ b/app/javascript/flavours/glitch/features/favourited_statuses/index.js @@ -0,0 +1,94 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'flavours/glitch/actions/favourites'; +import Column from 'flavours/glitch/features/ui/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; +import StatusList from 'flavours/glitch/components/status_list'; +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']), + hasMore: !!state.getIn(['status_lists', 'favourites', 'next']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class Favourites extends ImmutablePureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + intl: PropTypes.object.isRequired, + columnId: PropTypes.string, + multiColumn: PropTypes.bool, + hasMore: PropTypes.bool, + }; + + componentWillMount () { + this.props.dispatch(fetchFavouritedStatuses()); + } + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('FAVOURITES', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + handleScrollToBottom = () => { + this.props.dispatch(expandFavouritedStatuses()); + } + + render () { + const { intl, statusIds, columnId, multiColumn, hasMore } = this.props; + const pinned = !!columnId; + + return ( + + + + + + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/favourites/index.js b/app/javascript/flavours/glitch/features/favourites/index.js new file mode 100644 index 000000000..055a15ccb --- /dev/null +++ b/app/javascript/flavours/glitch/features/favourites/index.js @@ -0,0 +1,60 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; +import { fetchFavourites } from 'flavours/glitch/actions/interactions'; +import { ScrollContainer } from 'react-router-scroll-4'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import Column from 'flavours/glitch/features/ui/components/column'; +import ColumnBackButton from 'flavours/glitch/components/column_back_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]), +}); + +@connect(mapStateToProps) +export default class Favourites extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + }; + + componentWillMount () { + this.props.dispatch(fetchFavourites(this.props.params.statusId)); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) { + this.props.dispatch(fetchFavourites(nextProps.params.statusId)); + } + } + + render () { + const { accountIds } = this.props; + + if (!accountIds) { + return ( + + + + ); + } + + return ( + + + + +
+ {accountIds.map(id => )} +
+
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.js b/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.js new file mode 100644 index 000000000..dead0753f --- /dev/null +++ b/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.js @@ -0,0 +1,49 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Permalink from 'flavours/glitch/components/permalink'; +import Avatar from 'flavours/glitch/components/avatar'; +import DisplayName from 'flavours/glitch/components/display_name'; +import IconButton from 'flavours/glitch/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' }, +}); + +@injectIntl +export default class AccountAuthorize extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + onAuthorize: PropTypes.func.isRequired, + onReject: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render () { + const { intl, account, onAuthorize, onReject } = this.props; + const content = { __html: account.get('note_emojified') }; + + return ( +
+
+ +
+ +
+ +
+
+ +
+
+
+
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/follow_requests/containers/account_authorize_container.js b/app/javascript/flavours/glitch/features/follow_requests/containers/account_authorize_container.js new file mode 100644 index 000000000..693e98e8c --- /dev/null +++ b/app/javascript/flavours/glitch/features/follow_requests/containers/account_authorize_container.js @@ -0,0 +1,26 @@ +import { connect } from 'react-redux'; +import { makeGetAccount } from 'flavours/glitch/selectors'; +import AccountAuthorize from '../components/account_authorize'; +import { authorizeFollowRequest, rejectFollowRequest } from 'flavours/glitch/actions/accounts'; + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, props) => ({ + account: getAccount(state, props.id), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { id }) => ({ + onAuthorize () { + dispatch(authorizeFollowRequest(id)); + }, + + onReject () { + dispatch(rejectFollowRequest(id)); + }, +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(AccountAuthorize); diff --git a/app/javascript/flavours/glitch/features/follow_requests/index.js b/app/javascript/flavours/glitch/features/follow_requests/index.js new file mode 100644 index 000000000..04ff3f111 --- /dev/null +++ b/app/javascript/flavours/glitch/features/follow_requests/index.js @@ -0,0 +1,71 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; +import { ScrollContainer } from 'react-router-scroll-4'; +import Column from 'flavours/glitch/features/ui/components/column'; +import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim'; +import AccountAuthorizeContainer from './containers/account_authorize_container'; +import { fetchFollowRequests, expandFollowRequests } from 'flavours/glitch/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']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class FollowRequests extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired, + }; + + 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 => + + )} +
+
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/followers/index.js b/app/javascript/flavours/glitch/features/followers/index.js new file mode 100644 index 000000000..f0ef29ff6 --- /dev/null +++ b/app/javascript/flavours/glitch/features/followers/index.js @@ -0,0 +1,93 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; +import { + fetchAccount, + fetchFollowers, + expandFollowers, +} from 'flavours/glitch/actions/accounts'; +import { ScrollContainer } from 'react-router-scroll-4'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import Column from 'flavours/glitch/features/ui/components/column'; +import HeaderContainer from 'flavours/glitch/features/account_timeline/containers/header_container'; +import LoadMore from 'flavours/glitch/components/load_more'; +import ColumnBackButton from 'flavours/glitch/components/column_back_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']), + hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']), +}); + +@connect(mapStateToProps) +export default class Followers extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + hasMore: PropTypes.bool, + }; + + componentWillMount () { + this.props.dispatch(fetchAccount(this.props.params.accountId)); + this.props.dispatch(fetchFollowers(this.props.params.accountId)); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { + this.props.dispatch(fetchAccount(nextProps.params.accountId)); + this.props.dispatch(fetchFollowers(nextProps.params.accountId)); + } + } + + handleScroll = (e) => { + const { scrollTop, scrollHeight, clientHeight } = e.target; + + if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) { + this.props.dispatch(expandFollowers(this.props.params.accountId)); + } + } + + handleLoadMore = (e) => { + e.preventDefault(); + this.props.dispatch(expandFollowers(this.props.params.accountId)); + } + + render () { + const { accountIds, hasMore } = this.props; + + let loadMore = null; + + if (!accountIds) { + return ( + + + + ); + } + + if (hasMore) { + loadMore = ; + } + + return ( + + + + +
+
+ + {accountIds.map(id => )} + {loadMore} +
+
+
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/following/index.js b/app/javascript/flavours/glitch/features/following/index.js new file mode 100644 index 000000000..f30f7b0d9 --- /dev/null +++ b/app/javascript/flavours/glitch/features/following/index.js @@ -0,0 +1,93 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; +import { + fetchAccount, + fetchFollowing, + expandFollowing, +} from 'flavours/glitch/actions/accounts'; +import { ScrollContainer } from 'react-router-scroll-4'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import Column from 'flavours/glitch/features/ui/components/column'; +import HeaderContainer from 'flavours/glitch/features/account_timeline/containers/header_container'; +import LoadMore from 'flavours/glitch/components/load_more'; +import ColumnBackButton from 'flavours/glitch/components/column_back_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']), + hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']), +}); + +@connect(mapStateToProps) +export default class Following extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + hasMore: PropTypes.bool, + }; + + componentWillMount () { + this.props.dispatch(fetchAccount(this.props.params.accountId)); + this.props.dispatch(fetchFollowing(this.props.params.accountId)); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { + this.props.dispatch(fetchAccount(nextProps.params.accountId)); + this.props.dispatch(fetchFollowing(nextProps.params.accountId)); + } + } + + handleScroll = (e) => { + const { scrollTop, scrollHeight, clientHeight } = e.target; + + if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) { + this.props.dispatch(expandFollowing(this.props.params.accountId)); + } + } + + handleLoadMore = (e) => { + e.preventDefault(); + this.props.dispatch(expandFollowing(this.props.params.accountId)); + } + + render () { + const { accountIds, hasMore } = this.props; + + let loadMore = null; + + if (!accountIds) { + return ( + + + + ); + } + + if (hasMore) { + loadMore = ; + } + + return ( + + + + +
+
+ + {accountIds.map(id => )} + {loadMore} +
+
+
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/generic_not_found/index.js b/app/javascript/flavours/glitch/features/generic_not_found/index.js new file mode 100644 index 000000000..d01a1ba47 --- /dev/null +++ b/app/javascript/flavours/glitch/features/generic_not_found/index.js @@ -0,0 +1,11 @@ +import React from 'react'; +import Column from 'flavours/glitch/features/ui/components/column'; +import MissingIndicator from 'flavours/glitch/components/missing_indicator'; + +const GenericNotFound = () => ( + + + +); + +export default GenericNotFound; diff --git a/app/javascript/flavours/glitch/features/getting_started/index.js b/app/javascript/flavours/glitch/features/getting_started/index.js new file mode 100644 index 000000000..2af0fcf48 --- /dev/null +++ b/app/javascript/flavours/glitch/features/getting_started/index.js @@ -0,0 +1,145 @@ +import React from 'react'; +import Column from 'flavours/glitch/features/ui/components/column'; +import ColumnLink from 'flavours/glitch/features/ui/components/column_link'; +import ColumnSubheading from 'flavours/glitch/features/ui/components/column_subheading'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { connect } from 'react-redux'; +import { openModal } from 'flavours/glitch/actions/modal'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { me } from 'flavours/glitch/util/initial_state'; + +const messages = defineMessages({ + heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, + home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' }, + notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' }, + 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' }, + direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' }, + preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, + 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' }, + show_me_around: { id: 'getting_started.onboarding', defaultMessage: 'Show me around' }, + pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' }, +}); + +const mapStateToProps = state => ({ + myAccount: state.getIn(['accounts', me]), + columns: state.getIn(['settings', 'columns']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class GettingStarted extends ImmutablePureComponent { + + static propTypes = { + intl: PropTypes.object.isRequired, + myAccount: ImmutablePropTypes.map.isRequired, + columns: ImmutablePropTypes.list, + multiColumn: PropTypes.bool, + dispatch: PropTypes.func.isRequired, + }; + + openSettings = () => { + this.props.dispatch(openModal('SETTINGS', {})); + } + + openOnboardingModal = (e) => { + e.preventDefault(); + this.props.dispatch(openModal('ONBOARDING')); + } + + render () { + const { intl, myAccount, columns, multiColumn } = this.props; + + let navItems = []; + + if (multiColumn) { + if (!columns.find(item => item.get('id') === 'HOME')) { + navItems.push(); + } + + if (!columns.find(item => item.get('id') === 'NOTIFICATIONS')) { + navItems.push(); + } + + if (!columns.find(item => item.get('id') === 'COMMUNITY')) { + navItems.push(); + } + + if (!columns.find(item => item.get('id') === 'PUBLIC')) { + navItems.push(); + } + } + + if (!multiColumn || !columns.find(item => item.get('id') === 'DIRECT')) { + navItems.push(); + } + + navItems = navItems.concat([ + , + , + ]); + + if (myAccount.get('locked')) { + navItems.push(); + } + + navItems = navItems.concat([ + , + , + ]); + + return ( + +
+
+ + {navItems} + + + + + + +
+ +
+
+

+ + +  •  + + +  •  + + + +

+

+ glitch-soc/mastodon, + Mastodon: Mastodon, + }} + /> +

+
+
+
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/index.js b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js new file mode 100644 index 000000000..9f3c9bec7 --- /dev/null +++ b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js @@ -0,0 +1,118 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; +import Column from 'flavours/glitch/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import { + refreshHashtagTimeline, + expandHashtagTimeline, +} from 'flavours/glitch/actions/timelines'; +import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; +import { FormattedMessage } from 'react-intl'; +import { connectHashtagStream } from 'flavours/glitch/actions/streaming'; + +const mapStateToProps = (state, props) => ({ + hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0, +}); + +@connect(mapStateToProps) +export default class HashtagTimeline extends React.PureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + columnId: PropTypes.string, + dispatch: PropTypes.func.isRequired, + hasUnread: PropTypes.bool, + multiColumn: PropTypes.bool, + }; + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('HASHTAG', { id: this.props.params.id })); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + _subscribe (dispatch, id) { + this.disconnect = dispatch(connectHashtagStream(id)); + } + + _unsubscribe () { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + componentDidMount () { + const { dispatch } = this.props; + const { id } = this.props.params; + + dispatch(refreshHashtagTimeline(id)); + this._subscribe(dispatch, id); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.params.id !== this.props.params.id) { + this.props.dispatch(refreshHashtagTimeline(nextProps.params.id)); + this._unsubscribe(); + this._subscribe(this.props.dispatch, nextProps.params.id); + } + } + + componentWillUnmount () { + this._unsubscribe(); + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = () => { + this.props.dispatch(expandHashtagTimeline(this.props.params.id)); + } + + render () { + const { hasUnread, columnId, multiColumn } = this.props; + const { id } = this.props.params; + const pinned = !!columnId; + + return ( + + + + } + /> + + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/home_timeline/components/column_settings.js b/app/javascript/flavours/glitch/features/home_timeline/components/column_settings.js new file mode 100644 index 000000000..604a7c6dc --- /dev/null +++ b/app/javascript/flavours/glitch/features/home_timeline/components/column_settings.js @@ -0,0 +1,46 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import SettingToggle from 'flavours/glitch/features/notifications/components/setting_toggle'; +import SettingText from 'flavours/glitch/components/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' }, +}); + +@injectIntl +export default class ColumnSettings extends React.PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + onChange: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render () { + const { settings, onChange, intl } = this.props; + + return ( +
+ + +
+ } /> +
+ +
+ } /> +
+ + + +
+ +
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/home_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/home_timeline/containers/column_settings_container.js new file mode 100644 index 000000000..19a8e792f --- /dev/null +++ b/app/javascript/flavours/glitch/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 'flavours/glitch/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/flavours/glitch/features/home_timeline/index.js b/app/javascript/flavours/glitch/features/home_timeline/index.js new file mode 100644 index 000000000..2dfec6bbe --- /dev/null +++ b/app/javascript/flavours/glitch/features/home_timeline/index.js @@ -0,0 +1,90 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { expandHomeTimeline } from 'flavours/glitch/actions/timelines'; +import PropTypes from 'prop-types'; +import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; +import Column from 'flavours/glitch/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import { Link } from 'react-router-dom'; + +const messages = defineMessages({ + title: { id: 'column.home', defaultMessage: 'Home' }, +}); + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0, +}); + +@connect(mapStateToProps) +@injectIntl +export default class HomeTimeline extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + hasUnread: PropTypes.bool, + columnId: PropTypes.string, + multiColumn: PropTypes.bool, + }; + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('HOME', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = () => { + this.props.dispatch(expandHomeTimeline()); + } + + render () { + const { intl, hasUnread, columnId, multiColumn } = this.props; + const pinned = !!columnId; + + return ( + + + + + + }} />} + /> + + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/local_settings/index.js b/app/javascript/flavours/glitch/features/local_settings/index.js new file mode 100644 index 000000000..81d9a3f41 --- /dev/null +++ b/app/javascript/flavours/glitch/features/local_settings/index.js @@ -0,0 +1,68 @@ +// Package imports. +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +// Our imports +import LocalSettingsPage from './page'; +import LocalSettingsNavigation from './navigation'; +import { closeModal } from 'flavours/glitch/actions/modal'; +import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; + +// Stylesheet imports +import './style.scss'; + +const mapStateToProps = state => ({ + settings: state.get('local_settings'), +}); + +const mapDispatchToProps = dispatch => ({ + onChange (setting, value) { + dispatch(changeLocalSetting(setting, value)); + }, + onClose () { + dispatch(closeModal()); + }, +}); + +class LocalSettings extends React.PureComponent { + + static propTypes = { + onChange: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + settings: ImmutablePropTypes.map.isRequired, + }; + + state = { + currentIndex: 0, + }; + + navigateTo = (index) => + this.setState({ currentIndex: +index }); + + render () { + + const { navigateTo } = this; + const { onChange, onClose, settings } = this.props; + const { currentIndex } = this.state; + + return ( +
+ + +
+ ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(LocalSettings); diff --git a/app/javascript/flavours/glitch/features/local_settings/navigation/index.js b/app/javascript/flavours/glitch/features/local_settings/navigation/index.js new file mode 100644 index 000000000..fa35e83c7 --- /dev/null +++ b/app/javascript/flavours/glitch/features/local_settings/navigation/index.js @@ -0,0 +1,74 @@ +// Package imports +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, defineMessages } from 'react-intl'; + +// Our imports +import LocalSettingsNavigationItem from './item'; + +// Stylesheet imports +import './style.scss'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +const messages = defineMessages({ + general: { id: 'settings.general', defaultMessage: 'General' }, + collapsed: { id: 'settings.collapsed_statuses', defaultMessage: 'Collapsed toots' }, + media: { id: 'settings.media', defaultMessage: 'Media' }, + preferences: { id: 'settings.preferences', defaultMessage: 'Preferences' }, + close: { id: 'settings.close', defaultMessage: 'Close' }, +}); + +@injectIntl +export default class LocalSettingsNavigation extends React.PureComponent { + + static propTypes = { + index : PropTypes.number, + intl : PropTypes.object.isRequired, + onClose : PropTypes.func.isRequired, + onNavigate : PropTypes.func.isRequired, + }; + + render () { + + const { index, intl, onClose, onNavigate } = this.props; + + return ( + + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/local_settings/navigation/item/index.js b/app/javascript/flavours/glitch/features/local_settings/navigation/item/index.js new file mode 100644 index 000000000..a352d5fb2 --- /dev/null +++ b/app/javascript/flavours/glitch/features/local_settings/navigation/item/index.js @@ -0,0 +1,69 @@ +// Package imports +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +// Stylesheet imports +import './style.scss'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +export default class LocalSettingsPage extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + className: PropTypes.string, + href: PropTypes.string, + icon: PropTypes.string, + index: PropTypes.number.isRequired, + onNavigate: PropTypes.func, + title: PropTypes.string, + }; + + handleClick = (e) => { + const { index, onNavigate } = this.props; + if (onNavigate) { + onNavigate(index); + e.preventDefault(); + } + } + + render () { + const { handleClick } = this; + const { + active, + className, + href, + icon, + onNavigate, + title, + } = this.props; + + const finalClassName = classNames('glitch', 'local-settings__navigation__item', { + active, + }, className); + + const iconElem = icon ? : null; + + if (href) return ( + + {iconElem} {title} + + ); + else if (onNavigate) return ( + + {iconElem} {title} + + ); + else return null; + } + +} diff --git a/app/javascript/flavours/glitch/features/local_settings/navigation/item/style.scss b/app/javascript/flavours/glitch/features/local_settings/navigation/item/style.scss new file mode 100644 index 000000000..7f7371993 --- /dev/null +++ b/app/javascript/flavours/glitch/features/local_settings/navigation/item/style.scss @@ -0,0 +1,27 @@ +@import 'styles/mastodon/variables'; + +.glitch.local-settings__navigation__item { + display: block; + padding: 15px 20px; + color: inherit; + background: $primary-text-color; + border-bottom: 1px $ui-primary-color solid; + cursor: pointer; + text-decoration: none; + outline: none; + transition: background .3s; + + &:hover { + background: $ui-secondary-color; + } + + &.active { + background: $ui-highlight-color; + color: $primary-text-color; + } + + &.close, &.close:hover { + background: $error-value-color; + color: $primary-text-color; + } +} diff --git a/app/javascript/flavours/glitch/features/local_settings/navigation/style.scss b/app/javascript/flavours/glitch/features/local_settings/navigation/style.scss new file mode 100644 index 000000000..0336f943b --- /dev/null +++ b/app/javascript/flavours/glitch/features/local_settings/navigation/style.scss @@ -0,0 +1,10 @@ +@import 'styles/mastodon/variables'; + +.glitch.local-settings__navigation { + background: $primary-text-color; + color: $ui-base-color; + width: 200px; + font-size: 15px; + line-height: 20px; + overflow-y: auto; +} diff --git a/app/javascript/flavours/glitch/features/local_settings/page/index.js b/app/javascript/flavours/glitch/features/local_settings/page/index.js new file mode 100644 index 000000000..498230f7b --- /dev/null +++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js @@ -0,0 +1,212 @@ +// Package imports +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; + +// Our imports +import LocalSettingsPageItem from './item'; + +// Stylesheet imports +import './style.scss'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +const messages = defineMessages({ + layout_auto: { id: 'layout.auto', defaultMessage: 'Auto' }, + layout_desktop: { id: 'layout.desktop', defaultMessage: 'Desktop' }, + layout_mobile: { id: 'layout.single', defaultMessage: 'Mobile' }, + side_arm_none: { id: 'settings.side_arm.none', defaultMessage: 'None' }, +}); + +@injectIntl +export default class LocalSettingsPage extends React.PureComponent { + + static propTypes = { + index : PropTypes.number, + intl : PropTypes.object.isRequired, + onChange : PropTypes.func.isRequired, + settings : ImmutablePropTypes.map.isRequired, + }; + + pages = [ + ({ intl, onChange, settings }) => ( +
+

+ + + + + + + + + +
+

+ + + +
+
+ ), + ({ onChange, settings }) => ( +
+

+ + + +
+

+ + + + + + + + + + + + + + + + + + +
+
+

+ + + + + + +
+
+ ), + ({ onChange, settings }) => ( +
+

+ + + + + + +
+ ), + ]; + + render () { + const { pages } = this; + const { index, intl, onChange, settings } = this.props; + const CurrentPage = pages[index] || pages[0]; + + return ; + } + +} diff --git a/app/javascript/flavours/glitch/features/local_settings/page/item/index.js b/app/javascript/flavours/glitch/features/local_settings/page/item/index.js new file mode 100644 index 000000000..37e28c084 --- /dev/null +++ b/app/javascript/flavours/glitch/features/local_settings/page/item/index.js @@ -0,0 +1,90 @@ +// Package imports +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +// Stylesheet imports +import './style.scss'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +export default class LocalSettingsPageItem extends React.PureComponent { + + static propTypes = { + children: PropTypes.element.isRequired, + dependsOn: PropTypes.array, + dependsOnNot: PropTypes.array, + id: PropTypes.string.isRequired, + item: PropTypes.array.isRequired, + onChange: PropTypes.func.isRequired, + options: PropTypes.arrayOf(PropTypes.shape({ + value: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + })), + settings: ImmutablePropTypes.map.isRequired, + }; + + handleChange = e => { + const { target } = e; + const { item, onChange, options } = this.props; + if (options && options.length > 0) onChange(item, target.value); + else onChange(item, target.checked); + } + + render () { + const { handleChange } = this; + const { settings, item, id, options, children, dependsOn, dependsOnNot } = this.props; + let enabled = true; + + if (dependsOn) { + for (let i = 0; i < dependsOn.length; i++) { + enabled = enabled && settings.getIn(dependsOn[i]); + } + } + if (dependsOnNot) { + for (let i = 0; i < dependsOnNot.length; i++) { + enabled = enabled && !settings.getIn(dependsOnNot[i]); + } + } + + if (options && options.length > 0) { + const currentValue = settings.getIn(item); + const optionElems = options && options.length > 0 && options.map((opt) => ( + + )); + return ( + + ); + } else return ( + + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/local_settings/page/item/style.scss b/app/javascript/flavours/glitch/features/local_settings/page/item/style.scss new file mode 100644 index 000000000..b2d8f7185 --- /dev/null +++ b/app/javascript/flavours/glitch/features/local_settings/page/item/style.scss @@ -0,0 +1,7 @@ +@import 'styles/mastodon/variables'; + +.glitch.local-settings__page__item { + select { + margin-bottom: 5px; + } +} diff --git a/app/javascript/flavours/glitch/features/local_settings/page/style.scss b/app/javascript/flavours/glitch/features/local_settings/page/style.scss new file mode 100644 index 000000000..e9eedcad0 --- /dev/null +++ b/app/javascript/flavours/glitch/features/local_settings/page/style.scss @@ -0,0 +1,9 @@ +@import 'styles/mastodon/variables'; + +.glitch.local-settings__page { + display: block; + flex: auto; + padding: 15px 20px 15px 20px; + width: 360px; + overflow-y: auto; +} diff --git a/app/javascript/flavours/glitch/features/local_settings/style.scss b/app/javascript/flavours/glitch/features/local_settings/style.scss new file mode 100644 index 000000000..765294607 --- /dev/null +++ b/app/javascript/flavours/glitch/features/local_settings/style.scss @@ -0,0 +1,34 @@ +@import 'styles/mastodon/variables'; + +.glitch.local-settings { + position: relative; + display: flex; + flex-direction: row; + background: $ui-secondary-color; + color: $ui-base-color; + border-radius: 8px; + height: 80vh; + width: 80vw; + max-width: 740px; + max-height: 450px; + overflow: hidden; + + label { + display: block; + } + + h1 { + font-size: 18px; + font-weight: 500; + line-height: 24px; + margin-bottom: 20px; + } + + h2 { + font-size: 15px; + font-weight: 500; + line-height: 20px; + margin-top: 20px; + margin-bottom: 10px; + } +} diff --git a/app/javascript/flavours/glitch/features/mutes/index.js b/app/javascript/flavours/glitch/features/mutes/index.js new file mode 100644 index 000000000..87517eec9 --- /dev/null +++ b/app/javascript/flavours/glitch/features/mutes/index.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; +import { ScrollContainer } from 'react-router-scroll-4'; +import Column from 'flavours/glitch/features/ui/components/column'; +import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import { fetchMutes, expandMutes } from 'flavours/glitch/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']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class Mutes extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired, + }; + + 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 => + + )} +
+
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/clear_column_button.js b/app/javascript/flavours/glitch/features/notifications/components/clear_column_button.js new file mode 100644 index 000000000..22a10753f --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/clear_column_button.js @@ -0,0 +1,17 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +export default class ClearColumnButton extends React.Component { + + static propTypes = { + onClick: PropTypes.func.isRequired, + }; + + render () { + return ( + + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js new file mode 100644 index 000000000..88a29d4d3 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js @@ -0,0 +1,86 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; +import ClearColumnButton from './clear_column_button'; +import SettingToggle from './setting_toggle'; + +export default class ColumnSettings extends React.PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + pushSettings: ImmutablePropTypes.map.isRequired, + onChange: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, + }; + + onPushChange = (key, checked) => { + this.props.onChange(['push', ...key], checked); + } + + render () { + const { settings, pushSettings, onChange, onClear } = this.props; + + const alertStr = ; + const showStr = ; + const soundStr = ; + + const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed'); + const pushStr = showPushSettings && ; + const pushMeta = showPushSettings && ; + + return ( +
+
+ +
+ +
+ + +
+ + {showPushSettings && } + + +
+
+ +
+ + +
+ + {showPushSettings && } + + +
+
+ +
+ + +
+ + {showPushSettings && } + + +
+
+ +
+ + +
+ + {showPushSettings && } + + +
+
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow.js b/app/javascript/flavours/glitch/features/notifications/components/follow.js new file mode 100644 index 000000000..4b682733e --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/follow.js @@ -0,0 +1,97 @@ +// Package imports. +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { HotKeys } from 'react-hotkeys'; + +// Our imports. +import Permalink from 'flavours/glitch/components/permalink'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import NotificationOverlayContainer from '../containers/overlay_container'; + +export default class NotificationFollow extends ImmutablePureComponent { + + static propTypes = { + id: PropTypes.string.isRequired, + account: ImmutablePropTypes.map.isRequired, + notification: ImmutablePropTypes.map.isRequired, + }; + + handleMoveUp = () => { + const { notification, onMoveUp } = this.props; + onMoveUp(notification.get('id')); + } + + handleMoveDown = () => { + const { notification, onMoveDown } = this.props; + onMoveDown(notification.get('id')); + } + + handleOpen = () => { + this.handleOpenProfile(); + } + + handleOpenProfile = () => { + const { notification } = this.props; + this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`); + } + + handleMention = e => { + e.preventDefault(); + + const { notification, onMention } = this.props; + onMention(notification.get('account'), this.context.router.history); + } + + getHandlers () { + return { + moveUp: this.handleMoveUp, + moveDown: this.handleMoveDown, + open: this.handleOpen, + openProfile: this.handleOpenProfile, + mention: this.handleMention, + reply: this.handleMention, + }; + } + + render () { + const { account, notification } = this.props; + + // Links to the display name. + const displayName = account.get('display_name_html') || account.get('username'); + const link = ( + + ); + + // Renders. + return ( + +
+
+
+ +
+ + +
+ + + +
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification.js b/app/javascript/flavours/glitch/features/notifications/components/notification.js new file mode 100644 index 000000000..185da8395 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/notification.js @@ -0,0 +1,88 @@ +// Package imports. +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Our imports, +import StatusContainer from 'flavours/glitch/containers/status_container'; +import NotificationFollow from './follow'; + +export default class Notification extends ImmutablePureComponent { + + static propTypes = { + notification: ImmutablePropTypes.map.isRequired, + hidden: PropTypes.bool, + onMoveUp: PropTypes.func.isRequired, + onMoveDown: PropTypes.func.isRequired, + onMention: PropTypes.func.isRequired, + settings: ImmutablePropTypes.map.isRequired, + }; + + renderFollow () { + const { notification } = this.props; + return ( + + ); + } + + renderMention () { + const { notification } = this.props; + return ( + + ); + } + + renderFavourite () { + const { notification } = this.props; + return ( + + ); + } + + renderReblog () { + const { notification } = this.props; + return ( + + ); + } + + render () { + const { notification } = this.props; + switch(notification.get('type')) { + case 'follow': + return this.renderFollow(); + case 'mention': + return this.renderMention(); + case 'favourite': + return this.renderFavourite(); + case 'reblog': + return this.renderReblog(); + default: + return null; + } + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/overlay.js b/app/javascript/flavours/glitch/features/notifications/components/overlay.js new file mode 100644 index 000000000..e56f9c628 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/overlay.js @@ -0,0 +1,57 @@ +/** + * Notification overlay + */ + + +// Package imports. +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + markForDeletion: { id: 'notification.markForDeletion', defaultMessage: 'Mark for deletion' }, +}); + +@injectIntl +export default class NotificationOverlay extends ImmutablePureComponent { + + static propTypes = { + notification : ImmutablePropTypes.map.isRequired, + onMarkForDelete : PropTypes.func.isRequired, + show : PropTypes.bool.isRequired, + intl : PropTypes.object.isRequired, + }; + + onToggleMark = () => { + const mark = !this.props.notification.get('markedForDelete'); + const id = this.props.notification.get('id'); + this.props.onMarkForDelete(id, mark); + } + + render () { + const { notification, show, intl } = this.props; + + const active = notification.get('markedForDelete'); + const label = intl.formatMessage(messages.markForDeletion); + + return show ? ( +
+
+ +
+
+ ) : null; + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js new file mode 100644 index 000000000..281359d2a --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js @@ -0,0 +1,34 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Toggle from 'react-toggle'; + +export default class SettingToggle extends React.PureComponent { + + static propTypes = { + prefix: PropTypes.string, + settings: ImmutablePropTypes.map.isRequired, + settingKey: PropTypes.array.isRequired, + label: PropTypes.node.isRequired, + meta: PropTypes.node, + onChange: PropTypes.func.isRequired, + } + + onChange = ({ target }) => { + this.props.onChange(this.props.settingKey, target.checked); + } + + render () { + const { prefix, settings, settingKey, label, meta } = this.props; + const id = ['setting-toggle', prefix, ...settingKey].filter(Boolean).join('-'); + + return ( +
+ + + {meta && {meta}} +
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js new file mode 100644 index 000000000..ce502700c --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js @@ -0,0 +1,44 @@ +import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; +import ColumnSettings from '../components/column_settings'; +import { changeSetting, saveSettings } from 'flavours/glitch/actions/settings'; +import { clearNotifications } from 'flavours/glitch/actions/notifications'; +import { changeAlerts as changePushNotifications, saveSettings as savePushNotificationSettings } from 'flavours/glitch/actions/push_notifications'; +import { openModal } from 'flavours/glitch/actions/modal'; + +const messages = defineMessages({ + 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 mapStateToProps = state => ({ + settings: state.getIn(['settings', 'notifications']), + pushSettings: state.get('push_notifications'), +}); + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onChange (key, checked) { + if (key[0] === 'push') { + dispatch(changePushNotifications(key.slice(1), checked)); + } else { + dispatch(changeSetting(['notifications', ...key], checked)); + } + }, + + onSave () { + dispatch(saveSettings()); + dispatch(savePushNotificationSettings()); + }, + + onClear () { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.clearMessage), + confirm: intl.formatMessage(messages.clearConfirm), + onConfirm: () => dispatch(clearNotifications()), + })); + }, + +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings)); diff --git a/app/javascript/flavours/glitch/features/notifications/containers/notification_container.js b/app/javascript/flavours/glitch/features/notifications/containers/notification_container.js new file mode 100644 index 000000000..c60e72e1c --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/containers/notification_container.js @@ -0,0 +1,27 @@ +// Package imports. +import { connect } from 'react-redux'; + +// Our imports. +import { makeGetNotification } from 'flavours/glitch/selectors'; +import Notification from '../components/notification'; +import { mentionCompose } from 'flavours/glitch/actions/compose'; + +const makeMapStateToProps = () => { + const getNotification = makeGetNotification(); + + const mapStateToProps = (state, props) => ({ + notification: getNotification(state, props.notification, props.accountId), + settings: state.get('local_settings'), + notifCleaning: state.getIn(['notifications', 'cleaningMode']), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = dispatch => ({ + onMention: (account, router) => { + dispatch(mentionCompose(account, router)); + }, +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(Notification); diff --git a/app/javascript/flavours/glitch/features/notifications/containers/overlay_container.js b/app/javascript/flavours/glitch/features/notifications/containers/overlay_container.js new file mode 100644 index 000000000..ee2d19814 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/containers/overlay_container.js @@ -0,0 +1,18 @@ +// Package imports. +import { connect } from 'react-redux'; + +// Our imports. +import NotificationOverlay from '../components/overlay'; +import { markNotificationForDelete } from 'flavours/glitch/actions/notifications'; + +const mapDispatchToProps = dispatch => ({ + onMarkForDelete(id, yes) { + dispatch(markNotificationForDelete(id, yes)); + }, +}); + +const mapStateToProps = state => ({ + show: state.getIn(['notifications', 'cleaningMode']), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(NotificationOverlay); diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js new file mode 100644 index 000000000..12b0b5b83 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/index.js @@ -0,0 +1,193 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Column from 'flavours/glitch/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import { + enterNotificationClearingMode, + expandNotifications, + scrollTopNotifications, +} from 'flavours/glitch/actions/notifications'; +import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; +import NotificationContainer from './containers/notification_container'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import { createSelector } from 'reselect'; +import { List as ImmutableList } from 'immutable'; +import { debounce } from 'lodash'; +import ScrollableList from 'flavours/glitch/components/scrollable_list'; + +const messages = defineMessages({ + title: { id: 'column.notifications', defaultMessage: 'Notifications' }, +}); + +const getNotifications = createSelector([ + state => ImmutableList(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), + localSettings: state.get('local_settings'), + isLoading: state.getIn(['notifications', 'isLoading'], true), + isUnread: state.getIn(['notifications', 'unread']) > 0, + hasMore: !!state.getIn(['notifications', 'next']), + notifCleaningActive: state.getIn(['notifications', 'cleaningMode']), +}); + +/* glitch */ +const mapDispatchToProps = dispatch => ({ + onEnterCleaningMode(yes) { + dispatch(enterNotificationClearingMode(yes)); + }, + dispatch, +}); + +@connect(mapStateToProps, mapDispatchToProps) +@injectIntl +export default class Notifications extends React.PureComponent { + + static propTypes = { + columnId: PropTypes.string, + notifications: ImmutablePropTypes.list.isRequired, + dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, + intl: PropTypes.object.isRequired, + isLoading: PropTypes.bool, + isUnread: PropTypes.bool, + multiColumn: PropTypes.bool, + hasMore: PropTypes.bool, + localSettings: ImmutablePropTypes.map, + notifCleaningActive: PropTypes.bool, + onEnterCleaningMode: PropTypes.func, + }; + + static defaultProps = { + trackScroll: true, + }; + + handleScrollToBottom = debounce(() => { + this.props.dispatch(scrollTopNotifications(false)); + this.props.dispatch(expandNotifications()); + }, 300, { leading: true }); + + handleScrollToTop = debounce(() => { + this.props.dispatch(scrollTopNotifications(true)); + }, 100); + + handleScroll = debounce(() => { + this.props.dispatch(scrollTopNotifications(false)); + }, 100); + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('NOTIFICATIONS', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setColumnRef = c => { + this.column = c; + } + + handleMoveUp = id => { + const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) - 1; + this._selectChild(elementIndex); + } + + handleMoveDown = id => { + const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) + 1; + this._selectChild(elementIndex); + } + + _selectChild (index) { + const element = this.column.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`); + + if (element) { + element.focus(); + } + } + + render () { + const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props; + const pinned = !!columnId; + const emptyMessage = ; + + let scrollableContent = null; + + if (isLoading && this.scrollableContent) { + scrollableContent = this.scrollableContent; + } else if (notifications.size > 0 || hasMore) { + scrollableContent = notifications.map((item) => ( + + )); + } else { + scrollableContent = null; + } + + this.scrollableContent = scrollableContent; + + const scrollContainer = ( + + {scrollableContent} + + ); + + return ( + + + + + + {scrollContainer} + + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/pinned_statuses/index.js b/app/javascript/flavours/glitch/features/pinned_statuses/index.js new file mode 100644 index 000000000..f56d70176 --- /dev/null +++ b/app/javascript/flavours/glitch/features/pinned_statuses/index.js @@ -0,0 +1,59 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { fetchPinnedStatuses } from 'flavours/glitch/actions/pin_statuses'; +import Column from 'flavours/glitch/features/ui/components/column'; +import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim'; +import StatusList from 'flavours/glitch/components/status_list'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + heading: { id: 'column.pins', defaultMessage: 'Pinned toot' }, +}); + +const mapStateToProps = state => ({ + statusIds: state.getIn(['status_lists', 'pins', 'items']), + hasMore: !!state.getIn(['status_lists', 'pins', 'next']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class PinnedStatuses extends ImmutablePureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + intl: PropTypes.object.isRequired, + hasMore: PropTypes.bool.isRequired, + }; + + componentWillMount () { + this.props.dispatch(fetchPinnedStatuses()); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + render () { + const { intl, statusIds, hasMore } = this.props; + + return ( + + + + + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/public_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/public_timeline/containers/column_settings_container.js new file mode 100644 index 000000000..b13e20645 --- /dev/null +++ b/app/javascript/flavours/glitch/features/public_timeline/containers/column_settings_container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import ColumnSettings from 'flavours/glitch/features/community_timeline/components/column_settings'; +import { changeSetting } from 'flavours/glitch/actions/settings'; + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'public']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (key, checked) { + dispatch(changeSetting(['public', ...key], checked)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/flavours/glitch/features/public_timeline/index.js b/app/javascript/flavours/glitch/features/public_timeline/index.js new file mode 100644 index 000000000..bbdd4612e --- /dev/null +++ b/app/javascript/flavours/glitch/features/public_timeline/index.js @@ -0,0 +1,107 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; +import Column from 'flavours/glitch/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import { + refreshPublicTimeline, + expandPublicTimeline, +} from 'flavours/glitch/actions/timelines'; +import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import { connectPublicStream } from 'flavours/glitch/actions/streaming'; + +const messages = defineMessages({ + title: { id: 'column.public', defaultMessage: 'Federated timeline' }, +}); + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0, +}); + +@connect(mapStateToProps) +@injectIntl +export default class PublicTimeline extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + columnId: PropTypes.string, + multiColumn: PropTypes.bool, + hasUnread: PropTypes.bool, + }; + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('PUBLIC', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + componentDidMount () { + const { dispatch } = this.props; + + dispatch(refreshPublicTimeline()); + this.disconnect = dispatch(connectPublicStream()); + } + + componentWillUnmount () { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = () => { + this.props.dispatch(expandPublicTimeline()); + } + + render () { + const { intl, columnId, hasUnread, multiColumn } = this.props; + const pinned = !!columnId; + + return ( + + + + + + } + /> + + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/reblogs/index.js b/app/javascript/flavours/glitch/features/reblogs/index.js new file mode 100644 index 000000000..25b792b39 --- /dev/null +++ b/app/javascript/flavours/glitch/features/reblogs/index.js @@ -0,0 +1,60 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; +import { fetchReblogs } from 'flavours/glitch/actions/interactions'; +import { ScrollContainer } from 'react-router-scroll-4'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import Column from 'flavours/glitch/features/ui/components/column'; +import ColumnBackButton from 'flavours/glitch/components/column_back_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]), +}); + +@connect(mapStateToProps) +export default class Reblogs extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + }; + + componentWillMount () { + this.props.dispatch(fetchReblogs(this.props.params.statusId)); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) { + this.props.dispatch(fetchReblogs(nextProps.params.statusId)); + } + } + + render () { + const { accountIds } = this.props; + + if (!accountIds) { + return ( + + + + ); + } + + return ( + + + + +
+ {accountIds.map(id => )} +
+
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/report/components/status_check_box.js b/app/javascript/flavours/glitch/features/report/components/status_check_box.js new file mode 100644 index 000000000..cc9232201 --- /dev/null +++ b/app/javascript/flavours/glitch/features/report/components/status_check_box.js @@ -0,0 +1,37 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Toggle from 'react-toggle'; + +export default class StatusCheckBox extends React.PureComponent { + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + checked: PropTypes.bool, + onToggle: PropTypes.func.isRequired, + disabled: PropTypes.bool, + }; + + render () { + const { status, checked, onToggle, disabled } = this.props; + const content = { __html: status.get('contentHtml') }; + + if (status.get('reblog')) { + return null; + } + + return ( +
+
+ +
+ +
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/report/containers/status_check_box_container.js b/app/javascript/flavours/glitch/features/report/containers/status_check_box_container.js new file mode 100644 index 000000000..9bfd41ffc --- /dev/null +++ b/app/javascript/flavours/glitch/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 'flavours/glitch/actions/reports'; +import { Set as ImmutableSet } from 'immutable'; + +const mapStateToProps = (state, { id }) => ({ + status: state.getIn(['statuses', id]), + checked: state.getIn(['reports', 'new', 'status_ids'], ImmutableSet()).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/flavours/glitch/features/standalone/compose/index.js b/app/javascript/flavours/glitch/features/standalone/compose/index.js new file mode 100644 index 000000000..b33c21cb5 --- /dev/null +++ b/app/javascript/flavours/glitch/features/standalone/compose/index.js @@ -0,0 +1,20 @@ +import React from 'react'; +import ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container'; +import NotificationsContainer from 'flavours/glitch/features/ui/containers/notifications_container'; +import LoadingBarContainer from 'flavours/glitch/features/ui/containers/loading_bar_container'; +import ModalContainer from 'flavours/glitch/features/ui/containers/modal_container'; + +export default class Compose extends React.PureComponent { + + render () { + return ( +
+ + + + +
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js new file mode 100644 index 000000000..0ad2cef80 --- /dev/null +++ b/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; +import { + refreshHashtagTimeline, + expandHashtagTimeline, +} from 'flavours/glitch/actions/timelines'; +import Column from 'flavours/glitch/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; + +@connect() +export default class HashtagTimeline extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + hashtag: PropTypes.string.isRequired, + }; + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + componentDidMount () { + const { dispatch, hashtag } = this.props; + + dispatch(refreshHashtagTimeline(hashtag)); + + this.polling = setInterval(() => { + dispatch(refreshHashtagTimeline(hashtag)); + }, 10000); + } + + componentWillUnmount () { + if (typeof this.polling !== 'undefined') { + clearInterval(this.polling); + this.polling = null; + } + } + + handleLoadMore = () => { + this.props.dispatch(expandHashtagTimeline(this.props.hashtag)); + } + + render () { + const { hashtag } = this.props; + + return ( + + + + + + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js new file mode 100644 index 000000000..717f6fcaf --- /dev/null +++ b/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js @@ -0,0 +1,76 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; +import { + refreshPublicTimeline, + expandPublicTimeline, +} from 'flavours/glitch/actions/timelines'; +import Column from 'flavours/glitch/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' }, +}); + +@connect() +@injectIntl +export default class PublicTimeline extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + componentDidMount () { + const { dispatch } = this.props; + + dispatch(refreshPublicTimeline()); + + this.polling = setInterval(() => { + dispatch(refreshPublicTimeline()); + }, 3000); + } + + componentWillUnmount () { + if (typeof this.polling !== 'undefined') { + clearInterval(this.polling); + this.polling = null; + } + } + + handleLoadMore = () => { + this.props.dispatch(expandPublicTimeline()); + } + + render () { + const { intl } = this.props; + + return ( + + + + + + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.js b/app/javascript/flavours/glitch/features/status/components/action_bar.js new file mode 100644 index 000000000..4d660ee3c --- /dev/null +++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js @@ -0,0 +1,129 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import IconButton from 'flavours/glitch/components/icon_button'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container'; +import { defineMessages, injectIntl } from 'react-intl'; +import { me } from 'flavours/glitch/util/initial_state'; + +const messages = defineMessages({ + delete: { id: 'status.delete', defaultMessage: 'Delete' }, + mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, + reply: { id: 'status.reply', defaultMessage: 'Reply' }, + reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, + cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, + favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + report: { id: 'status.report', defaultMessage: 'Report @{name}' }, + share: { id: 'status.share', defaultMessage: 'Share' }, + pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, + unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, + embed: { id: 'status.embed', defaultMessage: 'Embed' }, +}); + +@injectIntl +export default class ActionBar extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + onReply: PropTypes.func.isRequired, + onReblog: PropTypes.func.isRequired, + onFavourite: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired, + onMention: PropTypes.func.isRequired, + onReport: PropTypes.func, + onPin: PropTypes.func, + onEmbed: PropTypes.func, + intl: PropTypes.object.isRequired, + }; + + handleReplyClick = () => { + this.props.onReply(this.props.status); + } + + handleReblogClick = (e) => { + this.props.onReblog(this.props.status, e); + } + + handleFavouriteClick = () => { + this.props.onFavourite(this.props.status); + } + + handleDeleteClick = () => { + this.props.onDelete(this.props.status); + } + + handleMentionClick = () => { + this.props.onMention(this.props.status.get('account'), this.context.router.history); + } + + handleReport = () => { + this.props.onReport(this.props.status); + } + + handlePinClick = () => { + this.props.onPin(this.props.status); + } + + handleShare = () => { + navigator.share({ + text: this.props.status.get('search_index'), + url: this.props.status.get('url'), + }); + } + + handleEmbed = () => { + this.props.onEmbed(this.props.status); + } + + render () { + const { status, intl } = this.props; + + const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); + + let menu = []; + + if (publicStatus) { + menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); + } + + if (me === status.getIn(['account', 'id'])) { + if (publicStatus) { + menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); + } + + menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); + } else { + menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); + menu.push(null); + menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); + } + + const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && ( +
+ ); + + let reblogIcon = 'retweet'; + //if (status.get('visibility') === 'direct') reblogIcon = 'envelope'; + // else if (status.get('visibility') === 'private') reblogIcon = 'lock'; + + let reblog_disabled = (status.get('visibility') === 'direct' || status.get('visibility') === 'private'); + + return ( +
+
+
+
+ {shareButton} + +
+ +
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/status/components/card.js b/app/javascript/flavours/glitch/features/status/components/card.js new file mode 100644 index 000000000..bb83374b9 --- /dev/null +++ b/app/javascript/flavours/glitch/features/status/components/card.js @@ -0,0 +1,125 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import punycode from 'punycode'; +import classnames from 'classnames'; + +const IDNA_PREFIX = 'xn--'; + +const decodeIDNA = domain => { + return domain + .split('.') + .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part) + .join('.'); +}; + +const getHostname = url => { + const parser = document.createElement('a'); + parser.href = url; + return parser.hostname; +}; + +export default class Card extends React.PureComponent { + + static propTypes = { + card: ImmutablePropTypes.map, + maxDescription: PropTypes.number, + }; + + static defaultProps = { + maxDescription: 50, + }; + + state = { + width: 0, + }; + + renderLink () { + const { card, maxDescription } = this.props; + + let image = ''; + let provider = card.get('provider_name'); + + if (card.get('image')) { + image = ( +
+ {card.get('title')} +
+ ); + } + + if (provider.length < 1) { + provider = decodeIDNA(getHostname(card.get('url'))); + } + + const className = classnames('status-card', { + 'horizontal': card.get('width') > card.get('height'), + }); + + return ( + + {image} + +
+ {card.get('title')} +

{(card.get('description') || '').substring(0, maxDescription)}

+ {provider} +
+
+ ); + } + + renderPhoto () { + const { card } = this.props; + + return ( + + {card.get('title')} + + ); + } + + setRef = c => { + if (c) { + this.setState({ width: c.offsetWidth }); + } + } + + renderVideo () { + const { card } = this.props; + const content = { __html: card.get('html') }; + const { width } = this.state; + const ratio = card.get('width') / card.get('height'); + const height = card.get('width') > card.get('height') ? (width / ratio) : (width * ratio); + + return ( +
+ ); + } + + render () { + const { card } = this.props; + + if (card === null) { + return null; + } + + switch(card.get('type')) { + case 'link': + return this.renderLink(); + case 'photo': + return this.renderPhoto(); + case 'video': + return this.renderVideo(); + case 'rich': + default: + return null; + } + } + +} diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js new file mode 100644 index 000000000..7c6f436d6 --- /dev/null +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -0,0 +1,128 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Avatar from 'flavours/glitch/components/avatar'; +import DisplayName from 'flavours/glitch/components/display_name'; +import StatusContent from 'flavours/glitch/components/status_content'; +import StatusGallery from 'flavours/glitch/components/media_gallery'; +import AttachmentList from 'flavours/glitch/components/attachment_list'; +import { Link } from 'react-router-dom'; +import { FormattedDate, FormattedNumber } from 'react-intl'; +import CardContainer from '../containers/card_container'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Video from 'flavours/glitch/features/video'; +import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon'; + +export default class DetailedStatus extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + settings: ImmutablePropTypes.map.isRequired, + onOpenMedia: PropTypes.func.isRequired, + onOpenVideo: PropTypes.func.isRequired, + }; + + handleAccountClick = (e) => { + if (e.button === 0) { + e.preventDefault(); + this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); + } + + e.stopPropagation(); + } + + // handleOpenVideo = startTime => { + // this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime); + // } + + render () { + const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status; + const { settings } = this.props; + + let media = ''; + let mediaIcon = null; + let applicationLink = ''; + let reblogLink = ''; + let reblogIcon = 'retweet'; + + if (status.get('media_attachments').size > 0) { + if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { + media = ; + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + media = ( +