From c49213f0ea311daba590db1d7a14a641cbd9fe93 Mon Sep 17 00:00:00 2001
From: Nick Schonning
Date: Sun, 29 Jan 2023 19:45:35 -0500
Subject: Upgrade ESlint to v8 (#23305)
---
app/javascript/mastodon/features/directory/index.js | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
(limited to 'app/javascript/mastodon/features/directory/index.js')
diff --git a/app/javascript/mastodon/features/directory/index.js b/app/javascript/mastodon/features/directory/index.js
index b45faa049..bb5e021cc 100644
--- a/app/javascript/mastodon/features/directory/index.js
+++ b/app/javascript/mastodon/features/directory/index.js
@@ -64,7 +64,7 @@ class Directory extends React.PureComponent {
} else {
dispatch(addColumn('DIRECTORY', this.getParams(this.props, this.state)));
}
- }
+ };
getParams = (props, state) => ({
order: state.order === null ? (props.params.order || 'active') : state.order,
@@ -74,11 +74,11 @@ class Directory extends React.PureComponent {
handleMove = dir => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
- }
+ };
handleHeaderClick = () => {
this.column.scrollTop();
- }
+ };
componentDidMount () {
const { dispatch } = this.props;
@@ -97,7 +97,7 @@ class Directory extends React.PureComponent {
setRef = c => {
this.column = c;
- }
+ };
handleChangeOrder = e => {
const { dispatch, columnId } = this.props;
@@ -107,7 +107,7 @@ class Directory extends React.PureComponent {
} else {
this.setState({ order: e.target.value });
}
- }
+ };
handleChangeLocal = e => {
const { dispatch, columnId } = this.props;
@@ -117,12 +117,12 @@ class Directory extends React.PureComponent {
} else {
this.setState({ local: e.target.value === '1' });
}
- }
+ };
handleLoadMore = () => {
const { dispatch } = this.props;
dispatch(expandDirectory(this.getParams(this.props, this.state)));
- }
+ };
render () {
const { isLoading, accountIds, intl, columnId, multiColumn, domain } = this.props;
--
cgit
From 44a7d87cb1f5df953b6c14c16c59e2e4ead1bcb9 Mon Sep 17 00:00:00 2001
From: Renaud Chaput
Date: Mon, 20 Feb 2023 03:20:59 +0100
Subject: Rename JSX files with proper `.jsx` extension (#23733)
---
.eslintrc.js | 5 +-
.github/workflows/lint-js.yml | 2 +
.github/workflows/test-js.yml | 2 +
.../__snapshots__/autosuggest_emoji-test.js.snap | 27 -
.../__snapshots__/autosuggest_emoji-test.jsx.snap | 27 +
.../__tests__/__snapshots__/avatar-test.js.snap | 39 --
.../__tests__/__snapshots__/avatar-test.jsx.snap | 39 ++
.../__snapshots__/avatar_overlay-test.js.snap | 54 --
.../__snapshots__/avatar_overlay-test.jsx.snap | 54 ++
.../__tests__/__snapshots__/button-test.js.snap | 66 --
.../__tests__/__snapshots__/button-test.jsx.snap | 66 ++
.../__snapshots__/display_name-test.js.snap | 27 -
.../__snapshots__/display_name-test.jsx.snap | 27 +
.../components/__tests__/autosuggest_emoji-test.js | 29 -
.../__tests__/autosuggest_emoji-test.jsx | 29 +
.../mastodon/components/__tests__/avatar-test.js | 36 --
.../mastodon/components/__tests__/avatar-test.jsx | 36 ++
.../components/__tests__/avatar_overlay-test.js | 29 -
.../components/__tests__/avatar_overlay-test.jsx | 29 +
.../mastodon/components/__tests__/button-test.js | 75 ---
.../mastodon/components/__tests__/button-test.jsx | 75 +++
.../components/__tests__/display_name-test.js | 18 -
.../components/__tests__/display_name-test.jsx | 18 +
app/javascript/mastodon/components/account.js | 157 -----
app/javascript/mastodon/components/account.jsx | 157 +++++
.../mastodon/components/admin/Counter.js | 117 ----
.../mastodon/components/admin/Counter.jsx | 117 ++++
.../mastodon/components/admin/Dimension.js | 93 ---
.../mastodon/components/admin/Dimension.jsx | 93 +++
.../components/admin/ReportReasonSelector.js | 159 -----
.../components/admin/ReportReasonSelector.jsx | 159 +++++
.../mastodon/components/admin/Retention.js | 151 -----
.../mastodon/components/admin/Retention.jsx | 151 +++++
app/javascript/mastodon/components/admin/Trends.js | 73 ---
.../mastodon/components/admin/Trends.jsx | 73 +++
.../mastodon/components/animated_number.js | 76 ---
.../mastodon/components/animated_number.jsx | 76 +++
.../mastodon/components/attachment_list.js | 48 --
.../mastodon/components/attachment_list.jsx | 48 ++
.../mastodon/components/autosuggest_emoji.js | 41 --
.../mastodon/components/autosuggest_emoji.jsx | 41 ++
.../mastodon/components/autosuggest_hashtag.js | 42 --
.../mastodon/components/autosuggest_hashtag.jsx | 42 ++
.../mastodon/components/autosuggest_input.js | 227 -------
.../mastodon/components/autosuggest_input.jsx | 227 +++++++
.../mastodon/components/autosuggest_textarea.js | 235 -------
.../mastodon/components/autosuggest_textarea.jsx | 235 +++++++
app/javascript/mastodon/components/avatar.js | 62 --
app/javascript/mastodon/components/avatar.jsx | 62 ++
.../mastodon/components/avatar_composite.js | 103 ----
.../mastodon/components/avatar_composite.jsx | 103 ++++
.../mastodon/components/avatar_overlay.js | 51 --
.../mastodon/components/avatar_overlay.jsx | 51 ++
app/javascript/mastodon/components/blurhash.js | 65 --
app/javascript/mastodon/components/blurhash.jsx | 65 ++
app/javascript/mastodon/components/button.js | 57 --
app/javascript/mastodon/components/button.jsx | 57 ++
app/javascript/mastodon/components/check.js | 9 -
app/javascript/mastodon/components/check.jsx | 9 +
app/javascript/mastodon/components/column.js | 62 --
app/javascript/mastodon/components/column.jsx | 62 ++
.../mastodon/components/column_back_button.js | 54 --
.../mastodon/components/column_back_button.jsx | 54 ++
.../mastodon/components/column_back_button_slim.js | 19 -
.../components/column_back_button_slim.jsx | 19 +
.../mastodon/components/column_header.js | 215 -------
.../mastodon/components/column_header.jsx | 215 +++++++
.../mastodon/components/common_counter.js | 62 --
.../mastodon/components/common_counter.jsx | 62 ++
.../mastodon/components/dismissable_banner.js | 51 --
.../mastodon/components/dismissable_banner.jsx | 51 ++
app/javascript/mastodon/components/display_name.js | 79 ---
.../mastodon/components/display_name.jsx | 79 +++
app/javascript/mastodon/components/domain.js | 42 --
app/javascript/mastodon/components/domain.jsx | 42 ++
.../mastodon/components/dropdown_menu.js | 335 ----------
.../mastodon/components/dropdown_menu.jsx | 335 ++++++++++
.../mastodon/components/edited_timestamp/index.js | 70 ---
.../mastodon/components/edited_timestamp/index.jsx | 70 +++
.../mastodon/components/error_boundary.js | 107 ----
.../mastodon/components/error_boundary.jsx | 107 ++++
app/javascript/mastodon/components/gifv.js | 73 ---
app/javascript/mastodon/components/gifv.jsx | 73 +++
app/javascript/mastodon/components/hashtag.js | 113 ----
app/javascript/mastodon/components/hashtag.jsx | 113 ++++
app/javascript/mastodon/components/icon.js | 21 -
app/javascript/mastodon/components/icon.jsx | 21 +
app/javascript/mastodon/components/icon_button.js | 164 -----
app/javascript/mastodon/components/icon_button.jsx | 164 +++++
.../mastodon/components/icon_with_badge.js | 22 -
.../mastodon/components/icon_with_badge.jsx | 22 +
app/javascript/mastodon/components/image.js | 33 -
app/javascript/mastodon/components/image.jsx | 33 +
.../mastodon/components/inline_account.js | 34 -
.../mastodon/components/inline_account.jsx | 34 +
.../components/intersection_observer_article.js | 130 ----
.../components/intersection_observer_article.jsx | 130 ++++
app/javascript/mastodon/components/load_gap.js | 34 -
app/javascript/mastodon/components/load_gap.jsx | 34 +
app/javascript/mastodon/components/load_more.js | 27 -
app/javascript/mastodon/components/load_more.jsx | 27 +
app/javascript/mastodon/components/load_pending.js | 22 -
.../mastodon/components/load_pending.jsx | 22 +
.../mastodon/components/loading_indicator.js | 32 -
.../mastodon/components/loading_indicator.jsx | 32 +
app/javascript/mastodon/components/logo.js | 10 -
app/javascript/mastodon/components/logo.jsx | 10 +
.../mastodon/components/media_attachments.js | 116 ----
.../mastodon/components/media_attachments.jsx | 116 ++++
.../mastodon/components/media_gallery.js | 368 -----------
.../mastodon/components/media_gallery.jsx | 368 +++++++++++
.../mastodon/components/missing_indicator.js | 29 -
.../mastodon/components/missing_indicator.jsx | 29 +
app/javascript/mastodon/components/modal_root.js | 157 -----
app/javascript/mastodon/components/modal_root.jsx | 157 +++++
.../mastodon/components/navigation_portal.js | 35 --
.../mastodon/components/navigation_portal.jsx | 35 ++
.../mastodon/components/not_signed_in_indicator.js | 12 -
.../components/not_signed_in_indicator.jsx | 12 +
.../components/picture_in_picture_placeholder.js | 69 ---
.../components/picture_in_picture_placeholder.jsx | 69 +++
app/javascript/mastodon/components/poll.js | 233 -------
app/javascript/mastodon/components/poll.jsx | 233 +++++++
app/javascript/mastodon/components/radio_button.js | 35 --
.../mastodon/components/radio_button.jsx | 35 ++
.../mastodon/components/regeneration_indicator.js | 18 -
.../mastodon/components/regeneration_indicator.jsx | 18 +
.../mastodon/components/relative_timestamp.js | 199 ------
.../mastodon/components/relative_timestamp.jsx | 199 ++++++
.../mastodon/components/scrollable_list.js | 367 -----------
.../mastodon/components/scrollable_list.jsx | 367 +++++++++++
.../mastodon/components/server_banner.js | 93 ---
.../mastodon/components/server_banner.jsx | 93 +++
app/javascript/mastodon/components/short_number.js | 117 ----
.../mastodon/components/short_number.jsx | 117 ++++
app/javascript/mastodon/components/skeleton.js | 11 -
app/javascript/mastodon/components/skeleton.jsx | 11 +
app/javascript/mastodon/components/status.js | 547 ----------------
app/javascript/mastodon/components/status.jsx | 547 ++++++++++++++++
.../mastodon/components/status_action_bar.js | 387 ------------
.../mastodon/components/status_action_bar.jsx | 387 ++++++++++++
.../mastodon/components/status_content.js | 304 ---------
.../mastodon/components/status_content.jsx | 304 +++++++++
app/javascript/mastodon/components/status_list.js | 131 ----
app/javascript/mastodon/components/status_list.jsx | 131 ++++
.../mastodon/components/timeline_hint.js | 18 -
.../mastodon/components/timeline_hint.jsx | 18 +
.../mastodon/containers/account_container.js | 72 ---
.../mastodon/containers/account_container.jsx | 72 +++
.../mastodon/containers/admin_component.js | 26 -
.../mastodon/containers/admin_component.jsx | 26 +
.../mastodon/containers/compose_container.js | 41 --
.../mastodon/containers/compose_container.jsx | 41 ++
.../mastodon/containers/domain_container.js | 32 -
.../mastodon/containers/domain_container.jsx | 32 +
app/javascript/mastodon/containers/mastodon.js | 98 ---
app/javascript/mastodon/containers/mastodon.jsx | 98 +++
.../mastodon/containers/media_container.js | 121 ----
.../mastodon/containers/media_container.jsx | 121 ++++
.../mastodon/containers/status_container.js | 250 --------
.../mastodon/containers/status_container.jsx | 250 ++++++++
app/javascript/mastodon/features/about/index.js | 219 -------
app/javascript/mastodon/features/about/index.jsx | 219 +++++++
.../features/account/components/account_note.js | 170 -----
.../features/account/components/account_note.jsx | 170 +++++
.../features/account/components/featured_tags.js | 52 --
.../features/account/components/featured_tags.jsx | 52 ++
.../account/components/follow_request_note.js | 37 --
.../account/components/follow_request_note.jsx | 37 ++
.../mastodon/features/account/components/header.js | 421 -------------
.../features/account/components/header.jsx | 421 +++++++++++++
.../mastodon/features/account/navigation.js | 52 --
.../mastodon/features/account/navigation.jsx | 52 ++
.../account_gallery/components/media_item.js | 146 -----
.../account_gallery/components/media_item.jsx | 146 +++++
.../mastodon/features/account_gallery/index.js | 228 -------
.../mastodon/features/account_gallery/index.jsx | 228 +++++++
.../features/account_timeline/components/header.js | 153 -----
.../account_timeline/components/header.jsx | 153 +++++
.../components/limited_account_hint.js | 36 --
.../components/limited_account_hint.jsx | 36 ++
.../account_timeline/components/moved_note.js | 37 --
.../account_timeline/components/moved_note.jsx | 37 ++
.../containers/header_container.js | 164 -----
.../containers/header_container.jsx | 164 +++++
.../mastodon/features/account_timeline/index.js | 208 -------
.../mastodon/features/account_timeline/index.jsx | 208 +++++++
app/javascript/mastodon/features/audio/index.js | 569 -----------------
app/javascript/mastodon/features/audio/index.jsx | 569 +++++++++++++++++
app/javascript/mastodon/features/blocks/index.js | 79 ---
app/javascript/mastodon/features/blocks/index.jsx | 79 +++
.../mastodon/features/bookmarked_statuses/index.js | 108 ----
.../features/bookmarked_statuses/index.jsx | 108 ++++
.../features/closed_registrations_modal/index.js | 75 ---
.../features/closed_registrations_modal/index.jsx | 75 +++
.../components/column_settings.js | 29 -
.../components/column_settings.jsx | 29 +
.../mastodon/features/community_timeline/index.js | 160 -----
.../mastodon/features/community_timeline/index.jsx | 160 +++++
.../features/compose/components/action_bar.js | 67 --
.../features/compose/components/action_bar.jsx | 67 ++
.../compose/components/autosuggest_account.js | 24 -
.../compose/components/autosuggest_account.jsx | 24 +
.../compose/components/character_counter.js | 25 -
.../compose/components/character_counter.jsx | 25 +
.../features/compose/components/compose_form.js | 301 ---------
.../features/compose/components/compose_form.jsx | 301 +++++++++
.../compose/components/emoji_picker_dropdown.js | 411 ------------
.../compose/components/emoji_picker_dropdown.jsx | 411 ++++++++++++
.../compose/components/language_dropdown.js | 327 ----------
.../compose/components/language_dropdown.jsx | 327 ++++++++++
.../features/compose/components/navigation_bar.js | 43 --
.../features/compose/components/navigation_bar.jsx | 43 ++
.../features/compose/components/poll_button.js | 55 --
.../features/compose/components/poll_button.jsx | 55 ++
.../features/compose/components/poll_form.js | 182 ------
.../features/compose/components/poll_form.jsx | 182 ++++++
.../compose/components/privacy_dropdown.js | 287 ---------
.../compose/components/privacy_dropdown.jsx | 287 +++++++++
.../features/compose/components/reply_indicator.js | 71 ---
.../compose/components/reply_indicator.jsx | 71 +++
.../mastodon/features/compose/components/search.js | 147 -----
.../features/compose/components/search.jsx | 147 +++++
.../features/compose/components/search_results.js | 140 -----
.../features/compose/components/search_results.jsx | 140 +++++
.../compose/components/text_icon_button.js | 38 --
.../compose/components/text_icon_button.jsx | 38 ++
.../mastodon/features/compose/components/upload.js | 66 --
.../features/compose/components/upload.jsx | 66 ++
.../features/compose/components/upload_button.js | 83 ---
.../features/compose/components/upload_button.jsx | 83 +++
.../features/compose/components/upload_form.js | 32 -
.../features/compose/components/upload_form.jsx | 32 +
.../features/compose/components/upload_progress.js | 52 --
.../compose/components/upload_progress.jsx | 52 ++
.../features/compose/components/warning.js | 26 -
.../features/compose/components/warning.jsx | 26 +
.../containers/sensitive_button_container.js | 71 ---
.../containers/sensitive_button_container.jsx | 71 +++
.../compose/containers/warning_container.js | 67 --
.../compose/containers/warning_container.jsx | 67 ++
app/javascript/mastodon/features/compose/index.js | 150 -----
app/javascript/mastodon/features/compose/index.jsx | 150 +++++
.../direct_timeline/components/conversation.js | 200 ------
.../direct_timeline/components/conversation.jsx | 200 ++++++
.../components/conversations_list.js | 75 ---
.../components/conversations_list.jsx | 75 +++
.../mastodon/features/direct_timeline/index.js | 107 ----
.../mastodon/features/direct_timeline/index.jsx | 107 ++++
.../features/directory/components/account_card.js | 235 -------
.../features/directory/components/account_card.jsx | 235 +++++++
.../mastodon/features/directory/index.js | 178 ------
.../mastodon/features/directory/index.jsx | 178 ++++++
.../mastodon/features/domain_blocks/index.js | 83 ---
.../mastodon/features/domain_blocks/index.jsx | 83 +++
.../mastodon/features/explore/components/story.js | 51 --
.../mastodon/features/explore/components/story.jsx | 51 ++
app/javascript/mastodon/features/explore/index.js | 107 ----
app/javascript/mastodon/features/explore/index.jsx | 107 ++++
app/javascript/mastodon/features/explore/links.js | 70 ---
app/javascript/mastodon/features/explore/links.jsx | 70 +++
.../mastodon/features/explore/results.js | 126 ----
.../mastodon/features/explore/results.jsx | 126 ++++
.../mastodon/features/explore/statuses.js | 64 --
.../mastodon/features/explore/statuses.jsx | 64 ++
.../mastodon/features/explore/suggestions.js | 51 --
.../mastodon/features/explore/suggestions.jsx | 51 ++
app/javascript/mastodon/features/explore/tags.js | 62 --
app/javascript/mastodon/features/explore/tags.jsx | 62 ++
.../mastodon/features/favourited_statuses/index.js | 108 ----
.../features/favourited_statuses/index.jsx | 108 ++++
.../mastodon/features/favourites/index.js | 92 ---
.../mastodon/features/favourites/index.jsx | 92 +++
.../mastodon/features/filters/added_to_filter.js | 102 ---
.../mastodon/features/filters/added_to_filter.jsx | 102 +++
.../mastodon/features/filters/select_filter.js | 192 ------
.../mastodon/features/filters/select_filter.jsx | 192 ++++++
.../follow_recommendations/components/account.js | 85 ---
.../follow_recommendations/components/account.jsx | 85 +++
.../features/follow_recommendations/index.js | 116 ----
.../features/follow_recommendations/index.jsx | 116 ++++
.../components/account_authorize.js | 49 --
.../components/account_authorize.jsx | 49 ++
.../mastodon/features/follow_requests/index.js | 91 ---
.../mastodon/features/follow_requests/index.jsx | 91 +++
.../mastodon/features/followed_tags/index.js | 89 ---
.../mastodon/features/followed_tags/index.jsx | 89 +++
.../mastodon/features/followers/index.js | 170 -----
.../mastodon/features/followers/index.jsx | 170 +++++
.../mastodon/features/following/index.js | 170 -----
.../mastodon/features/following/index.jsx | 170 +++++
.../mastodon/features/generic_not_found/index.js | 11 -
.../mastodon/features/generic_not_found/index.jsx | 11 +
.../getting_started/components/announcements.js | 449 --------------
.../getting_started/components/announcements.jsx | 449 ++++++++++++++
.../features/getting_started/components/trends.js | 51 --
.../features/getting_started/components/trends.jsx | 51 ++
.../mastodon/features/getting_started/index.js | 155 -----
.../mastodon/features/getting_started/index.jsx | 155 +++++
.../hashtag_timeline/components/column_settings.js | 133 ----
.../components/column_settings.jsx | 133 ++++
.../mastodon/features/hashtag_timeline/index.js | 237 -------
.../mastodon/features/hashtag_timeline/index.jsx | 237 +++++++
.../home_timeline/components/column_settings.js | 34 -
.../home_timeline/components/column_settings.jsx | 34 +
.../mastodon/features/home_timeline/index.js | 176 ------
.../mastodon/features/home_timeline/index.jsx | 176 ++++++
.../mastodon/features/interaction_modal/index.js | 161 -----
.../mastodon/features/interaction_modal/index.jsx | 161 +++++
.../mastodon/features/keyboard_shortcuts/index.js | 176 ------
.../mastodon/features/keyboard_shortcuts/index.jsx | 176 ++++++
.../features/list_adder/components/account.js | 43 --
.../features/list_adder/components/account.jsx | 43 ++
.../features/list_adder/components/list.js | 69 ---
.../features/list_adder/components/list.jsx | 69 +++
.../mastodon/features/list_adder/index.js | 73 ---
.../mastodon/features/list_adder/index.jsx | 73 +++
.../features/list_editor/components/account.js | 77 ---
.../features/list_editor/components/account.jsx | 77 +++
.../list_editor/components/edit_list_form.js | 70 ---
.../list_editor/components/edit_list_form.jsx | 70 +++
.../features/list_editor/components/search.js | 76 ---
.../features/list_editor/components/search.jsx | 76 +++
.../mastodon/features/list_editor/index.js | 79 ---
.../mastodon/features/list_editor/index.jsx | 79 +++
.../mastodon/features/list_timeline/index.js | 221 -------
.../mastodon/features/list_timeline/index.jsx | 221 +++++++
.../features/lists/components/new_list_form.js | 77 ---
.../features/lists/components/new_list_form.jsx | 77 +++
app/javascript/mastodon/features/lists/index.js | 89 ---
app/javascript/mastodon/features/lists/index.jsx | 89 +++
app/javascript/mastodon/features/mutes/index.js | 84 ---
app/javascript/mastodon/features/mutes/index.jsx | 84 +++
.../components/clear_column_button.js | 18 -
.../components/clear_column_button.jsx | 18 +
.../notifications/components/column_settings.js | 202 ------
.../notifications/components/column_settings.jsx | 202 ++++++
.../notifications/components/filter_bar.js | 110 ----
.../notifications/components/filter_bar.jsx | 110 ++++
.../notifications/components/follow_request.js | 59 --
.../notifications/components/follow_request.jsx | 59 ++
.../components/grant_permission_button.js | 19 -
.../components/grant_permission_button.jsx | 19 +
.../notifications/components/notification.js | 449 --------------
.../notifications/components/notification.jsx | 449 ++++++++++++++
.../components/notifications_permission_banner.js | 48 --
.../components/notifications_permission_banner.jsx | 48 ++
.../features/notifications/components/report.js | 62 --
.../features/notifications/components/report.jsx | 62 ++
.../notifications/components/setting_toggle.js | 34 -
.../notifications/components/setting_toggle.jsx | 34 +
.../mastodon/features/notifications/index.js | 290 ---------
.../mastodon/features/notifications/index.jsx | 290 +++++++++
.../picture_in_picture/components/footer.js | 192 ------
.../picture_in_picture/components/footer.jsx | 192 ++++++
.../picture_in_picture/components/header.js | 47 --
.../picture_in_picture/components/header.jsx | 47 ++
.../mastodon/features/picture_in_picture/index.js | 85 ---
.../mastodon/features/picture_in_picture/index.jsx | 85 +++
.../mastodon/features/pinned_statuses/index.js | 65 --
.../mastodon/features/pinned_statuses/index.jsx | 65 ++
.../mastodon/features/privacy_policy/index.js | 61 --
.../mastodon/features/privacy_policy/index.jsx | 61 ++
.../public_timeline/components/column_settings.js | 30 -
.../public_timeline/components/column_settings.jsx | 30 +
.../mastodon/features/public_timeline/index.js | 162 -----
.../mastodon/features/public_timeline/index.jsx | 162 +++++
app/javascript/mastodon/features/reblogs/index.js | 92 ---
app/javascript/mastodon/features/reblogs/index.jsx | 92 +++
.../mastodon/features/report/category.js | 106 ----
.../mastodon/features/report/category.jsx | 106 ++++
app/javascript/mastodon/features/report/comment.js | 83 ---
.../mastodon/features/report/comment.jsx | 83 +++
.../mastodon/features/report/components/option.js | 60 --
.../mastodon/features/report/components/option.jsx | 60 ++
.../features/report/components/status_check_box.js | 82 ---
.../report/components/status_check_box.jsx | 82 +++
app/javascript/mastodon/features/report/rules.js | 64 --
app/javascript/mastodon/features/report/rules.jsx | 64 ++
.../mastodon/features/report/statuses.js | 61 --
.../mastodon/features/report/statuses.jsx | 61 ++
app/javascript/mastodon/features/report/thanks.js | 84 ---
app/javascript/mastodon/features/report/thanks.jsx | 84 +++
.../mastodon/features/standalone/compose/index.js | 20 -
.../mastodon/features/standalone/compose/index.jsx | 20 +
.../features/status/components/action_bar.js | 300 ---------
.../features/status/components/action_bar.jsx | 300 +++++++++
.../mastodon/features/status/components/card.js | 289 ---------
.../mastodon/features/status/components/card.jsx | 289 +++++++++
.../features/status/components/detailed_status.js | 288 ---------
.../features/status/components/detailed_status.jsx | 288 +++++++++
app/javascript/mastodon/features/status/index.js | 686 ---------------------
app/javascript/mastodon/features/status/index.jsx | 686 +++++++++++++++++++++
.../features/subscribed_languages_modal/index.js | 125 ----
.../features/subscribed_languages_modal/index.jsx | 125 ++++
.../ui/components/__tests__/column-test.js | 24 -
.../ui/components/__tests__/column-test.jsx | 24 +
.../features/ui/components/actions_modal.js | 46 --
.../features/ui/components/actions_modal.jsx | 46 ++
.../mastodon/features/ui/components/audio_modal.js | 54 --
.../features/ui/components/audio_modal.jsx | 54 ++
.../mastodon/features/ui/components/block_modal.js | 103 ----
.../features/ui/components/block_modal.jsx | 103 ++++
.../mastodon/features/ui/components/boost_modal.js | 142 -----
.../features/ui/components/boost_modal.jsx | 142 +++++
.../mastodon/features/ui/components/bundle.js | 106 ----
.../mastodon/features/ui/components/bundle.jsx | 106 ++++
.../features/ui/components/bundle_column_error.js | 162 -----
.../features/ui/components/bundle_column_error.jsx | 162 +++++
.../features/ui/components/bundle_modal_error.js | 53 --
.../features/ui/components/bundle_modal_error.jsx | 53 ++
.../mastodon/features/ui/components/column.js | 72 ---
.../mastodon/features/ui/components/column.jsx | 72 +++
.../features/ui/components/column_header.js | 38 --
.../features/ui/components/column_header.jsx | 38 ++
.../mastodon/features/ui/components/column_link.js | 41 --
.../features/ui/components/column_link.jsx | 41 ++
.../features/ui/components/column_loading.js | 32 -
.../features/ui/components/column_loading.jsx | 32 +
.../features/ui/components/column_subheading.js | 16 -
.../features/ui/components/column_subheading.jsx | 16 +
.../features/ui/components/columns_area.js | 181 ------
.../features/ui/components/columns_area.jsx | 181 ++++++
.../ui/components/compare_history_modal.js | 99 ---
.../ui/components/compare_history_modal.jsx | 99 +++
.../features/ui/components/compose_panel.js | 68 --
.../features/ui/components/compose_panel.jsx | 68 ++
.../features/ui/components/confirmation_modal.js | 70 ---
.../features/ui/components/confirmation_modal.jsx | 70 +++
.../ui/components/disabled_account_banner.js | 92 ---
.../ui/components/disabled_account_banner.jsx | 92 +++
.../features/ui/components/drawer_loading.js | 11 -
.../features/ui/components/drawer_loading.jsx | 11 +
.../mastodon/features/ui/components/embed_modal.js | 97 ---
.../features/ui/components/embed_modal.jsx | 97 +++
.../features/ui/components/filter_modal.js | 134 ----
.../features/ui/components/filter_modal.jsx | 134 ++++
.../features/ui/components/focal_point_modal.js | 430 -------------
.../features/ui/components/focal_point_modal.jsx | 430 +++++++++++++
.../ui/components/follow_requests_column_link.js | 51 --
.../ui/components/follow_requests_column_link.jsx | 51 ++
.../mastodon/features/ui/components/header.js | 87 ---
.../mastodon/features/ui/components/header.jsx | 87 +++
.../features/ui/components/image_loader.js | 168 -----
.../features/ui/components/image_loader.jsx | 168 +++++
.../mastodon/features/ui/components/image_modal.js | 59 --
.../features/ui/components/image_modal.jsx | 59 ++
.../mastodon/features/ui/components/link_footer.js | 102 ---
.../features/ui/components/link_footer.jsx | 102 +++
.../mastodon/features/ui/components/list_panel.js | 55 --
.../mastodon/features/ui/components/list_panel.jsx | 55 ++
.../mastodon/features/ui/components/media_modal.js | 250 --------
.../features/ui/components/media_modal.jsx | 250 ++++++++
.../features/ui/components/modal_loading.js | 20 -
.../features/ui/components/modal_loading.jsx | 20 +
.../mastodon/features/ui/components/modal_root.js | 133 ----
.../mastodon/features/ui/components/modal_root.jsx | 133 ++++
.../mastodon/features/ui/components/mute_modal.js | 140 -----
.../mastodon/features/ui/components/mute_modal.jsx | 140 +++++
.../features/ui/components/navigation_panel.js | 107 ----
.../features/ui/components/navigation_panel.jsx | 107 ++++
.../features/ui/components/report_modal.js | 219 -------
.../features/ui/components/report_modal.jsx | 219 +++++++
.../features/ui/components/sign_in_banner.js | 40 --
.../features/ui/components/sign_in_banner.jsx | 40 ++
.../mastodon/features/ui/components/upload_area.js | 52 --
.../features/ui/components/upload_area.jsx | 52 ++
.../mastodon/features/ui/components/video_modal.js | 62 --
.../features/ui/components/video_modal.jsx | 62 ++
.../features/ui/components/zoomable_image.js | 450 --------------
.../features/ui/components/zoomable_image.jsx | 450 ++++++++++++++
app/javascript/mastodon/features/ui/index.js | 589 ------------------
app/javascript/mastodon/features/ui/index.jsx | 589 ++++++++++++++++++
.../features/ui/util/react_router_helpers.js | 101 ---
.../features/ui/util/react_router_helpers.jsx | 101 +++
.../mastodon/features/ui/util/reduced_motion.js | 44 --
.../mastodon/features/ui/util/reduced_motion.jsx | 44 ++
app/javascript/mastodon/features/video/index.js | 655 --------------------
app/javascript/mastodon/features/video/index.jsx | 655 ++++++++++++++++++++
app/javascript/mastodon/main.js | 46 --
app/javascript/mastodon/main.jsx | 46 ++
app/javascript/mastodon/utils/icons.js | 15 -
app/javascript/mastodon/utils/icons.jsx | 15 +
app/javascript/packs/admin.js | 245 --------
app/javascript/packs/admin.jsx | 245 ++++++++
app/javascript/packs/public.js | 332 ----------
app/javascript/packs/public.jsx | 332 ++++++++++
app/javascript/packs/share.js | 26 -
app/javascript/packs/share.jsx | 26 +
config/webpacker.yml | 1 +
package.json | 2 +-
491 files changed, 29087 insertions(+), 29079 deletions(-)
delete mode 100644 app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.js.snap
create mode 100644 app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.jsx.snap
delete mode 100644 app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap
create mode 100644 app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.jsx.snap
delete mode 100644 app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.js.snap
create mode 100644 app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.jsx.snap
delete mode 100644 app/javascript/mastodon/components/__tests__/__snapshots__/button-test.js.snap
create mode 100644 app/javascript/mastodon/components/__tests__/__snapshots__/button-test.jsx.snap
delete mode 100644 app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.js.snap
create mode 100644 app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.jsx.snap
delete mode 100644 app/javascript/mastodon/components/__tests__/autosuggest_emoji-test.js
create mode 100644 app/javascript/mastodon/components/__tests__/autosuggest_emoji-test.jsx
delete mode 100644 app/javascript/mastodon/components/__tests__/avatar-test.js
create mode 100644 app/javascript/mastodon/components/__tests__/avatar-test.jsx
delete mode 100644 app/javascript/mastodon/components/__tests__/avatar_overlay-test.js
create mode 100644 app/javascript/mastodon/components/__tests__/avatar_overlay-test.jsx
delete mode 100644 app/javascript/mastodon/components/__tests__/button-test.js
create mode 100644 app/javascript/mastodon/components/__tests__/button-test.jsx
delete mode 100644 app/javascript/mastodon/components/__tests__/display_name-test.js
create mode 100644 app/javascript/mastodon/components/__tests__/display_name-test.jsx
delete mode 100644 app/javascript/mastodon/components/account.js
create mode 100644 app/javascript/mastodon/components/account.jsx
delete mode 100644 app/javascript/mastodon/components/admin/Counter.js
create mode 100644 app/javascript/mastodon/components/admin/Counter.jsx
delete mode 100644 app/javascript/mastodon/components/admin/Dimension.js
create mode 100644 app/javascript/mastodon/components/admin/Dimension.jsx
delete mode 100644 app/javascript/mastodon/components/admin/ReportReasonSelector.js
create mode 100644 app/javascript/mastodon/components/admin/ReportReasonSelector.jsx
delete mode 100644 app/javascript/mastodon/components/admin/Retention.js
create mode 100644 app/javascript/mastodon/components/admin/Retention.jsx
delete mode 100644 app/javascript/mastodon/components/admin/Trends.js
create mode 100644 app/javascript/mastodon/components/admin/Trends.jsx
delete mode 100644 app/javascript/mastodon/components/animated_number.js
create mode 100644 app/javascript/mastodon/components/animated_number.jsx
delete mode 100644 app/javascript/mastodon/components/attachment_list.js
create mode 100644 app/javascript/mastodon/components/attachment_list.jsx
delete mode 100644 app/javascript/mastodon/components/autosuggest_emoji.js
create mode 100644 app/javascript/mastodon/components/autosuggest_emoji.jsx
delete mode 100644 app/javascript/mastodon/components/autosuggest_hashtag.js
create mode 100644 app/javascript/mastodon/components/autosuggest_hashtag.jsx
delete mode 100644 app/javascript/mastodon/components/autosuggest_input.js
create mode 100644 app/javascript/mastodon/components/autosuggest_input.jsx
delete mode 100644 app/javascript/mastodon/components/autosuggest_textarea.js
create mode 100644 app/javascript/mastodon/components/autosuggest_textarea.jsx
delete mode 100644 app/javascript/mastodon/components/avatar.js
create mode 100644 app/javascript/mastodon/components/avatar.jsx
delete mode 100644 app/javascript/mastodon/components/avatar_composite.js
create mode 100644 app/javascript/mastodon/components/avatar_composite.jsx
delete mode 100644 app/javascript/mastodon/components/avatar_overlay.js
create mode 100644 app/javascript/mastodon/components/avatar_overlay.jsx
delete mode 100644 app/javascript/mastodon/components/blurhash.js
create mode 100644 app/javascript/mastodon/components/blurhash.jsx
delete mode 100644 app/javascript/mastodon/components/button.js
create mode 100644 app/javascript/mastodon/components/button.jsx
delete mode 100644 app/javascript/mastodon/components/check.js
create mode 100644 app/javascript/mastodon/components/check.jsx
delete mode 100644 app/javascript/mastodon/components/column.js
create mode 100644 app/javascript/mastodon/components/column.jsx
delete mode 100644 app/javascript/mastodon/components/column_back_button.js
create mode 100644 app/javascript/mastodon/components/column_back_button.jsx
delete mode 100644 app/javascript/mastodon/components/column_back_button_slim.js
create mode 100644 app/javascript/mastodon/components/column_back_button_slim.jsx
delete mode 100644 app/javascript/mastodon/components/column_header.js
create mode 100644 app/javascript/mastodon/components/column_header.jsx
delete mode 100644 app/javascript/mastodon/components/common_counter.js
create mode 100644 app/javascript/mastodon/components/common_counter.jsx
delete mode 100644 app/javascript/mastodon/components/dismissable_banner.js
create mode 100644 app/javascript/mastodon/components/dismissable_banner.jsx
delete mode 100644 app/javascript/mastodon/components/display_name.js
create mode 100644 app/javascript/mastodon/components/display_name.jsx
delete mode 100644 app/javascript/mastodon/components/domain.js
create mode 100644 app/javascript/mastodon/components/domain.jsx
delete mode 100644 app/javascript/mastodon/components/dropdown_menu.js
create mode 100644 app/javascript/mastodon/components/dropdown_menu.jsx
delete mode 100644 app/javascript/mastodon/components/edited_timestamp/index.js
create mode 100644 app/javascript/mastodon/components/edited_timestamp/index.jsx
delete mode 100644 app/javascript/mastodon/components/error_boundary.js
create mode 100644 app/javascript/mastodon/components/error_boundary.jsx
delete mode 100644 app/javascript/mastodon/components/gifv.js
create mode 100644 app/javascript/mastodon/components/gifv.jsx
delete mode 100644 app/javascript/mastodon/components/hashtag.js
create mode 100644 app/javascript/mastodon/components/hashtag.jsx
delete mode 100644 app/javascript/mastodon/components/icon.js
create mode 100644 app/javascript/mastodon/components/icon.jsx
delete mode 100644 app/javascript/mastodon/components/icon_button.js
create mode 100644 app/javascript/mastodon/components/icon_button.jsx
delete mode 100644 app/javascript/mastodon/components/icon_with_badge.js
create mode 100644 app/javascript/mastodon/components/icon_with_badge.jsx
delete mode 100644 app/javascript/mastodon/components/image.js
create mode 100644 app/javascript/mastodon/components/image.jsx
delete mode 100644 app/javascript/mastodon/components/inline_account.js
create mode 100644 app/javascript/mastodon/components/inline_account.jsx
delete mode 100644 app/javascript/mastodon/components/intersection_observer_article.js
create mode 100644 app/javascript/mastodon/components/intersection_observer_article.jsx
delete mode 100644 app/javascript/mastodon/components/load_gap.js
create mode 100644 app/javascript/mastodon/components/load_gap.jsx
delete mode 100644 app/javascript/mastodon/components/load_more.js
create mode 100644 app/javascript/mastodon/components/load_more.jsx
delete mode 100644 app/javascript/mastodon/components/load_pending.js
create mode 100644 app/javascript/mastodon/components/load_pending.jsx
delete mode 100644 app/javascript/mastodon/components/loading_indicator.js
create mode 100644 app/javascript/mastodon/components/loading_indicator.jsx
delete mode 100644 app/javascript/mastodon/components/logo.js
create mode 100644 app/javascript/mastodon/components/logo.jsx
delete mode 100644 app/javascript/mastodon/components/media_attachments.js
create mode 100644 app/javascript/mastodon/components/media_attachments.jsx
delete mode 100644 app/javascript/mastodon/components/media_gallery.js
create mode 100644 app/javascript/mastodon/components/media_gallery.jsx
delete mode 100644 app/javascript/mastodon/components/missing_indicator.js
create mode 100644 app/javascript/mastodon/components/missing_indicator.jsx
delete mode 100644 app/javascript/mastodon/components/modal_root.js
create mode 100644 app/javascript/mastodon/components/modal_root.jsx
delete mode 100644 app/javascript/mastodon/components/navigation_portal.js
create mode 100644 app/javascript/mastodon/components/navigation_portal.jsx
delete mode 100644 app/javascript/mastodon/components/not_signed_in_indicator.js
create mode 100644 app/javascript/mastodon/components/not_signed_in_indicator.jsx
delete mode 100644 app/javascript/mastodon/components/picture_in_picture_placeholder.js
create mode 100644 app/javascript/mastodon/components/picture_in_picture_placeholder.jsx
delete mode 100644 app/javascript/mastodon/components/poll.js
create mode 100644 app/javascript/mastodon/components/poll.jsx
delete mode 100644 app/javascript/mastodon/components/radio_button.js
create mode 100644 app/javascript/mastodon/components/radio_button.jsx
delete mode 100644 app/javascript/mastodon/components/regeneration_indicator.js
create mode 100644 app/javascript/mastodon/components/regeneration_indicator.jsx
delete mode 100644 app/javascript/mastodon/components/relative_timestamp.js
create mode 100644 app/javascript/mastodon/components/relative_timestamp.jsx
delete mode 100644 app/javascript/mastodon/components/scrollable_list.js
create mode 100644 app/javascript/mastodon/components/scrollable_list.jsx
delete mode 100644 app/javascript/mastodon/components/server_banner.js
create mode 100644 app/javascript/mastodon/components/server_banner.jsx
delete mode 100644 app/javascript/mastodon/components/short_number.js
create mode 100644 app/javascript/mastodon/components/short_number.jsx
delete mode 100644 app/javascript/mastodon/components/skeleton.js
create mode 100644 app/javascript/mastodon/components/skeleton.jsx
delete mode 100644 app/javascript/mastodon/components/status.js
create mode 100644 app/javascript/mastodon/components/status.jsx
delete mode 100644 app/javascript/mastodon/components/status_action_bar.js
create mode 100644 app/javascript/mastodon/components/status_action_bar.jsx
delete mode 100644 app/javascript/mastodon/components/status_content.js
create mode 100644 app/javascript/mastodon/components/status_content.jsx
delete mode 100644 app/javascript/mastodon/components/status_list.js
create mode 100644 app/javascript/mastodon/components/status_list.jsx
delete mode 100644 app/javascript/mastodon/components/timeline_hint.js
create mode 100644 app/javascript/mastodon/components/timeline_hint.jsx
delete mode 100644 app/javascript/mastodon/containers/account_container.js
create mode 100644 app/javascript/mastodon/containers/account_container.jsx
delete mode 100644 app/javascript/mastodon/containers/admin_component.js
create mode 100644 app/javascript/mastodon/containers/admin_component.jsx
delete mode 100644 app/javascript/mastodon/containers/compose_container.js
create mode 100644 app/javascript/mastodon/containers/compose_container.jsx
delete mode 100644 app/javascript/mastodon/containers/domain_container.js
create mode 100644 app/javascript/mastodon/containers/domain_container.jsx
delete mode 100644 app/javascript/mastodon/containers/mastodon.js
create mode 100644 app/javascript/mastodon/containers/mastodon.jsx
delete mode 100644 app/javascript/mastodon/containers/media_container.js
create mode 100644 app/javascript/mastodon/containers/media_container.jsx
delete mode 100644 app/javascript/mastodon/containers/status_container.js
create mode 100644 app/javascript/mastodon/containers/status_container.jsx
delete mode 100644 app/javascript/mastodon/features/about/index.js
create mode 100644 app/javascript/mastodon/features/about/index.jsx
delete mode 100644 app/javascript/mastodon/features/account/components/account_note.js
create mode 100644 app/javascript/mastodon/features/account/components/account_note.jsx
delete mode 100644 app/javascript/mastodon/features/account/components/featured_tags.js
create mode 100644 app/javascript/mastodon/features/account/components/featured_tags.jsx
delete mode 100644 app/javascript/mastodon/features/account/components/follow_request_note.js
create mode 100644 app/javascript/mastodon/features/account/components/follow_request_note.jsx
delete mode 100644 app/javascript/mastodon/features/account/components/header.js
create mode 100644 app/javascript/mastodon/features/account/components/header.jsx
delete mode 100644 app/javascript/mastodon/features/account/navigation.js
create mode 100644 app/javascript/mastodon/features/account/navigation.jsx
delete mode 100644 app/javascript/mastodon/features/account_gallery/components/media_item.js
create mode 100644 app/javascript/mastodon/features/account_gallery/components/media_item.jsx
delete mode 100644 app/javascript/mastodon/features/account_gallery/index.js
create mode 100644 app/javascript/mastodon/features/account_gallery/index.jsx
delete mode 100644 app/javascript/mastodon/features/account_timeline/components/header.js
create mode 100644 app/javascript/mastodon/features/account_timeline/components/header.jsx
delete mode 100644 app/javascript/mastodon/features/account_timeline/components/limited_account_hint.js
create mode 100644 app/javascript/mastodon/features/account_timeline/components/limited_account_hint.jsx
delete mode 100644 app/javascript/mastodon/features/account_timeline/components/moved_note.js
create mode 100644 app/javascript/mastodon/features/account_timeline/components/moved_note.jsx
delete mode 100644 app/javascript/mastodon/features/account_timeline/containers/header_container.js
create mode 100644 app/javascript/mastodon/features/account_timeline/containers/header_container.jsx
delete mode 100644 app/javascript/mastodon/features/account_timeline/index.js
create mode 100644 app/javascript/mastodon/features/account_timeline/index.jsx
delete mode 100644 app/javascript/mastodon/features/audio/index.js
create mode 100644 app/javascript/mastodon/features/audio/index.jsx
delete mode 100644 app/javascript/mastodon/features/blocks/index.js
create mode 100644 app/javascript/mastodon/features/blocks/index.jsx
delete mode 100644 app/javascript/mastodon/features/bookmarked_statuses/index.js
create mode 100644 app/javascript/mastodon/features/bookmarked_statuses/index.jsx
delete mode 100644 app/javascript/mastodon/features/closed_registrations_modal/index.js
create mode 100644 app/javascript/mastodon/features/closed_registrations_modal/index.jsx
delete mode 100644 app/javascript/mastodon/features/community_timeline/components/column_settings.js
create mode 100644 app/javascript/mastodon/features/community_timeline/components/column_settings.jsx
delete mode 100644 app/javascript/mastodon/features/community_timeline/index.js
create mode 100644 app/javascript/mastodon/features/community_timeline/index.jsx
delete mode 100644 app/javascript/mastodon/features/compose/components/action_bar.js
create mode 100644 app/javascript/mastodon/features/compose/components/action_bar.jsx
delete mode 100644 app/javascript/mastodon/features/compose/components/autosuggest_account.js
create mode 100644 app/javascript/mastodon/features/compose/components/autosuggest_account.jsx
delete mode 100644 app/javascript/mastodon/features/compose/components/character_counter.js
create mode 100644 app/javascript/mastodon/features/compose/components/character_counter.jsx
delete mode 100644 app/javascript/mastodon/features/compose/components/compose_form.js
create mode 100644 app/javascript/mastodon/features/compose/components/compose_form.jsx
delete mode 100644 app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
create mode 100644 app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx
delete mode 100644 app/javascript/mastodon/features/compose/components/language_dropdown.js
create mode 100644 app/javascript/mastodon/features/compose/components/language_dropdown.jsx
delete mode 100644 app/javascript/mastodon/features/compose/components/navigation_bar.js
create mode 100644 app/javascript/mastodon/features/compose/components/navigation_bar.jsx
delete mode 100644 app/javascript/mastodon/features/compose/components/poll_button.js
create mode 100644 app/javascript/mastodon/features/compose/components/poll_button.jsx
delete mode 100644 app/javascript/mastodon/features/compose/components/poll_form.js
create mode 100644 app/javascript/mastodon/features/compose/components/poll_form.jsx
delete mode 100644 app/javascript/mastodon/features/compose/components/privacy_dropdown.js
create mode 100644 app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx
delete mode 100644 app/javascript/mastodon/features/compose/components/reply_indicator.js
create mode 100644 app/javascript/mastodon/features/compose/components/reply_indicator.jsx
delete mode 100644 app/javascript/mastodon/features/compose/components/search.js
create mode 100644 app/javascript/mastodon/features/compose/components/search.jsx
delete mode 100644 app/javascript/mastodon/features/compose/components/search_results.js
create mode 100644 app/javascript/mastodon/features/compose/components/search_results.jsx
delete mode 100644 app/javascript/mastodon/features/compose/components/text_icon_button.js
create mode 100644 app/javascript/mastodon/features/compose/components/text_icon_button.jsx
delete mode 100644 app/javascript/mastodon/features/compose/components/upload.js
create mode 100644 app/javascript/mastodon/features/compose/components/upload.jsx
delete mode 100644 app/javascript/mastodon/features/compose/components/upload_button.js
create mode 100644 app/javascript/mastodon/features/compose/components/upload_button.jsx
delete mode 100644 app/javascript/mastodon/features/compose/components/upload_form.js
create mode 100644 app/javascript/mastodon/features/compose/components/upload_form.jsx
delete mode 100644 app/javascript/mastodon/features/compose/components/upload_progress.js
create mode 100644 app/javascript/mastodon/features/compose/components/upload_progress.jsx
delete mode 100644 app/javascript/mastodon/features/compose/components/warning.js
create mode 100644 app/javascript/mastodon/features/compose/components/warning.jsx
delete mode 100644 app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
create mode 100644 app/javascript/mastodon/features/compose/containers/sensitive_button_container.jsx
delete mode 100644 app/javascript/mastodon/features/compose/containers/warning_container.js
create mode 100644 app/javascript/mastodon/features/compose/containers/warning_container.jsx
delete mode 100644 app/javascript/mastodon/features/compose/index.js
create mode 100644 app/javascript/mastodon/features/compose/index.jsx
delete mode 100644 app/javascript/mastodon/features/direct_timeline/components/conversation.js
create mode 100644 app/javascript/mastodon/features/direct_timeline/components/conversation.jsx
delete mode 100644 app/javascript/mastodon/features/direct_timeline/components/conversations_list.js
create mode 100644 app/javascript/mastodon/features/direct_timeline/components/conversations_list.jsx
delete mode 100644 app/javascript/mastodon/features/direct_timeline/index.js
create mode 100644 app/javascript/mastodon/features/direct_timeline/index.jsx
delete mode 100644 app/javascript/mastodon/features/directory/components/account_card.js
create mode 100644 app/javascript/mastodon/features/directory/components/account_card.jsx
delete mode 100644 app/javascript/mastodon/features/directory/index.js
create mode 100644 app/javascript/mastodon/features/directory/index.jsx
delete mode 100644 app/javascript/mastodon/features/domain_blocks/index.js
create mode 100644 app/javascript/mastodon/features/domain_blocks/index.jsx
delete mode 100644 app/javascript/mastodon/features/explore/components/story.js
create mode 100644 app/javascript/mastodon/features/explore/components/story.jsx
delete mode 100644 app/javascript/mastodon/features/explore/index.js
create mode 100644 app/javascript/mastodon/features/explore/index.jsx
delete mode 100644 app/javascript/mastodon/features/explore/links.js
create mode 100644 app/javascript/mastodon/features/explore/links.jsx
delete mode 100644 app/javascript/mastodon/features/explore/results.js
create mode 100644 app/javascript/mastodon/features/explore/results.jsx
delete mode 100644 app/javascript/mastodon/features/explore/statuses.js
create mode 100644 app/javascript/mastodon/features/explore/statuses.jsx
delete mode 100644 app/javascript/mastodon/features/explore/suggestions.js
create mode 100644 app/javascript/mastodon/features/explore/suggestions.jsx
delete mode 100644 app/javascript/mastodon/features/explore/tags.js
create mode 100644 app/javascript/mastodon/features/explore/tags.jsx
delete mode 100644 app/javascript/mastodon/features/favourited_statuses/index.js
create mode 100644 app/javascript/mastodon/features/favourited_statuses/index.jsx
delete mode 100644 app/javascript/mastodon/features/favourites/index.js
create mode 100644 app/javascript/mastodon/features/favourites/index.jsx
delete mode 100644 app/javascript/mastodon/features/filters/added_to_filter.js
create mode 100644 app/javascript/mastodon/features/filters/added_to_filter.jsx
delete mode 100644 app/javascript/mastodon/features/filters/select_filter.js
create mode 100644 app/javascript/mastodon/features/filters/select_filter.jsx
delete mode 100644 app/javascript/mastodon/features/follow_recommendations/components/account.js
create mode 100644 app/javascript/mastodon/features/follow_recommendations/components/account.jsx
delete mode 100644 app/javascript/mastodon/features/follow_recommendations/index.js
create mode 100644 app/javascript/mastodon/features/follow_recommendations/index.jsx
delete mode 100644 app/javascript/mastodon/features/follow_requests/components/account_authorize.js
create mode 100644 app/javascript/mastodon/features/follow_requests/components/account_authorize.jsx
delete mode 100644 app/javascript/mastodon/features/follow_requests/index.js
create mode 100644 app/javascript/mastodon/features/follow_requests/index.jsx
delete mode 100644 app/javascript/mastodon/features/followed_tags/index.js
create mode 100644 app/javascript/mastodon/features/followed_tags/index.jsx
delete mode 100644 app/javascript/mastodon/features/followers/index.js
create mode 100644 app/javascript/mastodon/features/followers/index.jsx
delete mode 100644 app/javascript/mastodon/features/following/index.js
create mode 100644 app/javascript/mastodon/features/following/index.jsx
delete mode 100644 app/javascript/mastodon/features/generic_not_found/index.js
create mode 100644 app/javascript/mastodon/features/generic_not_found/index.jsx
delete mode 100644 app/javascript/mastodon/features/getting_started/components/announcements.js
create mode 100644 app/javascript/mastodon/features/getting_started/components/announcements.jsx
delete mode 100644 app/javascript/mastodon/features/getting_started/components/trends.js
create mode 100644 app/javascript/mastodon/features/getting_started/components/trends.jsx
delete mode 100644 app/javascript/mastodon/features/getting_started/index.js
create mode 100644 app/javascript/mastodon/features/getting_started/index.jsx
delete mode 100644 app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js
create mode 100644 app/javascript/mastodon/features/hashtag_timeline/components/column_settings.jsx
delete mode 100644 app/javascript/mastodon/features/hashtag_timeline/index.js
create mode 100644 app/javascript/mastodon/features/hashtag_timeline/index.jsx
delete mode 100644 app/javascript/mastodon/features/home_timeline/components/column_settings.js
create mode 100644 app/javascript/mastodon/features/home_timeline/components/column_settings.jsx
delete mode 100644 app/javascript/mastodon/features/home_timeline/index.js
create mode 100644 app/javascript/mastodon/features/home_timeline/index.jsx
delete mode 100644 app/javascript/mastodon/features/interaction_modal/index.js
create mode 100644 app/javascript/mastodon/features/interaction_modal/index.jsx
delete mode 100644 app/javascript/mastodon/features/keyboard_shortcuts/index.js
create mode 100644 app/javascript/mastodon/features/keyboard_shortcuts/index.jsx
delete mode 100644 app/javascript/mastodon/features/list_adder/components/account.js
create mode 100644 app/javascript/mastodon/features/list_adder/components/account.jsx
delete mode 100644 app/javascript/mastodon/features/list_adder/components/list.js
create mode 100644 app/javascript/mastodon/features/list_adder/components/list.jsx
delete mode 100644 app/javascript/mastodon/features/list_adder/index.js
create mode 100644 app/javascript/mastodon/features/list_adder/index.jsx
delete mode 100644 app/javascript/mastodon/features/list_editor/components/account.js
create mode 100644 app/javascript/mastodon/features/list_editor/components/account.jsx
delete mode 100644 app/javascript/mastodon/features/list_editor/components/edit_list_form.js
create mode 100644 app/javascript/mastodon/features/list_editor/components/edit_list_form.jsx
delete mode 100644 app/javascript/mastodon/features/list_editor/components/search.js
create mode 100644 app/javascript/mastodon/features/list_editor/components/search.jsx
delete mode 100644 app/javascript/mastodon/features/list_editor/index.js
create mode 100644 app/javascript/mastodon/features/list_editor/index.jsx
delete mode 100644 app/javascript/mastodon/features/list_timeline/index.js
create mode 100644 app/javascript/mastodon/features/list_timeline/index.jsx
delete mode 100644 app/javascript/mastodon/features/lists/components/new_list_form.js
create mode 100644 app/javascript/mastodon/features/lists/components/new_list_form.jsx
delete mode 100644 app/javascript/mastodon/features/lists/index.js
create mode 100644 app/javascript/mastodon/features/lists/index.jsx
delete mode 100644 app/javascript/mastodon/features/mutes/index.js
create mode 100644 app/javascript/mastodon/features/mutes/index.jsx
delete mode 100644 app/javascript/mastodon/features/notifications/components/clear_column_button.js
create mode 100644 app/javascript/mastodon/features/notifications/components/clear_column_button.jsx
delete mode 100644 app/javascript/mastodon/features/notifications/components/column_settings.js
create mode 100644 app/javascript/mastodon/features/notifications/components/column_settings.jsx
delete mode 100644 app/javascript/mastodon/features/notifications/components/filter_bar.js
create mode 100644 app/javascript/mastodon/features/notifications/components/filter_bar.jsx
delete mode 100644 app/javascript/mastodon/features/notifications/components/follow_request.js
create mode 100644 app/javascript/mastodon/features/notifications/components/follow_request.jsx
delete mode 100644 app/javascript/mastodon/features/notifications/components/grant_permission_button.js
create mode 100644 app/javascript/mastodon/features/notifications/components/grant_permission_button.jsx
delete mode 100644 app/javascript/mastodon/features/notifications/components/notification.js
create mode 100644 app/javascript/mastodon/features/notifications/components/notification.jsx
delete mode 100644 app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js
create mode 100644 app/javascript/mastodon/features/notifications/components/notifications_permission_banner.jsx
delete mode 100644 app/javascript/mastodon/features/notifications/components/report.js
create mode 100644 app/javascript/mastodon/features/notifications/components/report.jsx
delete mode 100644 app/javascript/mastodon/features/notifications/components/setting_toggle.js
create mode 100644 app/javascript/mastodon/features/notifications/components/setting_toggle.jsx
delete mode 100644 app/javascript/mastodon/features/notifications/index.js
create mode 100644 app/javascript/mastodon/features/notifications/index.jsx
delete mode 100644 app/javascript/mastodon/features/picture_in_picture/components/footer.js
create mode 100644 app/javascript/mastodon/features/picture_in_picture/components/footer.jsx
delete mode 100644 app/javascript/mastodon/features/picture_in_picture/components/header.js
create mode 100644 app/javascript/mastodon/features/picture_in_picture/components/header.jsx
delete mode 100644 app/javascript/mastodon/features/picture_in_picture/index.js
create mode 100644 app/javascript/mastodon/features/picture_in_picture/index.jsx
delete mode 100644 app/javascript/mastodon/features/pinned_statuses/index.js
create mode 100644 app/javascript/mastodon/features/pinned_statuses/index.jsx
delete mode 100644 app/javascript/mastodon/features/privacy_policy/index.js
create mode 100644 app/javascript/mastodon/features/privacy_policy/index.jsx
delete mode 100644 app/javascript/mastodon/features/public_timeline/components/column_settings.js
create mode 100644 app/javascript/mastodon/features/public_timeline/components/column_settings.jsx
delete mode 100644 app/javascript/mastodon/features/public_timeline/index.js
create mode 100644 app/javascript/mastodon/features/public_timeline/index.jsx
delete mode 100644 app/javascript/mastodon/features/reblogs/index.js
create mode 100644 app/javascript/mastodon/features/reblogs/index.jsx
delete mode 100644 app/javascript/mastodon/features/report/category.js
create mode 100644 app/javascript/mastodon/features/report/category.jsx
delete mode 100644 app/javascript/mastodon/features/report/comment.js
create mode 100644 app/javascript/mastodon/features/report/comment.jsx
delete mode 100644 app/javascript/mastodon/features/report/components/option.js
create mode 100644 app/javascript/mastodon/features/report/components/option.jsx
delete mode 100644 app/javascript/mastodon/features/report/components/status_check_box.js
create mode 100644 app/javascript/mastodon/features/report/components/status_check_box.jsx
delete mode 100644 app/javascript/mastodon/features/report/rules.js
create mode 100644 app/javascript/mastodon/features/report/rules.jsx
delete mode 100644 app/javascript/mastodon/features/report/statuses.js
create mode 100644 app/javascript/mastodon/features/report/statuses.jsx
delete mode 100644 app/javascript/mastodon/features/report/thanks.js
create mode 100644 app/javascript/mastodon/features/report/thanks.jsx
delete mode 100644 app/javascript/mastodon/features/standalone/compose/index.js
create mode 100644 app/javascript/mastodon/features/standalone/compose/index.jsx
delete mode 100644 app/javascript/mastodon/features/status/components/action_bar.js
create mode 100644 app/javascript/mastodon/features/status/components/action_bar.jsx
delete mode 100644 app/javascript/mastodon/features/status/components/card.js
create mode 100644 app/javascript/mastodon/features/status/components/card.jsx
delete mode 100644 app/javascript/mastodon/features/status/components/detailed_status.js
create mode 100644 app/javascript/mastodon/features/status/components/detailed_status.jsx
delete mode 100644 app/javascript/mastodon/features/status/index.js
create mode 100644 app/javascript/mastodon/features/status/index.jsx
delete mode 100644 app/javascript/mastodon/features/subscribed_languages_modal/index.js
create mode 100644 app/javascript/mastodon/features/subscribed_languages_modal/index.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/__tests__/column-test.js
create mode 100644 app/javascript/mastodon/features/ui/components/__tests__/column-test.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/actions_modal.js
create mode 100644 app/javascript/mastodon/features/ui/components/actions_modal.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/audio_modal.js
create mode 100644 app/javascript/mastodon/features/ui/components/audio_modal.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/block_modal.js
create mode 100644 app/javascript/mastodon/features/ui/components/block_modal.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/boost_modal.js
create mode 100644 app/javascript/mastodon/features/ui/components/boost_modal.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/bundle.js
create mode 100644 app/javascript/mastodon/features/ui/components/bundle.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/bundle_column_error.js
create mode 100644 app/javascript/mastodon/features/ui/components/bundle_column_error.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/bundle_modal_error.js
create mode 100644 app/javascript/mastodon/features/ui/components/bundle_modal_error.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/column.js
create mode 100644 app/javascript/mastodon/features/ui/components/column.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/column_header.js
create mode 100644 app/javascript/mastodon/features/ui/components/column_header.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/column_link.js
create mode 100644 app/javascript/mastodon/features/ui/components/column_link.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/column_loading.js
create mode 100644 app/javascript/mastodon/features/ui/components/column_loading.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/column_subheading.js
create mode 100644 app/javascript/mastodon/features/ui/components/column_subheading.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/columns_area.js
create mode 100644 app/javascript/mastodon/features/ui/components/columns_area.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/compare_history_modal.js
create mode 100644 app/javascript/mastodon/features/ui/components/compare_history_modal.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/compose_panel.js
create mode 100644 app/javascript/mastodon/features/ui/components/compose_panel.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/confirmation_modal.js
create mode 100644 app/javascript/mastodon/features/ui/components/confirmation_modal.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/disabled_account_banner.js
create mode 100644 app/javascript/mastodon/features/ui/components/disabled_account_banner.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/drawer_loading.js
create mode 100644 app/javascript/mastodon/features/ui/components/drawer_loading.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/embed_modal.js
create mode 100644 app/javascript/mastodon/features/ui/components/embed_modal.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/filter_modal.js
create mode 100644 app/javascript/mastodon/features/ui/components/filter_modal.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/focal_point_modal.js
create mode 100644 app/javascript/mastodon/features/ui/components/focal_point_modal.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/follow_requests_column_link.js
create mode 100644 app/javascript/mastodon/features/ui/components/follow_requests_column_link.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/header.js
create mode 100644 app/javascript/mastodon/features/ui/components/header.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/image_loader.js
create mode 100644 app/javascript/mastodon/features/ui/components/image_loader.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/image_modal.js
create mode 100644 app/javascript/mastodon/features/ui/components/image_modal.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/link_footer.js
create mode 100644 app/javascript/mastodon/features/ui/components/link_footer.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/list_panel.js
create mode 100644 app/javascript/mastodon/features/ui/components/list_panel.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/media_modal.js
create mode 100644 app/javascript/mastodon/features/ui/components/media_modal.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/modal_loading.js
create mode 100644 app/javascript/mastodon/features/ui/components/modal_loading.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/modal_root.js
create mode 100644 app/javascript/mastodon/features/ui/components/modal_root.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/mute_modal.js
create mode 100644 app/javascript/mastodon/features/ui/components/mute_modal.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/navigation_panel.js
create mode 100644 app/javascript/mastodon/features/ui/components/navigation_panel.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/report_modal.js
create mode 100644 app/javascript/mastodon/features/ui/components/report_modal.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/sign_in_banner.js
create mode 100644 app/javascript/mastodon/features/ui/components/sign_in_banner.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/upload_area.js
create mode 100644 app/javascript/mastodon/features/ui/components/upload_area.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/video_modal.js
create mode 100644 app/javascript/mastodon/features/ui/components/video_modal.jsx
delete mode 100644 app/javascript/mastodon/features/ui/components/zoomable_image.js
create mode 100644 app/javascript/mastodon/features/ui/components/zoomable_image.jsx
delete mode 100644 app/javascript/mastodon/features/ui/index.js
create mode 100644 app/javascript/mastodon/features/ui/index.jsx
delete mode 100644 app/javascript/mastodon/features/ui/util/react_router_helpers.js
create mode 100644 app/javascript/mastodon/features/ui/util/react_router_helpers.jsx
delete mode 100644 app/javascript/mastodon/features/ui/util/reduced_motion.js
create mode 100644 app/javascript/mastodon/features/ui/util/reduced_motion.jsx
delete mode 100644 app/javascript/mastodon/features/video/index.js
create mode 100644 app/javascript/mastodon/features/video/index.jsx
delete mode 100644 app/javascript/mastodon/main.js
create mode 100644 app/javascript/mastodon/main.jsx
delete mode 100644 app/javascript/mastodon/utils/icons.js
create mode 100644 app/javascript/mastodon/utils/icons.jsx
delete mode 100644 app/javascript/packs/admin.js
create mode 100644 app/javascript/packs/admin.jsx
delete mode 100644 app/javascript/packs/public.js
create mode 100644 app/javascript/packs/public.jsx
delete mode 100644 app/javascript/packs/share.js
create mode 100644 app/javascript/packs/share.jsx
(limited to 'app/javascript/mastodon/features/directory/index.js')
diff --git a/.eslintrc.js b/.eslintrc.js
index b5ab511f8..606a87e41 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -43,7 +43,7 @@ module.exports = {
version: 'detect',
},
'import/extensions': [
- '.js',
+ '.js', '.jsx',
],
'import/ignore': [
'node_modules',
@@ -52,6 +52,7 @@ module.exports = {
'import/resolver': {
node: {
paths: ['app/javascript'],
+ extensions: ['.js', '.jsx'],
},
},
},
@@ -111,6 +112,7 @@ module.exports = {
semi: 'error',
'valid-typeof': 'error',
+ 'react/jsx-filename-extension': ['error', { 'allow': 'as-needed' }],
'react/jsx-boolean-value': 'error',
'react/jsx-closing-bracket-location': ['error', 'line-aligned'],
'react/jsx-curly-spacing': 'error',
@@ -185,6 +187,7 @@ module.exports = {
'always',
{
js: 'never',
+ jsx: 'never',
},
],
'import/newline-after-import': 'error',
diff --git a/.github/workflows/lint-js.yml b/.github/workflows/lint-js.yml
index 3e0d9d1a9..44929f63d 100644
--- a/.github/workflows/lint-js.yml
+++ b/.github/workflows/lint-js.yml
@@ -10,6 +10,7 @@ on:
- '.prettier*'
- '.eslint*'
- '**/*.js'
+ - '**/*.jsx'
- '.github/workflows/lint-js.yml'
pull_request:
@@ -20,6 +21,7 @@ on:
- '.prettier*'
- '.eslint*'
- '**/*.js'
+ - '**/*.jsx'
- '.github/workflows/lint-js.yml'
jobs:
diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml
index 60b8e318e..6a1cacb3f 100644
--- a/.github/workflows/test-js.yml
+++ b/.github/workflows/test-js.yml
@@ -8,6 +8,7 @@ on:
- 'yarn.lock'
- '.nvmrc'
- '**/*.js'
+ - '**/*.jsx'
- '**/*.snap'
- '.github/workflows/test-js.yml'
@@ -17,6 +18,7 @@ on:
- 'yarn.lock'
- '.nvmrc'
- '**/*.js'
+ - '**/*.jsx'
- '**/*.snap'
- '.github/workflows/test-js.yml'
diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.js.snap
deleted file mode 100644
index 1c3727848..000000000
--- a/app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.js.snap
+++ /dev/null
@@ -1,27 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[` renders emoji with custom url 1`] = `
-
-
- :foobar:
-
-`;
-
-exports[` renders native emoji 1`] = `
-
-
- :foobar:
-
-`;
diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.jsx.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.jsx.snap
new file mode 100644
index 000000000..1c3727848
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.jsx.snap
@@ -0,0 +1,27 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` renders emoji with custom url 1`] = `
+
+
+ :foobar:
+
+`;
+
+exports[` renders native emoji 1`] = `
+
+
+ :foobar:
+
+`;
diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap
deleted file mode 100644
index 7fbdedeb2..000000000
--- a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap
+++ /dev/null
@@ -1,39 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[` Autoplay renders a animated avatar 1`] = `
-
-
-
-`;
-
-exports[` Still renders a still avatar 1`] = `
-
-
-
-`;
diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.jsx.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.jsx.snap
new file mode 100644
index 000000000..7fbdedeb2
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.jsx.snap
@@ -0,0 +1,39 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` Autoplay renders a animated avatar 1`] = `
+
+
+
+`;
+
+exports[` Still renders a still avatar 1`] = `
+
+
+
+`;
diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.js.snap
deleted file mode 100644
index f8385357a..000000000
--- a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.js.snap
+++ /dev/null
@@ -1,54 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`
-
-
-
-
-
-
-
-
-
-
-
-`;
diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.jsx.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.jsx.snap
new file mode 100644
index 000000000..f8385357a
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.jsx.snap
@@ -0,0 +1,54 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/button-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/button-test.js.snap
deleted file mode 100644
index bfc091d45..000000000
--- a/app/javascript/mastodon/components/__tests__/__snapshots__/button-test.js.snap
+++ /dev/null
@@ -1,66 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[` adds class "button-secondary" if props.secondary given 1`] = `
-
-`;
-
-exports[` renders a button element 1`] = `
-
-`;
-
-exports[` renders a disabled attribute if props.disabled given 1`] = `
-
-`;
-
-exports[` renders class="button--block" if props.block given 1`] = `
-
-`;
-
-exports[` renders the children 1`] = `
-
-
- children
-
-
-`;
-
-exports[` renders the given text 1`] = `
-
- foo
-
-`;
-
-exports[` renders the props.text instead of children 1`] = `
-
- foo
-
-`;
diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/button-test.jsx.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/button-test.jsx.snap
new file mode 100644
index 000000000..bfc091d45
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/__snapshots__/button-test.jsx.snap
@@ -0,0 +1,66 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` adds class "button-secondary" if props.secondary given 1`] = `
+
+`;
+
+exports[` renders a button element 1`] = `
+
+`;
+
+exports[` renders a disabled attribute if props.disabled given 1`] = `
+
+`;
+
+exports[` renders class="button--block" if props.block given 1`] = `
+
+`;
+
+exports[` renders the children 1`] = `
+
+
+ children
+
+
+`;
+
+exports[` renders the given text 1`] = `
+
+ foo
+
+`;
+
+exports[` renders the props.text instead of children 1`] = `
+
+ foo
+
+`;
diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.js.snap
deleted file mode 100644
index 9c37580d7..000000000
--- a/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.js.snap
+++ /dev/null
@@ -1,27 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[` renders display name + account name 1`] = `
-
-
- Foo
",
- }
- }
- />
-
-
-
- @
- bar@baz
-
-
-`;
diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.jsx.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.jsx.snap
new file mode 100644
index 000000000..9c37580d7
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.jsx.snap
@@ -0,0 +1,27 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` renders display name + account name 1`] = `
+
+
+ Foo",
+ }
+ }
+ />
+
+
+
+ @
+ bar@baz
+
+
+`;
diff --git a/app/javascript/mastodon/components/__tests__/autosuggest_emoji-test.js b/app/javascript/mastodon/components/__tests__/autosuggest_emoji-test.js
deleted file mode 100644
index 05616e444..000000000
--- a/app/javascript/mastodon/components/__tests__/autosuggest_emoji-test.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import React from 'react';
-import renderer from 'react-test-renderer';
-import AutosuggestEmoji from '../autosuggest_emoji';
-
-describe(' ', () => {
- it('renders native emoji', () => {
- const emoji = {
- native: '💙',
- colons: ':foobar:',
- };
- const component = renderer.create( );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-
- it('renders emoji with custom url', () => {
- const emoji = {
- custom: true,
- imageUrl: 'http://example.com/emoji.png',
- native: 'foobar',
- colons: ':foobar:',
- };
- const component = renderer.create( );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-});
diff --git a/app/javascript/mastodon/components/__tests__/autosuggest_emoji-test.jsx b/app/javascript/mastodon/components/__tests__/autosuggest_emoji-test.jsx
new file mode 100644
index 000000000..05616e444
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/autosuggest_emoji-test.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import AutosuggestEmoji from '../autosuggest_emoji';
+
+describe(' ', () => {
+ it('renders native emoji', () => {
+ const emoji = {
+ native: '💙',
+ colons: ':foobar:',
+ };
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders emoji with custom url', () => {
+ const emoji = {
+ custom: true,
+ imageUrl: 'http://example.com/emoji.png',
+ native: 'foobar',
+ colons: ':foobar:',
+ };
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+});
diff --git a/app/javascript/mastodon/components/__tests__/avatar-test.js b/app/javascript/mastodon/components/__tests__/avatar-test.js
deleted file mode 100644
index dd3f7b7d2..000000000
--- a/app/javascript/mastodon/components/__tests__/avatar-test.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import React from 'react';
-import renderer from 'react-test-renderer';
-import { fromJS } from 'immutable';
-import Avatar from '../avatar';
-
-describe(' ', () => {
- const account = fromJS({
- username: 'alice',
- acct: 'alice',
- display_name: 'Alice',
- avatar: '/animated/alice.gif',
- avatar_static: '/static/alice.jpg',
- });
-
- const size = 100;
-
- describe('Autoplay', () => {
- it('renders a animated avatar', () => {
- const component = renderer.create( );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
- });
-
- describe('Still', () => {
- it('renders a still avatar', () => {
- const component = renderer.create( );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
- });
-
- // TODO add autoplay test if possible
-});
diff --git a/app/javascript/mastodon/components/__tests__/avatar-test.jsx b/app/javascript/mastodon/components/__tests__/avatar-test.jsx
new file mode 100644
index 000000000..dd3f7b7d2
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/avatar-test.jsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import { fromJS } from 'immutable';
+import Avatar from '../avatar';
+
+describe(' ', () => {
+ const account = fromJS({
+ username: 'alice',
+ acct: 'alice',
+ display_name: 'Alice',
+ avatar: '/animated/alice.gif',
+ avatar_static: '/static/alice.jpg',
+ });
+
+ const size = 100;
+
+ describe('Autoplay', () => {
+ it('renders a animated avatar', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+ });
+
+ describe('Still', () => {
+ it('renders a still avatar', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+ });
+
+ // TODO add autoplay test if possible
+});
diff --git a/app/javascript/mastodon/components/__tests__/avatar_overlay-test.js b/app/javascript/mastodon/components/__tests__/avatar_overlay-test.js
deleted file mode 100644
index 44addea83..000000000
--- a/app/javascript/mastodon/components/__tests__/avatar_overlay-test.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import React from 'react';
-import renderer from 'react-test-renderer';
-import { fromJS } from 'immutable';
-import AvatarOverlay from '../avatar_overlay';
-
-describe(' {
- const account = fromJS({
- username: 'alice',
- acct: 'alice',
- display_name: 'Alice',
- avatar: '/animated/alice.gif',
- avatar_static: '/static/alice.jpg',
- });
-
- const friend = fromJS({
- username: 'eve',
- acct: 'eve@blackhat.lair',
- display_name: 'Evelyn',
- avatar: '/animated/eve.gif',
- avatar_static: '/static/eve.jpg',
- });
-
- it('renders a overlay avatar', () => {
- const component = renderer.create( );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-});
diff --git a/app/javascript/mastodon/components/__tests__/avatar_overlay-test.jsx b/app/javascript/mastodon/components/__tests__/avatar_overlay-test.jsx
new file mode 100644
index 000000000..44addea83
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/avatar_overlay-test.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import { fromJS } from 'immutable';
+import AvatarOverlay from '../avatar_overlay';
+
+describe(' {
+ const account = fromJS({
+ username: 'alice',
+ acct: 'alice',
+ display_name: 'Alice',
+ avatar: '/animated/alice.gif',
+ avatar_static: '/static/alice.jpg',
+ });
+
+ const friend = fromJS({
+ username: 'eve',
+ acct: 'eve@blackhat.lair',
+ display_name: 'Evelyn',
+ avatar: '/animated/eve.gif',
+ avatar_static: '/static/eve.jpg',
+ });
+
+ it('renders a overlay avatar', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+});
diff --git a/app/javascript/mastodon/components/__tests__/button-test.js b/app/javascript/mastodon/components/__tests__/button-test.js
deleted file mode 100644
index f5a649f70..000000000
--- a/app/javascript/mastodon/components/__tests__/button-test.js
+++ /dev/null
@@ -1,75 +0,0 @@
-import { render, fireEvent, screen } from '@testing-library/react';
-import React from 'react';
-import renderer from 'react-test-renderer';
-import Button from '../button';
-
-describe(' ', () => {
- it('renders a button element', () => {
- const component = renderer.create( );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-
- it('renders the given text', () => {
- const text = 'foo';
- const component = renderer.create( );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-
- it('handles click events using the given handler', () => {
- const handler = jest.fn();
- render(button );
- fireEvent.click(screen.getByText('button'));
-
- expect(handler.mock.calls.length).toEqual(1);
- });
-
- it('does not handle click events if props.disabled given', () => {
- const handler = jest.fn();
- render(button );
- fireEvent.click(screen.getByText('button'));
-
- expect(handler.mock.calls.length).toEqual(0);
- });
-
- it('renders a disabled attribute if props.disabled given', () => {
- const component = renderer.create( );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-
- it('renders the children', () => {
- const children = children
;
- const component = renderer.create({children} );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-
- it('renders the props.text instead of children', () => {
- const text = 'foo';
- const children = children
;
- const component = renderer.create({children} );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-
- it('renders class="button--block" if props.block given', () => {
- const component = renderer.create( );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-
- it('adds class "button-secondary" if props.secondary given', () => {
- const component = renderer.create( );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-});
diff --git a/app/javascript/mastodon/components/__tests__/button-test.jsx b/app/javascript/mastodon/components/__tests__/button-test.jsx
new file mode 100644
index 000000000..f5a649f70
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/button-test.jsx
@@ -0,0 +1,75 @@
+import { render, fireEvent, screen } from '@testing-library/react';
+import React from 'react';
+import renderer from 'react-test-renderer';
+import Button from '../button';
+
+describe(' ', () => {
+ it('renders a button element', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders the given text', () => {
+ const text = 'foo';
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('handles click events using the given handler', () => {
+ const handler = jest.fn();
+ render(button );
+ fireEvent.click(screen.getByText('button'));
+
+ expect(handler.mock.calls.length).toEqual(1);
+ });
+
+ it('does not handle click events if props.disabled given', () => {
+ const handler = jest.fn();
+ render(button );
+ fireEvent.click(screen.getByText('button'));
+
+ expect(handler.mock.calls.length).toEqual(0);
+ });
+
+ it('renders a disabled attribute if props.disabled given', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders the children', () => {
+ const children = children
;
+ const component = renderer.create({children} );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders the props.text instead of children', () => {
+ const text = 'foo';
+ const children = children
;
+ const component = renderer.create({children} );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders class="button--block" if props.block given', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('adds class "button-secondary" if props.secondary given', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+});
diff --git a/app/javascript/mastodon/components/__tests__/display_name-test.js b/app/javascript/mastodon/components/__tests__/display_name-test.js
deleted file mode 100644
index 0d040c4cd..000000000
--- a/app/javascript/mastodon/components/__tests__/display_name-test.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from 'react';
-import renderer from 'react-test-renderer';
-import { fromJS } from 'immutable';
-import DisplayName from '../display_name';
-
-describe(' ', () => {
- it('renders display name + account name', () => {
- const account = fromJS({
- username: 'bar',
- acct: 'bar@baz',
- display_name_html: 'Foo
',
- });
- const component = renderer.create( );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-});
diff --git a/app/javascript/mastodon/components/__tests__/display_name-test.jsx b/app/javascript/mastodon/components/__tests__/display_name-test.jsx
new file mode 100644
index 000000000..0d040c4cd
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/display_name-test.jsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import { fromJS } from 'immutable';
+import DisplayName from '../display_name';
+
+describe(' ', () => {
+ it('renders display name + account name', () => {
+ const account = fromJS({
+ username: 'bar',
+ acct: 'bar@baz',
+ display_name_html: 'Foo
',
+ });
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+});
diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js
deleted file mode 100644
index 7706c3f88..000000000
--- a/app/javascript/mastodon/components/account.js
+++ /dev/null
@@ -1,157 +0,0 @@
-import React, { Fragment } from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import Avatar from './avatar';
-import DisplayName from './display_name';
-import IconButton from './icon_button';
-import { defineMessages, injectIntl } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { me } from '../initial_state';
-import RelativeTimestamp from './relative_timestamp';
-import Skeleton from 'mastodon/components/skeleton';
-import { Link } from 'react-router-dom';
-
-const messages = defineMessages({
- follow: { id: 'account.follow', defaultMessage: 'Follow' },
- unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
- requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
- unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
- unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
- mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' },
- unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute notifications from @{name}' },
- mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
- block: { id: 'account.block', defaultMessage: 'Block @{name}' },
-});
-
-export default @injectIntl
-class Account extends ImmutablePureComponent {
-
- static propTypes = {
- size: PropTypes.number,
- account: ImmutablePropTypes.map,
- onFollow: PropTypes.func.isRequired,
- onBlock: PropTypes.func.isRequired,
- onMute: PropTypes.func.isRequired,
- onMuteNotifications: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- hidden: PropTypes.bool,
- actionIcon: PropTypes.string,
- actionTitle: PropTypes.string,
- defaultAction: PropTypes.string,
- onActionClick: PropTypes.func,
- };
-
- static defaultProps = {
- size: 46,
- };
-
- handleFollow = () => {
- this.props.onFollow(this.props.account);
- };
-
- handleBlock = () => {
- this.props.onBlock(this.props.account);
- };
-
- handleMute = () => {
- this.props.onMute(this.props.account);
- };
-
- handleMuteNotifications = () => {
- this.props.onMuteNotifications(this.props.account, true);
- };
-
- handleUnmuteNotifications = () => {
- this.props.onMuteNotifications(this.props.account, false);
- };
-
- handleAction = () => {
- this.props.onActionClick(this.props.account);
- };
-
- render () {
- const { account, intl, hidden, onActionClick, actionIcon, actionTitle, defaultAction, size } = this.props;
-
- if (!account) {
- return (
-
- );
- }
-
- if (hidden) {
- return (
-
- {account.get('display_name')}
- {account.get('username')}
-
- );
- }
-
- let buttons;
-
- if (actionIcon) {
- if (onActionClick) {
- buttons = ;
- }
- } else if (account.get('id') !== me && account.get('relationship', null) !== null) {
- const following = account.getIn(['relationship', 'following']);
- const requested = account.getIn(['relationship', 'requested']);
- const blocking = account.getIn(['relationship', 'blocking']);
- const muting = account.getIn(['relationship', 'muting']);
-
- if (requested) {
- buttons = ;
- } else if (blocking) {
- buttons = ;
- } else if (muting) {
- let hidingNotificationsButton;
- if (account.getIn(['relationship', 'muting_notifications'])) {
- hidingNotificationsButton = ;
- } else {
- hidingNotificationsButton = ;
- }
- buttons = (
-
-
- {hidingNotificationsButton}
-
- );
- } else if (defaultAction === 'mute') {
- buttons = ;
- } else if (defaultAction === 'block') {
- buttons = ;
- } else if (!account.get('moved') || following) {
- buttons = ;
- }
- }
-
- let mute_expires_at;
- if (account.get('mute_expires_at')) {
- mute_expires_at =
;
- }
-
- return (
-
-
-
-
- {mute_expires_at}
-
-
-
-
- {buttons}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/account.jsx b/app/javascript/mastodon/components/account.jsx
new file mode 100644
index 000000000..7706c3f88
--- /dev/null
+++ b/app/javascript/mastodon/components/account.jsx
@@ -0,0 +1,157 @@
+import React, { Fragment } from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Avatar from './avatar';
+import DisplayName from './display_name';
+import IconButton from './icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { me } from '../initial_state';
+import RelativeTimestamp from './relative_timestamp';
+import Skeleton from 'mastodon/components/skeleton';
+import { Link } from 'react-router-dom';
+
+const messages = defineMessages({
+ follow: { id: 'account.follow', defaultMessage: 'Follow' },
+ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+ requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
+ unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
+ unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+ mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' },
+ unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute notifications from @{name}' },
+ mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
+ block: { id: 'account.block', defaultMessage: 'Block @{name}' },
+});
+
+export default @injectIntl
+class Account extends ImmutablePureComponent {
+
+ static propTypes = {
+ size: PropTypes.number,
+ account: ImmutablePropTypes.map,
+ onFollow: PropTypes.func.isRequired,
+ onBlock: PropTypes.func.isRequired,
+ onMute: PropTypes.func.isRequired,
+ onMuteNotifications: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ hidden: PropTypes.bool,
+ actionIcon: PropTypes.string,
+ actionTitle: PropTypes.string,
+ defaultAction: PropTypes.string,
+ onActionClick: PropTypes.func,
+ };
+
+ static defaultProps = {
+ size: 46,
+ };
+
+ handleFollow = () => {
+ this.props.onFollow(this.props.account);
+ };
+
+ handleBlock = () => {
+ this.props.onBlock(this.props.account);
+ };
+
+ handleMute = () => {
+ this.props.onMute(this.props.account);
+ };
+
+ handleMuteNotifications = () => {
+ this.props.onMuteNotifications(this.props.account, true);
+ };
+
+ handleUnmuteNotifications = () => {
+ this.props.onMuteNotifications(this.props.account, false);
+ };
+
+ handleAction = () => {
+ this.props.onActionClick(this.props.account);
+ };
+
+ render () {
+ const { account, intl, hidden, onActionClick, actionIcon, actionTitle, defaultAction, size } = this.props;
+
+ if (!account) {
+ return (
+
+ );
+ }
+
+ if (hidden) {
+ return (
+
+ {account.get('display_name')}
+ {account.get('username')}
+
+ );
+ }
+
+ let buttons;
+
+ if (actionIcon) {
+ if (onActionClick) {
+ buttons = ;
+ }
+ } else if (account.get('id') !== me && account.get('relationship', null) !== null) {
+ const following = account.getIn(['relationship', 'following']);
+ const requested = account.getIn(['relationship', 'requested']);
+ const blocking = account.getIn(['relationship', 'blocking']);
+ const muting = account.getIn(['relationship', 'muting']);
+
+ if (requested) {
+ buttons = ;
+ } else if (blocking) {
+ buttons = ;
+ } else if (muting) {
+ let hidingNotificationsButton;
+ if (account.getIn(['relationship', 'muting_notifications'])) {
+ hidingNotificationsButton = ;
+ } else {
+ hidingNotificationsButton = ;
+ }
+ buttons = (
+
+
+ {hidingNotificationsButton}
+
+ );
+ } else if (defaultAction === 'mute') {
+ buttons = ;
+ } else if (defaultAction === 'block') {
+ buttons = ;
+ } else if (!account.get('moved') || following) {
+ buttons = ;
+ }
+ }
+
+ let mute_expires_at;
+ if (account.get('mute_expires_at')) {
+ mute_expires_at =
;
+ }
+
+ return (
+
+
+
+
+ {mute_expires_at}
+
+
+
+
+ {buttons}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/admin/Counter.js b/app/javascript/mastodon/components/admin/Counter.js
deleted file mode 100644
index 5a5b2b869..000000000
--- a/app/javascript/mastodon/components/admin/Counter.js
+++ /dev/null
@@ -1,117 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import api from 'mastodon/api';
-import { FormattedNumber } from 'react-intl';
-import { Sparklines, SparklinesCurve } from 'react-sparklines';
-import classNames from 'classnames';
-import Skeleton from 'mastodon/components/skeleton';
-
-const percIncrease = (a, b) => {
- let percent;
-
- if (b !== 0) {
- if (a !== 0) {
- percent = (b - a) / a;
- } else {
- percent = 1;
- }
- } else if (b === 0 && a === 0) {
- percent = 0;
- } else {
- percent = - 1;
- }
-
- return percent;
-};
-
-export default class Counter extends React.PureComponent {
-
- static propTypes = {
- measure: PropTypes.string.isRequired,
- start_at: PropTypes.string.isRequired,
- end_at: PropTypes.string.isRequired,
- label: PropTypes.string.isRequired,
- href: PropTypes.string,
- params: PropTypes.object,
- target: PropTypes.string,
- };
-
- state = {
- loading: true,
- data: null,
- };
-
- componentDidMount () {
- const { measure, start_at, end_at, params } = this.props;
-
- api().post('/api/v1/admin/measures', { keys: [measure], start_at, end_at, [measure]: params }).then(res => {
- this.setState({
- loading: false,
- data: res.data,
- });
- }).catch(err => {
- console.error(err);
- });
- }
-
- render () {
- const { label, href, target } = this.props;
- const { loading, data } = this.state;
-
- let content;
-
- if (loading) {
- content = (
-
-
-
-
- );
- } else {
- const measure = data[0];
- const percentChange = measure.previous_total && percIncrease(measure.previous_total * 1, measure.total * 1);
-
- content = (
-
- {measure.human_value || }
- {measure.previous_total && ( 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'} )}
-
- );
- }
-
- const inner = (
-
-
- {content}
-
-
-
- {label}
-
-
-
- {!loading && (
- x.value * 1)}>
-
-
- )}
-
-
- );
-
- if (href) {
- return (
-
- {inner}
-
- );
- } else {
- return (
-
- {inner}
-
- );
- }
- }
-
-}
diff --git a/app/javascript/mastodon/components/admin/Counter.jsx b/app/javascript/mastodon/components/admin/Counter.jsx
new file mode 100644
index 000000000..5a5b2b869
--- /dev/null
+++ b/app/javascript/mastodon/components/admin/Counter.jsx
@@ -0,0 +1,117 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'mastodon/api';
+import { FormattedNumber } from 'react-intl';
+import { Sparklines, SparklinesCurve } from 'react-sparklines';
+import classNames from 'classnames';
+import Skeleton from 'mastodon/components/skeleton';
+
+const percIncrease = (a, b) => {
+ let percent;
+
+ if (b !== 0) {
+ if (a !== 0) {
+ percent = (b - a) / a;
+ } else {
+ percent = 1;
+ }
+ } else if (b === 0 && a === 0) {
+ percent = 0;
+ } else {
+ percent = - 1;
+ }
+
+ return percent;
+};
+
+export default class Counter extends React.PureComponent {
+
+ static propTypes = {
+ measure: PropTypes.string.isRequired,
+ start_at: PropTypes.string.isRequired,
+ end_at: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ href: PropTypes.string,
+ params: PropTypes.object,
+ target: PropTypes.string,
+ };
+
+ state = {
+ loading: true,
+ data: null,
+ };
+
+ componentDidMount () {
+ const { measure, start_at, end_at, params } = this.props;
+
+ api().post('/api/v1/admin/measures', { keys: [measure], start_at, end_at, [measure]: params }).then(res => {
+ this.setState({
+ loading: false,
+ data: res.data,
+ });
+ }).catch(err => {
+ console.error(err);
+ });
+ }
+
+ render () {
+ const { label, href, target } = this.props;
+ const { loading, data } = this.state;
+
+ let content;
+
+ if (loading) {
+ content = (
+
+
+
+
+ );
+ } else {
+ const measure = data[0];
+ const percentChange = measure.previous_total && percIncrease(measure.previous_total * 1, measure.total * 1);
+
+ content = (
+
+ {measure.human_value || }
+ {measure.previous_total && ( 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'} )}
+
+ );
+ }
+
+ const inner = (
+
+
+ {content}
+
+
+
+ {label}
+
+
+
+ {!loading && (
+ x.value * 1)}>
+
+
+ )}
+
+
+ );
+
+ if (href) {
+ return (
+
+ {inner}
+
+ );
+ } else {
+ return (
+
+ {inner}
+
+ );
+ }
+ }
+
+}
diff --git a/app/javascript/mastodon/components/admin/Dimension.js b/app/javascript/mastodon/components/admin/Dimension.js
deleted file mode 100644
index 977c8208d..000000000
--- a/app/javascript/mastodon/components/admin/Dimension.js
+++ /dev/null
@@ -1,93 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import api from 'mastodon/api';
-import { FormattedNumber } from 'react-intl';
-import { roundTo10 } from 'mastodon/utils/numbers';
-import Skeleton from 'mastodon/components/skeleton';
-
-export default class Dimension extends React.PureComponent {
-
- static propTypes = {
- dimension: PropTypes.string.isRequired,
- start_at: PropTypes.string.isRequired,
- end_at: PropTypes.string.isRequired,
- limit: PropTypes.number.isRequired,
- label: PropTypes.string.isRequired,
- params: PropTypes.object,
- };
-
- state = {
- loading: true,
- data: null,
- };
-
- componentDidMount () {
- const { start_at, end_at, dimension, limit, params } = this.props;
-
- api().post('/api/v1/admin/dimensions', { keys: [dimension], start_at, end_at, limit, [dimension]: params }).then(res => {
- this.setState({
- loading: false,
- data: res.data,
- });
- }).catch(err => {
- console.error(err);
- });
- }
-
- render () {
- const { label, limit } = this.props;
- const { loading, data } = this.state;
-
- let content;
-
- if (loading) {
- content = (
-
-
- {Array.from(Array(limit)).map((_, i) => (
-
-
-
-
-
-
-
-
-
- ))}
-
-
- );
- } else {
- const sum = data[0].data.reduce((sum, cur) => sum + (cur.value * 1), 0);
-
- content = (
-
-
- {data[0].data.map(item => (
-
-
-
- {item.human_key}
-
-
-
- {typeof item.human_value !== 'undefined' ? item.human_value : }
-
-
- ))}
-
-
- );
- }
-
- return (
-
-
{label}
-
- {content}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/admin/Dimension.jsx b/app/javascript/mastodon/components/admin/Dimension.jsx
new file mode 100644
index 000000000..977c8208d
--- /dev/null
+++ b/app/javascript/mastodon/components/admin/Dimension.jsx
@@ -0,0 +1,93 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'mastodon/api';
+import { FormattedNumber } from 'react-intl';
+import { roundTo10 } from 'mastodon/utils/numbers';
+import Skeleton from 'mastodon/components/skeleton';
+
+export default class Dimension extends React.PureComponent {
+
+ static propTypes = {
+ dimension: PropTypes.string.isRequired,
+ start_at: PropTypes.string.isRequired,
+ end_at: PropTypes.string.isRequired,
+ limit: PropTypes.number.isRequired,
+ label: PropTypes.string.isRequired,
+ params: PropTypes.object,
+ };
+
+ state = {
+ loading: true,
+ data: null,
+ };
+
+ componentDidMount () {
+ const { start_at, end_at, dimension, limit, params } = this.props;
+
+ api().post('/api/v1/admin/dimensions', { keys: [dimension], start_at, end_at, limit, [dimension]: params }).then(res => {
+ this.setState({
+ loading: false,
+ data: res.data,
+ });
+ }).catch(err => {
+ console.error(err);
+ });
+ }
+
+ render () {
+ const { label, limit } = this.props;
+ const { loading, data } = this.state;
+
+ let content;
+
+ if (loading) {
+ content = (
+
+
+ {Array.from(Array(limit)).map((_, i) => (
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+ } else {
+ const sum = data[0].data.reduce((sum, cur) => sum + (cur.value * 1), 0);
+
+ content = (
+
+
+ {data[0].data.map(item => (
+
+
+
+ {item.human_key}
+
+
+
+ {typeof item.human_value !== 'undefined' ? item.human_value : }
+
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+
{label}
+
+ {content}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/admin/ReportReasonSelector.js b/app/javascript/mastodon/components/admin/ReportReasonSelector.js
deleted file mode 100644
index 1f91d2517..000000000
--- a/app/javascript/mastodon/components/admin/ReportReasonSelector.js
+++ /dev/null
@@ -1,159 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import api from 'mastodon/api';
-import { injectIntl, defineMessages } from 'react-intl';
-import classNames from 'classnames';
-
-const messages = defineMessages({
- other: { id: 'report.categories.other', defaultMessage: 'Other' },
- spam: { id: 'report.categories.spam', defaultMessage: 'Spam' },
- violation: { id: 'report.categories.violation', defaultMessage: 'Content violates one or more server rules' },
-});
-
-class Category extends React.PureComponent {
-
- static propTypes = {
- id: PropTypes.string.isRequired,
- text: PropTypes.string.isRequired,
- selected: PropTypes.bool,
- disabled: PropTypes.bool,
- onSelect: PropTypes.func,
- children: PropTypes.node,
- };
-
- handleClick = () => {
- const { id, disabled, onSelect } = this.props;
-
- if (!disabled) {
- onSelect(id);
- }
- };
-
- render () {
- const { id, text, disabled, selected, children } = this.props;
-
- return (
-
- {selected &&
}
-
-
-
- {text}
-
-
- {(selected && children) && (
-
- {children}
-
- )}
-
- );
- }
-
-}
-
-class Rule extends React.PureComponent {
-
- static propTypes = {
- id: PropTypes.string.isRequired,
- text: PropTypes.string.isRequired,
- selected: PropTypes.bool,
- disabled: PropTypes.bool,
- onToggle: PropTypes.func,
- };
-
- handleClick = () => {
- const { id, disabled, onToggle } = this.props;
-
- if (!disabled) {
- onToggle(id);
- }
- };
-
- render () {
- const { id, text, disabled, selected } = this.props;
-
- return (
-
-
- {selected && }
- {text}
-
- );
- }
-
-}
-
-export default @injectIntl
-class ReportReasonSelector extends React.PureComponent {
-
- static propTypes = {
- id: PropTypes.string.isRequired,
- category: PropTypes.string.isRequired,
- rule_ids: PropTypes.arrayOf(PropTypes.string),
- disabled: PropTypes.bool,
- intl: PropTypes.object.isRequired,
- };
-
- state = {
- category: this.props.category,
- rule_ids: this.props.rule_ids || [],
- rules: [],
- };
-
- componentDidMount() {
- api().get('/api/v1/instance').then(res => {
- this.setState({
- rules: res.data.rules,
- });
- }).catch(err => {
- console.error(err);
- });
- }
-
- _save = () => {
- const { id, disabled } = this.props;
- const { category, rule_ids } = this.state;
-
- if (disabled) {
- return;
- }
-
- api().put(`/api/v1/admin/reports/${id}`, {
- category,
- rule_ids,
- }).catch(err => {
- console.error(err);
- });
- };
-
- handleSelect = id => {
- this.setState({ category: id }, () => this._save());
- };
-
- handleToggle = id => {
- const { rule_ids } = this.state;
-
- if (rule_ids.includes(id)) {
- this.setState({ rule_ids: rule_ids.filter(x => x !== id ) }, () => this._save());
- } else {
- this.setState({ rule_ids: [...rule_ids, id] }, () => this._save());
- }
- };
-
- render () {
- const { disabled, intl } = this.props;
- const { rules, category, rule_ids } = this.state;
-
- return (
-
-
-
-
- {rules.map(rule => )}
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/admin/ReportReasonSelector.jsx b/app/javascript/mastodon/components/admin/ReportReasonSelector.jsx
new file mode 100644
index 000000000..1f91d2517
--- /dev/null
+++ b/app/javascript/mastodon/components/admin/ReportReasonSelector.jsx
@@ -0,0 +1,159 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'mastodon/api';
+import { injectIntl, defineMessages } from 'react-intl';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+ other: { id: 'report.categories.other', defaultMessage: 'Other' },
+ spam: { id: 'report.categories.spam', defaultMessage: 'Spam' },
+ violation: { id: 'report.categories.violation', defaultMessage: 'Content violates one or more server rules' },
+});
+
+class Category extends React.PureComponent {
+
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ text: PropTypes.string.isRequired,
+ selected: PropTypes.bool,
+ disabled: PropTypes.bool,
+ onSelect: PropTypes.func,
+ children: PropTypes.node,
+ };
+
+ handleClick = () => {
+ const { id, disabled, onSelect } = this.props;
+
+ if (!disabled) {
+ onSelect(id);
+ }
+ };
+
+ render () {
+ const { id, text, disabled, selected, children } = this.props;
+
+ return (
+
+ {selected &&
}
+
+
+
+ {text}
+
+
+ {(selected && children) && (
+
+ {children}
+
+ )}
+
+ );
+ }
+
+}
+
+class Rule extends React.PureComponent {
+
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ text: PropTypes.string.isRequired,
+ selected: PropTypes.bool,
+ disabled: PropTypes.bool,
+ onToggle: PropTypes.func,
+ };
+
+ handleClick = () => {
+ const { id, disabled, onToggle } = this.props;
+
+ if (!disabled) {
+ onToggle(id);
+ }
+ };
+
+ render () {
+ const { id, text, disabled, selected } = this.props;
+
+ return (
+
+
+ {selected && }
+ {text}
+
+ );
+ }
+
+}
+
+export default @injectIntl
+class ReportReasonSelector extends React.PureComponent {
+
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ category: PropTypes.string.isRequired,
+ rule_ids: PropTypes.arrayOf(PropTypes.string),
+ disabled: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ category: this.props.category,
+ rule_ids: this.props.rule_ids || [],
+ rules: [],
+ };
+
+ componentDidMount() {
+ api().get('/api/v1/instance').then(res => {
+ this.setState({
+ rules: res.data.rules,
+ });
+ }).catch(err => {
+ console.error(err);
+ });
+ }
+
+ _save = () => {
+ const { id, disabled } = this.props;
+ const { category, rule_ids } = this.state;
+
+ if (disabled) {
+ return;
+ }
+
+ api().put(`/api/v1/admin/reports/${id}`, {
+ category,
+ rule_ids,
+ }).catch(err => {
+ console.error(err);
+ });
+ };
+
+ handleSelect = id => {
+ this.setState({ category: id }, () => this._save());
+ };
+
+ handleToggle = id => {
+ const { rule_ids } = this.state;
+
+ if (rule_ids.includes(id)) {
+ this.setState({ rule_ids: rule_ids.filter(x => x !== id ) }, () => this._save());
+ } else {
+ this.setState({ rule_ids: [...rule_ids, id] }, () => this._save());
+ }
+ };
+
+ render () {
+ const { disabled, intl } = this.props;
+ const { rules, category, rule_ids } = this.state;
+
+ return (
+
+
+
+
+ {rules.map(rule => )}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/admin/Retention.js b/app/javascript/mastodon/components/admin/Retention.js
deleted file mode 100644
index f312a45eb..000000000
--- a/app/javascript/mastodon/components/admin/Retention.js
+++ /dev/null
@@ -1,151 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import api from 'mastodon/api';
-import { FormattedMessage, FormattedNumber, FormattedDate } from 'react-intl';
-import classNames from 'classnames';
-import { roundTo10 } from 'mastodon/utils/numbers';
-
-const dateForCohort = cohort => {
- switch(cohort.frequency) {
- case 'day':
- return ;
- default:
- return ;
- }
-};
-
-export default class Retention extends React.PureComponent {
-
- static propTypes = {
- start_at: PropTypes.string,
- end_at: PropTypes.string,
- frequency: PropTypes.string,
- };
-
- state = {
- loading: true,
- data: null,
- };
-
- componentDidMount () {
- const { start_at, end_at, frequency } = this.props;
-
- api().post('/api/v1/admin/retention', { start_at, end_at, frequency }).then(res => {
- this.setState({
- loading: false,
- data: res.data,
- });
- }).catch(err => {
- console.error(err);
- });
- }
-
- render () {
- const { loading, data } = this.state;
- const { frequency } = this.props;
-
- let content;
-
- if (loading) {
- content = ;
- } else {
- content = (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {data[0].data.slice(1).map((retention, i) => (
-
-
- {i + 1}
-
-
- ))}
-
-
-
-
-
-
-
-
-
-
-
- sum + ((cohort.data[0].value * 1) - sum) / (i + 1), 0)} maximumFractionDigits={0} />
-
-
-
- {data[0].data.slice(1).map((retention, i) => {
- const average = data.reduce((sum, cohort, k) => cohort.data[i + 1] ? sum + (cohort.data[i + 1].rate - sum)/(k + 1) : sum, 0);
-
- return (
-
-
-
-
-
- );
- })}
-
-
-
-
- {data.slice(0, -1).map(cohort => (
-
-
-
- {dateForCohort(cohort)}
-
-
-
-
-
-
-
-
-
- {cohort.data.slice(1).map(retention => (
-
-
-
-
-
- ))}
-
- ))}
-
-
- );
- }
-
- let title = null;
- switch(frequency) {
- case 'day':
- title = ;
- break;
- default:
- title = ;
- }
-
- return (
-
-
{title}
-
- {content}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/admin/Retention.jsx b/app/javascript/mastodon/components/admin/Retention.jsx
new file mode 100644
index 000000000..f312a45eb
--- /dev/null
+++ b/app/javascript/mastodon/components/admin/Retention.jsx
@@ -0,0 +1,151 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'mastodon/api';
+import { FormattedMessage, FormattedNumber, FormattedDate } from 'react-intl';
+import classNames from 'classnames';
+import { roundTo10 } from 'mastodon/utils/numbers';
+
+const dateForCohort = cohort => {
+ switch(cohort.frequency) {
+ case 'day':
+ return ;
+ default:
+ return ;
+ }
+};
+
+export default class Retention extends React.PureComponent {
+
+ static propTypes = {
+ start_at: PropTypes.string,
+ end_at: PropTypes.string,
+ frequency: PropTypes.string,
+ };
+
+ state = {
+ loading: true,
+ data: null,
+ };
+
+ componentDidMount () {
+ const { start_at, end_at, frequency } = this.props;
+
+ api().post('/api/v1/admin/retention', { start_at, end_at, frequency }).then(res => {
+ this.setState({
+ loading: false,
+ data: res.data,
+ });
+ }).catch(err => {
+ console.error(err);
+ });
+ }
+
+ render () {
+ const { loading, data } = this.state;
+ const { frequency } = this.props;
+
+ let content;
+
+ if (loading) {
+ content = ;
+ } else {
+ content = (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {data[0].data.slice(1).map((retention, i) => (
+
+
+ {i + 1}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ sum + ((cohort.data[0].value * 1) - sum) / (i + 1), 0)} maximumFractionDigits={0} />
+
+
+
+ {data[0].data.slice(1).map((retention, i) => {
+ const average = data.reduce((sum, cohort, k) => cohort.data[i + 1] ? sum + (cohort.data[i + 1].rate - sum)/(k + 1) : sum, 0);
+
+ return (
+
+
+
+
+
+ );
+ })}
+
+
+
+
+ {data.slice(0, -1).map(cohort => (
+
+
+
+ {dateForCohort(cohort)}
+
+
+
+
+
+
+
+
+
+ {cohort.data.slice(1).map(retention => (
+
+
+
+
+
+ ))}
+
+ ))}
+
+
+ );
+ }
+
+ let title = null;
+ switch(frequency) {
+ case 'day':
+ title = ;
+ break;
+ default:
+ title = ;
+ }
+
+ return (
+
+
{title}
+
+ {content}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/admin/Trends.js b/app/javascript/mastodon/components/admin/Trends.js
deleted file mode 100644
index d01b8437e..000000000
--- a/app/javascript/mastodon/components/admin/Trends.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import api from 'mastodon/api';
-import { FormattedMessage } from 'react-intl';
-import classNames from 'classnames';
-import Hashtag from 'mastodon/components/hashtag';
-
-export default class Trends extends React.PureComponent {
-
- static propTypes = {
- limit: PropTypes.number.isRequired,
- };
-
- state = {
- loading: true,
- data: null,
- };
-
- componentDidMount () {
- const { limit } = this.props;
-
- api().get('/api/v1/admin/trends/tags', { params: { limit } }).then(res => {
- this.setState({
- loading: false,
- data: res.data,
- });
- }).catch(err => {
- console.error(err);
- });
- }
-
- render () {
- const { limit } = this.props;
- const { loading, data } = this.state;
-
- let content;
-
- if (loading) {
- content = (
-
- {Array.from(Array(limit)).map((_, i) => (
-
- ))}
-
- );
- } else {
- content = (
-
- {data.map(hashtag => (
- day.uses)}
- className={classNames(hashtag.requires_review && 'trends__item--requires-review', !hashtag.trendable && !hashtag.requires_review && 'trends__item--disabled')}
- />
- ))}
-
- );
- }
-
- return (
-
-
-
- {content}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/admin/Trends.jsx b/app/javascript/mastodon/components/admin/Trends.jsx
new file mode 100644
index 000000000..d01b8437e
--- /dev/null
+++ b/app/javascript/mastodon/components/admin/Trends.jsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'mastodon/api';
+import { FormattedMessage } from 'react-intl';
+import classNames from 'classnames';
+import Hashtag from 'mastodon/components/hashtag';
+
+export default class Trends extends React.PureComponent {
+
+ static propTypes = {
+ limit: PropTypes.number.isRequired,
+ };
+
+ state = {
+ loading: true,
+ data: null,
+ };
+
+ componentDidMount () {
+ const { limit } = this.props;
+
+ api().get('/api/v1/admin/trends/tags', { params: { limit } }).then(res => {
+ this.setState({
+ loading: false,
+ data: res.data,
+ });
+ }).catch(err => {
+ console.error(err);
+ });
+ }
+
+ render () {
+ const { limit } = this.props;
+ const { loading, data } = this.state;
+
+ let content;
+
+ if (loading) {
+ content = (
+
+ {Array.from(Array(limit)).map((_, i) => (
+
+ ))}
+
+ );
+ } else {
+ content = (
+
+ {data.map(hashtag => (
+ day.uses)}
+ className={classNames(hashtag.requires_review && 'trends__item--requires-review', !hashtag.trendable && !hashtag.requires_review && 'trends__item--disabled')}
+ />
+ ))}
+
+ );
+ }
+
+ return (
+
+
+
+ {content}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/animated_number.js b/app/javascript/mastodon/components/animated_number.js
deleted file mode 100644
index ce688f04f..000000000
--- a/app/javascript/mastodon/components/animated_number.js
+++ /dev/null
@@ -1,76 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ShortNumber from 'mastodon/components/short_number';
-import TransitionMotion from 'react-motion/lib/TransitionMotion';
-import spring from 'react-motion/lib/spring';
-import { reduceMotion } from 'mastodon/initial_state';
-
-const obfuscatedCount = count => {
- if (count < 0) {
- return 0;
- } else if (count <= 1) {
- return count;
- } else {
- return '1+';
- }
-};
-
-export default class AnimatedNumber extends React.PureComponent {
-
- static propTypes = {
- value: PropTypes.number.isRequired,
- obfuscate: PropTypes.bool,
- };
-
- state = {
- direction: 1,
- };
-
- componentWillReceiveProps (nextProps) {
- if (nextProps.value > this.props.value) {
- this.setState({ direction: 1 });
- } else if (nextProps.value < this.props.value) {
- this.setState({ direction: -1 });
- }
- }
-
- willEnter = () => {
- const { direction } = this.state;
-
- return { y: -1 * direction };
- };
-
- willLeave = () => {
- const { direction } = this.state;
-
- return { y: spring(1 * direction, { damping: 35, stiffness: 400 }) };
- };
-
- render () {
- const { value, obfuscate } = this.props;
- const { direction } = this.state;
-
- if (reduceMotion) {
- return obfuscate ? obfuscatedCount(value) : ;
- }
-
- const styles = [{
- key: `${value}`,
- data: value,
- style: { y: spring(0, { damping: 35, stiffness: 400 }) },
- }];
-
- return (
-
- {items => (
-
- {items.map(({ key, data, style }) => (
- 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : }
- ))}
-
- )}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/animated_number.jsx b/app/javascript/mastodon/components/animated_number.jsx
new file mode 100644
index 000000000..ce688f04f
--- /dev/null
+++ b/app/javascript/mastodon/components/animated_number.jsx
@@ -0,0 +1,76 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ShortNumber from 'mastodon/components/short_number';
+import TransitionMotion from 'react-motion/lib/TransitionMotion';
+import spring from 'react-motion/lib/spring';
+import { reduceMotion } from 'mastodon/initial_state';
+
+const obfuscatedCount = count => {
+ if (count < 0) {
+ return 0;
+ } else if (count <= 1) {
+ return count;
+ } else {
+ return '1+';
+ }
+};
+
+export default class AnimatedNumber extends React.PureComponent {
+
+ static propTypes = {
+ value: PropTypes.number.isRequired,
+ obfuscate: PropTypes.bool,
+ };
+
+ state = {
+ direction: 1,
+ };
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.value > this.props.value) {
+ this.setState({ direction: 1 });
+ } else if (nextProps.value < this.props.value) {
+ this.setState({ direction: -1 });
+ }
+ }
+
+ willEnter = () => {
+ const { direction } = this.state;
+
+ return { y: -1 * direction };
+ };
+
+ willLeave = () => {
+ const { direction } = this.state;
+
+ return { y: spring(1 * direction, { damping: 35, stiffness: 400 }) };
+ };
+
+ render () {
+ const { value, obfuscate } = this.props;
+ const { direction } = this.state;
+
+ if (reduceMotion) {
+ return obfuscate ? obfuscatedCount(value) : ;
+ }
+
+ const styles = [{
+ key: `${value}`,
+ data: value,
+ style: { y: spring(0, { damping: 35, stiffness: 400 }) },
+ }];
+
+ return (
+
+ {items => (
+
+ {items.map(({ key, data, style }) => (
+ 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : }
+ ))}
+
+ )}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/attachment_list.js b/app/javascript/mastodon/components/attachment_list.js
deleted file mode 100644
index 0e23889de..000000000
--- a/app/javascript/mastodon/components/attachment_list.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { FormattedMessage } from 'react-intl';
-import classNames from 'classnames';
-import Icon from 'mastodon/components/icon';
-
-const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
-
-export default class AttachmentList extends ImmutablePureComponent {
-
- static propTypes = {
- media: ImmutablePropTypes.list.isRequired,
- compact: PropTypes.bool,
- };
-
- render () {
- const { media, compact } = this.props;
-
- return (
-
- {!compact && (
-
-
-
- )}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/attachment_list.jsx b/app/javascript/mastodon/components/attachment_list.jsx
new file mode 100644
index 000000000..0e23889de
--- /dev/null
+++ b/app/javascript/mastodon/components/attachment_list.jsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage } from 'react-intl';
+import classNames from 'classnames';
+import Icon from 'mastodon/components/icon';
+
+const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
+
+export default class AttachmentList extends ImmutablePureComponent {
+
+ static propTypes = {
+ media: ImmutablePropTypes.list.isRequired,
+ compact: PropTypes.bool,
+ };
+
+ render () {
+ const { media, compact } = this.props;
+
+ return (
+
+ {!compact && (
+
+
+
+ )}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/autosuggest_emoji.js b/app/javascript/mastodon/components/autosuggest_emoji.js
deleted file mode 100644
index 4937e4d98..000000000
--- a/app/javascript/mastodon/components/autosuggest_emoji.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
-import { assetHost } from 'mastodon/utils/config';
-
-export default class AutosuggestEmoji extends React.PureComponent {
-
- static propTypes = {
- emoji: PropTypes.object.isRequired,
- };
-
- render () {
- const { emoji } = this.props;
- let url;
-
- if (emoji.custom) {
- url = emoji.imageUrl;
- } else {
- const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
-
- if (!mapping) {
- return null;
- }
-
- url = `${assetHost}/emoji/${mapping.filename}.svg`;
- }
-
- return (
-
-
-
- {emoji.colons}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/autosuggest_emoji.jsx b/app/javascript/mastodon/components/autosuggest_emoji.jsx
new file mode 100644
index 000000000..4937e4d98
--- /dev/null
+++ b/app/javascript/mastodon/components/autosuggest_emoji.jsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
+import { assetHost } from 'mastodon/utils/config';
+
+export default class AutosuggestEmoji extends React.PureComponent {
+
+ static propTypes = {
+ emoji: PropTypes.object.isRequired,
+ };
+
+ render () {
+ const { emoji } = this.props;
+ let url;
+
+ if (emoji.custom) {
+ url = emoji.imageUrl;
+ } else {
+ const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
+
+ if (!mapping) {
+ return null;
+ }
+
+ url = `${assetHost}/emoji/${mapping.filename}.svg`;
+ }
+
+ return (
+
+
+
+ {emoji.colons}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/autosuggest_hashtag.js b/app/javascript/mastodon/components/autosuggest_hashtag.js
deleted file mode 100644
index 9e9d888f8..000000000
--- a/app/javascript/mastodon/components/autosuggest_hashtag.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ShortNumber from 'mastodon/components/short_number';
-import { FormattedMessage } from 'react-intl';
-
-export default class AutosuggestHashtag extends React.PureComponent {
-
- static propTypes = {
- tag: PropTypes.shape({
- name: PropTypes.string.isRequired,
- url: PropTypes.string,
- history: PropTypes.array,
- }).isRequired,
- };
-
- render() {
- const { tag } = this.props;
- const weeklyUses = tag.history && (
- total + day.uses * 1, 0)}
- />
- );
-
- return (
-
-
- #{tag.name}
-
- {tag.history !== undefined && (
-
-
-
- )}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/autosuggest_hashtag.jsx b/app/javascript/mastodon/components/autosuggest_hashtag.jsx
new file mode 100644
index 000000000..9e9d888f8
--- /dev/null
+++ b/app/javascript/mastodon/components/autosuggest_hashtag.jsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ShortNumber from 'mastodon/components/short_number';
+import { FormattedMessage } from 'react-intl';
+
+export default class AutosuggestHashtag extends React.PureComponent {
+
+ static propTypes = {
+ tag: PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ url: PropTypes.string,
+ history: PropTypes.array,
+ }).isRequired,
+ };
+
+ render() {
+ const { tag } = this.props;
+ const weeklyUses = tag.history && (
+ total + day.uses * 1, 0)}
+ />
+ );
+
+ return (
+
+
+ #{tag.name}
+
+ {tag.history !== undefined && (
+
+
+
+ )}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/autosuggest_input.js b/app/javascript/mastodon/components/autosuggest_input.js
deleted file mode 100644
index f9616c581..000000000
--- a/app/javascript/mastodon/components/autosuggest_input.js
+++ /dev/null
@@ -1,227 +0,0 @@
-import React from 'react';
-import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
-import AutosuggestEmoji from './autosuggest_emoji';
-import AutosuggestHashtag from './autosuggest_hashtag';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import classNames from 'classnames';
-
-const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
- let word;
-
- let left = str.slice(0, caretPosition).search(/\S+$/);
- let right = str.slice(caretPosition).search(/\s/);
-
- if (right < 0) {
- word = str.slice(left);
- } else {
- word = str.slice(left, right + caretPosition);
- }
-
- if (!word || word.trim().length < 3 || searchTokens.indexOf(word[0]) === -1) {
- return [null, null];
- }
-
- word = word.trim().toLowerCase();
-
- if (word.length > 0) {
- return [left + 1, word];
- } else {
- return [null, null];
- }
-};
-
-export default class AutosuggestInput extends ImmutablePureComponent {
-
- static propTypes = {
- value: PropTypes.string,
- suggestions: ImmutablePropTypes.list,
- disabled: PropTypes.bool,
- placeholder: PropTypes.string,
- onSuggestionSelected: PropTypes.func.isRequired,
- onSuggestionsClearRequested: PropTypes.func.isRequired,
- onSuggestionsFetchRequested: PropTypes.func.isRequired,
- onChange: PropTypes.func.isRequired,
- onKeyUp: PropTypes.func,
- onKeyDown: PropTypes.func,
- autoFocus: PropTypes.bool,
- className: PropTypes.string,
- id: PropTypes.string,
- searchTokens: PropTypes.arrayOf(PropTypes.string),
- maxLength: PropTypes.number,
- lang: PropTypes.string,
- spellCheck: PropTypes.bool,
- };
-
- static defaultProps = {
- autoFocus: true,
- searchTokens: ['@', ':', '#'],
- };
-
- state = {
- suggestionsHidden: true,
- focused: false,
- selectedSuggestion: 0,
- lastToken: null,
- tokenStart: 0,
- };
-
- onChange = (e) => {
- const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart, this.props.searchTokens);
-
- if (token !== null && this.state.lastToken !== token) {
- this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
- this.props.onSuggestionsFetchRequested(token);
- } else if (token === null) {
- this.setState({ lastToken: null });
- this.props.onSuggestionsClearRequested();
- }
-
- this.props.onChange(e);
- };
-
- onKeyDown = (e) => {
- const { suggestions, disabled } = this.props;
- const { selectedSuggestion, suggestionsHidden } = this.state;
-
- if (disabled) {
- e.preventDefault();
- return;
- }
-
- if (e.which === 229 || e.isComposing) {
- // Ignore key events during text composition
- // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
- return;
- }
-
- switch(e.key) {
- case 'Escape':
- if (suggestions.size === 0 || suggestionsHidden) {
- document.querySelector('.ui').parentElement.focus();
- } else {
- e.preventDefault();
- this.setState({ suggestionsHidden: true });
- }
-
- break;
- case 'ArrowDown':
- if (suggestions.size > 0 && !suggestionsHidden) {
- e.preventDefault();
- this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
- }
-
- break;
- case 'ArrowUp':
- if (suggestions.size > 0 && !suggestionsHidden) {
- e.preventDefault();
- this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
- }
-
- break;
- case 'Enter':
- case 'Tab':
- // Select suggestion
- if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
- e.preventDefault();
- e.stopPropagation();
- this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
- }
-
- break;
- }
-
- if (e.defaultPrevented || !this.props.onKeyDown) {
- return;
- }
-
- this.props.onKeyDown(e);
- };
-
- onBlur = () => {
- this.setState({ suggestionsHidden: true, focused: false });
- };
-
- onFocus = () => {
- this.setState({ focused: true });
- };
-
- onSuggestionClick = (e) => {
- const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
- e.preventDefault();
- this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
- this.input.focus();
- };
-
- componentWillReceiveProps (nextProps) {
- if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
- this.setState({ suggestionsHidden: false });
- }
- }
-
- setInput = (c) => {
- this.input = c;
- };
-
- renderSuggestion = (suggestion, i) => {
- const { selectedSuggestion } = this.state;
- let inner, key;
-
- if (suggestion.type === 'emoji') {
- inner = ;
- key = suggestion.id;
- } else if (suggestion.type ==='hashtag') {
- inner = ;
- key = suggestion.name;
- } else if (suggestion.type === 'account') {
- inner = ;
- key = suggestion.id;
- }
-
- return (
-
- {inner}
-
- );
- };
-
- render () {
- const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, lang, spellCheck } = this.props;
- const { suggestionsHidden } = this.state;
-
- return (
-
-
- {placeholder}
-
-
-
-
-
- {suggestions.map(this.renderSuggestion)}
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/autosuggest_input.jsx b/app/javascript/mastodon/components/autosuggest_input.jsx
new file mode 100644
index 000000000..f9616c581
--- /dev/null
+++ b/app/javascript/mastodon/components/autosuggest_input.jsx
@@ -0,0 +1,227 @@
+import React from 'react';
+import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
+import AutosuggestEmoji from './autosuggest_emoji';
+import AutosuggestHashtag from './autosuggest_hashtag';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import classNames from 'classnames';
+
+const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
+ let word;
+
+ let left = str.slice(0, caretPosition).search(/\S+$/);
+ let right = str.slice(caretPosition).search(/\s/);
+
+ if (right < 0) {
+ word = str.slice(left);
+ } else {
+ word = str.slice(left, right + caretPosition);
+ }
+
+ if (!word || word.trim().length < 3 || searchTokens.indexOf(word[0]) === -1) {
+ return [null, null];
+ }
+
+ word = word.trim().toLowerCase();
+
+ if (word.length > 0) {
+ return [left + 1, word];
+ } else {
+ return [null, null];
+ }
+};
+
+export default class AutosuggestInput extends ImmutablePureComponent {
+
+ static propTypes = {
+ value: PropTypes.string,
+ suggestions: ImmutablePropTypes.list,
+ disabled: PropTypes.bool,
+ placeholder: PropTypes.string,
+ onSuggestionSelected: PropTypes.func.isRequired,
+ onSuggestionsClearRequested: PropTypes.func.isRequired,
+ onSuggestionsFetchRequested: PropTypes.func.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onKeyUp: PropTypes.func,
+ onKeyDown: PropTypes.func,
+ autoFocus: PropTypes.bool,
+ className: PropTypes.string,
+ id: PropTypes.string,
+ searchTokens: PropTypes.arrayOf(PropTypes.string),
+ maxLength: PropTypes.number,
+ lang: PropTypes.string,
+ spellCheck: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ autoFocus: true,
+ searchTokens: ['@', ':', '#'],
+ };
+
+ state = {
+ suggestionsHidden: true,
+ focused: false,
+ selectedSuggestion: 0,
+ lastToken: null,
+ tokenStart: 0,
+ };
+
+ onChange = (e) => {
+ const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart, this.props.searchTokens);
+
+ if (token !== null && this.state.lastToken !== token) {
+ this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
+ this.props.onSuggestionsFetchRequested(token);
+ } else if (token === null) {
+ this.setState({ lastToken: null });
+ this.props.onSuggestionsClearRequested();
+ }
+
+ this.props.onChange(e);
+ };
+
+ onKeyDown = (e) => {
+ const { suggestions, disabled } = this.props;
+ const { selectedSuggestion, suggestionsHidden } = this.state;
+
+ if (disabled) {
+ e.preventDefault();
+ return;
+ }
+
+ if (e.which === 229 || e.isComposing) {
+ // Ignore key events during text composition
+ // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
+ return;
+ }
+
+ switch(e.key) {
+ case 'Escape':
+ if (suggestions.size === 0 || suggestionsHidden) {
+ document.querySelector('.ui').parentElement.focus();
+ } else {
+ e.preventDefault();
+ this.setState({ suggestionsHidden: true });
+ }
+
+ break;
+ case 'ArrowDown':
+ if (suggestions.size > 0 && !suggestionsHidden) {
+ e.preventDefault();
+ this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
+ }
+
+ break;
+ case 'ArrowUp':
+ if (suggestions.size > 0 && !suggestionsHidden) {
+ e.preventDefault();
+ this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
+ }
+
+ break;
+ case 'Enter':
+ case 'Tab':
+ // Select suggestion
+ if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
+ }
+
+ break;
+ }
+
+ if (e.defaultPrevented || !this.props.onKeyDown) {
+ return;
+ }
+
+ this.props.onKeyDown(e);
+ };
+
+ onBlur = () => {
+ this.setState({ suggestionsHidden: true, focused: false });
+ };
+
+ onFocus = () => {
+ this.setState({ focused: true });
+ };
+
+ onSuggestionClick = (e) => {
+ const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
+ e.preventDefault();
+ this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
+ this.input.focus();
+ };
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
+ this.setState({ suggestionsHidden: false });
+ }
+ }
+
+ setInput = (c) => {
+ this.input = c;
+ };
+
+ renderSuggestion = (suggestion, i) => {
+ const { selectedSuggestion } = this.state;
+ let inner, key;
+
+ if (suggestion.type === 'emoji') {
+ inner = ;
+ key = suggestion.id;
+ } else if (suggestion.type ==='hashtag') {
+ inner = ;
+ key = suggestion.name;
+ } else if (suggestion.type === 'account') {
+ inner = ;
+ key = suggestion.id;
+ }
+
+ return (
+
+ {inner}
+
+ );
+ };
+
+ render () {
+ const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, lang, spellCheck } = this.props;
+ const { suggestionsHidden } = this.state;
+
+ return (
+
+
+ {placeholder}
+
+
+
+
+
+ {suggestions.map(this.renderSuggestion)}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js
deleted file mode 100644
index c04491298..000000000
--- a/app/javascript/mastodon/components/autosuggest_textarea.js
+++ /dev/null
@@ -1,235 +0,0 @@
-import React from 'react';
-import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
-import AutosuggestEmoji from './autosuggest_emoji';
-import AutosuggestHashtag from './autosuggest_hashtag';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import Textarea from 'react-textarea-autosize';
-import classNames from 'classnames';
-
-const textAtCursorMatchesToken = (str, caretPosition) => {
- let word;
-
- let left = str.slice(0, caretPosition).search(/\S+$/);
- let right = str.slice(caretPosition).search(/\s/);
-
- if (right < 0) {
- word = str.slice(left);
- } else {
- word = str.slice(left, right + caretPosition);
- }
-
- if (!word || word.trim().length < 3 || ['@', ':', '#'].indexOf(word[0]) === -1) {
- return [null, null];
- }
-
- word = word.trim().toLowerCase();
-
- if (word.length > 0) {
- return [left + 1, word];
- } else {
- return [null, null];
- }
-};
-
-export default class AutosuggestTextarea extends ImmutablePureComponent {
-
- static propTypes = {
- value: PropTypes.string,
- suggestions: ImmutablePropTypes.list,
- disabled: PropTypes.bool,
- placeholder: PropTypes.string,
- onSuggestionSelected: PropTypes.func.isRequired,
- onSuggestionsClearRequested: PropTypes.func.isRequired,
- onSuggestionsFetchRequested: PropTypes.func.isRequired,
- onChange: PropTypes.func.isRequired,
- onKeyUp: PropTypes.func,
- onKeyDown: PropTypes.func,
- onPaste: PropTypes.func.isRequired,
- autoFocus: PropTypes.bool,
- lang: PropTypes.string,
- };
-
- static defaultProps = {
- autoFocus: true,
- };
-
- state = {
- suggestionsHidden: true,
- focused: false,
- selectedSuggestion: 0,
- lastToken: null,
- tokenStart: 0,
- };
-
- onChange = (e) => {
- const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
-
- if (token !== null && this.state.lastToken !== token) {
- this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
- this.props.onSuggestionsFetchRequested(token);
- } else if (token === null) {
- this.setState({ lastToken: null });
- this.props.onSuggestionsClearRequested();
- }
-
- this.props.onChange(e);
- };
-
- onKeyDown = (e) => {
- const { suggestions, disabled } = this.props;
- const { selectedSuggestion, suggestionsHidden } = this.state;
-
- if (disabled) {
- e.preventDefault();
- return;
- }
-
- if (e.which === 229 || e.isComposing) {
- // Ignore key events during text composition
- // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
- return;
- }
-
- switch(e.key) {
- case 'Escape':
- if (suggestions.size === 0 || suggestionsHidden) {
- document.querySelector('.ui').parentElement.focus();
- } else {
- e.preventDefault();
- this.setState({ suggestionsHidden: true });
- }
-
- break;
- case 'ArrowDown':
- if (suggestions.size > 0 && !suggestionsHidden) {
- e.preventDefault();
- this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
- }
-
- break;
- case 'ArrowUp':
- if (suggestions.size > 0 && !suggestionsHidden) {
- e.preventDefault();
- this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
- }
-
- break;
- case 'Enter':
- case 'Tab':
- // Select suggestion
- if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
- e.preventDefault();
- e.stopPropagation();
- this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
- }
-
- break;
- }
-
- if (e.defaultPrevented || !this.props.onKeyDown) {
- return;
- }
-
- this.props.onKeyDown(e);
- };
-
- onBlur = () => {
- this.setState({ suggestionsHidden: true, focused: false });
- };
-
- onFocus = (e) => {
- this.setState({ focused: true });
- if (this.props.onFocus) {
- this.props.onFocus(e);
- }
- };
-
- onSuggestionClick = (e) => {
- const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
- e.preventDefault();
- this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
- this.textarea.focus();
- };
-
- componentWillReceiveProps (nextProps) {
- if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
- this.setState({ suggestionsHidden: false });
- }
- }
-
- setTextarea = (c) => {
- this.textarea = c;
- };
-
- onPaste = (e) => {
- if (e.clipboardData && e.clipboardData.files.length === 1) {
- this.props.onPaste(e.clipboardData.files);
- e.preventDefault();
- }
- };
-
- renderSuggestion = (suggestion, i) => {
- const { selectedSuggestion } = this.state;
- let inner, key;
-
- if (suggestion.type === 'emoji') {
- inner = ;
- key = suggestion.id;
- } else if (suggestion.type === 'hashtag') {
- inner = ;
- key = suggestion.name;
- } else if (suggestion.type === 'account') {
- inner = ;
- key = suggestion.id;
- }
-
- return (
-
- {inner}
-
- );
- };
-
- render () {
- const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, lang, children } = this.props;
- const { suggestionsHidden } = this.state;
-
- return [
-
-
-
- {placeholder}
-
-
-
-
- {children}
-
,
-
-
-
- {suggestions.map(this.renderSuggestion)}
-
-
,
- ];
- }
-
-}
diff --git a/app/javascript/mastodon/components/autosuggest_textarea.jsx b/app/javascript/mastodon/components/autosuggest_textarea.jsx
new file mode 100644
index 000000000..c04491298
--- /dev/null
+++ b/app/javascript/mastodon/components/autosuggest_textarea.jsx
@@ -0,0 +1,235 @@
+import React from 'react';
+import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
+import AutosuggestEmoji from './autosuggest_emoji';
+import AutosuggestHashtag from './autosuggest_hashtag';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Textarea from 'react-textarea-autosize';
+import classNames from 'classnames';
+
+const textAtCursorMatchesToken = (str, caretPosition) => {
+ let word;
+
+ let left = str.slice(0, caretPosition).search(/\S+$/);
+ let right = str.slice(caretPosition).search(/\s/);
+
+ if (right < 0) {
+ word = str.slice(left);
+ } else {
+ word = str.slice(left, right + caretPosition);
+ }
+
+ if (!word || word.trim().length < 3 || ['@', ':', '#'].indexOf(word[0]) === -1) {
+ return [null, null];
+ }
+
+ word = word.trim().toLowerCase();
+
+ if (word.length > 0) {
+ return [left + 1, word];
+ } else {
+ return [null, null];
+ }
+};
+
+export default class AutosuggestTextarea extends ImmutablePureComponent {
+
+ static propTypes = {
+ value: PropTypes.string,
+ suggestions: ImmutablePropTypes.list,
+ disabled: PropTypes.bool,
+ placeholder: PropTypes.string,
+ onSuggestionSelected: PropTypes.func.isRequired,
+ onSuggestionsClearRequested: PropTypes.func.isRequired,
+ onSuggestionsFetchRequested: PropTypes.func.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onKeyUp: PropTypes.func,
+ onKeyDown: PropTypes.func,
+ onPaste: PropTypes.func.isRequired,
+ autoFocus: PropTypes.bool,
+ lang: PropTypes.string,
+ };
+
+ static defaultProps = {
+ autoFocus: true,
+ };
+
+ state = {
+ suggestionsHidden: true,
+ focused: false,
+ selectedSuggestion: 0,
+ lastToken: null,
+ tokenStart: 0,
+ };
+
+ onChange = (e) => {
+ const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
+
+ if (token !== null && this.state.lastToken !== token) {
+ this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
+ this.props.onSuggestionsFetchRequested(token);
+ } else if (token === null) {
+ this.setState({ lastToken: null });
+ this.props.onSuggestionsClearRequested();
+ }
+
+ this.props.onChange(e);
+ };
+
+ onKeyDown = (e) => {
+ const { suggestions, disabled } = this.props;
+ const { selectedSuggestion, suggestionsHidden } = this.state;
+
+ if (disabled) {
+ e.preventDefault();
+ return;
+ }
+
+ if (e.which === 229 || e.isComposing) {
+ // Ignore key events during text composition
+ // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
+ return;
+ }
+
+ switch(e.key) {
+ case 'Escape':
+ if (suggestions.size === 0 || suggestionsHidden) {
+ document.querySelector('.ui').parentElement.focus();
+ } else {
+ e.preventDefault();
+ this.setState({ suggestionsHidden: true });
+ }
+
+ break;
+ case 'ArrowDown':
+ if (suggestions.size > 0 && !suggestionsHidden) {
+ e.preventDefault();
+ this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
+ }
+
+ break;
+ case 'ArrowUp':
+ if (suggestions.size > 0 && !suggestionsHidden) {
+ e.preventDefault();
+ this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
+ }
+
+ break;
+ case 'Enter':
+ case 'Tab':
+ // Select suggestion
+ if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
+ }
+
+ break;
+ }
+
+ if (e.defaultPrevented || !this.props.onKeyDown) {
+ return;
+ }
+
+ this.props.onKeyDown(e);
+ };
+
+ onBlur = () => {
+ this.setState({ suggestionsHidden: true, focused: false });
+ };
+
+ onFocus = (e) => {
+ this.setState({ focused: true });
+ if (this.props.onFocus) {
+ this.props.onFocus(e);
+ }
+ };
+
+ onSuggestionClick = (e) => {
+ const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
+ e.preventDefault();
+ this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
+ this.textarea.focus();
+ };
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
+ this.setState({ suggestionsHidden: false });
+ }
+ }
+
+ setTextarea = (c) => {
+ this.textarea = c;
+ };
+
+ onPaste = (e) => {
+ if (e.clipboardData && e.clipboardData.files.length === 1) {
+ this.props.onPaste(e.clipboardData.files);
+ e.preventDefault();
+ }
+ };
+
+ renderSuggestion = (suggestion, i) => {
+ const { selectedSuggestion } = this.state;
+ let inner, key;
+
+ if (suggestion.type === 'emoji') {
+ inner = ;
+ key = suggestion.id;
+ } else if (suggestion.type === 'hashtag') {
+ inner = ;
+ key = suggestion.name;
+ } else if (suggestion.type === 'account') {
+ inner = ;
+ key = suggestion.id;
+ }
+
+ return (
+
+ {inner}
+
+ );
+ };
+
+ render () {
+ const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, lang, children } = this.props;
+ const { suggestionsHidden } = this.state;
+
+ return [
+
+
+
+ {placeholder}
+
+
+
+
+ {children}
+
,
+
+
+
+ {suggestions.map(this.renderSuggestion)}
+
+
,
+ ];
+ }
+
+}
diff --git a/app/javascript/mastodon/components/avatar.js b/app/javascript/mastodon/components/avatar.js
deleted file mode 100644
index 013454ccf..000000000
--- a/app/javascript/mastodon/components/avatar.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { autoPlayGif } from '../initial_state';
-import classNames from 'classnames';
-
-export default class Avatar extends React.PureComponent {
-
- static propTypes = {
- account: ImmutablePropTypes.map,
- size: PropTypes.number.isRequired,
- style: PropTypes.object,
- inline: PropTypes.bool,
- animate: PropTypes.bool,
- };
-
- static defaultProps = {
- animate: autoPlayGif,
- size: 20,
- inline: false,
- };
-
- state = {
- hovering: false,
- };
-
- handleMouseEnter = () => {
- if (this.props.animate) return;
- this.setState({ hovering: true });
- };
-
- handleMouseLeave = () => {
- if (this.props.animate) return;
- this.setState({ hovering: false });
- };
-
- render () {
- const { account, size, animate, inline } = this.props;
- const { hovering } = this.state;
-
- const style = {
- ...this.props.style,
- width: `${size}px`,
- height: `${size}px`,
- };
-
- let src;
-
- if (hovering || animate) {
- src = account?.get('avatar');
- } else {
- src = account?.get('avatar_static');
- }
-
- return (
-
- {src &&
}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/avatar.jsx b/app/javascript/mastodon/components/avatar.jsx
new file mode 100644
index 000000000..013454ccf
--- /dev/null
+++ b/app/javascript/mastodon/components/avatar.jsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { autoPlayGif } from '../initial_state';
+import classNames from 'classnames';
+
+export default class Avatar extends React.PureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map,
+ size: PropTypes.number.isRequired,
+ style: PropTypes.object,
+ inline: PropTypes.bool,
+ animate: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ animate: autoPlayGif,
+ size: 20,
+ inline: false,
+ };
+
+ state = {
+ hovering: false,
+ };
+
+ handleMouseEnter = () => {
+ if (this.props.animate) return;
+ this.setState({ hovering: true });
+ };
+
+ handleMouseLeave = () => {
+ if (this.props.animate) return;
+ this.setState({ hovering: false });
+ };
+
+ render () {
+ const { account, size, animate, inline } = this.props;
+ const { hovering } = this.state;
+
+ const style = {
+ ...this.props.style,
+ width: `${size}px`,
+ height: `${size}px`,
+ };
+
+ let src;
+
+ if (hovering || animate) {
+ src = account?.get('avatar');
+ } else {
+ src = account?.get('avatar_static');
+ }
+
+ return (
+
+ {src &&
}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/avatar_composite.js b/app/javascript/mastodon/components/avatar_composite.js
deleted file mode 100644
index 220bf5b4f..000000000
--- a/app/javascript/mastodon/components/avatar_composite.js
+++ /dev/null
@@ -1,103 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { autoPlayGif } from '../initial_state';
-import Avatar from './avatar';
-
-export default class AvatarComposite extends React.PureComponent {
-
- static propTypes = {
- accounts: ImmutablePropTypes.list.isRequired,
- animate: PropTypes.bool,
- size: PropTypes.number.isRequired,
- };
-
- static defaultProps = {
- animate: autoPlayGif,
- };
-
- renderItem (account, size, index) {
- const { animate } = this.props;
-
- let width = 50;
- let height = 100;
- let top = 'auto';
- let left = 'auto';
- let bottom = 'auto';
- let right = 'auto';
-
- if (size === 1) {
- width = 100;
- }
-
- if (size === 4 || (size === 3 && index > 0)) {
- height = 50;
- }
-
- if (size === 2) {
- if (index === 0) {
- right = '1px';
- } else {
- left = '1px';
- }
- } else if (size === 3) {
- if (index === 0) {
- right = '1px';
- } else if (index > 0) {
- left = '1px';
- }
-
- if (index === 1) {
- bottom = '1px';
- } else if (index > 1) {
- top = '1px';
- }
- } else if (size === 4) {
- if (index === 0 || index === 2) {
- right = '1px';
- }
-
- if (index === 1 || index === 3) {
- left = '1px';
- }
-
- if (index < 2) {
- bottom = '1px';
- } else {
- top = '1px';
- }
- }
-
- const style = {
- left: left,
- top: top,
- right: right,
- bottom: bottom,
- width: `${width}%`,
- height: `${height}%`,
- };
-
- return (
-
- );
- }
-
- render() {
- const { accounts, size } = this.props;
-
- return (
-
- {accounts.take(4).map((account, i) => this.renderItem(account, Math.min(accounts.size, 4), i))}
-
- {accounts.size > 4 && (
-
- +{accounts.size - 4}
-
- )}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/avatar_composite.jsx b/app/javascript/mastodon/components/avatar_composite.jsx
new file mode 100644
index 000000000..220bf5b4f
--- /dev/null
+++ b/app/javascript/mastodon/components/avatar_composite.jsx
@@ -0,0 +1,103 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { autoPlayGif } from '../initial_state';
+import Avatar from './avatar';
+
+export default class AvatarComposite extends React.PureComponent {
+
+ static propTypes = {
+ accounts: ImmutablePropTypes.list.isRequired,
+ animate: PropTypes.bool,
+ size: PropTypes.number.isRequired,
+ };
+
+ static defaultProps = {
+ animate: autoPlayGif,
+ };
+
+ renderItem (account, size, index) {
+ const { animate } = this.props;
+
+ let width = 50;
+ let height = 100;
+ let top = 'auto';
+ let left = 'auto';
+ let bottom = 'auto';
+ let right = 'auto';
+
+ if (size === 1) {
+ width = 100;
+ }
+
+ if (size === 4 || (size === 3 && index > 0)) {
+ height = 50;
+ }
+
+ if (size === 2) {
+ if (index === 0) {
+ right = '1px';
+ } else {
+ left = '1px';
+ }
+ } else if (size === 3) {
+ if (index === 0) {
+ right = '1px';
+ } else if (index > 0) {
+ left = '1px';
+ }
+
+ if (index === 1) {
+ bottom = '1px';
+ } else if (index > 1) {
+ top = '1px';
+ }
+ } else if (size === 4) {
+ if (index === 0 || index === 2) {
+ right = '1px';
+ }
+
+ if (index === 1 || index === 3) {
+ left = '1px';
+ }
+
+ if (index < 2) {
+ bottom = '1px';
+ } else {
+ top = '1px';
+ }
+ }
+
+ const style = {
+ left: left,
+ top: top,
+ right: right,
+ bottom: bottom,
+ width: `${width}%`,
+ height: `${height}%`,
+ };
+
+ return (
+
+ );
+ }
+
+ render() {
+ const { accounts, size } = this.props;
+
+ return (
+
+ {accounts.take(4).map((account, i) => this.renderItem(account, Math.min(accounts.size, 4), i))}
+
+ {accounts.size > 4 && (
+
+ +{accounts.size - 4}
+
+ )}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/avatar_overlay.js b/app/javascript/mastodon/components/avatar_overlay.js
deleted file mode 100644
index 034e8ba56..000000000
--- a/app/javascript/mastodon/components/avatar_overlay.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { autoPlayGif } from '../initial_state';
-import Avatar from './avatar';
-
-export default class AvatarOverlay extends React.PureComponent {
-
- static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
- friend: ImmutablePropTypes.map.isRequired,
- animate: PropTypes.bool,
- size: PropTypes.number,
- baseSize: PropTypes.number,
- overlaySize: PropTypes.number,
- };
-
- static defaultProps = {
- animate: autoPlayGif,
- size: 46,
- baseSize: 36,
- overlaySize: 24,
- };
-
- state = {
- hovering: false,
- };
-
- handleMouseEnter = () => {
- if (this.props.animate) return;
- this.setState({ hovering: true });
- };
-
- handleMouseLeave = () => {
- if (this.props.animate) return;
- this.setState({ hovering: false });
- };
-
- render() {
- const { account, friend, animate, size, baseSize, overlaySize } = this.props;
- const { hovering } = this.state;
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/avatar_overlay.jsx b/app/javascript/mastodon/components/avatar_overlay.jsx
new file mode 100644
index 000000000..034e8ba56
--- /dev/null
+++ b/app/javascript/mastodon/components/avatar_overlay.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { autoPlayGif } from '../initial_state';
+import Avatar from './avatar';
+
+export default class AvatarOverlay extends React.PureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ friend: ImmutablePropTypes.map.isRequired,
+ animate: PropTypes.bool,
+ size: PropTypes.number,
+ baseSize: PropTypes.number,
+ overlaySize: PropTypes.number,
+ };
+
+ static defaultProps = {
+ animate: autoPlayGif,
+ size: 46,
+ baseSize: 36,
+ overlaySize: 24,
+ };
+
+ state = {
+ hovering: false,
+ };
+
+ handleMouseEnter = () => {
+ if (this.props.animate) return;
+ this.setState({ hovering: true });
+ };
+
+ handleMouseLeave = () => {
+ if (this.props.animate) return;
+ this.setState({ hovering: false });
+ };
+
+ render() {
+ const { account, friend, animate, size, baseSize, overlaySize } = this.props;
+ const { hovering } = this.state;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/blurhash.js b/app/javascript/mastodon/components/blurhash.js
deleted file mode 100644
index 2af5cfc56..000000000
--- a/app/javascript/mastodon/components/blurhash.js
+++ /dev/null
@@ -1,65 +0,0 @@
-// @ts-check
-
-import { decode } from 'blurhash';
-import React, { useRef, useEffect } from 'react';
-import PropTypes from 'prop-types';
-
-/**
- * @typedef BlurhashPropsBase
- * @property {string?} hash Hash to render
- * @property {number} width
- * Width of the blurred region in pixels. Defaults to 32
- * @property {number} [height]
- * Height of the blurred region in pixels. Defaults to width
- * @property {boolean} [dummy]
- * Whether dummy mode is enabled. If enabled, nothing is rendered
- * and canvas left untouched
- */
-
-/** @typedef {JSX.IntrinsicElements['canvas'] & BlurhashPropsBase} BlurhashProps */
-
-/**
- * Component that is used to render blurred of blurhash string
- *
- * @param {BlurhashProps} param1 Props of the component
- * @returns Canvas which will render blurred region element to embed
- */
-function Blurhash({
- hash,
- width = 32,
- height = width,
- dummy = false,
- ...canvasProps
-}) {
- const canvasRef = /** @type {import('react').MutableRefObject} */ (useRef());
-
- useEffect(() => {
- const { current: canvas } = canvasRef;
- canvas.width = canvas.width; // resets canvas
-
- if (dummy || !hash) return;
-
- try {
- const pixels = decode(hash, width, height);
- const ctx = canvas.getContext('2d');
- const imageData = new ImageData(pixels, width, height);
-
- ctx.putImageData(imageData, 0, 0);
- } catch (err) {
- console.error('Blurhash decoding failure', { err, hash });
- }
- }, [dummy, hash, width, height]);
-
- return (
-
- );
-}
-
-Blurhash.propTypes = {
- hash: PropTypes.string.isRequired,
- width: PropTypes.number,
- height: PropTypes.number,
- dummy: PropTypes.bool,
-};
-
-export default React.memo(Blurhash);
diff --git a/app/javascript/mastodon/components/blurhash.jsx b/app/javascript/mastodon/components/blurhash.jsx
new file mode 100644
index 000000000..2af5cfc56
--- /dev/null
+++ b/app/javascript/mastodon/components/blurhash.jsx
@@ -0,0 +1,65 @@
+// @ts-check
+
+import { decode } from 'blurhash';
+import React, { useRef, useEffect } from 'react';
+import PropTypes from 'prop-types';
+
+/**
+ * @typedef BlurhashPropsBase
+ * @property {string?} hash Hash to render
+ * @property {number} width
+ * Width of the blurred region in pixels. Defaults to 32
+ * @property {number} [height]
+ * Height of the blurred region in pixels. Defaults to width
+ * @property {boolean} [dummy]
+ * Whether dummy mode is enabled. If enabled, nothing is rendered
+ * and canvas left untouched
+ */
+
+/** @typedef {JSX.IntrinsicElements['canvas'] & BlurhashPropsBase} BlurhashProps */
+
+/**
+ * Component that is used to render blurred of blurhash string
+ *
+ * @param {BlurhashProps} param1 Props of the component
+ * @returns Canvas which will render blurred region element to embed
+ */
+function Blurhash({
+ hash,
+ width = 32,
+ height = width,
+ dummy = false,
+ ...canvasProps
+}) {
+ const canvasRef = /** @type {import('react').MutableRefObject} */ (useRef());
+
+ useEffect(() => {
+ const { current: canvas } = canvasRef;
+ canvas.width = canvas.width; // resets canvas
+
+ if (dummy || !hash) return;
+
+ try {
+ const pixels = decode(hash, width, height);
+ const ctx = canvas.getContext('2d');
+ const imageData = new ImageData(pixels, width, height);
+
+ ctx.putImageData(imageData, 0, 0);
+ } catch (err) {
+ console.error('Blurhash decoding failure', { err, hash });
+ }
+ }, [dummy, hash, width, height]);
+
+ return (
+
+ );
+}
+
+Blurhash.propTypes = {
+ hash: PropTypes.string.isRequired,
+ width: PropTypes.number,
+ height: PropTypes.number,
+ dummy: PropTypes.bool,
+};
+
+export default React.memo(Blurhash);
diff --git a/app/javascript/mastodon/components/button.js b/app/javascript/mastodon/components/button.js
deleted file mode 100644
index a05a75e89..000000000
--- a/app/javascript/mastodon/components/button.js
+++ /dev/null
@@ -1,57 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import classNames from 'classnames';
-
-export default class Button extends React.PureComponent {
-
- static propTypes = {
- text: PropTypes.node,
- type: PropTypes.string,
- onClick: PropTypes.func,
- disabled: PropTypes.bool,
- block: PropTypes.bool,
- secondary: PropTypes.bool,
- className: PropTypes.string,
- title: PropTypes.string,
- children: PropTypes.node,
- };
-
- static defaultProps = {
- type: 'button',
- };
-
- handleClick = (e) => {
- if (!this.props.disabled && this.props.onClick) {
- this.props.onClick(e);
- }
- };
-
- setRef = (c) => {
- this.node = c;
- };
-
- focus() {
- this.node.focus();
- }
-
- render () {
- const className = classNames('button', this.props.className, {
- 'button-secondary': this.props.secondary,
- 'button--block': this.props.block,
- });
-
- return (
-
- {this.props.text || this.props.children}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/button.jsx b/app/javascript/mastodon/components/button.jsx
new file mode 100644
index 000000000..a05a75e89
--- /dev/null
+++ b/app/javascript/mastodon/components/button.jsx
@@ -0,0 +1,57 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+export default class Button extends React.PureComponent {
+
+ static propTypes = {
+ text: PropTypes.node,
+ type: PropTypes.string,
+ onClick: PropTypes.func,
+ disabled: PropTypes.bool,
+ block: PropTypes.bool,
+ secondary: PropTypes.bool,
+ className: PropTypes.string,
+ title: PropTypes.string,
+ children: PropTypes.node,
+ };
+
+ static defaultProps = {
+ type: 'button',
+ };
+
+ handleClick = (e) => {
+ if (!this.props.disabled && this.props.onClick) {
+ this.props.onClick(e);
+ }
+ };
+
+ setRef = (c) => {
+ this.node = c;
+ };
+
+ focus() {
+ this.node.focus();
+ }
+
+ render () {
+ const className = classNames('button', this.props.className, {
+ 'button-secondary': this.props.secondary,
+ 'button--block': this.props.block,
+ });
+
+ return (
+
+ {this.props.text || this.props.children}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/check.js b/app/javascript/mastodon/components/check.js
deleted file mode 100644
index ee2ef1595..000000000
--- a/app/javascript/mastodon/components/check.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import React from 'react';
-
-const Check = () => (
-
-
-
-);
-
-export default Check;
diff --git a/app/javascript/mastodon/components/check.jsx b/app/javascript/mastodon/components/check.jsx
new file mode 100644
index 000000000..ee2ef1595
--- /dev/null
+++ b/app/javascript/mastodon/components/check.jsx
@@ -0,0 +1,9 @@
+import React from 'react';
+
+const Check = () => (
+
+
+
+);
+
+export default Check;
diff --git a/app/javascript/mastodon/components/column.js b/app/javascript/mastodon/components/column.js
deleted file mode 100644
index 5780a1397..000000000
--- a/app/javascript/mastodon/components/column.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { supportsPassiveEvents } from 'detect-passive-events';
-import { scrollTop } from '../scroll';
-
-export default class Column extends React.PureComponent {
-
- static propTypes = {
- children: PropTypes.node,
- label: PropTypes.string,
- bindToDocument: PropTypes.bool,
- };
-
- scrollTop () {
- const scrollable = this.props.bindToDocument ? document.scrollingElement : this.node.querySelector('.scrollable');
-
- if (!scrollable) {
- return;
- }
-
- this._interruptScrollAnimation = scrollTop(scrollable);
- }
-
- handleWheel = () => {
- if (typeof this._interruptScrollAnimation !== 'function') {
- return;
- }
-
- this._interruptScrollAnimation();
- };
-
- setRef = c => {
- this.node = c;
- };
-
- componentDidMount () {
- if (this.props.bindToDocument) {
- document.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
- } else {
- this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
- }
- }
-
- componentWillUnmount () {
- if (this.props.bindToDocument) {
- document.removeEventListener('wheel', this.handleWheel);
- } else {
- this.node.removeEventListener('wheel', this.handleWheel);
- }
- }
-
- render () {
- const { label, children } = this.props;
-
- return (
-
- {children}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/column.jsx b/app/javascript/mastodon/components/column.jsx
new file mode 100644
index 000000000..5780a1397
--- /dev/null
+++ b/app/javascript/mastodon/components/column.jsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { supportsPassiveEvents } from 'detect-passive-events';
+import { scrollTop } from '../scroll';
+
+export default class Column extends React.PureComponent {
+
+ static propTypes = {
+ children: PropTypes.node,
+ label: PropTypes.string,
+ bindToDocument: PropTypes.bool,
+ };
+
+ scrollTop () {
+ const scrollable = this.props.bindToDocument ? document.scrollingElement : this.node.querySelector('.scrollable');
+
+ if (!scrollable) {
+ return;
+ }
+
+ this._interruptScrollAnimation = scrollTop(scrollable);
+ }
+
+ handleWheel = () => {
+ if (typeof this._interruptScrollAnimation !== 'function') {
+ return;
+ }
+
+ this._interruptScrollAnimation();
+ };
+
+ setRef = c => {
+ this.node = c;
+ };
+
+ componentDidMount () {
+ if (this.props.bindToDocument) {
+ document.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
+ } else {
+ this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
+ }
+ }
+
+ componentWillUnmount () {
+ if (this.props.bindToDocument) {
+ document.removeEventListener('wheel', this.handleWheel);
+ } else {
+ this.node.removeEventListener('wheel', this.handleWheel);
+ }
+ }
+
+ render () {
+ const { label, children } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/column_back_button.js b/app/javascript/mastodon/components/column_back_button.js
deleted file mode 100644
index 5bbf11652..000000000
--- a/app/javascript/mastodon/components/column_back_button.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import React from 'react';
-import { FormattedMessage } from 'react-intl';
-import PropTypes from 'prop-types';
-import Icon from 'mastodon/components/icon';
-import { createPortal } from 'react-dom';
-
-export default class ColumnBackButton extends React.PureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- };
-
- static propTypes = {
- multiColumn: PropTypes.bool,
- };
-
- handleClick = () => {
- if (window.history && window.history.length === 1) {
- this.context.router.history.push('/');
- } else {
- this.context.router.history.goBack();
- }
- };
-
- render () {
- const { multiColumn } = this.props;
-
- const component = (
-
-
-
-
- );
-
- if (multiColumn) {
- return component;
- } else {
- // The portal container and the component may be rendered to the DOM in
- // the same React render pass, so the container might not be available at
- // the time `render()` is called.
- const container = document.getElementById('tabs-bar__portal');
- if (container === null) {
- // The container wasn't available, force a re-render so that the
- // component can eventually be inserted in the container and not scroll
- // with the rest of the area.
- this.forceUpdate();
- return component;
- } else {
- return createPortal(component, container);
- }
- }
- }
-
-}
diff --git a/app/javascript/mastodon/components/column_back_button.jsx b/app/javascript/mastodon/components/column_back_button.jsx
new file mode 100644
index 000000000..5bbf11652
--- /dev/null
+++ b/app/javascript/mastodon/components/column_back_button.jsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+import Icon from 'mastodon/components/icon';
+import { createPortal } from 'react-dom';
+
+export default class ColumnBackButton extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ multiColumn: PropTypes.bool,
+ };
+
+ handleClick = () => {
+ if (window.history && window.history.length === 1) {
+ this.context.router.history.push('/');
+ } else {
+ this.context.router.history.goBack();
+ }
+ };
+
+ render () {
+ const { multiColumn } = this.props;
+
+ const component = (
+
+
+
+
+ );
+
+ if (multiColumn) {
+ return component;
+ } else {
+ // The portal container and the component may be rendered to the DOM in
+ // the same React render pass, so the container might not be available at
+ // the time `render()` is called.
+ const container = document.getElementById('tabs-bar__portal');
+ if (container === null) {
+ // The container wasn't available, force a re-render so that the
+ // component can eventually be inserted in the container and not scroll
+ // with the rest of the area.
+ this.forceUpdate();
+ return component;
+ } else {
+ return createPortal(component, container);
+ }
+ }
+ }
+
+}
diff --git a/app/javascript/mastodon/components/column_back_button_slim.js b/app/javascript/mastodon/components/column_back_button_slim.js
deleted file mode 100644
index cc8bfb151..000000000
--- a/app/javascript/mastodon/components/column_back_button_slim.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import React from 'react';
-import { FormattedMessage } from 'react-intl';
-import ColumnBackButton from './column_back_button';
-import Icon from 'mastodon/components/icon';
-
-export default class ColumnBackButtonSlim extends ColumnBackButton {
-
- render () {
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/column_back_button_slim.jsx b/app/javascript/mastodon/components/column_back_button_slim.jsx
new file mode 100644
index 000000000..cc8bfb151
--- /dev/null
+++ b/app/javascript/mastodon/components/column_back_button_slim.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import ColumnBackButton from './column_back_button';
+import Icon from 'mastodon/components/icon';
+
+export default class ColumnBackButtonSlim extends ColumnBackButton {
+
+ render () {
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js
deleted file mode 100644
index 38f6ad60f..000000000
--- a/app/javascript/mastodon/components/column_header.js
+++ /dev/null
@@ -1,215 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { createPortal } from 'react-dom';
-import classNames from 'classnames';
-import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
-import Icon from 'mastodon/components/icon';
-
-const messages = defineMessages({
- show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
- hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
- moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' },
- moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' },
-});
-
-export default @injectIntl
-class ColumnHeader extends React.PureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- identity: PropTypes.object,
- };
-
- static propTypes = {
- intl: PropTypes.object.isRequired,
- title: PropTypes.node,
- icon: PropTypes.string,
- active: PropTypes.bool,
- multiColumn: PropTypes.bool,
- extraButton: PropTypes.node,
- showBackButton: PropTypes.bool,
- children: PropTypes.node,
- pinned: PropTypes.bool,
- placeholder: PropTypes.bool,
- onPin: PropTypes.func,
- onMove: PropTypes.func,
- onClick: PropTypes.func,
- appendContent: PropTypes.node,
- collapseIssues: PropTypes.bool,
- };
-
- state = {
- collapsed: true,
- animating: false,
- };
-
- historyBack = () => {
- if (window.history && window.history.length === 1) {
- this.context.router.history.push('/');
- } else {
- this.context.router.history.goBack();
- }
- };
-
- handleToggleClick = (e) => {
- e.stopPropagation();
- this.setState({ collapsed: !this.state.collapsed, animating: true });
- };
-
- handleTitleClick = () => {
- this.props.onClick?.();
- };
-
- handleMoveLeft = () => {
- this.props.onMove(-1);
- };
-
- handleMoveRight = () => {
- this.props.onMove(1);
- };
-
- handleBackClick = () => {
- this.historyBack();
- };
-
- handleTransitionEnd = () => {
- this.setState({ animating: false });
- };
-
- handlePin = () => {
- if (!this.props.pinned) {
- this.context.router.history.replace('/');
- }
-
- this.props.onPin();
- };
-
- render () {
- const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props;
- const { collapsed, animating } = this.state;
-
- const wrapperClassName = classNames('column-header__wrapper', {
- 'active': active,
- });
-
- const buttonClassName = classNames('column-header', {
- 'active': active,
- });
-
- const collapsibleClassName = classNames('column-header__collapsible', {
- 'collapsed': collapsed,
- 'animating': animating,
- });
-
- const collapsibleButtonClassName = classNames('column-header__button', {
- 'active': !collapsed,
- });
-
- let extraContent, pinButton, moveButtons, backButton, collapseButton;
-
- if (children) {
- extraContent = (
-
- {children}
-
- );
- }
-
- if (multiColumn && pinned) {
- pinButton = ;
-
- moveButtons = (
-
-
-
-
- );
- } else if (multiColumn && this.props.onPin) {
- pinButton = ;
- }
-
- if (!pinned && (multiColumn || showBackButton)) {
- backButton = (
-
-
-
-
- );
- }
-
- const collapsedContent = [
- extraContent,
- ];
-
- if (multiColumn) {
- collapsedContent.push(pinButton);
- collapsedContent.push(moveButtons);
- }
-
- if (this.context.identity.signedIn && (children || (multiColumn && this.props.onPin))) {
- collapseButton = (
-
-
-
- {collapseIssues && }
-
-
- );
- }
-
- const hasTitle = icon && title;
-
- const component = (
-
-
- {hasTitle && (
-
-
- {title}
-
- )}
-
- {!hasTitle && backButton}
-
-
- {hasTitle && backButton}
- {extraButton}
- {collapseButton}
-
-
-
-
-
- {(!collapsed || animating) && collapsedContent}
-
-
-
- {appendContent}
-
- );
-
- if (multiColumn || placeholder) {
- return component;
- } else {
- // The portal container and the component may be rendered to the DOM in
- // the same React render pass, so the container might not be available at
- // the time `render()` is called.
- const container = document.getElementById('tabs-bar__portal');
- if (container === null) {
- // The container wasn't available, force a re-render so that the
- // component can eventually be inserted in the container and not scroll
- // with the rest of the area.
- this.forceUpdate();
- return component;
- } else {
- return createPortal(component, container);
- }
- }
- }
-
-}
diff --git a/app/javascript/mastodon/components/column_header.jsx b/app/javascript/mastodon/components/column_header.jsx
new file mode 100644
index 000000000..38f6ad60f
--- /dev/null
+++ b/app/javascript/mastodon/components/column_header.jsx
@@ -0,0 +1,215 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { createPortal } from 'react-dom';
+import classNames from 'classnames';
+import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
+import Icon from 'mastodon/components/icon';
+
+const messages = defineMessages({
+ show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
+ hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
+ moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' },
+ moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' },
+});
+
+export default @injectIntl
+class ColumnHeader extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ identity: PropTypes.object,
+ };
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ title: PropTypes.node,
+ icon: PropTypes.string,
+ active: PropTypes.bool,
+ multiColumn: PropTypes.bool,
+ extraButton: PropTypes.node,
+ showBackButton: PropTypes.bool,
+ children: PropTypes.node,
+ pinned: PropTypes.bool,
+ placeholder: PropTypes.bool,
+ onPin: PropTypes.func,
+ onMove: PropTypes.func,
+ onClick: PropTypes.func,
+ appendContent: PropTypes.node,
+ collapseIssues: PropTypes.bool,
+ };
+
+ state = {
+ collapsed: true,
+ animating: false,
+ };
+
+ historyBack = () => {
+ if (window.history && window.history.length === 1) {
+ this.context.router.history.push('/');
+ } else {
+ this.context.router.history.goBack();
+ }
+ };
+
+ handleToggleClick = (e) => {
+ e.stopPropagation();
+ this.setState({ collapsed: !this.state.collapsed, animating: true });
+ };
+
+ handleTitleClick = () => {
+ this.props.onClick?.();
+ };
+
+ handleMoveLeft = () => {
+ this.props.onMove(-1);
+ };
+
+ handleMoveRight = () => {
+ this.props.onMove(1);
+ };
+
+ handleBackClick = () => {
+ this.historyBack();
+ };
+
+ handleTransitionEnd = () => {
+ this.setState({ animating: false });
+ };
+
+ handlePin = () => {
+ if (!this.props.pinned) {
+ this.context.router.history.replace('/');
+ }
+
+ this.props.onPin();
+ };
+
+ render () {
+ const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props;
+ const { collapsed, animating } = this.state;
+
+ const wrapperClassName = classNames('column-header__wrapper', {
+ 'active': active,
+ });
+
+ const buttonClassName = classNames('column-header', {
+ 'active': active,
+ });
+
+ const collapsibleClassName = classNames('column-header__collapsible', {
+ 'collapsed': collapsed,
+ 'animating': animating,
+ });
+
+ const collapsibleButtonClassName = classNames('column-header__button', {
+ 'active': !collapsed,
+ });
+
+ let extraContent, pinButton, moveButtons, backButton, collapseButton;
+
+ if (children) {
+ extraContent = (
+
+ {children}
+
+ );
+ }
+
+ if (multiColumn && pinned) {
+ pinButton = ;
+
+ moveButtons = (
+
+
+
+
+ );
+ } else if (multiColumn && this.props.onPin) {
+ pinButton = ;
+ }
+
+ if (!pinned && (multiColumn || showBackButton)) {
+ backButton = (
+
+
+
+
+ );
+ }
+
+ const collapsedContent = [
+ extraContent,
+ ];
+
+ if (multiColumn) {
+ collapsedContent.push(pinButton);
+ collapsedContent.push(moveButtons);
+ }
+
+ if (this.context.identity.signedIn && (children || (multiColumn && this.props.onPin))) {
+ collapseButton = (
+
+
+
+ {collapseIssues && }
+
+
+ );
+ }
+
+ const hasTitle = icon && title;
+
+ const component = (
+
+
+ {hasTitle && (
+
+
+ {title}
+
+ )}
+
+ {!hasTitle && backButton}
+
+
+ {hasTitle && backButton}
+ {extraButton}
+ {collapseButton}
+
+
+
+
+
+ {(!collapsed || animating) && collapsedContent}
+
+
+
+ {appendContent}
+
+ );
+
+ if (multiColumn || placeholder) {
+ return component;
+ } else {
+ // The portal container and the component may be rendered to the DOM in
+ // the same React render pass, so the container might not be available at
+ // the time `render()` is called.
+ const container = document.getElementById('tabs-bar__portal');
+ if (container === null) {
+ // The container wasn't available, force a re-render so that the
+ // component can eventually be inserted in the container and not scroll
+ // with the rest of the area.
+ this.forceUpdate();
+ return component;
+ } else {
+ return createPortal(component, container);
+ }
+ }
+ }
+
+}
diff --git a/app/javascript/mastodon/components/common_counter.js b/app/javascript/mastodon/components/common_counter.js
deleted file mode 100644
index dd9b62de9..000000000
--- a/app/javascript/mastodon/components/common_counter.js
+++ /dev/null
@@ -1,62 +0,0 @@
-// @ts-check
-import React from 'react';
-import { FormattedMessage } from 'react-intl';
-
-/**
- * Returns custom renderer for one of the common counter types
- *
- * @param {"statuses" | "following" | "followers"} counterType
- * Type of the counter
- * @param {boolean} isBold Whether display number must be displayed in bold
- * @returns {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
- * Renderer function
- * @throws If counterType is not covered by this function
- */
-export function counterRenderer(counterType, isBold = true) {
- /**
- * @type {(displayNumber: JSX.Element) => JSX.Element}
- */
- const renderCounter = isBold
- ? (displayNumber) => {displayNumber}
- : (displayNumber) => displayNumber;
-
- switch (counterType) {
- case 'statuses': {
- return (displayNumber, pluralReady) => (
-
- );
- }
- case 'following': {
- return (displayNumber, pluralReady) => (
-
- );
- }
- case 'followers': {
- return (displayNumber, pluralReady) => (
-
- );
- }
- default: throw Error(`Incorrect counter name: ${counterType}. Ensure it accepted by commonCounter function`);
- }
-}
diff --git a/app/javascript/mastodon/components/common_counter.jsx b/app/javascript/mastodon/components/common_counter.jsx
new file mode 100644
index 000000000..dd9b62de9
--- /dev/null
+++ b/app/javascript/mastodon/components/common_counter.jsx
@@ -0,0 +1,62 @@
+// @ts-check
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+
+/**
+ * Returns custom renderer for one of the common counter types
+ *
+ * @param {"statuses" | "following" | "followers"} counterType
+ * Type of the counter
+ * @param {boolean} isBold Whether display number must be displayed in bold
+ * @returns {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
+ * Renderer function
+ * @throws If counterType is not covered by this function
+ */
+export function counterRenderer(counterType, isBold = true) {
+ /**
+ * @type {(displayNumber: JSX.Element) => JSX.Element}
+ */
+ const renderCounter = isBold
+ ? (displayNumber) => {displayNumber}
+ : (displayNumber) => displayNumber;
+
+ switch (counterType) {
+ case 'statuses': {
+ return (displayNumber, pluralReady) => (
+
+ );
+ }
+ case 'following': {
+ return (displayNumber, pluralReady) => (
+
+ );
+ }
+ case 'followers': {
+ return (displayNumber, pluralReady) => (
+
+ );
+ }
+ default: throw Error(`Incorrect counter name: ${counterType}. Ensure it accepted by commonCounter function`);
+ }
+}
diff --git a/app/javascript/mastodon/components/dismissable_banner.js b/app/javascript/mastodon/components/dismissable_banner.js
deleted file mode 100644
index 47ca7e4bc..000000000
--- a/app/javascript/mastodon/components/dismissable_banner.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import React from 'react';
-import IconButton from './icon_button';
-import PropTypes from 'prop-types';
-import { injectIntl, defineMessages } from 'react-intl';
-import { bannerSettings } from 'mastodon/settings';
-
-const messages = defineMessages({
- dismiss: { id: 'dismissable_banner.dismiss', defaultMessage: 'Dismiss' },
-});
-
-export default @injectIntl
-class DismissableBanner extends React.PureComponent {
-
- static propTypes = {
- id: PropTypes.string.isRequired,
- children: PropTypes.node,
- intl: PropTypes.object.isRequired,
- };
-
- state = {
- visible: !bannerSettings.get(this.props.id),
- };
-
- handleDismiss = () => {
- const { id } = this.props;
- this.setState({ visible: false }, () => bannerSettings.set(id, true));
- };
-
- render () {
- const { visible } = this.state;
-
- if (!visible) {
- return null;
- }
-
- const { children, intl } = this.props;
-
- return (
-
-
- {children}
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/dismissable_banner.jsx b/app/javascript/mastodon/components/dismissable_banner.jsx
new file mode 100644
index 000000000..47ca7e4bc
--- /dev/null
+++ b/app/javascript/mastodon/components/dismissable_banner.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import IconButton from './icon_button';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+import { bannerSettings } from 'mastodon/settings';
+
+const messages = defineMessages({
+ dismiss: { id: 'dismissable_banner.dismiss', defaultMessage: 'Dismiss' },
+});
+
+export default @injectIntl
+class DismissableBanner extends React.PureComponent {
+
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ children: PropTypes.node,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ visible: !bannerSettings.get(this.props.id),
+ };
+
+ handleDismiss = () => {
+ const { id } = this.props;
+ this.setState({ visible: false }, () => bannerSettings.set(id, true));
+ };
+
+ render () {
+ const { visible } = this.state;
+
+ if (!visible) {
+ return null;
+ }
+
+ const { children, intl } = this.props;
+
+ return (
+
+
+ {children}
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js
deleted file mode 100644
index 1dd9fb1d6..000000000
--- a/app/javascript/mastodon/components/display_name.js
+++ /dev/null
@@ -1,79 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import { autoPlayGif } from 'mastodon/initial_state';
-import Skeleton from 'mastodon/components/skeleton';
-
-export default class DisplayName extends React.PureComponent {
-
- static propTypes = {
- account: ImmutablePropTypes.map,
- others: ImmutablePropTypes.list,
- localDomain: PropTypes.string,
- };
-
- handleMouseEnter = ({ currentTarget }) => {
- if (autoPlayGif) {
- return;
- }
-
- const emojis = currentTarget.querySelectorAll('.custom-emoji');
-
- for (var i = 0; i < emojis.length; i++) {
- let emoji = emojis[i];
- emoji.src = emoji.getAttribute('data-original');
- }
- };
-
- handleMouseLeave = ({ currentTarget }) => {
- if (autoPlayGif) {
- return;
- }
-
- const emojis = currentTarget.querySelectorAll('.custom-emoji');
-
- for (var i = 0; i < emojis.length; i++) {
- let emoji = emojis[i];
- emoji.src = emoji.getAttribute('data-static');
- }
- };
-
- render () {
- const { others, localDomain } = this.props;
-
- let displayName, suffix, account;
-
- if (others && others.size > 1) {
- displayName = others.take(2).map(a => ).reduce((prev, cur) => [prev, ', ', cur]);
-
- if (others.size - 2 > 0) {
- suffix = `+${others.size - 2}`;
- }
- } else if ((others && others.size > 0) || this.props.account) {
- if (others && others.size > 0) {
- account = others.first();
- } else {
- account = this.props.account;
- }
-
- let acct = account.get('acct');
-
- if (acct.indexOf('@') === -1 && localDomain) {
- acct = `${acct}@${localDomain}`;
- }
-
- displayName = ;
- suffix = @{acct} ;
- } else {
- displayName = ;
- suffix = ;
- }
-
- return (
-
- {displayName} {suffix}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/display_name.jsx b/app/javascript/mastodon/components/display_name.jsx
new file mode 100644
index 000000000..1dd9fb1d6
--- /dev/null
+++ b/app/javascript/mastodon/components/display_name.jsx
@@ -0,0 +1,79 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { autoPlayGif } from 'mastodon/initial_state';
+import Skeleton from 'mastodon/components/skeleton';
+
+export default class DisplayName extends React.PureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map,
+ others: ImmutablePropTypes.list,
+ localDomain: PropTypes.string,
+ };
+
+ handleMouseEnter = ({ currentTarget }) => {
+ if (autoPlayGif) {
+ return;
+ }
+
+ const emojis = currentTarget.querySelectorAll('.custom-emoji');
+
+ for (var i = 0; i < emojis.length; i++) {
+ let emoji = emojis[i];
+ emoji.src = emoji.getAttribute('data-original');
+ }
+ };
+
+ handleMouseLeave = ({ currentTarget }) => {
+ if (autoPlayGif) {
+ return;
+ }
+
+ const emojis = currentTarget.querySelectorAll('.custom-emoji');
+
+ for (var i = 0; i < emojis.length; i++) {
+ let emoji = emojis[i];
+ emoji.src = emoji.getAttribute('data-static');
+ }
+ };
+
+ render () {
+ const { others, localDomain } = this.props;
+
+ let displayName, suffix, account;
+
+ if (others && others.size > 1) {
+ displayName = others.take(2).map(a => ).reduce((prev, cur) => [prev, ', ', cur]);
+
+ if (others.size - 2 > 0) {
+ suffix = `+${others.size - 2}`;
+ }
+ } else if ((others && others.size > 0) || this.props.account) {
+ if (others && others.size > 0) {
+ account = others.first();
+ } else {
+ account = this.props.account;
+ }
+
+ let acct = account.get('acct');
+
+ if (acct.indexOf('@') === -1 && localDomain) {
+ acct = `${acct}@${localDomain}`;
+ }
+
+ displayName = ;
+ suffix = @{acct} ;
+ } else {
+ displayName = ;
+ suffix = ;
+ }
+
+ return (
+
+ {displayName} {suffix}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/domain.js b/app/javascript/mastodon/components/domain.js
deleted file mode 100644
index e09fa4591..000000000
--- a/app/javascript/mastodon/components/domain.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import IconButton from './icon_button';
-import { defineMessages, injectIntl } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-const messages = defineMessages({
- unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
-});
-
-export default @injectIntl
-class Account extends ImmutablePureComponent {
-
- static propTypes = {
- domain: PropTypes.string,
- onUnblockDomain: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- handleDomainUnblock = () => {
- this.props.onUnblockDomain(this.props.domain);
- };
-
- render () {
- const { domain, intl } = this.props;
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/domain.jsx b/app/javascript/mastodon/components/domain.jsx
new file mode 100644
index 000000000..e09fa4591
--- /dev/null
+++ b/app/javascript/mastodon/components/domain.jsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import IconButton from './icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
+});
+
+export default @injectIntl
+class Account extends ImmutablePureComponent {
+
+ static propTypes = {
+ domain: PropTypes.string,
+ onUnblockDomain: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleDomainUnblock = () => {
+ this.props.onUnblockDomain(this.props.domain);
+ };
+
+ render () {
+ const { domain, intl } = this.props;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js
deleted file mode 100644
index c04c513fb..000000000
--- a/app/javascript/mastodon/components/dropdown_menu.js
+++ /dev/null
@@ -1,335 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import IconButton from './icon_button';
-import Overlay from 'react-overlays/Overlay';
-import { supportsPassiveEvents } from 'detect-passive-events';
-import classNames from 'classnames';
-import { CircularProgress } from 'mastodon/components/loading_indicator';
-
-const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
-let id = 0;
-
-class DropdownMenu extends React.PureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- };
-
- static propTypes = {
- items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired,
- loading: PropTypes.bool,
- scrollable: PropTypes.bool,
- onClose: PropTypes.func.isRequired,
- style: PropTypes.object,
- openedViaKeyboard: PropTypes.bool,
- renderItem: PropTypes.func,
- renderHeader: PropTypes.func,
- onItemClick: PropTypes.func.isRequired,
- };
-
- static defaultProps = {
- style: {},
- };
-
- handleDocumentClick = e => {
- if (this.node && !this.node.contains(e.target)) {
- this.props.onClose();
- }
- };
-
- componentDidMount () {
- document.addEventListener('click', this.handleDocumentClick, false);
- document.addEventListener('keydown', this.handleKeyDown, false);
- document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
-
- if (this.focusedItem && this.props.openedViaKeyboard) {
- this.focusedItem.focus({ preventScroll: true });
- }
- }
-
- componentWillUnmount () {
- document.removeEventListener('click', this.handleDocumentClick, false);
- document.removeEventListener('keydown', this.handleKeyDown, false);
- document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
- }
-
- setRef = c => {
- this.node = c;
- };
-
- setFocusRef = c => {
- this.focusedItem = c;
- };
-
- handleKeyDown = e => {
- const items = Array.from(this.node.querySelectorAll('a, button'));
- const index = items.indexOf(document.activeElement);
- let element = null;
-
- switch(e.key) {
- case 'ArrowDown':
- element = items[index+1] || items[0];
- break;
- case 'ArrowUp':
- element = items[index-1] || items[items.length-1];
- break;
- case 'Tab':
- if (e.shiftKey) {
- element = items[index-1] || items[items.length-1];
- } else {
- element = items[index+1] || items[0];
- }
- break;
- case 'Home':
- element = items[0];
- break;
- case 'End':
- element = items[items.length-1];
- break;
- case 'Escape':
- this.props.onClose();
- break;
- }
-
- if (element) {
- element.focus();
- e.preventDefault();
- e.stopPropagation();
- }
- };
-
- handleItemKeyPress = e => {
- if (e.key === 'Enter' || e.key === ' ') {
- this.handleClick(e);
- }
- };
-
- handleClick = e => {
- const { onItemClick } = this.props;
- onItemClick(e);
- };
-
- renderItem = (option, i) => {
- if (option === null) {
- return ;
- }
-
- const { text, href = '#', target = '_blank', method } = option;
-
- return (
-
-
- {text}
-
-
- );
- };
-
- render () {
- const { items, scrollable, renderHeader, loading } = this.props;
-
- let renderItem = this.props.renderItem || this.renderItem;
-
- return (
-
- {loading && (
-
- )}
-
- {!loading && renderHeader && (
-
- {renderHeader(items)}
-
- )}
-
- {!loading && (
-
- {items.map((option, i) => renderItem(option, i, { onClick: this.handleClick, onKeyPress: this.handleItemKeyPress }))}
-
- )}
-
- );
- }
-
-}
-
-export default class Dropdown extends React.PureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- };
-
- static propTypes = {
- children: PropTypes.node,
- icon: PropTypes.string,
- items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired,
- loading: PropTypes.bool,
- size: PropTypes.number,
- title: PropTypes.string,
- disabled: PropTypes.bool,
- scrollable: PropTypes.bool,
- status: ImmutablePropTypes.map,
- isUserTouching: PropTypes.func,
- onOpen: PropTypes.func.isRequired,
- onClose: PropTypes.func.isRequired,
- openDropdownId: PropTypes.number,
- openedViaKeyboard: PropTypes.bool,
- renderItem: PropTypes.func,
- renderHeader: PropTypes.func,
- onItemClick: PropTypes.func,
- };
-
- static defaultProps = {
- title: 'Menu',
- };
-
- state = {
- id: id++,
- };
-
- handleClick = ({ type }) => {
- if (this.state.id === this.props.openDropdownId) {
- this.handleClose();
- } else {
- this.props.onOpen(this.state.id, this.handleItemClick, type !== 'click');
- }
- };
-
- handleClose = () => {
- if (this.activeElement) {
- this.activeElement.focus({ preventScroll: true });
- this.activeElement = null;
- }
- this.props.onClose(this.state.id);
- };
-
- handleMouseDown = () => {
- if (!this.state.open) {
- this.activeElement = document.activeElement;
- }
- };
-
- handleButtonKeyDown = (e) => {
- switch(e.key) {
- case ' ':
- case 'Enter':
- this.handleMouseDown();
- break;
- }
- };
-
- handleKeyPress = (e) => {
- switch(e.key) {
- case ' ':
- case 'Enter':
- this.handleClick(e);
- e.stopPropagation();
- e.preventDefault();
- break;
- }
- };
-
- handleItemClick = e => {
- const { onItemClick } = this.props;
- const i = Number(e.currentTarget.getAttribute('data-index'));
- const item = this.props.items[i];
-
- this.handleClose();
-
- if (typeof onItemClick === 'function') {
- e.preventDefault();
- onItemClick(item, i);
- } else if (item && typeof item.action === 'function') {
- e.preventDefault();
- item.action();
- } else if (item && item.to) {
- e.preventDefault();
- this.context.router.history.push(item.to);
- }
- };
-
- setTargetRef = c => {
- this.target = c;
- };
-
- findTarget = () => {
- return this.target;
- };
-
- componentWillUnmount = () => {
- if (this.state.id === this.props.openDropdownId) {
- this.handleClose();
- }
- };
-
- close = () => {
- this.handleClose();
- };
-
- render () {
- const {
- icon,
- items,
- size,
- title,
- disabled,
- loading,
- scrollable,
- openDropdownId,
- openedViaKeyboard,
- children,
- renderItem,
- renderHeader,
- } = this.props;
-
- const open = this.state.id === openDropdownId;
-
- const button = children ? React.cloneElement(React.Children.only(children), {
- onClick: this.handleClick,
- onMouseDown: this.handleMouseDown,
- onKeyDown: this.handleButtonKeyDown,
- onKeyPress: this.handleKeyPress,
- }) : (
-
- );
-
- return (
-
-
- {button}
-
-
- {({ props, arrowProps, placement }) => (
-
- )}
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/dropdown_menu.jsx b/app/javascript/mastodon/components/dropdown_menu.jsx
new file mode 100644
index 000000000..c04c513fb
--- /dev/null
+++ b/app/javascript/mastodon/components/dropdown_menu.jsx
@@ -0,0 +1,335 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import IconButton from './icon_button';
+import Overlay from 'react-overlays/Overlay';
+import { supportsPassiveEvents } from 'detect-passive-events';
+import classNames from 'classnames';
+import { CircularProgress } from 'mastodon/components/loading_indicator';
+
+const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
+let id = 0;
+
+class DropdownMenu extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired,
+ loading: PropTypes.bool,
+ scrollable: PropTypes.bool,
+ onClose: PropTypes.func.isRequired,
+ style: PropTypes.object,
+ openedViaKeyboard: PropTypes.bool,
+ renderItem: PropTypes.func,
+ renderHeader: PropTypes.func,
+ onItemClick: PropTypes.func.isRequired,
+ };
+
+ static defaultProps = {
+ style: {},
+ };
+
+ handleDocumentClick = e => {
+ if (this.node && !this.node.contains(e.target)) {
+ this.props.onClose();
+ }
+ };
+
+ componentDidMount () {
+ document.addEventListener('click', this.handleDocumentClick, false);
+ document.addEventListener('keydown', this.handleKeyDown, false);
+ document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+
+ if (this.focusedItem && this.props.openedViaKeyboard) {
+ this.focusedItem.focus({ preventScroll: true });
+ }
+ }
+
+ componentWillUnmount () {
+ document.removeEventListener('click', this.handleDocumentClick, false);
+ document.removeEventListener('keydown', this.handleKeyDown, false);
+ document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ setRef = c => {
+ this.node = c;
+ };
+
+ setFocusRef = c => {
+ this.focusedItem = c;
+ };
+
+ handleKeyDown = e => {
+ const items = Array.from(this.node.querySelectorAll('a, button'));
+ const index = items.indexOf(document.activeElement);
+ let element = null;
+
+ switch(e.key) {
+ case 'ArrowDown':
+ element = items[index+1] || items[0];
+ break;
+ case 'ArrowUp':
+ element = items[index-1] || items[items.length-1];
+ break;
+ case 'Tab':
+ if (e.shiftKey) {
+ element = items[index-1] || items[items.length-1];
+ } else {
+ element = items[index+1] || items[0];
+ }
+ break;
+ case 'Home':
+ element = items[0];
+ break;
+ case 'End':
+ element = items[items.length-1];
+ break;
+ case 'Escape':
+ this.props.onClose();
+ break;
+ }
+
+ if (element) {
+ element.focus();
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ };
+
+ handleItemKeyPress = e => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ this.handleClick(e);
+ }
+ };
+
+ handleClick = e => {
+ const { onItemClick } = this.props;
+ onItemClick(e);
+ };
+
+ renderItem = (option, i) => {
+ if (option === null) {
+ return ;
+ }
+
+ const { text, href = '#', target = '_blank', method } = option;
+
+ return (
+
+
+ {text}
+
+
+ );
+ };
+
+ render () {
+ const { items, scrollable, renderHeader, loading } = this.props;
+
+ let renderItem = this.props.renderItem || this.renderItem;
+
+ return (
+
+ {loading && (
+
+ )}
+
+ {!loading && renderHeader && (
+
+ {renderHeader(items)}
+
+ )}
+
+ {!loading && (
+
+ {items.map((option, i) => renderItem(option, i, { onClick: this.handleClick, onKeyPress: this.handleItemKeyPress }))}
+
+ )}
+
+ );
+ }
+
+}
+
+export default class Dropdown extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ children: PropTypes.node,
+ icon: PropTypes.string,
+ items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired,
+ loading: PropTypes.bool,
+ size: PropTypes.number,
+ title: PropTypes.string,
+ disabled: PropTypes.bool,
+ scrollable: PropTypes.bool,
+ status: ImmutablePropTypes.map,
+ isUserTouching: PropTypes.func,
+ onOpen: PropTypes.func.isRequired,
+ onClose: PropTypes.func.isRequired,
+ openDropdownId: PropTypes.number,
+ openedViaKeyboard: PropTypes.bool,
+ renderItem: PropTypes.func,
+ renderHeader: PropTypes.func,
+ onItemClick: PropTypes.func,
+ };
+
+ static defaultProps = {
+ title: 'Menu',
+ };
+
+ state = {
+ id: id++,
+ };
+
+ handleClick = ({ type }) => {
+ if (this.state.id === this.props.openDropdownId) {
+ this.handleClose();
+ } else {
+ this.props.onOpen(this.state.id, this.handleItemClick, type !== 'click');
+ }
+ };
+
+ handleClose = () => {
+ if (this.activeElement) {
+ this.activeElement.focus({ preventScroll: true });
+ this.activeElement = null;
+ }
+ this.props.onClose(this.state.id);
+ };
+
+ handleMouseDown = () => {
+ if (!this.state.open) {
+ this.activeElement = document.activeElement;
+ }
+ };
+
+ handleButtonKeyDown = (e) => {
+ switch(e.key) {
+ case ' ':
+ case 'Enter':
+ this.handleMouseDown();
+ break;
+ }
+ };
+
+ handleKeyPress = (e) => {
+ switch(e.key) {
+ case ' ':
+ case 'Enter':
+ this.handleClick(e);
+ e.stopPropagation();
+ e.preventDefault();
+ break;
+ }
+ };
+
+ handleItemClick = e => {
+ const { onItemClick } = this.props;
+ const i = Number(e.currentTarget.getAttribute('data-index'));
+ const item = this.props.items[i];
+
+ this.handleClose();
+
+ if (typeof onItemClick === 'function') {
+ e.preventDefault();
+ onItemClick(item, i);
+ } else if (item && typeof item.action === 'function') {
+ e.preventDefault();
+ item.action();
+ } else if (item && item.to) {
+ e.preventDefault();
+ this.context.router.history.push(item.to);
+ }
+ };
+
+ setTargetRef = c => {
+ this.target = c;
+ };
+
+ findTarget = () => {
+ return this.target;
+ };
+
+ componentWillUnmount = () => {
+ if (this.state.id === this.props.openDropdownId) {
+ this.handleClose();
+ }
+ };
+
+ close = () => {
+ this.handleClose();
+ };
+
+ render () {
+ const {
+ icon,
+ items,
+ size,
+ title,
+ disabled,
+ loading,
+ scrollable,
+ openDropdownId,
+ openedViaKeyboard,
+ children,
+ renderItem,
+ renderHeader,
+ } = this.props;
+
+ const open = this.state.id === openDropdownId;
+
+ const button = children ? React.cloneElement(React.Children.only(children), {
+ onClick: this.handleClick,
+ onMouseDown: this.handleMouseDown,
+ onKeyDown: this.handleButtonKeyDown,
+ onKeyPress: this.handleKeyPress,
+ }) : (
+
+ );
+
+ return (
+
+
+ {button}
+
+
+ {({ props, arrowProps, placement }) => (
+
+ )}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/edited_timestamp/index.js b/app/javascript/mastodon/components/edited_timestamp/index.js
deleted file mode 100644
index b30d88572..000000000
--- a/app/javascript/mastodon/components/edited_timestamp/index.js
+++ /dev/null
@@ -1,70 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { FormattedMessage, injectIntl } from 'react-intl';
-import Icon from 'mastodon/components/icon';
-import DropdownMenu from './containers/dropdown_menu_container';
-import { connect } from 'react-redux';
-import { openModal } from 'mastodon/actions/modal';
-import RelativeTimestamp from 'mastodon/components/relative_timestamp';
-import InlineAccount from 'mastodon/components/inline_account';
-
-const mapDispatchToProps = (dispatch, { statusId }) => ({
-
- onItemClick (index) {
- dispatch(openModal('COMPARE_HISTORY', { index, statusId }));
- },
-
-});
-
-export default @connect(null, mapDispatchToProps)
-@injectIntl
-class EditedTimestamp extends React.PureComponent {
-
- static propTypes = {
- statusId: PropTypes.string.isRequired,
- timestamp: PropTypes.string.isRequired,
- intl: PropTypes.object.isRequired,
- onItemClick: PropTypes.func.isRequired,
- };
-
- handleItemClick = (item, i) => {
- const { onItemClick } = this.props;
- onItemClick(i);
- };
-
- renderHeader = items => {
- return (
-
- );
- };
-
- renderItem = (item, index, { onClick, onKeyPress }) => {
- const formattedDate = ;
- const formattedName = ;
-
- const label = item.get('original') ? (
-
- ) : (
-
- );
-
- return (
-
- {label}
-
- );
- };
-
- render () {
- const { timestamp, intl, statusId } = this.props;
-
- return (
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/edited_timestamp/index.jsx b/app/javascript/mastodon/components/edited_timestamp/index.jsx
new file mode 100644
index 000000000..b30d88572
--- /dev/null
+++ b/app/javascript/mastodon/components/edited_timestamp/index.jsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage, injectIntl } from 'react-intl';
+import Icon from 'mastodon/components/icon';
+import DropdownMenu from './containers/dropdown_menu_container';
+import { connect } from 'react-redux';
+import { openModal } from 'mastodon/actions/modal';
+import RelativeTimestamp from 'mastodon/components/relative_timestamp';
+import InlineAccount from 'mastodon/components/inline_account';
+
+const mapDispatchToProps = (dispatch, { statusId }) => ({
+
+ onItemClick (index) {
+ dispatch(openModal('COMPARE_HISTORY', { index, statusId }));
+ },
+
+});
+
+export default @connect(null, mapDispatchToProps)
+@injectIntl
+class EditedTimestamp extends React.PureComponent {
+
+ static propTypes = {
+ statusId: PropTypes.string.isRequired,
+ timestamp: PropTypes.string.isRequired,
+ intl: PropTypes.object.isRequired,
+ onItemClick: PropTypes.func.isRequired,
+ };
+
+ handleItemClick = (item, i) => {
+ const { onItemClick } = this.props;
+ onItemClick(i);
+ };
+
+ renderHeader = items => {
+ return (
+
+ );
+ };
+
+ renderItem = (item, index, { onClick, onKeyPress }) => {
+ const formattedDate = ;
+ const formattedName = ;
+
+ const label = item.get('original') ? (
+
+ ) : (
+
+ );
+
+ return (
+
+ {label}
+
+ );
+ };
+
+ render () {
+ const { timestamp, intl, statusId } = this.props;
+
+ return (
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/error_boundary.js b/app/javascript/mastodon/components/error_boundary.js
deleted file mode 100644
index b711f1e46..000000000
--- a/app/javascript/mastodon/components/error_boundary.js
+++ /dev/null
@@ -1,107 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { FormattedMessage } from 'react-intl';
-import { version, source_url } from 'mastodon/initial_state';
-import StackTrace from 'stacktrace-js';
-import { Helmet } from 'react-helmet';
-
-export default class ErrorBoundary extends React.PureComponent {
-
- static propTypes = {
- children: PropTypes.node,
- };
-
- state = {
- hasError: false,
- errorMessage: undefined,
- stackTrace: undefined,
- mappedStackTrace: undefined,
- componentStack: undefined,
- };
-
- componentDidCatch (error, info) {
- this.setState({
- hasError: true,
- errorMessage: error.toString(),
- stackTrace: error.stack,
- componentStack: info && info.componentStack,
- mappedStackTrace: undefined,
- });
-
- StackTrace.fromError(error).then((stackframes) => {
- this.setState({
- mappedStackTrace: stackframes.map((sf) => sf.toString()).join('\n'),
- });
- }).catch(() => {
- this.setState({
- mappedStackTrace: undefined,
- });
- });
- }
-
- handleCopyStackTrace = () => {
- const { errorMessage, stackTrace, mappedStackTrace } = this.state;
- const textarea = document.createElement('textarea');
-
- let contents = [errorMessage, stackTrace];
- if (mappedStackTrace) {
- contents.push(mappedStackTrace);
- }
-
- textarea.textContent = contents.join('\n\n\n');
- textarea.style.position = 'fixed';
-
- document.body.appendChild(textarea);
-
- try {
- textarea.select();
- document.execCommand('copy');
- } catch (e) {
-
- } finally {
- document.body.removeChild(textarea);
- }
-
- this.setState({ copied: true });
- setTimeout(() => this.setState({ copied: false }), 700);
- };
-
- render() {
- const { hasError, copied, errorMessage } = this.state;
-
- if (!hasError) {
- return this.props.children;
- }
-
- const likelyBrowserAddonIssue = errorMessage && errorMessage.includes('NotFoundError');
-
- return (
-
-
-
- { likelyBrowserAddonIssue ? (
-
- ) : (
-
- )}
-
-
-
- { likelyBrowserAddonIssue ? (
-
- ) : (
-
- )}
-
-
-
Mastodon v{version} · ·
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/error_boundary.jsx b/app/javascript/mastodon/components/error_boundary.jsx
new file mode 100644
index 000000000..b711f1e46
--- /dev/null
+++ b/app/javascript/mastodon/components/error_boundary.jsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+import { version, source_url } from 'mastodon/initial_state';
+import StackTrace from 'stacktrace-js';
+import { Helmet } from 'react-helmet';
+
+export default class ErrorBoundary extends React.PureComponent {
+
+ static propTypes = {
+ children: PropTypes.node,
+ };
+
+ state = {
+ hasError: false,
+ errorMessage: undefined,
+ stackTrace: undefined,
+ mappedStackTrace: undefined,
+ componentStack: undefined,
+ };
+
+ componentDidCatch (error, info) {
+ this.setState({
+ hasError: true,
+ errorMessage: error.toString(),
+ stackTrace: error.stack,
+ componentStack: info && info.componentStack,
+ mappedStackTrace: undefined,
+ });
+
+ StackTrace.fromError(error).then((stackframes) => {
+ this.setState({
+ mappedStackTrace: stackframes.map((sf) => sf.toString()).join('\n'),
+ });
+ }).catch(() => {
+ this.setState({
+ mappedStackTrace: undefined,
+ });
+ });
+ }
+
+ handleCopyStackTrace = () => {
+ const { errorMessage, stackTrace, mappedStackTrace } = this.state;
+ const textarea = document.createElement('textarea');
+
+ let contents = [errorMessage, stackTrace];
+ if (mappedStackTrace) {
+ contents.push(mappedStackTrace);
+ }
+
+ textarea.textContent = contents.join('\n\n\n');
+ textarea.style.position = 'fixed';
+
+ document.body.appendChild(textarea);
+
+ try {
+ textarea.select();
+ document.execCommand('copy');
+ } catch (e) {
+
+ } finally {
+ document.body.removeChild(textarea);
+ }
+
+ this.setState({ copied: true });
+ setTimeout(() => this.setState({ copied: false }), 700);
+ };
+
+ render() {
+ const { hasError, copied, errorMessage } = this.state;
+
+ if (!hasError) {
+ return this.props.children;
+ }
+
+ const likelyBrowserAddonIssue = errorMessage && errorMessage.includes('NotFoundError');
+
+ return (
+
+
+
+ { likelyBrowserAddonIssue ? (
+
+ ) : (
+
+ )}
+
+
+
+ { likelyBrowserAddonIssue ? (
+
+ ) : (
+
+ )}
+
+
+
Mastodon v{version} · ·
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/gifv.js b/app/javascript/mastodon/components/gifv.js
deleted file mode 100644
index 1f0f99b46..000000000
--- a/app/javascript/mastodon/components/gifv.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-export default class GIFV extends React.PureComponent {
-
- static propTypes = {
- src: PropTypes.string.isRequired,
- alt: PropTypes.string,
- width: PropTypes.number,
- height: PropTypes.number,
- onClick: PropTypes.func,
- };
-
- state = {
- loading: true,
- };
-
- handleLoadedData = () => {
- this.setState({ loading: false });
- };
-
- componentWillReceiveProps (nextProps) {
- if (nextProps.src !== this.props.src) {
- this.setState({ loading: true });
- }
- }
-
- handleClick = e => {
- const { onClick } = this.props;
-
- if (onClick) {
- e.stopPropagation();
- onClick();
- }
- };
-
- render () {
- const { src, width, height, alt } = this.props;
- const { loading } = this.state;
-
- return (
-
- {loading && (
-
- )}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/gifv.jsx b/app/javascript/mastodon/components/gifv.jsx
new file mode 100644
index 000000000..1f0f99b46
--- /dev/null
+++ b/app/javascript/mastodon/components/gifv.jsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class GIFV extends React.PureComponent {
+
+ static propTypes = {
+ src: PropTypes.string.isRequired,
+ alt: PropTypes.string,
+ width: PropTypes.number,
+ height: PropTypes.number,
+ onClick: PropTypes.func,
+ };
+
+ state = {
+ loading: true,
+ };
+
+ handleLoadedData = () => {
+ this.setState({ loading: false });
+ };
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.src !== this.props.src) {
+ this.setState({ loading: true });
+ }
+ }
+
+ handleClick = e => {
+ const { onClick } = this.props;
+
+ if (onClick) {
+ e.stopPropagation();
+ onClick();
+ }
+ };
+
+ render () {
+ const { src, width, height, alt } = this.props;
+ const { loading } = this.state;
+
+ return (
+
+ {loading && (
+
+ )}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/hashtag.js b/app/javascript/mastodon/components/hashtag.js
deleted file mode 100644
index e516fc086..000000000
--- a/app/javascript/mastodon/components/hashtag.js
+++ /dev/null
@@ -1,113 +0,0 @@
-// @ts-check
-import React from 'react';
-import { Sparklines, SparklinesCurve } from 'react-sparklines';
-import { FormattedMessage } from 'react-intl';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { Link } from 'react-router-dom';
-import ShortNumber from 'mastodon/components/short_number';
-import Skeleton from 'mastodon/components/skeleton';
-import classNames from 'classnames';
-
-class SilentErrorBoundary extends React.Component {
-
- static propTypes = {
- children: PropTypes.node,
- };
-
- state = {
- error: false,
- };
-
- componentDidCatch () {
- this.setState({ error: true });
- }
-
- render () {
- if (this.state.error) {
- return null;
- }
-
- return this.props.children;
- }
-
-}
-
-/**
- * Used to render counter of how much people are talking about hashtag
- *
- * @type {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
- */
-export const accountsCountRenderer = (displayNumber, pluralReady) => (
- {displayNumber},
- days: 2,
- }}
- />
-);
-
-export const ImmutableHashtag = ({ hashtag }) => (
- day.get('uses')).toArray()}
- />
-);
-
-ImmutableHashtag.propTypes = {
- hashtag: ImmutablePropTypes.map.isRequired,
-};
-
-const Hashtag = ({ name, to, people, uses, history, className, description, withGraph }) => (
-
-
-
- {name ? #{name} : }
-
-
- {description ? (
- {description}
- ) : (
- typeof people !== 'undefined' ? :
- )}
-
-
- {typeof uses !== 'undefined' && (
-
-
-
- )}
-
- {withGraph && (
-
-
- 0)}>
-
-
-
-
- )}
-
-);
-
-Hashtag.propTypes = {
- name: PropTypes.string,
- to: PropTypes.string,
- people: PropTypes.number,
- description: PropTypes.node,
- uses: PropTypes.number,
- history: PropTypes.arrayOf(PropTypes.number),
- className: PropTypes.string,
- withGraph: PropTypes.bool,
-};
-
-Hashtag.defaultProps = {
- withGraph: true,
-};
-
-export default Hashtag;
diff --git a/app/javascript/mastodon/components/hashtag.jsx b/app/javascript/mastodon/components/hashtag.jsx
new file mode 100644
index 000000000..e516fc086
--- /dev/null
+++ b/app/javascript/mastodon/components/hashtag.jsx
@@ -0,0 +1,113 @@
+// @ts-check
+import React from 'react';
+import { Sparklines, SparklinesCurve } from 'react-sparklines';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { Link } from 'react-router-dom';
+import ShortNumber from 'mastodon/components/short_number';
+import Skeleton from 'mastodon/components/skeleton';
+import classNames from 'classnames';
+
+class SilentErrorBoundary extends React.Component {
+
+ static propTypes = {
+ children: PropTypes.node,
+ };
+
+ state = {
+ error: false,
+ };
+
+ componentDidCatch () {
+ this.setState({ error: true });
+ }
+
+ render () {
+ if (this.state.error) {
+ return null;
+ }
+
+ return this.props.children;
+ }
+
+}
+
+/**
+ * Used to render counter of how much people are talking about hashtag
+ *
+ * @type {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
+ */
+export const accountsCountRenderer = (displayNumber, pluralReady) => (
+ {displayNumber},
+ days: 2,
+ }}
+ />
+);
+
+export const ImmutableHashtag = ({ hashtag }) => (
+ day.get('uses')).toArray()}
+ />
+);
+
+ImmutableHashtag.propTypes = {
+ hashtag: ImmutablePropTypes.map.isRequired,
+};
+
+const Hashtag = ({ name, to, people, uses, history, className, description, withGraph }) => (
+
+
+
+ {name ? #{name} : }
+
+
+ {description ? (
+ {description}
+ ) : (
+ typeof people !== 'undefined' ? :
+ )}
+
+
+ {typeof uses !== 'undefined' && (
+
+
+
+ )}
+
+ {withGraph && (
+
+
+ 0)}>
+
+
+
+
+ )}
+
+);
+
+Hashtag.propTypes = {
+ name: PropTypes.string,
+ to: PropTypes.string,
+ people: PropTypes.number,
+ description: PropTypes.node,
+ uses: PropTypes.number,
+ history: PropTypes.arrayOf(PropTypes.number),
+ className: PropTypes.string,
+ withGraph: PropTypes.bool,
+};
+
+Hashtag.defaultProps = {
+ withGraph: true,
+};
+
+export default Hashtag;
diff --git a/app/javascript/mastodon/components/icon.js b/app/javascript/mastodon/components/icon.js
deleted file mode 100644
index d3d7c591d..000000000
--- a/app/javascript/mastodon/components/icon.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import classNames from 'classnames';
-
-export default class Icon extends React.PureComponent {
-
- static propTypes = {
- id: PropTypes.string.isRequired,
- className: PropTypes.string,
- fixedWidth: PropTypes.bool,
- };
-
- render () {
- const { id, className, fixedWidth, ...other } = this.props;
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/icon.jsx b/app/javascript/mastodon/components/icon.jsx
new file mode 100644
index 000000000..d3d7c591d
--- /dev/null
+++ b/app/javascript/mastodon/components/icon.jsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+export default class Icon extends React.PureComponent {
+
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ className: PropTypes.string,
+ fixedWidth: PropTypes.bool,
+ };
+
+ render () {
+ const { id, className, fixedWidth, ...other } = this.props;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js
deleted file mode 100644
index 003692373..000000000
--- a/app/javascript/mastodon/components/icon_button.js
+++ /dev/null
@@ -1,164 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import classNames from 'classnames';
-import Icon from 'mastodon/components/icon';
-import AnimatedNumber from 'mastodon/components/animated_number';
-
-export default class IconButton extends React.PureComponent {
-
- static propTypes = {
- className: PropTypes.string,
- title: PropTypes.string.isRequired,
- icon: PropTypes.string.isRequired,
- onClick: PropTypes.func,
- onMouseDown: PropTypes.func,
- onKeyDown: PropTypes.func,
- onKeyPress: PropTypes.func,
- size: PropTypes.number,
- active: PropTypes.bool,
- expanded: PropTypes.bool,
- style: PropTypes.object,
- activeStyle: PropTypes.object,
- disabled: PropTypes.bool,
- inverted: PropTypes.bool,
- animate: PropTypes.bool,
- overlay: PropTypes.bool,
- tabIndex: PropTypes.string,
- counter: PropTypes.number,
- obfuscateCount: PropTypes.bool,
- href: PropTypes.string,
- ariaHidden: PropTypes.bool,
- };
-
- static defaultProps = {
- size: 18,
- active: false,
- disabled: false,
- animate: false,
- overlay: false,
- tabIndex: '0',
- ariaHidden: false,
- };
-
- state = {
- activate: false,
- deactivate: false,
- };
-
- componentWillReceiveProps (nextProps) {
- if (!nextProps.animate) return;
-
- if (this.props.active && !nextProps.active) {
- this.setState({ activate: false, deactivate: true });
- } else if (!this.props.active && nextProps.active) {
- this.setState({ activate: true, deactivate: false });
- }
- }
-
- handleClick = (e) => {
- e.preventDefault();
-
- if (!this.props.disabled) {
- this.props.onClick(e);
- }
- };
-
- handleKeyPress = (e) => {
- if (this.props.onKeyPress && !this.props.disabled) {
- this.props.onKeyPress(e);
- }
- };
-
- handleMouseDown = (e) => {
- if (!this.props.disabled && this.props.onMouseDown) {
- this.props.onMouseDown(e);
- }
- };
-
- handleKeyDown = (e) => {
- if (!this.props.disabled && this.props.onKeyDown) {
- this.props.onKeyDown(e);
- }
- };
-
- render () {
- const style = {
- fontSize: `${this.props.size}px`,
- width: `${this.props.size * 1.28571429}px`,
- height: `${this.props.size * 1.28571429}px`,
- lineHeight: `${this.props.size}px`,
- ...this.props.style,
- ...(this.props.active ? this.props.activeStyle : {}),
- };
-
- const {
- active,
- className,
- disabled,
- expanded,
- icon,
- inverted,
- overlay,
- tabIndex,
- title,
- counter,
- obfuscateCount,
- href,
- ariaHidden,
- } = this.props;
-
- const {
- activate,
- deactivate,
- } = this.state;
-
- const classes = classNames(className, 'icon-button', {
- active,
- disabled,
- inverted,
- activate,
- deactivate,
- overlayed: overlay,
- 'icon-button--with-counter': typeof counter !== 'undefined',
- });
-
- if (typeof counter !== 'undefined') {
- style.width = 'auto';
- }
-
- let contents = (
-
- {typeof counter !== 'undefined' && }
-
- );
-
- if (href && !this.prop) {
- contents = (
-
- {contents}
-
- );
- }
-
- return (
-
- {contents}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/icon_button.jsx b/app/javascript/mastodon/components/icon_button.jsx
new file mode 100644
index 000000000..003692373
--- /dev/null
+++ b/app/javascript/mastodon/components/icon_button.jsx
@@ -0,0 +1,164 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import Icon from 'mastodon/components/icon';
+import AnimatedNumber from 'mastodon/components/animated_number';
+
+export default class IconButton extends React.PureComponent {
+
+ static propTypes = {
+ className: PropTypes.string,
+ title: PropTypes.string.isRequired,
+ icon: PropTypes.string.isRequired,
+ onClick: PropTypes.func,
+ onMouseDown: PropTypes.func,
+ onKeyDown: PropTypes.func,
+ onKeyPress: PropTypes.func,
+ size: PropTypes.number,
+ active: PropTypes.bool,
+ expanded: PropTypes.bool,
+ style: PropTypes.object,
+ activeStyle: PropTypes.object,
+ disabled: PropTypes.bool,
+ inverted: PropTypes.bool,
+ animate: PropTypes.bool,
+ overlay: PropTypes.bool,
+ tabIndex: PropTypes.string,
+ counter: PropTypes.number,
+ obfuscateCount: PropTypes.bool,
+ href: PropTypes.string,
+ ariaHidden: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ size: 18,
+ active: false,
+ disabled: false,
+ animate: false,
+ overlay: false,
+ tabIndex: '0',
+ ariaHidden: false,
+ };
+
+ state = {
+ activate: false,
+ deactivate: false,
+ };
+
+ componentWillReceiveProps (nextProps) {
+ if (!nextProps.animate) return;
+
+ if (this.props.active && !nextProps.active) {
+ this.setState({ activate: false, deactivate: true });
+ } else if (!this.props.active && nextProps.active) {
+ this.setState({ activate: true, deactivate: false });
+ }
+ }
+
+ handleClick = (e) => {
+ e.preventDefault();
+
+ if (!this.props.disabled) {
+ this.props.onClick(e);
+ }
+ };
+
+ handleKeyPress = (e) => {
+ if (this.props.onKeyPress && !this.props.disabled) {
+ this.props.onKeyPress(e);
+ }
+ };
+
+ handleMouseDown = (e) => {
+ if (!this.props.disabled && this.props.onMouseDown) {
+ this.props.onMouseDown(e);
+ }
+ };
+
+ handleKeyDown = (e) => {
+ if (!this.props.disabled && this.props.onKeyDown) {
+ this.props.onKeyDown(e);
+ }
+ };
+
+ render () {
+ const style = {
+ fontSize: `${this.props.size}px`,
+ width: `${this.props.size * 1.28571429}px`,
+ height: `${this.props.size * 1.28571429}px`,
+ lineHeight: `${this.props.size}px`,
+ ...this.props.style,
+ ...(this.props.active ? this.props.activeStyle : {}),
+ };
+
+ const {
+ active,
+ className,
+ disabled,
+ expanded,
+ icon,
+ inverted,
+ overlay,
+ tabIndex,
+ title,
+ counter,
+ obfuscateCount,
+ href,
+ ariaHidden,
+ } = this.props;
+
+ const {
+ activate,
+ deactivate,
+ } = this.state;
+
+ const classes = classNames(className, 'icon-button', {
+ active,
+ disabled,
+ inverted,
+ activate,
+ deactivate,
+ overlayed: overlay,
+ 'icon-button--with-counter': typeof counter !== 'undefined',
+ });
+
+ if (typeof counter !== 'undefined') {
+ style.width = 'auto';
+ }
+
+ let contents = (
+
+ {typeof counter !== 'undefined' && }
+
+ );
+
+ if (href && !this.prop) {
+ contents = (
+
+ {contents}
+
+ );
+ }
+
+ return (
+
+ {contents}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/icon_with_badge.js b/app/javascript/mastodon/components/icon_with_badge.js
deleted file mode 100644
index 4214eccfd..000000000
--- a/app/javascript/mastodon/components/icon_with_badge.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import Icon from 'mastodon/components/icon';
-
-const formatNumber = num => num > 40 ? '40+' : num;
-
-const IconWithBadge = ({ id, count, issueBadge, className }) => (
-
-
- {count > 0 && {formatNumber(count)} }
- {issueBadge && }
-
-);
-
-IconWithBadge.propTypes = {
- id: PropTypes.string.isRequired,
- count: PropTypes.number.isRequired,
- issueBadge: PropTypes.bool,
- className: PropTypes.string,
-};
-
-export default IconWithBadge;
diff --git a/app/javascript/mastodon/components/icon_with_badge.jsx b/app/javascript/mastodon/components/icon_with_badge.jsx
new file mode 100644
index 000000000..4214eccfd
--- /dev/null
+++ b/app/javascript/mastodon/components/icon_with_badge.jsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Icon from 'mastodon/components/icon';
+
+const formatNumber = num => num > 40 ? '40+' : num;
+
+const IconWithBadge = ({ id, count, issueBadge, className }) => (
+
+
+ {count > 0 && {formatNumber(count)} }
+ {issueBadge && }
+
+);
+
+IconWithBadge.propTypes = {
+ id: PropTypes.string.isRequired,
+ count: PropTypes.number.isRequired,
+ issueBadge: PropTypes.bool,
+ className: PropTypes.string,
+};
+
+export default IconWithBadge;
diff --git a/app/javascript/mastodon/components/image.js b/app/javascript/mastodon/components/image.js
deleted file mode 100644
index 6e81ddf08..000000000
--- a/app/javascript/mastodon/components/image.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import Blurhash from './blurhash';
-import classNames from 'classnames';
-
-export default class Image extends React.PureComponent {
-
- static propTypes = {
- src: PropTypes.string,
- srcSet: PropTypes.string,
- blurhash: PropTypes.string,
- className: PropTypes.string,
- };
-
- state = {
- loaded: false,
- };
-
- handleLoad = () => this.setState({ loaded: true });
-
- render () {
- const { src, srcSet, blurhash, className } = this.props;
- const { loaded } = this.state;
-
- return (
-
- {blurhash &&
}
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/image.jsx b/app/javascript/mastodon/components/image.jsx
new file mode 100644
index 000000000..6e81ddf08
--- /dev/null
+++ b/app/javascript/mastodon/components/image.jsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Blurhash from './blurhash';
+import classNames from 'classnames';
+
+export default class Image extends React.PureComponent {
+
+ static propTypes = {
+ src: PropTypes.string,
+ srcSet: PropTypes.string,
+ blurhash: PropTypes.string,
+ className: PropTypes.string,
+ };
+
+ state = {
+ loaded: false,
+ };
+
+ handleLoad = () => this.setState({ loaded: true });
+
+ render () {
+ const { src, srcSet, blurhash, className } = this.props;
+ const { loaded } = this.state;
+
+ return (
+
+ {blurhash &&
}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/inline_account.js b/app/javascript/mastodon/components/inline_account.js
deleted file mode 100644
index a1b495590..000000000
--- a/app/javascript/mastodon/components/inline_account.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { connect } from 'react-redux';
-import { makeGetAccount } from 'mastodon/selectors';
-import Avatar from 'mastodon/components/avatar';
-
-const makeMapStateToProps = () => {
- const getAccount = makeGetAccount();
-
- const mapStateToProps = (state, { accountId }) => ({
- account: getAccount(state, accountId),
- });
-
- return mapStateToProps;
-};
-
-export default @connect(makeMapStateToProps)
-class InlineAccount extends React.PureComponent {
-
- static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
- };
-
- render () {
- const { account } = this.props;
-
- return (
-
- {account.get('username')}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/inline_account.jsx b/app/javascript/mastodon/components/inline_account.jsx
new file mode 100644
index 000000000..a1b495590
--- /dev/null
+++ b/app/javascript/mastodon/components/inline_account.jsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'mastodon/selectors';
+import Avatar from 'mastodon/components/avatar';
+
+const makeMapStateToProps = () => {
+ const getAccount = makeGetAccount();
+
+ const mapStateToProps = (state, { accountId }) => ({
+ account: getAccount(state, accountId),
+ });
+
+ return mapStateToProps;
+};
+
+export default @connect(makeMapStateToProps)
+class InlineAccount extends React.PureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ };
+
+ render () {
+ const { account } = this.props;
+
+ return (
+
+ {account.get('username')}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/intersection_observer_article.js b/app/javascript/mastodon/components/intersection_observer_article.js
deleted file mode 100644
index c2feb003a..000000000
--- a/app/javascript/mastodon/components/intersection_observer_article.js
+++ /dev/null
@@ -1,130 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
-import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
-
-// Diff these props in the "unrendered" state
-const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
-
-export default class IntersectionObserverArticle extends React.Component {
-
- static propTypes = {
- intersectionObserverWrapper: PropTypes.object.isRequired,
- id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
- index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
- listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
- saveHeightKey: PropTypes.string,
- cachedHeight: PropTypes.number,
- onHeightChange: PropTypes.func,
- children: PropTypes.node,
- };
-
- state = {
- isHidden: false, // set to true in requestIdleCallback to trigger un-render
- };
-
- shouldComponentUpdate (nextProps, nextState) {
- const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight);
- const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight);
- if (!!isUnrendered !== !!willBeUnrendered) {
- // If we're going from rendered to unrendered (or vice versa) then update
- return true;
- }
- // If we are and remain hidden, diff based on props
- if (isUnrendered) {
- return !updateOnPropsForUnrendered.every(prop => nextProps[prop] === this.props[prop]);
- }
- // Else, assume the children have changed
- return true;
- }
-
- componentDidMount () {
- const { intersectionObserverWrapper, id } = this.props;
-
- intersectionObserverWrapper.observe(
- id,
- this.node,
- this.handleIntersection,
- );
-
- this.componentMounted = true;
- }
-
- componentWillUnmount () {
- const { intersectionObserverWrapper, id } = this.props;
- intersectionObserverWrapper.unobserve(id, this.node);
-
- this.componentMounted = false;
- }
-
- handleIntersection = (entry) => {
- this.entry = entry;
-
- scheduleIdleTask(this.calculateHeight);
- this.setState(this.updateStateAfterIntersection);
- };
-
- updateStateAfterIntersection = (prevState) => {
- if (prevState.isIntersecting !== false && !this.entry.isIntersecting) {
- scheduleIdleTask(this.hideIfNotIntersecting);
- }
- return {
- isIntersecting: this.entry.isIntersecting,
- isHidden: false,
- };
- };
-
- calculateHeight = () => {
- const { onHeightChange, saveHeightKey, id } = this.props;
- // save the height of the fully-rendered element (this is expensive
- // on Chrome, where we need to fall back to getBoundingClientRect)
- this.height = getRectFromEntry(this.entry).height;
-
- if (onHeightChange && saveHeightKey) {
- onHeightChange(saveHeightKey, id, this.height);
- }
- };
-
- hideIfNotIntersecting = () => {
- if (!this.componentMounted) {
- return;
- }
-
- // When the browser gets a chance, test if we're still not intersecting,
- // and if so, set our isHidden to true to trigger an unrender. The point of
- // this is to save DOM nodes and avoid using up too much memory.
- // See: https://github.com/mastodon/mastodon/issues/2900
- this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
- };
-
- handleRef = (node) => {
- this.node = node;
- };
-
- render () {
- const { children, id, index, listLength, cachedHeight } = this.props;
- const { isIntersecting, isHidden } = this.state;
-
- if (!isIntersecting && (isHidden || cachedHeight)) {
- return (
-
- {children && React.cloneElement(children, { hidden: true })}
-
- );
- }
-
- return (
-
- {children && React.cloneElement(children, { hidden: false })}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/intersection_observer_article.jsx b/app/javascript/mastodon/components/intersection_observer_article.jsx
new file mode 100644
index 000000000..c2feb003a
--- /dev/null
+++ b/app/javascript/mastodon/components/intersection_observer_article.jsx
@@ -0,0 +1,130 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
+import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
+
+// Diff these props in the "unrendered" state
+const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
+
+export default class IntersectionObserverArticle extends React.Component {
+
+ static propTypes = {
+ intersectionObserverWrapper: PropTypes.object.isRequired,
+ id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ saveHeightKey: PropTypes.string,
+ cachedHeight: PropTypes.number,
+ onHeightChange: PropTypes.func,
+ children: PropTypes.node,
+ };
+
+ state = {
+ isHidden: false, // set to true in requestIdleCallback to trigger un-render
+ };
+
+ shouldComponentUpdate (nextProps, nextState) {
+ const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight);
+ const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight);
+ if (!!isUnrendered !== !!willBeUnrendered) {
+ // If we're going from rendered to unrendered (or vice versa) then update
+ return true;
+ }
+ // If we are and remain hidden, diff based on props
+ if (isUnrendered) {
+ return !updateOnPropsForUnrendered.every(prop => nextProps[prop] === this.props[prop]);
+ }
+ // Else, assume the children have changed
+ return true;
+ }
+
+ componentDidMount () {
+ const { intersectionObserverWrapper, id } = this.props;
+
+ intersectionObserverWrapper.observe(
+ id,
+ this.node,
+ this.handleIntersection,
+ );
+
+ this.componentMounted = true;
+ }
+
+ componentWillUnmount () {
+ const { intersectionObserverWrapper, id } = this.props;
+ intersectionObserverWrapper.unobserve(id, this.node);
+
+ this.componentMounted = false;
+ }
+
+ handleIntersection = (entry) => {
+ this.entry = entry;
+
+ scheduleIdleTask(this.calculateHeight);
+ this.setState(this.updateStateAfterIntersection);
+ };
+
+ updateStateAfterIntersection = (prevState) => {
+ if (prevState.isIntersecting !== false && !this.entry.isIntersecting) {
+ scheduleIdleTask(this.hideIfNotIntersecting);
+ }
+ return {
+ isIntersecting: this.entry.isIntersecting,
+ isHidden: false,
+ };
+ };
+
+ calculateHeight = () => {
+ const { onHeightChange, saveHeightKey, id } = this.props;
+ // save the height of the fully-rendered element (this is expensive
+ // on Chrome, where we need to fall back to getBoundingClientRect)
+ this.height = getRectFromEntry(this.entry).height;
+
+ if (onHeightChange && saveHeightKey) {
+ onHeightChange(saveHeightKey, id, this.height);
+ }
+ };
+
+ hideIfNotIntersecting = () => {
+ if (!this.componentMounted) {
+ return;
+ }
+
+ // When the browser gets a chance, test if we're still not intersecting,
+ // and if so, set our isHidden to true to trigger an unrender. The point of
+ // this is to save DOM nodes and avoid using up too much memory.
+ // See: https://github.com/mastodon/mastodon/issues/2900
+ this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
+ };
+
+ handleRef = (node) => {
+ this.node = node;
+ };
+
+ render () {
+ const { children, id, index, listLength, cachedHeight } = this.props;
+ const { isIntersecting, isHidden } = this.state;
+
+ if (!isIntersecting && (isHidden || cachedHeight)) {
+ return (
+
+ {children && React.cloneElement(children, { hidden: true })}
+
+ );
+ }
+
+ return (
+
+ {children && React.cloneElement(children, { hidden: false })}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/load_gap.js b/app/javascript/mastodon/components/load_gap.js
deleted file mode 100644
index c50b245fc..000000000
--- a/app/javascript/mastodon/components/load_gap.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { injectIntl, defineMessages } from 'react-intl';
-import Icon from 'mastodon/components/icon';
-
-const messages = defineMessages({
- load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
-});
-
-export default @injectIntl
-class LoadGap extends React.PureComponent {
-
- static propTypes = {
- disabled: PropTypes.bool,
- maxId: PropTypes.string,
- onClick: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- handleClick = () => {
- this.props.onClick(this.props.maxId);
- };
-
- render () {
- const { disabled, intl } = this.props;
-
- return (
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/load_gap.jsx b/app/javascript/mastodon/components/load_gap.jsx
new file mode 100644
index 000000000..c50b245fc
--- /dev/null
+++ b/app/javascript/mastodon/components/load_gap.jsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+import Icon from 'mastodon/components/icon';
+
+const messages = defineMessages({
+ load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
+});
+
+export default @injectIntl
+class LoadGap extends React.PureComponent {
+
+ static propTypes = {
+ disabled: PropTypes.bool,
+ maxId: PropTypes.string,
+ onClick: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleClick = () => {
+ this.props.onClick(this.props.maxId);
+ };
+
+ render () {
+ const { disabled, intl } = this.props;
+
+ return (
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/load_more.js b/app/javascript/mastodon/components/load_more.js
deleted file mode 100644
index 150525214..000000000
--- a/app/javascript/mastodon/components/load_more.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import React from 'react';
-import { FormattedMessage } from 'react-intl';
-import PropTypes from 'prop-types';
-
-export default class LoadMore extends React.PureComponent {
-
- static propTypes = {
- onClick: PropTypes.func,
- disabled: PropTypes.bool,
- visible: PropTypes.bool,
- };
-
- static defaultProps = {
- visible: true,
- };
-
- render() {
- const { disabled, visible } = this.props;
-
- return (
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/load_more.jsx b/app/javascript/mastodon/components/load_more.jsx
new file mode 100644
index 000000000..150525214
--- /dev/null
+++ b/app/javascript/mastodon/components/load_more.jsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+export default class LoadMore extends React.PureComponent {
+
+ static propTypes = {
+ onClick: PropTypes.func,
+ disabled: PropTypes.bool,
+ visible: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ visible: true,
+ };
+
+ render() {
+ const { disabled, visible } = this.props;
+
+ return (
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/load_pending.js b/app/javascript/mastodon/components/load_pending.js
deleted file mode 100644
index a75259146..000000000
--- a/app/javascript/mastodon/components/load_pending.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import React from 'react';
-import { FormattedMessage } from 'react-intl';
-import PropTypes from 'prop-types';
-
-export default class LoadPending extends React.PureComponent {
-
- static propTypes = {
- onClick: PropTypes.func,
- count: PropTypes.number,
- };
-
- render() {
- const { count } = this.props;
-
- return (
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/load_pending.jsx b/app/javascript/mastodon/components/load_pending.jsx
new file mode 100644
index 000000000..a75259146
--- /dev/null
+++ b/app/javascript/mastodon/components/load_pending.jsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+export default class LoadPending extends React.PureComponent {
+
+ static propTypes = {
+ onClick: PropTypes.func,
+ count: PropTypes.number,
+ };
+
+ render() {
+ const { count } = this.props;
+
+ return (
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/loading_indicator.js b/app/javascript/mastodon/components/loading_indicator.js
deleted file mode 100644
index 33c59d94c..000000000
--- a/app/javascript/mastodon/components/loading_indicator.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-export const CircularProgress = ({ size, strokeWidth }) => {
- const viewBox = `0 0 ${size} ${size}`;
- const radius = (size - strokeWidth) / 2;
-
- return (
-
-
-
- );
-};
-
-CircularProgress.propTypes = {
- size: PropTypes.number.isRequired,
- strokeWidth: PropTypes.number.isRequired,
-};
-
-const LoadingIndicator = () => (
-
-
-
-);
-
-export default LoadingIndicator;
diff --git a/app/javascript/mastodon/components/loading_indicator.jsx b/app/javascript/mastodon/components/loading_indicator.jsx
new file mode 100644
index 000000000..33c59d94c
--- /dev/null
+++ b/app/javascript/mastodon/components/loading_indicator.jsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export const CircularProgress = ({ size, strokeWidth }) => {
+ const viewBox = `0 0 ${size} ${size}`;
+ const radius = (size - strokeWidth) / 2;
+
+ return (
+
+
+
+ );
+};
+
+CircularProgress.propTypes = {
+ size: PropTypes.number.isRequired,
+ strokeWidth: PropTypes.number.isRequired,
+};
+
+const LoadingIndicator = () => (
+
+
+
+);
+
+export default LoadingIndicator;
diff --git a/app/javascript/mastodon/components/logo.js b/app/javascript/mastodon/components/logo.js
deleted file mode 100644
index ee5c22496..000000000
--- a/app/javascript/mastodon/components/logo.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import React from 'react';
-
-const Logo = () => (
-
- Mastodon
-
-
-);
-
-export default Logo;
diff --git a/app/javascript/mastodon/components/logo.jsx b/app/javascript/mastodon/components/logo.jsx
new file mode 100644
index 000000000..ee5c22496
--- /dev/null
+++ b/app/javascript/mastodon/components/logo.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+
+const Logo = () => (
+
+ Mastodon
+
+
+);
+
+export default Logo;
diff --git a/app/javascript/mastodon/components/media_attachments.js b/app/javascript/mastodon/components/media_attachments.js
deleted file mode 100644
index 565a30330..000000000
--- a/app/javascript/mastodon/components/media_attachments.js
+++ /dev/null
@@ -1,116 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { MediaGallery, Video, Audio } from 'mastodon/features/ui/util/async-components';
-import Bundle from 'mastodon/features/ui/components/bundle';
-import noop from 'lodash/noop';
-
-export default class MediaAttachments extends ImmutablePureComponent {
-
- static propTypes = {
- status: ImmutablePropTypes.map.isRequired,
- height: PropTypes.number,
- width: PropTypes.number,
- };
-
- static defaultProps = {
- height: 110,
- width: 239,
- };
-
- updateOnProps = [
- 'status',
- ];
-
- renderLoadingMediaGallery = () => {
- const { height, width } = this.props;
-
- return (
-
- );
- };
-
- renderLoadingVideoPlayer = () => {
- const { height, width } = this.props;
-
- return (
-
- );
- };
-
- renderLoadingAudioPlayer = () => {
- const { height, width } = this.props;
-
- return (
-
- );
- };
-
- render () {
- const { status, width, height } = this.props;
- const mediaAttachments = status.get('media_attachments');
-
- if (mediaAttachments.size === 0) {
- return null;
- }
-
- if (mediaAttachments.getIn([0, 'type']) === 'audio') {
- const audio = mediaAttachments.get(0);
-
- return (
-
- {Component => (
-
- )}
-
- );
- } else if (mediaAttachments.getIn([0, 'type']) === 'video') {
- const video = mediaAttachments.get(0);
-
- return (
-
- {Component => (
-
- )}
-
- );
- } else {
- return (
-
- {Component => (
-
- )}
-
- );
- }
- }
-
-}
diff --git a/app/javascript/mastodon/components/media_attachments.jsx b/app/javascript/mastodon/components/media_attachments.jsx
new file mode 100644
index 000000000..565a30330
--- /dev/null
+++ b/app/javascript/mastodon/components/media_attachments.jsx
@@ -0,0 +1,116 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { MediaGallery, Video, Audio } from 'mastodon/features/ui/util/async-components';
+import Bundle from 'mastodon/features/ui/components/bundle';
+import noop from 'lodash/noop';
+
+export default class MediaAttachments extends ImmutablePureComponent {
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ height: PropTypes.number,
+ width: PropTypes.number,
+ };
+
+ static defaultProps = {
+ height: 110,
+ width: 239,
+ };
+
+ updateOnProps = [
+ 'status',
+ ];
+
+ renderLoadingMediaGallery = () => {
+ const { height, width } = this.props;
+
+ return (
+
+ );
+ };
+
+ renderLoadingVideoPlayer = () => {
+ const { height, width } = this.props;
+
+ return (
+
+ );
+ };
+
+ renderLoadingAudioPlayer = () => {
+ const { height, width } = this.props;
+
+ return (
+
+ );
+ };
+
+ render () {
+ const { status, width, height } = this.props;
+ const mediaAttachments = status.get('media_attachments');
+
+ if (mediaAttachments.size === 0) {
+ return null;
+ }
+
+ if (mediaAttachments.getIn([0, 'type']) === 'audio') {
+ const audio = mediaAttachments.get(0);
+
+ return (
+
+ {Component => (
+
+ )}
+
+ );
+ } else if (mediaAttachments.getIn([0, 'type']) === 'video') {
+ const video = mediaAttachments.get(0);
+
+ return (
+
+ {Component => (
+
+ )}
+
+ );
+ } else {
+ return (
+
+ {Component => (
+
+ )}
+
+ );
+ }
+ }
+
+}
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
deleted file mode 100644
index 659a83375..000000000
--- a/app/javascript/mastodon/components/media_gallery.js
+++ /dev/null
@@ -1,368 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import { is } from 'immutable';
-import IconButton from './icon_button';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import classNames from 'classnames';
-import { autoPlayGif, cropImages, displayMedia, useBlurhash } from '../initial_state';
-import { debounce } from 'lodash';
-import Blurhash from 'mastodon/components/blurhash';
-
-const messages = defineMessages({
- toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: '{number, plural, one {Hide image} other {Hide images}}' },
-});
-
-class Item extends React.PureComponent {
-
- static propTypes = {
- attachment: ImmutablePropTypes.map.isRequired,
- standalone: PropTypes.bool,
- index: PropTypes.number.isRequired,
- size: PropTypes.number.isRequired,
- onClick: PropTypes.func.isRequired,
- displayWidth: PropTypes.number,
- visible: PropTypes.bool.isRequired,
- autoplay: PropTypes.bool,
- };
-
- static defaultProps = {
- standalone: false,
- index: 0,
- size: 1,
- };
-
- state = {
- loaded: false,
- };
-
- handleMouseEnter = (e) => {
- if (this.hoverToPlay()) {
- e.target.play();
- }
- };
-
- handleMouseLeave = (e) => {
- if (this.hoverToPlay()) {
- e.target.pause();
- e.target.currentTime = 0;
- }
- };
-
- getAutoPlay() {
- return this.props.autoplay || autoPlayGif;
- }
-
- hoverToPlay () {
- const { attachment } = this.props;
- return !this.getAutoPlay() && attachment.get('type') === 'gifv';
- }
-
- handleClick = (e) => {
- const { index, onClick } = this.props;
-
- if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
- if (this.hoverToPlay()) {
- e.target.pause();
- e.target.currentTime = 0;
- }
- e.preventDefault();
- onClick(index);
- }
-
- e.stopPropagation();
- };
-
- handleImageLoad = () => {
- this.setState({ loaded: true });
- };
-
- render () {
- const { attachment, index, size, standalone, displayWidth, visible } = this.props;
-
- let width = 50;
- let height = 100;
- let top = 'auto';
- let left = 'auto';
- let bottom = 'auto';
- let right = 'auto';
-
- if (size === 1) {
- width = 100;
- }
-
- if (size === 4 || (size === 3 && index > 0)) {
- height = 50;
- }
-
- if (size === 2) {
- if (index === 0) {
- right = '2px';
- } else {
- left = '2px';
- }
- } else if (size === 3) {
- if (index === 0) {
- right = '2px';
- } else if (index > 0) {
- left = '2px';
- }
-
- if (index === 1) {
- bottom = '2px';
- } else if (index > 1) {
- top = '2px';
- }
- } else if (size === 4) {
- if (index === 0 || index === 2) {
- right = '2px';
- }
-
- if (index === 1 || index === 3) {
- left = '2px';
- }
-
- if (index < 2) {
- bottom = '2px';
- } else {
- top = '2px';
- }
- }
-
- let thumbnail = '';
-
- if (attachment.get('type') === 'unknown') {
- return (
-
- );
- } else if (attachment.get('type') === 'image') {
- const previewUrl = attachment.get('preview_url');
- const previewWidth = attachment.getIn(['meta', 'small', 'width']);
-
- const originalUrl = attachment.get('url');
- const originalWidth = attachment.getIn(['meta', 'original', 'width']);
-
- const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
-
- const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
- const sizes = hasSize && (displayWidth > 0) ? `${displayWidth * (width / 100)}px` : null;
-
- const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
- const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
- const x = ((focusX / 2) + .5) * 100;
- const y = ((focusY / -2) + .5) * 100;
-
- thumbnail = (
-
-
-
- );
- } else if (attachment.get('type') === 'gifv') {
- const autoPlay = this.getAutoPlay();
-
- thumbnail = (
-
-
-
- GIF
-
- );
- }
-
- return (
-
-
- {visible && thumbnail}
-
- );
- }
-
-}
-
-export default @injectIntl
-class MediaGallery extends React.PureComponent {
-
- static propTypes = {
- sensitive: PropTypes.bool,
- standalone: PropTypes.bool,
- media: ImmutablePropTypes.list.isRequired,
- size: PropTypes.object,
- height: PropTypes.number.isRequired,
- onOpenMedia: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- defaultWidth: PropTypes.number,
- cacheWidth: PropTypes.func,
- visible: PropTypes.bool,
- autoplay: PropTypes.bool,
- onToggleVisibility: PropTypes.func,
- };
-
- static defaultProps = {
- standalone: false,
- };
-
- state = {
- visible: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
- width: this.props.defaultWidth,
- };
-
- componentDidMount () {
- window.addEventListener('resize', this.handleResize, { passive: true });
- }
-
- componentWillUnmount () {
- window.removeEventListener('resize', this.handleResize);
- }
-
- componentWillReceiveProps (nextProps) {
- if (!is(nextProps.media, this.props.media) && nextProps.visible === undefined) {
- this.setState({ visible: displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all' });
- } else if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
- this.setState({ visible: nextProps.visible });
- }
- }
-
- handleResize = debounce(() => {
- if (this.node) {
- this._setDimensions();
- }
- }, 250, {
- trailing: true,
- });
-
- handleOpen = () => {
- if (this.props.onToggleVisibility) {
- this.props.onToggleVisibility();
- } else {
- this.setState({ visible: !this.state.visible });
- }
- };
-
- handleClick = (index) => {
- this.props.onOpenMedia(this.props.media, index);
- };
-
- handleRef = c => {
- this.node = c;
-
- if (this.node) {
- this._setDimensions();
- }
- };
-
- _setDimensions () {
- const width = this.node.offsetWidth;
-
- // offsetWidth triggers a layout, so only calculate when we need to
- if (this.props.cacheWidth) {
- this.props.cacheWidth(width);
- }
-
- this.setState({
- width: width,
- });
- }
-
- isFullSizeEligible() {
- const { media } = this.props;
- return media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
- }
-
- render () {
- const { media, intl, sensitive, height, defaultWidth, standalone, autoplay } = this.props;
- const { visible } = this.state;
-
- const width = this.state.width || defaultWidth;
-
- let children, spoilerButton;
-
- const style = {};
-
- if (this.isFullSizeEligible() && (standalone || !cropImages)) {
- if (width) {
- style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']);
- }
- } else if (width) {
- style.height = width / (16/9);
- } else {
- style.height = height;
- }
-
- const size = media.take(4).size;
- const uncached = media.every(attachment => attachment.get('type') === 'unknown');
-
- if (standalone && this.isFullSizeEligible()) {
- children = ;
- } else {
- children = media.take(4).map((attachment, i) => );
- }
-
- if (uncached) {
- spoilerButton = (
-
-
-
- );
- } else if (visible) {
- spoilerButton = ;
- } else {
- spoilerButton = (
-
- {sensitive ? : }
-
- );
- }
-
- return (
-
-
- {spoilerButton}
-
-
- {children}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/media_gallery.jsx b/app/javascript/mastodon/components/media_gallery.jsx
new file mode 100644
index 000000000..659a83375
--- /dev/null
+++ b/app/javascript/mastodon/components/media_gallery.jsx
@@ -0,0 +1,368 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { is } from 'immutable';
+import IconButton from './icon_button';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import classNames from 'classnames';
+import { autoPlayGif, cropImages, displayMedia, useBlurhash } from '../initial_state';
+import { debounce } from 'lodash';
+import Blurhash from 'mastodon/components/blurhash';
+
+const messages = defineMessages({
+ toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: '{number, plural, one {Hide image} other {Hide images}}' },
+});
+
+class Item extends React.PureComponent {
+
+ static propTypes = {
+ attachment: ImmutablePropTypes.map.isRequired,
+ standalone: PropTypes.bool,
+ index: PropTypes.number.isRequired,
+ size: PropTypes.number.isRequired,
+ onClick: PropTypes.func.isRequired,
+ displayWidth: PropTypes.number,
+ visible: PropTypes.bool.isRequired,
+ autoplay: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ standalone: false,
+ index: 0,
+ size: 1,
+ };
+
+ state = {
+ loaded: false,
+ };
+
+ handleMouseEnter = (e) => {
+ if (this.hoverToPlay()) {
+ e.target.play();
+ }
+ };
+
+ handleMouseLeave = (e) => {
+ if (this.hoverToPlay()) {
+ e.target.pause();
+ e.target.currentTime = 0;
+ }
+ };
+
+ getAutoPlay() {
+ return this.props.autoplay || autoPlayGif;
+ }
+
+ hoverToPlay () {
+ const { attachment } = this.props;
+ return !this.getAutoPlay() && attachment.get('type') === 'gifv';
+ }
+
+ handleClick = (e) => {
+ const { index, onClick } = this.props;
+
+ if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ if (this.hoverToPlay()) {
+ e.target.pause();
+ e.target.currentTime = 0;
+ }
+ e.preventDefault();
+ onClick(index);
+ }
+
+ e.stopPropagation();
+ };
+
+ handleImageLoad = () => {
+ this.setState({ loaded: true });
+ };
+
+ render () {
+ const { attachment, index, size, standalone, displayWidth, visible } = this.props;
+
+ let width = 50;
+ let height = 100;
+ let top = 'auto';
+ let left = 'auto';
+ let bottom = 'auto';
+ let right = 'auto';
+
+ if (size === 1) {
+ width = 100;
+ }
+
+ if (size === 4 || (size === 3 && index > 0)) {
+ height = 50;
+ }
+
+ if (size === 2) {
+ if (index === 0) {
+ right = '2px';
+ } else {
+ left = '2px';
+ }
+ } else if (size === 3) {
+ if (index === 0) {
+ right = '2px';
+ } else if (index > 0) {
+ left = '2px';
+ }
+
+ if (index === 1) {
+ bottom = '2px';
+ } else if (index > 1) {
+ top = '2px';
+ }
+ } else if (size === 4) {
+ if (index === 0 || index === 2) {
+ right = '2px';
+ }
+
+ if (index === 1 || index === 3) {
+ left = '2px';
+ }
+
+ if (index < 2) {
+ bottom = '2px';
+ } else {
+ top = '2px';
+ }
+ }
+
+ let thumbnail = '';
+
+ if (attachment.get('type') === 'unknown') {
+ return (
+
+ );
+ } else if (attachment.get('type') === 'image') {
+ const previewUrl = attachment.get('preview_url');
+ const previewWidth = attachment.getIn(['meta', 'small', 'width']);
+
+ const originalUrl = attachment.get('url');
+ const originalWidth = attachment.getIn(['meta', 'original', 'width']);
+
+ const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
+
+ const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
+ const sizes = hasSize && (displayWidth > 0) ? `${displayWidth * (width / 100)}px` : null;
+
+ const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
+ const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
+ const x = ((focusX / 2) + .5) * 100;
+ const y = ((focusY / -2) + .5) * 100;
+
+ thumbnail = (
+
+
+
+ );
+ } else if (attachment.get('type') === 'gifv') {
+ const autoPlay = this.getAutoPlay();
+
+ thumbnail = (
+
+
+
+ GIF
+
+ );
+ }
+
+ return (
+
+
+ {visible && thumbnail}
+
+ );
+ }
+
+}
+
+export default @injectIntl
+class MediaGallery extends React.PureComponent {
+
+ static propTypes = {
+ sensitive: PropTypes.bool,
+ standalone: PropTypes.bool,
+ media: ImmutablePropTypes.list.isRequired,
+ size: PropTypes.object,
+ height: PropTypes.number.isRequired,
+ onOpenMedia: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ defaultWidth: PropTypes.number,
+ cacheWidth: PropTypes.func,
+ visible: PropTypes.bool,
+ autoplay: PropTypes.bool,
+ onToggleVisibility: PropTypes.func,
+ };
+
+ static defaultProps = {
+ standalone: false,
+ };
+
+ state = {
+ visible: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
+ width: this.props.defaultWidth,
+ };
+
+ componentDidMount () {
+ window.addEventListener('resize', this.handleResize, { passive: true });
+ }
+
+ componentWillUnmount () {
+ window.removeEventListener('resize', this.handleResize);
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (!is(nextProps.media, this.props.media) && nextProps.visible === undefined) {
+ this.setState({ visible: displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all' });
+ } else if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
+ this.setState({ visible: nextProps.visible });
+ }
+ }
+
+ handleResize = debounce(() => {
+ if (this.node) {
+ this._setDimensions();
+ }
+ }, 250, {
+ trailing: true,
+ });
+
+ handleOpen = () => {
+ if (this.props.onToggleVisibility) {
+ this.props.onToggleVisibility();
+ } else {
+ this.setState({ visible: !this.state.visible });
+ }
+ };
+
+ handleClick = (index) => {
+ this.props.onOpenMedia(this.props.media, index);
+ };
+
+ handleRef = c => {
+ this.node = c;
+
+ if (this.node) {
+ this._setDimensions();
+ }
+ };
+
+ _setDimensions () {
+ const width = this.node.offsetWidth;
+
+ // offsetWidth triggers a layout, so only calculate when we need to
+ if (this.props.cacheWidth) {
+ this.props.cacheWidth(width);
+ }
+
+ this.setState({
+ width: width,
+ });
+ }
+
+ isFullSizeEligible() {
+ const { media } = this.props;
+ return media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
+ }
+
+ render () {
+ const { media, intl, sensitive, height, defaultWidth, standalone, autoplay } = this.props;
+ const { visible } = this.state;
+
+ const width = this.state.width || defaultWidth;
+
+ let children, spoilerButton;
+
+ const style = {};
+
+ if (this.isFullSizeEligible() && (standalone || !cropImages)) {
+ if (width) {
+ style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']);
+ }
+ } else if (width) {
+ style.height = width / (16/9);
+ } else {
+ style.height = height;
+ }
+
+ const size = media.take(4).size;
+ const uncached = media.every(attachment => attachment.get('type') === 'unknown');
+
+ if (standalone && this.isFullSizeEligible()) {
+ children = ;
+ } else {
+ children = media.take(4).map((attachment, i) => );
+ }
+
+ if (uncached) {
+ spoilerButton = (
+
+
+
+ );
+ } else if (visible) {
+ spoilerButton = ;
+ } else {
+ spoilerButton = (
+
+ {sensitive ? : }
+
+ );
+ }
+
+ return (
+
+
+ {spoilerButton}
+
+
+ {children}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/missing_indicator.js b/app/javascript/mastodon/components/missing_indicator.js
deleted file mode 100644
index 05e0d653d..000000000
--- a/app/javascript/mastodon/components/missing_indicator.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { FormattedMessage } from 'react-intl';
-import illustration from 'mastodon/../images/elephant_ui_disappointed.svg';
-import classNames from 'classnames';
-import { Helmet } from 'react-helmet';
-
-const MissingIndicator = ({ fullPage }) => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-);
-
-MissingIndicator.propTypes = {
- fullPage: PropTypes.bool,
-};
-
-export default MissingIndicator;
diff --git a/app/javascript/mastodon/components/missing_indicator.jsx b/app/javascript/mastodon/components/missing_indicator.jsx
new file mode 100644
index 000000000..05e0d653d
--- /dev/null
+++ b/app/javascript/mastodon/components/missing_indicator.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+import illustration from 'mastodon/../images/elephant_ui_disappointed.svg';
+import classNames from 'classnames';
+import { Helmet } from 'react-helmet';
+
+const MissingIndicator = ({ fullPage }) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+MissingIndicator.propTypes = {
+ fullPage: PropTypes.bool,
+};
+
+export default MissingIndicator;
diff --git a/app/javascript/mastodon/components/modal_root.js b/app/javascript/mastodon/components/modal_root.js
deleted file mode 100644
index c0525c221..000000000
--- a/app/javascript/mastodon/components/modal_root.js
+++ /dev/null
@@ -1,157 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import 'wicg-inert';
-import { createBrowserHistory } from 'history';
-import { multiply } from 'color-blend';
-
-export default class ModalRoot extends React.PureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- };
-
- static propTypes = {
- children: PropTypes.node,
- onClose: PropTypes.func.isRequired,
- backgroundColor: PropTypes.shape({
- r: PropTypes.number,
- g: PropTypes.number,
- b: PropTypes.number,
- }),
- ignoreFocus: PropTypes.bool,
- };
-
- activeElement = this.props.children ? document.activeElement : null;
-
- handleKeyUp = (e) => {
- if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
- && !!this.props.children) {
- this.props.onClose();
- }
- };
-
- handleKeyDown = (e) => {
- if (e.key === 'Tab') {
- const focusable = Array.from(this.node.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((x) => window.getComputedStyle(x).display !== 'none');
- const index = focusable.indexOf(e.target);
-
- let element;
-
- if (e.shiftKey) {
- element = focusable[index - 1] || focusable[focusable.length - 1];
- } else {
- element = focusable[index + 1] || focusable[0];
- }
-
- if (element) {
- element.focus();
- e.stopPropagation();
- e.preventDefault();
- }
- }
- };
-
- componentDidMount () {
- window.addEventListener('keyup', this.handleKeyUp, false);
- window.addEventListener('keydown', this.handleKeyDown, false);
- this.history = this.context.router ? this.context.router.history : createBrowserHistory();
- }
-
- componentWillReceiveProps (nextProps) {
- if (!!nextProps.children && !this.props.children) {
- this.activeElement = document.activeElement;
-
- this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true));
- }
- }
-
- componentDidUpdate (prevProps) {
- if (!this.props.children && !!prevProps.children) {
- this.getSiblings().forEach(sibling => sibling.removeAttribute('inert'));
-
- // Because of the wicg-inert polyfill, the activeElement may not be
- // immediately selectable, we have to wait for observers to run, as
- // described in https://github.com/WICG/inert#performance-and-gotchas
- Promise.resolve().then(() => {
- if (!this.props.ignoreFocus) {
- this.activeElement.focus({ preventScroll: true });
- }
- this.activeElement = null;
- }).catch(console.error);
-
- this._handleModalClose();
- }
- if (this.props.children && !prevProps.children) {
- this._handleModalOpen();
- }
- if (this.props.children) {
- this._ensureHistoryBuffer();
- }
- }
-
- componentWillUnmount () {
- window.removeEventListener('keyup', this.handleKeyUp);
- window.removeEventListener('keydown', this.handleKeyDown);
- }
-
- _handleModalOpen () {
- this._modalHistoryKey = Date.now();
- this.unlistenHistory = this.history.listen((_, action) => {
- if (action === 'POP') {
- this.props.onClose();
- }
- });
- }
-
- _handleModalClose () {
- if (this.unlistenHistory) {
- this.unlistenHistory();
- }
- const { state } = this.history.location;
- if (state && state.mastodonModalKey === this._modalHistoryKey) {
- this.history.goBack();
- }
- }
-
- _ensureHistoryBuffer () {
- const { pathname, state } = this.history.location;
- if (!state || state.mastodonModalKey !== this._modalHistoryKey) {
- this.history.push(pathname, { ...state, mastodonModalKey: this._modalHistoryKey });
- }
- }
-
- getSiblings = () => {
- return Array(...this.node.parentElement.childNodes).filter(node => node !== this.node);
- };
-
- setRef = ref => {
- this.node = ref;
- };
-
- render () {
- const { children, onClose } = this.props;
- const visible = !!children;
-
- if (!visible) {
- return (
-
- );
- }
-
- let backgroundColor = null;
-
- if (this.props.backgroundColor) {
- backgroundColor = multiply({ ...this.props.backgroundColor, a: 1 }, { r: 0, g: 0, b: 0, a: 0.7 });
- }
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/modal_root.jsx b/app/javascript/mastodon/components/modal_root.jsx
new file mode 100644
index 000000000..c0525c221
--- /dev/null
+++ b/app/javascript/mastodon/components/modal_root.jsx
@@ -0,0 +1,157 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import 'wicg-inert';
+import { createBrowserHistory } from 'history';
+import { multiply } from 'color-blend';
+
+export default class ModalRoot extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ children: PropTypes.node,
+ onClose: PropTypes.func.isRequired,
+ backgroundColor: PropTypes.shape({
+ r: PropTypes.number,
+ g: PropTypes.number,
+ b: PropTypes.number,
+ }),
+ ignoreFocus: PropTypes.bool,
+ };
+
+ activeElement = this.props.children ? document.activeElement : null;
+
+ handleKeyUp = (e) => {
+ if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
+ && !!this.props.children) {
+ this.props.onClose();
+ }
+ };
+
+ handleKeyDown = (e) => {
+ if (e.key === 'Tab') {
+ const focusable = Array.from(this.node.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((x) => window.getComputedStyle(x).display !== 'none');
+ const index = focusable.indexOf(e.target);
+
+ let element;
+
+ if (e.shiftKey) {
+ element = focusable[index - 1] || focusable[focusable.length - 1];
+ } else {
+ element = focusable[index + 1] || focusable[0];
+ }
+
+ if (element) {
+ element.focus();
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ }
+ };
+
+ componentDidMount () {
+ window.addEventListener('keyup', this.handleKeyUp, false);
+ window.addEventListener('keydown', this.handleKeyDown, false);
+ this.history = this.context.router ? this.context.router.history : createBrowserHistory();
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (!!nextProps.children && !this.props.children) {
+ this.activeElement = document.activeElement;
+
+ this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true));
+ }
+ }
+
+ componentDidUpdate (prevProps) {
+ if (!this.props.children && !!prevProps.children) {
+ this.getSiblings().forEach(sibling => sibling.removeAttribute('inert'));
+
+ // Because of the wicg-inert polyfill, the activeElement may not be
+ // immediately selectable, we have to wait for observers to run, as
+ // described in https://github.com/WICG/inert#performance-and-gotchas
+ Promise.resolve().then(() => {
+ if (!this.props.ignoreFocus) {
+ this.activeElement.focus({ preventScroll: true });
+ }
+ this.activeElement = null;
+ }).catch(console.error);
+
+ this._handleModalClose();
+ }
+ if (this.props.children && !prevProps.children) {
+ this._handleModalOpen();
+ }
+ if (this.props.children) {
+ this._ensureHistoryBuffer();
+ }
+ }
+
+ componentWillUnmount () {
+ window.removeEventListener('keyup', this.handleKeyUp);
+ window.removeEventListener('keydown', this.handleKeyDown);
+ }
+
+ _handleModalOpen () {
+ this._modalHistoryKey = Date.now();
+ this.unlistenHistory = this.history.listen((_, action) => {
+ if (action === 'POP') {
+ this.props.onClose();
+ }
+ });
+ }
+
+ _handleModalClose () {
+ if (this.unlistenHistory) {
+ this.unlistenHistory();
+ }
+ const { state } = this.history.location;
+ if (state && state.mastodonModalKey === this._modalHistoryKey) {
+ this.history.goBack();
+ }
+ }
+
+ _ensureHistoryBuffer () {
+ const { pathname, state } = this.history.location;
+ if (!state || state.mastodonModalKey !== this._modalHistoryKey) {
+ this.history.push(pathname, { ...state, mastodonModalKey: this._modalHistoryKey });
+ }
+ }
+
+ getSiblings = () => {
+ return Array(...this.node.parentElement.childNodes).filter(node => node !== this.node);
+ };
+
+ setRef = ref => {
+ this.node = ref;
+ };
+
+ render () {
+ const { children, onClose } = this.props;
+ const visible = !!children;
+
+ if (!visible) {
+ return (
+
+ );
+ }
+
+ let backgroundColor = null;
+
+ if (this.props.backgroundColor) {
+ backgroundColor = multiply({ ...this.props.backgroundColor, a: 1 }, { r: 0, g: 0, b: 0, a: 0.7 });
+ }
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/navigation_portal.js b/app/javascript/mastodon/components/navigation_portal.js
deleted file mode 100644
index 45407be43..000000000
--- a/app/javascript/mastodon/components/navigation_portal.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import React from 'react';
-import { Switch, Route, withRouter } from 'react-router-dom';
-import { showTrends } from 'mastodon/initial_state';
-import Trends from 'mastodon/features/getting_started/containers/trends_container';
-import AccountNavigation from 'mastodon/features/account/navigation';
-
-const DefaultNavigation = () => (
- <>
- {showTrends && (
- <>
-
-
- >
- )}
- >
-);
-
-export default @withRouter
-class NavigationPortal extends React.PureComponent {
-
- render () {
- return (
-
-
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/navigation_portal.jsx b/app/javascript/mastodon/components/navigation_portal.jsx
new file mode 100644
index 000000000..45407be43
--- /dev/null
+++ b/app/javascript/mastodon/components/navigation_portal.jsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import { Switch, Route, withRouter } from 'react-router-dom';
+import { showTrends } from 'mastodon/initial_state';
+import Trends from 'mastodon/features/getting_started/containers/trends_container';
+import AccountNavigation from 'mastodon/features/account/navigation';
+
+const DefaultNavigation = () => (
+ <>
+ {showTrends && (
+ <>
+
+
+ >
+ )}
+ >
+);
+
+export default @withRouter
+class NavigationPortal extends React.PureComponent {
+
+ render () {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/not_signed_in_indicator.js b/app/javascript/mastodon/components/not_signed_in_indicator.js
deleted file mode 100644
index b440c6be2..000000000
--- a/app/javascript/mastodon/components/not_signed_in_indicator.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import React from 'react';
-import { FormattedMessage } from 'react-intl';
-
-const NotSignedInIndicator = () => (
-
-);
-
-export default NotSignedInIndicator;
diff --git a/app/javascript/mastodon/components/not_signed_in_indicator.jsx b/app/javascript/mastodon/components/not_signed_in_indicator.jsx
new file mode 100644
index 000000000..b440c6be2
--- /dev/null
+++ b/app/javascript/mastodon/components/not_signed_in_indicator.jsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+
+const NotSignedInIndicator = () => (
+
+);
+
+export default NotSignedInIndicator;
diff --git a/app/javascript/mastodon/components/picture_in_picture_placeholder.js b/app/javascript/mastodon/components/picture_in_picture_placeholder.js
deleted file mode 100644
index 0effddef9..000000000
--- a/app/javascript/mastodon/components/picture_in_picture_placeholder.js
+++ /dev/null
@@ -1,69 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import Icon from 'mastodon/components/icon';
-import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
-import { connect } from 'react-redux';
-import { debounce } from 'lodash';
-import { FormattedMessage } from 'react-intl';
-
-export default @connect()
-class PictureInPicturePlaceholder extends React.PureComponent {
-
- static propTypes = {
- width: PropTypes.number,
- dispatch: PropTypes.func.isRequired,
- };
-
- state = {
- width: this.props.width,
- height: this.props.width && (this.props.width / (16/9)),
- };
-
- handleClick = () => {
- const { dispatch } = this.props;
- dispatch(removePictureInPicture());
- };
-
- setRef = c => {
- this.node = c;
-
- if (this.node) {
- this._setDimensions();
- }
- };
-
- _setDimensions () {
- const width = this.node.offsetWidth;
- const height = width / (16/9);
-
- this.setState({ width, height });
- }
-
- componentDidMount () {
- window.addEventListener('resize', this.handleResize, { passive: true });
- }
-
- componentWillUnmount () {
- window.removeEventListener('resize', this.handleResize);
- }
-
- handleResize = debounce(() => {
- if (this.node) {
- this._setDimensions();
- }
- }, 250, {
- trailing: true,
- });
-
- render () {
- const { height } = this.state;
-
- return (
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/picture_in_picture_placeholder.jsx b/app/javascript/mastodon/components/picture_in_picture_placeholder.jsx
new file mode 100644
index 000000000..0effddef9
--- /dev/null
+++ b/app/javascript/mastodon/components/picture_in_picture_placeholder.jsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Icon from 'mastodon/components/icon';
+import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
+import { connect } from 'react-redux';
+import { debounce } from 'lodash';
+import { FormattedMessage } from 'react-intl';
+
+export default @connect()
+class PictureInPicturePlaceholder extends React.PureComponent {
+
+ static propTypes = {
+ width: PropTypes.number,
+ dispatch: PropTypes.func.isRequired,
+ };
+
+ state = {
+ width: this.props.width,
+ height: this.props.width && (this.props.width / (16/9)),
+ };
+
+ handleClick = () => {
+ const { dispatch } = this.props;
+ dispatch(removePictureInPicture());
+ };
+
+ setRef = c => {
+ this.node = c;
+
+ if (this.node) {
+ this._setDimensions();
+ }
+ };
+
+ _setDimensions () {
+ const width = this.node.offsetWidth;
+ const height = width / (16/9);
+
+ this.setState({ width, height });
+ }
+
+ componentDidMount () {
+ window.addEventListener('resize', this.handleResize, { passive: true });
+ }
+
+ componentWillUnmount () {
+ window.removeEventListener('resize', this.handleResize);
+ }
+
+ handleResize = debounce(() => {
+ if (this.node) {
+ this._setDimensions();
+ }
+ }, 250, {
+ trailing: true,
+ });
+
+ render () {
+ const { height } = this.state;
+
+ return (
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/poll.js b/app/javascript/mastodon/components/poll.js
deleted file mode 100644
index 95a900c49..000000000
--- a/app/javascript/mastodon/components/poll.js
+++ /dev/null
@@ -1,233 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import classNames from 'classnames';
-import Motion from 'mastodon/features/ui/util/optional_motion';
-import spring from 'react-motion/lib/spring';
-import escapeTextContentForBrowser from 'escape-html';
-import emojify from 'mastodon/features/emoji/emoji';
-import RelativeTimestamp from './relative_timestamp';
-import Icon from 'mastodon/components/icon';
-
-const messages = defineMessages({
- closed: {
- id: 'poll.closed',
- defaultMessage: 'Closed',
- },
- voted: {
- id: 'poll.voted',
- defaultMessage: 'You voted for this answer',
- },
- votes: {
- id: 'poll.votes',
- defaultMessage: '{votes, plural, one {# vote} other {# votes}}',
- },
-});
-
-const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
- obj[`:${emoji.get('shortcode')}:`] = emoji.toJS();
- return obj;
-}, {});
-
-export default @injectIntl
-class Poll extends ImmutablePureComponent {
-
- static contextTypes = {
- identity: PropTypes.object,
- };
-
- static propTypes = {
- poll: ImmutablePropTypes.map,
- intl: PropTypes.object.isRequired,
- disabled: PropTypes.bool,
- refresh: PropTypes.func,
- onVote: PropTypes.func,
- };
-
- state = {
- selected: {},
- expired: null,
- };
-
- static getDerivedStateFromProps (props, state) {
- const { poll, intl } = props;
- const expires_at = poll.get('expires_at');
- const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < intl.now();
- return (expired === state.expired) ? null : { expired };
- }
-
- componentDidMount () {
- this._setupTimer();
- }
-
- componentDidUpdate () {
- this._setupTimer();
- }
-
- componentWillUnmount () {
- clearTimeout(this._timer);
- }
-
- _setupTimer () {
- const { poll, intl } = this.props;
- clearTimeout(this._timer);
- if (!this.state.expired) {
- const delay = (new Date(poll.get('expires_at'))).getTime() - intl.now();
- this._timer = setTimeout(() => {
- this.setState({ expired: true });
- }, delay);
- }
- }
-
- _toggleOption = value => {
- if (this.props.poll.get('multiple')) {
- const tmp = { ...this.state.selected };
- if (tmp[value]) {
- delete tmp[value];
- } else {
- tmp[value] = true;
- }
- this.setState({ selected: tmp });
- } else {
- const tmp = {};
- tmp[value] = true;
- this.setState({ selected: tmp });
- }
- };
-
- handleOptionChange = ({ target: { value } }) => {
- this._toggleOption(value);
- };
-
- handleOptionKeyPress = (e) => {
- if (e.key === 'Enter' || e.key === ' ') {
- this._toggleOption(e.target.getAttribute('data-index'));
- e.stopPropagation();
- e.preventDefault();
- }
- };
-
- handleVote = () => {
- if (this.props.disabled) {
- return;
- }
-
- this.props.onVote(Object.keys(this.state.selected));
- };
-
- handleRefresh = () => {
- if (this.props.disabled) {
- return;
- }
-
- this.props.refresh();
- };
-
- renderOption (option, optionIndex, showResults) {
- const { poll, disabled, intl } = this.props;
- const pollVotesCount = poll.get('voters_count') || poll.get('votes_count');
- const percent = pollVotesCount === 0 ? 0 : (option.get('votes_count') / pollVotesCount) * 100;
- const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') >= other.get('votes_count'));
- const active = !!this.state.selected[`${optionIndex}`];
- const voted = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex));
-
- let titleEmojified = option.get('title_emojified');
- if (!titleEmojified) {
- const emojiMap = makeEmojiMap(poll);
- titleEmojified = emojify(escapeTextContentForBrowser(option.get('title')), emojiMap);
- }
-
- return (
-
-
-
-
- {!showResults && (
-
- )}
- {showResults && (
-
- {Math.round(percent)}%
-
- )}
-
-
-
- {!!voted &&
-
- }
-
-
- {showResults && (
-
- {({ width }) =>
-
- }
-
- )}
-
- );
- }
-
- render () {
- const { poll, intl } = this.props;
- const { expired } = this.state;
-
- if (!poll) {
- return null;
- }
-
- const timeRemaining = expired ? intl.formatMessage(messages.closed) : ;
- const showResults = poll.get('voted') || expired;
- const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
-
- let votesCount = null;
-
- if (poll.get('voters_count') !== null && poll.get('voters_count') !== undefined) {
- votesCount = ;
- } else {
- votesCount = ;
- }
-
- return (
-
-
- {poll.get('options').map((option, i) => this.renderOption(option, i, showResults))}
-
-
-
- {!showResults && }
- {showResults && !this.props.disabled && · }
- {votesCount}
- {poll.get('expires_at') && · {timeRemaining} }
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/poll.jsx b/app/javascript/mastodon/components/poll.jsx
new file mode 100644
index 000000000..95a900c49
--- /dev/null
+++ b/app/javascript/mastodon/components/poll.jsx
@@ -0,0 +1,233 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import classNames from 'classnames';
+import Motion from 'mastodon/features/ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import escapeTextContentForBrowser from 'escape-html';
+import emojify from 'mastodon/features/emoji/emoji';
+import RelativeTimestamp from './relative_timestamp';
+import Icon from 'mastodon/components/icon';
+
+const messages = defineMessages({
+ closed: {
+ id: 'poll.closed',
+ defaultMessage: 'Closed',
+ },
+ voted: {
+ id: 'poll.voted',
+ defaultMessage: 'You voted for this answer',
+ },
+ votes: {
+ id: 'poll.votes',
+ defaultMessage: '{votes, plural, one {# vote} other {# votes}}',
+ },
+});
+
+const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
+ obj[`:${emoji.get('shortcode')}:`] = emoji.toJS();
+ return obj;
+}, {});
+
+export default @injectIntl
+class Poll extends ImmutablePureComponent {
+
+ static contextTypes = {
+ identity: PropTypes.object,
+ };
+
+ static propTypes = {
+ poll: ImmutablePropTypes.map,
+ intl: PropTypes.object.isRequired,
+ disabled: PropTypes.bool,
+ refresh: PropTypes.func,
+ onVote: PropTypes.func,
+ };
+
+ state = {
+ selected: {},
+ expired: null,
+ };
+
+ static getDerivedStateFromProps (props, state) {
+ const { poll, intl } = props;
+ const expires_at = poll.get('expires_at');
+ const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < intl.now();
+ return (expired === state.expired) ? null : { expired };
+ }
+
+ componentDidMount () {
+ this._setupTimer();
+ }
+
+ componentDidUpdate () {
+ this._setupTimer();
+ }
+
+ componentWillUnmount () {
+ clearTimeout(this._timer);
+ }
+
+ _setupTimer () {
+ const { poll, intl } = this.props;
+ clearTimeout(this._timer);
+ if (!this.state.expired) {
+ const delay = (new Date(poll.get('expires_at'))).getTime() - intl.now();
+ this._timer = setTimeout(() => {
+ this.setState({ expired: true });
+ }, delay);
+ }
+ }
+
+ _toggleOption = value => {
+ if (this.props.poll.get('multiple')) {
+ const tmp = { ...this.state.selected };
+ if (tmp[value]) {
+ delete tmp[value];
+ } else {
+ tmp[value] = true;
+ }
+ this.setState({ selected: tmp });
+ } else {
+ const tmp = {};
+ tmp[value] = true;
+ this.setState({ selected: tmp });
+ }
+ };
+
+ handleOptionChange = ({ target: { value } }) => {
+ this._toggleOption(value);
+ };
+
+ handleOptionKeyPress = (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ this._toggleOption(e.target.getAttribute('data-index'));
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ };
+
+ handleVote = () => {
+ if (this.props.disabled) {
+ return;
+ }
+
+ this.props.onVote(Object.keys(this.state.selected));
+ };
+
+ handleRefresh = () => {
+ if (this.props.disabled) {
+ return;
+ }
+
+ this.props.refresh();
+ };
+
+ renderOption (option, optionIndex, showResults) {
+ const { poll, disabled, intl } = this.props;
+ const pollVotesCount = poll.get('voters_count') || poll.get('votes_count');
+ const percent = pollVotesCount === 0 ? 0 : (option.get('votes_count') / pollVotesCount) * 100;
+ const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') >= other.get('votes_count'));
+ const active = !!this.state.selected[`${optionIndex}`];
+ const voted = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex));
+
+ let titleEmojified = option.get('title_emojified');
+ if (!titleEmojified) {
+ const emojiMap = makeEmojiMap(poll);
+ titleEmojified = emojify(escapeTextContentForBrowser(option.get('title')), emojiMap);
+ }
+
+ return (
+
+
+
+
+ {!showResults && (
+
+ )}
+ {showResults && (
+
+ {Math.round(percent)}%
+
+ )}
+
+
+
+ {!!voted &&
+
+ }
+
+
+ {showResults && (
+
+ {({ width }) =>
+
+ }
+
+ )}
+
+ );
+ }
+
+ render () {
+ const { poll, intl } = this.props;
+ const { expired } = this.state;
+
+ if (!poll) {
+ return null;
+ }
+
+ const timeRemaining = expired ? intl.formatMessage(messages.closed) : ;
+ const showResults = poll.get('voted') || expired;
+ const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
+
+ let votesCount = null;
+
+ if (poll.get('voters_count') !== null && poll.get('voters_count') !== undefined) {
+ votesCount = ;
+ } else {
+ votesCount = ;
+ }
+
+ return (
+
+
+ {poll.get('options').map((option, i) => this.renderOption(option, i, showResults))}
+
+
+
+ {!showResults && }
+ {showResults && !this.props.disabled && · }
+ {votesCount}
+ {poll.get('expires_at') && · {timeRemaining} }
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/radio_button.js b/app/javascript/mastodon/components/radio_button.js
deleted file mode 100644
index 0496fa286..000000000
--- a/app/javascript/mastodon/components/radio_button.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import classNames from 'classnames';
-
-export default class RadioButton extends React.PureComponent {
-
- static propTypes = {
- value: PropTypes.string.isRequired,
- checked: PropTypes.bool,
- name: PropTypes.string.isRequired,
- onChange: PropTypes.func.isRequired,
- label: PropTypes.node.isRequired,
- };
-
- render () {
- const { name, value, checked, onChange, label } = this.props;
-
- return (
-
-
-
-
-
- {label}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/radio_button.jsx b/app/javascript/mastodon/components/radio_button.jsx
new file mode 100644
index 000000000..0496fa286
--- /dev/null
+++ b/app/javascript/mastodon/components/radio_button.jsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+export default class RadioButton extends React.PureComponent {
+
+ static propTypes = {
+ value: PropTypes.string.isRequired,
+ checked: PropTypes.bool,
+ name: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ label: PropTypes.node.isRequired,
+ };
+
+ render () {
+ const { name, value, checked, onChange, label } = this.props;
+
+ return (
+
+
+
+
+
+ {label}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/regeneration_indicator.js b/app/javascript/mastodon/components/regeneration_indicator.js
deleted file mode 100644
index 52696a4a7..000000000
--- a/app/javascript/mastodon/components/regeneration_indicator.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from 'react';
-import { FormattedMessage } from 'react-intl';
-import illustration from 'mastodon/../images/elephant_ui_working.svg';
-
-const RegenerationIndicator = () => (
-
-
-
-
-
-
-
-
-
-
-);
-
-export default RegenerationIndicator;
diff --git a/app/javascript/mastodon/components/regeneration_indicator.jsx b/app/javascript/mastodon/components/regeneration_indicator.jsx
new file mode 100644
index 000000000..52696a4a7
--- /dev/null
+++ b/app/javascript/mastodon/components/regeneration_indicator.jsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import illustration from 'mastodon/../images/elephant_ui_working.svg';
+
+const RegenerationIndicator = () => (
+
+
+
+
+
+
+
+
+
+
+);
+
+export default RegenerationIndicator;
diff --git a/app/javascript/mastodon/components/relative_timestamp.js b/app/javascript/mastodon/components/relative_timestamp.js
deleted file mode 100644
index 512480339..000000000
--- a/app/javascript/mastodon/components/relative_timestamp.js
+++ /dev/null
@@ -1,199 +0,0 @@
-import React from 'react';
-import { injectIntl, defineMessages } from 'react-intl';
-import PropTypes from 'prop-types';
-
-const messages = defineMessages({
- today: { id: 'relative_time.today', defaultMessage: 'today' },
- just_now: { id: 'relative_time.just_now', defaultMessage: 'now' },
- just_now_full: { id: 'relative_time.full.just_now', defaultMessage: 'just now' },
- seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' },
- seconds_full: { id: 'relative_time.full.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} ago' },
- minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
- minutes_full: { id: 'relative_time.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} ago' },
- hours: { id: 'relative_time.hours', defaultMessage: '{number}h' },
- hours_full: { id: 'relative_time.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} ago' },
- days: { id: 'relative_time.days', defaultMessage: '{number}d' },
- days_full: { id: 'relative_time.full.days', defaultMessage: '{number, plural, one {# day} other {# days}} ago' },
- moments_remaining: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' },
- seconds_remaining: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' },
- minutes_remaining: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' },
- hours_remaining: { id: 'time_remaining.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} left' },
- days_remaining: { id: 'time_remaining.days', defaultMessage: '{number, plural, one {# day} other {# days}} left' },
-});
-
-const dateFormatOptions = {
- hour12: false,
- year: 'numeric',
- month: 'short',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
-};
-
-const shortDateFormatOptions = {
- month: 'short',
- day: 'numeric',
-};
-
-const SECOND = 1000;
-const MINUTE = 1000 * 60;
-const HOUR = 1000 * 60 * 60;
-const DAY = 1000 * 60 * 60 * 24;
-
-const MAX_DELAY = 2147483647;
-
-const selectUnits = delta => {
- const absDelta = Math.abs(delta);
-
- if (absDelta < MINUTE) {
- return 'second';
- } else if (absDelta < HOUR) {
- return 'minute';
- } else if (absDelta < DAY) {
- return 'hour';
- }
-
- return 'day';
-};
-
-const getUnitDelay = units => {
- switch (units) {
- case 'second':
- return SECOND;
- case 'minute':
- return MINUTE;
- case 'hour':
- return HOUR;
- case 'day':
- return DAY;
- default:
- return MAX_DELAY;
- }
-};
-
-export const timeAgoString = (intl, date, now, year, timeGiven, short) => {
- const delta = now - date.getTime();
-
- let relativeTime;
-
- if (delta < DAY && !timeGiven) {
- relativeTime = intl.formatMessage(messages.today);
- } else if (delta < 10 * SECOND) {
- relativeTime = intl.formatMessage(short ? messages.just_now : messages.just_now_full);
- } else if (delta < 7 * DAY) {
- if (delta < MINUTE) {
- relativeTime = intl.formatMessage(short ? messages.seconds : messages.seconds_full, { number: Math.floor(delta / SECOND) });
- } else if (delta < HOUR) {
- relativeTime = intl.formatMessage(short ? messages.minutes : messages.minutes_full, { number: Math.floor(delta / MINUTE) });
- } else if (delta < DAY) {
- relativeTime = intl.formatMessage(short ? messages.hours : messages.hours_full, { number: Math.floor(delta / HOUR) });
- } else {
- relativeTime = intl.formatMessage(short ? messages.days : messages.days_full, { number: Math.floor(delta / DAY) });
- }
- } else if (date.getFullYear() === year) {
- relativeTime = intl.formatDate(date, shortDateFormatOptions);
- } else {
- relativeTime = intl.formatDate(date, { ...shortDateFormatOptions, year: 'numeric' });
- }
-
- return relativeTime;
-};
-
-const timeRemainingString = (intl, date, now, timeGiven = true) => {
- const delta = date.getTime() - now;
-
- let relativeTime;
-
- if (delta < DAY && !timeGiven) {
- relativeTime = intl.formatMessage(messages.today);
- } else if (delta < 10 * SECOND) {
- relativeTime = intl.formatMessage(messages.moments_remaining);
- } else if (delta < MINUTE) {
- relativeTime = intl.formatMessage(messages.seconds_remaining, { number: Math.floor(delta / SECOND) });
- } else if (delta < HOUR) {
- relativeTime = intl.formatMessage(messages.minutes_remaining, { number: Math.floor(delta / MINUTE) });
- } else if (delta < DAY) {
- relativeTime = intl.formatMessage(messages.hours_remaining, { number: Math.floor(delta / HOUR) });
- } else {
- relativeTime = intl.formatMessage(messages.days_remaining, { number: Math.floor(delta / DAY) });
- }
-
- return relativeTime;
-};
-
-export default @injectIntl
-class RelativeTimestamp extends React.Component {
-
- static propTypes = {
- intl: PropTypes.object.isRequired,
- timestamp: PropTypes.string.isRequired,
- year: PropTypes.number.isRequired,
- futureDate: PropTypes.bool,
- short: PropTypes.bool,
- };
-
- state = {
- now: this.props.intl.now(),
- };
-
- static defaultProps = {
- year: (new Date()).getFullYear(),
- short: true,
- };
-
- shouldComponentUpdate (nextProps, nextState) {
- // As of right now the locale doesn't change without a new page load,
- // but we might as well check in case that ever changes.
- return this.props.timestamp !== nextProps.timestamp ||
- this.props.intl.locale !== nextProps.intl.locale ||
- this.state.now !== nextState.now;
- }
-
- componentWillReceiveProps (nextProps) {
- if (this.props.timestamp !== nextProps.timestamp) {
- this.setState({ now: this.props.intl.now() });
- }
- }
-
- componentDidMount () {
- this._scheduleNextUpdate(this.props, this.state);
- }
-
- componentWillUpdate (nextProps, nextState) {
- this._scheduleNextUpdate(nextProps, nextState);
- }
-
- componentWillUnmount () {
- clearTimeout(this._timer);
- }
-
- _scheduleNextUpdate (props, state) {
- clearTimeout(this._timer);
-
- const { timestamp } = props;
- const delta = (new Date(timestamp)).getTime() - state.now;
- const unitDelay = getUnitDelay(selectUnits(delta));
- const unitRemainder = Math.abs(delta % unitDelay);
- const updateInterval = 1000 * 10;
- const delay = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder);
-
- this._timer = setTimeout(() => {
- this.setState({ now: this.props.intl.now() });
- }, delay);
- }
-
- render () {
- const { timestamp, intl, year, futureDate, short } = this.props;
-
- const timeGiven = timestamp.includes('T');
- const date = new Date(timestamp);
- const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now, timeGiven) : timeAgoString(intl, date, this.state.now, year, timeGiven, short);
-
- return (
-
- {relativeTime}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/relative_timestamp.jsx b/app/javascript/mastodon/components/relative_timestamp.jsx
new file mode 100644
index 000000000..512480339
--- /dev/null
+++ b/app/javascript/mastodon/components/relative_timestamp.jsx
@@ -0,0 +1,199 @@
+import React from 'react';
+import { injectIntl, defineMessages } from 'react-intl';
+import PropTypes from 'prop-types';
+
+const messages = defineMessages({
+ today: { id: 'relative_time.today', defaultMessage: 'today' },
+ just_now: { id: 'relative_time.just_now', defaultMessage: 'now' },
+ just_now_full: { id: 'relative_time.full.just_now', defaultMessage: 'just now' },
+ seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' },
+ seconds_full: { id: 'relative_time.full.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} ago' },
+ minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
+ minutes_full: { id: 'relative_time.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} ago' },
+ hours: { id: 'relative_time.hours', defaultMessage: '{number}h' },
+ hours_full: { id: 'relative_time.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} ago' },
+ days: { id: 'relative_time.days', defaultMessage: '{number}d' },
+ days_full: { id: 'relative_time.full.days', defaultMessage: '{number, plural, one {# day} other {# days}} ago' },
+ moments_remaining: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' },
+ seconds_remaining: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' },
+ minutes_remaining: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' },
+ hours_remaining: { id: 'time_remaining.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} left' },
+ days_remaining: { id: 'time_remaining.days', defaultMessage: '{number, plural, one {# day} other {# days}} left' },
+});
+
+const dateFormatOptions = {
+ hour12: false,
+ year: 'numeric',
+ month: 'short',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+};
+
+const shortDateFormatOptions = {
+ month: 'short',
+ day: 'numeric',
+};
+
+const SECOND = 1000;
+const MINUTE = 1000 * 60;
+const HOUR = 1000 * 60 * 60;
+const DAY = 1000 * 60 * 60 * 24;
+
+const MAX_DELAY = 2147483647;
+
+const selectUnits = delta => {
+ const absDelta = Math.abs(delta);
+
+ if (absDelta < MINUTE) {
+ return 'second';
+ } else if (absDelta < HOUR) {
+ return 'minute';
+ } else if (absDelta < DAY) {
+ return 'hour';
+ }
+
+ return 'day';
+};
+
+const getUnitDelay = units => {
+ switch (units) {
+ case 'second':
+ return SECOND;
+ case 'minute':
+ return MINUTE;
+ case 'hour':
+ return HOUR;
+ case 'day':
+ return DAY;
+ default:
+ return MAX_DELAY;
+ }
+};
+
+export const timeAgoString = (intl, date, now, year, timeGiven, short) => {
+ const delta = now - date.getTime();
+
+ let relativeTime;
+
+ if (delta < DAY && !timeGiven) {
+ relativeTime = intl.formatMessage(messages.today);
+ } else if (delta < 10 * SECOND) {
+ relativeTime = intl.formatMessage(short ? messages.just_now : messages.just_now_full);
+ } else if (delta < 7 * DAY) {
+ if (delta < MINUTE) {
+ relativeTime = intl.formatMessage(short ? messages.seconds : messages.seconds_full, { number: Math.floor(delta / SECOND) });
+ } else if (delta < HOUR) {
+ relativeTime = intl.formatMessage(short ? messages.minutes : messages.minutes_full, { number: Math.floor(delta / MINUTE) });
+ } else if (delta < DAY) {
+ relativeTime = intl.formatMessage(short ? messages.hours : messages.hours_full, { number: Math.floor(delta / HOUR) });
+ } else {
+ relativeTime = intl.formatMessage(short ? messages.days : messages.days_full, { number: Math.floor(delta / DAY) });
+ }
+ } else if (date.getFullYear() === year) {
+ relativeTime = intl.formatDate(date, shortDateFormatOptions);
+ } else {
+ relativeTime = intl.formatDate(date, { ...shortDateFormatOptions, year: 'numeric' });
+ }
+
+ return relativeTime;
+};
+
+const timeRemainingString = (intl, date, now, timeGiven = true) => {
+ const delta = date.getTime() - now;
+
+ let relativeTime;
+
+ if (delta < DAY && !timeGiven) {
+ relativeTime = intl.formatMessage(messages.today);
+ } else if (delta < 10 * SECOND) {
+ relativeTime = intl.formatMessage(messages.moments_remaining);
+ } else if (delta < MINUTE) {
+ relativeTime = intl.formatMessage(messages.seconds_remaining, { number: Math.floor(delta / SECOND) });
+ } else if (delta < HOUR) {
+ relativeTime = intl.formatMessage(messages.minutes_remaining, { number: Math.floor(delta / MINUTE) });
+ } else if (delta < DAY) {
+ relativeTime = intl.formatMessage(messages.hours_remaining, { number: Math.floor(delta / HOUR) });
+ } else {
+ relativeTime = intl.formatMessage(messages.days_remaining, { number: Math.floor(delta / DAY) });
+ }
+
+ return relativeTime;
+};
+
+export default @injectIntl
+class RelativeTimestamp extends React.Component {
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ timestamp: PropTypes.string.isRequired,
+ year: PropTypes.number.isRequired,
+ futureDate: PropTypes.bool,
+ short: PropTypes.bool,
+ };
+
+ state = {
+ now: this.props.intl.now(),
+ };
+
+ static defaultProps = {
+ year: (new Date()).getFullYear(),
+ short: true,
+ };
+
+ shouldComponentUpdate (nextProps, nextState) {
+ // As of right now the locale doesn't change without a new page load,
+ // but we might as well check in case that ever changes.
+ return this.props.timestamp !== nextProps.timestamp ||
+ this.props.intl.locale !== nextProps.intl.locale ||
+ this.state.now !== nextState.now;
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (this.props.timestamp !== nextProps.timestamp) {
+ this.setState({ now: this.props.intl.now() });
+ }
+ }
+
+ componentDidMount () {
+ this._scheduleNextUpdate(this.props, this.state);
+ }
+
+ componentWillUpdate (nextProps, nextState) {
+ this._scheduleNextUpdate(nextProps, nextState);
+ }
+
+ componentWillUnmount () {
+ clearTimeout(this._timer);
+ }
+
+ _scheduleNextUpdate (props, state) {
+ clearTimeout(this._timer);
+
+ const { timestamp } = props;
+ const delta = (new Date(timestamp)).getTime() - state.now;
+ const unitDelay = getUnitDelay(selectUnits(delta));
+ const unitRemainder = Math.abs(delta % unitDelay);
+ const updateInterval = 1000 * 10;
+ const delay = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder);
+
+ this._timer = setTimeout(() => {
+ this.setState({ now: this.props.intl.now() });
+ }, delay);
+ }
+
+ render () {
+ const { timestamp, intl, year, futureDate, short } = this.props;
+
+ const timeGiven = timestamp.includes('T');
+ const date = new Date(timestamp);
+ const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now, timeGiven) : timeAgoString(intl, date, this.state.now, year, timeGiven, short);
+
+ return (
+
+ {relativeTime}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js
deleted file mode 100644
index 4a6ffb149..000000000
--- a/app/javascript/mastodon/components/scrollable_list.js
+++ /dev/null
@@ -1,367 +0,0 @@
-import React, { PureComponent } from 'react';
-import ScrollContainer from 'mastodon/containers/scroll_container';
-import PropTypes from 'prop-types';
-import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
-import LoadMore from './load_more';
-import LoadPending from './load_pending';
-import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
-import { throttle } from 'lodash';
-import { List as ImmutableList } from 'immutable';
-import classNames from 'classnames';
-import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
-import LoadingIndicator from './loading_indicator';
-import { connect } from 'react-redux';
-
-const MOUSE_IDLE_DELAY = 300;
-
-const mapStateToProps = (state, { scrollKey }) => {
- return {
- preventScroll: scrollKey === state.getIn(['dropdown_menu', 'scroll_key']),
- };
-};
-
-export default @connect(mapStateToProps, null, null, { forwardRef: true })
-class ScrollableList extends PureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- };
-
- static propTypes = {
- scrollKey: PropTypes.string.isRequired,
- onLoadMore: PropTypes.func,
- onLoadPending: PropTypes.func,
- onScrollToTop: PropTypes.func,
- onScroll: PropTypes.func,
- trackScroll: PropTypes.bool,
- isLoading: PropTypes.bool,
- showLoading: PropTypes.bool,
- hasMore: PropTypes.bool,
- numPending: PropTypes.number,
- prepend: PropTypes.node,
- append: PropTypes.node,
- alwaysPrepend: PropTypes.bool,
- emptyMessage: PropTypes.node,
- children: PropTypes.node,
- bindToDocument: PropTypes.bool,
- preventScroll: PropTypes.bool,
- };
-
- static defaultProps = {
- trackScroll: true,
- };
-
- state = {
- fullscreen: null,
- cachedMediaWidth: 250, // Default media/card width using default Mastodon theme
- };
-
- intersectionObserverWrapper = new IntersectionObserverWrapper();
-
- handleScroll = throttle(() => {
- if (this.node) {
- const scrollTop = this.getScrollTop();
- const scrollHeight = this.getScrollHeight();
- const clientHeight = this.getClientHeight();
- const offset = scrollHeight - scrollTop - clientHeight;
-
- if (400 > offset && this.props.onLoadMore && this.props.hasMore && !this.props.isLoading) {
- this.props.onLoadMore();
- }
-
- if (scrollTop < 100 && this.props.onScrollToTop) {
- this.props.onScrollToTop();
- } else if (this.props.onScroll) {
- this.props.onScroll();
- }
-
- if (!this.lastScrollWasSynthetic) {
- // If the last scroll wasn't caused by setScrollTop(), assume it was
- // intentional and cancel any pending scroll reset on mouse idle
- this.scrollToTopOnMouseIdle = false;
- }
- this.lastScrollWasSynthetic = false;
- }
- }, 150, {
- trailing: true,
- });
-
- mouseIdleTimer = null;
- mouseMovedRecently = false;
- lastScrollWasSynthetic = false;
- scrollToTopOnMouseIdle = false;
-
- _getScrollingElement = () => {
- if (this.props.bindToDocument) {
- return (document.scrollingElement || document.body);
- } else {
- return this.node;
- }
- };
-
- setScrollTop = newScrollTop => {
- if (this.getScrollTop() !== newScrollTop) {
- this.lastScrollWasSynthetic = true;
-
- this._getScrollingElement().scrollTop = newScrollTop;
- }
- };
-
- clearMouseIdleTimer = () => {
- if (this.mouseIdleTimer === null) {
- return;
- }
-
- clearTimeout(this.mouseIdleTimer);
- this.mouseIdleTimer = null;
- };
-
- handleMouseMove = throttle(() => {
- // As long as the mouse keeps moving, clear and restart the idle timer.
- this.clearMouseIdleTimer();
- this.mouseIdleTimer = setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY);
-
- if (!this.mouseMovedRecently && this.getScrollTop() === 0) {
- // Only set if we just started moving and are scrolled to the top.
- this.scrollToTopOnMouseIdle = true;
- }
-
- // Save setting this flag for last, so we can do the comparison above.
- this.mouseMovedRecently = true;
- }, MOUSE_IDLE_DELAY / 2);
-
- handleWheel = throttle(() => {
- this.scrollToTopOnMouseIdle = false;
- }, 150, {
- trailing: true,
- });
-
- handleMouseIdle = () => {
- if (this.scrollToTopOnMouseIdle && !this.props.preventScroll) {
- this.setScrollTop(0);
- }
-
- this.mouseMovedRecently = false;
- this.scrollToTopOnMouseIdle = false;
- };
-
- componentDidMount () {
- this.attachScrollListener();
- this.attachIntersectionObserver();
-
- attachFullscreenListener(this.onFullScreenChange);
-
- // Handle initial scroll position
- this.handleScroll();
- }
-
- getScrollPosition = () => {
- if (this.node && (this.getScrollTop() > 0 || this.mouseMovedRecently)) {
- return { height: this.getScrollHeight(), top: this.getScrollTop() };
- } else {
- return null;
- }
- };
-
- getScrollTop = () => {
- return this._getScrollingElement().scrollTop;
- };
-
- getScrollHeight = () => {
- return this._getScrollingElement().scrollHeight;
- };
-
- getClientHeight = () => {
- return this._getScrollingElement().clientHeight;
- };
-
- updateScrollBottom = (snapshot) => {
- const newScrollTop = this.getScrollHeight() - snapshot;
-
- this.setScrollTop(newScrollTop);
- };
-
- getSnapshotBeforeUpdate (prevProps) {
- const someItemInserted = React.Children.count(prevProps.children) > 0 &&
- React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
- this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
- const pendingChanged = (prevProps.numPending > 0) !== (this.props.numPending > 0);
-
- if (pendingChanged || someItemInserted && (this.getScrollTop() > 0 || this.mouseMovedRecently || this.props.preventScroll)) {
- return this.getScrollHeight() - this.getScrollTop();
- } else {
- return null;
- }
- }
-
- componentDidUpdate (prevProps, prevState, snapshot) {
- // Reset the scroll position when a new child comes in in order not to
- // jerk the scrollbar around if you're already scrolled down the page.
- if (snapshot !== null) {
- this.setScrollTop(this.getScrollHeight() - snapshot);
- }
- }
-
- cacheMediaWidth = (width) => {
- if (width && this.state.cachedMediaWidth !== width) {
- this.setState({ cachedMediaWidth: width });
- }
- };
-
- componentWillUnmount () {
- this.clearMouseIdleTimer();
- this.detachScrollListener();
- this.detachIntersectionObserver();
-
- detachFullscreenListener(this.onFullScreenChange);
- }
-
- onFullScreenChange = () => {
- this.setState({ fullscreen: isFullscreen() });
- };
-
- attachIntersectionObserver () {
- let nodeOptions = {
- root: this.node,
- rootMargin: '300% 0px',
- };
-
- this.intersectionObserverWrapper
- .connect(this.props.bindToDocument ? {} : nodeOptions);
- }
-
- detachIntersectionObserver () {
- this.intersectionObserverWrapper.disconnect();
- }
-
- attachScrollListener () {
- if (this.props.bindToDocument) {
- document.addEventListener('scroll', this.handleScroll);
- document.addEventListener('wheel', this.handleWheel);
- } else {
- this.node.addEventListener('scroll', this.handleScroll);
- this.node.addEventListener('wheel', this.handleWheel);
- }
- }
-
- detachScrollListener () {
- if (this.props.bindToDocument) {
- document.removeEventListener('scroll', this.handleScroll);
- document.removeEventListener('wheel', this.handleWheel);
- } else {
- this.node.removeEventListener('scroll', this.handleScroll);
- this.node.removeEventListener('wheel', this.handleWheel);
- }
- }
-
- getFirstChildKey (props) {
- const { children } = props;
- let firstChild = children;
-
- if (children instanceof ImmutableList) {
- firstChild = children.get(0);
- } else if (Array.isArray(children)) {
- firstChild = children[0];
- }
-
- return firstChild && firstChild.key;
- }
-
- setRef = (c) => {
- this.node = c;
- };
-
- handleLoadMore = e => {
- e.preventDefault();
- this.props.onLoadMore();
- };
-
- handleLoadPending = e => {
- e.preventDefault();
- this.props.onLoadPending();
- // Prevent the weird scroll-jumping behavior, as we explicitly don't want to
- // scroll to top, and we know the scroll height is going to change
- this.scrollToTopOnMouseIdle = false;
- this.lastScrollWasSynthetic = false;
- this.clearMouseIdleTimer();
- this.mouseIdleTimer = setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY);
- this.mouseMovedRecently = true;
- };
-
- render () {
- const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
- const { fullscreen } = this.state;
- const childrenCount = React.Children.count(children);
-
- const loadMore = (hasMore && onLoadMore) ? : null;
- const loadPending = (numPending > 0) ? : null;
- let scrollableArea = null;
-
- if (showLoading) {
- scrollableArea = (
-
-
- {prepend}
-
-
-
-
-
-
- );
- } else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) {
- scrollableArea = (
-
-
- {prepend}
-
- {loadPending}
-
- {React.Children.map(this.props.children, (child, index) => (
-
- {React.cloneElement(child, {
- getScrollPosition: this.getScrollPosition,
- updateScrollBottom: this.updateScrollBottom,
- cachedMediaWidth: this.state.cachedMediaWidth,
- cacheMediaWidth: this.cacheMediaWidth,
- })}
-
- ))}
-
- {loadMore}
-
- {!hasMore && append}
-
-
- );
- } else {
- scrollableArea = (
-
- {alwaysPrepend && prepend}
-
-
- {emptyMessage}
-
-
- );
- }
-
- if (trackScroll) {
- return (
-
- {scrollableArea}
-
- );
- } else {
- return scrollableArea;
- }
- }
-
-}
diff --git a/app/javascript/mastodon/components/scrollable_list.jsx b/app/javascript/mastodon/components/scrollable_list.jsx
new file mode 100644
index 000000000..4a6ffb149
--- /dev/null
+++ b/app/javascript/mastodon/components/scrollable_list.jsx
@@ -0,0 +1,367 @@
+import React, { PureComponent } from 'react';
+import ScrollContainer from 'mastodon/containers/scroll_container';
+import PropTypes from 'prop-types';
+import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
+import LoadMore from './load_more';
+import LoadPending from './load_pending';
+import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
+import { throttle } from 'lodash';
+import { List as ImmutableList } from 'immutable';
+import classNames from 'classnames';
+import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
+import LoadingIndicator from './loading_indicator';
+import { connect } from 'react-redux';
+
+const MOUSE_IDLE_DELAY = 300;
+
+const mapStateToProps = (state, { scrollKey }) => {
+ return {
+ preventScroll: scrollKey === state.getIn(['dropdown_menu', 'scroll_key']),
+ };
+};
+
+export default @connect(mapStateToProps, null, null, { forwardRef: true })
+class ScrollableList extends PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ scrollKey: PropTypes.string.isRequired,
+ onLoadMore: PropTypes.func,
+ onLoadPending: PropTypes.func,
+ onScrollToTop: PropTypes.func,
+ onScroll: PropTypes.func,
+ trackScroll: PropTypes.bool,
+ isLoading: PropTypes.bool,
+ showLoading: PropTypes.bool,
+ hasMore: PropTypes.bool,
+ numPending: PropTypes.number,
+ prepend: PropTypes.node,
+ append: PropTypes.node,
+ alwaysPrepend: PropTypes.bool,
+ emptyMessage: PropTypes.node,
+ children: PropTypes.node,
+ bindToDocument: PropTypes.bool,
+ preventScroll: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ trackScroll: true,
+ };
+
+ state = {
+ fullscreen: null,
+ cachedMediaWidth: 250, // Default media/card width using default Mastodon theme
+ };
+
+ intersectionObserverWrapper = new IntersectionObserverWrapper();
+
+ handleScroll = throttle(() => {
+ if (this.node) {
+ const scrollTop = this.getScrollTop();
+ const scrollHeight = this.getScrollHeight();
+ const clientHeight = this.getClientHeight();
+ const offset = scrollHeight - scrollTop - clientHeight;
+
+ if (400 > offset && this.props.onLoadMore && this.props.hasMore && !this.props.isLoading) {
+ this.props.onLoadMore();
+ }
+
+ if (scrollTop < 100 && this.props.onScrollToTop) {
+ this.props.onScrollToTop();
+ } else if (this.props.onScroll) {
+ this.props.onScroll();
+ }
+
+ if (!this.lastScrollWasSynthetic) {
+ // If the last scroll wasn't caused by setScrollTop(), assume it was
+ // intentional and cancel any pending scroll reset on mouse idle
+ this.scrollToTopOnMouseIdle = false;
+ }
+ this.lastScrollWasSynthetic = false;
+ }
+ }, 150, {
+ trailing: true,
+ });
+
+ mouseIdleTimer = null;
+ mouseMovedRecently = false;
+ lastScrollWasSynthetic = false;
+ scrollToTopOnMouseIdle = false;
+
+ _getScrollingElement = () => {
+ if (this.props.bindToDocument) {
+ return (document.scrollingElement || document.body);
+ } else {
+ return this.node;
+ }
+ };
+
+ setScrollTop = newScrollTop => {
+ if (this.getScrollTop() !== newScrollTop) {
+ this.lastScrollWasSynthetic = true;
+
+ this._getScrollingElement().scrollTop = newScrollTop;
+ }
+ };
+
+ clearMouseIdleTimer = () => {
+ if (this.mouseIdleTimer === null) {
+ return;
+ }
+
+ clearTimeout(this.mouseIdleTimer);
+ this.mouseIdleTimer = null;
+ };
+
+ handleMouseMove = throttle(() => {
+ // As long as the mouse keeps moving, clear and restart the idle timer.
+ this.clearMouseIdleTimer();
+ this.mouseIdleTimer = setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY);
+
+ if (!this.mouseMovedRecently && this.getScrollTop() === 0) {
+ // Only set if we just started moving and are scrolled to the top.
+ this.scrollToTopOnMouseIdle = true;
+ }
+
+ // Save setting this flag for last, so we can do the comparison above.
+ this.mouseMovedRecently = true;
+ }, MOUSE_IDLE_DELAY / 2);
+
+ handleWheel = throttle(() => {
+ this.scrollToTopOnMouseIdle = false;
+ }, 150, {
+ trailing: true,
+ });
+
+ handleMouseIdle = () => {
+ if (this.scrollToTopOnMouseIdle && !this.props.preventScroll) {
+ this.setScrollTop(0);
+ }
+
+ this.mouseMovedRecently = false;
+ this.scrollToTopOnMouseIdle = false;
+ };
+
+ componentDidMount () {
+ this.attachScrollListener();
+ this.attachIntersectionObserver();
+
+ attachFullscreenListener(this.onFullScreenChange);
+
+ // Handle initial scroll position
+ this.handleScroll();
+ }
+
+ getScrollPosition = () => {
+ if (this.node && (this.getScrollTop() > 0 || this.mouseMovedRecently)) {
+ return { height: this.getScrollHeight(), top: this.getScrollTop() };
+ } else {
+ return null;
+ }
+ };
+
+ getScrollTop = () => {
+ return this._getScrollingElement().scrollTop;
+ };
+
+ getScrollHeight = () => {
+ return this._getScrollingElement().scrollHeight;
+ };
+
+ getClientHeight = () => {
+ return this._getScrollingElement().clientHeight;
+ };
+
+ updateScrollBottom = (snapshot) => {
+ const newScrollTop = this.getScrollHeight() - snapshot;
+
+ this.setScrollTop(newScrollTop);
+ };
+
+ getSnapshotBeforeUpdate (prevProps) {
+ const someItemInserted = React.Children.count(prevProps.children) > 0 &&
+ React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
+ this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
+ const pendingChanged = (prevProps.numPending > 0) !== (this.props.numPending > 0);
+
+ if (pendingChanged || someItemInserted && (this.getScrollTop() > 0 || this.mouseMovedRecently || this.props.preventScroll)) {
+ return this.getScrollHeight() - this.getScrollTop();
+ } else {
+ return null;
+ }
+ }
+
+ componentDidUpdate (prevProps, prevState, snapshot) {
+ // Reset the scroll position when a new child comes in in order not to
+ // jerk the scrollbar around if you're already scrolled down the page.
+ if (snapshot !== null) {
+ this.setScrollTop(this.getScrollHeight() - snapshot);
+ }
+ }
+
+ cacheMediaWidth = (width) => {
+ if (width && this.state.cachedMediaWidth !== width) {
+ this.setState({ cachedMediaWidth: width });
+ }
+ };
+
+ componentWillUnmount () {
+ this.clearMouseIdleTimer();
+ this.detachScrollListener();
+ this.detachIntersectionObserver();
+
+ detachFullscreenListener(this.onFullScreenChange);
+ }
+
+ onFullScreenChange = () => {
+ this.setState({ fullscreen: isFullscreen() });
+ };
+
+ attachIntersectionObserver () {
+ let nodeOptions = {
+ root: this.node,
+ rootMargin: '300% 0px',
+ };
+
+ this.intersectionObserverWrapper
+ .connect(this.props.bindToDocument ? {} : nodeOptions);
+ }
+
+ detachIntersectionObserver () {
+ this.intersectionObserverWrapper.disconnect();
+ }
+
+ attachScrollListener () {
+ if (this.props.bindToDocument) {
+ document.addEventListener('scroll', this.handleScroll);
+ document.addEventListener('wheel', this.handleWheel);
+ } else {
+ this.node.addEventListener('scroll', this.handleScroll);
+ this.node.addEventListener('wheel', this.handleWheel);
+ }
+ }
+
+ detachScrollListener () {
+ if (this.props.bindToDocument) {
+ document.removeEventListener('scroll', this.handleScroll);
+ document.removeEventListener('wheel', this.handleWheel);
+ } else {
+ this.node.removeEventListener('scroll', this.handleScroll);
+ this.node.removeEventListener('wheel', this.handleWheel);
+ }
+ }
+
+ getFirstChildKey (props) {
+ const { children } = props;
+ let firstChild = children;
+
+ if (children instanceof ImmutableList) {
+ firstChild = children.get(0);
+ } else if (Array.isArray(children)) {
+ firstChild = children[0];
+ }
+
+ return firstChild && firstChild.key;
+ }
+
+ setRef = (c) => {
+ this.node = c;
+ };
+
+ handleLoadMore = e => {
+ e.preventDefault();
+ this.props.onLoadMore();
+ };
+
+ handleLoadPending = e => {
+ e.preventDefault();
+ this.props.onLoadPending();
+ // Prevent the weird scroll-jumping behavior, as we explicitly don't want to
+ // scroll to top, and we know the scroll height is going to change
+ this.scrollToTopOnMouseIdle = false;
+ this.lastScrollWasSynthetic = false;
+ this.clearMouseIdleTimer();
+ this.mouseIdleTimer = setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY);
+ this.mouseMovedRecently = true;
+ };
+
+ render () {
+ const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
+ const { fullscreen } = this.state;
+ const childrenCount = React.Children.count(children);
+
+ const loadMore = (hasMore && onLoadMore) ? : null;
+ const loadPending = (numPending > 0) ? : null;
+ let scrollableArea = null;
+
+ if (showLoading) {
+ scrollableArea = (
+
+
+ {prepend}
+
+
+
+
+
+
+ );
+ } else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) {
+ scrollableArea = (
+
+
+ {prepend}
+
+ {loadPending}
+
+ {React.Children.map(this.props.children, (child, index) => (
+
+ {React.cloneElement(child, {
+ getScrollPosition: this.getScrollPosition,
+ updateScrollBottom: this.updateScrollBottom,
+ cachedMediaWidth: this.state.cachedMediaWidth,
+ cacheMediaWidth: this.cacheMediaWidth,
+ })}
+
+ ))}
+
+ {loadMore}
+
+ {!hasMore && append}
+
+
+ );
+ } else {
+ scrollableArea = (
+
+ {alwaysPrepend && prepend}
+
+
+ {emptyMessage}
+
+
+ );
+ }
+
+ if (trackScroll) {
+ return (
+
+ {scrollableArea}
+
+ );
+ } else {
+ return scrollableArea;
+ }
+ }
+
+}
diff --git a/app/javascript/mastodon/components/server_banner.js b/app/javascript/mastodon/components/server_banner.js
deleted file mode 100644
index 617fdecdf..000000000
--- a/app/javascript/mastodon/components/server_banner.js
+++ /dev/null
@@ -1,93 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
-import { connect } from 'react-redux';
-import { fetchServer } from 'mastodon/actions/server';
-import ShortNumber from 'mastodon/components/short_number';
-import Skeleton from 'mastodon/components/skeleton';
-import Account from 'mastodon/containers/account_container';
-import { domain } from 'mastodon/initial_state';
-import Image from 'mastodon/components/image';
-import { Link } from 'react-router-dom';
-
-const messages = defineMessages({
- aboutActiveUsers: { id: 'server_banner.about_active_users', defaultMessage: 'People using this server during the last 30 days (Monthly Active Users)' },
-});
-
-const mapStateToProps = state => ({
- server: state.getIn(['server', 'server']),
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-class ServerBanner extends React.PureComponent {
-
- static propTypes = {
- server: PropTypes.object,
- dispatch: PropTypes.func,
- intl: PropTypes.object,
- };
-
- componentDidMount () {
- const { dispatch } = this.props;
- dispatch(fetchServer());
- }
-
- render () {
- const { server, intl } = this.props;
- const isLoading = server.get('isLoading');
-
- return (
-
-
-
-
-
-
- {isLoading ? (
- <>
-
-
-
-
-
- >
- ) : server.get('description')}
-
-
-
-
-
-
-
-
- {isLoading ? (
- <>
-
-
-
- >
- ) : (
- <>
-
-
-
- >
- )}
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/server_banner.jsx b/app/javascript/mastodon/components/server_banner.jsx
new file mode 100644
index 000000000..617fdecdf
--- /dev/null
+++ b/app/javascript/mastodon/components/server_banner.jsx
@@ -0,0 +1,93 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import { connect } from 'react-redux';
+import { fetchServer } from 'mastodon/actions/server';
+import ShortNumber from 'mastodon/components/short_number';
+import Skeleton from 'mastodon/components/skeleton';
+import Account from 'mastodon/containers/account_container';
+import { domain } from 'mastodon/initial_state';
+import Image from 'mastodon/components/image';
+import { Link } from 'react-router-dom';
+
+const messages = defineMessages({
+ aboutActiveUsers: { id: 'server_banner.about_active_users', defaultMessage: 'People using this server during the last 30 days (Monthly Active Users)' },
+});
+
+const mapStateToProps = state => ({
+ server: state.getIn(['server', 'server']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class ServerBanner extends React.PureComponent {
+
+ static propTypes = {
+ server: PropTypes.object,
+ dispatch: PropTypes.func,
+ intl: PropTypes.object,
+ };
+
+ componentDidMount () {
+ const { dispatch } = this.props;
+ dispatch(fetchServer());
+ }
+
+ render () {
+ const { server, intl } = this.props;
+ const isLoading = server.get('isLoading');
+
+ return (
+
+
+
+
+
+
+ {isLoading ? (
+ <>
+
+
+
+
+
+ >
+ ) : server.get('description')}
+
+
+
+
+
+
+
+
+ {isLoading ? (
+ <>
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/short_number.js b/app/javascript/mastodon/components/short_number.js
deleted file mode 100644
index 535c17727..000000000
--- a/app/javascript/mastodon/components/short_number.js
+++ /dev/null
@@ -1,117 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../utils/numbers';
-import { FormattedMessage, FormattedNumber } from 'react-intl';
-// @ts-check
-
-/**
- * @callback ShortNumberRenderer
- * @param {JSX.Element} displayNumber Number to display
- * @param {number} pluralReady Number used for pluralization
- * @returns {JSX.Element} Final render of number
- */
-
-/**
- * @typedef {object} ShortNumberProps
- * @property {number} value Number to display in short variant
- * @property {ShortNumberRenderer} [renderer]
- * Custom renderer for numbers, provided as a prop. If another renderer
- * passed as a child of this component, this prop won't be used.
- * @property {ShortNumberRenderer} [children]
- * Custom renderer for numbers, provided as a child. If another renderer
- * passed as a prop of this component, this one will be used instead.
- */
-
-/**
- * Component that renders short big number to a shorter version
- *
- * @param {ShortNumberProps} param0 Props for the component
- * @returns {JSX.Element} Rendered number
- */
-function ShortNumber({ value, renderer, children }) {
- const shortNumber = toShortNumber(value);
- const [, division] = shortNumber;
-
- // eslint-disable-next-line eqeqeq
- if (children != null && renderer != null) {
- console.warn('Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.');
- }
-
- // eslint-disable-next-line eqeqeq
- const customRenderer = children != null ? children : renderer;
-
- const displayNumber = ;
-
- // eslint-disable-next-line eqeqeq
- return customRenderer != null
- ? customRenderer(displayNumber, pluralReady(value, division))
- : displayNumber;
-}
-
-ShortNumber.propTypes = {
- value: PropTypes.number.isRequired,
- renderer: PropTypes.func,
- children: PropTypes.func,
-};
-
-/**
- * @typedef {object} ShortNumberCounterProps
- * @property {import('../utils/number').ShortNumber} value Short number
- */
-
-/**
- * Renders short number into corresponding localizable react fragment
- *
- * @param {ShortNumberCounterProps} param0 Props for the component
- * @returns {JSX.Element} FormattedMessage ready to be embedded in code
- */
-function ShortNumberCounter({ value }) {
- const [rawNumber, unit, maxFractionDigits = 0] = value;
-
- const count = (
-
- );
-
- let values = { count, rawNumber };
-
- switch (unit) {
- case DECIMAL_UNITS.THOUSAND: {
- return (
-
- );
- }
- case DECIMAL_UNITS.MILLION: {
- return (
-
- );
- }
- case DECIMAL_UNITS.BILLION: {
- return (
-
- );
- }
- // Not sure if we should go farther - @Sasha-Sorokin
- default: return count;
- }
-}
-
-ShortNumberCounter.propTypes = {
- value: PropTypes.arrayOf(PropTypes.number),
-};
-
-export default React.memo(ShortNumber);
diff --git a/app/javascript/mastodon/components/short_number.jsx b/app/javascript/mastodon/components/short_number.jsx
new file mode 100644
index 000000000..535c17727
--- /dev/null
+++ b/app/javascript/mastodon/components/short_number.jsx
@@ -0,0 +1,117 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../utils/numbers';
+import { FormattedMessage, FormattedNumber } from 'react-intl';
+// @ts-check
+
+/**
+ * @callback ShortNumberRenderer
+ * @param {JSX.Element} displayNumber Number to display
+ * @param {number} pluralReady Number used for pluralization
+ * @returns {JSX.Element} Final render of number
+ */
+
+/**
+ * @typedef {object} ShortNumberProps
+ * @property {number} value Number to display in short variant
+ * @property {ShortNumberRenderer} [renderer]
+ * Custom renderer for numbers, provided as a prop. If another renderer
+ * passed as a child of this component, this prop won't be used.
+ * @property {ShortNumberRenderer} [children]
+ * Custom renderer for numbers, provided as a child. If another renderer
+ * passed as a prop of this component, this one will be used instead.
+ */
+
+/**
+ * Component that renders short big number to a shorter version
+ *
+ * @param {ShortNumberProps} param0 Props for the component
+ * @returns {JSX.Element} Rendered number
+ */
+function ShortNumber({ value, renderer, children }) {
+ const shortNumber = toShortNumber(value);
+ const [, division] = shortNumber;
+
+ // eslint-disable-next-line eqeqeq
+ if (children != null && renderer != null) {
+ console.warn('Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.');
+ }
+
+ // eslint-disable-next-line eqeqeq
+ const customRenderer = children != null ? children : renderer;
+
+ const displayNumber = ;
+
+ // eslint-disable-next-line eqeqeq
+ return customRenderer != null
+ ? customRenderer(displayNumber, pluralReady(value, division))
+ : displayNumber;
+}
+
+ShortNumber.propTypes = {
+ value: PropTypes.number.isRequired,
+ renderer: PropTypes.func,
+ children: PropTypes.func,
+};
+
+/**
+ * @typedef {object} ShortNumberCounterProps
+ * @property {import('../utils/number').ShortNumber} value Short number
+ */
+
+/**
+ * Renders short number into corresponding localizable react fragment
+ *
+ * @param {ShortNumberCounterProps} param0 Props for the component
+ * @returns {JSX.Element} FormattedMessage ready to be embedded in code
+ */
+function ShortNumberCounter({ value }) {
+ const [rawNumber, unit, maxFractionDigits = 0] = value;
+
+ const count = (
+
+ );
+
+ let values = { count, rawNumber };
+
+ switch (unit) {
+ case DECIMAL_UNITS.THOUSAND: {
+ return (
+
+ );
+ }
+ case DECIMAL_UNITS.MILLION: {
+ return (
+
+ );
+ }
+ case DECIMAL_UNITS.BILLION: {
+ return (
+
+ );
+ }
+ // Not sure if we should go farther - @Sasha-Sorokin
+ default: return count;
+ }
+}
+
+ShortNumberCounter.propTypes = {
+ value: PropTypes.arrayOf(PropTypes.number),
+};
+
+export default React.memo(ShortNumber);
diff --git a/app/javascript/mastodon/components/skeleton.js b/app/javascript/mastodon/components/skeleton.js
deleted file mode 100644
index 6a17ffb26..000000000
--- a/app/javascript/mastodon/components/skeleton.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-const Skeleton = ({ width, height }) => ;
-
-Skeleton.propTypes = {
- width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
- height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
-};
-
-export default Skeleton;
diff --git a/app/javascript/mastodon/components/skeleton.jsx b/app/javascript/mastodon/components/skeleton.jsx
new file mode 100644
index 000000000..6a17ffb26
--- /dev/null
+++ b/app/javascript/mastodon/components/skeleton.jsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const Skeleton = ({ width, height }) => ;
+
+Skeleton.propTypes = {
+ width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
+ height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
+};
+
+export default Skeleton;
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
deleted file mode 100644
index f02910f5a..000000000
--- a/app/javascript/mastodon/components/status.js
+++ /dev/null
@@ -1,547 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import Avatar from './avatar';
-import AvatarOverlay from './avatar_overlay';
-import RelativeTimestamp from './relative_timestamp';
-import DisplayName from './display_name';
-import StatusContent from './status_content';
-import StatusActionBar from './status_action_bar';
-import AttachmentList from './attachment_list';
-import Card from '../features/status/components/card';
-import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
-import { HotKeys } from 'react-hotkeys';
-import classNames from 'classnames';
-import Icon from 'mastodon/components/icon';
-import { displayMedia } from '../initial_state';
-import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
-
-// We use the component (and not the container) since we do not want
-// to use the progress bar to show download progress
-import Bundle from '../features/ui/components/bundle';
-
-export const textForScreenReader = (intl, status, rebloggedByText = false) => {
- const displayName = status.getIn(['account', 'display_name']);
-
- const values = [
- displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName,
- status.get('spoiler_text') && status.get('hidden') ? status.get('spoiler_text') : status.get('search_index').slice(status.get('spoiler_text').length),
- intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }),
- status.getIn(['account', 'acct']),
- ];
-
- if (rebloggedByText) {
- values.push(rebloggedByText);
- }
-
- return values.join(', ');
-};
-
-export const defaultMediaVisibility = (status) => {
- if (!status) {
- return undefined;
- }
-
- if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
- status = status.get('reblog');
- }
-
- return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
-};
-
-const messages = defineMessages({
- public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
- unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
- private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
- direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
- edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
-});
-
-export default @injectIntl
-class Status extends ImmutablePureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- };
-
- static propTypes = {
- status: ImmutablePropTypes.map,
- account: ImmutablePropTypes.map,
- onClick: PropTypes.func,
- onReply: PropTypes.func,
- onFavourite: PropTypes.func,
- onReblog: PropTypes.func,
- onDelete: PropTypes.func,
- onDirect: PropTypes.func,
- onMention: PropTypes.func,
- onPin: PropTypes.func,
- onOpenMedia: PropTypes.func,
- onOpenVideo: PropTypes.func,
- onBlock: PropTypes.func,
- onAddFilter: PropTypes.func,
- onEmbed: PropTypes.func,
- onHeightChange: PropTypes.func,
- onToggleHidden: PropTypes.func,
- onToggleCollapsed: PropTypes.func,
- onTranslate: PropTypes.func,
- onInteractionModal: PropTypes.func,
- muted: PropTypes.bool,
- hidden: PropTypes.bool,
- unread: PropTypes.bool,
- onMoveUp: PropTypes.func,
- onMoveDown: PropTypes.func,
- showThread: PropTypes.bool,
- getScrollPosition: PropTypes.func,
- updateScrollBottom: PropTypes.func,
- cacheMediaWidth: PropTypes.func,
- cachedMediaWidth: PropTypes.number,
- scrollKey: PropTypes.string,
- deployPictureInPicture: PropTypes.func,
- pictureInPicture: ImmutablePropTypes.contains({
- inUse: PropTypes.bool,
- available: PropTypes.bool,
- }),
- };
-
- // Avoid checking props that are functions (and whose equality will always
- // evaluate to false. See react-immutable-pure-component for usage.
- updateOnProps = [
- 'status',
- 'account',
- 'muted',
- 'hidden',
- 'unread',
- 'pictureInPicture',
- ];
-
- state = {
- showMedia: defaultMediaVisibility(this.props.status),
- statusId: undefined,
- forceFilter: undefined,
- };
-
- static getDerivedStateFromProps(nextProps, prevState) {
- if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
- return {
- showMedia: defaultMediaVisibility(nextProps.status),
- statusId: nextProps.status.get('id'),
- };
- } else {
- return null;
- }
- }
-
- handleToggleMediaVisibility = () => {
- this.setState({ showMedia: !this.state.showMedia });
- };
-
- handleClick = e => {
- if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
- return;
- }
-
- if (e) {
- e.preventDefault();
- }
-
- this.handleHotkeyOpen();
- };
-
- handlePrependAccountClick = e => {
- this.handleAccountClick(e, false);
- };
-
- handleAccountClick = (e, proper = true) => {
- if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
- return;
- }
-
- if (e) {
- e.preventDefault();
- }
-
- this._openProfile(proper);
- };
-
- handleExpandedToggle = () => {
- this.props.onToggleHidden(this._properStatus());
- };
-
- handleCollapsedToggle = isCollapsed => {
- this.props.onToggleCollapsed(this._properStatus(), isCollapsed);
- };
-
- handleTranslate = () => {
- this.props.onTranslate(this._properStatus());
- };
-
- renderLoadingMediaGallery () {
- return
;
- }
-
- renderLoadingVideoPlayer () {
- return
;
- }
-
- renderLoadingAudioPlayer () {
- return
;
- }
-
- handleOpenVideo = (options) => {
- const status = this._properStatus();
- this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), options);
- };
-
- handleOpenMedia = (media, index) => {
- this.props.onOpenMedia(this._properStatus().get('id'), media, index);
- };
-
- handleHotkeyOpenMedia = e => {
- const { onOpenMedia, onOpenVideo } = this.props;
- const status = this._properStatus();
-
- e.preventDefault();
-
- if (status.get('media_attachments').size > 0) {
- if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
- onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), { startTime: 0 });
- } else {
- onOpenMedia(status.get('id'), status.get('media_attachments'), 0);
- }
- }
- };
-
- handleDeployPictureInPicture = (type, mediaProps) => {
- const { deployPictureInPicture } = this.props;
- const status = this._properStatus();
-
- deployPictureInPicture(status, type, mediaProps);
- };
-
- handleHotkeyReply = e => {
- e.preventDefault();
- this.props.onReply(this._properStatus(), this.context.router.history);
- };
-
- handleHotkeyFavourite = () => {
- this.props.onFavourite(this._properStatus());
- };
-
- handleHotkeyBoost = e => {
- this.props.onReblog(this._properStatus(), e);
- };
-
- handleHotkeyMention = e => {
- e.preventDefault();
- this.props.onMention(this._properStatus().get('account'), this.context.router.history);
- };
-
- handleHotkeyOpen = () => {
- if (this.props.onClick) {
- this.props.onClick();
- return;
- }
-
- const { router } = this.context;
- const status = this._properStatus();
-
- if (!router) {
- return;
- }
-
- router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
- };
-
- handleHotkeyOpenProfile = () => {
- this._openProfile();
- };
-
- _openProfile = (proper = true) => {
- const { router } = this.context;
- const status = proper ? this._properStatus() : this.props.status;
-
- if (!router) {
- return;
- }
-
- router.history.push(`/@${status.getIn(['account', 'acct'])}`);
- };
-
- handleHotkeyMoveUp = e => {
- this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured'));
- };
-
- handleHotkeyMoveDown = e => {
- this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured'));
- };
-
- handleHotkeyToggleHidden = () => {
- this.props.onToggleHidden(this._properStatus());
- };
-
- handleHotkeyToggleSensitive = () => {
- this.handleToggleMediaVisibility();
- };
-
- handleUnfilterClick = e => {
- this.setState({ forceFilter: false });
- e.preventDefault();
- };
-
- handleFilterClick = () => {
- this.setState({ forceFilter: true });
- };
-
- _properStatus () {
- const { status } = this.props;
-
- if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
- return status.get('reblog');
- } else {
- return status;
- }
- }
-
- handleRef = c => {
- this.node = c;
- };
-
- render () {
- let media = null;
- let statusAvatar, prepend, rebloggedByText;
-
- const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture } = this.props;
-
- let { status, account, ...other } = this.props;
-
- if (status === null) {
- return null;
- }
-
- const handlers = this.props.muted ? {} : {
- reply: this.handleHotkeyReply,
- favourite: this.handleHotkeyFavourite,
- boost: this.handleHotkeyBoost,
- mention: this.handleHotkeyMention,
- open: this.handleHotkeyOpen,
- openProfile: this.handleHotkeyOpenProfile,
- moveUp: this.handleHotkeyMoveUp,
- moveDown: this.handleHotkeyMoveDown,
- toggleHidden: this.handleHotkeyToggleHidden,
- toggleSensitive: this.handleHotkeyToggleSensitive,
- openMedia: this.handleHotkeyOpenMedia,
- };
-
- if (hidden) {
- return (
-
-
- {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
- {status.get('content')}
-
-
- );
- }
-
- const matchedFilters = status.get('matched_filters');
- if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) {
- const minHandlers = this.props.muted ? {} : {
- moveUp: this.handleHotkeyMoveUp,
- moveDown: this.handleHotkeyMoveDown,
- };
-
- return (
-
-
- : {matchedFilters.join(', ')}.
- {' '}
-
-
-
-
-
- );
- }
-
- if (featured) {
- prepend = (
-
- );
- } else if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
- const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
-
- prepend = (
-
- );
-
- rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} boosted' }, { name: status.getIn(['account', 'acct']) });
-
- account = status.get('account');
- status = status.get('reblog');
- } else if (showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id'])) {
- const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
-
- prepend = (
-
- );
- }
-
- if (pictureInPicture.get('inUse')) {
- media = ;
- } else if (status.get('media_attachments').size > 0) {
- if (this.props.muted) {
- media = (
-
- );
- } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
- const attachment = status.getIn(['media_attachments', 0]);
-
- media = (
-
- {Component => (
-
- )}
-
- );
- } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
- const attachment = status.getIn(['media_attachments', 0]);
-
- media = (
-
- {Component => (
-
- )}
-
- );
- } else {
- media = (
-
- {Component => (
-
- )}
-
- );
- }
- } else if (status.get('spoiler_text').length === 0 && status.get('card') && !this.props.muted) {
- media = (
-
- );
- }
-
- if (account === undefined || account === null) {
- statusAvatar = ;
- } else {
- statusAvatar = ;
- }
-
- const visibilityIconInfo = {
- 'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
- 'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
- 'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
- 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) },
- };
-
- const visibilityIcon = visibilityIconInfo[status.get('visibility')];
-
- return (
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx
new file mode 100644
index 000000000..f02910f5a
--- /dev/null
+++ b/app/javascript/mastodon/components/status.jsx
@@ -0,0 +1,547 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Avatar from './avatar';
+import AvatarOverlay from './avatar_overlay';
+import RelativeTimestamp from './relative_timestamp';
+import DisplayName from './display_name';
+import StatusContent from './status_content';
+import StatusActionBar from './status_action_bar';
+import AttachmentList from './attachment_list';
+import Card from '../features/status/components/card';
+import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
+import { HotKeys } from 'react-hotkeys';
+import classNames from 'classnames';
+import Icon from 'mastodon/components/icon';
+import { displayMedia } from '../initial_state';
+import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
+
+// We use the component (and not the container) since we do not want
+// to use the progress bar to show download progress
+import Bundle from '../features/ui/components/bundle';
+
+export const textForScreenReader = (intl, status, rebloggedByText = false) => {
+ const displayName = status.getIn(['account', 'display_name']);
+
+ const values = [
+ displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName,
+ status.get('spoiler_text') && status.get('hidden') ? status.get('spoiler_text') : status.get('search_index').slice(status.get('spoiler_text').length),
+ intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }),
+ status.getIn(['account', 'acct']),
+ ];
+
+ if (rebloggedByText) {
+ values.push(rebloggedByText);
+ }
+
+ return values.join(', ');
+};
+
+export const defaultMediaVisibility = (status) => {
+ if (!status) {
+ return undefined;
+ }
+
+ if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
+ status = status.get('reblog');
+ }
+
+ return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
+};
+
+const messages = defineMessages({
+ public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
+ unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+ private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
+ direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
+ edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
+});
+
+export default @injectIntl
+class Status extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map,
+ account: ImmutablePropTypes.map,
+ onClick: PropTypes.func,
+ onReply: PropTypes.func,
+ onFavourite: PropTypes.func,
+ onReblog: PropTypes.func,
+ onDelete: PropTypes.func,
+ onDirect: PropTypes.func,
+ onMention: PropTypes.func,
+ onPin: PropTypes.func,
+ onOpenMedia: PropTypes.func,
+ onOpenVideo: PropTypes.func,
+ onBlock: PropTypes.func,
+ onAddFilter: PropTypes.func,
+ onEmbed: PropTypes.func,
+ onHeightChange: PropTypes.func,
+ onToggleHidden: PropTypes.func,
+ onToggleCollapsed: PropTypes.func,
+ onTranslate: PropTypes.func,
+ onInteractionModal: PropTypes.func,
+ muted: PropTypes.bool,
+ hidden: PropTypes.bool,
+ unread: PropTypes.bool,
+ onMoveUp: PropTypes.func,
+ onMoveDown: PropTypes.func,
+ showThread: PropTypes.bool,
+ getScrollPosition: PropTypes.func,
+ updateScrollBottom: PropTypes.func,
+ cacheMediaWidth: PropTypes.func,
+ cachedMediaWidth: PropTypes.number,
+ scrollKey: PropTypes.string,
+ deployPictureInPicture: PropTypes.func,
+ pictureInPicture: ImmutablePropTypes.contains({
+ inUse: PropTypes.bool,
+ available: PropTypes.bool,
+ }),
+ };
+
+ // Avoid checking props that are functions (and whose equality will always
+ // evaluate to false. See react-immutable-pure-component for usage.
+ updateOnProps = [
+ 'status',
+ 'account',
+ 'muted',
+ 'hidden',
+ 'unread',
+ 'pictureInPicture',
+ ];
+
+ state = {
+ showMedia: defaultMediaVisibility(this.props.status),
+ statusId: undefined,
+ forceFilter: undefined,
+ };
+
+ static getDerivedStateFromProps(nextProps, prevState) {
+ if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
+ return {
+ showMedia: defaultMediaVisibility(nextProps.status),
+ statusId: nextProps.status.get('id'),
+ };
+ } else {
+ return null;
+ }
+ }
+
+ handleToggleMediaVisibility = () => {
+ this.setState({ showMedia: !this.state.showMedia });
+ };
+
+ handleClick = e => {
+ if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
+ return;
+ }
+
+ if (e) {
+ e.preventDefault();
+ }
+
+ this.handleHotkeyOpen();
+ };
+
+ handlePrependAccountClick = e => {
+ this.handleAccountClick(e, false);
+ };
+
+ handleAccountClick = (e, proper = true) => {
+ if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
+ return;
+ }
+
+ if (e) {
+ e.preventDefault();
+ }
+
+ this._openProfile(proper);
+ };
+
+ handleExpandedToggle = () => {
+ this.props.onToggleHidden(this._properStatus());
+ };
+
+ handleCollapsedToggle = isCollapsed => {
+ this.props.onToggleCollapsed(this._properStatus(), isCollapsed);
+ };
+
+ handleTranslate = () => {
+ this.props.onTranslate(this._properStatus());
+ };
+
+ renderLoadingMediaGallery () {
+ return
;
+ }
+
+ renderLoadingVideoPlayer () {
+ return
;
+ }
+
+ renderLoadingAudioPlayer () {
+ return
;
+ }
+
+ handleOpenVideo = (options) => {
+ const status = this._properStatus();
+ this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), options);
+ };
+
+ handleOpenMedia = (media, index) => {
+ this.props.onOpenMedia(this._properStatus().get('id'), media, index);
+ };
+
+ handleHotkeyOpenMedia = e => {
+ const { onOpenMedia, onOpenVideo } = this.props;
+ const status = this._properStatus();
+
+ e.preventDefault();
+
+ if (status.get('media_attachments').size > 0) {
+ if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+ onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), { startTime: 0 });
+ } else {
+ onOpenMedia(status.get('id'), status.get('media_attachments'), 0);
+ }
+ }
+ };
+
+ handleDeployPictureInPicture = (type, mediaProps) => {
+ const { deployPictureInPicture } = this.props;
+ const status = this._properStatus();
+
+ deployPictureInPicture(status, type, mediaProps);
+ };
+
+ handleHotkeyReply = e => {
+ e.preventDefault();
+ this.props.onReply(this._properStatus(), this.context.router.history);
+ };
+
+ handleHotkeyFavourite = () => {
+ this.props.onFavourite(this._properStatus());
+ };
+
+ handleHotkeyBoost = e => {
+ this.props.onReblog(this._properStatus(), e);
+ };
+
+ handleHotkeyMention = e => {
+ e.preventDefault();
+ this.props.onMention(this._properStatus().get('account'), this.context.router.history);
+ };
+
+ handleHotkeyOpen = () => {
+ if (this.props.onClick) {
+ this.props.onClick();
+ return;
+ }
+
+ const { router } = this.context;
+ const status = this._properStatus();
+
+ if (!router) {
+ return;
+ }
+
+ router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
+ };
+
+ handleHotkeyOpenProfile = () => {
+ this._openProfile();
+ };
+
+ _openProfile = (proper = true) => {
+ const { router } = this.context;
+ const status = proper ? this._properStatus() : this.props.status;
+
+ if (!router) {
+ return;
+ }
+
+ router.history.push(`/@${status.getIn(['account', 'acct'])}`);
+ };
+
+ handleHotkeyMoveUp = e => {
+ this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured'));
+ };
+
+ handleHotkeyMoveDown = e => {
+ this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured'));
+ };
+
+ handleHotkeyToggleHidden = () => {
+ this.props.onToggleHidden(this._properStatus());
+ };
+
+ handleHotkeyToggleSensitive = () => {
+ this.handleToggleMediaVisibility();
+ };
+
+ handleUnfilterClick = e => {
+ this.setState({ forceFilter: false });
+ e.preventDefault();
+ };
+
+ handleFilterClick = () => {
+ this.setState({ forceFilter: true });
+ };
+
+ _properStatus () {
+ const { status } = this.props;
+
+ if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
+ return status.get('reblog');
+ } else {
+ return status;
+ }
+ }
+
+ handleRef = c => {
+ this.node = c;
+ };
+
+ render () {
+ let media = null;
+ let statusAvatar, prepend, rebloggedByText;
+
+ const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture } = this.props;
+
+ let { status, account, ...other } = this.props;
+
+ if (status === null) {
+ return null;
+ }
+
+ const handlers = this.props.muted ? {} : {
+ reply: this.handleHotkeyReply,
+ favourite: this.handleHotkeyFavourite,
+ boost: this.handleHotkeyBoost,
+ mention: this.handleHotkeyMention,
+ open: this.handleHotkeyOpen,
+ openProfile: this.handleHotkeyOpenProfile,
+ moveUp: this.handleHotkeyMoveUp,
+ moveDown: this.handleHotkeyMoveDown,
+ toggleHidden: this.handleHotkeyToggleHidden,
+ toggleSensitive: this.handleHotkeyToggleSensitive,
+ openMedia: this.handleHotkeyOpenMedia,
+ };
+
+ if (hidden) {
+ return (
+
+
+ {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
+ {status.get('content')}
+
+
+ );
+ }
+
+ const matchedFilters = status.get('matched_filters');
+ if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) {
+ const minHandlers = this.props.muted ? {} : {
+ moveUp: this.handleHotkeyMoveUp,
+ moveDown: this.handleHotkeyMoveDown,
+ };
+
+ return (
+
+
+ : {matchedFilters.join(', ')}.
+ {' '}
+
+
+
+
+
+ );
+ }
+
+ if (featured) {
+ prepend = (
+
+ );
+ } else if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
+ const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
+
+ prepend = (
+
+ );
+
+ rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} boosted' }, { name: status.getIn(['account', 'acct']) });
+
+ account = status.get('account');
+ status = status.get('reblog');
+ } else if (showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id'])) {
+ const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
+
+ prepend = (
+
+ );
+ }
+
+ if (pictureInPicture.get('inUse')) {
+ media = ;
+ } else if (status.get('media_attachments').size > 0) {
+ if (this.props.muted) {
+ media = (
+
+ );
+ } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
+ const attachment = status.getIn(['media_attachments', 0]);
+
+ media = (
+
+ {Component => (
+
+ )}
+
+ );
+ } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+ const attachment = status.getIn(['media_attachments', 0]);
+
+ media = (
+
+ {Component => (
+
+ )}
+
+ );
+ } else {
+ media = (
+
+ {Component => (
+
+ )}
+
+ );
+ }
+ } else if (status.get('spoiler_text').length === 0 && status.get('card') && !this.props.muted) {
+ media = (
+
+ );
+ }
+
+ if (account === undefined || account === null) {
+ statusAvatar = ;
+ } else {
+ statusAvatar = ;
+ }
+
+ const visibilityIconInfo = {
+ 'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
+ 'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
+ 'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
+ 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) },
+ };
+
+ const visibilityIcon = visibilityIconInfo[status.get('visibility')];
+
+ return (
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
deleted file mode 100644
index eeb376561..000000000
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ /dev/null
@@ -1,387 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import IconButton from './icon_button';
-import DropdownMenuContainer from '../containers/dropdown_menu_container';
-import { defineMessages, injectIntl } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { me } from '../initial_state';
-import classNames from 'classnames';
-import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
-
-const messages = defineMessages({
- delete: { id: 'status.delete', defaultMessage: 'Delete' },
- redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
- edit: { id: 'status.edit', defaultMessage: 'Edit' },
- direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
- mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
- mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
- block: { id: 'account.block', defaultMessage: 'Block @{name}' },
- reply: { id: 'status.reply', defaultMessage: 'Reply' },
- share: { id: 'status.share', defaultMessage: 'Share' },
- more: { id: 'status.more', defaultMessage: 'More' },
- replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
- reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
- reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
- cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
- cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
- favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
- bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
- removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
- open: { id: 'status.open', defaultMessage: 'Expand this status' },
- report: { id: 'status.report', defaultMessage: 'Report @{name}' },
- muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
- unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
- pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
- unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
- embed: { id: 'status.embed', defaultMessage: 'Embed' },
- admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
- admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
- admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
- copy: { id: 'status.copy', defaultMessage: 'Copy link to post' },
- hide: { id: 'status.hide', defaultMessage: 'Hide post' },
- blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
- unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
- unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
- unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
- filter: { id: 'status.filter', defaultMessage: 'Filter this post' },
- openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
-});
-
-const mapStateToProps = (state, { status }) => ({
- relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-class StatusActionBar extends ImmutablePureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- identity: PropTypes.object,
- };
-
- static propTypes = {
- status: ImmutablePropTypes.map.isRequired,
- relationship: ImmutablePropTypes.map,
- onReply: PropTypes.func,
- onFavourite: PropTypes.func,
- onReblog: PropTypes.func,
- onDelete: PropTypes.func,
- onDirect: PropTypes.func,
- onMention: PropTypes.func,
- onMute: PropTypes.func,
- onUnmute: PropTypes.func,
- onBlock: PropTypes.func,
- onUnblock: PropTypes.func,
- onBlockDomain: PropTypes.func,
- onUnblockDomain: PropTypes.func,
- onReport: PropTypes.func,
- onEmbed: PropTypes.func,
- onMuteConversation: PropTypes.func,
- onPin: PropTypes.func,
- onBookmark: PropTypes.func,
- onFilter: PropTypes.func,
- onAddFilter: PropTypes.func,
- onInteractionModal: PropTypes.func,
- withDismiss: PropTypes.bool,
- withCounters: PropTypes.bool,
- scrollKey: PropTypes.string,
- intl: PropTypes.object.isRequired,
- };
-
- // Avoid checking props that are functions (and whose equality will always
- // evaluate to false. See react-immutable-pure-component for usage.
- updateOnProps = [
- 'status',
- 'relationship',
- 'withDismiss',
- ];
-
- handleReplyClick = () => {
- const { signedIn } = this.context.identity;
-
- if (signedIn) {
- this.props.onReply(this.props.status, this.context.router.history);
- } else {
- this.props.onInteractionModal('reply', this.props.status);
- }
- };
-
- handleShareClick = () => {
- navigator.share({
- text: this.props.status.get('search_index'),
- url: this.props.status.get('url'),
- }).catch((e) => {
- if (e.name !== 'AbortError') console.error(e);
- });
- };
-
- handleFavouriteClick = () => {
- const { signedIn } = this.context.identity;
-
- if (signedIn) {
- this.props.onFavourite(this.props.status);
- } else {
- this.props.onInteractionModal('favourite', this.props.status);
- }
- };
-
- handleReblogClick = e => {
- const { signedIn } = this.context.identity;
-
- if (signedIn) {
- this.props.onReblog(this.props.status, e);
- } else {
- this.props.onInteractionModal('reblog', this.props.status);
- }
- };
-
- handleBookmarkClick = () => {
- this.props.onBookmark(this.props.status);
- };
-
- handleDeleteClick = () => {
- this.props.onDelete(this.props.status, this.context.router.history);
- };
-
- handleRedraftClick = () => {
- this.props.onDelete(this.props.status, this.context.router.history, true);
- };
-
- handleEditClick = () => {
- this.props.onEdit(this.props.status, this.context.router.history);
- };
-
- handlePinClick = () => {
- this.props.onPin(this.props.status);
- };
-
- handleMentionClick = () => {
- this.props.onMention(this.props.status.get('account'), this.context.router.history);
- };
-
- handleDirectClick = () => {
- this.props.onDirect(this.props.status.get('account'), this.context.router.history);
- };
-
- handleMuteClick = () => {
- const { status, relationship, onMute, onUnmute } = this.props;
- const account = status.get('account');
-
- if (relationship && relationship.get('muting')) {
- onUnmute(account);
- } else {
- onMute(account);
- }
- };
-
- handleBlockClick = () => {
- const { status, relationship, onBlock, onUnblock } = this.props;
- const account = status.get('account');
-
- if (relationship && relationship.get('blocking')) {
- onUnblock(account);
- } else {
- onBlock(status);
- }
- };
-
- handleBlockDomain = () => {
- const { status, onBlockDomain } = this.props;
- const account = status.get('account');
-
- onBlockDomain(account.get('acct').split('@')[1]);
- };
-
- handleUnblockDomain = () => {
- const { status, onUnblockDomain } = this.props;
- const account = status.get('account');
-
- onUnblockDomain(account.get('acct').split('@')[1]);
- };
-
- handleOpen = () => {
- this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}`);
- };
-
- handleEmbed = () => {
- this.props.onEmbed(this.props.status);
- };
-
- handleReport = () => {
- this.props.onReport(this.props.status);
- };
-
- handleConversationMuteClick = () => {
- this.props.onMuteConversation(this.props.status);
- };
-
- handleFilterClick = () => {
- this.props.onAddFilter(this.props.status);
- };
-
- handleCopy = () => {
- const url = this.props.status.get('url');
- navigator.clipboard.writeText(url);
- };
-
- handleHideClick = () => {
- this.props.onFilter();
- };
-
- render () {
- const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props;
- const { signedIn, permissions } = this.context.identity;
-
- const anonymousAccess = !signedIn;
- const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
- const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
- const mutingConversation = status.get('muted');
- const account = status.get('account');
- const writtenByMe = status.getIn(['account', 'id']) === me;
- const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
-
- let menu = [];
-
- menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
-
- if (publicStatus && isRemote) {
- menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
- }
-
- menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
-
- if (publicStatus) {
- menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
- }
-
- menu.push(null);
-
- menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick });
-
- if (writtenByMe && pinnableStatus) {
- menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
- }
-
- menu.push(null);
-
- if (writtenByMe || withDismiss) {
- menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
- menu.push(null);
- }
-
- if (writtenByMe) {
- menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
- menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
- menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
- } else {
- menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.handleMentionClick });
- menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick });
- menu.push(null);
-
- if (relationship && relationship.get('muting')) {
- menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick });
- } else {
- menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.handleMuteClick });
- }
-
- if (relationship && relationship.get('blocking')) {
- menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.handleBlockClick });
- } else {
- menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.handleBlockClick });
- }
-
- if (!this.props.onFilter) {
- menu.push(null);
- menu.push({ text: intl.formatMessage(messages.filter), action: this.handleFilterClick });
- menu.push(null);
- }
-
- menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.handleReport });
-
- if (account.get('acct') !== account.get('username')) {
- const domain = account.get('acct').split('@')[1];
-
- menu.push(null);
-
- if (relationship && relationship.get('domain_blocking')) {
- menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.handleUnblockDomain });
- } else {
- menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.handleBlockDomain });
- }
- }
-
- if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
- menu.push(null);
- if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
- menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
- menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` });
- }
- if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) {
- const domain = account.get('acct').split('@')[1];
- menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` });
- }
- }
- }
-
- let replyIcon;
- let replyTitle;
- if (status.get('in_reply_to_id', null) === null) {
- replyIcon = 'reply';
- replyTitle = intl.formatMessage(messages.reply);
- } else {
- replyIcon = 'reply-all';
- replyTitle = intl.formatMessage(messages.replyAll);
- }
-
- const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
-
- let reblogTitle = '';
- if (status.get('reblogged')) {
- reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
- } else if (publicStatus) {
- reblogTitle = intl.formatMessage(messages.reblog);
- } else if (reblogPrivate) {
- reblogTitle = intl.formatMessage(messages.reblog_private);
- } else {
- reblogTitle = intl.formatMessage(messages.cannot_reblog);
- }
-
- const shareButton = ('share' in navigator) && publicStatus && (
-
- );
-
- const filterButton = this.props.onFilter && (
-
- );
-
- return (
-
-
-
-
-
-
- {shareButton}
-
- {filterButton}
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx
new file mode 100644
index 000000000..eeb376561
--- /dev/null
+++ b/app/javascript/mastodon/components/status_action_bar.jsx
@@ -0,0 +1,387 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import IconButton from './icon_button';
+import DropdownMenuContainer from '../containers/dropdown_menu_container';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { me } from '../initial_state';
+import classNames from 'classnames';
+import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
+
+const messages = defineMessages({
+ delete: { id: 'status.delete', defaultMessage: 'Delete' },
+ redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
+ edit: { id: 'status.edit', defaultMessage: 'Edit' },
+ direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
+ mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
+ mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
+ block: { id: 'account.block', defaultMessage: 'Block @{name}' },
+ reply: { id: 'status.reply', defaultMessage: 'Reply' },
+ share: { id: 'status.share', defaultMessage: 'Share' },
+ more: { id: 'status.more', defaultMessage: 'More' },
+ replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
+ reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+ reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
+ cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
+ cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+ favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+ bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
+ removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
+ open: { id: 'status.open', defaultMessage: 'Expand this status' },
+ report: { id: 'status.report', defaultMessage: 'Report @{name}' },
+ muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
+ unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
+ pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
+ unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
+ embed: { id: 'status.embed', defaultMessage: 'Embed' },
+ admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
+ admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
+ admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
+ copy: { id: 'status.copy', defaultMessage: 'Copy link to post' },
+ hide: { id: 'status.hide', defaultMessage: 'Hide post' },
+ blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
+ unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
+ unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+ unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
+ filter: { id: 'status.filter', defaultMessage: 'Filter this post' },
+ openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
+});
+
+const mapStateToProps = (state, { status }) => ({
+ relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class StatusActionBar extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ identity: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ relationship: ImmutablePropTypes.map,
+ onReply: PropTypes.func,
+ onFavourite: PropTypes.func,
+ onReblog: PropTypes.func,
+ onDelete: PropTypes.func,
+ onDirect: PropTypes.func,
+ onMention: PropTypes.func,
+ onMute: PropTypes.func,
+ onUnmute: PropTypes.func,
+ onBlock: PropTypes.func,
+ onUnblock: PropTypes.func,
+ onBlockDomain: PropTypes.func,
+ onUnblockDomain: PropTypes.func,
+ onReport: PropTypes.func,
+ onEmbed: PropTypes.func,
+ onMuteConversation: PropTypes.func,
+ onPin: PropTypes.func,
+ onBookmark: PropTypes.func,
+ onFilter: PropTypes.func,
+ onAddFilter: PropTypes.func,
+ onInteractionModal: PropTypes.func,
+ withDismiss: PropTypes.bool,
+ withCounters: PropTypes.bool,
+ scrollKey: PropTypes.string,
+ intl: PropTypes.object.isRequired,
+ };
+
+ // Avoid checking props that are functions (and whose equality will always
+ // evaluate to false. See react-immutable-pure-component for usage.
+ updateOnProps = [
+ 'status',
+ 'relationship',
+ 'withDismiss',
+ ];
+
+ handleReplyClick = () => {
+ const { signedIn } = this.context.identity;
+
+ if (signedIn) {
+ this.props.onReply(this.props.status, this.context.router.history);
+ } else {
+ this.props.onInteractionModal('reply', this.props.status);
+ }
+ };
+
+ handleShareClick = () => {
+ navigator.share({
+ text: this.props.status.get('search_index'),
+ url: this.props.status.get('url'),
+ }).catch((e) => {
+ if (e.name !== 'AbortError') console.error(e);
+ });
+ };
+
+ handleFavouriteClick = () => {
+ const { signedIn } = this.context.identity;
+
+ if (signedIn) {
+ this.props.onFavourite(this.props.status);
+ } else {
+ this.props.onInteractionModal('favourite', this.props.status);
+ }
+ };
+
+ handleReblogClick = e => {
+ const { signedIn } = this.context.identity;
+
+ if (signedIn) {
+ this.props.onReblog(this.props.status, e);
+ } else {
+ this.props.onInteractionModal('reblog', this.props.status);
+ }
+ };
+
+ handleBookmarkClick = () => {
+ this.props.onBookmark(this.props.status);
+ };
+
+ handleDeleteClick = () => {
+ this.props.onDelete(this.props.status, this.context.router.history);
+ };
+
+ handleRedraftClick = () => {
+ this.props.onDelete(this.props.status, this.context.router.history, true);
+ };
+
+ handleEditClick = () => {
+ this.props.onEdit(this.props.status, this.context.router.history);
+ };
+
+ handlePinClick = () => {
+ this.props.onPin(this.props.status);
+ };
+
+ handleMentionClick = () => {
+ this.props.onMention(this.props.status.get('account'), this.context.router.history);
+ };
+
+ handleDirectClick = () => {
+ this.props.onDirect(this.props.status.get('account'), this.context.router.history);
+ };
+
+ handleMuteClick = () => {
+ const { status, relationship, onMute, onUnmute } = this.props;
+ const account = status.get('account');
+
+ if (relationship && relationship.get('muting')) {
+ onUnmute(account);
+ } else {
+ onMute(account);
+ }
+ };
+
+ handleBlockClick = () => {
+ const { status, relationship, onBlock, onUnblock } = this.props;
+ const account = status.get('account');
+
+ if (relationship && relationship.get('blocking')) {
+ onUnblock(account);
+ } else {
+ onBlock(status);
+ }
+ };
+
+ handleBlockDomain = () => {
+ const { status, onBlockDomain } = this.props;
+ const account = status.get('account');
+
+ onBlockDomain(account.get('acct').split('@')[1]);
+ };
+
+ handleUnblockDomain = () => {
+ const { status, onUnblockDomain } = this.props;
+ const account = status.get('account');
+
+ onUnblockDomain(account.get('acct').split('@')[1]);
+ };
+
+ handleOpen = () => {
+ this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}`);
+ };
+
+ handleEmbed = () => {
+ this.props.onEmbed(this.props.status);
+ };
+
+ handleReport = () => {
+ this.props.onReport(this.props.status);
+ };
+
+ handleConversationMuteClick = () => {
+ this.props.onMuteConversation(this.props.status);
+ };
+
+ handleFilterClick = () => {
+ this.props.onAddFilter(this.props.status);
+ };
+
+ handleCopy = () => {
+ const url = this.props.status.get('url');
+ navigator.clipboard.writeText(url);
+ };
+
+ handleHideClick = () => {
+ this.props.onFilter();
+ };
+
+ render () {
+ const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props;
+ const { signedIn, permissions } = this.context.identity;
+
+ const anonymousAccess = !signedIn;
+ const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
+ const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
+ const mutingConversation = status.get('muted');
+ const account = status.get('account');
+ const writtenByMe = status.getIn(['account', 'id']) === me;
+ const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
+
+ let menu = [];
+
+ menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
+
+ if (publicStatus && isRemote) {
+ menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
+ }
+
+ menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
+
+ if (publicStatus) {
+ menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+ }
+
+ menu.push(null);
+
+ menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick });
+
+ if (writtenByMe && pinnableStatus) {
+ menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
+ }
+
+ menu.push(null);
+
+ if (writtenByMe || withDismiss) {
+ menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
+ menu.push(null);
+ }
+
+ if (writtenByMe) {
+ menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
+ menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
+ menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.handleMentionClick });
+ menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick });
+ menu.push(null);
+
+ if (relationship && relationship.get('muting')) {
+ menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.handleMuteClick });
+ }
+
+ if (relationship && relationship.get('blocking')) {
+ menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.handleBlockClick });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.handleBlockClick });
+ }
+
+ if (!this.props.onFilter) {
+ menu.push(null);
+ menu.push({ text: intl.formatMessage(messages.filter), action: this.handleFilterClick });
+ menu.push(null);
+ }
+
+ menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.handleReport });
+
+ if (account.get('acct') !== account.get('username')) {
+ const domain = account.get('acct').split('@')[1];
+
+ menu.push(null);
+
+ if (relationship && relationship.get('domain_blocking')) {
+ menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.handleUnblockDomain });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.handleBlockDomain });
+ }
+ }
+
+ if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
+ menu.push(null);
+ if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
+ menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
+ menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` });
+ }
+ if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) {
+ const domain = account.get('acct').split('@')[1];
+ menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` });
+ }
+ }
+ }
+
+ let replyIcon;
+ let replyTitle;
+ if (status.get('in_reply_to_id', null) === null) {
+ replyIcon = 'reply';
+ replyTitle = intl.formatMessage(messages.reply);
+ } else {
+ replyIcon = 'reply-all';
+ replyTitle = intl.formatMessage(messages.replyAll);
+ }
+
+ const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
+
+ let reblogTitle = '';
+ if (status.get('reblogged')) {
+ reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
+ } else if (publicStatus) {
+ reblogTitle = intl.formatMessage(messages.reblog);
+ } else if (reblogPrivate) {
+ reblogTitle = intl.formatMessage(messages.reblog_private);
+ } else {
+ reblogTitle = intl.formatMessage(messages.cannot_reblog);
+ }
+
+ const shareButton = ('share' in navigator) && publicStatus && (
+
+ );
+
+ const filterButton = this.props.onFilter && (
+
+ );
+
+ return (
+
+
+
+
+
+
+ {shareButton}
+
+ {filterButton}
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js
deleted file mode 100644
index ece54621f..000000000
--- a/app/javascript/mastodon/components/status_content.js
+++ /dev/null
@@ -1,304 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import { FormattedMessage, injectIntl } from 'react-intl';
-import { Link } from 'react-router-dom';
-import classnames from 'classnames';
-import PollContainer from 'mastodon/containers/poll_container';
-import Icon from 'mastodon/components/icon';
-import { autoPlayGif, languages as preloadedLanguages, translationEnabled } from 'mastodon/initial_state';
-
-const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
-
-class TranslateButton extends React.PureComponent {
-
- static propTypes = {
- translation: ImmutablePropTypes.map,
- onClick: PropTypes.func,
- };
-
- render () {
- const { translation, onClick } = this.props;
-
- if (translation) {
- const language = preloadedLanguages.find(lang => lang[0] === translation.get('detected_source_language'));
- const languageName = language ? language[2] : translation.get('detected_source_language');
- const provider = translation.get('provider');
-
- return (
-
- );
- }
-
- return (
-
-
-
- );
- }
-
-}
-
-export default @injectIntl
-class StatusContent extends React.PureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- identity: PropTypes.object,
- };
-
- static propTypes = {
- status: ImmutablePropTypes.map.isRequired,
- expanded: PropTypes.bool,
- onExpandedToggle: PropTypes.func,
- onTranslate: PropTypes.func,
- onClick: PropTypes.func,
- collapsable: PropTypes.bool,
- onCollapsedToggle: PropTypes.func,
- intl: PropTypes.object,
- };
-
- state = {
- hidden: true,
- };
-
- _updateStatusLinks () {
- const node = this.node;
-
- if (!node) {
- return;
- }
-
- const { status, onCollapsedToggle } = this.props;
- const links = node.querySelectorAll('a');
-
- let link, mention;
-
- for (var i = 0; i < links.length; ++i) {
- link = links[i];
-
- if (link.classList.contains('status-link')) {
- continue;
- }
-
- link.classList.add('status-link');
-
- mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
-
- if (mention) {
- link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
- link.setAttribute('title', mention.get('acct'));
- link.setAttribute('href', `/@${mention.get('acct')}`);
- } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
- link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
- link.setAttribute('href', `/tags/${link.text.slice(1)}`);
- } else {
- link.setAttribute('title', link.href);
- link.classList.add('unhandled-link');
- }
- }
-
- if (status.get('collapsed', null) === null && onCollapsedToggle) {
- const { collapsable, onClick } = this.props;
-
- const collapsed =
- collapsable
- && onClick
- && node.clientHeight > MAX_HEIGHT
- && status.get('spoiler_text').length === 0;
-
- onCollapsedToggle(collapsed);
- }
- }
-
- handleMouseEnter = ({ currentTarget }) => {
- if (autoPlayGif) {
- return;
- }
-
- const emojis = currentTarget.querySelectorAll('.custom-emoji');
-
- for (var i = 0; i < emojis.length; i++) {
- let emoji = emojis[i];
- emoji.src = emoji.getAttribute('data-original');
- }
- };
-
- handleMouseLeave = ({ currentTarget }) => {
- if (autoPlayGif) {
- return;
- }
-
- const emojis = currentTarget.querySelectorAll('.custom-emoji');
-
- for (var i = 0; i < emojis.length; i++) {
- let emoji = emojis[i];
- emoji.src = emoji.getAttribute('data-static');
- }
- };
-
- componentDidMount () {
- this._updateStatusLinks();
- }
-
- componentDidUpdate () {
- this._updateStatusLinks();
- }
-
- onMentionClick = (mention, e) => {
- if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
- e.preventDefault();
- this.context.router.history.push(`/@${mention.get('acct')}`);
- }
- };
-
- onHashtagClick = (hashtag, e) => {
- hashtag = hashtag.replace(/^#/, '');
-
- if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
- e.preventDefault();
- this.context.router.history.push(`/tags/${hashtag}`);
- }
- };
-
- handleMouseDown = (e) => {
- this.startXY = [e.clientX, e.clientY];
- };
-
- handleMouseUp = (e) => {
- if (!this.startXY) {
- return;
- }
-
- const [ startX, startY ] = this.startXY;
- const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
-
- let element = e.target;
- while (element) {
- if (element.localName === 'button' || element.localName === 'a' || element.localName === 'label') {
- return;
- }
- element = element.parentNode;
- }
-
- if (deltaX + deltaY < 5 && e.button === 0 && this.props.onClick) {
- this.props.onClick();
- }
-
- this.startXY = null;
- };
-
- handleSpoilerClick = (e) => {
- e.preventDefault();
-
- if (this.props.onExpandedToggle) {
- // The parent manages the state
- this.props.onExpandedToggle();
- } else {
- this.setState({ hidden: !this.state.hidden });
- }
- };
-
- handleTranslate = () => {
- this.props.onTranslate();
- };
-
- setRef = (c) => {
- this.node = c;
- };
-
- render () {
- const { status, intl } = this.props;
-
- const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
- const renderReadMore = this.props.onClick && status.get('collapsed');
- const renderTranslate = translationEnabled && this.context.identity.signedIn && this.props.onTranslate && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('contentHtml').length > 0 && status.get('language') !== null && intl.locale !== status.get('language');
-
- const content = { __html: status.get('translation') ? status.getIn(['translation', 'content']) : status.get('contentHtml') };
- const spoilerContent = { __html: status.get('spoilerHtml') };
- const lang = status.get('translation') ? intl.locale : status.get('language');
- const classNames = classnames('status__content', {
- 'status__content--with-action': this.props.onClick && this.context.router,
- 'status__content--with-spoiler': status.get('spoiler_text').length > 0,
- 'status__content--collapsed': renderReadMore,
- });
-
- const readMoreButton = renderReadMore && (
-
-
-
- );
-
- const translateButton = renderTranslate && (
-
- );
-
- const poll = !!status.get('poll') && (
-
- );
-
- if (status.get('spoiler_text').length > 0) {
- let mentionsPlaceholder = '';
-
- const mentionLinks = status.get('mentions').map(item => (
-
- @{item.get('username')}
-
- )).reduce((aggregate, item) => [...aggregate, item, ' '], []);
-
- const toggleText = hidden ? : ;
-
- if (hidden) {
- mentionsPlaceholder = {mentionLinks}
;
- }
-
- return (
-
-
-
- {' '}
- {toggleText}
-
-
- {mentionsPlaceholder}
-
-
-
- {!hidden && poll}
- {!hidden && translateButton}
-
- );
- } else if (this.props.onClick) {
- return (
- <>
-
-
-
- {poll}
- {translateButton}
-
-
- {readMoreButton}
- >
- );
- } else {
- return (
-
-
-
- {poll}
- {translateButton}
-
- );
- }
- }
-
-}
diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx
new file mode 100644
index 000000000..ece54621f
--- /dev/null
+++ b/app/javascript/mastodon/components/status_content.jsx
@@ -0,0 +1,304 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { FormattedMessage, injectIntl } from 'react-intl';
+import { Link } from 'react-router-dom';
+import classnames from 'classnames';
+import PollContainer from 'mastodon/containers/poll_container';
+import Icon from 'mastodon/components/icon';
+import { autoPlayGif, languages as preloadedLanguages, translationEnabled } from 'mastodon/initial_state';
+
+const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
+
+class TranslateButton extends React.PureComponent {
+
+ static propTypes = {
+ translation: ImmutablePropTypes.map,
+ onClick: PropTypes.func,
+ };
+
+ render () {
+ const { translation, onClick } = this.props;
+
+ if (translation) {
+ const language = preloadedLanguages.find(lang => lang[0] === translation.get('detected_source_language'));
+ const languageName = language ? language[2] : translation.get('detected_source_language');
+ const provider = translation.get('provider');
+
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ );
+ }
+
+}
+
+export default @injectIntl
+class StatusContent extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ identity: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ expanded: PropTypes.bool,
+ onExpandedToggle: PropTypes.func,
+ onTranslate: PropTypes.func,
+ onClick: PropTypes.func,
+ collapsable: PropTypes.bool,
+ onCollapsedToggle: PropTypes.func,
+ intl: PropTypes.object,
+ };
+
+ state = {
+ hidden: true,
+ };
+
+ _updateStatusLinks () {
+ const node = this.node;
+
+ if (!node) {
+ return;
+ }
+
+ const { status, onCollapsedToggle } = this.props;
+ const links = node.querySelectorAll('a');
+
+ let link, mention;
+
+ for (var i = 0; i < links.length; ++i) {
+ link = links[i];
+
+ if (link.classList.contains('status-link')) {
+ continue;
+ }
+
+ link.classList.add('status-link');
+
+ mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
+
+ if (mention) {
+ link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
+ link.setAttribute('title', mention.get('acct'));
+ link.setAttribute('href', `/@${mention.get('acct')}`);
+ } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
+ link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
+ link.setAttribute('href', `/tags/${link.text.slice(1)}`);
+ } else {
+ link.setAttribute('title', link.href);
+ link.classList.add('unhandled-link');
+ }
+ }
+
+ if (status.get('collapsed', null) === null && onCollapsedToggle) {
+ const { collapsable, onClick } = this.props;
+
+ const collapsed =
+ collapsable
+ && onClick
+ && node.clientHeight > MAX_HEIGHT
+ && status.get('spoiler_text').length === 0;
+
+ onCollapsedToggle(collapsed);
+ }
+ }
+
+ handleMouseEnter = ({ currentTarget }) => {
+ if (autoPlayGif) {
+ return;
+ }
+
+ const emojis = currentTarget.querySelectorAll('.custom-emoji');
+
+ for (var i = 0; i < emojis.length; i++) {
+ let emoji = emojis[i];
+ emoji.src = emoji.getAttribute('data-original');
+ }
+ };
+
+ handleMouseLeave = ({ currentTarget }) => {
+ if (autoPlayGif) {
+ return;
+ }
+
+ const emojis = currentTarget.querySelectorAll('.custom-emoji');
+
+ for (var i = 0; i < emojis.length; i++) {
+ let emoji = emojis[i];
+ emoji.src = emoji.getAttribute('data-static');
+ }
+ };
+
+ componentDidMount () {
+ this._updateStatusLinks();
+ }
+
+ componentDidUpdate () {
+ this._updateStatusLinks();
+ }
+
+ onMentionClick = (mention, e) => {
+ if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ this.context.router.history.push(`/@${mention.get('acct')}`);
+ }
+ };
+
+ onHashtagClick = (hashtag, e) => {
+ hashtag = hashtag.replace(/^#/, '');
+
+ if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ this.context.router.history.push(`/tags/${hashtag}`);
+ }
+ };
+
+ handleMouseDown = (e) => {
+ this.startXY = [e.clientX, e.clientY];
+ };
+
+ handleMouseUp = (e) => {
+ if (!this.startXY) {
+ return;
+ }
+
+ const [ startX, startY ] = this.startXY;
+ const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
+
+ let element = e.target;
+ while (element) {
+ if (element.localName === 'button' || element.localName === 'a' || element.localName === 'label') {
+ return;
+ }
+ element = element.parentNode;
+ }
+
+ if (deltaX + deltaY < 5 && e.button === 0 && this.props.onClick) {
+ this.props.onClick();
+ }
+
+ this.startXY = null;
+ };
+
+ handleSpoilerClick = (e) => {
+ e.preventDefault();
+
+ if (this.props.onExpandedToggle) {
+ // The parent manages the state
+ this.props.onExpandedToggle();
+ } else {
+ this.setState({ hidden: !this.state.hidden });
+ }
+ };
+
+ handleTranslate = () => {
+ this.props.onTranslate();
+ };
+
+ setRef = (c) => {
+ this.node = c;
+ };
+
+ render () {
+ const { status, intl } = this.props;
+
+ const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
+ const renderReadMore = this.props.onClick && status.get('collapsed');
+ const renderTranslate = translationEnabled && this.context.identity.signedIn && this.props.onTranslate && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('contentHtml').length > 0 && status.get('language') !== null && intl.locale !== status.get('language');
+
+ const content = { __html: status.get('translation') ? status.getIn(['translation', 'content']) : status.get('contentHtml') };
+ const spoilerContent = { __html: status.get('spoilerHtml') };
+ const lang = status.get('translation') ? intl.locale : status.get('language');
+ const classNames = classnames('status__content', {
+ 'status__content--with-action': this.props.onClick && this.context.router,
+ 'status__content--with-spoiler': status.get('spoiler_text').length > 0,
+ 'status__content--collapsed': renderReadMore,
+ });
+
+ const readMoreButton = renderReadMore && (
+
+
+
+ );
+
+ const translateButton = renderTranslate && (
+
+ );
+
+ const poll = !!status.get('poll') && (
+
+ );
+
+ if (status.get('spoiler_text').length > 0) {
+ let mentionsPlaceholder = '';
+
+ const mentionLinks = status.get('mentions').map(item => (
+
+ @{item.get('username')}
+
+ )).reduce((aggregate, item) => [...aggregate, item, ' '], []);
+
+ const toggleText = hidden ? : ;
+
+ if (hidden) {
+ mentionsPlaceholder = {mentionLinks}
;
+ }
+
+ return (
+
+
+
+ {' '}
+ {toggleText}
+
+
+ {mentionsPlaceholder}
+
+
+
+ {!hidden && poll}
+ {!hidden && translateButton}
+
+ );
+ } else if (this.props.onClick) {
+ return (
+ <>
+
+
+
+ {poll}
+ {translateButton}
+
+
+ {readMoreButton}
+ >
+ );
+ } else {
+ return (
+
+
+
+ {poll}
+ {translateButton}
+
+ );
+ }
+ }
+
+}
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
deleted file mode 100644
index 3d513bbf8..000000000
--- a/app/javascript/mastodon/components/status_list.js
+++ /dev/null
@@ -1,131 +0,0 @@
-import { debounce } from 'lodash';
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import StatusContainer from '../containers/status_container';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import LoadGap from './load_gap';
-import ScrollableList from './scrollable_list';
-import RegenerationIndicator from 'mastodon/components/regeneration_indicator';
-
-export default class StatusList extends ImmutablePureComponent {
-
- static propTypes = {
- scrollKey: PropTypes.string.isRequired,
- statusIds: ImmutablePropTypes.list.isRequired,
- featuredStatusIds: ImmutablePropTypes.list,
- onLoadMore: PropTypes.func,
- onScrollToTop: PropTypes.func,
- onScroll: PropTypes.func,
- trackScroll: PropTypes.bool,
- isLoading: PropTypes.bool,
- isPartial: PropTypes.bool,
- hasMore: PropTypes.bool,
- prepend: PropTypes.node,
- emptyMessage: PropTypes.node,
- alwaysPrepend: PropTypes.bool,
- withCounters: PropTypes.bool,
- timelineId: PropTypes.string,
- };
-
- static defaultProps = {
- trackScroll: true,
- };
-
- getFeaturedStatusCount = () => {
- return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0;
- };
-
- getCurrentStatusIndex = (id, featured) => {
- if (featured) {
- return this.props.featuredStatusIds.indexOf(id);
- } else {
- return this.props.statusIds.indexOf(id) + this.getFeaturedStatusCount();
- }
- };
-
- handleMoveUp = (id, featured) => {
- const elementIndex = this.getCurrentStatusIndex(id, featured) - 1;
- this._selectChild(elementIndex, true);
- };
-
- handleMoveDown = (id, featured) => {
- const elementIndex = this.getCurrentStatusIndex(id, featured) + 1;
- this._selectChild(elementIndex, false);
- };
-
- handleLoadOlder = debounce(() => {
- this.props.onLoadMore(this.props.statusIds.size > 0 ? this.props.statusIds.last() : undefined);
- }, 300, { leading: true });
-
- _selectChild (index, align_top) {
- const container = this.node.node;
- const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
-
- if (element) {
- if (align_top && container.scrollTop > element.offsetTop) {
- element.scrollIntoView(true);
- } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
- element.scrollIntoView(false);
- }
- element.focus();
- }
- }
-
- setRef = c => {
- this.node = c;
- };
-
- render () {
- const { statusIds, featuredStatusIds, onLoadMore, timelineId, ...other } = this.props;
- const { isLoading, isPartial } = other;
-
- if (isPartial) {
- return ;
- }
-
- let scrollableContent = (isLoading || statusIds.size > 0) ? (
- statusIds.map((statusId, index) => statusId === null ? (
- 0 ? statusIds.get(index - 1) : null}
- onClick={onLoadMore}
- />
- ) : (
-
- ))
- ) : null;
-
- if (scrollableContent && featuredStatusIds) {
- scrollableContent = featuredStatusIds.map(statusId => (
-
- )).concat(scrollableContent);
- }
-
- return (
-
- {scrollableContent}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/components/status_list.jsx b/app/javascript/mastodon/components/status_list.jsx
new file mode 100644
index 000000000..3d513bbf8
--- /dev/null
+++ b/app/javascript/mastodon/components/status_list.jsx
@@ -0,0 +1,131 @@
+import { debounce } from 'lodash';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import StatusContainer from '../containers/status_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import LoadGap from './load_gap';
+import ScrollableList from './scrollable_list';
+import RegenerationIndicator from 'mastodon/components/regeneration_indicator';
+
+export default class StatusList extends ImmutablePureComponent {
+
+ static propTypes = {
+ scrollKey: PropTypes.string.isRequired,
+ statusIds: ImmutablePropTypes.list.isRequired,
+ featuredStatusIds: ImmutablePropTypes.list,
+ onLoadMore: PropTypes.func,
+ onScrollToTop: PropTypes.func,
+ onScroll: PropTypes.func,
+ trackScroll: PropTypes.bool,
+ isLoading: PropTypes.bool,
+ isPartial: PropTypes.bool,
+ hasMore: PropTypes.bool,
+ prepend: PropTypes.node,
+ emptyMessage: PropTypes.node,
+ alwaysPrepend: PropTypes.bool,
+ withCounters: PropTypes.bool,
+ timelineId: PropTypes.string,
+ };
+
+ static defaultProps = {
+ trackScroll: true,
+ };
+
+ getFeaturedStatusCount = () => {
+ return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0;
+ };
+
+ getCurrentStatusIndex = (id, featured) => {
+ if (featured) {
+ return this.props.featuredStatusIds.indexOf(id);
+ } else {
+ return this.props.statusIds.indexOf(id) + this.getFeaturedStatusCount();
+ }
+ };
+
+ handleMoveUp = (id, featured) => {
+ const elementIndex = this.getCurrentStatusIndex(id, featured) - 1;
+ this._selectChild(elementIndex, true);
+ };
+
+ handleMoveDown = (id, featured) => {
+ const elementIndex = this.getCurrentStatusIndex(id, featured) + 1;
+ this._selectChild(elementIndex, false);
+ };
+
+ handleLoadOlder = debounce(() => {
+ this.props.onLoadMore(this.props.statusIds.size > 0 ? this.props.statusIds.last() : undefined);
+ }, 300, { leading: true });
+
+ _selectChild (index, align_top) {
+ const container = this.node.node;
+ const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+
+ if (element) {
+ if (align_top && container.scrollTop > element.offsetTop) {
+ element.scrollIntoView(true);
+ } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
+ element.scrollIntoView(false);
+ }
+ element.focus();
+ }
+ }
+
+ setRef = c => {
+ this.node = c;
+ };
+
+ render () {
+ const { statusIds, featuredStatusIds, onLoadMore, timelineId, ...other } = this.props;
+ const { isLoading, isPartial } = other;
+
+ if (isPartial) {
+ return ;
+ }
+
+ let scrollableContent = (isLoading || statusIds.size > 0) ? (
+ statusIds.map((statusId, index) => statusId === null ? (
+ 0 ? statusIds.get(index - 1) : null}
+ onClick={onLoadMore}
+ />
+ ) : (
+
+ ))
+ ) : null;
+
+ if (scrollableContent && featuredStatusIds) {
+ scrollableContent = featuredStatusIds.map(statusId => (
+
+ )).concat(scrollableContent);
+ }
+
+ return (
+
+ {scrollableContent}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/timeline_hint.js b/app/javascript/mastodon/components/timeline_hint.js
deleted file mode 100644
index ac9a79dcc..000000000
--- a/app/javascript/mastodon/components/timeline_hint.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { FormattedMessage } from 'react-intl';
-
-const TimelineHint = ({ resource, url }) => (
-
-);
-
-TimelineHint.propTypes = {
- resource: PropTypes.node.isRequired,
- url: PropTypes.string.isRequired,
-};
-
-export default TimelineHint;
diff --git a/app/javascript/mastodon/components/timeline_hint.jsx b/app/javascript/mastodon/components/timeline_hint.jsx
new file mode 100644
index 000000000..ac9a79dcc
--- /dev/null
+++ b/app/javascript/mastodon/components/timeline_hint.jsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+
+const TimelineHint = ({ resource, url }) => (
+
+);
+
+TimelineHint.propTypes = {
+ resource: PropTypes.node.isRequired,
+ url: PropTypes.string.isRequired,
+};
+
+export default TimelineHint;
diff --git a/app/javascript/mastodon/containers/account_container.js b/app/javascript/mastodon/containers/account_container.js
deleted file mode 100644
index 5a5136dd1..000000000
--- a/app/javascript/mastodon/containers/account_container.js
+++ /dev/null
@@ -1,72 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import { makeGetAccount } from '../selectors';
-import Account from '../components/account';
-import {
- followAccount,
- unfollowAccount,
- blockAccount,
- unblockAccount,
- muteAccount,
- unmuteAccount,
-} from '../actions/accounts';
-import { openModal } from '../actions/modal';
-import { initMuteModal } from '../actions/mutes';
-import { unfollowModal } from '../initial_state';
-
-const messages = defineMessages({
- unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
-});
-
-const makeMapStateToProps = () => {
- const getAccount = makeGetAccount();
-
- const mapStateToProps = (state, props) => ({
- account: getAccount(state, props.id),
- });
-
- 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(blockAccount(account.get('id')));
- }
- },
-
- onMute (account) {
- if (account.getIn(['relationship', 'muting'])) {
- dispatch(unmuteAccount(account.get('id')));
- } else {
- dispatch(initMuteModal(account));
- }
- },
-
-
- onMuteNotifications (account, notifications) {
- dispatch(muteAccount(account.get('id'), notifications));
- },
-});
-
-export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account));
diff --git a/app/javascript/mastodon/containers/account_container.jsx b/app/javascript/mastodon/containers/account_container.jsx
new file mode 100644
index 000000000..5a5136dd1
--- /dev/null
+++ b/app/javascript/mastodon/containers/account_container.jsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { makeGetAccount } from '../selectors';
+import Account from '../components/account';
+import {
+ followAccount,
+ unfollowAccount,
+ blockAccount,
+ unblockAccount,
+ muteAccount,
+ unmuteAccount,
+} from '../actions/accounts';
+import { openModal } from '../actions/modal';
+import { initMuteModal } from '../actions/mutes';
+import { unfollowModal } from '../initial_state';
+
+const messages = defineMessages({
+ unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
+});
+
+const makeMapStateToProps = () => {
+ const getAccount = makeGetAccount();
+
+ const mapStateToProps = (state, props) => ({
+ account: getAccount(state, props.id),
+ });
+
+ 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(blockAccount(account.get('id')));
+ }
+ },
+
+ onMute (account) {
+ if (account.getIn(['relationship', 'muting'])) {
+ dispatch(unmuteAccount(account.get('id')));
+ } else {
+ dispatch(initMuteModal(account));
+ }
+ },
+
+
+ onMuteNotifications (account, notifications) {
+ dispatch(muteAccount(account.get('id'), notifications));
+ },
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account));
diff --git a/app/javascript/mastodon/containers/admin_component.js b/app/javascript/mastodon/containers/admin_component.js
deleted file mode 100644
index 816b44bd1..000000000
--- a/app/javascript/mastodon/containers/admin_component.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { IntlProvider, addLocaleData } from 'react-intl';
-import { getLocale } from '../locales';
-
-const { localeData, messages } = getLocale();
-addLocaleData(localeData);
-
-export default class AdminComponent extends React.PureComponent {
-
- static propTypes = {
- locale: PropTypes.string.isRequired,
- children: PropTypes.node.isRequired,
- };
-
- render () {
- const { locale, children } = this.props;
-
- return (
-
- {children}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/containers/admin_component.jsx b/app/javascript/mastodon/containers/admin_component.jsx
new file mode 100644
index 000000000..816b44bd1
--- /dev/null
+++ b/app/javascript/mastodon/containers/admin_component.jsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from '../locales';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+export default class AdminComponent extends React.PureComponent {
+
+ static propTypes = {
+ locale: PropTypes.string.isRequired,
+ children: PropTypes.node.isRequired,
+ };
+
+ render () {
+ const { locale, children } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/containers/compose_container.js b/app/javascript/mastodon/containers/compose_container.js
deleted file mode 100644
index 7bc7bbaa4..000000000
--- a/app/javascript/mastodon/containers/compose_container.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import React from 'react';
-import { Provider } from 'react-redux';
-import PropTypes from 'prop-types';
-import configureStore from '../store/configureStore';
-import { hydrateStore } from '../actions/store';
-import { IntlProvider, addLocaleData } from 'react-intl';
-import { getLocale } from '../locales';
-import Compose from '../features/standalone/compose';
-import initialState from '../initial_state';
-import { fetchCustomEmojis } from '../actions/custom_emojis';
-
-const { localeData, messages } = getLocale();
-addLocaleData(localeData);
-
-const store = configureStore();
-
-if (initialState) {
- store.dispatch(hydrateStore(initialState));
-}
-
-store.dispatch(fetchCustomEmojis());
-
-export default class TimelineContainer extends React.PureComponent {
-
- static propTypes = {
- locale: PropTypes.string.isRequired,
- };
-
- render () {
- const { locale } = this.props;
-
- return (
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/containers/compose_container.jsx b/app/javascript/mastodon/containers/compose_container.jsx
new file mode 100644
index 000000000..7bc7bbaa4
--- /dev/null
+++ b/app/javascript/mastodon/containers/compose_container.jsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+import PropTypes from 'prop-types';
+import configureStore from '../store/configureStore';
+import { hydrateStore } from '../actions/store';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from '../locales';
+import Compose from '../features/standalone/compose';
+import initialState from '../initial_state';
+import { fetchCustomEmojis } from '../actions/custom_emojis';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+const store = configureStore();
+
+if (initialState) {
+ store.dispatch(hydrateStore(initialState));
+}
+
+store.dispatch(fetchCustomEmojis());
+
+export default class TimelineContainer extends React.PureComponent {
+
+ static propTypes = {
+ locale: PropTypes.string.isRequired,
+ };
+
+ render () {
+ const { locale } = this.props;
+
+ return (
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/containers/domain_container.js b/app/javascript/mastodon/containers/domain_container.js
deleted file mode 100644
index 8a8ba1df1..000000000
--- a/app/javascript/mastodon/containers/domain_container.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import { blockDomain, unblockDomain } from '../actions/domain_blocks';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import Domain from '../components/domain';
-import { openModal } from '../actions/modal';
-
-const messages = defineMessages({
- blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Block entire domain' },
-});
-
-const makeMapStateToProps = () => {
- const mapStateToProps = () => ({});
-
- return mapStateToProps;
-};
-
-const mapDispatchToProps = (dispatch, { intl }) => ({
- onBlockDomain (domain) {
- dispatch(openModal('CONFIRM', {
- message: {domain} }} />,
- confirm: intl.formatMessage(messages.blockDomainConfirm),
- onConfirm: () => dispatch(blockDomain(domain)),
- }));
- },
-
- onUnblockDomain (domain) {
- dispatch(unblockDomain(domain));
- },
-});
-
-export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Domain));
diff --git a/app/javascript/mastodon/containers/domain_container.jsx b/app/javascript/mastodon/containers/domain_container.jsx
new file mode 100644
index 000000000..8a8ba1df1
--- /dev/null
+++ b/app/javascript/mastodon/containers/domain_container.jsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { blockDomain, unblockDomain } from '../actions/domain_blocks';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Domain from '../components/domain';
+import { openModal } from '../actions/modal';
+
+const messages = defineMessages({
+ blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Block entire domain' },
+});
+
+const makeMapStateToProps = () => {
+ const mapStateToProps = () => ({});
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+ onBlockDomain (domain) {
+ dispatch(openModal('CONFIRM', {
+ message: {domain} }} />,
+ confirm: intl.formatMessage(messages.blockDomainConfirm),
+ onConfirm: () => dispatch(blockDomain(domain)),
+ }));
+ },
+
+ onUnblockDomain (domain) {
+ dispatch(unblockDomain(domain));
+ },
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Domain));
diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js
deleted file mode 100644
index 002b71e93..000000000
--- a/app/javascript/mastodon/containers/mastodon.js
+++ /dev/null
@@ -1,98 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Helmet } from 'react-helmet';
-import { IntlProvider, addLocaleData } from 'react-intl';
-import { Provider as ReduxProvider } from 'react-redux';
-import { BrowserRouter, Route } from 'react-router-dom';
-import { ScrollContext } from 'react-router-scroll-4';
-import configureStore from 'mastodon/store/configureStore';
-import UI from 'mastodon/features/ui';
-import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis';
-import { hydrateStore } from 'mastodon/actions/store';
-import { connectUserStream } from 'mastodon/actions/streaming';
-import ErrorBoundary from 'mastodon/components/error_boundary';
-import initialState, { title as siteTitle } from 'mastodon/initial_state';
-import { getLocale } from 'mastodon/locales';
-
-const { localeData, messages } = getLocale();
-addLocaleData(localeData);
-
-const title = process.env.NODE_ENV === 'production' ? siteTitle : `${siteTitle} (Dev)`;
-
-export const store = configureStore();
-const hydrateAction = hydrateStore(initialState);
-
-store.dispatch(hydrateAction);
-if (initialState.meta.me) {
- store.dispatch(fetchCustomEmojis());
-}
-
-const createIdentityContext = state => ({
- signedIn: !!state.meta.me,
- accountId: state.meta.me,
- disabledAccountId: state.meta.disabled_account_id,
- accessToken: state.meta.access_token,
- permissions: state.role ? state.role.permissions : 0,
-});
-
-export default class Mastodon extends React.PureComponent {
-
- static propTypes = {
- locale: PropTypes.string.isRequired,
- };
-
- static childContextTypes = {
- identity: PropTypes.shape({
- signedIn: PropTypes.bool.isRequired,
- accountId: PropTypes.string,
- disabledAccountId: PropTypes.string,
- accessToken: PropTypes.string,
- }).isRequired,
- };
-
- identity = createIdentityContext(initialState);
-
- getChildContext() {
- return {
- identity: this.identity,
- };
- }
-
- componentDidMount() {
- if (this.identity.signedIn) {
- this.disconnect = store.dispatch(connectUserStream());
- }
- }
-
- componentWillUnmount () {
- if (this.disconnect) {
- this.disconnect();
- this.disconnect = null;
- }
- }
-
- shouldUpdateScroll (prevRouterProps, { location }) {
- return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey);
- }
-
- render () {
- const { locale } = this.props;
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/containers/mastodon.jsx b/app/javascript/mastodon/containers/mastodon.jsx
new file mode 100644
index 000000000..002b71e93
--- /dev/null
+++ b/app/javascript/mastodon/containers/mastodon.jsx
@@ -0,0 +1,98 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Helmet } from 'react-helmet';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { Provider as ReduxProvider } from 'react-redux';
+import { BrowserRouter, Route } from 'react-router-dom';
+import { ScrollContext } from 'react-router-scroll-4';
+import configureStore from 'mastodon/store/configureStore';
+import UI from 'mastodon/features/ui';
+import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis';
+import { hydrateStore } from 'mastodon/actions/store';
+import { connectUserStream } from 'mastodon/actions/streaming';
+import ErrorBoundary from 'mastodon/components/error_boundary';
+import initialState, { title as siteTitle } from 'mastodon/initial_state';
+import { getLocale } from 'mastodon/locales';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+const title = process.env.NODE_ENV === 'production' ? siteTitle : `${siteTitle} (Dev)`;
+
+export const store = configureStore();
+const hydrateAction = hydrateStore(initialState);
+
+store.dispatch(hydrateAction);
+if (initialState.meta.me) {
+ store.dispatch(fetchCustomEmojis());
+}
+
+const createIdentityContext = state => ({
+ signedIn: !!state.meta.me,
+ accountId: state.meta.me,
+ disabledAccountId: state.meta.disabled_account_id,
+ accessToken: state.meta.access_token,
+ permissions: state.role ? state.role.permissions : 0,
+});
+
+export default class Mastodon extends React.PureComponent {
+
+ static propTypes = {
+ locale: PropTypes.string.isRequired,
+ };
+
+ static childContextTypes = {
+ identity: PropTypes.shape({
+ signedIn: PropTypes.bool.isRequired,
+ accountId: PropTypes.string,
+ disabledAccountId: PropTypes.string,
+ accessToken: PropTypes.string,
+ }).isRequired,
+ };
+
+ identity = createIdentityContext(initialState);
+
+ getChildContext() {
+ return {
+ identity: this.identity,
+ };
+ }
+
+ componentDidMount() {
+ if (this.identity.signedIn) {
+ this.disconnect = store.dispatch(connectUserStream());
+ }
+ }
+
+ componentWillUnmount () {
+ if (this.disconnect) {
+ this.disconnect();
+ this.disconnect = null;
+ }
+ }
+
+ shouldUpdateScroll (prevRouterProps, { location }) {
+ return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey);
+ }
+
+ render () {
+ const { locale } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/containers/media_container.js b/app/javascript/mastodon/containers/media_container.js
deleted file mode 100644
index 25dc17444..000000000
--- a/app/javascript/mastodon/containers/media_container.js
+++ /dev/null
@@ -1,121 +0,0 @@
-import React, { PureComponent, Fragment } from 'react';
-import ReactDOM from 'react-dom';
-import PropTypes from 'prop-types';
-import { IntlProvider, addLocaleData } from 'react-intl';
-import { fromJS } from 'immutable';
-import { getLocale } from 'mastodon/locales';
-import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
-import MediaGallery from 'mastodon/components/media_gallery';
-import Poll from 'mastodon/components/poll';
-import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
-import ModalRoot from 'mastodon/components/modal_root';
-import MediaModal from 'mastodon/features/ui/components/media_modal';
-import Video from 'mastodon/features/video';
-import Card from 'mastodon/features/status/components/card';
-import Audio from 'mastodon/features/audio';
-
-const { localeData, messages } = getLocale();
-addLocaleData(localeData);
-
-const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
-
-export default class MediaContainer extends PureComponent {
-
- static propTypes = {
- locale: PropTypes.string.isRequired,
- components: PropTypes.object.isRequired,
- };
-
- state = {
- media: null,
- index: null,
- time: null,
- backgroundColor: null,
- options: null,
- };
-
- handleOpenMedia = (media, index) => {
- document.body.classList.add('with-modals--active');
- document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
-
- this.setState({ media, index });
- };
-
- handleOpenVideo = (options) => {
- const { components } = this.props;
- const { media } = JSON.parse(components[options.componentIndex].getAttribute('data-props'));
- const mediaList = fromJS(media);
-
- document.body.classList.add('with-modals--active');
- document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
-
- this.setState({ media: mediaList, options });
- };
-
- handleCloseMedia = () => {
- document.body.classList.remove('with-modals--active');
- document.documentElement.style.marginRight = 0;
-
- this.setState({
- media: null,
- index: null,
- time: null,
- backgroundColor: null,
- options: null,
- });
- };
-
- setBackgroundColor = color => {
- this.setState({ backgroundColor: color });
- };
-
- render () {
- const { locale, components } = this.props;
-
- return (
-
-
- {[].map.call(components, (component, i) => {
- const componentName = component.getAttribute('data-component');
- const Component = MEDIA_COMPONENTS[componentName];
- const { media, card, poll, hashtag, ...props } = JSON.parse(component.getAttribute('data-props'));
-
- Object.assign(props, {
- ...(media ? { media: fromJS(media) } : {}),
- ...(card ? { card: fromJS(card) } : {}),
- ...(poll ? { poll: fromJS(poll) } : {}),
- ...(hashtag ? { hashtag: fromJS(hashtag) } : {}),
-
- ...(componentName === 'Video' ? {
- componentIndex: i,
- onOpenVideo: this.handleOpenVideo,
- } : {
- onOpenMedia: this.handleOpenMedia,
- }),
- });
-
- return ReactDOM.createPortal(
- ,
- component,
- );
- })}
-
-
- {this.state.media && (
-
- )}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/containers/media_container.jsx b/app/javascript/mastodon/containers/media_container.jsx
new file mode 100644
index 000000000..25dc17444
--- /dev/null
+++ b/app/javascript/mastodon/containers/media_container.jsx
@@ -0,0 +1,121 @@
+import React, { PureComponent, Fragment } from 'react';
+import ReactDOM from 'react-dom';
+import PropTypes from 'prop-types';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { fromJS } from 'immutable';
+import { getLocale } from 'mastodon/locales';
+import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
+import MediaGallery from 'mastodon/components/media_gallery';
+import Poll from 'mastodon/components/poll';
+import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
+import ModalRoot from 'mastodon/components/modal_root';
+import MediaModal from 'mastodon/features/ui/components/media_modal';
+import Video from 'mastodon/features/video';
+import Card from 'mastodon/features/status/components/card';
+import Audio from 'mastodon/features/audio';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
+
+export default class MediaContainer extends PureComponent {
+
+ static propTypes = {
+ locale: PropTypes.string.isRequired,
+ components: PropTypes.object.isRequired,
+ };
+
+ state = {
+ media: null,
+ index: null,
+ time: null,
+ backgroundColor: null,
+ options: null,
+ };
+
+ handleOpenMedia = (media, index) => {
+ document.body.classList.add('with-modals--active');
+ document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
+
+ this.setState({ media, index });
+ };
+
+ handleOpenVideo = (options) => {
+ const { components } = this.props;
+ const { media } = JSON.parse(components[options.componentIndex].getAttribute('data-props'));
+ const mediaList = fromJS(media);
+
+ document.body.classList.add('with-modals--active');
+ document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
+
+ this.setState({ media: mediaList, options });
+ };
+
+ handleCloseMedia = () => {
+ document.body.classList.remove('with-modals--active');
+ document.documentElement.style.marginRight = 0;
+
+ this.setState({
+ media: null,
+ index: null,
+ time: null,
+ backgroundColor: null,
+ options: null,
+ });
+ };
+
+ setBackgroundColor = color => {
+ this.setState({ backgroundColor: color });
+ };
+
+ render () {
+ const { locale, components } = this.props;
+
+ return (
+
+
+ {[].map.call(components, (component, i) => {
+ const componentName = component.getAttribute('data-component');
+ const Component = MEDIA_COMPONENTS[componentName];
+ const { media, card, poll, hashtag, ...props } = JSON.parse(component.getAttribute('data-props'));
+
+ Object.assign(props, {
+ ...(media ? { media: fromJS(media) } : {}),
+ ...(card ? { card: fromJS(card) } : {}),
+ ...(poll ? { poll: fromJS(poll) } : {}),
+ ...(hashtag ? { hashtag: fromJS(hashtag) } : {}),
+
+ ...(componentName === 'Video' ? {
+ componentIndex: i,
+ onOpenVideo: this.handleOpenVideo,
+ } : {
+ onOpenMedia: this.handleOpenMedia,
+ }),
+ });
+
+ return ReactDOM.createPortal(
+ ,
+ component,
+ );
+ })}
+
+
+ {this.state.media && (
+
+ )}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
deleted file mode 100644
index 294105f25..000000000
--- a/app/javascript/mastodon/containers/status_container.js
+++ /dev/null
@@ -1,250 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import Status from '../components/status';
-import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
-import {
- replyCompose,
- mentionCompose,
- directCompose,
-} from '../actions/compose';
-import {
- reblog,
- favourite,
- bookmark,
- unreblog,
- unfavourite,
- unbookmark,
- pin,
- unpin,
-} from '../actions/interactions';
-import {
- muteStatus,
- unmuteStatus,
- deleteStatus,
- hideStatus,
- revealStatus,
- toggleStatusCollapse,
- editStatus,
- translateStatus,
- undoStatusTranslation,
-} from '../actions/statuses';
-import {
- unmuteAccount,
- unblockAccount,
-} from '../actions/accounts';
-import {
- blockDomain,
- unblockDomain,
-} from '../actions/domain_blocks';
-import {
- initAddFilter,
-} from '../actions/filters';
-import { initMuteModal } from '../actions/mutes';
-import { initBlockModal } from '../actions/blocks';
-import { initBoostModal } from '../actions/boosts';
-import { initReport } from '../actions/reports';
-import { openModal } from '../actions/modal';
-import { deployPictureInPicture } from '../actions/picture_in_picture';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import { boostModal, deleteModal } from '../initial_state';
-import { showAlertForError } from '../actions/alerts';
-
-const messages = defineMessages({
- deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
- deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
- redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
- redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
- replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
- replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
- blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
-});
-
-const makeMapStateToProps = () => {
- const getStatus = makeGetStatus();
- const getPictureInPicture = makeGetPictureInPicture();
-
- const mapStateToProps = (state, props) => ({
- status: getStatus(state, props),
- pictureInPicture: getPictureInPicture(state, props),
- });
-
- return mapStateToProps;
-};
-
-const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
-
- onReply (status, router) {
- dispatch((_, getState) => {
- let state = getState();
-
- if (state.getIn(['compose', 'text']).trim().length !== 0) {
- dispatch(openModal('CONFIRM', {
- message: intl.formatMessage(messages.replyMessage),
- confirm: intl.formatMessage(messages.replyConfirm),
- onConfirm: () => dispatch(replyCompose(status, router)),
- }));
- } else {
- dispatch(replyCompose(status, router));
- }
- });
- },
-
- onModalReblog (status, privacy) {
- if (status.get('reblogged')) {
- dispatch(unreblog(status));
- } else {
- dispatch(reblog(status, privacy));
- }
- },
-
- onReblog (status, e) {
- if ((e && e.shiftKey) || !boostModal) {
- this.onModalReblog(status);
- } else {
- dispatch(initBoostModal({ status, onReblog: this.onModalReblog }));
- }
- },
-
- onFavourite (status) {
- if (status.get('favourited')) {
- dispatch(unfavourite(status));
- } else {
- dispatch(favourite(status));
- }
- },
-
- onBookmark (status) {
- if (status.get('bookmarked')) {
- dispatch(unbookmark(status));
- } else {
- dispatch(bookmark(status));
- }
- },
-
- onPin (status) {
- if (status.get('pinned')) {
- dispatch(unpin(status));
- } else {
- dispatch(pin(status));
- }
- },
-
- onEmbed (status) {
- dispatch(openModal('EMBED', {
- url: status.get('url'),
- onError: error => dispatch(showAlertForError(error)),
- }));
- },
-
- onDelete (status, history, withRedraft = false) {
- if (!deleteModal) {
- dispatch(deleteStatus(status.get('id'), history, withRedraft));
- } else {
- dispatch(openModal('CONFIRM', {
- message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
- confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
- onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
- }));
- }
- },
-
- onEdit (status, history) {
- dispatch(editStatus(status.get('id'), history));
- },
-
- onTranslate (status) {
- if (status.get('translation')) {
- dispatch(undoStatusTranslation(status.get('id')));
- } else {
- dispatch(translateStatus(status.get('id')));
- }
- },
-
- onDirect (account, router) {
- dispatch(directCompose(account, router));
- },
-
- onMention (account, router) {
- dispatch(mentionCompose(account, router));
- },
-
- onOpenMedia (statusId, media, index) {
- dispatch(openModal('MEDIA', { statusId, media, index }));
- },
-
- onOpenVideo (statusId, media, options) {
- dispatch(openModal('VIDEO', { statusId, media, options }));
- },
-
- onBlock (status) {
- const account = status.get('account');
- dispatch(initBlockModal(account));
- },
-
- onUnblock (account) {
- dispatch(unblockAccount(account.get('id')));
- },
-
- onReport (status) {
- dispatch(initReport(status.get('account'), status));
- },
-
- onAddFilter (status) {
- dispatch(initAddFilter(status, { contextType }));
- },
-
- onMute (account) {
- dispatch(initMuteModal(account));
- },
-
- onUnmute (account) {
- dispatch(unmuteAccount(account.get('id')));
- },
-
- onMuteConversation (status) {
- if (status.get('muted')) {
- dispatch(unmuteStatus(status.get('id')));
- } else {
- dispatch(muteStatus(status.get('id')));
- }
- },
-
- onToggleHidden (status) {
- if (status.get('hidden')) {
- dispatch(revealStatus(status.get('id')));
- } else {
- dispatch(hideStatus(status.get('id')));
- }
- },
-
- onToggleCollapsed (status, isCollapsed) {
- dispatch(toggleStatusCollapse(status.get('id'), isCollapsed));
- },
-
- onBlockDomain (domain) {
- dispatch(openModal('CONFIRM', {
- message: {domain} }} />,
- confirm: intl.formatMessage(messages.blockDomainConfirm),
- onConfirm: () => dispatch(blockDomain(domain)),
- }));
- },
-
- onUnblockDomain (domain) {
- dispatch(unblockDomain(domain));
- },
-
- deployPictureInPicture (status, type, mediaProps) {
- dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps));
- },
-
- onInteractionModal (type, status) {
- dispatch(openModal('INTERACTION', {
- type,
- accountId: status.getIn(['account', 'id']),
- url: status.get('url'),
- }));
- },
-
-});
-
-export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
diff --git a/app/javascript/mastodon/containers/status_container.jsx b/app/javascript/mastodon/containers/status_container.jsx
new file mode 100644
index 000000000..294105f25
--- /dev/null
+++ b/app/javascript/mastodon/containers/status_container.jsx
@@ -0,0 +1,250 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import Status from '../components/status';
+import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
+import {
+ replyCompose,
+ mentionCompose,
+ directCompose,
+} from '../actions/compose';
+import {
+ reblog,
+ favourite,
+ bookmark,
+ unreblog,
+ unfavourite,
+ unbookmark,
+ pin,
+ unpin,
+} from '../actions/interactions';
+import {
+ muteStatus,
+ unmuteStatus,
+ deleteStatus,
+ hideStatus,
+ revealStatus,
+ toggleStatusCollapse,
+ editStatus,
+ translateStatus,
+ undoStatusTranslation,
+} from '../actions/statuses';
+import {
+ unmuteAccount,
+ unblockAccount,
+} from '../actions/accounts';
+import {
+ blockDomain,
+ unblockDomain,
+} from '../actions/domain_blocks';
+import {
+ initAddFilter,
+} from '../actions/filters';
+import { initMuteModal } from '../actions/mutes';
+import { initBlockModal } from '../actions/blocks';
+import { initBoostModal } from '../actions/boosts';
+import { initReport } from '../actions/reports';
+import { openModal } from '../actions/modal';
+import { deployPictureInPicture } from '../actions/picture_in_picture';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { boostModal, deleteModal } from '../initial_state';
+import { showAlertForError } from '../actions/alerts';
+
+const messages = defineMessages({
+ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
+ deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
+ redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
+ redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
+ replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
+ replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+ blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
+});
+
+const makeMapStateToProps = () => {
+ const getStatus = makeGetStatus();
+ const getPictureInPicture = makeGetPictureInPicture();
+
+ const mapStateToProps = (state, props) => ({
+ status: getStatus(state, props),
+ pictureInPicture: getPictureInPicture(state, props),
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
+
+ onReply (status, router) {
+ dispatch((_, getState) => {
+ let state = getState();
+
+ if (state.getIn(['compose', 'text']).trim().length !== 0) {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.replyMessage),
+ confirm: intl.formatMessage(messages.replyConfirm),
+ onConfirm: () => dispatch(replyCompose(status, router)),
+ }));
+ } else {
+ dispatch(replyCompose(status, router));
+ }
+ });
+ },
+
+ onModalReblog (status, privacy) {
+ if (status.get('reblogged')) {
+ dispatch(unreblog(status));
+ } else {
+ dispatch(reblog(status, privacy));
+ }
+ },
+
+ onReblog (status, e) {
+ if ((e && e.shiftKey) || !boostModal) {
+ this.onModalReblog(status);
+ } else {
+ dispatch(initBoostModal({ status, onReblog: this.onModalReblog }));
+ }
+ },
+
+ onFavourite (status) {
+ if (status.get('favourited')) {
+ dispatch(unfavourite(status));
+ } else {
+ dispatch(favourite(status));
+ }
+ },
+
+ onBookmark (status) {
+ if (status.get('bookmarked')) {
+ dispatch(unbookmark(status));
+ } else {
+ dispatch(bookmark(status));
+ }
+ },
+
+ onPin (status) {
+ if (status.get('pinned')) {
+ dispatch(unpin(status));
+ } else {
+ dispatch(pin(status));
+ }
+ },
+
+ onEmbed (status) {
+ dispatch(openModal('EMBED', {
+ url: status.get('url'),
+ onError: error => dispatch(showAlertForError(error)),
+ }));
+ },
+
+ onDelete (status, history, withRedraft = false) {
+ if (!deleteModal) {
+ dispatch(deleteStatus(status.get('id'), history, withRedraft));
+ } else {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
+ confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
+ onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
+ }));
+ }
+ },
+
+ onEdit (status, history) {
+ dispatch(editStatus(status.get('id'), history));
+ },
+
+ onTranslate (status) {
+ if (status.get('translation')) {
+ dispatch(undoStatusTranslation(status.get('id')));
+ } else {
+ dispatch(translateStatus(status.get('id')));
+ }
+ },
+
+ onDirect (account, router) {
+ dispatch(directCompose(account, router));
+ },
+
+ onMention (account, router) {
+ dispatch(mentionCompose(account, router));
+ },
+
+ onOpenMedia (statusId, media, index) {
+ dispatch(openModal('MEDIA', { statusId, media, index }));
+ },
+
+ onOpenVideo (statusId, media, options) {
+ dispatch(openModal('VIDEO', { statusId, media, options }));
+ },
+
+ onBlock (status) {
+ const account = status.get('account');
+ dispatch(initBlockModal(account));
+ },
+
+ onUnblock (account) {
+ dispatch(unblockAccount(account.get('id')));
+ },
+
+ onReport (status) {
+ dispatch(initReport(status.get('account'), status));
+ },
+
+ onAddFilter (status) {
+ dispatch(initAddFilter(status, { contextType }));
+ },
+
+ onMute (account) {
+ dispatch(initMuteModal(account));
+ },
+
+ onUnmute (account) {
+ dispatch(unmuteAccount(account.get('id')));
+ },
+
+ onMuteConversation (status) {
+ if (status.get('muted')) {
+ dispatch(unmuteStatus(status.get('id')));
+ } else {
+ dispatch(muteStatus(status.get('id')));
+ }
+ },
+
+ onToggleHidden (status) {
+ if (status.get('hidden')) {
+ dispatch(revealStatus(status.get('id')));
+ } else {
+ dispatch(hideStatus(status.get('id')));
+ }
+ },
+
+ onToggleCollapsed (status, isCollapsed) {
+ dispatch(toggleStatusCollapse(status.get('id'), isCollapsed));
+ },
+
+ onBlockDomain (domain) {
+ dispatch(openModal('CONFIRM', {
+ message: {domain} }} />,
+ confirm: intl.formatMessage(messages.blockDomainConfirm),
+ onConfirm: () => dispatch(blockDomain(domain)),
+ }));
+ },
+
+ onUnblockDomain (domain) {
+ dispatch(unblockDomain(domain));
+ },
+
+ deployPictureInPicture (status, type, mediaProps) {
+ dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps));
+ },
+
+ onInteractionModal (type, status) {
+ dispatch(openModal('INTERACTION', {
+ type,
+ accountId: status.getIn(['account', 'id']),
+ url: status.get('url'),
+ }));
+ },
+
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
diff --git a/app/javascript/mastodon/features/about/index.js b/app/javascript/mastodon/features/about/index.js
deleted file mode 100644
index dc1942c63..000000000
--- a/app/javascript/mastodon/features/about/index.js
+++ /dev/null
@@ -1,219 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import Column from 'mastodon/components/column';
-import LinkFooter from 'mastodon/features/ui/components/link_footer';
-import { Helmet } from 'react-helmet';
-import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'mastodon/actions/server';
-import Account from 'mastodon/containers/account_container';
-import Skeleton from 'mastodon/components/skeleton';
-import Icon from 'mastodon/components/icon';
-import classNames from 'classnames';
-import Image from 'mastodon/components/image';
-
-const messages = defineMessages({
- title: { id: 'column.about', defaultMessage: 'About' },
- rules: { id: 'about.rules', defaultMessage: 'Server rules' },
- blocks: { id: 'about.blocks', defaultMessage: 'Moderated servers' },
- silenced: { id: 'about.domain_blocks.silenced.title', defaultMessage: 'Limited' },
- silencedExplanation: { id: 'about.domain_blocks.silenced.explanation', defaultMessage: 'You will generally not see profiles and content from this server, unless you explicitly look it up or opt into it by following.' },
- suspended: { id: 'about.domain_blocks.suspended.title', defaultMessage: 'Suspended' },
- suspendedExplanation: { id: 'about.domain_blocks.suspended.explanation', defaultMessage: 'No data from this server will be processed, stored or exchanged, making any interaction or communication with users from this server impossible.' },
-});
-
-const severityMessages = {
- silence: {
- title: messages.silenced,
- explanation: messages.silencedExplanation,
- },
-
- suspend: {
- title: messages.suspended,
- explanation: messages.suspendedExplanation,
- },
-};
-
-const mapStateToProps = state => ({
- server: state.getIn(['server', 'server']),
- extendedDescription: state.getIn(['server', 'extendedDescription']),
- domainBlocks: state.getIn(['server', 'domainBlocks']),
-});
-
-class Section extends React.PureComponent {
-
- static propTypes = {
- title: PropTypes.string,
- children: PropTypes.node,
- open: PropTypes.bool,
- onOpen: PropTypes.func,
- };
-
- state = {
- collapsed: !this.props.open,
- };
-
- handleClick = () => {
- const { onOpen } = this.props;
- const { collapsed } = this.state;
-
- this.setState({ collapsed: !collapsed }, () => onOpen && onOpen());
- };
-
- render () {
- const { title, children } = this.props;
- const { collapsed } = this.state;
-
- return (
-
-
- {title}
-
-
- {!collapsed && (
-
{children}
- )}
-
- );
- }
-
-}
-
-export default @connect(mapStateToProps)
-@injectIntl
-class About extends React.PureComponent {
-
- static propTypes = {
- server: ImmutablePropTypes.map,
- extendedDescription: ImmutablePropTypes.map,
- domainBlocks: ImmutablePropTypes.contains({
- isLoading: PropTypes.bool,
- isAvailable: PropTypes.bool,
- items: ImmutablePropTypes.list,
- }),
- dispatch: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- multiColumn: PropTypes.bool,
- };
-
- componentDidMount () {
- const { dispatch } = this.props;
- dispatch(fetchServer());
- dispatch(fetchExtendedDescription());
- }
-
- handleDomainBlocksOpen = () => {
- const { dispatch } = this.props;
- dispatch(fetchDomainBlocks());
- };
-
- render () {
- const { multiColumn, intl, server, extendedDescription, domainBlocks } = this.props;
- const isLoading = server.get('isLoading');
-
- return (
-
-
-
-
`${value} ${key.replace('@', '')}`).join(', ')} className='about__header__hero' />
- {isLoading ? : server.get('domain')}
- Mastodon }} />
-
-
-
-
-
- {extendedDescription.get('isLoading') ? (
- <>
-
-
-
-
-
-
-
- >
- ) : (extendedDescription.get('content')?.length > 0 ? (
-
- ) : (
-
- ))}
-
-
-
- {!isLoading && (server.get('rules').isEmpty() ? (
-
- ) : (
-
- {server.get('rules').map(rule => (
-
- {rule.get('text')}
-
- ))}
-
- ))}
-
-
-
- {domainBlocks.get('isLoading') ? (
- <>
-
-
-
- >
- ) : (domainBlocks.get('isAvailable') ? (
- <>
-
-
-
- {domainBlocks.get('items').map(block => (
-
-
-
{block.get('domain')}
- {intl.formatMessage(severityMessages[block.get('severity')].title)}
-
-
-
{(block.get('comment') || '').length > 0 ? block.get('comment') : }
-
- ))}
-
- >
- ) : (
-
- ))}
-
-
-
-
-
-
-
-
- {intl.formatMessage(messages.title)}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/about/index.jsx b/app/javascript/mastodon/features/about/index.jsx
new file mode 100644
index 000000000..dc1942c63
--- /dev/null
+++ b/app/javascript/mastodon/features/about/index.jsx
@@ -0,0 +1,219 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import Column from 'mastodon/components/column';
+import LinkFooter from 'mastodon/features/ui/components/link_footer';
+import { Helmet } from 'react-helmet';
+import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'mastodon/actions/server';
+import Account from 'mastodon/containers/account_container';
+import Skeleton from 'mastodon/components/skeleton';
+import Icon from 'mastodon/components/icon';
+import classNames from 'classnames';
+import Image from 'mastodon/components/image';
+
+const messages = defineMessages({
+ title: { id: 'column.about', defaultMessage: 'About' },
+ rules: { id: 'about.rules', defaultMessage: 'Server rules' },
+ blocks: { id: 'about.blocks', defaultMessage: 'Moderated servers' },
+ silenced: { id: 'about.domain_blocks.silenced.title', defaultMessage: 'Limited' },
+ silencedExplanation: { id: 'about.domain_blocks.silenced.explanation', defaultMessage: 'You will generally not see profiles and content from this server, unless you explicitly look it up or opt into it by following.' },
+ suspended: { id: 'about.domain_blocks.suspended.title', defaultMessage: 'Suspended' },
+ suspendedExplanation: { id: 'about.domain_blocks.suspended.explanation', defaultMessage: 'No data from this server will be processed, stored or exchanged, making any interaction or communication with users from this server impossible.' },
+});
+
+const severityMessages = {
+ silence: {
+ title: messages.silenced,
+ explanation: messages.silencedExplanation,
+ },
+
+ suspend: {
+ title: messages.suspended,
+ explanation: messages.suspendedExplanation,
+ },
+};
+
+const mapStateToProps = state => ({
+ server: state.getIn(['server', 'server']),
+ extendedDescription: state.getIn(['server', 'extendedDescription']),
+ domainBlocks: state.getIn(['server', 'domainBlocks']),
+});
+
+class Section extends React.PureComponent {
+
+ static propTypes = {
+ title: PropTypes.string,
+ children: PropTypes.node,
+ open: PropTypes.bool,
+ onOpen: PropTypes.func,
+ };
+
+ state = {
+ collapsed: !this.props.open,
+ };
+
+ handleClick = () => {
+ const { onOpen } = this.props;
+ const { collapsed } = this.state;
+
+ this.setState({ collapsed: !collapsed }, () => onOpen && onOpen());
+ };
+
+ render () {
+ const { title, children } = this.props;
+ const { collapsed } = this.state;
+
+ return (
+
+
+ {title}
+
+
+ {!collapsed && (
+
{children}
+ )}
+
+ );
+ }
+
+}
+
+export default @connect(mapStateToProps)
+@injectIntl
+class About extends React.PureComponent {
+
+ static propTypes = {
+ server: ImmutablePropTypes.map,
+ extendedDescription: ImmutablePropTypes.map,
+ domainBlocks: ImmutablePropTypes.contains({
+ isLoading: PropTypes.bool,
+ isAvailable: PropTypes.bool,
+ items: ImmutablePropTypes.list,
+ }),
+ dispatch: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ multiColumn: PropTypes.bool,
+ };
+
+ componentDidMount () {
+ const { dispatch } = this.props;
+ dispatch(fetchServer());
+ dispatch(fetchExtendedDescription());
+ }
+
+ handleDomainBlocksOpen = () => {
+ const { dispatch } = this.props;
+ dispatch(fetchDomainBlocks());
+ };
+
+ render () {
+ const { multiColumn, intl, server, extendedDescription, domainBlocks } = this.props;
+ const isLoading = server.get('isLoading');
+
+ return (
+
+
+
+
`${value} ${key.replace('@', '')}`).join(', ')} className='about__header__hero' />
+ {isLoading ? : server.get('domain')}
+ Mastodon }} />
+
+
+
+
+
+ {extendedDescription.get('isLoading') ? (
+ <>
+
+
+
+
+
+
+
+ >
+ ) : (extendedDescription.get('content')?.length > 0 ? (
+
+ ) : (
+
+ ))}
+
+
+
+ {!isLoading && (server.get('rules').isEmpty() ? (
+
+ ) : (
+
+ {server.get('rules').map(rule => (
+
+ {rule.get('text')}
+
+ ))}
+
+ ))}
+
+
+
+ {domainBlocks.get('isLoading') ? (
+ <>
+
+
+
+ >
+ ) : (domainBlocks.get('isAvailable') ? (
+ <>
+
+
+
+ {domainBlocks.get('items').map(block => (
+
+
+
{block.get('domain')}
+ {intl.formatMessage(severityMessages[block.get('severity')].title)}
+
+
+
{(block.get('comment') || '').length > 0 ? block.get('comment') : }
+
+ ))}
+
+ >
+ ) : (
+
+ ))}
+
+
+
+
+
+
+
+
+ {intl.formatMessage(messages.title)}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/account/components/account_note.js b/app/javascript/mastodon/features/account/components/account_note.js
deleted file mode 100644
index fdacc7583..000000000
--- a/app/javascript/mastodon/features/account/components/account_note.js
+++ /dev/null
@@ -1,170 +0,0 @@
-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 Textarea from 'react-textarea-autosize';
-import { is } from 'immutable';
-
-const messages = defineMessages({
- placeholder: { id: 'account_note.placeholder', defaultMessage: 'Click to add a note' },
-});
-
-class InlineAlert extends React.PureComponent {
-
- static propTypes = {
- show: PropTypes.bool,
- };
-
- state = {
- mountMessage: false,
- };
-
- static TRANSITION_DELAY = 200;
-
- componentWillReceiveProps (nextProps) {
- if (!this.props.show && nextProps.show) {
- this.setState({ mountMessage: true });
- } else if (this.props.show && !nextProps.show) {
- setTimeout(() => this.setState({ mountMessage: false }), InlineAlert.TRANSITION_DELAY);
- }
- }
-
- render () {
- const { show } = this.props;
- const { mountMessage } = this.state;
-
- return (
-
- {mountMessage && }
-
- );
- }
-
-}
-
-export default @injectIntl
-class AccountNote extends ImmutablePureComponent {
-
- static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
- value: PropTypes.string,
- onSave: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- state = {
- value: null,
- saving: false,
- saved: false,
- };
-
- componentWillMount () {
- this._reset();
- }
-
- componentWillReceiveProps (nextProps) {
- const accountWillChange = !is(this.props.account, nextProps.account);
- const newState = {};
-
- if (accountWillChange && this._isDirty()) {
- this._save(false);
- }
-
- if (accountWillChange || nextProps.value === this.state.value) {
- newState.saving = false;
- }
-
- if (this.props.value !== nextProps.value) {
- newState.value = nextProps.value;
- }
-
- this.setState(newState);
- }
-
- componentWillUnmount () {
- if (this._isDirty()) {
- this._save(false);
- }
- }
-
- setTextareaRef = c => {
- this.textarea = c;
- };
-
- handleChange = e => {
- this.setState({ value: e.target.value, saving: false });
- };
-
- handleKeyDown = e => {
- if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
- e.preventDefault();
-
- this._save();
-
- if (this.textarea) {
- this.textarea.blur();
- }
- } else if (e.keyCode === 27) {
- e.preventDefault();
-
- this._reset(() => {
- if (this.textarea) {
- this.textarea.blur();
- }
- });
- }
- };
-
- handleBlur = () => {
- if (this._isDirty()) {
- this._save();
- }
- };
-
- _save (showMessage = true) {
- this.setState({ saving: true }, () => this.props.onSave(this.state.value));
-
- if (showMessage) {
- this.setState({ saved: true }, () => setTimeout(() => this.setState({ saved: false }), 2000));
- }
- }
-
- _reset (callback) {
- this.setState({ value: this.props.value }, callback);
- }
-
- _isDirty () {
- return !this.state.saving && this.props.value !== null && this.state.value !== null && this.state.value !== this.props.value;
- }
-
- render () {
- const { account, intl } = this.props;
- const { value, saved } = this.state;
-
- if (!account) {
- return null;
- }
-
- return (
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/account/components/account_note.jsx b/app/javascript/mastodon/features/account/components/account_note.jsx
new file mode 100644
index 000000000..fdacc7583
--- /dev/null
+++ b/app/javascript/mastodon/features/account/components/account_note.jsx
@@ -0,0 +1,170 @@
+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 Textarea from 'react-textarea-autosize';
+import { is } from 'immutable';
+
+const messages = defineMessages({
+ placeholder: { id: 'account_note.placeholder', defaultMessage: 'Click to add a note' },
+});
+
+class InlineAlert extends React.PureComponent {
+
+ static propTypes = {
+ show: PropTypes.bool,
+ };
+
+ state = {
+ mountMessage: false,
+ };
+
+ static TRANSITION_DELAY = 200;
+
+ componentWillReceiveProps (nextProps) {
+ if (!this.props.show && nextProps.show) {
+ this.setState({ mountMessage: true });
+ } else if (this.props.show && !nextProps.show) {
+ setTimeout(() => this.setState({ mountMessage: false }), InlineAlert.TRANSITION_DELAY);
+ }
+ }
+
+ render () {
+ const { show } = this.props;
+ const { mountMessage } = this.state;
+
+ return (
+
+ {mountMessage && }
+
+ );
+ }
+
+}
+
+export default @injectIntl
+class AccountNote extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ value: PropTypes.string,
+ onSave: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ value: null,
+ saving: false,
+ saved: false,
+ };
+
+ componentWillMount () {
+ this._reset();
+ }
+
+ componentWillReceiveProps (nextProps) {
+ const accountWillChange = !is(this.props.account, nextProps.account);
+ const newState = {};
+
+ if (accountWillChange && this._isDirty()) {
+ this._save(false);
+ }
+
+ if (accountWillChange || nextProps.value === this.state.value) {
+ newState.saving = false;
+ }
+
+ if (this.props.value !== nextProps.value) {
+ newState.value = nextProps.value;
+ }
+
+ this.setState(newState);
+ }
+
+ componentWillUnmount () {
+ if (this._isDirty()) {
+ this._save(false);
+ }
+ }
+
+ setTextareaRef = c => {
+ this.textarea = c;
+ };
+
+ handleChange = e => {
+ this.setState({ value: e.target.value, saving: false });
+ };
+
+ handleKeyDown = e => {
+ if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+
+ this._save();
+
+ if (this.textarea) {
+ this.textarea.blur();
+ }
+ } else if (e.keyCode === 27) {
+ e.preventDefault();
+
+ this._reset(() => {
+ if (this.textarea) {
+ this.textarea.blur();
+ }
+ });
+ }
+ };
+
+ handleBlur = () => {
+ if (this._isDirty()) {
+ this._save();
+ }
+ };
+
+ _save (showMessage = true) {
+ this.setState({ saving: true }, () => this.props.onSave(this.state.value));
+
+ if (showMessage) {
+ this.setState({ saved: true }, () => setTimeout(() => this.setState({ saved: false }), 2000));
+ }
+ }
+
+ _reset (callback) {
+ this.setState({ value: this.props.value }, callback);
+ }
+
+ _isDirty () {
+ return !this.state.saving && this.props.value !== null && this.state.value !== null && this.state.value !== this.props.value;
+ }
+
+ render () {
+ const { account, intl } = this.props;
+ const { value, saved } = this.state;
+
+ if (!account) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/account/components/featured_tags.js b/app/javascript/mastodon/features/account/components/featured_tags.js
deleted file mode 100644
index 24a3f2171..000000000
--- a/app/javascript/mastodon/features/account/components/featured_tags.js
+++ /dev/null
@@ -1,52 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import Hashtag from 'mastodon/components/hashtag';
-
-const messages = defineMessages({
- lastStatusAt: { id: 'account.featured_tags.last_status_at', defaultMessage: 'Last post on {date}' },
- empty: { id: 'account.featured_tags.last_status_never', defaultMessage: 'No posts' },
-});
-
-export default @injectIntl
-class FeaturedTags extends ImmutablePureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- };
-
- static propTypes = {
- account: ImmutablePropTypes.map,
- featuredTags: ImmutablePropTypes.list,
- tagged: PropTypes.string,
- intl: PropTypes.object.isRequired,
- };
-
- render () {
- const { account, featuredTags, intl } = this.props;
-
- if (!account || account.get('suspended') || featuredTags.isEmpty()) {
- return null;
- }
-
- return (
-
-
}} />
-
- {featuredTags.take(3).map(featuredTag => (
- 0) ? intl.formatMessage(messages.lastStatusAt, { date: intl.formatDate(featuredTag.get('last_status_at'), { month: 'short', day: '2-digit' }) }) : intl.formatMessage(messages.empty)}
- />
- ))}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/account/components/featured_tags.jsx b/app/javascript/mastodon/features/account/components/featured_tags.jsx
new file mode 100644
index 000000000..24a3f2171
--- /dev/null
+++ b/app/javascript/mastodon/features/account/components/featured_tags.jsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Hashtag from 'mastodon/components/hashtag';
+
+const messages = defineMessages({
+ lastStatusAt: { id: 'account.featured_tags.last_status_at', defaultMessage: 'Last post on {date}' },
+ empty: { id: 'account.featured_tags.last_status_never', defaultMessage: 'No posts' },
+});
+
+export default @injectIntl
+class FeaturedTags extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ account: ImmutablePropTypes.map,
+ featuredTags: ImmutablePropTypes.list,
+ tagged: PropTypes.string,
+ intl: PropTypes.object.isRequired,
+ };
+
+ render () {
+ const { account, featuredTags, intl } = this.props;
+
+ if (!account || account.get('suspended') || featuredTags.isEmpty()) {
+ return null;
+ }
+
+ return (
+
+
}} />
+
+ {featuredTags.take(3).map(featuredTag => (
+ 0) ? intl.formatMessage(messages.lastStatusAt, { date: intl.formatDate(featuredTag.get('last_status_at'), { month: 'short', day: '2-digit' }) }) : intl.formatMessage(messages.empty)}
+ />
+ ))}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/account/components/follow_request_note.js b/app/javascript/mastodon/features/account/components/follow_request_note.js
deleted file mode 100644
index 300ae4266..000000000
--- a/app/javascript/mastodon/features/account/components/follow_request_note.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { FormattedMessage } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import Icon from 'mastodon/components/icon';
-
-export default class FollowRequestNote extends ImmutablePureComponent {
-
- static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
- };
-
- render () {
- const { account, onAuthorize, onReject } = this.props;
-
- return (
-
-
- }} />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/account/components/follow_request_note.jsx b/app/javascript/mastodon/features/account/components/follow_request_note.jsx
new file mode 100644
index 000000000..300ae4266
--- /dev/null
+++ b/app/javascript/mastodon/features/account/components/follow_request_note.jsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Icon from 'mastodon/components/icon';
+
+export default class FollowRequestNote extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ };
+
+ render () {
+ const { account, onAuthorize, onReject } = this.props;
+
+ return (
+
+
+ }} />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
deleted file mode 100644
index 539d72574..000000000
--- a/app/javascript/mastodon/features/account/components/header.js
+++ /dev/null
@@ -1,421 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import Button from 'mastodon/components/button';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { autoPlayGif, me, domain } from 'mastodon/initial_state';
-import classNames from 'classnames';
-import Icon from 'mastodon/components/icon';
-import IconButton from 'mastodon/components/icon_button';
-import Avatar from 'mastodon/components/avatar';
-import { counterRenderer } from 'mastodon/components/common_counter';
-import ShortNumber from 'mastodon/components/short_number';
-import { NavLink } from 'react-router-dom';
-import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
-import AccountNoteContainer from '../containers/account_note_container';
-import FollowRequestNoteContainer from '../containers/follow_request_note_container';
-import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
-import { Helmet } from 'react-helmet';
-
-const messages = defineMessages({
- unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
- follow: { id: 'account.follow', defaultMessage: 'Follow' },
- cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
- requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
- unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
- edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
- linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' },
- account_locked: { id: 'account.locked_info', defaultMessage: 'This account privacy status is set to locked. The owner manually reviews who can follow them.' },
- mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
- direct: { id: 'account.direct', defaultMessage: 'Direct message @{name}' },
- unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
- block: { id: 'account.block', defaultMessage: 'Block @{name}' },
- mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
- 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: 'Block domain {domain}' },
- unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
- hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' },
- showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' },
- enableNotifications: { id: 'account.enable_notifications', defaultMessage: 'Notify me when @{name} posts' },
- disableNotifications: { id: 'account.disable_notifications', defaultMessage: 'Stop notifying me when @{name} posts' },
- pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
- preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
- follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
- favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
- lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
- followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
- blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
- domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
- mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
- endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
- unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
- add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' },
- admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
- admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
- languages: { id: 'account.languages', defaultMessage: 'Change subscribed languages' },
- openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
-});
-
-const titleFromAccount = account => {
- const displayName = account.get('display_name');
- const acct = account.get('acct') === account.get('username') ? `${account.get('username')}@${domain}` : account.get('acct');
- const prefix = displayName.trim().length === 0 ? account.get('username') : displayName;
-
- return `${prefix} (@${acct})`;
-};
-
-const dateFormatOptions = {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- hour12: false,
- hour: '2-digit',
- minute: '2-digit',
-};
-
-export default @injectIntl
-class Header extends ImmutablePureComponent {
-
- static contextTypes = {
- identity: PropTypes.object,
- };
-
- static propTypes = {
- account: ImmutablePropTypes.map,
- identity_props: ImmutablePropTypes.list,
- onFollow: PropTypes.func.isRequired,
- onBlock: PropTypes.func.isRequired,
- onMention: PropTypes.func.isRequired,
- onDirect: PropTypes.func.isRequired,
- onReblogToggle: PropTypes.func.isRequired,
- onNotifyToggle: PropTypes.func.isRequired,
- onReport: PropTypes.func.isRequired,
- onMute: PropTypes.func.isRequired,
- onBlockDomain: PropTypes.func.isRequired,
- onUnblockDomain: PropTypes.func.isRequired,
- onEndorseToggle: PropTypes.func.isRequired,
- onAddToList: PropTypes.func.isRequired,
- onEditAccountNote: PropTypes.func.isRequired,
- onChangeLanguages: PropTypes.func.isRequired,
- onInteractionModal: PropTypes.func.isRequired,
- onOpenAvatar: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- domain: PropTypes.string.isRequired,
- hidden: PropTypes.bool,
- };
-
- openEditProfile = () => {
- window.open('/settings/profile', '_blank');
- };
-
- isStatusesPageActive = (match, location) => {
- if (!match) {
- return false;
- }
-
- return !location.pathname.match(/\/(followers|following)\/?$/);
- };
-
- handleMouseEnter = ({ currentTarget }) => {
- if (autoPlayGif) {
- return;
- }
-
- const emojis = currentTarget.querySelectorAll('.custom-emoji');
-
- for (var i = 0; i < emojis.length; i++) {
- let emoji = emojis[i];
- emoji.src = emoji.getAttribute('data-original');
- }
- };
-
- handleMouseLeave = ({ currentTarget }) => {
- if (autoPlayGif) {
- return;
- }
-
- const emojis = currentTarget.querySelectorAll('.custom-emoji');
-
- for (var i = 0; i < emojis.length; i++) {
- let emoji = emojis[i];
- emoji.src = emoji.getAttribute('data-static');
- }
- };
-
- handleAvatarClick = e => {
- if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
- e.preventDefault();
- this.props.onOpenAvatar();
- }
- };
-
- handleShare = () => {
- const { account } = this.props;
-
- navigator.share({
- text: `${titleFromAccount(account)}\n${account.get('note_plain')}`,
- url: account.get('url'),
- }).catch((e) => {
- if (e.name !== 'AbortError') console.error(e);
- });
- };
-
- render () {
- const { account, hidden, intl, domain } = this.props;
- const { signedIn, permissions } = this.context.identity;
-
- if (!account) {
- return null;
- }
-
- const suspended = account.get('suspended');
- const isRemote = account.get('acct') !== account.get('username');
- const remoteDomain = isRemote ? account.get('acct').split('@')[1] : null;
-
- let info = [];
- let actionBtn = '';
- let bellBtn = '';
- let lockedIcon = '';
- let menu = [];
-
- if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
- info.push( );
- } else if (me !== account.get('id') && account.getIn(['relationship', 'blocking'])) {
- info.push( );
- }
-
- if (me !== account.get('id') && account.getIn(['relationship', 'muting'])) {
- info.push( );
- } else if (me !== account.get('id') && account.getIn(['relationship', 'domain_blocking'])) {
- info.push( );
- }
-
- if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) {
- bellBtn = ;
- }
-
- if (me !== account.get('id')) {
- if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded
- actionBtn = '';
- } else if (account.getIn(['relationship', 'requested'])) {
- actionBtn = ;
- } else if (!account.getIn(['relationship', 'blocking'])) {
- actionBtn = ;
- } else if (account.getIn(['relationship', 'blocking'])) {
- actionBtn = ;
- }
- } else {
- actionBtn = ;
- }
-
- if (account.get('moved') && !account.getIn(['relationship', 'following'])) {
- actionBtn = '';
- }
-
- if (account.get('locked')) {
- lockedIcon = ;
- }
-
- if (signedIn && account.get('id') !== me) {
- menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
- menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.props.onDirect });
- menu.push(null);
- }
-
- if (isRemote) {
- menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: account.get('url') });
- menu.push(null);
- }
-
- if ('share' in navigator) {
- menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare });
- menu.push(null);
- }
-
- if (account.get('id') === me) {
- menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
- menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
- menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
- menu.push(null);
- menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
- menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
- menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
- menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
- menu.push(null);
- menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
- menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
- menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
- } else if (signedIn) {
- if (account.getIn(['relationship', 'following'])) {
- if (!account.getIn(['relationship', 'muting'])) {
- if (account.getIn(['relationship', 'showing_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 });
- }
-
- menu.push({ text: intl.formatMessage(messages.languages), action: this.props.onChangeLanguages });
- menu.push(null);
- }
-
- menu.push({ text: intl.formatMessage(account.getIn(['relationship', 'endorsed']) ? messages.unendorse : messages.endorse), action: this.props.onEndorseToggle });
- menu.push({ text: intl.formatMessage(messages.add_or_remove_from_list), action: this.props.onAddToList });
- menu.push(null);
- }
-
- 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 (signedIn && isRemote) {
- menu.push(null);
-
- if (account.getIn(['relationship', 'domain_blocking'])) {
- menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain: remoteDomain }), action: this.props.onUnblockDomain });
- } else {
- menu.push({ text: intl.formatMessage(messages.blockDomain, { domain: remoteDomain }), action: this.props.onBlockDomain });
- }
- }
-
- if ((account.get('id') !== me && (permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
- menu.push(null);
- if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
- menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${account.get('id')}` });
- }
- if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) {
- menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: remoteDomain }), href: `/admin/instances/${remoteDomain}` });
- }
- }
-
- const content = { __html: account.get('note_emojified') };
- const displayNameHtml = { __html: account.get('display_name_html') };
- const fields = account.get('fields');
- const isLocal = account.get('acct').indexOf('@') === -1;
- const acct = isLocal && domain ? `${account.get('acct')}@${domain}` : account.get('acct');
- const isIndexable = !account.get('noindex');
-
- let badge;
-
- if (account.get('bot')) {
- badge = (
);
- } else if (account.get('group')) {
- badge = (
);
- } else {
- badge = null;
- }
-
- return (
-
- {!(suspended || hidden || account.get('moved')) && account.getIn(['relationship', 'requested_by']) &&
}
-
-
-
- {!suspended && info}
-
-
- {!(suspended || hidden) &&
}
-
-
-
-
-
-
-
-
- {!suspended && (
-
- {!hidden && (
-
- {actionBtn}
- {bellBtn}
-
- )}
-
-
-
- )}
-
-
-
-
- {badge}
-
- @{acct} {lockedIcon}
-
-
-
-
- {!(suspended || hidden) && (
-
-
- {(account.get('id') !== me && signedIn) &&
}
-
- {account.get('note').length > 0 && account.get('note') !== '
' &&
}
-
-
-
-
- {intl.formatDate(account.get('created_at'), { year: 'numeric', month: 'short', day: '2-digit' })}
-
-
- {fields.map((pair, i) => (
-
-
-
-
- {pair.get('verified_at') && }
-
-
- ))}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )}
-
-
-
- {titleFromAccount(account)}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/account/components/header.jsx b/app/javascript/mastodon/features/account/components/header.jsx
new file mode 100644
index 000000000..539d72574
--- /dev/null
+++ b/app/javascript/mastodon/features/account/components/header.jsx
@@ -0,0 +1,421 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Button from 'mastodon/components/button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { autoPlayGif, me, domain } from 'mastodon/initial_state';
+import classNames from 'classnames';
+import Icon from 'mastodon/components/icon';
+import IconButton from 'mastodon/components/icon_button';
+import Avatar from 'mastodon/components/avatar';
+import { counterRenderer } from 'mastodon/components/common_counter';
+import ShortNumber from 'mastodon/components/short_number';
+import { NavLink } from 'react-router-dom';
+import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
+import AccountNoteContainer from '../containers/account_note_container';
+import FollowRequestNoteContainer from '../containers/follow_request_note_container';
+import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+ follow: { id: 'account.follow', defaultMessage: 'Follow' },
+ cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
+ requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
+ unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
+ edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
+ linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' },
+ account_locked: { id: 'account.locked_info', defaultMessage: 'This account privacy status is set to locked. The owner manually reviews who can follow them.' },
+ mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
+ direct: { id: 'account.direct', defaultMessage: 'Direct message @{name}' },
+ unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+ block: { id: 'account.block', defaultMessage: 'Block @{name}' },
+ mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
+ 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: 'Block domain {domain}' },
+ unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
+ hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' },
+ showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' },
+ enableNotifications: { id: 'account.enable_notifications', defaultMessage: 'Notify me when @{name} posts' },
+ disableNotifications: { id: 'account.disable_notifications', defaultMessage: 'Stop notifying me when @{name} posts' },
+ pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
+ preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+ follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
+ favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
+ lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
+ followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
+ blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
+ domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
+ mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
+ endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
+ unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
+ add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' },
+ admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
+ admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
+ languages: { id: 'account.languages', defaultMessage: 'Change subscribed languages' },
+ openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
+});
+
+const titleFromAccount = account => {
+ const displayName = account.get('display_name');
+ const acct = account.get('acct') === account.get('username') ? `${account.get('username')}@${domain}` : account.get('acct');
+ const prefix = displayName.trim().length === 0 ? account.get('username') : displayName;
+
+ return `${prefix} (@${acct})`;
+};
+
+const dateFormatOptions = {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ hour12: false,
+ hour: '2-digit',
+ minute: '2-digit',
+};
+
+export default @injectIntl
+class Header extends ImmutablePureComponent {
+
+ static contextTypes = {
+ identity: PropTypes.object,
+ };
+
+ static propTypes = {
+ account: ImmutablePropTypes.map,
+ identity_props: ImmutablePropTypes.list,
+ onFollow: PropTypes.func.isRequired,
+ onBlock: PropTypes.func.isRequired,
+ onMention: PropTypes.func.isRequired,
+ onDirect: PropTypes.func.isRequired,
+ onReblogToggle: PropTypes.func.isRequired,
+ onNotifyToggle: PropTypes.func.isRequired,
+ onReport: PropTypes.func.isRequired,
+ onMute: PropTypes.func.isRequired,
+ onBlockDomain: PropTypes.func.isRequired,
+ onUnblockDomain: PropTypes.func.isRequired,
+ onEndorseToggle: PropTypes.func.isRequired,
+ onAddToList: PropTypes.func.isRequired,
+ onEditAccountNote: PropTypes.func.isRequired,
+ onChangeLanguages: PropTypes.func.isRequired,
+ onInteractionModal: PropTypes.func.isRequired,
+ onOpenAvatar: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ domain: PropTypes.string.isRequired,
+ hidden: PropTypes.bool,
+ };
+
+ openEditProfile = () => {
+ window.open('/settings/profile', '_blank');
+ };
+
+ isStatusesPageActive = (match, location) => {
+ if (!match) {
+ return false;
+ }
+
+ return !location.pathname.match(/\/(followers|following)\/?$/);
+ };
+
+ handleMouseEnter = ({ currentTarget }) => {
+ if (autoPlayGif) {
+ return;
+ }
+
+ const emojis = currentTarget.querySelectorAll('.custom-emoji');
+
+ for (var i = 0; i < emojis.length; i++) {
+ let emoji = emojis[i];
+ emoji.src = emoji.getAttribute('data-original');
+ }
+ };
+
+ handleMouseLeave = ({ currentTarget }) => {
+ if (autoPlayGif) {
+ return;
+ }
+
+ const emojis = currentTarget.querySelectorAll('.custom-emoji');
+
+ for (var i = 0; i < emojis.length; i++) {
+ let emoji = emojis[i];
+ emoji.src = emoji.getAttribute('data-static');
+ }
+ };
+
+ handleAvatarClick = e => {
+ if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ this.props.onOpenAvatar();
+ }
+ };
+
+ handleShare = () => {
+ const { account } = this.props;
+
+ navigator.share({
+ text: `${titleFromAccount(account)}\n${account.get('note_plain')}`,
+ url: account.get('url'),
+ }).catch((e) => {
+ if (e.name !== 'AbortError') console.error(e);
+ });
+ };
+
+ render () {
+ const { account, hidden, intl, domain } = this.props;
+ const { signedIn, permissions } = this.context.identity;
+
+ if (!account) {
+ return null;
+ }
+
+ const suspended = account.get('suspended');
+ const isRemote = account.get('acct') !== account.get('username');
+ const remoteDomain = isRemote ? account.get('acct').split('@')[1] : null;
+
+ let info = [];
+ let actionBtn = '';
+ let bellBtn = '';
+ let lockedIcon = '';
+ let menu = [];
+
+ if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
+ info.push( );
+ } else if (me !== account.get('id') && account.getIn(['relationship', 'blocking'])) {
+ info.push( );
+ }
+
+ if (me !== account.get('id') && account.getIn(['relationship', 'muting'])) {
+ info.push( );
+ } else if (me !== account.get('id') && account.getIn(['relationship', 'domain_blocking'])) {
+ info.push( );
+ }
+
+ if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) {
+ bellBtn = ;
+ }
+
+ if (me !== account.get('id')) {
+ if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded
+ actionBtn = '';
+ } else if (account.getIn(['relationship', 'requested'])) {
+ actionBtn = ;
+ } else if (!account.getIn(['relationship', 'blocking'])) {
+ actionBtn = ;
+ } else if (account.getIn(['relationship', 'blocking'])) {
+ actionBtn = ;
+ }
+ } else {
+ actionBtn = ;
+ }
+
+ if (account.get('moved') && !account.getIn(['relationship', 'following'])) {
+ actionBtn = '';
+ }
+
+ if (account.get('locked')) {
+ lockedIcon = ;
+ }
+
+ if (signedIn && account.get('id') !== me) {
+ menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
+ menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.props.onDirect });
+ menu.push(null);
+ }
+
+ if (isRemote) {
+ menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: account.get('url') });
+ menu.push(null);
+ }
+
+ if ('share' in navigator) {
+ menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare });
+ menu.push(null);
+ }
+
+ if (account.get('id') === me) {
+ menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
+ menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
+ menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
+ menu.push(null);
+ menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
+ menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
+ menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
+ menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
+ menu.push(null);
+ menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
+ menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
+ menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
+ } else if (signedIn) {
+ if (account.getIn(['relationship', 'following'])) {
+ if (!account.getIn(['relationship', 'muting'])) {
+ if (account.getIn(['relationship', 'showing_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 });
+ }
+
+ menu.push({ text: intl.formatMessage(messages.languages), action: this.props.onChangeLanguages });
+ menu.push(null);
+ }
+
+ menu.push({ text: intl.formatMessage(account.getIn(['relationship', 'endorsed']) ? messages.unendorse : messages.endorse), action: this.props.onEndorseToggle });
+ menu.push({ text: intl.formatMessage(messages.add_or_remove_from_list), action: this.props.onAddToList });
+ menu.push(null);
+ }
+
+ 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 (signedIn && isRemote) {
+ menu.push(null);
+
+ if (account.getIn(['relationship', 'domain_blocking'])) {
+ menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain: remoteDomain }), action: this.props.onUnblockDomain });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.blockDomain, { domain: remoteDomain }), action: this.props.onBlockDomain });
+ }
+ }
+
+ if ((account.get('id') !== me && (permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
+ menu.push(null);
+ if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
+ menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${account.get('id')}` });
+ }
+ if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) {
+ menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: remoteDomain }), href: `/admin/instances/${remoteDomain}` });
+ }
+ }
+
+ const content = { __html: account.get('note_emojified') };
+ const displayNameHtml = { __html: account.get('display_name_html') };
+ const fields = account.get('fields');
+ const isLocal = account.get('acct').indexOf('@') === -1;
+ const acct = isLocal && domain ? `${account.get('acct')}@${domain}` : account.get('acct');
+ const isIndexable = !account.get('noindex');
+
+ let badge;
+
+ if (account.get('bot')) {
+ badge = (
);
+ } else if (account.get('group')) {
+ badge = (
);
+ } else {
+ badge = null;
+ }
+
+ return (
+
+ {!(suspended || hidden || account.get('moved')) && account.getIn(['relationship', 'requested_by']) &&
}
+
+
+
+ {!suspended && info}
+
+
+ {!(suspended || hidden) &&
}
+
+
+
+
+
+
+
+
+ {!suspended && (
+
+ {!hidden && (
+
+ {actionBtn}
+ {bellBtn}
+
+ )}
+
+
+
+ )}
+
+
+
+
+ {badge}
+
+ @{acct} {lockedIcon}
+
+
+
+
+ {!(suspended || hidden) && (
+
+
+ {(account.get('id') !== me && signedIn) &&
}
+
+ {account.get('note').length > 0 && account.get('note') !== '
' &&
}
+
+
+
+
+ {intl.formatDate(account.get('created_at'), { year: 'numeric', month: 'short', day: '2-digit' })}
+
+
+ {fields.map((pair, i) => (
+
+
+
+
+ {pair.get('verified_at') && }
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+ {titleFromAccount(account)}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/account/navigation.js b/app/javascript/mastodon/features/account/navigation.js
deleted file mode 100644
index eb9ff9a95..000000000
--- a/app/javascript/mastodon/features/account/navigation.js
+++ /dev/null
@@ -1,52 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { connect } from 'react-redux';
-import FeaturedTags from 'mastodon/features/account/containers/featured_tags_container';
-import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
-
-const mapStateToProps = (state, { match: { params: { acct } } }) => {
- const accountId = state.getIn(['accounts_map', normalizeForLookup(acct)]);
-
- if (!accountId) {
- return {
- isLoading: true,
- };
- }
-
- return {
- accountId,
- isLoading: false,
- };
-};
-
-export default @connect(mapStateToProps)
-class AccountNavigation extends React.PureComponent {
-
- static propTypes = {
- match: PropTypes.shape({
- params: PropTypes.shape({
- acct: PropTypes.string,
- tagged: PropTypes.string,
- }).isRequired,
- }).isRequired,
-
- accountId: PropTypes.string,
- isLoading: PropTypes.bool,
- };
-
- render () {
- const { accountId, isLoading, match: { params: { tagged } } } = this.props;
-
- if (isLoading) {
- return null;
- }
-
- return (
- <>
-
-
- >
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/account/navigation.jsx b/app/javascript/mastodon/features/account/navigation.jsx
new file mode 100644
index 000000000..eb9ff9a95
--- /dev/null
+++ b/app/javascript/mastodon/features/account/navigation.jsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import FeaturedTags from 'mastodon/features/account/containers/featured_tags_container';
+import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
+
+const mapStateToProps = (state, { match: { params: { acct } } }) => {
+ const accountId = state.getIn(['accounts_map', normalizeForLookup(acct)]);
+
+ if (!accountId) {
+ return {
+ isLoading: true,
+ };
+ }
+
+ return {
+ accountId,
+ isLoading: false,
+ };
+};
+
+export default @connect(mapStateToProps)
+class AccountNavigation extends React.PureComponent {
+
+ static propTypes = {
+ match: PropTypes.shape({
+ params: PropTypes.shape({
+ acct: PropTypes.string,
+ tagged: PropTypes.string,
+ }).isRequired,
+ }).isRequired,
+
+ accountId: PropTypes.string,
+ isLoading: PropTypes.bool,
+ };
+
+ render () {
+ const { accountId, isLoading, match: { params: { tagged } } } = this.props;
+
+ if (isLoading) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.js b/app/javascript/mastodon/features/account_gallery/components/media_item.js
deleted file mode 100644
index d6d60ebda..000000000
--- a/app/javascript/mastodon/features/account_gallery/components/media_item.js
+++ /dev/null
@@ -1,146 +0,0 @@
-import Blurhash from 'mastodon/components/blurhash';
-import classNames from 'classnames';
-import Icon from 'mastodon/components/icon';
-import { autoPlayGif, displayMedia, useBlurhash } from 'mastodon/initial_state';
-import PropTypes from 'prop-types';
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-export default class MediaItem extends ImmutablePureComponent {
-
- static propTypes = {
- attachment: ImmutablePropTypes.map.isRequired,
- displayWidth: PropTypes.number.isRequired,
- onOpenMedia: PropTypes.func.isRequired,
- };
-
- state = {
- visible: displayMedia !== 'hide_all' && !this.props.attachment.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
- loaded: false,
- };
-
- handleImageLoad = () => {
- this.setState({ loaded: true });
- };
-
- handleMouseEnter = e => {
- if (this.hoverToPlay()) {
- e.target.play();
- }
- };
-
- handleMouseLeave = e => {
- if (this.hoverToPlay()) {
- e.target.pause();
- e.target.currentTime = 0;
- }
- };
-
- hoverToPlay () {
- return !autoPlayGif && ['gifv', 'video'].indexOf(this.props.attachment.get('type')) !== -1;
- }
-
- handleClick = e => {
- if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
- e.preventDefault();
-
- if (this.state.visible) {
- this.props.onOpenMedia(this.props.attachment);
- } else {
- this.setState({ visible: true });
- }
- }
- };
-
- render () {
- const { attachment, displayWidth } = this.props;
- const { visible, loaded } = this.state;
-
- const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
- const height = width;
- const status = attachment.get('status');
- const title = status.get('spoiler_text') || attachment.get('description');
-
- let thumbnail, label, icon, content;
-
- if (!visible) {
- icon = (
-
-
-
- );
- } else {
- if (['audio', 'video'].includes(attachment.get('type'))) {
- content = (
-
- );
-
- if (attachment.get('type') === 'audio') {
- label = ;
- } else {
- label = ;
- }
- } else if (attachment.get('type') === 'image') {
- const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
- const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
- const x = ((focusX / 2) + .5) * 100;
- const y = ((focusY / -2) + .5) * 100;
-
- content = (
-
- );
- } else if (attachment.get('type') === 'gifv') {
- content = (
-
- );
-
- label = 'GIF';
- }
-
- thumbnail = (
-
- {content}
-
- {label && {label} }
-
- );
- }
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.jsx b/app/javascript/mastodon/features/account_gallery/components/media_item.jsx
new file mode 100644
index 000000000..d6d60ebda
--- /dev/null
+++ b/app/javascript/mastodon/features/account_gallery/components/media_item.jsx
@@ -0,0 +1,146 @@
+import Blurhash from 'mastodon/components/blurhash';
+import classNames from 'classnames';
+import Icon from 'mastodon/components/icon';
+import { autoPlayGif, displayMedia, useBlurhash } from 'mastodon/initial_state';
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class MediaItem extends ImmutablePureComponent {
+
+ static propTypes = {
+ attachment: ImmutablePropTypes.map.isRequired,
+ displayWidth: PropTypes.number.isRequired,
+ onOpenMedia: PropTypes.func.isRequired,
+ };
+
+ state = {
+ visible: displayMedia !== 'hide_all' && !this.props.attachment.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
+ loaded: false,
+ };
+
+ handleImageLoad = () => {
+ this.setState({ loaded: true });
+ };
+
+ handleMouseEnter = e => {
+ if (this.hoverToPlay()) {
+ e.target.play();
+ }
+ };
+
+ handleMouseLeave = e => {
+ if (this.hoverToPlay()) {
+ e.target.pause();
+ e.target.currentTime = 0;
+ }
+ };
+
+ hoverToPlay () {
+ return !autoPlayGif && ['gifv', 'video'].indexOf(this.props.attachment.get('type')) !== -1;
+ }
+
+ handleClick = e => {
+ if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+
+ if (this.state.visible) {
+ this.props.onOpenMedia(this.props.attachment);
+ } else {
+ this.setState({ visible: true });
+ }
+ }
+ };
+
+ render () {
+ const { attachment, displayWidth } = this.props;
+ const { visible, loaded } = this.state;
+
+ const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
+ const height = width;
+ const status = attachment.get('status');
+ const title = status.get('spoiler_text') || attachment.get('description');
+
+ let thumbnail, label, icon, content;
+
+ if (!visible) {
+ icon = (
+
+
+
+ );
+ } else {
+ if (['audio', 'video'].includes(attachment.get('type'))) {
+ content = (
+
+ );
+
+ if (attachment.get('type') === 'audio') {
+ label = ;
+ } else {
+ label = ;
+ }
+ } else if (attachment.get('type') === 'image') {
+ const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
+ const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
+ const x = ((focusX / 2) + .5) * 100;
+ const y = ((focusY / -2) + .5) * 100;
+
+ content = (
+
+ );
+ } else if (attachment.get('type') === 'gifv') {
+ content = (
+
+ );
+
+ label = 'GIF';
+ }
+
+ thumbnail = (
+
+ {content}
+
+ {label && {label} }
+
+ );
+ }
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js
deleted file mode 100644
index 3942b57cb..000000000
--- a/app/javascript/mastodon/features/account_gallery/index.js
+++ /dev/null
@@ -1,228 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import { lookupAccount, fetchAccount } from 'mastodon/actions/accounts';
-import { expandAccountMediaTimeline } from '../../actions/timelines';
-import LoadingIndicator from 'mastodon/components/loading_indicator';
-import Column from '../ui/components/column';
-import ColumnBackButton from 'mastodon/components/column_back_button';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { getAccountGallery } from 'mastodon/selectors';
-import MediaItem from './components/media_item';
-import HeaderContainer from '../account_timeline/containers/header_container';
-import ScrollContainer from 'mastodon/containers/scroll_container';
-import LoadMore from 'mastodon/components/load_more';
-import MissingIndicator from 'mastodon/components/missing_indicator';
-import { openModal } from 'mastodon/actions/modal';
-import { FormattedMessage } from 'react-intl';
-import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
-
-const mapStateToProps = (state, { params: { acct, id } }) => {
- const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
-
- if (!accountId) {
- return {
- isLoading: true,
- };
- }
-
- return {
- accountId,
- isAccount: !!state.getIn(['accounts', accountId]),
- attachments: getAccountGallery(state, accountId),
- isLoading: state.getIn(['timelines', `account:${accountId}:media`, 'isLoading']),
- hasMore: state.getIn(['timelines', `account:${accountId}:media`, 'hasMore']),
- suspended: state.getIn(['accounts', accountId, 'suspended'], false),
- blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
- };
-};
-
-class LoadMoreMedia extends ImmutablePureComponent {
-
- static propTypes = {
- maxId: PropTypes.string,
- onLoadMore: PropTypes.func.isRequired,
- };
-
- handleLoadMore = () => {
- this.props.onLoadMore(this.props.maxId);
- };
-
- render () {
- return (
-
- );
- }
-
-}
-
-export default @connect(mapStateToProps)
-class AccountGallery extends ImmutablePureComponent {
-
- static propTypes = {
- params: PropTypes.shape({
- acct: PropTypes.string,
- id: PropTypes.string,
- }).isRequired,
- accountId: PropTypes.string,
- dispatch: PropTypes.func.isRequired,
- attachments: ImmutablePropTypes.list.isRequired,
- isLoading: PropTypes.bool,
- hasMore: PropTypes.bool,
- isAccount: PropTypes.bool,
- blockedBy: PropTypes.bool,
- suspended: PropTypes.bool,
- multiColumn: PropTypes.bool,
- };
-
- state = {
- width: 323,
- };
-
- _load () {
- const { accountId, isAccount, dispatch } = this.props;
-
- if (!isAccount) dispatch(fetchAccount(accountId));
- dispatch(expandAccountMediaTimeline(accountId));
- }
-
- componentDidMount () {
- const { params: { acct }, accountId, dispatch } = this.props;
-
- if (accountId) {
- this._load();
- } else {
- dispatch(lookupAccount(acct));
- }
- }
-
- componentDidUpdate (prevProps) {
- const { params: { acct }, accountId, dispatch } = this.props;
-
- if (prevProps.accountId !== accountId && accountId) {
- this._load();
- } else if (prevProps.params.acct !== acct) {
- dispatch(lookupAccount(acct));
- }
- }
-
- handleScrollToBottom = () => {
- if (this.props.hasMore) {
- this.handleLoadMore(this.props.attachments.size > 0 ? this.props.attachments.last().getIn(['status', 'id']) : undefined);
- }
- };
-
- handleScroll = e => {
- const { scrollTop, scrollHeight, clientHeight } = e.target;
- const offset = scrollHeight - scrollTop - clientHeight;
-
- if (150 > offset && !this.props.isLoading) {
- this.handleScrollToBottom();
- }
- };
-
- handleLoadMore = maxId => {
- this.props.dispatch(expandAccountMediaTimeline(this.props.accountId, { maxId }));
- };
-
- handleLoadOlder = e => {
- e.preventDefault();
- this.handleScrollToBottom();
- };
-
- handleOpenMedia = attachment => {
- const { dispatch } = this.props;
- const statusId = attachment.getIn(['status', 'id']);
-
- if (attachment.get('type') === 'video') {
- dispatch(openModal('VIDEO', { media: attachment, statusId, options: { autoPlay: true } }));
- } else if (attachment.get('type') === 'audio') {
- dispatch(openModal('AUDIO', { media: attachment, statusId, options: { autoPlay: true } }));
- } else {
- const media = attachment.getIn(['status', 'media_attachments']);
- const index = media.findIndex(x => x.get('id') === attachment.get('id'));
-
- dispatch(openModal('MEDIA', { media, index, statusId }));
- }
- };
-
- handleRef = c => {
- if (c) {
- this.setState({ width: c.offsetWidth });
- }
- };
-
- render () {
- const { attachments, isLoading, hasMore, isAccount, multiColumn, blockedBy, suspended } = this.props;
- const { width } = this.state;
-
- if (!isAccount) {
- return (
-
-
-
- );
- }
-
- if (!attachments && isLoading) {
- return (
-
-
-
- );
- }
-
- let loadOlder = null;
-
- if (hasMore && !(isLoading && attachments.size === 0)) {
- loadOlder = ;
- }
-
- let emptyMessage;
-
- if (suspended) {
- emptyMessage = ;
- } else if (blockedBy) {
- emptyMessage = ;
- }
-
- return (
-
-
-
-
-
-
-
- {(suspended || blockedBy) ? (
-
- {emptyMessage}
-
- ) : (
-
- {attachments.map((attachment, index) => attachment === null ? (
- 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
- ) : (
-
- ))}
-
- {loadOlder}
-
- )}
-
- {isLoading && attachments.size === 0 && (
-
-
-
- )}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/account_gallery/index.jsx b/app/javascript/mastodon/features/account_gallery/index.jsx
new file mode 100644
index 000000000..3942b57cb
--- /dev/null
+++ b/app/javascript/mastodon/features/account_gallery/index.jsx
@@ -0,0 +1,228 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { lookupAccount, fetchAccount } from 'mastodon/actions/accounts';
+import { expandAccountMediaTimeline } from '../../actions/timelines';
+import LoadingIndicator from 'mastodon/components/loading_indicator';
+import Column from '../ui/components/column';
+import ColumnBackButton from 'mastodon/components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { getAccountGallery } from 'mastodon/selectors';
+import MediaItem from './components/media_item';
+import HeaderContainer from '../account_timeline/containers/header_container';
+import ScrollContainer from 'mastodon/containers/scroll_container';
+import LoadMore from 'mastodon/components/load_more';
+import MissingIndicator from 'mastodon/components/missing_indicator';
+import { openModal } from 'mastodon/actions/modal';
+import { FormattedMessage } from 'react-intl';
+import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
+
+const mapStateToProps = (state, { params: { acct, id } }) => {
+ const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
+
+ if (!accountId) {
+ return {
+ isLoading: true,
+ };
+ }
+
+ return {
+ accountId,
+ isAccount: !!state.getIn(['accounts', accountId]),
+ attachments: getAccountGallery(state, accountId),
+ isLoading: state.getIn(['timelines', `account:${accountId}:media`, 'isLoading']),
+ hasMore: state.getIn(['timelines', `account:${accountId}:media`, 'hasMore']),
+ suspended: state.getIn(['accounts', accountId, 'suspended'], false),
+ blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
+ };
+};
+
+class LoadMoreMedia extends ImmutablePureComponent {
+
+ static propTypes = {
+ maxId: PropTypes.string,
+ onLoadMore: PropTypes.func.isRequired,
+ };
+
+ handleLoadMore = () => {
+ this.props.onLoadMore(this.props.maxId);
+ };
+
+ render () {
+ return (
+
+ );
+ }
+
+}
+
+export default @connect(mapStateToProps)
+class AccountGallery extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.shape({
+ acct: PropTypes.string,
+ id: PropTypes.string,
+ }).isRequired,
+ accountId: PropTypes.string,
+ dispatch: PropTypes.func.isRequired,
+ attachments: ImmutablePropTypes.list.isRequired,
+ isLoading: PropTypes.bool,
+ hasMore: PropTypes.bool,
+ isAccount: PropTypes.bool,
+ blockedBy: PropTypes.bool,
+ suspended: PropTypes.bool,
+ multiColumn: PropTypes.bool,
+ };
+
+ state = {
+ width: 323,
+ };
+
+ _load () {
+ const { accountId, isAccount, dispatch } = this.props;
+
+ if (!isAccount) dispatch(fetchAccount(accountId));
+ dispatch(expandAccountMediaTimeline(accountId));
+ }
+
+ componentDidMount () {
+ const { params: { acct }, accountId, dispatch } = this.props;
+
+ if (accountId) {
+ this._load();
+ } else {
+ dispatch(lookupAccount(acct));
+ }
+ }
+
+ componentDidUpdate (prevProps) {
+ const { params: { acct }, accountId, dispatch } = this.props;
+
+ if (prevProps.accountId !== accountId && accountId) {
+ this._load();
+ } else if (prevProps.params.acct !== acct) {
+ dispatch(lookupAccount(acct));
+ }
+ }
+
+ handleScrollToBottom = () => {
+ if (this.props.hasMore) {
+ this.handleLoadMore(this.props.attachments.size > 0 ? this.props.attachments.last().getIn(['status', 'id']) : undefined);
+ }
+ };
+
+ handleScroll = e => {
+ const { scrollTop, scrollHeight, clientHeight } = e.target;
+ const offset = scrollHeight - scrollTop - clientHeight;
+
+ if (150 > offset && !this.props.isLoading) {
+ this.handleScrollToBottom();
+ }
+ };
+
+ handleLoadMore = maxId => {
+ this.props.dispatch(expandAccountMediaTimeline(this.props.accountId, { maxId }));
+ };
+
+ handleLoadOlder = e => {
+ e.preventDefault();
+ this.handleScrollToBottom();
+ };
+
+ handleOpenMedia = attachment => {
+ const { dispatch } = this.props;
+ const statusId = attachment.getIn(['status', 'id']);
+
+ if (attachment.get('type') === 'video') {
+ dispatch(openModal('VIDEO', { media: attachment, statusId, options: { autoPlay: true } }));
+ } else if (attachment.get('type') === 'audio') {
+ dispatch(openModal('AUDIO', { media: attachment, statusId, options: { autoPlay: true } }));
+ } else {
+ const media = attachment.getIn(['status', 'media_attachments']);
+ const index = media.findIndex(x => x.get('id') === attachment.get('id'));
+
+ dispatch(openModal('MEDIA', { media, index, statusId }));
+ }
+ };
+
+ handleRef = c => {
+ if (c) {
+ this.setState({ width: c.offsetWidth });
+ }
+ };
+
+ render () {
+ const { attachments, isLoading, hasMore, isAccount, multiColumn, blockedBy, suspended } = this.props;
+ const { width } = this.state;
+
+ if (!isAccount) {
+ return (
+
+
+
+ );
+ }
+
+ if (!attachments && isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ let loadOlder = null;
+
+ if (hasMore && !(isLoading && attachments.size === 0)) {
+ loadOlder = ;
+ }
+
+ let emptyMessage;
+
+ if (suspended) {
+ emptyMessage = ;
+ } else if (blockedBy) {
+ emptyMessage = ;
+ }
+
+ return (
+
+
+
+
+
+
+
+ {(suspended || blockedBy) ? (
+
+ {emptyMessage}
+
+ ) : (
+
+ {attachments.map((attachment, index) => attachment === null ? (
+ 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
+ ) : (
+
+ ))}
+
+ {loadOlder}
+
+ )}
+
+ {isLoading && attachments.size === 0 && (
+
+
+
+ )}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js
deleted file mode 100644
index bffa5554b..000000000
--- a/app/javascript/mastodon/features/account_timeline/components/header.js
+++ /dev/null
@@ -1,153 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import InnerHeader from '../../account/components/header';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import MovedNote from './moved_note';
-import { FormattedMessage } from 'react-intl';
-import { NavLink } from 'react-router-dom';
-
-export default class Header extends ImmutablePureComponent {
-
- static propTypes = {
- account: ImmutablePropTypes.map,
- onFollow: PropTypes.func.isRequired,
- onBlock: PropTypes.func.isRequired,
- onMention: PropTypes.func.isRequired,
- onDirect: PropTypes.func.isRequired,
- onReblogToggle: PropTypes.func.isRequired,
- onReport: PropTypes.func.isRequired,
- onMute: PropTypes.func.isRequired,
- onBlockDomain: PropTypes.func.isRequired,
- onUnblockDomain: PropTypes.func.isRequired,
- onEndorseToggle: PropTypes.func.isRequired,
- onAddToList: PropTypes.func.isRequired,
- onChangeLanguages: PropTypes.func.isRequired,
- onInteractionModal: PropTypes.func.isRequired,
- onOpenAvatar: PropTypes.func.isRequired,
- hideTabs: PropTypes.bool,
- domain: PropTypes.string.isRequired,
- hidden: PropTypes.bool,
- };
-
- 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);
- };
-
- handleDirect = () => {
- this.props.onDirect(this.props.account, this.context.router.history);
- };
-
- handleReport = () => {
- this.props.onReport(this.props.account);
- };
-
- handleReblogToggle = () => {
- this.props.onReblogToggle(this.props.account);
- };
-
- handleNotifyToggle = () => {
- this.props.onNotifyToggle(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);
- };
-
- handleUnblockDomain = () => {
- const domain = this.props.account.get('acct').split('@')[1];
-
- if (!domain) return;
-
- this.props.onUnblockDomain(domain);
- };
-
- handleEndorseToggle = () => {
- this.props.onEndorseToggle(this.props.account);
- };
-
- handleAddToList = () => {
- this.props.onAddToList(this.props.account);
- };
-
- handleEditAccountNote = () => {
- this.props.onEditAccountNote(this.props.account);
- };
-
- handleChangeLanguages = () => {
- this.props.onChangeLanguages(this.props.account);
- };
-
- handleInteractionModal = () => {
- this.props.onInteractionModal(this.props.account);
- };
-
- handleOpenAvatar = () => {
- this.props.onOpenAvatar(this.props.account);
- };
-
- render () {
- const { account, hidden, hideTabs } = this.props;
-
- if (account === null) {
- return null;
- }
-
- return (
-
- {(!hidden && account.get('moved')) &&
}
-
-
-
- {!(hideTabs || hidden) && (
-
-
-
-
-
- )}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.jsx b/app/javascript/mastodon/features/account_timeline/components/header.jsx
new file mode 100644
index 000000000..bffa5554b
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/components/header.jsx
@@ -0,0 +1,153 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import InnerHeader from '../../account/components/header';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import MovedNote from './moved_note';
+import { FormattedMessage } from 'react-intl';
+import { NavLink } from 'react-router-dom';
+
+export default class Header extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map,
+ onFollow: PropTypes.func.isRequired,
+ onBlock: PropTypes.func.isRequired,
+ onMention: PropTypes.func.isRequired,
+ onDirect: PropTypes.func.isRequired,
+ onReblogToggle: PropTypes.func.isRequired,
+ onReport: PropTypes.func.isRequired,
+ onMute: PropTypes.func.isRequired,
+ onBlockDomain: PropTypes.func.isRequired,
+ onUnblockDomain: PropTypes.func.isRequired,
+ onEndorseToggle: PropTypes.func.isRequired,
+ onAddToList: PropTypes.func.isRequired,
+ onChangeLanguages: PropTypes.func.isRequired,
+ onInteractionModal: PropTypes.func.isRequired,
+ onOpenAvatar: PropTypes.func.isRequired,
+ hideTabs: PropTypes.bool,
+ domain: PropTypes.string.isRequired,
+ hidden: PropTypes.bool,
+ };
+
+ 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);
+ };
+
+ handleDirect = () => {
+ this.props.onDirect(this.props.account, this.context.router.history);
+ };
+
+ handleReport = () => {
+ this.props.onReport(this.props.account);
+ };
+
+ handleReblogToggle = () => {
+ this.props.onReblogToggle(this.props.account);
+ };
+
+ handleNotifyToggle = () => {
+ this.props.onNotifyToggle(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);
+ };
+
+ handleUnblockDomain = () => {
+ const domain = this.props.account.get('acct').split('@')[1];
+
+ if (!domain) return;
+
+ this.props.onUnblockDomain(domain);
+ };
+
+ handleEndorseToggle = () => {
+ this.props.onEndorseToggle(this.props.account);
+ };
+
+ handleAddToList = () => {
+ this.props.onAddToList(this.props.account);
+ };
+
+ handleEditAccountNote = () => {
+ this.props.onEditAccountNote(this.props.account);
+ };
+
+ handleChangeLanguages = () => {
+ this.props.onChangeLanguages(this.props.account);
+ };
+
+ handleInteractionModal = () => {
+ this.props.onInteractionModal(this.props.account);
+ };
+
+ handleOpenAvatar = () => {
+ this.props.onOpenAvatar(this.props.account);
+ };
+
+ render () {
+ const { account, hidden, hideTabs } = this.props;
+
+ if (account === null) {
+ return null;
+ }
+
+ return (
+
+ {(!hidden && account.get('moved')) &&
}
+
+
+
+ {!(hideTabs || hidden) && (
+
+
+
+
+
+ )}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/account_timeline/components/limited_account_hint.js b/app/javascript/mastodon/features/account_timeline/components/limited_account_hint.js
deleted file mode 100644
index 9ee347bb5..000000000
--- a/app/javascript/mastodon/features/account_timeline/components/limited_account_hint.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { connect } from 'react-redux';
-import { revealAccount } from 'mastodon/actions/accounts';
-import { FormattedMessage } from 'react-intl';
-import Button from 'mastodon/components/button';
-import { domain } from 'mastodon/initial_state';
-
-const mapDispatchToProps = (dispatch, { accountId }) => ({
-
- reveal () {
- dispatch(revealAccount(accountId));
- },
-
-});
-
-export default @connect(() => {}, mapDispatchToProps)
-class LimitedAccountHint extends React.PureComponent {
-
- static propTypes = {
- accountId: PropTypes.string.isRequired,
- reveal: PropTypes.func,
- };
-
- render () {
- const { reveal } = this.props;
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/account_timeline/components/limited_account_hint.jsx b/app/javascript/mastodon/features/account_timeline/components/limited_account_hint.jsx
new file mode 100644
index 000000000..9ee347bb5
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/components/limited_account_hint.jsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { revealAccount } from 'mastodon/actions/accounts';
+import { FormattedMessage } from 'react-intl';
+import Button from 'mastodon/components/button';
+import { domain } from 'mastodon/initial_state';
+
+const mapDispatchToProps = (dispatch, { accountId }) => ({
+
+ reveal () {
+ dispatch(revealAccount(accountId));
+ },
+
+});
+
+export default @connect(() => {}, mapDispatchToProps)
+class LimitedAccountHint extends React.PureComponent {
+
+ static propTypes = {
+ accountId: PropTypes.string.isRequired,
+ reveal: PropTypes.func,
+ };
+
+ render () {
+ const { reveal } = this.props;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/account_timeline/components/moved_note.js b/app/javascript/mastodon/features/account_timeline/components/moved_note.js
deleted file mode 100644
index daff47a9d..000000000
--- a/app/javascript/mastodon/features/account_timeline/components/moved_note.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { FormattedMessage } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import AvatarOverlay from '../../../components/avatar_overlay';
-import DisplayName from '../../../components/display_name';
-import { Link } from 'react-router-dom';
-
-export default class MovedNote extends ImmutablePureComponent {
-
- static propTypes = {
- from: ImmutablePropTypes.map.isRequired,
- to: ImmutablePropTypes.map.isRequired,
- };
-
- render () {
- const { from, to } = this.props;
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/account_timeline/components/moved_note.jsx b/app/javascript/mastodon/features/account_timeline/components/moved_note.jsx
new file mode 100644
index 000000000..daff47a9d
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/components/moved_note.jsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import AvatarOverlay from '../../../components/avatar_overlay';
+import DisplayName from '../../../components/display_name';
+import { Link } from 'react-router-dom';
+
+export default class MovedNote extends ImmutablePureComponent {
+
+ static propTypes = {
+ from: ImmutablePropTypes.map.isRequired,
+ to: ImmutablePropTypes.map.isRequired,
+ };
+
+ render () {
+ const { from, to } = this.props;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
deleted file mode 100644
index f53cd2480..000000000
--- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js
+++ /dev/null
@@ -1,164 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import { makeGetAccount, getAccountHidden } from '../../../selectors';
-import Header from '../components/header';
-import {
- followAccount,
- unfollowAccount,
- unblockAccount,
- unmuteAccount,
- pinAccount,
- unpinAccount,
-} from '../../../actions/accounts';
-import {
- mentionCompose,
- directCompose,
-} from '../../../actions/compose';
-import { initMuteModal } from '../../../actions/mutes';
-import { initBlockModal } from '../../../actions/blocks';
-import { initReport } from '../../../actions/reports';
-import { openModal } from '../../../actions/modal';
-import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import { unfollowModal } from '../../../initial_state';
-
-const messages = defineMessages({
- cancelFollowRequestConfirm: { id: 'confirmations.cancel_follow_request.confirm', defaultMessage: 'Withdraw request' },
- unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
- blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
-});
-
-const makeMapStateToProps = () => {
- const getAccount = makeGetAccount();
-
- const mapStateToProps = (state, { accountId }) => ({
- account: getAccount(state, accountId),
- domain: state.getIn(['meta', 'domain']),
- hidden: getAccountHidden(state, accountId),
- });
-
- return mapStateToProps;
-};
-
-const mapDispatchToProps = (dispatch, { intl }) => ({
-
- onFollow (account) {
- if (account.getIn(['relationship', 'following'])) {
- 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 if (account.getIn(['relationship', 'requested'])) {
- if (unfollowModal) {
- dispatch(openModal('CONFIRM', {
- message: @{account.get('acct')} }} />,
- confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
- onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
- }));
- } else {
- dispatch(unfollowAccount(account.get('id')));
- }
- } else {
- dispatch(followAccount(account.get('id')));
- }
- },
-
- onInteractionModal (account) {
- dispatch(openModal('INTERACTION', {
- type: 'follow',
- accountId: account.get('id'),
- url: account.get('url'),
- }));
- },
-
- onBlock (account) {
- if (account.getIn(['relationship', 'blocking'])) {
- dispatch(unblockAccount(account.get('id')));
- } else {
- dispatch(initBlockModal(account));
- }
- },
-
- onMention (account, router) {
- dispatch(mentionCompose(account, router));
- },
-
- onDirect (account, router) {
- dispatch(directCompose(account, router));
- },
-
- onReblogToggle (account) {
- if (account.getIn(['relationship', 'showing_reblogs'])) {
- dispatch(followAccount(account.get('id'), { reblogs: false }));
- } else {
- dispatch(followAccount(account.get('id'), { reblogs: true }));
- }
- },
-
- onEndorseToggle (account) {
- if (account.getIn(['relationship', 'endorsed'])) {
- dispatch(unpinAccount(account.get('id')));
- } else {
- dispatch(pinAccount(account.get('id')));
- }
- },
-
- onNotifyToggle (account) {
- if (account.getIn(['relationship', 'notifying'])) {
- dispatch(followAccount(account.get('id'), { notify: false }));
- } else {
- dispatch(followAccount(account.get('id'), { notify: true }));
- }
- },
-
- onReport (account) {
- dispatch(initReport(account));
- },
-
- onMute (account) {
- if (account.getIn(['relationship', 'muting'])) {
- dispatch(unmuteAccount(account.get('id')));
- } else {
- dispatch(initMuteModal(account));
- }
- },
-
- onBlockDomain (domain) {
- dispatch(openModal('CONFIRM', {
- message: {domain} }} />,
- confirm: intl.formatMessage(messages.blockDomainConfirm),
- onConfirm: () => dispatch(blockDomain(domain)),
- }));
- },
-
- onUnblockDomain (domain) {
- dispatch(unblockDomain(domain));
- },
-
- onAddToList (account) {
- dispatch(openModal('LIST_ADDER', {
- accountId: account.get('id'),
- }));
- },
-
- onChangeLanguages (account) {
- dispatch(openModal('SUBSCRIBED_LANGUAGES', {
- accountId: account.get('id'),
- }));
- },
-
- onOpenAvatar (account) {
- dispatch(openModal('IMAGE', {
- src: account.get('avatar'),
- alt: account.get('acct'),
- }));
- },
-
-});
-
-export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));
diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx b/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx
new file mode 100644
index 000000000..f53cd2480
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx
@@ -0,0 +1,164 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { makeGetAccount, getAccountHidden } from '../../../selectors';
+import Header from '../components/header';
+import {
+ followAccount,
+ unfollowAccount,
+ unblockAccount,
+ unmuteAccount,
+ pinAccount,
+ unpinAccount,
+} from '../../../actions/accounts';
+import {
+ mentionCompose,
+ directCompose,
+} from '../../../actions/compose';
+import { initMuteModal } from '../../../actions/mutes';
+import { initBlockModal } from '../../../actions/blocks';
+import { initReport } from '../../../actions/reports';
+import { openModal } from '../../../actions/modal';
+import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { unfollowModal } from '../../../initial_state';
+
+const messages = defineMessages({
+ cancelFollowRequestConfirm: { id: 'confirmations.cancel_follow_request.confirm', defaultMessage: 'Withdraw request' },
+ unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
+ blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
+});
+
+const makeMapStateToProps = () => {
+ const getAccount = makeGetAccount();
+
+ const mapStateToProps = (state, { accountId }) => ({
+ account: getAccount(state, accountId),
+ domain: state.getIn(['meta', 'domain']),
+ hidden: getAccountHidden(state, accountId),
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+ onFollow (account) {
+ if (account.getIn(['relationship', 'following'])) {
+ 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 if (account.getIn(['relationship', 'requested'])) {
+ if (unfollowModal) {
+ dispatch(openModal('CONFIRM', {
+ message: @{account.get('acct')} }} />,
+ confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
+ onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
+ }));
+ } else {
+ dispatch(unfollowAccount(account.get('id')));
+ }
+ } else {
+ dispatch(followAccount(account.get('id')));
+ }
+ },
+
+ onInteractionModal (account) {
+ dispatch(openModal('INTERACTION', {
+ type: 'follow',
+ accountId: account.get('id'),
+ url: account.get('url'),
+ }));
+ },
+
+ onBlock (account) {
+ if (account.getIn(['relationship', 'blocking'])) {
+ dispatch(unblockAccount(account.get('id')));
+ } else {
+ dispatch(initBlockModal(account));
+ }
+ },
+
+ onMention (account, router) {
+ dispatch(mentionCompose(account, router));
+ },
+
+ onDirect (account, router) {
+ dispatch(directCompose(account, router));
+ },
+
+ onReblogToggle (account) {
+ if (account.getIn(['relationship', 'showing_reblogs'])) {
+ dispatch(followAccount(account.get('id'), { reblogs: false }));
+ } else {
+ dispatch(followAccount(account.get('id'), { reblogs: true }));
+ }
+ },
+
+ onEndorseToggle (account) {
+ if (account.getIn(['relationship', 'endorsed'])) {
+ dispatch(unpinAccount(account.get('id')));
+ } else {
+ dispatch(pinAccount(account.get('id')));
+ }
+ },
+
+ onNotifyToggle (account) {
+ if (account.getIn(['relationship', 'notifying'])) {
+ dispatch(followAccount(account.get('id'), { notify: false }));
+ } else {
+ dispatch(followAccount(account.get('id'), { notify: true }));
+ }
+ },
+
+ onReport (account) {
+ dispatch(initReport(account));
+ },
+
+ onMute (account) {
+ if (account.getIn(['relationship', 'muting'])) {
+ dispatch(unmuteAccount(account.get('id')));
+ } else {
+ dispatch(initMuteModal(account));
+ }
+ },
+
+ onBlockDomain (domain) {
+ dispatch(openModal('CONFIRM', {
+ message: {domain} }} />,
+ confirm: intl.formatMessage(messages.blockDomainConfirm),
+ onConfirm: () => dispatch(blockDomain(domain)),
+ }));
+ },
+
+ onUnblockDomain (domain) {
+ dispatch(unblockDomain(domain));
+ },
+
+ onAddToList (account) {
+ dispatch(openModal('LIST_ADDER', {
+ accountId: account.get('id'),
+ }));
+ },
+
+ onChangeLanguages (account) {
+ dispatch(openModal('SUBSCRIBED_LANGUAGES', {
+ accountId: account.get('id'),
+ }));
+ },
+
+ onOpenAvatar (account) {
+ dispatch(openModal('IMAGE', {
+ src: account.get('avatar'),
+ alt: account.get('acct'),
+ }));
+ },
+
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));
diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js
deleted file mode 100644
index 337977fde..000000000
--- a/app/javascript/mastodon/features/account_timeline/index.js
+++ /dev/null
@@ -1,208 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import { lookupAccount, fetchAccount } from '../../actions/accounts';
-import { expandAccountFeaturedTimeline, expandAccountTimeline } from '../../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';
-import { FormattedMessage } from 'react-intl';
-import MissingIndicator from 'mastodon/components/missing_indicator';
-import TimelineHint from 'mastodon/components/timeline_hint';
-import { me } from 'mastodon/initial_state';
-import { connectTimeline, disconnectTimeline } from 'mastodon/actions/timelines';
-import LimitedAccountHint from './components/limited_account_hint';
-import { getAccountHidden } from 'mastodon/selectors';
-import { fetchFeaturedTags } from '../../actions/featured_tags';
-import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
-
-const emptyList = ImmutableList();
-
-const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = false }) => {
- const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
-
- if (accountId === null) {
- return {
- isLoading: false,
- isAccount: false,
- statusIds: emptyList,
- };
- } else if (!accountId) {
- return {
- isLoading: true,
- statusIds: emptyList,
- };
- }
-
- const path = withReplies ? `${accountId}:with_replies` : `${accountId}${tagged ? `:${tagged}` : ''}`;
-
- return {
- accountId,
- remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
- remoteUrl: state.getIn(['accounts', accountId, 'url']),
- isAccount: !!state.getIn(['accounts', accountId]),
- statusIds: state.getIn(['timelines', `account:${path}`, 'items'], emptyList),
- featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'], emptyList),
- isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
- hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']),
- suspended: state.getIn(['accounts', accountId, 'suspended'], false),
- hidden: getAccountHidden(state, accountId),
- blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
- };
-};
-
-const RemoteHint = ({ url }) => (
- } />
-);
-
-RemoteHint.propTypes = {
- url: PropTypes.string.isRequired,
-};
-
-export default @connect(mapStateToProps)
-class AccountTimeline extends ImmutablePureComponent {
-
- static propTypes = {
- params: PropTypes.shape({
- acct: PropTypes.string,
- id: PropTypes.string,
- tagged: PropTypes.string,
- }).isRequired,
- accountId: PropTypes.string,
- dispatch: PropTypes.func.isRequired,
- statusIds: ImmutablePropTypes.list,
- featuredStatusIds: ImmutablePropTypes.list,
- isLoading: PropTypes.bool,
- hasMore: PropTypes.bool,
- withReplies: PropTypes.bool,
- blockedBy: PropTypes.bool,
- isAccount: PropTypes.bool,
- suspended: PropTypes.bool,
- hidden: PropTypes.bool,
- remote: PropTypes.bool,
- remoteUrl: PropTypes.string,
- multiColumn: PropTypes.bool,
- };
-
- _load () {
- const { accountId, withReplies, params: { tagged }, dispatch } = this.props;
-
- dispatch(fetchAccount(accountId));
-
- if (!withReplies) {
- dispatch(expandAccountFeaturedTimeline(accountId, { tagged }));
- }
-
- dispatch(fetchFeaturedTags(accountId));
- dispatch(expandAccountTimeline(accountId, { withReplies, tagged }));
-
- if (accountId === me) {
- dispatch(connectTimeline(`account:${me}`));
- }
- }
-
- componentDidMount () {
- const { params: { acct }, accountId, dispatch } = this.props;
-
- if (accountId) {
- this._load();
- } else {
- dispatch(lookupAccount(acct));
- }
- }
-
- componentDidUpdate (prevProps) {
- const { params: { acct, tagged }, accountId, withReplies, dispatch } = this.props;
-
- if (prevProps.accountId !== accountId && accountId) {
- this._load();
- } else if (prevProps.params.acct !== acct) {
- dispatch(lookupAccount(acct));
- } else if (prevProps.params.tagged !== tagged) {
- if (!withReplies) {
- dispatch(expandAccountFeaturedTimeline(accountId, { tagged }));
- }
- dispatch(expandAccountTimeline(accountId, { withReplies, tagged }));
- }
-
- if (prevProps.accountId === me && accountId !== me) {
- dispatch(disconnectTimeline(`account:${me}`));
- }
- }
-
- componentWillUnmount () {
- const { dispatch, accountId } = this.props;
-
- if (accountId === me) {
- dispatch(disconnectTimeline(`account:${me}`));
- }
- }
-
- handleLoadMore = maxId => {
- this.props.dispatch(expandAccountTimeline(this.props.accountId, { maxId, withReplies: this.props.withReplies, tagged: this.props.params.tagged }));
- };
-
- render () {
- const { accountId, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props;
-
- if (isLoading && statusIds.isEmpty()) {
- return (
-
-
-
- );
- } else if (!isLoading && !isAccount) {
- return (
-
-
-
-
- );
- }
-
- let emptyMessage;
-
- const forceEmptyState = suspended || blockedBy || hidden;
-
- if (suspended) {
- emptyMessage = ;
- } else if (hidden) {
- emptyMessage = ;
- } else if (blockedBy) {
- emptyMessage = ;
- } else if (remote && statusIds.isEmpty()) {
- emptyMessage = ;
- } else {
- emptyMessage = ;
- }
-
- const remoteMessage = remote ? : null;
-
- return (
-
-
-
- }
- alwaysPrepend
- append={remoteMessage}
- scrollKey='account_timeline'
- statusIds={forceEmptyState ? emptyList : statusIds}
- featuredStatusIds={featuredStatusIds}
- isLoading={isLoading}
- hasMore={!forceEmptyState && hasMore}
- onLoadMore={this.handleLoadMore}
- emptyMessage={emptyMessage}
- bindToDocument={!multiColumn}
- timelineId='account'
- />
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/account_timeline/index.jsx b/app/javascript/mastodon/features/account_timeline/index.jsx
new file mode 100644
index 000000000..337977fde
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/index.jsx
@@ -0,0 +1,208 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { lookupAccount, fetchAccount } from '../../actions/accounts';
+import { expandAccountFeaturedTimeline, expandAccountTimeline } from '../../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';
+import { FormattedMessage } from 'react-intl';
+import MissingIndicator from 'mastodon/components/missing_indicator';
+import TimelineHint from 'mastodon/components/timeline_hint';
+import { me } from 'mastodon/initial_state';
+import { connectTimeline, disconnectTimeline } from 'mastodon/actions/timelines';
+import LimitedAccountHint from './components/limited_account_hint';
+import { getAccountHidden } from 'mastodon/selectors';
+import { fetchFeaturedTags } from '../../actions/featured_tags';
+import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
+
+const emptyList = ImmutableList();
+
+const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = false }) => {
+ const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
+
+ if (accountId === null) {
+ return {
+ isLoading: false,
+ isAccount: false,
+ statusIds: emptyList,
+ };
+ } else if (!accountId) {
+ return {
+ isLoading: true,
+ statusIds: emptyList,
+ };
+ }
+
+ const path = withReplies ? `${accountId}:with_replies` : `${accountId}${tagged ? `:${tagged}` : ''}`;
+
+ return {
+ accountId,
+ remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
+ remoteUrl: state.getIn(['accounts', accountId, 'url']),
+ isAccount: !!state.getIn(['accounts', accountId]),
+ statusIds: state.getIn(['timelines', `account:${path}`, 'items'], emptyList),
+ featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'], emptyList),
+ isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
+ hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']),
+ suspended: state.getIn(['accounts', accountId, 'suspended'], false),
+ hidden: getAccountHidden(state, accountId),
+ blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
+ };
+};
+
+const RemoteHint = ({ url }) => (
+ } />
+);
+
+RemoteHint.propTypes = {
+ url: PropTypes.string.isRequired,
+};
+
+export default @connect(mapStateToProps)
+class AccountTimeline extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.shape({
+ acct: PropTypes.string,
+ id: PropTypes.string,
+ tagged: PropTypes.string,
+ }).isRequired,
+ accountId: PropTypes.string,
+ dispatch: PropTypes.func.isRequired,
+ statusIds: ImmutablePropTypes.list,
+ featuredStatusIds: ImmutablePropTypes.list,
+ isLoading: PropTypes.bool,
+ hasMore: PropTypes.bool,
+ withReplies: PropTypes.bool,
+ blockedBy: PropTypes.bool,
+ isAccount: PropTypes.bool,
+ suspended: PropTypes.bool,
+ hidden: PropTypes.bool,
+ remote: PropTypes.bool,
+ remoteUrl: PropTypes.string,
+ multiColumn: PropTypes.bool,
+ };
+
+ _load () {
+ const { accountId, withReplies, params: { tagged }, dispatch } = this.props;
+
+ dispatch(fetchAccount(accountId));
+
+ if (!withReplies) {
+ dispatch(expandAccountFeaturedTimeline(accountId, { tagged }));
+ }
+
+ dispatch(fetchFeaturedTags(accountId));
+ dispatch(expandAccountTimeline(accountId, { withReplies, tagged }));
+
+ if (accountId === me) {
+ dispatch(connectTimeline(`account:${me}`));
+ }
+ }
+
+ componentDidMount () {
+ const { params: { acct }, accountId, dispatch } = this.props;
+
+ if (accountId) {
+ this._load();
+ } else {
+ dispatch(lookupAccount(acct));
+ }
+ }
+
+ componentDidUpdate (prevProps) {
+ const { params: { acct, tagged }, accountId, withReplies, dispatch } = this.props;
+
+ if (prevProps.accountId !== accountId && accountId) {
+ this._load();
+ } else if (prevProps.params.acct !== acct) {
+ dispatch(lookupAccount(acct));
+ } else if (prevProps.params.tagged !== tagged) {
+ if (!withReplies) {
+ dispatch(expandAccountFeaturedTimeline(accountId, { tagged }));
+ }
+ dispatch(expandAccountTimeline(accountId, { withReplies, tagged }));
+ }
+
+ if (prevProps.accountId === me && accountId !== me) {
+ dispatch(disconnectTimeline(`account:${me}`));
+ }
+ }
+
+ componentWillUnmount () {
+ const { dispatch, accountId } = this.props;
+
+ if (accountId === me) {
+ dispatch(disconnectTimeline(`account:${me}`));
+ }
+ }
+
+ handleLoadMore = maxId => {
+ this.props.dispatch(expandAccountTimeline(this.props.accountId, { maxId, withReplies: this.props.withReplies, tagged: this.props.params.tagged }));
+ };
+
+ render () {
+ const { accountId, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props;
+
+ if (isLoading && statusIds.isEmpty()) {
+ return (
+
+
+
+ );
+ } else if (!isLoading && !isAccount) {
+ return (
+
+
+
+
+ );
+ }
+
+ let emptyMessage;
+
+ const forceEmptyState = suspended || blockedBy || hidden;
+
+ if (suspended) {
+ emptyMessage = ;
+ } else if (hidden) {
+ emptyMessage = ;
+ } else if (blockedBy) {
+ emptyMessage = ;
+ } else if (remote && statusIds.isEmpty()) {
+ emptyMessage = ;
+ } else {
+ emptyMessage = ;
+ }
+
+ const remoteMessage = remote ? : null;
+
+ return (
+
+
+
+ }
+ alwaysPrepend
+ append={remoteMessage}
+ scrollKey='account_timeline'
+ statusIds={forceEmptyState ? emptyList : statusIds}
+ featuredStatusIds={featuredStatusIds}
+ isLoading={isLoading}
+ hasMore={!forceEmptyState && hasMore}
+ onLoadMore={this.handleLoadMore}
+ emptyMessage={emptyMessage}
+ bindToDocument={!multiColumn}
+ timelineId='account'
+ />
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js
deleted file mode 100644
index bf954c06d..000000000
--- a/app/javascript/mastodon/features/audio/index.js
+++ /dev/null
@@ -1,569 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
-import { formatTime, getPointerPosition, fileNameFromURL } from 'mastodon/features/video';
-import Icon from 'mastodon/components/icon';
-import classNames from 'classnames';
-import { throttle, debounce } from 'lodash';
-import Visualizer from './visualizer';
-import { displayMedia, useBlurhash } from '../../initial_state';
-import Blurhash from '../../components/blurhash';
-import { is } from 'immutable';
-
-const messages = defineMessages({
- play: { id: 'video.play', defaultMessage: 'Play' },
- pause: { id: 'video.pause', defaultMessage: 'Pause' },
- mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
- unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
- download: { id: 'video.download', defaultMessage: 'Download file' },
- hide: { id: 'audio.hide', defaultMessage: 'Hide audio' },
-});
-
-const TICK_SIZE = 10;
-const PADDING = 180;
-
-export default @injectIntl
-class Audio extends React.PureComponent {
-
- static propTypes = {
- src: PropTypes.string.isRequired,
- alt: PropTypes.string,
- poster: PropTypes.string,
- duration: PropTypes.number,
- width: PropTypes.number,
- height: PropTypes.number,
- sensitive: PropTypes.bool,
- editable: PropTypes.bool,
- fullscreen: PropTypes.bool,
- intl: PropTypes.object.isRequired,
- blurhash: PropTypes.string,
- cacheWidth: PropTypes.func,
- visible: PropTypes.bool,
- onToggleVisibility: PropTypes.func,
- backgroundColor: PropTypes.string,
- foregroundColor: PropTypes.string,
- accentColor: PropTypes.string,
- currentTime: PropTypes.number,
- autoPlay: PropTypes.bool,
- volume: PropTypes.number,
- muted: PropTypes.bool,
- deployPictureInPicture: PropTypes.func,
- };
-
- state = {
- width: this.props.width,
- currentTime: 0,
- buffer: 0,
- duration: null,
- paused: true,
- muted: false,
- volume: 1,
- dragging: false,
- revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
- };
-
- constructor (props) {
- super(props);
- this.visualizer = new Visualizer(TICK_SIZE);
- }
-
- setPlayerRef = c => {
- this.player = c;
-
- if (this.player) {
- this._setDimensions();
- }
- };
-
- _pack() {
- return {
- src: this.props.src,
- volume: this.state.volume,
- muted: this.state.muted,
- currentTime: this.audio.currentTime,
- poster: this.props.poster,
- backgroundColor: this.props.backgroundColor,
- foregroundColor: this.props.foregroundColor,
- accentColor: this.props.accentColor,
- sensitive: this.props.sensitive,
- visible: this.props.visible,
- };
- }
-
- _setDimensions () {
- const width = this.player.offsetWidth;
- const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
-
- if (this.props.cacheWidth) {
- this.props.cacheWidth(width);
- }
-
- this.setState({ width, height });
- }
-
- setSeekRef = c => {
- this.seek = c;
- };
-
- setVolumeRef = c => {
- this.volume = c;
- };
-
- setAudioRef = c => {
- this.audio = c;
-
- if (this.audio) {
- this.audio.volume = 1;
- this.audio.muted = false;
- }
- };
-
- setCanvasRef = c => {
- this.canvas = c;
-
- this.visualizer.setCanvas(c);
- };
-
- componentDidMount () {
- window.addEventListener('scroll', this.handleScroll);
- window.addEventListener('resize', this.handleResize, { passive: true });
- }
-
- componentDidUpdate (prevProps, prevState) {
- if (prevProps.src !== this.props.src || this.state.width !== prevState.width || this.state.height !== prevState.height || prevProps.accentColor !== this.props.accentColor) {
- this._clear();
- this._draw();
- }
- }
-
- componentWillReceiveProps (nextProps) {
- if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
- this.setState({ revealed: nextProps.visible });
- }
- }
-
- componentWillUnmount () {
- window.removeEventListener('scroll', this.handleScroll);
- window.removeEventListener('resize', this.handleResize);
-
- if (!this.state.paused && this.audio && this.props.deployPictureInPicture) {
- this.props.deployPictureInPicture('audio', this._pack());
- }
- }
-
- togglePlay = () => {
- if (!this.audioContext) {
- this._initAudioContext();
- }
-
- if (this.state.paused) {
- this.setState({ paused: false }, () => this.audio.play());
- } else {
- this.setState({ paused: true }, () => this.audio.pause());
- }
- };
-
- handleResize = debounce(() => {
- if (this.player) {
- this._setDimensions();
- }
- }, 250, {
- trailing: true,
- });
-
- handlePlay = () => {
- this.setState({ paused: false });
-
- if (this.audioContext && this.audioContext.state === 'suspended') {
- this.audioContext.resume();
- }
-
- this._renderCanvas();
- };
-
- handlePause = () => {
- this.setState({ paused: true });
-
- if (this.audioContext) {
- this.audioContext.suspend();
- }
- };
-
- handleProgress = () => {
- const lastTimeRange = this.audio.buffered.length - 1;
-
- if (lastTimeRange > -1) {
- this.setState({ buffer: Math.ceil(this.audio.buffered.end(lastTimeRange) / this.audio.duration * 100) });
- }
- };
-
- toggleMute = () => {
- const muted = !this.state.muted;
-
- this.setState({ muted }, () => {
- if (this.gainNode) {
- this.gainNode.gain.value = muted ? 0 : this.state.volume;
- }
- });
- };
-
- toggleReveal = () => {
- if (this.props.onToggleVisibility) {
- this.props.onToggleVisibility();
- } else {
- this.setState({ revealed: !this.state.revealed });
- }
- };
-
- handleVolumeMouseDown = e => {
- document.addEventListener('mousemove', this.handleMouseVolSlide, true);
- document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
- document.addEventListener('touchmove', this.handleMouseVolSlide, true);
- document.addEventListener('touchend', this.handleVolumeMouseUp, true);
-
- this.handleMouseVolSlide(e);
-
- e.preventDefault();
- e.stopPropagation();
- };
-
- handleVolumeMouseUp = () => {
- document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
- document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
- document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
- document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
- };
-
- handleMouseDown = e => {
- document.addEventListener('mousemove', this.handleMouseMove, true);
- document.addEventListener('mouseup', this.handleMouseUp, true);
- document.addEventListener('touchmove', this.handleMouseMove, true);
- document.addEventListener('touchend', this.handleMouseUp, true);
-
- this.setState({ dragging: true });
- this.audio.pause();
- this.handleMouseMove(e);
-
- e.preventDefault();
- e.stopPropagation();
- };
-
- handleMouseUp = () => {
- document.removeEventListener('mousemove', this.handleMouseMove, true);
- document.removeEventListener('mouseup', this.handleMouseUp, true);
- document.removeEventListener('touchmove', this.handleMouseMove, true);
- document.removeEventListener('touchend', this.handleMouseUp, true);
-
- this.setState({ dragging: false });
- this.audio.play();
- };
-
- handleMouseMove = throttle(e => {
- const { x } = getPointerPosition(this.seek, e);
- const currentTime = this.audio.duration * x;
-
- if (!isNaN(currentTime)) {
- this.setState({ currentTime }, () => {
- this.audio.currentTime = currentTime;
- });
- }
- }, 15);
-
- handleTimeUpdate = () => {
- this.setState({
- currentTime: this.audio.currentTime,
- duration: this.audio.duration,
- });
- };
-
- handleMouseVolSlide = throttle(e => {
- const { x } = getPointerPosition(this.volume, e);
-
- if(!isNaN(x)) {
- this.setState({ volume: x }, () => {
- if (this.gainNode) {
- this.gainNode.gain.value = this.state.muted ? 0 : x;
- }
- });
- }
- }, 15);
-
- handleScroll = throttle(() => {
- if (!this.canvas || !this.audio) {
- return;
- }
-
- const { top, height } = this.canvas.getBoundingClientRect();
- const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
-
- if (!this.state.paused && !inView) {
- this.audio.pause();
-
- if (this.props.deployPictureInPicture) {
- this.props.deployPictureInPicture('audio', this._pack());
- }
-
- this.setState({ paused: true });
- }
- }, 150, { trailing: true });
-
- handleMouseEnter = () => {
- this.setState({ hovered: true });
- };
-
- handleMouseLeave = () => {
- this.setState({ hovered: false });
- };
-
- handleLoadedData = () => {
- const { autoPlay, currentTime } = this.props;
-
- if (currentTime) {
- this.audio.currentTime = currentTime;
- }
-
- if (autoPlay) {
- this.togglePlay();
- }
- };
-
- _initAudioContext () {
- const AudioContext = window.AudioContext || window.webkitAudioContext;
- const context = new AudioContext();
- const source = context.createMediaElementSource(this.audio);
- const gainNode = context.createGain();
-
- gainNode.gain.value = this.state.muted ? 0 : this.state.volume;
-
- this.visualizer.setAudioContext(context, source);
- source.connect(gainNode);
- gainNode.connect(context.destination);
-
- this.audioContext = context;
- this.gainNode = gainNode;
- }
-
- handleDownload = () => {
- fetch(this.props.src).then(res => res.blob()).then(blob => {
- const element = document.createElement('a');
- const objectURL = URL.createObjectURL(blob);
-
- element.setAttribute('href', objectURL);
- element.setAttribute('download', fileNameFromURL(this.props.src));
-
- document.body.appendChild(element);
- element.click();
- document.body.removeChild(element);
-
- URL.revokeObjectURL(objectURL);
- }).catch(err => {
- console.error(err);
- });
- };
-
- _renderCanvas () {
- requestAnimationFrame(() => {
- if (!this.audio) return;
-
- this.handleTimeUpdate();
- this._clear();
- this._draw();
-
- if (!this.state.paused) {
- this._renderCanvas();
- }
- });
- }
-
- _clear() {
- this.visualizer.clear(this.state.width, this.state.height);
- }
-
- _draw() {
- this.visualizer.draw(this._getCX(), this._getCY(), this._getAccentColor(), this._getRadius(), this._getScaleCoefficient());
- }
-
- _getRadius () {
- return parseInt(((this.state.height || this.props.height) - (PADDING * this._getScaleCoefficient()) * 2) / 2);
- }
-
- _getScaleCoefficient () {
- return (this.state.height || this.props.height) / 982;
- }
-
- _getCX() {
- return Math.floor(this.state.width / 2);
- }
-
- _getCY() {
- return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient()));
- }
-
- _getAccentColor () {
- return this.props.accentColor || '#ffffff';
- }
-
- _getBackgroundColor () {
- return this.props.backgroundColor || '#000000';
- }
-
- _getForegroundColor () {
- return this.props.foregroundColor || '#ffffff';
- }
-
- seekBy (time) {
- const currentTime = this.audio.currentTime + time;
-
- if (!isNaN(currentTime)) {
- this.setState({ currentTime }, () => {
- this.audio.currentTime = currentTime;
- });
- }
- }
-
- handleAudioKeyDown = e => {
- // On the audio element or the seek bar, we can safely use the space bar
- // for playback control because there are no buttons to press
-
- if (e.key === ' ') {
- e.preventDefault();
- e.stopPropagation();
- this.togglePlay();
- }
- };
-
- handleKeyDown = e => {
- switch(e.key) {
- case 'k':
- e.preventDefault();
- e.stopPropagation();
- this.togglePlay();
- break;
- case 'm':
- e.preventDefault();
- e.stopPropagation();
- this.toggleMute();
- break;
- case 'j':
- e.preventDefault();
- e.stopPropagation();
- this.seekBy(-10);
- break;
- case 'l':
- e.preventDefault();
- e.stopPropagation();
- this.seekBy(10);
- break;
- }
- };
-
- render () {
- const { src, intl, alt, editable, autoPlay, sensitive, blurhash } = this.props;
- const { paused, muted, volume, currentTime, duration, buffer, dragging, revealed } = this.state;
- const progress = Math.min((currentTime / duration) * 100, 100);
-
- let warning;
- if (sensitive) {
- warning = ;
- } else {
- warning = ;
- }
-
- return (
-
-
-
-
- {(revealed || editable) &&
}
-
-
-
-
-
- {warning}
-
-
-
- {(revealed || editable) &&
}
-
-
-
-
-
-
-
-
-
-
-
-
- {formatTime(Math.floor(currentTime))}
- /
- {formatTime(Math.floor(this.state.duration || this.props.duration))}
-
-
-
-
- {!editable &&
}
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/audio/index.jsx b/app/javascript/mastodon/features/audio/index.jsx
new file mode 100644
index 000000000..bf954c06d
--- /dev/null
+++ b/app/javascript/mastodon/features/audio/index.jsx
@@ -0,0 +1,569 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import { formatTime, getPointerPosition, fileNameFromURL } from 'mastodon/features/video';
+import Icon from 'mastodon/components/icon';
+import classNames from 'classnames';
+import { throttle, debounce } from 'lodash';
+import Visualizer from './visualizer';
+import { displayMedia, useBlurhash } from '../../initial_state';
+import Blurhash from '../../components/blurhash';
+import { is } from 'immutable';
+
+const messages = defineMessages({
+ play: { id: 'video.play', defaultMessage: 'Play' },
+ pause: { id: 'video.pause', defaultMessage: 'Pause' },
+ mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
+ unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
+ download: { id: 'video.download', defaultMessage: 'Download file' },
+ hide: { id: 'audio.hide', defaultMessage: 'Hide audio' },
+});
+
+const TICK_SIZE = 10;
+const PADDING = 180;
+
+export default @injectIntl
+class Audio extends React.PureComponent {
+
+ static propTypes = {
+ src: PropTypes.string.isRequired,
+ alt: PropTypes.string,
+ poster: PropTypes.string,
+ duration: PropTypes.number,
+ width: PropTypes.number,
+ height: PropTypes.number,
+ sensitive: PropTypes.bool,
+ editable: PropTypes.bool,
+ fullscreen: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ blurhash: PropTypes.string,
+ cacheWidth: PropTypes.func,
+ visible: PropTypes.bool,
+ onToggleVisibility: PropTypes.func,
+ backgroundColor: PropTypes.string,
+ foregroundColor: PropTypes.string,
+ accentColor: PropTypes.string,
+ currentTime: PropTypes.number,
+ autoPlay: PropTypes.bool,
+ volume: PropTypes.number,
+ muted: PropTypes.bool,
+ deployPictureInPicture: PropTypes.func,
+ };
+
+ state = {
+ width: this.props.width,
+ currentTime: 0,
+ buffer: 0,
+ duration: null,
+ paused: true,
+ muted: false,
+ volume: 1,
+ dragging: false,
+ revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
+ };
+
+ constructor (props) {
+ super(props);
+ this.visualizer = new Visualizer(TICK_SIZE);
+ }
+
+ setPlayerRef = c => {
+ this.player = c;
+
+ if (this.player) {
+ this._setDimensions();
+ }
+ };
+
+ _pack() {
+ return {
+ src: this.props.src,
+ volume: this.state.volume,
+ muted: this.state.muted,
+ currentTime: this.audio.currentTime,
+ poster: this.props.poster,
+ backgroundColor: this.props.backgroundColor,
+ foregroundColor: this.props.foregroundColor,
+ accentColor: this.props.accentColor,
+ sensitive: this.props.sensitive,
+ visible: this.props.visible,
+ };
+ }
+
+ _setDimensions () {
+ const width = this.player.offsetWidth;
+ const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
+
+ if (this.props.cacheWidth) {
+ this.props.cacheWidth(width);
+ }
+
+ this.setState({ width, height });
+ }
+
+ setSeekRef = c => {
+ this.seek = c;
+ };
+
+ setVolumeRef = c => {
+ this.volume = c;
+ };
+
+ setAudioRef = c => {
+ this.audio = c;
+
+ if (this.audio) {
+ this.audio.volume = 1;
+ this.audio.muted = false;
+ }
+ };
+
+ setCanvasRef = c => {
+ this.canvas = c;
+
+ this.visualizer.setCanvas(c);
+ };
+
+ componentDidMount () {
+ window.addEventListener('scroll', this.handleScroll);
+ window.addEventListener('resize', this.handleResize, { passive: true });
+ }
+
+ componentDidUpdate (prevProps, prevState) {
+ if (prevProps.src !== this.props.src || this.state.width !== prevState.width || this.state.height !== prevState.height || prevProps.accentColor !== this.props.accentColor) {
+ this._clear();
+ this._draw();
+ }
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
+ this.setState({ revealed: nextProps.visible });
+ }
+ }
+
+ componentWillUnmount () {
+ window.removeEventListener('scroll', this.handleScroll);
+ window.removeEventListener('resize', this.handleResize);
+
+ if (!this.state.paused && this.audio && this.props.deployPictureInPicture) {
+ this.props.deployPictureInPicture('audio', this._pack());
+ }
+ }
+
+ togglePlay = () => {
+ if (!this.audioContext) {
+ this._initAudioContext();
+ }
+
+ if (this.state.paused) {
+ this.setState({ paused: false }, () => this.audio.play());
+ } else {
+ this.setState({ paused: true }, () => this.audio.pause());
+ }
+ };
+
+ handleResize = debounce(() => {
+ if (this.player) {
+ this._setDimensions();
+ }
+ }, 250, {
+ trailing: true,
+ });
+
+ handlePlay = () => {
+ this.setState({ paused: false });
+
+ if (this.audioContext && this.audioContext.state === 'suspended') {
+ this.audioContext.resume();
+ }
+
+ this._renderCanvas();
+ };
+
+ handlePause = () => {
+ this.setState({ paused: true });
+
+ if (this.audioContext) {
+ this.audioContext.suspend();
+ }
+ };
+
+ handleProgress = () => {
+ const lastTimeRange = this.audio.buffered.length - 1;
+
+ if (lastTimeRange > -1) {
+ this.setState({ buffer: Math.ceil(this.audio.buffered.end(lastTimeRange) / this.audio.duration * 100) });
+ }
+ };
+
+ toggleMute = () => {
+ const muted = !this.state.muted;
+
+ this.setState({ muted }, () => {
+ if (this.gainNode) {
+ this.gainNode.gain.value = muted ? 0 : this.state.volume;
+ }
+ });
+ };
+
+ toggleReveal = () => {
+ if (this.props.onToggleVisibility) {
+ this.props.onToggleVisibility();
+ } else {
+ this.setState({ revealed: !this.state.revealed });
+ }
+ };
+
+ handleVolumeMouseDown = e => {
+ document.addEventListener('mousemove', this.handleMouseVolSlide, true);
+ document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
+ document.addEventListener('touchmove', this.handleMouseVolSlide, true);
+ document.addEventListener('touchend', this.handleVolumeMouseUp, true);
+
+ this.handleMouseVolSlide(e);
+
+ e.preventDefault();
+ e.stopPropagation();
+ };
+
+ handleVolumeMouseUp = () => {
+ document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
+ document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
+ document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
+ document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
+ };
+
+ handleMouseDown = e => {
+ document.addEventListener('mousemove', this.handleMouseMove, true);
+ document.addEventListener('mouseup', this.handleMouseUp, true);
+ document.addEventListener('touchmove', this.handleMouseMove, true);
+ document.addEventListener('touchend', this.handleMouseUp, true);
+
+ this.setState({ dragging: true });
+ this.audio.pause();
+ this.handleMouseMove(e);
+
+ e.preventDefault();
+ e.stopPropagation();
+ };
+
+ handleMouseUp = () => {
+ document.removeEventListener('mousemove', this.handleMouseMove, true);
+ document.removeEventListener('mouseup', this.handleMouseUp, true);
+ document.removeEventListener('touchmove', this.handleMouseMove, true);
+ document.removeEventListener('touchend', this.handleMouseUp, true);
+
+ this.setState({ dragging: false });
+ this.audio.play();
+ };
+
+ handleMouseMove = throttle(e => {
+ const { x } = getPointerPosition(this.seek, e);
+ const currentTime = this.audio.duration * x;
+
+ if (!isNaN(currentTime)) {
+ this.setState({ currentTime }, () => {
+ this.audio.currentTime = currentTime;
+ });
+ }
+ }, 15);
+
+ handleTimeUpdate = () => {
+ this.setState({
+ currentTime: this.audio.currentTime,
+ duration: this.audio.duration,
+ });
+ };
+
+ handleMouseVolSlide = throttle(e => {
+ const { x } = getPointerPosition(this.volume, e);
+
+ if(!isNaN(x)) {
+ this.setState({ volume: x }, () => {
+ if (this.gainNode) {
+ this.gainNode.gain.value = this.state.muted ? 0 : x;
+ }
+ });
+ }
+ }, 15);
+
+ handleScroll = throttle(() => {
+ if (!this.canvas || !this.audio) {
+ return;
+ }
+
+ const { top, height } = this.canvas.getBoundingClientRect();
+ const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
+
+ if (!this.state.paused && !inView) {
+ this.audio.pause();
+
+ if (this.props.deployPictureInPicture) {
+ this.props.deployPictureInPicture('audio', this._pack());
+ }
+
+ this.setState({ paused: true });
+ }
+ }, 150, { trailing: true });
+
+ handleMouseEnter = () => {
+ this.setState({ hovered: true });
+ };
+
+ handleMouseLeave = () => {
+ this.setState({ hovered: false });
+ };
+
+ handleLoadedData = () => {
+ const { autoPlay, currentTime } = this.props;
+
+ if (currentTime) {
+ this.audio.currentTime = currentTime;
+ }
+
+ if (autoPlay) {
+ this.togglePlay();
+ }
+ };
+
+ _initAudioContext () {
+ const AudioContext = window.AudioContext || window.webkitAudioContext;
+ const context = new AudioContext();
+ const source = context.createMediaElementSource(this.audio);
+ const gainNode = context.createGain();
+
+ gainNode.gain.value = this.state.muted ? 0 : this.state.volume;
+
+ this.visualizer.setAudioContext(context, source);
+ source.connect(gainNode);
+ gainNode.connect(context.destination);
+
+ this.audioContext = context;
+ this.gainNode = gainNode;
+ }
+
+ handleDownload = () => {
+ fetch(this.props.src).then(res => res.blob()).then(blob => {
+ const element = document.createElement('a');
+ const objectURL = URL.createObjectURL(blob);
+
+ element.setAttribute('href', objectURL);
+ element.setAttribute('download', fileNameFromURL(this.props.src));
+
+ document.body.appendChild(element);
+ element.click();
+ document.body.removeChild(element);
+
+ URL.revokeObjectURL(objectURL);
+ }).catch(err => {
+ console.error(err);
+ });
+ };
+
+ _renderCanvas () {
+ requestAnimationFrame(() => {
+ if (!this.audio) return;
+
+ this.handleTimeUpdate();
+ this._clear();
+ this._draw();
+
+ if (!this.state.paused) {
+ this._renderCanvas();
+ }
+ });
+ }
+
+ _clear() {
+ this.visualizer.clear(this.state.width, this.state.height);
+ }
+
+ _draw() {
+ this.visualizer.draw(this._getCX(), this._getCY(), this._getAccentColor(), this._getRadius(), this._getScaleCoefficient());
+ }
+
+ _getRadius () {
+ return parseInt(((this.state.height || this.props.height) - (PADDING * this._getScaleCoefficient()) * 2) / 2);
+ }
+
+ _getScaleCoefficient () {
+ return (this.state.height || this.props.height) / 982;
+ }
+
+ _getCX() {
+ return Math.floor(this.state.width / 2);
+ }
+
+ _getCY() {
+ return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient()));
+ }
+
+ _getAccentColor () {
+ return this.props.accentColor || '#ffffff';
+ }
+
+ _getBackgroundColor () {
+ return this.props.backgroundColor || '#000000';
+ }
+
+ _getForegroundColor () {
+ return this.props.foregroundColor || '#ffffff';
+ }
+
+ seekBy (time) {
+ const currentTime = this.audio.currentTime + time;
+
+ if (!isNaN(currentTime)) {
+ this.setState({ currentTime }, () => {
+ this.audio.currentTime = currentTime;
+ });
+ }
+ }
+
+ handleAudioKeyDown = e => {
+ // On the audio element or the seek bar, we can safely use the space bar
+ // for playback control because there are no buttons to press
+
+ if (e.key === ' ') {
+ e.preventDefault();
+ e.stopPropagation();
+ this.togglePlay();
+ }
+ };
+
+ handleKeyDown = e => {
+ switch(e.key) {
+ case 'k':
+ e.preventDefault();
+ e.stopPropagation();
+ this.togglePlay();
+ break;
+ case 'm':
+ e.preventDefault();
+ e.stopPropagation();
+ this.toggleMute();
+ break;
+ case 'j':
+ e.preventDefault();
+ e.stopPropagation();
+ this.seekBy(-10);
+ break;
+ case 'l':
+ e.preventDefault();
+ e.stopPropagation();
+ this.seekBy(10);
+ break;
+ }
+ };
+
+ render () {
+ const { src, intl, alt, editable, autoPlay, sensitive, blurhash } = this.props;
+ const { paused, muted, volume, currentTime, duration, buffer, dragging, revealed } = this.state;
+ const progress = Math.min((currentTime / duration) * 100, 100);
+
+ let warning;
+ if (sensitive) {
+ warning = ;
+ } else {
+ warning = ;
+ }
+
+ return (
+
+
+
+
+ {(revealed || editable) &&
}
+
+
+
+
+
+ {warning}
+
+
+
+ {(revealed || editable) &&
}
+
+
+
+
+
+
+
+
+
+
+
+
+ {formatTime(Math.floor(currentTime))}
+ /
+ {formatTime(Math.floor(this.state.duration || this.props.duration))}
+
+
+
+
+ {!editable &&
}
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/blocks/index.js b/app/javascript/mastodon/features/blocks/index.js
deleted file mode 100644
index e00f2b60e..000000000
--- a/app/javascript/mastodon/features/blocks/index.js
+++ /dev/null
@@ -1,79 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { debounce } from 'lodash';
-import PropTypes from 'prop-types';
-import LoadingIndicator from '../../components/loading_indicator';
-import Column from '../ui/components/column';
-import ColumnBackButtonSlim from '../../components/column_back_button_slim';
-import AccountContainer from '../../containers/account_container';
-import { fetchBlocks, expandBlocks } from '../../actions/blocks';
-import ScrollableList from '../../components/scrollable_list';
-
-const messages = defineMessages({
- heading: { id: 'column.blocks', defaultMessage: 'Blocked users' },
-});
-
-const mapStateToProps = state => ({
- accountIds: state.getIn(['user_lists', 'blocks', 'items']),
- hasMore: !!state.getIn(['user_lists', 'blocks', 'next']),
- isLoading: state.getIn(['user_lists', 'blocks', 'isLoading'], true),
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-class Blocks extends ImmutablePureComponent {
-
- static propTypes = {
- params: PropTypes.object.isRequired,
- dispatch: PropTypes.func.isRequired,
- accountIds: ImmutablePropTypes.list,
- hasMore: PropTypes.bool,
- isLoading: PropTypes.bool,
- intl: PropTypes.object.isRequired,
- multiColumn: PropTypes.bool,
- };
-
- componentWillMount () {
- this.props.dispatch(fetchBlocks());
- }
-
- handleLoadMore = debounce(() => {
- this.props.dispatch(expandBlocks());
- }, 300, { leading: true });
-
- render () {
- const { intl, accountIds, hasMore, multiColumn, isLoading } = this.props;
-
- if (!accountIds) {
- return (
-
-
-
- );
- }
-
- const emptyMessage = ;
-
- return (
-
-
-
- {accountIds.map(id =>
- ,
- )}
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/blocks/index.jsx b/app/javascript/mastodon/features/blocks/index.jsx
new file mode 100644
index 000000000..e00f2b60e
--- /dev/null
+++ b/app/javascript/mastodon/features/blocks/index.jsx
@@ -0,0 +1,79 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { debounce } from 'lodash';
+import PropTypes from 'prop-types';
+import LoadingIndicator from '../../components/loading_indicator';
+import Column from '../ui/components/column';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import AccountContainer from '../../containers/account_container';
+import { fetchBlocks, expandBlocks } from '../../actions/blocks';
+import ScrollableList from '../../components/scrollable_list';
+
+const messages = defineMessages({
+ heading: { id: 'column.blocks', defaultMessage: 'Blocked users' },
+});
+
+const mapStateToProps = state => ({
+ accountIds: state.getIn(['user_lists', 'blocks', 'items']),
+ hasMore: !!state.getIn(['user_lists', 'blocks', 'next']),
+ isLoading: state.getIn(['user_lists', 'blocks', 'isLoading'], true),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Blocks extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ accountIds: ImmutablePropTypes.list,
+ hasMore: PropTypes.bool,
+ isLoading: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ multiColumn: PropTypes.bool,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchBlocks());
+ }
+
+ handleLoadMore = debounce(() => {
+ this.props.dispatch(expandBlocks());
+ }, 300, { leading: true });
+
+ render () {
+ const { intl, accountIds, hasMore, multiColumn, isLoading } = this.props;
+
+ if (!accountIds) {
+ return (
+
+
+
+ );
+ }
+
+ const emptyMessage = ;
+
+ return (
+
+
+
+ {accountIds.map(id =>
+ ,
+ )}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/bookmarked_statuses/index.js b/app/javascript/mastodon/features/bookmarked_statuses/index.js
deleted file mode 100644
index 8ef7855c1..000000000
--- a/app/javascript/mastodon/features/bookmarked_statuses/index.js
+++ /dev/null
@@ -1,108 +0,0 @@
-import { debounce } from 'lodash';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Helmet } from 'react-helmet';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { connect } from 'react-redux';
-import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'mastodon/actions/bookmarks';
-import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
-import ColumnHeader from 'mastodon/components/column_header';
-import StatusList from 'mastodon/components/status_list';
-import Column from 'mastodon/features/ui/components/column';
-
-const messages = defineMessages({
- heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
-});
-
-const mapStateToProps = state => ({
- statusIds: state.getIn(['status_lists', 'bookmarks', 'items']),
- isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true),
- hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']),
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-class Bookmarks 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,
- isLoading: PropTypes.bool,
- };
-
- componentWillMount () {
- this.props.dispatch(fetchBookmarkedStatuses());
- }
-
- handlePin = () => {
- const { columnId, dispatch } = this.props;
-
- if (columnId) {
- dispatch(removeColumn(columnId));
- } else {
- dispatch(addColumn('BOOKMARKS', {}));
- }
- };
-
- handleMove = (dir) => {
- const { columnId, dispatch } = this.props;
- dispatch(moveColumn(columnId, dir));
- };
-
- handleHeaderClick = () => {
- this.column.scrollTop();
- };
-
- setRef = c => {
- this.column = c;
- };
-
- handleLoadMore = debounce(() => {
- this.props.dispatch(expandBookmarkedStatuses());
- }, 300, { leading: true });
-
- render () {
- const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
- const pinned = !!columnId;
-
- const emptyMessage = ;
-
- return (
-
-
-
-
-
-
- {intl.formatMessage(messages.heading)}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/bookmarked_statuses/index.jsx b/app/javascript/mastodon/features/bookmarked_statuses/index.jsx
new file mode 100644
index 000000000..8ef7855c1
--- /dev/null
+++ b/app/javascript/mastodon/features/bookmarked_statuses/index.jsx
@@ -0,0 +1,108 @@
+import { debounce } from 'lodash';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Helmet } from 'react-helmet';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { connect } from 'react-redux';
+import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'mastodon/actions/bookmarks';
+import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
+import ColumnHeader from 'mastodon/components/column_header';
+import StatusList from 'mastodon/components/status_list';
+import Column from 'mastodon/features/ui/components/column';
+
+const messages = defineMessages({
+ heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
+});
+
+const mapStateToProps = state => ({
+ statusIds: state.getIn(['status_lists', 'bookmarks', 'items']),
+ isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true),
+ hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Bookmarks 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,
+ isLoading: PropTypes.bool,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchBookmarkedStatuses());
+ }
+
+ handlePin = () => {
+ const { columnId, dispatch } = this.props;
+
+ if (columnId) {
+ dispatch(removeColumn(columnId));
+ } else {
+ dispatch(addColumn('BOOKMARKS', {}));
+ }
+ };
+
+ handleMove = (dir) => {
+ const { columnId, dispatch } = this.props;
+ dispatch(moveColumn(columnId, dir));
+ };
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ };
+
+ setRef = c => {
+ this.column = c;
+ };
+
+ handleLoadMore = debounce(() => {
+ this.props.dispatch(expandBookmarkedStatuses());
+ }, 300, { leading: true });
+
+ render () {
+ const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
+ const pinned = !!columnId;
+
+ const emptyMessage = ;
+
+ return (
+
+
+
+
+
+
+ {intl.formatMessage(messages.heading)}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/closed_registrations_modal/index.js b/app/javascript/mastodon/features/closed_registrations_modal/index.js
deleted file mode 100644
index e6540e825..000000000
--- a/app/javascript/mastodon/features/closed_registrations_modal/index.js
+++ /dev/null
@@ -1,75 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import { FormattedMessage } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { domain } from 'mastodon/initial_state';
-import { fetchServer } from 'mastodon/actions/server';
-
-const mapStateToProps = state => ({
- message: state.getIn(['server', 'server', 'registrations', 'message']),
-});
-
-export default @connect(mapStateToProps)
-class ClosedRegistrationsModal extends ImmutablePureComponent {
-
- componentDidMount () {
- const { dispatch } = this.props;
- dispatch(fetchServer());
- }
-
- render () {
- let closedRegistrationsMessage;
-
- if (this.props.message) {
- closedRegistrationsMessage = (
-
- );
- } else {
- closedRegistrationsMessage = (
-
- {domain} }}
- />
-
- );
- }
-
- return (
-
-
-
-
-
-
- {closedRegistrationsMessage}
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/closed_registrations_modal/index.jsx b/app/javascript/mastodon/features/closed_registrations_modal/index.jsx
new file mode 100644
index 000000000..e6540e825
--- /dev/null
+++ b/app/javascript/mastodon/features/closed_registrations_modal/index.jsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { domain } from 'mastodon/initial_state';
+import { fetchServer } from 'mastodon/actions/server';
+
+const mapStateToProps = state => ({
+ message: state.getIn(['server', 'server', 'registrations', 'message']),
+});
+
+export default @connect(mapStateToProps)
+class ClosedRegistrationsModal extends ImmutablePureComponent {
+
+ componentDidMount () {
+ const { dispatch } = this.props;
+ dispatch(fetchServer());
+ }
+
+ render () {
+ let closedRegistrationsMessage;
+
+ if (this.props.message) {
+ closedRegistrationsMessage = (
+
+ );
+ } else {
+ closedRegistrationsMessage = (
+
+ {domain} }}
+ />
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {closedRegistrationsMessage}
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/community_timeline/components/column_settings.js b/app/javascript/mastodon/features/community_timeline/components/column_settings.js
deleted file mode 100644
index 0cb6db883..000000000
--- a/app/javascript/mastodon/features/community_timeline/components/column_settings.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { injectIntl, FormattedMessage } from 'react-intl';
-import SettingToggle from '../../notifications/components/setting_toggle';
-
-export default @injectIntl
-class ColumnSettings extends React.PureComponent {
-
- static propTypes = {
- settings: ImmutablePropTypes.map.isRequired,
- onChange: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- columnId: PropTypes.string,
- };
-
- render () {
- const { settings, onChange } = this.props;
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/community_timeline/components/column_settings.jsx b/app/javascript/mastodon/features/community_timeline/components/column_settings.jsx
new file mode 100644
index 000000000..0cb6db883
--- /dev/null
+++ b/app/javascript/mastodon/features/community_timeline/components/column_settings.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import SettingToggle from '../../notifications/components/setting_toggle';
+
+export default @injectIntl
+class ColumnSettings extends React.PureComponent {
+
+ static propTypes = {
+ settings: ImmutablePropTypes.map.isRequired,
+ onChange: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ columnId: PropTypes.string,
+ };
+
+ render () {
+ const { settings, onChange } = this.props;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js
deleted file mode 100644
index 4dbd55cf2..000000000
--- a/app/javascript/mastodon/features/community_timeline/index.js
+++ /dev/null
@@ -1,160 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import PropTypes from 'prop-types';
-import StatusListContainer from '../ui/containers/status_list_container';
-import Column from '../../components/column';
-import ColumnHeader from '../../components/column_header';
-import { expandCommunityTimeline } from '../../actions/timelines';
-import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
-import ColumnSettingsContainer from './containers/column_settings_container';
-import { connectCommunityStream } from '../../actions/streaming';
-import { Helmet } from 'react-helmet';
-import { domain } from 'mastodon/initial_state';
-import DismissableBanner from 'mastodon/components/dismissable_banner';
-
-const messages = defineMessages({
- title: { id: 'column.community', defaultMessage: 'Local timeline' },
-});
-
-const mapStateToProps = (state, { columnId }) => {
- const uuid = columnId;
- const columns = state.getIn(['settings', 'columns']);
- const index = columns.findIndex(c => c.get('uuid') === uuid);
- const onlyMedia = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'community', 'other', 'onlyMedia']);
- const timelineState = state.getIn(['timelines', `community${onlyMedia ? ':media' : ''}`]);
-
- return {
- hasUnread: !!timelineState && timelineState.get('unread') > 0,
- onlyMedia,
- };
-};
-
-export default @connect(mapStateToProps)
-@injectIntl
-class CommunityTimeline extends React.PureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- identity: PropTypes.object,
- };
-
- static defaultProps = {
- onlyMedia: false,
- };
-
- static propTypes = {
- dispatch: PropTypes.func.isRequired,
- columnId: PropTypes.string,
- intl: PropTypes.object.isRequired,
- hasUnread: PropTypes.bool,
- multiColumn: PropTypes.bool,
- onlyMedia: PropTypes.bool,
- };
-
- handlePin = () => {
- const { columnId, dispatch, onlyMedia } = this.props;
-
- if (columnId) {
- dispatch(removeColumn(columnId));
- } else {
- dispatch(addColumn('COMMUNITY', { other: { onlyMedia } }));
- }
- };
-
- handleMove = (dir) => {
- const { columnId, dispatch } = this.props;
- dispatch(moveColumn(columnId, dir));
- };
-
- handleHeaderClick = () => {
- this.column.scrollTop();
- };
-
- componentDidMount () {
- const { dispatch, onlyMedia } = this.props;
- const { signedIn } = this.context.identity;
-
- dispatch(expandCommunityTimeline({ onlyMedia }));
-
- if (signedIn) {
- this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
- }
- }
-
- componentDidUpdate (prevProps) {
- const { signedIn } = this.context.identity;
-
- if (prevProps.onlyMedia !== this.props.onlyMedia) {
- const { dispatch, onlyMedia } = this.props;
-
- if (this.disconnect) {
- this.disconnect();
- }
-
- dispatch(expandCommunityTimeline({ onlyMedia }));
-
- if (signedIn) {
- this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
- }
- }
- }
-
- componentWillUnmount () {
- if (this.disconnect) {
- this.disconnect();
- this.disconnect = null;
- }
- }
-
- setRef = c => {
- this.column = c;
- };
-
- handleLoadMore = maxId => {
- const { dispatch, onlyMedia } = this.props;
-
- dispatch(expandCommunityTimeline({ maxId, onlyMedia }));
- };
-
- render () {
- const { intl, hasUnread, columnId, multiColumn, onlyMedia } = this.props;
- const pinned = !!columnId;
-
- return (
-
-
-
-
-
-
-
- }
- bindToDocument={!multiColumn}
- />
-
-
- {intl.formatMessage(messages.title)}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/community_timeline/index.jsx b/app/javascript/mastodon/features/community_timeline/index.jsx
new file mode 100644
index 000000000..4dbd55cf2
--- /dev/null
+++ b/app/javascript/mastodon/features/community_timeline/index.jsx
@@ -0,0 +1,160 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+import StatusListContainer from '../ui/containers/status_list_container';
+import Column from '../../components/column';
+import ColumnHeader from '../../components/column_header';
+import { expandCommunityTimeline } from '../../actions/timelines';
+import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { connectCommunityStream } from '../../actions/streaming';
+import { Helmet } from 'react-helmet';
+import { domain } from 'mastodon/initial_state';
+import DismissableBanner from 'mastodon/components/dismissable_banner';
+
+const messages = defineMessages({
+ title: { id: 'column.community', defaultMessage: 'Local timeline' },
+});
+
+const mapStateToProps = (state, { columnId }) => {
+ const uuid = columnId;
+ const columns = state.getIn(['settings', 'columns']);
+ const index = columns.findIndex(c => c.get('uuid') === uuid);
+ const onlyMedia = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'community', 'other', 'onlyMedia']);
+ const timelineState = state.getIn(['timelines', `community${onlyMedia ? ':media' : ''}`]);
+
+ return {
+ hasUnread: !!timelineState && timelineState.get('unread') > 0,
+ onlyMedia,
+ };
+};
+
+export default @connect(mapStateToProps)
+@injectIntl
+class CommunityTimeline extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ identity: PropTypes.object,
+ };
+
+ static defaultProps = {
+ onlyMedia: false,
+ };
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ columnId: PropTypes.string,
+ intl: PropTypes.object.isRequired,
+ hasUnread: PropTypes.bool,
+ multiColumn: PropTypes.bool,
+ onlyMedia: PropTypes.bool,
+ };
+
+ handlePin = () => {
+ const { columnId, dispatch, onlyMedia } = this.props;
+
+ if (columnId) {
+ dispatch(removeColumn(columnId));
+ } else {
+ dispatch(addColumn('COMMUNITY', { other: { onlyMedia } }));
+ }
+ };
+
+ handleMove = (dir) => {
+ const { columnId, dispatch } = this.props;
+ dispatch(moveColumn(columnId, dir));
+ };
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ };
+
+ componentDidMount () {
+ const { dispatch, onlyMedia } = this.props;
+ const { signedIn } = this.context.identity;
+
+ dispatch(expandCommunityTimeline({ onlyMedia }));
+
+ if (signedIn) {
+ this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
+ }
+ }
+
+ componentDidUpdate (prevProps) {
+ const { signedIn } = this.context.identity;
+
+ if (prevProps.onlyMedia !== this.props.onlyMedia) {
+ const { dispatch, onlyMedia } = this.props;
+
+ if (this.disconnect) {
+ this.disconnect();
+ }
+
+ dispatch(expandCommunityTimeline({ onlyMedia }));
+
+ if (signedIn) {
+ this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
+ }
+ }
+ }
+
+ componentWillUnmount () {
+ if (this.disconnect) {
+ this.disconnect();
+ this.disconnect = null;
+ }
+ }
+
+ setRef = c => {
+ this.column = c;
+ };
+
+ handleLoadMore = maxId => {
+ const { dispatch, onlyMedia } = this.props;
+
+ dispatch(expandCommunityTimeline({ maxId, onlyMedia }));
+ };
+
+ render () {
+ const { intl, hasUnread, columnId, multiColumn, onlyMedia } = this.props;
+ const pinned = !!columnId;
+
+ return (
+
+
+
+
+
+
+
+ }
+ bindToDocument={!multiColumn}
+ />
+
+
+ {intl.formatMessage(messages.title)}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/action_bar.js b/app/javascript/mastodon/features/compose/components/action_bar.js
deleted file mode 100644
index ee584cb1b..000000000
--- a/app/javascript/mastodon/features/compose/components/action_bar.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
-import { defineMessages, injectIntl } from 'react-intl';
-
-const messages = defineMessages({
- edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
- pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
- preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
- follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
- favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
- lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
- followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
- blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
- domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
- mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
- filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
- logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
- bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
-});
-
-export default @injectIntl
-class ActionBar extends React.PureComponent {
-
- static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
- onLogout: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- handleLogout = () => {
- this.props.onLogout();
- };
-
- render () {
- const { intl } = this.props;
-
- let menu = [];
-
- menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
- menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
- menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
- menu.push(null);
- menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
- menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
- menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
- menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
- menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
- menu.push(null);
- menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
- menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
- menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
- menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
- menu.push(null);
- menu.push({ text: intl.formatMessage(messages.logout), action: this.handleLogout });
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/compose/components/action_bar.jsx b/app/javascript/mastodon/features/compose/components/action_bar.jsx
new file mode 100644
index 000000000..ee584cb1b
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/action_bar.jsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+ edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
+ pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
+ preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+ follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
+ favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
+ lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
+ followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
+ blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
+ domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
+ mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
+ filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
+ logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
+ bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
+});
+
+export default @injectIntl
+class ActionBar extends React.PureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ onLogout: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleLogout = () => {
+ this.props.onLogout();
+ };
+
+ render () {
+ const { intl } = this.props;
+
+ let menu = [];
+
+ menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
+ menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
+ menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
+ menu.push(null);
+ menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
+ menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
+ menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
+ menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
+ menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
+ menu.push(null);
+ menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
+ menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
+ menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
+ menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
+ menu.push(null);
+ menu.push({ text: intl.formatMessage(messages.logout), action: this.handleLogout });
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/autosuggest_account.js b/app/javascript/mastodon/features/compose/components/autosuggest_account.js
deleted file mode 100644
index 1451be0e6..000000000
--- a/app/javascript/mastodon/features/compose/components/autosuggest_account.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import React from 'react';
-import Avatar from '../../../components/avatar';
-import DisplayName from '../../../components/display_name';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-export default class AutosuggestAccount extends ImmutablePureComponent {
-
- static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
- };
-
- render () {
- const { account } = this.props;
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/compose/components/autosuggest_account.jsx b/app/javascript/mastodon/features/compose/components/autosuggest_account.jsx
new file mode 100644
index 000000000..1451be0e6
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/autosuggest_account.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import Avatar from '../../../components/avatar';
+import DisplayName from '../../../components/display_name';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class AutosuggestAccount extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ };
+
+ render () {
+ const { account } = this.props;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/character_counter.js b/app/javascript/mastodon/features/compose/components/character_counter.js
deleted file mode 100644
index 0ecfc9141..000000000
--- a/app/javascript/mastodon/features/compose/components/character_counter.js
+++ /dev/null
@@ -1,25 +0,0 @@
-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/mastodon/features/compose/components/character_counter.jsx b/app/javascript/mastodon/features/compose/components/character_counter.jsx
new file mode 100644
index 000000000..0ecfc9141
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/character_counter.jsx
@@ -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/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
deleted file mode 100644
index e641d59f4..000000000
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ /dev/null
@@ -1,301 +0,0 @@
-import React from 'react';
-import CharacterCounter from './character_counter';
-import Button from '../../../components/button';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import ReplyIndicatorContainer from '../containers/reply_indicator_container';
-import AutosuggestTextarea from '../../../components/autosuggest_textarea';
-import AutosuggestInput from '../../../components/autosuggest_input';
-import PollButtonContainer from '../containers/poll_button_container';
-import UploadButtonContainer from '../containers/upload_button_container';
-import { defineMessages, injectIntl } from 'react-intl';
-import SpoilerButtonContainer from '../containers/spoiler_button_container';
-import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
-import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
-import PollFormContainer from '../containers/poll_form_container';
-import UploadFormContainer from '../containers/upload_form_container';
-import WarningContainer from '../containers/warning_container';
-import LanguageDropdown from '../containers/language_dropdown_container';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { length } from 'stringz';
-import { countableText } from '../util/counter';
-import Icon from 'mastodon/components/icon';
-
-const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
-
-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: 'Publish' },
- publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
- saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
-});
-
-export default @injectIntl
-class ComposeForm extends ImmutablePureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- };
-
- static propTypes = {
- intl: PropTypes.object.isRequired,
- text: PropTypes.string.isRequired,
- suggestions: ImmutablePropTypes.list,
- spoiler: PropTypes.bool,
- privacy: PropTypes.string,
- spoilerText: PropTypes.string,
- focusDate: PropTypes.instanceOf(Date),
- caretPosition: PropTypes.number,
- preselectDate: PropTypes.instanceOf(Date),
- isSubmitting: PropTypes.bool,
- isChangingUpload: PropTypes.bool,
- isEditing: PropTypes.bool,
- isUploading: PropTypes.bool,
- onChange: PropTypes.func.isRequired,
- onSubmit: PropTypes.func.isRequired,
- onClearSuggestions: PropTypes.func.isRequired,
- onFetchSuggestions: PropTypes.func.isRequired,
- onSuggestionSelected: PropTypes.func.isRequired,
- onChangeSpoilerText: PropTypes.func.isRequired,
- onPaste: PropTypes.func.isRequired,
- onPickEmoji: PropTypes.func.isRequired,
- autoFocus: PropTypes.bool,
- anyMedia: PropTypes.bool,
- isInReply: PropTypes.bool,
- singleColumn: PropTypes.bool,
- lang: PropTypes.string,
- };
-
- static defaultProps = {
- autoFocus: false,
- };
-
- handleChange = (e) => {
- this.props.onChange(e.target.value);
- };
-
- handleKeyDown = (e) => {
- if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
- this.handleSubmit();
- }
- };
-
- getFulltextForCharacterCounting = () => {
- return [this.props.spoiler? this.props.spoilerText: '', countableText(this.props.text)].join('');
- };
-
- canSubmit = () => {
- const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props;
- const fulltext = this.getFulltextForCharacterCounting();
- const isOnlyWhitespace = fulltext.length !== 0 && fulltext.trim().length === 0;
-
- return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > 500 || (isOnlyWhitespace && !anyMedia));
- };
-
- handleSubmit = (e) => {
- 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);
- }
-
- if (!this.canSubmit()) {
- return;
- }
-
- this.props.onSubmit(this.context.router ? this.context.router.history : null);
-
- if (e) {
- e.preventDefault();
- }
- };
-
- onSuggestionsClearRequested = () => {
- this.props.onClearSuggestions();
- };
-
- onSuggestionsFetchRequested = (token) => {
- this.props.onFetchSuggestions(token);
- };
-
- onSuggestionSelected = (tokenStart, token, value) => {
- this.props.onSuggestionSelected(tokenStart, token, value, ['text']);
- };
-
- onSpoilerSuggestionSelected = (tokenStart, token, value) => {
- this.props.onSuggestionSelected(tokenStart, token, value, ['spoiler_text']);
- };
-
- handleChangeSpoilerText = (e) => {
- this.props.onChangeSpoilerText(e.target.value);
- };
-
- handleFocus = () => {
- if (this.composeForm && !this.props.singleColumn) {
- const { left, right } = this.composeForm.getBoundingClientRect();
- if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
- this.composeForm.scrollIntoView();
- }
- }
- };
-
- componentDidMount () {
- this._updateFocusAndSelection({ });
- }
-
- componentDidUpdate (prevProps) {
- this._updateFocusAndSelection(prevProps);
- }
-
- _updateFocusAndSelection = (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 (this.props.focusDate && this.props.focusDate !== prevProps.focusDate) {
- let selectionEnd, selectionStart;
-
- if (this.props.preselectDate !== prevProps.preselectDate && this.props.isInReply) {
- selectionEnd = this.props.text.length;
- selectionStart = this.props.text.search(/\s/) + 1;
- } else if (typeof this.props.caretPosition === 'number') {
- selectionStart = this.props.caretPosition;
- selectionEnd = this.props.caretPosition;
- } else {
- selectionEnd = this.props.text.length;
- selectionStart = selectionEnd;
- }
-
- // Because of the wicg-inert polyfill, the activeElement may not be
- // immediately selectable, we have to wait for observers to run, as
- // described in https://github.com/WICG/inert#performance-and-gotchas
- Promise.resolve().then(() => {
- this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
- this.autosuggestTextarea.textarea.focus();
- }).catch(console.error);
- } else if(prevProps.isSubmitting && !this.props.isSubmitting) {
- this.autosuggestTextarea.textarea.focus();
- } else if (this.props.spoiler !== prevProps.spoiler) {
- if (this.props.spoiler) {
- this.spoilerText.input.focus();
- } else if (prevProps.spoiler) {
- this.autosuggestTextarea.textarea.focus();
- }
- }
- };
-
- setAutosuggestTextarea = (c) => {
- this.autosuggestTextarea = c;
- };
-
- setSpoilerText = (c) => {
- this.spoilerText = c;
- };
-
- setRef = c => {
- this.composeForm = c;
- };
-
- handleEmojiPick = (data) => {
- const { text } = this.props;
- const position = this.autosuggestTextarea.textarea.selectionStart;
- const needsSpace = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]);
-
- this.props.onPickEmoji(position, data, needsSpace);
- };
-
- render () {
- const { intl, onPaste, autoFocus } = this.props;
- const disabled = this.props.isSubmitting;
-
- let publishText = '';
-
- if (this.props.isEditing) {
- publishText = intl.formatMessage(messages.saveChanges);
- } else 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);
- }
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx
new file mode 100644
index 000000000..e641d59f4
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx
@@ -0,0 +1,301 @@
+import React from 'react';
+import CharacterCounter from './character_counter';
+import Button from '../../../components/button';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ReplyIndicatorContainer from '../containers/reply_indicator_container';
+import AutosuggestTextarea from '../../../components/autosuggest_textarea';
+import AutosuggestInput from '../../../components/autosuggest_input';
+import PollButtonContainer from '../containers/poll_button_container';
+import UploadButtonContainer from '../containers/upload_button_container';
+import { defineMessages, injectIntl } from 'react-intl';
+import SpoilerButtonContainer from '../containers/spoiler_button_container';
+import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
+import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
+import PollFormContainer from '../containers/poll_form_container';
+import UploadFormContainer from '../containers/upload_form_container';
+import WarningContainer from '../containers/warning_container';
+import LanguageDropdown from '../containers/language_dropdown_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { length } from 'stringz';
+import { countableText } from '../util/counter';
+import Icon from 'mastodon/components/icon';
+
+const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
+
+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: 'Publish' },
+ publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
+ saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
+});
+
+export default @injectIntl
+class ComposeForm extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ text: PropTypes.string.isRequired,
+ suggestions: ImmutablePropTypes.list,
+ spoiler: PropTypes.bool,
+ privacy: PropTypes.string,
+ spoilerText: PropTypes.string,
+ focusDate: PropTypes.instanceOf(Date),
+ caretPosition: PropTypes.number,
+ preselectDate: PropTypes.instanceOf(Date),
+ isSubmitting: PropTypes.bool,
+ isChangingUpload: PropTypes.bool,
+ isEditing: PropTypes.bool,
+ isUploading: PropTypes.bool,
+ onChange: PropTypes.func.isRequired,
+ onSubmit: PropTypes.func.isRequired,
+ onClearSuggestions: PropTypes.func.isRequired,
+ onFetchSuggestions: PropTypes.func.isRequired,
+ onSuggestionSelected: PropTypes.func.isRequired,
+ onChangeSpoilerText: PropTypes.func.isRequired,
+ onPaste: PropTypes.func.isRequired,
+ onPickEmoji: PropTypes.func.isRequired,
+ autoFocus: PropTypes.bool,
+ anyMedia: PropTypes.bool,
+ isInReply: PropTypes.bool,
+ singleColumn: PropTypes.bool,
+ lang: PropTypes.string,
+ };
+
+ static defaultProps = {
+ autoFocus: false,
+ };
+
+ handleChange = (e) => {
+ this.props.onChange(e.target.value);
+ };
+
+ handleKeyDown = (e) => {
+ if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+ this.handleSubmit();
+ }
+ };
+
+ getFulltextForCharacterCounting = () => {
+ return [this.props.spoiler? this.props.spoilerText: '', countableText(this.props.text)].join('');
+ };
+
+ canSubmit = () => {
+ const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props;
+ const fulltext = this.getFulltextForCharacterCounting();
+ const isOnlyWhitespace = fulltext.length !== 0 && fulltext.trim().length === 0;
+
+ return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > 500 || (isOnlyWhitespace && !anyMedia));
+ };
+
+ handleSubmit = (e) => {
+ 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);
+ }
+
+ if (!this.canSubmit()) {
+ return;
+ }
+
+ this.props.onSubmit(this.context.router ? this.context.router.history : null);
+
+ if (e) {
+ e.preventDefault();
+ }
+ };
+
+ onSuggestionsClearRequested = () => {
+ this.props.onClearSuggestions();
+ };
+
+ onSuggestionsFetchRequested = (token) => {
+ this.props.onFetchSuggestions(token);
+ };
+
+ onSuggestionSelected = (tokenStart, token, value) => {
+ this.props.onSuggestionSelected(tokenStart, token, value, ['text']);
+ };
+
+ onSpoilerSuggestionSelected = (tokenStart, token, value) => {
+ this.props.onSuggestionSelected(tokenStart, token, value, ['spoiler_text']);
+ };
+
+ handleChangeSpoilerText = (e) => {
+ this.props.onChangeSpoilerText(e.target.value);
+ };
+
+ handleFocus = () => {
+ if (this.composeForm && !this.props.singleColumn) {
+ const { left, right } = this.composeForm.getBoundingClientRect();
+ if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
+ this.composeForm.scrollIntoView();
+ }
+ }
+ };
+
+ componentDidMount () {
+ this._updateFocusAndSelection({ });
+ }
+
+ componentDidUpdate (prevProps) {
+ this._updateFocusAndSelection(prevProps);
+ }
+
+ _updateFocusAndSelection = (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 (this.props.focusDate && this.props.focusDate !== prevProps.focusDate) {
+ let selectionEnd, selectionStart;
+
+ if (this.props.preselectDate !== prevProps.preselectDate && this.props.isInReply) {
+ selectionEnd = this.props.text.length;
+ selectionStart = this.props.text.search(/\s/) + 1;
+ } else if (typeof this.props.caretPosition === 'number') {
+ selectionStart = this.props.caretPosition;
+ selectionEnd = this.props.caretPosition;
+ } else {
+ selectionEnd = this.props.text.length;
+ selectionStart = selectionEnd;
+ }
+
+ // Because of the wicg-inert polyfill, the activeElement may not be
+ // immediately selectable, we have to wait for observers to run, as
+ // described in https://github.com/WICG/inert#performance-and-gotchas
+ Promise.resolve().then(() => {
+ this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
+ this.autosuggestTextarea.textarea.focus();
+ }).catch(console.error);
+ } else if(prevProps.isSubmitting && !this.props.isSubmitting) {
+ this.autosuggestTextarea.textarea.focus();
+ } else if (this.props.spoiler !== prevProps.spoiler) {
+ if (this.props.spoiler) {
+ this.spoilerText.input.focus();
+ } else if (prevProps.spoiler) {
+ this.autosuggestTextarea.textarea.focus();
+ }
+ }
+ };
+
+ setAutosuggestTextarea = (c) => {
+ this.autosuggestTextarea = c;
+ };
+
+ setSpoilerText = (c) => {
+ this.spoilerText = c;
+ };
+
+ setRef = c => {
+ this.composeForm = c;
+ };
+
+ handleEmojiPick = (data) => {
+ const { text } = this.props;
+ const position = this.autosuggestTextarea.textarea.selectionStart;
+ const needsSpace = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]);
+
+ this.props.onPickEmoji(position, data, needsSpace);
+ };
+
+ render () {
+ const { intl, onPaste, autoFocus } = this.props;
+ const disabled = this.props.isSubmitting;
+
+ let publishText = '';
+
+ if (this.props.isEditing) {
+ publishText = intl.formatMessage(messages.saveChanges);
+ } else 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);
+ }
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
deleted file mode 100644
index 79378454d..000000000
--- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
+++ /dev/null
@@ -1,411 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
-import Overlay from 'react-overlays/Overlay';
-import classNames from 'classnames';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { supportsPassiveEvents } from 'detect-passive-events';
-import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
-import { assetHost } from 'mastodon/utils/config';
-
-const messages = defineMessages({
- emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
- emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
- 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' },
-});
-
-let EmojiPicker, Emoji; // load asynchronously
-
-const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
-
-const backgroundImageFn = () => `${assetHost}/emoji/sheet_13.png`;
-
-const notFoundFn = () => (
-
-);
-
-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,
- intl: PropTypes.object.isRequired,
- skinTone: PropTypes.number.isRequired,
- onSkinTone: PropTypes.func.isRequired,
- };
-
- static defaultProps = {
- style: {},
- loading: true,
- frequentlyUsedEmojis: [],
- };
-
- state = {
- modifierOpen: false,
- readyToFocus: 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);
-
- // Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need
- // to wait for a frame before focusing
- requestAnimationFrame(() => {
- this.setState({ readyToFocus: true });
- if (this.node) {
- const element = this.node.querySelector('input[type="search"]');
- if (element) element.focus();
- }
- });
- }
-
- 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),
- 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, event) => {
- if (!emoji.native) {
- emoji.native = emoji.colons;
- }
- if (!(event.ctrlKey || event.metaKey)) {
- 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;
-
- const categoriesSort = [
- 'recent',
- 'people',
- 'nature',
- 'foods',
- 'activity',
- 'places',
- 'objects',
- 'symbols',
- 'flags',
- ];
-
- categoriesSort.splice(1, 0, ...Array.from(categoriesFromEmojis(custom_emojis)).sort());
-
- return (
-
-
-
-
-
- );
- }
-
-}
-
-export default @injectIntl
-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,
- button: PropTypes.node,
- };
-
- 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, active: 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(e);
- }
- }
- };
-
- handleKeyDown = e => {
- if (e.key === 'Escape') {
- this.onHideDropdown();
- }
- };
-
- setTargetRef = c => {
- this.target = c;
- };
-
- findTarget = () => {
- return this.target;
- };
-
- render () {
- const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
- const title = intl.formatMessage(messages.emoji);
- const { active, loading } = this.state;
-
- return (
-
-
- {button ||
}
-
-
-
- {({ props, placement })=> (
-
- )}
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx
new file mode 100644
index 000000000..79378454d
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx
@@ -0,0 +1,411 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
+import Overlay from 'react-overlays/Overlay';
+import classNames from 'classnames';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { supportsPassiveEvents } from 'detect-passive-events';
+import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
+import { assetHost } from 'mastodon/utils/config';
+
+const messages = defineMessages({
+ emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
+ emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
+ 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' },
+});
+
+let EmojiPicker, Emoji; // load asynchronously
+
+const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
+
+const backgroundImageFn = () => `${assetHost}/emoji/sheet_13.png`;
+
+const notFoundFn = () => (
+
+);
+
+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,
+ intl: PropTypes.object.isRequired,
+ skinTone: PropTypes.number.isRequired,
+ onSkinTone: PropTypes.func.isRequired,
+ };
+
+ static defaultProps = {
+ style: {},
+ loading: true,
+ frequentlyUsedEmojis: [],
+ };
+
+ state = {
+ modifierOpen: false,
+ readyToFocus: 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);
+
+ // Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need
+ // to wait for a frame before focusing
+ requestAnimationFrame(() => {
+ this.setState({ readyToFocus: true });
+ if (this.node) {
+ const element = this.node.querySelector('input[type="search"]');
+ if (element) element.focus();
+ }
+ });
+ }
+
+ 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),
+ 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, event) => {
+ if (!emoji.native) {
+ emoji.native = emoji.colons;
+ }
+ if (!(event.ctrlKey || event.metaKey)) {
+ 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;
+
+ const categoriesSort = [
+ 'recent',
+ 'people',
+ 'nature',
+ 'foods',
+ 'activity',
+ 'places',
+ 'objects',
+ 'symbols',
+ 'flags',
+ ];
+
+ categoriesSort.splice(1, 0, ...Array.from(categoriesFromEmojis(custom_emojis)).sort());
+
+ return (
+
+
+
+
+
+ );
+ }
+
+}
+
+export default @injectIntl
+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,
+ button: PropTypes.node,
+ };
+
+ 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, active: 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(e);
+ }
+ }
+ };
+
+ handleKeyDown = e => {
+ if (e.key === 'Escape') {
+ this.onHideDropdown();
+ }
+ };
+
+ setTargetRef = c => {
+ this.target = c;
+ };
+
+ findTarget = () => {
+ return this.target;
+ };
+
+ render () {
+ const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
+ const title = intl.formatMessage(messages.emoji);
+ const { active, loading } = this.state;
+
+ return (
+
+
+ {button ||
}
+
+
+
+ {({ props, placement })=> (
+
+ )}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/language_dropdown.js b/app/javascript/mastodon/features/compose/components/language_dropdown.js
deleted file mode 100644
index d96d39f23..000000000
--- a/app/javascript/mastodon/features/compose/components/language_dropdown.js
+++ /dev/null
@@ -1,327 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { injectIntl, defineMessages } from 'react-intl';
-import TextIconButton from './text_icon_button';
-import Overlay from 'react-overlays/Overlay';
-import { supportsPassiveEvents } from 'detect-passive-events';
-import classNames from 'classnames';
-import { languages as preloadedLanguages } from 'mastodon/initial_state';
-import { loupeIcon, deleteIcon } from 'mastodon/utils/icons';
-import fuzzysort from 'fuzzysort';
-
-const messages = defineMessages({
- changeLanguage: { id: 'compose.language.change', defaultMessage: 'Change language' },
- search: { id: 'compose.language.search', defaultMessage: 'Search languages...' },
- clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
-});
-
-const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
-
-class LanguageDropdownMenu extends React.PureComponent {
-
- static propTypes = {
- value: PropTypes.string.isRequired,
- frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string).isRequired,
- onClose: PropTypes.func.isRequired,
- onChange: PropTypes.func.isRequired,
- languages: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)),
- intl: PropTypes.object,
- };
-
- static defaultProps = {
- languages: preloadedLanguages,
- };
-
- state = {
- searchValue: '',
- };
-
- 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);
-
- // Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need
- // to wait for a frame before focusing
- requestAnimationFrame(() => {
- if (this.node) {
- const element = this.node.querySelector('input[type="search"]');
- if (element) element.focus();
- }
- });
- }
-
- componentWillUnmount () {
- document.removeEventListener('click', this.handleDocumentClick, false);
- document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
- }
-
- setRef = c => {
- this.node = c;
- };
-
- setListRef = c => {
- this.listNode = c;
- };
-
- handleSearchChange = ({ target }) => {
- this.setState({ searchValue: target.value });
- };
-
- search () {
- const { languages, value, frequentlyUsedLanguages } = this.props;
- const { searchValue } = this.state;
-
- if (searchValue === '') {
- return [...languages].sort((a, b) => {
- // Push current selection to the top of the list
-
- if (a[0] === value) {
- return -1;
- } else if (b[0] === value) {
- return 1;
- } else {
- // Sort according to frequently used languages
-
- const indexOfA = frequentlyUsedLanguages.indexOf(a[0]);
- const indexOfB = frequentlyUsedLanguages.indexOf(b[0]);
-
- return ((indexOfA > -1 ? indexOfA : Infinity) - (indexOfB > -1 ? indexOfB : Infinity));
- }
- });
- }
-
- return fuzzysort.go(searchValue, languages, {
- keys: ['0', '1', '2'],
- limit: 5,
- threshold: -10000,
- }).map(result => result.obj);
- }
-
- frequentlyUsed () {
- const { languages, value } = this.props;
- const current = languages.find(lang => lang[0] === value);
- const results = [];
-
- if (current) {
- results.push(current);
- }
-
- return results;
- }
-
- handleClick = e => {
- const value = e.currentTarget.getAttribute('data-index');
-
- e.preventDefault();
-
- this.props.onClose();
- this.props.onChange(value);
- };
-
- handleKeyDown = e => {
- const { onClose } = this.props;
- const index = Array.from(this.listNode.childNodes).findIndex(node => node === e.currentTarget);
-
- let element = null;
-
- switch(e.key) {
- case 'Escape':
- onClose();
- break;
- case 'Enter':
- this.handleClick(e);
- break;
- case 'ArrowDown':
- element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
- break;
- case 'ArrowUp':
- element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
- break;
- case 'Tab':
- if (e.shiftKey) {
- element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
- } else {
- element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
- }
- break;
- case 'Home':
- element = this.listNode.firstChild;
- break;
- case 'End':
- element = this.listNode.lastChild;
- break;
- }
-
- if (element) {
- element.focus();
- e.preventDefault();
- e.stopPropagation();
- }
- };
-
- handleSearchKeyDown = e => {
- const { onChange, onClose } = this.props;
- const { searchValue } = this.state;
-
- let element = null;
-
- switch(e.key) {
- case 'Tab':
- case 'ArrowDown':
- element = this.listNode.firstChild;
-
- if (element) {
- element.focus();
- e.preventDefault();
- e.stopPropagation();
- }
-
- break;
- case 'Enter':
- element = this.listNode.firstChild;
-
- if (element) {
- onChange(element.getAttribute('data-index'));
- onClose();
- }
- break;
- case 'Escape':
- if (searchValue !== '') {
- e.preventDefault();
- this.handleClear();
- }
-
- break;
- }
- };
-
- handleClear = () => {
- this.setState({ searchValue: '' });
- };
-
- renderItem = lang => {
- const { value } = this.props;
-
- return (
-
- {lang[2]} ({lang[1]})
-
- );
- };
-
- render () {
- const { intl } = this.props;
- const { searchValue } = this.state;
- const isSearching = searchValue !== '';
- const results = this.search();
-
- return (
-
-
-
- {!isSearching ? loupeIcon : deleteIcon}
-
-
-
- {results.map(this.renderItem)}
-
-
- );
- }
-
-}
-
-export default @injectIntl
-class LanguageDropdown extends React.PureComponent {
-
- static propTypes = {
- value: PropTypes.string,
- frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string),
- intl: PropTypes.object.isRequired,
- onChange: PropTypes.func,
- onClose: PropTypes.func,
- };
-
- state = {
- open: false,
- placement: 'bottom',
- };
-
- handleToggle = () => {
- if (this.state.open && this.activeElement) {
- this.activeElement.focus({ preventScroll: true });
- }
-
- this.setState({ open: !this.state.open });
- };
-
- handleClose = () => {
- const { value, onClose } = this.props;
-
- if (this.state.open && this.activeElement) {
- this.activeElement.focus({ preventScroll: true });
- }
-
- this.setState({ open: false });
- onClose(value);
- };
-
- handleChange = value => {
- const { onChange } = this.props;
- onChange(value);
- };
-
- setTargetRef = c => {
- this.target = c;
- };
-
- findTarget = () => {
- return this.target;
- };
-
- handleOverlayEnter = (state) => {
- this.setState({ placement: state.placement });
- };
-
- render () {
- const { value, intl, frequentlyUsedLanguages } = this.props;
- const { open, placement } = this.state;
-
- return (
-
-
-
-
-
-
- {({ props, placement }) => (
-
- )}
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/compose/components/language_dropdown.jsx b/app/javascript/mastodon/features/compose/components/language_dropdown.jsx
new file mode 100644
index 000000000..d96d39f23
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/language_dropdown.jsx
@@ -0,0 +1,327 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+import TextIconButton from './text_icon_button';
+import Overlay from 'react-overlays/Overlay';
+import { supportsPassiveEvents } from 'detect-passive-events';
+import classNames from 'classnames';
+import { languages as preloadedLanguages } from 'mastodon/initial_state';
+import { loupeIcon, deleteIcon } from 'mastodon/utils/icons';
+import fuzzysort from 'fuzzysort';
+
+const messages = defineMessages({
+ changeLanguage: { id: 'compose.language.change', defaultMessage: 'Change language' },
+ search: { id: 'compose.language.search', defaultMessage: 'Search languages...' },
+ clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
+});
+
+const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
+
+class LanguageDropdownMenu extends React.PureComponent {
+
+ static propTypes = {
+ value: PropTypes.string.isRequired,
+ frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string).isRequired,
+ onClose: PropTypes.func.isRequired,
+ onChange: PropTypes.func.isRequired,
+ languages: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)),
+ intl: PropTypes.object,
+ };
+
+ static defaultProps = {
+ languages: preloadedLanguages,
+ };
+
+ state = {
+ searchValue: '',
+ };
+
+ 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);
+
+ // Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need
+ // to wait for a frame before focusing
+ requestAnimationFrame(() => {
+ if (this.node) {
+ const element = this.node.querySelector('input[type="search"]');
+ if (element) element.focus();
+ }
+ });
+ }
+
+ componentWillUnmount () {
+ document.removeEventListener('click', this.handleDocumentClick, false);
+ document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ setRef = c => {
+ this.node = c;
+ };
+
+ setListRef = c => {
+ this.listNode = c;
+ };
+
+ handleSearchChange = ({ target }) => {
+ this.setState({ searchValue: target.value });
+ };
+
+ search () {
+ const { languages, value, frequentlyUsedLanguages } = this.props;
+ const { searchValue } = this.state;
+
+ if (searchValue === '') {
+ return [...languages].sort((a, b) => {
+ // Push current selection to the top of the list
+
+ if (a[0] === value) {
+ return -1;
+ } else if (b[0] === value) {
+ return 1;
+ } else {
+ // Sort according to frequently used languages
+
+ const indexOfA = frequentlyUsedLanguages.indexOf(a[0]);
+ const indexOfB = frequentlyUsedLanguages.indexOf(b[0]);
+
+ return ((indexOfA > -1 ? indexOfA : Infinity) - (indexOfB > -1 ? indexOfB : Infinity));
+ }
+ });
+ }
+
+ return fuzzysort.go(searchValue, languages, {
+ keys: ['0', '1', '2'],
+ limit: 5,
+ threshold: -10000,
+ }).map(result => result.obj);
+ }
+
+ frequentlyUsed () {
+ const { languages, value } = this.props;
+ const current = languages.find(lang => lang[0] === value);
+ const results = [];
+
+ if (current) {
+ results.push(current);
+ }
+
+ return results;
+ }
+
+ handleClick = e => {
+ const value = e.currentTarget.getAttribute('data-index');
+
+ e.preventDefault();
+
+ this.props.onClose();
+ this.props.onChange(value);
+ };
+
+ handleKeyDown = e => {
+ const { onClose } = this.props;
+ const index = Array.from(this.listNode.childNodes).findIndex(node => node === e.currentTarget);
+
+ let element = null;
+
+ switch(e.key) {
+ case 'Escape':
+ onClose();
+ break;
+ case 'Enter':
+ this.handleClick(e);
+ break;
+ case 'ArrowDown':
+ element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
+ break;
+ case 'ArrowUp':
+ element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
+ break;
+ case 'Tab':
+ if (e.shiftKey) {
+ element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
+ } else {
+ element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
+ }
+ break;
+ case 'Home':
+ element = this.listNode.firstChild;
+ break;
+ case 'End':
+ element = this.listNode.lastChild;
+ break;
+ }
+
+ if (element) {
+ element.focus();
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ };
+
+ handleSearchKeyDown = e => {
+ const { onChange, onClose } = this.props;
+ const { searchValue } = this.state;
+
+ let element = null;
+
+ switch(e.key) {
+ case 'Tab':
+ case 'ArrowDown':
+ element = this.listNode.firstChild;
+
+ if (element) {
+ element.focus();
+ e.preventDefault();
+ e.stopPropagation();
+ }
+
+ break;
+ case 'Enter':
+ element = this.listNode.firstChild;
+
+ if (element) {
+ onChange(element.getAttribute('data-index'));
+ onClose();
+ }
+ break;
+ case 'Escape':
+ if (searchValue !== '') {
+ e.preventDefault();
+ this.handleClear();
+ }
+
+ break;
+ }
+ };
+
+ handleClear = () => {
+ this.setState({ searchValue: '' });
+ };
+
+ renderItem = lang => {
+ const { value } = this.props;
+
+ return (
+
+ {lang[2]} ({lang[1]})
+
+ );
+ };
+
+ render () {
+ const { intl } = this.props;
+ const { searchValue } = this.state;
+ const isSearching = searchValue !== '';
+ const results = this.search();
+
+ return (
+
+
+
+ {!isSearching ? loupeIcon : deleteIcon}
+
+
+
+ {results.map(this.renderItem)}
+
+
+ );
+ }
+
+}
+
+export default @injectIntl
+class LanguageDropdown extends React.PureComponent {
+
+ static propTypes = {
+ value: PropTypes.string,
+ frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string),
+ intl: PropTypes.object.isRequired,
+ onChange: PropTypes.func,
+ onClose: PropTypes.func,
+ };
+
+ state = {
+ open: false,
+ placement: 'bottom',
+ };
+
+ handleToggle = () => {
+ if (this.state.open && this.activeElement) {
+ this.activeElement.focus({ preventScroll: true });
+ }
+
+ this.setState({ open: !this.state.open });
+ };
+
+ handleClose = () => {
+ const { value, onClose } = this.props;
+
+ if (this.state.open && this.activeElement) {
+ this.activeElement.focus({ preventScroll: true });
+ }
+
+ this.setState({ open: false });
+ onClose(value);
+ };
+
+ handleChange = value => {
+ const { onChange } = this.props;
+ onChange(value);
+ };
+
+ setTargetRef = c => {
+ this.target = c;
+ };
+
+ findTarget = () => {
+ return this.target;
+ };
+
+ handleOverlayEnter = (state) => {
+ this.setState({ placement: state.placement });
+ };
+
+ render () {
+ const { value, intl, frequentlyUsedLanguages } = this.props;
+ const { open, placement } = this.state;
+
+ return (
+
+
+
+
+
+
+ {({ props, placement }) => (
+
+ )}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/navigation_bar.js b/app/javascript/mastodon/features/compose/components/navigation_bar.js
deleted file mode 100644
index be979af50..000000000
--- a/app/javascript/mastodon/features/compose/components/navigation_bar.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ActionBar from './action_bar';
-import Avatar from '../../../components/avatar';
-import { Link } from 'react-router-dom';
-import IconButton from '../../../components/icon_button';
-import { FormattedMessage } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-export default class NavigationBar extends ImmutablePureComponent {
-
- static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
- onLogout: PropTypes.func.isRequired,
- onClose: PropTypes.func,
- };
-
- render () {
- return (
-
-
-
{this.props.account.get('acct')}
-
-
-
-
-
-
@{this.props.account.get('acct')}
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/compose/components/navigation_bar.jsx b/app/javascript/mastodon/features/compose/components/navigation_bar.jsx
new file mode 100644
index 000000000..be979af50
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/navigation_bar.jsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ActionBar from './action_bar';
+import Avatar from '../../../components/avatar';
+import { Link } from 'react-router-dom';
+import IconButton from '../../../components/icon_button';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class NavigationBar extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ onLogout: PropTypes.func.isRequired,
+ onClose: PropTypes.func,
+ };
+
+ render () {
+ return (
+
+
+
{this.props.account.get('acct')}
+
+
+
+
+
+
@{this.props.account.get('acct')}
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/poll_button.js b/app/javascript/mastodon/features/compose/components/poll_button.js
deleted file mode 100644
index ff7a104aa..000000000
--- a/app/javascript/mastodon/features/compose/components/poll_button.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import React from 'react';
-import IconButton from '../../../components/icon_button';
-import PropTypes from 'prop-types';
-import { defineMessages, injectIntl } from 'react-intl';
-
-const messages = defineMessages({
- add_poll: { id: 'poll_button.add_poll', defaultMessage: 'Add a poll' },
- remove_poll: { id: 'poll_button.remove_poll', defaultMessage: 'Remove poll' },
-});
-
-const iconStyle = {
- height: null,
- lineHeight: '27px',
-};
-
-export default
-@injectIntl
-class PollButton extends React.PureComponent {
-
- static propTypes = {
- disabled: PropTypes.bool,
- unavailable: PropTypes.bool,
- active: PropTypes.bool,
- onClick: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- handleClick = () => {
- this.props.onClick();
- };
-
- render () {
- const { intl, active, unavailable, disabled } = this.props;
-
- if (unavailable) {
- return null;
- }
-
- return (
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/compose/components/poll_button.jsx b/app/javascript/mastodon/features/compose/components/poll_button.jsx
new file mode 100644
index 000000000..ff7a104aa
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/poll_button.jsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import IconButton from '../../../components/icon_button';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+ add_poll: { id: 'poll_button.add_poll', defaultMessage: 'Add a poll' },
+ remove_poll: { id: 'poll_button.remove_poll', defaultMessage: 'Remove poll' },
+});
+
+const iconStyle = {
+ height: null,
+ lineHeight: '27px',
+};
+
+export default
+@injectIntl
+class PollButton extends React.PureComponent {
+
+ static propTypes = {
+ disabled: PropTypes.bool,
+ unavailable: PropTypes.bool,
+ active: PropTypes.bool,
+ onClick: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleClick = () => {
+ this.props.onClick();
+ };
+
+ render () {
+ const { intl, active, unavailable, disabled } = this.props;
+
+ if (unavailable) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/poll_form.js b/app/javascript/mastodon/features/compose/components/poll_form.js
deleted file mode 100644
index bb03f6f66..000000000
--- a/app/javascript/mastodon/features/compose/components/poll_form.js
+++ /dev/null
@@ -1,182 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import IconButton from 'mastodon/components/icon_button';
-import Icon from 'mastodon/components/icon';
-import AutosuggestInput from 'mastodon/components/autosuggest_input';
-import classNames from 'classnames';
-
-const messages = defineMessages({
- option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Choice {number}' },
- add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add a choice' },
- remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this choice' },
- poll_duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' },
- switchToMultiple: { id: 'compose_form.poll.switch_to_multiple', defaultMessage: 'Change poll to allow multiple choices' },
- switchToSingle: { id: 'compose_form.poll.switch_to_single', defaultMessage: 'Change poll to allow for a single choice' },
- minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
- hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
- days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
-});
-
-@injectIntl
-class Option extends React.PureComponent {
-
- static propTypes = {
- title: PropTypes.string.isRequired,
- lang: PropTypes.string,
- index: PropTypes.number.isRequired,
- isPollMultiple: PropTypes.bool,
- autoFocus: PropTypes.bool,
- onChange: PropTypes.func.isRequired,
- onRemove: PropTypes.func.isRequired,
- onToggleMultiple: PropTypes.func.isRequired,
- suggestions: ImmutablePropTypes.list,
- onClearSuggestions: PropTypes.func.isRequired,
- onFetchSuggestions: PropTypes.func.isRequired,
- onSuggestionSelected: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- handleOptionTitleChange = e => {
- this.props.onChange(this.props.index, e.target.value);
- };
-
- handleOptionRemove = () => {
- this.props.onRemove(this.props.index);
- };
-
-
- handleToggleMultiple = e => {
- this.props.onToggleMultiple();
- e.preventDefault();
- e.stopPropagation();
- };
-
- handleCheckboxKeypress = e => {
- if (e.key === 'Enter' || e.key === ' ') {
- this.handleToggleMultiple(e);
- }
- };
-
- onSuggestionsClearRequested = () => {
- this.props.onClearSuggestions();
- };
-
- onSuggestionsFetchRequested = (token) => {
- this.props.onFetchSuggestions(token);
- };
-
- onSuggestionSelected = (tokenStart, token, value) => {
- this.props.onSuggestionSelected(tokenStart, token, value, ['poll', 'options', this.props.index]);
- };
-
- render () {
- const { isPollMultiple, title, lang, index, autoFocus, intl } = this.props;
-
- return (
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-
-}
-
-export default
-@injectIntl
-class PollForm extends ImmutablePureComponent {
-
- static propTypes = {
- options: ImmutablePropTypes.list,
- lang: PropTypes.string,
- expiresIn: PropTypes.number,
- isMultiple: PropTypes.bool,
- onChangeOption: PropTypes.func.isRequired,
- onAddOption: PropTypes.func.isRequired,
- onRemoveOption: PropTypes.func.isRequired,
- onChangeSettings: PropTypes.func.isRequired,
- suggestions: ImmutablePropTypes.list,
- onClearSuggestions: PropTypes.func.isRequired,
- onFetchSuggestions: PropTypes.func.isRequired,
- onSuggestionSelected: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- handleAddOption = () => {
- this.props.onAddOption('');
- };
-
- handleSelectDuration = e => {
- this.props.onChangeSettings(e.target.value, this.props.isMultiple);
- };
-
- handleToggleMultiple = () => {
- this.props.onChangeSettings(this.props.expiresIn, !this.props.isMultiple);
- };
-
- render () {
- const { options, lang, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl, ...other } = this.props;
-
- if (!options) {
- return null;
- }
-
- const autoFocusIndex = options.indexOf('');
-
- return (
-
-
- {options.map((title, i) => )}
-
-
-
- = 4} className='button button-secondary' onClick={this.handleAddOption}>
-
- {/* eslint-disable-next-line jsx-a11y/no-onchange */}
-
- {intl.formatMessage(messages.minutes, { number: 5 })}
- {intl.formatMessage(messages.minutes, { number: 30 })}
- {intl.formatMessage(messages.hours, { number: 1 })}
- {intl.formatMessage(messages.hours, { number: 6 })}
- {intl.formatMessage(messages.hours, { number: 12 })}
- {intl.formatMessage(messages.days, { number: 1 })}
- {intl.formatMessage(messages.days, { number: 3 })}
- {intl.formatMessage(messages.days, { number: 7 })}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/compose/components/poll_form.jsx b/app/javascript/mastodon/features/compose/components/poll_form.jsx
new file mode 100644
index 000000000..bb03f6f66
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/poll_form.jsx
@@ -0,0 +1,182 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import IconButton from 'mastodon/components/icon_button';
+import Icon from 'mastodon/components/icon';
+import AutosuggestInput from 'mastodon/components/autosuggest_input';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+ option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Choice {number}' },
+ add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add a choice' },
+ remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this choice' },
+ poll_duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' },
+ switchToMultiple: { id: 'compose_form.poll.switch_to_multiple', defaultMessage: 'Change poll to allow multiple choices' },
+ switchToSingle: { id: 'compose_form.poll.switch_to_single', defaultMessage: 'Change poll to allow for a single choice' },
+ minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
+ hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
+ days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
+});
+
+@injectIntl
+class Option extends React.PureComponent {
+
+ static propTypes = {
+ title: PropTypes.string.isRequired,
+ lang: PropTypes.string,
+ index: PropTypes.number.isRequired,
+ isPollMultiple: PropTypes.bool,
+ autoFocus: PropTypes.bool,
+ onChange: PropTypes.func.isRequired,
+ onRemove: PropTypes.func.isRequired,
+ onToggleMultiple: PropTypes.func.isRequired,
+ suggestions: ImmutablePropTypes.list,
+ onClearSuggestions: PropTypes.func.isRequired,
+ onFetchSuggestions: PropTypes.func.isRequired,
+ onSuggestionSelected: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleOptionTitleChange = e => {
+ this.props.onChange(this.props.index, e.target.value);
+ };
+
+ handleOptionRemove = () => {
+ this.props.onRemove(this.props.index);
+ };
+
+
+ handleToggleMultiple = e => {
+ this.props.onToggleMultiple();
+ e.preventDefault();
+ e.stopPropagation();
+ };
+
+ handleCheckboxKeypress = e => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ this.handleToggleMultiple(e);
+ }
+ };
+
+ onSuggestionsClearRequested = () => {
+ this.props.onClearSuggestions();
+ };
+
+ onSuggestionsFetchRequested = (token) => {
+ this.props.onFetchSuggestions(token);
+ };
+
+ onSuggestionSelected = (tokenStart, token, value) => {
+ this.props.onSuggestionSelected(tokenStart, token, value, ['poll', 'options', this.props.index]);
+ };
+
+ render () {
+ const { isPollMultiple, title, lang, index, autoFocus, intl } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
+
+export default
+@injectIntl
+class PollForm extends ImmutablePureComponent {
+
+ static propTypes = {
+ options: ImmutablePropTypes.list,
+ lang: PropTypes.string,
+ expiresIn: PropTypes.number,
+ isMultiple: PropTypes.bool,
+ onChangeOption: PropTypes.func.isRequired,
+ onAddOption: PropTypes.func.isRequired,
+ onRemoveOption: PropTypes.func.isRequired,
+ onChangeSettings: PropTypes.func.isRequired,
+ suggestions: ImmutablePropTypes.list,
+ onClearSuggestions: PropTypes.func.isRequired,
+ onFetchSuggestions: PropTypes.func.isRequired,
+ onSuggestionSelected: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleAddOption = () => {
+ this.props.onAddOption('');
+ };
+
+ handleSelectDuration = e => {
+ this.props.onChangeSettings(e.target.value, this.props.isMultiple);
+ };
+
+ handleToggleMultiple = () => {
+ this.props.onChangeSettings(this.props.expiresIn, !this.props.isMultiple);
+ };
+
+ render () {
+ const { options, lang, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl, ...other } = this.props;
+
+ if (!options) {
+ return null;
+ }
+
+ const autoFocusIndex = options.indexOf('');
+
+ return (
+
+
+ {options.map((title, i) => )}
+
+
+
+ = 4} className='button button-secondary' onClick={this.handleAddOption}>
+
+ {/* eslint-disable-next-line jsx-a11y/no-onchange */}
+
+ {intl.formatMessage(messages.minutes, { number: 5 })}
+ {intl.formatMessage(messages.minutes, { number: 30 })}
+ {intl.formatMessage(messages.hours, { number: 1 })}
+ {intl.formatMessage(messages.hours, { number: 6 })}
+ {intl.formatMessage(messages.hours, { number: 12 })}
+ {intl.formatMessage(messages.days, { number: 1 })}
+ {intl.formatMessage(messages.days, { number: 3 })}
+ {intl.formatMessage(messages.days, { number: 7 })}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
deleted file mode 100644
index ffd1094cd..000000000
--- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
+++ /dev/null
@@ -1,287 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { injectIntl, defineMessages } from 'react-intl';
-import IconButton from '../../../components/icon_button';
-import Overlay from 'react-overlays/Overlay';
-import { supportsPassiveEvents } from 'detect-passive-events';
-import classNames from 'classnames';
-import Icon from 'mastodon/components/icon';
-
-const messages = defineMessages({
- public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
- public_long: { id: 'privacy.public.long', defaultMessage: 'Visible for all' },
- unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
- unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Visible for all, but opted-out of discovery features' },
- private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
- private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' },
- direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
- direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' },
- change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
-});
-
-const listenerOptions = supportsPassiveEvents ? { 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();
- }
- };
-
- handleKeyDown = e => {
- const { items } = this.props;
- const value = e.currentTarget.getAttribute('data-index');
- const index = items.findIndex(item => {
- return (item.value === value);
- });
- let element = null;
-
- switch(e.key) {
- case 'Escape':
- this.props.onClose();
- break;
- case 'Enter':
- this.handleClick(e);
- break;
- case 'ArrowDown':
- element = this.node.childNodes[index + 1] || this.node.firstChild;
- break;
- case 'ArrowUp':
- element = this.node.childNodes[index - 1] || this.node.lastChild;
- break;
- case 'Tab':
- if (e.shiftKey) {
- element = this.node.childNodes[index - 1] || this.node.lastChild;
- } else {
- element = this.node.childNodes[index + 1] || this.node.firstChild;
- }
- break;
- case 'Home':
- element = this.node.firstChild;
- break;
- case 'End':
- element = this.node.lastChild;
- break;
- }
-
- if (element) {
- element.focus();
- this.props.onChange(element.getAttribute('data-index'));
- e.preventDefault();
- e.stopPropagation();
- }
- };
-
- handleClick = e => {
- 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);
- if (this.focusedItem) this.focusedItem.focus({ preventScroll: true });
- }
-
- componentWillUnmount () {
- document.removeEventListener('click', this.handleDocumentClick, false);
- document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
- }
-
- setRef = c => {
- this.node = c;
- };
-
- setFocusRef = c => {
- this.focusedItem = c;
- };
-
- render () {
- const { style, items, value } = this.props;
-
- return (
-
- {items.map(item => (
-
-
-
-
-
-
- {item.text}
- {item.meta}
-
-
- ))}
-
- );
- }
-
-}
-
-export default @injectIntl
-class PrivacyDropdown extends React.PureComponent {
-
- static propTypes = {
- isUserTouching: PropTypes.func,
- onModalOpen: PropTypes.func,
- onModalClose: PropTypes.func,
- value: PropTypes.string.isRequired,
- onChange: PropTypes.func.isRequired,
- noDirect: PropTypes.bool,
- container: PropTypes.func,
- disabled: PropTypes.bool,
- intl: PropTypes.object.isRequired,
- };
-
- state = {
- open: false,
- placement: 'bottom',
- };
-
- handleToggle = () => {
- if (this.props.isUserTouching && 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 {
- if (this.state.open && this.activeElement) {
- this.activeElement.focus({ preventScroll: true });
- }
- 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 'Escape':
- this.handleClose();
- break;
- }
- };
-
- handleMouseDown = () => {
- if (!this.state.open) {
- this.activeElement = document.activeElement;
- }
- };
-
- handleButtonKeyDown = (e) => {
- switch(e.key) {
- case ' ':
- case 'Enter':
- this.handleMouseDown();
- break;
- }
- };
-
- handleClose = () => {
- if (this.state.open && this.activeElement) {
- this.activeElement.focus({ preventScroll: true });
- }
- 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', 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) },
- ];
-
- if (!this.props.noDirect) {
- this.options.push(
- { icon: 'at', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
- );
- }
- }
-
- setTargetRef = c => {
- this.target = c;
- };
-
- findTarget = () => {
- return this.target;
- };
-
- handleOverlayEnter = (state) => {
- this.setState({ placement: state.placement });
- };
-
- render () {
- const { value, container, disabled, intl } = this.props;
- const { open, placement } = this.state;
-
- const valueOption = this.options.find(item => item.value === value);
-
- return (
-
-
-
-
-
-
- {({ props, placement }) => (
-
- )}
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx b/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx
new file mode 100644
index 000000000..ffd1094cd
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx
@@ -0,0 +1,287 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+import Overlay from 'react-overlays/Overlay';
+import { supportsPassiveEvents } from 'detect-passive-events';
+import classNames from 'classnames';
+import Icon from 'mastodon/components/icon';
+
+const messages = defineMessages({
+ public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
+ public_long: { id: 'privacy.public.long', defaultMessage: 'Visible for all' },
+ unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+ unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Visible for all, but opted-out of discovery features' },
+ private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
+ private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' },
+ direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
+ direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' },
+ change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
+});
+
+const listenerOptions = supportsPassiveEvents ? { 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();
+ }
+ };
+
+ handleKeyDown = e => {
+ const { items } = this.props;
+ const value = e.currentTarget.getAttribute('data-index');
+ const index = items.findIndex(item => {
+ return (item.value === value);
+ });
+ let element = null;
+
+ switch(e.key) {
+ case 'Escape':
+ this.props.onClose();
+ break;
+ case 'Enter':
+ this.handleClick(e);
+ break;
+ case 'ArrowDown':
+ element = this.node.childNodes[index + 1] || this.node.firstChild;
+ break;
+ case 'ArrowUp':
+ element = this.node.childNodes[index - 1] || this.node.lastChild;
+ break;
+ case 'Tab':
+ if (e.shiftKey) {
+ element = this.node.childNodes[index - 1] || this.node.lastChild;
+ } else {
+ element = this.node.childNodes[index + 1] || this.node.firstChild;
+ }
+ break;
+ case 'Home':
+ element = this.node.firstChild;
+ break;
+ case 'End':
+ element = this.node.lastChild;
+ break;
+ }
+
+ if (element) {
+ element.focus();
+ this.props.onChange(element.getAttribute('data-index'));
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ };
+
+ handleClick = e => {
+ 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);
+ if (this.focusedItem) this.focusedItem.focus({ preventScroll: true });
+ }
+
+ componentWillUnmount () {
+ document.removeEventListener('click', this.handleDocumentClick, false);
+ document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ setRef = c => {
+ this.node = c;
+ };
+
+ setFocusRef = c => {
+ this.focusedItem = c;
+ };
+
+ render () {
+ const { style, items, value } = this.props;
+
+ return (
+
+ {items.map(item => (
+
+
+
+
+
+
+ {item.text}
+ {item.meta}
+
+
+ ))}
+
+ );
+ }
+
+}
+
+export default @injectIntl
+class PrivacyDropdown extends React.PureComponent {
+
+ static propTypes = {
+ isUserTouching: PropTypes.func,
+ onModalOpen: PropTypes.func,
+ onModalClose: PropTypes.func,
+ value: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ noDirect: PropTypes.bool,
+ container: PropTypes.func,
+ disabled: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ open: false,
+ placement: 'bottom',
+ };
+
+ handleToggle = () => {
+ if (this.props.isUserTouching && 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 {
+ if (this.state.open && this.activeElement) {
+ this.activeElement.focus({ preventScroll: true });
+ }
+ 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 'Escape':
+ this.handleClose();
+ break;
+ }
+ };
+
+ handleMouseDown = () => {
+ if (!this.state.open) {
+ this.activeElement = document.activeElement;
+ }
+ };
+
+ handleButtonKeyDown = (e) => {
+ switch(e.key) {
+ case ' ':
+ case 'Enter':
+ this.handleMouseDown();
+ break;
+ }
+ };
+
+ handleClose = () => {
+ if (this.state.open && this.activeElement) {
+ this.activeElement.focus({ preventScroll: true });
+ }
+ 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', 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) },
+ ];
+
+ if (!this.props.noDirect) {
+ this.options.push(
+ { icon: 'at', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
+ );
+ }
+ }
+
+ setTargetRef = c => {
+ this.target = c;
+ };
+
+ findTarget = () => {
+ return this.target;
+ };
+
+ handleOverlayEnter = (state) => {
+ this.setState({ placement: state.placement });
+ };
+
+ render () {
+ const { value, container, disabled, intl } = this.props;
+ const { open, placement } = this.state;
+
+ const valueOption = this.options.find(item => item.value === value);
+
+ return (
+
+
+
+
+
+
+ {({ props, placement }) => (
+
+ )}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/reply_indicator.js b/app/javascript/mastodon/features/compose/components/reply_indicator.js
deleted file mode 100644
index 98b142ab8..000000000
--- a/app/javascript/mastodon/features/compose/components/reply_indicator.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import Avatar from '../../../components/avatar';
-import IconButton from '../../../components/icon_button';
-import DisplayName from '../../../components/display_name';
-import { defineMessages, injectIntl } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import AttachmentList from 'mastodon/components/attachment_list';
-
-const messages = defineMessages({
- cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
-});
-
-export default @injectIntl
-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.ctrlKey || e.metaKey)) {
- e.preventDefault();
- this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
- }
- };
-
- render () {
- const { status, intl } = this.props;
-
- if (!status) {
- return null;
- }
-
- const content = { __html: status.get('contentHtml') };
-
- return (
-
-
-
-
-
- {status.get('media_attachments').size > 0 && (
-
- )}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/compose/components/reply_indicator.jsx b/app/javascript/mastodon/features/compose/components/reply_indicator.jsx
new file mode 100644
index 000000000..98b142ab8
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/reply_indicator.jsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Avatar from '../../../components/avatar';
+import IconButton from '../../../components/icon_button';
+import DisplayName from '../../../components/display_name';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import AttachmentList from 'mastodon/components/attachment_list';
+
+const messages = defineMessages({
+ cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
+});
+
+export default @injectIntl
+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.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
+ }
+ };
+
+ render () {
+ const { status, intl } = this.props;
+
+ if (!status) {
+ return null;
+ }
+
+ const content = { __html: status.get('contentHtml') };
+
+ return (
+
+
+
+
+
+ {status.get('media_attachments').size > 0 && (
+
+ )}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/search.js b/app/javascript/mastodon/features/compose/components/search.js
deleted file mode 100644
index 0539c6b80..000000000
--- a/app/javascript/mastodon/features/compose/components/search.js
+++ /dev/null
@@ -1,147 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import Overlay from 'react-overlays/Overlay';
-import { searchEnabled } from '../../../initial_state';
-import Icon from 'mastodon/components/icon';
-
-const messages = defineMessages({
- placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
- placeholderSignedIn: { id: 'search.search_or_paste', defaultMessage: 'Search or paste URL' },
-});
-
-class SearchPopout extends React.PureComponent {
-
- render () {
- const extraInformation = searchEnabled ? : ;
- return (
-
-
-
-
- #example
- @username@domain
- URL
- URL
-
-
- {extraInformation}
-
- );
- }
-
-}
-
-export default @injectIntl
-class Search extends React.PureComponent {
-
- static contextTypes = {
- router: PropTypes.object.isRequired,
- identity: PropTypes.object.isRequired,
- };
-
- 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,
- openInRoute: PropTypes.bool,
- intl: PropTypes.object.isRequired,
- singleColumn: PropTypes.bool,
- };
-
- state = {
- expanded: false,
- };
-
- setRef = c => {
- this.searchForm = c;
- };
-
- handleChange = (e) => {
- this.props.onChange(e.target.value);
- };
-
- handleClear = (e) => {
- e.preventDefault();
-
- if (this.props.value.length > 0 || this.props.submitted) {
- this.props.onClear();
- }
- };
-
- handleKeyUp = (e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
-
- this.props.onSubmit();
-
- if (this.props.openInRoute) {
- this.context.router.history.push('/search');
- }
- } else if (e.key === 'Escape') {
- document.querySelector('.ui').parentElement.focus();
- }
- };
-
- handleFocus = () => {
- this.setState({ expanded: true });
- this.props.onShow();
-
- if (this.searchForm && !this.props.singleColumn) {
- const { left, right } = this.searchForm.getBoundingClientRect();
- if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
- this.searchForm.scrollIntoView();
- }
- }
- };
-
- handleBlur = () => {
- this.setState({ expanded: false });
- };
-
- findTarget = () => {
- return this.searchForm;
- };
-
- render () {
- const { intl, value, submitted } = this.props;
- const { expanded } = this.state;
- const { signedIn } = this.context.identity;
- const hasValue = value.length > 0 || submitted;
-
- return (
-
-
-
-
-
-
-
-
- {({ props, placement }) => (
-
- )}
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/compose/components/search.jsx b/app/javascript/mastodon/features/compose/components/search.jsx
new file mode 100644
index 000000000..0539c6b80
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/search.jsx
@@ -0,0 +1,147 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Overlay from 'react-overlays/Overlay';
+import { searchEnabled } from '../../../initial_state';
+import Icon from 'mastodon/components/icon';
+
+const messages = defineMessages({
+ placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
+ placeholderSignedIn: { id: 'search.search_or_paste', defaultMessage: 'Search or paste URL' },
+});
+
+class SearchPopout extends React.PureComponent {
+
+ render () {
+ const extraInformation = searchEnabled ? : ;
+ return (
+
+
+
+
+ #example
+ @username@domain
+ URL
+ URL
+
+
+ {extraInformation}
+
+ );
+ }
+
+}
+
+export default @injectIntl
+class Search extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object.isRequired,
+ identity: PropTypes.object.isRequired,
+ };
+
+ 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,
+ openInRoute: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ singleColumn: PropTypes.bool,
+ };
+
+ state = {
+ expanded: false,
+ };
+
+ setRef = c => {
+ this.searchForm = c;
+ };
+
+ handleChange = (e) => {
+ this.props.onChange(e.target.value);
+ };
+
+ handleClear = (e) => {
+ e.preventDefault();
+
+ if (this.props.value.length > 0 || this.props.submitted) {
+ this.props.onClear();
+ }
+ };
+
+ handleKeyUp = (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+
+ this.props.onSubmit();
+
+ if (this.props.openInRoute) {
+ this.context.router.history.push('/search');
+ }
+ } else if (e.key === 'Escape') {
+ document.querySelector('.ui').parentElement.focus();
+ }
+ };
+
+ handleFocus = () => {
+ this.setState({ expanded: true });
+ this.props.onShow();
+
+ if (this.searchForm && !this.props.singleColumn) {
+ const { left, right } = this.searchForm.getBoundingClientRect();
+ if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
+ this.searchForm.scrollIntoView();
+ }
+ }
+ };
+
+ handleBlur = () => {
+ this.setState({ expanded: false });
+ };
+
+ findTarget = () => {
+ return this.searchForm;
+ };
+
+ render () {
+ const { intl, value, submitted } = this.props;
+ const { expanded } = this.state;
+ const { signedIn } = this.context.identity;
+ const hasValue = value.length > 0 || submitted;
+
+ return (
+
+
+
+
+
+
+
+
+ {({ props, placement }) => (
+
+ )}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js
deleted file mode 100644
index 44ab43638..000000000
--- a/app/javascript/mastodon/features/compose/components/search_results.js
+++ /dev/null
@@ -1,140 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
-import AccountContainer from '../../../containers/account_container';
-import StatusContainer from '../../../containers/status_container';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { ImmutableHashtag as Hashtag } from '../../../components/hashtag';
-import Icon from 'mastodon/components/icon';
-import { searchEnabled } from '../../../initial_state';
-import LoadMore from 'mastodon/components/load_more';
-
-const messages = defineMessages({
- dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
-});
-
-export default @injectIntl
-class SearchResults extends ImmutablePureComponent {
-
- static propTypes = {
- results: ImmutablePropTypes.map.isRequired,
- suggestions: ImmutablePropTypes.list.isRequired,
- fetchSuggestions: PropTypes.func.isRequired,
- expandSearch: PropTypes.func.isRequired,
- dismissSuggestion: PropTypes.func.isRequired,
- searchTerm: PropTypes.string,
- intl: PropTypes.object.isRequired,
- };
-
- componentDidMount () {
- if (this.props.searchTerm === '') {
- this.props.fetchSuggestions();
- }
- }
-
- componentDidUpdate () {
- if (this.props.searchTerm === '') {
- this.props.fetchSuggestions();
- }
- }
-
- handleLoadMoreAccounts = () => this.props.expandSearch('accounts');
-
- handleLoadMoreStatuses = () => this.props.expandSearch('statuses');
-
- handleLoadMoreHashtags = () => this.props.expandSearch('hashtags');
-
- render () {
- const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props;
-
- if (searchTerm === '' && !suggestions.isEmpty()) {
- return (
-
-
-
-
-
-
-
- {suggestions && suggestions.map(suggestion => (
-
- ))}
-
-
- );
- }
-
- 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 =>
)}
-
- {results.get('accounts').size >= 5 &&
}
-
- );
- }
-
- if (results.get('statuses') && results.get('statuses').size > 0) {
- count += results.get('statuses').size;
- statuses = (
-
-
-
- {results.get('statuses').map(statusId => )}
-
- {results.get('statuses').size >= 5 && }
-
- );
- } else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) {
- statuses = (
-
- );
- }
-
- if (results.get('hashtags') && results.get('hashtags').size > 0) {
- count += results.get('hashtags').size;
- hashtags = (
-
-
-
- {results.get('hashtags').map(hashtag => )}
-
- {results.get('hashtags').size >= 5 && }
-
- );
- }
-
- return (
-
-
-
-
-
-
- {accounts}
- {statuses}
- {hashtags}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/compose/components/search_results.jsx b/app/javascript/mastodon/features/compose/components/search_results.jsx
new file mode 100644
index 000000000..44ab43638
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/search_results.jsx
@@ -0,0 +1,140 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import AccountContainer from '../../../containers/account_container';
+import StatusContainer from '../../../containers/status_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { ImmutableHashtag as Hashtag } from '../../../components/hashtag';
+import Icon from 'mastodon/components/icon';
+import { searchEnabled } from '../../../initial_state';
+import LoadMore from 'mastodon/components/load_more';
+
+const messages = defineMessages({
+ dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
+});
+
+export default @injectIntl
+class SearchResults extends ImmutablePureComponent {
+
+ static propTypes = {
+ results: ImmutablePropTypes.map.isRequired,
+ suggestions: ImmutablePropTypes.list.isRequired,
+ fetchSuggestions: PropTypes.func.isRequired,
+ expandSearch: PropTypes.func.isRequired,
+ dismissSuggestion: PropTypes.func.isRequired,
+ searchTerm: PropTypes.string,
+ intl: PropTypes.object.isRequired,
+ };
+
+ componentDidMount () {
+ if (this.props.searchTerm === '') {
+ this.props.fetchSuggestions();
+ }
+ }
+
+ componentDidUpdate () {
+ if (this.props.searchTerm === '') {
+ this.props.fetchSuggestions();
+ }
+ }
+
+ handleLoadMoreAccounts = () => this.props.expandSearch('accounts');
+
+ handleLoadMoreStatuses = () => this.props.expandSearch('statuses');
+
+ handleLoadMoreHashtags = () => this.props.expandSearch('hashtags');
+
+ render () {
+ const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props;
+
+ if (searchTerm === '' && !suggestions.isEmpty()) {
+ return (
+
+
+
+
+
+
+
+ {suggestions && suggestions.map(suggestion => (
+
+ ))}
+
+
+ );
+ }
+
+ 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 =>
)}
+
+ {results.get('accounts').size >= 5 &&
}
+
+ );
+ }
+
+ if (results.get('statuses') && results.get('statuses').size > 0) {
+ count += results.get('statuses').size;
+ statuses = (
+
+
+
+ {results.get('statuses').map(statusId => )}
+
+ {results.get('statuses').size >= 5 && }
+
+ );
+ } else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) {
+ statuses = (
+
+ );
+ }
+
+ if (results.get('hashtags') && results.get('hashtags').size > 0) {
+ count += results.get('hashtags').size;
+ hashtags = (
+
+
+
+ {results.get('hashtags').map(hashtag => )}
+
+ {results.get('hashtags').size >= 5 && }
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {accounts}
+ {statuses}
+ {hashtags}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/text_icon_button.js b/app/javascript/mastodon/features/compose/components/text_icon_button.js
deleted file mode 100644
index 73da32ad5..000000000
--- a/app/javascript/mastodon/features/compose/components/text_icon_button.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-const iconStyle = {
- height: null,
- lineHeight: '27px',
- width: `${18 * 1.28571429}px`,
-};
-
-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,
- };
-
- render () {
- const { label, title, active, ariaControls } = this.props;
-
- return (
-
- {label}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/compose/components/text_icon_button.jsx b/app/javascript/mastodon/features/compose/components/text_icon_button.jsx
new file mode 100644
index 000000000..73da32ad5
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/text_icon_button.jsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const iconStyle = {
+ height: null,
+ lineHeight: '27px',
+ width: `${18 * 1.28571429}px`,
+};
+
+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,
+ };
+
+ render () {
+ const { label, title, active, ariaControls } = this.props;
+
+ return (
+
+ {label}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js
deleted file mode 100644
index f114680b9..000000000
--- a/app/javascript/mastodon/features/compose/components/upload.js
+++ /dev/null
@@ -1,66 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import Motion from '../../ui/util/optional_motion';
-import spring from 'react-motion/lib/spring';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { FormattedMessage } from 'react-intl';
-import Icon from 'mastodon/components/icon';
-
-export default class Upload extends ImmutablePureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- };
-
- static propTypes = {
- media: ImmutablePropTypes.map.isRequired,
- onUndo: PropTypes.func.isRequired,
- onOpenFocalPoint: PropTypes.func.isRequired,
- };
-
- handleUndoClick = e => {
- e.stopPropagation();
- this.props.onUndo(this.props.media.get('id'));
- };
-
- handleFocalPointClick = e => {
- e.stopPropagation();
- this.props.onOpenFocalPoint(this.props.media.get('id'));
- };
-
- render () {
- const { media } = this.props;
-
- if (!media) {
- return null;
- }
-
- const focusX = media.getIn(['meta', 'focus', 'x']);
- const focusY = media.getIn(['meta', 'focus', 'y']);
- const x = ((focusX / 2) + .5) * 100;
- const y = ((focusY / -2) + .5) * 100;
-
- return (
-
-
- {({ scale }) => (
-
-
-
-
-
-
- {(media.get('description') || '').length === 0 && (
-
-
-
- )}
-
- )}
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/compose/components/upload.jsx b/app/javascript/mastodon/features/compose/components/upload.jsx
new file mode 100644
index 000000000..f114680b9
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/upload.jsx
@@ -0,0 +1,66 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Motion from '../../ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage } from 'react-intl';
+import Icon from 'mastodon/components/icon';
+
+export default class Upload extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ media: ImmutablePropTypes.map.isRequired,
+ onUndo: PropTypes.func.isRequired,
+ onOpenFocalPoint: PropTypes.func.isRequired,
+ };
+
+ handleUndoClick = e => {
+ e.stopPropagation();
+ this.props.onUndo(this.props.media.get('id'));
+ };
+
+ handleFocalPointClick = e => {
+ e.stopPropagation();
+ this.props.onOpenFocalPoint(this.props.media.get('id'));
+ };
+
+ render () {
+ const { media } = this.props;
+
+ if (!media) {
+ return null;
+ }
+
+ const focusX = media.getIn(['meta', 'focus', 'x']);
+ const focusY = media.getIn(['meta', 'focus', 'y']);
+ const x = ((focusX / 2) + .5) * 100;
+ const y = ((focusY / -2) + .5) * 100;
+
+ return (
+
+
+ {({ scale }) => (
+
+
+
+
+
+
+ {(media.get('description') || '').length === 0 && (
+
+
+
+ )}
+
+ )}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/upload_button.js b/app/javascript/mastodon/features/compose/components/upload_button.js
deleted file mode 100644
index 964340d82..000000000
--- a/app/javascript/mastodon/features/compose/components/upload_button.js
+++ /dev/null
@@ -1,83 +0,0 @@
-import React from 'react';
-import IconButton from '../../../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 images, a video or an audio file' },
-});
-
-const makeMapStateToProps = () => {
- const mapStateToProps = state => ({
- acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
- });
-
- return mapStateToProps;
-};
-
-const iconStyle = {
- height: null,
- lineHeight: '27px',
-};
-
-export default @connect(makeMapStateToProps)
-@injectIntl
-class UploadButton extends ImmutablePureComponent {
-
- static propTypes = {
- disabled: PropTypes.bool,
- unavailable: 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, unavailable, disabled, acceptContentTypes } = this.props;
-
- if (unavailable) {
- return null;
- }
-
- const message = intl.formatMessage(messages.upload);
-
- return (
-
-
-
- {message}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/compose/components/upload_button.jsx b/app/javascript/mastodon/features/compose/components/upload_button.jsx
new file mode 100644
index 000000000..964340d82
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/upload_button.jsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import IconButton from '../../../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 images, a video or an audio file' },
+});
+
+const makeMapStateToProps = () => {
+ const mapStateToProps = state => ({
+ acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
+ });
+
+ return mapStateToProps;
+};
+
+const iconStyle = {
+ height: null,
+ lineHeight: '27px',
+};
+
+export default @connect(makeMapStateToProps)
+@injectIntl
+class UploadButton extends ImmutablePureComponent {
+
+ static propTypes = {
+ disabled: PropTypes.bool,
+ unavailable: 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, unavailable, disabled, acceptContentTypes } = this.props;
+
+ if (unavailable) {
+ return null;
+ }
+
+ const message = intl.formatMessage(messages.upload);
+
+ return (
+
+
+
+ {message}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/upload_form.js b/app/javascript/mastodon/features/compose/components/upload_form.js
deleted file mode 100644
index 9ff2aa0fa..000000000
--- a/app/javascript/mastodon/features/compose/components/upload_form.js
+++ /dev/null
@@ -1,32 +0,0 @@
-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';
-import SensitiveButtonContainer from '../containers/sensitive_button_container';
-
-export default class UploadForm extends ImmutablePureComponent {
-
- static propTypes = {
- mediaIds: ImmutablePropTypes.list.isRequired,
- };
-
- render () {
- const { mediaIds } = this.props;
-
- return (
-
-
-
-
- {mediaIds.map(id => (
-
- ))}
-
-
- {!mediaIds.isEmpty() &&
}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/compose/components/upload_form.jsx b/app/javascript/mastodon/features/compose/components/upload_form.jsx
new file mode 100644
index 000000000..9ff2aa0fa
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/upload_form.jsx
@@ -0,0 +1,32 @@
+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';
+import SensitiveButtonContainer from '../containers/sensitive_button_container';
+
+export default class UploadForm extends ImmutablePureComponent {
+
+ static propTypes = {
+ mediaIds: ImmutablePropTypes.list.isRequired,
+ };
+
+ render () {
+ const { mediaIds } = this.props;
+
+ return (
+
+
+
+
+ {mediaIds.map(id => (
+
+ ))}
+
+
+ {!mediaIds.isEmpty() &&
}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/upload_progress.js b/app/javascript/mastodon/features/compose/components/upload_progress.js
deleted file mode 100644
index cabf520fd..000000000
--- a/app/javascript/mastodon/features/compose/components/upload_progress.js
+++ /dev/null
@@ -1,52 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import Motion from '../../ui/util/optional_motion';
-import spring from 'react-motion/lib/spring';
-import Icon from 'mastodon/components/icon';
-import { FormattedMessage } from 'react-intl';
-
-export default class UploadProgress extends React.PureComponent {
-
- static propTypes = {
- active: PropTypes.bool,
- progress: PropTypes.number,
- isProcessing: PropTypes.bool,
- };
-
- render () {
- const { active, progress, isProcessing } = this.props;
-
- if (!active) {
- return null;
- }
-
- let message;
-
- if (isProcessing) {
- message = ;
- } else {
- message = ;
- }
-
- return (
-
-
-
-
-
-
- {message}
-
-
-
- {({ width }) =>
-
- }
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/compose/components/upload_progress.jsx b/app/javascript/mastodon/features/compose/components/upload_progress.jsx
new file mode 100644
index 000000000..cabf520fd
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/upload_progress.jsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from '../../ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import Icon from 'mastodon/components/icon';
+import { FormattedMessage } from 'react-intl';
+
+export default class UploadProgress extends React.PureComponent {
+
+ static propTypes = {
+ active: PropTypes.bool,
+ progress: PropTypes.number,
+ isProcessing: PropTypes.bool,
+ };
+
+ render () {
+ const { active, progress, isProcessing } = this.props;
+
+ if (!active) {
+ return null;
+ }
+
+ let message;
+
+ if (isProcessing) {
+ message = ;
+ } else {
+ message = ;
+ }
+
+ return (
+
+
+
+
+
+
+ {message}
+
+
+
+ {({ width }) =>
+
+ }
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/warning.js b/app/javascript/mastodon/features/compose/components/warning.js
deleted file mode 100644
index 803b7f86a..000000000
--- a/app/javascript/mastodon/features/compose/components/warning.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import Motion from '../../ui/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/mastodon/features/compose/components/warning.jsx b/app/javascript/mastodon/features/compose/components/warning.jsx
new file mode 100644
index 000000000..803b7f86a
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/warning.jsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from '../../ui/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/mastodon/features/compose/containers/sensitive_button_container.js b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
deleted file mode 100644
index 1bcce5731..000000000
--- a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import classNames from 'classnames';
-import { changeComposeSensitivity } from 'mastodon/actions/compose';
-import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
-
-const messages = defineMessages({
- marked: {
- id: 'compose_form.sensitive.marked',
- defaultMessage: '{count, plural, one {Media is marked as sensitive} other {Media is marked as sensitive}}',
- },
- unmarked: {
- id: 'compose_form.sensitive.unmarked',
- defaultMessage: '{count, plural, one {Media is not marked as sensitive} other {Media is not marked as sensitive}}',
- },
-});
-
-const mapStateToProps = state => ({
- active: state.getIn(['compose', 'sensitive']),
- disabled: state.getIn(['compose', 'spoiler']),
- mediaCount: state.getIn(['compose', 'media_attachments']).size,
-});
-
-const mapDispatchToProps = dispatch => ({
-
- onClick () {
- dispatch(changeComposeSensitivity());
- },
-
-});
-
-class SensitiveButton extends React.PureComponent {
-
- static propTypes = {
- active: PropTypes.bool,
- disabled: PropTypes.bool,
- mediaCount: PropTypes.number,
- onClick: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- render () {
- const { active, disabled, mediaCount, onClick, intl } = this.props;
-
- return (
-
-
-
-
-
-
-
-
-
- );
- }
-
-}
-
-export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));
diff --git a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.jsx b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.jsx
new file mode 100644
index 000000000..1bcce5731
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.jsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { changeComposeSensitivity } from 'mastodon/actions/compose';
+import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
+
+const messages = defineMessages({
+ marked: {
+ id: 'compose_form.sensitive.marked',
+ defaultMessage: '{count, plural, one {Media is marked as sensitive} other {Media is marked as sensitive}}',
+ },
+ unmarked: {
+ id: 'compose_form.sensitive.unmarked',
+ defaultMessage: '{count, plural, one {Media is not marked as sensitive} other {Media is not marked as sensitive}}',
+ },
+});
+
+const mapStateToProps = state => ({
+ active: state.getIn(['compose', 'sensitive']),
+ disabled: state.getIn(['compose', 'spoiler']),
+ mediaCount: state.getIn(['compose', 'media_attachments']).size,
+});
+
+const mapDispatchToProps = dispatch => ({
+
+ onClick () {
+ dispatch(changeComposeSensitivity());
+ },
+
+});
+
+class SensitiveButton extends React.PureComponent {
+
+ static propTypes = {
+ active: PropTypes.bool,
+ disabled: PropTypes.bool,
+ mediaCount: PropTypes.number,
+ onClick: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ render () {
+ const { active, disabled, mediaCount, onClick, intl } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));
diff --git a/app/javascript/mastodon/features/compose/containers/warning_container.js b/app/javascript/mastodon/features/compose/containers/warning_container.js
deleted file mode 100644
index 3c6ed483d..000000000
--- a/app/javascript/mastodon/features/compose/containers/warning_container.js
+++ /dev/null
@@ -1,67 +0,0 @@
-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 '../../../initial_state';
-
-const buildHashtagRE = () => {
- try {
- const HASHTAG_SEPARATORS = '_\\u00b7\\u200c';
- const ALPHA = '\\p{L}\\p{M}';
- const WORD = '\\p{L}\\p{M}\\p{N}\\p{Pc}';
- return new RegExp(
- '(?:^|[^\\/\\)\\w])#((' +
- '[' + WORD + '_]' +
- '[' + WORD + HASHTAG_SEPARATORS + ']*' +
- '[' + ALPHA + HASHTAG_SEPARATORS + ']' +
- '[' + WORD + HASHTAG_SEPARATORS +']*' +
- '[' + WORD + '_]' +
- ')|(' +
- '[' + WORD + '_]*' +
- '[' + ALPHA + ']' +
- '[' + WORD + '_]*' +
- '))', 'iu',
- );
- } catch {
- return /(?:^|[^/)\w])#(\w*[a-zA-Z·]\w*)/i;
- }
-};
-
-const APPROX_HASHTAG_RE = buildHashtagRE();
-
-const mapStateToProps = state => ({
- needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
- hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && APPROX_HASHTAG_RE.test(state.getIn(['compose', 'text'])),
- directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
-});
-
-const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => {
- if (needsLockWarning) {
- return }} />} />;
- }
-
- if (hashtagWarning) {
- return } />;
- }
-
- if (directMessageWarning) {
- const message = (
-
-
-
- );
-
- return ;
- }
-
- return null;
-};
-
-WarningWrapper.propTypes = {
- needsLockWarning: PropTypes.bool,
- hashtagWarning: PropTypes.bool,
- directMessageWarning: PropTypes.bool,
-};
-
-export default connect(mapStateToProps)(WarningWrapper);
diff --git a/app/javascript/mastodon/features/compose/containers/warning_container.jsx b/app/javascript/mastodon/features/compose/containers/warning_container.jsx
new file mode 100644
index 000000000..3c6ed483d
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/warning_container.jsx
@@ -0,0 +1,67 @@
+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 '../../../initial_state';
+
+const buildHashtagRE = () => {
+ try {
+ const HASHTAG_SEPARATORS = '_\\u00b7\\u200c';
+ const ALPHA = '\\p{L}\\p{M}';
+ const WORD = '\\p{L}\\p{M}\\p{N}\\p{Pc}';
+ return new RegExp(
+ '(?:^|[^\\/\\)\\w])#((' +
+ '[' + WORD + '_]' +
+ '[' + WORD + HASHTAG_SEPARATORS + ']*' +
+ '[' + ALPHA + HASHTAG_SEPARATORS + ']' +
+ '[' + WORD + HASHTAG_SEPARATORS +']*' +
+ '[' + WORD + '_]' +
+ ')|(' +
+ '[' + WORD + '_]*' +
+ '[' + ALPHA + ']' +
+ '[' + WORD + '_]*' +
+ '))', 'iu',
+ );
+ } catch {
+ return /(?:^|[^/)\w])#(\w*[a-zA-Z·]\w*)/i;
+ }
+};
+
+const APPROX_HASHTAG_RE = buildHashtagRE();
+
+const mapStateToProps = state => ({
+ needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
+ hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && APPROX_HASHTAG_RE.test(state.getIn(['compose', 'text'])),
+ directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
+});
+
+const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => {
+ if (needsLockWarning) {
+ return }} />} />;
+ }
+
+ if (hashtagWarning) {
+ return } />;
+ }
+
+ if (directMessageWarning) {
+ const message = (
+
+
+
+ );
+
+ return ;
+ }
+
+ return null;
+};
+
+WarningWrapper.propTypes = {
+ needsLockWarning: PropTypes.bool,
+ hashtagWarning: PropTypes.bool,
+ directMessageWarning: PropTypes.bool,
+};
+
+export default connect(mapStateToProps)(WarningWrapper);
diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js
deleted file mode 100644
index 4b30d09ae..000000000
--- a/app/javascript/mastodon/features/compose/index.js
+++ /dev/null
@@ -1,150 +0,0 @@
-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 { changeComposing, mountCompose, unmountCompose } from '../../actions/compose';
-import { Link } from 'react-router-dom';
-import { injectIntl, defineMessages } from 'react-intl';
-import SearchContainer from './containers/search_container';
-import Motion from '../ui/util/optional_motion';
-import spring from 'react-motion/lib/spring';
-import SearchResultsContainer from './containers/search_results_container';
-import { openModal } from 'mastodon/actions/modal';
-import elephantUIPlane from '../../../images/elephant_ui_plane.svg';
-import { mascot } from '../../initial_state';
-import Icon from 'mastodon/components/icon';
-import { logOut } from 'mastodon/utils/log_out';
-import Column from 'mastodon/components/column';
-import { Helmet } from 'react-helmet';
-import { isMobile } from '../../is_mobile';
-
-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' },
- preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
- logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
- compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
- logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
- logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
-});
-
-const mapStateToProps = (state, ownProps) => ({
- columns: state.getIn(['settings', 'columns']),
- showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : false,
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-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 () {
- const { dispatch } = this.props;
- dispatch(mountCompose());
- }
-
- componentWillUnmount () {
- const { dispatch } = this.props;
- dispatch(unmountCompose());
- }
-
- handleLogoutClick = e => {
- const { dispatch, intl } = this.props;
-
- e.preventDefault();
- e.stopPropagation();
-
- dispatch(openModal('CONFIRM', {
- message: intl.formatMessage(messages.logoutMessage),
- confirm: intl.formatMessage(messages.logoutConfirm),
- closeWhenConfirm: false,
- onConfirm: () => logOut(),
- }));
-
- return false;
- };
-
- onFocus = () => {
- this.props.dispatch(changeComposing(true));
- };
-
- onBlur = () => {
- this.props.dispatch(changeComposing(false));
- };
-
- render () {
- const { multiColumn, showSearch, intl } = this.props;
-
- if (multiColumn) {
- const { columns } = this.props;
-
- return (
-
-
-
- {!columns.some(column => column.get('id') === 'HOME') && (
-
- )}
- {!columns.some(column => column.get('id') === 'NOTIFICATIONS') && (
-
- )}
- {!columns.some(column => column.get('id') === 'COMMUNITY') && (
-
- )}
- {!columns.some(column => column.get('id') === 'PUBLIC') && (
-
- )}
-
-
-
-
- {multiColumn &&
}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {({ x }) => (
-
-
-
- )}
-
-
-
- );
- }
-
- return (
-
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/compose/index.jsx b/app/javascript/mastodon/features/compose/index.jsx
new file mode 100644
index 000000000..4b30d09ae
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/index.jsx
@@ -0,0 +1,150 @@
+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 { changeComposing, mountCompose, unmountCompose } from '../../actions/compose';
+import { Link } from 'react-router-dom';
+import { injectIntl, defineMessages } from 'react-intl';
+import SearchContainer from './containers/search_container';
+import Motion from '../ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import SearchResultsContainer from './containers/search_results_container';
+import { openModal } from 'mastodon/actions/modal';
+import elephantUIPlane from '../../../images/elephant_ui_plane.svg';
+import { mascot } from '../../initial_state';
+import Icon from 'mastodon/components/icon';
+import { logOut } from 'mastodon/utils/log_out';
+import Column from 'mastodon/components/column';
+import { Helmet } from 'react-helmet';
+import { isMobile } from '../../is_mobile';
+
+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' },
+ preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+ logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
+ compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
+ logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
+ logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
+});
+
+const mapStateToProps = (state, ownProps) => ({
+ columns: state.getIn(['settings', 'columns']),
+ showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : false,
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+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 () {
+ const { dispatch } = this.props;
+ dispatch(mountCompose());
+ }
+
+ componentWillUnmount () {
+ const { dispatch } = this.props;
+ dispatch(unmountCompose());
+ }
+
+ handleLogoutClick = e => {
+ const { dispatch, intl } = this.props;
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.logoutMessage),
+ confirm: intl.formatMessage(messages.logoutConfirm),
+ closeWhenConfirm: false,
+ onConfirm: () => logOut(),
+ }));
+
+ return false;
+ };
+
+ onFocus = () => {
+ this.props.dispatch(changeComposing(true));
+ };
+
+ onBlur = () => {
+ this.props.dispatch(changeComposing(false));
+ };
+
+ render () {
+ const { multiColumn, showSearch, intl } = this.props;
+
+ if (multiColumn) {
+ const { columns } = this.props;
+
+ return (
+
+
+
+ {!columns.some(column => column.get('id') === 'HOME') && (
+
+ )}
+ {!columns.some(column => column.get('id') === 'NOTIFICATIONS') && (
+
+ )}
+ {!columns.some(column => column.get('id') === 'COMMUNITY') && (
+
+ )}
+ {!columns.some(column => column.get('id') === 'PUBLIC') && (
+
+ )}
+
+
+
+
+ {multiColumn &&
}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {({ x }) => (
+
+
+
+ )}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.js b/app/javascript/mastodon/features/direct_timeline/components/conversation.js
deleted file mode 100644
index fbdff1bdd..000000000
--- a/app/javascript/mastodon/features/direct_timeline/components/conversation.js
+++ /dev/null
@@ -1,200 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import StatusContent from 'mastodon/components/status_content';
-import AttachmentList from 'mastodon/components/attachment_list';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
-import AvatarComposite from 'mastodon/components/avatar_composite';
-import { Link } from 'react-router-dom';
-import IconButton from 'mastodon/components/icon_button';
-import RelativeTimestamp from 'mastodon/components/relative_timestamp';
-import { HotKeys } from 'react-hotkeys';
-import { autoPlayGif } from 'mastodon/initial_state';
-import classNames from 'classnames';
-
-const messages = defineMessages({
- more: { id: 'status.more', defaultMessage: 'More' },
- open: { id: 'conversation.open', defaultMessage: 'View conversation' },
- reply: { id: 'status.reply', defaultMessage: 'Reply' },
- markAsRead: { id: 'conversation.mark_as_read', defaultMessage: 'Mark as read' },
- delete: { id: 'conversation.delete', defaultMessage: 'Delete conversation' },
- muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
- unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
-});
-
-export default @injectIntl
-class Conversation extends ImmutablePureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- };
-
- static propTypes = {
- conversationId: PropTypes.string.isRequired,
- accounts: ImmutablePropTypes.list.isRequired,
- lastStatus: ImmutablePropTypes.map,
- unread:PropTypes.bool.isRequired,
- scrollKey: PropTypes.string,
- onMoveUp: PropTypes.func,
- onMoveDown: PropTypes.func,
- markRead: PropTypes.func.isRequired,
- delete: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- handleMouseEnter = ({ currentTarget }) => {
- if (autoPlayGif) {
- return;
- }
-
- const emojis = currentTarget.querySelectorAll('.custom-emoji');
-
- for (var i = 0; i < emojis.length; i++) {
- let emoji = emojis[i];
- emoji.src = emoji.getAttribute('data-original');
- }
- };
-
- handleMouseLeave = ({ currentTarget }) => {
- if (autoPlayGif) {
- return;
- }
-
- const emojis = currentTarget.querySelectorAll('.custom-emoji');
-
- for (var i = 0; i < emojis.length; i++) {
- let emoji = emojis[i];
- emoji.src = emoji.getAttribute('data-static');
- }
- };
-
- handleClick = () => {
- if (!this.context.router) {
- return;
- }
-
- const { lastStatus, unread, markRead } = this.props;
-
- if (unread) {
- markRead();
- }
-
- this.context.router.history.push(`/@${lastStatus.getIn(['account', 'acct'])}/${lastStatus.get('id')}`);
- };
-
- handleMarkAsRead = () => {
- this.props.markRead();
- };
-
- handleReply = () => {
- this.props.reply(this.props.lastStatus, this.context.router.history);
- };
-
- handleDelete = () => {
- this.props.delete();
- };
-
- handleHotkeyMoveUp = () => {
- this.props.onMoveUp(this.props.conversationId);
- };
-
- handleHotkeyMoveDown = () => {
- this.props.onMoveDown(this.props.conversationId);
- };
-
- handleConversationMute = () => {
- this.props.onMute(this.props.lastStatus);
- };
-
- handleShowMore = () => {
- this.props.onToggleHidden(this.props.lastStatus);
- };
-
- render () {
- const { accounts, lastStatus, unread, scrollKey, intl } = this.props;
-
- if (lastStatus === null) {
- return null;
- }
-
- const menu = [
- { text: intl.formatMessage(messages.open), action: this.handleClick },
- null,
- ];
-
- menu.push({ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMute });
-
- if (unread) {
- menu.push({ text: intl.formatMessage(messages.markAsRead), action: this.handleMarkAsRead });
- menu.push(null);
- }
-
- menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete });
-
- const names = accounts.map(a => ).reduce((prev, cur) => [prev, ', ', cur]);
-
- const handlers = {
- reply: this.handleReply,
- open: this.handleClick,
- moveUp: this.handleHotkeyMoveUp,
- moveDown: this.handleHotkeyMoveDown,
- toggleHidden: this.handleShowMore,
- };
-
- return (
-
-
-
-
-
-
-
- {unread && }
-
-
-
- {names} }} />
-
-
-
-
-
- {lastStatus.get('media_attachments').size > 0 && (
-
- )}
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx
new file mode 100644
index 000000000..fbdff1bdd
--- /dev/null
+++ b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx
@@ -0,0 +1,200 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import StatusContent from 'mastodon/components/status_content';
+import AttachmentList from 'mastodon/components/attachment_list';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
+import AvatarComposite from 'mastodon/components/avatar_composite';
+import { Link } from 'react-router-dom';
+import IconButton from 'mastodon/components/icon_button';
+import RelativeTimestamp from 'mastodon/components/relative_timestamp';
+import { HotKeys } from 'react-hotkeys';
+import { autoPlayGif } from 'mastodon/initial_state';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+ more: { id: 'status.more', defaultMessage: 'More' },
+ open: { id: 'conversation.open', defaultMessage: 'View conversation' },
+ reply: { id: 'status.reply', defaultMessage: 'Reply' },
+ markAsRead: { id: 'conversation.mark_as_read', defaultMessage: 'Mark as read' },
+ delete: { id: 'conversation.delete', defaultMessage: 'Delete conversation' },
+ muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
+ unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
+});
+
+export default @injectIntl
+class Conversation extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ conversationId: PropTypes.string.isRequired,
+ accounts: ImmutablePropTypes.list.isRequired,
+ lastStatus: ImmutablePropTypes.map,
+ unread:PropTypes.bool.isRequired,
+ scrollKey: PropTypes.string,
+ onMoveUp: PropTypes.func,
+ onMoveDown: PropTypes.func,
+ markRead: PropTypes.func.isRequired,
+ delete: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleMouseEnter = ({ currentTarget }) => {
+ if (autoPlayGif) {
+ return;
+ }
+
+ const emojis = currentTarget.querySelectorAll('.custom-emoji');
+
+ for (var i = 0; i < emojis.length; i++) {
+ let emoji = emojis[i];
+ emoji.src = emoji.getAttribute('data-original');
+ }
+ };
+
+ handleMouseLeave = ({ currentTarget }) => {
+ if (autoPlayGif) {
+ return;
+ }
+
+ const emojis = currentTarget.querySelectorAll('.custom-emoji');
+
+ for (var i = 0; i < emojis.length; i++) {
+ let emoji = emojis[i];
+ emoji.src = emoji.getAttribute('data-static');
+ }
+ };
+
+ handleClick = () => {
+ if (!this.context.router) {
+ return;
+ }
+
+ const { lastStatus, unread, markRead } = this.props;
+
+ if (unread) {
+ markRead();
+ }
+
+ this.context.router.history.push(`/@${lastStatus.getIn(['account', 'acct'])}/${lastStatus.get('id')}`);
+ };
+
+ handleMarkAsRead = () => {
+ this.props.markRead();
+ };
+
+ handleReply = () => {
+ this.props.reply(this.props.lastStatus, this.context.router.history);
+ };
+
+ handleDelete = () => {
+ this.props.delete();
+ };
+
+ handleHotkeyMoveUp = () => {
+ this.props.onMoveUp(this.props.conversationId);
+ };
+
+ handleHotkeyMoveDown = () => {
+ this.props.onMoveDown(this.props.conversationId);
+ };
+
+ handleConversationMute = () => {
+ this.props.onMute(this.props.lastStatus);
+ };
+
+ handleShowMore = () => {
+ this.props.onToggleHidden(this.props.lastStatus);
+ };
+
+ render () {
+ const { accounts, lastStatus, unread, scrollKey, intl } = this.props;
+
+ if (lastStatus === null) {
+ return null;
+ }
+
+ const menu = [
+ { text: intl.formatMessage(messages.open), action: this.handleClick },
+ null,
+ ];
+
+ menu.push({ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMute });
+
+ if (unread) {
+ menu.push({ text: intl.formatMessage(messages.markAsRead), action: this.handleMarkAsRead });
+ menu.push(null);
+ }
+
+ menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete });
+
+ const names = accounts.map(a => ).reduce((prev, cur) => [prev, ', ', cur]);
+
+ const handlers = {
+ reply: this.handleReply,
+ open: this.handleClick,
+ moveUp: this.handleHotkeyMoveUp,
+ moveDown: this.handleHotkeyMoveDown,
+ toggleHidden: this.handleShowMore,
+ };
+
+ return (
+
+
+
+
+
+
+
+ {unread && }
+
+
+
+ {names} }} />
+
+
+
+
+
+ {lastStatus.get('media_attachments').size > 0 && (
+
+ )}
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js
deleted file mode 100644
index 27e9a593f..000000000
--- a/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js
+++ /dev/null
@@ -1,75 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import ConversationContainer from '../containers/conversation_container';
-import ScrollableList from '../../../components/scrollable_list';
-import { debounce } from 'lodash';
-
-export default class ConversationsList extends ImmutablePureComponent {
-
- static propTypes = {
- conversations: ImmutablePropTypes.list.isRequired,
- scrollKey: PropTypes.string.isRequired,
- hasMore: PropTypes.bool,
- isLoading: PropTypes.bool,
- onLoadMore: PropTypes.func,
- };
-
- getCurrentIndex = id => this.props.conversations.findIndex(x => x.get('id') === id);
-
- handleMoveUp = id => {
- const elementIndex = this.getCurrentIndex(id) - 1;
- this._selectChild(elementIndex, true);
- };
-
- handleMoveDown = id => {
- const elementIndex = this.getCurrentIndex(id) + 1;
- this._selectChild(elementIndex, false);
- };
-
- _selectChild (index, align_top) {
- const container = this.node.node;
- const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
-
- if (element) {
- if (align_top && container.scrollTop > element.offsetTop) {
- element.scrollIntoView(true);
- } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
- element.scrollIntoView(false);
- }
- element.focus();
- }
- }
-
- setRef = c => {
- this.node = c;
- };
-
- handleLoadOlder = debounce(() => {
- const last = this.props.conversations.last();
-
- if (last && last.get('last_status')) {
- this.props.onLoadMore(last.get('last_status'));
- }
- }, 300, { leading: true });
-
- render () {
- const { conversations, onLoadMore, ...other } = this.props;
-
- return (
-
- {conversations.map(item => (
-
- ))}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversations_list.jsx b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.jsx
new file mode 100644
index 000000000..27e9a593f
--- /dev/null
+++ b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.jsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ConversationContainer from '../containers/conversation_container';
+import ScrollableList from '../../../components/scrollable_list';
+import { debounce } from 'lodash';
+
+export default class ConversationsList extends ImmutablePureComponent {
+
+ static propTypes = {
+ conversations: ImmutablePropTypes.list.isRequired,
+ scrollKey: PropTypes.string.isRequired,
+ hasMore: PropTypes.bool,
+ isLoading: PropTypes.bool,
+ onLoadMore: PropTypes.func,
+ };
+
+ getCurrentIndex = id => this.props.conversations.findIndex(x => x.get('id') === id);
+
+ handleMoveUp = id => {
+ const elementIndex = this.getCurrentIndex(id) - 1;
+ this._selectChild(elementIndex, true);
+ };
+
+ handleMoveDown = id => {
+ const elementIndex = this.getCurrentIndex(id) + 1;
+ this._selectChild(elementIndex, false);
+ };
+
+ _selectChild (index, align_top) {
+ const container = this.node.node;
+ const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+
+ if (element) {
+ if (align_top && container.scrollTop > element.offsetTop) {
+ element.scrollIntoView(true);
+ } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
+ element.scrollIntoView(false);
+ }
+ element.focus();
+ }
+ }
+
+ setRef = c => {
+ this.node = c;
+ };
+
+ handleLoadOlder = debounce(() => {
+ const last = this.props.conversations.last();
+
+ if (last && last.get('last_status')) {
+ this.props.onLoadMore(last.get('last_status'));
+ }
+ }, 300, { leading: true });
+
+ render () {
+ const { conversations, onLoadMore, ...other } = this.props;
+
+ return (
+
+ {conversations.map(item => (
+
+ ))}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/direct_timeline/index.js b/app/javascript/mastodon/features/direct_timeline/index.js
deleted file mode 100644
index a45965bb2..000000000
--- a/app/javascript/mastodon/features/direct_timeline/index.js
+++ /dev/null
@@ -1,107 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Helmet } from 'react-helmet';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import { connect } from 'react-redux';
-import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
-import { mountConversations, unmountConversations, expandConversations } from 'mastodon/actions/conversations';
-import { connectDirectStream } from 'mastodon/actions/streaming';
-import Column from 'mastodon/components/column';
-import ColumnHeader from 'mastodon/components/column_header';
-import ConversationsListContainer from './containers/conversations_list_container';
-
-const messages = defineMessages({
- title: { id: 'column.direct', defaultMessage: 'Direct messages' },
-});
-
-export default @connect()
-@injectIntl
-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(mountConversations());
- dispatch(expandConversations());
- this.disconnect = dispatch(connectDirectStream());
- }
-
- componentWillUnmount () {
- this.props.dispatch(unmountConversations());
-
- if (this.disconnect) {
- this.disconnect();
- this.disconnect = null;
- }
- }
-
- setRef = c => {
- this.column = c;
- };
-
- handleLoadMore = maxId => {
- this.props.dispatch(expandConversations({ maxId }));
- };
-
- render () {
- const { intl, hasUnread, columnId, multiColumn } = this.props;
- const pinned = !!columnId;
-
- return (
-
-
-
- }
- emptyMessage={ }
- />
-
-
- {intl.formatMessage(messages.title)}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/direct_timeline/index.jsx b/app/javascript/mastodon/features/direct_timeline/index.jsx
new file mode 100644
index 000000000..a45965bb2
--- /dev/null
+++ b/app/javascript/mastodon/features/direct_timeline/index.jsx
@@ -0,0 +1,107 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Helmet } from 'react-helmet';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
+import { mountConversations, unmountConversations, expandConversations } from 'mastodon/actions/conversations';
+import { connectDirectStream } from 'mastodon/actions/streaming';
+import Column from 'mastodon/components/column';
+import ColumnHeader from 'mastodon/components/column_header';
+import ConversationsListContainer from './containers/conversations_list_container';
+
+const messages = defineMessages({
+ title: { id: 'column.direct', defaultMessage: 'Direct messages' },
+});
+
+export default @connect()
+@injectIntl
+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(mountConversations());
+ dispatch(expandConversations());
+ this.disconnect = dispatch(connectDirectStream());
+ }
+
+ componentWillUnmount () {
+ this.props.dispatch(unmountConversations());
+
+ if (this.disconnect) {
+ this.disconnect();
+ this.disconnect = null;
+ }
+ }
+
+ setRef = c => {
+ this.column = c;
+ };
+
+ handleLoadMore = maxId => {
+ this.props.dispatch(expandConversations({ maxId }));
+ };
+
+ render () {
+ const { intl, hasUnread, columnId, multiColumn } = this.props;
+ const pinned = !!columnId;
+
+ return (
+
+
+
+ }
+ emptyMessage={ }
+ />
+
+
+ {intl.formatMessage(messages.title)}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/directory/components/account_card.js b/app/javascript/mastodon/features/directory/components/account_card.js
deleted file mode 100644
index 15c8ad303..000000000
--- a/app/javascript/mastodon/features/directory/components/account_card.js
+++ /dev/null
@@ -1,235 +0,0 @@
-import React from 'react';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import { connect } from 'react-redux';
-import { makeGetAccount } from 'mastodon/selectors';
-import Avatar from 'mastodon/components/avatar';
-import DisplayName from 'mastodon/components/display_name';
-import { Link } from 'react-router-dom';
-import Button from 'mastodon/components/button';
-import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
-import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state';
-import ShortNumber from 'mastodon/components/short_number';
-import {
- followAccount,
- unfollowAccount,
- unblockAccount,
- unmuteAccount,
-} from 'mastodon/actions/accounts';
-import { openModal } from 'mastodon/actions/modal';
-import classNames from 'classnames';
-
-const messages = defineMessages({
- unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
- follow: { id: 'account.follow', defaultMessage: 'Follow' },
- cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
- cancelFollowRequestConfirm: { id: 'confirmations.cancel_follow_request.confirm', defaultMessage: 'Withdraw request' },
- requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
- unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
- unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
- unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
- edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
-});
-
-const makeMapStateToProps = () => {
- const getAccount = makeGetAccount();
-
- const mapStateToProps = (state, { id }) => ({
- account: getAccount(state, id),
- });
-
- return mapStateToProps;
-};
-
-const mapDispatchToProps = (dispatch, { intl }) => ({
- onFollow(account) {
- if (account.getIn(['relationship', 'following'])) {
- 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 if (account.getIn(['relationship', 'requested'])) {
- if (unfollowModal) {
- dispatch(openModal('CONFIRM', {
- message: @{account.get('acct')} }} />,
- confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
- 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')));
- }
- },
-
- onMute(account) {
- if (account.getIn(['relationship', 'muting'])) {
- dispatch(unmuteAccount(account.get('id')));
- }
- },
-
-});
-
-export default
-@injectIntl
-@connect(makeMapStateToProps, mapDispatchToProps)
-class AccountCard extends ImmutablePureComponent {
-
- static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
- intl: PropTypes.object.isRequired,
- onFollow: PropTypes.func.isRequired,
- onBlock: PropTypes.func.isRequired,
- onMute: PropTypes.func.isRequired,
- };
-
- handleMouseEnter = ({ currentTarget }) => {
- if (autoPlayGif) {
- return;
- }
-
- const emojis = currentTarget.querySelectorAll('.custom-emoji');
-
- for (var i = 0; i < emojis.length; i++) {
- let emoji = emojis[i];
- emoji.src = emoji.getAttribute('data-original');
- }
- };
-
- handleMouseLeave = ({ currentTarget }) => {
- if (autoPlayGif) {
- return;
- }
-
- const emojis = currentTarget.querySelectorAll('.custom-emoji');
-
- for (var i = 0; i < emojis.length; i++) {
- let emoji = emojis[i];
- emoji.src = emoji.getAttribute('data-static');
- }
- };
-
- handleFollow = () => {
- this.props.onFollow(this.props.account);
- };
-
- handleBlock = () => {
- this.props.onBlock(this.props.account);
- };
-
- handleMute = () => {
- this.props.onMute(this.props.account);
- };
-
- handleEditProfile = () => {
- window.open('/settings/profile', '_blank');
- };
-
- render() {
- const { account, intl } = this.props;
-
- let actionBtn;
-
- if (me !== account.get('id')) {
- if (!account.get('relationship')) { // Wait until the relationship is loaded
- actionBtn = '';
- } else if (account.getIn(['relationship', 'requested'])) {
- actionBtn = ;
- } else if (account.getIn(['relationship', 'muting'])) {
- actionBtn = ;
- } else if (!account.getIn(['relationship', 'blocking'])) {
- actionBtn = ;
- } else if (account.getIn(['relationship', 'blocking'])) {
- actionBtn = ;
- }
- } else {
- actionBtn = ;
- }
-
- return (
-
-
-
-
-
-
-
-
-
- {account.get('note').length > 0 && (
-
- )}
-
-
-
-
-
-
-
-
-
-
-
- {' '}
-
-
-
-
-
-
- {' '}
-
-
-
-
-
-
-
- {actionBtn}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/directory/components/account_card.jsx b/app/javascript/mastodon/features/directory/components/account_card.jsx
new file mode 100644
index 000000000..15c8ad303
--- /dev/null
+++ b/app/javascript/mastodon/features/directory/components/account_card.jsx
@@ -0,0 +1,235 @@
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'mastodon/selectors';
+import Avatar from 'mastodon/components/avatar';
+import DisplayName from 'mastodon/components/display_name';
+import { Link } from 'react-router-dom';
+import Button from 'mastodon/components/button';
+import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
+import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state';
+import ShortNumber from 'mastodon/components/short_number';
+import {
+ followAccount,
+ unfollowAccount,
+ unblockAccount,
+ unmuteAccount,
+} from 'mastodon/actions/accounts';
+import { openModal } from 'mastodon/actions/modal';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+ follow: { id: 'account.follow', defaultMessage: 'Follow' },
+ cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
+ cancelFollowRequestConfirm: { id: 'confirmations.cancel_follow_request.confirm', defaultMessage: 'Withdraw request' },
+ requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
+ unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
+ unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
+ unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
+ edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
+});
+
+const makeMapStateToProps = () => {
+ const getAccount = makeGetAccount();
+
+ const mapStateToProps = (state, { id }) => ({
+ account: getAccount(state, id),
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+ onFollow(account) {
+ if (account.getIn(['relationship', 'following'])) {
+ 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 if (account.getIn(['relationship', 'requested'])) {
+ if (unfollowModal) {
+ dispatch(openModal('CONFIRM', {
+ message: @{account.get('acct')} }} />,
+ confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
+ 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')));
+ }
+ },
+
+ onMute(account) {
+ if (account.getIn(['relationship', 'muting'])) {
+ dispatch(unmuteAccount(account.get('id')));
+ }
+ },
+
+});
+
+export default
+@injectIntl
+@connect(makeMapStateToProps, mapDispatchToProps)
+class AccountCard extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ intl: PropTypes.object.isRequired,
+ onFollow: PropTypes.func.isRequired,
+ onBlock: PropTypes.func.isRequired,
+ onMute: PropTypes.func.isRequired,
+ };
+
+ handleMouseEnter = ({ currentTarget }) => {
+ if (autoPlayGif) {
+ return;
+ }
+
+ const emojis = currentTarget.querySelectorAll('.custom-emoji');
+
+ for (var i = 0; i < emojis.length; i++) {
+ let emoji = emojis[i];
+ emoji.src = emoji.getAttribute('data-original');
+ }
+ };
+
+ handleMouseLeave = ({ currentTarget }) => {
+ if (autoPlayGif) {
+ return;
+ }
+
+ const emojis = currentTarget.querySelectorAll('.custom-emoji');
+
+ for (var i = 0; i < emojis.length; i++) {
+ let emoji = emojis[i];
+ emoji.src = emoji.getAttribute('data-static');
+ }
+ };
+
+ handleFollow = () => {
+ this.props.onFollow(this.props.account);
+ };
+
+ handleBlock = () => {
+ this.props.onBlock(this.props.account);
+ };
+
+ handleMute = () => {
+ this.props.onMute(this.props.account);
+ };
+
+ handleEditProfile = () => {
+ window.open('/settings/profile', '_blank');
+ };
+
+ render() {
+ const { account, intl } = this.props;
+
+ let actionBtn;
+
+ if (me !== account.get('id')) {
+ if (!account.get('relationship')) { // Wait until the relationship is loaded
+ actionBtn = '';
+ } else if (account.getIn(['relationship', 'requested'])) {
+ actionBtn = ;
+ } else if (account.getIn(['relationship', 'muting'])) {
+ actionBtn = ;
+ } else if (!account.getIn(['relationship', 'blocking'])) {
+ actionBtn = ;
+ } else if (account.getIn(['relationship', 'blocking'])) {
+ actionBtn = ;
+ }
+ } else {
+ actionBtn = ;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {account.get('note').length > 0 && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {' '}
+
+
+
+
+
+
+ {' '}
+
+
+
+
+
+
+
+ {actionBtn}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/directory/index.js b/app/javascript/mastodon/features/directory/index.js
deleted file mode 100644
index bb5e021cc..000000000
--- a/app/javascript/mastodon/features/directory/index.js
+++ /dev/null
@@ -1,178 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import { defineMessages, injectIntl } from 'react-intl';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import Column from 'mastodon/components/column';
-import ColumnHeader from 'mastodon/components/column_header';
-import { addColumn, removeColumn, moveColumn, changeColumnParams } from 'mastodon/actions/columns';
-import { fetchDirectory, expandDirectory } from 'mastodon/actions/directory';
-import { List as ImmutableList } from 'immutable';
-import AccountCard from './components/account_card';
-import RadioButton from 'mastodon/components/radio_button';
-import LoadMore from 'mastodon/components/load_more';
-import ScrollContainer from 'mastodon/containers/scroll_container';
-import LoadingIndicator from 'mastodon/components/loading_indicator';
-import { Helmet } from 'react-helmet';
-
-const messages = defineMessages({
- title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
- recentlyActive: { id: 'directory.recently_active', defaultMessage: 'Recently active' },
- newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' },
- local: { id: 'directory.local', defaultMessage: 'From {domain} only' },
- federated: { id: 'directory.federated', defaultMessage: 'From known fediverse' },
-});
-
-const mapStateToProps = state => ({
- accountIds: state.getIn(['user_lists', 'directory', 'items'], ImmutableList()),
- isLoading: state.getIn(['user_lists', 'directory', 'isLoading'], true),
- domain: state.getIn(['meta', 'domain']),
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-class Directory extends React.PureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- };
-
- static propTypes = {
- isLoading: PropTypes.bool,
- accountIds: ImmutablePropTypes.list.isRequired,
- dispatch: PropTypes.func.isRequired,
- columnId: PropTypes.string,
- intl: PropTypes.object.isRequired,
- multiColumn: PropTypes.bool,
- domain: PropTypes.string.isRequired,
- params: PropTypes.shape({
- order: PropTypes.string,
- local: PropTypes.bool,
- }),
- };
-
- state = {
- order: null,
- local: null,
- };
-
- handlePin = () => {
- const { columnId, dispatch } = this.props;
-
- if (columnId) {
- dispatch(removeColumn(columnId));
- } else {
- dispatch(addColumn('DIRECTORY', this.getParams(this.props, this.state)));
- }
- };
-
- getParams = (props, state) => ({
- order: state.order === null ? (props.params.order || 'active') : state.order,
- local: state.local === null ? (props.params.local || false) : state.local,
- });
-
- handleMove = dir => {
- const { columnId, dispatch } = this.props;
- dispatch(moveColumn(columnId, dir));
- };
-
- handleHeaderClick = () => {
- this.column.scrollTop();
- };
-
- componentDidMount () {
- const { dispatch } = this.props;
- dispatch(fetchDirectory(this.getParams(this.props, this.state)));
- }
-
- componentDidUpdate (prevProps, prevState) {
- const { dispatch } = this.props;
- const paramsOld = this.getParams(prevProps, prevState);
- const paramsNew = this.getParams(this.props, this.state);
-
- if (paramsOld.order !== paramsNew.order || paramsOld.local !== paramsNew.local) {
- dispatch(fetchDirectory(paramsNew));
- }
- }
-
- setRef = c => {
- this.column = c;
- };
-
- handleChangeOrder = e => {
- const { dispatch, columnId } = this.props;
-
- if (columnId) {
- dispatch(changeColumnParams(columnId, ['order'], e.target.value));
- } else {
- this.setState({ order: e.target.value });
- }
- };
-
- handleChangeLocal = e => {
- const { dispatch, columnId } = this.props;
-
- if (columnId) {
- dispatch(changeColumnParams(columnId, ['local'], e.target.value === '1'));
- } else {
- this.setState({ local: e.target.value === '1' });
- }
- };
-
- handleLoadMore = () => {
- const { dispatch } = this.props;
- dispatch(expandDirectory(this.getParams(this.props, this.state)));
- };
-
- render () {
- const { isLoading, accountIds, intl, columnId, multiColumn, domain } = this.props;
- const { order, local } = this.getParams(this.props, this.state);
- const pinned = !!columnId;
-
- const scrollableArea = (
-
-
-
-
- {isLoading ?
: accountIds.map(accountId => (
-
- ))}
-
-
-
-
- );
-
- return (
-
-
-
- {multiColumn && !pinned ? {scrollableArea} : scrollableArea}
-
-
- {intl.formatMessage(messages.title)}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/directory/index.jsx b/app/javascript/mastodon/features/directory/index.jsx
new file mode 100644
index 000000000..bb5e021cc
--- /dev/null
+++ b/app/javascript/mastodon/features/directory/index.jsx
@@ -0,0 +1,178 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Column from 'mastodon/components/column';
+import ColumnHeader from 'mastodon/components/column_header';
+import { addColumn, removeColumn, moveColumn, changeColumnParams } from 'mastodon/actions/columns';
+import { fetchDirectory, expandDirectory } from 'mastodon/actions/directory';
+import { List as ImmutableList } from 'immutable';
+import AccountCard from './components/account_card';
+import RadioButton from 'mastodon/components/radio_button';
+import LoadMore from 'mastodon/components/load_more';
+import ScrollContainer from 'mastodon/containers/scroll_container';
+import LoadingIndicator from 'mastodon/components/loading_indicator';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+ title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
+ recentlyActive: { id: 'directory.recently_active', defaultMessage: 'Recently active' },
+ newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' },
+ local: { id: 'directory.local', defaultMessage: 'From {domain} only' },
+ federated: { id: 'directory.federated', defaultMessage: 'From known fediverse' },
+});
+
+const mapStateToProps = state => ({
+ accountIds: state.getIn(['user_lists', 'directory', 'items'], ImmutableList()),
+ isLoading: state.getIn(['user_lists', 'directory', 'isLoading'], true),
+ domain: state.getIn(['meta', 'domain']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Directory extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ isLoading: PropTypes.bool,
+ accountIds: ImmutablePropTypes.list.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ columnId: PropTypes.string,
+ intl: PropTypes.object.isRequired,
+ multiColumn: PropTypes.bool,
+ domain: PropTypes.string.isRequired,
+ params: PropTypes.shape({
+ order: PropTypes.string,
+ local: PropTypes.bool,
+ }),
+ };
+
+ state = {
+ order: null,
+ local: null,
+ };
+
+ handlePin = () => {
+ const { columnId, dispatch } = this.props;
+
+ if (columnId) {
+ dispatch(removeColumn(columnId));
+ } else {
+ dispatch(addColumn('DIRECTORY', this.getParams(this.props, this.state)));
+ }
+ };
+
+ getParams = (props, state) => ({
+ order: state.order === null ? (props.params.order || 'active') : state.order,
+ local: state.local === null ? (props.params.local || false) : state.local,
+ });
+
+ handleMove = dir => {
+ const { columnId, dispatch } = this.props;
+ dispatch(moveColumn(columnId, dir));
+ };
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ };
+
+ componentDidMount () {
+ const { dispatch } = this.props;
+ dispatch(fetchDirectory(this.getParams(this.props, this.state)));
+ }
+
+ componentDidUpdate (prevProps, prevState) {
+ const { dispatch } = this.props;
+ const paramsOld = this.getParams(prevProps, prevState);
+ const paramsNew = this.getParams(this.props, this.state);
+
+ if (paramsOld.order !== paramsNew.order || paramsOld.local !== paramsNew.local) {
+ dispatch(fetchDirectory(paramsNew));
+ }
+ }
+
+ setRef = c => {
+ this.column = c;
+ };
+
+ handleChangeOrder = e => {
+ const { dispatch, columnId } = this.props;
+
+ if (columnId) {
+ dispatch(changeColumnParams(columnId, ['order'], e.target.value));
+ } else {
+ this.setState({ order: e.target.value });
+ }
+ };
+
+ handleChangeLocal = e => {
+ const { dispatch, columnId } = this.props;
+
+ if (columnId) {
+ dispatch(changeColumnParams(columnId, ['local'], e.target.value === '1'));
+ } else {
+ this.setState({ local: e.target.value === '1' });
+ }
+ };
+
+ handleLoadMore = () => {
+ const { dispatch } = this.props;
+ dispatch(expandDirectory(this.getParams(this.props, this.state)));
+ };
+
+ render () {
+ const { isLoading, accountIds, intl, columnId, multiColumn, domain } = this.props;
+ const { order, local } = this.getParams(this.props, this.state);
+ const pinned = !!columnId;
+
+ const scrollableArea = (
+
+
+
+
+ {isLoading ?
: accountIds.map(accountId => (
+
+ ))}
+
+
+
+
+ );
+
+ return (
+
+
+
+ {multiColumn && !pinned ? {scrollableArea} : scrollableArea}
+
+
+ {intl.formatMessage(messages.title)}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/domain_blocks/index.js b/app/javascript/mastodon/features/domain_blocks/index.js
deleted file mode 100644
index 43b275c2d..000000000
--- a/app/javascript/mastodon/features/domain_blocks/index.js
+++ /dev/null
@@ -1,83 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { debounce } from 'lodash';
-import LoadingIndicator from '../../components/loading_indicator';
-import Column from '../ui/components/column';
-import ColumnBackButtonSlim from '../../components/column_back_button_slim';
-import DomainContainer from '../../containers/domain_container';
-import { fetchDomainBlocks, expandDomainBlocks } from '../../actions/domain_blocks';
-import ScrollableList from '../../components/scrollable_list';
-import { Helmet } from 'react-helmet';
-
-const messages = defineMessages({
- heading: { id: 'column.domain_blocks', defaultMessage: 'Blocked domains' },
- unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
-});
-
-const mapStateToProps = state => ({
- domains: state.getIn(['domain_lists', 'blocks', 'items']),
- hasMore: !!state.getIn(['domain_lists', 'blocks', 'next']),
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-class Blocks extends ImmutablePureComponent {
-
- static propTypes = {
- params: PropTypes.object.isRequired,
- dispatch: PropTypes.func.isRequired,
- hasMore: PropTypes.bool,
- domains: ImmutablePropTypes.orderedSet,
- intl: PropTypes.object.isRequired,
- multiColumn: PropTypes.bool,
- };
-
- componentWillMount () {
- this.props.dispatch(fetchDomainBlocks());
- }
-
- handleLoadMore = debounce(() => {
- this.props.dispatch(expandDomainBlocks());
- }, 300, { leading: true });
-
- render () {
- const { intl, domains, hasMore, multiColumn } = this.props;
-
- if (!domains) {
- return (
-
-
-
- );
- }
-
- const emptyMessage = ;
-
- return (
-
-
-
-
- {domains.map(domain =>
- ,
- )}
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/domain_blocks/index.jsx b/app/javascript/mastodon/features/domain_blocks/index.jsx
new file mode 100644
index 000000000..43b275c2d
--- /dev/null
+++ b/app/javascript/mastodon/features/domain_blocks/index.jsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { debounce } from 'lodash';
+import LoadingIndicator from '../../components/loading_indicator';
+import Column from '../ui/components/column';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import DomainContainer from '../../containers/domain_container';
+import { fetchDomainBlocks, expandDomainBlocks } from '../../actions/domain_blocks';
+import ScrollableList from '../../components/scrollable_list';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+ heading: { id: 'column.domain_blocks', defaultMessage: 'Blocked domains' },
+ unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
+});
+
+const mapStateToProps = state => ({
+ domains: state.getIn(['domain_lists', 'blocks', 'items']),
+ hasMore: !!state.getIn(['domain_lists', 'blocks', 'next']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Blocks extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ hasMore: PropTypes.bool,
+ domains: ImmutablePropTypes.orderedSet,
+ intl: PropTypes.object.isRequired,
+ multiColumn: PropTypes.bool,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchDomainBlocks());
+ }
+
+ handleLoadMore = debounce(() => {
+ this.props.dispatch(expandDomainBlocks());
+ }, 300, { leading: true });
+
+ render () {
+ const { intl, domains, hasMore, multiColumn } = this.props;
+
+ if (!domains) {
+ return (
+
+
+
+ );
+ }
+
+ const emptyMessage = ;
+
+ return (
+
+
+
+
+ {domains.map(domain =>
+ ,
+ )}
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/explore/components/story.js b/app/javascript/mastodon/features/explore/components/story.js
deleted file mode 100644
index 563128029..000000000
--- a/app/javascript/mastodon/features/explore/components/story.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import Blurhash from 'mastodon/components/blurhash';
-import { accountsCountRenderer } from 'mastodon/components/hashtag';
-import ShortNumber from 'mastodon/components/short_number';
-import Skeleton from 'mastodon/components/skeleton';
-import classNames from 'classnames';
-
-export default class Story extends React.PureComponent {
-
- static propTypes = {
- url: PropTypes.string,
- title: PropTypes.string,
- publisher: PropTypes.string,
- sharedTimes: PropTypes.number,
- thumbnail: PropTypes.string,
- blurhash: PropTypes.string,
- };
-
- state = {
- thumbnailLoaded: false,
- };
-
- handleImageLoad = () => this.setState({ thumbnailLoaded: true });
-
- render () {
- const { url, title, publisher, sharedTimes, thumbnail, blurhash } = this.props;
-
- const { thumbnailLoaded } = this.state;
-
- return (
-
-
-
{publisher ? publisher : }
-
{title ? title : }
-
{typeof sharedTimes === 'number' ? : }
-
-
-
- {thumbnail ? (
-
-
-
-
- ) :
}
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/explore/components/story.jsx b/app/javascript/mastodon/features/explore/components/story.jsx
new file mode 100644
index 000000000..563128029
--- /dev/null
+++ b/app/javascript/mastodon/features/explore/components/story.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Blurhash from 'mastodon/components/blurhash';
+import { accountsCountRenderer } from 'mastodon/components/hashtag';
+import ShortNumber from 'mastodon/components/short_number';
+import Skeleton from 'mastodon/components/skeleton';
+import classNames from 'classnames';
+
+export default class Story extends React.PureComponent {
+
+ static propTypes = {
+ url: PropTypes.string,
+ title: PropTypes.string,
+ publisher: PropTypes.string,
+ sharedTimes: PropTypes.number,
+ thumbnail: PropTypes.string,
+ blurhash: PropTypes.string,
+ };
+
+ state = {
+ thumbnailLoaded: false,
+ };
+
+ handleImageLoad = () => this.setState({ thumbnailLoaded: true });
+
+ render () {
+ const { url, title, publisher, sharedTimes, thumbnail, blurhash } = this.props;
+
+ const { thumbnailLoaded } = this.state;
+
+ return (
+
+
+
{publisher ? publisher : }
+
{title ? title : }
+
{typeof sharedTimes === 'number' ? : }
+
+
+
+ {thumbnail ? (
+
+
+
+
+ ) :
}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/explore/index.js b/app/javascript/mastodon/features/explore/index.js
deleted file mode 100644
index d91755ff6..000000000
--- a/app/javascript/mastodon/features/explore/index.js
+++ /dev/null
@@ -1,107 +0,0 @@
-import React from 'react';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import Column from 'mastodon/components/column';
-import ColumnHeader from 'mastodon/components/column_header';
-import { NavLink, Switch, Route } from 'react-router-dom';
-import Links from './links';
-import Tags from './tags';
-import Statuses from './statuses';
-import Suggestions from './suggestions';
-import Search from 'mastodon/features/compose/containers/search_container';
-import SearchResults from './results';
-import { Helmet } from 'react-helmet';
-import { showTrends } from 'mastodon/initial_state';
-
-const messages = defineMessages({
- title: { id: 'explore.title', defaultMessage: 'Explore' },
- searchResults: { id: 'explore.search_results', defaultMessage: 'Search results' },
-});
-
-const mapStateToProps = state => ({
- layout: state.getIn(['meta', 'layout']),
- isSearching: state.getIn(['search', 'submitted']) || !showTrends,
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-class Explore extends React.PureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- identity: PropTypes.object,
- };
-
- static propTypes = {
- intl: PropTypes.object.isRequired,
- multiColumn: PropTypes.bool,
- isSearching: PropTypes.bool,
- };
-
- handleHeaderClick = () => {
- this.column.scrollTop();
- };
-
- setRef = c => {
- this.column = c;
- };
-
- render() {
- const { intl, multiColumn, isSearching } = this.props;
- const { signedIn } = this.context.identity;
-
- return (
-
-
-
-
-
-
-
-
- {isSearching ? (
-
- ) : (
- <>
-
-
-
-
-
-
-
-
-
-
- {signedIn && (
-
-
-
- )}
-
-
-
-
-
-
-
-
-
-
- {intl.formatMessage(messages.title)}
-
-
- >
- )}
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/explore/index.jsx b/app/javascript/mastodon/features/explore/index.jsx
new file mode 100644
index 000000000..d91755ff6
--- /dev/null
+++ b/app/javascript/mastodon/features/explore/index.jsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import Column from 'mastodon/components/column';
+import ColumnHeader from 'mastodon/components/column_header';
+import { NavLink, Switch, Route } from 'react-router-dom';
+import Links from './links';
+import Tags from './tags';
+import Statuses from './statuses';
+import Suggestions from './suggestions';
+import Search from 'mastodon/features/compose/containers/search_container';
+import SearchResults from './results';
+import { Helmet } from 'react-helmet';
+import { showTrends } from 'mastodon/initial_state';
+
+const messages = defineMessages({
+ title: { id: 'explore.title', defaultMessage: 'Explore' },
+ searchResults: { id: 'explore.search_results', defaultMessage: 'Search results' },
+});
+
+const mapStateToProps = state => ({
+ layout: state.getIn(['meta', 'layout']),
+ isSearching: state.getIn(['search', 'submitted']) || !showTrends,
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Explore extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ identity: PropTypes.object,
+ };
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ multiColumn: PropTypes.bool,
+ isSearching: PropTypes.bool,
+ };
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ };
+
+ setRef = c => {
+ this.column = c;
+ };
+
+ render() {
+ const { intl, multiColumn, isSearching } = this.props;
+ const { signedIn } = this.context.identity;
+
+ return (
+
+
+
+
+
+
+
+
+ {isSearching ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+
+
+ {signedIn && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {intl.formatMessage(messages.title)}
+
+
+ >
+ )}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/explore/links.js b/app/javascript/mastodon/features/explore/links.js
deleted file mode 100644
index b47fc8fcf..000000000
--- a/app/javascript/mastodon/features/explore/links.js
+++ /dev/null
@@ -1,70 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import Story from './components/story';
-import LoadingIndicator from 'mastodon/components/loading_indicator';
-import { connect } from 'react-redux';
-import { fetchTrendingLinks } from 'mastodon/actions/trends';
-import { FormattedMessage } from 'react-intl';
-import DismissableBanner from 'mastodon/components/dismissable_banner';
-
-const mapStateToProps = state => ({
- links: state.getIn(['trends', 'links', 'items']),
- isLoading: state.getIn(['trends', 'links', 'isLoading']),
-});
-
-export default @connect(mapStateToProps)
-class Links extends React.PureComponent {
-
- static propTypes = {
- links: ImmutablePropTypes.list,
- isLoading: PropTypes.bool,
- dispatch: PropTypes.func.isRequired,
- };
-
- componentDidMount () {
- const { dispatch } = this.props;
- dispatch(fetchTrendingLinks());
- }
-
- render () {
- const { isLoading, links } = this.props;
-
- const banner = (
-
-
-
- );
-
- if (!isLoading && links.isEmpty()) {
- return (
-
- );
- }
-
- return (
-
- {banner}
-
- {isLoading ? ( ) : links.map(link => (
-
- ))}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/explore/links.jsx b/app/javascript/mastodon/features/explore/links.jsx
new file mode 100644
index 000000000..b47fc8fcf
--- /dev/null
+++ b/app/javascript/mastodon/features/explore/links.jsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Story from './components/story';
+import LoadingIndicator from 'mastodon/components/loading_indicator';
+import { connect } from 'react-redux';
+import { fetchTrendingLinks } from 'mastodon/actions/trends';
+import { FormattedMessage } from 'react-intl';
+import DismissableBanner from 'mastodon/components/dismissable_banner';
+
+const mapStateToProps = state => ({
+ links: state.getIn(['trends', 'links', 'items']),
+ isLoading: state.getIn(['trends', 'links', 'isLoading']),
+});
+
+export default @connect(mapStateToProps)
+class Links extends React.PureComponent {
+
+ static propTypes = {
+ links: ImmutablePropTypes.list,
+ isLoading: PropTypes.bool,
+ dispatch: PropTypes.func.isRequired,
+ };
+
+ componentDidMount () {
+ const { dispatch } = this.props;
+ dispatch(fetchTrendingLinks());
+ }
+
+ render () {
+ const { isLoading, links } = this.props;
+
+ const banner = (
+
+
+
+ );
+
+ if (!isLoading && links.isEmpty()) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {banner}
+
+ {isLoading ? ( ) : links.map(link => (
+
+ ))}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/explore/results.js b/app/javascript/mastodon/features/explore/results.js
deleted file mode 100644
index b2f6c72b7..000000000
--- a/app/javascript/mastodon/features/explore/results.js
+++ /dev/null
@@ -1,126 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
-import { connect } from 'react-redux';
-import { expandSearch } from 'mastodon/actions/search';
-import Account from 'mastodon/containers/account_container';
-import Status from 'mastodon/containers/status_container';
-import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
-import { List as ImmutableList } from 'immutable';
-import LoadMore from 'mastodon/components/load_more';
-import LoadingIndicator from 'mastodon/components/loading_indicator';
-import { Helmet } from 'react-helmet';
-
-const messages = defineMessages({
- title: { id: 'search_results.title', defaultMessage: 'Search for {q}' },
-});
-
-const mapStateToProps = state => ({
- isLoading: state.getIn(['search', 'isLoading']),
- results: state.getIn(['search', 'results']),
- q: state.getIn(['search', 'searchTerm']),
-});
-
-const appendLoadMore = (id, list, onLoadMore) => {
- if (list.size >= 5) {
- return list.push( );
- } else {
- return list;
- }
-};
-
-const renderAccounts = (results, onLoadMore) => appendLoadMore('accounts', results.get('accounts', ImmutableList()).map(item => (
-
-)), onLoadMore);
-
-const renderHashtags = (results, onLoadMore) => appendLoadMore('hashtags', results.get('hashtags', ImmutableList()).map(item => (
-
-)), onLoadMore);
-
-const renderStatuses = (results, onLoadMore) => appendLoadMore('statuses', results.get('statuses', ImmutableList()).map(item => (
-
-)), onLoadMore);
-
-export default @connect(mapStateToProps)
-@injectIntl
-class Results extends React.PureComponent {
-
- static propTypes = {
- results: ImmutablePropTypes.map,
- isLoading: PropTypes.bool,
- multiColumn: PropTypes.bool,
- dispatch: PropTypes.func.isRequired,
- q: PropTypes.string,
- intl: PropTypes.object,
- };
-
- state = {
- type: 'all',
- };
-
- handleSelectAll = () => this.setState({ type: 'all' });
- handleSelectAccounts = () => this.setState({ type: 'accounts' });
- handleSelectHashtags = () => this.setState({ type: 'hashtags' });
- handleSelectStatuses = () => this.setState({ type: 'statuses' });
- handleLoadMoreAccounts = () => this.loadMore('accounts');
- handleLoadMoreStatuses = () => this.loadMore('statuses');
- handleLoadMoreHashtags = () => this.loadMore('hashtags');
-
- loadMore (type) {
- const { dispatch } = this.props;
- dispatch(expandSearch(type));
- }
-
- render () {
- const { intl, isLoading, q, results } = this.props;
- const { type } = this.state;
-
- let filteredResults = ImmutableList();
-
- if (!isLoading) {
- switch(type) {
- case 'all':
- filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts), renderHashtags(results, this.handleLoadMoreHashtags), renderStatuses(results, this.handleLoadMoreStatuses));
- break;
- case 'accounts':
- filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts));
- break;
- case 'hashtags':
- filteredResults = filteredResults.concat(renderHashtags(results, this.handleLoadMoreHashtags));
- break;
- case 'statuses':
- filteredResults = filteredResults.concat(renderStatuses(results, this.handleLoadMoreStatuses));
- break;
- }
-
- if (filteredResults.size === 0) {
- filteredResults = (
-
-
-
- );
- }
- }
-
- return (
-
-
-
-
-
-
-
-
-
- {isLoading ? : filteredResults}
-
-
-
- {intl.formatMessage(messages.title, { q })}
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/explore/results.jsx b/app/javascript/mastodon/features/explore/results.jsx
new file mode 100644
index 000000000..b2f6c72b7
--- /dev/null
+++ b/app/javascript/mastodon/features/explore/results.jsx
@@ -0,0 +1,126 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import { expandSearch } from 'mastodon/actions/search';
+import Account from 'mastodon/containers/account_container';
+import Status from 'mastodon/containers/status_container';
+import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
+import { List as ImmutableList } from 'immutable';
+import LoadMore from 'mastodon/components/load_more';
+import LoadingIndicator from 'mastodon/components/loading_indicator';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+ title: { id: 'search_results.title', defaultMessage: 'Search for {q}' },
+});
+
+const mapStateToProps = state => ({
+ isLoading: state.getIn(['search', 'isLoading']),
+ results: state.getIn(['search', 'results']),
+ q: state.getIn(['search', 'searchTerm']),
+});
+
+const appendLoadMore = (id, list, onLoadMore) => {
+ if (list.size >= 5) {
+ return list.push( );
+ } else {
+ return list;
+ }
+};
+
+const renderAccounts = (results, onLoadMore) => appendLoadMore('accounts', results.get('accounts', ImmutableList()).map(item => (
+
+)), onLoadMore);
+
+const renderHashtags = (results, onLoadMore) => appendLoadMore('hashtags', results.get('hashtags', ImmutableList()).map(item => (
+
+)), onLoadMore);
+
+const renderStatuses = (results, onLoadMore) => appendLoadMore('statuses', results.get('statuses', ImmutableList()).map(item => (
+
+)), onLoadMore);
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Results extends React.PureComponent {
+
+ static propTypes = {
+ results: ImmutablePropTypes.map,
+ isLoading: PropTypes.bool,
+ multiColumn: PropTypes.bool,
+ dispatch: PropTypes.func.isRequired,
+ q: PropTypes.string,
+ intl: PropTypes.object,
+ };
+
+ state = {
+ type: 'all',
+ };
+
+ handleSelectAll = () => this.setState({ type: 'all' });
+ handleSelectAccounts = () => this.setState({ type: 'accounts' });
+ handleSelectHashtags = () => this.setState({ type: 'hashtags' });
+ handleSelectStatuses = () => this.setState({ type: 'statuses' });
+ handleLoadMoreAccounts = () => this.loadMore('accounts');
+ handleLoadMoreStatuses = () => this.loadMore('statuses');
+ handleLoadMoreHashtags = () => this.loadMore('hashtags');
+
+ loadMore (type) {
+ const { dispatch } = this.props;
+ dispatch(expandSearch(type));
+ }
+
+ render () {
+ const { intl, isLoading, q, results } = this.props;
+ const { type } = this.state;
+
+ let filteredResults = ImmutableList();
+
+ if (!isLoading) {
+ switch(type) {
+ case 'all':
+ filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts), renderHashtags(results, this.handleLoadMoreHashtags), renderStatuses(results, this.handleLoadMoreStatuses));
+ break;
+ case 'accounts':
+ filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts));
+ break;
+ case 'hashtags':
+ filteredResults = filteredResults.concat(renderHashtags(results, this.handleLoadMoreHashtags));
+ break;
+ case 'statuses':
+ filteredResults = filteredResults.concat(renderStatuses(results, this.handleLoadMoreStatuses));
+ break;
+ }
+
+ if (filteredResults.size === 0) {
+ filteredResults = (
+
+
+
+ );
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {isLoading ? : filteredResults}
+
+
+
+ {intl.formatMessage(messages.title, { q })}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/explore/statuses.js b/app/javascript/mastodon/features/explore/statuses.js
deleted file mode 100644
index b027487d5..000000000
--- a/app/javascript/mastodon/features/explore/statuses.js
+++ /dev/null
@@ -1,64 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import StatusList from 'mastodon/components/status_list';
-import { FormattedMessage } from 'react-intl';
-import { connect } from 'react-redux';
-import { fetchTrendingStatuses, expandTrendingStatuses } from 'mastodon/actions/trends';
-import { debounce } from 'lodash';
-import DismissableBanner from 'mastodon/components/dismissable_banner';
-
-const mapStateToProps = state => ({
- statusIds: state.getIn(['status_lists', 'trending', 'items']),
- isLoading: state.getIn(['status_lists', 'trending', 'isLoading'], true),
- hasMore: !!state.getIn(['status_lists', 'trending', 'next']),
-});
-
-export default @connect(mapStateToProps)
-class Statuses extends React.PureComponent {
-
- static propTypes = {
- statusIds: ImmutablePropTypes.list,
- isLoading: PropTypes.bool,
- hasMore: PropTypes.bool,
- multiColumn: PropTypes.bool,
- dispatch: PropTypes.func.isRequired,
- };
-
- componentDidMount () {
- const { dispatch } = this.props;
- dispatch(fetchTrendingStatuses());
- }
-
- handleLoadMore = debounce(() => {
- const { dispatch } = this.props;
- dispatch(expandTrendingStatuses());
- }, 300, { leading: true });
-
- render () {
- const { isLoading, hasMore, statusIds, multiColumn } = this.props;
-
- const emptyMessage = ;
-
- return (
- <>
-
-
-
-
-
- >
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/explore/statuses.jsx b/app/javascript/mastodon/features/explore/statuses.jsx
new file mode 100644
index 000000000..b027487d5
--- /dev/null
+++ b/app/javascript/mastodon/features/explore/statuses.jsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import StatusList from 'mastodon/components/status_list';
+import { FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import { fetchTrendingStatuses, expandTrendingStatuses } from 'mastodon/actions/trends';
+import { debounce } from 'lodash';
+import DismissableBanner from 'mastodon/components/dismissable_banner';
+
+const mapStateToProps = state => ({
+ statusIds: state.getIn(['status_lists', 'trending', 'items']),
+ isLoading: state.getIn(['status_lists', 'trending', 'isLoading'], true),
+ hasMore: !!state.getIn(['status_lists', 'trending', 'next']),
+});
+
+export default @connect(mapStateToProps)
+class Statuses extends React.PureComponent {
+
+ static propTypes = {
+ statusIds: ImmutablePropTypes.list,
+ isLoading: PropTypes.bool,
+ hasMore: PropTypes.bool,
+ multiColumn: PropTypes.bool,
+ dispatch: PropTypes.func.isRequired,
+ };
+
+ componentDidMount () {
+ const { dispatch } = this.props;
+ dispatch(fetchTrendingStatuses());
+ }
+
+ handleLoadMore = debounce(() => {
+ const { dispatch } = this.props;
+ dispatch(expandTrendingStatuses());
+ }, 300, { leading: true });
+
+ render () {
+ const { isLoading, hasMore, statusIds, multiColumn } = this.props;
+
+ const emptyMessage = ;
+
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/explore/suggestions.js b/app/javascript/mastodon/features/explore/suggestions.js
deleted file mode 100644
index e6ad09974..000000000
--- a/app/javascript/mastodon/features/explore/suggestions.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import AccountCard from 'mastodon/features/directory/components/account_card';
-import LoadingIndicator from 'mastodon/components/loading_indicator';
-import { connect } from 'react-redux';
-import { fetchSuggestions } from 'mastodon/actions/suggestions';
-import { FormattedMessage } from 'react-intl';
-
-const mapStateToProps = state => ({
- suggestions: state.getIn(['suggestions', 'items']),
- isLoading: state.getIn(['suggestions', 'isLoading']),
-});
-
-export default @connect(mapStateToProps)
-class Suggestions extends React.PureComponent {
-
- static propTypes = {
- isLoading: PropTypes.bool,
- suggestions: ImmutablePropTypes.list,
- dispatch: PropTypes.func.isRequired,
- };
-
- componentDidMount () {
- const { dispatch } = this.props;
- dispatch(fetchSuggestions(true));
- }
-
- render () {
- const { isLoading, suggestions } = this.props;
-
- if (!isLoading && suggestions.isEmpty()) {
- return (
-
- );
- }
-
- return (
-
- {isLoading ?
: suggestions.map(suggestion => (
-
- ))}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/explore/suggestions.jsx b/app/javascript/mastodon/features/explore/suggestions.jsx
new file mode 100644
index 000000000..e6ad09974
--- /dev/null
+++ b/app/javascript/mastodon/features/explore/suggestions.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import AccountCard from 'mastodon/features/directory/components/account_card';
+import LoadingIndicator from 'mastodon/components/loading_indicator';
+import { connect } from 'react-redux';
+import { fetchSuggestions } from 'mastodon/actions/suggestions';
+import { FormattedMessage } from 'react-intl';
+
+const mapStateToProps = state => ({
+ suggestions: state.getIn(['suggestions', 'items']),
+ isLoading: state.getIn(['suggestions', 'isLoading']),
+});
+
+export default @connect(mapStateToProps)
+class Suggestions extends React.PureComponent {
+
+ static propTypes = {
+ isLoading: PropTypes.bool,
+ suggestions: ImmutablePropTypes.list,
+ dispatch: PropTypes.func.isRequired,
+ };
+
+ componentDidMount () {
+ const { dispatch } = this.props;
+ dispatch(fetchSuggestions(true));
+ }
+
+ render () {
+ const { isLoading, suggestions } = this.props;
+
+ if (!isLoading && suggestions.isEmpty()) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {isLoading ?
: suggestions.map(suggestion => (
+
+ ))}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/explore/tags.js b/app/javascript/mastodon/features/explore/tags.js
deleted file mode 100644
index 258dc392f..000000000
--- a/app/javascript/mastodon/features/explore/tags.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
-import LoadingIndicator from 'mastodon/components/loading_indicator';
-import { connect } from 'react-redux';
-import { fetchTrendingHashtags } from 'mastodon/actions/trends';
-import { FormattedMessage } from 'react-intl';
-import DismissableBanner from 'mastodon/components/dismissable_banner';
-
-const mapStateToProps = state => ({
- hashtags: state.getIn(['trends', 'tags', 'items']),
- isLoadingHashtags: state.getIn(['trends', 'tags', 'isLoading']),
-});
-
-export default @connect(mapStateToProps)
-class Tags extends React.PureComponent {
-
- static propTypes = {
- hashtags: ImmutablePropTypes.list,
- isLoading: PropTypes.bool,
- dispatch: PropTypes.func.isRequired,
- };
-
- componentDidMount () {
- const { dispatch } = this.props;
- dispatch(fetchTrendingHashtags());
- }
-
- render () {
- const { isLoading, hashtags } = this.props;
-
- const banner = (
-
-
-
- );
-
- if (!isLoading && hashtags.isEmpty()) {
- return (
-
- );
- }
-
- return (
-
- {banner}
-
- {isLoading ? ( ) : hashtags.map(hashtag => (
-
- ))}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/explore/tags.jsx b/app/javascript/mastodon/features/explore/tags.jsx
new file mode 100644
index 000000000..258dc392f
--- /dev/null
+++ b/app/javascript/mastodon/features/explore/tags.jsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
+import LoadingIndicator from 'mastodon/components/loading_indicator';
+import { connect } from 'react-redux';
+import { fetchTrendingHashtags } from 'mastodon/actions/trends';
+import { FormattedMessage } from 'react-intl';
+import DismissableBanner from 'mastodon/components/dismissable_banner';
+
+const mapStateToProps = state => ({
+ hashtags: state.getIn(['trends', 'tags', 'items']),
+ isLoadingHashtags: state.getIn(['trends', 'tags', 'isLoading']),
+});
+
+export default @connect(mapStateToProps)
+class Tags extends React.PureComponent {
+
+ static propTypes = {
+ hashtags: ImmutablePropTypes.list,
+ isLoading: PropTypes.bool,
+ dispatch: PropTypes.func.isRequired,
+ };
+
+ componentDidMount () {
+ const { dispatch } = this.props;
+ dispatch(fetchTrendingHashtags());
+ }
+
+ render () {
+ const { isLoading, hashtags } = this.props;
+
+ const banner = (
+
+
+
+ );
+
+ if (!isLoading && hashtags.isEmpty()) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {banner}
+
+ {isLoading ? ( ) : hashtags.map(hashtag => (
+
+ ))}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js
deleted file mode 100644
index 89093f682..000000000
--- a/app/javascript/mastodon/features/favourited_statuses/index.js
+++ /dev/null
@@ -1,108 +0,0 @@
-import { debounce } from 'lodash';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Helmet } from 'react-helmet';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import { connect } from 'react-redux';
-import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
-import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'mastodon/actions/favourites';
-import ColumnHeader from 'mastodon/components/column_header';
-import StatusList from 'mastodon/components/status_list';
-import Column from 'mastodon/features/ui/components/column';
-
-const messages = defineMessages({
- heading: { id: 'column.favourites', defaultMessage: 'Favourites' },
-});
-
-const mapStateToProps = state => ({
- statusIds: state.getIn(['status_lists', 'favourites', 'items']),
- isLoading: state.getIn(['status_lists', 'favourites', 'isLoading'], true),
- hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-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,
- isLoading: 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;
- };
-
- handleLoadMore = debounce(() => {
- this.props.dispatch(expandFavouritedStatuses());
- }, 300, { leading: true });
-
- render () {
- const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
- const pinned = !!columnId;
-
- const emptyMessage = ;
-
- return (
-
-
-
-
-
-
- {intl.formatMessage(messages.heading)}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/favourited_statuses/index.jsx b/app/javascript/mastodon/features/favourited_statuses/index.jsx
new file mode 100644
index 000000000..89093f682
--- /dev/null
+++ b/app/javascript/mastodon/features/favourited_statuses/index.jsx
@@ -0,0 +1,108 @@
+import { debounce } from 'lodash';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Helmet } from 'react-helmet';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
+import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'mastodon/actions/favourites';
+import ColumnHeader from 'mastodon/components/column_header';
+import StatusList from 'mastodon/components/status_list';
+import Column from 'mastodon/features/ui/components/column';
+
+const messages = defineMessages({
+ heading: { id: 'column.favourites', defaultMessage: 'Favourites' },
+});
+
+const mapStateToProps = state => ({
+ statusIds: state.getIn(['status_lists', 'favourites', 'items']),
+ isLoading: state.getIn(['status_lists', 'favourites', 'isLoading'], true),
+ hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+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,
+ isLoading: 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;
+ };
+
+ handleLoadMore = debounce(() => {
+ this.props.dispatch(expandFavouritedStatuses());
+ }, 300, { leading: true });
+
+ render () {
+ const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
+ const pinned = !!columnId;
+
+ const emptyMessage = ;
+
+ return (
+
+
+
+
+
+
+ {intl.formatMessage(messages.heading)}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/favourites/index.js b/app/javascript/mastodon/features/favourites/index.js
deleted file mode 100644
index 7179e6470..000000000
--- a/app/javascript/mastodon/features/favourites/index.js
+++ /dev/null
@@ -1,92 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import { connect } from 'react-redux';
-import ColumnHeader from 'mastodon/components/column_header';
-import Icon from 'mastodon/components/icon';
-import { fetchFavourites } from 'mastodon/actions/interactions';
-import LoadingIndicator from 'mastodon/components/loading_indicator';
-import ScrollableList from 'mastodon/components/scrollable_list';
-import AccountContainer from 'mastodon/containers/account_container';
-import Column from 'mastodon/features/ui/components/column';
-import { Helmet } from 'react-helmet';
-
-const messages = defineMessages({
- refresh: { id: 'refresh', defaultMessage: 'Refresh' },
-});
-
-const mapStateToProps = (state, props) => ({
- accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]),
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-class Favourites extends ImmutablePureComponent {
-
- static propTypes = {
- params: PropTypes.object.isRequired,
- dispatch: PropTypes.func.isRequired,
- accountIds: ImmutablePropTypes.list,
- multiColumn: PropTypes.bool,
- intl: PropTypes.object.isRequired,
- };
-
- componentWillMount () {
- if (!this.props.accountIds) {
- 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));
- }
- }
-
- handleRefresh = () => {
- this.props.dispatch(fetchFavourites(this.props.params.statusId));
- };
-
- render () {
- const { intl, accountIds, multiColumn } = this.props;
-
- if (!accountIds) {
- return (
-
-
-
- );
- }
-
- const emptyMessage = ;
-
- return (
-
-
- )}
- />
-
-
- {accountIds.map(id =>
- ,
- )}
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/favourites/index.jsx b/app/javascript/mastodon/features/favourites/index.jsx
new file mode 100644
index 000000000..7179e6470
--- /dev/null
+++ b/app/javascript/mastodon/features/favourites/index.jsx
@@ -0,0 +1,92 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import ColumnHeader from 'mastodon/components/column_header';
+import Icon from 'mastodon/components/icon';
+import { fetchFavourites } from 'mastodon/actions/interactions';
+import LoadingIndicator from 'mastodon/components/loading_indicator';
+import ScrollableList from 'mastodon/components/scrollable_list';
+import AccountContainer from 'mastodon/containers/account_container';
+import Column from 'mastodon/features/ui/components/column';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+ refresh: { id: 'refresh', defaultMessage: 'Refresh' },
+});
+
+const mapStateToProps = (state, props) => ({
+ accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Favourites extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ accountIds: ImmutablePropTypes.list,
+ multiColumn: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ };
+
+ componentWillMount () {
+ if (!this.props.accountIds) {
+ 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));
+ }
+ }
+
+ handleRefresh = () => {
+ this.props.dispatch(fetchFavourites(this.props.params.statusId));
+ };
+
+ render () {
+ const { intl, accountIds, multiColumn } = this.props;
+
+ if (!accountIds) {
+ return (
+
+
+
+ );
+ }
+
+ const emptyMessage = ;
+
+ return (
+
+
+ )}
+ />
+
+
+ {accountIds.map(id =>
+ ,
+ )}
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/filters/added_to_filter.js b/app/javascript/mastodon/features/filters/added_to_filter.js
deleted file mode 100644
index 3785eb3c5..000000000
--- a/app/javascript/mastodon/features/filters/added_to_filter.js
+++ /dev/null
@@ -1,102 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { FormattedMessage } from 'react-intl';
-import { toServerSideType } from 'mastodon/utils/filters';
-import Button from 'mastodon/components/button';
-import { connect } from 'react-redux';
-
-const mapStateToProps = (state, { filterId }) => ({
- filter: state.getIn(['filters', filterId]),
-});
-
-export default @connect(mapStateToProps)
-class AddedToFilter extends React.PureComponent {
-
- static propTypes = {
- onClose: PropTypes.func.isRequired,
- contextType: PropTypes.string,
- filter: ImmutablePropTypes.map.isRequired,
- dispatch: PropTypes.func.isRequired,
- };
-
- handleCloseClick = () => {
- const { onClose } = this.props;
- onClose();
- };
-
- render () {
- const { filter, contextType } = this.props;
-
- let expiredMessage = null;
- if (filter.get('expires_at') && filter.get('expires_at') < new Date()) {
- expiredMessage = (
-
-
-
-
-
-
- );
- }
-
- let contextMismatchMessage = null;
- if (contextType && !filter.get('context').includes(toServerSideType(contextType))) {
- contextMismatchMessage = (
-
-
-
-
-
-
- );
- }
-
- const settings_link = (
-
-
-
- );
-
- return (
-
-
-
-
-
-
- {expiredMessage}
- {contextMismatchMessage}
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/filters/added_to_filter.jsx b/app/javascript/mastodon/features/filters/added_to_filter.jsx
new file mode 100644
index 000000000..3785eb3c5
--- /dev/null
+++ b/app/javascript/mastodon/features/filters/added_to_filter.jsx
@@ -0,0 +1,102 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import { toServerSideType } from 'mastodon/utils/filters';
+import Button from 'mastodon/components/button';
+import { connect } from 'react-redux';
+
+const mapStateToProps = (state, { filterId }) => ({
+ filter: state.getIn(['filters', filterId]),
+});
+
+export default @connect(mapStateToProps)
+class AddedToFilter extends React.PureComponent {
+
+ static propTypes = {
+ onClose: PropTypes.func.isRequired,
+ contextType: PropTypes.string,
+ filter: ImmutablePropTypes.map.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ };
+
+ handleCloseClick = () => {
+ const { onClose } = this.props;
+ onClose();
+ };
+
+ render () {
+ const { filter, contextType } = this.props;
+
+ let expiredMessage = null;
+ if (filter.get('expires_at') && filter.get('expires_at') < new Date()) {
+ expiredMessage = (
+
+
+
+
+
+
+ );
+ }
+
+ let contextMismatchMessage = null;
+ if (contextType && !filter.get('context').includes(toServerSideType(contextType))) {
+ contextMismatchMessage = (
+
+
+
+
+
+
+ );
+ }
+
+ const settings_link = (
+
+
+
+ );
+
+ return (
+
+
+
+
+
+
+ {expiredMessage}
+ {contextMismatchMessage}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/filters/select_filter.js b/app/javascript/mastodon/features/filters/select_filter.js
deleted file mode 100644
index 8a21905d7..000000000
--- a/app/javascript/mastodon/features/filters/select_filter.js
+++ /dev/null
@@ -1,192 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { connect } from 'react-redux';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import { toServerSideType } from 'mastodon/utils/filters';
-import { loupeIcon, deleteIcon } from 'mastodon/utils/icons';
-import Icon from 'mastodon/components/icon';
-import fuzzysort from 'fuzzysort';
-
-const messages = defineMessages({
- search: { id: 'filter_modal.select_filter.search', defaultMessage: 'Search or create' },
- clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
-});
-
-const mapStateToProps = (state, { contextType }) => ({
- filters: Array.from(state.get('filters').values()).map((filter) => [
- filter.get('id'),
- filter.get('title'),
- filter.get('keywords')?.map((keyword) => keyword.get('keyword')).join('\n'),
- filter.get('expires_at') && filter.get('expires_at') < new Date(),
- contextType && !filter.get('context').includes(toServerSideType(contextType)),
- ]),
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-class SelectFilter extends React.PureComponent {
-
- static propTypes = {
- onSelectFilter: PropTypes.func.isRequired,
- onNewFilter: PropTypes.func.isRequired,
- filters: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.object)),
- intl: PropTypes.object.isRequired,
- };
-
- state = {
- searchValue: '',
- };
-
- search () {
- const { filters } = this.props;
- const { searchValue } = this.state;
-
- if (searchValue === '') {
- return filters;
- }
-
- return fuzzysort.go(searchValue, filters, {
- keys: ['1', '2'],
- limit: 5,
- threshold: -10000,
- }).map(result => result.obj);
- }
-
- renderItem = filter => {
- let warning = null;
- if (filter[3] || filter[4]) {
- warning = (
-
- (
- {filter[3] && }
- {filter[3] && filter[4] && ', '}
- {filter[4] && }
- )
-
- );
- }
-
- return (
-
- {filter[1]} {warning}
-
- );
- };
-
- renderCreateNew (name) {
- return (
-
-
-
- );
- }
-
- handleSearchChange = ({ target }) => {
- this.setState({ searchValue: target.value });
- };
-
- setListRef = c => {
- this.listNode = c;
- };
-
- handleKeyDown = e => {
- const index = Array.from(this.listNode.childNodes).findIndex(node => node === e.currentTarget);
-
- let element = null;
-
- switch(e.key) {
- case ' ':
- case 'Enter':
- e.currentTarget.click();
- break;
- case 'ArrowDown':
- element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
- break;
- case 'ArrowUp':
- element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
- break;
- case 'Tab':
- if (e.shiftKey) {
- element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
- } else {
- element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
- }
- break;
- case 'Home':
- element = this.listNode.firstChild;
- break;
- case 'End':
- element = this.listNode.lastChild;
- break;
- }
-
- if (element) {
- element.focus();
- e.preventDefault();
- e.stopPropagation();
- }
- };
-
- handleSearchKeyDown = e => {
- let element = null;
-
- switch(e.key) {
- case 'Tab':
- case 'ArrowDown':
- element = this.listNode.firstChild;
-
- if (element) {
- element.focus();
- e.preventDefault();
- e.stopPropagation();
- }
-
- break;
- }
- };
-
- handleClear = () => {
- this.setState({ searchValue: '' });
- };
-
- handleItemClick = e => {
- const value = e.currentTarget.getAttribute('data-index');
-
- e.preventDefault();
-
- this.props.onSelectFilter(value);
- };
-
- handleNewFilterClick = e => {
- e.preventDefault();
-
- this.props.onNewFilter(this.state.searchValue);
- };
-
- render () {
- const { intl } = this.props;
-
- const { searchValue } = this.state;
- const isSearching = searchValue !== '';
- const results = this.search();
-
- return (
-
-
-
-
-
-
- {!isSearching ? loupeIcon : deleteIcon}
-
-
-
- {results.map(this.renderItem)}
- {isSearching && this.renderCreateNew(searchValue) }
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/filters/select_filter.jsx b/app/javascript/mastodon/features/filters/select_filter.jsx
new file mode 100644
index 000000000..8a21905d7
--- /dev/null
+++ b/app/javascript/mastodon/features/filters/select_filter.jsx
@@ -0,0 +1,192 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { toServerSideType } from 'mastodon/utils/filters';
+import { loupeIcon, deleteIcon } from 'mastodon/utils/icons';
+import Icon from 'mastodon/components/icon';
+import fuzzysort from 'fuzzysort';
+
+const messages = defineMessages({
+ search: { id: 'filter_modal.select_filter.search', defaultMessage: 'Search or create' },
+ clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
+});
+
+const mapStateToProps = (state, { contextType }) => ({
+ filters: Array.from(state.get('filters').values()).map((filter) => [
+ filter.get('id'),
+ filter.get('title'),
+ filter.get('keywords')?.map((keyword) => keyword.get('keyword')).join('\n'),
+ filter.get('expires_at') && filter.get('expires_at') < new Date(),
+ contextType && !filter.get('context').includes(toServerSideType(contextType)),
+ ]),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class SelectFilter extends React.PureComponent {
+
+ static propTypes = {
+ onSelectFilter: PropTypes.func.isRequired,
+ onNewFilter: PropTypes.func.isRequired,
+ filters: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.object)),
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ searchValue: '',
+ };
+
+ search () {
+ const { filters } = this.props;
+ const { searchValue } = this.state;
+
+ if (searchValue === '') {
+ return filters;
+ }
+
+ return fuzzysort.go(searchValue, filters, {
+ keys: ['1', '2'],
+ limit: 5,
+ threshold: -10000,
+ }).map(result => result.obj);
+ }
+
+ renderItem = filter => {
+ let warning = null;
+ if (filter[3] || filter[4]) {
+ warning = (
+
+ (
+ {filter[3] && }
+ {filter[3] && filter[4] && ', '}
+ {filter[4] && }
+ )
+
+ );
+ }
+
+ return (
+
+ {filter[1]} {warning}
+
+ );
+ };
+
+ renderCreateNew (name) {
+ return (
+
+
+
+ );
+ }
+
+ handleSearchChange = ({ target }) => {
+ this.setState({ searchValue: target.value });
+ };
+
+ setListRef = c => {
+ this.listNode = c;
+ };
+
+ handleKeyDown = e => {
+ const index = Array.from(this.listNode.childNodes).findIndex(node => node === e.currentTarget);
+
+ let element = null;
+
+ switch(e.key) {
+ case ' ':
+ case 'Enter':
+ e.currentTarget.click();
+ break;
+ case 'ArrowDown':
+ element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
+ break;
+ case 'ArrowUp':
+ element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
+ break;
+ case 'Tab':
+ if (e.shiftKey) {
+ element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
+ } else {
+ element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
+ }
+ break;
+ case 'Home':
+ element = this.listNode.firstChild;
+ break;
+ case 'End':
+ element = this.listNode.lastChild;
+ break;
+ }
+
+ if (element) {
+ element.focus();
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ };
+
+ handleSearchKeyDown = e => {
+ let element = null;
+
+ switch(e.key) {
+ case 'Tab':
+ case 'ArrowDown':
+ element = this.listNode.firstChild;
+
+ if (element) {
+ element.focus();
+ e.preventDefault();
+ e.stopPropagation();
+ }
+
+ break;
+ }
+ };
+
+ handleClear = () => {
+ this.setState({ searchValue: '' });
+ };
+
+ handleItemClick = e => {
+ const value = e.currentTarget.getAttribute('data-index');
+
+ e.preventDefault();
+
+ this.props.onSelectFilter(value);
+ };
+
+ handleNewFilterClick = e => {
+ e.preventDefault();
+
+ this.props.onNewFilter(this.state.searchValue);
+ };
+
+ render () {
+ const { intl } = this.props;
+
+ const { searchValue } = this.state;
+ const isSearching = searchValue !== '';
+ const results = this.search();
+
+ return (
+
+
+
+
+
+
+ {!isSearching ? loupeIcon : deleteIcon}
+
+
+
+ {results.map(this.renderItem)}
+ {isSearching && this.renderCreateNew(searchValue) }
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/follow_recommendations/components/account.js b/app/javascript/mastodon/features/follow_recommendations/components/account.js
deleted file mode 100644
index ddd0c8baa..000000000
--- a/app/javascript/mastodon/features/follow_recommendations/components/account.js
+++ /dev/null
@@ -1,85 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { connect } from 'react-redux';
-import { makeGetAccount } from 'mastodon/selectors';
-import Avatar from 'mastodon/components/avatar';
-import DisplayName from 'mastodon/components/display_name';
-import { Link } from 'react-router-dom';
-import IconButton from 'mastodon/components/icon_button';
-import { injectIntl, defineMessages } from 'react-intl';
-import { followAccount, unfollowAccount } from 'mastodon/actions/accounts';
-
-const messages = defineMessages({
- follow: { id: 'account.follow', defaultMessage: 'Follow' },
- unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
-});
-
-const makeMapStateToProps = () => {
- const getAccount = makeGetAccount();
-
- const mapStateToProps = (state, props) => ({
- account: getAccount(state, props.id),
- });
-
- return mapStateToProps;
-};
-
-const getFirstSentence = str => {
- const arr = str.split(/(([.?!]+\s)|[.。?!\n•])/);
-
- return arr[0];
-};
-
-export default @connect(makeMapStateToProps)
-@injectIntl
-class Account extends ImmutablePureComponent {
-
- static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
- intl: PropTypes.object.isRequired,
- dispatch: PropTypes.func.isRequired,
- };
-
- handleFollow = () => {
- const { account, dispatch } = this.props;
-
- if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
- dispatch(unfollowAccount(account.get('id')));
- } else {
- dispatch(followAccount(account.get('id')));
- }
- };
-
- render () {
- const { account, intl } = this.props;
-
- let button;
-
- if (account.getIn(['relationship', 'following'])) {
- button = ;
- } else {
- button = ;
- }
-
- return (
-
-
-
-
-
-
-
-
{getFirstSentence(account.get('note_plain'))}
-
-
-
- {button}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/follow_recommendations/components/account.jsx b/app/javascript/mastodon/features/follow_recommendations/components/account.jsx
new file mode 100644
index 000000000..ddd0c8baa
--- /dev/null
+++ b/app/javascript/mastodon/features/follow_recommendations/components/account.jsx
@@ -0,0 +1,85 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'mastodon/selectors';
+import Avatar from 'mastodon/components/avatar';
+import DisplayName from 'mastodon/components/display_name';
+import { Link } from 'react-router-dom';
+import IconButton from 'mastodon/components/icon_button';
+import { injectIntl, defineMessages } from 'react-intl';
+import { followAccount, unfollowAccount } from 'mastodon/actions/accounts';
+
+const messages = defineMessages({
+ follow: { id: 'account.follow', defaultMessage: 'Follow' },
+ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+});
+
+const makeMapStateToProps = () => {
+ const getAccount = makeGetAccount();
+
+ const mapStateToProps = (state, props) => ({
+ account: getAccount(state, props.id),
+ });
+
+ return mapStateToProps;
+};
+
+const getFirstSentence = str => {
+ const arr = str.split(/(([.?!]+\s)|[.。?!\n•])/);
+
+ return arr[0];
+};
+
+export default @connect(makeMapStateToProps)
+@injectIntl
+class Account extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ intl: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ };
+
+ handleFollow = () => {
+ const { account, dispatch } = this.props;
+
+ if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
+ dispatch(unfollowAccount(account.get('id')));
+ } else {
+ dispatch(followAccount(account.get('id')));
+ }
+ };
+
+ render () {
+ const { account, intl } = this.props;
+
+ let button;
+
+ if (account.getIn(['relationship', 'following'])) {
+ button = ;
+ } else {
+ button = ;
+ }
+
+ return (
+
+
+
+
+
+
+
+
{getFirstSentence(account.get('note_plain'))}
+
+
+
+ {button}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/follow_recommendations/index.js b/app/javascript/mastodon/features/follow_recommendations/index.js
deleted file mode 100644
index 436cc582b..000000000
--- a/app/javascript/mastodon/features/follow_recommendations/index.js
+++ /dev/null
@@ -1,116 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { connect } from 'react-redux';
-import { FormattedMessage } from 'react-intl';
-import { fetchSuggestions } from 'mastodon/actions/suggestions';
-import { changeSetting, saveSettings } from 'mastodon/actions/settings';
-import { requestBrowserPermission } from 'mastodon/actions/notifications';
-import { markAsPartial } from 'mastodon/actions/timelines';
-import Column from 'mastodon/features/ui/components/column';
-import Account from './components/account';
-import imageGreeting from 'mastodon/../images/elephant_ui_greeting.svg';
-import Button from 'mastodon/components/button';
-import { Helmet } from 'react-helmet';
-
-const mapStateToProps = state => ({
- suggestions: state.getIn(['suggestions', 'items']),
- isLoading: state.getIn(['suggestions', 'isLoading']),
-});
-
-export default @connect(mapStateToProps)
-class FollowRecommendations extends ImmutablePureComponent {
-
- static contextTypes = {
- router: PropTypes.object.isRequired,
- };
-
- static propTypes = {
- dispatch: PropTypes.func.isRequired,
- suggestions: ImmutablePropTypes.list,
- isLoading: PropTypes.bool,
- };
-
- componentDidMount () {
- const { dispatch, suggestions } = this.props;
-
- // Don't re-fetch if we're e.g. navigating backwards to this page,
- // since we don't want followed accounts to disappear from the list
-
- if (suggestions.size === 0) {
- dispatch(fetchSuggestions(true));
- }
- }
-
- componentWillUnmount () {
- const { dispatch } = this.props;
-
- // Force the home timeline to be reloaded when the user navigates
- // to it; if the user is new, it would've been empty before
-
- dispatch(markAsPartial('home'));
- }
-
- handleDone = () => {
- const { dispatch } = this.props;
- const { router } = this.context;
-
- dispatch(requestBrowserPermission((permission) => {
- if (permission === 'granted') {
- dispatch(changeSetting(['notifications', 'alerts', 'follow'], true));
- dispatch(changeSetting(['notifications', 'alerts', 'favourite'], true));
- dispatch(changeSetting(['notifications', 'alerts', 'reblog'], true));
- dispatch(changeSetting(['notifications', 'alerts', 'mention'], true));
- dispatch(changeSetting(['notifications', 'alerts', 'poll'], true));
- dispatch(changeSetting(['notifications', 'alerts', 'status'], true));
- dispatch(saveSettings());
- }
- }));
-
- router.history.push('/home');
- };
-
- render () {
- const { suggestions, isLoading } = this.props;
-
- return (
-
-
-
-
- {!isLoading && (
-
-
- {suggestions.size > 0 ? suggestions.map(suggestion => (
-
- )) : (
-
-
-
- )}
-
-
-
-
-
-
-
- )}
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/follow_recommendations/index.jsx b/app/javascript/mastodon/features/follow_recommendations/index.jsx
new file mode 100644
index 000000000..436cc582b
--- /dev/null
+++ b/app/javascript/mastodon/features/follow_recommendations/index.jsx
@@ -0,0 +1,116 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { FormattedMessage } from 'react-intl';
+import { fetchSuggestions } from 'mastodon/actions/suggestions';
+import { changeSetting, saveSettings } from 'mastodon/actions/settings';
+import { requestBrowserPermission } from 'mastodon/actions/notifications';
+import { markAsPartial } from 'mastodon/actions/timelines';
+import Column from 'mastodon/features/ui/components/column';
+import Account from './components/account';
+import imageGreeting from 'mastodon/../images/elephant_ui_greeting.svg';
+import Button from 'mastodon/components/button';
+import { Helmet } from 'react-helmet';
+
+const mapStateToProps = state => ({
+ suggestions: state.getIn(['suggestions', 'items']),
+ isLoading: state.getIn(['suggestions', 'isLoading']),
+});
+
+export default @connect(mapStateToProps)
+class FollowRecommendations extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object.isRequired,
+ };
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ suggestions: ImmutablePropTypes.list,
+ isLoading: PropTypes.bool,
+ };
+
+ componentDidMount () {
+ const { dispatch, suggestions } = this.props;
+
+ // Don't re-fetch if we're e.g. navigating backwards to this page,
+ // since we don't want followed accounts to disappear from the list
+
+ if (suggestions.size === 0) {
+ dispatch(fetchSuggestions(true));
+ }
+ }
+
+ componentWillUnmount () {
+ const { dispatch } = this.props;
+
+ // Force the home timeline to be reloaded when the user navigates
+ // to it; if the user is new, it would've been empty before
+
+ dispatch(markAsPartial('home'));
+ }
+
+ handleDone = () => {
+ const { dispatch } = this.props;
+ const { router } = this.context;
+
+ dispatch(requestBrowserPermission((permission) => {
+ if (permission === 'granted') {
+ dispatch(changeSetting(['notifications', 'alerts', 'follow'], true));
+ dispatch(changeSetting(['notifications', 'alerts', 'favourite'], true));
+ dispatch(changeSetting(['notifications', 'alerts', 'reblog'], true));
+ dispatch(changeSetting(['notifications', 'alerts', 'mention'], true));
+ dispatch(changeSetting(['notifications', 'alerts', 'poll'], true));
+ dispatch(changeSetting(['notifications', 'alerts', 'status'], true));
+ dispatch(saveSettings());
+ }
+ }));
+
+ router.history.push('/home');
+ };
+
+ render () {
+ const { suggestions, isLoading } = this.props;
+
+ return (
+
+
+
+
+ {!isLoading && (
+
+
+ {suggestions.size > 0 ? suggestions.map(suggestion => (
+
+ )) : (
+
+
+
+ )}
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/follow_requests/components/account_authorize.js b/app/javascript/mastodon/features/follow_requests/components/account_authorize.js
deleted file mode 100644
index d41f331e5..000000000
--- a/app/javascript/mastodon/features/follow_requests/components/account_authorize.js
+++ /dev/null
@@ -1,49 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { Link } from 'react-router-dom';
-import Avatar from '../../../components/avatar';
-import DisplayName from '../../../components/display_name';
-import IconButton from '../../../components/icon_button';
-import { defineMessages, injectIntl } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-const messages = defineMessages({
- authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
- reject: { id: 'follow_request.reject', defaultMessage: 'Reject' },
-});
-
-export default @injectIntl
-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/mastodon/features/follow_requests/components/account_authorize.jsx b/app/javascript/mastodon/features/follow_requests/components/account_authorize.jsx
new file mode 100644
index 000000000..d41f331e5
--- /dev/null
+++ b/app/javascript/mastodon/features/follow_requests/components/account_authorize.jsx
@@ -0,0 +1,49 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { Link } from 'react-router-dom';
+import Avatar from '../../../components/avatar';
+import DisplayName from '../../../components/display_name';
+import IconButton from '../../../components/icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
+ reject: { id: 'follow_request.reject', defaultMessage: 'Reject' },
+});
+
+export default @injectIntl
+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/mastodon/features/follow_requests/index.js b/app/javascript/mastodon/features/follow_requests/index.js
deleted file mode 100644
index 526ae4cde..000000000
--- a/app/javascript/mastodon/features/follow_requests/index.js
+++ /dev/null
@@ -1,91 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { debounce } from 'lodash';
-import Column from '../ui/components/column';
-import ColumnBackButtonSlim from '../../components/column_back_button_slim';
-import AccountAuthorizeContainer from './containers/account_authorize_container';
-import { fetchFollowRequests, expandFollowRequests } from '../../actions/accounts';
-import ScrollableList from '../../components/scrollable_list';
-import { me } from '../../initial_state';
-import { Helmet } from 'react-helmet';
-
-const messages = defineMessages({
- heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' },
-});
-
-const mapStateToProps = state => ({
- accountIds: state.getIn(['user_lists', 'follow_requests', 'items']),
- isLoading: state.getIn(['user_lists', 'follow_requests', 'isLoading'], true),
- hasMore: !!state.getIn(['user_lists', 'follow_requests', 'next']),
- locked: !!state.getIn(['accounts', me, 'locked']),
- domain: state.getIn(['meta', 'domain']),
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-class FollowRequests extends ImmutablePureComponent {
-
- static propTypes = {
- params: PropTypes.object.isRequired,
- dispatch: PropTypes.func.isRequired,
- hasMore: PropTypes.bool,
- isLoading: PropTypes.bool,
- accountIds: ImmutablePropTypes.list,
- locked: PropTypes.bool,
- domain: PropTypes.string,
- intl: PropTypes.object.isRequired,
- multiColumn: PropTypes.bool,
- };
-
- componentWillMount () {
- this.props.dispatch(fetchFollowRequests());
- }
-
- handleLoadMore = debounce(() => {
- this.props.dispatch(expandFollowRequests());
- }, 300, { leading: true });
-
- render () {
- const { intl, accountIds, hasMore, multiColumn, locked, domain, isLoading } = this.props;
-
- const emptyMessage = ;
- const unlockedPrependMessage = !locked && accountIds.size > 0 && (
-
-
-
- );
-
- return (
-
-
-
- {accountIds.map(id =>
- ,
- )}
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/follow_requests/index.jsx b/app/javascript/mastodon/features/follow_requests/index.jsx
new file mode 100644
index 000000000..526ae4cde
--- /dev/null
+++ b/app/javascript/mastodon/features/follow_requests/index.jsx
@@ -0,0 +1,91 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { debounce } from 'lodash';
+import Column from '../ui/components/column';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import AccountAuthorizeContainer from './containers/account_authorize_container';
+import { fetchFollowRequests, expandFollowRequests } from '../../actions/accounts';
+import ScrollableList from '../../components/scrollable_list';
+import { me } from '../../initial_state';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+ heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' },
+});
+
+const mapStateToProps = state => ({
+ accountIds: state.getIn(['user_lists', 'follow_requests', 'items']),
+ isLoading: state.getIn(['user_lists', 'follow_requests', 'isLoading'], true),
+ hasMore: !!state.getIn(['user_lists', 'follow_requests', 'next']),
+ locked: !!state.getIn(['accounts', me, 'locked']),
+ domain: state.getIn(['meta', 'domain']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class FollowRequests extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ hasMore: PropTypes.bool,
+ isLoading: PropTypes.bool,
+ accountIds: ImmutablePropTypes.list,
+ locked: PropTypes.bool,
+ domain: PropTypes.string,
+ intl: PropTypes.object.isRequired,
+ multiColumn: PropTypes.bool,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchFollowRequests());
+ }
+
+ handleLoadMore = debounce(() => {
+ this.props.dispatch(expandFollowRequests());
+ }, 300, { leading: true });
+
+ render () {
+ const { intl, accountIds, hasMore, multiColumn, locked, domain, isLoading } = this.props;
+
+ const emptyMessage = ;
+ const unlockedPrependMessage = !locked && accountIds.size > 0 && (
+
+
+
+ );
+
+ return (
+
+
+
+ {accountIds.map(id =>
+ ,
+ )}
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/followed_tags/index.js b/app/javascript/mastodon/features/followed_tags/index.js
deleted file mode 100644
index c2d0e4731..000000000
--- a/app/javascript/mastodon/features/followed_tags/index.js
+++ /dev/null
@@ -1,89 +0,0 @@
-import { debounce } from 'lodash';
-import PropTypes from 'prop-types';
-import React from 'react';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import { connect } from 'react-redux';
-import ColumnHeader from 'mastodon/components/column_header';
-import ScrollableList from 'mastodon/components/scrollable_list';
-import Column from 'mastodon/features/ui/components/column';
-import { Helmet } from 'react-helmet';
-import Hashtag from 'mastodon/components/hashtag';
-import { expandFollowedHashtags, fetchFollowedHashtags } from 'mastodon/actions/tags';
-
-const messages = defineMessages({
- heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
-});
-
-const mapStateToProps = state => ({
- hashtags: state.getIn(['followed_tags', 'items']),
- isLoading: state.getIn(['followed_tags', 'isLoading'], true),
- hasMore: !!state.getIn(['followed_tags', 'next']),
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-class FollowedTags extends ImmutablePureComponent {
-
- static propTypes = {
- params: PropTypes.object.isRequired,
- dispatch: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- hashtags: ImmutablePropTypes.list,
- isLoading: PropTypes.bool,
- hasMore: PropTypes.bool,
- multiColumn: PropTypes.bool,
- };
-
- componentDidMount() {
- this.props.dispatch(fetchFollowedHashtags());
- }
-
- handleLoadMore = debounce(() => {
- this.props.dispatch(expandFollowedHashtags());
- }, 300, { leading: true });
-
- render () {
- const { intl, hashtags, isLoading, hasMore, multiColumn } = this.props;
-
- const emptyMessage = ;
-
- return (
-
-
-
-
- {hashtags.map((hashtag) => (
- day.get('uses')).toArray()}
- />
- ))}
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/followed_tags/index.jsx b/app/javascript/mastodon/features/followed_tags/index.jsx
new file mode 100644
index 000000000..c2d0e4731
--- /dev/null
+++ b/app/javascript/mastodon/features/followed_tags/index.jsx
@@ -0,0 +1,89 @@
+import { debounce } from 'lodash';
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import ColumnHeader from 'mastodon/components/column_header';
+import ScrollableList from 'mastodon/components/scrollable_list';
+import Column from 'mastodon/features/ui/components/column';
+import { Helmet } from 'react-helmet';
+import Hashtag from 'mastodon/components/hashtag';
+import { expandFollowedHashtags, fetchFollowedHashtags } from 'mastodon/actions/tags';
+
+const messages = defineMessages({
+ heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
+});
+
+const mapStateToProps = state => ({
+ hashtags: state.getIn(['followed_tags', 'items']),
+ isLoading: state.getIn(['followed_tags', 'isLoading'], true),
+ hasMore: !!state.getIn(['followed_tags', 'next']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class FollowedTags extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ hashtags: ImmutablePropTypes.list,
+ isLoading: PropTypes.bool,
+ hasMore: PropTypes.bool,
+ multiColumn: PropTypes.bool,
+ };
+
+ componentDidMount() {
+ this.props.dispatch(fetchFollowedHashtags());
+ }
+
+ handleLoadMore = debounce(() => {
+ this.props.dispatch(expandFollowedHashtags());
+ }, 300, { leading: true });
+
+ render () {
+ const { intl, hashtags, isLoading, hasMore, multiColumn } = this.props;
+
+ const emptyMessage = ;
+
+ return (
+
+
+
+
+ {hashtags.map((hashtag) => (
+ day.get('uses')).toArray()}
+ />
+ ))}
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/followers/index.js b/app/javascript/mastodon/features/followers/index.js
deleted file mode 100644
index 277eb702f..000000000
--- a/app/javascript/mastodon/features/followers/index.js
+++ /dev/null
@@ -1,170 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { debounce } from 'lodash';
-import LoadingIndicator from '../../components/loading_indicator';
-import {
- lookupAccount,
- fetchAccount,
- fetchFollowers,
- expandFollowers,
-} from '../../actions/accounts';
-import { FormattedMessage } from 'react-intl';
-import AccountContainer from '../../containers/account_container';
-import Column from '../ui/components/column';
-import HeaderContainer from '../account_timeline/containers/header_container';
-import ColumnBackButton from '../../components/column_back_button';
-import ScrollableList from '../../components/scrollable_list';
-import MissingIndicator from 'mastodon/components/missing_indicator';
-import TimelineHint from 'mastodon/components/timeline_hint';
-import LimitedAccountHint from '../account_timeline/components/limited_account_hint';
-import { getAccountHidden } from 'mastodon/selectors';
-import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
-
-const mapStateToProps = (state, { params: { acct, id } }) => {
- const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
-
- if (!accountId) {
- return {
- isLoading: true,
- };
- }
-
- return {
- accountId,
- remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
- remoteUrl: state.getIn(['accounts', accountId, 'url']),
- isAccount: !!state.getIn(['accounts', accountId]),
- accountIds: state.getIn(['user_lists', 'followers', accountId, 'items']),
- hasMore: !!state.getIn(['user_lists', 'followers', accountId, 'next']),
- isLoading: state.getIn(['user_lists', 'followers', accountId, 'isLoading'], true),
- suspended: state.getIn(['accounts', accountId, 'suspended'], false),
- hidden: getAccountHidden(state, accountId),
- blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
- };
-};
-
-const RemoteHint = ({ url }) => (
- } />
-);
-
-RemoteHint.propTypes = {
- url: PropTypes.string.isRequired,
-};
-
-export default @connect(mapStateToProps)
-class Followers extends ImmutablePureComponent {
-
- static propTypes = {
- params: PropTypes.shape({
- acct: PropTypes.string,
- id: PropTypes.string,
- }).isRequired,
- accountId: PropTypes.string,
- dispatch: PropTypes.func.isRequired,
- accountIds: ImmutablePropTypes.list,
- hasMore: PropTypes.bool,
- isLoading: PropTypes.bool,
- blockedBy: PropTypes.bool,
- isAccount: PropTypes.bool,
- suspended: PropTypes.bool,
- hidden: PropTypes.bool,
- remote: PropTypes.bool,
- remoteUrl: PropTypes.string,
- multiColumn: PropTypes.bool,
- };
-
- _load () {
- const { accountId, isAccount, dispatch } = this.props;
-
- if (!isAccount) dispatch(fetchAccount(accountId));
- dispatch(fetchFollowers(accountId));
- }
-
- componentDidMount () {
- const { params: { acct }, accountId, dispatch } = this.props;
-
- if (accountId) {
- this._load();
- } else {
- dispatch(lookupAccount(acct));
- }
- }
-
- componentDidUpdate (prevProps) {
- const { params: { acct }, accountId, dispatch } = this.props;
-
- if (prevProps.accountId !== accountId && accountId) {
- this._load();
- } else if (prevProps.params.acct !== acct) {
- dispatch(lookupAccount(acct));
- }
- }
-
- handleLoadMore = debounce(() => {
- this.props.dispatch(expandFollowers(this.props.accountId));
- }, 300, { leading: true });
-
- render () {
- const { accountId, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl } = this.props;
-
- if (!isAccount) {
- return (
-
-
-
- );
- }
-
- if (!accountIds) {
- return (
-
-
-
- );
- }
-
- let emptyMessage;
-
- const forceEmptyState = blockedBy || suspended || hidden;
-
- if (suspended) {
- emptyMessage = ;
- } else if (hidden) {
- emptyMessage = ;
- } else if (blockedBy) {
- emptyMessage = ;
- } else if (remote && accountIds.isEmpty()) {
- emptyMessage = ;
- } else {
- emptyMessage = ;
- }
-
- const remoteMessage = remote ? : null;
-
- return (
-
-
-
- }
- alwaysPrepend
- append={remoteMessage}
- emptyMessage={emptyMessage}
- bindToDocument={!multiColumn}
- >
- {forceEmptyState ? [] : accountIds.map(id =>
- ,
- )}
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/followers/index.jsx b/app/javascript/mastodon/features/followers/index.jsx
new file mode 100644
index 000000000..277eb702f
--- /dev/null
+++ b/app/javascript/mastodon/features/followers/index.jsx
@@ -0,0 +1,170 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { debounce } from 'lodash';
+import LoadingIndicator from '../../components/loading_indicator';
+import {
+ lookupAccount,
+ fetchAccount,
+ fetchFollowers,
+ expandFollowers,
+} from '../../actions/accounts';
+import { FormattedMessage } from 'react-intl';
+import AccountContainer from '../../containers/account_container';
+import Column from '../ui/components/column';
+import HeaderContainer from '../account_timeline/containers/header_container';
+import ColumnBackButton from '../../components/column_back_button';
+import ScrollableList from '../../components/scrollable_list';
+import MissingIndicator from 'mastodon/components/missing_indicator';
+import TimelineHint from 'mastodon/components/timeline_hint';
+import LimitedAccountHint from '../account_timeline/components/limited_account_hint';
+import { getAccountHidden } from 'mastodon/selectors';
+import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
+
+const mapStateToProps = (state, { params: { acct, id } }) => {
+ const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
+
+ if (!accountId) {
+ return {
+ isLoading: true,
+ };
+ }
+
+ return {
+ accountId,
+ remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
+ remoteUrl: state.getIn(['accounts', accountId, 'url']),
+ isAccount: !!state.getIn(['accounts', accountId]),
+ accountIds: state.getIn(['user_lists', 'followers', accountId, 'items']),
+ hasMore: !!state.getIn(['user_lists', 'followers', accountId, 'next']),
+ isLoading: state.getIn(['user_lists', 'followers', accountId, 'isLoading'], true),
+ suspended: state.getIn(['accounts', accountId, 'suspended'], false),
+ hidden: getAccountHidden(state, accountId),
+ blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
+ };
+};
+
+const RemoteHint = ({ url }) => (
+ } />
+);
+
+RemoteHint.propTypes = {
+ url: PropTypes.string.isRequired,
+};
+
+export default @connect(mapStateToProps)
+class Followers extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.shape({
+ acct: PropTypes.string,
+ id: PropTypes.string,
+ }).isRequired,
+ accountId: PropTypes.string,
+ dispatch: PropTypes.func.isRequired,
+ accountIds: ImmutablePropTypes.list,
+ hasMore: PropTypes.bool,
+ isLoading: PropTypes.bool,
+ blockedBy: PropTypes.bool,
+ isAccount: PropTypes.bool,
+ suspended: PropTypes.bool,
+ hidden: PropTypes.bool,
+ remote: PropTypes.bool,
+ remoteUrl: PropTypes.string,
+ multiColumn: PropTypes.bool,
+ };
+
+ _load () {
+ const { accountId, isAccount, dispatch } = this.props;
+
+ if (!isAccount) dispatch(fetchAccount(accountId));
+ dispatch(fetchFollowers(accountId));
+ }
+
+ componentDidMount () {
+ const { params: { acct }, accountId, dispatch } = this.props;
+
+ if (accountId) {
+ this._load();
+ } else {
+ dispatch(lookupAccount(acct));
+ }
+ }
+
+ componentDidUpdate (prevProps) {
+ const { params: { acct }, accountId, dispatch } = this.props;
+
+ if (prevProps.accountId !== accountId && accountId) {
+ this._load();
+ } else if (prevProps.params.acct !== acct) {
+ dispatch(lookupAccount(acct));
+ }
+ }
+
+ handleLoadMore = debounce(() => {
+ this.props.dispatch(expandFollowers(this.props.accountId));
+ }, 300, { leading: true });
+
+ render () {
+ const { accountId, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl } = this.props;
+
+ if (!isAccount) {
+ return (
+
+
+
+ );
+ }
+
+ if (!accountIds) {
+ return (
+
+
+
+ );
+ }
+
+ let emptyMessage;
+
+ const forceEmptyState = blockedBy || suspended || hidden;
+
+ if (suspended) {
+ emptyMessage = ;
+ } else if (hidden) {
+ emptyMessage = ;
+ } else if (blockedBy) {
+ emptyMessage = ;
+ } else if (remote && accountIds.isEmpty()) {
+ emptyMessage = ;
+ } else {
+ emptyMessage = ;
+ }
+
+ const remoteMessage = remote ? : null;
+
+ return (
+
+
+
+ }
+ alwaysPrepend
+ append={remoteMessage}
+ emptyMessage={emptyMessage}
+ bindToDocument={!multiColumn}
+ >
+ {forceEmptyState ? [] : accountIds.map(id =>
+ ,
+ )}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/following/index.js b/app/javascript/mastodon/features/following/index.js
deleted file mode 100644
index e23d9b35c..000000000
--- a/app/javascript/mastodon/features/following/index.js
+++ /dev/null
@@ -1,170 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { debounce } from 'lodash';
-import LoadingIndicator from '../../components/loading_indicator';
-import {
- lookupAccount,
- fetchAccount,
- fetchFollowing,
- expandFollowing,
-} from '../../actions/accounts';
-import { FormattedMessage } from 'react-intl';
-import AccountContainer from '../../containers/account_container';
-import Column from '../ui/components/column';
-import HeaderContainer from '../account_timeline/containers/header_container';
-import ColumnBackButton from '../../components/column_back_button';
-import ScrollableList from '../../components/scrollable_list';
-import MissingIndicator from 'mastodon/components/missing_indicator';
-import TimelineHint from 'mastodon/components/timeline_hint';
-import LimitedAccountHint from '../account_timeline/components/limited_account_hint';
-import { getAccountHidden } from 'mastodon/selectors';
-import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
-
-const mapStateToProps = (state, { params: { acct, id } }) => {
- const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
-
- if (!accountId) {
- return {
- isLoading: true,
- };
- }
-
- return {
- accountId,
- remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
- remoteUrl: state.getIn(['accounts', accountId, 'url']),
- isAccount: !!state.getIn(['accounts', accountId]),
- accountIds: state.getIn(['user_lists', 'following', accountId, 'items']),
- hasMore: !!state.getIn(['user_lists', 'following', accountId, 'next']),
- isLoading: state.getIn(['user_lists', 'following', accountId, 'isLoading'], true),
- suspended: state.getIn(['accounts', accountId, 'suspended'], false),
- hidden: getAccountHidden(state, accountId),
- blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
- };
-};
-
-const RemoteHint = ({ url }) => (
- } />
-);
-
-RemoteHint.propTypes = {
- url: PropTypes.string.isRequired,
-};
-
-export default @connect(mapStateToProps)
-class Following extends ImmutablePureComponent {
-
- static propTypes = {
- params: PropTypes.shape({
- acct: PropTypes.string,
- id: PropTypes.string,
- }).isRequired,
- accountId: PropTypes.string,
- dispatch: PropTypes.func.isRequired,
- accountIds: ImmutablePropTypes.list,
- hasMore: PropTypes.bool,
- isLoading: PropTypes.bool,
- blockedBy: PropTypes.bool,
- isAccount: PropTypes.bool,
- suspended: PropTypes.bool,
- hidden: PropTypes.bool,
- remote: PropTypes.bool,
- remoteUrl: PropTypes.string,
- multiColumn: PropTypes.bool,
- };
-
- _load () {
- const { accountId, isAccount, dispatch } = this.props;
-
- if (!isAccount) dispatch(fetchAccount(accountId));
- dispatch(fetchFollowing(accountId));
- }
-
- componentDidMount () {
- const { params: { acct }, accountId, dispatch } = this.props;
-
- if (accountId) {
- this._load();
- } else {
- dispatch(lookupAccount(acct));
- }
- }
-
- componentDidUpdate (prevProps) {
- const { params: { acct }, accountId, dispatch } = this.props;
-
- if (prevProps.accountId !== accountId && accountId) {
- this._load();
- } else if (prevProps.params.acct !== acct) {
- dispatch(lookupAccount(acct));
- }
- }
-
- handleLoadMore = debounce(() => {
- this.props.dispatch(expandFollowing(this.props.accountId));
- }, 300, { leading: true });
-
- render () {
- const { accountId, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl } = this.props;
-
- if (!isAccount) {
- return (
-
-
-
- );
- }
-
- if (!accountIds) {
- return (
-
-
-
- );
- }
-
- let emptyMessage;
-
- const forceEmptyState = blockedBy || suspended || hidden;
-
- if (suspended) {
- emptyMessage = ;
- } else if (hidden) {
- emptyMessage = ;
- } else if (blockedBy) {
- emptyMessage = ;
- } else if (remote && accountIds.isEmpty()) {
- emptyMessage = ;
- } else {
- emptyMessage = ;
- }
-
- const remoteMessage = remote ? : null;
-
- return (
-
-
-
- }
- alwaysPrepend
- append={remoteMessage}
- emptyMessage={emptyMessage}
- bindToDocument={!multiColumn}
- >
- {forceEmptyState ? [] : accountIds.map(id =>
- ,
- )}
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/following/index.jsx b/app/javascript/mastodon/features/following/index.jsx
new file mode 100644
index 000000000..e23d9b35c
--- /dev/null
+++ b/app/javascript/mastodon/features/following/index.jsx
@@ -0,0 +1,170 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { debounce } from 'lodash';
+import LoadingIndicator from '../../components/loading_indicator';
+import {
+ lookupAccount,
+ fetchAccount,
+ fetchFollowing,
+ expandFollowing,
+} from '../../actions/accounts';
+import { FormattedMessage } from 'react-intl';
+import AccountContainer from '../../containers/account_container';
+import Column from '../ui/components/column';
+import HeaderContainer from '../account_timeline/containers/header_container';
+import ColumnBackButton from '../../components/column_back_button';
+import ScrollableList from '../../components/scrollable_list';
+import MissingIndicator from 'mastodon/components/missing_indicator';
+import TimelineHint from 'mastodon/components/timeline_hint';
+import LimitedAccountHint from '../account_timeline/components/limited_account_hint';
+import { getAccountHidden } from 'mastodon/selectors';
+import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
+
+const mapStateToProps = (state, { params: { acct, id } }) => {
+ const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
+
+ if (!accountId) {
+ return {
+ isLoading: true,
+ };
+ }
+
+ return {
+ accountId,
+ remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
+ remoteUrl: state.getIn(['accounts', accountId, 'url']),
+ isAccount: !!state.getIn(['accounts', accountId]),
+ accountIds: state.getIn(['user_lists', 'following', accountId, 'items']),
+ hasMore: !!state.getIn(['user_lists', 'following', accountId, 'next']),
+ isLoading: state.getIn(['user_lists', 'following', accountId, 'isLoading'], true),
+ suspended: state.getIn(['accounts', accountId, 'suspended'], false),
+ hidden: getAccountHidden(state, accountId),
+ blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
+ };
+};
+
+const RemoteHint = ({ url }) => (
+ } />
+);
+
+RemoteHint.propTypes = {
+ url: PropTypes.string.isRequired,
+};
+
+export default @connect(mapStateToProps)
+class Following extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.shape({
+ acct: PropTypes.string,
+ id: PropTypes.string,
+ }).isRequired,
+ accountId: PropTypes.string,
+ dispatch: PropTypes.func.isRequired,
+ accountIds: ImmutablePropTypes.list,
+ hasMore: PropTypes.bool,
+ isLoading: PropTypes.bool,
+ blockedBy: PropTypes.bool,
+ isAccount: PropTypes.bool,
+ suspended: PropTypes.bool,
+ hidden: PropTypes.bool,
+ remote: PropTypes.bool,
+ remoteUrl: PropTypes.string,
+ multiColumn: PropTypes.bool,
+ };
+
+ _load () {
+ const { accountId, isAccount, dispatch } = this.props;
+
+ if (!isAccount) dispatch(fetchAccount(accountId));
+ dispatch(fetchFollowing(accountId));
+ }
+
+ componentDidMount () {
+ const { params: { acct }, accountId, dispatch } = this.props;
+
+ if (accountId) {
+ this._load();
+ } else {
+ dispatch(lookupAccount(acct));
+ }
+ }
+
+ componentDidUpdate (prevProps) {
+ const { params: { acct }, accountId, dispatch } = this.props;
+
+ if (prevProps.accountId !== accountId && accountId) {
+ this._load();
+ } else if (prevProps.params.acct !== acct) {
+ dispatch(lookupAccount(acct));
+ }
+ }
+
+ handleLoadMore = debounce(() => {
+ this.props.dispatch(expandFollowing(this.props.accountId));
+ }, 300, { leading: true });
+
+ render () {
+ const { accountId, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl } = this.props;
+
+ if (!isAccount) {
+ return (
+
+
+
+ );
+ }
+
+ if (!accountIds) {
+ return (
+
+
+
+ );
+ }
+
+ let emptyMessage;
+
+ const forceEmptyState = blockedBy || suspended || hidden;
+
+ if (suspended) {
+ emptyMessage = ;
+ } else if (hidden) {
+ emptyMessage = ;
+ } else if (blockedBy) {
+ emptyMessage = ;
+ } else if (remote && accountIds.isEmpty()) {
+ emptyMessage = ;
+ } else {
+ emptyMessage = ;
+ }
+
+ const remoteMessage = remote ? : null;
+
+ return (
+
+
+
+ }
+ alwaysPrepend
+ append={remoteMessage}
+ emptyMessage={emptyMessage}
+ bindToDocument={!multiColumn}
+ >
+ {forceEmptyState ? [] : accountIds.map(id =>
+ ,
+ )}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/generic_not_found/index.js b/app/javascript/mastodon/features/generic_not_found/index.js
deleted file mode 100644
index 41cd61a5f..000000000
--- a/app/javascript/mastodon/features/generic_not_found/index.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import React from 'react';
-import Column from '../ui/components/column';
-import MissingIndicator from '../../components/missing_indicator';
-
-const GenericNotFound = () => (
-
-
-
-);
-
-export default GenericNotFound;
diff --git a/app/javascript/mastodon/features/generic_not_found/index.jsx b/app/javascript/mastodon/features/generic_not_found/index.jsx
new file mode 100644
index 000000000..41cd61a5f
--- /dev/null
+++ b/app/javascript/mastodon/features/generic_not_found/index.jsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import Column from '../ui/components/column';
+import MissingIndicator from '../../components/missing_indicator';
+
+const GenericNotFound = () => (
+
+
+
+);
+
+export default GenericNotFound;
diff --git a/app/javascript/mastodon/features/getting_started/components/announcements.js b/app/javascript/mastodon/features/getting_started/components/announcements.js
deleted file mode 100644
index 0cae0bd1f..000000000
--- a/app/javascript/mastodon/features/getting_started/components/announcements.js
+++ /dev/null
@@ -1,449 +0,0 @@
-import React from 'react';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import ReactSwipeableViews from 'react-swipeable-views';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import IconButton from 'mastodon/components/icon_button';
-import Icon from 'mastodon/components/icon';
-import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl';
-import { autoPlayGif, reduceMotion, disableSwiping, mascot } from 'mastodon/initial_state';
-import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg';
-import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light';
-import classNames from 'classnames';
-import EmojiPickerDropdown from 'mastodon/features/compose/containers/emoji_picker_dropdown_container';
-import AnimatedNumber from 'mastodon/components/animated_number';
-import TransitionMotion from 'react-motion/lib/TransitionMotion';
-import spring from 'react-motion/lib/spring';
-import { assetHost } from 'mastodon/utils/config';
-
-const messages = defineMessages({
- close: { id: 'lightbox.close', defaultMessage: 'Close' },
- previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
- next: { id: 'lightbox.next', defaultMessage: 'Next' },
-});
-
-class Content extends ImmutablePureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- };
-
- static propTypes = {
- announcement: ImmutablePropTypes.map.isRequired,
- };
-
- setRef = c => {
- this.node = c;
- };
-
- componentDidMount () {
- this._updateLinks();
- }
-
- componentDidUpdate () {
- this._updateLinks();
- }
-
- _updateLinks () {
- const node = this.node;
-
- if (!node) {
- return;
- }
-
- const links = node.querySelectorAll('a');
-
- for (var i = 0; i < links.length; ++i) {
- let link = links[i];
-
- if (link.classList.contains('status-link')) {
- continue;
- }
-
- link.classList.add('status-link');
-
- let mention = this.props.announcement.get('mentions').find(item => link.href === item.get('url'));
-
- if (mention) {
- link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
- link.setAttribute('title', mention.get('acct'));
- } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
- link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
- } else {
- let status = this.props.announcement.get('statuses').find(item => link.href === item.get('url'));
- if (status) {
- link.addEventListener('click', this.onStatusClick.bind(this, status), false);
- }
- link.setAttribute('title', link.href);
- link.classList.add('unhandled-link');
- }
-
- link.setAttribute('target', '_blank');
- link.setAttribute('rel', 'noopener noreferrer');
- }
- }
-
- onMentionClick = (mention, e) => {
- if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
- e.preventDefault();
- this.context.router.history.push(`/@${mention.get('acct')}`);
- }
- };
-
- onHashtagClick = (hashtag, e) => {
- hashtag = hashtag.replace(/^#/, '');
-
- if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
- e.preventDefault();
- this.context.router.history.push(`/tags/${hashtag}`);
- }
- };
-
- onStatusClick = (status, e) => {
- if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
- e.preventDefault();
- this.context.router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
- }
- };
-
- handleMouseEnter = ({ currentTarget }) => {
- if (autoPlayGif) {
- return;
- }
-
- const emojis = currentTarget.querySelectorAll('.custom-emoji');
-
- for (var i = 0; i < emojis.length; i++) {
- let emoji = emojis[i];
- emoji.src = emoji.getAttribute('data-original');
- }
- };
-
- handleMouseLeave = ({ currentTarget }) => {
- if (autoPlayGif) {
- return;
- }
-
- const emojis = currentTarget.querySelectorAll('.custom-emoji');
-
- for (var i = 0; i < emojis.length; i++) {
- let emoji = emojis[i];
- emoji.src = emoji.getAttribute('data-static');
- }
- };
-
- render () {
- const { announcement } = this.props;
-
- return (
-
- );
- }
-
-}
-
-class Emoji extends React.PureComponent {
-
- static propTypes = {
- emoji: PropTypes.string.isRequired,
- emojiMap: ImmutablePropTypes.map.isRequired,
- hovered: PropTypes.bool.isRequired,
- };
-
- render () {
- const { emoji, emojiMap, hovered } = this.props;
-
- if (unicodeMapping[emoji]) {
- const { filename, shortCode } = unicodeMapping[this.props.emoji];
- const title = shortCode ? `:${shortCode}:` : '';
-
- return (
-
- );
- } else if (emojiMap.get(emoji)) {
- const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
- const shortCode = `:${emoji}:`;
-
- return (
-
- );
- } else {
- return null;
- }
- }
-
-}
-
-class Reaction extends ImmutablePureComponent {
-
- static propTypes = {
- announcementId: PropTypes.string.isRequired,
- reaction: ImmutablePropTypes.map.isRequired,
- addReaction: PropTypes.func.isRequired,
- removeReaction: PropTypes.func.isRequired,
- emojiMap: ImmutablePropTypes.map.isRequired,
- style: PropTypes.object,
- };
-
- state = {
- hovered: false,
- };
-
- handleClick = () => {
- const { reaction, announcementId, addReaction, removeReaction } = this.props;
-
- if (reaction.get('me')) {
- removeReaction(announcementId, reaction.get('name'));
- } else {
- addReaction(announcementId, reaction.get('name'));
- }
- };
-
- handleMouseEnter = () => this.setState({ hovered: true });
-
- handleMouseLeave = () => this.setState({ hovered: false });
-
- render () {
- const { reaction } = this.props;
-
- let shortCode = reaction.get('name');
-
- if (unicodeMapping[shortCode]) {
- shortCode = unicodeMapping[shortCode].shortCode;
- }
-
- return (
-
-
-
-
- );
- }
-
-}
-
-class ReactionsBar extends ImmutablePureComponent {
-
- static propTypes = {
- announcementId: PropTypes.string.isRequired,
- reactions: ImmutablePropTypes.list.isRequired,
- addReaction: PropTypes.func.isRequired,
- removeReaction: PropTypes.func.isRequired,
- emojiMap: ImmutablePropTypes.map.isRequired,
- };
-
- handleEmojiPick = data => {
- const { addReaction, announcementId } = this.props;
- addReaction(announcementId, data.native.replace(/:/g, ''));
- };
-
- willEnter () {
- return { scale: reduceMotion ? 1 : 0 };
- }
-
- willLeave () {
- return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) };
- }
-
- render () {
- const { reactions } = this.props;
- const visibleReactions = reactions.filter(x => x.get('count') > 0);
-
- const styles = visibleReactions.map(reaction => ({
- key: reaction.get('name'),
- data: reaction,
- style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
- })).toArray();
-
- return (
-
- {items => (
-
- {items.map(({ key, data, style }) => (
-
- ))}
-
- {visibleReactions.size < 8 && } />}
-
- )}
-
- );
- }
-
-}
-
-class Announcement extends ImmutablePureComponent {
-
- static propTypes = {
- announcement: ImmutablePropTypes.map.isRequired,
- emojiMap: ImmutablePropTypes.map.isRequired,
- addReaction: PropTypes.func.isRequired,
- removeReaction: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- selected: PropTypes.bool,
- };
-
- state = {
- unread: !this.props.announcement.get('read'),
- };
-
- componentDidUpdate () {
- const { selected, announcement } = this.props;
- if (!selected && this.state.unread !== !announcement.get('read')) {
- this.setState({ unread: !announcement.get('read') });
- }
- }
-
- render () {
- const { announcement } = this.props;
- const { unread } = this.state;
- const startsAt = announcement.get('starts_at') && new Date(announcement.get('starts_at'));
- const endsAt = announcement.get('ends_at') && new Date(announcement.get('ends_at'));
- const now = new Date();
- const hasTimeRange = startsAt && endsAt;
- const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear();
- const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear();
- const skipTime = announcement.get('all_day');
-
- return (
-
-
-
- {hasTimeRange && · - }
-
-
-
-
-
-
- {unread && }
-
- );
- }
-
-}
-
-export default @injectIntl
-class Announcements extends ImmutablePureComponent {
-
- static propTypes = {
- announcements: ImmutablePropTypes.list,
- emojiMap: ImmutablePropTypes.map.isRequired,
- dismissAnnouncement: PropTypes.func.isRequired,
- addReaction: PropTypes.func.isRequired,
- removeReaction: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- state = {
- index: 0,
- };
-
- static getDerivedStateFromProps(props, state) {
- if (props.announcements.size > 0 && state.index >= props.announcements.size) {
- return { index: props.announcements.size - 1 };
- } else {
- return null;
- }
- }
-
- componentDidMount () {
- this._markAnnouncementAsRead();
- }
-
- componentDidUpdate () {
- this._markAnnouncementAsRead();
- }
-
- _markAnnouncementAsRead () {
- const { dismissAnnouncement, announcements } = this.props;
- const { index } = this.state;
- const announcement = announcements.get(announcements.size - 1 - index);
- if (!announcement.get('read')) dismissAnnouncement(announcement.get('id'));
- }
-
- handleChangeIndex = index => {
- this.setState({ index: index % this.props.announcements.size });
- };
-
- handleNextClick = () => {
- this.setState({ index: (this.state.index + 1) % this.props.announcements.size });
- };
-
- handlePrevClick = () => {
- this.setState({ index: (this.props.announcements.size + this.state.index - 1) % this.props.announcements.size });
- };
-
- render () {
- const { announcements, intl } = this.props;
- const { index } = this.state;
-
- if (announcements.isEmpty()) {
- return null;
- }
-
- return (
-
-
-
-
-
- {announcements.map((announcement, idx) => (
-
- )).reverse()}
-
-
- {announcements.size > 1 && (
-
-
- {index + 1} / {announcements.size}
-
-
- )}
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/getting_started/components/announcements.jsx b/app/javascript/mastodon/features/getting_started/components/announcements.jsx
new file mode 100644
index 000000000..0cae0bd1f
--- /dev/null
+++ b/app/javascript/mastodon/features/getting_started/components/announcements.jsx
@@ -0,0 +1,449 @@
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ReactSwipeableViews from 'react-swipeable-views';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from 'mastodon/components/icon_button';
+import Icon from 'mastodon/components/icon';
+import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl';
+import { autoPlayGif, reduceMotion, disableSwiping, mascot } from 'mastodon/initial_state';
+import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg';
+import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light';
+import classNames from 'classnames';
+import EmojiPickerDropdown from 'mastodon/features/compose/containers/emoji_picker_dropdown_container';
+import AnimatedNumber from 'mastodon/components/animated_number';
+import TransitionMotion from 'react-motion/lib/TransitionMotion';
+import spring from 'react-motion/lib/spring';
+import { assetHost } from 'mastodon/utils/config';
+
+const messages = defineMessages({
+ close: { id: 'lightbox.close', defaultMessage: 'Close' },
+ previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
+ next: { id: 'lightbox.next', defaultMessage: 'Next' },
+});
+
+class Content extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ announcement: ImmutablePropTypes.map.isRequired,
+ };
+
+ setRef = c => {
+ this.node = c;
+ };
+
+ componentDidMount () {
+ this._updateLinks();
+ }
+
+ componentDidUpdate () {
+ this._updateLinks();
+ }
+
+ _updateLinks () {
+ const node = this.node;
+
+ if (!node) {
+ return;
+ }
+
+ const links = node.querySelectorAll('a');
+
+ for (var i = 0; i < links.length; ++i) {
+ let link = links[i];
+
+ if (link.classList.contains('status-link')) {
+ continue;
+ }
+
+ link.classList.add('status-link');
+
+ let mention = this.props.announcement.get('mentions').find(item => link.href === item.get('url'));
+
+ if (mention) {
+ link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
+ link.setAttribute('title', mention.get('acct'));
+ } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
+ link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
+ } else {
+ let status = this.props.announcement.get('statuses').find(item => link.href === item.get('url'));
+ if (status) {
+ link.addEventListener('click', this.onStatusClick.bind(this, status), false);
+ }
+ link.setAttribute('title', link.href);
+ link.classList.add('unhandled-link');
+ }
+
+ link.setAttribute('target', '_blank');
+ link.setAttribute('rel', 'noopener noreferrer');
+ }
+ }
+
+ onMentionClick = (mention, e) => {
+ if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ this.context.router.history.push(`/@${mention.get('acct')}`);
+ }
+ };
+
+ onHashtagClick = (hashtag, e) => {
+ hashtag = hashtag.replace(/^#/, '');
+
+ if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ this.context.router.history.push(`/tags/${hashtag}`);
+ }
+ };
+
+ onStatusClick = (status, e) => {
+ if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ this.context.router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
+ }
+ };
+
+ handleMouseEnter = ({ currentTarget }) => {
+ if (autoPlayGif) {
+ return;
+ }
+
+ const emojis = currentTarget.querySelectorAll('.custom-emoji');
+
+ for (var i = 0; i < emojis.length; i++) {
+ let emoji = emojis[i];
+ emoji.src = emoji.getAttribute('data-original');
+ }
+ };
+
+ handleMouseLeave = ({ currentTarget }) => {
+ if (autoPlayGif) {
+ return;
+ }
+
+ const emojis = currentTarget.querySelectorAll('.custom-emoji');
+
+ for (var i = 0; i < emojis.length; i++) {
+ let emoji = emojis[i];
+ emoji.src = emoji.getAttribute('data-static');
+ }
+ };
+
+ render () {
+ const { announcement } = this.props;
+
+ return (
+
+ );
+ }
+
+}
+
+class Emoji extends React.PureComponent {
+
+ static propTypes = {
+ emoji: PropTypes.string.isRequired,
+ emojiMap: ImmutablePropTypes.map.isRequired,
+ hovered: PropTypes.bool.isRequired,
+ };
+
+ render () {
+ const { emoji, emojiMap, hovered } = this.props;
+
+ if (unicodeMapping[emoji]) {
+ const { filename, shortCode } = unicodeMapping[this.props.emoji];
+ const title = shortCode ? `:${shortCode}:` : '';
+
+ return (
+
+ );
+ } else if (emojiMap.get(emoji)) {
+ const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
+ const shortCode = `:${emoji}:`;
+
+ return (
+
+ );
+ } else {
+ return null;
+ }
+ }
+
+}
+
+class Reaction extends ImmutablePureComponent {
+
+ static propTypes = {
+ announcementId: PropTypes.string.isRequired,
+ reaction: ImmutablePropTypes.map.isRequired,
+ addReaction: PropTypes.func.isRequired,
+ removeReaction: PropTypes.func.isRequired,
+ emojiMap: ImmutablePropTypes.map.isRequired,
+ style: PropTypes.object,
+ };
+
+ state = {
+ hovered: false,
+ };
+
+ handleClick = () => {
+ const { reaction, announcementId, addReaction, removeReaction } = this.props;
+
+ if (reaction.get('me')) {
+ removeReaction(announcementId, reaction.get('name'));
+ } else {
+ addReaction(announcementId, reaction.get('name'));
+ }
+ };
+
+ handleMouseEnter = () => this.setState({ hovered: true });
+
+ handleMouseLeave = () => this.setState({ hovered: false });
+
+ render () {
+ const { reaction } = this.props;
+
+ let shortCode = reaction.get('name');
+
+ if (unicodeMapping[shortCode]) {
+ shortCode = unicodeMapping[shortCode].shortCode;
+ }
+
+ return (
+
+
+
+
+ );
+ }
+
+}
+
+class ReactionsBar extends ImmutablePureComponent {
+
+ static propTypes = {
+ announcementId: PropTypes.string.isRequired,
+ reactions: ImmutablePropTypes.list.isRequired,
+ addReaction: PropTypes.func.isRequired,
+ removeReaction: PropTypes.func.isRequired,
+ emojiMap: ImmutablePropTypes.map.isRequired,
+ };
+
+ handleEmojiPick = data => {
+ const { addReaction, announcementId } = this.props;
+ addReaction(announcementId, data.native.replace(/:/g, ''));
+ };
+
+ willEnter () {
+ return { scale: reduceMotion ? 1 : 0 };
+ }
+
+ willLeave () {
+ return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) };
+ }
+
+ render () {
+ const { reactions } = this.props;
+ const visibleReactions = reactions.filter(x => x.get('count') > 0);
+
+ const styles = visibleReactions.map(reaction => ({
+ key: reaction.get('name'),
+ data: reaction,
+ style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
+ })).toArray();
+
+ return (
+
+ {items => (
+
+ {items.map(({ key, data, style }) => (
+
+ ))}
+
+ {visibleReactions.size < 8 && } />}
+
+ )}
+
+ );
+ }
+
+}
+
+class Announcement extends ImmutablePureComponent {
+
+ static propTypes = {
+ announcement: ImmutablePropTypes.map.isRequired,
+ emojiMap: ImmutablePropTypes.map.isRequired,
+ addReaction: PropTypes.func.isRequired,
+ removeReaction: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ selected: PropTypes.bool,
+ };
+
+ state = {
+ unread: !this.props.announcement.get('read'),
+ };
+
+ componentDidUpdate () {
+ const { selected, announcement } = this.props;
+ if (!selected && this.state.unread !== !announcement.get('read')) {
+ this.setState({ unread: !announcement.get('read') });
+ }
+ }
+
+ render () {
+ const { announcement } = this.props;
+ const { unread } = this.state;
+ const startsAt = announcement.get('starts_at') && new Date(announcement.get('starts_at'));
+ const endsAt = announcement.get('ends_at') && new Date(announcement.get('ends_at'));
+ const now = new Date();
+ const hasTimeRange = startsAt && endsAt;
+ const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear();
+ const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear();
+ const skipTime = announcement.get('all_day');
+
+ return (
+
+
+
+ {hasTimeRange && · - }
+
+
+
+
+
+
+ {unread && }
+
+ );
+ }
+
+}
+
+export default @injectIntl
+class Announcements extends ImmutablePureComponent {
+
+ static propTypes = {
+ announcements: ImmutablePropTypes.list,
+ emojiMap: ImmutablePropTypes.map.isRequired,
+ dismissAnnouncement: PropTypes.func.isRequired,
+ addReaction: PropTypes.func.isRequired,
+ removeReaction: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ index: 0,
+ };
+
+ static getDerivedStateFromProps(props, state) {
+ if (props.announcements.size > 0 && state.index >= props.announcements.size) {
+ return { index: props.announcements.size - 1 };
+ } else {
+ return null;
+ }
+ }
+
+ componentDidMount () {
+ this._markAnnouncementAsRead();
+ }
+
+ componentDidUpdate () {
+ this._markAnnouncementAsRead();
+ }
+
+ _markAnnouncementAsRead () {
+ const { dismissAnnouncement, announcements } = this.props;
+ const { index } = this.state;
+ const announcement = announcements.get(announcements.size - 1 - index);
+ if (!announcement.get('read')) dismissAnnouncement(announcement.get('id'));
+ }
+
+ handleChangeIndex = index => {
+ this.setState({ index: index % this.props.announcements.size });
+ };
+
+ handleNextClick = () => {
+ this.setState({ index: (this.state.index + 1) % this.props.announcements.size });
+ };
+
+ handlePrevClick = () => {
+ this.setState({ index: (this.props.announcements.size + this.state.index - 1) % this.props.announcements.size });
+ };
+
+ render () {
+ const { announcements, intl } = this.props;
+ const { index } = this.state;
+
+ if (announcements.isEmpty()) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ {announcements.map((announcement, idx) => (
+
+ )).reverse()}
+
+
+ {announcements.size > 1 && (
+
+
+ {index + 1} / {announcements.size}
+
+
+ )}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/getting_started/components/trends.js b/app/javascript/mastodon/features/getting_started/components/trends.js
deleted file mode 100644
index 8dcdb4f61..000000000
--- a/app/javascript/mastodon/features/getting_started/components/trends.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import React from 'react';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
-import { FormattedMessage } from 'react-intl';
-import { Link } from 'react-router-dom';
-
-export default class Trends extends ImmutablePureComponent {
-
- static defaultProps = {
- loading: false,
- };
-
- static propTypes = {
- trends: ImmutablePropTypes.list,
- fetchTrends: PropTypes.func.isRequired,
- };
-
- componentDidMount () {
- this.props.fetchTrends();
- this.refreshInterval = setInterval(() => this.props.fetchTrends(), 900 * 1000);
- }
-
- componentWillUnmount () {
- if (this.refreshInterval) {
- clearInterval(this.refreshInterval);
- }
- }
-
- render () {
- const { trends } = this.props;
-
- if (!trends || trends.isEmpty()) {
- return null;
- }
-
- return (
-
-
-
-
-
-
-
- {trends.take(3).map(hashtag => )}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/getting_started/components/trends.jsx b/app/javascript/mastodon/features/getting_started/components/trends.jsx
new file mode 100644
index 000000000..8dcdb4f61
--- /dev/null
+++ b/app/javascript/mastodon/features/getting_started/components/trends.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
+import { FormattedMessage } from 'react-intl';
+import { Link } from 'react-router-dom';
+
+export default class Trends extends ImmutablePureComponent {
+
+ static defaultProps = {
+ loading: false,
+ };
+
+ static propTypes = {
+ trends: ImmutablePropTypes.list,
+ fetchTrends: PropTypes.func.isRequired,
+ };
+
+ componentDidMount () {
+ this.props.fetchTrends();
+ this.refreshInterval = setInterval(() => this.props.fetchTrends(), 900 * 1000);
+ }
+
+ componentWillUnmount () {
+ if (this.refreshInterval) {
+ clearInterval(this.refreshInterval);
+ }
+ }
+
+ render () {
+ const { trends } = this.props;
+
+ if (!trends || trends.isEmpty()) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+ {trends.take(3).map(hashtag => )}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
deleted file mode 100644
index fc91070d1..000000000
--- a/app/javascript/mastodon/features/getting_started/index.js
+++ /dev/null
@@ -1,155 +0,0 @@
-import React from 'react';
-import Column from 'mastodon/components/column';
-import ColumnHeader from 'mastodon/components/column_header';
-import ColumnLink from '../ui/components/column_link';
-import ColumnSubheading from '../ui/components/column_subheading';
-import { defineMessages, injectIntl } from 'react-intl';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { me, showTrends } from '../../initial_state';
-import { fetchFollowRequests } from 'mastodon/actions/accounts';
-import { List as ImmutableList } from 'immutable';
-import NavigationContainer from '../compose/containers/navigation_container';
-import LinkFooter from 'mastodon/features/ui/components/link_footer';
-import TrendsContainer from './containers/trends_container';
-import { Helmet } from 'react-helmet';
-
-const messages = defineMessages({
- 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' },
- settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
- community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
- explore: { id: 'navigation_bar.explore', defaultMessage: 'Explore' },
- direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' },
- bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
- preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
- follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
- favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
- blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
- domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
- mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
- pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
- lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
- discover: { id: 'navigation_bar.discover', defaultMessage: 'Discover' },
- personal: { id: 'navigation_bar.personal', defaultMessage: 'Personal' },
- security: { id: 'navigation_bar.security', defaultMessage: 'Security' },
- menu: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
-});
-
-const mapStateToProps = state => ({
- myAccount: state.getIn(['accounts', me]),
- unreadFollowRequests: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
-});
-
-const mapDispatchToProps = dispatch => ({
- fetchFollowRequests: () => dispatch(fetchFollowRequests()),
-});
-
-const badgeDisplay = (number, limit) => {
- if (number === 0) {
- return undefined;
- } else if (limit && number >= limit) {
- return `${limit}+`;
- } else {
- return number;
- }
-};
-
-export default @connect(mapStateToProps, mapDispatchToProps)
-@injectIntl
-class GettingStarted extends ImmutablePureComponent {
-
- static contextTypes = {
- router: PropTypes.object.isRequired,
- identity: PropTypes.object,
- };
-
- static propTypes = {
- intl: PropTypes.object.isRequired,
- myAccount: ImmutablePropTypes.map,
- multiColumn: PropTypes.bool,
- fetchFollowRequests: PropTypes.func.isRequired,
- unreadFollowRequests: PropTypes.number,
- unreadNotifications: PropTypes.number,
- };
-
- componentDidMount () {
- const { fetchFollowRequests } = this.props;
- const { signedIn } = this.context.identity;
-
- if (!signedIn) {
- return;
- }
-
- fetchFollowRequests();
- }
-
- render () {
- const { intl, myAccount, multiColumn, unreadFollowRequests } = this.props;
- const { signedIn } = this.context.identity;
-
- const navItems = [];
-
- navItems.push(
- ,
- );
-
- if (showTrends) {
- navItems.push(
- ,
- );
- }
-
- navItems.push(
- ,
- ,
- );
-
- if (signedIn) {
- navItems.push(
- ,
- ,
- ,
- ,
- ,
- ,
- );
-
- if (myAccount.get('locked') || unreadFollowRequests > 0) {
- navItems.push( );
- }
-
- navItems.push(
- ,
- ,
- );
- }
-
- return (
-
- {(signedIn && !multiColumn) ? : }
-
-
-
- {navItems}
-
-
- {!multiColumn &&
}
-
-
-
-
- {(multiColumn && showTrends) && }
-
-
- {intl.formatMessage(messages.menu)}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/getting_started/index.jsx b/app/javascript/mastodon/features/getting_started/index.jsx
new file mode 100644
index 000000000..fc91070d1
--- /dev/null
+++ b/app/javascript/mastodon/features/getting_started/index.jsx
@@ -0,0 +1,155 @@
+import React from 'react';
+import Column from 'mastodon/components/column';
+import ColumnHeader from 'mastodon/components/column_header';
+import ColumnLink from '../ui/components/column_link';
+import ColumnSubheading from '../ui/components/column_subheading';
+import { defineMessages, injectIntl } from 'react-intl';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { me, showTrends } from '../../initial_state';
+import { fetchFollowRequests } from 'mastodon/actions/accounts';
+import { List as ImmutableList } from 'immutable';
+import NavigationContainer from '../compose/containers/navigation_container';
+import LinkFooter from 'mastodon/features/ui/components/link_footer';
+import TrendsContainer from './containers/trends_container';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+ 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' },
+ settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
+ community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
+ explore: { id: 'navigation_bar.explore', defaultMessage: 'Explore' },
+ direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' },
+ bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
+ preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+ follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
+ favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
+ blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
+ domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
+ mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
+ pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
+ lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
+ discover: { id: 'navigation_bar.discover', defaultMessage: 'Discover' },
+ personal: { id: 'navigation_bar.personal', defaultMessage: 'Personal' },
+ security: { id: 'navigation_bar.security', defaultMessage: 'Security' },
+ menu: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
+});
+
+const mapStateToProps = state => ({
+ myAccount: state.getIn(['accounts', me]),
+ unreadFollowRequests: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
+});
+
+const mapDispatchToProps = dispatch => ({
+ fetchFollowRequests: () => dispatch(fetchFollowRequests()),
+});
+
+const badgeDisplay = (number, limit) => {
+ if (number === 0) {
+ return undefined;
+ } else if (limit && number >= limit) {
+ return `${limit}+`;
+ } else {
+ return number;
+ }
+};
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+class GettingStarted extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object.isRequired,
+ identity: PropTypes.object,
+ };
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ myAccount: ImmutablePropTypes.map,
+ multiColumn: PropTypes.bool,
+ fetchFollowRequests: PropTypes.func.isRequired,
+ unreadFollowRequests: PropTypes.number,
+ unreadNotifications: PropTypes.number,
+ };
+
+ componentDidMount () {
+ const { fetchFollowRequests } = this.props;
+ const { signedIn } = this.context.identity;
+
+ if (!signedIn) {
+ return;
+ }
+
+ fetchFollowRequests();
+ }
+
+ render () {
+ const { intl, myAccount, multiColumn, unreadFollowRequests } = this.props;
+ const { signedIn } = this.context.identity;
+
+ const navItems = [];
+
+ navItems.push(
+ ,
+ );
+
+ if (showTrends) {
+ navItems.push(
+ ,
+ );
+ }
+
+ navItems.push(
+ ,
+ ,
+ );
+
+ if (signedIn) {
+ navItems.push(
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ );
+
+ if (myAccount.get('locked') || unreadFollowRequests > 0) {
+ navItems.push( );
+ }
+
+ navItems.push(
+ ,
+ ,
+ );
+ }
+
+ return (
+
+ {(signedIn && !multiColumn) ? : }
+
+
+
+ {navItems}
+
+
+ {!multiColumn &&
}
+
+
+
+
+ {(multiColumn && showTrends) && }
+
+
+ {intl.formatMessage(messages.menu)}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js b/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js
deleted file mode 100644
index ac7863ed3..000000000
--- a/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js
+++ /dev/null
@@ -1,133 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import Toggle from 'react-toggle';
-import AsyncSelect from 'react-select/async';
-import { NonceProvider } from 'react-select';
-import SettingToggle from '../../notifications/components/setting_toggle';
-
-const messages = defineMessages({
- placeholder: { id: 'hashtag.column_settings.select.placeholder', defaultMessage: 'Enter hashtags…' },
- noOptions: { id: 'hashtag.column_settings.select.no_options_message', defaultMessage: 'No suggestions found' },
-});
-
-export default @injectIntl
-class ColumnSettings extends React.PureComponent {
-
- static propTypes = {
- settings: ImmutablePropTypes.map.isRequired,
- onChange: PropTypes.func.isRequired,
- onLoad: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- state = {
- open: this.hasTags(),
- };
-
- hasTags () {
- return ['all', 'any', 'none'].map(mode => this.tags(mode).length > 0).includes(true);
- }
-
- tags (mode) {
- let tags = this.props.settings.getIn(['tags', mode]) || [];
-
- if (tags.toJS) {
- return tags.toJS();
- } else {
- return tags;
- }
- }
-
- onSelect = mode => value => {
- const oldValue = this.tags(mode);
-
- // Prevent changes that add more than 4 tags, but allow removing
- // tags that were already added before
- if ((value.length > 4) && !(value < oldValue)) {
- return;
- }
-
- this.props.onChange(['tags', mode], value);
- };
-
- onToggle = () => {
- if (this.state.open && this.hasTags()) {
- this.props.onChange('tags', {});
- }
-
- this.setState({ open: !this.state.open });
- };
-
- noOptionsMessage = () => this.props.intl.formatMessage(messages.noOptions);
-
- modeSelect (mode) {
- return (
-
-
- {this.modeLabel(mode)}
-
-
-
-
-
-
- );
- }
-
- modeLabel (mode) {
- switch(mode) {
- case 'any':
- return ;
- case 'all':
- return ;
- case 'none':
- return ;
- default:
- return '';
- }
- }
-
- render () {
- const { settings, onChange } = this.props;
-
- return (
-
-
-
- {this.state.open && (
-
- {this.modeSelect('any')}
- {this.modeSelect('all')}
- {this.modeSelect('none')}
-
- )}
-
-
- } />
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.jsx b/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.jsx
new file mode 100644
index 000000000..ac7863ed3
--- /dev/null
+++ b/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.jsx
@@ -0,0 +1,133 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Toggle from 'react-toggle';
+import AsyncSelect from 'react-select/async';
+import { NonceProvider } from 'react-select';
+import SettingToggle from '../../notifications/components/setting_toggle';
+
+const messages = defineMessages({
+ placeholder: { id: 'hashtag.column_settings.select.placeholder', defaultMessage: 'Enter hashtags…' },
+ noOptions: { id: 'hashtag.column_settings.select.no_options_message', defaultMessage: 'No suggestions found' },
+});
+
+export default @injectIntl
+class ColumnSettings extends React.PureComponent {
+
+ static propTypes = {
+ settings: ImmutablePropTypes.map.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onLoad: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ open: this.hasTags(),
+ };
+
+ hasTags () {
+ return ['all', 'any', 'none'].map(mode => this.tags(mode).length > 0).includes(true);
+ }
+
+ tags (mode) {
+ let tags = this.props.settings.getIn(['tags', mode]) || [];
+
+ if (tags.toJS) {
+ return tags.toJS();
+ } else {
+ return tags;
+ }
+ }
+
+ onSelect = mode => value => {
+ const oldValue = this.tags(mode);
+
+ // Prevent changes that add more than 4 tags, but allow removing
+ // tags that were already added before
+ if ((value.length > 4) && !(value < oldValue)) {
+ return;
+ }
+
+ this.props.onChange(['tags', mode], value);
+ };
+
+ onToggle = () => {
+ if (this.state.open && this.hasTags()) {
+ this.props.onChange('tags', {});
+ }
+
+ this.setState({ open: !this.state.open });
+ };
+
+ noOptionsMessage = () => this.props.intl.formatMessage(messages.noOptions);
+
+ modeSelect (mode) {
+ return (
+
+
+ {this.modeLabel(mode)}
+
+
+
+
+
+
+ );
+ }
+
+ modeLabel (mode) {
+ switch(mode) {
+ case 'any':
+ return ;
+ case 'all':
+ return ;
+ case 'none':
+ return ;
+ default:
+ return '';
+ }
+ }
+
+ render () {
+ const { settings, onChange } = this.props;
+
+ return (
+
+
+
+ {this.state.open && (
+
+ {this.modeSelect('any')}
+ {this.modeSelect('all')}
+ {this.modeSelect('none')}
+
+ )}
+
+
+ } />
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js
deleted file mode 100644
index e5262d70d..000000000
--- a/app/javascript/mastodon/features/hashtag_timeline/index.js
+++ /dev/null
@@ -1,237 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import StatusListContainer from '../ui/containers/status_list_container';
-import Column from 'mastodon/components/column';
-import ColumnHeader from 'mastodon/components/column_header';
-import ColumnSettingsContainer from './containers/column_settings_container';
-import { expandHashtagTimeline, clearTimeline } from 'mastodon/actions/timelines';
-import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
-import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
-import { connectHashtagStream } from 'mastodon/actions/streaming';
-import { isEqual } from 'lodash';
-import { fetchHashtag, followHashtag, unfollowHashtag } from 'mastodon/actions/tags';
-import Icon from 'mastodon/components/icon';
-import classNames from 'classnames';
-import { Helmet } from 'react-helmet';
-
-const messages = defineMessages({
- followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' },
- unfollowHashtag: { id: 'hashtag.unfollow', defaultMessage: 'Unfollow hashtag' },
-});
-
-const mapStateToProps = (state, props) => ({
- hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}${props.params.local ? ':local' : ''}`, 'unread']) > 0,
- tag: state.getIn(['tags', props.params.id]),
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-class HashtagTimeline extends React.PureComponent {
-
- disconnects = [];
-
- static contextTypes = {
- identity: PropTypes.object,
- };
-
- static propTypes = {
- params: PropTypes.object.isRequired,
- columnId: PropTypes.string,
- dispatch: PropTypes.func.isRequired,
- hasUnread: PropTypes.bool,
- tag: ImmutablePropTypes.map,
- multiColumn: PropTypes.bool,
- intl: PropTypes.object,
- };
-
- handlePin = () => {
- const { columnId, dispatch } = this.props;
-
- if (columnId) {
- dispatch(removeColumn(columnId));
- } else {
- dispatch(addColumn('HASHTAG', { id: this.props.params.id }));
- }
- };
-
- title = () => {
- const { id } = this.props.params;
- const title = [id];
-
- if (this.additionalFor('any')) {
- title.push(' ', );
- }
-
- if (this.additionalFor('all')) {
- title.push(' ', );
- }
-
- if (this.additionalFor('none')) {
- title.push(' ', );
- }
-
- return title;
- };
-
- additionalFor = (mode) => {
- const { tags } = this.props.params;
-
- if (tags && (tags[mode] || []).length > 0) {
- return tags[mode].map(tag => tag.value).join('/');
- } else {
- return '';
- }
- };
-
- handleMove = (dir) => {
- const { columnId, dispatch } = this.props;
- dispatch(moveColumn(columnId, dir));
- };
-
- handleHeaderClick = () => {
- this.column.scrollTop();
- };
-
- _subscribe (dispatch, id, tags = {}, local) {
- const { signedIn } = this.context.identity;
-
- if (!signedIn) {
- return;
- }
-
- let any = (tags.any || []).map(tag => tag.value);
- let all = (tags.all || []).map(tag => tag.value);
- let none = (tags.none || []).map(tag => tag.value);
-
- [id, ...any].map(tag => {
- this.disconnects.push(dispatch(connectHashtagStream(id, tag, local, status => {
- let tags = status.tags.map(tag => tag.name);
-
- return all.filter(tag => tags.includes(tag)).length === all.length &&
- none.filter(tag => tags.includes(tag)).length === 0;
- })));
- });
- }
-
- _unsubscribe () {
- this.disconnects.map(disconnect => disconnect());
- this.disconnects = [];
- }
-
- _unload () {
- const { dispatch } = this.props;
- const { id, local } = this.props.params;
-
- this._unsubscribe();
- dispatch(clearTimeline(`hashtag:${id}${local ? ':local' : ''}`));
- }
-
- _load() {
- const { dispatch } = this.props;
- const { id, tags, local } = this.props.params;
-
- this._subscribe(dispatch, id, tags, local);
- dispatch(expandHashtagTimeline(id, { tags, local }));
- dispatch(fetchHashtag(id));
- }
-
- componentDidMount () {
- this._load();
- }
-
- componentDidUpdate (prevProps) {
- const { params } = this.props;
- const { id, tags, local } = prevProps.params;
-
- if (id !== params.id || !isEqual(tags, params.tags) || !isEqual(local, params.local)) {
- this._unload();
- this._load();
- }
- }
-
- componentWillUnmount () {
- this._unsubscribe();
- }
-
- setRef = c => {
- this.column = c;
- };
-
- handleLoadMore = maxId => {
- const { dispatch, params } = this.props;
- const { id, tags, local } = params;
-
- dispatch(expandHashtagTimeline(id, { maxId, tags, local }));
- };
-
- handleFollow = () => {
- const { dispatch, params, tag } = this.props;
- const { id } = params;
- const { signedIn } = this.context.identity;
-
- if (!signedIn) {
- return;
- }
-
- if (tag.get('following')) {
- dispatch(unfollowHashtag(id));
- } else {
- dispatch(followHashtag(id));
- }
- };
-
- render () {
- const { hasUnread, columnId, multiColumn, tag, intl } = this.props;
- const { id, local } = this.props.params;
- const pinned = !!columnId;
- const { signedIn } = this.context.identity;
-
- let followButton;
-
- if (tag) {
- const following = tag.get('following');
-
- followButton = (
-
-
-
- );
- }
-
- return (
-
-
- {columnId && }
-
-
- }
- bindToDocument={!multiColumn}
- />
-
-
- #{id}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.jsx b/app/javascript/mastodon/features/hashtag_timeline/index.jsx
new file mode 100644
index 000000000..e5262d70d
--- /dev/null
+++ b/app/javascript/mastodon/features/hashtag_timeline/index.jsx
@@ -0,0 +1,237 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import StatusListContainer from '../ui/containers/status_list_container';
+import Column from 'mastodon/components/column';
+import ColumnHeader from 'mastodon/components/column_header';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { expandHashtagTimeline, clearTimeline } from 'mastodon/actions/timelines';
+import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
+import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
+import { connectHashtagStream } from 'mastodon/actions/streaming';
+import { isEqual } from 'lodash';
+import { fetchHashtag, followHashtag, unfollowHashtag } from 'mastodon/actions/tags';
+import Icon from 'mastodon/components/icon';
+import classNames from 'classnames';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+ followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' },
+ unfollowHashtag: { id: 'hashtag.unfollow', defaultMessage: 'Unfollow hashtag' },
+});
+
+const mapStateToProps = (state, props) => ({
+ hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}${props.params.local ? ':local' : ''}`, 'unread']) > 0,
+ tag: state.getIn(['tags', props.params.id]),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class HashtagTimeline extends React.PureComponent {
+
+ disconnects = [];
+
+ static contextTypes = {
+ identity: PropTypes.object,
+ };
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ columnId: PropTypes.string,
+ dispatch: PropTypes.func.isRequired,
+ hasUnread: PropTypes.bool,
+ tag: ImmutablePropTypes.map,
+ multiColumn: PropTypes.bool,
+ intl: PropTypes.object,
+ };
+
+ handlePin = () => {
+ const { columnId, dispatch } = this.props;
+
+ if (columnId) {
+ dispatch(removeColumn(columnId));
+ } else {
+ dispatch(addColumn('HASHTAG', { id: this.props.params.id }));
+ }
+ };
+
+ title = () => {
+ const { id } = this.props.params;
+ const title = [id];
+
+ if (this.additionalFor('any')) {
+ title.push(' ', );
+ }
+
+ if (this.additionalFor('all')) {
+ title.push(' ', );
+ }
+
+ if (this.additionalFor('none')) {
+ title.push(' ', );
+ }
+
+ return title;
+ };
+
+ additionalFor = (mode) => {
+ const { tags } = this.props.params;
+
+ if (tags && (tags[mode] || []).length > 0) {
+ return tags[mode].map(tag => tag.value).join('/');
+ } else {
+ return '';
+ }
+ };
+
+ handleMove = (dir) => {
+ const { columnId, dispatch } = this.props;
+ dispatch(moveColumn(columnId, dir));
+ };
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ };
+
+ _subscribe (dispatch, id, tags = {}, local) {
+ const { signedIn } = this.context.identity;
+
+ if (!signedIn) {
+ return;
+ }
+
+ let any = (tags.any || []).map(tag => tag.value);
+ let all = (tags.all || []).map(tag => tag.value);
+ let none = (tags.none || []).map(tag => tag.value);
+
+ [id, ...any].map(tag => {
+ this.disconnects.push(dispatch(connectHashtagStream(id, tag, local, status => {
+ let tags = status.tags.map(tag => tag.name);
+
+ return all.filter(tag => tags.includes(tag)).length === all.length &&
+ none.filter(tag => tags.includes(tag)).length === 0;
+ })));
+ });
+ }
+
+ _unsubscribe () {
+ this.disconnects.map(disconnect => disconnect());
+ this.disconnects = [];
+ }
+
+ _unload () {
+ const { dispatch } = this.props;
+ const { id, local } = this.props.params;
+
+ this._unsubscribe();
+ dispatch(clearTimeline(`hashtag:${id}${local ? ':local' : ''}`));
+ }
+
+ _load() {
+ const { dispatch } = this.props;
+ const { id, tags, local } = this.props.params;
+
+ this._subscribe(dispatch, id, tags, local);
+ dispatch(expandHashtagTimeline(id, { tags, local }));
+ dispatch(fetchHashtag(id));
+ }
+
+ componentDidMount () {
+ this._load();
+ }
+
+ componentDidUpdate (prevProps) {
+ const { params } = this.props;
+ const { id, tags, local } = prevProps.params;
+
+ if (id !== params.id || !isEqual(tags, params.tags) || !isEqual(local, params.local)) {
+ this._unload();
+ this._load();
+ }
+ }
+
+ componentWillUnmount () {
+ this._unsubscribe();
+ }
+
+ setRef = c => {
+ this.column = c;
+ };
+
+ handleLoadMore = maxId => {
+ const { dispatch, params } = this.props;
+ const { id, tags, local } = params;
+
+ dispatch(expandHashtagTimeline(id, { maxId, tags, local }));
+ };
+
+ handleFollow = () => {
+ const { dispatch, params, tag } = this.props;
+ const { id } = params;
+ const { signedIn } = this.context.identity;
+
+ if (!signedIn) {
+ return;
+ }
+
+ if (tag.get('following')) {
+ dispatch(unfollowHashtag(id));
+ } else {
+ dispatch(followHashtag(id));
+ }
+ };
+
+ render () {
+ const { hasUnread, columnId, multiColumn, tag, intl } = this.props;
+ const { id, local } = this.props.params;
+ const pinned = !!columnId;
+ const { signedIn } = this.context.identity;
+
+ let followButton;
+
+ if (tag) {
+ const following = tag.get('following');
+
+ followButton = (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {columnId && }
+
+
+ }
+ bindToDocument={!multiColumn}
+ />
+
+
+ #{id}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/home_timeline/components/column_settings.js b/app/javascript/mastodon/features/home_timeline/components/column_settings.js
deleted file mode 100644
index 455e21881..000000000
--- a/app/javascript/mastodon/features/home_timeline/components/column_settings.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { injectIntl, FormattedMessage } from 'react-intl';
-import SettingToggle from '../../notifications/components/setting_toggle';
-
-export default @injectIntl
-class ColumnSettings extends React.PureComponent {
-
- static propTypes = {
- settings: ImmutablePropTypes.map.isRequired,
- onChange: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- render () {
- const { settings, onChange } = this.props;
-
- return (
-
-
-
-
- } />
-
-
-
- } />
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/home_timeline/components/column_settings.jsx b/app/javascript/mastodon/features/home_timeline/components/column_settings.jsx
new file mode 100644
index 000000000..455e21881
--- /dev/null
+++ b/app/javascript/mastodon/features/home_timeline/components/column_settings.jsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import SettingToggle from '../../notifications/components/setting_toggle';
+
+export default @injectIntl
+class ColumnSettings extends React.PureComponent {
+
+ static propTypes = {
+ settings: ImmutablePropTypes.map.isRequired,
+ onChange: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ render () {
+ const { settings, onChange } = this.props;
+
+ return (
+
+
+
+
+ } />
+
+
+
+ } />
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js
deleted file mode 100644
index 001de15d1..000000000
--- a/app/javascript/mastodon/features/home_timeline/index.js
+++ /dev/null
@@ -1,176 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import { expandHomeTimeline } from '../../actions/timelines';
-import PropTypes from 'prop-types';
-import StatusListContainer from '../ui/containers/status_list_container';
-import Column from '../../components/column';
-import ColumnHeader from '../../components/column_header';
-import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import ColumnSettingsContainer from './containers/column_settings_container';
-import { Link } from 'react-router-dom';
-import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/announcements';
-import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
-import classNames from 'classnames';
-import IconWithBadge from 'mastodon/components/icon_with_badge';
-import NotSignedInIndicator from 'mastodon/components/not_signed_in_indicator';
-import { Helmet } from 'react-helmet';
-
-const messages = defineMessages({
- title: { id: 'column.home', defaultMessage: 'Home' },
- show_announcements: { id: 'home.show_announcements', defaultMessage: 'Show announcements' },
- hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' },
-});
-
-const mapStateToProps = state => ({
- hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
- isPartial: state.getIn(['timelines', 'home', 'isPartial']),
- hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(),
- unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')),
- showAnnouncements: state.getIn(['announcements', 'show']),
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-class HomeTimeline extends React.PureComponent {
-
- static contextTypes = {
- identity: PropTypes.object,
- };
-
- static propTypes = {
- dispatch: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- hasUnread: PropTypes.bool,
- isPartial: PropTypes.bool,
- columnId: PropTypes.string,
- multiColumn: PropTypes.bool,
- hasAnnouncements: PropTypes.bool,
- unreadAnnouncements: PropTypes.number,
- showAnnouncements: 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 = maxId => {
- this.props.dispatch(expandHomeTimeline({ maxId }));
- };
-
- componentDidMount () {
- setTimeout(() => this.props.dispatch(fetchAnnouncements()), 700);
- this._checkIfReloadNeeded(false, this.props.isPartial);
- }
-
- componentDidUpdate (prevProps) {
- this._checkIfReloadNeeded(prevProps.isPartial, this.props.isPartial);
- }
-
- componentWillUnmount () {
- this._stopPolling();
- }
-
- _checkIfReloadNeeded (wasPartial, isPartial) {
- const { dispatch } = this.props;
-
- if (wasPartial === isPartial) {
- return;
- } else if (!wasPartial && isPartial) {
- this.polling = setInterval(() => {
- dispatch(expandHomeTimeline());
- }, 3000);
- } else if (wasPartial && !isPartial) {
- this._stopPolling();
- }
- }
-
- _stopPolling () {
- if (this.polling) {
- clearInterval(this.polling);
- this.polling = null;
- }
- }
-
- handleToggleAnnouncementsClick = (e) => {
- e.stopPropagation();
- this.props.dispatch(toggleShowAnnouncements());
- };
-
- render () {
- const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
- const pinned = !!columnId;
- const { signedIn } = this.context.identity;
-
- let announcementsButton = null;
-
- if (hasAnnouncements) {
- announcementsButton = (
-
-
-
- );
- }
-
- return (
-
- }
- >
-
-
-
- {signedIn ? (
- }} />}
- bindToDocument={!multiColumn}
- />
- ) : }
-
-
- {intl.formatMessage(messages.title)}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/home_timeline/index.jsx b/app/javascript/mastodon/features/home_timeline/index.jsx
new file mode 100644
index 000000000..001de15d1
--- /dev/null
+++ b/app/javascript/mastodon/features/home_timeline/index.jsx
@@ -0,0 +1,176 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { expandHomeTimeline } from '../../actions/timelines';
+import PropTypes from 'prop-types';
+import StatusListContainer from '../ui/containers/status_list_container';
+import Column from '../../components/column';
+import ColumnHeader from '../../components/column_header';
+import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { Link } from 'react-router-dom';
+import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/announcements';
+import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
+import classNames from 'classnames';
+import IconWithBadge from 'mastodon/components/icon_with_badge';
+import NotSignedInIndicator from 'mastodon/components/not_signed_in_indicator';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+ title: { id: 'column.home', defaultMessage: 'Home' },
+ show_announcements: { id: 'home.show_announcements', defaultMessage: 'Show announcements' },
+ hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' },
+});
+
+const mapStateToProps = state => ({
+ hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
+ isPartial: state.getIn(['timelines', 'home', 'isPartial']),
+ hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(),
+ unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')),
+ showAnnouncements: state.getIn(['announcements', 'show']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class HomeTimeline extends React.PureComponent {
+
+ static contextTypes = {
+ identity: PropTypes.object,
+ };
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ hasUnread: PropTypes.bool,
+ isPartial: PropTypes.bool,
+ columnId: PropTypes.string,
+ multiColumn: PropTypes.bool,
+ hasAnnouncements: PropTypes.bool,
+ unreadAnnouncements: PropTypes.number,
+ showAnnouncements: 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 = maxId => {
+ this.props.dispatch(expandHomeTimeline({ maxId }));
+ };
+
+ componentDidMount () {
+ setTimeout(() => this.props.dispatch(fetchAnnouncements()), 700);
+ this._checkIfReloadNeeded(false, this.props.isPartial);
+ }
+
+ componentDidUpdate (prevProps) {
+ this._checkIfReloadNeeded(prevProps.isPartial, this.props.isPartial);
+ }
+
+ componentWillUnmount () {
+ this._stopPolling();
+ }
+
+ _checkIfReloadNeeded (wasPartial, isPartial) {
+ const { dispatch } = this.props;
+
+ if (wasPartial === isPartial) {
+ return;
+ } else if (!wasPartial && isPartial) {
+ this.polling = setInterval(() => {
+ dispatch(expandHomeTimeline());
+ }, 3000);
+ } else if (wasPartial && !isPartial) {
+ this._stopPolling();
+ }
+ }
+
+ _stopPolling () {
+ if (this.polling) {
+ clearInterval(this.polling);
+ this.polling = null;
+ }
+ }
+
+ handleToggleAnnouncementsClick = (e) => {
+ e.stopPropagation();
+ this.props.dispatch(toggleShowAnnouncements());
+ };
+
+ render () {
+ const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
+ const pinned = !!columnId;
+ const { signedIn } = this.context.identity;
+
+ let announcementsButton = null;
+
+ if (hasAnnouncements) {
+ announcementsButton = (
+
+
+
+ );
+ }
+
+ return (
+
+ }
+ >
+
+
+
+ {signedIn ? (
+ }} />}
+ bindToDocument={!multiColumn}
+ />
+ ) : }
+
+
+ {intl.formatMessage(messages.title)}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/interaction_modal/index.js b/app/javascript/mastodon/features/interaction_modal/index.js
deleted file mode 100644
index c1d346fed..000000000
--- a/app/javascript/mastodon/features/interaction_modal/index.js
+++ /dev/null
@@ -1,161 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { FormattedMessage } from 'react-intl';
-import { registrationsOpen } from 'mastodon/initial_state';
-import { connect } from 'react-redux';
-import Icon from 'mastodon/components/icon';
-import classNames from 'classnames';
-import { openModal, closeModal } from 'mastodon/actions/modal';
-
-const mapStateToProps = (state, { accountId }) => ({
- displayNameHtml: state.getIn(['accounts', accountId, 'display_name_html']),
-});
-
-const mapDispatchToProps = (dispatch) => ({
- onSignupClick() {
- dispatch(closeModal());
- dispatch(openModal('CLOSED_REGISTRATIONS'));
- },
-});
-
-class Copypaste extends React.PureComponent {
-
- static propTypes = {
- value: PropTypes.string,
- };
-
- state = {
- copied: false,
- };
-
- setRef = c => {
- this.input = c;
- };
-
- handleInputClick = () => {
- this.setState({ copied: false });
- this.input.focus();
- this.input.select();
- this.input.setSelectionRange(0, this.input.value.length);
- };
-
- handleButtonClick = () => {
- const { value } = this.props;
- navigator.clipboard.writeText(value);
- this.input.blur();
- this.setState({ copied: true });
- this.timeout = setTimeout(() => this.setState({ copied: false }), 700);
- };
-
- componentWillUnmount () {
- if (this.timeout) clearTimeout(this.timeout);
- }
-
- render () {
- const { value } = this.props;
- const { copied } = this.state;
-
- return (
-
-
-
-
- {copied ? : }
-
-
- );
- }
-
-}
-
-export default @connect(mapStateToProps, mapDispatchToProps)
-class InteractionModal extends React.PureComponent {
-
- static propTypes = {
- displayNameHtml: PropTypes.string,
- url: PropTypes.string,
- type: PropTypes.oneOf(['reply', 'reblog', 'favourite', 'follow']),
- onSignupClick: PropTypes.func.isRequired,
- };
-
- handleSignupClick = () => {
- this.props.onSignupClick();
- };
-
- render () {
- const { url, type, displayNameHtml } = this.props;
-
- const name = ;
-
- let title, actionDescription, icon;
-
- switch(type) {
- case 'reply':
- icon = ;
- title = ;
- actionDescription = ;
- break;
- case 'reblog':
- icon = ;
- title = ;
- actionDescription = ;
- break;
- case 'favourite':
- icon = ;
- title = ;
- actionDescription = ;
- break;
- case 'follow':
- icon = ;
- title = ;
- actionDescription = ;
- break;
- }
-
- let signupButton;
-
- if (registrationsOpen) {
- signupButton = (
-
-
-
- );
- } else {
- signupButton = (
-
-
-
- );
- }
-
- return (
-
-
-
{icon} {title}
-
{actionDescription}
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/interaction_modal/index.jsx b/app/javascript/mastodon/features/interaction_modal/index.jsx
new file mode 100644
index 000000000..c1d346fed
--- /dev/null
+++ b/app/javascript/mastodon/features/interaction_modal/index.jsx
@@ -0,0 +1,161 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+import { registrationsOpen } from 'mastodon/initial_state';
+import { connect } from 'react-redux';
+import Icon from 'mastodon/components/icon';
+import classNames from 'classnames';
+import { openModal, closeModal } from 'mastodon/actions/modal';
+
+const mapStateToProps = (state, { accountId }) => ({
+ displayNameHtml: state.getIn(['accounts', accountId, 'display_name_html']),
+});
+
+const mapDispatchToProps = (dispatch) => ({
+ onSignupClick() {
+ dispatch(closeModal());
+ dispatch(openModal('CLOSED_REGISTRATIONS'));
+ },
+});
+
+class Copypaste extends React.PureComponent {
+
+ static propTypes = {
+ value: PropTypes.string,
+ };
+
+ state = {
+ copied: false,
+ };
+
+ setRef = c => {
+ this.input = c;
+ };
+
+ handleInputClick = () => {
+ this.setState({ copied: false });
+ this.input.focus();
+ this.input.select();
+ this.input.setSelectionRange(0, this.input.value.length);
+ };
+
+ handleButtonClick = () => {
+ const { value } = this.props;
+ navigator.clipboard.writeText(value);
+ this.input.blur();
+ this.setState({ copied: true });
+ this.timeout = setTimeout(() => this.setState({ copied: false }), 700);
+ };
+
+ componentWillUnmount () {
+ if (this.timeout) clearTimeout(this.timeout);
+ }
+
+ render () {
+ const { value } = this.props;
+ const { copied } = this.state;
+
+ return (
+
+
+
+
+ {copied ? : }
+
+
+ );
+ }
+
+}
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+class InteractionModal extends React.PureComponent {
+
+ static propTypes = {
+ displayNameHtml: PropTypes.string,
+ url: PropTypes.string,
+ type: PropTypes.oneOf(['reply', 'reblog', 'favourite', 'follow']),
+ onSignupClick: PropTypes.func.isRequired,
+ };
+
+ handleSignupClick = () => {
+ this.props.onSignupClick();
+ };
+
+ render () {
+ const { url, type, displayNameHtml } = this.props;
+
+ const name = ;
+
+ let title, actionDescription, icon;
+
+ switch(type) {
+ case 'reply':
+ icon = ;
+ title = ;
+ actionDescription = ;
+ break;
+ case 'reblog':
+ icon = ;
+ title = ;
+ actionDescription = ;
+ break;
+ case 'favourite':
+ icon = ;
+ title = ;
+ actionDescription = ;
+ break;
+ case 'follow':
+ icon = ;
+ title = ;
+ actionDescription = ;
+ break;
+ }
+
+ let signupButton;
+
+ if (registrationsOpen) {
+ signupButton = (
+
+
+
+ );
+ } else {
+ signupButton = (
+
+
+
+ );
+ }
+
+ return (
+
+
+
{icon} {title}
+
{actionDescription}
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/keyboard_shortcuts/index.js b/app/javascript/mastodon/features/keyboard_shortcuts/index.js
deleted file mode 100644
index 9a870478d..000000000
--- a/app/javascript/mastodon/features/keyboard_shortcuts/index.js
+++ /dev/null
@@ -1,176 +0,0 @@
-import React from 'react';
-import Column from 'mastodon/components/column';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import PropTypes from 'prop-types';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import ColumnHeader from 'mastodon/components/column_header';
-import { Helmet } from 'react-helmet';
-
-const messages = defineMessages({
- heading: { id: 'keyboard_shortcuts.heading', defaultMessage: 'Keyboard Shortcuts' },
-});
-
-export default @injectIntl
-class KeyboardShortcuts extends ImmutablePureComponent {
-
- static propTypes = {
- intl: PropTypes.object.isRequired,
- multiColumn: PropTypes.bool,
- };
-
- render () {
- const { intl, multiColumn } = this.props;
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
- r
-
-
-
- m
-
-
-
- p
-
-
-
- f
-
-
-
- b
-
-
-
- enter , o
-
-
-
- e
-
-
-
- x
-
-
-
- h
-
-
-
- up , k
-
-
-
- down , j
-
-
-
- 1 -9
-
-
-
- n
-
-
-
- alt +n
-
-
-
- alt +x
-
-
-
- backspace
-
-
-
- s
-
-
-
- esc
-
-
-
- g +h
-
-
-
- g +n
-
-
-
- g +l
-
-
-
- g +t
-
-
-
- g +d
-
-
-
- g +s
-
-
-
- g +f
-
-
-
- g +p
-
-
-
- g +u
-
-
-
- g +b
-
-
-
- g +m
-
-
-
- g +r
-
-
-
- ?
-
-
-
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx b/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx
new file mode 100644
index 000000000..9a870478d
--- /dev/null
+++ b/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx
@@ -0,0 +1,176 @@
+import React from 'react';
+import Column from 'mastodon/components/column';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ColumnHeader from 'mastodon/components/column_header';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+ heading: { id: 'keyboard_shortcuts.heading', defaultMessage: 'Keyboard Shortcuts' },
+});
+
+export default @injectIntl
+class KeyboardShortcuts extends ImmutablePureComponent {
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ multiColumn: PropTypes.bool,
+ };
+
+ render () {
+ const { intl, multiColumn } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ r
+
+
+
+ m
+
+
+
+ p
+
+
+
+ f
+
+
+
+ b
+
+
+
+ enter , o
+
+
+
+ e
+
+
+
+ x
+
+
+
+ h
+
+
+
+ up , k
+
+
+
+ down , j
+
+
+
+ 1 -9
+
+
+
+ n
+
+
+
+ alt +n
+
+
+
+ alt +x
+
+
+
+ backspace
+
+
+
+ s
+
+
+
+ esc
+
+
+
+ g +h
+
+
+
+ g +n
+
+
+
+ g +l
+
+
+
+ g +t
+
+
+
+ g +d
+
+
+
+ g +s
+
+
+
+ g +f
+
+
+
+ g +p
+
+
+
+ g +u
+
+
+
+ g +b
+
+
+
+ g +m
+
+
+
+ g +r
+
+
+
+ ?
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/list_adder/components/account.js b/app/javascript/mastodon/features/list_adder/components/account.js
deleted file mode 100644
index 1369aac07..000000000
--- a/app/javascript/mastodon/features/list_adder/components/account.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import { makeGetAccount } from '../../../selectors';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import Avatar from '../../../components/avatar';
-import DisplayName from '../../../components/display_name';
-import { injectIntl } from 'react-intl';
-
-const makeMapStateToProps = () => {
- const getAccount = makeGetAccount();
-
- const mapStateToProps = (state, { accountId }) => ({
- account: getAccount(state, accountId),
- });
-
- return mapStateToProps;
-};
-
-
-export default @connect(makeMapStateToProps)
-@injectIntl
-class Account extends ImmutablePureComponent {
-
- static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
- };
-
- render () {
- const { account } = this.props;
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/list_adder/components/account.jsx b/app/javascript/mastodon/features/list_adder/components/account.jsx
new file mode 100644
index 000000000..1369aac07
--- /dev/null
+++ b/app/javascript/mastodon/features/list_adder/components/account.jsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { makeGetAccount } from '../../../selectors';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from '../../../components/avatar';
+import DisplayName from '../../../components/display_name';
+import { injectIntl } from 'react-intl';
+
+const makeMapStateToProps = () => {
+ const getAccount = makeGetAccount();
+
+ const mapStateToProps = (state, { accountId }) => ({
+ account: getAccount(state, accountId),
+ });
+
+ return mapStateToProps;
+};
+
+
+export default @connect(makeMapStateToProps)
+@injectIntl
+class Account extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ };
+
+ render () {
+ const { account } = this.props;
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/list_adder/components/list.js b/app/javascript/mastodon/features/list_adder/components/list.js
deleted file mode 100644
index 60c8958a7..000000000
--- a/app/javascript/mastodon/features/list_adder/components/list.js
+++ /dev/null
@@ -1,69 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { connect } from 'react-redux';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import IconButton from '../../../components/icon_button';
-import { defineMessages, injectIntl } from 'react-intl';
-import { removeFromListAdder, addToListAdder } from '../../../actions/lists';
-import Icon from 'mastodon/components/icon';
-
-const messages = defineMessages({
- remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
- add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
-});
-
-const MapStateToProps = (state, { listId, added }) => ({
- list: state.get('lists').get(listId),
- added: typeof added === 'undefined' ? state.getIn(['listAdder', 'lists', 'items']).includes(listId) : added,
-});
-
-const mapDispatchToProps = (dispatch, { listId }) => ({
- onRemove: () => dispatch(removeFromListAdder(listId)),
- onAdd: () => dispatch(addToListAdder(listId)),
-});
-
-export default @connect(MapStateToProps, mapDispatchToProps)
-@injectIntl
-class List extends ImmutablePureComponent {
-
- static propTypes = {
- list: ImmutablePropTypes.map.isRequired,
- intl: PropTypes.object.isRequired,
- onRemove: PropTypes.func.isRequired,
- onAdd: PropTypes.func.isRequired,
- added: PropTypes.bool,
- };
-
- static defaultProps = {
- added: false,
- };
-
- render () {
- const { list, intl, onRemove, onAdd, added } = this.props;
-
- let button;
-
- if (added) {
- button = ;
- } else {
- button = ;
- }
-
- return (
-
-
-
-
- {list.get('title')}
-
-
-
- {button}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/list_adder/components/list.jsx b/app/javascript/mastodon/features/list_adder/components/list.jsx
new file mode 100644
index 000000000..60c8958a7
--- /dev/null
+++ b/app/javascript/mastodon/features/list_adder/components/list.jsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import IconButton from '../../../components/icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+import { removeFromListAdder, addToListAdder } from '../../../actions/lists';
+import Icon from 'mastodon/components/icon';
+
+const messages = defineMessages({
+ remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
+ add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
+});
+
+const MapStateToProps = (state, { listId, added }) => ({
+ list: state.get('lists').get(listId),
+ added: typeof added === 'undefined' ? state.getIn(['listAdder', 'lists', 'items']).includes(listId) : added,
+});
+
+const mapDispatchToProps = (dispatch, { listId }) => ({
+ onRemove: () => dispatch(removeFromListAdder(listId)),
+ onAdd: () => dispatch(addToListAdder(listId)),
+});
+
+export default @connect(MapStateToProps, mapDispatchToProps)
+@injectIntl
+class List extends ImmutablePureComponent {
+
+ static propTypes = {
+ list: ImmutablePropTypes.map.isRequired,
+ intl: PropTypes.object.isRequired,
+ onRemove: PropTypes.func.isRequired,
+ onAdd: PropTypes.func.isRequired,
+ added: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ added: false,
+ };
+
+ render () {
+ const { list, intl, onRemove, onAdd, added } = this.props;
+
+ let button;
+
+ if (added) {
+ button = ;
+ } else {
+ button = ;
+ }
+
+ return (
+
+
+
+
+ {list.get('title')}
+
+
+
+ {button}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/list_adder/index.js b/app/javascript/mastodon/features/list_adder/index.js
deleted file mode 100644
index cb8a15e8c..000000000
--- a/app/javascript/mastodon/features/list_adder/index.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { connect } from 'react-redux';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { injectIntl } from 'react-intl';
-import { setupListAdder, resetListAdder } from '../../actions/lists';
-import { createSelector } from 'reselect';
-import List from './components/list';
-import Account from './components/account';
-import NewListForm from '../lists/components/new_list_form';
-// hack
-
-const getOrderedLists = createSelector([state => state.get('lists')], lists => {
- if (!lists) {
- return lists;
- }
-
- return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
-});
-
-const mapStateToProps = state => ({
- listIds: getOrderedLists(state).map(list=>list.get('id')),
-});
-
-const mapDispatchToProps = dispatch => ({
- onInitialize: accountId => dispatch(setupListAdder(accountId)),
- onReset: () => dispatch(resetListAdder()),
-});
-
-export default @connect(mapStateToProps, mapDispatchToProps)
-@injectIntl
-class ListAdder extends ImmutablePureComponent {
-
- static propTypes = {
- accountId: PropTypes.string.isRequired,
- onClose: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- onInitialize: PropTypes.func.isRequired,
- onReset: PropTypes.func.isRequired,
- listIds: ImmutablePropTypes.list.isRequired,
- };
-
- componentDidMount () {
- const { onInitialize, accountId } = this.props;
- onInitialize(accountId);
- }
-
- componentWillUnmount () {
- const { onReset } = this.props;
- onReset();
- }
-
- render () {
- const { accountId, listIds } = this.props;
-
- return (
-
-
-
-
-
-
-
- {listIds.map(ListId =>
)}
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/list_adder/index.jsx b/app/javascript/mastodon/features/list_adder/index.jsx
new file mode 100644
index 000000000..cb8a15e8c
--- /dev/null
+++ b/app/javascript/mastodon/features/list_adder/index.jsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { injectIntl } from 'react-intl';
+import { setupListAdder, resetListAdder } from '../../actions/lists';
+import { createSelector } from 'reselect';
+import List from './components/list';
+import Account from './components/account';
+import NewListForm from '../lists/components/new_list_form';
+// hack
+
+const getOrderedLists = createSelector([state => state.get('lists')], lists => {
+ if (!lists) {
+ return lists;
+ }
+
+ return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
+});
+
+const mapStateToProps = state => ({
+ listIds: getOrderedLists(state).map(list=>list.get('id')),
+});
+
+const mapDispatchToProps = dispatch => ({
+ onInitialize: accountId => dispatch(setupListAdder(accountId)),
+ onReset: () => dispatch(resetListAdder()),
+});
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+class ListAdder extends ImmutablePureComponent {
+
+ static propTypes = {
+ accountId: PropTypes.string.isRequired,
+ onClose: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ onInitialize: PropTypes.func.isRequired,
+ onReset: PropTypes.func.isRequired,
+ listIds: ImmutablePropTypes.list.isRequired,
+ };
+
+ componentDidMount () {
+ const { onInitialize, accountId } = this.props;
+ onInitialize(accountId);
+ }
+
+ componentWillUnmount () {
+ const { onReset } = this.props;
+ onReset();
+ }
+
+ render () {
+ const { accountId, listIds } = this.props;
+
+ return (
+
+
+
+
+
+
+
+ {listIds.map(ListId =>
)}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/list_editor/components/account.js b/app/javascript/mastodon/features/list_editor/components/account.js
deleted file mode 100644
index 48085af43..000000000
--- a/app/javascript/mastodon/features/list_editor/components/account.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { connect } from 'react-redux';
-import { makeGetAccount } from '../../../selectors';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import Avatar from '../../../components/avatar';
-import DisplayName from '../../../components/display_name';
-import IconButton from '../../../components/icon_button';
-import { defineMessages, injectIntl } from 'react-intl';
-import { removeFromListEditor, addToListEditor } from '../../../actions/lists';
-
-const messages = defineMessages({
- remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
- add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
-});
-
-const makeMapStateToProps = () => {
- const getAccount = makeGetAccount();
-
- const mapStateToProps = (state, { accountId, added }) => ({
- account: getAccount(state, accountId),
- added: typeof added === 'undefined' ? state.getIn(['listEditor', 'accounts', 'items']).includes(accountId) : added,
- });
-
- return mapStateToProps;
-};
-
-const mapDispatchToProps = (dispatch, { accountId }) => ({
- onRemove: () => dispatch(removeFromListEditor(accountId)),
- onAdd: () => dispatch(addToListEditor(accountId)),
-});
-
-export default @connect(makeMapStateToProps, mapDispatchToProps)
-@injectIntl
-class Account extends ImmutablePureComponent {
-
- static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
- intl: PropTypes.object.isRequired,
- onRemove: PropTypes.func.isRequired,
- onAdd: PropTypes.func.isRequired,
- added: PropTypes.bool,
- };
-
- static defaultProps = {
- added: false,
- };
-
- render () {
- const { account, intl, onRemove, onAdd, added } = this.props;
-
- let button;
-
- if (added) {
- button = ;
- } else {
- button = ;
- }
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/list_editor/components/account.jsx b/app/javascript/mastodon/features/list_editor/components/account.jsx
new file mode 100644
index 000000000..48085af43
--- /dev/null
+++ b/app/javascript/mastodon/features/list_editor/components/account.jsx
@@ -0,0 +1,77 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { makeGetAccount } from '../../../selectors';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from '../../../components/avatar';
+import DisplayName from '../../../components/display_name';
+import IconButton from '../../../components/icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+import { removeFromListEditor, addToListEditor } from '../../../actions/lists';
+
+const messages = defineMessages({
+ remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
+ add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
+});
+
+const makeMapStateToProps = () => {
+ const getAccount = makeGetAccount();
+
+ const mapStateToProps = (state, { accountId, added }) => ({
+ account: getAccount(state, accountId),
+ added: typeof added === 'undefined' ? state.getIn(['listEditor', 'accounts', 'items']).includes(accountId) : added,
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { accountId }) => ({
+ onRemove: () => dispatch(removeFromListEditor(accountId)),
+ onAdd: () => dispatch(addToListEditor(accountId)),
+});
+
+export default @connect(makeMapStateToProps, mapDispatchToProps)
+@injectIntl
+class Account extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ intl: PropTypes.object.isRequired,
+ onRemove: PropTypes.func.isRequired,
+ onAdd: PropTypes.func.isRequired,
+ added: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ added: false,
+ };
+
+ render () {
+ const { account, intl, onRemove, onAdd, added } = this.props;
+
+ let button;
+
+ if (added) {
+ button = ;
+ } else {
+ button = ;
+ }
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/list_editor/components/edit_list_form.js b/app/javascript/mastodon/features/list_editor/components/edit_list_form.js
deleted file mode 100644
index 4d7e49ec0..000000000
--- a/app/javascript/mastodon/features/list_editor/components/edit_list_form.js
+++ /dev/null
@@ -1,70 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import { changeListEditorTitle, submitListEditor } from '../../../actions/lists';
-import IconButton from '../../../components/icon_button';
-import { defineMessages, injectIntl } from 'react-intl';
-
-const messages = defineMessages({
- title: { id: 'lists.edit.submit', defaultMessage: 'Change title' },
-});
-
-const mapStateToProps = state => ({
- value: state.getIn(['listEditor', 'title']),
- disabled: !state.getIn(['listEditor', 'isChanged']) || !state.getIn(['listEditor', 'title']),
-});
-
-const mapDispatchToProps = dispatch => ({
- onChange: value => dispatch(changeListEditorTitle(value)),
- onSubmit: () => dispatch(submitListEditor(false)),
-});
-
-export default @connect(mapStateToProps, mapDispatchToProps)
-@injectIntl
-class ListForm extends React.PureComponent {
-
- static propTypes = {
- value: PropTypes.string.isRequired,
- disabled: PropTypes.bool,
- intl: PropTypes.object.isRequired,
- onChange: PropTypes.func.isRequired,
- onSubmit: PropTypes.func.isRequired,
- };
-
- handleChange = e => {
- this.props.onChange(e.target.value);
- };
-
- handleSubmit = e => {
- e.preventDefault();
- this.props.onSubmit();
- };
-
- handleClick = () => {
- this.props.onSubmit();
- };
-
- render () {
- const { value, disabled, intl } = this.props;
-
- const title = intl.formatMessage(messages.title);
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/list_editor/components/edit_list_form.jsx b/app/javascript/mastodon/features/list_editor/components/edit_list_form.jsx
new file mode 100644
index 000000000..4d7e49ec0
--- /dev/null
+++ b/app/javascript/mastodon/features/list_editor/components/edit_list_form.jsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { changeListEditorTitle, submitListEditor } from '../../../actions/lists';
+import IconButton from '../../../components/icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+ title: { id: 'lists.edit.submit', defaultMessage: 'Change title' },
+});
+
+const mapStateToProps = state => ({
+ value: state.getIn(['listEditor', 'title']),
+ disabled: !state.getIn(['listEditor', 'isChanged']) || !state.getIn(['listEditor', 'title']),
+});
+
+const mapDispatchToProps = dispatch => ({
+ onChange: value => dispatch(changeListEditorTitle(value)),
+ onSubmit: () => dispatch(submitListEditor(false)),
+});
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+class ListForm extends React.PureComponent {
+
+ static propTypes = {
+ value: PropTypes.string.isRequired,
+ disabled: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onSubmit: PropTypes.func.isRequired,
+ };
+
+ handleChange = e => {
+ this.props.onChange(e.target.value);
+ };
+
+ handleSubmit = e => {
+ e.preventDefault();
+ this.props.onSubmit();
+ };
+
+ handleClick = () => {
+ this.props.onSubmit();
+ };
+
+ render () {
+ const { value, disabled, intl } = this.props;
+
+ const title = intl.formatMessage(messages.title);
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/list_editor/components/search.js b/app/javascript/mastodon/features/list_editor/components/search.js
deleted file mode 100644
index 3ee26c8eb..000000000
--- a/app/javascript/mastodon/features/list_editor/components/search.js
+++ /dev/null
@@ -1,76 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { connect } from 'react-redux';
-import { defineMessages, injectIntl } from 'react-intl';
-import { fetchListSuggestions, clearListSuggestions, changeListSuggestions } from '../../../actions/lists';
-import classNames from 'classnames';
-import Icon from 'mastodon/components/icon';
-
-const messages = defineMessages({
- search: { id: 'lists.search', defaultMessage: 'Search among people you follow' },
-});
-
-const mapStateToProps = state => ({
- value: state.getIn(['listEditor', 'suggestions', 'value']),
-});
-
-const mapDispatchToProps = dispatch => ({
- onSubmit: value => dispatch(fetchListSuggestions(value)),
- onClear: () => dispatch(clearListSuggestions()),
- onChange: value => dispatch(changeListSuggestions(value)),
-});
-
-export default @connect(mapStateToProps, mapDispatchToProps)
-@injectIntl
-class Search extends React.PureComponent {
-
- static propTypes = {
- intl: PropTypes.object.isRequired,
- value: PropTypes.string.isRequired,
- onChange: PropTypes.func.isRequired,
- onSubmit: PropTypes.func.isRequired,
- onClear: PropTypes.func.isRequired,
- };
-
- handleChange = e => {
- this.props.onChange(e.target.value);
- };
-
- handleKeyUp = e => {
- if (e.keyCode === 13) {
- this.props.onSubmit(this.props.value);
- }
- };
-
- handleClear = () => {
- this.props.onClear();
- };
-
- render () {
- const { value, intl } = this.props;
- const hasValue = value.length > 0;
-
- return (
-
-
- {intl.formatMessage(messages.search)}
-
-
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/list_editor/components/search.jsx b/app/javascript/mastodon/features/list_editor/components/search.jsx
new file mode 100644
index 000000000..3ee26c8eb
--- /dev/null
+++ b/app/javascript/mastodon/features/list_editor/components/search.jsx
@@ -0,0 +1,76 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+import { fetchListSuggestions, clearListSuggestions, changeListSuggestions } from '../../../actions/lists';
+import classNames from 'classnames';
+import Icon from 'mastodon/components/icon';
+
+const messages = defineMessages({
+ search: { id: 'lists.search', defaultMessage: 'Search among people you follow' },
+});
+
+const mapStateToProps = state => ({
+ value: state.getIn(['listEditor', 'suggestions', 'value']),
+});
+
+const mapDispatchToProps = dispatch => ({
+ onSubmit: value => dispatch(fetchListSuggestions(value)),
+ onClear: () => dispatch(clearListSuggestions()),
+ onChange: value => dispatch(changeListSuggestions(value)),
+});
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+class Search extends React.PureComponent {
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ value: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onSubmit: PropTypes.func.isRequired,
+ onClear: PropTypes.func.isRequired,
+ };
+
+ handleChange = e => {
+ this.props.onChange(e.target.value);
+ };
+
+ handleKeyUp = e => {
+ if (e.keyCode === 13) {
+ this.props.onSubmit(this.props.value);
+ }
+ };
+
+ handleClear = () => {
+ this.props.onClear();
+ };
+
+ render () {
+ const { value, intl } = this.props;
+ const hasValue = value.length > 0;
+
+ return (
+
+
+ {intl.formatMessage(messages.search)}
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/list_editor/index.js b/app/javascript/mastodon/features/list_editor/index.js
deleted file mode 100644
index 48466604a..000000000
--- a/app/javascript/mastodon/features/list_editor/index.js
+++ /dev/null
@@ -1,79 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { connect } from 'react-redux';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { injectIntl } from 'react-intl';
-import { setupListEditor, clearListSuggestions, resetListEditor } from '../../actions/lists';
-import Account from './components/account';
-import Search from './components/search';
-import EditListForm from './components/edit_list_form';
-import Motion from '../ui/util/optional_motion';
-import spring from 'react-motion/lib/spring';
-
-const mapStateToProps = state => ({
- accountIds: state.getIn(['listEditor', 'accounts', 'items']),
- searchAccountIds: state.getIn(['listEditor', 'suggestions', 'items']),
-});
-
-const mapDispatchToProps = dispatch => ({
- onInitialize: listId => dispatch(setupListEditor(listId)),
- onClear: () => dispatch(clearListSuggestions()),
- onReset: () => dispatch(resetListEditor()),
-});
-
-export default @connect(mapStateToProps, mapDispatchToProps)
-@injectIntl
-class ListEditor extends ImmutablePureComponent {
-
- static propTypes = {
- listId: PropTypes.string.isRequired,
- onClose: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- onInitialize: PropTypes.func.isRequired,
- onClear: PropTypes.func.isRequired,
- onReset: PropTypes.func.isRequired,
- accountIds: ImmutablePropTypes.list.isRequired,
- searchAccountIds: ImmutablePropTypes.list.isRequired,
- };
-
- componentDidMount () {
- const { onInitialize, listId } = this.props;
- onInitialize(listId);
- }
-
- componentWillUnmount () {
- const { onReset } = this.props;
- onReset();
- }
-
- render () {
- const { accountIds, searchAccountIds, onClear } = this.props;
- const showSearch = searchAccountIds.size > 0;
-
- return (
-
-
-
-
-
-
-
- {accountIds.map(accountId =>
)}
-
-
- {showSearch &&
}
-
-
- {({ x }) => (
-
- {searchAccountIds.map(accountId =>
)}
-
- )}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/list_editor/index.jsx b/app/javascript/mastodon/features/list_editor/index.jsx
new file mode 100644
index 000000000..48466604a
--- /dev/null
+++ b/app/javascript/mastodon/features/list_editor/index.jsx
@@ -0,0 +1,79 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { injectIntl } from 'react-intl';
+import { setupListEditor, clearListSuggestions, resetListEditor } from '../../actions/lists';
+import Account from './components/account';
+import Search from './components/search';
+import EditListForm from './components/edit_list_form';
+import Motion from '../ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+
+const mapStateToProps = state => ({
+ accountIds: state.getIn(['listEditor', 'accounts', 'items']),
+ searchAccountIds: state.getIn(['listEditor', 'suggestions', 'items']),
+});
+
+const mapDispatchToProps = dispatch => ({
+ onInitialize: listId => dispatch(setupListEditor(listId)),
+ onClear: () => dispatch(clearListSuggestions()),
+ onReset: () => dispatch(resetListEditor()),
+});
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+class ListEditor extends ImmutablePureComponent {
+
+ static propTypes = {
+ listId: PropTypes.string.isRequired,
+ onClose: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ onInitialize: PropTypes.func.isRequired,
+ onClear: PropTypes.func.isRequired,
+ onReset: PropTypes.func.isRequired,
+ accountIds: ImmutablePropTypes.list.isRequired,
+ searchAccountIds: ImmutablePropTypes.list.isRequired,
+ };
+
+ componentDidMount () {
+ const { onInitialize, listId } = this.props;
+ onInitialize(listId);
+ }
+
+ componentWillUnmount () {
+ const { onReset } = this.props;
+ onReset();
+ }
+
+ render () {
+ const { accountIds, searchAccountIds, onClear } = this.props;
+ const showSearch = searchAccountIds.size > 0;
+
+ return (
+
+
+
+
+
+
+
+ {accountIds.map(accountId =>
)}
+
+
+ {showSearch &&
}
+
+
+ {({ x }) => (
+
+ {searchAccountIds.map(accountId =>
)}
+
+ )}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/list_timeline/index.js b/app/javascript/mastodon/features/list_timeline/index.js
deleted file mode 100644
index 25dbe311a..000000000
--- a/app/javascript/mastodon/features/list_timeline/index.js
+++ /dev/null
@@ -1,221 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Helmet } from 'react-helmet';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
-import { connect } from 'react-redux';
-import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
-import { fetchList, deleteList, updateList } from 'mastodon/actions/lists';
-import { openModal } from 'mastodon/actions/modal';
-import { connectListStream } from 'mastodon/actions/streaming';
-import { expandListTimeline } from 'mastodon/actions/timelines';
-import Column from 'mastodon/components/column';
-import ColumnBackButton from 'mastodon/components/column_back_button';
-import ColumnHeader from 'mastodon/components/column_header';
-import Icon from 'mastodon/components/icon';
-import LoadingIndicator from 'mastodon/components/loading_indicator';
-import MissingIndicator from 'mastodon/components/missing_indicator';
-import RadioButton from 'mastodon/components/radio_button';
-import StatusListContainer from 'mastodon/features/ui/containers/status_list_container';
-
-const messages = defineMessages({
- deleteMessage: { id: 'confirmations.delete_list.message', defaultMessage: 'Are you sure you want to permanently delete this list?' },
- deleteConfirm: { id: 'confirmations.delete_list.confirm', defaultMessage: 'Delete' },
- followed: { id: 'lists.replies_policy.followed', defaultMessage: 'Any followed user' },
- none: { id: 'lists.replies_policy.none', defaultMessage: 'No one' },
- list: { id: 'lists.replies_policy.list', defaultMessage: 'Members of the list' },
-});
-
-const mapStateToProps = (state, props) => ({
- list: state.getIn(['lists', props.params.id]),
- hasUnread: state.getIn(['timelines', `list:${props.params.id}`, 'unread']) > 0,
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-class ListTimeline extends React.PureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- };
-
- static propTypes = {
- params: PropTypes.object.isRequired,
- dispatch: PropTypes.func.isRequired,
- columnId: PropTypes.string,
- hasUnread: PropTypes.bool,
- multiColumn: PropTypes.bool,
- list: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]),
- intl: PropTypes.object.isRequired,
- };
-
- handlePin = () => {
- const { columnId, dispatch } = this.props;
-
- if (columnId) {
- dispatch(removeColumn(columnId));
- } else {
- dispatch(addColumn('LIST', { id: this.props.params.id }));
- this.context.router.history.push('/');
- }
- };
-
- handleMove = (dir) => {
- const { columnId, dispatch } = this.props;
- dispatch(moveColumn(columnId, dir));
- };
-
- handleHeaderClick = () => {
- this.column.scrollTop();
- };
-
- componentDidMount () {
- const { dispatch } = this.props;
- const { id } = this.props.params;
-
- dispatch(fetchList(id));
- dispatch(expandListTimeline(id));
-
- this.disconnect = dispatch(connectListStream(id));
- }
-
- componentWillReceiveProps (nextProps) {
- const { dispatch } = this.props;
- const { id } = nextProps.params;
-
- if (id !== this.props.params.id) {
- if (this.disconnect) {
- this.disconnect();
- this.disconnect = null;
- }
-
- dispatch(fetchList(id));
- dispatch(expandListTimeline(id));
-
- this.disconnect = dispatch(connectListStream(id));
- }
- }
-
- componentWillUnmount () {
- if (this.disconnect) {
- this.disconnect();
- this.disconnect = null;
- }
- }
-
- setRef = c => {
- this.column = c;
- };
-
- handleLoadMore = maxId => {
- const { id } = this.props.params;
- this.props.dispatch(expandListTimeline(id, { maxId }));
- };
-
- handleEditClick = () => {
- this.props.dispatch(openModal('LIST_EDITOR', { listId: this.props.params.id }));
- };
-
- handleDeleteClick = () => {
- const { dispatch, columnId, intl } = this.props;
- const { id } = this.props.params;
-
- dispatch(openModal('CONFIRM', {
- message: intl.formatMessage(messages.deleteMessage),
- confirm: intl.formatMessage(messages.deleteConfirm),
- onConfirm: () => {
- dispatch(deleteList(id));
-
- if (columnId) {
- dispatch(removeColumn(columnId));
- } else {
- this.context.router.history.push('/lists');
- }
- },
- }));
- };
-
- handleRepliesPolicyChange = ({ target }) => {
- const { dispatch } = this.props;
- const { id } = this.props.params;
- dispatch(updateList(id, undefined, false, target.value));
- };
-
- render () {
- const { hasUnread, columnId, multiColumn, list, intl } = this.props;
- const { id } = this.props.params;
- const pinned = !!columnId;
- const title = list ? list.get('title') : id;
- const replies_policy = list ? list.get('replies_policy') : undefined;
-
- if (typeof list === 'undefined') {
- return (
-
-
-
-
-
- );
- } else if (list === false) {
- return (
-
-
-
-
- );
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- { replies_policy !== undefined && (
-
-
-
-
-
- { ['none', 'list', 'followed'].map(policy => (
-
- ))}
-
-
- )}
-
-
- }
- bindToDocument={!multiColumn}
- />
-
-
- {title}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/list_timeline/index.jsx b/app/javascript/mastodon/features/list_timeline/index.jsx
new file mode 100644
index 000000000..25dbe311a
--- /dev/null
+++ b/app/javascript/mastodon/features/list_timeline/index.jsx
@@ -0,0 +1,221 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Helmet } from 'react-helmet';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import { connect } from 'react-redux';
+import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
+import { fetchList, deleteList, updateList } from 'mastodon/actions/lists';
+import { openModal } from 'mastodon/actions/modal';
+import { connectListStream } from 'mastodon/actions/streaming';
+import { expandListTimeline } from 'mastodon/actions/timelines';
+import Column from 'mastodon/components/column';
+import ColumnBackButton from 'mastodon/components/column_back_button';
+import ColumnHeader from 'mastodon/components/column_header';
+import Icon from 'mastodon/components/icon';
+import LoadingIndicator from 'mastodon/components/loading_indicator';
+import MissingIndicator from 'mastodon/components/missing_indicator';
+import RadioButton from 'mastodon/components/radio_button';
+import StatusListContainer from 'mastodon/features/ui/containers/status_list_container';
+
+const messages = defineMessages({
+ deleteMessage: { id: 'confirmations.delete_list.message', defaultMessage: 'Are you sure you want to permanently delete this list?' },
+ deleteConfirm: { id: 'confirmations.delete_list.confirm', defaultMessage: 'Delete' },
+ followed: { id: 'lists.replies_policy.followed', defaultMessage: 'Any followed user' },
+ none: { id: 'lists.replies_policy.none', defaultMessage: 'No one' },
+ list: { id: 'lists.replies_policy.list', defaultMessage: 'Members of the list' },
+});
+
+const mapStateToProps = (state, props) => ({
+ list: state.getIn(['lists', props.params.id]),
+ hasUnread: state.getIn(['timelines', `list:${props.params.id}`, 'unread']) > 0,
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class ListTimeline extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ columnId: PropTypes.string,
+ hasUnread: PropTypes.bool,
+ multiColumn: PropTypes.bool,
+ list: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]),
+ intl: PropTypes.object.isRequired,
+ };
+
+ handlePin = () => {
+ const { columnId, dispatch } = this.props;
+
+ if (columnId) {
+ dispatch(removeColumn(columnId));
+ } else {
+ dispatch(addColumn('LIST', { id: this.props.params.id }));
+ this.context.router.history.push('/');
+ }
+ };
+
+ handleMove = (dir) => {
+ const { columnId, dispatch } = this.props;
+ dispatch(moveColumn(columnId, dir));
+ };
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ };
+
+ componentDidMount () {
+ const { dispatch } = this.props;
+ const { id } = this.props.params;
+
+ dispatch(fetchList(id));
+ dispatch(expandListTimeline(id));
+
+ this.disconnect = dispatch(connectListStream(id));
+ }
+
+ componentWillReceiveProps (nextProps) {
+ const { dispatch } = this.props;
+ const { id } = nextProps.params;
+
+ if (id !== this.props.params.id) {
+ if (this.disconnect) {
+ this.disconnect();
+ this.disconnect = null;
+ }
+
+ dispatch(fetchList(id));
+ dispatch(expandListTimeline(id));
+
+ this.disconnect = dispatch(connectListStream(id));
+ }
+ }
+
+ componentWillUnmount () {
+ if (this.disconnect) {
+ this.disconnect();
+ this.disconnect = null;
+ }
+ }
+
+ setRef = c => {
+ this.column = c;
+ };
+
+ handleLoadMore = maxId => {
+ const { id } = this.props.params;
+ this.props.dispatch(expandListTimeline(id, { maxId }));
+ };
+
+ handleEditClick = () => {
+ this.props.dispatch(openModal('LIST_EDITOR', { listId: this.props.params.id }));
+ };
+
+ handleDeleteClick = () => {
+ const { dispatch, columnId, intl } = this.props;
+ const { id } = this.props.params;
+
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.deleteMessage),
+ confirm: intl.formatMessage(messages.deleteConfirm),
+ onConfirm: () => {
+ dispatch(deleteList(id));
+
+ if (columnId) {
+ dispatch(removeColumn(columnId));
+ } else {
+ this.context.router.history.push('/lists');
+ }
+ },
+ }));
+ };
+
+ handleRepliesPolicyChange = ({ target }) => {
+ const { dispatch } = this.props;
+ const { id } = this.props.params;
+ dispatch(updateList(id, undefined, false, target.value));
+ };
+
+ render () {
+ const { hasUnread, columnId, multiColumn, list, intl } = this.props;
+ const { id } = this.props.params;
+ const pinned = !!columnId;
+ const title = list ? list.get('title') : id;
+ const replies_policy = list ? list.get('replies_policy') : undefined;
+
+ if (typeof list === 'undefined') {
+ return (
+
+
+
+
+
+ );
+ } else if (list === false) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ { replies_policy !== undefined && (
+
+
+
+
+
+ { ['none', 'list', 'followed'].map(policy => (
+
+ ))}
+
+
+ )}
+
+
+ }
+ bindToDocument={!multiColumn}
+ />
+
+
+ {title}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/lists/components/new_list_form.js b/app/javascript/mastodon/features/lists/components/new_list_form.js
deleted file mode 100644
index 4e00e5200..000000000
--- a/app/javascript/mastodon/features/lists/components/new_list_form.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import { changeListEditorTitle, submitListEditor } from 'mastodon/actions/lists';
-import Button from 'mastodon/components/button';
-import { defineMessages, injectIntl } from 'react-intl';
-
-const messages = defineMessages({
- label: { id: 'lists.new.title_placeholder', defaultMessage: 'New list title' },
- title: { id: 'lists.new.create', defaultMessage: 'Add list' },
-});
-
-const mapStateToProps = state => ({
- value: state.getIn(['listEditor', 'title']),
- disabled: state.getIn(['listEditor', 'isSubmitting']),
-});
-
-const mapDispatchToProps = dispatch => ({
- onChange: value => dispatch(changeListEditorTitle(value)),
- onSubmit: () => dispatch(submitListEditor(true)),
-});
-
-export default @connect(mapStateToProps, mapDispatchToProps)
-@injectIntl
-class NewListForm extends React.PureComponent {
-
- static propTypes = {
- value: PropTypes.string.isRequired,
- disabled: PropTypes.bool,
- intl: PropTypes.object.isRequired,
- onChange: PropTypes.func.isRequired,
- onSubmit: PropTypes.func.isRequired,
- };
-
- handleChange = e => {
- this.props.onChange(e.target.value);
- };
-
- handleSubmit = e => {
- e.preventDefault();
- this.props.onSubmit();
- };
-
- handleClick = () => {
- this.props.onSubmit();
- };
-
- render () {
- const { value, disabled, intl } = this.props;
-
- const label = intl.formatMessage(messages.label);
- const title = intl.formatMessage(messages.title);
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/lists/components/new_list_form.jsx b/app/javascript/mastodon/features/lists/components/new_list_form.jsx
new file mode 100644
index 000000000..4e00e5200
--- /dev/null
+++ b/app/javascript/mastodon/features/lists/components/new_list_form.jsx
@@ -0,0 +1,77 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { changeListEditorTitle, submitListEditor } from 'mastodon/actions/lists';
+import Button from 'mastodon/components/button';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+ label: { id: 'lists.new.title_placeholder', defaultMessage: 'New list title' },
+ title: { id: 'lists.new.create', defaultMessage: 'Add list' },
+});
+
+const mapStateToProps = state => ({
+ value: state.getIn(['listEditor', 'title']),
+ disabled: state.getIn(['listEditor', 'isSubmitting']),
+});
+
+const mapDispatchToProps = dispatch => ({
+ onChange: value => dispatch(changeListEditorTitle(value)),
+ onSubmit: () => dispatch(submitListEditor(true)),
+});
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+class NewListForm extends React.PureComponent {
+
+ static propTypes = {
+ value: PropTypes.string.isRequired,
+ disabled: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onSubmit: PropTypes.func.isRequired,
+ };
+
+ handleChange = e => {
+ this.props.onChange(e.target.value);
+ };
+
+ handleSubmit = e => {
+ e.preventDefault();
+ this.props.onSubmit();
+ };
+
+ handleClick = () => {
+ this.props.onSubmit();
+ };
+
+ render () {
+ const { value, disabled, intl } = this.props;
+
+ const label = intl.formatMessage(messages.label);
+ const title = intl.formatMessage(messages.title);
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/lists/index.js b/app/javascript/mastodon/features/lists/index.js
deleted file mode 100644
index 3a0b1373a..000000000
--- a/app/javascript/mastodon/features/lists/index.js
+++ /dev/null
@@ -1,89 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Helmet } from 'react-helmet';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import { fetchLists } from 'mastodon/actions/lists';
-import LoadingIndicator from 'mastodon/components/loading_indicator';
-import ScrollableList from 'mastodon/components/scrollable_list';
-import Column from 'mastodon/components/column';
-import ColumnHeader from 'mastodon/components/column_header';
-import ColumnLink from 'mastodon/features/ui/components/column_link';
-import ColumnSubheading from 'mastodon/features/ui/components/column_subheading';
-import NewListForm from './components/new_list_form';
-
-const messages = defineMessages({
- heading: { id: 'column.lists', defaultMessage: 'Lists' },
- subheading: { id: 'lists.subheading', defaultMessage: 'Your lists' },
-});
-
-const getOrderedLists = createSelector([state => state.get('lists')], lists => {
- if (!lists) {
- return lists;
- }
-
- return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
-});
-
-const mapStateToProps = state => ({
- lists: getOrderedLists(state),
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-class Lists extends ImmutablePureComponent {
-
- static propTypes = {
- params: PropTypes.object.isRequired,
- dispatch: PropTypes.func.isRequired,
- lists: ImmutablePropTypes.list,
- intl: PropTypes.object.isRequired,
- multiColumn: PropTypes.bool,
- };
-
- componentWillMount () {
- this.props.dispatch(fetchLists());
- }
-
- render () {
- const { intl, lists, multiColumn } = this.props;
-
- if (!lists) {
- return (
-
-
-
- );
- }
-
- const emptyMessage = ;
-
- return (
-
-
-
-
-
- }
- bindToDocument={!multiColumn}
- >
- {lists.map(list =>
- ,
- )}
-
-
-
- {intl.formatMessage(messages.heading)}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/lists/index.jsx b/app/javascript/mastodon/features/lists/index.jsx
new file mode 100644
index 000000000..3a0b1373a
--- /dev/null
+++ b/app/javascript/mastodon/features/lists/index.jsx
@@ -0,0 +1,89 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Helmet } from 'react-helmet';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchLists } from 'mastodon/actions/lists';
+import LoadingIndicator from 'mastodon/components/loading_indicator';
+import ScrollableList from 'mastodon/components/scrollable_list';
+import Column from 'mastodon/components/column';
+import ColumnHeader from 'mastodon/components/column_header';
+import ColumnLink from 'mastodon/features/ui/components/column_link';
+import ColumnSubheading from 'mastodon/features/ui/components/column_subheading';
+import NewListForm from './components/new_list_form';
+
+const messages = defineMessages({
+ heading: { id: 'column.lists', defaultMessage: 'Lists' },
+ subheading: { id: 'lists.subheading', defaultMessage: 'Your lists' },
+});
+
+const getOrderedLists = createSelector([state => state.get('lists')], lists => {
+ if (!lists) {
+ return lists;
+ }
+
+ return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
+});
+
+const mapStateToProps = state => ({
+ lists: getOrderedLists(state),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Lists extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ lists: ImmutablePropTypes.list,
+ intl: PropTypes.object.isRequired,
+ multiColumn: PropTypes.bool,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchLists());
+ }
+
+ render () {
+ const { intl, lists, multiColumn } = this.props;
+
+ if (!lists) {
+ return (
+
+
+
+ );
+ }
+
+ const emptyMessage = ;
+
+ return (
+
+
+
+
+
+ }
+ bindToDocument={!multiColumn}
+ >
+ {lists.map(list =>
+ ,
+ )}
+
+
+
+ {intl.formatMessage(messages.heading)}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/mutes/index.js b/app/javascript/mastodon/features/mutes/index.js
deleted file mode 100644
index 65df6149f..000000000
--- a/app/javascript/mastodon/features/mutes/index.js
+++ /dev/null
@@ -1,84 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { debounce } from 'lodash';
-import LoadingIndicator from '../../components/loading_indicator';
-import Column from '../ui/components/column';
-import ColumnBackButtonSlim from '../../components/column_back_button_slim';
-import AccountContainer from '../../containers/account_container';
-import { fetchMutes, expandMutes } from '../../actions/mutes';
-import ScrollableList from '../../components/scrollable_list';
-import { Helmet } from 'react-helmet';
-
-const messages = defineMessages({
- heading: { id: 'column.mutes', defaultMessage: 'Muted users' },
-});
-
-const mapStateToProps = state => ({
- accountIds: state.getIn(['user_lists', 'mutes', 'items']),
- hasMore: !!state.getIn(['user_lists', 'mutes', 'next']),
- isLoading: state.getIn(['user_lists', 'mutes', 'isLoading'], true),
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-class Mutes extends ImmutablePureComponent {
-
- static propTypes = {
- params: PropTypes.object.isRequired,
- dispatch: PropTypes.func.isRequired,
- hasMore: PropTypes.bool,
- isLoading: PropTypes.bool,
- accountIds: ImmutablePropTypes.list,
- intl: PropTypes.object.isRequired,
- multiColumn: PropTypes.bool,
- };
-
- componentWillMount () {
- this.props.dispatch(fetchMutes());
- }
-
- handleLoadMore = debounce(() => {
- this.props.dispatch(expandMutes());
- }, 300, { leading: true });
-
- render () {
- const { intl, hasMore, accountIds, multiColumn, isLoading } = this.props;
-
- if (!accountIds) {
- return (
-
-
-
- );
- }
-
- const emptyMessage = ;
-
- return (
-
-
-
- {accountIds.map(id =>
- ,
- )}
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/mutes/index.jsx b/app/javascript/mastodon/features/mutes/index.jsx
new file mode 100644
index 000000000..65df6149f
--- /dev/null
+++ b/app/javascript/mastodon/features/mutes/index.jsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { debounce } from 'lodash';
+import LoadingIndicator from '../../components/loading_indicator';
+import Column from '../ui/components/column';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import AccountContainer from '../../containers/account_container';
+import { fetchMutes, expandMutes } from '../../actions/mutes';
+import ScrollableList from '../../components/scrollable_list';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+ heading: { id: 'column.mutes', defaultMessage: 'Muted users' },
+});
+
+const mapStateToProps = state => ({
+ accountIds: state.getIn(['user_lists', 'mutes', 'items']),
+ hasMore: !!state.getIn(['user_lists', 'mutes', 'next']),
+ isLoading: state.getIn(['user_lists', 'mutes', 'isLoading'], true),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Mutes extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ hasMore: PropTypes.bool,
+ isLoading: PropTypes.bool,
+ accountIds: ImmutablePropTypes.list,
+ intl: PropTypes.object.isRequired,
+ multiColumn: PropTypes.bool,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchMutes());
+ }
+
+ handleLoadMore = debounce(() => {
+ this.props.dispatch(expandMutes());
+ }, 300, { leading: true });
+
+ render () {
+ const { intl, hasMore, accountIds, multiColumn, isLoading } = this.props;
+
+ if (!accountIds) {
+ return (
+
+
+
+ );
+ }
+
+ const emptyMessage = ;
+
+ return (
+
+
+
+ {accountIds.map(id =>
+ ,
+ )}
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/notifications/components/clear_column_button.js b/app/javascript/mastodon/features/notifications/components/clear_column_button.js
deleted file mode 100644
index b82fd092f..000000000
--- a/app/javascript/mastodon/features/notifications/components/clear_column_button.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { FormattedMessage } from 'react-intl';
-import Icon from 'mastodon/components/icon';
-
-export default class ClearColumnButton extends React.PureComponent {
-
- static propTypes = {
- onClick: PropTypes.func.isRequired,
- };
-
- render () {
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/notifications/components/clear_column_button.jsx b/app/javascript/mastodon/features/notifications/components/clear_column_button.jsx
new file mode 100644
index 000000000..b82fd092f
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/clear_column_button.jsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+import Icon from 'mastodon/components/icon';
+
+export default class ClearColumnButton extends React.PureComponent {
+
+ static propTypes = {
+ onClick: PropTypes.func.isRequired,
+ };
+
+ render () {
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js
deleted file mode 100644
index 9251847ba..000000000
--- a/app/javascript/mastodon/features/notifications/components/column_settings.js
+++ /dev/null
@@ -1,202 +0,0 @@
-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 GrantPermissionButton from './grant_permission_button';
-import SettingToggle from './setting_toggle';
-import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_REPORTS } from 'mastodon/permissions';
-
-export default class ColumnSettings extends React.PureComponent {
-
- static contextTypes = {
- identity: PropTypes.object,
- };
-
- static propTypes = {
- settings: ImmutablePropTypes.map.isRequired,
- pushSettings: ImmutablePropTypes.map.isRequired,
- onChange: PropTypes.func.isRequired,
- onClear: PropTypes.func.isRequired,
- onRequestNotificationPermission: PropTypes.func,
- alertsEnabled: PropTypes.bool,
- browserSupport: PropTypes.bool,
- browserPermission: PropTypes.string,
- };
-
- onPushChange = (path, checked) => {
- this.props.onChange(['push', ...path], checked);
- };
-
- render () {
- const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission, onRequestNotificationPermission } = this.props;
-
- const unreadMarkersShowStr = ;
- const filterBarShowStr = ;
- const filterAdvancedStr = ;
- const alertStr = ;
- const showStr = ;
- const soundStr = ;
-
- const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
- const pushStr = showPushSettings && ;
-
- return (
-
- {alertsEnabled && browserSupport && browserPermission === 'denied' && (
-
-
-
- )}
-
- {alertsEnabled && browserSupport && browserPermission === 'default' && (
-
-
-
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {showPushSettings && }
-
-
-
-
-
-
-
-
-
-
- {showPushSettings && }
-
-
-
-
-
-
-
-
-
-
- {showPushSettings && }
-
-
-
-
-
-
-
-
-
-
- {showPushSettings && }
-
-
-
-
-
-
-
-
-
-
- {showPushSettings && }
-
-
-
-
-
-
-
-
-
-
- {showPushSettings && }
-
-
-
-
-
-
-
-
-
-
- {showPushSettings && }
-
-
-
-
-
-
-
-
-
-
- {showPushSettings && }
-
-
-
-
-
- {((this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) && (
-
-
-
-
-
- {showPushSettings && }
-
-
-
-
- )}
-
- {((this.context.identity.permissions & PERMISSION_MANAGE_REPORTS) === PERMISSION_MANAGE_REPORTS) && (
-
-
-
-
-
- {showPushSettings && }
-
-
-
-
- )}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.jsx b/app/javascript/mastodon/features/notifications/components/column_settings.jsx
new file mode 100644
index 000000000..9251847ba
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/column_settings.jsx
@@ -0,0 +1,202 @@
+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 GrantPermissionButton from './grant_permission_button';
+import SettingToggle from './setting_toggle';
+import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_REPORTS } from 'mastodon/permissions';
+
+export default class ColumnSettings extends React.PureComponent {
+
+ static contextTypes = {
+ identity: PropTypes.object,
+ };
+
+ static propTypes = {
+ settings: ImmutablePropTypes.map.isRequired,
+ pushSettings: ImmutablePropTypes.map.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onClear: PropTypes.func.isRequired,
+ onRequestNotificationPermission: PropTypes.func,
+ alertsEnabled: PropTypes.bool,
+ browserSupport: PropTypes.bool,
+ browserPermission: PropTypes.string,
+ };
+
+ onPushChange = (path, checked) => {
+ this.props.onChange(['push', ...path], checked);
+ };
+
+ render () {
+ const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission, onRequestNotificationPermission } = this.props;
+
+ const unreadMarkersShowStr = ;
+ const filterBarShowStr = ;
+ const filterAdvancedStr = ;
+ const alertStr = ;
+ const showStr = ;
+ const soundStr = ;
+
+ const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
+ const pushStr = showPushSettings && ;
+
+ return (
+
+ {alertsEnabled && browserSupport && browserPermission === 'denied' && (
+
+
+
+ )}
+
+ {alertsEnabled && browserSupport && browserPermission === 'default' && (
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {showPushSettings && }
+
+
+
+
+
+
+
+
+
+
+ {showPushSettings && }
+
+
+
+
+
+
+
+
+
+
+ {showPushSettings && }
+
+
+
+
+
+
+
+
+
+
+ {showPushSettings && }
+
+
+
+
+
+
+
+
+
+
+ {showPushSettings && }
+
+
+
+
+
+
+
+
+
+
+ {showPushSettings && }
+
+
+
+
+
+
+
+
+
+
+ {showPushSettings && }
+
+
+
+
+
+
+
+
+
+
+ {showPushSettings && }
+
+
+
+
+
+ {((this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) && (
+
+
+
+
+
+ {showPushSettings && }
+
+
+
+
+ )}
+
+ {((this.context.identity.permissions & PERMISSION_MANAGE_REPORTS) === PERMISSION_MANAGE_REPORTS) && (
+
+
+
+
+
+ {showPushSettings && }
+
+
+
+
+ )}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/notifications/components/filter_bar.js b/app/javascript/mastodon/features/notifications/components/filter_bar.js
deleted file mode 100644
index 368eb0b7e..000000000
--- a/app/javascript/mastodon/features/notifications/components/filter_bar.js
+++ /dev/null
@@ -1,110 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import Icon from 'mastodon/components/icon';
-
-const tooltips = defineMessages({
- mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
- favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' },
- boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
- polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
- follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
- statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
-});
-
-export default @injectIntl
-class FilterBar extends React.PureComponent {
-
- static propTypes = {
- selectFilter: PropTypes.func.isRequired,
- selectedFilter: PropTypes.string.isRequired,
- advancedMode: PropTypes.bool.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- onClick (notificationType) {
- return () => this.props.selectFilter(notificationType);
- }
-
- render () {
- const { selectedFilter, advancedMode, intl } = this.props;
- const renderedElement = !advancedMode ? (
-
-
-
-
-
-
-
-
- ) : (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- return renderedElement;
- }
-
-}
diff --git a/app/javascript/mastodon/features/notifications/components/filter_bar.jsx b/app/javascript/mastodon/features/notifications/components/filter_bar.jsx
new file mode 100644
index 000000000..368eb0b7e
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/filter_bar.jsx
@@ -0,0 +1,110 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Icon from 'mastodon/components/icon';
+
+const tooltips = defineMessages({
+ mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
+ favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' },
+ boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
+ polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
+ follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
+ statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
+});
+
+export default @injectIntl
+class FilterBar extends React.PureComponent {
+
+ static propTypes = {
+ selectFilter: PropTypes.func.isRequired,
+ selectedFilter: PropTypes.string.isRequired,
+ advancedMode: PropTypes.bool.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ onClick (notificationType) {
+ return () => this.props.selectFilter(notificationType);
+ }
+
+ render () {
+ const { selectedFilter, advancedMode, intl } = this.props;
+ const renderedElement = !advancedMode ? (
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ return renderedElement;
+ }
+
+}
diff --git a/app/javascript/mastodon/features/notifications/components/follow_request.js b/app/javascript/mastodon/features/notifications/components/follow_request.js
deleted file mode 100644
index 08de875e3..000000000
--- a/app/javascript/mastodon/features/notifications/components/follow_request.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import React, { Fragment } from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import Avatar from 'mastodon/components/avatar';
-import DisplayName from 'mastodon/components/display_name';
-import { Link } from 'react-router-dom';
-import IconButton from 'mastodon/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' },
-});
-
-export default @injectIntl
-class FollowRequest extends ImmutablePureComponent {
-
- static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
- onAuthorize: PropTypes.func.isRequired,
- onReject: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- render () {
- const { intl, hidden, account, onAuthorize, onReject } = this.props;
-
- if (!account) {
- return
;
- }
-
- if (hidden) {
- return (
-
- {account.get('display_name')}
- {account.get('username')}
-
- );
- }
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/notifications/components/follow_request.jsx b/app/javascript/mastodon/features/notifications/components/follow_request.jsx
new file mode 100644
index 000000000..08de875e3
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/follow_request.jsx
@@ -0,0 +1,59 @@
+import React, { Fragment } from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Avatar from 'mastodon/components/avatar';
+import DisplayName from 'mastodon/components/display_name';
+import { Link } from 'react-router-dom';
+import IconButton from 'mastodon/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' },
+});
+
+export default @injectIntl
+class FollowRequest extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ onAuthorize: PropTypes.func.isRequired,
+ onReject: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ render () {
+ const { intl, hidden, account, onAuthorize, onReject } = this.props;
+
+ if (!account) {
+ return
;
+ }
+
+ if (hidden) {
+ return (
+
+ {account.get('display_name')}
+ {account.get('username')}
+
+ );
+ }
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/notifications/components/grant_permission_button.js b/app/javascript/mastodon/features/notifications/components/grant_permission_button.js
deleted file mode 100644
index 798e4c787..000000000
--- a/app/javascript/mastodon/features/notifications/components/grant_permission_button.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { FormattedMessage } from 'react-intl';
-
-export default class GrantPermissionButton extends React.PureComponent {
-
- static propTypes = {
- onClick: PropTypes.func.isRequired,
- };
-
- render () {
- return (
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/notifications/components/grant_permission_button.jsx b/app/javascript/mastodon/features/notifications/components/grant_permission_button.jsx
new file mode 100644
index 000000000..798e4c787
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/grant_permission_button.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+
+export default class GrantPermissionButton extends React.PureComponent {
+
+ static propTypes = {
+ onClick: PropTypes.func.isRequired,
+ };
+
+ render () {
+ return (
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js
deleted file mode 100644
index 9e2517f08..000000000
--- a/app/javascript/mastodon/features/notifications/components/notification.js
+++ /dev/null
@@ -1,449 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
-import { HotKeys } from 'react-hotkeys';
-import PropTypes from 'prop-types';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { me } from 'mastodon/initial_state';
-import StatusContainer from 'mastodon/containers/status_container';
-import AccountContainer from 'mastodon/containers/account_container';
-import Report from './report';
-import FollowRequestContainer from '../containers/follow_request_container';
-import Icon from 'mastodon/components/icon';
-import { Link } from 'react-router-dom';
-import classNames from 'classnames';
-
-const messages = defineMessages({
- favourite: { id: 'notification.favourite', defaultMessage: '{name} favourited your status' },
- follow: { id: 'notification.follow', defaultMessage: '{name} followed you' },
- ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' },
- poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' },
- reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' },
- status: { id: 'notification.status', defaultMessage: '{name} just posted' },
- update: { id: 'notification.update', defaultMessage: '{name} edited a post' },
- adminSignUp: { id: 'notification.admin.sign_up', defaultMessage: '{name} signed up' },
- adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' },
-});
-
-const notificationForScreenReader = (intl, message, timestamp) => {
- const output = [message];
-
- output.push(intl.formatDate(timestamp, { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }));
-
- return output.join(', ');
-};
-
-export default @injectIntl
-class Notification extends ImmutablePureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- };
-
- static propTypes = {
- notification: ImmutablePropTypes.map.isRequired,
- hidden: PropTypes.bool,
- onMoveUp: PropTypes.func.isRequired,
- onMoveDown: PropTypes.func.isRequired,
- onMention: PropTypes.func.isRequired,
- onFavourite: PropTypes.func.isRequired,
- onReblog: PropTypes.func.isRequired,
- onToggleHidden: PropTypes.func.isRequired,
- status: ImmutablePropTypes.map,
- intl: PropTypes.object.isRequired,
- getScrollPosition: PropTypes.func,
- updateScrollBottom: PropTypes.func,
- cacheMediaWidth: PropTypes.func,
- cachedMediaWidth: PropTypes.number,
- unread: PropTypes.bool,
- };
-
- handleMoveUp = () => {
- const { notification, onMoveUp } = this.props;
- onMoveUp(notification.get('id'));
- };
-
- handleMoveDown = () => {
- const { notification, onMoveDown } = this.props;
- onMoveDown(notification.get('id'));
- };
-
- handleOpen = () => {
- const { notification } = this.props;
-
- if (notification.get('status')) {
- this.context.router.history.push(`/@${notification.getIn(['status', 'account', 'acct'])}/${notification.get('status')}`);
- } else {
- this.handleOpenProfile();
- }
- };
-
- handleOpenProfile = () => {
- const { notification } = this.props;
- this.context.router.history.push(`/@${notification.getIn(['account', 'acct'])}`);
- };
-
- handleMention = e => {
- e.preventDefault();
-
- const { notification, onMention } = this.props;
- onMention(notification.get('account'), this.context.router.history);
- };
-
- handleHotkeyFavourite = () => {
- const { status } = this.props;
- if (status) this.props.onFavourite(status);
- };
-
- handleHotkeyBoost = e => {
- const { status } = this.props;
- if (status) this.props.onReblog(status, e);
- };
-
- handleHotkeyToggleHidden = () => {
- const { status } = this.props;
- if (status) this.props.onToggleHidden(status);
- };
-
- getHandlers () {
- return {
- reply: this.handleMention,
- favourite: this.handleHotkeyFavourite,
- boost: this.handleHotkeyBoost,
- mention: this.handleMention,
- open: this.handleOpen,
- openProfile: this.handleOpenProfile,
- moveUp: this.handleMoveUp,
- moveDown: this.handleMoveDown,
- toggleHidden: this.handleHotkeyToggleHidden,
- };
- }
-
- renderFollow (notification, account, link) {
- const { intl, unread } = this.props;
-
- return (
-
-
-
- );
- }
-
- renderFollowRequest (notification, account, link) {
- const { intl, unread } = this.props;
-
- return (
-
-
-
- );
- }
-
- renderMention (notification) {
- return (
-
- );
- }
-
- renderFavourite (notification, link) {
- const { intl, unread } = this.props;
-
- return (
-
-
-
- );
- }
-
- renderReblog (notification, link) {
- const { intl, unread } = this.props;
-
- return (
-
-
-
- );
- }
-
- renderStatus (notification, link) {
- const { intl, unread, status } = this.props;
-
- if (!status) {
- return null;
- }
-
- return (
-
-
-
- );
- }
-
- renderUpdate (notification, link) {
- const { intl, unread, status } = this.props;
-
- if (!status) {
- return null;
- }
-
- return (
-
-
-
- );
- }
-
- renderPoll (notification, account) {
- const { intl, unread, status } = this.props;
- const ownPoll = me === account.get('id');
- const message = ownPoll ? intl.formatMessage(messages.ownPoll) : intl.formatMessage(messages.poll);
-
- if (!status) {
- return null;
- }
-
- return (
-
-
-
-
-
-
-
-
- {ownPoll ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
- );
- }
-
- renderAdminSignUp (notification, account, link) {
- const { intl, unread } = this.props;
-
- return (
-
-
-
- );
- }
-
- renderAdminReport (notification, account, link) {
- const { intl, unread, report } = this.props;
-
- if (!report) {
- return null;
- }
-
- const targetAccount = report.get('target_account');
- const targetDisplayNameHtml = { __html: targetAccount.get('display_name_html') };
- const targetLink = ;
-
- return (
-
-
-
- );
- }
-
- render () {
- const { notification } = this.props;
- const account = notification.get('account');
- const displayNameHtml = { __html: account.get('display_name_html') };
- const link = ;
-
- switch(notification.get('type')) {
- case 'follow':
- return this.renderFollow(notification, account, link);
- case 'follow_request':
- return this.renderFollowRequest(notification, account, link);
- case 'mention':
- return this.renderMention(notification);
- case 'favourite':
- return this.renderFavourite(notification, link);
- case 'reblog':
- return this.renderReblog(notification, link);
- case 'status':
- return this.renderStatus(notification, link);
- case 'update':
- return this.renderUpdate(notification, link);
- case 'poll':
- return this.renderPoll(notification, account);
- case 'admin.sign_up':
- return this.renderAdminSignUp(notification, account, link);
- case 'admin.report':
- return this.renderAdminReport(notification, account, link);
- }
-
- return null;
- }
-
-}
diff --git a/app/javascript/mastodon/features/notifications/components/notification.jsx b/app/javascript/mastodon/features/notifications/components/notification.jsx
new file mode 100644
index 000000000..9e2517f08
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/notification.jsx
@@ -0,0 +1,449 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
+import { HotKeys } from 'react-hotkeys';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { me } from 'mastodon/initial_state';
+import StatusContainer from 'mastodon/containers/status_container';
+import AccountContainer from 'mastodon/containers/account_container';
+import Report from './report';
+import FollowRequestContainer from '../containers/follow_request_container';
+import Icon from 'mastodon/components/icon';
+import { Link } from 'react-router-dom';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+ favourite: { id: 'notification.favourite', defaultMessage: '{name} favourited your status' },
+ follow: { id: 'notification.follow', defaultMessage: '{name} followed you' },
+ ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' },
+ poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' },
+ reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' },
+ status: { id: 'notification.status', defaultMessage: '{name} just posted' },
+ update: { id: 'notification.update', defaultMessage: '{name} edited a post' },
+ adminSignUp: { id: 'notification.admin.sign_up', defaultMessage: '{name} signed up' },
+ adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' },
+});
+
+const notificationForScreenReader = (intl, message, timestamp) => {
+ const output = [message];
+
+ output.push(intl.formatDate(timestamp, { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }));
+
+ return output.join(', ');
+};
+
+export default @injectIntl
+class Notification extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ notification: ImmutablePropTypes.map.isRequired,
+ hidden: PropTypes.bool,
+ onMoveUp: PropTypes.func.isRequired,
+ onMoveDown: PropTypes.func.isRequired,
+ onMention: PropTypes.func.isRequired,
+ onFavourite: PropTypes.func.isRequired,
+ onReblog: PropTypes.func.isRequired,
+ onToggleHidden: PropTypes.func.isRequired,
+ status: ImmutablePropTypes.map,
+ intl: PropTypes.object.isRequired,
+ getScrollPosition: PropTypes.func,
+ updateScrollBottom: PropTypes.func,
+ cacheMediaWidth: PropTypes.func,
+ cachedMediaWidth: PropTypes.number,
+ unread: PropTypes.bool,
+ };
+
+ handleMoveUp = () => {
+ const { notification, onMoveUp } = this.props;
+ onMoveUp(notification.get('id'));
+ };
+
+ handleMoveDown = () => {
+ const { notification, onMoveDown } = this.props;
+ onMoveDown(notification.get('id'));
+ };
+
+ handleOpen = () => {
+ const { notification } = this.props;
+
+ if (notification.get('status')) {
+ this.context.router.history.push(`/@${notification.getIn(['status', 'account', 'acct'])}/${notification.get('status')}`);
+ } else {
+ this.handleOpenProfile();
+ }
+ };
+
+ handleOpenProfile = () => {
+ const { notification } = this.props;
+ this.context.router.history.push(`/@${notification.getIn(['account', 'acct'])}`);
+ };
+
+ handleMention = e => {
+ e.preventDefault();
+
+ const { notification, onMention } = this.props;
+ onMention(notification.get('account'), this.context.router.history);
+ };
+
+ handleHotkeyFavourite = () => {
+ const { status } = this.props;
+ if (status) this.props.onFavourite(status);
+ };
+
+ handleHotkeyBoost = e => {
+ const { status } = this.props;
+ if (status) this.props.onReblog(status, e);
+ };
+
+ handleHotkeyToggleHidden = () => {
+ const { status } = this.props;
+ if (status) this.props.onToggleHidden(status);
+ };
+
+ getHandlers () {
+ return {
+ reply: this.handleMention,
+ favourite: this.handleHotkeyFavourite,
+ boost: this.handleHotkeyBoost,
+ mention: this.handleMention,
+ open: this.handleOpen,
+ openProfile: this.handleOpenProfile,
+ moveUp: this.handleMoveUp,
+ moveDown: this.handleMoveDown,
+ toggleHidden: this.handleHotkeyToggleHidden,
+ };
+ }
+
+ renderFollow (notification, account, link) {
+ const { intl, unread } = this.props;
+
+ return (
+
+
+
+ );
+ }
+
+ renderFollowRequest (notification, account, link) {
+ const { intl, unread } = this.props;
+
+ return (
+
+
+
+ );
+ }
+
+ renderMention (notification) {
+ return (
+
+ );
+ }
+
+ renderFavourite (notification, link) {
+ const { intl, unread } = this.props;
+
+ return (
+
+
+
+ );
+ }
+
+ renderReblog (notification, link) {
+ const { intl, unread } = this.props;
+
+ return (
+
+
+
+ );
+ }
+
+ renderStatus (notification, link) {
+ const { intl, unread, status } = this.props;
+
+ if (!status) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+ }
+
+ renderUpdate (notification, link) {
+ const { intl, unread, status } = this.props;
+
+ if (!status) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+ }
+
+ renderPoll (notification, account) {
+ const { intl, unread, status } = this.props;
+ const ownPoll = me === account.get('id');
+ const message = ownPoll ? intl.formatMessage(messages.ownPoll) : intl.formatMessage(messages.poll);
+
+ if (!status) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {ownPoll ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ );
+ }
+
+ renderAdminSignUp (notification, account, link) {
+ const { intl, unread } = this.props;
+
+ return (
+
+
+
+ );
+ }
+
+ renderAdminReport (notification, account, link) {
+ const { intl, unread, report } = this.props;
+
+ if (!report) {
+ return null;
+ }
+
+ const targetAccount = report.get('target_account');
+ const targetDisplayNameHtml = { __html: targetAccount.get('display_name_html') };
+ const targetLink = ;
+
+ return (
+
+
+
+ );
+ }
+
+ render () {
+ const { notification } = this.props;
+ const account = notification.get('account');
+ const displayNameHtml = { __html: account.get('display_name_html') };
+ const link = ;
+
+ switch(notification.get('type')) {
+ case 'follow':
+ return this.renderFollow(notification, account, link);
+ case 'follow_request':
+ return this.renderFollowRequest(notification, account, link);
+ case 'mention':
+ return this.renderMention(notification);
+ case 'favourite':
+ return this.renderFavourite(notification, link);
+ case 'reblog':
+ return this.renderReblog(notification, link);
+ case 'status':
+ return this.renderStatus(notification, link);
+ case 'update':
+ return this.renderUpdate(notification, link);
+ case 'poll':
+ return this.renderPoll(notification, account);
+ case 'admin.sign_up':
+ return this.renderAdminSignUp(notification, account, link);
+ case 'admin.report':
+ return this.renderAdminReport(notification, account, link);
+ }
+
+ return null;
+ }
+
+}
diff --git a/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js b/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js
deleted file mode 100644
index 3a7556c1d..000000000
--- a/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import React from 'react';
-import Icon from 'mastodon/components/icon';
-import Button from 'mastodon/components/button';
-import IconButton from 'mastodon/components/icon_button';
-import { requestBrowserPermission } from 'mastodon/actions/notifications';
-import { changeSetting } from 'mastodon/actions/settings';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-
-const messages = defineMessages({
- close: { id: 'lightbox.close', defaultMessage: 'Close' },
-});
-
-export default @connect()
-@injectIntl
-class NotificationsPermissionBanner extends React.PureComponent {
-
- static propTypes = {
- dispatch: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- handleClick = () => {
- this.props.dispatch(requestBrowserPermission());
- };
-
- handleClose = () => {
- this.props.dispatch(changeSetting(['notifications', 'dismissPermissionBanner'], true));
- };
-
- render () {
- const { intl } = this.props;
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.jsx b/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.jsx
new file mode 100644
index 000000000..3a7556c1d
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.jsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import Icon from 'mastodon/components/icon';
+import Button from 'mastodon/components/button';
+import IconButton from 'mastodon/components/icon_button';
+import { requestBrowserPermission } from 'mastodon/actions/notifications';
+import { changeSetting } from 'mastodon/actions/settings';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+
+const messages = defineMessages({
+ close: { id: 'lightbox.close', defaultMessage: 'Close' },
+});
+
+export default @connect()
+@injectIntl
+class NotificationsPermissionBanner extends React.PureComponent {
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleClick = () => {
+ this.props.dispatch(requestBrowserPermission());
+ };
+
+ handleClose = () => {
+ this.props.dispatch(changeSetting(['notifications', 'dismissPermissionBanner'], true));
+ };
+
+ render () {
+ const { intl } = this.props;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/notifications/components/report.js b/app/javascript/mastodon/features/notifications/components/report.js
deleted file mode 100644
index 3ce3eb9d3..000000000
--- a/app/javascript/mastodon/features/notifications/components/report.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import React, { Fragment } from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import AvatarOverlay from 'mastodon/components/avatar_overlay';
-import RelativeTimestamp from 'mastodon/components/relative_timestamp';
-
-const messages = defineMessages({
- openReport: { id: 'report_notification.open', defaultMessage: 'Open report' },
- other: { id: 'report_notification.categories.other', defaultMessage: 'Other' },
- spam: { id: 'report_notification.categories.spam', defaultMessage: 'Spam' },
- violation: { id: 'report_notification.categories.violation', defaultMessage: 'Rule violation' },
-});
-
-export default @injectIntl
-class Report extends ImmutablePureComponent {
-
- static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
- report: ImmutablePropTypes.map.isRequired,
- hidden: PropTypes.bool,
- intl: PropTypes.object.isRequired,
- };
-
- render () {
- const { intl, hidden, report, account } = this.props;
-
- if (!report) {
- return null;
- }
-
- if (hidden) {
- return (
-
- {report.get('id')}
-
- );
- }
-
- return (
-
-
-
-
-
- ·
-
- {intl.formatMessage(messages[report.get('category')])}
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/notifications/components/report.jsx b/app/javascript/mastodon/features/notifications/components/report.jsx
new file mode 100644
index 000000000..3ce3eb9d3
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/report.jsx
@@ -0,0 +1,62 @@
+import React, { Fragment } from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import AvatarOverlay from 'mastodon/components/avatar_overlay';
+import RelativeTimestamp from 'mastodon/components/relative_timestamp';
+
+const messages = defineMessages({
+ openReport: { id: 'report_notification.open', defaultMessage: 'Open report' },
+ other: { id: 'report_notification.categories.other', defaultMessage: 'Other' },
+ spam: { id: 'report_notification.categories.spam', defaultMessage: 'Spam' },
+ violation: { id: 'report_notification.categories.violation', defaultMessage: 'Rule violation' },
+});
+
+export default @injectIntl
+class Report extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ report: ImmutablePropTypes.map.isRequired,
+ hidden: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ };
+
+ render () {
+ const { intl, hidden, report, account } = this.props;
+
+ if (!report) {
+ return null;
+ }
+
+ if (hidden) {
+ return (
+
+ {report.get('id')}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ ·
+
+ {intl.formatMessage(messages[report.get('category')])}
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.js b/app/javascript/mastodon/features/notifications/components/setting_toggle.js
deleted file mode 100644
index c979e4383..000000000
--- a/app/javascript/mastodon/features/notifications/components/setting_toggle.js
+++ /dev/null
@@ -1,34 +0,0 @@
-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,
- settingPath: PropTypes.array.isRequired,
- label: PropTypes.node.isRequired,
- onChange: PropTypes.func.isRequired,
- defaultValue: PropTypes.bool,
- disabled: PropTypes.bool,
- };
-
- onChange = ({ target }) => {
- this.props.onChange(this.props.settingPath, target.checked);
- };
-
- render () {
- const { prefix, settings, settingPath, label, defaultValue, disabled } = this.props;
- const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-');
-
- return (
-
-
- {label}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.jsx b/app/javascript/mastodon/features/notifications/components/setting_toggle.jsx
new file mode 100644
index 000000000..c979e4383
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/setting_toggle.jsx
@@ -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,
+ settingPath: PropTypes.array.isRequired,
+ label: PropTypes.node.isRequired,
+ onChange: PropTypes.func.isRequired,
+ defaultValue: PropTypes.bool,
+ disabled: PropTypes.bool,
+ };
+
+ onChange = ({ target }) => {
+ this.props.onChange(this.props.settingPath, target.checked);
+ };
+
+ render () {
+ const { prefix, settings, settingPath, label, defaultValue, disabled } = this.props;
+ const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-');
+
+ return (
+
+
+ {label}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js
deleted file mode 100644
index fee016a02..000000000
--- a/app/javascript/mastodon/features/notifications/index.js
+++ /dev/null
@@ -1,290 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import Column from '../../components/column';
-import ColumnHeader from '../../components/column_header';
-import {
- expandNotifications,
- scrollTopNotifications,
- loadPending,
- mountNotifications,
- unmountNotifications,
- markNotificationsAsRead,
-} from '../../actions/notifications';
-import { submitMarkers } from '../../actions/markers';
-import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
-import NotificationContainer from './containers/notification_container';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import ColumnSettingsContainer from './containers/column_settings_container';
-import FilterBarContainer from './containers/filter_bar_container';
-import { createSelector } from 'reselect';
-import { List as ImmutableList } from 'immutable';
-import { debounce } from 'lodash';
-import ScrollableList from '../../components/scrollable_list';
-import LoadGap from '../../components/load_gap';
-import Icon from 'mastodon/components/icon';
-import compareId from 'mastodon/compare_id';
-import NotificationsPermissionBanner from './components/notifications_permission_banner';
-import NotSignedInIndicator from 'mastodon/components/not_signed_in_indicator';
-import { Helmet } from 'react-helmet';
-
-const messages = defineMessages({
- title: { id: 'column.notifications', defaultMessage: 'Notifications' },
- markAsRead : { id: 'notifications.mark_as_read', defaultMessage: 'Mark every notification as read' },
-});
-
-const getExcludedTypes = createSelector([
- state => state.getIn(['settings', 'notifications', 'shows']),
-], (shows) => {
- return ImmutableList(shows.filter(item => !item).keys());
-});
-
-const getNotifications = createSelector([
- state => state.getIn(['settings', 'notifications', 'quickFilter', 'show']),
- state => state.getIn(['settings', 'notifications', 'quickFilter', 'active']),
- getExcludedTypes,
- state => state.getIn(['notifications', 'items']),
-], (showFilterBar, allowedType, excludedTypes, notifications) => {
- if (!showFilterBar || allowedType === 'all') {
- // used if user changed the notification settings after loading the notifications from the server
- // otherwise a list of notifications will come pre-filtered from the backend
- // we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
- return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')));
- }
- return notifications.filter(item => item === null || allowedType === item.get('type'));
-});
-
-const mapStateToProps = state => ({
- showFilterBar: state.getIn(['settings', 'notifications', 'quickFilter', 'show']),
- notifications: getNotifications(state),
- isLoading: state.getIn(['notifications', 'isLoading'], 0) > 0,
- isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0,
- hasMore: state.getIn(['notifications', 'hasMore']),
- numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
- lastReadId: state.getIn(['settings', 'notifications', 'showUnread']) ? state.getIn(['notifications', 'readMarkerId']) : '0',
- canMarkAsRead: state.getIn(['settings', 'notifications', 'showUnread']) && state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
- needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default' && !state.getIn(['settings', 'notifications', 'dismissPermissionBanner']),
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-class Notifications extends React.PureComponent {
-
- static contextTypes = {
- identity: PropTypes.object,
- };
-
- static propTypes = {
- columnId: PropTypes.string,
- notifications: ImmutablePropTypes.list.isRequired,
- showFilterBar: PropTypes.bool.isRequired,
- dispatch: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- isLoading: PropTypes.bool,
- isUnread: PropTypes.bool,
- multiColumn: PropTypes.bool,
- hasMore: PropTypes.bool,
- numPending: PropTypes.number,
- lastReadId: PropTypes.string,
- canMarkAsRead: PropTypes.bool,
- needsNotificationPermission: PropTypes.bool,
- };
-
- static defaultProps = {
- trackScroll: true,
- };
-
- componentWillMount() {
- this.props.dispatch(mountNotifications());
- }
-
- componentWillUnmount () {
- this.handleLoadOlder.cancel();
- this.handleScrollToTop.cancel();
- this.handleScroll.cancel();
- this.props.dispatch(scrollTopNotifications(false));
- this.props.dispatch(unmountNotifications());
- }
-
- handleLoadGap = (maxId) => {
- this.props.dispatch(expandNotifications({ maxId }));
- };
-
- handleLoadOlder = debounce(() => {
- const last = this.props.notifications.last();
- this.props.dispatch(expandNotifications({ maxId: last && last.get('id') }));
- }, 300, { leading: true });
-
- handleLoadPending = () => {
- this.props.dispatch(loadPending());
- };
-
- 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 !== null && item.get('id') === id) - 1;
- this._selectChild(elementIndex, true);
- };
-
- handleMoveDown = id => {
- const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
- this._selectChild(elementIndex, false);
- };
-
- _selectChild (index, align_top) {
- const container = this.column.node;
- const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
-
- if (element) {
- if (align_top && container.scrollTop > element.offsetTop) {
- element.scrollIntoView(true);
- } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
- element.scrollIntoView(false);
- }
- element.focus();
- }
- }
-
- handleMarkAsRead = () => {
- this.props.dispatch(markNotificationsAsRead());
- this.props.dispatch(submitMarkers({ immediate: true }));
- };
-
- render () {
- const { intl, notifications, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
- const pinned = !!columnId;
- const emptyMessage = ;
- const { signedIn } = this.context.identity;
-
- let scrollableContent = null;
-
- const filterBarContainer = (signedIn && showFilterBar)
- ? ( )
- : null;
-
- if (isLoading && this.scrollableContent) {
- scrollableContent = this.scrollableContent;
- } else if (notifications.size > 0 || hasMore) {
- scrollableContent = notifications.map((item, index) => item === null ? (
- 0 ? notifications.getIn([index - 1, 'id']) : null}
- onClick={this.handleLoadGap}
- />
- ) : (
- 0}
- />
- ));
- } else {
- scrollableContent = null;
- }
-
- this.scrollableContent = scrollableContent;
-
- let scrollContainer;
-
- if (signedIn) {
- scrollContainer = (
- }
- alwaysPrepend
- emptyMessage={emptyMessage}
- onLoadMore={this.handleLoadOlder}
- onLoadPending={this.handleLoadPending}
- onScrollToTop={this.handleScrollToTop}
- onScroll={this.handleScroll}
- bindToDocument={!multiColumn}
- >
- {scrollableContent}
-
- );
- } else {
- scrollContainer = ;
- }
-
- let extraButton = null;
-
- if (canMarkAsRead) {
- extraButton = (
-
-
-
- );
- }
-
- return (
-
-
-
-
-
- {filterBarContainer}
- {scrollContainer}
-
-
- {intl.formatMessage(messages.title)}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/notifications/index.jsx b/app/javascript/mastodon/features/notifications/index.jsx
new file mode 100644
index 000000000..fee016a02
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/index.jsx
@@ -0,0 +1,290 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Column from '../../components/column';
+import ColumnHeader from '../../components/column_header';
+import {
+ expandNotifications,
+ scrollTopNotifications,
+ loadPending,
+ mountNotifications,
+ unmountNotifications,
+ markNotificationsAsRead,
+} from '../../actions/notifications';
+import { submitMarkers } from '../../actions/markers';
+import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
+import NotificationContainer from './containers/notification_container';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import FilterBarContainer from './containers/filter_bar_container';
+import { createSelector } from 'reselect';
+import { List as ImmutableList } from 'immutable';
+import { debounce } from 'lodash';
+import ScrollableList from '../../components/scrollable_list';
+import LoadGap from '../../components/load_gap';
+import Icon from 'mastodon/components/icon';
+import compareId from 'mastodon/compare_id';
+import NotificationsPermissionBanner from './components/notifications_permission_banner';
+import NotSignedInIndicator from 'mastodon/components/not_signed_in_indicator';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+ title: { id: 'column.notifications', defaultMessage: 'Notifications' },
+ markAsRead : { id: 'notifications.mark_as_read', defaultMessage: 'Mark every notification as read' },
+});
+
+const getExcludedTypes = createSelector([
+ state => state.getIn(['settings', 'notifications', 'shows']),
+], (shows) => {
+ return ImmutableList(shows.filter(item => !item).keys());
+});
+
+const getNotifications = createSelector([
+ state => state.getIn(['settings', 'notifications', 'quickFilter', 'show']),
+ state => state.getIn(['settings', 'notifications', 'quickFilter', 'active']),
+ getExcludedTypes,
+ state => state.getIn(['notifications', 'items']),
+], (showFilterBar, allowedType, excludedTypes, notifications) => {
+ if (!showFilterBar || allowedType === 'all') {
+ // used if user changed the notification settings after loading the notifications from the server
+ // otherwise a list of notifications will come pre-filtered from the backend
+ // we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
+ return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')));
+ }
+ return notifications.filter(item => item === null || allowedType === item.get('type'));
+});
+
+const mapStateToProps = state => ({
+ showFilterBar: state.getIn(['settings', 'notifications', 'quickFilter', 'show']),
+ notifications: getNotifications(state),
+ isLoading: state.getIn(['notifications', 'isLoading'], 0) > 0,
+ isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0,
+ hasMore: state.getIn(['notifications', 'hasMore']),
+ numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
+ lastReadId: state.getIn(['settings', 'notifications', 'showUnread']) ? state.getIn(['notifications', 'readMarkerId']) : '0',
+ canMarkAsRead: state.getIn(['settings', 'notifications', 'showUnread']) && state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
+ needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default' && !state.getIn(['settings', 'notifications', 'dismissPermissionBanner']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Notifications extends React.PureComponent {
+
+ static contextTypes = {
+ identity: PropTypes.object,
+ };
+
+ static propTypes = {
+ columnId: PropTypes.string,
+ notifications: ImmutablePropTypes.list.isRequired,
+ showFilterBar: PropTypes.bool.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ isLoading: PropTypes.bool,
+ isUnread: PropTypes.bool,
+ multiColumn: PropTypes.bool,
+ hasMore: PropTypes.bool,
+ numPending: PropTypes.number,
+ lastReadId: PropTypes.string,
+ canMarkAsRead: PropTypes.bool,
+ needsNotificationPermission: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ trackScroll: true,
+ };
+
+ componentWillMount() {
+ this.props.dispatch(mountNotifications());
+ }
+
+ componentWillUnmount () {
+ this.handleLoadOlder.cancel();
+ this.handleScrollToTop.cancel();
+ this.handleScroll.cancel();
+ this.props.dispatch(scrollTopNotifications(false));
+ this.props.dispatch(unmountNotifications());
+ }
+
+ handleLoadGap = (maxId) => {
+ this.props.dispatch(expandNotifications({ maxId }));
+ };
+
+ handleLoadOlder = debounce(() => {
+ const last = this.props.notifications.last();
+ this.props.dispatch(expandNotifications({ maxId: last && last.get('id') }));
+ }, 300, { leading: true });
+
+ handleLoadPending = () => {
+ this.props.dispatch(loadPending());
+ };
+
+ 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 !== null && item.get('id') === id) - 1;
+ this._selectChild(elementIndex, true);
+ };
+
+ handleMoveDown = id => {
+ const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
+ this._selectChild(elementIndex, false);
+ };
+
+ _selectChild (index, align_top) {
+ const container = this.column.node;
+ const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+
+ if (element) {
+ if (align_top && container.scrollTop > element.offsetTop) {
+ element.scrollIntoView(true);
+ } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
+ element.scrollIntoView(false);
+ }
+ element.focus();
+ }
+ }
+
+ handleMarkAsRead = () => {
+ this.props.dispatch(markNotificationsAsRead());
+ this.props.dispatch(submitMarkers({ immediate: true }));
+ };
+
+ render () {
+ const { intl, notifications, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
+ const pinned = !!columnId;
+ const emptyMessage = ;
+ const { signedIn } = this.context.identity;
+
+ let scrollableContent = null;
+
+ const filterBarContainer = (signedIn && showFilterBar)
+ ? ( )
+ : null;
+
+ if (isLoading && this.scrollableContent) {
+ scrollableContent = this.scrollableContent;
+ } else if (notifications.size > 0 || hasMore) {
+ scrollableContent = notifications.map((item, index) => item === null ? (
+ 0 ? notifications.getIn([index - 1, 'id']) : null}
+ onClick={this.handleLoadGap}
+ />
+ ) : (
+ 0}
+ />
+ ));
+ } else {
+ scrollableContent = null;
+ }
+
+ this.scrollableContent = scrollableContent;
+
+ let scrollContainer;
+
+ if (signedIn) {
+ scrollContainer = (
+ }
+ alwaysPrepend
+ emptyMessage={emptyMessage}
+ onLoadMore={this.handleLoadOlder}
+ onLoadPending={this.handleLoadPending}
+ onScrollToTop={this.handleScrollToTop}
+ onScroll={this.handleScroll}
+ bindToDocument={!multiColumn}
+ >
+ {scrollableContent}
+
+ );
+ } else {
+ scrollContainer = ;
+ }
+
+ let extraButton = null;
+
+ if (canMarkAsRead) {
+ extraButton = (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {filterBarContainer}
+ {scrollContainer}
+
+
+ {intl.formatMessage(messages.title)}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/picture_in_picture/components/footer.js b/app/javascript/mastodon/features/picture_in_picture/components/footer.js
deleted file mode 100644
index 0ee6d06c7..000000000
--- a/app/javascript/mastodon/features/picture_in_picture/components/footer.js
+++ /dev/null
@@ -1,192 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import IconButton from 'mastodon/components/icon_button';
-import classNames from 'classnames';
-import { me, boostModal } from 'mastodon/initial_state';
-import { defineMessages, injectIntl } from 'react-intl';
-import { replyCompose } from 'mastodon/actions/compose';
-import { reblog, favourite, unreblog, unfavourite } from 'mastodon/actions/interactions';
-import { makeGetStatus } from 'mastodon/selectors';
-import { initBoostModal } from 'mastodon/actions/boosts';
-import { openModal } from 'mastodon/actions/modal';
-
-const messages = defineMessages({
- reply: { id: 'status.reply', defaultMessage: 'Reply' },
- replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
- reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
- reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
- cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
- cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
- favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
- replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
- replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
- open: { id: 'status.open', defaultMessage: 'Expand this status' },
-});
-
-const makeMapStateToProps = () => {
- const getStatus = makeGetStatus();
-
- const mapStateToProps = (state, { statusId }) => ({
- status: getStatus(state, { id: statusId }),
- askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
- });
-
- return mapStateToProps;
-};
-
-export default @connect(makeMapStateToProps)
-@injectIntl
-class Footer extends ImmutablePureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- identity: PropTypes.object,
- };
-
- static propTypes = {
- statusId: PropTypes.string.isRequired,
- status: ImmutablePropTypes.map.isRequired,
- intl: PropTypes.object.isRequired,
- dispatch: PropTypes.func.isRequired,
- askReplyConfirmation: PropTypes.bool,
- withOpenButton: PropTypes.bool,
- onClose: PropTypes.func,
- };
-
- _performReply = () => {
- const { dispatch, status, onClose } = this.props;
- const { router } = this.context;
-
- if (onClose) {
- onClose(true);
- }
-
- dispatch(replyCompose(status, router.history));
- };
-
- handleReplyClick = () => {
- const { dispatch, askReplyConfirmation, status, intl } = this.props;
- const { signedIn } = this.context.identity;
-
- if (signedIn) {
- if (askReplyConfirmation) {
- dispatch(openModal('CONFIRM', {
- message: intl.formatMessage(messages.replyMessage),
- confirm: intl.formatMessage(messages.replyConfirm),
- onConfirm: this._performReply,
- }));
- } else {
- this._performReply();
- }
- } else {
- dispatch(openModal('INTERACTION', {
- type: 'reply',
- accountId: status.getIn(['account', 'id']),
- url: status.get('url'),
- }));
- }
- };
-
- handleFavouriteClick = () => {
- const { dispatch, status } = this.props;
- const { signedIn } = this.context.identity;
-
- if (signedIn) {
- if (status.get('favourited')) {
- dispatch(unfavourite(status));
- } else {
- dispatch(favourite(status));
- }
- } else {
- dispatch(openModal('INTERACTION', {
- type: 'favourite',
- accountId: status.getIn(['account', 'id']),
- url: status.get('url'),
- }));
- }
- };
-
- _performReblog = (status, privacy) => {
- const { dispatch } = this.props;
- dispatch(reblog(status, privacy));
- };
-
- handleReblogClick = e => {
- const { dispatch, status } = this.props;
- const { signedIn } = this.context.identity;
-
- if (signedIn) {
- if (status.get('reblogged')) {
- dispatch(unreblog(status));
- } else if ((e && e.shiftKey) || !boostModal) {
- this._performReblog(status);
- } else {
- dispatch(initBoostModal({ status, onReblog: this._performReblog }));
- }
- } else {
- dispatch(openModal('INTERACTION', {
- type: 'reblog',
- accountId: status.getIn(['account', 'id']),
- url: status.get('url'),
- }));
- }
- };
-
- handleOpenClick = e => {
- const { router } = this.context;
-
- if (e.button !== 0 || !router) {
- return;
- }
-
- const { status, onClose } = this.props;
-
- if (onClose) {
- onClose();
- }
-
- router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
- };
-
- render () {
- const { status, intl, withOpenButton } = this.props;
-
- const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
- const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
-
- let replyIcon, replyTitle;
-
- if (status.get('in_reply_to_id', null) === null) {
- replyIcon = 'reply';
- replyTitle = intl.formatMessage(messages.reply);
- } else {
- replyIcon = 'reply-all';
- replyTitle = intl.formatMessage(messages.replyAll);
- }
-
- let reblogTitle = '';
-
- if (status.get('reblogged')) {
- reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
- } else if (publicStatus) {
- reblogTitle = intl.formatMessage(messages.reblog);
- } else if (reblogPrivate) {
- reblogTitle = intl.formatMessage(messages.reblog_private);
- } else {
- reblogTitle = intl.formatMessage(messages.cannot_reblog);
- }
-
- return (
-
-
-
-
- {withOpenButton && }
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx b/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx
new file mode 100644
index 000000000..0ee6d06c7
--- /dev/null
+++ b/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx
@@ -0,0 +1,192 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from 'mastodon/components/icon_button';
+import classNames from 'classnames';
+import { me, boostModal } from 'mastodon/initial_state';
+import { defineMessages, injectIntl } from 'react-intl';
+import { replyCompose } from 'mastodon/actions/compose';
+import { reblog, favourite, unreblog, unfavourite } from 'mastodon/actions/interactions';
+import { makeGetStatus } from 'mastodon/selectors';
+import { initBoostModal } from 'mastodon/actions/boosts';
+import { openModal } from 'mastodon/actions/modal';
+
+const messages = defineMessages({
+ reply: { id: 'status.reply', defaultMessage: 'Reply' },
+ replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
+ reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+ reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
+ cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
+ cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+ favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+ replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
+ replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+ open: { id: 'status.open', defaultMessage: 'Expand this status' },
+});
+
+const makeMapStateToProps = () => {
+ const getStatus = makeGetStatus();
+
+ const mapStateToProps = (state, { statusId }) => ({
+ status: getStatus(state, { id: statusId }),
+ askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
+ });
+
+ return mapStateToProps;
+};
+
+export default @connect(makeMapStateToProps)
+@injectIntl
+class Footer extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ identity: PropTypes.object,
+ };
+
+ static propTypes = {
+ statusId: PropTypes.string.isRequired,
+ status: ImmutablePropTypes.map.isRequired,
+ intl: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ askReplyConfirmation: PropTypes.bool,
+ withOpenButton: PropTypes.bool,
+ onClose: PropTypes.func,
+ };
+
+ _performReply = () => {
+ const { dispatch, status, onClose } = this.props;
+ const { router } = this.context;
+
+ if (onClose) {
+ onClose(true);
+ }
+
+ dispatch(replyCompose(status, router.history));
+ };
+
+ handleReplyClick = () => {
+ const { dispatch, askReplyConfirmation, status, intl } = this.props;
+ const { signedIn } = this.context.identity;
+
+ if (signedIn) {
+ if (askReplyConfirmation) {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.replyMessage),
+ confirm: intl.formatMessage(messages.replyConfirm),
+ onConfirm: this._performReply,
+ }));
+ } else {
+ this._performReply();
+ }
+ } else {
+ dispatch(openModal('INTERACTION', {
+ type: 'reply',
+ accountId: status.getIn(['account', 'id']),
+ url: status.get('url'),
+ }));
+ }
+ };
+
+ handleFavouriteClick = () => {
+ const { dispatch, status } = this.props;
+ const { signedIn } = this.context.identity;
+
+ if (signedIn) {
+ if (status.get('favourited')) {
+ dispatch(unfavourite(status));
+ } else {
+ dispatch(favourite(status));
+ }
+ } else {
+ dispatch(openModal('INTERACTION', {
+ type: 'favourite',
+ accountId: status.getIn(['account', 'id']),
+ url: status.get('url'),
+ }));
+ }
+ };
+
+ _performReblog = (status, privacy) => {
+ const { dispatch } = this.props;
+ dispatch(reblog(status, privacy));
+ };
+
+ handleReblogClick = e => {
+ const { dispatch, status } = this.props;
+ const { signedIn } = this.context.identity;
+
+ if (signedIn) {
+ if (status.get('reblogged')) {
+ dispatch(unreblog(status));
+ } else if ((e && e.shiftKey) || !boostModal) {
+ this._performReblog(status);
+ } else {
+ dispatch(initBoostModal({ status, onReblog: this._performReblog }));
+ }
+ } else {
+ dispatch(openModal('INTERACTION', {
+ type: 'reblog',
+ accountId: status.getIn(['account', 'id']),
+ url: status.get('url'),
+ }));
+ }
+ };
+
+ handleOpenClick = e => {
+ const { router } = this.context;
+
+ if (e.button !== 0 || !router) {
+ return;
+ }
+
+ const { status, onClose } = this.props;
+
+ if (onClose) {
+ onClose();
+ }
+
+ router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
+ };
+
+ render () {
+ const { status, intl, withOpenButton } = this.props;
+
+ const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
+ const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
+
+ let replyIcon, replyTitle;
+
+ if (status.get('in_reply_to_id', null) === null) {
+ replyIcon = 'reply';
+ replyTitle = intl.formatMessage(messages.reply);
+ } else {
+ replyIcon = 'reply-all';
+ replyTitle = intl.formatMessage(messages.replyAll);
+ }
+
+ let reblogTitle = '';
+
+ if (status.get('reblogged')) {
+ reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
+ } else if (publicStatus) {
+ reblogTitle = intl.formatMessage(messages.reblog);
+ } else if (reblogPrivate) {
+ reblogTitle = intl.formatMessage(messages.reblog_private);
+ } else {
+ reblogTitle = intl.formatMessage(messages.cannot_reblog);
+ }
+
+ return (
+
+
+
+
+ {withOpenButton && }
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/picture_in_picture/components/header.js b/app/javascript/mastodon/features/picture_in_picture/components/header.js
deleted file mode 100644
index e05d8c62e..000000000
--- a/app/javascript/mastodon/features/picture_in_picture/components/header.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import IconButton from 'mastodon/components/icon_button';
-import { Link } from 'react-router-dom';
-import Avatar from 'mastodon/components/avatar';
-import DisplayName from 'mastodon/components/display_name';
-import { defineMessages, injectIntl } from 'react-intl';
-
-const messages = defineMessages({
- close: { id: 'lightbox.close', defaultMessage: 'Close' },
-});
-
-const mapStateToProps = (state, { accountId }) => ({
- account: state.getIn(['accounts', accountId]),
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-class Header extends ImmutablePureComponent {
-
- static propTypes = {
- accountId: PropTypes.string.isRequired,
- statusId: PropTypes.string.isRequired,
- account: ImmutablePropTypes.map.isRequired,
- onClose: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- render () {
- const { account, statusId, onClose, intl } = this.props;
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/picture_in_picture/components/header.jsx b/app/javascript/mastodon/features/picture_in_picture/components/header.jsx
new file mode 100644
index 000000000..e05d8c62e
--- /dev/null
+++ b/app/javascript/mastodon/features/picture_in_picture/components/header.jsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from 'mastodon/components/icon_button';
+import { Link } from 'react-router-dom';
+import Avatar from 'mastodon/components/avatar';
+import DisplayName from 'mastodon/components/display_name';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+ close: { id: 'lightbox.close', defaultMessage: 'Close' },
+});
+
+const mapStateToProps = (state, { accountId }) => ({
+ account: state.getIn(['accounts', accountId]),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Header extends ImmutablePureComponent {
+
+ static propTypes = {
+ accountId: PropTypes.string.isRequired,
+ statusId: PropTypes.string.isRequired,
+ account: ImmutablePropTypes.map.isRequired,
+ onClose: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ render () {
+ const { account, statusId, onClose, intl } = this.props;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/picture_in_picture/index.js b/app/javascript/mastodon/features/picture_in_picture/index.js
deleted file mode 100644
index 01a7d43f2..000000000
--- a/app/javascript/mastodon/features/picture_in_picture/index.js
+++ /dev/null
@@ -1,85 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import Video from 'mastodon/features/video';
-import Audio from 'mastodon/features/audio';
-import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
-import Header from './components/header';
-import Footer from './components/footer';
-
-const mapStateToProps = state => ({
- ...state.get('picture_in_picture'),
-});
-
-export default @connect(mapStateToProps)
-class PictureInPicture extends React.Component {
-
- static propTypes = {
- statusId: PropTypes.string,
- accountId: PropTypes.string,
- type: PropTypes.string,
- src: PropTypes.string,
- muted: PropTypes.bool,
- volume: PropTypes.number,
- currentTime: PropTypes.number,
- poster: PropTypes.string,
- backgroundColor: PropTypes.string,
- foregroundColor: PropTypes.string,
- accentColor: PropTypes.string,
- dispatch: PropTypes.func.isRequired,
- };
-
- handleClose = () => {
- const { dispatch } = this.props;
- dispatch(removePictureInPicture());
- };
-
- render () {
- const { type, src, currentTime, accountId, statusId } = this.props;
-
- if (!currentTime) {
- return null;
- }
-
- let player;
-
- if (type === 'video') {
- player = (
-
- );
- } else if (type === 'audio') {
- player = (
-
- );
- }
-
- return (
-
-
-
- {player}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/picture_in_picture/index.jsx b/app/javascript/mastodon/features/picture_in_picture/index.jsx
new file mode 100644
index 000000000..01a7d43f2
--- /dev/null
+++ b/app/javascript/mastodon/features/picture_in_picture/index.jsx
@@ -0,0 +1,85 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import Video from 'mastodon/features/video';
+import Audio from 'mastodon/features/audio';
+import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
+import Header from './components/header';
+import Footer from './components/footer';
+
+const mapStateToProps = state => ({
+ ...state.get('picture_in_picture'),
+});
+
+export default @connect(mapStateToProps)
+class PictureInPicture extends React.Component {
+
+ static propTypes = {
+ statusId: PropTypes.string,
+ accountId: PropTypes.string,
+ type: PropTypes.string,
+ src: PropTypes.string,
+ muted: PropTypes.bool,
+ volume: PropTypes.number,
+ currentTime: PropTypes.number,
+ poster: PropTypes.string,
+ backgroundColor: PropTypes.string,
+ foregroundColor: PropTypes.string,
+ accentColor: PropTypes.string,
+ dispatch: PropTypes.func.isRequired,
+ };
+
+ handleClose = () => {
+ const { dispatch } = this.props;
+ dispatch(removePictureInPicture());
+ };
+
+ render () {
+ const { type, src, currentTime, accountId, statusId } = this.props;
+
+ if (!currentTime) {
+ return null;
+ }
+
+ let player;
+
+ if (type === 'video') {
+ player = (
+
+ );
+ } else if (type === 'audio') {
+ player = (
+
+ );
+ }
+
+ return (
+
+
+
+ {player}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/pinned_statuses/index.js b/app/javascript/mastodon/features/pinned_statuses/index.js
deleted file mode 100644
index 504fda415..000000000
--- a/app/javascript/mastodon/features/pinned_statuses/index.js
+++ /dev/null
@@ -1,65 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { fetchPinnedStatuses } from '../../actions/pin_statuses';
-import Column from '../ui/components/column';
-import ColumnBackButtonSlim from '../../components/column_back_button_slim';
-import StatusList from '../../components/status_list';
-import { defineMessages, injectIntl } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { Helmet } from 'react-helmet';
-
-const messages = defineMessages({
- heading: { id: 'column.pins', defaultMessage: 'Pinned post' },
-});
-
-const mapStateToProps = state => ({
- statusIds: state.getIn(['status_lists', 'pins', 'items']),
- hasMore: !!state.getIn(['status_lists', 'pins', 'next']),
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-class PinnedStatuses extends ImmutablePureComponent {
-
- static propTypes = {
- dispatch: PropTypes.func.isRequired,
- statusIds: ImmutablePropTypes.list.isRequired,
- intl: PropTypes.object.isRequired,
- hasMore: PropTypes.bool.isRequired,
- multiColumn: PropTypes.bool,
- };
-
- componentWillMount () {
- this.props.dispatch(fetchPinnedStatuses());
- }
-
- handleHeaderClick = () => {
- this.column.scrollTop();
- };
-
- setRef = c => {
- this.column = c;
- };
-
- render () {
- const { intl, statusIds, hasMore, multiColumn } = this.props;
-
- return (
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/pinned_statuses/index.jsx b/app/javascript/mastodon/features/pinned_statuses/index.jsx
new file mode 100644
index 000000000..504fda415
--- /dev/null
+++ b/app/javascript/mastodon/features/pinned_statuses/index.jsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { fetchPinnedStatuses } from '../../actions/pin_statuses';
+import Column from '../ui/components/column';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import StatusList from '../../components/status_list';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+ heading: { id: 'column.pins', defaultMessage: 'Pinned post' },
+});
+
+const mapStateToProps = state => ({
+ statusIds: state.getIn(['status_lists', 'pins', 'items']),
+ hasMore: !!state.getIn(['status_lists', 'pins', 'next']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class PinnedStatuses extends ImmutablePureComponent {
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ statusIds: ImmutablePropTypes.list.isRequired,
+ intl: PropTypes.object.isRequired,
+ hasMore: PropTypes.bool.isRequired,
+ multiColumn: PropTypes.bool,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchPinnedStatuses());
+ }
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ };
+
+ setRef = c => {
+ this.column = c;
+ };
+
+ render () {
+ const { intl, statusIds, hasMore, multiColumn } = this.props;
+
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/privacy_policy/index.js b/app/javascript/mastodon/features/privacy_policy/index.js
deleted file mode 100644
index 3df487e8f..000000000
--- a/app/javascript/mastodon/features/privacy_policy/index.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { Helmet } from 'react-helmet';
-import { FormattedMessage, FormattedDate, injectIntl, defineMessages } from 'react-intl';
-import Column from 'mastodon/components/column';
-import api from 'mastodon/api';
-import Skeleton from 'mastodon/components/skeleton';
-
-const messages = defineMessages({
- title: { id: 'privacy_policy.title', defaultMessage: 'Privacy Policy' },
-});
-
-export default @injectIntl
-class PrivacyPolicy extends React.PureComponent {
-
- static propTypes = {
- intl: PropTypes.object,
- multiColumn: PropTypes.bool,
- };
-
- state = {
- content: null,
- lastUpdated: null,
- isLoading: true,
- };
-
- componentDidMount () {
- api().get('/api/v1/instance/privacy_policy').then(({ data }) => {
- this.setState({ content: data.content, lastUpdated: data.updated_at, isLoading: false });
- }).catch(() => {
- this.setState({ isLoading: false });
- });
- }
-
- render () {
- const { intl, multiColumn } = this.props;
- const { isLoading, content, lastUpdated } = this.state;
-
- return (
-
-
-
-
- {intl.formatMessage(messages.title)}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/privacy_policy/index.jsx b/app/javascript/mastodon/features/privacy_policy/index.jsx
new file mode 100644
index 000000000..3df487e8f
--- /dev/null
+++ b/app/javascript/mastodon/features/privacy_policy/index.jsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Helmet } from 'react-helmet';
+import { FormattedMessage, FormattedDate, injectIntl, defineMessages } from 'react-intl';
+import Column from 'mastodon/components/column';
+import api from 'mastodon/api';
+import Skeleton from 'mastodon/components/skeleton';
+
+const messages = defineMessages({
+ title: { id: 'privacy_policy.title', defaultMessage: 'Privacy Policy' },
+});
+
+export default @injectIntl
+class PrivacyPolicy extends React.PureComponent {
+
+ static propTypes = {
+ intl: PropTypes.object,
+ multiColumn: PropTypes.bool,
+ };
+
+ state = {
+ content: null,
+ lastUpdated: null,
+ isLoading: true,
+ };
+
+ componentDidMount () {
+ api().get('/api/v1/instance/privacy_policy').then(({ data }) => {
+ this.setState({ content: data.content, lastUpdated: data.updated_at, isLoading: false });
+ }).catch(() => {
+ this.setState({ isLoading: false });
+ });
+ }
+
+ render () {
+ const { intl, multiColumn } = this.props;
+ const { isLoading, content, lastUpdated } = this.state;
+
+ return (
+
+
+
+
+ {intl.formatMessage(messages.title)}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/public_timeline/components/column_settings.js b/app/javascript/mastodon/features/public_timeline/components/column_settings.js
deleted file mode 100644
index 756b6fe06..000000000
--- a/app/javascript/mastodon/features/public_timeline/components/column_settings.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { injectIntl, FormattedMessage } from 'react-intl';
-import SettingToggle from '../../notifications/components/setting_toggle';
-
-export default @injectIntl
-class ColumnSettings extends React.PureComponent {
-
- static propTypes = {
- settings: ImmutablePropTypes.map.isRequired,
- onChange: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- columnId: PropTypes.string,
- };
-
- render () {
- const { settings, onChange } = this.props;
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/public_timeline/components/column_settings.jsx b/app/javascript/mastodon/features/public_timeline/components/column_settings.jsx
new file mode 100644
index 000000000..756b6fe06
--- /dev/null
+++ b/app/javascript/mastodon/features/public_timeline/components/column_settings.jsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import SettingToggle from '../../notifications/components/setting_toggle';
+
+export default @injectIntl
+class ColumnSettings extends React.PureComponent {
+
+ static propTypes = {
+ settings: ImmutablePropTypes.map.isRequired,
+ onChange: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ columnId: PropTypes.string,
+ };
+
+ render () {
+ const { settings, onChange } = this.props;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js
deleted file mode 100644
index aaef45c86..000000000
--- a/app/javascript/mastodon/features/public_timeline/index.js
+++ /dev/null
@@ -1,162 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import PropTypes from 'prop-types';
-import StatusListContainer from '../ui/containers/status_list_container';
-import Column from '../../components/column';
-import ColumnHeader from '../../components/column_header';
-import { expandPublicTimeline } from '../../actions/timelines';
-import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
-import ColumnSettingsContainer from './containers/column_settings_container';
-import { connectPublicStream } from '../../actions/streaming';
-import { Helmet } from 'react-helmet';
-import DismissableBanner from 'mastodon/components/dismissable_banner';
-
-const messages = defineMessages({
- title: { id: 'column.public', defaultMessage: 'Federated timeline' },
-});
-
-const mapStateToProps = (state, { columnId }) => {
- const uuid = columnId;
- const columns = state.getIn(['settings', 'columns']);
- const index = columns.findIndex(c => c.get('uuid') === uuid);
- const onlyMedia = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'public', 'other', 'onlyMedia']);
- const onlyRemote = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyRemote']) : state.getIn(['settings', 'public', 'other', 'onlyRemote']);
- const timelineState = state.getIn(['timelines', `public${onlyMedia ? ':media' : ''}`]);
-
- return {
- hasUnread: !!timelineState && timelineState.get('unread') > 0,
- onlyMedia,
- onlyRemote,
- };
-};
-
-export default @connect(mapStateToProps)
-@injectIntl
-class PublicTimeline extends React.PureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- identity: PropTypes.object,
- };
-
- static defaultProps = {
- onlyMedia: false,
- };
-
- static propTypes = {
- dispatch: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- columnId: PropTypes.string,
- multiColumn: PropTypes.bool,
- hasUnread: PropTypes.bool,
- onlyMedia: PropTypes.bool,
- onlyRemote: PropTypes.bool,
- };
-
- handlePin = () => {
- const { columnId, dispatch, onlyMedia, onlyRemote } = this.props;
-
- if (columnId) {
- dispatch(removeColumn(columnId));
- } else {
- dispatch(addColumn(onlyRemote ? 'REMOTE' : 'PUBLIC', { other: { onlyMedia, onlyRemote } }));
- }
- };
-
- handleMove = (dir) => {
- const { columnId, dispatch } = this.props;
- dispatch(moveColumn(columnId, dir));
- };
-
- handleHeaderClick = () => {
- this.column.scrollTop();
- };
-
- componentDidMount () {
- const { dispatch, onlyMedia, onlyRemote } = this.props;
- const { signedIn } = this.context.identity;
-
- dispatch(expandPublicTimeline({ onlyMedia, onlyRemote }));
-
- if (signedIn) {
- this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote }));
- }
- }
-
- componentDidUpdate (prevProps) {
- const { signedIn } = this.context.identity;
-
- if (prevProps.onlyMedia !== this.props.onlyMedia || prevProps.onlyRemote !== this.props.onlyRemote) {
- const { dispatch, onlyMedia, onlyRemote } = this.props;
-
- if (this.disconnect) {
- this.disconnect();
- }
-
- dispatch(expandPublicTimeline({ onlyMedia, onlyRemote }));
-
- if (signedIn) {
- this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote }));
- }
- }
- }
-
- componentWillUnmount () {
- if (this.disconnect) {
- this.disconnect();
- this.disconnect = null;
- }
- }
-
- setRef = c => {
- this.column = c;
- };
-
- handleLoadMore = maxId => {
- const { dispatch, onlyMedia, onlyRemote } = this.props;
-
- dispatch(expandPublicTimeline({ maxId, onlyMedia, onlyRemote }));
- };
-
- render () {
- const { intl, columnId, hasUnread, multiColumn, onlyMedia, onlyRemote } = this.props;
- const pinned = !!columnId;
-
- return (
-
-
-
-
-
-
-
-
-
- }
- bindToDocument={!multiColumn}
- />
-
-
- {intl.formatMessage(messages.title)}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/public_timeline/index.jsx b/app/javascript/mastodon/features/public_timeline/index.jsx
new file mode 100644
index 000000000..aaef45c86
--- /dev/null
+++ b/app/javascript/mastodon/features/public_timeline/index.jsx
@@ -0,0 +1,162 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+import StatusListContainer from '../ui/containers/status_list_container';
+import Column from '../../components/column';
+import ColumnHeader from '../../components/column_header';
+import { expandPublicTimeline } from '../../actions/timelines';
+import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { connectPublicStream } from '../../actions/streaming';
+import { Helmet } from 'react-helmet';
+import DismissableBanner from 'mastodon/components/dismissable_banner';
+
+const messages = defineMessages({
+ title: { id: 'column.public', defaultMessage: 'Federated timeline' },
+});
+
+const mapStateToProps = (state, { columnId }) => {
+ const uuid = columnId;
+ const columns = state.getIn(['settings', 'columns']);
+ const index = columns.findIndex(c => c.get('uuid') === uuid);
+ const onlyMedia = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'public', 'other', 'onlyMedia']);
+ const onlyRemote = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyRemote']) : state.getIn(['settings', 'public', 'other', 'onlyRemote']);
+ const timelineState = state.getIn(['timelines', `public${onlyMedia ? ':media' : ''}`]);
+
+ return {
+ hasUnread: !!timelineState && timelineState.get('unread') > 0,
+ onlyMedia,
+ onlyRemote,
+ };
+};
+
+export default @connect(mapStateToProps)
+@injectIntl
+class PublicTimeline extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ identity: PropTypes.object,
+ };
+
+ static defaultProps = {
+ onlyMedia: false,
+ };
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ columnId: PropTypes.string,
+ multiColumn: PropTypes.bool,
+ hasUnread: PropTypes.bool,
+ onlyMedia: PropTypes.bool,
+ onlyRemote: PropTypes.bool,
+ };
+
+ handlePin = () => {
+ const { columnId, dispatch, onlyMedia, onlyRemote } = this.props;
+
+ if (columnId) {
+ dispatch(removeColumn(columnId));
+ } else {
+ dispatch(addColumn(onlyRemote ? 'REMOTE' : 'PUBLIC', { other: { onlyMedia, onlyRemote } }));
+ }
+ };
+
+ handleMove = (dir) => {
+ const { columnId, dispatch } = this.props;
+ dispatch(moveColumn(columnId, dir));
+ };
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ };
+
+ componentDidMount () {
+ const { dispatch, onlyMedia, onlyRemote } = this.props;
+ const { signedIn } = this.context.identity;
+
+ dispatch(expandPublicTimeline({ onlyMedia, onlyRemote }));
+
+ if (signedIn) {
+ this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote }));
+ }
+ }
+
+ componentDidUpdate (prevProps) {
+ const { signedIn } = this.context.identity;
+
+ if (prevProps.onlyMedia !== this.props.onlyMedia || prevProps.onlyRemote !== this.props.onlyRemote) {
+ const { dispatch, onlyMedia, onlyRemote } = this.props;
+
+ if (this.disconnect) {
+ this.disconnect();
+ }
+
+ dispatch(expandPublicTimeline({ onlyMedia, onlyRemote }));
+
+ if (signedIn) {
+ this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote }));
+ }
+ }
+ }
+
+ componentWillUnmount () {
+ if (this.disconnect) {
+ this.disconnect();
+ this.disconnect = null;
+ }
+ }
+
+ setRef = c => {
+ this.column = c;
+ };
+
+ handleLoadMore = maxId => {
+ const { dispatch, onlyMedia, onlyRemote } = this.props;
+
+ dispatch(expandPublicTimeline({ maxId, onlyMedia, onlyRemote }));
+ };
+
+ render () {
+ const { intl, columnId, hasUnread, multiColumn, onlyMedia, onlyRemote } = this.props;
+ const pinned = !!columnId;
+
+ return (
+
+
+
+
+
+
+
+
+
+ }
+ bindToDocument={!multiColumn}
+ />
+
+
+ {intl.formatMessage(messages.title)}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/reblogs/index.js b/app/javascript/mastodon/features/reblogs/index.js
deleted file mode 100644
index 31e5dc1d4..000000000
--- a/app/javascript/mastodon/features/reblogs/index.js
+++ /dev/null
@@ -1,92 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import LoadingIndicator from '../../components/loading_indicator';
-import { fetchReblogs } from '../../actions/interactions';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import AccountContainer from '../../containers/account_container';
-import Column from '../ui/components/column';
-import ScrollableList from '../../components/scrollable_list';
-import Icon from 'mastodon/components/icon';
-import ColumnHeader from '../../components/column_header';
-import { Helmet } from 'react-helmet';
-
-const messages = defineMessages({
- refresh: { id: 'refresh', defaultMessage: 'Refresh' },
-});
-
-const mapStateToProps = (state, props) => ({
- accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]),
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-class Reblogs extends ImmutablePureComponent {
-
- static propTypes = {
- params: PropTypes.object.isRequired,
- dispatch: PropTypes.func.isRequired,
- accountIds: ImmutablePropTypes.list,
- multiColumn: PropTypes.bool,
- intl: PropTypes.object.isRequired,
- };
-
- componentWillMount () {
- if (!this.props.accountIds) {
- 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));
- }
- }
-
- handleRefresh = () => {
- this.props.dispatch(fetchReblogs(this.props.params.statusId));
- };
-
- render () {
- const { intl, accountIds, multiColumn } = this.props;
-
- if (!accountIds) {
- return (
-
-
-
- );
- }
-
- const emptyMessage = ;
-
- return (
-
-
- )}
- />
-
-
- {accountIds.map(id =>
- ,
- )}
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/reblogs/index.jsx b/app/javascript/mastodon/features/reblogs/index.jsx
new file mode 100644
index 000000000..31e5dc1d4
--- /dev/null
+++ b/app/javascript/mastodon/features/reblogs/index.jsx
@@ -0,0 +1,92 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import { fetchReblogs } from '../../actions/interactions';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import AccountContainer from '../../containers/account_container';
+import Column from '../ui/components/column';
+import ScrollableList from '../../components/scrollable_list';
+import Icon from 'mastodon/components/icon';
+import ColumnHeader from '../../components/column_header';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+ refresh: { id: 'refresh', defaultMessage: 'Refresh' },
+});
+
+const mapStateToProps = (state, props) => ({
+ accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Reblogs extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ accountIds: ImmutablePropTypes.list,
+ multiColumn: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ };
+
+ componentWillMount () {
+ if (!this.props.accountIds) {
+ 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));
+ }
+ }
+
+ handleRefresh = () => {
+ this.props.dispatch(fetchReblogs(this.props.params.statusId));
+ };
+
+ render () {
+ const { intl, accountIds, multiColumn } = this.props;
+
+ if (!accountIds) {
+ return (
+
+
+
+ );
+ }
+
+ const emptyMessage = ;
+
+ return (
+
+
+ )}
+ />
+
+
+ {accountIds.map(id =>
+ ,
+ )}
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/report/category.js b/app/javascript/mastodon/features/report/category.js
deleted file mode 100644
index c6c0a506f..000000000
--- a/app/javascript/mastodon/features/report/category.js
+++ /dev/null
@@ -1,106 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { connect } from 'react-redux';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import Button from 'mastodon/components/button';
-import Option from './components/option';
-import { List as ImmutableList } from 'immutable';
-
-const messages = defineMessages({
- dislike: { id: 'report.reasons.dislike', defaultMessage: 'I don\'t like it' },
- dislike_description: { id: 'report.reasons.dislike_description', defaultMessage: 'It is not something you want to see' },
- spam: { id: 'report.reasons.spam', defaultMessage: 'It\'s spam' },
- spam_description: { id: 'report.reasons.spam_description', defaultMessage: 'Malicious links, fake engagement, or repetitive replies' },
- violation: { id: 'report.reasons.violation', defaultMessage: 'It violates server rules' },
- violation_description: { id: 'report.reasons.violation_description', defaultMessage: 'You are aware that it breaks specific rules' },
- other: { id: 'report.reasons.other', defaultMessage: 'It\'s something else' },
- other_description: { id: 'report.reasons.other_description', defaultMessage: 'The issue does not fit into other categories' },
- status: { id: 'report.category.title_status', defaultMessage: 'post' },
- account: { id: 'report.category.title_account', defaultMessage: 'profile' },
-});
-
-const mapStateToProps = state => ({
- rules: state.getIn(['server', 'server', 'rules'], ImmutableList()),
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-class Category extends React.PureComponent {
-
- static propTypes = {
- onNextStep: PropTypes.func.isRequired,
- rules: ImmutablePropTypes.list,
- category: PropTypes.string,
- onChangeCategory: PropTypes.func.isRequired,
- startedFrom: PropTypes.oneOf(['status', 'account']),
- intl: PropTypes.object.isRequired,
- };
-
- handleNextClick = () => {
- const { onNextStep, category } = this.props;
-
- switch(category) {
- case 'dislike':
- onNextStep('thanks');
- break;
- case 'violation':
- onNextStep('rules');
- break;
- default:
- onNextStep('statuses');
- break;
- }
- };
-
- handleCategoryToggle = (value, checked) => {
- const { onChangeCategory } = this.props;
-
- if (checked) {
- onChangeCategory(value);
- }
- };
-
- render () {
- const { category, startedFrom, rules, intl } = this.props;
-
- const options = rules.size > 0 ? [
- 'dislike',
- 'spam',
- 'violation',
- 'other',
- ] : [
- 'dislike',
- 'spam',
- 'other',
- ];
-
- return (
-
-
-
-
-
- {options.map(item => (
-
- ))}
-
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/report/category.jsx b/app/javascript/mastodon/features/report/category.jsx
new file mode 100644
index 000000000..c6c0a506f
--- /dev/null
+++ b/app/javascript/mastodon/features/report/category.jsx
@@ -0,0 +1,106 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Button from 'mastodon/components/button';
+import Option from './components/option';
+import { List as ImmutableList } from 'immutable';
+
+const messages = defineMessages({
+ dislike: { id: 'report.reasons.dislike', defaultMessage: 'I don\'t like it' },
+ dislike_description: { id: 'report.reasons.dislike_description', defaultMessage: 'It is not something you want to see' },
+ spam: { id: 'report.reasons.spam', defaultMessage: 'It\'s spam' },
+ spam_description: { id: 'report.reasons.spam_description', defaultMessage: 'Malicious links, fake engagement, or repetitive replies' },
+ violation: { id: 'report.reasons.violation', defaultMessage: 'It violates server rules' },
+ violation_description: { id: 'report.reasons.violation_description', defaultMessage: 'You are aware that it breaks specific rules' },
+ other: { id: 'report.reasons.other', defaultMessage: 'It\'s something else' },
+ other_description: { id: 'report.reasons.other_description', defaultMessage: 'The issue does not fit into other categories' },
+ status: { id: 'report.category.title_status', defaultMessage: 'post' },
+ account: { id: 'report.category.title_account', defaultMessage: 'profile' },
+});
+
+const mapStateToProps = state => ({
+ rules: state.getIn(['server', 'server', 'rules'], ImmutableList()),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Category extends React.PureComponent {
+
+ static propTypes = {
+ onNextStep: PropTypes.func.isRequired,
+ rules: ImmutablePropTypes.list,
+ category: PropTypes.string,
+ onChangeCategory: PropTypes.func.isRequired,
+ startedFrom: PropTypes.oneOf(['status', 'account']),
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleNextClick = () => {
+ const { onNextStep, category } = this.props;
+
+ switch(category) {
+ case 'dislike':
+ onNextStep('thanks');
+ break;
+ case 'violation':
+ onNextStep('rules');
+ break;
+ default:
+ onNextStep('statuses');
+ break;
+ }
+ };
+
+ handleCategoryToggle = (value, checked) => {
+ const { onChangeCategory } = this.props;
+
+ if (checked) {
+ onChangeCategory(value);
+ }
+ };
+
+ render () {
+ const { category, startedFrom, rules, intl } = this.props;
+
+ const options = rules.size > 0 ? [
+ 'dislike',
+ 'spam',
+ 'violation',
+ 'other',
+ ] : [
+ 'dislike',
+ 'spam',
+ 'other',
+ ];
+
+ return (
+
+
+
+
+
+ {options.map(item => (
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/report/comment.js b/app/javascript/mastodon/features/report/comment.js
deleted file mode 100644
index cde156415..000000000
--- a/app/javascript/mastodon/features/report/comment.js
+++ /dev/null
@@ -1,83 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
-import Button from 'mastodon/components/button';
-import Toggle from 'react-toggle';
-
-const messages = defineMessages({
- placeholder: { id: 'report.placeholder', defaultMessage: 'Type or paste additional comments' },
-});
-
-export default @injectIntl
-class Comment extends React.PureComponent {
-
- static propTypes = {
- onSubmit: PropTypes.func.isRequired,
- comment: PropTypes.string.isRequired,
- onChangeComment: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- isSubmitting: PropTypes.bool,
- forward: PropTypes.bool,
- isRemote: PropTypes.bool,
- domain: PropTypes.string,
- onChangeForward: PropTypes.func.isRequired,
- };
-
- handleClick = () => {
- const { onSubmit } = this.props;
- onSubmit();
- };
-
- handleChange = e => {
- const { onChangeComment } = this.props;
- onChangeComment(e.target.value);
- };
-
- handleKeyDown = e => {
- if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
- this.handleClick();
- }
- };
-
- handleForwardChange = e => {
- const { onChangeForward } = this.props;
- onChangeForward(e.target.checked);
- };
-
- render () {
- const { comment, isRemote, forward, domain, isSubmitting, intl } = this.props;
-
- return (
-
-
-
-
-
- {isRemote && (
-
-
-
-
-
-
-
-
- )}
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/report/comment.jsx b/app/javascript/mastodon/features/report/comment.jsx
new file mode 100644
index 000000000..cde156415
--- /dev/null
+++ b/app/javascript/mastodon/features/report/comment.jsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
+import Button from 'mastodon/components/button';
+import Toggle from 'react-toggle';
+
+const messages = defineMessages({
+ placeholder: { id: 'report.placeholder', defaultMessage: 'Type or paste additional comments' },
+});
+
+export default @injectIntl
+class Comment extends React.PureComponent {
+
+ static propTypes = {
+ onSubmit: PropTypes.func.isRequired,
+ comment: PropTypes.string.isRequired,
+ onChangeComment: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ isSubmitting: PropTypes.bool,
+ forward: PropTypes.bool,
+ isRemote: PropTypes.bool,
+ domain: PropTypes.string,
+ onChangeForward: PropTypes.func.isRequired,
+ };
+
+ handleClick = () => {
+ const { onSubmit } = this.props;
+ onSubmit();
+ };
+
+ handleChange = e => {
+ const { onChangeComment } = this.props;
+ onChangeComment(e.target.value);
+ };
+
+ handleKeyDown = e => {
+ if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+ this.handleClick();
+ }
+ };
+
+ handleForwardChange = e => {
+ const { onChangeForward } = this.props;
+ onChangeForward(e.target.checked);
+ };
+
+ render () {
+ const { comment, isRemote, forward, domain, isSubmitting, intl } = this.props;
+
+ return (
+
+
+
+
+
+ {isRemote && (
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/report/components/option.js b/app/javascript/mastodon/features/report/components/option.js
deleted file mode 100644
index 42c04b018..000000000
--- a/app/javascript/mastodon/features/report/components/option.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import classNames from 'classnames';
-import Check from 'mastodon/components/check';
-
-export default class Option extends React.PureComponent {
-
- static propTypes = {
- name: PropTypes.string.isRequired,
- value: PropTypes.string.isRequired,
- checked: PropTypes.bool,
- label: PropTypes.node,
- description: PropTypes.node,
- onToggle: PropTypes.func,
- multiple: PropTypes.bool,
- labelComponent: PropTypes.node,
- };
-
- handleKeyPress = e => {
- const { value, checked, onToggle } = this.props;
-
- if (e.key === 'Enter' || e.key === ' ') {
- e.stopPropagation();
- e.preventDefault();
- onToggle(value, !checked);
- }
- };
-
- handleChange = e => {
- const { value, onToggle } = this.props;
- onToggle(value, e.target.checked);
- };
-
- render () {
- const { name, value, checked, label, labelComponent, description, multiple } = this.props;
-
- return (
-
-
-
- {checked && }
-
- {labelComponent ? labelComponent : (
-
- {label}
- {description}
-
- )}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/report/components/option.jsx b/app/javascript/mastodon/features/report/components/option.jsx
new file mode 100644
index 000000000..42c04b018
--- /dev/null
+++ b/app/javascript/mastodon/features/report/components/option.jsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import Check from 'mastodon/components/check';
+
+export default class Option extends React.PureComponent {
+
+ static propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+ checked: PropTypes.bool,
+ label: PropTypes.node,
+ description: PropTypes.node,
+ onToggle: PropTypes.func,
+ multiple: PropTypes.bool,
+ labelComponent: PropTypes.node,
+ };
+
+ handleKeyPress = e => {
+ const { value, checked, onToggle } = this.props;
+
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.stopPropagation();
+ e.preventDefault();
+ onToggle(value, !checked);
+ }
+ };
+
+ handleChange = e => {
+ const { value, onToggle } = this.props;
+ onToggle(value, e.target.checked);
+ };
+
+ render () {
+ const { name, value, checked, label, labelComponent, description, multiple } = this.props;
+
+ return (
+
+
+
+ {checked && }
+
+ {labelComponent ? labelComponent : (
+
+ {label}
+ {description}
+
+ )}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/report/components/status_check_box.js b/app/javascript/mastodon/features/report/components/status_check_box.js
deleted file mode 100644
index 5366da90b..000000000
--- a/app/javascript/mastodon/features/report/components/status_check_box.js
+++ /dev/null
@@ -1,82 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import StatusContent from 'mastodon/components/status_content';
-import Avatar from 'mastodon/components/avatar';
-import DisplayName from 'mastodon/components/display_name';
-import RelativeTimestamp from 'mastodon/components/relative_timestamp';
-import Option from './option';
-import MediaAttachments from 'mastodon/components/media_attachments';
-import { injectIntl, defineMessages } from 'react-intl';
-import Icon from 'mastodon/components/icon';
-
-const messages = defineMessages({
- public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
- unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
- private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
- direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
-});
-
-export default @injectIntl
-class StatusCheckBox extends React.PureComponent {
-
- static propTypes = {
- id: PropTypes.string.isRequired,
- status: ImmutablePropTypes.map.isRequired,
- checked: PropTypes.bool,
- onToggle: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- handleStatusesToggle = (value, checked) => {
- const { onToggle } = this.props;
- onToggle(value, checked);
- };
-
- render () {
- const { status, checked, intl } = this.props;
-
- if (status.get('reblog')) {
- return null;
- }
-
- const visibilityIconInfo = {
- 'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
- 'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
- 'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
- 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) },
- };
-
- const visibilityIcon = visibilityIconInfo[status.get('visibility')];
-
- const labelComponent = (
-
- );
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/report/components/status_check_box.jsx b/app/javascript/mastodon/features/report/components/status_check_box.jsx
new file mode 100644
index 000000000..5366da90b
--- /dev/null
+++ b/app/javascript/mastodon/features/report/components/status_check_box.jsx
@@ -0,0 +1,82 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import StatusContent from 'mastodon/components/status_content';
+import Avatar from 'mastodon/components/avatar';
+import DisplayName from 'mastodon/components/display_name';
+import RelativeTimestamp from 'mastodon/components/relative_timestamp';
+import Option from './option';
+import MediaAttachments from 'mastodon/components/media_attachments';
+import { injectIntl, defineMessages } from 'react-intl';
+import Icon from 'mastodon/components/icon';
+
+const messages = defineMessages({
+ public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
+ unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+ private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
+ direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
+});
+
+export default @injectIntl
+class StatusCheckBox extends React.PureComponent {
+
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ status: ImmutablePropTypes.map.isRequired,
+ checked: PropTypes.bool,
+ onToggle: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleStatusesToggle = (value, checked) => {
+ const { onToggle } = this.props;
+ onToggle(value, checked);
+ };
+
+ render () {
+ const { status, checked, intl } = this.props;
+
+ if (status.get('reblog')) {
+ return null;
+ }
+
+ const visibilityIconInfo = {
+ 'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
+ 'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
+ 'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
+ 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) },
+ };
+
+ const visibilityIcon = visibilityIconInfo[status.get('visibility')];
+
+ const labelComponent = (
+
+ );
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/report/rules.js b/app/javascript/mastodon/features/report/rules.js
deleted file mode 100644
index 920da68d6..000000000
--- a/app/javascript/mastodon/features/report/rules.js
+++ /dev/null
@@ -1,64 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { connect } from 'react-redux';
-import { FormattedMessage } from 'react-intl';
-import Button from 'mastodon/components/button';
-import Option from './components/option';
-
-const mapStateToProps = state => ({
- rules: state.getIn(['server', 'server', 'rules']),
-});
-
-export default @connect(mapStateToProps)
-class Rules extends React.PureComponent {
-
- static propTypes = {
- onNextStep: PropTypes.func.isRequired,
- rules: ImmutablePropTypes.list,
- selectedRuleIds: ImmutablePropTypes.set.isRequired,
- onToggle: PropTypes.func.isRequired,
- };
-
- handleNextClick = () => {
- const { onNextStep } = this.props;
- onNextStep('statuses');
- };
-
- handleRulesToggle = (value, checked) => {
- const { onToggle } = this.props;
- onToggle(value, checked);
- };
-
- render () {
- const { rules, selectedRuleIds } = this.props;
-
- return (
-
-
-
-
-
- {rules.map(item => (
-
- ))}
-
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/report/rules.jsx b/app/javascript/mastodon/features/report/rules.jsx
new file mode 100644
index 000000000..920da68d6
--- /dev/null
+++ b/app/javascript/mastodon/features/report/rules.jsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { FormattedMessage } from 'react-intl';
+import Button from 'mastodon/components/button';
+import Option from './components/option';
+
+const mapStateToProps = state => ({
+ rules: state.getIn(['server', 'server', 'rules']),
+});
+
+export default @connect(mapStateToProps)
+class Rules extends React.PureComponent {
+
+ static propTypes = {
+ onNextStep: PropTypes.func.isRequired,
+ rules: ImmutablePropTypes.list,
+ selectedRuleIds: ImmutablePropTypes.set.isRequired,
+ onToggle: PropTypes.func.isRequired,
+ };
+
+ handleNextClick = () => {
+ const { onNextStep } = this.props;
+ onNextStep('statuses');
+ };
+
+ handleRulesToggle = (value, checked) => {
+ const { onToggle } = this.props;
+ onToggle(value, checked);
+ };
+
+ render () {
+ const { rules, selectedRuleIds } = this.props;
+
+ return (
+
+
+
+
+
+ {rules.map(item => (
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/report/statuses.js b/app/javascript/mastodon/features/report/statuses.js
deleted file mode 100644
index d5d86034f..000000000
--- a/app/javascript/mastodon/features/report/statuses.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { connect } from 'react-redux';
-import StatusCheckBox from 'mastodon/features/report/containers/status_check_box_container';
-import { OrderedSet } from 'immutable';
-import { FormattedMessage } from 'react-intl';
-import Button from 'mastodon/components/button';
-import LoadingIndicator from 'mastodon/components/loading_indicator';
-
-const mapStateToProps = (state, { accountId }) => ({
- availableStatusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}:with_replies`, 'items'])),
- isLoading: state.getIn(['timelines', `account:${accountId}:with_replies`, 'isLoading']),
-});
-
-export default @connect(mapStateToProps)
-class Statuses extends React.PureComponent {
-
- static propTypes = {
- onNextStep: PropTypes.func.isRequired,
- accountId: PropTypes.string.isRequired,
- availableStatusIds: ImmutablePropTypes.set.isRequired,
- selectedStatusIds: ImmutablePropTypes.set.isRequired,
- isLoading: PropTypes.bool,
- onToggle: PropTypes.func.isRequired,
- };
-
- handleNextClick = () => {
- const { onNextStep } = this.props;
- onNextStep('comment');
- };
-
- render () {
- const { availableStatusIds, selectedStatusIds, onToggle, isLoading } = this.props;
-
- return (
-
-
-
-
-
- {isLoading ? : availableStatusIds.union(selectedStatusIds).map(statusId => (
-
- ))}
-
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/report/statuses.jsx b/app/javascript/mastodon/features/report/statuses.jsx
new file mode 100644
index 000000000..d5d86034f
--- /dev/null
+++ b/app/javascript/mastodon/features/report/statuses.jsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import StatusCheckBox from 'mastodon/features/report/containers/status_check_box_container';
+import { OrderedSet } from 'immutable';
+import { FormattedMessage } from 'react-intl';
+import Button from 'mastodon/components/button';
+import LoadingIndicator from 'mastodon/components/loading_indicator';
+
+const mapStateToProps = (state, { accountId }) => ({
+ availableStatusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}:with_replies`, 'items'])),
+ isLoading: state.getIn(['timelines', `account:${accountId}:with_replies`, 'isLoading']),
+});
+
+export default @connect(mapStateToProps)
+class Statuses extends React.PureComponent {
+
+ static propTypes = {
+ onNextStep: PropTypes.func.isRequired,
+ accountId: PropTypes.string.isRequired,
+ availableStatusIds: ImmutablePropTypes.set.isRequired,
+ selectedStatusIds: ImmutablePropTypes.set.isRequired,
+ isLoading: PropTypes.bool,
+ onToggle: PropTypes.func.isRequired,
+ };
+
+ handleNextClick = () => {
+ const { onNextStep } = this.props;
+ onNextStep('comment');
+ };
+
+ render () {
+ const { availableStatusIds, selectedStatusIds, onToggle, isLoading } = this.props;
+
+ return (
+
+
+
+
+
+ {isLoading ? : availableStatusIds.union(selectedStatusIds).map(statusId => (
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/report/thanks.js b/app/javascript/mastodon/features/report/thanks.js
deleted file mode 100644
index d169b1e32..000000000
--- a/app/javascript/mastodon/features/report/thanks.js
+++ /dev/null
@@ -1,84 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { FormattedMessage } from 'react-intl';
-import Button from 'mastodon/components/button';
-import { connect } from 'react-redux';
-import {
- unfollowAccount,
- muteAccount,
- blockAccount,
-} from 'mastodon/actions/accounts';
-
-const mapStateToProps = () => ({});
-
-export default @connect(mapStateToProps)
-class Thanks extends React.PureComponent {
-
- static propTypes = {
- submitted: PropTypes.bool,
- onClose: PropTypes.func.isRequired,
- account: ImmutablePropTypes.map.isRequired,
- dispatch: PropTypes.func.isRequired,
- };
-
- handleCloseClick = () => {
- const { onClose } = this.props;
- onClose();
- };
-
- handleUnfollowClick = () => {
- const { dispatch, account, onClose } = this.props;
- dispatch(unfollowAccount(account.get('id')));
- onClose();
- };
-
- handleMuteClick = () => {
- const { dispatch, account, onClose } = this.props;
- dispatch(muteAccount(account.get('id')));
- onClose();
- };
-
- handleBlockClick = () => {
- const { dispatch, account, onClose } = this.props;
- dispatch(blockAccount(account.get('id')));
- onClose();
- };
-
- render () {
- const { account, submitted } = this.props;
-
- return (
-
- {submitted ? : }
- {submitted ? : }
-
- {account.getIn(['relationship', 'following']) && (
-
-
-
-
-
-
- )}
-
-
-
- {!account.getIn(['relationship', 'muting']) ? : }
-
-
-
-
-
- {!account.getIn(['relationship', 'blocking']) ? : }
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/report/thanks.jsx b/app/javascript/mastodon/features/report/thanks.jsx
new file mode 100644
index 000000000..d169b1e32
--- /dev/null
+++ b/app/javascript/mastodon/features/report/thanks.jsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import Button from 'mastodon/components/button';
+import { connect } from 'react-redux';
+import {
+ unfollowAccount,
+ muteAccount,
+ blockAccount,
+} from 'mastodon/actions/accounts';
+
+const mapStateToProps = () => ({});
+
+export default @connect(mapStateToProps)
+class Thanks extends React.PureComponent {
+
+ static propTypes = {
+ submitted: PropTypes.bool,
+ onClose: PropTypes.func.isRequired,
+ account: ImmutablePropTypes.map.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ };
+
+ handleCloseClick = () => {
+ const { onClose } = this.props;
+ onClose();
+ };
+
+ handleUnfollowClick = () => {
+ const { dispatch, account, onClose } = this.props;
+ dispatch(unfollowAccount(account.get('id')));
+ onClose();
+ };
+
+ handleMuteClick = () => {
+ const { dispatch, account, onClose } = this.props;
+ dispatch(muteAccount(account.get('id')));
+ onClose();
+ };
+
+ handleBlockClick = () => {
+ const { dispatch, account, onClose } = this.props;
+ dispatch(blockAccount(account.get('id')));
+ onClose();
+ };
+
+ render () {
+ const { account, submitted } = this.props;
+
+ return (
+
+ {submitted ? : }
+ {submitted ? : }
+
+ {account.getIn(['relationship', 'following']) && (
+
+
+
+
+
+
+ )}
+
+
+
+ {!account.getIn(['relationship', 'muting']) ? : }
+
+
+
+
+
+ {!account.getIn(['relationship', 'blocking']) ? : }
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/standalone/compose/index.js b/app/javascript/mastodon/features/standalone/compose/index.js
deleted file mode 100644
index fbadef6f4..000000000
--- a/app/javascript/mastodon/features/standalone/compose/index.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import React from 'react';
-import ComposeFormContainer from '../../compose/containers/compose_form_container';
-import NotificationsContainer from '../../ui/containers/notifications_container';
-import LoadingBarContainer from '../../ui/containers/loading_bar_container';
-import ModalContainer from '../../ui/containers/modal_container';
-
-export default class Compose extends React.PureComponent {
-
- render () {
- return (
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/standalone/compose/index.jsx b/app/javascript/mastodon/features/standalone/compose/index.jsx
new file mode 100644
index 000000000..fbadef6f4
--- /dev/null
+++ b/app/javascript/mastodon/features/standalone/compose/index.jsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import ComposeFormContainer from '../../compose/containers/compose_form_container';
+import NotificationsContainer from '../../ui/containers/notifications_container';
+import LoadingBarContainer from '../../ui/containers/loading_bar_container';
+import ModalContainer from '../../ui/containers/modal_container';
+
+export default class Compose extends React.PureComponent {
+
+ render () {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
deleted file mode 100644
index 0d4767331..000000000
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ /dev/null
@@ -1,300 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { connect } from 'react-redux';
-import IconButton from '../../../components/icon_button';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
-import { defineMessages, injectIntl } from 'react-intl';
-import { me } from '../../../initial_state';
-import classNames from 'classnames';
-import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
-
-const messages = defineMessages({
- delete: { id: 'status.delete', defaultMessage: 'Delete' },
- redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
- edit: { id: 'status.edit', defaultMessage: 'Edit' },
- direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
- mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
- reply: { id: 'status.reply', defaultMessage: 'Reply' },
- reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
- reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
- cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
- cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
- favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
- bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
- more: { id: 'status.more', defaultMessage: 'More' },
- mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' },
- muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
- unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
- block: { id: 'status.block', defaultMessage: 'Block @{name}' },
- 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' },
- admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
- admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
- admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
- copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
- blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
- unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
- unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
- unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
- openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
-});
-
-const mapStateToProps = (state, { status }) => ({
- relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
-});
-
-export default @connect(mapStateToProps)
-@injectIntl
-class ActionBar extends React.PureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- identity: PropTypes.object,
- };
-
- static propTypes = {
- status: ImmutablePropTypes.map.isRequired,
- relationship: ImmutablePropTypes.map,
- onReply: PropTypes.func.isRequired,
- onReblog: PropTypes.func.isRequired,
- onFavourite: PropTypes.func.isRequired,
- onBookmark: PropTypes.func.isRequired,
- onDelete: PropTypes.func.isRequired,
- onEdit: PropTypes.func.isRequired,
- onDirect: PropTypes.func.isRequired,
- onMention: PropTypes.func.isRequired,
- onMute: PropTypes.func,
- onUnmute: PropTypes.func,
- onBlock: PropTypes.func,
- onUnblock: PropTypes.func,
- onBlockDomain: PropTypes.func,
- onUnblockDomain: PropTypes.func,
- onMuteConversation: PropTypes.func,
- 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);
- };
-
- handleBookmarkClick = (e) => {
- this.props.onBookmark(this.props.status, e);
- };
-
- handleDeleteClick = () => {
- this.props.onDelete(this.props.status, this.context.router.history);
- };
-
- handleRedraftClick = () => {
- this.props.onDelete(this.props.status, this.context.router.history, true);
- };
-
- handleEditClick = () => {
- this.props.onEdit(this.props.status, this.context.router.history);
- };
-
- handleDirectClick = () => {
- this.props.onDirect(this.props.status.get('account'), this.context.router.history);
- };
-
- handleMentionClick = () => {
- this.props.onMention(this.props.status.get('account'), this.context.router.history);
- };
-
- handleMuteClick = () => {
- const { status, relationship, onMute, onUnmute } = this.props;
- const account = status.get('account');
-
- if (relationship && relationship.get('muting')) {
- onUnmute(account);
- } else {
- onMute(account);
- }
- };
-
- handleBlockClick = () => {
- const { status, relationship, onBlock, onUnblock } = this.props;
- const account = status.get('account');
-
- if (relationship && relationship.get('blocking')) {
- onUnblock(account);
- } else {
- onBlock(status);
- }
- };
-
- handleBlockDomain = () => {
- const { status, onBlockDomain } = this.props;
- const account = status.get('account');
-
- onBlockDomain(account.get('acct').split('@')[1]);
- };
-
- handleUnblockDomain = () => {
- const { status, onUnblockDomain } = this.props;
- const account = status.get('account');
-
- onUnblockDomain(account.get('acct').split('@')[1]);
- };
-
- handleConversationMuteClick = () => {
- this.props.onMuteConversation(this.props.status);
- };
-
- 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);
- };
-
- handleCopy = () => {
- const url = this.props.status.get('url');
- navigator.clipboard.writeText(url);
- };
-
- render () {
- const { status, relationship, intl } = this.props;
- const { signedIn, permissions } = this.context.identity;
-
- const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
- const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
- const mutingConversation = status.get('muted');
- const account = status.get('account');
- const writtenByMe = status.getIn(['account', 'id']) === me;
- const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
-
- let menu = [];
-
- if (publicStatus) {
- if (isRemote) {
- menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
- }
-
- menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
- menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
- menu.push(null);
- }
-
- if (writtenByMe) {
- if (pinnableStatus) {
- menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
- menu.push(null);
- }
-
- menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
- menu.push(null);
- menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
- menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
- menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
- } else {
- menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
- menu.push(null);
-
- if (relationship && relationship.get('muting')) {
- menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick });
- } else {
- menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.handleMuteClick });
- }
-
- if (relationship && relationship.get('blocking')) {
- menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.handleBlockClick });
- } else {
- menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.handleBlockClick });
- }
-
- menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
-
- if (account.get('acct') !== account.get('username')) {
- const domain = account.get('acct').split('@')[1];
-
- menu.push(null);
-
- if (relationship && relationship.get('domain_blocking')) {
- menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.handleUnblockDomain });
- } else {
- menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.handleBlockDomain });
- }
- }
-
- if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
- menu.push(null);
- if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
- menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
- menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` });
- }
- if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) {
- const domain = account.get('acct').split('@')[1];
- menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` });
- }
- }
- }
-
- const shareButton = ('share' in navigator) && publicStatus && (
-
- );
-
- let replyIcon;
- if (status.get('in_reply_to_id', null) === null) {
- replyIcon = 'reply';
- } else {
- replyIcon = 'reply-all';
- }
-
- const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
-
- let reblogTitle;
- if (status.get('reblogged')) {
- reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
- } else if (publicStatus) {
- reblogTitle = intl.formatMessage(messages.reblog);
- } else if (reblogPrivate) {
- reblogTitle = intl.formatMessage(messages.reblog_private);
- } else {
- reblogTitle = intl.formatMessage(messages.cannot_reblog);
- }
-
- return (
-
-
-
-
-
-
- {shareButton}
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/status/components/action_bar.jsx b/app/javascript/mastodon/features/status/components/action_bar.jsx
new file mode 100644
index 000000000..0d4767331
--- /dev/null
+++ b/app/javascript/mastodon/features/status/components/action_bar.jsx
@@ -0,0 +1,300 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import IconButton from '../../../components/icon_button';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
+import { defineMessages, injectIntl } from 'react-intl';
+import { me } from '../../../initial_state';
+import classNames from 'classnames';
+import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
+
+const messages = defineMessages({
+ delete: { id: 'status.delete', defaultMessage: 'Delete' },
+ redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
+ edit: { id: 'status.edit', defaultMessage: 'Edit' },
+ direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
+ mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
+ reply: { id: 'status.reply', defaultMessage: 'Reply' },
+ reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+ reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
+ cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
+ cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+ favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+ bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
+ more: { id: 'status.more', defaultMessage: 'More' },
+ mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' },
+ muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
+ unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
+ block: { id: 'status.block', defaultMessage: 'Block @{name}' },
+ 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' },
+ admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
+ admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
+ admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
+ copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
+ blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
+ unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
+ unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+ unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
+ openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
+});
+
+const mapStateToProps = (state, { status }) => ({
+ relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class ActionBar extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ identity: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ relationship: ImmutablePropTypes.map,
+ onReply: PropTypes.func.isRequired,
+ onReblog: PropTypes.func.isRequired,
+ onFavourite: PropTypes.func.isRequired,
+ onBookmark: PropTypes.func.isRequired,
+ onDelete: PropTypes.func.isRequired,
+ onEdit: PropTypes.func.isRequired,
+ onDirect: PropTypes.func.isRequired,
+ onMention: PropTypes.func.isRequired,
+ onMute: PropTypes.func,
+ onUnmute: PropTypes.func,
+ onBlock: PropTypes.func,
+ onUnblock: PropTypes.func,
+ onBlockDomain: PropTypes.func,
+ onUnblockDomain: PropTypes.func,
+ onMuteConversation: PropTypes.func,
+ 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);
+ };
+
+ handleBookmarkClick = (e) => {
+ this.props.onBookmark(this.props.status, e);
+ };
+
+ handleDeleteClick = () => {
+ this.props.onDelete(this.props.status, this.context.router.history);
+ };
+
+ handleRedraftClick = () => {
+ this.props.onDelete(this.props.status, this.context.router.history, true);
+ };
+
+ handleEditClick = () => {
+ this.props.onEdit(this.props.status, this.context.router.history);
+ };
+
+ handleDirectClick = () => {
+ this.props.onDirect(this.props.status.get('account'), this.context.router.history);
+ };
+
+ handleMentionClick = () => {
+ this.props.onMention(this.props.status.get('account'), this.context.router.history);
+ };
+
+ handleMuteClick = () => {
+ const { status, relationship, onMute, onUnmute } = this.props;
+ const account = status.get('account');
+
+ if (relationship && relationship.get('muting')) {
+ onUnmute(account);
+ } else {
+ onMute(account);
+ }
+ };
+
+ handleBlockClick = () => {
+ const { status, relationship, onBlock, onUnblock } = this.props;
+ const account = status.get('account');
+
+ if (relationship && relationship.get('blocking')) {
+ onUnblock(account);
+ } else {
+ onBlock(status);
+ }
+ };
+
+ handleBlockDomain = () => {
+ const { status, onBlockDomain } = this.props;
+ const account = status.get('account');
+
+ onBlockDomain(account.get('acct').split('@')[1]);
+ };
+
+ handleUnblockDomain = () => {
+ const { status, onUnblockDomain } = this.props;
+ const account = status.get('account');
+
+ onUnblockDomain(account.get('acct').split('@')[1]);
+ };
+
+ handleConversationMuteClick = () => {
+ this.props.onMuteConversation(this.props.status);
+ };
+
+ 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);
+ };
+
+ handleCopy = () => {
+ const url = this.props.status.get('url');
+ navigator.clipboard.writeText(url);
+ };
+
+ render () {
+ const { status, relationship, intl } = this.props;
+ const { signedIn, permissions } = this.context.identity;
+
+ const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
+ const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
+ const mutingConversation = status.get('muted');
+ const account = status.get('account');
+ const writtenByMe = status.getIn(['account', 'id']) === me;
+ const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
+
+ let menu = [];
+
+ if (publicStatus) {
+ if (isRemote) {
+ menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
+ }
+
+ menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
+ menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+ menu.push(null);
+ }
+
+ if (writtenByMe) {
+ if (pinnableStatus) {
+ menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
+ menu.push(null);
+ }
+
+ menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
+ menu.push(null);
+ menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
+ menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
+ menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+ menu.push(null);
+
+ if (relationship && relationship.get('muting')) {
+ menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.handleMuteClick });
+ }
+
+ if (relationship && relationship.get('blocking')) {
+ menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.handleBlockClick });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.handleBlockClick });
+ }
+
+ menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
+
+ if (account.get('acct') !== account.get('username')) {
+ const domain = account.get('acct').split('@')[1];
+
+ menu.push(null);
+
+ if (relationship && relationship.get('domain_blocking')) {
+ menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.handleUnblockDomain });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.handleBlockDomain });
+ }
+ }
+
+ if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
+ menu.push(null);
+ if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
+ menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
+ menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` });
+ }
+ if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) {
+ const domain = account.get('acct').split('@')[1];
+ menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` });
+ }
+ }
+ }
+
+ const shareButton = ('share' in navigator) && publicStatus && (
+
+ );
+
+ let replyIcon;
+ if (status.get('in_reply_to_id', null) === null) {
+ replyIcon = 'reply';
+ } else {
+ replyIcon = 'reply-all';
+ }
+
+ const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
+
+ let reblogTitle;
+ if (status.get('reblogged')) {
+ reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
+ } else if (publicStatus) {
+ reblogTitle = intl.formatMessage(messages.reblog);
+ } else if (reblogPrivate) {
+ reblogTitle = intl.formatMessage(messages.reblog_private);
+ } else {
+ reblogTitle = intl.formatMessage(messages.cannot_reblog);
+ }
+
+ return (
+
+
+
+
+
+
+ {shareButton}
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js
deleted file mode 100644
index 34fac1010..000000000
--- a/app/javascript/mastodon/features/status/components/card.js
+++ /dev/null
@@ -1,289 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import Immutable from 'immutable';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { FormattedMessage } from 'react-intl';
-import punycode from 'punycode';
-import classnames from 'classnames';
-import Icon from 'mastodon/components/icon';
-import { useBlurhash } from 'mastodon/initial_state';
-import Blurhash from 'mastodon/components/blurhash';
-import { debounce } from 'lodash';
-
-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;
-};
-
-const trim = (text, len) => {
- const cut = text.indexOf(' ', len);
-
- if (cut === -1) {
- return text;
- }
-
- return text.slice(0, cut) + (text.length > len ? '…' : '');
-};
-
-const domParser = new DOMParser();
-
-const addAutoPlay = html => {
- const document = domParser.parseFromString(html, 'text/html').documentElement;
- const iframe = document.querySelector('iframe');
-
- if (iframe) {
- if (iframe.src.indexOf('?') !== -1) {
- iframe.src += '&';
- } else {
- iframe.src += '?';
- }
-
- iframe.src += 'autoplay=1&auto_play=1';
-
- // DOM parser creates html/body elements around original HTML fragment,
- // so we need to get innerHTML out of the body and not the entire document
- return document.querySelector('body').innerHTML;
- }
-
- return html;
-};
-
-export default class Card extends React.PureComponent {
-
- static propTypes = {
- card: ImmutablePropTypes.map,
- maxDescription: PropTypes.number,
- onOpenMedia: PropTypes.func.isRequired,
- compact: PropTypes.bool,
- defaultWidth: PropTypes.number,
- cacheWidth: PropTypes.func,
- sensitive: PropTypes.bool,
- };
-
- static defaultProps = {
- maxDescription: 50,
- compact: false,
- };
-
- state = {
- width: this.props.defaultWidth || 280,
- previewLoaded: false,
- embedded: false,
- revealed: !this.props.sensitive,
- };
-
- componentWillReceiveProps (nextProps) {
- if (!Immutable.is(this.props.card, nextProps.card)) {
- this.setState({ embedded: false, previewLoaded: false });
- }
- if (this.props.sensitive !== nextProps.sensitive) {
- this.setState({ revealed: !nextProps.sensitive });
- }
- }
-
- componentDidMount () {
- window.addEventListener('resize', this.handleResize, { passive: true });
- }
-
- componentWillUnmount () {
- window.removeEventListener('resize', this.handleResize);
- }
-
- _setDimensions () {
- const width = this.node.offsetWidth;
-
- if (this.props.cacheWidth) {
- this.props.cacheWidth(width);
- }
-
- this.setState({ width });
- }
-
- handleResize = debounce(() => {
- if (this.node) {
- this._setDimensions();
- }
- }, 250, {
- trailing: true,
- });
-
- handlePhotoClick = () => {
- const { card, onOpenMedia } = this.props;
-
- onOpenMedia(
- Immutable.fromJS([
- {
- type: 'image',
- url: card.get('embed_url'),
- description: card.get('title'),
- meta: {
- original: {
- width: card.get('width'),
- height: card.get('height'),
- },
- },
- },
- ]),
- 0,
- );
- };
-
- handleEmbedClick = () => {
- const { card } = this.props;
-
- if (card.get('type') === 'photo') {
- this.handlePhotoClick();
- } else {
- this.setState({ embedded: true });
- }
- };
-
- setRef = c => {
- this.node = c;
-
- if (this.node) {
- this._setDimensions();
- }
- };
-
- handleImageLoad = () => {
- this.setState({ previewLoaded: true });
- };
-
- handleReveal = e => {
- e.preventDefault();
- e.stopPropagation();
- this.setState({ revealed: true });
- };
-
- renderVideo () {
- const { card } = this.props;
- const content = { __html: addAutoPlay(card.get('html')) };
- const { width } = this.state;
- const ratio = card.get('width') / card.get('height');
- const height = width / ratio;
-
- return (
-
- );
- }
-
- render () {
- const { card, maxDescription, compact } = this.props;
- const { width, embedded, revealed } = this.state;
-
- if (card === null) {
- return null;
- }
-
- const provider = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name');
- const horizontal = (!compact && card.get('width') > card.get('height') && (card.get('width') + 100 >= width)) || card.get('type') !== 'link' || embedded;
- const interactive = card.get('type') !== 'link';
- const className = classnames('status-card', { horizontal, compact, interactive });
- const title = interactive ? {card.get('title')} : {card.get('title')} ;
- const ratio = card.get('width') / card.get('height');
- const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
-
- const description = (
-
- {title}
- {!(horizontal || compact) &&
{trim(card.get('description') || '', maxDescription)}
}
-
{provider}
-
- );
-
- let embed = '';
- let canvas = (
-
- );
- let thumbnail = ;
- let spoilerButton = (
-
-
-
- );
- spoilerButton = (
-
- {spoilerButton}
-
- );
-
- if (interactive) {
- if (embedded) {
- embed = this.renderVideo();
- } else {
- let iconVariant = 'play';
-
- if (card.get('type') === 'photo') {
- iconVariant = 'search-plus';
- }
-
- embed = (
-
- {canvas}
- {thumbnail}
-
- {revealed && (
-
- )}
- {!revealed && spoilerButton}
-
- );
- }
-
- return (
-
- {embed}
- {!compact && description}
-
- );
- } else if (card.get('image')) {
- embed = (
-
- {canvas}
- {thumbnail}
-
- );
- } else {
- embed = (
-
-
-
- );
- }
-
- return (
-
- {embed}
- {description}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/status/components/card.jsx b/app/javascript/mastodon/features/status/components/card.jsx
new file mode 100644
index 000000000..34fac1010
--- /dev/null
+++ b/app/javascript/mastodon/features/status/components/card.jsx
@@ -0,0 +1,289 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Immutable from 'immutable';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import punycode from 'punycode';
+import classnames from 'classnames';
+import Icon from 'mastodon/components/icon';
+import { useBlurhash } from 'mastodon/initial_state';
+import Blurhash from 'mastodon/components/blurhash';
+import { debounce } from 'lodash';
+
+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;
+};
+
+const trim = (text, len) => {
+ const cut = text.indexOf(' ', len);
+
+ if (cut === -1) {
+ return text;
+ }
+
+ return text.slice(0, cut) + (text.length > len ? '…' : '');
+};
+
+const domParser = new DOMParser();
+
+const addAutoPlay = html => {
+ const document = domParser.parseFromString(html, 'text/html').documentElement;
+ const iframe = document.querySelector('iframe');
+
+ if (iframe) {
+ if (iframe.src.indexOf('?') !== -1) {
+ iframe.src += '&';
+ } else {
+ iframe.src += '?';
+ }
+
+ iframe.src += 'autoplay=1&auto_play=1';
+
+ // DOM parser creates html/body elements around original HTML fragment,
+ // so we need to get innerHTML out of the body and not the entire document
+ return document.querySelector('body').innerHTML;
+ }
+
+ return html;
+};
+
+export default class Card extends React.PureComponent {
+
+ static propTypes = {
+ card: ImmutablePropTypes.map,
+ maxDescription: PropTypes.number,
+ onOpenMedia: PropTypes.func.isRequired,
+ compact: PropTypes.bool,
+ defaultWidth: PropTypes.number,
+ cacheWidth: PropTypes.func,
+ sensitive: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ maxDescription: 50,
+ compact: false,
+ };
+
+ state = {
+ width: this.props.defaultWidth || 280,
+ previewLoaded: false,
+ embedded: false,
+ revealed: !this.props.sensitive,
+ };
+
+ componentWillReceiveProps (nextProps) {
+ if (!Immutable.is(this.props.card, nextProps.card)) {
+ this.setState({ embedded: false, previewLoaded: false });
+ }
+ if (this.props.sensitive !== nextProps.sensitive) {
+ this.setState({ revealed: !nextProps.sensitive });
+ }
+ }
+
+ componentDidMount () {
+ window.addEventListener('resize', this.handleResize, { passive: true });
+ }
+
+ componentWillUnmount () {
+ window.removeEventListener('resize', this.handleResize);
+ }
+
+ _setDimensions () {
+ const width = this.node.offsetWidth;
+
+ if (this.props.cacheWidth) {
+ this.props.cacheWidth(width);
+ }
+
+ this.setState({ width });
+ }
+
+ handleResize = debounce(() => {
+ if (this.node) {
+ this._setDimensions();
+ }
+ }, 250, {
+ trailing: true,
+ });
+
+ handlePhotoClick = () => {
+ const { card, onOpenMedia } = this.props;
+
+ onOpenMedia(
+ Immutable.fromJS([
+ {
+ type: 'image',
+ url: card.get('embed_url'),
+ description: card.get('title'),
+ meta: {
+ original: {
+ width: card.get('width'),
+ height: card.get('height'),
+ },
+ },
+ },
+ ]),
+ 0,
+ );
+ };
+
+ handleEmbedClick = () => {
+ const { card } = this.props;
+
+ if (card.get('type') === 'photo') {
+ this.handlePhotoClick();
+ } else {
+ this.setState({ embedded: true });
+ }
+ };
+
+ setRef = c => {
+ this.node = c;
+
+ if (this.node) {
+ this._setDimensions();
+ }
+ };
+
+ handleImageLoad = () => {
+ this.setState({ previewLoaded: true });
+ };
+
+ handleReveal = e => {
+ e.preventDefault();
+ e.stopPropagation();
+ this.setState({ revealed: true });
+ };
+
+ renderVideo () {
+ const { card } = this.props;
+ const content = { __html: addAutoPlay(card.get('html')) };
+ const { width } = this.state;
+ const ratio = card.get('width') / card.get('height');
+ const height = width / ratio;
+
+ return (
+
+ );
+ }
+
+ render () {
+ const { card, maxDescription, compact } = this.props;
+ const { width, embedded, revealed } = this.state;
+
+ if (card === null) {
+ return null;
+ }
+
+ const provider = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name');
+ const horizontal = (!compact && card.get('width') > card.get('height') && (card.get('width') + 100 >= width)) || card.get('type') !== 'link' || embedded;
+ const interactive = card.get('type') !== 'link';
+ const className = classnames('status-card', { horizontal, compact, interactive });
+ const title = interactive ? {card.get('title')} : {card.get('title')} ;
+ const ratio = card.get('width') / card.get('height');
+ const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
+
+ const description = (
+
+ {title}
+ {!(horizontal || compact) &&
{trim(card.get('description') || '', maxDescription)}
}
+
{provider}
+
+ );
+
+ let embed = '';
+ let canvas = (
+
+ );
+ let thumbnail = ;
+ let spoilerButton = (
+
+
+
+ );
+ spoilerButton = (
+
+ {spoilerButton}
+
+ );
+
+ if (interactive) {
+ if (embedded) {
+ embed = this.renderVideo();
+ } else {
+ let iconVariant = 'play';
+
+ if (card.get('type') === 'photo') {
+ iconVariant = 'search-plus';
+ }
+
+ embed = (
+
+ {canvas}
+ {thumbnail}
+
+ {revealed && (
+
+ )}
+ {!revealed && spoilerButton}
+
+ );
+ }
+
+ return (
+
+ {embed}
+ {!compact && description}
+
+ );
+ } else if (card.get('image')) {
+ embed = (
+
+ {canvas}
+ {thumbnail}
+
+ );
+ } else {
+ embed = (
+
+
+
+ );
+ }
+
+ return (
+
+ {embed}
+ {description}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
deleted file mode 100644
index 064231ffe..000000000
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ /dev/null
@@ -1,288 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import Avatar from '../../../components/avatar';
-import DisplayName from '../../../components/display_name';
-import StatusContent from '../../../components/status_content';
-import MediaGallery from '../../../components/media_gallery';
-import { Link } from 'react-router-dom';
-import { injectIntl, defineMessages, FormattedDate } from 'react-intl';
-import Card from './card';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import Video from '../../video';
-import Audio from '../../audio';
-import scheduleIdleTask from '../../ui/util/schedule_idle_task';
-import classNames from 'classnames';
-import Icon from 'mastodon/components/icon';
-import AnimatedNumber from 'mastodon/components/animated_number';
-import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
-import EditedTimestamp from 'mastodon/components/edited_timestamp';
-
-const messages = defineMessages({
- public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
- unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
- private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
- direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
-});
-
-export default @injectIntl
-class DetailedStatus extends ImmutablePureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- };
-
- static propTypes = {
- status: ImmutablePropTypes.map,
- onOpenMedia: PropTypes.func.isRequired,
- onOpenVideo: PropTypes.func.isRequired,
- onToggleHidden: PropTypes.func.isRequired,
- onTranslate: PropTypes.func.isRequired,
- measureHeight: PropTypes.bool,
- onHeightChange: PropTypes.func,
- domain: PropTypes.string.isRequired,
- compact: PropTypes.bool,
- showMedia: PropTypes.bool,
- pictureInPicture: ImmutablePropTypes.contains({
- inUse: PropTypes.bool,
- available: PropTypes.bool,
- }),
- onToggleMediaVisibility: PropTypes.func,
- };
-
- state = {
- height: null,
- };
-
- handleAccountClick = (e) => {
- if (e.button === 0 && !(e.ctrlKey || e.metaKey) && this.context.router) {
- e.preventDefault();
- this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
- }
-
- e.stopPropagation();
- };
-
- handleOpenVideo = (options) => {
- this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), options);
- };
-
- handleExpandedToggle = () => {
- this.props.onToggleHidden(this.props.status);
- };
-
- _measureHeight (heightJustChanged) {
- if (this.props.measureHeight && this.node) {
- scheduleIdleTask(() => this.node && this.setState({ height: Math.ceil(this.node.scrollHeight) + 1 }));
-
- if (this.props.onHeightChange && heightJustChanged) {
- this.props.onHeightChange();
- }
- }
- }
-
- setRef = c => {
- this.node = c;
- this._measureHeight();
- };
-
- componentDidUpdate (prevProps, prevState) {
- this._measureHeight(prevState.height !== this.state.height);
- }
-
- handleModalLink = e => {
- e.preventDefault();
-
- let href;
-
- if (e.target.nodeName !== 'A') {
- href = e.target.parentNode.href;
- } else {
- href = e.target.href;
- }
-
- window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
- };
-
- handleTranslate = () => {
- const { onTranslate, status } = this.props;
- onTranslate(status);
- };
-
- render () {
- const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
- const outerStyle = { boxSizing: 'border-box' };
- const { intl, compact, pictureInPicture } = this.props;
-
- if (!status) {
- return null;
- }
-
- let media = '';
- let applicationLink = '';
- let reblogLink = '';
- let reblogIcon = 'retweet';
- let favouriteLink = '';
- let edited = '';
-
- if (this.props.measureHeight) {
- outerStyle.height = `${this.state.height}px`;
- }
-
- if (pictureInPicture.get('inUse')) {
- media = ;
- } else if (status.get('media_attachments').size > 0) {
- if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
- const attachment = status.getIn(['media_attachments', 0]);
-
- media = (
-
- );
- } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
- const attachment = status.getIn(['media_attachments', 0]);
-
- media = (
-
- );
- } else {
- media = (
-
- );
- }
- } else if (status.get('spoiler_text').length === 0) {
- media = ;
- }
-
- if (status.get('application')) {
- applicationLink = · {status.getIn(['application', 'name'])} ;
- }
-
- const visibilityIconInfo = {
- 'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
- 'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
- 'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
- 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) },
- };
-
- const visibilityIcon = visibilityIconInfo[status.get('visibility')];
- const visibilityLink = · ;
-
- if (['private', 'direct'].includes(status.get('visibility'))) {
- reblogLink = '';
- } else if (this.context.router) {
- reblogLink = (
-
- ·
-
-
-
-
-
-
-
- );
- } else {
- reblogLink = (
-
- ·
-
-
-
-
-
-
-
- );
- }
-
- if (this.context.router) {
- favouriteLink = (
-
-
-
-
-
-
- );
- } else {
- favouriteLink = (
-
-
-
-
-
-
- );
- }
-
- if (status.get('edited_at')) {
- edited = (
-
- ·
-
-
- );
- }
-
- return (
-
-
-
-
-
-
-
-
-
- {media}
-
-
-
-
- {edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.jsx b/app/javascript/mastodon/features/status/components/detailed_status.jsx
new file mode 100644
index 000000000..064231ffe
--- /dev/null
+++ b/app/javascript/mastodon/features/status/components/detailed_status.jsx
@@ -0,0 +1,288 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from '../../../components/avatar';
+import DisplayName from '../../../components/display_name';
+import StatusContent from '../../../components/status_content';
+import MediaGallery from '../../../components/media_gallery';
+import { Link } from 'react-router-dom';
+import { injectIntl, defineMessages, FormattedDate } from 'react-intl';
+import Card from './card';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Video from '../../video';
+import Audio from '../../audio';
+import scheduleIdleTask from '../../ui/util/schedule_idle_task';
+import classNames from 'classnames';
+import Icon from 'mastodon/components/icon';
+import AnimatedNumber from 'mastodon/components/animated_number';
+import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
+import EditedTimestamp from 'mastodon/components/edited_timestamp';
+
+const messages = defineMessages({
+ public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
+ unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+ private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
+ direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
+});
+
+export default @injectIntl
+class DetailedStatus extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map,
+ onOpenMedia: PropTypes.func.isRequired,
+ onOpenVideo: PropTypes.func.isRequired,
+ onToggleHidden: PropTypes.func.isRequired,
+ onTranslate: PropTypes.func.isRequired,
+ measureHeight: PropTypes.bool,
+ onHeightChange: PropTypes.func,
+ domain: PropTypes.string.isRequired,
+ compact: PropTypes.bool,
+ showMedia: PropTypes.bool,
+ pictureInPicture: ImmutablePropTypes.contains({
+ inUse: PropTypes.bool,
+ available: PropTypes.bool,
+ }),
+ onToggleMediaVisibility: PropTypes.func,
+ };
+
+ state = {
+ height: null,
+ };
+
+ handleAccountClick = (e) => {
+ if (e.button === 0 && !(e.ctrlKey || e.metaKey) && this.context.router) {
+ e.preventDefault();
+ this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
+ }
+
+ e.stopPropagation();
+ };
+
+ handleOpenVideo = (options) => {
+ this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), options);
+ };
+
+ handleExpandedToggle = () => {
+ this.props.onToggleHidden(this.props.status);
+ };
+
+ _measureHeight (heightJustChanged) {
+ if (this.props.measureHeight && this.node) {
+ scheduleIdleTask(() => this.node && this.setState({ height: Math.ceil(this.node.scrollHeight) + 1 }));
+
+ if (this.props.onHeightChange && heightJustChanged) {
+ this.props.onHeightChange();
+ }
+ }
+ }
+
+ setRef = c => {
+ this.node = c;
+ this._measureHeight();
+ };
+
+ componentDidUpdate (prevProps, prevState) {
+ this._measureHeight(prevState.height !== this.state.height);
+ }
+
+ handleModalLink = e => {
+ e.preventDefault();
+
+ let href;
+
+ if (e.target.nodeName !== 'A') {
+ href = e.target.parentNode.href;
+ } else {
+ href = e.target.href;
+ }
+
+ window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
+ };
+
+ handleTranslate = () => {
+ const { onTranslate, status } = this.props;
+ onTranslate(status);
+ };
+
+ render () {
+ const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
+ const outerStyle = { boxSizing: 'border-box' };
+ const { intl, compact, pictureInPicture } = this.props;
+
+ if (!status) {
+ return null;
+ }
+
+ let media = '';
+ let applicationLink = '';
+ let reblogLink = '';
+ let reblogIcon = 'retweet';
+ let favouriteLink = '';
+ let edited = '';
+
+ if (this.props.measureHeight) {
+ outerStyle.height = `${this.state.height}px`;
+ }
+
+ if (pictureInPicture.get('inUse')) {
+ media = ;
+ } else if (status.get('media_attachments').size > 0) {
+ if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
+ const attachment = status.getIn(['media_attachments', 0]);
+
+ media = (
+
+ );
+ } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+ const attachment = status.getIn(['media_attachments', 0]);
+
+ media = (
+
+ );
+ } else {
+ media = (
+
+ );
+ }
+ } else if (status.get('spoiler_text').length === 0) {
+ media = ;
+ }
+
+ if (status.get('application')) {
+ applicationLink = · {status.getIn(['application', 'name'])} ;
+ }
+
+ const visibilityIconInfo = {
+ 'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
+ 'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
+ 'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
+ 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) },
+ };
+
+ const visibilityIcon = visibilityIconInfo[status.get('visibility')];
+ const visibilityLink = · ;
+
+ if (['private', 'direct'].includes(status.get('visibility'))) {
+ reblogLink = '';
+ } else if (this.context.router) {
+ reblogLink = (
+
+ ·
+
+
+
+
+
+
+
+ );
+ } else {
+ reblogLink = (
+
+ ·
+
+
+
+
+
+
+
+ );
+ }
+
+ if (this.context.router) {
+ favouriteLink = (
+
+
+
+
+
+
+ );
+ } else {
+ favouriteLink = (
+
+
+
+
+
+
+ );
+ }
+
+ if (status.get('edited_at')) {
+ edited = (
+
+ ·
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {media}
+
+
+
+
+ {edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
deleted file mode 100644
index 2c6728fc0..000000000
--- a/app/javascript/mastodon/features/status/index.js
+++ /dev/null
@@ -1,686 +0,0 @@
-import Immutable from 'immutable';
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import classNames from 'classnames';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { createSelector } from 'reselect';
-import {
- fetchStatus,
- muteStatus,
- unmuteStatus,
- deleteStatus,
- editStatus,
- hideStatus,
- revealStatus,
- translateStatus,
- undoStatusTranslation,
-} from '../../actions/statuses';
-import MissingIndicator from '../../components/missing_indicator';
-import LoadingIndicator from 'mastodon/components/loading_indicator';
-import DetailedStatus from './components/detailed_status';
-import ActionBar from './components/action_bar';
-import Column from '../ui/components/column';
-import {
- favourite,
- unfavourite,
- bookmark,
- unbookmark,
- reblog,
- unreblog,
- pin,
- unpin,
-} from '../../actions/interactions';
-import {
- replyCompose,
- mentionCompose,
- directCompose,
-} from '../../actions/compose';
-import {
- unblockAccount,
- unmuteAccount,
-} from '../../actions/accounts';
-import {
- blockDomain,
- unblockDomain,
-} from '../../actions/domain_blocks';
-import { initMuteModal } from '../../actions/mutes';
-import { initBlockModal } from '../../actions/blocks';
-import { initBoostModal } from '../../actions/boosts';
-import { initReport } from '../../actions/reports';
-import { makeGetStatus, makeGetPictureInPicture } from '../../selectors';
-import ScrollContainer from 'mastodon/containers/scroll_container';
-import ColumnBackButton from '../../components/column_back_button';
-import ColumnHeader from '../../components/column_header';
-import StatusContainer from '../../containers/status_container';
-import { openModal } from '../../actions/modal';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { HotKeys } from 'react-hotkeys';
-import { boostModal, deleteModal } from '../../initial_state';
-import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
-import { textForScreenReader, defaultMediaVisibility } from '../../components/status';
-import Icon from 'mastodon/components/icon';
-import { Helmet } from 'react-helmet';
-
-const messages = defineMessages({
- deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
- deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
- redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
- redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
- revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
- hideAll: { id: 'status.show_less_all', defaultMessage: 'Show less for all' },
- detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' },
- replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
- replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
- blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
-});
-
-const makeMapStateToProps = () => {
- const getStatus = makeGetStatus();
- const getPictureInPicture = makeGetPictureInPicture();
-
- const getAncestorsIds = createSelector([
- (_, { id }) => id,
- state => state.getIn(['contexts', 'inReplyTos']),
- ], (statusId, inReplyTos) => {
- let ancestorsIds = Immutable.List();
- ancestorsIds = ancestorsIds.withMutations(mutable => {
- let id = statusId;
-
- while (id && !mutable.includes(id)) {
- mutable.unshift(id);
- id = inReplyTos.get(id);
- }
- });
-
- return ancestorsIds;
- });
-
- const getDescendantsIds = createSelector([
- (_, { id }) => id,
- state => state.getIn(['contexts', 'replies']),
- state => state.get('statuses'),
- ], (statusId, contextReplies, statuses) => {
- let descendantsIds = [];
- const ids = [statusId];
-
- while (ids.length > 0) {
- let id = ids.pop();
- const replies = contextReplies.get(id);
-
- if (statusId !== id) {
- descendantsIds.push(id);
- }
-
- if (replies) {
- replies.reverse().forEach(reply => {
- if (!ids.includes(reply) && !descendantsIds.includes(reply) && statusId !== reply) ids.push(reply);
- });
- }
- }
-
- let insertAt = descendantsIds.findIndex((id) => statuses.get(id).get('in_reply_to_account_id') !== statuses.get(id).get('account'));
- if (insertAt !== -1) {
- descendantsIds.forEach((id, idx) => {
- if (idx > insertAt && statuses.get(id).get('in_reply_to_account_id') === statuses.get(id).get('account')) {
- descendantsIds.splice(idx, 1);
- descendantsIds.splice(insertAt, 0, id);
- insertAt += 1;
- }
- });
- }
-
- return Immutable.List(descendantsIds);
- });
-
- const mapStateToProps = (state, props) => {
- const status = getStatus(state, { id: props.params.statusId });
-
- let ancestorsIds = Immutable.List();
- let descendantsIds = Immutable.List();
-
- if (status) {
- ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
- descendantsIds = getDescendantsIds(state, { id: status.get('id') });
- }
-
- return {
- isLoading: state.getIn(['statuses', props.params.statusId, 'isLoading']),
- status,
- ancestorsIds,
- descendantsIds,
- askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
- domain: state.getIn(['meta', 'domain']),
- pictureInPicture: getPictureInPicture(state, { id: props.params.statusId }),
- };
- };
-
- return mapStateToProps;
-};
-
-const truncate = (str, num) => {
- if (str.length > num) {
- return str.slice(0, num) + '…';
- } else {
- return str;
- }
-};
-
-const titleFromStatus = status => {
- const displayName = status.getIn(['account', 'display_name']);
- const username = status.getIn(['account', 'username']);
- const prefix = displayName.trim().length === 0 ? username : displayName;
- const text = status.get('search_index');
-
- return `${prefix}: "${truncate(text, 30)}"`;
-};
-
-export default @injectIntl
-@connect(makeMapStateToProps)
-class Status extends ImmutablePureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- identity: PropTypes.object,
- };
-
- static propTypes = {
- params: PropTypes.object.isRequired,
- dispatch: PropTypes.func.isRequired,
- status: ImmutablePropTypes.map,
- isLoading: PropTypes.bool,
- ancestorsIds: ImmutablePropTypes.list,
- descendantsIds: ImmutablePropTypes.list,
- intl: PropTypes.object.isRequired,
- askReplyConfirmation: PropTypes.bool,
- multiColumn: PropTypes.bool,
- domain: PropTypes.string.isRequired,
- pictureInPicture: ImmutablePropTypes.contains({
- inUse: PropTypes.bool,
- available: PropTypes.bool,
- }),
- };
-
- state = {
- fullscreen: false,
- showMedia: defaultMediaVisibility(this.props.status),
- loadedStatusId: undefined,
- };
-
- componentWillMount () {
- this.props.dispatch(fetchStatus(this.props.params.statusId));
- }
-
- componentDidMount () {
- attachFullscreenListener(this.onFullScreenChange);
- }
-
- componentWillReceiveProps (nextProps) {
- if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
- this._scrolledIntoView = false;
- this.props.dispatch(fetchStatus(nextProps.params.statusId));
- }
-
- if (nextProps.params.statusId && nextProps.ancestorsIds.size > this.props.ancestorsIds.size) {
- this._scrolledIntoView = false;
- }
-
- if (nextProps.status && nextProps.status.get('id') !== this.state.loadedStatusId) {
- this.setState({ showMedia: defaultMediaVisibility(nextProps.status), loadedStatusId: nextProps.status.get('id') });
- }
- }
-
- handleToggleMediaVisibility = () => {
- this.setState({ showMedia: !this.state.showMedia });
- };
-
- handleFavouriteClick = (status) => {
- const { dispatch } = this.props;
- const { signedIn } = this.context.identity;
-
- if (signedIn) {
- if (status.get('favourited')) {
- dispatch(unfavourite(status));
- } else {
- dispatch(favourite(status));
- }
- } else {
- dispatch(openModal('INTERACTION', {
- type: 'favourite',
- accountId: status.getIn(['account', 'id']),
- url: status.get('url'),
- }));
- }
- };
-
- handlePin = (status) => {
- if (status.get('pinned')) {
- this.props.dispatch(unpin(status));
- } else {
- this.props.dispatch(pin(status));
- }
- };
-
- handleReplyClick = (status) => {
- const { askReplyConfirmation, dispatch, intl } = this.props;
- const { signedIn } = this.context.identity;
-
- if (signedIn) {
- if (askReplyConfirmation) {
- dispatch(openModal('CONFIRM', {
- message: intl.formatMessage(messages.replyMessage),
- confirm: intl.formatMessage(messages.replyConfirm),
- onConfirm: () => dispatch(replyCompose(status, this.context.router.history)),
- }));
- } else {
- dispatch(replyCompose(status, this.context.router.history));
- }
- } else {
- dispatch(openModal('INTERACTION', {
- type: 'reply',
- accountId: status.getIn(['account', 'id']),
- url: status.get('url'),
- }));
- }
- };
-
- handleModalReblog = (status, privacy) => {
- this.props.dispatch(reblog(status, privacy));
- };
-
- handleReblogClick = (status, e) => {
- const { dispatch } = this.props;
- const { signedIn } = this.context.identity;
-
- if (signedIn) {
- if (status.get('reblogged')) {
- dispatch(unreblog(status));
- } else {
- if ((e && e.shiftKey) || !boostModal) {
- this.handleModalReblog(status);
- } else {
- dispatch(initBoostModal({ status, onReblog: this.handleModalReblog }));
- }
- }
- } else {
- dispatch(openModal('INTERACTION', {
- type: 'reblog',
- accountId: status.getIn(['account', 'id']),
- url: status.get('url'),
- }));
- }
- };
-
- handleBookmarkClick = (status) => {
- if (status.get('bookmarked')) {
- this.props.dispatch(unbookmark(status));
- } else {
- this.props.dispatch(bookmark(status));
- }
- };
-
- handleDeleteClick = (status, history, withRedraft = false) => {
- const { dispatch, intl } = this.props;
-
- if (!deleteModal) {
- dispatch(deleteStatus(status.get('id'), history, withRedraft));
- } else {
- dispatch(openModal('CONFIRM', {
- message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
- confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
- onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
- }));
- }
- };
-
- handleEditClick = (status, history) => {
- this.props.dispatch(editStatus(status.get('id'), history));
- };
-
- handleDirectClick = (account, router) => {
- this.props.dispatch(directCompose(account, router));
- };
-
- handleMentionClick = (account, router) => {
- this.props.dispatch(mentionCompose(account, router));
- };
-
- handleOpenMedia = (media, index) => {
- this.props.dispatch(openModal('MEDIA', { statusId: this.props.status.get('id'), media, index }));
- };
-
- handleOpenVideo = (media, options) => {
- this.props.dispatch(openModal('VIDEO', { statusId: this.props.status.get('id'), media, options }));
- };
-
- handleHotkeyOpenMedia = e => {
- const { status } = this.props;
-
- e.preventDefault();
-
- if (status.get('media_attachments').size > 0) {
- if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
- this.handleOpenVideo(status.getIn(['media_attachments', 0]), { startTime: 0 });
- } else {
- this.handleOpenMedia(status.get('media_attachments'), 0);
- }
- }
- };
-
- handleMuteClick = (account) => {
- this.props.dispatch(initMuteModal(account));
- };
-
- handleConversationMuteClick = (status) => {
- if (status.get('muted')) {
- this.props.dispatch(unmuteStatus(status.get('id')));
- } else {
- this.props.dispatch(muteStatus(status.get('id')));
- }
- };
-
- handleToggleHidden = (status) => {
- if (status.get('hidden')) {
- this.props.dispatch(revealStatus(status.get('id')));
- } else {
- this.props.dispatch(hideStatus(status.get('id')));
- }
- };
-
- handleToggleAll = () => {
- const { status, ancestorsIds, descendantsIds } = this.props;
- const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS());
-
- if (status.get('hidden')) {
- this.props.dispatch(revealStatus(statusIds));
- } else {
- this.props.dispatch(hideStatus(statusIds));
- }
- };
-
- handleTranslate = status => {
- const { dispatch } = this.props;
-
- if (status.get('translation')) {
- dispatch(undoStatusTranslation(status.get('id')));
- } else {
- dispatch(translateStatus(status.get('id')));
- }
- };
-
- handleBlockClick = (status) => {
- const { dispatch } = this.props;
- const account = status.get('account');
- dispatch(initBlockModal(account));
- };
-
- handleReport = (status) => {
- this.props.dispatch(initReport(status.get('account'), status));
- };
-
- handleEmbed = (status) => {
- this.props.dispatch(openModal('EMBED', { url: status.get('url') }));
- };
-
- handleUnmuteClick = account => {
- this.props.dispatch(unmuteAccount(account.get('id')));
- };
-
- handleUnblockClick = account => {
- this.props.dispatch(unblockAccount(account.get('id')));
- };
-
- handleBlockDomainClick = domain => {
- this.props.dispatch(openModal('CONFIRM', {
- message: {domain} }} />,
- confirm: this.props.intl.formatMessage(messages.blockDomainConfirm),
- onConfirm: () => this.props.dispatch(blockDomain(domain)),
- }));
- };
-
- handleUnblockDomainClick = domain => {
- this.props.dispatch(unblockDomain(domain));
- };
-
-
- handleHotkeyMoveUp = () => {
- this.handleMoveUp(this.props.status.get('id'));
- };
-
- handleHotkeyMoveDown = () => {
- this.handleMoveDown(this.props.status.get('id'));
- };
-
- handleHotkeyReply = e => {
- e.preventDefault();
- this.handleReplyClick(this.props.status);
- };
-
- handleHotkeyFavourite = () => {
- this.handleFavouriteClick(this.props.status);
- };
-
- handleHotkeyBoost = () => {
- this.handleReblogClick(this.props.status);
- };
-
- handleHotkeyMention = e => {
- e.preventDefault();
- this.handleMentionClick(this.props.status.get('account'));
- };
-
- handleHotkeyOpenProfile = () => {
- this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
- };
-
- handleHotkeyToggleHidden = () => {
- this.handleToggleHidden(this.props.status);
- };
-
- handleHotkeyToggleSensitive = () => {
- this.handleToggleMediaVisibility();
- };
-
- handleMoveUp = id => {
- const { status, ancestorsIds, descendantsIds } = this.props;
-
- if (id === status.get('id')) {
- this._selectChild(ancestorsIds.size - 1, true);
- } else {
- let index = ancestorsIds.indexOf(id);
-
- if (index === -1) {
- index = descendantsIds.indexOf(id);
- this._selectChild(ancestorsIds.size + index, true);
- } else {
- this._selectChild(index - 1, true);
- }
- }
- };
-
- handleMoveDown = id => {
- const { status, ancestorsIds, descendantsIds } = this.props;
-
- if (id === status.get('id')) {
- this._selectChild(ancestorsIds.size + 1, false);
- } else {
- let index = ancestorsIds.indexOf(id);
-
- if (index === -1) {
- index = descendantsIds.indexOf(id);
- this._selectChild(ancestorsIds.size + index + 2, false);
- } else {
- this._selectChild(index + 1, false);
- }
- }
- };
-
- _selectChild (index, align_top) {
- const container = this.node;
- const element = container.querySelectorAll('.focusable')[index];
-
- if (element) {
- if (align_top && container.scrollTop > element.offsetTop) {
- element.scrollIntoView(true);
- } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
- element.scrollIntoView(false);
- }
- element.focus();
- }
- }
-
- renderChildren (list) {
- return list.map(id => (
-
- ));
- }
-
- setRef = c => {
- this.node = c;
- };
-
- componentDidUpdate () {
- if (this._scrolledIntoView) {
- return;
- }
-
- const { status, ancestorsIds } = this.props;
-
- if (status && ancestorsIds && ancestorsIds.size > 0) {
- const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
-
- window.requestAnimationFrame(() => {
- element.scrollIntoView(true);
- });
- this._scrolledIntoView = true;
- }
- }
-
- componentWillUnmount () {
- detachFullscreenListener(this.onFullScreenChange);
- }
-
- onFullScreenChange = () => {
- this.setState({ fullscreen: isFullscreen() });
- };
-
- render () {
- let ancestors, descendants;
- const { isLoading, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
- const { fullscreen } = this.state;
-
- if (isLoading) {
- return (
-
-
-
- );
- }
-
- if (status === null) {
- return (
-
-
-
-
- );
- }
-
- if (ancestorsIds && ancestorsIds.size > 0) {
- ancestors = {this.renderChildren(ancestorsIds)}
;
- }
-
- if (descendantsIds && descendantsIds.size > 0) {
- descendants = {this.renderChildren(descendantsIds)}
;
- }
-
- const isLocal = status.getIn(['account', 'acct'], '').indexOf('@') === -1;
- const isIndexable = !status.getIn(['account', 'noindex']);
-
- const handlers = {
- moveUp: this.handleHotkeyMoveUp,
- moveDown: this.handleHotkeyMoveDown,
- reply: this.handleHotkeyReply,
- favourite: this.handleHotkeyFavourite,
- boost: this.handleHotkeyBoost,
- mention: this.handleHotkeyMention,
- openProfile: this.handleHotkeyOpenProfile,
- toggleHidden: this.handleHotkeyToggleHidden,
- toggleSensitive: this.handleHotkeyToggleSensitive,
- openMedia: this.handleHotkeyOpenMedia,
- };
-
- return (
-
-
- )}
- />
-
-
-
- {ancestors}
-
-
-
-
-
- {descendants}
-
-
-
-
- {titleFromStatus(status)}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx
new file mode 100644
index 000000000..2c6728fc0
--- /dev/null
+++ b/app/javascript/mastodon/features/status/index.jsx
@@ -0,0 +1,686 @@
+import Immutable from 'immutable';
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { createSelector } from 'reselect';
+import {
+ fetchStatus,
+ muteStatus,
+ unmuteStatus,
+ deleteStatus,
+ editStatus,
+ hideStatus,
+ revealStatus,
+ translateStatus,
+ undoStatusTranslation,
+} from '../../actions/statuses';
+import MissingIndicator from '../../components/missing_indicator';
+import LoadingIndicator from 'mastodon/components/loading_indicator';
+import DetailedStatus from './components/detailed_status';
+import ActionBar from './components/action_bar';
+import Column from '../ui/components/column';
+import {
+ favourite,
+ unfavourite,
+ bookmark,
+ unbookmark,
+ reblog,
+ unreblog,
+ pin,
+ unpin,
+} from '../../actions/interactions';
+import {
+ replyCompose,
+ mentionCompose,
+ directCompose,
+} from '../../actions/compose';
+import {
+ unblockAccount,
+ unmuteAccount,
+} from '../../actions/accounts';
+import {
+ blockDomain,
+ unblockDomain,
+} from '../../actions/domain_blocks';
+import { initMuteModal } from '../../actions/mutes';
+import { initBlockModal } from '../../actions/blocks';
+import { initBoostModal } from '../../actions/boosts';
+import { initReport } from '../../actions/reports';
+import { makeGetStatus, makeGetPictureInPicture } from '../../selectors';
+import ScrollContainer from 'mastodon/containers/scroll_container';
+import ColumnBackButton from '../../components/column_back_button';
+import ColumnHeader from '../../components/column_header';
+import StatusContainer from '../../containers/status_container';
+import { openModal } from '../../actions/modal';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { HotKeys } from 'react-hotkeys';
+import { boostModal, deleteModal } from '../../initial_state';
+import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
+import { textForScreenReader, defaultMediaVisibility } from '../../components/status';
+import Icon from 'mastodon/components/icon';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
+ deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
+ redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
+ redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
+ revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
+ hideAll: { id: 'status.show_less_all', defaultMessage: 'Show less for all' },
+ detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' },
+ replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
+ replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+ blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
+});
+
+const makeMapStateToProps = () => {
+ const getStatus = makeGetStatus();
+ const getPictureInPicture = makeGetPictureInPicture();
+
+ const getAncestorsIds = createSelector([
+ (_, { id }) => id,
+ state => state.getIn(['contexts', 'inReplyTos']),
+ ], (statusId, inReplyTos) => {
+ let ancestorsIds = Immutable.List();
+ ancestorsIds = ancestorsIds.withMutations(mutable => {
+ let id = statusId;
+
+ while (id && !mutable.includes(id)) {
+ mutable.unshift(id);
+ id = inReplyTos.get(id);
+ }
+ });
+
+ return ancestorsIds;
+ });
+
+ const getDescendantsIds = createSelector([
+ (_, { id }) => id,
+ state => state.getIn(['contexts', 'replies']),
+ state => state.get('statuses'),
+ ], (statusId, contextReplies, statuses) => {
+ let descendantsIds = [];
+ const ids = [statusId];
+
+ while (ids.length > 0) {
+ let id = ids.pop();
+ const replies = contextReplies.get(id);
+
+ if (statusId !== id) {
+ descendantsIds.push(id);
+ }
+
+ if (replies) {
+ replies.reverse().forEach(reply => {
+ if (!ids.includes(reply) && !descendantsIds.includes(reply) && statusId !== reply) ids.push(reply);
+ });
+ }
+ }
+
+ let insertAt = descendantsIds.findIndex((id) => statuses.get(id).get('in_reply_to_account_id') !== statuses.get(id).get('account'));
+ if (insertAt !== -1) {
+ descendantsIds.forEach((id, idx) => {
+ if (idx > insertAt && statuses.get(id).get('in_reply_to_account_id') === statuses.get(id).get('account')) {
+ descendantsIds.splice(idx, 1);
+ descendantsIds.splice(insertAt, 0, id);
+ insertAt += 1;
+ }
+ });
+ }
+
+ return Immutable.List(descendantsIds);
+ });
+
+ const mapStateToProps = (state, props) => {
+ const status = getStatus(state, { id: props.params.statusId });
+
+ let ancestorsIds = Immutable.List();
+ let descendantsIds = Immutable.List();
+
+ if (status) {
+ ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
+ descendantsIds = getDescendantsIds(state, { id: status.get('id') });
+ }
+
+ return {
+ isLoading: state.getIn(['statuses', props.params.statusId, 'isLoading']),
+ status,
+ ancestorsIds,
+ descendantsIds,
+ askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
+ domain: state.getIn(['meta', 'domain']),
+ pictureInPicture: getPictureInPicture(state, { id: props.params.statusId }),
+ };
+ };
+
+ return mapStateToProps;
+};
+
+const truncate = (str, num) => {
+ if (str.length > num) {
+ return str.slice(0, num) + '…';
+ } else {
+ return str;
+ }
+};
+
+const titleFromStatus = status => {
+ const displayName = status.getIn(['account', 'display_name']);
+ const username = status.getIn(['account', 'username']);
+ const prefix = displayName.trim().length === 0 ? username : displayName;
+ const text = status.get('search_index');
+
+ return `${prefix}: "${truncate(text, 30)}"`;
+};
+
+export default @injectIntl
+@connect(makeMapStateToProps)
+class Status extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ identity: PropTypes.object,
+ };
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ status: ImmutablePropTypes.map,
+ isLoading: PropTypes.bool,
+ ancestorsIds: ImmutablePropTypes.list,
+ descendantsIds: ImmutablePropTypes.list,
+ intl: PropTypes.object.isRequired,
+ askReplyConfirmation: PropTypes.bool,
+ multiColumn: PropTypes.bool,
+ domain: PropTypes.string.isRequired,
+ pictureInPicture: ImmutablePropTypes.contains({
+ inUse: PropTypes.bool,
+ available: PropTypes.bool,
+ }),
+ };
+
+ state = {
+ fullscreen: false,
+ showMedia: defaultMediaVisibility(this.props.status),
+ loadedStatusId: undefined,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchStatus(this.props.params.statusId));
+ }
+
+ componentDidMount () {
+ attachFullscreenListener(this.onFullScreenChange);
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
+ this._scrolledIntoView = false;
+ this.props.dispatch(fetchStatus(nextProps.params.statusId));
+ }
+
+ if (nextProps.params.statusId && nextProps.ancestorsIds.size > this.props.ancestorsIds.size) {
+ this._scrolledIntoView = false;
+ }
+
+ if (nextProps.status && nextProps.status.get('id') !== this.state.loadedStatusId) {
+ this.setState({ showMedia: defaultMediaVisibility(nextProps.status), loadedStatusId: nextProps.status.get('id') });
+ }
+ }
+
+ handleToggleMediaVisibility = () => {
+ this.setState({ showMedia: !this.state.showMedia });
+ };
+
+ handleFavouriteClick = (status) => {
+ const { dispatch } = this.props;
+ const { signedIn } = this.context.identity;
+
+ if (signedIn) {
+ if (status.get('favourited')) {
+ dispatch(unfavourite(status));
+ } else {
+ dispatch(favourite(status));
+ }
+ } else {
+ dispatch(openModal('INTERACTION', {
+ type: 'favourite',
+ accountId: status.getIn(['account', 'id']),
+ url: status.get('url'),
+ }));
+ }
+ };
+
+ handlePin = (status) => {
+ if (status.get('pinned')) {
+ this.props.dispatch(unpin(status));
+ } else {
+ this.props.dispatch(pin(status));
+ }
+ };
+
+ handleReplyClick = (status) => {
+ const { askReplyConfirmation, dispatch, intl } = this.props;
+ const { signedIn } = this.context.identity;
+
+ if (signedIn) {
+ if (askReplyConfirmation) {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.replyMessage),
+ confirm: intl.formatMessage(messages.replyConfirm),
+ onConfirm: () => dispatch(replyCompose(status, this.context.router.history)),
+ }));
+ } else {
+ dispatch(replyCompose(status, this.context.router.history));
+ }
+ } else {
+ dispatch(openModal('INTERACTION', {
+ type: 'reply',
+ accountId: status.getIn(['account', 'id']),
+ url: status.get('url'),
+ }));
+ }
+ };
+
+ handleModalReblog = (status, privacy) => {
+ this.props.dispatch(reblog(status, privacy));
+ };
+
+ handleReblogClick = (status, e) => {
+ const { dispatch } = this.props;
+ const { signedIn } = this.context.identity;
+
+ if (signedIn) {
+ if (status.get('reblogged')) {
+ dispatch(unreblog(status));
+ } else {
+ if ((e && e.shiftKey) || !boostModal) {
+ this.handleModalReblog(status);
+ } else {
+ dispatch(initBoostModal({ status, onReblog: this.handleModalReblog }));
+ }
+ }
+ } else {
+ dispatch(openModal('INTERACTION', {
+ type: 'reblog',
+ accountId: status.getIn(['account', 'id']),
+ url: status.get('url'),
+ }));
+ }
+ };
+
+ handleBookmarkClick = (status) => {
+ if (status.get('bookmarked')) {
+ this.props.dispatch(unbookmark(status));
+ } else {
+ this.props.dispatch(bookmark(status));
+ }
+ };
+
+ handleDeleteClick = (status, history, withRedraft = false) => {
+ const { dispatch, intl } = this.props;
+
+ if (!deleteModal) {
+ dispatch(deleteStatus(status.get('id'), history, withRedraft));
+ } else {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
+ confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
+ onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
+ }));
+ }
+ };
+
+ handleEditClick = (status, history) => {
+ this.props.dispatch(editStatus(status.get('id'), history));
+ };
+
+ handleDirectClick = (account, router) => {
+ this.props.dispatch(directCompose(account, router));
+ };
+
+ handleMentionClick = (account, router) => {
+ this.props.dispatch(mentionCompose(account, router));
+ };
+
+ handleOpenMedia = (media, index) => {
+ this.props.dispatch(openModal('MEDIA', { statusId: this.props.status.get('id'), media, index }));
+ };
+
+ handleOpenVideo = (media, options) => {
+ this.props.dispatch(openModal('VIDEO', { statusId: this.props.status.get('id'), media, options }));
+ };
+
+ handleHotkeyOpenMedia = e => {
+ const { status } = this.props;
+
+ e.preventDefault();
+
+ if (status.get('media_attachments').size > 0) {
+ if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+ this.handleOpenVideo(status.getIn(['media_attachments', 0]), { startTime: 0 });
+ } else {
+ this.handleOpenMedia(status.get('media_attachments'), 0);
+ }
+ }
+ };
+
+ handleMuteClick = (account) => {
+ this.props.dispatch(initMuteModal(account));
+ };
+
+ handleConversationMuteClick = (status) => {
+ if (status.get('muted')) {
+ this.props.dispatch(unmuteStatus(status.get('id')));
+ } else {
+ this.props.dispatch(muteStatus(status.get('id')));
+ }
+ };
+
+ handleToggleHidden = (status) => {
+ if (status.get('hidden')) {
+ this.props.dispatch(revealStatus(status.get('id')));
+ } else {
+ this.props.dispatch(hideStatus(status.get('id')));
+ }
+ };
+
+ handleToggleAll = () => {
+ const { status, ancestorsIds, descendantsIds } = this.props;
+ const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS());
+
+ if (status.get('hidden')) {
+ this.props.dispatch(revealStatus(statusIds));
+ } else {
+ this.props.dispatch(hideStatus(statusIds));
+ }
+ };
+
+ handleTranslate = status => {
+ const { dispatch } = this.props;
+
+ if (status.get('translation')) {
+ dispatch(undoStatusTranslation(status.get('id')));
+ } else {
+ dispatch(translateStatus(status.get('id')));
+ }
+ };
+
+ handleBlockClick = (status) => {
+ const { dispatch } = this.props;
+ const account = status.get('account');
+ dispatch(initBlockModal(account));
+ };
+
+ handleReport = (status) => {
+ this.props.dispatch(initReport(status.get('account'), status));
+ };
+
+ handleEmbed = (status) => {
+ this.props.dispatch(openModal('EMBED', { url: status.get('url') }));
+ };
+
+ handleUnmuteClick = account => {
+ this.props.dispatch(unmuteAccount(account.get('id')));
+ };
+
+ handleUnblockClick = account => {
+ this.props.dispatch(unblockAccount(account.get('id')));
+ };
+
+ handleBlockDomainClick = domain => {
+ this.props.dispatch(openModal('CONFIRM', {
+ message: {domain} }} />,
+ confirm: this.props.intl.formatMessage(messages.blockDomainConfirm),
+ onConfirm: () => this.props.dispatch(blockDomain(domain)),
+ }));
+ };
+
+ handleUnblockDomainClick = domain => {
+ this.props.dispatch(unblockDomain(domain));
+ };
+
+
+ handleHotkeyMoveUp = () => {
+ this.handleMoveUp(this.props.status.get('id'));
+ };
+
+ handleHotkeyMoveDown = () => {
+ this.handleMoveDown(this.props.status.get('id'));
+ };
+
+ handleHotkeyReply = e => {
+ e.preventDefault();
+ this.handleReplyClick(this.props.status);
+ };
+
+ handleHotkeyFavourite = () => {
+ this.handleFavouriteClick(this.props.status);
+ };
+
+ handleHotkeyBoost = () => {
+ this.handleReblogClick(this.props.status);
+ };
+
+ handleHotkeyMention = e => {
+ e.preventDefault();
+ this.handleMentionClick(this.props.status.get('account'));
+ };
+
+ handleHotkeyOpenProfile = () => {
+ this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
+ };
+
+ handleHotkeyToggleHidden = () => {
+ this.handleToggleHidden(this.props.status);
+ };
+
+ handleHotkeyToggleSensitive = () => {
+ this.handleToggleMediaVisibility();
+ };
+
+ handleMoveUp = id => {
+ const { status, ancestorsIds, descendantsIds } = this.props;
+
+ if (id === status.get('id')) {
+ this._selectChild(ancestorsIds.size - 1, true);
+ } else {
+ let index = ancestorsIds.indexOf(id);
+
+ if (index === -1) {
+ index = descendantsIds.indexOf(id);
+ this._selectChild(ancestorsIds.size + index, true);
+ } else {
+ this._selectChild(index - 1, true);
+ }
+ }
+ };
+
+ handleMoveDown = id => {
+ const { status, ancestorsIds, descendantsIds } = this.props;
+
+ if (id === status.get('id')) {
+ this._selectChild(ancestorsIds.size + 1, false);
+ } else {
+ let index = ancestorsIds.indexOf(id);
+
+ if (index === -1) {
+ index = descendantsIds.indexOf(id);
+ this._selectChild(ancestorsIds.size + index + 2, false);
+ } else {
+ this._selectChild(index + 1, false);
+ }
+ }
+ };
+
+ _selectChild (index, align_top) {
+ const container = this.node;
+ const element = container.querySelectorAll('.focusable')[index];
+
+ if (element) {
+ if (align_top && container.scrollTop > element.offsetTop) {
+ element.scrollIntoView(true);
+ } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
+ element.scrollIntoView(false);
+ }
+ element.focus();
+ }
+ }
+
+ renderChildren (list) {
+ return list.map(id => (
+
+ ));
+ }
+
+ setRef = c => {
+ this.node = c;
+ };
+
+ componentDidUpdate () {
+ if (this._scrolledIntoView) {
+ return;
+ }
+
+ const { status, ancestorsIds } = this.props;
+
+ if (status && ancestorsIds && ancestorsIds.size > 0) {
+ const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
+
+ window.requestAnimationFrame(() => {
+ element.scrollIntoView(true);
+ });
+ this._scrolledIntoView = true;
+ }
+ }
+
+ componentWillUnmount () {
+ detachFullscreenListener(this.onFullScreenChange);
+ }
+
+ onFullScreenChange = () => {
+ this.setState({ fullscreen: isFullscreen() });
+ };
+
+ render () {
+ let ancestors, descendants;
+ const { isLoading, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
+ const { fullscreen } = this.state;
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (status === null) {
+ return (
+
+
+
+
+ );
+ }
+
+ if (ancestorsIds && ancestorsIds.size > 0) {
+ ancestors = {this.renderChildren(ancestorsIds)}
;
+ }
+
+ if (descendantsIds && descendantsIds.size > 0) {
+ descendants = {this.renderChildren(descendantsIds)}
;
+ }
+
+ const isLocal = status.getIn(['account', 'acct'], '').indexOf('@') === -1;
+ const isIndexable = !status.getIn(['account', 'noindex']);
+
+ const handlers = {
+ moveUp: this.handleHotkeyMoveUp,
+ moveDown: this.handleHotkeyMoveDown,
+ reply: this.handleHotkeyReply,
+ favourite: this.handleHotkeyFavourite,
+ boost: this.handleHotkeyBoost,
+ mention: this.handleHotkeyMention,
+ openProfile: this.handleHotkeyOpenProfile,
+ toggleHidden: this.handleHotkeyToggleHidden,
+ toggleSensitive: this.handleHotkeyToggleSensitive,
+ openMedia: this.handleHotkeyOpenMedia,
+ };
+
+ return (
+
+
+ )}
+ />
+
+
+
+ {ancestors}
+
+
+
+
+
+ {descendants}
+
+
+
+
+ {titleFromStatus(status)}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/subscribed_languages_modal/index.js b/app/javascript/mastodon/features/subscribed_languages_modal/index.js
deleted file mode 100644
index f1360613e..000000000
--- a/app/javascript/mastodon/features/subscribed_languages_modal/index.js
+++ /dev/null
@@ -1,125 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import { is, List as ImmutableList, Set as ImmutableSet } from 'immutable';
-import { languages as preloadedLanguages } from 'mastodon/initial_state';
-import Option from 'mastodon/features/report/components/option';
-import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
-import IconButton from 'mastodon/components/icon_button';
-import Button from 'mastodon/components/button';
-import { followAccount } from 'mastodon/actions/accounts';
-
-const messages = defineMessages({
- close: { id: 'lightbox.close', defaultMessage: 'Close' },
-});
-
-const getAccountLanguages = createSelector([
- (state, accountId) => state.getIn(['timelines', `account:${accountId}`, 'items'], ImmutableList()),
- state => state.get('statuses'),
-], (statusIds, statuses) =>
- new ImmutableSet(statusIds.map(statusId => statuses.get(statusId)).filter(status => !status.get('reblog')).map(status => status.get('language'))));
-
-const mapStateToProps = (state, { accountId }) => ({
- acct: state.getIn(['accounts', accountId, 'acct']),
- availableLanguages: getAccountLanguages(state, accountId),
- selectedLanguages: ImmutableSet(state.getIn(['relationships', accountId, 'languages']) || ImmutableList()),
-});
-
-const mapDispatchToProps = (dispatch, { accountId }) => ({
-
- onSubmit (languages) {
- dispatch(followAccount(accountId, { languages }));
- },
-
-});
-
-export default @connect(mapStateToProps, mapDispatchToProps)
-@injectIntl
-class SubscribedLanguagesModal extends ImmutablePureComponent {
-
- static propTypes = {
- accountId: PropTypes.string.isRequired,
- acct: PropTypes.string.isRequired,
- availableLanguages: ImmutablePropTypes.setOf(PropTypes.string),
- selectedLanguages: ImmutablePropTypes.setOf(PropTypes.string),
- onClose: PropTypes.func.isRequired,
- languages: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)),
- intl: PropTypes.object.isRequired,
- submit: PropTypes.func.isRequired,
- };
-
- static defaultProps = {
- languages: preloadedLanguages,
- };
-
- state = {
- selectedLanguages: this.props.selectedLanguages,
- };
-
- handleLanguageToggle = (value, checked) => {
- const { selectedLanguages } = this.state;
-
- if (checked) {
- this.setState({ selectedLanguages: selectedLanguages.add(value) });
- } else {
- this.setState({ selectedLanguages: selectedLanguages.delete(value) });
- }
- };
-
- handleSubmit = () => {
- this.props.onSubmit(this.state.selectedLanguages.toArray());
- this.props.onClose();
- };
-
- renderItem (value) {
- const language = this.props.languages.find(language => language[0] === value);
- const checked = this.state.selectedLanguages.includes(value);
-
- if (!language) {
- return null;
- }
-
- return (
-
- );
- }
-
- render () {
- const { acct, availableLanguages, selectedLanguages, intl, onClose } = this.props;
-
- return (
-
-
-
- {acct} }} />
-
-
-
-
-
-
- {availableLanguages.union(selectedLanguages).delete(null).map(value => this.renderItem(value))}
-
-
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/subscribed_languages_modal/index.jsx b/app/javascript/mastodon/features/subscribed_languages_modal/index.jsx
new file mode 100644
index 000000000..f1360613e
--- /dev/null
+++ b/app/javascript/mastodon/features/subscribed_languages_modal/index.jsx
@@ -0,0 +1,125 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { is, List as ImmutableList, Set as ImmutableSet } from 'immutable';
+import { languages as preloadedLanguages } from 'mastodon/initial_state';
+import Option from 'mastodon/features/report/components/option';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import IconButton from 'mastodon/components/icon_button';
+import Button from 'mastodon/components/button';
+import { followAccount } from 'mastodon/actions/accounts';
+
+const messages = defineMessages({
+ close: { id: 'lightbox.close', defaultMessage: 'Close' },
+});
+
+const getAccountLanguages = createSelector([
+ (state, accountId) => state.getIn(['timelines', `account:${accountId}`, 'items'], ImmutableList()),
+ state => state.get('statuses'),
+], (statusIds, statuses) =>
+ new ImmutableSet(statusIds.map(statusId => statuses.get(statusId)).filter(status => !status.get('reblog')).map(status => status.get('language'))));
+
+const mapStateToProps = (state, { accountId }) => ({
+ acct: state.getIn(['accounts', accountId, 'acct']),
+ availableLanguages: getAccountLanguages(state, accountId),
+ selectedLanguages: ImmutableSet(state.getIn(['relationships', accountId, 'languages']) || ImmutableList()),
+});
+
+const mapDispatchToProps = (dispatch, { accountId }) => ({
+
+ onSubmit (languages) {
+ dispatch(followAccount(accountId, { languages }));
+ },
+
+});
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+class SubscribedLanguagesModal extends ImmutablePureComponent {
+
+ static propTypes = {
+ accountId: PropTypes.string.isRequired,
+ acct: PropTypes.string.isRequired,
+ availableLanguages: ImmutablePropTypes.setOf(PropTypes.string),
+ selectedLanguages: ImmutablePropTypes.setOf(PropTypes.string),
+ onClose: PropTypes.func.isRequired,
+ languages: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)),
+ intl: PropTypes.object.isRequired,
+ submit: PropTypes.func.isRequired,
+ };
+
+ static defaultProps = {
+ languages: preloadedLanguages,
+ };
+
+ state = {
+ selectedLanguages: this.props.selectedLanguages,
+ };
+
+ handleLanguageToggle = (value, checked) => {
+ const { selectedLanguages } = this.state;
+
+ if (checked) {
+ this.setState({ selectedLanguages: selectedLanguages.add(value) });
+ } else {
+ this.setState({ selectedLanguages: selectedLanguages.delete(value) });
+ }
+ };
+
+ handleSubmit = () => {
+ this.props.onSubmit(this.state.selectedLanguages.toArray());
+ this.props.onClose();
+ };
+
+ renderItem (value) {
+ const language = this.props.languages.find(language => language[0] === value);
+ const checked = this.state.selectedLanguages.includes(value);
+
+ if (!language) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+
+ render () {
+ const { acct, availableLanguages, selectedLanguages, intl, onClose } = this.props;
+
+ return (
+
+
+
+ {acct} }} />
+
+
+
+
+
+
+ {availableLanguages.union(selectedLanguages).delete(null).map(value => this.renderItem(value))}
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/__tests__/column-test.js b/app/javascript/mastodon/features/ui/components/__tests__/column-test.js
deleted file mode 100644
index a56859be0..000000000
--- a/app/javascript/mastodon/features/ui/components/__tests__/column-test.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { render, fireEvent, screen } from '@testing-library/react';
-import React from 'react';
-import Column from '../column';
-
-describe(' ', () => {
- describe(' click handler', () => {
- it('runs the scroll animation if the column contains scrollable content', () => {
- const scrollToMock = jest.fn();
- const { container } = render(
-
-
- ,
- );
- container.querySelector('.scrollable').scrollTo = scrollToMock;
- fireEvent.click(screen.getByText('notifications'));
- expect(scrollToMock).toHaveBeenCalledWith({ behavior: 'smooth', top: 0 });
- });
-
- it('does not try to scroll if there is no scrollable content', () => {
- render( );
- fireEvent.click(screen.getByText('notifications'));
- });
- });
-});
diff --git a/app/javascript/mastodon/features/ui/components/__tests__/column-test.jsx b/app/javascript/mastodon/features/ui/components/__tests__/column-test.jsx
new file mode 100644
index 000000000..a56859be0
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/__tests__/column-test.jsx
@@ -0,0 +1,24 @@
+import { render, fireEvent, screen } from '@testing-library/react';
+import React from 'react';
+import Column from '../column';
+
+describe(' ', () => {
+ describe(' click handler', () => {
+ it('runs the scroll animation if the column contains scrollable content', () => {
+ const scrollToMock = jest.fn();
+ const { container } = render(
+
+
+ ,
+ );
+ container.querySelector('.scrollable').scrollTo = scrollToMock;
+ fireEvent.click(screen.getByText('notifications'));
+ expect(scrollToMock).toHaveBeenCalledWith({ behavior: 'smooth', top: 0 });
+ });
+
+ it('does not try to scroll if there is no scrollable content', () => {
+ render( );
+ fireEvent.click(screen.getByText('notifications'));
+ });
+ });
+});
diff --git a/app/javascript/mastodon/features/ui/components/actions_modal.js b/app/javascript/mastodon/features/ui/components/actions_modal.js
deleted file mode 100644
index fd59c1e20..000000000
--- a/app/javascript/mastodon/features/ui/components/actions_modal.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import IconButton from '../../../components/icon_button';
-import classNames from 'classnames';
-
-export default class ActionsModal extends ImmutablePureComponent {
-
- static propTypes = {
- status: ImmutablePropTypes.map,
- actions: PropTypes.array,
- onClick: PropTypes.func,
- };
-
- renderAction = (action, i) => {
- if (action === null) {
- return ;
- }
-
- const { icon = null, text, meta = null, active = false, href = '#' } = action;
-
- return (
-
-
- {icon && }
-
-
-
- );
- };
-
- render () {
- return (
-
-
- {this.props.actions.map(this.renderAction)}
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/actions_modal.jsx b/app/javascript/mastodon/features/ui/components/actions_modal.jsx
new file mode 100644
index 000000000..fd59c1e20
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/actions_modal.jsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import IconButton from '../../../components/icon_button';
+import classNames from 'classnames';
+
+export default class ActionsModal extends ImmutablePureComponent {
+
+ static propTypes = {
+ status: ImmutablePropTypes.map,
+ actions: PropTypes.array,
+ onClick: PropTypes.func,
+ };
+
+ renderAction = (action, i) => {
+ if (action === null) {
+ return ;
+ }
+
+ const { icon = null, text, meta = null, active = false, href = '#' } = action;
+
+ return (
+
+
+ {icon && }
+
+
+
+ );
+ };
+
+ render () {
+ return (
+
+
+ {this.props.actions.map(this.renderAction)}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/audio_modal.js b/app/javascript/mastodon/features/ui/components/audio_modal.js
deleted file mode 100644
index c46fefce8..000000000
--- a/app/javascript/mastodon/features/ui/components/audio_modal.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import Audio from 'mastodon/features/audio';
-import { connect } from 'react-redux';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import Footer from 'mastodon/features/picture_in_picture/components/footer';
-
-const mapStateToProps = (state, { statusId }) => ({
- accountStaticAvatar: state.getIn(['accounts', state.getIn(['statuses', statusId, 'account']), 'avatar_static']),
-});
-
-export default @connect(mapStateToProps)
-class AudioModal extends ImmutablePureComponent {
-
- static propTypes = {
- media: ImmutablePropTypes.map.isRequired,
- statusId: PropTypes.string.isRequired,
- accountStaticAvatar: PropTypes.string.isRequired,
- options: PropTypes.shape({
- autoPlay: PropTypes.bool,
- }),
- onClose: PropTypes.func.isRequired,
- onChangeBackgroundColor: PropTypes.func.isRequired,
- };
-
- render () {
- const { media, accountStaticAvatar, statusId, onClose } = this.props;
- const options = this.props.options || {};
-
- return (
-
-
-
-
- {statusId && }
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/audio_modal.jsx b/app/javascript/mastodon/features/ui/components/audio_modal.jsx
new file mode 100644
index 000000000..c46fefce8
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/audio_modal.jsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Audio from 'mastodon/features/audio';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Footer from 'mastodon/features/picture_in_picture/components/footer';
+
+const mapStateToProps = (state, { statusId }) => ({
+ accountStaticAvatar: state.getIn(['accounts', state.getIn(['statuses', statusId, 'account']), 'avatar_static']),
+});
+
+export default @connect(mapStateToProps)
+class AudioModal extends ImmutablePureComponent {
+
+ static propTypes = {
+ media: ImmutablePropTypes.map.isRequired,
+ statusId: PropTypes.string.isRequired,
+ accountStaticAvatar: PropTypes.string.isRequired,
+ options: PropTypes.shape({
+ autoPlay: PropTypes.bool,
+ }),
+ onClose: PropTypes.func.isRequired,
+ onChangeBackgroundColor: PropTypes.func.isRequired,
+ };
+
+ render () {
+ const { media, accountStaticAvatar, statusId, onClose } = this.props;
+ const options = this.props.options || {};
+
+ return (
+
+
+
+
+ {statusId && }
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/block_modal.js b/app/javascript/mastodon/features/ui/components/block_modal.js
deleted file mode 100644
index 6c9d2043c..000000000
--- a/app/javascript/mastodon/features/ui/components/block_modal.js
+++ /dev/null
@@ -1,103 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import { injectIntl, FormattedMessage } from 'react-intl';
-import { makeGetAccount } from '../../../selectors';
-import Button from '../../../components/button';
-import { closeModal } from '../../../actions/modal';
-import { blockAccount } from '../../../actions/accounts';
-import { initReport } from '../../../actions/reports';
-
-
-const makeMapStateToProps = () => {
- const getAccount = makeGetAccount();
-
- const mapStateToProps = state => ({
- account: getAccount(state, state.getIn(['blocks', 'new', 'account_id'])),
- });
-
- return mapStateToProps;
-};
-
-const mapDispatchToProps = dispatch => {
- return {
- onConfirm(account) {
- dispatch(blockAccount(account.get('id')));
- },
-
- onBlockAndReport(account) {
- dispatch(blockAccount(account.get('id')));
- dispatch(initReport(account));
- },
-
- onClose() {
- dispatch(closeModal());
- },
- };
-};
-
-export default @connect(makeMapStateToProps, mapDispatchToProps)
-@injectIntl
-class BlockModal extends React.PureComponent {
-
- static propTypes = {
- account: PropTypes.object.isRequired,
- onClose: PropTypes.func.isRequired,
- onBlockAndReport: PropTypes.func.isRequired,
- onConfirm: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- componentDidMount() {
- this.button.focus();
- }
-
- handleClick = () => {
- this.props.onClose();
- this.props.onConfirm(this.props.account);
- };
-
- handleSecondary = () => {
- this.props.onClose();
- this.props.onBlockAndReport(this.props.account);
- };
-
- handleCancel = () => {
- this.props.onClose();
- };
-
- setRef = (c) => {
- this.button = c;
- };
-
- render () {
- const { account } = this.props;
-
- return (
-
-
-
- @{account.get('acct')} }}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/block_modal.jsx b/app/javascript/mastodon/features/ui/components/block_modal.jsx
new file mode 100644
index 000000000..6c9d2043c
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/block_modal.jsx
@@ -0,0 +1,103 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import { makeGetAccount } from '../../../selectors';
+import Button from '../../../components/button';
+import { closeModal } from '../../../actions/modal';
+import { blockAccount } from '../../../actions/accounts';
+import { initReport } from '../../../actions/reports';
+
+
+const makeMapStateToProps = () => {
+ const getAccount = makeGetAccount();
+
+ const mapStateToProps = state => ({
+ account: getAccount(state, state.getIn(['blocks', 'new', 'account_id'])),
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ onConfirm(account) {
+ dispatch(blockAccount(account.get('id')));
+ },
+
+ onBlockAndReport(account) {
+ dispatch(blockAccount(account.get('id')));
+ dispatch(initReport(account));
+ },
+
+ onClose() {
+ dispatch(closeModal());
+ },
+ };
+};
+
+export default @connect(makeMapStateToProps, mapDispatchToProps)
+@injectIntl
+class BlockModal extends React.PureComponent {
+
+ static propTypes = {
+ account: PropTypes.object.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onBlockAndReport: PropTypes.func.isRequired,
+ onConfirm: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ componentDidMount() {
+ this.button.focus();
+ }
+
+ handleClick = () => {
+ this.props.onClose();
+ this.props.onConfirm(this.props.account);
+ };
+
+ handleSecondary = () => {
+ this.props.onClose();
+ this.props.onBlockAndReport(this.props.account);
+ };
+
+ handleCancel = () => {
+ this.props.onClose();
+ };
+
+ setRef = (c) => {
+ this.button = c;
+ };
+
+ render () {
+ const { account } = this.props;
+
+ return (
+
+
+
+ @{account.get('acct')} }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.js b/app/javascript/mastodon/features/ui/components/boost_modal.js
deleted file mode 100644
index d6a6cea31..000000000
--- a/app/javascript/mastodon/features/ui/components/boost_modal.js
+++ /dev/null
@@ -1,142 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import Button from '../../../components/button';
-import StatusContent from '../../../components/status_content';
-import Avatar from '../../../components/avatar';
-import RelativeTimestamp from '../../../components/relative_timestamp';
-import DisplayName from '../../../components/display_name';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import Icon from 'mastodon/components/icon';
-import AttachmentList from 'mastodon/components/attachment_list';
-import PrivacyDropdown from 'mastodon/features/compose/components/privacy_dropdown';
-import classNames from 'classnames';
-import { changeBoostPrivacy } from 'mastodon/actions/boosts';
-
-const messages = defineMessages({
- cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
- reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
- public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
- unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
- private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
- direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
-});
-
-const mapStateToProps = state => {
- return {
- privacy: state.getIn(['boosts', 'new', 'privacy']),
- };
-};
-
-const mapDispatchToProps = dispatch => {
- return {
- onChangeBoostPrivacy(value) {
- dispatch(changeBoostPrivacy(value));
- },
- };
-};
-
-export default @connect(mapStateToProps, mapDispatchToProps)
-@injectIntl
-class BoostModal extends ImmutablePureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- };
-
- static propTypes = {
- status: ImmutablePropTypes.map.isRequired,
- onReblog: PropTypes.func.isRequired,
- onClose: PropTypes.func.isRequired,
- onChangeBoostPrivacy: PropTypes.func.isRequired,
- privacy: PropTypes.string.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- componentDidMount() {
- this.button.focus();
- }
-
- handleReblog = () => {
- this.props.onReblog(this.props.status, this.props.privacy);
- this.props.onClose();
- };
-
- handleAccountClick = (e) => {
- if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
- e.preventDefault();
- this.props.onClose();
- this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
- }
- };
-
- _findContainer = () => {
- return document.getElementsByClassName('modal-root__container')[0];
- };
-
- setRef = (c) => {
- this.button = c;
- };
-
- render () {
- const { status, privacy, intl } = this.props;
- const buttonText = status.get('reblogged') ? messages.cancel_reblog : messages.reblog;
-
- const visibilityIconInfo = {
- 'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
- 'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
- 'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
- 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) },
- };
-
- const visibilityIcon = visibilityIconInfo[status.get('visibility')];
-
- return (
-
-
-
-
-
-
-
- {status.get('media_attachments').size > 0 && (
-
- )}
-
-
-
-
-
Shift + }} />
- {status.get('visibility') !== 'private' && !status.get('reblogged') && (
-
- )}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.jsx b/app/javascript/mastodon/features/ui/components/boost_modal.jsx
new file mode 100644
index 000000000..d6a6cea31
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/boost_modal.jsx
@@ -0,0 +1,142 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Button from '../../../components/button';
+import StatusContent from '../../../components/status_content';
+import Avatar from '../../../components/avatar';
+import RelativeTimestamp from '../../../components/relative_timestamp';
+import DisplayName from '../../../components/display_name';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Icon from 'mastodon/components/icon';
+import AttachmentList from 'mastodon/components/attachment_list';
+import PrivacyDropdown from 'mastodon/features/compose/components/privacy_dropdown';
+import classNames from 'classnames';
+import { changeBoostPrivacy } from 'mastodon/actions/boosts';
+
+const messages = defineMessages({
+ cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
+ reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+ public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
+ unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+ private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
+ direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
+});
+
+const mapStateToProps = state => {
+ return {
+ privacy: state.getIn(['boosts', 'new', 'privacy']),
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ onChangeBoostPrivacy(value) {
+ dispatch(changeBoostPrivacy(value));
+ },
+ };
+};
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+class BoostModal extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ onReblog: PropTypes.func.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onChangeBoostPrivacy: PropTypes.func.isRequired,
+ privacy: PropTypes.string.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ componentDidMount() {
+ this.button.focus();
+ }
+
+ handleReblog = () => {
+ this.props.onReblog(this.props.status, this.props.privacy);
+ this.props.onClose();
+ };
+
+ handleAccountClick = (e) => {
+ if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ this.props.onClose();
+ this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
+ }
+ };
+
+ _findContainer = () => {
+ return document.getElementsByClassName('modal-root__container')[0];
+ };
+
+ setRef = (c) => {
+ this.button = c;
+ };
+
+ render () {
+ const { status, privacy, intl } = this.props;
+ const buttonText = status.get('reblogged') ? messages.cancel_reblog : messages.reblog;
+
+ const visibilityIconInfo = {
+ 'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
+ 'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
+ 'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
+ 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) },
+ };
+
+ const visibilityIcon = visibilityIconInfo[status.get('visibility')];
+
+ return (
+
+
+
+
+
+
+
+ {status.get('media_attachments').size > 0 && (
+
+ )}
+
+
+
+
+
Shift + }} />
+ {status.get('visibility') !== 'private' && !status.get('reblogged') && (
+
+ )}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/bundle.js b/app/javascript/mastodon/features/ui/components/bundle.js
deleted file mode 100644
index 1b10a218b..000000000
--- a/app/javascript/mastodon/features/ui/components/bundle.js
+++ /dev/null
@@ -1,106 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-const emptyComponent = () => null;
-const noop = () => { };
-
-class Bundle extends React.PureComponent {
-
- static propTypes = {
- fetchComponent: PropTypes.func.isRequired,
- loading: PropTypes.func,
- error: PropTypes.func,
- children: PropTypes.func.isRequired,
- renderDelay: PropTypes.number,
- onFetch: PropTypes.func,
- onFetchSuccess: PropTypes.func,
- onFetchFail: PropTypes.func,
- };
-
- static defaultProps = {
- loading: emptyComponent,
- error: emptyComponent,
- renderDelay: 0,
- onFetch: noop,
- onFetchSuccess: noop,
- onFetchFail: noop,
- };
-
- static cache = new Map;
-
- state = {
- mod: undefined,
- forceRender: false,
- };
-
- componentWillMount() {
- this.load(this.props);
- }
-
- componentWillReceiveProps(nextProps) {
- if (nextProps.fetchComponent !== this.props.fetchComponent) {
- this.load(nextProps);
- }
- }
-
- componentWillUnmount () {
- if (this.timeout) {
- clearTimeout(this.timeout);
- }
- }
-
- load = (props) => {
- const { fetchComponent, onFetch, onFetchSuccess, onFetchFail, renderDelay } = props || this.props;
- const cachedMod = Bundle.cache.get(fetchComponent);
-
- if (fetchComponent === undefined) {
- this.setState({ mod: null });
- return Promise.resolve();
- }
-
- onFetch();
-
- if (cachedMod) {
- this.setState({ mod: cachedMod.default });
- onFetchSuccess();
- return Promise.resolve();
- }
-
- this.setState({ mod: undefined });
-
- if (renderDelay !== 0) {
- this.timestamp = new Date();
- this.timeout = setTimeout(() => this.setState({ forceRender: true }), renderDelay);
- }
-
- return fetchComponent()
- .then((mod) => {
- Bundle.cache.set(fetchComponent, mod);
- this.setState({ mod: mod.default });
- onFetchSuccess();
- })
- .catch((error) => {
- this.setState({ mod: null });
- onFetchFail(error);
- });
- };
-
- render() {
- const { loading: Loading, error: Error, children, renderDelay } = this.props;
- const { mod, forceRender } = this.state;
- const elapsed = this.timestamp ? (new Date() - this.timestamp) : renderDelay;
-
- if (mod === undefined) {
- return (elapsed >= renderDelay || forceRender) ? : null;
- }
-
- if (mod === null) {
- return ;
- }
-
- return children(mod);
- }
-
-}
-
-export default Bundle;
diff --git a/app/javascript/mastodon/features/ui/components/bundle.jsx b/app/javascript/mastodon/features/ui/components/bundle.jsx
new file mode 100644
index 000000000..1b10a218b
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/bundle.jsx
@@ -0,0 +1,106 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const emptyComponent = () => null;
+const noop = () => { };
+
+class Bundle extends React.PureComponent {
+
+ static propTypes = {
+ fetchComponent: PropTypes.func.isRequired,
+ loading: PropTypes.func,
+ error: PropTypes.func,
+ children: PropTypes.func.isRequired,
+ renderDelay: PropTypes.number,
+ onFetch: PropTypes.func,
+ onFetchSuccess: PropTypes.func,
+ onFetchFail: PropTypes.func,
+ };
+
+ static defaultProps = {
+ loading: emptyComponent,
+ error: emptyComponent,
+ renderDelay: 0,
+ onFetch: noop,
+ onFetchSuccess: noop,
+ onFetchFail: noop,
+ };
+
+ static cache = new Map;
+
+ state = {
+ mod: undefined,
+ forceRender: false,
+ };
+
+ componentWillMount() {
+ this.load(this.props);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (nextProps.fetchComponent !== this.props.fetchComponent) {
+ this.load(nextProps);
+ }
+ }
+
+ componentWillUnmount () {
+ if (this.timeout) {
+ clearTimeout(this.timeout);
+ }
+ }
+
+ load = (props) => {
+ const { fetchComponent, onFetch, onFetchSuccess, onFetchFail, renderDelay } = props || this.props;
+ const cachedMod = Bundle.cache.get(fetchComponent);
+
+ if (fetchComponent === undefined) {
+ this.setState({ mod: null });
+ return Promise.resolve();
+ }
+
+ onFetch();
+
+ if (cachedMod) {
+ this.setState({ mod: cachedMod.default });
+ onFetchSuccess();
+ return Promise.resolve();
+ }
+
+ this.setState({ mod: undefined });
+
+ if (renderDelay !== 0) {
+ this.timestamp = new Date();
+ this.timeout = setTimeout(() => this.setState({ forceRender: true }), renderDelay);
+ }
+
+ return fetchComponent()
+ .then((mod) => {
+ Bundle.cache.set(fetchComponent, mod);
+ this.setState({ mod: mod.default });
+ onFetchSuccess();
+ })
+ .catch((error) => {
+ this.setState({ mod: null });
+ onFetchFail(error);
+ });
+ };
+
+ render() {
+ const { loading: Loading, error: Error, children, renderDelay } = this.props;
+ const { mod, forceRender } = this.state;
+ const elapsed = this.timestamp ? (new Date() - this.timestamp) : renderDelay;
+
+ if (mod === undefined) {
+ return (elapsed >= renderDelay || forceRender) ? : null;
+ }
+
+ if (mod === null) {
+ return ;
+ }
+
+ return children(mod);
+ }
+
+}
+
+export default Bundle;
diff --git a/app/javascript/mastodon/features/ui/components/bundle_column_error.js b/app/javascript/mastodon/features/ui/components/bundle_column_error.js
deleted file mode 100644
index 9955173eb..000000000
--- a/app/javascript/mastodon/features/ui/components/bundle_column_error.js
+++ /dev/null
@@ -1,162 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { injectIntl, FormattedMessage } from 'react-intl';
-import Column from 'mastodon/components/column';
-import Button from 'mastodon/components/button';
-import { Helmet } from 'react-helmet';
-import { Link } from 'react-router-dom';
-import classNames from 'classnames';
-import { autoPlayGif } from 'mastodon/initial_state';
-
-class GIF extends React.PureComponent {
-
- static propTypes = {
- src: PropTypes.string.isRequired,
- staticSrc: PropTypes.string.isRequired,
- className: PropTypes.string,
- animate: PropTypes.bool,
- };
-
- static defaultProps = {
- animate: autoPlayGif,
- };
-
- state = {
- hovering: false,
- };
-
- handleMouseEnter = () => {
- const { animate } = this.props;
-
- if (!animate) {
- this.setState({ hovering: true });
- }
- };
-
- handleMouseLeave = () => {
- const { animate } = this.props;
-
- if (!animate) {
- this.setState({ hovering: false });
- }
- };
-
- render () {
- const { src, staticSrc, className, animate } = this.props;
- const { hovering } = this.state;
-
- return (
-
- );
- }
-
-}
-
-class CopyButton extends React.PureComponent {
-
- static propTypes = {
- children: PropTypes.node.isRequired,
- value: PropTypes.string.isRequired,
- };
-
- state = {
- copied: false,
- };
-
- handleClick = () => {
- const { value } = this.props;
- navigator.clipboard.writeText(value);
- this.setState({ copied: true });
- this.timeout = setTimeout(() => this.setState({ copied: false }), 700);
- };
-
- componentWillUnmount () {
- if (this.timeout) clearTimeout(this.timeout);
- }
-
- render () {
- const { children } = this.props;
- const { copied } = this.state;
-
- return (
- {copied ? : children}
- );
- }
-
-}
-
-export default @injectIntl
-class BundleColumnError extends React.PureComponent {
-
- static propTypes = {
- errorType: PropTypes.oneOf(['routing', 'network', 'error']),
- onRetry: PropTypes.func,
- intl: PropTypes.object.isRequired,
- multiColumn: PropTypes.bool,
- stacktrace: PropTypes.string,
- };
-
- static defaultProps = {
- errorType: 'routing',
- };
-
- handleRetry = () => {
- const { onRetry } = this.props;
-
- if (onRetry) {
- onRetry();
- }
- };
-
- render () {
- const { errorType, multiColumn, stacktrace } = this.props;
-
- let title, body;
-
- switch(errorType) {
- case 'routing':
- title = ;
- body = ;
- break;
- case 'network':
- title = ;
- body = ;
- break;
- case 'error':
- title = ;
- body = ;
- break;
- }
-
- return (
-
-
-
-
-
-
{title}
-
{body}
-
-
- {errorType === 'network' && }
- {errorType === 'error' && }
-
-
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/bundle_column_error.jsx b/app/javascript/mastodon/features/ui/components/bundle_column_error.jsx
new file mode 100644
index 000000000..9955173eb
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/bundle_column_error.jsx
@@ -0,0 +1,162 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import Column from 'mastodon/components/column';
+import Button from 'mastodon/components/button';
+import { Helmet } from 'react-helmet';
+import { Link } from 'react-router-dom';
+import classNames from 'classnames';
+import { autoPlayGif } from 'mastodon/initial_state';
+
+class GIF extends React.PureComponent {
+
+ static propTypes = {
+ src: PropTypes.string.isRequired,
+ staticSrc: PropTypes.string.isRequired,
+ className: PropTypes.string,
+ animate: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ animate: autoPlayGif,
+ };
+
+ state = {
+ hovering: false,
+ };
+
+ handleMouseEnter = () => {
+ const { animate } = this.props;
+
+ if (!animate) {
+ this.setState({ hovering: true });
+ }
+ };
+
+ handleMouseLeave = () => {
+ const { animate } = this.props;
+
+ if (!animate) {
+ this.setState({ hovering: false });
+ }
+ };
+
+ render () {
+ const { src, staticSrc, className, animate } = this.props;
+ const { hovering } = this.state;
+
+ return (
+
+ );
+ }
+
+}
+
+class CopyButton extends React.PureComponent {
+
+ static propTypes = {
+ children: PropTypes.node.isRequired,
+ value: PropTypes.string.isRequired,
+ };
+
+ state = {
+ copied: false,
+ };
+
+ handleClick = () => {
+ const { value } = this.props;
+ navigator.clipboard.writeText(value);
+ this.setState({ copied: true });
+ this.timeout = setTimeout(() => this.setState({ copied: false }), 700);
+ };
+
+ componentWillUnmount () {
+ if (this.timeout) clearTimeout(this.timeout);
+ }
+
+ render () {
+ const { children } = this.props;
+ const { copied } = this.state;
+
+ return (
+ {copied ? : children}
+ );
+ }
+
+}
+
+export default @injectIntl
+class BundleColumnError extends React.PureComponent {
+
+ static propTypes = {
+ errorType: PropTypes.oneOf(['routing', 'network', 'error']),
+ onRetry: PropTypes.func,
+ intl: PropTypes.object.isRequired,
+ multiColumn: PropTypes.bool,
+ stacktrace: PropTypes.string,
+ };
+
+ static defaultProps = {
+ errorType: 'routing',
+ };
+
+ handleRetry = () => {
+ const { onRetry } = this.props;
+
+ if (onRetry) {
+ onRetry();
+ }
+ };
+
+ render () {
+ const { errorType, multiColumn, stacktrace } = this.props;
+
+ let title, body;
+
+ switch(errorType) {
+ case 'routing':
+ title = ;
+ body = ;
+ break;
+ case 'network':
+ title = ;
+ body = ;
+ break;
+ case 'error':
+ title = ;
+ body = ;
+ break;
+ }
+
+ return (
+
+
+
+
+
+
{title}
+
{body}
+
+
+ {errorType === 'network' && }
+ {errorType === 'error' && }
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/bundle_modal_error.js b/app/javascript/mastodon/features/ui/components/bundle_modal_error.js
deleted file mode 100644
index d79d0ca4a..000000000
--- a/app/javascript/mastodon/features/ui/components/bundle_modal_error.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { defineMessages, injectIntl } from 'react-intl';
-
-import IconButton from '../../../components/icon_button';
-
-const messages = defineMessages({
- error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this component.' },
- retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' },
- close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' },
-});
-
-class BundleModalError extends React.PureComponent {
-
- static propTypes = {
- onRetry: PropTypes.func.isRequired,
- onClose: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- handleRetry = () => {
- this.props.onRetry();
- };
-
- render () {
- const { onClose, intl: { formatMessage } } = this.props;
-
- // Keep the markup in sync with
- // (make sure they have the same dimensions)
- return (
-
-
-
- {formatMessage(messages.error)}
-
-
-
-
-
- {formatMessage(messages.close)}
-
-
-
-
- );
- }
-
-}
-
-export default injectIntl(BundleModalError);
diff --git a/app/javascript/mastodon/features/ui/components/bundle_modal_error.jsx b/app/javascript/mastodon/features/ui/components/bundle_modal_error.jsx
new file mode 100644
index 000000000..d79d0ca4a
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/bundle_modal_error.jsx
@@ -0,0 +1,53 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+
+import IconButton from '../../../components/icon_button';
+
+const messages = defineMessages({
+ error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this component.' },
+ retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' },
+ close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' },
+});
+
+class BundleModalError extends React.PureComponent {
+
+ static propTypes = {
+ onRetry: PropTypes.func.isRequired,
+ onClose: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleRetry = () => {
+ this.props.onRetry();
+ };
+
+ render () {
+ const { onClose, intl: { formatMessage } } = this.props;
+
+ // Keep the markup in sync with
+ // (make sure they have the same dimensions)
+ return (
+
+
+
+ {formatMessage(messages.error)}
+
+
+
+
+
+ {formatMessage(messages.close)}
+
+
+
+
+ );
+ }
+
+}
+
+export default injectIntl(BundleModalError);
diff --git a/app/javascript/mastodon/features/ui/components/column.js b/app/javascript/mastodon/features/ui/components/column.js
deleted file mode 100644
index 7bc2f7e00..000000000
--- a/app/javascript/mastodon/features/ui/components/column.js
+++ /dev/null
@@ -1,72 +0,0 @@
-import React from 'react';
-import ColumnHeader from './column_header';
-import PropTypes from 'prop-types';
-import { debounce } from 'lodash';
-import { scrollTop } from '../../../scroll';
-import { isMobile } from '../../../is_mobile';
-
-export default class Column extends React.PureComponent {
-
- static propTypes = {
- heading: PropTypes.string,
- icon: PropTypes.string,
- children: PropTypes.node,
- active: PropTypes.bool,
- hideHeadingOnMobile: PropTypes.bool,
- };
-
- handleHeaderClick = () => {
- const scrollable = this.node.querySelector('.scrollable');
-
- if (!scrollable) {
- return;
- }
-
- this._interruptScrollAnimation = scrollTop(scrollable);
- };
-
- scrollTop () {
- const scrollable = this.node.querySelector('.scrollable');
-
- if (!scrollable) {
- return;
- }
-
- this._interruptScrollAnimation = scrollTop(scrollable);
- }
-
-
- handleScroll = debounce(() => {
- if (typeof this._interruptScrollAnimation !== 'undefined') {
- this._interruptScrollAnimation();
- }
- }, 200);
-
- setRef = (c) => {
- this.node = c;
- };
-
- render () {
- const { heading, icon, children, active, hideHeadingOnMobile } = this.props;
-
- const showHeading = heading && (!hideHeadingOnMobile || (hideHeadingOnMobile && !isMobile(window.innerWidth)));
-
- const columnHeaderId = showHeading && heading.replace(/ /g, '-');
- const header = showHeading && (
-
- );
- return (
-
- {header}
- {children}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/column.jsx b/app/javascript/mastodon/features/ui/components/column.jsx
new file mode 100644
index 000000000..7bc2f7e00
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/column.jsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import ColumnHeader from './column_header';
+import PropTypes from 'prop-types';
+import { debounce } from 'lodash';
+import { scrollTop } from '../../../scroll';
+import { isMobile } from '../../../is_mobile';
+
+export default class Column extends React.PureComponent {
+
+ static propTypes = {
+ heading: PropTypes.string,
+ icon: PropTypes.string,
+ children: PropTypes.node,
+ active: PropTypes.bool,
+ hideHeadingOnMobile: PropTypes.bool,
+ };
+
+ handleHeaderClick = () => {
+ const scrollable = this.node.querySelector('.scrollable');
+
+ if (!scrollable) {
+ return;
+ }
+
+ this._interruptScrollAnimation = scrollTop(scrollable);
+ };
+
+ scrollTop () {
+ const scrollable = this.node.querySelector('.scrollable');
+
+ if (!scrollable) {
+ return;
+ }
+
+ this._interruptScrollAnimation = scrollTop(scrollable);
+ }
+
+
+ handleScroll = debounce(() => {
+ if (typeof this._interruptScrollAnimation !== 'undefined') {
+ this._interruptScrollAnimation();
+ }
+ }, 200);
+
+ setRef = (c) => {
+ this.node = c;
+ };
+
+ render () {
+ const { heading, icon, children, active, hideHeadingOnMobile } = this.props;
+
+ const showHeading = heading && (!hideHeadingOnMobile || (hideHeadingOnMobile && !isMobile(window.innerWidth)));
+
+ const columnHeaderId = showHeading && heading.replace(/ /g, '-');
+ const header = showHeading && (
+
+ );
+ return (
+
+ {header}
+ {children}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/column_header.js b/app/javascript/mastodon/features/ui/components/column_header.js
deleted file mode 100644
index 4ceef5957..000000000
--- a/app/javascript/mastodon/features/ui/components/column_header.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import classNames from 'classnames';
-import Icon from 'mastodon/components/icon';
-
-export default class ColumnHeader extends React.PureComponent {
-
- static propTypes = {
- icon: PropTypes.string,
- type: PropTypes.string,
- active: PropTypes.bool,
- onClick: PropTypes.func,
- columnHeaderId: PropTypes.string,
- };
-
- handleClick = () => {
- this.props.onClick();
- };
-
- render () {
- const { icon, type, active, columnHeaderId } = this.props;
- let iconElement = '';
-
- if (icon) {
- iconElement = ;
- }
-
- return (
-
-
- {iconElement}
- {type}
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/column_header.jsx b/app/javascript/mastodon/features/ui/components/column_header.jsx
new file mode 100644
index 000000000..4ceef5957
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/column_header.jsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import Icon from 'mastodon/components/icon';
+
+export default class ColumnHeader extends React.PureComponent {
+
+ static propTypes = {
+ icon: PropTypes.string,
+ type: PropTypes.string,
+ active: PropTypes.bool,
+ onClick: PropTypes.func,
+ columnHeaderId: PropTypes.string,
+ };
+
+ handleClick = () => {
+ this.props.onClick();
+ };
+
+ render () {
+ const { icon, type, active, columnHeaderId } = this.props;
+ let iconElement = '';
+
+ if (icon) {
+ iconElement = ;
+ }
+
+ return (
+
+
+ {iconElement}
+ {type}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/column_link.js b/app/javascript/mastodon/features/ui/components/column_link.js
deleted file mode 100644
index 8eebbf526..000000000
--- a/app/javascript/mastodon/features/ui/components/column_link.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { NavLink } from 'react-router-dom';
-import Icon from 'mastodon/components/icon';
-import classNames from 'classnames';
-
-const ColumnLink = ({ icon, text, to, href, method, badge, transparent, ...other }) => {
- const className = classNames('column-link', { 'column-link--transparent': transparent });
- const badgeElement = typeof badge !== 'undefined' ? {badge} : null;
- const iconElement = typeof icon === 'string' ? : icon;
-
- if (href) {
- return (
-
- {iconElement}
- {text}
- {badgeElement}
-
- );
- } else {
- return (
-
- {iconElement}
- {text}
- {badgeElement}
-
- );
- }
-};
-
-ColumnLink.propTypes = {
- icon: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
- text: PropTypes.string.isRequired,
- to: PropTypes.string,
- href: PropTypes.string,
- method: PropTypes.string,
- badge: PropTypes.node,
- transparent: PropTypes.bool,
-};
-
-export default ColumnLink;
diff --git a/app/javascript/mastodon/features/ui/components/column_link.jsx b/app/javascript/mastodon/features/ui/components/column_link.jsx
new file mode 100644
index 000000000..8eebbf526
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/column_link.jsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { NavLink } from 'react-router-dom';
+import Icon from 'mastodon/components/icon';
+import classNames from 'classnames';
+
+const ColumnLink = ({ icon, text, to, href, method, badge, transparent, ...other }) => {
+ const className = classNames('column-link', { 'column-link--transparent': transparent });
+ const badgeElement = typeof badge !== 'undefined' ? {badge} : null;
+ const iconElement = typeof icon === 'string' ? : icon;
+
+ if (href) {
+ return (
+
+ {iconElement}
+ {text}
+ {badgeElement}
+
+ );
+ } else {
+ return (
+
+ {iconElement}
+ {text}
+ {badgeElement}
+
+ );
+ }
+};
+
+ColumnLink.propTypes = {
+ icon: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
+ text: PropTypes.string.isRequired,
+ to: PropTypes.string,
+ href: PropTypes.string,
+ method: PropTypes.string,
+ badge: PropTypes.node,
+ transparent: PropTypes.bool,
+};
+
+export default ColumnLink;
diff --git a/app/javascript/mastodon/features/ui/components/column_loading.js b/app/javascript/mastodon/features/ui/components/column_loading.js
deleted file mode 100644
index e5ed22584..000000000
--- a/app/javascript/mastodon/features/ui/components/column_loading.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import Column from '../../../components/column';
-import ColumnHeader from '../../../components/column_header';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-export default class ColumnLoading extends ImmutablePureComponent {
-
- static propTypes = {
- title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
- icon: PropTypes.string,
- multiColumn: PropTypes.bool,
- };
-
- static defaultProps = {
- title: '',
- icon: '',
- };
-
- render() {
- let { title, icon, multiColumn } = this.props;
-
- return (
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/column_loading.jsx b/app/javascript/mastodon/features/ui/components/column_loading.jsx
new file mode 100644
index 000000000..e5ed22584
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/column_loading.jsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Column from '../../../components/column';
+import ColumnHeader from '../../../components/column_header';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class ColumnLoading extends ImmutablePureComponent {
+
+ static propTypes = {
+ title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
+ icon: PropTypes.string,
+ multiColumn: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ title: '',
+ icon: '',
+ };
+
+ render() {
+ let { title, icon, multiColumn } = this.props;
+
+ return (
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/column_subheading.js b/app/javascript/mastodon/features/ui/components/column_subheading.js
deleted file mode 100644
index 8160c4aa3..000000000
--- a/app/javascript/mastodon/features/ui/components/column_subheading.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-const ColumnSubheading = ({ text }) => {
- return (
-
- {text}
-
- );
-};
-
-ColumnSubheading.propTypes = {
- text: PropTypes.string.isRequired,
-};
-
-export default ColumnSubheading;
diff --git a/app/javascript/mastodon/features/ui/components/column_subheading.jsx b/app/javascript/mastodon/features/ui/components/column_subheading.jsx
new file mode 100644
index 000000000..8160c4aa3
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/column_subheading.jsx
@@ -0,0 +1,16 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const ColumnSubheading = ({ text }) => {
+ return (
+
+ {text}
+
+ );
+};
+
+ColumnSubheading.propTypes = {
+ text: PropTypes.string.isRequired,
+};
+
+export default ColumnSubheading;
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
deleted file mode 100644
index 1dd6e34e8..000000000
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ /dev/null
@@ -1,181 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import BundleContainer from '../containers/bundle_container';
-import ColumnLoading from './column_loading';
-import DrawerLoading from './drawer_loading';
-import BundleColumnError from './bundle_column_error';
-import {
- Compose,
- Notifications,
- HomeTimeline,
- CommunityTimeline,
- PublicTimeline,
- HashtagTimeline,
- DirectTimeline,
- FavouritedStatuses,
- BookmarkedStatuses,
- ListTimeline,
- Directory,
-} from '../../ui/util/async-components';
-import ComposePanel from './compose_panel';
-import NavigationPanel from './navigation_panel';
-import { supportsPassiveEvents } from 'detect-passive-events';
-import { scrollRight } from '../../../scroll';
-
-const componentMap = {
- 'COMPOSE': Compose,
- 'HOME': HomeTimeline,
- 'NOTIFICATIONS': Notifications,
- 'PUBLIC': PublicTimeline,
- 'REMOTE': PublicTimeline,
- 'COMMUNITY': CommunityTimeline,
- 'HASHTAG': HashtagTimeline,
- 'DIRECT': DirectTimeline,
- 'FAVOURITES': FavouritedStatuses,
- 'BOOKMARKS': BookmarkedStatuses,
- 'LIST': ListTimeline,
- 'DIRECTORY': Directory,
-};
-
-export default class ColumnsArea extends ImmutablePureComponent {
-
- static contextTypes = {
- router: PropTypes.object.isRequired,
- };
-
- static propTypes = {
- columns: ImmutablePropTypes.list.isRequired,
- isModalOpen: PropTypes.bool.isRequired,
- singleColumn: PropTypes.bool,
- children: PropTypes.node,
- };
-
- // Corresponds to (max-width: $no-gap-breakpoint + 285px - 1px) in SCSS
- mediaQuery = 'matchMedia' in window && window.matchMedia('(max-width: 1174px)');
-
- state = {
- renderComposePanel: !(this.mediaQuery && this.mediaQuery.matches),
- };
-
- componentDidMount() {
- if (!this.props.singleColumn) {
- this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
- }
-
- if (this.mediaQuery) {
- if (this.mediaQuery.addEventListener) {
- this.mediaQuery.addEventListener('change', this.handleLayoutChange);
- } else {
- this.mediaQuery.addListener(this.handleLayoutChange);
- }
- this.setState({ renderComposePanel: !this.mediaQuery.matches });
- }
-
- this.isRtlLayout = document.getElementsByTagName('body')[0].classList.contains('rtl');
- }
-
- componentWillUpdate(nextProps) {
- if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) {
- this.node.removeEventListener('wheel', this.handleWheel);
- }
- }
-
- componentDidUpdate(prevProps) {
- if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) {
- this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
- }
- }
-
- componentWillUnmount () {
- if (!this.props.singleColumn) {
- this.node.removeEventListener('wheel', this.handleWheel);
- }
-
- if (this.mediaQuery) {
- if (this.mediaQuery.removeEventListener) {
- this.mediaQuery.removeEventListener('change', this.handleLayoutChange);
- } else {
- this.mediaQuery.removeListener(this.handleLayoutChange);
- }
- }
- }
-
- handleChildrenContentChange() {
- if (!this.props.singleColumn) {
- const modifier = this.isRtlLayout ? -1 : 1;
- this._interruptScrollAnimation = scrollRight(this.node, (this.node.scrollWidth - window.innerWidth) * modifier);
- }
- }
-
- handleLayoutChange = (e) => {
- this.setState({ renderComposePanel: !e.matches });
- };
-
- handleWheel = () => {
- if (typeof this._interruptScrollAnimation !== 'function') {
- return;
- }
-
- this._interruptScrollAnimation();
- };
-
- setRef = (node) => {
- this.node = node;
- };
-
- renderLoading = columnId => () => {
- return columnId === 'COMPOSE' ? : ;
- };
-
- renderError = (props) => {
- return ;
- };
-
- render () {
- const { columns, children, singleColumn, isModalOpen } = this.props;
- const { renderComposePanel } = this.state;
-
- if (singleColumn) {
- return (
-
-
-
- {renderComposePanel && }
-
-
-
-
-
-
-
- );
- }
-
- return (
-
- {columns.map(column => {
- const params = column.get('params', null) === null ? null : column.get('params').toJS();
- const other = params && params.other ? params.other : {};
-
- return (
-
- {SpecificComponent => }
-
- );
- })}
-
- {React.Children.map(children, child => React.cloneElement(child, { multiColumn: true }))}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.jsx b/app/javascript/mastodon/features/ui/components/columns_area.jsx
new file mode 100644
index 000000000..1dd6e34e8
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/columns_area.jsx
@@ -0,0 +1,181 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import BundleContainer from '../containers/bundle_container';
+import ColumnLoading from './column_loading';
+import DrawerLoading from './drawer_loading';
+import BundleColumnError from './bundle_column_error';
+import {
+ Compose,
+ Notifications,
+ HomeTimeline,
+ CommunityTimeline,
+ PublicTimeline,
+ HashtagTimeline,
+ DirectTimeline,
+ FavouritedStatuses,
+ BookmarkedStatuses,
+ ListTimeline,
+ Directory,
+} from '../../ui/util/async-components';
+import ComposePanel from './compose_panel';
+import NavigationPanel from './navigation_panel';
+import { supportsPassiveEvents } from 'detect-passive-events';
+import { scrollRight } from '../../../scroll';
+
+const componentMap = {
+ 'COMPOSE': Compose,
+ 'HOME': HomeTimeline,
+ 'NOTIFICATIONS': Notifications,
+ 'PUBLIC': PublicTimeline,
+ 'REMOTE': PublicTimeline,
+ 'COMMUNITY': CommunityTimeline,
+ 'HASHTAG': HashtagTimeline,
+ 'DIRECT': DirectTimeline,
+ 'FAVOURITES': FavouritedStatuses,
+ 'BOOKMARKS': BookmarkedStatuses,
+ 'LIST': ListTimeline,
+ 'DIRECTORY': Directory,
+};
+
+export default class ColumnsArea extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object.isRequired,
+ };
+
+ static propTypes = {
+ columns: ImmutablePropTypes.list.isRequired,
+ isModalOpen: PropTypes.bool.isRequired,
+ singleColumn: PropTypes.bool,
+ children: PropTypes.node,
+ };
+
+ // Corresponds to (max-width: $no-gap-breakpoint + 285px - 1px) in SCSS
+ mediaQuery = 'matchMedia' in window && window.matchMedia('(max-width: 1174px)');
+
+ state = {
+ renderComposePanel: !(this.mediaQuery && this.mediaQuery.matches),
+ };
+
+ componentDidMount() {
+ if (!this.props.singleColumn) {
+ this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
+ }
+
+ if (this.mediaQuery) {
+ if (this.mediaQuery.addEventListener) {
+ this.mediaQuery.addEventListener('change', this.handleLayoutChange);
+ } else {
+ this.mediaQuery.addListener(this.handleLayoutChange);
+ }
+ this.setState({ renderComposePanel: !this.mediaQuery.matches });
+ }
+
+ this.isRtlLayout = document.getElementsByTagName('body')[0].classList.contains('rtl');
+ }
+
+ componentWillUpdate(nextProps) {
+ if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) {
+ this.node.removeEventListener('wheel', this.handleWheel);
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) {
+ this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
+ }
+ }
+
+ componentWillUnmount () {
+ if (!this.props.singleColumn) {
+ this.node.removeEventListener('wheel', this.handleWheel);
+ }
+
+ if (this.mediaQuery) {
+ if (this.mediaQuery.removeEventListener) {
+ this.mediaQuery.removeEventListener('change', this.handleLayoutChange);
+ } else {
+ this.mediaQuery.removeListener(this.handleLayoutChange);
+ }
+ }
+ }
+
+ handleChildrenContentChange() {
+ if (!this.props.singleColumn) {
+ const modifier = this.isRtlLayout ? -1 : 1;
+ this._interruptScrollAnimation = scrollRight(this.node, (this.node.scrollWidth - window.innerWidth) * modifier);
+ }
+ }
+
+ handleLayoutChange = (e) => {
+ this.setState({ renderComposePanel: !e.matches });
+ };
+
+ handleWheel = () => {
+ if (typeof this._interruptScrollAnimation !== 'function') {
+ return;
+ }
+
+ this._interruptScrollAnimation();
+ };
+
+ setRef = (node) => {
+ this.node = node;
+ };
+
+ renderLoading = columnId => () => {
+ return columnId === 'COMPOSE' ? : ;
+ };
+
+ renderError = (props) => {
+ return ;
+ };
+
+ render () {
+ const { columns, children, singleColumn, isModalOpen } = this.props;
+ const { renderComposePanel } = this.state;
+
+ if (singleColumn) {
+ return (
+
+
+
+ {renderComposePanel && }
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {columns.map(column => {
+ const params = column.get('params', null) === null ? null : column.get('params').toJS();
+ const other = params && params.other ? params.other : {};
+
+ return (
+
+ {SpecificComponent => }
+
+ );
+ })}
+
+ {React.Children.map(children, child => React.cloneElement(child, { multiColumn: true }))}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/compare_history_modal.js b/app/javascript/mastodon/features/ui/components/compare_history_modal.js
deleted file mode 100644
index ecccc8f7d..000000000
--- a/app/javascript/mastodon/features/ui/components/compare_history_modal.js
+++ /dev/null
@@ -1,99 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { connect } from 'react-redux';
-import { FormattedMessage } from 'react-intl';
-import { closeModal } from 'mastodon/actions/modal';
-import emojify from 'mastodon/features/emoji/emoji';
-import escapeTextContentForBrowser from 'escape-html';
-import InlineAccount from 'mastodon/components/inline_account';
-import IconButton from 'mastodon/components/icon_button';
-import RelativeTimestamp from 'mastodon/components/relative_timestamp';
-import MediaAttachments from 'mastodon/components/media_attachments';
-
-const mapStateToProps = (state, { statusId }) => ({
- versions: state.getIn(['history', statusId, 'items']),
-});
-
-const mapDispatchToProps = dispatch => ({
-
- onClose() {
- dispatch(closeModal());
- },
-
-});
-
-export default @connect(mapStateToProps, mapDispatchToProps)
-class CompareHistoryModal extends React.PureComponent {
-
- static propTypes = {
- onClose: PropTypes.func.isRequired,
- index: PropTypes.number.isRequired,
- statusId: PropTypes.string.isRequired,
- versions: ImmutablePropTypes.list.isRequired,
- };
-
- render () {
- const { index, versions, onClose } = this.props;
- const currentVersion = versions.get(index);
-
- const emojiMap = currentVersion.get('emojis').reduce((obj, emoji) => {
- obj[`:${emoji.get('shortcode')}:`] = emoji.toJS();
- return obj;
- }, {});
-
- const content = { __html: emojify(currentVersion.get('content'), emojiMap) };
- const spoilerContent = { __html: emojify(escapeTextContentForBrowser(currentVersion.get('spoiler_text')), emojiMap) };
-
- const formattedDate = ;
- const formattedName = ;
-
- const label = currentVersion.get('original') ? (
-
- ) : (
-
- );
-
- return (
-
-
-
- {label}
-
-
-
-
- {currentVersion.get('spoiler_text').length > 0 && (
-
-
-
-
- )}
-
-
-
- {!!currentVersion.get('poll') && (
-
-
- {currentVersion.getIn(['poll', 'options']).map(option => (
-
-
-
-
-
- ))}
-
-
- )}
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/compare_history_modal.jsx b/app/javascript/mastodon/features/ui/components/compare_history_modal.jsx
new file mode 100644
index 000000000..ecccc8f7d
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/compare_history_modal.jsx
@@ -0,0 +1,99 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { FormattedMessage } from 'react-intl';
+import { closeModal } from 'mastodon/actions/modal';
+import emojify from 'mastodon/features/emoji/emoji';
+import escapeTextContentForBrowser from 'escape-html';
+import InlineAccount from 'mastodon/components/inline_account';
+import IconButton from 'mastodon/components/icon_button';
+import RelativeTimestamp from 'mastodon/components/relative_timestamp';
+import MediaAttachments from 'mastodon/components/media_attachments';
+
+const mapStateToProps = (state, { statusId }) => ({
+ versions: state.getIn(['history', statusId, 'items']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+ onClose() {
+ dispatch(closeModal());
+ },
+
+});
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+class CompareHistoryModal extends React.PureComponent {
+
+ static propTypes = {
+ onClose: PropTypes.func.isRequired,
+ index: PropTypes.number.isRequired,
+ statusId: PropTypes.string.isRequired,
+ versions: ImmutablePropTypes.list.isRequired,
+ };
+
+ render () {
+ const { index, versions, onClose } = this.props;
+ const currentVersion = versions.get(index);
+
+ const emojiMap = currentVersion.get('emojis').reduce((obj, emoji) => {
+ obj[`:${emoji.get('shortcode')}:`] = emoji.toJS();
+ return obj;
+ }, {});
+
+ const content = { __html: emojify(currentVersion.get('content'), emojiMap) };
+ const spoilerContent = { __html: emojify(escapeTextContentForBrowser(currentVersion.get('spoiler_text')), emojiMap) };
+
+ const formattedDate = ;
+ const formattedName = ;
+
+ const label = currentVersion.get('original') ? (
+
+ ) : (
+
+ );
+
+ return (
+
+
+
+ {label}
+
+
+
+
+ {currentVersion.get('spoiler_text').length > 0 && (
+
+
+
+
+ )}
+
+
+
+ {!!currentVersion.get('poll') && (
+
+
+ {currentVersion.getIn(['poll', 'options']).map(option => (
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/compose_panel.js b/app/javascript/mastodon/features/ui/components/compose_panel.js
deleted file mode 100644
index 6cb352322..000000000
--- a/app/javascript/mastodon/features/ui/components/compose_panel.js
+++ /dev/null
@@ -1,68 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import SearchContainer from 'mastodon/features/compose/containers/search_container';
-import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
-import NavigationContainer from 'mastodon/features/compose/containers/navigation_container';
-import LinkFooter from './link_footer';
-import ServerBanner from 'mastodon/components/server_banner';
-import { changeComposing, mountCompose, unmountCompose } from 'mastodon/actions/compose';
-
-export default @connect()
-class ComposePanel extends React.PureComponent {
-
- static contextTypes = {
- identity: PropTypes.object.isRequired,
- };
-
- static propTypes = {
- dispatch: PropTypes.func.isRequired,
- };
-
- onFocus = () => {
- const { dispatch } = this.props;
- dispatch(changeComposing(true));
- };
-
- onBlur = () => {
- const { dispatch } = this.props;
- dispatch(changeComposing(false));
- };
-
- componentDidMount () {
- const { dispatch } = this.props;
- dispatch(mountCompose());
- }
-
- componentWillUnmount () {
- const { dispatch } = this.props;
- dispatch(unmountCompose());
- }
-
- render() {
- const { signedIn } = this.context.identity;
-
- return (
-
-
-
- {!signedIn && (
-
-
-
-
- )}
-
- {signedIn && (
-
-
-
-
- )}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/compose_panel.jsx b/app/javascript/mastodon/features/ui/components/compose_panel.jsx
new file mode 100644
index 000000000..6cb352322
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/compose_panel.jsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import SearchContainer from 'mastodon/features/compose/containers/search_container';
+import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
+import NavigationContainer from 'mastodon/features/compose/containers/navigation_container';
+import LinkFooter from './link_footer';
+import ServerBanner from 'mastodon/components/server_banner';
+import { changeComposing, mountCompose, unmountCompose } from 'mastodon/actions/compose';
+
+export default @connect()
+class ComposePanel extends React.PureComponent {
+
+ static contextTypes = {
+ identity: PropTypes.object.isRequired,
+ };
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ };
+
+ onFocus = () => {
+ const { dispatch } = this.props;
+ dispatch(changeComposing(true));
+ };
+
+ onBlur = () => {
+ const { dispatch } = this.props;
+ dispatch(changeComposing(false));
+ };
+
+ componentDidMount () {
+ const { dispatch } = this.props;
+ dispatch(mountCompose());
+ }
+
+ componentWillUnmount () {
+ const { dispatch } = this.props;
+ dispatch(unmountCompose());
+ }
+
+ render() {
+ const { signedIn } = this.context.identity;
+
+ return (
+
+
+
+ {!signedIn && (
+
+
+
+
+ )}
+
+ {signedIn && (
+
+
+
+
+ )}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modal.js b/app/javascript/mastodon/features/ui/components/confirmation_modal.js
deleted file mode 100644
index b023b00b2..000000000
--- a/app/javascript/mastodon/features/ui/components/confirmation_modal.js
+++ /dev/null
@@ -1,70 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { injectIntl, FormattedMessage } from 'react-intl';
-import Button from '../../../components/button';
-
-export default @injectIntl
-class ConfirmationModal extends React.PureComponent {
-
- static propTypes = {
- message: PropTypes.node.isRequired,
- confirm: PropTypes.string.isRequired,
- onClose: PropTypes.func.isRequired,
- onConfirm: PropTypes.func.isRequired,
- secondary: PropTypes.string,
- onSecondary: PropTypes.func,
- closeWhenConfirm: PropTypes.bool,
- intl: PropTypes.object.isRequired,
- };
-
- static defaultProps = {
- closeWhenConfirm: true,
- };
-
- componentDidMount() {
- this.button.focus();
- }
-
- handleClick = () => {
- if (this.props.closeWhenConfirm) {
- this.props.onClose();
- }
- this.props.onConfirm();
- };
-
- handleSecondary = () => {
- this.props.onClose();
- this.props.onSecondary();
- };
-
- handleCancel = () => {
- this.props.onClose();
- };
-
- setRef = (c) => {
- this.button = c;
- };
-
- render () {
- const { message, confirm, secondary } = this.props;
-
- return (
-
-
- {message}
-
-
-
-
-
-
- {secondary !== undefined && (
-
- )}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modal.jsx b/app/javascript/mastodon/features/ui/components/confirmation_modal.jsx
new file mode 100644
index 000000000..b023b00b2
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/confirmation_modal.jsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import Button from '../../../components/button';
+
+export default @injectIntl
+class ConfirmationModal extends React.PureComponent {
+
+ static propTypes = {
+ message: PropTypes.node.isRequired,
+ confirm: PropTypes.string.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onConfirm: PropTypes.func.isRequired,
+ secondary: PropTypes.string,
+ onSecondary: PropTypes.func,
+ closeWhenConfirm: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ };
+
+ static defaultProps = {
+ closeWhenConfirm: true,
+ };
+
+ componentDidMount() {
+ this.button.focus();
+ }
+
+ handleClick = () => {
+ if (this.props.closeWhenConfirm) {
+ this.props.onClose();
+ }
+ this.props.onConfirm();
+ };
+
+ handleSecondary = () => {
+ this.props.onClose();
+ this.props.onSecondary();
+ };
+
+ handleCancel = () => {
+ this.props.onClose();
+ };
+
+ setRef = (c) => {
+ this.button = c;
+ };
+
+ render () {
+ const { message, confirm, secondary } = this.props;
+
+ return (
+
+
+ {message}
+
+
+
+
+
+
+ {secondary !== undefined && (
+
+ )}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/disabled_account_banner.js b/app/javascript/mastodon/features/ui/components/disabled_account_banner.js
deleted file mode 100644
index 35520478b..000000000
--- a/app/javascript/mastodon/features/ui/components/disabled_account_banner.js
+++ /dev/null
@@ -1,92 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { connect } from 'react-redux';
-import { Link } from 'react-router-dom';
-import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
-import { disabledAccountId, movedToAccountId, domain } from 'mastodon/initial_state';
-import { openModal } from 'mastodon/actions/modal';
-import { logOut } from 'mastodon/utils/log_out';
-
-const messages = defineMessages({
- logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
- logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
-});
-
-const mapStateToProps = (state) => ({
- disabledAcct: state.getIn(['accounts', disabledAccountId, 'acct']),
- movedToAcct: movedToAccountId ? state.getIn(['accounts', movedToAccountId, 'acct']) : undefined,
-});
-
-const mapDispatchToProps = (dispatch, { intl }) => ({
- onLogout () {
- dispatch(openModal('CONFIRM', {
- message: intl.formatMessage(messages.logoutMessage),
- confirm: intl.formatMessage(messages.logoutConfirm),
- closeWhenConfirm: false,
- onConfirm: () => logOut(),
- }));
- },
-});
-
-export default @injectIntl
-@connect(mapStateToProps, mapDispatchToProps)
-class DisabledAccountBanner extends React.PureComponent {
-
- static propTypes = {
- disabledAcct: PropTypes.string.isRequired,
- movedToAcct: PropTypes.string,
- onLogout: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- handleLogOutClick = e => {
- e.preventDefault();
- e.stopPropagation();
-
- this.props.onLogout();
-
- return false;
- };
-
- render () {
- const { disabledAcct, movedToAcct } = this.props;
-
- const disabledAccountLink = (
-
- {disabledAcct}@{domain}
-
- );
-
- return (
-
-
- {movedToAcct ? (
- {movedToAcct.includes('@') ? movedToAcct : `${movedToAcct}@${domain}`},
- }}
- />
- ) : (
-
- )}
-
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/disabled_account_banner.jsx b/app/javascript/mastodon/features/ui/components/disabled_account_banner.jsx
new file mode 100644
index 000000000..35520478b
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/disabled_account_banner.jsx
@@ -0,0 +1,92 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { Link } from 'react-router-dom';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import { disabledAccountId, movedToAccountId, domain } from 'mastodon/initial_state';
+import { openModal } from 'mastodon/actions/modal';
+import { logOut } from 'mastodon/utils/log_out';
+
+const messages = defineMessages({
+ logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
+ logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
+});
+
+const mapStateToProps = (state) => ({
+ disabledAcct: state.getIn(['accounts', disabledAccountId, 'acct']),
+ movedToAcct: movedToAccountId ? state.getIn(['accounts', movedToAccountId, 'acct']) : undefined,
+});
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+ onLogout () {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.logoutMessage),
+ confirm: intl.formatMessage(messages.logoutConfirm),
+ closeWhenConfirm: false,
+ onConfirm: () => logOut(),
+ }));
+ },
+});
+
+export default @injectIntl
+@connect(mapStateToProps, mapDispatchToProps)
+class DisabledAccountBanner extends React.PureComponent {
+
+ static propTypes = {
+ disabledAcct: PropTypes.string.isRequired,
+ movedToAcct: PropTypes.string,
+ onLogout: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleLogOutClick = e => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ this.props.onLogout();
+
+ return false;
+ };
+
+ render () {
+ const { disabledAcct, movedToAcct } = this.props;
+
+ const disabledAccountLink = (
+
+ {disabledAcct}@{domain}
+
+ );
+
+ return (
+
+
+ {movedToAcct ? (
+ {movedToAcct.includes('@') ? movedToAcct : `${movedToAcct}@${domain}`},
+ }}
+ />
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/drawer_loading.js b/app/javascript/mastodon/features/ui/components/drawer_loading.js
deleted file mode 100644
index 08b0d2347..000000000
--- a/app/javascript/mastodon/features/ui/components/drawer_loading.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import React from 'react';
-
-const DrawerLoading = () => (
-
-);
-
-export default DrawerLoading;
diff --git a/app/javascript/mastodon/features/ui/components/drawer_loading.jsx b/app/javascript/mastodon/features/ui/components/drawer_loading.jsx
new file mode 100644
index 000000000..08b0d2347
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/drawer_loading.jsx
@@ -0,0 +1,11 @@
+import React from 'react';
+
+const DrawerLoading = () => (
+
+);
+
+export default DrawerLoading;
diff --git a/app/javascript/mastodon/features/ui/components/embed_modal.js b/app/javascript/mastodon/features/ui/components/embed_modal.js
deleted file mode 100644
index a054dd3cf..000000000
--- a/app/javascript/mastodon/features/ui/components/embed_modal.js
+++ /dev/null
@@ -1,97 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
-import api from 'mastodon/api';
-import IconButton from 'mastodon/components/icon_button';
-
-const messages = defineMessages({
- close: { id: 'lightbox.close', defaultMessage: 'Close' },
-});
-
-export default @injectIntl
-class EmbedModal extends ImmutablePureComponent {
-
- static propTypes = {
- url: PropTypes.string.isRequired,
- onClose: PropTypes.func.isRequired,
- onError: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- state = {
- loading: false,
- oembed: null,
- };
-
- componentDidMount () {
- const { url } = this.props;
-
- this.setState({ loading: true });
-
- api().post('/api/web/embed', { url }).then(res => {
- this.setState({ loading: false, oembed: res.data });
-
- const iframeDocument = this.iframe.contentWindow.document;
-
- iframeDocument.open();
- iframeDocument.write(res.data.html);
- iframeDocument.close();
-
- iframeDocument.body.style.margin = 0;
- this.iframe.width = iframeDocument.body.scrollWidth;
- this.iframe.height = iframeDocument.body.scrollHeight;
- }).catch(error => {
- this.props.onError(error);
- });
- }
-
- setIframeRef = c => {
- this.iframe = c;
- };
-
- handleTextareaClick = (e) => {
- e.target.select();
- };
-
- render () {
- const { intl, onClose } = this.props;
- const { oembed } = this.state;
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/embed_modal.jsx b/app/javascript/mastodon/features/ui/components/embed_modal.jsx
new file mode 100644
index 000000000..a054dd3cf
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/embed_modal.jsx
@@ -0,0 +1,97 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import api from 'mastodon/api';
+import IconButton from 'mastodon/components/icon_button';
+
+const messages = defineMessages({
+ close: { id: 'lightbox.close', defaultMessage: 'Close' },
+});
+
+export default @injectIntl
+class EmbedModal extends ImmutablePureComponent {
+
+ static propTypes = {
+ url: PropTypes.string.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onError: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ loading: false,
+ oembed: null,
+ };
+
+ componentDidMount () {
+ const { url } = this.props;
+
+ this.setState({ loading: true });
+
+ api().post('/api/web/embed', { url }).then(res => {
+ this.setState({ loading: false, oembed: res.data });
+
+ const iframeDocument = this.iframe.contentWindow.document;
+
+ iframeDocument.open();
+ iframeDocument.write(res.data.html);
+ iframeDocument.close();
+
+ iframeDocument.body.style.margin = 0;
+ this.iframe.width = iframeDocument.body.scrollWidth;
+ this.iframe.height = iframeDocument.body.scrollHeight;
+ }).catch(error => {
+ this.props.onError(error);
+ });
+ }
+
+ setIframeRef = c => {
+ this.iframe = c;
+ };
+
+ handleTextareaClick = (e) => {
+ e.target.select();
+ };
+
+ render () {
+ const { intl, onClose } = this.props;
+ const { oembed } = this.state;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/filter_modal.js b/app/javascript/mastodon/features/ui/components/filter_modal.js
deleted file mode 100644
index 376db961d..000000000
--- a/app/javascript/mastodon/features/ui/components/filter_modal.js
+++ /dev/null
@@ -1,134 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import { fetchStatus } from 'mastodon/actions/statuses';
-import { fetchFilters, createFilter, createFilterStatus } from 'mastodon/actions/filters';
-import PropTypes from 'prop-types';
-import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import IconButton from 'mastodon/components/icon_button';
-import SelectFilter from 'mastodon/features/filters/select_filter';
-import AddedToFilter from 'mastodon/features/filters/added_to_filter';
-
-const messages = defineMessages({
- close: { id: 'lightbox.close', defaultMessage: 'Close' },
-});
-
-export default @connect(undefined)
-@injectIntl
-class FilterModal extends ImmutablePureComponent {
-
- static propTypes = {
- statusId: PropTypes.string.isRequired,
- contextType: PropTypes.string,
- dispatch: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- state = {
- step: 'select',
- filterId: null,
- isSubmitting: false,
- isSubmitted: false,
- };
-
- handleNewFilterSuccess = (result) => {
- this.handleSelectFilter(result.id);
- };
-
- handleSuccess = () => {
- const { dispatch, statusId } = this.props;
- dispatch(fetchStatus(statusId, true));
- this.setState({ isSubmitting: false, isSubmitted: true, step: 'submitted' });
- };
-
- handleFail = () => {
- this.setState({ isSubmitting: false });
- };
-
- handleNextStep = step => {
- this.setState({ step });
- };
-
- handleSelectFilter = (filterId) => {
- const { dispatch, statusId } = this.props;
-
- this.setState({ isSubmitting: true, filterId });
-
- dispatch(createFilterStatus({
- filter_id: filterId,
- status_id: statusId,
- }, this.handleSuccess, this.handleFail));
- };
-
- handleNewFilter = (title) => {
- const { dispatch } = this.props;
-
- this.setState({ isSubmitting: true });
-
- dispatch(createFilter({
- title,
- context: ['home', 'notifications', 'public', 'thread', 'account'],
- action: 'warn',
- }, this.handleNewFilterSuccess, this.handleFail));
- };
-
- componentDidMount () {
- const { dispatch } = this.props;
-
- dispatch(fetchFilters());
- }
-
- render () {
- const {
- intl,
- statusId,
- contextType,
- onClose,
- } = this.props;
-
- const {
- step,
- filterId,
- } = this.state;
-
- let stepComponent;
-
- switch(step) {
- case 'select':
- stepComponent = (
-
- );
- break;
- case 'create':
- stepComponent = null;
- break;
- case 'submitted':
- stepComponent = (
-
- );
- }
-
- return (
-
-
-
-
-
-
-
- {stepComponent}
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/filter_modal.jsx b/app/javascript/mastodon/features/ui/components/filter_modal.jsx
new file mode 100644
index 000000000..376db961d
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/filter_modal.jsx
@@ -0,0 +1,134 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { fetchStatus } from 'mastodon/actions/statuses';
+import { fetchFilters, createFilter, createFilterStatus } from 'mastodon/actions/filters';
+import PropTypes from 'prop-types';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import IconButton from 'mastodon/components/icon_button';
+import SelectFilter from 'mastodon/features/filters/select_filter';
+import AddedToFilter from 'mastodon/features/filters/added_to_filter';
+
+const messages = defineMessages({
+ close: { id: 'lightbox.close', defaultMessage: 'Close' },
+});
+
+export default @connect(undefined)
+@injectIntl
+class FilterModal extends ImmutablePureComponent {
+
+ static propTypes = {
+ statusId: PropTypes.string.isRequired,
+ contextType: PropTypes.string,
+ dispatch: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ step: 'select',
+ filterId: null,
+ isSubmitting: false,
+ isSubmitted: false,
+ };
+
+ handleNewFilterSuccess = (result) => {
+ this.handleSelectFilter(result.id);
+ };
+
+ handleSuccess = () => {
+ const { dispatch, statusId } = this.props;
+ dispatch(fetchStatus(statusId, true));
+ this.setState({ isSubmitting: false, isSubmitted: true, step: 'submitted' });
+ };
+
+ handleFail = () => {
+ this.setState({ isSubmitting: false });
+ };
+
+ handleNextStep = step => {
+ this.setState({ step });
+ };
+
+ handleSelectFilter = (filterId) => {
+ const { dispatch, statusId } = this.props;
+
+ this.setState({ isSubmitting: true, filterId });
+
+ dispatch(createFilterStatus({
+ filter_id: filterId,
+ status_id: statusId,
+ }, this.handleSuccess, this.handleFail));
+ };
+
+ handleNewFilter = (title) => {
+ const { dispatch } = this.props;
+
+ this.setState({ isSubmitting: true });
+
+ dispatch(createFilter({
+ title,
+ context: ['home', 'notifications', 'public', 'thread', 'account'],
+ action: 'warn',
+ }, this.handleNewFilterSuccess, this.handleFail));
+ };
+
+ componentDidMount () {
+ const { dispatch } = this.props;
+
+ dispatch(fetchFilters());
+ }
+
+ render () {
+ const {
+ intl,
+ statusId,
+ contextType,
+ onClose,
+ } = this.props;
+
+ const {
+ step,
+ filterId,
+ } = this.state;
+
+ let stepComponent;
+
+ switch(step) {
+ case 'select':
+ stepComponent = (
+
+ );
+ break;
+ case 'create':
+ stepComponent = null;
+ break;
+ case 'submitted':
+ stepComponent = (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ {stepComponent}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js
deleted file mode 100644
index 6e8d017ee..000000000
--- a/app/javascript/mastodon/features/ui/components/focal_point_modal.js
+++ /dev/null
@@ -1,430 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { connect } from 'react-redux';
-import classNames from 'classnames';
-import { changeUploadCompose, uploadThumbnail, onChangeMediaDescription, onChangeMediaFocus } from '../../../actions/compose';
-import { getPointerPosition } from '../../video';
-import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
-import IconButton from 'mastodon/components/icon_button';
-import Button from 'mastodon/components/button';
-import Video from 'mastodon/features/video';
-import Audio from 'mastodon/features/audio';
-import Textarea from 'react-textarea-autosize';
-import UploadProgress from 'mastodon/features/compose/components/upload_progress';
-import CharacterCounter from 'mastodon/features/compose/components/character_counter';
-import { length } from 'stringz';
-import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
-import GIFV from 'mastodon/components/gifv';
-import { me } from 'mastodon/initial_state';
-// eslint-disable-next-line import/no-extraneous-dependencies
-import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js';
-// eslint-disable-next-line import/extensions
-import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js';
-import { assetHost } from 'mastodon/utils/config';
-
-const messages = defineMessages({
- close: { id: 'lightbox.close', defaultMessage: 'Close' },
- apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' },
- applying: { id: 'upload_modal.applying', defaultMessage: 'Applying…' },
- placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' },
- chooseImage: { id: 'upload_modal.choose_image', defaultMessage: 'Choose image' },
- discardMessage: { id: 'confirmations.discard_edit_media.message', defaultMessage: 'You have unsaved changes to the media description or preview, discard them anyway?' },
- discardConfirm: { id: 'confirmations.discard_edit_media.confirm', defaultMessage: 'Discard' },
-});
-
-const mapStateToProps = (state, { id }) => ({
- media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
- account: state.getIn(['accounts', me]),
- isUploadingThumbnail: state.getIn(['compose', 'isUploadingThumbnail']),
- description: state.getIn(['compose', 'media_modal', 'description']),
- lang: state.getIn(['compose', 'language']),
- focusX: state.getIn(['compose', 'media_modal', 'focusX']),
- focusY: state.getIn(['compose', 'media_modal', 'focusY']),
- dirty: state.getIn(['compose', 'media_modal', 'dirty']),
- is_changing_upload: state.getIn(['compose', 'is_changing_upload']),
-});
-
-const mapDispatchToProps = (dispatch, { id }) => ({
-
- onSave: (description, x, y) => {
- dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
- },
-
- onChangeDescription: (description) => {
- dispatch(onChangeMediaDescription(description));
- },
-
- onChangeFocus: (focusX, focusY) => {
- dispatch(onChangeMediaFocus(focusX, focusY));
- },
-
- onSelectThumbnail: files => {
- dispatch(uploadThumbnail(id, files[0]));
- },
-
-});
-
-const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******')
- .replace(/\n/g, ' ')
- .replace(/\*\*\*\*\*\*/g, '\n\n');
-
-class ImageLoader extends React.PureComponent {
-
- static propTypes = {
- src: PropTypes.string.isRequired,
- width: PropTypes.number,
- height: PropTypes.number,
- };
-
- state = {
- loading: true,
- };
-
- componentDidMount() {
- const image = new Image();
- image.addEventListener('load', () => this.setState({ loading: false }));
- image.src = this.props.src;
- }
-
- render () {
- const { loading } = this.state;
-
- if (loading) {
- return ;
- } else {
- return ;
- }
- }
-
-}
-
-export default @connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true })
-@(component => injectIntl(component, { withRef: true }))
-class FocalPointModal extends ImmutablePureComponent {
-
- static propTypes = {
- media: ImmutablePropTypes.map.isRequired,
- account: ImmutablePropTypes.map.isRequired,
- isUploadingThumbnail: PropTypes.bool,
- onSave: PropTypes.func.isRequired,
- onChangeDescription: PropTypes.func.isRequired,
- onChangeFocus: PropTypes.func.isRequired,
- onSelectThumbnail: PropTypes.func.isRequired,
- onClose: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- state = {
- dragging: false,
- dirty: false,
- progress: 0,
- loading: true,
- ocrStatus: '',
- };
-
- componentWillUnmount () {
- document.removeEventListener('mousemove', this.handleMouseMove);
- document.removeEventListener('mouseup', this.handleMouseUp);
- }
-
- handleMouseDown = e => {
- document.addEventListener('mousemove', this.handleMouseMove);
- document.addEventListener('mouseup', this.handleMouseUp);
-
- this.updatePosition(e);
- this.setState({ dragging: true });
- };
-
- handleTouchStart = e => {
- document.addEventListener('touchmove', this.handleMouseMove);
- document.addEventListener('touchend', this.handleTouchEnd);
-
- this.updatePosition(e);
- this.setState({ dragging: true });
- };
-
- handleMouseMove = e => {
- this.updatePosition(e);
- };
-
- handleMouseUp = () => {
- document.removeEventListener('mousemove', this.handleMouseMove);
- document.removeEventListener('mouseup', this.handleMouseUp);
-
- this.setState({ dragging: false });
- };
-
- handleTouchEnd = () => {
- document.removeEventListener('touchmove', this.handleMouseMove);
- document.removeEventListener('touchend', this.handleTouchEnd);
-
- this.setState({ dragging: false });
- };
-
- updatePosition = e => {
- const { x, y } = getPointerPosition(this.node, e);
- const focusX = (x - .5) * 2;
- const focusY = (y - .5) * -2;
-
- this.props.onChangeFocus(focusX, focusY);
- };
-
- handleChange = e => {
- this.props.onChangeDescription(e.target.value);
- };
-
- handleKeyDown = (e) => {
- if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
- this.props.onChangeDescription(e.target.value);
- this.handleSubmit(e);
- }
- };
-
- handleSubmit = (e) => {
- e.preventDefault();
- e.stopPropagation();
- this.props.onSave(this.props.description, this.props.focusX, this.props.focusY);
- };
-
- getCloseConfirmationMessage = () => {
- const { intl, dirty } = this.props;
-
- if (dirty) {
- return {
- message: intl.formatMessage(messages.discardMessage),
- confirm: intl.formatMessage(messages.discardConfirm),
- };
- } else {
- return null;
- }
- };
-
- setRef = c => {
- this.node = c;
- };
-
- handleTextDetection = () => {
- this._detectText();
- };
-
- _detectText = (refreshCache = false) => {
- const { media } = this.props;
-
- this.setState({ detecting: true });
-
- fetchTesseract().then(({ createWorker }) => {
- const worker = createWorker({
- workerPath: tesseractWorkerPath,
- corePath: tesseractCorePath,
- langPath: `${assetHost}/ocr/lang-data/`,
- logger: ({ status, progress }) => {
- if (status === 'recognizing text') {
- this.setState({ ocrStatus: 'detecting', progress });
- } else {
- this.setState({ ocrStatus: 'preparing', progress });
- }
- },
- cacheMethod: refreshCache ? 'refresh' : 'write',
- });
-
- let media_url = media.get('url');
-
- if (window.URL && URL.createObjectURL) {
- try {
- media_url = URL.createObjectURL(media.get('file'));
- } catch (error) {
- console.error(error);
- }
- }
-
- return (async () => {
- await worker.load();
- await worker.loadLanguage('eng');
- await worker.initialize('eng');
- const { data: { text } } = await worker.recognize(media_url);
- this.setState({ detecting: false });
- this.props.onChangeDescription(removeExtraLineBreaks(text));
- await worker.terminate();
- })().catch((e) => {
- if (refreshCache) {
- throw e;
- } else {
- this._detectText(true);
- }
- });
- }).catch((e) => {
- console.error(e);
- this.setState({ detecting: false });
- });
- };
-
- handleThumbnailChange = e => {
- if (e.target.files.length > 0) {
- this.props.onSelectThumbnail(e.target.files);
- }
- };
-
- setFileInputRef = c => {
- this.fileInput = c;
- };
-
- handleFileInputClick = () => {
- this.fileInput.click();
- };
-
- render () {
- const { media, intl, account, onClose, isUploadingThumbnail, description, lang, focusX, focusY, dirty, is_changing_upload } = this.props;
- const { dragging, detecting, progress, ocrStatus } = this.state;
- const x = (focusX / 2) + .5;
- const y = (focusY / -2) + .5;
-
- const width = media.getIn(['meta', 'original', 'width']) || null;
- const height = media.getIn(['meta', 'original', 'height']) || null;
- const focals = ['image', 'gifv'].includes(media.get('type'));
- const thumbnailable = ['audio', 'video'].includes(media.get('type'));
-
- const previewRatio = 16/9;
- const previewWidth = 200;
- const previewHeight = previewWidth / previewRatio;
-
- let descriptionLabel = null;
-
- if (media.get('type') === 'audio') {
- descriptionLabel = ;
- } else if (media.get('type') === 'video') {
- descriptionLabel = ;
- } else {
- descriptionLabel = ;
- }
-
- let ocrMessage = '';
- if (ocrStatus === 'detecting') {
- ocrMessage = ;
- } else {
- ocrMessage = ;
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
- {focals && (
-
- {media.get('type') === 'image' &&
}
- {media.get('type') === 'gifv' &&
}
-
-
-
-
-
-
- )}
-
- {media.get('type') === 'video' && (
-
- )}
-
- {media.get('type') === 'audio' && (
-
- )}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.jsx b/app/javascript/mastodon/features/ui/components/focal_point_modal.jsx
new file mode 100644
index 000000000..6e8d017ee
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.jsx
@@ -0,0 +1,430 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { connect } from 'react-redux';
+import classNames from 'classnames';
+import { changeUploadCompose, uploadThumbnail, onChangeMediaDescription, onChangeMediaFocus } from '../../../actions/compose';
+import { getPointerPosition } from '../../video';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import IconButton from 'mastodon/components/icon_button';
+import Button from 'mastodon/components/button';
+import Video from 'mastodon/features/video';
+import Audio from 'mastodon/features/audio';
+import Textarea from 'react-textarea-autosize';
+import UploadProgress from 'mastodon/features/compose/components/upload_progress';
+import CharacterCounter from 'mastodon/features/compose/components/character_counter';
+import { length } from 'stringz';
+import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
+import GIFV from 'mastodon/components/gifv';
+import { me } from 'mastodon/initial_state';
+// eslint-disable-next-line import/no-extraneous-dependencies
+import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js';
+// eslint-disable-next-line import/extensions
+import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js';
+import { assetHost } from 'mastodon/utils/config';
+
+const messages = defineMessages({
+ close: { id: 'lightbox.close', defaultMessage: 'Close' },
+ apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' },
+ applying: { id: 'upload_modal.applying', defaultMessage: 'Applying…' },
+ placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' },
+ chooseImage: { id: 'upload_modal.choose_image', defaultMessage: 'Choose image' },
+ discardMessage: { id: 'confirmations.discard_edit_media.message', defaultMessage: 'You have unsaved changes to the media description or preview, discard them anyway?' },
+ discardConfirm: { id: 'confirmations.discard_edit_media.confirm', defaultMessage: 'Discard' },
+});
+
+const mapStateToProps = (state, { id }) => ({
+ media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
+ account: state.getIn(['accounts', me]),
+ isUploadingThumbnail: state.getIn(['compose', 'isUploadingThumbnail']),
+ description: state.getIn(['compose', 'media_modal', 'description']),
+ lang: state.getIn(['compose', 'language']),
+ focusX: state.getIn(['compose', 'media_modal', 'focusX']),
+ focusY: state.getIn(['compose', 'media_modal', 'focusY']),
+ dirty: state.getIn(['compose', 'media_modal', 'dirty']),
+ is_changing_upload: state.getIn(['compose', 'is_changing_upload']),
+});
+
+const mapDispatchToProps = (dispatch, { id }) => ({
+
+ onSave: (description, x, y) => {
+ dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
+ },
+
+ onChangeDescription: (description) => {
+ dispatch(onChangeMediaDescription(description));
+ },
+
+ onChangeFocus: (focusX, focusY) => {
+ dispatch(onChangeMediaFocus(focusX, focusY));
+ },
+
+ onSelectThumbnail: files => {
+ dispatch(uploadThumbnail(id, files[0]));
+ },
+
+});
+
+const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******')
+ .replace(/\n/g, ' ')
+ .replace(/\*\*\*\*\*\*/g, '\n\n');
+
+class ImageLoader extends React.PureComponent {
+
+ static propTypes = {
+ src: PropTypes.string.isRequired,
+ width: PropTypes.number,
+ height: PropTypes.number,
+ };
+
+ state = {
+ loading: true,
+ };
+
+ componentDidMount() {
+ const image = new Image();
+ image.addEventListener('load', () => this.setState({ loading: false }));
+ image.src = this.props.src;
+ }
+
+ render () {
+ const { loading } = this.state;
+
+ if (loading) {
+ return ;
+ } else {
+ return ;
+ }
+ }
+
+}
+
+export default @connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true })
+@(component => injectIntl(component, { withRef: true }))
+class FocalPointModal extends ImmutablePureComponent {
+
+ static propTypes = {
+ media: ImmutablePropTypes.map.isRequired,
+ account: ImmutablePropTypes.map.isRequired,
+ isUploadingThumbnail: PropTypes.bool,
+ onSave: PropTypes.func.isRequired,
+ onChangeDescription: PropTypes.func.isRequired,
+ onChangeFocus: PropTypes.func.isRequired,
+ onSelectThumbnail: PropTypes.func.isRequired,
+ onClose: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ dragging: false,
+ dirty: false,
+ progress: 0,
+ loading: true,
+ ocrStatus: '',
+ };
+
+ componentWillUnmount () {
+ document.removeEventListener('mousemove', this.handleMouseMove);
+ document.removeEventListener('mouseup', this.handleMouseUp);
+ }
+
+ handleMouseDown = e => {
+ document.addEventListener('mousemove', this.handleMouseMove);
+ document.addEventListener('mouseup', this.handleMouseUp);
+
+ this.updatePosition(e);
+ this.setState({ dragging: true });
+ };
+
+ handleTouchStart = e => {
+ document.addEventListener('touchmove', this.handleMouseMove);
+ document.addEventListener('touchend', this.handleTouchEnd);
+
+ this.updatePosition(e);
+ this.setState({ dragging: true });
+ };
+
+ handleMouseMove = e => {
+ this.updatePosition(e);
+ };
+
+ handleMouseUp = () => {
+ document.removeEventListener('mousemove', this.handleMouseMove);
+ document.removeEventListener('mouseup', this.handleMouseUp);
+
+ this.setState({ dragging: false });
+ };
+
+ handleTouchEnd = () => {
+ document.removeEventListener('touchmove', this.handleMouseMove);
+ document.removeEventListener('touchend', this.handleTouchEnd);
+
+ this.setState({ dragging: false });
+ };
+
+ updatePosition = e => {
+ const { x, y } = getPointerPosition(this.node, e);
+ const focusX = (x - .5) * 2;
+ const focusY = (y - .5) * -2;
+
+ this.props.onChangeFocus(focusX, focusY);
+ };
+
+ handleChange = e => {
+ this.props.onChangeDescription(e.target.value);
+ };
+
+ handleKeyDown = (e) => {
+ if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+ this.props.onChangeDescription(e.target.value);
+ this.handleSubmit(e);
+ }
+ };
+
+ handleSubmit = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ this.props.onSave(this.props.description, this.props.focusX, this.props.focusY);
+ };
+
+ getCloseConfirmationMessage = () => {
+ const { intl, dirty } = this.props;
+
+ if (dirty) {
+ return {
+ message: intl.formatMessage(messages.discardMessage),
+ confirm: intl.formatMessage(messages.discardConfirm),
+ };
+ } else {
+ return null;
+ }
+ };
+
+ setRef = c => {
+ this.node = c;
+ };
+
+ handleTextDetection = () => {
+ this._detectText();
+ };
+
+ _detectText = (refreshCache = false) => {
+ const { media } = this.props;
+
+ this.setState({ detecting: true });
+
+ fetchTesseract().then(({ createWorker }) => {
+ const worker = createWorker({
+ workerPath: tesseractWorkerPath,
+ corePath: tesseractCorePath,
+ langPath: `${assetHost}/ocr/lang-data/`,
+ logger: ({ status, progress }) => {
+ if (status === 'recognizing text') {
+ this.setState({ ocrStatus: 'detecting', progress });
+ } else {
+ this.setState({ ocrStatus: 'preparing', progress });
+ }
+ },
+ cacheMethod: refreshCache ? 'refresh' : 'write',
+ });
+
+ let media_url = media.get('url');
+
+ if (window.URL && URL.createObjectURL) {
+ try {
+ media_url = URL.createObjectURL(media.get('file'));
+ } catch (error) {
+ console.error(error);
+ }
+ }
+
+ return (async () => {
+ await worker.load();
+ await worker.loadLanguage('eng');
+ await worker.initialize('eng');
+ const { data: { text } } = await worker.recognize(media_url);
+ this.setState({ detecting: false });
+ this.props.onChangeDescription(removeExtraLineBreaks(text));
+ await worker.terminate();
+ })().catch((e) => {
+ if (refreshCache) {
+ throw e;
+ } else {
+ this._detectText(true);
+ }
+ });
+ }).catch((e) => {
+ console.error(e);
+ this.setState({ detecting: false });
+ });
+ };
+
+ handleThumbnailChange = e => {
+ if (e.target.files.length > 0) {
+ this.props.onSelectThumbnail(e.target.files);
+ }
+ };
+
+ setFileInputRef = c => {
+ this.fileInput = c;
+ };
+
+ handleFileInputClick = () => {
+ this.fileInput.click();
+ };
+
+ render () {
+ const { media, intl, account, onClose, isUploadingThumbnail, description, lang, focusX, focusY, dirty, is_changing_upload } = this.props;
+ const { dragging, detecting, progress, ocrStatus } = this.state;
+ const x = (focusX / 2) + .5;
+ const y = (focusY / -2) + .5;
+
+ const width = media.getIn(['meta', 'original', 'width']) || null;
+ const height = media.getIn(['meta', 'original', 'height']) || null;
+ const focals = ['image', 'gifv'].includes(media.get('type'));
+ const thumbnailable = ['audio', 'video'].includes(media.get('type'));
+
+ const previewRatio = 16/9;
+ const previewWidth = 200;
+ const previewHeight = previewWidth / previewRatio;
+
+ let descriptionLabel = null;
+
+ if (media.get('type') === 'audio') {
+ descriptionLabel = ;
+ } else if (media.get('type') === 'video') {
+ descriptionLabel = ;
+ } else {
+ descriptionLabel = ;
+ }
+
+ let ocrMessage = '';
+ if (ocrStatus === 'detecting') {
+ ocrMessage = ;
+ } else {
+ ocrMessage = ;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {focals &&
}
+
+ {thumbnailable && (
+
+
+
+
+
+
+ {intl.formatMessage(messages.chooseImage)}
+
+
+
+
+
+
+ )}
+
+
+ {descriptionLabel}
+
+
+
+
+
+
+
+
+
+
+
+ 1500 || is_changing_upload}
+ text={intl.formatMessage(is_changing_upload ? messages.applying : messages.apply)}
+ />
+
+
+
+ {focals && (
+
+ {media.get('type') === 'image' &&
}
+ {media.get('type') === 'gifv' &&
}
+
+
+
+
+
+
+ )}
+
+ {media.get('type') === 'video' && (
+
+ )}
+
+ {media.get('type') === 'audio' && (
+
+ )}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/follow_requests_column_link.js b/app/javascript/mastodon/features/ui/components/follow_requests_column_link.js
deleted file mode 100644
index 8d4057782..000000000
--- a/app/javascript/mastodon/features/ui/components/follow_requests_column_link.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { fetchFollowRequests } from 'mastodon/actions/accounts';
-import { connect } from 'react-redux';
-import ColumnLink from 'mastodon/features/ui/components/column_link';
-import IconWithBadge from 'mastodon/components/icon_with_badge';
-import { List as ImmutableList } from 'immutable';
-import { injectIntl, defineMessages } from 'react-intl';
-
-const messages = defineMessages({
- text: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
-});
-
-const mapStateToProps = state => ({
- count: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
-});
-
-export default @injectIntl
-@connect(mapStateToProps)
-class FollowRequestsColumnLink extends React.Component {
-
- static propTypes = {
- dispatch: PropTypes.func.isRequired,
- count: PropTypes.number.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- componentDidMount () {
- const { dispatch } = this.props;
-
- dispatch(fetchFollowRequests());
- }
-
- render () {
- const { count, intl } = this.props;
-
- if (count === 0) {
- return null;
- }
-
- return (
- }
- text={intl.formatMessage(messages.text)}
- />
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/follow_requests_column_link.jsx b/app/javascript/mastodon/features/ui/components/follow_requests_column_link.jsx
new file mode 100644
index 000000000..8d4057782
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/follow_requests_column_link.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { fetchFollowRequests } from 'mastodon/actions/accounts';
+import { connect } from 'react-redux';
+import ColumnLink from 'mastodon/features/ui/components/column_link';
+import IconWithBadge from 'mastodon/components/icon_with_badge';
+import { List as ImmutableList } from 'immutable';
+import { injectIntl, defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+ text: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
+});
+
+const mapStateToProps = state => ({
+ count: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
+});
+
+export default @injectIntl
+@connect(mapStateToProps)
+class FollowRequestsColumnLink extends React.Component {
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ count: PropTypes.number.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ componentDidMount () {
+ const { dispatch } = this.props;
+
+ dispatch(fetchFollowRequests());
+ }
+
+ render () {
+ const { count, intl } = this.props;
+
+ if (count === 0) {
+ return null;
+ }
+
+ return (
+ }
+ text={intl.formatMessage(messages.text)}
+ />
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/header.js b/app/javascript/mastodon/features/ui/components/header.js
deleted file mode 100644
index 1384bebda..000000000
--- a/app/javascript/mastodon/features/ui/components/header.js
+++ /dev/null
@@ -1,87 +0,0 @@
-import React from 'react';
-import Logo from 'mastodon/components/logo';
-import { Link, withRouter } from 'react-router-dom';
-import { FormattedMessage } from 'react-intl';
-import { registrationsOpen, me } from 'mastodon/initial_state';
-import Avatar from 'mastodon/components/avatar';
-import PropTypes from 'prop-types';
-import { connect } from 'react-redux';
-import { openModal } from 'mastodon/actions/modal';
-
-const Account = connect(state => ({
- account: state.getIn(['accounts', me]),
-}))(({ account }) => (
-
-
-
-));
-
-const mapDispatchToProps = (dispatch) => ({
- openClosedRegistrationsModal() {
- dispatch(openModal('CLOSED_REGISTRATIONS'));
- },
-});
-
-export default @connect(null, mapDispatchToProps)
-@withRouter
-class Header extends React.PureComponent {
-
- static contextTypes = {
- identity: PropTypes.object,
- };
-
- static propTypes = {
- openClosedRegistrationsModal: PropTypes.func,
- location: PropTypes.object,
- };
-
- render () {
- const { signedIn } = this.context.identity;
- const { location, openClosedRegistrationsModal } = this.props;
-
- let content;
-
- if (signedIn) {
- content = (
- <>
- {location.pathname !== '/publish' && }
-
- >
- );
- } else {
- let signupButton;
-
- if (registrationsOpen) {
- signupButton = (
-
-
-
- );
- } else {
- signupButton = (
-
-
-
- );
- }
-
- content = (
- <>
-
- {signupButton}
- >
- );
- }
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/header.jsx b/app/javascript/mastodon/features/ui/components/header.jsx
new file mode 100644
index 000000000..1384bebda
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/header.jsx
@@ -0,0 +1,87 @@
+import React from 'react';
+import Logo from 'mastodon/components/logo';
+import { Link, withRouter } from 'react-router-dom';
+import { FormattedMessage } from 'react-intl';
+import { registrationsOpen, me } from 'mastodon/initial_state';
+import Avatar from 'mastodon/components/avatar';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { openModal } from 'mastodon/actions/modal';
+
+const Account = connect(state => ({
+ account: state.getIn(['accounts', me]),
+}))(({ account }) => (
+
+
+
+));
+
+const mapDispatchToProps = (dispatch) => ({
+ openClosedRegistrationsModal() {
+ dispatch(openModal('CLOSED_REGISTRATIONS'));
+ },
+});
+
+export default @connect(null, mapDispatchToProps)
+@withRouter
+class Header extends React.PureComponent {
+
+ static contextTypes = {
+ identity: PropTypes.object,
+ };
+
+ static propTypes = {
+ openClosedRegistrationsModal: PropTypes.func,
+ location: PropTypes.object,
+ };
+
+ render () {
+ const { signedIn } = this.context.identity;
+ const { location, openClosedRegistrationsModal } = this.props;
+
+ let content;
+
+ if (signedIn) {
+ content = (
+ <>
+ {location.pathname !== '/publish' && }
+
+ >
+ );
+ } else {
+ let signupButton;
+
+ if (registrationsOpen) {
+ signupButton = (
+
+
+
+ );
+ } else {
+ signupButton = (
+
+
+
+ );
+ }
+
+ content = (
+ <>
+
+ {signupButton}
+ >
+ );
+ }
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/image_loader.js b/app/javascript/mastodon/features/ui/components/image_loader.js
deleted file mode 100644
index 92aeef5c4..000000000
--- a/app/javascript/mastodon/features/ui/components/image_loader.js
+++ /dev/null
@@ -1,168 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React, { PureComponent } from 'react';
-import { LoadingBar } from 'react-redux-loading-bar';
-import ZoomableImage from './zoomable_image';
-
-export default class ImageLoader extends PureComponent {
-
- static propTypes = {
- alt: PropTypes.string,
- src: PropTypes.string.isRequired,
- previewSrc: PropTypes.string,
- width: PropTypes.number,
- height: PropTypes.number,
- onClick: PropTypes.func,
- zoomButtonHidden: PropTypes.bool,
- };
-
- static defaultProps = {
- alt: '',
- width: null,
- height: null,
- };
-
- state = {
- loading: true,
- error: false,
- width: null,
- };
-
- removers = [];
- canvas = null;
-
- get canvasContext() {
- if (!this.canvas) {
- return null;
- }
- this._canvasContext = this._canvasContext || this.canvas.getContext('2d');
- return this._canvasContext;
- }
-
- componentDidMount () {
- this.loadImage(this.props);
- }
-
- UNSAFE_componentWillReceiveProps (nextProps) {
- if (this.props.src !== nextProps.src) {
- this.loadImage(nextProps);
- }
- }
-
- componentWillUnmount () {
- this.removeEventListeners();
- }
-
- loadImage (props) {
- this.removeEventListeners();
- this.setState({ loading: true, error: false });
- Promise.all([
- props.previewSrc && this.loadPreviewCanvas(props),
- this.hasSize() && this.loadOriginalImage(props),
- ].filter(Boolean))
- .then(() => {
- this.setState({ loading: false, error: false });
- this.clearPreviewCanvas();
- })
- .catch(() => this.setState({ loading: false, error: true }));
- }
-
- loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => {
- const image = new Image();
- const removeEventListeners = () => {
- image.removeEventListener('error', handleError);
- image.removeEventListener('load', handleLoad);
- };
- const handleError = () => {
- removeEventListeners();
- reject();
- };
- const handleLoad = () => {
- removeEventListeners();
- this.canvasContext.drawImage(image, 0, 0, width, height);
- resolve();
- };
- image.addEventListener('error', handleError);
- image.addEventListener('load', handleLoad);
- image.src = previewSrc;
- this.removers.push(removeEventListeners);
- });
-
- clearPreviewCanvas () {
- const { width, height } = this.canvas;
- this.canvasContext.clearRect(0, 0, width, height);
- }
-
- loadOriginalImage = ({ src }) => new Promise((resolve, reject) => {
- const image = new Image();
- const removeEventListeners = () => {
- image.removeEventListener('error', handleError);
- image.removeEventListener('load', handleLoad);
- };
- const handleError = () => {
- removeEventListeners();
- reject();
- };
- const handleLoad = () => {
- removeEventListeners();
- resolve();
- };
- image.addEventListener('error', handleError);
- image.addEventListener('load', handleLoad);
- image.src = src;
- this.removers.push(removeEventListeners);
- });
-
- removeEventListeners () {
- this.removers.forEach(listeners => listeners());
- this.removers = [];
- }
-
- hasSize () {
- const { width, height } = this.props;
- return typeof width === 'number' && typeof height === 'number';
- }
-
- setCanvasRef = c => {
- this.canvas = c;
- if (c) this.setState({ width: c.offsetWidth });
- };
-
- render () {
- const { alt, src, width, height, onClick } = this.props;
- const { loading } = this.state;
-
- const className = classNames('image-loader', {
- 'image-loader--loading': loading,
- 'image-loader--amorphous': !this.hasSize(),
- });
-
- return (
-
- {loading ? (
- <>
-
-
-
-
- >
- ) : (
-
- )}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/image_loader.jsx b/app/javascript/mastodon/features/ui/components/image_loader.jsx
new file mode 100644
index 000000000..92aeef5c4
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/image_loader.jsx
@@ -0,0 +1,168 @@
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React, { PureComponent } from 'react';
+import { LoadingBar } from 'react-redux-loading-bar';
+import ZoomableImage from './zoomable_image';
+
+export default class ImageLoader extends PureComponent {
+
+ static propTypes = {
+ alt: PropTypes.string,
+ src: PropTypes.string.isRequired,
+ previewSrc: PropTypes.string,
+ width: PropTypes.number,
+ height: PropTypes.number,
+ onClick: PropTypes.func,
+ zoomButtonHidden: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ alt: '',
+ width: null,
+ height: null,
+ };
+
+ state = {
+ loading: true,
+ error: false,
+ width: null,
+ };
+
+ removers = [];
+ canvas = null;
+
+ get canvasContext() {
+ if (!this.canvas) {
+ return null;
+ }
+ this._canvasContext = this._canvasContext || this.canvas.getContext('2d');
+ return this._canvasContext;
+ }
+
+ componentDidMount () {
+ this.loadImage(this.props);
+ }
+
+ UNSAFE_componentWillReceiveProps (nextProps) {
+ if (this.props.src !== nextProps.src) {
+ this.loadImage(nextProps);
+ }
+ }
+
+ componentWillUnmount () {
+ this.removeEventListeners();
+ }
+
+ loadImage (props) {
+ this.removeEventListeners();
+ this.setState({ loading: true, error: false });
+ Promise.all([
+ props.previewSrc && this.loadPreviewCanvas(props),
+ this.hasSize() && this.loadOriginalImage(props),
+ ].filter(Boolean))
+ .then(() => {
+ this.setState({ loading: false, error: false });
+ this.clearPreviewCanvas();
+ })
+ .catch(() => this.setState({ loading: false, error: true }));
+ }
+
+ loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => {
+ const image = new Image();
+ const removeEventListeners = () => {
+ image.removeEventListener('error', handleError);
+ image.removeEventListener('load', handleLoad);
+ };
+ const handleError = () => {
+ removeEventListeners();
+ reject();
+ };
+ const handleLoad = () => {
+ removeEventListeners();
+ this.canvasContext.drawImage(image, 0, 0, width, height);
+ resolve();
+ };
+ image.addEventListener('error', handleError);
+ image.addEventListener('load', handleLoad);
+ image.src = previewSrc;
+ this.removers.push(removeEventListeners);
+ });
+
+ clearPreviewCanvas () {
+ const { width, height } = this.canvas;
+ this.canvasContext.clearRect(0, 0, width, height);
+ }
+
+ loadOriginalImage = ({ src }) => new Promise((resolve, reject) => {
+ const image = new Image();
+ const removeEventListeners = () => {
+ image.removeEventListener('error', handleError);
+ image.removeEventListener('load', handleLoad);
+ };
+ const handleError = () => {
+ removeEventListeners();
+ reject();
+ };
+ const handleLoad = () => {
+ removeEventListeners();
+ resolve();
+ };
+ image.addEventListener('error', handleError);
+ image.addEventListener('load', handleLoad);
+ image.src = src;
+ this.removers.push(removeEventListeners);
+ });
+
+ removeEventListeners () {
+ this.removers.forEach(listeners => listeners());
+ this.removers = [];
+ }
+
+ hasSize () {
+ const { width, height } = this.props;
+ return typeof width === 'number' && typeof height === 'number';
+ }
+
+ setCanvasRef = c => {
+ this.canvas = c;
+ if (c) this.setState({ width: c.offsetWidth });
+ };
+
+ render () {
+ const { alt, src, width, height, onClick } = this.props;
+ const { loading } = this.state;
+
+ const className = classNames('image-loader', {
+ 'image-loader--loading': loading,
+ 'image-loader--amorphous': !this.hasSize(),
+ });
+
+ return (
+
+ {loading ? (
+ <>
+
+
+
+
+ >
+ ) : (
+
+ )}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/image_modal.js b/app/javascript/mastodon/features/ui/components/image_modal.js
deleted file mode 100644
index 7522c3da5..000000000
--- a/app/javascript/mastodon/features/ui/components/image_modal.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import classNames from 'classnames';
-import { defineMessages, injectIntl } from 'react-intl';
-import IconButton from 'mastodon/components/icon_button';
-import ImageLoader from './image_loader';
-
-const messages = defineMessages({
- close: { id: 'lightbox.close', defaultMessage: 'Close' },
-});
-
-export default @injectIntl
-class ImageModal extends React.PureComponent {
-
- static propTypes = {
- src: PropTypes.string.isRequired,
- alt: PropTypes.string.isRequired,
- onClose: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- state = {
- navigationHidden: false,
- };
-
- toggleNavigation = () => {
- this.setState(prevState => ({
- navigationHidden: !prevState.navigationHidden,
- }));
- };
-
- render () {
- const { intl, src, alt, onClose } = this.props;
- const { navigationHidden } = this.state;
-
- const navigationClassName = classNames('media-modal__navigation', {
- 'media-modal__navigation--hidden': navigationHidden,
- });
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/image_modal.jsx b/app/javascript/mastodon/features/ui/components/image_modal.jsx
new file mode 100644
index 000000000..7522c3da5
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/image_modal.jsx
@@ -0,0 +1,59 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { defineMessages, injectIntl } from 'react-intl';
+import IconButton from 'mastodon/components/icon_button';
+import ImageLoader from './image_loader';
+
+const messages = defineMessages({
+ close: { id: 'lightbox.close', defaultMessage: 'Close' },
+});
+
+export default @injectIntl
+class ImageModal extends React.PureComponent {
+
+ static propTypes = {
+ src: PropTypes.string.isRequired,
+ alt: PropTypes.string.isRequired,
+ onClose: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ navigationHidden: false,
+ };
+
+ toggleNavigation = () => {
+ this.setState(prevState => ({
+ navigationHidden: !prevState.navigationHidden,
+ }));
+ };
+
+ render () {
+ const { intl, src, alt, onClose } = this.props;
+ const { navigationHidden } = this.state;
+
+ const navigationClassName = classNames('media-modal__navigation', {
+ 'media-modal__navigation--hidden': navigationHidden,
+ });
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/link_footer.js b/app/javascript/mastodon/features/ui/components/link_footer.js
deleted file mode 100644
index be2111207..000000000
--- a/app/javascript/mastodon/features/ui/components/link_footer.js
+++ /dev/null
@@ -1,102 +0,0 @@
-import { connect } from 'react-redux';
-import React from 'react';
-import PropTypes from 'prop-types';
-import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
-import { Link } from 'react-router-dom';
-import { domain, version, source_url, statusPageUrl, profile_directory as profileDirectory } from 'mastodon/initial_state';
-import { logOut } from 'mastodon/utils/log_out';
-import { openModal } from 'mastodon/actions/modal';
-import { PERMISSION_INVITE_USERS } from 'mastodon/permissions';
-
-const messages = defineMessages({
- logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
- logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
-});
-
-const mapDispatchToProps = (dispatch, { intl }) => ({
- onLogout () {
- dispatch(openModal('CONFIRM', {
- message: intl.formatMessage(messages.logoutMessage),
- confirm: intl.formatMessage(messages.logoutConfirm),
- closeWhenConfirm: false,
- onConfirm: () => logOut(),
- }));
- },
-});
-
-export default @injectIntl
-@connect(null, mapDispatchToProps)
-class LinkFooter extends React.PureComponent {
-
- static contextTypes = {
- identity: PropTypes.object,
- };
-
- static propTypes = {
- onLogout: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- handleLogoutClick = e => {
- e.preventDefault();
- e.stopPropagation();
-
- this.props.onLogout();
-
- return false;
- };
-
- render () {
- const { signedIn, permissions } = this.context.identity;
-
- const canInvite = signedIn && ((permissions & PERMISSION_INVITE_USERS) === PERMISSION_INVITE_USERS);
- const canProfileDirectory = profileDirectory;
-
- const DividingCircle = {' · '} ;
-
- return (
-
-
- {domain} :
- {' '}
-
- {statusPageUrl && (
- <>
- {DividingCircle}
-
- >
- )}
- {canInvite && (
- <>
- {DividingCircle}
-
- >
- )}
- {canProfileDirectory && (
- <>
- {DividingCircle}
-
- >
- )}
- {DividingCircle}
-
-
-
-
- Mastodon :
- {' '}
-
- {DividingCircle}
-
- {DividingCircle}
-
- {DividingCircle}
-
- {DividingCircle}
- v{version}
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/link_footer.jsx b/app/javascript/mastodon/features/ui/components/link_footer.jsx
new file mode 100644
index 000000000..be2111207
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/link_footer.jsx
@@ -0,0 +1,102 @@
+import { connect } from 'react-redux';
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import { Link } from 'react-router-dom';
+import { domain, version, source_url, statusPageUrl, profile_directory as profileDirectory } from 'mastodon/initial_state';
+import { logOut } from 'mastodon/utils/log_out';
+import { openModal } from 'mastodon/actions/modal';
+import { PERMISSION_INVITE_USERS } from 'mastodon/permissions';
+
+const messages = defineMessages({
+ logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
+ logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
+});
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+ onLogout () {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.logoutMessage),
+ confirm: intl.formatMessage(messages.logoutConfirm),
+ closeWhenConfirm: false,
+ onConfirm: () => logOut(),
+ }));
+ },
+});
+
+export default @injectIntl
+@connect(null, mapDispatchToProps)
+class LinkFooter extends React.PureComponent {
+
+ static contextTypes = {
+ identity: PropTypes.object,
+ };
+
+ static propTypes = {
+ onLogout: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleLogoutClick = e => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ this.props.onLogout();
+
+ return false;
+ };
+
+ render () {
+ const { signedIn, permissions } = this.context.identity;
+
+ const canInvite = signedIn && ((permissions & PERMISSION_INVITE_USERS) === PERMISSION_INVITE_USERS);
+ const canProfileDirectory = profileDirectory;
+
+ const DividingCircle = {' · '} ;
+
+ return (
+
+
+ {domain} :
+ {' '}
+
+ {statusPageUrl && (
+ <>
+ {DividingCircle}
+
+ >
+ )}
+ {canInvite && (
+ <>
+ {DividingCircle}
+
+ >
+ )}
+ {canProfileDirectory && (
+ <>
+ {DividingCircle}
+
+ >
+ )}
+ {DividingCircle}
+
+
+
+
+ Mastodon :
+ {' '}
+
+ {DividingCircle}
+
+ {DividingCircle}
+
+ {DividingCircle}
+
+ {DividingCircle}
+ v{version}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/list_panel.js b/app/javascript/mastodon/features/ui/components/list_panel.js
deleted file mode 100644
index 2f92a9254..000000000
--- a/app/javascript/mastodon/features/ui/components/list_panel.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { createSelector } from 'reselect';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { connect } from 'react-redux';
-import { withRouter } from 'react-router-dom';
-import { fetchLists } from 'mastodon/actions/lists';
-import ColumnLink from './column_link';
-
-const getOrderedLists = createSelector([state => state.get('lists')], lists => {
- if (!lists) {
- return lists;
- }
-
- return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))).take(4);
-});
-
-const mapStateToProps = state => ({
- lists: getOrderedLists(state),
-});
-
-export default @withRouter
-@connect(mapStateToProps)
-class ListPanel extends ImmutablePureComponent {
-
- static propTypes = {
- dispatch: PropTypes.func.isRequired,
- lists: ImmutablePropTypes.list,
- };
-
- componentDidMount () {
- const { dispatch } = this.props;
- dispatch(fetchLists());
- }
-
- render () {
- const { lists } = this.props;
-
- if (!lists || lists.isEmpty()) {
- return null;
- }
-
- return (
-
-
-
- {lists.map(list => (
-
- ))}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/list_panel.jsx b/app/javascript/mastodon/features/ui/components/list_panel.jsx
new file mode 100644
index 000000000..2f92a9254
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/list_panel.jsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { createSelector } from 'reselect';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { connect } from 'react-redux';
+import { withRouter } from 'react-router-dom';
+import { fetchLists } from 'mastodon/actions/lists';
+import ColumnLink from './column_link';
+
+const getOrderedLists = createSelector([state => state.get('lists')], lists => {
+ if (!lists) {
+ return lists;
+ }
+
+ return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))).take(4);
+});
+
+const mapStateToProps = state => ({
+ lists: getOrderedLists(state),
+});
+
+export default @withRouter
+@connect(mapStateToProps)
+class ListPanel extends ImmutablePureComponent {
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ lists: ImmutablePropTypes.list,
+ };
+
+ componentDidMount () {
+ const { dispatch } = this.props;
+ dispatch(fetchLists());
+ }
+
+ render () {
+ const { lists } = this.props;
+
+ if (!lists || lists.isEmpty()) {
+ return null;
+ }
+
+ return (
+
+
+
+ {lists.map(list => (
+
+ ))}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js
deleted file mode 100644
index 1cda8de04..000000000
--- a/app/javascript/mastodon/features/ui/components/media_modal.js
+++ /dev/null
@@ -1,250 +0,0 @@
-import React from 'react';
-import ReactSwipeableViews from 'react-swipeable-views';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import Video from 'mastodon/features/video';
-import classNames from 'classnames';
-import { defineMessages, injectIntl } from 'react-intl';
-import IconButton from 'mastodon/components/icon_button';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import ImageLoader from './image_loader';
-import Icon from 'mastodon/components/icon';
-import GIFV from 'mastodon/components/gifv';
-import { disableSwiping } from 'mastodon/initial_state';
-import Footer from 'mastodon/features/picture_in_picture/components/footer';
-import { getAverageFromBlurhash } from 'mastodon/blurhash';
-
-const messages = defineMessages({
- close: { id: 'lightbox.close', defaultMessage: 'Close' },
- previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
- next: { id: 'lightbox.next', defaultMessage: 'Next' },
-});
-
-export default @injectIntl
-class MediaModal extends ImmutablePureComponent {
-
- static propTypes = {
- media: ImmutablePropTypes.list.isRequired,
- statusId: PropTypes.string,
- index: PropTypes.number.isRequired,
- onClose: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- onChangeBackgroundColor: PropTypes.func.isRequired,
- currentTime: PropTypes.number,
- autoPlay: PropTypes.bool,
- volume: PropTypes.number,
- };
-
- state = {
- index: null,
- navigationHidden: false,
- zoomButtonHidden: false,
- };
-
- handleSwipe = (index) => {
- this.setState({ index: index % this.props.media.size });
- };
-
- handleTransitionEnd = () => {
- this.setState({
- zoomButtonHidden: false,
- });
- };
-
- handleNextClick = () => {
- this.setState({
- index: (this.getIndex() + 1) % this.props.media.size,
- zoomButtonHidden: true,
- });
- };
-
- handlePrevClick = () => {
- this.setState({
- index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size,
- zoomButtonHidden: true,
- });
- };
-
- handleChangeIndex = (e) => {
- const index = Number(e.currentTarget.getAttribute('data-index'));
-
- this.setState({
- index: index % this.props.media.size,
- zoomButtonHidden: true,
- });
- };
-
- handleKeyDown = (e) => {
- switch(e.key) {
- case 'ArrowLeft':
- this.handlePrevClick();
- e.preventDefault();
- e.stopPropagation();
- break;
- case 'ArrowRight':
- this.handleNextClick();
- e.preventDefault();
- e.stopPropagation();
- break;
- }
- };
-
- componentDidMount () {
- window.addEventListener('keydown', this.handleKeyDown, false);
-
- this._sendBackgroundColor();
- }
-
- componentDidUpdate (prevProps, prevState) {
- if (prevState.index !== this.state.index) {
- this._sendBackgroundColor();
- }
- }
-
- _sendBackgroundColor () {
- const { media, onChangeBackgroundColor } = this.props;
- const index = this.getIndex();
- const blurhash = media.getIn([index, 'blurhash']);
-
- if (blurhash) {
- const backgroundColor = getAverageFromBlurhash(blurhash);
- onChangeBackgroundColor(backgroundColor);
- }
- }
-
- componentWillUnmount () {
- window.removeEventListener('keydown', this.handleKeyDown);
-
- this.props.onChangeBackgroundColor(null);
- }
-
- getIndex () {
- return this.state.index !== null ? this.state.index : this.props.index;
- }
-
- toggleNavigation = () => {
- this.setState(prevState => ({
- navigationHidden: !prevState.navigationHidden,
- }));
- };
-
- render () {
- const { media, statusId, intl, onClose } = this.props;
- const { navigationHidden } = this.state;
-
- const index = this.getIndex();
-
- const leftNav = media.size > 1 && ;
- const rightNav = media.size > 1 && ;
-
- const content = media.map((image) => {
- const width = image.getIn(['meta', 'original', 'width']) || null;
- const height = image.getIn(['meta', 'original', 'height']) || null;
-
- if (image.get('type') === 'image') {
- return (
-
- );
- } else if (image.get('type') === 'video') {
- const { currentTime, autoPlay, volume } = this.props;
-
- return (
-
- );
- } else if (image.get('type') === 'gifv') {
- return (
-
- );
- }
-
- return null;
- }).toArray();
-
- // you can't use 100vh, because the viewport height is taller
- // than the visible part of the document in some mobile
- // browsers when it's address bar is visible.
- // https://developers.google.com/web/updates/2016/12/url-bar-resizing
- const swipeableViewsStyle = {
- width: '100%',
- height: '100%',
- };
-
- const containerStyle = {
- alignItems: 'center', // center vertically
- };
-
- const navigationClassName = classNames('media-modal__navigation', {
- 'media-modal__navigation--hidden': navigationHidden,
- });
-
- let pagination;
-
- if (media.size > 1) {
- pagination = media.map((item, i) => (
-
- {i + 1}
-
- ));
- }
-
- return (
-
-
-
- {content}
-
-
-
-
-
-
- {leftNav}
- {rightNav}
-
-
- {pagination &&
}
- {statusId &&
}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.jsx b/app/javascript/mastodon/features/ui/components/media_modal.jsx
new file mode 100644
index 000000000..1cda8de04
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/media_modal.jsx
@@ -0,0 +1,250 @@
+import React from 'react';
+import ReactSwipeableViews from 'react-swipeable-views';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Video from 'mastodon/features/video';
+import classNames from 'classnames';
+import { defineMessages, injectIntl } from 'react-intl';
+import IconButton from 'mastodon/components/icon_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImageLoader from './image_loader';
+import Icon from 'mastodon/components/icon';
+import GIFV from 'mastodon/components/gifv';
+import { disableSwiping } from 'mastodon/initial_state';
+import Footer from 'mastodon/features/picture_in_picture/components/footer';
+import { getAverageFromBlurhash } from 'mastodon/blurhash';
+
+const messages = defineMessages({
+ close: { id: 'lightbox.close', defaultMessage: 'Close' },
+ previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
+ next: { id: 'lightbox.next', defaultMessage: 'Next' },
+});
+
+export default @injectIntl
+class MediaModal extends ImmutablePureComponent {
+
+ static propTypes = {
+ media: ImmutablePropTypes.list.isRequired,
+ statusId: PropTypes.string,
+ index: PropTypes.number.isRequired,
+ onClose: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ onChangeBackgroundColor: PropTypes.func.isRequired,
+ currentTime: PropTypes.number,
+ autoPlay: PropTypes.bool,
+ volume: PropTypes.number,
+ };
+
+ state = {
+ index: null,
+ navigationHidden: false,
+ zoomButtonHidden: false,
+ };
+
+ handleSwipe = (index) => {
+ this.setState({ index: index % this.props.media.size });
+ };
+
+ handleTransitionEnd = () => {
+ this.setState({
+ zoomButtonHidden: false,
+ });
+ };
+
+ handleNextClick = () => {
+ this.setState({
+ index: (this.getIndex() + 1) % this.props.media.size,
+ zoomButtonHidden: true,
+ });
+ };
+
+ handlePrevClick = () => {
+ this.setState({
+ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size,
+ zoomButtonHidden: true,
+ });
+ };
+
+ handleChangeIndex = (e) => {
+ const index = Number(e.currentTarget.getAttribute('data-index'));
+
+ this.setState({
+ index: index % this.props.media.size,
+ zoomButtonHidden: true,
+ });
+ };
+
+ handleKeyDown = (e) => {
+ switch(e.key) {
+ case 'ArrowLeft':
+ this.handlePrevClick();
+ e.preventDefault();
+ e.stopPropagation();
+ break;
+ case 'ArrowRight':
+ this.handleNextClick();
+ e.preventDefault();
+ e.stopPropagation();
+ break;
+ }
+ };
+
+ componentDidMount () {
+ window.addEventListener('keydown', this.handleKeyDown, false);
+
+ this._sendBackgroundColor();
+ }
+
+ componentDidUpdate (prevProps, prevState) {
+ if (prevState.index !== this.state.index) {
+ this._sendBackgroundColor();
+ }
+ }
+
+ _sendBackgroundColor () {
+ const { media, onChangeBackgroundColor } = this.props;
+ const index = this.getIndex();
+ const blurhash = media.getIn([index, 'blurhash']);
+
+ if (blurhash) {
+ const backgroundColor = getAverageFromBlurhash(blurhash);
+ onChangeBackgroundColor(backgroundColor);
+ }
+ }
+
+ componentWillUnmount () {
+ window.removeEventListener('keydown', this.handleKeyDown);
+
+ this.props.onChangeBackgroundColor(null);
+ }
+
+ getIndex () {
+ return this.state.index !== null ? this.state.index : this.props.index;
+ }
+
+ toggleNavigation = () => {
+ this.setState(prevState => ({
+ navigationHidden: !prevState.navigationHidden,
+ }));
+ };
+
+ render () {
+ const { media, statusId, intl, onClose } = this.props;
+ const { navigationHidden } = this.state;
+
+ const index = this.getIndex();
+
+ const leftNav = media.size > 1 && ;
+ const rightNav = media.size > 1 && ;
+
+ const content = media.map((image) => {
+ const width = image.getIn(['meta', 'original', 'width']) || null;
+ const height = image.getIn(['meta', 'original', 'height']) || null;
+
+ if (image.get('type') === 'image') {
+ return (
+
+ );
+ } else if (image.get('type') === 'video') {
+ const { currentTime, autoPlay, volume } = this.props;
+
+ return (
+
+ );
+ } else if (image.get('type') === 'gifv') {
+ return (
+
+ );
+ }
+
+ return null;
+ }).toArray();
+
+ // you can't use 100vh, because the viewport height is taller
+ // than the visible part of the document in some mobile
+ // browsers when it's address bar is visible.
+ // https://developers.google.com/web/updates/2016/12/url-bar-resizing
+ const swipeableViewsStyle = {
+ width: '100%',
+ height: '100%',
+ };
+
+ const containerStyle = {
+ alignItems: 'center', // center vertically
+ };
+
+ const navigationClassName = classNames('media-modal__navigation', {
+ 'media-modal__navigation--hidden': navigationHidden,
+ });
+
+ let pagination;
+
+ if (media.size > 1) {
+ pagination = media.map((item, i) => (
+
+ {i + 1}
+
+ ));
+ }
+
+ return (
+
+
+
+ {content}
+
+
+
+
+
+
+ {leftNav}
+ {rightNav}
+
+
+ {pagination &&
}
+ {statusId &&
}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/modal_loading.js b/app/javascript/mastodon/features/ui/components/modal_loading.js
deleted file mode 100644
index f403ca4c9..000000000
--- a/app/javascript/mastodon/features/ui/components/modal_loading.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import React from 'react';
-
-import LoadingIndicator from '../../../components/loading_indicator';
-
-// Keep the markup in sync with
-// (make sure they have the same dimensions)
-const ModalLoading = () => (
-
-);
-
-export default ModalLoading;
diff --git a/app/javascript/mastodon/features/ui/components/modal_loading.jsx b/app/javascript/mastodon/features/ui/components/modal_loading.jsx
new file mode 100644
index 000000000..f403ca4c9
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/modal_loading.jsx
@@ -0,0 +1,20 @@
+import React from 'react';
+
+import LoadingIndicator from '../../../components/loading_indicator';
+
+// Keep the markup in sync with
+// (make sure they have the same dimensions)
+const ModalLoading = () => (
+
+);
+
+export default ModalLoading;
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
deleted file mode 100644
index 5a1734977..000000000
--- a/app/javascript/mastodon/features/ui/components/modal_root.js
+++ /dev/null
@@ -1,133 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
-import Base from 'mastodon/components/modal_root';
-import BundleContainer from '../containers/bundle_container';
-import BundleModalError from './bundle_modal_error';
-import ModalLoading from './modal_loading';
-import ActionsModal from './actions_modal';
-import MediaModal from './media_modal';
-import VideoModal from './video_modal';
-import BoostModal from './boost_modal';
-import AudioModal from './audio_modal';
-import ConfirmationModal from './confirmation_modal';
-import FocalPointModal from './focal_point_modal';
-import ImageModal from './image_modal';
-import {
- MuteModal,
- BlockModal,
- ReportModal,
- EmbedModal,
- ListEditor,
- ListAdder,
- CompareHistoryModal,
- FilterModal,
- InteractionModal,
- SubscribedLanguagesModal,
- ClosedRegistrationsModal,
-} from 'mastodon/features/ui/util/async-components';
-import { Helmet } from 'react-helmet';
-
-const MODAL_COMPONENTS = {
- 'MEDIA': () => Promise.resolve({ default: MediaModal }),
- 'VIDEO': () => Promise.resolve({ default: VideoModal }),
- 'AUDIO': () => Promise.resolve({ default: AudioModal }),
- 'IMAGE': () => Promise.resolve({ default: ImageModal }),
- 'BOOST': () => Promise.resolve({ default: BoostModal }),
- 'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
- 'MUTE': MuteModal,
- 'BLOCK': BlockModal,
- 'REPORT': ReportModal,
- 'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
- 'EMBED': EmbedModal,
- 'LIST_EDITOR': ListEditor,
- 'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
- 'LIST_ADDER': ListAdder,
- 'COMPARE_HISTORY': CompareHistoryModal,
- 'FILTER': FilterModal,
- 'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal,
- 'INTERACTION': InteractionModal,
- 'CLOSED_REGISTRATIONS': ClosedRegistrationsModal,
-};
-
-export default class ModalRoot extends React.PureComponent {
-
- static propTypes = {
- type: PropTypes.string,
- props: PropTypes.object,
- onClose: PropTypes.func.isRequired,
- ignoreFocus: PropTypes.bool,
- };
-
- state = {
- backgroundColor: null,
- };
-
- getSnapshotBeforeUpdate () {
- return { visible: !!this.props.type };
- }
-
- componentDidUpdate (prevProps, prevState, { visible }) {
- if (visible) {
- document.body.classList.add('with-modals--active');
- document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
- } else {
- document.body.classList.remove('with-modals--active');
- document.documentElement.style.marginRight = 0;
- }
- }
-
- setBackgroundColor = color => {
- this.setState({ backgroundColor: color });
- };
-
- renderLoading = modalId => () => {
- return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? : null;
- };
-
- renderError = (props) => {
- const { onClose } = this.props;
-
- return ;
- };
-
- handleClose = (ignoreFocus = false) => {
- const { onClose } = this.props;
- let message = null;
- try {
- message = this._modal?.getWrappedInstance?.().getCloseConfirmationMessage?.();
- } catch (_) {
- // injectIntl defines `getWrappedInstance` but errors out if `withRef`
- // isn't set.
- // This would be much smoother with react-intl 3+ and `forwardRef`.
- }
- onClose(message, ignoreFocus);
- };
-
- setModalRef = (c) => {
- this._modal = c;
- };
-
- render () {
- const { type, props, ignoreFocus } = this.props;
- const { backgroundColor } = this.state;
- const visible = !!type;
-
- return (
-
- {visible && (
- <>
-
- {(SpecificComponent) => }
-
-
-
-
-
- >
- )}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx
new file mode 100644
index 000000000..5a1734977
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx
@@ -0,0 +1,133 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
+import Base from 'mastodon/components/modal_root';
+import BundleContainer from '../containers/bundle_container';
+import BundleModalError from './bundle_modal_error';
+import ModalLoading from './modal_loading';
+import ActionsModal from './actions_modal';
+import MediaModal from './media_modal';
+import VideoModal from './video_modal';
+import BoostModal from './boost_modal';
+import AudioModal from './audio_modal';
+import ConfirmationModal from './confirmation_modal';
+import FocalPointModal from './focal_point_modal';
+import ImageModal from './image_modal';
+import {
+ MuteModal,
+ BlockModal,
+ ReportModal,
+ EmbedModal,
+ ListEditor,
+ ListAdder,
+ CompareHistoryModal,
+ FilterModal,
+ InteractionModal,
+ SubscribedLanguagesModal,
+ ClosedRegistrationsModal,
+} from 'mastodon/features/ui/util/async-components';
+import { Helmet } from 'react-helmet';
+
+const MODAL_COMPONENTS = {
+ 'MEDIA': () => Promise.resolve({ default: MediaModal }),
+ 'VIDEO': () => Promise.resolve({ default: VideoModal }),
+ 'AUDIO': () => Promise.resolve({ default: AudioModal }),
+ 'IMAGE': () => Promise.resolve({ default: ImageModal }),
+ 'BOOST': () => Promise.resolve({ default: BoostModal }),
+ 'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
+ 'MUTE': MuteModal,
+ 'BLOCK': BlockModal,
+ 'REPORT': ReportModal,
+ 'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
+ 'EMBED': EmbedModal,
+ 'LIST_EDITOR': ListEditor,
+ 'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
+ 'LIST_ADDER': ListAdder,
+ 'COMPARE_HISTORY': CompareHistoryModal,
+ 'FILTER': FilterModal,
+ 'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal,
+ 'INTERACTION': InteractionModal,
+ 'CLOSED_REGISTRATIONS': ClosedRegistrationsModal,
+};
+
+export default class ModalRoot extends React.PureComponent {
+
+ static propTypes = {
+ type: PropTypes.string,
+ props: PropTypes.object,
+ onClose: PropTypes.func.isRequired,
+ ignoreFocus: PropTypes.bool,
+ };
+
+ state = {
+ backgroundColor: null,
+ };
+
+ getSnapshotBeforeUpdate () {
+ return { visible: !!this.props.type };
+ }
+
+ componentDidUpdate (prevProps, prevState, { visible }) {
+ if (visible) {
+ document.body.classList.add('with-modals--active');
+ document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
+ } else {
+ document.body.classList.remove('with-modals--active');
+ document.documentElement.style.marginRight = 0;
+ }
+ }
+
+ setBackgroundColor = color => {
+ this.setState({ backgroundColor: color });
+ };
+
+ renderLoading = modalId => () => {
+ return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? : null;
+ };
+
+ renderError = (props) => {
+ const { onClose } = this.props;
+
+ return ;
+ };
+
+ handleClose = (ignoreFocus = false) => {
+ const { onClose } = this.props;
+ let message = null;
+ try {
+ message = this._modal?.getWrappedInstance?.().getCloseConfirmationMessage?.();
+ } catch (_) {
+ // injectIntl defines `getWrappedInstance` but errors out if `withRef`
+ // isn't set.
+ // This would be much smoother with react-intl 3+ and `forwardRef`.
+ }
+ onClose(message, ignoreFocus);
+ };
+
+ setModalRef = (c) => {
+ this._modal = c;
+ };
+
+ render () {
+ const { type, props, ignoreFocus } = this.props;
+ const { backgroundColor } = this.state;
+ const visible = !!type;
+
+ return (
+
+ {visible && (
+ <>
+
+ {(SpecificComponent) => }
+
+
+
+
+
+ >
+ )}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/mute_modal.js b/app/javascript/mastodon/features/ui/components/mute_modal.js
deleted file mode 100644
index b3e0ef56b..000000000
--- a/app/javascript/mastodon/features/ui/components/mute_modal.js
+++ /dev/null
@@ -1,140 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import Toggle from 'react-toggle';
-import Button from '../../../components/button';
-import { closeModal } from '../../../actions/modal';
-import { muteAccount } from '../../../actions/accounts';
-import { toggleHideNotifications, changeMuteDuration } from '../../../actions/mutes';
-
-const messages = defineMessages({
- minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
- hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
- days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
- indefinite: { id: 'mute_modal.indefinite', defaultMessage: 'Indefinite' },
-});
-
-const mapStateToProps = state => {
- return {
- account: state.getIn(['mutes', 'new', 'account']),
- notifications: state.getIn(['mutes', 'new', 'notifications']),
- muteDuration: state.getIn(['mutes', 'new', 'duration']),
- };
-};
-
-const mapDispatchToProps = dispatch => {
- return {
- onConfirm(account, notifications, muteDuration) {
- dispatch(muteAccount(account.get('id'), notifications, muteDuration));
- },
-
- onClose() {
- dispatch(closeModal());
- },
-
- onToggleNotifications() {
- dispatch(toggleHideNotifications());
- },
-
- onChangeMuteDuration(e) {
- dispatch(changeMuteDuration(e.target.value));
- },
- };
-};
-
-export default @connect(mapStateToProps, mapDispatchToProps)
-@injectIntl
-class MuteModal extends React.PureComponent {
-
- static propTypes = {
- account: PropTypes.object.isRequired,
- notifications: PropTypes.bool.isRequired,
- onClose: PropTypes.func.isRequired,
- onConfirm: PropTypes.func.isRequired,
- onToggleNotifications: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- muteDuration: PropTypes.number.isRequired,
- onChangeMuteDuration: PropTypes.func.isRequired,
- };
-
- componentDidMount() {
- this.button.focus();
- }
-
- handleClick = () => {
- this.props.onClose();
- this.props.onConfirm(this.props.account, this.props.notifications, this.props.muteDuration);
- };
-
- handleCancel = () => {
- this.props.onClose();
- };
-
- setRef = (c) => {
- this.button = c;
- };
-
- toggleNotifications = () => {
- this.props.onToggleNotifications();
- };
-
- changeMuteDuration = (e) => {
- this.props.onChangeMuteDuration(e);
- };
-
- render () {
- const { account, notifications, muteDuration, intl } = this.props;
-
- return (
-
-
-
- @{account.get('acct')} }}
- />
-
-
-
-
-
-
-
-
-
-
-
- :
-
- {/* eslint-disable-next-line jsx-a11y/no-onchange */}
-
- {intl.formatMessage(messages.indefinite)}
- {intl.formatMessage(messages.minutes, { number: 5 })}
- {intl.formatMessage(messages.minutes, { number: 30 })}
- {intl.formatMessage(messages.hours, { number: 1 })}
- {intl.formatMessage(messages.hours, { number: 6 })}
- {intl.formatMessage(messages.days, { number: 1 })}
- {intl.formatMessage(messages.days, { number: 3 })}
- {intl.formatMessage(messages.days, { number: 7 })}
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/mute_modal.jsx b/app/javascript/mastodon/features/ui/components/mute_modal.jsx
new file mode 100644
index 000000000..b3e0ef56b
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/mute_modal.jsx
@@ -0,0 +1,140 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Toggle from 'react-toggle';
+import Button from '../../../components/button';
+import { closeModal } from '../../../actions/modal';
+import { muteAccount } from '../../../actions/accounts';
+import { toggleHideNotifications, changeMuteDuration } from '../../../actions/mutes';
+
+const messages = defineMessages({
+ minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
+ hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
+ days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
+ indefinite: { id: 'mute_modal.indefinite', defaultMessage: 'Indefinite' },
+});
+
+const mapStateToProps = state => {
+ return {
+ account: state.getIn(['mutes', 'new', 'account']),
+ notifications: state.getIn(['mutes', 'new', 'notifications']),
+ muteDuration: state.getIn(['mutes', 'new', 'duration']),
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ onConfirm(account, notifications, muteDuration) {
+ dispatch(muteAccount(account.get('id'), notifications, muteDuration));
+ },
+
+ onClose() {
+ dispatch(closeModal());
+ },
+
+ onToggleNotifications() {
+ dispatch(toggleHideNotifications());
+ },
+
+ onChangeMuteDuration(e) {
+ dispatch(changeMuteDuration(e.target.value));
+ },
+ };
+};
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+class MuteModal extends React.PureComponent {
+
+ static propTypes = {
+ account: PropTypes.object.isRequired,
+ notifications: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onConfirm: PropTypes.func.isRequired,
+ onToggleNotifications: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ muteDuration: PropTypes.number.isRequired,
+ onChangeMuteDuration: PropTypes.func.isRequired,
+ };
+
+ componentDidMount() {
+ this.button.focus();
+ }
+
+ handleClick = () => {
+ this.props.onClose();
+ this.props.onConfirm(this.props.account, this.props.notifications, this.props.muteDuration);
+ };
+
+ handleCancel = () => {
+ this.props.onClose();
+ };
+
+ setRef = (c) => {
+ this.button = c;
+ };
+
+ toggleNotifications = () => {
+ this.props.onToggleNotifications();
+ };
+
+ changeMuteDuration = (e) => {
+ this.props.onChangeMuteDuration(e);
+ };
+
+ render () {
+ const { account, notifications, muteDuration, intl } = this.props;
+
+ return (
+
+
+
+ @{account.get('acct')} }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ :
+
+ {/* eslint-disable-next-line jsx-a11y/no-onchange */}
+
+ {intl.formatMessage(messages.indefinite)}
+ {intl.formatMessage(messages.minutes, { number: 5 })}
+ {intl.formatMessage(messages.minutes, { number: 30 })}
+ {intl.formatMessage(messages.hours, { number: 1 })}
+ {intl.formatMessage(messages.hours, { number: 6 })}
+ {intl.formatMessage(messages.days, { number: 1 })}
+ {intl.formatMessage(messages.days, { number: 3 })}
+ {intl.formatMessage(messages.days, { number: 7 })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.js b/app/javascript/mastodon/features/ui/components/navigation_panel.js
deleted file mode 100644
index 9a9309be0..000000000
--- a/app/javascript/mastodon/features/ui/components/navigation_panel.js
+++ /dev/null
@@ -1,107 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { defineMessages, injectIntl } from 'react-intl';
-import { Link } from 'react-router-dom';
-import Logo from 'mastodon/components/logo';
-import { timelinePreview, showTrends } from 'mastodon/initial_state';
-import ColumnLink from './column_link';
-import DisabledAccountBanner from './disabled_account_banner';
-import FollowRequestsColumnLink from './follow_requests_column_link';
-import ListPanel from './list_panel';
-import NotificationsCounterIcon from './notifications_counter_icon';
-import SignInBanner from './sign_in_banner';
-import NavigationPortal from 'mastodon/components/navigation_portal';
-
-const messages = defineMessages({
- home: { id: 'tabs_bar.home', defaultMessage: 'Home' },
- notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
- explore: { id: 'explore.title', defaultMessage: 'Explore' },
- local: { id: 'tabs_bar.local_timeline', defaultMessage: 'Local' },
- federated: { id: 'tabs_bar.federated_timeline', defaultMessage: 'Federated' },
- direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' },
- favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
- bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
- lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
- preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
- followsAndFollowers: { id: 'navigation_bar.follows_and_followers', defaultMessage: 'Follows and followers' },
- about: { id: 'navigation_bar.about', defaultMessage: 'About' },
- search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
-});
-
-export default @injectIntl
-class NavigationPanel extends React.Component {
-
- static contextTypes = {
- router: PropTypes.object.isRequired,
- identity: PropTypes.object.isRequired,
- };
-
- static propTypes = {
- intl: PropTypes.object.isRequired,
- };
-
- render () {
- const { intl } = this.props;
- const { signedIn, disabledAccountId } = this.context.identity;
-
- return (
-
-
-
-
-
-
- {signedIn && (
-
-
- } text={intl.formatMessage(messages.notifications)} />
-
-
- )}
-
- {showTrends ? (
-
- ) : (
-
- )}
-
- {(signedIn || timelinePreview) && (
- <>
-
-
- >
- )}
-
- {!signedIn && (
-
-
- { disabledAccountId ? : }
-
- )}
-
- {signedIn && (
-
-
-
-
-
-
-
-
-
-
-
-
- )}
-
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx
new file mode 100644
index 000000000..9a9309be0
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { Link } from 'react-router-dom';
+import Logo from 'mastodon/components/logo';
+import { timelinePreview, showTrends } from 'mastodon/initial_state';
+import ColumnLink from './column_link';
+import DisabledAccountBanner from './disabled_account_banner';
+import FollowRequestsColumnLink from './follow_requests_column_link';
+import ListPanel from './list_panel';
+import NotificationsCounterIcon from './notifications_counter_icon';
+import SignInBanner from './sign_in_banner';
+import NavigationPortal from 'mastodon/components/navigation_portal';
+
+const messages = defineMessages({
+ home: { id: 'tabs_bar.home', defaultMessage: 'Home' },
+ notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
+ explore: { id: 'explore.title', defaultMessage: 'Explore' },
+ local: { id: 'tabs_bar.local_timeline', defaultMessage: 'Local' },
+ federated: { id: 'tabs_bar.federated_timeline', defaultMessage: 'Federated' },
+ direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' },
+ favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
+ bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
+ lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
+ preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+ followsAndFollowers: { id: 'navigation_bar.follows_and_followers', defaultMessage: 'Follows and followers' },
+ about: { id: 'navigation_bar.about', defaultMessage: 'About' },
+ search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
+});
+
+export default @injectIntl
+class NavigationPanel extends React.Component {
+
+ static contextTypes = {
+ router: PropTypes.object.isRequired,
+ identity: PropTypes.object.isRequired,
+ };
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ };
+
+ render () {
+ const { intl } = this.props;
+ const { signedIn, disabledAccountId } = this.context.identity;
+
+ return (
+
+
+
+
+
+
+ {signedIn && (
+
+
+ } text={intl.formatMessage(messages.notifications)} />
+
+
+ )}
+
+ {showTrends ? (
+
+ ) : (
+
+ )}
+
+ {(signedIn || timelinePreview) && (
+ <>
+
+
+ >
+ )}
+
+ {!signedIn && (
+
+
+ { disabledAccountId ? : }
+
+ )}
+
+ {signedIn && (
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/report_modal.js b/app/javascript/mastodon/features/ui/components/report_modal.js
deleted file mode 100644
index 22c31eb52..000000000
--- a/app/javascript/mastodon/features/ui/components/report_modal.js
+++ /dev/null
@@ -1,219 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import { submitReport } from 'mastodon/actions/reports';
-import { expandAccountTimeline } from 'mastodon/actions/timelines';
-import { fetchServer } from 'mastodon/actions/server';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { makeGetAccount } from 'mastodon/selectors';
-import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
-import { OrderedSet } from 'immutable';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import IconButton from 'mastodon/components/icon_button';
-import Category from 'mastodon/features/report/category';
-import Statuses from 'mastodon/features/report/statuses';
-import Rules from 'mastodon/features/report/rules';
-import Comment from 'mastodon/features/report/comment';
-import Thanks from 'mastodon/features/report/thanks';
-
-const messages = defineMessages({
- close: { id: 'lightbox.close', defaultMessage: 'Close' },
-});
-
-const makeMapStateToProps = () => {
- const getAccount = makeGetAccount();
-
- const mapStateToProps = (state, { accountId }) => ({
- account: getAccount(state, accountId),
- });
-
- return mapStateToProps;
-};
-
-export default @connect(makeMapStateToProps)
-@injectIntl
-class ReportModal extends ImmutablePureComponent {
-
- static propTypes = {
- accountId: PropTypes.string.isRequired,
- statusId: PropTypes.string,
- dispatch: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- account: ImmutablePropTypes.map.isRequired,
- };
-
- state = {
- step: 'category',
- selectedStatusIds: OrderedSet(this.props.statusId ? [this.props.statusId] : []),
- comment: '',
- category: null,
- selectedRuleIds: OrderedSet(),
- forward: true,
- isSubmitting: false,
- isSubmitted: false,
- };
-
- handleSubmit = () => {
- const { dispatch, accountId } = this.props;
- const { selectedStatusIds, comment, category, selectedRuleIds, forward } = this.state;
-
- this.setState({ isSubmitting: true });
-
- dispatch(submitReport({
- account_id: accountId,
- status_ids: selectedStatusIds.toArray(),
- comment,
- forward,
- category,
- rule_ids: selectedRuleIds.toArray(),
- }, this.handleSuccess, this.handleFail));
- };
-
- handleSuccess = () => {
- this.setState({ isSubmitting: false, isSubmitted: true, step: 'thanks' });
- };
-
- handleFail = () => {
- this.setState({ isSubmitting: false });
- };
-
- handleStatusToggle = (statusId, checked) => {
- const { selectedStatusIds } = this.state;
-
- if (checked) {
- this.setState({ selectedStatusIds: selectedStatusIds.add(statusId) });
- } else {
- this.setState({ selectedStatusIds: selectedStatusIds.remove(statusId) });
- }
- };
-
- handleRuleToggle = (ruleId, checked) => {
- const { selectedRuleIds } = this.state;
-
- if (checked) {
- this.setState({ selectedRuleIds: selectedRuleIds.add(ruleId) });
- } else {
- this.setState({ selectedRuleIds: selectedRuleIds.remove(ruleId) });
- }
- };
-
- handleChangeCategory = category => {
- this.setState({ category });
- };
-
- handleChangeComment = comment => {
- this.setState({ comment });
- };
-
- handleChangeForward = forward => {
- this.setState({ forward });
- };
-
- handleNextStep = step => {
- this.setState({ step });
- };
-
- componentDidMount () {
- const { dispatch, accountId } = this.props;
-
- dispatch(expandAccountTimeline(accountId, { withReplies: true }));
- dispatch(fetchServer());
- }
-
- render () {
- const {
- accountId,
- account,
- intl,
- onClose,
- } = this.props;
-
- if (!account) {
- return null;
- }
-
- const {
- step,
- selectedStatusIds,
- selectedRuleIds,
- comment,
- forward,
- category,
- isSubmitting,
- isSubmitted,
- } = this.state;
-
- const domain = account.get('acct').split('@')[1];
- const isRemote = !!domain;
-
- let stepComponent;
-
- switch(step) {
- case 'category':
- stepComponent = (
-
- );
- break;
- case 'rules':
- stepComponent = (
-
- );
- break;
- case 'statuses':
- stepComponent = (
-
- );
- break;
- case 'comment':
- stepComponent = (
-
- );
- break;
- case 'thanks':
- stepComponent = (
-
- );
- }
-
- return (
-
-
-
- {account.get('acct')} }} />
-
-
-
- {stepComponent}
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/report_modal.jsx b/app/javascript/mastodon/features/ui/components/report_modal.jsx
new file mode 100644
index 000000000..22c31eb52
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/report_modal.jsx
@@ -0,0 +1,219 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { submitReport } from 'mastodon/actions/reports';
+import { expandAccountTimeline } from 'mastodon/actions/timelines';
+import { fetchServer } from 'mastodon/actions/server';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { makeGetAccount } from 'mastodon/selectors';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import { OrderedSet } from 'immutable';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import IconButton from 'mastodon/components/icon_button';
+import Category from 'mastodon/features/report/category';
+import Statuses from 'mastodon/features/report/statuses';
+import Rules from 'mastodon/features/report/rules';
+import Comment from 'mastodon/features/report/comment';
+import Thanks from 'mastodon/features/report/thanks';
+
+const messages = defineMessages({
+ close: { id: 'lightbox.close', defaultMessage: 'Close' },
+});
+
+const makeMapStateToProps = () => {
+ const getAccount = makeGetAccount();
+
+ const mapStateToProps = (state, { accountId }) => ({
+ account: getAccount(state, accountId),
+ });
+
+ return mapStateToProps;
+};
+
+export default @connect(makeMapStateToProps)
+@injectIntl
+class ReportModal extends ImmutablePureComponent {
+
+ static propTypes = {
+ accountId: PropTypes.string.isRequired,
+ statusId: PropTypes.string,
+ dispatch: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ account: ImmutablePropTypes.map.isRequired,
+ };
+
+ state = {
+ step: 'category',
+ selectedStatusIds: OrderedSet(this.props.statusId ? [this.props.statusId] : []),
+ comment: '',
+ category: null,
+ selectedRuleIds: OrderedSet(),
+ forward: true,
+ isSubmitting: false,
+ isSubmitted: false,
+ };
+
+ handleSubmit = () => {
+ const { dispatch, accountId } = this.props;
+ const { selectedStatusIds, comment, category, selectedRuleIds, forward } = this.state;
+
+ this.setState({ isSubmitting: true });
+
+ dispatch(submitReport({
+ account_id: accountId,
+ status_ids: selectedStatusIds.toArray(),
+ comment,
+ forward,
+ category,
+ rule_ids: selectedRuleIds.toArray(),
+ }, this.handleSuccess, this.handleFail));
+ };
+
+ handleSuccess = () => {
+ this.setState({ isSubmitting: false, isSubmitted: true, step: 'thanks' });
+ };
+
+ handleFail = () => {
+ this.setState({ isSubmitting: false });
+ };
+
+ handleStatusToggle = (statusId, checked) => {
+ const { selectedStatusIds } = this.state;
+
+ if (checked) {
+ this.setState({ selectedStatusIds: selectedStatusIds.add(statusId) });
+ } else {
+ this.setState({ selectedStatusIds: selectedStatusIds.remove(statusId) });
+ }
+ };
+
+ handleRuleToggle = (ruleId, checked) => {
+ const { selectedRuleIds } = this.state;
+
+ if (checked) {
+ this.setState({ selectedRuleIds: selectedRuleIds.add(ruleId) });
+ } else {
+ this.setState({ selectedRuleIds: selectedRuleIds.remove(ruleId) });
+ }
+ };
+
+ handleChangeCategory = category => {
+ this.setState({ category });
+ };
+
+ handleChangeComment = comment => {
+ this.setState({ comment });
+ };
+
+ handleChangeForward = forward => {
+ this.setState({ forward });
+ };
+
+ handleNextStep = step => {
+ this.setState({ step });
+ };
+
+ componentDidMount () {
+ const { dispatch, accountId } = this.props;
+
+ dispatch(expandAccountTimeline(accountId, { withReplies: true }));
+ dispatch(fetchServer());
+ }
+
+ render () {
+ const {
+ accountId,
+ account,
+ intl,
+ onClose,
+ } = this.props;
+
+ if (!account) {
+ return null;
+ }
+
+ const {
+ step,
+ selectedStatusIds,
+ selectedRuleIds,
+ comment,
+ forward,
+ category,
+ isSubmitting,
+ isSubmitted,
+ } = this.state;
+
+ const domain = account.get('acct').split('@')[1];
+ const isRemote = !!domain;
+
+ let stepComponent;
+
+ switch(step) {
+ case 'category':
+ stepComponent = (
+
+ );
+ break;
+ case 'rules':
+ stepComponent = (
+
+ );
+ break;
+ case 'statuses':
+ stepComponent = (
+
+ );
+ break;
+ case 'comment':
+ stepComponent = (
+
+ );
+ break;
+ case 'thanks':
+ stepComponent = (
+
+ );
+ }
+
+ return (
+
+
+
+ {account.get('acct')} }} />
+
+
+
+ {stepComponent}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/sign_in_banner.js b/app/javascript/mastodon/features/ui/components/sign_in_banner.js
deleted file mode 100644
index 86fcc11b5..000000000
--- a/app/javascript/mastodon/features/ui/components/sign_in_banner.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import React, { useCallback } from 'react';
-import { FormattedMessage } from 'react-intl';
-import { useDispatch } from 'react-redux';
-import { registrationsOpen } from 'mastodon/initial_state';
-import { openModal } from 'mastodon/actions/modal';
-
-const SignInBanner = () => {
- const dispatch = useDispatch();
-
- const openClosedRegistrationsModal = useCallback(
- () => dispatch(openModal('CLOSED_REGISTRATIONS')),
- [dispatch],
- );
-
- let signupButton;
-
- if (registrationsOpen) {
- signupButton = (
-
-
-
- );
- } else {
- signupButton = (
-
-
-
- );
- }
-
- return (
-
- );
-};
-
-export default SignInBanner;
diff --git a/app/javascript/mastodon/features/ui/components/sign_in_banner.jsx b/app/javascript/mastodon/features/ui/components/sign_in_banner.jsx
new file mode 100644
index 000000000..86fcc11b5
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/sign_in_banner.jsx
@@ -0,0 +1,40 @@
+import React, { useCallback } from 'react';
+import { FormattedMessage } from 'react-intl';
+import { useDispatch } from 'react-redux';
+import { registrationsOpen } from 'mastodon/initial_state';
+import { openModal } from 'mastodon/actions/modal';
+
+const SignInBanner = () => {
+ const dispatch = useDispatch();
+
+ const openClosedRegistrationsModal = useCallback(
+ () => dispatch(openModal('CLOSED_REGISTRATIONS')),
+ [dispatch],
+ );
+
+ let signupButton;
+
+ if (registrationsOpen) {
+ signupButton = (
+
+
+
+ );
+ } else {
+ signupButton = (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+export default SignInBanner;
diff --git a/app/javascript/mastodon/features/ui/components/upload_area.js b/app/javascript/mastodon/features/ui/components/upload_area.js
deleted file mode 100644
index 035fe7a26..000000000
--- a/app/javascript/mastodon/features/ui/components/upload_area.js
+++ /dev/null
@@ -1,52 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import Motion from '../../ui/util/optional_motion';
-import spring from 'react-motion/lib/spring';
-import { FormattedMessage } from 'react-intl';
-
-export default class UploadArea extends React.PureComponent {
-
- static propTypes = {
- active: PropTypes.bool,
- onClose: PropTypes.func,
- };
-
- handleKeyUp = (e) => {
- const keyCode = e.keyCode;
- if (this.props.active) {
- switch(keyCode) {
- case 27:
- e.preventDefault();
- e.stopPropagation();
- this.props.onClose();
- break;
- }
- }
- };
-
- componentDidMount () {
- window.addEventListener('keyup', this.handleKeyUp, false);
- }
-
- componentWillUnmount () {
- window.removeEventListener('keyup', this.handleKeyUp);
- }
-
- render () {
- const { active } = this.props;
-
- return (
-
- {({ backgroundOpacity, backgroundScale }) => (
-
- )}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/upload_area.jsx b/app/javascript/mastodon/features/ui/components/upload_area.jsx
new file mode 100644
index 000000000..035fe7a26
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/upload_area.jsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from '../../ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import { FormattedMessage } from 'react-intl';
+
+export default class UploadArea extends React.PureComponent {
+
+ static propTypes = {
+ active: PropTypes.bool,
+ onClose: PropTypes.func,
+ };
+
+ handleKeyUp = (e) => {
+ const keyCode = e.keyCode;
+ if (this.props.active) {
+ switch(keyCode) {
+ case 27:
+ e.preventDefault();
+ e.stopPropagation();
+ this.props.onClose();
+ break;
+ }
+ }
+ };
+
+ componentDidMount () {
+ window.addEventListener('keyup', this.handleKeyUp, false);
+ }
+
+ componentWillUnmount () {
+ window.removeEventListener('keyup', this.handleKeyUp);
+ }
+
+ render () {
+ const { active } = this.props;
+
+ return (
+
+ {({ backgroundOpacity, backgroundScale }) => (
+
+ )}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/video_modal.js b/app/javascript/mastodon/features/ui/components/video_modal.js
deleted file mode 100644
index abaccbe98..000000000
--- a/app/javascript/mastodon/features/ui/components/video_modal.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import Video from 'mastodon/features/video';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import Footer from 'mastodon/features/picture_in_picture/components/footer';
-import { getAverageFromBlurhash } from 'mastodon/blurhash';
-
-export default class VideoModal extends ImmutablePureComponent {
-
- static propTypes = {
- media: ImmutablePropTypes.map.isRequired,
- statusId: PropTypes.string,
- options: PropTypes.shape({
- startTime: PropTypes.number,
- autoPlay: PropTypes.bool,
- defaultVolume: PropTypes.number,
- }),
- onClose: PropTypes.func.isRequired,
- onChangeBackgroundColor: PropTypes.func.isRequired,
- };
-
- componentDidMount () {
- const { media, onChangeBackgroundColor } = this.props;
-
- const backgroundColor = getAverageFromBlurhash(media.get('blurhash'));
-
- if (backgroundColor) {
- onChangeBackgroundColor(backgroundColor);
- }
- }
-
- render () {
- const { media, statusId, onClose } = this.props;
- const options = this.props.options || {};
-
- return (
-
-
-
-
-
-
- {statusId && }
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/video_modal.jsx b/app/javascript/mastodon/features/ui/components/video_modal.jsx
new file mode 100644
index 000000000..abaccbe98
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/video_modal.jsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Video from 'mastodon/features/video';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Footer from 'mastodon/features/picture_in_picture/components/footer';
+import { getAverageFromBlurhash } from 'mastodon/blurhash';
+
+export default class VideoModal extends ImmutablePureComponent {
+
+ static propTypes = {
+ media: ImmutablePropTypes.map.isRequired,
+ statusId: PropTypes.string,
+ options: PropTypes.shape({
+ startTime: PropTypes.number,
+ autoPlay: PropTypes.bool,
+ defaultVolume: PropTypes.number,
+ }),
+ onClose: PropTypes.func.isRequired,
+ onChangeBackgroundColor: PropTypes.func.isRequired,
+ };
+
+ componentDidMount () {
+ const { media, onChangeBackgroundColor } = this.props;
+
+ const backgroundColor = getAverageFromBlurhash(media.get('blurhash'));
+
+ if (backgroundColor) {
+ onChangeBackgroundColor(backgroundColor);
+ }
+ }
+
+ render () {
+ const { media, statusId, onClose } = this.props;
+ const options = this.props.options || {};
+
+ return (
+
+
+
+
+
+
+ {statusId && }
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/zoomable_image.js b/app/javascript/mastodon/features/ui/components/zoomable_image.js
deleted file mode 100644
index 3b2bb0286..000000000
--- a/app/javascript/mastodon/features/ui/components/zoomable_image.js
+++ /dev/null
@@ -1,450 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import IconButton from 'mastodon/components/icon_button';
-import { defineMessages, injectIntl } from 'react-intl';
-
-const messages = defineMessages({
- compress: { id: 'lightbox.compress', defaultMessage: 'Compress image view box' },
- expand: { id: 'lightbox.expand', defaultMessage: 'Expand image view box' },
-});
-
-const MIN_SCALE = 1;
-const MAX_SCALE = 4;
-const NAV_BAR_HEIGHT = 66;
-
-const getMidpoint = (p1, p2) => ({
- x: (p1.clientX + p2.clientX) / 2,
- y: (p1.clientY + p2.clientY) / 2,
-});
-
-const getDistance = (p1, p2) =>
- Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2));
-
-const clamp = (min, max, value) => Math.min(max, Math.max(min, value));
-
-// Normalizing mousewheel speed across browsers
-// copy from: https://github.com/facebookarchive/fixed-data-table/blob/master/src/vendor_upstream/dom/normalizeWheel.js
-const normalizeWheel = event => {
- // Reasonable defaults
- const PIXEL_STEP = 10;
- const LINE_HEIGHT = 40;
- const PAGE_HEIGHT = 800;
-
- let sX = 0,
- sY = 0, // spinX, spinY
- pX = 0,
- pY = 0; // pixelX, pixelY
-
- // Legacy
- if ('detail' in event) {
- sY = event.detail;
- }
- if ('wheelDelta' in event) {
- sY = -event.wheelDelta / 120;
- }
- if ('wheelDeltaY' in event) {
- sY = -event.wheelDeltaY / 120;
- }
- if ('wheelDeltaX' in event) {
- sX = -event.wheelDeltaX / 120;
- }
-
- // side scrolling on FF with DOMMouseScroll
- if ('axis' in event && event.axis === event.HORIZONTAL_AXIS) {
- sX = sY;
- sY = 0;
- }
-
- pX = sX * PIXEL_STEP;
- pY = sY * PIXEL_STEP;
-
- if ('deltaY' in event) {
- pY = event.deltaY;
- }
- if ('deltaX' in event) {
- pX = event.deltaX;
- }
-
- if ((pX || pY) && event.deltaMode) {
- if (event.deltaMode === 1) { // delta in LINE units
- pX *= LINE_HEIGHT;
- pY *= LINE_HEIGHT;
- } else { // delta in PAGE units
- pX *= PAGE_HEIGHT;
- pY *= PAGE_HEIGHT;
- }
- }
-
- // Fall-back if spin cannot be determined
- if (pX && !sX) {
- sX = (pX < 1) ? -1 : 1;
- }
- if (pY && !sY) {
- sY = (pY < 1) ? -1 : 1;
- }
-
- return {
- spinX: sX,
- spinY: sY,
- pixelX: pX,
- pixelY: pY,
- };
-};
-
-export default @injectIntl
-class ZoomableImage extends React.PureComponent {
-
- static propTypes = {
- alt: PropTypes.string,
- src: PropTypes.string.isRequired,
- width: PropTypes.number,
- height: PropTypes.number,
- onClick: PropTypes.func,
- zoomButtonHidden: PropTypes.bool,
- intl: PropTypes.object.isRequired,
- };
-
- static defaultProps = {
- alt: '',
- width: null,
- height: null,
- };
-
- state = {
- scale: MIN_SCALE,
- zoomMatrix: {
- type: null, // 'width' 'height'
- fullScreen: null, // bool
- rate: null, // full screen scale rate
- clientWidth: null,
- clientHeight: null,
- offsetWidth: null,
- offsetHeight: null,
- clientHeightFixed: null,
- scrollTop: null,
- scrollLeft: null,
- translateX: null,
- translateY: null,
- },
- zoomState: 'expand', // 'expand' 'compress'
- navigationHidden: false,
- dragPosition: { top: 0, left: 0, x: 0, y: 0 },
- dragged: false,
- lockScroll: { x: 0, y: 0 },
- lockTranslate: { x: 0, y: 0 },
- };
-
- removers = [];
- container = null;
- image = null;
- lastTouchEndTime = 0;
- lastDistance = 0;
-
- componentDidMount () {
- let handler = this.handleTouchStart;
- this.container.addEventListener('touchstart', handler);
- this.removers.push(() => this.container.removeEventListener('touchstart', handler));
- handler = this.handleTouchMove;
- // on Chrome 56+, touch event listeners will default to passive
- // https://www.chromestatus.com/features/5093566007214080
- this.container.addEventListener('touchmove', handler, { passive: false });
- this.removers.push(() => this.container.removeEventListener('touchend', handler));
-
- handler = this.mouseDownHandler;
- this.container.addEventListener('mousedown', handler);
- this.removers.push(() => this.container.removeEventListener('mousedown', handler));
-
- handler = this.mouseWheelHandler;
- this.container.addEventListener('wheel', handler);
- this.removers.push(() => this.container.removeEventListener('wheel', handler));
- // Old Chrome
- this.container.addEventListener('mousewheel', handler);
- this.removers.push(() => this.container.removeEventListener('mousewheel', handler));
- // Old Firefox
- this.container.addEventListener('DOMMouseScroll', handler);
- this.removers.push(() => this.container.removeEventListener('DOMMouseScroll', handler));
-
- this.initZoomMatrix();
- }
-
- componentWillUnmount () {
- this.removeEventListeners();
- }
-
- componentDidUpdate () {
- this.setState({ zoomState: this.state.scale >= this.state.zoomMatrix.rate ? 'compress' : 'expand' });
-
- if (this.state.scale === MIN_SCALE) {
- this.container.style.removeProperty('cursor');
- }
- }
-
- UNSAFE_componentWillReceiveProps () {
- // reset when slide to next image
- if (this.props.zoomButtonHidden) {
- this.setState({
- scale: MIN_SCALE,
- lockTranslate: { x: 0, y: 0 },
- }, () => {
- this.container.scrollLeft = 0;
- this.container.scrollTop = 0;
- });
- }
- }
-
- removeEventListeners () {
- this.removers.forEach(listeners => listeners());
- this.removers = [];
- }
-
- mouseWheelHandler = e => {
- e.preventDefault();
-
- const event = normalizeWheel(e);
-
- if (this.state.zoomMatrix.type === 'width') {
- // full width, scroll vertical
- this.container.scrollTop = Math.max(this.container.scrollTop + event.pixelY, this.state.lockScroll.y);
- } else {
- // full height, scroll horizontal
- this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelY, this.state.lockScroll.x);
- }
-
- // lock horizontal scroll
- this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelX, this.state.lockScroll.x);
- };
-
- mouseDownHandler = e => {
- this.container.style.cursor = 'grabbing';
- this.container.style.userSelect = 'none';
-
- this.setState({ dragPosition: {
- left: this.container.scrollLeft,
- top: this.container.scrollTop,
- // Get the current mouse position
- x: e.clientX,
- y: e.clientY,
- } });
-
- this.image.addEventListener('mousemove', this.mouseMoveHandler);
- this.image.addEventListener('mouseup', this.mouseUpHandler);
- };
-
- mouseMoveHandler = e => {
- const dx = e.clientX - this.state.dragPosition.x;
- const dy = e.clientY - this.state.dragPosition.y;
-
- this.container.scrollLeft = Math.max(this.state.dragPosition.left - dx, this.state.lockScroll.x);
- this.container.scrollTop = Math.max(this.state.dragPosition.top - dy, this.state.lockScroll.y);
-
- this.setState({ dragged: true });
- };
-
- mouseUpHandler = () => {
- this.container.style.cursor = 'grab';
- this.container.style.removeProperty('user-select');
-
- this.image.removeEventListener('mousemove', this.mouseMoveHandler);
- this.image.removeEventListener('mouseup', this.mouseUpHandler);
- };
-
- handleTouchStart = e => {
- if (e.touches.length !== 2) return;
-
- this.lastDistance = getDistance(...e.touches);
- };
-
- handleTouchMove = e => {
- const { scrollTop, scrollHeight, clientHeight } = this.container;
- if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) {
- // prevent propagating event to MediaModal
- e.stopPropagation();
- return;
- }
- if (e.touches.length !== 2) return;
-
- e.preventDefault();
- e.stopPropagation();
-
- const distance = getDistance(...e.touches);
- const midpoint = getMidpoint(...e.touches);
- const _MAX_SCALE = Math.max(MAX_SCALE, this.state.zoomMatrix.rate);
- const scale = clamp(MIN_SCALE, _MAX_SCALE, this.state.scale * distance / this.lastDistance);
-
- this.zoom(scale, midpoint);
-
- this.lastMidpoint = midpoint;
- this.lastDistance = distance;
- };
-
- zoom(nextScale, midpoint) {
- const { scale, zoomMatrix } = this.state;
- const { scrollLeft, scrollTop } = this.container;
-
- // math memo:
- // x = (scrollLeft + midpoint.x) / scrollWidth
- // x' = (nextScrollLeft + midpoint.x) / nextScrollWidth
- // scrollWidth = clientWidth * scale
- // scrollWidth' = clientWidth * nextScale
- // Solve x = x' for nextScrollLeft
- const nextScrollLeft = (scrollLeft + midpoint.x) * nextScale / scale - midpoint.x;
- const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y;
-
- this.setState({ scale: nextScale }, () => {
- this.container.scrollLeft = nextScrollLeft;
- this.container.scrollTop = nextScrollTop;
- // reset the translateX/Y constantly
- if (nextScale < zoomMatrix.rate) {
- this.setState({
- lockTranslate: {
- x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)),
- y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)),
- },
- });
- }
- });
- }
-
- handleClick = e => {
- // don't propagate event to MediaModal
- e.stopPropagation();
- const dragged = this.state.dragged;
- this.setState({ dragged: false });
- if (dragged) return;
- const handler = this.props.onClick;
- if (handler) handler();
- this.setState({ navigationHidden: !this.state.navigationHidden });
- };
-
- handleMouseDown = e => {
- e.preventDefault();
- };
-
- initZoomMatrix = () => {
- const { width, height } = this.props;
- const { clientWidth, clientHeight } = this.container;
- const { offsetWidth, offsetHeight } = this.image;
- const clientHeightFixed = clientHeight - NAV_BAR_HEIGHT;
-
- const type = width / height < clientWidth / clientHeightFixed ? 'width' : 'height';
- const fullScreen = type === 'width' ? width > clientWidth : height > clientHeightFixed;
- const rate = type === 'width' ? Math.min(clientWidth, width) / offsetWidth : Math.min(clientHeightFixed, height) / offsetHeight;
- const scrollTop = type === 'width' ? (clientHeight - offsetHeight) / 2 - NAV_BAR_HEIGHT : (clientHeightFixed - offsetHeight) / 2;
- const scrollLeft = (clientWidth - offsetWidth) / 2;
- const translateX = type === 'width' ? (width - offsetWidth) / (2 * rate) : 0;
- const translateY = type === 'height' ? (height - offsetHeight) / (2 * rate) : 0;
-
- this.setState({
- zoomMatrix: {
- type: type,
- fullScreen: fullScreen,
- rate: rate,
- clientWidth: clientWidth,
- clientHeight: clientHeight,
- offsetWidth: offsetWidth,
- offsetHeight: offsetHeight,
- clientHeightFixed: clientHeightFixed,
- scrollTop: scrollTop,
- scrollLeft: scrollLeft,
- translateX: translateX,
- translateY: translateY,
- },
- });
- };
-
- handleZoomClick = e => {
- e.preventDefault();
- e.stopPropagation();
-
- const { scale, zoomMatrix } = this.state;
-
- if ( scale >= zoomMatrix.rate ) {
- this.setState({
- scale: MIN_SCALE,
- lockScroll: {
- x: 0,
- y: 0,
- },
- lockTranslate: {
- x: 0,
- y: 0,
- },
- }, () => {
- this.container.scrollLeft = 0;
- this.container.scrollTop = 0;
- });
- } else {
- this.setState({
- scale: zoomMatrix.rate,
- lockScroll: {
- x: zoomMatrix.scrollLeft,
- y: zoomMatrix.scrollTop,
- },
- lockTranslate: {
- x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX,
- y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY,
- },
- }, () => {
- this.container.scrollLeft = zoomMatrix.scrollLeft;
- this.container.scrollTop = zoomMatrix.scrollTop;
- });
- }
-
- this.container.style.cursor = 'grab';
- this.container.style.removeProperty('user-select');
- };
-
- setContainerRef = c => {
- this.container = c;
- };
-
- setImageRef = c => {
- this.image = c;
- };
-
- render () {
- const { alt, src, width, height, intl } = this.props;
- const { scale, lockTranslate } = this.state;
- const overflow = scale === MIN_SCALE ? 'hidden' : 'scroll';
- const zoomButtonShouldHide = this.state.navigationHidden || this.props.zoomButtonHidden || this.state.zoomMatrix.rate <= MIN_SCALE ? 'media-modal__zoom-button--hidden' : '';
- const zoomButtonTitle = this.state.zoomState === 'compress' ? intl.formatMessage(messages.compress) : intl.formatMessage(messages.expand);
-
- return (
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/zoomable_image.jsx b/app/javascript/mastodon/features/ui/components/zoomable_image.jsx
new file mode 100644
index 000000000..3b2bb0286
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/zoomable_image.jsx
@@ -0,0 +1,450 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import IconButton from 'mastodon/components/icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+ compress: { id: 'lightbox.compress', defaultMessage: 'Compress image view box' },
+ expand: { id: 'lightbox.expand', defaultMessage: 'Expand image view box' },
+});
+
+const MIN_SCALE = 1;
+const MAX_SCALE = 4;
+const NAV_BAR_HEIGHT = 66;
+
+const getMidpoint = (p1, p2) => ({
+ x: (p1.clientX + p2.clientX) / 2,
+ y: (p1.clientY + p2.clientY) / 2,
+});
+
+const getDistance = (p1, p2) =>
+ Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2));
+
+const clamp = (min, max, value) => Math.min(max, Math.max(min, value));
+
+// Normalizing mousewheel speed across browsers
+// copy from: https://github.com/facebookarchive/fixed-data-table/blob/master/src/vendor_upstream/dom/normalizeWheel.js
+const normalizeWheel = event => {
+ // Reasonable defaults
+ const PIXEL_STEP = 10;
+ const LINE_HEIGHT = 40;
+ const PAGE_HEIGHT = 800;
+
+ let sX = 0,
+ sY = 0, // spinX, spinY
+ pX = 0,
+ pY = 0; // pixelX, pixelY
+
+ // Legacy
+ if ('detail' in event) {
+ sY = event.detail;
+ }
+ if ('wheelDelta' in event) {
+ sY = -event.wheelDelta / 120;
+ }
+ if ('wheelDeltaY' in event) {
+ sY = -event.wheelDeltaY / 120;
+ }
+ if ('wheelDeltaX' in event) {
+ sX = -event.wheelDeltaX / 120;
+ }
+
+ // side scrolling on FF with DOMMouseScroll
+ if ('axis' in event && event.axis === event.HORIZONTAL_AXIS) {
+ sX = sY;
+ sY = 0;
+ }
+
+ pX = sX * PIXEL_STEP;
+ pY = sY * PIXEL_STEP;
+
+ if ('deltaY' in event) {
+ pY = event.deltaY;
+ }
+ if ('deltaX' in event) {
+ pX = event.deltaX;
+ }
+
+ if ((pX || pY) && event.deltaMode) {
+ if (event.deltaMode === 1) { // delta in LINE units
+ pX *= LINE_HEIGHT;
+ pY *= LINE_HEIGHT;
+ } else { // delta in PAGE units
+ pX *= PAGE_HEIGHT;
+ pY *= PAGE_HEIGHT;
+ }
+ }
+
+ // Fall-back if spin cannot be determined
+ if (pX && !sX) {
+ sX = (pX < 1) ? -1 : 1;
+ }
+ if (pY && !sY) {
+ sY = (pY < 1) ? -1 : 1;
+ }
+
+ return {
+ spinX: sX,
+ spinY: sY,
+ pixelX: pX,
+ pixelY: pY,
+ };
+};
+
+export default @injectIntl
+class ZoomableImage extends React.PureComponent {
+
+ static propTypes = {
+ alt: PropTypes.string,
+ src: PropTypes.string.isRequired,
+ width: PropTypes.number,
+ height: PropTypes.number,
+ onClick: PropTypes.func,
+ zoomButtonHidden: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ };
+
+ static defaultProps = {
+ alt: '',
+ width: null,
+ height: null,
+ };
+
+ state = {
+ scale: MIN_SCALE,
+ zoomMatrix: {
+ type: null, // 'width' 'height'
+ fullScreen: null, // bool
+ rate: null, // full screen scale rate
+ clientWidth: null,
+ clientHeight: null,
+ offsetWidth: null,
+ offsetHeight: null,
+ clientHeightFixed: null,
+ scrollTop: null,
+ scrollLeft: null,
+ translateX: null,
+ translateY: null,
+ },
+ zoomState: 'expand', // 'expand' 'compress'
+ navigationHidden: false,
+ dragPosition: { top: 0, left: 0, x: 0, y: 0 },
+ dragged: false,
+ lockScroll: { x: 0, y: 0 },
+ lockTranslate: { x: 0, y: 0 },
+ };
+
+ removers = [];
+ container = null;
+ image = null;
+ lastTouchEndTime = 0;
+ lastDistance = 0;
+
+ componentDidMount () {
+ let handler = this.handleTouchStart;
+ this.container.addEventListener('touchstart', handler);
+ this.removers.push(() => this.container.removeEventListener('touchstart', handler));
+ handler = this.handleTouchMove;
+ // on Chrome 56+, touch event listeners will default to passive
+ // https://www.chromestatus.com/features/5093566007214080
+ this.container.addEventListener('touchmove', handler, { passive: false });
+ this.removers.push(() => this.container.removeEventListener('touchend', handler));
+
+ handler = this.mouseDownHandler;
+ this.container.addEventListener('mousedown', handler);
+ this.removers.push(() => this.container.removeEventListener('mousedown', handler));
+
+ handler = this.mouseWheelHandler;
+ this.container.addEventListener('wheel', handler);
+ this.removers.push(() => this.container.removeEventListener('wheel', handler));
+ // Old Chrome
+ this.container.addEventListener('mousewheel', handler);
+ this.removers.push(() => this.container.removeEventListener('mousewheel', handler));
+ // Old Firefox
+ this.container.addEventListener('DOMMouseScroll', handler);
+ this.removers.push(() => this.container.removeEventListener('DOMMouseScroll', handler));
+
+ this.initZoomMatrix();
+ }
+
+ componentWillUnmount () {
+ this.removeEventListeners();
+ }
+
+ componentDidUpdate () {
+ this.setState({ zoomState: this.state.scale >= this.state.zoomMatrix.rate ? 'compress' : 'expand' });
+
+ if (this.state.scale === MIN_SCALE) {
+ this.container.style.removeProperty('cursor');
+ }
+ }
+
+ UNSAFE_componentWillReceiveProps () {
+ // reset when slide to next image
+ if (this.props.zoomButtonHidden) {
+ this.setState({
+ scale: MIN_SCALE,
+ lockTranslate: { x: 0, y: 0 },
+ }, () => {
+ this.container.scrollLeft = 0;
+ this.container.scrollTop = 0;
+ });
+ }
+ }
+
+ removeEventListeners () {
+ this.removers.forEach(listeners => listeners());
+ this.removers = [];
+ }
+
+ mouseWheelHandler = e => {
+ e.preventDefault();
+
+ const event = normalizeWheel(e);
+
+ if (this.state.zoomMatrix.type === 'width') {
+ // full width, scroll vertical
+ this.container.scrollTop = Math.max(this.container.scrollTop + event.pixelY, this.state.lockScroll.y);
+ } else {
+ // full height, scroll horizontal
+ this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelY, this.state.lockScroll.x);
+ }
+
+ // lock horizontal scroll
+ this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelX, this.state.lockScroll.x);
+ };
+
+ mouseDownHandler = e => {
+ this.container.style.cursor = 'grabbing';
+ this.container.style.userSelect = 'none';
+
+ this.setState({ dragPosition: {
+ left: this.container.scrollLeft,
+ top: this.container.scrollTop,
+ // Get the current mouse position
+ x: e.clientX,
+ y: e.clientY,
+ } });
+
+ this.image.addEventListener('mousemove', this.mouseMoveHandler);
+ this.image.addEventListener('mouseup', this.mouseUpHandler);
+ };
+
+ mouseMoveHandler = e => {
+ const dx = e.clientX - this.state.dragPosition.x;
+ const dy = e.clientY - this.state.dragPosition.y;
+
+ this.container.scrollLeft = Math.max(this.state.dragPosition.left - dx, this.state.lockScroll.x);
+ this.container.scrollTop = Math.max(this.state.dragPosition.top - dy, this.state.lockScroll.y);
+
+ this.setState({ dragged: true });
+ };
+
+ mouseUpHandler = () => {
+ this.container.style.cursor = 'grab';
+ this.container.style.removeProperty('user-select');
+
+ this.image.removeEventListener('mousemove', this.mouseMoveHandler);
+ this.image.removeEventListener('mouseup', this.mouseUpHandler);
+ };
+
+ handleTouchStart = e => {
+ if (e.touches.length !== 2) return;
+
+ this.lastDistance = getDistance(...e.touches);
+ };
+
+ handleTouchMove = e => {
+ const { scrollTop, scrollHeight, clientHeight } = this.container;
+ if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) {
+ // prevent propagating event to MediaModal
+ e.stopPropagation();
+ return;
+ }
+ if (e.touches.length !== 2) return;
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ const distance = getDistance(...e.touches);
+ const midpoint = getMidpoint(...e.touches);
+ const _MAX_SCALE = Math.max(MAX_SCALE, this.state.zoomMatrix.rate);
+ const scale = clamp(MIN_SCALE, _MAX_SCALE, this.state.scale * distance / this.lastDistance);
+
+ this.zoom(scale, midpoint);
+
+ this.lastMidpoint = midpoint;
+ this.lastDistance = distance;
+ };
+
+ zoom(nextScale, midpoint) {
+ const { scale, zoomMatrix } = this.state;
+ const { scrollLeft, scrollTop } = this.container;
+
+ // math memo:
+ // x = (scrollLeft + midpoint.x) / scrollWidth
+ // x' = (nextScrollLeft + midpoint.x) / nextScrollWidth
+ // scrollWidth = clientWidth * scale
+ // scrollWidth' = clientWidth * nextScale
+ // Solve x = x' for nextScrollLeft
+ const nextScrollLeft = (scrollLeft + midpoint.x) * nextScale / scale - midpoint.x;
+ const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y;
+
+ this.setState({ scale: nextScale }, () => {
+ this.container.scrollLeft = nextScrollLeft;
+ this.container.scrollTop = nextScrollTop;
+ // reset the translateX/Y constantly
+ if (nextScale < zoomMatrix.rate) {
+ this.setState({
+ lockTranslate: {
+ x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)),
+ y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)),
+ },
+ });
+ }
+ });
+ }
+
+ handleClick = e => {
+ // don't propagate event to MediaModal
+ e.stopPropagation();
+ const dragged = this.state.dragged;
+ this.setState({ dragged: false });
+ if (dragged) return;
+ const handler = this.props.onClick;
+ if (handler) handler();
+ this.setState({ navigationHidden: !this.state.navigationHidden });
+ };
+
+ handleMouseDown = e => {
+ e.preventDefault();
+ };
+
+ initZoomMatrix = () => {
+ const { width, height } = this.props;
+ const { clientWidth, clientHeight } = this.container;
+ const { offsetWidth, offsetHeight } = this.image;
+ const clientHeightFixed = clientHeight - NAV_BAR_HEIGHT;
+
+ const type = width / height < clientWidth / clientHeightFixed ? 'width' : 'height';
+ const fullScreen = type === 'width' ? width > clientWidth : height > clientHeightFixed;
+ const rate = type === 'width' ? Math.min(clientWidth, width) / offsetWidth : Math.min(clientHeightFixed, height) / offsetHeight;
+ const scrollTop = type === 'width' ? (clientHeight - offsetHeight) / 2 - NAV_BAR_HEIGHT : (clientHeightFixed - offsetHeight) / 2;
+ const scrollLeft = (clientWidth - offsetWidth) / 2;
+ const translateX = type === 'width' ? (width - offsetWidth) / (2 * rate) : 0;
+ const translateY = type === 'height' ? (height - offsetHeight) / (2 * rate) : 0;
+
+ this.setState({
+ zoomMatrix: {
+ type: type,
+ fullScreen: fullScreen,
+ rate: rate,
+ clientWidth: clientWidth,
+ clientHeight: clientHeight,
+ offsetWidth: offsetWidth,
+ offsetHeight: offsetHeight,
+ clientHeightFixed: clientHeightFixed,
+ scrollTop: scrollTop,
+ scrollLeft: scrollLeft,
+ translateX: translateX,
+ translateY: translateY,
+ },
+ });
+ };
+
+ handleZoomClick = e => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const { scale, zoomMatrix } = this.state;
+
+ if ( scale >= zoomMatrix.rate ) {
+ this.setState({
+ scale: MIN_SCALE,
+ lockScroll: {
+ x: 0,
+ y: 0,
+ },
+ lockTranslate: {
+ x: 0,
+ y: 0,
+ },
+ }, () => {
+ this.container.scrollLeft = 0;
+ this.container.scrollTop = 0;
+ });
+ } else {
+ this.setState({
+ scale: zoomMatrix.rate,
+ lockScroll: {
+ x: zoomMatrix.scrollLeft,
+ y: zoomMatrix.scrollTop,
+ },
+ lockTranslate: {
+ x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX,
+ y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY,
+ },
+ }, () => {
+ this.container.scrollLeft = zoomMatrix.scrollLeft;
+ this.container.scrollTop = zoomMatrix.scrollTop;
+ });
+ }
+
+ this.container.style.cursor = 'grab';
+ this.container.style.removeProperty('user-select');
+ };
+
+ setContainerRef = c => {
+ this.container = c;
+ };
+
+ setImageRef = c => {
+ this.image = c;
+ };
+
+ render () {
+ const { alt, src, width, height, intl } = this.props;
+ const { scale, lockTranslate } = this.state;
+ const overflow = scale === MIN_SCALE ? 'hidden' : 'scroll';
+ const zoomButtonShouldHide = this.state.navigationHidden || this.props.zoomButtonHidden || this.state.zoomMatrix.rate <= MIN_SCALE ? 'media-modal__zoom-button--hidden' : '';
+ const zoomButtonTitle = this.state.zoomState === 'compress' ? intl.formatMessage(messages.compress) : intl.formatMessage(messages.expand);
+
+ return (
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
deleted file mode 100644
index 4f0ea0450..000000000
--- a/app/javascript/mastodon/features/ui/index.js
+++ /dev/null
@@ -1,589 +0,0 @@
-import classNames from 'classnames';
-import React from 'react';
-import { HotKeys } from 'react-hotkeys';
-import { defineMessages, injectIntl } from 'react-intl';
-import { connect } from 'react-redux';
-import { Redirect, Route, withRouter } from 'react-router-dom';
-import PropTypes from 'prop-types';
-import NotificationsContainer from './containers/notifications_container';
-import LoadingBarContainer from './containers/loading_bar_container';
-import ModalContainer from './containers/modal_container';
-import { layoutFromWindow } from 'mastodon/is_mobile';
-import { debounce } from 'lodash';
-import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
-import { expandHomeTimeline } from '../../actions/timelines';
-import { expandNotifications } from '../../actions/notifications';
-import { fetchServer } from '../../actions/server';
-import { clearHeight } from '../../actions/height_cache';
-import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
-import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
-import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
-import BundleColumnError from './components/bundle_column_error';
-import UploadArea from './components/upload_area';
-import ColumnsAreaContainer from './containers/columns_area_container';
-import PictureInPicture from 'mastodon/features/picture_in_picture';
-import {
- Compose,
- Status,
- GettingStarted,
- KeyboardShortcuts,
- PublicTimeline,
- CommunityTimeline,
- AccountTimeline,
- AccountGallery,
- HomeTimeline,
- Followers,
- Following,
- Reblogs,
- Favourites,
- DirectTimeline,
- HashtagTimeline,
- Notifications,
- FollowRequests,
- FavouritedStatuses,
- BookmarkedStatuses,
- FollowedTags,
- ListTimeline,
- Blocks,
- DomainBlocks,
- Mutes,
- PinnedStatuses,
- Lists,
- Directory,
- Explore,
- FollowRecommendations,
- About,
- PrivacyPolicy,
-} from './util/async-components';
-import initialState, { me, owner, singleUserMode, showTrends, trendsAsLanding } from '../../initial_state';
-import { closeOnboarding, INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
-import Header from './components/header';
-
-// Dummy import, to make sure that ends up in the application bundle.
-// Without this it ends up in ~8 very commonly used bundles.
-import '../../components/status';
-
-const messages = defineMessages({
- beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' },
-});
-
-const mapStateToProps = state => ({
- layout: state.getIn(['meta', 'layout']),
- isComposing: state.getIn(['compose', 'is_composing']),
- hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0,
- hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0,
- canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4,
- dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null,
- firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION,
- username: state.getIn(['accounts', me, 'username']),
-});
-
-const keyMap = {
- help: '?',
- new: 'n',
- search: 's',
- forceNew: 'option+n',
- toggleComposeSpoilers: 'option+x',
- focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
- reply: 'r',
- favourite: 'f',
- boost: 'b',
- mention: 'm',
- open: ['enter', 'o'],
- openProfile: 'p',
- moveDown: ['down', 'j'],
- moveUp: ['up', 'k'],
- back: 'backspace',
- goToHome: 'g h',
- goToNotifications: 'g n',
- goToLocal: 'g l',
- goToFederated: 'g t',
- goToDirect: 'g d',
- goToStart: 'g s',
- goToFavourites: 'g f',
- goToPinned: 'g p',
- goToProfile: 'g u',
- goToBlocked: 'g b',
- goToMuted: 'g m',
- goToRequests: 'g r',
- toggleHidden: 'x',
- toggleSensitive: 'h',
- openMedia: 'e',
-};
-
-class SwitchingColumnsArea extends React.PureComponent {
-
- static contextTypes = {
- identity: PropTypes.object,
- };
-
- static propTypes = {
- children: PropTypes.node,
- location: PropTypes.object,
- mobile: PropTypes.bool,
- };
-
- componentWillMount () {
- if (this.props.mobile) {
- document.body.classList.toggle('layout-single-column', true);
- document.body.classList.toggle('layout-multiple-columns', false);
- } else {
- document.body.classList.toggle('layout-single-column', false);
- document.body.classList.toggle('layout-multiple-columns', true);
- }
- }
-
- componentDidUpdate (prevProps) {
- if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) {
- this.node.handleChildrenContentChange();
- }
-
- if (prevProps.mobile !== this.props.mobile) {
- document.body.classList.toggle('layout-single-column', this.props.mobile);
- document.body.classList.toggle('layout-multiple-columns', !this.props.mobile);
- }
- }
-
- setRef = c => {
- if (c) {
- this.node = c;
- }
- };
-
- render () {
- const { children, mobile } = this.props;
- const { signedIn } = this.context.identity;
-
- let redirect;
-
- if (signedIn) {
- if (mobile) {
- redirect = ;
- } else {
- redirect = ;
- }
- } else if (singleUserMode && owner && initialState?.accounts[owner]) {
- redirect = ;
- } else if (showTrends && trendsAsLanding) {
- redirect = ;
- } else {
- redirect = ;
- }
-
- return (
-
-
- {redirect}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Legacy routes, cannot be easily factored with other routes because they share a param name */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-
-}
-
-export default @connect(mapStateToProps)
-@injectIntl
-@withRouter
-class UI extends React.PureComponent {
-
- static contextTypes = {
- router: PropTypes.object.isRequired,
- identity: PropTypes.object.isRequired,
- };
-
- static propTypes = {
- dispatch: PropTypes.func.isRequired,
- children: PropTypes.node,
- isComposing: PropTypes.bool,
- hasComposingText: PropTypes.bool,
- hasMediaAttachments: PropTypes.bool,
- canUploadMore: PropTypes.bool,
- location: PropTypes.object,
- intl: PropTypes.object.isRequired,
- dropdownMenuIsOpen: PropTypes.bool,
- layout: PropTypes.string.isRequired,
- firstLaunch: PropTypes.bool,
- username: PropTypes.string,
- };
-
- state = {
- draggingOver: false,
- };
-
- handleBeforeUnload = e => {
- const { intl, dispatch, isComposing, hasComposingText, hasMediaAttachments } = this.props;
-
- dispatch(synchronouslySubmitMarkers());
-
- if (isComposing && (hasComposingText || hasMediaAttachments)) {
- e.preventDefault();
- // Setting returnValue to any string causes confirmation dialog.
- // Many browsers no longer display this text to users,
- // but we set user-friendly message for other browsers, e.g. Edge.
- e.returnValue = intl.formatMessage(messages.beforeUnload);
- }
- };
-
- handleWindowFocus = () => {
- this.props.dispatch(focusApp());
- this.props.dispatch(submitMarkers({ immediate: true }));
- };
-
- handleWindowBlur = () => {
- this.props.dispatch(unfocusApp());
- };
-
- handleDragEnter = (e) => {
- e.preventDefault();
-
- if (!this.dragTargets) {
- this.dragTargets = [];
- }
-
- if (this.dragTargets.indexOf(e.target) === -1) {
- this.dragTargets.push(e.target);
- }
-
- if (e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files') && this.props.canUploadMore && this.context.identity.signedIn) {
- this.setState({ draggingOver: true });
- }
- };
-
- handleDragOver = (e) => {
- if (this.dataTransferIsText(e.dataTransfer)) return false;
-
- e.preventDefault();
- e.stopPropagation();
-
- try {
- e.dataTransfer.dropEffect = 'copy';
- } catch (err) {
-
- }
-
- return false;
- };
-
- handleDrop = (e) => {
- if (this.dataTransferIsText(e.dataTransfer)) return;
-
- e.preventDefault();
-
- this.setState({ draggingOver: false });
- this.dragTargets = [];
-
- if (e.dataTransfer && e.dataTransfer.files.length >= 1 && this.props.canUploadMore && this.context.identity.signedIn) {
- this.props.dispatch(uploadCompose(e.dataTransfer.files));
- }
- };
-
- handleDragLeave = (e) => {
- e.preventDefault();
- e.stopPropagation();
-
- this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el));
-
- if (this.dragTargets.length > 0) {
- return;
- }
-
- this.setState({ draggingOver: false });
- };
-
- dataTransferIsText = (dataTransfer) => {
- return (dataTransfer && Array.from(dataTransfer.types).filter((type) => type === 'text/plain').length === 1);
- };
-
- closeUploadModal = () => {
- this.setState({ draggingOver: false });
- };
-
- handleServiceWorkerPostMessage = ({ data }) => {
- if (data.type === 'navigate') {
- this.context.router.history.push(data.path);
- } else {
- console.warn('Unknown message type:', data.type);
- }
- };
-
- handleLayoutChange = debounce(() => {
- this.props.dispatch(clearHeight()); // The cached heights are no longer accurate, invalidate
- }, 500, {
- trailing: true,
- });
-
- handleResize = () => {
- const layout = layoutFromWindow();
-
- if (layout !== this.props.layout) {
- this.handleLayoutChange.cancel();
- this.props.dispatch(changeLayout(layout));
- } else {
- this.handleLayoutChange();
- }
- };
-
- componentDidMount () {
- const { signedIn } = this.context.identity;
-
- window.addEventListener('focus', this.handleWindowFocus, false);
- window.addEventListener('blur', this.handleWindowBlur, false);
- window.addEventListener('beforeunload', this.handleBeforeUnload, false);
- window.addEventListener('resize', this.handleResize, { passive: true });
-
- document.addEventListener('dragenter', this.handleDragEnter, false);
- document.addEventListener('dragover', this.handleDragOver, false);
- document.addEventListener('drop', this.handleDrop, false);
- document.addEventListener('dragleave', this.handleDragLeave, false);
- document.addEventListener('dragend', this.handleDragEnd, false);
-
- if ('serviceWorker' in navigator) {
- navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
- }
-
- // On first launch, redirect to the follow recommendations page
- if (signedIn && this.props.firstLaunch) {
- this.context.router.history.replace('/start');
- this.props.dispatch(closeOnboarding());
- }
-
- if (signedIn) {
- this.props.dispatch(fetchMarkers());
- this.props.dispatch(expandHomeTimeline());
- this.props.dispatch(expandNotifications());
-
- setTimeout(() => this.props.dispatch(fetchServer()), 3000);
- }
-
- this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
- return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
- };
- }
-
- componentWillUnmount () {
- window.removeEventListener('focus', this.handleWindowFocus);
- window.removeEventListener('blur', this.handleWindowBlur);
- window.removeEventListener('beforeunload', this.handleBeforeUnload);
- window.removeEventListener('resize', this.handleResize);
-
- document.removeEventListener('dragenter', this.handleDragEnter);
- document.removeEventListener('dragover', this.handleDragOver);
- document.removeEventListener('drop', this.handleDrop);
- document.removeEventListener('dragleave', this.handleDragLeave);
- document.removeEventListener('dragend', this.handleDragEnd);
- }
-
- setRef = c => {
- this.node = c;
- };
-
- handleHotkeyNew = e => {
- e.preventDefault();
-
- const element = this.node.querySelector('.compose-form__autosuggest-wrapper textarea');
-
- if (element) {
- element.focus();
- }
- };
-
- handleHotkeySearch = e => {
- e.preventDefault();
-
- const element = this.node.querySelector('.search__input');
-
- if (element) {
- element.focus();
- }
- };
-
- handleHotkeyForceNew = e => {
- this.handleHotkeyNew(e);
- this.props.dispatch(resetCompose());
- };
-
- handleHotkeyToggleComposeSpoilers = e => {
- e.preventDefault();
- this.props.dispatch(changeComposeSpoilerness());
- };
-
- handleHotkeyFocusColumn = e => {
- const index = (e.key * 1) + 1; // First child is drawer, skip that
- const column = this.node.querySelector(`.column:nth-child(${index})`);
- if (!column) return;
- const container = column.querySelector('.scrollable');
-
- if (container) {
- const status = container.querySelector('.focusable');
-
- if (status) {
- if (container.scrollTop > status.offsetTop) {
- status.scrollIntoView(true);
- }
- status.focus();
- }
- }
- };
-
- handleHotkeyBack = () => {
- if (window.history && window.history.length === 1) {
- this.context.router.history.push('/');
- } else {
- this.context.router.history.goBack();
- }
- };
-
- setHotkeysRef = c => {
- this.hotkeys = c;
- };
-
- handleHotkeyToggleHelp = () => {
- if (this.props.location.pathname === '/keyboard-shortcuts') {
- this.context.router.history.goBack();
- } else {
- this.context.router.history.push('/keyboard-shortcuts');
- }
- };
-
- handleHotkeyGoToHome = () => {
- this.context.router.history.push('/home');
- };
-
- handleHotkeyGoToNotifications = () => {
- this.context.router.history.push('/notifications');
- };
-
- handleHotkeyGoToLocal = () => {
- this.context.router.history.push('/public/local');
- };
-
- handleHotkeyGoToFederated = () => {
- this.context.router.history.push('/public');
- };
-
- handleHotkeyGoToDirect = () => {
- this.context.router.history.push('/conversations');
- };
-
- handleHotkeyGoToStart = () => {
- this.context.router.history.push('/getting-started');
- };
-
- handleHotkeyGoToFavourites = () => {
- this.context.router.history.push('/favourites');
- };
-
- handleHotkeyGoToPinned = () => {
- this.context.router.history.push('/pinned');
- };
-
- handleHotkeyGoToProfile = () => {
- this.context.router.history.push(`/@${this.props.username}`);
- };
-
- handleHotkeyGoToBlocked = () => {
- this.context.router.history.push('/blocks');
- };
-
- handleHotkeyGoToMuted = () => {
- this.context.router.history.push('/mutes');
- };
-
- handleHotkeyGoToRequests = () => {
- this.context.router.history.push('/follow_requests');
- };
-
- render () {
- const { draggingOver } = this.state;
- const { children, isComposing, location, dropdownMenuIsOpen, layout } = this.props;
-
- const handlers = {
- help: this.handleHotkeyToggleHelp,
- new: this.handleHotkeyNew,
- search: this.handleHotkeySearch,
- forceNew: this.handleHotkeyForceNew,
- toggleComposeSpoilers: this.handleHotkeyToggleComposeSpoilers,
- focusColumn: this.handleHotkeyFocusColumn,
- back: this.handleHotkeyBack,
- goToHome: this.handleHotkeyGoToHome,
- goToNotifications: this.handleHotkeyGoToNotifications,
- goToLocal: this.handleHotkeyGoToLocal,
- goToFederated: this.handleHotkeyGoToFederated,
- goToDirect: this.handleHotkeyGoToDirect,
- goToStart: this.handleHotkeyGoToStart,
- goToFavourites: this.handleHotkeyGoToFavourites,
- goToPinned: this.handleHotkeyGoToPinned,
- goToProfile: this.handleHotkeyGoToProfile,
- goToBlocked: this.handleHotkeyGoToBlocked,
- goToMuted: this.handleHotkeyGoToMuted,
- goToRequests: this.handleHotkeyGoToRequests,
- };
-
- return (
-
-
-
-
-
- {children}
-
-
- {layout !== 'mobile' &&
}
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx
new file mode 100644
index 000000000..4f0ea0450
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/index.jsx
@@ -0,0 +1,589 @@
+import classNames from 'classnames';
+import React from 'react';
+import { HotKeys } from 'react-hotkeys';
+import { defineMessages, injectIntl } from 'react-intl';
+import { connect } from 'react-redux';
+import { Redirect, Route, withRouter } from 'react-router-dom';
+import PropTypes from 'prop-types';
+import NotificationsContainer from './containers/notifications_container';
+import LoadingBarContainer from './containers/loading_bar_container';
+import ModalContainer from './containers/modal_container';
+import { layoutFromWindow } from 'mastodon/is_mobile';
+import { debounce } from 'lodash';
+import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
+import { expandHomeTimeline } from '../../actions/timelines';
+import { expandNotifications } from '../../actions/notifications';
+import { fetchServer } from '../../actions/server';
+import { clearHeight } from '../../actions/height_cache';
+import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
+import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
+import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
+import BundleColumnError from './components/bundle_column_error';
+import UploadArea from './components/upload_area';
+import ColumnsAreaContainer from './containers/columns_area_container';
+import PictureInPicture from 'mastodon/features/picture_in_picture';
+import {
+ Compose,
+ Status,
+ GettingStarted,
+ KeyboardShortcuts,
+ PublicTimeline,
+ CommunityTimeline,
+ AccountTimeline,
+ AccountGallery,
+ HomeTimeline,
+ Followers,
+ Following,
+ Reblogs,
+ Favourites,
+ DirectTimeline,
+ HashtagTimeline,
+ Notifications,
+ FollowRequests,
+ FavouritedStatuses,
+ BookmarkedStatuses,
+ FollowedTags,
+ ListTimeline,
+ Blocks,
+ DomainBlocks,
+ Mutes,
+ PinnedStatuses,
+ Lists,
+ Directory,
+ Explore,
+ FollowRecommendations,
+ About,
+ PrivacyPolicy,
+} from './util/async-components';
+import initialState, { me, owner, singleUserMode, showTrends, trendsAsLanding } from '../../initial_state';
+import { closeOnboarding, INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
+import Header from './components/header';
+
+// Dummy import, to make sure that ends up in the application bundle.
+// Without this it ends up in ~8 very commonly used bundles.
+import '../../components/status';
+
+const messages = defineMessages({
+ beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' },
+});
+
+const mapStateToProps = state => ({
+ layout: state.getIn(['meta', 'layout']),
+ isComposing: state.getIn(['compose', 'is_composing']),
+ hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0,
+ hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0,
+ canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4,
+ dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null,
+ firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION,
+ username: state.getIn(['accounts', me, 'username']),
+});
+
+const keyMap = {
+ help: '?',
+ new: 'n',
+ search: 's',
+ forceNew: 'option+n',
+ toggleComposeSpoilers: 'option+x',
+ focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
+ reply: 'r',
+ favourite: 'f',
+ boost: 'b',
+ mention: 'm',
+ open: ['enter', 'o'],
+ openProfile: 'p',
+ moveDown: ['down', 'j'],
+ moveUp: ['up', 'k'],
+ back: 'backspace',
+ goToHome: 'g h',
+ goToNotifications: 'g n',
+ goToLocal: 'g l',
+ goToFederated: 'g t',
+ goToDirect: 'g d',
+ goToStart: 'g s',
+ goToFavourites: 'g f',
+ goToPinned: 'g p',
+ goToProfile: 'g u',
+ goToBlocked: 'g b',
+ goToMuted: 'g m',
+ goToRequests: 'g r',
+ toggleHidden: 'x',
+ toggleSensitive: 'h',
+ openMedia: 'e',
+};
+
+class SwitchingColumnsArea extends React.PureComponent {
+
+ static contextTypes = {
+ identity: PropTypes.object,
+ };
+
+ static propTypes = {
+ children: PropTypes.node,
+ location: PropTypes.object,
+ mobile: PropTypes.bool,
+ };
+
+ componentWillMount () {
+ if (this.props.mobile) {
+ document.body.classList.toggle('layout-single-column', true);
+ document.body.classList.toggle('layout-multiple-columns', false);
+ } else {
+ document.body.classList.toggle('layout-single-column', false);
+ document.body.classList.toggle('layout-multiple-columns', true);
+ }
+ }
+
+ componentDidUpdate (prevProps) {
+ if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) {
+ this.node.handleChildrenContentChange();
+ }
+
+ if (prevProps.mobile !== this.props.mobile) {
+ document.body.classList.toggle('layout-single-column', this.props.mobile);
+ document.body.classList.toggle('layout-multiple-columns', !this.props.mobile);
+ }
+ }
+
+ setRef = c => {
+ if (c) {
+ this.node = c;
+ }
+ };
+
+ render () {
+ const { children, mobile } = this.props;
+ const { signedIn } = this.context.identity;
+
+ let redirect;
+
+ if (signedIn) {
+ if (mobile) {
+ redirect = ;
+ } else {
+ redirect = ;
+ }
+ } else if (singleUserMode && owner && initialState?.accounts[owner]) {
+ redirect = ;
+ } else if (showTrends && trendsAsLanding) {
+ redirect = ;
+ } else {
+ redirect = ;
+ }
+
+ return (
+
+
+ {redirect}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Legacy routes, cannot be easily factored with other routes because they share a param name */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
+
+export default @connect(mapStateToProps)
+@injectIntl
+@withRouter
+class UI extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object.isRequired,
+ identity: PropTypes.object.isRequired,
+ };
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ children: PropTypes.node,
+ isComposing: PropTypes.bool,
+ hasComposingText: PropTypes.bool,
+ hasMediaAttachments: PropTypes.bool,
+ canUploadMore: PropTypes.bool,
+ location: PropTypes.object,
+ intl: PropTypes.object.isRequired,
+ dropdownMenuIsOpen: PropTypes.bool,
+ layout: PropTypes.string.isRequired,
+ firstLaunch: PropTypes.bool,
+ username: PropTypes.string,
+ };
+
+ state = {
+ draggingOver: false,
+ };
+
+ handleBeforeUnload = e => {
+ const { intl, dispatch, isComposing, hasComposingText, hasMediaAttachments } = this.props;
+
+ dispatch(synchronouslySubmitMarkers());
+
+ if (isComposing && (hasComposingText || hasMediaAttachments)) {
+ e.preventDefault();
+ // Setting returnValue to any string causes confirmation dialog.
+ // Many browsers no longer display this text to users,
+ // but we set user-friendly message for other browsers, e.g. Edge.
+ e.returnValue = intl.formatMessage(messages.beforeUnload);
+ }
+ };
+
+ handleWindowFocus = () => {
+ this.props.dispatch(focusApp());
+ this.props.dispatch(submitMarkers({ immediate: true }));
+ };
+
+ handleWindowBlur = () => {
+ this.props.dispatch(unfocusApp());
+ };
+
+ handleDragEnter = (e) => {
+ e.preventDefault();
+
+ if (!this.dragTargets) {
+ this.dragTargets = [];
+ }
+
+ if (this.dragTargets.indexOf(e.target) === -1) {
+ this.dragTargets.push(e.target);
+ }
+
+ if (e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files') && this.props.canUploadMore && this.context.identity.signedIn) {
+ this.setState({ draggingOver: true });
+ }
+ };
+
+ handleDragOver = (e) => {
+ if (this.dataTransferIsText(e.dataTransfer)) return false;
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ try {
+ e.dataTransfer.dropEffect = 'copy';
+ } catch (err) {
+
+ }
+
+ return false;
+ };
+
+ handleDrop = (e) => {
+ if (this.dataTransferIsText(e.dataTransfer)) return;
+
+ e.preventDefault();
+
+ this.setState({ draggingOver: false });
+ this.dragTargets = [];
+
+ if (e.dataTransfer && e.dataTransfer.files.length >= 1 && this.props.canUploadMore && this.context.identity.signedIn) {
+ this.props.dispatch(uploadCompose(e.dataTransfer.files));
+ }
+ };
+
+ handleDragLeave = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el));
+
+ if (this.dragTargets.length > 0) {
+ return;
+ }
+
+ this.setState({ draggingOver: false });
+ };
+
+ dataTransferIsText = (dataTransfer) => {
+ return (dataTransfer && Array.from(dataTransfer.types).filter((type) => type === 'text/plain').length === 1);
+ };
+
+ closeUploadModal = () => {
+ this.setState({ draggingOver: false });
+ };
+
+ handleServiceWorkerPostMessage = ({ data }) => {
+ if (data.type === 'navigate') {
+ this.context.router.history.push(data.path);
+ } else {
+ console.warn('Unknown message type:', data.type);
+ }
+ };
+
+ handleLayoutChange = debounce(() => {
+ this.props.dispatch(clearHeight()); // The cached heights are no longer accurate, invalidate
+ }, 500, {
+ trailing: true,
+ });
+
+ handleResize = () => {
+ const layout = layoutFromWindow();
+
+ if (layout !== this.props.layout) {
+ this.handleLayoutChange.cancel();
+ this.props.dispatch(changeLayout(layout));
+ } else {
+ this.handleLayoutChange();
+ }
+ };
+
+ componentDidMount () {
+ const { signedIn } = this.context.identity;
+
+ window.addEventListener('focus', this.handleWindowFocus, false);
+ window.addEventListener('blur', this.handleWindowBlur, false);
+ window.addEventListener('beforeunload', this.handleBeforeUnload, false);
+ window.addEventListener('resize', this.handleResize, { passive: true });
+
+ document.addEventListener('dragenter', this.handleDragEnter, false);
+ document.addEventListener('dragover', this.handleDragOver, false);
+ document.addEventListener('drop', this.handleDrop, false);
+ document.addEventListener('dragleave', this.handleDragLeave, false);
+ document.addEventListener('dragend', this.handleDragEnd, false);
+
+ if ('serviceWorker' in navigator) {
+ navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
+ }
+
+ // On first launch, redirect to the follow recommendations page
+ if (signedIn && this.props.firstLaunch) {
+ this.context.router.history.replace('/start');
+ this.props.dispatch(closeOnboarding());
+ }
+
+ if (signedIn) {
+ this.props.dispatch(fetchMarkers());
+ this.props.dispatch(expandHomeTimeline());
+ this.props.dispatch(expandNotifications());
+
+ setTimeout(() => this.props.dispatch(fetchServer()), 3000);
+ }
+
+ this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
+ return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
+ };
+ }
+
+ componentWillUnmount () {
+ window.removeEventListener('focus', this.handleWindowFocus);
+ window.removeEventListener('blur', this.handleWindowBlur);
+ window.removeEventListener('beforeunload', this.handleBeforeUnload);
+ window.removeEventListener('resize', this.handleResize);
+
+ document.removeEventListener('dragenter', this.handleDragEnter);
+ document.removeEventListener('dragover', this.handleDragOver);
+ document.removeEventListener('drop', this.handleDrop);
+ document.removeEventListener('dragleave', this.handleDragLeave);
+ document.removeEventListener('dragend', this.handleDragEnd);
+ }
+
+ setRef = c => {
+ this.node = c;
+ };
+
+ handleHotkeyNew = e => {
+ e.preventDefault();
+
+ const element = this.node.querySelector('.compose-form__autosuggest-wrapper textarea');
+
+ if (element) {
+ element.focus();
+ }
+ };
+
+ handleHotkeySearch = e => {
+ e.preventDefault();
+
+ const element = this.node.querySelector('.search__input');
+
+ if (element) {
+ element.focus();
+ }
+ };
+
+ handleHotkeyForceNew = e => {
+ this.handleHotkeyNew(e);
+ this.props.dispatch(resetCompose());
+ };
+
+ handleHotkeyToggleComposeSpoilers = e => {
+ e.preventDefault();
+ this.props.dispatch(changeComposeSpoilerness());
+ };
+
+ handleHotkeyFocusColumn = e => {
+ const index = (e.key * 1) + 1; // First child is drawer, skip that
+ const column = this.node.querySelector(`.column:nth-child(${index})`);
+ if (!column) return;
+ const container = column.querySelector('.scrollable');
+
+ if (container) {
+ const status = container.querySelector('.focusable');
+
+ if (status) {
+ if (container.scrollTop > status.offsetTop) {
+ status.scrollIntoView(true);
+ }
+ status.focus();
+ }
+ }
+ };
+
+ handleHotkeyBack = () => {
+ if (window.history && window.history.length === 1) {
+ this.context.router.history.push('/');
+ } else {
+ this.context.router.history.goBack();
+ }
+ };
+
+ setHotkeysRef = c => {
+ this.hotkeys = c;
+ };
+
+ handleHotkeyToggleHelp = () => {
+ if (this.props.location.pathname === '/keyboard-shortcuts') {
+ this.context.router.history.goBack();
+ } else {
+ this.context.router.history.push('/keyboard-shortcuts');
+ }
+ };
+
+ handleHotkeyGoToHome = () => {
+ this.context.router.history.push('/home');
+ };
+
+ handleHotkeyGoToNotifications = () => {
+ this.context.router.history.push('/notifications');
+ };
+
+ handleHotkeyGoToLocal = () => {
+ this.context.router.history.push('/public/local');
+ };
+
+ handleHotkeyGoToFederated = () => {
+ this.context.router.history.push('/public');
+ };
+
+ handleHotkeyGoToDirect = () => {
+ this.context.router.history.push('/conversations');
+ };
+
+ handleHotkeyGoToStart = () => {
+ this.context.router.history.push('/getting-started');
+ };
+
+ handleHotkeyGoToFavourites = () => {
+ this.context.router.history.push('/favourites');
+ };
+
+ handleHotkeyGoToPinned = () => {
+ this.context.router.history.push('/pinned');
+ };
+
+ handleHotkeyGoToProfile = () => {
+ this.context.router.history.push(`/@${this.props.username}`);
+ };
+
+ handleHotkeyGoToBlocked = () => {
+ this.context.router.history.push('/blocks');
+ };
+
+ handleHotkeyGoToMuted = () => {
+ this.context.router.history.push('/mutes');
+ };
+
+ handleHotkeyGoToRequests = () => {
+ this.context.router.history.push('/follow_requests');
+ };
+
+ render () {
+ const { draggingOver } = this.state;
+ const { children, isComposing, location, dropdownMenuIsOpen, layout } = this.props;
+
+ const handlers = {
+ help: this.handleHotkeyToggleHelp,
+ new: this.handleHotkeyNew,
+ search: this.handleHotkeySearch,
+ forceNew: this.handleHotkeyForceNew,
+ toggleComposeSpoilers: this.handleHotkeyToggleComposeSpoilers,
+ focusColumn: this.handleHotkeyFocusColumn,
+ back: this.handleHotkeyBack,
+ goToHome: this.handleHotkeyGoToHome,
+ goToNotifications: this.handleHotkeyGoToNotifications,
+ goToLocal: this.handleHotkeyGoToLocal,
+ goToFederated: this.handleHotkeyGoToFederated,
+ goToDirect: this.handleHotkeyGoToDirect,
+ goToStart: this.handleHotkeyGoToStart,
+ goToFavourites: this.handleHotkeyGoToFavourites,
+ goToPinned: this.handleHotkeyGoToPinned,
+ goToProfile: this.handleHotkeyGoToProfile,
+ goToBlocked: this.handleHotkeyGoToBlocked,
+ goToMuted: this.handleHotkeyGoToMuted,
+ goToRequests: this.handleHotkeyGoToRequests,
+ };
+
+ return (
+
+
+
+
+
+ {children}
+
+
+ {layout !== 'mobile' &&
}
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/util/react_router_helpers.js b/app/javascript/mastodon/features/ui/util/react_router_helpers.js
deleted file mode 100644
index 21b352878..000000000
--- a/app/javascript/mastodon/features/ui/util/react_router_helpers.js
+++ /dev/null
@@ -1,101 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { Switch, Route } from 'react-router-dom';
-import StackTrace from 'stacktrace-js';
-import ColumnLoading from '../components/column_loading';
-import BundleColumnError from '../components/bundle_column_error';
-import BundleContainer from '../containers/bundle_container';
-
-// Small wrapper to pass multiColumn to the route components
-export class WrappedSwitch extends React.PureComponent {
-
- render () {
- const { multiColumn, children } = this.props;
-
- return (
-
- {React.Children.map(children, child => React.cloneElement(child, { multiColumn }))}
-
- );
- }
-
-}
-
-WrappedSwitch.propTypes = {
- multiColumn: PropTypes.bool,
- children: PropTypes.node,
-};
-
-// Small Wrapper to extract the params from the route and pass
-// them to the rendered component, together with the content to
-// be rendered inside (the children)
-export class WrappedRoute extends React.Component {
-
- static propTypes = {
- component: PropTypes.func.isRequired,
- content: PropTypes.node,
- multiColumn: PropTypes.bool,
- componentParams: PropTypes.object,
- };
-
- static defaultProps = {
- componentParams: {},
- };
-
- static getDerivedStateFromError () {
- return {
- hasError: true,
- };
- }
-
- state = {
- hasError: false,
- stacktrace: '',
- };
-
- componentDidCatch (error) {
- StackTrace.fromError(error).then(stackframes => {
- this.setState({ stacktrace: error.toString() + '\n' + stackframes.map(frame => frame.toString()).join('\n') });
- }).catch(err => {
- console.error(err);
- });
- }
-
- renderComponent = ({ match }) => {
- const { component, content, multiColumn, componentParams } = this.props;
- const { hasError, stacktrace } = this.state;
-
- if (hasError) {
- return (
-
- );
- }
-
- return (
-
- {Component => {content} }
-
- );
- };
-
- renderLoading = () => {
- const { multiColumn } = this.props;
-
- return ;
- };
-
- renderError = (props) => {
- return ;
- };
-
- render () {
- const { component: Component, content, ...rest } = this.props;
-
- return ;
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/util/react_router_helpers.jsx b/app/javascript/mastodon/features/ui/util/react_router_helpers.jsx
new file mode 100644
index 000000000..21b352878
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/util/react_router_helpers.jsx
@@ -0,0 +1,101 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Switch, Route } from 'react-router-dom';
+import StackTrace from 'stacktrace-js';
+import ColumnLoading from '../components/column_loading';
+import BundleColumnError from '../components/bundle_column_error';
+import BundleContainer from '../containers/bundle_container';
+
+// Small wrapper to pass multiColumn to the route components
+export class WrappedSwitch extends React.PureComponent {
+
+ render () {
+ const { multiColumn, children } = this.props;
+
+ return (
+
+ {React.Children.map(children, child => React.cloneElement(child, { multiColumn }))}
+
+ );
+ }
+
+}
+
+WrappedSwitch.propTypes = {
+ multiColumn: PropTypes.bool,
+ children: PropTypes.node,
+};
+
+// Small Wrapper to extract the params from the route and pass
+// them to the rendered component, together with the content to
+// be rendered inside (the children)
+export class WrappedRoute extends React.Component {
+
+ static propTypes = {
+ component: PropTypes.func.isRequired,
+ content: PropTypes.node,
+ multiColumn: PropTypes.bool,
+ componentParams: PropTypes.object,
+ };
+
+ static defaultProps = {
+ componentParams: {},
+ };
+
+ static getDerivedStateFromError () {
+ return {
+ hasError: true,
+ };
+ }
+
+ state = {
+ hasError: false,
+ stacktrace: '',
+ };
+
+ componentDidCatch (error) {
+ StackTrace.fromError(error).then(stackframes => {
+ this.setState({ stacktrace: error.toString() + '\n' + stackframes.map(frame => frame.toString()).join('\n') });
+ }).catch(err => {
+ console.error(err);
+ });
+ }
+
+ renderComponent = ({ match }) => {
+ const { component, content, multiColumn, componentParams } = this.props;
+ const { hasError, stacktrace } = this.state;
+
+ if (hasError) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {Component => {content} }
+
+ );
+ };
+
+ renderLoading = () => {
+ const { multiColumn } = this.props;
+
+ return ;
+ };
+
+ renderError = (props) => {
+ return ;
+ };
+
+ render () {
+ const { component: Component, content, ...rest } = this.props;
+
+ return ;
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/util/reduced_motion.js b/app/javascript/mastodon/features/ui/util/reduced_motion.js
deleted file mode 100644
index 1123b80ed..000000000
--- a/app/javascript/mastodon/features/ui/util/reduced_motion.js
+++ /dev/null
@@ -1,44 +0,0 @@
-// Like react-motion's Motion, but reduces all animations to cross-fades
-// for the benefit of users with motion sickness.
-import React from 'react';
-import Motion from 'react-motion/lib/Motion';
-import PropTypes from 'prop-types';
-
-const stylesToKeep = ['opacity', 'backgroundOpacity'];
-
-const extractValue = (value) => {
- // This is either an object with a "val" property or it's a number
- return (typeof value === 'object' && value && 'val' in value) ? value.val : value;
-};
-
-class ReducedMotion extends React.Component {
-
- static propTypes = {
- defaultStyle: PropTypes.object,
- style: PropTypes.object,
- children: PropTypes.func,
- };
-
- render() {
-
- const { style, defaultStyle, children } = this.props;
-
- Object.keys(style).forEach(key => {
- if (stylesToKeep.includes(key)) {
- return;
- }
- // If it's setting an x or height or scale or some other value, we need
- // to preserve the end-state value without actually animating it
- style[key] = defaultStyle[key] = extractValue(style[key]);
- });
-
- return (
-
- {children}
-
- );
- }
-
-}
-
-export default ReducedMotion;
diff --git a/app/javascript/mastodon/features/ui/util/reduced_motion.jsx b/app/javascript/mastodon/features/ui/util/reduced_motion.jsx
new file mode 100644
index 000000000..1123b80ed
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/util/reduced_motion.jsx
@@ -0,0 +1,44 @@
+// Like react-motion's Motion, but reduces all animations to cross-fades
+// for the benefit of users with motion sickness.
+import React from 'react';
+import Motion from 'react-motion/lib/Motion';
+import PropTypes from 'prop-types';
+
+const stylesToKeep = ['opacity', 'backgroundOpacity'];
+
+const extractValue = (value) => {
+ // This is either an object with a "val" property or it's a number
+ return (typeof value === 'object' && value && 'val' in value) ? value.val : value;
+};
+
+class ReducedMotion extends React.Component {
+
+ static propTypes = {
+ defaultStyle: PropTypes.object,
+ style: PropTypes.object,
+ children: PropTypes.func,
+ };
+
+ render() {
+
+ const { style, defaultStyle, children } = this.props;
+
+ Object.keys(style).forEach(key => {
+ if (stylesToKeep.includes(key)) {
+ return;
+ }
+ // If it's setting an x or height or scale or some other value, we need
+ // to preserve the end-state value without actually animating it
+ style[key] = defaultStyle[key] = extractValue(style[key]);
+ });
+
+ return (
+
+ {children}
+
+ );
+ }
+
+}
+
+export default ReducedMotion;
diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js
deleted file mode 100644
index 8d63394aa..000000000
--- a/app/javascript/mastodon/features/video/index.js
+++ /dev/null
@@ -1,655 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import { is } from 'immutable';
-import { throttle, debounce } from 'lodash';
-import classNames from 'classnames';
-import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
-import { displayMedia, useBlurhash } from '../../initial_state';
-import Icon from 'mastodon/components/icon';
-import Blurhash from 'mastodon/components/blurhash';
-
-const messages = defineMessages({
- play: { id: 'video.play', defaultMessage: 'Play' },
- pause: { id: 'video.pause', defaultMessage: 'Pause' },
- mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
- unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
- hide: { id: 'video.hide', defaultMessage: 'Hide video' },
- expand: { id: 'video.expand', defaultMessage: 'Expand video' },
- close: { id: 'video.close', defaultMessage: 'Close video' },
- fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' },
- exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
-});
-
-export const formatTime = secondsNum => {
- let hours = Math.floor(secondsNum / 3600);
- let minutes = Math.floor((secondsNum - (hours * 3600)) / 60);
- let seconds = secondsNum - (hours * 3600) - (minutes * 60);
-
- if (hours < 10) hours = '0' + hours;
- if (minutes < 10) minutes = '0' + minutes;
- if (seconds < 10) seconds = '0' + seconds;
-
- return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}`;
-};
-
-export const findElementPosition = el => {
- let box;
-
- if (el.getBoundingClientRect && el.parentNode) {
- box = el.getBoundingClientRect();
- }
-
- if (!box) {
- return {
- left: 0,
- top: 0,
- };
- }
-
- const docEl = document.documentElement;
- const body = document.body;
-
- const clientLeft = docEl.clientLeft || body.clientLeft || 0;
- const scrollLeft = window.pageXOffset || body.scrollLeft;
- const left = (box.left + scrollLeft) - clientLeft;
-
- const clientTop = docEl.clientTop || body.clientTop || 0;
- const scrollTop = window.pageYOffset || body.scrollTop;
- const top = (box.top + scrollTop) - clientTop;
-
- return {
- left: Math.round(left),
- top: Math.round(top),
- };
-};
-
-export const getPointerPosition = (el, event) => {
- const position = {};
- const box = findElementPosition(el);
- const boxW = el.offsetWidth;
- const boxH = el.offsetHeight;
- const boxY = box.top;
- const boxX = box.left;
-
- let pageY = event.pageY;
- let pageX = event.pageX;
-
- if (event.changedTouches) {
- pageX = event.changedTouches[0].pageX;
- pageY = event.changedTouches[0].pageY;
- }
-
- position.y = Math.max(0, Math.min(1, (pageY - boxY) / boxH));
- position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
-
- return position;
-};
-
-export const fileNameFromURL = str => {
- const url = new URL(str);
- const pathname = url.pathname;
- const index = pathname.lastIndexOf('/');
-
- return pathname.slice(index + 1);
-};
-
-export default @injectIntl
-class Video extends React.PureComponent {
-
- static propTypes = {
- preview: PropTypes.string,
- frameRate: PropTypes.string,
- src: PropTypes.string.isRequired,
- alt: PropTypes.string,
- width: PropTypes.number,
- height: PropTypes.number,
- sensitive: PropTypes.bool,
- currentTime: PropTypes.number,
- onOpenVideo: PropTypes.func,
- onCloseVideo: PropTypes.func,
- detailed: PropTypes.bool,
- inline: PropTypes.bool,
- editable: PropTypes.bool,
- alwaysVisible: PropTypes.bool,
- cacheWidth: PropTypes.func,
- visible: PropTypes.bool,
- onToggleVisibility: PropTypes.func,
- deployPictureInPicture: PropTypes.func,
- intl: PropTypes.object.isRequired,
- blurhash: PropTypes.string,
- autoPlay: PropTypes.bool,
- volume: PropTypes.number,
- muted: PropTypes.bool,
- componentIndex: PropTypes.number,
- autoFocus: PropTypes.bool,
- };
-
- static defaultProps = {
- frameRate: '25',
- };
-
- state = {
- currentTime: 0,
- duration: 0,
- volume: 0.5,
- paused: true,
- dragging: false,
- containerWidth: this.props.width,
- fullscreen: false,
- hovered: false,
- muted: false,
- revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
- };
-
- setPlayerRef = c => {
- this.player = c;
-
- if (this.player) {
- this._setDimensions();
- }
- };
-
- _setDimensions () {
- const width = this.player.offsetWidth;
-
- if (this.props.cacheWidth) {
- this.props.cacheWidth(width);
- }
-
- this.setState({
- containerWidth: width,
- });
- }
-
- setVideoRef = c => {
- this.video = c;
-
- if (this.video) {
- this.setState({ volume: this.video.volume, muted: this.video.muted });
- }
- };
-
- setSeekRef = c => {
- this.seek = c;
- };
-
- setVolumeRef = c => {
- this.volume = c;
- };
-
- handleClickRoot = e => e.stopPropagation();
-
- handlePlay = () => {
- this.setState({ paused: false });
- this._updateTime();
- };
-
- handlePause = () => {
- this.setState({ paused: true });
- };
-
- _updateTime () {
- requestAnimationFrame(() => {
- if (!this.video) return;
-
- this.handleTimeUpdate();
-
- if (!this.state.paused) {
- this._updateTime();
- }
- });
- }
-
- handleTimeUpdate = () => {
- this.setState({
- currentTime: this.video.currentTime,
- duration:this.video.duration,
- });
- };
-
- handleVolumeMouseDown = e => {
- document.addEventListener('mousemove', this.handleMouseVolSlide, true);
- document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
- document.addEventListener('touchmove', this.handleMouseVolSlide, true);
- document.addEventListener('touchend', this.handleVolumeMouseUp, true);
-
- this.handleMouseVolSlide(e);
-
- e.preventDefault();
- e.stopPropagation();
- };
-
- handleVolumeMouseUp = () => {
- document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
- document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
- document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
- document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
- };
-
- handleMouseVolSlide = throttle(e => {
- const { x } = getPointerPosition(this.volume, e);
-
- if(!isNaN(x)) {
- this.setState({ volume: x }, () => {
- this.video.volume = x;
- });
- }
- }, 15);
-
- handleMouseDown = e => {
- document.addEventListener('mousemove', this.handleMouseMove, true);
- document.addEventListener('mouseup', this.handleMouseUp, true);
- document.addEventListener('touchmove', this.handleMouseMove, true);
- document.addEventListener('touchend', this.handleMouseUp, true);
-
- this.setState({ dragging: true });
- this.video.pause();
- this.handleMouseMove(e);
-
- e.preventDefault();
- e.stopPropagation();
- };
-
- handleMouseUp = () => {
- document.removeEventListener('mousemove', this.handleMouseMove, true);
- document.removeEventListener('mouseup', this.handleMouseUp, true);
- document.removeEventListener('touchmove', this.handleMouseMove, true);
- document.removeEventListener('touchend', this.handleMouseUp, true);
-
- this.setState({ dragging: false });
- this.video.play();
- };
-
- handleMouseMove = throttle(e => {
- const { x } = getPointerPosition(this.seek, e);
- const currentTime = this.video.duration * x;
-
- if (!isNaN(currentTime)) {
- this.setState({ currentTime }, () => {
- this.video.currentTime = currentTime;
- });
- }
- }, 15);
-
- seekBy (time) {
- const currentTime = this.video.currentTime + time;
-
- if (!isNaN(currentTime)) {
- this.setState({ currentTime }, () => {
- this.video.currentTime = currentTime;
- });
- }
- }
-
- handleVideoKeyDown = e => {
- // On the video element or the seek bar, we can safely use the space bar
- // for playback control because there are no buttons to press
-
- if (e.key === ' ') {
- e.preventDefault();
- e.stopPropagation();
- this.togglePlay();
- }
- };
-
- handleKeyDown = e => {
- const frameTime = 1 / this.getFrameRate();
-
- switch(e.key) {
- case 'k':
- e.preventDefault();
- e.stopPropagation();
- this.togglePlay();
- break;
- case 'm':
- e.preventDefault();
- e.stopPropagation();
- this.toggleMute();
- break;
- case 'f':
- e.preventDefault();
- e.stopPropagation();
- this.toggleFullscreen();
- break;
- case 'j':
- e.preventDefault();
- e.stopPropagation();
- this.seekBy(-10);
- break;
- case 'l':
- e.preventDefault();
- e.stopPropagation();
- this.seekBy(10);
- break;
- case ',':
- e.preventDefault();
- e.stopPropagation();
- this.seekBy(-frameTime);
- break;
- case '.':
- e.preventDefault();
- e.stopPropagation();
- this.seekBy(frameTime);
- break;
- }
-
- // If we are in fullscreen mode, we don't want any hotkeys
- // interacting with the UI that's not visible
-
- if (this.state.fullscreen) {
- e.preventDefault();
- e.stopPropagation();
-
- if (e.key === 'Escape') {
- exitFullscreen();
- }
- }
- };
-
- togglePlay = () => {
- if (this.state.paused) {
- this.setState({ paused: false }, () => this.video.play());
- } else {
- this.setState({ paused: true }, () => this.video.pause());
- }
- };
-
- toggleFullscreen = () => {
- if (isFullscreen()) {
- exitFullscreen();
- } else {
- requestFullscreen(this.player);
- }
- };
-
- componentDidMount () {
- document.addEventListener('fullscreenchange', this.handleFullscreenChange, true);
- document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
- document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
- document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
-
- window.addEventListener('scroll', this.handleScroll);
- window.addEventListener('resize', this.handleResize, { passive: true });
- }
-
- componentWillUnmount () {
- window.removeEventListener('scroll', this.handleScroll);
- window.removeEventListener('resize', this.handleResize);
-
- document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
- document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
- document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
- document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
-
- if (!this.state.paused && this.video && this.props.deployPictureInPicture) {
- this.props.deployPictureInPicture('video', {
- src: this.props.src,
- currentTime: this.video.currentTime,
- muted: this.video.muted,
- volume: this.video.volume,
- });
- }
- }
-
- componentWillReceiveProps (nextProps) {
- if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
- this.setState({ revealed: nextProps.visible });
- }
- }
-
- componentDidUpdate (prevProps, prevState) {
- if (prevState.revealed && !this.state.revealed && this.video) {
- this.video.pause();
- }
- }
-
- handleResize = debounce(() => {
- if (this.player) {
- this._setDimensions();
- }
- }, 250, {
- trailing: true,
- });
-
- handleScroll = throttle(() => {
- if (!this.video) {
- return;
- }
-
- const { top, height } = this.video.getBoundingClientRect();
- const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
-
- if (!this.state.paused && !inView) {
- this.video.pause();
-
- if (this.props.deployPictureInPicture) {
- this.props.deployPictureInPicture('video', {
- src: this.props.src,
- currentTime: this.video.currentTime,
- muted: this.video.muted,
- volume: this.video.volume,
- });
- }
-
- this.setState({ paused: true });
- }
- }, 150, { trailing: true });
-
- handleFullscreenChange = () => {
- this.setState({ fullscreen: isFullscreen() });
- };
-
- handleMouseEnter = () => {
- this.setState({ hovered: true });
- };
-
- handleMouseLeave = () => {
- this.setState({ hovered: false });
- };
-
- toggleMute = () => {
- const muted = !this.video.muted;
-
- this.setState({ muted }, () => {
- this.video.muted = muted;
- });
- };
-
- toggleReveal = () => {
- if (this.props.onToggleVisibility) {
- this.props.onToggleVisibility();
- } else {
- this.setState({ revealed: !this.state.revealed });
- }
- };
-
- handleLoadedData = () => {
- const { currentTime, volume, muted, autoPlay } = this.props;
-
- if (currentTime) {
- this.video.currentTime = currentTime;
- }
-
- if (volume !== undefined) {
- this.video.volume = volume;
- }
-
- if (muted !== undefined) {
- this.video.muted = muted;
- }
-
- if (autoPlay) {
- this.video.play();
- }
- };
-
- handleProgress = () => {
- const lastTimeRange = this.video.buffered.length - 1;
-
- if (lastTimeRange > -1) {
- this.setState({ buffer: Math.ceil(this.video.buffered.end(lastTimeRange) / this.video.duration * 100) });
- }
- };
-
- handleVolumeChange = () => {
- this.setState({ volume: this.video.volume, muted: this.video.muted });
- };
-
- handleOpenVideo = () => {
- this.video.pause();
-
- this.props.onOpenVideo({
- startTime: this.video.currentTime,
- autoPlay: !this.state.paused,
- defaultVolume: this.state.volume,
- componentIndex: this.props.componentIndex,
- });
- };
-
- handleCloseVideo = () => {
- this.video.pause();
- this.props.onCloseVideo();
- };
-
- getFrameRate () {
- if (this.props.frameRate && isNaN(this.props.frameRate)) {
- // The frame rate is returned as a fraction string so we
- // need to convert it to a number
-
- return this.props.frameRate.split('/').reduce((p, c) => p / c);
- }
-
- return this.props.frameRate;
- }
-
- render () {
- const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, editable, blurhash, autoFocus } = this.props;
- const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
- const progress = Math.min((currentTime / duration) * 100, 100);
- const playerStyle = {};
-
- let { width, height } = this.props;
-
- if (inline && containerWidth) {
- width = containerWidth;
- height = containerWidth / (16/9);
-
- playerStyle.height = height;
- }
-
- let preload;
-
- if (this.props.currentTime || fullscreen || dragging) {
- preload = 'auto';
- } else if (detailed) {
- preload = 'metadata';
- } else {
- preload = 'none';
- }
-
- let warning;
-
- if (sensitive) {
- warning = ;
- } else {
- warning = ;
- }
-
- return (
-
-
-
- {(revealed || editable) &&
}
-
-
-
- {warning}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {(detailed || fullscreen) && (
-
- {formatTime(Math.floor(currentTime))}
- /
- {formatTime(Math.floor(duration))}
-
- )}
-
-
-
- {(!onCloseVideo && !editable && !fullscreen && !this.props.alwaysVisible) && }
- {(!fullscreen && onOpenVideo) && }
- {onCloseVideo && }
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/video/index.jsx b/app/javascript/mastodon/features/video/index.jsx
new file mode 100644
index 000000000..8d63394aa
--- /dev/null
+++ b/app/javascript/mastodon/features/video/index.jsx
@@ -0,0 +1,655 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { is } from 'immutable';
+import { throttle, debounce } from 'lodash';
+import classNames from 'classnames';
+import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
+import { displayMedia, useBlurhash } from '../../initial_state';
+import Icon from 'mastodon/components/icon';
+import Blurhash from 'mastodon/components/blurhash';
+
+const messages = defineMessages({
+ play: { id: 'video.play', defaultMessage: 'Play' },
+ pause: { id: 'video.pause', defaultMessage: 'Pause' },
+ mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
+ unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
+ hide: { id: 'video.hide', defaultMessage: 'Hide video' },
+ expand: { id: 'video.expand', defaultMessage: 'Expand video' },
+ close: { id: 'video.close', defaultMessage: 'Close video' },
+ fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' },
+ exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
+});
+
+export const formatTime = secondsNum => {
+ let hours = Math.floor(secondsNum / 3600);
+ let minutes = Math.floor((secondsNum - (hours * 3600)) / 60);
+ let seconds = secondsNum - (hours * 3600) - (minutes * 60);
+
+ if (hours < 10) hours = '0' + hours;
+ if (minutes < 10) minutes = '0' + minutes;
+ if (seconds < 10) seconds = '0' + seconds;
+
+ return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}`;
+};
+
+export const findElementPosition = el => {
+ let box;
+
+ if (el.getBoundingClientRect && el.parentNode) {
+ box = el.getBoundingClientRect();
+ }
+
+ if (!box) {
+ return {
+ left: 0,
+ top: 0,
+ };
+ }
+
+ const docEl = document.documentElement;
+ const body = document.body;
+
+ const clientLeft = docEl.clientLeft || body.clientLeft || 0;
+ const scrollLeft = window.pageXOffset || body.scrollLeft;
+ const left = (box.left + scrollLeft) - clientLeft;
+
+ const clientTop = docEl.clientTop || body.clientTop || 0;
+ const scrollTop = window.pageYOffset || body.scrollTop;
+ const top = (box.top + scrollTop) - clientTop;
+
+ return {
+ left: Math.round(left),
+ top: Math.round(top),
+ };
+};
+
+export const getPointerPosition = (el, event) => {
+ const position = {};
+ const box = findElementPosition(el);
+ const boxW = el.offsetWidth;
+ const boxH = el.offsetHeight;
+ const boxY = box.top;
+ const boxX = box.left;
+
+ let pageY = event.pageY;
+ let pageX = event.pageX;
+
+ if (event.changedTouches) {
+ pageX = event.changedTouches[0].pageX;
+ pageY = event.changedTouches[0].pageY;
+ }
+
+ position.y = Math.max(0, Math.min(1, (pageY - boxY) / boxH));
+ position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
+
+ return position;
+};
+
+export const fileNameFromURL = str => {
+ const url = new URL(str);
+ const pathname = url.pathname;
+ const index = pathname.lastIndexOf('/');
+
+ return pathname.slice(index + 1);
+};
+
+export default @injectIntl
+class Video extends React.PureComponent {
+
+ static propTypes = {
+ preview: PropTypes.string,
+ frameRate: PropTypes.string,
+ src: PropTypes.string.isRequired,
+ alt: PropTypes.string,
+ width: PropTypes.number,
+ height: PropTypes.number,
+ sensitive: PropTypes.bool,
+ currentTime: PropTypes.number,
+ onOpenVideo: PropTypes.func,
+ onCloseVideo: PropTypes.func,
+ detailed: PropTypes.bool,
+ inline: PropTypes.bool,
+ editable: PropTypes.bool,
+ alwaysVisible: PropTypes.bool,
+ cacheWidth: PropTypes.func,
+ visible: PropTypes.bool,
+ onToggleVisibility: PropTypes.func,
+ deployPictureInPicture: PropTypes.func,
+ intl: PropTypes.object.isRequired,
+ blurhash: PropTypes.string,
+ autoPlay: PropTypes.bool,
+ volume: PropTypes.number,
+ muted: PropTypes.bool,
+ componentIndex: PropTypes.number,
+ autoFocus: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ frameRate: '25',
+ };
+
+ state = {
+ currentTime: 0,
+ duration: 0,
+ volume: 0.5,
+ paused: true,
+ dragging: false,
+ containerWidth: this.props.width,
+ fullscreen: false,
+ hovered: false,
+ muted: false,
+ revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
+ };
+
+ setPlayerRef = c => {
+ this.player = c;
+
+ if (this.player) {
+ this._setDimensions();
+ }
+ };
+
+ _setDimensions () {
+ const width = this.player.offsetWidth;
+
+ if (this.props.cacheWidth) {
+ this.props.cacheWidth(width);
+ }
+
+ this.setState({
+ containerWidth: width,
+ });
+ }
+
+ setVideoRef = c => {
+ this.video = c;
+
+ if (this.video) {
+ this.setState({ volume: this.video.volume, muted: this.video.muted });
+ }
+ };
+
+ setSeekRef = c => {
+ this.seek = c;
+ };
+
+ setVolumeRef = c => {
+ this.volume = c;
+ };
+
+ handleClickRoot = e => e.stopPropagation();
+
+ handlePlay = () => {
+ this.setState({ paused: false });
+ this._updateTime();
+ };
+
+ handlePause = () => {
+ this.setState({ paused: true });
+ };
+
+ _updateTime () {
+ requestAnimationFrame(() => {
+ if (!this.video) return;
+
+ this.handleTimeUpdate();
+
+ if (!this.state.paused) {
+ this._updateTime();
+ }
+ });
+ }
+
+ handleTimeUpdate = () => {
+ this.setState({
+ currentTime: this.video.currentTime,
+ duration:this.video.duration,
+ });
+ };
+
+ handleVolumeMouseDown = e => {
+ document.addEventListener('mousemove', this.handleMouseVolSlide, true);
+ document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
+ document.addEventListener('touchmove', this.handleMouseVolSlide, true);
+ document.addEventListener('touchend', this.handleVolumeMouseUp, true);
+
+ this.handleMouseVolSlide(e);
+
+ e.preventDefault();
+ e.stopPropagation();
+ };
+
+ handleVolumeMouseUp = () => {
+ document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
+ document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
+ document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
+ document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
+ };
+
+ handleMouseVolSlide = throttle(e => {
+ const { x } = getPointerPosition(this.volume, e);
+
+ if(!isNaN(x)) {
+ this.setState({ volume: x }, () => {
+ this.video.volume = x;
+ });
+ }
+ }, 15);
+
+ handleMouseDown = e => {
+ document.addEventListener('mousemove', this.handleMouseMove, true);
+ document.addEventListener('mouseup', this.handleMouseUp, true);
+ document.addEventListener('touchmove', this.handleMouseMove, true);
+ document.addEventListener('touchend', this.handleMouseUp, true);
+
+ this.setState({ dragging: true });
+ this.video.pause();
+ this.handleMouseMove(e);
+
+ e.preventDefault();
+ e.stopPropagation();
+ };
+
+ handleMouseUp = () => {
+ document.removeEventListener('mousemove', this.handleMouseMove, true);
+ document.removeEventListener('mouseup', this.handleMouseUp, true);
+ document.removeEventListener('touchmove', this.handleMouseMove, true);
+ document.removeEventListener('touchend', this.handleMouseUp, true);
+
+ this.setState({ dragging: false });
+ this.video.play();
+ };
+
+ handleMouseMove = throttle(e => {
+ const { x } = getPointerPosition(this.seek, e);
+ const currentTime = this.video.duration * x;
+
+ if (!isNaN(currentTime)) {
+ this.setState({ currentTime }, () => {
+ this.video.currentTime = currentTime;
+ });
+ }
+ }, 15);
+
+ seekBy (time) {
+ const currentTime = this.video.currentTime + time;
+
+ if (!isNaN(currentTime)) {
+ this.setState({ currentTime }, () => {
+ this.video.currentTime = currentTime;
+ });
+ }
+ }
+
+ handleVideoKeyDown = e => {
+ // On the video element or the seek bar, we can safely use the space bar
+ // for playback control because there are no buttons to press
+
+ if (e.key === ' ') {
+ e.preventDefault();
+ e.stopPropagation();
+ this.togglePlay();
+ }
+ };
+
+ handleKeyDown = e => {
+ const frameTime = 1 / this.getFrameRate();
+
+ switch(e.key) {
+ case 'k':
+ e.preventDefault();
+ e.stopPropagation();
+ this.togglePlay();
+ break;
+ case 'm':
+ e.preventDefault();
+ e.stopPropagation();
+ this.toggleMute();
+ break;
+ case 'f':
+ e.preventDefault();
+ e.stopPropagation();
+ this.toggleFullscreen();
+ break;
+ case 'j':
+ e.preventDefault();
+ e.stopPropagation();
+ this.seekBy(-10);
+ break;
+ case 'l':
+ e.preventDefault();
+ e.stopPropagation();
+ this.seekBy(10);
+ break;
+ case ',':
+ e.preventDefault();
+ e.stopPropagation();
+ this.seekBy(-frameTime);
+ break;
+ case '.':
+ e.preventDefault();
+ e.stopPropagation();
+ this.seekBy(frameTime);
+ break;
+ }
+
+ // If we are in fullscreen mode, we don't want any hotkeys
+ // interacting with the UI that's not visible
+
+ if (this.state.fullscreen) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (e.key === 'Escape') {
+ exitFullscreen();
+ }
+ }
+ };
+
+ togglePlay = () => {
+ if (this.state.paused) {
+ this.setState({ paused: false }, () => this.video.play());
+ } else {
+ this.setState({ paused: true }, () => this.video.pause());
+ }
+ };
+
+ toggleFullscreen = () => {
+ if (isFullscreen()) {
+ exitFullscreen();
+ } else {
+ requestFullscreen(this.player);
+ }
+ };
+
+ componentDidMount () {
+ document.addEventListener('fullscreenchange', this.handleFullscreenChange, true);
+ document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
+ document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
+ document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
+
+ window.addEventListener('scroll', this.handleScroll);
+ window.addEventListener('resize', this.handleResize, { passive: true });
+ }
+
+ componentWillUnmount () {
+ window.removeEventListener('scroll', this.handleScroll);
+ window.removeEventListener('resize', this.handleResize);
+
+ document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
+ document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
+ document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
+ document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
+
+ if (!this.state.paused && this.video && this.props.deployPictureInPicture) {
+ this.props.deployPictureInPicture('video', {
+ src: this.props.src,
+ currentTime: this.video.currentTime,
+ muted: this.video.muted,
+ volume: this.video.volume,
+ });
+ }
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
+ this.setState({ revealed: nextProps.visible });
+ }
+ }
+
+ componentDidUpdate (prevProps, prevState) {
+ if (prevState.revealed && !this.state.revealed && this.video) {
+ this.video.pause();
+ }
+ }
+
+ handleResize = debounce(() => {
+ if (this.player) {
+ this._setDimensions();
+ }
+ }, 250, {
+ trailing: true,
+ });
+
+ handleScroll = throttle(() => {
+ if (!this.video) {
+ return;
+ }
+
+ const { top, height } = this.video.getBoundingClientRect();
+ const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
+
+ if (!this.state.paused && !inView) {
+ this.video.pause();
+
+ if (this.props.deployPictureInPicture) {
+ this.props.deployPictureInPicture('video', {
+ src: this.props.src,
+ currentTime: this.video.currentTime,
+ muted: this.video.muted,
+ volume: this.video.volume,
+ });
+ }
+
+ this.setState({ paused: true });
+ }
+ }, 150, { trailing: true });
+
+ handleFullscreenChange = () => {
+ this.setState({ fullscreen: isFullscreen() });
+ };
+
+ handleMouseEnter = () => {
+ this.setState({ hovered: true });
+ };
+
+ handleMouseLeave = () => {
+ this.setState({ hovered: false });
+ };
+
+ toggleMute = () => {
+ const muted = !this.video.muted;
+
+ this.setState({ muted }, () => {
+ this.video.muted = muted;
+ });
+ };
+
+ toggleReveal = () => {
+ if (this.props.onToggleVisibility) {
+ this.props.onToggleVisibility();
+ } else {
+ this.setState({ revealed: !this.state.revealed });
+ }
+ };
+
+ handleLoadedData = () => {
+ const { currentTime, volume, muted, autoPlay } = this.props;
+
+ if (currentTime) {
+ this.video.currentTime = currentTime;
+ }
+
+ if (volume !== undefined) {
+ this.video.volume = volume;
+ }
+
+ if (muted !== undefined) {
+ this.video.muted = muted;
+ }
+
+ if (autoPlay) {
+ this.video.play();
+ }
+ };
+
+ handleProgress = () => {
+ const lastTimeRange = this.video.buffered.length - 1;
+
+ if (lastTimeRange > -1) {
+ this.setState({ buffer: Math.ceil(this.video.buffered.end(lastTimeRange) / this.video.duration * 100) });
+ }
+ };
+
+ handleVolumeChange = () => {
+ this.setState({ volume: this.video.volume, muted: this.video.muted });
+ };
+
+ handleOpenVideo = () => {
+ this.video.pause();
+
+ this.props.onOpenVideo({
+ startTime: this.video.currentTime,
+ autoPlay: !this.state.paused,
+ defaultVolume: this.state.volume,
+ componentIndex: this.props.componentIndex,
+ });
+ };
+
+ handleCloseVideo = () => {
+ this.video.pause();
+ this.props.onCloseVideo();
+ };
+
+ getFrameRate () {
+ if (this.props.frameRate && isNaN(this.props.frameRate)) {
+ // The frame rate is returned as a fraction string so we
+ // need to convert it to a number
+
+ return this.props.frameRate.split('/').reduce((p, c) => p / c);
+ }
+
+ return this.props.frameRate;
+ }
+
+ render () {
+ const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, editable, blurhash, autoFocus } = this.props;
+ const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
+ const progress = Math.min((currentTime / duration) * 100, 100);
+ const playerStyle = {};
+
+ let { width, height } = this.props;
+
+ if (inline && containerWidth) {
+ width = containerWidth;
+ height = containerWidth / (16/9);
+
+ playerStyle.height = height;
+ }
+
+ let preload;
+
+ if (this.props.currentTime || fullscreen || dragging) {
+ preload = 'auto';
+ } else if (detailed) {
+ preload = 'metadata';
+ } else {
+ preload = 'none';
+ }
+
+ let warning;
+
+ if (sensitive) {
+ warning = ;
+ } else {
+ warning = ;
+ }
+
+ return (
+
+
+
+ {(revealed || editable) &&
}
+
+
+
+ {warning}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {(detailed || fullscreen) && (
+
+ {formatTime(Math.floor(currentTime))}
+ /
+ {formatTime(Math.floor(duration))}
+
+ )}
+
+
+
+ {(!onCloseVideo && !editable && !fullscreen && !this.props.alwaysVisible) && }
+ {(!fullscreen && onOpenVideo) && }
+ {onCloseVideo && }
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/main.js b/app/javascript/mastodon/main.js
deleted file mode 100644
index 69a7ee91f..000000000
--- a/app/javascript/mastodon/main.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import React from 'react';
-import ReactDOM from 'react-dom';
-import { setupBrowserNotifications } from 'mastodon/actions/notifications';
-import Mastodon, { store } from 'mastodon/containers/mastodon';
-import { me } from 'mastodon/initial_state';
-import ready from 'mastodon/ready';
-
-const perf = require('mastodon/performance');
-
-/**
- * @returns {Promise}
- */
-function main() {
- perf.start('main()');
-
- return ready(async () => {
- const mountNode = document.getElementById('mastodon');
- const props = JSON.parse(mountNode.getAttribute('data-props'));
-
- ReactDOM.render( , mountNode);
- store.dispatch(setupBrowserNotifications());
-
- if (process.env.NODE_ENV === 'production' && me && 'serviceWorker' in navigator) {
- const { Workbox } = await import('workbox-window');
- const wb = new Workbox('/sw.js');
- /** @type {ServiceWorkerRegistration} */
- let registration;
-
- try {
- registration = await wb.register();
- } catch (err) {
- console.error(err);
- }
-
- if (registration) {
- const registerPushNotifications = await import('mastodon/actions/push_notifications');
-
- store.dispatch(registerPushNotifications.register());
- }
- }
-
- perf.stop('main()');
- });
-}
-
-export default main;
diff --git a/app/javascript/mastodon/main.jsx b/app/javascript/mastodon/main.jsx
new file mode 100644
index 000000000..69a7ee91f
--- /dev/null
+++ b/app/javascript/mastodon/main.jsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { setupBrowserNotifications } from 'mastodon/actions/notifications';
+import Mastodon, { store } from 'mastodon/containers/mastodon';
+import { me } from 'mastodon/initial_state';
+import ready from 'mastodon/ready';
+
+const perf = require('mastodon/performance');
+
+/**
+ * @returns {Promise}
+ */
+function main() {
+ perf.start('main()');
+
+ return ready(async () => {
+ const mountNode = document.getElementById('mastodon');
+ const props = JSON.parse(mountNode.getAttribute('data-props'));
+
+ ReactDOM.render( , mountNode);
+ store.dispatch(setupBrowserNotifications());
+
+ if (process.env.NODE_ENV === 'production' && me && 'serviceWorker' in navigator) {
+ const { Workbox } = await import('workbox-window');
+ const wb = new Workbox('/sw.js');
+ /** @type {ServiceWorkerRegistration} */
+ let registration;
+
+ try {
+ registration = await wb.register();
+ } catch (err) {
+ console.error(err);
+ }
+
+ if (registration) {
+ const registerPushNotifications = await import('mastodon/actions/push_notifications');
+
+ store.dispatch(registerPushNotifications.register());
+ }
+ }
+
+ perf.stop('main()');
+ });
+}
+
+export default main;
diff --git a/app/javascript/mastodon/utils/icons.js b/app/javascript/mastodon/utils/icons.js
deleted file mode 100644
index c3e362e39..000000000
--- a/app/javascript/mastodon/utils/icons.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import React from 'react';
-
-// Copied from emoji-mart for consistency with emoji picker and since
-// they don't export the icons in the package
-export const loupeIcon = (
-
-
-
-);
-
-export const deleteIcon = (
-
-
-
-);
diff --git a/app/javascript/mastodon/utils/icons.jsx b/app/javascript/mastodon/utils/icons.jsx
new file mode 100644
index 000000000..c3e362e39
--- /dev/null
+++ b/app/javascript/mastodon/utils/icons.jsx
@@ -0,0 +1,15 @@
+import React from 'react';
+
+// Copied from emoji-mart for consistency with emoji picker and since
+// they don't export the icons in the package
+export const loupeIcon = (
+
+
+
+);
+
+export const deleteIcon = (
+
+
+
+);
diff --git a/app/javascript/packs/admin.js b/app/javascript/packs/admin.js
deleted file mode 100644
index 038e9b434..000000000
--- a/app/javascript/packs/admin.js
+++ /dev/null
@@ -1,245 +0,0 @@
-import './public-path';
-import { delegate } from '@rails/ujs';
-import ready from '../mastodon/ready';
-
-const setAnnouncementEndsAttributes = (target) => {
- const valid = target?.value && target?.validity?.valid;
- const element = document.querySelector('input[type="datetime-local"]#announcement_ends_at');
- if (valid) {
- element.classList.remove('optional');
- element.required = true;
- element.min = target.value;
- } else {
- element.classList.add('optional');
- element.removeAttribute('required');
- element.removeAttribute('min');
- }
-};
-
-delegate(document, 'input[type="datetime-local"]#announcement_starts_at', 'change', ({ target }) => {
- setAnnouncementEndsAttributes(target);
-});
-
-const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]';
-
-const showSelectAll = () => {
- const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
- selectAllMatchingElement.classList.add('active');
-};
-
-const hideSelectAll = () => {
- const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
- const hiddenField = document.querySelector('#select_all_matching');
- const selectedMsg = document.querySelector('.batch-table__select-all .selected');
- const notSelectedMsg = document.querySelector('.batch-table__select-all .not-selected');
-
- selectAllMatchingElement.classList.remove('active');
- selectedMsg.classList.remove('active');
- notSelectedMsg.classList.add('active');
- hiddenField.value = '0';
-};
-
-delegate(document, '#batch_checkbox_all', 'change', ({ target }) => {
- const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
-
- [].forEach.call(document.querySelectorAll(batchCheckboxClassName), (content) => {
- content.checked = target.checked;
- });
-
- if (selectAllMatchingElement) {
- if (target.checked) {
- showSelectAll();
- } else {
- hideSelectAll();
- }
- }
-});
-
-delegate(document, '.batch-table__select-all button', 'click', () => {
- const hiddenField = document.querySelector('#select_all_matching');
- const active = hiddenField.value === '1';
- const selectedMsg = document.querySelector('.batch-table__select-all .selected');
- const notSelectedMsg = document.querySelector('.batch-table__select-all .not-selected');
-
- if (active) {
- hiddenField.value = '0';
- selectedMsg.classList.remove('active');
- notSelectedMsg.classList.add('active');
- } else {
- hiddenField.value = '1';
- notSelectedMsg.classList.remove('active');
- selectedMsg.classList.add('active');
- }
-});
-
-delegate(document, batchCheckboxClassName, 'change', () => {
- const checkAllElement = document.querySelector('#batch_checkbox_all');
- const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
-
- if (checkAllElement) {
- checkAllElement.checked = [].every.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
- checkAllElement.indeterminate = !checkAllElement.checked && [].some.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
-
- if (selectAllMatchingElement) {
- if (checkAllElement.checked) {
- showSelectAll();
- } else {
- hideSelectAll();
- }
- }
- }
-});
-
-delegate(document, '.media-spoiler-show-button', 'click', () => {
- [].forEach.call(document.querySelectorAll('button.media-spoiler'), (element) => {
- element.click();
- });
-});
-
-delegate(document, '.media-spoiler-hide-button', 'click', () => {
- [].forEach.call(document.querySelectorAll('.spoiler-button.spoiler-button--visible button'), (element) => {
- element.click();
- });
-});
-
-delegate(document, '.filter-subset--with-select select', 'change', ({ target }) => {
- target.form.submit();
-});
-
-const onDomainBlockSeverityChange = (target) => {
- const rejectMediaDiv = document.querySelector('.input.with_label.domain_block_reject_media');
- const rejectReportsDiv = document.querySelector('.input.with_label.domain_block_reject_reports');
-
- if (rejectMediaDiv) {
- rejectMediaDiv.style.display = (target.value === 'suspend') ? 'none' : 'block';
- }
-
- if (rejectReportsDiv) {
- rejectReportsDiv.style.display = (target.value === 'suspend') ? 'none' : 'block';
- }
-};
-
-delegate(document, '#domain_block_severity', 'change', ({ target }) => onDomainBlockSeverityChange(target));
-
-const onEnableBootstrapTimelineAccountsChange = (target) => {
- const bootstrapTimelineAccountsField = document.querySelector('#form_admin_settings_bootstrap_timeline_accounts');
-
- if (bootstrapTimelineAccountsField) {
- bootstrapTimelineAccountsField.disabled = !target.checked;
- if (target.checked) {
- bootstrapTimelineAccountsField.parentElement.classList.remove('disabled');
- bootstrapTimelineAccountsField.parentElement.parentElement.classList.remove('disabled');
- } else {
- bootstrapTimelineAccountsField.parentElement.classList.add('disabled');
- bootstrapTimelineAccountsField.parentElement.parentElement.classList.add('disabled');
- }
- }
-};
-
-delegate(document, '#form_admin_settings_enable_bootstrap_timeline_accounts', 'change', ({ target }) => onEnableBootstrapTimelineAccountsChange(target));
-
-const onChangeRegistrationMode = (target) => {
- const enabled = target.value === 'approved';
-
- [].forEach.call(document.querySelectorAll('#form_admin_settings_require_invite_text'), (input) => {
- input.disabled = !enabled;
- if (enabled) {
- let element = input;
- do {
- element.classList.remove('disabled');
- element = element.parentElement;
- } while (element && !element.classList.contains('fields-group'));
- } else {
- let element = input;
- do {
- element.classList.add('disabled');
- element = element.parentElement;
- } while (element && !element.classList.contains('fields-group'));
- }
- });
-};
-
-const convertUTCDateTimeToLocal = (value) => {
- const date = new Date(value + 'Z');
- const twoChars = (x) => (x.toString().padStart(2, '0'));
- return `${date.getFullYear()}-${twoChars(date.getMonth()+1)}-${twoChars(date.getDate())}T${twoChars(date.getHours())}:${twoChars(date.getMinutes())}`;
-};
-
-const convertLocalDatetimeToUTC = (value) => {
- const re = /^([0-9]{4,})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2})/;
- const match = re.exec(value);
- const date = new Date(match[1], match[2] - 1, match[3], match[4], match[5]);
- const fullISO8601 = date.toISOString();
- return fullISO8601.slice(0, fullISO8601.indexOf('T') + 6);
-};
-
-delegate(document, '#form_admin_settings_registrations_mode', 'change', ({ target }) => onChangeRegistrationMode(target));
-
-ready(() => {
- const domainBlockSeverityInput = document.getElementById('domain_block_severity');
- if (domainBlockSeverityInput) onDomainBlockSeverityChange(domainBlockSeverityInput);
-
- const enableBootstrapTimelineAccounts = document.getElementById('form_admin_settings_enable_bootstrap_timeline_accounts');
- if (enableBootstrapTimelineAccounts) onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts);
-
- const registrationMode = document.getElementById('form_admin_settings_registrations_mode');
- if (registrationMode) onChangeRegistrationMode(registrationMode);
-
- const checkAllElement = document.querySelector('#batch_checkbox_all');
- if (checkAllElement) {
- checkAllElement.checked = [].every.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
- checkAllElement.indeterminate = !checkAllElement.checked && [].some.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
- }
-
- document.querySelector('a#add-instance-button')?.addEventListener('click', (e) => {
- const domain = document.querySelector('input[type="text"]#by_domain')?.value;
-
- if (domain) {
- const url = new URL(event.target.href);
- url.searchParams.set('_domain', domain);
- e.target.href = url;
- }
- });
-
- [].forEach.call(document.querySelectorAll('input[type="datetime-local"]'), element => {
- if (element.value) {
- element.value = convertUTCDateTimeToLocal(element.value);
- }
- if (element.placeholder) {
- element.placeholder = convertUTCDateTimeToLocal(element.placeholder);
- }
- });
-
- delegate(document, 'form', 'submit', ({ target }) => {
- [].forEach.call(target.querySelectorAll('input[type="datetime-local"]'), element => {
- if (element.value && element.validity.valid) {
- element.value = convertLocalDatetimeToUTC(element.value);
- }
- });
- });
-
- const announcementStartsAt = document.querySelector('input[type="datetime-local"]#announcement_starts_at');
- if (announcementStartsAt) {
- setAnnouncementEndsAttributes(announcementStartsAt);
- }
-
- const React = require('react');
- const ReactDOM = require('react-dom');
-
- [].forEach.call(document.querySelectorAll('[data-admin-component]'), element => {
- const componentName = element.getAttribute('data-admin-component');
- const { locale, ...componentProps } = JSON.parse(element.getAttribute('data-props'));
-
- import('../mastodon/containers/admin_component').then(({ default: AdminComponent }) => {
- return import('../mastodon/components/admin/' + componentName).then(({ default: Component }) => {
- ReactDOM.render((
-
-
-
- ), element);
- });
- }).catch(error => {
- console.error(error);
- });
- });
-});
diff --git a/app/javascript/packs/admin.jsx b/app/javascript/packs/admin.jsx
new file mode 100644
index 000000000..038e9b434
--- /dev/null
+++ b/app/javascript/packs/admin.jsx
@@ -0,0 +1,245 @@
+import './public-path';
+import { delegate } from '@rails/ujs';
+import ready from '../mastodon/ready';
+
+const setAnnouncementEndsAttributes = (target) => {
+ const valid = target?.value && target?.validity?.valid;
+ const element = document.querySelector('input[type="datetime-local"]#announcement_ends_at');
+ if (valid) {
+ element.classList.remove('optional');
+ element.required = true;
+ element.min = target.value;
+ } else {
+ element.classList.add('optional');
+ element.removeAttribute('required');
+ element.removeAttribute('min');
+ }
+};
+
+delegate(document, 'input[type="datetime-local"]#announcement_starts_at', 'change', ({ target }) => {
+ setAnnouncementEndsAttributes(target);
+});
+
+const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]';
+
+const showSelectAll = () => {
+ const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
+ selectAllMatchingElement.classList.add('active');
+};
+
+const hideSelectAll = () => {
+ const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
+ const hiddenField = document.querySelector('#select_all_matching');
+ const selectedMsg = document.querySelector('.batch-table__select-all .selected');
+ const notSelectedMsg = document.querySelector('.batch-table__select-all .not-selected');
+
+ selectAllMatchingElement.classList.remove('active');
+ selectedMsg.classList.remove('active');
+ notSelectedMsg.classList.add('active');
+ hiddenField.value = '0';
+};
+
+delegate(document, '#batch_checkbox_all', 'change', ({ target }) => {
+ const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
+
+ [].forEach.call(document.querySelectorAll(batchCheckboxClassName), (content) => {
+ content.checked = target.checked;
+ });
+
+ if (selectAllMatchingElement) {
+ if (target.checked) {
+ showSelectAll();
+ } else {
+ hideSelectAll();
+ }
+ }
+});
+
+delegate(document, '.batch-table__select-all button', 'click', () => {
+ const hiddenField = document.querySelector('#select_all_matching');
+ const active = hiddenField.value === '1';
+ const selectedMsg = document.querySelector('.batch-table__select-all .selected');
+ const notSelectedMsg = document.querySelector('.batch-table__select-all .not-selected');
+
+ if (active) {
+ hiddenField.value = '0';
+ selectedMsg.classList.remove('active');
+ notSelectedMsg.classList.add('active');
+ } else {
+ hiddenField.value = '1';
+ notSelectedMsg.classList.remove('active');
+ selectedMsg.classList.add('active');
+ }
+});
+
+delegate(document, batchCheckboxClassName, 'change', () => {
+ const checkAllElement = document.querySelector('#batch_checkbox_all');
+ const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
+
+ if (checkAllElement) {
+ checkAllElement.checked = [].every.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
+ checkAllElement.indeterminate = !checkAllElement.checked && [].some.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
+
+ if (selectAllMatchingElement) {
+ if (checkAllElement.checked) {
+ showSelectAll();
+ } else {
+ hideSelectAll();
+ }
+ }
+ }
+});
+
+delegate(document, '.media-spoiler-show-button', 'click', () => {
+ [].forEach.call(document.querySelectorAll('button.media-spoiler'), (element) => {
+ element.click();
+ });
+});
+
+delegate(document, '.media-spoiler-hide-button', 'click', () => {
+ [].forEach.call(document.querySelectorAll('.spoiler-button.spoiler-button--visible button'), (element) => {
+ element.click();
+ });
+});
+
+delegate(document, '.filter-subset--with-select select', 'change', ({ target }) => {
+ target.form.submit();
+});
+
+const onDomainBlockSeverityChange = (target) => {
+ const rejectMediaDiv = document.querySelector('.input.with_label.domain_block_reject_media');
+ const rejectReportsDiv = document.querySelector('.input.with_label.domain_block_reject_reports');
+
+ if (rejectMediaDiv) {
+ rejectMediaDiv.style.display = (target.value === 'suspend') ? 'none' : 'block';
+ }
+
+ if (rejectReportsDiv) {
+ rejectReportsDiv.style.display = (target.value === 'suspend') ? 'none' : 'block';
+ }
+};
+
+delegate(document, '#domain_block_severity', 'change', ({ target }) => onDomainBlockSeverityChange(target));
+
+const onEnableBootstrapTimelineAccountsChange = (target) => {
+ const bootstrapTimelineAccountsField = document.querySelector('#form_admin_settings_bootstrap_timeline_accounts');
+
+ if (bootstrapTimelineAccountsField) {
+ bootstrapTimelineAccountsField.disabled = !target.checked;
+ if (target.checked) {
+ bootstrapTimelineAccountsField.parentElement.classList.remove('disabled');
+ bootstrapTimelineAccountsField.parentElement.parentElement.classList.remove('disabled');
+ } else {
+ bootstrapTimelineAccountsField.parentElement.classList.add('disabled');
+ bootstrapTimelineAccountsField.parentElement.parentElement.classList.add('disabled');
+ }
+ }
+};
+
+delegate(document, '#form_admin_settings_enable_bootstrap_timeline_accounts', 'change', ({ target }) => onEnableBootstrapTimelineAccountsChange(target));
+
+const onChangeRegistrationMode = (target) => {
+ const enabled = target.value === 'approved';
+
+ [].forEach.call(document.querySelectorAll('#form_admin_settings_require_invite_text'), (input) => {
+ input.disabled = !enabled;
+ if (enabled) {
+ let element = input;
+ do {
+ element.classList.remove('disabled');
+ element = element.parentElement;
+ } while (element && !element.classList.contains('fields-group'));
+ } else {
+ let element = input;
+ do {
+ element.classList.add('disabled');
+ element = element.parentElement;
+ } while (element && !element.classList.contains('fields-group'));
+ }
+ });
+};
+
+const convertUTCDateTimeToLocal = (value) => {
+ const date = new Date(value + 'Z');
+ const twoChars = (x) => (x.toString().padStart(2, '0'));
+ return `${date.getFullYear()}-${twoChars(date.getMonth()+1)}-${twoChars(date.getDate())}T${twoChars(date.getHours())}:${twoChars(date.getMinutes())}`;
+};
+
+const convertLocalDatetimeToUTC = (value) => {
+ const re = /^([0-9]{4,})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2})/;
+ const match = re.exec(value);
+ const date = new Date(match[1], match[2] - 1, match[3], match[4], match[5]);
+ const fullISO8601 = date.toISOString();
+ return fullISO8601.slice(0, fullISO8601.indexOf('T') + 6);
+};
+
+delegate(document, '#form_admin_settings_registrations_mode', 'change', ({ target }) => onChangeRegistrationMode(target));
+
+ready(() => {
+ const domainBlockSeverityInput = document.getElementById('domain_block_severity');
+ if (domainBlockSeverityInput) onDomainBlockSeverityChange(domainBlockSeverityInput);
+
+ const enableBootstrapTimelineAccounts = document.getElementById('form_admin_settings_enable_bootstrap_timeline_accounts');
+ if (enableBootstrapTimelineAccounts) onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts);
+
+ const registrationMode = document.getElementById('form_admin_settings_registrations_mode');
+ if (registrationMode) onChangeRegistrationMode(registrationMode);
+
+ const checkAllElement = document.querySelector('#batch_checkbox_all');
+ if (checkAllElement) {
+ checkAllElement.checked = [].every.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
+ checkAllElement.indeterminate = !checkAllElement.checked && [].some.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
+ }
+
+ document.querySelector('a#add-instance-button')?.addEventListener('click', (e) => {
+ const domain = document.querySelector('input[type="text"]#by_domain')?.value;
+
+ if (domain) {
+ const url = new URL(event.target.href);
+ url.searchParams.set('_domain', domain);
+ e.target.href = url;
+ }
+ });
+
+ [].forEach.call(document.querySelectorAll('input[type="datetime-local"]'), element => {
+ if (element.value) {
+ element.value = convertUTCDateTimeToLocal(element.value);
+ }
+ if (element.placeholder) {
+ element.placeholder = convertUTCDateTimeToLocal(element.placeholder);
+ }
+ });
+
+ delegate(document, 'form', 'submit', ({ target }) => {
+ [].forEach.call(target.querySelectorAll('input[type="datetime-local"]'), element => {
+ if (element.value && element.validity.valid) {
+ element.value = convertLocalDatetimeToUTC(element.value);
+ }
+ });
+ });
+
+ const announcementStartsAt = document.querySelector('input[type="datetime-local"]#announcement_starts_at');
+ if (announcementStartsAt) {
+ setAnnouncementEndsAttributes(announcementStartsAt);
+ }
+
+ const React = require('react');
+ const ReactDOM = require('react-dom');
+
+ [].forEach.call(document.querySelectorAll('[data-admin-component]'), element => {
+ const componentName = element.getAttribute('data-admin-component');
+ const { locale, ...componentProps } = JSON.parse(element.getAttribute('data-props'));
+
+ import('../mastodon/containers/admin_component').then(({ default: AdminComponent }) => {
+ return import('../mastodon/components/admin/' + componentName).then(({ default: Component }) => {
+ ReactDOM.render((
+
+
+
+ ), element);
+ });
+ }).catch(error => {
+ console.error(error);
+ });
+ });
+});
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
deleted file mode 100644
index a5e2014f7..000000000
--- a/app/javascript/packs/public.js
+++ /dev/null
@@ -1,332 +0,0 @@
-import './public-path';
-import escapeTextContentForBrowser from 'escape-html';
-import loadPolyfills from '../mastodon/load_polyfills';
-import ready from '../mastodon/ready';
-import { start } from '../mastodon/common';
-import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions';
-import 'cocoon-js-vanilla';
-
-start();
-
-window.addEventListener('message', e => {
- const data = e.data || {};
-
- if (!window.parent || data.type !== 'setHeight') {
- return;
- }
-
- ready(() => {
- window.parent.postMessage({
- type: 'setHeight',
- id: data.id,
- height: document.getElementsByTagName('html')[0].scrollHeight,
- }, '*');
- });
-});
-
-function main() {
- const IntlMessageFormat = require('intl-messageformat').default;
- const { timeAgoString } = require('../mastodon/components/relative_timestamp');
- const { delegate } = require('@rails/ujs');
- const emojify = require('../mastodon/features/emoji/emoji').default;
- const { getLocale } = require('../mastodon/locales');
- const { messages } = getLocale();
- const React = require('react');
- const ReactDOM = require('react-dom');
- const { createBrowserHistory } = require('history');
-
- const scrollToDetailedStatus = () => {
- const history = createBrowserHistory();
- const detailedStatuses = document.querySelectorAll('.public-layout .detailed-status');
- const location = history.location;
-
- if (detailedStatuses.length === 1 && (!location.state || !location.state.scrolledToDetailedStatus)) {
- detailedStatuses[0].scrollIntoView();
- history.replace(location.pathname, { ...location.state, scrolledToDetailedStatus: true });
- }
- };
-
- const getEmojiAnimationHandler = (swapTo) => {
- return ({ target }) => {
- target.src = target.getAttribute(swapTo);
- };
- };
-
- ready(() => {
- const locale = document.documentElement.lang;
-
- const dateTimeFormat = new Intl.DateTimeFormat(locale, {
- year: 'numeric',
- month: 'long',
- day: 'numeric',
- hour: 'numeric',
- minute: 'numeric',
- });
-
- const dateFormat = new Intl.DateTimeFormat(locale, {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- timeFormat: false,
- });
-
- const timeFormat = new Intl.DateTimeFormat(locale, {
- timeStyle: 'short',
- hour12: false,
- });
-
- [].forEach.call(document.querySelectorAll('.emojify'), (content) => {
- content.innerHTML = emojify(content.innerHTML);
- });
-
- [].forEach.call(document.querySelectorAll('time.formatted'), (content) => {
- const datetime = new Date(content.getAttribute('datetime'));
- const formattedDate = dateTimeFormat.format(datetime);
-
- content.title = formattedDate;
- content.textContent = formattedDate;
- });
-
- const isToday = date => {
- const today = new Date();
-
- return date.getDate() === today.getDate() &&
- date.getMonth() === today.getMonth() &&
- date.getFullYear() === today.getFullYear();
- };
- const todayFormat = new IntlMessageFormat(messages['relative_format.today'] || 'Today at {time}', locale);
-
- [].forEach.call(document.querySelectorAll('time.relative-formatted'), (content) => {
- const datetime = new Date(content.getAttribute('datetime'));
-
- let formattedContent;
-
- if (isToday(datetime)) {
- const formattedTime = timeFormat.format(datetime);
-
- formattedContent = todayFormat.format({ time: formattedTime });
- } else {
- formattedContent = dateFormat.format(datetime);
- }
-
- content.title = formattedContent;
- content.textContent = formattedContent;
- });
-
- [].forEach.call(document.querySelectorAll('time.time-ago'), (content) => {
- const datetime = new Date(content.getAttribute('datetime'));
- const now = new Date();
-
- content.title = dateTimeFormat.format(datetime);
- content.textContent = timeAgoString({
- formatMessage: ({ id, defaultMessage }, values) => (new IntlMessageFormat(messages[id] || defaultMessage, locale)).format(values),
- formatDate: (date, options) => (new Intl.DateTimeFormat(locale, options)).format(date),
- }, datetime, now, now.getFullYear(), content.getAttribute('datetime').includes('T'));
- });
-
- const reactComponents = document.querySelectorAll('[data-component]');
-
- if (reactComponents.length > 0) {
- import(/* webpackChunkName: "containers/media_container" */ '../mastodon/containers/media_container')
- .then(({ default: MediaContainer }) => {
- [].forEach.call(reactComponents, (component) => {
- [].forEach.call(component.children, (child) => {
- component.removeChild(child);
- });
- });
-
- const content = document.createElement('div');
-
- ReactDOM.render( , content);
- document.body.appendChild(content);
- scrollToDetailedStatus();
- })
- .catch(error => {
- console.error(error);
- scrollToDetailedStatus();
- });
- } else {
- scrollToDetailedStatus();
- }
-
- delegate(document, '#registration_user_password_confirmation,#registration_user_password', 'input', () => {
- const password = document.getElementById('registration_user_password');
- const confirmation = document.getElementById('registration_user_password_confirmation');
- if (confirmation.value && confirmation.value.length > password.maxLength) {
- confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.exceeds_maxlength'] || 'Password confirmation exceeds the maximum password length', locale)).format());
- } else if (password.value && password.value !== confirmation.value) {
- confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.mismatching'] || 'Password confirmation does not match', locale)).format());
- } else {
- confirmation.setCustomValidity('');
- }
- });
-
- delegate(document, '#user_password,#user_password_confirmation', 'input', () => {
- const password = document.getElementById('user_password');
- const confirmation = document.getElementById('user_password_confirmation');
- if (!confirmation) return;
-
- if (confirmation.value && confirmation.value.length > password.maxLength) {
- confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.exceeds_maxlength'] || 'Password confirmation exceeds the maximum password length', locale)).format());
- } else if (password.value && password.value !== confirmation.value) {
- confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.mismatching'] || 'Password confirmation does not match', locale)).format());
- } else {
- confirmation.setCustomValidity('');
- }
- });
-
- delegate(document, '.custom-emoji', 'mouseover', getEmojiAnimationHandler('data-original'));
- delegate(document, '.custom-emoji', 'mouseout', getEmojiAnimationHandler('data-static'));
-
- delegate(document, '.status__content__spoiler-link', 'click', function() {
- const statusEl = this.parentNode.parentNode;
-
- if (statusEl.dataset.spoiler === 'expanded') {
- statusEl.dataset.spoiler = 'folded';
- this.textContent = (new IntlMessageFormat(messages['status.show_more'] || 'Show more', locale)).format();
- } else {
- statusEl.dataset.spoiler = 'expanded';
- this.textContent = (new IntlMessageFormat(messages['status.show_less'] || 'Show less', locale)).format();
- }
-
- return false;
- });
-
- [].forEach.call(document.querySelectorAll('.status__content__spoiler-link'), (spoilerLink) => {
- const statusEl = spoilerLink.parentNode.parentNode;
- const message = (statusEl.dataset.spoiler === 'expanded') ? (messages['status.show_less'] || 'Show less') : (messages['status.show_more'] || 'Show more');
- spoilerLink.textContent = (new IntlMessageFormat(message, locale)).format();
- });
- });
-
- delegate(document, '#account_display_name', 'input', ({ target }) => {
- const name = document.querySelector('.card .display-name strong');
- if (name) {
- if (target.value) {
- name.innerHTML = emojify(escapeTextContentForBrowser(target.value));
- } else {
- name.textContent = target.dataset.default;
- }
- }
- });
-
- delegate(document, '#account_avatar', 'change', ({ target }) => {
- const avatar = document.querySelector('.card .avatar img');
- const [file] = target.files || [];
- const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc;
-
- avatar.src = url;
- });
-
- const getProfileAvatarAnimationHandler = (swapTo) => {
- //animate avatar gifs on the profile page when moused over
- return ({ target }) => {
- const swapSrc = target.getAttribute(swapTo);
- //only change the img source if autoplay is off and the image src is actually different
- if(target.getAttribute('data-autoplay') !== 'true' && target.src !== swapSrc) {
- target.src = swapSrc;
- }
- };
- };
-
- delegate(document, 'img#profile_page_avatar', 'mouseover', getProfileAvatarAnimationHandler('data-original'));
-
- delegate(document, 'img#profile_page_avatar', 'mouseout', getProfileAvatarAnimationHandler('data-static'));
-
- delegate(document, '#account_header', 'change', ({ target }) => {
- const header = document.querySelector('.card .card__img img');
- const [file] = target.files || [];
- const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc;
-
- header.src = url;
- });
-
- delegate(document, '#account_locked', 'change', ({ target }) => {
- const lock = document.querySelector('.card .display-name i');
-
- if (lock) {
- if (target.checked) {
- delete lock.dataset.hidden;
- } else {
- lock.dataset.hidden = 'true';
- }
- }
- });
-
- delegate(document, '.input-copy input', 'click', ({ target }) => {
- target.focus();
- target.select();
- target.setSelectionRange(0, target.value.length);
- });
-
- delegate(document, '.input-copy button', 'click', ({ target }) => {
- const input = target.parentNode.querySelector('.input-copy__wrapper input');
-
- const oldReadOnly = input.readonly;
-
- input.readonly = false;
- input.focus();
- input.select();
- input.setSelectionRange(0, input.value.length);
-
- try {
- if (document.execCommand('copy')) {
- input.blur();
- target.parentNode.classList.add('copied');
-
- setTimeout(() => {
- target.parentNode.classList.remove('copied');
- }, 700);
- }
- } catch (err) {
- console.error(err);
- }
-
- input.readonly = oldReadOnly;
- });
-
- const toggleSidebar = () => {
- const sidebar = document.querySelector('.sidebar ul');
- const toggleButton = document.querySelector('.sidebar__toggle__icon');
-
- if (sidebar.classList.contains('visible')) {
- document.body.style.overflow = null;
- toggleButton.setAttribute('aria-expanded', false);
- } else {
- document.body.style.overflow = 'hidden';
- toggleButton.setAttribute('aria-expanded', true);
- }
-
- toggleButton.classList.toggle('active');
- sidebar.classList.toggle('visible');
- };
-
- delegate(document, '.sidebar__toggle__icon', 'click', () => {
- toggleSidebar();
- });
-
- delegate(document, '.sidebar__toggle__icon', 'keydown', e => {
- if (e.key === ' ' || e.key === 'Enter') {
- e.preventDefault();
- toggleSidebar();
- }
- });
-
- // Empty the honeypot fields in JS in case something like an extension
- // automatically filled them.
- delegate(document, '#registration_new_user,#new_user', 'submit', () => {
- ['user_website', 'user_confirm_password', 'registration_user_website', 'registration_user_confirm_password'].forEach(id => {
- const field = document.getElementById(id);
- if (field) {
- field.value = '';
- }
- });
- });
-}
-
-loadPolyfills()
- .then(main)
- .then(loadKeyboardExtensions)
- .catch(error => {
- console.error(error);
- });
diff --git a/app/javascript/packs/public.jsx b/app/javascript/packs/public.jsx
new file mode 100644
index 000000000..a5e2014f7
--- /dev/null
+++ b/app/javascript/packs/public.jsx
@@ -0,0 +1,332 @@
+import './public-path';
+import escapeTextContentForBrowser from 'escape-html';
+import loadPolyfills from '../mastodon/load_polyfills';
+import ready from '../mastodon/ready';
+import { start } from '../mastodon/common';
+import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions';
+import 'cocoon-js-vanilla';
+
+start();
+
+window.addEventListener('message', e => {
+ const data = e.data || {};
+
+ if (!window.parent || data.type !== 'setHeight') {
+ return;
+ }
+
+ ready(() => {
+ window.parent.postMessage({
+ type: 'setHeight',
+ id: data.id,
+ height: document.getElementsByTagName('html')[0].scrollHeight,
+ }, '*');
+ });
+});
+
+function main() {
+ const IntlMessageFormat = require('intl-messageformat').default;
+ const { timeAgoString } = require('../mastodon/components/relative_timestamp');
+ const { delegate } = require('@rails/ujs');
+ const emojify = require('../mastodon/features/emoji/emoji').default;
+ const { getLocale } = require('../mastodon/locales');
+ const { messages } = getLocale();
+ const React = require('react');
+ const ReactDOM = require('react-dom');
+ const { createBrowserHistory } = require('history');
+
+ const scrollToDetailedStatus = () => {
+ const history = createBrowserHistory();
+ const detailedStatuses = document.querySelectorAll('.public-layout .detailed-status');
+ const location = history.location;
+
+ if (detailedStatuses.length === 1 && (!location.state || !location.state.scrolledToDetailedStatus)) {
+ detailedStatuses[0].scrollIntoView();
+ history.replace(location.pathname, { ...location.state, scrolledToDetailedStatus: true });
+ }
+ };
+
+ const getEmojiAnimationHandler = (swapTo) => {
+ return ({ target }) => {
+ target.src = target.getAttribute(swapTo);
+ };
+ };
+
+ ready(() => {
+ const locale = document.documentElement.lang;
+
+ const dateTimeFormat = new Intl.DateTimeFormat(locale, {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: 'numeric',
+ });
+
+ const dateFormat = new Intl.DateTimeFormat(locale, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ timeFormat: false,
+ });
+
+ const timeFormat = new Intl.DateTimeFormat(locale, {
+ timeStyle: 'short',
+ hour12: false,
+ });
+
+ [].forEach.call(document.querySelectorAll('.emojify'), (content) => {
+ content.innerHTML = emojify(content.innerHTML);
+ });
+
+ [].forEach.call(document.querySelectorAll('time.formatted'), (content) => {
+ const datetime = new Date(content.getAttribute('datetime'));
+ const formattedDate = dateTimeFormat.format(datetime);
+
+ content.title = formattedDate;
+ content.textContent = formattedDate;
+ });
+
+ const isToday = date => {
+ const today = new Date();
+
+ return date.getDate() === today.getDate() &&
+ date.getMonth() === today.getMonth() &&
+ date.getFullYear() === today.getFullYear();
+ };
+ const todayFormat = new IntlMessageFormat(messages['relative_format.today'] || 'Today at {time}', locale);
+
+ [].forEach.call(document.querySelectorAll('time.relative-formatted'), (content) => {
+ const datetime = new Date(content.getAttribute('datetime'));
+
+ let formattedContent;
+
+ if (isToday(datetime)) {
+ const formattedTime = timeFormat.format(datetime);
+
+ formattedContent = todayFormat.format({ time: formattedTime });
+ } else {
+ formattedContent = dateFormat.format(datetime);
+ }
+
+ content.title = formattedContent;
+ content.textContent = formattedContent;
+ });
+
+ [].forEach.call(document.querySelectorAll('time.time-ago'), (content) => {
+ const datetime = new Date(content.getAttribute('datetime'));
+ const now = new Date();
+
+ content.title = dateTimeFormat.format(datetime);
+ content.textContent = timeAgoString({
+ formatMessage: ({ id, defaultMessage }, values) => (new IntlMessageFormat(messages[id] || defaultMessage, locale)).format(values),
+ formatDate: (date, options) => (new Intl.DateTimeFormat(locale, options)).format(date),
+ }, datetime, now, now.getFullYear(), content.getAttribute('datetime').includes('T'));
+ });
+
+ const reactComponents = document.querySelectorAll('[data-component]');
+
+ if (reactComponents.length > 0) {
+ import(/* webpackChunkName: "containers/media_container" */ '../mastodon/containers/media_container')
+ .then(({ default: MediaContainer }) => {
+ [].forEach.call(reactComponents, (component) => {
+ [].forEach.call(component.children, (child) => {
+ component.removeChild(child);
+ });
+ });
+
+ const content = document.createElement('div');
+
+ ReactDOM.render( , content);
+ document.body.appendChild(content);
+ scrollToDetailedStatus();
+ })
+ .catch(error => {
+ console.error(error);
+ scrollToDetailedStatus();
+ });
+ } else {
+ scrollToDetailedStatus();
+ }
+
+ delegate(document, '#registration_user_password_confirmation,#registration_user_password', 'input', () => {
+ const password = document.getElementById('registration_user_password');
+ const confirmation = document.getElementById('registration_user_password_confirmation');
+ if (confirmation.value && confirmation.value.length > password.maxLength) {
+ confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.exceeds_maxlength'] || 'Password confirmation exceeds the maximum password length', locale)).format());
+ } else if (password.value && password.value !== confirmation.value) {
+ confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.mismatching'] || 'Password confirmation does not match', locale)).format());
+ } else {
+ confirmation.setCustomValidity('');
+ }
+ });
+
+ delegate(document, '#user_password,#user_password_confirmation', 'input', () => {
+ const password = document.getElementById('user_password');
+ const confirmation = document.getElementById('user_password_confirmation');
+ if (!confirmation) return;
+
+ if (confirmation.value && confirmation.value.length > password.maxLength) {
+ confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.exceeds_maxlength'] || 'Password confirmation exceeds the maximum password length', locale)).format());
+ } else if (password.value && password.value !== confirmation.value) {
+ confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.mismatching'] || 'Password confirmation does not match', locale)).format());
+ } else {
+ confirmation.setCustomValidity('');
+ }
+ });
+
+ delegate(document, '.custom-emoji', 'mouseover', getEmojiAnimationHandler('data-original'));
+ delegate(document, '.custom-emoji', 'mouseout', getEmojiAnimationHandler('data-static'));
+
+ delegate(document, '.status__content__spoiler-link', 'click', function() {
+ const statusEl = this.parentNode.parentNode;
+
+ if (statusEl.dataset.spoiler === 'expanded') {
+ statusEl.dataset.spoiler = 'folded';
+ this.textContent = (new IntlMessageFormat(messages['status.show_more'] || 'Show more', locale)).format();
+ } else {
+ statusEl.dataset.spoiler = 'expanded';
+ this.textContent = (new IntlMessageFormat(messages['status.show_less'] || 'Show less', locale)).format();
+ }
+
+ return false;
+ });
+
+ [].forEach.call(document.querySelectorAll('.status__content__spoiler-link'), (spoilerLink) => {
+ const statusEl = spoilerLink.parentNode.parentNode;
+ const message = (statusEl.dataset.spoiler === 'expanded') ? (messages['status.show_less'] || 'Show less') : (messages['status.show_more'] || 'Show more');
+ spoilerLink.textContent = (new IntlMessageFormat(message, locale)).format();
+ });
+ });
+
+ delegate(document, '#account_display_name', 'input', ({ target }) => {
+ const name = document.querySelector('.card .display-name strong');
+ if (name) {
+ if (target.value) {
+ name.innerHTML = emojify(escapeTextContentForBrowser(target.value));
+ } else {
+ name.textContent = target.dataset.default;
+ }
+ }
+ });
+
+ delegate(document, '#account_avatar', 'change', ({ target }) => {
+ const avatar = document.querySelector('.card .avatar img');
+ const [file] = target.files || [];
+ const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc;
+
+ avatar.src = url;
+ });
+
+ const getProfileAvatarAnimationHandler = (swapTo) => {
+ //animate avatar gifs on the profile page when moused over
+ return ({ target }) => {
+ const swapSrc = target.getAttribute(swapTo);
+ //only change the img source if autoplay is off and the image src is actually different
+ if(target.getAttribute('data-autoplay') !== 'true' && target.src !== swapSrc) {
+ target.src = swapSrc;
+ }
+ };
+ };
+
+ delegate(document, 'img#profile_page_avatar', 'mouseover', getProfileAvatarAnimationHandler('data-original'));
+
+ delegate(document, 'img#profile_page_avatar', 'mouseout', getProfileAvatarAnimationHandler('data-static'));
+
+ delegate(document, '#account_header', 'change', ({ target }) => {
+ const header = document.querySelector('.card .card__img img');
+ const [file] = target.files || [];
+ const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc;
+
+ header.src = url;
+ });
+
+ delegate(document, '#account_locked', 'change', ({ target }) => {
+ const lock = document.querySelector('.card .display-name i');
+
+ if (lock) {
+ if (target.checked) {
+ delete lock.dataset.hidden;
+ } else {
+ lock.dataset.hidden = 'true';
+ }
+ }
+ });
+
+ delegate(document, '.input-copy input', 'click', ({ target }) => {
+ target.focus();
+ target.select();
+ target.setSelectionRange(0, target.value.length);
+ });
+
+ delegate(document, '.input-copy button', 'click', ({ target }) => {
+ const input = target.parentNode.querySelector('.input-copy__wrapper input');
+
+ const oldReadOnly = input.readonly;
+
+ input.readonly = false;
+ input.focus();
+ input.select();
+ input.setSelectionRange(0, input.value.length);
+
+ try {
+ if (document.execCommand('copy')) {
+ input.blur();
+ target.parentNode.classList.add('copied');
+
+ setTimeout(() => {
+ target.parentNode.classList.remove('copied');
+ }, 700);
+ }
+ } catch (err) {
+ console.error(err);
+ }
+
+ input.readonly = oldReadOnly;
+ });
+
+ const toggleSidebar = () => {
+ const sidebar = document.querySelector('.sidebar ul');
+ const toggleButton = document.querySelector('.sidebar__toggle__icon');
+
+ if (sidebar.classList.contains('visible')) {
+ document.body.style.overflow = null;
+ toggleButton.setAttribute('aria-expanded', false);
+ } else {
+ document.body.style.overflow = 'hidden';
+ toggleButton.setAttribute('aria-expanded', true);
+ }
+
+ toggleButton.classList.toggle('active');
+ sidebar.classList.toggle('visible');
+ };
+
+ delegate(document, '.sidebar__toggle__icon', 'click', () => {
+ toggleSidebar();
+ });
+
+ delegate(document, '.sidebar__toggle__icon', 'keydown', e => {
+ if (e.key === ' ' || e.key === 'Enter') {
+ e.preventDefault();
+ toggleSidebar();
+ }
+ });
+
+ // Empty the honeypot fields in JS in case something like an extension
+ // automatically filled them.
+ delegate(document, '#registration_new_user,#new_user', 'submit', () => {
+ ['user_website', 'user_confirm_password', 'registration_user_website', 'registration_user_confirm_password'].forEach(id => {
+ const field = document.getElementById(id);
+ if (field) {
+ field.value = '';
+ }
+ });
+ });
+}
+
+loadPolyfills()
+ .then(main)
+ .then(loadKeyboardExtensions)
+ .catch(error => {
+ console.error(error);
+ });
diff --git a/app/javascript/packs/share.js b/app/javascript/packs/share.js
deleted file mode 100644
index 1225d7b52..000000000
--- a/app/javascript/packs/share.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import './public-path';
-import loadPolyfills from '../mastodon/load_polyfills';
-import { start } from '../mastodon/common';
-
-start();
-
-function loaded() {
- const ComposeContainer = require('../mastodon/containers/compose_container').default;
- const React = require('react');
- const ReactDOM = require('react-dom');
- const mountNode = document.getElementById('mastodon-compose');
-
- if (mountNode !== null) {
- const props = JSON.parse(mountNode.getAttribute('data-props'));
- ReactDOM.render( , mountNode);
- }
-}
-
-function main() {
- const ready = require('../mastodon/ready').default;
- ready(loaded);
-}
-
-loadPolyfills().then(main).catch(error => {
- console.error(error);
-});
diff --git a/app/javascript/packs/share.jsx b/app/javascript/packs/share.jsx
new file mode 100644
index 000000000..1225d7b52
--- /dev/null
+++ b/app/javascript/packs/share.jsx
@@ -0,0 +1,26 @@
+import './public-path';
+import loadPolyfills from '../mastodon/load_polyfills';
+import { start } from '../mastodon/common';
+
+start();
+
+function loaded() {
+ const ComposeContainer = require('../mastodon/containers/compose_container').default;
+ const React = require('react');
+ const ReactDOM = require('react-dom');
+ const mountNode = document.getElementById('mastodon-compose');
+
+ if (mountNode !== null) {
+ const props = JSON.parse(mountNode.getAttribute('data-props'));
+ ReactDOM.render( , mountNode);
+ }
+}
+
+function main() {
+ const ready = require('../mastodon/ready').default;
+ ready(loaded);
+}
+
+loadPolyfills().then(main).catch(error => {
+ console.error(error);
+});
diff --git a/config/webpacker.yml b/config/webpacker.yml
index 4ad78a190..0baff662b 100644
--- a/config/webpacker.yml
+++ b/config/webpacker.yml
@@ -35,6 +35,7 @@ default: &default
extensions:
- .mjs
- .js
+ - .jsx
- .sass
- .scss
- .css
diff --git a/package.json b/package.json
index 57ee85b71..06af9045e 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
"start": "node ./streaming/index.js",
"test": "${npm_execpath} run test:lint:js && ${npm_execpath} run test:jest",
"test:lint": "${npm_execpath} run test:lint:js && ${npm_execpath} run test:lint:sass",
- "test:lint:js": "eslint --ext=js . --cache --report-unused-disable-directives",
+ "test:lint:js": "eslint --ext=.js,.jsx . --cache --report-unused-disable-directives",
"test:lint:sass": "stylelint \"**/*.{css,scss}\" && prettier --check \"**/*.{css,scss}\"",
"test:jest": "cross-env NODE_ENV=test jest",
"format": "prettier --write .",
--
cgit