From 30712c4886a6da7e0628fadbaa855d8c95a1536f Mon Sep 17 00:00:00 2001 From: Ember <me@ember-is.gay> Date: Thu, 18 Jan 2024 00:41:52 +1100 Subject: [PATCH] add blobfox theme --- .../flavours/blobfox/actions/account_notes.ts | 18 + .../flavours/blobfox/actions/accounts.js | 774 +++++++ .../blobfox/actions/accounts_typed.ts | 97 + .../flavours/blobfox/actions/alerts.js | 59 + .../flavours/blobfox/actions/announcements.js | 181 ++ .../flavours/blobfox/actions/app.ts | 9 + .../flavours/blobfox/actions/blocks.js | 100 + .../flavours/blobfox/actions/bookmarks.js | 91 + .../flavours/blobfox/actions/boosts.js | 32 + .../flavours/blobfox/actions/bundles.js | 25 + .../flavours/blobfox/actions/columns.js | 54 + .../flavours/blobfox/actions/compose.js | 840 ++++++++ .../flavours/blobfox/actions/conversations.js | 113 + .../flavours/blobfox/actions/custom_emojis.js | 40 + .../flavours/blobfox/actions/directory.js | 62 + .../flavours/blobfox/actions/domain_blocks.js | 152 ++ .../blobfox/actions/domain_blocks_typed.ts | 13 + .../flavours/blobfox/actions/dropdown_menu.ts | 11 + .../flavours/blobfox/actions/emojis.js | 14 + .../flavours/blobfox/actions/favourites.js | 94 + .../flavours/blobfox/actions/featured_tags.js | 34 + .../flavours/blobfox/actions/filters.js | 97 + .../flavours/blobfox/actions/height_cache.js | 17 + .../flavours/blobfox/actions/history.js | 38 + .../blobfox/actions/importer/index.js | 93 + .../blobfox/actions/importer/normalizer.js | 135 ++ .../flavours/blobfox/actions/interactions.js | 600 ++++++ .../flavours/blobfox/actions/languages.js | 12 + .../flavours/blobfox/actions/lists.js | 373 ++++ .../blobfox/actions/local_settings.js | 81 + .../flavours/blobfox/actions/markers.js | 152 ++ .../flavours/blobfox/actions/modal.ts | 19 + .../flavours/blobfox/actions/mutes.js | 117 + .../flavours/blobfox/actions/notifications.js | 404 ++++ .../blobfox/actions/notifications_typed.ts | 23 + .../flavours/blobfox/actions/onboarding.js | 8 + .../blobfox/actions/picture_in_picture.js | 46 + .../flavours/blobfox/actions/pin_statuses.js | 42 + .../flavours/blobfox/actions/polls.js | 61 + .../actions/push_notifications/index.js | 17 + .../actions/push_notifications/registerer.js | 134 ++ .../actions/push_notifications/setter.js | 34 + .../flavours/blobfox/actions/reports.js | 42 + .../flavours/blobfox/actions/search.js | 201 ++ .../flavours/blobfox/actions/server.js | 131 ++ .../flavours/blobfox/actions/settings.js | 36 + .../flavours/blobfox/actions/statuses.js | 351 +++ .../flavours/blobfox/actions/store.js | 43 + .../flavours/blobfox/actions/streaming.js | 184 ++ .../flavours/blobfox/actions/suggestions.js | 65 + .../flavours/blobfox/actions/tags.js | 172 ++ .../flavours/blobfox/actions/timelines.js | 235 ++ .../flavours/blobfox/actions/trends.js | 140 ++ app/javascript/flavours/blobfox/api.ts | 63 + .../flavours/blobfox/api_types/accounts.ts | 45 + .../blobfox/api_types/custom_emoji.ts | 8 + .../blobfox/api_types/relationships.ts | 18 + app/javascript/flavours/blobfox/blurhash.ts | 111 + app/javascript/flavours/blobfox/compare_id.ts | 11 + .../components/__tests__/hashtag_bar.tsx | 214 ++ .../flavours/blobfox/components/account.jsx | 182 ++ .../blobfox/components/admin/Counter.jsx | 121 ++ .../blobfox/components/admin/Dimension.jsx | 95 + .../blobfox/components/admin/ImpactReport.jsx | 91 + .../components/admin/ReportReasonSelector.jsx | 165 ++ .../blobfox/components/admin/Retention.jsx | 155 ++ .../blobfox/components/admin/Trends.jsx | 76 + .../blobfox/components/animated_number.tsx | 81 + .../blobfox/components/attachment_list.jsx | 51 + .../blobfox/components/autosuggest_emoji.jsx | 43 + .../components/autosuggest_hashtag.tsx | 42 + .../blobfox/components/autosuggest_input.jsx | 230 ++ .../components/autosuggest_textarea.jsx | 240 +++ .../flavours/blobfox/components/avatar.tsx | 49 + .../blobfox/components/avatar_composite.jsx | 106 + .../blobfox/components/avatar_overlay.tsx | 57 + .../flavours/blobfox/components/blurhash.tsx | 48 + .../flavours/blobfox/components/button.tsx | 58 + .../flavours/blobfox/components/check.tsx | 13 + .../blobfox/components/circular_progress.tsx | 27 + .../flavours/blobfox/components/column.jsx | 73 + .../blobfox/components/column_back_button.jsx | 63 + .../components/column_back_button_slim.jsx | 40 + .../blobfox/components/column_header.jsx | 219 ++ .../flavours/blobfox/components/counters.tsx | 45 + .../blobfox/components/dismissable_banner.tsx | 66 + .../blobfox/components/display_name.tsx | 127 ++ .../flavours/blobfox/components/domain.tsx | 44 + .../blobfox/components/dropdown_menu.jsx | 338 +++ .../containers/dropdown_menu_container.js | 32 + .../components/edited_timestamp/index.jsx | 77 + .../blobfox/components/empty_account.tsx | 33 + .../blobfox/components/error_boundary.jsx | 111 + .../flavours/blobfox/components/gifv.tsx | 70 + .../flavours/blobfox/components/hashtag.jsx | 123 ++ .../blobfox/components/hashtag_bar.tsx | 234 ++ .../flavours/blobfox/components/icon.tsx | 20 + .../blobfox/components/icon_button.tsx | 180 ++ .../blobfox/components/icon_with_badge.tsx | 24 + .../blobfox/components/inline_account.jsx | 37 + .../intersection_observer_article.jsx | 131 ++ .../flavours/blobfox/components/link.jsx | 97 + .../flavours/blobfox/components/load_gap.tsx | 34 + .../flavours/blobfox/components/load_more.tsx | 24 + .../blobfox/components/load_pending.tsx | 18 + .../blobfox/components/loading_indicator.tsx | 7 + .../flavours/blobfox/components/logo.jsx | 14 + .../blobfox/components/media_attachments.jsx | 128 ++ .../blobfox/components/media_gallery.jsx | 395 ++++ .../blobfox/components/modal_root.jsx | 165 ++ .../blobfox/components/navigation_portal.tsx | 25 + .../components/not_signed_in_indicator.tsx | 12 + .../components/notification_purge_buttons.jsx | 64 + .../flavours/blobfox/components/permalink.jsx | 50 + .../picture_in_picture_placeholder.jsx | 33 + .../flavours/blobfox/components/poll.jsx | 249 +++ .../blobfox/components/radio_button.tsx | 33 + .../components/regeneration_indicator.jsx | 18 + .../blobfox/components/relative_timestamp.tsx | 282 +++ .../flavours/blobfox/components/router.tsx | 80 + .../blobfox/components/scrollable_list.jsx | 405 ++++ .../blobfox/components/server_banner.jsx | 97 + .../blobfox/components/server_hero_image.tsx | 35 + .../blobfox/components/setting_text.jsx | 35 + .../blobfox/components/short_number.tsx | 90 + .../flavours/blobfox/components/skeleton.tsx | 10 + .../flavours/blobfox/components/status.jsx | 879 ++++++++ .../blobfox/components/status_action_bar.jsx | 371 ++++ .../blobfox/components/status_content.jsx | 491 +++++ .../blobfox/components/status_header.jsx | 71 + .../blobfox/components/status_icons.jsx | 147 ++ .../blobfox/components/status_list.jsx | 137 ++ .../blobfox/components/status_prepend.jsx | 158 ++ .../blobfox/components/status_reactions.jsx | 175 ++ .../components/status_visibility_icon.jsx | 54 + .../blobfox/components/timeline_hint.tsx | 25 + .../blobfox/components/verified_badge.tsx | 27 + .../blobfox/containers/account_container.jsx | 76 + .../blobfox/containers/admin_component.jsx | 22 + .../blobfox/containers/compose_container.jsx | 31 + .../blobfox/containers/domain_container.jsx | 36 + .../containers/dropdown_menu_container.js | 37 + ...intersection_observer_article_container.js | 18 + .../flavours/blobfox/containers/mastodon.jsx | 97 + .../blobfox/containers/media_container.jsx | 127 ++ .../notification_purge_buttons_container.js | 53 + .../blobfox/containers/poll_container.js | 26 + .../blobfox/containers/scroll_container.js | 18 + .../blobfox/containers/status_container.js | 313 +++ .../flavours/blobfox/features/about/index.jsx | 225 ++ .../account/components/account_note.jsx | 174 ++ .../account/components/action_bar.jsx | 85 + .../account/components/featured_tags.jsx | 52 + .../components/follow_request_note.jsx | 38 + .../features/account/components/header.jsx | 404 ++++ .../components/profile_column_header.jsx | 36 + .../containers/account_note_container.js | 19 + .../containers/featured_tags_container.js | 17 + .../follow_request_note_container.js | 17 + .../blobfox/features/account/navigation.jsx | 55 + .../account_gallery/components/media_item.jsx | 155 ++ .../features/account_gallery/index.jsx | 239 +++ .../account_timeline/components/header.jsx | 165 ++ .../components/limited_account_hint.tsx | 35 + .../components/memorial_note.jsx | 11 + .../components/moved_note.jsx | 52 + .../containers/header_container.jsx | 186 ++ .../features/account_timeline/index.jsx | 210 ++ .../flavours/blobfox/features/audio/index.jsx | 600 ++++++ .../blobfox/features/audio/visualizer.js | 136 ++ .../blobfox/features/blocks/index.jsx | 82 + .../features/bookmarked_statuses/index.jsx | 113 + .../closed_registrations_modal/index.jsx | 77 + .../components/column_settings.jsx | 45 + .../containers/column_settings_container.js | 29 + .../features/community_timeline/index.jsx | 166 ++ .../compose/components/action_bar.jsx | 73 + .../components/autosuggest_account.jsx | 24 + .../compose/components/character_counter.jsx | 26 + .../compose/components/compose_form.jsx | 356 ++++ .../features/compose/components/dropdown.jsx | 243 +++ .../compose/components/dropdown_menu.jsx | 200 ++ .../components/emoji_picker_dropdown.jsx | 422 ++++ .../features/compose/components/header.jsx | 134 ++ .../compose/components/language_dropdown.jsx | 334 +++ .../compose/components/navigation_bar.jsx | 54 + .../features/compose/components/options.jsx | 311 +++ .../features/compose/components/poll_form.jsx | 181 ++ .../compose/components/privacy_dropdown.jsx | 90 + .../features/compose/components/publisher.jsx | 92 + .../compose/components/reply_indicator.jsx | 70 + .../features/compose/components/search.jsx | 400 ++++ .../compose/components/search_results.jsx | 89 + .../compose/components/text_icon_button.jsx | 38 + .../compose/components/textarea_icons.jsx | 61 + .../features/compose/components/upload.jsx | 66 + .../compose/components/upload_form.jsx | 32 + .../compose/components/upload_progress.jsx | 56 + .../features/compose/components/warning.jsx | 28 + .../autosuggest_account_container.js | 16 + .../containers/compose_form_container.js | 150 ++ .../compose/containers/dropdown_container.js | 14 + .../emoji_picker_dropdown_container.js | 85 + .../compose/containers/header_container.js | 42 + .../containers/language_dropdown_container.js | 37 + .../containers/navigation_container.js | 36 + .../compose/containers/options_container.js | 56 + .../compose/containers/poll_form_container.js | 53 + .../containers/privacy_dropdown_container.js | 30 + .../containers/reply_indicator_container.js | 33 + .../compose/containers/search_container.js | 53 + .../containers/search_results_container.js | 20 + .../containers/sensitive_button_container.jsx | 77 + .../compose/containers/upload_container.js | 26 + .../containers/upload_form_container.js | 9 + .../containers/upload_progress_container.js | 11 + .../compose/containers/warning_container.jsx | 47 + .../blobfox/features/compose/index.jsx | 122 ++ .../blobfox/features/compose/util/counter.js | 9 + .../features/compose/util/url_regex.js | 30 + .../components/column_settings.jsx | 47 + .../components/conversation.jsx | 233 ++ .../components/conversations_list.jsx | 77 + .../containers/column_settings_container.js | 19 + .../containers/conversation_container.js | 81 + .../conversations_list_container.js | 16 + .../features/direct_timeline/index.jsx | 165 ++ .../directory/components/account_card.jsx | 255 +++ .../blobfox/features/directory/index.jsx | 179 ++ .../blobfox/features/domain_blocks/index.jsx | 87 + .../flavours/blobfox/features/emoji/emoji.js | 166 ++ .../features/emoji/emoji_compressed.d.ts | 57 + .../features/emoji/emoji_compressed.js | 135 ++ .../blobfox/features/emoji/emoji_map.json | 1 + .../features/emoji/emoji_mart_data_light.ts | 43 + .../features/emoji/emoji_mart_search_light.js | 185 ++ .../blobfox/features/emoji/emoji_picker.js | 7 + .../emoji/emoji_unicode_mapping_light.ts | 60 + .../blobfox/features/emoji/emoji_utils.js | 258 +++ .../features/emoji/unicode_to_filename.js | 29 + .../features/emoji/unicode_to_unified_name.js | 24 + .../explore/components/search_section.jsx | 20 + .../features/explore/components/story.jsx | 61 + .../blobfox/features/explore/index.jsx | 114 + .../blobfox/features/explore/links.jsx | 90 + .../blobfox/features/explore/results.jsx | 229 ++ .../blobfox/features/explore/statuses.jsx | 78 + .../blobfox/features/explore/suggestions.jsx | 70 + .../blobfox/features/explore/tags.jsx | 76 + .../features/favourited_statuses/index.jsx | 113 + .../blobfox/features/favourites/index.jsx | 114 + .../features/filters/added_to_filter.jsx | 106 + .../features/filters/select_filter.jsx | 196 ++ .../blobfox/features/firehose/index.jsx | 229 ++ .../components/account_authorize.jsx | 52 + .../containers/account_authorize_container.js | 27 + .../features/follow_requests/index.jsx | 96 + .../blobfox/features/followed_tags/index.jsx | 93 + .../blobfox/features/followers/index.jsx | 177 ++ .../blobfox/features/following/index.jsx | 177 ++ .../components/announcements.jsx | 455 ++++ .../getting_started/components/trends.jsx | 54 + .../containers/announcements_container.js | 22 + .../containers/trends_container.js | 15 + .../features/getting_started/index.jsx | 208 ++ .../features/getting_started_misc/index.jsx | 68 + .../components/column_settings.jsx | 138 ++ .../components/hashtag_header.jsx | 79 + .../containers/column_settings_container.js | 33 + .../features/hashtag_timeline/index.jsx | 226 ++ .../components/column_settings.tsx | 109 + .../components/critical_update_banner.tsx | 26 + .../components/explore_prompt.tsx | 46 + .../blobfox/features/home_timeline/index.jsx | 239 +++ .../features/interaction_modal/index.jsx | 417 ++++ .../features/keyboard_shortcuts/index.jsx | 152 ++ .../list_adder/components/account.jsx | 43 + .../features/list_adder/components/list.jsx | 72 + .../blobfox/features/list_adder/index.jsx | 76 + .../list_editor/components/account.jsx | 79 + .../list_editor/components/edit_list_form.jsx | 73 + .../list_editor/components/search.jsx | 81 + .../blobfox/features/list_editor/index.jsx | 83 + .../blobfox/features/list_timeline/index.jsx | 244 +++ .../lists/components/new_list_form.jsx | 81 + .../flavours/blobfox/features/lists/index.jsx | 93 + .../blobfox/features/local_settings/index.jsx | 70 + .../local_settings/navigation/index.jsx | 95 + .../local_settings/navigation/item/index.jsx | 73 + .../page/deprecated_item/index.jsx | 83 + .../features/local_settings/page/index.jsx | 505 +++++ .../local_settings/page/item/index.jsx | 118 ++ .../flavours/blobfox/features/mutes/index.jsx | 88 + .../notifications/components/admin_report.jsx | 115 + .../notifications/components/admin_signup.jsx | 108 + .../components/clear_column_button.jsx | 20 + .../components/column_settings.jsx | 218 ++ .../notifications/components/filter_bar.jsx | 121 ++ .../notifications/components/follow.jsx | 108 + .../components/follow_request.jsx | 141 ++ .../components/grant_permission_button.jsx | 20 + .../notifications/components/notification.jsx | 258 +++ .../notifications_permission_banner.jsx | 51 + .../notifications/components/overlay.jsx | 61 + .../components/pill_bar_button.jsx | 43 + .../notifications/components/report.jsx | 67 + .../components/setting_toggle.jsx | 38 + .../containers/admin_report_container.js | 15 + .../containers/column_settings_container.js | 78 + .../containers/filter_bar_container.js | 17 + .../containers/follow_request_container.js | 17 + .../containers/notification_container.js | 25 + .../containers/overlay_container.js | 19 + .../blobfox/features/notifications/index.jsx | 368 ++++ .../components/arrow_small_right.jsx | 7 + .../components/progress_indicator.jsx | 28 + .../features/onboarding/components/step.jsx | 50 + .../blobfox/features/onboarding/follows.jsx | 80 + .../blobfox/features/onboarding/index.jsx | 149 ++ .../blobfox/features/onboarding/share.jsx | 200 ++ .../picture_in_picture/components/footer.jsx | 231 ++ .../picture_in_picture/components/header.jsx | 50 + .../features/picture_in_picture/index.jsx | 93 + .../containers/account_container.js | 25 + .../containers/search_container.js | 24 + .../features/pinned_accounts_editor/index.jsx | 82 + .../features/pinned_statuses/index.jsx | 70 + .../blobfox/features/privacy_policy/index.jsx | 65 + .../components/column_settings.jsx | 46 + .../containers/column_settings_container.js | 29 + .../features/public_timeline/index.jsx | 171 ++ .../blobfox/features/reblogs/index.jsx | 115 + .../blobfox/features/report/category.jsx | 108 + .../blobfox/features/report/comment.jsx | 121 ++ .../features/report/components/option.jsx | 62 + .../report/components/status_check_box.jsx | 67 + .../containers/status_check_box_container.js | 17 + .../blobfox/features/report/rules.jsx | 69 + .../blobfox/features/report/statuses.jsx | 65 + .../blobfox/features/report/thanks.jsx | 88 + .../features/standalone/compose/index.jsx | 21 + .../features/status/components/action_bar.jsx | 267 +++ .../features/status/components/card.jsx | 256 +++ .../status/components/detailed_status.jsx | 362 ++++ .../containers/detailed_status_container.js | 176 ++ .../blobfox/features/status/index.jsx | 807 +++++++ .../subscribed_languages_modal/index.jsx | 127 ++ .../features/ui/components/actions_modal.jsx | 94 + .../features/ui/components/audio_modal.jsx | 61 + .../features/ui/components/block_modal.jsx | 100 + .../features/ui/components/boost_modal.jsx | 131 ++ .../blobfox/features/ui/components/bundle.jsx | 106 + .../ui/components/bundle_column_error.jsx | 166 ++ .../ui/components/bundle_modal_error.jsx | 54 + .../blobfox/features/ui/components/column.jsx | 78 + .../features/ui/components/column_header.jsx | 40 + .../features/ui/components/column_link.jsx | 57 + .../features/ui/components/column_loading.jsx | 32 + .../ui/components/column_subheading.jsx | 15 + .../features/ui/components/columns_area.jsx | 180 ++ .../ui/components/compare_history_modal.jsx | 110 + .../features/ui/components/compose_panel.jsx | 62 + .../ui/components/confirmation_modal.jsx | 83 + .../components/deprecated_settings_modal.jsx | 82 + .../ui/components/disabled_account_banner.jsx | 99 + .../features/ui/components/doodle_modal.jsx | 619 ++++++ .../features/ui/components/drawer_loading.jsx | 9 + .../features/ui/components/embed_modal.jsx | 100 + .../ui/components/favourite_modal.jsx | 94 + .../features/ui/components/filter_modal.jsx | 136 ++ .../ui/components/focal_point_modal.jsx | 426 ++++ .../follow_requests_column_link.jsx | 54 + .../blobfox/features/ui/components/header.jsx | 124 ++ .../features/ui/components/image_loader.jsx | 174 ++ .../features/ui/components/image_modal.jsx | 64 + .../features/ui/components/link_footer.jsx | 111 + .../features/ui/components/list_panel.jsx | 56 + .../features/ui/components/media_modal.jsx | 261 +++ .../features/ui/components/modal_loading.jsx | 18 + .../features/ui/components/modal_root.jsx | 142 ++ .../features/ui/components/mute_modal.jsx | 139 ++ .../ui/components/navigation_panel.jsx | 127 ++ .../components/notifications_counter_icon.js | 10 + .../features/ui/components/report_modal.jsx | 227 ++ .../features/ui/components/sign_in_banner.jsx | 54 + .../features/ui/components/upload_area.jsx | 55 + .../features/ui/components/video_modal.jsx | 74 + .../features/ui/components/zoomable_image.jsx | 456 ++++ .../ui/containers/bundle_container.js | 18 + .../ui/containers/columns_area_container.js | 22 + .../ui/containers/loading_bar_container.js | 9 + .../features/ui/containers/modal_container.js | 36 + .../ui/containers/notifications_container.js | 33 + .../ui/containers/status_list_container.js | 89 + .../flavours/blobfox/features/ui/index.jsx | 675 ++++++ .../features/ui/util/async-components.js | 203 ++ .../blobfox/features/ui/util/fullscreen.js | 46 + .../features/ui/util/get_rect_from_entry.js | 21 + .../ui/util/intersection_observer_wrapper.js | 57 + .../features/ui/util/optional_motion.js | 7 + .../features/ui/util/react_router_helpers.jsx | 105 + .../features/ui/util/reduced_motion.jsx | 45 + .../features/ui/util/schedule_idle_task.js | 29 + .../flavours/blobfox/features/video/index.jsx | 665 ++++++ .../flavours/blobfox/hooks/useHovering.ts | 17 + .../images/elephant_ui_disappointed.svg | 1 + .../blobfox/images/elephant_ui_working.svg | 1 + .../blobfox/images/glitch-preview.jpg | Bin 0 -> 197277 bytes .../blobfox/images/logo_warn_glitch.svg | 49 + .../flavours/blobfox/images/mbstobon-ui-0.png | Bin 0 -> 39646 bytes .../flavours/blobfox/images/mbstobon-ui-1.png | Bin 0 -> 43609 bytes .../flavours/blobfox/images/mbstobon-ui-2.png | Bin 0 -> 40376 bytes .../flavours/blobfox/images/mbstobon-ui-3.png | Bin 0 -> 32449 bytes .../blobfox/images/wave-drawer-glitched.png | Bin 0 -> 3544 bytes .../flavours/blobfox/images/wave-drawer.png | Bin 0 -> 3269 bytes .../flavours/blobfox/initial_state.js | 144 ++ app/javascript/flavours/blobfox/is_mobile.ts | 34 + .../blobfox/load_keyboard_extensions.js | 16 + app/javascript/flavours/blobfox/main.jsx | 47 + .../flavours/blobfox/models/account.ts | 149 ++ .../flavours/blobfox/models/custom_emoji.ts | 15 + .../flavours/blobfox/models/relationship.ts | 29 + app/javascript/flavours/blobfox/names.yml | 40 + .../flavours/blobfox/packs/admin.jsx | 25 + .../flavours/blobfox/packs/common.js | 8 + .../flavours/blobfox/packs/error.js | 14 + app/javascript/flavours/blobfox/packs/home.js | 11 + .../flavours/blobfox/packs/public.jsx | 242 +++ .../flavours/blobfox/packs/settings.js | 42 + .../flavours/blobfox/packs/share.jsx | 27 + .../flavours/blobfox/packs/sign_up.js | 42 + .../flavours/blobfox/performance.js | 30 + .../flavours/blobfox/permissions.ts | 4 + .../blobfox/polyfills/extra_polyfills.ts | 1 + .../flavours/blobfox/polyfills/index.ts | 21 + .../flavours/blobfox/polyfills/intl.ts | 106 + app/javascript/flavours/blobfox/ready.js | 32 + .../flavours/blobfox/reducers/accounts.ts | 84 + .../flavours/blobfox/reducers/accounts_map.js | 24 + .../flavours/blobfox/reducers/alerts.js | 30 + .../blobfox/reducers/announcements.js | 103 + .../flavours/blobfox/reducers/blocks.js | 22 + .../flavours/blobfox/reducers/boosts.js | 25 + .../flavours/blobfox/reducers/compose.js | 660 ++++++ .../flavours/blobfox/reducers/contexts.js | 107 + .../blobfox/reducers/conversations.js | 118 ++ .../blobfox/reducers/custom_emojis.js | 16 + .../flavours/blobfox/reducers/domain_lists.js | 26 + .../blobfox/reducers/dropdown_menu.ts | 33 + .../flavours/blobfox/reducers/filters.js | 45 + .../blobfox/reducers/followed_tags.js | 43 + .../flavours/blobfox/reducers/height_cache.js | 24 + .../flavours/blobfox/reducers/history.js | 29 + .../flavours/blobfox/reducers/index.ts | 111 + .../flavours/blobfox/reducers/list_adder.js | 48 + .../flavours/blobfox/reducers/list_editor.js | 99 + .../flavours/blobfox/reducers/lists.js | 38 + .../blobfox/reducers/local_settings.js | 79 + .../flavours/blobfox/reducers/markers.js | 26 + .../blobfox/reducers/media_attachments.js | 16 + .../flavours/blobfox/reducers/meta.js | 25 + .../flavours/blobfox/reducers/modal.ts | 83 + .../flavours/blobfox/reducers/mutes.js | 31 + .../blobfox/reducers/notifications.js | 376 ++++ .../blobfox/reducers/picture_in_picture.js | 26 + .../reducers/pinned_accounts_editor.js | 58 + .../flavours/blobfox/reducers/polls.js | 45 + .../blobfox/reducers/push_notifications.js | 54 + .../blobfox/reducers/relationships.ts | 123 ++ .../flavours/blobfox/reducers/search.js | 82 + .../flavours/blobfox/reducers/server.js | 63 + .../flavours/blobfox/reducers/settings.js | 203 ++ .../flavours/blobfox/reducers/status_lists.js | 151 ++ .../flavours/blobfox/reducers/statuses.js | 191 ++ .../flavours/blobfox/reducers/suggestions.js | 40 + .../flavours/blobfox/reducers/tags.js | 26 + .../flavours/blobfox/reducers/timelines.js | 233 ++ .../flavours/blobfox/reducers/trends.js | 47 + .../flavours/blobfox/reducers/user_lists.js | 216 ++ app/javascript/flavours/blobfox/scroll.ts | 50 + .../flavours/blobfox/selectors/accounts.ts | 47 + .../flavours/blobfox/selectors/index.js | 118 ++ app/javascript/flavours/blobfox/settings.js | 50 + .../flavours/blobfox/store/index.ts | 8 + .../blobfox/store/middlewares/errors.ts | 21 + .../blobfox/store/middlewares/loading_bar.ts | 69 + .../blobfox/store/middlewares/sounds.ts | 64 + .../flavours/blobfox/store/store.ts | 39 + .../flavours/blobfox/store/typed_functions.ts | 15 + app/javascript/flavours/blobfox/stream.js | 275 +++ .../flavours/blobfox/styles/_mixins.scss | 97 + .../flavours/blobfox/styles/about.scss | 56 + .../blobfox/styles/accessibility.scss | 55 + .../flavours/blobfox/styles/accounts.scss | 381 ++++ .../flavours/blobfox/styles/admin.scss | 1884 +++++++++++++++++ .../flavours/blobfox/styles/basics.scss | 294 +++ .../flavours/blobfox/styles/branding.scss | 3 + .../blobfox/styles/components/about.scss | 295 +++ .../blobfox/styles/components/accounts.scss | 807 +++++++ .../styles/components/announcements.scss | 233 ++ .../blobfox/styles/components/boost.scss | 44 + .../blobfox/styles/components/columns.scss | 1364 ++++++++++++ .../styles/components/compose_form.scss | 685 ++++++ .../blobfox/styles/components/directory.scss | 68 + .../blobfox/styles/components/domains.scss | 23 + .../blobfox/styles/components/doodle.scss | 86 + .../blobfox/styles/components/drawer.scss | 298 +++ .../blobfox/styles/components/emoji.scss | 104 + .../styles/components/emoji_picker.scss | 261 +++ .../blobfox/styles/components/explore.scss | 147 ++ .../blobfox/styles/components/index.scss | 25 + .../blobfox/styles/components/lists.scss | 94 + .../styles/components/local_settings.scss | 173 ++ .../blobfox/styles/components/media.scss | 795 +++++++ .../blobfox/styles/components/misc.scss | 1740 +++++++++++++++ .../blobfox/styles/components/modal.scss | 1502 +++++++++++++ .../styles/components/privacy_policy.scss | 209 ++ .../components/regeneration_indicator.scss | 43 + .../blobfox/styles/components/search.scss | 337 +++ .../blobfox/styles/components/sensitive.scss | 26 + .../blobfox/styles/components/signed_out.scss | 110 + .../styles/components/single_column.scss | 335 +++ .../blobfox/styles/components/status.scss | 1212 +++++++++++ .../flavours/blobfox/styles/containers.scss | 109 + .../flavours/blobfox/styles/contrast.scss | 3 + .../blobfox/styles/contrast/diff.scss | 79 + .../blobfox/styles/contrast/variables.scss | 22 + .../flavours/blobfox/styles/dashboard.scss | 123 ++ .../flavours/blobfox/styles/forms.scss | 1224 +++++++++++ .../flavours/blobfox/styles/index.scss | 24 + .../flavours/blobfox/styles/lists.scss | 19 + .../blobfox/styles/mastodon-light.scss | 3 + .../blobfox/styles/mastodon-light/diff.scss | 743 +++++++ .../styles/mastodon-light/variables.scss | 57 + .../flavours/blobfox/styles/modal.scss | 37 + .../flavours/blobfox/styles/polls.scss | 314 +++ .../flavours/blobfox/styles/reset.scss | 95 + .../flavours/blobfox/styles/rich_text.scss | 99 + .../flavours/blobfox/styles/rtl.scss | 123 ++ .../flavours/blobfox/styles/statuses.scss | 232 ++ .../flavours/blobfox/styles/tables.scss | 376 ++++ .../flavours/blobfox/styles/variables.scss | 103 + .../flavours/blobfox/styles/widgets.scss | 402 ++++ app/javascript/flavours/blobfox/theme.yml | 48 + app/javascript/flavours/blobfox/types/util.ts | 1 + .../flavours/blobfox/utils/backend_links.js | 18 + .../flavours/blobfox/utils/base64.ts | 10 + .../flavours/blobfox/utils/config.js | 10 + .../flavours/blobfox/utils/content_warning.js | 31 + .../flavours/blobfox/utils/filters.ts | 16 + .../flavours/blobfox/utils/hashtag.js | 8 + .../flavours/blobfox/utils/hashtags.ts | 29 + app/javascript/flavours/blobfox/utils/html.js | 6 + .../flavours/blobfox/utils/icons.jsx | 13 + app/javascript/flavours/blobfox/utils/idna.js | 10 + .../flavours/blobfox/utils/js_helpers.js | 5 + .../flavours/blobfox/utils/log_out.js | 35 + .../flavours/blobfox/utils/notifications.js | 30 + .../flavours/blobfox/utils/numbers.ts | 71 + .../blobfox/utils/privacy_preference.js | 5 + .../flavours/blobfox/utils/react_helpers.js | 21 + .../flavours/blobfox/utils/react_router.jsx | 61 + .../flavours/blobfox/utils/resize_image.js | 191 ++ .../flavours/blobfox/utils/scrollbar.js | 34 + app/javascript/flavours/blobfox/uuid.ts | 8 + .../skins/blobfox/contrast/common.scss | 1 + .../skins/blobfox/contrast/names.yml | 12 + .../skins/blobfox/mastodon-light/common.scss | 1 + .../skins/blobfox/mastodon-light/names.yml | 12 + .../blobfox/queens-pink-contrast/common.scss | 3 + .../blobfox/queens-pink-contrast/diff.scss | 381 ++++ .../blobfox/queens-pink-contrast/names.yml | 4 + .../queens-pink-contrast/screenshot.jpg | Bin 0 -> 173565 bytes .../queens-pink-contrast/variables.scss | 81 + .../skins/blobfox/solarpunk/common.scss | 3 + .../skins/blobfox/solarpunk/diff.scss | 143 ++ .../skins/blobfox/solarpunk/names.yml | 4 + .../skins/blobfox/solarpunk/variables.scss | 75 + 578 files changed, 71749 insertions(+) create mode 100644 app/javascript/flavours/blobfox/actions/account_notes.ts create mode 100644 app/javascript/flavours/blobfox/actions/accounts.js create mode 100644 app/javascript/flavours/blobfox/actions/accounts_typed.ts create mode 100644 app/javascript/flavours/blobfox/actions/alerts.js create mode 100644 app/javascript/flavours/blobfox/actions/announcements.js create mode 100644 app/javascript/flavours/blobfox/actions/app.ts create mode 100644 app/javascript/flavours/blobfox/actions/blocks.js create mode 100644 app/javascript/flavours/blobfox/actions/bookmarks.js create mode 100644 app/javascript/flavours/blobfox/actions/boosts.js create mode 100644 app/javascript/flavours/blobfox/actions/bundles.js create mode 100644 app/javascript/flavours/blobfox/actions/columns.js create mode 100644 app/javascript/flavours/blobfox/actions/compose.js create mode 100644 app/javascript/flavours/blobfox/actions/conversations.js create mode 100644 app/javascript/flavours/blobfox/actions/custom_emojis.js create mode 100644 app/javascript/flavours/blobfox/actions/directory.js create mode 100644 app/javascript/flavours/blobfox/actions/domain_blocks.js create mode 100644 app/javascript/flavours/blobfox/actions/domain_blocks_typed.ts create mode 100644 app/javascript/flavours/blobfox/actions/dropdown_menu.ts create mode 100644 app/javascript/flavours/blobfox/actions/emojis.js create mode 100644 app/javascript/flavours/blobfox/actions/favourites.js create mode 100644 app/javascript/flavours/blobfox/actions/featured_tags.js create mode 100644 app/javascript/flavours/blobfox/actions/filters.js create mode 100644 app/javascript/flavours/blobfox/actions/height_cache.js create mode 100644 app/javascript/flavours/blobfox/actions/history.js create mode 100644 app/javascript/flavours/blobfox/actions/importer/index.js create mode 100644 app/javascript/flavours/blobfox/actions/importer/normalizer.js create mode 100644 app/javascript/flavours/blobfox/actions/interactions.js create mode 100644 app/javascript/flavours/blobfox/actions/languages.js create mode 100644 app/javascript/flavours/blobfox/actions/lists.js create mode 100644 app/javascript/flavours/blobfox/actions/local_settings.js create mode 100644 app/javascript/flavours/blobfox/actions/markers.js create mode 100644 app/javascript/flavours/blobfox/actions/modal.ts create mode 100644 app/javascript/flavours/blobfox/actions/mutes.js create mode 100644 app/javascript/flavours/blobfox/actions/notifications.js create mode 100644 app/javascript/flavours/blobfox/actions/notifications_typed.ts create mode 100644 app/javascript/flavours/blobfox/actions/onboarding.js create mode 100644 app/javascript/flavours/blobfox/actions/picture_in_picture.js create mode 100644 app/javascript/flavours/blobfox/actions/pin_statuses.js create mode 100644 app/javascript/flavours/blobfox/actions/polls.js create mode 100644 app/javascript/flavours/blobfox/actions/push_notifications/index.js create mode 100644 app/javascript/flavours/blobfox/actions/push_notifications/registerer.js create mode 100644 app/javascript/flavours/blobfox/actions/push_notifications/setter.js create mode 100644 app/javascript/flavours/blobfox/actions/reports.js create mode 100644 app/javascript/flavours/blobfox/actions/search.js create mode 100644 app/javascript/flavours/blobfox/actions/server.js create mode 100644 app/javascript/flavours/blobfox/actions/settings.js create mode 100644 app/javascript/flavours/blobfox/actions/statuses.js create mode 100644 app/javascript/flavours/blobfox/actions/store.js create mode 100644 app/javascript/flavours/blobfox/actions/streaming.js create mode 100644 app/javascript/flavours/blobfox/actions/suggestions.js create mode 100644 app/javascript/flavours/blobfox/actions/tags.js create mode 100644 app/javascript/flavours/blobfox/actions/timelines.js create mode 100644 app/javascript/flavours/blobfox/actions/trends.js create mode 100644 app/javascript/flavours/blobfox/api.ts create mode 100644 app/javascript/flavours/blobfox/api_types/accounts.ts create mode 100644 app/javascript/flavours/blobfox/api_types/custom_emoji.ts create mode 100644 app/javascript/flavours/blobfox/api_types/relationships.ts create mode 100644 app/javascript/flavours/blobfox/blurhash.ts create mode 100644 app/javascript/flavours/blobfox/compare_id.ts create mode 100644 app/javascript/flavours/blobfox/components/__tests__/hashtag_bar.tsx create mode 100644 app/javascript/flavours/blobfox/components/account.jsx create mode 100644 app/javascript/flavours/blobfox/components/admin/Counter.jsx create mode 100644 app/javascript/flavours/blobfox/components/admin/Dimension.jsx create mode 100644 app/javascript/flavours/blobfox/components/admin/ImpactReport.jsx create mode 100644 app/javascript/flavours/blobfox/components/admin/ReportReasonSelector.jsx create mode 100644 app/javascript/flavours/blobfox/components/admin/Retention.jsx create mode 100644 app/javascript/flavours/blobfox/components/admin/Trends.jsx create mode 100644 app/javascript/flavours/blobfox/components/animated_number.tsx create mode 100644 app/javascript/flavours/blobfox/components/attachment_list.jsx create mode 100644 app/javascript/flavours/blobfox/components/autosuggest_emoji.jsx create mode 100644 app/javascript/flavours/blobfox/components/autosuggest_hashtag.tsx create mode 100644 app/javascript/flavours/blobfox/components/autosuggest_input.jsx create mode 100644 app/javascript/flavours/blobfox/components/autosuggest_textarea.jsx create mode 100644 app/javascript/flavours/blobfox/components/avatar.tsx create mode 100644 app/javascript/flavours/blobfox/components/avatar_composite.jsx create mode 100644 app/javascript/flavours/blobfox/components/avatar_overlay.tsx create mode 100644 app/javascript/flavours/blobfox/components/blurhash.tsx create mode 100644 app/javascript/flavours/blobfox/components/button.tsx create mode 100644 app/javascript/flavours/blobfox/components/check.tsx create mode 100644 app/javascript/flavours/blobfox/components/circular_progress.tsx create mode 100644 app/javascript/flavours/blobfox/components/column.jsx create mode 100644 app/javascript/flavours/blobfox/components/column_back_button.jsx create mode 100644 app/javascript/flavours/blobfox/components/column_back_button_slim.jsx create mode 100644 app/javascript/flavours/blobfox/components/column_header.jsx create mode 100644 app/javascript/flavours/blobfox/components/counters.tsx create mode 100644 app/javascript/flavours/blobfox/components/dismissable_banner.tsx create mode 100644 app/javascript/flavours/blobfox/components/display_name.tsx create mode 100644 app/javascript/flavours/blobfox/components/domain.tsx create mode 100644 app/javascript/flavours/blobfox/components/dropdown_menu.jsx create mode 100644 app/javascript/flavours/blobfox/components/edited_timestamp/containers/dropdown_menu_container.js create mode 100644 app/javascript/flavours/blobfox/components/edited_timestamp/index.jsx create mode 100644 app/javascript/flavours/blobfox/components/empty_account.tsx create mode 100644 app/javascript/flavours/blobfox/components/error_boundary.jsx create mode 100644 app/javascript/flavours/blobfox/components/gifv.tsx create mode 100644 app/javascript/flavours/blobfox/components/hashtag.jsx create mode 100644 app/javascript/flavours/blobfox/components/hashtag_bar.tsx create mode 100644 app/javascript/flavours/blobfox/components/icon.tsx create mode 100644 app/javascript/flavours/blobfox/components/icon_button.tsx create mode 100644 app/javascript/flavours/blobfox/components/icon_with_badge.tsx create mode 100644 app/javascript/flavours/blobfox/components/inline_account.jsx create mode 100644 app/javascript/flavours/blobfox/components/intersection_observer_article.jsx create mode 100644 app/javascript/flavours/blobfox/components/link.jsx create mode 100644 app/javascript/flavours/blobfox/components/load_gap.tsx create mode 100644 app/javascript/flavours/blobfox/components/load_more.tsx create mode 100644 app/javascript/flavours/blobfox/components/load_pending.tsx create mode 100644 app/javascript/flavours/blobfox/components/loading_indicator.tsx create mode 100644 app/javascript/flavours/blobfox/components/logo.jsx create mode 100644 app/javascript/flavours/blobfox/components/media_attachments.jsx create mode 100644 app/javascript/flavours/blobfox/components/media_gallery.jsx create mode 100644 app/javascript/flavours/blobfox/components/modal_root.jsx create mode 100644 app/javascript/flavours/blobfox/components/navigation_portal.tsx create mode 100644 app/javascript/flavours/blobfox/components/not_signed_in_indicator.tsx create mode 100644 app/javascript/flavours/blobfox/components/notification_purge_buttons.jsx create mode 100644 app/javascript/flavours/blobfox/components/permalink.jsx create mode 100644 app/javascript/flavours/blobfox/components/picture_in_picture_placeholder.jsx create mode 100644 app/javascript/flavours/blobfox/components/poll.jsx create mode 100644 app/javascript/flavours/blobfox/components/radio_button.tsx create mode 100644 app/javascript/flavours/blobfox/components/regeneration_indicator.jsx create mode 100644 app/javascript/flavours/blobfox/components/relative_timestamp.tsx create mode 100644 app/javascript/flavours/blobfox/components/router.tsx create mode 100644 app/javascript/flavours/blobfox/components/scrollable_list.jsx create mode 100644 app/javascript/flavours/blobfox/components/server_banner.jsx create mode 100644 app/javascript/flavours/blobfox/components/server_hero_image.tsx create mode 100644 app/javascript/flavours/blobfox/components/setting_text.jsx create mode 100644 app/javascript/flavours/blobfox/components/short_number.tsx create mode 100644 app/javascript/flavours/blobfox/components/skeleton.tsx create mode 100644 app/javascript/flavours/blobfox/components/status.jsx create mode 100644 app/javascript/flavours/blobfox/components/status_action_bar.jsx create mode 100644 app/javascript/flavours/blobfox/components/status_content.jsx create mode 100644 app/javascript/flavours/blobfox/components/status_header.jsx create mode 100644 app/javascript/flavours/blobfox/components/status_icons.jsx create mode 100644 app/javascript/flavours/blobfox/components/status_list.jsx create mode 100644 app/javascript/flavours/blobfox/components/status_prepend.jsx create mode 100644 app/javascript/flavours/blobfox/components/status_reactions.jsx create mode 100644 app/javascript/flavours/blobfox/components/status_visibility_icon.jsx create mode 100644 app/javascript/flavours/blobfox/components/timeline_hint.tsx create mode 100644 app/javascript/flavours/blobfox/components/verified_badge.tsx create mode 100644 app/javascript/flavours/blobfox/containers/account_container.jsx create mode 100644 app/javascript/flavours/blobfox/containers/admin_component.jsx create mode 100644 app/javascript/flavours/blobfox/containers/compose_container.jsx create mode 100644 app/javascript/flavours/blobfox/containers/domain_container.jsx create mode 100644 app/javascript/flavours/blobfox/containers/dropdown_menu_container.js create mode 100644 app/javascript/flavours/blobfox/containers/intersection_observer_article_container.js create mode 100644 app/javascript/flavours/blobfox/containers/mastodon.jsx create mode 100644 app/javascript/flavours/blobfox/containers/media_container.jsx create mode 100644 app/javascript/flavours/blobfox/containers/notification_purge_buttons_container.js create mode 100644 app/javascript/flavours/blobfox/containers/poll_container.js create mode 100644 app/javascript/flavours/blobfox/containers/scroll_container.js create mode 100644 app/javascript/flavours/blobfox/containers/status_container.js create mode 100644 app/javascript/flavours/blobfox/features/about/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/account/components/account_note.jsx create mode 100644 app/javascript/flavours/blobfox/features/account/components/action_bar.jsx create mode 100644 app/javascript/flavours/blobfox/features/account/components/featured_tags.jsx create mode 100644 app/javascript/flavours/blobfox/features/account/components/follow_request_note.jsx create mode 100644 app/javascript/flavours/blobfox/features/account/components/header.jsx create mode 100644 app/javascript/flavours/blobfox/features/account/components/profile_column_header.jsx create mode 100644 app/javascript/flavours/blobfox/features/account/containers/account_note_container.js create mode 100644 app/javascript/flavours/blobfox/features/account/containers/featured_tags_container.js create mode 100644 app/javascript/flavours/blobfox/features/account/containers/follow_request_note_container.js create mode 100644 app/javascript/flavours/blobfox/features/account/navigation.jsx create mode 100644 app/javascript/flavours/blobfox/features/account_gallery/components/media_item.jsx create mode 100644 app/javascript/flavours/blobfox/features/account_gallery/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/account_timeline/components/header.jsx create mode 100644 app/javascript/flavours/blobfox/features/account_timeline/components/limited_account_hint.tsx create mode 100644 app/javascript/flavours/blobfox/features/account_timeline/components/memorial_note.jsx create mode 100644 app/javascript/flavours/blobfox/features/account_timeline/components/moved_note.jsx create mode 100644 app/javascript/flavours/blobfox/features/account_timeline/containers/header_container.jsx create mode 100644 app/javascript/flavours/blobfox/features/account_timeline/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/audio/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/audio/visualizer.js create mode 100644 app/javascript/flavours/blobfox/features/blocks/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/bookmarked_statuses/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/closed_registrations_modal/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/community_timeline/components/column_settings.jsx create mode 100644 app/javascript/flavours/blobfox/features/community_timeline/containers/column_settings_container.js create mode 100644 app/javascript/flavours/blobfox/features/community_timeline/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/components/action_bar.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/components/autosuggest_account.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/components/character_counter.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/components/compose_form.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/components/dropdown.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/components/dropdown_menu.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/components/emoji_picker_dropdown.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/components/header.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/components/language_dropdown.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/components/navigation_bar.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/components/options.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/components/poll_form.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/components/privacy_dropdown.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/components/publisher.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/components/reply_indicator.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/components/search.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/components/search_results.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/components/text_icon_button.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/components/textarea_icons.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/components/upload.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/components/upload_form.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/components/upload_progress.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/components/warning.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/containers/autosuggest_account_container.js create mode 100644 app/javascript/flavours/blobfox/features/compose/containers/compose_form_container.js create mode 100644 app/javascript/flavours/blobfox/features/compose/containers/dropdown_container.js create mode 100644 app/javascript/flavours/blobfox/features/compose/containers/emoji_picker_dropdown_container.js create mode 100644 app/javascript/flavours/blobfox/features/compose/containers/header_container.js create mode 100644 app/javascript/flavours/blobfox/features/compose/containers/language_dropdown_container.js create mode 100644 app/javascript/flavours/blobfox/features/compose/containers/navigation_container.js create mode 100644 app/javascript/flavours/blobfox/features/compose/containers/options_container.js create mode 100644 app/javascript/flavours/blobfox/features/compose/containers/poll_form_container.js create mode 100644 app/javascript/flavours/blobfox/features/compose/containers/privacy_dropdown_container.js create mode 100644 app/javascript/flavours/blobfox/features/compose/containers/reply_indicator_container.js create mode 100644 app/javascript/flavours/blobfox/features/compose/containers/search_container.js create mode 100644 app/javascript/flavours/blobfox/features/compose/containers/search_results_container.js create mode 100644 app/javascript/flavours/blobfox/features/compose/containers/sensitive_button_container.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/containers/upload_container.js create mode 100644 app/javascript/flavours/blobfox/features/compose/containers/upload_form_container.js create mode 100644 app/javascript/flavours/blobfox/features/compose/containers/upload_progress_container.js create mode 100644 app/javascript/flavours/blobfox/features/compose/containers/warning_container.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/compose/util/counter.js create mode 100644 app/javascript/flavours/blobfox/features/compose/util/url_regex.js create mode 100644 app/javascript/flavours/blobfox/features/direct_timeline/components/column_settings.jsx create mode 100644 app/javascript/flavours/blobfox/features/direct_timeline/components/conversation.jsx create mode 100644 app/javascript/flavours/blobfox/features/direct_timeline/components/conversations_list.jsx create mode 100644 app/javascript/flavours/blobfox/features/direct_timeline/containers/column_settings_container.js create mode 100644 app/javascript/flavours/blobfox/features/direct_timeline/containers/conversation_container.js create mode 100644 app/javascript/flavours/blobfox/features/direct_timeline/containers/conversations_list_container.js create mode 100644 app/javascript/flavours/blobfox/features/direct_timeline/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/directory/components/account_card.jsx create mode 100644 app/javascript/flavours/blobfox/features/directory/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/domain_blocks/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/emoji/emoji.js create mode 100644 app/javascript/flavours/blobfox/features/emoji/emoji_compressed.d.ts create mode 100644 app/javascript/flavours/blobfox/features/emoji/emoji_compressed.js create mode 100644 app/javascript/flavours/blobfox/features/emoji/emoji_map.json create mode 100644 app/javascript/flavours/blobfox/features/emoji/emoji_mart_data_light.ts create mode 100644 app/javascript/flavours/blobfox/features/emoji/emoji_mart_search_light.js create mode 100644 app/javascript/flavours/blobfox/features/emoji/emoji_picker.js create mode 100644 app/javascript/flavours/blobfox/features/emoji/emoji_unicode_mapping_light.ts create mode 100644 app/javascript/flavours/blobfox/features/emoji/emoji_utils.js create mode 100644 app/javascript/flavours/blobfox/features/emoji/unicode_to_filename.js create mode 100644 app/javascript/flavours/blobfox/features/emoji/unicode_to_unified_name.js create mode 100644 app/javascript/flavours/blobfox/features/explore/components/search_section.jsx create mode 100644 app/javascript/flavours/blobfox/features/explore/components/story.jsx create mode 100644 app/javascript/flavours/blobfox/features/explore/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/explore/links.jsx create mode 100644 app/javascript/flavours/blobfox/features/explore/results.jsx create mode 100644 app/javascript/flavours/blobfox/features/explore/statuses.jsx create mode 100644 app/javascript/flavours/blobfox/features/explore/suggestions.jsx create mode 100644 app/javascript/flavours/blobfox/features/explore/tags.jsx create mode 100644 app/javascript/flavours/blobfox/features/favourited_statuses/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/favourites/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/filters/added_to_filter.jsx create mode 100644 app/javascript/flavours/blobfox/features/filters/select_filter.jsx create mode 100644 app/javascript/flavours/blobfox/features/firehose/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/follow_requests/components/account_authorize.jsx create mode 100644 app/javascript/flavours/blobfox/features/follow_requests/containers/account_authorize_container.js create mode 100644 app/javascript/flavours/blobfox/features/follow_requests/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/followed_tags/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/followers/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/following/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/getting_started/components/announcements.jsx create mode 100644 app/javascript/flavours/blobfox/features/getting_started/components/trends.jsx create mode 100644 app/javascript/flavours/blobfox/features/getting_started/containers/announcements_container.js create mode 100644 app/javascript/flavours/blobfox/features/getting_started/containers/trends_container.js create mode 100644 app/javascript/flavours/blobfox/features/getting_started/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/getting_started_misc/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/hashtag_timeline/components/column_settings.jsx create mode 100644 app/javascript/flavours/blobfox/features/hashtag_timeline/components/hashtag_header.jsx create mode 100644 app/javascript/flavours/blobfox/features/hashtag_timeline/containers/column_settings_container.js create mode 100644 app/javascript/flavours/blobfox/features/hashtag_timeline/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/home_timeline/components/column_settings.tsx create mode 100644 app/javascript/flavours/blobfox/features/home_timeline/components/critical_update_banner.tsx create mode 100644 app/javascript/flavours/blobfox/features/home_timeline/components/explore_prompt.tsx create mode 100644 app/javascript/flavours/blobfox/features/home_timeline/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/interaction_modal/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/keyboard_shortcuts/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/list_adder/components/account.jsx create mode 100644 app/javascript/flavours/blobfox/features/list_adder/components/list.jsx create mode 100644 app/javascript/flavours/blobfox/features/list_adder/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/list_editor/components/account.jsx create mode 100644 app/javascript/flavours/blobfox/features/list_editor/components/edit_list_form.jsx create mode 100644 app/javascript/flavours/blobfox/features/list_editor/components/search.jsx create mode 100644 app/javascript/flavours/blobfox/features/list_editor/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/list_timeline/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/lists/components/new_list_form.jsx create mode 100644 app/javascript/flavours/blobfox/features/lists/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/local_settings/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/local_settings/navigation/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/local_settings/navigation/item/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/local_settings/page/deprecated_item/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/local_settings/page/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/local_settings/page/item/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/mutes/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/notifications/components/admin_report.jsx create mode 100644 app/javascript/flavours/blobfox/features/notifications/components/admin_signup.jsx create mode 100644 app/javascript/flavours/blobfox/features/notifications/components/clear_column_button.jsx create mode 100644 app/javascript/flavours/blobfox/features/notifications/components/column_settings.jsx create mode 100644 app/javascript/flavours/blobfox/features/notifications/components/filter_bar.jsx create mode 100644 app/javascript/flavours/blobfox/features/notifications/components/follow.jsx create mode 100644 app/javascript/flavours/blobfox/features/notifications/components/follow_request.jsx create mode 100644 app/javascript/flavours/blobfox/features/notifications/components/grant_permission_button.jsx create mode 100644 app/javascript/flavours/blobfox/features/notifications/components/notification.jsx create mode 100644 app/javascript/flavours/blobfox/features/notifications/components/notifications_permission_banner.jsx create mode 100644 app/javascript/flavours/blobfox/features/notifications/components/overlay.jsx create mode 100644 app/javascript/flavours/blobfox/features/notifications/components/pill_bar_button.jsx create mode 100644 app/javascript/flavours/blobfox/features/notifications/components/report.jsx create mode 100644 app/javascript/flavours/blobfox/features/notifications/components/setting_toggle.jsx create mode 100644 app/javascript/flavours/blobfox/features/notifications/containers/admin_report_container.js create mode 100644 app/javascript/flavours/blobfox/features/notifications/containers/column_settings_container.js create mode 100644 app/javascript/flavours/blobfox/features/notifications/containers/filter_bar_container.js create mode 100644 app/javascript/flavours/blobfox/features/notifications/containers/follow_request_container.js create mode 100644 app/javascript/flavours/blobfox/features/notifications/containers/notification_container.js create mode 100644 app/javascript/flavours/blobfox/features/notifications/containers/overlay_container.js create mode 100644 app/javascript/flavours/blobfox/features/notifications/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/onboarding/components/arrow_small_right.jsx create mode 100644 app/javascript/flavours/blobfox/features/onboarding/components/progress_indicator.jsx create mode 100644 app/javascript/flavours/blobfox/features/onboarding/components/step.jsx create mode 100644 app/javascript/flavours/blobfox/features/onboarding/follows.jsx create mode 100644 app/javascript/flavours/blobfox/features/onboarding/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/onboarding/share.jsx create mode 100644 app/javascript/flavours/blobfox/features/picture_in_picture/components/footer.jsx create mode 100644 app/javascript/flavours/blobfox/features/picture_in_picture/components/header.jsx create mode 100644 app/javascript/flavours/blobfox/features/picture_in_picture/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/pinned_accounts_editor/containers/account_container.js create mode 100644 app/javascript/flavours/blobfox/features/pinned_accounts_editor/containers/search_container.js create mode 100644 app/javascript/flavours/blobfox/features/pinned_accounts_editor/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/pinned_statuses/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/privacy_policy/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/public_timeline/components/column_settings.jsx create mode 100644 app/javascript/flavours/blobfox/features/public_timeline/containers/column_settings_container.js create mode 100644 app/javascript/flavours/blobfox/features/public_timeline/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/reblogs/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/report/category.jsx create mode 100644 app/javascript/flavours/blobfox/features/report/comment.jsx create mode 100644 app/javascript/flavours/blobfox/features/report/components/option.jsx create mode 100644 app/javascript/flavours/blobfox/features/report/components/status_check_box.jsx create mode 100644 app/javascript/flavours/blobfox/features/report/containers/status_check_box_container.js create mode 100644 app/javascript/flavours/blobfox/features/report/rules.jsx create mode 100644 app/javascript/flavours/blobfox/features/report/statuses.jsx create mode 100644 app/javascript/flavours/blobfox/features/report/thanks.jsx create mode 100644 app/javascript/flavours/blobfox/features/standalone/compose/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/status/components/action_bar.jsx create mode 100644 app/javascript/flavours/blobfox/features/status/components/card.jsx create mode 100644 app/javascript/flavours/blobfox/features/status/components/detailed_status.jsx create mode 100644 app/javascript/flavours/blobfox/features/status/containers/detailed_status_container.js create mode 100644 app/javascript/flavours/blobfox/features/status/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/subscribed_languages_modal/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/actions_modal.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/audio_modal.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/block_modal.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/boost_modal.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/bundle.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/bundle_column_error.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/bundle_modal_error.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/column.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/column_header.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/column_link.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/column_loading.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/column_subheading.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/columns_area.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/compare_history_modal.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/compose_panel.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/confirmation_modal.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/deprecated_settings_modal.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/disabled_account_banner.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/doodle_modal.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/drawer_loading.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/embed_modal.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/favourite_modal.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/filter_modal.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/focal_point_modal.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/follow_requests_column_link.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/header.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/image_loader.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/image_modal.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/link_footer.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/list_panel.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/media_modal.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/modal_loading.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/modal_root.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/mute_modal.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/navigation_panel.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/notifications_counter_icon.js create mode 100644 app/javascript/flavours/blobfox/features/ui/components/report_modal.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/sign_in_banner.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/upload_area.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/video_modal.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/components/zoomable_image.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/containers/bundle_container.js create mode 100644 app/javascript/flavours/blobfox/features/ui/containers/columns_area_container.js create mode 100644 app/javascript/flavours/blobfox/features/ui/containers/loading_bar_container.js create mode 100644 app/javascript/flavours/blobfox/features/ui/containers/modal_container.js create mode 100644 app/javascript/flavours/blobfox/features/ui/containers/notifications_container.js create mode 100644 app/javascript/flavours/blobfox/features/ui/containers/status_list_container.js create mode 100644 app/javascript/flavours/blobfox/features/ui/index.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/util/async-components.js create mode 100644 app/javascript/flavours/blobfox/features/ui/util/fullscreen.js create mode 100644 app/javascript/flavours/blobfox/features/ui/util/get_rect_from_entry.js create mode 100644 app/javascript/flavours/blobfox/features/ui/util/intersection_observer_wrapper.js create mode 100644 app/javascript/flavours/blobfox/features/ui/util/optional_motion.js create mode 100644 app/javascript/flavours/blobfox/features/ui/util/react_router_helpers.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/util/reduced_motion.jsx create mode 100644 app/javascript/flavours/blobfox/features/ui/util/schedule_idle_task.js create mode 100644 app/javascript/flavours/blobfox/features/video/index.jsx create mode 100644 app/javascript/flavours/blobfox/hooks/useHovering.ts create mode 100644 app/javascript/flavours/blobfox/images/elephant_ui_disappointed.svg create mode 100644 app/javascript/flavours/blobfox/images/elephant_ui_working.svg create mode 100644 app/javascript/flavours/blobfox/images/glitch-preview.jpg create mode 100644 app/javascript/flavours/blobfox/images/logo_warn_glitch.svg create mode 100644 app/javascript/flavours/blobfox/images/mbstobon-ui-0.png create mode 100644 app/javascript/flavours/blobfox/images/mbstobon-ui-1.png create mode 100644 app/javascript/flavours/blobfox/images/mbstobon-ui-2.png create mode 100644 app/javascript/flavours/blobfox/images/mbstobon-ui-3.png create mode 100644 app/javascript/flavours/blobfox/images/wave-drawer-glitched.png create mode 100644 app/javascript/flavours/blobfox/images/wave-drawer.png create mode 100644 app/javascript/flavours/blobfox/initial_state.js create mode 100644 app/javascript/flavours/blobfox/is_mobile.ts create mode 100644 app/javascript/flavours/blobfox/load_keyboard_extensions.js create mode 100644 app/javascript/flavours/blobfox/main.jsx create mode 100644 app/javascript/flavours/blobfox/models/account.ts create mode 100644 app/javascript/flavours/blobfox/models/custom_emoji.ts create mode 100644 app/javascript/flavours/blobfox/models/relationship.ts create mode 100644 app/javascript/flavours/blobfox/names.yml create mode 100644 app/javascript/flavours/blobfox/packs/admin.jsx create mode 100644 app/javascript/flavours/blobfox/packs/common.js create mode 100644 app/javascript/flavours/blobfox/packs/error.js create mode 100644 app/javascript/flavours/blobfox/packs/home.js create mode 100644 app/javascript/flavours/blobfox/packs/public.jsx create mode 100644 app/javascript/flavours/blobfox/packs/settings.js create mode 100644 app/javascript/flavours/blobfox/packs/share.jsx create mode 100644 app/javascript/flavours/blobfox/packs/sign_up.js create mode 100644 app/javascript/flavours/blobfox/performance.js create mode 100644 app/javascript/flavours/blobfox/permissions.ts create mode 100644 app/javascript/flavours/blobfox/polyfills/extra_polyfills.ts create mode 100644 app/javascript/flavours/blobfox/polyfills/index.ts create mode 100644 app/javascript/flavours/blobfox/polyfills/intl.ts create mode 100644 app/javascript/flavours/blobfox/ready.js create mode 100644 app/javascript/flavours/blobfox/reducers/accounts.ts create mode 100644 app/javascript/flavours/blobfox/reducers/accounts_map.js create mode 100644 app/javascript/flavours/blobfox/reducers/alerts.js create mode 100644 app/javascript/flavours/blobfox/reducers/announcements.js create mode 100644 app/javascript/flavours/blobfox/reducers/blocks.js create mode 100644 app/javascript/flavours/blobfox/reducers/boosts.js create mode 100644 app/javascript/flavours/blobfox/reducers/compose.js create mode 100644 app/javascript/flavours/blobfox/reducers/contexts.js create mode 100644 app/javascript/flavours/blobfox/reducers/conversations.js create mode 100644 app/javascript/flavours/blobfox/reducers/custom_emojis.js create mode 100644 app/javascript/flavours/blobfox/reducers/domain_lists.js create mode 100644 app/javascript/flavours/blobfox/reducers/dropdown_menu.ts create mode 100644 app/javascript/flavours/blobfox/reducers/filters.js create mode 100644 app/javascript/flavours/blobfox/reducers/followed_tags.js create mode 100644 app/javascript/flavours/blobfox/reducers/height_cache.js create mode 100644 app/javascript/flavours/blobfox/reducers/history.js create mode 100644 app/javascript/flavours/blobfox/reducers/index.ts create mode 100644 app/javascript/flavours/blobfox/reducers/list_adder.js create mode 100644 app/javascript/flavours/blobfox/reducers/list_editor.js create mode 100644 app/javascript/flavours/blobfox/reducers/lists.js create mode 100644 app/javascript/flavours/blobfox/reducers/local_settings.js create mode 100644 app/javascript/flavours/blobfox/reducers/markers.js create mode 100644 app/javascript/flavours/blobfox/reducers/media_attachments.js create mode 100644 app/javascript/flavours/blobfox/reducers/meta.js create mode 100644 app/javascript/flavours/blobfox/reducers/modal.ts create mode 100644 app/javascript/flavours/blobfox/reducers/mutes.js create mode 100644 app/javascript/flavours/blobfox/reducers/notifications.js create mode 100644 app/javascript/flavours/blobfox/reducers/picture_in_picture.js create mode 100644 app/javascript/flavours/blobfox/reducers/pinned_accounts_editor.js create mode 100644 app/javascript/flavours/blobfox/reducers/polls.js create mode 100644 app/javascript/flavours/blobfox/reducers/push_notifications.js create mode 100644 app/javascript/flavours/blobfox/reducers/relationships.ts create mode 100644 app/javascript/flavours/blobfox/reducers/search.js create mode 100644 app/javascript/flavours/blobfox/reducers/server.js create mode 100644 app/javascript/flavours/blobfox/reducers/settings.js create mode 100644 app/javascript/flavours/blobfox/reducers/status_lists.js create mode 100644 app/javascript/flavours/blobfox/reducers/statuses.js create mode 100644 app/javascript/flavours/blobfox/reducers/suggestions.js create mode 100644 app/javascript/flavours/blobfox/reducers/tags.js create mode 100644 app/javascript/flavours/blobfox/reducers/timelines.js create mode 100644 app/javascript/flavours/blobfox/reducers/trends.js create mode 100644 app/javascript/flavours/blobfox/reducers/user_lists.js create mode 100644 app/javascript/flavours/blobfox/scroll.ts create mode 100644 app/javascript/flavours/blobfox/selectors/accounts.ts create mode 100644 app/javascript/flavours/blobfox/selectors/index.js create mode 100644 app/javascript/flavours/blobfox/settings.js create mode 100644 app/javascript/flavours/blobfox/store/index.ts create mode 100644 app/javascript/flavours/blobfox/store/middlewares/errors.ts create mode 100644 app/javascript/flavours/blobfox/store/middlewares/loading_bar.ts create mode 100644 app/javascript/flavours/blobfox/store/middlewares/sounds.ts create mode 100644 app/javascript/flavours/blobfox/store/store.ts create mode 100644 app/javascript/flavours/blobfox/store/typed_functions.ts create mode 100644 app/javascript/flavours/blobfox/stream.js create mode 100644 app/javascript/flavours/blobfox/styles/_mixins.scss create mode 100644 app/javascript/flavours/blobfox/styles/about.scss create mode 100644 app/javascript/flavours/blobfox/styles/accessibility.scss create mode 100644 app/javascript/flavours/blobfox/styles/accounts.scss create mode 100644 app/javascript/flavours/blobfox/styles/admin.scss create mode 100644 app/javascript/flavours/blobfox/styles/basics.scss create mode 100644 app/javascript/flavours/blobfox/styles/branding.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/about.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/accounts.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/announcements.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/boost.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/columns.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/compose_form.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/directory.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/domains.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/doodle.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/drawer.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/emoji.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/emoji_picker.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/explore.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/index.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/lists.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/local_settings.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/media.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/misc.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/modal.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/privacy_policy.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/regeneration_indicator.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/search.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/sensitive.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/signed_out.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/single_column.scss create mode 100644 app/javascript/flavours/blobfox/styles/components/status.scss create mode 100644 app/javascript/flavours/blobfox/styles/containers.scss create mode 100644 app/javascript/flavours/blobfox/styles/contrast.scss create mode 100644 app/javascript/flavours/blobfox/styles/contrast/diff.scss create mode 100644 app/javascript/flavours/blobfox/styles/contrast/variables.scss create mode 100644 app/javascript/flavours/blobfox/styles/dashboard.scss create mode 100644 app/javascript/flavours/blobfox/styles/forms.scss create mode 100644 app/javascript/flavours/blobfox/styles/index.scss create mode 100644 app/javascript/flavours/blobfox/styles/lists.scss create mode 100644 app/javascript/flavours/blobfox/styles/mastodon-light.scss create mode 100644 app/javascript/flavours/blobfox/styles/mastodon-light/diff.scss create mode 100644 app/javascript/flavours/blobfox/styles/mastodon-light/variables.scss create mode 100644 app/javascript/flavours/blobfox/styles/modal.scss create mode 100644 app/javascript/flavours/blobfox/styles/polls.scss create mode 100644 app/javascript/flavours/blobfox/styles/reset.scss create mode 100644 app/javascript/flavours/blobfox/styles/rich_text.scss create mode 100644 app/javascript/flavours/blobfox/styles/rtl.scss create mode 100644 app/javascript/flavours/blobfox/styles/statuses.scss create mode 100644 app/javascript/flavours/blobfox/styles/tables.scss create mode 100644 app/javascript/flavours/blobfox/styles/variables.scss create mode 100644 app/javascript/flavours/blobfox/styles/widgets.scss create mode 100644 app/javascript/flavours/blobfox/theme.yml create mode 100644 app/javascript/flavours/blobfox/types/util.ts create mode 100644 app/javascript/flavours/blobfox/utils/backend_links.js create mode 100644 app/javascript/flavours/blobfox/utils/base64.ts create mode 100644 app/javascript/flavours/blobfox/utils/config.js create mode 100644 app/javascript/flavours/blobfox/utils/content_warning.js create mode 100644 app/javascript/flavours/blobfox/utils/filters.ts create mode 100644 app/javascript/flavours/blobfox/utils/hashtag.js create mode 100644 app/javascript/flavours/blobfox/utils/hashtags.ts create mode 100644 app/javascript/flavours/blobfox/utils/html.js create mode 100644 app/javascript/flavours/blobfox/utils/icons.jsx create mode 100644 app/javascript/flavours/blobfox/utils/idna.js create mode 100644 app/javascript/flavours/blobfox/utils/js_helpers.js create mode 100644 app/javascript/flavours/blobfox/utils/log_out.js create mode 100644 app/javascript/flavours/blobfox/utils/notifications.js create mode 100644 app/javascript/flavours/blobfox/utils/numbers.ts create mode 100644 app/javascript/flavours/blobfox/utils/privacy_preference.js create mode 100644 app/javascript/flavours/blobfox/utils/react_helpers.js create mode 100644 app/javascript/flavours/blobfox/utils/react_router.jsx create mode 100644 app/javascript/flavours/blobfox/utils/resize_image.js create mode 100644 app/javascript/flavours/blobfox/utils/scrollbar.js create mode 100644 app/javascript/flavours/blobfox/uuid.ts create mode 100644 app/javascript/skins/blobfox/contrast/common.scss create mode 100644 app/javascript/skins/blobfox/contrast/names.yml create mode 100644 app/javascript/skins/blobfox/mastodon-light/common.scss create mode 100644 app/javascript/skins/blobfox/mastodon-light/names.yml create mode 100644 app/javascript/skins/blobfox/queens-pink-contrast/common.scss create mode 100644 app/javascript/skins/blobfox/queens-pink-contrast/diff.scss create mode 100644 app/javascript/skins/blobfox/queens-pink-contrast/names.yml create mode 100644 app/javascript/skins/blobfox/queens-pink-contrast/screenshot.jpg create mode 100644 app/javascript/skins/blobfox/queens-pink-contrast/variables.scss create mode 100644 app/javascript/skins/blobfox/solarpunk/common.scss create mode 100644 app/javascript/skins/blobfox/solarpunk/diff.scss create mode 100644 app/javascript/skins/blobfox/solarpunk/names.yml create mode 100644 app/javascript/skins/blobfox/solarpunk/variables.scss diff --git a/app/javascript/flavours/blobfox/actions/account_notes.ts b/app/javascript/flavours/blobfox/actions/account_notes.ts new file mode 100644 index 00000000000000..b49327584764c9 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/account_notes.ts @@ -0,0 +1,18 @@ +import type { ApiRelationshipJSON } from 'flavours/blobfox/api_types/relationships'; +import { createAppAsyncThunk } from 'flavours/blobfox/store/typed_functions'; + +import api from '../api'; + +export const submitAccountNote = createAppAsyncThunk( + 'account_note/submit', + async (args: { id: string; value: string }, { getState }) => { + const response = await api(getState).post<ApiRelationshipJSON>( + `/api/v1/accounts/${args.id}/note`, + { + comment: args.value, + }, + ); + + return { relationship: response.data }; + }, +); diff --git a/app/javascript/flavours/blobfox/actions/accounts.js b/app/javascript/flavours/blobfox/actions/accounts.js new file mode 100644 index 00000000000000..a93c027def6332 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/accounts.js @@ -0,0 +1,774 @@ +import api, { getLinks } from '../api'; + +import { + followAccountSuccess, unfollowAccountSuccess, + authorizeFollowRequestSuccess, rejectFollowRequestSuccess, + followAccountRequest, followAccountFail, + unfollowAccountRequest, unfollowAccountFail, + muteAccountSuccess, unmuteAccountSuccess, + blockAccountSuccess, unblockAccountSuccess, + pinAccountSuccess, unpinAccountSuccess, + fetchRelationshipsSuccess, +} from './accounts_typed'; +import { importFetchedAccount, importFetchedAccounts } from './importer'; + +export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST'; +export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS'; +export const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL'; + +export const ACCOUNT_LOOKUP_REQUEST = 'ACCOUNT_LOOKUP_REQUEST'; +export const ACCOUNT_LOOKUP_SUCCESS = 'ACCOUNT_LOOKUP_SUCCESS'; +export const ACCOUNT_LOOKUP_FAIL = 'ACCOUNT_LOOKUP_FAIL'; + +export const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST'; +export const ACCOUNT_BLOCK_FAIL = 'ACCOUNT_BLOCK_FAIL'; + +export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST'; +export const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL'; + +export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST'; +export const ACCOUNT_MUTE_FAIL = 'ACCOUNT_MUTE_FAIL'; + +export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST'; +export const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL'; + +export const ACCOUNT_PIN_REQUEST = 'ACCOUNT_PIN_REQUEST'; +export const ACCOUNT_PIN_FAIL = 'ACCOUNT_PIN_FAIL'; + +export const ACCOUNT_UNPIN_REQUEST = 'ACCOUNT_UNPIN_REQUEST'; +export const ACCOUNT_UNPIN_FAIL = 'ACCOUNT_UNPIN_FAIL'; + +export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST'; +export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS'; +export const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL'; + +export const FOLLOWERS_EXPAND_REQUEST = 'FOLLOWERS_EXPAND_REQUEST'; +export const FOLLOWERS_EXPAND_SUCCESS = 'FOLLOWERS_EXPAND_SUCCESS'; +export const FOLLOWERS_EXPAND_FAIL = 'FOLLOWERS_EXPAND_FAIL'; + +export const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST'; +export const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS'; +export const FOLLOWING_FETCH_FAIL = 'FOLLOWING_FETCH_FAIL'; + +export const FOLLOWING_EXPAND_REQUEST = 'FOLLOWING_EXPAND_REQUEST'; +export const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS'; +export const FOLLOWING_EXPAND_FAIL = 'FOLLOWING_EXPAND_FAIL'; + +export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST'; +export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL'; + +export const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST'; +export const FOLLOW_REQUESTS_FETCH_SUCCESS = 'FOLLOW_REQUESTS_FETCH_SUCCESS'; +export const FOLLOW_REQUESTS_FETCH_FAIL = 'FOLLOW_REQUESTS_FETCH_FAIL'; + +export const FOLLOW_REQUESTS_EXPAND_REQUEST = 'FOLLOW_REQUESTS_EXPAND_REQUEST'; +export const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS'; +export const FOLLOW_REQUESTS_EXPAND_FAIL = 'FOLLOW_REQUESTS_EXPAND_FAIL'; + +export const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST'; +export const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS'; +export const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL'; + +export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST'; +export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS'; +export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL'; + +export const PINNED_ACCOUNTS_FETCH_REQUEST = 'PINNED_ACCOUNTS_FETCH_REQUEST'; +export const PINNED_ACCOUNTS_FETCH_SUCCESS = 'PINNED_ACCOUNTS_FETCH_SUCCESS'; +export const PINNED_ACCOUNTS_FETCH_FAIL = 'PINNED_ACCOUNTS_FETCH_FAIL'; + +export const PINNED_ACCOUNTS_SUGGESTIONS_FETCH_REQUEST = 'PINNED_ACCOUNTS_SUGGESTIONS_FETCH_REQUEST'; +export const PINNED_ACCOUNTS_SUGGESTIONS_FETCH_SUCCESS = 'PINNED_ACCOUNTS_SUGGESTIONS_FETCH_SUCCESS'; +export const PINNED_ACCOUNTS_SUGGESTIONS_FETCH_FAIL = 'PINNED_ACCOUNTS_SUGGESTIONS_FETCH_FAIL'; + +export const PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR = 'PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR'; +export const PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE = 'PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE'; + +export const PINNED_ACCOUNTS_EDITOR_RESET = 'PINNED_ACCOUNTS_EDITOR_RESET'; + +export const ACCOUNT_REVEAL = 'ACCOUNT_REVEAL'; + +export * from './accounts_typed'; + +export function fetchAccount(id) { + return (dispatch, getState) => { + dispatch(fetchRelationships([id])); + + if (getState().getIn(['accounts', id], null) !== null) { + return; + } + + dispatch(fetchAccountRequest(id)); + + api(getState).get(`/api/v1/accounts/${id}`).then(response => { + dispatch(importFetchedAccount(response.data)); + dispatch(fetchAccountSuccess()); + }).catch(error => { + dispatch(fetchAccountFail(id, error)); + }); + }; +} + +export const lookupAccount = acct => (dispatch, getState) => { + dispatch(lookupAccountRequest(acct)); + + api(getState).get('/api/v1/accounts/lookup', { params: { acct } }).then(response => { + dispatch(fetchRelationships([response.data.id])); + dispatch(importFetchedAccount(response.data)); + dispatch(lookupAccountSuccess()); + }).catch(error => { + dispatch(lookupAccountFail(acct, error)); + }); +}; + +export const lookupAccountRequest = (acct) => ({ + type: ACCOUNT_LOOKUP_REQUEST, + acct, +}); + +export const lookupAccountSuccess = () => ({ + type: ACCOUNT_LOOKUP_SUCCESS, +}); + +export const lookupAccountFail = (acct, error) => ({ + type: ACCOUNT_LOOKUP_FAIL, + acct, + error, + skipAlert: true, +}); + +export function fetchAccountRequest(id) { + return { + type: ACCOUNT_FETCH_REQUEST, + id, + }; +} + +export function fetchAccountSuccess() { + return { + type: ACCOUNT_FETCH_SUCCESS, + }; +} + +export function fetchAccountFail(id, error) { + return { + type: ACCOUNT_FETCH_FAIL, + id, + error, + skipAlert: true, + }; +} + +export function followAccount(id, options = { reblogs: true }) { + return (dispatch, getState) => { + const alreadyFollowing = getState().getIn(['relationships', id, 'following']); + const locked = getState().getIn(['accounts', id, 'locked'], false); + + dispatch(followAccountRequest({ id, locked })); + + api(getState).post(`/api/v1/accounts/${id}/follow`, options).then(response => { + dispatch(followAccountSuccess({relationship: response.data, alreadyFollowing})); + }).catch(error => { + dispatch(followAccountFail({ id, error, locked })); + }); + }; +} + +export function unfollowAccount(id) { + return (dispatch, getState) => { + dispatch(unfollowAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => { + dispatch(unfollowAccountSuccess({relationship: response.data, statuses: getState().get('statuses')})); + }).catch(error => { + dispatch(unfollowAccountFail({ id, error })); + }); + }; +} + +export function blockAccount(id) { + return (dispatch, getState) => { + dispatch(blockAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/block`).then(response => { + // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers + dispatch(blockAccountSuccess({ relationship: response.data, statuses: getState().get('statuses') })); + }).catch(error => { + dispatch(blockAccountFail({ id, error })); + }); + }; +} + +export function unblockAccount(id) { + return (dispatch, getState) => { + dispatch(unblockAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/unblock`).then(response => { + dispatch(unblockAccountSuccess({ relationship: response.data })); + }).catch(error => { + dispatch(unblockAccountFail({ id, error })); + }); + }; +} + +export function blockAccountRequest(id) { + return { + type: ACCOUNT_BLOCK_REQUEST, + id, + }; +} +export function blockAccountFail(error) { + return { + type: ACCOUNT_BLOCK_FAIL, + error, + }; +} + +export function unblockAccountRequest(id) { + return { + type: ACCOUNT_UNBLOCK_REQUEST, + id, + }; +} + +export function unblockAccountFail(error) { + return { + type: ACCOUNT_UNBLOCK_FAIL, + error, + }; +} + + +export function muteAccount(id, notifications, duration=0) { + return (dispatch, getState) => { + dispatch(muteAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, duration }).then(response => { + // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers + dispatch(muteAccountSuccess({ relationship: response.data, statuses: getState().get('statuses') })); + }).catch(error => { + dispatch(muteAccountFail({ id, error })); + }); + }; +} + +export function unmuteAccount(id) { + return (dispatch, getState) => { + dispatch(unmuteAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => { + dispatch(unmuteAccountSuccess({ relationship: response.data })); + }).catch(error => { + dispatch(unmuteAccountFail({ id, error })); + }); + }; +} + +export function muteAccountRequest(id) { + return { + type: ACCOUNT_MUTE_REQUEST, + id, + }; +} + +export function muteAccountFail(error) { + return { + type: ACCOUNT_MUTE_FAIL, + error, + }; +} + +export function unmuteAccountRequest(id) { + return { + type: ACCOUNT_UNMUTE_REQUEST, + id, + }; +} + +export function unmuteAccountFail(error) { + return { + type: ACCOUNT_UNMUTE_FAIL, + error, + }; +} + + +export function fetchFollowers(id) { + return (dispatch, getState) => { + dispatch(fetchFollowersRequest(id)); + + api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchFollowersSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => { + dispatch(fetchFollowersFail(id, error)); + }); + }; +} + +export function fetchFollowersRequest(id) { + return { + type: FOLLOWERS_FETCH_REQUEST, + id, + }; +} + +export function fetchFollowersSuccess(id, accounts, next) { + return { + type: FOLLOWERS_FETCH_SUCCESS, + id, + accounts, + next, + }; +} + +export function fetchFollowersFail(id, error) { + return { + type: FOLLOWERS_FETCH_FAIL, + id, + error, + skipNotFound: true, + }; +} + +export function expandFollowers(id) { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'followers', id, 'next']); + + if (url === null) { + return; + } + + dispatch(expandFollowersRequest(id)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(expandFollowersSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => { + dispatch(expandFollowersFail(id, error)); + }); + }; +} + +export function expandFollowersRequest(id) { + return { + type: FOLLOWERS_EXPAND_REQUEST, + id, + }; +} + +export function expandFollowersSuccess(id, accounts, next) { + return { + type: FOLLOWERS_EXPAND_SUCCESS, + id, + accounts, + next, + }; +} + +export function expandFollowersFail(id, error) { + return { + type: FOLLOWERS_EXPAND_FAIL, + id, + error, + }; +} + +export function fetchFollowing(id) { + return (dispatch, getState) => { + dispatch(fetchFollowingRequest(id)); + + api(getState).get(`/api/v1/accounts/${id}/following`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchFollowingSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => { + dispatch(fetchFollowingFail(id, error)); + }); + }; +} + +export function fetchFollowingRequest(id) { + return { + type: FOLLOWING_FETCH_REQUEST, + id, + }; +} + +export function fetchFollowingSuccess(id, accounts, next) { + return { + type: FOLLOWING_FETCH_SUCCESS, + id, + accounts, + next, + }; +} + +export function fetchFollowingFail(id, error) { + return { + type: FOLLOWING_FETCH_FAIL, + id, + error, + skipNotFound: true, + }; +} + +export function expandFollowing(id) { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'following', id, 'next']); + + if (url === null) { + return; + } + + dispatch(expandFollowingRequest(id)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(expandFollowingSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => { + dispatch(expandFollowingFail(id, error)); + }); + }; +} + +export function expandFollowingRequest(id) { + return { + type: FOLLOWING_EXPAND_REQUEST, + id, + }; +} + +export function expandFollowingSuccess(id, accounts, next) { + return { + type: FOLLOWING_EXPAND_SUCCESS, + id, + accounts, + next, + }; +} + +export function expandFollowingFail(id, error) { + return { + type: FOLLOWING_EXPAND_FAIL, + id, + error, + }; +} + +export function fetchRelationships(accountIds) { + return (dispatch, getState) => { + const state = getState(); + const loadedRelationships = state.get('relationships'); + const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null); + const signedIn = !!state.getIn(['meta', 'me']); + + if (!signedIn || newAccountIds.length === 0) { + return; + } + + dispatch(fetchRelationshipsRequest(newAccountIds)); + + api(getState).get(`/api/v1/accounts/relationships?with_suspended=true&${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => { + dispatch(fetchRelationshipsSuccess({ relationships: response.data })); + }).catch(error => { + dispatch(fetchRelationshipsFail(error)); + }); + }; +} + +export function fetchRelationshipsRequest(ids) { + return { + type: RELATIONSHIPS_FETCH_REQUEST, + ids, + skipLoading: true, + }; +} + +export function fetchRelationshipsFail(error) { + return { + type: RELATIONSHIPS_FETCH_FAIL, + error, + skipLoading: true, + skipNotFound: true, + }; +} + +export function fetchFollowRequests() { + return (dispatch, getState) => { + dispatch(fetchFollowRequestsRequest()); + + api(getState).get('/api/v1/follow_requests').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null)); + }).catch(error => dispatch(fetchFollowRequestsFail(error))); + }; +} + +export function fetchFollowRequestsRequest() { + return { + type: FOLLOW_REQUESTS_FETCH_REQUEST, + }; +} + +export function fetchFollowRequestsSuccess(accounts, next) { + return { + type: FOLLOW_REQUESTS_FETCH_SUCCESS, + accounts, + next, + }; +} + +export function fetchFollowRequestsFail(error) { + return { + type: FOLLOW_REQUESTS_FETCH_FAIL, + error, + }; +} + +export function expandFollowRequests() { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'follow_requests', 'next']); + + if (url === null) { + return; + } + + dispatch(expandFollowRequestsRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); + dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null)); + }).catch(error => dispatch(expandFollowRequestsFail(error))); + }; +} + +export function expandFollowRequestsRequest() { + return { + type: FOLLOW_REQUESTS_EXPAND_REQUEST, + }; +} + +export function expandFollowRequestsSuccess(accounts, next) { + return { + type: FOLLOW_REQUESTS_EXPAND_SUCCESS, + accounts, + next, + }; +} + +export function expandFollowRequestsFail(error) { + return { + type: FOLLOW_REQUESTS_EXPAND_FAIL, + error, + }; +} + +export function authorizeFollowRequest(id) { + return (dispatch, getState) => { + dispatch(authorizeFollowRequestRequest(id)); + + api(getState) + .post(`/api/v1/follow_requests/${id}/authorize`) + .then(() => dispatch(authorizeFollowRequestSuccess({ id }))) + .catch(error => dispatch(authorizeFollowRequestFail(id, error))); + }; +} + +export function authorizeFollowRequestRequest(id) { + return { + type: FOLLOW_REQUEST_AUTHORIZE_REQUEST, + id, + }; +} + +export function authorizeFollowRequestFail(id, error) { + return { + type: FOLLOW_REQUEST_AUTHORIZE_FAIL, + id, + error, + }; +} + + +export function rejectFollowRequest(id) { + return (dispatch, getState) => { + dispatch(rejectFollowRequestRequest(id)); + + api(getState) + .post(`/api/v1/follow_requests/${id}/reject`) + .then(() => dispatch(rejectFollowRequestSuccess({ id }))) + .catch(error => dispatch(rejectFollowRequestFail(id, error))); + }; +} + +export function rejectFollowRequestRequest(id) { + return { + type: FOLLOW_REQUEST_REJECT_REQUEST, + id, + }; +} + +export function rejectFollowRequestFail(id, error) { + return { + type: FOLLOW_REQUEST_REJECT_FAIL, + id, + error, + }; +} + +export function pinAccount(id) { + return (dispatch, getState) => { + dispatch(pinAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/pin`).then(response => { + dispatch(pinAccountSuccess({ relationship: response.data })); + }).catch(error => { + dispatch(pinAccountFail(error)); + }); + }; +} + +export function unpinAccount(id) { + return (dispatch, getState) => { + dispatch(unpinAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/unpin`).then(response => { + dispatch(unpinAccountSuccess({ relationship: response.data })); + }).catch(error => { + dispatch(unpinAccountFail(error)); + }); + }; +} + +export function pinAccountRequest(id) { + return { + type: ACCOUNT_PIN_REQUEST, + id, + }; +} + +export function pinAccountFail(error) { + return { + type: ACCOUNT_PIN_FAIL, + error, + }; +} + +export function unpinAccountRequest(id) { + return { + type: ACCOUNT_UNPIN_REQUEST, + id, + }; +} + +export function unpinAccountFail(error) { + return { + type: ACCOUNT_UNPIN_FAIL, + error, + }; +} + +export function fetchPinnedAccounts() { + return (dispatch, getState) => { + dispatch(fetchPinnedAccountsRequest()); + + api(getState).get('/api/v1/endorsements', { params: { limit: 0 } }).then(response => { + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchPinnedAccountsSuccess(response.data)); + }).catch(err => dispatch(fetchPinnedAccountsFail(err))); + }; +} + +export function fetchPinnedAccountsRequest() { + return { + type: PINNED_ACCOUNTS_FETCH_REQUEST, + }; +} + +export function fetchPinnedAccountsSuccess(accounts, next) { + return { + type: PINNED_ACCOUNTS_FETCH_SUCCESS, + accounts, + next, + }; +} + +export function fetchPinnedAccountsFail(error) { + return { + type: PINNED_ACCOUNTS_FETCH_FAIL, + error, + }; +} + +export function fetchPinnedAccountsSuggestions(q) { + return (dispatch, getState) => { + dispatch(fetchPinnedAccountsSuggestionsRequest()); + + const params = { + q, + resolve: false, + limit: 4, + following: true, + }; + + api(getState).get('/api/v1/accounts/search', { params }).then(response => { + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchPinnedAccountsSuggestionsSuccess(q, response.data)); + }).catch(err => dispatch(fetchPinnedAccountsSuggestionsFail(err))); + }; +} + +export function fetchPinnedAccountsSuggestionsRequest() { + return { + type: PINNED_ACCOUNTS_SUGGESTIONS_FETCH_REQUEST, + }; +} + +export function fetchPinnedAccountsSuggestionsSuccess(query, accounts) { + return { + type: PINNED_ACCOUNTS_SUGGESTIONS_FETCH_SUCCESS, + query, + accounts, + }; +} + +export function fetchPinnedAccountsSuggestionsFail(error) { + return { + type: PINNED_ACCOUNTS_SUGGESTIONS_FETCH_FAIL, + error, + }; +} + +export function clearPinnedAccountsSuggestions() { + return { + type: PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR, + }; +} + +export function changePinnedAccountsSuggestions(value) { + return { + type: PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE, + value, + }; +} + +export function resetPinnedAccountsEditor() { + return { + type: PINNED_ACCOUNTS_EDITOR_RESET, + }; +} + diff --git a/app/javascript/flavours/blobfox/actions/accounts_typed.ts b/app/javascript/flavours/blobfox/actions/accounts_typed.ts new file mode 100644 index 00000000000000..f57bf854f8ee58 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/accounts_typed.ts @@ -0,0 +1,97 @@ +import { createAction } from '@reduxjs/toolkit'; + +import type { ApiAccountJSON } from 'flavours/blobfox/api_types/accounts'; +import type { ApiRelationshipJSON } from 'flavours/blobfox/api_types/relationships'; + +export const revealAccount = createAction<{ + id: string; +}>('accounts/revealAccount'); + +export const importAccounts = createAction<{ accounts: ApiAccountJSON[] }>( + 'accounts/importAccounts', +); + +function actionWithSkipLoadingTrue<Args extends object>(args: Args) { + return { + payload: { + ...args, + skipLoading: true, + }, + }; +} + +export const followAccountSuccess = createAction( + 'accounts/followAccount/SUCCESS', + actionWithSkipLoadingTrue<{ + relationship: ApiRelationshipJSON; + alreadyFollowing: boolean; + }>, +); + +export const unfollowAccountSuccess = createAction( + 'accounts/unfollowAccount/SUCCESS', + actionWithSkipLoadingTrue<{ + relationship: ApiRelationshipJSON; + statuses: unknown; + alreadyFollowing?: boolean; + }>, +); + +export const authorizeFollowRequestSuccess = createAction<{ id: string }>( + 'accounts/followRequestAuthorize/SUCCESS', +); + +export const rejectFollowRequestSuccess = createAction<{ id: string }>( + 'accounts/followRequestReject/SUCCESS', +); + +export const followAccountRequest = createAction( + 'accounts/follow/REQUEST', + actionWithSkipLoadingTrue<{ id: string; locked: boolean }>, +); + +export const followAccountFail = createAction( + 'accounts/follow/FAIL', + actionWithSkipLoadingTrue<{ id: string; error: string; locked: boolean }>, +); + +export const unfollowAccountRequest = createAction( + 'accounts/unfollow/REQUEST', + actionWithSkipLoadingTrue<{ id: string }>, +); + +export const unfollowAccountFail = createAction( + 'accounts/unfollow/FAIL', + actionWithSkipLoadingTrue<{ id: string; error: string }>, +); + +export const blockAccountSuccess = createAction<{ + relationship: ApiRelationshipJSON; + statuses: unknown; +}>('accounts/block/SUCCESS'); + +export const unblockAccountSuccess = createAction<{ + relationship: ApiRelationshipJSON; +}>('accounts/unblock/SUCCESS'); + +export const muteAccountSuccess = createAction<{ + relationship: ApiRelationshipJSON; + statuses: unknown; +}>('accounts/mute/SUCCESS'); + +export const unmuteAccountSuccess = createAction<{ + relationship: ApiRelationshipJSON; +}>('accounts/unmute/SUCCESS'); + +export const pinAccountSuccess = createAction<{ + relationship: ApiRelationshipJSON; +}>('accounts/pin/SUCCESS'); + +export const unpinAccountSuccess = createAction<{ + relationship: ApiRelationshipJSON; +}>('accounts/unpin/SUCCESS'); + +export const fetchRelationshipsSuccess = createAction( + 'relationships/fetch/SUCCESS', + actionWithSkipLoadingTrue<{ relationships: ApiRelationshipJSON[] }>, +); diff --git a/app/javascript/flavours/blobfox/actions/alerts.js b/app/javascript/flavours/blobfox/actions/alerts.js new file mode 100644 index 00000000000000..42834146bf5ba6 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/alerts.js @@ -0,0 +1,59 @@ +import { defineMessages } from 'react-intl'; + +const messages = defineMessages({ + unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, + unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' }, + rateLimitedTitle: { id: 'alert.rate_limited.title', defaultMessage: 'Rate limited' }, + rateLimitedMessage: { id: 'alert.rate_limited.message', defaultMessage: 'Please retry after {retry_time, time, medium}.' }, +}); + +export const ALERT_SHOW = 'ALERT_SHOW'; +export const ALERT_DISMISS = 'ALERT_DISMISS'; +export const ALERT_CLEAR = 'ALERT_CLEAR'; +export const ALERT_NOOP = 'ALERT_NOOP'; + +export const dismissAlert = alert => ({ + type: ALERT_DISMISS, + alert, +}); + +export const clearAlert = () => ({ + type: ALERT_CLEAR, +}); + +export const showAlert = alert => ({ + type: ALERT_SHOW, + alert, +}); + +export const showAlertForError = (error, skipNotFound = false) => { + if (error.response) { + const { data, status, statusText, headers } = error.response; + + // Skip these errors as they are reflected in the UI + if (skipNotFound && (status === 404 || status === 410)) { + return { type: ALERT_NOOP }; + } + + // Rate limit errors + if (status === 429 && headers['x-ratelimit-reset']) { + return showAlert({ + title: messages.rateLimitedTitle, + message: messages.rateLimitedMessage, + values: { 'retry_time': new Date(headers['x-ratelimit-reset']) }, + }); + } + + return showAlert({ + title: `${status}`, + message: data.error || statusText, + }); + } + + console.error(error); + + return showAlert({ + title: messages.unexpectedTitle, + message: messages.unexpectedMessage, + }); +}; diff --git a/app/javascript/flavours/blobfox/actions/announcements.js b/app/javascript/flavours/blobfox/actions/announcements.js new file mode 100644 index 00000000000000..339c5f3adc5a8e --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/announcements.js @@ -0,0 +1,181 @@ +import api from '../api'; + +import { normalizeAnnouncement } from './importer/normalizer'; + +export const ANNOUNCEMENTS_FETCH_REQUEST = 'ANNOUNCEMENTS_FETCH_REQUEST'; +export const ANNOUNCEMENTS_FETCH_SUCCESS = 'ANNOUNCEMENTS_FETCH_SUCCESS'; +export const ANNOUNCEMENTS_FETCH_FAIL = 'ANNOUNCEMENTS_FETCH_FAIL'; +export const ANNOUNCEMENTS_UPDATE = 'ANNOUNCEMENTS_UPDATE'; +export const ANNOUNCEMENTS_DELETE = 'ANNOUNCEMENTS_DELETE'; + +export const ANNOUNCEMENTS_DISMISS_REQUEST = 'ANNOUNCEMENTS_DISMISS_REQUEST'; +export const ANNOUNCEMENTS_DISMISS_SUCCESS = 'ANNOUNCEMENTS_DISMISS_SUCCESS'; +export const ANNOUNCEMENTS_DISMISS_FAIL = 'ANNOUNCEMENTS_DISMISS_FAIL'; + +export const ANNOUNCEMENTS_REACTION_ADD_REQUEST = 'ANNOUNCEMENTS_REACTION_ADD_REQUEST'; +export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS'; +export const ANNOUNCEMENTS_REACTION_ADD_FAIL = 'ANNOUNCEMENTS_REACTION_ADD_FAIL'; + +export const ANNOUNCEMENTS_REACTION_REMOVE_REQUEST = 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST'; +export const ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS = 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS'; +export const ANNOUNCEMENTS_REACTION_REMOVE_FAIL = 'ANNOUNCEMENTS_REACTION_REMOVE_FAIL'; + +export const ANNOUNCEMENTS_REACTION_UPDATE = 'ANNOUNCEMENTS_REACTION_UPDATE'; + +export const ANNOUNCEMENTS_TOGGLE_SHOW = 'ANNOUNCEMENTS_TOGGLE_SHOW'; + +const noOp = () => {}; + +export const fetchAnnouncements = (done = noOp) => (dispatch, getState) => { + dispatch(fetchAnnouncementsRequest()); + + api(getState).get('/api/v1/announcements').then(response => { + dispatch(fetchAnnouncementsSuccess(response.data.map(x => normalizeAnnouncement(x)))); + }).catch(error => { + dispatch(fetchAnnouncementsFail(error)); + }).finally(() => { + done(); + }); +}; + +export const fetchAnnouncementsRequest = () => ({ + type: ANNOUNCEMENTS_FETCH_REQUEST, + skipLoading: true, +}); + +export const fetchAnnouncementsSuccess = announcements => ({ + type: ANNOUNCEMENTS_FETCH_SUCCESS, + announcements, + skipLoading: true, +}); + +export const fetchAnnouncementsFail= error => ({ + type: ANNOUNCEMENTS_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, +}); + +export const updateAnnouncements = announcement => ({ + type: ANNOUNCEMENTS_UPDATE, + announcement: normalizeAnnouncement(announcement), +}); + +export const dismissAnnouncement = announcementId => (dispatch, getState) => { + dispatch(dismissAnnouncementRequest(announcementId)); + + api(getState).post(`/api/v1/announcements/${announcementId}/dismiss`).then(() => { + dispatch(dismissAnnouncementSuccess(announcementId)); + }).catch(error => { + dispatch(dismissAnnouncementFail(announcementId, error)); + }); +}; + +export const dismissAnnouncementRequest = announcementId => ({ + type: ANNOUNCEMENTS_DISMISS_REQUEST, + id: announcementId, +}); + +export const dismissAnnouncementSuccess = announcementId => ({ + type: ANNOUNCEMENTS_DISMISS_SUCCESS, + id: announcementId, +}); + +export const dismissAnnouncementFail = (announcementId, error) => ({ + type: ANNOUNCEMENTS_DISMISS_FAIL, + id: announcementId, + error, +}); + +export const addReaction = (announcementId, name) => (dispatch, getState) => { + const announcement = getState().getIn(['announcements', 'items']).find(x => x.get('id') === announcementId); + + let alreadyAdded = false; + + if (announcement) { + const reaction = announcement.get('reactions').find(x => x.get('name') === name); + if (reaction && reaction.get('me')) { + alreadyAdded = true; + } + } + + if (!alreadyAdded) { + dispatch(addReactionRequest(announcementId, name, alreadyAdded)); + } + + api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${encodeURIComponent(name)}`).then(() => { + dispatch(addReactionSuccess(announcementId, name, alreadyAdded)); + }).catch(err => { + if (!alreadyAdded) { + dispatch(addReactionFail(announcementId, name, err)); + } + }); +}; + +export const addReactionRequest = (announcementId, name) => ({ + type: ANNOUNCEMENTS_REACTION_ADD_REQUEST, + id: announcementId, + name, + skipLoading: true, +}); + +export const addReactionSuccess = (announcementId, name) => ({ + type: ANNOUNCEMENTS_REACTION_ADD_SUCCESS, + id: announcementId, + name, + skipLoading: true, +}); + +export const addReactionFail = (announcementId, name, error) => ({ + type: ANNOUNCEMENTS_REACTION_ADD_FAIL, + id: announcementId, + name, + error, + skipLoading: true, +}); + +export const removeReaction = (announcementId, name) => (dispatch, getState) => { + dispatch(removeReactionRequest(announcementId, name)); + + api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${encodeURIComponent(name)}`).then(() => { + dispatch(removeReactionSuccess(announcementId, name)); + }).catch(err => { + dispatch(removeReactionFail(announcementId, name, err)); + }); +}; + +export const removeReactionRequest = (announcementId, name) => ({ + type: ANNOUNCEMENTS_REACTION_REMOVE_REQUEST, + id: announcementId, + name, + skipLoading: true, +}); + +export const removeReactionSuccess = (announcementId, name) => ({ + type: ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS, + id: announcementId, + name, + skipLoading: true, +}); + +export const removeReactionFail = (announcementId, name, error) => ({ + type: ANNOUNCEMENTS_REACTION_REMOVE_FAIL, + id: announcementId, + name, + error, + skipLoading: true, +}); + +export const updateReaction = reaction => ({ + type: ANNOUNCEMENTS_REACTION_UPDATE, + reaction, +}); + +export const toggleShowAnnouncements = () => ({ + type: ANNOUNCEMENTS_TOGGLE_SHOW, +}); + +export const deleteAnnouncement = id => ({ + type: ANNOUNCEMENTS_DELETE, + id, +}); diff --git a/app/javascript/flavours/blobfox/actions/app.ts b/app/javascript/flavours/blobfox/actions/app.ts new file mode 100644 index 00000000000000..6fbfc07f68c931 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/app.ts @@ -0,0 +1,9 @@ +import { createAction } from '@reduxjs/toolkit'; + +import type { LayoutType } from '../is_mobile'; + +interface ChangeLayoutPayload { + layout: LayoutType; +} +export const changeLayout = + createAction<ChangeLayoutPayload>('APP_LAYOUT_CHANGE'); diff --git a/app/javascript/flavours/blobfox/actions/blocks.js b/app/javascript/flavours/blobfox/actions/blocks.js new file mode 100644 index 00000000000000..e293657ad36ef9 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/blocks.js @@ -0,0 +1,100 @@ +import api, { getLinks } from '../api'; + +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts } from './importer'; +import { openModal } from './modal'; + +export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST'; +export const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS'; +export const BLOCKS_FETCH_FAIL = 'BLOCKS_FETCH_FAIL'; + +export const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST'; +export const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS'; +export const BLOCKS_EXPAND_FAIL = 'BLOCKS_EXPAND_FAIL'; + +export const BLOCKS_INIT_MODAL = 'BLOCKS_INIT_MODAL'; + +export function fetchBlocks() { + return (dispatch, getState) => { + dispatch(fetchBlocksRequest()); + + api(getState).get('/api/v1/blocks').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchBlocksSuccess(response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => dispatch(fetchBlocksFail(error))); + }; +} + +export function fetchBlocksRequest() { + return { + type: BLOCKS_FETCH_REQUEST, + }; +} + +export function fetchBlocksSuccess(accounts, next) { + return { + type: BLOCKS_FETCH_SUCCESS, + accounts, + next, + }; +} + +export function fetchBlocksFail(error) { + return { + type: BLOCKS_FETCH_FAIL, + error, + }; +} + +export function expandBlocks() { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'blocks', 'next']); + + if (url === null) { + return; + } + + dispatch(expandBlocksRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); + dispatch(expandBlocksSuccess(response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => dispatch(expandBlocksFail(error))); + }; +} + +export function expandBlocksRequest() { + return { + type: BLOCKS_EXPAND_REQUEST, + }; +} + +export function expandBlocksSuccess(accounts, next) { + return { + type: BLOCKS_EXPAND_SUCCESS, + accounts, + next, + }; +} + +export function expandBlocksFail(error) { + return { + type: BLOCKS_EXPAND_FAIL, + error, + }; +} + +export function initBlockModal(account) { + return dispatch => { + dispatch({ + type: BLOCKS_INIT_MODAL, + account, + }); + + dispatch(openModal({ modalType: 'BLOCK' })); + }; +} diff --git a/app/javascript/flavours/blobfox/actions/bookmarks.js b/app/javascript/flavours/blobfox/actions/bookmarks.js new file mode 100644 index 00000000000000..0b16f61e63635a --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/bookmarks.js @@ -0,0 +1,91 @@ +import api, { getLinks } from '../api'; + +import { importFetchedStatuses } from './importer'; + +export const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQUEST'; +export const BOOKMARKED_STATUSES_FETCH_SUCCESS = 'BOOKMARKED_STATUSES_FETCH_SUCCESS'; +export const BOOKMARKED_STATUSES_FETCH_FAIL = 'BOOKMARKED_STATUSES_FETCH_FAIL'; + +export const BOOKMARKED_STATUSES_EXPAND_REQUEST = 'BOOKMARKED_STATUSES_EXPAND_REQUEST'; +export const BOOKMARKED_STATUSES_EXPAND_SUCCESS = 'BOOKMARKED_STATUSES_EXPAND_SUCCESS'; +export const BOOKMARKED_STATUSES_EXPAND_FAIL = 'BOOKMARKED_STATUSES_EXPAND_FAIL'; + +export function fetchBookmarkedStatuses() { + return (dispatch, getState) => { + if (getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) { + return; + } + + dispatch(fetchBookmarkedStatusesRequest()); + + api(getState).get('/api/v1/bookmarks').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchBookmarkedStatusesFail(error)); + }); + }; +} + +export function fetchBookmarkedStatusesRequest() { + return { + type: BOOKMARKED_STATUSES_FETCH_REQUEST, + }; +} + +export function fetchBookmarkedStatusesSuccess(statuses, next) { + return { + type: BOOKMARKED_STATUSES_FETCH_SUCCESS, + statuses, + next, + }; +} + +export function fetchBookmarkedStatusesFail(error) { + return { + type: BOOKMARKED_STATUSES_FETCH_FAIL, + error, + }; +} + +export function expandBookmarkedStatuses() { + return (dispatch, getState) => { + const url = getState().getIn(['status_lists', 'bookmarks', 'next'], null); + + if (url === null || getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) { + return; + } + + dispatch(expandBookmarkedStatusesRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandBookmarkedStatusesFail(error)); + }); + }; +} + +export function expandBookmarkedStatusesRequest() { + return { + type: BOOKMARKED_STATUSES_EXPAND_REQUEST, + }; +} + +export function expandBookmarkedStatusesSuccess(statuses, next) { + return { + type: BOOKMARKED_STATUSES_EXPAND_SUCCESS, + statuses, + next, + }; +} + +export function expandBookmarkedStatusesFail(error) { + return { + type: BOOKMARKED_STATUSES_EXPAND_FAIL, + error, + }; +} diff --git a/app/javascript/flavours/blobfox/actions/boosts.js b/app/javascript/flavours/blobfox/actions/boosts.js new file mode 100644 index 00000000000000..1fc2e391e26827 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/boosts.js @@ -0,0 +1,32 @@ +import { openModal } from './modal'; + +export const BOOSTS_INIT_MODAL = 'BOOSTS_INIT_MODAL'; +export const BOOSTS_CHANGE_PRIVACY = 'BOOSTS_CHANGE_PRIVACY'; + +export function initBoostModal(props) { + return (dispatch, getState) => { + const default_privacy = getState().getIn(['compose', 'default_privacy']); + + const privacy = props.status.get('visibility') === 'private' ? 'private' : default_privacy; + + dispatch({ + type: BOOSTS_INIT_MODAL, + privacy, + }); + + dispatch(openModal({ + modalType: 'BOOST', + modalProps: props, + })); + }; +} + + +export function changeBoostPrivacy(privacy) { + return dispatch => { + dispatch({ + type: BOOSTS_CHANGE_PRIVACY, + privacy, + }); + }; +} diff --git a/app/javascript/flavours/blobfox/actions/bundles.js b/app/javascript/flavours/blobfox/actions/bundles.js new file mode 100644 index 00000000000000..ecc9c8f7d3ec22 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/bundles.js @@ -0,0 +1,25 @@ +export const BUNDLE_FETCH_REQUEST = 'BUNDLE_FETCH_REQUEST'; +export const BUNDLE_FETCH_SUCCESS = 'BUNDLE_FETCH_SUCCESS'; +export const BUNDLE_FETCH_FAIL = 'BUNDLE_FETCH_FAIL'; + +export function fetchBundleRequest(skipLoading) { + return { + type: BUNDLE_FETCH_REQUEST, + skipLoading, + }; +} + +export function fetchBundleSuccess(skipLoading) { + return { + type: BUNDLE_FETCH_SUCCESS, + skipLoading, + }; +} + +export function fetchBundleFail(error, skipLoading) { + return { + type: BUNDLE_FETCH_FAIL, + error, + skipLoading, + }; +} diff --git a/app/javascript/flavours/blobfox/actions/columns.js b/app/javascript/flavours/blobfox/actions/columns.js new file mode 100644 index 00000000000000..302c3f0f9b34cb --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/columns.js @@ -0,0 +1,54 @@ +import { saveSettings } from './settings'; + +export const COLUMN_ADD = 'COLUMN_ADD'; +export const COLUMN_REMOVE = 'COLUMN_REMOVE'; +export const COLUMN_MOVE = 'COLUMN_MOVE'; +export const COLUMN_PARAMS_CHANGE = 'COLUMN_PARAMS_CHANGE'; + +export function addColumn(id, params) { + return dispatch => { + dispatch({ + type: COLUMN_ADD, + id, + params, + }); + + dispatch(saveSettings()); + }; +} + +export function removeColumn(uuid) { + return dispatch => { + dispatch({ + type: COLUMN_REMOVE, + uuid, + }); + + dispatch(saveSettings()); + }; +} + +export function moveColumn(uuid, direction) { + return dispatch => { + dispatch({ + type: COLUMN_MOVE, + uuid, + direction, + }); + + dispatch(saveSettings()); + }; +} + +export function changeColumnParams(uuid, path, value) { + return dispatch => { + dispatch({ + type: COLUMN_PARAMS_CHANGE, + uuid, + path, + value, + }); + + dispatch(saveSettings()); + }; +} diff --git a/app/javascript/flavours/blobfox/actions/compose.js b/app/javascript/flavours/blobfox/actions/compose.js new file mode 100644 index 00000000000000..0c1cbdd052c800 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/compose.js @@ -0,0 +1,840 @@ +import { defineMessages } from 'react-intl'; + +import axios from 'axios'; +import { throttle } from 'lodash'; + +import api from 'flavours/blobfox/api'; +import { search as emojiSearch } from 'flavours/blobfox/features/emoji/emoji_mart_search_light'; +import { tagHistory } from 'flavours/blobfox/settings'; +import { recoverHashtags } from 'flavours/blobfox/utils/hashtag'; + +import { showAlert, showAlertForError } from './alerts'; +import { useEmoji } from './emojis'; +import { importFetchedAccounts, importFetchedStatus } from './importer'; +import { openModal } from './modal'; +import { updateTimeline } from './timelines'; + +/** @type {AbortController | undefined} */ +let fetchComposeSuggestionsAccountsController; +/** @type {AbortController | undefined} */ +let fetchComposeSuggestionsTagsController; + +export const COMPOSE_CHANGE = 'COMPOSE_CHANGE'; +export const COMPOSE_CYCLE_ELEFRIEND = 'COMPOSE_CYCLE_ELEFRIEND'; +export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST'; +export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS'; +export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL'; +export const COMPOSE_REPLY = 'COMPOSE_REPLY'; +export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL'; +export const COMPOSE_DIRECT = 'COMPOSE_DIRECT'; +export const COMPOSE_MENTION = 'COMPOSE_MENTION'; +export const COMPOSE_RESET = 'COMPOSE_RESET'; + +export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST'; +export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS'; +export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL'; +export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS'; +export const COMPOSE_UPLOAD_PROCESSING = 'COMPOSE_UPLOAD_PROCESSING'; +export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO'; + +export const THUMBNAIL_UPLOAD_REQUEST = 'THUMBNAIL_UPLOAD_REQUEST'; +export const THUMBNAIL_UPLOAD_SUCCESS = 'THUMBNAIL_UPLOAD_SUCCESS'; +export const THUMBNAIL_UPLOAD_FAIL = 'THUMBNAIL_UPLOAD_FAIL'; +export const THUMBNAIL_UPLOAD_PROGRESS = 'THUMBNAIL_UPLOAD_PROGRESS'; + +export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR'; +export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY'; +export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT'; +export const COMPOSE_SUGGESTION_IGNORE = 'COMPOSE_SUGGESTION_IGNORE'; +export const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE'; + +export const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE'; + +export const COMPOSE_MOUNT = 'COMPOSE_MOUNT'; +export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; + +export const COMPOSE_ADVANCED_OPTIONS_CHANGE = 'COMPOSE_ADVANCED_OPTIONS_CHANGE'; +export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; +export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; +export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; +export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; +export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; +export const COMPOSE_CONTENT_TYPE_CHANGE = 'COMPOSE_CONTENT_TYPE_CHANGE'; +export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE'; + +export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; + +export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST'; +export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS'; +export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL'; + +export const COMPOSE_DOODLE_SET = 'COMPOSE_DOODLE_SET'; + +export const COMPOSE_POLL_ADD = 'COMPOSE_POLL_ADD'; +export const COMPOSE_POLL_REMOVE = 'COMPOSE_POLL_REMOVE'; +export const COMPOSE_POLL_OPTION_ADD = 'COMPOSE_POLL_OPTION_ADD'; +export const COMPOSE_POLL_OPTION_CHANGE = 'COMPOSE_POLL_OPTION_CHANGE'; +export const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE'; +export const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE'; + +export const INIT_MEDIA_EDIT_MODAL = 'INIT_MEDIA_EDIT_MODAL'; + +export const COMPOSE_CHANGE_MEDIA_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTION'; +export const COMPOSE_CHANGE_MEDIA_FOCUS = 'COMPOSE_CHANGE_MEDIA_FOCUS'; + +export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS'; +export const COMPOSE_FOCUS = 'COMPOSE_FOCUS'; + +const messages = defineMessages({ + uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, + uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, + open: { id: 'compose.published.open', defaultMessage: 'Open' }, + published: { id: 'compose.published.body', defaultMessage: 'Post published.' }, + saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' }, +}); + +export const ensureComposeIsVisible = (getState, routerHistory) => { + if (!getState().getIn(['compose', 'mounted'])) { + routerHistory.push('/publish'); + } +}; + +export function setComposeToStatus(status, text, spoiler_text, content_type) { + return{ + type: COMPOSE_SET_STATUS, + status, + text, + spoiler_text, + content_type, + }; +} + +export function changeCompose(text) { + return { + type: COMPOSE_CHANGE, + text: text, + }; +} + +export function cycleElefriendCompose() { + return { + type: COMPOSE_CYCLE_ELEFRIEND, + }; +} + +export function replyCompose(status, routerHistory) { + return (dispatch, getState) => { + const prependCWRe = getState().getIn(['local_settings', 'prepend_cw_re']); + dispatch({ + type: COMPOSE_REPLY, + status: status, + prependCWRe: prependCWRe, + }); + + ensureComposeIsVisible(getState, routerHistory); + }; +} + +export function cancelReplyCompose() { + return { + type: COMPOSE_REPLY_CANCEL, + }; +} + +export function resetCompose() { + return { + type: COMPOSE_RESET, + }; +} + +export const focusCompose = (routerHistory, defaultText) => dispatch => { + dispatch({ + type: COMPOSE_FOCUS, + defaultText, + }); + + ensureComposeIsVisible(routerHistory); +}; + +export function mentionCompose(account, routerHistory) { + return (dispatch, getState) => { + dispatch({ + type: COMPOSE_MENTION, + account: account, + }); + + ensureComposeIsVisible(getState, routerHistory); + }; +} + +export function directCompose(account, routerHistory) { + return (dispatch, getState) => { + dispatch({ + type: COMPOSE_DIRECT, + account: account, + }); + + ensureComposeIsVisible(getState, routerHistory); + }; +} + +export function submitCompose(routerHistory) { + return function (dispatch, getState) { + let status = getState().getIn(['compose', 'text'], ''); + const media = getState().getIn(['compose', 'media_attachments']); + const statusId = getState().getIn(['compose', 'id'], null); + const spoilers = getState().getIn(['compose', 'spoiler']) || getState().getIn(['local_settings', 'always_show_spoilers_field']); + let spoilerText = spoilers ? getState().getIn(['compose', 'spoiler_text'], '') : ''; + + if ((!status || !status.length) && media.size === 0) { + return; + } + + if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) { + status = status + ' 👁️'; + } + + dispatch(submitComposeRequest()); + + // If we're editing a post with media attachments, those have not + // necessarily been changed on the server. Do it now in the same + // API call. + let media_attributes; + if (statusId !== null) { + media_attributes = media.map(item => { + let focus; + + if (item.getIn(['meta', 'focus'])) { + focus = `${item.getIn(['meta', 'focus', 'x']).toFixed(2)},${item.getIn(['meta', 'focus', 'y']).toFixed(2)}`; + } + + return { + id: item.get('id'), + description: item.get('description'), + focus, + }; + }); + } + + api(getState).request({ + url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`, + method: statusId === null ? 'post' : 'put', + data: { + status, + content_type: getState().getIn(['compose', 'content_type']), + in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), + media_ids: media.map(item => item.get('id')), + media_attributes, + sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0), + spoiler_text: spoilerText, + visibility: getState().getIn(['compose', 'privacy']), + poll: getState().getIn(['compose', 'poll'], null), + language: getState().getIn(['compose', 'language']), + }, + headers: { + 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), + }, + }).then(function (response) { + if (routerHistory + && (routerHistory.location.pathname === '/publish' || routerHistory.location.pathname === '/statuses/new') + && window.history.state + && !getState().getIn(['compose', 'advanced_options', 'threaded_mode'])) { + routerHistory.goBack(); + } + + dispatch(insertIntoTagHistory(response.data.tags, status)); + dispatch(submitComposeSuccess({ ...response.data })); + + // If the response has no data then we can't do anything else. + if (!response.data) { + return; + } + + // To make the app more responsive, immediately push the status + // into the columns + const insertIfOnline = timelineId => { + const timeline = getState().getIn(['timelines', timelineId]); + + if (timeline && timeline.get('items').size > 0 && timeline.getIn(['items', 0]) !== null && timeline.get('online')) { + dispatch(updateTimeline(timelineId, { ...response.data })); + } + }; + + if (statusId) { + dispatch(importFetchedStatus({ ...response.data })); + } + + if (statusId === null) { + insertIfOnline('home'); + } + + if (statusId === null && response.data.in_reply_to_id === null && response.data.visibility === 'public') { + insertIfOnline('community'); + if (!response.data.local_only) { + insertIfOnline('public'); + } + } else if (statusId === null && response.data.visibility === 'direct') { + insertIfOnline('direct'); + } + + dispatch(showAlert({ + message: statusId === null ? messages.published : messages.saved, + action: messages.open, + dismissAfter: 10000, + onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`), + })); + }).catch(function (error) { + dispatch(submitComposeFail(error)); + }); + }; +} + +export function submitComposeRequest() { + return { + type: COMPOSE_SUBMIT_REQUEST, + }; +} + +export function submitComposeSuccess(status) { + return { + type: COMPOSE_SUBMIT_SUCCESS, + status: status, + }; +} + +export function submitComposeFail(error) { + return { + type: COMPOSE_SUBMIT_FAIL, + error: error, + }; +} + +export function doodleSet(options) { + return { + type: COMPOSE_DOODLE_SET, + options: options, + }; +} + +export function uploadCompose(files) { + return function (dispatch, getState) { + const uploadLimit = 4; + const media = getState().getIn(['compose', 'media_attachments']); + const pending = getState().getIn(['compose', 'pending_media_attachments']); + const progress = new Array(files.length).fill(0); + + let total = Array.from(files).reduce((a, v) => a + v.size, 0); + + if (files.length + media.size + pending > uploadLimit) { + dispatch(showAlert({ message: messages.uploadErrorLimit })); + return; + } + + if (getState().getIn(['compose', 'poll'])) { + dispatch(showAlert({ message: messages.uploadErrorPoll })); + return; + } + + dispatch(uploadComposeRequest()); + + for (const [i, file] of Array.from(files).entries()) { + if (media.size + i > 3) break; + + const data = new FormData(); + data.append('file', file); + + api(getState).post('/api/v2/media', data, { + onUploadProgress: function({ loaded }){ + progress[i] = loaded; + dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total)); + }, + }).then(({ status, data }) => { + // If server-side processing of the media attachment has not completed yet, + // poll the server until it is, before showing the media attachment as uploaded + + if (status === 200) { + dispatch(uploadComposeSuccess(data, file)); + } else if (status === 202) { + dispatch(uploadComposeProcessing()); + + let tryCount = 1; + + const poll = () => { + api(getState).get(`/api/v1/media/${data.id}`).then(response => { + if (response.status === 200) { + dispatch(uploadComposeSuccess(response.data, file)); + } else if (response.status === 206) { + const retryAfter = (Math.log2(tryCount) || 1) * 1000; + tryCount += 1; + setTimeout(() => poll(), retryAfter); + } + }).catch(error => dispatch(uploadComposeFail(error))); + }; + + poll(); + } + + }).catch(error => dispatch(uploadComposeFail(error))); + } + }; +} + +export const uploadComposeProcessing = () => ({ + type: COMPOSE_UPLOAD_PROCESSING, +}); + +export const uploadThumbnail = (id, file) => (dispatch, getState) => { + dispatch(uploadThumbnailRequest()); + + const total = file.size; + const data = new FormData(); + + data.append('thumbnail', file); + + api(getState).put(`/api/v1/media/${id}`, data, { + onUploadProgress: ({ loaded }) => { + dispatch(uploadThumbnailProgress(loaded, total)); + }, + }).then(({ data }) => { + dispatch(uploadThumbnailSuccess(data)); + }).catch(error => { + dispatch(uploadThumbnailFail(id, error)); + }); +}; + +export const uploadThumbnailRequest = () => ({ + type: THUMBNAIL_UPLOAD_REQUEST, + skipLoading: true, +}); + +export const uploadThumbnailProgress = (loaded, total) => ({ + type: THUMBNAIL_UPLOAD_PROGRESS, + loaded, + total, + skipLoading: true, +}); + +export const uploadThumbnailSuccess = media => ({ + type: THUMBNAIL_UPLOAD_SUCCESS, + media, + skipLoading: true, +}); + +export const uploadThumbnailFail = error => ({ + type: THUMBNAIL_UPLOAD_FAIL, + error, + skipLoading: true, +}); + +export function initMediaEditModal(id) { + return dispatch => { + dispatch({ + type: INIT_MEDIA_EDIT_MODAL, + id, + }); + + dispatch(openModal({ + modalType: 'FOCAL_POINT', + modalProps: { id }, + })); + }; +} + +export function onChangeMediaDescription(description) { + return { + type: COMPOSE_CHANGE_MEDIA_DESCRIPTION, + description, + }; +} + +export function onChangeMediaFocus(focusX, focusY) { + return { + type: COMPOSE_CHANGE_MEDIA_FOCUS, + focusX, + focusY, + }; +} + +export function changeUploadCompose(id, params) { + return (dispatch, getState) => { + dispatch(changeUploadComposeRequest()); + + let media = getState().getIn(['compose', 'media_attachments']).find((item) => item.get('id') === id); + + // Editing already-attached media is deferred to editing the post itself. + // For simplicity's sake, fake an API reply. + if (media && !media.get('unattached')) { + const { focus, ...other } = params; + const data = { ...media.toJS(), ...other }; + + if (focus) { + const [x, y] = focus.split(','); + data.meta = { focus: { x: parseFloat(x), y: parseFloat(y) } }; + } + + dispatch(changeUploadComposeSuccess(data, true)); + } else { + api(getState).put(`/api/v1/media/${id}`, params).then(response => { + dispatch(changeUploadComposeSuccess(response.data, false)); + }).catch(error => { + dispatch(changeUploadComposeFail(id, error)); + }); + } + }; +} + +export function changeUploadComposeRequest() { + return { + type: COMPOSE_UPLOAD_CHANGE_REQUEST, + skipLoading: true, + }; +} + +export function changeUploadComposeSuccess(media, attached) { + return { + type: COMPOSE_UPLOAD_CHANGE_SUCCESS, + media: media, + attached: attached, + skipLoading: true, + }; +} + +export function changeUploadComposeFail(error) { + return { + type: COMPOSE_UPLOAD_CHANGE_FAIL, + error: error, + skipLoading: true, + }; +} + +export function uploadComposeRequest() { + return { + type: COMPOSE_UPLOAD_REQUEST, + skipLoading: true, + }; +} + +export function uploadComposeProgress(loaded, total) { + return { + type: COMPOSE_UPLOAD_PROGRESS, + loaded: loaded, + total: total, + }; +} + +export function uploadComposeSuccess(media, file) { + return { + type: COMPOSE_UPLOAD_SUCCESS, + media: media, + file: file, + skipLoading: true, + }; +} + +export function uploadComposeFail(error) { + return { + type: COMPOSE_UPLOAD_FAIL, + error: error, + skipLoading: true, + }; +} + +export function undoUploadCompose(media_id) { + return { + type: COMPOSE_UPLOAD_UNDO, + media_id: media_id, + }; +} + +export function clearComposeSuggestions() { + if (fetchComposeSuggestionsAccountsController) { + fetchComposeSuggestionsAccountsController.abort(); + } + return { + type: COMPOSE_SUGGESTIONS_CLEAR, + }; +} + +const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => { + if (fetchComposeSuggestionsAccountsController) { + fetchComposeSuggestionsAccountsController.abort(); + } + + fetchComposeSuggestionsAccountsController = new AbortController(); + + api(getState).get('/api/v1/accounts/search', { + signal: fetchComposeSuggestionsAccountsController.signal, + + params: { + q: token.slice(1), + resolve: false, + limit: 4, + }, + }).then(response => { + dispatch(importFetchedAccounts(response.data)); + dispatch(readyComposeSuggestionsAccounts(token, response.data)); + }).catch(error => { + if (!axios.isCancel(error)) { + dispatch(showAlertForError(error)); + } + }).finally(() => { + fetchComposeSuggestionsAccountsController = undefined; + }); +}, 200, { leading: true, trailing: true }); + +const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => { + const results = emojiSearch(token.replace(':', ''), { maxResults: 5 }); + dispatch(readyComposeSuggestionsEmojis(token, results)); +}; + +const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => { + if (fetchComposeSuggestionsTagsController) { + fetchComposeSuggestionsTagsController.abort(); + } + + dispatch(updateSuggestionTags(token)); + + fetchComposeSuggestionsTagsController = new AbortController(); + + api(getState).get('/api/v2/search', { + signal: fetchComposeSuggestionsTagsController.signal, + + params: { + type: 'hashtags', + q: token.slice(1), + resolve: false, + limit: 4, + }, + }).then(({ data }) => { + dispatch(readyComposeSuggestionsTags(token, data.hashtags)); + }).catch(error => { + if (!axios.isCancel(error)) { + dispatch(showAlertForError(error)); + } + }).finally(() => { + fetchComposeSuggestionsTagsController = undefined; + }); +}, 200, { leading: true, trailing: true }); + +export function fetchComposeSuggestions(token) { + return (dispatch, getState) => { + switch (token[0]) { + case ':': + fetchComposeSuggestionsEmojis(dispatch, getState, token); + break; + case '#': + fetchComposeSuggestionsTags(dispatch, getState, token); + break; + default: + fetchComposeSuggestionsAccounts(dispatch, getState, token); + break; + } + }; +} + +export function readyComposeSuggestionsEmojis(token, emojis) { + return { + type: COMPOSE_SUGGESTIONS_READY, + token, + emojis, + }; +} + +export function readyComposeSuggestionsAccounts(token, accounts) { + return { + type: COMPOSE_SUGGESTIONS_READY, + token, + accounts, + }; +} + +export const readyComposeSuggestionsTags = (token, tags) => ({ + type: COMPOSE_SUGGESTIONS_READY, + token, + tags, +}); + +export function selectComposeSuggestion(position, token, suggestion, path) { + return (dispatch, getState) => { + let completion; + if (suggestion.type === 'emoji') { + completion = suggestion.native || suggestion.colons; + + dispatch(useEmoji(suggestion)); + } else if (suggestion.type === 'hashtag') { + completion = `#${suggestion.name}`; + } else if (suggestion.type === 'account') { + completion = '@' + getState().getIn(['accounts', suggestion.id, 'acct']); + } + + // We don't want to replace hashtags that vary only in case due to accessibility, but we need to fire off an event so that + // the suggestions are dismissed and the cursor moves forward. + if (suggestion.type !== 'hashtag' || token.slice(1).localeCompare(suggestion.name, undefined, { sensitivity: 'accent' }) !== 0) { + dispatch({ + type: COMPOSE_SUGGESTION_SELECT, + position, + token, + completion, + path, + }); + } else { + dispatch({ + type: COMPOSE_SUGGESTION_IGNORE, + position, + token, + completion, + path, + }); + } + }; +} + +export function updateSuggestionTags(token) { + return { + type: COMPOSE_SUGGESTION_TAGS_UPDATE, + token, + }; +} + +export function updateTagHistory(tags) { + return { + type: COMPOSE_TAG_HISTORY_UPDATE, + tags, + }; +} + +export function hydrateCompose() { + return (dispatch, getState) => { + const me = getState().getIn(['meta', 'me']); + const history = tagHistory.get(me); + + if (history !== null) { + dispatch(updateTagHistory(history)); + } + }; +} + +function insertIntoTagHistory(recognizedTags, text) { + return (dispatch, getState) => { + const state = getState(); + const oldHistory = state.getIn(['compose', 'tagHistory']); + const me = state.getIn(['meta', 'me']); + const names = recoverHashtags(recognizedTags, text); + const intersectedOldHistory = oldHistory.filter(name => names.findIndex(newName => newName.toLowerCase() === name.toLowerCase()) === -1); + + names.push(...intersectedOldHistory.toJS()); + + const newHistory = names.slice(0, 1000); + + tagHistory.set(me, newHistory); + dispatch(updateTagHistory(newHistory)); + }; +} + +export function mountCompose() { + return { + type: COMPOSE_MOUNT, + }; +} + +export function unmountCompose() { + return { + type: COMPOSE_UNMOUNT, + }; +} + +export function changeComposeAdvancedOption(option, value) { + return { + option, + type: COMPOSE_ADVANCED_OPTIONS_CHANGE, + value, + }; +} + +export function changeComposeSensitivity() { + return { + type: COMPOSE_SENSITIVITY_CHANGE, + }; +} + +export const changeComposeLanguage = language => ({ + type: COMPOSE_LANGUAGE_CHANGE, + language, +}); + +export function changeComposeSpoilerness() { + return { + type: COMPOSE_SPOILERNESS_CHANGE, + }; +} + +export function changeComposeSpoilerText(text) { + return { + type: COMPOSE_SPOILER_TEXT_CHANGE, + text, + }; +} + +export function changeComposeVisibility(value) { + return { + type: COMPOSE_VISIBILITY_CHANGE, + value, + }; +} + +export function changeComposeContentType(value) { + return { + type: COMPOSE_CONTENT_TYPE_CHANGE, + value, + }; +} + +export function insertEmojiCompose(position, emoji) { + return { + type: COMPOSE_EMOJI_INSERT, + position, + emoji, + }; +} + +export function addPoll() { + return { + type: COMPOSE_POLL_ADD, + }; +} + +export function removePoll() { + return { + type: COMPOSE_POLL_REMOVE, + }; +} + +export function addPollOption(title) { + return { + type: COMPOSE_POLL_OPTION_ADD, + title, + }; +} + +export function changePollOption(index, title) { + return { + type: COMPOSE_POLL_OPTION_CHANGE, + index, + title, + }; +} + +export function removePollOption(index) { + return { + type: COMPOSE_POLL_OPTION_REMOVE, + index, + }; +} + +export function changePollSettings(expiresIn, isMultiple) { + return { + type: COMPOSE_POLL_SETTINGS_CHANGE, + expiresIn, + isMultiple, + }; +} diff --git a/app/javascript/flavours/blobfox/actions/conversations.js b/app/javascript/flavours/blobfox/actions/conversations.js new file mode 100644 index 00000000000000..8c4c4529fb7e8b --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/conversations.js @@ -0,0 +1,113 @@ +import api, { getLinks } from '../api'; + +import { + importFetchedAccounts, + importFetchedStatuses, + importFetchedStatus, +} from './importer'; + +export const CONVERSATIONS_MOUNT = 'CONVERSATIONS_MOUNT'; +export const CONVERSATIONS_UNMOUNT = 'CONVERSATIONS_UNMOUNT'; + +export const CONVERSATIONS_FETCH_REQUEST = 'CONVERSATIONS_FETCH_REQUEST'; +export const CONVERSATIONS_FETCH_SUCCESS = 'CONVERSATIONS_FETCH_SUCCESS'; +export const CONVERSATIONS_FETCH_FAIL = 'CONVERSATIONS_FETCH_FAIL'; +export const CONVERSATIONS_UPDATE = 'CONVERSATIONS_UPDATE'; + +export const CONVERSATIONS_READ = 'CONVERSATIONS_READ'; + +export const CONVERSATIONS_DELETE_REQUEST = 'CONVERSATIONS_DELETE_REQUEST'; +export const CONVERSATIONS_DELETE_SUCCESS = 'CONVERSATIONS_DELETE_SUCCESS'; +export const CONVERSATIONS_DELETE_FAIL = 'CONVERSATIONS_DELETE_FAIL'; + +export const mountConversations = () => ({ + type: CONVERSATIONS_MOUNT, +}); + +export const unmountConversations = () => ({ + type: CONVERSATIONS_UNMOUNT, +}); + +export const markConversationRead = conversationId => (dispatch, getState) => { + dispatch({ + type: CONVERSATIONS_READ, + id: conversationId, + }); + + api(getState).post(`/api/v1/conversations/${conversationId}/read`); +}; + +export const expandConversations = ({ maxId } = {}) => (dispatch, getState) => { + dispatch(expandConversationsRequest()); + + const params = { max_id: maxId }; + + if (!maxId) { + params.since_id = getState().getIn(['conversations', 'items', 0, 'last_status']); + } + + const isLoadingRecent = !!params.since_id; + + api(getState).get('/api/v1/conversations', { params }) + .then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data.reduce((aggr, item) => aggr.concat(item.accounts), []))); + dispatch(importFetchedStatuses(response.data.map(item => item.last_status).filter(x => !!x))); + dispatch(expandConversationsSuccess(response.data, next ? next.uri : null, isLoadingRecent)); + }) + .catch(err => dispatch(expandConversationsFail(err))); +}; + +export const expandConversationsRequest = () => ({ + type: CONVERSATIONS_FETCH_REQUEST, +}); + +export const expandConversationsSuccess = (conversations, next, isLoadingRecent) => ({ + type: CONVERSATIONS_FETCH_SUCCESS, + conversations, + next, + isLoadingRecent, +}); + +export const expandConversationsFail = error => ({ + type: CONVERSATIONS_FETCH_FAIL, + error, +}); + +export const updateConversations = conversation => dispatch => { + dispatch(importFetchedAccounts(conversation.accounts)); + + if (conversation.last_status) { + dispatch(importFetchedStatus(conversation.last_status)); + } + + dispatch({ + type: CONVERSATIONS_UPDATE, + conversation, + }); +}; + +export const deleteConversation = conversationId => (dispatch, getState) => { + dispatch(deleteConversationRequest(conversationId)); + + api(getState).delete(`/api/v1/conversations/${conversationId}`) + .then(() => dispatch(deleteConversationSuccess(conversationId))) + .catch(error => dispatch(deleteConversationFail(conversationId, error))); +}; + +export const deleteConversationRequest = id => ({ + type: CONVERSATIONS_DELETE_REQUEST, + id, +}); + +export const deleteConversationSuccess = id => ({ + type: CONVERSATIONS_DELETE_SUCCESS, + id, +}); + +export const deleteConversationFail = (id, error) => ({ + type: CONVERSATIONS_DELETE_FAIL, + id, + error, +}); diff --git a/app/javascript/flavours/blobfox/actions/custom_emojis.js b/app/javascript/flavours/blobfox/actions/custom_emojis.js new file mode 100644 index 00000000000000..9ec8156b170a21 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/custom_emojis.js @@ -0,0 +1,40 @@ +import api from '../api'; + +export const CUSTOM_EMOJIS_FETCH_REQUEST = 'CUSTOM_EMOJIS_FETCH_REQUEST'; +export const CUSTOM_EMOJIS_FETCH_SUCCESS = 'CUSTOM_EMOJIS_FETCH_SUCCESS'; +export const CUSTOM_EMOJIS_FETCH_FAIL = 'CUSTOM_EMOJIS_FETCH_FAIL'; + +export function fetchCustomEmojis() { + return (dispatch, getState) => { + dispatch(fetchCustomEmojisRequest()); + + api(getState).get('/api/v1/custom_emojis').then(response => { + dispatch(fetchCustomEmojisSuccess(response.data)); + }).catch(error => { + dispatch(fetchCustomEmojisFail(error)); + }); + }; +} + +export function fetchCustomEmojisRequest() { + return { + type: CUSTOM_EMOJIS_FETCH_REQUEST, + skipLoading: true, + }; +} + +export function fetchCustomEmojisSuccess(custom_emojis) { + return { + type: CUSTOM_EMOJIS_FETCH_SUCCESS, + custom_emojis, + skipLoading: true, + }; +} + +export function fetchCustomEmojisFail(error) { + return { + type: CUSTOM_EMOJIS_FETCH_FAIL, + error, + skipLoading: true, + }; +} diff --git a/app/javascript/flavours/blobfox/actions/directory.js b/app/javascript/flavours/blobfox/actions/directory.js new file mode 100644 index 00000000000000..cda63f2b5a43eb --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/directory.js @@ -0,0 +1,62 @@ +import api from '../api'; + +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts } from './importer'; + +export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST'; +export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS'; +export const DIRECTORY_FETCH_FAIL = 'DIRECTORY_FETCH_FAIL'; + +export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST'; +export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS'; +export const DIRECTORY_EXPAND_FAIL = 'DIRECTORY_EXPAND_FAIL'; + +export const fetchDirectory = params => (dispatch, getState) => { + dispatch(fetchDirectoryRequest()); + + api(getState).get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchDirectorySuccess(data)); + dispatch(fetchRelationships(data.map(x => x.id))); + }).catch(error => dispatch(fetchDirectoryFail(error))); +}; + +export const fetchDirectoryRequest = () => ({ + type: DIRECTORY_FETCH_REQUEST, +}); + +export const fetchDirectorySuccess = accounts => ({ + type: DIRECTORY_FETCH_SUCCESS, + accounts, +}); + +export const fetchDirectoryFail = error => ({ + type: DIRECTORY_FETCH_FAIL, + error, +}); + +export const expandDirectory = params => (dispatch, getState) => { + dispatch(expandDirectoryRequest()); + + const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size; + + api(getState).get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(expandDirectorySuccess(data)); + dispatch(fetchRelationships(data.map(x => x.id))); + }).catch(error => dispatch(expandDirectoryFail(error))); +}; + +export const expandDirectoryRequest = () => ({ + type: DIRECTORY_EXPAND_REQUEST, +}); + +export const expandDirectorySuccess = accounts => ({ + type: DIRECTORY_EXPAND_SUCCESS, + accounts, +}); + +export const expandDirectoryFail = error => ({ + type: DIRECTORY_EXPAND_FAIL, + error, +}); diff --git a/app/javascript/flavours/blobfox/actions/domain_blocks.js b/app/javascript/flavours/blobfox/actions/domain_blocks.js new file mode 100644 index 00000000000000..718002613f4053 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/domain_blocks.js @@ -0,0 +1,152 @@ +import api, { getLinks } from '../api'; + +import { blockDomainSuccess, unblockDomainSuccess } from "./domain_blocks_typed"; + +export * from "./domain_blocks_typed"; + +export const DOMAIN_BLOCK_REQUEST = 'DOMAIN_BLOCK_REQUEST'; +export const DOMAIN_BLOCK_FAIL = 'DOMAIN_BLOCK_FAIL'; + +export const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST'; +export const DOMAIN_UNBLOCK_FAIL = 'DOMAIN_UNBLOCK_FAIL'; + +export const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST'; +export const DOMAIN_BLOCKS_FETCH_SUCCESS = 'DOMAIN_BLOCKS_FETCH_SUCCESS'; +export const DOMAIN_BLOCKS_FETCH_FAIL = 'DOMAIN_BLOCKS_FETCH_FAIL'; + +export const DOMAIN_BLOCKS_EXPAND_REQUEST = 'DOMAIN_BLOCKS_EXPAND_REQUEST'; +export const DOMAIN_BLOCKS_EXPAND_SUCCESS = 'DOMAIN_BLOCKS_EXPAND_SUCCESS'; +export const DOMAIN_BLOCKS_EXPAND_FAIL = 'DOMAIN_BLOCKS_EXPAND_FAIL'; + +export function blockDomain(domain) { + return (dispatch, getState) => { + dispatch(blockDomainRequest(domain)); + + api(getState).post('/api/v1/domain_blocks', { domain }).then(() => { + const at_domain = '@' + domain; + const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id')); + + dispatch(blockDomainSuccess({ domain, accounts })); + }).catch(err => { + dispatch(blockDomainFail(domain, err)); + }); + }; +} + +export function blockDomainRequest(domain) { + return { + type: DOMAIN_BLOCK_REQUEST, + domain, + }; +} + +export function blockDomainFail(domain, error) { + return { + type: DOMAIN_BLOCK_FAIL, + domain, + error, + }; +} + +export function unblockDomain(domain) { + return (dispatch, getState) => { + dispatch(unblockDomainRequest(domain)); + + api(getState).delete('/api/v1/domain_blocks', { params: { domain } }).then(() => { + const at_domain = '@' + domain; + const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id')); + dispatch(unblockDomainSuccess({ domain, accounts })); + }).catch(err => { + dispatch(unblockDomainFail(domain, err)); + }); + }; +} + +export function unblockDomainRequest(domain) { + return { + type: DOMAIN_UNBLOCK_REQUEST, + domain, + }; +} + +export function unblockDomainFail(domain, error) { + return { + type: DOMAIN_UNBLOCK_FAIL, + domain, + error, + }; +} + +export function fetchDomainBlocks() { + return (dispatch, getState) => { + dispatch(fetchDomainBlocksRequest()); + + api(getState).get('/api/v1/domain_blocks').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(fetchDomainBlocksSuccess(response.data, next ? next.uri : null)); + }).catch(err => { + dispatch(fetchDomainBlocksFail(err)); + }); + }; +} + +export function fetchDomainBlocksRequest() { + return { + type: DOMAIN_BLOCKS_FETCH_REQUEST, + }; +} + +export function fetchDomainBlocksSuccess(domains, next) { + return { + type: DOMAIN_BLOCKS_FETCH_SUCCESS, + domains, + next, + }; +} + +export function fetchDomainBlocksFail(error) { + return { + type: DOMAIN_BLOCKS_FETCH_FAIL, + error, + }; +} + +export function expandDomainBlocks() { + return (dispatch, getState) => { + const url = getState().getIn(['domain_lists', 'blocks', 'next']); + + if (!url) { + return; + } + + dispatch(expandDomainBlocksRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandDomainBlocksSuccess(response.data, next ? next.uri : null)); + }).catch(err => { + dispatch(expandDomainBlocksFail(err)); + }); + }; +} + +export function expandDomainBlocksRequest() { + return { + type: DOMAIN_BLOCKS_EXPAND_REQUEST, + }; +} + +export function expandDomainBlocksSuccess(domains, next) { + return { + type: DOMAIN_BLOCKS_EXPAND_SUCCESS, + domains, + next, + }; +} + +export function expandDomainBlocksFail(error) { + return { + type: DOMAIN_BLOCKS_EXPAND_FAIL, + error, + }; +} diff --git a/app/javascript/flavours/blobfox/actions/domain_blocks_typed.ts b/app/javascript/flavours/blobfox/actions/domain_blocks_typed.ts new file mode 100644 index 00000000000000..4be6f7ed63495e --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/domain_blocks_typed.ts @@ -0,0 +1,13 @@ +import { createAction } from '@reduxjs/toolkit'; + +import type { Account } from 'flavours/blobfox/models/account'; + +export const blockDomainSuccess = createAction<{ + domain: string; + accounts: Account[]; +}>('domain_blocks/block/SUCCESS'); + +export const unblockDomainSuccess = createAction<{ + domain: string; + accounts: Account[]; +}>('domain_blocks/unblock/SUCCESS'); diff --git a/app/javascript/flavours/blobfox/actions/dropdown_menu.ts b/app/javascript/flavours/blobfox/actions/dropdown_menu.ts new file mode 100644 index 00000000000000..3694df1ae03e29 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/dropdown_menu.ts @@ -0,0 +1,11 @@ +import { createAction } from '@reduxjs/toolkit'; + +export const openDropdownMenu = createAction<{ + id: string; + keyboard: boolean; + scrollKey: string; +}>('dropdownMenu/open'); + +export const closeDropdownMenu = createAction<{ id: string }>( + 'dropdownMenu/close', +); diff --git a/app/javascript/flavours/blobfox/actions/emojis.js b/app/javascript/flavours/blobfox/actions/emojis.js new file mode 100644 index 00000000000000..3b5d53996c8204 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/emojis.js @@ -0,0 +1,14 @@ +import { saveSettings } from './settings'; + +export const EMOJI_USE = 'EMOJI_USE'; + +export function useEmoji(emoji) { + return dispatch => { + dispatch({ + type: EMOJI_USE, + emoji, + }); + + dispatch(saveSettings()); + }; +} diff --git a/app/javascript/flavours/blobfox/actions/favourites.js b/app/javascript/flavours/blobfox/actions/favourites.js new file mode 100644 index 00000000000000..2d4d4e6206e845 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/favourites.js @@ -0,0 +1,94 @@ +import api, { getLinks } from '../api'; + +import { importFetchedStatuses } from './importer'; + +export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST'; +export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS'; +export const FAVOURITED_STATUSES_FETCH_FAIL = 'FAVOURITED_STATUSES_FETCH_FAIL'; + +export const FAVOURITED_STATUSES_EXPAND_REQUEST = 'FAVOURITED_STATUSES_EXPAND_REQUEST'; +export const FAVOURITED_STATUSES_EXPAND_SUCCESS = 'FAVOURITED_STATUSES_EXPAND_SUCCESS'; +export const FAVOURITED_STATUSES_EXPAND_FAIL = 'FAVOURITED_STATUSES_EXPAND_FAIL'; + +export function fetchFavouritedStatuses() { + return (dispatch, getState) => { + if (getState().getIn(['status_lists', 'favourites', 'isLoading'])) { + return; + } + + dispatch(fetchFavouritedStatusesRequest()); + + api(getState).get('/api/v1/favourites').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchFavouritedStatusesFail(error)); + }); + }; +} + +export function fetchFavouritedStatusesRequest() { + return { + type: FAVOURITED_STATUSES_FETCH_REQUEST, + skipLoading: true, + }; +} + +export function fetchFavouritedStatusesSuccess(statuses, next) { + return { + type: FAVOURITED_STATUSES_FETCH_SUCCESS, + statuses, + next, + skipLoading: true, + }; +} + +export function fetchFavouritedStatusesFail(error) { + return { + type: FAVOURITED_STATUSES_FETCH_FAIL, + error, + skipLoading: true, + }; +} + +export function expandFavouritedStatuses() { + return (dispatch, getState) => { + const url = getState().getIn(['status_lists', 'favourites', 'next'], null); + + if (url === null || getState().getIn(['status_lists', 'favourites', 'isLoading'])) { + return; + } + + dispatch(expandFavouritedStatusesRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandFavouritedStatusesFail(error)); + }); + }; +} + +export function expandFavouritedStatusesRequest() { + return { + type: FAVOURITED_STATUSES_EXPAND_REQUEST, + }; +} + +export function expandFavouritedStatusesSuccess(statuses, next) { + return { + type: FAVOURITED_STATUSES_EXPAND_SUCCESS, + statuses, + next, + }; +} + +export function expandFavouritedStatusesFail(error) { + return { + type: FAVOURITED_STATUSES_EXPAND_FAIL, + error, + }; +} diff --git a/app/javascript/flavours/blobfox/actions/featured_tags.js b/app/javascript/flavours/blobfox/actions/featured_tags.js new file mode 100644 index 00000000000000..18bb615394571c --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/featured_tags.js @@ -0,0 +1,34 @@ +import api from '../api'; + +export const FEATURED_TAGS_FETCH_REQUEST = 'FEATURED_TAGS_FETCH_REQUEST'; +export const FEATURED_TAGS_FETCH_SUCCESS = 'FEATURED_TAGS_FETCH_SUCCESS'; +export const FEATURED_TAGS_FETCH_FAIL = 'FEATURED_TAGS_FETCH_FAIL'; + +export const fetchFeaturedTags = (id) => (dispatch, getState) => { + if (getState().getIn(['user_lists', 'featured_tags', id, 'items'])) { + return; + } + + dispatch(fetchFeaturedTagsRequest(id)); + + api(getState).get(`/api/v1/accounts/${id}/featured_tags`) + .then(({ data }) => dispatch(fetchFeaturedTagsSuccess(id, data))) + .catch(err => dispatch(fetchFeaturedTagsFail(id, err))); +}; + +export const fetchFeaturedTagsRequest = (id) => ({ + type: FEATURED_TAGS_FETCH_REQUEST, + id, +}); + +export const fetchFeaturedTagsSuccess = (id, tags) => ({ + type: FEATURED_TAGS_FETCH_SUCCESS, + id, + tags, +}); + +export const fetchFeaturedTagsFail = (id, error) => ({ + type: FEATURED_TAGS_FETCH_FAIL, + id, + error, +}); diff --git a/app/javascript/flavours/blobfox/actions/filters.js b/app/javascript/flavours/blobfox/actions/filters.js new file mode 100644 index 00000000000000..a11956ac564c6a --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/filters.js @@ -0,0 +1,97 @@ +import api from '../api'; + +import { openModal } from './modal'; + +export const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST'; +export const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS'; +export const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL'; + +export const FILTERS_STATUS_CREATE_REQUEST = 'FILTERS_STATUS_CREATE_REQUEST'; +export const FILTERS_STATUS_CREATE_SUCCESS = 'FILTERS_STATUS_CREATE_SUCCESS'; +export const FILTERS_STATUS_CREATE_FAIL = 'FILTERS_STATUS_CREATE_FAIL'; + +export const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST'; +export const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS'; +export const FILTERS_CREATE_FAIL = 'FILTERS_CREATE_FAIL'; + +export const initAddFilter = (status, { contextType }) => dispatch => + dispatch(openModal({ + modalType: 'FILTER', + modalProps: { + statusId: status?.get('id'), + contextType: contextType, + }, + })); + +export const fetchFilters = () => (dispatch, getState) => { + dispatch({ + type: FILTERS_FETCH_REQUEST, + skipLoading: true, + }); + + api(getState) + .get('/api/v2/filters') + .then(({ data }) => dispatch({ + type: FILTERS_FETCH_SUCCESS, + filters: data, + skipLoading: true, + })) + .catch(err => dispatch({ + type: FILTERS_FETCH_FAIL, + err, + skipLoading: true, + skipAlert: true, + })); +}; + +export const createFilterStatus = (params, onSuccess, onFail) => (dispatch, getState) => { + dispatch(createFilterStatusRequest()); + + api(getState).post(`/api/v2/filters/${params.filter_id}/statuses`, params).then(response => { + dispatch(createFilterStatusSuccess(response.data)); + if (onSuccess) onSuccess(); + }).catch(error => { + dispatch(createFilterStatusFail(error)); + if (onFail) onFail(); + }); +}; + +export const createFilterStatusRequest = () => ({ + type: FILTERS_STATUS_CREATE_REQUEST, +}); + +export const createFilterStatusSuccess = filter_status => ({ + type: FILTERS_STATUS_CREATE_SUCCESS, + filter_status, +}); + +export const createFilterStatusFail = error => ({ + type: FILTERS_STATUS_CREATE_FAIL, + error, +}); + +export const createFilter = (params, onSuccess, onFail) => (dispatch, getState) => { + dispatch(createFilterRequest()); + + api(getState).post('/api/v2/filters', params).then(response => { + dispatch(createFilterSuccess(response.data)); + if (onSuccess) onSuccess(response.data); + }).catch(error => { + dispatch(createFilterFail(error)); + if (onFail) onFail(); + }); +}; + +export const createFilterRequest = () => ({ + type: FILTERS_CREATE_REQUEST, +}); + +export const createFilterSuccess = filter => ({ + type: FILTERS_CREATE_SUCCESS, + filter, +}); + +export const createFilterFail = error => ({ + type: FILTERS_CREATE_FAIL, + error, +}); diff --git a/app/javascript/flavours/blobfox/actions/height_cache.js b/app/javascript/flavours/blobfox/actions/height_cache.js new file mode 100644 index 00000000000000..a8645410c85b1b --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/height_cache.js @@ -0,0 +1,17 @@ +export const HEIGHT_CACHE_SET = 'HEIGHT_CACHE_SET'; +export const HEIGHT_CACHE_CLEAR = 'HEIGHT_CACHE_CLEAR'; + +export function setHeight (key, id, height) { + return { + type: HEIGHT_CACHE_SET, + key, + id, + height, + }; +} + +export function clearHeight () { + return { + type: HEIGHT_CACHE_CLEAR, + }; +} diff --git a/app/javascript/flavours/blobfox/actions/history.js b/app/javascript/flavours/blobfox/actions/history.js new file mode 100644 index 00000000000000..52401b7dce3f95 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/history.js @@ -0,0 +1,38 @@ +import api from '../api'; + +import { importFetchedAccounts } from './importer'; + +export const HISTORY_FETCH_REQUEST = 'HISTORY_FETCH_REQUEST'; +export const HISTORY_FETCH_SUCCESS = 'HISTORY_FETCH_SUCCESS'; +export const HISTORY_FETCH_FAIL = 'HISTORY_FETCH_FAIL'; + +export const fetchHistory = statusId => (dispatch, getState) => { + const loading = getState().getIn(['history', statusId, 'loading']); + + if (loading) { + return; + } + + dispatch(fetchHistoryRequest(statusId)); + + api(getState).get(`/api/v1/statuses/${statusId}/history`).then(({ data }) => { + dispatch(importFetchedAccounts(data.map(x => x.account))); + dispatch(fetchHistorySuccess(statusId, data)); + }).catch(error => dispatch(fetchHistoryFail(error))); +}; + +export const fetchHistoryRequest = statusId => ({ + type: HISTORY_FETCH_REQUEST, + statusId, +}); + +export const fetchHistorySuccess = (statusId, history) => ({ + type: HISTORY_FETCH_SUCCESS, + statusId, + history, +}); + +export const fetchHistoryFail = error => ({ + type: HISTORY_FETCH_FAIL, + error, +}); diff --git a/app/javascript/flavours/blobfox/actions/importer/index.js b/app/javascript/flavours/blobfox/actions/importer/index.js new file mode 100644 index 00000000000000..5fbc9bb5bbb64a --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/importer/index.js @@ -0,0 +1,93 @@ +import { importAccounts } from '../accounts_typed'; + +import { normalizeStatus, normalizePoll } from './normalizer'; + +export const STATUS_IMPORT = 'STATUS_IMPORT'; +export const STATUSES_IMPORT = 'STATUSES_IMPORT'; +export const POLLS_IMPORT = 'POLLS_IMPORT'; +export const FILTERS_IMPORT = 'FILTERS_IMPORT'; + +function pushUnique(array, object) { + if (array.every(element => element.id !== object.id)) { + array.push(object); + } +} + +export function importStatus(status) { + return { type: STATUS_IMPORT, status }; +} + +export function importStatuses(statuses) { + return { type: STATUSES_IMPORT, statuses }; +} + +export function importFilters(filters) { + return { type: FILTERS_IMPORT, filters }; +} + +export function importPolls(polls) { + return { type: POLLS_IMPORT, polls }; +} + +export function importFetchedAccount(account) { + return importFetchedAccounts([account]); +} + +export function importFetchedAccounts(accounts) { + const normalAccounts = []; + + function processAccount(account) { + pushUnique(normalAccounts, account); + + if (account.moved) { + processAccount(account.moved); + } + } + + accounts.forEach(processAccount); + + return importAccounts({ accounts: normalAccounts }); +} + +export function importFetchedStatus(status) { + return importFetchedStatuses([status]); +} + +export function importFetchedStatuses(statuses) { + return (dispatch, getState) => { + const accounts = []; + const normalStatuses = []; + const polls = []; + const filters = []; + + function processStatus(status) { + pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id]), getState().get('local_settings'))); + pushUnique(accounts, status.account); + + if (status.filtered) { + status.filtered.forEach(result => pushUnique(filters, result.filter)); + } + + if (status.reblog && status.reblog.id) { + processStatus(status.reblog); + } + + if (status.poll && status.poll.id) { + pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id]))); + } + } + + statuses.forEach(processStatus); + + dispatch(importPolls(polls)); + dispatch(importFetchedAccounts(accounts)); + dispatch(importStatuses(normalStatuses)); + dispatch(importFilters(filters)); + }; +} + +export function importFetchedPoll(poll) { + return (dispatch, getState) => { + dispatch(importPolls([normalizePoll(poll, getState().getIn(['polls', poll.id]))])); + }; +} diff --git a/app/javascript/flavours/blobfox/actions/importer/normalizer.js b/app/javascript/flavours/blobfox/actions/importer/normalizer.js new file mode 100644 index 00000000000000..c2ad0f9908a126 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/importer/normalizer.js @@ -0,0 +1,135 @@ +import escapeTextContentForBrowser from 'escape-html'; + +import emojify from '../../features/emoji/emoji'; +import { autoHideCW } from '../../utils/content_warning'; + +const domParser = new DOMParser(); + +const makeEmojiMap = emojis => emojis.reduce((obj, emoji) => { + obj[`:${emoji.shortcode}:`] = emoji; + return obj; +}, {}); + +export function searchTextFromRawStatus (status) { + const spoilerText = status.spoiler_text || ''; + const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n'); + return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; +} + +export function normalizeFilterResult(result) { + const normalResult = { ...result }; + + normalResult.filter = normalResult.filter.id; + + return normalResult; +} + +export function normalizeStatus(status, normalOldStatus, settings) { + const normalStatus = { ...status }; + normalStatus.account = status.account.id; + + if (status.reblog && status.reblog.id) { + normalStatus.reblog = status.reblog.id; + } + + if (status.poll && status.poll.id) { + normalStatus.poll = status.poll.id; + } + + if (status.filtered) { + normalStatus.filtered = status.filtered.map(normalizeFilterResult); + } + + // Only calculate these values when status first encountered and + // when the underlying values change. Otherwise keep the ones + // already in the reducer + if (normalOldStatus && normalOldStatus.get('content') === normalStatus.content && normalOldStatus.get('spoiler_text') === normalStatus.spoiler_text) { + normalStatus.search_index = normalOldStatus.get('search_index'); + normalStatus.contentHtml = normalOldStatus.get('contentHtml'); + normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); + normalStatus.hidden = normalOldStatus.get('hidden'); + + if (normalOldStatus.get('translation')) { + normalStatus.translation = normalOldStatus.get('translation'); + } + } else { + const spoilerText = normalStatus.spoiler_text || ''; + const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n'); + const emojiMap = makeEmojiMap(normalStatus.emojis); + + normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; + normalStatus.contentHtml = emojify(normalStatus.content, emojiMap); + normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap); + normalStatus.hidden = (spoilerText.length > 0 || normalStatus.sensitive) && autoHideCW(settings, spoilerText); + } + + if (normalOldStatus) { + const list = normalOldStatus.get('media_attachments'); + if (normalStatus.media_attachments && list) { + normalStatus.media_attachments.forEach(item => { + const oldItem = list.find(i => i.get('id') === item.id); + if (oldItem && oldItem.get('description') === item.description) { + item.translation = oldItem.get('translation'); + } + }); + } + } + + return normalStatus; +} + +export function normalizeStatusTranslation(translation, status) { + const emojiMap = makeEmojiMap(status.get('emojis').toJS()); + + const normalTranslation = { + detected_source_language: translation.detected_source_language, + language: translation.language, + provider: translation.provider, + contentHtml: emojify(translation.content, emojiMap), + spoilerHtml: emojify(escapeTextContentForBrowser(translation.spoiler_text), emojiMap), + spoiler_text: translation.spoiler_text, + }; + + return normalTranslation; +} + +export function normalizePoll(poll, normalOldPoll) { + const normalPoll = { ...poll }; + const emojiMap = makeEmojiMap(poll.emojis); + + normalPoll.options = poll.options.map((option, index) => { + const normalOption = { + ...option, + voted: poll.own_votes && poll.own_votes.includes(index), + titleHtml: emojify(escapeTextContentForBrowser(option.title), emojiMap), + }; + + if (normalOldPoll && normalOldPoll.getIn(['options', index, 'title']) === option.title) { + normalOption.translation = normalOldPoll.getIn(['options', index, 'translation']); + } + + return normalOption; + }); + + return normalPoll; +} + +export function normalizePollOptionTranslation(translation, poll) { + const emojiMap = makeEmojiMap(poll.get('emojis').toJS()); + + const normalTranslation = { + ...translation, + titleHtml: emojify(escapeTextContentForBrowser(translation.title), emojiMap), + }; + + return normalTranslation; +} + +export function normalizeAnnouncement(announcement) { + const normalAnnouncement = { ...announcement }; + const emojiMap = makeEmojiMap(normalAnnouncement.emojis); + + normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap); + + return normalAnnouncement; +} diff --git a/app/javascript/flavours/blobfox/actions/interactions.js b/app/javascript/flavours/blobfox/actions/interactions.js new file mode 100644 index 00000000000000..7cc7863af6319d --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/interactions.js @@ -0,0 +1,600 @@ +import api, { getLinks } from '../api'; + +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts, importFetchedStatus } from './importer'; + +export const REBLOG_REQUEST = 'REBLOG_REQUEST'; +export const REBLOG_SUCCESS = 'REBLOG_SUCCESS'; +export const REBLOG_FAIL = 'REBLOG_FAIL'; + +export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST'; +export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS'; +export const REBLOGS_EXPAND_FAIL = 'REBLOGS_EXPAND_FAIL'; + +export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST'; +export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS'; +export const FAVOURITE_FAIL = 'FAVOURITE_FAIL'; + +export const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST'; +export const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS'; +export const UNREBLOG_FAIL = 'UNREBLOG_FAIL'; + +export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST'; +export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS'; +export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL'; + +export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST'; +export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS'; +export const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL'; + +export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST'; +export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS'; +export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL'; + +export const FAVOURITES_EXPAND_REQUEST = 'FAVOURITES_EXPAND_REQUEST'; +export const FAVOURITES_EXPAND_SUCCESS = 'FAVOURITES_EXPAND_SUCCESS'; +export const FAVOURITES_EXPAND_FAIL = 'FAVOURITES_EXPAND_FAIL'; + +export const PIN_REQUEST = 'PIN_REQUEST'; +export const PIN_SUCCESS = 'PIN_SUCCESS'; +export const PIN_FAIL = 'PIN_FAIL'; + +export const UNPIN_REQUEST = 'UNPIN_REQUEST'; +export const UNPIN_SUCCESS = 'UNPIN_SUCCESS'; +export const UNPIN_FAIL = 'UNPIN_FAIL'; + +export const BOOKMARK_REQUEST = 'BOOKMARK_REQUEST'; +export const BOOKMARK_SUCCESS = 'BOOKMARKED_SUCCESS'; +export const BOOKMARK_FAIL = 'BOOKMARKED_FAIL'; + +export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST'; +export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS'; +export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL'; + +export const REACTION_UPDATE = 'REACTION_UPDATE'; + +export const REACTION_ADD_REQUEST = 'REACTION_ADD_REQUEST'; +export const REACTION_ADD_SUCCESS = 'REACTION_ADD_SUCCESS'; +export const REACTION_ADD_FAIL = 'REACTION_ADD_FAIL'; + +export const REACTION_REMOVE_REQUEST = 'REACTION_REMOVE_REQUEST'; +export const REACTION_REMOVE_SUCCESS = 'REACTION_REMOVE_SUCCESS'; +export const REACTION_REMOVE_FAIL = 'REACTION_REMOVE_FAIL'; + +export function reblog(status, visibility) { + return function (dispatch, getState) { + dispatch(reblogRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`, { visibility }).then(function (response) { + // The reblog API method returns a new status wrapped around the original. In this case we are only + // interested in how the original is modified, hence passing it skipping the wrapper + dispatch(importFetchedStatus(response.data.reblog)); + dispatch(reblogSuccess(status)); + }).catch(function (error) { + dispatch(reblogFail(status, error)); + }); + }; +} + +export function unreblog(status) { + return (dispatch, getState) => { + dispatch(unreblogRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(response => { + dispatch(importFetchedStatus(response.data)); + dispatch(unreblogSuccess(status)); + }).catch(error => { + dispatch(unreblogFail(status, error)); + }); + }; +} + +export function reblogRequest(status) { + return { + type: REBLOG_REQUEST, + status: status, + skipLoading: true, + }; +} + +export function reblogSuccess(status) { + return { + type: REBLOG_SUCCESS, + status: status, + skipLoading: true, + }; +} + +export function reblogFail(status, error) { + return { + type: REBLOG_FAIL, + status: status, + error: error, + skipLoading: true, + }; +} + +export function unreblogRequest(status) { + return { + type: UNREBLOG_REQUEST, + status: status, + skipLoading: true, + }; +} + +export function unreblogSuccess(status) { + return { + type: UNREBLOG_SUCCESS, + status: status, + skipLoading: true, + }; +} + +export function unreblogFail(status, error) { + return { + type: UNREBLOG_FAIL, + status: status, + error: error, + skipLoading: true, + }; +} + +export function favourite(status) { + return function (dispatch, getState) { + dispatch(favouriteRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function (response) { + dispatch(importFetchedStatus(response.data)); + dispatch(favouriteSuccess(status)); + }).catch(function (error) { + dispatch(favouriteFail(status, error)); + }); + }; +} + +export function unfavourite(status) { + return (dispatch, getState) => { + dispatch(unfavouriteRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(response => { + dispatch(importFetchedStatus(response.data)); + dispatch(unfavouriteSuccess(status)); + }).catch(error => { + dispatch(unfavouriteFail(status, error)); + }); + }; +} + +export function favouriteRequest(status) { + return { + type: FAVOURITE_REQUEST, + status: status, + skipLoading: true, + }; +} + +export function favouriteSuccess(status) { + return { + type: FAVOURITE_SUCCESS, + status: status, + skipLoading: true, + }; +} + +export function favouriteFail(status, error) { + return { + type: FAVOURITE_FAIL, + status: status, + error: error, + skipLoading: true, + }; +} + +export function unfavouriteRequest(status) { + return { + type: UNFAVOURITE_REQUEST, + status: status, + skipLoading: true, + }; +} + +export function unfavouriteSuccess(status) { + return { + type: UNFAVOURITE_SUCCESS, + status: status, + skipLoading: true, + }; +} + +export function unfavouriteFail(status, error) { + return { + type: UNFAVOURITE_FAIL, + status: status, + error: error, + skipLoading: true, + }; +} + +export function bookmark(status) { + return function (dispatch, getState) { + dispatch(bookmarkRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function (response) { + dispatch(importFetchedStatus(response.data)); + dispatch(bookmarkSuccess(status, response.data)); + }).catch(function (error) { + dispatch(bookmarkFail(status, error)); + }); + }; +} + +export function unbookmark(status) { + return (dispatch, getState) => { + dispatch(unbookmarkRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/unbookmark`).then(response => { + dispatch(importFetchedStatus(response.data)); + dispatch(unbookmarkSuccess(status, response.data)); + }).catch(error => { + dispatch(unbookmarkFail(status, error)); + }); + }; +} + +export function bookmarkRequest(status) { + return { + type: BOOKMARK_REQUEST, + status: status, + }; +} + +export function bookmarkSuccess(status, response) { + return { + type: BOOKMARK_SUCCESS, + status: status, + response: response, + }; +} + +export function bookmarkFail(status, error) { + return { + type: BOOKMARK_FAIL, + status: status, + error: error, + }; +} + +export function unbookmarkRequest(status) { + return { + type: UNBOOKMARK_REQUEST, + status: status, + }; +} + +export function unbookmarkSuccess(status, response) { + return { + type: UNBOOKMARK_SUCCESS, + status: status, + response: response, + }; +} + +export function unbookmarkFail(status, error) { + return { + type: UNBOOKMARK_FAIL, + status: status, + error: error, + }; +} + +export function fetchReblogs(id) { + return (dispatch, getState) => { + dispatch(fetchReblogsRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchReblogsSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => { + dispatch(fetchReblogsFail(id, error)); + }); + }; +} + +export function fetchReblogsRequest(id) { + return { + type: REBLOGS_FETCH_REQUEST, + id, + }; +} + +export function fetchReblogsSuccess(id, accounts, next) { + return { + type: REBLOGS_FETCH_SUCCESS, + id, + accounts, + next, + }; +} + +export function fetchReblogsFail(id, error) { + return { + type: REBLOGS_FETCH_FAIL, + id, + error, + }; +} + +export function expandReblogs(id) { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'reblogged_by', id, 'next']); + if (url === null) { + return; + } + + dispatch(expandReblogsRequest(id)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(expandReblogsSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => dispatch(expandReblogsFail(id, error))); + }; +} + +export function expandReblogsRequest(id) { + return { + type: REBLOGS_EXPAND_REQUEST, + id, + }; +} + +export function expandReblogsSuccess(id, accounts, next) { + return { + type: REBLOGS_EXPAND_SUCCESS, + id, + accounts, + next, + }; +} + +export function expandReblogsFail(id, error) { + return { + type: REBLOGS_EXPAND_FAIL, + id, + error, + }; +} + +export function fetchFavourites(id) { + return (dispatch, getState) => { + dispatch(fetchFavouritesRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchFavouritesSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => { + dispatch(fetchFavouritesFail(id, error)); + }); + }; +} + +export function fetchFavouritesRequest(id) { + return { + type: FAVOURITES_FETCH_REQUEST, + id, + }; +} + +export function fetchFavouritesSuccess(id, accounts, next) { + return { + type: FAVOURITES_FETCH_SUCCESS, + id, + accounts, + next, + }; +} + +export function fetchFavouritesFail(id, error) { + return { + type: FAVOURITES_FETCH_FAIL, + id, + error, + }; +} + +export function expandFavourites(id) { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'favourited_by', id, 'next']); + if (url === null) { + return; + } + + dispatch(expandFavouritesRequest(id)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(expandFavouritesSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => dispatch(expandFavouritesFail(id, error))); + }; +} + +export function expandFavouritesRequest(id) { + return { + type: FAVOURITES_EXPAND_REQUEST, + id, + }; +} + +export function expandFavouritesSuccess(id, accounts, next) { + return { + type: FAVOURITES_EXPAND_SUCCESS, + id, + accounts, + next, + }; +} + +export function expandFavouritesFail(id, error) { + return { + type: FAVOURITES_EXPAND_FAIL, + id, + error, + }; +} + +export function pin(status) { + return (dispatch, getState) => { + dispatch(pinRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { + dispatch(importFetchedStatus(response.data)); + dispatch(pinSuccess(status)); + }).catch(error => { + dispatch(pinFail(status, error)); + }); + }; +} + +export function pinRequest(status) { + return { + type: PIN_REQUEST, + status, + skipLoading: true, + }; +} + +export function pinSuccess(status) { + return { + type: PIN_SUCCESS, + status, + skipLoading: true, + }; +} + +export function pinFail(status, error) { + return { + type: PIN_FAIL, + status, + error, + skipLoading: true, + }; +} + +export function unpin (status) { + return (dispatch, getState) => { + dispatch(unpinRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { + dispatch(importFetchedStatus(response.data)); + dispatch(unpinSuccess(status)); + }).catch(error => { + dispatch(unpinFail(status, error)); + }); + }; +} + +export function unpinRequest(status) { + return { + type: UNPIN_REQUEST, + status, + skipLoading: true, + }; +} + +export function unpinSuccess(status) { + return { + type: UNPIN_SUCCESS, + status, + skipLoading: true, + }; +} + +export function unpinFail(status, error) { + return { + type: UNPIN_FAIL, + status, + error, + skipLoading: true, + }; +} + +export const addReaction = (statusId, name, url) => (dispatch, getState) => { + const status = getState().get('statuses').get(statusId); + let alreadyAdded = false; + if (status) { + const reaction = status.get('reactions').find(x => x.get('name') === name); + if (reaction && reaction.get('me')) { + alreadyAdded = true; + } + } + if (!alreadyAdded) { + dispatch(addReactionRequest(statusId, name, url)); + } + + // encodeURIComponent is required for the Keycap Number Sign emoji, see: + // <https://github.com/blobfox-soc/mastodon/pull/1980#issuecomment-1345538932> + api(getState).post(`/api/v1/statuses/${statusId}/react/${encodeURIComponent(name)}`).then(() => { + dispatch(addReactionSuccess(statusId, name)); + }).catch(err => { + if (!alreadyAdded) { + dispatch(addReactionFail(statusId, name, err)); + } + }); +}; + +export const addReactionRequest = (statusId, name, url) => ({ + type: REACTION_ADD_REQUEST, + id: statusId, + name, + url, +}); + +export const addReactionSuccess = (statusId, name) => ({ + type: REACTION_ADD_SUCCESS, + id: statusId, + name, +}); + +export const addReactionFail = (statusId, name, error) => ({ + type: REACTION_ADD_FAIL, + id: statusId, + name, + error, +}); + +export const removeReaction = (statusId, name) => (dispatch, getState) => { + dispatch(removeReactionRequest(statusId, name)); + + api(getState).post(`/api/v1/statuses/${statusId}/unreact/${encodeURIComponent(name)}`).then(() => { + dispatch(removeReactionSuccess(statusId, name)); + }).catch(err => { + dispatch(removeReactionFail(statusId, name, err)); + }); +}; + +export const removeReactionRequest = (statusId, name) => ({ + type: REACTION_REMOVE_REQUEST, + id: statusId, + name, +}); + +export const removeReactionSuccess = (statusId, name) => ({ + type: REACTION_REMOVE_SUCCESS, + id: statusId, + name, +}); + +export const removeReactionFail = (statusId, name) => ({ + type: REACTION_REMOVE_FAIL, + id: statusId, + name, +}); diff --git a/app/javascript/flavours/blobfox/actions/languages.js b/app/javascript/flavours/blobfox/actions/languages.js new file mode 100644 index 00000000000000..ad186ba0ccd3e5 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/languages.js @@ -0,0 +1,12 @@ +import { saveSettings } from './settings'; + +export const LANGUAGE_USE = 'LANGUAGE_USE'; + +export const useLanguage = language => dispatch => { + dispatch({ + type: LANGUAGE_USE, + language, + }); + + dispatch(saveSettings()); +}; diff --git a/app/javascript/flavours/blobfox/actions/lists.js b/app/javascript/flavours/blobfox/actions/lists.js new file mode 100644 index 00000000000000..b0789cd426450a --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/lists.js @@ -0,0 +1,373 @@ +import api from '../api'; + +import { showAlertForError } from './alerts'; +import { importFetchedAccounts } from './importer'; + +export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST'; +export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS'; +export const LIST_FETCH_FAIL = 'LIST_FETCH_FAIL'; + +export const LISTS_FETCH_REQUEST = 'LISTS_FETCH_REQUEST'; +export const LISTS_FETCH_SUCCESS = 'LISTS_FETCH_SUCCESS'; +export const LISTS_FETCH_FAIL = 'LISTS_FETCH_FAIL'; + +export const LIST_EDITOR_TITLE_CHANGE = 'LIST_EDITOR_TITLE_CHANGE'; +export const LIST_EDITOR_RESET = 'LIST_EDITOR_RESET'; +export const LIST_EDITOR_SETUP = 'LIST_EDITOR_SETUP'; + +export const LIST_CREATE_REQUEST = 'LIST_CREATE_REQUEST'; +export const LIST_CREATE_SUCCESS = 'LIST_CREATE_SUCCESS'; +export const LIST_CREATE_FAIL = 'LIST_CREATE_FAIL'; + +export const LIST_UPDATE_REQUEST = 'LIST_UPDATE_REQUEST'; +export const LIST_UPDATE_SUCCESS = 'LIST_UPDATE_SUCCESS'; +export const LIST_UPDATE_FAIL = 'LIST_UPDATE_FAIL'; + +export const LIST_DELETE_REQUEST = 'LIST_DELETE_REQUEST'; +export const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS'; +export const LIST_DELETE_FAIL = 'LIST_DELETE_FAIL'; + +export const LIST_ACCOUNTS_FETCH_REQUEST = 'LIST_ACCOUNTS_FETCH_REQUEST'; +export const LIST_ACCOUNTS_FETCH_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS'; +export const LIST_ACCOUNTS_FETCH_FAIL = 'LIST_ACCOUNTS_FETCH_FAIL'; + +export const LIST_EDITOR_SUGGESTIONS_CHANGE = 'LIST_EDITOR_SUGGESTIONS_CHANGE'; +export const LIST_EDITOR_SUGGESTIONS_READY = 'LIST_EDITOR_SUGGESTIONS_READY'; +export const LIST_EDITOR_SUGGESTIONS_CLEAR = 'LIST_EDITOR_SUGGESTIONS_CLEAR'; + +export const LIST_EDITOR_ADD_REQUEST = 'LIST_EDITOR_ADD_REQUEST'; +export const LIST_EDITOR_ADD_SUCCESS = 'LIST_EDITOR_ADD_SUCCESS'; +export const LIST_EDITOR_ADD_FAIL = 'LIST_EDITOR_ADD_FAIL'; + +export const LIST_EDITOR_REMOVE_REQUEST = 'LIST_EDITOR_REMOVE_REQUEST'; +export const LIST_EDITOR_REMOVE_SUCCESS = 'LIST_EDITOR_REMOVE_SUCCESS'; +export const LIST_EDITOR_REMOVE_FAIL = 'LIST_EDITOR_REMOVE_FAIL'; + +export const LIST_ADDER_RESET = 'LIST_ADDER_RESET'; +export const LIST_ADDER_SETUP = 'LIST_ADDER_SETUP'; + +export const LIST_ADDER_LISTS_FETCH_REQUEST = 'LIST_ADDER_LISTS_FETCH_REQUEST'; +export const LIST_ADDER_LISTS_FETCH_SUCCESS = 'LIST_ADDER_LISTS_FETCH_SUCCESS'; +export const LIST_ADDER_LISTS_FETCH_FAIL = 'LIST_ADDER_LISTS_FETCH_FAIL'; + +export const fetchList = id => (dispatch, getState) => { + if (getState().getIn(['lists', id])) { + return; + } + + dispatch(fetchListRequest(id)); + + api(getState).get(`/api/v1/lists/${id}`) + .then(({ data }) => dispatch(fetchListSuccess(data))) + .catch(err => dispatch(fetchListFail(id, err))); +}; + +export const fetchListRequest = id => ({ + type: LIST_FETCH_REQUEST, + id, +}); + +export const fetchListSuccess = list => ({ + type: LIST_FETCH_SUCCESS, + list, +}); + +export const fetchListFail = (id, error) => ({ + type: LIST_FETCH_FAIL, + id, + error, +}); + +export const fetchLists = () => (dispatch, getState) => { + dispatch(fetchListsRequest()); + + api(getState).get('/api/v1/lists') + .then(({ data }) => dispatch(fetchListsSuccess(data))) + .catch(err => dispatch(fetchListsFail(err))); +}; + +export const fetchListsRequest = () => ({ + type: LISTS_FETCH_REQUEST, +}); + +export const fetchListsSuccess = lists => ({ + type: LISTS_FETCH_SUCCESS, + lists, +}); + +export const fetchListsFail = error => ({ + type: LISTS_FETCH_FAIL, + error, +}); + +export const submitListEditor = shouldReset => (dispatch, getState) => { + const listId = getState().getIn(['listEditor', 'listId']); + const title = getState().getIn(['listEditor', 'title']); + + if (listId === null) { + dispatch(createList(title, shouldReset)); + } else { + dispatch(updateList(listId, title, shouldReset)); + } +}; + +export const setupListEditor = listId => (dispatch, getState) => { + dispatch({ + type: LIST_EDITOR_SETUP, + list: getState().getIn(['lists', listId]), + }); + + dispatch(fetchListAccounts(listId)); +}; + +export const changeListEditorTitle = value => ({ + type: LIST_EDITOR_TITLE_CHANGE, + value, +}); + +export const createList = (title, shouldReset) => (dispatch, getState) => { + dispatch(createListRequest()); + + api(getState).post('/api/v1/lists', { title }).then(({ data }) => { + dispatch(createListSuccess(data)); + + if (shouldReset) { + dispatch(resetListEditor()); + } + }).catch(err => dispatch(createListFail(err))); +}; + +export const createListRequest = () => ({ + type: LIST_CREATE_REQUEST, +}); + +export const createListSuccess = list => ({ + type: LIST_CREATE_SUCCESS, + list, +}); + +export const createListFail = error => ({ + type: LIST_CREATE_FAIL, + error, +}); + +export const updateList = (id, title, shouldReset, isExclusive, replies_policy) => (dispatch, getState) => { + dispatch(updateListRequest(id)); + + api(getState).put(`/api/v1/lists/${id}`, { title, replies_policy, exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive }).then(({ data }) => { + dispatch(updateListSuccess(data)); + + if (shouldReset) { + dispatch(resetListEditor()); + } + }).catch(err => dispatch(updateListFail(id, err))); +}; + +export const updateListRequest = id => ({ + type: LIST_UPDATE_REQUEST, + id, +}); + +export const updateListSuccess = list => ({ + type: LIST_UPDATE_SUCCESS, + list, +}); + +export const updateListFail = (id, error) => ({ + type: LIST_UPDATE_FAIL, + id, + error, +}); + +export const resetListEditor = () => ({ + type: LIST_EDITOR_RESET, +}); + +export const deleteList = id => (dispatch, getState) => { + dispatch(deleteListRequest(id)); + + api(getState).delete(`/api/v1/lists/${id}`) + .then(() => dispatch(deleteListSuccess(id))) + .catch(err => dispatch(deleteListFail(id, err))); +}; + +export const deleteListRequest = id => ({ + type: LIST_DELETE_REQUEST, + id, +}); + +export const deleteListSuccess = id => ({ + type: LIST_DELETE_SUCCESS, + id, +}); + +export const deleteListFail = (id, error) => ({ + type: LIST_DELETE_FAIL, + id, + error, +}); + +export const fetchListAccounts = listId => (dispatch, getState) => { + dispatch(fetchListAccountsRequest(listId)); + + api(getState).get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchListAccountsSuccess(listId, data)); + }).catch(err => dispatch(fetchListAccountsFail(listId, err))); +}; + +export const fetchListAccountsRequest = id => ({ + type: LIST_ACCOUNTS_FETCH_REQUEST, + id, +}); + +export const fetchListAccountsSuccess = (id, accounts, next) => ({ + type: LIST_ACCOUNTS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +export const fetchListAccountsFail = (id, error) => ({ + type: LIST_ACCOUNTS_FETCH_FAIL, + id, + error, +}); + +export const fetchListSuggestions = q => (dispatch, getState) => { + const params = { + q, + resolve: false, + limit: 4, + following: true, + }; + + api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchListSuggestionsReady(q, data)); + }).catch(error => dispatch(showAlertForError(error))); +}; + +export const fetchListSuggestionsReady = (query, accounts) => ({ + type: LIST_EDITOR_SUGGESTIONS_READY, + query, + accounts, +}); + +export const clearListSuggestions = () => ({ + type: LIST_EDITOR_SUGGESTIONS_CLEAR, +}); + +export const changeListSuggestions = value => ({ + type: LIST_EDITOR_SUGGESTIONS_CHANGE, + value, +}); + +export const addToListEditor = accountId => (dispatch, getState) => { + dispatch(addToList(getState().getIn(['listEditor', 'listId']), accountId)); +}; + +export const addToList = (listId, accountId) => (dispatch, getState) => { + dispatch(addToListRequest(listId, accountId)); + + api(getState).post(`/api/v1/lists/${listId}/accounts`, { account_ids: [accountId] }) + .then(() => dispatch(addToListSuccess(listId, accountId))) + .catch(err => dispatch(addToListFail(listId, accountId, err))); +}; + +export const addToListRequest = (listId, accountId) => ({ + type: LIST_EDITOR_ADD_REQUEST, + listId, + accountId, +}); + +export const addToListSuccess = (listId, accountId) => ({ + type: LIST_EDITOR_ADD_SUCCESS, + listId, + accountId, +}); + +export const addToListFail = (listId, accountId, error) => ({ + type: LIST_EDITOR_ADD_FAIL, + listId, + accountId, + error, +}); + +export const removeFromListEditor = accountId => (dispatch, getState) => { + dispatch(removeFromList(getState().getIn(['listEditor', 'listId']), accountId)); +}; + +export const removeFromList = (listId, accountId) => (dispatch, getState) => { + dispatch(removeFromListRequest(listId, accountId)); + + api(getState).delete(`/api/v1/lists/${listId}/accounts`, { params: { account_ids: [accountId] } }) + .then(() => dispatch(removeFromListSuccess(listId, accountId))) + .catch(err => dispatch(removeFromListFail(listId, accountId, err))); +}; + +export const removeFromListRequest = (listId, accountId) => ({ + type: LIST_EDITOR_REMOVE_REQUEST, + listId, + accountId, +}); + +export const removeFromListSuccess = (listId, accountId) => ({ + type: LIST_EDITOR_REMOVE_SUCCESS, + listId, + accountId, +}); + +export const removeFromListFail = (listId, accountId, error) => ({ + type: LIST_EDITOR_REMOVE_FAIL, + listId, + accountId, + error, +}); + +export const resetListAdder = () => ({ + type: LIST_ADDER_RESET, +}); + +export const setupListAdder = accountId => (dispatch, getState) => { + dispatch({ + type: LIST_ADDER_SETUP, + account: getState().getIn(['accounts', accountId]), + }); + dispatch(fetchLists()); + dispatch(fetchAccountLists(accountId)); +}; + +export const fetchAccountLists = accountId => (dispatch, getState) => { + dispatch(fetchAccountListsRequest(accountId)); + + api(getState).get(`/api/v1/accounts/${accountId}/lists`) + .then(({ data }) => dispatch(fetchAccountListsSuccess(accountId, data))) + .catch(err => dispatch(fetchAccountListsFail(accountId, err))); +}; + +export const fetchAccountListsRequest = id => ({ + type:LIST_ADDER_LISTS_FETCH_REQUEST, + id, +}); + +export const fetchAccountListsSuccess = (id, lists) => ({ + type: LIST_ADDER_LISTS_FETCH_SUCCESS, + id, + lists, +}); + +export const fetchAccountListsFail = (id, err) => ({ + type: LIST_ADDER_LISTS_FETCH_FAIL, + id, + err, +}); + +export const addToListAdder = listId => (dispatch, getState) => { + dispatch(addToList(listId, getState().getIn(['listAdder', 'accountId']))); +}; + +export const removeFromListAdder = listId => (dispatch, getState) => { + dispatch(removeFromList(listId, getState().getIn(['listAdder', 'accountId']))); +}; + diff --git a/app/javascript/flavours/blobfox/actions/local_settings.js b/app/javascript/flavours/blobfox/actions/local_settings.js new file mode 100644 index 00000000000000..a02e9d763eb9cd --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/local_settings.js @@ -0,0 +1,81 @@ +import { expandSpoilers, disableSwiping } from 'flavours/blobfox/initial_state'; + +import { openModal } from './modal'; + +export const LOCAL_SETTING_CHANGE = 'LOCAL_SETTING_CHANGE'; +export const LOCAL_SETTING_DELETE = 'LOCAL_SETTING_DELETE'; + +export function checkDeprecatedLocalSettings() { + return (dispatch, getState) => { + const local_auto_unfold = getState().getIn(['local_settings', 'content_warnings', 'auto_unfold']); + const local_swipe_to_change_columns = getState().getIn(['local_settings', 'swipe_to_change_columns']); + let changed_settings = []; + + if (local_auto_unfold !== null && local_auto_unfold !== undefined) { + if (local_auto_unfold === expandSpoilers) { + dispatch(deleteLocalSetting(['content_warnings', 'auto_unfold'])); + } else { + changed_settings.push('user_setting_expand_spoilers'); + } + } + + if (local_swipe_to_change_columns !== null && local_swipe_to_change_columns !== undefined) { + if (local_swipe_to_change_columns === !disableSwiping) { + dispatch(deleteLocalSetting(['swipe_to_change_columns'])); + } else { + changed_settings.push('user_setting_disable_swiping'); + } + } + + if (changed_settings.length > 0) { + dispatch(openModal({ + modalType: 'DEPRECATED_SETTINGS', + modalProps: { + settings: changed_settings, + onConfirm: () => dispatch(clearDeprecatedLocalSettings()), + }, + })); + } + }; +} + +export function clearDeprecatedLocalSettings() { + return (dispatch) => { + dispatch(deleteLocalSetting(['content_warnings', 'auto_unfold'])); + dispatch(deleteLocalSetting(['swipe_to_change_columns'])); + }; +} + +export function changeLocalSetting(key, value) { + return dispatch => { + dispatch({ + type: LOCAL_SETTING_CHANGE, + key, + value, + }); + + dispatch(saveLocalSettings()); + }; +} + +export function deleteLocalSetting(key) { + return dispatch => { + dispatch({ + type: LOCAL_SETTING_DELETE, + key, + }); + + dispatch(saveLocalSettings()); + }; +} + +// __TODO :__ +// Right now `saveLocalSettings()` doesn't keep track of which user +// is currently signed in, but it might be better to give each user +// their *own* local settings. +export function saveLocalSettings() { + return (_, getState) => { + const localSettings = getState().get('local_settings').toJS(); + localStorage.setItem('mastodon-settings', JSON.stringify(localSettings)); + }; +} diff --git a/app/javascript/flavours/blobfox/actions/markers.js b/app/javascript/flavours/blobfox/actions/markers.js new file mode 100644 index 00000000000000..ccb1b23d6f9f54 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/markers.js @@ -0,0 +1,152 @@ +import { List as ImmutableList } from 'immutable'; + +import { debounce } from 'lodash'; + +import api from '../api'; +import { compareId } from '../compare_id'; + +export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST'; +export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS'; +export const MARKERS_FETCH_FAIL = 'MARKERS_FETCH_FAIL'; +export const MARKERS_SUBMIT_SUCCESS = 'MARKERS_SUBMIT_SUCCESS'; + +export const synchronouslySubmitMarkers = () => (dispatch, getState) => { + const accessToken = getState().getIn(['meta', 'access_token'], ''); + const params = _buildParams(getState()); + + if (Object.keys(params).length === 0 || accessToken === '') { + return; + } + + // The Fetch API allows us to perform requests that will be carried out + // after the page closes. But that only works if the `keepalive` attribute + // is supported. + if (window.fetch && 'keepalive' in new Request('')) { + fetch('/api/v1/markers', { + keepalive: true, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}`, + }, + body: JSON.stringify(params), + }); + + return; + } else if (navigator && navigator.sendBeacon) { + // Failing that, we can use sendBeacon, but we have to encode the data as + // FormData for DoorKeeper to recognize the token. + const formData = new FormData(); + + formData.append('bearer_token', accessToken); + + for (const [id, value] of Object.entries(params)) { + formData.append(`${id}[last_read_id]`, value.last_read_id); + } + + if (navigator.sendBeacon('/api/v1/markers', formData)) { + return; + } + } + + // If neither Fetch nor sendBeacon worked, try to perform a synchronous + // request. + try { + const client = new XMLHttpRequest(); + + client.open('POST', '/api/v1/markers', false); + client.setRequestHeader('Content-Type', 'application/json'); + client.setRequestHeader('Authorization', `Bearer ${accessToken}`); + client.send(JSON.stringify(params)); + } catch (e) { + // Do not make the BeforeUnload handler error out + } +}; + +const _buildParams = (state) => { + const params = {}; + + const lastHomeId = state.getIn(['timelines', 'home', 'items'], ImmutableList()).find(item => item !== null); + const lastNotificationId = state.getIn(['notifications', 'lastReadId']); + + if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) { + params.home = { + last_read_id: lastHomeId, + }; + } + + if (lastNotificationId && lastNotificationId !== '0' && compareId(lastNotificationId, state.getIn(['markers', 'notifications'])) > 0) { + params.notifications = { + last_read_id: lastNotificationId, + }; + } + + return params; +}; + +const debouncedSubmitMarkers = debounce((dispatch, getState) => { + const accessToken = getState().getIn(['meta', 'access_token'], ''); + const params = _buildParams(getState()); + + if (Object.keys(params).length === 0 || accessToken === '') { + return; + } + + api(getState).post('/api/v1/markers', params).then(() => { + dispatch(submitMarkersSuccess(params)); + }).catch(() => {}); +}, 300000, { leading: true, trailing: true }); + +export function submitMarkersSuccess({ home, notifications }) { + return { + type: MARKERS_SUBMIT_SUCCESS, + home: (home || {}).last_read_id, + notifications: (notifications || {}).last_read_id, + }; +} + +export function submitMarkers(params = {}) { + const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); + + if (params.immediate === true) { + debouncedSubmitMarkers.flush(); + } + + return result; +} + +export const fetchMarkers = () => (dispatch, getState) => { + const params = { timeline: ['notifications'] }; + + dispatch(fetchMarkersRequest()); + + api(getState).get('/api/v1/markers', { params }).then(response => { + dispatch(fetchMarkersSuccess(response.data)); + }).catch(error => { + dispatch(fetchMarkersFail(error)); + }); +}; + +export function fetchMarkersRequest() { + return { + type: MARKERS_FETCH_REQUEST, + skipLoading: true, + }; +} + +export function fetchMarkersSuccess(markers) { + return { + type: MARKERS_FETCH_SUCCESS, + markers, + skipLoading: true, + }; +} + +export function fetchMarkersFail(error) { + return { + type: MARKERS_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, + }; +} diff --git a/app/javascript/flavours/blobfox/actions/modal.ts b/app/javascript/flavours/blobfox/actions/modal.ts new file mode 100644 index 00000000000000..441d185a9c808c --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/modal.ts @@ -0,0 +1,19 @@ +import { createAction } from '@reduxjs/toolkit'; + +import type { ModalProps } from 'flavours/blobfox/reducers/modal'; + +import type { MODAL_COMPONENTS } from '../features/ui/components/modal_root'; + +export type ModalType = keyof typeof MODAL_COMPONENTS; + +interface OpenModalPayload { + modalType: ModalType; + modalProps: ModalProps; +} +export const openModal = createAction<OpenModalPayload>('MODAL_OPEN'); + +interface CloseModalPayload { + modalType: ModalType | undefined; + ignoreFocus: boolean; +} +export const closeModal = createAction<CloseModalPayload>('MODAL_CLOSE'); diff --git a/app/javascript/flavours/blobfox/actions/mutes.js b/app/javascript/flavours/blobfox/actions/mutes.js new file mode 100644 index 00000000000000..fb041078b84488 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/mutes.js @@ -0,0 +1,117 @@ +import api, { getLinks } from '../api'; + +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts } from './importer'; +import { openModal } from './modal'; + +export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST'; +export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS'; +export const MUTES_FETCH_FAIL = 'MUTES_FETCH_FAIL'; + +export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST'; +export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS'; +export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL'; + +export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; +export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; +export const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION'; + +export function fetchMutes() { + return (dispatch, getState) => { + dispatch(fetchMutesRequest()); + + api(getState).get('/api/v1/mutes').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchMutesSuccess(response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => dispatch(fetchMutesFail(error))); + }; +} + +export function fetchMutesRequest() { + return { + type: MUTES_FETCH_REQUEST, + }; +} + +export function fetchMutesSuccess(accounts, next) { + return { + type: MUTES_FETCH_SUCCESS, + accounts, + next, + }; +} + +export function fetchMutesFail(error) { + return { + type: MUTES_FETCH_FAIL, + error, + }; +} + +export function expandMutes() { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'mutes', 'next']); + + if (url === null) { + return; + } + + dispatch(expandMutesRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); + dispatch(expandMutesSuccess(response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => dispatch(expandMutesFail(error))); + }; +} + +export function expandMutesRequest() { + return { + type: MUTES_EXPAND_REQUEST, + }; +} + +export function expandMutesSuccess(accounts, next) { + return { + type: MUTES_EXPAND_SUCCESS, + accounts, + next, + }; +} + +export function expandMutesFail(error) { + return { + type: MUTES_EXPAND_FAIL, + error, + }; +} + +export function initMuteModal(account) { + return dispatch => { + dispatch({ + type: MUTES_INIT_MODAL, + account, + }); + + dispatch(openModal({ modalType: 'MUTE' })); + }; +} + +export function toggleHideNotifications() { + return dispatch => { + dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); + }; +} + +export function changeMuteDuration(duration) { + return dispatch => { + dispatch({ + type: MUTES_CHANGE_DURATION, + duration, + }); + }; +} diff --git a/app/javascript/flavours/blobfox/actions/notifications.js b/app/javascript/flavours/blobfox/actions/notifications.js new file mode 100644 index 00000000000000..ced89453d236a5 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/notifications.js @@ -0,0 +1,404 @@ +import { IntlMessageFormat } from 'intl-messageformat'; +import { defineMessages } from 'react-intl'; + +import { List as ImmutableList } from 'immutable'; + +import { compareId } from 'flavours/blobfox/compare_id'; +import { usePendingItems as preferPendingItems } from 'flavours/blobfox/initial_state'; + +import api, { getLinks } from '../api'; +import { unescapeHTML } from '../utils/html'; +import { requestNotificationPermission } from '../utils/notifications'; + +import { fetchFollowRequests, fetchRelationships } from './accounts'; +import { + importFetchedAccount, + importFetchedAccounts, + importFetchedStatus, + importFetchedStatuses, +} from './importer'; +import { submitMarkers } from './markers'; +import { notificationsUpdate } from "./notifications_typed"; +import { register as registerPushNotifications } from './push_notifications'; +import { saveSettings } from './settings'; + +export * from "./notifications_typed"; + +export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; + +// tracking the notif cleaning request +export const NOTIFICATIONS_DELETE_MARKED_REQUEST = 'NOTIFICATIONS_DELETE_MARKED_REQUEST'; +export const NOTIFICATIONS_DELETE_MARKED_SUCCESS = 'NOTIFICATIONS_DELETE_MARKED_SUCCESS'; +export const NOTIFICATIONS_DELETE_MARKED_FAIL = 'NOTIFICATIONS_DELETE_MARKED_FAIL'; +export const NOTIFICATIONS_MARK_ALL_FOR_DELETE = 'NOTIFICATIONS_MARK_ALL_FOR_DELETE'; +export const NOTIFICATIONS_ENTER_CLEARING_MODE = 'NOTIFICATIONS_ENTER_CLEARING_MODE'; // arg: yes +// Unmark notifications (when the cleaning mode is left) +export const NOTIFICATIONS_UNMARK_ALL_FOR_DELETE = 'NOTIFICATIONS_UNMARK_ALL_FOR_DELETE'; +// Mark one for delete +export const NOTIFICATION_MARK_FOR_DELETE = 'NOTIFICATION_MARK_FOR_DELETE'; + +export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST'; +export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS'; +export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL'; + +export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET'; + +export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; +export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; +export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING'; + +export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT'; +export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT'; + +export const NOTIFICATIONS_SET_VISIBILITY = 'NOTIFICATIONS_SET_VISIBILITY'; + +export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ'; + +export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT'; +export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION'; + +defineMessages({ + mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, +}); + +const fetchRelatedRelationships = (dispatch, notifications) => { + const accountIds = notifications.filter(item => ['follow', 'follow_request', 'admin.sign_up'].indexOf(item.type) !== -1).map(item => item.account.id); + + if (accountIds.length > 0) { + dispatch(fetchRelationships(accountIds)); + } +}; + +export const loadPending = () => ({ + type: NOTIFICATIONS_LOAD_PENDING, +}); + +export function updateNotifications(notification, intlMessages, intlLocale) { + return (dispatch, getState) => { + const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']); + const showInColumn = activeFilter === 'all' ? getState().getIn(['settings', 'notifications', 'shows', notification.type], true) : activeFilter === notification.type; + const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); + const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true); + + let filtered = false; + + if (['mention', 'status'].includes(notification.type) && notification.status.filtered) { + const filters = notification.status.filtered.filter(result => result.filter.context.includes('notifications')); + + if (filters.some(result => result.filter.filter_action === 'hide')) { + return; + } + + filtered = filters.length > 0; + } + + if (['follow_request'].includes(notification.type)) { + dispatch(fetchFollowRequests()); + } + + dispatch(submitMarkers()); + + if (showInColumn) { + dispatch(importFetchedAccount(notification.account)); + + if (notification.status) { + dispatch(importFetchedStatus(notification.status)); + } + + if (notification.report) { + dispatch(importFetchedAccount(notification.report.target_account)); + } + + + dispatch(notificationsUpdate({ notification, preferPendingItems, playSound: playSound && !filtered})); + + fetchRelatedRelationships(dispatch, [notification]); + } else if (playSound && !filtered) { + dispatch({ + type: NOTIFICATIONS_UPDATE_NOOP, + meta: { sound: 'boop' }, + }); + } + + // Desktop notifications + if (typeof window.Notification !== 'undefined' && showAlert && !filtered) { + const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username }); + const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : ''); + + const notify = new Notification(title, { body, icon: notification.account.avatar, tag: notification.id }); + + notify.addEventListener('click', () => { + window.focus(); + notify.close(); + }); + } + }; +} + +const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS(); + +const excludeTypesFromFilter = filter => { + const allTypes = ImmutableList([ + 'follow', + 'follow_request', + 'favourite', + 'reaction', + 'reblog', + 'mention', + 'poll', + 'status', + 'update', + 'admin.sign_up', + 'admin.report', + ]); + + return allTypes.filterNot(item => item === filter).toJS(); +}; + +const noOp = () => {}; + +let expandNotificationsController = new AbortController(); + +export function expandNotifications({ maxId, forceLoad } = {}, done = noOp) { + return (dispatch, getState) => { + const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']); + const notifications = getState().get('notifications'); + const isLoadingMore = !!maxId; + + if (notifications.get('isLoading')) { + if (forceLoad) { + expandNotificationsController.abort(); + expandNotificationsController = new AbortController(); + } else { + done(); + return; + } + } + + const params = { + max_id: maxId, + exclude_types: activeFilter === 'all' + ? excludeTypesFromSettings(getState()) + : excludeTypesFromFilter(activeFilter), + }; + + if (!params.max_id && (notifications.get('items', ImmutableList()).size + notifications.get('pendingItems', ImmutableList()).size) > 0) { + const a = notifications.getIn(['pendingItems', 0, 'id']); + const b = notifications.getIn(['items', 0, 'id']); + + if (a && b && compareId(a, b) > 0) { + params.since_id = a; + } else { + params.since_id = b || a; + } + } + + const isLoadingRecent = !!params.since_id; + + dispatch(expandNotificationsRequest(isLoadingMore)); + + api(getState).get('/api/v1/notifications', { params, signal: expandNotificationsController.signal }).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data.map(item => item.account))); + dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status))); + dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account))); + + dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems)); + fetchRelatedRelationships(dispatch, response.data); + dispatch(submitMarkers()); + }).catch(error => { + dispatch(expandNotificationsFail(error, isLoadingMore)); + }).finally(() => { + done(); + }); + }; +} + +export function expandNotificationsRequest(isLoadingMore) { + return { + type: NOTIFICATIONS_EXPAND_REQUEST, + skipLoading: !isLoadingMore, + }; +} + +export function expandNotificationsSuccess(notifications, next, isLoadingMore, isLoadingRecent, usePendingItems) { + return { + type: NOTIFICATIONS_EXPAND_SUCCESS, + notifications, + next, + isLoadingRecent: isLoadingRecent, + usePendingItems, + skipLoading: !isLoadingMore, + }; +} + +export function expandNotificationsFail(error, isLoadingMore) { + return { + type: NOTIFICATIONS_EXPAND_FAIL, + error, + skipLoading: !isLoadingMore, + skipAlert: !isLoadingMore || error.name === 'AbortError', + }; +} + +export function clearNotifications() { + return (dispatch, getState) => { + dispatch({ + type: NOTIFICATIONS_CLEAR, + }); + + api(getState).post('/api/v1/notifications/clear'); + }; +} + +export function scrollTopNotifications(top) { + return { + type: NOTIFICATIONS_SCROLL_TOP, + top, + }; +} + +export function deleteMarkedNotifications() { + return (dispatch, getState) => { + dispatch(deleteMarkedNotificationsRequest()); + + let ids = []; + getState().getIn(['notifications', 'items']).forEach((n) => { + if (n.get('markedForDelete')) { + ids.push(n.get('id')); + } + }); + + if (ids.length === 0) { + return; + } + + api(getState).delete(`/api/v1/notifications/destroy_multiple?ids[]=${ids.join('&ids[]=')}`).then(() => { + dispatch(deleteMarkedNotificationsSuccess()); + }).catch(error => { + console.error(error); + dispatch(deleteMarkedNotificationsFail(error)); + }); + }; +} + +export function enterNotificationClearingMode(yes) { + return { + type: NOTIFICATIONS_ENTER_CLEARING_MODE, + yes: yes, + }; +} + +export function markAllNotifications(yes) { + return { + type: NOTIFICATIONS_MARK_ALL_FOR_DELETE, + yes: yes, // true, false or null. null = invert + }; +} + +export function deleteMarkedNotificationsRequest() { + return { + type: NOTIFICATIONS_DELETE_MARKED_REQUEST, + }; +} + +export function deleteMarkedNotificationsFail() { + return { + type: NOTIFICATIONS_DELETE_MARKED_FAIL, + }; +} + +export function markNotificationForDelete(id, yes) { + return { + type: NOTIFICATION_MARK_FOR_DELETE, + id: id, + yes: yes, + }; +} + +export function deleteMarkedNotificationsSuccess() { + return { + type: NOTIFICATIONS_DELETE_MARKED_SUCCESS, + }; +} + +export function mountNotifications() { + return { + type: NOTIFICATIONS_MOUNT, + }; +} + +export function unmountNotifications() { + return { + type: NOTIFICATIONS_UNMOUNT, + }; +} + +export function notificationsSetVisibility(visibility) { + return { + type: NOTIFICATIONS_SET_VISIBILITY, + visibility: visibility, + }; +} + +export function setFilter (filterType) { + return dispatch => { + dispatch({ + type: NOTIFICATIONS_FILTER_SET, + path: ['notifications', 'quickFilter', 'active'], + value: filterType, + }); + dispatch(expandNotifications({ forceLoad: true })); + dispatch(saveSettings()); + }; +} + +export function markNotificationsAsRead() { + return { + type: NOTIFICATIONS_MARK_AS_READ, + }; +} + +// Browser support +export function setupBrowserNotifications() { + return dispatch => { + dispatch(setBrowserSupport('Notification' in window)); + if ('Notification' in window) { + dispatch(setBrowserPermission(Notification.permission)); + } + + if ('Notification' in window && 'permissions' in navigator) { + navigator.permissions.query({ name: 'notifications' }).then((status) => { + status.onchange = () => dispatch(setBrowserPermission(Notification.permission)); + }).catch(console.warn); + } + }; +} + +export function requestBrowserPermission(callback = noOp) { + return dispatch => { + requestNotificationPermission((permission) => { + dispatch(setBrowserPermission(permission)); + callback(permission); + + if (permission === 'granted') { + dispatch(registerPushNotifications()); + } + }); + }; +} + +export function setBrowserSupport (value) { + return { + type: NOTIFICATIONS_SET_BROWSER_SUPPORT, + value, + }; +} + +export function setBrowserPermission (value) { + return { + type: NOTIFICATIONS_SET_BROWSER_PERMISSION, + value, + }; +} diff --git a/app/javascript/flavours/blobfox/actions/notifications_typed.ts b/app/javascript/flavours/blobfox/actions/notifications_typed.ts new file mode 100644 index 00000000000000..176362f4b1edd4 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/notifications_typed.ts @@ -0,0 +1,23 @@ +import { createAction } from '@reduxjs/toolkit'; + +import type { ApiAccountJSON } from '../api_types/accounts'; +// To be replaced once ApiNotificationJSON type exists +interface FakeApiNotificationJSON { + type: string; + account: ApiAccountJSON; +} + +export const notificationsUpdate = createAction( + 'notifications/update', + ({ + playSound, + ...args + }: { + notification: FakeApiNotificationJSON; + usePendingItems: boolean; + playSound: boolean; + }) => ({ + payload: args, + meta: { sound: playSound ? 'boop' : undefined }, + }), +); diff --git a/app/javascript/flavours/blobfox/actions/onboarding.js b/app/javascript/flavours/blobfox/actions/onboarding.js new file mode 100644 index 00000000000000..a1dd3a731eddc1 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/onboarding.js @@ -0,0 +1,8 @@ +import { changeSetting, saveSettings } from './settings'; + +export const INTRODUCTION_VERSION = 20181216044202; + +export const closeOnboarding = () => dispatch => { + dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION)); + dispatch(saveSettings()); +}; diff --git a/app/javascript/flavours/blobfox/actions/picture_in_picture.js b/app/javascript/flavours/blobfox/actions/picture_in_picture.js new file mode 100644 index 00000000000000..898375abeb9337 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/picture_in_picture.js @@ -0,0 +1,46 @@ +// @ts-check + +export const PICTURE_IN_PICTURE_DEPLOY = 'PICTURE_IN_PICTURE_DEPLOY'; +export const PICTURE_IN_PICTURE_REMOVE = 'PICTURE_IN_PICTURE_REMOVE'; + +/** + * @typedef MediaProps + * @property {string} src + * @property {boolean} muted + * @property {number} volume + * @property {number} currentTime + * @property {string} poster + * @property {string} backgroundColor + * @property {string} foregroundColor + * @property {string} accentColor + */ + +/** + * @param {string} statusId + * @param {string} accountId + * @param {string} playerType + * @param {MediaProps} props + * @returns {object} + */ +export const deployPictureInPicture = (statusId, accountId, playerType, props) => { + // @ts-expect-error + return (dispatch, getState) => { + // Do not open a player for a toot that does not exist + if (getState().hasIn(['statuses', statusId])) { + dispatch({ + type: PICTURE_IN_PICTURE_DEPLOY, + statusId, + accountId, + playerType, + props, + }); + } + }; +}; + +/* + * @return {object} + */ +export const removePictureInPicture = () => ({ + type: PICTURE_IN_PICTURE_REMOVE, +}); diff --git a/app/javascript/flavours/blobfox/actions/pin_statuses.js b/app/javascript/flavours/blobfox/actions/pin_statuses.js new file mode 100644 index 00000000000000..baa10d15627d66 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/pin_statuses.js @@ -0,0 +1,42 @@ +import api from '../api'; +import { me } from '../initial_state'; + +import { importFetchedStatuses } from './importer'; + +export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST'; +export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS'; +export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL'; + +export function fetchPinnedStatuses() { + return (dispatch, getState) => { + dispatch(fetchPinnedStatusesRequest()); + + api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => { + dispatch(importFetchedStatuses(response.data)); + dispatch(fetchPinnedStatusesSuccess(response.data, null)); + }).catch(error => { + dispatch(fetchPinnedStatusesFail(error)); + }); + }; +} + +export function fetchPinnedStatusesRequest() { + return { + type: PINNED_STATUSES_FETCH_REQUEST, + }; +} + +export function fetchPinnedStatusesSuccess(statuses, next) { + return { + type: PINNED_STATUSES_FETCH_SUCCESS, + statuses, + next, + }; +} + +export function fetchPinnedStatusesFail(error) { + return { + type: PINNED_STATUSES_FETCH_FAIL, + error, + }; +} diff --git a/app/javascript/flavours/blobfox/actions/polls.js b/app/javascript/flavours/blobfox/actions/polls.js new file mode 100644 index 00000000000000..a37410dc90fa4d --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/polls.js @@ -0,0 +1,61 @@ +import api from '../api'; + +import { importFetchedPoll } from './importer'; + +export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST'; +export const POLL_VOTE_SUCCESS = 'POLL_VOTE_SUCCESS'; +export const POLL_VOTE_FAIL = 'POLL_VOTE_FAIL'; + +export const POLL_FETCH_REQUEST = 'POLL_FETCH_REQUEST'; +export const POLL_FETCH_SUCCESS = 'POLL_FETCH_SUCCESS'; +export const POLL_FETCH_FAIL = 'POLL_FETCH_FAIL'; + +export const vote = (pollId, choices) => (dispatch, getState) => { + dispatch(voteRequest()); + + api(getState).post(`/api/v1/polls/${pollId}/votes`, { choices }) + .then(({ data }) => { + dispatch(importFetchedPoll(data)); + dispatch(voteSuccess(data)); + }) + .catch(err => dispatch(voteFail(err))); +}; + +export const fetchPoll = pollId => (dispatch, getState) => { + dispatch(fetchPollRequest()); + + api(getState).get(`/api/v1/polls/${pollId}`) + .then(({ data }) => { + dispatch(importFetchedPoll(data)); + dispatch(fetchPollSuccess(data)); + }) + .catch(err => dispatch(fetchPollFail(err))); +}; + +export const voteRequest = () => ({ + type: POLL_VOTE_REQUEST, +}); + +export const voteSuccess = poll => ({ + type: POLL_VOTE_SUCCESS, + poll, +}); + +export const voteFail = error => ({ + type: POLL_VOTE_FAIL, + error, +}); + +export const fetchPollRequest = () => ({ + type: POLL_FETCH_REQUEST, +}); + +export const fetchPollSuccess = poll => ({ + type: POLL_FETCH_SUCCESS, + poll, +}); + +export const fetchPollFail = error => ({ + type: POLL_FETCH_FAIL, + error, +}); diff --git a/app/javascript/flavours/blobfox/actions/push_notifications/index.js b/app/javascript/flavours/blobfox/actions/push_notifications/index.js new file mode 100644 index 00000000000000..46b63867f1c224 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/push_notifications/index.js @@ -0,0 +1,17 @@ +import { saveSettings } from './registerer'; +import { setAlerts } from './setter'; + +export function changeAlerts(path, value) { + return dispatch => { + dispatch(setAlerts(path, value)); + dispatch(saveSettings()); + }; +} + +export { + CLEAR_SUBSCRIPTION, + SET_BROWSER_SUPPORT, + SET_SUBSCRIPTION, + SET_ALERTS, +} from './setter'; +export { register } from './registerer'; diff --git a/app/javascript/flavours/blobfox/actions/push_notifications/registerer.js b/app/javascript/flavours/blobfox/actions/push_notifications/registerer.js new file mode 100644 index 00000000000000..b3d3850e31d115 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/push_notifications/registerer.js @@ -0,0 +1,134 @@ +import api from '../../api'; +import { me } from '../../initial_state'; +import { pushNotificationsSetting } from '../../settings'; +import { decode as decodeBase64 } from '../../utils/base64'; + +import { setBrowserSupport, setSubscription, clearSubscription } from './setter'; + +// Taken from https://www.npmjs.com/package/web-push +const urlBase64ToUint8Array = (base64String) => { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/-/g, '+') + .replace(/_/g, '/'); + + return decodeBase64(base64); +}; + +const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content'); + +const getRegistration = () => navigator.serviceWorker.ready; + +const getPushSubscription = (registration) => + registration.pushManager.getSubscription() + .then(subscription => ({ registration, subscription })); + +const subscribe = (registration) => + registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(getApplicationServerKey()), + }); + +const unsubscribe = ({ registration, subscription }) => + subscription ? subscription.unsubscribe().then(() => registration) : registration; + +const sendSubscriptionToBackend = (subscription) => { + const params = { subscription }; + + if (me) { + const data = pushNotificationsSetting.get(me); + if (data) { + params.data = data; + } + } + + return api().post('/api/web/push_subscriptions', params).then(response => response.data); +}; + +// Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload +const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype); + +export function register () { + return (dispatch, getState) => { + dispatch(setBrowserSupport(supportsPushNotifications)); + + if (supportsPushNotifications) { + if (!getApplicationServerKey()) { + console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.'); + return; + } + + getRegistration() + .then(getPushSubscription) + .then(({ registration, subscription }) => { + if (subscription !== null) { + // We have a subscription, check if it is still valid + const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey)).toString(); + const subscriptionServerKey = urlBase64ToUint8Array(getApplicationServerKey()).toString(); + const serverEndpoint = getState().getIn(['push_notifications', 'subscription', 'endpoint']); + + // If the VAPID public key did not change and the endpoint corresponds + // to the endpoint saved in the backend, the subscription is valid + if (subscriptionServerKey === currentServerKey && subscription.endpoint === serverEndpoint) { + return subscription; + } else { + // Something went wrong, try to subscribe again + return unsubscribe({ registration, subscription }).then(subscribe).then( + subscription => sendSubscriptionToBackend(subscription)); + } + } + + // No subscription, try to subscribe + return subscribe(registration).then( + subscription => sendSubscriptionToBackend(subscription)); + }) + .then(subscription => { + // If we got a PushSubscription (and not a subscription object from the backend) + // it means that the backend subscription is valid (and was set during hydration) + if (!(subscription instanceof PushSubscription)) { + dispatch(setSubscription(subscription)); + if (me) { + pushNotificationsSetting.set(me, { alerts: subscription.alerts }); + } + } + }) + .catch(error => { + if (error.code === 20 && error.name === 'AbortError') { + console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.'); + } else if (error.code === 5 && error.name === 'InvalidCharacterError') { + console.error('The VAPID public key seems to be invalid:', getApplicationServerKey()); + } + + // Clear alerts and hide UI settings + dispatch(clearSubscription()); + if (me) { + pushNotificationsSetting.remove(me); + } + + return getRegistration() + .then(getPushSubscription) + .then(unsubscribe); + }) + .catch(console.warn); + } else { + console.warn('Your browser does not support Web Push Notifications.'); + } + }; +} + +export function saveSettings() { + return (_, getState) => { + const state = getState().get('push_notifications'); + const subscription = state.get('subscription'); + const alerts = state.get('alerts'); + const data = { alerts }; + + api().put(`/api/web/push_subscriptions/${subscription.get('id')}`, { + data, + }).then(() => { + if (me) { + pushNotificationsSetting.set(me, data); + } + }).catch(console.warn); + }; +} diff --git a/app/javascript/flavours/blobfox/actions/push_notifications/setter.js b/app/javascript/flavours/blobfox/actions/push_notifications/setter.js new file mode 100644 index 00000000000000..5561766bff42b3 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/push_notifications/setter.js @@ -0,0 +1,34 @@ +export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT'; +export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION'; +export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION'; +export const SET_ALERTS = 'PUSH_NOTIFICATIONS_SET_ALERTS'; + +export function setBrowserSupport (value) { + return { + type: SET_BROWSER_SUPPORT, + value, + }; +} + +export function setSubscription (subscription) { + return { + type: SET_SUBSCRIPTION, + subscription, + }; +} + +export function clearSubscription () { + return { + type: CLEAR_SUBSCRIPTION, + }; +} + +export function setAlerts (path, value) { + return dispatch => { + dispatch({ + type: SET_ALERTS, + path, + value, + }); + }; +} diff --git a/app/javascript/flavours/blobfox/actions/reports.js b/app/javascript/flavours/blobfox/actions/reports.js new file mode 100644 index 00000000000000..756b8cd05e1648 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/reports.js @@ -0,0 +1,42 @@ +import api from '../api'; + +import { openModal } from './modal'; + +export const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST'; +export const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS'; +export const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL'; + +export const initReport = (account, status) => dispatch => + dispatch(openModal({ + modalType: 'REPORT', + modalProps: { + accountId: account.get('id'), + statusId: status?.get('id'), + }, + })); + +export const submitReport = (params, onSuccess, onFail) => (dispatch, getState) => { + dispatch(submitReportRequest()); + + api(getState).post('/api/v1/reports', params).then(response => { + dispatch(submitReportSuccess(response.data)); + if (onSuccess) onSuccess(); + }).catch(error => { + dispatch(submitReportFail(error)); + if (onFail) onFail(); + }); +}; + +export const submitReportRequest = () => ({ + type: REPORT_SUBMIT_REQUEST, +}); + +export const submitReportSuccess = report => ({ + type: REPORT_SUBMIT_SUCCESS, + report, +}); + +export const submitReportFail = error => ({ + type: REPORT_SUBMIT_FAIL, + error, +}); diff --git a/app/javascript/flavours/blobfox/actions/search.js b/app/javascript/flavours/blobfox/actions/search.js new file mode 100644 index 00000000000000..7fcc1edcd8db07 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/search.js @@ -0,0 +1,201 @@ +import { fromJS } from 'immutable'; + +import { searchHistory } from 'flavours/blobfox/settings'; + +import api from '../api'; + +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts, importFetchedStatuses } from './importer'; + +export const SEARCH_CHANGE = 'SEARCH_CHANGE'; +export const SEARCH_CLEAR = 'SEARCH_CLEAR'; +export const SEARCH_SHOW = 'SEARCH_SHOW'; + +export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST'; +export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS'; +export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL'; + +export const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST'; +export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS'; +export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL'; + +export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE'; + +export function changeSearch(value) { + return { + type: SEARCH_CHANGE, + value, + }; +} + +export function clearSearch() { + return { + type: SEARCH_CLEAR, + }; +} + +export function submitSearch(type) { + return (dispatch, getState) => { + const value = getState().getIn(['search', 'value']); + const signedIn = !!getState().getIn(['meta', 'me']); + + if (value.length === 0) { + dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '', type)); + return; + } + + dispatch(fetchSearchRequest(type)); + + api(getState).get('/api/v2/search', { + params: { + q: value, + resolve: signedIn, + limit: 11, + type, + }, + }).then(response => { + if (response.data.accounts) { + dispatch(importFetchedAccounts(response.data.accounts)); + } + + if (response.data.statuses) { + dispatch(importFetchedStatuses(response.data.statuses)); + } + + dispatch(fetchSearchSuccess(response.data, value, type)); + dispatch(fetchRelationships(response.data.accounts.map(item => item.id))); + }).catch(error => { + dispatch(fetchSearchFail(error)); + }); + }; +} + +export function fetchSearchRequest(searchType) { + return { + type: SEARCH_FETCH_REQUEST, + searchType, + }; +} + +export function fetchSearchSuccess(results, searchTerm, searchType) { + return { + type: SEARCH_FETCH_SUCCESS, + results, + searchType, + searchTerm, + }; +} + +export function fetchSearchFail(error) { + return { + type: SEARCH_FETCH_FAIL, + error, + }; +} + +export const expandSearch = type => (dispatch, getState) => { + const value = getState().getIn(['search', 'value']); + const offset = getState().getIn(['search', 'results', type]).size - 1; + + dispatch(expandSearchRequest(type)); + + api(getState).get('/api/v2/search', { + params: { + q: value, + type, + offset, + limit: 11, + }, + }).then(({ data }) => { + if (data.accounts) { + dispatch(importFetchedAccounts(data.accounts)); + } + + if (data.statuses) { + dispatch(importFetchedStatuses(data.statuses)); + } + + dispatch(expandSearchSuccess(data, value, type)); + dispatch(fetchRelationships(data.accounts.map(item => item.id))); + }).catch(error => { + dispatch(expandSearchFail(error)); + }); +}; + +export const expandSearchRequest = (searchType) => ({ + type: SEARCH_EXPAND_REQUEST, + searchType, +}); + +export const expandSearchSuccess = (results, searchTerm, searchType) => ({ + type: SEARCH_EXPAND_SUCCESS, + results, + searchTerm, + searchType, +}); + +export const expandSearchFail = error => ({ + type: SEARCH_EXPAND_FAIL, + error, +}); + +export const showSearch = () => ({ + type: SEARCH_SHOW, +}); + +export const openURL = routerHistory => (dispatch, getState) => { + const value = getState().getIn(['search', 'value']); + const signedIn = !!getState().getIn(['meta', 'me']); + + if (!signedIn) { + return; + } + + dispatch(fetchSearchRequest()); + + api(getState).get('/api/v2/search', { params: { q: value, resolve: true } }).then(response => { + if (response.data.accounts?.length > 0) { + dispatch(importFetchedAccounts(response.data.accounts)); + routerHistory.push(`/@${response.data.accounts[0].acct}`); + } else if (response.data.statuses?.length > 0) { + dispatch(importFetchedStatuses(response.data.statuses)); + routerHistory.push(`/@${response.data.statuses[0].account.acct}/${response.data.statuses[0].id}`); + } + + dispatch(fetchSearchSuccess(response.data, value)); + }).catch(err => { + dispatch(fetchSearchFail(err)); + }); +}; + +export const clickSearchResult = (q, type) => (dispatch, getState) => { + const previous = getState().getIn(['search', 'recent']); + const me = getState().getIn(['meta', 'me']); + const current = previous.add(fromJS({ type, q })).takeLast(4); + + searchHistory.set(me, current.toJS()); + dispatch(updateSearchHistory(current)); +}; + +export const forgetSearchResult = q => (dispatch, getState) => { + const previous = getState().getIn(['search', 'recent']); + const me = getState().getIn(['meta', 'me']); + const current = previous.filterNot(result => result.get('q') === q); + + searchHistory.set(me, current.toJS()); + dispatch(updateSearchHistory(current)); +}; + +export const updateSearchHistory = recent => ({ + type: SEARCH_HISTORY_UPDATE, + recent, +}); + +export const hydrateSearch = () => (dispatch, getState) => { + const me = getState().getIn(['meta', 'me']); + const history = searchHistory.get(me); + + if (history !== null) { + dispatch(updateSearchHistory(history)); + } +}; \ No newline at end of file diff --git a/app/javascript/flavours/blobfox/actions/server.js b/app/javascript/flavours/blobfox/actions/server.js new file mode 100644 index 00000000000000..65f3efc3a72a3d --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/server.js @@ -0,0 +1,131 @@ +import api from '../api'; + +import { importFetchedAccount } from './importer'; + +export const SERVER_FETCH_REQUEST = 'Server_FETCH_REQUEST'; +export const SERVER_FETCH_SUCCESS = 'Server_FETCH_SUCCESS'; +export const SERVER_FETCH_FAIL = 'Server_FETCH_FAIL'; + +export const SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST = 'SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST'; +export const SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS = 'SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS'; +export const SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL = 'SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL'; + +export const EXTENDED_DESCRIPTION_REQUEST = 'EXTENDED_DESCRIPTION_REQUEST'; +export const EXTENDED_DESCRIPTION_SUCCESS = 'EXTENDED_DESCRIPTION_SUCCESS'; +export const EXTENDED_DESCRIPTION_FAIL = 'EXTENDED_DESCRIPTION_FAIL'; + +export const SERVER_DOMAIN_BLOCKS_FETCH_REQUEST = 'SERVER_DOMAIN_BLOCKS_FETCH_REQUEST'; +export const SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS = 'SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS'; +export const SERVER_DOMAIN_BLOCKS_FETCH_FAIL = 'SERVER_DOMAIN_BLOCKS_FETCH_FAIL'; + +export const fetchServer = () => (dispatch, getState) => { + if (getState().getIn(['server', 'server', 'isLoading'])) { + return; + } + + dispatch(fetchServerRequest()); + + api(getState) + .get('/api/v2/instance').then(({ data }) => { + if (data.contact.account) dispatch(importFetchedAccount(data.contact.account)); + dispatch(fetchServerSuccess(data)); + }).catch(err => dispatch(fetchServerFail(err))); +}; + +const fetchServerRequest = () => ({ + type: SERVER_FETCH_REQUEST, +}); + +const fetchServerSuccess = server => ({ + type: SERVER_FETCH_SUCCESS, + server, +}); + +const fetchServerFail = error => ({ + type: SERVER_FETCH_FAIL, + error, +}); + +export const fetchServerTranslationLanguages = () => (dispatch, getState) => { + dispatch(fetchServerTranslationLanguagesRequest()); + + api(getState) + .get('/api/v1/instance/translation_languages').then(({ data }) => { + dispatch(fetchServerTranslationLanguagesSuccess(data)); + }).catch(err => dispatch(fetchServerTranslationLanguagesFail(err))); +}; + +const fetchServerTranslationLanguagesRequest = () => ({ + type: SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST, +}); + +const fetchServerTranslationLanguagesSuccess = translationLanguages => ({ + type: SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS, + translationLanguages, +}); + +const fetchServerTranslationLanguagesFail = error => ({ + type: SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL, + error, +}); + +export const fetchExtendedDescription = () => (dispatch, getState) => { + if (getState().getIn(['server', 'extendedDescription', 'isLoading'])) { + return; + } + + dispatch(fetchExtendedDescriptionRequest()); + + api(getState) + .get('/api/v1/instance/extended_description') + .then(({ data }) => dispatch(fetchExtendedDescriptionSuccess(data))) + .catch(err => dispatch(fetchExtendedDescriptionFail(err))); +}; + +const fetchExtendedDescriptionRequest = () => ({ + type: EXTENDED_DESCRIPTION_REQUEST, +}); + +const fetchExtendedDescriptionSuccess = description => ({ + type: EXTENDED_DESCRIPTION_SUCCESS, + description, +}); + +const fetchExtendedDescriptionFail = error => ({ + type: EXTENDED_DESCRIPTION_FAIL, + error, +}); + +export const fetchDomainBlocks = () => (dispatch, getState) => { + if (getState().getIn(['server', 'domainBlocks', 'isLoading'])) { + return; + } + + dispatch(fetchDomainBlocksRequest()); + + api(getState) + .get('/api/v1/instance/domain_blocks') + .then(({ data }) => dispatch(fetchDomainBlocksSuccess(true, data))) + .catch(err => { + if (err.response.status === 404) { + dispatch(fetchDomainBlocksSuccess(false, [])); + } else { + dispatch(fetchDomainBlocksFail(err)); + } + }); +}; + +const fetchDomainBlocksRequest = () => ({ + type: SERVER_DOMAIN_BLOCKS_FETCH_REQUEST, +}); + +const fetchDomainBlocksSuccess = (isAvailable, blocks) => ({ + type: SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS, + isAvailable, + blocks, +}); + +const fetchDomainBlocksFail = error => ({ + type: SERVER_DOMAIN_BLOCKS_FETCH_FAIL, + error, +}); diff --git a/app/javascript/flavours/blobfox/actions/settings.js b/app/javascript/flavours/blobfox/actions/settings.js new file mode 100644 index 00000000000000..3685b0684e0b83 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/settings.js @@ -0,0 +1,36 @@ +import { debounce } from 'lodash'; + +import api from '../api'; + +import { showAlertForError } from './alerts'; + +export const SETTING_CHANGE = 'SETTING_CHANGE'; +export const SETTING_SAVE = 'SETTING_SAVE'; + +export function changeSetting(path, value) { + return dispatch => { + dispatch({ + type: SETTING_CHANGE, + path, + value, + }); + + dispatch(saveSettings()); + }; +} + +const debouncedSave = debounce((dispatch, getState) => { + if (getState().getIn(['settings', 'saved'])) { + return; + } + + const data = getState().get('settings').filter((_, path) => path !== 'saved').toJS(); + + api().put('/api/web/settings', { data }) + .then(() => dispatch({ type: SETTING_SAVE })) + .catch(error => dispatch(showAlertForError(error))); +}, 5000, { trailing: true }); + +export function saveSettings() { + return (dispatch, getState) => debouncedSave(dispatch, getState); +} diff --git a/app/javascript/flavours/blobfox/actions/statuses.js b/app/javascript/flavours/blobfox/actions/statuses.js new file mode 100644 index 00000000000000..5bdd31c3438cbe --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/statuses.js @@ -0,0 +1,351 @@ +import api from '../api'; + +import { ensureComposeIsVisible, setComposeToStatus } from './compose'; +import { importFetchedStatus, importFetchedStatuses } from './importer'; +import { deleteFromTimelines } from './timelines'; + +export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; +export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; +export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL'; + +export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST'; +export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS'; +export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL'; + +export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST'; +export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS'; +export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL'; + +export const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST'; +export const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS'; +export const STATUS_MUTE_FAIL = 'STATUS_MUTE_FAIL'; + +export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST'; +export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS'; +export const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL'; + +export const STATUS_REVEAL = 'STATUS_REVEAL'; +export const STATUS_HIDE = 'STATUS_HIDE'; +export const STATUS_COLLAPSE = 'STATUS_COLLAPSE'; + +export const REDRAFT = 'REDRAFT'; + +export const STATUS_FETCH_SOURCE_REQUEST = 'STATUS_FETCH_SOURCE_REQUEST'; +export const STATUS_FETCH_SOURCE_SUCCESS = 'STATUS_FETCH_SOURCE_SUCCESS'; +export const STATUS_FETCH_SOURCE_FAIL = 'STATUS_FETCH_SOURCE_FAIL'; + +export const STATUS_TRANSLATE_REQUEST = 'STATUS_TRANSLATE_REQUEST'; +export const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS'; +export const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL'; +export const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO'; + +export function fetchStatusRequest(id, skipLoading) { + return { + type: STATUS_FETCH_REQUEST, + id, + skipLoading, + }; +} + +export function fetchStatus(id, forceFetch = false) { + return (dispatch, getState) => { + const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null; + + dispatch(fetchContext(id)); + + if (skipLoading) { + return; + } + + dispatch(fetchStatusRequest(id, skipLoading)); + + api(getState).get(`/api/v1/statuses/${id}`).then(response => { + dispatch(importFetchedStatus(response.data)); + dispatch(fetchStatusSuccess(skipLoading)); + }).catch(error => { + dispatch(fetchStatusFail(id, error, skipLoading)); + }); + }; +} + +export function fetchStatusSuccess(skipLoading) { + return { + type: STATUS_FETCH_SUCCESS, + skipLoading, + }; +} + +export function fetchStatusFail(id, error, skipLoading) { + return { + type: STATUS_FETCH_FAIL, + id, + error, + skipLoading, + skipAlert: true, + }; +} + +export function redraft(status, raw_text, content_type) { + return { + type: REDRAFT, + status, + raw_text, + content_type, + }; +} + +export const editStatus = (id, routerHistory) => (dispatch, getState) => { + let status = getState().getIn(['statuses', id]); + + if (status.get('poll')) { + status = status.set('poll', getState().getIn(['polls', status.get('poll')])); + } + + dispatch(fetchStatusSourceRequest()); + + api(getState).get(`/api/v1/statuses/${id}/source`).then(response => { + dispatch(fetchStatusSourceSuccess()); + ensureComposeIsVisible(getState, routerHistory); + dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text, response.data.content_type)); + }).catch(error => { + dispatch(fetchStatusSourceFail(error)); + }); +}; + +export const fetchStatusSourceRequest = () => ({ + type: STATUS_FETCH_SOURCE_REQUEST, +}); + +export const fetchStatusSourceSuccess = () => ({ + type: STATUS_FETCH_SOURCE_SUCCESS, +}); + +export const fetchStatusSourceFail = error => ({ + type: STATUS_FETCH_SOURCE_FAIL, + error, +}); + +export function deleteStatus(id, routerHistory, withRedraft = false) { + return (dispatch, getState) => { + let status = getState().getIn(['statuses', id]); + + if (status.get('poll')) { + status = status.set('poll', getState().getIn(['polls', status.get('poll')])); + } + + dispatch(deleteStatusRequest(id)); + + api(getState).delete(`/api/v1/statuses/${id}`).then(response => { + dispatch(deleteStatusSuccess(id)); + dispatch(deleteFromTimelines(id)); + + if (withRedraft) { + dispatch(redraft(status, response.data.text, response.data.content_type)); + + ensureComposeIsVisible(getState, routerHistory); + } + }).catch(error => { + dispatch(deleteStatusFail(id, error)); + }); + }; +} + +export function deleteStatusRequest(id) { + return { + type: STATUS_DELETE_REQUEST, + id: id, + }; +} + +export function deleteStatusSuccess(id) { + return { + type: STATUS_DELETE_SUCCESS, + id: id, + }; +} + +export function deleteStatusFail(id, error) { + return { + type: STATUS_DELETE_FAIL, + id: id, + error: error, + }; +} + +export const updateStatus = status => dispatch => + dispatch(importFetchedStatus(status)); + +export function fetchContext(id) { + return (dispatch, getState) => { + dispatch(fetchContextRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/context`).then(response => { + dispatch(importFetchedStatuses(response.data.ancestors.concat(response.data.descendants))); + dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants)); + + }).catch(error => { + if (error.response && error.response.status === 404) { + dispatch(deleteFromTimelines(id)); + } + + dispatch(fetchContextFail(id, error)); + }); + }; +} + +export function fetchContextRequest(id) { + return { + type: CONTEXT_FETCH_REQUEST, + id, + }; +} + +export function fetchContextSuccess(id, ancestors, descendants) { + return { + type: CONTEXT_FETCH_SUCCESS, + id, + ancestors, + descendants, + statuses: ancestors.concat(descendants), + }; +} + +export function fetchContextFail(id, error) { + return { + type: CONTEXT_FETCH_FAIL, + id, + error, + skipAlert: true, + }; +} + +export function muteStatus(id) { + return (dispatch, getState) => { + dispatch(muteStatusRequest(id)); + + api(getState).post(`/api/v1/statuses/${id}/mute`).then(() => { + dispatch(muteStatusSuccess(id)); + }).catch(error => { + dispatch(muteStatusFail(id, error)); + }); + }; +} + +export function muteStatusRequest(id) { + return { + type: STATUS_MUTE_REQUEST, + id, + }; +} + +export function muteStatusSuccess(id) { + return { + type: STATUS_MUTE_SUCCESS, + id, + }; +} + +export function muteStatusFail(id, error) { + return { + type: STATUS_MUTE_FAIL, + id, + error, + }; +} + +export function unmuteStatus(id) { + return (dispatch, getState) => { + dispatch(unmuteStatusRequest(id)); + + api(getState).post(`/api/v1/statuses/${id}/unmute`).then(() => { + dispatch(unmuteStatusSuccess(id)); + }).catch(error => { + dispatch(unmuteStatusFail(id, error)); + }); + }; +} + +export function unmuteStatusRequest(id) { + return { + type: STATUS_UNMUTE_REQUEST, + id, + }; +} + +export function unmuteStatusSuccess(id) { + return { + type: STATUS_UNMUTE_SUCCESS, + id, + }; +} + +export function unmuteStatusFail(id, error) { + return { + type: STATUS_UNMUTE_FAIL, + id, + error, + }; +} + +export function hideStatus(ids) { + if (!Array.isArray(ids)) { + ids = [ids]; + } + + return { + type: STATUS_HIDE, + ids, + }; +} + +export function revealStatus(ids) { + if (!Array.isArray(ids)) { + ids = [ids]; + } + + return { + type: STATUS_REVEAL, + ids, + }; +} + +export function toggleStatusCollapse(id, isCollapsed) { + return { + type: STATUS_COLLAPSE, + id, + isCollapsed, + }; +} + +export const translateStatus = id => (dispatch, getState) => { + dispatch(translateStatusRequest(id)); + + api(getState).post(`/api/v1/statuses/${id}/translate`).then(response => { + dispatch(translateStatusSuccess(id, response.data)); + }).catch(error => { + dispatch(translateStatusFail(id, error)); + }); +}; + +export const translateStatusRequest = id => ({ + type: STATUS_TRANSLATE_REQUEST, + id, +}); + +export const translateStatusSuccess = (id, translation) => ({ + type: STATUS_TRANSLATE_SUCCESS, + id, + translation, +}); + +export const translateStatusFail = (id, error) => ({ + type: STATUS_TRANSLATE_FAIL, + id, + error, +}); + +export const undoStatusTranslation = (id, pollId) => ({ + type: STATUS_TRANSLATE_UNDO, + id, + pollId, +}); diff --git a/app/javascript/flavours/blobfox/actions/store.js b/app/javascript/flavours/blobfox/actions/store.js new file mode 100644 index 00000000000000..86a42433d25092 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/store.js @@ -0,0 +1,43 @@ +import { Iterable, fromJS } from 'immutable'; + +import { hydrateCompose } from './compose'; +import { importFetchedAccounts } from './importer'; +import { hydrateSearch } from './search'; +import { saveSettings } from './settings'; + +export const STORE_HYDRATE = 'STORE_HYDRATE'; +export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; + +const convertState = rawState => + fromJS(rawState, (k, v) => + Iterable.isIndexed(v) ? v.toList() : v.toMap()); + +const applyMigrations = (state) => { + return state.withMutations(state => { + // Migrate blobfox-soc local-only “Show unread marker” setting to Mastodon's setting + if (state.getIn(['local_settings', 'notifications', 'show_unread']) !== undefined) { + // Only change if the Mastodon setting does not deviate from default + if (state.getIn(['settings', 'notifications', 'showUnread']) !== false) { + state.setIn(['settings', 'notifications', 'showUnread'], state.getIn(['local_settings', 'notifications', 'show_unread'])); + } + state.removeIn(['local_settings', 'notifications', 'show_unread']); + } + }); +}; + + +export function hydrateStore(rawState) { + return dispatch => { + const state = applyMigrations(convertState(rawState)); + + dispatch({ + type: STORE_HYDRATE, + state, + }); + + dispatch(hydrateCompose()); + dispatch(hydrateSearch()); + dispatch(importFetchedAccounts(Object.values(rawState.accounts))); + dispatch(saveSettings()); + }; +} diff --git a/app/javascript/flavours/blobfox/actions/streaming.js b/app/javascript/flavours/blobfox/actions/streaming.js new file mode 100644 index 00000000000000..d8341a5c1682ce --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/streaming.js @@ -0,0 +1,184 @@ +// @ts-check + +import { getLocale } from '../locales'; +import { connectStream } from '../stream'; + +import { + fetchAnnouncements, + updateAnnouncements, + updateReaction as updateAnnouncementsReaction, + deleteAnnouncement, +} from './announcements'; +import { updateConversations } from './conversations'; +import { updateNotifications, expandNotifications } from './notifications'; +import { updateStatus } from './statuses'; +import { + updateTimeline, + deleteFromTimelines, + expandHomeTimeline, + connectTimeline, + disconnectTimeline, + fillHomeTimelineGaps, + fillPublicTimelineGaps, + fillCommunityTimelineGaps, + fillListTimelineGaps, +} from './timelines'; + +/** + * @param {number} max + * @returns {number} + */ +const randomUpTo = max => + Math.floor(Math.random() * Math.floor(max)); + +/** + * @param {string} timelineId + * @param {string} channelName + * @param {Object.<string, string>} params + * @param {Object} options + * @param {function(Function, Function): void} [options.fallback] + * @param {function(): void} [options.fillGaps] + * @param {function(object): boolean} [options.accept] + * @returns {function(): void} + */ +export const connectTimelineStream = (timelineId, channelName, params = {}, options = {}) => { + const { messages } = getLocale(); + + return connectStream(channelName, params, (dispatch, getState) => { + const locale = getState().getIn(['meta', 'locale']); + + // @ts-expect-error + let pollingId; + + /** + * @param {function(Function, Function): void} fallback + */ + + const useFallback = fallback => { + fallback(dispatch, () => { + // eslint-disable-next-line react-hooks/rules-of-hooks -- this is not a react hook + pollingId = setTimeout(() => useFallback(fallback), 20000 + randomUpTo(20000)); + }); + }; + + return { + onConnect() { + dispatch(connectTimeline(timelineId)); + + // @ts-expect-error + if (pollingId) { + // @ts-ignore + clearTimeout(pollingId); pollingId = null; + } + + if (options.fillGaps) { + dispatch(options.fillGaps()); + } + }, + + onDisconnect() { + dispatch(disconnectTimeline(timelineId)); + + if (options.fallback) { + // @ts-expect-error + pollingId = setTimeout(() => useFallback(options.fallback), randomUpTo(40000)); + } + }, + + onReceive(data) { + switch (data.event) { + case 'update': + // @ts-expect-error + dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept)); + break; + case 'status.update': + // @ts-expect-error + dispatch(updateStatus(JSON.parse(data.payload))); + break; + case 'delete': + dispatch(deleteFromTimelines(data.payload)); + break; + case 'notification': + // @ts-expect-error + dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); + break; + case 'conversation': + // @ts-expect-error + dispatch(updateConversations(JSON.parse(data.payload))); + break; + case 'announcement': + // @ts-expect-error + dispatch(updateAnnouncements(JSON.parse(data.payload))); + break; + case 'announcement.reaction': + // @ts-expect-error + dispatch(updateAnnouncementsReaction(JSON.parse(data.payload))); + break; + case 'announcement.delete': + dispatch(deleteAnnouncement(data.payload)); + break; + } + }, + }; + }); +}; + +/** + * @param {Function} dispatch + * @param {function(): void} done + */ +const refreshHomeTimelineAndNotification = (dispatch, done) => { + // @ts-expect-error + dispatch(expandHomeTimeline({}, () => + // @ts-expect-error + dispatch(expandNotifications({}, () => + dispatch(fetchAnnouncements(done)))))); +}; + +/** + * @returns {function(): void} + */ +export const connectUserStream = () => + // @ts-expect-error + connectTimelineStream('home', 'user', {}, { fallback: refreshHomeTimelineAndNotification, fillGaps: fillHomeTimelineGaps }); + +/** + * @param {Object} options + * @param {boolean} [options.onlyMedia] + * @returns {function(): void} + */ +export const connectCommunityStream = ({ onlyMedia } = {}) => + connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => (fillCommunityTimelineGaps({ onlyMedia })) }); + +/** + * @param {Object} options + * @param {boolean} [options.onlyMedia] + * @param {boolean} [options.onlyRemote] + * @param {boolean} [options.allowLocalOnly] + * @returns {function(): void} + */ +export const connectPublicStream = ({ onlyMedia, onlyRemote, allowLocalOnly } = {}) => + connectTimelineStream(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => fillPublicTimelineGaps({ onlyMedia, onlyRemote, allowLocalOnly }) }); + +/** + * @param {string} columnId + * @param {string} tagName + * @param {boolean} onlyLocal + * @param {function(object): boolean} accept + * @returns {function(): void} + */ +export const connectHashtagStream = (columnId, tagName, onlyLocal, accept) => + connectTimelineStream(`hashtag:${columnId}${onlyLocal ? ':local' : ''}`, `hashtag${onlyLocal ? ':local' : ''}`, { tag: tagName }, { accept }); + +/** + * @returns {function(): void} + */ +export const connectDirectStream = () => + connectTimelineStream('direct', 'direct'); + +/** + * @param {string} listId + * @returns {function(): void} + */ +export const connectListStream = listId => + connectTimelineStream(`list:${listId}`, 'list', { list: listId }, { fillGaps: () => fillListTimelineGaps(listId) }); diff --git a/app/javascript/flavours/blobfox/actions/suggestions.js b/app/javascript/flavours/blobfox/actions/suggestions.js new file mode 100644 index 00000000000000..870a311024d12f --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/suggestions.js @@ -0,0 +1,65 @@ +import api from '../api'; + +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts } from './importer'; + +export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST'; +export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS'; +export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL'; + +export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS'; + +export function fetchSuggestions(withRelationships = false) { + return (dispatch, getState) => { + dispatch(fetchSuggestionsRequest()); + + api(getState).get('/api/v2/suggestions', { params: { limit: 20 } }).then(response => { + dispatch(importFetchedAccounts(response.data.map(x => x.account))); + dispatch(fetchSuggestionsSuccess(response.data)); + + if (withRelationships) { + dispatch(fetchRelationships(response.data.map(item => item.account.id))); + } + }).catch(error => dispatch(fetchSuggestionsFail(error))); + }; +} + +export function fetchSuggestionsRequest() { + return { + type: SUGGESTIONS_FETCH_REQUEST, + skipLoading: true, + }; +} + +export function fetchSuggestionsSuccess(suggestions) { + return { + type: SUGGESTIONS_FETCH_SUCCESS, + suggestions, + skipLoading: true, + }; +} + +export function fetchSuggestionsFail(error) { + return { + type: SUGGESTIONS_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, + }; +} + +export const dismissSuggestion = accountId => (dispatch, getState) => { + dispatch({ + type: SUGGESTIONS_DISMISS, + id: accountId, + }); + + api(getState).delete(`/api/v1/suggestions/${accountId}`).then(() => { + dispatch(fetchSuggestionsRequest()); + + api(getState).get('/api/v2/suggestions').then(response => { + dispatch(importFetchedAccounts(response.data.map(x => x.account))); + dispatch(fetchSuggestionsSuccess(response.data)); + }).catch(error => dispatch(fetchSuggestionsFail(error))); + }).catch(() => {}); +}; diff --git a/app/javascript/flavours/blobfox/actions/tags.js b/app/javascript/flavours/blobfox/actions/tags.js new file mode 100644 index 00000000000000..dda8c924bb59a4 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/tags.js @@ -0,0 +1,172 @@ +import api, { getLinks } from '../api'; + +export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST'; +export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS'; +export const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL'; + +export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST'; +export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS'; +export const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL'; + +export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST'; +export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS'; +export const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL'; + +export const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST'; +export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS'; +export const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL'; + +export const HASHTAG_UNFOLLOW_REQUEST = 'HASHTAG_UNFOLLOW_REQUEST'; +export const HASHTAG_UNFOLLOW_SUCCESS = 'HASHTAG_UNFOLLOW_SUCCESS'; +export const HASHTAG_UNFOLLOW_FAIL = 'HASHTAG_UNFOLLOW_FAIL'; + +export const fetchHashtag = name => (dispatch, getState) => { + dispatch(fetchHashtagRequest()); + + api(getState).get(`/api/v1/tags/${name}`).then(({ data }) => { + dispatch(fetchHashtagSuccess(name, data)); + }).catch(err => { + dispatch(fetchHashtagFail(err)); + }); +}; + +export const fetchHashtagRequest = () => ({ + type: HASHTAG_FETCH_REQUEST, +}); + +export const fetchHashtagSuccess = (name, tag) => ({ + type: HASHTAG_FETCH_SUCCESS, + name, + tag, +}); + +export const fetchHashtagFail = error => ({ + type: HASHTAG_FETCH_FAIL, + error, +}); + +export const fetchFollowedHashtags = () => (dispatch, getState) => { + dispatch(fetchFollowedHashtagsRequest()); + + api(getState).get('/api/v1/followed_tags').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null)); + }).catch(err => { + dispatch(fetchFollowedHashtagsFail(err)); + }); +}; + +export function fetchFollowedHashtagsRequest() { + return { + type: FOLLOWED_HASHTAGS_FETCH_REQUEST, + }; +} + +export function fetchFollowedHashtagsSuccess(followed_tags, next) { + return { + type: FOLLOWED_HASHTAGS_FETCH_SUCCESS, + followed_tags, + next, + }; +} + +export function fetchFollowedHashtagsFail(error) { + return { + type: FOLLOWED_HASHTAGS_FETCH_FAIL, + error, + }; +} + +export function expandFollowedHashtags() { + return (dispatch, getState) => { + const url = getState().getIn(['followed_tags', 'next']); + + if (url === null) { + return; + } + + dispatch(expandFollowedHashtagsRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandFollowedHashtagsFail(error)); + }); + }; +} + +export function expandFollowedHashtagsRequest() { + return { + type: FOLLOWED_HASHTAGS_EXPAND_REQUEST, + }; +} + +export function expandFollowedHashtagsSuccess(followed_tags, next) { + return { + type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS, + followed_tags, + next, + }; +} + +export function expandFollowedHashtagsFail(error) { + return { + type: FOLLOWED_HASHTAGS_EXPAND_FAIL, + error, + }; +} + +export const followHashtag = name => (dispatch, getState) => { + dispatch(followHashtagRequest(name)); + + api(getState).post(`/api/v1/tags/${name}/follow`).then(({ data }) => { + dispatch(followHashtagSuccess(name, data)); + }).catch(err => { + dispatch(followHashtagFail(name, err)); + }); +}; + +export const followHashtagRequest = name => ({ + type: HASHTAG_FOLLOW_REQUEST, + name, +}); + +export const followHashtagSuccess = (name, tag) => ({ + type: HASHTAG_FOLLOW_SUCCESS, + name, + tag, +}); + +export const followHashtagFail = (name, error) => ({ + type: HASHTAG_FOLLOW_FAIL, + name, + error, +}); + +export const unfollowHashtag = name => (dispatch, getState) => { + dispatch(unfollowHashtagRequest(name)); + + api(getState).post(`/api/v1/tags/${name}/unfollow`).then(({ data }) => { + dispatch(unfollowHashtagSuccess(name, data)); + }).catch(err => { + dispatch(unfollowHashtagFail(name, err)); + }); +}; + +export const unfollowHashtagRequest = name => ({ + type: HASHTAG_UNFOLLOW_REQUEST, + name, +}); + +export const unfollowHashtagSuccess = (name, tag) => ({ + type: HASHTAG_UNFOLLOW_SUCCESS, + name, + tag, +}); + +export const unfollowHashtagFail = (name, error) => ({ + type: HASHTAG_UNFOLLOW_FAIL, + name, + error, +}); diff --git a/app/javascript/flavours/blobfox/actions/timelines.js b/app/javascript/flavours/blobfox/actions/timelines.js new file mode 100644 index 00000000000000..429efc5a0ef10b --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/timelines.js @@ -0,0 +1,235 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; + +import api, { getLinks } from 'flavours/blobfox/api'; +import { compareId } from 'flavours/blobfox/compare_id'; +import { usePendingItems as preferPendingItems } from 'flavours/blobfox/initial_state'; +import { toServerSideType } from 'flavours/blobfox/utils/filters'; + +import { importFetchedStatus, importFetchedStatuses } from './importer'; +import { submitMarkers } from './markers'; + +export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; +export const TIMELINE_DELETE = 'TIMELINE_DELETE'; +export const TIMELINE_CLEAR = 'TIMELINE_CLEAR'; + +export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST'; +export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS'; +export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; + +export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; +export const TIMELINE_LOAD_PENDING = 'TIMELINE_LOAD_PENDING'; +export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; +export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; + +export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL'; + +export const loadPending = timeline => ({ + type: TIMELINE_LOAD_PENDING, + timeline, +}); + +export function updateTimeline(timeline, status, accept) { + return (dispatch, getState) => { + if (typeof accept === 'function' && !accept(status)) { + return; + } + + if (getState().getIn(['timelines', timeline, 'isPartial'])) { + // Prevent new items from being added to a partial timeline, + // since it will be reloaded anyway + + return; + } + + let filtered = false; + + if (status.filtered) { + const contextType = toServerSideType(timeline); + const filters = status.filtered.filter(result => result.filter.context.includes(contextType)); + + filtered = filters.length > 0; + } + + dispatch(importFetchedStatus(status)); + + dispatch({ + type: TIMELINE_UPDATE, + timeline, + status, + usePendingItems: preferPendingItems, + filtered, + }); + + if (timeline === 'home') { + dispatch(submitMarkers()); + } + }; +} + +export function deleteFromTimelines(id) { + return (dispatch, getState) => { + const accountId = getState().getIn(['statuses', id, 'account']); + const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => status.get('id')); + const reblogOf = getState().getIn(['statuses', id, 'reblog'], null); + + dispatch({ + type: TIMELINE_DELETE, + id, + accountId, + references, + reblogOf, + }); + }; +} + +export function clearTimeline(timeline) { + return (dispatch) => { + dispatch({ type: TIMELINE_CLEAR, timeline }); + }; +} + +const noOp = () => {}; + +const parseTags = (tags = {}, mode) => { + return (tags[mode] || []).map((tag) => { + return tag.value; + }); +}; + +export function expandTimeline(timelineId, path, params = {}, done = noOp) { + return (dispatch, getState) => { + const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); + const isLoadingMore = !!params.max_id; + + if (timeline.get('isLoading')) { + done(); + return; + } + + if (!params.max_id && !params.pinned && (timeline.get('items', ImmutableList()).size + timeline.get('pendingItems', ImmutableList()).size) > 0) { + const a = timeline.getIn(['pendingItems', 0]); + const b = timeline.getIn(['items', 0]); + + if (a && b && compareId(a, b) > 0) { + params.since_id = a; + } else { + params.since_id = b || a; + } + } + + const isLoadingRecent = !!params.since_id; + + dispatch(expandTimelineRequest(timelineId, isLoadingMore)); + + api(getState).get(path, { params }).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems)); + + if (timelineId === 'home') { + dispatch(submitMarkers()); + } + }).catch(error => { + dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); + }).finally(() => { + done(); + }); + }; +} + +export function fillTimelineGaps(timelineId, path, params = {}, done = noOp) { + return (dispatch, getState) => { + const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); + const items = timeline.get('items'); + const nullIndexes = items.map((statusId, index) => statusId === null ? index : null); + const gaps = nullIndexes.map(index => index > 0 ? items.get(index - 1) : null); + + // Only expand at most two gaps to avoid doing too many requests + done = gaps.take(2).reduce((done, maxId) => { + return (() => dispatch(expandTimeline(timelineId, path, { ...params, maxId }, done))); + }, done); + + done(); + }; +} + +export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done); +export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote, allowLocalOnly } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, allow_local_only: !!allowLocalOnly, max_id: maxId, only_media: !!onlyMedia }, done); +export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done); +export const expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done); +export const expandAccountTimeline = (accountId, { maxId, withReplies, tagged } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, exclude_reblogs: withReplies, tagged, max_id: maxId }); +export const expandAccountFeaturedTimeline = (accountId, { tagged } = {}) => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, tagged }); +export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 }); +export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); +export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => { + return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, { + max_id: maxId, + any: parseTags(tags, 'any'), + all: parseTags(tags, 'all'), + none: parseTags(tags, 'none'), + local: local, + }, done); +}; + +export const fillHomeTimelineGaps = (done = noOp) => fillTimelineGaps('home', '/api/v1/timelines/home', {}, done); +export const fillPublicTimelineGaps = ({ onlyMedia, onlyRemote, allowLocalOnly } = {}, done = noOp) => fillTimelineGaps(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, only_media: !!onlyMedia, allow_local_only: !!allowLocalOnly }, done); +export const fillCommunityTimelineGaps = ({ onlyMedia } = {}, done = noOp) => fillTimelineGaps(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, only_media: !!onlyMedia }, done); +export const fillListTimelineGaps = (id, done = noOp) => fillTimelineGaps(`list:${id}`, `/api/v1/timelines/list/${id}`, {}, done); + +export function expandTimelineRequest(timeline, isLoadingMore) { + return { + type: TIMELINE_EXPAND_REQUEST, + timeline, + skipLoading: !isLoadingMore, + }; +} + +export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore, usePendingItems) { + return { + type: TIMELINE_EXPAND_SUCCESS, + timeline, + statuses, + next, + partial, + isLoadingRecent, + usePendingItems, + skipLoading: !isLoadingMore, + }; +} + +export function expandTimelineFail(timeline, error, isLoadingMore) { + return { + type: TIMELINE_EXPAND_FAIL, + timeline, + error, + skipLoading: !isLoadingMore, + skipNotFound: timeline.startsWith('account:'), + }; +} + +export function scrollTopTimeline(timeline, top) { + return { + type: TIMELINE_SCROLL_TOP, + timeline, + top, + }; +} + +export function connectTimeline(timeline) { + return { + type: TIMELINE_CONNECT, + timeline, + usePendingItems: preferPendingItems, + }; +} + +export const disconnectTimeline = timeline => ({ + type: TIMELINE_DISCONNECT, + timeline, + usePendingItems: preferPendingItems, +}); + +export const markAsPartial = timeline => ({ + type: TIMELINE_MARK_AS_PARTIAL, + timeline, +}); diff --git a/app/javascript/flavours/blobfox/actions/trends.js b/app/javascript/flavours/blobfox/actions/trends.js new file mode 100644 index 00000000000000..d314423884efe1 --- /dev/null +++ b/app/javascript/flavours/blobfox/actions/trends.js @@ -0,0 +1,140 @@ +import api, { getLinks } from '../api'; + +import { importFetchedStatuses } from './importer'; + +export const TRENDS_TAGS_FETCH_REQUEST = 'TRENDS_TAGS_FETCH_REQUEST'; +export const TRENDS_TAGS_FETCH_SUCCESS = 'TRENDS_TAGS_FETCH_SUCCESS'; +export const TRENDS_TAGS_FETCH_FAIL = 'TRENDS_TAGS_FETCH_FAIL'; + +export const TRENDS_LINKS_FETCH_REQUEST = 'TRENDS_LINKS_FETCH_REQUEST'; +export const TRENDS_LINKS_FETCH_SUCCESS = 'TRENDS_LINKS_FETCH_SUCCESS'; +export const TRENDS_LINKS_FETCH_FAIL = 'TRENDS_LINKS_FETCH_FAIL'; + +export const TRENDS_STATUSES_FETCH_REQUEST = 'TRENDS_STATUSES_FETCH_REQUEST'; +export const TRENDS_STATUSES_FETCH_SUCCESS = 'TRENDS_STATUSES_FETCH_SUCCESS'; +export const TRENDS_STATUSES_FETCH_FAIL = 'TRENDS_STATUSES_FETCH_FAIL'; + +export const TRENDS_STATUSES_EXPAND_REQUEST = 'TRENDS_STATUSES_EXPAND_REQUEST'; +export const TRENDS_STATUSES_EXPAND_SUCCESS = 'TRENDS_STATUSES_EXPAND_SUCCESS'; +export const TRENDS_STATUSES_EXPAND_FAIL = 'TRENDS_STATUSES_EXPAND_FAIL'; + +export const fetchTrendingHashtags = () => (dispatch, getState) => { + dispatch(fetchTrendingHashtagsRequest()); + + api(getState) + .get('/api/v1/trends/tags') + .then(({ data }) => dispatch(fetchTrendingHashtagsSuccess(data))) + .catch(err => dispatch(fetchTrendingHashtagsFail(err))); +}; + +export const fetchTrendingHashtagsRequest = () => ({ + type: TRENDS_TAGS_FETCH_REQUEST, + skipLoading: true, +}); + +export const fetchTrendingHashtagsSuccess = trends => ({ + type: TRENDS_TAGS_FETCH_SUCCESS, + trends, + skipLoading: true, +}); + +export const fetchTrendingHashtagsFail = error => ({ + type: TRENDS_TAGS_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, +}); + +export const fetchTrendingLinks = () => (dispatch, getState) => { + dispatch(fetchTrendingLinksRequest()); + + api(getState) + .get('/api/v1/trends/links') + .then(({ data }) => dispatch(fetchTrendingLinksSuccess(data))) + .catch(err => dispatch(fetchTrendingLinksFail(err))); +}; + +export const fetchTrendingLinksRequest = () => ({ + type: TRENDS_LINKS_FETCH_REQUEST, + skipLoading: true, +}); + +export const fetchTrendingLinksSuccess = trends => ({ + type: TRENDS_LINKS_FETCH_SUCCESS, + trends, + skipLoading: true, +}); + +export const fetchTrendingLinksFail = error => ({ + type: TRENDS_LINKS_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, +}); + +export const fetchTrendingStatuses = () => (dispatch, getState) => { + if (getState().getIn(['status_lists', 'trending', 'isLoading'])) { + return; + } + + dispatch(fetchTrendingStatusesRequest()); + + api(getState).get('/api/v1/trends/statuses').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(fetchTrendingStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(err => dispatch(fetchTrendingStatusesFail(err))); +}; + +export const fetchTrendingStatusesRequest = () => ({ + type: TRENDS_STATUSES_FETCH_REQUEST, + skipLoading: true, +}); + +export const fetchTrendingStatusesSuccess = (statuses, next) => ({ + type: TRENDS_STATUSES_FETCH_SUCCESS, + statuses, + next, + skipLoading: true, +}); + +export const fetchTrendingStatusesFail = error => ({ + type: TRENDS_STATUSES_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, +}); + + +export const expandTrendingStatuses = () => (dispatch, getState) => { + const url = getState().getIn(['status_lists', 'trending', 'next'], null); + + if (url === null || getState().getIn(['status_lists', 'trending', 'isLoading'])) { + return; + } + + dispatch(expandTrendingStatusesRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(expandTrendingStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandTrendingStatusesFail(error)); + }); +}; + +export const expandTrendingStatusesRequest = () => ({ + type: TRENDS_STATUSES_EXPAND_REQUEST, +}); + +export const expandTrendingStatusesSuccess = (statuses, next) => ({ + type: TRENDS_STATUSES_EXPAND_SUCCESS, + statuses, + next, +}); + +export const expandTrendingStatusesFail = error => ({ + type: TRENDS_STATUSES_EXPAND_FAIL, + error, +}); diff --git a/app/javascript/flavours/blobfox/api.ts b/app/javascript/flavours/blobfox/api.ts new file mode 100644 index 00000000000000..f262fd85707941 --- /dev/null +++ b/app/javascript/flavours/blobfox/api.ts @@ -0,0 +1,63 @@ +import type { AxiosResponse, RawAxiosRequestHeaders } from 'axios'; +import axios from 'axios'; +import LinkHeader from 'http-link-header'; + +import ready from './ready'; +import type { GetState } from './store'; + +export const getLinks = (response: AxiosResponse) => { + const value = response.headers.link as string | undefined; + + if (!value) { + return new LinkHeader(); + } + + return LinkHeader.parse(value); +}; + +const csrfHeader: RawAxiosRequestHeaders = {}; + +const setCSRFHeader = () => { + const csrfToken = document.querySelector<HTMLMetaElement>( + 'meta[name=csrf-token]', + ); + + if (csrfToken) { + csrfHeader['X-CSRF-Token'] = csrfToken.content; + } +}; + +void ready(setCSRFHeader); + +const authorizationHeaderFromState = (getState?: GetState) => { + const accessToken = + getState && (getState().meta.get('access_token', '') as string); + + if (!accessToken) { + return {}; + } + + return { + Authorization: `Bearer ${accessToken}`, + } as RawAxiosRequestHeaders; +}; + +// eslint-disable-next-line import/no-default-export +export default function api(getState: GetState) { + return axios.create({ + headers: { + ...csrfHeader, + ...authorizationHeaderFromState(getState), + }, + + transformResponse: [ + function (data: unknown) { + try { + return JSON.parse(data as string) as unknown; + } catch { + return data; + } + }, + ], + }); +} diff --git a/app/javascript/flavours/blobfox/api_types/accounts.ts b/app/javascript/flavours/blobfox/api_types/accounts.ts new file mode 100644 index 00000000000000..ce55dc604ad001 --- /dev/null +++ b/app/javascript/flavours/blobfox/api_types/accounts.ts @@ -0,0 +1,45 @@ +import type { ApiCustomEmojiJSON } from './custom_emoji'; + +export interface ApiAccountFieldJSON { + name: string; + value: string; + verified_at: string | null; +} + +export interface ApiAccountRoleJSON { + color: string; + id: string; + name: string; +} + +// See app/serializers/rest/account_serializer.rb +export interface ApiAccountJSON { + acct: string; + avatar: string; + avatar_static: string; + bot: boolean; + created_at: string; + discoverable: boolean; + display_name: string; + emojis: ApiCustomEmojiJSON[]; + fields: ApiAccountFieldJSON[]; + followers_count: number; + following_count: number; + group: boolean; + header: string; + header_static: string; + id: string; + last_status_at: string; + locked: boolean; + noindex?: boolean; + note: string; + roles?: ApiAccountJSON[]; + statuses_count: number; + uri: string; + url: string; + username: string; + moved?: ApiAccountJSON; + suspended?: boolean; + limited?: boolean; + memorial?: boolean; +} diff --git a/app/javascript/flavours/blobfox/api_types/custom_emoji.ts b/app/javascript/flavours/blobfox/api_types/custom_emoji.ts new file mode 100644 index 00000000000000..05144d6f68d0e8 --- /dev/null +++ b/app/javascript/flavours/blobfox/api_types/custom_emoji.ts @@ -0,0 +1,8 @@ +// See app/serializers/rest/account_serializer.rb +export interface ApiCustomEmojiJSON { + shortcode: string; + static_url: string; + url: string; + category?: string; + visible_in_picker: boolean; +} diff --git a/app/javascript/flavours/blobfox/api_types/relationships.ts b/app/javascript/flavours/blobfox/api_types/relationships.ts new file mode 100644 index 00000000000000..9f26a0ce9b333d --- /dev/null +++ b/app/javascript/flavours/blobfox/api_types/relationships.ts @@ -0,0 +1,18 @@ +// See app/serializers/rest/relationship_serializer.rb +export interface ApiRelationshipJSON { + blocked_by: boolean; + blocking: boolean; + domain_blocking: boolean; + endorsed: boolean; + followed_by: boolean; + following: boolean; + id: string; + languages: string[] | null; + muting_notifications: boolean; + muting: boolean; + note: string; + notifying: boolean; + requested_by: boolean; + requested: boolean; + showing_reblogs: boolean; +} diff --git a/app/javascript/flavours/blobfox/blurhash.ts b/app/javascript/flavours/blobfox/blurhash.ts new file mode 100644 index 00000000000000..cafe7b12dcff8b --- /dev/null +++ b/app/javascript/flavours/blobfox/blurhash.ts @@ -0,0 +1,111 @@ +const DIGIT_CHARACTERS = [ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'J', + 'K', + 'L', + 'M', + 'N', + 'O', + 'P', + 'Q', + 'R', + 'S', + 'T', + 'U', + 'V', + 'W', + 'X', + 'Y', + 'Z', + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'g', + 'h', + 'i', + 'j', + 'k', + 'l', + 'm', + 'n', + 'o', + 'p', + 'q', + 'r', + 's', + 't', + 'u', + 'v', + 'w', + 'x', + 'y', + 'z', + '#', + '$', + '%', + '*', + '+', + ',', + '-', + '.', + ':', + ';', + '=', + '?', + '@', + '[', + ']', + '^', + '_', + '{', + '|', + '}', + '~', +]; + +export const decode83 = (str: string) => { + let value = 0; + let digit; + + for (const c of str) { + digit = DIGIT_CHARACTERS.indexOf(c); + value = value * 83 + digit; + } + + return value; +}; + +export const intToRGB = (int: number) => ({ + r: Math.max(0, int >> 16), + g: Math.max(0, (int >> 8) & 255), + b: Math.max(0, int & 255), +}); + +export const getAverageFromBlurhash = (blurhash: string) => { + if (!blurhash) { + return null; + } + + return intToRGB(decode83(blurhash.slice(2, 6))); +}; diff --git a/app/javascript/flavours/blobfox/compare_id.ts b/app/javascript/flavours/blobfox/compare_id.ts new file mode 100644 index 00000000000000..30b05724817892 --- /dev/null +++ b/app/javascript/flavours/blobfox/compare_id.ts @@ -0,0 +1,11 @@ +export function compareId(id1: string, id2: string) { + if (id1 === id2) { + return 0; + } + + if (id1.length === id2.length) { + return id1 > id2 ? 1 : -1; + } else { + return id1.length > id2.length ? 1 : -1; + } +} diff --git a/app/javascript/flavours/blobfox/components/__tests__/hashtag_bar.tsx b/app/javascript/flavours/blobfox/components/__tests__/hashtag_bar.tsx new file mode 100644 index 00000000000000..b7225fc92e01e4 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/__tests__/hashtag_bar.tsx @@ -0,0 +1,214 @@ +import { fromJS } from 'immutable'; + +import type { StatusLike } from '../hashtag_bar'; +import { computeHashtagBarForStatus } from '../hashtag_bar'; + +function createStatus( + content: string, + hashtags: string[], + hasMedia = false, + spoilerText?: string, +) { + return fromJS({ + tags: hashtags.map((name) => ({ name })), + contentHtml: content, + media_attachments: hasMedia ? ['fakeMedia'] : [], + spoiler_text: spoilerText, + }) as unknown as StatusLike; // need to force the type here, as it is not properly defined +} + +describe('computeHashtagBarForStatus', () => { + it('does nothing when there are no tags', () => { + const status = createStatus('<p>Simple text</p>', []); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"<p>Simple text</p>"`, + ); + }); + + it('displays out of band hashtags in the bar', () => { + const status = createStatus( + '<p>Simple text <a href="test">#hashtag</a></p>', + ['hashtag', 'test'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual(['test']); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"<p>Simple text <a href="test">#hashtag</a></p>"`, + ); + }); + + it('does not truncate the contents when the last child is a text node', () => { + const status = createStatus( + 'this is a #<a class="zrl" href="https://example.com/search?tag=test">test</a>. Some more text', + ['test'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"this is a #<a class="zrl" href="https://example.com/search?tag=test">test</a>. Some more text"`, + ); + }); + + it('extract tags from the last line', () => { + const status = createStatus( + '<p>Simple text</p><p><a href="test">#hashtag</a></p>', + ['hashtag'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual(['hashtag']); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"<p>Simple text</p>"`, + ); + }); + + it('does not include tags from content', () => { + const status = createStatus( + '<p>Simple text with a <a href="test">#hashtag</a></p><p><a href="test">#hashtag</a></p>', + ['hashtag'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"<p>Simple text with a <a href="test">#hashtag</a></p>"`, + ); + }); + + it('works with one line status and hashtags', () => { + const status = createStatus( + '<p><a href="test">#test</a>. And another <a href="test">#hashtag</a></p>', + ['hashtag', 'test'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"<p><a href="test">#test</a>. And another <a href="test">#hashtag</a></p>"`, + ); + }); + + it('de-duplicate accentuated characters with case differences', () => { + const status = createStatus( + '<p>Text</p><p><a href="test">#éaa</a> <a href="test">#Éaa</a></p>', + ['éaa'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual(['Éaa']); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"<p>Text</p>"`, + ); + }); + + it('handles server-side normalized tags with accentuated characters', () => { + const status = createStatus( + '<p>Text</p><p><a href="test">#éaa</a> <a href="test">#Éaa</a></p>', + ['eaa'], // The server may normalize the hashtags in the `tags` attribute + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual(['Éaa']); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"<p>Text</p>"`, + ); + }); + + it('does not display in bar a hashtag in content with a case difference', () => { + const status = createStatus( + '<p>Text <a href="test">#Éaa</a></p><p><a href="test">#éaa</a></p>', + ['éaa'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"<p>Text <a href="test">#Éaa</a></p>"`, + ); + }); + + it('does not modify a status with a line of hashtags only', () => { + const status = createStatus( + '<p><a href="test">#test</a> <a href="test">#hashtag</a></p>', + ['test', 'hashtag'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"<p><a href="test">#test</a> <a href="test">#hashtag</a></p>"`, + ); + }); + + it('puts the hashtags in the bar if a status content has hashtags in the only line and has a media', () => { + const status = createStatus( + '<p>This is my content! <a href="test">#hashtag</a></p>', + ['hashtag'], + true, + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"<p>This is my content! <a href="test">#hashtag</a></p>"`, + ); + }); + + it('puts the hashtags in the bar if a status content is only hashtags and has a media', () => { + const status = createStatus( + '<p><a href="test">#test</a> <a href="test">#hashtag</a></p>', + ['test', 'hashtag'], + true, + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual(['test', 'hashtag']); + expect(statusContentProps.statusContent).toMatchInlineSnapshot(`""`); + }); + + it('does not use the hashtag bar if the status content is only hashtags, has a CW and a media', () => { + const status = createStatus( + '<p><a href="test">#test</a> <a href="test">#hashtag</a></p>', + ['test', 'hashtag'], + true, + 'My CW text', + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"<p><a href="test">#test</a> <a href="test">#hashtag</a></p>"`, + ); + }); +}); diff --git a/app/javascript/flavours/blobfox/components/account.jsx b/app/javascript/flavours/blobfox/components/account.jsx new file mode 100644 index 00000000000000..a83c9c066d87ad --- /dev/null +++ b/app/javascript/flavours/blobfox/components/account.jsx @@ -0,0 +1,182 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { EmptyAccount } from 'flavours/blobfox/components/empty_account'; +import { ShortNumber } from 'flavours/blobfox/components/short_number'; +import { VerifiedBadge } from 'flavours/blobfox/components/verified_badge'; + +import { me } from '../initial_state'; + +import { Avatar } from './avatar'; +import { Button } from './button'; +import { FollowersCounter } from './counters'; +import { DisplayName } from './display_name'; +import Permalink from './permalink'; +import { RelativeTimestamp } from './relative_timestamp'; + +const messages = defineMessages({ + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' }, + unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' }, + unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' }, + mute_notifications: { id: 'account.mute_notifications_short', defaultMessage: 'Mute notifications' }, + unmute_notifications: { id: 'account.unmute_notifications_short', defaultMessage: 'Unmute notifications' }, + mute: { id: 'account.mute_short', defaultMessage: 'Mute' }, + block: { id: 'account.block_short', defaultMessage: 'Block' }, +}); + +class Account extends ImmutablePureComponent { + + static propTypes = { + size: PropTypes.number, + account: ImmutablePropTypes.record, + onFollow: PropTypes.func.isRequired, + onBlock: PropTypes.func.isRequired, + onMute: PropTypes.func.isRequired, + onMuteNotifications: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + hidden: PropTypes.bool, + minimal: PropTypes.bool, + defaultAction: PropTypes.string, + withBio: PropTypes.bool, + }; + + 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); + }; + + render () { + const { account, intl, hidden, withBio, defaultAction, size, minimal } = this.props; + + if (!account) { + return <EmptyAccount size={size} minimal={minimal} />; + } + + if (hidden) { + return ( + <> + {account.get('display_name')} + {account.get('username')} + </> + ); + } + + let buttons; + + 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 = <Button text={intl.formatMessage(messages.cancel_follow_request)} onClick={this.handleFollow} />; + } else if (blocking) { + buttons = <Button text={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />; + } else if (muting) { + let hidingNotificationsButton; + + if (account.getIn(['relationship', 'muting_notifications'])) { + hidingNotificationsButton = <Button text={intl.formatMessage(messages.unmute_notifications)} onClick={this.handleUnmuteNotifications} />; + } else { + hidingNotificationsButton = <Button text={intl.formatMessage(messages.mute_notifications)} onClick={this.handleMuteNotifications} />; + } + + buttons = ( + <> + <Button text={intl.formatMessage(messages.unmute)} onClick={this.handleMute} /> + {hidingNotificationsButton} + </> + ); + } else if (defaultAction === 'mute') { + buttons = <Button title={intl.formatMessage(messages.mute)} onClick={this.handleMute} />; + } else if (defaultAction === 'block') { + buttons = <Button text={intl.formatMessage(messages.block)} onClick={this.handleBlock} />; + } else if (!account.get('suspended') && !account.get('moved') || following) { + buttons = <Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} />; + } + } + + let muteTimeRemaining; + + if (account.get('mute_expires_at')) { + muteTimeRemaining = <>· <RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></>; + } + + let verification; + + const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at')); + + if (firstVerifiedField) { + verification = <VerifiedBadge link={firstVerifiedField.get('value')} />; + } + + return ( + <div className={classNames('account', { 'account--minimal': minimal })}> + <div className='account__wrapper'> + <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}> + <div className='account__avatar-wrapper'> + <Avatar account={account} size={size} /> + </div> + + <div className='account__contents'> + <DisplayName account={account} inline /> + {!minimal && ( + <div className='account__details'> + {account.get('followers_count') !== -1 && ( + <ShortNumber value={account.get('followers_count')} renderer={FollowersCounter} /> + )} {verification} {muteTimeRemaining} + </div> + )} + </div> + </Permalink> + + {!minimal && ( + <div className='account__relationship'> + {buttons} + </div> + )} + </div> + + {withBio && (account.get('note').length > 0 ? ( + <div + className='account__note translate' + dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }} + /> + ) : ( + <div className='account__note account__note--missing'><FormattedMessage id='account.no_bio' defaultMessage='No description provided.' /></div> + ))} + </div> + ); + } + +} + +export default injectIntl(Account); diff --git a/app/javascript/flavours/blobfox/components/admin/Counter.jsx b/app/javascript/flavours/blobfox/components/admin/Counter.jsx new file mode 100644 index 00000000000000..f9a5fc53962a6c --- /dev/null +++ b/app/javascript/flavours/blobfox/components/admin/Counter.jsx @@ -0,0 +1,121 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedNumber } from 'react-intl'; + +import classNames from 'classnames'; + +import { Sparklines, SparklinesCurve } from 'react-sparklines'; + +import api from 'flavours/blobfox/api'; +import { Skeleton } from 'flavours/blobfox/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 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 = ( + <> + <span className='sparkline__value__total'><Skeleton width={43} /></span> + <span className='sparkline__value__change'><Skeleton width={43} /></span> + </> + ); + } else { + const measure = data[0]; + const percentChange = measure.previous_total && percIncrease(measure.previous_total * 1, measure.total * 1); + + content = ( + <> + <span className='sparkline__value__total'>{measure.human_value || <FormattedNumber value={measure.total} />}</span> + {measure.previous_total && (<span className={classNames('sparkline__value__change', { positive: percentChange > 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'}<FormattedNumber value={percentChange} style='percent' /></span>)} + </> + ); + } + + const inner = ( + <> + <div className='sparkline__value'> + {content} + </div> + + <div className='sparkline__label'> + {label} + </div> + + <div className='sparkline__graph'> + {!loading && ( + <Sparklines width={259} height={55} data={data[0].data.map(x => x.value * 1)}> + <SparklinesCurve /> + </Sparklines> + )} + </div> + </> + ); + + if (href) { + return ( + <a href={href} className='sparkline' target={target}> + {inner} + </a> + ); + } else { + return ( + <div className='sparkline'> + {inner} + </div> + ); + } + } + +} diff --git a/app/javascript/flavours/blobfox/components/admin/Dimension.jsx b/app/javascript/flavours/blobfox/components/admin/Dimension.jsx new file mode 100644 index 00000000000000..bcd4afa3ac16eb --- /dev/null +++ b/app/javascript/flavours/blobfox/components/admin/Dimension.jsx @@ -0,0 +1,95 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedNumber } from 'react-intl'; + +import api from 'flavours/blobfox/api'; +import { Skeleton } from 'flavours/blobfox/components/skeleton'; +import { roundTo10 } from 'flavours/blobfox/utils/numbers'; + +export default class Dimension extends 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 = ( + <table> + <tbody> + {Array.from(Array(limit)).map((_, i) => ( + <tr className='dimension__item' key={i}> + <td className='dimension__item__key'> + <Skeleton width={100} /> + </td> + + <td className='dimension__item__value'> + <Skeleton width={60} /> + </td> + </tr> + ))} + </tbody> + </table> + ); + } else { + const sum = data[0].data.reduce((sum, cur) => sum + (cur.value * 1), 0); + + content = ( + <table> + <tbody> + {data[0].data.map(item => ( + <tr className='dimension__item' key={item.key}> + <td className='dimension__item__key'> + <span className={`dimension__item__indicator dimension__item__indicator--${roundTo10(((item.value * 1) / sum) * 100)}`} /> + <span title={item.key}>{item.human_key}</span> + </td> + + <td className='dimension__item__value'> + {typeof item.human_value !== 'undefined' ? item.human_value : <FormattedNumber value={item.value} />} + </td> + </tr> + ))} + </tbody> + </table> + ); + } + + return ( + <div className='dimension'> + <h4>{label}</h4> + + {content} + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/components/admin/ImpactReport.jsx b/app/javascript/flavours/blobfox/components/admin/ImpactReport.jsx new file mode 100644 index 00000000000000..fe3a2ea29ff5f5 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/admin/ImpactReport.jsx @@ -0,0 +1,91 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedNumber, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import api from 'flavours/blobfox/api'; +import { Skeleton } from 'flavours/blobfox/components/skeleton'; + +export default class ImpactReport extends PureComponent { + + static propTypes = { + domain: PropTypes.string.isRequired, + }; + + state = { + loading: true, + data: null, + }; + + componentDidMount () { + const { domain } = this.props; + + const params = { + domain: domain, + include_subdomains: true, + }; + + api().post('/api/v1/admin/measures', { + keys: ['instance_accounts', 'instance_follows', 'instance_followers'], + start_at: null, + end_at: null, + instance_accounts: params, + instance_follows: params, + instance_followers: params, + }).then(res => { + this.setState({ + loading: false, + data: res.data, + }); + }).catch(err => { + console.error(err); + }); + } + + render () { + const { loading, data } = this.state; + + return ( + <div className='dimension'> + <h4><FormattedMessage id='admin.impact_report.title' defaultMessage='Impact summary' /></h4> + + <table> + <tbody> + <tr className='dimension__item'> + <td className='dimension__item__key'> + <FormattedMessage id='admin.impact_report.instance_accounts' defaultMessage='Accounts profiles this would delete' /> + </td> + + <td className='dimension__item__value'> + {loading ? <Skeleton width={60} /> : <FormattedNumber value={data[0].total} />} + </td> + </tr> + + <tr className={classNames('dimension__item', { negative: !loading && data[1].total > 0 })}> + <td className='dimension__item__key'> + <FormattedMessage id='admin.impact_report.instance_follows' defaultMessage='Followers their users would lose' /> + </td> + + <td className='dimension__item__value'> + {loading ? <Skeleton width={60} /> : <FormattedNumber value={data[1].total} />} + </td> + </tr> + + <tr className={classNames('dimension__item', { negative: !loading && data[2].total > 0 })}> + <td className='dimension__item__key'> + <FormattedMessage id='admin.impact_report.instance_followers' defaultMessage='Followers our users would lose' /> + </td> + + <td className='dimension__item__value'> + {loading ? <Skeleton width={60} /> : <FormattedNumber value={data[2].total} />} + </td> + </tr> + </tbody> + </table> + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/components/admin/ReportReasonSelector.jsx b/app/javascript/flavours/blobfox/components/admin/ReportReasonSelector.jsx new file mode 100644 index 00000000000000..6ef9c912330469 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/admin/ReportReasonSelector.jsx @@ -0,0 +1,165 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { injectIntl, defineMessages } from 'react-intl'; + +import classNames from 'classnames'; + +import api from 'flavours/blobfox/api'; + +const messages = defineMessages({ + legal: { id: 'report.categories.legal', defaultMessage: 'Legal' }, + 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 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 ( + <div tabIndex={0} role='button' className={classNames('report-reason-selector__category', { selected, disabled })} onClick={this.handleClick}> + {selected && <input type='hidden' name='report[category]' value={id} />} + + <div className='report-reason-selector__category__label'> + <span className={classNames('poll__input', { active: selected, disabled })} /> + {text} + </div> + + {(selected && children) && ( + <div className='report-reason-selector__category__rules'> + {children} + </div> + )} + </div> + ); + } + +} + +class Rule extends 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 ( + <div tabIndex={0} role='button' className={classNames('report-reason-selector__rule', { selected, disabled })} onClick={this.handleClick}> + <span className={classNames('poll__input', { checkbox: true, active: selected, disabled })} /> + {selected && <input type='hidden' name='report[rule_ids][]' value={id} />} + {text} + </div> + ); + } + +} + +class ReportReasonSelector extends 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 ( + <div className='report-reason-selector'> + <Category id='other' text={intl.formatMessage(messages.other)} selected={category === 'other'} onSelect={this.handleSelect} disabled={disabled} /> + <Category id='legal' text={intl.formatMessage(messages.legal)} selected={category === 'legal'} onSelect={this.handleSelect} disabled={disabled} /> + <Category id='spam' text={intl.formatMessage(messages.spam)} selected={category === 'spam'} onSelect={this.handleSelect} disabled={disabled} /> + <Category id='violation' text={intl.formatMessage(messages.violation)} selected={category === 'violation'} onSelect={this.handleSelect} disabled={disabled}> + {rules.map(rule => <Rule key={rule.id} id={rule.id} text={rule.text} selected={rule_ids.includes(rule.id)} onToggle={this.handleToggle} disabled={disabled} />)} + </Category> + </div> + ); + } + +} + +export default injectIntl(ReportReasonSelector); diff --git a/app/javascript/flavours/blobfox/components/admin/Retention.jsx b/app/javascript/flavours/blobfox/components/admin/Retention.jsx new file mode 100644 index 00000000000000..3c1bec0fdd2c29 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/admin/Retention.jsx @@ -0,0 +1,155 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage, FormattedNumber, FormattedDate } from 'react-intl'; + +import classNames from 'classnames'; + +import api from 'flavours/blobfox/api'; +import { roundTo10 } from 'flavours/blobfox/utils/numbers'; + +const dateForCohort = cohort => { + const timeZone = 'UTC'; + switch(cohort.frequency) { + case 'day': + return <FormattedDate value={cohort.period} month='long' day='2-digit' timeZone={timeZone} />; + default: + return <FormattedDate value={cohort.period} month='long' year='numeric' timeZone={timeZone} />; + } +}; + +export default class Retention extends 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 = <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />; + } else { + content = ( + <table className='retention__table'> + <thead> + <tr> + <th> + <div className='retention__table__date retention__table__label'> + <FormattedMessage id='admin.dashboard.retention.cohort' defaultMessage='Sign-up month' /> + </div> + </th> + + <th> + <div className='retention__table__number retention__table__label'> + <FormattedMessage id='admin.dashboard.retention.cohort_size' defaultMessage='New users' /> + </div> + </th> + + {data[0].data.slice(1).map((retention, i) => ( + <th key={retention.date}> + <div className='retention__table__number retention__table__label'> + {i + 1} + </div> + </th> + ))} + </tr> + + <tr> + <td> + <div className='retention__table__date retention__table__average'> + <FormattedMessage id='admin.dashboard.retention.average' defaultMessage='Average' /> + </div> + </td> + + <td> + <div className='retention__table__size'> + <FormattedNumber value={data.reduce((sum, cohort, i) => sum + ((cohort.data[0].value * 1) - sum) / (i + 1), 0)} maximumFractionDigits={0} /> + </div> + </td> + + {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 ( + <td key={retention.date}> + <div className={classNames('retention__table__box', 'retention__table__average', `retention__table__box--${roundTo10(average * 100)}`)}> + <FormattedNumber value={average} style='percent' /> + </div> + </td> + ); + })} + </tr> + </thead> + + <tbody> + {data.slice(0, -1).map(cohort => ( + <tr key={cohort.period}> + <td> + <div className='retention__table__date'> + {dateForCohort(cohort)} + </div> + </td> + + <td> + <div className='retention__table__size'> + <FormattedNumber value={cohort.data[0].value} /> + </div> + </td> + + {cohort.data.slice(1).map(retention => ( + <td key={retention.date}> + <div className={classNames('retention__table__box', `retention__table__box--${roundTo10(retention.rate * 100)}`)}> + <FormattedNumber value={retention.rate} style='percent' /> + </div> + </td> + ))} + </tr> + ))} + </tbody> + </table> + ); + } + + let title = null; + switch(frequency) { + case 'day': + title = <FormattedMessage id='admin.dashboard.daily_retention' defaultMessage='User retention rate by day after sign-up' />; + break; + default: + title = <FormattedMessage id='admin.dashboard.monthly_retention' defaultMessage='User retention rate by month after sign-up' />; + } + + return ( + <div className='retention'> + <h4>{title}</h4> + + {content} + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/components/admin/Trends.jsx b/app/javascript/flavours/blobfox/components/admin/Trends.jsx new file mode 100644 index 00000000000000..6dad32390d8017 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/admin/Trends.jsx @@ -0,0 +1,76 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import api from 'flavours/blobfox/api'; +import Hashtag from 'flavours/blobfox/components/hashtag'; + +export default class Trends extends 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 = ( + <div> + {Array.from(Array(limit)).map((_, i) => ( + <Hashtag key={i} /> + ))} + </div> + ); + } else { + content = ( + <div> + {data.map(hashtag => ( + <Hashtag + key={hashtag.name} + name={hashtag.name} + href={hashtag.id === undefined ? undefined : `/admin/tags/${hashtag.id}`} + people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1} + uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1} + history={hashtag.history.reverse().map(day => day.uses)} + className={classNames(hashtag.requires_review && 'trends__item--requires-review', !hashtag.trendable && !hashtag.requires_review && 'trends__item--disabled')} + /> + ))} + </div> + ); + } + + return ( + <div className='trends trends--compact'> + <h4><FormattedMessage id='trends.trending_now' defaultMessage='Trending now' /></h4> + + {content} + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/components/animated_number.tsx b/app/javascript/flavours/blobfox/components/animated_number.tsx new file mode 100644 index 00000000000000..05a7e01898411e --- /dev/null +++ b/app/javascript/flavours/blobfox/components/animated_number.tsx @@ -0,0 +1,81 @@ +import { useCallback, useState } from 'react'; + +import { TransitionMotion, spring } from 'react-motion'; + +import { reduceMotion } from '../initial_state'; + +import { ShortNumber } from './short_number'; + +const obfuscatedCount = (count: number) => { + if (count < 0) { + return 0; + } else if (count <= 1) { + return count; + } else { + return '1+'; + } +}; + +interface Props { + value: number; + obfuscate?: boolean; +} +export const AnimatedNumber: React.FC<Props> = ({ value, obfuscate }) => { + const [previousValue, setPreviousValue] = useState(value); + const [direction, setDirection] = useState<1 | -1>(1); + + if (previousValue !== value) { + setPreviousValue(value); + setDirection(value > previousValue ? 1 : -1); + } + + const willEnter = useCallback(() => ({ y: -1 * direction }), [direction]); + const willLeave = useCallback( + () => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }), + [direction], + ); + + if (reduceMotion) { + return obfuscate ? ( + <>{obfuscatedCount(value)}</> + ) : ( + <ShortNumber value={value} /> + ); + } + + const styles = [ + { + key: `${value}`, + data: value, + style: { y: spring(0, { damping: 35, stiffness: 400 }) }, + }, + ]; + + return ( + <TransitionMotion + styles={styles} + willEnter={willEnter} + willLeave={willLeave} + > + {(items) => ( + <span className='animated-number'> + {items.map(({ key, data, style }) => ( + <span + key={key} + style={{ + position: direction * style.y > 0 ? 'absolute' : 'static', + transform: `translateY(${style.y * 100}%)`, + }} + > + {obfuscate ? ( + obfuscatedCount(data as number) + ) : ( + <ShortNumber value={data as number} /> + )} + </span> + ))} + </span> + )} + </TransitionMotion> + ); +}; diff --git a/app/javascript/flavours/blobfox/components/attachment_list.jsx b/app/javascript/flavours/blobfox/components/attachment_list.jsx new file mode 100644 index 00000000000000..e54584f766e190 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/attachment_list.jsx @@ -0,0 +1,51 @@ +import PropTypes from 'prop-types'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { Icon } from 'flavours/blobfox/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 ( + <div className={classNames('attachment-list', { compact })}> + {!compact && ( + <div className='attachment-list__icon'> + <Icon id='link' /> + </div> + )} + + <ul className='attachment-list__list'> + {media.map(attachment => { + const displayUrl = attachment.get('remote_url') || attachment.get('url'); + + return ( + <li key={attachment.get('id')}> + <a href={displayUrl} target='_blank' rel='noopener noreferrer'> + {compact && <Icon id='link' />} + {compact && ' ' } + {displayUrl ? filename(displayUrl) : <FormattedMessage id='attachments_list.unprocessed' defaultMessage='(unprocessed)' />} + </a> + </li> + ); + })} + </ul> + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/components/autosuggest_emoji.jsx b/app/javascript/flavours/blobfox/components/autosuggest_emoji.jsx new file mode 100644 index 00000000000000..1d56f9139c1387 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/autosuggest_emoji.jsx @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { assetHost } from 'flavours/blobfox/utils/config'; + +import { unicodeMapping } from '../features/emoji/emoji_unicode_mapping_light'; + +export default class AutosuggestEmoji extends 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 ( + <div className='autosuggest-emoji'> + <img + className='emojione' + src={url} + alt={emoji.native || emoji.colons} + /> + + {emoji.colons} + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/components/autosuggest_hashtag.tsx b/app/javascript/flavours/blobfox/components/autosuggest_hashtag.tsx new file mode 100644 index 00000000000000..f45aaf1714cd45 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/autosuggest_hashtag.tsx @@ -0,0 +1,42 @@ +import { FormattedMessage } from 'react-intl'; + +import { ShortNumber } from 'flavours/blobfox/components/short_number'; + +interface Props { + tag: { + name: string; + url?: string; + history?: { + uses: number; + accounts: string; + day: string; + }[]; + following?: boolean; + type: 'hashtag'; + }; +} + +export const AutosuggestHashtag: React.FC<Props> = ({ tag }) => { + const weeklyUses = tag.history && ( + <ShortNumber + value={tag.history.reduce((total, day) => total + day.uses * 1, 0)} + /> + ); + + return ( + <div className='autosuggest-hashtag'> + <div className='autosuggest-hashtag__name'> + #<strong>{tag.name}</strong> + </div> + {tag.history !== undefined && ( + <div className='autosuggest-hashtag__uses'> + <FormattedMessage + id='autosuggest_hashtag.per_week' + defaultMessage='{count} per week' + values={{ count: weeklyUses }} + /> + </div> + )} + </div> + ); +}; diff --git a/app/javascript/flavours/blobfox/components/autosuggest_input.jsx b/app/javascript/flavours/blobfox/components/autosuggest_input.jsx new file mode 100644 index 00000000000000..6d2474b4426bb0 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/autosuggest_input.jsx @@ -0,0 +1,230 @@ +import PropTypes from 'prop-types'; + +import classNames from 'classnames'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; + +import AutosuggestEmoji from './autosuggest_emoji'; +import { AutosuggestHashtag } from './autosuggest_hashtag'; + +const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => { + let word; + + let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/); + let right = str.slice(caretPosition).search(/[\s\u200B]/); + + 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, 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(); + }; + + UNSAFE_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 = <AutosuggestEmoji emoji={suggestion} />; + key = suggestion.id; + } else if (suggestion.type ==='hashtag') { + inner = <AutosuggestHashtag tag={suggestion} />; + key = suggestion.name; + } else if (suggestion.type === 'account') { + inner = <AutosuggestAccountContainer id={suggestion.id} />; + key = suggestion.id; + } + + return ( + <div role='button' tabIndex={0} key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}> + {inner} + </div> + ); + }; + + render () { + const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, lang, spellCheck } = this.props; + const { suggestionsHidden } = this.state; + + return ( + <div className='autosuggest-input'> + <label> + <span style={{ display: 'none' }}>{placeholder}</span> + + <input + type='text' + ref={this.setInput} + disabled={disabled} + placeholder={placeholder} + autoFocus={autoFocus} + value={value} + onChange={this.onChange} + onKeyDown={this.onKeyDown} + onKeyUp={onKeyUp} + onFocus={this.onFocus} + onBlur={this.onBlur} + dir='auto' + aria-autocomplete='list' + id={id} + className={className} + maxLength={maxLength} + lang={lang} + spellCheck={spellCheck} + /> + </label> + + <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}> + {suggestions.map(this.renderSuggestion)} + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/components/autosuggest_textarea.jsx b/app/javascript/flavours/blobfox/components/autosuggest_textarea.jsx new file mode 100644 index 00000000000000..28384075c3c16e --- /dev/null +++ b/app/javascript/flavours/blobfox/components/autosuggest_textarea.jsx @@ -0,0 +1,240 @@ +import PropTypes from 'prop-types'; +import { useCallback, useRef, useState, useEffect, forwardRef } from 'react'; + +import classNames from 'classnames'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + +import Textarea from 'react-textarea-autosize'; + +import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; + +import AutosuggestEmoji from './autosuggest_emoji'; +import { AutosuggestHashtag } from './autosuggest_hashtag'; + +const textAtCursorMatchesToken = (str, caretPosition) => { + let word; + + let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/); + let right = str.slice(caretPosition).search(/[\s\u200B]/); + + 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, word]; + } else { + return [null, null]; + } +}; + +const AutosuggestTextarea = forwardRef(({ + value, + suggestions, + disabled, + placeholder, + onSuggestionSelected, + onSuggestionsClearRequested, + onSuggestionsFetchRequested, + onChange, + onKeyUp, + onKeyDown, + onPaste, + onFocus, + autoFocus = true, + lang, + children, +}, textareaRef) => { + + const [suggestionsHidden, setSuggestionsHidden] = useState(true); + const [selectedSuggestion, setSelectedSuggestion] = useState(0); + const lastTokenRef = useRef(null); + const tokenStartRef = useRef(0); + + const handleChange = useCallback((e) => { + const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart); + + if (token !== null && lastTokenRef.current !== token) { + tokenStartRef.current = tokenStart; + lastTokenRef.current = token; + setSelectedSuggestion(0); + onSuggestionsFetchRequested(token); + } else if (token === null) { + lastTokenRef.current = null; + onSuggestionsClearRequested(); + } + + onChange(e); + }, [onSuggestionsFetchRequested, onSuggestionsClearRequested, onChange, setSelectedSuggestion]); + + const handleKeyDown = useCallback((e) => { + 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(); + setSuggestionsHidden(true); + } + + break; + case 'ArrowDown': + if (suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + setSelectedSuggestion(Math.min(selectedSuggestion + 1, suggestions.size - 1)); + } + + break; + case 'ArrowUp': + if (suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + setSelectedSuggestion(Math.max(selectedSuggestion - 1, 0)); + } + + break; + case 'Enter': + case 'Tab': + // Select suggestion + if (lastTokenRef.current !== null && suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + e.stopPropagation(); + onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestions.get(selectedSuggestion)); + } + + break; + } + + if (e.defaultPrevented || !onKeyDown) { + return; + } + + onKeyDown(e); + }, [disabled, suggestions, suggestionsHidden, selectedSuggestion, setSelectedSuggestion, setSuggestionsHidden, onSuggestionSelected, onKeyDown]); + + const handleBlur = useCallback(() => { + setSuggestionsHidden(true); + }, [setSuggestionsHidden]); + + const handleFocus = useCallback((e) => { + if (onFocus) { + onFocus(e); + } + }, [onFocus]); + + const handleSuggestionClick = useCallback((e) => { + const suggestion = suggestions.get(e.currentTarget.getAttribute('data-index')); + e.preventDefault(); + onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestion); + textareaRef.current?.focus(); + }, [suggestions, onSuggestionSelected, textareaRef]); + + const handlePaste = useCallback((e) => { + if (e.clipboardData && e.clipboardData.files.length === 1) { + onPaste(e.clipboardData.files); + e.preventDefault(); + } + }, [onPaste]); + + // Show the suggestions again whenever they change and the textarea is focused + useEffect(() => { + if (suggestions.size > 0 && textareaRef.current === document.activeElement) { + setSuggestionsHidden(false); + } + }, [suggestions, textareaRef, setSuggestionsHidden]); + + const renderSuggestion = (suggestion, i) => { + let inner, key; + + if (suggestion.type === 'emoji') { + inner = <AutosuggestEmoji emoji={suggestion} />; + key = suggestion.id; + } else if (suggestion.type === 'hashtag') { + inner = <AutosuggestHashtag tag={suggestion} />; + key = suggestion.name; + } else if (suggestion.type === 'account') { + inner = <AutosuggestAccountContainer id={suggestion.id} />; + key = suggestion.id; + } + + return ( + <div role='button' tabIndex={0} key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={handleSuggestionClick}> + {inner} + </div> + ); + }; + + return [ + <div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'> + <div className='autosuggest-textarea'> + <label> + <span style={{ display: 'none' }}>{placeholder}</span> + + <Textarea + ref={textareaRef} + className='autosuggest-textarea__textarea' + disabled={disabled} + placeholder={placeholder} + autoFocus={autoFocus} + value={value} + onChange={handleChange} + onKeyDown={handleKeyDown} + onKeyUp={onKeyUp} + onFocus={handleFocus} + onBlur={handleBlur} + onPaste={handlePaste} + dir='auto' + aria-autocomplete='list' + lang={lang} + /> + </label> + </div> + {children} + </div>, + + <div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'> + <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}> + {suggestions.map(renderSuggestion)} + </div> + </div>, + ]; +}); + +AutosuggestTextarea.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, + onFocus:PropTypes.func, + children: PropTypes.node, + autoFocus: PropTypes.bool, + lang: PropTypes.string, +}; + +export default AutosuggestTextarea; diff --git a/app/javascript/flavours/blobfox/components/avatar.tsx b/app/javascript/flavours/blobfox/components/avatar.tsx new file mode 100644 index 00000000000000..5a4b595fcfa6d2 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/avatar.tsx @@ -0,0 +1,49 @@ +import classNames from 'classnames'; + +import type { Account } from 'flavours/blobfox/models/account'; + +import { useHovering } from '../hooks/useHovering'; +import { autoPlayGif } from '../initial_state'; + +interface Props { + account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there + size: number; + style?: React.CSSProperties; + inline?: boolean; + animate?: boolean; +} + +export const Avatar: React.FC<Props> = ({ + account, + animate = autoPlayGif, + size = 20, + inline = false, + style: styleFromParent, +}) => { + const { hovering, handleMouseEnter, handleMouseLeave } = useHovering(animate); + + const style = { + ...styleFromParent, + width: `${size}px`, + height: `${size}px`, + }; + + const src = + hovering || animate + ? account?.get('avatar') + : account?.get('avatar_static'); + + return ( + <div + className={classNames('account__avatar', { + 'account__avatar-inline': inline, + })} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + style={style} + data-avatar-of={account && `@${account.get('acct')}`} + > + {src && <img src={src} alt={account?.get('acct')} />} + </div> + ); +}; diff --git a/app/javascript/flavours/blobfox/components/avatar_composite.jsx b/app/javascript/flavours/blobfox/components/avatar_composite.jsx new file mode 100644 index 00000000000000..c736f1dd53200d --- /dev/null +++ b/app/javascript/flavours/blobfox/components/avatar_composite.jsx @@ -0,0 +1,106 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + +import { autoPlayGif } from '../initial_state'; + +import { Avatar } from './avatar'; + +export default class AvatarComposite extends 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 ( + <div key={account.get('id')} style={style}> + <Avatar account={account} animate={animate} /> + </div> + ); + } + + render() { + const { accounts, size } = this.props; + + return ( + <div className='account__avatar-composite' style={{ width: `${size}px`, height: `${size}px` }}> + {accounts.take(4).map((account, i) => this.renderItem(account, Math.min(accounts.size, 4), i))} + + {accounts.size > 4 && ( + <span className='account__avatar-composite__label'> + +{accounts.size - 4} + </span> + )} + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/components/avatar_overlay.tsx b/app/javascript/flavours/blobfox/components/avatar_overlay.tsx new file mode 100644 index 00000000000000..d2d26aaab40e22 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/avatar_overlay.tsx @@ -0,0 +1,57 @@ +import type { Account } from 'flavours/blobfox/models/account'; + +import { useHovering } from '../hooks/useHovering'; +import { autoPlayGif } from '../initial_state'; + +interface Props { + account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there + friend: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there + size?: number; + baseSize?: number; + overlaySize?: number; +} + +export const AvatarOverlay: React.FC<Props> = ({ + account, + friend, + size = 46, + baseSize = 36, + overlaySize = 24, +}) => { + const { hovering, handleMouseEnter, handleMouseLeave } = + useHovering(autoPlayGif); + const accountSrc = hovering + ? account?.get('avatar') + : account?.get('avatar_static'); + const friendSrc = hovering + ? friend?.get('avatar') + : friend?.get('avatar_static'); + + return ( + <div + className='account__avatar-overlay' + style={{ width: size, height: size }} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + > + <div className='account__avatar-overlay-base'> + <div + className='account__avatar' + style={{ width: `${baseSize}px`, height: `${baseSize}px` }} + data-avatar-of={`@${account?.get('acct')}`} + > + {accountSrc && <img src={accountSrc} alt={account?.get('acct')} />} + </div> + </div> + <div className='account__avatar-overlay-overlay'> + <div + className='account__avatar' + style={{ width: `${overlaySize}px`, height: `${overlaySize}px` }} + data-avatar-of={`@${friend?.get('acct')}`} + > + {friendSrc && <img src={friendSrc} alt={friend?.get('acct')} />} + </div> + </div> + </div> + ); +}; diff --git a/app/javascript/flavours/blobfox/components/blurhash.tsx b/app/javascript/flavours/blobfox/components/blurhash.tsx new file mode 100644 index 00000000000000..8e2a8af23e5937 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/blurhash.tsx @@ -0,0 +1,48 @@ +import { memo, useRef, useEffect } from 'react'; + +import { decode } from 'blurhash'; + +interface Props extends React.HTMLAttributes<HTMLCanvasElement> { + hash: string; + width?: number; + height?: number; + dummy?: boolean; // Whether dummy mode is enabled. If enabled, nothing is rendered and canvas left untouched + children?: never; +} +const Blurhash: React.FC<Props> = ({ + hash, + width = 32, + height = width, + dummy = false, + ...canvasProps +}) => { + const canvasRef = useRef<HTMLCanvasElement>(null); + + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const canvas = canvasRef.current!; + + // eslint-disable-next-line no-self-assign + 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 ( + <canvas {...canvasProps} ref={canvasRef} width={width} height={height} /> + ); +}; + +const MemoizedBlurhash = memo(Blurhash); + +export { MemoizedBlurhash as Blurhash }; diff --git a/app/javascript/flavours/blobfox/components/button.tsx b/app/javascript/flavours/blobfox/components/button.tsx new file mode 100644 index 00000000000000..0b6a0f267ebd6e --- /dev/null +++ b/app/javascript/flavours/blobfox/components/button.tsx @@ -0,0 +1,58 @@ +import { useCallback } from 'react'; + +import classNames from 'classnames'; + +interface BaseProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { + block?: boolean; + secondary?: boolean; + text?: JSX.Element; +} + +interface PropsWithChildren extends BaseProps { + text?: never; +} + +interface PropsWithText extends BaseProps { + text: JSX.Element; + children: never; +} + +type Props = PropsWithText | PropsWithChildren; + +export const Button: React.FC<Props> = ({ + text, + type = 'button', + onClick, + disabled, + block, + secondary, + className, + title, + children, + ...props +}) => { + const handleClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>( + (e) => { + if (!disabled && onClick) { + onClick(e); + } + }, + [disabled, onClick], + ); + + return ( + <button + className={classNames('button', className, { + 'button-secondary': secondary, + 'button--block': block, + })} + disabled={disabled} + onClick={handleClick} + title={title} + type={type} + {...props} + > + {text ?? children} + </button> + ); +}; diff --git a/app/javascript/flavours/blobfox/components/check.tsx b/app/javascript/flavours/blobfox/components/check.tsx new file mode 100644 index 00000000000000..901f89fc5b2bfa --- /dev/null +++ b/app/javascript/flavours/blobfox/components/check.tsx @@ -0,0 +1,13 @@ +export const Check: React.FC = () => ( + <svg + xmlns='http://www.w3.org/2000/svg' + viewBox='0 0 20 20' + fill='currentColor' + > + <path + fillRule='evenodd' + d='M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z' + clipRule='evenodd' + /> + </svg> +); diff --git a/app/javascript/flavours/blobfox/components/circular_progress.tsx b/app/javascript/flavours/blobfox/components/circular_progress.tsx new file mode 100644 index 00000000000000..850eb93e482cf1 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/circular_progress.tsx @@ -0,0 +1,27 @@ +interface Props { + size: number; + strokeWidth: number; +} + +export const CircularProgress: React.FC<Props> = ({ size, strokeWidth }) => { + const viewBox = `0 0 ${size} ${size}`; + const radius = (size - strokeWidth) / 2; + + return ( + <svg + width={size} + height={size} + viewBox={viewBox} + className='circular-progress' + role='progressbar' + > + <circle + fill='none' + cx={size / 2} + cy={size / 2} + r={radius} + strokeWidth={`${strokeWidth}px`} + /> + </svg> + ); +}; diff --git a/app/javascript/flavours/blobfox/components/column.jsx b/app/javascript/flavours/blobfox/components/column.jsx new file mode 100644 index 00000000000000..22d6eabed7bd3c --- /dev/null +++ b/app/javascript/flavours/blobfox/components/column.jsx @@ -0,0 +1,73 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { supportsPassiveEvents } from 'detect-passive-events'; + +import { scrollTop } from '../scroll'; + +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; + +export default class Column extends PureComponent { + + static propTypes = { + children: PropTypes.node, + extraClasses: PropTypes.string, + label: PropTypes.string, + bindToDocument: PropTypes.bool, + }; + + scrollTop () { + let scrollable = null; + + if (this.props.bindToDocument) { + scrollable = document.scrollingElement; + } else { + scrollable = 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, listenerOptions); + } else { + this.node.addEventListener('wheel', this.handleWheel, listenerOptions); + } + } + + componentWillUnmount () { + if (this.props.bindToDocument) { + document.removeEventListener('wheel', this.handleWheel, listenerOptions); + } else { + this.node.removeEventListener('wheel', this.handleWheel, listenerOptions); + } + } + + render () { + const { label, children, extraClasses } = this.props; + + return ( + <div role='region' aria-label={label} className={`column ${extraClasses || ''}`} ref={this.setRef}> + {children} + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/components/column_back_button.jsx b/app/javascript/flavours/blobfox/components/column_back_button.jsx new file mode 100644 index 00000000000000..d51d6be09dba94 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/column_back_button.jsx @@ -0,0 +1,63 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; +import { createPortal } from 'react-dom'; + +import { FormattedMessage } from 'react-intl'; + +import { withRouter } from 'react-router-dom'; + +import { Icon } from 'flavours/blobfox/components/icon'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +export class ColumnBackButton extends PureComponent { + + static propTypes = { + multiColumn: PropTypes.bool, + onClick: PropTypes.func, + ...WithRouterPropTypes, + }; + + handleClick = () => { + const { onClick, history } = this.props; + + if (onClick) { + onClick(); + } else if (history.location?.state?.fromMastodon) { + history.goBack(); + } else { + history.push('/'); + } + }; + + render () { + const { multiColumn } = this.props; + + const component = ( + <button onClick={this.handleClick} className='column-back-button'> + <Icon id='chevron-left' className='column-back-button__icon' fixedWidth /> + <FormattedMessage id='column_back_button.label' defaultMessage='Back' /> + </button> + ); + + 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); + } + } + } + +} + +export default withRouter(ColumnBackButton); diff --git a/app/javascript/flavours/blobfox/components/column_back_button_slim.jsx b/app/javascript/flavours/blobfox/components/column_back_button_slim.jsx new file mode 100644 index 00000000000000..82961df5561568 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/column_back_button_slim.jsx @@ -0,0 +1,40 @@ +import { PureComponent } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { withRouter } from 'react-router-dom'; + +import { Icon } from 'flavours/blobfox/components/icon'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +class ColumnBackButtonSlim extends PureComponent { + + static propTypes = { + ...WithRouterPropTypes, + }; + + handleClick = () => { + const { location, history } = this.props; + + // Check if there is a previous page in the app to go back to per https://stackoverflow.com/a/70532858/9703201 + // When upgrading to V6, check `location.key !== 'default'` instead per https://github.com/remix-run/history/blob/main/docs/api-reference.md#location + if (location.key) { + history.goBack(); + } else { + history.push('/'); + } + }; + + render () { + return ( + <div className='column-back-button--slim'> + <div role='button' tabIndex={0} onClick={this.handleClick} className='column-back-button column-back-button--slim-button'> + <Icon id='chevron-left' className='column-back-button__icon' fixedWidth /> + <FormattedMessage id='column_back_button.label' defaultMessage='Back' /> + </div> + </div> + ); + } +} + +export default withRouter(ColumnBackButtonSlim); diff --git a/app/javascript/flavours/blobfox/components/column_header.jsx b/app/javascript/flavours/blobfox/components/column_header.jsx new file mode 100644 index 00000000000000..dce67ab9950d82 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/column_header.jsx @@ -0,0 +1,219 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; +import { createPortal } from 'react-dom'; + +import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; + +import classNames from 'classnames'; +import { withRouter } from 'react-router-dom'; + +import { Icon } from 'flavours/blobfox/components/icon'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +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' }, +}); + +class ColumnHeader extends PureComponent { + + static contextTypes = { + 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, + ...WithRouterPropTypes, + }; + + state = { + collapsed: true, + animating: false, + }; + + 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 = () => { + const { history } = this.props; + + if (history.location?.state?.fromMastodon) { + history.goBack(); + } else { + history.push('/'); + } + }; + + handleTransitionEnd = () => { + this.setState({ animating: false }); + }; + + handlePin = () => { + if (!this.props.pinned) { + this.props.history.replace('/'); + } + + this.props.onPin(); + }; + + render () { + const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues, history } = 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 = ( + <div key='extra-content' className='column-header__collapsible__extra'> + {children} + </div> + ); + } + + if (multiColumn && pinned) { + pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='times' /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>; + + moveButtons = ( + <div key='move-buttons' className='column-header__setting-arrows'> + <button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='icon-button column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' /></button> + <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='icon-button column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' /></button> + </div> + ); + } else if (multiColumn && this.props.onPin) { + pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>; + } + + if (!pinned && ((multiColumn && history.location?.state?.fromMastodon) || showBackButton)) { + backButton = ( + <button onClick={this.handleBackClick} className='column-header__back-button'> + <Icon id='chevron-left' className='column-back-button__icon' fixedWidth /> + <FormattedMessage id='column_back_button.label' defaultMessage='Back' /> + </button> + ); + } + + const collapsedContent = [ + extraContent, + ]; + + if (multiColumn) { + collapsedContent.push(pinButton); + collapsedContent.push(moveButtons); + } + + if (this.context.identity.signedIn && (children || (multiColumn && this.props.onPin))) { + collapseButton = ( + <button + className={collapsibleButtonClassName} + title={formatMessage(collapsed ? messages.show : messages.hide)} + aria-label={formatMessage(collapsed ? messages.show : messages.hide)} + onClick={this.handleToggleClick} + > + <i className='icon-with-badge'> + <Icon id='sliders' /> + {collapseIssues && <i className='icon-with-badge__issue-badge' />} + </i> + </button> + ); + } + + const hasTitle = icon && title; + + const component = ( + <div className={wrapperClassName}> + <h1 className={buttonClassName}> + {hasTitle && ( + <button onClick={this.handleTitleClick}> + <Icon id={icon} fixedWidth className='column-header__icon' /> + {title} + </button> + )} + + {!hasTitle && backButton} + + <div className='column-header__buttons'> + {hasTitle && backButton} + {extraButton} + {collapseButton} + </div> + </h1> + + <div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}> + <div className='column-header__collapsible-inner'> + {(!collapsed || animating) && collapsedContent} + </div> + </div> + + {appendContent} + </div> + ); + + 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); + } + } + } + +} + +export default injectIntl(withRouter(ColumnHeader)); diff --git a/app/javascript/flavours/blobfox/components/counters.tsx b/app/javascript/flavours/blobfox/components/counters.tsx new file mode 100644 index 00000000000000..35b0ad8d607421 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/counters.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import { FormattedMessage } from 'react-intl'; + +export const StatusesCounter = ( + displayNumber: React.ReactNode, + pluralReady: number, +) => ( + <FormattedMessage + id='account.statuses_counter' + defaultMessage='{count, plural, one {{counter} Post} other {{counter} Posts}}' + values={{ + count: pluralReady, + counter: <strong>{displayNumber}</strong>, + }} + /> +); + +export const FollowingCounter = ( + displayNumber: React.ReactNode, + pluralReady: number, +) => ( + <FormattedMessage + id='account.following_counter' + defaultMessage='{count, plural, one {{counter} Following} other {{counter} Following}}' + values={{ + count: pluralReady, + counter: <strong>{displayNumber}</strong>, + }} + /> +); + +export const FollowersCounter = ( + displayNumber: React.ReactNode, + pluralReady: number, +) => ( + <FormattedMessage + id='account.followers_counter' + defaultMessage='{count, plural, one {{counter} Follower} other {{counter} Followers}}' + values={{ + count: pluralReady, + counter: <strong>{displayNumber}</strong>, + }} + /> +); diff --git a/app/javascript/flavours/blobfox/components/dismissable_banner.tsx b/app/javascript/flavours/blobfox/components/dismissable_banner.tsx new file mode 100644 index 00000000000000..40ee47fb120834 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/dismissable_banner.tsx @@ -0,0 +1,66 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call, + @typescript-eslint/no-unsafe-return, + @typescript-eslint/no-unsafe-assignment, + @typescript-eslint/no-unsafe-member-access + -- the settings store is not yet typed */ +import type { PropsWithChildren } from 'react'; +import { useCallback, useState, useEffect } from 'react'; + +import { defineMessages, useIntl } from 'react-intl'; + +import { changeSetting } from 'flavours/blobfox/actions/settings'; +import { bannerSettings } from 'flavours/blobfox/settings'; +import { useAppSelector, useAppDispatch } from 'flavours/blobfox/store'; + +import { IconButton } from './icon_button'; + +const messages = defineMessages({ + dismiss: { id: 'dismissable_banner.dismiss', defaultMessage: 'Dismiss' }, +}); + +interface Props { + id: string; +} + +export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({ + id, + children, +}) => { + const dismissed = useAppSelector((state) => + state.settings.getIn(['dismissed_banners', id], false), + ); + const dispatch = useAppDispatch(); + + const [visible, setVisible] = useState(!bannerSettings.get(id) && !dismissed); + const intl = useIntl(); + + const handleDismiss = useCallback(() => { + setVisible(false); + bannerSettings.set(id, true); + dispatch(changeSetting(['dismissed_banners', id], true)); + }, [id, dispatch]); + + useEffect(() => { + if (!visible && !dismissed) { + dispatch(changeSetting(['dismissed_banners', id], true)); + } + }, [id, dispatch, visible, dismissed]); + + if (!visible) { + return null; + } + + return ( + <div className='dismissable-banner'> + <div className='dismissable-banner__action'> + <IconButton + icon='times' + title={intl.formatMessage(messages.dismiss)} + onClick={handleDismiss} + /> + </div> + + <div className='dismissable-banner__message'>{children}</div> + </div> + ); +}; diff --git a/app/javascript/flavours/blobfox/components/display_name.tsx b/app/javascript/flavours/blobfox/components/display_name.tsx new file mode 100644 index 00000000000000..3eae2e14504640 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/display_name.tsx @@ -0,0 +1,127 @@ +import React from 'react'; + +import classNames from 'classnames'; + +import type { List } from 'immutable'; + +import type { Account } from 'flavours/blobfox/models/account'; + +import { autoPlayGif } from '../initial_state'; + +import { Skeleton } from './skeleton'; + +interface Props { + account?: Account; + others?: List<Account>; + localDomain?: string; + inline?: boolean; +} + +export class DisplayName extends React.PureComponent<Props> { + handleMouseEnter: React.ReactEventHandler<HTMLSpanElement> = ({ + currentTarget, + }) => { + if (autoPlayGif) { + return; + } + + const emojis = + currentTarget.querySelectorAll<HTMLImageElement>('img.custom-emoji'); + + emojis.forEach((emoji) => { + const originalSrc = emoji.getAttribute('data-original'); + if (originalSrc != null) emoji.src = originalSrc; + }); + }; + + handleMouseLeave: React.ReactEventHandler<HTMLSpanElement> = ({ + currentTarget, + }) => { + if (autoPlayGif) { + return; + } + + const emojis = + currentTarget.querySelectorAll<HTMLImageElement>('img.custom-emoji'); + + emojis.forEach((emoji) => { + const staticSrc = emoji.getAttribute('data-static'); + if (staticSrc != null) emoji.src = staticSrc; + }); + }; + + render() { + const { others, localDomain, inline } = this.props; + + let displayName: React.ReactNode, + suffix: React.ReactNode, + account: Account | undefined; + + if (others && others.size > 0) { + account = others.first(); + } else if (this.props.account) { + account = this.props.account; + } + + if (others && others.size > 1) { + displayName = others + .take(2) + .map((a) => ( + <bdi key={a.get('id')}> + <strong + className='display-name__html' + dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} + /> + </bdi> + )) + .reduce((prev, cur) => [prev, ', ', cur]); + + if (others.size - 2 > 0) { + suffix = `+${others.size - 2}`; + } + } else if (account) { + let acct = account.get('acct'); + + if (!acct.includes('@') && localDomain) { + acct = `${acct}@${localDomain}`; + } + + displayName = ( + <bdi> + <strong + className='display-name__html' + dangerouslySetInnerHTML={{ + __html: account.get('display_name_html'), + }} + /> + </bdi> + ); + suffix = <span className='display-name__account'>@{acct}</span>; + } else { + displayName = ( + <bdi> + <strong className='display-name__html'> + <Skeleton width='10ch' /> + </strong> + </bdi> + ); + suffix = ( + <span className='display-name__account'> + <Skeleton width='7ch' /> + </span> + ); + } + + return ( + <span + className={classNames('display-name', { inline })} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + > + {displayName} + {inline ? ' ' : null} + {suffix} + </span> + ); + } +} diff --git a/app/javascript/flavours/blobfox/components/domain.tsx b/app/javascript/flavours/blobfox/components/domain.tsx new file mode 100644 index 00000000000000..f4a3b9d4b69d2a --- /dev/null +++ b/app/javascript/flavours/blobfox/components/domain.tsx @@ -0,0 +1,44 @@ +import { useCallback } from 'react'; + +import { defineMessages, useIntl } from 'react-intl'; + +import { IconButton } from './icon_button'; + +const messages = defineMessages({ + unblockDomain: { + id: 'account.unblock_domain', + defaultMessage: 'Unblock domain {domain}', + }, +}); + +interface Props { + domain: string; + onUnblockDomain: (domain: string) => void; +} + +export const Domain: React.FC<Props> = ({ domain, onUnblockDomain }) => { + const intl = useIntl(); + + const handleDomainUnblock = useCallback(() => { + onUnblockDomain(domain); + }, [domain, onUnblockDomain]); + + return ( + <div className='domain'> + <div className='domain__wrapper'> + <span className='domain__domain-name'> + <strong>{domain}</strong> + </span> + + <div className='domain__buttons'> + <IconButton + active + icon='unlock' + title={intl.formatMessage(messages.unblockDomain, { domain })} + onClick={handleDomainUnblock} + /> + </div> + </div> + </div> + ); +}; diff --git a/app/javascript/flavours/blobfox/components/dropdown_menu.jsx b/app/javascript/flavours/blobfox/components/dropdown_menu.jsx new file mode 100644 index 00000000000000..9640b4fdfeca45 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/dropdown_menu.jsx @@ -0,0 +1,338 @@ +import PropTypes from 'prop-types'; +import { PureComponent, cloneElement, Children } from 'react'; + +import classNames from 'classnames'; +import { withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + +import { supportsPassiveEvents } from 'detect-passive-events'; +import Overlay from 'react-overlays/Overlay'; + +import { CircularProgress } from 'flavours/blobfox/components/circular_progress'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +import { IconButton } from './icon_button'; + +const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true; +let id = 0; + +class DropdownMenu extends PureComponent { + + 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(); + e.stopPropagation(); + } + }; + + componentDidMount () { + document.addEventListener('click', this.handleDocumentClick, { capture: true }); + document.addEventListener('keydown', this.handleKeyDown, { capture: true }); + document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); + + if (this.focusedItem && this.props.openedViaKeyboard) { + this.focusedItem.focus({ preventScroll: true }); + } + } + + componentWillUnmount () { + document.removeEventListener('click', this.handleDocumentClick, { capture: true }); + document.removeEventListener('keydown', this.handleKeyDown, { capture: true }); + 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 <li key={`sep-${i}`} className='dropdown-menu__separator' />; + } + + const { text, href = '#', target = '_blank', method, dangerous } = option; + + return ( + <li className={classNames('dropdown-menu__item', { 'dropdown-menu__item--dangerous': dangerous })} key={`${text}-${i}`}> + <a href={href} target={target} data-method={method} rel='noopener noreferrer' role='button' tabIndex={0} ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}> + {text} + </a> + </li> + ); + }; + + render () { + const { items, scrollable, renderHeader, loading } = this.props; + + let renderItem = this.props.renderItem || this.renderItem; + + return ( + <div className={classNames('dropdown-menu__container', { 'dropdown-menu__container--loading': loading })} ref={this.setRef}> + {loading && ( + <CircularProgress size={30} strokeWidth={3.5} /> + )} + + {!loading && renderHeader && ( + <div className='dropdown-menu__container__header'> + {renderHeader(items)} + </div> + )} + + {!loading && ( + <ul className={classNames('dropdown-menu__container__list', { 'dropdown-menu__container__list--scrollable': scrollable })}> + {items.map((option, i) => renderItem(option, i, { onClick: this.handleClick, onKeyPress: this.handleItemKeyPress }))} + </ul> + )} + </div> + ); + } + +} + +class Dropdown extends PureComponent { + + 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, + ...WithRouterPropTypes + }; + + 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.props.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 ? cloneElement(Children.only(children), { + onClick: this.handleClick, + onMouseDown: this.handleMouseDown, + onKeyDown: this.handleButtonKeyDown, + onKeyPress: this.handleKeyPress, + }) : ( + <IconButton + icon={icon} + title={title} + active={open} + disabled={disabled} + size={size} + onClick={this.handleClick} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleButtonKeyDown} + onKeyPress={this.handleKeyPress} + /> + ); + + return ( + <> + <span ref={this.setTargetRef}> + {button} + </span> + <Overlay show={open} offset={[5, 5]} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed' }}> + {({ props, arrowProps, placement }) => ( + <div {...props}> + <div className={`dropdown-animation dropdown-menu ${placement}`}> + <div className={`dropdown-menu__arrow ${placement}`} {...arrowProps} /> + <DropdownMenu + items={items} + loading={loading} + scrollable={scrollable} + onClose={this.handleClose} + openedViaKeyboard={openedViaKeyboard} + renderItem={renderItem} + renderHeader={renderHeader} + onItemClick={this.handleItemClick} + /> + </div> + </div> + )} + </Overlay> + </> + ); + } + +} + +export default withRouter(Dropdown); diff --git a/app/javascript/flavours/blobfox/components/edited_timestamp/containers/dropdown_menu_container.js b/app/javascript/flavours/blobfox/components/edited_timestamp/containers/dropdown_menu_container.js new file mode 100644 index 00000000000000..b52f19e46705c4 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/edited_timestamp/containers/dropdown_menu_container.js @@ -0,0 +1,32 @@ +import { connect } from 'react-redux'; + +import { openDropdownMenu, closeDropdownMenu } from 'flavours/blobfox/actions/dropdown_menu'; +import { fetchHistory } from 'flavours/blobfox/actions/history'; +import DropdownMenu from 'flavours/blobfox/components/dropdown_menu'; + +/** + * + * @param {import('flavours/blobfox/store').RootState} state + * @param {*} props + */ +const mapStateToProps = (state, { statusId }) => ({ + openDropdownId: state.dropdownMenu.openId, + openedViaKeyboard: state.dropdownMenu.keyboard, + items: state.getIn(['history', statusId, 'items']), + loading: state.getIn(['history', statusId, 'loading']), +}); + +const mapDispatchToProps = (dispatch, { statusId }) => ({ + + onOpen (id, onItemClick, keyboard) { + dispatch(fetchHistory(statusId)); + dispatch(openDropdownMenu({ id, keyboard })); + }, + + onClose (id) { + dispatch(closeDropdownMenu({ id })); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu); diff --git a/app/javascript/flavours/blobfox/components/edited_timestamp/index.jsx b/app/javascript/flavours/blobfox/components/edited_timestamp/index.jsx new file mode 100644 index 00000000000000..d38b2865b4a2fa --- /dev/null +++ b/app/javascript/flavours/blobfox/components/edited_timestamp/index.jsx @@ -0,0 +1,77 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage, injectIntl } from 'react-intl'; + +import { connect } from 'react-redux'; + +import { openModal } from 'flavours/blobfox/actions/modal'; +import { Icon } from 'flavours/blobfox/components/icon'; +import InlineAccount from 'flavours/blobfox/components/inline_account'; +import { RelativeTimestamp } from 'flavours/blobfox/components/relative_timestamp'; + +import DropdownMenu from './containers/dropdown_menu_container'; + +const mapDispatchToProps = (dispatch, { statusId }) => ({ + + onItemClick (index) { + dispatch(openModal({ + modalType: 'COMPARE_HISTORY', + modalProps: { index, statusId }, + })); + }, + +}); + +class EditedTimestamp extends 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 ( + <FormattedMessage id='status.edited_x_times' defaultMessage='Edited {count, plural, one {# time} other {# times}}' values={{ count: items.size - 1 }} /> + ); + }; + + renderItem = (item, index, { onClick, onKeyPress }) => { + const formattedDate = <RelativeTimestamp timestamp={item.get('created_at')} short={false} />; + const formattedName = <InlineAccount accountId={item.get('account')} />; + + const label = item.get('original') ? ( + <FormattedMessage id='status.history.created' defaultMessage='{name} created {date}' values={{ name: formattedName, date: formattedDate }} /> + ) : ( + <FormattedMessage id='status.history.edited' defaultMessage='{name} edited {date}' values={{ name: formattedName, date: formattedDate }} /> + ); + + return ( + <li className='dropdown-menu__item edited-timestamp__history__item' key={item.get('created_at')}> + <button data-index={index} onClick={onClick} onKeyPress={onKeyPress}>{label}</button> + </li> + ); + }; + + render () { + const { timestamp, intl, statusId } = this.props; + + return ( + <DropdownMenu statusId={statusId} renderItem={this.renderItem} scrollable renderHeader={this.renderHeader} onItemClick={this.handleItemClick}> + <button className='dropdown-menu__text-button'> + <FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: intl.formatDate(timestamp, { hour12: false, month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }} /> <Icon id='caret-down' /> + </button> + </DropdownMenu> + ); + } + +} + +export default connect(null, mapDispatchToProps)(injectIntl(EditedTimestamp)); diff --git a/app/javascript/flavours/blobfox/components/empty_account.tsx b/app/javascript/flavours/blobfox/components/empty_account.tsx new file mode 100644 index 00000000000000..1c2ddc1aed2035 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/empty_account.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import classNames from 'classnames'; + +import { DisplayName } from 'flavours/blobfox/components/display_name'; +import { Skeleton } from 'flavours/blobfox/components/skeleton'; + +interface Props { + size?: number; + minimal?: boolean; +} + +export const EmptyAccount: React.FC<Props> = ({ + size = 46, + minimal = false, +}) => { + return ( + <div className={classNames('account', { 'account--minimal': minimal })}> + <div className='account__wrapper'> + <div className='account__display-name'> + <div className='account__avatar-wrapper'> + <Skeleton width={size} height={size} /> + </div> + + <div> + <DisplayName /> + <Skeleton width='7ch' /> + </div> + </div> + </div> + </div> + ); +}; diff --git a/app/javascript/flavours/blobfox/components/error_boundary.jsx b/app/javascript/flavours/blobfox/components/error_boundary.jsx new file mode 100644 index 00000000000000..3cea43c06ecf39 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/error_boundary.jsx @@ -0,0 +1,111 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import StackTrace from 'stacktrace-js'; + +import { version, source_url } from 'flavours/blobfox/initial_state'; + +export default class ErrorBoundary extends 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 ( + <div className='error-boundary'> + <div> + <p className='error-boundary__error'> + { likelyBrowserAddonIssue ? ( + <FormattedMessage id='error.unexpected_crash.explanation_addons' defaultMessage='This page could not be displayed correctly. This error is likely caused by a browser add-on or automatic translation tools.' /> + ) : ( + <FormattedMessage id='error.unexpected_crash.explanation' defaultMessage='Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.' /> + )} + </p> + + <p> + { likelyBrowserAddonIssue ? ( + <FormattedMessage id='error.unexpected_crash.next_steps_addons' defaultMessage='Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' /> + ) : ( + <FormattedMessage id='error.unexpected_crash.next_steps' defaultMessage='Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' /> + )} + </p> + + <p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied ? 'copied' : ''}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p> + </div> + + <Helmet> + <meta name='robots' content='noindex' /> + </Helmet> + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/components/gifv.tsx b/app/javascript/flavours/blobfox/components/gifv.tsx new file mode 100644 index 00000000000000..c2be591128f458 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/gifv.tsx @@ -0,0 +1,70 @@ +import { useCallback, useState } from 'react'; + +interface Props { + src: string; + key: string; + alt?: string; + lang?: string; + width: number; + height: number; + onClick?: () => void; +} + +export const GIFV: React.FC<Props> = ({ + src, + alt, + lang, + width, + height, + onClick, +}) => { + const [loading, setLoading] = useState(true); + + const handleLoadedData: React.ReactEventHandler<HTMLVideoElement> = + useCallback(() => { + setLoading(false); + }, [setLoading]); + + const handleClick: React.MouseEventHandler = useCallback( + (e) => { + if (onClick) { + e.stopPropagation(); + onClick(); + } + }, + [onClick], + ); + + return ( + <div className='gifv' style={{ position: 'relative' }}> + {loading && ( + <canvas + width={width} + height={height} + role='button' + tabIndex={0} + aria-label={alt} + title={alt} + lang={lang} + onClick={handleClick} + /> + )} + + <video + src={src} + role='button' + tabIndex={0} + aria-label={alt} + title={alt} + lang={lang} + muted + loop + autoPlay + playsInline + onClick={handleClick} + onLoadedData={handleLoadedData} + style={{ position: loading ? 'absolute' : 'static', top: 0, left: 0 }} + /> + </div> + ); +}; diff --git a/app/javascript/flavours/blobfox/components/hashtag.jsx b/app/javascript/flavours/blobfox/components/hashtag.jsx new file mode 100644 index 00000000000000..101c8c518a4efd --- /dev/null +++ b/app/javascript/flavours/blobfox/components/hashtag.jsx @@ -0,0 +1,123 @@ +// @ts-check +import PropTypes from 'prop-types'; +import { Component } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + +import { Sparklines, SparklinesCurve } from 'react-sparklines'; + +import { ShortNumber } from 'flavours/blobfox/components/short_number'; +import { Skeleton } from 'flavours/blobfox/components/skeleton'; + +import Permalink from './permalink'; + +class SilentErrorBoundary extends 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) => ( + <FormattedMessage + id='trends.counter_by_accounts' + defaultMessage='{count, plural, one {{counter} person} other {{counter} people}} in the past {days, plural, one {day} other {# days}}' + values={{ + count: pluralReady, + counter: <strong>{displayNumber}</strong>, + days: 2, + }} + /> +); + +// @ts-expect-error +export const ImmutableHashtag = ({ hashtag }) => ( + <Hashtag + name={hashtag.get('name')} + href={hashtag.get('url')} + to={`/tags/${hashtag.get('name')}`} + people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1} + // @ts-expect-error + history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()} + /> +); + +ImmutableHashtag.propTypes = { + hashtag: ImmutablePropTypes.map.isRequired, +}; + +// @ts-expect-error +const Hashtag = ({ name, href, to, people, uses, history, className, description, withGraph }) => ( + <div className={classNames('trends__item', className)}> + <div className='trends__item__name'> + <Permalink href={href} to={to}> + {name ? <>#<span>{name}</span></> : <Skeleton width={50} />} + </Permalink> + + {description ? ( + <span>{description}</span> + ) : ( + typeof people !== 'undefined' ? <ShortNumber value={people} renderer={accountsCountRenderer} /> : <Skeleton width={100} /> + )} + </div> + + {typeof uses !== 'undefined' && ( + <div className='trends__item__current'> + <ShortNumber value={uses} /> + </div> + )} + + {withGraph && ( + <div className='trends__item__sparkline'> + <SilentErrorBoundary> + <Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}> + <SparklinesCurve style={{ fill: 'none' }} /> + </Sparklines> + </SilentErrorBoundary> + </div> + )} + </div> +); + +Hashtag.propTypes = { + name: PropTypes.string, + href: 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/flavours/blobfox/components/hashtag_bar.tsx b/app/javascript/flavours/blobfox/components/hashtag_bar.tsx new file mode 100644 index 00000000000000..91fa9221983a70 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/hashtag_bar.tsx @@ -0,0 +1,234 @@ +import { useState, useCallback } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { Link } from 'react-router-dom'; + +import type { List, Record } from 'immutable'; + +import { groupBy, minBy } from 'lodash'; + +import { getStatusContent } from './status_content'; + +// Fit on a single line on desktop +const VISIBLE_HASHTAGS = 3; + +// Those types are not correct, they need to be replaced once this part of the state is typed +export type TagLike = Record<{ name: string }>; +export type StatusLike = Record<{ + tags: List<TagLike>; + contentHTML: string; + media_attachments: List<unknown>; + spoiler_text?: string; +}>; + +function normalizeHashtag(hashtag: string) { + return ( + hashtag && hashtag.startsWith('#') ? hashtag.slice(1) : hashtag + ).normalize('NFKC'); +} + +function isNodeLinkHashtag(element: Node): element is HTMLLinkElement { + return ( + element instanceof HTMLAnchorElement && + // it may be a <a> starting with a hashtag + (element.textContent?.[0] === '#' || + // or a #<a> + element.previousSibling?.textContent?.[ + element.previousSibling.textContent.length - 1 + ] === '#') + ); +} + +/** + * Removes duplicates from an hashtag list, case-insensitive, keeping only the best one + * "Best" here is defined by the one with the more casing difference (ie, the most camel-cased one) + * @param hashtags The list of hashtags + * @returns The input hashtags, but with only 1 occurence of each (case-insensitive) + */ +function uniqueHashtagsWithCaseHandling(hashtags: string[]) { + const groups = groupBy(hashtags, (tag) => + tag.normalize('NFKD').toLowerCase(), + ); + + return Object.values(groups).map((tags) => { + if (tags.length === 1) return tags[0]; + + // The best match is the one where we have the less difference between upper and lower case letter count + const best = minBy(tags, (tag) => { + const upperCase = Array.from(tag).reduce( + (acc, char) => (acc += char.toUpperCase() === char ? 1 : 0), + 0, + ); + + const lowerCase = tag.length - upperCase; + + return Math.abs(lowerCase - upperCase); + }); + + return best ?? tags[0]; + }); +} + +// Create the collator once, this is much more efficient +const collator = new Intl.Collator(undefined, { + sensitivity: 'base', // we use this to emulate the ASCII folding done on the server-side, hopefuly more efficiently +}); + +function localeAwareInclude(collection: string[], value: string) { + const normalizedValue = value.normalize('NFKC'); + + return !!collection.find( + (item) => collator.compare(item.normalize('NFKC'), normalizedValue) === 0, + ); +} + +// We use an intermediate function here to make it easier to test +export function computeHashtagBarForStatus(status: StatusLike): { + statusContentProps: { statusContent: string }; + hashtagsInBar: string[]; +} { + let statusContent = getStatusContent(status); + + const tagNames = status + .get('tags') + .map((tag) => tag.get('name')) + .toJS(); + + // this is returned if we stop the processing early, it does not change what is displayed + const defaultResult = { + statusContentProps: { statusContent }, + hashtagsInBar: [], + }; + + // return early if this status does not have any tags + if (tagNames.length === 0) return defaultResult; + + const template = document.createElement('template'); + template.innerHTML = statusContent.trim(); + + const lastChild = template.content.lastChild; + + if (!lastChild || lastChild.nodeType === Node.TEXT_NODE) return defaultResult; + + template.content.removeChild(lastChild); + const contentWithoutLastLine = template; + + // First, try to parse + const contentHashtags = Array.from( + contentWithoutLastLine.content.querySelectorAll<HTMLLinkElement>('a[href]'), + ).reduce<string[]>((result, link) => { + if (isNodeLinkHashtag(link)) { + if (link.textContent) result.push(normalizeHashtag(link.textContent)); + } + return result; + }, []); + + // Now we parse the last line, and try to see if it only contains hashtags + const lastLineHashtags: string[] = []; + // try to see if the last line is only hashtags + let onlyHashtags = true; + + const normalizedTagNames = tagNames.map((tag) => tag.normalize('NFKC')); + + Array.from(lastChild.childNodes).forEach((node) => { + if (isNodeLinkHashtag(node) && node.textContent) { + const normalized = normalizeHashtag(node.textContent); + + if (!localeAwareInclude(normalizedTagNames, normalized)) { + // stop here, this is not a real hashtag, so consider it as text + onlyHashtags = false; + return; + } + + if (!localeAwareInclude(contentHashtags, normalized)) + // only add it if it does not appear in the rest of the content + lastLineHashtags.push(normalized); + } else if (node.nodeType !== Node.TEXT_NODE || node.nodeValue?.trim()) { + // not a space + onlyHashtags = false; + } + }); + + const hashtagsInBar = tagNames.filter((tag) => { + const normalizedTag = tag.normalize('NFKC'); + // the tag does not appear at all in the status content, it is an out-of-band tag + return ( + !localeAwareInclude(contentHashtags, normalizedTag) && + !localeAwareInclude(lastLineHashtags, normalizedTag) + ); + }); + + const isOnlyOneLine = contentWithoutLastLine.content.childElementCount === 0; + const hasMedia = status.get('media_attachments').size > 0; + const hasSpoiler = !!status.get('spoiler_text'); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- due to https://github.com/microsoft/TypeScript/issues/9998 + if (onlyHashtags && ((hasMedia && !hasSpoiler) || !isOnlyOneLine)) { + // if the last line only contains hashtags, and we either: + // - have other content in the status + // - dont have other content, but a media and no CW. If it has a CW, then we do not remove the content to avoid having an empty content behind the CW button + statusContent = contentWithoutLastLine.innerHTML; + // and add the tags to the bar + hashtagsInBar.push(...lastLineHashtags); + } + + return { + statusContentProps: { statusContent }, + hashtagsInBar: uniqueHashtagsWithCaseHandling(hashtagsInBar), + }; +} + +/** + * This function will process a status to, at the same time (avoiding parsing it twice): + * - build the HashtagBar for this status + * - remove the last-line hashtags from the status content + * @param status The status to process + * @returns Props to be passed to the <StatusContent> component, and the hashtagBar to render + */ +export function getHashtagBarForStatus(status: StatusLike) { + const { statusContentProps, hashtagsInBar } = + computeHashtagBarForStatus(status); + + return { + statusContentProps, + hashtagBar: <HashtagBar hashtags={hashtagsInBar} />, + }; +} + +const HashtagBar: React.FC<{ + hashtags: string[]; +}> = ({ hashtags }) => { + const [expanded, setExpanded] = useState(false); + const handleClick = useCallback(() => { + setExpanded(true); + }, []); + + if (hashtags.length === 0) { + return null; + } + + const revealedHashtags = expanded + ? hashtags + : hashtags.slice(0, VISIBLE_HASHTAGS); + + return ( + <div className='hashtag-bar'> + {revealedHashtags.map((hashtag) => ( + <Link key={hashtag} to={`/tags/${hashtag}`}> + #<span>{hashtag}</span> + </Link> + ))} + + {!expanded && hashtags.length > VISIBLE_HASHTAGS && ( + <button className='link-button' onClick={handleClick}> + <FormattedMessage + id='hashtags.and_other' + defaultMessage='…and {count, plural, other {# more}}' + values={{ count: hashtags.length - VISIBLE_HASHTAGS }} + /> + </button> + )} + </div> + ); +}; diff --git a/app/javascript/flavours/blobfox/components/icon.tsx b/app/javascript/flavours/blobfox/components/icon.tsx new file mode 100644 index 00000000000000..3d091c7059e18c --- /dev/null +++ b/app/javascript/flavours/blobfox/components/icon.tsx @@ -0,0 +1,20 @@ +import classNames from 'classnames'; + +interface Props extends React.HTMLAttributes<HTMLImageElement> { + id: string; + className?: string; + fixedWidth?: boolean; + children?: never; +} + +export const Icon: React.FC<Props> = ({ + id, + className, + fixedWidth, + ...other +}) => ( + <i + className={classNames('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })} + {...other} + /> +); diff --git a/app/javascript/flavours/blobfox/components/icon_button.tsx b/app/javascript/flavours/blobfox/components/icon_button.tsx new file mode 100644 index 00000000000000..8bca60fa971f78 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/icon_button.tsx @@ -0,0 +1,180 @@ +import { PureComponent } from 'react'; + +import classNames from 'classnames'; + +import { AnimatedNumber } from './animated_number'; +import { Icon } from './icon'; + +interface Props { + className?: string; + title: string; + icon: string; + onClick?: React.MouseEventHandler<HTMLButtonElement>; + onMouseDown?: React.MouseEventHandler<HTMLButtonElement>; + onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>; + onKeyPress?: React.KeyboardEventHandler<HTMLButtonElement>; + size: number; + active: boolean; + expanded?: boolean; + style?: React.CSSProperties; + activeStyle?: React.CSSProperties; + disabled: boolean; + inverted?: boolean; + animate: boolean; + overlay: boolean; + tabIndex: number; + label?: string; + counter?: number; + obfuscateCount?: boolean; + href?: string; + ariaHidden: boolean; +} +interface States { + activate: boolean; + deactivate: boolean; +} +export class IconButton extends PureComponent<Props, States> { + static defaultProps = { + size: 18, + active: false, + disabled: false, + animate: false, + overlay: false, + tabIndex: 0, + ariaHidden: false, + }; + + state = { + activate: false, + deactivate: false, + }; + + UNSAFE_componentWillReceiveProps(nextProps: Props) { + 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: React.MouseEventHandler<HTMLButtonElement> = (e) => { + e.preventDefault(); + + if (!this.props.disabled && this.props.onClick != null) { + this.props.onClick(e); + } + }; + + handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> = (e) => { + if (this.props.onKeyPress && !this.props.disabled) { + this.props.onKeyPress(e); + } + }; + + handleMouseDown: React.MouseEventHandler<HTMLButtonElement> = (e) => { + if (!this.props.disabled && this.props.onMouseDown) { + this.props.onMouseDown(e); + } + }; + + handleKeyDown: React.KeyboardEventHandler<HTMLButtonElement> = (e) => { + if (!this.props.disabled && this.props.onKeyDown) { + this.props.onKeyDown(e); + } + }; + + render() { + // Hack required for some icons which have an overriden size + let containerSize = '1.28571429em'; + if (this.props.style?.fontSize) { + containerSize = `${this.props.size * 1.28571429}px`; + } + + const style = { + fontSize: `${this.props.size}px`, + height: containerSize, + lineHeight: `${this.props.size}px`, + ...this.props.style, + ...(this.props.active ? this.props.activeStyle : {}), + }; + if (!this.props.label) { + style.width = containerSize; + } else { + style.textAlign = 'left'; + } + + 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 = ( + <> + <Icon id={icon} fixedWidth aria-hidden='true' />{' '} + {typeof counter !== 'undefined' && ( + <span className='icon-button__counter'> + <AnimatedNumber value={counter} obfuscate={obfuscateCount} /> + </span> + )} + {this.props.label} + </> + ); + + if (href != null) { + contents = ( + <a href={href} target='_blank' rel='noopener noreferrer'> + {contents} + </a> + ); + } + + return ( + <button + type='button' + aria-label={title} + aria-expanded={expanded} + aria-hidden={ariaHidden} + title={title} + className={classes} + onClick={this.handleClick} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleKeyDown} + onKeyPress={this.handleKeyPress} + style={style} + tabIndex={tabIndex} + disabled={disabled} + > + {contents} + </button> + ); + } +} diff --git a/app/javascript/flavours/blobfox/components/icon_with_badge.tsx b/app/javascript/flavours/blobfox/components/icon_with_badge.tsx new file mode 100644 index 00000000000000..8898f413299480 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/icon_with_badge.tsx @@ -0,0 +1,24 @@ +import { Icon } from './icon'; + +const formatNumber = (num: number): number | string => (num > 40 ? '40+' : num); + +interface Props { + id: string; + count: number; + issueBadge: boolean; + className: string; +} +export const IconWithBadge: React.FC<Props> = ({ + id, + count, + issueBadge, + className, +}) => ( + <i className='icon-with-badge'> + <Icon id={id} fixedWidth className={className} /> + {count > 0 && ( + <i className='icon-with-badge__badge'>{formatNumber(count)}</i> + )} + {issueBadge && <i className='icon-with-badge__issue-badge' />} + </i> +); diff --git a/app/javascript/flavours/blobfox/components/inline_account.jsx b/app/javascript/flavours/blobfox/components/inline_account.jsx new file mode 100644 index 00000000000000..dd913c5e81ccef --- /dev/null +++ b/app/javascript/flavours/blobfox/components/inline_account.jsx @@ -0,0 +1,37 @@ +import { PureComponent } from 'react'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +import { Avatar } from 'flavours/blobfox/components/avatar'; +import { makeGetAccount } from 'flavours/blobfox/selectors'; + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { accountId }) => ({ + account: getAccount(state, accountId), + }); + + return mapStateToProps; +}; + +class InlineAccount extends PureComponent { + + static propTypes = { + account: ImmutablePropTypes.record.isRequired, + }; + + render () { + const { account } = this.props; + + return ( + <span className='inline-account'> + <Avatar size={13} account={account} /> <strong>{account.get('username')}</strong> + </span> + ); + } + +} + +export default connect(makeMapStateToProps)(InlineAccount); diff --git a/app/javascript/flavours/blobfox/components/intersection_observer_article.jsx b/app/javascript/flavours/blobfox/components/intersection_observer_article.jsx new file mode 100644 index 00000000000000..8efa969f9bff56 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/intersection_observer_article.jsx @@ -0,0 +1,131 @@ +import PropTypes from 'prop-types'; +import { cloneElement, Component } from 'react'; + +import getRectFromEntry from '../features/ui/util/get_rect_from_entry'; +import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; + +// Diff these props in the "unrendered" state +const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight']; + +export default class IntersectionObserverArticle extends 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 ( + <article + ref={this.handleRef} + aria-posinset={index + 1} + aria-setsize={listLength} + style={{ height: `${this.height || cachedHeight}px`, opacity: 0, overflow: 'hidden' }} + data-id={id} + tabIndex={-1} + > + {children && cloneElement(children, { hidden: true })} + </article> + ); + } + + return ( + <article ref={this.handleRef} aria-posinset={index + 1} aria-setsize={listLength} data-id={id} tabIndex={-1}> + {children && cloneElement(children, { hidden: false })} + </article> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/components/link.jsx b/app/javascript/flavours/blobfox/components/link.jsx new file mode 100644 index 00000000000000..5059922f6ab1e6 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/link.jsx @@ -0,0 +1,97 @@ +// Inspired by <CommonLink> from Mastodon GO! +// ~ 😘 kibi! + +// Package imports. +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import classNames from 'classnames'; + +// Utils. +import { assignHandlers } from 'flavours/blobfox/utils/react_helpers'; +// Handlers. +const handlers = { + + // We don't handle clicks that are made with modifiers, since these + // often have special browser meanings (eg, "open in new tab"). + click (e) { + const { onClick } = this.props; + if (!onClick || e.button || e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) { + return; + } + onClick(e); + e.preventDefault(); // Prevents following of the link + }, +}; + +// The component. +export default class Link extends PureComponent { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(this, handlers); + } + + // Rendering. + render () { + const { click } = this.handlers; + const { + children, + className, + href, + onClick, + role, + title, + ...rest + } = this.props; + const computedClass = classNames('link', className, `role-${role}`); + + // We assume that our `onClick` is a routing function and give it + // the qualities of a link even if no `href` is provided. However, + // if we have neither an `onClick` or an `href`, our link is + // purely presentational. + const conditionalProps = {}; + if (href) { + conditionalProps.href = href; + conditionalProps.onClick = click; + } else if (onClick) { + conditionalProps.onClick = click; + conditionalProps.role = 'link'; + conditionalProps.tabIndex = 0; + } else { + conditionalProps.role = 'presentation'; + } + + // If we were provided a `role` it overwrites any that we may have + // set above. This can be used for "links" which are actually + // buttons. + if (role) { + conditionalProps.role = role; + } + + // Rendering. We set `rel='noopener'` for user privacy, and our + // `target` as `'_blank'`. + return ( + <a + className={computedClass} + {...conditionalProps} + rel='noopener' + target='_blank' + title={title} + {...rest} + >{children}</a> + ); + } + +} + +// Props. +Link.propTypes = { + children: PropTypes.node, + className: PropTypes.string, + href: PropTypes.string, // The link destination + onClick: PropTypes.func, // A function to call instead of opening the link + role: PropTypes.string, // An ARIA role for the link + title: PropTypes.string, // A title for the link +}; diff --git a/app/javascript/flavours/blobfox/components/load_gap.tsx b/app/javascript/flavours/blobfox/components/load_gap.tsx new file mode 100644 index 00000000000000..565930bb6ba668 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/load_gap.tsx @@ -0,0 +1,34 @@ +import { useCallback } from 'react'; + +import { useIntl, defineMessages } from 'react-intl'; + +import { Icon } from 'flavours/blobfox/components/icon'; + +const messages = defineMessages({ + load_more: { id: 'status.load_more', defaultMessage: 'Load more' }, +}); + +interface Props { + disabled: boolean; + maxId: string; + onClick: (maxId: string) => void; +} + +export const LoadGap: React.FC<Props> = ({ disabled, maxId, onClick }) => { + const intl = useIntl(); + + const handleClick = useCallback(() => { + onClick(maxId); + }, [maxId, onClick]); + + return ( + <button + className='load-more load-gap' + disabled={disabled} + onClick={handleClick} + aria-label={intl.formatMessage(messages.load_more)} + > + <Icon id='ellipsis-h' /> + </button> + ); +}; diff --git a/app/javascript/flavours/blobfox/components/load_more.tsx b/app/javascript/flavours/blobfox/components/load_more.tsx new file mode 100644 index 00000000000000..8b5746ad30b34a --- /dev/null +++ b/app/javascript/flavours/blobfox/components/load_more.tsx @@ -0,0 +1,24 @@ +import { FormattedMessage } from 'react-intl'; + +interface Props { + onClick: (event: React.MouseEvent) => void; + disabled?: boolean; + visible?: boolean; +} +export const LoadMore: React.FC<Props> = ({ + onClick, + disabled, + visible = true, +}) => { + return ( + <button + type='button' + className='load-more' + disabled={disabled || !visible} + style={{ visibility: visible ? 'visible' : 'hidden' }} + onClick={onClick} + > + <FormattedMessage id='status.load_more' defaultMessage='Load more' /> + </button> + ); +}; diff --git a/app/javascript/flavours/blobfox/components/load_pending.tsx b/app/javascript/flavours/blobfox/components/load_pending.tsx new file mode 100644 index 00000000000000..f7589622edb2d5 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/load_pending.tsx @@ -0,0 +1,18 @@ +import { FormattedMessage } from 'react-intl'; + +interface Props { + onClick: (event: React.MouseEvent) => void; + count: number; +} + +export const LoadPending: React.FC<Props> = ({ onClick, count }) => { + return ( + <button className='load-more load-gap' onClick={onClick}> + <FormattedMessage + id='load_pending' + defaultMessage='{count, plural, one {# new item} other {# new items}}' + values={{ count }} + /> + </button> + ); +}; diff --git a/app/javascript/flavours/blobfox/components/loading_indicator.tsx b/app/javascript/flavours/blobfox/components/loading_indicator.tsx new file mode 100644 index 00000000000000..6bc24a0d617ecf --- /dev/null +++ b/app/javascript/flavours/blobfox/components/loading_indicator.tsx @@ -0,0 +1,7 @@ +import { CircularProgress } from './circular_progress'; + +export const LoadingIndicator: React.FC = () => ( + <div className='loading-indicator'> + <CircularProgress size={50} strokeWidth={6} /> + </div> +); diff --git a/app/javascript/flavours/blobfox/components/logo.jsx b/app/javascript/flavours/blobfox/components/logo.jsx new file mode 100644 index 00000000000000..16ca9f80fd0b04 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/logo.jsx @@ -0,0 +1,14 @@ +import logo from 'mastodon/../images/logo.svg'; + +export const WordmarkLogo = () => ( + <svg viewBox='0 0 261 66' className='logo logo--wordmark' role='img'> + <title>Mastodon</title> + <use xlinkHref='#logo-symbol-wordmark' /> + </svg> +); + +export const SymbolLogo = () => ( + <img src={logo} alt='Mastodon' className='logo logo--icon' /> +); + +export default WordmarkLogo; diff --git a/app/javascript/flavours/blobfox/components/media_attachments.jsx b/app/javascript/flavours/blobfox/components/media_attachments.jsx new file mode 100644 index 00000000000000..a10a1b26115f26 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/media_attachments.jsx @@ -0,0 +1,128 @@ +import PropTypes from 'prop-types'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import noop from 'lodash/noop'; + +import Bundle from 'flavours/blobfox/features/ui/components/bundle'; +import { MediaGallery, Video, Audio } from 'flavours/blobfox/features/ui/util/async-components'; + +export default class MediaAttachments extends ImmutablePureComponent { + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + lang: PropTypes.string, + height: PropTypes.number, + width: PropTypes.number, + visible: PropTypes.bool, + }; + + static defaultProps = { + height: 110, + width: 239, + }; + + updateOnProps = [ + 'status', + ]; + + renderLoadingMediaGallery = () => { + const { height, width } = this.props; + + return ( + <div className='media-gallery' style={{ height, width }} /> + ); + }; + + renderLoadingVideoPlayer = () => { + const { height, width } = this.props; + + return ( + <div className='video-player' style={{ height, width }} /> + ); + }; + + renderLoadingAudioPlayer = () => { + const { height, width } = this.props; + + return ( + <div className='audio-player' style={{ height, width }} /> + ); + }; + + render () { + const { status, width, height, visible } = this.props; + const mediaAttachments = status.get('media_attachments'); + const language = status.getIn(['language', 'translation']) || status.get('language') || this.props.lang; + + if (mediaAttachments.size === 0) { + return null; + } + + if (mediaAttachments.getIn([0, 'type']) === 'audio') { + const audio = mediaAttachments.get(0); + const description = audio.getIn(['translation', 'description']) || audio.get('description'); + + return ( + <Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} > + {Component => ( + <Component + src={audio.get('url')} + alt={description} + lang={language} + width={width} + height={height} + poster={audio.get('preview_url') || status.getIn(['account', 'avatar_static'])} + backgroundColor={audio.getIn(['meta', 'colors', 'background'])} + foregroundColor={audio.getIn(['meta', 'colors', 'foreground'])} + accentColor={audio.getIn(['meta', 'colors', 'accent'])} + duration={audio.getIn(['meta', 'original', 'duration'], 0)} + /> + )} + </Bundle> + ); + } else if (mediaAttachments.getIn([0, 'type']) === 'video') { + const video = mediaAttachments.get(0); + const description = video.getIn(['translation', 'description']) || video.get('description'); + + return ( + <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} > + {Component => ( + <Component + preview={video.get('preview_url')} + frameRate={video.getIn(['meta', 'original', 'frame_rate'])} + blurhash={video.get('blurhash')} + src={video.get('url')} + alt={description} + lang={language} + width={width} + height={height} + inline + sensitive={status.get('sensitive')} + visible={visible} + onOpenVideo={noop} + /> + )} + </Bundle> + ); + } else { + return ( + <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} > + {Component => ( + <Component + media={mediaAttachments} + lang={language} + sensitive={status.get('sensitive')} + defaultWidth={width} + visible={visible} + height={height} + onOpenMedia={noop} + /> + )} + </Bundle> + ); + } + } + +} diff --git a/app/javascript/flavours/blobfox/components/media_gallery.jsx b/app/javascript/flavours/blobfox/components/media_gallery.jsx new file mode 100644 index 00000000000000..a2778c39873194 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/media_gallery.jsx @@ -0,0 +1,395 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import { is } from 'immutable'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +import { debounce } from 'lodash'; + +import { Blurhash } from 'flavours/blobfox/components/blurhash'; + +import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state'; + +import { IconButton } from './icon_button'; + +const messages = defineMessages({ + hidden: { + defaultMessage: 'Media hidden', + id: 'status.media_hidden', + }, + sensitive: { + defaultMessage: 'Sensitive', + id: 'media_gallery.sensitive', + }, + toggle: { + defaultMessage: 'Click to view', + id: 'status.sensitive_toggle', + }, + toggle_visible: { + defaultMessage: '{number, plural, one {Hide image} other {Hide images}}', + id: 'media_gallery.toggle_visible', + }, + warning: { + defaultMessage: 'Sensitive content', + id: 'status.sensitive_warning', + }, +}); + +class Item extends PureComponent { + + static propTypes = { + attachment: ImmutablePropTypes.map.isRequired, + lang: PropTypes.string, + standalone: PropTypes.bool, + index: PropTypes.number.isRequired, + size: PropTypes.number.isRequired, + letterbox: PropTypes.bool, + 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, lang, index, size, standalone, letterbox, displayWidth, visible } = this.props; + + let badges = [], thumbnail; + + let width = 50; + let height = 100; + + if (size === 1) { + width = 100; + } + + if (size === 4 || (size === 3 && index > 0)) { + height = 50; + } + + if (attachment.get('description')?.length > 0) { + badges.push(<span key='alt' className='media-gallery__gifv__label'>ALT</span>); + } + + const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); + + if (attachment.get('type') === 'unknown') { + return ( + <div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}> + <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={description} lang={lang} target='_blank' rel='noopener noreferrer'> + <Blurhash + hash={attachment.get('blurhash')} + className='media-gallery__preview' + dummy={!useBlurhash} + /> + </a> + </div> + ); + } 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 = ( + <a + className='media-gallery__item-thumbnail' + href={attachment.get('remote_url') || originalUrl} + onClick={this.handleClick} + target='_blank' + rel='noopener noreferrer' + > + <img + className={letterbox ? 'letterbox' : null} + src={previewUrl} + srcSet={srcSet} + sizes={sizes} + alt={description} + title={description} + lang={lang} + style={{ objectPosition: letterbox ? null : `${x}% ${y}%` }} + onLoad={this.handleImageLoad} + /> + </a> + ); + } else if (attachment.get('type') === 'gifv') { + const autoPlay = this.getAutoPlay(); + + badges.push(<span key='gif' className='media-gallery__gifv__label'>GIF</span>); + + thumbnail = ( + <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> + <video + className={`media-gallery__item-gifv-thumbnail${letterbox ? ' letterbox' : ''}`} + aria-label={description} + title={description} + lang={lang} + role='application' + src={attachment.get('url')} + onClick={this.handleClick} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + autoPlay={autoPlay} + playsInline + loop + muted + /> + </div> + ); + } + + return ( + <div className={classNames('media-gallery__item', { standalone, letterbox, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}> + <Blurhash + hash={attachment.get('blurhash')} + dummy={!useBlurhash} + className={classNames('media-gallery__preview', { + 'media-gallery__preview--hidden': visible && this.state.loaded, + })} + /> + + {visible && thumbnail} + + {badges && ( + <div className='media-gallery__item__badges'> + {badges} + </div> + )} + </div> + ); + } + +} + +class MediaGallery extends PureComponent { + + static propTypes = { + sensitive: PropTypes.bool, + standalone: PropTypes.bool, + letterbox: PropTypes.bool, + fullwidth: PropTypes.bool, + hidden: PropTypes.bool, + media: ImmutablePropTypes.list.isRequired, + lang: PropTypes.string, + size: PropTypes.object, + 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); + } + + UNSAFE_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 }); + } + } + + componentDidUpdate () { + if (this.node) { + this.handleResize(); + } + } + + handleResize = debounce(() => { + if (this.node) { + this._setDimensions(); + } + }, 250, { + leading: true, + 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, this.props.lang); + }; + + handleRef = (node) => { + this.node = node; + + if (this.node) { + this._setDimensions(); + } + }; + + _setDimensions () { + const width = this.node.offsetWidth; + + if (width && width !== this.state.width) { + // offsetWidth triggers a layout, so only calculate when we need to + if (this.props.cacheWidth) { + this.props.cacheWidth(width); + } + + this.setState({ + width: width, + }); + } + } + + isStandaloneEligible() { + const { media, standalone } = this.props; + return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']); + } + + render () { + const { media, lang, intl, sensitive, letterbox, fullwidth, defaultWidth, autoplay } = this.props; + const { visible } = this.state; + const size = media.take(4).size; + const uncached = media.every(attachment => attachment.get('type') === 'unknown'); + + const width = this.state.width || defaultWidth; + + let children, spoilerButton; + + const style = {}; + + const computedClass = classNames('media-gallery', { 'full-width': fullwidth }); + + if (this.isStandaloneEligible()) { // TODO: cropImages setting + style.aspectRatio = `${this.props.media.getIn([0, 'meta', 'small', 'aspect'])}`; + } else { + style.aspectRatio = '16 / 9'; + } + + if (this.isStandaloneEligible()) { + children = <Item standalone autoplay={autoplay} onClick={this.handleClick} attachment={media.get(0)} lang={lang} displayWidth={width} visible={visible} />; + } else { + children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} autoplay={autoplay} onClick={this.handleClick} attachment={attachment} index={i} lang={lang} size={size} letterbox={letterbox} displayWidth={width} visible={visible || uncached} />); + } + + if (uncached) { + spoilerButton = ( + <button type='button' disabled className='spoiler-button__overlay'> + <span className='spoiler-button__overlay__label'> + <FormattedMessage id='status.uncached_media_warning' defaultMessage='Preview not available' /> + <span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.open' defaultMessage='Click to open' /></span> + </span> + </button> + ); + } else if (visible) { + spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible, { number: size })} icon='eye-slash' overlay onClick={this.handleOpen} ariaHidden />; + } else { + spoilerButton = ( + <button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'> + <span className='spoiler-button__overlay__label'> + {sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />} + <span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span> + </span> + </button> + ); + } + + return ( + <div className={computedClass} style={style} ref={this.handleRef}> + <div className={classNames('spoiler-button', { 'spoiler-button--minified': visible && !uncached, 'spoiler-button--click-thru': uncached })}> + {spoilerButton} + {visible && sensitive && ( + <span className='sensitive-marker'> + <FormattedMessage {...messages.sensitive} /> + </span> + )} + </div> + + {children} + </div> + ); + } + +} + +export default injectIntl(MediaGallery); diff --git a/app/javascript/flavours/blobfox/components/modal_root.jsx b/app/javascript/flavours/blobfox/components/modal_root.jsx new file mode 100644 index 00000000000000..d5723c58cde47a --- /dev/null +++ b/app/javascript/flavours/blobfox/components/modal_root.jsx @@ -0,0 +1,165 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import 'wicg-inert'; + +import { multiply } from 'color-blend'; +import { createBrowserHistory } from 'history'; + +import { WithOptionalRouterPropTypes, withOptionalRouter } from 'flavours/blobfox/utils/react_router'; + +class ModalRoot extends PureComponent { + + static propTypes = { + children: PropTypes.node, + onClose: PropTypes.func.isRequired, + backgroundColor: PropTypes.shape({ + r: PropTypes.number, + g: PropTypes.number, + b: PropTypes.number, + }), + noEsc: PropTypes.bool, + ignoreFocus: PropTypes.bool, + ...WithOptionalRouterPropTypes, + }; + + activeElement = this.props.children ? document.activeElement : null; + + handleKeyUp = (e) => { + if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27) + && !!this.props.children && !this.props.noEsc) { + 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.props.history || createBrowserHistory(); + + if (this.props.children) { + this._handleModalOpen(); + } + } + + UNSAFE_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 ( + <div className='modal-root' ref={this.setRef} style={{ opacity: 0 }} /> + ); + } + + let backgroundColor = null; + + if (this.props.backgroundColor) { + backgroundColor = multiply({ ...this.props.backgroundColor, a: 1 }, { r: 0, g: 0, b: 0, a: 0.7 }); + } + + return ( + <div className='modal-root' ref={this.setRef}> + <div style={{ pointerEvents: visible ? 'auto' : 'none' }}> + <div role='presentation' className='modal-root__overlay' onClick={onClose} style={{ backgroundColor: backgroundColor ? `rgba(${backgroundColor.r}, ${backgroundColor.g}, ${backgroundColor.b}, 0.7)` : null }} /> + <div role='dialog' className='modal-root__container'>{children}</div> + </div> + </div> + ); + } + +} + +export default withOptionalRouter(ModalRoot); diff --git a/app/javascript/flavours/blobfox/components/navigation_portal.tsx b/app/javascript/flavours/blobfox/components/navigation_portal.tsx new file mode 100644 index 00000000000000..b1856f4509d6a7 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/navigation_portal.tsx @@ -0,0 +1,25 @@ +import { Switch, Route } from 'react-router-dom'; + +import AccountNavigation from 'flavours/blobfox/features/account/navigation'; +import Trends from 'flavours/blobfox/features/getting_started/containers/trends_container'; +import { showTrends } from 'flavours/blobfox/initial_state'; + +const DefaultNavigation: React.FC = () => + showTrends ? ( + <> + <div className='flex-spacer' /> + <Trends /> + </> + ) : null; + +export const NavigationPortal: React.FC = () => ( + <Switch> + <Route path='/@:acct' exact component={AccountNavigation} /> + <Route path='/@:acct/tagged/:tagged?' exact component={AccountNavigation} /> + <Route path='/@:acct/with_replies' exact component={AccountNavigation} /> + <Route path='/@:acct/followers' exact component={AccountNavigation} /> + <Route path='/@:acct/following' exact component={AccountNavigation} /> + <Route path='/@:acct/media' exact component={AccountNavigation} /> + <Route component={DefaultNavigation} /> + </Switch> +); diff --git a/app/javascript/flavours/blobfox/components/not_signed_in_indicator.tsx b/app/javascript/flavours/blobfox/components/not_signed_in_indicator.tsx new file mode 100644 index 00000000000000..015f74dcaeabed --- /dev/null +++ b/app/javascript/flavours/blobfox/components/not_signed_in_indicator.tsx @@ -0,0 +1,12 @@ +import { FormattedMessage } from 'react-intl'; + +export const NotSignedInIndicator: React.FC = () => ( + <div className='scrollable scrollable--flex'> + <div className='empty-column-indicator'> + <FormattedMessage + id='not_signed_in_indicator.not_signed_in' + defaultMessage='You need to login to access this resource.' + /> + </div> + </div> +); diff --git a/app/javascript/flavours/blobfox/components/notification_purge_buttons.jsx b/app/javascript/flavours/blobfox/components/notification_purge_buttons.jsx new file mode 100644 index 00000000000000..2500d082a5c503 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/notification_purge_buttons.jsx @@ -0,0 +1,64 @@ +/** + * Buttons widget for controlling the notification clearing mode. + * In idle state, the cleaning mode button is shown. When the mode is active, + * a Confirm and Abort buttons are shown in its place. + */ + + +// Package imports // +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { Icon } from 'flavours/blobfox/components/icon'; + +const messages = defineMessages({ + btnAll : { id: 'notification_purge.btn_all', defaultMessage: 'Select\nall' }, + btnNone : { id: 'notification_purge.btn_none', defaultMessage: 'Select\nnone' }, + btnInvert : { id: 'notification_purge.btn_invert', defaultMessage: 'Invert\nselection' }, + btnApply : { id: 'notification_purge.btn_apply', defaultMessage: 'Clear\nselected' }, +}); + +class NotificationPurgeButtons extends ImmutablePureComponent { + + static propTypes = { + onDeleteMarked : PropTypes.func.isRequired, + onMarkAll : PropTypes.func.isRequired, + onMarkNone : PropTypes.func.isRequired, + onInvert : PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + markNewForDelete: PropTypes.bool, + }; + + render () { + const { intl, markNewForDelete } = this.props; + + //className='active' + return ( + <div className='column-header__notif-cleaning-buttons'> + <button onClick={this.props.onMarkAll} className={classNames('column-header__button', { active: markNewForDelete })}> + <b>∀</b><br />{intl.formatMessage(messages.btnAll)} + </button> + + <button onClick={this.props.onMarkNone} className={classNames('column-header__button', { active: !markNewForDelete })}> + <b>∅</b><br />{intl.formatMessage(messages.btnNone)} + </button> + + <button onClick={this.props.onInvert} className='column-header__button'> + <b>¬</b><br />{intl.formatMessage(messages.btnInvert)} + </button> + + <button onClick={this.props.onDeleteMarked} className='column-header__button'> + <Icon id='trash' /><br />{intl.formatMessage(messages.btnApply)} + </button> + </div> + ); + } + +} + +export default injectIntl(NotificationPurgeButtons); diff --git a/app/javascript/flavours/blobfox/components/permalink.jsx b/app/javascript/flavours/blobfox/components/permalink.jsx new file mode 100644 index 00000000000000..d3e77efd3890ea --- /dev/null +++ b/app/javascript/flavours/blobfox/components/permalink.jsx @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { withOptionalRouter, WithOptionalRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +class Permalink extends PureComponent { + + static propTypes = { + className: PropTypes.string, + href: PropTypes.string.isRequired, + to: PropTypes.string.isRequired, + children: PropTypes.node, + onInterceptClick: PropTypes.func, + ...WithOptionalRouterPropTypes, + }; + + handleClick = (e) => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + if (this.props.onInterceptClick && this.props.onInterceptClick()) { + e.preventDefault(); + return; + } + + if (this.props.history) { + e.preventDefault(); + this.props.history.push(this.props.to); + } + } + }; + + render () { + const { + children, + className, + href, + to, + onInterceptClick, + ...other + } = this.props; + + return ( + <a target='_blank' href={href} onClick={this.handleClick} {...other} className={`permalink${className ? ' ' + className : ''}`}> + {children} + </a> + ); + } + +} + +export default withOptionalRouter(Permalink); diff --git a/app/javascript/flavours/blobfox/components/picture_in_picture_placeholder.jsx b/app/javascript/flavours/blobfox/components/picture_in_picture_placeholder.jsx new file mode 100644 index 00000000000000..159719b4a12e68 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/picture_in_picture_placeholder.jsx @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { connect } from 'react-redux'; + +import { removePictureInPicture } from 'flavours/blobfox/actions/picture_in_picture'; +import { Icon } from 'flavours/blobfox/components/icon'; + +class PictureInPicturePlaceholder extends PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + }; + + handleClick = () => { + const { dispatch } = this.props; + dispatch(removePictureInPicture()); + }; + + render () { + return ( + <div className='picture-in-picture-placeholder' role='button' tabIndex={0} onClick={this.handleClick}> + <Icon id='window-restore' /> + <FormattedMessage id='picture_in_picture.restore' defaultMessage='Put it back' /> + </div> + ); + } + +} + +export default connect()(PictureInPicturePlaceholder); diff --git a/app/javascript/flavours/blobfox/components/poll.jsx b/app/javascript/flavours/blobfox/components/poll.jsx new file mode 100644 index 00000000000000..db3e49073b8415 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/poll.jsx @@ -0,0 +1,249 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import escapeTextContentForBrowser from 'escape-html'; +import spring from 'react-motion/lib/spring'; + +import { Icon } from 'flavours/blobfox/components/icon'; +import emojify from 'flavours/blobfox/features/emoji/emoji'; +import Motion from 'flavours/blobfox/features/ui/util/optional_motion'; + +import { RelativeTimestamp } from './relative_timestamp'; + +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; +}, {}); + +class Poll extends ImmutablePureComponent { + + static contextTypes = { + identity: PropTypes.object, + }; + + static propTypes = { + poll: ImmutablePropTypes.map, + lang: PropTypes.string, + intl: PropTypes.object.isRequired, + disabled: PropTypes.bool, + refresh: PropTypes.func, + onVote: PropTypes.func, + }; + + state = { + selected: {}, + expired: null, + }; + + static getDerivedStateFromProps (props, state) { + const { poll } = props; + const expires_at = poll.get('expires_at'); + const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < Date.now(); + return (expired === state.expired) ? null : { expired }; + } + + componentDidMount () { + this._setupTimer(); + } + + componentDidUpdate () { + this._setupTimer(); + } + + componentWillUnmount () { + clearTimeout(this._timer); + } + + _setupTimer () { + const { poll } = this.props; + clearTimeout(this._timer); + if (!this.state.expired) { + const delay = (new Date(poll.get('expires_at'))).getTime() - Date.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(); + }; + + handleReveal = () => { + this.setState({ revealed: true }); + }; + + renderOption (option, optionIndex, showResults) { + const { poll, lang, 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)); + + const title = option.getIn(['translation', 'title']) || option.get('title'); + let titleHtml = option.getIn(['translation', 'titleHtml']) || option.get('titleHtml'); + + if (!titleHtml) { + const emojiMap = makeEmojiMap(poll); + titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap); + } + + return ( + <li key={option.get('title')}> + <label className={classNames('poll__option', { selectable: !showResults })}> + <input + name='vote-options' + type={poll.get('multiple') ? 'checkbox' : 'radio'} + value={optionIndex} + checked={active} + onChange={this.handleOptionChange} + disabled={disabled} + /> + + {!showResults && ( + <span + className={classNames('poll__input', { checkbox: poll.get('multiple'), active })} + tabIndex={0} + role={poll.get('multiple') ? 'checkbox' : 'radio'} + onKeyPress={this.handleOptionKeyPress} + aria-checked={active} + aria-label={title} + lang={lang} + data-index={optionIndex} + /> + )} + {showResults && ( + <span + className='poll__number' + title={intl.formatMessage(messages.votes, { + votes: option.get('votes_count'), + })} + > + {Math.round(percent)}% + </span> + )} + + <span + className='poll__option__text translate' + lang={lang} + dangerouslySetInnerHTML={{ __html: titleHtml }} + /> + + {!!voted && <span className='poll__voted'> + <Icon id='check' className='poll__voted__mark' title={intl.formatMessage(messages.voted)} /> + </span>} + </label> + + {showResults && ( + <Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}> + {({ width }) => + <span className={classNames('poll__chart', { leading })} style={{ width: `${width}%` }} /> + } + </Motion> + )} + </li> + ); + } + + render () { + const { poll, intl } = this.props; + const { revealed, expired } = this.state; + + if (!poll) { + return null; + } + + const timeRemaining = expired ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />; + const showResults = poll.get('voted') || revealed || 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 = <FormattedMessage id='poll.total_people' defaultMessage='{count, plural, one {# person} other {# people}}' values={{ count: poll.get('voters_count') }} />; + } else { + votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />; + } + + return ( + <div className='poll'> + <ul> + {poll.get('options').map((option, i) => this.renderOption(option, i, showResults))} + </ul> + + <div className='poll__footer'> + {!showResults && <button className='button button-secondary' disabled={disabled || !this.context.identity.signedIn} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>} + {!showResults && <><button className='poll__link' onClick={this.handleReveal}><FormattedMessage id='poll.reveal' defaultMessage='See results' /></button> · </>} + {showResults && !this.props.disabled && <><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </>} + {votesCount} + {poll.get('expires_at') && <> · {timeRemaining}</>} + </div> + </div> + ); + } + +} + +export default injectIntl(Poll); diff --git a/app/javascript/flavours/blobfox/components/radio_button.tsx b/app/javascript/flavours/blobfox/components/radio_button.tsx new file mode 100644 index 00000000000000..d0a565b9e65aa3 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/radio_button.tsx @@ -0,0 +1,33 @@ +import classNames from 'classnames'; + +interface Props { + value: string; + checked: boolean; + name: string; + onChange: (event: React.ChangeEvent<HTMLInputElement>) => void; + label: React.ReactNode; +} + +export const RadioButton: React.FC<Props> = ({ + name, + value, + checked, + onChange, + label, +}) => { + return ( + <label className='radio-button'> + <input + name={name} + type='radio' + value={value} + checked={checked} + onChange={onChange} + /> + + <span className={classNames('radio-button__input', { checked })} /> + + <span>{label}</span> + </label> + ); +}; diff --git a/app/javascript/flavours/blobfox/components/regeneration_indicator.jsx b/app/javascript/flavours/blobfox/components/regeneration_indicator.jsx new file mode 100644 index 00000000000000..caaa67ca1dbbf6 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/regeneration_indicator.jsx @@ -0,0 +1,18 @@ +import { FormattedMessage } from 'react-intl'; + +import illustration from 'flavours/blobfox/images/elephant_ui_working.svg'; + +const RegenerationIndicator = () => ( + <div className='regeneration-indicator'> + <div className='regeneration-indicator__figure'> + <img src={illustration} alt='' /> + </div> + + <div className='regeneration-indicator__label'> + <FormattedMessage id='regeneration_indicator.label' tagName='strong' defaultMessage='Loading…' /> + <FormattedMessage id='regeneration_indicator.sublabel' defaultMessage='Your home feed is being prepared!' /> + </div> + </div> +); + +export default RegenerationIndicator; diff --git a/app/javascript/flavours/blobfox/components/relative_timestamp.tsx b/app/javascript/flavours/blobfox/components/relative_timestamp.tsx new file mode 100644 index 00000000000000..ac3ab0fb4d48f6 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/relative_timestamp.tsx @@ -0,0 +1,282 @@ +import { Component } from 'react'; + +import type { IntlShape } from 'react-intl'; +import { injectIntl, defineMessages } from 'react-intl'; + +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', +} as const; + +const shortDateFormatOptions = { + month: 'short', + day: 'numeric', +} as const; + +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: number) => { + 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: string) => { + 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: IntlShape, + date: Date, + now: number, + year: number, + timeGiven: boolean, + short?: boolean, +) => { + 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: IntlShape, + date: Date, + now: number, + 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; +}; + +interface Props { + intl: IntlShape; + timestamp: string; + year: number; + futureDate?: boolean; + short?: boolean; +} +interface States { + now: number; +} +class RelativeTimestamp extends Component<Props, States> { + state = { + now: Date.now(), + }; + + static defaultProps = { + year: new Date().getFullYear(), + short: true, + }; + + _timer: number | undefined; + + shouldComponentUpdate(nextProps: Props, nextState: States) { + // 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 + ); + } + + UNSAFE_componentWillReceiveProps(nextProps: Props) { + if (this.props.timestamp !== nextProps.timestamp) { + this.setState({ now: Date.now() }); + } + } + + componentDidMount() { + this._scheduleNextUpdate(this.props, this.state); + } + + UNSAFE_componentWillUpdate(nextProps: Props, nextState: States) { + this._scheduleNextUpdate(nextProps, nextState); + } + + componentWillUnmount() { + window.clearTimeout(this._timer); + } + + _scheduleNextUpdate(props: Props, state: States) { + window.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 = window.setTimeout(() => { + this.setState({ now: Date.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 ( + <time + dateTime={timestamp} + title={intl.formatDate(date, dateFormatOptions)} + > + {relativeTime} + </time> + ); + } +} + +const RelativeTimestampWithIntl = injectIntl(RelativeTimestamp); + +export { RelativeTimestampWithIntl as RelativeTimestamp }; diff --git a/app/javascript/flavours/blobfox/components/router.tsx b/app/javascript/flavours/blobfox/components/router.tsx new file mode 100644 index 00000000000000..188f3b483865cb --- /dev/null +++ b/app/javascript/flavours/blobfox/components/router.tsx @@ -0,0 +1,80 @@ +import type { PropsWithChildren } from 'react'; +import React from 'react'; + +import { Router as OriginalRouter } from 'react-router'; + +import type { + LocationDescriptor, + LocationDescriptorObject, + Path, +} from 'history'; +import { createBrowserHistory } from 'history'; + +import { layoutFromWindow } from 'flavours/blobfox/is_mobile'; + +interface MastodonLocationState { + fromMastodon?: boolean; + mastodonModalKey?: string; +} +type HistoryPath = Path | LocationDescriptor<MastodonLocationState>; + +const browserHistory = createBrowserHistory< + MastodonLocationState | undefined +>(); +const originalPush = browserHistory.push.bind(browserHistory); +const originalReplace = browserHistory.replace.bind(browserHistory); + +function normalizePath( + path: HistoryPath, + state?: MastodonLocationState, +): LocationDescriptorObject<MastodonLocationState> { + const location = typeof path === 'string' ? { pathname: path } : { ...path }; + + if (location.state === undefined && state !== undefined) { + location.state = state; + } else if ( + location.state !== undefined && + state !== undefined && + process.env.NODE_ENV === 'development' + ) { + // eslint-disable-next-line no-console + console.log( + 'You should avoid providing a 2nd state argument to push when the 1st argument is a location-like object that already has state; it is ignored', + ); + } + + if ( + layoutFromWindow() === 'multi-column' && + !location.pathname?.startsWith('/deck') + ) { + location.pathname = `/deck${location.pathname}`; + } + + return location; +} + +browserHistory.push = (path: HistoryPath, state?: MastodonLocationState) => { + const location = normalizePath(path, state); + + location.state = location.state ?? {}; + location.state.fromMastodon = true; + + originalPush(location); +}; + +browserHistory.replace = (path: HistoryPath, state?: MastodonLocationState) => { + const location = normalizePath(path, state); + + if (!location.pathname) return; + + if (browserHistory.location.state?.fromMastodon) { + location.state = location.state ?? {}; + location.state.fromMastodon = true; + } + + originalReplace(location); +}; + +export const Router: React.FC<PropsWithChildren> = ({ children }) => { + return <OriginalRouter history={browserHistory}>{children}</OriginalRouter>; +}; diff --git a/app/javascript/flavours/blobfox/components/scrollable_list.jsx b/app/javascript/flavours/blobfox/components/scrollable_list.jsx new file mode 100644 index 00000000000000..f8061aad96b9c8 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/scrollable_list.jsx @@ -0,0 +1,405 @@ +import PropTypes from 'prop-types'; +import { Children, cloneElement, PureComponent } from 'react'; + +import classNames from 'classnames'; +import { useLocation } from 'react-router-dom'; + +import { List as ImmutableList } from 'immutable'; +import { connect } from 'react-redux'; + +import { supportsPassiveEvents } from 'detect-passive-events'; +import { throttle } from 'lodash'; + +import ScrollContainer from 'flavours/blobfox/containers/scroll_container'; + +import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container'; +import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen'; +import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; + +import { LoadMore } from './load_more'; +import { LoadPending } from './load_pending'; +import { LoadingIndicator } from './loading_indicator'; + +const MOUSE_IDLE_DELAY = 300; + +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; + +/** + * + * @param {import('flavours/blobfox/store').RootState} state + * @param {*} props + */ +const mapStateToProps = (state, { scrollKey }) => { + return { + preventScroll: scrollKey === state.dropdownMenu.scrollKey, + }; +}; + +// This component only exists to be able to call useLocation() +const IOArticleContainerWrapper = ({id, index, listLength, intersectionObserverWrapper, trackScroll, scrollKey, children}) => { + const location = useLocation(); + + return (<IntersectionObserverArticleContainer + id={id} + index={index} + listLength={listLength} + intersectionObserverWrapper={intersectionObserverWrapper} + saveHeightKey={trackScroll ? `${location.key}:${scrollKey}` : null} + > + {children} + </IntersectionObserverArticleContainer>); +}; + +IOArticleContainerWrapper.propTypes = { + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + scrollKey: PropTypes.string.isRequired, + intersectionObserverWrapper: PropTypes.object.isRequired, + trackScroll: PropTypes.bool.isRequired, + children: PropTypes.node, +}; + +class ScrollableList extends PureComponent { + + 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: 300, + }; + + 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 (scrollTop > 0 && offset < 400 && 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 = Children.count(prevProps.children) > 0 && + Children.count(prevProps.children) < 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.updateScrollBottom(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, listenerOptions); + } else { + this.node.addEventListener('scroll', this.handleScroll); + this.node.addEventListener('wheel', this.handleWheel, listenerOptions); + } + } + + detachScrollListener () { + if (this.props.bindToDocument) { + document.removeEventListener('scroll', this.handleScroll); + document.removeEventListener('wheel', this.handleWheel, listenerOptions); + } else { + this.node.removeEventListener('scroll', this.handleScroll); + this.node.removeEventListener('wheel', this.handleWheel, listenerOptions); + } + } + + 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 = Children.count(children); + + const loadMore = (hasMore && onLoadMore) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null; + const loadPending = (numPending > 0) ? <LoadPending count={numPending} onClick={this.handleLoadPending} /> : null; + let scrollableArea = null; + + if (showLoading) { + scrollableArea = ( + <div className='scrollable scrollable--flex' ref={this.setRef}> + <div role='feed' className='item-list'> + {prepend} + </div> + + <div className='scrollable__append'> + <LoadingIndicator /> + </div> + </div> + ); + } else if (isLoading || childrenCount > 0 || hasMore || !emptyMessage) { + scrollableArea = ( + <div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}> + <div role='feed' className='item-list'> + {prepend} + + {loadPending} + + {Children.map(this.props.children, (child, index) => ( + <IOArticleContainerWrapper + key={child.key} + id={child.key} + index={index} + listLength={childrenCount} + intersectionObserverWrapper={this.intersectionObserverWrapper} + trackScroll={trackScroll} + scrollKey={scrollKey} + > + {cloneElement(child, { + getScrollPosition: this.getScrollPosition, + updateScrollBottom: this.updateScrollBottom, + cachedMediaWidth: this.state.cachedMediaWidth, + cacheMediaWidth: this.cacheMediaWidth, + })} + </IOArticleContainerWrapper> + ))} + + {loadMore} + + {!hasMore && append} + </div> + </div> + ); + } else { + scrollableArea = ( + <div className={classNames('scrollable scrollable--flex', { fullscreen })} ref={this.setRef}> + {alwaysPrepend && prepend} + + <div className='empty-column-indicator'> + {emptyMessage} + </div> + </div> + ); + } + + if (trackScroll) { + return ( + <ScrollContainer scrollKey={scrollKey}> + {scrollableArea} + </ScrollContainer> + ); + } else { + return scrollableArea; + } + } + +} + +export default connect(mapStateToProps, null, null, { forwardRef: true })(ScrollableList); diff --git a/app/javascript/flavours/blobfox/components/server_banner.jsx b/app/javascript/flavours/blobfox/components/server_banner.jsx new file mode 100644 index 00000000000000..396ea67016b3c9 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/server_banner.jsx @@ -0,0 +1,97 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; + +import { Link } from 'react-router-dom'; + +import { connect } from 'react-redux'; + +import { fetchServer } from 'flavours/blobfox/actions/server'; +import { ServerHeroImage } from 'flavours/blobfox/components/server_hero_image'; +import { ShortNumber } from 'flavours/blobfox/components/short_number'; +import { Skeleton } from 'flavours/blobfox/components/skeleton'; +import Account from 'flavours/blobfox/containers/account_container'; +import { domain } from 'flavours/blobfox/initial_state'; + +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']), +}); + +class ServerBanner extends 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 ( + <div className='server-banner'> + <div className='server-banner__introduction'> + <FormattedMessage id='server_banner.introduction' defaultMessage='{domain} is part of the decentralized social network powered by {mastodon}.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank'>Mastodon</a> }} /> + </div> + + <ServerHeroImage blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} className='server-banner__hero' /> + + <div className='server-banner__description'> + {isLoading ? ( + <> + <Skeleton width='100%' /> + <br /> + <Skeleton width='100%' /> + <br /> + <Skeleton width='70%' /> + </> + ) : server.get('description')} + </div> + + <div className='server-banner__meta'> + <div className='server-banner__meta__column'> + <h4><FormattedMessage id='server_banner.administered_by' defaultMessage='Administered by:' /></h4> + + <Account id={server.getIn(['contact', 'account', 'id'])} size={36} minimal /> + </div> + + <div className='server-banner__meta__column'> + <h4><FormattedMessage id='server_banner.server_stats' defaultMessage='Server stats:' /></h4> + + {isLoading ? ( + <> + <strong className='server-banner__number'><Skeleton width='10ch' /></strong> + <br /> + <span className='server-banner__number-label'><Skeleton width='5ch' /></span> + </> + ) : ( + <> + <strong className='server-banner__number'><ShortNumber value={server.getIn(['usage', 'users', 'active_month'])} /></strong> + <br /> + <span className='server-banner__number-label' title={intl.formatMessage(messages.aboutActiveUsers)}><FormattedMessage id='server_banner.active_users' defaultMessage='active users' /></span> + </> + )} + </div> + </div> + + <hr className='spacer' /> + + <Link className='button button--block button-secondary' to='/about'><FormattedMessage id='server_banner.learn_more' defaultMessage='Learn more' /></Link> + </div> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(ServerBanner)); diff --git a/app/javascript/flavours/blobfox/components/server_hero_image.tsx b/app/javascript/flavours/blobfox/components/server_hero_image.tsx new file mode 100644 index 00000000000000..68b7f03df39744 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/server_hero_image.tsx @@ -0,0 +1,35 @@ +import { useCallback, useState } from 'react'; + +import classNames from 'classnames'; + +import { Blurhash } from './blurhash'; + +interface Props { + src: string; + srcSet?: string; + blurhash?: string; + className?: string; +} + +export const ServerHeroImage: React.FC<Props> = ({ + src, + srcSet, + blurhash, + className, +}) => { + const [loaded, setLoaded] = useState(false); + + const handleLoad = useCallback(() => { + setLoaded(true); + }, [setLoaded]); + + return ( + <div + className={classNames('image', { loaded }, className)} + role='presentation' + > + {blurhash && <Blurhash hash={blurhash} className='image__preview' />} + <img src={src} srcSet={srcSet} alt='' onLoad={handleLoad} /> + </div> + ); +}; diff --git a/app/javascript/flavours/blobfox/components/setting_text.jsx b/app/javascript/flavours/blobfox/components/setting_text.jsx new file mode 100644 index 00000000000000..79d4bf8ea315e2 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/setting_text.jsx @@ -0,0 +1,35 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + +export default class SettingText extends PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + settingPath: PropTypes.array.isRequired, + label: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + }; + + handleChange = (e) => { + this.props.onChange(this.props.settingPath, e.target.value); + }; + + render () { + const { settings, settingPath, label } = this.props; + + return ( + <label> + <span style={{ display: 'none' }}>{label}</span> + <input + className='setting-text' + value={settings.getIn(settingPath)} + onChange={this.handleChange} + placeholder={label} + /> + </label> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/components/short_number.tsx b/app/javascript/flavours/blobfox/components/short_number.tsx new file mode 100644 index 00000000000000..74c3c5d75e71cf --- /dev/null +++ b/app/javascript/flavours/blobfox/components/short_number.tsx @@ -0,0 +1,90 @@ +import { memo } from 'react'; + +import { FormattedMessage, FormattedNumber } from 'react-intl'; + +import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../utils/numbers'; + +type ShortNumberRenderer = ( + displayNumber: JSX.Element, + pluralReady: number, +) => JSX.Element; + +interface ShortNumberProps { + value: number; + renderer?: ShortNumberRenderer; + children?: ShortNumberRenderer; +} + +export const ShortNumberRenderer: React.FC<ShortNumberProps> = ({ + value, + renderer, + children, +}) => { + const shortNumber = toShortNumber(value); + const [, division] = shortNumber; + + if (children && renderer) { + 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.', + ); + } + + const customRenderer = children ?? renderer ?? null; + + const displayNumber = <ShortNumberCounter value={shortNumber} />; + + return ( + customRenderer?.(displayNumber, pluralReady(value, division)) ?? + displayNumber + ); +}; +export const ShortNumber = memo(ShortNumberRenderer); + +interface ShortNumberCounterProps { + value: number[]; +} +const ShortNumberCounter: React.FC<ShortNumberCounterProps> = ({ value }) => { + const [rawNumber, unit, maxFractionDigits = 0] = value; + + const count = ( + <FormattedNumber + value={rawNumber} + maximumFractionDigits={maxFractionDigits} + /> + ); + + const values = { count, rawNumber }; + + switch (unit) { + case DECIMAL_UNITS.THOUSAND: { + return ( + <FormattedMessage + id='units.short.thousand' + defaultMessage='{count}K' + values={values} + /> + ); + } + case DECIMAL_UNITS.MILLION: { + return ( + <FormattedMessage + id='units.short.million' + defaultMessage='{count}M' + values={values} + /> + ); + } + case DECIMAL_UNITS.BILLION: { + return ( + <FormattedMessage + id='units.short.billion' + defaultMessage='{count}B' + values={values} + /> + ); + } + // Not sure if we should go farther - @Sasha-Sorokin + default: + return count; + } +}; diff --git a/app/javascript/flavours/blobfox/components/skeleton.tsx b/app/javascript/flavours/blobfox/components/skeleton.tsx new file mode 100644 index 00000000000000..d6f1aed7238f87 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/skeleton.tsx @@ -0,0 +1,10 @@ +interface Props { + width?: number | string; + height?: number | string; +} + +export const Skeleton: React.FC<Props> = ({ width, height }) => ( + <span className='skeleton' style={{ width, height }}> + ‌ + </span> +); diff --git a/app/javascript/flavours/blobfox/components/status.jsx b/app/javascript/flavours/blobfox/components/status.jsx new file mode 100644 index 00000000000000..f4c4d0d5e387c8 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/status.jsx @@ -0,0 +1,879 @@ +import PropTypes from 'prop-types'; + +import { injectIntl, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { HotKeys } from 'react-hotkeys'; + +import PictureInPicturePlaceholder from 'flavours/blobfox/components/picture_in_picture_placeholder'; +import PollContainer from 'flavours/blobfox/containers/poll_container'; +import NotificationOverlayContainer from 'flavours/blobfox/features/notifications/containers/overlay_container'; +import { autoUnfoldCW } from 'flavours/blobfox/utils/content_warning'; +import { withOptionalRouter, WithOptionalRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +import Card from '../features/status/components/card'; +// 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'; +import { MediaGallery, Video, Audio } from '../features/ui/util/async-components'; +import { displayMedia, visibleReactions } from '../initial_state'; + +import AttachmentList from './attachment_list'; +import { getHashtagBarForStatus } from './hashtag_bar'; +import StatusActionBar from './status_action_bar'; +import StatusContent from './status_content'; +import StatusHeader from './status_header'; +import StatusIcons from './status_icons'; +import StatusPrepend from './status_prepend'; +import StatusReactions from './status_reactions'; + +const domParser = new DOMParser(); + +export const textForScreenReader = (intl, status, rebloggedByText = false, expanded = false) => { + const displayName = status.getIn(['account', 'display_name']); + + const spoilerText = status.getIn(['translation', 'spoiler_text']) || status.get('spoiler_text'); + const contentHtml = status.getIn(['translation', 'contentHtml']) || status.get('contentHtml'); + const contentText = domParser.parseFromString(contentHtml, 'text/html').documentElement.textContent; + + const values = [ + displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName, + spoilerText && !expanded ? spoilerText : contentText, + 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, settings) => { + if (!status) { + return undefined; + } + + if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { + status = status.get('reblog'); + } + + if (settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text')) { + return true; + } + + return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all'); +}; + +class Status extends ImmutablePureComponent { + + static contextTypes = { + identity: PropTypes.object, + }; + + static propTypes = { + containerId: PropTypes.string, + id: PropTypes.string, + status: ImmutablePropTypes.map, + account: ImmutablePropTypes.record, + previousId: PropTypes.string, + nextInReplyToId: PropTypes.string, + rootId: PropTypes.string, + onClick: PropTypes.func, + onReply: PropTypes.func, + onFavourite: PropTypes.func, + onReblog: PropTypes.func, + onBookmark: PropTypes.func, + onDelete: PropTypes.func, + onDirect: PropTypes.func, + onMention: PropTypes.func, + onReactionAdd: PropTypes.func, + onReactionRemove: 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, + onTranslate: PropTypes.func, + onInteractionModal: PropTypes.func, + muted: PropTypes.bool, + hidden: PropTypes.bool, + unread: PropTypes.bool, + prepend: PropTypes.string, + withDismiss: PropTypes.bool, + onMoveUp: PropTypes.func, + onMoveDown: PropTypes.func, + getScrollPosition: PropTypes.func, + updateScrollBottom: PropTypes.func, + expanded: PropTypes.bool, + intl: PropTypes.object.isRequired, + cacheMediaWidth: PropTypes.func, + cachedMediaWidth: PropTypes.number, + scrollKey: PropTypes.string, + deployPictureInPicture: PropTypes.func, + settings: ImmutablePropTypes.map.isRequired, + pictureInPicture: ImmutablePropTypes.contains({ + inUse: PropTypes.bool, + available: PropTypes.bool, + }), + ...WithOptionalRouterPropTypes, + }; + + state = { + isCollapsed: false, + autoCollapsed: false, + isExpanded: undefined, + showMedia: undefined, + statusId: undefined, + revealBehindCW: undefined, + showCard: false, + forceFilter: undefined, + }; + + // Avoid checking props that are functions (and whose equality will always + // evaluate to false. See react-immutable-pure-component for usage. + updateOnProps = [ + 'status', + 'account', + 'settings', + 'prepend', + 'muted', + 'notification', + 'hidden', + 'expanded', + 'unread', + 'pictureInPicture', + 'previousId', + 'nextInReplyToId', + 'rootId', + ]; + + updateOnStates = [ + 'isExpanded', + 'isCollapsed', + 'showMedia', + 'forceFilter', + ]; + + // If our settings have changed to disable collapsed statuses, then we + // need to make sure that we uncollapse every one. We do that by watching + // for changes to `settings.collapsed.enabled` in + // `getderivedStateFromProps()`. + + // We also need to watch for changes on the `collapse` prop---if this + // changes to anything other than `undefined`, then we need to collapse or + // uncollapse our status accordingly. + static getDerivedStateFromProps(nextProps, prevState) { + let update = {}; + let updated = false; + + // Make sure the state mirrors props we track… + if (nextProps.expanded !== prevState.expandedProp) { + update.expandedProp = nextProps.expanded; + updated = true; + } + if (nextProps.status?.get('hidden') !== prevState.statusPropHidden) { + update.statusPropHidden = nextProps.status?.get('hidden'); + updated = true; + } + + // Update state based on new props + if (!nextProps.settings.getIn(['collapsed', 'enabled'])) { + if (prevState.isCollapsed) { + update.isCollapsed = false; + updated = true; + } + } + + // Handle uncollapsing toots when the shared CW state is expanded + if (nextProps.settings.getIn(['content_warnings', 'shared_state']) && + nextProps.status?.get('spoiler_text')?.length && nextProps.status?.get('hidden') === false && + prevState.statusPropHidden !== false && prevState.isCollapsed + ) { + update.isCollapsed = false; + updated = true; + } + + // The “expanded” prop is used to one-off change the local state. + // It's used in the thread view when unfolding/re-folding all CWs at once. + if (nextProps.expanded !== prevState.expandedProp && + nextProps.expanded !== undefined + ) { + update.isExpanded = nextProps.expanded; + if (nextProps.expanded) update.isCollapsed = false; + updated = true; + } + + if (prevState.isExpanded === undefined && update.isExpanded === undefined) { + update.isExpanded = autoUnfoldCW(nextProps.settings, nextProps.status); + updated = true; + } + + if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) { + update.showMedia = defaultMediaVisibility(nextProps.status, nextProps.settings); + update.statusId = nextProps.status.get('id'); + updated = true; + } + + if (nextProps.settings.getIn(['media', 'reveal_behind_cw']) !== prevState.revealBehindCW) { + update.revealBehindCW = nextProps.settings.getIn(['media', 'reveal_behind_cw']); + if (update.revealBehindCW) { + update.showMedia = defaultMediaVisibility(nextProps.status, nextProps.settings); + } + updated = true; + } + + return updated ? update : null; + } + + // When mounting, we just check to see if our status should be collapsed, + // and collapse it if so. We don't need to worry about whether collapsing + // is enabled here, because `setCollapsed()` already takes that into + // account. + + // The cases where a status should be collapsed are: + // + // - The `collapse` prop has been set to `true` + // - The user has decided in local settings to collapse all statuses. + // - The user has decided to collapse all notifications ('muted' + // statuses). + // - The user has decided to collapse long statuses and the status is + // over the user set value (default 400 without media, or 610px with). + // - The status is a reply and the user has decided to collapse all + // replies. + // - The status contains media and the user has decided to collapse all + // statuses with media. + // - The status is a reblog the user has decided to collapse all + // statuses which are reblogs. + componentDidMount () { + const { node } = this; + const { + status, + settings, + collapse, + muted, + prepend, + } = this.props; + + // Prevent a crash when node is undefined. Not completely sure why this + // happens, might be because status === null. + if (node === undefined) return; + + const autoCollapseSettings = settings.getIn(['collapsed', 'auto']); + + // Don't autocollapse if CW state is shared and status is explicitly revealed, + // as it could cause surprising changes when receiving notifications + if (settings.getIn(['content_warnings', 'shared_state']) && status.get('spoiler_text').length && !status.get('hidden')) return; + + let autoCollapseHeight = parseInt(autoCollapseSettings.get('height')); + if (status.get('media_attachments').size && !muted) { + autoCollapseHeight += 210; + } + + if (collapse || + autoCollapseSettings.get('all') || + (autoCollapseSettings.get('notifications') && muted) || + (autoCollapseSettings.get('lengthy') && node.clientHeight > autoCollapseHeight) || + (autoCollapseSettings.get('reblogs') && prepend === 'reblogged_by') || + (autoCollapseSettings.get('replies') && status.get('in_reply_to_id', null) !== null) || + (autoCollapseSettings.get('media') && !(status.get('spoiler_text').length) && status.get('media_attachments').size > 0) + ) { + this.setCollapsed(true); + // Hack to fix timeline jumps on second rendering when auto-collapsing + this.setState({ autoCollapsed: true }); + } + + // Hack to fix timeline jumps when a preview card is fetched + this.setState({ + showCard: !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card') && this.props.settings.get('inline_preview_cards'), + }); + } + + // Hack to fix timeline jumps on second rendering when auto-collapsing + // or on subsequent rendering when a preview card has been fetched + getSnapshotBeforeUpdate() { + if (!this.props.getScrollPosition) return null; + + const { muted, hidden, status, settings } = this.props; + + const doShowCard = !muted && !hidden && status && status.get('card') && settings.get('inline_preview_cards'); + if (this.state.autoCollapsed || (doShowCard && !this.state.showCard)) { + if (doShowCard) this.setState({ showCard: true }); + if (this.state.autoCollapsed) this.setState({ autoCollapsed: false }); + return this.props.getScrollPosition(); + } else { + return null; + } + } + + componentDidUpdate(prevProps, prevState, snapshot) { + if (snapshot !== null && this.props.updateScrollBottom && this.node.offsetTop < snapshot.top) { + this.props.updateScrollBottom(snapshot.height - snapshot.top); + } + } + + componentWillUnmount() { + if (this.node && this.props.getScrollPosition) { + const position = this.props.getScrollPosition(); + if (position !== null && this.node.offsetTop < position.top) { + requestAnimationFrame(() => { + this.props.updateScrollBottom(position.height - position.top); + }); + } + } + } + + // `setCollapsed()` sets the value of `isCollapsed` in our state, that is, + // whether the toot is collapsed or not. + + // `setCollapsed()` automatically checks for us whether toot collapsing + // is enabled, so we don't have to. + setCollapsed = (value) => { + if (this.props.settings.getIn(['collapsed', 'enabled'])) { + if (value) { + this.setExpansion(false); + } + this.setState({ isCollapsed: value }); + } else { + this.setState({ isCollapsed: false }); + } + }; + + setExpansion = (value) => { + if (this.props.settings.getIn(['content_warnings', 'shared_state']) && this.props.status.get('hidden') === value) { + this.props.onToggleHidden(this.props.status); + } + + this.setState({ isExpanded: value }); + if (value) { + this.setCollapsed(false); + } + }; + + // `parseClick()` takes a click event and responds appropriately. + // If our status is collapsed, then clicking on it should uncollapse it. + // If `Shift` is held, then clicking on it should collapse it. + // Otherwise, we open the url handed to us in `destination`, if + // applicable. + parseClick = (e, destination) => { + const { status, history } = this.props; + const { isCollapsed } = this.state; + if (!history) return; + + if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) { + if (isCollapsed) this.setCollapsed(false); + else if (e.shiftKey) { + this.setCollapsed(true); + document.getSelection().removeAllRanges(); + } else if (this.props.onClick) { + this.props.onClick(); + return; + } else { + if (destination === undefined) { + destination = `/@${ + status.getIn(['reblog', 'account', 'acct'], status.getIn(['account', 'acct'])) + }/${ + status.getIn(['reblog', 'id'], status.get('id')) + }`; + } + history.push(destination); + } + e.preventDefault(); + } + }; + + handleToggleMediaVisibility = () => { + this.setState({ showMedia: !this.state.showMedia }); + }; + + handleExpandedToggle = () => { + if (this.props.settings.getIn(['content_warnings', 'shared_state'])) { + this.props.onToggleHidden(this.props.status); + } else if (this.props.status.get('spoiler_text')) { + this.setExpansion(!this.state.isExpanded); + } + }; + + handleOpenVideo = (options) => { + const { status } = this.props; + const lang = status.getIn(['translation', 'language']) || status.get('language'); + this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), lang, options); + }; + + handleOpenMedia = (media, index) => { + const { status } = this.props; + const lang = status.getIn(['translation', 'language']) || status.get('language'); + this.props.onOpenMedia(status.get('id'), media, index, lang); + }; + + handleHotkeyOpenMedia = e => { + const { status, onOpenMedia, onOpenVideo } = this.props; + const statusId = status.get('id'); + + e.preventDefault(); + + if (status.get('media_attachments').size > 0) { + const lang = status.getIn(['translation', 'language']) || status.get('language'); + if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + onOpenVideo(statusId, status.getIn(['media_attachments', 0]), lang, { startTime: 0 }); + } else { + onOpenMedia(statusId, status.get('media_attachments'), 0, lang); + } + } + }; + + handleDeployPictureInPicture = (type, mediaProps) => { + const { deployPictureInPicture, status } = this.props; + + deployPictureInPicture(status, type, mediaProps); + }; + + handleHotkeyReply = e => { + e.preventDefault(); + this.props.onReply(this.props.status, this.props.history); + }; + + handleHotkeyFavourite = (e) => { + this.props.onFavourite(this.props.status, e); + }; + + handleHotkeyBoost = e => { + this.props.onReblog(this.props.status, e); + }; + + handleHotkeyBookmark = e => { + this.props.onBookmark(this.props.status, e); + }; + + handleHotkeyMention = e => { + e.preventDefault(); + this.props.onMention(this.props.status.get('account'), this.props.history); + }; + + handleHotkeyOpen = () => { + const status = this.props.status; + this.props.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`); + }; + + handleHotkeyOpenProfile = () => { + this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`); + }; + + handleHotkeyMoveUp = e => { + this.props.onMoveUp(this.props.containerId || this.props.id, e.target.getAttribute('data-featured')); + }; + + handleHotkeyMoveDown = e => { + this.props.onMoveDown(this.props.containerId || this.props.id, e.target.getAttribute('data-featured')); + }; + + handleHotkeyCollapse = () => { + if (!this.props.settings.getIn(['collapsed', 'enabled'])) + return; + + this.setCollapsed(!this.state.isCollapsed); + }; + + handleHotkeyToggleSensitive = () => { + this.handleToggleMediaVisibility(); + }; + + handleUnfilterClick = e => { + this.setState({ forceFilter: false }); + e.preventDefault(); + }; + + handleFilterClick = () => { + this.setState({ forceFilter: true }); + }; + + handleRef = c => { + this.node = c; + }; + + handleTranslate = () => { + this.props.onTranslate(this.props.status); + }; + + renderLoadingMediaGallery () { + return <div className='media-gallery' style={{ height: '110px' }} />; + } + + renderLoadingVideoPlayer () { + return <div className='video-player' style={{ height: '110px' }} />; + } + + renderLoadingAudioPlayer () { + return <div className='audio-player' style={{ height: '110px' }} />; + } + + render () { + const { signedIn } = this.context.identity; + const { + handleRef, + parseClick, + setCollapsed, + } = this; + const { + intl, + status, + account, + settings, + collapsed, + muted, + intersectionObserverWrapper, + onOpenVideo, + onOpenMedia, + notification, + hidden, + unread, + featured, + pictureInPicture, + previousId, + nextInReplyToId, + rootId, + history, + ...other + } = this.props; + const { isCollapsed } = this.state; + let background = null; + let attachments = null; + + // Depending on user settings, some media are considered as parts of the + // contents (affected by CW) while other will be displayed outside of the + // CW. + let contentMedia = []; + let contentMediaIcons = []; + let extraMedia = []; + let extraMediaIcons = []; + let media = contentMedia; + let mediaIcons = contentMediaIcons; + + if (settings.getIn(['content_warnings', 'media_outside'])) { + media = extraMedia; + mediaIcons = extraMediaIcons; + } + + if (status === null) { + return null; + } + + const isExpanded = settings.getIn(['content_warnings', 'shared_state']) ? !status.get('hidden') : this.state.isExpanded; + + const handlers = { + 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.handleExpandedToggle, + bookmark: this.handleHotkeyBookmark, + toggleCollapse: this.handleHotkeyCollapse, + toggleSensitive: this.handleHotkeyToggleSensitive, + openMedia: this.handleHotkeyOpenMedia, + }; + + let prepend, rebloggedByText; + + if (hidden) { + return ( + <HotKeys handlers={handlers}> + <div ref={this.handleRef} className='status focusable' tabIndex={0}> + <span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span> + <span>{status.get('content')}</span> + </div> + </HotKeys> + ); + } + + const connectUp = previousId && previousId === status.get('in_reply_to_id'); + const connectToRoot = rootId && rootId === status.get('in_reply_to_id'); + const connectReply = nextInReplyToId && nextInReplyToId === status.get('id'); + 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 ( + <HotKeys handlers={minHandlers}> + <div className='status__wrapper status__wrapper--filtered focusable' tabIndex={0} ref={this.handleRef}> + <FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {matchedFilters.join(', ')}. + {' '} + <button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}> + <FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' /> + </button> + </div> + </HotKeys> + ); + } + + // If user backgrounds for collapsed statuses are enabled, then we + // initialize our background accordingly. This will only be rendered if + // the status is collapsed. + if (settings.getIn(['collapsed', 'backgrounds', 'user_backgrounds'])) { + background = status.getIn(['account', 'header']); + } + + // This handles our media attachments. + // If a media file is of unknwon type or if the status is muted + // (notification), we show a list of links instead of embedded media. + + // After we have generated our appropriate media element and stored it in + // `media`, we snatch the thumbnail to use as our `background` if media + // backgrounds for collapsed statuses are enabled. + + attachments = status.get('media_attachments'); + + if (pictureInPicture.get('inUse')) { + media.push(<PictureInPicturePlaceholder />); + mediaIcons.push('video-camera'); + } else if (attachments.size > 0) { + const language = status.getIn(['translation', 'language']) || status.get('language'); + + if (muted || attachments.some(item => item.get('type') === 'unknown')) { + media.push( + <AttachmentList + compact + media={status.get('media_attachments')} + />, + ); + } else if (attachments.getIn([0, 'type']) === 'audio') { + const attachment = status.getIn(['media_attachments', 0]); + const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); + + media.push( + <Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} > + {Component => ( + <Component + src={attachment.get('url')} + alt={description} + lang={language} + poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])} + backgroundColor={attachment.getIn(['meta', 'colors', 'background'])} + foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])} + accentColor={attachment.getIn(['meta', 'colors', 'accent'])} + duration={attachment.getIn(['meta', 'original', 'duration'], 0)} + width={this.props.cachedMediaWidth} + height={110} + cacheWidth={this.props.cacheMediaWidth} + deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined} + sensitive={status.get('sensitive')} + blurhash={attachment.get('blurhash')} + visible={this.state.showMedia} + onToggleVisibility={this.handleToggleMediaVisibility} + /> + )} + </Bundle>, + ); + mediaIcons.push('music'); + } else if (attachments.getIn([0, 'type']) === 'video') { + const attachment = status.getIn(['media_attachments', 0]); + const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); + + media.push( + <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} > + {Component => (<Component + preview={attachment.get('preview_url')} + frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])} + blurhash={attachment.get('blurhash')} + src={attachment.get('url')} + alt={description} + lang={language} + inline + sensitive={status.get('sensitive')} + letterbox={settings.getIn(['media', 'letterbox'])} + fullwidth={!rootId && settings.getIn(['media', 'fullwidth'])} + preventPlayback={isCollapsed || !isExpanded} + onOpenVideo={this.handleOpenVideo} + deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined} + visible={this.state.showMedia} + onToggleVisibility={this.handleToggleMediaVisibility} + />)} + </Bundle>, + ); + mediaIcons.push('video-camera'); + } else { // Media type is 'image' or 'gifv' + media.push( + <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}> + {Component => ( + <Component + media={attachments} + lang={language} + sensitive={status.get('sensitive')} + letterbox={settings.getIn(['media', 'letterbox'])} + fullwidth={!rootId && settings.getIn(['media', 'fullwidth'])} + hidden={isCollapsed || !isExpanded} + onOpenMedia={this.handleOpenMedia} + cacheWidth={this.props.cacheMediaWidth} + defaultWidth={this.props.cachedMediaWidth} + visible={this.state.showMedia} + onToggleVisibility={this.handleToggleMediaVisibility} + /> + )} + </Bundle>, + ); + mediaIcons.push('picture-o'); + } + + if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) { + background = attachments.getIn([0, 'preview_url']); + } + } else if (status.get('card') && settings.get('inline_preview_cards') && !this.props.muted) { + media.push( + <Card + onOpenMedia={this.handleOpenMedia} + card={status.get('card')} + compact + sensitive={status.get('sensitive')} + />, + ); + mediaIcons.push('link'); + } + + if (status.get('poll')) { + const language = status.getIn(['translation', 'language']) || status.get('language'); + contentMedia.push(<PollContainer pollId={status.get('poll')} lang={language} />); + contentMediaIcons.push('tasks'); + } + + // Here we prepare extra data-* attributes for CSS selectors. + // Users can use those for theming, hiding avatars etc via UserStyle + const selectorAttribs = { + 'data-status-by': `@${status.getIn(['account', 'acct'])}`, + }; + + if (this.props.prepend && account) { + const notifKind = { + favourite: 'favourited', + reaction: 'reacted', + reblog: 'boosted', + reblogged_by: 'boosted', + status: 'posted', + }[this.props.prepend]; + + selectorAttribs[`data-${notifKind}-by`] = `@${account.get('acct')}`; + + prepend = ( + <StatusPrepend + type={this.props.prepend} + account={account} + parseClick={parseClick} + notificationId={this.props.notificationId} + /> + ); + } + + if (this.props.prepend === 'reblog') { + rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} boosted' }, { name: account.get('acct') }); + } + + const computedClass = classNames('status', `status-${status.get('visibility')}`, { + collapsed: isCollapsed, + 'has-background': isCollapsed && background, + 'status__wrapper-reply': !!status.get('in_reply_to_id'), + 'status--in-thread': !!rootId, + 'status--first-in-thread': previousId && (!connectUp || connectToRoot), + unread, + muted, + }, 'focusable'); + + const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); + contentMedia.push(hashtagBar); + + return ( + <HotKeys handlers={handlers}> + <div + className={computedClass} + style={isCollapsed && background ? { backgroundImage: `url(${background})` } : null} + {...selectorAttribs} + ref={handleRef} + tabIndex={0} + data-featured={featured ? 'true' : null} + aria-label={textForScreenReader(intl, status, rebloggedByText, !status.get('hidden'))} + data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined} + > + {!muted && prepend} + + {(connectReply || connectUp || connectToRoot) && <div className={classNames('status__line', { 'status__line--full': connectReply, 'status__line--first': !status.get('in_reply_to_id') && !connectToRoot })} />} + + <header className='status__info'> + <span> + {muted && prepend} + {!muted || !isCollapsed ? ( + <StatusHeader + status={status} + friend={account} + collapsed={isCollapsed} + parseClick={parseClick} + /> + ) : null} + </span> + <StatusIcons + status={status} + mediaIcons={contentMediaIcons.concat(extraMediaIcons)} + collapsible={settings.getIn(['collapsed', 'enabled'])} + collapsed={isCollapsed} + setCollapsed={setCollapsed} + settings={settings.get('status_icons')} + /> + </header> + <StatusContent + status={status} + media={contentMedia} + extraMedia={extraMedia} + mediaIcons={contentMediaIcons} + expanded={isExpanded} + onExpandedToggle={this.handleExpandedToggle} + onTranslate={this.handleTranslate} + parseClick={parseClick} + disabled={!history} + tagLinks={settings.get('tag_misleading_links')} + rewriteMentions={settings.get('rewrite_mentions')} + {...statusContentProps} + /> + + <StatusReactions + statusId={status.get('id')} + reactions={status.get('reactions')} + numVisible={visibleReactions} + addReaction={this.props.onReactionAdd} + removeReaction={this.props.onReactionRemove} + canReact={this.context.identity.signedIn} + /> + + {!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? ( + <StatusActionBar + status={status} + account={status.get('account')} + showReplyCount={settings.get('show_reply_count')} + onFilter={matchedFilters ? this.handleFilterClick : null} + {...other} + /> + ) : null} + {notification ? ( + <NotificationOverlayContainer + notification={notification} + /> + ) : null} + </div> + </HotKeys> + ); + } + +} + +export default withOptionalRouter(injectIntl(Status)); diff --git a/app/javascript/flavours/blobfox/components/status_action_bar.jsx b/app/javascript/flavours/blobfox/components/status_action_bar.jsx new file mode 100644 index 00000000000000..e5f79c84ca3a90 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/status_action_bar.jsx @@ -0,0 +1,371 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import classNames from 'classnames'; +import { withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/blobfox/permissions'; +import { accountAdminLink, statusAdminLink } from 'flavours/blobfox/utils/backend_links'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +import DropdownMenuContainer from '../containers/dropdown_menu_container'; +import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container'; +import { me, maxReactions } from '../initial_state'; + +import { IconButton } from './icon_button'; +import { RelativeTimestamp } from './relative_timestamp'; + +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: 'Privately mention @{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: 'Favorite' }, + react: { id: 'status.react', defaultMessage: 'React' }, + bookmark: { id: 'status.bookmark', defaultMessage: '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' }, + edited: { id: 'status.edited', defaultMessage: 'Edited {date}' }, + filter: { id: 'status.filter', defaultMessage: 'Filter this post' }, + openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' }, +}); + +class StatusActionBar extends ImmutablePureComponent { + + static contextTypes = { + identity: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + onReply: PropTypes.func, + onFavourite: PropTypes.func, + onReactionAdd: PropTypes.func, + onReblog: PropTypes.func, + onDelete: PropTypes.func, + onDirect: PropTypes.func, + onMention: PropTypes.func, + onMute: PropTypes.func, + onBlock: 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, + showReplyCount: PropTypes.bool, + scrollKey: PropTypes.string, + intl: PropTypes.object.isRequired, + ...WithRouterPropTypes, + }; + + // Avoid checking props that are functions (and whose equality will always + // evaluate to false. See react-immutable-pure-component for usage. + updateOnProps = [ + 'status', + 'showReplyCount', + 'withCounters', + 'withDismiss', + ]; + + handleReplyClick = () => { + const { signedIn } = this.context.identity; + + if (signedIn) { + this.props.onReply(this.props.status, this.props.history); + } else { + this.props.onInteractionModal('reply', this.props.status); + } + }; + + handleShareClick = () => { + navigator.share({ + url: this.props.status.get('url'), + }); + }; + + handleFavouriteClick = (e) => { + const { signedIn } = this.context.identity; + + if (signedIn) { + this.props.onFavourite(this.props.status, e); + } else { + this.props.onInteractionModal('favourite', this.props.status); + } + }; + + handleEmojiPick = data => { + this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, ''), data.imageUrl); + }; + + 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 = (e) => { + this.props.onBookmark(this.props.status, e); + }; + + handleDeleteClick = () => { + this.props.onDelete(this.props.status, this.props.history); + }; + + handleRedraftClick = () => { + this.props.onDelete(this.props.status, this.props.history, true); + }; + + handleEditClick = () => { + this.props.onEdit(this.props.status, this.props.history); + }; + + handlePinClick = () => { + this.props.onPin(this.props.status); + }; + + handleMentionClick = () => { + this.props.onMention(this.props.status.get('account'), this.props.history); + }; + + handleDirectClick = () => { + this.props.onDirect(this.props.status.get('account'), this.props.history); + }; + + handleMuteClick = () => { + this.props.onMute(this.props.status.get('account')); + }; + + handleBlockClick = () => { + this.props.onBlock(this.props.status); + }; + + handleOpen = () => { + this.props.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); + }; + + handleCopy = () => { + const url = this.props.status.get('url'); + navigator.clipboard.writeText(url); + }; + + handleHideClick = () => { + this.props.onFilter(); + }; + + handleFilterClick = () => { + this.props.onAddFilter(this.props.status); + }; + + handleNoOp = () => {}; // hack for reaction add button + + render () { + const { status, intl, withDismiss, withCounters, showReplyCount, scrollKey } = this.props; + const { permissions, signedIn } = this.context.identity; + + const mutingConversation = status.get('muted'); + const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); + const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility')); + const writtenByMe = status.getIn(['account', 'id']) === me; + const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']); + + let menu = []; + let reblogIcon = 'retweet'; + let replyIcon; + let replyTitle; + + 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 && 'share' in navigator) { + menu.push({ text: intl.formatMessage(messages.share), action: this.handleShareClick }); + } + + if (publicStatus && (signedIn || !isRemote)) { + menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); + } + + if (signedIn) { + menu.push(null); + + 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, dangerous: true }); + menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick, dangerous: true }); + } else { + menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); + menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick }); + menu.push(null); + + if (!this.props.onFilter) { + menu.push({ text: intl.formatMessage(messages.filter), action: this.handleFilterClick, dangerous: true }); + menu.push(null); + } + + menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick, dangerous: true }); + menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick, dangerous: true }); + menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport, dangerous: true }); + + if (((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && (accountAdminLink || statusAdminLink)) || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) { + menu.push(null); + if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) { + if (accountAdminLink !== undefined) { + menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: accountAdminLink(status.getIn(['account', 'id'])) }); + } + if (statusAdminLink !== undefined) { + menu.push({ text: intl.formatMessage(messages.admin_status), href: statusAdminLink(status.getIn(['account', 'id']), status.get('id')) }); + } + } + if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) { + const domain = status.getIn(['account', 'acct']).split('@')[1]; + menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` }); + } + } + } + } + + 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 filterButton = this.props.onFilter && ( + <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleHideClick} /> + ); + + const canReact = permissions && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions; + const reactButton = ( + <IconButton + className='status__action-bar-button' + onClick={this.handleNoOp} // EmojiPickerDropdown handles that + title={intl.formatMessage(messages.react)} + disabled={!canReact} + icon='plus' + /> + ); + + return ( + <div className='status__action-bar'> + <IconButton + className='status__action-bar-button' + title={replyTitle} + icon={replyIcon} + onClick={this.handleReplyClick} + counter={showReplyCount ? status.get('replies_count') : undefined} + obfuscateCount + /> + <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon={reblogIcon} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} /> + <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} /> + { + permissions + ? <EmojiPickerDropdown className='status__action-bar-button' onPickEmoji={this.handleEmojiPick} button={reactButton} disabled={!canReact} /> + : reactButton + } + <IconButton className='status__action-bar-button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /> + { + permissions + ? <EmojiPickerDropdown className='status__action-bar-button' onPickEmoji={this.handleEmojiPick} button={reactButton} disabled={!canReact} /> + : reactButton + } + + {filterButton} + + <div className='status__action-bar-dropdown'> + <DropdownMenuContainer + scrollKey={scrollKey} + status={status} + items={menu} + icon='ellipsis-h' + size={18} + direction='right' + ariaLabel={intl.formatMessage(messages.more)} + /> + </div> + + <div className='status__action-bar-spacer' /> + <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'> + <RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>} + </a> + </div> + ); + } + +} + +export default withRouter(injectIntl(StatusActionBar)); diff --git a/app/javascript/flavours/blobfox/components/status_content.jsx b/app/javascript/flavours/blobfox/components/status_content.jsx new file mode 100644 index 00000000000000..85df1b58385828 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/status_content.jsx @@ -0,0 +1,491 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage, injectIntl } from 'react-intl'; + +import classnames from 'classnames'; +import { withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +import { Icon } from 'flavours/blobfox/components/icon'; +import { autoPlayGif, languages as preloadedLanguages } from 'flavours/blobfox/initial_state'; +import { decode as decodeIDNA } from 'flavours/blobfox/utils/idna'; + +import Permalink from './permalink'; + +const textMatchesTarget = (text, origin, host) => { + return (text === origin || text === host + || text.startsWith(origin + '/') || text.startsWith(host + '/') + || 'www.' + text === host || ('www.' + text).startsWith(host + '/')); +}; + +const isLinkMisleading = (link) => { + let linkTextParts = []; + + // Reconstruct visible text, as we do not have much control over how links + // from remote software look, and we can't rely on `innerText` because the + // `invisible` class does not set `display` to `none`. + + const walk = (node) => { + switch (node.nodeType) { + case Node.TEXT_NODE: + linkTextParts.push(node.textContent); + break; + case Node.ELEMENT_NODE: + if (node.classList.contains('invisible')) return; + const children = node.childNodes; + for (let i = 0; i < children.length; i++) { + walk(children[i]); + } + break; + } + }; + + walk(link); + + const linkText = linkTextParts.join(''); + const targetURL = new URL(link.href); + + if (targetURL.protocol === 'magnet:') { + return !linkText.startsWith('magnet:'); + } + + if (targetURL.protocol === 'xmpp:') { + return !(linkText === targetURL.href || 'xmpp:' + linkText === targetURL.href); + } + + // The following may not work with international domain names + if (textMatchesTarget(linkText, targetURL.origin, targetURL.host) || textMatchesTarget(linkText.toLowerCase(), targetURL.origin, targetURL.host)) { + return false; + } + + // The link hasn't been recognized, maybe it features an international domain name + const hostname = decodeIDNA(targetURL.hostname).normalize('NFKC'); + const host = targetURL.host.replace(targetURL.hostname, hostname); + const origin = targetURL.origin.replace(targetURL.host, host); + const text = linkText.normalize('NFKC'); + return !(textMatchesTarget(text, origin, host) || textMatchesTarget(text.toLowerCase(), origin, host)); +}; + +/** + * + * @param {any} status + * @returns {string} + */ +export function getStatusContent(status) { + return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml'); +} + +class TranslateButton extends 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 ( + <div className='translate-button'> + <div className='translate-button__meta'> + <FormattedMessage id='status.translated_from_with' defaultMessage='Translated from {lang} using {provider}' values={{ lang: languageName, provider }} /> + </div> + + <button className='link-button' onClick={onClick}> + <FormattedMessage id='status.show_original' defaultMessage='Show original' /> + </button> + </div> + ); + } + + return ( + <button className='status__content__translate-button' onClick={onClick}> + <FormattedMessage id='status.translate' defaultMessage='Translate' /> + </button> + ); + } + +} + +const mapStateToProps = state => ({ + languages: state.getIn(['server', 'translationLanguages', 'items']), +}); + +class StatusContent extends PureComponent { + + static contextTypes = { + identity: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + statusContent: PropTypes.string, + expanded: PropTypes.bool, + collapsed: PropTypes.bool, + onExpandedToggle: PropTypes.func, + onTranslate: PropTypes.func, + media: PropTypes.node, + extraMedia: PropTypes.node, + mediaIcons: PropTypes.arrayOf(PropTypes.string), + parseClick: PropTypes.func, + disabled: PropTypes.bool, + onUpdate: PropTypes.func, + tagLinks: PropTypes.bool, + rewriteMentions: PropTypes.string, + languages: ImmutablePropTypes.map, + intl: PropTypes.object, + // from react-router + match: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + history: PropTypes.object.isRequired + }; + + static defaultProps = { + tagLinks: true, + rewriteMentions: 'no', + }; + + state = { + hidden: true, + }; + + _updateStatusLinks () { + const node = this.contentsNode; + const { tagLinks, rewriteMentions } = this.props; + + 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.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')}`); + if (rewriteMentions !== 'no') { + while (link.firstChild) link.removeChild(link.firstChild); + link.appendChild(document.createTextNode('@')); + const acctSpan = document.createElement('span'); + acctSpan.textContent = rewriteMentions === 'acct' ? mention.get('acct') : mention.get('username'); + link.appendChild(acctSpan); + } + } 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 { + link.addEventListener('click', this.onLinkClick.bind(this), false); + link.setAttribute('title', link.href); + link.classList.add('unhandled-link'); + + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener nofollow noreferrer'); + + try { + if (tagLinks && isLinkMisleading(link)) { + // Add a tag besides the link to display its origin + + const url = new URL(link.href); + const tag = document.createElement('span'); + tag.classList.add('link-origin-tag'); + switch (url.protocol) { + case 'xmpp:': + tag.textContent = `[${url.href}]`; + break; + case 'magnet:': + tag.textContent = '(magnet)'; + break; + default: + tag.textContent = `[${url.host}]`; + } + link.insertAdjacentText('beforeend', ' '); + link.insertAdjacentElement('beforeend', tag); + } + } catch (e) { + // The URL is invalid, remove the href just to be safe + if (tagLinks && e instanceof TypeError) link.removeAttribute('href'); + } + } + } + } + + 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(); + if (this.props.onUpdate) this.props.onUpdate(); + } + + onLinkClick = (e) => { + if (this.props.collapsed) { + if (this.props.parseClick) this.props.parseClick(e); + } + }; + + onMentionClick = (mention, e) => { + if (this.props.parseClick) { + this.props.parseClick(e, `/@${mention.get('acct')}`); + } + }; + + onHashtagClick = (hashtag, e) => { + hashtag = hashtag.replace(/^#/, ''); + + if (this.props.parseClick) { + this.props.parseClick(e, `/tags/${hashtag}`); + } + }; + + handleMouseDown = (e) => { + this.startXY = [e.clientX, e.clientY]; + }; + + handleMouseUp = (e) => { + const { parseClick, disabled } = this.props; + + if (disabled || !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 !== e.currentTarget) { + if (['button', 'video', 'a', 'label', 'canvas'].includes(element.localName) || element.getAttribute('role') === 'button') { + return; + } + element = element.parentNode; + } + + if (deltaX + deltaY < 5 && e.button === 0 && parseClick) { + parseClick(e); + } + + this.startXY = null; + }; + + handleSpoilerClick = (e) => { + e.preventDefault(); + + if (this.props.onExpandedToggle) { + this.props.onExpandedToggle(); + } else { + this.setState({ hidden: !this.state.hidden }); + } + }; + + handleTranslate = () => { + this.props.onTranslate(); + }; + + setContentsRef = (c) => { + this.contentsNode = c; + }; + + render () { + const { + status, + media, + extraMedia, + mediaIcons, + parseClick, + disabled, + tagLinks, + rewriteMentions, + intl, + statusContent, + } = this.props; + + const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; + const contentLocale = intl.locale.replace(/[_-].*/, ''); + const targetLanguages = this.props.languages?.get(status.get('language') || 'und'); + const renderTranslate = this.props.onTranslate && this.context.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale); + + const content = { __html: statusContent ?? getStatusContent(status) }; + const spoilerContent = { __html: status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml') }; + const language = status.getIn(['translation', 'language']) || status.get('language'); + const classNames = classnames('status__content', { + 'status__content--with-action': parseClick && !disabled, + 'status__content--with-spoiler': status.get('spoiler_text').length > 0, + }); + + const translateButton = renderTranslate && ( + <TranslateButton onClick={this.handleTranslate} translation={status.get('translation')} /> + ); + + if (status.get('spoiler_text').length > 0) { + let mentionsPlaceholder = ''; + + const mentionLinks = status.get('mentions').map(item => ( + <Permalink + to={`/@${item.get('acct')}`} + href={item.get('url')} + key={item.get('id')} + className='mention' + > + @<span>{item.get('username')}</span> + </Permalink> + )).reduce((aggregate, item) => [...aggregate, item, ' '], []); + + let toggleText = null; + if (hidden) { + toggleText = [ + <FormattedMessage + id='status.show_more' + defaultMessage='Show more' + key='0' + />, + ]; + if (mediaIcons) { + mediaIcons.forEach((mediaIcon, idx) => { + toggleText.push( + <Icon + fixedWidth + className='status__content__spoiler-icon' + id={mediaIcon} + aria-hidden='true' + key={`icon-${idx}`} + />, + ); + }); + } + } else { + toggleText = ( + <FormattedMessage + id='status.show_less' + defaultMessage='Show less' + key='0' + /> + ); + } + + if (hidden) { + mentionsPlaceholder = <div>{mentionLinks}</div>; + } + + return ( + <div className={classNames} tabIndex={0} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> + <p + style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }} + > + <span dangerouslySetInnerHTML={spoilerContent} className='translate' lang={language} /> + {' '} + <button type='button' className='status__content__spoiler-link' onClick={this.handleSpoilerClick} aria-expanded={!hidden}> + {toggleText} + </button> + </p> + + {mentionsPlaceholder} + + <div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}> + <div + ref={this.setContentsRef} + key={`contents-${tagLinks}`} + tabIndex={!hidden ? 0 : null} + dangerouslySetInnerHTML={content} + className='status__content__text translate' + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + lang={language} + /> + {!hidden && translateButton} + {media} + </div> + + {extraMedia} + </div> + ); + } else if (parseClick) { + return ( + <div + className={classNames} + onMouseDown={this.handleMouseDown} + onMouseUp={this.handleMouseUp} + tabIndex={0} + > + <div + ref={this.setContentsRef} + key={`contents-${tagLinks}-${rewriteMentions}`} + dangerouslySetInnerHTML={content} + className='status__content__text translate' + tabIndex={0} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + lang={language} + /> + {translateButton} + {media} + {extraMedia} + </div> + ); + } else { + return ( + <div + className='status__content' + tabIndex={0} + > + <div + ref={this.setContentsRef} + key={`contents-${tagLinks}`} + className='status__content__text translate' + dangerouslySetInnerHTML={content} + tabIndex={0} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + lang={language} + /> + {translateButton} + {media} + {extraMedia} + </div> + ); + } + } + +} + +export default withRouter(connect(mapStateToProps)(injectIntl(StatusContent))); diff --git a/app/javascript/flavours/blobfox/components/status_header.jsx b/app/javascript/flavours/blobfox/components/status_header.jsx new file mode 100644 index 00000000000000..1c51707cefaa9c --- /dev/null +++ b/app/javascript/flavours/blobfox/components/status_header.jsx @@ -0,0 +1,71 @@ +// Package imports. +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + +// Mastodon imports. +import { Avatar } from './avatar'; +import { AvatarOverlay } from './avatar_overlay'; +import { DisplayName } from './display_name'; + +export default class StatusHeader extends PureComponent { + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + friend: ImmutablePropTypes.map, + parseClick: PropTypes.func.isRequired, + }; + + // Handles clicks on account name/image + handleClick = (acct, e) => { + const { parseClick } = this.props; + parseClick(e, `/@${acct}`); + }; + + handleAccountClick = (e) => { + const { status } = this.props; + this.handleClick(status.getIn(['account', 'acct']), e); + }; + + // Rendering. + render () { + const { + status, + friend, + } = this.props; + + const account = status.get('account'); + + let statusAvatar; + if (friend === undefined || friend === null) { + statusAvatar = <Avatar account={account} size={46} />; + } else { + statusAvatar = <AvatarOverlay account={account} friend={friend} />; + } + + return ( + <div className='status__info__account'> + <a + href={account.get('url')} + target='_blank' + className='status__avatar' + onClick={this.handleAccountClick} + rel='noopener noreferrer' + > + {statusAvatar} + </a> + <a + href={account.get('url')} + target='_blank' + className='status__display-name' + onClick={this.handleAccountClick} + rel='noopener noreferrer' + > + <DisplayName account={account} /> + </a> + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/components/status_icons.jsx b/app/javascript/flavours/blobfox/components/status_icons.jsx new file mode 100644 index 00000000000000..34b4ad6e5885a4 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/status_icons.jsx @@ -0,0 +1,147 @@ +// Package imports. +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + + +// Mastodon imports. +import { Icon } from 'flavours/blobfox/components/icon'; +import { languages } from 'flavours/blobfox/initial_state'; + +import { IconButton } from './icon_button'; +import VisibilityIcon from './status_visibility_icon'; + +// Messages for use with internationalization stuff. +const messages = defineMessages({ + collapse: { id: 'status.collapse', defaultMessage: 'Collapse' }, + uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' }, + inReplyTo: { id: 'status.in_reply_to', defaultMessage: 'This toot is a reply' }, + previewCard: { id: 'status.has_preview_card', defaultMessage: 'Features an attached preview card' }, + pictures: { id: 'status.has_pictures', defaultMessage: 'Features attached pictures' }, + poll: { id: 'status.is_poll', defaultMessage: 'This toot is a poll' }, + video: { id: 'status.has_video', defaultMessage: 'Features attached videos' }, + audio: { id: 'status.has_audio', defaultMessage: 'Features attached audio files' }, + localOnly: { id: 'status.local_only', defaultMessage: 'Only visible from your instance' }, +}); + +const LanguageIcon = ({ language }) => { + if (!languages) return null; + + const lang = languages.find((lang) => lang[0] === language); + if (!lang) return null; + + return ( + <span className='text-icon' title={`${lang[2]} (${lang[1]})`} aria-hidden='true'> + {lang[0].toUpperCase()} + </span> + ); +}; + +LanguageIcon.propTypes = { + language: PropTypes.string.isRequired, +}; + +class StatusIcons extends PureComponent { + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + mediaIcons: PropTypes.arrayOf(PropTypes.string), + collapsible: PropTypes.bool, + collapsed: PropTypes.bool, + setCollapsed: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + settings: ImmutablePropTypes.map.isRequired, + }; + + // Handles clicks on collapsed button + handleCollapsedClick = (e) => { + const { collapsed, setCollapsed } = this.props; + if (e.button === 0) { + setCollapsed(!collapsed); + e.preventDefault(); + } + }; + + mediaIconTitleText (mediaIcon) { + const { intl } = this.props; + + const message = { + 'link': messages.previewCard, + 'picture-o': messages.pictures, + 'tasks': messages.poll, + 'video-camera': messages.video, + 'music': messages.audio, + }[mediaIcon]; + + return message && intl.formatMessage(message); + } + + renderIcon (mediaIcon) { + return ( + <Icon + fixedWidth + className='status__media-icon' + key={`media-icon--${mediaIcon}`} + id={mediaIcon} + aria-hidden='true' + title={this.mediaIconTitleText(mediaIcon)} + /> + ); + } + + // Rendering. + render () { + const { + status, + mediaIcons, + collapsible, + collapsed, + settings, + intl, + } = this.props; + + return ( + <div className='status__info__icons'> + {settings.get('language') && status.get('language') && <LanguageIcon language={status.get('language')} />} + {settings.get('reply') && status.get('in_reply_to_id', null) !== null ? ( + <Icon + className='status__reply-icon' + fixedWidth + id='comment' + aria-hidden='true' + title={intl.formatMessage(messages.inReplyTo)} + /> + ) : null} + {settings.get('local_only') && status.get('local_only') && + <Icon + fixedWidth + id='home' + aria-hidden='true' + title={intl.formatMessage(messages.localOnly)} + />} + {settings.get('media') && !!mediaIcons && mediaIcons.map(icon => this.renderIcon(icon))} + {settings.get('visibility') && <VisibilityIcon visibility={status.get('visibility')} />} + {collapsible && ( + <IconButton + className='status__collapse-button' + animate + active={collapsed} + title={ + collapsed ? + intl.formatMessage(messages.uncollapse) : + intl.formatMessage(messages.collapse) + } + icon='angle-double-up' + onClick={this.handleCollapsedClick} + /> + )} + </div> + ); + } + +} + +export default injectIntl(StatusIcons); diff --git a/app/javascript/flavours/blobfox/components/status_list.jsx b/app/javascript/flavours/blobfox/components/status_list.jsx new file mode 100644 index 00000000000000..3f103742f23037 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/status_list.jsx @@ -0,0 +1,137 @@ +import PropTypes from 'prop-types'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { debounce } from 'lodash'; + +import RegenerationIndicator from 'flavours/blobfox/components/regeneration_indicator'; + +import StatusContainer from '../containers/status_container'; + +import { LoadGap } from './load_gap'; +import ScrollableList from './scrollable_list'; + +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.isRequired, + lastId: PropTypes.string, + regex: 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(() => { + const { statusIds, lastId, onLoadMore } = this.props; + onLoadMore(lastId || (statusIds.size > 0 ? 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 <RegenerationIndicator />; + } + + let scrollableContent = (isLoading || statusIds.size > 0) ? ( + statusIds.map((statusId, index) => statusId === null ? ( + <LoadGap + key={'gap:' + statusIds.get(index + 1)} + disabled={isLoading} + maxId={index > 0 ? statusIds.get(index - 1) : null} + onClick={onLoadMore} + /> + ) : ( + <StatusContainer + key={statusId} + id={statusId} + onMoveUp={this.handleMoveUp} + onMoveDown={this.handleMoveDown} + contextType={timelineId} + scrollKey={this.props.scrollKey} + withCounters={this.props.withCounters} + /> + )) + ) : null; + + if (scrollableContent && featuredStatusIds) { + scrollableContent = featuredStatusIds.map(statusId => ( + <StatusContainer + key={`f-${statusId}`} + id={statusId} + featured + onMoveUp={this.handleMoveUp} + onMoveDown={this.handleMoveDown} + contextType={timelineId} + scrollKey={this.props.scrollKey} + withCounters={this.props.withCounters} + /> + )).concat(scrollableContent); + } + + return ( + <ScrollableList {...other} showLoading={isLoading && statusIds.size === 0} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}> + {scrollableContent} + </ScrollableList> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/components/status_prepend.jsx b/app/javascript/flavours/blobfox/components/status_prepend.jsx new file mode 100644 index 00000000000000..017ca66a19bae7 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/status_prepend.jsx @@ -0,0 +1,158 @@ +// Package imports // +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + +import { Icon } from 'flavours/blobfox/components/icon'; +import { me } from 'flavours/blobfox/initial_state'; + +export default class StatusPrepend extends PureComponent { + + static propTypes = { + type: PropTypes.string.isRequired, + account: ImmutablePropTypes.map.isRequired, + parseClick: PropTypes.func.isRequired, + notificationId: PropTypes.number, + }; + + handleClick = (e) => { + const { account, parseClick } = this.props; + parseClick(e, `/@${account.get('acct')}`); + }; + + Message = () => { + const { type, account } = this.props; + let link = ( + <a + onClick={this.handleClick} + href={account.get('url')} + className='status__display-name' + > + <b + dangerouslySetInnerHTML={{ + __html : account.get('display_name_html') || account.get('username'), + }} + /> + </a> + ); + switch (type) { + case 'featured': + return ( + <FormattedMessage id='status.pinned' defaultMessage='Pinned post' /> + ); + case 'reblogged_by': + return ( + <FormattedMessage + id='status.reblogged_by' + defaultMessage='{name} boosted' + values={{ name : link }} + /> + ); + case 'favourite': + return ( + <FormattedMessage + id='notification.favourite' + defaultMessage='{name} favorited your status' + values={{ name : link }} + /> + ); + case 'reaction': + return ( + <FormattedMessage + id='notification.reaction' + defaultMessage='{name} reacted to your status' + values={{ name: link }} + /> + ); + case 'reblog': + return ( + <FormattedMessage + id='notification.reblog' + defaultMessage='{name} boosted your status' + values={{ name : link }} + /> + ); + case 'status': + return ( + <FormattedMessage + id='notification.status' + defaultMessage='{name} just posted' + values={{ name: link }} + /> + ); + case 'poll': + if (me === account.get('id')) { + return ( + <FormattedMessage + id='notification.own_poll' + defaultMessage='Your poll has ended' + /> + ); + } else { + return ( + <FormattedMessage + id='notification.poll' + defaultMessage='A poll you have voted in has ended' + /> + ); + } + case 'update': + return ( + <FormattedMessage + id='notification.update' + defaultMessage='{name} edited a post' + values={{ name: link }} + /> + ); + } + return null; + }; + + render () { + const { Message } = this; + const { type } = this.props; + + let iconId; + + switch(type) { + case 'favourite': + iconId = 'star'; + break; + case 'reaction': + iconId = 'plus'; + break; + case 'featured': + iconId = 'thumb-tack'; + break; + case 'poll': + iconId = 'tasks'; + break; + case 'reblog': + case 'reblogged_by': + iconId = 'retweet'; + break; + case 'status': + iconId = 'bell'; + break; + case 'update': + iconId = 'pencil'; + break; + } + + return !type ? null : ( + <aside className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend' : 'notification__message'}> + <div className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend-icon-wrapper' : 'notification__favourite-icon-wrapper'}> + <Icon + className={`status__prepend-icon ${type === 'favourite' ? 'star-icon' : ''}`} + id={iconId} + /> + </div> + <Message /> + </aside> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/components/status_reactions.jsx b/app/javascript/flavours/blobfox/components/status_reactions.jsx new file mode 100644 index 00000000000000..81443d20555e14 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/status_reactions.jsx @@ -0,0 +1,175 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +import classNames from 'classnames'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import TransitionMotion from 'react-motion/lib/TransitionMotion'; +import spring from 'react-motion/lib/spring'; + +import { unicodeMapping } from '../features/emoji/emoji_unicode_mapping_light'; +import { autoPlayGif, reduceMotion } from '../initial_state'; +import { assetHost } from '../utils/config'; + +import { AnimatedNumber } from './animated_number'; + +export default class StatusReactions extends ImmutablePureComponent { + + static propTypes = { + statusId: PropTypes.string.isRequired, + reactions: ImmutablePropTypes.list.isRequired, + numVisible: PropTypes.number, + addReaction: PropTypes.func.isRequired, + canReact: PropTypes.bool.isRequired, + removeReaction: PropTypes.func.isRequired, + }; + + willEnter() { + return { scale: reduceMotion ? 1 : 0 }; + } + + willLeave() { + return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) }; + } + + render() { + const { reactions, numVisible } = this.props; + let visibleReactions = reactions + .filter(x => x.get('count') > 0) + .sort((a, b) => b.get('count') - a.get('count')); + + if (numVisible >= 0) { + visibleReactions = visibleReactions.filter((_, i) => i < numVisible); + } + + const styles = visibleReactions.map(reaction => ({ + key: reaction.get('name'), + data: reaction, + style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) }, + })).toArray(); + + return ( + <TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}> + {items => ( + <div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}> + {items.map(({ key, data, style }) => ( + <Reaction + key={key} + statusId={this.props.statusId} + reaction={data} + style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }} + addReaction={this.props.addReaction} + removeReaction={this.props.removeReaction} + canReact={this.props.canReact} + /> + ))} + </div> + )} + </TransitionMotion> + ); + } + +} + +class Reaction extends ImmutablePureComponent { + + static propTypes = { + statusId: PropTypes.string, + reaction: ImmutablePropTypes.map.isRequired, + addReaction: PropTypes.func.isRequired, + removeReaction: PropTypes.func.isRequired, + canReact: PropTypes.bool.isRequired, + style: PropTypes.object, + }; + + state = { + hovered: false, + }; + + handleClick = () => { + const { reaction, statusId, addReaction, removeReaction } = this.props; + + if (reaction.get('me')) { + removeReaction(statusId, reaction.get('name')); + } else { + addReaction(statusId, reaction.get('name')); + } + }; + + handleMouseEnter = () => this.setState({ hovered: true }); + + handleMouseLeave = () => this.setState({ hovered: false }); + + render() { + const { reaction } = this.props; + + return ( + <button + className={classNames('reactions-bar__item', { active: reaction.get('me') })} + onClick={this.handleClick} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + disabled={!this.props.canReact} + style={this.props.style} + > + <span className='reactions-bar__item__emoji'> + <Emoji + hovered={this.state.hovered} + emoji={reaction.get('name')} + url={reaction.get('url')} + staticUrl={reaction.get('static_url')} + /> + </span> + <span className='reactions-bar__item__count'> + <AnimatedNumber value={reaction.get('count')} /> + </span> + </button> + ); + } + +} + +class Emoji extends React.PureComponent { + + static propTypes = { + emoji: PropTypes.string.isRequired, + hovered: PropTypes.bool.isRequired, + url: PropTypes.string, + staticUrl: PropTypes.string, + }; + + render() { + const { emoji, hovered, url, staticUrl } = this.props; + + if (unicodeMapping[emoji]) { + const { filename, shortCode } = unicodeMapping[this.props.emoji]; + const title = shortCode ? `:${shortCode}:` : ''; + + return ( + <img + draggable='false' + className='emojione' + alt={emoji} + title={title} + src={`${assetHost}/emoji/${filename}.svg`} + /> + ); + } else { + const filename = (autoPlayGif || hovered) ? url : staticUrl; + const shortCode = `:${emoji}:`; + + return ( + <img + draggable='false' + className='emojione custom-emoji' + alt={shortCode} + title={shortCode} + src={filename} + /> + ); + } + } + +} diff --git a/app/javascript/flavours/blobfox/components/status_visibility_icon.jsx b/app/javascript/flavours/blobfox/components/status_visibility_icon.jsx new file mode 100644 index 00000000000000..f2d7bd562b726d --- /dev/null +++ b/app/javascript/flavours/blobfox/components/status_visibility_icon.jsx @@ -0,0 +1,54 @@ +// Package imports // +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { Icon } from 'flavours/blobfox/components/icon'; + +const messages = defineMessages({ + public: { id: 'privacy.public.short', defaultMessage: 'Public' }, + unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, + private: { id: 'privacy.private.short', defaultMessage: 'Followers only' }, + direct: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, +}); + +class VisibilityIcon extends ImmutablePureComponent { + + static propTypes = { + visibility: PropTypes.string, + intl: PropTypes.object.isRequired, + withLabel: PropTypes.bool, + }; + + render() { + const { withLabel, visibility, intl } = this.props; + + const visibilityIcon = { + public: 'globe', + unlisted: 'unlock', + private: 'lock', + direct: 'envelope', + }[visibility]; + + const label = intl.formatMessage(messages[visibility]); + + const icon = (<Icon + className='status__visibility-icon' + fixedWidth + id={visibilityIcon} + title={label} + aria-hidden='true' + />); + + if (withLabel) { + return (<span style={{ whiteSpace: 'nowrap' }}>{icon} {label}</span>); + } else { + return icon; + } + } + +} + +export default injectIntl(VisibilityIcon); diff --git a/app/javascript/flavours/blobfox/components/timeline_hint.tsx b/app/javascript/flavours/blobfox/components/timeline_hint.tsx new file mode 100644 index 00000000000000..bf2a2d8bbaeda7 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/timeline_hint.tsx @@ -0,0 +1,25 @@ +import { FormattedMessage } from 'react-intl'; + +interface Props { + resource: JSX.Element; + url: string; +} + +export const TimelineHint: React.FC<Props> = ({ resource, url }) => ( + <div className='timeline-hint'> + <strong> + <FormattedMessage + id='timeline_hint.remote_resource_not_displayed' + defaultMessage='{resource} from other servers are not displayed.' + values={{ resource }} + /> + </strong> + <br /> + <a href={url} target='_blank' rel='noopener noreferrer'> + <FormattedMessage + id='account.browse_more_on_origin_server' + defaultMessage='Browse more on the original profile' + /> + </a> + </div> +); diff --git a/app/javascript/flavours/blobfox/components/verified_badge.tsx b/app/javascript/flavours/blobfox/components/verified_badge.tsx new file mode 100644 index 00000000000000..9a6adcfa8601c9 --- /dev/null +++ b/app/javascript/flavours/blobfox/components/verified_badge.tsx @@ -0,0 +1,27 @@ +import { Icon } from './icon'; + +const domParser = new DOMParser(); + +const stripRelMe = (html: string) => { + const document = domParser.parseFromString(html, 'text/html').documentElement; + + document.querySelectorAll<HTMLAnchorElement>('a[rel]').forEach((link) => { + link.rel = link.rel + .split(' ') + .filter((x: string) => x !== 'me') + .join(' '); + }); + + const body = document.querySelector('body'); + return body ? { __html: body.innerHTML } : undefined; +}; + +interface Props { + link: string; +} +export const VerifiedBadge: React.FC<Props> = ({ link }) => ( + <span className='verified-badge'> + <Icon id='check' className='verified-badge__mark' /> + <span dangerouslySetInnerHTML={stripRelMe(link)} /> + </span> +); diff --git a/app/javascript/flavours/blobfox/containers/account_container.jsx b/app/javascript/flavours/blobfox/containers/account_container.jsx new file mode 100644 index 00000000000000..a134452e77210f --- /dev/null +++ b/app/javascript/flavours/blobfox/containers/account_container.jsx @@ -0,0 +1,76 @@ +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { connect } from 'react-redux'; + +import { + followAccount, + unfollowAccount, + blockAccount, + unblockAccount, + muteAccount, + unmuteAccount, +} from '../actions/accounts'; +import { openModal } from '../actions/modal'; +import { initMuteModal } from '../actions/mutes'; +import Account from '../components/account'; +import { unfollowModal } from '../initial_state'; +import { makeGetAccount } from '../selectors'; + +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({ + modalType: 'CONFIRM', + modalProps: { + message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, + 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/flavours/blobfox/containers/admin_component.jsx b/app/javascript/flavours/blobfox/containers/admin_component.jsx new file mode 100644 index 00000000000000..ee0bc2f316beb1 --- /dev/null +++ b/app/javascript/flavours/blobfox/containers/admin_component.jsx @@ -0,0 +1,22 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { IntlProvider } from 'flavours/blobfox/locales'; + +export default class AdminComponent extends PureComponent { + + static propTypes = { + children: PropTypes.node.isRequired, + }; + + render () { + const { children } = this.props; + + return ( + <IntlProvider> + {children} + </IntlProvider> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/containers/compose_container.jsx b/app/javascript/flavours/blobfox/containers/compose_container.jsx new file mode 100644 index 00000000000000..f76550678ed2e1 --- /dev/null +++ b/app/javascript/flavours/blobfox/containers/compose_container.jsx @@ -0,0 +1,31 @@ +import { PureComponent } from 'react'; + +import { Provider } from 'react-redux'; + +import { fetchCustomEmojis } from '../actions/custom_emojis'; +import { hydrateStore } from '../actions/store'; +import Compose from '../features/standalone/compose'; +import initialState from '../initial_state'; +import { IntlProvider } from '../locales'; +import { store } from '../store'; + + +if (initialState) { + store.dispatch(hydrateStore(initialState)); +} + +store.dispatch(fetchCustomEmojis()); + +export default class ComposeContainer extends PureComponent { + + render () { + return ( + <IntlProvider> + <Provider store={store}> + <Compose /> + </Provider> + </IntlProvider> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/containers/domain_container.jsx b/app/javascript/flavours/blobfox/containers/domain_container.jsx new file mode 100644 index 00000000000000..c719a5775c7c16 --- /dev/null +++ b/app/javascript/flavours/blobfox/containers/domain_container.jsx @@ -0,0 +1,36 @@ +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { connect } from 'react-redux'; + +import { blockDomain, unblockDomain } from '../actions/domain_blocks'; +import { openModal } from '../actions/modal'; +import { Domain } from '../components/domain'; + +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({ + modalType: 'CONFIRM', + modalProps: { + message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />, + 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/flavours/blobfox/containers/dropdown_menu_container.js b/app/javascript/flavours/blobfox/containers/dropdown_menu_container.js new file mode 100644 index 00000000000000..501bafdc53c67f --- /dev/null +++ b/app/javascript/flavours/blobfox/containers/dropdown_menu_container.js @@ -0,0 +1,37 @@ +import { connect } from 'react-redux'; + +import { openDropdownMenu, closeDropdownMenu } from '../actions/dropdown_menu'; +import { openModal, closeModal } from '../actions/modal'; +import DropdownMenu from '../components/dropdown_menu'; +import { isUserTouching } from '../is_mobile'; + +/** + * @param {import('flavours/blobfox/store').RootState} state + */ +const mapStateToProps = state => ({ + openDropdownId: state.dropdownMenu.openId, + openedViaKeyboard: state.dropdownMenu.keyboard, +}); + +const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({ + onOpen(id, onItemClick, keyboard) { + dispatch(isUserTouching() ? openModal({ + modalType: 'ACTIONS', + modalProps: { + status, + actions: items, + onClick: onItemClick, + }, + }) : openDropdownMenu({ id, keyboard, scrollKey })); + }, + + onClose(id) { + dispatch(closeModal({ + modalType: 'ACTIONS', + ignoreFocus: false, + })); + dispatch(closeDropdownMenu({ id })); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu); diff --git a/app/javascript/flavours/blobfox/containers/intersection_observer_article_container.js b/app/javascript/flavours/blobfox/containers/intersection_observer_article_container.js new file mode 100644 index 00000000000000..8d9bda6704326f --- /dev/null +++ b/app/javascript/flavours/blobfox/containers/intersection_observer_article_container.js @@ -0,0 +1,18 @@ +import { connect } from 'react-redux'; + +import { setHeight } from '../actions/height_cache'; +import IntersectionObserverArticle from '../components/intersection_observer_article'; + +const makeMapStateToProps = (state, props) => ({ + cachedHeight: state.getIn(['height_cache', props.saveHeightKey, props.id]), +}); + +const mapDispatchToProps = (dispatch) => ({ + + onHeightChange (key, id, height) { + dispatch(setHeight(key, id, height)); + }, + +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(IntersectionObserverArticle); diff --git a/app/javascript/flavours/blobfox/containers/mastodon.jsx b/app/javascript/flavours/blobfox/containers/mastodon.jsx new file mode 100644 index 00000000000000..fc0361808eee95 --- /dev/null +++ b/app/javascript/flavours/blobfox/containers/mastodon.jsx @@ -0,0 +1,97 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { Helmet } from 'react-helmet'; +import { Route } from 'react-router-dom'; + +import { Provider as ReduxProvider } from 'react-redux'; + +import { ScrollContext } from 'react-router-scroll-4'; + +import { fetchCustomEmojis } from 'flavours/blobfox/actions/custom_emojis'; +import { checkDeprecatedLocalSettings } from 'flavours/blobfox/actions/local_settings'; +import { hydrateStore } from 'flavours/blobfox/actions/store'; +import { connectUserStream } from 'flavours/blobfox/actions/streaming'; +import ErrorBoundary from 'flavours/blobfox/components/error_boundary'; +import { Router } from 'flavours/blobfox/components/router'; +import UI from 'flavours/blobfox/features/ui'; +import initialState, { title as siteTitle } from 'flavours/blobfox/initial_state'; +import { IntlProvider } from 'flavours/blobfox/locales'; +import { store } from 'flavours/blobfox/store'; + +const title = process.env.NODE_ENV === 'production' ? siteTitle : `${siteTitle} (Dev)`; + +const hydrateAction = hydrateStore(initialState); + +store.dispatch(hydrateAction); + +// check for deprecated local settings +store.dispatch(checkDeprecatedLocalSettings()); + +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 PureComponent { + + 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 () { + return ( + <IntlProvider> + <ReduxProvider store={store}> + <ErrorBoundary> + <Router> + <ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}> + <Route path='/' component={UI} /> + </ScrollContext> + </Router> + + <Helmet defaultTitle={title} titleTemplate={`%s - ${title}`} /> + </ErrorBoundary> + </ReduxProvider> + </IntlProvider> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/containers/media_container.jsx b/app/javascript/flavours/blobfox/containers/media_container.jsx new file mode 100644 index 00000000000000..84a9a2c9cd186b --- /dev/null +++ b/app/javascript/flavours/blobfox/containers/media_container.jsx @@ -0,0 +1,127 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; +import { createPortal } from 'react-dom'; + +import { fromJS } from 'immutable'; + +import { ImmutableHashtag as Hashtag } from 'flavours/blobfox/components/hashtag'; +import MediaGallery from 'flavours/blobfox/components/media_gallery'; +import ModalRoot from 'flavours/blobfox/components/modal_root'; +import Poll from 'flavours/blobfox/components/poll'; +import Audio from 'flavours/blobfox/features/audio'; +import Card from 'flavours/blobfox/features/status/components/card'; +import MediaModal from 'flavours/blobfox/features/ui/components/media_modal'; +import Video from 'flavours/blobfox/features/video'; +import { IntlProvider } from 'flavours/blobfox/locales'; +import { getScrollbarWidth } from 'flavours/blobfox/utils/scrollbar'; + +const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio }; + +export default class MediaContainer extends PureComponent { + + static propTypes = { + components: PropTypes.object.isRequired, + }; + + state = { + media: null, + index: null, + lang: null, + time: null, + backgroundColor: null, + options: null, + }; + + handleOpenMedia = (media, index, lang) => { + document.body.classList.add('with-modals--active'); + document.documentElement.style.marginRight = `${getScrollbarWidth()}px`; + + this.setState({ media, index, lang }); + }; + + handleOpenVideo = (lang, 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, lang, 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 { components } = this.props; + + let handleOpenVideo; + + // Don't offer to expand the video in a lightbox if we're in a frame + if (window.self === window.top) { + handleOpenVideo = this.handleOpenVideo; + } + + return ( + <IntlProvider> + <> + {[].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: handleOpenVideo, + } : { + onOpenMedia: this.handleOpenMedia, + }), + }); + + return createPortal( + <Component {...props} key={`media-${i}`} />, + component, + ); + })} + + <ModalRoot backgroundColor={this.state.backgroundColor} onClose={this.handleCloseMedia}> + {this.state.media && ( + <MediaModal + media={this.state.media} + index={this.state.index || 0} + lang={this.state.lang} + currentTime={this.state.options?.startTime} + autoPlay={this.state.options?.autoPlay} + volume={this.state.options?.defaultVolume} + onClose={this.handleCloseMedia} + onChangeBackgroundColor={this.setBackgroundColor} + /> + )} + </ModalRoot> + </> + </IntlProvider> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/containers/notification_purge_buttons_container.js b/app/javascript/flavours/blobfox/containers/notification_purge_buttons_container.js new file mode 100644 index 00000000000000..22bf98f7f182a1 --- /dev/null +++ b/app/javascript/flavours/blobfox/containers/notification_purge_buttons_container.js @@ -0,0 +1,53 @@ +// Package imports. +import { defineMessages, injectIntl } from 'react-intl'; + +import { connect } from 'react-redux'; + +// Our imports. +import { openModal } from 'flavours/blobfox/actions/modal'; +import { + deleteMarkedNotifications, + enterNotificationClearingMode, + markAllNotifications, +} from 'flavours/blobfox/actions/notifications'; +import NotificationPurgeButtons from 'flavours/blobfox/components/notification_purge_buttons'; + +const messages = defineMessages({ + clearMessage: { id: 'notifications.marked_clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all selected notifications?' }, + clearConfirm: { id: 'notifications.marked_clear', defaultMessage: 'Clear selected notifications' }, +}); + +const mapDispatchToProps = (dispatch, { intl }) => ({ + onEnterCleaningMode(yes) { + dispatch(enterNotificationClearingMode(yes)); + }, + + onDeleteMarked() { + dispatch(openModal({ + modalType: 'CONFIRM', + modalProps: { + message: intl.formatMessage(messages.clearMessage), + confirm: intl.formatMessage(messages.clearConfirm), + onConfirm: () => dispatch(deleteMarkedNotifications()), + }, + })); + }, + + onMarkAll() { + dispatch(markAllNotifications(true)); + }, + + onMarkNone() { + dispatch(markAllNotifications(false)); + }, + + onInvert() { + dispatch(markAllNotifications(null)); + }, +}); + +const mapStateToProps = state => ({ + markNewForDelete: state.getIn(['notifications', 'markNewForDelete']), +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationPurgeButtons)); diff --git a/app/javascript/flavours/blobfox/containers/poll_container.js b/app/javascript/flavours/blobfox/containers/poll_container.js new file mode 100644 index 00000000000000..a67055ec344177 --- /dev/null +++ b/app/javascript/flavours/blobfox/containers/poll_container.js @@ -0,0 +1,26 @@ +import { connect } from 'react-redux'; + +import { debounce } from 'lodash'; + +import { fetchPoll, vote } from 'flavours/blobfox/actions/polls'; +import Poll from 'flavours/blobfox/components/poll'; + +const mapDispatchToProps = (dispatch, { pollId }) => ({ + refresh: debounce( + () => { + dispatch(fetchPoll(pollId)); + }, + 1000, + { leading: true }, + ), + + onVote (choices) { + dispatch(vote(pollId, choices)); + }, +}); + +const mapStateToProps = (state, { pollId }) => ({ + poll: state.getIn(['polls', pollId]), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Poll); diff --git a/app/javascript/flavours/blobfox/containers/scroll_container.js b/app/javascript/flavours/blobfox/containers/scroll_container.js new file mode 100644 index 00000000000000..d21ff63687dbfd --- /dev/null +++ b/app/javascript/flavours/blobfox/containers/scroll_container.js @@ -0,0 +1,18 @@ +import { ScrollContainer as OriginalScrollContainer } from 'react-router-scroll-4'; + +// ScrollContainer is used to automatically scroll to the top when pushing a +// new history state and remembering the scroll position when going back. +// There are a few things we need to do differently, though. +const defaultShouldUpdateScroll = (prevRouterProps, { location }) => { + // If the change is caused by opening a modal, do not scroll to top + return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey); +}; + +export default +class ScrollContainer extends OriginalScrollContainer { + + static defaultProps = { + shouldUpdateScroll: defaultShouldUpdateScroll, + }; + +} diff --git a/app/javascript/flavours/blobfox/containers/status_container.js b/app/javascript/flavours/blobfox/containers/status_container.js new file mode 100644 index 00000000000000..f12d95c68e849a --- /dev/null +++ b/app/javascript/flavours/blobfox/containers/status_container.js @@ -0,0 +1,313 @@ +import { defineMessages, injectIntl } from 'react-intl'; + +import { connect } from 'react-redux'; + +import { initBlockModal } from 'flavours/blobfox/actions/blocks'; +import { initBoostModal } from 'flavours/blobfox/actions/boosts'; +import { + replyCompose, + mentionCompose, + directCompose, +} from 'flavours/blobfox/actions/compose'; +import { + initAddFilter, +} from 'flavours/blobfox/actions/filters'; +import { + reblog, + favourite, + bookmark, + unreblog, + unfavourite, + unbookmark, + pin, + unpin, + addReaction, + removeReaction, +} from 'flavours/blobfox/actions/interactions'; +import { changeLocalSetting } from 'flavours/blobfox/actions/local_settings'; +import { openModal } from 'flavours/blobfox/actions/modal'; +import { initMuteModal } from 'flavours/blobfox/actions/mutes'; +import { deployPictureInPicture } from 'flavours/blobfox/actions/picture_in_picture'; +import { initReport } from 'flavours/blobfox/actions/reports'; +import { + muteStatus, + unmuteStatus, + deleteStatus, + hideStatus, + revealStatus, + editStatus, + translateStatus, + undoStatusTranslation, +} from 'flavours/blobfox/actions/statuses'; +import Status from 'flavours/blobfox/components/status'; +import { boostModal, favouriteModal, deleteModal } from 'flavours/blobfox/initial_state'; +import { makeGetStatus, makeGetPictureInPicture } from 'flavours/blobfox/selectors'; + +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? Favorites 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?' }, + editConfirm: { id: 'confirmations.edit.confirm', defaultMessage: 'Edit' }, + editMessage: { id: 'confirmations.edit.message', defaultMessage: 'Editing now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, + unfilterConfirm: { id: 'confirmations.unfilter.confirm', defaultMessage: 'Show' }, + author: { id: 'confirmations.unfilter.author', defaultMessage: 'Author' }, + matchingFilters: { id: 'confirmations.unfilter.filters', defaultMessage: 'Matching {count, plural, one {filter} other {filters}}' }, + editFilter: { id: 'confirmations.unfilter.edit_filter', defaultMessage: 'Edit filter' }, +}); + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + const getPictureInPicture = makeGetPictureInPicture(); + + const mapStateToProps = (state, props) => { + + let status = getStatus(state, props); + let reblogStatus = status ? status.get('reblog', null) : null; + let account = undefined; + let prepend = undefined; + + if (props.featured && status) { + account = status.get('account'); + prepend = 'featured'; + } else if (reblogStatus !== null && typeof reblogStatus === 'object') { + account = status.get('account'); + status = reblogStatus; + prepend = 'reblogged_by'; + } + + return { + containerId: props.containerId || props.id, // Should match reblogStatus's id for reblogs + status: status, + nextInReplyToId: props.nextId ? state.getIn(['statuses', props.nextId, 'in_reply_to_id']) : null, + account: account || props.account, + settings: state.get('local_settings'), + prepend: prepend || props.prepend, + pictureInPicture: getPictureInPicture(state, props), + }; + }; + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ + + onReply (status, router) { + dispatch((_, getState) => { + let state = getState(); + + if (state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0) { + dispatch(openModal({ + modalType: 'CONFIRM', + modalProps: { + message: intl.formatMessage(messages.replyMessage), + confirm: intl.formatMessage(messages.replyConfirm), + onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_before_clearing_draft'], false)), + 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) { + dispatch((_, getState) => { + let state = getState(); + if (state.getIn(['local_settings', 'confirm_boost_missing_media_description']) && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) { + dispatch(initBoostModal({ status, onReblog: this.onModalReblog, missingMediaDescription: true })); + } else if (e.shiftKey || !boostModal) { + this.onModalReblog(status); + } else { + dispatch(initBoostModal({ status, onReblog: this.onModalReblog })); + } + }); + }, + + onBookmark (status) { + if (status.get('bookmarked')) { + dispatch(unbookmark(status)); + } else { + dispatch(bookmark(status)); + } + }, + + onModalFavourite (status) { + dispatch(favourite(status)); + }, + + onFavourite (status, e) { + if (status.get('favourited')) { + dispatch(unfavourite(status)); + } else { + if (e.shiftKey || !favouriteModal) { + this.onModalFavourite(status); + } else { + dispatch(openModal({ + modalType: 'FAVOURITE', + modalProps: { + status, + onFavourite: this.onModalFavourite, + }, + })); + } + } + }, + + onPin (status) { + if (status.get('pinned')) { + dispatch(unpin(status)); + } else { + dispatch(pin(status)); + } + }, + + onReactionAdd (statusId, name, url) { + dispatch(addReaction(statusId, name, url)); + }, + + onReactionRemove (statusId, name) { + dispatch(removeReaction(statusId, name)); + }, + + onEmbed (status) { + dispatch(openModal({ + modalType: 'EMBED', + modalProps: { + id: status.get('id'), + onError: error => dispatch(showAlertForError(error)), + }, + })); + }, + + onDelete (status, history, withRedraft = false) { + if (!deleteModal) { + dispatch(deleteStatus(status.get('id'), history, withRedraft)); + } else { + dispatch(openModal({ + modalType: 'CONFIRM', + modalProps: { + 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((_, getState) => { + let state = getState(); + if (state.getIn(['compose', 'text']).trim().length !== 0) { + dispatch(openModal({ + modalType: 'CONFIRM', + modalProps: { + message: intl.formatMessage(messages.editMessage), + confirm: intl.formatMessage(messages.editConfirm), + onConfirm: () => dispatch(editStatus(status.get('id'), history)), + }, + })); + } else { + dispatch(editStatus(status.get('id'), history)); + } + }); + }, + + onTranslate (status) { + if (status.get('translation')) { + dispatch(undoStatusTranslation(status.get('id'), status.get('poll'))); + } else { + dispatch(translateStatus(status.get('id'))); + } + }, + + onDirect (account, router) { + dispatch(directCompose(account, router)); + }, + + onMention (account, router) { + dispatch(mentionCompose(account, router)); + }, + + onOpenMedia (statusId, media, index, lang) { + dispatch(openModal({ + modalType: 'MEDIA', + modalProps: { statusId, media, index, lang }, + })); + }, + + onOpenVideo (statusId, media, lang, options) { + dispatch(openModal({ + modalType: 'VIDEO', + modalProps: { statusId, media, lang, options }, + })); + }, + + onBlock (status) { + const account = status.get('account'); + dispatch(initBlockModal(account)); + }, + + onReport (status) { + dispatch(initReport(status.get('account'), status)); + }, + + onAddFilter (status) { + dispatch(initAddFilter(status, { contextType })); + }, + + onMute (account) { + dispatch(initMuteModal(account)); + }, + + 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'))); + } + }, + + deployPictureInPicture (status, type, mediaProps) { + dispatch((_, getState) => { + if (getState().getIn(['local_settings', 'media', 'pop_in_player'])) { + dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps)); + } + }); + }, + + onInteractionModal (type, status) { + dispatch(openModal({ + modalType: 'INTERACTION', + modalProps: { + type, + accountId: status.getIn(['account', 'id']), + url: status.get('uri'), + }, + })); + }, + +}); + +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); diff --git a/app/javascript/flavours/blobfox/features/about/index.jsx b/app/javascript/flavours/blobfox/features/about/index.jsx new file mode 100644 index 00000000000000..f15b0e45ef716d --- /dev/null +++ b/app/javascript/flavours/blobfox/features/about/index.jsx @@ -0,0 +1,225 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; +import { Helmet } from 'react-helmet'; + +import { List as ImmutableList } from 'immutable'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'flavours/blobfox/actions/server'; +import Column from 'flavours/blobfox/components/column'; +import { Icon } from 'flavours/blobfox/components/icon'; +import { ServerHeroImage } from 'flavours/blobfox/components/server_hero_image'; +import { Skeleton } from 'flavours/blobfox/components/skeleton'; +import Account from 'flavours/blobfox/containers/account_container'; +import LinkFooter from 'flavours/blobfox/features/ui/components/link_footer'; + +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 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 ( + <div className={classNames('about__section', { active: !collapsed })}> + <div className='about__section__title' role='button' tabIndex={0} onClick={this.handleClick}> + <Icon id={collapsed ? 'chevron-right' : 'chevron-down'} fixedWidth /> {title} + </div> + + {!collapsed && ( + <div className='about__section__body'>{children}</div> + )} + </div> + ); + } + +} + +class About extends 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 ( + <Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}> + <div className='scrollable about'> + <div className='about__header'> + <ServerHeroImage blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} srcSet={server.getIn(['thumbnail', 'versions'])?.map((value, key) => `${value} ${key.replace('@', '')}`).join(', ')} className='about__header__hero' /> + <h1>{isLoading ? <Skeleton width='10ch' /> : server.get('domain')}</h1> + <p><FormattedMessage id='about.powered_by' defaultMessage='Decentralized social media powered by {mastodon}' values={{ mastodon: <a href='https://joinmastodon.org' className='about__mail' target='_blank'>Mastodon</a> }} /></p> + </div> + + <div className='about__meta'> + <div className='about__meta__column'> + <h4><FormattedMessage id='server_banner.administered_by' defaultMessage='Administered by:' /></h4> + + <Account id={server.getIn(['contact', 'account', 'id'])} size={36} minimal /> + </div> + + <hr className='about__meta__divider' /> + + <div className='about__meta__column'> + <h4><FormattedMessage id='about.contact' defaultMessage='Contact:' /></h4> + + {isLoading ? <Skeleton width='10ch' /> : <a className='about__mail' href={`mailto:${server.getIn(['contact', 'email'])}`}>{server.getIn(['contact', 'email'])}</a>} + </div> + </div> + + <Section open title={intl.formatMessage(messages.title)}> + {extendedDescription.get('isLoading') ? ( + <> + <Skeleton width='100%' /> + <br /> + <Skeleton width='100%' /> + <br /> + <Skeleton width='100%' /> + <br /> + <Skeleton width='70%' /> + </> + ) : (extendedDescription.get('content')?.length > 0 ? ( + <div + className='prose' + dangerouslySetInnerHTML={{ __html: extendedDescription.get('content') }} + /> + ) : ( + <p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p> + ))} + </Section> + + <Section title={intl.formatMessage(messages.rules)}> + {!isLoading && (server.get('rules', ImmutableList()).isEmpty() ? ( + <p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p> + ) : ( + <ol className='rules-list'> + {server.get('rules').map(rule => ( + <li key={rule.get('id')}> + <span className='rules-list__text'>{rule.get('text')}</span> + </li> + ))} + </ol> + ))} + </Section> + + <Section title={intl.formatMessage(messages.blocks)} onOpen={this.handleDomainBlocksOpen}> + {domainBlocks.get('isLoading') ? ( + <> + <Skeleton width='100%' /> + <br /> + <Skeleton width='70%' /> + </> + ) : (domainBlocks.get('isAvailable') ? ( + <> + <p><FormattedMessage id='about.domain_blocks.preamble' defaultMessage='Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server.' /></p> + + <div className='about__domain-blocks'> + {domainBlocks.get('items').map(block => ( + <div className='about__domain-blocks__domain' key={block.get('domain')}> + <div className='about__domain-blocks__domain__header'> + <h6><span title={`SHA-256: ${block.get('digest')}`}>{block.get('domain')}</span></h6> + <span className='about__domain-blocks__domain__type' title={intl.formatMessage(severityMessages[block.get('severity')].explanation)}>{intl.formatMessage(severityMessages[block.get('severity')].title)}</span> + </div> + + <p>{(block.get('comment') || '').length > 0 ? block.get('comment') : <FormattedMessage id='about.domain_blocks.no_reason_available' defaultMessage='Reason not available' />}</p> + </div> + ))} + </div> + </> + ) : ( + <p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p> + ))} + </Section> + + <LinkFooter /> + + <div className='about__footer'> + <p><FormattedMessage id='about.fork_disclaimer' defaultMessage='blobfox-soc is free open source software forked from Mastodon.' /></p> + <p><FormattedMessage id='about.disclaimer' defaultMessage='Mastodon is free, open-source software, and a trademark of Mastodon gGmbH.' /></p> + </div> + </div> + + <Helmet> + <title>{intl.formatMessage(messages.title)}</title> + <meta name='robots' content='all' /> + </Helmet> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(About)); diff --git a/app/javascript/flavours/blobfox/features/account/components/account_note.jsx b/app/javascript/flavours/blobfox/features/account/components/account_note.jsx new file mode 100644 index 00000000000000..272a4ee312c08d --- /dev/null +++ b/app/javascript/flavours/blobfox/features/account/components/account_note.jsx @@ -0,0 +1,174 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { is } from 'immutable'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import Textarea from 'react-textarea-autosize'; + +const messages = defineMessages({ + placeholder: { id: 'account_note.placeholder', defaultMessage: 'Click to add a note' }, +}); + +class InlineAlert extends PureComponent { + + static propTypes = { + show: PropTypes.bool, + }; + + state = { + mountMessage: false, + }; + + static TRANSITION_DELAY = 200; + + UNSAFE_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 ( + <span aria-live='polite' role='status' className='inline-alert' style={{ opacity: show ? 1 : 0 }}> + {mountMessage && <FormattedMessage id='generic.saved' defaultMessage='Saved' />} + </span> + ); + } + +} + +class AccountNote extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.record.isRequired, + value: PropTypes.string, + onSave: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + value: null, + saving: false, + saved: false, + }; + + UNSAFE_componentWillMount () { + this._reset(); + } + + UNSAFE_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 ( + <div className='account__header__account-note'> + <label htmlFor={`account-note-${account.get('id')}`}> + <FormattedMessage id='account.account_note_header' defaultMessage='Note' /> <InlineAlert show={saved} /> + </label> + + <Textarea + id={`account-note-${account.get('id')}`} + className='account__header__account-note__content' + disabled={this.props.value === null || value === null} + placeholder={intl.formatMessage(messages.placeholder)} + value={value || ''} + onChange={this.handleChange} + onKeyDown={this.handleKeyDown} + onBlur={this.handleBlur} + ref={this.setTextareaRef} + /> + </div> + ); + } + +} + +export default injectIntl(AccountNote); diff --git a/app/javascript/flavours/blobfox/features/account/components/action_bar.jsx b/app/javascript/flavours/blobfox/features/account/components/action_bar.jsx new file mode 100644 index 00000000000000..8730f8fe2669f7 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/account/components/action_bar.jsx @@ -0,0 +1,85 @@ +import { PureComponent } from 'react'; + +import { FormattedMessage, FormattedNumber } from 'react-intl'; + +import { NavLink } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + +import { Icon } from 'flavours/blobfox/components/icon'; + +class ActionBar extends PureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + }; + + isStatusesPageActive = (match, location) => { + if (!match) { + return false; + } + return !location.pathname.match(/\/(followers|following)\/?$/); + }; + + render () { + const { account } = this.props; + + if (account.get('suspended')) { + return ( + <div> + <div className='account__disclaimer'> + <Icon id='info-circle' fixedWidth /> <FormattedMessage + id='account.suspended_disclaimer_full' + defaultMessage='This user has been suspended by a moderator.' + /> + </div> + </div> + ); + } + + let extraInfo = ''; + + if (account.get('acct') !== account.get('username')) { + extraInfo = ( + <div className='account__disclaimer'> + <Icon id='info-circle' fixedWidth /> <FormattedMessage + id='account.disclaimer_full' + defaultMessage="Information below may reflect the user's profile incompletely." + /> + {' '} + <a target='_blank' rel='noopener' href={account.get('url')}> + <FormattedMessage id='account.view_full_profile' defaultMessage='View full profile' /> + </a> + </div> + ); + } + + return ( + <div> + {extraInfo} + + <div className='account__action-bar'> + <div className='account__action-bar-links'> + <NavLink isActive={this.isStatusesPageActive} activeClassName='active' className='account__action-bar__tab' to={`/@${account.get('acct')}`}> + <FormattedMessage id='account.posts' defaultMessage='Posts' /> + <strong><FormattedNumber value={account.get('statuses_count')} /></strong> + </NavLink> + + <NavLink exact activeClassName='active' className='account__action-bar__tab' to={`/@${account.get('acct')}/following`}> + <FormattedMessage id='account.follows' defaultMessage='Follows' /> + <strong><FormattedNumber value={account.get('following_count')} /></strong> + </NavLink> + + <NavLink exact activeClassName='active' className='account__action-bar__tab' to={`/@${account.get('acct')}/followers`}> + <FormattedMessage id='account.followers' defaultMessage='Followers' /> + <strong>{ account.get('followers_count') < 0 ? '-' : <FormattedNumber value={account.get('followers_count')} /> }</strong> + </NavLink> + </div> + </div> + </div> + ); + } + +} + +export default ActionBar; diff --git a/app/javascript/flavours/blobfox/features/account/components/featured_tags.jsx b/app/javascript/flavours/blobfox/features/account/components/featured_tags.jsx new file mode 100644 index 00000000000000..4a1219cf984203 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/account/components/featured_tags.jsx @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import Hashtag from 'flavours/blobfox/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' }, +}); + +class FeaturedTags extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.record, + 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 ( + <div className='getting-started__trends'> + <h4><FormattedMessage id='account.featured_tags.title' defaultMessage="{name}'s featured hashtags" values={{ name: <bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /> }} /></h4> + + {featuredTags.take(3).map(featuredTag => ( + <Hashtag + key={featuredTag.get('name')} + name={featuredTag.get('name')} + href={featuredTag.get('url')} + to={`/@${account.get('acct')}/tagged/${featuredTag.get('name')}`} + uses={featuredTag.get('statuses_count') * 1} + withGraph={false} + description={((featuredTag.get('statuses_count') * 1) > 0) ? intl.formatMessage(messages.lastStatusAt, { date: intl.formatDate(featuredTag.get('last_status_at'), { month: 'short', day: '2-digit' }) }) : intl.formatMessage(messages.empty)} + /> + ))} + </div> + ); + } + +} + +export default injectIntl(FeaturedTags); diff --git a/app/javascript/flavours/blobfox/features/account/components/follow_request_note.jsx b/app/javascript/flavours/blobfox/features/account/components/follow_request_note.jsx new file mode 100644 index 00000000000000..c830ca3d64eafd --- /dev/null +++ b/app/javascript/flavours/blobfox/features/account/components/follow_request_note.jsx @@ -0,0 +1,38 @@ +import { FormattedMessage } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { Icon } from 'flavours/blobfox/components/icon'; + +export default class FollowRequestNote extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.record.isRequired, + }; + + render () { + const { account, onAuthorize, onReject } = this.props; + + return ( + <div className='follow-request-banner'> + <div className='follow-request-banner__message'> + <FormattedMessage id='account.requested_follow' defaultMessage='{name} has requested to follow you' values={{ name: <bdi><strong dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi> }} /> + </div> + + <div className='follow-request-banner__action'> + <button type='button' className='button button-tertiary button--confirmation' onClick={onAuthorize}> + <Icon id='check' fixedWidth /> + <FormattedMessage id='follow_request.authorize' defaultMessage='Authorize' /> + </button> + + <button type='button' className='button button-tertiary button--destructive' onClick={onReject}> + <Icon id='times' fixedWidth /> + <FormattedMessage id='follow_request.reject' defaultMessage='Reject' /> + </button> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/account/components/header.jsx b/app/javascript/flavours/blobfox/features/account/components/header.jsx new file mode 100644 index 00000000000000..35b3650825f855 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/account/components/header.jsx @@ -0,0 +1,404 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; +import { Helmet } from 'react-helmet'; +import { withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { Avatar } from 'flavours/blobfox/components/avatar'; +import { Button } from 'flavours/blobfox/components/button'; +import { Icon } from 'flavours/blobfox/components/icon'; +import { IconButton } from 'flavours/blobfox/components/icon_button'; +import DropdownMenuContainer from 'flavours/blobfox/containers/dropdown_menu_container'; +import { autoPlayGif, me, domain } from 'flavours/blobfox/initial_state'; +import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/blobfox/permissions'; +import { preferencesLink, profileLink, accountAdminLink } from 'flavours/blobfox/utils/backend_links'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +import AccountNoteContainer from '../containers/account_note_container'; +import FollowRequestNoteContainer from '../containers/follow_request_note_container'; + +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: 'Privately mention @{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: 'Favorites' }, + 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', +}; + +class Header extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.record, + 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, + onChangeLanguages: PropTypes.func.isRequired, + onInteractionModal: PropTypes.func.isRequired, + onOpenAvatar: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + domain: PropTypes.string.isRequired, + hidden: PropTypes.bool, + ...WithRouterPropTypes, + }; + + static contextTypes = { + identity: PropTypes.object, + }; + + openEditProfile = () => { + window.open(profileLink, '_blank'); + }; + + 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({ + 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(<span className='relationship-tag'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>); + } else if (me !== account.get('id') && account.getIn(['relationship', 'blocking'])) { + info.push(<span className='relationship-tag'><FormattedMessage id='account.blocked' defaultMessage='Blocked' /></span>); + } + + if (me !== account.get('id') && account.getIn(['relationship', 'muting'])) { + info.push(<span className='relationship-tag'><FormattedMessage id='account.muted' defaultMessage='Muted' /></span>); + } else if (me !== account.get('id') && account.getIn(['relationship', 'domain_blocking'])) { + info.push(<span className='relationship-tag'><FormattedMessage id='account.domain_blocked' defaultMessage='Domain blocked' /></span>); + } + + if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) { + bellBtn = <IconButton icon={account.getIn(['relationship', 'notifying']) ? 'bell' : 'bell-o'} size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />; + } + + if (me !== account.get('id')) { + if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded + actionBtn = ''; + } else if (account.getIn(['relationship', 'requested'])) { + actionBtn = <Button text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />; + } else if (!account.getIn(['relationship', 'blocking'])) { + actionBtn = <Button className={classNames({ 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={signedIn ? this.props.onFollow : this.props.onInteractionModal} />; + } else if (account.getIn(['relationship', 'blocking'])) { + actionBtn = <Button text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />; + } + } else if (profileLink) { + actionBtn = <Button text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />; + } + + if (account.get('moved') && !account.getIn(['relationship', 'following'])) { + actionBtn = ''; + } + + if (account.get('suspended') && !account.getIn(['relationship', 'following'])) { + actionBtn = ''; + } + + if (account.get('locked')) { + lockedIcon = <Icon id='lock' title={intl.formatMessage(messages.account_locked)} />; + } + + if (signedIn && account.get('id') !== me && !account.get('suspended')) { + 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 && !account.get('suspended')) { + menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare }); + menu.push(null); + } + + if (account.get('id') === me) { + if (profileLink) menu.push({ text: intl.formatMessage(messages.edit_profile), href: profileLink }); + if (preferencesLink) menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink }); + 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, dangerous: true }); + } + + 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, dangerous: true }); + } + + if (!account.get('suspended')) { + menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport, dangerous: true }); + } + } + + 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, dangerous: true }); + } + } + + if (account.get('id') !== me && ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && accountAdminLink) || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) { + menu.push(null); + if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && accountAdminLink) { + menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: accountAdminLink(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 = (<div className='account-role bot'><FormattedMessage id='account.badges.bot' defaultMessage='Automated' /></div>); + } else if (account.get('group')) { + badge = (<div className='account-role group'><FormattedMessage id='account.badges.group' defaultMessage='Group' /></div>); + } else { + badge = null; + } + + let role = null; + if (account.getIn(['roles', 0])) { + role = (<div key='role' className={`account-role user-role-${account.getIn(['roles', 0, 'id'])}`}>{account.getIn(['roles', 0, 'name'])}</div>); + } + + return ( + <div className={classNames('account__header', { inactive: !!account.get('moved') })} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> + {!(suspended || hidden || account.get('moved')) && account.getIn(['relationship', 'requested_by']) && <FollowRequestNoteContainer account={account} />} + + <div className='account__header__image'> + <div className='account__header__info'> + {info} + </div> + + {!(suspended || hidden) && <img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' className='parallax' />} + </div> + + <div className='account__header__bar'> + <div className='account__header__tabs'> + <a className='avatar' href={account.get('avatar')} rel='noopener noreferrer' target='_blank' onClick={this.handleAvatarClick}> + <Avatar account={suspended || hidden ? undefined : account} size={90} /> + {role} + </a> + + <div className='account__header__tabs__buttons'> + {!hidden && ( + <> + {actionBtn} + {bellBtn} + </> + )} + + <DropdownMenuContainer disabled={menu.length === 0} items={menu} icon='ellipsis-v' size={24} direction='right' /> + </div> + </div> + + <div className='account__header__tabs__name'> + <h1> + <span dangerouslySetInnerHTML={displayNameHtml} /> {badge} + <small> + <span>@{acct}</span> {lockedIcon} + </small> + </h1> + </div> + + {signedIn && <AccountNoteContainer account={account} />} + + {!(suspended || hidden) && ( + <div className='account__header__extra'> + <div className='account__header__bio'> + { fields.size > 0 && ( + <div className='account__header__fields'> + {fields.map((pair, i) => ( + <dl key={i}> + <dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} /> + + <dd className={pair.get('verified_at') && 'verified'} title={pair.get('value_plain')}> + {pair.get('verified_at') && <span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><Icon id='check' className='verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} className='translate' /> + </dd> + </dl> + ))} + </div> + )} + + {account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content translate' dangerouslySetInnerHTML={content} />} + + <div className='account__header__joined'><FormattedMessage id='account.joined' defaultMessage='Joined {date}' values={{ date: intl.formatDate(account.get('created_at'), { year: 'numeric', month: 'short', day: '2-digit' }) }} /></div> + </div> + </div> + )} + </div> + + <Helmet> + <title>{titleFromAccount(account)}</title> + <meta name='robots' content={(isLocal && isIndexable) ? 'all' : 'noindex'} /> + <link rel='canonical' href={account.get('url')} /> + </Helmet> + </div> + ); + } + +} + +export default withRouter(injectIntl(Header)); diff --git a/app/javascript/flavours/blobfox/features/account/components/profile_column_header.jsx b/app/javascript/flavours/blobfox/features/account/components/profile_column_header.jsx new file mode 100644 index 00000000000000..2dc4216bdd1001 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/account/components/profile_column_header.jsx @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { injectIntl, defineMessages } from 'react-intl'; + +import ColumnHeader from '../../../components/column_header'; + +const messages = defineMessages({ + profile: { id: 'column_header.profile', defaultMessage: 'Profile' }, +}); + +class ProfileColumnHeader extends PureComponent { + + static propTypes = { + onClick: PropTypes.func, + multiColumn: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + render() { + const { onClick, intl, multiColumn } = this.props; + + return ( + <ColumnHeader + icon='user-circle' + title={intl.formatMessage(messages.profile)} + onClick={onClick} + showBackButton + multiColumn={multiColumn} + /> + ); + } + +} + +export default injectIntl(ProfileColumnHeader); diff --git a/app/javascript/flavours/blobfox/features/account/containers/account_note_container.js b/app/javascript/flavours/blobfox/features/account/containers/account_note_container.js new file mode 100644 index 00000000000000..3d24e158b34e77 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/account/containers/account_note_container.js @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; + +import { submitAccountNote } from 'flavours/blobfox/actions/account_notes'; + +import AccountNote from '../components/account_note'; + +const mapStateToProps = (state, { account }) => ({ + value: account.getIn(['relationship', 'note']), +}); + +const mapDispatchToProps = (dispatch, { account }) => ({ + + onSave (value) { + dispatch(submitAccountNote({ id: account.get('id'), value})); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(AccountNote); diff --git a/app/javascript/flavours/blobfox/features/account/containers/featured_tags_container.js b/app/javascript/flavours/blobfox/features/account/containers/featured_tags_container.js new file mode 100644 index 00000000000000..5d4344086653bb --- /dev/null +++ b/app/javascript/flavours/blobfox/features/account/containers/featured_tags_container.js @@ -0,0 +1,17 @@ +import { List as ImmutableList } from 'immutable'; +import { connect } from 'react-redux'; + +import { makeGetAccount } from 'flavours/blobfox/selectors'; + +import FeaturedTags from '../components/featured_tags'; + +const mapStateToProps = () => { + const getAccount = makeGetAccount(); + + return (state, { accountId }) => ({ + account: getAccount(state, accountId), + featuredTags: state.getIn(['user_lists', 'featured_tags', accountId, 'items'], ImmutableList()), + }); +}; + +export default connect(mapStateToProps)(FeaturedTags); diff --git a/app/javascript/flavours/blobfox/features/account/containers/follow_request_note_container.js b/app/javascript/flavours/blobfox/features/account/containers/follow_request_note_container.js new file mode 100644 index 00000000000000..1771a78fecfae0 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/account/containers/follow_request_note_container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; + +import { authorizeFollowRequest, rejectFollowRequest } from 'flavours/blobfox/actions/accounts'; + +import FollowRequestNote from '../components/follow_request_note'; + +const mapDispatchToProps = (dispatch, { account }) => ({ + onAuthorize () { + dispatch(authorizeFollowRequest(account.get('id'))); + }, + + onReject () { + dispatch(rejectFollowRequest(account.get('id'))); + }, +}); + +export default connect(null, mapDispatchToProps)(FollowRequestNote); diff --git a/app/javascript/flavours/blobfox/features/account/navigation.jsx b/app/javascript/flavours/blobfox/features/account/navigation.jsx new file mode 100644 index 00000000000000..ddb8a835275c05 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/account/navigation.jsx @@ -0,0 +1,55 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { connect } from 'react-redux'; + +import FeaturedTags from 'flavours/blobfox/features/account/containers/featured_tags_container'; +import { normalizeForLookup } from 'flavours/blobfox/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, + }; +}; + +class AccountNavigation extends 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 ( + <> + <div className='flex-spacer' /> + <FeaturedTags accountId={accountId} tagged={tagged} /> + </> + ); + } + +} + +export default connect(mapStateToProps)(AccountNavigation); diff --git a/app/javascript/flavours/blobfox/features/account_gallery/components/media_item.jsx b/app/javascript/flavours/blobfox/features/account_gallery/components/media_item.jsx new file mode 100644 index 00000000000000..cda7fd5e00be10 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/account_gallery/components/media_item.jsx @@ -0,0 +1,155 @@ +import PropTypes from 'prop-types'; + +import classNames from 'classnames'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { Blurhash } from 'flavours/blobfox/components/blurhash'; +import { Icon } from 'flavours/blobfox/components/icon'; +import { autoPlayGif, displayMedia, useBlurhash } from 'flavours/blobfox/initial_state'; + +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 = ( + <span className='account-gallery__item__icons'> + <Icon id='eye-slash' /> + </span> + ); + } else { + if (['audio', 'video'].includes(attachment.get('type'))) { + content = ( + <img + src={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])} + alt={attachment.get('description')} + lang={status.get('language')} + onLoad={this.handleImageLoad} + /> + ); + + if (attachment.get('type') === 'audio') { + label = <Icon id='music' />; + } else { + label = <Icon id='play' />; + } + } 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 = ( + <img + src={attachment.get('preview_url')} + alt={attachment.get('description')} + lang={status.get('language')} + style={{ objectPosition: `${x}% ${y}%` }} + onLoad={this.handleImageLoad} + /> + ); + } else if (attachment.get('type') === 'gifv') { + content = ( + <video + className='media-gallery__item-gifv-thumbnail' + aria-label={attachment.get('description')} + title={attachment.get('description')} + lang={status.get('language')} + role='application' + src={attachment.get('url')} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + autoPlay={autoPlayGif} + playsInline + loop + muted + /> + ); + + label = 'GIF'; + } + + thumbnail = ( + <div className='media-gallery__gifv'> + {content} + + {label && ( + <div className='media-gallery__item__badges'> + <span className='media-gallery__gifv__label'>{label}</span> + </div> + )} + </div> + ); + } + + return ( + <div className='account-gallery__item' style={{ width, height }}> + <a className='media-gallery__item-thumbnail' href={status.get('url')} onClick={this.handleClick} title={title} target='_blank' rel='noopener noreferrer'> + <Blurhash + hash={attachment.get('blurhash')} + className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} + dummy={!useBlurhash} + /> + + {visible ? thumbnail : icon} + </a> + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/account_gallery/index.jsx b/app/javascript/flavours/blobfox/features/account_gallery/index.jsx new file mode 100644 index 00000000000000..7ef3bc688c6d39 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/account_gallery/index.jsx @@ -0,0 +1,239 @@ +import PropTypes from 'prop-types'; + +import { FormattedMessage } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { lookupAccount, fetchAccount } from 'flavours/blobfox/actions/accounts'; +import { openModal } from 'flavours/blobfox/actions/modal'; +import { LoadMore } from 'flavours/blobfox/components/load_more'; +import { LoadingIndicator } from 'flavours/blobfox/components/loading_indicator'; +import ScrollContainer from 'flavours/blobfox/containers/scroll_container'; +import ProfileColumnHeader from 'flavours/blobfox/features/account/components/profile_column_header'; +import BundleColumnError from 'flavours/blobfox/features/ui/components/bundle_column_error'; +import { normalizeForLookup } from 'flavours/blobfox/reducers/accounts_map'; +import { getAccountGallery } from 'flavours/blobfox/selectors'; + +import { expandAccountMediaTimeline } from '../../actions/timelines'; +import HeaderContainer from '../account_timeline/containers/header_container'; +import Column from '../ui/components/column'; + +import MediaItem from './components/media_item'; + +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), + }; +}; + +class LoadMoreMedia extends ImmutablePureComponent { + + static propTypes = { + maxId: PropTypes.string, + onLoadMore: PropTypes.func.isRequired, + }; + + handleLoadMore = () => { + this.props.onLoadMore(this.props.maxId); + }; + + render () { + return ( + <LoadMore + disabled={this.props.disabled} + onClick={this.handleLoadMore} + /> + ); + } + +} + +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, + 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)); + } + } + + handleHeaderClick = () => { + this.column.scrollTop(); + }; + + 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(); + }; + + setColumnRef = c => { + this.column = c; + }; + + handleOpenMedia = attachment => { + const { dispatch } = this.props; + const statusId = attachment.getIn(['status', 'id']); + const lang = attachment.getIn(['status', 'language']); + + if (attachment.get('type') === 'video') { + dispatch(openModal({ + modalType: 'VIDEO', + modalProps: { media: attachment, statusId, lang, options: { autoPlay: true } }, + })); + } else if (attachment.get('type') === 'audio') { + dispatch(openModal({ + modalType: 'AUDIO', + modalProps: { media: attachment, statusId, lang, options: { autoPlay: true } }, + })); + } else { + const media = attachment.getIn(['status', 'media_attachments']); + const index = media.findIndex(x => x.get('id') === attachment.get('id')); + + dispatch(openModal({ + modalType: 'MEDIA', + modalProps: { media, index, statusId, lang }, + })); + } + }; + + handleRef = c => { + if (c) { + this.setState({ width: c.offsetWidth }); + } + }; + + render () { + const { attachments, isLoading, hasMore, isAccount, multiColumn, suspended } = this.props; + const { width } = this.state; + + if (!isAccount) { + return ( + <BundleColumnError multiColumn={multiColumn} errorType='routing' /> + ); + } + + if (!attachments && isLoading) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + let loadOlder = null; + + if (hasMore && !(isLoading && attachments.size === 0)) { + loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />; + } + + return ( + <Column ref={this.setColumnRef}> + <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} /> + + <ScrollContainer scrollKey='account_gallery'> + <div className='scrollable scrollable--flex' onScroll={this.handleScroll}> + <HeaderContainer accountId={this.props.accountId} /> + + {suspended ? ( + <div className='empty-column-indicator'> + <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' /> + </div> + ) : ( + <div role='feed' className='account-gallery__container' ref={this.handleRef}> + {attachments.map((attachment, index) => attachment === null ? ( + <LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} /> + ) : ( + <MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} /> + ))} + + {loadOlder} + </div> + )} + + {isLoading && attachments.size === 0 && ( + <div className='scrollable__append'> + <LoadingIndicator /> + </div> + )} + </div> + </ScrollContainer> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(AccountGallery); diff --git a/app/javascript/flavours/blobfox/features/account_timeline/components/header.jsx b/app/javascript/flavours/blobfox/features/account_timeline/components/header.jsx new file mode 100644 index 00000000000000..cb9fc5b87f4650 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/account_timeline/components/header.jsx @@ -0,0 +1,165 @@ +import PropTypes from 'prop-types'; + +import { FormattedMessage } from 'react-intl'; + +import { NavLink, withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +import ActionBar from '../../account/components/action_bar'; +import InnerHeader from '../../account/components/header'; + +import MemorialNote from './memorial_note'; +import MovedNote from './moved_note'; + +class Header extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.record, + 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, + ...WithRouterPropTypes, + }; + + handleFollow = () => { + this.props.onFollow(this.props.account); + }; + + handleBlock = () => { + this.props.onBlock(this.props.account); + }; + + handleMention = () => { + this.props.onMention(this.props.account, this.props.history); + }; + + handleDirect = () => { + this.props.onDirect(this.props.account, this.props.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 ( + <div className='account-timeline__header'> + {(!hidden && account.get('memorial')) && <MemorialNote />} + {(!hidden && account.get('moved')) && <MovedNote from={account} to={account.get('moved')} />} + + <InnerHeader + account={account} + onFollow={this.handleFollow} + onBlock={this.handleBlock} + onMention={this.handleMention} + onDirect={this.handleDirect} + onReblogToggle={this.handleReblogToggle} + onNotifyToggle={this.handleNotifyToggle} + onReport={this.handleReport} + onMute={this.handleMute} + onBlockDomain={this.handleBlockDomain} + onUnblockDomain={this.handleUnblockDomain} + onEndorseToggle={this.handleEndorseToggle} + onAddToList={this.handleAddToList} + onEditAccountNote={this.handleEditAccountNote} + onChangeLanguages={this.handleChangeLanguages} + onInteractionModal={this.handleInteractionModal} + onOpenAvatar={this.handleOpenAvatar} + domain={this.props.domain} + hidden={hidden} + /> + + <ActionBar + account={account} + /> + + {!(hideTabs || hidden) && ( + <div className='account__section-headline'> + <NavLink exact to={`/@${account.get('acct')}`}><FormattedMessage id='account.posts' defaultMessage='Posts' /></NavLink> + <NavLink exact to={`/@${account.get('acct')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Posts and replies' /></NavLink> + <NavLink exact to={`/@${account.get('acct')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink> + </div> + )} + </div> + ); + } + +} + +export default withRouter(Header); diff --git a/app/javascript/flavours/blobfox/features/account_timeline/components/limited_account_hint.tsx b/app/javascript/flavours/blobfox/features/account_timeline/components/limited_account_hint.tsx new file mode 100644 index 00000000000000..8096dff4996146 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/account_timeline/components/limited_account_hint.tsx @@ -0,0 +1,35 @@ +import { useCallback } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { revealAccount } from 'flavours/blobfox/actions/accounts_typed'; +import { Button } from 'flavours/blobfox/components/button'; +import { domain } from 'flavours/blobfox/initial_state'; +import { useAppDispatch } from 'flavours/blobfox/store'; + +export const LimitedAccountHint: React.FC<{ accountId: string }> = ({ + accountId, +}) => { + const dispatch = useAppDispatch(); + const reveal = useCallback(() => { + dispatch(revealAccount({ id: accountId })); + }, [dispatch, accountId]); + + return ( + <div className='limited-account-hint'> + <p> + <FormattedMessage + id='limited_account_hint.title' + defaultMessage='This profile has been hidden by the moderators of {domain}.' + values={{ domain }} + /> + </p> + <Button onClick={reveal}> + <FormattedMessage + id='limited_account_hint.action' + defaultMessage='Show profile anyway' + /> + </Button> + </div> + ); +}; diff --git a/app/javascript/flavours/blobfox/features/account_timeline/components/memorial_note.jsx b/app/javascript/flavours/blobfox/features/account_timeline/components/memorial_note.jsx new file mode 100644 index 00000000000000..a04808f1caf737 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/account_timeline/components/memorial_note.jsx @@ -0,0 +1,11 @@ +import { FormattedMessage } from 'react-intl'; + +const MemorialNote = () => ( + <div className='account-memorial-banner'> + <div className='account-memorial-banner__message'> + <FormattedMessage id='account.in_memoriam' defaultMessage='In Memoriam.' /> + </div> + </div> +); + +export default MemorialNote; diff --git a/app/javascript/flavours/blobfox/features/account_timeline/components/moved_note.jsx b/app/javascript/flavours/blobfox/features/account_timeline/components/moved_note.jsx new file mode 100644 index 00000000000000..aecb30827a9e32 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/account_timeline/components/moved_note.jsx @@ -0,0 +1,52 @@ +import { FormattedMessage } from 'react-intl'; + +import { withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { Icon } from 'flavours/blobfox/components/icon'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +import { AvatarOverlay } from '../../../components/avatar_overlay'; +import { DisplayName } from '../../../components/display_name'; + +class MovedNote extends ImmutablePureComponent { + + static propTypes = { + from: ImmutablePropTypes.map.isRequired, + to: ImmutablePropTypes.map.isRequired, + ...WithRouterPropTypes, + }; + + handleAccountClick = e => { + if (e.button === 0) { + e.preventDefault(); + this.props.history.push(`/@${this.props.to.get('acct')}`); + } + + e.stopPropagation(); + }; + + render () { + const { from, to } = this.props; + const displayNameHtml = { __html: from.get('display_name_html') }; + + return ( + <div className='account__moved-note'> + <div className='account__moved-note__message'> + <div className='account__moved-note__icon-wrapper'><Icon id='suitcase' className='account__moved-note__icon' fixedWidth /></div> + <FormattedMessage id='account.moved_to' defaultMessage='{name} has indicated that their new account is now:' values={{ name: <bdi><strong dangerouslySetInnerHTML={displayNameHtml} /></bdi> }} /> + </div> + + <a href={to.get('url')} onClick={this.handleAccountClick} className='detailed-status__display-name'> + <div className='detailed-status__display-avatar'><AvatarOverlay account={to} friend={from} /></div> + <DisplayName account={to} /> + </a> + </div> + ); + } + +} + +export default withRouter(MovedNote); diff --git a/app/javascript/flavours/blobfox/features/account_timeline/containers/header_container.jsx b/app/javascript/flavours/blobfox/features/account_timeline/containers/header_container.jsx new file mode 100644 index 00000000000000..c3a3de71ed9c3b --- /dev/null +++ b/app/javascript/flavours/blobfox/features/account_timeline/containers/header_container.jsx @@ -0,0 +1,186 @@ +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { connect } from 'react-redux'; + +import { + followAccount, + unfollowAccount, + unblockAccount, + unmuteAccount, + pinAccount, + unpinAccount, +} from '../../../actions/accounts'; +import { initBlockModal } from '../../../actions/blocks'; +import { + mentionCompose, + directCompose, +} from '../../../actions/compose'; +import { blockDomain, unblockDomain } from '../../../actions/domain_blocks'; +import { openModal } from '../../../actions/modal'; +import { initMuteModal } from '../../../actions/mutes'; +import { initReport } from '../../../actions/reports'; +import { unfollowModal } from '../../../initial_state'; +import { makeGetAccount, getAccountHidden } from '../../../selectors'; +import Header from '../components/header'; + +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: 'Block 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({ + modalType: 'CONFIRM', + modalProps: { + message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, + 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({ + modalType: 'CONFIRM', + modalProps: { + message: <FormattedMessage id='confirmations.cancel_follow_request.message' defaultMessage='Are you sure you want to withdraw your request to follow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, + 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({ + modalType: 'INTERACTION', + modalProps: { + type: 'follow', + accountId: account.get('id'), + url: account.get('uri'), + }, + })); + }, + + 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({ + modalType: 'CONFIRM', + modalProps: { + message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />, + confirm: intl.formatMessage(messages.blockDomainConfirm), + onConfirm: () => dispatch(blockDomain(domain)), + }, + })); + }, + + onUnblockDomain (domain) { + dispatch(unblockDomain(domain)); + }, + + onAddToList (account) { + dispatch(openModal({ + modalType: 'LIST_ADDER', + modalProps: { + accountId: account.get('id'), + }, + })); + }, + + onChangeLanguages (account) { + dispatch(openModal({ + modalType: 'SUBSCRIBED_LANGUAGES', + modalProps: { + accountId: account.get('id'), + }, + })); + }, + + onOpenAvatar (account) { + dispatch(openModal({ + modalType: 'IMAGE', + modalProps: { + src: account.get('avatar'), + alt: account.get('acct'), + }, + })); + }, + +}); + +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header)); diff --git a/app/javascript/flavours/blobfox/features/account_timeline/index.jsx b/app/javascript/flavours/blobfox/features/account_timeline/index.jsx new file mode 100644 index 00000000000000..4ecd55dff43228 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/account_timeline/index.jsx @@ -0,0 +1,210 @@ +import PropTypes from 'prop-types'; + +import { FormattedMessage } from 'react-intl'; + +import { List as ImmutableList } from 'immutable'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { TimelineHint } from 'flavours/blobfox/components/timeline_hint'; +import ProfileColumnHeader from 'flavours/blobfox/features/account/components/profile_column_header'; +import BundleColumnError from 'flavours/blobfox/features/ui/components/bundle_column_error'; +import { normalizeForLookup } from 'flavours/blobfox/reducers/accounts_map'; +import { getAccountHidden } from 'flavours/blobfox/selectors'; + +import { lookupAccount, fetchAccount } from '../../actions/accounts'; +import { fetchFeaturedTags } from '../../actions/featured_tags'; +import { expandAccountFeaturedTimeline, expandAccountTimeline } from '../../actions/timelines'; +import { LoadingIndicator } from '../../components/loading_indicator'; +import StatusList from '../../components/status_list'; +import Column from '../ui/components/column'; + +import { LimitedAccountHint } from './components/limited_account_hint'; +import HeaderContainer from './containers/header_container'; + +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'], ImmutableList()), + featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'], ImmutableList()), + 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), + }; +}; + +const RemoteHint = ({ url }) => ( + <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.statuses' defaultMessage='Older posts' />} /> +); + +RemoteHint.propTypes = { + url: PropTypes.string.isRequired, +}; + +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, + 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 })); + } + + 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 })); + } + } + + UNSAFE_componentWillReceiveProps (nextProps) { + const { dispatch } = this.props; + + if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) { + dispatch(fetchAccount(nextProps.params.accountId)); + + if (!nextProps.withReplies) { + dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId)); + } + + dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies })); + } + } + + handleHeaderClick = () => { + this.column.scrollTop(); + }; + + handleLoadMore = maxId => { + this.props.dispatch(expandAccountTimeline(this.props.accountId, { maxId, withReplies: this.props.withReplies, tagged: this.props.params.tagged })); + }; + + setRef = c => { + this.column = c; + }; + + render () { + const { accountId, statusIds, featuredStatusIds, isLoading, hasMore, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props; + + if (isLoading && statusIds.isEmpty()) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } else if (!isLoading && !isAccount) { + return ( + <BundleColumnError multiColumn={multiColumn} errorType='routing' /> + ); + } + + let emptyMessage; + + const forceEmptyState = suspended || hidden; + + if (suspended) { + emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />; + } else if (hidden) { + emptyMessage = <LimitedAccountHint accountId={accountId} />; + } else if (remote && statusIds.isEmpty()) { + emptyMessage = <RemoteHint url={remoteUrl} />; + } else { + emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No posts found' />; + } + + const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null; + + return ( + <Column ref={this.setRef}> + <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} /> + + <StatusList + prepend={<HeaderContainer accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={this.props.params.tagged} />} + 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' + /> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(AccountTimeline); diff --git a/app/javascript/flavours/blobfox/features/audio/index.jsx b/app/javascript/flavours/blobfox/features/audio/index.jsx new file mode 100644 index 00000000000000..199eda8a7eb633 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/audio/index.jsx @@ -0,0 +1,600 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import { is } from 'immutable'; + +import { throttle, debounce } from 'lodash'; + +import { Icon } from 'flavours/blobfox/components/icon'; +import { formatTime, getPointerPosition, fileNameFromURL } from 'flavours/blobfox/features/video'; + +import { Blurhash } from '../../components/blurhash'; +import { displayMedia, useBlurhash } from '../../initial_state'; + +import Visualizer from './visualizer'; + +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; + +class Audio extends PureComponent { + + static propTypes = { + src: PropTypes.string.isRequired, + alt: PropTypes.string, + lang: 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 (width && width !== this.state.containerWidth) { + 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 (this.player) { + this._setDimensions(); + } + + 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(); + } + } + + UNSAFE_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.state.volume === 0); + + this.setState((state) => ({ muted, volume: Math.max(state.volume || 0.5, 0.05) }), () => { + if (this.gainNode) { + this.gainNode.gain.value = this.state.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((state) => ({ volume: x, muted: state.muted && x === 0 }), () => { + 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) / 2 - PADDING * this._getScaleCoefficient()); + } + + _getScaleCoefficient () { + return (this.state.height || this.props.height) / 982; + } + + _getCX() { + return Math.floor(this.state.width / 2); + } + + _getCY() { + return Math.floor((this.state.height || this.props.height) / 2); + } + + _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, lang, editable, autoPlay, sensitive, blurhash } = this.props; + const { paused, volume, currentTime, duration, buffer, dragging, revealed } = this.state; + const progress = Math.min((currentTime / duration) * 100, 100); + const muted = this.state.muted || volume === 0; + + let warning; + + if (sensitive) { + warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />; + } else { + warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />; + } + + return ( + <div className={classNames('audio-player', { editable, inactive: !revealed })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), aspectRatio: '16 / 9' }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex={0} onKeyDown={this.handleKeyDown}> + + <Blurhash + hash={blurhash} + className={classNames('media-gallery__preview', { + 'media-gallery__preview--hidden': revealed, + })} + dummy={!useBlurhash} + /> + + {(revealed || editable) && <audio + ref={this.setAudioRef} + preload={autoPlay ? 'auto' : 'none'} + onPlay={this.handlePlay} + onPause={this.handlePause} + onProgress={this.handleProgress} + onLoadedData={this.handleLoadedData} + crossOrigin='anonymous' + > + <source src={src.replace("/original/", "/opus/").replace(".mp3", ".webm")} type="audio/webm; codecs=opus"/> + <source src={src} type="audio/mpeg; codecs=mp3"/> + </audio> + } + + <canvas + role='button' + tabIndex={0} + className='audio-player__canvas' + width={this.state.width} + height={this.state.height} + style={{ width: '100%', position: 'absolute', top: 0, left: 0 }} + ref={this.setCanvasRef} + onClick={this.togglePlay} + onKeyDown={this.handleAudioKeyDown} + title={alt} + aria-label={alt} + lang={lang} + /> + + <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}> + <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}> + <span className='spoiler-button__overlay__label'> + {warning} + <span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span> + </span> + </button> + </div> + + {(revealed || editable) && <img + src={this.props.poster} + alt='' + style={{ + position: 'absolute', + left: '50%', + top: '50%', + height: `calc(${(100 - 2 * 100 * PADDING / 982)}% - ${TICK_SIZE * 2}px)`, + aspectRatio: '1', + transform: 'translate(-50%, -50%)', + borderRadius: '50%', + pointerEvents: 'none', + }} + />} + + <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}> + <div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} /> + <div className='video-player__seek__progress' style={{ width: `${progress}%`, backgroundColor: this._getAccentColor() }} /> + + <span + className={classNames('video-player__seek__handle', { active: dragging })} + tabIndex={0} + style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }} + onKeyDown={this.handleAudioKeyDown} + /> + </div> + + <div className='video-player__controls active'> + <div className='video-player__buttons-bar'> + <div className='video-player__buttons left'> + <button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} className='player-button' onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button> + <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button> + + <div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}> + <div className='video-player__volume__current' style={{ width: `${muted ? 0 : volume * 100}%`, backgroundColor: this._getAccentColor() }} /> + + <span + className='video-player__volume__handle' + tabIndex={0} + style={{ left: `${muted ? 0 : volume * 100}%`, backgroundColor: this._getAccentColor() }} + /> + </div> + + <span className='video-player__time'> + <span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span> + <span className='video-player__time-sep'>/</span> + <span className='video-player__time-total'>{formatTime(Math.floor(this.state.duration || this.props.duration))}</span> + </span> + </div> + + <div className='video-player__buttons right'> + {!editable && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>} + <a title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} className='video-player__download__icon player-button' href={this.props.src} download> + <Icon id={'download'} fixedWidth /> + </a> + </div> + </div> + </div> + </div> + ); + } + +} + +export default injectIntl(Audio); diff --git a/app/javascript/flavours/blobfox/features/audio/visualizer.js b/app/javascript/flavours/blobfox/features/audio/visualizer.js new file mode 100644 index 00000000000000..77d5b5a65cdce1 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/audio/visualizer.js @@ -0,0 +1,136 @@ +/* +Copyright (c) 2020 by Alex Permyakov (https://codepen.io/alexdevp/pen/RNELPV) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +const hex2rgba = (hex, alpha = 1) => { + const [r, g, b] = hex.match(/\w\w/g).map(x => parseInt(x, 16)); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +}; + +export default class Visualizer { + + constructor (tickSize) { + this.tickSize = tickSize; + } + + setCanvas(canvas) { + this.canvas = canvas; + if (canvas) { + this.context = canvas.getContext('2d'); + } + } + + setAudioContext(context, source) { + const analyser = context.createAnalyser(); + + analyser.smoothingTimeConstant = 0.6; + analyser.fftSize = 2048; + + source.connect(analyser); + + this.analyser = analyser; + } + + getTickPoints (count) { + const coords = []; + + for(let i = 0; i < count; i++) { + const rad = Math.PI * 2 * i / count; + coords.push({ x: Math.cos(rad), y: -Math.sin(rad) }); + } + + return coords; + } + + drawTick (cx, cy, mainColor, x1, y1, x2, y2) { + const dx1 = Math.ceil(cx + x1); + const dy1 = Math.ceil(cy + y1); + const dx2 = Math.ceil(cx + x2); + const dy2 = Math.ceil(cy + y2); + + const gradient = this.context.createLinearGradient(dx1, dy1, dx2, dy2); + + const lastColor = hex2rgba(mainColor, 0); + + gradient.addColorStop(0, mainColor); + gradient.addColorStop(0.6, mainColor); + gradient.addColorStop(1, lastColor); + + this.context.beginPath(); + this.context.strokeStyle = gradient; + this.context.lineWidth = 2; + this.context.moveTo(dx1, dy1); + this.context.lineTo(dx2, dy2); + this.context.stroke(); + } + + getTicks (count, size, radius, scaleCoefficient) { + const ticks = this.getTickPoints(count); + const lesser = 200; + const m = []; + const bufferLength = this.analyser ? this.analyser.frequencyBinCount : 0; + const frequencyData = new Uint8Array(bufferLength); + const allScales = []; + + if (this.analyser) { + this.analyser.getByteFrequencyData(frequencyData); + } + + ticks.forEach((tick, i) => { + const coef = 1 - i / (ticks.length * 2.5); + + let delta = ((frequencyData[i] || 0) - lesser * coef) * scaleCoefficient; + + if (delta < 0) { + delta = 0; + } + + const k = radius / (radius - (size + delta)); + + const x1 = tick.x * (radius - size); + const y1 = tick.y * (radius - size); + const x2 = x1 * k; + const y2 = y1 * k; + + m.push({ x1, y1, x2, y2 }); + + if (i < 20) { + let scale = delta / (200 * scaleCoefficient); + scale = scale < 1 ? 1 : scale; + allScales.push(scale); + } + }); + + const scale = allScales.reduce((pv, cv) => pv + cv, 0) / allScales.length; + + return m.map(({ x1, y1, x2, y2 }) => ({ + x1: x1, + y1: y1, + x2: x2 * scale, + y2: y2 * scale, + })); + } + + clear (width, height) { + this.context.clearRect(0, 0, width, height); + } + + draw (cx, cy, color, radius, coefficient) { + this.context.save(); + + const ticks = this.getTicks(parseInt(360 * coefficient), this.tickSize, radius, coefficient); + + ticks.forEach(tick => { + this.drawTick(cx, cy, color, tick.x1, tick.y1, tick.x2, tick.y2); + }); + + this.context.restore(); + } + +} diff --git a/app/javascript/flavours/blobfox/features/blocks/index.jsx b/app/javascript/flavours/blobfox/features/blocks/index.jsx new file mode 100644 index 00000000000000..d976174ce0c2b0 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/blocks/index.jsx @@ -0,0 +1,82 @@ +import PropTypes from 'prop-types'; + +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 { debounce } from 'lodash'; + +import { fetchBlocks, expandBlocks } from '../../actions/blocks'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import { LoadingIndicator } from '../../components/loading_indicator'; +import ScrollableList from '../../components/scrollable_list'; +import AccountContainer from '../../containers/account_container'; +import Column from '../ui/components/column'; + +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), +}); + +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, + }; + + UNSAFE_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 ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + const emptyMessage = <FormattedMessage id='empty_column.blocks' defaultMessage="You haven't blocked any users yet." />; + + return ( + <Column bindToDocument={!multiColumn} icon='ban' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + <ScrollableList + scrollKey='blocks' + onLoadMore={this.handleLoadMore} + hasMore={hasMore} + isLoading={isLoading} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {accountIds.map(id => + <AccountContainer key={id} id={id} defaultAction='block' />, + )} + </ScrollableList> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(Blocks)); diff --git a/app/javascript/flavours/blobfox/features/bookmarked_statuses/index.jsx b/app/javascript/flavours/blobfox/features/bookmarked_statuses/index.jsx new file mode 100644 index 00000000000000..d61b97022df25f --- /dev/null +++ b/app/javascript/flavours/blobfox/features/bookmarked_statuses/index.jsx @@ -0,0 +1,113 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { debounce } from 'lodash'; + +import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'flavours/blobfox/actions/bookmarks'; +import { addColumn, removeColumn, moveColumn } from 'flavours/blobfox/actions/columns'; +import ColumnHeader from 'flavours/blobfox/components/column_header'; +import StatusList from 'flavours/blobfox/components/status_list'; +import Column from 'flavours/blobfox/features/ui/components/column'; +import { getStatusList } from 'flavours/blobfox/selectors'; + +const messages = defineMessages({ + heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, +}); + +const mapStateToProps = state => ({ + statusIds: getStatusList(state, 'bookmarks'), + isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true), + hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']), +}); + +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, + }; + + UNSAFE_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 = <FormattedMessage id='empty_column.bookmarked_statuses' defaultMessage="You don't have any bookmarked posts yet. When you bookmark one, it will show up here." />; + + return ( + <Column bindToDocument={!multiColumn} ref={this.setRef}> + <ColumnHeader + icon='bookmark' + title={intl.formatMessage(messages.heading)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + showBackButton + /> + + <StatusList + trackScroll={!pinned} + statusIds={statusIds} + scrollKey={`bookmarked_statuses-${columnId}`} + hasMore={hasMore} + isLoading={isLoading} + onLoadMore={this.handleLoadMore} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + /> + + <Helmet> + <title>{intl.formatMessage(messages.heading)}</title> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(Bookmarks)); diff --git a/app/javascript/flavours/blobfox/features/closed_registrations_modal/index.jsx b/app/javascript/flavours/blobfox/features/closed_registrations_modal/index.jsx new file mode 100644 index 00000000000000..b3742a30fa638b --- /dev/null +++ b/app/javascript/flavours/blobfox/features/closed_registrations_modal/index.jsx @@ -0,0 +1,77 @@ +import { FormattedMessage } from 'react-intl'; + +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { fetchServer } from 'flavours/blobfox/actions/server'; +import { domain } from 'flavours/blobfox/initial_state'; + +const mapStateToProps = state => ({ + message: state.getIn(['server', 'server', 'registrations', 'message']), +}); + +class ClosedRegistrationsModal extends ImmutablePureComponent { + + componentDidMount () { + const { dispatch } = this.props; + dispatch(fetchServer()); + } + + render () { + let closedRegistrationsMessage; + + if (this.props.message) { + closedRegistrationsMessage = ( + <p + className='prose' + dangerouslySetInnerHTML={{ __html: this.props.message }} + /> + ); + } else { + closedRegistrationsMessage = ( + <p className='prose'> + <FormattedMessage + id='closed_registrations_modal.description' + defaultMessage='Creating an account on {domain} is currently not possible, but please keep in mind that you do not need an account specifically on {domain} to use Mastodon.' + values={{ domain: <strong>{domain}</strong> }} + /> + </p> + ); + } + + return ( + <div className='modal-root__modal interaction-modal'> + <div className='interaction-modal__lead'> + <h3><FormattedMessage id='closed_registrations_modal.title' defaultMessage='Signing up on Mastodon' /></h3> + <p> + <FormattedMessage + id='closed_registrations_modal.preamble' + defaultMessage='Mastodon is decentralized, so no matter where you create your account, you will be able to follow and interact with anyone on this server. You can even self-host it!' + /> + </p> + </div> + + <div className='interaction-modal__choices'> + <div className='interaction-modal__choices__choice'> + <h3><FormattedMessage id='interaction_modal.on_this_server' defaultMessage='On this server' /></h3> + {closedRegistrationsMessage} + </div> + + <div className='interaction-modal__choices__choice'> + <h3><FormattedMessage id='interaction_modal.on_another_server' defaultMessage='On a different server' /></h3> + <p className='prose'> + <FormattedMessage + id='closed_registrations.other_server_instructions' + defaultMessage='Since Mastodon is decentralized, you can create an account on another server and still interact with this one.' + /> + </p> + <a href='https://joinmastodon.org/servers' className='button button--block'><FormattedMessage id='closed_registrations_modal.find_another_server' defaultMessage='Find another server' /></a> + </div> + </div> + </div> + ); + } + +} + +export default connect(mapStateToProps)(ClosedRegistrationsModal); diff --git a/app/javascript/flavours/blobfox/features/community_timeline/components/column_settings.jsx b/app/javascript/flavours/blobfox/features/community_timeline/components/column_settings.jsx new file mode 100644 index 00000000000000..b31d490ba2593c --- /dev/null +++ b/app/javascript/flavours/blobfox/features/community_timeline/components/column_settings.jsx @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + +import SettingText from 'flavours/blobfox/components/setting_text'; +import SettingToggle from 'flavours/blobfox/features/notifications/components/setting_toggle'; + +const messages = defineMessages({ + filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }, + settings: { id: 'home.settings', defaultMessage: 'Column settings' }, +}); + +class ColumnSettings extends PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + onChange: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + columnId: PropTypes.string, + }; + + render () { + const { settings, onChange, intl } = this.props; + + return ( + <div> + <div className='column-settings__row'> + <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} /> + </div> + + <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> + + <div className='column-settings__row'> + <SettingText settings={settings} settingPath={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> + </div> + </div> + ); + } + +} + +export default injectIntl(ColumnSettings); diff --git a/app/javascript/flavours/blobfox/features/community_timeline/containers/column_settings_container.js b/app/javascript/flavours/blobfox/features/community_timeline/containers/column_settings_container.js new file mode 100644 index 00000000000000..1e9f1213945a03 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/community_timeline/containers/column_settings_container.js @@ -0,0 +1,29 @@ +import { connect } from 'react-redux'; + +import { changeColumnParams } from '../../../actions/columns'; +import { changeSetting } from '../../../actions/settings'; +import ColumnSettings from '../components/column_settings'; + +const mapStateToProps = (state, { columnId }) => { + const uuid = columnId; + const columns = state.getIn(['settings', 'columns']); + const index = columns.findIndex(c => c.get('uuid') === uuid); + + return { + settings: (uuid && index >= 0) ? columns.get(index).get('params') : state.getIn(['settings', 'community']), + }; +}; + +const mapDispatchToProps = (dispatch, { columnId }) => { + return { + onChange (key, checked) { + if (columnId) { + dispatch(changeColumnParams(columnId, key, checked)); + } else { + dispatch(changeSetting(['community', ...key], checked)); + } + }, + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/flavours/blobfox/features/community_timeline/index.jsx b/app/javascript/flavours/blobfox/features/community_timeline/index.jsx new file mode 100644 index 00000000000000..3f715b644e6fd7 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/community_timeline/index.jsx @@ -0,0 +1,166 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import { connect } from 'react-redux'; + +import { DismissableBanner } from 'flavours/blobfox/components/dismissable_banner'; +import { domain } from 'flavours/blobfox/initial_state'; + +import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; +import { connectCommunityStream } from '../../actions/streaming'; +import { expandCommunityTimeline } from '../../actions/timelines'; +import Column from '../../components/column'; +import ColumnHeader from '../../components/column_header'; +import StatusListContainer from '../ui/containers/status_list_container'; + +import ColumnSettingsContainer from './containers/column_settings_container'; + +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 regex = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'regex', 'body']) : state.getIn(['settings', 'community', 'regex', 'body']); + const timelineState = state.getIn(['timelines', `community${onlyMedia ? ':media' : ''}`]); + + return { + hasUnread: !!timelineState && timelineState.get('unread') > 0, + onlyMedia, + regex, + }; +}; + +class CommunityTimeline extends PureComponent { + + static contextTypes = { + 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, + regex: PropTypes.string, + }; + + 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 ( + <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}> + <ColumnHeader + icon='users' + active={hasUnread} + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + > + <ColumnSettingsContainer columnId={columnId} /> + </ColumnHeader> + + <StatusListContainer + prepend={<DismissableBanner id='community_timeline'><FormattedMessage id='dismissable_banner.community_timeline' defaultMessage='These are the most recent public posts from people whose accounts are hosted by {domain}.' values={{ domain }} /></DismissableBanner>} + trackScroll={!pinned} + scrollKey={`community_timeline-${columnId}`} + timelineId={`community${onlyMedia ? ':media' : ''}`} + onLoadMore={this.handleLoadMore} + emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} + bindToDocument={!multiColumn} + regex={this.props.regex} + /> + + <Helmet> + <title>{intl.formatMessage(messages.title)}</title> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(CommunityTimeline)); diff --git a/app/javascript/flavours/blobfox/features/compose/components/action_bar.jsx b/app/javascript/flavours/blobfox/features/compose/components/action_bar.jsx new file mode 100644 index 00000000000000..2da781b792df0b --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/components/action_bar.jsx @@ -0,0 +1,73 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + +import { preferencesLink, profileLink } from 'flavours/blobfox/utils/backend_links'; + +import DropdownMenuContainer from '../../../containers/dropdown_menu_container'; + +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: 'Favorites' }, + 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' }, + filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' }, + logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, + bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, +}); + +class ActionBar extends PureComponent { + + static propTypes = { + account: ImmutablePropTypes.record.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: profileLink }); + menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink }); + 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 ( + <div className='compose__action-bar'> + <div className='compose__action-bar-dropdown'> + <DropdownMenuContainer items={menu} icon='bars' size={18} direction='right' /> + </div> + </div> + ); + } + +} + +export default injectIntl(ActionBar); diff --git a/app/javascript/flavours/blobfox/features/compose/components/autosuggest_account.jsx b/app/javascript/flavours/blobfox/features/compose/components/autosuggest_account.jsx new file mode 100644 index 00000000000000..8ffc9062e5c9fd --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/components/autosuggest_account.jsx @@ -0,0 +1,24 @@ +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { Avatar } from '../../../components/avatar'; +import { DisplayName } from '../../../components/display_name'; + +export default class AutosuggestAccount extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.record.isRequired, + }; + + render () { + const { account } = this.props; + + return ( + <div className='autosuggest-account' title={account.get('acct')}> + <div className='autosuggest-account-icon'><Avatar account={account} size={24} /></div> + <DisplayName account={account} inline /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/compose/components/character_counter.jsx b/app/javascript/flavours/blobfox/features/compose/components/character_counter.jsx new file mode 100644 index 00000000000000..42452b30f6af55 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/components/character_counter.jsx @@ -0,0 +1,26 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { length } from 'stringz'; + +export default class CharacterCounter extends PureComponent { + + static propTypes = { + text: PropTypes.string.isRequired, + max: PropTypes.number.isRequired, + }; + + checkRemainingText (diff) { + if (diff < 0) { + return <span className='character-counter character-counter--over'>{diff}</span>; + } + + return <span className='character-counter'>{diff}</span>; + } + + render () { + const diff = this.props.max - length(this.props.text); + return this.checkRemainingText(diff); + } + +} diff --git a/app/javascript/flavours/blobfox/features/compose/components/compose_form.jsx b/app/javascript/flavours/blobfox/features/compose/components/compose_form.jsx new file mode 100644 index 00000000000000..282a28ba711fa1 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/components/compose_form.jsx @@ -0,0 +1,356 @@ +import PropTypes from 'prop-types'; +import { createRef } from 'react'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { length } from 'stringz'; + +import { maxChars } from 'flavours/blobfox/initial_state'; +import { isMobile } from 'flavours/blobfox/is_mobile'; +import { WithOptionalRouterPropTypes, withOptionalRouter } from 'flavours/blobfox/utils/react_router'; + +import AutosuggestInput from '../../../components/autosuggest_input'; +import AutosuggestTextarea from '../../../components/autosuggest_textarea'; +import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; +import OptionsContainer from '../containers/options_container'; +import PollFormContainer from '../containers/poll_form_container'; +import ReplyIndicatorContainer from '../containers/reply_indicator_container'; +import UploadFormContainer from '../containers/upload_form_container'; +import WarningContainer from '../containers/warning_container'; +import { countableText } from '../util/counter'; + +import CharacterCounter from './character_counter'; +import Publisher from './publisher'; +import TextareaIcons from './textarea_icons'; + +const messages = defineMessages({ + placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, + missingDescriptionMessage: { + id: 'confirmations.missing_media_description.message', + defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.', + }, + missingDescriptionConfirm: { + id: 'confirmations.missing_media_description.confirm', + defaultMessage: 'Send anyway', + }, + spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' }, +}); + +class ComposeForm extends ImmutablePureComponent { + 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, + showSearch: PropTypes.bool, + anyMedia: PropTypes.bool, + isInReply: PropTypes.bool, + singleColumn: PropTypes.bool, + lang: PropTypes.string, + advancedOptions: ImmutablePropTypes.map, + layout: PropTypes.string, + media: ImmutablePropTypes.list, + sideArm: PropTypes.string, + sensitive: PropTypes.bool, + spoilersAlwaysOn: PropTypes.bool, + mediaDescriptionConfirmation: PropTypes.bool, + preselectOnReply: PropTypes.bool, + onChangeSpoilerness: PropTypes.func.isRequired, + onChangeVisibility: PropTypes.func.isRequired, + onMediaDescriptionConfirm: PropTypes.func.isRequired, + ...WithOptionalRouterPropTypes + }; + + static defaultProps = { + showSearch: false, + }; + + state = { + highlighted: false, + }; + + constructor(props) { + super(props); + this.textareaRef = createRef(null); + } + + handleChange = (e) => { + this.props.onChange(e.target.value); + }; + + handleKeyDown = (e) => { + if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + this.handleSubmit(); + } + + if (e.keyCode === 13 && e.altKey) { + this.handleSecondarySubmit(); + } + }; + + getFulltextForCharacterCounting = () => { + return [ + this.props.spoiler? this.props.spoilerText: '', + countableText(this.props.text), + this.props.advancedOptions && this.props.advancedOptions.get('do_not_federate') ? ' 👁️' : '', + ].join(''); + }; + + canSubmit = () => { + const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props; + const fulltext = this.getFulltextForCharacterCounting(); + + return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars || (!fulltext.trim().length && !anyMedia)); + }; + + handleSubmit = (e, overriddenVisibility = null) => { + if (this.props.text !== this.textareaRef.current.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.textareaRef.current.value); + } + + if (!this.canSubmit()) { + return; + } + + if (e) { + e.preventDefault(); + } + + // Submit unless there are media with missing descriptions + if (this.props.mediaDescriptionConfirmation && this.props.media && this.props.media.some(item => !item.get('description'))) { + const firstWithoutDescription = this.props.media.find(item => !item.get('description')); + this.props.onMediaDescriptionConfirm(this.props.history || null, firstWithoutDescription.get('id'), overriddenVisibility); + } else { + if (overriddenVisibility) { + this.props.onChangeVisibility(overriddenVisibility); + } + this.props.onSubmit(this.props.history || null); + } + }; + + // Handles the secondary submit button. + handleSecondarySubmit = () => { + const { sideArm } = this.props; + this.handleSubmit(null, sideArm === 'none' ? null : sideArm); + }; + + 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({ }); + } + + componentWillUnmount () { + if (this.timeout) clearTimeout(this.timeout); + } + + 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 && this.props.preselectOnReply) { + 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.textareaRef.current.setSelectionRange(selectionStart, selectionEnd); + this.textareaRef.current.focus(); + if (!this.props.singleColumn) this.textareaRef.current.scrollIntoView(); + this.setState({ highlighted: true }); + this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700); + }).catch(console.error); + } else if(prevProps.isSubmitting && !this.props.isSubmitting) { + this.textareaRef.current.focus(); + } else if (this.props.spoiler !== prevProps.spoiler) { + if (this.props.spoiler) { + this.spoilerText.input.focus(); + } else if (prevProps.spoiler) { + this.textareaRef.current.focus(); + } + } + }; + + setSpoilerText = (c) => { + this.spoilerText = c; + }; + + setRef = c => { + this.composeForm = c; + }; + + handleEmojiPick = (data) => { + const position = this.textareaRef.current.selectionStart; + + this.props.onPickEmoji(position, data); + }; + + render () { + const { + intl, + advancedOptions, + isSubmitting, + layout, + onChangeSpoilerness, + onPaste, + privacy, + sensitive, + showSearch, + sideArm, + spoilersAlwaysOn, + isEditing, + } = this.props; + const { highlighted } = this.state; + const disabled = this.props.isSubmitting; + + return ( + <form className='compose-form' onSubmit={this.handleSubmit}> + <WarningContainer /> + + <ReplyIndicatorContainer /> + + <div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef} aria-hidden={!this.props.spoiler}> + <AutosuggestInput + placeholder={intl.formatMessage(messages.spoiler_placeholder)} + value={this.props.spoilerText} + onChange={this.handleChangeSpoilerText} + onKeyDown={this.handleKeyDown} + disabled={!this.props.spoiler} + ref={this.setSpoilerText} + suggestions={this.props.suggestions} + onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} + onSuggestionsClearRequested={this.onSuggestionsClearRequested} + onSuggestionSelected={this.onSpoilerSuggestionSelected} + searchTokens={[':']} + id='cw-spoiler-input' + className='spoiler-input__input' + lang={this.props.lang} + autoFocus={false} + spellCheck + /> + </div> + + <div className={classNames('compose-form__highlightable', { active: highlighted })}> + <AutosuggestTextarea + ref={this.textareaRef} + placeholder={intl.formatMessage(messages.placeholder)} + disabled={disabled} + value={this.props.text} + onChange={this.handleChange} + suggestions={this.props.suggestions} + onFocus={this.handleFocus} + onKeyDown={this.handleKeyDown} + onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} + onSuggestionsClearRequested={this.onSuggestionsClearRequested} + onSuggestionSelected={this.onSuggestionSelected} + onPaste={onPaste} + autoFocus={!showSearch && !isMobile(window.innerWidth, layout)} + lang={this.props.lang} + > + <TextareaIcons advancedOptions={advancedOptions} /> + <div className='compose-form__modifiers'> + <UploadFormContainer /> + <PollFormContainer /> + </div> + </AutosuggestTextarea> + <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} /> + + <div className='compose-form__buttons-wrapper'> + <OptionsContainer + advancedOptions={advancedOptions} + disabled={isSubmitting} + onToggleSpoiler={this.props.spoilersAlwaysOn ? null : onChangeSpoilerness} + onUpload={onPaste} + isEditing={isEditing} + sensitive={sensitive || (spoilersAlwaysOn && this.props.spoilerText && this.props.spoilerText.length > 0)} + spoiler={spoilersAlwaysOn ? (this.props.spoilerText && this.props.spoilerText.length > 0) : this.props.spoiler} + /> + <div className='character-counter__wrapper'> + <CharacterCounter max={maxChars} text={this.getFulltextForCharacterCounting()} /> + </div> + </div> + </div> + + <Publisher + disabled={!this.canSubmit()} + isEditing={isEditing} + onSecondarySubmit={this.handleSecondarySubmit} + privacy={privacy} + sideArm={sideArm} + /> + </form> + ); + } + +} + +export default withOptionalRouter(injectIntl(ComposeForm)); diff --git a/app/javascript/flavours/blobfox/features/compose/components/dropdown.jsx b/app/javascript/flavours/blobfox/features/compose/components/dropdown.jsx new file mode 100644 index 00000000000000..5f2de1189bfe25 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/components/dropdown.jsx @@ -0,0 +1,243 @@ +// Package imports. +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import classNames from 'classnames'; + +import Overlay from 'react-overlays/Overlay'; + +// Components. +import { IconButton } from 'flavours/blobfox/components/icon_button'; + +import DropdownMenu from './dropdown_menu'; + +// The component. +export default class ComposerOptionsDropdown extends PureComponent { + + static propTypes = { + isUserTouching: PropTypes.func, + disabled: PropTypes.bool, + icon: PropTypes.string, + items: PropTypes.arrayOf(PropTypes.shape({ + icon: PropTypes.string, + meta: PropTypes.string, + name: PropTypes.string.isRequired, + text: PropTypes.string, + })).isRequired, + onModalOpen: PropTypes.func, + onModalClose: PropTypes.func, + title: PropTypes.string, + value: PropTypes.string, + onChange: PropTypes.func, + container: PropTypes.func, + renderItemContents: PropTypes.func, + closeOnChange: PropTypes.bool, + }; + + static defaultProps = { + closeOnChange: true, + }; + + state = { + open: false, + openedViaKeyboard: undefined, + placement: 'bottom', + }; + + // Toggles opening and closing the dropdown. + handleToggle = ({ type }) => { + const { onModalOpen } = this.props; + const { open } = this.state; + + if (this.props.isUserTouching && this.props.isUserTouching()) { + if (open) { + this.props.onModalClose(); + } else { + const modal = this.handleMakeModal(); + if (modal && onModalOpen) { + onModalOpen(modal); + } + } + } else { + if (open && this.activeElement) { + this.activeElement.focus({ preventScroll: true }); + } + this.setState({ open: !open, openedViaKeyboard: type !== 'click' }); + } + }; + + 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; + } + }; + + handleKeyPress = (e) => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleToggle(e); + e.stopPropagation(); + e.preventDefault(); + break; + } + }; + + handleClose = () => { + if (this.state.open && this.activeElement) { + this.activeElement.focus({ preventScroll: true }); + } + this.setState({ open: false }); + }; + + handleItemClick = (e) => { + const { + items, + onChange, + onModalClose, + closeOnChange, + } = this.props; + + const i = Number(e.currentTarget.getAttribute('data-index')); + + const { name } = items[i]; + + e.preventDefault(); // Prevents focus from changing + if (closeOnChange) onModalClose(); + onChange(name); + }; + + // Creates an action modal object. + handleMakeModal = () => { + const { + items, + onChange, + onModalOpen, + onModalClose, + value, + } = this.props; + + // Required props. + if (!(onChange && onModalOpen && onModalClose && items)) { + return null; + } + + // The object. + return { + renderItemContents: this.props.renderItemContents, + onClick: this.handleItemClick, + actions: items.map( + ({ + name, + ...rest + }) => ({ + ...rest, + active: value && name === value, + name, + }), + ), + }; + }; + + setTargetRef = c => { + this.target = c; + }; + + findTarget = () => { + return this.target; + }; + + handleOverlayEnter = (state) => { + this.setState({ placement: state.placement }); + }; + + // Rendering. + render () { + const { + disabled, + title, + icon, + items, + onChange, + value, + container, + renderItemContents, + closeOnChange, + } = this.props; + const { open, placement } = this.state; + + const active = value && items.findIndex(({ name }) => name === value) === (placement === 'bottom' ? 0 : (items.length - 1)); + + return ( + <div + className={classNames('privacy-dropdown', placement, { active: open })} + onKeyDown={this.handleKeyDown} + ref={this.setTargetRef} + > + <div className={classNames('privacy-dropdown__value', { active })}> + <IconButton + active={open} + className='privacy-dropdown__value-icon' + disabled={disabled} + icon={icon} + inverted + onClick={this.handleToggle} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleButtonKeyDown} + onKeyPress={this.handleKeyPress} + size={18} + style={{ + height: null, + lineHeight: '27px', + }} + title={title} + /> + </div> + + <Overlay + containerPadding={20} + placement={placement} + show={open} + flip + target={this.findTarget} + container={container} + popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }} + > + {({ props, placement }) => ( + <div {...props}> + <div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}> + <DropdownMenu + items={items} + renderItemContents={renderItemContents} + onChange={onChange} + onClose={this.handleClose} + value={value} + openedViaKeyboard={this.state.openedViaKeyboard} + closeOnChange={closeOnChange} + /> + </div> + </div> + )} + </Overlay> + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/compose/components/dropdown_menu.jsx b/app/javascript/flavours/blobfox/features/compose/components/dropdown_menu.jsx new file mode 100644 index 00000000000000..45b94dedee902c --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/components/dropdown_menu.jsx @@ -0,0 +1,200 @@ +// Package imports. +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import classNames from 'classnames'; + +import { supportsPassiveEvents } from 'detect-passive-events'; + +// Components. +import { Icon } from 'flavours/blobfox/components/icon'; + +const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true; + +// The component. +export default class ComposerOptionsDropdownContent extends PureComponent { + + static propTypes = { + items: PropTypes.arrayOf(PropTypes.shape({ + icon: PropTypes.string, + meta: PropTypes.node, + name: PropTypes.string.isRequired, + text: PropTypes.node, + })), + onChange: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + style: PropTypes.object, + value: PropTypes.string, + renderItemContents: PropTypes.func, + openedViaKeyboard: PropTypes.bool, + closeOnChange: PropTypes.bool, + }; + + static defaultProps = { + style: {}, + closeOnChange: true, + }; + + state = { + value: this.props.openedViaKeyboard ? this.props.items[0].name : undefined, + }; + + // When the document is clicked elsewhere, we close the dropdown. + handleDocumentClick = (e) => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); + e.stopPropagation(); + } + }; + + // Stores our node in `this.node`. + setRef = (node) => { + this.node = node; + }; + + // On mounting, we add our listeners. + componentDidMount () { + document.addEventListener('click', this.handleDocumentClick, { capture: true }); + document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); + if (this.focusedItem) { + this.focusedItem.focus({ preventScroll: true }); + } else { + this.node.firstChild.focus({ preventScroll: true }); + } + } + + // On unmounting, we remove our listeners. + componentWillUnmount () { + document.removeEventListener('click', this.handleDocumentClick, { capture: true }); + document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + handleClick = (e) => { + const i = Number(e.currentTarget.getAttribute('data-index')); + + const { + onChange, + onClose, + closeOnChange, + items, + } = this.props; + + const { name } = items[i]; + + e.preventDefault(); // Prevents change in focus on click + if (closeOnChange) { + onClose(); + } + onChange(name); + }; + + // Handle changes differently whether the dropdown is a list of options or actions + handleChange = (name) => { + if (this.props.value) { + this.props.onChange(name); + } else { + this.setState({ value: name }); + } + }; + + handleKeyDown = (e) => { + const index = Number(e.currentTarget.getAttribute('data-index')); + const { items } = this.props; + let element = null; + + switch(e.key) { + case 'Escape': + this.props.onClose(); + break; + case 'Enter': + case ' ': + 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.handleChange(items[Number(element.getAttribute('data-index'))].name); + e.preventDefault(); + e.stopPropagation(); + } + }; + + setFocusRef = c => { + this.focusedItem = c; + }; + + renderItem = (item, i) => { + const { name, icon, meta, text } = item; + + const active = (name === (this.props.value || this.state.value)); + + const computedClass = classNames('privacy-dropdown__option', { active }); + + let contents = this.props.renderItemContents && this.props.renderItemContents(item, i); + + if (!contents) { + contents = ( + <> + {icon && <Icon className='icon' fixedWidth id={icon} />} + + <div className='privacy-dropdown__option__content'> + <strong>{text}</strong> + {meta} + </div> + </> + ); + } + + return ( + <div + className={computedClass} + onClick={this.handleClick} + onKeyDown={this.handleKeyDown} + role='option' + aria-selected={active} + tabIndex={0} + key={name} + data-index={i} + ref={active ? this.setFocusRef : null} + > + {contents} + </div> + ); + }; + + // Rendering. + render () { + const { + items, + style, + } = this.props; + + // The result. + return ( + <div style={{ ...style }} role='listbox' ref={this.setRef}> + {!!items && items.map((item, i) => this.renderItem(item, i))} + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/compose/components/emoji_picker_dropdown.jsx b/app/javascript/flavours/blobfox/features/compose/components/emoji_picker_dropdown.jsx new file mode 100644 index 00000000000000..e228f9c205f922 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/components/emoji_picker_dropdown.jsx @@ -0,0 +1,422 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + +import { supportsPassiveEvents } from 'detect-passive-events'; +import Overlay from 'react-overlays/Overlay'; + +import { useSystemEmojiFont } from 'flavours/blobfox/initial_state'; +import { assetHost } from 'flavours/blobfox/utils/config'; + +import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji'; +import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components'; + +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, capture: true } : true; + +const backgroundImageFn = () => `${assetHost}/emoji/sheet_13.png`; + +const notFoundFn = () => ( + <div className='emoji-mart-no-results'> + <Emoji + emoji='sleuth_or_spy' + set='twitter' + size={32} + sheetSize={32} + backgroundImageFn={backgroundImageFn} + /> + + <div className='emoji-mart-no-results-label'> + <FormattedMessage id='emoji_button.not_found' defaultMessage='No matching emojis found' /> + </div> + </div> +); + +class ModifierPickerMenu extends 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); + }; + + UNSAFE_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, { capture: true }); + document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + removeListeners () { + document.removeEventListener('click', this.handleDocumentClick, { capture: true }); + document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + setRef = c => { + this.node = c; + }; + + render () { + const { active } = this.props; + + return ( + <div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}> + <button type='button' onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button> + <button type='button' onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button> + <button type='button' onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button> + <button type='button' onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button> + <button type='button' onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button> + <button type='button' onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button> + </div> + ); + } + +} + +class ModifierPicker extends 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 ( + <div className='emoji-picker-dropdown__modifiers'> + <Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /> + <ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} /> + </div> + ); + } + +} + +class EmojiPickerMenuImpl extends 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, { capture: true }); + 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, { capture: true }); + 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 <div style={{ width: 299 }} />; + } + + 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 ( + <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}> + <EmojiPicker + perLine={8} + emojiSize={22} + sheetSize={32} + custom={buildCustomEmojis(custom_emojis)} + color='' + emoji='' + set='twitter' + title={title} + i18n={this.getI18n()} + onClick={this.handleClick} + include={categoriesSort} + recent={frequentlyUsedEmojis} + skin={skinTone} + showPreview={false} + showSkinTones={false} + backgroundImageFn={backgroundImageFn} + notFound={notFoundFn} + autoFocus={this.state.readyToFocus} + emojiTooltip + native={useSystemEmojiFont} + /> + + <ModifierPicker + active={modifierOpen} + modifier={skinTone} + onOpen={this.handleModifierOpen} + onClose={this.handleModifierClose} + onChange={this.handleModifierChange} + /> + </div> + ); + } + +} + +const EmojiPickerMenu = injectIntl(EmojiPickerMenuImpl); + +class EmojiPickerDropdown extends 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, + disabled: PropTypes.bool, + }; + + 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.disabled && !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 ( + <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}> + <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}> + {button || <img + className={classNames('emojione', { 'pulse-loading': active && loading })} + alt='🙂' + src={`${assetHost}/emoji/1f642.svg`} + />} + </div> + + <Overlay show={active} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed' }}> + {({ props, placement })=> ( + <div {...props} style={{ ...props.style, width: 299 }}> + <div className={`dropdown-animation ${placement}`}> + <EmojiPickerMenu + custom_emojis={this.props.custom_emojis} + loading={loading} + onClose={this.onHideDropdown} + onPick={onPickEmoji} + onSkinTone={onSkinTone} + skinTone={skinTone} + frequentlyUsedEmojis={frequentlyUsedEmojis} + /> + </div> + </div> + )} + </Overlay> + </div> + ); + } + +} + +export default injectIntl(EmojiPickerDropdown); diff --git a/app/javascript/flavours/blobfox/features/compose/components/header.jsx b/app/javascript/flavours/blobfox/features/compose/components/header.jsx new file mode 100644 index 00000000000000..a8d3e080a04b7b --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/components/header.jsx @@ -0,0 +1,134 @@ +import PropTypes from 'prop-types'; + +import { injectIntl, defineMessages } from 'react-intl'; + +import { Link } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { Icon } from 'flavours/blobfox/components/icon'; +import { signOutLink } from 'flavours/blobfox/utils/backend_links'; +import { conditionalRender } from 'flavours/blobfox/utils/react_helpers'; + +const messages = defineMessages({ + community: { + defaultMessage: 'Local timeline', + id: 'navigation_bar.community_timeline', + }, + home_timeline: { + defaultMessage: 'Home', + id: 'tabs_bar.home', + }, + logout: { + defaultMessage: 'Logout', + id: 'navigation_bar.logout', + }, + notifications: { + defaultMessage: 'Notifications', + id: 'tabs_bar.notifications', + }, + public: { + defaultMessage: 'Federated timeline', + id: 'navigation_bar.public_timeline', + }, + settings: { + defaultMessage: 'App settings', + id: 'navigation_bar.app_settings', + }, + start: { + defaultMessage: 'Getting started', + id: 'getting_started.heading', + }, +}); + +class Header extends ImmutablePureComponent { + + static propTypes = { + columns: ImmutablePropTypes.list, + unreadNotifications: PropTypes.number, + showNotificationsBadge: PropTypes.bool, + intl: PropTypes.object, + onSettingsClick: PropTypes.func, + onLogout: PropTypes.func.isRequired, + }; + + handleLogoutClick = e => { + e.preventDefault(); + e.stopPropagation(); + + this.props.onLogout(); + + return false; + }; + + render () { + const { intl, columns, unreadNotifications, showNotificationsBadge, onSettingsClick } = this.props; + + // Only renders the component if the column isn't being shown. + const renderForColumn = conditionalRender.bind(null, + columnId => !columns || !columns.some( + column => column.get('id') === columnId, + ), + ); + + // The result. + return ( + <nav className='drawer__header'> + <Link + aria-label={intl.formatMessage(messages.start)} + title={intl.formatMessage(messages.start)} + to='/getting-started' + ><Icon id='asterisk' /></Link> + {renderForColumn('HOME', ( + <Link + aria-label={intl.formatMessage(messages.home_timeline)} + title={intl.formatMessage(messages.home_timeline)} + to='/home' + ><Icon id='home' /></Link> + ))} + {renderForColumn('NOTIFICATIONS', ( + <Link + aria-label={intl.formatMessage(messages.notifications)} + title={intl.formatMessage(messages.notifications)} + to='/notifications' + > + <span className='icon-badge-wrapper'> + <Icon id='bell' /> + { showNotificationsBadge && unreadNotifications > 0 && <div className='icon-badge' />} + </span> + </Link> + ))} + {renderForColumn('COMMUNITY', ( + <Link + aria-label={intl.formatMessage(messages.community)} + title={intl.formatMessage(messages.community)} + to='/public/local' + ><Icon id='users' /></Link> + ))} + {renderForColumn('PUBLIC', ( + <Link + aria-label={intl.formatMessage(messages.public)} + title={intl.formatMessage(messages.public)} + to='/public' + ><Icon id='globe' /></Link> + ))} + <a + aria-label={intl.formatMessage(messages.settings)} + onClick={onSettingsClick} + href='/settings/preferences' + title={intl.formatMessage(messages.settings)} + ><Icon id='cogs' /></a> + <a + aria-label={intl.formatMessage(messages.logout)} + onClick={this.handleLogoutClick} + href={signOutLink} + title={intl.formatMessage(messages.logout)} + ><Icon id='sign-out' /></a> + </nav> + ); + } + +} + +export default injectIntl(Header); diff --git a/app/javascript/flavours/blobfox/features/compose/components/language_dropdown.jsx b/app/javascript/flavours/blobfox/features/compose/components/language_dropdown.jsx new file mode 100644 index 00000000000000..53ef6ebd63716b --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/components/language_dropdown.jsx @@ -0,0 +1,334 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { injectIntl, defineMessages } from 'react-intl'; + +import classNames from 'classnames'; + +import { supportsPassiveEvents } from 'detect-passive-events'; +import fuzzysort from 'fuzzysort'; +import Overlay from 'react-overlays/Overlay'; + +import { languages as preloadedLanguages } from 'flavours/blobfox/initial_state'; +import { loupeIcon, deleteIcon } from 'flavours/blobfox/utils/icons'; + +import TextIconButton from './text_icon_button'; + +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, capture: true } : true; + +class LanguageDropdownMenu extends 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(); + e.stopPropagation(); + } + }; + + componentDidMount () { + document.addEventListener('click', this.handleDocumentClick, { capture: true }); + 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, { capture: true }); + 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 ( + <div key={lang[0]} role='option' tabIndex={0} data-index={lang[0]} className={classNames('language-dropdown__dropdown__results__item', { active: lang[0] === value })} aria-selected={lang[0] === value} onClick={this.handleClick} onKeyDown={this.handleKeyDown}> + <span className='language-dropdown__dropdown__results__item__native-name' lang={lang[0]}>{lang[2]}</span> <span className='language-dropdown__dropdown__results__item__common-name'>({lang[1]})</span> + </div> + ); + }; + + render () { + const { intl } = this.props; + const { searchValue } = this.state; + const isSearching = searchValue !== ''; + const results = this.search(); + + return ( + <div ref={this.setRef}> + <div className='emoji-mart-search'> + <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} /> + <button type='button' className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button> + </div> + + <div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}> + {results.map(this.renderItem)} + </div> + </div> + ); + } + +} + +class LanguageDropdown extends 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 ( + <div className={classNames('privacy-dropdown', placement, { active: open })}> + <div className='privacy-dropdown__value' ref={this.setTargetRef} > + <TextIconButton + className='privacy-dropdown__value-icon' + label={value && value.toUpperCase()} + title={intl.formatMessage(messages.changeLanguage)} + active={open} + onClick={this.handleToggle} + /> + </div> + + <Overlay show={open} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}> + {({ props, placement }) => ( + <div {...props}> + <div className={`dropdown-animation language-dropdown__dropdown ${placement}`} > + <LanguageDropdownMenu + value={value} + frequentlyUsedLanguages={frequentlyUsedLanguages} + onClose={this.handleClose} + onChange={this.handleChange} + intl={intl} + /> + </div> + </div> + )} + </Overlay> + </div> + ); + } + +} + +export default injectIntl(LanguageDropdown); diff --git a/app/javascript/flavours/blobfox/features/compose/components/navigation_bar.jsx b/app/javascript/flavours/blobfox/features/compose/components/navigation_bar.jsx new file mode 100644 index 00000000000000..b9601dd59ac49a --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/components/navigation_bar.jsx @@ -0,0 +1,54 @@ +import PropTypes from 'prop-types'; + +import { FormattedMessage } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import Permalink from 'flavours/blobfox/components/permalink'; +import { profileLink } from 'flavours/blobfox/utils/backend_links'; + +import { Avatar } from '../../../components/avatar'; + +import ActionBar from './action_bar'; + +export default class NavigationBar extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.record.isRequired, + onLogout: PropTypes.func.isRequired, + onClose: PropTypes.func, + }; + + render () { + const username = this.props.account.get('acct'); + return ( + <div className='navigation-bar'> + <Permalink className='avatar' href={this.props.account.get('url')} to={`/@${username}`}> + <span style={{ display: 'none' }}>{username}</span> + <Avatar account={this.props.account} size={46} /> + </Permalink> + + <div className='navigation-bar__profile'> + <span> + <Permalink className='acct' href={this.props.account.get('url')} to={`/@${username}`}> + <strong className='navigation-bar__profile-account'>@{username}</strong> + </Permalink> + </span> + + { profileLink !== undefined && ( + <a + className='edit' + href={profileLink} + ><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a> + )} + </div> + + <div className='navigation-bar__actions'> + <ActionBar account={this.props.account} onLogout={this.props.onLogout} /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/compose/components/options.jsx b/app/javascript/flavours/blobfox/features/compose/components/options.jsx new file mode 100644 index 00000000000000..e3f46d97486b03 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/components/options.jsx @@ -0,0 +1,311 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import Toggle from 'react-toggle'; + +import { IconButton } from 'flavours/blobfox/components/icon_button'; +import { pollLimits } from 'flavours/blobfox/initial_state'; + +import DropdownContainer from '../containers/dropdown_container'; +import LanguageDropdown from '../containers/language_dropdown_container'; +import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; + +import TextIconButton from './text_icon_button'; + +const messages = defineMessages({ + advanced_options_icon_title: { + defaultMessage: 'Advanced options', + id: 'advanced_options.icon_title', + }, + attach: { + defaultMessage: 'Attach...', + id: 'compose.attach', + }, + content_type: { + defaultMessage: 'Content type', + id: 'content-type.change', + }, + doodle: { + defaultMessage: 'Draw something', + id: 'compose.attach.doodle', + }, + html: { + defaultMessage: 'HTML', + id: 'compose.content-type.html', + }, + local_only_long: { + defaultMessage: 'Do not post to other instances', + id: 'advanced_options.local-only.long', + }, + local_only_short: { + defaultMessage: 'Local-only', + id: 'advanced_options.local-only.short', + }, + markdown: { + defaultMessage: 'Markdown', + id: 'compose.content-type.markdown', + }, + plain: { + defaultMessage: 'Plain text', + id: 'compose.content-type.plain', + }, + spoiler: { + defaultMessage: 'Hide text behind warning', + id: 'compose_form.spoiler', + }, + threaded_mode_long: { + defaultMessage: 'Automatically opens a reply on posting', + id: 'advanced_options.threaded_mode.long', + }, + threaded_mode_short: { + defaultMessage: 'Threaded mode', + id: 'advanced_options.threaded_mode.short', + }, + upload: { + defaultMessage: 'Upload a file', + id: 'compose.attach.upload', + }, + add_poll: { + defaultMessage: 'Add a poll', + id: 'poll_button.add_poll', + }, + remove_poll: { + defaultMessage: 'Remove poll', + id: 'poll_button.remove_poll', + }, +}); + +const mapStateToProps = (state, { name }) => ({ + checked: state.getIn(['compose', 'advanced_options', name]), +}); + +class ToggleOptionImpl extends ImmutablePureComponent { + + static propTypes = { + name: PropTypes.string.isRequired, + checked: PropTypes.bool, + onChangeAdvancedOption: PropTypes.func.isRequired, + }; + + handleChange = () => { + this.props.onChangeAdvancedOption(this.props.name); + }; + + render() { + const { meta, text, checked } = this.props; + + return ( + <> + <Toggle checked={checked} onChange={this.handleChange} /> + + <div className='privacy-dropdown__option__content'> + <strong>{text}</strong> + {meta} + </div> + </> + ); + } + +} + +const ToggleOption = connect(mapStateToProps)(ToggleOptionImpl); + +class ComposerOptions extends ImmutablePureComponent { + + static propTypes = { + acceptContentTypes: PropTypes.string, + advancedOptions: ImmutablePropTypes.map, + disabled: PropTypes.bool, + allowMedia: PropTypes.bool, + allowPoll: PropTypes.bool, + hasPoll: PropTypes.bool, + intl: PropTypes.object.isRequired, + onChangeAdvancedOption: PropTypes.func.isRequired, + onChangeContentType: PropTypes.func.isRequired, + onTogglePoll: PropTypes.func.isRequired, + onDoodleOpen: PropTypes.func.isRequired, + onToggleSpoiler: PropTypes.func, + onUpload: PropTypes.func.isRequired, + contentType: PropTypes.string, + resetFileKey: PropTypes.number, + spoiler: PropTypes.bool, + showContentTypeChoice: PropTypes.bool, + isEditing: PropTypes.bool, + }; + + handleChangeFiles = ({ target: { files } }) => { + const { onUpload } = this.props; + if (files.length) { + onUpload(files); + } + }; + + handleClickAttach = (name) => { + const { fileElement } = this; + const { onDoodleOpen } = this.props; + + switch (name) { + case 'upload': + if (fileElement) { + fileElement.click(); + } + return; + case 'doodle': + onDoodleOpen(); + return; + } + }; + + handleRefFileElement = (fileElement) => { + this.fileElement = fileElement; + }; + + renderToggleItemContents = (item) => { + const { onChangeAdvancedOption } = this.props; + const { name, meta, text } = item; + + return <ToggleOption name={name} text={text} meta={meta} onChangeAdvancedOption={onChangeAdvancedOption} />; + }; + + render () { + const { + acceptContentTypes, + advancedOptions, + contentType, + disabled, + allowMedia, + allowPoll, + hasPoll, + onChangeAdvancedOption, + onChangeContentType, + onTogglePoll, + onToggleSpoiler, + resetFileKey, + spoiler, + showContentTypeChoice, + isEditing, + intl: { formatMessage }, + } = this.props; + + const contentTypeItems = { + plain: { + icon: 'file-text', + name: 'text/plain', + text: formatMessage(messages.plain), + }, + html: { + icon: 'code', + name: 'text/html', + text: formatMessage(messages.html), + }, + markdown: { + icon: 'arrow-circle-down', + name: 'text/markdown', + text: formatMessage(messages.markdown), + }, + }; + + // The result. + return ( + <div className='compose-form__buttons'> + <input + accept={acceptContentTypes} + disabled={disabled || !allowMedia} + key={resetFileKey} + onChange={this.handleChangeFiles} + ref={this.handleRefFileElement} + type='file' + multiple + style={{ display: 'none' }} + /> + <DropdownContainer + disabled={disabled || !allowMedia} + icon='paperclip' + items={[ + { + icon: 'cloud-upload', + name: 'upload', + text: formatMessage(messages.upload), + }, + { + icon: 'paint-brush', + name: 'doodle', + text: formatMessage(messages.doodle), + }, + ]} + onChange={this.handleClickAttach} + title={formatMessage(messages.attach)} + /> + {!!pollLimits && ( + <IconButton + active={hasPoll} + disabled={disabled || !allowPoll} + icon='tasks' + inverted + onClick={onTogglePoll} + size={18} + style={{ + height: null, + lineHeight: null, + }} + title={formatMessage(hasPoll ? messages.remove_poll : messages.add_poll)} + /> + )} + <hr /> + <PrivacyDropdownContainer disabled={disabled || isEditing} /> + {showContentTypeChoice && ( + <DropdownContainer + disabled={disabled} + icon={(contentTypeItems[contentType.split('/')[1]] || {}).icon} + items={[ + contentTypeItems.plain, + contentTypeItems.html, + contentTypeItems.markdown, + ]} + onChange={onChangeContentType} + title={formatMessage(messages.content_type)} + value={contentType} + /> + )} + {onToggleSpoiler && ( + <TextIconButton + active={spoiler} + ariaControls='cw-spoiler-input' + label='CW' + onClick={onToggleSpoiler} + title={formatMessage(messages.spoiler)} + /> + )} + <LanguageDropdown /> + <DropdownContainer + disabled={disabled || isEditing} + icon='ellipsis-h' + items={advancedOptions ? [ + { + meta: formatMessage(messages.local_only_long), + name: 'do_not_federate', + text: formatMessage(messages.local_only_short), + }, + { + meta: formatMessage(messages.threaded_mode_long), + name: 'threaded_mode', + text: formatMessage(messages.threaded_mode_short), + }, + ] : null} + onChange={onChangeAdvancedOption} + renderItemContents={this.renderToggleItemContents} + title={formatMessage(messages.advanced_options_icon_title)} + closeOnChange={false} + /> + </div> + ); + } + +} + +export default injectIntl(ComposerOptions); diff --git a/app/javascript/flavours/blobfox/features/compose/components/poll_form.jsx b/app/javascript/flavours/blobfox/features/compose/components/poll_form.jsx new file mode 100644 index 00000000000000..008888265091d2 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/components/poll_form.jsx @@ -0,0 +1,181 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import AutosuggestInput from 'flavours/blobfox/components/autosuggest_input'; +import { Icon } from 'flavours/blobfox/components/icon'; +import { IconButton } from 'flavours/blobfox/components/icon_button'; +import { pollLimits } from 'flavours/blobfox/initial_state'; + +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' }, + single_choice: { id: 'compose_form.poll.single_choice', defaultMessage: 'Allow one choice' }, + multiple_choices: { id: 'compose_form.poll.multiple_choices', defaultMessage: 'Allow multiple choices' }, + 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}}' }, +}); + +class OptionIntl extends 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, + 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); + }; + + 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 ( + <li> + <label className='poll__option editable'> + <span className={classNames('poll__input', { checkbox: isPollMultiple })} /> + + <AutosuggestInput + placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })} + maxLength={pollLimits.max_option_chars} + value={title} + lang={lang} + spellCheck + onChange={this.handleOptionTitleChange} + suggestions={this.props.suggestions} + onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} + onSuggestionsClearRequested={this.onSuggestionsClearRequested} + onSuggestionSelected={this.onSuggestionSelected} + searchTokens={[':']} + autoFocus={autoFocus} + /> + </label> + + <div className='poll__cancel'> + <IconButton disabled={index < 1} title={intl.formatMessage(messages.remove_option)} icon='times' onClick={this.handleOptionRemove} /> + </div> + </li> + ); + } + +} + +const Option = injectIntl(OptionIntl); + +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); + }; + + handleSelectMultiple = e => { + this.props.onChangeSettings(this.props.expiresIn, e.target.value === 'true'); + }; + + render () { + const { options, lang, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl, ...other } = this.props; + + if (!options) { + return null; + } + + const autoFocusIndex = options.indexOf(''); + + return ( + <div className='compose-form__poll-wrapper'> + <ul> + {options.map((title, i) => <Option title={title} lang={lang} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} autoFocus={i === autoFocusIndex} {...other} />)} + {options.size < pollLimits.max_options && ( + <label className='poll__text editable'> + <span className={classNames('poll__input')} style={{ opacity: 0 }} /> + <button className='button button-secondary' onClick={this.handleAddOption} type='button'><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button> + </label> + )} + </ul> + + <div className='poll__footer'> + {/* eslint-disable-next-line jsx-a11y/no-onchange */} + <select value={isMultiple ? 'true' : 'false'} onChange={this.handleSelectMultiple}> + <option value='false'>{intl.formatMessage(messages.single_choice)}</option> + <option value='true'>{intl.formatMessage(messages.multiple_choices)}</option> + </select> + + {/* eslint-disable-next-line jsx-a11y/no-onchange */} + <select value={expiresIn} onChange={this.handleSelectDuration}> + <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option> + <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option> + <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option> + <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option> + <option value={43200}>{intl.formatMessage(messages.hours, { number: 12 })}</option> + <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option> + <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option> + <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option> + <option value={1209600}>{intl.formatMessage(messages.days, { number: 14 })}</option> + <option value={2629746}>{intl.formatMessage(messages.days, { number: 30 })}</option> + <option value={7889238}>{intl.formatMessage(messages.days, { number: 91 })}</option> + <option value={2629746}>{intl.formatMessage(messages.days, { number: 182 })}</option> + <option value={31536000}>{intl.formatMessage(messages.days, { number: 365 })}</option> + </select> + </div> + </div> + ); + } + +} + +export default injectIntl(PollForm); diff --git a/app/javascript/flavours/blobfox/features/compose/components/privacy_dropdown.jsx b/app/javascript/flavours/blobfox/features/compose/components/privacy_dropdown.jsx new file mode 100644 index 00000000000000..06775230fe40d0 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/components/privacy_dropdown.jsx @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { injectIntl, defineMessages } from 'react-intl'; + +import Dropdown from './dropdown'; + +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' }, +}); + +class PrivacyDropdown extends 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, + }; + + render () { + const { value, onChange, onModalOpen, onModalClose, disabled, noDirect, container, isUserTouching, intl: { formatMessage } } = this.props; + + // We predefine our privacy items so that we can easily pick the + // dropdown icon later. + const privacyItems = { + direct: { + icon: 'envelope', + meta: formatMessage(messages.direct_long), + name: 'direct', + text: formatMessage(messages.direct_short), + }, + private: { + icon: 'lock', + meta: formatMessage(messages.private_long), + name: 'private', + text: formatMessage(messages.private_short), + }, + public: { + icon: 'globe', + meta: formatMessage(messages.public_long), + name: 'public', + text: formatMessage(messages.public_short), + }, + unlisted: { + icon: 'unlock', + meta: formatMessage(messages.unlisted_long), + name: 'unlisted', + text: formatMessage(messages.unlisted_short), + }, + }; + + const items = [privacyItems.public, privacyItems.unlisted, privacyItems.private]; + + if (!noDirect) { + items.push(privacyItems.direct); + } + + return ( + <Dropdown + disabled={disabled} + icon={(privacyItems[value] || {}).icon} + items={items} + onChange={onChange} + isUserTouching={isUserTouching} + onModalClose={onModalClose} + onModalOpen={onModalOpen} + title={formatMessage(messages.change_privacy)} + container={container} + value={value} + /> + ); + } + +} + +export default injectIntl(PrivacyDropdown); diff --git a/app/javascript/flavours/blobfox/features/compose/components/publisher.jsx b/app/javascript/flavours/blobfox/features/compose/components/publisher.jsx new file mode 100644 index 00000000000000..df71df09818aa1 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/components/publisher.jsx @@ -0,0 +1,92 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { Button } from 'flavours/blobfox/components/button'; +import { Icon } from 'flavours/blobfox/components/icon'; + +const messages = defineMessages({ + publish: { + defaultMessage: 'Publish', + id: 'compose_form.publish', + }, + publishLoud: { + defaultMessage: '{publish}!', + id: 'compose_form.publish_loud', + }, + saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' }, + public: { id: 'privacy.public.short', defaultMessage: 'Public' }, + unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, + private: { id: 'privacy.private.short', defaultMessage: 'Followers only' }, + direct: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, +}); + +class Publisher extends ImmutablePureComponent { + + static propTypes = { + disabled: PropTypes.bool, + intl: PropTypes.object.isRequired, + onSecondarySubmit: PropTypes.func, + privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']), + sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']), + isEditing: PropTypes.bool, + }; + + render () { + const { disabled, intl, onSecondarySubmit, privacy, sideArm, isEditing } = this.props; + + const privacyIcons = { direct: 'envelope', private: 'lock', public: 'globe', unlisted: 'unlock' }; + + let publishText; + if (isEditing) { + publishText = intl.formatMessage(messages.saveChanges); + } else if (privacy === 'private' || privacy === 'direct') { + const iconId = privacyIcons[privacy]; + publishText = ( + <span> + <Icon id={iconId} /> {intl.formatMessage(messages.publish)} + </span> + ); + } else { + publishText = privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish); + } + + const privacyNames = { + public: messages.public, + unlisted: messages.unlisted, + private: messages.private, + direct: messages.direct, + }; + + return ( + <div className='compose-form__publish'> + {sideArm && !isEditing && sideArm !== 'none' && ( + <div className='compose-form__publish-button-wrapper'> + <Button + className='side_arm' + disabled={disabled} + onClick={onSecondarySubmit} + style={{ padding: null }} + text={<Icon id={privacyIcons[sideArm]} />} + title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage(privacyNames[sideArm])}`} + /> + </div> + )} + <div className='compose-form__publish-button-wrapper'> + <Button + className='primary' + type='submit' + text={publishText} + title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage(privacyNames[privacy])}`} + disabled={disabled} + /> + </div> + </div> + ); + } + +} + +export default injectIntl(Publisher); diff --git a/app/javascript/flavours/blobfox/features/compose/components/reply_indicator.jsx b/app/javascript/flavours/blobfox/features/compose/components/reply_indicator.jsx new file mode 100644 index 00000000000000..a3b42eea1c62bb --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/components/reply_indicator.jsx @@ -0,0 +1,70 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import AttachmentList from 'flavours/blobfox/components/attachment_list'; + +import { IconButton } from '../../../components/icon_button'; +import AccountContainer from '../../../containers/account_container'; + +const messages = defineMessages({ + cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }, +}); + +class ReplyIndicator extends ImmutablePureComponent { + + static propTypes = { + status: ImmutablePropTypes.map, + onCancel: PropTypes.func, + intl: PropTypes.object.isRequired, + }; + + handleClick = () => { + const { onCancel } = this.props; + if (onCancel) { + onCancel(); + } + }; + + render () { + const { status, intl } = this.props; + + if (!status) { + return null; + } + + const content = { __html: status.get('contentHtml') }; + + const account = status.get('account'); + + return ( + <div className='reply-indicator'> + <div className='reply-indicator__header'> + <div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} inverted /></div> + + {account && ( + <AccountContainer + id={account} + small + /> + )} + </div> + + <div className='reply-indicator__content translate' dangerouslySetInnerHTML={content} /> + + {status.get('media_attachments').size > 0 && ( + <AttachmentList + compact + media={status.get('media_attachments')} + /> + )} + </div> + ); + } + +} + +export default injectIntl(ReplyIndicator); diff --git a/app/javascript/flavours/blobfox/features/compose/components/search.jsx b/app/javascript/flavours/blobfox/features/compose/components/search.jsx new file mode 100644 index 00000000000000..76debfd7fb3b7c --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/components/search.jsx @@ -0,0 +1,400 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage, FormattedList } from 'react-intl'; + +import classNames from 'classnames'; +import { withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + +import { Icon } from 'flavours/blobfox/components/icon'; +import { domain, searchEnabled } from 'flavours/blobfox/initial_state'; +import { HASHTAG_REGEX } from 'flavours/blobfox/utils/hashtags'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +const messages = defineMessages({ + placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, + placeholderSignedIn: { id: 'search.search_or_paste', defaultMessage: 'Search or paste URL' }, +}); + +const labelForRecentSearch = search => { + switch(search.get('type')) { + case 'account': + return `@${search.get('q')}`; + case 'hashtag': + return `#${search.get('q')}`; + default: + return search.get('q'); + } +}; + +class Search extends PureComponent { + + static contextTypes = { + identity: PropTypes.object.isRequired, + }; + + static propTypes = { + value: PropTypes.string.isRequired, + recent: ImmutablePropTypes.orderedSet, + submitted: PropTypes.bool, + onChange: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + onOpenURL: PropTypes.func.isRequired, + onClickSearchResult: PropTypes.func.isRequired, + onForgetSearchResult: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, + onShow: PropTypes.func.isRequired, + openInRoute: PropTypes.bool, + intl: PropTypes.object.isRequired, + singleColumn: PropTypes.bool, + ...WithRouterPropTypes, + }; + + state = { + expanded: false, + selectedOption: -1, + options: [], + }; + + defaultOptions = [ + { label: <><mark>has:</mark> <FormattedList type='disjunction' value={['media', 'poll', 'embed']} /></>, action: e => { e.preventDefault(); this._insertText('has:'); } }, + { label: <><mark>is:</mark> <FormattedList type='disjunction' value={['reply', 'sensitive']} /></>, action: e => { e.preventDefault(); this._insertText('is:'); } }, + { label: <><mark>language:</mark> <FormattedMessage id='search_popout.language_code' defaultMessage='ISO language code' /></>, action: e => { e.preventDefault(); this._insertText('language:'); } }, + { label: <><mark>from:</mark> <FormattedMessage id='search_popout.user' defaultMessage='user' /></>, action: e => { e.preventDefault(); this._insertText('from:'); } }, + { label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:'); } }, + { label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:'); } }, + { label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:'); } }, + { label: <><mark>in:</mark> <FormattedList type='disjunction' value={['all', 'library', 'public']} /></>, action: e => { e.preventDefault(); this._insertText('in:'); } } + ]; + + setRef = c => { + this.searchForm = c; + }; + + handleChange = ({ target }) => { + const { onChange } = this.props; + + onChange(target.value); + + this._calculateOptions(target.value); + }; + + handleClear = e => { + const { value, submitted, onClear } = this.props; + + e.preventDefault(); + + if (value.length > 0 || submitted) { + onClear(); + this.setState({ options: [], selectedOption: -1 }); + } + }; + + handleKeyDown = (e) => { + const { selectedOption } = this.state; + const options = searchEnabled ? this._getOptions().concat(this.defaultOptions) : this._getOptions(); + + switch(e.key) { + case 'Escape': + e.preventDefault(); + this._unfocus(); + + break; + case 'ArrowDown': + e.preventDefault(); + + if (options.length > 0) { + this.setState({ selectedOption: Math.min(selectedOption + 1, options.length - 1) }); + } + + break; + case 'ArrowUp': + e.preventDefault(); + + if (options.length > 0) { + this.setState({ selectedOption: Math.max(selectedOption - 1, -1) }); + } + + break; + case 'Enter': + e.preventDefault(); + + if (selectedOption === -1) { + this._submit(); + } else if (options.length > 0) { + options[selectedOption].action(e); + } + + break; + case 'Delete': + if (selectedOption > -1 && options.length > 0) { + const search = options[selectedOption]; + + if (typeof search.forget === 'function') { + e.preventDefault(); + search.forget(e); + } + } + + break; + } + }; + + handleFocus = () => { + const { onShow, singleColumn } = this.props; + + this.setState({ expanded: true, selectedOption: -1 }); + onShow(); + + if (this.searchForm && !singleColumn) { + const { left, right } = this.searchForm.getBoundingClientRect(); + + if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) { + this.searchForm.scrollIntoView(); + } + } + }; + + handleBlur = () => { + this.setState({ expanded: false, selectedOption: -1 }); + }; + + handleHashtagClick = () => { + const { value, onClickSearchResult, history } = this.props; + + const query = value.trim().replace(/^#/, ''); + + history.push(`/tags/${query}`); + onClickSearchResult(query, 'hashtag'); + this._unfocus(); + }; + + handleAccountClick = () => { + const { value, onClickSearchResult, history } = this.props; + + const query = value.trim().replace(/^@/, ''); + + history.push(`/@${query}`); + onClickSearchResult(query, 'account'); + this._unfocus(); + }; + + handleURLClick = () => { + const { onOpenURL, history } = this.props; + + onOpenURL(history); + this._unfocus(); + }; + + handleStatusSearch = () => { + this._submit('statuses'); + }; + + handleAccountSearch = () => { + this._submit('accounts'); + }; + + handleRecentSearchClick = search => { + const { onChange, history } = this.props; + + if (search.get('type') === 'account') { + history.push(`/@${search.get('q')}`); + } else if (search.get('type') === 'hashtag') { + history.push(`/tags/${search.get('q')}`); + } else { + onChange(search.get('q')); + this._submit(search.get('type')); + } + + this._unfocus(); + }; + + handleForgetRecentSearchClick = search => { + const { onForgetSearchResult } = this.props; + + onForgetSearchResult(search.get('q')); + }; + + _unfocus () { + document.querySelector('.ui').parentElement.focus(); + } + + _insertText (text) { + const { value, onChange } = this.props; + + if (value === '') { + onChange(text); + } else if (value[value.length - 1] === ' ') { + onChange(`${value}${text}`); + } else { + onChange(`${value} ${text}`); + } + } + + _submit (type) { + const { onSubmit, openInRoute, value, onClickSearchResult, history } = this.props; + + onSubmit(type); + + if (value) { + onClickSearchResult(value, type); + } + + if (openInRoute) { + history.push('/search'); + } + + this._unfocus(); + } + + _getOptions () { + const { options } = this.state; + + if (options.length > 0) { + return options; + } + + const { recent } = this.props; + + return recent.toArray().map(search => ({ + label: labelForRecentSearch(search), + + action: () => this.handleRecentSearchClick(search), + + forget: e => { + e.stopPropagation(); + this.handleForgetRecentSearchClick(search); + }, + })); + } + + _calculateOptions (value) { + const { signedIn } = this.context.identity; + const trimmedValue = value.trim(); + const options = []; + + if (trimmedValue.length > 0) { + const couldBeURL = trimmedValue.startsWith('https://') && !trimmedValue.includes(' '); + + if (couldBeURL) { + options.push({ key: 'open-url', label: <FormattedMessage id='search.quick_action.open_url' defaultMessage='Open URL in Mastodon' />, action: this.handleURLClick }); + } + + const couldBeHashtag = (trimmedValue.startsWith('#') && trimmedValue.length > 1) || trimmedValue.match(HASHTAG_REGEX); + + if (couldBeHashtag) { + options.push({ key: 'go-to-hashtag', label: <FormattedMessage id='search.quick_action.go_to_hashtag' defaultMessage='Go to hashtag {x}' values={{ x: <mark>#{trimmedValue.replace(/^#/, '')}</mark> }} />, action: this.handleHashtagClick }); + } + + const couldBeUsername = trimmedValue.match(/^@?[a-z0-9_-]+(@[^\s]+)?$/i); + + if (couldBeUsername) { + options.push({ key: 'go-to-account', label: <FormattedMessage id='search.quick_action.go_to_account' defaultMessage='Go to profile {x}' values={{ x: <mark>@{trimmedValue.replace(/^@/, '')}</mark> }} />, action: this.handleAccountClick }); + } + + const couldBeStatusSearch = searchEnabled; + + if (couldBeStatusSearch && signedIn) { + options.push({ key: 'status-search', label: <FormattedMessage id='search.quick_action.status_search' defaultMessage='Posts matching {x}' values={{ x: <mark>{trimmedValue}</mark> }} />, action: this.handleStatusSearch }); + } + + const couldBeUserSearch = true; + + if (couldBeUserSearch) { + options.push({ key: 'account-search', label: <FormattedMessage id='search.quick_action.account_search' defaultMessage='Profiles matching {x}' values={{ x: <mark>{trimmedValue}</mark> }} />, action: this.handleAccountSearch }); + } + } + + this.setState({ options }); + } + + render () { + const { intl, value, submitted, recent } = this.props; + const { expanded, options, selectedOption } = this.state; + const { signedIn } = this.context.identity; + + const hasValue = value.length > 0 || submitted; + + return ( + <div className={classNames('search', { active: expanded })}> + <input + ref={this.setRef} + className='search__input' + type='text' + placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)} + aria-label={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)} + value={value || ''} + onChange={this.handleChange} + onKeyDown={this.handleKeyDown} + onFocus={this.handleFocus} + onBlur={this.handleBlur} + /> + + <div role='button' tabIndex={0} className='search__icon' onClick={this.handleClear}> + <Icon id='search' className={hasValue ? '' : 'active'} /> + <Icon id='times-circle' className={hasValue ? 'active' : ''} /> + </div> + + <div className='search__popout'> + {options.length === 0 && ( + <> + <h4><FormattedMessage id='search_popout.recent' defaultMessage='Recent searches' /></h4> + + <div className='search__popout__menu'> + {recent.size > 0 ? this._getOptions().map(({ label, action, forget }, i) => ( + <button key={label} onMouseDown={action} className={classNames('search__popout__menu__item search__popout__menu__item--flex', { selected: selectedOption === i })}> + <span>{label}</span> + <button className='icon-button' onMouseDown={forget}><Icon id='times' /></button> + </button> + )) : ( + <div className='search__popout__menu__message'> + <FormattedMessage id='search.no_recent_searches' defaultMessage='No recent searches' /> + </div> + )} + </div> + </> + )} + + {options.length > 0 && ( + <> + <h4><FormattedMessage id='search_popout.quick_actions' defaultMessage='Quick actions' /></h4> + + <div className='search__popout__menu'> + {options.map(({ key, label, action }, i) => ( + <button key={key} onMouseDown={action} className={classNames('search__popout__menu__item', { selected: selectedOption === i })}> + {label} + </button> + ))} + </div> + </> + )} + + <h4><FormattedMessage id='search_popout.options' defaultMessage='Search options' /></h4> + + {searchEnabled && signedIn ? ( + <div className='search__popout__menu'> + {this.defaultOptions.map(({ key, label, action }, i) => ( + <button key={key} onMouseDown={action} className={classNames('search__popout__menu__item', { selected: selectedOption === ((options.length || recent.size) + i) })}> + {label} + </button> + ))} + </div> + ) : ( + <div className='search__popout__menu__message'> + {searchEnabled ? ( + <FormattedMessage id='search_popout.full_text_search_logged_out_message' defaultMessage='Only available when logged in.' /> + ) : ( + <FormattedMessage id='search_popout.full_text_search_disabled_message' defaultMessage='Not available on {domain}.' values={{ domain }} /> + )} + </div> + )} + </div> + </div> + ); + } + +} + +export default withRouter(injectIntl(Search)); diff --git a/app/javascript/flavours/blobfox/features/compose/components/search_results.jsx b/app/javascript/flavours/blobfox/features/compose/components/search_results.jsx new file mode 100644 index 00000000000000..dd383dec41d9c5 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/components/search_results.jsx @@ -0,0 +1,89 @@ +import PropTypes from 'prop-types'; + +import { FormattedMessage } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { Icon } from 'flavours/blobfox/components/icon'; +import { LoadMore } from 'flavours/blobfox/components/load_more'; +import { SearchSection } from 'flavours/blobfox/features/explore/components/search_section'; + +import { ImmutableHashtag as Hashtag } from '../../../components/hashtag'; +import AccountContainer from '../../../containers/account_container'; +import StatusContainer from '../../../containers/status_container'; + +const INITIAL_PAGE_LIMIT = 10; + +const withoutLastResult = list => { + if (list.size > INITIAL_PAGE_LIMIT && list.size % INITIAL_PAGE_LIMIT === 1) { + return list.skipLast(1); + } else { + return list; + } +}; + +class SearchResults extends ImmutablePureComponent { + + static propTypes = { + results: ImmutablePropTypes.map.isRequired, + expandSearch: PropTypes.func.isRequired, + searchTerm: PropTypes.string, + }; + + handleLoadMoreAccounts = () => this.props.expandSearch('accounts'); + + handleLoadMoreStatuses = () => this.props.expandSearch('statuses'); + + handleLoadMoreHashtags = () => this.props.expandSearch('hashtags'); + + render () { + const { results } = this.props; + + let accounts, statuses, hashtags; + + if (results.get('accounts') && results.get('accounts').size > 0) { + accounts = ( + <SearchSection title={<><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></>}> + {withoutLastResult(results.get('accounts')).map(accountId => <AccountContainer key={accountId} id={accountId} />)} + {(results.get('accounts').size > INITIAL_PAGE_LIMIT && results.get('accounts').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={this.handleLoadMoreAccounts} />} + </SearchSection> + ); + } + + if (results.get('hashtags') && results.get('hashtags').size > 0) { + hashtags = ( + <SearchSection title={<><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></>}> + {withoutLastResult(results.get('hashtags')).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)} + {(results.get('hashtags').size > INITIAL_PAGE_LIMIT && results.get('hashtags').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={this.handleLoadMoreHashtags} />} + </SearchSection> + ); + } + + if (results.get('statuses') && results.get('statuses').size > 0) { + statuses = ( + <SearchSection title={<><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></>}> + {withoutLastResult(results.get('statuses')).map(statusId => <StatusContainer key={statusId} id={statusId} />)} + {(results.get('statuses').size > INITIAL_PAGE_LIMIT && results.get('statuses').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={this.handleLoadMoreStatuses} />} + </SearchSection> + ); + } + + + return ( + <div className='drawer--results'> + <header className='search-results__header'> + <Icon id='search' fixedWidth /> + <FormattedMessage id='explore.search_results' defaultMessage='Search results' /> + </header> + + {accounts} + {hashtags} + {statuses} + </div> + ); + } + +} + +export default SearchResults; diff --git a/app/javascript/flavours/blobfox/features/compose/components/text_icon_button.jsx b/app/javascript/flavours/blobfox/features/compose/components/text_icon_button.jsx new file mode 100644 index 00000000000000..166d022b88ac79 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/components/text_icon_button.jsx @@ -0,0 +1,38 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +const iconStyle = { + height: null, + lineHeight: '27px', + minWidth: `${18 * 1.28571429}px`, +}; + +export default class TextIconButton extends 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 ( + <button + type='button' + title={title} + aria-label={title} + className={`text-icon-button ${active ? 'active' : ''}`} + aria-expanded={active} + onClick={this.props.onClick} + aria-controls={ariaControls} style={iconStyle} + > + {label} + </button> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/compose/components/textarea_icons.jsx b/app/javascript/flavours/blobfox/features/compose/components/textarea_icons.jsx new file mode 100644 index 00000000000000..b54f1256956da6 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/components/textarea_icons.jsx @@ -0,0 +1,61 @@ +// Package imports. +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Components. +import { Icon } from 'flavours/blobfox/components/icon'; +// Messages. +const messages = defineMessages({ + localOnly: { + defaultMessage: 'This post is local-only', + id: 'advanced_options.local-only.tooltip', + }, + threadedMode: { + defaultMessage: 'Threaded mode enabled', + id: 'advanced_options.threaded_mode.tooltip', + }, +}); + +// We use an array of tuples here instead of an object because it +// preserves order. +const iconMap = [ + ['do_not_federate', 'home', messages.localOnly], + ['threaded_mode', 'comments', messages.threadedMode], +]; + +class TextareaIcons extends ImmutablePureComponent { + + static propTypes = { + advancedOptions: ImmutablePropTypes.map, + intl: PropTypes.object.isRequired, + }; + + render () { + const { advancedOptions, intl } = this.props; + return ( + <div className='compose-form__textarea-icons'> + {advancedOptions ? iconMap.map( + ([key, icon, message]) => advancedOptions.get(key) ? ( + <span + className='textarea_icon' + key={key} + title={intl.formatMessage(message)} + > + <Icon + fixedWidth + id={icon} + /> + </span> + ) : null, + ) : null} + </div> + ); + } + +} + +export default injectIntl(TextareaIcons); diff --git a/app/javascript/flavours/blobfox/features/compose/components/upload.jsx b/app/javascript/flavours/blobfox/features/compose/components/upload.jsx new file mode 100644 index 00000000000000..5af24570baf806 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/components/upload.jsx @@ -0,0 +1,66 @@ +import PropTypes from 'prop-types'; + +import { FormattedMessage } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import spring from 'react-motion/lib/spring'; + +import { Icon } from 'flavours/blobfox/components/icon'; + +import Motion from '../../ui/util/optional_motion'; + +export default class Upload extends ImmutablePureComponent { + + 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 ( + <div className='compose-form__upload'> + <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}> + {({ scale }) => ( + <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}> + <div className='compose-form__upload__actions'> + <button type='button' className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button> + <button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button> + </div> + + {(media.get('description') || '').length === 0 && ( + <div className='compose-form__upload__warning'> + <button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button> + </div> + )} + </div> + )} + </Motion> + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/compose/components/upload_form.jsx b/app/javascript/flavours/blobfox/features/compose/components/upload_form.jsx new file mode 100644 index 00000000000000..cf2e53ad905f0d --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/components/upload_form.jsx @@ -0,0 +1,32 @@ +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import SensitiveButtonContainer from '../containers/sensitive_button_container'; +import UploadContainer from '../containers/upload_container'; +import UploadProgressContainer from '../containers/upload_progress_container'; + +export default class UploadForm extends ImmutablePureComponent { + + static propTypes = { + mediaIds: ImmutablePropTypes.list.isRequired, + }; + + render () { + const { mediaIds } = this.props; + + return ( + <div className='compose-form__upload-wrapper'> + <UploadProgressContainer /> + + <div className='compose-form__uploads-wrapper'> + {mediaIds.map(id => ( + <UploadContainer id={id} key={id} /> + ))} + </div> + + {!mediaIds.isEmpty() && <SensitiveButtonContainer />} + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/compose/components/upload_progress.jsx b/app/javascript/flavours/blobfox/features/compose/components/upload_progress.jsx new file mode 100644 index 00000000000000..906093d7821533 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/components/upload_progress.jsx @@ -0,0 +1,56 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import spring from 'react-motion/lib/spring'; + +import { Icon } from 'flavours/blobfox/components/icon'; + +import Motion from '../../ui/util/optional_motion'; + +export default class UploadProgress extends 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 = <FormattedMessage id='upload_progress.processing' defaultMessage='Processing…' />; + } else { + message = <FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />; + } + + return ( + <div className='upload-progress'> + <div className='upload-progress__icon'> + <Icon id='upload' /> + </div> + + <div className='upload-progress__message'> + {message} + + <div className='upload-progress__backdrop'> + <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}> + {({ width }) => + <div className='upload-progress__tracker' style={{ width: `${width}%` }} /> + } + </Motion> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/compose/components/warning.jsx b/app/javascript/flavours/blobfox/features/compose/components/warning.jsx new file mode 100644 index 00000000000000..c5babc30a5a1ad --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/components/warning.jsx @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import spring from 'react-motion/lib/spring'; + +import Motion from '../../ui/util/optional_motion'; + +export default class Warning extends PureComponent { + + static propTypes = { + message: PropTypes.node.isRequired, + }; + + render () { + const { message } = this.props; + + return ( + <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> + {({ opacity, scaleX, scaleY }) => ( + <div className='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}> + {message} + </div> + )} + </Motion> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/compose/containers/autosuggest_account_container.js b/app/javascript/flavours/blobfox/features/compose/containers/autosuggest_account_container.js new file mode 100644 index 00000000000000..f86f01bd97e38c --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/containers/autosuggest_account_container.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux'; + +import { makeGetAccount } from '../../../selectors'; +import AutosuggestAccount from '../components/autosuggest_account'; + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { id }) => ({ + account: getAccount(state, id), + }); + + return mapStateToProps; +}; + +export default connect(makeMapStateToProps)(AutosuggestAccount); diff --git a/app/javascript/flavours/blobfox/features/compose/containers/compose_form_container.js b/app/javascript/flavours/blobfox/features/compose/containers/compose_form_container.js new file mode 100644 index 00000000000000..e2713a22138661 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/containers/compose_form_container.js @@ -0,0 +1,150 @@ +import { defineMessages, injectIntl } from 'react-intl'; + +import { connect } from 'react-redux'; + +import { privacyPreference } from 'flavours/blobfox/utils/privacy_preference'; + +import { + changeCompose, + submitCompose, + clearComposeSuggestions, + fetchComposeSuggestions, + selectComposeSuggestion, + changeComposeSpoilerText, + changeComposeSpoilerness, + changeComposeVisibility, + insertEmojiCompose, + uploadCompose, +} from '../../../actions/compose'; +import { changeLocalSetting } from '../../../actions/local_settings'; +import { + openModal, +} from '../../../actions/modal'; +import ComposeForm from '../components/compose_form'; + +const messages = defineMessages({ + missingDescriptionMessage: { + id: 'confirmations.missing_media_description.message', + defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.', + }, + missingDescriptionConfirm: { + id: 'confirmations.missing_media_description.confirm', + defaultMessage: 'Send anyway', + }, + missingDescriptionEdit: { + id: 'confirmations.missing_media_description.edit', + defaultMessage: 'Edit media', + }, +}); + +const sideArmPrivacy = state => { + const inReplyTo = state.getIn(['compose', 'in_reply_to']); + const replyPrivacy = inReplyTo ? state.getIn(['statuses', inReplyTo, 'visibility']) : null; + const sideArmBasePrivacy = state.getIn(['local_settings', 'side_arm']); + const sideArmRestrictedPrivacy = replyPrivacy ? privacyPreference(replyPrivacy, sideArmBasePrivacy) : null; + let sideArmPrivacy = null; + switch (state.getIn(['local_settings', 'side_arm_reply_mode'])) { + case 'copy': + sideArmPrivacy = replyPrivacy; + break; + case 'restrict': + sideArmPrivacy = sideArmRestrictedPrivacy; + break; + } + return sideArmPrivacy || sideArmBasePrivacy; +}; + +const mapStateToProps = state => ({ + text: state.getIn(['compose', 'text']), + suggestions: state.getIn(['compose', 'suggestions']), + spoiler: state.getIn(['local_settings', 'always_show_spoilers_field']) || state.getIn(['compose', 'spoiler']), + spoilerText: state.getIn(['compose', 'spoiler_text']), + privacy: state.getIn(['compose', 'privacy']), + focusDate: state.getIn(['compose', 'focusDate']), + caretPosition: state.getIn(['compose', 'caretPosition']), + preselectDate: state.getIn(['compose', 'preselectDate']), + isSubmitting: state.getIn(['compose', 'is_submitting']), + isEditing: state.getIn(['compose', 'id']) !== null, + isChangingUpload: state.getIn(['compose', 'is_changing_upload']), + isUploading: state.getIn(['compose', 'is_uploading']), + anyMedia: state.getIn(['compose', 'media_attachments']).size > 0, + isInReply: state.getIn(['compose', 'in_reply_to']) !== null, + lang: state.getIn(['compose', 'language']), + advancedOptions: state.getIn(['compose', 'advanced_options']), + layout: state.getIn(['local_settings', 'layout']), + media: state.getIn(['compose', 'media_attachments']), + sideArm: sideArmPrivacy(state), + sensitive: state.getIn(['compose', 'sensitive']), + showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), + spoilersAlwaysOn: state.getIn(['local_settings', 'always_show_spoilers_field']), + mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']), + preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']), +}); + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onChange (text) { + dispatch(changeCompose(text)); + }, + + onSubmit (router) { + dispatch(submitCompose(router)); + }, + + onClearSuggestions () { + dispatch(clearComposeSuggestions()); + }, + + onFetchSuggestions (token) { + dispatch(fetchComposeSuggestions(token)); + }, + + onSuggestionSelected (position, token, suggestion, path) { + dispatch(selectComposeSuggestion(position, token, suggestion, path)); + }, + + onChangeSpoilerText (text) { + dispatch(changeComposeSpoilerText(text)); + }, + + onPaste (files) { + dispatch(uploadCompose(files)); + }, + + onPickEmoji (position, emoji) { + dispatch(insertEmojiCompose(position, emoji)); + }, + + onChangeSpoilerness() { + dispatch(changeComposeSpoilerness()); + }, + + onChangeVisibility(value) { + dispatch(changeComposeVisibility(value)); + }, + + onMediaDescriptionConfirm(routerHistory, mediaId, overriddenVisibility = null) { + dispatch(openModal({ + modalType: 'CONFIRM', + modalProps: { + message: intl.formatMessage(messages.missingDescriptionMessage), + confirm: intl.formatMessage(messages.missingDescriptionConfirm), + onConfirm: () => { + if (overriddenVisibility) { + dispatch(changeComposeVisibility(overriddenVisibility)); + } + dispatch(submitCompose(routerHistory)); + }, + secondary: intl.formatMessage(messages.missingDescriptionEdit), + onSecondary: () => dispatch(openModal({ + modalType: 'FOCAL_POINT', + modalProps: { id: mediaId }, + })), + onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_missing_media_description'], false)), + }, + })); + }, + +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ComposeForm)); diff --git a/app/javascript/flavours/blobfox/features/compose/containers/dropdown_container.js b/app/javascript/flavours/blobfox/features/compose/containers/dropdown_container.js new file mode 100644 index 00000000000000..25d60d9328c8cb --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/containers/dropdown_container.js @@ -0,0 +1,14 @@ +import { connect } from 'react-redux'; + +import { openModal, closeModal } from 'flavours/blobfox/actions/modal'; +import { isUserTouching } from 'flavours/blobfox/is_mobile'; + +import Dropdown from '../components/dropdown'; + +const mapDispatchToProps = dispatch => ({ + isUserTouching, + onModalOpen: props => dispatch(openModal({ modalType: 'ACTIONS', modalProps: props })), + onModalClose: () => dispatch(closeModal({ modalType: undefined, ignoreFocus: false })), +}); + +export default connect(null, mapDispatchToProps)(Dropdown); diff --git a/app/javascript/flavours/blobfox/features/compose/containers/emoji_picker_dropdown_container.js b/app/javascript/flavours/blobfox/features/compose/containers/emoji_picker_dropdown_container.js new file mode 100644 index 00000000000000..a0e50029dfa303 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/containers/emoji_picker_dropdown_container.js @@ -0,0 +1,85 @@ +import { Map as ImmutableMap } from 'immutable'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; + +import { useEmoji } from '../../../actions/emojis'; +import { changeSetting } from '../../../actions/settings'; +import EmojiPickerDropdown from '../components/emoji_picker_dropdown'; + +const perLine = 8; +const lines = 2; + +const DEFAULTS = [ + '+1', + 'grinning', + 'kissing_heart', + 'heart_eyes', + 'laughing', + 'stuck_out_tongue_winking_eye', + 'sweat_smile', + 'joy', + 'yum', + 'disappointed', + 'thinking_face', + 'weary', + 'sob', + 'sunglasses', + 'heart', + 'ok_hand', +]; + +const getFrequentlyUsedEmojis = createSelector([ + state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()), +], emojiCounters => { + let emojis = emojiCounters + .keySeq() + .sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b)) + .reverse() + .slice(0, perLine * lines) + .toArray(); + + if (emojis.length < DEFAULTS.length) { + let uniqueDefaults = DEFAULTS.filter(emoji => !emojis.includes(emoji)); + emojis = emojis.concat(uniqueDefaults.slice(0, DEFAULTS.length - emojis.length)); + } + + return emojis; +}); + +const getCustomEmojis = createSelector([ + state => state.get('custom_emojis'), +], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => { + const aShort = a.get('shortcode').toLowerCase(); + const bShort = b.get('shortcode').toLowerCase(); + + if (aShort < bShort) { + return -1; + } else if (aShort > bShort ) { + return 1; + } else { + return 0; + } +})); + +const mapStateToProps = state => ({ + custom_emojis: getCustomEmojis(state), + skinTone: state.getIn(['settings', 'skinTone']), + frequentlyUsedEmojis: getFrequentlyUsedEmojis(state), +}); + +const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({ + onSkinTone: skinTone => { + dispatch(changeSetting(['skinTone'], skinTone)); + }, + + onPickEmoji: emoji => { + // eslint-disable-next-line react-hooks/rules-of-hooks -- this is not a react hook + dispatch(useEmoji(emoji)); + + if (onPickEmoji) { + onPickEmoji(emoji); + } + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown); diff --git a/app/javascript/flavours/blobfox/features/compose/containers/header_container.js b/app/javascript/flavours/blobfox/features/compose/containers/header_container.js new file mode 100644 index 00000000000000..b7931ffa895d89 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/containers/header_container.js @@ -0,0 +1,42 @@ +import { defineMessages, injectIntl } from 'react-intl'; + +import { connect } from 'react-redux'; + +import { openModal } from 'flavours/blobfox/actions/modal'; +import { logOut } from 'flavours/blobfox/utils/log_out'; + +import Header from '../components/header'; + +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 => { + return { + columns: state.getIn(['settings', 'columns']), + unreadNotifications: state.getIn(['notifications', 'unread']), + showNotificationsBadge: state.getIn(['local_settings', 'notifications', 'tab_badge']), + }; +}; + +const mapDispatchToProps = (dispatch, { intl }) => ({ + onSettingsClick (e) { + e.preventDefault(); + e.stopPropagation(); + dispatch(openModal({ modalType: 'SETTINGS', modalProps: {} })); + }, + onLogout () { + dispatch(openModal({ + modalType: 'CONFIRM', + modalProps: { + message: intl.formatMessage(messages.logoutMessage), + confirm: intl.formatMessage(messages.logoutConfirm), + closeWhenConfirm: false, + onConfirm: () => logOut(), + }, + })); + }, +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Header)); diff --git a/app/javascript/flavours/blobfox/features/compose/containers/language_dropdown_container.js b/app/javascript/flavours/blobfox/features/compose/containers/language_dropdown_container.js new file mode 100644 index 00000000000000..7344097e229a2c --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/containers/language_dropdown_container.js @@ -0,0 +1,37 @@ +import { Map as ImmutableMap } from 'immutable'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; + +import { changeComposeLanguage } from 'flavours/blobfox/actions/compose'; +import { useLanguage } from 'flavours/blobfox/actions/languages'; + +import LanguageDropdown from '../components/language_dropdown'; + +const getFrequentlyUsedLanguages = createSelector([ + state => state.getIn(['settings', 'frequentlyUsedLanguages'], ImmutableMap()), +], languageCounters => ( + languageCounters.keySeq() + .sort((a, b) => languageCounters.get(a) - languageCounters.get(b)) + .reverse() + .toArray() +)); + +const mapStateToProps = state => ({ + frequentlyUsedLanguages: getFrequentlyUsedLanguages(state), + value: state.getIn(['compose', 'language']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (value) { + dispatch(changeComposeLanguage(value)); + }, + + onClose (value) { + // eslint-disable-next-line react-hooks/rules-of-hooks -- this is not a react hook + dispatch(useLanguage(value)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(LanguageDropdown); diff --git a/app/javascript/flavours/blobfox/features/compose/containers/navigation_container.js b/app/javascript/flavours/blobfox/features/compose/containers/navigation_container.js new file mode 100644 index 00000000000000..8b8d2f57a2a7b8 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/containers/navigation_container.js @@ -0,0 +1,36 @@ +import { defineMessages, injectIntl } from 'react-intl'; + +import { connect } from 'react-redux'; + +import { openModal } from 'flavours/blobfox/actions/modal'; +import { logOut } from 'flavours/blobfox/utils/log_out'; + +import { me } from '../../../initial_state'; +import NavigationBar from '../components/navigation_bar'; + +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 => { + return { + account: state.getIn(['accounts', me]), + }; +}; + +const mapDispatchToProps = (dispatch, { intl }) => ({ + onLogout () { + dispatch(openModal({ + modalType: 'CONFIRM', + modalProps: { + message: intl.formatMessage(messages.logoutMessage), + confirm: intl.formatMessage(messages.logoutConfirm), + closeWhenConfirm: false, + onConfirm: () => logOut(), + }, + })); + }, +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NavigationBar)); diff --git a/app/javascript/flavours/blobfox/features/compose/containers/options_container.js b/app/javascript/flavours/blobfox/features/compose/containers/options_container.js new file mode 100644 index 00000000000000..12ded500f4c34c --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/containers/options_container.js @@ -0,0 +1,56 @@ +import { connect } from 'react-redux'; + +import { + changeComposeAdvancedOption, + changeComposeContentType, + addPoll, + removePoll, +} from 'flavours/blobfox/actions/compose'; +import { openModal } from 'flavours/blobfox/actions/modal'; + +import Options from '../components/options'; + +function mapStateToProps (state) { + const poll = state.getIn(['compose', 'poll']); + const media = state.getIn(['compose', 'media_attachments']); + const pending_media = state.getIn(['compose', 'pending_media_attachments']); + return { + acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','), + resetFileKey: state.getIn(['compose', 'resetFileKey']), + hasPoll: !!poll, + allowMedia: !poll && (media ? media.size + pending_media < 4 && !media.some(item => ['video', 'audio'].includes(item.get('type'))) : pending_media < 4), + allowPoll: !(media && !!media.size), + showContentTypeChoice: state.getIn(['local_settings', 'show_content_type_choice']), + contentType: state.getIn(['compose', 'content_type']), + }; +} + +const mapDispatchToProps = (dispatch) => ({ + + onChangeAdvancedOption(option, value) { + dispatch(changeComposeAdvancedOption(option, value)); + }, + + onChangeContentType(value) { + dispatch(changeComposeContentType(value)); + }, + + onTogglePoll() { + dispatch((_, getState) => { + if (getState().getIn(['compose', 'poll'])) { + dispatch(removePoll()); + } else { + dispatch(addPoll()); + } + }); + }, + + onDoodleOpen() { + dispatch(openModal({ + modalType: 'DOODLE', + modalProps: { noEsc: true, noClose: true }, + })); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Options); diff --git a/app/javascript/flavours/blobfox/features/compose/containers/poll_form_container.js b/app/javascript/flavours/blobfox/features/compose/containers/poll_form_container.js new file mode 100644 index 00000000000000..177ffcea6acb4b --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/containers/poll_form_container.js @@ -0,0 +1,53 @@ +import { connect } from 'react-redux'; + +import { + addPollOption, + removePollOption, + changePollOption, + changePollSettings, + clearComposeSuggestions, + fetchComposeSuggestions, + selectComposeSuggestion, +} from '../../../actions/compose'; +import PollForm from '../components/poll_form'; + +const mapStateToProps = state => ({ + suggestions: state.getIn(['compose', 'suggestions']), + options: state.getIn(['compose', 'poll', 'options']), + lang: state.getIn(['compose', 'language']), + expiresIn: state.getIn(['compose', 'poll', 'expires_in']), + isMultiple: state.getIn(['compose', 'poll', 'multiple']), +}); + +const mapDispatchToProps = dispatch => ({ + onAddOption(title) { + dispatch(addPollOption(title)); + }, + + onRemoveOption(index) { + dispatch(removePollOption(index)); + }, + + onChangeOption(index, title) { + dispatch(changePollOption(index, title)); + }, + + onChangeSettings(expiresIn, isMultiple) { + dispatch(changePollSettings(expiresIn, isMultiple)); + }, + + onClearSuggestions () { + dispatch(clearComposeSuggestions()); + }, + + onFetchSuggestions (token) { + dispatch(fetchComposeSuggestions(token)); + }, + + onSuggestionSelected (position, token, accountId, path) { + dispatch(selectComposeSuggestion(position, token, accountId, path)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(PollForm); diff --git a/app/javascript/flavours/blobfox/features/compose/containers/privacy_dropdown_container.js b/app/javascript/flavours/blobfox/features/compose/containers/privacy_dropdown_container.js new file mode 100644 index 00000000000000..6d26abf4f6623b --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/containers/privacy_dropdown_container.js @@ -0,0 +1,30 @@ +import { connect } from 'react-redux'; + +import { changeComposeVisibility } from '../../../actions/compose'; +import { openModal, closeModal } from '../../../actions/modal'; +import { isUserTouching } from '../../../is_mobile'; +import PrivacyDropdown from '../components/privacy_dropdown'; + +const mapStateToProps = state => ({ + value: state.getIn(['compose', 'privacy']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (value) { + dispatch(changeComposeVisibility(value)); + }, + + isUserTouching, + onModalOpen: props => dispatch(openModal({ + modalType: 'ACTIONS', + modalProps: props, + })), + onModalClose: () => dispatch(closeModal({ + modalType: undefined, + ignoreFocus: false, + })), + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown); diff --git a/app/javascript/flavours/blobfox/features/compose/containers/reply_indicator_container.js b/app/javascript/flavours/blobfox/features/compose/containers/reply_indicator_container.js new file mode 100644 index 00000000000000..678124b2a8d93c --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/containers/reply_indicator_container.js @@ -0,0 +1,33 @@ +import { connect } from 'react-redux'; + +import { cancelReplyCompose } from '../../../actions/compose'; +import ReplyIndicator from '../components/reply_indicator'; + +const makeMapStateToProps = () => { + const mapStateToProps = state => { + let statusId = state.getIn(['compose', 'id'], null); + let editing = true; + + if (statusId === null) { + statusId = state.getIn(['compose', 'in_reply_to']); + editing = false; + } + + return { + status: state.getIn(['statuses', statusId]), + editing, + }; + }; + + return mapStateToProps; +}; + +const mapDispatchToProps = dispatch => ({ + + onCancel () { + dispatch(cancelReplyCompose()); + }, + +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator); diff --git a/app/javascript/flavours/blobfox/features/compose/containers/search_container.js b/app/javascript/flavours/blobfox/features/compose/containers/search_container.js new file mode 100644 index 00000000000000..a0217e3bd0febc --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/containers/search_container.js @@ -0,0 +1,53 @@ +import { connect } from 'react-redux'; + +import { + changeSearch, + clearSearch, + submitSearch, + showSearch, + openURL, + clickSearchResult, + forgetSearchResult, +} from 'flavours/blobfox/actions/search'; + +import Search from '../components/search'; + +const mapStateToProps = state => ({ + value: state.getIn(['search', 'value']), + submitted: state.getIn(['search', 'submitted']), + recent: state.getIn(['search', 'recent']).reverse(), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (value) { + dispatch(changeSearch(value)); + }, + + onClear () { + dispatch(clearSearch()); + }, + + onSubmit (type) { + dispatch(submitSearch(type)); + }, + + onShow () { + dispatch(showSearch()); + }, + + onOpenURL (routerHistory) { + dispatch(openURL(routerHistory)); + }, + + onClickSearchResult (q, type) { + dispatch(clickSearchResult(q, type)); + }, + + onForgetSearchResult (q) { + dispatch(forgetSearchResult(q)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Search); diff --git a/app/javascript/flavours/blobfox/features/compose/containers/search_results_container.js b/app/javascript/flavours/blobfox/features/compose/containers/search_results_container.js new file mode 100644 index 00000000000000..3906b08a0190f4 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/containers/search_results_container.js @@ -0,0 +1,20 @@ +import { connect } from 'react-redux'; + +import { expandSearch } from 'flavours/blobfox/actions/search'; +import { fetchSuggestions, dismissSuggestion } from 'flavours/blobfox/actions/suggestions'; + +import SearchResults from '../components/search_results'; + +const mapStateToProps = state => ({ + results: state.getIn(['search', 'results']), + suggestions: state.getIn(['suggestions', 'items']), + searchTerm: state.getIn(['search', 'searchTerm']), +}); + +const mapDispatchToProps = dispatch => ({ + fetchSuggestions: () => dispatch(fetchSuggestions()), + expandSearch: type => dispatch(expandSearch(type)), + dismissSuggestion: account => dispatch(dismissSuggestion(account.get('id'))), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(SearchResults); diff --git a/app/javascript/flavours/blobfox/features/compose/containers/sensitive_button_container.jsx b/app/javascript/flavours/blobfox/features/compose/containers/sensitive_button_container.jsx new file mode 100644 index 00000000000000..96bcdc9bad7aa9 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/containers/sensitive_button_container.jsx @@ -0,0 +1,77 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import { connect } from 'react-redux'; + +import { changeComposeSensitivity } from 'flavours/blobfox/actions/compose'; + +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 => { + const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']); + const spoilerText = state.getIn(['compose', 'spoiler_text']); + return { + active: state.getIn(['compose', 'sensitive']) || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0), + disabled: state.getIn(['compose', 'spoiler']), + mediaCount: state.getIn(['compose', 'media_attachments']).size, + }; +}; + +const mapDispatchToProps = dispatch => ({ + + onClick () { + dispatch(changeComposeSensitivity()); + }, + +}); + +class SensitiveButton extends 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 ( + <div className='compose-form__sensitive-button'> + <label className={classNames('icon-button', { active })} title={intl.formatMessage(active ? messages.marked : messages.unmarked, { count: mediaCount })}> + <input + name='mark-sensitive' + type='checkbox' + checked={active} + onChange={onClick} + disabled={disabled} + /> + + <FormattedMessage + id='compose_form.sensitive.hide' + defaultMessage='{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}' + values={{ count: mediaCount }} + /> + </label> + </div> + ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton)); diff --git a/app/javascript/flavours/blobfox/features/compose/containers/upload_container.js b/app/javascript/flavours/blobfox/features/compose/containers/upload_container.js new file mode 100644 index 00000000000000..77bb90db87b91a --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/containers/upload_container.js @@ -0,0 +1,26 @@ +import { connect } from 'react-redux'; + +import { undoUploadCompose, initMediaEditModal, submitCompose } from '../../../actions/compose'; +import Upload from '../components/upload'; + +const mapStateToProps = (state, { id }) => ({ + media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), +}); + +const mapDispatchToProps = dispatch => ({ + + onUndo: id => { + dispatch(undoUploadCompose(id)); + }, + + onOpenFocalPoint: id => { + dispatch(initMediaEditModal(id)); + }, + + onSubmit (router) { + dispatch(submitCompose(router)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Upload); diff --git a/app/javascript/flavours/blobfox/features/compose/containers/upload_form_container.js b/app/javascript/flavours/blobfox/features/compose/containers/upload_form_container.js new file mode 100644 index 00000000000000..336525cf5399d6 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/containers/upload_form_container.js @@ -0,0 +1,9 @@ +import { connect } from 'react-redux'; + +import UploadForm from '../components/upload_form'; + +const mapStateToProps = state => ({ + mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')), +}); + +export default connect(mapStateToProps)(UploadForm); diff --git a/app/javascript/flavours/blobfox/features/compose/containers/upload_progress_container.js b/app/javascript/flavours/blobfox/features/compose/containers/upload_progress_container.js new file mode 100644 index 00000000000000..ffff321c3fcdf1 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/containers/upload_progress_container.js @@ -0,0 +1,11 @@ +import { connect } from 'react-redux'; + +import UploadProgress from '../components/upload_progress'; + +const mapStateToProps = state => ({ + active: state.getIn(['compose', 'is_uploading']), + progress: state.getIn(['compose', 'progress']), + isProcessing: state.getIn(['compose', 'is_processing']), +}); + +export default connect(mapStateToProps)(UploadProgress); diff --git a/app/javascript/flavours/blobfox/features/compose/containers/warning_container.jsx b/app/javascript/flavours/blobfox/features/compose/containers/warning_container.jsx new file mode 100644 index 00000000000000..4908e0634e2bc0 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/containers/warning_container.jsx @@ -0,0 +1,47 @@ +import PropTypes from 'prop-types'; + +import { FormattedMessage } from 'react-intl'; + +import { connect } from 'react-redux'; + +import { me } from 'flavours/blobfox/initial_state'; +import { profileLink, privacyPolicyLink } from 'flavours/blobfox/utils/backend_links'; +import { HASHTAG_PATTERN_REGEX } from 'flavours/blobfox/utils/hashtags'; + +import Warning from '../components/warning'; + +const mapStateToProps = state => ({ + needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']), + hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && HASHTAG_PATTERN_REGEX.test(state.getIn(['compose', 'text'])), + directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct', +}); + +const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => { + if (needsLockWarning) { + return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href={profileLink}><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />; + } + + if (hashtagWarning) { + return <Warning message={<FormattedMessage id='compose_form.hashtag_warning' defaultMessage="This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag." />} />; + } + + if (directMessageWarning) { + const message = ( + <span> + <FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> {!!privacyPolicyLink && <a href={privacyPolicyLink} target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>} + </span> + ); + + return <Warning message={message} />; + } + + return null; +}; + +WarningWrapper.propTypes = { + needsLockWarning: PropTypes.bool, + hashtagWarning: PropTypes.bool, + directMessageWarning: PropTypes.bool, +}; + +export default connect(mapStateToProps)(WarningWrapper); diff --git a/app/javascript/flavours/blobfox/features/compose/index.jsx b/app/javascript/flavours/blobfox/features/compose/index.jsx new file mode 100644 index 00000000000000..34da2b82676c1c --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/index.jsx @@ -0,0 +1,122 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { injectIntl, defineMessages } from 'react-intl'; + +import classNames from 'classnames'; +import { Helmet } from 'react-helmet'; + +import { connect } from 'react-redux'; + +import spring from 'react-motion/lib/spring'; + +import { mountCompose, unmountCompose, cycleElefriendCompose } from 'flavours/blobfox/actions/compose'; +import Column from 'flavours/blobfox/components/column'; + +import { mascot } from '../../initial_state'; +import Motion from '../ui/util/optional_motion'; + +import ComposeFormContainer from './containers/compose_form_container'; +import HeaderContainer from './containers/header_container'; +import NavigationContainer from './containers/navigation_container'; +import SearchContainer from './containers/search_container'; +import SearchResultsContainer from './containers/search_results_container'; + +const messages = defineMessages({ + compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' }, +}); + +const mapStateToProps = (state, ownProps) => ({ + elefriend: state.getIn(['compose', 'elefriend']), + showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : false, +}); + +const mapDispatchToProps = (dispatch) => ({ + onClickElefriend () { + dispatch(cycleElefriendCompose()); + }, + + onMount () { + dispatch(mountCompose()); + }, + + onUnmount () { + dispatch(unmountCompose()); + }, +}); + +class Compose extends PureComponent { + + static propTypes = { + multiColumn: PropTypes.bool, + showSearch: PropTypes.bool, + elefriend: PropTypes.number, + onClickElefriend: PropTypes.func, + onMount: PropTypes.func, + onUnmount: PropTypes.func, + intl: PropTypes.object.isRequired, + }; + + componentDidMount () { + this.props.onMount(); + } + + componentWillUnmount () { + this.props.onUnmount(); + } + + render () { + const { + elefriend, + intl, + multiColumn, + onClickElefriend, + showSearch, + } = this.props; + const computedClass = classNames('drawer', `mbstobon-${elefriend}`); + + if (multiColumn) { + return ( + <div className={computedClass} role='region' aria-label={intl.formatMessage(messages.compose)}> + <HeaderContainer /> + + {multiColumn && <SearchContainer />} + + <div className='drawer__pager'> + <div className='drawer__inner'> + <NavigationContainer /> + + <ComposeFormContainer /> + + <div className='drawer__inner__mastodon'> + {mascot ? <img alt='' draggable='false' src={mascot} /> : <button className='mastodon' onClick={onClickElefriend} />} + </div> + </div> + + <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}> + {({ x }) => ( + <div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}> + <SearchResultsContainer /> + </div> + )} + </Motion> + </div> + </div> + ); + } + + return ( + <Column> + <NavigationContainer /> + <ComposeFormContainer /> + + <Helmet> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(Compose)); diff --git a/app/javascript/flavours/blobfox/features/compose/util/counter.js b/app/javascript/flavours/blobfox/features/compose/util/counter.js new file mode 100644 index 00000000000000..ec2431096b5a46 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/util/counter.js @@ -0,0 +1,9 @@ +import { urlRegex } from './url_regex'; + +const urlPlaceholder = '$2xxxxxxxxxxxxxxxxxxxxxxx'; + +export function countableText(inputText) { + return inputText + .replace(urlRegex, urlPlaceholder) + .replace(/(^|[^/\w])@(([a-z0-9_]+)@[a-z0-9.-]+[a-z0-9]+)/ig, '$1@$3'); +} diff --git a/app/javascript/flavours/blobfox/features/compose/util/url_regex.js b/app/javascript/flavours/blobfox/features/compose/util/url_regex.js new file mode 100644 index 00000000000000..887612ae306765 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/compose/util/url_regex.js @@ -0,0 +1,30 @@ +import regexSupplant from 'twitter-text/dist/lib/regexSupplant'; +import validDomain from 'twitter-text/dist/regexp/validDomain'; +import validPortNumber from 'twitter-text/dist/regexp/validPortNumber'; +import validUrlPath from 'twitter-text/dist/regexp/validUrlPath'; +import validUrlPrecedingChars from 'twitter-text/dist/regexp/validUrlPrecedingChars'; +import validUrlQueryChars from 'twitter-text/dist/regexp/validUrlQueryChars'; +import validUrlQueryEndingChars from 'twitter-text/dist/regexp/validUrlQueryEndingChars'; + +// The difference with twitter-text's extractURL is that the protocol isn't +// optional. + +export const urlRegex = regexSupplant( + '(' + // $1 URL + '(#{validUrlPrecedingChars})' + // $2 + '(https?:\\/\\/)' + // $3 Protocol + '(#{validDomain})' + // $4 Domain(s) + '(?::(#{validPortNumber}))?' + // $5 Port number (optional) + '(\\/#{validUrlPath}*)?' + // $6 URL Path + '(\\?#{validUrlQueryChars}*#{validUrlQueryEndingChars})?' + // $7 Query String + ')', + { + validUrlPrecedingChars, + validDomain, + validPortNumber, + validUrlPath, + validUrlQueryChars, + validUrlQueryEndingChars, + }, + 'gi', +); diff --git a/app/javascript/flavours/blobfox/features/direct_timeline/components/column_settings.jsx b/app/javascript/flavours/blobfox/features/direct_timeline/components/column_settings.jsx new file mode 100644 index 00000000000000..94168d1d920a90 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/direct_timeline/components/column_settings.jsx @@ -0,0 +1,47 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + +import SettingToggle from 'flavours/blobfox/features/notifications/components/setting_toggle'; + +import SettingText from '../../../components/setting_text'; + +const messages = defineMessages({ + filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }, + settings: { id: 'home.settings', defaultMessage: 'Column settings' }, +}); + +class ColumnSettings extends PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + onChange: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render () { + const { settings, onChange, intl } = this.props; + + return ( + <div> + <span className='column-settings__section'><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span> + + <div className='column-settings__row'> + <SettingToggle settings={settings} settingPath={['conversations']} onChange={onChange} label={<FormattedMessage id='direct.group_by_conversations' defaultMessage='Group by conversation' />} /> + </div> + + <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> + + <div className='column-settings__row'> + <SettingText settings={settings} settingPath={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> + </div> + </div> + ); + } + +} + +export default injectIntl(ColumnSettings); diff --git a/app/javascript/flavours/blobfox/features/direct_timeline/components/conversation.jsx b/app/javascript/flavours/blobfox/features/direct_timeline/components/conversation.jsx new file mode 100644 index 00000000000000..cdac53539e8944 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/direct_timeline/components/conversation.jsx @@ -0,0 +1,233 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; +import { withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { HotKeys } from 'react-hotkeys'; + +import AttachmentList from 'flavours/blobfox/components/attachment_list'; +import AvatarComposite from 'flavours/blobfox/components/avatar_composite'; +import { IconButton } from 'flavours/blobfox/components/icon_button'; +import Permalink from 'flavours/blobfox/components/permalink'; +import { RelativeTimestamp } from 'flavours/blobfox/components/relative_timestamp'; +import StatusContent from 'flavours/blobfox/components/status_content'; +import DropdownMenuContainer from 'flavours/blobfox/containers/dropdown_menu_container'; +import { autoPlayGif } from 'flavours/blobfox/initial_state'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +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' }, +}); + +class Conversation extends ImmutablePureComponent { + + 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, + ...WithRouterPropTypes, + }; + + state = { + isExpanded: undefined, + }; + + parseClick = (e, destination) => { + const { history, lastStatus, unread, markRead } = this.props; + if (!history) return; + + if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) { + if (destination === undefined) { + if (unread) { + markRead(); + } + destination = `/statuses/${lastStatus.get('id')}`; + } + history.push(destination); + e.preventDefault(); + } + }; + + 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.props.history) { + return; + } + + const { lastStatus, unread, markRead } = this.props; + + if (unread) { + markRead(); + } + + this.props.history.push(`/@${lastStatus.getIn(['account', 'acct'])}/${lastStatus.get('id')}`); + }; + + handleMarkAsRead = () => { + this.props.markRead(); + }; + + handleReply = () => { + this.props.reply(this.props.lastStatus, this.props.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); + + if (this.props.lastStatus.get('spoiler_text')) { + this.setExpansion(!this.state.isExpanded); + } + }; + + setExpansion = value => { + this.setState({ isExpanded: value }); + }; + + render () { + const { accounts, lastStatus, unread, scrollKey, intl } = this.props; + + if (lastStatus === null) { + return null; + } + + const isExpanded = this.props.settings.getIn(['content_warnings', 'shared_state']) ? !lastStatus.get('hidden') : this.state.isExpanded; + + 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 => <Permalink to={`/@${a.get('acct')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Permalink>).reduce((prev, cur) => [prev, ', ', cur]); + + const handlers = { + reply: this.handleReply, + open: this.handleClick, + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + toggleHidden: this.handleShowMore, + }; + + let media = null; + if (lastStatus.get('media_attachments').size > 0) { + media = <AttachmentList compact media={lastStatus.get('media_attachments')} />; + } + + return ( + <HotKeys handlers={handlers}> + <div className={classNames('conversation focusable muted', { 'conversation--unread': unread })} tabIndex={0}> + <div className='conversation__avatar' onClick={this.handleClick} role='presentation'> + <AvatarComposite accounts={accounts} size={48} /> + </div> + + <div className='conversation__content'> + <div className='conversation__content__info'> + <div className='conversation__content__relative-time'> + {unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} /> + </div> + + <div className='conversation__content__names' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> + <FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} /> + </div> + </div> + + <StatusContent + status={lastStatus} + parseClick={this.parseClick} + expanded={isExpanded} + onExpandedToggle={this.handleShowMore} + collapsible + media={media} + /> + + <div className='status__action-bar'> + <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReply} /> + + <div className='status__action-bar-dropdown'> + <DropdownMenuContainer + scrollKey={scrollKey} + status={lastStatus} + items={menu} + icon='ellipsis-h' + size={18} + direction='right' + title={intl.formatMessage(messages.more)} + /> + </div> + </div> + </div> + </div> + </HotKeys> + ); + } + +} + +export default withRouter(injectIntl(Conversation)); diff --git a/app/javascript/flavours/blobfox/features/direct_timeline/components/conversations_list.jsx b/app/javascript/flavours/blobfox/features/direct_timeline/components/conversations_list.jsx new file mode 100644 index 00000000000000..8c12ea9e5f68a2 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/direct_timeline/components/conversations_list.jsx @@ -0,0 +1,77 @@ +import PropTypes from 'prop-types'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { debounce } from 'lodash'; + +import ScrollableList from '../../../components/scrollable_list'; +import ConversationContainer from '../containers/conversation_container'; + +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, isLoading, onLoadMore, ...other } = this.props; + + return ( + <ScrollableList {...other} isLoading={isLoading} showLoading={isLoading && conversations.isEmpty()} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}> + {conversations.map(item => ( + <ConversationContainer + key={item.get('id')} + conversationId={item.get('id')} + onMoveUp={this.handleMoveUp} + onMoveDown={this.handleMoveDown} + scrollKey={this.props.scrollKey} + /> + ))} + </ScrollableList> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/direct_timeline/containers/column_settings_container.js b/app/javascript/flavours/blobfox/features/direct_timeline/containers/column_settings_container.js new file mode 100644 index 00000000000000..e7cdbf60187b96 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/direct_timeline/containers/column_settings_container.js @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; + +import { changeSetting } from 'flavours/blobfox/actions/settings'; + +import ColumnSettings from '../components/column_settings'; + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'direct']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (path, checked) { + dispatch(changeSetting(['direct', ...path], checked)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/flavours/blobfox/features/direct_timeline/containers/conversation_container.js b/app/javascript/flavours/blobfox/features/direct_timeline/containers/conversation_container.js new file mode 100644 index 00000000000000..ab80d0629635d4 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/direct_timeline/containers/conversation_container.js @@ -0,0 +1,81 @@ +import { defineMessages, injectIntl } from 'react-intl'; + +import { connect } from 'react-redux'; + +import { replyCompose } from 'flavours/blobfox/actions/compose'; +import { markConversationRead, deleteConversation } from 'flavours/blobfox/actions/conversations'; +import { openModal } from 'flavours/blobfox/actions/modal'; +import { muteStatus, unmuteStatus, hideStatus, revealStatus } from 'flavours/blobfox/actions/statuses'; +import { makeGetStatus } from 'flavours/blobfox/selectors'; + +import Conversation from '../components/conversation'; + +const messages = defineMessages({ + 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?' }, +}); + +const mapStateToProps = () => { + const getStatus = makeGetStatus(); + + return (state, { conversationId }) => { + const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId); + const lastStatusId = conversation.get('last_status', null); + + return { + accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)), + unread: conversation.get('unread'), + lastStatus: lastStatusId && getStatus(state, { id: lastStatusId }), + settings: state.get('local_settings'), + }; + }; +}; + +const mapDispatchToProps = (dispatch, { intl, conversationId }) => ({ + + markRead () { + dispatch(markConversationRead(conversationId)); + }, + + reply (status, router) { + dispatch((_, getState) => { + let state = getState(); + + if (state.getIn(['compose', 'text']).trim().length !== 0) { + dispatch(openModal({ + modalType: 'CONFIRM', + modalProps: { + message: intl.formatMessage(messages.replyMessage), + confirm: intl.formatMessage(messages.replyConfirm), + onConfirm: () => dispatch(replyCompose(status, router)), + }, + })); + } else { + dispatch(replyCompose(status, router)); + } + }); + }, + + delete () { + dispatch(deleteConversation(conversationId)); + }, + + onMute (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'))); + } + }, + +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Conversation)); diff --git a/app/javascript/flavours/blobfox/features/direct_timeline/containers/conversations_list_container.js b/app/javascript/flavours/blobfox/features/direct_timeline/containers/conversations_list_container.js new file mode 100644 index 00000000000000..1dcd3ec1bd4ad3 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/direct_timeline/containers/conversations_list_container.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux'; + +import { expandConversations } from '../../../actions/conversations'; +import ConversationsList from '../components/conversations_list'; + +const mapStateToProps = state => ({ + conversations: state.getIn(['conversations', 'items']), + isLoading: state.getIn(['conversations', 'isLoading'], true), + hasMore: state.getIn(['conversations', 'hasMore'], false), +}); + +const mapDispatchToProps = dispatch => ({ + onLoadMore: maxId => dispatch(expandConversations({ maxId })), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ConversationsList); diff --git a/app/javascript/flavours/blobfox/features/direct_timeline/index.jsx b/app/javascript/flavours/blobfox/features/direct_timeline/index.jsx new file mode 100644 index 00000000000000..d47464f0720d7e --- /dev/null +++ b/app/javascript/flavours/blobfox/features/direct_timeline/index.jsx @@ -0,0 +1,165 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import { connect } from 'react-redux'; + +import { addColumn, removeColumn, moveColumn } from 'flavours/blobfox/actions/columns'; +import { mountConversations, unmountConversations, expandConversations } from 'flavours/blobfox/actions/conversations'; +import { connectDirectStream } from 'flavours/blobfox/actions/streaming'; +import { expandDirectTimeline } from 'flavours/blobfox/actions/timelines'; +import Column from 'flavours/blobfox/components/column'; +import ColumnHeader from 'flavours/blobfox/components/column_header'; +import StatusListContainer from 'flavours/blobfox/features/ui/containers/status_list_container'; + +import ColumnSettingsContainer from './containers/column_settings_container'; +import ConversationsListContainer from './containers/conversations_list_container'; + +const messages = defineMessages({ + title: { id: 'column.direct', defaultMessage: 'Private mentions' }, +}); + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0, + conversationsMode: state.getIn(['settings', 'direct', 'conversations']), +}); + +class DirectTimeline extends PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + columnId: PropTypes.string, + intl: PropTypes.object.isRequired, + hasUnread: PropTypes.bool, + multiColumn: PropTypes.bool, + conversationsMode: 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, conversationsMode } = this.props; + + dispatch(mountConversations()); + + if (conversationsMode) { + dispatch(expandConversations()); + } else { + dispatch(expandDirectTimeline()); + } + + this.disconnect = dispatch(connectDirectStream()); + } + + componentDidUpdate(prevProps) { + const { dispatch, conversationsMode } = this.props; + + if (prevProps.conversationsMode && !conversationsMode) { + dispatch(expandDirectTimeline()); + } else if (!prevProps.conversationsMode && conversationsMode) { + dispatch(expandConversations()); + } + } + + componentWillUnmount () { + this.props.dispatch(unmountConversations()); + + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + setRef = c => { + this.column = c; + }; + + handleLoadMoreTimeline = maxId => { + this.props.dispatch(expandDirectTimeline({ maxId })); + }; + + handleLoadMoreConversations = maxId => { + this.props.dispatch(expandConversations({ maxId })); + }; + + render () { + const { intl, hasUnread, columnId, multiColumn, conversationsMode } = this.props; + const pinned = !!columnId; + + let contents; + if (conversationsMode) { + contents = ( + <ConversationsListContainer + trackScroll={!pinned} + scrollKey={`direct_timeline-${columnId}`} + timelineId='direct' + bindToDocument={!multiColumn} + onLoadMore={this.handleLoadMore} + prepend={<div className='follow_requests-unlocked_explanation'><span><FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a></span></div>} + alwaysPrepend + emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any private mentions yet. When you send or receive one, it will show up here." />} + /> + ); + } else { + contents = ( + <StatusListContainer + trackScroll={!pinned} + scrollKey={`direct_timeline-${columnId}`} + timelineId='direct' + bindToDocument={!multiColumn} + onLoadMore={this.handleLoadMoreTimeline} + prepend={<div className='follow_requests-unlocked_explanation'><span><FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a></span></div>} + alwaysPrepend + emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any private mentions yet. When you send or receive one, it will show up here." />} + /> + ); + } + + return ( + <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}> + <ColumnHeader + icon='envelope' + active={hasUnread} + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + > + <ColumnSettingsContainer /> + </ColumnHeader> + + {contents} + + <Helmet> + <title>{intl.formatMessage(messages.title)}</title> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(DirectTimeline)); diff --git a/app/javascript/flavours/blobfox/features/directory/components/account_card.jsx b/app/javascript/flavours/blobfox/features/directory/components/account_card.jsx new file mode 100644 index 00000000000000..cdf38e06a972cc --- /dev/null +++ b/app/javascript/flavours/blobfox/features/directory/components/account_card.jsx @@ -0,0 +1,255 @@ +import PropTypes from 'prop-types'; + +import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; + +import classNames from 'classnames'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { + followAccount, + unfollowAccount, + unblockAccount, + unmuteAccount, +} from 'flavours/blobfox/actions/accounts'; +import { openModal } from 'flavours/blobfox/actions/modal'; +import { Avatar } from 'flavours/blobfox/components/avatar'; +import { Button } from 'flavours/blobfox/components/button'; +import { DisplayName } from 'flavours/blobfox/components/display_name'; +import { IconButton } from 'flavours/blobfox/components/icon_button'; +import Permalink from 'flavours/blobfox/components/permalink'; +import { ShortNumber } from 'flavours/blobfox/components/short_number'; +import { autoPlayGif, me, unfollowModal } from 'flavours/blobfox/initial_state'; +import { makeGetAccount } from 'flavours/blobfox/selectors'; + +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' }, + dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' }, +}); + +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({ + modalType: 'CONFIRM', + modalProps: { + message: ( + <FormattedMessage + id='confirmations.unfollow.message' + defaultMessage='Are you sure you want to unfollow {name}?' + values={{ name: <strong>@{account.get('acct')}</strong> }} + /> + ), + 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({ + modalType: 'CONFIRM', + modalProps: { + message: <FormattedMessage id='confirmations.cancel_follow_request.message' defaultMessage='Are you sure you want to withdraw your request to follow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, + 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'))); + } + }, + +}); + +class AccountCard extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.record.isRequired, + intl: PropTypes.object.isRequired, + onFollow: PropTypes.func.isRequired, + onBlock: PropTypes.func.isRequired, + onMute: PropTypes.func.isRequired, + onDismiss: PropTypes.func, + }; + + 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'); + }; + + handleDismiss = (e) => { + const { account, onDismiss } = this.props; + onDismiss(account.get('id')); + + e.preventDefault(); + e.stopPropagation(); + }; + + 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 = <Button text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.handleFollow} />; + } else if (account.getIn(['relationship', 'muting'])) { + actionBtn = <Button text={intl.formatMessage(messages.unmute)} onClick={this.handleMute} />; + } else if (!account.getIn(['relationship', 'blocking'])) { + actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames({ 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.handleFollow} />; + } else if (account.getIn(['relationship', 'blocking'])) { + actionBtn = <Button text={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />; + } + } else { + actionBtn = <Button text={intl.formatMessage(messages.edit_profile)} onClick={this.handleEditProfile} />; + } + + return ( + <div className='account-card'> + <Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='account-card__permalink'> + <div className='account-card__header'> + {this.props.onDismiss && <IconButton className='media-modal__close' title={intl.formatMessage(messages.dismissSuggestion)} icon='times' onClick={this.handleDismiss} size={20} />} + + <img + src={ + autoPlayGif ? account.get('header') : account.get('header_static') + } + alt='' + /> + </div> + + <div className='account-card__title'> + <div className='account-card__title__avatar'><Avatar account={account} size={56} /></div> + <DisplayName account={account} /> + </div> + </Permalink> + + {account.get('note').length > 0 && ( + <div + className='account-card__bio translate' + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }} + /> + )} + + <div className='account-card__actions'> + <div className='account-card__counters'> + <div className='account-card__counters__item'> + <ShortNumber value={account.get('statuses_count')} /> + <small> + <FormattedMessage id='account.posts' defaultMessage='Posts' /> + </small> + </div> + + <div className='account-card__counters__item'> + {account.get('followers_count') < 0 ? '-' : <ShortNumber value={account.get('followers_count')} />}{' '} + <small> + <FormattedMessage + id='account.followers' + defaultMessage='Followers' + /> + </small> + </div> + + <div className='account-card__counters__item'> + <ShortNumber value={account.get('following_count')} />{' '} + <small> + <FormattedMessage + id='account.following' + defaultMessage='Following' + /> + </small> + </div> + </div> + + <div className='account-card__actions__button'> + {actionBtn} + </div> + </div> + </div> + ); + } + +} + +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(AccountCard)); diff --git a/app/javascript/flavours/blobfox/features/directory/index.jsx b/app/javascript/flavours/blobfox/features/directory/index.jsx new file mode 100644 index 00000000000000..95caaae1211629 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/directory/index.jsx @@ -0,0 +1,179 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import { List as ImmutableList } from 'immutable'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +import { addColumn, removeColumn, moveColumn, changeColumnParams } from 'flavours/blobfox/actions/columns'; +import { fetchDirectory, expandDirectory } from 'flavours/blobfox/actions/directory'; +import Column from 'flavours/blobfox/components/column'; +import ColumnHeader from 'flavours/blobfox/components/column_header'; +import { LoadMore } from 'flavours/blobfox/components/load_more'; +import { LoadingIndicator } from 'flavours/blobfox/components/loading_indicator'; +import { RadioButton } from 'flavours/blobfox/components/radio_button'; +import ScrollContainer from 'flavours/blobfox/containers/scroll_container'; + +import AccountCard from './components/account_card'; + +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']), +}); + +class Directory extends PureComponent { + + 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 = ( + <div className='scrollable'> + <div className='filter-form'> + <div className='filter-form__column' role='group'> + <RadioButton name='order' value='active' label={intl.formatMessage(messages.recentlyActive)} checked={order === 'active'} onChange={this.handleChangeOrder} /> + <RadioButton name='order' value='new' label={intl.formatMessage(messages.newArrivals)} checked={order === 'new'} onChange={this.handleChangeOrder} /> + </div> + + <div className='filter-form__column' role='group'> + <RadioButton name='local' value='1' label={intl.formatMessage(messages.local, { domain })} checked={local} onChange={this.handleChangeLocal} /> + <RadioButton name='local' value='0' label={intl.formatMessage(messages.federated)} checked={!local} onChange={this.handleChangeLocal} /> + </div> + </div> + + <div className='directory__list'> + {isLoading ? <LoadingIndicator /> : accountIds.map(accountId => ( + <AccountCard id={accountId} key={accountId} /> + ))} + </div> + + <LoadMore onClick={this.handleLoadMore} visible={!isLoading} /> + </div> + ); + + return ( + <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}> + <ColumnHeader + icon='address-book-o' + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + /> + + {multiColumn && !pinned ? <ScrollContainer scrollKey='directory'>{scrollableArea}</ScrollContainer> : scrollableArea} + + <Helmet> + <title>{intl.formatMessage(messages.title)}</title> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(Directory)); diff --git a/app/javascript/flavours/blobfox/features/domain_blocks/index.jsx b/app/javascript/flavours/blobfox/features/domain_blocks/index.jsx new file mode 100644 index 00000000000000..9e63b2f8170570 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/domain_blocks/index.jsx @@ -0,0 +1,87 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { debounce } from 'lodash'; + +import { fetchDomainBlocks, expandDomainBlocks } from '../../actions/domain_blocks'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import { LoadingIndicator } from '../../components/loading_indicator'; +import ScrollableList from '../../components/scrollable_list'; +import DomainContainer from '../../containers/domain_container'; +import Column from '../ui/components/column'; + +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']), +}); + +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, + }; + + UNSAFE_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 ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + const emptyMessage = <FormattedMessage id='empty_column.domain_blocks' defaultMessage='There are no blocked domains yet.' />; + + return ( + <Column bindToDocument={!multiColumn} icon='minus-circle' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + + <ScrollableList + scrollKey='domain_blocks' + onLoadMore={this.handleLoadMore} + hasMore={hasMore} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {domains.map(domain => + <DomainContainer key={domain} domain={domain} />, + )} + </ScrollableList> + + <Helmet> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(Blocks)); diff --git a/app/javascript/flavours/blobfox/features/emoji/emoji.js b/app/javascript/flavours/blobfox/features/emoji/emoji.js new file mode 100644 index 00000000000000..d283871211f104 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/emoji/emoji.js @@ -0,0 +1,166 @@ +import Trie from 'substring-trie'; + +import { assetHost } from 'flavours/blobfox/utils/config'; + +import { autoPlayGif, useSystemEmojiFont } from '../../initial_state'; + +import { unicodeMapping } from './emoji_unicode_mapping_light'; + +const trie = new Trie(Object.keys(unicodeMapping)); + +// Convert to file names from emojis. (For different variation selector emojis) +const emojiFilenames = (emojis) => { + return emojis.map(v => unicodeMapping[v].filename); +}; + +// Emoji requiring extra borders depending on theme +const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞', '🕺', '📱', '📲', '🚲']); +const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️']); + +const emojiFilename = (filename) => { + const borderedEmoji = (document.body && document.body.classList.contains('skin-mastodon-light')) ? lightEmoji : darkEmoji; + return borderedEmoji.includes(filename) ? (filename + '_border') : filename; +}; + +const emojifyTextNode = (node, customEmojis) => { + const VS15 = 0xFE0E; + const VS16 = 0xFE0F; + + let str = node.textContent; + + const fragment = new DocumentFragment(); + let i = 0; + + for (;;) { + let unicode_emoji; + + // Skip to the next potential emoji to replace (either custom emoji or custom emoji :shortcode:) + if (customEmojis === null) { + while (i < str.length && (useSystemEmojiFont || !(unicode_emoji = trie.search(str.slice(i))))) { + i += str.codePointAt(i) < 65536 ? 1 : 2; + } + } else { + while (i < str.length && str[i] !== ':' && (useSystemEmojiFont || !(unicode_emoji = trie.search(str.slice(i))))) { + i += str.codePointAt(i) < 65536 ? 1 : 2; + } + } + + // We reached the end of the string, nothing to replace + if (i === str.length) { + break; + } + + let rend, replacement = null; + if (str[i] === ':') { // Potentially the start of a custom emoji :shortcode: + rend = str.indexOf(':', i + 1) + 1; + + // no matching ending ':', skip + if (!rend) { + i++; + continue; + } + + const shortcode = str.slice(i, rend); + const custom_emoji = customEmojis[shortcode]; + + // not a recognized shortcode, skip + if (!custom_emoji) { + i++; + continue; + } + + // now got a replacee as ':shortcode:' + // if you want additional emoji handler, add statements below which set replacement and return true. + const filename = autoPlayGif ? custom_emoji.url : custom_emoji.static_url; + replacement = document.createElement('img'); + replacement.setAttribute('draggable', 'false'); + replacement.setAttribute('class', 'emojione custom-emoji'); + replacement.setAttribute('alt', shortcode); + replacement.setAttribute('title', shortcode); + replacement.setAttribute('src', filename); + replacement.setAttribute('data-original', custom_emoji.url); + replacement.setAttribute('data-static', custom_emoji.static_url); + } else { // start of an unicode emoji + rend = i + unicode_emoji.length; + + // If the matched character was followed by VS15 (for selecting text presentation), skip it. + if (str.codePointAt(rend - 1) !== VS16 && str.codePointAt(rend) === VS15) { + i = rend + 1; + continue; + } + + const { filename, shortCode } = unicodeMapping[unicode_emoji]; + const title = shortCode ? `:${shortCode}:` : ''; + + replacement = document.createElement('img'); + replacement.setAttribute('draggable', 'false'); + replacement.setAttribute('class', 'emojione'); + replacement.setAttribute('alt', unicode_emoji); + replacement.setAttribute('title', title); + replacement.setAttribute('src', `${assetHost}/emoji/${emojiFilename(filename)}.svg`); + } + + // Add the processed-up-to-now string and the emoji replacement + fragment.append(document.createTextNode(str.slice(0, i))); + fragment.append(replacement); + str = str.slice(rend); + i = 0; + } + + fragment.append(document.createTextNode(str)); + node.parentElement.replaceChild(fragment, node); +}; + +const emojifyNode = (node, customEmojis) => { + for (const child of node.childNodes) { + switch(child.nodeType) { + case Node.TEXT_NODE: + emojifyTextNode(child, customEmojis); + break; + case Node.ELEMENT_NODE: + if (!child.classList.contains('invisible')) + emojifyNode(child, customEmojis); + break; + } + } +}; + +const emojify = (str, customEmojis = {}) => { + const wrapper = document.createElement('div'); + wrapper.innerHTML = str; + + if (!Object.keys(customEmojis).length) + customEmojis = null; + + emojifyNode(wrapper, customEmojis); + + return wrapper.innerHTML; +}; + +export default emojify; + +export const buildCustomEmojis = (customEmojis) => { + const emojis = []; + + customEmojis.forEach(emoji => { + const shortcode = emoji.get('shortcode'); + const url = autoPlayGif ? emoji.get('url') : emoji.get('static_url'); + const name = shortcode.replace(':', ''); + + emojis.push({ + id: name, + name, + short_names: [name], + text: '', + emoticons: [], + keywords: [name], + imageUrl: url, + custom: true, + customCategory: emoji.get('category'), + }); + }); + + return emojis; +}; + +export const categoriesFromEmojis = customEmojis => customEmojis.reduce((set, emoji) => set.add(emoji.get('category') ? `custom-${emoji.get('category')}` : 'custom'), new Set(['custom'])); diff --git a/app/javascript/flavours/blobfox/features/emoji/emoji_compressed.d.ts b/app/javascript/flavours/blobfox/features/emoji/emoji_compressed.d.ts new file mode 100644 index 00000000000000..3b627325a09c95 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/emoji/emoji_compressed.d.ts @@ -0,0 +1,57 @@ +import type { BaseEmoji, EmojiData, NimbleEmojiIndex } from 'emoji-mart'; +import type { Category, Data, Emoji } from 'emoji-mart/dist-es/utils/data'; + +/* + * The 'search' property, although not defined in the [`Emoji`]{@link node_modules/@types/emoji-mart/dist-es/utils/data.d.ts#Emoji} type, + * is used in the application. + * This could be due to an oversight by the library maintainer. + * The `search` property is defined and used [here]{@link node_modules/emoji-mart/dist/utils/data.js#uncompress}. + */ +export type Search = string; +/* + * The 'skins' property does not exist in the application data. + * This could be a potential area of refactoring or error handling. + * The non-existence of 'skins' property is evident at [this location]{@link app/javascript/flavours/blobfox/features/emoji/emoji_compressed.js:121}. + */ +type Skins = null; + +type Filename = string; +type UnicodeFilename = string; +export type FilenameData = [ + filename: Filename, + unicodeFilename?: UnicodeFilename, +][]; +export type ShortCodesToEmojiDataKey = + | EmojiData['id'] + | BaseEmoji['native'] + | keyof NimbleEmojiIndex['emojis']; + +type SearchData = [ + BaseEmoji['native'], + Emoji['short_names'], + Search, + Emoji['unified'], +]; + +export type ShortCodesToEmojiData = Record< + ShortCodesToEmojiDataKey, + [FilenameData, SearchData] +>; +type EmojisWithoutShortCodes = FilenameData; + +type EmojiCompressed = [ + ShortCodesToEmojiData, + Skins, + Category[], + Data['aliases'], + EmojisWithoutShortCodes, +]; + +/* + * `emoji_compressed.js` uses `babel-plugin-preval`, which makes it difficult to convert to TypeScript. + * As a temporary solution, we are allowing a default export here to apply the TypeScript type `EmojiCompressed` to the JS file export. + * - {@link app/javascript/flavours/blobfox/features/emoji/emoji_compressed.js} + */ +declare const emojiCompressed: EmojiCompressed; + +export default emojiCompressed; // eslint-disable-line import/no-default-export diff --git a/app/javascript/flavours/blobfox/features/emoji/emoji_compressed.js b/app/javascript/flavours/blobfox/features/emoji/emoji_compressed.js new file mode 100644 index 00000000000000..40cf707a03e51f --- /dev/null +++ b/app/javascript/flavours/blobfox/features/emoji/emoji_compressed.js @@ -0,0 +1,135 @@ +/* eslint-disable import/no-commonjs -- + We need to use CommonJS here due to preval */ +// @preval +// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt +// This file contains the compressed version of the emoji data from +// both emoji_map.json and from emoji-mart's emojiIndex and data objects. +// It's designed to be emitted in an array format to take up less space +// over the wire. + +const { emojiIndex } = require('emoji-mart'); +let data = require('emoji-mart/data/all.json'); +const { uncompress: emojiMartUncompress } = require('emoji-mart/dist/utils/data'); + +const emojiMap = require('./emoji_map.json'); +const { unicodeToFilename } = require('./unicode_to_filename'); +const { unicodeToUnifiedName } = require('./unicode_to_unified_name'); + + +if(data.compressed) { + data = emojiMartUncompress(data); +} + +const emojiMartData = data; + +const excluded = ['®', '©', '™']; +const skinTones = ['🏻', '🏼', '🏽', '🏾', '🏿']; +const shortcodeMap = {}; + +const shortCodesToEmojiData = {}; +const emojisWithoutShortCodes = []; + +Object.keys(emojiIndex.emojis).forEach(key => { + let emoji = emojiIndex.emojis[key]; + + // Emojis with skin tone modifiers are stored like this + if (Object.prototype.hasOwnProperty.call(emoji, '1')) { + emoji = emoji['1']; + } + + shortcodeMap[emoji.native] = emoji.id; +}); + +const stripModifiers = unicode => { + skinTones.forEach(tone => { + unicode = unicode.replace(tone, ''); + }); + + return unicode; +}; + +Object.keys(emojiMap).forEach(key => { + if (excluded.includes(key)) { + delete emojiMap[key]; + return; + } + + const normalizedKey = stripModifiers(key); + let shortcode = shortcodeMap[normalizedKey]; + + if (!shortcode) { + shortcode = shortcodeMap[normalizedKey + '\uFE0F']; + } + + const filename = emojiMap[key]; + + const filenameData = [key]; + + if (unicodeToFilename(key) !== filename) { + // filename can't be derived using unicodeToFilename + filenameData.push(filename); + } + + if (typeof shortcode === 'undefined') { + emojisWithoutShortCodes.push(filenameData); + } else { + if (!Array.isArray(shortCodesToEmojiData[shortcode])) { + shortCodesToEmojiData[shortcode] = [[]]; + } + + shortCodesToEmojiData[shortcode][0].push(filenameData); + } +}); + +Object.keys(emojiIndex.emojis).forEach(key => { + let emoji = emojiIndex.emojis[key]; + + // Emojis with skin tone modifiers are stored like this + if (Object.prototype.hasOwnProperty.call(emoji, '1')) { + emoji = emoji['1']; + } + + const { native } = emoji; + let { short_names, search, unified } = emojiMartData.emojis[key]; + + if (short_names[0] !== key) { + throw new Error('The compressor expects the first short_code to be the ' + + 'key. It may need to be rewritten if the emoji change such that this ' + + 'is no longer the case.'); + } + + short_names = short_names.slice(1); // first short name can be inferred from the key + + const searchData = [native, short_names, search]; + + if (unicodeToUnifiedName(native) !== unified) { + // unified name can't be derived from unicodeToUnifiedName + searchData.push(unified); + } + + if (!Array.isArray(shortCodesToEmojiData[key])) { + shortCodesToEmojiData[key] = [[]]; + } + + shortCodesToEmojiData[key].push(searchData); +}); + +// JSON.parse/stringify is to emulate what @preval is doing and avoid any +// inconsistent behavior in dev mode +module.exports = JSON.parse(JSON.stringify([ + shortCodesToEmojiData, + /* + * The property `skins` is not found in the current context. + * This could potentially lead to issues when interacting with modules or data structures + * that expect the presence of `skins` property. + * Currently, no definitions or references to `skins` property can be found in: + * - {@link node_modules/emoji-mart/dist/utils/data.js} + * - {@link node_modules/emoji-mart/data/all.json} + * - {@link app/javascript/flavours/blobfox/features/emoji/emoji_compressed.d.ts#Skins} + * Future refactorings or updates should consider adding definitions or handling for `skins` property. + */ + emojiMartData.skins, + emojiMartData.categories, + emojiMartData.aliases, + emojisWithoutShortCodes, +])); diff --git a/app/javascript/flavours/blobfox/features/emoji/emoji_map.json b/app/javascript/flavours/blobfox/features/emoji/emoji_map.json new file mode 100644 index 00000000000000..64f6615b7932f4 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/emoji/emoji_map.json @@ -0,0 +1 @@ +{"😀":"1f600","😃":"1f603","😄":"1f604","😁":"1f601","😆":"1f606","😅":"1f605","🤣":"1f923","😂":"1f602","🙂":"1f642","🙃":"1f643","🫠":"1fae0","😉":"1f609","😊":"1f60a","😇":"1f607","🥰":"1f970","😍":"1f60d","🤩":"1f929","😘":"1f618","😗":"1f617","☺":"263a","😚":"1f61a","😙":"1f619","🥲":"1f972","😋":"1f60b","😛":"1f61b","😜":"1f61c","🤪":"1f92a","😝":"1f61d","🤑":"1f911","🤗":"1f917","🤭":"1f92d","🫢":"1fae2","🫣":"1fae3","🤫":"1f92b","🤔":"1f914","🫡":"1fae1","🤐":"1f910","🤨":"1f928","😐":"1f610","😑":"1f611","😶":"1f636","🫥":"1fae5","😏":"1f60f","😒":"1f612","🙄":"1f644","😬":"1f62c","🤥":"1f925","😌":"1f60c","😔":"1f614","😪":"1f62a","🤤":"1f924","😴":"1f634","😷":"1f637","🤒":"1f912","🤕":"1f915","🤢":"1f922","🤮":"1f92e","🤧":"1f927","🥵":"1f975","🥶":"1f976","🥴":"1f974","😵":"1f635","🤯":"1f92f","🤠":"1f920","🥳":"1f973","🥸":"1f978","😎":"1f60e","🤓":"1f913","🧐":"1f9d0","😕":"1f615","🫤":"1fae4","😟":"1f61f","🙁":"1f641","☹":"2639","😮":"1f62e","😯":"1f62f","😲":"1f632","😳":"1f633","🥺":"1f97a","🥹":"1f979","😦":"1f626","😧":"1f627","😨":"1f628","😰":"1f630","😥":"1f625","😢":"1f622","😭":"1f62d","😱":"1f631","😖":"1f616","😣":"1f623","😞":"1f61e","😓":"1f613","😩":"1f629","😫":"1f62b","🥱":"1f971","😤":"1f624","😡":"1f621","😠":"1f620","🤬":"1f92c","😈":"1f608","👿":"1f47f","💀":"1f480","☠":"2620","💩":"1f4a9","🤡":"1f921","👹":"1f479","👺":"1f47a","👻":"1f47b","👽":"1f47d","👾":"1f47e","🤖":"1f916","😺":"1f63a","😸":"1f638","😹":"1f639","😻":"1f63b","😼":"1f63c","😽":"1f63d","🙀":"1f640","😿":"1f63f","😾":"1f63e","🙈":"1f648","🙉":"1f649","🙊":"1f64a","💋":"1f48b","💌":"1f48c","💘":"1f498","💝":"1f49d","💖":"1f496","💗":"1f497","💓":"1f493","💞":"1f49e","💕":"1f495","💟":"1f49f","❣":"2763","💔":"1f494","❤":"2764","🧡":"1f9e1","💛":"1f49b","💚":"1f49a","💙":"1f499","💜":"1f49c","🤎":"1f90e","🖤":"1f5a4","🤍":"1f90d","💯":"1f4af","💢":"1f4a2","💥":"1f4a5","💫":"1f4ab","💦":"1f4a6","💨":"1f4a8","🕳":"1f573","💣":"1f4a3","💬":"1f4ac","🗨":"1f5e8","🗯":"1f5ef","💭":"1f4ad","💤":"1f4a4","👋":"1f44b","🤚":"1f91a","🖐":"1f590","✋":"270b","🖖":"1f596","🫱":"1faf1","🫲":"1faf2","🫳":"1faf3","🫴":"1faf4","👌":"1f44c","🤌":"1f90c","🤏":"1f90f","✌":"270c","🤞":"1f91e","🫰":"1faf0","🤟":"1f91f","🤘":"1f918","🤙":"1f919","👈":"1f448","👉":"1f449","👆":"1f446","🖕":"1f595","👇":"1f447","☝":"261d","🫵":"1faf5","👍":"1f44d","👎":"1f44e","✊":"270a","👊":"1f44a","🤛":"1f91b","🤜":"1f91c","👏":"1f44f","🙌":"1f64c","🫶":"1faf6","👐":"1f450","🤲":"1f932","🤝":"1f91d","🙏":"1f64f","✍":"270d","💅":"1f485","🤳":"1f933","💪":"1f4aa","🦾":"1f9be","🦿":"1f9bf","🦵":"1f9b5","🦶":"1f9b6","👂":"1f442","🦻":"1f9bb","👃":"1f443","🧠":"1f9e0","🫀":"1fac0","🫁":"1fac1","🦷":"1f9b7","🦴":"1f9b4","👀":"1f440","👁":"1f441","👅":"1f445","👄":"1f444","🫦":"1fae6","👶":"1f476","🧒":"1f9d2","👦":"1f466","👧":"1f467","🧑":"1f9d1","👱":"1f471","👨":"1f468","🧔":"1f9d4","👩":"1f469","🧓":"1f9d3","👴":"1f474","👵":"1f475","🙍":"1f64d","🙎":"1f64e","🙅":"1f645","🙆":"1f646","💁":"1f481","🙋":"1f64b","🧏":"1f9cf","🙇":"1f647","🤦":"1f926","🤷":"1f937","👮":"1f46e","🕵":"1f575","💂":"1f482","🥷":"1f977","👷":"1f477","🫅":"1fac5","🤴":"1f934","👸":"1f478","👳":"1f473","👲":"1f472","🧕":"1f9d5","🤵":"1f935","👰":"1f470","🤰":"1f930","🫃":"1fac3","🫄":"1fac4","🤱":"1f931","👼":"1f47c","🎅":"1f385","🤶":"1f936","🦸":"1f9b8","🦹":"1f9b9","🧙":"1f9d9","🧚":"1f9da","🧛":"1f9db","🧜":"1f9dc","🧝":"1f9dd","🧞":"1f9de","🧟":"1f9df","🧌":"1f9cc","💆":"1f486","💇":"1f487","🚶":"1f6b6","🧍":"1f9cd","🧎":"1f9ce","🏃":"1f3c3","💃":"1f483","🕺":"1f57a","🕴":"1f574","👯":"1f46f","🧖":"1f9d6","🧗":"1f9d7","🤺":"1f93a","🏇":"1f3c7","⛷":"26f7","🏂":"1f3c2","🏌":"1f3cc","🏄":"1f3c4","🚣":"1f6a3","🏊":"1f3ca","⛹":"26f9","🏋":"1f3cb","🚴":"1f6b4","🚵":"1f6b5","🤸":"1f938","🤼":"1f93c","🤽":"1f93d","🤾":"1f93e","🤹":"1f939","🧘":"1f9d8","🛀":"1f6c0","🛌":"1f6cc","👭":"1f46d","👫":"1f46b","👬":"1f46c","💏":"1f48f","💑":"1f491","👪":"1f46a","🗣":"1f5e3","👤":"1f464","👥":"1f465","🫂":"1fac2","👣":"1f463","🏻":"1f463","🏼":"1f463","🏽":"1f463","🏾":"1f463","🏿":"1f463","🦰":"1f463","🦱":"1f463","🦳":"1f463","🦲":"1f463","🐵":"1f435","🐒":"1f412","🦍":"1f98d","🦧":"1f9a7","🐶":"1f436","🐕":"1f415","🦮":"1f9ae","🐩":"1f429","🐺":"1f43a","🦊":"1f98a","🦝":"1f99d","🐱":"1f431","🐈":"1f408","🦁":"1f981","🐯":"1f42f","🐅":"1f405","🐆":"1f406","🐴":"1f434","🐎":"1f40e","🦄":"1f984","🦓":"1f993","🦌":"1f98c","🦬":"1f9ac","🐮":"1f42e","🐂":"1f402","🐃":"1f403","🐄":"1f404","🐷":"1f437","🐖":"1f416","🐗":"1f417","🐽":"1f43d","🐏":"1f40f","🐑":"1f411","🐐":"1f410","🐪":"1f42a","🐫":"1f42b","🦙":"1f999","🦒":"1f992","🐘":"1f418","🦣":"1f9a3","🦏":"1f98f","🦛":"1f99b","🐭":"1f42d","🐁":"1f401","🐀":"1f400","🐹":"1f439","🐰":"1f430","🐇":"1f407","🐿":"1f43f","🦫":"1f9ab","🦔":"1f994","🦇":"1f987","🐻":"1f43b","🐨":"1f428","🐼":"1f43c","🦥":"1f9a5","🦦":"1f9a6","🦨":"1f9a8","🦘":"1f998","🦡":"1f9a1","🐾":"1f43e","🦃":"1f983","🐔":"1f414","🐓":"1f413","🐣":"1f423","🐤":"1f424","🐥":"1f425","🐦":"1f426","🐧":"1f427","🕊":"1f54a","🦅":"1f985","🦆":"1f986","🦢":"1f9a2","🦉":"1f989","🦤":"1f9a4","🪶":"1fab6","🦩":"1f9a9","🦚":"1f99a","🦜":"1f99c","🐸":"1f438","🐊":"1f40a","🐢":"1f422","🦎":"1f98e","🐍":"1f40d","🐲":"1f432","🐉":"1f409","🦕":"1f995","🦖":"1f996","🐳":"1f433","🐋":"1f40b","🐬":"1f42c","🦭":"1f9ad","🐟":"1f41f","🐠":"1f420","🐡":"1f421","🦈":"1f988","🐙":"1f419","🐚":"1f41a","🪸":"1fab8","🐌":"1f40c","🦋":"1f98b","🐛":"1f41b","🐜":"1f41c","🐝":"1f41d","🪲":"1fab2","🐞":"1f41e","🦗":"1f997","🪳":"1fab3","🕷":"1f577","🕸":"1f578","🦂":"1f982","🦟":"1f99f","🪰":"1fab0","🪱":"1fab1","🦠":"1f9a0","💐":"1f490","🌸":"1f338","💮":"1f4ae","🪷":"1fab7","🏵":"1f3f5","🌹":"1f339","🥀":"1f940","🌺":"1f33a","🌻":"1f33b","🌼":"1f33c","🌷":"1f337","🌱":"1f331","🪴":"1fab4","🌲":"1f332","🌳":"1f333","🌴":"1f334","🌵":"1f335","🌾":"1f33e","🌿":"1f33f","☘":"2618","🍀":"1f340","🍁":"1f341","🍂":"1f342","🍃":"1f343","🪹":"1fab9","🪺":"1faba","🍇":"1f347","🍈":"1f348","🍉":"1f349","🍊":"1f34a","🍋":"1f34b","🍌":"1f34c","🍍":"1f34d","🥭":"1f96d","🍎":"1f34e","🍏":"1f34f","🍐":"1f350","🍑":"1f351","🍒":"1f352","🍓":"1f353","🫐":"1fad0","🥝":"1f95d","🍅":"1f345","🫒":"1fad2","🥥":"1f965","🥑":"1f951","🍆":"1f346","🥔":"1f954","🥕":"1f955","🌽":"1f33d","🌶":"1f336","🫑":"1fad1","🥒":"1f952","🥬":"1f96c","🥦":"1f966","🧄":"1f9c4","🧅":"1f9c5","🍄":"1f344","🥜":"1f95c","🫘":"1fad8","🌰":"1f330","🍞":"1f35e","🥐":"1f950","🥖":"1f956","🫓":"1fad3","🥨":"1f968","🥯":"1f96f","🥞":"1f95e","🧇":"1f9c7","🧀":"1f9c0","🍖":"1f356","🍗":"1f357","🥩":"1f969","🥓":"1f953","🍔":"1f354","🍟":"1f35f","🍕":"1f355","🌭":"1f32d","🥪":"1f96a","🌮":"1f32e","🌯":"1f32f","🫔":"1fad4","🥙":"1f959","🧆":"1f9c6","🥚":"1f95a","🍳":"1f373","🥘":"1f958","🍲":"1f372","🫕":"1fad5","🥣":"1f963","🥗":"1f957","🍿":"1f37f","🧈":"1f9c8","🧂":"1f9c2","🥫":"1f96b","🍱":"1f371","🍘":"1f358","🍙":"1f359","🍚":"1f35a","🍛":"1f35b","🍜":"1f35c","🍝":"1f35d","🍠":"1f360","🍢":"1f362","🍣":"1f363","🍤":"1f364","🍥":"1f365","🥮":"1f96e","🍡":"1f361","🥟":"1f95f","🥠":"1f960","🥡":"1f961","🦀":"1f980","🦞":"1f99e","🦐":"1f990","🦑":"1f991","🦪":"1f9aa","🍦":"1f366","🍧":"1f367","🍨":"1f368","🍩":"1f369","🍪":"1f36a","🎂":"1f382","🍰":"1f370","🧁":"1f9c1","🥧":"1f967","🍫":"1f36b","🍬":"1f36c","🍭":"1f36d","🍮":"1f36e","🍯":"1f36f","🍼":"1f37c","🥛":"1f95b","☕":"2615","🫖":"1fad6","🍵":"1f375","🍶":"1f376","🍾":"1f37e","🍷":"1f377","🍸":"1f378","🍹":"1f379","🍺":"1f37a","🍻":"1f37b","🥂":"1f942","🥃":"1f943","🫗":"1fad7","🥤":"1f964","🧋":"1f9cb","🧃":"1f9c3","🧉":"1f9c9","🧊":"1f9ca","🥢":"1f962","🍽":"1f37d","🍴":"1f374","🥄":"1f944","🔪":"1f52a","🫙":"1fad9","🏺":"1f3fa","🌍":"1f30d","🌎":"1f30e","🌏":"1f30f","🌐":"1f310","🗺":"1f5fa","🗾":"1f5fe","🧭":"1f9ed","🏔":"1f3d4","⛰":"26f0","🌋":"1f30b","🗻":"1f5fb","🏕":"1f3d5","🏖":"1f3d6","🏜":"1f3dc","🏝":"1f3dd","🏞":"1f3de","🏟":"1f3df","🏛":"1f3db","🏗":"1f3d7","🧱":"1f9f1","🪨":"1faa8","🪵":"1fab5","🛖":"1f6d6","🏘":"1f3d8","🏚":"1f3da","🏠":"1f3e0","🏡":"1f3e1","🏢":"1f3e2","🏣":"1f3e3","🏤":"1f3e4","🏥":"1f3e5","🏦":"1f3e6","🏨":"1f3e8","🏩":"1f3e9","🏪":"1f3ea","🏫":"1f3eb","🏬":"1f3ec","🏭":"1f3ed","🏯":"1f3ef","🏰":"1f3f0","💒":"1f492","🗼":"1f5fc","🗽":"1f5fd","⛪":"26ea","🕌":"1f54c","🛕":"1f6d5","🕍":"1f54d","⛩":"26e9","🕋":"1f54b","⛲":"26f2","⛺":"26fa","🌁":"1f301","🌃":"1f303","🏙":"1f3d9","🌄":"1f304","🌅":"1f305","🌆":"1f306","🌇":"1f307","🌉":"1f309","♨":"2668","🎠":"1f3a0","🛝":"1f6dd","🎡":"1f3a1","🎢":"1f3a2","💈":"1f488","🎪":"1f3aa","🚂":"1f682","🚃":"1f683","🚄":"1f684","🚅":"1f685","🚆":"1f686","🚇":"1f687","🚈":"1f688","🚉":"1f689","🚊":"1f68a","🚝":"1f69d","🚞":"1f69e","🚋":"1f68b","🚌":"1f68c","🚍":"1f68d","🚎":"1f68e","🚐":"1f690","🚑":"1f691","🚒":"1f692","🚓":"1f693","🚔":"1f694","🚕":"1f695","🚖":"1f696","🚗":"1f697","🚘":"1f698","🚙":"1f699","🛻":"1f6fb","🚚":"1f69a","🚛":"1f69b","🚜":"1f69c","🏎":"1f3ce","🏍":"1f3cd","🛵":"1f6f5","🦽":"1f9bd","🦼":"1f9bc","🛺":"1f6fa","🚲":"1f6b2","🛴":"1f6f4","🛹":"1f6f9","🛼":"1f6fc","🚏":"1f68f","🛣":"1f6e3","🛤":"1f6e4","🛢":"1f6e2","⛽":"26fd","🛞":"1f6de","🚨":"1f6a8","🚥":"1f6a5","🚦":"1f6a6","🛑":"1f6d1","🚧":"1f6a7","⚓":"2693","🛟":"1f6df","⛵":"26f5","🛶":"1f6f6","🚤":"1f6a4","🛳":"1f6f3","⛴":"26f4","🛥":"1f6e5","🚢":"1f6a2","✈":"2708","🛩":"1f6e9","🛫":"1f6eb","🛬":"1f6ec","🪂":"1fa82","💺":"1f4ba","🚁":"1f681","🚟":"1f69f","🚠":"1f6a0","🚡":"1f6a1","🛰":"1f6f0","🚀":"1f680","🛸":"1f6f8","🛎":"1f6ce","🧳":"1f9f3","⌛":"231b","⏳":"23f3","⌚":"231a","⏰":"23f0","⏱":"23f1","⏲":"23f2","🕰":"1f570","🕛":"1f55b","🕧":"1f567","🕐":"1f550","🕜":"1f55c","🕑":"1f551","🕝":"1f55d","🕒":"1f552","🕞":"1f55e","🕓":"1f553","🕟":"1f55f","🕔":"1f554","🕠":"1f560","🕕":"1f555","🕡":"1f561","🕖":"1f556","🕢":"1f562","🕗":"1f557","🕣":"1f563","🕘":"1f558","🕤":"1f564","🕙":"1f559","🕥":"1f565","🕚":"1f55a","🕦":"1f566","🌑":"1f311","🌒":"1f312","🌓":"1f313","🌔":"1f314","🌕":"1f315","🌖":"1f316","🌗":"1f317","🌘":"1f318","🌙":"1f319","🌚":"1f31a","🌛":"1f31b","🌜":"1f31c","🌡":"1f321","☀":"2600","🌝":"1f31d","🌞":"1f31e","🪐":"1fa90","⭐":"2b50","🌟":"1f31f","🌠":"1f320","🌌":"1f30c","☁":"2601","⛅":"26c5","⛈":"26c8","🌤":"1f324","🌥":"1f325","🌦":"1f326","🌧":"1f327","🌨":"1f328","🌩":"1f329","🌪":"1f32a","🌫":"1f32b","🌬":"1f32c","🌀":"1f300","🌈":"1f308","🌂":"1f302","☂":"2602","☔":"2614","⛱":"26f1","⚡":"26a1","❄":"2744","☃":"2603","⛄":"26c4","☄":"2604","🔥":"1f525","💧":"1f4a7","🌊":"1f30a","🎃":"1f383","🎄":"1f384","🎆":"1f386","🎇":"1f387","🧨":"1f9e8","✨":"2728","🎈":"1f388","🎉":"1f389","🎊":"1f38a","🎋":"1f38b","🎍":"1f38d","🎎":"1f38e","🎏":"1f38f","🎐":"1f390","🎑":"1f391","🧧":"1f9e7","🎀":"1f380","🎁":"1f381","🎗":"1f397","🎟":"1f39f","🎫":"1f3ab","🎖":"1f396","🏆":"1f3c6","🏅":"1f3c5","🥇":"1f947","🥈":"1f948","🥉":"1f949","⚽":"26bd","⚾":"26be","🥎":"1f94e","🏀":"1f3c0","🏐":"1f3d0","🏈":"1f3c8","🏉":"1f3c9","🎾":"1f3be","🥏":"1f94f","🎳":"1f3b3","🏏":"1f3cf","🏑":"1f3d1","🏒":"1f3d2","🥍":"1f94d","🏓":"1f3d3","🏸":"1f3f8","🥊":"1f94a","🥋":"1f94b","🥅":"1f945","⛳":"26f3","⛸":"26f8","🎣":"1f3a3","🤿":"1f93f","🎽":"1f3bd","🎿":"1f3bf","🛷":"1f6f7","🥌":"1f94c","🎯":"1f3af","🪀":"1fa80","🪁":"1fa81","🎱":"1f3b1","🔮":"1f52e","🪄":"1fa84","🧿":"1f9ff","🪬":"1faac","🎮":"1f3ae","🕹":"1f579","🎰":"1f3b0","🎲":"1f3b2","🧩":"1f9e9","🧸":"1f9f8","🪅":"1fa85","🪩":"1faa9","🪆":"1fa86","♠":"2660","♥":"2665","♦":"2666","♣":"2663","♟":"265f","🃏":"1f0cf","🀄":"1f004","🎴":"1f3b4","🎭":"1f3ad","🖼":"1f5bc","🎨":"1f3a8","🧵":"1f9f5","🪡":"1faa1","🧶":"1f9f6","🪢":"1faa2","👓":"1f453","🕶":"1f576","🥽":"1f97d","🥼":"1f97c","🦺":"1f9ba","👔":"1f454","👕":"1f455","👖":"1f456","🧣":"1f9e3","🧤":"1f9e4","🧥":"1f9e5","🧦":"1f9e6","👗":"1f457","👘":"1f458","🥻":"1f97b","🩱":"1fa71","🩲":"1fa72","🩳":"1fa73","👙":"1f459","👚":"1f45a","👛":"1f45b","👜":"1f45c","👝":"1f45d","🛍":"1f6cd","🎒":"1f392","🩴":"1fa74","👞":"1f45e","👟":"1f45f","🥾":"1f97e","🥿":"1f97f","👠":"1f460","👡":"1f461","🩰":"1fa70","👢":"1f462","👑":"1f451","👒":"1f452","🎩":"1f3a9","🎓":"1f393","🧢":"1f9e2","🪖":"1fa96","⛑":"26d1","📿":"1f4ff","💄":"1f484","💍":"1f48d","💎":"1f48e","🔇":"1f507","🔈":"1f508","🔉":"1f509","🔊":"1f50a","📢":"1f4e2","📣":"1f4e3","📯":"1f4ef","🔔":"1f514","🔕":"1f515","🎼":"1f3bc","🎵":"1f3b5","🎶":"1f3b6","🎙":"1f399","🎚":"1f39a","🎛":"1f39b","🎤":"1f3a4","🎧":"1f3a7","📻":"1f4fb","🎷":"1f3b7","🪗":"1fa97","🎸":"1f3b8","🎹":"1f3b9","🎺":"1f3ba","🎻":"1f3bb","🪕":"1fa95","🥁":"1f941","🪘":"1fa98","📱":"1f4f1","📲":"1f4f2","☎":"260e","📞":"1f4de","📟":"1f4df","📠":"1f4e0","🔋":"1f50b","🪫":"1faab","🔌":"1f50c","💻":"1f4bb","🖥":"1f5a5","🖨":"1f5a8","⌨":"2328","🖱":"1f5b1","🖲":"1f5b2","💽":"1f4bd","💾":"1f4be","💿":"1f4bf","📀":"1f4c0","🧮":"1f9ee","🎥":"1f3a5","🎞":"1f39e","📽":"1f4fd","🎬":"1f3ac","📺":"1f4fa","📷":"1f4f7","📸":"1f4f8","📹":"1f4f9","📼":"1f4fc","🔍":"1f50d","🔎":"1f50e","🕯":"1f56f","💡":"1f4a1","🔦":"1f526","🏮":"1f3ee","🪔":"1fa94","📔":"1f4d4","📕":"1f4d5","📖":"1f4d6","📗":"1f4d7","📘":"1f4d8","📙":"1f4d9","📚":"1f4da","📓":"1f4d3","📒":"1f4d2","📃":"1f4c3","📜":"1f4dc","📄":"1f4c4","📰":"1f4f0","🗞":"1f5de","📑":"1f4d1","🔖":"1f516","🏷":"1f3f7","💰":"1f4b0","🪙":"1fa99","💴":"1f4b4","💵":"1f4b5","💶":"1f4b6","💷":"1f4b7","💸":"1f4b8","💳":"1f4b3","🧾":"1f9fe","💹":"1f4b9","✉":"2709","📧":"1f4e7","📨":"1f4e8","📩":"1f4e9","📤":"1f4e4","📥":"1f4e5","📦":"1f4e6","📫":"1f4eb","📪":"1f4ea","📬":"1f4ec","📭":"1f4ed","📮":"1f4ee","🗳":"1f5f3","✏":"270f","✒":"2712","🖋":"1f58b","🖊":"1f58a","🖌":"1f58c","🖍":"1f58d","📝":"1f4dd","💼":"1f4bc","📁":"1f4c1","📂":"1f4c2","🗂":"1f5c2","📅":"1f4c5","📆":"1f4c6","🗒":"1f5d2","🗓":"1f5d3","📇":"1f4c7","📈":"1f4c8","📉":"1f4c9","📊":"1f4ca","📋":"1f4cb","📌":"1f4cc","📍":"1f4cd","📎":"1f4ce","🖇":"1f587","📏":"1f4cf","📐":"1f4d0","✂":"2702","🗃":"1f5c3","🗄":"1f5c4","🗑":"1f5d1","🔒":"1f512","🔓":"1f513","🔏":"1f50f","🔐":"1f510","🔑":"1f511","🗝":"1f5dd","🔨":"1f528","🪓":"1fa93","⛏":"26cf","⚒":"2692","🛠":"1f6e0","🗡":"1f5e1","⚔":"2694","🔫":"1f52b","🪃":"1fa83","🏹":"1f3f9","🛡":"1f6e1","🪚":"1fa9a","🔧":"1f527","🪛":"1fa9b","🔩":"1f529","⚙":"2699","🗜":"1f5dc","⚖":"2696","🦯":"1f9af","🔗":"1f517","⛓":"26d3","🪝":"1fa9d","🧰":"1f9f0","🧲":"1f9f2","🪜":"1fa9c","⚗":"2697","🧪":"1f9ea","🧫":"1f9eb","🧬":"1f9ec","🔬":"1f52c","🔭":"1f52d","📡":"1f4e1","💉":"1f489","🩸":"1fa78","💊":"1f48a","🩹":"1fa79","🩼":"1fa7c","🩺":"1fa7a","🩻":"1fa7b","🚪":"1f6aa","🛗":"1f6d7","🪞":"1fa9e","🪟":"1fa9f","🛏":"1f6cf","🛋":"1f6cb","🪑":"1fa91","🚽":"1f6bd","🪠":"1faa0","🚿":"1f6bf","🛁":"1f6c1","🪤":"1faa4","🪒":"1fa92","🧴":"1f9f4","🧷":"1f9f7","🧹":"1f9f9","🧺":"1f9fa","🧻":"1f9fb","🪣":"1faa3","🧼":"1f9fc","🫧":"1fae7","🪥":"1faa5","🧽":"1f9fd","🧯":"1f9ef","🛒":"1f6d2","🚬":"1f6ac","⚰":"26b0","🪦":"1faa6","⚱":"26b1","🗿":"1f5ff","🪧":"1faa7","🪪":"1faaa","🏧":"1f3e7","🚮":"1f6ae","🚰":"1f6b0","♿":"267f","🚹":"1f6b9","🚺":"1f6ba","🚻":"1f6bb","🚼":"1f6bc","🚾":"1f6be","🛂":"1f6c2","🛃":"1f6c3","🛄":"1f6c4","🛅":"1f6c5","⚠":"26a0","🚸":"1f6b8","⛔":"26d4","🚫":"1f6ab","🚳":"1f6b3","🚭":"1f6ad","🚯":"1f6af","🚱":"1f6b1","🚷":"1f6b7","📵":"1f4f5","🔞":"1f51e","☢":"2622","☣":"2623","⬆":"2b06","↗":"2197","➡":"27a1","↘":"2198","⬇":"2b07","↙":"2199","⬅":"2b05","↖":"2196","↕":"2195","↔":"2194","↩":"21a9","↪":"21aa","⤴":"2934","⤵":"2935","🔃":"1f503","🔄":"1f504","🔙":"1f519","🔚":"1f51a","🔛":"1f51b","🔜":"1f51c","🔝":"1f51d","🛐":"1f6d0","⚛":"269b","🕉":"1f549","✡":"2721","☸":"2638","☯":"262f","✝":"271d","☦":"2626","☪":"262a","☮":"262e","🕎":"1f54e","🔯":"1f52f","♈":"2648","♉":"2649","♊":"264a","♋":"264b","♌":"264c","♍":"264d","♎":"264e","♏":"264f","♐":"2650","♑":"2651","♒":"2652","♓":"2653","⛎":"26ce","🔀":"1f500","🔁":"1f501","🔂":"1f502","▶":"25b6","⏩":"23e9","⏭":"23ed","⏯":"23ef","◀":"25c0","⏪":"23ea","⏮":"23ee","🔼":"1f53c","⏫":"23eb","🔽":"1f53d","⏬":"23ec","⏸":"23f8","⏹":"23f9","⏺":"23fa","⏏":"23cf","🎦":"1f3a6","🔅":"1f505","🔆":"1f506","📶":"1f4f6","📳":"1f4f3","📴":"1f4f4","♀":"2640","♂":"2642","⚧":"26a7","✖":"2716","➕":"2795","➖":"2796","➗":"2797","🟰":"1f7f0","♾":"267e","‼":"203c","⁉":"2049","❓":"2753","❔":"2754","❕":"2755","❗":"2757","〰":"3030","💱":"1f4b1","💲":"1f4b2","⚕":"2695","♻":"267b","⚜":"269c","🔱":"1f531","📛":"1f4db","🔰":"1f530","⭕":"2b55","✅":"2705","☑":"2611","✔":"2714","❌":"274c","❎":"274e","➰":"27b0","➿":"27bf","〽":"303d","✳":"2733","✴":"2734","❇":"2747","©":"a9","®":"ae","™":"2122","🔟":"1f51f","🔠":"1f520","🔡":"1f521","🔢":"1f522","🔣":"1f523","🔤":"1f524","🅰":"1f170","🆎":"1f18e","🅱":"1f171","🆑":"1f191","🆒":"1f192","🆓":"1f193","ℹ":"2139","🆔":"1f194","Ⓜ":"24c2","🆕":"1f195","🆖":"1f196","🅾":"1f17e","🆗":"1f197","🅿":"1f17f","🆘":"1f198","🆙":"1f199","🆚":"1f19a","🈁":"1f201","🈂":"1f202","🈷":"1f237","🈶":"1f236","🈯":"1f22f","🉐":"1f250","🈹":"1f239","🈚":"1f21a","🈲":"1f232","🉑":"1f251","🈸":"1f238","🈴":"1f234","🈳":"1f233","㊗":"3297","㊙":"3299","🈺":"1f23a","🈵":"1f235","🔴":"1f534","🟠":"1f7e0","🟡":"1f7e1","🟢":"1f7e2","🔵":"1f535","🟣":"1f7e3","🟤":"1f7e4","⚫":"26ab","⚪":"26aa","🟥":"1f7e5","🟧":"1f7e7","🟨":"1f7e8","🟩":"1f7e9","🟦":"1f7e6","🟪":"1f7ea","🟫":"1f7eb","⬛":"2b1b","⬜":"2b1c","◼":"25fc","◻":"25fb","◾":"25fe","◽":"25fd","▪":"25aa","▫":"25ab","🔶":"1f536","🔷":"1f537","🔸":"1f538","🔹":"1f539","🔺":"1f53a","🔻":"1f53b","💠":"1f4a0","🔘":"1f518","🔳":"1f533","🔲":"1f532","🏁":"1f3c1","🚩":"1f6a9","🎌":"1f38c","🏴":"1f3f4","🏳":"1f3f3","☺️":"263a","☹️":"2639","☠️":"2620","❣️":"2763","❤️":"2764","🕳️":"1f573","🗨️":"1f5e8","🗯️":"1f5ef","👋🏻":"1f44b-1f3fb","👋🏼":"1f44b-1f3fc","👋🏽":"1f44b-1f3fd","👋🏾":"1f44b-1f3fe","👋🏿":"1f44b-1f3ff","🤚🏻":"1f91a-1f3fb","🤚🏼":"1f91a-1f3fc","🤚🏽":"1f91a-1f3fd","🤚🏾":"1f91a-1f3fe","🤚🏿":"1f91a-1f3ff","🖐️":"1f590","🖐🏻":"1f590-1f3fb","🖐🏼":"1f590-1f3fc","🖐🏽":"1f590-1f3fd","🖐🏾":"1f590-1f3fe","🖐🏿":"1f590-1f3ff","✋🏻":"270b-1f3fb","✋🏼":"270b-1f3fc","✋🏽":"270b-1f3fd","✋🏾":"270b-1f3fe","✋🏿":"270b-1f3ff","🖖🏻":"1f596-1f3fb","🖖🏼":"1f596-1f3fc","🖖🏽":"1f596-1f3fd","🖖🏾":"1f596-1f3fe","🖖🏿":"1f596-1f3ff","🫱🏻":"1faf1-1f3fb","🫱🏼":"1faf1-1f3fc","🫱🏽":"1faf1-1f3fd","🫱🏾":"1faf1-1f3fe","🫱🏿":"1faf1-1f3ff","🫲🏻":"1faf2-1f3fb","🫲🏼":"1faf2-1f3fc","🫲🏽":"1faf2-1f3fd","🫲🏾":"1faf2-1f3fe","🫲🏿":"1faf2-1f3ff","🫳🏻":"1faf3-1f3fb","🫳🏼":"1faf3-1f3fc","🫳🏽":"1faf3-1f3fd","🫳🏾":"1faf3-1f3fe","🫳🏿":"1faf3-1f3ff","🫴🏻":"1faf4-1f3fb","🫴🏼":"1faf4-1f3fc","🫴🏽":"1faf4-1f3fd","🫴🏾":"1faf4-1f3fe","🫴🏿":"1faf4-1f3ff","👌🏻":"1f44c-1f3fb","👌🏼":"1f44c-1f3fc","👌🏽":"1f44c-1f3fd","👌🏾":"1f44c-1f3fe","👌🏿":"1f44c-1f3ff","🤌🏻":"1f90c-1f3fb","🤌🏼":"1f90c-1f3fc","🤌🏽":"1f90c-1f3fd","🤌🏾":"1f90c-1f3fe","🤌🏿":"1f90c-1f3ff","🤏🏻":"1f90f-1f3fb","🤏🏼":"1f90f-1f3fc","🤏🏽":"1f90f-1f3fd","🤏🏾":"1f90f-1f3fe","🤏🏿":"1f90f-1f3ff","✌️":"270c","✌🏻":"270c-1f3fb","✌🏼":"270c-1f3fc","✌🏽":"270c-1f3fd","✌🏾":"270c-1f3fe","✌🏿":"270c-1f3ff","🤞🏻":"1f91e-1f3fb","🤞🏼":"1f91e-1f3fc","🤞🏽":"1f91e-1f3fd","🤞🏾":"1f91e-1f3fe","🤞🏿":"1f91e-1f3ff","🫰🏻":"1faf0-1f3fb","🫰🏼":"1faf0-1f3fc","🫰🏽":"1faf0-1f3fd","🫰🏾":"1faf0-1f3fe","🫰🏿":"1faf0-1f3ff","🤟🏻":"1f91f-1f3fb","🤟🏼":"1f91f-1f3fc","🤟🏽":"1f91f-1f3fd","🤟🏾":"1f91f-1f3fe","🤟🏿":"1f91f-1f3ff","🤘🏻":"1f918-1f3fb","🤘🏼":"1f918-1f3fc","🤘🏽":"1f918-1f3fd","🤘🏾":"1f918-1f3fe","🤘🏿":"1f918-1f3ff","🤙🏻":"1f919-1f3fb","🤙🏼":"1f919-1f3fc","🤙🏽":"1f919-1f3fd","🤙🏾":"1f919-1f3fe","🤙🏿":"1f919-1f3ff","👈🏻":"1f448-1f3fb","👈🏼":"1f448-1f3fc","👈🏽":"1f448-1f3fd","👈🏾":"1f448-1f3fe","👈🏿":"1f448-1f3ff","👉🏻":"1f449-1f3fb","👉🏼":"1f449-1f3fc","👉🏽":"1f449-1f3fd","👉🏾":"1f449-1f3fe","👉🏿":"1f449-1f3ff","👆🏻":"1f446-1f3fb","👆🏼":"1f446-1f3fc","👆🏽":"1f446-1f3fd","👆🏾":"1f446-1f3fe","👆🏿":"1f446-1f3ff","🖕🏻":"1f595-1f3fb","🖕🏼":"1f595-1f3fc","🖕🏽":"1f595-1f3fd","🖕🏾":"1f595-1f3fe","🖕🏿":"1f595-1f3ff","👇🏻":"1f447-1f3fb","👇🏼":"1f447-1f3fc","👇🏽":"1f447-1f3fd","👇🏾":"1f447-1f3fe","👇🏿":"1f447-1f3ff","☝️":"261d","☝🏻":"261d-1f3fb","☝🏼":"261d-1f3fc","☝🏽":"261d-1f3fd","☝🏾":"261d-1f3fe","☝🏿":"261d-1f3ff","🫵🏻":"1faf5-1f3fb","🫵🏼":"1faf5-1f3fc","🫵🏽":"1faf5-1f3fd","🫵🏾":"1faf5-1f3fe","🫵🏿":"1faf5-1f3ff","👍🏻":"1f44d-1f3fb","👍🏼":"1f44d-1f3fc","👍🏽":"1f44d-1f3fd","👍🏾":"1f44d-1f3fe","👍🏿":"1f44d-1f3ff","👎🏻":"1f44e-1f3fb","👎🏼":"1f44e-1f3fc","👎🏽":"1f44e-1f3fd","👎🏾":"1f44e-1f3fe","👎🏿":"1f44e-1f3ff","✊🏻":"270a-1f3fb","✊🏼":"270a-1f3fc","✊🏽":"270a-1f3fd","✊🏾":"270a-1f3fe","✊🏿":"270a-1f3ff","👊🏻":"1f44a-1f3fb","👊🏼":"1f44a-1f3fc","👊🏽":"1f44a-1f3fd","👊🏾":"1f44a-1f3fe","👊🏿":"1f44a-1f3ff","🤛🏻":"1f91b-1f3fb","🤛🏼":"1f91b-1f3fc","🤛🏽":"1f91b-1f3fd","🤛🏾":"1f91b-1f3fe","🤛🏿":"1f91b-1f3ff","🤜🏻":"1f91c-1f3fb","🤜🏼":"1f91c-1f3fc","🤜🏽":"1f91c-1f3fd","🤜🏾":"1f91c-1f3fe","🤜🏿":"1f91c-1f3ff","👏🏻":"1f44f-1f3fb","👏🏼":"1f44f-1f3fc","👏🏽":"1f44f-1f3fd","👏🏾":"1f44f-1f3fe","👏🏿":"1f44f-1f3ff","🙌🏻":"1f64c-1f3fb","🙌🏼":"1f64c-1f3fc","🙌🏽":"1f64c-1f3fd","🙌🏾":"1f64c-1f3fe","🙌🏿":"1f64c-1f3ff","🫶🏻":"1faf6-1f3fb","🫶🏼":"1faf6-1f3fc","🫶🏽":"1faf6-1f3fd","🫶🏾":"1faf6-1f3fe","🫶🏿":"1faf6-1f3ff","👐🏻":"1f450-1f3fb","👐🏼":"1f450-1f3fc","👐🏽":"1f450-1f3fd","👐🏾":"1f450-1f3fe","👐🏿":"1f450-1f3ff","🤲🏻":"1f932-1f3fb","🤲🏼":"1f932-1f3fc","🤲🏽":"1f932-1f3fd","🤲🏾":"1f932-1f3fe","🤲🏿":"1f932-1f3ff","🤝🏻":"1f91d-1f3fb","🤝🏼":"1f91d-1f3fc","🤝🏽":"1f91d-1f3fd","🤝🏾":"1f91d-1f3fe","🤝🏿":"1f91d-1f3ff","🙏🏻":"1f64f-1f3fb","🙏🏼":"1f64f-1f3fc","🙏🏽":"1f64f-1f3fd","🙏🏾":"1f64f-1f3fe","🙏🏿":"1f64f-1f3ff","✍️":"270d","✍🏻":"270d-1f3fb","✍🏼":"270d-1f3fc","✍🏽":"270d-1f3fd","✍🏾":"270d-1f3fe","✍🏿":"270d-1f3ff","💅🏻":"1f485-1f3fb","💅🏼":"1f485-1f3fc","💅🏽":"1f485-1f3fd","💅🏾":"1f485-1f3fe","💅🏿":"1f485-1f3ff","🤳🏻":"1f933-1f3fb","🤳🏼":"1f933-1f3fc","🤳🏽":"1f933-1f3fd","🤳🏾":"1f933-1f3fe","🤳🏿":"1f933-1f3ff","💪🏻":"1f4aa-1f3fb","💪🏼":"1f4aa-1f3fc","💪🏽":"1f4aa-1f3fd","💪🏾":"1f4aa-1f3fe","💪🏿":"1f4aa-1f3ff","🦵🏻":"1f9b5-1f3fb","🦵🏼":"1f9b5-1f3fc","🦵🏽":"1f9b5-1f3fd","🦵🏾":"1f9b5-1f3fe","🦵🏿":"1f9b5-1f3ff","🦶🏻":"1f9b6-1f3fb","🦶🏼":"1f9b6-1f3fc","🦶🏽":"1f9b6-1f3fd","🦶🏾":"1f9b6-1f3fe","🦶🏿":"1f9b6-1f3ff","👂🏻":"1f442-1f3fb","👂🏼":"1f442-1f3fc","👂🏽":"1f442-1f3fd","👂🏾":"1f442-1f3fe","👂🏿":"1f442-1f3ff","🦻🏻":"1f9bb-1f3fb","🦻🏼":"1f9bb-1f3fc","🦻🏽":"1f9bb-1f3fd","🦻🏾":"1f9bb-1f3fe","🦻🏿":"1f9bb-1f3ff","👃🏻":"1f443-1f3fb","👃🏼":"1f443-1f3fc","👃🏽":"1f443-1f3fd","👃🏾":"1f443-1f3fe","👃🏿":"1f443-1f3ff","👁️":"1f441","👶🏻":"1f476-1f3fb","👶🏼":"1f476-1f3fc","👶🏽":"1f476-1f3fd","👶🏾":"1f476-1f3fe","👶🏿":"1f476-1f3ff","🧒🏻":"1f9d2-1f3fb","🧒🏼":"1f9d2-1f3fc","🧒🏽":"1f9d2-1f3fd","🧒🏾":"1f9d2-1f3fe","🧒🏿":"1f9d2-1f3ff","👦🏻":"1f466-1f3fb","👦🏼":"1f466-1f3fc","👦🏽":"1f466-1f3fd","👦🏾":"1f466-1f3fe","👦🏿":"1f466-1f3ff","👧🏻":"1f467-1f3fb","👧🏼":"1f467-1f3fc","👧🏽":"1f467-1f3fd","👧🏾":"1f467-1f3fe","👧🏿":"1f467-1f3ff","🧑🏻":"1f9d1-1f3fb","🧑🏼":"1f9d1-1f3fc","🧑🏽":"1f9d1-1f3fd","🧑🏾":"1f9d1-1f3fe","🧑🏿":"1f9d1-1f3ff","👱🏻":"1f471-1f3fb","👱🏼":"1f471-1f3fc","👱🏽":"1f471-1f3fd","👱🏾":"1f471-1f3fe","👱🏿":"1f471-1f3ff","👨🏻":"1f468-1f3fb","👨🏼":"1f468-1f3fc","👨🏽":"1f468-1f3fd","👨🏾":"1f468-1f3fe","👨🏿":"1f468-1f3ff","🧔🏻":"1f9d4-1f3fb","🧔🏼":"1f9d4-1f3fc","🧔🏽":"1f9d4-1f3fd","🧔🏾":"1f9d4-1f3fe","🧔🏿":"1f9d4-1f3ff","👩🏻":"1f469-1f3fb","👩🏼":"1f469-1f3fc","👩🏽":"1f469-1f3fd","👩🏾":"1f469-1f3fe","👩🏿":"1f469-1f3ff","🧓🏻":"1f9d3-1f3fb","🧓🏼":"1f9d3-1f3fc","🧓🏽":"1f9d3-1f3fd","🧓🏾":"1f9d3-1f3fe","🧓🏿":"1f9d3-1f3ff","👴🏻":"1f474-1f3fb","👴🏼":"1f474-1f3fc","👴🏽":"1f474-1f3fd","👴🏾":"1f474-1f3fe","👴🏿":"1f474-1f3ff","👵🏻":"1f475-1f3fb","👵🏼":"1f475-1f3fc","👵🏽":"1f475-1f3fd","👵🏾":"1f475-1f3fe","👵🏿":"1f475-1f3ff","🙍🏻":"1f64d-1f3fb","🙍🏼":"1f64d-1f3fc","🙍🏽":"1f64d-1f3fd","🙍🏾":"1f64d-1f3fe","🙍🏿":"1f64d-1f3ff","🙎🏻":"1f64e-1f3fb","🙎🏼":"1f64e-1f3fc","🙎🏽":"1f64e-1f3fd","🙎🏾":"1f64e-1f3fe","🙎🏿":"1f64e-1f3ff","🙅🏻":"1f645-1f3fb","🙅🏼":"1f645-1f3fc","🙅🏽":"1f645-1f3fd","🙅🏾":"1f645-1f3fe","🙅🏿":"1f645-1f3ff","🙆🏻":"1f646-1f3fb","🙆🏼":"1f646-1f3fc","🙆🏽":"1f646-1f3fd","🙆🏾":"1f646-1f3fe","🙆🏿":"1f646-1f3ff","💁🏻":"1f481-1f3fb","💁🏼":"1f481-1f3fc","💁🏽":"1f481-1f3fd","💁🏾":"1f481-1f3fe","💁🏿":"1f481-1f3ff","🙋🏻":"1f64b-1f3fb","🙋🏼":"1f64b-1f3fc","🙋🏽":"1f64b-1f3fd","🙋🏾":"1f64b-1f3fe","🙋🏿":"1f64b-1f3ff","🧏🏻":"1f9cf-1f3fb","🧏🏼":"1f9cf-1f3fc","🧏🏽":"1f9cf-1f3fd","🧏🏾":"1f9cf-1f3fe","🧏🏿":"1f9cf-1f3ff","🙇🏻":"1f647-1f3fb","🙇🏼":"1f647-1f3fc","🙇🏽":"1f647-1f3fd","🙇🏾":"1f647-1f3fe","🙇🏿":"1f647-1f3ff","🤦🏻":"1f926-1f3fb","🤦🏼":"1f926-1f3fc","🤦🏽":"1f926-1f3fd","🤦🏾":"1f926-1f3fe","🤦🏿":"1f926-1f3ff","🤷🏻":"1f937-1f3fb","🤷🏼":"1f937-1f3fc","🤷🏽":"1f937-1f3fd","🤷🏾":"1f937-1f3fe","🤷🏿":"1f937-1f3ff","👮🏻":"1f46e-1f3fb","👮🏼":"1f46e-1f3fc","👮🏽":"1f46e-1f3fd","👮🏾":"1f46e-1f3fe","👮🏿":"1f46e-1f3ff","🕵️":"1f575","🕵🏻":"1f575-1f3fb","🕵🏼":"1f575-1f3fc","🕵🏽":"1f575-1f3fd","🕵🏾":"1f575-1f3fe","🕵🏿":"1f575-1f3ff","💂🏻":"1f482-1f3fb","💂🏼":"1f482-1f3fc","💂🏽":"1f482-1f3fd","💂🏾":"1f482-1f3fe","💂🏿":"1f482-1f3ff","🥷🏻":"1f977-1f3fb","🥷🏼":"1f977-1f3fc","🥷🏽":"1f977-1f3fd","🥷🏾":"1f977-1f3fe","🥷🏿":"1f977-1f3ff","👷🏻":"1f477-1f3fb","👷🏼":"1f477-1f3fc","👷🏽":"1f477-1f3fd","👷🏾":"1f477-1f3fe","👷🏿":"1f477-1f3ff","🫅🏻":"1fac5-1f3fb","🫅🏼":"1fac5-1f3fc","🫅🏽":"1fac5-1f3fd","🫅🏾":"1fac5-1f3fe","🫅🏿":"1fac5-1f3ff","🤴🏻":"1f934-1f3fb","🤴🏼":"1f934-1f3fc","🤴🏽":"1f934-1f3fd","🤴🏾":"1f934-1f3fe","🤴🏿":"1f934-1f3ff","👸🏻":"1f478-1f3fb","👸🏼":"1f478-1f3fc","👸🏽":"1f478-1f3fd","👸🏾":"1f478-1f3fe","👸🏿":"1f478-1f3ff","👳🏻":"1f473-1f3fb","👳🏼":"1f473-1f3fc","👳🏽":"1f473-1f3fd","👳🏾":"1f473-1f3fe","👳🏿":"1f473-1f3ff","👲🏻":"1f472-1f3fb","👲🏼":"1f472-1f3fc","👲🏽":"1f472-1f3fd","👲🏾":"1f472-1f3fe","👲🏿":"1f472-1f3ff","🧕🏻":"1f9d5-1f3fb","🧕🏼":"1f9d5-1f3fc","🧕🏽":"1f9d5-1f3fd","🧕🏾":"1f9d5-1f3fe","🧕🏿":"1f9d5-1f3ff","🤵🏻":"1f935-1f3fb","🤵🏼":"1f935-1f3fc","🤵🏽":"1f935-1f3fd","🤵🏾":"1f935-1f3fe","🤵🏿":"1f935-1f3ff","👰🏻":"1f470-1f3fb","👰🏼":"1f470-1f3fc","👰🏽":"1f470-1f3fd","👰🏾":"1f470-1f3fe","👰🏿":"1f470-1f3ff","🤰🏻":"1f930-1f3fb","🤰🏼":"1f930-1f3fc","🤰🏽":"1f930-1f3fd","🤰🏾":"1f930-1f3fe","🤰🏿":"1f930-1f3ff","🫃🏻":"1fac3-1f3fb","🫃🏼":"1fac3-1f3fc","🫃🏽":"1fac3-1f3fd","🫃🏾":"1fac3-1f3fe","🫃🏿":"1fac3-1f3ff","🫄🏻":"1fac4-1f3fb","🫄🏼":"1fac4-1f3fc","🫄🏽":"1fac4-1f3fd","🫄🏾":"1fac4-1f3fe","🫄🏿":"1fac4-1f3ff","🤱🏻":"1f931-1f3fb","🤱🏼":"1f931-1f3fc","🤱🏽":"1f931-1f3fd","🤱🏾":"1f931-1f3fe","🤱🏿":"1f931-1f3ff","👼🏻":"1f47c-1f3fb","👼🏼":"1f47c-1f3fc","👼🏽":"1f47c-1f3fd","👼🏾":"1f47c-1f3fe","👼🏿":"1f47c-1f3ff","🎅🏻":"1f385-1f3fb","🎅🏼":"1f385-1f3fc","🎅🏽":"1f385-1f3fd","🎅🏾":"1f385-1f3fe","🎅🏿":"1f385-1f3ff","🤶🏻":"1f936-1f3fb","🤶🏼":"1f936-1f3fc","🤶🏽":"1f936-1f3fd","🤶🏾":"1f936-1f3fe","🤶🏿":"1f936-1f3ff","🦸🏻":"1f9b8-1f3fb","🦸🏼":"1f9b8-1f3fc","🦸🏽":"1f9b8-1f3fd","🦸🏾":"1f9b8-1f3fe","🦸🏿":"1f9b8-1f3ff","🦹🏻":"1f9b9-1f3fb","🦹🏼":"1f9b9-1f3fc","🦹🏽":"1f9b9-1f3fd","🦹🏾":"1f9b9-1f3fe","🦹🏿":"1f9b9-1f3ff","🧙🏻":"1f9d9-1f3fb","🧙🏼":"1f9d9-1f3fc","🧙🏽":"1f9d9-1f3fd","🧙🏾":"1f9d9-1f3fe","🧙🏿":"1f9d9-1f3ff","🧚🏻":"1f9da-1f3fb","🧚🏼":"1f9da-1f3fc","🧚🏽":"1f9da-1f3fd","🧚🏾":"1f9da-1f3fe","🧚🏿":"1f9da-1f3ff","🧛🏻":"1f9db-1f3fb","🧛🏼":"1f9db-1f3fc","🧛🏽":"1f9db-1f3fd","🧛🏾":"1f9db-1f3fe","🧛🏿":"1f9db-1f3ff","🧜🏻":"1f9dc-1f3fb","🧜🏼":"1f9dc-1f3fc","🧜🏽":"1f9dc-1f3fd","🧜🏾":"1f9dc-1f3fe","🧜🏿":"1f9dc-1f3ff","🧝🏻":"1f9dd-1f3fb","🧝🏼":"1f9dd-1f3fc","🧝🏽":"1f9dd-1f3fd","🧝🏾":"1f9dd-1f3fe","🧝🏿":"1f9dd-1f3ff","💆🏻":"1f486-1f3fb","💆🏼":"1f486-1f3fc","💆🏽":"1f486-1f3fd","💆🏾":"1f486-1f3fe","💆🏿":"1f486-1f3ff","💇🏻":"1f487-1f3fb","💇🏼":"1f487-1f3fc","💇🏽":"1f487-1f3fd","💇🏾":"1f487-1f3fe","💇🏿":"1f487-1f3ff","🚶🏻":"1f6b6-1f3fb","🚶🏼":"1f6b6-1f3fc","🚶🏽":"1f6b6-1f3fd","🚶🏾":"1f6b6-1f3fe","🚶🏿":"1f6b6-1f3ff","🧍🏻":"1f9cd-1f3fb","🧍🏼":"1f9cd-1f3fc","🧍🏽":"1f9cd-1f3fd","🧍🏾":"1f9cd-1f3fe","🧍🏿":"1f9cd-1f3ff","🧎🏻":"1f9ce-1f3fb","🧎🏼":"1f9ce-1f3fc","🧎🏽":"1f9ce-1f3fd","🧎🏾":"1f9ce-1f3fe","🧎🏿":"1f9ce-1f3ff","🏃🏻":"1f3c3-1f3fb","🏃🏼":"1f3c3-1f3fc","🏃🏽":"1f3c3-1f3fd","🏃🏾":"1f3c3-1f3fe","🏃🏿":"1f3c3-1f3ff","💃🏻":"1f483-1f3fb","💃🏼":"1f483-1f3fc","💃🏽":"1f483-1f3fd","💃🏾":"1f483-1f3fe","💃🏿":"1f483-1f3ff","🕺🏻":"1f57a-1f3fb","🕺🏼":"1f57a-1f3fc","🕺🏽":"1f57a-1f3fd","🕺🏾":"1f57a-1f3fe","🕺🏿":"1f57a-1f3ff","🕴️":"1f574","🕴🏻":"1f574-1f3fb","🕴🏼":"1f574-1f3fc","🕴🏽":"1f574-1f3fd","🕴🏾":"1f574-1f3fe","🕴🏿":"1f574-1f3ff","🧖🏻":"1f9d6-1f3fb","🧖🏼":"1f9d6-1f3fc","🧖🏽":"1f9d6-1f3fd","🧖🏾":"1f9d6-1f3fe","🧖🏿":"1f9d6-1f3ff","🧗🏻":"1f9d7-1f3fb","🧗🏼":"1f9d7-1f3fc","🧗🏽":"1f9d7-1f3fd","🧗🏾":"1f9d7-1f3fe","🧗🏿":"1f9d7-1f3ff","🏇🏻":"1f3c7-1f3fb","🏇🏼":"1f3c7-1f3fc","🏇🏽":"1f3c7-1f3fd","🏇🏾":"1f3c7-1f3fe","🏇🏿":"1f3c7-1f3ff","⛷️":"26f7","🏂🏻":"1f3c2-1f3fb","🏂🏼":"1f3c2-1f3fc","🏂🏽":"1f3c2-1f3fd","🏂🏾":"1f3c2-1f3fe","🏂🏿":"1f3c2-1f3ff","🏌️":"1f3cc","🏌🏻":"1f3cc-1f3fb","🏌🏼":"1f3cc-1f3fc","🏌🏽":"1f3cc-1f3fd","🏌🏾":"1f3cc-1f3fe","🏌🏿":"1f3cc-1f3ff","🏄🏻":"1f3c4-1f3fb","🏄🏼":"1f3c4-1f3fc","🏄🏽":"1f3c4-1f3fd","🏄🏾":"1f3c4-1f3fe","🏄🏿":"1f3c4-1f3ff","🚣🏻":"1f6a3-1f3fb","🚣🏼":"1f6a3-1f3fc","🚣🏽":"1f6a3-1f3fd","🚣🏾":"1f6a3-1f3fe","🚣🏿":"1f6a3-1f3ff","🏊🏻":"1f3ca-1f3fb","🏊🏼":"1f3ca-1f3fc","🏊🏽":"1f3ca-1f3fd","🏊🏾":"1f3ca-1f3fe","🏊🏿":"1f3ca-1f3ff","⛹️":"26f9","⛹🏻":"26f9-1f3fb","⛹🏼":"26f9-1f3fc","⛹🏽":"26f9-1f3fd","⛹🏾":"26f9-1f3fe","⛹🏿":"26f9-1f3ff","🏋️":"1f3cb","🏋🏻":"1f3cb-1f3fb","🏋🏼":"1f3cb-1f3fc","🏋🏽":"1f3cb-1f3fd","🏋🏾":"1f3cb-1f3fe","🏋🏿":"1f3cb-1f3ff","🚴🏻":"1f6b4-1f3fb","🚴🏼":"1f6b4-1f3fc","🚴🏽":"1f6b4-1f3fd","🚴🏾":"1f6b4-1f3fe","🚴🏿":"1f6b4-1f3ff","🚵🏻":"1f6b5-1f3fb","🚵🏼":"1f6b5-1f3fc","🚵🏽":"1f6b5-1f3fd","🚵🏾":"1f6b5-1f3fe","🚵🏿":"1f6b5-1f3ff","🤸🏻":"1f938-1f3fb","🤸🏼":"1f938-1f3fc","🤸🏽":"1f938-1f3fd","🤸🏾":"1f938-1f3fe","🤸🏿":"1f938-1f3ff","🤽🏻":"1f93d-1f3fb","🤽🏼":"1f93d-1f3fc","🤽🏽":"1f93d-1f3fd","🤽🏾":"1f93d-1f3fe","🤽🏿":"1f93d-1f3ff","🤾🏻":"1f93e-1f3fb","🤾🏼":"1f93e-1f3fc","🤾🏽":"1f93e-1f3fd","🤾🏾":"1f93e-1f3fe","🤾🏿":"1f93e-1f3ff","🤹🏻":"1f939-1f3fb","🤹🏼":"1f939-1f3fc","🤹🏽":"1f939-1f3fd","🤹🏾":"1f939-1f3fe","🤹🏿":"1f939-1f3ff","🧘🏻":"1f9d8-1f3fb","🧘🏼":"1f9d8-1f3fc","🧘🏽":"1f9d8-1f3fd","🧘🏾":"1f9d8-1f3fe","🧘🏿":"1f9d8-1f3ff","🛀🏻":"1f6c0-1f3fb","🛀🏼":"1f6c0-1f3fc","🛀🏽":"1f6c0-1f3fd","🛀🏾":"1f6c0-1f3fe","🛀🏿":"1f6c0-1f3ff","🛌🏻":"1f6cc-1f3fb","🛌🏼":"1f6cc-1f3fc","🛌🏽":"1f6cc-1f3fd","🛌🏾":"1f6cc-1f3fe","🛌🏿":"1f6cc-1f3ff","👭🏻":"1f46d-1f3fb","👭🏼":"1f46d-1f3fc","👭🏽":"1f46d-1f3fd","👭🏾":"1f46d-1f3fe","👭🏿":"1f46d-1f3ff","👫🏻":"1f46b-1f3fb","👫🏼":"1f46b-1f3fc","👫🏽":"1f46b-1f3fd","👫🏾":"1f46b-1f3fe","👫🏿":"1f46b-1f3ff","👬🏻":"1f46c-1f3fb","👬🏼":"1f46c-1f3fc","👬🏽":"1f46c-1f3fd","👬🏾":"1f46c-1f3fe","👬🏿":"1f46c-1f3ff","💏🏻":"1f48f-1f3fb","💏🏼":"1f48f-1f3fc","💏🏽":"1f48f-1f3fd","💏🏾":"1f48f-1f3fe","💏🏿":"1f48f-1f3ff","💑🏻":"1f491-1f3fb","💑🏼":"1f491-1f3fc","💑🏽":"1f491-1f3fd","💑🏾":"1f491-1f3fe","💑🏿":"1f491-1f3ff","🗣️":"1f5e3","🐿️":"1f43f","🕊️":"1f54a","🕷️":"1f577","🕸️":"1f578","🏵️":"1f3f5","☘️":"2618","🌶️":"1f336","🍽️":"1f37d","🗺️":"1f5fa","🏔️":"1f3d4","⛰️":"26f0","🏕️":"1f3d5","🏖️":"1f3d6","🏜️":"1f3dc","🏝️":"1f3dd","🏞️":"1f3de","🏟️":"1f3df","🏛️":"1f3db","🏗️":"1f3d7","🏘️":"1f3d8","🏚️":"1f3da","⛩️":"26e9","🏙️":"1f3d9","♨️":"2668","🏎️":"1f3ce","🏍️":"1f3cd","🛣️":"1f6e3","🛤️":"1f6e4","🛢️":"1f6e2","🛳️":"1f6f3","⛴️":"26f4","🛥️":"1f6e5","✈️":"2708","🛩️":"1f6e9","🛰️":"1f6f0","🛎️":"1f6ce","⏱️":"23f1","⏲️":"23f2","🕰️":"1f570","🌡️":"1f321","☀️":"2600","☁️":"2601","⛈️":"26c8","🌤️":"1f324","🌥️":"1f325","🌦️":"1f326","🌧️":"1f327","🌨️":"1f328","🌩️":"1f329","🌪️":"1f32a","🌫️":"1f32b","🌬️":"1f32c","☂️":"2602","⛱️":"26f1","❄️":"2744","☃️":"2603","☄️":"2604","🎗️":"1f397","🎟️":"1f39f","🎖️":"1f396","⛸️":"26f8","🕹️":"1f579","♠️":"2660","♥️":"2665","♦️":"2666","♣️":"2663","♟️":"265f","🖼️":"1f5bc","🕶️":"1f576","🛍️":"1f6cd","⛑️":"26d1","🎙️":"1f399","🎚️":"1f39a","🎛️":"1f39b","☎️":"260e","🖥️":"1f5a5","🖨️":"1f5a8","⌨️":"2328","🖱️":"1f5b1","🖲️":"1f5b2","🎞️":"1f39e","📽️":"1f4fd","🕯️":"1f56f","🗞️":"1f5de","🏷️":"1f3f7","✉️":"2709","🗳️":"1f5f3","✏️":"270f","✒️":"2712","🖋️":"1f58b","🖊️":"1f58a","🖌️":"1f58c","🖍️":"1f58d","🗂️":"1f5c2","🗒️":"1f5d2","🗓️":"1f5d3","🖇️":"1f587","✂️":"2702","🗃️":"1f5c3","🗄️":"1f5c4","🗑️":"1f5d1","🗝️":"1f5dd","⛏️":"26cf","⚒️":"2692","🛠️":"1f6e0","🗡️":"1f5e1","⚔️":"2694","🛡️":"1f6e1","⚙️":"2699","🗜️":"1f5dc","⚖️":"2696","⛓️":"26d3","⚗️":"2697","🛏️":"1f6cf","🛋️":"1f6cb","⚰️":"26b0","⚱️":"26b1","⚠️":"26a0","☢️":"2622","☣️":"2623","⬆️":"2b06","↗️":"2197","➡️":"27a1","↘️":"2198","⬇️":"2b07","↙️":"2199","⬅️":"2b05","↖️":"2196","↕️":"2195","↔️":"2194","↩️":"21a9","↪️":"21aa","⤴️":"2934","⤵️":"2935","⚛️":"269b","🕉️":"1f549","✡️":"2721","☸️":"2638","☯️":"262f","✝️":"271d","☦️":"2626","☪️":"262a","☮️":"262e","▶️":"25b6","⏭️":"23ed","⏯️":"23ef","◀️":"25c0","⏮️":"23ee","⏸️":"23f8","⏹️":"23f9","⏺️":"23fa","⏏️":"23cf","♀️":"2640","♂️":"2642","⚧️":"26a7","✖️":"2716","♾️":"267e","‼️":"203c","⁉️":"2049","〰️":"3030","⚕️":"2695","♻️":"267b","⚜️":"269c","☑️":"2611","✔️":"2714","〽️":"303d","✳️":"2733","✴️":"2734","❇️":"2747","©️":"a9","®️":"ae","™️":"2122","#⃣":"23-20e3","*⃣":"2a-20e3","0⃣":"30-20e3","1⃣":"31-20e3","2⃣":"32-20e3","3⃣":"33-20e3","4⃣":"34-20e3","5⃣":"35-20e3","6⃣":"36-20e3","7⃣":"37-20e3","8⃣":"38-20e3","9⃣":"39-20e3","🅰️":"1f170","🅱️":"1f171","ℹ️":"2139","Ⓜ️":"24c2","🅾️":"1f17e","🅿️":"1f17f","🈂️":"1f202","🈷️":"1f237","㊗️":"3297","㊙️":"3299","◼️":"25fc","◻️":"25fb","▪️":"25aa","▫️":"25ab","🏳️":"1f3f3","🇦🇨":"1f1e6-1f1e8","🇦🇩":"1f1e6-1f1e9","🇦🇪":"1f1e6-1f1ea","🇦🇫":"1f1e6-1f1eb","🇦🇬":"1f1e6-1f1ec","🇦🇮":"1f1e6-1f1ee","🇦🇱":"1f1e6-1f1f1","🇦🇲":"1f1e6-1f1f2","🇦🇴":"1f1e6-1f1f4","🇦🇶":"1f1e6-1f1f6","🇦🇷":"1f1e6-1f1f7","🇦🇸":"1f1e6-1f1f8","🇦🇹":"1f1e6-1f1f9","🇦🇺":"1f1e6-1f1fa","🇦🇼":"1f1e6-1f1fc","🇦🇽":"1f1e6-1f1fd","🇦🇿":"1f1e6-1f1ff","🇧🇦":"1f1e7-1f1e6","🇧🇧":"1f1e7-1f1e7","🇧🇩":"1f1e7-1f1e9","🇧🇪":"1f1e7-1f1ea","🇧🇫":"1f1e7-1f1eb","🇧🇬":"1f1e7-1f1ec","🇧🇭":"1f1e7-1f1ed","🇧🇮":"1f1e7-1f1ee","🇧🇯":"1f1e7-1f1ef","🇧🇱":"1f1e7-1f1f1","🇧🇲":"1f1e7-1f1f2","🇧🇳":"1f1e7-1f1f3","🇧🇴":"1f1e7-1f1f4","🇧🇶":"1f1e7-1f1f6","🇧🇷":"1f1e7-1f1f7","🇧🇸":"1f1e7-1f1f8","🇧🇹":"1f1e7-1f1f9","🇧🇻":"1f1e7-1f1fb","🇧🇼":"1f1e7-1f1fc","🇧🇾":"1f1e7-1f1fe","🇧🇿":"1f1e7-1f1ff","🇨🇦":"1f1e8-1f1e6","🇨🇨":"1f1e8-1f1e8","🇨🇩":"1f1e8-1f1e9","🇨🇫":"1f1e8-1f1eb","🇨🇬":"1f1e8-1f1ec","🇨🇭":"1f1e8-1f1ed","🇨🇮":"1f1e8-1f1ee","🇨🇰":"1f1e8-1f1f0","🇨🇱":"1f1e8-1f1f1","🇨🇲":"1f1e8-1f1f2","🇨🇳":"1f1e8-1f1f3","🇨🇴":"1f1e8-1f1f4","🇨🇵":"1f1e8-1f1f5","🇨🇷":"1f1e8-1f1f7","🇨🇺":"1f1e8-1f1fa","🇨🇻":"1f1e8-1f1fb","🇨🇼":"1f1e8-1f1fc","🇨🇽":"1f1e8-1f1fd","🇨🇾":"1f1e8-1f1fe","🇨🇿":"1f1e8-1f1ff","🇩🇪":"1f1e9-1f1ea","🇩🇬":"1f1e9-1f1ec","🇩🇯":"1f1e9-1f1ef","🇩🇰":"1f1e9-1f1f0","🇩🇲":"1f1e9-1f1f2","🇩🇴":"1f1e9-1f1f4","🇩🇿":"1f1e9-1f1ff","🇪🇦":"1f1ea-1f1e6","🇪🇨":"1f1ea-1f1e8","🇪🇪":"1f1ea-1f1ea","🇪🇬":"1f1ea-1f1ec","🇪🇭":"1f1ea-1f1ed","🇪🇷":"1f1ea-1f1f7","🇪🇸":"1f1ea-1f1f8","🇪🇹":"1f1ea-1f1f9","🇪🇺":"1f1ea-1f1fa","🇫🇮":"1f1eb-1f1ee","🇫🇯":"1f1eb-1f1ef","🇫🇰":"1f1eb-1f1f0","🇫🇲":"1f1eb-1f1f2","🇫🇴":"1f1eb-1f1f4","🇫🇷":"1f1eb-1f1f7","🇬🇦":"1f1ec-1f1e6","🇬🇧":"1f1ec-1f1e7","🇬🇩":"1f1ec-1f1e9","🇬🇪":"1f1ec-1f1ea","🇬🇫":"1f1ec-1f1eb","🇬🇬":"1f1ec-1f1ec","🇬🇭":"1f1ec-1f1ed","🇬🇮":"1f1ec-1f1ee","🇬🇱":"1f1ec-1f1f1","🇬🇲":"1f1ec-1f1f2","🇬🇳":"1f1ec-1f1f3","🇬🇵":"1f1ec-1f1f5","🇬🇶":"1f1ec-1f1f6","🇬🇷":"1f1ec-1f1f7","🇬🇸":"1f1ec-1f1f8","🇬🇹":"1f1ec-1f1f9","🇬🇺":"1f1ec-1f1fa","🇬🇼":"1f1ec-1f1fc","🇬🇾":"1f1ec-1f1fe","🇭🇰":"1f1ed-1f1f0","🇭🇲":"1f1ed-1f1f2","🇭🇳":"1f1ed-1f1f3","🇭🇷":"1f1ed-1f1f7","🇭🇹":"1f1ed-1f1f9","🇭🇺":"1f1ed-1f1fa","🇮🇨":"1f1ee-1f1e8","🇮🇩":"1f1ee-1f1e9","🇮🇪":"1f1ee-1f1ea","🇮🇱":"1f1ee-1f1f1","🇮🇲":"1f1ee-1f1f2","🇮🇳":"1f1ee-1f1f3","🇮🇴":"1f1ee-1f1f4","🇮🇶":"1f1ee-1f1f6","🇮🇷":"1f1ee-1f1f7","🇮🇸":"1f1ee-1f1f8","🇮🇹":"1f1ee-1f1f9","🇯🇪":"1f1ef-1f1ea","🇯🇲":"1f1ef-1f1f2","🇯🇴":"1f1ef-1f1f4","🇯🇵":"1f1ef-1f1f5","🇰🇪":"1f1f0-1f1ea","🇰🇬":"1f1f0-1f1ec","🇰🇭":"1f1f0-1f1ed","🇰🇮":"1f1f0-1f1ee","🇰🇲":"1f1f0-1f1f2","🇰🇳":"1f1f0-1f1f3","🇰🇵":"1f1f0-1f1f5","🇰🇷":"1f1f0-1f1f7","🇰🇼":"1f1f0-1f1fc","🇰🇾":"1f1f0-1f1fe","🇰🇿":"1f1f0-1f1ff","🇱🇦":"1f1f1-1f1e6","🇱🇧":"1f1f1-1f1e7","🇱🇨":"1f1f1-1f1e8","🇱🇮":"1f1f1-1f1ee","🇱🇰":"1f1f1-1f1f0","🇱🇷":"1f1f1-1f1f7","🇱🇸":"1f1f1-1f1f8","🇱🇹":"1f1f1-1f1f9","🇱🇺":"1f1f1-1f1fa","🇱🇻":"1f1f1-1f1fb","🇱🇾":"1f1f1-1f1fe","🇲🇦":"1f1f2-1f1e6","🇲🇨":"1f1f2-1f1e8","🇲🇩":"1f1f2-1f1e9","🇲🇪":"1f1f2-1f1ea","🇲🇫":"1f1f2-1f1eb","🇲🇬":"1f1f2-1f1ec","🇲🇭":"1f1f2-1f1ed","🇲🇰":"1f1f2-1f1f0","🇲🇱":"1f1f2-1f1f1","🇲🇲":"1f1f2-1f1f2","🇲🇳":"1f1f2-1f1f3","🇲🇴":"1f1f2-1f1f4","🇲🇵":"1f1f2-1f1f5","🇲🇶":"1f1f2-1f1f6","🇲🇷":"1f1f2-1f1f7","🇲🇸":"1f1f2-1f1f8","🇲🇹":"1f1f2-1f1f9","🇲🇺":"1f1f2-1f1fa","🇲🇻":"1f1f2-1f1fb","🇲🇼":"1f1f2-1f1fc","🇲🇽":"1f1f2-1f1fd","🇲🇾":"1f1f2-1f1fe","🇲🇿":"1f1f2-1f1ff","🇳🇦":"1f1f3-1f1e6","🇳🇨":"1f1f3-1f1e8","🇳🇪":"1f1f3-1f1ea","🇳🇫":"1f1f3-1f1eb","🇳🇬":"1f1f3-1f1ec","🇳🇮":"1f1f3-1f1ee","🇳🇱":"1f1f3-1f1f1","🇳🇴":"1f1f3-1f1f4","🇳🇵":"1f1f3-1f1f5","🇳🇷":"1f1f3-1f1f7","🇳🇺":"1f1f3-1f1fa","🇳🇿":"1f1f3-1f1ff","🇴🇲":"1f1f4-1f1f2","🇵🇦":"1f1f5-1f1e6","🇵🇪":"1f1f5-1f1ea","🇵🇫":"1f1f5-1f1eb","🇵🇬":"1f1f5-1f1ec","🇵🇭":"1f1f5-1f1ed","🇵🇰":"1f1f5-1f1f0","🇵🇱":"1f1f5-1f1f1","🇵🇲":"1f1f5-1f1f2","🇵🇳":"1f1f5-1f1f3","🇵🇷":"1f1f5-1f1f7","🇵🇸":"1f1f5-1f1f8","🇵🇹":"1f1f5-1f1f9","🇵🇼":"1f1f5-1f1fc","🇵🇾":"1f1f5-1f1fe","🇶🇦":"1f1f6-1f1e6","🇷🇪":"1f1f7-1f1ea","🇷🇴":"1f1f7-1f1f4","🇷🇸":"1f1f7-1f1f8","🇷🇺":"1f1f7-1f1fa","🇷🇼":"1f1f7-1f1fc","🇸🇦":"1f1f8-1f1e6","🇸🇧":"1f1f8-1f1e7","🇸🇨":"1f1f8-1f1e8","🇸🇩":"1f1f8-1f1e9","🇸🇪":"1f1f8-1f1ea","🇸🇬":"1f1f8-1f1ec","🇸🇭":"1f1f8-1f1ed","🇸🇮":"1f1f8-1f1ee","🇸🇯":"1f1f8-1f1ef","🇸🇰":"1f1f8-1f1f0","🇸🇱":"1f1f8-1f1f1","🇸🇲":"1f1f8-1f1f2","🇸🇳":"1f1f8-1f1f3","🇸🇴":"1f1f8-1f1f4","🇸🇷":"1f1f8-1f1f7","🇸🇸":"1f1f8-1f1f8","🇸🇹":"1f1f8-1f1f9","🇸🇻":"1f1f8-1f1fb","🇸🇽":"1f1f8-1f1fd","🇸🇾":"1f1f8-1f1fe","🇸🇿":"1f1f8-1f1ff","🇹🇦":"1f1f9-1f1e6","🇹🇨":"1f1f9-1f1e8","🇹🇩":"1f1f9-1f1e9","🇹🇫":"1f1f9-1f1eb","🇹🇬":"1f1f9-1f1ec","🇹🇭":"1f1f9-1f1ed","🇹🇯":"1f1f9-1f1ef","🇹🇰":"1f1f9-1f1f0","🇹🇱":"1f1f9-1f1f1","🇹🇲":"1f1f9-1f1f2","🇹🇳":"1f1f9-1f1f3","🇹🇴":"1f1f9-1f1f4","🇹🇷":"1f1f9-1f1f7","🇹🇹":"1f1f9-1f1f9","🇹🇻":"1f1f9-1f1fb","🇹🇼":"1f1f9-1f1fc","🇹🇿":"1f1f9-1f1ff","🇺🇦":"1f1fa-1f1e6","🇺🇬":"1f1fa-1f1ec","🇺🇲":"1f1fa-1f1f2","🇺🇳":"1f1fa-1f1f3","🇺🇸":"1f1fa-1f1f8","🇺🇾":"1f1fa-1f1fe","🇺🇿":"1f1fa-1f1ff","🇻🇦":"1f1fb-1f1e6","🇻🇨":"1f1fb-1f1e8","🇻🇪":"1f1fb-1f1ea","🇻🇬":"1f1fb-1f1ec","🇻🇮":"1f1fb-1f1ee","🇻🇳":"1f1fb-1f1f3","🇻🇺":"1f1fb-1f1fa","🇼🇫":"1f1fc-1f1eb","🇼🇸":"1f1fc-1f1f8","🇽🇰":"1f1fd-1f1f0","🇾🇪":"1f1fe-1f1ea","🇾🇹":"1f1fe-1f1f9","🇿🇦":"1f1ff-1f1e6","🇿🇲":"1f1ff-1f1f2","🇿🇼":"1f1ff-1f1fc","😶🌫":"1f636-200d-1f32b-fe0f","😮💨":"1f62e-200d-1f4a8","😵💫":"1f635-200d-1f4ab","❤🔥":"2764-fe0f-200d-1f525","❤🩹":"2764-fe0f-200d-1fa79","👁🗨":"1f441-200d-1f5e8","🧔♂":"1f9d4-200d-2642-fe0f","🧔♀":"1f9d4-200d-2640-fe0f","👨🦰":"1f468-200d-1f9b0","👨🦱":"1f468-200d-1f9b1","👨🦳":"1f468-200d-1f9b3","👨🦲":"1f468-200d-1f9b2","👩🦰":"1f469-200d-1f9b0","🧑🦰":"1f9d1-200d-1f9b0","👩🦱":"1f469-200d-1f9b1","🧑🦱":"1f9d1-200d-1f9b1","👩🦳":"1f469-200d-1f9b3","🧑🦳":"1f9d1-200d-1f9b3","👩🦲":"1f469-200d-1f9b2","🧑🦲":"1f9d1-200d-1f9b2","👱♀":"1f471-200d-2640-fe0f","👱♂":"1f471-200d-2642-fe0f","🙍♂":"1f64d-200d-2642-fe0f","🙍♀":"1f64d-200d-2640-fe0f","🙎♂":"1f64e-200d-2642-fe0f","🙎♀":"1f64e-200d-2640-fe0f","🙅♂":"1f645-200d-2642-fe0f","🙅♀":"1f645-200d-2640-fe0f","🙆♂":"1f646-200d-2642-fe0f","🙆♀":"1f646-200d-2640-fe0f","💁♂":"1f481-200d-2642-fe0f","💁♀":"1f481-200d-2640-fe0f","🙋♂":"1f64b-200d-2642-fe0f","🙋♀":"1f64b-200d-2640-fe0f","🧏♂":"1f9cf-200d-2642-fe0f","🧏♀":"1f9cf-200d-2640-fe0f","🙇♂":"1f647-200d-2642-fe0f","🙇♀":"1f647-200d-2640-fe0f","🤦♂":"1f926-200d-2642-fe0f","🤦♀":"1f926-200d-2640-fe0f","🤷♂":"1f937-200d-2642-fe0f","🤷♀":"1f937-200d-2640-fe0f","🧑⚕":"1f9d1-200d-2695-fe0f","👨⚕":"1f468-200d-2695-fe0f","👩⚕":"1f469-200d-2695-fe0f","🧑🎓":"1f9d1-200d-1f393","👨🎓":"1f468-200d-1f393","👩🎓":"1f469-200d-1f393","🧑🏫":"1f9d1-200d-1f3eb","👨🏫":"1f468-200d-1f3eb","👩🏫":"1f469-200d-1f3eb","🧑⚖":"1f9d1-200d-2696-fe0f","👨⚖":"1f468-200d-2696-fe0f","👩⚖":"1f469-200d-2696-fe0f","🧑🌾":"1f9d1-200d-1f33e","👨🌾":"1f468-200d-1f33e","👩🌾":"1f469-200d-1f33e","🧑🍳":"1f9d1-200d-1f373","👨🍳":"1f468-200d-1f373","👩🍳":"1f469-200d-1f373","🧑🔧":"1f9d1-200d-1f527","👨🔧":"1f468-200d-1f527","👩🔧":"1f469-200d-1f527","🧑🏭":"1f9d1-200d-1f3ed","👨🏭":"1f468-200d-1f3ed","👩🏭":"1f469-200d-1f3ed","🧑💼":"1f9d1-200d-1f4bc","👨💼":"1f468-200d-1f4bc","👩💼":"1f469-200d-1f4bc","🧑🔬":"1f9d1-200d-1f52c","👨🔬":"1f468-200d-1f52c","👩🔬":"1f469-200d-1f52c","🧑💻":"1f9d1-200d-1f4bb","👨💻":"1f468-200d-1f4bb","👩💻":"1f469-200d-1f4bb","🧑🎤":"1f9d1-200d-1f3a4","👨🎤":"1f468-200d-1f3a4","👩🎤":"1f469-200d-1f3a4","🧑🎨":"1f9d1-200d-1f3a8","👨🎨":"1f468-200d-1f3a8","👩🎨":"1f469-200d-1f3a8","🧑✈":"1f9d1-200d-2708-fe0f","👨✈":"1f468-200d-2708-fe0f","👩✈":"1f469-200d-2708-fe0f","🧑🚀":"1f9d1-200d-1f680","👨🚀":"1f468-200d-1f680","👩🚀":"1f469-200d-1f680","🧑🚒":"1f9d1-200d-1f692","👨🚒":"1f468-200d-1f692","👩🚒":"1f469-200d-1f692","👮♂":"1f46e-200d-2642-fe0f","👮♀":"1f46e-200d-2640-fe0f","🕵♂":"1f575-fe0f-200d-2642-fe0f","🕵♀":"1f575-fe0f-200d-2640-fe0f","💂♂":"1f482-200d-2642-fe0f","💂♀":"1f482-200d-2640-fe0f","👷♂":"1f477-200d-2642-fe0f","👷♀":"1f477-200d-2640-fe0f","👳♂":"1f473-200d-2642-fe0f","👳♀":"1f473-200d-2640-fe0f","🤵♂":"1f935-200d-2642-fe0f","🤵♀":"1f935-200d-2640-fe0f","👰♂":"1f470-200d-2642-fe0f","👰♀":"1f470-200d-2640-fe0f","👩🍼":"1f469-200d-1f37c","👨🍼":"1f468-200d-1f37c","🧑🍼":"1f9d1-200d-1f37c","🧑🎄":"1f9d1-200d-1f384","🦸♂":"1f9b8-200d-2642-fe0f","🦸♀":"1f9b8-200d-2640-fe0f","🦹♂":"1f9b9-200d-2642-fe0f","🦹♀":"1f9b9-200d-2640-fe0f","🧙♂":"1f9d9-200d-2642-fe0f","🧙♀":"1f9d9-200d-2640-fe0f","🧚♂":"1f9da-200d-2642-fe0f","🧚♀":"1f9da-200d-2640-fe0f","🧛♂":"1f9db-200d-2642-fe0f","🧛♀":"1f9db-200d-2640-fe0f","🧜♂":"1f9dc-200d-2642-fe0f","🧜♀":"1f9dc-200d-2640-fe0f","🧝♂":"1f9dd-200d-2642-fe0f","🧝♀":"1f9dd-200d-2640-fe0f","🧞♂":"1f9de-200d-2642-fe0f","🧞♀":"1f9de-200d-2640-fe0f","🧟♂":"1f9df-200d-2642-fe0f","🧟♀":"1f9df-200d-2640-fe0f","💆♂":"1f486-200d-2642-fe0f","💆♀":"1f486-200d-2640-fe0f","💇♂":"1f487-200d-2642-fe0f","💇♀":"1f487-200d-2640-fe0f","🚶♂":"1f6b6-200d-2642-fe0f","🚶♀":"1f6b6-200d-2640-fe0f","🧍♂":"1f9cd-200d-2642-fe0f","🧍♀":"1f9cd-200d-2640-fe0f","🧎♂":"1f9ce-200d-2642-fe0f","🧎♀":"1f9ce-200d-2640-fe0f","🧑🦯":"1f9d1-200d-1f9af","👨🦯":"1f468-200d-1f9af","👩🦯":"1f469-200d-1f9af","🧑🦼":"1f9d1-200d-1f9bc","👨🦼":"1f468-200d-1f9bc","👩🦼":"1f469-200d-1f9bc","🧑🦽":"1f9d1-200d-1f9bd","👨🦽":"1f468-200d-1f9bd","👩🦽":"1f469-200d-1f9bd","🏃♂":"1f3c3-200d-2642-fe0f","🏃♀":"1f3c3-200d-2640-fe0f","👯♂":"1f46f-200d-2642-fe0f","👯♀":"1f46f-200d-2640-fe0f","🧖♂":"1f9d6-200d-2642-fe0f","🧖♀":"1f9d6-200d-2640-fe0f","🧗♂":"1f9d7-200d-2642-fe0f","🧗♀":"1f9d7-200d-2640-fe0f","🏌♂":"1f3cc-fe0f-200d-2642-fe0f","🏌♀":"1f3cc-fe0f-200d-2640-fe0f","🏄♂":"1f3c4-200d-2642-fe0f","🏄♀":"1f3c4-200d-2640-fe0f","🚣♂":"1f6a3-200d-2642-fe0f","🚣♀":"1f6a3-200d-2640-fe0f","🏊♂":"1f3ca-200d-2642-fe0f","🏊♀":"1f3ca-200d-2640-fe0f","⛹♂":"26f9-fe0f-200d-2642-fe0f","⛹♀":"26f9-fe0f-200d-2640-fe0f","🏋♂":"1f3cb-fe0f-200d-2642-fe0f","🏋♀":"1f3cb-fe0f-200d-2640-fe0f","🚴♂":"1f6b4-200d-2642-fe0f","🚴♀":"1f6b4-200d-2640-fe0f","🚵♂":"1f6b5-200d-2642-fe0f","🚵♀":"1f6b5-200d-2640-fe0f","🤸♂":"1f938-200d-2642-fe0f","🤸♀":"1f938-200d-2640-fe0f","🤼♂":"1f93c-200d-2642-fe0f","🤼♀":"1f93c-200d-2640-fe0f","🤽♂":"1f93d-200d-2642-fe0f","🤽♀":"1f93d-200d-2640-fe0f","🤾♂":"1f93e-200d-2642-fe0f","🤾♀":"1f93e-200d-2640-fe0f","🤹♂":"1f939-200d-2642-fe0f","🤹♀":"1f939-200d-2640-fe0f","🧘♂":"1f9d8-200d-2642-fe0f","🧘♀":"1f9d8-200d-2640-fe0f","👨👦":"1f468-200d-1f466","👨👧":"1f468-200d-1f467","👩👦":"1f469-200d-1f466","👩👧":"1f469-200d-1f467","🐕🦺":"1f415-200d-1f9ba","🐈⬛":"1f408-200d-2b1b","🐻❄":"1f43b-200d-2744-fe0f","#️⃣":"23-20e3","*️⃣":"2a-20e3","0️⃣":"30-20e3","1️⃣":"31-20e3","2️⃣":"32-20e3","3️⃣":"33-20e3","4️⃣":"34-20e3","5️⃣":"35-20e3","6️⃣":"36-20e3","7️⃣":"37-20e3","8️⃣":"38-20e3","9️⃣":"39-20e3","🏳🌈":"1f3f3-fe0f-200d-1f308","🏳⚧":"1f3f3-fe0f-200d-26a7-fe0f","🏴☠":"1f3f4-200d-2620-fe0f","😶🌫️":"1f636-200d-1f32b-fe0f","❤️🔥":"2764-fe0f-200d-1f525","❤️🩹":"2764-fe0f-200d-1fa79","👁🗨️":"1f441-200d-1f5e8","👁️🗨":"1f441-200d-1f5e8","🧔♂️":"1f9d4-200d-2642-fe0f","🧔🏻♂":"1f9d4-1f3fb-200d-2642-fe0f","🧔🏼♂":"1f9d4-1f3fc-200d-2642-fe0f","🧔🏽♂":"1f9d4-1f3fd-200d-2642-fe0f","🧔🏾♂":"1f9d4-1f3fe-200d-2642-fe0f","🧔🏿♂":"1f9d4-1f3ff-200d-2642-fe0f","🧔♀️":"1f9d4-200d-2640-fe0f","🧔🏻♀":"1f9d4-1f3fb-200d-2640-fe0f","🧔🏼♀":"1f9d4-1f3fc-200d-2640-fe0f","🧔🏽♀":"1f9d4-1f3fd-200d-2640-fe0f","🧔🏾♀":"1f9d4-1f3fe-200d-2640-fe0f","🧔🏿♀":"1f9d4-1f3ff-200d-2640-fe0f","👨🏻🦰":"1f468-1f3fb-200d-1f9b0","👨🏼🦰":"1f468-1f3fc-200d-1f9b0","👨🏽🦰":"1f468-1f3fd-200d-1f9b0","👨🏾🦰":"1f468-1f3fe-200d-1f9b0","👨🏿🦰":"1f468-1f3ff-200d-1f9b0","👨🏻🦱":"1f468-1f3fb-200d-1f9b1","👨🏼🦱":"1f468-1f3fc-200d-1f9b1","👨🏽🦱":"1f468-1f3fd-200d-1f9b1","👨🏾🦱":"1f468-1f3fe-200d-1f9b1","👨🏿🦱":"1f468-1f3ff-200d-1f9b1","👨🏻🦳":"1f468-1f3fb-200d-1f9b3","👨🏼🦳":"1f468-1f3fc-200d-1f9b3","👨🏽🦳":"1f468-1f3fd-200d-1f9b3","👨🏾🦳":"1f468-1f3fe-200d-1f9b3","👨🏿🦳":"1f468-1f3ff-200d-1f9b3","👨🏻🦲":"1f468-1f3fb-200d-1f9b2","👨🏼🦲":"1f468-1f3fc-200d-1f9b2","👨🏽🦲":"1f468-1f3fd-200d-1f9b2","👨🏾🦲":"1f468-1f3fe-200d-1f9b2","👨🏿🦲":"1f468-1f3ff-200d-1f9b2","👩🏻🦰":"1f469-1f3fb-200d-1f9b0","👩🏼🦰":"1f469-1f3fc-200d-1f9b0","👩🏽🦰":"1f469-1f3fd-200d-1f9b0","👩🏾🦰":"1f469-1f3fe-200d-1f9b0","👩🏿🦰":"1f469-1f3ff-200d-1f9b0","🧑🏻🦰":"1f9d1-1f3fb-200d-1f9b0","🧑🏼🦰":"1f9d1-1f3fc-200d-1f9b0","🧑🏽🦰":"1f9d1-1f3fd-200d-1f9b0","🧑🏾🦰":"1f9d1-1f3fe-200d-1f9b0","🧑🏿🦰":"1f9d1-1f3ff-200d-1f9b0","👩🏻🦱":"1f469-1f3fb-200d-1f9b1","👩🏼🦱":"1f469-1f3fc-200d-1f9b1","👩🏽🦱":"1f469-1f3fd-200d-1f9b1","👩🏾🦱":"1f469-1f3fe-200d-1f9b1","👩🏿🦱":"1f469-1f3ff-200d-1f9b1","🧑🏻🦱":"1f9d1-1f3fb-200d-1f9b1","🧑🏼🦱":"1f9d1-1f3fc-200d-1f9b1","🧑🏽🦱":"1f9d1-1f3fd-200d-1f9b1","🧑🏾🦱":"1f9d1-1f3fe-200d-1f9b1","🧑🏿🦱":"1f9d1-1f3ff-200d-1f9b1","👩🏻🦳":"1f469-1f3fb-200d-1f9b3","👩🏼🦳":"1f469-1f3fc-200d-1f9b3","👩🏽🦳":"1f469-1f3fd-200d-1f9b3","👩🏾🦳":"1f469-1f3fe-200d-1f9b3","👩🏿🦳":"1f469-1f3ff-200d-1f9b3","🧑🏻🦳":"1f9d1-1f3fb-200d-1f9b3","🧑🏼🦳":"1f9d1-1f3fc-200d-1f9b3","🧑🏽🦳":"1f9d1-1f3fd-200d-1f9b3","🧑🏾🦳":"1f9d1-1f3fe-200d-1f9b3","🧑🏿🦳":"1f9d1-1f3ff-200d-1f9b3","👩🏻🦲":"1f469-1f3fb-200d-1f9b2","👩🏼🦲":"1f469-1f3fc-200d-1f9b2","👩🏽🦲":"1f469-1f3fd-200d-1f9b2","👩🏾🦲":"1f469-1f3fe-200d-1f9b2","👩🏿🦲":"1f469-1f3ff-200d-1f9b2","🧑🏻🦲":"1f9d1-1f3fb-200d-1f9b2","🧑🏼🦲":"1f9d1-1f3fc-200d-1f9b2","🧑🏽🦲":"1f9d1-1f3fd-200d-1f9b2","🧑🏾🦲":"1f9d1-1f3fe-200d-1f9b2","🧑🏿🦲":"1f9d1-1f3ff-200d-1f9b2","👱♀️":"1f471-200d-2640-fe0f","👱🏻♀":"1f471-1f3fb-200d-2640-fe0f","👱🏼♀":"1f471-1f3fc-200d-2640-fe0f","👱🏽♀":"1f471-1f3fd-200d-2640-fe0f","👱🏾♀":"1f471-1f3fe-200d-2640-fe0f","👱🏿♀":"1f471-1f3ff-200d-2640-fe0f","👱♂️":"1f471-200d-2642-fe0f","👱🏻♂":"1f471-1f3fb-200d-2642-fe0f","👱🏼♂":"1f471-1f3fc-200d-2642-fe0f","👱🏽♂":"1f471-1f3fd-200d-2642-fe0f","👱🏾♂":"1f471-1f3fe-200d-2642-fe0f","👱🏿♂":"1f471-1f3ff-200d-2642-fe0f","🙍♂️":"1f64d-200d-2642-fe0f","🙍🏻♂":"1f64d-1f3fb-200d-2642-fe0f","🙍🏼♂":"1f64d-1f3fc-200d-2642-fe0f","🙍🏽♂":"1f64d-1f3fd-200d-2642-fe0f","🙍🏾♂":"1f64d-1f3fe-200d-2642-fe0f","🙍🏿♂":"1f64d-1f3ff-200d-2642-fe0f","🙍♀️":"1f64d-200d-2640-fe0f","🙍🏻♀":"1f64d-1f3fb-200d-2640-fe0f","🙍🏼♀":"1f64d-1f3fc-200d-2640-fe0f","🙍🏽♀":"1f64d-1f3fd-200d-2640-fe0f","🙍🏾♀":"1f64d-1f3fe-200d-2640-fe0f","🙍🏿♀":"1f64d-1f3ff-200d-2640-fe0f","🙎♂️":"1f64e-200d-2642-fe0f","🙎🏻♂":"1f64e-1f3fb-200d-2642-fe0f","🙎🏼♂":"1f64e-1f3fc-200d-2642-fe0f","🙎🏽♂":"1f64e-1f3fd-200d-2642-fe0f","🙎🏾♂":"1f64e-1f3fe-200d-2642-fe0f","🙎🏿♂":"1f64e-1f3ff-200d-2642-fe0f","🙎♀️":"1f64e-200d-2640-fe0f","🙎🏻♀":"1f64e-1f3fb-200d-2640-fe0f","🙎🏼♀":"1f64e-1f3fc-200d-2640-fe0f","🙎🏽♀":"1f64e-1f3fd-200d-2640-fe0f","🙎🏾♀":"1f64e-1f3fe-200d-2640-fe0f","🙎🏿♀":"1f64e-1f3ff-200d-2640-fe0f","🙅♂️":"1f645-200d-2642-fe0f","🙅🏻♂":"1f645-1f3fb-200d-2642-fe0f","🙅🏼♂":"1f645-1f3fc-200d-2642-fe0f","🙅🏽♂":"1f645-1f3fd-200d-2642-fe0f","🙅🏾♂":"1f645-1f3fe-200d-2642-fe0f","🙅🏿♂":"1f645-1f3ff-200d-2642-fe0f","🙅♀️":"1f645-200d-2640-fe0f","🙅🏻♀":"1f645-1f3fb-200d-2640-fe0f","🙅🏼♀":"1f645-1f3fc-200d-2640-fe0f","🙅🏽♀":"1f645-1f3fd-200d-2640-fe0f","🙅🏾♀":"1f645-1f3fe-200d-2640-fe0f","🙅🏿♀":"1f645-1f3ff-200d-2640-fe0f","🙆♂️":"1f646-200d-2642-fe0f","🙆🏻♂":"1f646-1f3fb-200d-2642-fe0f","🙆🏼♂":"1f646-1f3fc-200d-2642-fe0f","🙆🏽♂":"1f646-1f3fd-200d-2642-fe0f","🙆🏾♂":"1f646-1f3fe-200d-2642-fe0f","🙆🏿♂":"1f646-1f3ff-200d-2642-fe0f","🙆♀️":"1f646-200d-2640-fe0f","🙆🏻♀":"1f646-1f3fb-200d-2640-fe0f","🙆🏼♀":"1f646-1f3fc-200d-2640-fe0f","🙆🏽♀":"1f646-1f3fd-200d-2640-fe0f","🙆🏾♀":"1f646-1f3fe-200d-2640-fe0f","🙆🏿♀":"1f646-1f3ff-200d-2640-fe0f","💁♂️":"1f481-200d-2642-fe0f","💁🏻♂":"1f481-1f3fb-200d-2642-fe0f","💁🏼♂":"1f481-1f3fc-200d-2642-fe0f","💁🏽♂":"1f481-1f3fd-200d-2642-fe0f","💁🏾♂":"1f481-1f3fe-200d-2642-fe0f","💁🏿♂":"1f481-1f3ff-200d-2642-fe0f","💁♀️":"1f481-200d-2640-fe0f","💁🏻♀":"1f481-1f3fb-200d-2640-fe0f","💁🏼♀":"1f481-1f3fc-200d-2640-fe0f","💁🏽♀":"1f481-1f3fd-200d-2640-fe0f","💁🏾♀":"1f481-1f3fe-200d-2640-fe0f","💁🏿♀":"1f481-1f3ff-200d-2640-fe0f","🙋♂️":"1f64b-200d-2642-fe0f","🙋🏻♂":"1f64b-1f3fb-200d-2642-fe0f","🙋🏼♂":"1f64b-1f3fc-200d-2642-fe0f","🙋🏽♂":"1f64b-1f3fd-200d-2642-fe0f","🙋🏾♂":"1f64b-1f3fe-200d-2642-fe0f","🙋🏿♂":"1f64b-1f3ff-200d-2642-fe0f","🙋♀️":"1f64b-200d-2640-fe0f","🙋🏻♀":"1f64b-1f3fb-200d-2640-fe0f","🙋🏼♀":"1f64b-1f3fc-200d-2640-fe0f","🙋🏽♀":"1f64b-1f3fd-200d-2640-fe0f","🙋🏾♀":"1f64b-1f3fe-200d-2640-fe0f","🙋🏿♀":"1f64b-1f3ff-200d-2640-fe0f","🧏♂️":"1f9cf-200d-2642-fe0f","🧏🏻♂":"1f9cf-1f3fb-200d-2642-fe0f","🧏🏼♂":"1f9cf-1f3fc-200d-2642-fe0f","🧏🏽♂":"1f9cf-1f3fd-200d-2642-fe0f","🧏🏾♂":"1f9cf-1f3fe-200d-2642-fe0f","🧏🏿♂":"1f9cf-1f3ff-200d-2642-fe0f","🧏♀️":"1f9cf-200d-2640-fe0f","🧏🏻♀":"1f9cf-1f3fb-200d-2640-fe0f","🧏🏼♀":"1f9cf-1f3fc-200d-2640-fe0f","🧏🏽♀":"1f9cf-1f3fd-200d-2640-fe0f","🧏🏾♀":"1f9cf-1f3fe-200d-2640-fe0f","🧏🏿♀":"1f9cf-1f3ff-200d-2640-fe0f","🙇♂️":"1f647-200d-2642-fe0f","🙇🏻♂":"1f647-1f3fb-200d-2642-fe0f","🙇🏼♂":"1f647-1f3fc-200d-2642-fe0f","🙇🏽♂":"1f647-1f3fd-200d-2642-fe0f","🙇🏾♂":"1f647-1f3fe-200d-2642-fe0f","🙇🏿♂":"1f647-1f3ff-200d-2642-fe0f","🙇♀️":"1f647-200d-2640-fe0f","🙇🏻♀":"1f647-1f3fb-200d-2640-fe0f","🙇🏼♀":"1f647-1f3fc-200d-2640-fe0f","🙇🏽♀":"1f647-1f3fd-200d-2640-fe0f","🙇🏾♀":"1f647-1f3fe-200d-2640-fe0f","🙇🏿♀":"1f647-1f3ff-200d-2640-fe0f","🤦♂️":"1f926-200d-2642-fe0f","🤦🏻♂":"1f926-1f3fb-200d-2642-fe0f","🤦🏼♂":"1f926-1f3fc-200d-2642-fe0f","🤦🏽♂":"1f926-1f3fd-200d-2642-fe0f","🤦🏾♂":"1f926-1f3fe-200d-2642-fe0f","🤦🏿♂":"1f926-1f3ff-200d-2642-fe0f","🤦♀️":"1f926-200d-2640-fe0f","🤦🏻♀":"1f926-1f3fb-200d-2640-fe0f","🤦🏼♀":"1f926-1f3fc-200d-2640-fe0f","🤦🏽♀":"1f926-1f3fd-200d-2640-fe0f","🤦🏾♀":"1f926-1f3fe-200d-2640-fe0f","🤦🏿♀":"1f926-1f3ff-200d-2640-fe0f","🤷♂️":"1f937-200d-2642-fe0f","🤷🏻♂":"1f937-1f3fb-200d-2642-fe0f","🤷🏼♂":"1f937-1f3fc-200d-2642-fe0f","🤷🏽♂":"1f937-1f3fd-200d-2642-fe0f","🤷🏾♂":"1f937-1f3fe-200d-2642-fe0f","🤷🏿♂":"1f937-1f3ff-200d-2642-fe0f","🤷♀️":"1f937-200d-2640-fe0f","🤷🏻♀":"1f937-1f3fb-200d-2640-fe0f","🤷🏼♀":"1f937-1f3fc-200d-2640-fe0f","🤷🏽♀":"1f937-1f3fd-200d-2640-fe0f","🤷🏾♀":"1f937-1f3fe-200d-2640-fe0f","🤷🏿♀":"1f937-1f3ff-200d-2640-fe0f","🧑⚕️":"1f9d1-200d-2695-fe0f","🧑🏻⚕":"1f9d1-1f3fb-200d-2695-fe0f","🧑🏼⚕":"1f9d1-1f3fc-200d-2695-fe0f","🧑🏽⚕":"1f9d1-1f3fd-200d-2695-fe0f","🧑🏾⚕":"1f9d1-1f3fe-200d-2695-fe0f","🧑🏿⚕":"1f9d1-1f3ff-200d-2695-fe0f","👨⚕️":"1f468-200d-2695-fe0f","👨🏻⚕":"1f468-1f3fb-200d-2695-fe0f","👨🏼⚕":"1f468-1f3fc-200d-2695-fe0f","👨🏽⚕":"1f468-1f3fd-200d-2695-fe0f","👨🏾⚕":"1f468-1f3fe-200d-2695-fe0f","👨🏿⚕":"1f468-1f3ff-200d-2695-fe0f","👩⚕️":"1f469-200d-2695-fe0f","👩🏻⚕":"1f469-1f3fb-200d-2695-fe0f","👩🏼⚕":"1f469-1f3fc-200d-2695-fe0f","👩🏽⚕":"1f469-1f3fd-200d-2695-fe0f","👩🏾⚕":"1f469-1f3fe-200d-2695-fe0f","👩🏿⚕":"1f469-1f3ff-200d-2695-fe0f","🧑🏻🎓":"1f9d1-1f3fb-200d-1f393","🧑🏼🎓":"1f9d1-1f3fc-200d-1f393","🧑🏽🎓":"1f9d1-1f3fd-200d-1f393","🧑🏾🎓":"1f9d1-1f3fe-200d-1f393","🧑🏿🎓":"1f9d1-1f3ff-200d-1f393","👨🏻🎓":"1f468-1f3fb-200d-1f393","👨🏼🎓":"1f468-1f3fc-200d-1f393","👨🏽🎓":"1f468-1f3fd-200d-1f393","👨🏾🎓":"1f468-1f3fe-200d-1f393","👨🏿🎓":"1f468-1f3ff-200d-1f393","👩🏻🎓":"1f469-1f3fb-200d-1f393","👩🏼🎓":"1f469-1f3fc-200d-1f393","👩🏽🎓":"1f469-1f3fd-200d-1f393","👩🏾🎓":"1f469-1f3fe-200d-1f393","👩🏿🎓":"1f469-1f3ff-200d-1f393","🧑🏻🏫":"1f9d1-1f3fb-200d-1f3eb","🧑🏼🏫":"1f9d1-1f3fc-200d-1f3eb","🧑🏽🏫":"1f9d1-1f3fd-200d-1f3eb","🧑🏾🏫":"1f9d1-1f3fe-200d-1f3eb","🧑🏿🏫":"1f9d1-1f3ff-200d-1f3eb","👨🏻🏫":"1f468-1f3fb-200d-1f3eb","👨🏼🏫":"1f468-1f3fc-200d-1f3eb","👨🏽🏫":"1f468-1f3fd-200d-1f3eb","👨🏾🏫":"1f468-1f3fe-200d-1f3eb","👨🏿🏫":"1f468-1f3ff-200d-1f3eb","👩🏻🏫":"1f469-1f3fb-200d-1f3eb","👩🏼🏫":"1f469-1f3fc-200d-1f3eb","👩🏽🏫":"1f469-1f3fd-200d-1f3eb","👩🏾🏫":"1f469-1f3fe-200d-1f3eb","👩🏿🏫":"1f469-1f3ff-200d-1f3eb","🧑⚖️":"1f9d1-200d-2696-fe0f","🧑🏻⚖":"1f9d1-1f3fb-200d-2696-fe0f","🧑🏼⚖":"1f9d1-1f3fc-200d-2696-fe0f","🧑🏽⚖":"1f9d1-1f3fd-200d-2696-fe0f","🧑🏾⚖":"1f9d1-1f3fe-200d-2696-fe0f","🧑🏿⚖":"1f9d1-1f3ff-200d-2696-fe0f","👨⚖️":"1f468-200d-2696-fe0f","👨🏻⚖":"1f468-1f3fb-200d-2696-fe0f","👨🏼⚖":"1f468-1f3fc-200d-2696-fe0f","👨🏽⚖":"1f468-1f3fd-200d-2696-fe0f","👨🏾⚖":"1f468-1f3fe-200d-2696-fe0f","👨🏿⚖":"1f468-1f3ff-200d-2696-fe0f","👩⚖️":"1f469-200d-2696-fe0f","👩🏻⚖":"1f469-1f3fb-200d-2696-fe0f","👩🏼⚖":"1f469-1f3fc-200d-2696-fe0f","👩🏽⚖":"1f469-1f3fd-200d-2696-fe0f","👩🏾⚖":"1f469-1f3fe-200d-2696-fe0f","👩🏿⚖":"1f469-1f3ff-200d-2696-fe0f","🧑🏻🌾":"1f9d1-1f3fb-200d-1f33e","🧑🏼🌾":"1f9d1-1f3fc-200d-1f33e","🧑🏽🌾":"1f9d1-1f3fd-200d-1f33e","🧑🏾🌾":"1f9d1-1f3fe-200d-1f33e","🧑🏿🌾":"1f9d1-1f3ff-200d-1f33e","👨🏻🌾":"1f468-1f3fb-200d-1f33e","👨🏼🌾":"1f468-1f3fc-200d-1f33e","👨🏽🌾":"1f468-1f3fd-200d-1f33e","👨🏾🌾":"1f468-1f3fe-200d-1f33e","👨🏿🌾":"1f468-1f3ff-200d-1f33e","👩🏻🌾":"1f469-1f3fb-200d-1f33e","👩🏼🌾":"1f469-1f3fc-200d-1f33e","👩🏽🌾":"1f469-1f3fd-200d-1f33e","👩🏾🌾":"1f469-1f3fe-200d-1f33e","👩🏿🌾":"1f469-1f3ff-200d-1f33e","🧑🏻🍳":"1f9d1-1f3fb-200d-1f373","🧑🏼🍳":"1f9d1-1f3fc-200d-1f373","🧑🏽🍳":"1f9d1-1f3fd-200d-1f373","🧑🏾🍳":"1f9d1-1f3fe-200d-1f373","🧑🏿🍳":"1f9d1-1f3ff-200d-1f373","👨🏻🍳":"1f468-1f3fb-200d-1f373","👨🏼🍳":"1f468-1f3fc-200d-1f373","👨🏽🍳":"1f468-1f3fd-200d-1f373","👨🏾🍳":"1f468-1f3fe-200d-1f373","👨🏿🍳":"1f468-1f3ff-200d-1f373","👩🏻🍳":"1f469-1f3fb-200d-1f373","👩🏼🍳":"1f469-1f3fc-200d-1f373","👩🏽🍳":"1f469-1f3fd-200d-1f373","👩🏾🍳":"1f469-1f3fe-200d-1f373","👩🏿🍳":"1f469-1f3ff-200d-1f373","🧑🏻🔧":"1f9d1-1f3fb-200d-1f527","🧑🏼🔧":"1f9d1-1f3fc-200d-1f527","🧑🏽🔧":"1f9d1-1f3fd-200d-1f527","🧑🏾🔧":"1f9d1-1f3fe-200d-1f527","🧑🏿🔧":"1f9d1-1f3ff-200d-1f527","👨🏻🔧":"1f468-1f3fb-200d-1f527","👨🏼🔧":"1f468-1f3fc-200d-1f527","👨🏽🔧":"1f468-1f3fd-200d-1f527","👨🏾🔧":"1f468-1f3fe-200d-1f527","👨🏿🔧":"1f468-1f3ff-200d-1f527","👩🏻🔧":"1f469-1f3fb-200d-1f527","👩🏼🔧":"1f469-1f3fc-200d-1f527","👩🏽🔧":"1f469-1f3fd-200d-1f527","👩🏾🔧":"1f469-1f3fe-200d-1f527","👩🏿🔧":"1f469-1f3ff-200d-1f527","🧑🏻🏭":"1f9d1-1f3fb-200d-1f3ed","🧑🏼🏭":"1f9d1-1f3fc-200d-1f3ed","🧑🏽🏭":"1f9d1-1f3fd-200d-1f3ed","🧑🏾🏭":"1f9d1-1f3fe-200d-1f3ed","🧑🏿🏭":"1f9d1-1f3ff-200d-1f3ed","👨🏻🏭":"1f468-1f3fb-200d-1f3ed","👨🏼🏭":"1f468-1f3fc-200d-1f3ed","👨🏽🏭":"1f468-1f3fd-200d-1f3ed","👨🏾🏭":"1f468-1f3fe-200d-1f3ed","👨🏿🏭":"1f468-1f3ff-200d-1f3ed","👩🏻🏭":"1f469-1f3fb-200d-1f3ed","👩🏼🏭":"1f469-1f3fc-200d-1f3ed","👩🏽🏭":"1f469-1f3fd-200d-1f3ed","👩🏾🏭":"1f469-1f3fe-200d-1f3ed","👩🏿🏭":"1f469-1f3ff-200d-1f3ed","🧑🏻💼":"1f9d1-1f3fb-200d-1f4bc","🧑🏼💼":"1f9d1-1f3fc-200d-1f4bc","🧑🏽💼":"1f9d1-1f3fd-200d-1f4bc","🧑🏾💼":"1f9d1-1f3fe-200d-1f4bc","🧑🏿💼":"1f9d1-1f3ff-200d-1f4bc","👨🏻💼":"1f468-1f3fb-200d-1f4bc","👨🏼💼":"1f468-1f3fc-200d-1f4bc","👨🏽💼":"1f468-1f3fd-200d-1f4bc","👨🏾💼":"1f468-1f3fe-200d-1f4bc","👨🏿💼":"1f468-1f3ff-200d-1f4bc","👩🏻💼":"1f469-1f3fb-200d-1f4bc","👩🏼💼":"1f469-1f3fc-200d-1f4bc","👩🏽💼":"1f469-1f3fd-200d-1f4bc","👩🏾💼":"1f469-1f3fe-200d-1f4bc","👩🏿💼":"1f469-1f3ff-200d-1f4bc","🧑🏻🔬":"1f9d1-1f3fb-200d-1f52c","🧑🏼🔬":"1f9d1-1f3fc-200d-1f52c","🧑🏽🔬":"1f9d1-1f3fd-200d-1f52c","🧑🏾🔬":"1f9d1-1f3fe-200d-1f52c","🧑🏿🔬":"1f9d1-1f3ff-200d-1f52c","👨🏻🔬":"1f468-1f3fb-200d-1f52c","👨🏼🔬":"1f468-1f3fc-200d-1f52c","👨🏽🔬":"1f468-1f3fd-200d-1f52c","👨🏾🔬":"1f468-1f3fe-200d-1f52c","👨🏿🔬":"1f468-1f3ff-200d-1f52c","👩🏻🔬":"1f469-1f3fb-200d-1f52c","👩🏼🔬":"1f469-1f3fc-200d-1f52c","👩🏽🔬":"1f469-1f3fd-200d-1f52c","👩🏾🔬":"1f469-1f3fe-200d-1f52c","👩🏿🔬":"1f469-1f3ff-200d-1f52c","🧑🏻💻":"1f9d1-1f3fb-200d-1f4bb","🧑🏼💻":"1f9d1-1f3fc-200d-1f4bb","🧑🏽💻":"1f9d1-1f3fd-200d-1f4bb","🧑🏾💻":"1f9d1-1f3fe-200d-1f4bb","🧑🏿💻":"1f9d1-1f3ff-200d-1f4bb","👨🏻💻":"1f468-1f3fb-200d-1f4bb","👨🏼💻":"1f468-1f3fc-200d-1f4bb","👨🏽💻":"1f468-1f3fd-200d-1f4bb","👨🏾💻":"1f468-1f3fe-200d-1f4bb","👨🏿💻":"1f468-1f3ff-200d-1f4bb","👩🏻💻":"1f469-1f3fb-200d-1f4bb","👩🏼💻":"1f469-1f3fc-200d-1f4bb","👩🏽💻":"1f469-1f3fd-200d-1f4bb","👩🏾💻":"1f469-1f3fe-200d-1f4bb","👩🏿💻":"1f469-1f3ff-200d-1f4bb","🧑🏻🎤":"1f9d1-1f3fb-200d-1f3a4","🧑🏼🎤":"1f9d1-1f3fc-200d-1f3a4","🧑🏽🎤":"1f9d1-1f3fd-200d-1f3a4","🧑🏾🎤":"1f9d1-1f3fe-200d-1f3a4","🧑🏿🎤":"1f9d1-1f3ff-200d-1f3a4","👨🏻🎤":"1f468-1f3fb-200d-1f3a4","👨🏼🎤":"1f468-1f3fc-200d-1f3a4","👨🏽🎤":"1f468-1f3fd-200d-1f3a4","👨🏾🎤":"1f468-1f3fe-200d-1f3a4","👨🏿🎤":"1f468-1f3ff-200d-1f3a4","👩🏻🎤":"1f469-1f3fb-200d-1f3a4","👩🏼🎤":"1f469-1f3fc-200d-1f3a4","👩🏽🎤":"1f469-1f3fd-200d-1f3a4","👩🏾🎤":"1f469-1f3fe-200d-1f3a4","👩🏿🎤":"1f469-1f3ff-200d-1f3a4","🧑🏻🎨":"1f9d1-1f3fb-200d-1f3a8","🧑🏼🎨":"1f9d1-1f3fc-200d-1f3a8","🧑🏽🎨":"1f9d1-1f3fd-200d-1f3a8","🧑🏾🎨":"1f9d1-1f3fe-200d-1f3a8","🧑🏿🎨":"1f9d1-1f3ff-200d-1f3a8","👨🏻🎨":"1f468-1f3fb-200d-1f3a8","👨🏼🎨":"1f468-1f3fc-200d-1f3a8","👨🏽🎨":"1f468-1f3fd-200d-1f3a8","👨🏾🎨":"1f468-1f3fe-200d-1f3a8","👨🏿🎨":"1f468-1f3ff-200d-1f3a8","👩🏻🎨":"1f469-1f3fb-200d-1f3a8","👩🏼🎨":"1f469-1f3fc-200d-1f3a8","👩🏽🎨":"1f469-1f3fd-200d-1f3a8","👩🏾🎨":"1f469-1f3fe-200d-1f3a8","👩🏿🎨":"1f469-1f3ff-200d-1f3a8","🧑✈️":"1f9d1-200d-2708-fe0f","🧑🏻✈":"1f9d1-1f3fb-200d-2708-fe0f","🧑🏼✈":"1f9d1-1f3fc-200d-2708-fe0f","🧑🏽✈":"1f9d1-1f3fd-200d-2708-fe0f","🧑🏾✈":"1f9d1-1f3fe-200d-2708-fe0f","🧑🏿✈":"1f9d1-1f3ff-200d-2708-fe0f","👨✈️":"1f468-200d-2708-fe0f","👨🏻✈":"1f468-1f3fb-200d-2708-fe0f","👨🏼✈":"1f468-1f3fc-200d-2708-fe0f","👨🏽✈":"1f468-1f3fd-200d-2708-fe0f","👨🏾✈":"1f468-1f3fe-200d-2708-fe0f","👨🏿✈":"1f468-1f3ff-200d-2708-fe0f","👩✈️":"1f469-200d-2708-fe0f","👩🏻✈":"1f469-1f3fb-200d-2708-fe0f","👩🏼✈":"1f469-1f3fc-200d-2708-fe0f","👩🏽✈":"1f469-1f3fd-200d-2708-fe0f","👩🏾✈":"1f469-1f3fe-200d-2708-fe0f","👩🏿✈":"1f469-1f3ff-200d-2708-fe0f","🧑🏻🚀":"1f9d1-1f3fb-200d-1f680","🧑🏼🚀":"1f9d1-1f3fc-200d-1f680","🧑🏽🚀":"1f9d1-1f3fd-200d-1f680","🧑🏾🚀":"1f9d1-1f3fe-200d-1f680","🧑🏿🚀":"1f9d1-1f3ff-200d-1f680","👨🏻🚀":"1f468-1f3fb-200d-1f680","👨🏼🚀":"1f468-1f3fc-200d-1f680","👨🏽🚀":"1f468-1f3fd-200d-1f680","👨🏾🚀":"1f468-1f3fe-200d-1f680","👨🏿🚀":"1f468-1f3ff-200d-1f680","👩🏻🚀":"1f469-1f3fb-200d-1f680","👩🏼🚀":"1f469-1f3fc-200d-1f680","👩🏽🚀":"1f469-1f3fd-200d-1f680","👩🏾🚀":"1f469-1f3fe-200d-1f680","👩🏿🚀":"1f469-1f3ff-200d-1f680","🧑🏻🚒":"1f9d1-1f3fb-200d-1f692","🧑🏼🚒":"1f9d1-1f3fc-200d-1f692","🧑🏽🚒":"1f9d1-1f3fd-200d-1f692","🧑🏾🚒":"1f9d1-1f3fe-200d-1f692","🧑🏿🚒":"1f9d1-1f3ff-200d-1f692","👨🏻🚒":"1f468-1f3fb-200d-1f692","👨🏼🚒":"1f468-1f3fc-200d-1f692","👨🏽🚒":"1f468-1f3fd-200d-1f692","👨🏾🚒":"1f468-1f3fe-200d-1f692","👨🏿🚒":"1f468-1f3ff-200d-1f692","👩🏻🚒":"1f469-1f3fb-200d-1f692","👩🏼🚒":"1f469-1f3fc-200d-1f692","👩🏽🚒":"1f469-1f3fd-200d-1f692","👩🏾🚒":"1f469-1f3fe-200d-1f692","👩🏿🚒":"1f469-1f3ff-200d-1f692","👮♂️":"1f46e-200d-2642-fe0f","👮🏻♂":"1f46e-1f3fb-200d-2642-fe0f","👮🏼♂":"1f46e-1f3fc-200d-2642-fe0f","👮🏽♂":"1f46e-1f3fd-200d-2642-fe0f","👮🏾♂":"1f46e-1f3fe-200d-2642-fe0f","👮🏿♂":"1f46e-1f3ff-200d-2642-fe0f","👮♀️":"1f46e-200d-2640-fe0f","👮🏻♀":"1f46e-1f3fb-200d-2640-fe0f","👮🏼♀":"1f46e-1f3fc-200d-2640-fe0f","👮🏽♀":"1f46e-1f3fd-200d-2640-fe0f","👮🏾♀":"1f46e-1f3fe-200d-2640-fe0f","👮🏿♀":"1f46e-1f3ff-200d-2640-fe0f","🕵♂️":"1f575-fe0f-200d-2642-fe0f","🕵️♂":"1f575-fe0f-200d-2642-fe0f","🕵🏻♂":"1f575-1f3fb-200d-2642-fe0f","🕵🏼♂":"1f575-1f3fc-200d-2642-fe0f","🕵🏽♂":"1f575-1f3fd-200d-2642-fe0f","🕵🏾♂":"1f575-1f3fe-200d-2642-fe0f","🕵🏿♂":"1f575-1f3ff-200d-2642-fe0f","🕵♀️":"1f575-fe0f-200d-2640-fe0f","🕵️♀":"1f575-fe0f-200d-2640-fe0f","🕵🏻♀":"1f575-1f3fb-200d-2640-fe0f","🕵🏼♀":"1f575-1f3fc-200d-2640-fe0f","🕵🏽♀":"1f575-1f3fd-200d-2640-fe0f","🕵🏾♀":"1f575-1f3fe-200d-2640-fe0f","🕵🏿♀":"1f575-1f3ff-200d-2640-fe0f","💂♂️":"1f482-200d-2642-fe0f","💂🏻♂":"1f482-1f3fb-200d-2642-fe0f","💂🏼♂":"1f482-1f3fc-200d-2642-fe0f","💂🏽♂":"1f482-1f3fd-200d-2642-fe0f","💂🏾♂":"1f482-1f3fe-200d-2642-fe0f","💂🏿♂":"1f482-1f3ff-200d-2642-fe0f","💂♀️":"1f482-200d-2640-fe0f","💂🏻♀":"1f482-1f3fb-200d-2640-fe0f","💂🏼♀":"1f482-1f3fc-200d-2640-fe0f","💂🏽♀":"1f482-1f3fd-200d-2640-fe0f","💂🏾♀":"1f482-1f3fe-200d-2640-fe0f","💂🏿♀":"1f482-1f3ff-200d-2640-fe0f","👷♂️":"1f477-200d-2642-fe0f","👷🏻♂":"1f477-1f3fb-200d-2642-fe0f","👷🏼♂":"1f477-1f3fc-200d-2642-fe0f","👷🏽♂":"1f477-1f3fd-200d-2642-fe0f","👷🏾♂":"1f477-1f3fe-200d-2642-fe0f","👷🏿♂":"1f477-1f3ff-200d-2642-fe0f","👷♀️":"1f477-200d-2640-fe0f","👷🏻♀":"1f477-1f3fb-200d-2640-fe0f","👷🏼♀":"1f477-1f3fc-200d-2640-fe0f","👷🏽♀":"1f477-1f3fd-200d-2640-fe0f","👷🏾♀":"1f477-1f3fe-200d-2640-fe0f","👷🏿♀":"1f477-1f3ff-200d-2640-fe0f","👳♂️":"1f473-200d-2642-fe0f","👳🏻♂":"1f473-1f3fb-200d-2642-fe0f","👳🏼♂":"1f473-1f3fc-200d-2642-fe0f","👳🏽♂":"1f473-1f3fd-200d-2642-fe0f","👳🏾♂":"1f473-1f3fe-200d-2642-fe0f","👳🏿♂":"1f473-1f3ff-200d-2642-fe0f","👳♀️":"1f473-200d-2640-fe0f","👳🏻♀":"1f473-1f3fb-200d-2640-fe0f","👳🏼♀":"1f473-1f3fc-200d-2640-fe0f","👳🏽♀":"1f473-1f3fd-200d-2640-fe0f","👳🏾♀":"1f473-1f3fe-200d-2640-fe0f","👳🏿♀":"1f473-1f3ff-200d-2640-fe0f","🤵♂️":"1f935-200d-2642-fe0f","🤵🏻♂":"1f935-1f3fb-200d-2642-fe0f","🤵🏼♂":"1f935-1f3fc-200d-2642-fe0f","🤵🏽♂":"1f935-1f3fd-200d-2642-fe0f","🤵🏾♂":"1f935-1f3fe-200d-2642-fe0f","🤵🏿♂":"1f935-1f3ff-200d-2642-fe0f","🤵♀️":"1f935-200d-2640-fe0f","🤵🏻♀":"1f935-1f3fb-200d-2640-fe0f","🤵🏼♀":"1f935-1f3fc-200d-2640-fe0f","🤵🏽♀":"1f935-1f3fd-200d-2640-fe0f","🤵🏾♀":"1f935-1f3fe-200d-2640-fe0f","🤵🏿♀":"1f935-1f3ff-200d-2640-fe0f","👰♂️":"1f470-200d-2642-fe0f","👰🏻♂":"1f470-1f3fb-200d-2642-fe0f","👰🏼♂":"1f470-1f3fc-200d-2642-fe0f","👰🏽♂":"1f470-1f3fd-200d-2642-fe0f","👰🏾♂":"1f470-1f3fe-200d-2642-fe0f","👰🏿♂":"1f470-1f3ff-200d-2642-fe0f","👰♀️":"1f470-200d-2640-fe0f","👰🏻♀":"1f470-1f3fb-200d-2640-fe0f","👰🏼♀":"1f470-1f3fc-200d-2640-fe0f","👰🏽♀":"1f470-1f3fd-200d-2640-fe0f","👰🏾♀":"1f470-1f3fe-200d-2640-fe0f","👰🏿♀":"1f470-1f3ff-200d-2640-fe0f","👩🏻🍼":"1f469-1f3fb-200d-1f37c","👩🏼🍼":"1f469-1f3fc-200d-1f37c","👩🏽🍼":"1f469-1f3fd-200d-1f37c","👩🏾🍼":"1f469-1f3fe-200d-1f37c","👩🏿🍼":"1f469-1f3ff-200d-1f37c","👨🏻🍼":"1f468-1f3fb-200d-1f37c","👨🏼🍼":"1f468-1f3fc-200d-1f37c","👨🏽🍼":"1f468-1f3fd-200d-1f37c","👨🏾🍼":"1f468-1f3fe-200d-1f37c","👨🏿🍼":"1f468-1f3ff-200d-1f37c","🧑🏻🍼":"1f9d1-1f3fb-200d-1f37c","🧑🏼🍼":"1f9d1-1f3fc-200d-1f37c","🧑🏽🍼":"1f9d1-1f3fd-200d-1f37c","🧑🏾🍼":"1f9d1-1f3fe-200d-1f37c","🧑🏿🍼":"1f9d1-1f3ff-200d-1f37c","🧑🏻🎄":"1f9d1-1f3fb-200d-1f384","🧑🏼🎄":"1f9d1-1f3fc-200d-1f384","🧑🏽🎄":"1f9d1-1f3fd-200d-1f384","🧑🏾🎄":"1f9d1-1f3fe-200d-1f384","🧑🏿🎄":"1f9d1-1f3ff-200d-1f384","🦸♂️":"1f9b8-200d-2642-fe0f","🦸🏻♂":"1f9b8-1f3fb-200d-2642-fe0f","🦸🏼♂":"1f9b8-1f3fc-200d-2642-fe0f","🦸🏽♂":"1f9b8-1f3fd-200d-2642-fe0f","🦸🏾♂":"1f9b8-1f3fe-200d-2642-fe0f","🦸🏿♂":"1f9b8-1f3ff-200d-2642-fe0f","🦸♀️":"1f9b8-200d-2640-fe0f","🦸🏻♀":"1f9b8-1f3fb-200d-2640-fe0f","🦸🏼♀":"1f9b8-1f3fc-200d-2640-fe0f","🦸🏽♀":"1f9b8-1f3fd-200d-2640-fe0f","🦸🏾♀":"1f9b8-1f3fe-200d-2640-fe0f","🦸🏿♀":"1f9b8-1f3ff-200d-2640-fe0f","🦹♂️":"1f9b9-200d-2642-fe0f","🦹🏻♂":"1f9b9-1f3fb-200d-2642-fe0f","🦹🏼♂":"1f9b9-1f3fc-200d-2642-fe0f","🦹🏽♂":"1f9b9-1f3fd-200d-2642-fe0f","🦹🏾♂":"1f9b9-1f3fe-200d-2642-fe0f","🦹🏿♂":"1f9b9-1f3ff-200d-2642-fe0f","🦹♀️":"1f9b9-200d-2640-fe0f","🦹🏻♀":"1f9b9-1f3fb-200d-2640-fe0f","🦹🏼♀":"1f9b9-1f3fc-200d-2640-fe0f","🦹🏽♀":"1f9b9-1f3fd-200d-2640-fe0f","🦹🏾♀":"1f9b9-1f3fe-200d-2640-fe0f","🦹🏿♀":"1f9b9-1f3ff-200d-2640-fe0f","🧙♂️":"1f9d9-200d-2642-fe0f","🧙🏻♂":"1f9d9-1f3fb-200d-2642-fe0f","🧙🏼♂":"1f9d9-1f3fc-200d-2642-fe0f","🧙🏽♂":"1f9d9-1f3fd-200d-2642-fe0f","🧙🏾♂":"1f9d9-1f3fe-200d-2642-fe0f","🧙🏿♂":"1f9d9-1f3ff-200d-2642-fe0f","🧙♀️":"1f9d9-200d-2640-fe0f","🧙🏻♀":"1f9d9-1f3fb-200d-2640-fe0f","🧙🏼♀":"1f9d9-1f3fc-200d-2640-fe0f","🧙🏽♀":"1f9d9-1f3fd-200d-2640-fe0f","🧙🏾♀":"1f9d9-1f3fe-200d-2640-fe0f","🧙🏿♀":"1f9d9-1f3ff-200d-2640-fe0f","🧚♂️":"1f9da-200d-2642-fe0f","🧚🏻♂":"1f9da-1f3fb-200d-2642-fe0f","🧚🏼♂":"1f9da-1f3fc-200d-2642-fe0f","🧚🏽♂":"1f9da-1f3fd-200d-2642-fe0f","🧚🏾♂":"1f9da-1f3fe-200d-2642-fe0f","🧚🏿♂":"1f9da-1f3ff-200d-2642-fe0f","🧚♀️":"1f9da-200d-2640-fe0f","🧚🏻♀":"1f9da-1f3fb-200d-2640-fe0f","🧚🏼♀":"1f9da-1f3fc-200d-2640-fe0f","🧚🏽♀":"1f9da-1f3fd-200d-2640-fe0f","🧚🏾♀":"1f9da-1f3fe-200d-2640-fe0f","🧚🏿♀":"1f9da-1f3ff-200d-2640-fe0f","🧛♂️":"1f9db-200d-2642-fe0f","🧛🏻♂":"1f9db-1f3fb-200d-2642-fe0f","🧛🏼♂":"1f9db-1f3fc-200d-2642-fe0f","🧛🏽♂":"1f9db-1f3fd-200d-2642-fe0f","🧛🏾♂":"1f9db-1f3fe-200d-2642-fe0f","🧛🏿♂":"1f9db-1f3ff-200d-2642-fe0f","🧛♀️":"1f9db-200d-2640-fe0f","🧛🏻♀":"1f9db-1f3fb-200d-2640-fe0f","🧛🏼♀":"1f9db-1f3fc-200d-2640-fe0f","🧛🏽♀":"1f9db-1f3fd-200d-2640-fe0f","🧛🏾♀":"1f9db-1f3fe-200d-2640-fe0f","🧛🏿♀":"1f9db-1f3ff-200d-2640-fe0f","🧜♂️":"1f9dc-200d-2642-fe0f","🧜🏻♂":"1f9dc-1f3fb-200d-2642-fe0f","🧜🏼♂":"1f9dc-1f3fc-200d-2642-fe0f","🧜🏽♂":"1f9dc-1f3fd-200d-2642-fe0f","🧜🏾♂":"1f9dc-1f3fe-200d-2642-fe0f","🧜🏿♂":"1f9dc-1f3ff-200d-2642-fe0f","🧜♀️":"1f9dc-200d-2640-fe0f","🧜🏻♀":"1f9dc-1f3fb-200d-2640-fe0f","🧜🏼♀":"1f9dc-1f3fc-200d-2640-fe0f","🧜🏽♀":"1f9dc-1f3fd-200d-2640-fe0f","🧜🏾♀":"1f9dc-1f3fe-200d-2640-fe0f","🧜🏿♀":"1f9dc-1f3ff-200d-2640-fe0f","🧝♂️":"1f9dd-200d-2642-fe0f","🧝🏻♂":"1f9dd-1f3fb-200d-2642-fe0f","🧝🏼♂":"1f9dd-1f3fc-200d-2642-fe0f","🧝🏽♂":"1f9dd-1f3fd-200d-2642-fe0f","🧝🏾♂":"1f9dd-1f3fe-200d-2642-fe0f","🧝🏿♂":"1f9dd-1f3ff-200d-2642-fe0f","🧝♀️":"1f9dd-200d-2640-fe0f","🧝🏻♀":"1f9dd-1f3fb-200d-2640-fe0f","🧝🏼♀":"1f9dd-1f3fc-200d-2640-fe0f","🧝🏽♀":"1f9dd-1f3fd-200d-2640-fe0f","🧝🏾♀":"1f9dd-1f3fe-200d-2640-fe0f","🧝🏿♀":"1f9dd-1f3ff-200d-2640-fe0f","🧞♂️":"1f9de-200d-2642-fe0f","🧞♀️":"1f9de-200d-2640-fe0f","🧟♂️":"1f9df-200d-2642-fe0f","🧟♀️":"1f9df-200d-2640-fe0f","💆♂️":"1f486-200d-2642-fe0f","💆🏻♂":"1f486-1f3fb-200d-2642-fe0f","💆🏼♂":"1f486-1f3fc-200d-2642-fe0f","💆🏽♂":"1f486-1f3fd-200d-2642-fe0f","💆🏾♂":"1f486-1f3fe-200d-2642-fe0f","💆🏿♂":"1f486-1f3ff-200d-2642-fe0f","💆♀️":"1f486-200d-2640-fe0f","💆🏻♀":"1f486-1f3fb-200d-2640-fe0f","💆🏼♀":"1f486-1f3fc-200d-2640-fe0f","💆🏽♀":"1f486-1f3fd-200d-2640-fe0f","💆🏾♀":"1f486-1f3fe-200d-2640-fe0f","💆🏿♀":"1f486-1f3ff-200d-2640-fe0f","💇♂️":"1f487-200d-2642-fe0f","💇🏻♂":"1f487-1f3fb-200d-2642-fe0f","💇🏼♂":"1f487-1f3fc-200d-2642-fe0f","💇🏽♂":"1f487-1f3fd-200d-2642-fe0f","💇🏾♂":"1f487-1f3fe-200d-2642-fe0f","💇🏿♂":"1f487-1f3ff-200d-2642-fe0f","💇♀️":"1f487-200d-2640-fe0f","💇🏻♀":"1f487-1f3fb-200d-2640-fe0f","💇🏼♀":"1f487-1f3fc-200d-2640-fe0f","💇🏽♀":"1f487-1f3fd-200d-2640-fe0f","💇🏾♀":"1f487-1f3fe-200d-2640-fe0f","💇🏿♀":"1f487-1f3ff-200d-2640-fe0f","🚶♂️":"1f6b6-200d-2642-fe0f","🚶🏻♂":"1f6b6-1f3fb-200d-2642-fe0f","🚶🏼♂":"1f6b6-1f3fc-200d-2642-fe0f","🚶🏽♂":"1f6b6-1f3fd-200d-2642-fe0f","🚶🏾♂":"1f6b6-1f3fe-200d-2642-fe0f","🚶🏿♂":"1f6b6-1f3ff-200d-2642-fe0f","🚶♀️":"1f6b6-200d-2640-fe0f","🚶🏻♀":"1f6b6-1f3fb-200d-2640-fe0f","🚶🏼♀":"1f6b6-1f3fc-200d-2640-fe0f","🚶🏽♀":"1f6b6-1f3fd-200d-2640-fe0f","🚶🏾♀":"1f6b6-1f3fe-200d-2640-fe0f","🚶🏿♀":"1f6b6-1f3ff-200d-2640-fe0f","🧍♂️":"1f9cd-200d-2642-fe0f","🧍🏻♂":"1f9cd-1f3fb-200d-2642-fe0f","🧍🏼♂":"1f9cd-1f3fc-200d-2642-fe0f","🧍🏽♂":"1f9cd-1f3fd-200d-2642-fe0f","🧍🏾♂":"1f9cd-1f3fe-200d-2642-fe0f","🧍🏿♂":"1f9cd-1f3ff-200d-2642-fe0f","🧍♀️":"1f9cd-200d-2640-fe0f","🧍🏻♀":"1f9cd-1f3fb-200d-2640-fe0f","🧍🏼♀":"1f9cd-1f3fc-200d-2640-fe0f","🧍🏽♀":"1f9cd-1f3fd-200d-2640-fe0f","🧍🏾♀":"1f9cd-1f3fe-200d-2640-fe0f","🧍🏿♀":"1f9cd-1f3ff-200d-2640-fe0f","🧎♂️":"1f9ce-200d-2642-fe0f","🧎🏻♂":"1f9ce-1f3fb-200d-2642-fe0f","🧎🏼♂":"1f9ce-1f3fc-200d-2642-fe0f","🧎🏽♂":"1f9ce-1f3fd-200d-2642-fe0f","🧎🏾♂":"1f9ce-1f3fe-200d-2642-fe0f","🧎🏿♂":"1f9ce-1f3ff-200d-2642-fe0f","🧎♀️":"1f9ce-200d-2640-fe0f","🧎🏻♀":"1f9ce-1f3fb-200d-2640-fe0f","🧎🏼♀":"1f9ce-1f3fc-200d-2640-fe0f","🧎🏽♀":"1f9ce-1f3fd-200d-2640-fe0f","🧎🏾♀":"1f9ce-1f3fe-200d-2640-fe0f","🧎🏿♀":"1f9ce-1f3ff-200d-2640-fe0f","🧑🏻🦯":"1f9d1-1f3fb-200d-1f9af","🧑🏼🦯":"1f9d1-1f3fc-200d-1f9af","🧑🏽🦯":"1f9d1-1f3fd-200d-1f9af","🧑🏾🦯":"1f9d1-1f3fe-200d-1f9af","🧑🏿🦯":"1f9d1-1f3ff-200d-1f9af","👨🏻🦯":"1f468-1f3fb-200d-1f9af","👨🏼🦯":"1f468-1f3fc-200d-1f9af","👨🏽🦯":"1f468-1f3fd-200d-1f9af","👨🏾🦯":"1f468-1f3fe-200d-1f9af","👨🏿🦯":"1f468-1f3ff-200d-1f9af","👩🏻🦯":"1f469-1f3fb-200d-1f9af","👩🏼🦯":"1f469-1f3fc-200d-1f9af","👩🏽🦯":"1f469-1f3fd-200d-1f9af","👩🏾🦯":"1f469-1f3fe-200d-1f9af","👩🏿🦯":"1f469-1f3ff-200d-1f9af","🧑🏻🦼":"1f9d1-1f3fb-200d-1f9bc","🧑🏼🦼":"1f9d1-1f3fc-200d-1f9bc","🧑🏽🦼":"1f9d1-1f3fd-200d-1f9bc","🧑🏾🦼":"1f9d1-1f3fe-200d-1f9bc","🧑🏿🦼":"1f9d1-1f3ff-200d-1f9bc","👨🏻🦼":"1f468-1f3fb-200d-1f9bc","👨🏼🦼":"1f468-1f3fc-200d-1f9bc","👨🏽🦼":"1f468-1f3fd-200d-1f9bc","👨🏾🦼":"1f468-1f3fe-200d-1f9bc","👨🏿🦼":"1f468-1f3ff-200d-1f9bc","👩🏻🦼":"1f469-1f3fb-200d-1f9bc","👩🏼🦼":"1f469-1f3fc-200d-1f9bc","👩🏽🦼":"1f469-1f3fd-200d-1f9bc","👩🏾🦼":"1f469-1f3fe-200d-1f9bc","👩🏿🦼":"1f469-1f3ff-200d-1f9bc","🧑🏻🦽":"1f9d1-1f3fb-200d-1f9bd","🧑🏼🦽":"1f9d1-1f3fc-200d-1f9bd","🧑🏽🦽":"1f9d1-1f3fd-200d-1f9bd","🧑🏾🦽":"1f9d1-1f3fe-200d-1f9bd","🧑🏿🦽":"1f9d1-1f3ff-200d-1f9bd","👨🏻🦽":"1f468-1f3fb-200d-1f9bd","👨🏼🦽":"1f468-1f3fc-200d-1f9bd","👨🏽🦽":"1f468-1f3fd-200d-1f9bd","👨🏾🦽":"1f468-1f3fe-200d-1f9bd","👨🏿🦽":"1f468-1f3ff-200d-1f9bd","👩🏻🦽":"1f469-1f3fb-200d-1f9bd","👩🏼🦽":"1f469-1f3fc-200d-1f9bd","👩🏽🦽":"1f469-1f3fd-200d-1f9bd","👩🏾🦽":"1f469-1f3fe-200d-1f9bd","👩🏿🦽":"1f469-1f3ff-200d-1f9bd","🏃♂️":"1f3c3-200d-2642-fe0f","🏃🏻♂":"1f3c3-1f3fb-200d-2642-fe0f","🏃🏼♂":"1f3c3-1f3fc-200d-2642-fe0f","🏃🏽♂":"1f3c3-1f3fd-200d-2642-fe0f","🏃🏾♂":"1f3c3-1f3fe-200d-2642-fe0f","🏃🏿♂":"1f3c3-1f3ff-200d-2642-fe0f","🏃♀️":"1f3c3-200d-2640-fe0f","🏃🏻♀":"1f3c3-1f3fb-200d-2640-fe0f","🏃🏼♀":"1f3c3-1f3fc-200d-2640-fe0f","🏃🏽♀":"1f3c3-1f3fd-200d-2640-fe0f","🏃🏾♀":"1f3c3-1f3fe-200d-2640-fe0f","🏃🏿♀":"1f3c3-1f3ff-200d-2640-fe0f","👯♂️":"1f46f-200d-2642-fe0f","👯♀️":"1f46f-200d-2640-fe0f","🧖♂️":"1f9d6-200d-2642-fe0f","🧖🏻♂":"1f9d6-1f3fb-200d-2642-fe0f","🧖🏼♂":"1f9d6-1f3fc-200d-2642-fe0f","🧖🏽♂":"1f9d6-1f3fd-200d-2642-fe0f","🧖🏾♂":"1f9d6-1f3fe-200d-2642-fe0f","🧖🏿♂":"1f9d6-1f3ff-200d-2642-fe0f","🧖♀️":"1f9d6-200d-2640-fe0f","🧖🏻♀":"1f9d6-1f3fb-200d-2640-fe0f","🧖🏼♀":"1f9d6-1f3fc-200d-2640-fe0f","🧖🏽♀":"1f9d6-1f3fd-200d-2640-fe0f","🧖🏾♀":"1f9d6-1f3fe-200d-2640-fe0f","🧖🏿♀":"1f9d6-1f3ff-200d-2640-fe0f","🧗♂️":"1f9d7-200d-2642-fe0f","🧗🏻♂":"1f9d7-1f3fb-200d-2642-fe0f","🧗🏼♂":"1f9d7-1f3fc-200d-2642-fe0f","🧗🏽♂":"1f9d7-1f3fd-200d-2642-fe0f","🧗🏾♂":"1f9d7-1f3fe-200d-2642-fe0f","🧗🏿♂":"1f9d7-1f3ff-200d-2642-fe0f","🧗♀️":"1f9d7-200d-2640-fe0f","🧗🏻♀":"1f9d7-1f3fb-200d-2640-fe0f","🧗🏼♀":"1f9d7-1f3fc-200d-2640-fe0f","🧗🏽♀":"1f9d7-1f3fd-200d-2640-fe0f","🧗🏾♀":"1f9d7-1f3fe-200d-2640-fe0f","🧗🏿♀":"1f9d7-1f3ff-200d-2640-fe0f","🏌♂️":"1f3cc-fe0f-200d-2642-fe0f","🏌️♂":"1f3cc-fe0f-200d-2642-fe0f","🏌🏻♂":"1f3cc-1f3fb-200d-2642-fe0f","🏌🏼♂":"1f3cc-1f3fc-200d-2642-fe0f","🏌🏽♂":"1f3cc-1f3fd-200d-2642-fe0f","🏌🏾♂":"1f3cc-1f3fe-200d-2642-fe0f","🏌🏿♂":"1f3cc-1f3ff-200d-2642-fe0f","🏌♀️":"1f3cc-fe0f-200d-2640-fe0f","🏌️♀":"1f3cc-fe0f-200d-2640-fe0f","🏌🏻♀":"1f3cc-1f3fb-200d-2640-fe0f","🏌🏼♀":"1f3cc-1f3fc-200d-2640-fe0f","🏌🏽♀":"1f3cc-1f3fd-200d-2640-fe0f","🏌🏾♀":"1f3cc-1f3fe-200d-2640-fe0f","🏌🏿♀":"1f3cc-1f3ff-200d-2640-fe0f","🏄♂️":"1f3c4-200d-2642-fe0f","🏄🏻♂":"1f3c4-1f3fb-200d-2642-fe0f","🏄🏼♂":"1f3c4-1f3fc-200d-2642-fe0f","🏄🏽♂":"1f3c4-1f3fd-200d-2642-fe0f","🏄🏾♂":"1f3c4-1f3fe-200d-2642-fe0f","🏄🏿♂":"1f3c4-1f3ff-200d-2642-fe0f","🏄♀️":"1f3c4-200d-2640-fe0f","🏄🏻♀":"1f3c4-1f3fb-200d-2640-fe0f","🏄🏼♀":"1f3c4-1f3fc-200d-2640-fe0f","🏄🏽♀":"1f3c4-1f3fd-200d-2640-fe0f","🏄🏾♀":"1f3c4-1f3fe-200d-2640-fe0f","🏄🏿♀":"1f3c4-1f3ff-200d-2640-fe0f","🚣♂️":"1f6a3-200d-2642-fe0f","🚣🏻♂":"1f6a3-1f3fb-200d-2642-fe0f","🚣🏼♂":"1f6a3-1f3fc-200d-2642-fe0f","🚣🏽♂":"1f6a3-1f3fd-200d-2642-fe0f","🚣🏾♂":"1f6a3-1f3fe-200d-2642-fe0f","🚣🏿♂":"1f6a3-1f3ff-200d-2642-fe0f","🚣♀️":"1f6a3-200d-2640-fe0f","🚣🏻♀":"1f6a3-1f3fb-200d-2640-fe0f","🚣🏼♀":"1f6a3-1f3fc-200d-2640-fe0f","🚣🏽♀":"1f6a3-1f3fd-200d-2640-fe0f","🚣🏾♀":"1f6a3-1f3fe-200d-2640-fe0f","🚣🏿♀":"1f6a3-1f3ff-200d-2640-fe0f","🏊♂️":"1f3ca-200d-2642-fe0f","🏊🏻♂":"1f3ca-1f3fb-200d-2642-fe0f","🏊🏼♂":"1f3ca-1f3fc-200d-2642-fe0f","🏊🏽♂":"1f3ca-1f3fd-200d-2642-fe0f","🏊🏾♂":"1f3ca-1f3fe-200d-2642-fe0f","🏊🏿♂":"1f3ca-1f3ff-200d-2642-fe0f","🏊♀️":"1f3ca-200d-2640-fe0f","🏊🏻♀":"1f3ca-1f3fb-200d-2640-fe0f","🏊🏼♀":"1f3ca-1f3fc-200d-2640-fe0f","🏊🏽♀":"1f3ca-1f3fd-200d-2640-fe0f","🏊🏾♀":"1f3ca-1f3fe-200d-2640-fe0f","🏊🏿♀":"1f3ca-1f3ff-200d-2640-fe0f","⛹♂️":"26f9-fe0f-200d-2642-fe0f","⛹️♂":"26f9-fe0f-200d-2642-fe0f","⛹🏻♂":"26f9-1f3fb-200d-2642-fe0f","⛹🏼♂":"26f9-1f3fc-200d-2642-fe0f","⛹🏽♂":"26f9-1f3fd-200d-2642-fe0f","⛹🏾♂":"26f9-1f3fe-200d-2642-fe0f","⛹🏿♂":"26f9-1f3ff-200d-2642-fe0f","⛹♀️":"26f9-fe0f-200d-2640-fe0f","⛹️♀":"26f9-fe0f-200d-2640-fe0f","⛹🏻♀":"26f9-1f3fb-200d-2640-fe0f","⛹🏼♀":"26f9-1f3fc-200d-2640-fe0f","⛹🏽♀":"26f9-1f3fd-200d-2640-fe0f","⛹🏾♀":"26f9-1f3fe-200d-2640-fe0f","⛹🏿♀":"26f9-1f3ff-200d-2640-fe0f","🏋♂️":"1f3cb-fe0f-200d-2642-fe0f","🏋️♂":"1f3cb-fe0f-200d-2642-fe0f","🏋🏻♂":"1f3cb-1f3fb-200d-2642-fe0f","🏋🏼♂":"1f3cb-1f3fc-200d-2642-fe0f","🏋🏽♂":"1f3cb-1f3fd-200d-2642-fe0f","🏋🏾♂":"1f3cb-1f3fe-200d-2642-fe0f","🏋🏿♂":"1f3cb-1f3ff-200d-2642-fe0f","🏋♀️":"1f3cb-fe0f-200d-2640-fe0f","🏋️♀":"1f3cb-fe0f-200d-2640-fe0f","🏋🏻♀":"1f3cb-1f3fb-200d-2640-fe0f","🏋🏼♀":"1f3cb-1f3fc-200d-2640-fe0f","🏋🏽♀":"1f3cb-1f3fd-200d-2640-fe0f","🏋🏾♀":"1f3cb-1f3fe-200d-2640-fe0f","🏋🏿♀":"1f3cb-1f3ff-200d-2640-fe0f","🚴♂️":"1f6b4-200d-2642-fe0f","🚴🏻♂":"1f6b4-1f3fb-200d-2642-fe0f","🚴🏼♂":"1f6b4-1f3fc-200d-2642-fe0f","🚴🏽♂":"1f6b4-1f3fd-200d-2642-fe0f","🚴🏾♂":"1f6b4-1f3fe-200d-2642-fe0f","🚴🏿♂":"1f6b4-1f3ff-200d-2642-fe0f","🚴♀️":"1f6b4-200d-2640-fe0f","🚴🏻♀":"1f6b4-1f3fb-200d-2640-fe0f","🚴🏼♀":"1f6b4-1f3fc-200d-2640-fe0f","🚴🏽♀":"1f6b4-1f3fd-200d-2640-fe0f","🚴🏾♀":"1f6b4-1f3fe-200d-2640-fe0f","🚴🏿♀":"1f6b4-1f3ff-200d-2640-fe0f","🚵♂️":"1f6b5-200d-2642-fe0f","🚵🏻♂":"1f6b5-1f3fb-200d-2642-fe0f","🚵🏼♂":"1f6b5-1f3fc-200d-2642-fe0f","🚵🏽♂":"1f6b5-1f3fd-200d-2642-fe0f","🚵🏾♂":"1f6b5-1f3fe-200d-2642-fe0f","🚵🏿♂":"1f6b5-1f3ff-200d-2642-fe0f","🚵♀️":"1f6b5-200d-2640-fe0f","🚵🏻♀":"1f6b5-1f3fb-200d-2640-fe0f","🚵🏼♀":"1f6b5-1f3fc-200d-2640-fe0f","🚵🏽♀":"1f6b5-1f3fd-200d-2640-fe0f","🚵🏾♀":"1f6b5-1f3fe-200d-2640-fe0f","🚵🏿♀":"1f6b5-1f3ff-200d-2640-fe0f","🤸♂️":"1f938-200d-2642-fe0f","🤸🏻♂":"1f938-1f3fb-200d-2642-fe0f","🤸🏼♂":"1f938-1f3fc-200d-2642-fe0f","🤸🏽♂":"1f938-1f3fd-200d-2642-fe0f","🤸🏾♂":"1f938-1f3fe-200d-2642-fe0f","🤸🏿♂":"1f938-1f3ff-200d-2642-fe0f","🤸♀️":"1f938-200d-2640-fe0f","🤸🏻♀":"1f938-1f3fb-200d-2640-fe0f","🤸🏼♀":"1f938-1f3fc-200d-2640-fe0f","🤸🏽♀":"1f938-1f3fd-200d-2640-fe0f","🤸🏾♀":"1f938-1f3fe-200d-2640-fe0f","🤸🏿♀":"1f938-1f3ff-200d-2640-fe0f","🤼♂️":"1f93c-200d-2642-fe0f","🤼♀️":"1f93c-200d-2640-fe0f","🤽♂️":"1f93d-200d-2642-fe0f","🤽🏻♂":"1f93d-1f3fb-200d-2642-fe0f","🤽🏼♂":"1f93d-1f3fc-200d-2642-fe0f","🤽🏽♂":"1f93d-1f3fd-200d-2642-fe0f","🤽🏾♂":"1f93d-1f3fe-200d-2642-fe0f","🤽🏿♂":"1f93d-1f3ff-200d-2642-fe0f","🤽♀️":"1f93d-200d-2640-fe0f","🤽🏻♀":"1f93d-1f3fb-200d-2640-fe0f","🤽🏼♀":"1f93d-1f3fc-200d-2640-fe0f","🤽🏽♀":"1f93d-1f3fd-200d-2640-fe0f","🤽🏾♀":"1f93d-1f3fe-200d-2640-fe0f","🤽🏿♀":"1f93d-1f3ff-200d-2640-fe0f","🤾♂️":"1f93e-200d-2642-fe0f","🤾🏻♂":"1f93e-1f3fb-200d-2642-fe0f","🤾🏼♂":"1f93e-1f3fc-200d-2642-fe0f","🤾🏽♂":"1f93e-1f3fd-200d-2642-fe0f","🤾🏾♂":"1f93e-1f3fe-200d-2642-fe0f","🤾🏿♂":"1f93e-1f3ff-200d-2642-fe0f","🤾♀️":"1f93e-200d-2640-fe0f","🤾🏻♀":"1f93e-1f3fb-200d-2640-fe0f","🤾🏼♀":"1f93e-1f3fc-200d-2640-fe0f","🤾🏽♀":"1f93e-1f3fd-200d-2640-fe0f","🤾🏾♀":"1f93e-1f3fe-200d-2640-fe0f","🤾🏿♀":"1f93e-1f3ff-200d-2640-fe0f","🤹♂️":"1f939-200d-2642-fe0f","🤹🏻♂":"1f939-1f3fb-200d-2642-fe0f","🤹🏼♂":"1f939-1f3fc-200d-2642-fe0f","🤹🏽♂":"1f939-1f3fd-200d-2642-fe0f","🤹🏾♂":"1f939-1f3fe-200d-2642-fe0f","🤹🏿♂":"1f939-1f3ff-200d-2642-fe0f","🤹♀️":"1f939-200d-2640-fe0f","🤹🏻♀":"1f939-1f3fb-200d-2640-fe0f","🤹🏼♀":"1f939-1f3fc-200d-2640-fe0f","🤹🏽♀":"1f939-1f3fd-200d-2640-fe0f","🤹🏾♀":"1f939-1f3fe-200d-2640-fe0f","🤹🏿♀":"1f939-1f3ff-200d-2640-fe0f","🧘♂️":"1f9d8-200d-2642-fe0f","🧘🏻♂":"1f9d8-1f3fb-200d-2642-fe0f","🧘🏼♂":"1f9d8-1f3fc-200d-2642-fe0f","🧘🏽♂":"1f9d8-1f3fd-200d-2642-fe0f","🧘🏾♂":"1f9d8-1f3fe-200d-2642-fe0f","🧘🏿♂":"1f9d8-1f3ff-200d-2642-fe0f","🧘♀️":"1f9d8-200d-2640-fe0f","🧘🏻♀":"1f9d8-1f3fb-200d-2640-fe0f","🧘🏼♀":"1f9d8-1f3fc-200d-2640-fe0f","🧘🏽♀":"1f9d8-1f3fd-200d-2640-fe0f","🧘🏾♀":"1f9d8-1f3fe-200d-2640-fe0f","🧘🏿♀":"1f9d8-1f3ff-200d-2640-fe0f","🐻❄️":"1f43b-200d-2744-fe0f","🏳️🌈":"1f3f3-fe0f-200d-1f308","🏳⚧️":"1f3f3-fe0f-200d-26a7-fe0f","🏳️⚧":"1f3f3-fe0f-200d-26a7-fe0f","🏴☠️":"1f3f4-200d-2620-fe0f","👁️🗨️":"1f441-200d-1f5e8","🫱🏻🫲🏼":"1faf1-1f3fb-200d-1faf2-1f3fc","🫱🏻🫲🏽":"1faf1-1f3fb-200d-1faf2-1f3fd","🫱🏻🫲🏾":"1faf1-1f3fb-200d-1faf2-1f3fe","🫱🏻🫲🏿":"1faf1-1f3fb-200d-1faf2-1f3ff","🫱🏼🫲🏻":"1faf1-1f3fc-200d-1faf2-1f3fb","🫱🏼🫲🏽":"1faf1-1f3fc-200d-1faf2-1f3fd","🫱🏼🫲🏾":"1faf1-1f3fc-200d-1faf2-1f3fe","🫱🏼🫲🏿":"1faf1-1f3fc-200d-1faf2-1f3ff","🫱🏽🫲🏻":"1faf1-1f3fd-200d-1faf2-1f3fb","🫱🏽🫲🏼":"1faf1-1f3fd-200d-1faf2-1f3fc","🫱🏽🫲🏾":"1faf1-1f3fd-200d-1faf2-1f3fe","🫱🏽🫲🏿":"1faf1-1f3fd-200d-1faf2-1f3ff","🫱🏾🫲🏻":"1faf1-1f3fe-200d-1faf2-1f3fb","🫱🏾🫲🏼":"1faf1-1f3fe-200d-1faf2-1f3fc","🫱🏾🫲🏽":"1faf1-1f3fe-200d-1faf2-1f3fd","🫱🏾🫲🏿":"1faf1-1f3fe-200d-1faf2-1f3ff","🫱🏿🫲🏻":"1faf1-1f3ff-200d-1faf2-1f3fb","🫱🏿🫲🏼":"1faf1-1f3ff-200d-1faf2-1f3fc","🫱🏿🫲🏽":"1faf1-1f3ff-200d-1faf2-1f3fd","🫱🏿🫲🏾":"1faf1-1f3ff-200d-1faf2-1f3fe","🧔🏻♂️":"1f9d4-1f3fb-200d-2642-fe0f","🧔🏼♂️":"1f9d4-1f3fc-200d-2642-fe0f","🧔🏽♂️":"1f9d4-1f3fd-200d-2642-fe0f","🧔🏾♂️":"1f9d4-1f3fe-200d-2642-fe0f","🧔🏿♂️":"1f9d4-1f3ff-200d-2642-fe0f","🧔🏻♀️":"1f9d4-1f3fb-200d-2640-fe0f","🧔🏼♀️":"1f9d4-1f3fc-200d-2640-fe0f","🧔🏽♀️":"1f9d4-1f3fd-200d-2640-fe0f","🧔🏾♀️":"1f9d4-1f3fe-200d-2640-fe0f","🧔🏿♀️":"1f9d4-1f3ff-200d-2640-fe0f","👱🏻♀️":"1f471-1f3fb-200d-2640-fe0f","👱🏼♀️":"1f471-1f3fc-200d-2640-fe0f","👱🏽♀️":"1f471-1f3fd-200d-2640-fe0f","👱🏾♀️":"1f471-1f3fe-200d-2640-fe0f","👱🏿♀️":"1f471-1f3ff-200d-2640-fe0f","👱🏻♂️":"1f471-1f3fb-200d-2642-fe0f","👱🏼♂️":"1f471-1f3fc-200d-2642-fe0f","👱🏽♂️":"1f471-1f3fd-200d-2642-fe0f","👱🏾♂️":"1f471-1f3fe-200d-2642-fe0f","👱🏿♂️":"1f471-1f3ff-200d-2642-fe0f","🙍🏻♂️":"1f64d-1f3fb-200d-2642-fe0f","🙍🏼♂️":"1f64d-1f3fc-200d-2642-fe0f","🙍🏽♂️":"1f64d-1f3fd-200d-2642-fe0f","🙍🏾♂️":"1f64d-1f3fe-200d-2642-fe0f","🙍🏿♂️":"1f64d-1f3ff-200d-2642-fe0f","🙍🏻♀️":"1f64d-1f3fb-200d-2640-fe0f","🙍🏼♀️":"1f64d-1f3fc-200d-2640-fe0f","🙍🏽♀️":"1f64d-1f3fd-200d-2640-fe0f","🙍🏾♀️":"1f64d-1f3fe-200d-2640-fe0f","🙍🏿♀️":"1f64d-1f3ff-200d-2640-fe0f","🙎🏻♂️":"1f64e-1f3fb-200d-2642-fe0f","🙎🏼♂️":"1f64e-1f3fc-200d-2642-fe0f","🙎🏽♂️":"1f64e-1f3fd-200d-2642-fe0f","🙎🏾♂️":"1f64e-1f3fe-200d-2642-fe0f","🙎🏿♂️":"1f64e-1f3ff-200d-2642-fe0f","🙎🏻♀️":"1f64e-1f3fb-200d-2640-fe0f","🙎🏼♀️":"1f64e-1f3fc-200d-2640-fe0f","🙎🏽♀️":"1f64e-1f3fd-200d-2640-fe0f","🙎🏾♀️":"1f64e-1f3fe-200d-2640-fe0f","🙎🏿♀️":"1f64e-1f3ff-200d-2640-fe0f","🙅🏻♂️":"1f645-1f3fb-200d-2642-fe0f","🙅🏼♂️":"1f645-1f3fc-200d-2642-fe0f","🙅🏽♂️":"1f645-1f3fd-200d-2642-fe0f","🙅🏾♂️":"1f645-1f3fe-200d-2642-fe0f","🙅🏿♂️":"1f645-1f3ff-200d-2642-fe0f","🙅🏻♀️":"1f645-1f3fb-200d-2640-fe0f","🙅🏼♀️":"1f645-1f3fc-200d-2640-fe0f","🙅🏽♀️":"1f645-1f3fd-200d-2640-fe0f","🙅🏾♀️":"1f645-1f3fe-200d-2640-fe0f","🙅🏿♀️":"1f645-1f3ff-200d-2640-fe0f","🙆🏻♂️":"1f646-1f3fb-200d-2642-fe0f","🙆🏼♂️":"1f646-1f3fc-200d-2642-fe0f","🙆🏽♂️":"1f646-1f3fd-200d-2642-fe0f","🙆🏾♂️":"1f646-1f3fe-200d-2642-fe0f","🙆🏿♂️":"1f646-1f3ff-200d-2642-fe0f","🙆🏻♀️":"1f646-1f3fb-200d-2640-fe0f","🙆🏼♀️":"1f646-1f3fc-200d-2640-fe0f","🙆🏽♀️":"1f646-1f3fd-200d-2640-fe0f","🙆🏾♀️":"1f646-1f3fe-200d-2640-fe0f","🙆🏿♀️":"1f646-1f3ff-200d-2640-fe0f","💁🏻♂️":"1f481-1f3fb-200d-2642-fe0f","💁🏼♂️":"1f481-1f3fc-200d-2642-fe0f","💁🏽♂️":"1f481-1f3fd-200d-2642-fe0f","💁🏾♂️":"1f481-1f3fe-200d-2642-fe0f","💁🏿♂️":"1f481-1f3ff-200d-2642-fe0f","💁🏻♀️":"1f481-1f3fb-200d-2640-fe0f","💁🏼♀️":"1f481-1f3fc-200d-2640-fe0f","💁🏽♀️":"1f481-1f3fd-200d-2640-fe0f","💁🏾♀️":"1f481-1f3fe-200d-2640-fe0f","💁🏿♀️":"1f481-1f3ff-200d-2640-fe0f","🙋🏻♂️":"1f64b-1f3fb-200d-2642-fe0f","🙋🏼♂️":"1f64b-1f3fc-200d-2642-fe0f","🙋🏽♂️":"1f64b-1f3fd-200d-2642-fe0f","🙋🏾♂️":"1f64b-1f3fe-200d-2642-fe0f","🙋🏿♂️":"1f64b-1f3ff-200d-2642-fe0f","🙋🏻♀️":"1f64b-1f3fb-200d-2640-fe0f","🙋🏼♀️":"1f64b-1f3fc-200d-2640-fe0f","🙋🏽♀️":"1f64b-1f3fd-200d-2640-fe0f","🙋🏾♀️":"1f64b-1f3fe-200d-2640-fe0f","🙋🏿♀️":"1f64b-1f3ff-200d-2640-fe0f","🧏🏻♂️":"1f9cf-1f3fb-200d-2642-fe0f","🧏🏼♂️":"1f9cf-1f3fc-200d-2642-fe0f","🧏🏽♂️":"1f9cf-1f3fd-200d-2642-fe0f","🧏🏾♂️":"1f9cf-1f3fe-200d-2642-fe0f","🧏🏿♂️":"1f9cf-1f3ff-200d-2642-fe0f","🧏🏻♀️":"1f9cf-1f3fb-200d-2640-fe0f","🧏🏼♀️":"1f9cf-1f3fc-200d-2640-fe0f","🧏🏽♀️":"1f9cf-1f3fd-200d-2640-fe0f","🧏🏾♀️":"1f9cf-1f3fe-200d-2640-fe0f","🧏🏿♀️":"1f9cf-1f3ff-200d-2640-fe0f","🙇🏻♂️":"1f647-1f3fb-200d-2642-fe0f","🙇🏼♂️":"1f647-1f3fc-200d-2642-fe0f","🙇🏽♂️":"1f647-1f3fd-200d-2642-fe0f","🙇🏾♂️":"1f647-1f3fe-200d-2642-fe0f","🙇🏿♂️":"1f647-1f3ff-200d-2642-fe0f","🙇🏻♀️":"1f647-1f3fb-200d-2640-fe0f","🙇🏼♀️":"1f647-1f3fc-200d-2640-fe0f","🙇🏽♀️":"1f647-1f3fd-200d-2640-fe0f","🙇🏾♀️":"1f647-1f3fe-200d-2640-fe0f","🙇🏿♀️":"1f647-1f3ff-200d-2640-fe0f","🤦🏻♂️":"1f926-1f3fb-200d-2642-fe0f","🤦🏼♂️":"1f926-1f3fc-200d-2642-fe0f","🤦🏽♂️":"1f926-1f3fd-200d-2642-fe0f","🤦🏾♂️":"1f926-1f3fe-200d-2642-fe0f","🤦🏿♂️":"1f926-1f3ff-200d-2642-fe0f","🤦🏻♀️":"1f926-1f3fb-200d-2640-fe0f","🤦🏼♀️":"1f926-1f3fc-200d-2640-fe0f","🤦🏽♀️":"1f926-1f3fd-200d-2640-fe0f","🤦🏾♀️":"1f926-1f3fe-200d-2640-fe0f","🤦🏿♀️":"1f926-1f3ff-200d-2640-fe0f","🤷🏻♂️":"1f937-1f3fb-200d-2642-fe0f","🤷🏼♂️":"1f937-1f3fc-200d-2642-fe0f","🤷🏽♂️":"1f937-1f3fd-200d-2642-fe0f","🤷🏾♂️":"1f937-1f3fe-200d-2642-fe0f","🤷🏿♂️":"1f937-1f3ff-200d-2642-fe0f","🤷🏻♀️":"1f937-1f3fb-200d-2640-fe0f","🤷🏼♀️":"1f937-1f3fc-200d-2640-fe0f","🤷🏽♀️":"1f937-1f3fd-200d-2640-fe0f","🤷🏾♀️":"1f937-1f3fe-200d-2640-fe0f","🤷🏿♀️":"1f937-1f3ff-200d-2640-fe0f","🧑🏻⚕️":"1f9d1-1f3fb-200d-2695-fe0f","🧑🏼⚕️":"1f9d1-1f3fc-200d-2695-fe0f","🧑🏽⚕️":"1f9d1-1f3fd-200d-2695-fe0f","🧑🏾⚕️":"1f9d1-1f3fe-200d-2695-fe0f","🧑🏿⚕️":"1f9d1-1f3ff-200d-2695-fe0f","👨🏻⚕️":"1f468-1f3fb-200d-2695-fe0f","👨🏼⚕️":"1f468-1f3fc-200d-2695-fe0f","👨🏽⚕️":"1f468-1f3fd-200d-2695-fe0f","👨🏾⚕️":"1f468-1f3fe-200d-2695-fe0f","👨🏿⚕️":"1f468-1f3ff-200d-2695-fe0f","👩🏻⚕️":"1f469-1f3fb-200d-2695-fe0f","👩🏼⚕️":"1f469-1f3fc-200d-2695-fe0f","👩🏽⚕️":"1f469-1f3fd-200d-2695-fe0f","👩🏾⚕️":"1f469-1f3fe-200d-2695-fe0f","👩🏿⚕️":"1f469-1f3ff-200d-2695-fe0f","🧑🏻⚖️":"1f9d1-1f3fb-200d-2696-fe0f","🧑🏼⚖️":"1f9d1-1f3fc-200d-2696-fe0f","🧑🏽⚖️":"1f9d1-1f3fd-200d-2696-fe0f","🧑🏾⚖️":"1f9d1-1f3fe-200d-2696-fe0f","🧑🏿⚖️":"1f9d1-1f3ff-200d-2696-fe0f","👨🏻⚖️":"1f468-1f3fb-200d-2696-fe0f","👨🏼⚖️":"1f468-1f3fc-200d-2696-fe0f","👨🏽⚖️":"1f468-1f3fd-200d-2696-fe0f","👨🏾⚖️":"1f468-1f3fe-200d-2696-fe0f","👨🏿⚖️":"1f468-1f3ff-200d-2696-fe0f","👩🏻⚖️":"1f469-1f3fb-200d-2696-fe0f","👩🏼⚖️":"1f469-1f3fc-200d-2696-fe0f","👩🏽⚖️":"1f469-1f3fd-200d-2696-fe0f","👩🏾⚖️":"1f469-1f3fe-200d-2696-fe0f","👩🏿⚖️":"1f469-1f3ff-200d-2696-fe0f","🧑🏻✈️":"1f9d1-1f3fb-200d-2708-fe0f","🧑🏼✈️":"1f9d1-1f3fc-200d-2708-fe0f","🧑🏽✈️":"1f9d1-1f3fd-200d-2708-fe0f","🧑🏾✈️":"1f9d1-1f3fe-200d-2708-fe0f","🧑🏿✈️":"1f9d1-1f3ff-200d-2708-fe0f","👨🏻✈️":"1f468-1f3fb-200d-2708-fe0f","👨🏼✈️":"1f468-1f3fc-200d-2708-fe0f","👨🏽✈️":"1f468-1f3fd-200d-2708-fe0f","👨🏾✈️":"1f468-1f3fe-200d-2708-fe0f","👨🏿✈️":"1f468-1f3ff-200d-2708-fe0f","👩🏻✈️":"1f469-1f3fb-200d-2708-fe0f","👩🏼✈️":"1f469-1f3fc-200d-2708-fe0f","👩🏽✈️":"1f469-1f3fd-200d-2708-fe0f","👩🏾✈️":"1f469-1f3fe-200d-2708-fe0f","👩🏿✈️":"1f469-1f3ff-200d-2708-fe0f","👮🏻♂️":"1f46e-1f3fb-200d-2642-fe0f","👮🏼♂️":"1f46e-1f3fc-200d-2642-fe0f","👮🏽♂️":"1f46e-1f3fd-200d-2642-fe0f","👮🏾♂️":"1f46e-1f3fe-200d-2642-fe0f","👮🏿♂️":"1f46e-1f3ff-200d-2642-fe0f","👮🏻♀️":"1f46e-1f3fb-200d-2640-fe0f","👮🏼♀️":"1f46e-1f3fc-200d-2640-fe0f","👮🏽♀️":"1f46e-1f3fd-200d-2640-fe0f","👮🏾♀️":"1f46e-1f3fe-200d-2640-fe0f","👮🏿♀️":"1f46e-1f3ff-200d-2640-fe0f","🕵️♂️":"1f575-fe0f-200d-2642-fe0f","🕵🏻♂️":"1f575-1f3fb-200d-2642-fe0f","🕵🏼♂️":"1f575-1f3fc-200d-2642-fe0f","🕵🏽♂️":"1f575-1f3fd-200d-2642-fe0f","🕵🏾♂️":"1f575-1f3fe-200d-2642-fe0f","🕵🏿♂️":"1f575-1f3ff-200d-2642-fe0f","🕵️♀️":"1f575-fe0f-200d-2640-fe0f","🕵🏻♀️":"1f575-1f3fb-200d-2640-fe0f","🕵🏼♀️":"1f575-1f3fc-200d-2640-fe0f","🕵🏽♀️":"1f575-1f3fd-200d-2640-fe0f","🕵🏾♀️":"1f575-1f3fe-200d-2640-fe0f","🕵🏿♀️":"1f575-1f3ff-200d-2640-fe0f","💂🏻♂️":"1f482-1f3fb-200d-2642-fe0f","💂🏼♂️":"1f482-1f3fc-200d-2642-fe0f","💂🏽♂️":"1f482-1f3fd-200d-2642-fe0f","💂🏾♂️":"1f482-1f3fe-200d-2642-fe0f","💂🏿♂️":"1f482-1f3ff-200d-2642-fe0f","💂🏻♀️":"1f482-1f3fb-200d-2640-fe0f","💂🏼♀️":"1f482-1f3fc-200d-2640-fe0f","💂🏽♀️":"1f482-1f3fd-200d-2640-fe0f","💂🏾♀️":"1f482-1f3fe-200d-2640-fe0f","💂🏿♀️":"1f482-1f3ff-200d-2640-fe0f","👷🏻♂️":"1f477-1f3fb-200d-2642-fe0f","👷🏼♂️":"1f477-1f3fc-200d-2642-fe0f","👷🏽♂️":"1f477-1f3fd-200d-2642-fe0f","👷🏾♂️":"1f477-1f3fe-200d-2642-fe0f","👷🏿♂️":"1f477-1f3ff-200d-2642-fe0f","👷🏻♀️":"1f477-1f3fb-200d-2640-fe0f","👷🏼♀️":"1f477-1f3fc-200d-2640-fe0f","👷🏽♀️":"1f477-1f3fd-200d-2640-fe0f","👷🏾♀️":"1f477-1f3fe-200d-2640-fe0f","👷🏿♀️":"1f477-1f3ff-200d-2640-fe0f","👳🏻♂️":"1f473-1f3fb-200d-2642-fe0f","👳🏼♂️":"1f473-1f3fc-200d-2642-fe0f","👳🏽♂️":"1f473-1f3fd-200d-2642-fe0f","👳🏾♂️":"1f473-1f3fe-200d-2642-fe0f","👳🏿♂️":"1f473-1f3ff-200d-2642-fe0f","👳🏻♀️":"1f473-1f3fb-200d-2640-fe0f","👳🏼♀️":"1f473-1f3fc-200d-2640-fe0f","👳🏽♀️":"1f473-1f3fd-200d-2640-fe0f","👳🏾♀️":"1f473-1f3fe-200d-2640-fe0f","👳🏿♀️":"1f473-1f3ff-200d-2640-fe0f","🤵🏻♂️":"1f935-1f3fb-200d-2642-fe0f","🤵🏼♂️":"1f935-1f3fc-200d-2642-fe0f","🤵🏽♂️":"1f935-1f3fd-200d-2642-fe0f","🤵🏾♂️":"1f935-1f3fe-200d-2642-fe0f","🤵🏿♂️":"1f935-1f3ff-200d-2642-fe0f","🤵🏻♀️":"1f935-1f3fb-200d-2640-fe0f","🤵🏼♀️":"1f935-1f3fc-200d-2640-fe0f","🤵🏽♀️":"1f935-1f3fd-200d-2640-fe0f","🤵🏾♀️":"1f935-1f3fe-200d-2640-fe0f","🤵🏿♀️":"1f935-1f3ff-200d-2640-fe0f","👰🏻♂️":"1f470-1f3fb-200d-2642-fe0f","👰🏼♂️":"1f470-1f3fc-200d-2642-fe0f","👰🏽♂️":"1f470-1f3fd-200d-2642-fe0f","👰🏾♂️":"1f470-1f3fe-200d-2642-fe0f","👰🏿♂️":"1f470-1f3ff-200d-2642-fe0f","👰🏻♀️":"1f470-1f3fb-200d-2640-fe0f","👰🏼♀️":"1f470-1f3fc-200d-2640-fe0f","👰🏽♀️":"1f470-1f3fd-200d-2640-fe0f","👰🏾♀️":"1f470-1f3fe-200d-2640-fe0f","👰🏿♀️":"1f470-1f3ff-200d-2640-fe0f","🦸🏻♂️":"1f9b8-1f3fb-200d-2642-fe0f","🦸🏼♂️":"1f9b8-1f3fc-200d-2642-fe0f","🦸🏽♂️":"1f9b8-1f3fd-200d-2642-fe0f","🦸🏾♂️":"1f9b8-1f3fe-200d-2642-fe0f","🦸🏿♂️":"1f9b8-1f3ff-200d-2642-fe0f","🦸🏻♀️":"1f9b8-1f3fb-200d-2640-fe0f","🦸🏼♀️":"1f9b8-1f3fc-200d-2640-fe0f","🦸🏽♀️":"1f9b8-1f3fd-200d-2640-fe0f","🦸🏾♀️":"1f9b8-1f3fe-200d-2640-fe0f","🦸🏿♀️":"1f9b8-1f3ff-200d-2640-fe0f","🦹🏻♂️":"1f9b9-1f3fb-200d-2642-fe0f","🦹🏼♂️":"1f9b9-1f3fc-200d-2642-fe0f","🦹🏽♂️":"1f9b9-1f3fd-200d-2642-fe0f","🦹🏾♂️":"1f9b9-1f3fe-200d-2642-fe0f","🦹🏿♂️":"1f9b9-1f3ff-200d-2642-fe0f","🦹🏻♀️":"1f9b9-1f3fb-200d-2640-fe0f","🦹🏼♀️":"1f9b9-1f3fc-200d-2640-fe0f","🦹🏽♀️":"1f9b9-1f3fd-200d-2640-fe0f","🦹🏾♀️":"1f9b9-1f3fe-200d-2640-fe0f","🦹🏿♀️":"1f9b9-1f3ff-200d-2640-fe0f","🧙🏻♂️":"1f9d9-1f3fb-200d-2642-fe0f","🧙🏼♂️":"1f9d9-1f3fc-200d-2642-fe0f","🧙🏽♂️":"1f9d9-1f3fd-200d-2642-fe0f","🧙🏾♂️":"1f9d9-1f3fe-200d-2642-fe0f","🧙🏿♂️":"1f9d9-1f3ff-200d-2642-fe0f","🧙🏻♀️":"1f9d9-1f3fb-200d-2640-fe0f","🧙🏼♀️":"1f9d9-1f3fc-200d-2640-fe0f","🧙🏽♀️":"1f9d9-1f3fd-200d-2640-fe0f","🧙🏾♀️":"1f9d9-1f3fe-200d-2640-fe0f","🧙🏿♀️":"1f9d9-1f3ff-200d-2640-fe0f","🧚🏻♂️":"1f9da-1f3fb-200d-2642-fe0f","🧚🏼♂️":"1f9da-1f3fc-200d-2642-fe0f","🧚🏽♂️":"1f9da-1f3fd-200d-2642-fe0f","🧚🏾♂️":"1f9da-1f3fe-200d-2642-fe0f","🧚🏿♂️":"1f9da-1f3ff-200d-2642-fe0f","🧚🏻♀️":"1f9da-1f3fb-200d-2640-fe0f","🧚🏼♀️":"1f9da-1f3fc-200d-2640-fe0f","🧚🏽♀️":"1f9da-1f3fd-200d-2640-fe0f","🧚🏾♀️":"1f9da-1f3fe-200d-2640-fe0f","🧚🏿♀️":"1f9da-1f3ff-200d-2640-fe0f","🧛🏻♂️":"1f9db-1f3fb-200d-2642-fe0f","🧛🏼♂️":"1f9db-1f3fc-200d-2642-fe0f","🧛🏽♂️":"1f9db-1f3fd-200d-2642-fe0f","🧛🏾♂️":"1f9db-1f3fe-200d-2642-fe0f","🧛🏿♂️":"1f9db-1f3ff-200d-2642-fe0f","🧛🏻♀️":"1f9db-1f3fb-200d-2640-fe0f","🧛🏼♀️":"1f9db-1f3fc-200d-2640-fe0f","🧛🏽♀️":"1f9db-1f3fd-200d-2640-fe0f","🧛🏾♀️":"1f9db-1f3fe-200d-2640-fe0f","🧛🏿♀️":"1f9db-1f3ff-200d-2640-fe0f","🧜🏻♂️":"1f9dc-1f3fb-200d-2642-fe0f","🧜🏼♂️":"1f9dc-1f3fc-200d-2642-fe0f","🧜🏽♂️":"1f9dc-1f3fd-200d-2642-fe0f","🧜🏾♂️":"1f9dc-1f3fe-200d-2642-fe0f","🧜🏿♂️":"1f9dc-1f3ff-200d-2642-fe0f","🧜🏻♀️":"1f9dc-1f3fb-200d-2640-fe0f","🧜🏼♀️":"1f9dc-1f3fc-200d-2640-fe0f","🧜🏽♀️":"1f9dc-1f3fd-200d-2640-fe0f","🧜🏾♀️":"1f9dc-1f3fe-200d-2640-fe0f","🧜🏿♀️":"1f9dc-1f3ff-200d-2640-fe0f","🧝🏻♂️":"1f9dd-1f3fb-200d-2642-fe0f","🧝🏼♂️":"1f9dd-1f3fc-200d-2642-fe0f","🧝🏽♂️":"1f9dd-1f3fd-200d-2642-fe0f","🧝🏾♂️":"1f9dd-1f3fe-200d-2642-fe0f","🧝🏿♂️":"1f9dd-1f3ff-200d-2642-fe0f","🧝🏻♀️":"1f9dd-1f3fb-200d-2640-fe0f","🧝🏼♀️":"1f9dd-1f3fc-200d-2640-fe0f","🧝🏽♀️":"1f9dd-1f3fd-200d-2640-fe0f","🧝🏾♀️":"1f9dd-1f3fe-200d-2640-fe0f","🧝🏿♀️":"1f9dd-1f3ff-200d-2640-fe0f","💆🏻♂️":"1f486-1f3fb-200d-2642-fe0f","💆🏼♂️":"1f486-1f3fc-200d-2642-fe0f","💆🏽♂️":"1f486-1f3fd-200d-2642-fe0f","💆🏾♂️":"1f486-1f3fe-200d-2642-fe0f","💆🏿♂️":"1f486-1f3ff-200d-2642-fe0f","💆🏻♀️":"1f486-1f3fb-200d-2640-fe0f","💆🏼♀️":"1f486-1f3fc-200d-2640-fe0f","💆🏽♀️":"1f486-1f3fd-200d-2640-fe0f","💆🏾♀️":"1f486-1f3fe-200d-2640-fe0f","💆🏿♀️":"1f486-1f3ff-200d-2640-fe0f","💇🏻♂️":"1f487-1f3fb-200d-2642-fe0f","💇🏼♂️":"1f487-1f3fc-200d-2642-fe0f","💇🏽♂️":"1f487-1f3fd-200d-2642-fe0f","💇🏾♂️":"1f487-1f3fe-200d-2642-fe0f","💇🏿♂️":"1f487-1f3ff-200d-2642-fe0f","💇🏻♀️":"1f487-1f3fb-200d-2640-fe0f","💇🏼♀️":"1f487-1f3fc-200d-2640-fe0f","💇🏽♀️":"1f487-1f3fd-200d-2640-fe0f","💇🏾♀️":"1f487-1f3fe-200d-2640-fe0f","💇🏿♀️":"1f487-1f3ff-200d-2640-fe0f","🚶🏻♂️":"1f6b6-1f3fb-200d-2642-fe0f","🚶🏼♂️":"1f6b6-1f3fc-200d-2642-fe0f","🚶🏽♂️":"1f6b6-1f3fd-200d-2642-fe0f","🚶🏾♂️":"1f6b6-1f3fe-200d-2642-fe0f","🚶🏿♂️":"1f6b6-1f3ff-200d-2642-fe0f","🚶🏻♀️":"1f6b6-1f3fb-200d-2640-fe0f","🚶🏼♀️":"1f6b6-1f3fc-200d-2640-fe0f","🚶🏽♀️":"1f6b6-1f3fd-200d-2640-fe0f","🚶🏾♀️":"1f6b6-1f3fe-200d-2640-fe0f","🚶🏿♀️":"1f6b6-1f3ff-200d-2640-fe0f","🧍🏻♂️":"1f9cd-1f3fb-200d-2642-fe0f","🧍🏼♂️":"1f9cd-1f3fc-200d-2642-fe0f","🧍🏽♂️":"1f9cd-1f3fd-200d-2642-fe0f","🧍🏾♂️":"1f9cd-1f3fe-200d-2642-fe0f","🧍🏿♂️":"1f9cd-1f3ff-200d-2642-fe0f","🧍🏻♀️":"1f9cd-1f3fb-200d-2640-fe0f","🧍🏼♀️":"1f9cd-1f3fc-200d-2640-fe0f","🧍🏽♀️":"1f9cd-1f3fd-200d-2640-fe0f","🧍🏾♀️":"1f9cd-1f3fe-200d-2640-fe0f","🧍🏿♀️":"1f9cd-1f3ff-200d-2640-fe0f","🧎🏻♂️":"1f9ce-1f3fb-200d-2642-fe0f","🧎🏼♂️":"1f9ce-1f3fc-200d-2642-fe0f","🧎🏽♂️":"1f9ce-1f3fd-200d-2642-fe0f","🧎🏾♂️":"1f9ce-1f3fe-200d-2642-fe0f","🧎🏿♂️":"1f9ce-1f3ff-200d-2642-fe0f","🧎🏻♀️":"1f9ce-1f3fb-200d-2640-fe0f","🧎🏼♀️":"1f9ce-1f3fc-200d-2640-fe0f","🧎🏽♀️":"1f9ce-1f3fd-200d-2640-fe0f","🧎🏾♀️":"1f9ce-1f3fe-200d-2640-fe0f","🧎🏿♀️":"1f9ce-1f3ff-200d-2640-fe0f","🏃🏻♂️":"1f3c3-1f3fb-200d-2642-fe0f","🏃🏼♂️":"1f3c3-1f3fc-200d-2642-fe0f","🏃🏽♂️":"1f3c3-1f3fd-200d-2642-fe0f","🏃🏾♂️":"1f3c3-1f3fe-200d-2642-fe0f","🏃🏿♂️":"1f3c3-1f3ff-200d-2642-fe0f","🏃🏻♀️":"1f3c3-1f3fb-200d-2640-fe0f","🏃🏼♀️":"1f3c3-1f3fc-200d-2640-fe0f","🏃🏽♀️":"1f3c3-1f3fd-200d-2640-fe0f","🏃🏾♀️":"1f3c3-1f3fe-200d-2640-fe0f","🏃🏿♀️":"1f3c3-1f3ff-200d-2640-fe0f","🧖🏻♂️":"1f9d6-1f3fb-200d-2642-fe0f","🧖🏼♂️":"1f9d6-1f3fc-200d-2642-fe0f","🧖🏽♂️":"1f9d6-1f3fd-200d-2642-fe0f","🧖🏾♂️":"1f9d6-1f3fe-200d-2642-fe0f","🧖🏿♂️":"1f9d6-1f3ff-200d-2642-fe0f","🧖🏻♀️":"1f9d6-1f3fb-200d-2640-fe0f","🧖🏼♀️":"1f9d6-1f3fc-200d-2640-fe0f","🧖🏽♀️":"1f9d6-1f3fd-200d-2640-fe0f","🧖🏾♀️":"1f9d6-1f3fe-200d-2640-fe0f","🧖🏿♀️":"1f9d6-1f3ff-200d-2640-fe0f","🧗🏻♂️":"1f9d7-1f3fb-200d-2642-fe0f","🧗🏼♂️":"1f9d7-1f3fc-200d-2642-fe0f","🧗🏽♂️":"1f9d7-1f3fd-200d-2642-fe0f","🧗🏾♂️":"1f9d7-1f3fe-200d-2642-fe0f","🧗🏿♂️":"1f9d7-1f3ff-200d-2642-fe0f","🧗🏻♀️":"1f9d7-1f3fb-200d-2640-fe0f","🧗🏼♀️":"1f9d7-1f3fc-200d-2640-fe0f","🧗🏽♀️":"1f9d7-1f3fd-200d-2640-fe0f","🧗🏾♀️":"1f9d7-1f3fe-200d-2640-fe0f","🧗🏿♀️":"1f9d7-1f3ff-200d-2640-fe0f","🏌️♂️":"1f3cc-fe0f-200d-2642-fe0f","🏌🏻♂️":"1f3cc-1f3fb-200d-2642-fe0f","🏌🏼♂️":"1f3cc-1f3fc-200d-2642-fe0f","🏌🏽♂️":"1f3cc-1f3fd-200d-2642-fe0f","🏌🏾♂️":"1f3cc-1f3fe-200d-2642-fe0f","🏌🏿♂️":"1f3cc-1f3ff-200d-2642-fe0f","🏌️♀️":"1f3cc-fe0f-200d-2640-fe0f","🏌🏻♀️":"1f3cc-1f3fb-200d-2640-fe0f","🏌🏼♀️":"1f3cc-1f3fc-200d-2640-fe0f","🏌🏽♀️":"1f3cc-1f3fd-200d-2640-fe0f","🏌🏾♀️":"1f3cc-1f3fe-200d-2640-fe0f","🏌🏿♀️":"1f3cc-1f3ff-200d-2640-fe0f","🏄🏻♂️":"1f3c4-1f3fb-200d-2642-fe0f","🏄🏼♂️":"1f3c4-1f3fc-200d-2642-fe0f","🏄🏽♂️":"1f3c4-1f3fd-200d-2642-fe0f","🏄🏾♂️":"1f3c4-1f3fe-200d-2642-fe0f","🏄🏿♂️":"1f3c4-1f3ff-200d-2642-fe0f","🏄🏻♀️":"1f3c4-1f3fb-200d-2640-fe0f","🏄🏼♀️":"1f3c4-1f3fc-200d-2640-fe0f","🏄🏽♀️":"1f3c4-1f3fd-200d-2640-fe0f","🏄🏾♀️":"1f3c4-1f3fe-200d-2640-fe0f","🏄🏿♀️":"1f3c4-1f3ff-200d-2640-fe0f","🚣🏻♂️":"1f6a3-1f3fb-200d-2642-fe0f","🚣🏼♂️":"1f6a3-1f3fc-200d-2642-fe0f","🚣🏽♂️":"1f6a3-1f3fd-200d-2642-fe0f","🚣🏾♂️":"1f6a3-1f3fe-200d-2642-fe0f","🚣🏿♂️":"1f6a3-1f3ff-200d-2642-fe0f","🚣🏻♀️":"1f6a3-1f3fb-200d-2640-fe0f","🚣🏼♀️":"1f6a3-1f3fc-200d-2640-fe0f","🚣🏽♀️":"1f6a3-1f3fd-200d-2640-fe0f","🚣🏾♀️":"1f6a3-1f3fe-200d-2640-fe0f","🚣🏿♀️":"1f6a3-1f3ff-200d-2640-fe0f","🏊🏻♂️":"1f3ca-1f3fb-200d-2642-fe0f","🏊🏼♂️":"1f3ca-1f3fc-200d-2642-fe0f","🏊🏽♂️":"1f3ca-1f3fd-200d-2642-fe0f","🏊🏾♂️":"1f3ca-1f3fe-200d-2642-fe0f","🏊🏿♂️":"1f3ca-1f3ff-200d-2642-fe0f","🏊🏻♀️":"1f3ca-1f3fb-200d-2640-fe0f","🏊🏼♀️":"1f3ca-1f3fc-200d-2640-fe0f","🏊🏽♀️":"1f3ca-1f3fd-200d-2640-fe0f","🏊🏾♀️":"1f3ca-1f3fe-200d-2640-fe0f","🏊🏿♀️":"1f3ca-1f3ff-200d-2640-fe0f","⛹️♂️":"26f9-fe0f-200d-2642-fe0f","⛹🏻♂️":"26f9-1f3fb-200d-2642-fe0f","⛹🏼♂️":"26f9-1f3fc-200d-2642-fe0f","⛹🏽♂️":"26f9-1f3fd-200d-2642-fe0f","⛹🏾♂️":"26f9-1f3fe-200d-2642-fe0f","⛹🏿♂️":"26f9-1f3ff-200d-2642-fe0f","⛹️♀️":"26f9-fe0f-200d-2640-fe0f","⛹🏻♀️":"26f9-1f3fb-200d-2640-fe0f","⛹🏼♀️":"26f9-1f3fc-200d-2640-fe0f","⛹🏽♀️":"26f9-1f3fd-200d-2640-fe0f","⛹🏾♀️":"26f9-1f3fe-200d-2640-fe0f","⛹🏿♀️":"26f9-1f3ff-200d-2640-fe0f","🏋️♂️":"1f3cb-fe0f-200d-2642-fe0f","🏋🏻♂️":"1f3cb-1f3fb-200d-2642-fe0f","🏋🏼♂️":"1f3cb-1f3fc-200d-2642-fe0f","🏋🏽♂️":"1f3cb-1f3fd-200d-2642-fe0f","🏋🏾♂️":"1f3cb-1f3fe-200d-2642-fe0f","🏋🏿♂️":"1f3cb-1f3ff-200d-2642-fe0f","🏋️♀️":"1f3cb-fe0f-200d-2640-fe0f","🏋🏻♀️":"1f3cb-1f3fb-200d-2640-fe0f","🏋🏼♀️":"1f3cb-1f3fc-200d-2640-fe0f","🏋🏽♀️":"1f3cb-1f3fd-200d-2640-fe0f","🏋🏾♀️":"1f3cb-1f3fe-200d-2640-fe0f","🏋🏿♀️":"1f3cb-1f3ff-200d-2640-fe0f","🚴🏻♂️":"1f6b4-1f3fb-200d-2642-fe0f","🚴🏼♂️":"1f6b4-1f3fc-200d-2642-fe0f","🚴🏽♂️":"1f6b4-1f3fd-200d-2642-fe0f","🚴🏾♂️":"1f6b4-1f3fe-200d-2642-fe0f","🚴🏿♂️":"1f6b4-1f3ff-200d-2642-fe0f","🚴🏻♀️":"1f6b4-1f3fb-200d-2640-fe0f","🚴🏼♀️":"1f6b4-1f3fc-200d-2640-fe0f","🚴🏽♀️":"1f6b4-1f3fd-200d-2640-fe0f","🚴🏾♀️":"1f6b4-1f3fe-200d-2640-fe0f","🚴🏿♀️":"1f6b4-1f3ff-200d-2640-fe0f","🚵🏻♂️":"1f6b5-1f3fb-200d-2642-fe0f","🚵🏼♂️":"1f6b5-1f3fc-200d-2642-fe0f","🚵🏽♂️":"1f6b5-1f3fd-200d-2642-fe0f","🚵🏾♂️":"1f6b5-1f3fe-200d-2642-fe0f","🚵🏿♂️":"1f6b5-1f3ff-200d-2642-fe0f","🚵🏻♀️":"1f6b5-1f3fb-200d-2640-fe0f","🚵🏼♀️":"1f6b5-1f3fc-200d-2640-fe0f","🚵🏽♀️":"1f6b5-1f3fd-200d-2640-fe0f","🚵🏾♀️":"1f6b5-1f3fe-200d-2640-fe0f","🚵🏿♀️":"1f6b5-1f3ff-200d-2640-fe0f","🤸🏻♂️":"1f938-1f3fb-200d-2642-fe0f","🤸🏼♂️":"1f938-1f3fc-200d-2642-fe0f","🤸🏽♂️":"1f938-1f3fd-200d-2642-fe0f","🤸🏾♂️":"1f938-1f3fe-200d-2642-fe0f","🤸🏿♂️":"1f938-1f3ff-200d-2642-fe0f","🤸🏻♀️":"1f938-1f3fb-200d-2640-fe0f","🤸🏼♀️":"1f938-1f3fc-200d-2640-fe0f","🤸🏽♀️":"1f938-1f3fd-200d-2640-fe0f","🤸🏾♀️":"1f938-1f3fe-200d-2640-fe0f","🤸🏿♀️":"1f938-1f3ff-200d-2640-fe0f","🤽🏻♂️":"1f93d-1f3fb-200d-2642-fe0f","🤽🏼♂️":"1f93d-1f3fc-200d-2642-fe0f","🤽🏽♂️":"1f93d-1f3fd-200d-2642-fe0f","🤽🏾♂️":"1f93d-1f3fe-200d-2642-fe0f","🤽🏿♂️":"1f93d-1f3ff-200d-2642-fe0f","🤽🏻♀️":"1f93d-1f3fb-200d-2640-fe0f","🤽🏼♀️":"1f93d-1f3fc-200d-2640-fe0f","🤽🏽♀️":"1f93d-1f3fd-200d-2640-fe0f","🤽🏾♀️":"1f93d-1f3fe-200d-2640-fe0f","🤽🏿♀️":"1f93d-1f3ff-200d-2640-fe0f","🤾🏻♂️":"1f93e-1f3fb-200d-2642-fe0f","🤾🏼♂️":"1f93e-1f3fc-200d-2642-fe0f","🤾🏽♂️":"1f93e-1f3fd-200d-2642-fe0f","🤾🏾♂️":"1f93e-1f3fe-200d-2642-fe0f","🤾🏿♂️":"1f93e-1f3ff-200d-2642-fe0f","🤾🏻♀️":"1f93e-1f3fb-200d-2640-fe0f","🤾🏼♀️":"1f93e-1f3fc-200d-2640-fe0f","🤾🏽♀️":"1f93e-1f3fd-200d-2640-fe0f","🤾🏾♀️":"1f93e-1f3fe-200d-2640-fe0f","🤾🏿♀️":"1f93e-1f3ff-200d-2640-fe0f","🤹🏻♂️":"1f939-1f3fb-200d-2642-fe0f","🤹🏼♂️":"1f939-1f3fc-200d-2642-fe0f","🤹🏽♂️":"1f939-1f3fd-200d-2642-fe0f","🤹🏾♂️":"1f939-1f3fe-200d-2642-fe0f","🤹🏿♂️":"1f939-1f3ff-200d-2642-fe0f","🤹🏻♀️":"1f939-1f3fb-200d-2640-fe0f","🤹🏼♀️":"1f939-1f3fc-200d-2640-fe0f","🤹🏽♀️":"1f939-1f3fd-200d-2640-fe0f","🤹🏾♀️":"1f939-1f3fe-200d-2640-fe0f","🤹🏿♀️":"1f939-1f3ff-200d-2640-fe0f","🧘🏻♂️":"1f9d8-1f3fb-200d-2642-fe0f","🧘🏼♂️":"1f9d8-1f3fc-200d-2642-fe0f","🧘🏽♂️":"1f9d8-1f3fd-200d-2642-fe0f","🧘🏾♂️":"1f9d8-1f3fe-200d-2642-fe0f","🧘🏿♂️":"1f9d8-1f3ff-200d-2642-fe0f","🧘🏻♀️":"1f9d8-1f3fb-200d-2640-fe0f","🧘🏼♀️":"1f9d8-1f3fc-200d-2640-fe0f","🧘🏽♀️":"1f9d8-1f3fd-200d-2640-fe0f","🧘🏾♀️":"1f9d8-1f3fe-200d-2640-fe0f","🧘🏿♀️":"1f9d8-1f3ff-200d-2640-fe0f","🧑🤝🧑":"1f9d1-200d-1f91d-200d-1f9d1","👩❤👨":"1f469-200d-2764-fe0f-200d-1f468","👨❤👨":"1f468-200d-2764-fe0f-200d-1f468","👩❤👩":"1f469-200d-2764-fe0f-200d-1f469","👨👩👦":"1f468-200d-1f469-200d-1f466","👨👩👧":"1f468-200d-1f469-200d-1f467","👨👨👦":"1f468-200d-1f468-200d-1f466","👨👨👧":"1f468-200d-1f468-200d-1f467","👩👩👦":"1f469-200d-1f469-200d-1f466","👩👩👧":"1f469-200d-1f469-200d-1f467","👨👦👦":"1f468-200d-1f466-200d-1f466","👨👧👦":"1f468-200d-1f467-200d-1f466","👨👧👧":"1f468-200d-1f467-200d-1f467","👩👦👦":"1f469-200d-1f466-200d-1f466","👩👧👦":"1f469-200d-1f467-200d-1f466","👩👧👧":"1f469-200d-1f467-200d-1f467","🏳️⚧️":"1f3f3-fe0f-200d-26a7-fe0f","👩❤️👨":"1f469-200d-2764-fe0f-200d-1f468","👨❤️👨":"1f468-200d-2764-fe0f-200d-1f468","👩❤️👩":"1f469-200d-2764-fe0f-200d-1f469","🧑🏻🤝🧑🏻":"1f9d1-1f3fb-200d-1f91d-200d-1f9d1-1f3fb","🧑🏻🤝🧑🏼":"1f9d1-1f3fb-200d-1f91d-200d-1f9d1-1f3fc","🧑🏻🤝🧑🏽":"1f9d1-1f3fb-200d-1f91d-200d-1f9d1-1f3fd","🧑🏻🤝🧑🏾":"1f9d1-1f3fb-200d-1f91d-200d-1f9d1-1f3fe","🧑🏻🤝🧑🏿":"1f9d1-1f3fb-200d-1f91d-200d-1f9d1-1f3ff","🧑🏼🤝🧑🏻":"1f9d1-1f3fc-200d-1f91d-200d-1f9d1-1f3fb","🧑🏼🤝🧑🏼":"1f9d1-1f3fc-200d-1f91d-200d-1f9d1-1f3fc","🧑🏼🤝🧑🏽":"1f9d1-1f3fc-200d-1f91d-200d-1f9d1-1f3fd","🧑🏼🤝🧑🏾":"1f9d1-1f3fc-200d-1f91d-200d-1f9d1-1f3fe","🧑🏼🤝🧑🏿":"1f9d1-1f3fc-200d-1f91d-200d-1f9d1-1f3ff","🧑🏽🤝🧑🏻":"1f9d1-1f3fd-200d-1f91d-200d-1f9d1-1f3fb","🧑🏽🤝🧑🏼":"1f9d1-1f3fd-200d-1f91d-200d-1f9d1-1f3fc","🧑🏽🤝🧑🏽":"1f9d1-1f3fd-200d-1f91d-200d-1f9d1-1f3fd","🧑🏽🤝🧑🏾":"1f9d1-1f3fd-200d-1f91d-200d-1f9d1-1f3fe","🧑🏽🤝🧑🏿":"1f9d1-1f3fd-200d-1f91d-200d-1f9d1-1f3ff","🧑🏾🤝🧑🏻":"1f9d1-1f3fe-200d-1f91d-200d-1f9d1-1f3fb","🧑🏾🤝🧑🏼":"1f9d1-1f3fe-200d-1f91d-200d-1f9d1-1f3fc","🧑🏾🤝🧑🏽":"1f9d1-1f3fe-200d-1f91d-200d-1f9d1-1f3fd","🧑🏾🤝🧑🏾":"1f9d1-1f3fe-200d-1f91d-200d-1f9d1-1f3fe","🧑🏾🤝🧑🏿":"1f9d1-1f3fe-200d-1f91d-200d-1f9d1-1f3ff","🧑🏿🤝🧑🏻":"1f9d1-1f3ff-200d-1f91d-200d-1f9d1-1f3fb","🧑🏿🤝🧑🏼":"1f9d1-1f3ff-200d-1f91d-200d-1f9d1-1f3fc","🧑🏿🤝🧑🏽":"1f9d1-1f3ff-200d-1f91d-200d-1f9d1-1f3fd","🧑🏿🤝🧑🏾":"1f9d1-1f3ff-200d-1f91d-200d-1f9d1-1f3fe","🧑🏿🤝🧑🏿":"1f9d1-1f3ff-200d-1f91d-200d-1f9d1-1f3ff","👩🏻🤝👩🏼":"1f469-1f3fb-200d-1f91d-200d-1f469-1f3fc","👩🏻🤝👩🏽":"1f469-1f3fb-200d-1f91d-200d-1f469-1f3fd","👩🏻🤝👩🏾":"1f469-1f3fb-200d-1f91d-200d-1f469-1f3fe","👩🏻🤝👩🏿":"1f469-1f3fb-200d-1f91d-200d-1f469-1f3ff","👩🏼🤝👩🏻":"1f469-1f3fc-200d-1f91d-200d-1f469-1f3fb","👩🏼🤝👩🏽":"1f469-1f3fc-200d-1f91d-200d-1f469-1f3fd","👩🏼🤝👩🏾":"1f469-1f3fc-200d-1f91d-200d-1f469-1f3fe","👩🏼🤝👩🏿":"1f469-1f3fc-200d-1f91d-200d-1f469-1f3ff","👩🏽🤝👩🏻":"1f469-1f3fd-200d-1f91d-200d-1f469-1f3fb","👩🏽🤝👩🏼":"1f469-1f3fd-200d-1f91d-200d-1f469-1f3fc","👩🏽🤝👩🏾":"1f469-1f3fd-200d-1f91d-200d-1f469-1f3fe","👩🏽🤝👩🏿":"1f469-1f3fd-200d-1f91d-200d-1f469-1f3ff","👩🏾🤝👩🏻":"1f469-1f3fe-200d-1f91d-200d-1f469-1f3fb","👩🏾🤝👩🏼":"1f469-1f3fe-200d-1f91d-200d-1f469-1f3fc","👩🏾🤝👩🏽":"1f469-1f3fe-200d-1f91d-200d-1f469-1f3fd","👩🏾🤝👩🏿":"1f469-1f3fe-200d-1f91d-200d-1f469-1f3ff","👩🏿🤝👩🏻":"1f469-1f3ff-200d-1f91d-200d-1f469-1f3fb","👩🏿🤝👩🏼":"1f469-1f3ff-200d-1f91d-200d-1f469-1f3fc","👩🏿🤝👩🏽":"1f469-1f3ff-200d-1f91d-200d-1f469-1f3fd","👩🏿🤝👩🏾":"1f469-1f3ff-200d-1f91d-200d-1f469-1f3fe","👩🏻🤝👨🏼":"1f469-1f3fb-200d-1f91d-200d-1f468-1f3fc","👩🏻🤝👨🏽":"1f469-1f3fb-200d-1f91d-200d-1f468-1f3fd","👩🏻🤝👨🏾":"1f469-1f3fb-200d-1f91d-200d-1f468-1f3fe","👩🏻🤝👨🏿":"1f469-1f3fb-200d-1f91d-200d-1f468-1f3ff","👩🏼🤝👨🏻":"1f469-1f3fc-200d-1f91d-200d-1f468-1f3fb","👩🏼🤝👨🏽":"1f469-1f3fc-200d-1f91d-200d-1f468-1f3fd","👩🏼🤝👨🏾":"1f469-1f3fc-200d-1f91d-200d-1f468-1f3fe","👩🏼🤝👨🏿":"1f469-1f3fc-200d-1f91d-200d-1f468-1f3ff","👩🏽🤝👨🏻":"1f469-1f3fd-200d-1f91d-200d-1f468-1f3fb","👩🏽🤝👨🏼":"1f469-1f3fd-200d-1f91d-200d-1f468-1f3fc","👩🏽🤝👨🏾":"1f469-1f3fd-200d-1f91d-200d-1f468-1f3fe","👩🏽🤝👨🏿":"1f469-1f3fd-200d-1f91d-200d-1f468-1f3ff","👩🏾🤝👨🏻":"1f469-1f3fe-200d-1f91d-200d-1f468-1f3fb","👩🏾🤝👨🏼":"1f469-1f3fe-200d-1f91d-200d-1f468-1f3fc","👩🏾🤝👨🏽":"1f469-1f3fe-200d-1f91d-200d-1f468-1f3fd","👩🏾🤝👨🏿":"1f469-1f3fe-200d-1f91d-200d-1f468-1f3ff","👩🏿🤝👨🏻":"1f469-1f3ff-200d-1f91d-200d-1f468-1f3fb","👩🏿🤝👨🏼":"1f469-1f3ff-200d-1f91d-200d-1f468-1f3fc","👩🏿🤝👨🏽":"1f469-1f3ff-200d-1f91d-200d-1f468-1f3fd","👩🏿🤝👨🏾":"1f469-1f3ff-200d-1f91d-200d-1f468-1f3fe","👨🏻🤝👨🏼":"1f468-1f3fb-200d-1f91d-200d-1f468-1f3fc","👨🏻🤝👨🏽":"1f468-1f3fb-200d-1f91d-200d-1f468-1f3fd","👨🏻🤝👨🏾":"1f468-1f3fb-200d-1f91d-200d-1f468-1f3fe","👨🏻🤝👨🏿":"1f468-1f3fb-200d-1f91d-200d-1f468-1f3ff","👨🏼🤝👨🏻":"1f468-1f3fc-200d-1f91d-200d-1f468-1f3fb","👨🏼🤝👨🏽":"1f468-1f3fc-200d-1f91d-200d-1f468-1f3fd","👨🏼🤝👨🏾":"1f468-1f3fc-200d-1f91d-200d-1f468-1f3fe","👨🏼🤝👨🏿":"1f468-1f3fc-200d-1f91d-200d-1f468-1f3ff","👨🏽🤝👨🏻":"1f468-1f3fd-200d-1f91d-200d-1f468-1f3fb","👨🏽🤝👨🏼":"1f468-1f3fd-200d-1f91d-200d-1f468-1f3fc","👨🏽🤝👨🏾":"1f468-1f3fd-200d-1f91d-200d-1f468-1f3fe","👨🏽🤝👨🏿":"1f468-1f3fd-200d-1f91d-200d-1f468-1f3ff","👨🏾🤝👨🏻":"1f468-1f3fe-200d-1f91d-200d-1f468-1f3fb","👨🏾🤝👨🏼":"1f468-1f3fe-200d-1f91d-200d-1f468-1f3fc","👨🏾🤝👨🏽":"1f468-1f3fe-200d-1f91d-200d-1f468-1f3fd","👨🏾🤝👨🏿":"1f468-1f3fe-200d-1f91d-200d-1f468-1f3ff","👨🏿🤝👨🏻":"1f468-1f3ff-200d-1f91d-200d-1f468-1f3fb","👨🏿🤝👨🏼":"1f468-1f3ff-200d-1f91d-200d-1f468-1f3fc","👨🏿🤝👨🏽":"1f468-1f3ff-200d-1f91d-200d-1f468-1f3fd","👨🏿🤝👨🏾":"1f468-1f3ff-200d-1f91d-200d-1f468-1f3fe","👩❤💋👨":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f468","👨❤💋👨":"1f468-200d-2764-fe0f-200d-1f48b-200d-1f468","👩❤💋👩":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f469","🧑🏻❤🧑🏼":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f9d1-1f3fc","🧑🏻❤🧑🏽":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f9d1-1f3fd","🧑🏻❤🧑🏾":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f9d1-1f3fe","🧑🏻❤🧑🏿":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f9d1-1f3ff","🧑🏼❤🧑🏻":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f9d1-1f3fb","🧑🏼❤🧑🏽":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f9d1-1f3fd","🧑🏼❤🧑🏾":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f9d1-1f3fe","🧑🏼❤🧑🏿":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f9d1-1f3ff","🧑🏽❤🧑🏻":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f9d1-1f3fb","🧑🏽❤🧑🏼":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f9d1-1f3fc","🧑🏽❤🧑🏾":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f9d1-1f3fe","🧑🏽❤🧑🏿":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f9d1-1f3ff","🧑🏾❤🧑🏻":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f9d1-1f3fb","🧑🏾❤🧑🏼":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f9d1-1f3fc","🧑🏾❤🧑🏽":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f9d1-1f3fd","🧑🏾❤🧑🏿":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f9d1-1f3ff","🧑🏿❤🧑🏻":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f9d1-1f3fb","🧑🏿❤🧑🏼":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f9d1-1f3fc","🧑🏿❤🧑🏽":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f9d1-1f3fd","🧑🏿❤🧑🏾":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f9d1-1f3fe","👩🏻❤👨🏻":"1f469-1f3fb-200d-2764-fe0f-200d-1f468-1f3fb","👩🏻❤👨🏼":"1f469-1f3fb-200d-2764-fe0f-200d-1f468-1f3fc","👩🏻❤👨🏽":"1f469-1f3fb-200d-2764-fe0f-200d-1f468-1f3fd","👩🏻❤👨🏾":"1f469-1f3fb-200d-2764-fe0f-200d-1f468-1f3fe","👩🏻❤👨🏿":"1f469-1f3fb-200d-2764-fe0f-200d-1f468-1f3ff","👩🏼❤👨🏻":"1f469-1f3fc-200d-2764-fe0f-200d-1f468-1f3fb","👩🏼❤👨🏼":"1f469-1f3fc-200d-2764-fe0f-200d-1f468-1f3fc","👩🏼❤👨🏽":"1f469-1f3fc-200d-2764-fe0f-200d-1f468-1f3fd","👩🏼❤👨🏾":"1f469-1f3fc-200d-2764-fe0f-200d-1f468-1f3fe","👩🏼❤👨🏿":"1f469-1f3fc-200d-2764-fe0f-200d-1f468-1f3ff","👩🏽❤👨🏻":"1f469-1f3fd-200d-2764-fe0f-200d-1f468-1f3fb","👩🏽❤👨🏼":"1f469-1f3fd-200d-2764-fe0f-200d-1f468-1f3fc","👩🏽❤👨🏽":"1f469-1f3fd-200d-2764-fe0f-200d-1f468-1f3fd","👩🏽❤👨🏾":"1f469-1f3fd-200d-2764-fe0f-200d-1f468-1f3fe","👩🏽❤👨🏿":"1f469-1f3fd-200d-2764-fe0f-200d-1f468-1f3ff","👩🏾❤👨🏻":"1f469-1f3fe-200d-2764-fe0f-200d-1f468-1f3fb","👩🏾❤👨🏼":"1f469-1f3fe-200d-2764-fe0f-200d-1f468-1f3fc","👩🏾❤👨🏽":"1f469-1f3fe-200d-2764-fe0f-200d-1f468-1f3fd","👩🏾❤👨🏾":"1f469-1f3fe-200d-2764-fe0f-200d-1f468-1f3fe","👩🏾❤👨🏿":"1f469-1f3fe-200d-2764-fe0f-200d-1f468-1f3ff","👩🏿❤👨🏻":"1f469-1f3ff-200d-2764-fe0f-200d-1f468-1f3fb","👩🏿❤👨🏼":"1f469-1f3ff-200d-2764-fe0f-200d-1f468-1f3fc","👩🏿❤👨🏽":"1f469-1f3ff-200d-2764-fe0f-200d-1f468-1f3fd","👩🏿❤👨🏾":"1f469-1f3ff-200d-2764-fe0f-200d-1f468-1f3fe","👩🏿❤👨🏿":"1f469-1f3ff-200d-2764-fe0f-200d-1f468-1f3ff","👨🏻❤👨🏻":"1f468-1f3fb-200d-2764-fe0f-200d-1f468-1f3fb","👨🏻❤👨🏼":"1f468-1f3fb-200d-2764-fe0f-200d-1f468-1f3fc","👨🏻❤👨🏽":"1f468-1f3fb-200d-2764-fe0f-200d-1f468-1f3fd","👨🏻❤👨🏾":"1f468-1f3fb-200d-2764-fe0f-200d-1f468-1f3fe","👨🏻❤👨🏿":"1f468-1f3fb-200d-2764-fe0f-200d-1f468-1f3ff","👨🏼❤👨🏻":"1f468-1f3fc-200d-2764-fe0f-200d-1f468-1f3fb","👨🏼❤👨🏼":"1f468-1f3fc-200d-2764-fe0f-200d-1f468-1f3fc","👨🏼❤👨🏽":"1f468-1f3fc-200d-2764-fe0f-200d-1f468-1f3fd","👨🏼❤👨🏾":"1f468-1f3fc-200d-2764-fe0f-200d-1f468-1f3fe","👨🏼❤👨🏿":"1f468-1f3fc-200d-2764-fe0f-200d-1f468-1f3ff","👨🏽❤👨🏻":"1f468-1f3fd-200d-2764-fe0f-200d-1f468-1f3fb","👨🏽❤👨🏼":"1f468-1f3fd-200d-2764-fe0f-200d-1f468-1f3fc","👨🏽❤👨🏽":"1f468-1f3fd-200d-2764-fe0f-200d-1f468-1f3fd","👨🏽❤👨🏾":"1f468-1f3fd-200d-2764-fe0f-200d-1f468-1f3fe","👨🏽❤👨🏿":"1f468-1f3fd-200d-2764-fe0f-200d-1f468-1f3ff","👨🏾❤👨🏻":"1f468-1f3fe-200d-2764-fe0f-200d-1f468-1f3fb","👨🏾❤👨🏼":"1f468-1f3fe-200d-2764-fe0f-200d-1f468-1f3fc","👨🏾❤👨🏽":"1f468-1f3fe-200d-2764-fe0f-200d-1f468-1f3fd","👨🏾❤👨🏾":"1f468-1f3fe-200d-2764-fe0f-200d-1f468-1f3fe","👨🏾❤👨🏿":"1f468-1f3fe-200d-2764-fe0f-200d-1f468-1f3ff","👨🏿❤👨🏻":"1f468-1f3ff-200d-2764-fe0f-200d-1f468-1f3fb","👨🏿❤👨🏼":"1f468-1f3ff-200d-2764-fe0f-200d-1f468-1f3fc","👨🏿❤👨🏽":"1f468-1f3ff-200d-2764-fe0f-200d-1f468-1f3fd","👨🏿❤👨🏾":"1f468-1f3ff-200d-2764-fe0f-200d-1f468-1f3fe","👨🏿❤👨🏿":"1f468-1f3ff-200d-2764-fe0f-200d-1f468-1f3ff","👩🏻❤👩🏻":"1f469-1f3fb-200d-2764-fe0f-200d-1f469-1f3fb","👩🏻❤👩🏼":"1f469-1f3fb-200d-2764-fe0f-200d-1f469-1f3fc","👩🏻❤👩🏽":"1f469-1f3fb-200d-2764-fe0f-200d-1f469-1f3fd","👩🏻❤👩🏾":"1f469-1f3fb-200d-2764-fe0f-200d-1f469-1f3fe","👩🏻❤👩🏿":"1f469-1f3fb-200d-2764-fe0f-200d-1f469-1f3ff","👩🏼❤👩🏻":"1f469-1f3fc-200d-2764-fe0f-200d-1f469-1f3fb","👩🏼❤👩🏼":"1f469-1f3fc-200d-2764-fe0f-200d-1f469-1f3fc","👩🏼❤👩🏽":"1f469-1f3fc-200d-2764-fe0f-200d-1f469-1f3fd","👩🏼❤👩🏾":"1f469-1f3fc-200d-2764-fe0f-200d-1f469-1f3fe","👩🏼❤👩🏿":"1f469-1f3fc-200d-2764-fe0f-200d-1f469-1f3ff","👩🏽❤👩🏻":"1f469-1f3fd-200d-2764-fe0f-200d-1f469-1f3fb","👩🏽❤👩🏼":"1f469-1f3fd-200d-2764-fe0f-200d-1f469-1f3fc","👩🏽❤👩🏽":"1f469-1f3fd-200d-2764-fe0f-200d-1f469-1f3fd","👩🏽❤👩🏾":"1f469-1f3fd-200d-2764-fe0f-200d-1f469-1f3fe","👩🏽❤👩🏿":"1f469-1f3fd-200d-2764-fe0f-200d-1f469-1f3ff","👩🏾❤👩🏻":"1f469-1f3fe-200d-2764-fe0f-200d-1f469-1f3fb","👩🏾❤👩🏼":"1f469-1f3fe-200d-2764-fe0f-200d-1f469-1f3fc","👩🏾❤👩🏽":"1f469-1f3fe-200d-2764-fe0f-200d-1f469-1f3fd","👩🏾❤👩🏾":"1f469-1f3fe-200d-2764-fe0f-200d-1f469-1f3fe","👩🏾❤👩🏿":"1f469-1f3fe-200d-2764-fe0f-200d-1f469-1f3ff","👩🏿❤👩🏻":"1f469-1f3ff-200d-2764-fe0f-200d-1f469-1f3fb","👩🏿❤👩🏼":"1f469-1f3ff-200d-2764-fe0f-200d-1f469-1f3fc","👩🏿❤👩🏽":"1f469-1f3ff-200d-2764-fe0f-200d-1f469-1f3fd","👩🏿❤👩🏾":"1f469-1f3ff-200d-2764-fe0f-200d-1f469-1f3fe","👩🏿❤👩🏿":"1f469-1f3ff-200d-2764-fe0f-200d-1f469-1f3ff","👨👩👧👦":"1f468-200d-1f469-200d-1f467-200d-1f466","👨👩👦👦":"1f468-200d-1f469-200d-1f466-200d-1f466","👨👩👧👧":"1f468-200d-1f469-200d-1f467-200d-1f467","👨👨👧👦":"1f468-200d-1f468-200d-1f467-200d-1f466","👨👨👦👦":"1f468-200d-1f468-200d-1f466-200d-1f466","👨👨👧👧":"1f468-200d-1f468-200d-1f467-200d-1f467","👩👩👧👦":"1f469-200d-1f469-200d-1f467-200d-1f466","👩👩👦👦":"1f469-200d-1f469-200d-1f466-200d-1f466","👩👩👧👧":"1f469-200d-1f469-200d-1f467-200d-1f467","🏴":"1f3f4-e0067-e0062-e0065-e006e-e0067-e007f","🏴":"1f3f4-e0067-e0062-e0073-e0063-e0074-e007f","🏴":"1f3f4-e0067-e0062-e0077-e006c-e0073-e007f","👩❤️💋👨":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f468","👨❤️💋👨":"1f468-200d-2764-fe0f-200d-1f48b-200d-1f468","👩❤️💋👩":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f469","🧑🏻❤️🧑🏼":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f9d1-1f3fc","🧑🏻❤️🧑🏽":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f9d1-1f3fd","🧑🏻❤️🧑🏾":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f9d1-1f3fe","🧑🏻❤️🧑🏿":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f9d1-1f3ff","🧑🏼❤️🧑🏻":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f9d1-1f3fb","🧑🏼❤️🧑🏽":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f9d1-1f3fd","🧑🏼❤️🧑🏾":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f9d1-1f3fe","🧑🏼❤️🧑🏿":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f9d1-1f3ff","🧑🏽❤️🧑🏻":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f9d1-1f3fb","🧑🏽❤️🧑🏼":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f9d1-1f3fc","🧑🏽❤️🧑🏾":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f9d1-1f3fe","🧑🏽❤️🧑🏿":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f9d1-1f3ff","🧑🏾❤️🧑🏻":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f9d1-1f3fb","🧑🏾❤️🧑🏼":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f9d1-1f3fc","🧑🏾❤️🧑🏽":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f9d1-1f3fd","🧑🏾❤️🧑🏿":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f9d1-1f3ff","🧑🏿❤️🧑🏻":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f9d1-1f3fb","🧑🏿❤️🧑🏼":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f9d1-1f3fc","🧑🏿❤️🧑🏽":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f9d1-1f3fd","🧑🏿❤️🧑🏾":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f9d1-1f3fe","👩🏻❤️👨🏻":"1f469-1f3fb-200d-2764-fe0f-200d-1f468-1f3fb","👩🏻❤️👨🏼":"1f469-1f3fb-200d-2764-fe0f-200d-1f468-1f3fc","👩🏻❤️👨🏽":"1f469-1f3fb-200d-2764-fe0f-200d-1f468-1f3fd","👩🏻❤️👨🏾":"1f469-1f3fb-200d-2764-fe0f-200d-1f468-1f3fe","👩🏻❤️👨🏿":"1f469-1f3fb-200d-2764-fe0f-200d-1f468-1f3ff","👩🏼❤️👨🏻":"1f469-1f3fc-200d-2764-fe0f-200d-1f468-1f3fb","👩🏼❤️👨🏼":"1f469-1f3fc-200d-2764-fe0f-200d-1f468-1f3fc","👩🏼❤️👨🏽":"1f469-1f3fc-200d-2764-fe0f-200d-1f468-1f3fd","👩🏼❤️👨🏾":"1f469-1f3fc-200d-2764-fe0f-200d-1f468-1f3fe","👩🏼❤️👨🏿":"1f469-1f3fc-200d-2764-fe0f-200d-1f468-1f3ff","👩🏽❤️👨🏻":"1f469-1f3fd-200d-2764-fe0f-200d-1f468-1f3fb","👩🏽❤️👨🏼":"1f469-1f3fd-200d-2764-fe0f-200d-1f468-1f3fc","👩🏽❤️👨🏽":"1f469-1f3fd-200d-2764-fe0f-200d-1f468-1f3fd","👩🏽❤️👨🏾":"1f469-1f3fd-200d-2764-fe0f-200d-1f468-1f3fe","👩🏽❤️👨🏿":"1f469-1f3fd-200d-2764-fe0f-200d-1f468-1f3ff","👩🏾❤️👨🏻":"1f469-1f3fe-200d-2764-fe0f-200d-1f468-1f3fb","👩🏾❤️👨🏼":"1f469-1f3fe-200d-2764-fe0f-200d-1f468-1f3fc","👩🏾❤️👨🏽":"1f469-1f3fe-200d-2764-fe0f-200d-1f468-1f3fd","👩🏾❤️👨🏾":"1f469-1f3fe-200d-2764-fe0f-200d-1f468-1f3fe","👩🏾❤️👨🏿":"1f469-1f3fe-200d-2764-fe0f-200d-1f468-1f3ff","👩🏿❤️👨🏻":"1f469-1f3ff-200d-2764-fe0f-200d-1f468-1f3fb","👩🏿❤️👨🏼":"1f469-1f3ff-200d-2764-fe0f-200d-1f468-1f3fc","👩🏿❤️👨🏽":"1f469-1f3ff-200d-2764-fe0f-200d-1f468-1f3fd","👩🏿❤️👨🏾":"1f469-1f3ff-200d-2764-fe0f-200d-1f468-1f3fe","👩🏿❤️👨🏿":"1f469-1f3ff-200d-2764-fe0f-200d-1f468-1f3ff","👨🏻❤️👨🏻":"1f468-1f3fb-200d-2764-fe0f-200d-1f468-1f3fb","👨🏻❤️👨🏼":"1f468-1f3fb-200d-2764-fe0f-200d-1f468-1f3fc","👨🏻❤️👨🏽":"1f468-1f3fb-200d-2764-fe0f-200d-1f468-1f3fd","👨🏻❤️👨🏾":"1f468-1f3fb-200d-2764-fe0f-200d-1f468-1f3fe","👨🏻❤️👨🏿":"1f468-1f3fb-200d-2764-fe0f-200d-1f468-1f3ff","👨🏼❤️👨🏻":"1f468-1f3fc-200d-2764-fe0f-200d-1f468-1f3fb","👨🏼❤️👨🏼":"1f468-1f3fc-200d-2764-fe0f-200d-1f468-1f3fc","👨🏼❤️👨🏽":"1f468-1f3fc-200d-2764-fe0f-200d-1f468-1f3fd","👨🏼❤️👨🏾":"1f468-1f3fc-200d-2764-fe0f-200d-1f468-1f3fe","👨🏼❤️👨🏿":"1f468-1f3fc-200d-2764-fe0f-200d-1f468-1f3ff","👨🏽❤️👨🏻":"1f468-1f3fd-200d-2764-fe0f-200d-1f468-1f3fb","👨🏽❤️👨🏼":"1f468-1f3fd-200d-2764-fe0f-200d-1f468-1f3fc","👨🏽❤️👨🏽":"1f468-1f3fd-200d-2764-fe0f-200d-1f468-1f3fd","👨🏽❤️👨🏾":"1f468-1f3fd-200d-2764-fe0f-200d-1f468-1f3fe","👨🏽❤️👨🏿":"1f468-1f3fd-200d-2764-fe0f-200d-1f468-1f3ff","👨🏾❤️👨🏻":"1f468-1f3fe-200d-2764-fe0f-200d-1f468-1f3fb","👨🏾❤️👨🏼":"1f468-1f3fe-200d-2764-fe0f-200d-1f468-1f3fc","👨🏾❤️👨🏽":"1f468-1f3fe-200d-2764-fe0f-200d-1f468-1f3fd","👨🏾❤️👨🏾":"1f468-1f3fe-200d-2764-fe0f-200d-1f468-1f3fe","👨🏾❤️👨🏿":"1f468-1f3fe-200d-2764-fe0f-200d-1f468-1f3ff","👨🏿❤️👨🏻":"1f468-1f3ff-200d-2764-fe0f-200d-1f468-1f3fb","👨🏿❤️👨🏼":"1f468-1f3ff-200d-2764-fe0f-200d-1f468-1f3fc","👨🏿❤️👨🏽":"1f468-1f3ff-200d-2764-fe0f-200d-1f468-1f3fd","👨🏿❤️👨🏾":"1f468-1f3ff-200d-2764-fe0f-200d-1f468-1f3fe","👨🏿❤️👨🏿":"1f468-1f3ff-200d-2764-fe0f-200d-1f468-1f3ff","👩🏻❤️👩🏻":"1f469-1f3fb-200d-2764-fe0f-200d-1f469-1f3fb","👩🏻❤️👩🏼":"1f469-1f3fb-200d-2764-fe0f-200d-1f469-1f3fc","👩🏻❤️👩🏽":"1f469-1f3fb-200d-2764-fe0f-200d-1f469-1f3fd","👩🏻❤️👩🏾":"1f469-1f3fb-200d-2764-fe0f-200d-1f469-1f3fe","👩🏻❤️👩🏿":"1f469-1f3fb-200d-2764-fe0f-200d-1f469-1f3ff","👩🏼❤️👩🏻":"1f469-1f3fc-200d-2764-fe0f-200d-1f469-1f3fb","👩🏼❤️👩🏼":"1f469-1f3fc-200d-2764-fe0f-200d-1f469-1f3fc","👩🏼❤️👩🏽":"1f469-1f3fc-200d-2764-fe0f-200d-1f469-1f3fd","👩🏼❤️👩🏾":"1f469-1f3fc-200d-2764-fe0f-200d-1f469-1f3fe","👩🏼❤️👩🏿":"1f469-1f3fc-200d-2764-fe0f-200d-1f469-1f3ff","👩🏽❤️👩🏻":"1f469-1f3fd-200d-2764-fe0f-200d-1f469-1f3fb","👩🏽❤️👩🏼":"1f469-1f3fd-200d-2764-fe0f-200d-1f469-1f3fc","👩🏽❤️👩🏽":"1f469-1f3fd-200d-2764-fe0f-200d-1f469-1f3fd","👩🏽❤️👩🏾":"1f469-1f3fd-200d-2764-fe0f-200d-1f469-1f3fe","👩🏽❤️👩🏿":"1f469-1f3fd-200d-2764-fe0f-200d-1f469-1f3ff","👩🏾❤️👩🏻":"1f469-1f3fe-200d-2764-fe0f-200d-1f469-1f3fb","👩🏾❤️👩🏼":"1f469-1f3fe-200d-2764-fe0f-200d-1f469-1f3fc","👩🏾❤️👩🏽":"1f469-1f3fe-200d-2764-fe0f-200d-1f469-1f3fd","👩🏾❤️👩🏾":"1f469-1f3fe-200d-2764-fe0f-200d-1f469-1f3fe","👩🏾❤️👩🏿":"1f469-1f3fe-200d-2764-fe0f-200d-1f469-1f3ff","👩🏿❤️👩🏻":"1f469-1f3ff-200d-2764-fe0f-200d-1f469-1f3fb","👩🏿❤️👩🏼":"1f469-1f3ff-200d-2764-fe0f-200d-1f469-1f3fc","👩🏿❤️👩🏽":"1f469-1f3ff-200d-2764-fe0f-200d-1f469-1f3fd","👩🏿❤️👩🏾":"1f469-1f3ff-200d-2764-fe0f-200d-1f469-1f3fe","👩🏿❤️👩🏿":"1f469-1f3ff-200d-2764-fe0f-200d-1f469-1f3ff","🧑🏻❤💋🧑🏼":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fc","🧑🏻❤💋🧑🏽":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fd","🧑🏻❤💋🧑🏾":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fe","🧑🏻❤💋🧑🏿":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3ff","🧑🏼❤💋🧑🏻":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fb","🧑🏼❤💋🧑🏽":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fd","🧑🏼❤💋🧑🏾":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fe","🧑🏼❤💋🧑🏿":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3ff","🧑🏽❤💋🧑🏻":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fb","🧑🏽❤💋🧑🏼":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fc","🧑🏽❤💋🧑🏾":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fe","🧑🏽❤💋🧑🏿":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3ff","🧑🏾❤💋🧑🏻":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fb","🧑🏾❤💋🧑🏼":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fc","🧑🏾❤💋🧑🏽":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fd","🧑🏾❤💋🧑🏿":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3ff","🧑🏿❤💋🧑🏻":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fb","🧑🏿❤💋🧑🏼":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fc","🧑🏿❤💋🧑🏽":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fd","🧑🏿❤💋🧑🏾":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fe","👩🏻❤💋👨🏻":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👩🏻❤💋👨🏼":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👩🏻❤💋👨🏽":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👩🏻❤💋👨🏾":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👩🏻❤💋👨🏿":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👩🏼❤💋👨🏻":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👩🏼❤💋👨🏼":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👩🏼❤💋👨🏽":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👩🏼❤💋👨🏾":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👩🏼❤💋👨🏿":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👩🏽❤💋👨🏻":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👩🏽❤💋👨🏼":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👩🏽❤💋👨🏽":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👩🏽❤💋👨🏾":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👩🏽❤💋👨🏿":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👩🏾❤💋👨🏻":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👩🏾❤💋👨🏼":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👩🏾❤💋👨🏽":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👩🏾❤💋👨🏾":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👩🏾❤💋👨🏿":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👩🏿❤💋👨🏻":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👩🏿❤💋👨🏼":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👩🏿❤💋👨🏽":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👩🏿❤💋👨🏾":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👩🏿❤💋👨🏿":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👨🏻❤💋👨🏻":"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👨🏻❤💋👨🏼":"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👨🏻❤💋👨🏽":"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👨🏻❤💋👨🏾":"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👨🏻❤💋👨🏿":"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👨🏼❤💋👨🏻":"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👨🏼❤💋👨🏼":"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👨🏼❤💋👨🏽":"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👨🏼❤💋👨🏾":"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👨🏼❤💋👨🏿":"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👨🏽❤💋👨🏻":"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👨🏽❤💋👨🏼":"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👨🏽❤💋👨🏽":"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👨🏽❤💋👨🏾":"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👨🏽❤💋👨🏿":"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👨🏾❤💋👨🏻":"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👨🏾❤💋👨🏼":"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👨🏾❤💋👨🏽":"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👨🏾❤💋👨🏾":"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👨🏾❤💋👨🏿":"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👨🏿❤💋👨🏻":"1f468-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👨🏿❤💋👨🏼":"1f468-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👨🏿❤💋👨🏽":"1f468-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👨🏿❤💋👨🏾":"1f468-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👨🏿❤💋👨🏿":"1f468-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👩🏻❤💋👩🏻":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fb","👩🏻❤💋👩🏼":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fc","👩🏻❤💋👩🏽":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fd","👩🏻❤💋👩🏾":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fe","👩🏻❤💋👩🏿":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3ff","👩🏼❤💋👩🏻":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fb","👩🏼❤💋👩🏼":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fc","👩🏼❤💋👩🏽":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fd","👩🏼❤💋👩🏾":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fe","👩🏼❤💋👩🏿":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3ff","👩🏽❤💋👩🏻":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fb","👩🏽❤💋👩🏼":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fc","👩🏽❤💋👩🏽":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fd","👩🏽❤💋👩🏾":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fe","👩🏽❤💋👩🏿":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3ff","👩🏾❤💋👩🏻":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fb","👩🏾❤💋👩🏼":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fc","👩🏾❤💋👩🏽":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fd","👩🏾❤💋👩🏾":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fe","👩🏾❤💋👩🏿":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3ff","👩🏿❤💋👩🏻":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fb","👩🏿❤💋👩🏼":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fc","👩🏿❤💋👩🏽":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fd","👩🏿❤💋👩🏾":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fe","👩🏿❤💋👩🏿":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3ff","🧑🏻❤️💋🧑🏼":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fc","🧑🏻❤️💋🧑🏽":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fd","🧑🏻❤️💋🧑🏾":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fe","🧑🏻❤️💋🧑🏿":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3ff","🧑🏼❤️💋🧑🏻":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fb","🧑🏼❤️💋🧑🏽":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fd","🧑🏼❤️💋🧑🏾":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fe","🧑🏼❤️💋🧑🏿":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3ff","🧑🏽❤️💋🧑🏻":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fb","🧑🏽❤️💋🧑🏼":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fc","🧑🏽❤️💋🧑🏾":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fe","🧑🏽❤️💋🧑🏿":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3ff","🧑🏾❤️💋🧑🏻":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fb","🧑🏾❤️💋🧑🏼":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fc","🧑🏾❤️💋🧑🏽":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fd","🧑🏾❤️💋🧑🏿":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3ff","🧑🏿❤️💋🧑🏻":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fb","🧑🏿❤️💋🧑🏼":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fc","🧑🏿❤️💋🧑🏽":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fd","🧑🏿❤️💋🧑🏾":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fe","👩🏻❤️💋👨🏻":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👩🏻❤️💋👨🏼":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👩🏻❤️💋👨🏽":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👩🏻❤️💋👨🏾":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👩🏻❤️💋👨🏿":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👩🏼❤️💋👨🏻":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👩🏼❤️💋👨🏼":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👩🏼❤️💋👨🏽":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👩🏼❤️💋👨🏾":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👩🏼❤️💋👨🏿":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👩🏽❤️💋👨🏻":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👩🏽❤️💋👨🏼":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👩🏽❤️💋👨🏽":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👩🏽❤️💋👨🏾":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👩🏽❤️💋👨🏿":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👩🏾❤️💋👨🏻":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👩🏾❤️💋👨🏼":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👩🏾❤️💋👨🏽":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👩🏾❤️💋👨🏾":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👩🏾❤️💋👨🏿":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👩🏿❤️💋👨🏻":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👩🏿❤️💋👨🏼":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👩🏿❤️💋👨🏽":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👩🏿❤️💋👨🏾":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👩🏿❤️💋👨🏿":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👨🏻❤️💋👨🏻":"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👨🏻❤️💋👨🏼":"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👨🏻❤️💋👨🏽":"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👨🏻❤️💋👨🏾":"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👨🏻❤️💋👨🏿":"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👨🏼❤️💋👨🏻":"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👨🏼❤️💋👨🏼":"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👨🏼❤️💋👨🏽":"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👨🏼❤️💋👨🏾":"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👨🏼❤️💋👨🏿":"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👨🏽❤️💋👨🏻":"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👨🏽❤️💋👨🏼":"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👨🏽❤️💋👨🏽":"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👨🏽❤️💋👨🏾":"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👨🏽❤️💋👨🏿":"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👨🏾❤️💋👨🏻":"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👨🏾❤️💋👨🏼":"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👨🏾❤️💋👨🏽":"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👨🏾❤️💋👨🏾":"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👨🏾❤️💋👨🏿":"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👨🏿❤️💋👨🏻":"1f468-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👨🏿❤️💋👨🏼":"1f468-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👨🏿❤️💋👨🏽":"1f468-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👨🏿❤️💋👨🏾":"1f468-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👨🏿❤️💋👨🏿":"1f468-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👩🏻❤️💋👩🏻":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fb","👩🏻❤️💋👩🏼":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fc","👩🏻❤️💋👩🏽":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fd","👩🏻❤️💋👩🏾":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fe","👩🏻❤️💋👩🏿":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3ff","👩🏼❤️💋👩🏻":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fb","👩🏼❤️💋👩🏼":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fc","👩🏼❤️💋👩🏽":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fd","👩🏼❤️💋👩🏾":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fe","👩🏼❤️💋👩🏿":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3ff","👩🏽❤️💋👩🏻":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fb","👩🏽❤️💋👩🏼":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fc","👩🏽❤️💋👩🏽":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fd","👩🏽❤️💋👩🏾":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fe","👩🏽❤️💋👩🏿":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3ff","👩🏾❤️💋👩🏻":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fb","👩🏾❤️💋👩🏼":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fc","👩🏾❤️💋👩🏽":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fd","👩🏾❤️💋👩🏾":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fe","👩🏾❤️💋👩🏿":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3ff","👩🏿❤️💋👩🏻":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fb","👩🏿❤️💋👩🏼":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fc","👩🏿❤️💋👩🏽":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fd","👩🏿❤️💋👩🏾":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fe","👩🏿❤️💋👩🏿":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3ff"} \ No newline at end of file diff --git a/app/javascript/flavours/blobfox/features/emoji/emoji_mart_data_light.ts b/app/javascript/flavours/blobfox/features/emoji/emoji_mart_data_light.ts new file mode 100644 index 00000000000000..ffca1f8b0630a3 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/emoji/emoji_mart_data_light.ts @@ -0,0 +1,43 @@ +// The output of this module is designed to mimic emoji-mart's +// "data" object, such that we can use it for a light version of emoji-mart's +// emojiIndex.search functionality. +import type { BaseEmoji } from 'emoji-mart'; +import type { Emoji } from 'emoji-mart/dist-es/utils/data'; + +import type { Search, ShortCodesToEmojiData } from './emoji_compressed'; +import emojiCompressed from './emoji_compressed'; +import { unicodeToUnifiedName } from './unicode_to_unified_name'; + +type Emojis = { + [key in NonNullable<keyof ShortCodesToEmojiData>]: { + native: BaseEmoji['native']; + search: Search; + short_names: Emoji['short_names']; + unified: Emoji['unified']; + }; +}; + +const [ + shortCodesToEmojiData, + skins, + categories, + short_names, + _emojisWithoutShortCodes, +] = emojiCompressed; + +const emojis: Emojis = {}; + +// decompress +Object.keys(shortCodesToEmojiData).forEach((shortCode) => { + const [_filenameData, searchData] = shortCodesToEmojiData[shortCode]; + const [native, short_names, search, unified] = searchData; + + emojis[shortCode] = { + native, + search, + short_names: short_names ? [shortCode].concat(short_names) : undefined, + unified: unified ?? unicodeToUnifiedName(native), + }; +}); + +export { emojis, skins, categories, short_names }; diff --git a/app/javascript/flavours/blobfox/features/emoji/emoji_mart_search_light.js b/app/javascript/flavours/blobfox/features/emoji/emoji_mart_search_light.js new file mode 100644 index 00000000000000..83e154b0b287e5 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/emoji/emoji_mart_search_light.js @@ -0,0 +1,185 @@ +// This code is largely borrowed from: +// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/emoji-index.js + +import * as data from './emoji_mart_data_light'; +import { getData, getSanitizedData, uniq, intersect } from './emoji_utils'; + +let originalPool = {}; +let index = {}; +let emojisList = {}; +let emoticonsList = {}; +let customEmojisList = []; + +for (let emoji in data.emojis) { + let emojiData = data.emojis[emoji]; + let { short_names, emoticons } = emojiData; + let id = short_names[0]; + + if (emoticons) { + emoticons.forEach(emoticon => { + if (emoticonsList[emoticon]) { + return; + } + + emoticonsList[emoticon] = id; + }); + } + + emojisList[id] = getSanitizedData(id); + originalPool[id] = emojiData; +} + +function clearCustomEmojis(pool) { + customEmojisList.forEach((emoji) => { + let emojiId = emoji.id || emoji.short_names[0]; + + delete pool[emojiId]; + delete emojisList[emojiId]; + }); +} + +function addCustomToPool(custom, pool) { + if (customEmojisList.length) clearCustomEmojis(pool); + + custom.forEach((emoji) => { + let emojiId = emoji.id || emoji.short_names[0]; + + if (emojiId && !pool[emojiId]) { + pool[emojiId] = getData(emoji); + emojisList[emojiId] = getSanitizedData(emoji); + } + }); + + customEmojisList = custom; + index = {}; +} + +function search(value, { emojisToShowFilter, maxResults, include, exclude, custom } = {}) { + if (custom !== undefined) { + if (customEmojisList !== custom) + addCustomToPool(custom, originalPool); + } else { + custom = []; + } + + maxResults = maxResults || 75; + include = include || []; + exclude = exclude || []; + + let results = null, + pool = originalPool; + + if (value.length) { + if (value === '-' || value === '-1') { + return [emojisList['-1']]; + } + + let values = value.toLowerCase().split(/[\s|,\-_]+/), + allResults = []; + + if (values.length > 2) { + values = [values[0], values[1]]; + } + + if (include.length || exclude.length) { + pool = {}; + + data.categories.forEach(category => { + let isIncluded = include && include.length ? include.indexOf(category.name.toLowerCase()) > -1 : true; + let isExcluded = exclude && exclude.length ? exclude.indexOf(category.name.toLowerCase()) > -1 : false; + if (!isIncluded || isExcluded) { + return; + } + + category.emojis.forEach(emojiId => pool[emojiId] = data.emojis[emojiId]); + }); + + if (custom.length) { + let customIsIncluded = include && include.length ? include.indexOf('custom') > -1 : true; + let customIsExcluded = exclude && exclude.length ? exclude.indexOf('custom') > -1 : false; + if (customIsIncluded && !customIsExcluded) { + addCustomToPool(custom, pool); + } + } + } + + const searchValue = (value) => { + let aPool = pool, + aIndex = index, + length = 0; + + for (let charIndex = 0; charIndex < value.length; charIndex++) { + const char = value[charIndex]; + length++; + + aIndex[char] = aIndex[char] || {}; + aIndex = aIndex[char]; + + if (!aIndex.results) { + let scores = {}; + + aIndex.results = []; + aIndex.pool = {}; + + for (let id in aPool) { + let emoji = aPool[id], + { search } = emoji, + sub = value.slice(0, length), + subIndex = search.indexOf(sub); + + if (subIndex !== -1) { + let score = subIndex + 1; + if (sub === id) score = 0; + + aIndex.results.push(emojisList[id]); + aIndex.pool[id] = emoji; + + scores[id] = score; + } + } + + aIndex.results.sort((a, b) => { + let aScore = scores[a.id], + bScore = scores[b.id]; + + return aScore - bScore; + }); + } + + aPool = aIndex.pool; + } + + return aIndex.results; + }; + + if (values.length > 1) { + results = searchValue(value); + } else { + results = []; + } + + allResults = values.map(searchValue).filter(a => a); + + if (allResults.length > 1) { + allResults = intersect.apply(null, allResults); + } else if (allResults.length) { + allResults = allResults[0]; + } + + results = uniq(results.concat(allResults)); + } + + if (results) { + if (emojisToShowFilter) { + results = results.filter((result) => emojisToShowFilter(data.emojis[result.id])); + } + + if (results && results.length > maxResults) { + results = results.slice(0, maxResults); + } + } + + return results; +} + +export { search }; diff --git a/app/javascript/flavours/blobfox/features/emoji/emoji_picker.js b/app/javascript/flavours/blobfox/features/emoji/emoji_picker.js new file mode 100644 index 00000000000000..8725d39ecd78a3 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/emoji/emoji_picker.js @@ -0,0 +1,7 @@ +import Emoji from 'emoji-mart/dist-es/components/emoji/emoji'; +import Picker from 'emoji-mart/dist-es/components/picker/picker'; + +export { + Picker, + Emoji, +}; diff --git a/app/javascript/flavours/blobfox/features/emoji/emoji_unicode_mapping_light.ts b/app/javascript/flavours/blobfox/features/emoji/emoji_unicode_mapping_light.ts new file mode 100644 index 00000000000000..191419496fb2a9 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/emoji/emoji_unicode_mapping_light.ts @@ -0,0 +1,60 @@ +// A mapping of unicode strings to an object containing the filename +// (i.e. the svg filename) and a shortCode intended to be shown +// as a "title" attribute in an HTML element (aka tooltip). + +import type { + FilenameData, + ShortCodesToEmojiDataKey, +} from './emoji_compressed'; +import emojiCompressed from './emoji_compressed'; +import { unicodeToFilename } from './unicode_to_filename'; + +type UnicodeMapping = { + [key in FilenameData[number][0]]: { + shortCode: ShortCodesToEmojiDataKey; + filename: FilenameData[number][number]; + }; +}; + +const [ + shortCodesToEmojiData, + _skins, + _categories, + _short_names, + emojisWithoutShortCodes, +] = emojiCompressed; + +// decompress +const unicodeMapping: UnicodeMapping = {}; + +function processEmojiMapData( + emojiMapData: FilenameData[number], + shortCode?: ShortCodesToEmojiDataKey, +) { + const [native, _filename] = emojiMapData; + let filename = emojiMapData[1]; + if (!filename) { + // filename name can be derived from unicodeToFilename + filename = unicodeToFilename(native); + } + unicodeMapping[native] = { + shortCode, + filename, + }; +} + +Object.keys(shortCodesToEmojiData).forEach( + (shortCode: ShortCodesToEmojiDataKey) => { + if (shortCode === undefined) return; + const [filenameData, _searchData] = shortCodesToEmojiData[shortCode]; + filenameData.forEach((emojiMapData) => { + processEmojiMapData(emojiMapData, shortCode); + }); + }, +); + +emojisWithoutShortCodes.forEach((emojiMapData) => { + processEmojiMapData(emojiMapData); +}); + +export { unicodeMapping }; diff --git a/app/javascript/flavours/blobfox/features/emoji/emoji_utils.js b/app/javascript/flavours/blobfox/features/emoji/emoji_utils.js new file mode 100644 index 00000000000000..83bcc9d82f9f14 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/emoji/emoji_utils.js @@ -0,0 +1,258 @@ +// This code is largely borrowed from: +// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/index.js + +import * as data from './emoji_mart_data_light'; + +const buildSearch = (data) => { + const search = []; + + let addToSearch = (strings, split) => { + if (!strings) { + return; + } + + (Array.isArray(strings) ? strings : [strings]).forEach((string) => { + (split ? string.split(/[-|_|\s]+/) : [string]).forEach((s) => { + s = s.toLowerCase(); + + if (search.indexOf(s) === -1) { + search.push(s); + } + }); + }); + }; + + addToSearch(data.short_names, true); + addToSearch(data.name, true); + addToSearch(data.keywords, false); + addToSearch(data.emoticons, false); + + return search.join(','); +}; + +const _String = String; + +const stringFromCodePoint = _String.fromCodePoint || function () { + let MAX_SIZE = 0x4000; + let codeUnits = []; + let highSurrogate; + let lowSurrogate; + let index = -1; + let length = arguments.length; + if (!length) { + return ''; + } + let result = ''; + while (++index < length) { + let codePoint = Number(arguments[index]); + if ( + !isFinite(codePoint) || // `NaN`, `+Infinity`, or `-Infinity` + codePoint < 0 || // not a valid Unicode code point + codePoint > 0x10FFFF || // not a valid Unicode code point + Math.floor(codePoint) !== codePoint // not an integer + ) { + throw RangeError('Invalid code point: ' + codePoint); + } + if (codePoint <= 0xFFFF) { // BMP code point + codeUnits.push(codePoint); + } else { // Astral code point; split in surrogate halves + // http://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae + codePoint -= 0x10000; + highSurrogate = (codePoint >> 10) + 0xD800; + lowSurrogate = (codePoint % 0x400) + 0xDC00; + codeUnits.push(highSurrogate, lowSurrogate); + } + if (index + 1 === length || codeUnits.length > MAX_SIZE) { + result += String.fromCharCode.apply(null, codeUnits); + codeUnits.length = 0; + } + } + return result; +}; + + +const _JSON = JSON; + +const COLONS_REGEX = /^(?::([^:]+):)(?::skin-tone-(\d):)?$/; +const SKINS = [ + '1F3FA', '1F3FB', '1F3FC', + '1F3FD', '1F3FE', '1F3FF', +]; + +function unifiedToNative(unified) { + let unicodes = unified.split('-'), + codePoints = unicodes.map((u) => `0x${u}`); + + return stringFromCodePoint.apply(null, codePoints); +} + +function sanitize(emoji) { + let { name, short_names, skin_tone, skin_variations, emoticons, unified, custom, imageUrl } = emoji, + id = emoji.id || short_names[0], + colons = `:${id}:`; + + if (custom) { + return { + id, + name, + colons, + emoticons, + custom, + imageUrl, + }; + } + + if (skin_tone) { + colons += `:skin-tone-${skin_tone}:`; + } + + return { + id, + name, + colons, + emoticons, + unified: unified.toLowerCase(), + skin: skin_tone || (skin_variations ? 1 : null), + native: unifiedToNative(unified), + }; +} + +function getSanitizedData() { + return sanitize(getData(...arguments)); +} + +function getData(emoji, skin, set) { + let emojiData = {}; + + if (typeof emoji === 'string') { + let matches = emoji.match(COLONS_REGEX); + + if (matches) { + emoji = matches[1]; + + if (matches[2]) { + skin = parseInt(matches[2]); + } + } + + if (Object.prototype.hasOwnProperty.call(data.short_names, emoji)) { + emoji = data.short_names[emoji]; + } + + if (Object.prototype.hasOwnProperty.call(data.emojis, emoji)) { + emojiData = data.emojis[emoji]; + } + } else if (emoji.id) { + if (Object.prototype.hasOwnProperty.call(data.short_names, emoji.id)) { + emoji.id = data.short_names[emoji.id]; + } + + if (Object.prototype.hasOwnProperty.call(data.emojis, emoji.id)) { + emojiData = data.emojis[emoji.id]; + skin = skin || emoji.skin; + } + } + + if (!Object.keys(emojiData).length) { + emojiData = emoji; + emojiData.custom = true; + + if (!emojiData.search) { + emojiData.search = buildSearch(emoji); + } + } + + emojiData.emoticons = emojiData.emoticons || []; + emojiData.variations = emojiData.variations || []; + + if (emojiData.skin_variations && skin > 1 && set) { + emojiData = JSON.parse(_JSON.stringify(emojiData)); + + let skinKey = SKINS[skin - 1], + variationData = emojiData.skin_variations[skinKey]; + + if (!variationData.variations && emojiData.variations) { + delete emojiData.variations; + } + + if (variationData[`has_img_${set}`]) { + emojiData.skin_tone = skin; + + for (let k in variationData) { + let v = variationData[k]; + emojiData[k] = v; + } + } + } + + if (emojiData.variations && emojiData.variations.length) { + emojiData = JSON.parse(_JSON.stringify(emojiData)); + emojiData.unified = emojiData.variations.shift(); + } + + return emojiData; +} + +function uniq(arr) { + return arr.reduce((acc, item) => { + if (acc.indexOf(item) === -1) { + acc.push(item); + } + return acc; + }, []); +} + +function intersect(a, b) { + const uniqA = uniq(a); + const uniqB = uniq(b); + + return uniqA.filter(item => uniqB.indexOf(item) >= 0); +} + +function deepMerge(a, b) { + let o = {}; + + for (let key in a) { + let originalValue = a[key], + value = originalValue; + + if (Object.prototype.hasOwnProperty.call(b, key)) { + value = b[key]; + } + + if (typeof value === 'object') { + value = deepMerge(originalValue, value); + } + + o[key] = value; + } + + return o; +} + +// https://github.com/sonicdoe/measure-scrollbar +function measureScrollbar() { + const div = document.createElement('div'); + + div.style.width = '100px'; + div.style.height = '100px'; + div.style.overflow = 'scroll'; + div.style.position = 'absolute'; + div.style.top = '-9999px'; + + document.body.appendChild(div); + const scrollbarWidth = div.offsetWidth - div.clientWidth; + document.body.removeChild(div); + + return scrollbarWidth; +} + +export { + getData, + getSanitizedData, + uniq, + intersect, + deepMerge, + unifiedToNative, + measureScrollbar, +}; diff --git a/app/javascript/flavours/blobfox/features/emoji/unicode_to_filename.js b/app/javascript/flavours/blobfox/features/emoji/unicode_to_filename.js new file mode 100644 index 00000000000000..3395c77174cc9d --- /dev/null +++ b/app/javascript/flavours/blobfox/features/emoji/unicode_to_filename.js @@ -0,0 +1,29 @@ +/* eslint-disable import/no-commonjs -- + We need to use CommonJS here as its imported into a preval file (`emoji_compressed.js`) */ + +// taken from: +// https://github.com/twitter/twemoji/blob/47732c7/twemoji-generator.js#L848-L866 +exports.unicodeToFilename = (str) => { + let result = ''; + let charCode = 0; + let p = 0; + let i = 0; + while (i < str.length) { + charCode = str.charCodeAt(i++); + if (p) { + if (result.length > 0) { + result += '-'; + } + result += (0x10000 + ((p - 0xD800) << 10) + (charCode - 0xDC00)).toString(16); + p = 0; + } else if (0xD800 <= charCode && charCode <= 0xDBFF) { + p = charCode; + } else { + if (result.length > 0) { + result += '-'; + } + result += charCode.toString(16); + } + } + return result; +}; diff --git a/app/javascript/flavours/blobfox/features/emoji/unicode_to_unified_name.js b/app/javascript/flavours/blobfox/features/emoji/unicode_to_unified_name.js new file mode 100644 index 00000000000000..108b911222adc0 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/emoji/unicode_to_unified_name.js @@ -0,0 +1,24 @@ +/* eslint-disable import/no-commonjs -- + We need to use CommonJS here as its imported into a preval file (`emoji_compressed.js`) */ + +function padLeft(str, num) { + while (str.length < num) { + str = '0' + str; + } + + return str; +} + +exports.unicodeToUnifiedName = (str) => { + let output = ''; + + for (let i = 0; i < str.length; i += 2) { + if (i > 0) { + output += '-'; + } + + output += padLeft(str.codePointAt(i).toString(16).toUpperCase(), 4); + } + + return output; +}; diff --git a/app/javascript/flavours/blobfox/features/explore/components/search_section.jsx b/app/javascript/flavours/blobfox/features/explore/components/search_section.jsx new file mode 100644 index 00000000000000..c84e3f7cef6f84 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/explore/components/search_section.jsx @@ -0,0 +1,20 @@ +import PropTypes from 'prop-types'; + +import { FormattedMessage } from 'react-intl'; + +export const SearchSection = ({ title, onClickMore, children }) => ( + <div className='search-results__section'> + <div className='search-results__section__header'> + <h3>{title}</h3> + {onClickMore && <button onClick={onClickMore}><FormattedMessage id='search_results.see_all' defaultMessage='See all' /></button>} + </div> + + {children} + </div> +); + +SearchSection.propTypes = { + title: PropTypes.node.isRequired, + onClickMore: PropTypes.func, + children: PropTypes.children, +}; \ No newline at end of file diff --git a/app/javascript/flavours/blobfox/features/explore/components/story.jsx b/app/javascript/flavours/blobfox/features/explore/components/story.jsx new file mode 100644 index 00000000000000..da1a170580d667 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/explore/components/story.jsx @@ -0,0 +1,61 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import { Blurhash } from 'flavours/blobfox/components/blurhash'; +import { accountsCountRenderer } from 'flavours/blobfox/components/hashtag'; +import { RelativeTimestamp } from 'flavours/blobfox/components/relative_timestamp'; +import { ShortNumber } from 'flavours/blobfox/components/short_number'; +import { Skeleton } from 'flavours/blobfox/components/skeleton'; + +export default class Story extends PureComponent { + + static propTypes = { + url: PropTypes.string, + title: PropTypes.string, + lang: PropTypes.string, + publisher: PropTypes.string, + publishedAt: PropTypes.string, + author: PropTypes.string, + sharedTimes: PropTypes.number, + thumbnail: PropTypes.string, + thumbnailDescription: PropTypes.string, + blurhash: PropTypes.string, + expanded: PropTypes.bool, + }; + + state = { + thumbnailLoaded: false, + }; + + handleImageLoad = () => this.setState({ thumbnailLoaded: true }); + + render () { + const { expanded, url, title, lang, publisher, author, publishedAt, sharedTimes, thumbnail, thumbnailDescription, blurhash } = this.props; + + const { thumbnailLoaded } = this.state; + + return ( + <a className={classNames('story', { expanded })} href={url} target='blank' rel='noopener'> + <div className='story__details'> + <div className='story__details__publisher'>{publisher ? <span lang={lang}>{publisher}</span> : <Skeleton width={50} />}{publishedAt && <> · <RelativeTimestamp timestamp={publishedAt} /></>}</div> + <div className='story__details__title' lang={lang}>{title ? title : <Skeleton />}</div> + <div className='story__details__shared'>{author && <><FormattedMessage id='link_preview.author' defaultMessage='By {name}' values={{ name: <strong>{author}</strong> }} /> · </>}{typeof sharedTimes === 'number' ? <ShortNumber value={sharedTimes} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}</div> + </div> + + <div className='story__thumbnail'> + {thumbnail ? ( + <> + <div className={classNames('story__thumbnail__preview', { 'story__thumbnail__preview--hidden': thumbnailLoaded })}><Blurhash hash={blurhash} /></div> + <img src={thumbnail} onLoad={this.handleImageLoad} alt={thumbnailDescription} title={thumbnailDescription} lang={lang} /> + </> + ) : <Skeleton />} + </div> + </a> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/explore/index.jsx b/app/javascript/flavours/blobfox/features/explore/index.jsx new file mode 100644 index 00000000000000..a63368f0f5eee2 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/explore/index.jsx @@ -0,0 +1,114 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; +import { NavLink, Switch, Route } from 'react-router-dom'; + +import { connect } from 'react-redux'; + +import Column from 'flavours/blobfox/components/column'; +import ColumnHeader from 'flavours/blobfox/components/column_header'; +import Search from 'flavours/blobfox/features/compose/containers/search_container'; +import { trendsEnabled } from 'flavours/blobfox/initial_state'; + +import Links from './links'; +import SearchResults from './results'; +import Statuses from './statuses'; +import Suggestions from './suggestions'; +import Tags from './tags'; + +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']) || !trendsEnabled, +}); + +class Explore extends PureComponent { + + static contextTypes = { + 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 ( + <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}> + <ColumnHeader + icon={isSearching ? 'search' : 'hashtag'} + title={intl.formatMessage(isSearching ? messages.searchResults : messages.title)} + onClick={this.handleHeaderClick} + multiColumn={multiColumn} + /> + + <div className='explore__search-header'> + <Search /> + </div> + + {isSearching ? ( + <SearchResults /> + ) : ( + <> + <div className='account__section-headline'> + <NavLink exact to='/explore'> + <FormattedMessage tagName='div' id='explore.trending_statuses' defaultMessage='Posts' /> + </NavLink> + + <NavLink exact to='/explore/tags'> + <FormattedMessage tagName='div' id='explore.trending_tags' defaultMessage='Hashtags' /> + </NavLink> + + {signedIn && ( + <NavLink exact to='/explore/suggestions'> + <FormattedMessage tagName='div' id='explore.suggested_follows' defaultMessage='People' /> + </NavLink> + )} + + <NavLink exact to='/explore/links'> + <FormattedMessage tagName='div' id='explore.trending_links' defaultMessage='News' /> + </NavLink> + </div> + + <Switch> + <Route path='/explore/tags' component={Tags} /> + <Route path='/explore/links' component={Links} /> + <Route path='/explore/suggestions' component={Suggestions} /> + <Route exact path={['/explore', '/explore/posts', '/search']}> + <Statuses multiColumn={multiColumn} /> + </Route> + </Switch> + + <Helmet> + <title>{intl.formatMessage(messages.title)}</title> + <meta name='robots' content={isSearching ? 'noindex' : 'all'} /> + </Helmet> + </> + )} + </Column> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(Explore)); diff --git a/app/javascript/flavours/blobfox/features/explore/links.jsx b/app/javascript/flavours/blobfox/features/explore/links.jsx new file mode 100644 index 00000000000000..83836304f616de --- /dev/null +++ b/app/javascript/flavours/blobfox/features/explore/links.jsx @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +import { fetchTrendingLinks } from 'flavours/blobfox/actions/trends'; +import { DismissableBanner } from 'flavours/blobfox/components/dismissable_banner'; +import { LoadingIndicator } from 'flavours/blobfox/components/loading_indicator'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +import Story from './components/story'; + +const mapStateToProps = state => ({ + links: state.getIn(['trends', 'links', 'items']), + isLoading: state.getIn(['trends', 'links', 'isLoading']), +}); + +class Links extends PureComponent { + + static propTypes = { + links: ImmutablePropTypes.list, + isLoading: PropTypes.bool, + dispatch: PropTypes.func.isRequired, + ...WithRouterPropTypes, + }; + + componentDidMount () { + const { dispatch, links, history } = this.props; + + // If we're navigating back to the screen, do not trigger a reload + if (history.action === 'POP' && links.size > 0) { + return; + } + + dispatch(fetchTrendingLinks()); + } + + render () { + const { isLoading, links } = this.props; + + const banner = ( + <DismissableBanner id='explore/links'> + <FormattedMessage id='dismissable_banner.explore_links' defaultMessage='These are news stories being shared the most on the social web today. Newer news stories posted by more different people are ranked higher.' /> + </DismissableBanner> + ); + + if (!isLoading && links.isEmpty()) { + return ( + <div className='explore__links scrollable scrollable--flex'> + {banner} + + <div className='empty-column-indicator'> + <FormattedMessage id='empty_column.explore_statuses' defaultMessage='Nothing is trending right now. Check back later!' /> + </div> + </div> + ); + } + + return ( + <div className='explore__links scrollable' data-nosnippet> + {banner} + + {isLoading ? (<LoadingIndicator />) : links.map((link, i) => ( + <Story + key={link.get('id')} + expanded={i === 0} + lang={link.get('language')} + url={link.get('url')} + title={link.get('title')} + publisher={link.get('provider_name')} + publishedAt={link.get('published_at')} + author={link.get('author_name')} + sharedTimes={link.getIn(['history', 0, 'accounts']) * 1 + link.getIn(['history', 1, 'accounts']) * 1} + thumbnail={link.get('image')} + thumbnailDescription={link.get('image_description')} + blurhash={link.get('blurhash')} + /> + ))} + </div> + ); + } + +} + +export default connect(mapStateToProps)(withRouter(Links)); diff --git a/app/javascript/flavours/blobfox/features/explore/results.jsx b/app/javascript/flavours/blobfox/features/explore/results.jsx new file mode 100644 index 00000000000000..63d2a41af67bba --- /dev/null +++ b/app/javascript/flavours/blobfox/features/explore/results.jsx @@ -0,0 +1,229 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import { List as ImmutableList } from 'immutable'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +import { submitSearch, expandSearch } from 'flavours/blobfox/actions/search'; +import { ImmutableHashtag as Hashtag } from 'flavours/blobfox/components/hashtag'; +import { Icon } from 'flavours/blobfox/components/icon'; +import ScrollableList from 'flavours/blobfox/components/scrollable_list'; +import Account from 'flavours/blobfox/containers/account_container'; +import Status from 'flavours/blobfox/containers/status_container'; + +import { SearchSection } from './components/search_section'; + +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']), + submittedType: state.getIn(['search', 'type']), +}); + +const INITIAL_PAGE_LIMIT = 10; +const INITIAL_DISPLAY = 4; + +const hidePeek = list => { + if (list.size > INITIAL_PAGE_LIMIT && list.size % INITIAL_PAGE_LIMIT === 1) { + return list.skipLast(1); + } else { + return list; + } +}; + +const renderAccounts = accounts => hidePeek(accounts).map(id => ( + <Account key={id} id={id} /> +)); + +const renderHashtags = hashtags => hidePeek(hashtags).map(hashtag => ( + <Hashtag key={hashtag.get('name')} hashtag={hashtag} /> +)); + +const renderStatuses = statuses => hidePeek(statuses).map(id => ( + <Status key={id} id={id} /> +)); + +class Results extends PureComponent { + + static propTypes = { + results: ImmutablePropTypes.contains({ + accounts: ImmutablePropTypes.orderedSet, + statuses: ImmutablePropTypes.orderedSet, + hashtags: ImmutablePropTypes.orderedSet, + }), + isLoading: PropTypes.bool, + multiColumn: PropTypes.bool, + dispatch: PropTypes.func.isRequired, + q: PropTypes.string, + intl: PropTypes.object, + submittedType: PropTypes.oneOf(['accounts', 'statuses', 'hashtags']), + }; + + state = { + type: this.props.submittedType || 'all', + }; + + static getDerivedStateFromProps(props, state) { + if (props.submittedType !== state.type) { + return { + type: props.submittedType || 'all', + }; + } + + return null; + } + + handleSelectAll = () => { + const { submittedType, dispatch } = this.props; + + // If we originally searched for a specific type, we need to resubmit + // the query to get all types of results + if (submittedType) { + dispatch(submitSearch()); + } + + this.setState({ type: 'all' }); + }; + + handleSelectAccounts = () => { + const { submittedType, dispatch } = this.props; + + // If we originally searched for something else (but not everything), + // we need to resubmit the query for this specific type + if (submittedType !== 'accounts') { + dispatch(submitSearch('accounts')); + } + + this.setState({ type: 'accounts' }); + }; + + handleSelectHashtags = () => { + const { submittedType, dispatch } = this.props; + + // If we originally searched for something else (but not everything), + // we need to resubmit the query for this specific type + if (submittedType !== 'hashtags') { + dispatch(submitSearch('hashtags')); + } + + this.setState({ type: 'hashtags' }); + }; + + handleSelectStatuses = () => { + const { submittedType, dispatch } = this.props; + + // If we originally searched for something else (but not everything), + // we need to resubmit the query for this specific type + if (submittedType !== 'statuses') { + dispatch(submitSearch('statuses')); + } + + 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)); + } + + handleLoadMore = () => { + const { type } = this.state; + + if (type !== 'all') { + this._loadMore(type); + } + }; + + render () { + const { intl, isLoading, q, results } = this.props; + const { type } = this.state; + + // We request 1 more result than we display so we can tell if there'd be a next page + const hasMore = type !== 'all' ? results.get(type, ImmutableList()).size > INITIAL_PAGE_LIMIT && results.get(type).size % INITIAL_PAGE_LIMIT === 1 : false; + + let filteredResults; + + const accounts = results.get('accounts', ImmutableList()); + const hashtags = results.get('hashtags', ImmutableList()); + const statuses = results.get('statuses', ImmutableList()); + + switch(type) { + case 'all': + filteredResults = (accounts.size + hashtags.size + statuses.size) > 0 ? ( + <> + {accounts.size > 0 && ( + <SearchSection key='accounts' title={<><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></>} onClickMore={this.handleLoadMoreAccounts}> + {accounts.take(INITIAL_DISPLAY).map(id => <Account key={id} id={id} />)} + </SearchSection> + )} + + {hashtags.size > 0 && ( + <SearchSection key='hashtags' title={<><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></>} onClickMore={this.handleLoadMoreHashtags}> + {hashtags.take(INITIAL_DISPLAY).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)} + </SearchSection> + )} + + {statuses.size > 0 && ( + <SearchSection key='statuses' title={<><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></>} onClickMore={this.handleLoadMoreStatuses}> + {statuses.take(INITIAL_DISPLAY).map(id => <Status key={id} id={id} />)} + </SearchSection> + )} + </> + ) : []; + break; + case 'accounts': + filteredResults = renderAccounts(accounts); + break; + case 'hashtags': + filteredResults = renderHashtags(hashtags); + break; + case 'statuses': + filteredResults = renderStatuses(statuses); + break; + } + + return ( + <> + <div className='account__section-headline'> + <button onClick={this.handleSelectAll} className={type === 'all' ? 'active' : undefined}><FormattedMessage id='search_results.all' defaultMessage='All' /></button> + <button onClick={this.handleSelectAccounts} className={type === 'accounts' ? 'active' : undefined}><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></button> + <button onClick={this.handleSelectHashtags} className={type === 'hashtags' ? 'active' : undefined}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button> + <button onClick={this.handleSelectStatuses} className={type === 'statuses' ? 'active' : undefined}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button> + </div> + + <div className='explore__search-results' data-nosnippet> + <ScrollableList + scrollKey='search-results' + isLoading={isLoading} + onLoadMore={this.handleLoadMore} + hasMore={hasMore} + emptyMessage={<FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' />} + bindToDocument + > + {filteredResults} + </ScrollableList> + </div> + + <Helmet> + <title>{intl.formatMessage(messages.title, { q })}</title> + </Helmet> + </> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(Results)); diff --git a/app/javascript/flavours/blobfox/features/explore/statuses.jsx b/app/javascript/flavours/blobfox/features/explore/statuses.jsx new file mode 100644 index 00000000000000..3039709fe78fd2 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/explore/statuses.jsx @@ -0,0 +1,78 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +import { debounce } from 'lodash'; + + +import { fetchTrendingStatuses, expandTrendingStatuses } from 'flavours/blobfox/actions/trends'; +import { DismissableBanner } from 'flavours/blobfox/components/dismissable_banner'; +import StatusList from 'flavours/blobfox/components/status_list'; +import { getStatusList } from 'flavours/blobfox/selectors'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +const mapStateToProps = state => ({ + statusIds: getStatusList(state, 'trending'), + isLoading: state.getIn(['status_lists', 'trending', 'isLoading'], true), + hasMore: !!state.getIn(['status_lists', 'trending', 'next']), +}); + +class Statuses extends PureComponent { + + static propTypes = { + statusIds: ImmutablePropTypes.list, + isLoading: PropTypes.bool, + hasMore: PropTypes.bool, + multiColumn: PropTypes.bool, + dispatch: PropTypes.func.isRequired, + ...WithRouterPropTypes, + }; + + componentDidMount () { + const { dispatch, statusIds, history } = this.props; + + // If we're navigating back to the screen, do not trigger a reload + if (history.action === 'POP' && statusIds.size > 0) { + return; + } + + dispatch(fetchTrendingStatuses()); + } + + handleLoadMore = debounce(() => { + const { dispatch } = this.props; + dispatch(expandTrendingStatuses()); + }, 300, { leading: true }); + + render () { + const { isLoading, hasMore, statusIds, multiColumn } = this.props; + + const emptyMessage = <FormattedMessage id='empty_column.explore_statuses' defaultMessage='Nothing is trending right now. Check back later!' />; + + return ( + <StatusList + trackScroll + prepend={<DismissableBanner id='explore/statuses'><FormattedMessage id='dismissable_banner.explore_statuses' defaultMessage='These are posts from across the social web that are gaining traction today. Newer posts with more boosts and favorites are ranked higher.' /></DismissableBanner>} + alwaysPrepend + timelineId='explore' + statusIds={statusIds} + scrollKey='explore-statuses' + hasMore={hasMore} + isLoading={isLoading} + onLoadMore={this.handleLoadMore} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + withCounters + /> + ); + } + +} + +export default connect(mapStateToProps)(withRouter(Statuses)); diff --git a/app/javascript/flavours/blobfox/features/explore/suggestions.jsx b/app/javascript/flavours/blobfox/features/explore/suggestions.jsx new file mode 100644 index 00000000000000..405b05c2f5eb06 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/explore/suggestions.jsx @@ -0,0 +1,70 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +import { fetchSuggestions, dismissSuggestion } from 'flavours/blobfox/actions/suggestions'; +import { LoadingIndicator } from 'flavours/blobfox/components/loading_indicator'; +import AccountCard from 'flavours/blobfox/features/directory/components/account_card'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +const mapStateToProps = state => ({ + suggestions: state.getIn(['suggestions', 'items']), + isLoading: state.getIn(['suggestions', 'isLoading']), +}); + +class Suggestions extends PureComponent { + + static propTypes = { + isLoading: PropTypes.bool, + suggestions: ImmutablePropTypes.list, + dispatch: PropTypes.func.isRequired, + ...WithRouterPropTypes, + }; + + componentDidMount () { + const { dispatch, suggestions, history } = this.props; + + // If we're navigating back to the screen, do not trigger a reload + if (history.action === 'POP' && suggestions.size > 0) { + return; + } + + dispatch(fetchSuggestions(true)); + } + + handleDismiss = (accountId) => { + const { dispatch } = this.props; + dispatch(dismissSuggestion(accountId)); + }; + + render () { + const { isLoading, suggestions } = this.props; + + if (!isLoading && suggestions.isEmpty()) { + return ( + <div className='explore__suggestions scrollable scrollable--flex'> + <div className='empty-column-indicator'> + <FormattedMessage id='empty_column.explore_statuses' defaultMessage='Nothing is trending right now. Check back later!' /> + </div> + </div> + ); + } + + return ( + <div className='explore__suggestions scrollable' data-nosnippet> + {isLoading ? <LoadingIndicator /> : suggestions.map(suggestion => ( + <AccountCard key={suggestion.get('account')} id={suggestion.get('account')} onDismiss={suggestion.get('source') === 'past_interactions' ? this.handleDismiss : null} /> + ))} + </div> + ); + } + +} + +export default connect(mapStateToProps)(withRouter(Suggestions)); diff --git a/app/javascript/flavours/blobfox/features/explore/tags.jsx b/app/javascript/flavours/blobfox/features/explore/tags.jsx new file mode 100644 index 00000000000000..c08acac515ccce --- /dev/null +++ b/app/javascript/flavours/blobfox/features/explore/tags.jsx @@ -0,0 +1,76 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +import { fetchTrendingHashtags } from 'flavours/blobfox/actions/trends'; +import { DismissableBanner } from 'flavours/blobfox/components/dismissable_banner'; +import { ImmutableHashtag as Hashtag } from 'flavours/blobfox/components/hashtag'; +import { LoadingIndicator } from 'flavours/blobfox/components/loading_indicator'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +const mapStateToProps = state => ({ + hashtags: state.getIn(['trends', 'tags', 'items']), + isLoadingHashtags: state.getIn(['trends', 'tags', 'isLoading']), +}); + +class Tags extends PureComponent { + + static propTypes = { + hashtags: ImmutablePropTypes.list, + isLoading: PropTypes.bool, + dispatch: PropTypes.func.isRequired, + ...WithRouterPropTypes, + }; + + componentDidMount () { + const { dispatch, history, hashtags } = this.props; + + // If we're navigating back to the screen, do not trigger a reload + if (history.action === 'POP' && hashtags.size > 0) { + return; + } + + dispatch(fetchTrendingHashtags()); + } + + render () { + const { isLoading, hashtags } = this.props; + + const banner = ( + <DismissableBanner id='explore/tags'> + <FormattedMessage id='dismissable_banner.explore_tags' defaultMessage='These are hashtags that are gaining traction on the social web today. Hashtags that are used by more different people are ranked higher.' /> + </DismissableBanner> + ); + + if (!isLoading && hashtags.isEmpty()) { + return ( + <div className='explore__links scrollable scrollable--flex'> + {banner} + + <div className='empty-column-indicator'> + <FormattedMessage id='empty_column.explore_statuses' defaultMessage='Nothing is trending right now. Check back later!' /> + </div> + </div> + ); + } + + return ( + <div className='scrollable explore__links' data-nosnippet> + {banner} + + {isLoading ? (<LoadingIndicator />) : hashtags.map(hashtag => ( + <Hashtag key={hashtag.get('name')} hashtag={hashtag} /> + ))} + </div> + ); + } + +} + +export default connect(mapStateToProps)(withRouter(Tags)); diff --git a/app/javascript/flavours/blobfox/features/favourited_statuses/index.jsx b/app/javascript/flavours/blobfox/features/favourited_statuses/index.jsx new file mode 100644 index 00000000000000..2be1489dc325c7 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/favourited_statuses/index.jsx @@ -0,0 +1,113 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { debounce } from 'lodash'; + +import { addColumn, removeColumn, moveColumn } from 'flavours/blobfox/actions/columns'; +import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'flavours/blobfox/actions/favourites'; +import ColumnHeader from 'flavours/blobfox/components/column_header'; +import StatusList from 'flavours/blobfox/components/status_list'; +import Column from 'flavours/blobfox/features/ui/components/column'; +import { getStatusList } from 'flavours/blobfox/selectors'; + +const messages = defineMessages({ + heading: { id: 'column.favourites', defaultMessage: 'Favorites' }, +}); + +const mapStateToProps = state => ({ + statusIds: getStatusList(state, 'favourites'), + isLoading: state.getIn(['status_lists', 'favourites', 'isLoading'], true), + hasMore: !!state.getIn(['status_lists', 'favourites', 'next']), +}); + +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, + }; + + UNSAFE_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 = <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any favorite posts yet. When you favorite one, it will show up here." />; + + return ( + <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}> + <ColumnHeader + icon='star' + title={intl.formatMessage(messages.heading)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + showBackButton + /> + + <StatusList + trackScroll={!pinned} + statusIds={statusIds} + scrollKey={`favourited_statuses-${columnId}`} + hasMore={hasMore} + isLoading={isLoading} + onLoadMore={this.handleLoadMore} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + /> + + <Helmet> + <title>{intl.formatMessage(messages.heading)}</title> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(Favourites)); diff --git a/app/javascript/flavours/blobfox/features/favourites/index.jsx b/app/javascript/flavours/blobfox/features/favourites/index.jsx new file mode 100644 index 00000000000000..4451d047c2a433 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/favourites/index.jsx @@ -0,0 +1,114 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { debounce } from 'lodash'; + +import { fetchFavourites, expandFavourites } from 'flavours/blobfox/actions/interactions'; +import ColumnHeader from 'flavours/blobfox/components/column_header'; +import { Icon } from 'flavours/blobfox/components/icon'; +import { LoadingIndicator } from 'flavours/blobfox/components/loading_indicator'; +import ScrollableList from 'flavours/blobfox/components/scrollable_list'; +import AccountContainer from 'flavours/blobfox/containers/account_container'; +import Column from 'flavours/blobfox/features/ui/components/column'; + +const messages = defineMessages({ + heading: { id: 'column.favourited_by', defaultMessage: 'Favourited by' }, + refresh: { id: 'refresh', defaultMessage: 'Refresh' }, +}); + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'items']), + hasMore: !!state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'next']), + isLoading: state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'isLoading'], true), +}); + +class Favourites extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + hasMore: PropTypes.bool, + isLoading: PropTypes.bool, + multiColumn: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + UNSAFE_componentWillMount () { + if (!this.props.accountIds) { + this.props.dispatch(fetchFavourites(this.props.params.statusId)); + } + } + + handleHeaderClick = () => { + this.column.scrollTop(); + }; + + setRef = c => { + this.column = c; + }; + + handleRefresh = () => { + this.props.dispatch(fetchFavourites(this.props.params.statusId)); + }; + + handleLoadMore = debounce(() => { + this.props.dispatch(expandFavourites(this.props.params.statusId)); + }, 300, { leading: true }); + + render () { + const { intl, accountIds, hasMore, isLoading, multiColumn } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + const emptyMessage = <FormattedMessage id='empty_column.favourites' defaultMessage='No one has favorited this post yet. When someone does, they will show up here.' />; + + return ( + <Column ref={this.setRef}> + <ColumnHeader + icon='star' + title={intl.formatMessage(messages.heading)} + onClick={this.handleHeaderClick} + showBackButton + multiColumn={multiColumn} + extraButton={( + <button type='button' className='column-header__button' title={intl.formatMessage(messages.refresh)} aria-label={intl.formatMessage(messages.refresh)} onClick={this.handleRefresh}><Icon id='refresh' /></button> + )} + /> + + <ScrollableList + scrollKey='favourites' + onLoadMore={this.handleLoadMore} + hasMore={hasMore} + isLoading={isLoading} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {accountIds.map(id => + <AccountContainer key={id} id={id} withNote={false} />, + )} + </ScrollableList> + + <Helmet> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(Favourites)); diff --git a/app/javascript/flavours/blobfox/features/filters/added_to_filter.jsx b/app/javascript/flavours/blobfox/features/filters/added_to_filter.jsx new file mode 100644 index 00000000000000..e4264b90ceaccb --- /dev/null +++ b/app/javascript/flavours/blobfox/features/filters/added_to_filter.jsx @@ -0,0 +1,106 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +import { Button } from 'flavours/blobfox/components/button'; +import { toServerSideType } from 'flavours/blobfox/utils/filters'; + +const mapStateToProps = (state, { filterId }) => ({ + filter: state.getIn(['filters', filterId]), +}); + +class AddedToFilter extends 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 = ( + <> + <h4 className='report-dialog-modal__subtitle'><FormattedMessage id='filter_modal.added.expired_title' defaultMessage='Expired filter!' /></h4> + <p className='report-dialog-modal__lead'> + <FormattedMessage + id='filter_modal.added.expired_explanation' + defaultMessage='This filter category has expired, you will need to change the expiration date for it to apply.' + /> + </p> + </> + ); + } + + let contextMismatchMessage = null; + if (contextType && !filter.get('context').includes(toServerSideType(contextType))) { + contextMismatchMessage = ( + <> + <h4 className='report-dialog-modal__subtitle'><FormattedMessage id='filter_modal.added.context_mismatch_title' defaultMessage='Context mismatch!' /></h4> + <p className='report-dialog-modal__lead'> + <FormattedMessage + id='filter_modal.added.context_mismatch_explanation' + defaultMessage='This filter category does not apply to the context in which you have accessed this post. If you want the post to be filtered in this context too, you will have to edit the filter.' + /> + </p> + </> + ); + } + + const settings_link = ( + <a href={`/filters/${filter.get('id')}/edit`}> + <FormattedMessage + id='filter_modal.added.settings_link' + defaultMessage='settings page' + /> + </a> + ); + + return ( + <> + <h3 className='report-dialog-modal__title'><FormattedMessage id='filter_modal.added.title' defaultMessage='Filter added!' /></h3> + <p className='report-dialog-modal__lead'> + <FormattedMessage + id='filter_modal.added.short_explanation' + defaultMessage='This post has been added to the following filter category: {title}.' + values={{ title: filter.get('title') }} + /> + </p> + + {expiredMessage} + {contextMismatchMessage} + + <h4 className='report-dialog-modal__subtitle'><FormattedMessage id='filter_modal.added.review_and_configure_title' defaultMessage='Filter settings' /></h4> + <p className='report-dialog-modal__lead'> + <FormattedMessage + id='filter_modal.added.review_and_configure' + defaultMessage='To review and further configure this filter category, go to the {settings_link}.' + values={{ settings_link }} + /> + </p> + + <div className='flex-spacer' /> + + <div className='report-dialog-modal__actions'> + <Button onClick={this.handleCloseClick}><FormattedMessage id='report.close' defaultMessage='Done' /></Button> + </div> + </> + ); + } + +} + +export default connect(mapStateToProps)(AddedToFilter); diff --git a/app/javascript/flavours/blobfox/features/filters/select_filter.jsx b/app/javascript/flavours/blobfox/features/filters/select_filter.jsx new file mode 100644 index 00000000000000..23ce2cc73b8a39 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/filters/select_filter.jsx @@ -0,0 +1,196 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { connect } from 'react-redux'; + +import fuzzysort from 'fuzzysort'; + +import { Icon } from 'flavours/blobfox/components/icon'; +import { toServerSideType } from 'flavours/blobfox/utils/filters'; +import { loupeIcon, deleteIcon } from 'flavours/blobfox/utils/icons'; + +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)), + ]), +}); + +class SelectFilter extends 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 = ( + <span className='language-dropdown__dropdown__results__item__common-name'> + ( + {filter[3] && <FormattedMessage id='filter_modal.select_filter.expired' defaultMessage='expired' />} + {filter[3] && filter[4] && ', '} + {filter[4] && <FormattedMessage id='filter_modal.select_filter.context_mismatch' defaultMessage='does not apply to this context' />} + ) + </span> + ); + } + + return ( + <div key={filter[0]} role='button' tabIndex={0} data-index={filter[0]} className='language-dropdown__dropdown__results__item' onClick={this.handleItemClick} onKeyDown={this.handleKeyDown}> + <span className='language-dropdown__dropdown__results__item__native-name'>{filter[1]}</span> {warning} + </div> + ); + }; + + renderCreateNew (name) { + return ( + <div key='add-new-filter' role='button' tabIndex={0} className='language-dropdown__dropdown__results__item' onClick={this.handleNewFilterClick} onKeyDown={this.handleKeyDown}> + <Icon id='plus' fixedWidth /> <FormattedMessage id='filter_modal.select_filter.prompt_new' defaultMessage='New category: {name}' values={{ name }} /> + </div> + ); + } + + 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 ( + <> + <h3 className='report-dialog-modal__title'><FormattedMessage id='filter_modal.select_filter.title' defaultMessage='Filter this post' /></h3> + <p className='report-dialog-modal__lead'><FormattedMessage id='filter_modal.select_filter.subtitle' defaultMessage='Use an existing category or create a new one' /></p> + + <div className='emoji-mart-search'> + <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} autoFocus /> + <button className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button> + </div> + + <div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}> + {results.map(this.renderItem)} + {isSearching && this.renderCreateNew(searchValue) } + </div> + + </> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(SelectFilter)); diff --git a/app/javascript/flavours/blobfox/features/firehose/index.jsx b/app/javascript/flavours/blobfox/features/firehose/index.jsx new file mode 100644 index 00000000000000..86a3c894f1a593 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/firehose/index.jsx @@ -0,0 +1,229 @@ +import PropTypes from 'prop-types'; +import { useRef, useCallback, useEffect } from 'react'; + +import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; +import { NavLink } from 'react-router-dom'; + +import { addColumn } from 'flavours/blobfox/actions/columns'; +import { changeSetting } from 'flavours/blobfox/actions/settings'; +import { connectPublicStream, connectCommunityStream } from 'flavours/blobfox/actions/streaming'; +import { expandPublicTimeline, expandCommunityTimeline } from 'flavours/blobfox/actions/timelines'; +import { DismissableBanner } from 'flavours/blobfox/components/dismissable_banner'; +import SettingText from 'flavours/blobfox/components/setting_text'; +import initialState, { domain } from 'flavours/blobfox/initial_state'; +import { useAppDispatch, useAppSelector } from 'flavours/blobfox/store'; + +import Column from '../../components/column'; +import ColumnHeader from '../../components/column_header'; +import SettingToggle from '../notifications/components/setting_toggle'; +import StatusListContainer from '../ui/containers/status_list_container'; + +const messages = defineMessages({ + title: { id: 'column.firehose', defaultMessage: 'Live feeds' }, + filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }, +}); + +// TODO: use a proper React context later on +const useIdentity = () => ({ + signedIn: !!initialState.meta.me, + accountId: initialState.meta.me, + disabledAccountId: initialState.meta.disabled_account_id, + accessToken: initialState.meta.access_token, + permissions: initialState.role ? initialState.role.permissions : 0, +}); + +const ColumnSettings = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const settings = useAppSelector((state) => state.getIn(['settings', 'firehose'])); + const onChange = useCallback( + (key, checked) => dispatch(changeSetting(['firehose', ...key], checked)), + [dispatch], + ); + + return ( + <div> + <div className='column-settings__row'> + <SettingToggle + settings={settings} + settingPath={['onlyMedia']} + onChange={onChange} + label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} + /> + <SettingToggle + settings={settings} + settingPath={['allowLocalOnly']} + onChange={onChange} + label={<FormattedMessage id='firehose.column_settings.allow_local_only' defaultMessage='Show local-only posts in "All"' />} + /> + <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> + <SettingText + settings={settings} + settingPath={['regex', 'body']} + onChange={onChange} + label={intl.formatMessage(messages.filter_regex)} + /> + </div> + </div> + ); +}; + +const Firehose = ({ feedType, multiColumn }) => { + const dispatch = useAppDispatch(); + const intl = useIntl(); + const { signedIn } = useIdentity(); + const columnRef = useRef(null); + + const allowLocalOnly = useAppSelector((state) => state.getIn(['settings', 'firehose', 'allowLocalOnly'])); + const regex = useAppSelector((state) => state.getIn(['settings', 'firehose', 'regex', 'body'])); + + const onlyMedia = useAppSelector((state) => state.getIn(['settings', 'firehose', 'onlyMedia'], false)); + const hasUnread = useAppSelector((state) => state.getIn(['timelines', `${feedType}${feedType === 'public' && allowLocalOnly ? ':allow_local_only' : ''}${onlyMedia ? ':media' : ''}`, 'unread'], 0) > 0); + + const handlePin = useCallback( + () => { + switch(feedType) { + case 'community': + dispatch(addColumn('COMMUNITY', { other: { onlyMedia }, regex: { body: regex } })); + break; + case 'public': + dispatch(addColumn('PUBLIC', { other: { onlyMedia, allowLocalOnly }, regex: { body: regex } })); + break; + case 'public:remote': + dispatch(addColumn('REMOTE', { other: { onlyMedia, onlyRemote: true }, regex: { body: regex } })); + break; + } + }, + [dispatch, onlyMedia, feedType, allowLocalOnly, regex], + ); + + const handleLoadMore = useCallback( + (maxId) => { + switch(feedType) { + case 'community': + dispatch(expandCommunityTimeline({ maxId, onlyMedia })); + break; + case 'public': + dispatch(expandPublicTimeline({ maxId, onlyMedia, allowLocalOnly })); + break; + case 'public:remote': + dispatch(expandPublicTimeline({ maxId, onlyMedia, onlyRemote: true })); + break; + } + }, + [dispatch, onlyMedia, allowLocalOnly, feedType], + ); + + const handleHeaderClick = useCallback(() => columnRef.current?.scrollTop(), []); + + useEffect(() => { + let disconnect; + + switch(feedType) { + case 'community': + dispatch(expandCommunityTimeline({ onlyMedia })); + if (signedIn) { + disconnect = dispatch(connectCommunityStream({ onlyMedia })); + } + break; + case 'public': + dispatch(expandPublicTimeline({ onlyMedia, allowLocalOnly })); + if (signedIn) { + disconnect = dispatch(connectPublicStream({ onlyMedia, allowLocalOnly })); + } + break; + case 'public:remote': + dispatch(expandPublicTimeline({ onlyMedia, onlyRemote: true })); + if (signedIn) { + disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote: true })); + } + break; + } + + return () => disconnect?.(); + }, [dispatch, signedIn, feedType, onlyMedia, allowLocalOnly]); + + const prependBanner = feedType === 'community' ? ( + <DismissableBanner id='community_timeline'> + <FormattedMessage + id='dismissable_banner.community_timeline' + defaultMessage='These are the most recent public posts from people whose accounts are hosted by {domain}.' + values={{ domain }} + /> + </DismissableBanner> + ) : ( + <DismissableBanner id='public_timeline'> + <FormattedMessage + id='dismissable_banner.public_timeline' + defaultMessage='These are the most recent public posts from people on the social web that people on {domain} follow.' + values={{ domain }} + /> + </DismissableBanner> + ); + + const emptyMessage = feedType === 'community' ? ( + <FormattedMessage + id='empty_column.community' + defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' + /> + ) : ( + <FormattedMessage + id='empty_column.public' + defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up' + /> + ); + + return ( + <Column bindToDocument={!multiColumn} ref={columnRef} label={intl.formatMessage(messages.title)}> + <ColumnHeader + icon='globe' + active={hasUnread} + title={intl.formatMessage(messages.title)} + onPin={handlePin} + onClick={handleHeaderClick} + multiColumn={multiColumn} + > + <ColumnSettings /> + </ColumnHeader> + + <div className='account__section-headline'> + <NavLink exact to='/public/local'> + <FormattedMessage tagName='div' id='firehose.local' defaultMessage='This server' /> + </NavLink> + + <NavLink exact to='/public/remote'> + <FormattedMessage tagName='div' id='firehose.remote' defaultMessage='Other servers' /> + </NavLink> + + <NavLink exact to='/public'> + <FormattedMessage tagName='div' id='firehose.all' defaultMessage='All' /> + </NavLink> + </div> + + <StatusListContainer + prepend={prependBanner} + timelineId={`${feedType}${feedType === 'public' && allowLocalOnly ? ':allow_local_only' : ''}${onlyMedia ? ':media' : ''}`} + onLoadMore={handleLoadMore} + trackScroll + scrollKey='firehose' + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + regex={regex} + /> + + <Helmet> + <title>{intl.formatMessage(messages.title)}</title> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); +}; + +Firehose.propTypes = { + multiColumn: PropTypes.bool, + feedType: PropTypes.string, +}; + +export default Firehose; diff --git a/app/javascript/flavours/blobfox/features/follow_requests/components/account_authorize.jsx b/app/javascript/flavours/blobfox/features/follow_requests/components/account_authorize.jsx new file mode 100644 index 00000000000000..5746af1b392a5f --- /dev/null +++ b/app/javascript/flavours/blobfox/features/follow_requests/components/account_authorize.jsx @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { Avatar } from '../../../components/avatar'; +import { DisplayName } from '../../../components/display_name'; +import { IconButton } from '../../../components/icon_button'; +import Permalink from '../../../components/permalink'; + +const messages = defineMessages({ + authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' }, + reject: { id: 'follow_request.reject', defaultMessage: 'Reject' }, +}); + +class AccountAuthorize extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.record.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 ( + <div className='account-authorize__wrapper'> + <div className='account-authorize'> + <Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='detailed-status__display-name'> + <div className='account-authorize__avatar'><Avatar account={account} size={48} /></div> + <DisplayName account={account} /> + </Permalink> + + <div className='account__header__content translate' dangerouslySetInnerHTML={content} /> + </div> + + <div className='account--panel'> + <div className='account--panel__button'><IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} /></div> + <div className='account--panel__button'><IconButton title={intl.formatMessage(messages.reject)} icon='times' onClick={onReject} /></div> + </div> + </div> + ); + } + +} + +export default injectIntl(AccountAuthorize); diff --git a/app/javascript/flavours/blobfox/features/follow_requests/containers/account_authorize_container.js b/app/javascript/flavours/blobfox/features/follow_requests/containers/account_authorize_container.js new file mode 100644 index 00000000000000..c9c8dd7d874d13 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/follow_requests/containers/account_authorize_container.js @@ -0,0 +1,27 @@ +import { connect } from 'react-redux'; + +import { authorizeFollowRequest, rejectFollowRequest } from '../../../actions/accounts'; +import { makeGetAccount } from '../../../selectors'; +import AccountAuthorize from '../components/account_authorize'; + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, props) => ({ + account: getAccount(state, props.id), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { id }) => ({ + onAuthorize () { + dispatch(authorizeFollowRequest(id)); + }, + + onReject () { + dispatch(rejectFollowRequest(id)); + }, +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(AccountAuthorize); diff --git a/app/javascript/flavours/blobfox/features/follow_requests/index.jsx b/app/javascript/flavours/blobfox/features/follow_requests/index.jsx new file mode 100644 index 00000000000000..796254e0ec9928 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/follow_requests/index.jsx @@ -0,0 +1,96 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { debounce } from 'lodash'; + +import { fetchFollowRequests, expandFollowRequests } from '../../actions/accounts'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import ScrollableList from '../../components/scrollable_list'; +import { me } from '../../initial_state'; +import Column from '../ui/components/column'; + +import AccountAuthorizeContainer from './containers/account_authorize_container'; + +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']), +}); + +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, + }; + + UNSAFE_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 = <FormattedMessage id='empty_column.follow_requests' defaultMessage="You don't have any follow requests yet. When you receive one, it will show up here." />; + const unlockedPrependMessage = !locked && accountIds.size > 0 && ( + <div className='follow_requests-unlocked_explanation'> + <FormattedMessage + id='follow_requests.unlocked_explanation' + defaultMessage='Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.' + values={{ domain: domain }} + /> + </div> + ); + + return ( + <Column bindToDocument={!multiColumn} icon='user-plus' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + <ScrollableList + scrollKey='follow_requests' + onLoadMore={this.handleLoadMore} + hasMore={hasMore} + isLoading={isLoading} + showLoading={isLoading && accountIds.size === 0} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + prepend={unlockedPrependMessage} + > + {accountIds.map(id => + <AccountAuthorizeContainer key={id} id={id} />, + )} + </ScrollableList> + + <Helmet> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(FollowRequests)); diff --git a/app/javascript/flavours/blobfox/features/followed_tags/index.jsx b/app/javascript/flavours/blobfox/features/followed_tags/index.jsx new file mode 100644 index 00000000000000..7d877402b668dd --- /dev/null +++ b/app/javascript/flavours/blobfox/features/followed_tags/index.jsx @@ -0,0 +1,93 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { debounce } from 'lodash'; + +import { expandFollowedHashtags, fetchFollowedHashtags } from 'flavours/blobfox/actions/tags'; +import ColumnHeader from 'flavours/blobfox/components/column_header'; +import Hashtag from 'flavours/blobfox/components/hashtag'; +import ScrollableList from 'flavours/blobfox/components/scrollable_list'; +import Column from 'flavours/blobfox/features/ui/components/column'; + +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']), +}); + +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 = <FormattedMessage id='empty_column.followed_tags' defaultMessage='You have not followed any hashtags yet. When you do, they will show up here.' />; + + return ( + <Column bindToDocument={!multiColumn}> + <ColumnHeader + icon='hashtag' + title={intl.formatMessage(messages.heading)} + showBackButton + multiColumn={multiColumn} + /> + + <ScrollableList + scrollKey='followed_tags' + emptyMessage={emptyMessage} + hasMore={hasMore} + isLoading={isLoading} + onLoadMore={this.handleLoadMore} + bindToDocument={!multiColumn} + > + {hashtags.map((hashtag) => ( + <Hashtag + key={hashtag.get('name')} + name={hashtag.get('name')} + to={`/tags/${hashtag.get('name')}`} + withGraph={false} + // Taken from ImmutableHashtag. Should maybe refactor ImmutableHashtag to accept more options? + people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1} + history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()} + /> + ))} + </ScrollableList> + + <Helmet> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(FollowedTags)); diff --git a/app/javascript/flavours/blobfox/features/followers/index.jsx b/app/javascript/flavours/blobfox/features/followers/index.jsx new file mode 100644 index 00000000000000..85cc79b362122d --- /dev/null +++ b/app/javascript/flavours/blobfox/features/followers/index.jsx @@ -0,0 +1,177 @@ +import PropTypes from 'prop-types'; + +import { FormattedMessage } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { debounce } from 'lodash'; + +import { TimelineHint } from 'flavours/blobfox/components/timeline_hint'; +import BundleColumnError from 'flavours/blobfox/features/ui/components/bundle_column_error'; +import { normalizeForLookup } from 'flavours/blobfox/reducers/accounts_map'; +import { getAccountHidden } from 'flavours/blobfox/selectors'; + +import { + lookupAccount, + fetchAccount, + fetchFollowers, + expandFollowers, +} from '../../actions/accounts'; +import { LoadingIndicator } from '../../components/loading_indicator'; +import ScrollableList from '../../components/scrollable_list'; +import AccountContainer from '../../containers/account_container'; +import ProfileColumnHeader from '../account/components/profile_column_header'; +import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint'; +import HeaderContainer from '../account_timeline/containers/header_container'; +import Column from '../ui/components/column'; + +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), + }; +}; + +const RemoteHint = ({ url }) => ( + <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.followers' defaultMessage='Followers' />} /> +); + +RemoteHint.propTypes = { + url: PropTypes.string.isRequired, +}; + +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, + 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 }); + + setRef = c => { + this.column = c; + }; + + handleHeaderClick = () => { + this.column.scrollTop(); + }; + + render () { + const { accountId, accountIds, hasMore, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl } = this.props; + + if (!isAccount) { + return ( + <BundleColumnError multiColumn={multiColumn} errorType='routing' /> + ); + } + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + let emptyMessage; + + const forceEmptyState = suspended || hidden; + + if (suspended) { + emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />; + } else if (hidden) { + emptyMessage = <LimitedAccountHint accountId={accountId} />; + } else if (remote && accountIds.isEmpty()) { + emptyMessage = <RemoteHint url={remoteUrl} />; + } else { + emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />; + } + + const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null; + + return ( + <Column ref={this.setRef}> + <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} /> + + <ScrollableList + scrollKey='followers' + hasMore={!forceEmptyState && hasMore} + isLoading={isLoading} + onLoadMore={this.handleLoadMore} + prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />} + alwaysPrepend + append={remoteMessage} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {accountIds.map(id => + <AccountContainer key={id} id={id} withNote={false} />, + )} + </ScrollableList> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(Followers); diff --git a/app/javascript/flavours/blobfox/features/following/index.jsx b/app/javascript/flavours/blobfox/features/following/index.jsx new file mode 100644 index 00000000000000..42336864d7551b --- /dev/null +++ b/app/javascript/flavours/blobfox/features/following/index.jsx @@ -0,0 +1,177 @@ +import PropTypes from 'prop-types'; + +import { FormattedMessage } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { debounce } from 'lodash'; + +import { TimelineHint } from 'flavours/blobfox/components/timeline_hint'; +import BundleColumnError from 'flavours/blobfox/features/ui/components/bundle_column_error'; +import { normalizeForLookup } from 'flavours/blobfox/reducers/accounts_map'; +import { getAccountHidden } from 'flavours/blobfox/selectors'; + +import { + lookupAccount, + fetchAccount, + fetchFollowing, + expandFollowing, +} from '../../actions/accounts'; +import { LoadingIndicator } from '../../components/loading_indicator'; +import ScrollableList from '../../components/scrollable_list'; +import AccountContainer from '../../containers/account_container'; +import ProfileColumnHeader from '../account/components/profile_column_header'; +import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint'; +import HeaderContainer from '../account_timeline/containers/header_container'; +import Column from '../ui/components/column'; + +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), + }; +}; + +const RemoteHint = ({ url }) => ( + <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.follows' defaultMessage='Follows' />} /> +); + +RemoteHint.propTypes = { + url: PropTypes.string.isRequired, +}; + +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, + 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 }); + + setRef = c => { + this.column = c; + }; + + handleHeaderClick = () => { + this.column.scrollTop(); + }; + + render () { + const { accountId, accountIds, hasMore, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl } = this.props; + + if (!isAccount) { + return ( + <BundleColumnError multiColumn={multiColumn} errorType='routing' /> + ); + } + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + let emptyMessage; + + const forceEmptyState = suspended || hidden; + + if (suspended) { + emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />; + } else if (hidden) { + emptyMessage = <LimitedAccountHint accountId={accountId} />; + } else if (remote && accountIds.isEmpty()) { + emptyMessage = <RemoteHint url={remoteUrl} />; + } else { + emptyMessage = <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />; + } + + const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null; + + return ( + <Column ref={this.setRef}> + <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} /> + + <ScrollableList + scrollKey='following' + hasMore={!forceEmptyState && hasMore} + isLoading={isLoading} + onLoadMore={this.handleLoadMore} + prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />} + alwaysPrepend + append={remoteMessage} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {accountIds.map(id => + <AccountContainer key={id} id={id} withNote={false} />, + )} + </ScrollableList> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(Following); diff --git a/app/javascript/flavours/blobfox/features/getting_started/components/announcements.jsx b/app/javascript/flavours/blobfox/features/getting_started/components/announcements.jsx new file mode 100644 index 00000000000000..982053a3953b51 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/getting_started/components/announcements.jsx @@ -0,0 +1,455 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl'; + +import classNames from 'classnames'; +import { withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import TransitionMotion from 'react-motion/lib/TransitionMotion'; +import spring from 'react-motion/lib/spring'; +import ReactSwipeableViews from 'react-swipeable-views'; + +import { AnimatedNumber } from 'flavours/blobfox/components/animated_number'; +import { Icon } from 'flavours/blobfox/components/icon'; +import { IconButton } from 'flavours/blobfox/components/icon_button'; +import EmojiPickerDropdown from 'flavours/blobfox/features/compose/containers/emoji_picker_dropdown_container'; +import { unicodeMapping } from 'flavours/blobfox/features/emoji/emoji_unicode_mapping_light'; +import { autoPlayGif, reduceMotion, disableSwiping, mascot } from 'flavours/blobfox/initial_state'; +import { assetHost } from 'flavours/blobfox/utils/config'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; +import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, + previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, + next: { id: 'lightbox.next', defaultMessage: 'Next' }, +}); + +class ContentWithRouter extends ImmutablePureComponent { + static propTypes = { + announcement: ImmutablePropTypes.map.isRequired, + ...WithRouterPropTypes, + }; + + 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.props.history && e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.props.history.push(`/@${mention.get('acct')}`); + } + }; + + onHashtagClick = (hashtag, e) => { + hashtag = hashtag.replace(/^#/, ''); + + if (this.props.history&& e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.props.history.push(`/tags/${hashtag}`); + } + }; + + onStatusClick = (status, e) => { + if (this.props.history && e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.props.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 ( + <div + className='announcements__item__content translate' + ref={this.setRef} + dangerouslySetInnerHTML={{ __html: announcement.get('contentHtml') }} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + /> + ); + } + +} + +const Content = withRouter(ContentWithRouter); + +class Emoji extends 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 ( + <img + draggable='false' + className='emojione' + alt={emoji} + title={title} + src={`${assetHost}/emoji/${filename}.svg`} + /> + ); + } else if (emojiMap.get(emoji)) { + const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']); + const shortCode = `:${emoji}:`; + + return ( + <img + draggable='false' + className='emojione custom-emoji' + alt={shortCode} + title={shortCode} + src={filename} + /> + ); + } 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 ( + <button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`} style={this.props.style}> + <span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span> + <span className='reactions-bar__item__count'><AnimatedNumber value={reaction.get('count')} /></span> + </button> + ); + } + +} + +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 ( + <TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}> + {items => ( + <div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}> + {items.map(({ key, data, style }) => ( + <Reaction + key={key} + reaction={data} + style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }} + announcementId={this.props.announcementId} + addReaction={this.props.addReaction} + removeReaction={this.props.removeReaction} + emojiMap={this.props.emojiMap} + /> + ))} + + {visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} />} + </div> + )} + </TransitionMotion> + ); + } + +} + +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 ( + <div className='announcements__item'> + <strong className='announcements__item__range'> + <FormattedMessage id='announcement.announcement' defaultMessage='Announcement' /> + {hasTimeRange && <span> · <FormattedDate value={startsAt} hour12={false} year={(skipYear || startsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month='short' day='2-digit' hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /> - <FormattedDate value={endsAt} hour12={false} year={(skipYear || endsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month={skipEndDate ? undefined : 'short'} day={skipEndDate ? undefined : '2-digit'} hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /></span>} + </strong> + + <Content announcement={announcement} /> + + <ReactionsBar + reactions={announcement.get('reactions')} + announcementId={announcement.get('id')} + addReaction={this.props.addReaction} + removeReaction={this.props.removeReaction} + emojiMap={this.props.emojiMap} + /> + + {unread && <span className='announcements__item__unread' />} + </div> + ); + } + +} + +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 ( + <div className='announcements'> + <img className='announcements__mastodon' alt='' draggable='false' src={mascot || elephantUIPlane} /> + + <div className='announcements__container'> + <ReactSwipeableViews animateHeight animateTransitions={!reduceMotion} index={index} onChangeIndex={this.handleChangeIndex}> + {announcements.map((announcement, idx) => ( + <Announcement + key={announcement.get('id')} + announcement={announcement} + emojiMap={this.props.emojiMap} + addReaction={this.props.addReaction} + removeReaction={this.props.removeReaction} + intl={intl} + selected={index === idx} + disabled={disableSwiping} + /> + )).reverse()} + </ReactSwipeableViews> + + {announcements.size > 1 && ( + <div className='announcements__pagination'> + <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.previous)} icon='chevron-left' onClick={this.handlePrevClick} size={13} /> + <span>{index + 1} / {announcements.size}</span> + <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.next)} icon='chevron-right' onClick={this.handleNextClick} size={13} /> + </div> + )} + </div> + </div> + ); + } + +} + +export default injectIntl(Announcements); diff --git a/app/javascript/flavours/blobfox/features/getting_started/components/trends.jsx b/app/javascript/flavours/blobfox/features/getting_started/components/trends.jsx new file mode 100644 index 00000000000000..0971cce1352270 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/getting_started/components/trends.jsx @@ -0,0 +1,54 @@ +import PropTypes from 'prop-types'; + +import { FormattedMessage } from 'react-intl'; + +import { Link } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { ImmutableHashtag as Hashtag } from 'flavours/blobfox/components/hashtag'; + +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 ( + <div className='getting-started__trends'> + <h4> + <Link to={'/explore/tags'}> + <FormattedMessage id='trends.trending_now' defaultMessage='Trending now' /> + </Link> + </h4> + + {trends.take(3).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)} + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/getting_started/containers/announcements_container.js b/app/javascript/flavours/blobfox/features/getting_started/containers/announcements_container.js new file mode 100644 index 00000000000000..3af2628a403965 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/getting_started/containers/announcements_container.js @@ -0,0 +1,22 @@ +import { Map as ImmutableMap } from 'immutable'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; + +import { addReaction, removeReaction, dismissAnnouncement } from 'flavours/blobfox/actions/announcements'; + +import Announcements from '../components/announcements'; + +const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap())); + +const mapStateToProps = state => ({ + announcements: state.getIn(['announcements', 'items']), + emojiMap: customEmojiMap(state), +}); + +const mapDispatchToProps = dispatch => ({ + dismissAnnouncement: id => dispatch(dismissAnnouncement(id)), + addReaction: (id, name) => dispatch(addReaction(id, name)), + removeReaction: (id, name) => dispatch(removeReaction(id, name)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Announcements); diff --git a/app/javascript/flavours/blobfox/features/getting_started/containers/trends_container.js b/app/javascript/flavours/blobfox/features/getting_started/containers/trends_container.js new file mode 100644 index 00000000000000..e35c0c46f54336 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/getting_started/containers/trends_container.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; + +import { fetchTrendingHashtags } from 'flavours/blobfox/actions/trends'; + +import Trends from '../components/trends'; + +const mapStateToProps = state => ({ + trends: state.getIn(['trends', 'tags', 'items']), +}); + +const mapDispatchToProps = dispatch => ({ + fetchTrends: () => dispatch(fetchTrendingHashtags()), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Trends); diff --git a/app/javascript/flavours/blobfox/features/getting_started/index.jsx b/app/javascript/flavours/blobfox/features/getting_started/index.jsx new file mode 100644 index 00000000000000..e1bc9cb9fbfe2b --- /dev/null +++ b/app/javascript/flavours/blobfox/features/getting_started/index.jsx @@ -0,0 +1,208 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import { List as ImmutableList } from 'immutable'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; + +import { fetchFollowRequests } from 'flavours/blobfox/actions/accounts'; +import { fetchLists } from 'flavours/blobfox/actions/lists'; +import { openModal } from 'flavours/blobfox/actions/modal'; +import Column from 'flavours/blobfox/features/ui/components/column'; +import LinkFooter from 'flavours/blobfox/features/ui/components/link_footer'; +import { preferencesLink } from 'flavours/blobfox/utils/backend_links'; + +import { me, showTrends } from '../../initial_state'; +import NavigationBar from '../compose/components/navigation_bar'; +import ColumnLink from '../ui/components/column_link'; +import ColumnSubheading from '../ui/components/column_subheading'; + +import TrendsContainer from './containers/trends_container'; + +const messages = defineMessages({ + heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, + home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' }, + notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' }, + public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' }, + navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' }, + settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' }, + community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, + explore: { id: 'navigation_bar.explore', defaultMessage: 'Explore' }, + direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' }, + bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, + preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, + follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, + lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, + keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' }, + lists_subheading: { id: 'column_subheading.lists', defaultMessage: 'Lists' }, + misc: { id: 'navigation_bar.misc', defaultMessage: 'Misc' }, + menu: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, +}); + +const makeMapStateToProps = () => { + 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), + myAccount: state.getIn(['accounts', me]), + columns: state.getIn(['settings', 'columns']), + unreadFollowRequests: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size, + unreadNotifications: state.getIn(['notifications', 'unread']), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = dispatch => ({ + fetchFollowRequests: () => dispatch(fetchFollowRequests()), + fetchLists: () => dispatch(fetchLists()), + openSettings: () => dispatch(openModal({ + modalType: 'SETTINGS', + modalProps: {}, + })), +}); + +const badgeDisplay = (number, limit) => { + if (number === 0) { + return undefined; + } else if (limit && number >= limit) { + return `${limit}+`; + } else { + return number; + } +}; + +class GettingStarted extends ImmutablePureComponent { + + static contextTypes = { + identity: PropTypes.object, + }; + + static propTypes = { + intl: PropTypes.object.isRequired, + myAccount: ImmutablePropTypes.map, + columns: ImmutablePropTypes.list, + multiColumn: PropTypes.bool, + fetchFollowRequests: PropTypes.func.isRequired, + unreadFollowRequests: PropTypes.number, + unreadNotifications: PropTypes.number, + lists: ImmutablePropTypes.list, + fetchLists: PropTypes.func.isRequired, + openSettings: PropTypes.func.isRequired, + }; + + UNSAFE_componentWillMount () { + this.props.fetchLists(); + } + + componentDidMount () { + const { fetchFollowRequests } = this.props; + const { signedIn } = this.context.identity; + + if (!signedIn) { + return; + } + + fetchFollowRequests(); + } + + render () { + const { intl, myAccount, columns, multiColumn, unreadFollowRequests, unreadNotifications, lists, openSettings } = this.props; + const { signedIn } = this.context.identity; + + const navItems = []; + let listItems = []; + + if (multiColumn) { + if (signedIn && !columns.find(item => item.get('id') === 'HOME')) { + navItems.push(<ColumnLink key='home' icon='home' text={intl.formatMessage(messages.home_timeline)} to='/home' />); + } + + if (!columns.find(item => item.get('id') === 'NOTIFICATIONS')) { + navItems.push(<ColumnLink key='notifications' icon='bell' text={intl.formatMessage(messages.notifications)} badge={badgeDisplay(unreadNotifications)} to='/notifications' />); + } + + if (!columns.find(item => item.get('id') === 'COMMUNITY')) { + navItems.push(<ColumnLink key='community_timeline' icon='users' text={intl.formatMessage(messages.community_timeline)} to='/public/local' />); + } + + if (!columns.find(item => item.get('id') === 'PUBLIC')) { + navItems.push(<ColumnLink key='public_timeline' icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/public' />); + } + } + + if (showTrends) { + navItems.push(<ColumnLink key='explore' icon='hashtag' text={intl.formatMessage(messages.explore)} to='/explore' />); + } + + if (signedIn) { + if (!multiColumn || !columns.find(item => item.get('id') === 'DIRECT')) { + navItems.push(<ColumnLink key='conversations' icon='envelope' text={intl.formatMessage(messages.direct)} to='/conversations' />); + } + + if (!multiColumn || !columns.find(item => item.get('id') === 'BOOKMARKS')) { + navItems.push(<ColumnLink key='bookmarks' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} to='/bookmarks' />); + } + + if (myAccount.get('locked') || unreadFollowRequests > 0) { + navItems.push(<ColumnLink key='follow_requests' icon='user-plus' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />); + } + + navItems.push(<ColumnLink key='getting_started' icon='ellipsis-h' text={intl.formatMessage(messages.misc)} to='/getting-started-misc' />); + + listItems = listItems.concat([ + <div key='9'> + <ColumnLink key='lists' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' /> + {lists.filter(list => !columns.find(item => item.get('id') === 'LIST' && item.getIn(['params', 'id']) === list.get('id'))).map(list => + <ColumnLink key={`list-${list.get('id')}`} to={`/lists/${list.get('id')}`} icon='list-ul' text={list.get('title')} />, + )} + </div>, + ]); + } + + return ( + <Column bindToDocument={!multiColumn} icon='asterisk' heading={intl.formatMessage(messages.heading)} label={intl.formatMessage(messages.menu)} hideHeadingOnMobile> + <div className='scrollable optionally-scrollable'> + <div className='getting-started__wrapper'> + {!multiColumn && signedIn && <NavigationBar account={myAccount} />} + {multiColumn && <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} />} + {navItems} + {signedIn && ( + <> + <ColumnSubheading text={intl.formatMessage(messages.lists_subheading)} /> + {listItems} + <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} /> + { preferencesLink !== undefined && <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href={preferencesLink} /> } + <ColumnLink icon='cogs' text={intl.formatMessage(messages.settings)} onClick={openSettings} /> + </> + )} + </div> + + <LinkFooter multiColumn /> + </div> + + {(multiColumn && showTrends) && <TrendsContainer />} + + <Helmet> + <title>{intl.formatMessage(messages.menu)}</title> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} + +export default connect(makeMapStateToProps, mapDispatchToProps)(injectIntl(GettingStarted)); diff --git a/app/javascript/flavours/blobfox/features/getting_started_misc/index.jsx b/app/javascript/flavours/blobfox/features/getting_started_misc/index.jsx new file mode 100644 index 00000000000000..b1d94e608b597b --- /dev/null +++ b/app/javascript/flavours/blobfox/features/getting_started_misc/index.jsx @@ -0,0 +1,68 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { openModal } from 'flavours/blobfox/actions/modal'; +import ColumnBackButtonSlim from 'flavours/blobfox/components/column_back_button_slim'; +import Column from 'flavours/blobfox/features/ui/components/column'; +import ColumnLink from 'flavours/blobfox/features/ui/components/column_link'; +import ColumnSubheading from 'flavours/blobfox/features/ui/components/column_subheading'; + + +const messages = defineMessages({ + heading: { id: 'column.heading', defaultMessage: 'Misc' }, + subheading: { id: 'column.subheading', defaultMessage: 'Miscellaneous options' }, + favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' }, + 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' }, + pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' }, + keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' }, + featured_users: { id: 'navigation_bar.featured_users', defaultMessage: 'Featured users' }, +}); + +class GettingStartedMisc extends ImmutablePureComponent { + + static contextTypes = { + identity: PropTypes.object, + }; + + static propTypes = { + intl: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + }; + + openFeaturedAccountsModal = () => { + this.props.dispatch(openModal({ + modalType: 'PINNED_ACCOUNTS_EDITOR', + })); + }; + + render () { + const { intl } = this.props; + const { signedIn } = this.context.identity; + + return ( + <Column icon='ellipsis-h' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + + <div className='scrollable'> + <ColumnSubheading text={intl.formatMessage(messages.subheading)} /> + {signedIn && (<ColumnLink key='favourites' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />)} + {signedIn && (<ColumnLink key='pinned' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />)} + {signedIn && (<ColumnLink key='featured_users' icon='users' text={intl.formatMessage(messages.featured_users)} onClick={this.openFeaturedAccountsModal} />)} + {signedIn && (<ColumnLink key='mutes' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />)} + {signedIn && (<ColumnLink key='blocks' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />)} + {signedIn && (<ColumnLink key='domain_blocks' icon='minus-circle' text={intl.formatMessage(messages.domain_blocks)} to='/domain_blocks' />)} + <ColumnLink key='shortcuts' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' /> + </div> + </Column> + ); + } + +} + +export default connect()(injectIntl(GettingStartedMisc)); diff --git a/app/javascript/flavours/blobfox/features/hashtag_timeline/components/column_settings.jsx b/app/javascript/flavours/blobfox/features/hashtag_timeline/components/column_settings.jsx new file mode 100644 index 00000000000000..c60de4c5189469 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/hashtag_timeline/components/column_settings.jsx @@ -0,0 +1,138 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + +import { NonceProvider } from 'react-select'; +import AsyncSelect from 'react-select/async'; +import Toggle from 'react-toggle'; + +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' }, +}); + +class ColumnSettings extends 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 ( + <div className='column-settings__row'> + <span className='column-settings__section'> + {this.modeLabel(mode)} + </span> + + <NonceProvider nonce={document.querySelector('meta[name=style-nonce]').content} cacheKey='tags'> + <AsyncSelect + isMulti + autoFocus + value={this.tags(mode)} + onChange={this.onSelect(mode)} + loadOptions={this.props.onLoad} + className='column-select__container' + classNamePrefix='column-select' + name='tags' + placeholder={this.props.intl.formatMessage(messages.placeholder)} + noOptionsMessage={this.noOptionsMessage} + /> + </NonceProvider> + </div> + ); + } + + modeLabel (mode) { + switch(mode) { + case 'any': + return <FormattedMessage id='hashtag.column_settings.tag_mode.any' defaultMessage='Any of these' />; + case 'all': + return <FormattedMessage id='hashtag.column_settings.tag_mode.all' defaultMessage='All of these' />; + case 'none': + return <FormattedMessage id='hashtag.column_settings.tag_mode.none' defaultMessage='None of these' />; + default: + return ''; + } + } + + render () { + const { settings, onChange } = this.props; + + return ( + <div> + <div className='column-settings__row'> + <div className='setting-toggle'> + <Toggle id='hashtag.column_settings.tag_toggle' onChange={this.onToggle} checked={this.state.open} /> + + <span className='setting-toggle__label'> + <FormattedMessage id='hashtag.column_settings.tag_toggle' defaultMessage='Include additional tags in this column' /> + </span> + </div> + </div> + + {this.state.open && ( + <div className='column-settings__hashtags'> + {this.modeSelect('any')} + {this.modeSelect('all')} + {this.modeSelect('none')} + </div> + )} + + <div className='column-settings__row'> + <SettingToggle settings={settings} settingPath={['local']} onChange={onChange} label={<FormattedMessage id='community.column_settings.local_only' defaultMessage='Local only' />} /> + </div> + </div> + ); + } + +} + +export default injectIntl(ColumnSettings); diff --git a/app/javascript/flavours/blobfox/features/hashtag_timeline/components/hashtag_header.jsx b/app/javascript/flavours/blobfox/features/hashtag_timeline/components/hashtag_header.jsx new file mode 100644 index 00000000000000..cf1eb76b8a7dbe --- /dev/null +++ b/app/javascript/flavours/blobfox/features/hashtag_timeline/components/hashtag_header.jsx @@ -0,0 +1,79 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + +import { Button } from 'flavours/blobfox/components/button'; +import { ShortNumber } from 'flavours/blobfox/components/short_number'; + +const messages = defineMessages({ + followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' }, + unfollowHashtag: { id: 'hashtag.unfollow', defaultMessage: 'Unfollow hashtag' }, +}); + +const usesRenderer = (displayNumber, pluralReady) => ( + <FormattedMessage + id='hashtag.counter_by_uses' + defaultMessage='{count, plural, one {{counter} post} other {{counter} posts}}' + values={{ + count: pluralReady, + counter: <strong>{displayNumber}</strong>, + }} + /> +); + +const peopleRenderer = (displayNumber, pluralReady) => ( + <FormattedMessage + id='hashtag.counter_by_accounts' + defaultMessage='{count, plural, one {{counter} participant} other {{counter} participants}}' + values={{ + count: pluralReady, + counter: <strong>{displayNumber}</strong>, + }} + /> +); + +const usesTodayRenderer = (displayNumber, pluralReady) => ( + <FormattedMessage + id='hashtag.counter_by_uses_today' + defaultMessage='{count, plural, one {{counter} post} other {{counter} posts}} today' + values={{ + count: pluralReady, + counter: <strong>{displayNumber}</strong>, + }} + /> +); + +export const HashtagHeader = injectIntl(({ tag, intl, disabled, onClick }) => { + if (!tag) { + return null; + } + + const [uses, people] = tag.get('history').reduce((arr, day) => [arr[0] + day.get('uses') * 1, arr[1] + day.get('accounts') * 1], [0, 0]); + const dividingCircle = <span aria-hidden>{' · '}</span>; + + return ( + <div className='hashtag-header'> + <div className='hashtag-header__header'> + <h1>#{tag.get('name')}</h1> + <Button onClick={onClick} text={intl.formatMessage(tag.get('following') ? messages.unfollowHashtag : messages.followHashtag)} disabled={disabled} /> + </div> + + <div> + <ShortNumber value={uses} renderer={usesRenderer} /> + {dividingCircle} + <ShortNumber value={people} renderer={peopleRenderer} /> + {dividingCircle} + <ShortNumber value={tag.getIn(['history', 0, 'uses']) * 1} renderer={usesTodayRenderer} /> + </div> + </div> + ); +}); + +HashtagHeader.propTypes = { + tag: ImmutablePropTypes.map, + disabled: PropTypes.bool, + onClick: PropTypes.func, + intl: PropTypes.object, +}; \ No newline at end of file diff --git a/app/javascript/flavours/blobfox/features/hashtag_timeline/containers/column_settings_container.js b/app/javascript/flavours/blobfox/features/hashtag_timeline/containers/column_settings_container.js new file mode 100644 index 00000000000000..be95004cc7bfaf --- /dev/null +++ b/app/javascript/flavours/blobfox/features/hashtag_timeline/containers/column_settings_container.js @@ -0,0 +1,33 @@ +import { connect } from 'react-redux'; + +import { changeColumnParams } from '../../../actions/columns'; +import api from '../../../api'; +import ColumnSettings from '../components/column_settings'; + +const mapStateToProps = (state, { columnId }) => { + const columns = state.getIn(['settings', 'columns']); + const index = columns.findIndex(c => c.get('uuid') === columnId); + + if (!(columnId && index >= 0)) { + return {}; + } + + return { + settings: columns.get(index).get('params'), + onLoad (value) { + return api(() => state).get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => { + return (response.data.hashtags || []).map((tag) => { + return { value: tag.name, label: `#${tag.name}` }; + }); + }); + }, + }; +}; + +const mapDispatchToProps = (dispatch, { columnId }) => ({ + onChange (key, value) { + dispatch(changeColumnParams(columnId, key, value)); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/flavours/blobfox/features/hashtag_timeline/index.jsx b/app/javascript/flavours/blobfox/features/hashtag_timeline/index.jsx new file mode 100644 index 00000000000000..bcc2e99aee450f --- /dev/null +++ b/app/javascript/flavours/blobfox/features/hashtag_timeline/index.jsx @@ -0,0 +1,226 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +import { isEqual } from 'lodash'; + +import { addColumn, removeColumn, moveColumn } from 'flavours/blobfox/actions/columns'; +import { connectHashtagStream } from 'flavours/blobfox/actions/streaming'; +import { fetchHashtag, followHashtag, unfollowHashtag } from 'flavours/blobfox/actions/tags'; +import { expandHashtagTimeline, clearTimeline } from 'flavours/blobfox/actions/timelines'; +import Column from 'flavours/blobfox/components/column'; +import ColumnHeader from 'flavours/blobfox/components/column_header'; + +import StatusListContainer from '../ui/containers/status_list_container'; + +import { HashtagHeader } from './components/hashtag_header'; +import ColumnSettingsContainer from './containers/column_settings_container'; + +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]), +}); + +class HashtagTimeline extends 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, + }; + + 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(' ', <FormattedMessage key='any' id='hashtag.column_header.tag_mode.any' values={{ additional: this.additionalFor('any') }} defaultMessage='or {additional}' />); + } + + if (this.additionalFor('all')) { + title.push(' ', <FormattedMessage key='all' id='hashtag.column_header.tag_mode.all' values={{ additional: this.additionalFor('all') }} defaultMessage='and {additional}' />); + } + + if (this.additionalFor('none')) { + title.push(' ', <FormattedMessage key='none' id='hashtag.column_header.tag_mode.none' values={{ additional: this.additionalFor('none') }} defaultMessage='without {additional}' />); + } + + 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 } = this.props; + const { id, local } = this.props.params; + const pinned = !!columnId; + const { signedIn } = this.context.identity; + + return ( + <Column bindToDocument={!multiColumn} ref={this.setRef} label={`#${id}`}> + <ColumnHeader + icon='hashtag' + active={hasUnread} + title={this.title()} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + showBackButton + > + {columnId && <ColumnSettingsContainer columnId={columnId} />} + </ColumnHeader> + + <StatusListContainer + prepend={pinned ? null : <HashtagHeader tag={tag} disabled={!signedIn} onClick={this.handleFollow} />} + alwaysPrepend + trackScroll={!pinned} + scrollKey={`hashtag_timeline-${columnId}`} + timelineId={`hashtag:${id}${local ? ':local' : ''}`} + onLoadMore={this.handleLoadMore} + emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} + bindToDocument={!multiColumn} + /> + + <Helmet> + <title>#{id}</title> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(HashtagTimeline); diff --git a/app/javascript/flavours/blobfox/features/home_timeline/components/column_settings.tsx b/app/javascript/flavours/blobfox/features/home_timeline/components/column_settings.tsx new file mode 100644 index 00000000000000..70fc35cbb18d1a --- /dev/null +++ b/app/javascript/flavours/blobfox/features/home_timeline/components/column_settings.tsx @@ -0,0 +1,109 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call, + @typescript-eslint/no-unsafe-return, + @typescript-eslint/no-unsafe-assignment, + @typescript-eslint/no-unsafe-member-access + -- the settings store is not yet typed */ +import { useCallback } from 'react'; + +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; + +import SettingText from 'flavours/blobfox/components/setting_text'; +import { useAppSelector, useAppDispatch } from 'flavours/blobfox/store'; + +import { changeSetting } from '../../../actions/settings'; +import SettingToggle from '../../notifications/components/setting_toggle'; + +const messages = defineMessages({ + filter_regex: { + id: 'home.column_settings.filter_regex', + defaultMessage: 'Filter out by regular expressions', + }, + settings: { id: 'home.settings', defaultMessage: 'Column settings' }, +}); + +export const ColumnSettings: React.FC = () => { + const settings = useAppSelector((state) => state.settings.get('home')); + + const intl = useIntl(); + + const dispatch = useAppDispatch(); + const onChange = useCallback( + (key: string, checked: boolean) => { + dispatch(changeSetting(['home', ...key], checked)); + }, + [dispatch], + ); + + return ( + <div> + <span className='column-settings__section'> + <FormattedMessage + id='home.column_settings.basic' + defaultMessage='Basic' + /> + </span> + + <div className='column-settings__row'> + <SettingToggle + prefix='home_timeline' + settings={settings} + settingPath={['shows', 'reblog']} + onChange={onChange} + label={ + <FormattedMessage + id='home.column_settings.show_reblogs' + defaultMessage='Show boosts' + /> + } + /> + </div> + + <div className='column-settings__row'> + <SettingToggle + prefix='home_timeline' + settings={settings} + settingPath={['shows', 'reply']} + onChange={onChange} + label={ + <FormattedMessage + id='home.column_settings.show_replies' + defaultMessage='Show replies' + /> + } + /> + </div> + + <div className='column-settings__row'> + <SettingToggle + prefix='home_timeline' + settings={settings} + settingPath={['shows', 'direct']} + onChange={onChange} + label={ + <FormattedMessage + id='home.column_settings.show_direct' + defaultMessage='Show private mentions' + /> + } + /> + </div> + + <span className='column-settings__section'> + <FormattedMessage + id='home.column_settings.advanced' + defaultMessage='Advanced' + /> + </span> + + <div className='column-settings__row'> + <SettingText + prefix='home_timeline' + settings={settings} + settingPath={['regex', 'body']} + onChange={onChange} + label={intl.formatMessage(messages.filter_regex)} + /> + </div> + </div> + ); +}; diff --git a/app/javascript/flavours/blobfox/features/home_timeline/components/critical_update_banner.tsx b/app/javascript/flavours/blobfox/features/home_timeline/components/critical_update_banner.tsx new file mode 100644 index 00000000000000..d0dd2b6acda66f --- /dev/null +++ b/app/javascript/flavours/blobfox/features/home_timeline/components/critical_update_banner.tsx @@ -0,0 +1,26 @@ +import { FormattedMessage } from 'react-intl'; + +export const CriticalUpdateBanner = () => ( + <div className='warning-banner'> + <div className='warning-banner__message'> + <h1> + <FormattedMessage + id='home.pending_critical_update.title' + defaultMessage='Critical security update available!' + /> + </h1> + <p> + <FormattedMessage + id='home.pending_critical_update.body' + defaultMessage='Please update your Mastodon server as soon as possible!' + />{' '} + <a href='/admin/software_updates'> + <FormattedMessage + id='home.pending_critical_update.link' + defaultMessage='See updates' + /> + </a> + </p> + </div> + </div> +); diff --git a/app/javascript/flavours/blobfox/features/home_timeline/components/explore_prompt.tsx b/app/javascript/flavours/blobfox/features/home_timeline/components/explore_prompt.tsx new file mode 100644 index 00000000000000..8d39d6372d793f --- /dev/null +++ b/app/javascript/flavours/blobfox/features/home_timeline/components/explore_prompt.tsx @@ -0,0 +1,46 @@ +import { FormattedMessage } from 'react-intl'; + +import { Link } from 'react-router-dom'; + +import { DismissableBanner } from 'flavours/blobfox/components/dismissable_banner'; +import background from 'mastodon/../images/friends-cropped.png'; + +export const ExplorePrompt = () => ( + <DismissableBanner id='home.explore_prompt'> + <img + src={background} + alt='' + className='dismissable-banner__background-image' + /> + + <h1> + <FormattedMessage + id='home.explore_prompt.title' + defaultMessage='This is your home base within Mastodon.' + /> + </h1> + <p> + <FormattedMessage + id='home.explore_prompt.body' + defaultMessage="Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. If that feels too quiet, you may want to:" + /> + </p> + + <div className='dismissable-banner__message__wrapper'> + <div className='dismissable-banner__message__actions'> + <Link to='/explore' className='button'> + <FormattedMessage + id='home.actions.go_to_explore' + defaultMessage="See what's trending" + /> + </Link> + <Link to='/explore/suggestions' className='button button-tertiary'> + <FormattedMessage + id='home.actions.go_to_suggestions' + defaultMessage='Find people to follow' + /> + </Link> + </div> + </div> + </DismissableBanner> +); diff --git a/app/javascript/flavours/blobfox/features/home_timeline/index.jsx b/app/javascript/flavours/blobfox/features/home_timeline/index.jsx new file mode 100644 index 00000000000000..0164ed006a050b --- /dev/null +++ b/app/javascript/flavours/blobfox/features/home_timeline/index.jsx @@ -0,0 +1,239 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; +import { Helmet } from 'react-helmet'; + +import { List as ImmutableList } from 'immutable'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; + +import { fetchAnnouncements, toggleShowAnnouncements } from 'flavours/blobfox/actions/announcements'; +import { IconWithBadge } from 'flavours/blobfox/components/icon_with_badge'; +import { NotSignedInIndicator } from 'flavours/blobfox/components/not_signed_in_indicator'; +import AnnouncementsContainer from 'flavours/blobfox/features/getting_started/containers/announcements_container'; +import { me, criticalUpdatesPending } from 'flavours/blobfox/initial_state'; + +import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; +import { expandHomeTimeline } from '../../actions/timelines'; +import Column from '../../components/column'; +import ColumnHeader from '../../components/column_header'; +import StatusListContainer from '../ui/containers/status_list_container'; + +import { ColumnSettings } from './components/column_settings'; +import { CriticalUpdateBanner } from './components/critical_update_banner'; +import { ExplorePrompt } from './components/explore_prompt'; + +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 getHomeFeedSpeed = createSelector([ + state => state.getIn(['timelines', 'home', 'items'], ImmutableList()), + state => state.getIn(['timelines', 'home', 'pendingItems'], ImmutableList()), + state => state.get('statuses'), +], (statusIds, pendingStatusIds, statusMap) => { + const recentStatusIds = pendingStatusIds.concat(statusIds); + const statuses = recentStatusIds.filter(id => id !== null).map(id => statusMap.get(id)).filter(status => status?.get('account') !== me).take(20); + + if (statuses.isEmpty()) { + return { + gap: 0, + newest: new Date(0), + }; + } + + const datetimes = statuses.map(status => status.get('created_at', 0)); + const oldest = new Date(datetimes.min()); + const newest = new Date(datetimes.max()); + const averageGap = (newest - oldest) / (1000 * (statuses.size + 1)); // Average gap between posts on first page in seconds + + return { + gap: averageGap, + newest, + }; +}); + +const homeTooSlow = createSelector([ + state => state.getIn(['timelines', 'home', 'isLoading']), + state => state.getIn(['timelines', 'home', 'isPartial']), + getHomeFeedSpeed, +], (isLoading, isPartial, speed) => + !isLoading && !isPartial // Only if the home feed has finished loading + && ( + (speed.gap > (30 * 60) // If the average gap between posts is more than 30 minutes + || (Date.now() - speed.newest) > (1000 * 3600)) // If the most recent post is from over an hour ago + ) +); + +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']), + tooSlow: homeTooSlow(state), + regex: state.getIn(['settings', 'home', 'regex', 'body']), +}); + +class HomeTimeline extends 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, + tooSlow: PropTypes.bool, + regex: PropTypes.string, + }; + + 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, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props; + const pinned = !!columnId; + const { signedIn } = this.context.identity; + const banners = []; + + let announcementsButton; + + if (hasAnnouncements) { + announcementsButton = ( + <button + className={classNames('column-header__button', { 'active': showAnnouncements })} + title={intl.formatMessage(showAnnouncements ? messages.hide_announcements : messages.show_announcements)} + aria-label={intl.formatMessage(showAnnouncements ? messages.hide_announcements : messages.show_announcements)} + onClick={this.handleToggleAnnouncementsClick} + > + <IconWithBadge id='bullhorn' count={unreadAnnouncements} /> + </button> + ); + } + + if (criticalUpdatesPending) { + banners.push(<CriticalUpdateBanner key='critical-update-banner' />); + } + + if (tooSlow) { + banners.push(<ExplorePrompt key='explore-prompt' />); + } + + return ( + <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}> + <ColumnHeader + icon='home' + active={hasUnread} + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + extraButton={announcementsButton} + appendContent={hasAnnouncements && showAnnouncements && <AnnouncementsContainer />} + > + <ColumnSettings /> + </ColumnHeader> + + {signedIn ? ( + <StatusListContainer + prepend={banners} + alwaysPrepend + trackScroll={!pinned} + scrollKey={`home_timeline-${columnId}`} + onLoadMore={this.handleLoadMore} + timelineId='home' + emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Follow more people to fill it up.' />} + bindToDocument={!multiColumn} + regex={this.props.regex} + /> + ) : <NotSignedInIndicator />} + + <Helmet> + <title>{intl.formatMessage(messages.title)}</title> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(HomeTimeline)); diff --git a/app/javascript/flavours/blobfox/features/interaction_modal/index.jsx b/app/javascript/flavours/blobfox/features/interaction_modal/index.jsx new file mode 100644 index 00000000000000..f6ac45d07dbc42 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/interaction_modal/index.jsx @@ -0,0 +1,417 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import { connect } from 'react-redux'; + +import { throttle, escapeRegExp } from 'lodash'; + +import { openModal, closeModal } from 'flavours/blobfox/actions/modal'; +import api from 'flavours/blobfox/api'; +import { Button } from 'flavours/blobfox/components/button'; +import { Icon } from 'flavours/blobfox/components/icon'; +import { registrationsOpen, sso_redirect } from 'flavours/blobfox/initial_state'; + +const messages = defineMessages({ + loginPrompt: { id: 'interaction_modal.login.prompt', defaultMessage: 'Domain of your home server, e.g. mastodon.social' }, +}); + +const mapStateToProps = (state, { accountId }) => ({ + displayNameHtml: state.getIn(['accounts', accountId, 'display_name_html']), + signupUrl: state.getIn(['server', 'server', 'registrations', 'url'], null) || '/auth/sign_up', +}); + +const mapDispatchToProps = (dispatch) => ({ + onSignupClick() { + dispatch(closeModal({ + modalType: undefined, + ignoreFocus: false, + })); + dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' })); + }, +}); + +const PERSISTENCE_KEY = 'mastodon_home'; + +const isValidDomain = value => { + const url = new URL('https:///path'); + url.hostname = value; + return url.hostname === value; +}; + +const valueToDomain = value => { + // If the user starts typing an URL + if (/^https?:\/\//.test(value)) { + try { + const url = new URL(value); + + // Consider that if there is a path, the URL is more meaningful than a bare domain + if (url.pathname.length > 1) { + return ''; + } + + return url.host; + } catch { + return undefined; + } + // If the user writes their full handle including username + } else if (value.includes('@')) { + if (value.replace(/^@/, '').split('@').length > 2) { + return undefined; + } + return ''; + } + + return value; +}; + +const addInputToOptions = (value, options) => { + value = value.trim(); + + if (value.includes('.') && isValidDomain(value)) { + return [value].concat(options.filter((x) => x !== value)); + } + + return options; +}; + +class LoginForm extends React.PureComponent { + + static propTypes = { + resourceUrl: PropTypes.string, + intl: PropTypes.object.isRequired, + }; + + state = { + value: localStorage ? (localStorage.getItem(PERSISTENCE_KEY) || '') : '', + expanded: false, + selectedOption: -1, + isLoading: false, + isSubmitting: false, + error: false, + options: [], + networkOptions: [], + }; + + setRef = c => { + this.input = c; + }; + + isValueValid = (value) => { + let likelyAcct = false; + let url = null; + + if (value.startsWith('/')) { + return false; + } + + if (value.startsWith('@')) { + value = value.slice(1); + likelyAcct = true; + } + + // The user is in the middle of typing something, do not error out + if (value === '') { + return true; + } + + if (/^https?:\/\//.test(value) && !likelyAcct) { + url = value; + } else { + url = `https://${value}`; + } + + try { + new URL(url); + return true; + } catch(_) { + return false; + } + }; + + handleChange = ({ target }) => { + const error = !this.isValueValid(target.value); + this.setState(state => ({ error, value: target.value, isLoading: true, options: addInputToOptions(target.value, state.networkOptions) }), () => this._loadOptions()); + }; + + handleMessage = (event) => { + const { resourceUrl } = this.props; + + if (event.origin !== window.origin || event.source !== this.iframeRef.contentWindow) { + return; + } + + if (event.data?.type === 'fetchInteractionURL-failure') { + this.setState({ isSubmitting: false, error: true }); + } else if (event.data?.type === 'fetchInteractionURL-success') { + if (/^https?:\/\//.test(event.data.template)) { + try { + const url = new URL(event.data.template.replace('{uri}', encodeURIComponent(resourceUrl))); + + if (localStorage) { + localStorage.setItem(PERSISTENCE_KEY, event.data.uri_or_domain); + } + + window.location.href = url; + } catch (e) { + console.error(e); + this.setState({ isSubmitting: false, error: true }); + } + } else { + this.setState({ isSubmitting: false, error: true }); + } + } + }; + + componentDidMount () { + window.addEventListener('message', this.handleMessage); + } + + componentWillUnmount () { + window.removeEventListener('message', this.handleMessage); + } + + handleSubmit = () => { + const { value } = this.state; + + this.setState({ isSubmitting: true }); + + this.iframeRef.contentWindow.postMessage({ + type: 'fetchInteractionURL', + uri_or_domain: value.trim(), + }, window.origin); + }; + + setIFrameRef = (iframe) => { + this.iframeRef = iframe; + }; + + handleFocus = () => { + this.setState({ expanded: true }); + }; + + handleBlur = () => { + this.setState({ expanded: false }); + }; + + handleKeyDown = (e) => { + const { options, selectedOption } = this.state; + + switch(e.key) { + case 'ArrowDown': + e.preventDefault(); + + if (options.length > 0) { + this.setState({ selectedOption: Math.min(selectedOption + 1, options.length - 1) }); + } + + break; + case 'ArrowUp': + e.preventDefault(); + + if (options.length > 0) { + this.setState({ selectedOption: Math.max(selectedOption - 1, -1) }); + } + + break; + case 'Enter': + e.preventDefault(); + + if (selectedOption === -1) { + this.handleSubmit(); + } else if (options.length > 0) { + this.setState({ value: options[selectedOption], error: false }, () => this.handleSubmit()); + } + + break; + } + }; + + handleOptionClick = e => { + const index = Number(e.currentTarget.getAttribute('data-index')); + const option = this.state.options[index]; + + e.preventDefault(); + this.setState({ selectedOption: index, value: option, error: false }, () => this.handleSubmit()); + }; + + _loadOptions = throttle(() => { + const { value } = this.state; + + const domain = valueToDomain(value.trim()); + + if (typeof domain === 'undefined') { + this.setState({ options: [], networkOptions: [], isLoading: false, error: true }); + return; + } + + if (domain.length === 0) { + this.setState({ options: [], networkOptions: [], isLoading: false }); + return; + } + + api().get('/api/v1/peers/search', { params: { q: domain } }).then(({ data }) => { + if (!data) { + data = []; + } + + this.setState((state) => ({ networkOptions: data, options: addInputToOptions(state.value, data), isLoading: false })); + }).catch(() => { + this.setState({ isLoading: false }); + }); + }, 200, { leading: true, trailing: true }); + + render () { + const { intl } = this.props; + const { value, expanded, options, selectedOption, error, isSubmitting } = this.state; + const domain = (valueToDomain(value) || '').trim(); + const domainRegExp = new RegExp(`(${escapeRegExp(domain)})`, 'gi'); + const hasPopOut = domain.length > 0 && options.length > 0; + + return ( + <div className={classNames('interaction-modal__login', { focused: expanded, expanded: hasPopOut, invalid: error })}> + + <iframe + ref={this.setIFrameRef} + style={{display: 'none'}} + src='/remote_interaction_helper' + sandbox='allow-scripts allow-same-origin' + title='remote interaction helper' + /> + + <div className='interaction-modal__login__input'> + <input + ref={this.setRef} + type='text' + value={value} + placeholder={intl.formatMessage(messages.loginPrompt)} + aria-label={intl.formatMessage(messages.loginPrompt)} + autoFocus + onChange={this.handleChange} + onFocus={this.handleFocus} + onBlur={this.handleBlur} + onKeyDown={this.handleKeyDown} + autoComplete='off' + autoCapitalize='off' + spellCheck='false' + /> + + <Button onClick={this.handleSubmit} disabled={isSubmitting || error}><FormattedMessage id='interaction_modal.login.action' defaultMessage='Take me home' /></Button> + </div> + + {hasPopOut && ( + <div className='search__popout'> + <div className='search__popout__menu'> + {options.map((option, i) => ( + <button key={option} onMouseDown={this.handleOptionClick} data-index={i} className={classNames('search__popout__menu__item', { selected: selectedOption === i })}> + {option.split(domainRegExp).map((part, i) => ( + part.toLowerCase() === domain.toLowerCase() ? ( + <mark key={i}> + {part} + </mark> + ) : ( + <span key={i}> + {part} + </span> + ) + ))} + </button> + ))} + </div> + </div> + )} + </div> + ); + } + +} + +const IntlLoginForm = injectIntl(LoginForm); + +class InteractionModal extends React.PureComponent { + + static propTypes = { + displayNameHtml: PropTypes.string, + url: PropTypes.string, + type: PropTypes.oneOf(['reply', 'reblog', 'favourite', 'follow']), + onSignupClick: PropTypes.func.isRequired, + signupUrl: PropTypes.string.isRequired, + }; + + handleSignupClick = () => { + this.props.onSignupClick(); + }; + + render () { + const { url, type, displayNameHtml, signupUrl } = this.props; + + const name = <bdi dangerouslySetInnerHTML={{ __html: displayNameHtml }} />; + + let title, actionDescription, icon; + + switch(type) { + case 'reply': + icon = <Icon id='reply' />; + title = <FormattedMessage id='interaction_modal.title.reply' defaultMessage="Reply to {name}'s post" values={{ name }} />; + actionDescription = <FormattedMessage id='interaction_modal.description.reply' defaultMessage='With an account on Mastodon, you can respond to this post.' />; + break; + case 'reblog': + icon = <Icon id='retweet' />; + title = <FormattedMessage id='interaction_modal.title.reblog' defaultMessage="Boost {name}'s post" values={{ name }} />; + actionDescription = <FormattedMessage id='interaction_modal.description.reblog' defaultMessage='With an account on Mastodon, you can boost this post to share it with your own followers.' />; + break; + case 'favourite': + icon = <Icon id='star' />; + title = <FormattedMessage id='interaction_modal.title.favourite' defaultMessage="Favorite {name}'s post" values={{ name }} />; + actionDescription = <FormattedMessage id='interaction_modal.description.favourite' defaultMessage='With an account on Mastodon, you can favorite this post to let the author know you appreciate it and save it for later.' />; + break; + case 'follow': + icon = <Icon id='user-plus' />; + title = <FormattedMessage id='interaction_modal.title.follow' defaultMessage='Follow {name}' values={{ name }} />; + actionDescription = <FormattedMessage id='interaction_modal.description.follow' defaultMessage='With an account on Mastodon, you can follow {name} to receive their posts in your home feed.' values={{ name }} />; + break; + } + + let signupButton; + + if (sso_redirect) { + signupButton = ( + <a href={sso_redirect} data-method='post' className='link-button'> + <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' /> + </a> + ); + } else if (registrationsOpen) { + signupButton = ( + <a href={signupUrl} className='link-button'> + <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' /> + </a> + ); + } else { + signupButton = ( + <button className='link-button' onClick={this.handleSignupClick}> + <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' /> + </button> + ); + } + + return ( + <div className='modal-root__modal interaction-modal'> + <div className='interaction-modal__lead'> + <h3><span className='interaction-modal__icon'>{icon}</span> {title}</h3> + <p>{actionDescription} <strong><FormattedMessage id='interaction_modal.sign_in' defaultMessage='You are not logged in to this server. Where is your account hosted?' /></strong></p> + </div> + + <IntlLoginForm resourceUrl={url} /> + + <p className='hint'><FormattedMessage id='interaction_modal.sign_in_hint' defaultMessage="Tip: That's the website where you signed up. If you don't remember, look for the welcome e-mail in your inbox. You can also enter your full username! (e.g. @Mastodon@mastodon.social)" /></p> + <p><FormattedMessage id='interaction_modal.no_account_yet' defaultMessage='Not on Mastodon?' /> {signupButton}</p> + </div> + ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(InteractionModal); diff --git a/app/javascript/flavours/blobfox/features/keyboard_shortcuts/index.jsx b/app/javascript/flavours/blobfox/features/keyboard_shortcuts/index.jsx new file mode 100644 index 00000000000000..220cc37ccc6b75 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/keyboard_shortcuts/index.jsx @@ -0,0 +1,152 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import Column from 'flavours/blobfox/components/column'; +import ColumnHeader from 'flavours/blobfox/components/column_header'; + +const messages = defineMessages({ + heading: { id: 'keyboard_shortcuts.heading', defaultMessage: 'Keyboard Shortcuts' }, +}); + +const mapStateToProps = state => ({ + collapseEnabled: state.getIn(['local_settings', 'collapsed', 'enabled']), +}); + +class KeyboardShortcuts extends ImmutablePureComponent { + + static propTypes = { + intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, + collapseEnabled: PropTypes.bool, + }; + + render () { + const { intl, collapseEnabled, multiColumn } = this.props; + + return ( + <Column> + <ColumnHeader + title={intl.formatMessage(messages.heading)} + icon='question' + multiColumn={multiColumn} + /> + + <div className='keyboard-shortcuts scrollable optionally-scrollable'> + <table> + <thead> + <tr> + <th><FormattedMessage id='keyboard_shortcuts.hotkey' defaultMessage='Hotkey' /></th> + <th><FormattedMessage id='keyboard_shortcuts.description' defaultMessage='Description' /></th> + </tr> + </thead> + <tbody> + <tr> + <td><kbd>r</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.reply' defaultMessage='to reply' /></td> + </tr> + <tr> + <td><kbd>m</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.mention' defaultMessage='to mention author' /></td> + </tr> + <tr> + <td><kbd>p</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.profile' defaultMessage="to open author's profile" /></td> + </tr> + <tr> + <td><kbd>f</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.favourite' defaultMessage='to favorite' /></td> + </tr> + <tr> + <td><kbd>b</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.boost' defaultMessage='to boost' /></td> + </tr> + <tr> + <td><kbd>d</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.bookmark' defaultMessage='to bookmark' /></td> + </tr> + <tr> + <td><kbd>enter</kbd>, <kbd>o</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open status' /></td> + </tr> + <tr> + <td><kbd>e</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.open_media' defaultMessage='to open media' /></td> + </tr> + <tr> + <td><kbd>x</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.toggle_hidden' defaultMessage='to show/hide text behind CW' /></td> + </tr> + <tr> + <td><kbd>h</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></td> + </tr> + {collapseEnabled && ( + <tr> + <td><kbd>shift</kbd>+<kbd>x</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.toggle_collapse' defaultMessage='to collapse/uncollapse toots' /></td> + </tr> + )} + <tr> + <td><kbd>up</kbd>, <kbd>k</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td> + </tr> + <tr> + <td><kbd>down</kbd>, <kbd>j</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.down' defaultMessage='to move down in the list' /></td> + </tr> + <tr> + <td><kbd>1</kbd>-<kbd>9</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.column' defaultMessage='to focus a status in one of the columns' /></td> + </tr> + <tr> + <td><kbd>n</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.compose' defaultMessage='to focus the compose textarea' /></td> + </tr> + <tr> + <td><kbd>alt</kbd>+<kbd>n</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a brand new post' /></td> + </tr> + <tr> + <td><kbd>alt</kbd>+<kbd>x</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.spoilers' defaultMessage='to show/hide CW field' /></td> + </tr> + <tr> + <td><kbd>backspace</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></td> + </tr> + <tr> + <td><kbd>s</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.search' defaultMessage='to focus search' /></td> + </tr> + <tr> + <td><kbd>alt</kbd>+<kbd>enter</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.secondary_toot' defaultMessage='to send toot using secondary privacy setting' /></td> + </tr> + <tr> + <td><kbd>esc</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.unfocus' defaultMessage='to un-focus compose textarea/search' /></td> + </tr> + <tr> + <td><kbd>?</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.legend' defaultMessage='to display this legend' /></td> + </tr> + </tbody> + </table> + </div> + + <Helmet> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(KeyboardShortcuts)); diff --git a/app/javascript/flavours/blobfox/features/list_adder/components/account.jsx b/app/javascript/flavours/blobfox/features/list_adder/components/account.jsx new file mode 100644 index 00000000000000..94a90726e33342 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/list_adder/components/account.jsx @@ -0,0 +1,43 @@ +import { injectIntl } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { Avatar } from '../../../components/avatar'; +import { DisplayName } from '../../../components/display_name'; +import { makeGetAccount } from '../../../selectors'; + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { accountId }) => ({ + account: getAccount(state, accountId), + }); + + return mapStateToProps; +}; + +class Account extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.record.isRequired, + }; + + render () { + const { account } = this.props; + return ( + <div className='account'> + <div className='account__wrapper'> + <div className='account__display-name'> + <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> + <DisplayName account={account} /> + </div> + </div> + </div> + ); + } + +} + +export default connect(makeMapStateToProps)(injectIntl(Account)); diff --git a/app/javascript/flavours/blobfox/features/list_adder/components/list.jsx b/app/javascript/flavours/blobfox/features/list_adder/components/list.jsx new file mode 100644 index 00000000000000..385bd9f404a44f --- /dev/null +++ b/app/javascript/flavours/blobfox/features/list_adder/components/list.jsx @@ -0,0 +1,72 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { Icon } from 'flavours/blobfox/components/icon'; + +import { removeFromListAdder, addToListAdder } from '../../../actions/lists'; +import { IconButton } from '../../../components/icon_button'; + +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)), +}); + +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 = <IconButton icon='times' title={intl.formatMessage(messages.remove)} onClick={onRemove} />; + } else { + button = <IconButton icon='plus' title={intl.formatMessage(messages.add)} onClick={onAdd} />; + } + + return ( + <div className='list'> + <div className='list__wrapper'> + <div className='list__display-name'> + <Icon id='list-ul' className='column-link__icon' fixedWidth /> + {list.get('title')} + </div> + + <div className='account__relationship'> + {button} + </div> + </div> + </div> + ); + } + +} + +export default connect(MapStateToProps, mapDispatchToProps)(injectIntl(List)); diff --git a/app/javascript/flavours/blobfox/features/list_adder/index.jsx b/app/javascript/flavours/blobfox/features/list_adder/index.jsx new file mode 100644 index 00000000000000..1ba9972e00d346 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/list_adder/index.jsx @@ -0,0 +1,76 @@ +import PropTypes from 'prop-types'; + +import { injectIntl } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; + +import { setupListAdder, resetListAdder } from '../../actions/lists'; +import NewListForm from '../lists/components/new_list_form'; + +import Account from './components/account'; +import List from './components/list'; +// 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()), +}); + +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 ( + <div className='modal-root__modal list-adder'> + <div className='list-adder__account'> + <Account accountId={accountId} /> + </div> + + <NewListForm /> + + + <div className='list-adder__lists'> + {listIds.map(ListId => <List key={ListId} listId={ListId} />)} + </div> + </div> + ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ListAdder)); diff --git a/app/javascript/flavours/blobfox/features/list_editor/components/account.jsx b/app/javascript/flavours/blobfox/features/list_editor/components/account.jsx new file mode 100644 index 00000000000000..96b5e96df87e55 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/list_editor/components/account.jsx @@ -0,0 +1,79 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { removeFromListEditor, addToListEditor } from '../../../actions/lists'; +import { Avatar } from '../../../components/avatar'; +import { DisplayName } from '../../../components/display_name'; +import { IconButton } from '../../../components/icon_button'; +import { makeGetAccount } from '../../../selectors'; + +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)), +}); + +class Account extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.record.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 = <IconButton icon='times' title={intl.formatMessage(messages.remove)} onClick={onRemove} />; + } else { + button = <IconButton icon='plus' title={intl.formatMessage(messages.add)} onClick={onAdd} />; + } + + return ( + <div className='account'> + <div className='account__wrapper'> + <div className='account__display-name'> + <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> + <DisplayName account={account} /> + </div> + + <div className='account__relationship'> + {button} + </div> + </div> + </div> + ); + } + +} + +export default connect(makeMapStateToProps, mapDispatchToProps)(injectIntl(Account)); diff --git a/app/javascript/flavours/blobfox/features/list_editor/components/edit_list_form.jsx b/app/javascript/flavours/blobfox/features/list_editor/components/edit_list_form.jsx new file mode 100644 index 00000000000000..9e087a97d714d9 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/list_editor/components/edit_list_form.jsx @@ -0,0 +1,73 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import { connect } from 'react-redux'; + +import { changeListEditorTitle, submitListEditor } from '../../../actions/lists'; +import { IconButton } from '../../../components/icon_button'; + +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)), +}); + +class ListForm extends 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 ( + <form className='column-inline-form' onSubmit={this.handleSubmit}> + <input + className='setting-text' + value={value} + onChange={this.handleChange} + /> + + <IconButton + disabled={disabled} + icon='check' + title={title} + onClick={this.handleClick} + /> + </form> + ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ListForm)); diff --git a/app/javascript/flavours/blobfox/features/list_editor/components/search.jsx b/app/javascript/flavours/blobfox/features/list_editor/components/search.jsx new file mode 100644 index 00000000000000..04899ac8a719a1 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/list_editor/components/search.jsx @@ -0,0 +1,81 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import { connect } from 'react-redux'; + +import { Icon } from 'flavours/blobfox/components/icon'; + +import { fetchListSuggestions, clearListSuggestions, changeListSuggestions } from '../../../actions/lists'; + +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)), +}); + +class Search extends 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 ( + <div className='list-editor__search search'> + <label> + <span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span> + + <input + className='search__input' + type='text' + value={value} + onChange={this.handleChange} + onKeyUp={this.handleKeyUp} + placeholder={intl.formatMessage(messages.search)} + /> + </label> + + <div role='button' tabIndex={0} className='search__icon' onClick={this.handleClear}> + <Icon id='search' className={classNames({ active: !hasValue })} /> + <Icon id='times-circle' aria-label={intl.formatMessage(messages.search)} className={classNames({ active: hasValue })} /> + </div> + </div> + ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(Search)); diff --git a/app/javascript/flavours/blobfox/features/list_editor/index.jsx b/app/javascript/flavours/blobfox/features/list_editor/index.jsx new file mode 100644 index 00000000000000..85e90169e80b2c --- /dev/null +++ b/app/javascript/flavours/blobfox/features/list_editor/index.jsx @@ -0,0 +1,83 @@ +import PropTypes from 'prop-types'; + +import { injectIntl } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import spring from 'react-motion/lib/spring'; + +import { setupListEditor, clearListSuggestions, resetListEditor } from '../../actions/lists'; +import Motion from '../ui/util/optional_motion'; + +import Account from './components/account'; +import EditListForm from './components/edit_list_form'; +import Search from './components/search'; + +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()), +}); + +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 ( + <div className='modal-root__modal list-editor'> + <EditListForm /> + + <Search /> + + <div className='drawer__pager'> + <div className='drawer__inner list-editor__accounts'> + {accountIds.map(accountId => <Account key={accountId} accountId={accountId} added />)} + </div> + + {showSearch && <div role='button' tabIndex={-1} className='drawer__backdrop' onClick={onClear} />} + + <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}> + {({ x }) => ( + <div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}> + {searchAccountIds.map(accountId => <Account key={accountId} accountId={accountId} />)} + </div> + )} + </Motion> + </div> + </div> + ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ListEditor)); diff --git a/app/javascript/flavours/blobfox/features/list_timeline/index.jsx b/app/javascript/flavours/blobfox/features/list_timeline/index.jsx new file mode 100644 index 00000000000000..e3ddab06abcfff --- /dev/null +++ b/app/javascript/flavours/blobfox/features/list_timeline/index.jsx @@ -0,0 +1,244 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; + +import { Helmet } from 'react-helmet'; +import { withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +import Toggle from 'react-toggle'; + +import { addColumn, removeColumn, moveColumn } from 'flavours/blobfox/actions/columns'; +import { fetchList, deleteList, updateList } from 'flavours/blobfox/actions/lists'; +import { openModal } from 'flavours/blobfox/actions/modal'; +import { connectListStream } from 'flavours/blobfox/actions/streaming'; +import { expandListTimeline } from 'flavours/blobfox/actions/timelines'; +import Column from 'flavours/blobfox/components/column'; +import ColumnHeader from 'flavours/blobfox/components/column_header'; +import { Icon } from 'flavours/blobfox/components/icon'; +import { LoadingIndicator } from 'flavours/blobfox/components/loading_indicator'; +import { RadioButton } from 'flavours/blobfox/components/radio_button'; +import BundleColumnError from 'flavours/blobfox/features/ui/components/bundle_column_error'; +import StatusListContainer from 'flavours/blobfox/features/ui/containers/status_list_container'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +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, +}); + +class ListTimeline extends PureComponent { + + 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, + ...WithRouterPropTypes, + }; + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('LIST', { id: this.props.params.id })); + this.props.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)); + } + + UNSAFE_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({ + modalType: 'LIST_EDITOR', + modalProps: { listId: this.props.params.id }, + })); + }; + + handleDeleteClick = () => { + const { dispatch, columnId, intl } = this.props; + const { id } = this.props.params; + + dispatch(openModal({ + modalType: 'CONFIRM', + modalProps: { + message: intl.formatMessage(messages.deleteMessage), + confirm: intl.formatMessage(messages.deleteConfirm), + onConfirm: () => { + dispatch(deleteList(id)); + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + this.props.history.push('/lists'); + } + }, + }, + })); + }; + + handleRepliesPolicyChange = ({ target }) => { + const { dispatch } = this.props; + const { id } = this.props.params; + dispatch(updateList(id, undefined, false, undefined, target.value)); + }; + + onExclusiveToggle = ({ target }) => { + const { dispatch } = this.props; + const { id } = this.props.params; + dispatch(updateList(id, undefined, false, target.checked, undefined)); + }; + + 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; + const isExclusive = list ? list.get('exclusive') : undefined; + + if (typeof list === 'undefined') { + return ( + <Column> + <div className='scrollable'> + <LoadingIndicator /> + </div> + </Column> + ); + } else if (list === false) { + return ( + <BundleColumnError multiColumn={multiColumn} errorType='routing' /> + ); + } + + return ( + <Column bindToDocument={!multiColumn} ref={this.setRef} label={title}> + <ColumnHeader + icon='list-ul' + active={hasUnread} + title={title} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + > + <div className='column-settings__row column-header__links'> + <button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleEditClick}> + <Icon id='pencil' /> <FormattedMessage id='lists.edit' defaultMessage='Edit list' /> + </button> + + <button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleDeleteClick}> + <Icon id='trash' /> <FormattedMessage id='lists.delete' defaultMessage='Delete list' /> + </button> + </div> + + <div className='setting-toggle'> + <Toggle id={`list-${id}-exclusive`} checked={isExclusive} onChange={this.onExclusiveToggle} /> + <label htmlFor={`list-${id}-exclusive`} className='setting-toggle__label'> + <FormattedMessage id='lists.exclusive' defaultMessage='Hide these posts from home' /> + </label> + </div> + + { replies_policy !== undefined && ( + <div role='group' aria-labelledby={`list-${id}-replies-policy`}> + <span id={`list-${id}-replies-policy`} className='column-settings__section'> + <FormattedMessage id='lists.replies_policy.title' defaultMessage='Show replies to:' /> + </span> + <div className='column-settings__row'> + { ['none', 'list', 'followed'].map(policy => ( + <RadioButton name='order' key={policy} value={policy} label={intl.formatMessage(messages[policy])} checked={replies_policy === policy} onChange={this.handleRepliesPolicyChange} /> + ))} + </div> + </div> + )} + + <hr /> + </ColumnHeader> + + <StatusListContainer + trackScroll={!pinned} + scrollKey={`list_timeline-${columnId}`} + timelineId={`list:${id}`} + onLoadMore={this.handleLoadMore} + emptyMessage={<FormattedMessage id='empty_column.list' defaultMessage='There is nothing in this list yet. When members of this list post new statuses, they will appear here.' />} + bindToDocument={!multiColumn} + /> + + <Helmet> + <title>{title}</title> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} + +export default withRouter(connect(mapStateToProps)(injectIntl(ListTimeline))); diff --git a/app/javascript/flavours/blobfox/features/lists/components/new_list_form.jsx b/app/javascript/flavours/blobfox/features/lists/components/new_list_form.jsx new file mode 100644 index 00000000000000..f5581a765a7a7c --- /dev/null +++ b/app/javascript/flavours/blobfox/features/lists/components/new_list_form.jsx @@ -0,0 +1,81 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import { connect } from 'react-redux'; + +import { changeListEditorTitle, submitListEditor } from 'flavours/blobfox/actions/lists'; +import { IconButton } from 'flavours/blobfox/components/icon_button'; + +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)), +}); + +class NewListForm extends 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 ( + <form className='column-inline-form' onSubmit={this.handleSubmit}> + <label> + <span style={{ display: 'none' }}>{label}</span> + + <input + className='setting-text' + value={value} + disabled={disabled} + onChange={this.handleChange} + placeholder={label} + /> + </label> + + <IconButton + disabled={disabled || !value} + icon='plus' + title={title} + onClick={this.handleClick} + /> + </form> + ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(NewListForm)); diff --git a/app/javascript/flavours/blobfox/features/lists/index.jsx b/app/javascript/flavours/blobfox/features/lists/index.jsx new file mode 100644 index 00000000000000..9384df3cf210e1 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/lists/index.jsx @@ -0,0 +1,93 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; + +import { fetchLists } from 'flavours/blobfox/actions/lists'; +import ColumnBackButtonSlim from 'flavours/blobfox/components/column_back_button_slim'; +import { LoadingIndicator } from 'flavours/blobfox/components/loading_indicator'; +import ScrollableList from 'flavours/blobfox/components/scrollable_list'; +import Column from 'flavours/blobfox/features/ui/components/column'; +import ColumnLink from 'flavours/blobfox/features/ui/components/column_link'; +import ColumnSubheading from 'flavours/blobfox/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), +}); + +class Lists extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + lists: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, + }; + + UNSAFE_componentWillMount () { + this.props.dispatch(fetchLists()); + } + + render () { + const { intl, lists, multiColumn } = this.props; + + if (!lists) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + const emptyMessage = <FormattedMessage id='empty_column.lists' defaultMessage="You don't have any lists yet. When you create one, it will show up here." />; + + return ( + <Column bindToDocument={!multiColumn} icon='bars' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + + <NewListForm /> + + <ColumnSubheading text={intl.formatMessage(messages.subheading)} /> + <ScrollableList + scrollKey='lists' + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {lists.map(list => + <ColumnLink key={list.get('id')} to={`/lists/${list.get('id')}`} icon='list-ul' text={list.get('title')} />, + )} + </ScrollableList> + + <Helmet> + <title>{intl.formatMessage(messages.heading)}</title> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(Lists)); diff --git a/app/javascript/flavours/blobfox/features/local_settings/index.jsx b/app/javascript/flavours/blobfox/features/local_settings/index.jsx new file mode 100644 index 00000000000000..c6062c2bd06c7c --- /dev/null +++ b/app/javascript/flavours/blobfox/features/local_settings/index.jsx @@ -0,0 +1,70 @@ +// Package imports. +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +// Our imports +import { changeLocalSetting } from 'flavours/blobfox/actions/local_settings'; +import { closeModal } from 'flavours/blobfox/actions/modal'; + +import LocalSettingsNavigation from './navigation'; +import LocalSettingsPage from './page'; + +const mapStateToProps = state => ({ + settings: state.get('local_settings'), +}); + +const mapDispatchToProps = dispatch => ({ + onChange (setting, value) { + dispatch(changeLocalSetting(setting, value)); + }, + onClose () { + dispatch(closeModal({ + modalType: undefined, + ignoreFocus: false, + })); + }, +}); + +class LocalSettings extends PureComponent { + + static propTypes = { + onChange: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + settings: ImmutablePropTypes.map.isRequired, + }; + + state = { + currentIndex: 0, + }; + + navigateTo = (index) => + this.setState({ currentIndex: +index }); + + render () { + + const { navigateTo } = this; + const { onChange, onClose, settings } = this.props; + const { currentIndex } = this.state; + + return ( + <div className='blobfox modal-root__modal local-settings'> + <LocalSettingsNavigation + index={currentIndex} + onClose={onClose} + onNavigate={navigateTo} + /> + <LocalSettingsPage + index={currentIndex} + onChange={onChange} + settings={settings} + /> + </div> + ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(LocalSettings); diff --git a/app/javascript/flavours/blobfox/features/local_settings/navigation/index.jsx b/app/javascript/flavours/blobfox/features/local_settings/navigation/index.jsx new file mode 100644 index 00000000000000..1f9f3a3edb84cc --- /dev/null +++ b/app/javascript/flavours/blobfox/features/local_settings/navigation/index.jsx @@ -0,0 +1,95 @@ +// Package imports +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { injectIntl, defineMessages } from 'react-intl'; + +// Our imports +import { preferencesLink } from 'flavours/blobfox/utils/backend_links'; + +import LocalSettingsNavigationItem from './item'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +const messages = defineMessages({ + general: { id: 'settings.general', defaultMessage: 'General' }, + compose: { id: 'settings.compose_box_opts', defaultMessage: 'Compose box' }, + content_warnings: { id: 'settings.content_warnings', defaultMessage: 'Content Warnings' }, + collapsed: { id: 'settings.collapsed_statuses', defaultMessage: 'Collapsed toots' }, + media: { id: 'settings.media', defaultMessage: 'Media' }, + preferences: { id: 'settings.preferences', defaultMessage: 'Preferences' }, + close: { id: 'settings.close', defaultMessage: 'Close' }, +}); + +class LocalSettingsNavigation extends PureComponent { + + static propTypes = { + index : PropTypes.number, + intl : PropTypes.object.isRequired, + onClose : PropTypes.func.isRequired, + onNavigate : PropTypes.func.isRequired, + }; + + render () { + + const { index, intl, onClose, onNavigate } = this.props; + + return ( + <nav className='blobfox local-settings__navigation'> + <LocalSettingsNavigationItem + active={index === 0} + index={0} + onNavigate={onNavigate} + icon='cogs' + title={intl.formatMessage(messages.general)} + /> + <LocalSettingsNavigationItem + active={index === 1} + index={1} + onNavigate={onNavigate} + icon='pencil' + title={intl.formatMessage(messages.compose)} + /> + <LocalSettingsNavigationItem + active={index === 2} + index={2} + onNavigate={onNavigate} + textIcon='CW' + title={intl.formatMessage(messages.content_warnings)} + /> + <LocalSettingsNavigationItem + active={index === 3} + index={3} + onNavigate={onNavigate} + icon='angle-double-up' + title={intl.formatMessage(messages.collapsed)} + /> + <LocalSettingsNavigationItem + active={index === 4} + index={4} + onNavigate={onNavigate} + icon='image' + title={intl.formatMessage(messages.media)} + /> + <LocalSettingsNavigationItem + active={index === 5} + href={preferencesLink} + index={5} + icon='cog' + title={intl.formatMessage(messages.preferences)} + /> + <LocalSettingsNavigationItem + active={index === 6} + className='close' + index={6} + onNavigate={onClose} + icon='times' + title={intl.formatMessage(messages.close)} + /> + </nav> + ); + } + +} + +export default injectIntl(LocalSettingsNavigation); diff --git a/app/javascript/flavours/blobfox/features/local_settings/navigation/item/index.jsx b/app/javascript/flavours/blobfox/features/local_settings/navigation/item/index.jsx new file mode 100644 index 00000000000000..30e692a92f0844 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/local_settings/navigation/item/index.jsx @@ -0,0 +1,73 @@ +// Package imports +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import classNames from 'classnames'; + +import { Icon } from 'flavours/blobfox/components/icon'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +export default class LocalSettingsPage extends PureComponent { + + static propTypes = { + active: PropTypes.bool, + className: PropTypes.string, + href: PropTypes.string, + icon: PropTypes.string, + textIcon: PropTypes.string, + index: PropTypes.number.isRequired, + onNavigate: PropTypes.func, + title: PropTypes.string, + }; + + handleClick = (e) => { + const { index, onNavigate } = this.props; + if (onNavigate) { + onNavigate(index); + e.preventDefault(); + } + }; + + render () { + const { handleClick } = this; + const { + active, + className, + href, + icon, + textIcon, + onNavigate, + title, + } = this.props; + + const finalClassName = classNames('blobfox', 'local-settings__navigation__item', { + active, + }, className); + + const iconElem = icon ? <Icon fixedWidth id={icon} /> : (textIcon ? <span className='text-icon-button'>{textIcon}</span> : null); + + if (href) return ( + <a + href={href} + className={finalClassName} + title={title} + aria-label={title} + > + {iconElem} <span>{title}</span> + </a> + ); + else if (onNavigate) return ( + <button + onClick={handleClick} + className={finalClassName} + title={title} + aria-label={title} + > + {iconElem} <span>{title}</span> + </button> + ); + else return null; + } + +} diff --git a/app/javascript/flavours/blobfox/features/local_settings/page/deprecated_item/index.jsx b/app/javascript/flavours/blobfox/features/local_settings/page/deprecated_item/index.jsx new file mode 100644 index 00000000000000..0f53952cbe45cf --- /dev/null +++ b/app/javascript/flavours/blobfox/features/local_settings/page/deprecated_item/index.jsx @@ -0,0 +1,83 @@ +// Package imports +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +export default class LocalSettingsPageItem extends PureComponent { + + static propTypes = { + children: PropTypes.node.isRequired, + id: PropTypes.string.isRequired, + options: PropTypes.arrayOf(PropTypes.shape({ + value: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + hint: PropTypes.string, + })), + value: PropTypes.any, + placeholder: PropTypes.string, + }; + + render () { + const { id, options, children, placeholder, value } = this.props; + + if (options && options.length > 0) { + const currentValue = value; + const optionElems = options && options.length > 0 && options.map((opt) => { + let optionId = `${id}--${opt.value}`; + return ( + <label key={id} htmlFor={optionId}> + <input + type='radio' + name={id} + id={optionId} + value={opt.value} + checked={currentValue === opt.value} + disabled + /> + {opt.message} + {opt.hint && <span className='hint'>{opt.hint}</span>} + </label> + ); + }); + return ( + <div className='blobfox local-settings__page__item radio_buttons'> + <fieldset> + <legend>{children}</legend> + {optionElems} + </fieldset> + </div> + ); + } else if (placeholder) { + return ( + <div className='blobfox local-settings__page__item string'> + <label htmlFor={id}> + <p>{children}</p> + <p> + <input + id={id} + type='text' + value={value} + placeholder={placeholder} + disabled + /> + </p> + </label> + </div> + ); + } else return ( + <div className='blobfox local-settings__page__item boolean'> + <label htmlFor={id}> + <input + id={id} + type='checkbox' + checked={value} + disabled + /> + {children} + </label> + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/local_settings/page/index.jsx b/app/javascript/flavours/blobfox/features/local_settings/page/index.jsx new file mode 100644 index 00000000000000..917839d5c10ac5 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/local_settings/page/index.jsx @@ -0,0 +1,505 @@ +// Package imports +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + + +// Our imports +import { expandSpoilers } from 'flavours/blobfox/initial_state'; +import { preferenceLink } from 'flavours/blobfox/utils/backend_links'; + +import DeprecatedLocalSettingsPageItem from './deprecated_item'; +import LocalSettingsPageItem from './item'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +const messages = defineMessages({ + side_arm_none: { id: 'settings.side_arm.none', defaultMessage: 'None' }, + side_arm_keep: { id: 'settings.side_arm_reply_mode.keep', defaultMessage: 'Keep its set privacy' }, + side_arm_copy: { id: 'settings.side_arm_reply_mode.copy', defaultMessage: 'Copy privacy setting of the toot being replied to' }, + side_arm_restrict: { id: 'settings.side_arm_reply_mode.restrict', defaultMessage: 'Restrict privacy setting to that of the toot being replied to' }, + regexp: { id: 'settings.content_warnings.regexp', defaultMessage: 'Regular expression' }, + rewrite_mentions_no: { id: 'settings.rewrite_mentions_no', defaultMessage: 'Do not rewrite mentions' }, + rewrite_mentions_acct: { id: 'settings.rewrite_mentions_acct', defaultMessage: 'Rewrite with username and domain (when the account is remote)' }, + rewrite_mentions_username: { id: 'settings.rewrite_mentions_username', defaultMessage: 'Rewrite with username' }, + pop_in_left: { id: 'settings.pop_in_left', defaultMessage: 'Left' }, + pop_in_right: { id: 'settings.pop_in_right', defaultMessage: 'Right' }, + public: { id: 'privacy.public.short', defaultMessage: 'Public' }, + unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, + private: { id: 'privacy.private.short', defaultMessage: 'Followers only' }, + direct: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, +}); + +class LocalSettingsPage extends PureComponent { + + static propTypes = { + index : PropTypes.number, + intl : PropTypes.object.isRequired, + onChange : PropTypes.func.isRequired, + settings : ImmutablePropTypes.map.isRequired, + }; + + pages = [ + ({ intl, onChange, settings }) => ( + <div className='blobfox local-settings__page general'> + <h1><FormattedMessage id='settings.general' defaultMessage='General' /></h1> + <LocalSettingsPageItem + settings={settings} + item={['show_reply_count']} + id='mastodon-settings--reply-count' + onChange={onChange} + > + <FormattedMessage id='settings.show_reply_counter' defaultMessage='Display an estimate of the reply count' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['hicolor_privacy_icons']} + id='mastodon-settings--hicolor_privacy_icons' + onChange={onChange} + > + <FormattedMessage id='settings.hicolor_privacy_icons' defaultMessage='High color privacy icons' /> + <span className='hint'><FormattedMessage id='settings.hicolor_privacy_icons.hint' defaultMessage='Display privacy icons in bright and easily distinguishable colors' /></span> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['confirm_boost_missing_media_description']} + id='mastodon-settings--confirm_boost_missing_media_description' + onChange={onChange} + > + <FormattedMessage id='settings.confirm_boost_missing_media_description' defaultMessage='Show confirmation dialog before boosting toots lacking media descriptions' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['tag_misleading_links']} + id='mastodon-settings--tag_misleading_links' + onChange={onChange} + > + <FormattedMessage id='settings.tag_misleading_links' defaultMessage='Tag misleading links' /> + <span className='hint'><FormattedMessage id='settings.tag_misleading_links.hint' defaultMessage='Add a visual indication with the link target host to every link not mentioning it explicitly' /></span> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['rewrite_mentions']} + id='mastodon-settings--rewrite_mentions' + options={[ + { value: 'no', message: intl.formatMessage(messages.rewrite_mentions_no) }, + { value: 'acct', message: intl.formatMessage(messages.rewrite_mentions_acct) }, + { value: 'username', message: intl.formatMessage(messages.rewrite_mentions_username) }, + ]} + onChange={onChange} + > + <FormattedMessage id='settings.rewrite_mentions' defaultMessage='Rewrite mentions in displayed statuses' /> + </LocalSettingsPageItem> + <section> + <h2><FormattedMessage id='settings.notifications_opts' defaultMessage='Notifications options' /></h2> + <LocalSettingsPageItem + settings={settings} + item={['notifications', 'tab_badge']} + id='mastodon-settings--notifications-tab_badge' + onChange={onChange} + > + <FormattedMessage id='settings.notifications.tab_badge' defaultMessage='Unread notifications badge' /> + <span className='hint'><FormattedMessage id='settings.notifications.tab_badge.hint' defaultMessage="Display a badge for unread notifications in the column icons when the notifications column isn't open" /></span> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['notifications', 'favicon_badge']} + id='mastodon-settings--notifications-favicon_badge' + onChange={onChange} + > + <FormattedMessage id='settings.notifications.favicon_badge' defaultMessage='Unread notifications favicon badge' /> + <span className='hint'><FormattedMessage id='settings.notifications.favicon_badge.hint' defaultMessage='Add a badge for unread notifications to the favicon' /></span> + </LocalSettingsPageItem> + </section> + + <section> + <h2><FormattedMessage id='settings.status_icons' defaultMessage='Toot icons' /></h2> + <LocalSettingsPageItem + settings={settings} + item={['status_icons', 'language']} + id='mastodon-settings--status-icons-language' + onChange={onChange} + > + <FormattedMessage id='settings.status_icons_language' defaultMessage='Language indicator' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['status_icons', 'reply']} + id='mastodon-settings--status-icons-reply' + onChange={onChange} + > + <FormattedMessage id='settings.status_icons_reply' defaultMessage='Reply indicator' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['status_icons', 'local_only']} + id='mastodon-settings--status-icons-local_only' + onChange={onChange} + > + <FormattedMessage id='settings.status_icons_local_only' defaultMessage='Local-only indicator' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['status_icons', 'media']} + id='mastodon-settings--status-icons-media' + onChange={onChange} + > + <FormattedMessage id='settings.status_icons_media' defaultMessage='Media and poll indicators' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['status_icons', 'visibility']} + id='mastodon-settings--status-icons-visibility' + onChange={onChange} + > + <FormattedMessage id='settings.status_icons_visibility' defaultMessage='Toot privacy indicator' /> + </LocalSettingsPageItem> + </section> + <section> + <h2><FormattedMessage id='settings.layout_opts' defaultMessage='Layout options' /></h2> + <LocalSettingsPageItem + settings={settings} + item={['stretch']} + id='mastodon-settings--stretch' + onChange={onChange} + > + <FormattedMessage id='settings.wide_view' defaultMessage='Wide view (Desktop mode only)' /> + <span className='hint'><FormattedMessage id='settings.wide_view_hint' defaultMessage='Stretches columns to better fill the available space.' /></span> + </LocalSettingsPageItem> + </section> + </div> + ), + ({ intl, onChange, settings }) => ( + <div className='blobfox local-settings__page compose_box_opts'> + <h1><FormattedMessage id='settings.compose_box_opts' defaultMessage='Compose box' /></h1> + <LocalSettingsPageItem + settings={settings} + item={['always_show_spoilers_field']} + id='mastodon-settings--always_show_spoilers_field' + onChange={onChange} + > + <FormattedMessage id='settings.always_show_spoilers_field' defaultMessage='Always enable the Content Warning field' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['prepend_cw_re']} + id='mastodon-settings--prepend_cw_re' + onChange={onChange} + > + <FormattedMessage id='settings.prepend_cw_re' defaultMessage='Prepend “re: ” to content warnings when replying' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['preselect_on_reply']} + id='mastodon-settings--preselect_on_reply' + onChange={onChange} + > + <FormattedMessage id='settings.preselect_on_reply' defaultMessage='Pre-select usernames on reply' /> + <span className='hint'><FormattedMessage id='settings.preselect_on_reply_hint' defaultMessage='When replying to a conversation with multiple participants, pre-select usernames past the first' /></span> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['confirm_missing_media_description']} + id='mastodon-settings--confirm_missing_media_description' + onChange={onChange} + > + <FormattedMessage id='settings.confirm_missing_media_description' defaultMessage='Show confirmation dialog before sending toots lacking media descriptions' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['confirm_before_clearing_draft']} + id='mastodon-settings--confirm_before_clearing_draft' + onChange={onChange} + > + <FormattedMessage id='settings.confirm_before_clearing_draft' defaultMessage='Show confirmation dialog before overwriting the message being composed' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['show_content_type_choice']} + id='mastodon-settings--show_content_type_choice' + onChange={onChange} + > + <FormattedMessage id='settings.show_content_type_choice' defaultMessage='Show content-type choice when authoring toots' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['side_arm']} + id='mastodon-settings--side_arm' + options={[ + { value: 'none', message: intl.formatMessage(messages.side_arm_none) }, + { value: 'direct', message: intl.formatMessage(messages.direct) }, + { value: 'private', message: intl.formatMessage(messages.private) }, + { value: 'unlisted', message: intl.formatMessage(messages.unlisted) }, + { value: 'public', message: intl.formatMessage(messages.public) }, + ]} + onChange={onChange} + > + <FormattedMessage id='settings.side_arm' defaultMessage='Secondary toot button:' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['side_arm_reply_mode']} + id='mastodon-settings--side_arm_reply_mode' + options={[ + { value: 'keep', message: intl.formatMessage(messages.side_arm_keep) }, + { value: 'copy', message: intl.formatMessage(messages.side_arm_copy) }, + { value: 'restrict', message: intl.formatMessage(messages.side_arm_restrict) }, + ]} + onChange={onChange} + > + <FormattedMessage id='settings.side_arm_reply_mode' defaultMessage='When replying to a toot, the secondary toot button should:' /> + </LocalSettingsPageItem> + </div> + ), + ({ intl, onChange, settings }) => ( + <div className='blobfox local-settings__page content_warnings'> + <h1><FormattedMessage id='settings.content_warnings' defaultMessage='Content Warnings' /></h1> + <LocalSettingsPageItem + settings={settings} + item={['content_warnings', 'shared_state']} + id='mastodon-settings--content_warnings-shared_state' + onChange={onChange} + > + <FormattedMessage id='settings.content_warnings_shared_state' defaultMessage='Show/hide content of all copies at once' /> + <span className='hint'><FormattedMessage id='settings.content_warnings_shared_state_hint' defaultMessage='Reproduce upstream Mastodon behavior by having the Content Warning button affect all copies of a post at once. This will prevent automatic collapsing of any copy of a toot with unfolded CW' /></span> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['content_warnings', 'media_outside']} + id='mastodon-settings--content_warnings-media_outside' + onChange={onChange} + > + <FormattedMessage id='settings.content_warnings_media_outside' defaultMessage='Display media attachments outside content warnings' /> + <span className='hint'><FormattedMessage id='settings.content_warnings_media_outside_hint' defaultMessage='Reproduce upstream Mastodon behavior by having the Content Warning toggle not affect media attachments' /></span> + </LocalSettingsPageItem> + <section> + <h2><FormattedMessage id='settings.content_warnings_unfold_opts' defaultMessage='Auto-unfolding options' /></h2> + <DeprecatedLocalSettingsPageItem + id='mastodon-settings--content_warnings-auto_unfold' + value={expandSpoilers} + > + <FormattedMessage id='settings.enable_content_warnings_auto_unfold' defaultMessage='Automatically unfold content-warnings' /> + <span className='hint'> + <FormattedMessage + id='settings.deprecated_setting' + defaultMessage="This setting is now controlled from Mastodon's {settings_page_link}" + values={{ + settings_page_link: ( + <a href={preferenceLink('user_setting_expand_spoilers')}> + <FormattedMessage + id='settings.shared_settings_link' + defaultMessage='user preferences' + /> + </a> + ), + }} + /> + </span> + </DeprecatedLocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['content_warnings', 'filter']} + id='mastodon-settings--content_warnings-auto_unfold' + onChange={onChange} + placeholder={intl.formatMessage(messages.regexp)} + disabled={!expandSpoilers} + > + <FormattedMessage id='settings.content_warnings_filter' defaultMessage='Content warnings to not automatically unfold:' /> + </LocalSettingsPageItem> + </section> + </div> + ), + ({ onChange, settings }) => ( + <div className='blobfox local-settings__page collapsed'> + <h1><FormattedMessage id='settings.collapsed_statuses' defaultMessage='Collapsed toots' /></h1> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'enabled']} + id='mastodon-settings--collapsed-enabled' + onChange={onChange} + > + <FormattedMessage id='settings.enable_collapsed' defaultMessage='Enable collapsed toots' /> + <span className='hint'><FormattedMessage id='settings.enable_collapsed_hint' defaultMessage='Collapsed posts have parts of their contents hidden to take up less screen space. This is distinct from the Content Warning feature' /></span> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'show_action_bar']} + id='mastodon-settings--collapsed-show-action-bar' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + > + <FormattedMessage id='settings.show_action_bar' defaultMessage='Show action buttons in collapsed toots' /> + </LocalSettingsPageItem> + <section> + <h2><FormattedMessage id='settings.auto_collapse' defaultMessage='Automatic collapsing' /></h2> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'auto', 'all']} + id='mastodon-settings--collapsed-auto-all' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + > + <FormattedMessage id='settings.auto_collapse_all' defaultMessage='Everything' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'auto', 'notifications']} + id='mastodon-settings--collapsed-auto-notifications' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + dependsOnNot={[['collapsed', 'auto', 'all']]} + > + <FormattedMessage id='settings.auto_collapse_notifications' defaultMessage='Notifications' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'auto', 'lengthy']} + id='mastodon-settings--collapsed-auto-lengthy' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + dependsOnNot={[['collapsed', 'auto', 'all']]} + > + <FormattedMessage id='settings.auto_collapse_lengthy' defaultMessage='Lengthy toots' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'auto', 'reblogs']} + id='mastodon-settings--collapsed-auto-reblogs' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + dependsOnNot={[['collapsed', 'auto', 'all']]} + > + <FormattedMessage id='settings.auto_collapse_reblogs' defaultMessage='Boosts' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'auto', 'replies']} + id='mastodon-settings--collapsed-auto-replies' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + dependsOnNot={[['collapsed', 'auto', 'all']]} + > + <FormattedMessage id='settings.auto_collapse_replies' defaultMessage='Replies' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'auto', 'media']} + id='mastodon-settings--collapsed-auto-media' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + dependsOnNot={[['collapsed', 'auto', 'all']]} + > + <FormattedMessage id='settings.auto_collapse_media' defaultMessage='Toots with media' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'auto', 'height']} + id='mastodon-settings--collapsed-auto-height' + placeholder='400' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + dependsOnNot={[['collapsed', 'auto', 'all']]} + inputProps={{ type: 'number', min: '200', max: '999' }} + > + <FormattedMessage id='settings.auto_collapse_height' defaultMessage='Height (in pixels) for a toot to be considered lengthy' /> + </LocalSettingsPageItem> + </section> + <section> + <h2><FormattedMessage id='settings.image_backgrounds' defaultMessage='Image backgrounds' /></h2> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'backgrounds', 'user_backgrounds']} + id='mastodon-settings--collapsed-user-backgrouns' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + > + <FormattedMessage id='settings.image_backgrounds_users' defaultMessage='Give collapsed toots an image background' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'backgrounds', 'preview_images']} + id='mastodon-settings--collapsed-preview-images' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + > + <FormattedMessage id='settings.image_backgrounds_media' defaultMessage='Preview collapsed toot media' /> + <span className='hint'><FormattedMessage id='settings.image_backgrounds_media_hint' defaultMessage='If the post has any media attachment, use the first one as a background' /></span> + </LocalSettingsPageItem> + </section> + </div> + ), + ({ intl, onChange, settings }) => ( + <div className='blobfox local-settings__page media'> + <h1><FormattedMessage id='settings.media' defaultMessage='Media' /></h1> + <LocalSettingsPageItem + settings={settings} + item={['media', 'letterbox']} + id='mastodon-settings--media-letterbox' + onChange={onChange} + > + <FormattedMessage id='settings.media_letterbox' defaultMessage='Letterbox media' /> + <span className='hint'><FormattedMessage id='settings.media_letterbox_hint' defaultMessage='Scale down and letterbox media to fill the image containers instead of stretching and cropping them' /></span> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['media', 'fullwidth']} + id='mastodon-settings--media-fullwidth' + onChange={onChange} + > + <FormattedMessage id='settings.media_fullwidth' defaultMessage='Full-width media previews' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['inline_preview_cards']} + id='mastodon-settings--inline-preview-cards' + onChange={onChange} + > + <FormattedMessage id='settings.inline_preview_cards' defaultMessage='Inline preview cards for external links' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['media', 'reveal_behind_cw']} + id='mastodon-settings--reveal-behind-cw' + onChange={onChange} + > + <FormattedMessage id='settings.media_reveal_behind_cw' defaultMessage='Reveal sensitive media behind a CW by default' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['media', 'pop_in_player']} + id='mastodon-settings--pop-in-player' + onChange={onChange} + > + <FormattedMessage id='settings.pop_in_player' defaultMessage='Enable pop-in player' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['media', 'pop_in_position']} + id='mastodon-settings--pop-in-position' + options={[ + { value: 'left', message: intl.formatMessage(messages.pop_in_left) }, + { value: 'right', message: intl.formatMessage(messages.pop_in_right) }, + ]} + onChange={onChange} + dependsOn={[['media', 'pop_in_player']]} + > + <FormattedMessage id='settings.pop_in_position' defaultMessage='Pop-in player position:' /> + </LocalSettingsPageItem> + </div> + ), + ]; + + render () { + const { pages } = this; + const { index, intl, onChange, settings } = this.props; + const CurrentPage = pages[index] || pages[0]; + + return <CurrentPage intl={intl} onChange={onChange} settings={settings} />; + } + +} + +export default injectIntl(LocalSettingsPage); diff --git a/app/javascript/flavours/blobfox/features/local_settings/page/item/index.jsx b/app/javascript/flavours/blobfox/features/local_settings/page/item/index.jsx new file mode 100644 index 00000000000000..82f1c19088f8dc --- /dev/null +++ b/app/javascript/flavours/blobfox/features/local_settings/page/item/index.jsx @@ -0,0 +1,118 @@ +// Package imports +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +export default class LocalSettingsPageItem extends PureComponent { + + static propTypes = { + children: PropTypes.node.isRequired, + dependsOn: PropTypes.array, + dependsOnNot: PropTypes.array, + id: PropTypes.string.isRequired, + item: PropTypes.array.isRequired, + onChange: PropTypes.func.isRequired, + inputProps: PropTypes.object, + options: PropTypes.arrayOf(PropTypes.shape({ + value: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + hint: PropTypes.string, + })), + settings: ImmutablePropTypes.map.isRequired, + placeholder: PropTypes.string, + disabled: PropTypes.bool, + }; + + handleChange = e => { + const { target } = e; + const { item, onChange, options, placeholder } = this.props; + if (options && options.length > 0) onChange(item, target.value); + else if (placeholder) onChange(item, target.value); + else onChange(item, target.checked); + }; + + render () { + const { handleChange } = this; + const { settings, item, id, inputProps, options, children, dependsOn, dependsOnNot, placeholder, disabled } = this.props; + let enabled = !disabled; + + if (dependsOn) { + for (let i = 0; i < dependsOn.length; i++) { + enabled = enabled && settings.getIn(dependsOn[i]); + } + } + if (dependsOnNot) { + for (let i = 0; i < dependsOnNot.length; i++) { + enabled = enabled && !settings.getIn(dependsOnNot[i]); + } + } + + if (options && options.length > 0) { + const currentValue = settings.getIn(item); + const optionElems = options && options.length > 0 && options.map((opt) => { + let optionId = `${id}--${opt.value}`; + return ( + <label key={optionId} htmlFor={optionId}> + <input + type='radio' + name={id} + id={optionId} + value={opt.value} + onBlur={handleChange} + onChange={handleChange} + checked={currentValue === opt.value} + disabled={!enabled} + {...inputProps} + /> + {opt.message} + {opt.hint && <span className='hint'>{opt.hint}</span>} + </label> + ); + }); + return ( + <div className='blobfox local-settings__page__item radio_buttons'> + <fieldset> + <legend>{children}</legend> + {optionElems} + </fieldset> + </div> + ); + } else if (placeholder) { + return ( + <div className='blobfox local-settings__page__item string'> + <label htmlFor={id}> + <p>{children}</p> + <p> + <input + id={id} + type='text' + value={settings.getIn(item)} + placeholder={placeholder} + onChange={handleChange} + disabled={!enabled} + {...inputProps} + /> + </p> + </label> + </div> + ); + } else return ( + <div className='blobfox local-settings__page__item boolean'> + <label htmlFor={id}> + <input + id={id} + type='checkbox' + checked={settings.getIn(item)} + onChange={handleChange} + disabled={!enabled} + {...inputProps} + /> + {children} + </label> + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/mutes/index.jsx b/app/javascript/flavours/blobfox/features/mutes/index.jsx new file mode 100644 index 00000000000000..947fe4c9b2712d --- /dev/null +++ b/app/javascript/flavours/blobfox/features/mutes/index.jsx @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { debounce } from 'lodash'; + +import { fetchMutes, expandMutes } from '../../actions/mutes'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import { LoadingIndicator } from '../../components/loading_indicator'; +import ScrollableList from '../../components/scrollable_list'; +import AccountContainer from '../../containers/account_container'; +import Column from '../ui/components/column'; + +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), +}); + +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, + }; + + UNSAFE_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 ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + const emptyMessage = <FormattedMessage id='empty_column.mutes' defaultMessage="You haven't muted any users yet." />; + + return ( + <Column bindToDocument={!multiColumn} icon='volume-off' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + <ScrollableList + scrollKey='mutes' + onLoadMore={this.handleLoadMore} + hasMore={hasMore} + isLoading={isLoading} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {accountIds.map(id => + <AccountContainer key={id} id={id} defaultAction='mute' />, + )} + </ScrollableList> + + <Helmet> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(Mutes)); diff --git a/app/javascript/flavours/blobfox/features/notifications/components/admin_report.jsx b/app/javascript/flavours/blobfox/features/notifications/components/admin_report.jsx new file mode 100644 index 00000000000000..b19b8d7453f01d --- /dev/null +++ b/app/javascript/flavours/blobfox/features/notifications/components/admin_report.jsx @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; +import { withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { HotKeys } from 'react-hotkeys'; + +import { Icon } from 'flavours/blobfox/components/icon'; +import Permalink from 'flavours/blobfox/components/permalink'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +import NotificationOverlayContainer from '../containers/overlay_container'; + +import Report from './report'; + +class AdminReport extends ImmutablePureComponent { + + static propTypes = { + hidden: PropTypes.bool, + id: PropTypes.string.isRequired, + account: ImmutablePropTypes.map.isRequired, + notification: ImmutablePropTypes.map.isRequired, + unread: PropTypes.bool, + report: ImmutablePropTypes.map.isRequired, + ...WithRouterPropTypes, + }; + + handleMoveUp = () => { + const { notification, onMoveUp } = this.props; + onMoveUp(notification.get('id')); + }; + + handleMoveDown = () => { + const { notification, onMoveDown } = this.props; + onMoveDown(notification.get('id')); + }; + + handleOpen = () => { + this.handleOpenProfile(); + }; + + handleOpenProfile = () => { + const { history, notification } = this.props; + history.push(`/@${notification.getIn(['account', 'acct'])}`); + }; + + handleMention = e => { + e.preventDefault(); + + const { history, notification, onMention } = this.props; + onMention(notification.get('account'), history); + }; + + getHandlers () { + return { + moveUp: this.handleMoveUp, + moveDown: this.handleMoveDown, + open: this.handleOpen, + openProfile: this.handleOpenProfile, + mention: this.handleMention, + reply: this.handleMention, + }; + } + + render () { + const { account, notification, unread, report } = this.props; + + if (!report) { + return null; + } + + // Links to the display name. + const displayName = account.get('display_name_html') || account.get('username'); + const link = ( + <bdi><Permalink + className='notification__display-name' + href={account.get('url')} + title={account.get('acct')} + to={`/@${account.get('acct')}`} + dangerouslySetInnerHTML={{ __html: displayName }} + /></bdi> + ); + + const targetAccount = report.get('target_account'); + const targetDisplayNameHtml = { __html: targetAccount.get('display_name_html') }; + const targetLink = <bdi><Permalink className='notification__display-name' href={targetAccount.get('url')} title={targetAccount.get('acct')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>; + + return ( + <HotKeys handlers={this.getHandlers()}> + <div className={classNames('notification notification-admin-report focusable', { unread })} tabIndex={0}> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <Icon id='flag' fixedWidth /> + </div> + + <span title={notification.get('created_at')}> + <FormattedMessage id='notification.admin.report' defaultMessage='{name} reported {target}' values={{ name: link, target: targetLink }} /> + </span> + </div> + + <Report account={account} report={notification.get('report')} hidden={this.props.hidden} /> + <NotificationOverlayContainer notification={notification} /> + </div> + </HotKeys> + ); + } + +} + +export default withRouter(AdminReport); diff --git a/app/javascript/flavours/blobfox/features/notifications/components/admin_signup.jsx b/app/javascript/flavours/blobfox/features/notifications/components/admin_signup.jsx new file mode 100644 index 00000000000000..39fe15a8aa2eb4 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/notifications/components/admin_signup.jsx @@ -0,0 +1,108 @@ +import PropTypes from 'prop-types'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; +import { withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { HotKeys } from 'react-hotkeys'; + +import { Icon } from 'flavours/blobfox/components/icon'; +import Permalink from 'flavours/blobfox/components/permalink'; +import AccountContainer from 'flavours/blobfox/containers/account_container'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +import NotificationOverlayContainer from '../containers/overlay_container'; + +class NotificationAdminSignup extends ImmutablePureComponent { + + static propTypes = { + hidden: PropTypes.bool, + id: PropTypes.string.isRequired, + account: ImmutablePropTypes.map.isRequired, + notification: ImmutablePropTypes.map.isRequired, + unread: PropTypes.bool, + ...WithRouterPropTypes, + }; + + handleMoveUp = () => { + const { notification, onMoveUp } = this.props; + onMoveUp(notification.get('id')); + }; + + handleMoveDown = () => { + const { notification, onMoveDown } = this.props; + onMoveDown(notification.get('id')); + }; + + handleOpen = () => { + this.handleOpenProfile(); + }; + + handleOpenProfile = () => { + const { history, notification } = this.props; + history.push(`/@${notification.getIn(['account', 'acct'])}`); + }; + + handleMention = e => { + e.preventDefault(); + + const { history, notification, onMention } = this.props; + onMention(notification.get('account'), history); + }; + + getHandlers () { + return { + moveUp: this.handleMoveUp, + moveDown: this.handleMoveDown, + open: this.handleOpen, + openProfile: this.handleOpenProfile, + mention: this.handleMention, + reply: this.handleMention, + }; + } + + render () { + const { account, notification, hidden, unread } = this.props; + + // Links to the display name. + const displayName = account.get('display_name_html') || account.get('username'); + const link = ( + <bdi><Permalink + className='notification__display-name' + href={account.get('url')} + title={account.get('acct')} + to={`/@${account.get('acct')}`} + dangerouslySetInnerHTML={{ __html: displayName }} + /></bdi> + ); + + // Renders. + return ( + <HotKeys handlers={this.getHandlers()}> + <div className={classNames('notification notification-admin-sign-up focusable', { unread })} tabIndex={0}> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <Icon fixedWidth id='user-plus' /> + </div> + + <FormattedMessage + id='notification.admin.sign_up' + defaultMessage='{name} signed up' + values={{ name: link }} + /> + </div> + + <AccountContainer hidden={hidden} id={account.get('id')} withNote={false} /> + <NotificationOverlayContainer notification={notification} /> + </div> + </HotKeys> + ); + } + +} + +export default withRouter(NotificationAdminSignup); diff --git a/app/javascript/flavours/blobfox/features/notifications/components/clear_column_button.jsx b/app/javascript/flavours/blobfox/features/notifications/components/clear_column_button.jsx new file mode 100644 index 00000000000000..63121999227fe3 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/notifications/components/clear_column_button.jsx @@ -0,0 +1,20 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { Icon } from 'flavours/blobfox/components/icon'; + +export default class ClearColumnButton extends PureComponent { + + static propTypes = { + onClick: PropTypes.func.isRequired, + }; + + render () { + return ( + <button className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.props.onClick}><Icon id='eraser' /> <FormattedMessage id='notifications.clear' defaultMessage='Clear notifications' /></button> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/notifications/components/column_settings.jsx b/app/javascript/flavours/blobfox/features/notifications/components/column_settings.jsx new file mode 100644 index 00000000000000..9b6ed0ff123542 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/notifications/components/column_settings.jsx @@ -0,0 +1,218 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + +import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_REPORTS } from 'flavours/blobfox/permissions'; + +import ClearColumnButton from './clear_column_button'; +import GrantPermissionButton from './grant_permission_button'; +import PillBarButton from './pill_bar_button'; +import SettingToggle from './setting_toggle'; + +export default class ColumnSettings extends 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 = <FormattedMessage id='notifications.column_settings.unread_notifications.highlight' defaultMessage='Highlight unread notifications' />; + const filterBarShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show_bar' defaultMessage='Show filter bar' />; + const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />; + const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />; + const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />; + const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />; + + const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed'); + const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />; + + return ( + <div> + {alertsEnabled && browserSupport && browserPermission === 'denied' && ( + <div className='column-settings__row column-settings__row--with-margin'> + <span className='warning-hint'><FormattedMessage id='notifications.permission_denied' defaultMessage='Desktop notifications are unavailable due to previously denied browser permissions request' /></span> + </div> + )} + + {alertsEnabled && browserSupport && browserPermission === 'default' && ( + <div className='column-settings__row column-settings__row--with-margin'> + <span className='warning-hint'> + <FormattedMessage id='notifications.permission_required' defaultMessage='Desktop notifications are unavailable because the required permission has not been granted.' /> <GrantPermissionButton onClick={onRequestNotificationPermission} /> + </span> + </div> + )} + + <div className='column-settings__row'> + <ClearColumnButton onClick={onClear} /> + </div> + + <div role='group' aria-labelledby='notifications-unread-markers'> + <span id='notifications-unread-markers' className='column-settings__section'> + <FormattedMessage id='notifications.column_settings.unread_notifications.category' defaultMessage='Unread notifications' /> + </span> + + <div className='column-settings__row'> + <SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['showUnread']} onChange={onChange} label={unreadMarkersShowStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-filter-bar'> + <span id='notifications-filter-bar' className='column-settings__section'> + <FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' /> + </span> + + <div className='column-settings__row'> + <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterBarShowStr} /> + <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-follow'> + <span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> + + <div className='column-settings__pillbar'> + <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} /> + {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} onChange={this.onPushChange} label={pushStr} />} + <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} /> + <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-follow-request'> + <span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span> + + <div className='column-settings__pillbar'> + <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} /> + {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />} + <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} /> + <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-favourite'> + <span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favorites:' /></span> + + <div className='column-settings__pillbar'> + <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> + {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} onChange={this.onPushChange} label={pushStr} />} + <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} /> + <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-reaction'> + <span id='notifications-reaction' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reaction' defaultMessage='Reactions:' /></span> + + <div className='column-settings__pillbar'> + <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reaction']} onChange={onChange} label={alertStr} /> + {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reaction']} onChange={this.onPushChange} label={pushStr} />} + <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'reaction']} onChange={onChange} label={showStr} /> + <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'reaction']} onChange={onChange} label={soundStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-mention'> + <span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> + + <div className='column-settings__pillbar'> + <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} /> + {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} onChange={this.onPushChange} label={pushStr} />} + <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} /> + <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-reblog'> + <span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> + + <div className='column-settings__pillbar'> + <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> + {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} onChange={this.onPushChange} label={pushStr} />} + <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} /> + <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-poll'> + <span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span> + + <div className='column-settings__pillbar'> + <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} /> + {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} onChange={this.onPushChange} label={pushStr} />} + <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} /> + <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-status'> + <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status' defaultMessage='New posts:' /></span> + + <div className='column-settings__pillbar'> + <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status']} onChange={onChange} label={alertStr} /> + {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'status']} onChange={this.onPushChange} label={pushStr} />} + <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'status']} onChange={onChange} label={showStr} /> + <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-update'> + <span id='notifications-update' className='column-settings__section'><FormattedMessage id='notifications.column_settings.update' defaultMessage='Edits:' /></span> + + <div className='column-settings__pillbar'> + <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'update']} onChange={onChange} label={alertStr} /> + {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'update']} onChange={this.onPushChange} label={pushStr} />} + <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'update']} onChange={onChange} label={showStr} /> + <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'update']} onChange={onChange} label={soundStr} /> + </div> + </div> + + {((this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) && ( + <div role='group' aria-labelledby='notifications-admin-sign-up'> + <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.admin.sign_up' defaultMessage='New sign-ups:' /></span> + + <div className='column-settings__pillbar'> + <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'admin.sign_up']} onChange={onChange} label={alertStr} /> + {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'admin.sign_up']} onChange={this.onPushChange} label={pushStr} />} + <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'admin.sign_up']} onChange={onChange} label={showStr} /> + <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'admin.sign_up']} onChange={onChange} label={soundStr} /> + </div> + </div> + )} + + {((this.context.identity.permissions & PERMISSION_MANAGE_REPORTS) === PERMISSION_MANAGE_REPORTS) && ( + <div role='group' aria-labelledby='notifications-admin-report'> + <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.admin.report' defaultMessage='New reports:' /></span> + + <div className='column-settings__pillbar'> + <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'admin.report']} onChange={onChange} label={alertStr} /> + {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'admin.report']} onChange={this.onPushChange} label={pushStr} />} + <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'admin.report']} onChange={onChange} label={showStr} /> + <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'admin.report']} onChange={onChange} label={soundStr} /> + </div> + </div> + )} + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/notifications/components/filter_bar.jsx b/app/javascript/flavours/blobfox/features/notifications/components/filter_bar.jsx new file mode 100644 index 00000000000000..0bfbf8afa3c9d0 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/notifications/components/filter_bar.jsx @@ -0,0 +1,121 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { Icon } from 'flavours/blobfox/components/icon'; + +const tooltips = defineMessages({ + mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' }, + favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favorites' }, + reactions: { id: 'notifications.filter.reactions', defaultMessage: 'Reactions' }, + 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' }, +}); + +class FilterBar extends 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 ? ( + <div className='notification__filter-bar'> + <button + className={selectedFilter === 'all' ? 'active' : ''} + onClick={this.onClick('all')} + > + <FormattedMessage + id='notifications.filter.all' + defaultMessage='All' + /> + </button> + <button + className={selectedFilter === 'mention' ? 'active' : ''} + onClick={this.onClick('mention')} + > + <FormattedMessage + id='notifications.filter.mentions' + defaultMessage='Mentions' + /> + </button> + </div> + ) : ( + <div className='notification__filter-bar'> + <button + className={selectedFilter === 'all' ? 'active' : ''} + onClick={this.onClick('all')} + > + <FormattedMessage + id='notifications.filter.all' + defaultMessage='All' + /> + </button> + <button + className={selectedFilter === 'mention' ? 'active' : ''} + onClick={this.onClick('mention')} + title={intl.formatMessage(tooltips.mentions)} + > + <Icon id='reply-all' fixedWidth /> + </button> + <button + className={selectedFilter === 'favourite' ? 'active' : ''} + onClick={this.onClick('favourite')} + title={intl.formatMessage(tooltips.favourites)} + > + <Icon id='star' fixedWidth /> + </button> + <button + className={selectedFilter === 'reaction' ? 'active' : ''} + onClick={this.onClick('reaction')} + title={intl.formatMessage(tooltips.reactions)} + > + <Icon id='plus' fixedWidth /> + </button> + <button + className={selectedFilter === 'reblog' ? 'active' : ''} + onClick={this.onClick('reblog')} + title={intl.formatMessage(tooltips.boosts)} + > + <Icon id='retweet' fixedWidth /> + </button> + <button + className={selectedFilter === 'poll' ? 'active' : ''} + onClick={this.onClick('poll')} + title={intl.formatMessage(tooltips.polls)} + > + <Icon id='tasks' fixedWidth /> + </button> + <button + className={selectedFilter === 'status' ? 'active' : ''} + onClick={this.onClick('status')} + title={intl.formatMessage(tooltips.statuses)} + > + <Icon id='home' fixedWidth /> + </button> + <button + className={selectedFilter === 'follow' ? 'active' : ''} + onClick={this.onClick('follow')} + title={intl.formatMessage(tooltips.follows)} + > + <Icon id='user-plus' fixedWidth /> + </button> + </div> + ); + return renderedElement; + } + +} + +export default injectIntl(FilterBar); diff --git a/app/javascript/flavours/blobfox/features/notifications/components/follow.jsx b/app/javascript/flavours/blobfox/features/notifications/components/follow.jsx new file mode 100644 index 00000000000000..2d04ab9d16ce10 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/notifications/components/follow.jsx @@ -0,0 +1,108 @@ +import PropTypes from 'prop-types'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; +import { withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { HotKeys } from 'react-hotkeys'; + +import { Icon } from 'flavours/blobfox/components/icon'; +import Permalink from 'flavours/blobfox/components/permalink'; +import AccountContainer from 'flavours/blobfox/containers/account_container'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +import NotificationOverlayContainer from '../containers/overlay_container'; + +class NotificationFollow extends ImmutablePureComponent { + + static propTypes = { + hidden: PropTypes.bool, + id: PropTypes.string.isRequired, + account: ImmutablePropTypes.map.isRequired, + notification: ImmutablePropTypes.map.isRequired, + unread: PropTypes.bool, + ...WithRouterPropTypes, + }; + + handleMoveUp = () => { + const { notification, onMoveUp } = this.props; + onMoveUp(notification.get('id')); + }; + + handleMoveDown = () => { + const { notification, onMoveDown } = this.props; + onMoveDown(notification.get('id')); + }; + + handleOpen = () => { + this.handleOpenProfile(); + }; + + handleOpenProfile = () => { + const { history, notification } = this.props; + history.push(`/@${notification.getIn(['account', 'acct'])}`); + }; + + handleMention = e => { + e.preventDefault(); + + const { history, notification, onMention } = this.props; + onMention(notification.get('account'), history); + }; + + getHandlers () { + return { + moveUp: this.handleMoveUp, + moveDown: this.handleMoveDown, + open: this.handleOpen, + openProfile: this.handleOpenProfile, + mention: this.handleMention, + reply: this.handleMention, + }; + } + + render () { + const { account, notification, hidden, unread } = this.props; + + // Links to the display name. + const displayName = account.get('display_name_html') || account.get('username'); + const link = ( + <bdi><Permalink + className='notification__display-name' + href={account.get('url')} + title={account.get('acct')} + to={`/@${account.get('acct')}`} + dangerouslySetInnerHTML={{ __html: displayName }} + /></bdi> + ); + + // Renders. + return ( + <HotKeys handlers={this.getHandlers()}> + <div className={classNames('notification notification-follow focusable', { unread })} tabIndex={0}> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <Icon fixedWidth id='user-plus' /> + </div> + + <FormattedMessage + id='notification.follow' + defaultMessage='{name} followed you' + values={{ name: link }} + /> + </div> + + <AccountContainer hidden={hidden} id={account.get('id')} withNote={false} /> + <NotificationOverlayContainer notification={notification} /> + </div> + </HotKeys> + ); + } + +} + +export default withRouter(NotificationFollow); diff --git a/app/javascript/flavours/blobfox/features/notifications/components/follow_request.jsx b/app/javascript/flavours/blobfox/features/notifications/components/follow_request.jsx new file mode 100644 index 00000000000000..749169358b4110 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/notifications/components/follow_request.jsx @@ -0,0 +1,141 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; +import { withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { HotKeys } from 'react-hotkeys'; + +import { Avatar } from 'flavours/blobfox/components/avatar'; +import { DisplayName } from 'flavours/blobfox/components/display_name'; +import { Icon } from 'flavours/blobfox/components/icon'; +import { IconButton } from 'flavours/blobfox/components/icon_button'; +import Permalink from 'flavours/blobfox/components/permalink'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +import NotificationOverlayContainer from '../containers/overlay_container'; + +const messages = defineMessages({ + authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' }, + reject: { id: 'follow_request.reject', defaultMessage: 'Reject' }, +}); + +class FollowRequest extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.record.isRequired, + onAuthorize: PropTypes.func.isRequired, + onReject: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + notification: ImmutablePropTypes.map.isRequired, + unread: PropTypes.bool, + ...WithRouterPropTypes, + }; + + handleMoveUp = () => { + const { notification, onMoveUp } = this.props; + onMoveUp(notification.get('id')); + }; + + handleMoveDown = () => { + const { notification, onMoveDown } = this.props; + onMoveDown(notification.get('id')); + }; + + handleOpen = () => { + this.handleOpenProfile(); + }; + + handleOpenProfile = () => { + const { history, notification } = this.props; + history.push(`/@${notification.getIn(['account', 'acct'])}`); + }; + + handleMention = e => { + e.preventDefault(); + + const { history, notification, onMention } = this.props; + onMention(notification.get('account'), history); + }; + + getHandlers () { + return { + moveUp: this.handleMoveUp, + moveDown: this.handleMoveDown, + open: this.handleOpen, + openProfile: this.handleOpenProfile, + mention: this.handleMention, + reply: this.handleMention, + }; + } + + render () { + const { intl, hidden, account, onAuthorize, onReject, notification, unread } = this.props; + + if (!account) { + return <div />; + } + + if (hidden) { + return ( + <> + {account.get('display_name')} + {account.get('username')} + </> + ); + } + + // Links to the display name. + const displayName = account.get('display_name_html') || account.get('username'); + const link = ( + <bdi><Permalink + className='notification__display-name' + href={account.get('url')} + title={account.get('acct')} + to={`/@${account.get('acct')}`} + dangerouslySetInnerHTML={{ __html: displayName }} + /></bdi> + ); + + return ( + <HotKeys handlers={this.getHandlers()}> + <div className={classNames('notification notification-follow-request focusable', { unread })} tabIndex={0}> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <Icon id='user' fixedWidth /> + </div> + + <FormattedMessage + id='notification.follow_request' + defaultMessage='{name} has requested to follow you' + values={{ name: link }} + /> + </div> + + <div className='account'> + <div className='account__wrapper'> + <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}> + <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> + <DisplayName account={account} /> + </Permalink> + + <div className='account__relationship'> + <IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} /> + <IconButton title={intl.formatMessage(messages.reject)} icon='times' onClick={onReject} /> + </div> + </div> + </div> + + <NotificationOverlayContainer notification={notification} /> + </div> + </HotKeys> + ); + } + +} + +export default withRouter(injectIntl(FollowRequest)); diff --git a/app/javascript/flavours/blobfox/features/notifications/components/grant_permission_button.jsx b/app/javascript/flavours/blobfox/features/notifications/components/grant_permission_button.jsx new file mode 100644 index 00000000000000..cd46d878bbc2e0 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/notifications/components/grant_permission_button.jsx @@ -0,0 +1,20 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +export default class GrantPermissionButton extends PureComponent { + + static propTypes = { + onClick: PropTypes.func.isRequired, + }; + + render () { + return ( + <button className='text-btn column-header__permission-btn' tabIndex={0} onClick={this.props.onClick}> + <FormattedMessage id='notifications.grant_permission' defaultMessage='Grant permission.' /> + </button> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/notifications/components/notification.jsx b/app/javascript/flavours/blobfox/features/notifications/components/notification.jsx new file mode 100644 index 00000000000000..146562563be009 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/notifications/components/notification.jsx @@ -0,0 +1,258 @@ +// Package imports. +import PropTypes from 'prop-types'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Our imports, +import StatusContainer from 'flavours/blobfox/containers/status_container'; + +import NotificationAdminReportContainer from '../containers/admin_report_container'; +import NotificationFollowRequestContainer from '../containers/follow_request_container'; + +import NotificationAdminSignup from './admin_signup'; +import NotificationFollow from './follow'; + +export default class Notification extends ImmutablePureComponent { + + static propTypes = { + notification: ImmutablePropTypes.map.isRequired, + hidden: PropTypes.bool, + onMoveUp: PropTypes.func.isRequired, + onMoveDown: PropTypes.func.isRequired, + onMention: PropTypes.func.isRequired, + getScrollPosition: PropTypes.func, + updateScrollBottom: PropTypes.func, + cacheMediaWidth: PropTypes.func, + cachedMediaWidth: PropTypes.number, + onUnmount: PropTypes.func, + unread: PropTypes.bool, + }; + + render () { + const { + hidden, + notification, + onMoveDown, + onMoveUp, + onMention, + getScrollPosition, + updateScrollBottom, + } = this.props; + + switch(notification.get('type')) { + case 'follow': + return ( + <NotificationFollow + hidden={hidden} + id={notification.get('id')} + account={notification.get('account')} + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + unread={this.props.unread} + /> + ); + case 'follow_request': + return ( + <NotificationFollowRequestContainer + hidden={hidden} + id={notification.get('id')} + account={notification.get('account')} + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + unread={this.props.unread} + /> + ); + case 'admin.sign_up': + return ( + <NotificationAdminSignup + hidden={hidden} + id={notification.get('id')} + account={notification.get('account')} + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + unread={this.props.unread} + /> + ); + case 'admin.report': + return ( + <NotificationAdminReportContainer + hidden={hidden} + id={notification.get('id')} + account={notification.get('account')} + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + unread={this.props.unread} + /> + ); + case 'mention': + return ( + <StatusContainer + containerId={notification.get('id')} + hidden={hidden} + id={notification.get('status')} + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + contextType='notifications' + getScrollPosition={getScrollPosition} + updateScrollBottom={updateScrollBottom} + cachedMediaWidth={this.props.cachedMediaWidth} + cacheMediaWidth={this.props.cacheMediaWidth} + onUnmount={this.props.onUnmount} + withDismiss + unread={this.props.unread} + /> + ); + case 'status': + return ( + <StatusContainer + containerId={notification.get('id')} + hidden={hidden} + id={notification.get('status')} + account={notification.get('account')} + prepend='status' + muted + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + contextType='notifications' + getScrollPosition={getScrollPosition} + updateScrollBottom={updateScrollBottom} + cachedMediaWidth={this.props.cachedMediaWidth} + cacheMediaWidth={this.props.cacheMediaWidth} + onUnmount={this.props.onUnmount} + withDismiss + unread={this.props.unread} + /> + ); + case 'favourite': + return ( + <StatusContainer + containerId={notification.get('id')} + hidden={hidden} + id={notification.get('status')} + account={notification.get('account')} + prepend='favourite' + muted + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + contextType='notifications' + getScrollPosition={getScrollPosition} + updateScrollBottom={updateScrollBottom} + cachedMediaWidth={this.props.cachedMediaWidth} + cacheMediaWidth={this.props.cacheMediaWidth} + onUnmount={this.props.onUnmount} + withDismiss + unread={this.props.unread} + /> + ); + case 'reaction': + return ( + <StatusContainer + containerId={notification.get('id')} + hidden={hidden} + id={notification.get('status')} + account={notification.get('account')} + prepend='reaction' + muted + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + getScrollPosition={getScrollPosition} + updateScrollBottom={updateScrollBottom} + cachedMediaWidth={this.props.cachedMediaWidth} + cacheMediaWidth={this.props.cacheMediaWidth} + onUnmount={this.props.onUnmount} + withDismiss + unread={this.props.unread} + /> + ); + case 'reblog': + return ( + <StatusContainer + containerId={notification.get('id')} + hidden={hidden} + id={notification.get('status')} + account={notification.get('account')} + prepend='reblog' + muted + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + contextType='notifications' + getScrollPosition={getScrollPosition} + updateScrollBottom={updateScrollBottom} + cachedMediaWidth={this.props.cachedMediaWidth} + cacheMediaWidth={this.props.cacheMediaWidth} + onUnmount={this.props.onUnmount} + withDismiss + unread={this.props.unread} + /> + ); + case 'poll': + return ( + <StatusContainer + containerId={notification.get('id')} + hidden={hidden} + id={notification.get('status')} + account={notification.get('account')} + prepend='poll' + muted + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + contextType='notifications' + getScrollPosition={getScrollPosition} + updateScrollBottom={updateScrollBottom} + cachedMediaWidth={this.props.cachedMediaWidth} + cacheMediaWidth={this.props.cacheMediaWidth} + onUnmount={this.props.onUnmount} + withDismiss + unread={this.props.unread} + /> + ); + case 'update': + return ( + <StatusContainer + containerId={notification.get('id')} + hidden={hidden} + id={notification.get('status')} + account={notification.get('account')} + prepend='update' + muted + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + contextType='notifications' + getScrollPosition={getScrollPosition} + updateScrollBottom={updateScrollBottom} + cachedMediaWidth={this.props.cachedMediaWidth} + cacheMediaWidth={this.props.cacheMediaWidth} + onUnmount={this.props.onUnmount} + withDismiss + unread={this.props.unread} + /> + ); + default: + return null; + } + } + +} diff --git a/app/javascript/flavours/blobfox/features/notifications/components/notifications_permission_banner.jsx b/app/javascript/flavours/blobfox/features/notifications/components/notifications_permission_banner.jsx new file mode 100644 index 00000000000000..ea45de786f8d6c --- /dev/null +++ b/app/javascript/flavours/blobfox/features/notifications/components/notifications_permission_banner.jsx @@ -0,0 +1,51 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { connect } from 'react-redux'; + +import { requestBrowserPermission } from 'flavours/blobfox/actions/notifications'; +import { changeSetting } from 'flavours/blobfox/actions/settings'; +import { Button } from 'flavours/blobfox/components/button'; +import { Icon } from 'flavours/blobfox/components/icon'; +import { IconButton } from 'flavours/blobfox/components/icon_button'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); + +class NotificationsPermissionBanner extends 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 ( + <div className='notifications-permission-banner'> + <div className='notifications-permission-banner__close'> + <IconButton icon='times' onClick={this.handleClose} title={intl.formatMessage(messages.close)} /> + </div> + + <h2><FormattedMessage id='notifications_permission_banner.title' defaultMessage='Never miss a thing' /></h2> + <p><FormattedMessage id='notifications_permission_banner.how_to_control' defaultMessage="To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled." values={{ icon: <Icon id='sliders' /> }} /></p> + <Button onClick={this.handleClick}><FormattedMessage id='notifications_permission_banner.enable' defaultMessage='Enable desktop notifications' /></Button> + </div> + ); + } + +} + +export default connect()(injectIntl(NotificationsPermissionBanner)); diff --git a/app/javascript/flavours/blobfox/features/notifications/components/overlay.jsx b/app/javascript/flavours/blobfox/features/notifications/components/overlay.jsx new file mode 100644 index 00000000000000..066f0647f31f06 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/notifications/components/overlay.jsx @@ -0,0 +1,61 @@ +/** + * Notification overlay + */ + + +// Package imports. +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { Icon } from 'flavours/blobfox/components/icon'; + +const messages = defineMessages({ + markForDeletion: { id: 'notification.markForDeletion', defaultMessage: 'Mark for deletion' }, +}); + +class NotificationOverlay extends ImmutablePureComponent { + + static propTypes = { + notification : ImmutablePropTypes.map.isRequired, + onMarkForDelete : PropTypes.func.isRequired, + show : PropTypes.bool.isRequired, + intl : PropTypes.object.isRequired, + }; + + onToggleMark = () => { + const mark = !this.props.notification.get('markedForDelete'); + const id = this.props.notification.get('id'); + this.props.onMarkForDelete(id, mark); + }; + + render () { + const { notification, show, intl } = this.props; + + const active = notification.get('markedForDelete'); + const label = intl.formatMessage(messages.markForDeletion); + + return show ? ( + <div + aria-label={label} + role='checkbox' + aria-checked={active} + tabIndex={0} + className={`notification__dismiss-overlay ${active ? 'active' : ''}`} + onClick={this.onToggleMark} + > + <div className='wrappy'> + <div className='ckbox' aria-hidden='true' title={label}> + {active ? (<Icon id='check' />) : ''} + </div> + </div> + </div> + ) : null; + } + +} + +export default injectIntl(NotificationOverlay); diff --git a/app/javascript/flavours/blobfox/features/notifications/components/pill_bar_button.jsx b/app/javascript/flavours/blobfox/features/notifications/components/pill_bar_button.jsx new file mode 100644 index 00000000000000..633401d6e3102b --- /dev/null +++ b/app/javascript/flavours/blobfox/features/notifications/components/pill_bar_button.jsx @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import classNames from 'classnames'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + +export default class PillBarButton extends PureComponent { + + static propTypes = { + prefix: PropTypes.string, + settings: ImmutablePropTypes.map.isRequired, + settingPath: PropTypes.array.isRequired, + label: PropTypes.node.isRequired, + onChange: PropTypes.func.isRequired, + disabled: PropTypes.bool, + }; + + onChange = () => { + const { settings, settingPath } = this.props; + this.props.onChange(settingPath, !settings.getIn(settingPath)); + }; + + render () { + const { prefix, settings, settingPath, label, disabled } = this.props; + const id = ['setting-pillbar-button', prefix, ...settingPath].filter(Boolean).join('-'); + const active = settings.getIn(settingPath); + + return ( + <button + key={id} + id={id} + className={classNames('pillbar-button', { active })} + disabled={disabled} + onClick={this.onChange} + aria-pressed={active} + > + {label} + </button> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/notifications/components/report.jsx b/app/javascript/flavours/blobfox/features/notifications/components/report.jsx new file mode 100644 index 00000000000000..63b4f18a98f028 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/notifications/components/report.jsx @@ -0,0 +1,67 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { AvatarOverlay } from 'flavours/blobfox/components/avatar_overlay'; +import { RelativeTimestamp } from 'flavours/blobfox/components/relative_timestamp'; + +// This needs to be kept in sync with app/models/report.rb +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' }, + legal: { id: 'report_notification.categories.legal', defaultMessage: 'Legal' }, + violation: { id: 'report_notification.categories.violation', defaultMessage: 'Rule violation' }, +}); + +class Report extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.record.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 ( + <div className='notification__report'> + <div className='notification__report__avatar'> + <AvatarOverlay account={report.get('target_account')} friend={account} /> + </div> + + <div className='notification__report__details'> + <div> + <RelativeTimestamp timestamp={report.get('created_at')} short={false} /> · <FormattedMessage id='report_notification.attached_statuses' defaultMessage='{count, plural, one {# post} other {# posts}} attached' values={{ count: report.get('status_ids').size }} /> + <br /> + <strong>{intl.formatMessage(messages[report.get('category')])}</strong> + </div> + + <div className='notification__report__actions'> + <a href={`/admin/reports/${report.get('id')}`} className='button' target='_blank' rel='noopener noreferrer'>{intl.formatMessage(messages.openReport)}</a> + </div> + </div> + </div> + ); + } + +} + +export default injectIntl(Report); diff --git a/app/javascript/flavours/blobfox/features/notifications/components/setting_toggle.jsx b/app/javascript/flavours/blobfox/features/notifications/components/setting_toggle.jsx new file mode 100644 index 00000000000000..2f849c54841031 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/notifications/components/setting_toggle.jsx @@ -0,0 +1,38 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + +import Toggle from 'react-toggle'; + +export default class SettingToggle extends PureComponent { + + static propTypes = { + prefix: PropTypes.string, + settings: ImmutablePropTypes.map.isRequired, + settingPath: PropTypes.array.isRequired, + label: PropTypes.node.isRequired, + meta: PropTypes.node, + 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, meta, defaultValue, disabled } = this.props; + const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-'); + + return ( + <div className='setting-toggle'> + <Toggle disabled={disabled} id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> + <label htmlFor={id} className='setting-toggle__label'>{label}</label> + {meta && <span className='setting-meta__label'>{meta}</span>} + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/notifications/containers/admin_report_container.js b/app/javascript/flavours/blobfox/features/notifications/containers/admin_report_container.js new file mode 100644 index 00000000000000..27a8450a750a24 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/notifications/containers/admin_report_container.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; + +import { makeGetReport } from 'flavours/blobfox/selectors'; + +import AdminReport from '../components/admin_report'; + +const mapStateToProps = (state, { notification }) => { + const getReport = makeGetReport(); + + return { + report: notification.get('report') ? getReport(state, notification.get('report'), notification.getIn(['report', 'target_account', 'id'])) : null, + }; +}; + +export default connect(mapStateToProps)(AdminReport); diff --git a/app/javascript/flavours/blobfox/features/notifications/containers/column_settings_container.js b/app/javascript/flavours/blobfox/features/notifications/containers/column_settings_container.js new file mode 100644 index 00000000000000..1e62ed9a5a45b6 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/notifications/containers/column_settings_container.js @@ -0,0 +1,78 @@ +import { defineMessages, injectIntl } from 'react-intl'; + +import { connect } from 'react-redux'; + +import { showAlert } from '../../../actions/alerts'; +import { openModal } from '../../../actions/modal'; +import { setFilter, clearNotifications, requestBrowserPermission } from '../../../actions/notifications'; +import { changeAlerts as changePushNotifications } from '../../../actions/push_notifications'; +import { changeSetting } from '../../../actions/settings'; +import ColumnSettings from '../components/column_settings'; + +const messages = defineMessages({ + clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' }, + clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' }, + permissionDenied: { id: 'notifications.permission_denied_alert', defaultMessage: 'Desktop notifications can\'t be enabled, as browser permission has been denied before' }, +}); + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'notifications']), + pushSettings: state.get('push_notifications'), + alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true), + browserSupport: state.getIn(['notifications', 'browserSupport']), + browserPermission: state.getIn(['notifications', 'browserPermission']), +}); + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onChange (path, checked) { + if (path[0] === 'push') { + if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { + dispatch(requestBrowserPermission((permission) => { + if (permission === 'granted') { + dispatch(changePushNotifications(path.slice(1), checked)); + } else { + dispatch(showAlert({ message: messages.permissionDenied })); + } + })); + } else { + dispatch(changePushNotifications(path.slice(1), checked)); + } + } else if (path[0] === 'quickFilter') { + dispatch(changeSetting(['notifications', ...path], checked)); + dispatch(setFilter('all')); + } else if (path[0] === 'alerts' && checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { + if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { + dispatch(requestBrowserPermission((permission) => { + if (permission === 'granted') { + dispatch(changeSetting(['notifications', ...path], checked)); + } else { + dispatch(showAlert({ message: messages.permissionDenied })); + } + })); + } else { + dispatch(changeSetting(['notifications', ...path], checked)); + } + } else { + dispatch(changeSetting(['notifications', ...path], checked)); + } + }, + + onClear () { + dispatch(openModal({ + modalType: 'CONFIRM', + modalProps: { + message: intl.formatMessage(messages.clearMessage), + confirm: intl.formatMessage(messages.clearConfirm), + onConfirm: () => dispatch(clearNotifications()), + }, + })); + }, + + onRequestNotificationPermission () { + dispatch(requestBrowserPermission()); + }, + +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings)); diff --git a/app/javascript/flavours/blobfox/features/notifications/containers/filter_bar_container.js b/app/javascript/flavours/blobfox/features/notifications/containers/filter_bar_container.js new file mode 100644 index 00000000000000..4e0184cef30534 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/notifications/containers/filter_bar_container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; + +import { setFilter } from '../../../actions/notifications'; +import FilterBar from '../components/filter_bar'; + +const makeMapStateToProps = state => ({ + selectedFilter: state.getIn(['settings', 'notifications', 'quickFilter', 'active']), + advancedMode: state.getIn(['settings', 'notifications', 'quickFilter', 'advanced']), +}); + +const mapDispatchToProps = (dispatch) => ({ + selectFilter (newActiveFilter) { + dispatch(setFilter(newActiveFilter)); + }, +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(FilterBar); diff --git a/app/javascript/flavours/blobfox/features/notifications/containers/follow_request_container.js b/app/javascript/flavours/blobfox/features/notifications/containers/follow_request_container.js new file mode 100644 index 00000000000000..ebe41844dc2697 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/notifications/containers/follow_request_container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; + +import { authorizeFollowRequest, rejectFollowRequest } from 'flavours/blobfox/actions/accounts'; + +import FollowRequest from '../components/follow_request'; + +const mapDispatchToProps = (dispatch, { account }) => ({ + onAuthorize () { + dispatch(authorizeFollowRequest(account.get('id'))); + }, + + onReject () { + dispatch(rejectFollowRequest(account.get('id'))); + }, +}); + +export default connect(null, mapDispatchToProps)(FollowRequest); diff --git a/app/javascript/flavours/blobfox/features/notifications/containers/notification_container.js b/app/javascript/flavours/blobfox/features/notifications/containers/notification_container.js new file mode 100644 index 00000000000000..3a507128983b81 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/notifications/containers/notification_container.js @@ -0,0 +1,25 @@ +import { connect } from 'react-redux'; + +import { mentionCompose } from 'flavours/blobfox/actions/compose'; +import { makeGetNotification } from 'flavours/blobfox/selectors'; + +import Notification from '../components/notification'; + +const makeMapStateToProps = () => { + const getNotification = makeGetNotification(); + + const mapStateToProps = (state, props) => ({ + notification: getNotification(state, props.notification, props.accountId), + notifCleaning: state.getIn(['notifications', 'cleaningMode']), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = dispatch => ({ + onMention: (account, history) => { + dispatch(mentionCompose(account, history)); + }, +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(Notification); diff --git a/app/javascript/flavours/blobfox/features/notifications/containers/overlay_container.js b/app/javascript/flavours/blobfox/features/notifications/containers/overlay_container.js new file mode 100644 index 00000000000000..145efb3663f6d4 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/notifications/containers/overlay_container.js @@ -0,0 +1,19 @@ +// Package imports. +import { connect } from 'react-redux'; + +// Our imports. +import { markNotificationForDelete } from 'flavours/blobfox/actions/notifications'; + +import NotificationOverlay from '../components/overlay'; + +const mapDispatchToProps = dispatch => ({ + onMarkForDelete(id, yes) { + dispatch(markNotificationForDelete(id, yes)); + }, +}); + +const mapStateToProps = state => ({ + show: state.getIn(['notifications', 'cleaningMode']), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(NotificationOverlay); diff --git a/app/javascript/flavours/blobfox/features/notifications/index.jsx b/app/javascript/flavours/blobfox/features/notifications/index.jsx new file mode 100644 index 00000000000000..c0cca9debcd56c --- /dev/null +++ b/app/javascript/flavours/blobfox/features/notifications/index.jsx @@ -0,0 +1,368 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; +import { Helmet } from 'react-helmet'; + +import { List as ImmutableList } from 'immutable'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; + +import { debounce } from 'lodash'; + +import { compareId } from 'flavours/blobfox/compare_id'; +import { Icon } from 'flavours/blobfox/components/icon'; +import { NotSignedInIndicator } from 'flavours/blobfox/components/not_signed_in_indicator'; + +import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; +import { submitMarkers } from '../../actions/markers'; +import { + enterNotificationClearingMode, + expandNotifications, + scrollTopNotifications, + loadPending, + mountNotifications, + unmountNotifications, + markNotificationsAsRead, +} from '../../actions/notifications'; +import Column from '../../components/column'; +import ColumnHeader from '../../components/column_header'; +import { LoadGap } from '../../components/load_gap'; +import ScrollableList from '../../components/scrollable_list'; +import NotificationPurgeButtonsContainer from '../../containers/notification_purge_buttons_container'; + +import NotificationsPermissionBanner from './components/notifications_permission_banner'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import FilterBarContainer from './containers/filter_bar_container'; +import NotificationContainer from './containers/notification_container'; + +const messages = defineMessages({ + title: { id: 'column.notifications', defaultMessage: 'Notifications' }, + enterNotifCleaning : { id: 'notification_purge.start', defaultMessage: 'Enter notification cleaning mode' }, + 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), + localSettings: state.get('local_settings'), + 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, + notifCleaningActive: state.getIn(['notifications', 'cleaningMode']), + 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']), +}); + +/* blobfox */ +const mapDispatchToProps = dispatch => ({ + onEnterCleaningMode(yes) { + dispatch(enterNotificationClearingMode(yes)); + }, + dispatch, +}); + +class Notifications extends 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, + localSettings: ImmutablePropTypes.map, + notifCleaningActive: PropTypes.bool, + onEnterCleaningMode: PropTypes.func, + lastReadId: PropTypes.string, + canMarkAsRead: PropTypes.bool, + needsNotificationPermission: PropTypes.bool, + }; + + static defaultProps = { + trackScroll: true, + }; + + state = { + animatingNCD: false, + }; + + componentDidMount() { + 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(); + } + } + + handleTransitionEndNCD = () => { + this.setState({ animatingNCD: false }); + }; + + onEnterCleaningMode = () => { + this.setState({ animatingNCD: true }); + this.props.onEnterCleaningMode(!this.props.notifCleaningActive); + }; + + 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 { notifCleaningActive } = this.props; + const { animatingNCD } = this.state; + const pinned = !!columnId; + const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. When other people interact with you, you will see it here." />; + const { signedIn } = this.context.identity; + + let scrollableContent = null; + + const filterBarContainer = (signedIn && showFilterBar) + ? (<FilterBarContainer />) + : null; + + if (isLoading && this.scrollableContent) { + scrollableContent = this.scrollableContent; + } else if (notifications.size > 0 || hasMore) { + scrollableContent = notifications.map((item, index) => item === null ? ( + <LoadGap + key={'gap:' + notifications.getIn([index + 1, 'id'])} + disabled={isLoading} + maxId={index > 0 ? notifications.getIn([index - 1, 'id']) : null} + onClick={this.handleLoadGap} + /> + ) : ( + <NotificationContainer + key={item.get('id')} + notification={item} + accountId={item.get('account')} + onMoveUp={this.handleMoveUp} + onMoveDown={this.handleMoveDown} + unread={lastReadId !== '0' && compareId(item.get('id'), lastReadId) > 0} + /> + )); + } else { + scrollableContent = null; + } + + this.scrollableContent = scrollableContent; + + let scrollContainer; + + if (signedIn) { + scrollContainer = ( + <ScrollableList + scrollKey={`notifications-${columnId}`} + trackScroll={!pinned} + isLoading={isLoading} + showLoading={isLoading && notifications.size === 0} + hasMore={hasMore} + numPending={numPending} + prepend={needsNotificationPermission && <NotificationsPermissionBanner />} + alwaysPrepend + emptyMessage={emptyMessage} + onLoadMore={this.handleLoadOlder} + onLoadPending={this.handleLoadPending} + onScrollToTop={this.handleScrollToTop} + onScroll={this.handleScroll} + bindToDocument={!multiColumn} + > + {scrollableContent} + </ScrollableList> + ); + } else { + scrollContainer = <NotSignedInIndicator />; + } + + const extraButtons = []; + + if (canMarkAsRead) { + extraButtons.push( + <button + key='mark-as-read' + aria-label={intl.formatMessage(messages.markAsRead)} + title={intl.formatMessage(messages.markAsRead)} + onClick={this.handleMarkAsRead} + className='column-header__button' + > + <Icon id='check' /> + </button>, + ); + } + + const notifCleaningButtonClassName = classNames('column-header__button', { + 'active': notifCleaningActive, + }); + + const notifCleaningDrawerClassName = classNames('ncd column-header__collapsible', { + 'collapsed': !notifCleaningActive, + 'animating': animatingNCD, + }); + + const msgEnterNotifCleaning = intl.formatMessage(messages.enterNotifCleaning); + + extraButtons.push( + <button + key='notif-cleaning' + aria-label={msgEnterNotifCleaning} + title={msgEnterNotifCleaning} + onClick={this.onEnterCleaningMode} + className={notifCleaningButtonClassName} + > + <Icon id='eraser' /> + </button>, + ); + + const notifCleaningDrawer = ( + <div className={notifCleaningDrawerClassName} onTransitionEnd={this.handleTransitionEndNCD}> + <div className='column-header__collapsible-inner nopad-drawer'> + {(notifCleaningActive || animatingNCD) ? (<NotificationPurgeButtonsContainer />) : null } + </div> + </div> + ); + + return ( + <Column + bindToDocument={!multiColumn} + ref={this.setColumnRef} + extraClasses={this.props.notifCleaningActive ? 'notif-cleaning' : null} + label={intl.formatMessage(messages.title)} + > + <ColumnHeader + icon='bell' + active={isUnread} + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + localSettings={this.props.localSettings} + extraButton={extraButtons} + appendContent={notifCleaningDrawer} + > + <ColumnSettingsContainer /> + </ColumnHeader> + + {filterBarContainer} + {scrollContainer} + + <Helmet> + <title>{intl.formatMessage(messages.title)}</title> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(Notifications)); diff --git a/app/javascript/flavours/blobfox/features/onboarding/components/arrow_small_right.jsx b/app/javascript/flavours/blobfox/features/onboarding/components/arrow_small_right.jsx new file mode 100644 index 00000000000000..79b9db383f563d --- /dev/null +++ b/app/javascript/flavours/blobfox/features/onboarding/components/arrow_small_right.jsx @@ -0,0 +1,7 @@ +const ArrowSmallRight = () => ( + <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='currentColor'> + <path fillRule='evenodd' d='M5 10a.75.75 0 01.75-.75h6.638L10.23 7.29a.75.75 0 111.04-1.08l3.5 3.25a.75.75 0 010 1.08l-3.5 3.25a.75.75 0 11-1.04-1.08l2.158-1.96H5.75A.75.75 0 015 10z' clipRule='evenodd' /> + </svg> +); + +export default ArrowSmallRight; \ No newline at end of file diff --git a/app/javascript/flavours/blobfox/features/onboarding/components/progress_indicator.jsx b/app/javascript/flavours/blobfox/features/onboarding/components/progress_indicator.jsx new file mode 100644 index 00000000000000..5a2623616ecb3f --- /dev/null +++ b/app/javascript/flavours/blobfox/features/onboarding/components/progress_indicator.jsx @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types'; +import { Fragment } from 'react'; + +import classNames from 'classnames'; + +import { Check } from 'flavours/blobfox/components/check'; + + +const ProgressIndicator = ({ steps, completed }) => ( + <div className='onboarding__progress-indicator'> + {(new Array(steps)).fill().map((_, i) => ( + <Fragment key={i}> + {i > 0 && <div className={classNames('onboarding__progress-indicator__line', { active: completed > i })} />} + + <div className={classNames('onboarding__progress-indicator__step', { active: completed > i })}> + {completed > i && <Check />} + </div> + </Fragment> + ))} + </div> +); + +ProgressIndicator.propTypes = { + steps: PropTypes.number.isRequired, + completed: PropTypes.number, +}; + +export default ProgressIndicator; diff --git a/app/javascript/flavours/blobfox/features/onboarding/components/step.jsx b/app/javascript/flavours/blobfox/features/onboarding/components/step.jsx new file mode 100644 index 00000000000000..b904c47ab308f4 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/onboarding/components/step.jsx @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; + +import { Check } from 'flavours/blobfox/components/check'; +import { Icon } from 'flavours/blobfox/components/icon'; + +import ArrowSmallRight from './arrow_small_right'; + +const Step = ({ label, description, icon, completed, onClick, href }) => { + const content = ( + <> + <div className='onboarding__steps__item__icon'> + <Icon id={icon} /> + </div> + + <div className='onboarding__steps__item__description'> + <h6>{label}</h6> + <p>{description}</p> + </div> + + <div className={completed ? 'onboarding__steps__item__progress' : 'onboarding__steps__item__go'}> + {completed ? <Check /> : <ArrowSmallRight />} + </div> + </> + ); + + if (href) { + return ( + <a href={href} onClick={onClick} target='_blank' rel='noopener' className='onboarding__steps__item'> + {content} + </a> + ); + } + + return ( + <button onClick={onClick} className='onboarding__steps__item'> + {content} + </button> + ); +}; + +Step.propTypes = { + label: PropTypes.node, + description: PropTypes.node, + icon: PropTypes.string, + completed: PropTypes.bool, + href: PropTypes.string, + onClick: PropTypes.func, +}; + +export default Step; diff --git a/app/javascript/flavours/blobfox/features/onboarding/follows.jsx b/app/javascript/flavours/blobfox/features/onboarding/follows.jsx new file mode 100644 index 00000000000000..de753b0e6dbaf8 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/onboarding/follows.jsx @@ -0,0 +1,80 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +import { fetchSuggestions } from 'flavours/blobfox/actions/suggestions'; +import { markAsPartial } from 'flavours/blobfox/actions/timelines'; +import Column from 'flavours/blobfox/components/column'; +import ColumnBackButton from 'flavours/blobfox/components/column_back_button'; +import { EmptyAccount } from 'flavours/blobfox/components/empty_account'; +import Account from 'flavours/blobfox/containers/account_container'; + +const mapStateToProps = state => ({ + suggestions: state.getIn(['suggestions', 'items']), + isLoading: state.getIn(['suggestions', 'isLoading']), +}); + +class Follows extends PureComponent { + + static propTypes = { + onBack: PropTypes.func, + dispatch: PropTypes.func.isRequired, + suggestions: ImmutablePropTypes.list, + isLoading: PropTypes.bool, + multiColumn: PropTypes.bool, + }; + + componentDidMount () { + const { dispatch } = this.props; + dispatch(fetchSuggestions(true)); + } + + componentWillUnmount () { + const { dispatch } = this.props; + dispatch(markAsPartial('home')); + } + + render () { + const { onBack, isLoading, suggestions, multiColumn } = this.props; + + let loadedContent; + + if (isLoading) { + loadedContent = (new Array(8)).fill().map((_, i) => <EmptyAccount key={i} />); + } else if (suggestions.isEmpty()) { + loadedContent = <div className='follow-recommendations__empty'><FormattedMessage id='onboarding.follows.empty' defaultMessage='Unfortunately, no results can be shown right now. You can try using search or browsing the explore page to find people to follow, or try again later.' /></div>; + } else { + loadedContent = suggestions.map(suggestion => <Account id={suggestion.get('account')} key={suggestion.get('account')} withBio />); + } + + return ( + <Column> + <ColumnBackButton multiColumn={multiColumn} onClick={onBack} /> + + <div className='scrollable privacy-policy'> + <div className='column-title'> + <h3><FormattedMessage id='onboarding.follows.title' defaultMessage='Popular on Mastodon' /></h3> + <p><FormattedMessage id='onboarding.follows.lead' defaultMessage='You curate your own home feed. The more people you follow, the more active and interesting it will be. These profiles may be a good starting point—you can always unfollow them later!' /></p> + </div> + + <div className='follow-recommendations'> + {loadedContent} + </div> + + <p className='onboarding__lead'><FormattedMessage id='onboarding.tips.accounts_from_other_servers' defaultMessage='<strong>Did you know?</strong> Since Mastodon is decentralized, some profiles you come across will be hosted on servers other than yours. And yet you can interact with them seamlessly! Their server is in the second half of their username!' values={{ strong: chunks => <strong>{chunks}</strong> }} /></p> + + <div className='onboarding__footer'> + <button className='link-button' onClick={onBack}><FormattedMessage id='onboarding.actions.back' defaultMessage='Take me back' /></button> + </div> + </div> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(Follows); diff --git a/app/javascript/flavours/blobfox/features/onboarding/index.jsx b/app/javascript/flavours/blobfox/features/onboarding/index.jsx new file mode 100644 index 00000000000000..abb1dd7d075ec0 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/onboarding/index.jsx @@ -0,0 +1,149 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; + +import { Helmet } from 'react-helmet'; +import { Link, withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { debounce } from 'lodash'; + +import { fetchAccount } from 'flavours/blobfox/actions/accounts'; +import { focusCompose } from 'flavours/blobfox/actions/compose'; +import { closeOnboarding } from 'flavours/blobfox/actions/onboarding'; +import Column from 'flavours/blobfox/features/ui/components/column'; +import { me } from 'flavours/blobfox/initial_state'; +import { makeGetAccount } from 'flavours/blobfox/selectors'; +import { assetHost } from 'flavours/blobfox/utils/config'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; +import illustration from 'mastodon/../images/elephant_ui_conversation.svg'; + +import ArrowSmallRight from './components/arrow_small_right'; +import Step from './components/step'; +import Follows from './follows'; +import Share from './share'; + +const messages = defineMessages({ + template: { id: 'onboarding.compose.template', defaultMessage: 'Hello #Mastodon!' }, +}); + +const mapStateToProps = () => { + const getAccount = makeGetAccount(); + + return state => ({ + account: getAccount(state, me), + }); +}; + +class Onboarding extends ImmutablePureComponent { + static propTypes = { + dispatch: PropTypes.func.isRequired, + account: ImmutablePropTypes.record, + multiColumn: PropTypes.bool, + ...WithRouterPropTypes, + }; + + state = { + step: null, + profileClicked: false, + shareClicked: false, + }; + + handleClose = () => { + const { dispatch, history } = this.props; + + dispatch(closeOnboarding()); + history.push('/home'); + }; + + handleProfileClick = () => { + this.setState({ profileClicked: true }); + }; + + handleFollowClick = () => { + this.setState({ step: 'follows' }); + }; + + handleComposeClick = () => { + const { dispatch, intl, history } = this.props; + + dispatch(focusCompose(history, intl.formatMessage(messages.template))); + }; + + handleShareClick = () => { + this.setState({ step: 'share', shareClicked: true }); + }; + + handleBackClick = () => { + this.setState({ step: null }); + }; + + handleWindowFocus = debounce(() => { + const { dispatch, account } = this.props; + dispatch(fetchAccount(account.get('id'))); + }, 1000, { trailing: true }); + + componentDidMount () { + window.addEventListener('focus', this.handleWindowFocus, false); + } + + componentWillUnmount () { + window.removeEventListener('focus', this.handleWindowFocus); + } + + render () { + const { account, multiColumn } = this.props; + const { step, shareClicked } = this.state; + + switch(step) { + case 'follows': + return <Follows onBack={this.handleBackClick} multiColumn={multiColumn} />; + case 'share': + return <Share onBack={this.handleBackClick} multiColumn={multiColumn} />; + } + + return ( + <Column> + <div className='scrollable privacy-policy'> + <div className='column-title'> + <img src={illustration} alt='' className='onboarding__illustration' /> + <h3><FormattedMessage id='onboarding.start.title' defaultMessage="You've made it!" /></h3> + <p><FormattedMessage id='onboarding.start.lead' defaultMessage="Your new Mastodon account is ready to go. Here's how you can make the most of it:" /></p> + </div> + + <div className='onboarding__steps'> + <Step onClick={this.handleProfileClick} href='/settings/profile' completed={(!account.get('avatar').endsWith('missing.png')) || (account.get('display_name').length > 0 && account.get('note').length > 0)} icon='address-book-o' label={<FormattedMessage id='onboarding.steps.setup_profile.title' defaultMessage='Customize your profile' />} description={<FormattedMessage id='onboarding.steps.setup_profile.body' defaultMessage='Others are more likely to interact with you with a filled out profile.' />} /> + <Step onClick={this.handleFollowClick} completed={(account.get('following_count') * 1) >= 7} icon='user-plus' label={<FormattedMessage id='onboarding.steps.follow_people.title' defaultMessage='Find at least {count, plural, one {one person} other {# people}} to follow' values={{ count: 7 }} />} description={<FormattedMessage id='onboarding.steps.follow_people.body' defaultMessage="You curate your own home feed. Let's fill it with interesting people." />} /> + <Step onClick={this.handleComposeClick} completed={(account.get('statuses_count') * 1) >= 1} icon='pencil-square-o' label={<FormattedMessage id='onboarding.steps.publish_status.title' defaultMessage='Make your first post' />} description={<FormattedMessage id='onboarding.steps.publish_status.body' defaultMessage='Say hello to the world.' values={{ emoji: <img className='emojione' alt='🐘' src={`${assetHost}/emoji/1f418.svg`} /> }} />} /> + <Step onClick={this.handleShareClick} completed={shareClicked} icon='copy' label={<FormattedMessage id='onboarding.steps.share_profile.title' defaultMessage='Share your profile' />} description={<FormattedMessage id='onboarding.steps.share_profile.body' defaultMessage='Let your friends know how to find you on Mastodon!' />} /> + </div> + + <p className='onboarding__lead'><FormattedMessage id='onboarding.start.skip' defaultMessage="Don't need help getting started?" /></p> + + <div className='onboarding__links'> + <Link to='/explore' className='onboarding__link'> + <FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage='Take me to trending' /> + <ArrowSmallRight /> + </Link> + + <Link to='/home' className='onboarding__link'> + <FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Take me to my home feed' /> + <ArrowSmallRight /> + </Link> + </div> + </div> + + <Helmet> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} + +export default withRouter(connect(mapStateToProps)(injectIntl(Onboarding))); diff --git a/app/javascript/flavours/blobfox/features/onboarding/share.jsx b/app/javascript/flavours/blobfox/features/onboarding/share.jsx new file mode 100644 index 00000000000000..b6d4d0eb02c798 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/onboarding/share.jsx @@ -0,0 +1,200 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; +import { Link } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +import SwipeableViews from 'react-swipeable-views'; + +import Column from 'flavours/blobfox/components/column'; +import ColumnBackButton from 'flavours/blobfox/components/column_back_button'; +import { Icon } from 'flavours/blobfox/components/icon'; +import { me, domain } from 'flavours/blobfox/initial_state'; + +import ArrowSmallRight from './components/arrow_small_right'; + +const messages = defineMessages({ + shareableMessage: { id: 'onboarding.share.message', defaultMessage: 'I\'m {username} on #Mastodon! Come follow me at {url}' }, +}); + +const mapStateToProps = state => ({ + account: state.getIn(['accounts', me]), +}); + +class CopyPasteText extends PureComponent { + + static propTypes = { + value: PropTypes.string, + }; + + state = { + copied: false, + focused: false, + }; + + setRef = c => { + this.input = c; + }; + + handleInputClick = () => { + this.setState({ copied: false }); + this.input.focus(); + this.input.select(); + this.input.setSelectionRange(0, this.props.value.length); + }; + + handleButtonClick = e => { + e.stopPropagation(); + + const { value } = this.props; + navigator.clipboard.writeText(value); + this.input.blur(); + this.setState({ copied: true }); + this.timeout = setTimeout(() => this.setState({ copied: false }), 700); + }; + + handleFocus = () => { + this.setState({ focused: true }); + }; + + handleBlur = () => { + this.setState({ focused: false }); + }; + + componentWillUnmount () { + if (this.timeout) clearTimeout(this.timeout); + } + + render () { + const { value } = this.props; + const { copied, focused } = this.state; + + return ( + <div className={classNames('copy-paste-text', { copied, focused })} tabIndex='0' role='button' onClick={this.handleInputClick}> + <textarea readOnly value={value} ref={this.setRef} onClick={this.handleInputClick} onFocus={this.handleFocus} onBlur={this.handleBlur} /> + + <button className='button' onClick={this.handleButtonClick}> + <Icon id='copy' /> {copied ? <FormattedMessage id='copypaste.copied' defaultMessage='Copied' /> : <FormattedMessage id='copypaste.copy_to_clipboard' defaultMessage='Copy to clipboard' />} + </button> + </div> + ); + } + +} + +class TipCarousel extends PureComponent { + + static propTypes = { + children: PropTypes.node, + }; + + state = { + index: 0, + }; + + handleSwipe = index => { + this.setState({ index }); + }; + + handleChangeIndex = e => { + this.setState({ index: Number(e.currentTarget.getAttribute('data-index')) }); + }; + + handleKeyDown = e => { + switch(e.key) { + case 'ArrowLeft': + e.preventDefault(); + this.setState(({ index }, { children }) => ({ index: Math.abs(index - 1) % children.length })); + break; + case 'ArrowRight': + e.preventDefault(); + this.setState(({ index }, { children }) => ({ index: (index + 1) % children.length })); + break; + } + }; + + render () { + const { children } = this.props; + const { index } = this.state; + + return ( + <div className='tip-carousel' tabIndex='0' onKeyDown={this.handleKeyDown}> + <SwipeableViews onChangeIndex={this.handleSwipe} index={index} enableMouseEvents tabIndex='-1'> + {children} + </SwipeableViews> + + <div className='media-modal__pagination'> + {children.map((_, i) => ( + <button key={i} className={classNames('media-modal__page-dot', { active: i === index })} data-index={i} onClick={this.handleChangeIndex}> + {i + 1} + </button> + ))} + </div> + </div> + ); + } + +} + +class Share extends PureComponent { + + static propTypes = { + onBack: PropTypes.func, + account: ImmutablePropTypes.record, + multiColumn: PropTypes.bool, + intl: PropTypes.object, + }; + + render () { + const { onBack, account, multiColumn, intl } = this.props; + + const url = (new URL(`/@${account.get('username')}`, document.baseURI)).href; + + return ( + <Column> + <ColumnBackButton multiColumn={multiColumn} onClick={onBack} /> + + <div className='scrollable privacy-policy'> + <div className='column-title'> + <h3><FormattedMessage id='onboarding.share.title' defaultMessage='Share your profile' /></h3> + <p><FormattedMessage id='onboarding.share.lead' defaultMessage='Let people know how they can find you on Mastodon!' /></p> + </div> + + <CopyPasteText value={intl.formatMessage(messages.shareableMessage, { username: `@${account.get('username')}@${domain}`, url })} /> + + <TipCarousel> + <div><p className='onboarding__lead'><FormattedMessage id='onboarding.tips.verification' defaultMessage='<strong>Did you know?</strong> You can verify your account by putting a link to your Mastodon profile on your own website and adding the website to your profile. No fees or documents necessary!' values={{ strong: chunks => <strong>{chunks}</strong> }} /></p></div> + <div><p className='onboarding__lead'><FormattedMessage id='onboarding.tips.migration' defaultMessage='<strong>Did you know?</strong> If you feel like {domain} is not a great server choice for you in the future, you can move to another Mastodon server without losing your followers. You can even host your own server!' values={{ domain, strong: chunks => <strong>{chunks}</strong> }} /></p></div> + <div><p className='onboarding__lead'><FormattedMessage id='onboarding.tips.2fa' defaultMessage='<strong>Did you know?</strong> You can secure your account by setting up two-factor authentication in your account settings. It works with any TOTP app of your choice, no phone number necessary!' values={{ strong: chunks => <strong>{chunks}</strong> }} /></p></div> + </TipCarousel> + + <p className='onboarding__lead'><FormattedMessage id='onboarding.share.next_steps' defaultMessage='Possible next steps:' /></p> + + <div className='onboarding__links'> + <Link to='/home' className='onboarding__link'> + <FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Take me to my home feed' /> + <ArrowSmallRight /> + </Link> + + <Link to='/explore' className='onboarding__link'> + <FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage='Take me to trending' /> + <ArrowSmallRight /> + </Link> + </div> + + <div className='onboarding__footer'> + <button className='link-button' onClick={onBack}><FormattedMessage id='onboarding.action.back' defaultMessage='Take me back' /></button> + </div> + </div> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(Share)); diff --git a/app/javascript/flavours/blobfox/features/picture_in_picture/components/footer.jsx b/app/javascript/flavours/blobfox/features/picture_in_picture/components/footer.jsx new file mode 100644 index 00000000000000..e91282d9529027 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/picture_in_picture/components/footer.jsx @@ -0,0 +1,231 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import classNames from 'classnames'; +import { withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { initBoostModal } from 'flavours/blobfox/actions/boosts'; +import { replyCompose } from 'flavours/blobfox/actions/compose'; +import { reblog, favourite, unreblog, unfavourite } from 'flavours/blobfox/actions/interactions'; +import { openModal } from 'flavours/blobfox/actions/modal'; +import { IconButton } from 'flavours/blobfox/components/icon_button'; +import { me, boostModal } from 'flavours/blobfox/initial_state'; +import { makeGetStatus } from 'flavours/blobfox/selectors'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +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: 'Favorite' }, + 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, + showReplyCount: state.getIn(['local_settings', 'show_reply_count']), + }); + + return mapStateToProps; +}; + +class Footer extends ImmutablePureComponent { + + static contextTypes = { + identity: PropTypes.object, + }; + + static propTypes = { + statusId: PropTypes.string.isRequired, + status: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + askReplyConfirmation: PropTypes.bool, + showReplyCount: PropTypes.bool, + withOpenButton: PropTypes.bool, + onClose: PropTypes.func, + ...WithRouterPropTypes, + }; + + _performReply = () => { + const { dispatch, status, onClose, history } = this.props; + + if (onClose) { + onClose(true); + } + + dispatch(replyCompose(status, history)); + }; + + handleReplyClick = () => { + const { dispatch, askReplyConfirmation, status, intl } = this.props; + const { signedIn } = this.context.identity; + + if (signedIn) { + if (askReplyConfirmation) { + dispatch(openModal({ + modalType: 'CONFIRM', + modalProps: { + message: intl.formatMessage(messages.replyMessage), + confirm: intl.formatMessage(messages.replyConfirm), + onConfirm: this._performReply, + }, + })); + } else { + this._performReply(); + } + } else { + dispatch(openModal({ + modalType: 'INTERACTION', + modalProps: { + type: 'reply', + accountId: status.getIn(['account', 'id']), + url: status.get('uri'), + }, + })); + } + }; + + 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({ + modalType: 'INTERACTION', + modalProps: { + type: 'favourite', + accountId: status.getIn(['account', 'id']), + url: status.get('uri'), + }, + })); + } + }; + + _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({ + modalType: 'INTERACTION', + modalProps: { + type: 'reblog', + accountId: status.getIn(['account', 'id']), + url: status.get('uri'), + }, + })); + } + }; + + handleOpenClick = e => { + if (e.button !== 0 || !history) { + return; + } + + const { status, onClose } = this.props; + + if (onClose) { + onClose(); + } + + this.props.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`); + }; + + render () { + const { status, intl, showReplyCount, 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); + } + + let replyButton = null; + if (showReplyCount) { + replyButton = ( + <IconButton + className='status__action-bar-button' + title={replyTitle} + icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} + onClick={this.handleReplyClick} + counter={status.get('replies_count')} + obfuscateCount + /> + ); + } else { + replyButton = ( + <IconButton + className='status__action-bar-button' + title={replyTitle} + icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} + onClick={this.handleReplyClick} + /> + ); + } + + return ( + <div className='picture-in-picture__footer'> + {replyButton} + <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} /> + <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} /> + {withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' onClick={this.handleOpenClick} href={status.get('url')} />} + </div> + ); + } + +} + +export default withRouter(connect(makeMapStateToProps)(injectIntl(Footer))); diff --git a/app/javascript/flavours/blobfox/features/picture_in_picture/components/header.jsx b/app/javascript/flavours/blobfox/features/picture_in_picture/components/header.jsx new file mode 100644 index 00000000000000..887924f33767e8 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/picture_in_picture/components/header.jsx @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import { Link } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { Avatar } from 'flavours/blobfox/components/avatar'; +import { DisplayName } from 'flavours/blobfox/components/display_name'; +import { IconButton } from 'flavours/blobfox/components/icon_button'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); + +const mapStateToProps = (state, { accountId }) => ({ + account: state.getIn(['accounts', accountId]), +}); + +class Header extends ImmutablePureComponent { + + static propTypes = { + accountId: PropTypes.string.isRequired, + statusId: PropTypes.string.isRequired, + account: ImmutablePropTypes.record.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render () { + const { account, statusId, onClose, intl } = this.props; + + return ( + <div className='picture-in-picture__header'> + <Link to={`/@${account.get('acct')}/${statusId}`} className='picture-in-picture__header__account'> + <Avatar account={account} size={36} /> + <DisplayName account={account} /> + </Link> + + <IconButton icon='times' onClick={onClose} title={intl.formatMessage(messages.close)} /> + </div> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(Header)); diff --git a/app/javascript/flavours/blobfox/features/picture_in_picture/index.jsx b/app/javascript/flavours/blobfox/features/picture_in_picture/index.jsx new file mode 100644 index 00000000000000..9c5d0bb422bd01 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/picture_in_picture/index.jsx @@ -0,0 +1,93 @@ +import PropTypes from 'prop-types'; +import { Component } from 'react'; + +import classNames from 'classnames'; + +import { connect } from 'react-redux'; + +import { removePictureInPicture } from 'flavours/blobfox/actions/picture_in_picture'; +import Audio from 'flavours/blobfox/features/audio'; +import Video from 'flavours/blobfox/features/video'; + +import Footer from './components/footer'; +import Header from './components/header'; + +const mapStateToProps = state => ({ + ...state.get('picture_in_picture'), + left: state.getIn(['local_settings', 'media', 'pop_in_position']) === 'left', +}); + +class PictureInPicture extends 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, + left: PropTypes.bool, + }; + + handleClose = () => { + const { dispatch } = this.props; + dispatch(removePictureInPicture()); + }; + + render () { + const { type, src, currentTime, accountId, statusId, left } = this.props; + + if (!currentTime) { + return null; + } + + let player; + + if (type === 'video') { + player = ( + <Video + src={src} + currentTime={this.props.currentTime} + volume={this.props.volume} + muted={this.props.muted} + autoPlay + inline + alwaysVisible + /> + ); + } else if (type === 'audio') { + player = ( + <Audio + src={src} + currentTime={this.props.currentTime} + volume={this.props.volume} + muted={this.props.muted} + poster={this.props.poster} + backgroundColor={this.props.backgroundColor} + foregroundColor={this.props.foregroundColor} + accentColor={this.props.accentColor} + autoPlay + /> + ); + } + + return ( + <div className={classNames('picture-in-picture', { left })}> + <Header accountId={accountId} statusId={statusId} onClose={this.handleClose} /> + + {player} + + <Footer statusId={statusId} /> + </div> + ); + } + +} + +export default connect(mapStateToProps)(PictureInPicture); diff --git a/app/javascript/flavours/blobfox/features/pinned_accounts_editor/containers/account_container.js b/app/javascript/flavours/blobfox/features/pinned_accounts_editor/containers/account_container.js new file mode 100644 index 00000000000000..841d5baeb4d85c --- /dev/null +++ b/app/javascript/flavours/blobfox/features/pinned_accounts_editor/containers/account_container.js @@ -0,0 +1,25 @@ +import { injectIntl } from 'react-intl'; + +import { connect } from 'react-redux'; + +import { pinAccount, unpinAccount } from 'flavours/blobfox/actions/accounts'; +import Account from 'flavours/blobfox/features/list_editor/components/account'; +import { makeGetAccount } from 'flavours/blobfox/selectors'; + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { accountId, added }) => ({ + account: getAccount(state, accountId), + added: typeof added === 'undefined' ? state.getIn(['pinnedAccountsEditor', 'accounts', 'items']).includes(accountId) : added, + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { accountId }) => ({ + onRemove: () => dispatch(unpinAccount(accountId)), + onAdd: () => dispatch(pinAccount(accountId)), +}); + +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account)); diff --git a/app/javascript/flavours/blobfox/features/pinned_accounts_editor/containers/search_container.js b/app/javascript/flavours/blobfox/features/pinned_accounts_editor/containers/search_container.js new file mode 100644 index 00000000000000..095417884afcc7 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/pinned_accounts_editor/containers/search_container.js @@ -0,0 +1,24 @@ +import { injectIntl } from 'react-intl'; + +import { connect } from 'react-redux'; + +import Search from 'flavours/blobfox/features/list_editor/components/search'; + +import { + fetchPinnedAccountsSuggestions, + clearPinnedAccountsSuggestions, + changePinnedAccountsSuggestions, +} from '../../../actions/accounts'; + + +const mapStateToProps = state => ({ + value: state.getIn(['pinnedAccountsEditor', 'suggestions', 'value']), +}); + +const mapDispatchToProps = dispatch => ({ + onSubmit: value => dispatch(fetchPinnedAccountsSuggestions(value)), + onClear: () => dispatch(clearPinnedAccountsSuggestions()), + onChange: value => dispatch(changePinnedAccountsSuggestions(value)), +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Search)); diff --git a/app/javascript/flavours/blobfox/features/pinned_accounts_editor/index.jsx b/app/javascript/flavours/blobfox/features/pinned_accounts_editor/index.jsx new file mode 100644 index 00000000000000..d550984f597ab9 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/pinned_accounts_editor/index.jsx @@ -0,0 +1,82 @@ +import PropTypes from 'prop-types'; + +import { injectIntl, FormattedMessage } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import spring from 'react-motion/lib/spring'; + +import { fetchPinnedAccounts, clearPinnedAccountsSuggestions, resetPinnedAccountsEditor } from 'flavours/blobfox/actions/accounts'; +import Motion from 'flavours/blobfox/features/ui/util/optional_motion'; + +import AccountContainer from './containers/account_container'; +import SearchContainer from './containers/search_container'; + +const mapStateToProps = state => ({ + accountIds: state.getIn(['pinnedAccountsEditor', 'accounts', 'items']), + searchAccountIds: state.getIn(['pinnedAccountsEditor', 'suggestions', 'items']), +}); + +const mapDispatchToProps = dispatch => ({ + onInitialize: () => dispatch(fetchPinnedAccounts()), + onClear: () => dispatch(clearPinnedAccountsSuggestions()), + onReset: () => dispatch(resetPinnedAccountsEditor()), +}); + +class PinnedAccountsEditor extends ImmutablePureComponent { + + static propTypes = { + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + onInitialize: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, + onReset: PropTypes.func.isRequired, + title: PropTypes.string.isRequired, + accountIds: ImmutablePropTypes.list.isRequired, + searchAccountIds: ImmutablePropTypes.list.isRequired, + }; + + componentDidMount () { + const { onInitialize } = this.props; + onInitialize(); + } + + componentWillUnmount () { + const { onReset } = this.props; + onReset(); + } + + render () { + const { accountIds, searchAccountIds, onClear } = this.props; + const showSearch = searchAccountIds.size > 0; + + return ( + <div className='modal-root__modal list-editor'> + <h4><FormattedMessage id='endorsed_accounts_editor.endorsed_accounts' defaultMessage='Featured accounts' /></h4> + + <SearchContainer /> + + <div className='drawer__pager'> + <div className='drawer__inner list-editor__accounts'> + {accountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} added />)} + </div> + + {showSearch && <div role='button' tabIndex={-1} className='drawer__backdrop' onClick={onClear} />} + + <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}> + {({ x }) => + (<div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}> + {searchAccountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} />)} + </div>) + } + </Motion> + </div> + </div> + ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(PinnedAccountsEditor)); diff --git a/app/javascript/flavours/blobfox/features/pinned_statuses/index.jsx b/app/javascript/flavours/blobfox/features/pinned_statuses/index.jsx new file mode 100644 index 00000000000000..287b5be16235c8 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/pinned_statuses/index.jsx @@ -0,0 +1,70 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { getStatusList } from 'flavours/blobfox/selectors'; + +import { fetchPinnedStatuses } from '../../actions/pin_statuses'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import StatusList from '../../components/status_list'; +import Column from '../ui/components/column'; + +const messages = defineMessages({ + heading: { id: 'column.pins', defaultMessage: 'Pinned post' }, +}); + +const mapStateToProps = state => ({ + statusIds: getStatusList(state, 'pins'), + hasMore: !!state.getIn(['status_lists', 'pins', 'next']), +}); + +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, + }; + + UNSAFE_componentWillMount () { + this.props.dispatch(fetchPinnedStatuses()); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + }; + + setRef = c => { + this.column = c; + }; + + render () { + const { intl, statusIds, hasMore, multiColumn } = this.props; + + return ( + <Column bindToDocument={!multiColumn} icon='thumb-tack' heading={intl.formatMessage(messages.heading)} ref={this.setRef}> + <ColumnBackButtonSlim /> + <StatusList + statusIds={statusIds} + scrollKey='pinned_statuses' + hasMore={hasMore} + bindToDocument={!multiColumn} + /> + <Helmet> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(PinnedStatuses)); diff --git a/app/javascript/flavours/blobfox/features/privacy_policy/index.jsx b/app/javascript/flavours/blobfox/features/privacy_policy/index.jsx new file mode 100644 index 00000000000000..888037e46ef2a7 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/privacy_policy/index.jsx @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage, FormattedDate, injectIntl, defineMessages } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import api from 'flavours/blobfox/api'; +import Column from 'flavours/blobfox/components/column'; +import { Skeleton } from 'flavours/blobfox/components/skeleton'; + +const messages = defineMessages({ + title: { id: 'privacy_policy.title', defaultMessage: 'Privacy Policy' }, +}); + +class PrivacyPolicy extends 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 ( + <Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}> + <div className='scrollable privacy-policy'> + <div className='column-title'> + <h3><FormattedMessage id='privacy_policy.title' defaultMessage='Privacy Policy' /></h3> + <p><FormattedMessage id='privacy_policy.last_updated' defaultMessage='Last updated {date}' values={{ date: isLoading ? <Skeleton width='10ch' /> : <FormattedDate value={lastUpdated} year='numeric' month='short' day='2-digit' /> }} /></p> + </div> + + <div + className='privacy-policy__body prose' + dangerouslySetInnerHTML={{ __html: content }} + /> + </div> + + <Helmet> + <title>{intl.formatMessage(messages.title)}</title> + <meta name='robots' content='all' /> + </Helmet> + </Column> + ); + } + +} + +export default injectIntl(PrivacyPolicy); diff --git a/app/javascript/flavours/blobfox/features/public_timeline/components/column_settings.jsx b/app/javascript/flavours/blobfox/features/public_timeline/components/column_settings.jsx new file mode 100644 index 00000000000000..82684c83685439 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/public_timeline/components/column_settings.jsx @@ -0,0 +1,46 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + +import SettingText from '../../../components/setting_text'; +import SettingToggle from '../../notifications/components/setting_toggle'; + +const messages = defineMessages({ + filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }, +}); + +class ColumnSettings extends PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + onChange: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + columnId: PropTypes.string, + }; + + render () { + const { settings, onChange, intl } = this.props; + + return ( + <div> + <div className='column-settings__row'> + <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} /> + <SettingToggle settings={settings} settingPath={['other', 'onlyRemote']} onChange={onChange} label={<FormattedMessage id='community.column_settings.remote_only' defaultMessage='Remote only' />} /> + {!settings.getIn(['other', 'onlyRemote']) && <SettingToggle settings={settings} settingPath={['other', 'allowLocalOnly']} onChange={onChange} label={<FormattedMessage id='community.column_settings.allow_local_only' defaultMessage='Show local-only toots' />} />} + </div> + + <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> + + <div className='column-settings__row'> + <SettingText settings={settings} settingPath={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> + </div> + </div> + ); + } + +} + +export default injectIntl(ColumnSettings); diff --git a/app/javascript/flavours/blobfox/features/public_timeline/containers/column_settings_container.js b/app/javascript/flavours/blobfox/features/public_timeline/containers/column_settings_container.js new file mode 100644 index 00000000000000..6476d51ffbf25e --- /dev/null +++ b/app/javascript/flavours/blobfox/features/public_timeline/containers/column_settings_container.js @@ -0,0 +1,29 @@ +import { connect } from 'react-redux'; + +import { changeColumnParams } from '../../../actions/columns'; +import { changeSetting } from '../../../actions/settings'; +import ColumnSettings from '../components/column_settings'; + +const mapStateToProps = (state, { columnId }) => { + const uuid = columnId; + const columns = state.getIn(['settings', 'columns']); + const index = columns.findIndex(c => c.get('uuid') === uuid); + + return { + settings: (uuid && index >= 0) ? columns.get(index).get('params') : state.getIn(['settings', 'public']), + }; +}; + +const mapDispatchToProps = (dispatch, { columnId }) => { + return { + onChange (key, checked) { + if (columnId) { + dispatch(changeColumnParams(columnId, key, checked)); + } else { + dispatch(changeSetting(['public', ...key], checked)); + } + }, + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/flavours/blobfox/features/public_timeline/index.jsx b/app/javascript/flavours/blobfox/features/public_timeline/index.jsx new file mode 100644 index 00000000000000..b5e750851c73b5 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/public_timeline/index.jsx @@ -0,0 +1,171 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import { connect } from 'react-redux'; + +import { DismissableBanner } from 'flavours/blobfox/components/dismissable_banner'; +import { domain } from 'flavours/blobfox/initial_state'; + +import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; +import { connectPublicStream } from '../../actions/streaming'; +import { expandPublicTimeline } from '../../actions/timelines'; +import Column from '../../components/column'; +import ColumnHeader from '../../components/column_header'; +import StatusListContainer from '../ui/containers/status_list_container'; + +import ColumnSettingsContainer from './containers/column_settings_container'; + +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 allowLocalOnly = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'allowLocalOnly']) : state.getIn(['settings', 'public', 'other', 'allowLocalOnly']); + const regex = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'regex', 'body']) : state.getIn(['settings', 'public', 'regex', 'body']); + const timelineState = state.getIn(['timelines', `public${onlyRemote ? ':remote' : allowLocalOnly ? ':allow_local_only' : ''}${onlyMedia ? ':media' : ''}`]); + + return { + hasUnread: !!timelineState && timelineState.get('unread') > 0, + onlyMedia, + onlyRemote, + allowLocalOnly, + regex, + }; +}; + +class PublicTimeline extends PureComponent { + + static contextTypes = { + 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, + allowLocalOnly: PropTypes.bool, + regex: PropTypes.string, + }; + + handlePin = () => { + const { columnId, dispatch, onlyMedia, onlyRemote, allowLocalOnly } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn(onlyRemote ? 'REMOTE' : 'PUBLIC', { other: { onlyMedia, onlyRemote, allowLocalOnly } })); + } + }; + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + }; + + handleHeaderClick = () => { + this.column.scrollTop(); + }; + + componentDidMount () { + const { dispatch, onlyMedia, onlyRemote, allowLocalOnly } = this.props; + const { signedIn } = this.context.identity; + + dispatch(expandPublicTimeline({ onlyMedia, onlyRemote, allowLocalOnly })); + if (signedIn) { + this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote, allowLocalOnly })); + } + } + + componentDidUpdate (prevProps) { + const { signedIn } = this.context.identity; + + if (prevProps.onlyMedia !== this.props.onlyMedia || prevProps.onlyRemote !== this.props.onlyRemote || prevProps.allowLocalOnly !== this.props.allowLocalOnly) { + const { dispatch, onlyMedia, onlyRemote, allowLocalOnly } = this.props; + + if (this.disconnect) { + this.disconnect(); + } + + dispatch(expandPublicTimeline({ onlyMedia, onlyRemote, allowLocalOnly })); + + if (signedIn) { + this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote, allowLocalOnly })); + } + } + } + + componentWillUnmount () { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + setRef = c => { + this.column = c; + }; + + handleLoadMore = maxId => { + const { dispatch, onlyMedia, onlyRemote, allowLocalOnly } = this.props; + + dispatch(expandPublicTimeline({ maxId, onlyMedia, onlyRemote, allowLocalOnly })); + }; + + render () { + const { intl, columnId, hasUnread, multiColumn, onlyMedia, onlyRemote, allowLocalOnly } = this.props; + const pinned = !!columnId; + + return ( + <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}> + <ColumnHeader + icon='globe' + active={hasUnread} + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + > + <ColumnSettingsContainer columnId={columnId} /> + </ColumnHeader> + + <StatusListContainer + prepend={<DismissableBanner id='public_timeline'><FormattedMessage id='dismissable_banner.public_timeline' defaultMessage='These are the most recent public posts from people on the social web that people on {domain} follow.' values={{ domain }} /></DismissableBanner>} + timelineId={`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`} + onLoadMore={this.handleLoadMore} + trackScroll={!pinned} + scrollKey={`public_timeline-${columnId}`} + emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up' />} + bindToDocument={!multiColumn} + regex={this.props.regex} + /> + + <Helmet> + <title>{intl.formatMessage(messages.title)}</title> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(PublicTimeline)); diff --git a/app/javascript/flavours/blobfox/features/reblogs/index.jsx b/app/javascript/flavours/blobfox/features/reblogs/index.jsx new file mode 100644 index 00000000000000..d617b4de829715 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/reblogs/index.jsx @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { debounce } from 'lodash'; + +import { Icon } from 'flavours/blobfox/components/icon'; + +import { fetchReblogs, expandReblogs } from '../../actions/interactions'; +import ColumnHeader from '../../components/column_header'; +import { LoadingIndicator } from '../../components/loading_indicator'; +import ScrollableList from '../../components/scrollable_list'; +import AccountContainer from '../../containers/account_container'; +import Column from '../ui/components/column'; + +const messages = defineMessages({ + heading: { id: 'column.reblogged_by', defaultMessage: 'Boosted by' }, + refresh: { id: 'refresh', defaultMessage: 'Refresh' }, +}); + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId, 'items']), + hasMore: !!state.getIn(['user_lists', 'reblogged_by', props.params.statusId, 'next']), + isLoading: state.getIn(['user_lists', 'reblogged_by', props.params.statusId, 'isLoading'], true), +}); + +class Reblogs extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + hasMore: PropTypes.bool, + isLoading: PropTypes.bool, + multiColumn: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + UNSAFE_componentWillMount () { + if (!this.props.accountIds) { + this.props.dispatch(fetchReblogs(this.props.params.statusId)); + } + } + + handleHeaderClick = () => { + this.column.scrollTop(); + }; + + setRef = c => { + this.column = c; + }; + + handleRefresh = () => { + this.props.dispatch(fetchReblogs(this.props.params.statusId)); + }; + + handleLoadMore = debounce(() => { + this.props.dispatch(expandReblogs(this.props.params.statusId)); + }, 300, { leading: true }); + + render () { + const { intl, accountIds, hasMore, isLoading, multiColumn } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + const emptyMessage = <FormattedMessage id='status.reblogs.empty' defaultMessage='No one has boosted this post yet. When someone does, they will show up here.' />; + + return ( + <Column ref={this.setRef}> + <ColumnHeader + icon='retweet' + title={intl.formatMessage(messages.heading)} + onClick={this.handleHeaderClick} + showBackButton + multiColumn={multiColumn} + extraButton={( + <button className='column-header__button' title={intl.formatMessage(messages.refresh)} aria-label={intl.formatMessage(messages.refresh)} onClick={this.handleRefresh}><Icon id='refresh' /></button> + )} + /> + + <ScrollableList + scrollKey='reblogs' + onLoadMore={this.handleLoadMore} + hasMore={hasMore} + isLoading={isLoading} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {accountIds.map(id => + <AccountContainer key={id} id={id} withNote={false} />, + )} + </ScrollableList> + + <Helmet> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(Reblogs)); diff --git a/app/javascript/flavours/blobfox/features/report/category.jsx b/app/javascript/flavours/blobfox/features/report/category.jsx new file mode 100644 index 00000000000000..ee9382667fd96c --- /dev/null +++ b/app/javascript/flavours/blobfox/features/report/category.jsx @@ -0,0 +1,108 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { List as ImmutableList } from 'immutable'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +import { Button } from 'flavours/blobfox/components/button'; + +import Option from './components/option'; + +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()), +}); + +class Category extends 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 ? [ + 'spam', + 'violation', + 'other', + ] : [ + 'spam', + 'other', + ]; + + return ( + <> + <h3 className='report-dialog-modal__title'><FormattedMessage id='report.category.title' defaultMessage="Tell us what's going on with this {type}" values={{ type: intl.formatMessage(messages[startedFrom]) }} /></h3> + <p className='report-dialog-modal__lead'><FormattedMessage id='report.category.subtitle' defaultMessage='Choose the best match' /></p> + + <div> + {options.map(item => ( + <Option + key={item} + name='category' + value={item} + checked={category === item} + onToggle={this.handleCategoryToggle} + label={intl.formatMessage(messages[item])} + description={intl.formatMessage(messages[`${item}_description`])} + /> + ))} + </div> + + <div className='flex-spacer' /> + + <div className='report-dialog-modal__actions'> + <Button onClick={this.handleNextClick} disabled={category === null}><FormattedMessage id='report.next' defaultMessage='Next' /></Button> + </div> + </> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(Category)); diff --git a/app/javascript/flavours/blobfox/features/report/comment.jsx b/app/javascript/flavours/blobfox/features/report/comment.jsx new file mode 100644 index 00000000000000..5c7c835704e623 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/report/comment.jsx @@ -0,0 +1,121 @@ +import PropTypes from 'prop-types'; +import { useCallback, useEffect, useRef } from 'react'; + +import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; + +import { OrderedSet, List as ImmutableList } from 'immutable'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { shallowEqual } from 'react-redux'; +import { createSelector } from 'reselect'; + +import Toggle from 'react-toggle'; + +import { fetchAccount } from 'flavours/blobfox/actions/accounts'; +import { Button } from 'flavours/blobfox/components/button'; +import { useAppDispatch, useAppSelector } from 'flavours/blobfox/store'; + +const messages = defineMessages({ + placeholder: { id: 'report.placeholder', defaultMessage: 'Type or paste additional comments' }, +}); + +const selectRepliedToAccountIds = createSelector( + [ + (state) => state.get('statuses'), + (_, statusIds) => statusIds, + ], + (statusesMap, statusIds) => statusIds.map((statusId) => statusesMap.getIn([statusId, 'in_reply_to_account_id'])), + { + resultEqualityCheck: shallowEqual, + } +); + +const Comment = ({ comment, domain, statusIds, isRemote, isSubmitting, selectedDomains, onSubmit, onChangeComment, onToggleDomain }) => { + const intl = useIntl(); + + const dispatch = useAppDispatch(); + const loadedRef = useRef(false); + + const handleClick = useCallback(() => onSubmit(), [onSubmit]); + const handleChange = useCallback((e) => onChangeComment(e.target.value), [onChangeComment]); + const handleToggleDomain = useCallback(e => onToggleDomain(e.target.value, e.target.checked), [onToggleDomain]); + + const handleKeyDown = useCallback((e) => { + if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + handleClick(); + } + }, [handleClick]); + + // Memoize accountIds since we don't want it to trigger `useEffect` on each render + const accountIds = useAppSelector((state) => domain ? selectRepliedToAccountIds(state, statusIds) : ImmutableList()); + + // While we could memoize `availableDomains`, it is pretty inexpensive to recompute + const accountsMap = useAppSelector((state) => state.get('accounts')); + const availableDomains = domain ? OrderedSet([domain]).union(accountIds.map((accountId) => accountsMap.getIn([accountId, 'acct'], '').split('@')[1]).filter(domain => !!domain)) : OrderedSet(); + + useEffect(() => { + if (loadedRef.current) { + return; + } + + loadedRef.current = true; + + // First, pre-select known domains + availableDomains.forEach((domain) => { + onToggleDomain(domain, true); + }); + + // Then, fetch missing replied-to accounts + const unknownAccounts = OrderedSet(accountIds.filter(accountId => accountId && !accountsMap.has(accountId))); + unknownAccounts.forEach((accountId) => { + dispatch(fetchAccount(accountId)); + }); + }); + + return ( + <> + <h3 className='report-dialog-modal__title'><FormattedMessage id='report.comment.title' defaultMessage='Is there anything else you think we should know?' /></h3> + + <textarea + className='report-dialog-modal__textarea' + placeholder={intl.formatMessage(messages.placeholder)} + value={comment} + onChange={handleChange} + onKeyDown={handleKeyDown} + disabled={isSubmitting} + /> + + {isRemote && ( + <> + <p className='report-dialog-modal__lead'><FormattedMessage id='report.forward_hint' defaultMessage='The account is from another server. Send an anonymized copy of the report there as well?' /></p> + + { availableDomains.map((domain) => ( + <label className='report-dialog-modal__toggle' key={`toggle-${domain}`}> + <Toggle checked={selectedDomains.includes(domain)} disabled={isSubmitting} onChange={handleToggleDomain} value={domain} /> + <FormattedMessage id='report.forward' defaultMessage='Forward to {target}' values={{ target: domain }} /> + </label> + ))} + </> + )} + + <div className='flex-spacer' /> + + <div className='report-dialog-modal__actions'> + <Button onClick={handleClick} disabled={isSubmitting}><FormattedMessage id='report.submit' defaultMessage='Submit report' /></Button> + </div> + </> + ); +}; + +Comment.propTypes = { + comment: PropTypes.string.isRequired, + domain: PropTypes.string, + statusIds: ImmutablePropTypes.list.isRequired, + isRemote: PropTypes.bool, + isSubmitting: PropTypes.bool, + selectedDomains: ImmutablePropTypes.set.isRequired, + onSubmit: PropTypes.func.isRequired, + onChangeComment: PropTypes.func.isRequired, + onToggleDomain: PropTypes.func.isRequired, +}; + +export default Comment; diff --git a/app/javascript/flavours/blobfox/features/report/components/option.jsx b/app/javascript/flavours/blobfox/features/report/components/option.jsx new file mode 100644 index 00000000000000..873fb0d8274892 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/report/components/option.jsx @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import classNames from 'classnames'; + +import { Check } from 'flavours/blobfox/components/check'; + +export default class Option extends 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 ( + <label className='dialog-option poll__option selectable'> + <input type={multiple ? 'checkbox' : 'radio'} name={name} value={value} checked={checked} onChange={this.handleChange} /> + + <span + className={classNames('poll__input', { active: checked, checkbox: multiple })} + tabIndex={0} + role='radio' + onKeyPress={this.handleKeyPress} + aria-checked={checked} + aria-label={label} + >{checked && <Check />}</span> + + {labelComponent ? labelComponent : ( + <span className='poll__option__text'> + <strong>{label}</strong> + {description} + </span> + )} + </label> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/report/components/status_check_box.jsx b/app/javascript/flavours/blobfox/features/report/components/status_check_box.jsx new file mode 100644 index 00000000000000..d7c5cd2c1e3299 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/report/components/status_check_box.jsx @@ -0,0 +1,67 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + +import { Avatar } from 'flavours/blobfox/components/avatar'; +import { DisplayName } from 'flavours/blobfox/components/display_name'; +import MediaAttachments from 'flavours/blobfox/components/media_attachments'; +import { RelativeTimestamp } from 'flavours/blobfox/components/relative_timestamp'; +import StatusContent from 'flavours/blobfox/components/status_content'; +import VisibilityIcon from 'flavours/blobfox/components/status_visibility_icon'; + +import Option from './option'; + +class StatusCheckBox extends PureComponent { + + static propTypes = { + id: PropTypes.string.isRequired, + status: ImmutablePropTypes.map.isRequired, + checked: PropTypes.bool, + onToggle: PropTypes.func.isRequired, + }; + + handleStatusesToggle = (value, checked) => { + const { onToggle } = this.props; + onToggle(value, checked); + }; + + render () { + const { status, checked } = this.props; + + if (status.get('reblog')) { + return null; + } + + const labelComponent = ( + <div className='status-check-box__status poll__option__text'> + <div className='detailed-status__display-name'> + <div className='detailed-status__display-avatar'> + <Avatar account={status.get('account')} size={46} /> + </div> + + <div> + <DisplayName account={status.get('account')} /> · <VisibilityIcon visibility={status.get('visibility')} /><RelativeTimestamp timestamp={status.get('created_at')} /> + </div> + </div> + + <StatusContent status={status} media={<MediaAttachments status={status} visible={false} />} /> + </div> + ); + + return ( + <Option + name='status_ids' + value={status.get('id')} + checked={checked} + onToggle={this.handleStatusesToggle} + label={status.get('search_index')} + labelComponent={labelComponent} + multiple + /> + ); + } + +} + +export default StatusCheckBox; diff --git a/app/javascript/flavours/blobfox/features/report/containers/status_check_box_container.js b/app/javascript/flavours/blobfox/features/report/containers/status_check_box_container.js new file mode 100644 index 00000000000000..123b6f127a6ea4 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/report/containers/status_check_box_container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; + +import { makeGetStatus } from 'flavours/blobfox/selectors'; + +import StatusCheckBox from '../components/status_check_box'; + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, { id }) => ({ + status: getStatus(state, { id }), + }); + + return mapStateToProps; +}; + +export default connect(makeMapStateToProps)(StatusCheckBox); diff --git a/app/javascript/flavours/blobfox/features/report/rules.jsx b/app/javascript/flavours/blobfox/features/report/rules.jsx new file mode 100644 index 00000000000000..ffb0e29e7feb83 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/report/rules.jsx @@ -0,0 +1,69 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +import { Button } from 'flavours/blobfox/components/button'; + +import Option from './components/option'; + +const mapStateToProps = state => ({ + rules: state.getIn(['server', 'server', 'rules']), +}); + +class Rules extends 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 ( + <> + <h3 className='report-dialog-modal__title'><FormattedMessage id='report.rules.title' defaultMessage='Which rules are being violated?' /></h3> + <p className='report-dialog-modal__lead'><FormattedMessage id='report.rules.subtitle' defaultMessage='Select all that apply' /></p> + + <div> + {rules.map(item => ( + <Option + key={item.get('id')} + name='rule_ids' + value={item.get('id')} + checked={selectedRuleIds.includes(item.get('id'))} + onToggle={this.handleRulesToggle} + label={item.get('text')} + multiple + /> + ))} + </div> + + <div className='flex-spacer' /> + + <div className='report-dialog-modal__actions'> + <Button onClick={this.handleNextClick} disabled={selectedRuleIds.size < 1}><FormattedMessage id='report.next' defaultMessage='Next' /></Button> + </div> + </> + ); + } + +} + +export default connect(mapStateToProps)(Rules); diff --git a/app/javascript/flavours/blobfox/features/report/statuses.jsx b/app/javascript/flavours/blobfox/features/report/statuses.jsx new file mode 100644 index 00000000000000..5bcf1f795c373a --- /dev/null +++ b/app/javascript/flavours/blobfox/features/report/statuses.jsx @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { OrderedSet } from 'immutable'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +import { Button } from 'flavours/blobfox/components/button'; +import { LoadingIndicator } from 'flavours/blobfox/components/loading_indicator'; +import StatusCheckBox from 'flavours/blobfox/features/report/containers/status_check_box_container'; + +const mapStateToProps = (state, { accountId }) => ({ + availableStatusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}:with_replies`, 'items'])), + isLoading: state.getIn(['timelines', `account:${accountId}:with_replies`, 'isLoading']), +}); + +class Statuses extends 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 ( + <> + <h3 className='report-dialog-modal__title'><FormattedMessage id='report.statuses.title' defaultMessage='Are there any posts that back up this report?' /></h3> + <p className='report-dialog-modal__lead'><FormattedMessage id='report.statuses.subtitle' defaultMessage='Select all that apply' /></p> + + <div className='report-dialog-modal__statuses'> + {isLoading ? <LoadingIndicator /> : availableStatusIds.union(selectedStatusIds).map(statusId => ( + <StatusCheckBox + id={statusId} + key={statusId} + checked={selectedStatusIds.includes(statusId)} + onToggle={onToggle} + /> + ))} + </div> + + <div className='flex-spacer' /> + + <div className='report-dialog-modal__actions'> + <Button onClick={this.handleNextClick}><FormattedMessage id='report.next' defaultMessage='Next' /></Button> + </div> + </> + ); + } + +} + +export default connect(mapStateToProps)(Statuses); diff --git a/app/javascript/flavours/blobfox/features/report/thanks.jsx b/app/javascript/flavours/blobfox/features/report/thanks.jsx new file mode 100644 index 00000000000000..aee3e9980134fa --- /dev/null +++ b/app/javascript/flavours/blobfox/features/report/thanks.jsx @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +import { + unfollowAccount, + muteAccount, + blockAccount, +} from 'flavours/blobfox/actions/accounts'; +import { Button } from 'flavours/blobfox/components/button'; + +const mapStateToProps = () => ({}); + +class Thanks extends PureComponent { + + static propTypes = { + submitted: PropTypes.bool, + onClose: PropTypes.func.isRequired, + account: ImmutablePropTypes.record.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 ( + <> + <h3 className='report-dialog-modal__title'>{submitted ? <FormattedMessage id='report.thanks.title_actionable' defaultMessage="Thanks for reporting, we'll look into this." /> : <FormattedMessage id='report.thanks.title' defaultMessage="Don't want to see this?" />}</h3> + <p className='report-dialog-modal__lead'>{submitted ? <FormattedMessage id='report.thanks.take_action_actionable' defaultMessage='While we review this, you can take action against @{name}:' values={{ name: account.get('username') }} /> : <FormattedMessage id='report.thanks.take_action' defaultMessage='Here are your options for controlling what you see on Mastodon:' />}</p> + + {account.getIn(['relationship', 'following']) && ( + <> + <h4 className='report-dialog-modal__subtitle'><FormattedMessage id='report.unfollow' defaultMessage='Unfollow @{name}' values={{ name: account.get('username') }} /></h4> + <p className='report-dialog-modal__lead'><FormattedMessage id='report.unfollow_explanation' defaultMessage='You are following this account. To not see their posts in your home feed anymore, unfollow them.' /></p> + <Button secondary onClick={this.handleUnfollowClick}><FormattedMessage id='account.unfollow' defaultMessage='Unfollow' /></Button> + <hr /> + </> + )} + + <h4 className='report-dialog-modal__subtitle'><FormattedMessage id='account.mute' defaultMessage='Mute @{name}' values={{ name: account.get('username') }} /></h4> + <p className='report-dialog-modal__lead'><FormattedMessage id='report.mute_explanation' defaultMessage='You will not see their posts. They can still follow you and see your posts and will not know that they are muted.' /></p> + <Button secondary onClick={this.handleMuteClick}>{!account.getIn(['relationship', 'muting']) ? <FormattedMessage id='report.mute' defaultMessage='Mute' /> : <FormattedMessage id='account.muted' defaultMessage='Muted' />}</Button> + + <hr /> + + <h4 className='report-dialog-modal__subtitle'><FormattedMessage id='account.block' defaultMessage='Block @{name}' values={{ name: account.get('username') }} /></h4> + <p className='report-dialog-modal__lead'><FormattedMessage id='report.block_explanation' defaultMessage='You will not see their posts. They will not be able to see your posts or follow you. They will be able to tell that they are blocked.' /></p> + <Button secondary onClick={this.handleBlockClick}>{!account.getIn(['relationship', 'blocking']) ? <FormattedMessage id='report.block' defaultMessage='Block' /> : <FormattedMessage id='account.blocked' defaultMessage='Blocked' />}</Button> + + <div className='flex-spacer' /> + + <div className='report-dialog-modal__actions'> + <Button onClick={this.handleCloseClick}><FormattedMessage id='report.close' defaultMessage='Done' /></Button> + </div> + </> + ); + } + +} + +export default connect(mapStateToProps)(Thanks); diff --git a/app/javascript/flavours/blobfox/features/standalone/compose/index.jsx b/app/javascript/flavours/blobfox/features/standalone/compose/index.jsx new file mode 100644 index 00000000000000..c36e843f5ada65 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/standalone/compose/index.jsx @@ -0,0 +1,21 @@ +import { PureComponent } from 'react'; + +import ComposeFormContainer from '../../compose/containers/compose_form_container'; +import LoadingBarContainer from '../../ui/containers/loading_bar_container'; +import ModalContainer from '../../ui/containers/modal_container'; +import NotificationsContainer from '../../ui/containers/notifications_container'; + +export default class Compose extends PureComponent { + + render () { + return ( + <div> + <ComposeFormContainer autoFocus /> + <NotificationsContainer /> + <ModalContainer /> + <LoadingBarContainer className='loading-bar' /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/status/components/action_bar.jsx b/app/javascript/flavours/blobfox/features/status/components/action_bar.jsx new file mode 100644 index 00000000000000..a71eea35e318ef --- /dev/null +++ b/app/javascript/flavours/blobfox/features/status/components/action_bar.jsx @@ -0,0 +1,267 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import classNames from 'classnames'; +import { withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + +import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/blobfox/permissions'; +import { accountAdminLink, statusAdminLink } from 'flavours/blobfox/utils/backend_links'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +import { IconButton } from '../../../components/icon_button'; +import DropdownMenuContainer from '../../../containers/dropdown_menu_container'; +import { me, maxReactions } from '../../../initial_state'; +import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container'; + +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: 'Privately mention @{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: 'Favorite' }, + react: { id: 'status.react', defaultMessage: 'React' }, + 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 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' }, + openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' }, +}); + +class ActionBar extends PureComponent { + + static contextTypes = { + identity: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + onReply: PropTypes.func.isRequired, + onReblog: PropTypes.func.isRequired, + onFavourite: PropTypes.func.isRequired, + onReactionAdd: 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, + onBlock: PropTypes.func, + onMuteConversation: PropTypes.func, + onReport: PropTypes.func, + onPin: PropTypes.func, + onEmbed: PropTypes.func, + intl: PropTypes.object.isRequired, + ...WithRouterPropTypes, + }; + + handleReplyClick = () => { + this.props.onReply(this.props.status); + }; + + handleReblogClick = (e) => { + this.props.onReblog(this.props.status, e); + }; + + handleFavouriteClick = (e) => { + this.props.onFavourite(this.props.status, e); + }; + + handleEmojiPick = data => { + this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, ''), data.imageUrl); + }; + + handleBookmarkClick = (e) => { + this.props.onBookmark(this.props.status, e); + }; + + handleDeleteClick = () => { + this.props.onDelete(this.props.status, this.props.history); + }; + + handleRedraftClick = () => { + this.props.onDelete(this.props.status, this.props.history, true); + }; + + handleEditClick = () => { + this.props.onEdit(this.props.status, this.props.history); + }; + + handleDirectClick = () => { + this.props.onDirect(this.props.status.get('account'), this.props.history); + }; + + handleMentionClick = () => { + this.props.onMention(this.props.status.get('account'), this.props.history); + }; + + handleMuteClick = () => { + this.props.onMute(this.props.status.get('account')); + }; + + handleBlockClick = () => { + this.props.onBlock(this.props.status); + }; + + handleConversationMuteClick = () => { + this.props.onMuteConversation(this.props.status); + }; + + handleReport = () => { + this.props.onReport(this.props.status); + }; + + handlePinClick = () => { + this.props.onPin(this.props.status); + }; + + handleShare = () => { + navigator.share({ + 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); + }; + + handleNoOp = () => {}; // hack for reaction add button + + render () { + const { status, 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 writtenByMe = status.getIn(['account', 'id']) === me; + const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']); + + let menu = []; + + 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 && 'share' in navigator) { + menu.push({ text: intl.formatMessage(messages.share), action: this.handleShare }); + } + + if (publicStatus && (signedIn || !isRemote)) { + menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); + } + + if (signedIn) { + 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, dangerous: true }); + menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick, dangerous: true }); + } else { + menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); + menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick }); + menu.push(null); + menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick, dangerous: true }); + menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick, dangerous: true }); + menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport, dangerous: true }); + if (((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && (accountAdminLink || statusAdminLink)) || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) { + menu.push(null); + if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) { + if (accountAdminLink !== undefined) { + menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: accountAdminLink(status.getIn(['account', 'id'])) }); + } + if (statusAdminLink !== undefined) { + menu.push({ text: intl.formatMessage(messages.admin_status), href: statusAdminLink(status.getIn(['account', 'id']), status.get('id')) }); + } + } + if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) { + const domain = status.getIn(['account', 'acct']).split('@')[1]; + menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` }); + } + } + } + } + + const canReact = signedIn && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions; + const reactButton = ( + <IconButton + className='plus-icon' + onClick={this.handleNoOp} // EmojiPickerDropdown handles that + title={intl.formatMessage(messages.react)} + disabled={!canReact} + icon='plus' + /> + ); + + 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 ( + <div className='detailed-status__action-bar'> + <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_id', null) === null ? 'reply' : 'reply-all'} onClick={this.handleReplyClick} /></div> + <div className='detailed-status__button'><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div> + <div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div> + <div className='detailed-status__button'> + { + signedIn + ? <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={reactButton} disabled={!canReact} /> + : reactButton + } + </div> + <div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div> + + <div className='detailed-status__action-bar-dropdown'> + <DropdownMenuContainer size={18} icon='ellipsis-h' items={menu} direction='left' title={intl.formatMessage(messages.more)} /> + </div> + </div> + ); + } + +} + +export default withRouter(injectIntl(ActionBar)); diff --git a/app/javascript/flavours/blobfox/features/status/components/card.jsx b/app/javascript/flavours/blobfox/features/status/components/card.jsx new file mode 100644 index 00000000000000..51e548a1584b24 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/status/components/card.jsx @@ -0,0 +1,256 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import Immutable from 'immutable'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +import { Blurhash } from 'flavours/blobfox/components/blurhash'; +import { Icon } from 'flavours/blobfox/components/icon'; +import { useBlurhash } from 'flavours/blobfox/initial_state'; +import { decode as decodeIDNA } from 'flavours/blobfox/utils/idna'; + +const getHostname = url => { + const parser = document.createElement('a'); + parser.href = url; + return parser.hostname; +}; + +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 PureComponent { + + static propTypes = { + card: ImmutablePropTypes.map, + onOpenMedia: PropTypes.func.isRequired, + compact: PropTypes.bool, + sensitive: PropTypes.bool, + }; + + static defaultProps = { + compact: false, + }; + + state = { + previewLoaded: false, + embedded: false, + revealed: !this.props.sensitive, + }; + + UNSAFE_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); + } + + 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; + }; + + 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')) }; + + return ( + <div + ref={this.setRef} + className='status-card__image status-card-video' + dangerouslySetInnerHTML={content} + style={{ aspectRatio: `${card.get('width')} / ${card.get('height')}` }} + /> + ); + } + + render () { + const { card, compact } = this.props; + const { 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('type') !== 'link' || embedded; + const interactive = card.get('type') !== 'link'; + const className = classNames('status-card', { horizontal, compact, interactive }); + const title = interactive ? <a className='status-card__title' href={card.get('url')} title={card.get('title')} rel='noopener noreferrer' target='_blank'><strong>{card.get('title')}</strong></a> : <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>; + const language = card.get('language') || ''; + + const description = ( + <div className='status-card__content' lang={language}> + {title} + {!(horizontal || compact) && <p className='status-card__description' title={card.get('description')}>{card.get('description')}</p>} + <span className='status-card__host'>{provider}</span> + </div> + ); + + const thumbnailStyle = { + visibility: revealed? null : 'hidden', + }; + + if (horizontal) { + thumbnailStyle.aspectRatio = (compact && !embedded) ? '16 / 9' : `${card.get('width')} / ${card.get('height')}`; + } + + let embed = ''; + let canvas = ( + <Blurhash + className={classNames('status-card__image-preview', { + 'status-card__image-preview--hidden': revealed && this.state.previewLoaded, + })} + hash={card.get('blurhash')} + dummy={!useBlurhash} + /> + ); + + const thumbnailDescription = card.get('image_description'); + const thumbnail = <img src={card.get('image')} alt={thumbnailDescription} title={thumbnailDescription} lang={language} style={thumbnailStyle} onLoad={this.handleImageLoad} className='status-card__image-image' />; + + let spoilerButton = ( + <button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'> + <span className='spoiler-button__overlay__label'> + <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> + <span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span> + </span> + </button> + ); + + spoilerButton = ( + <div className={classNames('spoiler-button', { 'spoiler-button--minified': revealed })}> + {spoilerButton} + </div> + ); + + if (interactive) { + if (embedded) { + embed = this.renderVideo(); + } else { + let iconVariant = 'play'; + + if (card.get('type') === 'photo') { + iconVariant = 'search-plus'; + } + + embed = ( + <div className='status-card__image'> + {canvas} + {thumbnail} + + {revealed ? ( + <div className='status-card__actions'> + <div> + <button type='button' onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button> + {horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>} + </div> + </div> + ) : spoilerButton} + </div> + ); + } + + return ( + <div className={className} ref={this.setRef} onClick={revealed ? null : this.handleReveal} role={revealed ? 'button' : null}> + {embed} + {!compact && description} + </div> + ); + } else if (card.get('image')) { + embed = ( + <div className='status-card__image'> + {canvas} + {thumbnail} + </div> + ); + } else { + embed = ( + <div className='status-card__image'> + <Icon id='file-text' /> + </div> + ); + } + + return ( + <a href={card.get('url')} className={className} target='_blank' rel='noopener noreferrer' ref={this.setRef}> + {embed} + {description} + </a> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/status/components/detailed_status.jsx b/app/javascript/flavours/blobfox/features/status/components/detailed_status.jsx new file mode 100644 index 00000000000000..2b4c96bc83ce97 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/status/components/detailed_status.jsx @@ -0,0 +1,362 @@ +import PropTypes from 'prop-types'; + +import { injectIntl, FormattedDate } from 'react-intl'; + +import classNames from 'classnames'; +import { Link, withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { AnimatedNumber } from 'flavours/blobfox/components/animated_number'; +import AttachmentList from 'flavours/blobfox/components/attachment_list'; +import EditedTimestamp from 'flavours/blobfox/components/edited_timestamp'; +import { getHashtagBarForStatus } from 'flavours/blobfox/components/hashtag_bar'; +import { Icon } from 'flavours/blobfox/components/icon'; +import PictureInPicturePlaceholder from 'flavours/blobfox/components/picture_in_picture_placeholder'; +import VisibilityIcon from 'flavours/blobfox/components/status_visibility_icon'; +import PollContainer from 'flavours/blobfox/containers/poll_container'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +import { Avatar } from '../../../components/avatar'; +import { DisplayName } from '../../../components/display_name'; +import MediaGallery from '../../../components/media_gallery'; +import StatusContent from '../../../components/status_content'; +import StatusReactions from '../../../components/status_reactions'; +import Audio from '../../audio'; +import scheduleIdleTask from '../../ui/util/schedule_idle_task'; +import Video from '../../video'; + +import Card from './card'; + +class DetailedStatus extends ImmutablePureComponent { + + static contextTypes = { + identity: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map, + settings: ImmutablePropTypes.map.isRequired, + onOpenMedia: PropTypes.func.isRequired, + onOpenVideo: PropTypes.func.isRequired, + onToggleHidden: PropTypes.func, + onTranslate: PropTypes.func.isRequired, + expanded: PropTypes.bool, + 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, + onReactionAdd: PropTypes.func.isRequired, + onReactionRemove: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + ...WithRouterPropTypes, + }; + + state = { + height: null, + }; + + handleAccountClick = (e) => { + if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey) && this.props.history) { + e.preventDefault(); + this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`); + } + + e.stopPropagation(); + }; + + parseClick = (e, destination) => { + if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey) && this.props.history) { + e.preventDefault(); + this.props.history.push(destination); + } + + e.stopPropagation(); + }; + + handleOpenVideo = (options) => { + this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), options); + }; + + _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); + } + + handleChildUpdate = () => { + this._measureHeight(); + }; + + 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 { compact, pictureInPicture, expanded, onToggleHidden, settings } = this.props; + + if (!status) { + return null; + } + + let applicationLink = ''; + let reblogLink = ''; + let reblogIcon = 'retweet'; + let favouriteLink = ''; + let edited = ''; + + // Depending on user settings, some media are considered as parts of the + // contents (affected by CW) while other will be displayed outside of the + // CW. + let contentMedia = []; + let contentMediaIcons = []; + let extraMedia = []; + let extraMediaIcons = []; + let media = contentMedia; + let mediaIcons = contentMediaIcons; + + if (settings.getIn(['content_warnings', 'media_outside'])) { + media = extraMedia; + mediaIcons = extraMediaIcons; + } + + if (this.props.measureHeight) { + outerStyle.height = `${this.state.height}px`; + } + + const language = status.getIn(['translation', 'language']) || status.get('language'); + + if (pictureInPicture.get('inUse')) { + media.push(<PictureInPicturePlaceholder />); + mediaIcons.push('video-camera'); + } else if (status.get('media_attachments').size > 0) { + if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { + media.push(<AttachmentList media={status.get('media_attachments')} />); + } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + const attachment = status.getIn(['media_attachments', 0]); + const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); + + media.push( + <Audio + src={attachment.get('url')} + alt={description} + lang={language} + duration={attachment.getIn(['meta', 'original', 'duration'], 0)} + poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])} + backgroundColor={attachment.getIn(['meta', 'colors', 'background'])} + foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])} + accentColor={attachment.getIn(['meta', 'colors', 'accent'])} + sensitive={status.get('sensitive')} + visible={this.props.showMedia} + blurhash={attachment.get('blurhash')} + height={150} + onToggleVisibility={this.props.onToggleMediaVisibility} + />, + ); + mediaIcons.push('music'); + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + const attachment = status.getIn(['media_attachments', 0]); + const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); + + media.push( + <Video + preview={attachment.get('preview_url')} + frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])} + blurhash={attachment.get('blurhash')} + src={attachment.get('url')} + alt={description} + lang={language} + inline + sensitive={status.get('sensitive')} + letterbox={settings.getIn(['media', 'letterbox'])} + fullwidth={settings.getIn(['media', 'fullwidth'])} + preventPlayback={!expanded} + onOpenVideo={this.handleOpenVideo} + autoplay + visible={this.props.showMedia} + onToggleVisibility={this.props.onToggleMediaVisibility} + />, + ); + mediaIcons.push('video-camera'); + } else { + media.push( + <MediaGallery + standalone + sensitive={status.get('sensitive')} + media={status.get('media_attachments')} + lang={language} + letterbox={settings.getIn(['media', 'letterbox'])} + fullwidth={settings.getIn(['media', 'fullwidth'])} + hidden={!expanded} + onOpenMedia={this.props.onOpenMedia} + visible={this.props.showMedia} + onToggleVisibility={this.props.onToggleMediaVisibility} + />, + ); + mediaIcons.push('picture-o'); + } + } else if (status.get('card')) { + media.push(<Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card')} />); + mediaIcons.push('link'); + } + + if (status.get('poll')) { + contentMedia.push(<PollContainer pollId={status.get('poll')} lang={status.get('language')} />); + contentMediaIcons.push('tasks'); + } + + if (status.get('application')) { + applicationLink = <> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></>; + } + + const visibilityLink = <> · <VisibilityIcon visibility={status.get('visibility')} /></>; + + if (status.get('visibility') === 'direct') { + reblogIcon = 'envelope'; + } else if (status.get('visibility') === 'private') { + reblogIcon = 'lock'; + } + + if (!['unlisted', 'public'].includes(status.get('visibility'))) { + reblogLink = null; + } else if (this.props.history) { + reblogLink = ( + <> + {' · '} + <Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/reblogs`} className='detailed-status__link'> + <Icon id={reblogIcon} /> + <span className='detailed-status__reblogs'> + <AnimatedNumber value={status.get('reblogs_count')} /> + </span> + </Link> + </> + ); + } else { + reblogLink = ( + <> + {' · '} + <a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}> + <Icon id={reblogIcon} /> + <span className='detailed-status__reblogs'> + <AnimatedNumber value={status.get('reblogs_count')} /> + </span> + </a> + </> + ); + } + + if (this.props.history) { + favouriteLink = ( + <Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/favourites`} className='detailed-status__link'> + <Icon id='star' /> + <span className='detailed-status__favorites'> + <AnimatedNumber value={status.get('favourites_count')} /> + </span> + </Link> + ); + } else { + favouriteLink = ( + <a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}> + <Icon id='star' /> + <span className='detailed-status__favorites'> + <AnimatedNumber value={status.get('favourites_count')} /> + </span> + </a> + ); + } + + if (status.get('edited_at')) { + edited = ( + <> + {' · '} + <EditedTimestamp statusId={status.get('id')} timestamp={status.get('edited_at')} /> + </> + ); + } + + const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); + contentMedia.push(hashtagBar); + + return ( + <div style={outerStyle}> + <div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })} data-status-by={status.getIn(['account', 'acct'])}> + <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'> + <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div> + <DisplayName account={status.get('account')} localDomain={this.props.domain} /> + </a> + + <StatusContent + status={status} + media={contentMedia} + extraMedia={extraMedia} + mediaIcons={contentMediaIcons} + expanded={expanded} + collapsed={false} + onExpandedToggle={onToggleHidden} + onTranslate={this.handleTranslate} + parseClick={this.parseClick} + onUpdate={this.handleChildUpdate} + tagLinks={settings.get('tag_misleading_links')} + rewriteMentions={settings.get('rewrite_mentions')} + disabled + {...statusContentProps} + /> + + <StatusReactions + statusId={status.get('id')} + reactions={status.get('reactions')} + addReaction={this.props.onReactionAdd} + removeReaction={this.props.onReactionRemove} + canReact={this.context.identity.signedIn} + /> + + <div className='detailed-status__meta'> + <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'> + <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /> + </a>{edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink} + </div> + </div> + </div> + ); + } + +} + +export default withRouter(injectIntl(DetailedStatus)); diff --git a/app/javascript/flavours/blobfox/features/status/containers/detailed_status_container.js b/app/javascript/flavours/blobfox/features/status/containers/detailed_status_container.js new file mode 100644 index 00000000000000..40803441d201d2 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/status/containers/detailed_status_container.js @@ -0,0 +1,176 @@ +import { defineMessages, injectIntl } from 'react-intl'; + +import { connect } from 'react-redux'; + +import { showAlertForError } from '../../../actions/alerts'; +import { initBlockModal } from '../../../actions/blocks'; +import { initBoostModal } from '../../../actions/boosts'; +import { + replyCompose, + mentionCompose, + directCompose, +} from '../../../actions/compose'; +import { + reblog, + favourite, + unreblog, + unfavourite, + pin, + unpin, +} from '../../../actions/interactions'; +import { openModal } from '../../../actions/modal'; +import { initMuteModal } from '../../../actions/mutes'; +import { initReport } from '../../../actions/reports'; +import { + muteStatus, + unmuteStatus, + deleteStatus, +} from '../../../actions/statuses'; +import { boostModal, deleteModal } from '../../../initial_state'; +import { makeGetStatus } from '../../../selectors'; +import DetailedStatus from '../components/detailed_status'; + +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? Favorites 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?' }, +}); + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, props) => ({ + status: getStatus(state, props), + domain: state.getIn(['meta', 'domain']), + settings: state.get('local_settings'), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onReply (status, router) { + dispatch((_, getState) => { + let state = getState(); + if (state.getIn(['compose', 'text']).trim().length !== 0) { + dispatch(openModal({ + modalType: 'CONFIRM', + modalProps: { + message: intl.formatMessage(messages.replyMessage), + confirm: intl.formatMessage(messages.replyConfirm), + onConfirm: () => dispatch(replyCompose(status, router)), + }, + })); + } else { + dispatch(replyCompose(status, router)); + } + }); + }, + + onModalReblog (status, privacy) { + dispatch(reblog(status, privacy)); + }, + + onReblog (status, e) { + if (status.get('reblogged')) { + dispatch(unreblog(status)); + } else { + if (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)); + } + }, + + onPin (status) { + if (status.get('pinned')) { + dispatch(unpin(status)); + } else { + dispatch(pin(status)); + } + }, + + onEmbed (status) { + dispatch(openModal({ + modalType: 'EMBED', + modalProps: { + id: status.get('id'), + onError: error => dispatch(showAlertForError(error)), + }, + })); + }, + + onDelete (status, history, withRedraft = false) { + if (!deleteModal) { + dispatch(deleteStatus(status.get('id'), history, withRedraft)); + } else { + dispatch(openModal({ + modalType: 'CONFIRM', + modalProps: { + message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage), + confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm), + onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)), + }, + })); + } + }, + + onDirect (account, router) { + dispatch(directCompose(account, router)); + }, + + onMention (account, router) { + dispatch(mentionCompose(account, router)); + }, + + onOpenMedia (media, index, lang) { + dispatch(openModal({ + modalType: 'MEDIA', + modalProps: { media, index, lang }, + })); + }, + + onOpenVideo (media, lang, options) { + dispatch(openModal({ + modalType: 'VIDEO', + modalProps: { media, lang, options }, + })); + }, + + onBlock (status) { + const account = status.get('account'); + dispatch(initBlockModal(account)); + }, + + onReport (status) { + dispatch(initReport(status.get('account'), status)); + }, + + onMute (account) { + dispatch(initMuteModal(account)); + }, + + onMuteConversation (status) { + if (status.get('muted')) { + dispatch(unmuteStatus(status.get('id'))); + } else { + dispatch(muteStatus(status.get('id'))); + } + }, + +}); + +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus)); diff --git a/app/javascript/flavours/blobfox/features/status/index.jsx b/app/javascript/flavours/blobfox/features/status/index.jsx new file mode 100644 index 00000000000000..0e387e6f237179 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/status/index.jsx @@ -0,0 +1,807 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import classNames from 'classnames'; +import { Helmet } from 'react-helmet'; +import { withRouter } from 'react-router-dom'; + +import Immutable from 'immutable'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; + +import { HotKeys } from 'react-hotkeys'; + +import { Icon } from 'flavours/blobfox/components/icon'; +import { LoadingIndicator } from 'flavours/blobfox/components/loading_indicator'; +import ScrollContainer from 'flavours/blobfox/containers/scroll_container'; +import BundleColumnError from 'flavours/blobfox/features/ui/components/bundle_column_error'; +import { autoUnfoldCW } from 'flavours/blobfox/utils/content_warning'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +import { initBlockModal } from '../../actions/blocks'; +import { initBoostModal } from '../../actions/boosts'; +import { + replyCompose, + mentionCompose, + directCompose, +} from '../../actions/compose'; +import { + favourite, + unfavourite, + bookmark, + unbookmark, + reblog, + unreblog, + pin, + unpin, + addReaction, + removeReaction, +} from '../../actions/interactions'; +import { changeLocalSetting } from '../../actions/local_settings'; +import { openModal } from '../../actions/modal'; +import { initMuteModal } from '../../actions/mutes'; +import { initReport } from '../../actions/reports'; +import { + fetchStatus, + muteStatus, + unmuteStatus, + deleteStatus, + editStatus, + hideStatus, + revealStatus, + translateStatus, + undoStatusTranslation, +} from '../../actions/statuses'; +import ColumnHeader from '../../components/column_header'; +import { textForScreenReader, defaultMediaVisibility } from '../../components/status'; +import StatusContainer from '../../containers/status_container'; +import { boostModal, favouriteModal, deleteModal } from '../../initial_state'; +import { makeGetStatus, makeGetPictureInPicture } from '../../selectors'; +import Column from '../ui/components/column'; +import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen'; + +import ActionBar from './components/action_bar'; +import DetailedStatus from './components/detailed_status'; + + +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? Favorites 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' }, + statusTitleWithAttachments: { id: 'status.title.with_attachments', defaultMessage: '{user} posted {attachmentCount, plural, one {an attachment} other {# attachments}}' }, + 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?' }, + tootHeading: { id: 'account.posts_with_replies', defaultMessage: 'Posts and replies' }, +}); + +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, + settings: state.get('local_settings'), + askReplyConfirmation: state.getIn(['local_settings', 'confirm_before_clearing_draft']) && 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) => { + const arr = Array.from(str); + if (arr.length > num) { + return arr.slice(0, num).join('') + '…'; + } else { + return str; + } +}; + +const titleFromStatus = (intl, status) => { + const displayName = status.getIn(['account', 'display_name']); + const username = status.getIn(['account', 'username']); + const user = displayName.trim().length === 0 ? username : displayName; + const text = status.get('search_index'); + const attachmentCount = status.get('media_attachments').size; + + return text ? `${user}: "${truncate(text, 30)}"` : intl.formatMessage(messages.statusTitleWithAttachments, { user, attachmentCount }); +}; + +class Status extends ImmutablePureComponent { + + static contextTypes = { + identity: PropTypes.object, + }; + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + status: ImmutablePropTypes.map, + isLoading: PropTypes.bool, + settings: ImmutablePropTypes.map.isRequired, + ancestorsIds: ImmutablePropTypes.list.isRequired, + descendantsIds: ImmutablePropTypes.list.isRequired, + intl: PropTypes.object.isRequired, + askReplyConfirmation: PropTypes.bool, + multiColumn: PropTypes.bool, + domain: PropTypes.string.isRequired, + pictureInPicture: ImmutablePropTypes.contains({ + inUse: PropTypes.bool, + available: PropTypes.bool, + }), + ...WithRouterPropTypes + }; + + state = { + fullscreen: false, + isExpanded: undefined, + threadExpanded: undefined, + statusId: undefined, + loadedStatusId: undefined, + showMedia: undefined, + revealBehindCW: undefined, + }; + + componentDidMount () { + attachFullscreenListener(this.onFullScreenChange); + this.props.dispatch(fetchStatus(this.props.params.statusId)); + this._scrollStatusIntoView(); + } + + static getDerivedStateFromProps(props, state) { + let update = {}; + let updated = false; + + if (props.params.statusId && state.statusId !== props.params.statusId) { + props.dispatch(fetchStatus(props.params.statusId)); + update.threadExpanded = undefined; + update.statusId = props.params.statusId; + updated = true; + } + + const revealBehindCW = props.settings.getIn(['media', 'reveal_behind_cw']); + if (revealBehindCW !== state.revealBehindCW) { + update.revealBehindCW = revealBehindCW; + if (revealBehindCW) update.showMedia = defaultMediaVisibility(props.status, props.settings); + updated = true; + } + + if (props.status && state.loadedStatusId !== props.status.get('id')) { + update.showMedia = defaultMediaVisibility(props.status, props.settings); + update.loadedStatusId = props.status.get('id'); + update.isExpanded = autoUnfoldCW(props.settings, props.status); + updated = true; + } + + return updated ? update : null; + } + + handleToggleHidden = () => { + const { status } = this.props; + + if (this.props.settings.getIn(['content_warnings', 'shared_state'])) { + if (status.get('hidden')) { + this.props.dispatch(revealStatus(status.get('id'))); + } else { + this.props.dispatch(hideStatus(status.get('id'))); + } + } else if (this.props.status.get('spoiler_text')) { + this.setExpansion(!this.state.isExpanded); + } + }; + + handleToggleMediaVisibility = () => { + this.setState({ showMedia: !this.state.showMedia }); + }; + + handleModalFavourite = (status) => { + this.props.dispatch(favourite(status)); + }; + + handleFavouriteClick = (status, e) => { + const { dispatch } = this.props; + const { signedIn } = this.context.identity; + + if (signedIn) { + if (status.get('favourited')) { + dispatch(unfavourite(status)); + } else { + if ((e && e.shiftKey) || !favouriteModal) { + this.handleModalFavourite(status); + } else { + dispatch(openModal({ + modalType: 'FAVOURITE', + modalProps: { + status, + onFavourite: this.handleModalFavourite, + }, + })); + } + } + } else { + dispatch(openModal({ + modalType: 'INTERACTION', + modalProps: { + type: 'favourite', + accountId: status.getIn(['account', 'id']), + url: status.get('uri'), + }, + })); + } + }; + + handleReactionAdd = (statusId, name, url) => { + const { dispatch } = this.props; + const { signedIn } = this.context.identity; + + if (signedIn) { + dispatch(addReaction(statusId, name, url)); + } + }; + + handleReactionRemove = (statusId, name) => { + this.props.dispatch(removeReaction(statusId, name)); + }; + + 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({ + modalType: 'CONFIRM', + modalProps: { + message: intl.formatMessage(messages.replyMessage), + confirm: intl.formatMessage(messages.replyConfirm), + onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_before_clearing_draft'], false)), + onConfirm: () => dispatch(replyCompose(status, this.props.history)), + }, + })); + } else { + dispatch(replyCompose(status, this.props.history)); + } + } else { + dispatch(openModal({ + modalType: 'INTERACTION', + modalProps: { + type: 'reply', + accountId: status.getIn(['account', 'id']), + url: status.get('uri'), + }, + })); + } + }; + + handleModalReblog = (status, privacy) => { + const { dispatch } = this.props; + + if (status.get('reblogged')) { + dispatch(unreblog(status)); + } else { + dispatch(reblog(status, privacy)); + } + }; + + handleReblogClick = (status, e) => { + const { settings, dispatch } = this.props; + const { signedIn } = this.context.identity; + + if (signedIn) { + if (settings.get('confirm_boost_missing_media_description') && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) { + dispatch(initBoostModal({ status, onReblog: this.handleModalReblog, missingMediaDescription: true })); + } else if ((e && e.shiftKey) || !boostModal) { + this.handleModalReblog(status); + } else { + dispatch(initBoostModal({ status, onReblog: this.handleModalReblog })); + } + } else { + dispatch(openModal({ + modalType: 'INTERACTION', + modalProps: { + type: 'reblog', + accountId: status.getIn(['account', 'id']), + url: status.get('uri'), + }, + })); + } + }; + + 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({ + modalType: 'CONFIRM', + modalProps: { + 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, history) => { + this.props.dispatch(directCompose(account, history)); + }; + + handleMentionClick = (account, history) => { + this.props.dispatch(mentionCompose(account, history)); + }; + + handleOpenMedia = (media, index, lang) => { + this.props.dispatch(openModal({ + modalType: 'MEDIA', + modalProps: { statusId: this.props.status.get('id'), media, index, lang }, + })); + }; + + handleOpenVideo = (media, lang, options) => { + this.props.dispatch(openModal({ + modalType: 'VIDEO', + modalProps: { statusId: this.props.status.get('id'), media, lang, 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'))); + } + }; + + handleToggleAll = () => { + const { status, ancestorsIds, descendantsIds, settings } = this.props; + const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS()); + let { isExpanded } = this.state; + + if (settings.getIn(['content_warnings', 'shared_state'])) + isExpanded = !status.get('hidden'); + + if (!isExpanded) { + this.props.dispatch(revealStatus(statusIds)); + } else { + this.props.dispatch(hideStatus(statusIds)); + } + + this.setState({ isExpanded: !isExpanded, threadExpanded: !isExpanded }); + }; + + handleTranslate = status => { + const { dispatch } = this.props; + + if (status.get('translation')) { + dispatch(undoStatusTranslation(status.get('id'), status.get('poll'))); + } 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({ + modalType: 'EMBED', + modalProps: { id: status.get('id') }, + })); + }; + + handleHotkeyToggleSensitive = () => { + this.handleToggleMediaVisibility(); + }; + + 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); + }; + + handleHotkeyBookmark = () => { + this.handleBookmarkClick(this.props.status); + }; + + handleHotkeyMention = e => { + e.preventDefault(); + this.handleMentionClick(this.props.status); + }; + + handleHotkeyOpenProfile = () => { + this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`); + }; + + 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(); + } + } + + handleHeaderClick = () => { + this.column.scrollTop(); + }; + + renderChildren (list, ancestors) { + const { params: { statusId } } = this.props; + + return list.map((id, i) => ( + <StatusContainer + key={id} + id={id} + expanded={this.state.threadExpanded} + onMoveUp={this.handleMoveUp} + onMoveDown={this.handleMoveDown} + contextType='thread' + previousId={i > 0 ? list.get(i - 1) : undefined} + nextId={list.get(i + 1) || (ancestors && statusId)} + rootId={statusId} + /> + )); + } + + setExpansion = value => { + this.setState({ isExpanded: value }); + }; + + setRef = c => { + this.node = c; + }; + + setColumnRef = c => { + this.column = c; + }; + + _scrollStatusIntoView () { + const { status, multiColumn } = this.props; + + if (status) { + window.requestAnimationFrame(() => { + this.node?.querySelector('.detailed-status__wrapper')?.scrollIntoView(true); + + // In the single-column interface, `scrollIntoView` will put the post behind the header, + // so compensate for that. + if (!multiColumn) { + const offset = document.querySelector('.column-header__wrapper')?.getBoundingClientRect()?.bottom; + if (offset) { + const scrollingElement = document.scrollingElement || document.body; + scrollingElement.scrollBy(0, -offset); + } + } + }); + } + } + + componentDidUpdate (prevProps) { + const { status, ancestorsIds } = this.props; + + if (status && (ancestorsIds.size > prevProps.ancestorsIds.size || prevProps.status?.get('id') !== status.get('id'))) { + this._scrollStatusIntoView(); + } + } + + componentWillUnmount () { + detachFullscreenListener(this.onFullScreenChange); + } + + onFullScreenChange = () => { + this.setState({ fullscreen: isFullscreen() }); + }; + + shouldUpdateScroll = (prevRouterProps, { location }) => { + // Do not change scroll when opening a modal + if (location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey) { + return false; + } + + // Scroll to focused post if it is loaded + const child = this.node?.querySelector('.detailed-status__wrapper'); + if (child) { + return [0, child.offsetTop]; + } + + // Do not scroll otherwise, `componentDidUpdate` will take care of that + return false; + }; + + render () { + let ancestors, descendants; + const { isLoading, status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props; + const { fullscreen } = this.state; + + if (isLoading) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + if (status === null) { + return ( + <BundleColumnError multiColumn={multiColumn} errorType='routing' /> + ); + } + + const isExpanded = settings.getIn(['content_warnings', 'shared_state']) ? !status.get('hidden') : this.state.isExpanded; + + if (ancestorsIds && ancestorsIds.size > 0) { + ancestors = <>{this.renderChildren(ancestorsIds, true)}</>; + } + + 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, + bookmark: this.handleHotkeyBookmark, + mention: this.handleHotkeyMention, + openProfile: this.handleHotkeyOpenProfile, + toggleHidden: this.handleToggleHidden, + toggleSensitive: this.handleHotkeyToggleSensitive, + openMedia: this.handleHotkeyOpenMedia, + }; + + return ( + <Column bindToDocument={!multiColumn} ref={this.setColumnRef} label={intl.formatMessage(messages.detailedStatus)}> + <ColumnHeader + icon='comment' + title={intl.formatMessage(messages.tootHeading)} + onClick={this.handleHeaderClick} + showBackButton + multiColumn={multiColumn} + extraButton={( + <button type='button' className='column-header__button' title={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll}><Icon id={!isExpanded ? 'eye-slash' : 'eye'} /></button> + )} + /> + + <ScrollContainer scrollKey='thread' shouldUpdateScroll={this.shouldUpdateScroll}> + <div className={classNames('scrollable', { fullscreen })} ref={this.setRef}> + {ancestors} + + <HotKeys handlers={handlers}> + <div className={classNames('focusable', 'detailed-status__wrapper', `detailed-status__wrapper-${status.get('visibility')}`)} tabIndex={0} aria-label={textForScreenReader(intl, status, false, isExpanded)}> + <DetailedStatus + key={`details-${status.get('id')}`} + status={status} + settings={settings} + onOpenVideo={this.handleOpenVideo} + onOpenMedia={this.handleOpenMedia} + onReactionAdd={this.handleReactionAdd} + onReactionRemove={this.handleReactionRemove} + expanded={isExpanded} + onToggleHidden={this.handleToggleHidden} + onTranslate={this.handleTranslate} + domain={domain} + showMedia={this.state.showMedia} + onToggleMediaVisibility={this.handleToggleMediaVisibility} + pictureInPicture={pictureInPicture} + /> + + <ActionBar + key={`action-bar-${status.get('id')}`} + status={status} + onReply={this.handleReplyClick} + onFavourite={this.handleFavouriteClick} + onReactionAdd={this.handleReactionAdd} + onReblog={this.handleReblogClick} + onBookmark={this.handleBookmarkClick} + onDelete={this.handleDeleteClick} + onEdit={this.handleEditClick} + onDirect={this.handleDirectClick} + onMention={this.handleMentionClick} + onMute={this.handleMuteClick} + onMuteConversation={this.handleConversationMuteClick} + onBlock={this.handleBlockClick} + onReport={this.handleReport} + onPin={this.handlePin} + onEmbed={this.handleEmbed} + /> + </div> + </HotKeys> + + {descendants} + </div> + </ScrollContainer> + + <Helmet> + <title>{titleFromStatus(intl, status)}</title> + <meta name='robots' content={(isLocal && isIndexable) ? 'all' : 'noindex'} /> + <link rel='canonical' href={status.get('url')} /> + </Helmet> + </Column> + ); + } + +} + +export default withRouter(injectIntl(connect(makeMapStateToProps)(Status))); diff --git a/app/javascript/flavours/blobfox/features/subscribed_languages_modal/index.jsx b/app/javascript/flavours/blobfox/features/subscribed_languages_modal/index.jsx new file mode 100644 index 00000000000000..31308c6ce822fa --- /dev/null +++ b/app/javascript/flavours/blobfox/features/subscribed_languages_modal/index.jsx @@ -0,0 +1,127 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; + +import { is, List as ImmutableList, Set as ImmutableSet } from 'immutable'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; + +import { followAccount } from 'flavours/blobfox/actions/accounts'; +import { Button } from 'flavours/blobfox/components/button'; +import { IconButton } from 'flavours/blobfox/components/icon_button'; +import Option from 'flavours/blobfox/features/report/components/option'; +import { languages as preloadedLanguages } from 'flavours/blobfox/initial_state'; + +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 })); + }, + +}); + +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 ( + <Option + key={value} + name='languages' + value={value} + label={language[1]} + checked={checked} + onToggle={this.handleLanguageToggle} + multiple + /> + ); + } + + render () { + const { acct, availableLanguages, selectedLanguages, intl, onClose } = this.props; + + return ( + <div className='modal-root__modal report-dialog-modal'> + <div className='report-modal__target'> + <IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} /> + <FormattedMessage id='subscribed_languages.target' defaultMessage='Change subscribed languages for {target}' values={{ target: <strong>{acct}</strong> }} /> + </div> + + <div className='report-dialog-modal__container'> + <p className='report-dialog-modal__lead'><FormattedMessage id='subscribed_languages.lead' defaultMessage='Only posts in selected languages will appear on your home and list timelines after the change. Select none to receive posts in all languages.' /></p> + + <div> + {availableLanguages.union(selectedLanguages).delete(null).map(value => this.renderItem(value))} + </div> + + <div className='flex-spacer' /> + + <div className='report-dialog-modal__actions'> + <Button disabled={is(this.state.selectedLanguages, this.props.selectedLanguages)} onClick={this.handleSubmit}><FormattedMessage id='subscribed_languages.save' defaultMessage='Save changes' /></Button> + </div> + </div> + </div> + ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SubscribedLanguagesModal)); diff --git a/app/javascript/flavours/blobfox/features/ui/components/actions_modal.jsx b/app/javascript/flavours/blobfox/features/ui/components/actions_modal.jsx new file mode 100644 index 00000000000000..6b58f9423b2c3a --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/actions_modal.jsx @@ -0,0 +1,94 @@ +import PropTypes from 'prop-types'; + +import classNames from 'classnames'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { Avatar } from 'flavours/blobfox/components/avatar'; +import { DisplayName } from 'flavours/blobfox/components/display_name'; +import { RelativeTimestamp } from 'flavours/blobfox/components/relative_timestamp'; +import StatusContent from 'flavours/blobfox/components/status_content'; + +import { IconButton } from '../../../components/icon_button'; + +export default class ActionsModal extends ImmutablePureComponent { + + static propTypes = { + status: ImmutablePropTypes.map, + onClick: PropTypes.func, + actions: PropTypes.arrayOf(PropTypes.shape({ + active: PropTypes.bool, + href: PropTypes.string, + icon: PropTypes.string, + meta: PropTypes.string, + name: PropTypes.string, + text: PropTypes.string, + })), + renderItemContents: PropTypes.func, + }; + + renderAction = (action, i) => { + if (action === null) { + return <li key={`sep-${i}`} className='dropdown-menu__separator' />; + } + + const { icon = null, text, meta = null, active = false, href = '#' } = action; + let contents = this.props.renderItemContents && this.props.renderItemContents(action, i); + + if (!contents) { + contents = ( + <> + {icon && <IconButton title={text} icon={icon} role='presentation' tabIndex={-1} inverted />} + <div> + <div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div> + <div>{meta}</div> + </div> + </> + ); + } + + return ( + <li key={`${text}-${i}`}> + <a href={href} target='_blank' rel='noopener noreferrer' onClick={this.props.onClick} data-index={i} className={classNames('link', { active })}> + {contents} + </a> + </li> + ); + }; + + render () { + const status = this.props.status && ( + <div className='status light'> + <div className='boost-modal__status-header'> + <div className='boost-modal__status-time'> + <a href={this.props.status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'> + <RelativeTimestamp timestamp={this.props.status.get('created_at')} /> + </a> + </div> + + <a href={this.props.status.getIn(['account', 'url'])} className='status__display-name' rel='noopener noreferrer'> + <div className='status__avatar'> + <Avatar account={this.props.status.get('account')} size={48} /> + </div> + + <DisplayName account={this.props.status.get('account')} /> + </a> + </div> + + <StatusContent status={this.props.status} /> + </div> + ); + + return ( + <div className='modal-root__modal actions-modal'> + {status} + + <ul className={classNames({ 'with-status': !!status })}> + {this.props.actions.map(this.renderAction)} + </ul> + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/ui/components/audio_modal.jsx b/app/javascript/flavours/blobfox/features/ui/components/audio_modal.jsx new file mode 100644 index 00000000000000..afdbade68ef987 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/audio_modal.jsx @@ -0,0 +1,61 @@ +import PropTypes from 'prop-types'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import Audio from 'flavours/blobfox/features/audio'; +import Footer from 'flavours/blobfox/features/picture_in_picture/components/footer'; + +const mapStateToProps = (state, { statusId }) => ({ + status: state.getIn(['statuses', statusId]), + accountStaticAvatar: state.getIn(['accounts', state.getIn(['statuses', statusId, 'account']), 'avatar_static']), +}); + +class AudioModal extends ImmutablePureComponent { + + static propTypes = { + media: ImmutablePropTypes.map.isRequired, + statusId: PropTypes.string.isRequired, + status: ImmutablePropTypes.map.isRequired, + accountStaticAvatar: PropTypes.string.isRequired, + options: PropTypes.shape({ + autoPlay: PropTypes.bool, + }), + onClose: PropTypes.func.isRequired, + onChangeBackgroundColor: PropTypes.func.isRequired, + }; + + render () { + const { media, status, accountStaticAvatar, onClose } = this.props; + const options = this.props.options || {}; + const language = status.getIn(['translation', 'language']) || status.get('language'); + const description = media.getIn(['translation', 'description']) || media.get('description'); + + return ( + <div className='modal-root__modal audio-modal'> + <div className='audio-modal__container'> + <Audio + src={media.get('url')} + alt={description} + lang={language} + duration={media.getIn(['meta', 'original', 'duration'], 0)} + height={150} + poster={media.get('preview_url') || accountStaticAvatar} + backgroundColor={media.getIn(['meta', 'colors', 'background'])} + foregroundColor={media.getIn(['meta', 'colors', 'foreground'])} + accentColor={media.getIn(['meta', 'colors', 'accent'])} + autoPlay={options.autoPlay} + /> + </div> + + <div className='media-modal__overlay'> + {status && <Footer statusId={status.get('id')} withOpenButton onClose={onClose} />} + </div> + </div> + ); + } + +} + +export default connect(mapStateToProps, null, null, { forwardRef: true })(AudioModal); diff --git a/app/javascript/flavours/blobfox/features/ui/components/block_modal.jsx b/app/javascript/flavours/blobfox/features/ui/components/block_modal.jsx new file mode 100644 index 00000000000000..cfac692324aa23 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/block_modal.jsx @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { injectIntl, FormattedMessage } from 'react-intl'; + +import { connect } from 'react-redux'; + +import { blockAccount } from '../../../actions/accounts'; +import { closeModal } from '../../../actions/modal'; +import { initReport } from '../../../actions/reports'; +import { Button } from '../../../components/button'; +import { makeGetAccount } from '../../../selectors'; + +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({ + modalType: undefined, + ignoreFocus: false, + })); + }, + }; +}; + +class BlockModal extends PureComponent { + + static propTypes = { + account: PropTypes.object.isRequired, + onClose: PropTypes.func.isRequired, + onBlockAndReport: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleClick = () => { + this.props.onClose(); + this.props.onConfirm(this.props.account); + }; + + handleSecondary = () => { + this.props.onClose(); + this.props.onBlockAndReport(this.props.account); + }; + + handleCancel = () => { + this.props.onClose(); + }; + + render () { + const { account } = this.props; + + return ( + <div className='modal-root__modal block-modal'> + <div className='block-modal__container'> + <p> + <FormattedMessage + id='confirmations.block.message' + defaultMessage='Are you sure you want to block {name}?' + values={{ name: <strong>@{account.get('acct')}</strong> }} + /> + </p> + </div> + + <div className='block-modal__action-bar'> + <Button onClick={this.handleCancel} className='block-modal__cancel-button'> + <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' /> + </Button> + <Button onClick={this.handleSecondary} className='confirmation-modal__secondary-button'> + <FormattedMessage id='confirmations.block.block_and_report' defaultMessage='Block & Report' /> + </Button> + <Button onClick={this.handleClick} autoFocus> + <FormattedMessage id='confirmations.block.confirm' defaultMessage='Block' /> + </Button> + </div> + </div> + ); + } + +} + +export default connect(makeMapStateToProps, mapDispatchToProps)(injectIntl(BlockModal)); diff --git a/app/javascript/flavours/blobfox/features/ui/components/boost_modal.jsx b/app/javascript/flavours/blobfox/features/ui/components/boost_modal.jsx new file mode 100644 index 00000000000000..8a1ebe77b4c187 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/boost_modal.jsx @@ -0,0 +1,131 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; +import { withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { changeBoostPrivacy } from 'flavours/blobfox/actions/boosts'; +import AttachmentList from 'flavours/blobfox/components/attachment_list'; +import { Icon } from 'flavours/blobfox/components/icon'; +import VisibilityIcon from 'flavours/blobfox/components/status_visibility_icon'; +import PrivacyDropdown from 'flavours/blobfox/features/compose/components/privacy_dropdown'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +import { Avatar } from '../../../components/avatar'; +import { Button } from '../../../components/button'; +import { DisplayName } from '../../../components/display_name'; +import { RelativeTimestamp } from '../../../components/relative_timestamp'; +import StatusContent from '../../../components/status_content'; + +const messages = defineMessages({ + cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, + reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, +}); + +const mapStateToProps = state => { + return { + privacy: state.getIn(['boosts', 'new', 'privacy']), + }; +}; + +const mapDispatchToProps = dispatch => { + return { + onChangeBoostPrivacy(value) { + dispatch(changeBoostPrivacy(value)); + }, + }; +}; + +class BoostModal extends ImmutablePureComponent { + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + onReblog: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + missingMediaDescription: PropTypes.bool, + intl: PropTypes.object.isRequired, + ...WithRouterPropTypes, + }; + + handleReblog = () => { + this.props.onReblog(this.props.status, this.props.privacy); + this.props.onClose(); + }; + + handleAccountClick = (e) => { + if (e.button === 0) { + e.preventDefault(); + this.props.onClose(); + this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`); + } + }; + + _findContainer = () => { + return document.getElementsByClassName('modal-root__container')[0]; + }; + + render () { + const { status, missingMediaDescription, privacy, intl } = this.props; + const buttonText = status.get('reblogged') ? messages.cancel_reblog : messages.reblog; + + return ( + <div className='modal-root__modal boost-modal'> + <div className='boost-modal__container'> + <div className={classNames('status', `status-${status.get('visibility')}`, 'light')}> + <div className='boost-modal__status-header'> + <div className='boost-modal__status-time'> + <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'> + <VisibilityIcon visibility={status.get('visibility')} /> + <RelativeTimestamp timestamp={status.get('created_at')} /></a> + </div> + + <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'> + <div className='status__avatar'> + <Avatar account={status.get('account')} size={48} /> + </div> + + <DisplayName account={status.get('account')} /> + </a> + </div> + + <StatusContent status={status} /> + + {status.get('media_attachments').size > 0 && ( + <AttachmentList + compact + media={status.get('media_attachments')} + /> + )} + </div> + </div> + + <div className='boost-modal__action-bar'> + <div> + { missingMediaDescription ? + <FormattedMessage id='boost_modal.missing_description' defaultMessage='This toot contains some media without description' /> + : + <FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon id='retweet' /></span> }} /> + } + </div> + + {status.get('visibility') !== 'private' && !status.get('reblogged') && ( + <PrivacyDropdown + noDirect + value={privacy} + container={this._findContainer} + onChange={this.props.onChangeBoostPrivacy} + /> + )} + <Button text={intl.formatMessage(buttonText)} onClick={this.handleReblog} autoFocus /> + </div> + </div> + ); + } + +} + +export default withRouter(connect(mapStateToProps, mapDispatchToProps)(injectIntl(BoostModal))); diff --git a/app/javascript/flavours/blobfox/features/ui/components/bundle.jsx b/app/javascript/flavours/blobfox/features/ui/components/bundle.jsx new file mode 100644 index 00000000000000..15c4220b3483ad --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/bundle.jsx @@ -0,0 +1,106 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +const emptyComponent = () => null; +const noop = () => { }; + +class Bundle extends 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, + }; + + UNSAFE_componentWillMount() { + this.load(this.props); + } + + UNSAFE_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) ? <Loading /> : null; + } + + if (mod === null) { + return <Error onRetry={this.load} />; + } + + return children(mod); + } + +} + +export default Bundle; diff --git a/app/javascript/flavours/blobfox/features/ui/components/bundle_column_error.jsx b/app/javascript/flavours/blobfox/features/ui/components/bundle_column_error.jsx new file mode 100644 index 00000000000000..59e8ffda043917 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/bundle_column_error.jsx @@ -0,0 +1,166 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { injectIntl, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; + +import { Button } from 'flavours/blobfox/components/button'; +import Column from 'flavours/blobfox/components/column'; +import { autoPlayGif } from 'flavours/blobfox/initial_state'; + +class GIF extends 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 ( + <img + className={className} + src={(hovering || animate) ? src : staticSrc} + alt='' + role='presentation' + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + /> + ); + } + +} + +class CopyButton extends 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 ( + <Button onClick={this.handleClick} className={copied ? 'copied' : 'copyable'}>{copied ? <FormattedMessage id='copypaste.copied' defaultMessage='Copied' /> : children}</Button> + ); + } + +} + +class BundleColumnError extends 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 = <FormattedMessage id='bundle_column_error.routing.title' defaultMessage='404' />; + body = <FormattedMessage id='bundle_column_error.routing.body' defaultMessage='The requested page could not be found. Are you sure the URL in the address bar is correct?' />; + break; + case 'network': + title = <FormattedMessage id='bundle_column_error.network.title' defaultMessage='Network error' />; + body = <FormattedMessage id='bundle_column_error.network.body' defaultMessage='There was an error when trying to load this page. This could be due to a temporary problem with your internet connection or this server.' />; + break; + case 'error': + title = <FormattedMessage id='bundle_column_error.error.title' defaultMessage='Oh, no!' />; + body = <FormattedMessage id='bundle_column_error.error.body' defaultMessage='The requested page could not be rendered. It could be due to a bug in our code, or a browser compatibility issue.' />; + break; + } + + return ( + <Column bindToDocument={!multiColumn}> + <div className='error-column'> + <GIF src='/oops.gif' staticSrc='/oops.png' className='error-column__image' /> + + <div className='error-column__message'> + <h1>{title}</h1> + <p>{body}</p> + + <div className='error-column__message__actions'> + {errorType === 'network' && <Button onClick={this.handleRetry}><FormattedMessage id='bundle_column_error.retry' defaultMessage='Try again' /></Button>} + {errorType === 'error' && <CopyButton value={stacktrace}><FormattedMessage id='bundle_column_error.copy_stacktrace' defaultMessage='Copy error report' /></CopyButton>} + <Link to='/' className={classNames('button', { 'button-tertiary': errorType !== 'routing' })}><FormattedMessage id='bundle_column_error.return' defaultMessage='Go back home' /></Link> + </div> + </div> + </div> + + <Helmet> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} + +export default injectIntl(BundleColumnError); diff --git a/app/javascript/flavours/blobfox/features/ui/components/bundle_modal_error.jsx b/app/javascript/flavours/blobfox/features/ui/components/bundle_modal_error.jsx new file mode 100644 index 00000000000000..67dba3ce0cefcc --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/bundle_modal_error.jsx @@ -0,0 +1,54 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +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 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 <ModalLoading /> + // (make sure they have the same dimensions) + return ( + <div className='modal-root__modal error-modal'> + <div className='error-modal__body'> + <IconButton title={formatMessage(messages.retry)} icon='refresh' onClick={this.handleRetry} size={64} /> + {formatMessage(messages.error)} + </div> + + <div className='error-modal__footer'> + <div> + <button + onClick={onClose} + className='error-modal__nav onboarding-modal__skip' + > + {formatMessage(messages.close)} + </button> + </div> + </div> + </div> + ); + } + +} + +export default injectIntl(BundleModalError); diff --git a/app/javascript/flavours/blobfox/features/ui/components/column.jsx b/app/javascript/flavours/blobfox/features/ui/components/column.jsx new file mode 100644 index 00000000000000..6e8ff93e190f3b --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/column.jsx @@ -0,0 +1,78 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { debounce } from 'lodash'; + +import { isMobile } from '../../../is_mobile'; +import { scrollTop } from '../../../scroll'; + +import ColumnHeader from './column_header'; + +export default class Column extends PureComponent { + + static propTypes = { + heading: PropTypes.string, + icon: PropTypes.string, + children: PropTypes.node, + active: PropTypes.bool, + hideHeadingOnMobile: PropTypes.bool, + name: PropTypes.string, + bindToDocument: PropTypes.bool, + }; + + handleHeaderClick = () => { + const scrollable = this.props.bindToDocument ? document.scrollingElement : this.node.querySelector('.scrollable'); + + if (!scrollable) { + return; + } + + this._interruptScrollAnimation = scrollTop(scrollable); + }; + + scrollTop () { + const scrollable = this.props.bindToDocument ? document.scrollingElement : 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, name } = this.props; + + const showHeading = heading && (!hideHeadingOnMobile || (hideHeadingOnMobile && !isMobile(window.innerWidth))); + + const columnHeaderId = showHeading && heading.replace(/ /g, '-'); + const header = showHeading && ( + <ColumnHeader icon={icon} active={active} type={heading} onClick={this.handleHeaderClick} columnHeaderId={columnHeaderId} /> + ); + return ( + <div + ref={this.setRef} + role='region' + data-column={name} + aria-labelledby={columnHeaderId} + className='column' + onScroll={this.handleScroll} + > + {header} + {children} + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/ui/components/column_header.jsx b/app/javascript/flavours/blobfox/features/ui/components/column_header.jsx new file mode 100644 index 00000000000000..5d535f2e4fb0fe --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/column_header.jsx @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import classNames from 'classnames'; + +import { Icon } from 'flavours/blobfox/components/icon'; + +export default class ColumnHeader extends 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 = <Icon id={icon} fixedWidth className='column-header__icon' />; + } + + return ( + <h1 className={classNames('column-header', { active })} id={columnHeaderId || null}> + <button onClick={this.handleClick}> + {iconElement} + {type} + </button> + </h1> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/ui/components/column_link.jsx b/app/javascript/flavours/blobfox/features/ui/components/column_link.jsx new file mode 100644 index 00000000000000..04c09632a576ca --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/column_link.jsx @@ -0,0 +1,57 @@ +import PropTypes from 'prop-types'; + +import classNames from 'classnames'; +import { NavLink } from 'react-router-dom'; + +import { Icon } from 'flavours/blobfox/components/icon'; + +const ColumnLink = ({ icon, text, to, onClick, href, method, badge, transparent, ...other }) => { + const className = classNames('column-link', { 'column-link--transparent': transparent }); + const badgeElement = typeof badge !== 'undefined' ? <span className='column-link__badge'>{badge}</span> : null; + const iconElement = typeof icon === 'string' ? <Icon id={icon} fixedWidth className='column-link__icon' /> : icon; + + if (href) { + return ( + <a href={href} className={className} data-method={method} title={text} {...other}> + {iconElement} + <span>{text}</span> + {badgeElement} + </a> + ); + } else if (to) { + return ( + <NavLink to={to} className={className} title={text} {...other}> + {iconElement} + <span>{text}</span> + {badgeElement} + </NavLink> + ); + } else { + const handleOnClick = (e) => { + e.preventDefault(); + e.stopPropagation(); + return onClick(e); + }; + return ( + // eslint-disable-next-line jsx-a11y/anchor-is-valid -- intentional to have the same look and feel as other menu items + <a href='#' onClick={onClick && handleOnClick} className={className} title={text} {...other} tabIndex={0}> + {iconElement} + <span>{text}</span> + {badgeElement} + </a> + ); + } +}; + +ColumnLink.propTypes = { + icon: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, + text: PropTypes.string.isRequired, + to: PropTypes.string, + onClick: PropTypes.func, + href: PropTypes.string, + method: PropTypes.string, + badge: PropTypes.node, + transparent: PropTypes.bool, +}; + +export default ColumnLink; diff --git a/app/javascript/flavours/blobfox/features/ui/components/column_loading.jsx b/app/javascript/flavours/blobfox/features/ui/components/column_loading.jsx new file mode 100644 index 00000000000000..d00da6a66f9bc0 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/column_loading.jsx @@ -0,0 +1,32 @@ +import PropTypes from 'prop-types'; + +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import Column from 'flavours/blobfox/components/column'; +import ColumnHeader from 'flavours/blobfox/components/column_header'; + +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 ( + <Column> + <ColumnHeader icon={icon} title={title} multiColumn={multiColumn} focusable={false} placeholder /> + <div className='scrollable' /> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/ui/components/column_subheading.jsx b/app/javascript/flavours/blobfox/features/ui/components/column_subheading.jsx new file mode 100644 index 00000000000000..e970a0bfdd0e1e --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/column_subheading.jsx @@ -0,0 +1,15 @@ +import PropTypes from 'prop-types'; + +const ColumnSubheading = ({ text }) => { + return ( + <div className='column-subheading'> + {text} + </div> + ); +}; + +ColumnSubheading.propTypes = { + text: PropTypes.string.isRequired, +}; + +export default ColumnSubheading; diff --git a/app/javascript/flavours/blobfox/features/ui/components/columns_area.jsx b/app/javascript/flavours/blobfox/features/ui/components/columns_area.jsx new file mode 100644 index 00000000000000..05a02ae6ceea9c --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/columns_area.jsx @@ -0,0 +1,180 @@ +import PropTypes from 'prop-types'; +import { Children, cloneElement } from 'react'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { supportsPassiveEvents } from 'detect-passive-events'; + +import { scrollRight } from '../../../scroll'; +import BundleContainer from '../containers/bundle_container'; +import { + Compose, + Notifications, + HomeTimeline, + CommunityTimeline, + PublicTimeline, + HashtagTimeline, + DirectTimeline, + FavouritedStatuses, + BookmarkedStatuses, + ListTimeline, + Directory, +} from '../util/async-components'; + +import BundleColumnError from './bundle_column_error'; +import ColumnLoading from './column_loading'; +import ComposePanel from './compose_panel'; +import DrawerLoading from './drawer_loading'; +import NavigationPanel from './navigation_panel'; + +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 propTypes = { + columns: ImmutablePropTypes.list.isRequired, + singleColumn: PropTypes.bool, + children: PropTypes.node, + openSettings: PropTypes.func, + }; + + // 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'); + } + + UNSAFE_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' ? <DrawerLoading /> : <ColumnLoading multiColumn />; + }; + + renderError = (props) => { + return <BundleColumnError multiColumn errorType='network' {...props} />; + }; + + render () { + const { columns, children, singleColumn, openSettings } = this.props; + const { renderComposePanel } = this.state; + + if (singleColumn) { + return ( + <div className='columns-area__panels'> + <div className='columns-area__panels__pane columns-area__panels__pane--compositional'> + <div className='columns-area__panels__pane__inner'> + {renderComposePanel && <ComposePanel />} + </div> + </div> + + <div className='columns-area__panels__main'> + <div className='tabs-bar__wrapper'><div id='tabs-bar__portal' /></div> + <div className='columns-area columns-area--mobile'>{children}</div> + </div> + + <div className='columns-area__panels__pane columns-area__panels__pane--start columns-area__panels__pane--navigational'> + <div className='columns-area__panels__pane__inner'> + <NavigationPanel onOpenSettings={openSettings} /> + </div> + </div> + </div> + ); + } + + return ( + <div className='columns-area' ref={this.setRef}> + {columns.map(column => { + const params = column.get('params', null) === null ? null : column.get('params').toJS(); + const other = params && params.other ? params.other : {}; + + return ( + <BundleContainer key={column.get('uuid')} fetchComponent={componentMap[column.get('id')]} loading={this.renderLoading(column.get('id'))} error={this.renderError}> + {SpecificComponent => <SpecificComponent columnId={column.get('uuid')} params={params} multiColumn {...other} />} + </BundleContainer> + ); + })} + + {Children.map(children, child => cloneElement(child, { multiColumn: true }))} + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/ui/components/compare_history_modal.jsx b/app/javascript/flavours/blobfox/features/ui/components/compare_history_modal.jsx new file mode 100644 index 00000000000000..3cc75328076965 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/compare_history_modal.jsx @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +import escapeTextContentForBrowser from 'escape-html'; + +import { closeModal } from 'flavours/blobfox/actions/modal'; +import { IconButton } from 'flavours/blobfox/components/icon_button'; +import InlineAccount from 'flavours/blobfox/components/inline_account'; +import MediaAttachments from 'flavours/blobfox/components/media_attachments'; +import { RelativeTimestamp } from 'flavours/blobfox/components/relative_timestamp'; +import emojify from 'flavours/blobfox/features/emoji/emoji'; + +const mapStateToProps = (state, { statusId }) => ({ + language: state.getIn(['statuses', statusId, 'language']), + versions: state.getIn(['history', statusId, 'items']), +}); + +const mapDispatchToProps = dispatch => ({ + + onClose() { + dispatch(closeModal({ + modalType: undefined, + ignoreFocus: false, + })); + }, + +}); + +class CompareHistoryModal extends PureComponent { + + static propTypes = { + onClose: PropTypes.func.isRequired, + index: PropTypes.number.isRequired, + statusId: PropTypes.string.isRequired, + language: PropTypes.string.isRequired, + versions: ImmutablePropTypes.list.isRequired, + }; + + render () { + const { index, versions, language, 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 = <RelativeTimestamp timestamp={currentVersion.get('created_at')} short={false} />; + const formattedName = <InlineAccount accountId={currentVersion.get('account')} />; + + const label = currentVersion.get('original') ? ( + <FormattedMessage id='status.history.created' defaultMessage='{name} created {date}' values={{ name: formattedName, date: formattedDate }} /> + ) : ( + <FormattedMessage id='status.history.edited' defaultMessage='{name} edited {date}' values={{ name: formattedName, date: formattedDate }} /> + ); + + return ( + <div className='modal-root__modal compare-history-modal'> + <div className='report-modal__target'> + <IconButton className='report-modal__close' icon='times' onClick={onClose} size={20} /> + {label} + </div> + + <div className='compare-history-modal__container'> + <div className='status__content'> + {currentVersion.get('spoiler_text').length > 0 && ( + <> + <div className='translate' dangerouslySetInnerHTML={spoilerContent} lang={language} /> + <hr /> + </> + )} + + <div className='status__content__text status__content__text--visible translate' dangerouslySetInnerHTML={content} lang={language} /> + + {!!currentVersion.get('poll') && ( + <div className='poll'> + <ul> + {currentVersion.getIn(['poll', 'options']).map(option => ( + <li key={option.get('title')}> + <span className='poll__input disabled' /> + + <span + className='poll__option__text translate' + dangerouslySetInnerHTML={{ __html: emojify(escapeTextContentForBrowser(option.get('title')), emojiMap) }} + lang={language} + /> + </li> + ))} + </ul> + </div> + )} + + <MediaAttachments status={currentVersion} lang={language} /> + </div> + </div> + </div> + ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(CompareHistoryModal); diff --git a/app/javascript/flavours/blobfox/features/ui/components/compose_panel.jsx b/app/javascript/flavours/blobfox/features/ui/components/compose_panel.jsx new file mode 100644 index 00000000000000..c6cda04da767f5 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/compose_panel.jsx @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { connect } from 'react-redux'; + +import { mountCompose, unmountCompose } from 'flavours/blobfox/actions/compose'; +import ServerBanner from 'flavours/blobfox/components/server_banner'; +import ComposeFormContainer from 'flavours/blobfox/features/compose/containers/compose_form_container'; +import NavigationContainer from 'flavours/blobfox/features/compose/containers/navigation_container'; +import SearchContainer from 'flavours/blobfox/features/compose/containers/search_container'; + +import LinkFooter from './link_footer'; + +class ComposePanel extends PureComponent { + + static contextTypes = { + identity: PropTypes.object.isRequired, + }; + + static propTypes = { + dispatch: PropTypes.func.isRequired, + }; + + componentDidMount () { + const { dispatch } = this.props; + dispatch(mountCompose()); + } + + componentWillUnmount () { + const { dispatch } = this.props; + dispatch(unmountCompose()); + } + + render() { + const { signedIn } = this.context.identity; + + return ( + <div className='compose-panel'> + <SearchContainer openInRoute /> + + {!signedIn && ( + <> + <ServerBanner /> + <div className='flex-spacer' /> + </> + )} + + {signedIn && ( + <> + <NavigationContainer /> + <ComposeFormContainer singleColumn /> + </> + )} + + <LinkFooter /> + </div> + ); + } + +} + +export default connect()(ComposePanel); diff --git a/app/javascript/flavours/blobfox/features/ui/components/confirmation_modal.jsx b/app/javascript/flavours/blobfox/features/ui/components/confirmation_modal.jsx new file mode 100644 index 00000000000000..51a501595bb6e0 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/confirmation_modal.jsx @@ -0,0 +1,83 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { injectIntl, FormattedMessage } from 'react-intl'; + +import { Button } from '../../../components/button'; + +class ConfirmationModal extends 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, + onDoNotAsk: PropTypes.func, + intl: PropTypes.object.isRequired, + }; + + static defaultProps = { + closeWhenConfirm: true, + }; + + handleClick = () => { + if (this.props.closeWhenConfirm) { + this.props.onClose(); + } + this.props.onConfirm(); + if (this.props.onDoNotAsk && this.doNotAskCheckbox.checked) { + this.props.onDoNotAsk(); + } + }; + + handleSecondary = () => { + this.props.onClose(); + this.props.onSecondary(); + }; + + handleCancel = () => { + this.props.onClose(); + }; + + setDoNotAskRef = (c) => { + this.doNotAskCheckbox = c; + }; + + render () { + const { message, confirm, secondary, onDoNotAsk } = this.props; + + return ( + <div className='modal-root__modal confirmation-modal'> + <div className='confirmation-modal__container'> + {message} + </div> + + <div> + { onDoNotAsk && ( + <div className='confirmation-modal__do_not_ask_again'> + <input type='checkbox' id='confirmation-modal__do_not_ask_again-checkbox' ref={this.setDoNotAskRef} /> + <label htmlFor='confirmation-modal__do_not_ask_again-checkbox'> + <FormattedMessage id='confirmation_modal.do_not_ask_again' defaultMessage='Do not ask for confirmation again' /> + </label> + </div> + )} + <div className='confirmation-modal__action-bar'> + <Button onClick={this.handleCancel} className='confirmation-modal__cancel-button'> + <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' /> + </Button> + {secondary !== undefined && ( + <Button text={secondary} onClick={this.handleSecondary} className='confirmation-modal__secondary-button' /> + )} + <Button text={confirm} onClick={this.handleClick} autoFocus /> + </div> + </div> + </div> + ); + } + +} + +export default injectIntl(ConfirmationModal); diff --git a/app/javascript/flavours/blobfox/features/ui/components/deprecated_settings_modal.jsx b/app/javascript/flavours/blobfox/features/ui/components/deprecated_settings_modal.jsx new file mode 100644 index 00000000000000..90b0084b175dc0 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/deprecated_settings_modal.jsx @@ -0,0 +1,82 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + +import { Button } from 'flavours/blobfox/components/button'; +import { Icon } from 'flavours/blobfox/components/icon'; +import illustration from 'flavours/blobfox/images/logo_warn_blobfox.svg'; +import { preferenceLink } from 'flavours/blobfox/utils/backend_links'; + +const messages = defineMessages({ + discardChanges: { id: 'confirmations.deprecated_settings.confirm', defaultMessage: 'Use Mastodon preferences' }, + user_setting_expand_spoilers: { id: 'settings.enable_content_warnings_auto_unfold', defaultMessage: 'Automatically unfold content-warnings' }, + user_setting_disable_swiping: { id: 'settings.swipe_to_change_columns', defaultMessage: 'Allow swiping to change columns (Mobile only)' }, +}); + +class DeprecatedSettingsModal extends PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.list.isRequired, + onClose: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleClick = () => { + this.props.onConfirm(); + this.props.onClose(); + }; + + render () { + const { settings, intl } = this.props; + + return ( + <div className='modal-root__modal confirmation-modal'> + <div className='confirmation-modal__container'> + + <img src={illustration} className='modal-warning' alt='' /> + + <FormattedMessage + id='confirmations.deprecated_settings.message' + defaultMessage='Some of the blobfox-soc device-specific {app_settings} you are using have been replaced by Mastodon {preferences} and will be overriden:' + values={{ + app_settings: ( + <strong className='deprecated-settings-label'> + <Icon id='cogs' /> <FormattedMessage id='navigation_bar.app_settings' defaultMessage='App settings' /> + </strong> + ), + preferences: ( + <strong className='deprecated-settings-label'> + <Icon id='cog' /> <FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /> + </strong> + ), + }} + /> + + <div className='deprecated-settings-info'> + <ul> + { settings.map((setting_name) => ( + <li key={setting_name}> + <a href={preferenceLink(setting_name)}><FormattedMessage {...messages[setting_name]} /></a> + </li> + )) } + </ul> + </div> + </div> + + <div> + <div className='confirmation-modal__action-bar'> + <div /> + <Button text={intl.formatMessage(messages.discardChanges)} onClick={this.handleClick} autoFocus /> + </div> + </div> + </div> + ); + } + +} + +export default injectIntl(DeprecatedSettingsModal); diff --git a/app/javascript/flavours/blobfox/features/ui/components/disabled_account_banner.jsx b/app/javascript/flavours/blobfox/features/ui/components/disabled_account_banner.jsx new file mode 100644 index 00000000000000..94fbce30dc8d00 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/disabled_account_banner.jsx @@ -0,0 +1,99 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; + +import { Link } from 'react-router-dom'; + +import { connect } from 'react-redux'; + +import { openModal } from 'flavours/blobfox/actions/modal'; +import { disabledAccountId, movedToAccountId, domain } from 'flavours/blobfox/initial_state'; +import { logOut } from 'flavours/blobfox/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({ + modalType: 'CONFIRM', + modalProps: { + message: intl.formatMessage(messages.logoutMessage), + confirm: intl.formatMessage(messages.logoutConfirm), + closeWhenConfirm: false, + onConfirm: () => logOut(), + }, + })); + }, +}); + +class DisabledAccountBanner extends 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 = ( + <Link to={`/@${disabledAcct}`}> + {disabledAcct}@{domain} + </Link> + ); + + return ( + <div className='sign-in-banner'> + <p> + {movedToAcct ? ( + <FormattedMessage + id='moved_to_account_banner.text' + defaultMessage='Your account {disabledAccount} is currently disabled because you moved to {movedToAccount}.' + values={{ + disabledAccount: disabledAccountLink, + movedToAccount: <Link to={`/@${movedToAcct}`}>{movedToAcct.includes('@') ? movedToAcct : `${movedToAcct}@${domain}`}</Link>, + }} + /> + ) : ( + <FormattedMessage + id='disabled_account_banner.text' + defaultMessage='Your account {disabledAccount} is currently disabled.' + values={{ + disabledAccount: disabledAccountLink, + }} + /> + )} + </p> + <a href='/auth/edit' className='button button--block'> + <FormattedMessage id='disabled_account_banner.account_settings' defaultMessage='Account settings' /> + </a> + <button type='button' className='button button--block button-tertiary' onClick={this.handleLogOutClick}> + <FormattedMessage id='confirmations.logout.confirm' defaultMessage='Log out' /> + </button> + </div> + ); + } + +} + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(DisabledAccountBanner)); diff --git a/app/javascript/flavours/blobfox/features/ui/components/doodle_modal.jsx b/app/javascript/flavours/blobfox/features/ui/components/doodle_modal.jsx new file mode 100644 index 00000000000000..b4c3c905dc0cf6 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/doodle_modal.jsx @@ -0,0 +1,619 @@ +import PropTypes from 'prop-types'; + +import classNames from 'classnames'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import Atrament from 'atrament'; // the doodling library +import { debounce, mapValues } from 'lodash'; + +import { doodleSet, uploadCompose } from 'flavours/blobfox/actions/compose'; +import { Button } from 'flavours/blobfox/components/button'; +import { IconButton } from 'flavours/blobfox/components/icon_button'; +// palette nicked from MyPaint, CC0 +const palette = [ + ['rgb( 0, 0, 0)', 'Black'], + ['rgb( 38, 38, 38)', 'Gray 15'], + ['rgb( 77, 77, 77)', 'Grey 30'], + ['rgb(128, 128, 128)', 'Grey 50'], + ['rgb(171, 171, 171)', 'Grey 67'], + ['rgb(217, 217, 217)', 'Grey 85'], + ['rgb(255, 255, 255)', 'White'], + ['rgb(128, 0, 0)', 'Maroon'], + ['rgb(209, 0, 0)', 'English-red'], + ['rgb(255, 54, 34)', 'Tomato'], + ['rgb(252, 60, 3)', 'Orange-red'], + ['rgb(255, 140, 105)', 'Salmon'], + ['rgb(252, 232, 32)', 'Cadium-yellow'], + ['rgb(243, 253, 37)', 'Lemon yellow'], + ['rgb(121, 5, 35)', 'Dark crimson'], + ['rgb(169, 32, 62)', 'Deep carmine'], + ['rgb(255, 140, 0)', 'Orange'], + ['rgb(255, 168, 18)', 'Dark tangerine'], + ['rgb(217, 144, 88)', 'Persian orange'], + ['rgb(194, 178, 128)', 'Sand'], + ['rgb(255, 229, 180)', 'Peach'], + ['rgb(100, 54, 46)', 'Bole'], + ['rgb(108, 41, 52)', 'Dark cordovan'], + ['rgb(163, 65, 44)', 'Chestnut'], + ['rgb(228, 136, 100)', 'Dark salmon'], + ['rgb(255, 195, 143)', 'Apricot'], + ['rgb(255, 219, 188)', 'Unbleached silk'], + ['rgb(242, 227, 198)', 'Straw'], + ['rgb( 53, 19, 13)', 'Bistre'], + ['rgb( 84, 42, 14)', 'Dark chocolate'], + ['rgb(102, 51, 43)', 'Burnt sienna'], + ['rgb(184, 66, 0)', 'Sienna'], + ['rgb(216, 153, 12)', 'Yellow ochre'], + ['rgb(210, 180, 140)', 'Tan'], + ['rgb(232, 204, 144)', 'Dark wheat'], + ['rgb( 0, 49, 83)', 'Prussian blue'], + ['rgb( 48, 69, 119)', 'Dark grey blue'], + ['rgb( 0, 71, 171)', 'Cobalt blue'], + ['rgb( 31, 117, 254)', 'Blue'], + ['rgb(120, 180, 255)', 'Bright french blue'], + ['rgb(171, 200, 255)', 'Bright steel blue'], + ['rgb(208, 231, 255)', 'Ice blue'], + ['rgb( 30, 51, 58)', 'Medium jungle green'], + ['rgb( 47, 79, 79)', 'Dark slate grey'], + ['rgb( 74, 104, 93)', 'Dark grullo green'], + ['rgb( 0, 128, 128)', 'Teal'], + ['rgb( 67, 170, 176)', 'Turquoise'], + ['rgb(109, 174, 199)', 'Cerulean frost'], + ['rgb(173, 217, 186)', 'Tiffany green'], + ['rgb( 22, 34, 29)', 'Gray-asparagus'], + ['rgb( 36, 48, 45)', 'Medium dark teal'], + ['rgb( 74, 104, 93)', 'Xanadu'], + ['rgb(119, 198, 121)', 'Mint'], + ['rgb(175, 205, 182)', 'Timberwolf'], + ['rgb(185, 245, 246)', 'Celeste'], + ['rgb(193, 255, 234)', 'Aquamarine'], + ['rgb( 29, 52, 35)', 'Cal Poly Pomona'], + ['rgb( 1, 68, 33)', 'Forest green'], + ['rgb( 42, 128, 0)', 'Napier green'], + ['rgb(128, 128, 0)', 'Olive'], + ['rgb( 65, 156, 105)', 'Sea green'], + ['rgb(189, 246, 29)', 'Green-yellow'], + ['rgb(231, 244, 134)', 'Bright chartreuse'], + ['rgb(138, 23, 137)', 'Purple'], + ['rgb( 78, 39, 138)', 'Violet'], + ['rgb(193, 75, 110)', 'Dark thulian pink'], + ['rgb(222, 49, 99)', 'Cerise'], + ['rgb(255, 20, 147)', 'Deep pink'], + ['rgb(255, 102, 204)', 'Rose pink'], + ['rgb(255, 203, 219)', 'Pink'], + ['rgb(255, 255, 255)', 'White'], + ['rgb(229, 17, 1)', 'RGB Red'], + ['rgb( 0, 255, 0)', 'RGB Green'], + ['rgb( 0, 0, 255)', 'RGB Blue'], + ['rgb( 0, 255, 255)', 'CMYK Cyan'], + ['rgb(255, 0, 255)', 'CMYK Magenta'], + ['rgb(255, 255, 0)', 'CMYK Yellow'], +]; + +// re-arrange to the right order for display +let palReordered = []; +for (let row = 0; row < 7; row++) { + for (let col = 0; col < 11; col++) { + palReordered.push(palette[col * 7 + row]); + } + palReordered.push(null); // null indicates a <br /> +} + +// Utility for converting base64 image to binary for upload +// https://stackoverflow.com/questions/35940290/how-to-convert-base64-string-to-javascript-file-object-like-as-from-file-input-f +function dataURLtoFile(dataurl, filename) { + let arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1], + bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n); + while(n--){ + u8arr[n] = bstr.charCodeAt(n); + } + return new File([u8arr], filename, { type: mime }); +} +/** Doodle canvas size options */ +const DOODLE_SIZES = { + normal: [500, 500, 'Square 500'], + tootbanner: [702, 330, 'Tootbanner'], + s640x480: [640, 480, '640×480 - 480p'], + s800x600: [800, 600, '800×600 - SVGA'], + s720x480: [720, 405, '720x405 - 16:9'], +}; + + +const mapStateToProps = state => ({ + options: state.getIn(['compose', 'doodle']), +}); + +const mapDispatchToProps = dispatch => ({ + /** + * Set options in the redux store + * @param {Object} opts + */ + setOpt: (opts) => dispatch(doodleSet(opts)), + /** + * Submit doodle for upload + * @param {File} file + */ + submit: (file) => dispatch(uploadCompose([file])), +}); + +/** + * Doodling dialog with drawing canvas + * + * Keyboard shortcuts: + * - Delete: Clear screen, fill with background color + * - Backspace, Ctrl+Z: Undo one step + * - Ctrl held while drawing: Use background color + * - Shift held while clicking screen: Use fill tool + * + * Palette: + * - Left mouse button: pick foreground + * - Ctrl + left mouse button: pick background + * - Right mouse button: pick background + */ +class DoodleModal extends ImmutablePureComponent { + + static propTypes = { + options: ImmutablePropTypes.map, + onClose: PropTypes.func.isRequired, + setOpt: PropTypes.func.isRequired, + submit: PropTypes.func.isRequired, + }; + + //region Option getters/setters + + /** Foreground color */ + get fg () { + return this.props.options.get('fg'); + } + set fg (value) { + this.props.setOpt({ fg: value }); + } + + /** Background color */ + get bg () { + return this.props.options.get('bg'); + } + set bg (value) { + this.props.setOpt({ bg: value }); + } + + /** Swap Fg and Bg for drawing */ + get swapped () { + return this.props.options.get('swapped'); + } + set swapped (value) { + this.props.setOpt({ swapped: value }); + } + + /** Mode - 'draw' or 'fill' */ + get mode () { + return this.props.options.get('mode'); + } + set mode (value) { + this.props.setOpt({ mode: value }); + } + + /** Base line weight */ + get weight () { + return this.props.options.get('weight'); + } + set weight (value) { + this.props.setOpt({ weight: value }); + } + + /** Drawing opacity */ + get opacity () { + return this.props.options.get('opacity'); + } + set opacity (value) { + this.props.setOpt({ opacity: value }); + } + + /** Adaptive stroke - change width with speed */ + get adaptiveStroke () { + return this.props.options.get('adaptiveStroke'); + } + set adaptiveStroke (value) { + this.props.setOpt({ adaptiveStroke: value }); + } + + /** Smoothing (for mouse drawing) */ + get smoothing () { + return this.props.options.get('smoothing'); + } + set smoothing (value) { + this.props.setOpt({ smoothing: value }); + } + + /** Size preset */ + get size () { + return this.props.options.get('size'); + } + set size (value) { + this.props.setOpt({ size: value }); + } + + //endregion + + /** + * Key up handler + * @param {KeyboardEvent} e + */ + handleKeyUp = (e) => { + if (e.target.nodeName === 'INPUT') return; + + if (e.key === 'Delete') { + e.preventDefault(); + this.handleClearBtn(); + return; + } + + if (e.key === 'Backspace' || (e.key === 'z' && (e.ctrlKey || e.metaKey))) { + e.preventDefault(); + this.undo(); + } + + if (e.key === 'Control' || e.key === 'Meta') { + this.controlHeld = false; + this.swapped = false; + } + + if (e.key === 'Shift') { + this.shiftHeld = false; + this.mode = 'draw'; + } + }; + + /** + * Key down handler + * @param {KeyboardEvent} e + */ + handleKeyDown = (e) => { + if (e.key === 'Control' || e.key === 'Meta') { + this.controlHeld = true; + this.swapped = true; + } + + if (e.key === 'Shift') { + this.shiftHeld = true; + this.mode = 'fill'; + } + }; + + /** + * Component installed in the DOM, do some initial set-up + */ + componentDidMount () { + this.controlHeld = false; + this.shiftHeld = false; + this.swapped = false; + window.addEventListener('keyup', this.handleKeyUp, false); + window.addEventListener('keydown', this.handleKeyDown, false); + } + + /** + * Tear component down + */ + componentWillUnmount () { + window.removeEventListener('keyup', this.handleKeyUp, false); + window.removeEventListener('keydown', this.handleKeyDown, false); + if (this.sketcher) this.sketcher.destroy(); + } + + /** + * Set reference to the canvas element. + * This is called during component init + * @param {HTMLCanvasElement} elem - canvas element + */ + setCanvasRef = (elem) => { + this.canvas = elem; + if (elem) { + elem.addEventListener('dirty', () => { + this.saveUndo(); + this.sketcher._dirty = false; + }); + + elem.addEventListener('click', () => { + // sketcher bug - does not fire dirty on fill + if (this.mode === 'fill') { + this.saveUndo(); + } + }); + + // prevent context menu + elem.addEventListener('contextmenu', (e) => { + e.preventDefault(); + }); + + elem.addEventListener('mousedown', (e) => { + if (e.button === 2) { + this.swapped = true; + } + }); + + elem.addEventListener('mouseup', (e) => { + if (e.button === 2) { + this.swapped = this.controlHeld; + } + }); + + this.initSketcher(elem); + this.mode = 'draw'; // Reset mode - it's confusing if left at 'fill' + } + }; + + /** + * Set up the sketcher instance + * @param {HTMLCanvasElement | null} canvas - canvas element. Null if we're just resizing + */ + initSketcher (canvas = null) { + const sizepreset = DOODLE_SIZES[this.size]; + + if (this.sketcher) this.sketcher.destroy(); + this.sketcher = new Atrament(canvas || this.canvas, sizepreset[0], sizepreset[1]); + + if (canvas) { + this.ctx = this.sketcher.context; + this.updateSketcherSettings(); + } + + this.clearScreen(); + } + + /** + * Done button handler + */ + onDoneButton = () => { + const dataUrl = this.sketcher.toImage(); + const file = dataURLtoFile(dataUrl, 'doodle.png'); + this.props.submit(file); + this.props.onClose(); // close dialog + }; + + /** + * Cancel button handler + */ + onCancelButton = () => { + if (this.undos.length > 1 && !confirm('Discard doodle? All changes will be lost!')) { + return; + } + + this.props.onClose(); // close dialog + }; + + /** + * Update sketcher options based on state + */ + updateSketcherSettings () { + if (!this.sketcher) return; + + if (this.oldSize !== this.size) this.initSketcher(); + + this.sketcher.color = (this.swapped ? this.bg : this.fg); + this.sketcher.opacity = this.opacity; + this.sketcher.weight = this.weight; + this.sketcher.mode = this.mode; + this.sketcher.smoothing = this.smoothing; + this.sketcher.adaptiveStroke = this.adaptiveStroke; + + this.oldSize = this.size; + } + + /** + * Fill screen with background color + */ + clearScreen = () => { + this.ctx.fillStyle = this.bg; + this.ctx.fillRect(-1, -1, this.canvas.width+2, this.canvas.height+2); + this.undos = []; + + this.doSaveUndo(); + }; + + /** + * Undo one step + */ + undo = () => { + if (this.undos.length > 1) { + this.undos.pop(); + const buf = this.undos.pop(); + + this.sketcher.clear(); + this.ctx.putImageData(buf, 0, 0); + this.doSaveUndo(); + } + }; + + /** + * Save canvas content into the undo buffer immediately + */ + doSaveUndo = () => { + this.undos.push(this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height)); + }; + + /** + * Called on each canvas change. + * Saves canvas content to the undo buffer after some period of inactivity. + */ + saveUndo = debounce(() => { + this.doSaveUndo(); + }, 100); + + /** + * Palette left click. + * Selects Fg color (or Bg, if Control/Meta is held) + * @param {MouseEvent<HTMLButtonElement>} e - event + */ + onPaletteClick = (e) => { + const c = e.target.dataset.color; + + if (this.controlHeld) { + this.bg = c; + } else { + this.fg = c; + } + + e.target.blur(); + e.preventDefault(); + }; + + /** + * Palette right click. + * Selects Bg color + * @param {MouseEvent<HTMLButtonElement>} e - event + */ + onPaletteRClick = (e) => { + this.bg = e.target.dataset.color; + e.target.blur(); + e.preventDefault(); + }; + + /** + * Handle click on the Draw mode button + * @param {MouseEvent<HTMLButtonElement>} e - event + */ + setModeDraw = (e) => { + this.mode = 'draw'; + e.target.blur(); + }; + + /** + * Handle click on the Fill mode button + * @param {MouseEvent<HTMLButtonElement>} e - event + */ + setModeFill = (e) => { + this.mode = 'fill'; + e.target.blur(); + }; + + /** + * Handle click on Smooth checkbox + * @param {ChangeEvent<HTMLInputElement>} e - event + */ + tglSmooth = (e) => { + this.smoothing = !this.smoothing; + e.target.blur(); + }; + + /** + * Handle click on Adaptive checkbox + * @param {ChangeEvent<HTMLInputElement>} e - event + */ + tglAdaptive = (e) => { + this.adaptiveStroke = !this.adaptiveStroke; + e.target.blur(); + }; + + /** + * Handle change of the Weight input field + * @param {ChangeEvent<HTMLInputElement>} e - event + */ + setWeight = (e) => { + this.weight = +e.target.value || 1; + }; + + /** + * Set size - clalback from the select box + * @param {ChangeEvent<HTMLSelectElement>} e - event + */ + changeSize = (e) => { + let newSize = e.target.value; + if (newSize === this.oldSize) return; + + if (this.undos.length > 1 && !confirm('Change canvas size? This will erase your current drawing!')) { + return; + } + + this.size = newSize; + }; + + handleClearBtn = () => { + if (this.undos.length > 1 && !confirm('Clear canvas? This will erase your current drawing!')) { + return; + } + + this.clearScreen(); + }; + + /** + * Render the component + */ + render () { + this.updateSketcherSettings(); + + return ( + <div className='modal-root__modal doodle-modal'> + <div className='doodle-modal__container'> + <canvas ref={this.setCanvasRef} /> + </div> + + <div className='doodle-modal__action-bar'> + <div className='doodle-toolbar'> + <Button text='Done' onClick={this.onDoneButton} /> + <Button text='Cancel' onClick={this.onCancelButton} /> + </div> + <div className='filler' /> + <div className='doodle-toolbar with-inputs'> + <div> + <label htmlFor='dd_smoothing'>Smoothing</label> + <span className='val'> + <input type='checkbox' id='dd_smoothing' onChange={this.tglSmooth} checked={this.smoothing} /> + </span> + </div> + <div> + <label htmlFor='dd_adaptive'>Adaptive</label> + <span className='val'> + <input type='checkbox' id='dd_adaptive' onChange={this.tglAdaptive} checked={this.adaptiveStroke} /> + </span> + </div> + <div> + <label htmlFor='dd_weight'>Weight</label> + <span className='val'> + <input type='number' min={1} id='dd_weight' value={this.weight} onChange={this.setWeight} /> + </span> + </div> + <div> + <select aria-label='Canvas size' onInput={this.changeSize} defaultValue={this.size}> + { Object.values(mapValues(DOODLE_SIZES, (val, k) => + <option key={k} value={k}>{val[2]}</option>, + )) } + </select> + </div> + </div> + <div className='doodle-toolbar'> + <IconButton icon='pencil' title='Draw' label='Draw' onClick={this.setModeDraw} size={18} active={this.mode === 'draw'} inverted /> + <IconButton icon='bath' title='Fill' label='Fill' onClick={this.setModeFill} size={18} active={this.mode === 'fill'} inverted /> + <IconButton icon='undo' title='Undo' label='Undo' onClick={this.undo} size={18} inverted /> + <IconButton icon='trash' title='Clear' label='Clear' onClick={this.handleClearBtn} size={18} inverted /> + </div> + <div className='doodle-palette'> + { + palReordered.map((c, i) => + c === null ? + <br key={i} /> : + <button + key={i} + style={{ backgroundColor: c[0] }} + onClick={this.onPaletteClick} + onContextMenu={this.onPaletteRClick} + data-color={c[0]} + title={c[1]} + className={classNames({ + 'foreground': this.fg === c[0], + 'background': this.bg === c[0], + })} + />, + ) + } + </div> + </div> + </div> + ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(DoodleModal); diff --git a/app/javascript/flavours/blobfox/features/ui/components/drawer_loading.jsx b/app/javascript/flavours/blobfox/features/ui/components/drawer_loading.jsx new file mode 100644 index 00000000000000..11072ad98cc076 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/drawer_loading.jsx @@ -0,0 +1,9 @@ +const DrawerLoading = () => ( + <div className='drawer'> + <div className='drawer__pager'> + <div className='drawer__inner' /> + </div> + </div> +); + +export default DrawerLoading; diff --git a/app/javascript/flavours/blobfox/features/ui/components/embed_modal.jsx b/app/javascript/flavours/blobfox/features/ui/components/embed_modal.jsx new file mode 100644 index 00000000000000..f357059d76d53b --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/embed_modal.jsx @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; + +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import api from 'flavours/blobfox/api'; +import { IconButton } from 'flavours/blobfox/components/icon_button'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); + +class EmbedModal extends ImmutablePureComponent { + + static propTypes = { + id: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + onError: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + loading: false, + oembed: null, + }; + + componentDidMount () { + const { id } = this.props; + + this.setState({ loading: true }); + + api().get(`/api/web/embeds/${id}`).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 ( + <div className='modal-root__modal report-modal embed-modal'> + <div className='report-modal__target'> + <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} /> + <FormattedMessage id='status.embed' defaultMessage='Embed' /> + </div> + + <div className='report-modal__container embed-modal__container' style={{ display: 'block' }}> + <p className='hint'> + <FormattedMessage id='embed.instructions' defaultMessage='Embed this status on your website by copying the code below.' /> + </p> + + <input + type='text' + className='embed-modal__html' + readOnly + value={oembed && oembed.html || ''} + onClick={this.handleTextareaClick} + /> + + <p className='hint'> + <FormattedMessage id='embed.preview' defaultMessage='Here is what it will look like:' /> + </p> + + <iframe + className='embed-modal__iframe' + frameBorder='0' + ref={this.setIframeRef} + sandbox='allow-scripts allow-same-origin' + title='preview' + /> + </div> + </div> + ); + } + +} + +export default injectIntl(EmbedModal); diff --git a/app/javascript/flavours/blobfox/features/ui/components/favourite_modal.jsx b/app/javascript/flavours/blobfox/features/ui/components/favourite_modal.jsx new file mode 100644 index 00000000000000..47bcd6a612c744 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/favourite_modal.jsx @@ -0,0 +1,94 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; +import { withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import AttachmentList from 'flavours/blobfox/components/attachment_list'; +import { Avatar } from 'flavours/blobfox/components/avatar'; +import { Button } from 'flavours/blobfox/components/button'; +import { DisplayName } from 'flavours/blobfox/components/display_name'; +import { Icon } from 'flavours/blobfox/components/icon'; +import { RelativeTimestamp } from 'flavours/blobfox/components/relative_timestamp'; +import StatusContent from 'flavours/blobfox/components/status_content'; +import VisibilityIcon from 'flavours/blobfox/components/status_visibility_icon'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +const messages = defineMessages({ + favourite: { id: 'status.favourite', defaultMessage: 'Favorite' }, +}); + +class FavouriteModal extends ImmutablePureComponent { + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + onFavourite: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + ...WithRouterPropTypes, + }; + + handleFavourite = () => { + this.props.onFavourite(this.props.status); + this.props.onClose(); + }; + + handleAccountClick = (e) => { + if (e.button === 0) { + e.preventDefault(); + this.props.onClose(); + this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`); + } + }; + + render () { + const { status, intl } = this.props; + + return ( + <div className='modal-root__modal boost-modal'> + <div className='boost-modal__container'> + <div className={classNames('status', `status-${status.get('visibility')}`, 'light')}> + <div className='boost-modal__status-header'> + <div className='boost-modal__status-time'> + <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'> + <VisibilityIcon visibility={status.get('visibility')} /> + <RelativeTimestamp timestamp={status.get('created_at')} /> + </a> + </div> + + <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'> + <div className='status__avatar'> + <Avatar account={status.get('account')} size={48} /> + </div> + + <DisplayName account={status.get('account')} /> + + </a> + </div> + + <StatusContent status={status} /> + + {status.get('media_attachments').size > 0 && ( + <AttachmentList + compact + media={status.get('media_attachments')} + /> + )} + </div> + </div> + + <div className='boost-modal__action-bar'> + <div><FormattedMessage id='favourite_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon id='star' /></span> }} /></div> + <Button text={intl.formatMessage(messages.favourite)} onClick={this.handleFavourite} autoFocus /> + </div> + </div> + ); + } + +} + +export default withRouter(injectIntl(FavouriteModal)); diff --git a/app/javascript/flavours/blobfox/features/ui/components/filter_modal.jsx b/app/javascript/flavours/blobfox/features/ui/components/filter_modal.jsx new file mode 100644 index 00000000000000..7d66e150e49d04 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/filter_modal.jsx @@ -0,0 +1,136 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; + +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { fetchFilters, createFilter, createFilterStatus } from 'flavours/blobfox/actions/filters'; +import { fetchStatus } from 'flavours/blobfox/actions/statuses'; +import { IconButton } from 'flavours/blobfox/components/icon_button'; +import AddedToFilter from 'flavours/blobfox/features/filters/added_to_filter'; +import SelectFilter from 'flavours/blobfox/features/filters/select_filter'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); + +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 = ( + <SelectFilter + contextType={contextType} + onSelectFilter={this.handleSelectFilter} + onNewFilter={this.handleNewFilter} + /> + ); + break; + case 'create': + stepComponent = null; + break; + case 'submitted': + stepComponent = ( + <AddedToFilter + contextType={contextType} + filterId={filterId} + statusId={statusId} + onClose={onClose} + /> + ); + } + + return ( + <div className='modal-root__modal report-dialog-modal'> + <div className='report-modal__target'> + <IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} /> + <FormattedMessage id='filter_modal.title.status' defaultMessage='Filter a post' /> + </div> + + <div className='report-dialog-modal__container'> + {stepComponent} + </div> + </div> + ); + } + +} + +export default connect()(injectIntl(FilterModal)); diff --git a/app/javascript/flavours/blobfox/features/ui/components/focal_point_modal.jsx b/app/javascript/flavours/blobfox/features/ui/components/focal_point_modal.jsx new file mode 100644 index 00000000000000..6d75f2acab94f8 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/focal_point_modal.jsx @@ -0,0 +1,426 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import Textarea from 'react-textarea-autosize'; +import { length } from 'stringz'; +// eslint-disable-next-line import/extensions +import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js'; +// eslint-disable-next-line import/no-extraneous-dependencies +import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js'; + +import { Button } from 'flavours/blobfox/components/button'; +import { GIFV } from 'flavours/blobfox/components/gifv'; +import { IconButton } from 'flavours/blobfox/components/icon_button'; +import Audio from 'flavours/blobfox/features/audio'; +import CharacterCounter from 'flavours/blobfox/features/compose/components/character_counter'; +import UploadProgress from 'flavours/blobfox/features/compose/components/upload_progress'; +import { Tesseract as fetchTesseract } from 'flavours/blobfox/features/ui/util/async-components'; +import { me } from 'flavours/blobfox/initial_state'; +import { assetHost } from 'flavours/blobfox/utils/config'; + +import { changeUploadCompose, uploadThumbnail, onChangeMediaDescription, onChangeMediaFocus } from '../../../actions/compose'; +import Video, { getPointerPosition } from '../../video'; + +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 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 <canvas width={this.props.width} height={this.props.height} />; + } else { + return <img {...this.props} alt='' />; + } + } + +} + +class FocalPointModal extends ImmutablePureComponent { + + static propTypes = { + media: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.record.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)) { + e.preventDefault(); + e.stopPropagation(); + this.props.onChangeDescription(e.target.value); + this.handleSubmit(); + } + }; + + handleSubmit = () => { + 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 = <FormattedMessage id='upload_form.audio_description' defaultMessage='Describe for people who are hard of hearing' />; + } else if (media.get('type') === 'video') { + descriptionLabel = <FormattedMessage id='upload_form.video_description' defaultMessage='Describe for people who are deaf, hard of hearing, blind or have low vision' />; + } else { + descriptionLabel = <FormattedMessage id='upload_form.description' defaultMessage='Describe for people who are blind or have low vision' />; + } + + let ocrMessage = ''; + if (ocrStatus === 'detecting') { + ocrMessage = <FormattedMessage id='upload_modal.analyzing_picture' defaultMessage='Analyzing picture…' />; + } else { + ocrMessage = <FormattedMessage id='upload_modal.preparing_ocr' defaultMessage='Preparing OCR…' />; + } + + return ( + <div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}> + <div className='report-modal__target'> + <IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} /> + <FormattedMessage id='upload_modal.edit_media' defaultMessage='Edit media' /> + </div> + + <div className='report-modal__container'> + <div className='report-modal__comment'> + {focals && <p><FormattedMessage id='upload_modal.hint' defaultMessage='Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.' /></p>} + + {thumbnailable && ( + <> + <label className='setting-text-label' htmlFor='upload-modal__thumbnail'><FormattedMessage id='upload_form.thumbnail' defaultMessage='Change thumbnail' /></label> + + <Button disabled={isUploadingThumbnail || !media.get('unattached')} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} /> + + <label> + <span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span> + + <input + id='upload-modal__thumbnail' + ref={this.setFileInputRef} + type='file' + accept='image/png,image/jpeg' + onChange={this.handleThumbnailChange} + style={{ display: 'none' }} + disabled={isUploadingThumbnail || is_changing_upload} + /> + </label> + + <hr className='setting-divider' /> + </> + )} + + <label className='setting-text-label' htmlFor='upload-modal__description'> + {descriptionLabel} + </label> + + <div className='setting-text__wrapper'> + <Textarea + id='upload-modal__description' + className='setting-text light' + value={detecting ? '…' : description} + lang={lang} + onChange={this.handleChange} + onKeyDown={this.handleKeyDown} + disabled={detecting || is_changing_upload} + autoFocus + /> + + <div className='setting-text__modifiers'> + <UploadProgress progress={progress * 100} active={detecting} icon='file-text-o' message={ocrMessage} /> + </div> + </div> + + <div className='setting-text__toolbar'> + <button disabled={detecting || media.get('type') !== 'image' || is_changing_upload} className='link-button' onClick={this.handleTextDetection}><FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' /></button> + <CharacterCounter max={10000} text={detecting ? '' : description} /> + </div> + + <Button disabled={!dirty || detecting || isUploadingThumbnail || length(description) > 10000 || is_changing_upload} text={intl.formatMessage(is_changing_upload ? messages.applying : messages.apply)} onClick={this.handleSubmit} /> + </div> + + <div className='focal-point-modal__content'> + {focals && ( + <div className={classNames('focal-point', { dragging })} ref={this.setRef} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart}> + {media.get('type') === 'image' && <ImageLoader src={media.get('url')} width={width} height={height} alt='' />} + {media.get('type') === 'gifv' && <GIFV src={media.get('url')} key={media.get('url')} width={width} height={height} />} + + <div className='focal-point__preview'> + <strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong> + <div style={{ width: previewWidth, height: previewHeight, backgroundImage: `url(${media.get('preview_url')})`, backgroundSize: 'cover', backgroundPosition: `${x * 100}% ${y * 100}%` }} /> + </div> + + <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} /> + <div className='focal-point__overlay' /> + </div> + )} + + {media.get('type') === 'video' && ( + <Video + preview={media.get('preview_url')} + frameRate={media.getIn(['meta', 'original', 'frame_rate'])} + blurhash={media.get('blurhash')} + src={media.get('url')} + detailed + inline + editable + /> + )} + + {media.get('type') === 'audio' && ( + <Audio + src={media.get('url')} + duration={media.getIn(['meta', 'original', 'duration'], 0)} + height={150} + poster={media.get('preview_url') || account.get('avatar_static')} + backgroundColor={media.getIn(['meta', 'colors', 'background'])} + foregroundColor={media.getIn(['meta', 'colors', 'foreground'])} + accentColor={media.getIn(['meta', 'colors', 'accent'])} + editable + /> + )} + </div> + </div> + </div> + ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps, null, { + forwardRef: true, +})(injectIntl(FocalPointModal, { forwardRef: true })); diff --git a/app/javascript/flavours/blobfox/features/ui/components/follow_requests_column_link.jsx b/app/javascript/flavours/blobfox/features/ui/components/follow_requests_column_link.jsx new file mode 100644 index 00000000000000..d2481f0d4d49d0 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/follow_requests_column_link.jsx @@ -0,0 +1,54 @@ +import PropTypes from 'prop-types'; +import { Component } from 'react'; + +import { injectIntl, defineMessages } from 'react-intl'; + +import { List as ImmutableList } from 'immutable'; +import { connect } from 'react-redux'; + +import { fetchFollowRequests } from 'flavours/blobfox/actions/accounts'; +import { IconWithBadge } from 'flavours/blobfox/components/icon_with_badge'; +import ColumnLink from 'flavours/blobfox/features/ui/components/column_link'; + +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, +}); + +class FollowRequestsColumnLink extends 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 ( + <ColumnLink + transparent + to='/follow_requests' + icon={<IconWithBadge className='column-link__icon' id='user-plus' count={count} />} + text={intl.formatMessage(messages.text)} + /> + ); + } + +} + +export default injectIntl(connect(mapStateToProps)(FollowRequestsColumnLink)); diff --git a/app/javascript/flavours/blobfox/features/ui/components/header.jsx b/app/javascript/flavours/blobfox/features/ui/components/header.jsx new file mode 100644 index 00000000000000..6ef773b4d30418 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/header.jsx @@ -0,0 +1,124 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; + +import { Link, withRouter } from 'react-router-dom'; + +import { connect } from 'react-redux'; + +import { openModal } from 'flavours/blobfox/actions/modal'; +import { fetchServer } from 'flavours/blobfox/actions/server'; +import { Avatar } from 'flavours/blobfox/components/avatar'; +import { Icon } from 'flavours/blobfox/components/icon'; +import { WordmarkLogo, SymbolLogo } from 'flavours/blobfox/components/logo'; +import Permalink from 'flavours/blobfox/components/permalink'; +import { registrationsOpen, me, sso_redirect } from 'flavours/blobfox/initial_state'; + +const Account = connect(state => ({ + account: state.getIn(['accounts', me]), +}))(({ account }) => ( + <Permalink href={account.get('url')} to={`/@${account.get('acct')}`} title={account.get('acct')}> + <Avatar account={account} size={35} /> + </Permalink> +)); + +const messages = defineMessages({ + search: { id: 'navigation_bar.search', defaultMessage: 'Search' }, +}); + +const mapStateToProps = (state) => ({ + signupUrl: state.getIn(['server', 'server', 'registrations', 'url'], null) || '/auth/sign_up', +}); + +const mapDispatchToProps = (dispatch) => ({ + openClosedRegistrationsModal() { + dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' })); + }, + dispatchServer() { + dispatch(fetchServer()); + } +}); + +class Header extends PureComponent { + + static contextTypes = { + identity: PropTypes.object, + }; + + static propTypes = { + openClosedRegistrationsModal: PropTypes.func, + location: PropTypes.object, + signupUrl: PropTypes.string.isRequired, + dispatchServer: PropTypes.func, + intl: PropTypes.object.isRequired, + }; + + componentDidMount () { + const { dispatchServer } = this.props; + dispatchServer(); + } + + render () { + const { signedIn } = this.context.identity; + const { location, openClosedRegistrationsModal, signupUrl, intl } = this.props; + + let content; + + if (signedIn) { + content = ( + <> + {location.pathname !== '/search' && <Link to='/search' className='button button-secondary' aria-label={intl.formatMessage(messages.search)}><Icon id='search' /></Link>} + {location.pathname !== '/publish' && <Link to='/publish' className='button button-secondary'><FormattedMessage id='compose_form.publish_form' defaultMessage='New post' /></Link>} + <Account /> + </> + ); + } else { + + if (sso_redirect) { + content = ( + <a href={sso_redirect} data-method='post' className='button button--block button-tertiary'><FormattedMessage id='sign_in_banner.sso_redirect' defaultMessage='Login or Register' /></a> + ); + } else { + let signupButton; + + if (registrationsOpen) { + signupButton = ( + <a href={signupUrl} className='button'> + <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' /> + </a> + ); + } else { + signupButton = ( + <button className='button' onClick={openClosedRegistrationsModal}> + <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' /> + </button> + ); + } + + content = ( + <> + {signupButton} + <a href='/auth/sign_in' className='button button-tertiary'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' /></a> + </> + ); + } + } + + return ( + <div className='ui__header'> + <Link to='/' className='ui__header__logo'> + <WordmarkLogo /> + <SymbolLogo /> + </Link> + + <div className='ui__header__links'> + {content} + </div> + </div> + ); + } + +} + +export default injectIntl(withRouter(connect(mapStateToProps, mapDispatchToProps)(Header))); diff --git a/app/javascript/flavours/blobfox/features/ui/components/image_loader.jsx b/app/javascript/flavours/blobfox/features/ui/components/image_loader.jsx new file mode 100644 index 00000000000000..9dabc621b427e4 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/image_loader.jsx @@ -0,0 +1,174 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import classNames from 'classnames'; + +import { LoadingBar } from 'react-redux-loading-bar'; + +import ZoomableImage from './zoomable_image'; + +export default class ImageLoader extends PureComponent { + + static propTypes = { + alt: PropTypes.string, + lang: PropTypes.string, + src: PropTypes.string.isRequired, + previewSrc: PropTypes.string, + width: PropTypes.number, + height: PropTypes.number, + onClick: PropTypes.func, + zoomButtonHidden: PropTypes.bool, + }; + + static defaultProps = { + alt: '', + lang: '', + 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, lang, 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 ( + <div className={className}> + {loading ? ( + <> + <div className='loading-bar__container' style={{ width: this.state.width || width }}> + <LoadingBar className='loading-bar' loading={1} /> + </div> + <canvas + className='image-loader__preview-canvas' + ref={this.setCanvasRef} + width={width} + height={height} + /> + </> + ) : ( + <ZoomableImage + alt={alt} + lang={lang} + src={src} + onClick={onClick} + width={width} + height={height} + zoomButtonHidden={this.props.zoomButtonHidden} + /> + )} + </div> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/ui/components/image_modal.jsx b/app/javascript/flavours/blobfox/features/ui/components/image_modal.jsx new file mode 100644 index 00000000000000..a3106d0ac18c3e --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/image_modal.jsx @@ -0,0 +1,64 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import { IconButton } from 'flavours/blobfox/components/icon_button'; + +import ImageLoader from './image_loader'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); + +class ImageModal extends 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 ( + <div className='modal-root__modal media-modal'> + <div className='media-modal__closer' role='presentation' onClick={onClose} > + <ImageLoader + src={src} + width={400} + height={400} + alt={alt} + onClick={this.toggleNavigation} + /> + </div> + + <div className={navigationClassName}> + <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} /> + </div> + </div> + ); + } + +} + +export default injectIntl(ImageModal); diff --git a/app/javascript/flavours/blobfox/features/ui/components/link_footer.jsx b/app/javascript/flavours/blobfox/features/ui/components/link_footer.jsx new file mode 100644 index 00000000000000..d9e80497f371c4 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/link_footer.jsx @@ -0,0 +1,111 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; + +import { Link } from 'react-router-dom'; + +import { connect } from 'react-redux'; + +import { openModal } from 'flavours/blobfox/actions/modal'; +import { domain, version, source_url, statusPageUrl, profile_directory as profileDirectory } from 'flavours/blobfox/initial_state'; +import { PERMISSION_INVITE_USERS } from 'flavours/blobfox/permissions'; +import { logOut } from 'flavours/blobfox/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 mapDispatchToProps = (dispatch, { intl }) => ({ + onLogout () { + dispatch(openModal({ + modalType: 'CONFIRM', + modalProps: { + message: intl.formatMessage(messages.logoutMessage), + confirm: intl.formatMessage(messages.logoutConfirm), + closeWhenConfirm: false, + onConfirm: () => logOut(), + }, + })); + }, +}); + +class LinkFooter extends PureComponent { + + static contextTypes = { + identity: PropTypes.object, + }; + + static propTypes = { + multiColumn: PropTypes.bool, + 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 { multiColumn } = this.props; + + const canInvite = signedIn && ((permissions & PERMISSION_INVITE_USERS) === PERMISSION_INVITE_USERS); + const canProfileDirectory = profileDirectory; + + const DividingCircle = <span aria-hidden>{' · '}</span>; + + return ( + <div className='link-footer'> + <p> + <strong>{domain}</strong>: + {' '} + <Link to='/about' target={multiColumn ? '_blank' : undefined}><FormattedMessage id='footer.about' defaultMessage='About' /></Link> + {statusPageUrl && ( + <> + {DividingCircle} + <a href={statusPageUrl} target='_blank' rel='noopener'><FormattedMessage id='footer.status' defaultMessage='Status' /></a> + </> + )} + {canInvite && ( + <> + {DividingCircle} + <a href='/invites' target='_blank'><FormattedMessage id='footer.invite' defaultMessage='Invite people' /></a> + </> + )} + {canProfileDirectory && ( + <> + {DividingCircle} + <Link to='/directory'><FormattedMessage id='footer.directory' defaultMessage='Profiles directory' /></Link> + </> + )} + {DividingCircle} + <Link to='/privacy-policy' target={multiColumn ? '_blank' : undefined}><FormattedMessage id='footer.privacy_policy' defaultMessage='Privacy policy' /></Link> + </p> + + <p> + <strong>Mastodon</strong>: + {' '} + <a href='https://joinmastodon.org' target='_blank'><FormattedMessage id='footer.about' defaultMessage='About' /></a> + {DividingCircle} + <a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='footer.get_app' defaultMessage='Get the app' /></a> + {DividingCircle} + <Link to='/keyboard-shortcuts'><FormattedMessage id='footer.keyboard_shortcuts' defaultMessage='Keyboard shortcuts' /></Link> + {DividingCircle} + <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='footer.source_code' defaultMessage='View source code' /></a> + {DividingCircle} + <span className='version'>v{version}</span> + </p> + </div> + ); + } + +} + +export default injectIntl(connect(null, mapDispatchToProps)(LinkFooter)); diff --git a/app/javascript/flavours/blobfox/features/ui/components/list_panel.jsx b/app/javascript/flavours/blobfox/features/ui/components/list_panel.jsx new file mode 100644 index 00000000000000..aeae19018172d2 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/list_panel.jsx @@ -0,0 +1,56 @@ +import PropTypes from 'prop-types'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; + +import { fetchLists } from 'flavours/blobfox/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), +}); + +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 ( + <div className='list-panel'> + <hr /> + + {lists.map(list => ( + <ColumnLink icon='list-ul' key={list.get('id')} strict text={list.get('title')} to={`/lists/${list.get('id')}`} transparent /> + ))} + </div> + ); + } + +} + +export default connect(mapStateToProps)(ListPanel); diff --git a/app/javascript/flavours/blobfox/features/ui/components/media_modal.jsx b/app/javascript/flavours/blobfox/features/ui/components/media_modal.jsx new file mode 100644 index 00000000000000..77d4b7ae792f9b --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/media_modal.jsx @@ -0,0 +1,261 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import ReactSwipeableViews from 'react-swipeable-views'; + +import { getAverageFromBlurhash } from 'flavours/blobfox/blurhash'; +import { GIFV } from 'flavours/blobfox/components/gifv'; +import { Icon } from 'flavours/blobfox/components/icon'; +import { IconButton } from 'flavours/blobfox/components/icon_button'; +import Footer from 'flavours/blobfox/features/picture_in_picture/components/footer'; +import Video from 'flavours/blobfox/features/video'; +import { disableSwiping } from 'flavours/blobfox/initial_state'; + +import ImageLoader from './image_loader'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, + previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, + next: { id: 'lightbox.next', defaultMessage: 'Next' }, +}); + +class MediaModal extends ImmutablePureComponent { + + static propTypes = { + media: ImmutablePropTypes.list.isRequired, + statusId: PropTypes.string, + lang: 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, lang, intl, onClose } = this.props; + const { navigationHidden } = this.state; + + const index = this.getIndex(); + + const leftNav = media.size > 1 && <button tabIndex={0} className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><Icon id='chevron-left' fixedWidth /></button>; + const rightNav = media.size > 1 && <button tabIndex={0} className='media-modal__nav media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><Icon id='chevron-right' fixedWidth /></button>; + + const content = media.map((image) => { + const width = image.getIn(['meta', 'original', 'width']) || null; + const height = image.getIn(['meta', 'original', 'height']) || null; + const description = image.getIn(['translation', 'description']) || image.get('description'); + + if (image.get('type') === 'image') { + return ( + <ImageLoader + previewSrc={image.get('preview_url')} + src={image.get('url')} + width={width} + height={height} + alt={description} + lang={lang} + key={image.get('url')} + onClick={this.toggleNavigation} + zoomButtonHidden={this.state.zoomButtonHidden} + /> + ); + } else if (image.get('type') === 'video') { + const { currentTime, autoPlay, volume } = this.props; + + return ( + <Video + preview={image.get('preview_url')} + blurhash={image.get('blurhash')} + src={image.get('url')} + width={image.get('width')} + height={image.get('height')} + frameRate={image.getIn(['meta', 'original', 'frame_rate'])} + currentTime={currentTime || 0} + autoPlay={autoPlay || false} + volume={volume || 1} + onCloseVideo={onClose} + detailed + alt={description} + lang={lang} + key={image.get('url')} + /> + ); + } else if (image.get('type') === 'gifv') { + return ( + <GIFV + src={image.get('url')} + width={width} + height={height} + key={image.get('url')} + alt={description} + lang={lang} + onClick={this.toggleNavigation} + /> + ); + } + + 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) => ( + <button key={i} className={classNames('media-modal__page-dot', { active: i === index })} data-index={i} onClick={this.handleChangeIndex}> + {i + 1} + </button> + )); + } + + return ( + <div className='modal-root__modal media-modal'> + <div className='media-modal__closer' role='presentation' onClick={onClose} > + <ReactSwipeableViews + style={swipeableViewsStyle} + containerStyle={containerStyle} + onChangeIndex={this.handleSwipe} + onTransitionEnd={this.handleTransitionEnd} + index={index} + disabled={disableSwiping} + > + {content} + </ReactSwipeableViews> + </div> + + <div className={navigationClassName}> + <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} /> + + {leftNav} + {rightNav} + + <div className='media-modal__overlay'> + {pagination && <ul className='media-modal__pagination'>{pagination}</ul>} + {statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />} + </div> + </div> + </div> + ); + } + +} + +export default injectIntl(MediaModal); diff --git a/app/javascript/flavours/blobfox/features/ui/components/modal_loading.jsx b/app/javascript/flavours/blobfox/features/ui/components/modal_loading.jsx new file mode 100644 index 00000000000000..7d19e73513336d --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/modal_loading.jsx @@ -0,0 +1,18 @@ +import { LoadingIndicator } from '../../../components/loading_indicator'; + +// Keep the markup in sync with <BundleModalError /> +// (make sure they have the same dimensions) +const ModalLoading = () => ( + <div className='modal-root__modal error-modal'> + <div className='error-modal__body'> + <LoadingIndicator /> + </div> + <div className='error-modal__footer'> + <div> + <button className='error-modal__nav onboarding-modal__skip' /> + </div> + </div> + </div> +); + +export default ModalLoading; diff --git a/app/javascript/flavours/blobfox/features/ui/components/modal_root.jsx b/app/javascript/flavours/blobfox/features/ui/components/modal_root.jsx new file mode 100644 index 00000000000000..81c63cab9fc401 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/modal_root.jsx @@ -0,0 +1,142 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { Helmet } from 'react-helmet'; + +import Base from 'flavours/blobfox/components/modal_root'; +import { + MuteModal, + BlockModal, + ReportModal, + SettingsModal, + EmbedModal, + ListEditor, + ListAdder, + PinnedAccountsEditor, + CompareHistoryModal, + FilterModal, + InteractionModal, + SubscribedLanguagesModal, + ClosedRegistrationsModal, +} from 'flavours/blobfox/features/ui/util/async-components'; +import { getScrollbarWidth } from 'flavours/blobfox/utils/scrollbar'; + +import BundleContainer from '../containers/bundle_container'; + +import ActionsModal from './actions_modal'; +import AudioModal from './audio_modal'; +import BoostModal from './boost_modal'; +import BundleModalError from './bundle_modal_error'; +import ConfirmationModal from './confirmation_modal'; +import DeprecatedSettingsModal from './deprecated_settings_modal'; +import DoodleModal from './doodle_modal'; +import FavouriteModal from './favourite_modal'; +import FocalPointModal from './focal_point_modal'; +import ImageModal from './image_modal'; +import MediaModal from './media_modal'; +import ModalLoading from './modal_loading'; +import VideoModal from './video_modal'; + +export 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 }), + 'FAVOURITE': () => Promise.resolve({ default: FavouriteModal }), + 'DOODLE': () => Promise.resolve({ default: DoodleModal }), + 'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }), + 'MUTE': MuteModal, + 'BLOCK': BlockModal, + 'REPORT': ReportModal, + 'SETTINGS': SettingsModal, + 'DEPRECATED_SETTINGS': () => Promise.resolve({ default: DeprecatedSettingsModal }), + 'ACTIONS': () => Promise.resolve({ default: ActionsModal }), + 'EMBED': EmbedModal, + 'LIST_EDITOR': ListEditor, + 'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }), + 'LIST_ADDER': ListAdder, + 'PINNED_ACCOUNTS_EDITOR': PinnedAccountsEditor, + 'COMPARE_HISTORY': CompareHistoryModal, + 'FILTER': FilterModal, + 'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal, + 'INTERACTION': InteractionModal, + 'CLOSED_REGISTRATIONS': ClosedRegistrationsModal, +}; + +export default class ModalRoot extends PureComponent { + + static propTypes = { + type: PropTypes.string, + props: PropTypes.object, + onClose: PropTypes.func.isRequired, + ignoreFocus: PropTypes.bool, + }; + + state = { + backgroundColor: null, + }; + + componentDidUpdate () { + if (this.props.type) { + 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', 'FAVOURITE', 'DOODLE', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null; + }; + + renderError = (props) => { + const { onClose } = this.props; + + return <BundleModalError {...props} onClose={onClose} />; + }; + + handleClose = (ignoreFocus = false) => { + const { onClose } = this.props; + const message = this._modal?.getCloseConfirmationMessage?.(); + onClose(message, ignoreFocus); + }; + + setModalRef = (c) => { + this._modal = c; + }; + + // prevent closing of modal when clicking the overlay + noop = () => {}; + + render () { + const { type, props, ignoreFocus } = this.props; + const { backgroundColor } = this.state; + const visible = !!type; + + return ( + <Base backgroundColor={backgroundColor} onClose={props && props.noClose ? this.noop : this.handleClose} noEsc={props ? props.noEsc : false} ignoreFocus={ignoreFocus}> + {visible && ( + <> + <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}> + {(SpecificComponent) => { + const ref = typeof SpecificComponent !== 'function' ? this.setModalRef : undefined; + return <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={ref} />; + }} + </BundleContainer> + + <Helmet> + <meta name='robots' content='noindex' /> + </Helmet> + </> + )} + </Base> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/ui/components/mute_modal.jsx b/app/javascript/flavours/blobfox/features/ui/components/mute_modal.jsx new file mode 100644 index 00000000000000..2d95cabef84db1 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/mute_modal.jsx @@ -0,0 +1,139 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { connect } from 'react-redux'; + +import Toggle from 'react-toggle'; + +import { muteAccount } from '../../../actions/accounts'; +import { closeModal } from '../../../actions/modal'; +import { toggleHideNotifications, changeMuteDuration } from '../../../actions/mutes'; +import { Button } from '../../../components/button'; + +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({ + modalType: undefined, + ignoreFocus: false, + })); + }, + + onToggleNotifications() { + dispatch(toggleHideNotifications()); + }, + + onChangeMuteDuration(e) { + dispatch(changeMuteDuration(e.target.value)); + }, + }; +}; + +class MuteModal extends 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, + }; + + handleClick = () => { + this.props.onClose(); + this.props.onConfirm(this.props.account, this.props.notifications, this.props.muteDuration); + }; + + handleCancel = () => { + this.props.onClose(); + }; + + toggleNotifications = () => { + this.props.onToggleNotifications(); + }; + + changeMuteDuration = (e) => { + this.props.onChangeMuteDuration(e); + }; + + render () { + const { account, notifications, muteDuration, intl } = this.props; + + return ( + <div className='modal-root__modal mute-modal'> + <div className='mute-modal__container'> + <p> + <FormattedMessage + id='confirmations.mute.message' + defaultMessage='Are you sure you want to mute {name}?' + values={{ name: <strong>@{account.get('acct')}</strong> }} + /> + </p> + <p className='mute-modal__explanation'> + <FormattedMessage + id='confirmations.mute.explanation' + defaultMessage='This will hide posts from them and posts mentioning them, but it will still allow them to see your posts and follow you.' + /> + </p> + <div className='setting-toggle'> + <Toggle id='mute-modal__hide-notifications-checkbox' checked={notifications} onChange={this.toggleNotifications} /> + <label className='setting-toggle__label' htmlFor='mute-modal__hide-notifications-checkbox'> + <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' /> + </label> + </div> + <div> + <span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span> + + {/* eslint-disable-next-line jsx-a11y/no-onchange */} + <select value={muteDuration} onChange={this.changeMuteDuration}> + <option value={0}>{intl.formatMessage(messages.indefinite)}</option> + <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option> + <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option> + <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option> + <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option> + <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option> + <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option> + <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option> + </select> + </div> + </div> + + <div className='mute-modal__action-bar'> + <Button onClick={this.handleCancel} className='mute-modal__cancel-button'> + <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' /> + </Button> + <Button onClick={this.handleClick} autoFocus> + <FormattedMessage id='confirmations.mute.confirm' defaultMessage='Mute' /> + </Button> + </div> + </div> + ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(MuteModal)); diff --git a/app/javascript/flavours/blobfox/features/ui/components/navigation_panel.jsx b/app/javascript/flavours/blobfox/features/ui/components/navigation_panel.jsx new file mode 100644 index 00000000000000..5ed79d0c2b154c --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/navigation_panel.jsx @@ -0,0 +1,127 @@ +import PropTypes from 'prop-types'; +import { Component } from 'react'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import { NavigationPortal } from 'flavours/blobfox/components/navigation_portal'; +import { timelinePreview, trendsEnabled } from 'flavours/blobfox/initial_state'; +import { transientSingleColumn } from 'flavours/blobfox/is_mobile'; +import { preferencesLink } from 'flavours/blobfox/utils/backend_links'; + +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'; + +const messages = defineMessages({ + home: { id: 'tabs_bar.home', defaultMessage: 'Home' }, + notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' }, + explore: { id: 'explore.title', defaultMessage: 'Explore' }, + firehose: { id: 'column.firehose', defaultMessage: 'Live feeds' }, + direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' }, + favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' }, + 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' }, + advancedInterface: { id: 'navigation_bar.advanced_interface', defaultMessage: 'Open in advanced web interface' }, + openedInClassicInterface: { id: 'navigation_bar.opened_in_classic_interface', defaultMessage: 'Posts, accounts, and other specific pages are opened by default in the classic web interface.' }, + app_settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, +}); + +class NavigationPanel extends Component { + + static contextTypes = { + identity: PropTypes.object.isRequired, + }; + + static propTypes = { + intl: PropTypes.object.isRequired, + onOpenSettings: PropTypes.func, + }; + + isFirehoseActive = (match, location) => { + return match || location.pathname.startsWith('/public'); + }; + + render () { + const { intl, onOpenSettings } = this.props; + const { signedIn, disabledAccountId } = this.context.identity; + + let banner = undefined; + + if(transientSingleColumn) + banner = (<div className='switch-to-advanced'> + {intl.formatMessage(messages.openedInClassicInterface)} + {" "} + <a href={`/deck${location.pathname}`} className='switch-to-advanced__toggle'> + {intl.formatMessage(messages.advancedInterface)} + </a> + </div>); + + return ( + <div className='navigation-panel'> + {banner && + <div className='navigation-panel__banner'> + {banner} + </div> + } + + {signedIn && ( + <> + <ColumnLink transparent to='/home' icon='home' text={intl.formatMessage(messages.home)} /> + <ColumnLink transparent to='/notifications' icon={<NotificationsCounterIcon className='column-link__icon' />} text={intl.formatMessage(messages.notifications)} /> + <FollowRequestsColumnLink /> + </> + )} + + {trendsEnabled ? ( + <ColumnLink transparent to='/explore' icon='hashtag' text={intl.formatMessage(messages.explore)} /> + ) : ( + <ColumnLink transparent to='/search' icon='search' text={intl.formatMessage(messages.search)} /> + )} + + {(signedIn || timelinePreview) && ( + <ColumnLink transparent to='/public/local' isActive={this.isFirehoseActive} icon='globe' text={intl.formatMessage(messages.firehose)} /> + )} + + {!signedIn && ( + <div className='navigation-panel__sign-in-banner'> + <hr /> + { disabledAccountId ? <DisabledAccountBanner /> : <SignInBanner /> } + </div> + )} + + {signedIn && ( + <> + <ColumnLink transparent to='/conversations' icon='at' text={intl.formatMessage(messages.direct)} /> + <ColumnLink transparent to='/bookmarks' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} /> + <ColumnLink transparent to='/favourites' icon='star' text={intl.formatMessage(messages.favourites)} /> + <ColumnLink transparent to='/lists' icon='list-ul' text={intl.formatMessage(messages.lists)} /> + + <ListPanel /> + + <hr /> + + {!!preferencesLink && <ColumnLink transparent href={preferencesLink} icon='cog' text={intl.formatMessage(messages.preferences)} />} + <ColumnLink transparent onClick={onOpenSettings} icon='cogs' text={intl.formatMessage(messages.app_settings)} /> + </> + )} + + <div className='navigation-panel__legal'> + <hr /> + <ColumnLink transparent to='/about' icon='ellipsis-h' text={intl.formatMessage(messages.about)} /> + </div> + + <NavigationPortal /> + </div> + ); + } + +} + +export default injectIntl(NavigationPanel); diff --git a/app/javascript/flavours/blobfox/features/ui/components/notifications_counter_icon.js b/app/javascript/flavours/blobfox/features/ui/components/notifications_counter_icon.js new file mode 100644 index 00000000000000..1509a0962ee93d --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/notifications_counter_icon.js @@ -0,0 +1,10 @@ +import { connect } from 'react-redux'; + +import { IconWithBadge } from 'flavours/blobfox/components/icon_with_badge'; + +const mapStateToProps = state => ({ + count: state.getIn(['local_settings', 'notifications', 'tab_badge']) ? state.getIn(['notifications', 'unread']) : 0, + id: 'bell', +}); + +export default connect(mapStateToProps)(IconWithBadge); diff --git a/app/javascript/flavours/blobfox/features/ui/components/report_modal.jsx b/app/javascript/flavours/blobfox/features/ui/components/report_modal.jsx new file mode 100644 index 00000000000000..79029da022d5ca --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/report_modal.jsx @@ -0,0 +1,227 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; + +import { OrderedSet } from 'immutable'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { fetchRelationships } from 'flavours/blobfox/actions/accounts'; +import { submitReport } from 'flavours/blobfox/actions/reports'; +import { fetchServer } from 'flavours/blobfox/actions/server'; +import { expandAccountTimeline } from 'flavours/blobfox/actions/timelines'; +import { IconButton } from 'flavours/blobfox/components/icon_button'; +import Category from 'flavours/blobfox/features/report/category'; +import Comment from 'flavours/blobfox/features/report/comment'; +import Rules from 'flavours/blobfox/features/report/rules'; +import Statuses from 'flavours/blobfox/features/report/statuses'; +import Thanks from 'flavours/blobfox/features/report/thanks'; +import { makeGetAccount } from 'flavours/blobfox/selectors'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { accountId }) => ({ + account: getAccount(state, accountId), + }); + + return mapStateToProps; +}; + +class ReportModal extends ImmutablePureComponent { + + static propTypes = { + accountId: PropTypes.string.isRequired, + statusId: PropTypes.string, + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + account: ImmutablePropTypes.record.isRequired, + }; + + state = { + step: 'category', + selectedStatusIds: OrderedSet(this.props.statusId ? [this.props.statusId] : []), + selectedDomains: OrderedSet(), + comment: '', + category: null, + selectedRuleIds: OrderedSet(), + isSubmitting: false, + isSubmitted: false, + }; + + handleSubmit = () => { + const { dispatch, accountId } = this.props; + const { selectedStatusIds, selectedDomains, comment, category, selectedRuleIds } = this.state; + + this.setState({ isSubmitting: true }); + + dispatch(submitReport({ + account_id: accountId, + status_ids: selectedStatusIds.toArray(), + forward_to_domains: selectedDomains.toArray(), + comment, + forward: selectedDomains.size > 0, + 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) }); + } + }; + + handleDomainToggle = (domain, checked) => { + if (checked) { + this.setState((state) => ({ selectedDomains: state.selectedDomains.add(domain) })); + } else { + this.setState((state) => ({ selectedDomains: state.selectedDomains.remove(domain) })); + } + }; + + handleRuleToggle = (ruleId, checked) => { + if (checked) { + this.setState((state) => ({ selectedRuleIds: state.selectedRuleIds.add(ruleId) })); + } else { + this.setState((state) => ({ selectedRuleIds: state.selectedRuleIds.remove(ruleId) })); + } + }; + + handleChangeCategory = category => { + this.setState({ category }); + }; + + handleChangeComment = comment => { + this.setState({ comment }); + }; + + handleNextStep = step => { + this.setState({ step }); + }; + + componentDidMount () { + const { dispatch, accountId } = this.props; + + dispatch(fetchRelationships([accountId])); + dispatch(expandAccountTimeline(accountId, { withReplies: true })); + dispatch(fetchServer()); + } + + render () { + const { + accountId, + account, + intl, + onClose, + } = this.props; + + if (!account) { + return null; + } + + const { + step, + selectedStatusIds, + selectedRuleIds, + selectedDomains, + comment, + category, + isSubmitting, + isSubmitted, + } = this.state; + + const domain = account.get('acct').split('@')[1]; + const isRemote = !!domain; + + let stepComponent; + + switch(step) { + case 'category': + stepComponent = ( + <Category + onNextStep={this.handleNextStep} + startedFrom={this.props.statusId ? 'status' : 'account'} + category={category} + onChangeCategory={this.handleChangeCategory} + /> + ); + break; + case 'rules': + stepComponent = ( + <Rules + onNextStep={this.handleNextStep} + selectedRuleIds={selectedRuleIds} + onToggle={this.handleRuleToggle} + /> + ); + break; + case 'statuses': + stepComponent = ( + <Statuses + onNextStep={this.handleNextStep} + accountId={accountId} + selectedStatusIds={selectedStatusIds} + onToggle={this.handleStatusToggle} + /> + ); + break; + case 'comment': + stepComponent = ( + <Comment + onSubmit={this.handleSubmit} + isSubmitting={isSubmitting} + isRemote={isRemote} + comment={comment} + domain={domain} + onChangeComment={this.handleChangeComment} + statusIds={selectedStatusIds} + selectedDomains={selectedDomains} + onToggleDomain={this.handleDomainToggle} + /> + ); + break; + case 'thanks': + stepComponent = ( + <Thanks + submitted={isSubmitted} + account={account} + onClose={onClose} + /> + ); + } + + return ( + <div className='modal-root__modal report-dialog-modal'> + <div className='report-modal__target'> + <IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} /> + <FormattedMessage id='report.target' defaultMessage='Report {target}' values={{ target: <strong>{account.get('acct')}</strong> }} /> + </div> + + <div className='report-dialog-modal__container'> + {stepComponent} + </div> + </div> + ); + } + +} + +export default connect(makeMapStateToProps)(injectIntl(ReportModal)); diff --git a/app/javascript/flavours/blobfox/features/ui/components/sign_in_banner.jsx b/app/javascript/flavours/blobfox/features/ui/components/sign_in_banner.jsx new file mode 100644 index 00000000000000..069e45a4f9d645 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/sign_in_banner.jsx @@ -0,0 +1,54 @@ +import { useCallback } from 'react'; + +import { FormattedMessage } from 'react-intl'; + + +import { openModal } from 'flavours/blobfox/actions/modal'; +import { registrationsOpen, sso_redirect } from 'flavours/blobfox/initial_state'; +import { useAppDispatch, useAppSelector } from 'flavours/blobfox/store'; + +const SignInBanner = () => { + const dispatch = useAppDispatch(); + + const openClosedRegistrationsModal = useCallback( + () => dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' })), + [dispatch], + ); + + let signupButton; + + const signupUrl = useAppSelector((state) => state.getIn(['server', 'server', 'registrations', 'url'], null) || '/auth/sign_up'); + + if (sso_redirect) { + return ( + <div className='sign-in-banner'> + <p><FormattedMessage id='sign_in_banner.text' defaultMessage='Login to follow profiles or hashtags, favorite, share and reply to posts. You can also interact from your account on a different server.' /></p> + <a href={sso_redirect} data-method='post' className='button button--block button-tertiary'><FormattedMessage id='sign_in_banner.sso_redirect' defaultMessage='Login or Register' /></a> + </div> + ); + } + + if (registrationsOpen) { + signupButton = ( + <a href={signupUrl} className='button button--block'> + <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' /> + </a> + ); + } else { + signupButton = ( + <button className='button button--block' onClick={openClosedRegistrationsModal}> + <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' /> + </button> + ); + } + + return ( + <div className='sign-in-banner'> + <p><FormattedMessage id='sign_in_banner.text' defaultMessage='Login to follow profiles or hashtags, favorite, share and reply to posts. You can also interact from your account on a different server.' /></p> + {signupButton} + <a href='/auth/sign_in' className='button button--block button-tertiary'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' /></a> + </div> + ); +}; + +export default SignInBanner; diff --git a/app/javascript/flavours/blobfox/features/ui/components/upload_area.jsx b/app/javascript/flavours/blobfox/features/ui/components/upload_area.jsx new file mode 100644 index 00000000000000..b2702d35efdfd6 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/upload_area.jsx @@ -0,0 +1,55 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import spring from 'react-motion/lib/spring'; + +import Motion from '../util/optional_motion'; + +export default class UploadArea extends 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 ( + <Motion defaultStyle={{ backgroundOpacity: 0, backgroundScale: 0.95 }} style={{ backgroundOpacity: spring(active ? 1 : 0, { stiffness: 150, damping: 15 }), backgroundScale: spring(active ? 1 : 0.95, { stiffness: 200, damping: 3 }) }}> + {({ backgroundOpacity, backgroundScale }) => ( + <div className='upload-area' style={{ visibility: active ? 'visible' : 'hidden', opacity: backgroundOpacity }}> + <div className='upload-area__drop'> + <div className='upload-area__background' style={{ transform: `scale(${backgroundScale})` }} /> + <div className='upload-area__content'><FormattedMessage id='upload_area.title' defaultMessage='Drag & drop to upload' /></div> + </div> + </div> + )} + </Motion> + ); + } + +} diff --git a/app/javascript/flavours/blobfox/features/ui/components/video_modal.jsx b/app/javascript/flavours/blobfox/features/ui/components/video_modal.jsx new file mode 100644 index 00000000000000..7bed641cd968c1 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/video_modal.jsx @@ -0,0 +1,74 @@ +import PropTypes from 'prop-types'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { getAverageFromBlurhash } from 'flavours/blobfox/blurhash'; +import Footer from 'flavours/blobfox/features/picture_in_picture/components/footer'; +import Video from 'flavours/blobfox/features/video'; + +const mapStateToProps = (state, { statusId }) => ({ + status: state.getIn(['statuses', statusId]), +}); + +class VideoModal extends ImmutablePureComponent { + + static propTypes = { + media: ImmutablePropTypes.map.isRequired, + statusId: PropTypes.string, + status: ImmutablePropTypes.map, + 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, status, onClose } = this.props; + const options = this.props.options || {}; + const language = status.getIn(['translation', 'language']) || status.get('language'); + const description = media.getIn(['translation', 'description']) || media.get('description'); + + return ( + <div className='modal-root__modal video-modal'> + <div className='video-modal__container'> + <Video + preview={media.get('preview_url')} + frameRate={media.getIn(['meta', 'original', 'frame_rate'])} + blurhash={media.get('blurhash')} + src={media.get('url')} + currentTime={options.startTime} + autoPlay={options.autoPlay} + volume={options.defaultVolume} + onCloseVideo={onClose} + autoFocus + detailed + alt={description} + lang={language} + /> + </div> + + <div className='media-modal__overlay'> + {status && <Footer statusId={status.get('id')} withOpenButton onClose={onClose} />} + </div> + </div> + ); + } + +} + +export default connect(mapStateToProps, null, null, { forwardRef: true })(VideoModal); diff --git a/app/javascript/flavours/blobfox/features/ui/components/zoomable_image.jsx b/app/javascript/flavours/blobfox/features/ui/components/zoomable_image.jsx new file mode 100644 index 00000000000000..7ec3a3d6cd7f5a --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/components/zoomable_image.jsx @@ -0,0 +1,456 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import { IconButton } from 'flavours/blobfox/components/icon_button'; + +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, + }; +}; + +class ZoomableImage extends PureComponent { + + static propTypes = { + alt: PropTypes.string, + lang: 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: '', + lang: '', + 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, lang, 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 ( + <> + <IconButton + className={`media-modal__zoom-button ${zoomButtonShouldHide}`} + title={zoomButtonTitle} + icon={this.state.zoomState} + onClick={this.handleZoomClick} + size={40} + style={{ + fontSize: '30px', /* Fontawesome's fa-compress fa-expand is larger than fa-close */ + }} + /> + <div + className='zoomable-image' + ref={this.setContainerRef} + style={{ overflow }} + > + <img + role='presentation' + ref={this.setImageRef} + alt={alt} + title={alt} + lang={lang} + src={src} + width={width} + height={height} + style={{ + transform: `scale(${scale}) translate(-${lockTranslate.x}px, -${lockTranslate.y}px)`, + transformOrigin: '0 0', + }} + draggable={false} + onClick={this.handleClick} + onMouseDown={this.handleMouseDown} + /> + </div> + </> + ); + } + +} + +export default injectIntl(ZoomableImage); diff --git a/app/javascript/flavours/blobfox/features/ui/containers/bundle_container.js b/app/javascript/flavours/blobfox/features/ui/containers/bundle_container.js new file mode 100644 index 00000000000000..6a476fe2483cf3 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/containers/bundle_container.js @@ -0,0 +1,18 @@ +import { connect } from 'react-redux'; + +import { fetchBundleRequest, fetchBundleSuccess, fetchBundleFail } from '../../../actions/bundles'; +import Bundle from '../components/bundle'; + +const mapDispatchToProps = dispatch => ({ + onFetch () { + dispatch(fetchBundleRequest()); + }, + onFetchSuccess () { + dispatch(fetchBundleSuccess()); + }, + onFetchFail (error) { + dispatch(fetchBundleFail(error)); + }, +}); + +export default connect(null, mapDispatchToProps)(Bundle); diff --git a/app/javascript/flavours/blobfox/features/ui/containers/columns_area_container.js b/app/javascript/flavours/blobfox/features/ui/containers/columns_area_container.js new file mode 100644 index 00000000000000..76c9a3e28cc13b --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/containers/columns_area_container.js @@ -0,0 +1,22 @@ +import { connect } from 'react-redux'; + +import { openModal } from 'flavours/blobfox/actions/modal'; + +import ColumnsArea from '../components/columns_area'; + +const mapStateToProps = state => ({ + columns: state.getIn(['settings', 'columns']), +}); + +const mapDispatchToProps = dispatch => ({ + openSettings (e) { + e.preventDefault(); + e.stopPropagation(); + dispatch(openModal({ + modalType: 'SETTINGS', + modalProps: {}, + })); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true })(ColumnsArea); diff --git a/app/javascript/flavours/blobfox/features/ui/containers/loading_bar_container.js b/app/javascript/flavours/blobfox/features/ui/containers/loading_bar_container.js new file mode 100644 index 00000000000000..7efdac55ee393f --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/containers/loading_bar_container.js @@ -0,0 +1,9 @@ +import { connect } from 'react-redux'; + +import LoadingBar from 'react-redux-loading-bar'; + +const mapStateToProps = (state, ownProps) => ({ + loading: state.get('loadingBar')[ownProps.scope || 'default'], +}); + +export default connect(mapStateToProps)(LoadingBar.WrappedComponent); diff --git a/app/javascript/flavours/blobfox/features/ui/containers/modal_container.js b/app/javascript/flavours/blobfox/features/ui/containers/modal_container.js new file mode 100644 index 00000000000000..1c3872cd50436f --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/containers/modal_container.js @@ -0,0 +1,36 @@ +import { connect } from 'react-redux'; + +import { openModal, closeModal } from '../../../actions/modal'; +import ModalRoot from '../components/modal_root'; + +const mapStateToProps = state => ({ + ignoreFocus: state.getIn(['modal', 'ignoreFocus']), + type: state.getIn(['modal', 'stack', 0, 'modalType'], null), + props: state.getIn(['modal', 'stack', 0, 'modalProps'], {}), +}); + +const mapDispatchToProps = dispatch => ({ + onClose (confirmationMessage, ignoreFocus = false) { + if (confirmationMessage) { + dispatch( + openModal({ + modalType: 'CONFIRM', + modalProps: { + message: confirmationMessage.message, + confirm: confirmationMessage.confirm, + onConfirm: () => dispatch(closeModal({ + modalType: undefined, + ignoreFocus: { ignoreFocus }, + })), + } }), + ); + } else { + dispatch(closeModal({ + modalType: undefined, + ignoreFocus: { ignoreFocus }, + })); + } + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot); diff --git a/app/javascript/flavours/blobfox/features/ui/containers/notifications_container.js b/app/javascript/flavours/blobfox/features/ui/containers/notifications_container.js new file mode 100644 index 00000000000000..3d60cfdad1b24a --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/containers/notifications_container.js @@ -0,0 +1,33 @@ +import { injectIntl } from 'react-intl'; + +import { connect } from 'react-redux'; + +import { NotificationStack } from 'react-notification'; + +import { dismissAlert } from '../../../actions/alerts'; +import { getAlerts } from '../../../selectors'; + +const formatIfNeeded = (intl, message, values) => { + if (typeof message === 'object') { + return intl.formatMessage(message, values); + } + + return message; +}; + +const mapStateToProps = (state, { intl }) => ({ + notifications: getAlerts(state).map(alert => ({ + ...alert, + action: formatIfNeeded(intl, alert.action, alert.values), + title: formatIfNeeded(intl, alert.title, alert.values), + message: formatIfNeeded(intl, alert.message, alert.values), + })), +}); + +const mapDispatchToProps = (dispatch) => ({ + onDismiss (alert) { + dispatch(dismissAlert(alert)); + }, +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationStack)); diff --git a/app/javascript/flavours/blobfox/features/ui/containers/status_list_container.js b/app/javascript/flavours/blobfox/features/ui/containers/status_list_container.js new file mode 100644 index 00000000000000..f34d099b24015f --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/containers/status_list_container.js @@ -0,0 +1,89 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; + +import { debounce } from 'lodash'; + +import { scrollTopTimeline, loadPending } from '../../../actions/timelines'; +import StatusList from '../../../components/status_list'; +import { me } from '../../../initial_state'; + +const getRegex = createSelector([ + (state, { regex }) => regex, +], (rawRegex) => { + let regex = null; + + try { + regex = rawRegex && new RegExp(rawRegex.trim(), 'i'); + } catch (e) { + // Bad regex, don't affect filters + } + return regex; +}); + +const makeGetStatusIds = (pending = false) => createSelector([ + (state, { type }) => state.getIn(['settings', type], ImmutableMap()), + (state, { type }) => state.getIn(['timelines', type, pending ? 'pendingItems' : 'items'], ImmutableList()), + (state) => state.get('statuses'), + getRegex, +], (columnSettings, statusIds, statuses, regex) => { + return statusIds.filter(id => { + if (id === null) return true; + + const statusForId = statuses.get(id); + let showStatus = true; + + if (statusForId.get('account') === me) return true; + + if (columnSettings.getIn(['shows', 'reblog']) === false) { + showStatus = showStatus && statusForId.get('reblog') === null; + } + + if (columnSettings.getIn(['shows', 'reply']) === false) { + showStatus = showStatus && (statusForId.get('in_reply_to_id') === null || statusForId.get('in_reply_to_account_id') === me); + } + + if (columnSettings.getIn(['shows', 'direct']) === false) { + showStatus = showStatus && statusForId.get('visibility') !== 'direct'; + } + + if (showStatus && regex) { + const searchIndex = statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'search_index']) : statusForId.get('search_index'); + showStatus = !regex.test(searchIndex); + } + + return showStatus; + }); +}); + +const makeMapStateToProps = () => { + const getStatusIds = makeGetStatusIds(); + const getPendingStatusIds = makeGetStatusIds(true); + + const mapStateToProps = (state, { timelineId, regex }) => ({ + statusIds: getStatusIds(state, { type: timelineId, regex }), + lastId: state.getIn(['timelines', timelineId, 'items'])?.last(), + isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true), + isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false), + hasMore: state.getIn(['timelines', timelineId, 'hasMore']), + numPending: getPendingStatusIds(state, { type: timelineId }).size, + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { timelineId }) => ({ + + onScrollToTop: debounce(() => { + dispatch(scrollTopTimeline(timelineId, true)); + }, 100), + + onScroll: debounce(() => { + dispatch(scrollTopTimeline(timelineId, false)); + }, 100), + + onLoadPending: () => dispatch(loadPending(timelineId)), + +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList); diff --git a/app/javascript/flavours/blobfox/features/ui/index.jsx b/app/javascript/flavours/blobfox/features/ui/index.jsx new file mode 100644 index 00000000000000..dc27f86d4251e8 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/index.jsx @@ -0,0 +1,675 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; + +import classNames from 'classnames'; +import { Redirect, Route, withRouter } from 'react-router-dom'; + +import { connect } from 'react-redux'; + +import Favico from 'favico.js'; +import { debounce } from 'lodash'; +import { HotKeys } from 'react-hotkeys'; + +import { changeLayout } from 'flavours/blobfox/actions/app'; +import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/blobfox/actions/markers'; +import { INTRODUCTION_VERSION } from 'flavours/blobfox/actions/onboarding'; +import PermaLink from 'flavours/blobfox/components/permalink'; +import PictureInPicture from 'flavours/blobfox/features/picture_in_picture'; +import { layoutFromWindow } from 'flavours/blobfox/is_mobile'; +import { WithRouterPropTypes } from 'flavours/blobfox/utils/react_router'; + +import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose'; +import { clearHeight } from '../../actions/height_cache'; +import { expandNotifications, notificationsSetVisibility } from '../../actions/notifications'; +import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server'; +import { expandHomeTimeline } from '../../actions/timelines'; +import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding } from '../../initial_state'; + +import BundleColumnError from './components/bundle_column_error'; +import Header from './components/header'; +import UploadArea from './components/upload_area'; +import ColumnsAreaContainer from './containers/columns_area_container'; +import LoadingBarContainer from './containers/loading_bar_container'; +import ModalContainer from './containers/modal_container'; +import NotificationsContainer from './containers/notifications_container'; +import { + Compose, + Status, + GettingStarted, + KeyboardShortcuts, + Firehose, + AccountTimeline, + AccountGallery, + HomeTimeline, + Followers, + Following, + Reblogs, + Favourites, + DirectTimeline, + HashtagTimeline, + Notifications, + FollowRequests, + FavouritedStatuses, + BookmarkedStatuses, + FollowedTags, + ListTimeline, + Blocks, + DomainBlocks, + Mutes, + PinnedStatuses, + Lists, + GettingStartedMisc, + Directory, + Explore, + Onboarding, + About, + PrivacyPolicy, +} from './util/async-components'; +import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; + +// Dummy import, to make sure that <Status /> 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']), + 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, + isWide: state.getIn(['local_settings', 'stretch']), + dropdownMenuIsOpen: state.dropdownMenu.openId !== null, + unreadNotifications: state.getIn(['notifications', 'unread']), + showFaviconBadge: state.getIn(['local_settings', 'notifications', 'favicon_badge']), + hicolorPrivacyIcons: state.getIn(['local_settings', 'hicolor_privacy_icons']), + moved: state.getIn(['accounts', me, 'moved']) && state.getIn(['accounts', state.getIn(['accounts', me, 'moved'])]), + 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', + bookmark: 'd', + toggleCollapse: 'shift+x', + toggleSensitive: 'h', + openMedia: 'e', +}; + +class SwitchingColumnsArea extends PureComponent { + + static contextTypes = { + identity: PropTypes.object, + }; + + static propTypes = { + children: PropTypes.node, + location: PropTypes.object, + singleColumn: PropTypes.bool, + }; + + UNSAFE_componentWillMount () { + if (this.props.singleColumn) { + 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.singleColumn !== this.props.singleColumn) { + document.body.classList.toggle('layout-single-column', this.props.singleColumn); + document.body.classList.toggle('layout-multiple-columns', !this.props.singleColumn); + } + } + + setRef = c => { + if (c) { + this.node = c; + } + }; + + render () { + const { children, singleColumn } = this.props; + const { signedIn } = this.context.identity; + const pathName = this.props.location.pathname; + + let redirect; + + if (signedIn) { + if (singleColumn) { + redirect = <Redirect from='/' to='/home' exact />; + } else { + redirect = <Redirect from='/' to='/deck/getting-started' exact />; + } + } else if (singleUserMode && owner && initialState?.accounts[owner]) { + redirect = <Redirect from='/' to={`/@${initialState.accounts[owner].username}`} exact />; + } else if (trendsEnabled && trendsAsLanding) { + redirect = <Redirect from='/' to='/explore' exact />; + } else { + redirect = <Redirect from='/' to='/about' exact />; + } + + return ( + <ColumnsAreaContainer ref={this.setRef} singleColumn={singleColumn}> + <WrappedSwitch> + {redirect} + + {singleColumn ? <Redirect from='/deck' to='/home' exact /> : null} + {singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={pathName.slice(5)} /> : null} + {/* Redirect old bookmarks (without /deck) with home-like routes to the advanced interface */} + {!singleColumn && pathName === '/getting-started' ? <Redirect from='/getting-started' to='/deck/getting-started' exact /> : null} + {!singleColumn && pathName === '/home' ? <Redirect from='/home' to='/deck/getting-started' exact /> : null} + + <WrappedRoute path='/getting-started' component={GettingStarted} content={children} /> + <WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} /> + <WrappedRoute path='/about' component={About} content={children} /> + <WrappedRoute path='/privacy-policy' component={PrivacyPolicy} content={children} /> + + <WrappedRoute path={['/home', '/timelines/home']} component={HomeTimeline} content={children} /> + <Redirect from='/timelines/public' to='/public' exact /> + <Redirect from='/timelines/public/local' to='/public/local' exact /> + <WrappedRoute path='/public' exact component={Firehose} componentParams={{ feedType: 'public' }} content={children} /> + <WrappedRoute path='/public/local' exact component={Firehose} componentParams={{ feedType: 'community' }} content={children} /> + <WrappedRoute path='/public/remote' exact component={Firehose} componentParams={{ feedType: 'public:remote' }} content={children} /> + <WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} /> + <WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} /> + <WrappedRoute path='/lists/:id' component={ListTimeline} content={children} /> + <WrappedRoute path='/notifications' component={Notifications} content={children} /> + <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} /> + + <WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} /> + <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} /> + + <WrappedRoute path='/start' exact component={Onboarding} content={children} /> + <WrappedRoute path='/directory' component={Directory} content={children} /> + <WrappedRoute path={['/explore', '/search']} component={Explore} content={children} /> + <WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} /> + + <WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} /> + <WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} content={children} /> + <WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} /> + <WrappedRoute path={['/accounts/:id/followers', '/users/:acct/followers', '/@:acct/followers']} component={Followers} content={children} /> + <WrappedRoute path={['/accounts/:id/following', '/users/:acct/following', '/@:acct/following']} component={Following} content={children} /> + <WrappedRoute path={['/@:acct/media', '/accounts/:id/media']} component={AccountGallery} content={children} /> + <WrappedRoute path='/@:acct/:statusId' exact component={Status} content={children} /> + <WrappedRoute path='/@:acct/:statusId/reblogs' component={Reblogs} content={children} /> + <WrappedRoute path='/@:acct/:statusId/favourites' component={Favourites} content={children} /> + + {/* Legacy routes, cannot be easily factored with other routes because they share a param name */} + <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} /> + <WrappedRoute path='/timelines/list/:id' component={ListTimeline} content={children} /> + <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} /> + <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} /> + <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} /> + + <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} /> + <WrappedRoute path='/blocks' component={Blocks} content={children} /> + <WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} /> + <WrappedRoute path='/followed_tags' component={FollowedTags} content={children} /> + <WrappedRoute path='/mutes' component={Mutes} content={children} /> + <WrappedRoute path='/lists' component={Lists} content={children} /> + <WrappedRoute path='/getting-started-misc' component={GettingStartedMisc} content={children} /> + + <Route component={BundleColumnError} /> + </WrappedSwitch> + </ColumnsAreaContainer> + ); + } + +} + +class UI extends PureComponent { + + static contextTypes = { + identity: PropTypes.object.isRequired, + }; + + static propTypes = { + dispatch: PropTypes.func.isRequired, + children: PropTypes.node, + isWide: PropTypes.bool, + systemFontUi: PropTypes.bool, + isComposing: PropTypes.bool, + hasComposingText: PropTypes.bool, + hasMediaAttachments: PropTypes.bool, + canUploadMore: PropTypes.bool, + intl: PropTypes.object.isRequired, + dropdownMenuIsOpen: PropTypes.bool, + unreadNotifications: PropTypes.number, + showFaviconBadge: PropTypes.bool, + hicolorPrivacyIcons: PropTypes.bool, + moved: PropTypes.map, + layout: PropTypes.string.isRequired, + firstLaunch: PropTypes.bool, + username: PropTypes.string, + ...WithRouterPropTypes, + }; + + state = { + draggingOver: false, + }; + + handleBeforeUnload = e => { + const { intl, dispatch, hasComposingText, hasMediaAttachments } = this.props; + + dispatch(synchronouslySubmitMarkers()); + + if (hasComposingText || hasMediaAttachments) { + // 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); + } + }; + + handleVisibilityChange = () => { + const visibility = !document[this.visibilityHiddenProp]; + this.props.dispatch(notificationsSetVisibility(visibility)); + if (visibility) { + this.props.dispatch(submitMarkers({ immediate: true })); + } + }; + + 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.props.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('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); + } + + this.favicon = new Favico({ animation:'none' }); + + if (signedIn) { + this.props.dispatch(fetchMarkers()); + this.props.dispatch(expandHomeTimeline()); + this.props.dispatch(expandNotifications()); + this.props.dispatch(fetchServerTranslationLanguages()); + + setTimeout(() => this.props.dispatch(fetchServer()), 3000); + } + + this.hotkeys.__mousetrap__.stopCallback = (e, element) => { + return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName); + }; + + if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support + this.visibilityHiddenProp = 'hidden'; + this.visibilityChange = 'visibilitychange'; + } else if (typeof document.msHidden !== 'undefined') { + this.visibilityHiddenProp = 'msHidden'; + this.visibilityChange = 'msvisibilitychange'; + } else if (typeof document.webkitHidden !== 'undefined') { + this.visibilityHiddenProp = 'webkitHidden'; + this.visibilityChange = 'webkitvisibilitychange'; + } + + if (this.visibilityChange !== undefined) { + document.addEventListener(this.visibilityChange, this.handleVisibilityChange, false); + this.handleVisibilityChange(); + } + } + + componentDidUpdate (prevProps) { + if (this.props.unreadNotifications !== prevProps.unreadNotifications || + this.props.showFaviconBadge !== prevProps.showFaviconBadge) { + if (this.favicon) { + try { + this.favicon.badge(this.props.showFaviconBadge ? this.props.unreadNotifications : 0); + } catch (err) { + console.error(err); + } + } + } + } + + componentWillUnmount () { + if (this.visibilityChange !== undefined) { + document.removeEventListener(this.visibilityChange, this.handleVisibilityChange); + } + + 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 = () => { + const { history } = this.props; + + if (history.location?.state?.fromMastodon) { + history.goBack(); + } else { + history.push('/'); + } + }; + + setHotkeysRef = c => { + this.hotkeys = c; + }; + + handleHotkeyToggleHelp = () => { + if (this.props.location.pathname === '/keyboard-shortcuts') { + this.props.history.goBack(); + } else { + this.props.history.push('/keyboard-shortcuts'); + } + }; + + handleHotkeyGoToHome = () => { + this.props.history.push('/home'); + }; + + handleHotkeyGoToNotifications = () => { + this.props.history.push('/notifications'); + }; + + handleHotkeyGoToLocal = () => { + this.props.history.push('/public/local'); + }; + + handleHotkeyGoToFederated = () => { + this.props.history.push('/public'); + }; + + handleHotkeyGoToDirect = () => { + this.props.history.push('/conversations'); + }; + + handleHotkeyGoToStart = () => { + this.props.history.push('/getting-started'); + }; + + handleHotkeyGoToFavourites = () => { + this.props.history.push('/favourites'); + }; + + handleHotkeyGoToPinned = () => { + this.props.history.push('/pinned'); + }; + + handleHotkeyGoToProfile = () => { + this.props.history.push(`/@${this.props.username}`); + }; + + handleHotkeyGoToBlocked = () => { + this.props.history.push('/blocks'); + }; + + handleHotkeyGoToMuted = () => { + this.props.history.push('/mutes'); + }; + + handleHotkeyGoToRequests = () => { + this.props.history.push('/follow_requests'); + }; + + render () { + const { draggingOver } = this.state; + const { children, isWide, location, dropdownMenuIsOpen, layout, moved } = this.props; + + const columnsClass = layout => { + switch (layout) { + case 'single': + return 'single-column'; + case 'multiple': + return 'multi-columns'; + default: + return 'auto-columns'; + } + }; + + const className = classNames('ui', columnsClass(layout), { + 'wide': isWide, + 'system-font': this.props.systemFontUi, + 'hicolor-privacy-icons': this.props.hicolorPrivacyIcons, + }); + + 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 ( + <HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef} attach={window} focused> + <div className={className} ref={this.setRef} style={{ pointerEvents: dropdownMenuIsOpen ? 'none' : null }}> + {moved && (<div className='flash-message alert'> + <FormattedMessage + id='moved_to_warning' + defaultMessage='This account is marked as moved to {moved_to_link}, and may thus not accept new follows.' + values={{ moved_to_link: ( + <PermaLink href={moved.get('url')} to={`/@${moved.get('acct')}`}> + @{moved.get('acct')} + </PermaLink> + ) }} + /> + </div>)} + + <Header /> + + <SwitchingColumnsArea location={location} singleColumn={layout === 'mobile' || layout === 'single-column'}> + {children} + </SwitchingColumnsArea> + + {layout !== 'mobile' && <PictureInPicture />} + <NotificationsContainer /> + <LoadingBarContainer className='loading-bar' /> + <ModalContainer /> + <UploadArea active={draggingOver} onClose={this.closeUploadModal} /> + </div> + </HotKeys> + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(withRouter(UI))); diff --git a/app/javascript/flavours/blobfox/features/ui/util/async-components.js b/app/javascript/flavours/blobfox/features/ui/util/async-components.js new file mode 100644 index 00000000000000..c2b4042e5a4ed4 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/util/async-components.js @@ -0,0 +1,203 @@ +export function EmojiPicker () { + return import(/* webpackChunkName: "flavours/blobfox/async/emoji_picker" */'flavours/blobfox/features/emoji/emoji_picker'); +} + +export function Compose () { + return import(/* webpackChunkName: "flavours/blobfox/async/compose" */'flavours/blobfox/features/compose'); +} + +export function Notifications () { + return import(/* webpackChunkName: "flavours/blobfox/async/notifications" */'flavours/blobfox/features/notifications'); +} + +export function HomeTimeline () { + return import(/* webpackChunkName: "flavours/blobfox/async/home_timeline" */'flavours/blobfox/features/home_timeline'); +} + +export function PublicTimeline () { + return import(/* webpackChunkName: "flavours/blobfox/async/public_timeline" */'flavours/blobfox/features/public_timeline'); +} + +export function CommunityTimeline () { + return import(/* webpackChunkName: "flavours/blobfox/async/community_timeline" */'flavours/blobfox/features/community_timeline'); +} + +export function Firehose () { + return import(/* webpackChunkName: "flavours/blobfox/async/firehose" */'../../firehose'); +} + +export function HashtagTimeline () { + return import(/* webpackChunkName: "flavours/blobfox/async/hashtag_timeline" */'flavours/blobfox/features/hashtag_timeline'); +} + +export function ListTimeline () { + return import(/* webpackChunkName: "flavours/blobfox/async/list_timeline" */'flavours/blobfox/features/list_timeline'); +} + +export function Lists () { + return import(/* webpackChunkName: "flavours/blobfox/async/lists" */'flavours/blobfox/features/lists'); +} + +export function ListEditor () { + return import(/* webpackChunkName: "flavours/blobfox/async/list_editor" */'flavours/blobfox/features/list_editor'); +} + +export function PinnedAccountsEditor () { + return import(/* webpackChunkName: "flavours/blobfox/async/pinned_accounts_editor" */'flavours/blobfox/features/pinned_accounts_editor'); +} + +export function DirectTimeline() { + return import(/* webpackChunkName: "flavours/blobfox/async/direct_timeline" */'flavours/blobfox/features/direct_timeline'); +} + +export function Status () { + return import(/* webpackChunkName: "flavours/blobfox/async/status" */'flavours/blobfox/features/status'); +} + +export function GettingStarted () { + return import(/* webpackChunkName: "flavours/blobfox/async/getting_started" */'flavours/blobfox/features/getting_started'); +} + +export function KeyboardShortcuts () { + return import(/* webpackChunkName: "flavours/blobfox/async/keyboard_shortcuts" */'flavours/blobfox/features/keyboard_shortcuts'); +} + +export function PinnedStatuses () { + return import(/* webpackChunkName: "flavours/blobfox/async/pinned_statuses" */'flavours/blobfox/features/pinned_statuses'); +} + +export function AccountTimeline () { + return import(/* webpackChunkName: "flavours/blobfox/async/account_timeline" */'flavours/blobfox/features/account_timeline'); +} + +export function AccountGallery () { + return import(/* webpackChunkName: "flavours/blobfox/async/account_gallery" */'flavours/blobfox/features/account_gallery'); +} + +export function Followers () { + return import(/* webpackChunkName: "flavours/blobfox/async/followers" */'flavours/blobfox/features/followers'); +} + +export function Following () { + return import(/* webpackChunkName: "flavours/blobfox/async/following" */'flavours/blobfox/features/following'); +} + +export function Reblogs () { + return import(/* webpackChunkName: "flavours/blobfox/async/reblogs" */'flavours/blobfox/features/reblogs'); +} + +export function Favourites () { + return import(/* webpackChunkName: "flavours/blobfox/async/favourites" */'flavours/blobfox/features/favourites'); +} + +export function FollowRequests () { + return import(/* webpackChunkName: "flavours/blobfox/async/follow_requests" */'flavours/blobfox/features/follow_requests'); +} + +export function FavouritedStatuses () { + return import(/* webpackChunkName: "flavours/blobfox/async/favourited_statuses" */'flavours/blobfox/features/favourited_statuses'); +} + +export function FollowedTags () { + return import(/* webpackChunkName: "flavours/blobfox/async/followed_tags" */'flavours/blobfox/features/followed_tags'); +} + +export function BookmarkedStatuses () { + return import(/* webpackChunkName: "flavours/blobfox/async/bookmarked_statuses" */'flavours/blobfox/features/bookmarked_statuses'); +} + +export function Blocks () { + return import(/* webpackChunkName: "flavours/blobfox/async/blocks" */'flavours/blobfox/features/blocks'); +} + +export function DomainBlocks () { + return import(/* webpackChunkName: "flavours/blobfox/async/domain_blocks" */'flavours/blobfox/features/domain_blocks'); +} + +export function Mutes () { + return import(/* webpackChunkName: "flavours/blobfox/async/mutes" */'flavours/blobfox/features/mutes'); +} + +export function MuteModal () { + return import(/* webpackChunkName: "flavours/blobfox/async/mute_modal" */'flavours/blobfox/features/ui/components/mute_modal'); +} + +export function BlockModal () { + return import(/* webpackChunkName: "flavours/blobfox/async/block_modal" */'flavours/blobfox/features/ui/components/block_modal'); +} + +export function ReportModal () { + return import(/* webpackChunkName: "flavours/blobfox/async/report_modal" */'flavours/blobfox/features/ui/components/report_modal'); +} + +export function SettingsModal () { + return import(/* webpackChunkName: "flavours/blobfox/async/settings_modal" */'flavours/blobfox/features/local_settings'); +} + +export function MediaGallery () { + return import(/* webpackChunkName: "flavours/blobfox/async/media_gallery" */'flavours/blobfox/components/media_gallery'); +} + +export function Video () { + return import(/* webpackChunkName: "flavours/blobfox/async/video" */'flavours/blobfox/features/video'); +} + +export function Audio () { + return import(/* webpackChunkName: "features/blobfox/async/audio" */'flavours/blobfox/features/audio'); +} + +export function EmbedModal () { + return import(/* webpackChunkName: "flavours/blobfox/async/embed_modal" */'flavours/blobfox/features/ui/components/embed_modal'); +} + +export function GettingStartedMisc () { + return import(/* webpackChunkName: "flavours/blobfox/async/getting_started_misc" */'flavours/blobfox/features/getting_started_misc'); +} + +export function ListAdder () { + return import(/* webpackChunkName: "features/blobfox/async/list_adder" */'flavours/blobfox/features/list_adder'); +} + +export function Tesseract () { + return import(/*webpackChunkName: "tesseract" */'tesseract.js'); +} + +export function Directory () { + return import(/* webpackChunkName: "features/blobfox/async/directory" */'flavours/blobfox/features/directory'); +} + +export function Onboarding () { + return import(/* webpackChunkName: "features/blobfox/async/onboarding" */'flavours/blobfox/features/onboarding'); +} + +export function CompareHistoryModal () { + return import(/*webpackChunkName: "flavours/blobfox/async/compare_history_modal" */'flavours/blobfox/features/ui/components/compare_history_modal'); +} + +export function FilterModal () { + return import(/*webpackChunkName: "flavours/blobfox/async/filter_modal" */'flavours/blobfox/features/ui/components/filter_modal'); +} + +export function Explore () { + return import(/* webpackChunkName: "flavours/blobfox/async/explore" */'flavours/blobfox/features/explore'); +} + +export function InteractionModal () { + return import(/*webpackChunkName: "flavours/blobfox/async/modals/interaction_modal" */'flavours/blobfox/features/interaction_modal'); +} + +export function SubscribedLanguagesModal () { + return import(/*webpackChunkName: "flavours/blobfox/async/modals/subscribed_languages_modal" */'flavours/blobfox/features/subscribed_languages_modal'); +} + +export function ClosedRegistrationsModal () { + return import(/*webpackChunkName: "flavours/blobfox/async/modals/closed_registrations_modal" */'flavours/blobfox/features/closed_registrations_modal'); +} + +export function About () { + return import(/*webpackChunkName: "features/blobfox/async/about" */'flavours/blobfox/features/about'); +} + +export function PrivacyPolicy () { + return import(/*webpackChunkName: "features/blobfox/async/privacy_policy" */'flavours/blobfox/features/privacy_policy'); +} diff --git a/app/javascript/flavours/blobfox/features/ui/util/fullscreen.js b/app/javascript/flavours/blobfox/features/ui/util/fullscreen.js new file mode 100644 index 00000000000000..cf5d0cf98d0cd6 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/util/fullscreen.js @@ -0,0 +1,46 @@ +// APIs for normalizing fullscreen operations. Note that Edge uses +// the WebKit-prefixed APIs currently (as of Edge 16). + +export const isFullscreen = () => document.fullscreenElement || + document.webkitFullscreenElement || + document.mozFullScreenElement; + +export const exitFullscreen = () => { + if (document.exitFullscreen) { + document.exitFullscreen(); + } else if (document.webkitExitFullscreen) { + document.webkitExitFullscreen(); + } else if (document.mozCancelFullScreen) { + document.mozCancelFullScreen(); + } +}; + +export const requestFullscreen = el => { + if (el.requestFullscreen) { + el.requestFullscreen(); + } else if (el.webkitRequestFullscreen) { + el.webkitRequestFullscreen(); + } else if (el.mozRequestFullScreen) { + el.mozRequestFullScreen(); + } +}; + +export const attachFullscreenListener = (listener) => { + if ('onfullscreenchange' in document) { + document.addEventListener('fullscreenchange', listener); + } else if ('onwebkitfullscreenchange' in document) { + document.addEventListener('webkitfullscreenchange', listener); + } else if ('onmozfullscreenchange' in document) { + document.addEventListener('mozfullscreenchange', listener); + } +}; + +export const detachFullscreenListener = (listener) => { + if ('onfullscreenchange' in document) { + document.removeEventListener('fullscreenchange', listener); + } else if ('onwebkitfullscreenchange' in document) { + document.removeEventListener('webkitfullscreenchange', listener); + } else if ('onmozfullscreenchange' in document) { + document.removeEventListener('mozfullscreenchange', listener); + } +}; diff --git a/app/javascript/flavours/blobfox/features/ui/util/get_rect_from_entry.js b/app/javascript/flavours/blobfox/features/ui/util/get_rect_from_entry.js new file mode 100644 index 00000000000000..c266cd7dce7374 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/util/get_rect_from_entry.js @@ -0,0 +1,21 @@ + +// Get the bounding client rect from an IntersectionObserver entry. +// This is to work around a bug in Chrome: https://crbug.com/737228 + +let hasBoundingRectBug; + +function getRectFromEntry(entry) { + if (typeof hasBoundingRectBug !== 'boolean') { + const boundingRect = entry.target.getBoundingClientRect(); + const observerRect = entry.boundingClientRect; + hasBoundingRectBug = boundingRect.height !== observerRect.height || + boundingRect.top !== observerRect.top || + boundingRect.width !== observerRect.width || + boundingRect.bottom !== observerRect.bottom || + boundingRect.left !== observerRect.left || + boundingRect.right !== observerRect.right; + } + return hasBoundingRectBug ? entry.target.getBoundingClientRect() : entry.boundingClientRect; +} + +export default getRectFromEntry; diff --git a/app/javascript/flavours/blobfox/features/ui/util/intersection_observer_wrapper.js b/app/javascript/flavours/blobfox/features/ui/util/intersection_observer_wrapper.js new file mode 100644 index 00000000000000..2b24c65831d87c --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/util/intersection_observer_wrapper.js @@ -0,0 +1,57 @@ +// Wrapper for IntersectionObserver in order to make working with it +// a bit easier. We also follow this performance advice: +// "If you need to observe multiple elements, it is both possible and +// advised to observe multiple elements using the same IntersectionObserver +// instance by calling observe() multiple times." +// https://developers.google.com/web/updates/2016/04/intersectionobserver + +class IntersectionObserverWrapper { + + callbacks = {}; + observerBacklog = []; + observer = null; + + connect (options) { + const onIntersection = (entries) => { + entries.forEach(entry => { + const id = entry.target.getAttribute('data-id'); + if (this.callbacks[id]) { + this.callbacks[id](entry); + } + }); + }; + + this.observer = new IntersectionObserver(onIntersection, options); + this.observerBacklog.forEach(([ id, node, callback ]) => { + this.observe(id, node, callback); + }); + this.observerBacklog = null; + } + + observe (id, node, callback) { + if (!this.observer) { + this.observerBacklog.push([ id, node, callback ]); + } else { + this.callbacks[id] = callback; + this.observer.observe(node); + } + } + + unobserve (id, node) { + if (this.observer) { + delete this.callbacks[id]; + this.observer.unobserve(node); + } + } + + disconnect () { + if (this.observer) { + this.callbacks = {}; + this.observer.disconnect(); + this.observer = null; + } + } + +} + +export default IntersectionObserverWrapper; diff --git a/app/javascript/flavours/blobfox/features/ui/util/optional_motion.js b/app/javascript/flavours/blobfox/features/ui/util/optional_motion.js new file mode 100644 index 00000000000000..0b6d4d97f7967f --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/util/optional_motion.js @@ -0,0 +1,7 @@ +import Motion from 'react-motion/lib/Motion'; + +import { reduceMotion } from '../../../initial_state'; + +import ReducedMotion from './reduced_motion'; + +export default reduceMotion ? ReducedMotion : Motion; diff --git a/app/javascript/flavours/blobfox/features/ui/util/react_router_helpers.jsx b/app/javascript/flavours/blobfox/features/ui/util/react_router_helpers.jsx new file mode 100644 index 00000000000000..c0ee31bf68041b --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/util/react_router_helpers.jsx @@ -0,0 +1,105 @@ +import PropTypes from 'prop-types'; +import { Component, cloneElement, Children } from 'react'; + +import { Switch, Route, useLocation } from 'react-router-dom'; + +import StackTrace from 'stacktrace-js'; + +import BundleColumnError from '../components/bundle_column_error'; +import ColumnLoading from '../components/column_loading'; +import BundleContainer from '../containers/bundle_container'; + +// Small wrapper to pass multiColumn to the route components +export const WrappedSwitch = ({ multiColumn, children }) => { + const location = useLocation(); + + const decklessLocation = multiColumn && location.pathname.startsWith('/deck') + ? {...location, pathname: location.pathname.slice(5)} + : location; + + return ( + <Switch location={decklessLocation}> + {Children.map(children, child => child ? cloneElement(child, { multiColumn }) : null)} + </Switch> + ); +}; + + +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 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 ( + <BundleColumnError + stacktrace={stacktrace} + multiColumn={multiColumn} + errorType='error' + /> + ); + } + + return ( + <BundleContainer fetchComponent={component} loading={this.renderLoading} error={this.renderError}> + {Component => <Component params={match.params} multiColumn={multiColumn} {...componentParams}>{content}</Component>} + </BundleContainer> + ); + }; + + renderLoading = () => { + const { multiColumn } = this.props; + + return <ColumnLoading multiColumn={multiColumn} />; + }; + + renderError = (props) => { + return <BundleColumnError {...props} errorType='network' />; + }; + + render () { + const { component: Component, content, ...rest } = this.props; + + return <Route {...rest} render={this.renderComponent} />; + } + +} diff --git a/app/javascript/flavours/blobfox/features/ui/util/reduced_motion.jsx b/app/javascript/flavours/blobfox/features/ui/util/reduced_motion.jsx new file mode 100644 index 00000000000000..fd044497f80279 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/util/reduced_motion.jsx @@ -0,0 +1,45 @@ +// Like react-motion's Motion, but reduces all animations to cross-fades +// for the benefit of users with motion sickness. +import PropTypes from 'prop-types'; +import { Component } from 'react'; + +import Motion from 'react-motion/lib/Motion'; + +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 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 ( + <Motion style={style} defaultStyle={defaultStyle}> + {children} + </Motion> + ); + } + +} + +export default ReducedMotion; diff --git a/app/javascript/flavours/blobfox/features/ui/util/schedule_idle_task.js b/app/javascript/flavours/blobfox/features/ui/util/schedule_idle_task.js new file mode 100644 index 00000000000000..b04d4a8eefa3b7 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/ui/util/schedule_idle_task.js @@ -0,0 +1,29 @@ +// Wrapper to call requestIdleCallback() to schedule low-priority work. +// See https://developer.mozilla.org/en-US/docs/Web/API/Background_Tasks_API +// for a good breakdown of the concepts behind this. + +import Queue from 'tiny-queue'; + +const taskQueue = new Queue(); +let runningRequestIdleCallback = false; + +function runTasks(deadline) { + while (taskQueue.length && deadline.timeRemaining() > 0) { + taskQueue.shift()(); + } + if (taskQueue.length) { + requestIdleCallback(runTasks); + } else { + runningRequestIdleCallback = false; + } +} + +function scheduleIdleTask(task) { + taskQueue.push(task); + if (!runningRequestIdleCallback) { + runningRequestIdleCallback = true; + requestIdleCallback(runTasks); + } +} + +export default scheduleIdleTask; diff --git a/app/javascript/flavours/blobfox/features/video/index.jsx b/app/javascript/flavours/blobfox/features/video/index.jsx new file mode 100644 index 00000000000000..7fb70f33c135d1 --- /dev/null +++ b/app/javascript/flavours/blobfox/features/video/index.jsx @@ -0,0 +1,665 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import { is } from 'immutable'; + +import { throttle } from 'lodash'; + +import { Blurhash } from 'flavours/blobfox/components/blurhash'; +import { Icon } from 'flavours/blobfox/components/icon'; +import { playerSettings } from 'flavours/blobfox/settings'; + +import { displayMedia, useBlurhash } from '../../initial_state'; +import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen'; + +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); +}; + +class Video extends PureComponent { + + static propTypes = { + preview: PropTypes.string, + frameRate: PropTypes.string, + src: PropTypes.string.isRequired, + alt: PropTypes.string, + lang: PropTypes.string, + sensitive: PropTypes.bool, + currentTime: PropTypes.number, + onOpenVideo: PropTypes.func, + onCloseVideo: PropTypes.func, + detailed: PropTypes.bool, + inline: PropTypes.bool, + editable: PropTypes.bool, + alwaysVisible: PropTypes.bool, + visible: PropTypes.bool, + letterbox: PropTypes.bool, + fullwidth: PropTypes.bool, + preventPlayback: 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, + 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; + }; + + 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((state) => ({ volume: x, muted: state.muted && x === 0 }), () => { + this._syncVideoToVolumeState(x); + this._saveVolumeState(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); + + this._syncVideoFromLocalStorage(); + } + + componentWillUnmount () { + window.removeEventListener('scroll', this.handleScroll); + + 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, + }); + } + } + + UNSAFE_componentWillReceiveProps (nextProps) { + if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) { + this.setState({ revealed: nextProps.visible }); + } + } + + componentDidUpdate (prevProps) { + if (this.video && this.state.revealed && this.props.preventPlayback && !prevProps.preventPlayback) { + this.video.pause(); + } + } + + 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.state.volume === 0); + + this.setState((state) => ({ muted, volume: Math.max(state.volume || 0.5, 0.05) }), () => { + this._syncVideoToVolumeState(); + this._saveVolumeState(); + }); + }; + + _syncVideoToVolumeState = (volume = null, muted = null) => { + if (!this.video) { + return; + } + + this.video.volume = volume ?? this.state.volume; + this.video.muted = muted ?? this.state.muted; + }; + + _saveVolumeState = (volume = null, muted = null) => { + playerSettings.set('volume', volume ?? this.state.volume); + playerSettings.set('muted', muted ?? this.state.muted); + }; + + _syncVideoFromLocalStorage = () => { + this.setState({ volume: playerSettings.get('volume') ?? 0.5, muted: playerSettings.get('muted') ?? false }, () => { + this._syncVideoToVolumeState(); + }); + }; + + toggleReveal = () => { + if (this.state.revealed) { + this.setState({ paused: true }); + } + + 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 }); + this._saveVolumeState(this.video.volume, this.video.muted); + }; + + handleOpenVideo = () => { + this.video.pause(); + + this.props.onOpenVideo(this.props.lang, { + 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, lang, letterbox, fullwidth, detailed, sensitive, editable, blurhash, autoFocus } = this.props; + const { currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, revealed } = this.state; + const progress = Math.min((currentTime / duration) * 100, 100); + const muted = this.state.muted || volume === 0; + + const playerStyle = {}; + + if (inline) { + playerStyle.aspectRatio = '16 / 9'; + } + + let preload; + + if (this.props.currentTime || fullscreen || dragging) { + preload = 'auto'; + } else if (detailed) { + preload = 'metadata'; + } else { + preload = 'none'; + } + + let warning; + + if (sensitive) { + warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />; + } else { + warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />; + } + + return ( + <div + role='menuitem' + className={classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, editable, letterbox, 'full-width': fullwidth })} + style={playerStyle} + ref={this.setPlayerRef} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + onClick={this.handleClickRoot} + onKeyDown={this.handleKeyDown} + tabIndex={0} + > + <Blurhash + hash={blurhash} + className={classNames('media-gallery__preview', { + 'media-gallery__preview--hidden': revealed, + })} + dummy={!useBlurhash} + /> + + {(revealed || editable) && <video + ref={this.setVideoRef} + src={src} + poster={preview} + preload={preload} + role='button' + tabIndex={0} + aria-label={alt} + title={alt} + lang={lang} + onClick={this.togglePlay} + onKeyDown={this.handleVideoKeyDown} + onPlay={this.handlePlay} + onPause={this.handlePause} + onLoadedData={this.handleLoadedData} + onProgress={this.handleProgress} + onVolumeChange={this.handleVolumeChange} + style={{ ...playerStyle, width: '100%' }} + />} + + <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}> + <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}> + <span className='spoiler-button__overlay__label'> + {warning} + <span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span> + </span> + </button> + </div> + + <div className={classNames('video-player__controls', { active: paused || hovered })}> + <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}> + <div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} /> + <div className='video-player__seek__progress' style={{ width: `${progress}%` }} /> + + <span + className={classNames('video-player__seek__handle', { active: dragging })} + tabIndex={0} + style={{ left: `${progress}%` }} + onKeyDown={this.handleVideoKeyDown} + /> + </div> + + <div className='video-player__buttons-bar'> + <div className='video-player__buttons left'> + <button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} className='player-button' onClick={this.togglePlay} autoFocus={autoFocus}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button> + <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button> + + <div className={classNames('video-player__volume', { active: this.state.hovered })} onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}> + <div className='video-player__volume__current' style={{ width: `${muted ? 0 : volume * 100}%` }} /> + + <span + className={classNames('video-player__volume__handle')} + tabIndex={0} + style={{ left: `${muted ? 0 : volume * 100}%` }} + /> + </div> + + {(detailed || fullscreen) && ( + <span className='video-player__time'> + <span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span> + <span className='video-player__time-sep'>/</span> + <span className='video-player__time-total'>{formatTime(Math.floor(duration))}</span> + </span> + )} + </div> + + <div className='video-player__buttons right'> + {(!onCloseVideo && !editable && !fullscreen && !this.props.alwaysVisible) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>} + {(!fullscreen && onOpenVideo) && <button type='button' title={intl.formatMessage(messages.expand)} aria-label={intl.formatMessage(messages.expand)} className='player-button' onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>} + {onCloseVideo && <button type='button' title={intl.formatMessage(messages.close)} aria-label={intl.formatMessage(messages.close)} className='player-button' onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>} + <button type='button' title={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} className='player-button' onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button> + </div> + </div> + </div> + </div> + ); + } + +} + +export default injectIntl(Video); diff --git a/app/javascript/flavours/blobfox/hooks/useHovering.ts b/app/javascript/flavours/blobfox/hooks/useHovering.ts new file mode 100644 index 00000000000000..2062e70d26ac98 --- /dev/null +++ b/app/javascript/flavours/blobfox/hooks/useHovering.ts @@ -0,0 +1,17 @@ +import { useCallback, useState } from 'react'; + +export const useHovering = (animate?: boolean) => { + const [hovering, setHovering] = useState<boolean>(animate ?? false); + + const handleMouseEnter = useCallback(() => { + if (animate) return; + setHovering(true); + }, [animate]); + + const handleMouseLeave = useCallback(() => { + if (animate) return; + setHovering(false); + }, [animate]); + + return { hovering, handleMouseEnter, handleMouseLeave }; +}; diff --git a/app/javascript/flavours/blobfox/images/elephant_ui_disappointed.svg b/app/javascript/flavours/blobfox/images/elephant_ui_disappointed.svg new file mode 100644 index 00000000000000..580c15a138838e --- /dev/null +++ b/app/javascript/flavours/blobfox/images/elephant_ui_disappointed.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" height="134.11569" width="134.61565" viewBox="0 0 134.61565 134.11569"><path d="M82.69963 103.86569c6.8 1.5 11 2.4 11.3-6.200005.3-8.6-1.8-17.3-1.8-17.3l-13.6 1.1 4.1 22.400005z" class="st32" fill="#3a434e" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M65.39963 112.96569c-.2 10.3-.6 17.5 6.5 17.4 7.1-.1 12.6 1.1 13.6-5.3 1.1-6.3 1.9-20.6.7-28.000005" class="st32" fill="#3a434e" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M86.39963 97.66569c-1.4-7.5-4.1-23.2-4.1-23.2s13.2-1.5 10.4-13c-2.7-11.4-7.5-22.6-11-31.1s-14.5-16.9-28.6-15.7c-19.2 1.6-25.6 7-31.6 23.1-5.4 14.4-10.4 47.2-8.9 63.3.8 8.7 5 13.7 14.4 13.5 9.4-.2 39.8-.8 49.8-2.8.3-.1.6-.1.9-.2" class="st33" fill="#56606b"/><path d="M85.89963 97.76569l-4.1-23.2c0-.3.1-.5.4-.6 2.6-.4 5.3-1.4 7.3-3.1 1-.8 1.9-1.9 2.4-3.1.5-1.2.7-2.5.6-3.9 0-1.3-.4-2.6-.7-4-.3-1.3-.7-2.7-1.1-4-.8-2.7-1.7-5.3-2.6-7.9-1.9-5.2-4-10.4-6.1-15.5-.5-1.3-1-2.6-1.7-3.8-.6-1.2-1.4-2.3-2.3-3.4-1.7-2.1-3.8-4-6-5.5-4.6-3-10-4.7-15.4-4.9-2.7-.1-5.5.3-8.2.6-2.7.4-5.5.9-8.1 1.7-2.6.8-5.1 1.9-7.3 3.5s-4.1 3.6-5.6 5.8c-1.5 2.3-2.8 4.7-3.9 7.3-.6 1.3-1.1 2.5-1.6 3.8-.4 1.3-.9 2.6-1.3 3.9-1.6 5.3-2.8 10.7-3.9 16.1-1 5.4-1.9 10.9-2.6 16.4-.7 5.5-1.2 11-1.3 16.6-.1 2.8-.1 5.5.1 8.3.1 2.8.5 5.5 1.6 8 1 2.5 2.9 4.6 5.4 5.7 2.4 1.1 5.2 1.3 8 1.3 5.6-.1 11.1-.2 16.7-.4 11.1-.4 22.2-.8 33.2-2.3.1 0 .2.1.2.2s0 .2-.1.2c-2.7.9-5.5 1.2-8.3 1.4-2.8.2-5.6.5-8.3.6-5.6.3-11.1.6-16.7.7-5.6.2-11.1.3-16.7.4-2.8.1-5.7-.1-8.4-1.3s-4.7-3.5-5.8-6.2c-1.1-2.6-1.5-5.5-1.6-8.3-.2-2.8-.2-5.6-.1-8.4.2-5.6.7-11.1 1.3-16.7.7-5.5 1.5-11 2.6-16.5s2.3-10.9 3.9-16.3c.4-1.3.9-2.7 1.3-4 .5-1.3 1-2.6 1.6-3.9 1.1-2.6 2.4-5.1 4-7.4 1.6-2.3 3.6-4.4 5.9-6.1 2.3-1.7 4.9-2.8 7.6-3.7 2.7-.8 5.5-1.4 8.2-1.7 2.8-.3 5.5-.7 8.4-.6 5.6.2 11.2 1.9 15.9 5 2.4 1.6 4.5 3.5 6.3 5.7.9 1.1 1.7 2.3 2.4 3.5.7 1.3 1.2 2.6 1.7 3.9 2.1 5.1 4.2 10.3 6.1 15.5.9 2.6 1.8 5.3 2.6 7.9.4 1.3.8 2.7 1.1 4 .3 1.3.7 2.7.8 4.2.1 1.4-.1 2.9-.7 4.3s-1.5 2.5-2.6 3.5c-2.3 1.9-5 2.8-7.9 3.3l.4-.6 4.1 23.2c0 .3-.1.5-.4.6-.3.1-.7.5-.7.2z"/><path d="M26.49963 114.06569c-4.7 0-7.4-2.1-10-4.4-2.3-2-3.2-4.6-3.4-8.6-.1-2.700005-.6-10.000005.4-18.800005 3.8.9 9.7 3.8 13.4 7.6 5.6 5.7 17.7 6.3 22.7 6.3h1.8l.1-.4s.5-2.6 1.8-5.2l.3-.6-.7-.1c-.4-.1-10.9-1.9-9.7-10.8.7-4.9 13.3-7.9 33.9-7.9 2.2 0 3.8 0 4.2.1l3.5 2.2c-1.5.5-2.6.6-2.6.6l-.5.1.1.5c0 .2 2.8 16.4 4.1 24 0 0-7.9 13.100005-8 13.000005-.1-.1-.3-.1-.3-.1-.3 0-.7.1-.9.1-9.9 1.7-39.6 2.4-49.3 2.6l-.9-.2z" class="st34" fill="#3a434e"/><path d="M45.89963 51.36569c-.7 0-1.4-.6-1.4-1.4v-5.1c0-.7.6-1.4 1.4-1.4.7 0 1.4.6 1.4 1.4v5.1c-.1.8-.7 1.4-1.4 1.4z"/><path d="M72.89963 30.365685c-3.5.4-2.7 2.9-1.2 3.5 1.5.6 3.7.1 4.3-1.6.4-1.6-1.3-2.1-3.1-1.9z" class="st35" fill="#4f5862"/><path d="M44.29963 53.965685c-.4.7-1.5.2-2.7-.6-1.2-.8-2.1-1.5-1.6-2.2.4-.7 1.6-.4 2.8.4 1.2.8 2 1.7 1.5 2.4z" class="st34" fill="#4f5862"/><path d="M27.29963 36.165685c0-5.6-3.7-9.4-7.9-9.8-4.2-.4-9-.3-14.0000002 11.3-5.00000001 11.6-6.7 15.7-2.6 17.9 4.1 2.2 9.5000002 1.5 11.3000002-1.4 0 0 5.3 3.8 9.7-3.8" class="st36" fill="#56606b" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M11.19963 40.565685c-2.7000002 5.1-2.7000002 7.7-.5 8.5 2.2.8 4.1.7 6.4-3 0 0 2 .7 4.9-4.1.9-1.5-.7-2.6-.7-2.6s-4.8 1.3-7.1-5l-3 6.2z" class="st34" fill="#3a434e" fill-opacity="0"/><path d="M9.7996298 43.365685l4.4000002-9s1.8 6.3 7.8 4.9" class="st7" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M27.89963 67.365685c-4.9.8-9.7 4.5-9.3 15.7.4 11.2.5 18.700005 6.1 20.000005 5.5 1.3 13.8.3 14.1-7.100005.3-7.4.3-16.1.3-16.1" class="st36" fill="#53606c" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M28.69963 102.96569c-1.4 0-2.8-.2-4.1-.5-1.2-.3-2.2-.9-3-2 .5.2 1.1.3 1.7.3 1.2 0 5.2-.5 5.8-7.200005.7-7.4 2.8-10.9 6.6-10.9.8 0 1.6.1 2.6.4 0 3.4-.1 8.3-.2 12.7-.2 6.700005-7.2 7.200005-9.4 7.200005z" class="st34" fill="#3a434e"/><path d="M50.69963 18.965685c-5.2 2.9-14.6 4.7-18.1-1.5-3-5.4 2.1-9.6999996 7.8-9.9999996 5.7-.3 7.6 1.2 7.6 1.2s1.9-5.9 9.3-7.69999998c3.9-1 6-.1 6.2 1.19999998 0 0 3.6-.9 4 3.5 0 0 3.9-.4 3.1 5.1999996-.8 5.6-10.6 10.1-17.7 6.4 0 0-1.1 1.2-2.2 1.7z" class="st33" fill="#56606b"/><path d="M40.79963 21.665685c-2.7 0-4.8-.9-6.3-2.3-.7-1.1-.8-2.9-.3-4.3.8-1.9 2.6-3.3 4.6-3.7 1.2-.2 2.6-.4 3.9-.4 3.3 0 6.2.8 7.3 1.9l.6.6.3-.7s.7-2 2.2-2c.2 0 .5.1.8.2 2.2.9 3.5 1.2 4.6 1.2.5 0 .9-.1 1.3-.2.1-.1.4-.1.6-.1.6 0 1.5.3 1.8.8.2.3.2.6.1 1l-.2.6h.7c.4 0 1.4.2 1.8.9.2.4.2 1-.2 1.7-1.8.8-3.8 1.2-5.7 1.2-2 0-4 0-5.6-.8 0 0-1.2 1.3-2.2 1.8-3.1 1.6-7 2.6-10.1 2.6z" class="st34" fill="#3a434e" fill-opacity=".94117647"/><path d="M61.79963 18.66569c-3.1.5-6.3.1-8.9-1.5.7.2 1.5.4 2.2.5.7.2 1.4.2 2.2.3.7.1 1.4 0 2.2 0 .7-.1 1.4-.1 2.2-.2h.1c.3 0 .5.1.6.4-.1.2-.3.5-.6.5z"/><path d="M37.59963 21.26569c-2.4-.4-4.8-2.1-5.7-4.5-.5-1.2-.7-2.6-.3-3.9.3-1.3 1.1-2.4 2.1-3.3 2-1.7 4.6-2.5 7.1-2.6 1.3-.1 2.5 0 3.8.1.6.1 1.3.2 1.9.4.6.2 1.2.4 1.9.8l-.8.2c.6-1.6 1.6-3 2.8-4.2 1.2-1.2 2.6-2.2 4.1-2.9 1.5-.7 3.2-1.1 4.8-1.3.8-.1 1.7-.1 2.6.1.4.1.9.3 1.3.6s.7.8.8 1.3l-.6-.4c.6-.1 1.2-.1 1.7 0 .6.1 1.1.4 1.6.8.4.4.7.9.9 1.5.1.3.2.5.2.8l.1.8-.5-.4c1 0 1.9.3 2.6.9.7.7 1 1.6 1.1 2.5.1.9 0 1.7-.1 2.5-.2.9-.5 1.7-1 2.4-.9 1.4-2.2 2.5-3.7 3.4-1.4.9-3 1.4-4.6 1.8-.3.1-.5-.1-.6-.4-.1-.3.1-.5.4-.6 1.5-.3 3-.9 4.3-1.7 1.3-.8 2.5-1.8 3.3-3.1.4-.6.7-1.3.8-2 .1-.7.2-1.5.1-2.2-.1-.7-.3-1.4-.8-1.9-.5-.4-1.2-.7-1.8-.7-.3 0-.5-.2-.5-.4l-.1-.7c-.1-.2-.1-.4-.2-.7-.2-.4-.4-.8-.7-1.1-.3-.3-.7-.5-1.1-.6-.4-.1-.9-.1-1.3 0-.3.1-.5-.1-.6-.4-.1-.5-.7-.9-1.4-1.1-.7-.2-1.5-.2-2.2-.1-1.5.2-3.1.6-4.5 1.2-1.4.7-2.7 1.6-3.8 2.7-1.1 1.1-2 2.5-2.5 3.8-.1.3-.4.4-.6.3h-.1c-.4-.2-1-.5-1.5-.6-.6-.1-1.2-.2-1.7-.3-1.2-.1-2.4-.2-3.6-.1-2.4.1-4.7.8-6.5 2.3-.9.7-1.6 1.7-1.9 2.8-.3 1.1-.2 2.3.2 3.4s1.1 2.1 1.9 2.9c.6.9 1.7 1.5 2.9 1.9z"/><path d="M63.49963 2.1656854c0 3.5-2.6 5.5-4.3 6.1m8.3-2.6c.2 3.4-3.3 5.1999996-3.3 5.1999996" class="st7" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M90.29963 84.765685c2.6 2.3 3 4.3-2.4 4.8-5.3.5-25.7 2.4-28.2 2.6-2.4.3-3.4 1.7-3.4 2.8 0 1.1.5 3.2 4 3.1 3.4-.1 23.8-1.5 30.4-2.4 6.6-.8 14.4-2.4 13.4-9s-5.4-8.7-5.4-8.7l-8.4 6.8z" class="st37" fill="#737039" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M90.29963 84.765685c2.6 2.3 3 4.3-2.4 4.8-5.3.5-25.7 2.4-28.2 2.6-2.4.3-3.4 1.7-3.4 2.8 0 1.1.5 3.2 4 3.1 3.4-.1 23.8-1.5 30.4-2.4 6.6-.8 13.8-2.3 13.4-9-.3-5.5-3.1-7-4.4-8.1-.5-.1-1-.1-1.6-.2l-7.8 6.4z" fill="#625d28" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M102.69963 64.665685c5.4-.1 10.3-1.9 12.2-6.5 1.9-4.6 8.7-10.1 14.2-2.1 5.4 8.1 6.6 17.3 2.8 23.7-3.8 6.5-12.1 3.5-14.9-.5-2.7-4-8.6-2.9-14.5-2.7-5.9.2.2-11.9.2-11.9z" class="st37" fill="#737039" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M65.89963 54.865685s10.2 21.3 13.5 26.8c3.2 5.5 12.9 6.2 17.4 3.5 4.5-2.7 7.3-7.3 8-15.1.7-7.9-2.4-14.9-10-15.2-7.6-.3-11.9 7.6-12.1 13.7" class="st36" fill="#53606c" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M65.89963 54.865685s10.2 21.3 13.5 26.8c3.2 5.5 12.9 6.2 17.4 3.5 4.5-2.7 7.3-7.3 8-15.1.7-7.9-2.4-14.9-10-15.2-7.6-.3-11.9 7.6-12.1 13.7" class="st36" fill="#56606b" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M90.19963 86.165685c-3.7 0-8.3-1.3-10.4-4.8-.9-1.5-2.4-4.3-4.4-8.4l5.9-.1c4 7.4 5.9 9.8 8 9.8 3.9 0 6-3.4 6.9-9.5.2-1.2.3-2.3.4-3.4.5-4.6.9-7.2 3.4-7.5.3 0 .6-.1.9-.1 2.1 0 2.5 1.2 3.1 2.8.1.2.2.5.2.7.1 1.3.1 2.7 0 4.2-.7 7.3-3.1 11.9-7.7 14.7-1.6 1-3.9 1.6-6.3 1.6z" class="st34" fill="#3a434e"/><path d="M89.19963 63.86569l-.3 6.6c-.1 1.1-.2 2.2-.4 3.3-.1.6-.3 1.1-.5 1.7-.3.5-.6 1.1-1.2 1.5-.2.1-.5.1-.7-.2-.1-.2-.1-.5.2-.7.7-.4 1.1-1.5 1.3-2.5.2-1 .4-2.1.5-3.2.2-2.2.3-4.4.5-6.6 0-.2.2-.3.3-.3.2.1.3.3.3.4z"/><path d="M52.29963 68.665685c-6.3.6-11.1 3.9-10 10.7 1.1 6.8 7.6 8.1 16 7.7 8.4-.4 26.4-1.3 26.4-1.3s-3.3-1.7-4.8-3.3c-.5-.6-1-1.4-1.6-2.5-1.6.1-15.5.8-22.7 1-3.4.1-3.8-1.2-3.9-1.8-.3-1.2.5-2.7 2.8-2.8 3.1-.2 10.8-.7 21.4-.7h11.5s.9-.3 1-9.1c0-.1-29.8 1.5-36.1 2.1z" class="st37" fill="#737039" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M56.19963 86.665685c-8.8 0-12.6-2.3-13.5-7.4 0-.1 0-.2-.1-.4 1.2 1.5 3.5 2.7 5.6 2.7 1.4 0 2.6-.5 3.6-1.5.5.7 1.4 1.4 3.7 1.4h.4c6.9-.2 19.5-.8 22.1-.9.6 1.1 1.2 1.9 1.6 2.4.9 1 2.4 1.9 3.6 2.5-5 .3-18.1.9-24.7 1.2h-2.3z" class="st39" fill="#625d28"/><path d="M44.09963 57.865685c-2.2-.6-5.8-8.3-8.7-8.7-2.9-.3-6.6 1.6-3.2 8.5 3.4 6.9 8 10 14.3 8.2 6.3-1.8 12.7-5.1 14.5-8.3 1.8-3.2-.6-6.2-4.8-4.3-4.1 1.7-9.9 5.2-12.1 4.6z" fill="#b3bfcd" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M43.09963 65.865685c-4.3 0-7.7-2.7-10.4-8.4-1.4-2.8-1.7-5.1-.8-6.4.8-1.3 2.3-1.4 2.9-1.4h.6c.4 0 .8.2 1.2.6-.7.1-1.3.5-1.6 1.2-.6 1.2-.3 2.9.9 4.7l.4.6c2.1 3.1 4.1 6 7.8 6 .9 0 1.9-.2 2.9-.6 5.6-2 9.4-3.6 11.1-5.4 1.2-1.3 1.9-2.6 1.7-3.6.5.2.9.5 1.1.9.5.8.4 1.9-.2 3-1.6 2.8-7.4 6.2-14.2 8.1-1.2.5-2.3.7-3.4.7z" class="st35" fill="#93a1b5"/><path d="M13.89963 107.66569c-.1 5.1 1.3 10.2 2.3 14.8 1.3 5.5 1.3 10.1 5.2 10.7 3.9.6 10.1.9 14.4 0 4.3-.9 4.1-5.2 4.5-8.2.4-3.1 0-10.7 0-10.7s-1.1-1.4-3-1.9" class="st34" fill="#3a434e"/><path d="M14.39963 107.66569c-.1 5.2 1.3 10.3 2.5 15.4l.8 3.9c.3 1.3.6 2.5 1.1 3.6.3.5.6 1 1.1 1.4.5.3 1 .5 1.6.6 1.3.2 2.6.3 3.9.4 2.6.2 5.2.2 7.8 0 1.3-.1 2.6-.3 3.7-.7 1.1-.4 1.9-1.4 2.3-2.6.4-1.2.5-2.4.7-3.7.1-.7.2-1.3.2-1.9.1-.6.1-1.3.1-1.9 0-2.6 0-5.2-.2-7.8l.1.3c-.1-.2-.3-.4-.5-.6l-.6-.6c-.4-.4-.9-.7-1.5-.9-.1 0-.1-.1-.1-.2s.1-.1.2-.1c.6.1 1.2.3 1.7.6.3.1.5.3.8.5.3.2.5.4.8.6.1.1.1.2.1.2.1 2.6.2 5.3.2 7.9 0 .7 0 1.3-.1 2s-.2 1.3-.2 2c-.1 1.3-.3 2.7-.7 4-.5 1.3-1.5 2.6-2.8 3.1-1.4.6-2.7.7-4 .8-2.7.2-5.3.2-8 0-1.3-.1-2.6-.2-4-.4-.7-.1-1.4-.4-2-.8-.6-.5-1-1.1-1.4-1.7-.6-1.3-.9-2.6-1.2-3.9l-.8-3.9c-1.1-5.1-2.6-10.3-2.5-15.6 0-.3.2-.5.5-.5.2 0 .4.2.4.5z"/><path d="M68.19963 86.665685l.4 4.6s.3 1.5 2.4 1.5c2.1-.1 2.2-2 2.2-2l-.1-4.5-4.9.4z" class="st37" fill="#737039" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M110.49963 71.465685c-.5 1.8.5 2.9 3.8 4.6 3.3 1.8 4 5.1 8.2 6 4.3.9 8.2-4.5 3.8-10.1-4.5-5.6-14.1-6.5-15.8-.5z" class="st39" fill="#625d28"/><circle r="1.7" cy="57.765686" cx="126.09963" fill="#99988c"/><path d="M17.39963 115.26569s.8 3.9 1.1 6.3c.3 2.4.9 3.8 5.9 3.2 5-.6 4.9-1.5 5.1-6.4.2-4.9-.1-7.4-3.7-7.6-3.6-.2-9.1.7-8.4 4.5z" class="st33" fill="#56606b"/><path fill="#3a434e" class="st34" d="M11.19963 40.565685c-2.7000002 5.1-2.7000002 7.7-.5 8.5 2.2.8 4.1.7 6.4-3 0 0 2 .7 4.9-4.1.9-1.5-.7-2.6-.7-2.6s-4.8 1.3-7.1-5l-3 6.2z"/></svg> \ No newline at end of file diff --git a/app/javascript/flavours/blobfox/images/elephant_ui_working.svg b/app/javascript/flavours/blobfox/images/elephant_ui_working.svg new file mode 100644 index 00000000000000..8ba475db0a07c4 --- /dev/null +++ b/app/javascript/flavours/blobfox/images/elephant_ui_working.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 124.12477 127.91685" width="124.12476" height="127.91685"><path d="M72.584191 46.815676c-2.3-2.2-4.2-2.5-6.6-.6-2.4 1.9-2.1 4.8.9 7.6 3.1 2.9 4.7 4.1 6.7 5 2.1.9 5.4 2.5 10.5-2s10.2-11.1 9.4-14.7c-.8-3.6-4.1-1.8-6.8 1.2s-3.7 4-5.4 5.2c-1.5 1.3-3.8 3-8.7-1.7z" class="st0" style="fill:#93a1b5;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;fill-opacity:1"/><path d="M116.384191 75.015676c0 6.3-3.9 9.8-9.1 9.8-5.3 0-9.9-3.5-9.9-9.8 0-6.3 4.3-10.3 9.5-10.3s9.5 4 9.5 10.3z" style="fill:#3a434e;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;fill-opacity:1"/><path d="M54.184191 16.615676c-23 1.2-30.5 14.1-32.8 27.8-3 18.2-8.2 44.2-9.2 53.2s-1 16 6 22 11 5 23 7 19 0 20-8l16.8-1.1s14.5 5.5 18.8 6.9c4.3 1.4 10.6.5 12.1-7.1s.2-12.5-6.6-14.4c-6.8-1.9-10.6-2.9-10.6-2.9l4.4-30.1s17.4 1.6 22.6-20c0 0 3.9 1.1 4.8-2.8.9-3.9-2.6-6.2-5.6-4.8l-2.5-1s-.2-3.8-3.5-4.2c-2.1-.2-6 3.4-3 7.4 0 0-3.4 8.9-12 7.8-8.6-1.1-12.5-11.2-15-18.2s-10.7-18.3-27.7-17.5z" class="st2" style="fill:#56606b;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;fill-opacity:1"/><path d="M95.484191 69.915676c-.6 0-1.2 0-1.8-.1-4.2-.2-10.9-2.4-17-7.8-.7-1-.4-2-.2-2.5.5-1.1 1.7-1.8 3-1.8.8 0 1.5.3 2.2.8 3 2.2 7.8 5.1 13.8 5.1.9 0 1.8-.1 2.7-.2 7.2-1 12.1-5.8 14.3-9.9.6-1.2 1.3-2.5 1.8-3.5.2-.5.3-.7.6-.9.3-.2 1 .2 1 .2l2.1.8c-4.5 17.8-17.4 19.2-21.2 19.2h-1.3v.6z" class="st3" style="fill:#3a434e;opacity:.98;fill-opacity:1"/><path d="M48.884191 126.915676c-2.2 0-4.7-.2-7.6-.7-2.8-.5-5.1-.8-7.1-1-6.9-.9-10.3-1.3-15.6-5.9-7-6-6.8-13-5.8-21.6.3-2.3.8-5.9 1.7-11.2 3.1 1.4 6.1 2.2 8.7 2.2 3.1 0 5.4-1.2 6.6-3.4 1.6 1.9 6.9 7.3 13.3 7.3 1 0 1.9-.1 2.8-.4 3.5-1 19.8-2.1 46.9-3.4l-1.7 11.7.4.1s3.8 1 10.6 2.9c6.1 1.7 7.9 5.6 6.3 13.8-1.3 6.6-6.2 7.3-8.2 7.3-1.1 0-2.2-.2-3.3-.5-4.2-1.4-18.6-6.8-18.7-6.9h-.1l-17.3 1.2-.1.4c-.7 5.4-4.5 8.1-11.8 8.1z" class="st3" style="fill:#3a434e;fill-opacity:1"/><path d="M41.184191 103.415676c-3.8-1.4-6-1.4-7.7-1.4-1.8 0-4.6 3.3 1.4 5.4 6 2.1 10.3 3.4 10.3 3.4s1.8-2.1 3.5-2.9c1.6-.8 2.3-.9 2.3-.9l-9.8-3.6z" style="fill:#56606b;fill-opacity:1"/><path d="M27.584191 38.615676c1.2-5-2.1-8.2-5.7-9.2-3.5-1-8.4-1.7-13.9 6.9s-9.5 16.5-6.4 20.6c3.1 4.1 9.3 3.4 11.8-.8 0 0 5.7 3.8 9.5-4.2" class="st2" style="fill:#56606b;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;fill-opacity:1"/><path d="M10.884191 45.115676c-1.6 2.5-.8 5 2 5.9 2.7 1 5-1.5 6.5-3.8 1.6-2.3 3.6-5.9 3.6-5.9s-3.7 1.2-5.6-.2c-2-1.4-1.5-3.8-1.5-3.8l-5 7.8z" class="st3" style="fill:#3a434e;fill-opacity:1"/><path d="M22.684191 41.415676c-2.6 1.1-6.8.6-6.9-4.1 0 0-5.1 7.6-5.9 9.6" class="st5" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/><path d="M67.584191 5.215676c0-3.4-3.9-2.6-3.9-2.6 0-1.2-2-3.5-8.5-.6-6.4 2.9-7.3 6-7.3 6-3.8-1.7-9.6-2.6-13.5.8-3.9 3.4-4.3 10 2.3 13.5 0 0 2.9.9 7.7-.4 4.8-1.3 7.7-3.3 7.7-3.3s3.7 2.3 9 .6c5.3-1.7 9.9-4.5 10.3-10.1.5-5.7-3.8-3.9-3.8-3.9z" style="fill:#56606b;fill-opacity:1"/><path d="M67.084191 16.315676c-1-5.5-7-3-7-3 .5-2.1-3-4.1-5.5-2.7-2.5 1.4-6.6-.1-6.6-.1-6.4-4.4-14.3-2.1-16.1 2-1.1 3.3.2 7.3 4.8 9.7 0 0 2.9.9 7.7-.4 4.8-1.3 7.7-3.3 7.7-3.3s3.7 2.3 9 .6c2.3-.6 4.3-1.5 6-2.8 0 .1 0 0 0 0z" style="fill:#3a434e;fill-opacity:1"/><path d="M36.684191 22.715676c-.1 0-.2 0-.2-.1-3.1-1.6-5-4.1-5.4-7-.3-2.7.8-5.4 3-7.3 3.9-3.3 9.5-2.8 13.6-1.1.5-1.1 2.2-3.5 7.3-5.8 4.5-2 6.9-1.5 8-.8.6.4.9.9 1.1 1.3.7-.1 2-.1 3 .7.5.4.9 1 1 1.8.7-.1 1.8-.2 2.7.4 1 .7 1.4 2.1 1.2 4.1-.4 5-3.9 8.5-10.7 10.6-5.5 1.7-9.3-.6-9.4-.7-.2-.1-.3-.5-.2-.7.1-.2.5-.3.7-.2 0 0 3.6 2.2 8.6.6 6.3-2 9.6-5.1 10-9.7.1-1.6-.2-2.8-.8-3.3-.9-.7-2.3-.1-2.3-.1-.2.1-.3 0-.5 0-.1-.1-.2-.2-.2-.4 0-.8-.2-1.3-.6-1.7-.9-.8-2.6-.4-2.7-.4-.1 0-.3 0-.4-.1-.1-.1-.2-.2-.2-.4 0-.3-.2-.7-.7-1-.6-.4-2.6-1.1-7.1.8-6.1 2.7-7 5.6-7 5.7 0 .1-.1.3-.3.3-.1.1-.3.1-.4 0-3.9-1.7-9.3-2.4-13 .8-1.9 1.7-2.9 4.1-2.6 6.4.3 2.5 2 4.7 4.8 6.2.2.1.3.4.2.7-.1.3-.3.4-.5.4z"/><path d="M40.584191 84.115676s6.3 16.8 7.1 19.3c.8 2.5 1.8 3.4 7.3 3 5.5-.4 6.7-21.5 6.7-21.5l-21.1-.8z" style="fill:#191b22;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;fill-opacity:1"/><path d="M51.084191 103.415676c-1.7-2.1-1.9-4.2-1.9-4.2l2-10.9 3-1.4-3.1 16.5zm33.9-35.3l-23.9 1.9 1.2 8 25.2 1.1 4.6-9c-2.3-.3-4.7-.9-7.1-2z" class="st9" style="fill:#191b22;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;fill-opacity:1"/><path d="M28.484191 82.915676c7.2 9.4 12.7 11.4 21.8 7.7 8.5-3.4 15.4-9 15.1-15-.3-6-2.1-10.3-9.1-9.8-2.3.2-6.8 2.8-9.6 4.4-1.8 1-4.2 2.2-6 .4-1.8-1.8-4.3-4.4-4.3-4.4" class="st2" style="fill:#56606b;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;fill-opacity:1"/><path d="M49.284191 80.515676c-.3-.2-.5-.4-.7-.6-1.2.7-2.3 1.3-2.8 1.6-2 1.2-3.8.5-4.7-.3-.9-.9-2.3-2.4-5.5-1.5-3.7 1-4.5 5.7-2.5 8.4 6.5 6.1 12.8 4.1 15.2 3.3 2.4-.8 6.3-2.7 6.3-2.7l.6-6c-2-.8-4.1-.9-5.9-2.2z" class="st3" style="fill:#3a434e;fill-opacity:1"/><path d="M28.484191 82.915676c7.2 9.4 12.7 11.4 21.8 7.7 8.5-3.4 15.4-9 15.1-15-.3-6-2.1-10.3-9.1-9.8-2.3.2-6.8 2.8-9.6 4.4-1.8 1-4.2 2.2-6 .4-1.8-1.8-4.3-4.4-4.3-4.4m35.4-8.6c6.5 8.3 15.5 12.5 21.8 12.7" class="st5" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/><path d="M53.184191 104.415676c-1.6.1-2.7-1.1-2.4-2.7l4.9-25.9c.3-1.6 1.9-3 3.6-3.1l26.9-1.7c1.6-.1 3.6-1.4 4.4-2.9l.7-1.3c.7-1.5 2.7-2.8 4.4-2.9l4.5-.3c1.6-.1 3.1 1.1 3.3 2.8v.2c.2 1.6.5 3.7.7 4.6.2.9.3 3 .1 4.6l-2.2 21.3c-.2 1.6-1.7 3.1-3.3 3.3l-45.6 4z" style="fill:#191b22;fill-opacity:1"/><path d="M53.184191 104.415676c-1.6.1-2.7-1.1-2.4-2.7l4.9-25.9c.3-1.6 1.9-3 3.6-3.1l26.9-1.7c1.6-.1 3.6-1.4 4.4-2.9l.7-1.3c.7-1.5 2.7-2.8 4.4-2.9l4.5-.3c1.6-.1 3.1 1.1 3.3 2.8v.2c.2 1.6.5 3.7.7 4.6.2.9.3 3 .1 4.6l-2.2 21.3c-.2 1.6-1.7 3.1-3.3 3.3l-45.6 4z" class="st5" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/><path d="M55.684191 105.915676c-1.6.1-2.3-.4-2-2l4.4-25.6c.3-1.6 1.9-3 3.6-3.1l26.9-1.7c1.6-.1 3.6-1.4 4.4-2.9l.7-1.3c.7-1.5 2.7-2.8 4.4-2.9l4.5-.3c1.6-.1 3.1 1.1 3.3 2.8v.2c.2 1.6 1.3 2.9 2.5 2.8 1.2-.1 1.9 1.2 1.6 2.8l-5.2 24.9c-.3 1.6-2 3.1-3.6 3.2l-45.5 3.1z" style="fill:#191b22;fill-opacity:1"/><path d="M53.184191 104.315676l4.9-26c.3-1.6 1.9-3 3.6-3.1l26.9-1.7c1.6-.1 3.6-1.4 4.4-2.9l.7-1.3c.7-1.5 2.7-2.8 4.4-2.9l4.5-.3c1.6-.1 3.1 1.1 3.3 2.8v.2c.2 1.6 1.3 2.9 2.5 2.8 1.2-.1 1.9 1.2 1.6 2.8l-5.2 24.9c-.3 1.6-2 3.1-3.6 3.2l-46.7 3.7m9.2-103.9c-.3 2.9-2.9 4.9-4.1 5.8m8-3.2c-.7 3.5-4 6-4 6" class="st5" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/><path d="M39.884191 53.615676c-2.3-2.2-4.2-2.5-6.6-.6-2.4 1.9-2.1 4.8.9 7.6 3.1 2.9 4.7 4.1 6.7 5 2 .9 5.4 2.5 10.5-2s10.2-11.1 9.4-14.7c-.8-3.6-4.1-1.8-6.8 1.2s-3.7 4-5.4 5.2c-1.7 1.2-3.8 3-8.7-1.7z" class="st0" style="fill:#93a1b5;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;fill-opacity:1"/><path d="M44.384191 61.315676c-2.3 0-4.7-1.2-6.6-3.1-.9-.9-1.1-2-.7-2.9.3-.8 1.1-1.3 1.8-1.3.2 0 .5 0 .7.1 2.3 2.1 4.2 3.2 5.9 3.2 1.6 0 2.7-.8 3.5-1.4 1.7-1.3 2.8-2.3 5.5-5.3.9-1.1 1.9-1.9 2.7-2.4.3.2.6.4.7.8.2.8-.2 2-.7 2.7-.9 1.3-4.9 5.4-9 8.4-1.1.7-2.4 1.2-3.8 1.2z" style="fill:#b3bfcd;fill-opacity:1"/><path d="M45.784191 50.115676c-.7 0-1.4-.6-1.4-1.4v-6.1c0-.7.6-1.4 1.4-1.4.7 0 1.4.6 1.4 1.4v6.1c0 .8-.6 1.4-1.4 1.4z"/><path d="M61.184191 118.215676c.7-7.1-3.5-10.6-9.2-11.1-5.7-.5-10.2 6.8-9.1 13.1 1.1 6.3 6.7 7.2 6.7 7.2" class="st5" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/><path d="M52.084191 107.515676c-2.2-.7-4.3-1.4-6.5-2.2-2.1-.8-4.3-1.5-6.4-2.3-.1 0-.2-.2-.1-.3 0-.1.2-.2.3-.2 2.2.6 4.4 1.3 6.5 1.9 2.2.7 4.3 1.3 6.5 2 .3.1.4.4.3.6-.1.4-.4.6-.6.5zm25.4 10.1c-.2-1.4-.2-2.9.1-4.2.2-1.4.6-2.7 1.1-4-.3 1.3-.5 2.7-.6 4.1 0 1.4.1 2.7.4 4 .1.3-.1.5-.3.6-.2.1-.6-.1-.7-.5 0 .1 0 .1 0 0z"/><path d="M104.284191 103.615676c-3.6-.7-8.5 2.1-9.5 9.7s2.1 10.7 5.3 11.6m15.1-83.5l-.39999 1.4m-1.90001-1.2c2.4 1.7 6.4 3.4 6.4 3.4m-1.6-2.6l-.60001 1.59999" class="st5" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/><path d="M47.484191 79.215676c3.2 2.7 7 3.3 7 3.3" style="fill:none;stroke:#000;stroke-miterlimit:10"/><path d="M69.284191 24.315676c-3.5.4-2.7 2.9-1.2 3.5 1.5.6 3.7.1 4.3-1.6.4-1.6-1.3-2.1-3.1-1.9z" style="fill:#4f5862;fill-opacity:1"/></svg> \ No newline at end of file diff --git a/app/javascript/flavours/blobfox/images/glitch-preview.jpg b/app/javascript/flavours/blobfox/images/glitch-preview.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fc5c4204359659e32ec8d758855891e297f5093c GIT binary patch literal 197277 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%0AZf$5*<oC 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 KIoqB2DEv269CTIy literal 0 HcmV?d00001 diff --git a/app/javascript/flavours/blobfox/images/logo_warn_glitch.svg b/app/javascript/flavours/blobfox/images/logo_warn_glitch.svg new file mode 100644 index 00000000000000..0fea63d2908969 --- /dev/null +++ b/app/javascript/flavours/blobfox/images/logo_warn_glitch.svg @@ -0,0 +1,49 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + viewBox="0 0 216.41507 232.00976" + version="1.1" + id="svg6" + sodipodi:docname="logo_warn_blobfox.svg" + inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg"> + <defs + id="defs10" /> + <sodipodi:namedview + id="namedview8" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageshadow="2" + inkscape:pageopacity="0.0" + inkscape:pagecheckerboard="0" + showgrid="false" + inkscape:zoom="1.7951831" + inkscape:cx="-30.916067" + inkscape:cy="90.241493" + inkscape:window-width="1920" + inkscape:window-height="1011" + inkscape:window-x="0" + inkscape:window-y="32" + inkscape:window-maximized="1" + inkscape:current-layer="svg6" /> + <g + id="g2025"> + <path + d="M211.80683 139.0875c-3.1825 16.36625-28.4925 34.2775-57.5625 37.74875-15.16 1.80875-30.0825 3.47125-45.99875 2.74125-26.0275-1.1925-46.565-6.2125-46.565-6.2125 0 2.53375.15625 4.94625.46875 7.2025 3.38375 25.68625 25.47 27.225 46.3925 27.9425 21.115.7225 39.91625-5.20625 39.91625-5.20625l.86875 19.09s-14.77 7.93125-41.08125 9.39c-14.50875.7975-32.52375-.365-53.50625-5.91875C9.23183 213.82 1.40558 165.31125.20808 116.09125c-.36375-14.61375-.14-28.39375-.14-39.91875 0-50.33 32.97625-65.0825 32.97625-65.0825C49.67058 3.45375 78.20308.2425 107.86433 0h.72875c29.66125.2425 58.21125 3.45375 74.8375 11.09 0 0 32.97625 14.7525 32.97625 65.0825 0 0 .4125 37.13375-4.6 62.915" + fill="#3088d4" + id="path2" /> + <path + d="m 124.52893,137.75645 c 0,9.01375 -7.30875,16.32125 -16.3225,16.32125 -9.01375,0 -16.32125,-7.3075 -16.32125,-16.32125 0,-9.01375 7.3075,-16.3225 16.32125,-16.3225 9.01375,0 16.3225,7.30875 16.3225,16.3225" + fill="#ffffff" + id="path4" + sodipodi:nodetypes="csssc" /> + <path + id="path1121" + d="m 108.20703,25.453125 c -9.013749,0 -16.322264,7.308516 -16.322264,16.322266 0,5.31808 2.555126,37.386806 6.492187,67.763669 4.100497,4.20028 15.890147,3.77063 19.660157,-0.01 3.9367,-30.375272 6.49219,-62.4364 6.49219,-67.753909 0,-9.01375 -7.30852,-16.322266 -16.32227,-16.322266 z" + style="fill:#ffffff" + sodipodi:nodetypes="ssccsss" /> + </g> +</svg> diff --git a/app/javascript/flavours/blobfox/images/mbstobon-ui-0.png b/app/javascript/flavours/blobfox/images/mbstobon-ui-0.png new file mode 100644 index 0000000000000000000000000000000000000000..25e1707c9932ea9091d2d794f3ca78b7f6b45978 GIT binary patch literal 39646 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 literal 0 HcmV?d00001 diff --git a/app/javascript/flavours/blobfox/images/mbstobon-ui-1.png b/app/javascript/flavours/blobfox/images/mbstobon-ui-1.png new file mode 100644 index 0000000000000000000000000000000000000000..64cf3cbf322e9a636e9fcbefa1fbe0786e27172e GIT binary patch literal 43609 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 BhnD~V literal 0 HcmV?d00001 diff --git a/app/javascript/flavours/blobfox/images/mbstobon-ui-2.png b/app/javascript/flavours/blobfox/images/mbstobon-ui-2.png new file mode 100644 index 0000000000000000000000000000000000000000..b767a9122a05a7154fb0c6d1cd3bf9dc81627e23 GIT binary patch literal 40376 zcmb4pRaYEL)9noI?(XjH8r&hcySw|~5(rLkcY?bQ?(XgooZuEX&-ea=bJ1P<s=HS0 z-My;2Dn?aV1{r|>0RR9X%gKII2LOQb|KSTbnEz%}lHIre47`i1uKRx$_`jh-J+$&4 zpz`~!<00v0Y3kwZ1^~cix+{*W$ZNVNGRiTlyC^9rD6!DP0L1-ZX6*d1hXH_G0l9DD z-+eYNbG>~GhL^uYIla5I^fV_Mw~j*l%kaZ&%e>*JZ)p;ben}+rMkI#gN+7~V6Ua(P zU_j8PhGWRXal`lEhBdG+k7!<}GwU1bId*XNiCWcqE&p(=|C@KZZe6n7<L!Nu`#M9v z>yC0nMaw@fr|g!TlJoxqFLMr#Sj3gx8o7sIv48>>{JC9B`~_40EoAl$a0)pKw1#W~ z$s^`Dg3gcs@vp00JN(}kH59L6?xJ<8fcp^3rv?HYR0lv7h-*(zY?)ulL$C?+|GaR( z<_`d^R$msgzQXCib=!|(gXOE^{m8N)XLF=8Q*tin1V$Il_r?N25+>O7fHem-C&UsM zA4DI>7qm4Sajs10Jbu75(DFauwiRs2m;Ryf5yR<V+1G<Q&@!y&o#5v>Jw@DG1G_Ez zJ?Q70`Z>!3Pe2^B{Oi3`P#?gTD(LY`&*MT(0%fPLQC@SR6v7a{f|ZR`1*U~%-Nfz4 zMi`$(yNa8Qgl(jfu@_nQWXOq}Ba4d(Ij|_(JQ1?&eKQmZjPljz=k*r(YN&bFvc#$$ zS9L?{li*Xw(TWVa3S%HDrimU@^HK8xk?IK*t-fJ)0N{lPAVT%3!!rjb>x?(U@41rE zSmwuEzg4sjp?G9nxh%iD0zKD)BXkOR19`y&qVd*?=Y&n=A0JO2XWCOMzzaz~C8z9r zr5JBPP4pvtV8p{ckU;d#LVAxPr*~MFp(6uwnej>gU{3C_c03a>KX+#S=8x;M!qEDI zS-E`u08bNz&OJFLS2_qa2U0dE;l-5m14Yz6bCBriggTD))||`;(oQ6LxZoGCTCA?E z(@a0TCA*c;-G%JDN8zz3kOk^Quf+v<F6nWnhD-(Gt92*j^+CC6_J1riDX_=+L9Sdw zposwIKX)&G2^;6@T5%6$s$?a^G(qXaNHUA_6xmY7yrY-kLe1S14@E)rVCa-G$1j-W zpa9XyY-&D%Ewjy1^-5ar24>SwzFOQ4BqTl}Fx@8XK0DR~RPJK`>B1p12jTTuTo(=~ zBU(MwBr~5NcMgoXd}>QdXhge&RHO@_o>PN7N8aO}BQ@zxu7(<F+EZ2NgxTu)s8ecf zgbE-SKq!Kbc8UybLDNM^Dph?T#|nZ=t2VKKjr-2#hW3jbJ^(VskZae>`GXTW=YL{v zwRy#YApaJ#x31)Z<@@vs*n@o`tf(VMbz?*A3rw)KSLjSPbZG)D7s8BJ!x@R+lZX0o zRfilo>~Z7%(?aOkia544H}F5AxO!-q>baR;L)P*C=w&(-h2bN};o@^l)J9CAR=q_Z zvKlP%23rRf%Pn(*Zc44siQkk@|2w$&>52d+*r$SMxX1AR@BKd5c)R9-xquJvi@-OB zckw#YJCAhzI7RQKKL0TS|66;lK*RlkGC$d6$ZT#qVpUe8L>}|GHl^$rHP}SeL`8`z zbV<`J*crsVnMl3t7^)`lj=4z7#{HE~LFWHtu5$o+4R)F88lwl7f=FXdjNz0yS&0yZ zdWZw=xibKZli==k)q4W2Uf8ozFoo)0hnnVlwryn2PIvJ|Eh$5IX3~L$j(R`zC9XjN z%a0bFlx`GE@w(BWe7FB&FhJT8iAJyillu#jD5a;&f?nu5Z2s=g;E3|H8A{1H#kz<G zLYT>it_-|4ia3eNH%%?!#|9M4Kf#07$T#|F910?U0q_x#>NTHy3)!K`EH>WQxXH;B zHP~}q<r?M#eeCoeJQ$s!+2F;ci`b3Q9S=Y45P+L51U>FskZwkyEL?NCgA@O3Fc5lb zrt^iv<WGpAt1^P04LNbO>cBAalEDxn6z8t5yI3&4v#J+I*%@Z=X|F6Kg^o}g_J-J> zgoD)iyBPD|Z9YGECqJI_gJq;e05=8Up&=?o#Y{(nv?x<XEA*J5KzYy&7pG0EafGgJ zTBTp9sb;!o&@o;xGu14v&@d#_h$W;AzU+64YE-3ZJ(lEe#~JbA8Rk-#q4!$}Xuz?T z=v8dV6y(#jd@+~5OBPbiX#X5JH-5eeSY&q_^N-pvtW@y_)Y1I9(Cd`U$3<}qQ-;Rq zmIY>QZ1LR0pAsPOkV_FF2uRg!tcy6+fc7p_#wUwH*Ct92{6xiDo^DwnX3}iyTf|`# zZLa<GmJ`$Ku18tkFZu~@d<(=nk(89<6;sS8gjHtvk+L#*2oUE-%JCqp=-jt(=9(~B z`BGl-|Im^dgDrYOZ&fH>V~LN&iZcLG{xTE#rNBJW0*R7<w$}%T19YaLzCJCk7?Snb z4Jk7u>mjYs6)Qd;q*5`L@9KlvijaG7*fzR(Q-({0(1>~Suy?b@9Q(dPx-QqTUXa<G zy(bYz7~oEt-_#ZOW#*MSW~2<03W)2%*2+PKg9^VZ<O0;TB_oiLH=?on3%0`1{VQOK zSNG$R4_+bE&|RE|<eN=%GW?T^xT?<Xi9lid-rbz>XKg%JiT9UW=nU-9kJ2OFC3MaB zBf)I=3jz7c;y4=Vq>_P<q>WM81b1Bq@fR>XEIaV7T*3$&D~<|(^0dPHA6>o~u%(&$ zSIRlxI-ar7FU0#EPgYRn)k-7Q3+F9v>mb!#?vf0>!v0Xt-q1gCF8D<rR+F+aJBXDK zBO4BPT@)vKmD>lvJ+UH#_R~lQtBb*kP({=I-f<32r@3=u*WjzlK%ER^bOYL(Ak-58 zon22i4ryCx&hh=x#ofl^9!c5GuMFhh641F~youBuKZs$1oF$+LV@`>J%tr<Ir34!2 zcZKdWBA~Qw0(l1IOsjpD#OF$gc#4U$$&ba%kNrb8BKI0iLrg(0heRjwH;a=r-yik2 zu2iy&teg_jT281It_SR&_GSFVz|fP;3<}CkV%2L0VfQDf85mCrA#MT)aXu}JG7ntW zv)^+R_F_FhD<N)wc5I`lnzk%bDCW4BllrTWk2Li$js)L-SDxG=r}m-wFexcF`)&m3 zO^V{L(`NdD`H#hOg{ro}-7c5dzDA#J{fGW|n87Se0+7008UNApc<EQX8K=3<C>4*o z6y$5}cy!Im$4xyaFMM-iNrblUU{7j&B3FI#@!WwVt5UxmodL?cW};2&_eT^L-jE<d z(~i?Rfccf)yU3=kTv4u6F%rNS$O1WH$#W~TH;Na<xgC(+xw$JJ;sp7K^5?xja0w5G zUC>hmilh;f8zKOoj#5K?u6p)dscB;t&g4ATQZH@bnspGi3n&p6*;jWR;nb@+JUk@4 zGdz@~t%v4N-#b7h9C${8_r~mjtAA>aPjkmLaiESJiKr)bbN1t3%m7-{_0Z6O;AO`w zpjQ@AifQ=!o{m%D7Gg-N{<hbSKa<@gWoq75=0;GtutitNuS+H|eqv0^>XPbw12Pd> zv~ufp?v()^Ocn3k@Evyn`0G)yW1`;}psW@1jl7Fqd6>#}4w<frRIVCc=pQ~{boQ+o zqg?z^bnlK0f`K)Zd(~O#>ediZgw(WRmyo&pj}uHsIr>{;+*AEB$|Muq&$jF@s`ube zHgG+wZi3uq014(M?O{uiDO=R2Zsh$8c?qJ)yu}N1*{!RafA72CkFy@eGNjmgoah3) ze!Kz;=A#a{QE|FMnR(`sJ*#4F)Z}$>5-6IycHCQgx_=zEjC*}9J_I}YKtxuM=3;>e z{O}L)Pt2{a1^jL-uTWSv@zP4KyQ|Bk;wvq|NAb%^7hO29qr#a^FS@bCQ22A^7SuyQ zF2MS?GGfP8=)-CBy68yQaBMA1BFisQ+vhL&cM@wtqsU#LwiU3bnQWW6pkv~*`Pipq zGEGcm`ek+Z+dGHPJKCbap>DN_({p1JpmddENd{e>Bus-1C{Llvgk_rL8eNpwY%h*r zqbO+(+stjJeo=GKhGvV=@Eym*NdSOZ`ObVx%6F$y@5g)Z$_N^;1y_<0fPJSa4%|<O ze*Uww1f#Mc=NoPHw~W^W8ubVRPLTMH;9Q`@c07)ddk&ITZT<!-&g{Y<Cf7VDN{sWQ z2#Y=FYP2KaIdTuOKe5kziV|t~DjZuW(x~g7RIY<%5LQ|(Z+Gs#Y1f1cz^1G0JwkV} ziv$X*Wm4@Rv2Iz+JxOwy<`NI)t;vDh!dqfc8R#S~pOKd;)B}963h<%rIO-AENTlGn z=xU-NnI$ypupZ%cTIEs<%1mkznF+XR$s^k{)FJ$UROR3tswue)_#g-Hn#e}OjRMFB z;+h%!ufBQtB?*Cjs{I-1=XJdaeQQ6z=+)VIA4*;~-p^#X1y~W#O+0HroGJTMYt<se z51j2qlRyizJrcYV)3G1aD{Z_RS!3o+pDa5g8~cFdst9ae@WQ64xPmtUbra-m65D>U z0KAe~bOM)W1det{#&8)u?EYqT{17r5uPsA=pSfcTac$I)H5MJE1<@Kw@d`hF&SnvX zn%^Ceo>-6uxjzH)5syNWy`mU|hX7lMZ4-|{vr1OVrG&6zH+&G!GCj#Xr7>3lT!p3< zw|7~bTN`0Xz}kl?I9cM7f184E+Z5YG>&k|~8Hgri7uI%|2WiDYesvHd(GBxJ?W%Zo z;z;h_-=_4K<f57HoP=m{2ZTt&tS(NX5+M^=oF2&{{-nJ6n0mM19-^Z0IN%;}Y?fBa zJ%#rnN&b~&hRzre&*W(m0;xc~^j6SAlmm~JflxH@fs~1=)<=I&SQo)QY0H0Zp@$mL zwy}qUbXA^4+&(^I4WJ8K5#_{}ydyE^Zo2${i3z+Lg<#n>tP0JHvO5;#I!24r&+Zq; zgtpXik`J3Bh>+OC`>BqgZI_j5W~km&AU@wJHb__t58*4GUwIWL_~5EeAy?y1P&E!) z&D}l!nkPMHdOuu7E!KlHZcp~N?3JZuMZ$UVl}_~EnIFg4dkCC_+AF1w`=V;Pb}v74 zTf?IpN;YVeJp_Byt{{btT|tPzQd_&CP%YU3go;tW)WUa$cW#mU!Rfgrl~O@{<{WB6 z*xQJb)3B!q#g84JQP_<~^I}p3sks0S?1lIWP<<A=V#rDt9Y~?I|BQx@aSp@j;wL>O z3uzb#Js`m=_wr%ua)Z4UM81=xW_|3D*bxEJo{7$plA&GYtaMI>YRZDsIu~oGYSdCs z!4>K{+?YGe0AXAkv;q@~dfXr^+qqba@Aa-6Q^d5U#i803g{IJzRu%CkYiIAkHi*@1 zj*qV&4O&Y)FjNiPC#q>Zy@YE;*TF@kb06Qu)FH}45Fn$;ay}0WiAZMd40~ZO`yz}_ zfrmAP=;!Btg=#-R8-VN!@BOPITc&;4l?tYuRI6ATs`hm0vs8lwx-L}RUL0xuXm-mN z+AF1IJ50;hIyM~6{PGQ%hMs*CvJ`N2!F9wd;y#JVvYT1L%Zotem0;{Y{AIcb=ut>! zJ}sx1uMt=S+WjcbftTkuyNSQ?lT6CU1*!9NSALoj#c2|y;tIHhT($OzoY>316+4)8 zs%-oahODO7WEtOcZn3JEK+2?%>Oghd2(iUIyOUD#mhsMZ^&5y`V%0xUp-@L(xC5t> zE-h+^*B?+^t>h|@qGi9=)@^<ce8b-WkwPSpi!i(W(ej4>u1h)w1p?RJ{*|~t+GEuS z$*tant|izg_bycXgwpCodZf3>_-5#jjhM`CmpZAZr&CU-Q+-~Mc2SAzNc9h60K4LV zUeygfHC`$`3=i6k392ec#$hTq73+7QBEkf{85Sr0pa+ebBKGvrz42aV-tOcAU5a<I zpC^msv)Ap`fLrYz5)SJZ#aU$0tdYR57fLF6R|2us>3M-WsZdcSD<To3MXYi<L|c?e z-yy2Xn+%DhG`zYll6(#N+*$c|+xYY<L{;g9EUefTc~1YrqBC73vh9BIlwV1~I(SO_ z@~`u@QUvjC*b5O>HNKp{DiJ%tqD$Do(~oY19LT@ax7jr&q0W|H`Bq#s&hE+AetWs# zV~KhzM6PxT`?}V|Gs0V2;4wc~2}BCStOJ#5BRyjIuDs>gq1WO5wMo4(Iq3gS6RiV8 z(D%bPI!B+GX>lb+L@Hr%^Vj!DsS8I5QP=`yYL@gzI=Jk;=wBt8;k3-<J`TjxuhgmI zVRWbR(tokcU!gUbERm8SVw0xoarA6ijW4Y|LV6xODmLfu^J#rgg-&K8^1Y#aiVX^9 zY(T7LcS~cyV_Nc8u%e>h-dMj~0>&ZnF7Dc>9sf?zy*x@uO-%cI-owzDhTz1LDC0f4 zUM944Kq?GU8jNFLs@?)Lo#xUDHFj23>Oqaa7^`<BQfcO2e)AnVv93ffp!Plk<ba;! zy_4pYZslG-RWl99hd>XDs}DNf7=%pNDpcR38M08$Eehpy>7`2WLgSK;Ht4nyHE#g> zA*%RLgYN_EXKiq75EYMKzqb{&^|mEP*o*aRH{**`M66#!RmhJTj>3~aw)-K;thx$w z!(d*}S7(wLsRW@%JeY>|so_$5Be5<Q9UGWrPSvYMHVMXu-<OZpl+Z;Q5s2(!hyNw_ zUy5#Z=Zib8?*BuN`8Ev*s{>-f18)TrkoDh~{7a*9cHS#K-GB2<9)*)EaGdkF&q;8* zuuWVHDP|MT46?Qay1<oLE+Spb|J(>`)Uf3;Q~+2;F(4AC6SNWMuw^1JTi9kmG+%3p z)7cA46LJQtKx>f#?CpqoN~xIbv0h&Z==-ssF8CTbfwBO9-X(Q4z-%D?1`eQZKkNhR zh!l_(909)@`HR;{-`q-eqa=Q1(Q26*72y;3@nc__hr4uxj;d5w%(HBc2q7oNq%`6e z^BP?wRK_A(3lPeNqG``sxo>p}t1j1ZJBgH1h|Iw@V)zV8VYnrLDc_8|UcR3Oo*Ci* zq8YUW^*itn2U>AIKI#nKZ9F|04~yLT3-@=dDU&#&XeXBORI0o4^7v5~j|=vYe9Yg1 z_GG(3eErilEaZ<}JR+SKwYF@8(a8t{j)AMQ6ToAhznLmP<>uN$qvEiLjDk^HEpsBd zRfGVepC(a;L-hWQk6zLsQ{d=dI~5u6QY>O;@pgm<8&2Kn_2vkLW%K?sWwPaFtF&GU z=Jgt}247OyKH9Z<U4pwPb-1%VcP?A#g}*<wxTb^CRD;+@aj_cR<bm$vQL6(%aAo8! zST5*9Z<r|@t<+-ep3TgT%8wYgo=9hPBLwmGZ9~%Y(tU5lw;CMSX%RdIS%KkJ*R&2_ zim<siO0&AB-s`R0r1m~?84D%cU|6;~WFwX0Q^W@fB|l22tib3e5PTlrjJ<4rs8CF7 zC=*c4No-yeC&0YWgex^ZVt$xy=kK2rV+$e`&OB6l6V#)`w<(`>&aQRphh)R2q)+B} zy*(JLwppc}tpUMbVCPLFF`6dLnB0h84a6BcQebo~{<z07GZH)8<H84veZMk~P{r~m z8i*HqYKaD6$#5k7v1++v2_q;30{u!^a0LXNu+Dzl4K~jYMS8+LIenLukpQig0A<Ev z5<AC$jM~U!=Gfs`iAqpU0#G~j`-X*&y?La+MosG&$Tlx`#$KK6#&FM5?@}Y4?S|=P z^<F=yrcJq7|M?IBOW#X*)Xw!3LS`fFAA4EV6s!bN+3188`Jgt?J46Hzje|6=11)e# z4{6_$I$#TKwtU{1>^m~mZd4@HZ{<M@phEL{K^<<8e5j1%9@*_Lkn_>%7~_}Ve3Gg< z%&{+<9!jKBP`IXMH5>LDW0Y%}AriZ3@#E|1WAjEHEKB4DwTq;Cb!RSV@K)%j{4UB0 zk!ldc#3k3B`wG(^w?QvpFEA%|d>g^4CbT(1Agl_Ak&13n)IujMPG({hw+>;4ewJ&v zUo?+kP;*<#%{*>};i}Y&b%`EQfEuu1R#h*eV3~qb`i&7<FH28?;KCWIM(I0Iq3s2T zpB0o69+;pyMs~t&8KNb=ao*QpI59oGO+|%Ka_FkX)y#HNXk}yX5b}oBego!|X?kQ3 z^H&NJz>^Mr<tfg-Ai?>T<Qd2fDIcxkppgMBgw%<JW*XD0(@!u6p->ZgVx^N3IZU=Q zc$61p!qcL>sto8?_zXZ1db8mB!$Sw(FN;7|Zj0w}&vVga45O5?_jl3Mu_JTs@Jyok z(zwxmyh-F$1)O^uz-%ywq_A1Q%)J{S+MbclpTC<Ka;L%3g9tig_sE}_pdnWzJ0VhJ zkR%uGsyHS8t09h;?TLpcS3l<Lbv(b)e~c}-?4=$(Gl<+_FiC|F;f5t=j0iuNJej)x zypq<~(LRx*c1Y$j2Q^+lo)f1c%g~JT0xE!%gIAXgZ~ZLi;+x1>v0imnGjybV^EVos zC<+jHlMo>euAbuExEpQ#NfW#_fM^WNi8=A;Y9>Jch$VMRY#qL45NZ+$bC0*+WhdH- z@{7PJChLxVb>Yj6$LsV$bHR*NS}q&OgpG9$!Hee1!ZQ6NiTwBDlsx#3B>ZXA>{A6~ zjQW@HxF1pl+#|EFRG&I(pKpa-et>qdgkGk-<}8H&LRy{j>=)B`ggespG0N={h022? zIaoWVmLJ}c;6wcUvp%Td>x64Ud>qan*2|jFizoRhd>3{G(1xczVDZpWMqY^K6s->Z z4$ApWPYf(3##7;h7DKb`SAAl4wrtD4-1D?`TQm*VZk#YeE^YKkR(4#t9^`=^)!Dgd z;*!5N(hkW6`PPXIgakh8$BW(;#N;4K3MM<j{G<GFWu#`ZjA$P=8g~040~W1pyU}r~ zTXYGfCjas945a;ogeZoHsryNk*~+vTf%L<v7G4MQSayyTm_XSWGaHt74Ybe3F%KxO zKAU2z1lM=Lrbp+a_d@ItAG6&#?@C<;FQ<Upu9$Od4X=H}-lb3bAz23viRai*w6xcB z_kEAEO&o-wc=}l!75FqhLn%jfR=-MD@@CVsL4tdo%Pwt^%Tjxr+a5j@apD?+0Urpr z4Dl<zkEDu~)kLZ&O45+l>sOPaTPawO#)IEyiE}N3yAl_|4}xfT2ZCH<N82wk=Hg1* z%+!k(h546`D-8U*lj-ztmr6KJ);*+QsikObikd**Cy`VkY+%zOJrH(uA(a7MjKERY z1(NbdLh!#_V_(a9L=lWe`4Cb}{J^I=v78b^Lu;T45~Sw`n9H%b(1cg;5(Rh~;79eh z2Cg=X9c-V07Y(t49qL|#(~f*hqG<T1_5c>)u6<bUF84-R%HYw-+jHg~d*sS0s6bey z0Jh@aAC+4(^5*H#J36fQI0g6u$xH%fW`r!Pp=`0*sH$bQ(w*Z|N)oHf-?aO7iZy?# z_sfoNwUHTrh+o3Ib`6Aw#+^1ozSQHNe8R{k)e}B0{TCXWhQMJb75$o`@lP=K-c0C9 zK_T-{{3$Cb=_0s~G>$RM_GbYe+|wK<%-~jX;D<wTV422=u%54`;hi`%ve*WT5{WEA zZl$Lajun!&E?P4XzLe~k;FmAhlm38+s#1AW)0(G5SjMF}u-{$?z3sY!XKwn|hti{4 zx*EDYidhE*TTK3{Ggl!Jm^hR;(g)*!kstCqxQ4#DcVJ%E+mAE!W^H$)9a>AJa(gnF z&5<*JuL7$_h>vc3g0_4jyZENpqv<3OrTjwZLnyLp@wv0}jH>;W7>*d|uQdI#)pSOJ zKcf#>bB@7NiA88=QyVprV6sBc&H}A6ydR6j#0cyuhaQuS)c?iox_50Y-t!GY!_w{7 ze{tDpdr?wEvswe`!Uv3QkhNyiE}*GkRG~KL6Yhr@(4-Awjr!c@2AwhIIS>Hw{9)(5 zcMJM#z|+RZ)WV0wQh4CSqo$^qez$W;yM#zJnQ}H|)auN2c7l0qVzE#LC5d#t&|!M< zKHoM{BHud!vFI;&vw4sJe?R7B^k<i?6jSnfL%0r_)THGJPyc#{5H<!#>fuS=tB0@3 zddZ4>$l2632X#Uyie2eJ1+%qHC%|KqF6{E>rEPhPDU9}%q57V)8}II5in{b_%5u1U z{4%-bWHWcJ5FiTYtlu0MLf9>r+a9_@snCZ!<0YF1RL-mV;3+ahxHbmUZhUB024Bz@ zW!zoBUj7#2A2az$ItVQiE<)gn?4>P&)V{TW2P|IVqO)kS3Sc-AHPGND@Ipn9pVL(p zLX%yw3^5EAnaFb~uy{U2rt`-a_`?^<m4#ISk%|i*KPhOUcvmH|4bfOvuPeft?x9P1 zr7<F$k9-vlOf6&!4^_1gwfgKYFp}cjx@5cWS;)Z3*Ji?DylEkZrK0pgJaZSJV$-zR zWKXkV1U+t$>DrC;X<))QEtW}VdHbNf#LjIc6kwy+oDoyy14)3_@{;eySeM?@_ThP2 z511#ljkUjPG5%pt<%hpPvzb!RObCOoa6Vwi`{<I;ki{Lbna0ANI@lxzdQIt;Ab0(D zKa{nn9lXr`BF8y4u`%T`&+G{eUq09+kISr;kQc_-$&erwKZW!Zhq`fnl55WI%q3(p zH$JrOSeI+*a>dLfcyd_9H$M8>%Ix!Ad<&D-Q_ufl%4R9$#4QUi?*;0tSoHAjoR()c zoqsS2NOip(<>a-_r<>y5ox$f(wgPkH2HJWNC2mdY&GPCaUr+b)Kt)vAdWq6d4;SMi z54LQ|6`$u-y{1M^OPn5)2ifO8y_s*@2PPOEvF~-SZe|qrD(mfRZ+2p4xH$-)o~<f} zls|*AybJF%E22#fs(NA}t_KxV(Zp0GO2hxi>bQ_a`z_x4gZ)XP5_r(+M`rrm#N>hn zw;J>jl7V5(=hCu2d9;jwjGQ9SP~M}2ZOg!y@nwzGZy<TxxaIVGSZXV2j3dDg!<c@f z2rCvq)M;T}tP_|`;^I`Koi7@a$YRSQPWr21Wy69n?q@$>U)|xAJqu1<$P=0PmhC%- z)GYQ5c~x@(QKzvOSVC+6yZnUUy`AtsQoV2b)D~k$1&Kcs;vuz7swm<c;d}^b{8E;h zspH={<14M^X)!B_U+f7lUzbP!?rxq61vP2UPSAsw%J;%_Z-bD-jlzqHuXyIFsr<M6 zMMosod$0%;rpm4+KD~Dy2+j%PYnp%>qc-K(>>z<x%dtL^UhWAtjS{OL6YO19kEg4Y zR@8WXZC0~z-^co2$Y7I==m;mI%~C<B-$un-5M@K%s3qVxHcI>DlIEF+9T1{ZBfr~L zCJz9KUC@G%#eozEGjM1?DqBqSo5;v4ue~G`pRRtERu&9}$D3jY$Q(_!8&kIheoIn7 z@7&d^!td7glm4I0UAjX%{Q{9=l~WU91R*hHD$S?2$l8<%5X`5h0!)weuVS9K(51+H z1vpwnD3t#_A2i6l_<E?CJZE~^O{IJP%ItkL?(=o&qy96wdK2w8Cv@X<N_^He;ehCM z0hAVY@khoejv$JoqI0E)37clEMR8%)bkH}3ao0-4^Kclh8g{m_&g2P`lrW0bUN>TK zMcx%tQ6)7mN~_}q6%#^Why09QcS-|9HL~bcWGX6`>s^w;uWhfR@?|5nBglg=s3!fC zSL0be{DEby7S^e~keE_A9ML&KJWc<+=U<_QcdpFOdZuq`z-a}%NcOGImen2;vL?cC zlFPjyMS$O4lPG)uaZt&=cNZiqB>Dl)T-vhGJtyo3TpbfI<KaSAi@IXK!us2rdav{2 zRC=Fz)l*U{&qPSZo76vff<=oecHUjTY=NDBwOhn9pNMh0`HE7|y3zE5!Z^1HLsYAZ zmxwGq??y7(umN8i#rE9QD_lc|bMpWv!N{An7MFyYt`-D(CgoPMjzo)&duh+o>`BGq zwE$%FtRy`ODrtWtIXJ|o$9MLmOco$%9iLi;75hYxdFEphJ)&a*WDVEh?|=x)<BVQD z;{(WxhKX^LBkK&JFQ|GbgnahzpYT`0yKfA)lgb(ageAJyDBY?s)P%w{3n^Xf!}~BJ z<Np<ein8(mpFKQ9lA1$jWRXK$VxL?6XfG=`0ZPMGpUZD5c}Nf{`)&xVy3bC^t*z2$ zk8ga<XeghYmdEAp2q=ehd@$JZY~RM_1FO=EHcTiZ;jW)h(rcHAc)f=;Ec2ZpHXQQ@ zNrv&Vnu5ZAH*9x62^ViE)BYCF<&mK!RS$hgBfq06gb=5KLYKj4K9VS(eB}uKQ9-i^ zPoDAEi=~Qsz~3GKU4q)Qsi3qdpFWA}b7Nr>_{upaf0UBEeWlJ?mh#U{4Z}$%&K5fW zH+Ooyd;Pu-OuTursW?0;BLs?ZbHg30dx{!ltkK@HDhNx>mA#hkKX)CyO*mre1q~h< z79NtN<fHL)J;Xm)@S>2_;JKwjVEXon{w1kcccB@S(;AcO%XR<+oc8Z5*9TW5;zhCP z#Z95^=Qy>D0T1g|NacDo;rv$V3>BrtC1Hnn!>&uA8_|SY<T`7SvQwLw5L-&Q^P3>} z+!Rk4b1zA%+1|K@ESi*Pa9^O{M%jBq=pv6VMk8ydqrM4raHG(alV={At%VN~eJV2B z(nyYyJAMe%k2Kr5r2((~>}hL;UnVd+@YgkzTa?GeR-Ei_?wsg#>W8Y5pC5u=RM?g` zTa6&W+j~RMG*x*~q{H_wZ)Ix9dhiD)*?T9eE`lWHoVFxwMiaxZ{c`^QU3>shLjG*| z5lhlf_sE3+3e$K;%XG<P!4uv=(yBE-XiYEqGv@O^y0~^&WdcVY7n_HOVa(BY_*u{( z#J~A=EPQ}Xp*1w__G1SXA2OOfL@)x6J!FuR_l3c;lf37fH9=wSFj>j5Xe^5uQ^`Lz zOHgwcr@p<c`iK=(b_CnJ3lR}=Qae?0q>CQfSPCEdO%=aKK^&_4G?Y1h@(2{1XuXN< zu=e)guX!8>TvW%G3*tznEhUDUr`k)`=?>k@q(Qa-LeV{p|EfBUf8am4K1QPUt%!e+ z&#gWP4cj0+x>f@%ZOA|J+2UBP@?(5H(fFt|l1BsPIN?ad-EsfAUNKdK%P5Bii@<QN z?;auZogrs)(@AT1oM$NZhz%On5-j{eG;8GdiC^lZU9IuW$)-Iio7aMsR|Ae3AZal6 zvs+?4d6wMBM*X|T?jpaPC!D2r)a;b*RL5*YXPqo_0`iC#K@v2-@=8U9(RrZeT<V_} z#!Edd=?2Kb{}h*PCP*>7r=ZkoS!(RS6o4)u9u2B3*^WrXso>GLqea8uW8rzE3Af_P zmiCQL5^dCYgfkJM`e^TSlMkGGgZiim_O~sW*VHj-ne=e-%CZwC1pQS6fh7|^2t#>6 z9iy*tDLK(nsWIRP82?7<DV!yWKS}~@SM1M}3AaIggThdIkPutIt%w4WXZ*DfI3aJ` zt%aT7+Sh3?zL@widdY-k`i@=DuBu_`Emy#UUb1#agX82n75}(;4+2}n;3QxlG7#Sd z-dxJ%9A8|xn2Y9qY;s_^#Bi%-d+HqMWP<QOW8)YpusX$;8&NJv3(s6)={_>`)Sdtb z3v1i>S<;Z+Qm%U**AdSoX&;lil7lngM}9?^8kSr};j7Az#TTpd{SYe(T^zbM0c>nt zxB2PSoEDu*Z{Ra)-1KOjZZ<L2BW2w}lIAI_ZFauYo6b4dbrIqF@o$AX;P^9E;_rT> z_p$$TJSQ|s-ZlGV>Uv}c5y@xud|j(jQ>YMJcQ<NqM$%CMDvqje7sZ#;++$=y1Gp23 zq`+S5TfW;JQeLG{&n?=EC*m01plsM8!imrm-~FjbmhcTuGO@_)xJ!te0FA=r<Qo>J zxUnJ1uuLR+11rKb6@4c|N)_s2X96J{l|hAb$jS7cm&FdtH&V?e*i=;8Cvb!EflgT@ zd5oY9KMridn|UaY(O+14;C$Q&L}@zQS<+z%{T2=Ng8j@NI3kR1vK$gTXjp_v^H`sa z^k`X7XR^4{QjjeKB=(SbEfZG%<tG5ULeD}?(%rI@(_f7z&k+75%1sXs1KOrqX?wWb z!QD(Rx6gIx&LAIn#2?FexjoE>uIyjJPk1?jxLixlKW<tQTzQ~z`tdA@ilj2^_P&~H zLE<u(We`yqA40Evg84VFT<lPiHp1uAU8_F%2}??m*6gz{a_prCy&2hZ`cSD@VK@0M z6X<Np7NZUSAbtuIWVw!hrwm>8lJ*K-T5L+Jkzt+8nN(ufIOonLFcesrD1#|OACR)A zy&&o#%?Da1=$s|7m`C1NYV_tZ?G{sVFcamV+_%Zxj*IxyevNa^M@Ud`Hf@m{@nxD* zB1p@M`#@EgiT;i;c>#PXBlFgpK2dCGlJ0AU+y{wOYm$-<mqaZi1$6axaL&w|DGvFg zc9q96f=G#B?lNuB_!2AwvUh>cwB){&wn0j<et9r6O?H0isZL7B8dge$%<J<Emyx;% z9^nPIKg5pg`@-pgZ#T(nv?eshnvX4rpYH2?J5#!}`arrsuSEgOZ{xW0IiqwCIS6qu z{j+QFjQoj=x9-$5#f<*HXCcOz5;Y(;O6A^S3A4%Kt(sice+wsap=xk%;ls#w*7p`* zjuCXX@#8yaSmK*pwy<dFpP1i;FLP;XMc`!0`KIcE<nV_6&?R$BgcoE02)kwHX%q*$ zaOQwIEfWPMT5Ie|D!(cltBwRSC@SyI=DW@$yng0+2g(9>59mLCK_+qD{)lRq#%h4A zz}#cEdExxnw{7onS2@q2Q(#-h`-O)v=(UAm53}kn5H%2ALyH#wb5NA)USC)8C=DxX zGIKg|>D>xCZ)#={(Lvuf)u^(khk@+FqtN7;QZgEylP%svW@|o*=1e%N5j@?hq>55Q z-(LnJ*FDd(w15yI?Qc0CxK84fa0rW-{%AuM+qBa=c<(Jxb|ul9a$I>P)ty|4ML=36 zWAuC{T9U#~s+%?wMltryF!}<Kk|tbHzBtvUK&`(U#wH)Rx24_`hNit|hd|7zX|+~b zH%hZo3a2(WoE&mTC4nl&{Y?75k7R|shFWi77^4IKxIHZ{BC5@damkN#88%@UT}XrQ zO5}OX&3CX-JMW+0kM)7$PGD0Pj-czqgMZ`!k)VY!RmGc*ykTKjpG(udT_dTmluMkv z6!O^k87_#=CX<H*@^qVpsdsM#_Cl1hn$SiXBx8ZKRofh+n+(xf_*VMl+M#8Hc<CQ` zYFE8-nd_OARobLB4a66WpcY-*Z(@34k{fIGX8@X&g;|PJ$4nl6bxR6J4o18ZO{fw{ zb=*eTqkV5|^K`**+DsmF0ObN(HS~_VX%i`mJ*ZJmwU5zZ4LO|@n%H$N9*^6HnWopC zK~8Xcs>8g7<1J%eO}JzI=dPJ`j6-Y4J@jF)j^|3g&f}<c(oDDa_>cIL3!%YPejHFl z@qT15xVUe04$XNmgL8yGC4Zg2t#5%0@xOkrna5I}n=eC+Wt=zhigxP=&?k5Pzf1FZ zMV|(fZ^>QgE7n<%<NJ`MeSptXTfCM&!)!fVat&BX_aJWjiZT%h%z&OGCYC7(fkovI z6j+`xJDi~wwHyNjg@3N-IBcBfspZ_UOiIa4DQnXNnm8b<w|K0&@zm>S7M+3`nm<&9 zHjbj&MQ8_F#h1VB&H$Zo$5`ZPVhW-X0x;3UgJ|-6D2z1y;hH!r=VU!l8;fAN+Z)7q zbiAhZOYXzp$$4NOnk>Y^9aK_#S?2TR*%V!dmeqU%Q-&Pt$5#G?+_+Cyj!15_-rWSB ze-pL`q78KB9r2e~XWMZn;|oGBSF-J_pz1hf-RusNP0N2&@ZCtF>%!CFk$t&%RebiT zXN|lbx)Z6L7kc;CG%wDW25(9z*Z+0LJ>HKb@tqTgqoRZlxRwRjjD<?|nsfaoNoT`z zQAD(X*apOum~kf4R1144E7UapIiXNcxHK9a@>hO72q+|M*VA<rQ`gs2x1cXUDUc?J z-d;+mO8y0%Oa_htu}BD@tI|-^CfN{*<4$0$+{Q=2Kh_tbcp!7VnJkCw{R~yluUsH0 zx;cPA2d^SH;V&tMzi{jkC$HEtGL`j)!H0P_VLLY-w!OxcOGdJ74fkAmCc5E#{P{f` zcZ{x>8BH&Y5({0(7k>9TCs*9I8%<tjzH$D0CojmnrG-S#<VZ$Q-hHnBHzU#Ydh&4P zN+0y*m(s!1XIYRk407rj{<o9Cq#O988*APM++l%T>7c$C^U33i=7{E*Dr~XIx;f%y zDo@yWG0s>jjD%eB=Bc7=>r(}LzujfwL=S)Q<I6)wvhp%~m&$u=e9@v`H8%0uxg6z} z8YxU%H$Mbhi}b8zw@^7Eo7H^CsW;w)(qOQ;jVTQv&38l6F>KZmS1#>!Di*0yIc2k& zLcK__TKg+-ba2BX7T!GKpZ<)d?tLXM(>V`8(Ka5n{_YFjT9){7kcBPNJlCcp8(SR1 zhb8O%hOp>??jOT4`B6viM##r2R)EiIn<2CqQU_U=aiFx0k`t-&&Px*#I6it@lpj7K zyOv_Sc5}j3B4NlX`IdPwM7dQ(?uljaAy$hrmmUMShF7$lFw!7D@(vd(ihz7Wf=UZV zJ3^#OBzwhIRZ6zo(BzKq%)(l>sS^Jlru=lz-t$Td%ea2hY8_g>UM~b)l0@<Iwxw{` zs4Sr5_(wUzo2b2!e%Vn;YO8D{Wukf(j5!AY>k#c5Tue4hX1FX-D000BH1c7g3DITp z=tcl(+Kg&k=ZYfp#D864Jw{;I={*)s%-&5)y{H%oDz=ckQMRD>pbGkYW_dNNqurVP zkK(Vf{k#KN+*(sKzI?tY_b!oi<87c4u2+A5ycdOVRONe<?_S5-xE$Y@lW|vgwp0P< zp-H%F^G`x9ouFnUqv<6`xeUnV6xG1A7>{EMKC#Lw5c+`-I^P`)T_6@YtHeD_*^v?V zL6zOn@v>^JZ^gM?^K-BQ_rR~I@4ud&oyfC;YP^)nFMmv=!?=mSWO9)G@s78pN`C5S z_BASH!c&liKXg$iV<e(_e|#A`00sqg|M334riCgcD*=Zstx&!Qc|mRG*S_qa36eKW zMlB{sLr2<P+l|UD%5Hvc2pnRsBy#p19e2-k7A3doE)=rccKzqV);DX-G<o_`2X_8~ zGxeezL4~^yyJS1;eY)$GCX&SN%ww|Cf+wsfhu8RaIFi@4uK_*r7sDzTQy^gPlz{>M z5`I2~oqsn_vBJqPYt=ONV_t%>k1AT7bRh8E=Zf5ogJ^NHuxCWYVwhgZ?}m;dZosso zXUoxr{n8tHssq95iKkT}8>;Wu-2pHCLA2tPOmvvt;BqbeQOSntP`{(p8C0#}fvn}v zdvlweq8SqBLPcm7Pw>N*vXt64dN0WeHDDQ1tBhXXh7(ry0DQP2&aBu44NT)uH!T($ zbR<I46MRPm?`fRVm$AW=2gfV5Cz$s){c8jCRJmWLr1LT7NMFFYk<Y!6Xz<4IB+<*Z zyxt5ifn}wyFM4F8o@5*YOB0N5d0T1N9nB&BtFa>X_^t`U^%~haonKA**Z3F$^rCZ^ zbvsU=TxtuD_T)s(#Ux*b>v4-(2Dqy+=PEegh2(Ee@QueLG_-?3Vo*FLW{%)z0J9sY z`^Vf-9|FXey`gCzED6OJtIvI{9ZE&*$<?{t(*;kfrZ(k<;iltUXwI%jzH2zdI^0s` zSlcojwyP|)i<(UhdMCcji#VKy<d!ImIGPNUSY(KhL8#3!D7_5ROn#4IT{Fed$ufEm zuRfuoWzTOR^$pO)%`osMS%MBF3@!tShQ94;x|2oUw;io+=mbOYe8ehl!i6xBwL)}~ zrX6*r9j|sHFc@KW1GhcXjLbsdE(m+Ew!7RXkcc_njhJT4ojLb7b<l$xC*TeP^12Ht zG$~L8?@aJ`DIN<J<>q3VyT>LqujyR8;IGtPM|twC3Bn?aTM)`qB>Qf<tN2mYP<#>D z8!L?xe~Op|ds+7`rbWucsp|9;OYx*)&<s(5B%S`dd?8UH)jX9eH{S$oBZkbY3%AXO zEK%y=Ui-kefe2ePI+v@zWf~RyI|XUQ^`qV^)nHd8rFTOZ=r{~X@)*5ECN7&B8*f^C z5oug3i8a5Gj~QC`rl71x@5eea6vP+uh}Qc(y$f&s1#GImL2D-CZnzgo0+cSCjK9F^ z`!wI;ueTSuP>q>E4*|Tj?R}L-9_AN-#+%c=ml*Cu*j}KE1W69ZUp<}HXPq(<Tob!s zmXa!w50>Op{vZCBq*(nIW&9h)?k;WAgiSZn*Fp%I-#_ZNvbhRhG*)8Y3BLECEZc_m zsH@aQDpUb25w;K<7j+Dp3=u^!EVmL^+A}0DYm+0lGNg*R9Px5~A7V$h3yaK>I3Mn> zyXM{%+9b3Fs97%=B3lqS119K8IR$<~vX9n3^uP^(tk<R0kzMFDSlP2jITTqQS~7To zf(MU)rU)Q^x0&)9W=CY(`iTW3FX~1z`PT~l&809(5|7@oFucg=UAD8uKn3|ktTL40 zVeqWjCfz8WNGT{PG#n&DJd-Q<^Dpem_ekYGf}_Q#N{t#?B+$Y9v2d(#?bZoIOOE8K zZ(OwLTyDNDOFf@b2S|G|153%G$(h|?qB72u^_4|;M_lH6?kaID!i_ldz_^M2q=M!~ z9h*PLdL8ZRcqVFis^1C7q1DHOemYBGN--`Pak>XM*BrcK_G!Z2<JAjw&Gs^nWTBfz zuaC7GKS4>o((iOv2b*e+k^-w1c{>DYBKIFv1L0Dh4+of^m~rcg=S*9_k-(DdTH^aA zy}!Pat~9N-quVr<3S+Y6OxP(h)@JrN=cj4G@~&R6dd<G`{{GBfR}yhWUYs073Fr!# zW*sflK85vlqt9XMF>dNYUDts_g$)@>0=y3hJ3};$pO0mvkOPkSew9o2%xa{Os*)e& z)v^6G#)Q`di1e>ATz<O<8}$?(vr{F;!;&O#ruW{rsMKR|vX({!-GzB5opGFPAPD~> zapq6f?JQiv>#fI9t$d%O5C)~<yuz#oQ<0Ig$~#WYjwPDEUj|GRtYv>}{rvSQGAZQ( z&)K;3NNfJ`i`)d7rtzM&z2T~9ugxH@=xT<u-GZ3SiN<(OBB=jsFDhNAotM-I8BEzj zqTf)aam+K)y>6x4Nb=KfPCMj<D+`KNA$Z2uc7(5=@h-mr4mU=+F}QALN`z}t73t_W z@@uN@R)Wu8ujwGLo+~K_X+;L!I4(FP;QA$4oWH?zB5DdTX(h*_tfV9d^Bc9w4qBv( z3bO+a&5ej}&pKFhAv7jCH?u~J-oa<|wxfyRqk<_;qjU3bt#>zl3Jkt+X*OEGWvH^f z*E`-HG~H6U(nraYP1L(s6jX?FrhMTIq)(m|0cx_aO>%jwan{Dq-t#vnYO@*3v~6;| z%VD_r*K|QIn|ej_0Uoh_5%;y{uU>G`7cL+u{3J0eDd$eXUOM60)xpc4tDYRu9*U4X zXosIh`f0rFq<!ZodOKi0O@jwpqPRP;nP{=81GGw2RE7&?0{fJwp<Z`>Unm5LCYPn_ zDW+ZLAdSnwe}oF|33jucv)a$jc15|JS_7Z@oGPP@vL(JNR7Cbvoi+K){XVxlX<shM zReXxOM(W~&v%Qcm!zdtv5^1h^wwbtt)1g)pC_!w85LQ35gSUk>u0p;lQ=5QZ1zt%a z_8oHzbl<>j1yV0|c^FQitRYb}iQ^q>;AAfiov=0Ym33O!ypN2txcRnyv7)erQQerS z7d#I%2lwEo;8b>bF>IMaica5v^^GHDgY60SZ+ewqe_s$B;qq@djneAjUO)DbY^`|` zEUno?a~UT2zNT;uYhsb)d@>r{b6(Z&p+2a^%#Q`WWrb<tQ2C>tgL#+s|InhI7H(*# zWOp=)!1@nDOx%OAbA<J81or}a4cuej7qZ&4KU3CD${#*j?f9EkqrGR@w{73)?Qy$! z?hc9;S(@q9Ulc=jP+*f1$J)c>5oKiw937qG2gEhj4no!G@)fGx$l)g76MrID42g%c z?_2GYeFFswKF`x-&TNM*kP(6?GEsXu=v>K&*!KPez&N?=@g{Z?Rh$Z6MBR4wKB^}h zT~>%TfeHA2lri=D-kaLh>Pv)I>P&UPr1bE?RhkfAxUyq!>T$Nd($Zi~;eH-KxR`Ze z5glS!LKxNDnZ7V`-)g<IELmO0nZIc8;|r+nJb3p${mDG9U4+<rAPe}%q4xe9yy&XN z{dcpv%i{3i)ukG}apRzb%K>|p{h}5h<Ep?QM!DlpiS#h{St>=Lm|T=alf*0a0xVzh zx6Gn#=PEf_AcG3VZPyFYuL2Lag9Er2%6{tbxZy@3p0GKxCYw|aq;F)RFr~`Uxc`2S zE!Q7)R!8&XDqy@|(qxyT`rplZZJ8DwTI=T*Y1g@an+|%pHUg58hHVSsn$by?o27<| zoJrb;EK?G;SFNOaOeIG;_9O+sV=~vYi0MKn^v{>8yO;?liMcwrXV}iJc|Qok4`ohA z@7*^g!*JY874Fo7?)(FUU&{AZML#z4ut$U3SB4Y%Ue8yRUnA;nAG^?GGzW9+GS)Z$ z3{2&328C2@;J7T6*+?%$G)vI`N>xc=0IbrYEyzHK%E7At48x%DtjCT~WDKwgV8IeV zFa{4>Bfb-Cs{+ueBE*o;5f9f4`d!1=t1YGXQ&$*Jb#5%9d;gr|1NjriMU@7JBUw`t z$j4Vqi}r)b8OuzMB)rHF=5xWh-|)uaxB5}n%-_(*mf7x_XL9cx6tQ;=VY4XH#4L-) zTN5+h;<YoBEVFT^+fmyS0IM#SpT+z^L-)6@<5A!>bHCm8>c;*CZO1#MJ@^#0zvQ#6 z-NS*digwa6|0O5D)c23dP;WTbyTQqoFPY_!^LJL}=-EJ_&*j6uVuq7|xSUFppBxQ0 z{1(!I+})avzwP(wnp>runRnT)MqOysUXq|n2hfv+GHnTxOsyF%c0KxmGPVJ7#km+5 zD{(Vzvba!0Biy<=Kag$ovZ-XD8?dn8ZtdX$^`OR7bXa=mF9eLi*F0DLn76`;1iR*V zA{or(_l)l+#I$3WPUTO3f?W@uzlOb4_>vV~7zs1Ph8%v(3P^_pReaeV@RKv(n^Ovx zL&^}uw|t7`hwD=$Vri3n-u!Wr>1ld4x*T=;2~@;i%q#Qy9qi~f54=49dNqio-)_h) zoq*jk#9mQO0f#y-KJz>0aYxjMteKon*J;%&ibB5svL031dza?-L}Fqv45X|9*1^?= zv37^ht^$Kw2-M^SV&o3T2CH+RaIm-~!J-fRvO1<Ji__`f&}M`JILv4na-tId(wMY# zE|FTfC&Y9dL|%JQ>KHm3h2kwamqvweAE&$<Kk<G6m3|LH1&``|4*;alIl6j|#zc>@ z`&y`gF{8S{B5jw`?c1!B(;-lN9!d>zqk+FZ1zP?1bQ@L*VuptwSG0nO5eV8mp@hau z&}Zk;eS){wp3CbrLI(3w>Rg&}b<S(mt$<dnCj7%9IMd&|=?-q`=oLM&(!zR7U+EY_ zs?5{xmUk2K4fxNfi~LriSIS@^>rw+6n&r0o{F7Pv+4QsVQ))E*DDenNWVVy=@~JY+ zdTc~%T@+)B>|GXyy<}6%Bx?AL_RiUo15)inIbvAKqAvGo_2{t<I|-+^ZBv1_5d~xd zbe*r(TD7f*Evp;U!7QA=X*yWT3J$q5$_^~RZc%pJ4f~opK_-Z_Mn0l$C-$jC!EnGK zrGeDgc4si=@7WbLXnW?J03@Yn2mKm#z&_<3II|`C<T0)BsFd!_MZ>s8<%}_cosf-u z{D)P-W&O$j17<*(zgaQ|eM1IHB7BKwxv%wm72{hkT0v`Bo*UA>V-8(h7%L8rE)1AD z=)Iy#rY?(P`zmH`x;7}KiqN_ZAvz2)+IO4s89p4sKok?A=?R3r?|)utAAiPV$~sI+ z>v+rupC3YD@cSStci_10Ruu29U3@d9)s~S`Oke7s151Iw!?)h;r^x8`C@yeAE8OtV z3MRdA?f9#XTK0!R|D?}phLUk$XRpY|)<72LVFE78FzIO<w%%8sXxGE}fbSlV4<h(! zg<zk&$<$&He&o>4tL#SiMt9TcSq#DC1ae&&hXRym-@N;McOLyAzppG@cc%-R(th9G z;l0=0O4!|xkbDIn)%g1Cz?jVZDYNd8-<r`lXeJ_*i<4?GiQg$p-|+{)b#0!v=;e4& zgv>tZHEpy3ZDD<(8lb^~W1+=N2SPz82thzV(CIz>aA%hrJh1fSJPJrtfN^HL4Yz+_ zUidECW5>e&=Jl#Z&cEWM6^AY4f7tW5u_KuJfREoyAJR#p%^p8UuR%}-;wd`2$8RlJ z>4795_W}MYGzH=j>L$)+PYz}rhYjE->eXK<t?Q%)F=M@H)HII-50k_&@N*zJBz^w} zcf8FX&lddkP7eOg+3NL2?o=n-bGC2AwLj&!>NA*EeGD_JUt&)6@0eNrJLXhZGqbw7 z+nDS$!O5!fL&`xB0XKlsK3pMjob0-jrXq%rg&A|W%kf@%<4Y)Q7)Oo($+1X(MmZG$ zaAHtZXtYR2Kz;xUtp*v~iOuby$ff7mls8yzmtQmRZ{S}*>kinP=LmM;yrce(v+DsD z_-`h!2(BFZepv{@fY3$|YcLJ%*KWP9c>pFl58(eMa0;i<IKm;e{}G6TVDx}rx~VS{ zJY@`p`7JDd2wtGmS9xsC1RuiAJL8Y+JMSJi;s3!?e}k`G35)&#Cst2pPW3L#td23K zx`~<9A2X*q(K}VBTG?_o;7Vyy6t#o$2Ow2~>pYLvhKDY-K`ogwTBFe5s1xjtDRHH+ z$4iu7Knh&{u?QD@I;)tjEmGtQL!c1G6+mGOVm96r$Z3#67I_mWc#ofOFdf7(trDK4 zlXxAoYizskCthJ4l2sEI6Zz@k6d!rg9Lc%2!x2M2S*($@KpMfFQ1HjC8`LQTPN{(Q zyC62sx*W%y_+O^@PJ)9Q7!=>Vub_^(S4oylC;5V)m48x_o9V*<%6Xj4DS<nAe9GCJ zGW|~8ns7EJ$~&3E+05il=1{{-zQY`T!%TK%4sS7&%b3Hpne3eEYUZdm&=Ln_viOyY zbW<pu#^Dq+^=N7Ob~%u#J1M}GEjTIyTH_#ag+wc5BIR1(cU@fZJUR#zfG7mQ$Kv>m zX<breu(Oy<Ts#MoLzim-E@!VRI-tWONq-#^?fVVzv55nWu{!#Z7)s`L8R%J$={Pig z+5aPc!Ub$RZtqt|ewV{Ji84$CeDKm#L?w-^6>u^%J!s7Dt>x`*25L`&@MwrNu`i=3 zqy4#)ObSgC12f=g>My7hgE<#5@v*DXka{jR&4=luEWMLJ=xoNVypwV3&!%qOoyf%5 zpzgHhz?~Rg@@*LQ$le$x$^T+%fmSm|RpU~Pq^&ccqX48vs}7Jclp28}Wha1n9lRAb zS}sAlO{P+ic1#s>snEi~4S<LqO|TH6ekky3q9TRFkHn-h<Yeo}byr!E-MNg$R2=#o zCiCSAkjqTFjC{K-{SFf!*)DyUPXk~0;*0ptYoYwW2S^iK=QD-i&j-GYm%B5d&es4< z1{cR;bkYYiS)f}gsscVb4J!OFdGlU*GE}-d(~lj(8EOW2ePN=CmpKQxC6Sxq!vxCM zSI(a28+|A1i_fMZc_*#!olRonowyBWgFX|}{&Y9s6-+aN1sFR!hRNapSO3&5m~pmy zp#y9*$vO^5w;mz8^#)f#8-WqH+Hb3!n;zQQ5fG5butG#MPM#v>^HX5?nGWCx$Mo^e z$yOCpMg0mczIit|cM33W4aADT`8Hg8Kky-3;F?Ux?ONUgo_cldZzr|hxaZ=3%s(<1 zJHQL~4NV#WkuRWyb(rcJPvAA;7?O;)kJF54WLCq$)>k-(2@q*#<J8e4wv6ceAB<bs zXd<a(r?YZ>ZiWvND32e(jPkJ%Jq6MuU`Yh_jYG$LAU+G+7$$wIk*>a9F9a=Y|LYQ5 z0tg(_!&|?GvIWbPv~_yz{{|ip#|3=a<c4b;Tmd31ASQ955OE}hjv|bS32J4d-DC*k zDhHO^K(6#6;>-9UqtGn1JLUVv*1plcZ@cAKUXE$J7Ff^RlIwndUT9tg9Qn|&&$l?* zJffpjY`lgHb$EzUgyF0m3Pg9q<lT?83u_bb`SD?h&xBFJu9@L!sAShe(vVDc@DT&` z$jC|mKv@Ly$`l;Z4h0DawnKXemQRO+B2m)LG)#WS7>r;4|6vk3{sNa(15LQJf?Nqy zfR2IkAvql-C6w19o51O?xau`b^ryj>CLIw_;D@k-5I!Q>A&Nsjj_tr`9vX%4xkwW( zH&T-0MQ%&E;fHXErcoQ-;DwkvY<~w{FwV8xv);X371Fp0c$%?X%x~`v{OR<2RXO{O zJQ7EmBHu$40WEAmz|VlkLEJX<lQdv_yxOyt9{^5bJWV(R_yO}mKA1j~BGJ7DZzS*) zP+x*aFNApw39Fmjis@aPlVSNN&$)AkyyaFBzk~EGx03i6{D<Glgx^8%xLff(4piR? z)e5TDLiL$YT?f@MW>^0U+^<<JvIHnbI}SpC)}}LIkr+w)B#0{z%|@eiERE6^FwU>2 z17TsU^6tpz&z=C%o3{W=9F(W~la>oG-E;yNGV%WQOO~*sg_th+gI(7D#|-O&YzB_A zzRO22j%({8^V&>oH))S=jb6nMe(_}<ICC^({CC$yIq+n!n)A?bGLzIW!}_8A$7&3l z27i_jm>yp-Kz7(`IC9)7_7Fd0#kYOTaE7Qw_8sX4Efc(cS`#1E0QJOyeXLr0Ru1-t zF0$$tSanX2BW{7fIYBPG8(w%j$mGZ2AGZbB>mZo(y&yBsm&~c2!p!O_=2UBDRxiS- z4x$|g?G}PtXo!w(#u`%^oK_gW5q4=ZDF&qyN;Om_V0;Udv_iyoTa=2fG(8D^7yPrk zK73X&t%3d<EPdkgV+)1}`^8$VrS=-8OK7ciZS5Fb#OpDnbl-0=rG5o@Ry8WbM8toI zp)zO59b~GWeY&y+>b{lW<6V})^Y_=M-AfON42Nf}y(w+xbr6$QxX<9vFwS}cyaXY} zVMwVZs}*aGd!5maGVM`@B31OQC<r!iuvEnU56J6Gm0OX6ykTmTHQYlL-z0lHSALVX z#^1y7Ro~?H*gbr?^_#r3_8tyToyZ)1%}hozhYy*_`OKk+w6gZJqd;f8Rx$vsWgy-% zilNtsKw;`=w^{8ldN^Gjj}|0_Ht$>5&AYNk7Ls-?%=c-@g_tPt;h1c*XDmpzVSMOO z3}e8xnq`zsfnk4^j=X(poY#69W(-FsSx0+kxOgvPin7T1+@3O8=gr%T|1zC~@2tRo z?n86my5K*z`ZoTCycB&@<QWWLihMD~q&4y<3|I5AzK_@o!xA_alOftEwHQ+Wo~nT0 zTPLz%Y#XkMG0Df!!Ln!6`vu@01aM{x+#M$rH@o+bF+r~Ta?m$q(>pkiTYz(dc%yR* zRGbqeb_?Xc7o_bRX#7=>)FGAdjD*WlaBVwO7eKW@RlCMLrL9(I3*h=vFv5k8jsT}0 z&2P_Uv(XAKi!lwtXM^Gz_HgOxLO(DISUz<VT#)I_Q(!H#`!GSqO1s1Y81k>pfj6ZB z?f+gsAGKWrW`Q4)m56wV`#F@!+yrQvfsIyWIs;P~{4uzFJ^WB_ADMFc=J{`Ic<ZL) z4!e{xLcGILeB=-3LPwj6Xr;m1+ja;3fN6P~#ljETIdCX_6p_}ztH6Y43+m_%Y>tP> z&yNk$KI&qEE4xt}>4-t%`@kOo6OH{ohGV)e2Y*9Y;Wu1+hpsv2F{*AkmEOj2x17pz z=R8KmA5P`bFFwXs4?mUn{{D9kym1qA{;-^x)jwfQ^+=M{0@-1eLYpt>^Fw(t<d;JG z0KL7!fM~V5wk4wrpYd600SiReX|{)lFSajEe`d%h<k7R^-qs&gW`FP<R)3z5rBfGT zXd)x*k0>Tn8Ogsr&5s3`-q!&9@){_a0^xk-9k4GIe+i>>9`A4-zapJob*MM_L^aGY zm;$_|R>gNf`bY3jN&~0!)^{L!CZ>7B;AN@xseBd2%onWbCz7};@I`yCN1P7w68k-t zSW}|PP7nm})x#iM4ijb#e~;|yhS~&NoCIADjH!q8gA0IrbAUH|)BBvYRNWGw<{Z^? zJS#xW|0#~$H$crJigT_EaQn-Old4O2F*1rHE?LeT<}>r>M>8ktCpjM5+XmQdmPG={ zZ!*x(WnP8Ia4Lnti+1~*<Iy8j#zC}uR<gDNRIKZq`t(ik>#b$80aG{OFl%;y6BBT( zwV30#mS32zl|G<rIpa@JtNaD93ox0fjIm0x1J2Kk;~@WM%qaXfTn>vOu<vDXW7?!U z{*yi{>)ej1G`{JR_yrsBiqu|Df<1|G0&L_m8i{iOU*QaE5D&p<2-_R953HWA5xwAK zp>@BM0VBf@It|l*Vg#^`v4j{)8K0xft7D`s0*|m;=b-O;Q$Hs7>x<#P+C$8AF{V_| zlR3N_K2TP1Ej8>$6^+zzCRMyh4aYH#7kOl#<CqkEkv}vX$7$^^GTS|lZ}1{>I1!(I zj@S4(h^u+Sw~G9tFH#i<<98%J361Y$KBS$V*};MRt>&meSjJBXqdiJn%tA|0%J?d? zC#BN`6q9T}q3_>eqTUYhU+Ybckzr-r4ot!XUK7@bIT{n-30u&FPrHQ2O_!Q@eU(+s zJR9`_&u4j!HST`M6sshTvfJH-_J_R%+=by@W@Ur?GV4>eDV->R>1i&mdWSzw`!Sz8 z9TkB^3HZY(bG^SkG}Eh;N{jKg0Y3-*Z}6YnwQJ>(7AV7jh-1pChk)->NO@(DWup&9 z9T%}7oh^_)vvCwGXxg7wcp?BB#@lQ0Jf;<8PKNEH<V*~uD}w2%bS3aQk|Wq9`Z}R$ zN6>V^>$sIike>58tT+PdUq}D`XsCW2(fV70tyfdD;ywbSj>IXhK*#_~j<Kt&kD^pl zQ%2!ZJ{#?`aSf?W+Xk<cnv}Muw-*ujb=y&60QJ^<-5$gvWB>SdNFNNrWiWH1SsG{C zi}DXlSHLlL9i=Tu`q>_sq2~?ox);6Zqz?i7g#9e;Eytw!O||Cl9rT(Ja?2+In)4rI z?YR%4nSsX95#D+`YhxWeSeDe8<i}2zG)#h&4?=09L4M9|dfp}{i;bDiw}4ozku^a- zV`nPU&u)vQk5&*`LfeTB9DhFCc(J4rjS05pWQ&0EmIvUB5ZuxRXIuccya#6-1h>3R z<cx!;y5()Ya>hZF{_Sm6+<Xv|zxg)pmmkF8KYg1y=Nv%CZU3g#8AYOW3?qvRDRcyW zQtW5vQ$tzrADqfEFs<d?X@uHN-(4Qs0;aTvMopH<v0YWOxeN$h1i`?;jGGGkhg*pj zL$UYe@3V$hfK0=yY--<*!?n!rl6}RmJ9ES*03X;N`M;Qe$E{Y&>?7Cs7EA@<t$rW% zBT)AU=)LODzLP}WTu#DtWc=2UPPD0u6^=GO$I>!5e7%{m(>?#P1MF#iE=Sq_mo}AA z==&ayic+;TZgEu~nxz2t<2Ck8-N8Lg$Tp*1Je`BOE%i~NX-bz<!%CXc?bP6>DNT=2 zOFxrG!hc5l*{u#{FZUx8l8{q@Q=};>Y@*a@#pz}?Zmo{UqKmGT-afD9C2(y3g2`VJ zOq@?;`=Zxo%ni=U3ztzmI)JQjApc7%2|h3mxjTkidB6?_kiE&3cD(pz;3w>XNt=sW zZ|A`n4&SQ2&)`)MKA-Fqye*WofJ=ek<_1cSJ4gsNhoNMPbh_o%w)rkN>&=FD0@kGY zAFD-<vAW($z|R<VEjgXauPeyU+tznF2|R#qB-yTM1txFJ3%c`al%G2o@z`PTx%Uu{ zjfT&yLOiD6bN())_?(~ezbH0;&QIbN#pav*R6eC}CgxG#tDu-gII6|&s=jUfwUb*H zg|}2LH>S{42Z65)5-&lbJxEb})2#PX2NKNNz^L}|l)Jo{U(5l4Jdn{jrsA;w9S{=@ zmdL2pG<RIkyus5h&t;nRQI55m<ssI;_n*EsOE4APJozD8NH1yu1U_WrD~SDlu%;xy z=)re{=xxW&vtO#*(!xTp<TMy}Huxvo{d^73%<h~(DO4N@rxY7CbgNSFdnFUCg?Via zEe{jbrgcRQB)0}oHmA;{fTc92j<e%5JMP+JbVn{FT(K+5)lw#I*p-rkr4-2|0ks4u zupPKvz}<#lIaAJmLN;iAigF!LAYq>{l;*?ilcD{QIEmU)8Y@;2Vixg0>P5HRe?*an zh))p-6yo>_@Dr}V2~;6lkgDA%CNhxagxE2)%ZqOYF5=KEYq|{+?Yb6t5mUSGefmvP z`ooq2=6`^=PjTBb8+^6Sp=XU8&9^a(gkj=jA>}l(0Y=>fr4`V6zbO}8Fv)rjYYEW8 zsZ{NDFpEnhyUdc?W>4=K(0LrxqtrZ4(bFbkH67D1?q@lu+d`m}Pb)au&tS&VWBsgp zPT}6`M}7Bwiq2exe_cI#<V4$v3ATM<1h1&U4C6Q|-43qeRUof~$m@_d?GlbIP4e`q z2eUy&sJMSTtc1XLj)-sk2L$u+tr_`2V4U8A(jeNuXO$BZNF71R0`NJeMaB+Z?&Aia z`j=U?$bQJ~XkId!7{{6$$P2)O7`|qrf580CFkLQ@Hl_Ap!-;or=-pE{@?}PB$?u9a z<k3_aD`(v~Yal!eiVws%oBh_c&>R@YnBym~>;Cm1OShqY5CfW;th>U5)wF~+zXSXV zpS`B{=U{Ftxc<KPlOO5$=^F^YQcnEPfyOrpzw$8~4-PQr*B4VZxs`G6jG*YijYu&9 zLth)}xE51uVL67ATf$IC*Sn0|(o1mAR=JSB1oR~^c^s5TC_KPWpC0#v`!^U{0M0HD z2tdJjUaE|6cxfK^9VjScgA|ktndP*Qo+ed>iI^X5pY(d!{~eR{Bkkz3@2jxaFpsX? zf<9ey{t`f6L00IPXE0%6KPG?tWlRypcl!s;VaW_3x&O9FM8A14QNEdNdtYk5RBs?G z$uJR$Fu~(|Du7zEkG0!LyaAX?jEx+^mnM|3>q&b+Fw|Eml<~l7dv9j|Ct)Z>!_X>w zF~8&Q!`~mE9up;8j0rBOffbOy<`A(jp#1Xxeqa0NwvcyKK5ZYjkas{nZEtoTYsfob zE^Ti%k$1pZw7of*W&0P>{8S^3TgYCIG$KwagkYoX)6j?^-}W~yw!7e3yU0t2<=fm9 zixWeZBe#WON#)NBt!a7@up9XD;h~s;tx5j?{{$F279#mP84hxSZxob7StNo?k=QjU zFG9+s@mmtH3(J$gVPYitc5!)~$^|B`(=*${^vod_S+9Tcz<IqzM8Gu0%@`jq*j+MU zcg?pk9K_eQmWd)@p9z8o&YMB>+;1?F9}MQGA6pmehrON8%Mb7>pnO<xGNlI`%%b8j z)20fh276rMa*P>NWwpTI7T<^73d5{$;i=yO4YXUN{|%Ycu`KDZ-=iakgagf=si%E+ zZenbly&niueomZr8749-PW#+2k$t<5t+dZwLS*0hw9k!@-1lJKd-GtL{0@iwDS&Uk zB%(Y3fh3te>jRDR>{l6nM#?l8W|;%95zDP6*V;u?cri>WfxI9TOgDJ<XaL4bsC*Qn zd-7VSl3C>wSP_Nsfv}m<UcS4OWcla3bjV}u;__#K<~aLzp7C!IwAXfm1<L~adLV0w zfBM0qE3%iT0nNE~xAfbIvCdA8Ihcs~maOJ!z_kLxdr3;ZdIX&Rc^Dj6dVo0-=-P3{ z0aS=rDB)0!hvP=U{xJvzGHJMd+sv(2o1BV?bIGj+mM-wmMpBKxq60(TUD5v;J|e^a z%}K95%4nsU!9ziwIax8TImiLWDU$C7iA?Wpj1ZYVmgKu_M5Zso_s(-1ShbK3uHJ`N zC+yB{g<;BM64G8Op0Ag&&{`_qIE?xK)vo`(_qwUmvOUOU76qyU;O+vx5KJqCh=fxB z`6xVH3cLH6Q8Afk0<dQ~%4Dx}oanJ$@~j3v9TUXN{-p{-w)f1o3j23ChN)U$N55S@ zUc+?tse|Q}*`WDr=(va(Ht_QjhSHM4@DP)jDDtZosTK^wG%p7B)Pl%+$5S3V8Tg*j zr3NpP@v<U20jUIn2Q#DCVfm4JGGU}|u#OJbzN6e)0b7;>U)8Nut@Az`PJMu)zr&jY zgJcpDSIbGiK-t`yn76hx%t&9Hsi`nJ87CyeB-}W`UdISQxhDxXh~Kx24SB2CShk+Z zmggvlk3)+IAP=ImpKK2;a)X^9-WVG4m;T!PuK&Nkon6%HuLS>3p=ucff)Fdk^dWcP z>C>R}ad4*6G~s<pn8^!yunuYbX0lg<^*l^Hg=4HH;<?zum~cs%)lx>1sbJ&*sm{_S zecOe<vNzD|4E+3*e444XFTG}eSkUeiyJNVCZ(8%bz8lKuUQoxwu8)Kucubh$6M_S0 zA7$hsJ5IN{TLvf#CF~yr=P2Hp1`UIE9NC`D1np1UmrTh$-;nQ5m*{s2Ry{`izvd)2 z41DyvVC%-^q#vM6PFFLECrEUqB2_PuTH!bn$L(({#Bq1S?@Xk?`G}IzWwftuqrK%r zgj@x|{jEX}u?stUEbIoB!6AR?`P~xYob9a2`6a;BL0HiMff-P=34Ei?5;)GPMN{U& zM*$w3{vO|@3giJmgf|Fq5R&C|X)fv2(R#-&rdf7jeF+oP@vx!M0%w!Ol-?*t+b2h} z+KyLW*V#lIGFs-tR$UHRGx|`A4~sETz_pk($p%bRMq0$lx4w(7+KW^c^g&=qG|Hsi zkYZUIlwCZ6hXR8CIjn{mjOp?60OHs+5VT%~p#6zkc7OCH63=z8(c1K_10RtqDKsdc zoE%KetK7>^<N6duQ~ZcEDa05*tQ=@eBgVJ{@7YXrg`hPG9ib=z_Ch2=)}*%G#JY^# zHOgf}`^K}nrFXP%4~2VsO0WZ(ieQg*&@dA!QdzD=1@mJ(7Rcw+k0Q()>*tMqTR14v zPS9V-JQ-j)4h<QMyAoq4uECUXTSewqYrB}!_L@4F2zk<KmwLPLOAbbUo(-O!mU$85 zXy+L+5kIev{&-B}JYg?DnvAo1ESV_SN_-&BU{&C3$UAOG&|@|BI<iYfTY^T1XlGtc zK8%SDH53^n)Bf4x9j~=tdkCfWjBm0T2=N;KZF-VxhQ=&z&7t66fO1!<X2cbYk&1*1 zK|6*98ed&SV*LtgTI$(Y86ccDnxOa!ZZd5*{*+yu-NzQIReIylG|GcsuzwuT)vnsE z?8pQp_JzVQ)Q&YqTN>~^Vzdrb4h1kH8iL4F@JnX2MIau9m;(XH3@N(*w%=IgXa%Ni zOQ#9`S7`fR11XblhrPZ9P5*+TGcx{3`8KfMjy5@~vjOu*%otV&jgV2U<)7j6`<O;I zr*(bD-!LhMF<_zHVG?EwrWw?3Fzr-W8R_(P#9+j?p*X~hjw0je7+N(TugLQ_Pg_3S ztb^#_H#8f(sflAEh#EeI17Cux2md|J@9wGSu)q6gOnz-nJfL(IC+GP$OPtL~oPov< zCC-aUoGF`Fc;q}bjc{peaww0TPN=B_6&ppeX(_}cG^GYNJ{U4d7pttkP}u*5`jQ=w zdU-2dfy1`$1^wj}W|i$>*Jw6#>B-%VEjS`;4NtN9UWiO{h)%lsMHnJp1mp8}^1#2* zT&RD#n;BVEg2O&k$ay%&L!PrY74g4NG=2t=yqgFKjgW@|uOg8cuHs=kB4A$ndxnjZ zy8<`QhJ$DKy0PuiX|QJ%DK=h;i@@iA2rZ;=SNH(JadQv9YCXTh%D5TpgSGiJl+LSS z@1w_Y4D$##l4+DOunJIP-A(Bj-1djzUfCvSq5;Tb0UvTN-^oE@A3(X#NzX{T;B(US zIW~ds<un`fVfm41ie|0gm{ypqEAY#GNa+Ld%O#{V_+@%<<G<6S(y(A-khp}}@lfhB z`>ae2{KcQz(qDeG8IIq|7xbcjUfW}^C%MAtHPr_|VF<!w4Ut50J^lwTfV_qLXGDyV z!FLf6m-3F0IAST0X{S*hU`C;ah%06U;+^`*3~8+I8Aicmhf62_#TFj_x_$Jvu{F^L z-Tcx7zR_i{U;{DE;BB%pjfNv-z-AaV9%^sMn^77Z&F+#X4u_LNoprPZXCr01n{F5u zMt%oUnwsm49=Q&tKW?1f-^SEk%t<y-x=spQL4RXQP*8JB3fCt%;NuP^-c*lNpbp*z zHF+!|<wrRwL`n{Nocs9{+9zqaCx8G(c9_lE)ux%_ww9CsWB)j5`@1REUuo>W=|J!z zs4TH-=rDNVj2nqKS5QVPz6Tv5W6DVAdSu>#?DkP3zCal|0B#i|JT(<le*++33nR<i z+#hgbyXqH;O3GN2A2vJHi)>M5X=$Aa(~pfwsxQtbFue%53%hb)_Qs{z_UWtVQxi)g zfWQ45I##fWzY+sJ#^lH6q&rX|)7E|1_n4o8y=0n{f>o6+b5sc5D#7lrq?xoJL10&4 zO3E(Al)23!g>ZWr*Q5{)uuoA6-2ihsjE~X<T-&Lg)VBZO58Ltd?eL~#r%+T!ps)#? z-C^cRIAJRIEDP}o7sfOgi!B5!90~p)1V_M_6ojG>wK23nR`GQC>C-580J(t6aez%k z`9JoWRKtrarxO@I{4;trE%VcgE9FM^D2+WeVZZS4Gj$k=Yv1A6-ECXmq^3@d^cybJ z-An$z%&NPETy&=op!6pY2_QJ|=ubdtVUiT3)aAiDi)&a}A7;W!`!n|aB*B<~NlBP2 zkdY*$1=`njOg1EGDiXx(m<n}uNV>Uu(nZ46&~hp4x&qc73X|LDj2yUOXy|}YnOR$b zatMZ@w7|^zd=0JwCv55F!qUxi8s#9wHiWzU3J{-X(q6En2BwYQ(({hORe&s^I9PoA z^f$w8>`fky0KdqhQEsa{^lh>OIC?S7|7j<T$<xfWvF!^ewUb7;FxWBRxKsp25}qii z4L8w{dW|`69m9w9Ns1OjWirX+F+ePdNPrG^9h-`h#2PZAKj`G{LO*gba8*96YKHwk zfOZEW`y0h0d>SMlgi-~00!B1KC=U6}z%=8+kRn6rckKGhf#hIxdI5Za>r<;BXtJwT z<k@uDEs_p24nA2*psZ-)JAOt?TMJIekFUbTFVoxb{(PJ6A+h!UBHUpj-Ph%`$^JA- zCuu2;gCB!#5oIz&q$5mgyn$7zyGg`gW$aLzrzDx^fDlQ90^Myinq+45`vbYVFo5L! z6xx0Yi`IcN4W_RJIo6QZpU|Kzjk0MYboiiU7EDPRO;UEFQRYB$aA0Qm3hPPfYYCM? z(ME`VWow+<1zCU$6jHjFQl<&kxDY}ys<s4Ksle}UgFE#ddf^Q$c_z+S;Mpv1R8D#k ziIc`rlA(<OR7(ZCKZA~q0>vtPdoRX+);}44&<Y$!qn!?<E74A}%h+^Kl2~bG^asVK z`q(kNWgxDD+63gUf%Qqc)Ui#pgS!qkIj~_GjA?;%%CHA?mKK=<$-&5FR`)iHa;;(% zPlLKuu)jxX-@;BLLKYIti?U=i8wA?8n}V7RrAbn<fUV#E{Kvh2>7ODy43qxq+<hNN zqx2<kH4JI2gSLdrin>Y2Nw45c8PBMrK4#C2?-TL05Kw<0;VeU{N#Mjy@W4qT6%;#3 zf(jfv`U5%Bdoc2IOkii-Ca5Zcb{8V!Z0~ddwMRph15-MnWgNu9kP_g_nc72?Rt(d1 zGhF^7l+((tfhenXgH{D`i(z3GLSDJbp=u|-QH1mSjC`L04l&OK8+;UoTx3uI0dTff zFz09Vk;}d8>K2+XeU;zO-S>fj(w{=6z#YQaz82O+Q#8jMN{=q#fR6nc8(u=#tsvoV z!F4y_`Zcawi|cR3b;0!~dL08fALXDWTn!CBgTked&WFkc#*(8P4qFcLB<K(@RzOJX zNQsNvi(F*2$?pSGxIR^aQv!hvu<L0?OLwiOC<(MHDE4Z-3`&Xt2Zz<<<yG?W$Vhl5 zen8VG69h$qfRhHwaJE`^oprrExtt&2v}v9u({Cdu{cUc_N#UkpC}TrCh(L<Mu`qpn zib>=loI-GCp&cK%`=K2PZYkPHfD79CIJ=KNM}Df09Z4<*oNJ&aAC|Sjiz!fLP}Tr7 zQ(;mQO!h(0vE|xwC^<w)V9Kej1WqLBLQx59jKHZ0NOl9n6i_!L?~M!OA%sID8e1`{ zz%M;JTOe=<;AkI$4z>>e<D0!d{Zo9GDwb%t9aEt_Cj*<_XQEsf?C3gY((ZucKa=7% zs1Rpb)A~%b0-W!ll>lc~v<iarUHkkudmVkbSk90=Xutib2~!G8)@KujoZez}mYfV# zUIm`8s^NEmZ^31t94M}b$d93EB~<j%cEnK(g7VmcDO6(Hf$MJ)-58SjF(8Z}1h{BX zhtLj)erc4-`Wx%)dAkE@5>ssB*}lHaf~#AoS0>qMS?=Bs5-7DFRLay#9L$IhoI=wm zP_4qGH>M30#qBam+;kqU<LmZ(*U|6G-Grga|K%>hR+KtSdCLT*iutzyxK@EP9vUx( zAN-H0>r>Io(J7!+4pd=n4yKra10yE^AvP4F@(a<45F!Y;0<pnXw~x{n*HvMBsGBj# zl)4v?{18(Sq1S-We13>()!dHBGs?+efl^tZ8tkY85Xpy*7_4o9i3^}2Z2(jQ_yihl z#|ZeOeV$%Nk#kYz<WrTGF_p^;F<p;<YNMx(`#k)34SZ1>-*2zJD`zleD4)lxYdIn@ z8SOw(5y66}0ZFihouM;0EqefD(%KG<^d5Fk0q+5C^aYY{@*@f_(cBIEGk4#Is!=*v zLiylE9FrQCngDGMELjiHSHPKSTIHl22)Kf@<0IgP?EmyR`f@<ok3Y+6-{37B(QVAh zmWx+S?8|r^Ie(b|Q30o{hjmv&T=boN?F?AbsKqL8#_UeGBXRPfun-l3&J+6-_)9GW zb}_qM3>R#()p~pLUA6%q*x%ACcj+<CW6C9pH?;}gwB_#m5P=fE&9NTB7*OCJ3Bf6# zqVU=pm^2rvW`o`fNJ_K}f$Kxtn!SCEPR{i1ra8D)d+5)Z?-lU2{Uvg;1=5PC>vWy@ z_i9K;C@+WFGPu03pAr=UT$yQd(z`6c6k`{_BosmN3L!twRN(Ux0<@+%KS_Zo1*MeD z{Z3<wwl|Y$>(O1}z+7u`_u5M62`*vcrD^7C!<WqUefT6yZ{;($JQjp;6(FZV&1fj_ z!McSoezqp?5U9Ke>SGB<Tb;=1eUu`16EddLKmgg@6w_>tmZv+foR2B^S>1UJf87qU z6v`W*<a-cl!4#nEO`Fsvi6-FC51PJ+3B3g|#nB{1<-CyRa3qjW5R_(J6(n7k0+mDu z+Ca5~Z0`pk!4%PW7SjczkM~@|l&e~)Rq%UE+l-tH%k=h}dpw*m&w=(bD4zmyS6EXn z2s-d#osW{%Fy-r^P@AIAuJyrmADx|kje8p8a7rfG=vodahap}*_y1s;wgFc_yMWSf zL;Ym<_6Vb+^;Lu46^2(CAkzo4t8`#GBq^X2iYmc@lC}86C^SKWI0|$cf1(37(SlC5 z<CLXP5#7&SO=5yW&kbZnzyIr6TH4@d;H}&}-vXfYf+@opeL#T&p%CbDi0!6vraIJ5 zi&8Q%z?zRBC`^rgWsmDVik)_ido+(E!>iq$li|zd7Q~JQe%1Nx^$8GD;Oi@)>b!xg zmrD(0{ZfM#15522s))H!WIoh7>d=aai;pytHWV#%h$J0;mvj)yJ2c@w4TV;WKl06i z=1r1wnNSBekqh2zv4}&ybir0iEEOctD1x+$3WDSj3BialFd|^QECuu&KW+JR8k22_ z7TIN~T3u!f{FJesaoV4U@k@RVnl6EP#zMYI!=#_V^a~7p4(J4lL*6Ec_K8>51J}Xk zAbjo1<geO;=y;dd=i}@bY_?!+0(QphtclsXSxEQnNEcWQ>QzYBL7g^>$n9lnts}wf zq%YPyNN**Bax!#{a%+JkXaYDuK4HQjA(RI(`w1Q>b{{`&srghua%*K?_}J2F*i}2- zl>afN(;>j2)YQYGcj4^>un!#fP56lgss7TqQySJc!D`jlo!kuk8&EWVSWNYreA28X zZ(M>|!Bn5;j%VqO8Ox&u_z=|l=(ae?I?d)dH0y4k?LvA5skXOCeW1AqxG)Ej+Z9lH zU>O1t11(J*#5@S(+-Kd)?X3lpevEf4cY%kdNtR*S7COK|G`|gtHbS}pc0C`yTr{}U z?e-Q^&8hD@`5LeYv}XD2a%$ZU2zM}Y4ETF&Zn}@T;sVP-y$Pv$bd#dJ9yX?Xp_?m6 zFT<^Whr}Yyy_kgcoNPZpDeNsXJ?R7?v;g6UXmHzi|Jbo4J$R7xXp|yrEeTA+8{spl zQ8|ztB1o2F`d-F?lL7d6ks;vZodW-Rn(13RkR+2(+hVr7-lYw=4Tz)b^ZDSdDqIDj z5-1xX`T<7|AT=gq=sj?kqub-8>okoCsCRo|N_K#|40ppq+G-3)UX;7v+oeY7V0x;W z90ei4AA(?xBiY?x>;Gk)1(0?@=q}rlgCu|yeiH=qk)u%#8YD|GnKuElJ0-1X0d|3- zeh#OKfiI`h(9i(&{nn&>8k4pNElVcx{+eR6grad!<m*d&^mfP1pcaC=6w<A@jf%EL z*sLI-d)a1_q>_ta<1$)SrMX7KZ*%v1y8}v3oAhDa&kks#=j6=pp<CrL1!*^fl^Mwp z%v~IW2I+!dk)u%#+T1R;{~wrn=`v_9g0UCEd7}rDiU=(Yu(qSWEpI(=BhU)EkhSwB zv7yaD_@Q)k_cf6Xkd5Fjf@BT4y_s}df>fKPSwW-jgH72;NBSMahK)3=*8IXXT*;hl zUqI=Bq=N~ahE0ZnJ!?3eGrN0=>}h&iR8+R<rQ1VOcj%sh<0+m>n=C1j1Ik|H3Ijfj z#uU1k0DM04@|AF{fP$0YJ7=2WY6D3}99Fju)L7;pAU^=*pqr<#=(S=x6!?l@<Opv~ zc(A8HH9~qZBv#@!wUTb{pe>c6O&bHgSAcYZ^(5Qop_jMwVG~@S;g#Gy-!6etXPMkF zKqBl@zI+J#a~HR#1n6|qdo)Vdm{ht8m^xW@we0r}Xr0@TddlS(?#x(w9S;IVWnR7x ze%S#W4u@O|XE_5GDpv|NHW=-$Z&?Z4iKARxM$oc)7v5eeQ4;dUKvCG+6&1LfA-xpc zzKUd18;N9+CKozLk<$G%gNTz(uRyoGPsc*VBPmmPZ*A_LZ<j#nElocr1uWp@tK#?| zzrf~Y<)oW9KnUCvI_ctS(=SDL7P)Y;9)r6LZm!isH@Og_n@`2~B2Th^XXLjnkeCXG z><gEc4$kCm+6?Pb195F01Xke^fb<laUyZW1MIrqz<>Or<v_o<=I=-HC^J-G@1Z}RO zRi#Yws?KbH^rl7Dkxsrtx}llcC7SCs{5f~Ow|k8;fN_u;8(U~o=)xC+SSY9E^eL`` zn|4TR&|NA64P(3VnIL#hvOzLzHOZ;Kne>3cOQ3bMiG5F>1(ODG)_4o7YBwnYeakA4 z_Ze-GO49YCnEOne_EZPHidsg5YH`<YB;C<OJk>!nX%e=vn4%wmR6qhZy#&>=jP?}? zo^I3pROgEBZm&Qox`AW~rdohB-b!KGqsC%6ZL)9s%@iwEuD2-U7Tr1NfM4Vo#fA-| zF@DG|V*-oc==ruw;i^&?b%nWp2OT`{nUe`v*I+6^_G%*tPZN9?M=io72{h3D&OBaU z0ZPXyh;`t!wz7^S9VAGJbWa~jZvY9nYe}a*BE6xJ<?A(9q~Rf*TS?n1P-X{i{8p1R zAnCm%0uU&I{2X_(Ptg`6w}@^o%w6+2rl80t1Cl>8E`!c5AhTQvbaNF5J4cG`hB^Y1 zhc(pV9Rd59-g$bf!+9x2&-30h3lp#z-7835JpxK6!^H^*90vOiw6AHX-wf+@-yo?q zz)Fz!;;MG@Lj497KDdFkaX&f_f)##Ry<`Kgwx#X^CvEh`ZNz06>H1YPuSs)To8}sA z$`Ivb`vb~sa;y&%xGA!u%s{9EP979TGs`I_y;$9P3qjH`#Ym*jK-0h*l9&G0feE+` zm!y8pHoh`f(z7^-wBI*se`x?i!iG%TIcCAJ%4(3i(yM>>Qn<PVM*JMAe2_c?4j#mA z-m(c+_vM$gAgOSHzv8I5=H6;(n+;FTha@RNqoF82Lnacs4{!u<!EHwC8j|sKG;e6< zv5lJR6s*+)S^hcsq=8a&vk83m5wn^>pvzlbZTj966hVHD{M^4rxrJIJexs`m9_n07 z8-m$Z>pBvcMpwVxBX;x4j<N^su>rDDg8KN3pnabQYC#5y)^`QSHjJX=V*HC<do*t| zUPdMZ+HXIj0N4}5k@PglKIBrkx)e%Am>h23esHoFc$0}Y!ix6HCwCXAJ$#!D{JC=j z)NA3xJ77t|{Dh)WrYmNj+9ReQHf?BDk#1W@^QJh@)g|~<O7oHKF99egpCnL<-c^@Q z9Q7t<H)gX9;7A92g(iTL!^uGa-cZGFc#?7u6qeql1QP{b0X&4MVK~-mVO7R#q}~FK zzZ=kn?P?C#=fYVgu&4F4+eZnRJoYfXx2-tIn&JSJK%V`7jXfrb=|kPg6wLx^KZZCy z+0Mrw0W;{c{LwFfCb+s1O1}mr6M&OpZ{NVTp`#5}HTUh8%wSv-h?_ufGEb&p(|_RM zg|?36I4F+wqK$awwNdyrYe;u&q-8@JuQ#Q*K9OQ>8j?9*<aPy={$7+!Pdf<HSBU^G z%I)MqehebH0M5WT>Ci_@lWt(@WJX73h(p7&)!84DEi%vkpTM*;T|$Ph8P5VEuh5s1 zrFjPI`x3wjn4rlX^iHf9pLL8^@-}4uz86zVQ2-+`*)kCe*6XsgMh~n*_A|x;hhlu= z$M^1o%$Dm61*l~-jPk=-2g3L=IL<e)Q$Cf3+ImyPY(S9@Bh}*>%Xu}t@lU96!7YMO z<GQU2t9H5yWCJNyk#5^WTSJ1E>QnqKk>VW}I#ez`w><*o<YBCM8`=p#UMUm?asiw{ z17$D^EW7j#Q{cD=$7o>^*{z70z_cNK88`!zd2%1{I;J@6w|iU37HrP4kG@d(w5$zs zFpwOxtG<9tZEs<Jn3MH?p7k5f!lVvH*ye@ZehxRLp=~!9bu#!*g#D*LNuMD35%3-0 z4O|8Fn_%rAk;=&~v6hDVXW;2|rs{d#7$^<f=419vx&!Ujl2rAyw$xEmpX7n2B=;pX zo70fY0pt$lqa36K8Gt}36o>jQm7kMd92$<P?(PD@qM+8`Ljn>XDQg}FfazJjIsqJJ z{fvLMuU!xPz1yeUnT5jsEYsPGB8TlcLu8s5xYj%m5Av5ZZJ!tTa=0!GZYfOJ6;3!E zPAG)2zWz$5O#}V|oKN$nJ6V|+T)@qt>-*B{;PuC0lR`TqSWrBINx>x2?Lg6h->oB= zYNWQgo#$HO+}*5Ll7@yfbmUCz&k|5}qfrLRpe#RUat{YAJ(88wN#GF_D>2-^N!AZn zY5je#V!G??0{l1YEMM#A6VCo5e~xB*FeWYsH0{@oClWpjN@Fnj%ka5CXWm$r?Rz68 z>#wU1q`3}O#s?QU=?;=4Ve@nF@^Z9FlPYP3qQxvrYa+=unp#r4-<IUz)(++;l59>I z^E#bd4WBijw1FF(j3zlEAEGj6at~gz3L)TN@^ad&X159BC-k~7dK%y)OaNx@Pxq@| zlZ7_fu^h+#<@93B7m=Ys<}*+r+~l8_hq9ZoB1#7_8VRx(K@kFt<N&J@o2qF^xW15f z6(lnLU^x9mh>eHS{oS?5e*)jdRG;iB@j9q!H`(EXk?tC2dJW8d6lJRl)wb93=!SY` zyOMQD$;PCQhBPG8Nz$p@LfA3gW`}&~4n2?-DKu!G3ETvVpe&LD$xjz>YwUs_kMRS# zn67*eVUqK{YF<|XXx|GGdqFhRE4Qd>5Iu8l%-T`A;$+w7JoVBelZ7#%m}vhv+0HMR zOPc?pco`GbNe3jve7FTs#s>uZ3qJ>m&CHO3v9uWs{AB^8rlX6-BLZKA!>ZuyJmYmA zth3K;g{7^7btXJ!!FEIDsWzy+6?Nmyu*iJ(FJX#NCS{g3qS^8eNjce`G|DXW6P8A) z{e}V*&WF-KhWwF}PeU{&iv6-R;3rsZw9cCB-v>@L?^F<f3sUbuq!4@ufT*Htu=+n1 z)x4m$lQk<W`d!c;jWSGzN*Q5jG|FG#66e3aZRdiNqK*)fLM8(T5n(UCWKv`WMIuhf z2a%=#F(S0Y^NH!*@fG|K_ID6t4}mX9^XsDwEh4gf&>;B|riH|YE~;hxBY62Au*l%x zO_&}Kom-l<%yY8C1EuH%C#|ZW{UGuUK;{RbNN&|8J2@Gi5rBy#FTzA@H(4!ms?~Om z>;kC^qOdRx1#4mVr=X$$A{7{h%_w+g6%^!w8-#GcRDV(u{C@MC_mR@0S$d|mZPOZV zg-9#-M?fSA0RfKR@IE`gt_PZ*!?1#aKn46Z!lHONrzO_Wra}}4l8i3cNU3y*1~mbx z5NkBLL!j`3zl2bDA}U&dZ?_-7_osm<Hw36*a(Gq4pdkGtYv;e+RU6#|tM5epyB=*{ zyeBt5Kche?dZSSW!HI%D4EX_L+~u^$?M0+@{GEzPPpk*d#ZZR)J>DAH2h=9mumGBU z5KTboawuJG!bWR@usIH42>~C70)skX2mE;;1*H5CPMUmvd7RZs_W)UH@@m?^Ee9tF z9lo(N1Nq8_BZDTCFGENIaPs{yYLQW3N0dXPjHzXLuwgY{57ru$tlkH11Y8Gn$a)sW zfd3FEE`kZiL3AX1H(*Tckqnbl6Tq;ry81L4F;#va&C)qBx$ZA^J-!24UV?u=x7~68 za<VmnQtp!Z$KyFi?8CFFww`!26wfxx<??h{rY^nLe(*H2IaY5uPQt~d@UN*bXEv;u z4c|ViSKV_8(DY@q+<o@yueHXzBOnrh_^Ad&9Y5qpGZgd)nJ9g`oeM_-RRyqz3uE)~ z7)6eyQHDl2Cf(oXKp<+qK=42a><9lUfMu3OIRX=rj##H;UDh1*@_)Rvhf3gJ>mfK6 zDDr12RCR*n%AsBEP6p^gi-Yr!80x`;!0o^V@Nqj_Kv%{=#+;r{uB`Y@1C+a(l#NeF zlE7PbijKC+AfH_6S<n*F5@Z_WY)ej`iisQxQwZbz{b1n%aM(ZLoMYgLOY(XD5m-wj z-||lM$1KFLXt|3QL=TgpQie^Z%y1Y^j)Tt!fiRRM2L7)3bq3R;WQw3jdv|@-H1`Y0 zmoQ1gfx^V?i|A5kd_cXqFHJFzo;g8JjO!gR9ZYfKZYR_Bv3SjHTzJc^tpD&g!2Q{8 zSpZzpD@f*KCv<py@7g;)A-<~uHe!k}6l1u7`Bq;D@0?dZiQ$&lS`}wBs1kTjgD(ja zHbP(#h>0K{23c$t{<A<68~9G*elDN|xD>^=Nt2<Q2dskX!+@6G!$$`~+WhVF8PM@t z&90a&kIZ@{HL`Pjwpmw@)!hJ4z!Sa9=1$&Y8;ZMuUsFe#Zvan$;SW5}1rlEduErD; z$w^K=(b!BHlV6-BQz0g4A3Fh1X4iMHI!!tOb3#yG51}}WOhWz=!z@SzVBt7eeG60^ z19yhJ<i9qq<s(SEK?k*TFq7=2SzqrBys|zwq9I`mLlo`E*K?%-Hn;ol?9JiRVRo1J z&ydeG!EI2k;mO|L{VMQN;En$7q`46$41PlnMsh&86;YxYQxG9&fzs1A+@0b=Ys))T z7PAD9$cMm@(0F7<1N1Z?2dE^)MIW?t><4j5sY9_5sTRZ9jdV0cVe}~w{25SUoQ_YC zX*Sqs?cxcbb^{qRrgp*)#a=NeFcj}W+aJBCkAo|Wxz(l9)b0hrdQ;rrnF2ST+Y70D zrDx~6z%!T}`nP+Y#LFzb3_(3{;4kEQ*5rV4>uZy5VG6F!plf;Goudvwc40D4@-S7l z_wDDpEnzlGSc2tYqji9~x?)=1`YENp(+Q0YK>{|_!&?<l`4u?oaA@;Ez=2W1)ZWPx z5J*8d4doqB7=b`+XdJ)p8k?gas4@lGI;(hm0>F9l5Z`}>RsVVbF5sw*xZDcdjG<fg z1W9)uyz>{-AL~ig!`(nN19h&=Ne(CnFD@qLx`0gSw(e?4PWpz|QJ9!;6OcrSAe$$R z<m2Vzk;`00G(mbIH17(fXTixcV45HNPFH15+7j7m4XqjqT46*x#76W1ioI-MJ+?HR zsYWS)P|8Ho!`{r=A#Ux*xcm&0b<=zLwYwOW{T}tN`K0SjF62*hAejTot!#2PV3HLy zCU-gd=_s{3q=Yb)ymz<PuMR2SU`%uwwZ~q<nnzEhrSbnU_A8^QItES%!DPpPmD2~v zDT}|&8d{a9LDbis>kBaZ0?szT(a<I!l(re`o`$*I-sW#(O4aqf$FKiA>b^J73t%pW zOjMtnn>#g7dJzXto7obTKv`YmISg}eS8z)pEwB~EOjX)`3Si}=yVhNXU_bI?KKm6c zrDMWfw7mBgCw_1y`+oie0>lR8GL*J=k1Gdv7l&#;NNsqS9o}u$5KWr0a?+bt+l96n zqrXxZ?h@E|1Ny!fQS;zEOz(-6x%s%$1Eq^8%b#R$9gq8yB&MIpG7P=A3fusx_ap>C zmVlElAm5*}z6ULi1<M3ciW!~1k;N=!N&O!<RL=$RB~0b<z@Wg-Q3IRX!`&h~t#b>? z^%gLN0KYP^B)|8eVk_*+Ly|Fep7$YXU@5HsHR_%hQE!+K_zy5O?{czp1EsQ)=LL+H zX&l4PoJnEwW9L{?dpA&F5>1k2O9j4sO&BPQJ46El5zgI+!Ak%rhRq2=?^l3RM)Q6f z(Bd@&t1-oWPhr4z34Ahq1J&(ssSOT!%_pR5Na|jP9>*&~B)fs=Ruez{mV!(}dM<4E zBkG=4ao;e0qjND%_MGH^ayjt0ox(?N!S=4K7WtB$@l$|ebXwBB3RY_fMh%BAkqqJ_ zA(8{i!AnBIq6kgz23ful#6Avt98-xPiV6DMiD4KV1ssFvGvgohog1!WKn-DbbO&qK zdVAtl>r{Z_8qHH+Jhh!$w&<pT4jM#549JDjke&z2evSU;TvBsP&iDUe$X7Ypxq-3+ z<9>O~`fFxlT>QiJzAwkjuO?tWpd6IIZHM(sKnEdIXl8tpFiBV1Dy%u_6;)cS0#pxd zg48Nlu_?gPXfch))w1h%Br`{Ng)*XcTn+rjZhW(>hB=0<og?D`q}mEUv-{sJCPELH zs5(|7b+9J9+pRaK%mUNSzl$)W(gCCm^y{c4x4=K%CHbC7A-f<4k~y(J`6#Ajm%wlw z4`=w?j-{9ahmDv-<w<}*rzLIg!`c?`?XHRVK_Y=Xbgp4Y-vE+;jt?QR0-9@~S-}P$ z)E7$f&xvvH5gnWnYowq7-y9(0(^d_U>3!XTDdu~Zb*GHQH2B&ds322%qAlz1p=pZS zngOf&a2KR3U?#|LbUZU2Ji>k{g3X-_0Ixv5$ogHKzj!UgA4GqA_y2G2Ok*TTuKWIr zh|H|3>*{lQj-J`sUGDD8P+C)5$rJ@kprub11q%?&4+3PFA7n}(WZ8mEjX^Hp2Bb9& z+l2hVYT1yX53(r2CS)2iNf|O%2SqL@a?Rx~XU98x?;PDT)7{fYb$4ZEWcouybyZha zb=TR~<O2%XFQc+LGBV<y@BQP&3;4nkm22={Ftgi92v8z5pnMsVH02rl$@TbC-@}CO zu2^^bNz*ko=-cqt4cN#FE{fQ8P_wT@oaAn6D}jX9CSd+9Eao8c&HC*8D12-}@dM!~ zh0q68u{)L3;2>u~vuMZqf_2CL3X@!I8nda^j|123vzoA<V$VP*2Mna$geKKM6w(9@ zF802!`|Ggu+eH88t3+Rld1n<qhuN_$0m(!ff$|Yd@~W9$hchPO)c?lBOg@U4dCi&L zT!DKp!n=`480%+h>vBw59N{Jpp)F9Pp!7a?@EBxb2p2(q2h#6B>EDC?NssWO<EZff zk@6eBvi2gXCCV#eD3*0#(RvUsV?wun-h$<Id(B1brjPIW((EW8eFzq1>;FB4Nx0HN z_V!rr-uyJ<pZ_+&*WoW?_>}F;i6qhtl>e~KU3DEsxsI8%dBGBCKgf1?N(Au+RQ?KH zy#w(D7|F#Z4Hp6^fRv~tjIyn`m|bzs+jZji$KaJKa6b)ya0mYOf0KOpJ1L5PFOU19 zONeq@&qBTfR%&_HMl1jZ80K%Og-v$DernH2+2^V-GwBYHJJf}z=6_Gna=Bq=U^@>e zCWwD$jn$XUn(co>5=fp%d!VeFba4dHycsX@?0yr9?_wqk-mzB82Qk*ELB3eS=1cI> zO;|3&<dh)o#Ryr0fGkp&tlEj}Ew$%RR`B>Fq_<$H1Yf-m*WOe7_#YIx{Bnhnk8YtJ z6^Kk+Uw2JA(9BrMWiR;+%;Zqny6Z(uPVrIu_hf^sU$W12jQZZ;gK`(nVP^EJgZQy| zE+V+i;$!e9>+m9G;$|!9X^HFsl=W9{JP-F?Fngt(It{928088kko?>B1zf`9pFUOp zN(kX=aN{Mon}v}X81Z71ti{O{02g5sye86_C}7Nm%`r$-VRH&@jY<A^#pA|{qx_wF zcbR-9B=Zr09FGmHq4si~_i`D2*#gtTKDC;nJ{eS^o{6%yfrz5M<5**6d)07PHTi<| zQmVsfzT1F9maV5FUYLhJ#3Y_vO?p-$9Rp?j9xOf&uU5@&eUowX7^`3wCcJ=&n5b<u zaEW?;NdvgiJMiAO;6@DD^H9zUl#K1HqeE;b3{*N<l+&%GB$RScO@XRH&Vh}R;w5i{ z`wup_@<%KD(2Fs}4}<y<P@@*7T3VRx<kMY%)xuG4GE>1KQ04ol;SVF@3f)b5QzD%M zWfiy%3opWV*1^95m!AT$4WWVg0$;c8c4*zJE7l!L@A$#^J-Gd6aBUsp%TOMLl!z_p z3fp7TAi&F#AY~73c+rIiV^F<g)@$b^IC;fVYLqYMw|MnVm8X9%=E~O~_Y9<d0_3E9 zrl}@>K5hk|w@*S-&0B*vW`&zG@IQQ$`R5HDmfU`c3=SxRufbby!fO$1J_Q$l6fQcT zWCcU&f5QUg7G^Tw3Wkd;?*6s6;Pzj@->kvLMVOg}tPqIpM20RpE|~=t5;ECjXUCo% znuM_;+}Q%PY``uhB~Cg;${puUYMwVnL%wwvuKaiSz&A`fHRl;{F98#nWuq>{%k{sF zH~#VC-aIz}!88%eEW8iCylR4lU!nh<E)scI4V3qf|4KYx7$bUrmh(TT`CF-F9NtE7 z!@iWaEbla9L2{z?FTDoy&%@uWz{*89a{)%v0zrc{DJCE`*DDWZ9))bO5o1rHf%z(| z+<|!AT7@x$L_{nS=tU}8Q{9A94`BQOTn^zv3G$~P^%STN!V?RZ$vg?pIH<9YhboVQ z<QV0X1j_~afTTcx&$~~+*H+BxhifDgHHq{Kl*Ol~;@po(9-9_CZfrg~47CNwGr&{U z-EJ|98JMp4_BY|%%TSqtGmpVot~rNI79q|O$1V`z6(twX*i37Q^dwsmENsB~Z4f>t zct17|CFCBO2gFg#MhKO_?9o$Lg;T3=LBo>;h=1WSPXJ^5GG(_)_5wJkL5+dR0uCY4 za@hxzy}S$8+JNIZV(yu}MrPoD`DT*lR^nnOGB7W0>Stqq!0{R1KBX|>AZIaQ8710= zd2B-PO?cyba4m$&6EJ=ON*;JL20uFnXk+_3LXs^+NSUByKa!F#ngYK7{vwFD*5WP> z<2Y2&a)W{gBOX*U;1}Vo0A8=c3l$SZ`~Lu_;stR2229;B({m|@1xFI7D|jd>@-$_k zSf{{*wbD{3JnAa~<&c1M9z%}D+jj05A{N;I-nk54Uo@LjzJi%`OeB%P0_9IQKZC@# zrlF#~R(PLC@LN#*b9nhCyq<#alHnvrU2t}QqyR=ijBKy#7B1wnGT|rtbq9d7f<h4% zD-dtiU2yaKCQ#g>mR3wj6R?w45P4>EGb4tV^w|+^{)+c3)1t)sr80NaG|^On`(l!) zILfW4fRf{&6h*QGlo-W9iV{m?7$pm&*+6cmwD&E@xqwD&?;@*gM~H1F4D9)OqP2@e zED}+rf|;#;<0<&R%O(c$Ivr)bNF<RyfKs-al-WyG%zOL(OYp5#xLW}CGF%ulk#jpi z63pJ+qlj2;pLL6nbD=3UK_b09K=K|`#$a<EMA+zpk64dj5gNH-VzdO80QjX=KRd^- z^TFS$@<QP*zf*$rG@Q=E)P%w1Oc6%PP*#%!nKYS_<gQaf6kJF-+{<K$Q&~`w3MnMY zLZd*SkWwNM2<+Cm#0b7f#2O(1o0uToCE)hc@b(hC<{MDFW3Ac5txn|N##zVTv|&UW z--Fi{;7tLm7a(^Y&gUCf1vi3Z8Z*VJ>REyu$d_u)d;*e#uqsmu$~g!|AY5s5!7<i6 zRZKRa+Vc@8Vhf-xlGy$x&wQrJeIGu1&xAeHcG+$I*G$|cmx4zWJW_xO7jg>Rw4pd1 z2~G;60-?-K5K@|;hfqSG*mh?sUxK^q@NQt1do594RGZ|OO5}(GWdOVZ!He+L9e6#2 zr2>e@;MBMo8R?c68rKif^?@tEDM2ZfH2fe%0V4|5$6&1r@m3?WxR|Ae8neE;29}$^ z7hC<@H$Qg=>BH|~(haqG18&Pbv$EwS`$^<P$y|$bpGbgmmj(G21mA(%cj5XL-1i`Q z5;9Y8dc^QuUfZENZd^aX4sNnkZUwA~3qA-Jd@KdQIBea4c!!~wwqSXO`ih&^TebP~ ze3Hp|laKL#CawA~1JB$Z=Ra{w+IevGbhri-FsK(}ILKQNyal(H;MGl7cECRi&LeQP zWWJu>)4mx8$|iKO!J2f-P;6;U$^<M2;DTo)OyuFV58-{9HpR(dR#M%?%zf+%FZ{23 zk`H{!EW!Fu3~9l20+PcY)WfUmQ3uPpp|!2^##hV|xQu0>)->GLa5IKA2R6@vnt_P| zjJwSl3Y#ZxYN17CQ`*3li42rW7GPUqVgi<f0Lu{xV<osBfL?BPm}_8p6SGXQq41w^ z`BNsm<~fdZ(%N=|_$XUBb0@I39W}5VZX3%N;&q<)HD9U1SE`!7h}U_gpjmtjf*;XL zTv_L-G0j;Qa%ek-v>ldVgRZP;YQyRT#017fbpkRIP;`wR%fQZA)>f1)V+@%U3zju~ zxv9LBzGBbydOUexS#%*kPMh5=)4-qFc2>YY0OtPsGd@1>`-X5ij({_F4C~zdy}4rs zICICOjr*^sjDO6b`eE4mkom+Pc1V2)&OQN8mf*bWkfSk^kke#IIu;x!sZHM0Ty8UR zJ)AMKQpe16+fejfhT}+sW!b2+kDluDc=E&r#y!YP(uRee$AoPO;3I#+Cz<e>`wC`r zons6*bH}jGN9i7pTYbBI5%mF-PmQ=-D#7JZ&6A_>zP!u(6r4g^fUK>`sacV(1<9IT z#N;Hj(aXZIF1N83MZuvo3V9hc04oTZt;(8H->t5w2g~<zJeiUL#z(*zWoKZI07FPm zK=7~N*LeI>#t;8}m^-3tqEDigbJXtPIMM@i2X>D#jUoqR3CIIEl!daO0f>av;|AL@ z!UQwVSnJMAvx?ZYwq^(!C{DVJD-hf9dNtq;tySq;y|!s>x4LUVyVv8%l{_$729a;T z$ruo6;4O4etXvI=fIkA>K6(RY?jae#hYehF$EdFT{?Fd7YjA*@1CK*?CM^sbOp>g1 zvrkbE5c0RbX*_J7%%llfQ9A&R?eB|@fUvqxdE<9_J)T@Cxsac#H&@J6M6AFcA@x<2 zjtoG4@#q26akI#V@xKrG8O*l$Jfy(xwEPoc^~NdF+FmPT)>dhbKW>PqRG=n3rgDv8 zL|&WkZ$Oj<=~)omu%o)BZ&Ke)uvK}@(d_4Va>WHEQcyhw)muQdO3WDg=0A;Jh=_r& z(sq}w!<yM_*O3LCFVZ=Ty3g$oJk9s<>|x)>T-&-{ZE^a!*}dv~5N+N8$kp2U*}=7| zQ|B5z>fB?aiyYaqJcf^K2A7@OBPu{m!c@tzMod%rE`5VoW)YDGvK0{4bhiyq_QOv; z9LX@#z;qVwoPp|m1Z%fgdb=1#8Xx%b(E^-7faF2fy|cl4br)G28{p{(Fb@kP8@)<% zes8cm>=%*PuKRPd@xa<v8eohYyzoXKtL+JW4wIKM)!>4=whbF}1!^p0tOR9T+r+!M z$(?$|&Vsvka;LJZe5VE4T;2~q`S5Hio&%<{ze4tmj}E=B&EJ!6N3r-K@ZSDc|Ly?| zf(}4(_9(lAoq}ZjzOK^RavT0VwExZBAi4AJtL)Y0b3^PO`?vAcru(?sbDwLrC^voZ zGG=>3Ex?!H70j+w+5^7|p2v1lEZ;YK%sMtxO-=XAC1V+%(b%bD;+79-^Zk~(vkq$@ zZb1Avh}_c#3vU3Amm&Xslh5G>@HmiPhqbt_iz<wyVZFy=k%tH@x0t3x@QtfuE0Gw* z;Y<9KsPdtBpSNzza95D**IDB|-ND0i3FrC%$)=aUzLw=6?_sV(dZ1NZ&GXpXvK(}K z&-K{G4Q<<}RVjY>=l*4^q=%zM2(^cf1V(oWb%2gSlyr9L%E6T`K}}CPq^4ZP3IW+5 zB;z_r$0Z5_wt|rK9=deCPi5^q@7^jib7q-rzRboNyn72~&Ok2z-5uq*{5w`JGIM4H z;bq9C(ri?>dOAMx&;aE>!Mt%?{@t(s_Jg|_RghKGN1laeh<UCn7v&%ZL6fUJyT9eN z|2=%-h=Jtpm%;GwVQ!!Lwgbr)FRp#-doaL$wS)Th+pbc`8Q>J?Jdi?2>*8t~2PvJp zE{rZ>h-><YLuwkP3ISt)<JPQ6bG_O}Cp7Vp*_{1<)-%u6!X%r@>$;%~T%{lkdOkip zvS#U&k8?kzikUK>mA?wlG`N6!yRgG>ulfN=LG#`e``^Q$!gdA?l67!8n0x34BwM)Q zL#ylD06?<S{=IiyrBogu2qS|~QW@TEhc1jRVxz}EC_=<I<j=dD9)V(DzVABb`lv%a z(q_uXOZir}jHI12+FjnL3zXv$Y2EYj;gS?^fs}_R-{j?gobR?X4SEpl?`pRP$zBF- zcf3^3_dF}Q1j&N}%eeu7WQ%8TsKDNNefw$Gn!QLA`j)h8(1l&F7#lqX92-U{CVa*w z;hbl}%>!+&-<}fcQ1^WUeCbS0Uj~**XUx&xYA)ZYOF#!zB#}MR)$!qwPut^Zc(#nI z;Pwcg&$XEJzuEv$`^)bR4{&DNk@2F>f!oUfp6e1M`<KCk<$jYl!vywWSdzWexp%PK z4mgh;Z^!+|j<>yDI|gFU0}sR`q-S8N<S|V>SV*g#agv?tdea;|Z=<zd^&o0r4iLC0 zKoYoKgWmb^<jW#vhJT6AhG=Jm|D5}!R>4t%9W$}}cK)~ru<RTpTf5qW9Jq7+4cMW8 z<S?j&{U&cj-}N2bO!!=feWUj}_tLI%n|oAsE>gsXi;RtrY-Y449a86DvK(+xL8(hF zvYqZ%4Y(<_(N@<IlxSVPlXrBw=P4fwsoHnqFE06!Sk5l+{4eJB>-?e%u<SHpML!@p zEGuWOO+C8-$iZ2Xy-^9hgXGl#*0+DYa`+(GSzUM6MTQveQOP<t83WRBAq9FgBy}Dp zrd=LM!)Oqiy&AjZBHQU(6`NDYskGG<;CQXe+w+fMM?B@jBM$uf^hO-;;I7-+?e5K0 z>7<i)xSFDGkn9I6d*?2j<M##)mc6^Rb3+E57WJJSQXd&;eZ`JG*GZiuzTqB~^=u;P z!zj2jkbVTlOFkDoIBlJ`Ub)D2y1xNXM)EXJz<)A;d<MxAkXQH{%Rq?3B(R3R@j65c zNF?wkI!GhY=paOL8y&o9kJ_I1+LVV#9;V5h(U(6{9g%^{pShptW=)dlg14NbeU|BN z`AEU=UBW)aT9U()ZYFo;4iO~#=Kv25BzM-eQ_E87vIk=zra@c;_i>NXwAoqlqJon0 zcC6Cwm5XerZ)DA+jrpu{o^^6;0$b$JY-Fu9WOk%xBRhtcQxKg4E)uxcfCT~<V2Z%K zWRKYM*Ma%A<tJbMxjFpCxr~pH-WI?1kMezVszF<51G6Igr4V`<yu+|0`vLs9Lj=ig zIKb{Gnc>y96D!zSl;s(R(Mgyr1U#08$2^B~KrReWeQ=MR>e<|@z?%@hXMt-G6Z5wT zgf^a1L6`<IWOE}Z3dB(vqB5?!1mP5}0>TAcb<G~J=PyBc%IfpG%1^$m04}rZkGM!S zwtkN4h#2;O?bcEllodG$3ZYYqKpfyR>;)_j8g#m;Ylkn=ZHs-l_3gw8mghW|X=%8} zG~f-0d+b#AQcnRFiS#*40^nM9q72~JcuGyAw}4H&)H<+Ate4Sp8Rc9f(hDdDB0Yt2 zF4-gYd}H}3TRyQ}e)1&*<aaB~2$`b#GpGCMb^`><mLSQ&42ao&>MRZzBnQ~`vj@@6 zgX<>;W3hJzmeR3&;h?$4PW5!gY)0n$WuO3SBe0f%wKS|L(it<@eSZ^JBb`|U7V!P| zP`ruc5;~YdMxcWQB-iW_bG}wiWBEO+Pdps-<Zq@@FzKZx`47{@qcLC)79@M65DwFd z9B|torWM?i)h-UaZTD(dIV|q6Q{D3nKdCnjM_KXKsjx|9bBhWZ&jWDKn}(zGyfI)3 zy=gc~xJKYE5K9Qu6mW^aH7<4FE^L<@y}A6}LxGc@TPjPOOf<gA=kwwy4A^12+5@Wl z>|tAx1L6S>4dgrL0C!vMN2^`su(-!gb)^hP=~oR$sg&U;{i@+8O{k;7uNsb0sS%)n zU$xufhL>>F6e0xWE<kt<S1mvYuA15|H+qZm6D|J&fTijgR|+;){yr<>FaT$lRdyHv z+2Nq5Su=JS;!y+0?!Z#?v7GV{Kz;UTwD<1%3gkg?k?nNPF&yPq#c-6KV>rsKisdLB z0L@m#a+HqYD7Pxc5{>jVl(Rsj!F8sH^d*!tMHFDq*UMF7xi;k|TTHefpED>&1RotZ z)6n6p@+=*OVjQHU(^U7_#(KtF?K(;DLD!xAUEA5C(B>V!P>)PqTh%v4cF!$l0N3hn z58ULs`Ny>iP=<UN|H(3B3&?N;(i!~oX;2Ee2Ivrfd=u8!kT(}$aRFbx2k*X#PF+Ik zDRcz#{sQ{eHRSyT0x;*@*5x`f<tJMLKz<LVaf<xuSndG$o!R|d?V|@gH4wjgte&l4 z&%o(fal~FsdxPcCvfOvq*U`$`k>`7VeWlCqH|rC)_`p4&2y4saFd4Wt^^GS^VHU?# z0IeMI%3(8QY^2zJ7J!RyUPBj3M7aV><taADPm|G_oWF@)Ucgy>fF3Ck<y)6Cdin$d zWdxtWgHk3x&A-ixVFQlMsW?jui$@%B(1}~~YsUaEceMMn;)qyht=sr2Hiu;+)p0~$ zppEr)y5|<{w5iP-sobE;>n%Wr6M#?UjaR`<f$N$~>W%}-1J?n^1?8ZWLJ8NTE)oia z#FCfX%9po+ueiLx=;{M>p+uM~uwsERt2J4Flj7n6qw71%PrCfpnj>%udg>f^#esA# zTDYy^SO88PEX6T<%5@9qx!I#^88>Eg?_*Z{pk)>u*Kr{X40q@{?60ptXmVxXg6kB! zynX^gpD4HoZjqahK80&t^Az>{AEiAiKxvIYBSWJ%?k=9YcV`zcwgb-9u_-ntPm>8W zxzLoKY>N^qp(`CPRXuZR*M7l6AGpU)#Ib+gSC2s(Up<Cx>PYME2HMAU9VFi+;v#`A z;JT|w+@|s(5dneTz;(0izP=M(5NHqA6`akN8~kz&aRjl3Fork)t<gF(7HJTI_8|<- z-aSFUgUdg(uSHoaFDx%mUVT7dUG<gX$+sv&9O-$kKQ%@oi6qjaY;Wehn@glmF0wN{ zx3*kAXW!{HuClgyu2v4Aa)?sK<Bu_k!y@V&WhWM8b9sJc0me_XMfnP-tXpJpES+@r zL=x#8D0gv*SiNSF3^$JQAUMi)<!dLMqkI+*WF|k!fAo^Ro=76S1LbZm5z9SdxyVkr z$KLdV<S1K}pJa~mUssSgBAS@u)e}I!C6dVD0cD^wxGt7ER0R6{xNd}GiwKWEYg~5& zNkoJu$Y@+lF2~5x1Y6&2xE=z{-g*^W&*}os=6{wQzIQG5r{-hpisdLH^J7|Dj#5LQ zd2rb!qpRfnC5p?6(NzasP=q<fN;${oc#(`&<a{VDM~tq5E(pTh2rK206a8>20g<Cz zJvm7!!O27t=>sTtbCG-NsrCBb640vH)%Dd9C=K!fM0v=qePIVq?&c_e8m%BywBaa0 z7tAiUD`nUmhm6JoBNiCjmos{DA8rr{90xs~=g*U_o=75n0A-zvG>854jV2>Zg~hI} zFFb%S5341}YN)*N5rd>ICepm-Z@aa>D=1|%ushTjBv{}3R=zu6Y%V{^5|a@^i7-Es zbo4|L=?f_9TqI50WNSUUzVip7kz!ZZ*8s8xm^UB&!af}3PouOs-<qRLEXut^xo(_I zB^^DHM7lLI>LMEBC@q(G9ikZsvfyr*gq4lu8za^_+yc^&CEpo0Y4rjoL~_-EYz$}f zA7h8_-IAlUdSJg30>057Tuv;?_To#WgifiX+b5DpUl!#yMp-WL9Za6_bR7$KlxHpQ zZK+yWdPjNl@3qn;;3#uUILhI^7L{^!f|H3P(l1apbBWEeFZY<*r+ja{^2Wy!j&gV+ zln`;!%@awaPoQk(5}Rdz?$PUrn{1~i9OZB=N+DyJbn`?K=@Te}4BJ2zN#Jn>v4X$_ z1p#m4Zh?#;(w1w~AT>l<5L}n_>oGK9H~QJ1{8%F_TNsS20}fO*I-p@a<Ic|m37IX( z1yEEls@QhbSJJSVH`|ouBot#9)u0808Cc1j{O2pB_LFX&NFsd>q{azR0i0zXVF676 zn)7T?pJUO$2K70WcZ*S975L#V?``mG{oOiz*8YFg@c$)dUMI5kmG&qw>}j^a`mFJv zHS6`{PX>!IC4)-R%@awaPoUg=>Qq-hkR7S^Re^6jvv;<J4^)$f)U%`|;3uwkB8hZw eWKa(giuwOOHG%!ZyJh_V0000<MNUMnLSTXcEgTpC literal 0 HcmV?d00001 diff --git a/app/javascript/flavours/blobfox/images/mbstobon-ui-3.png b/app/javascript/flavours/blobfox/images/mbstobon-ui-3.png new file mode 100644 index 0000000000000000000000000000000000000000..a1fb642a003b2e2b652b1e6022a84f08dc76105f GIT binary patch literal 32449 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%0Arc|r&DC@j0pAQE7yV9T$8x=q-T1FG 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 Cr$1f* literal 0 HcmV?d00001 diff --git a/app/javascript/flavours/blobfox/images/wave-drawer-glitched.png b/app/javascript/flavours/blobfox/images/wave-drawer-glitched.png new file mode 100644 index 0000000000000000000000000000000000000000..2290663db49ef6336a1bcdcd39d81811fcf4b5a8 GIT binary patch literal 3544 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 O0lJz-8rACdvHt>8r?yZ4 literal 0 HcmV?d00001 diff --git a/app/javascript/flavours/blobfox/images/wave-drawer.png b/app/javascript/flavours/blobfox/images/wave-drawer.png new file mode 100644 index 0000000000000000000000000000000000000000..ca9f9e1d85d222e7bf80649c55783db928cc79d4 GIT binary patch literal 3269 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 D7raE) literal 0 HcmV?d00001 diff --git a/app/javascript/flavours/blobfox/initial_state.js b/app/javascript/flavours/blobfox/initial_state.js new file mode 100644 index 00000000000000..52066aac420546 --- /dev/null +++ b/app/javascript/flavours/blobfox/initial_state.js @@ -0,0 +1,144 @@ +// @ts-check + + +/** + * @typedef {[code: string, name: string, localName: string]} InitialStateLanguage + */ + +/** + * @typedef InitialStateMeta + * @property {string} access_token + * @property {boolean=} advanced_layout + * @property {boolean} auto_play_gif + * @property {boolean} activity_api_enabled + * @property {string} admin + * @property {boolean=} boost_modal + * @property {boolean=} favourite_modal + * @property {boolean} crop_images + * @property {boolean=} delete_modal + * @property {boolean=} disable_swiping + * @property {string=} disabled_account_id + * @property {string} display_media + * @property {string} domain + * @property {boolean=} expand_spoilers + * @property {boolean} limited_federation_mode + * @property {string} locale + * @property {string | null} mascot + * @property {number} max_reactions + * @property {string=} me + * @property {string=} moved_to_account_id + * @property {string=} owner + * @property {boolean} profile_directory + * @property {boolean} registrations_open + * @property {boolean} reduce_motion + * @property {string} repository + * @property {boolean} search_enabled + * @property {boolean} trends_enabled + * @property {boolean} single_user_mode + * @property {string} source_url + * @property {string} streaming_api_base_url + * @property {boolean} timeline_preview + * @property {string} title + * @property {boolean} show_trends + * @property {boolean} trends_as_landing_page + * @property {boolean} unfollow_modal + * @property {boolean} use_blurhash + * @property {boolean=} use_pending_items + * @property {string} version + * @property {number} visible_reactions + * @property {string} sso_redirect + * @property {number} visible_reactions + * @property {boolean} translation_enabled + * @property {string} status_page_url + * @property {boolean} system_emoji_font + * @property {string} default_content_type + */ + +/** @type {string} */ +const initialPath = document.querySelector("head meta[name=initialPath]")?.getAttribute("content") ?? ''; +/** @type {boolean} */ +export const hasMultiColumnPath = initialPath === '/' + || initialPath === '/getting-started' + || initialPath === '/home' + || initialPath.startsWith('/deck'); + +/** + * @typedef InitialState + * @property {Record<string, import("./api_types/accounts").ApiAccountJSON>} accounts + * @property {InitialStateLanguage[]} languages + * @property {boolean=} critical_updates_pending + * @property {InitialStateMeta} meta + * @property {object} local_settings + * @property {number} max_toot_chars + * @property {number} poll_limits + * @property {number} max_reactions + */ + +const element = document.getElementById('initial-state'); +/** @type {InitialState | undefined} */ +const initialState = element?.textContent && JSON.parse(element.textContent); + +// Glitch-soc-specific “local settings” +if (initialState) { + try { + // @ts-expect-error + initialState.local_settings = JSON.parse(localStorage.getItem('mastodon-settings')); + } catch (e) { + initialState.local_settings = {}; + } +} + +/** + * @template {keyof InitialStateMeta} K + * @param {K} prop + * @returns {InitialStateMeta[K] | undefined} + */ +const getMeta = (prop) => initialState?.meta && initialState.meta[prop]; + +export const activityApiEnabled = getMeta('activity_api_enabled'); +export const autoPlayGif = getMeta('auto_play_gif'); +export const boostModal = getMeta('boost_modal'); +export const cropImages = getMeta('crop_images'); +export const deleteModal = getMeta('delete_modal'); +export const disableSwiping = getMeta('disable_swiping'); +export const disabledAccountId = getMeta('disabled_account_id'); +export const displayMedia = getMeta('display_media'); +export const domain = getMeta('domain'); +export const expandSpoilers = getMeta('expand_spoilers'); +export const forceSingleColumn = !getMeta('advanced_layout'); +export const limitedFederationMode = getMeta('limited_federation_mode'); +export const mascot = getMeta('mascot'); +export const maxReactions = (initialState && initialState.max_reactions) || 1; +export const me = getMeta('me'); +export const movedToAccountId = getMeta('moved_to_account_id'); +export const owner = getMeta('owner'); +export const profile_directory = getMeta('profile_directory'); +export const reduceMotion = getMeta('reduce_motion'); +export const registrationsOpen = getMeta('registrations_open'); +export const repository = getMeta('repository'); +export const searchEnabled = getMeta('search_enabled'); +export const trendsEnabled = getMeta('trends_enabled'); +export const showTrends = getMeta('show_trends'); +export const singleUserMode = getMeta('single_user_mode'); +export const source_url = getMeta('source_url'); +export const timelinePreview = getMeta('timeline_preview'); +export const title = getMeta('title'); +export const trendsAsLanding = getMeta('trends_as_landing_page'); +export const unfollowModal = getMeta('unfollow_modal'); +export const useBlurhash = getMeta('use_blurhash'); +export const usePendingItems = getMeta('use_pending_items'); +export const version = getMeta('version'); +export const visibleReactions = getMeta('visible_reactions'); +export const languages = initialState?.languages; +export const criticalUpdatesPending = initialState?.critical_updates_pending; +export const statusPageUrl = getMeta('status_page_url'); +export const sso_redirect = getMeta('sso_redirect'); + +// Glitch-soc-specific settings +export const maxChars = (initialState && initialState.max_toot_chars) || 500; +export const favouriteModal = getMeta('favourite_modal'); +export const pollLimits = (initialState && initialState.poll_limits); +export const defaultContentType = getMeta('default_content_type'); +export const useSystemEmojiFont = getMeta('system_emoji_font'); + +export default initialState; diff --git a/app/javascript/flavours/blobfox/is_mobile.ts b/app/javascript/flavours/blobfox/is_mobile.ts new file mode 100644 index 00000000000000..7f339e287bfd61 --- /dev/null +++ b/app/javascript/flavours/blobfox/is_mobile.ts @@ -0,0 +1,34 @@ +import { supportsPassiveEvents } from 'detect-passive-events'; + +import { forceSingleColumn, hasMultiColumnPath } from './initial_state'; + +const LAYOUT_BREAKPOINT = 630; + +export const isMobile = (width: number) => width <= LAYOUT_BREAKPOINT; + +export const transientSingleColumn = !forceSingleColumn && !hasMultiColumnPath; + +export type LayoutType = 'mobile' | 'single-column' | 'multi-column'; +export const layoutFromWindow = (): LayoutType => { + if (isMobile(window.innerWidth)) { + return 'mobile'; + } else if (!forceSingleColumn && !transientSingleColumn) { + return 'multi-column'; + } else { + return 'single-column'; + } +}; + +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; + +let userTouching = false; + +const touchListener = () => { + userTouching = true; + + window.removeEventListener('touchstart', touchListener); +}; + +window.addEventListener('touchstart', touchListener, listenerOptions); + +export const isUserTouching = () => userTouching; diff --git a/app/javascript/flavours/blobfox/load_keyboard_extensions.js b/app/javascript/flavours/blobfox/load_keyboard_extensions.js new file mode 100644 index 00000000000000..2dd0e45fa714c4 --- /dev/null +++ b/app/javascript/flavours/blobfox/load_keyboard_extensions.js @@ -0,0 +1,16 @@ +// On KaiOS, we may not be able to use a mouse cursor or navigate using Tab-based focus, so we install +// special left/right focus navigation keyboard listeners, at least on public pages (i.e. so folks +// can at least log in using KaiOS devices). + +function importArrowKeyNavigation() { + return import(/* webpackChunkName: "arrow-key-navigation" */ 'arrow-key-navigation'); +} + +export default function loadKeyboardExtensions() { + if (/KAIOS/.test(navigator.userAgent)) { + return importArrowKeyNavigation().then(arrowKeyNav => { + arrowKeyNav.register(); + }); + } + return Promise.resolve(); +} diff --git a/app/javascript/flavours/blobfox/main.jsx b/app/javascript/flavours/blobfox/main.jsx new file mode 100644 index 00000000000000..c6ee5617532f84 --- /dev/null +++ b/app/javascript/flavours/blobfox/main.jsx @@ -0,0 +1,47 @@ +import { createRoot } from 'react-dom/client'; + +import { setupBrowserNotifications } from 'flavours/blobfox/actions/notifications'; +import Mastodon from 'flavours/blobfox/containers/mastodon'; +import { me } from 'flavours/blobfox/initial_state'; +import * as perf from 'flavours/blobfox/performance'; +import ready from 'flavours/blobfox/ready'; +import { store } from 'flavours/blobfox/store'; + +/** + * @returns {Promise<void>} + */ +function main() { + perf.start('main()'); + + return ready(async () => { + const mountNode = document.getElementById('mastodon'); + const props = JSON.parse(mountNode.getAttribute('data-props')); + + const root = createRoot(mountNode); + root.render(<Mastodon {...props} />); + 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 && 'Notification' in window && Notification.permission === 'granted') { + const registerPushNotifications = await import('flavours/blobfox/actions/push_notifications'); + + store.dispatch(registerPushNotifications.register()); + } + } + + perf.stop('main()'); + }); +} + +export default main; diff --git a/app/javascript/flavours/blobfox/models/account.ts b/app/javascript/flavours/blobfox/models/account.ts new file mode 100644 index 00000000000000..02541f1f93fab0 --- /dev/null +++ b/app/javascript/flavours/blobfox/models/account.ts @@ -0,0 +1,149 @@ +import type { RecordOf } from 'immutable'; +import { List, Record as ImmutableRecord } from 'immutable'; + +import escapeTextContentForBrowser from 'escape-html'; + +import type { + ApiAccountFieldJSON, + ApiAccountRoleJSON, + ApiAccountJSON, +} from 'flavours/blobfox/api_types/accounts'; +import type { ApiCustomEmojiJSON } from 'flavours/blobfox/api_types/custom_emoji'; +import emojify from 'flavours/blobfox/features/emoji/emoji'; +import { unescapeHTML } from 'flavours/blobfox/utils/html'; + +import { CustomEmojiFactory } from './custom_emoji'; +import type { CustomEmoji } from './custom_emoji'; + +// AccountField +interface AccountFieldShape extends Required<ApiAccountFieldJSON> { + name_emojified: string; + value_emojified: string; + value_plain: string | null; +} + +type AccountField = RecordOf<AccountFieldShape>; + +const AccountFieldFactory = ImmutableRecord<AccountFieldShape>({ + name: '', + value: '', + verified_at: null, + name_emojified: '', + value_emojified: '', + value_plain: null, +}); + +// AccountRole +export type AccountRoleShape = ApiAccountRoleJSON; +export type AccountRole = RecordOf<AccountRoleShape>; + +const AccountRoleFactory = ImmutableRecord<AccountRoleShape>({ + color: '', + id: '', + name: '', +}); + +// Account +export interface AccountShape + extends Required< + Omit<ApiAccountJSON, 'emojis' | 'fields' | 'roles' | 'moved'> + > { + emojis: List<CustomEmoji>; + fields: List<AccountField>; + roles: List<AccountRole>; + display_name_html: string; + note_emojified: string; + note_plain: string | null; + hidden: boolean; + moved: string | null; +} + +export type Account = RecordOf<AccountShape>; + +export const accountDefaultValues: AccountShape = { + acct: '', + avatar: '', + avatar_static: '', + bot: false, + created_at: '', + discoverable: false, + display_name: '', + display_name_html: '', + emojis: List<CustomEmoji>(), + fields: List<AccountField>(), + group: false, + header: '', + header_static: '', + id: '', + last_status_at: '', + locked: false, + noindex: false, + note: '', + note_emojified: '', + note_plain: 'string', + roles: List<AccountRole>(), + uri: '', + url: '', + username: '', + followers_count: 0, + following_count: 0, + statuses_count: 0, + hidden: false, + suspended: false, + memorial: false, + limited: false, + moved: null, +}; + +const AccountFactory = ImmutableRecord<AccountShape>(accountDefaultValues); + +type EmojiMap = Record<string, ApiCustomEmojiJSON>; + +function makeEmojiMap(emojis: ApiCustomEmojiJSON[]) { + return emojis.reduce<EmojiMap>((obj, emoji) => { + obj[`:${emoji.shortcode}:`] = emoji; + return obj; + }, {}); +} + +function createAccountField( + jsonField: ApiAccountFieldJSON, + emojiMap: EmojiMap, +) { + return AccountFieldFactory({ + ...jsonField, + name_emojified: emojify( + escapeTextContentForBrowser(jsonField.name), + emojiMap, + ), + value_emojified: emojify(jsonField.value, emojiMap), + value_plain: unescapeHTML(jsonField.value), + }); +} + +export function createAccountFromServerJSON(serverJSON: ApiAccountJSON) { + const { moved, ...accountJSON } = serverJSON; + + const emojiMap = makeEmojiMap(accountJSON.emojis); + + const displayName = + accountJSON.display_name.trim().length === 0 + ? accountJSON.username + : accountJSON.display_name; + + return AccountFactory({ + ...accountJSON, + moved: moved?.id, + fields: List( + serverJSON.fields.map((field) => createAccountField(field, emojiMap)), + ), + emojis: List(serverJSON.emojis.map((emoji) => CustomEmojiFactory(emoji))), + roles: List(serverJSON.roles?.map((role) => AccountRoleFactory(role))), + display_name_html: emojify( + escapeTextContentForBrowser(displayName), + emojiMap, + ), + note_emojified: emojify(accountJSON.note, emojiMap), + note_plain: unescapeHTML(accountJSON.note), + }); +} diff --git a/app/javascript/flavours/blobfox/models/custom_emoji.ts b/app/javascript/flavours/blobfox/models/custom_emoji.ts new file mode 100644 index 00000000000000..736b6b620c31c9 --- /dev/null +++ b/app/javascript/flavours/blobfox/models/custom_emoji.ts @@ -0,0 +1,15 @@ +import type { RecordOf } from 'immutable'; +import { Record } from 'immutable'; + +import type { ApiCustomEmojiJSON } from 'flavours/blobfox/api_types/custom_emoji'; + +type CustomEmojiShape = Required<ApiCustomEmojiJSON>; // no changes from server shape +export type CustomEmoji = RecordOf<CustomEmojiShape>; + +export const CustomEmojiFactory = Record<CustomEmojiShape>({ + shortcode: '', + static_url: '', + url: '', + category: '', + visible_in_picker: false, +}); diff --git a/app/javascript/flavours/blobfox/models/relationship.ts b/app/javascript/flavours/blobfox/models/relationship.ts new file mode 100644 index 00000000000000..a2391683ff64f3 --- /dev/null +++ b/app/javascript/flavours/blobfox/models/relationship.ts @@ -0,0 +1,29 @@ +import type { RecordOf } from 'immutable'; +import { Record } from 'immutable'; + +import type { ApiRelationshipJSON } from 'flavours/blobfox/api_types/relationships'; + +type RelationshipShape = Required<ApiRelationshipJSON>; // no changes from server shape +export type Relationship = RecordOf<RelationshipShape>; + +const RelationshipFactory = Record<RelationshipShape>({ + blocked_by: false, + blocking: false, + domain_blocking: false, + endorsed: false, + followed_by: false, + following: false, + id: '', + languages: null, + muting_notifications: false, + muting: false, + note: '', + notifying: false, + requested_by: false, + requested: false, + showing_reblogs: false, +}); + +export function createRelationship(attributes: Partial<RelationshipShape>) { + return RelationshipFactory(attributes); +} diff --git a/app/javascript/flavours/blobfox/names.yml b/app/javascript/flavours/blobfox/names.yml new file mode 100644 index 00000000000000..4f743d6248edee --- /dev/null +++ b/app/javascript/flavours/blobfox/names.yml @@ -0,0 +1,40 @@ +en: + flavours: + blobfox: + description: The default flavour for blobfoxSoc instances. + name: blobfox Edition + skins: + blobfox: + default: Default +cs: + flavours: + blobfox: + description: Výchozí rozhraní instancí blobfoxSoc. + name: blobfox + skins: + blobfox: + default: Výchozí +pl: + flavours: + blobfox: + description: Domyślny motyw instancji blobfoxSoc. + skins: + blobfox: + default: Domyślny +es: + flavours: + blobfox: + description: El diseño predeterminado para las instancias con blobfoxSoc. + name: blobfoxsoc + skins: + blobfox: + default: Predeterminado + +ja: + flavours: + blobfox: + description: blobfoxSocインスタンスのデフォルトフレーバーです。 + name: blobfox Edition + skins: + blobfox: + default: デフォルト diff --git a/app/javascript/flavours/blobfox/packs/admin.jsx b/app/javascript/flavours/blobfox/packs/admin.jsx new file mode 100644 index 00000000000000..9a33fe31e14461 --- /dev/null +++ b/app/javascript/flavours/blobfox/packs/admin.jsx @@ -0,0 +1,25 @@ +import 'packs/public-path'; +import { createRoot } from 'react-dom/client'; + +import ready from 'flavours/blobfox/ready'; + +ready(() => { + [].forEach.call(document.querySelectorAll('[data-admin-component]'), element => { + const componentName = element.getAttribute('data-admin-component'); + const { ...componentProps } = JSON.parse(element.getAttribute('data-props')); + + import('flavours/blobfox/containers/admin_component').then(({ default: AdminComponent }) => { + return import('flavours/blobfox/components/admin/' + componentName).then(({ default: Component }) => { + const root = createRoot(element); + + root.render ( + <AdminComponent> + <Component {...componentProps} /> + </AdminComponent>, + ); + }); + }).catch(error => { + console.error(error); + }); + }); +}); diff --git a/app/javascript/flavours/blobfox/packs/common.js b/app/javascript/flavours/blobfox/packs/common.js new file mode 100644 index 00000000000000..439ae9d27eedeb --- /dev/null +++ b/app/javascript/flavours/blobfox/packs/common.js @@ -0,0 +1,8 @@ +import 'packs/public-path'; +import Rails from '@rails/ujs'; +import 'flavours/blobfox/styles/index.scss'; + +Rails.start(); + +// This ensures that webpack compiles our images. +require.context('../images', true); diff --git a/app/javascript/flavours/blobfox/packs/error.js b/app/javascript/flavours/blobfox/packs/error.js new file mode 100644 index 00000000000000..ab27f51435037a --- /dev/null +++ b/app/javascript/flavours/blobfox/packs/error.js @@ -0,0 +1,14 @@ +import 'packs/public-path'; +import ready from 'flavours/blobfox/ready'; + +ready(() => { + const image = document.querySelector('img'); + + image.addEventListener('mouseenter', () => { + image.src = '/oops.gif'; + }); + + image.addEventListener('mouseleave', () => { + image.src = '/oops.png'; + }); +}); diff --git a/app/javascript/flavours/blobfox/packs/home.js b/app/javascript/flavours/blobfox/packs/home.js new file mode 100644 index 00000000000000..a6ce1876212a82 --- /dev/null +++ b/app/javascript/flavours/blobfox/packs/home.js @@ -0,0 +1,11 @@ +import 'packs/public-path'; +import { loadLocale } from 'flavours/blobfox/locales'; +import main from "flavours/blobfox/main"; +import { loadPolyfills } from 'flavours/blobfox/polyfills'; + +loadPolyfills() + .then(loadLocale) + .then(main) + .catch(e => { + console.error(e); + }); diff --git a/app/javascript/flavours/blobfox/packs/public.jsx b/app/javascript/flavours/blobfox/packs/public.jsx new file mode 100644 index 00000000000000..9df968e084789f --- /dev/null +++ b/app/javascript/flavours/blobfox/packs/public.jsx @@ -0,0 +1,242 @@ +import 'packs/public-path'; +import { createRoot } from 'react-dom/client'; + +import { IntlMessageFormat } from 'intl-messageformat'; +import { defineMessages } from 'react-intl'; + +import Rails from '@rails/ujs'; +import axios from 'axios'; +import { createBrowserHistory } from 'history'; +import { throttle } from 'lodash'; + +import { timeAgoString } from 'flavours/blobfox/components/relative_timestamp'; +import emojify from 'flavours/blobfox/features/emoji/emoji'; +import loadKeyboardExtensions from 'flavours/blobfox/load_keyboard_extensions'; +import { loadLocale, getLocale } from 'flavours/blobfox/locales'; +import { loadPolyfills } from 'flavours/blobfox/polyfills'; + +const messages = defineMessages({ + usernameTaken: { id: 'username.taken', defaultMessage: 'That username is taken. Try another' }, + passwordExceedsLength: { id: 'password_confirmation.exceeds_maxlength', defaultMessage: 'Password confirmation exceeds the maximum password length' }, + passwordDoesNotMatch: { id: 'password_confirmation.mismatching', defaultMessage: 'Password confirmation does not match' }, +}); + +function main() { + const { messages: localeData } = getLocale(); + + 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); + }; + }; + + 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, + }); + + const formatMessage = ({ id, defaultMessage }, values) => { + const messageFormat = new IntlMessageFormat(localeData[id] || defaultMessage, locale); + return messageFormat.format(values); + }; + + [].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(localeData['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(); + + const timeGiven = content.getAttribute('datetime').includes('T'); + content.title = timeGiven ? dateTimeFormat.format(datetime) : dateFormat.format(datetime); + content.textContent = timeAgoString({ + formatMessage, + formatDate: (date, options) => (new Intl.DateTimeFormat(locale, options)).format(date), + }, datetime, now, now.getFullYear(), timeGiven); + }); + + const reactComponents = document.querySelectorAll('[data-component]'); + if (reactComponents.length > 0) { + import(/* webpackChunkName: "containers/media_container" */ 'flavours/blobfox/containers/media_container') + .then(({ default: MediaContainer }) => { + [].forEach.call(reactComponents, (component) => { + [].forEach.call(component.children, (child) => { + component.removeChild(child); + }); + }); + + const content = document.createElement('div'); + + const root = createRoot(content); + root.render(<MediaContainer locale={locale} components={reactComponents} />); + document.body.appendChild(content); + scrollToDetailedStatus(); + }) + .catch(error => { + console.error(error); + scrollToDetailedStatus(); + }); + } else { + scrollToDetailedStatus(); + } + + Rails.delegate(document, '#user_account_attributes_username', 'input', throttle(() => { + const username = document.getElementById('user_account_attributes_username'); + + if (username.value && username.value.length > 0) { + axios.get('/api/v1/accounts/lookup', { params: { acct: username.value } }).then(() => { + username.setCustomValidity(formatMessage(messages.usernameTaken)); + }).catch(() => { + username.setCustomValidity(''); + }); + } else { + username.setCustomValidity(''); + } + }, 500, { leading: false, trailing: true })); + + Rails.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(formatMessage(messages.passwordExceedsLength)); + } else if (password.value && password.value !== confirmation.value) { + confirmation.setCustomValidity(formatMessage(messages.passwordDoesNotMatch)); + } else { + confirmation.setCustomValidity(''); + } + }); + + Rails.delegate(document, '.custom-emoji', 'mouseover', getEmojiAnimationHandler('data-original')); + Rails.delegate(document, '.custom-emoji', 'mouseout', getEmojiAnimationHandler('data-static')); + + Rails.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(localeData['status.show_more'] || 'Show more', locale)).format(); + } else { + statusEl.dataset.spoiler = 'expanded'; + this.textContent = (new IntlMessageFormat(localeData['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') ? (localeData['status.show_less'] || 'Show less') : (localeData['status.show_more'] || 'Show more'); + spoilerLink.textContent = (new IntlMessageFormat(message, locale)).format(); + }); + + 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'); + }; + + Rails.delegate(document, '.sidebar__toggle__icon', 'click', () => { + toggleSidebar(); + }); + + Rails.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. + Rails.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(loadLocale) + .then(main) + .then(loadKeyboardExtensions) + .catch(error => { + console.error(error); + }); diff --git a/app/javascript/flavours/blobfox/packs/settings.js b/app/javascript/flavours/blobfox/packs/settings.js new file mode 100644 index 00000000000000..049a3a7187632b --- /dev/null +++ b/app/javascript/flavours/blobfox/packs/settings.js @@ -0,0 +1,42 @@ +import 'packs/public-path'; +import Rails from '@rails/ujs'; + +import loadKeyboardExtensions from 'flavours/blobfox/load_keyboard_extensions'; +import { loadPolyfills } from 'flavours/blobfox/polyfills'; +import 'cocoon-js-vanilla'; + +function main() { + 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'); + }; + + Rails.delegate(document, '.sidebar__toggle__icon', 'click', () => { + toggleSidebar(); + }); + + Rails.delegate(document, '.sidebar__toggle__icon', 'keydown', e => { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + toggleSidebar(); + } + }); +} + +loadPolyfills() + .then(main) + .then(loadKeyboardExtensions) + .catch(error => { + console.error(error); + }); diff --git a/app/javascript/flavours/blobfox/packs/share.jsx b/app/javascript/flavours/blobfox/packs/share.jsx new file mode 100644 index 00000000000000..73d65cb9b58764 --- /dev/null +++ b/app/javascript/flavours/blobfox/packs/share.jsx @@ -0,0 +1,27 @@ +import 'packs/public-path'; +import { createRoot } from 'react-dom/client'; + +import ComposeContainer from 'flavours/blobfox/containers/compose_container'; +import { loadPolyfills } from 'flavours/blobfox/polyfills'; +import ready from 'flavours/blobfox/ready'; + +function loaded() { + const mountNode = document.getElementById('mastodon-compose'); + + if (mountNode) { + const attr = mountNode.getAttribute('data-props'); + if(!attr) return; + + const props = JSON.parse(attr); + const root = createRoot(mountNode); + root.render(<ComposeContainer {...props} />); + } +} + +function main() { + ready(loaded); +} + +loadPolyfills().then(main).catch(error => { + console.error(error); +}); diff --git a/app/javascript/flavours/blobfox/packs/sign_up.js b/app/javascript/flavours/blobfox/packs/sign_up.js new file mode 100644 index 00000000000000..42c86aca9fc2b5 --- /dev/null +++ b/app/javascript/flavours/blobfox/packs/sign_up.js @@ -0,0 +1,42 @@ +import 'packs/public-path'; +import axios from 'axios'; + +import ready from 'flavours/blobfox/ready'; + +ready(() => { + setInterval(() => { + axios.get('/api/v1/emails/check_confirmation').then((response) => { + if (response.data) { + window.location = '/start'; + } + }).catch(error => { + console.error(error); + }); + }, 5000); + + document.querySelectorAll('.timer-button').forEach(button => { + let counter = 30; + + const container = document.createElement('span'); + + const updateCounter = () => { + container.innerText = ` (${counter})`; + }; + + updateCounter(); + + const countdown = setInterval(() => { + counter--; + + if (counter === 0) { + button.disabled = false; + button.removeChild(container); + clearInterval(countdown); + } else { + updateCounter(); + } + }, 1000); + + button.appendChild(container); + }); +}); diff --git a/app/javascript/flavours/blobfox/performance.js b/app/javascript/flavours/blobfox/performance.js new file mode 100644 index 00000000000000..42849c82b10378 --- /dev/null +++ b/app/javascript/flavours/blobfox/performance.js @@ -0,0 +1,30 @@ +// +// Tools for performance debugging, only enabled in development mode. +// Open up Chrome Dev Tools, then Timeline, then User Timing to see output. +// Also see config/webpack/loaders/mark.js for the webpack loader marks. + +import * as marky from 'marky'; + +if (process.env.NODE_ENV === 'development') { + if (typeof performance !== 'undefined' && performance.setResourceTimingBufferSize) { + // Increase Firefox's performance entry limit; otherwise it's capped to 150. + // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1331135 + performance.setResourceTimingBufferSize(Infinity); + } + + // allows us to easily do e.g. ReactPerf.printWasted() while debugging + //window.ReactPerf = require('react-addons-perf'); + //window.ReactPerf.start(); +} + +export function start(name) { + if (process.env.NODE_ENV === 'development') { + marky.mark(name); + } +} + +export function stop(name) { + if (process.env.NODE_ENV === 'development') { + marky.stop(name); + } +} diff --git a/app/javascript/flavours/blobfox/permissions.ts b/app/javascript/flavours/blobfox/permissions.ts new file mode 100644 index 00000000000000..b583535c00e35f --- /dev/null +++ b/app/javascript/flavours/blobfox/permissions.ts @@ -0,0 +1,4 @@ +export const PERMISSION_INVITE_USERS = 0x0000000000010000; +export const PERMISSION_MANAGE_USERS = 0x0000000000000400; +export const PERMISSION_MANAGE_FEDERATION = 0x0000000000000020; +export const PERMISSION_MANAGE_REPORTS = 0x0000000000000010; diff --git a/app/javascript/flavours/blobfox/polyfills/extra_polyfills.ts b/app/javascript/flavours/blobfox/polyfills/extra_polyfills.ts new file mode 100644 index 00000000000000..a8d5530c5fcb6a --- /dev/null +++ b/app/javascript/flavours/blobfox/polyfills/extra_polyfills.ts @@ -0,0 +1 @@ +import 'requestidlecallback'; diff --git a/app/javascript/flavours/blobfox/polyfills/index.ts b/app/javascript/flavours/blobfox/polyfills/index.ts new file mode 100644 index 00000000000000..431c5b0f30f350 --- /dev/null +++ b/app/javascript/flavours/blobfox/polyfills/index.ts @@ -0,0 +1,21 @@ +// Convenience function to load polyfills and return a promise when it's done. +// If there are no polyfills, then this is just Promise.resolve() which means +// it will execute in the same tick of the event loop (i.e. near-instant). + +import { loadIntlPolyfills } from './intl'; + +function importExtraPolyfills() { + return import(/* webpackChunkName: "extra_polyfills" */ './extra_polyfills'); +} + +export function loadPolyfills() { + // Safari does not have requestIdleCallback. + // This avoids shipping them all the polyfills. + const needsExtraPolyfills = !window.requestIdleCallback; + + return Promise.all([ + loadIntlPolyfills(), + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- those properties might not exist in old browsers, even if they are always here in types + needsExtraPolyfills && importExtraPolyfills(), + ]); +} diff --git a/app/javascript/flavours/blobfox/polyfills/intl.ts b/app/javascript/flavours/blobfox/polyfills/intl.ts new file mode 100644 index 00000000000000..b825da66214f51 --- /dev/null +++ b/app/javascript/flavours/blobfox/polyfills/intl.ts @@ -0,0 +1,106 @@ +// import { shouldPolyfill as shouldPolyfillCanonicalLocales } from '@formatjs/intl-getcanonicallocales/should-polyfill'; +// import { shouldPolyfill as shouldPolyfillLocale } from '@formatjs/intl-locale/should-polyfill'; +import { shouldPolyfill as shoudPolyfillPluralRules } from '@formatjs/intl-pluralrules/should-polyfill'; +// import { shouldPolyfill as shouldPolyfillNumberFormat } from '@formatjs/intl-numberformat/should-polyfill'; +// import { shouldPolyfill as shouldPolyfillIntlDateTimeFormat } from '@formatjs/intl-datetimeformat/should-polyfill'; +// import { shouldPolyfill as shouldPolyfillIntlRelativeTimeFormat } from '@formatjs/intl-relativetimeformat/should-polyfill'; + +// async function loadGetCanonicalLocalesPolyfill() { +// // This platform already supports Intl.getCanonicalLocales +// if (shouldPolyfillCanonicalLocales()) { +// await import('@formatjs/intl-getcanonicallocales/polyfill'); +// } +// } + +// async function loadLocalePolyfill() { +// // This platform already supports Intl.Locale +// if (shouldPolyfillLocale()) { +// await import('@formatjs/intl-locale/polyfill'); +// } +// } + +// async function loadIntlNumberFormatPolyfill(locale: string) { +// const unsupportedLocale = shouldPolyfillNumberFormat(locale); +// // This locale is supported +// if (!unsupportedLocale) { +// return; +// } +// // Load the polyfill 1st BEFORE loading data +// await import('@formatjs/intl-numberformat/polyfill-force'); +// await import(`@formatjs/intl-numberformat/locale-data/${unsupportedLocale}`); +// } + +// async function loadIntlDateTimeFormatPolyfill(locale: string) { +// const unsupportedLocale = shouldPolyfillIntlDateTimeFormat(locale); +// // This locale is supported +// if (!unsupportedLocale) { +// return; +// } +// // Load the polyfill 1st BEFORE loading data +// await import('@formatjs/intl-datetimeformat/polyfill-force'); + +// // Parallelize CLDR data loading +// const dataPolyfills = [ +// import('@formatjs/intl-datetimeformat/add-all-tz'), +// import(`@formatjs/intl-datetimeformat/locale-data/${unsupportedLocale}`), +// ]; +// await Promise.all(dataPolyfills); +// } + +async function loadIntlPluralRulesPolyfills(locale: string) { + const unsupportedLocale = shoudPolyfillPluralRules(locale); + // This locale is supported + if (!unsupportedLocale) { + return; + } + // Load the polyfill 1st BEFORE loading data + await import( + /* webpackChunkName: "i18n-pluralrules-polyfill" */ '@formatjs/intl-pluralrules/polyfill-force' + ); + await import( + /* webpackChunkName: "i18n-pluralrules-polyfill-[request]" */ `@formatjs/intl-pluralrules/locale-data/${unsupportedLocale}` + ); +} + +// async function loadIntlRelativeTimeFormatPolyfill(locale: string) { +// const unsupportedLocale = shouldPolyfillIntlRelativeTimeFormat(locale); +// // This locale is supported +// if (!unsupportedLocale) { +// return; +// } +// // Load the polyfill 1st BEFORE loading data +// await import( +// /* webpackChunkName: "i18n-relativetimeformat-polyfill" */ +// '@formatjs/intl-relativetimeformat/polyfill-force' +// ); +// await import( +// /* webpackChunkName: "i18n-relativetimeformat-polyfill-[request]" */ +// `@formatjs/intl-relativetimeformat/locale-data/${unsupportedLocale}` +// ); +// } + +export async function loadIntlPolyfills() { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we want to match empty strings + const locale = document.querySelector('html')?.lang || 'en'; + + // order is important here + + // Supported in IE11 and most other browsers, not useful + // await loadGetCanonicalLocalesPolyfill() + + // Supported in IE11 and most other browsers, not useful + // await loadLocalePolyfill() + + // Supported in IE11 and most other browsers, not useful + // await loadIntlNumberFormatPolyfill(locale) + + // Supported in IE11 and most other browsers, not useful + // await loadIntlDateTimeFormatPolyfill(locale) + + // Supported from Safari 13+, may still be useful + await loadIntlPluralRulesPolyfills(locale); + + // This is not used yet in the codebase yet + // Supported from Safari 14+ + // await loadIntlRelativeTimeFormatPolyfill(locale); +} diff --git a/app/javascript/flavours/blobfox/ready.js b/app/javascript/flavours/blobfox/ready.js new file mode 100644 index 00000000000000..e769cc756079f0 --- /dev/null +++ b/app/javascript/flavours/blobfox/ready.js @@ -0,0 +1,32 @@ +// @ts-check + +/** + * @param {(() => void) | (() => Promise<void>)} callback + * @returns {Promise<void>} + */ +export default function ready(callback) { + return new Promise((resolve, reject) => { + function loaded() { + let result; + try { + result = callback(); + } catch (err) { + reject(err); + + return; + } + + if (typeof result?.then === 'function') { + result.then(resolve).catch(reject); + } else { + resolve(); + } + } + + if (['interactive', 'complete'].includes(document.readyState)) { + loaded(); + } else { + document.addEventListener('DOMContentLoaded', loaded); + } + }); +} diff --git a/app/javascript/flavours/blobfox/reducers/accounts.ts b/app/javascript/flavours/blobfox/reducers/accounts.ts new file mode 100644 index 00000000000000..0fe91fd34d1ab7 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/accounts.ts @@ -0,0 +1,84 @@ +import { Map as ImmutableMap } from 'immutable'; + +import type { Reducer } from 'redux'; + +import { + followAccountSuccess, + unfollowAccountSuccess, + importAccounts, + revealAccount, +} from 'flavours/blobfox/actions/accounts_typed'; +import type { ApiAccountJSON } from 'flavours/blobfox/api_types/accounts'; +import { me } from 'flavours/blobfox/initial_state'; +import type { Account } from 'flavours/blobfox/models/account'; +import { createAccountFromServerJSON } from 'flavours/blobfox/models/account'; + +const initialState = ImmutableMap<string, Account>(); + +const normalizeAccount = ( + state: typeof initialState, + account: ApiAccountJSON, +) => { + return state.set( + account.id, + createAccountFromServerJSON(account).set( + 'hidden', + state.get(account.id)?.hidden === false + ? false + : account.limited || false, + ), + ); +}; + +const normalizeAccounts = ( + state: typeof initialState, + accounts: ApiAccountJSON[], +) => { + accounts.forEach((account) => { + state = normalizeAccount(state, account); + }); + + return state; +}; + +function getCurrentUser() { + if (!me) + throw new Error( + 'No current user (me) defined when calling `accountsReducer`', + ); + + return me; +} + +export const accountsReducer: Reducer<typeof initialState> = ( + state = initialState, + action, +) => { + if (revealAccount.match(action)) + return state.setIn([action.payload.id, 'hidden'], false); + else if (importAccounts.match(action)) + return normalizeAccounts(state, action.payload.accounts); + else if (followAccountSuccess.match(action)) { + return state + .update( + action.payload.relationship.id, + (account) => account?.update('followers_count', (n) => n + 1), + ) + .update( + getCurrentUser(), + (account) => account?.update('following_count', (n) => n + 1), + ); + } else if (unfollowAccountSuccess.match(action)) + return state + .update( + action.payload.relationship.id, + (account) => + account?.update('followers_count', (n) => Math.max(0, n - 1)), + ) + .update( + getCurrentUser(), + (account) => + account?.update('following_count', (n) => Math.max(0, n - 1)), + ); + else return state; +}; diff --git a/app/javascript/flavours/blobfox/reducers/accounts_map.js b/app/javascript/flavours/blobfox/reducers/accounts_map.js new file mode 100644 index 00000000000000..9053dcc9c0528b --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/accounts_map.js @@ -0,0 +1,24 @@ +import { Map as ImmutableMap } from 'immutable'; + +import { ACCOUNT_LOOKUP_FAIL } from '../actions/accounts'; +import { importAccounts } from '../actions/accounts_typed'; +import { domain } from '../initial_state'; + +export const normalizeForLookup = str => { + str = str.toLowerCase(); + const trailingIndex = str.indexOf(`@${domain.toLowerCase()}`); + return (trailingIndex > 0) ? str.slice(0, trailingIndex) : str; +}; + +const initialState = ImmutableMap(); + +export default function accountsMap(state = initialState, action) { + switch(action.type) { + case ACCOUNT_LOOKUP_FAIL: + return action.error?.response?.status === 404 ? state.set(normalizeForLookup(action.acct), null) : state; + case importAccounts.type: + return state.withMutations(map => action.payload.accounts.forEach(account => map.set(normalizeForLookup(account.acct), account.id))); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/alerts.js b/app/javascript/flavours/blobfox/reducers/alerts.js new file mode 100644 index 00000000000000..1ca9b62a0210b7 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/alerts.js @@ -0,0 +1,30 @@ +import { List as ImmutableList } from 'immutable'; + +import { + ALERT_SHOW, + ALERT_DISMISS, + ALERT_CLEAR, +} from '../actions/alerts'; + +const initialState = ImmutableList([]); + +let id = 0; + +const addAlert = (state, alert) => + state.push({ + key: id++, + ...alert, + }); + +export default function alerts(state = initialState, action) { + switch(action.type) { + case ALERT_SHOW: + return addAlert(state, action.alert); + case ALERT_DISMISS: + return state.filterNot(item => item.key === action.alert.key); + case ALERT_CLEAR: + return state.clear(); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/announcements.js b/app/javascript/flavours/blobfox/reducers/announcements.js new file mode 100644 index 00000000000000..2134b04c6df1dd --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/announcements.js @@ -0,0 +1,103 @@ +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; + +import { + ANNOUNCEMENTS_FETCH_REQUEST, + ANNOUNCEMENTS_FETCH_SUCCESS, + ANNOUNCEMENTS_FETCH_FAIL, + ANNOUNCEMENTS_UPDATE, + ANNOUNCEMENTS_REACTION_UPDATE, + ANNOUNCEMENTS_REACTION_ADD_REQUEST, + ANNOUNCEMENTS_REACTION_ADD_FAIL, + ANNOUNCEMENTS_REACTION_REMOVE_REQUEST, + ANNOUNCEMENTS_REACTION_REMOVE_FAIL, + ANNOUNCEMENTS_TOGGLE_SHOW, + ANNOUNCEMENTS_DELETE, + ANNOUNCEMENTS_DISMISS_SUCCESS, +} from '../actions/announcements'; + +const initialState = ImmutableMap({ + items: ImmutableList(), + isLoading: false, + show: false, +}); + +const updateReaction = (state, id, name, updater) => state.update('items', list => list.map(announcement => { + if (announcement.get('id') === id) { + return announcement.update('reactions', reactions => { + const idx = reactions.findIndex(reaction => reaction.get('name') === name); + + if (idx > -1) { + return reactions.update(idx, reaction => updater(reaction)); + } + + return reactions.push(updater(fromJS({ name, count: 0 }))); + }); + } + + return announcement; +})); + +const updateReactionCount = (state, reaction) => updateReaction(state, reaction.announcement_id, reaction.name, x => x.set('count', reaction.count)); + +const addReaction = (state, id, name) => updateReaction(state, id, name, x => x.set('me', true).update('count', y => y + 1)); + +const removeReaction = (state, id, name) => updateReaction(state, id, name, x => x.set('me', false).update('count', y => y - 1)); + +const sortAnnouncements = list => list.sortBy(x => x.get('starts_at') || x.get('published_at')); + +const updateAnnouncement = (state, announcement) => { + const idx = state.get('items').findIndex(x => x.get('id') === announcement.get('id')); + + if (idx > -1) { + // Deep merge is used because announcements from the streaming API do not contain + // personalized data about which reactions have been selected by the given user, + // and that is information we want to preserve + return state.update('items', list => sortAnnouncements(list.update(idx, x => x.mergeDeep(announcement)))); + } + + return state.update('items', list => sortAnnouncements(list.unshift(announcement))); +}; + +export default function announcementsReducer(state = initialState, action) { + switch(action.type) { + case ANNOUNCEMENTS_TOGGLE_SHOW: + return state.withMutations(map => { + map.set('show', !map.get('show')); + }); + case ANNOUNCEMENTS_FETCH_REQUEST: + return state.set('isLoading', true); + case ANNOUNCEMENTS_FETCH_SUCCESS: + return state.withMutations(map => { + const items = fromJS(action.announcements); + + map.set('items', items); + map.set('isLoading', false); + }); + case ANNOUNCEMENTS_FETCH_FAIL: + return state.set('isLoading', false); + case ANNOUNCEMENTS_UPDATE: + return updateAnnouncement(state, fromJS(action.announcement)); + case ANNOUNCEMENTS_REACTION_UPDATE: + return updateReactionCount(state, action.reaction); + case ANNOUNCEMENTS_REACTION_ADD_REQUEST: + case ANNOUNCEMENTS_REACTION_REMOVE_FAIL: + return addReaction(state, action.id, action.name); + case ANNOUNCEMENTS_REACTION_REMOVE_REQUEST: + case ANNOUNCEMENTS_REACTION_ADD_FAIL: + return removeReaction(state, action.id, action.name); + case ANNOUNCEMENTS_DISMISS_SUCCESS: + return updateAnnouncement(state, fromJS({ 'id': action.id, 'read': true })); + case ANNOUNCEMENTS_DELETE: + return state.update('items', list => { + const idx = list.findIndex(x => x.get('id') === action.id); + + if (idx > -1) { + return list.delete(idx); + } + + return list; + }); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/blocks.js b/app/javascript/flavours/blobfox/reducers/blocks.js new file mode 100644 index 00000000000000..1b65071634fb16 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/blocks.js @@ -0,0 +1,22 @@ +import Immutable from 'immutable'; + +import { + BLOCKS_INIT_MODAL, +} from '../actions/blocks'; + +const initialState = Immutable.Map({ + new: Immutable.Map({ + account_id: null, + }), +}); + +export default function mutes(state = initialState, action) { + switch (action.type) { + case BLOCKS_INIT_MODAL: + return state.withMutations((state) => { + state.setIn(['new', 'account_id'], action.account.get('id')); + }); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/boosts.js b/app/javascript/flavours/blobfox/reducers/boosts.js new file mode 100644 index 00000000000000..9573748e752a25 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/boosts.js @@ -0,0 +1,25 @@ +import Immutable from 'immutable'; + +import { + BOOSTS_INIT_MODAL, + BOOSTS_CHANGE_PRIVACY, +} from 'flavours/blobfox/actions/boosts'; + +const initialState = Immutable.Map({ + new: Immutable.Map({ + privacy: 'public', + }), +}); + +export default function mutes(state = initialState, action) { + switch (action.type) { + case BOOSTS_INIT_MODAL: + return state.withMutations((state) => { + state.setIn(['new', 'privacy'], action.privacy); + }); + case BOOSTS_CHANGE_PRIVACY: + return state.setIn(['new', 'privacy'], action.privacy); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/compose.js b/app/javascript/flavours/blobfox/reducers/compose.js new file mode 100644 index 00000000000000..4722d3fdfeb445 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/compose.js @@ -0,0 +1,660 @@ +import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; + +import { + COMPOSE_MOUNT, + COMPOSE_UNMOUNT, + COMPOSE_CHANGE, + COMPOSE_CYCLE_ELEFRIEND, + COMPOSE_REPLY, + COMPOSE_REPLY_CANCEL, + COMPOSE_DIRECT, + COMPOSE_MENTION, + COMPOSE_SUBMIT_REQUEST, + COMPOSE_SUBMIT_SUCCESS, + COMPOSE_SUBMIT_FAIL, + COMPOSE_UPLOAD_REQUEST, + COMPOSE_UPLOAD_SUCCESS, + COMPOSE_UPLOAD_FAIL, + COMPOSE_UPLOAD_UNDO, + COMPOSE_UPLOAD_PROGRESS, + COMPOSE_UPLOAD_PROCESSING, + THUMBNAIL_UPLOAD_REQUEST, + THUMBNAIL_UPLOAD_SUCCESS, + THUMBNAIL_UPLOAD_FAIL, + THUMBNAIL_UPLOAD_PROGRESS, + COMPOSE_SUGGESTIONS_CLEAR, + COMPOSE_SUGGESTIONS_READY, + COMPOSE_SUGGESTION_SELECT, + COMPOSE_SUGGESTION_IGNORE, + COMPOSE_SUGGESTION_TAGS_UPDATE, + COMPOSE_TAG_HISTORY_UPDATE, + COMPOSE_ADVANCED_OPTIONS_CHANGE, + COMPOSE_SENSITIVITY_CHANGE, + COMPOSE_SPOILERNESS_CHANGE, + COMPOSE_SPOILER_TEXT_CHANGE, + COMPOSE_VISIBILITY_CHANGE, + COMPOSE_LANGUAGE_CHANGE, + COMPOSE_CONTENT_TYPE_CHANGE, + COMPOSE_EMOJI_INSERT, + COMPOSE_UPLOAD_CHANGE_REQUEST, + COMPOSE_UPLOAD_CHANGE_SUCCESS, + COMPOSE_UPLOAD_CHANGE_FAIL, + COMPOSE_DOODLE_SET, + COMPOSE_RESET, + COMPOSE_POLL_ADD, + COMPOSE_POLL_REMOVE, + COMPOSE_POLL_OPTION_ADD, + COMPOSE_POLL_OPTION_CHANGE, + COMPOSE_POLL_OPTION_REMOVE, + COMPOSE_POLL_SETTINGS_CHANGE, + INIT_MEDIA_EDIT_MODAL, + COMPOSE_CHANGE_MEDIA_DESCRIPTION, + COMPOSE_CHANGE_MEDIA_FOCUS, + COMPOSE_SET_STATUS, + COMPOSE_FOCUS, +} from '../actions/compose'; +import { REDRAFT } from '../actions/statuses'; +import { STORE_HYDRATE } from '../actions/store'; +import { TIMELINE_DELETE } from '../actions/timelines'; +import { me, defaultContentType } from '../initial_state'; +import { recoverHashtags } from '../utils/hashtag'; +import { unescapeHTML } from '../utils/html'; +import { overwrite } from '../utils/js_helpers'; +import { privacyPreference } from '../utils/privacy_preference'; +import { uuid } from '../uuid'; + +const totalElefriends = 3; + +// ~4% chance you'll end up with an unexpected friend +// blobfox-soc/mastodon repo created_at date: 2017-04-20T21:55:28Z +const blobfoxProbability = 1 - 0.0420215528; + +const initialState = ImmutableMap({ + mounted: 0, + advanced_options: ImmutableMap({ + do_not_federate: false, + threaded_mode: false, + }), + sensitive: false, + elefriend: Math.random() < blobfoxProbability ? Math.floor(Math.random() * totalElefriends) : totalElefriends, + spoiler: false, + spoiler_text: '', + privacy: null, + id: null, + content_type: defaultContentType || 'text/plain', + text: '', + focusDate: null, + caretPosition: null, + preselectDate: null, + in_reply_to: null, + is_submitting: false, + is_uploading: false, + is_changing_upload: false, + progress: 0, + isUploadingThumbnail: false, + thumbnailProgress: 0, + media_attachments: ImmutableList(), + pending_media_attachments: 0, + poll: null, + suggestion_token: null, + suggestions: ImmutableList(), + default_advanced_options: ImmutableMap({ + do_not_federate: false, + threaded_mode: null, // Do not reset + }), + default_privacy: 'public', + default_sensitive: false, + default_language: 'en', + resetFileKey: Math.floor((Math.random() * 0x10000)), + idempotencyKey: null, + tagHistory: ImmutableList(), + media_modal: ImmutableMap({ + id: null, + description: '', + focusX: 0, + focusY: 0, + dirty: false, + }), + doodle: ImmutableMap({ + fg: 'rgb( 0, 0, 0)', + bg: 'rgb(255, 255, 255)', + swapped: false, + mode: 'draw', + size: 'normal', + weight: 2, + opacity: 1, + adaptiveStroke: true, + smoothing: false, + }), +}); + +const initialPoll = ImmutableMap({ + options: ImmutableList(['', '']), + expires_in: 24 * 3600, + multiple: false, +}); + +function statusToTextMentions(state, status) { + let set = ImmutableOrderedSet([]); + + if (status.getIn(['account', 'id']) !== me) { + set = set.add(`@${status.getIn(['account', 'acct'])} `); + } + + return set.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)).join(''); +} + +function apiStatusToTextMentions (state, status) { + let set = ImmutableOrderedSet([]); + + if (status.account.id !== me) { + set = set.add(`@${status.account.acct} `); + } + + return set.union(status.mentions.filter( + mention => mention.id !== me, + ).map( + mention => `@${mention.acct} `, + )).join(''); +} + +function apiStatusToTextHashtags (state, status) { + const text = unescapeHTML(status.content); + return ImmutableOrderedSet([]).union(recoverHashtags(status.tags, text).map( + (name) => `#${name} `, + )).join(''); +} + +function clearAll(state) { + return state.withMutations(map => { + map.set('id', null); + map.set('text', ''); + if (defaultContentType) map.set('content_type', defaultContentType); + map.set('spoiler', false); + map.set('spoiler_text', ''); + map.set('is_submitting', false); + map.set('is_changing_upload', false); + map.set('in_reply_to', null); + map.update( + 'advanced_options', + map => map.mergeWith(overwrite, state.get('default_advanced_options')), + ); + map.set('privacy', state.get('default_privacy')); + map.set('sensitive', state.get('default_sensitive')); + map.set('language', state.get('default_language')); + map.update('media_attachments', list => list.clear()); + map.set('poll', null); + map.set('idempotencyKey', uuid()); + }); +} + +function continueThread (state, status) { + return state.withMutations(function (map) { + let text = apiStatusToTextMentions(state, status); + text = text + apiStatusToTextHashtags(state, status); + map.set('text', text); + if (status.spoiler_text) { + map.set('spoiler', true); + map.set('spoiler_text', status.spoiler_text); + } else { + map.set('spoiler', false); + map.set('spoiler_text', ''); + } + map.set('is_submitting', false); + map.set('in_reply_to', status.id); + map.update( + 'advanced_options', + map => map.merge(new ImmutableMap({ do_not_federate: status.local_only })), + ); + map.set('privacy', status.visibility); + map.set('sensitive', false); + map.update('media_attachments', list => list.clear()); + map.set('poll', null); + map.set('idempotencyKey', uuid()); + map.set('focusDate', new Date()); + map.set('caretPosition', null); + map.set('preselectDate', new Date()); + }); +} + +function appendMedia(state, media, file) { + const prevSize = state.get('media_attachments').size; + + return state.withMutations(map => { + if (media.get('type') === 'image') { + media = media.set('file', file); + } + map.update('media_attachments', list => list.push(media.set('unattached', true))); + map.set('is_uploading', false); + map.set('is_processing', false); + map.set('resetFileKey', Math.floor((Math.random() * 0x10000))); + map.set('idempotencyKey', uuid()); + map.update('pending_media_attachments', n => n - 1); + + if (prevSize === 0 && (state.get('default_sensitive') || state.get('spoiler'))) { + map.set('sensitive', true); + } + }); +} + +function removeMedia(state, mediaId) { + const prevSize = state.get('media_attachments').size; + + return state.withMutations(map => { + map.update('media_attachments', list => list.filterNot(item => item.get('id') === mediaId)); + map.set('idempotencyKey', uuid()); + + if (prevSize === 1) { + map.set('sensitive', false); + } + }); +} + +const insertSuggestion = (state, position, token, completion, path) => { + return state.withMutations(map => { + map.updateIn(path, oldText => `${oldText.slice(0, position)}${completion}${completion[0] === ':' ? '\u200B' : ' '}${oldText.slice(position + token.length)}`); + map.set('suggestion_token', null); + map.set('suggestions', ImmutableList()); + if (path.length === 1 && path[0] === 'text') { + map.set('focusDate', new Date()); + map.set('caretPosition', position + completion.length + 1); + } + map.set('idempotencyKey', uuid()); + }); +}; + +const ignoreSuggestion = (state, position, token, completion, path) => { + return state.withMutations(map => { + map.updateIn(path, oldText => `${oldText.slice(0, position + token.length)} ${oldText.slice(position + token.length)}`); + map.set('suggestion_token', null); + map.set('suggestions', ImmutableList()); + map.set('focusDate', new Date()); + map.set('caretPosition', position + token.length + 1); + map.set('idempotencyKey', uuid()); + }); +}; + +const sortHashtagsByUse = (state, tags) => { + const personalHistory = state.get('tagHistory').map(tag => tag.toLowerCase()); + + const tagsWithLowercase = tags.map(t => ({ ...t, lowerName: t.name.toLowerCase() })); + const sorted = tagsWithLowercase.sort((a, b) => { + const usedA = personalHistory.includes(a.lowerName); + const usedB = personalHistory.includes(b.lowerName); + + if (usedA === usedB) { + return 0; + } else if (usedA && !usedB) { + return -1; + } else { + return 1; + } + }); + sorted.forEach(tag => delete tag.lowerName); + return sorted; +}; + +const insertEmoji = (state, position, emojiData) => { + const emoji = emojiData.native; + + return state.withMutations(map => { + map.update('text', oldText => `${oldText.slice(0, position)}${emoji}\u200B${oldText.slice(position)}`); + map.set('focusDate', new Date()); + map.set('caretPosition', position + emoji.length + 1); + map.set('idempotencyKey', uuid()); + }); +}; + +const hydrate = (state, hydratedState) => { + state = clearAll(state.merge(hydratedState)); + + if (hydratedState.get('text')) { + state = state.set('text', hydratedState.get('text')).set('focusDate', new Date()); + } + + return state; +}; + +const domParser = new DOMParser(); + +const expandMentions = status => { + const fragment = domParser.parseFromString(status.get('content'), 'text/html').documentElement; + + status.get('mentions').forEach(mention => { + fragment.querySelector(`a[href="${mention.get('url')}"]`).textContent = `@${mention.get('acct')}`; + }); + + return fragment.innerHTML; +}; + +const expiresInFromExpiresAt = expires_at => { + if (!expires_at) return 24 * 3600; + const delta = (new Date(expires_at).getTime() - Date.now()) / 1000; + return [300, 1800, 3600, 21600, 86400, 259200, 604800].find(expires_in => expires_in >= delta) || 24 * 3600; +}; + +const mergeLocalHashtagResults = (suggestions, prefix, tagHistory) => { + prefix = prefix.toLowerCase(); + if (suggestions.length < 4) { + const localTags = tagHistory.filter(tag => tag.toLowerCase().startsWith(prefix) && !suggestions.some(suggestion => suggestion.type === 'hashtag' && suggestion.name.toLowerCase() === tag.toLowerCase())); + return suggestions.concat(localTags.slice(0, 4 - suggestions.length).toJS().map(tag => ({ type: 'hashtag', name: tag }))); + } else { + return suggestions; + } +}; + +const normalizeSuggestions = (state, { accounts, emojis, tags, token }) => { + if (accounts) { + return accounts.map(item => ({ id: item.id, type: 'account' })); + } else if (emojis) { + return emojis.map(item => ({ ...item, type: 'emoji' })); + } else { + return mergeLocalHashtagResults(sortHashtagsByUse(state, tags.map(item => ({ ...item, type: 'hashtag' }))), token.slice(1), state.get('tagHistory')); + } +}; + +const updateSuggestionTags = (state, token) => { + const prefix = token.slice(1); + + const suggestions = state.get('suggestions').toJS(); + return state.merge({ + suggestions: ImmutableList(mergeLocalHashtagResults(suggestions, prefix, state.get('tagHistory'))), + suggestion_token: token, + }); +}; + +export default function compose(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return hydrate(state, action.state.get('compose')); + case COMPOSE_MOUNT: + return state.set('mounted', state.get('mounted') + 1); + case COMPOSE_UNMOUNT: + return state.set('mounted', Math.max(state.get('mounted') - 1, 0)); + case COMPOSE_ADVANCED_OPTIONS_CHANGE: + return state + .set('advanced_options', state.get('advanced_options').set(action.option, !!overwrite(!state.getIn(['advanced_options', action.option]), action.value))) + .set('idempotencyKey', uuid()); + case COMPOSE_SENSITIVITY_CHANGE: + return state.withMutations(map => { + if (!state.get('spoiler')) { + map.set('sensitive', !state.get('sensitive')); + } + + map.set('idempotencyKey', uuid()); + }); + case COMPOSE_SPOILERNESS_CHANGE: + return state.withMutations(map => { + map.set('spoiler', !state.get('spoiler')); + map.set('idempotencyKey', uuid()); + + if (!state.get('sensitive') && state.get('media_attachments').size >= 1) { + map.set('sensitive', true); + } + }); + case COMPOSE_SPOILER_TEXT_CHANGE: + return state + .set('spoiler_text', action.text) + .set('idempotencyKey', uuid()); + case COMPOSE_VISIBILITY_CHANGE: + return state + .set('privacy', action.value) + .set('idempotencyKey', uuid()); + case COMPOSE_CONTENT_TYPE_CHANGE: + return state + .set('content_type', action.value) + .set('idempotencyKey', uuid()); + case COMPOSE_CHANGE: + return state + .set('text', action.text) + .set('idempotencyKey', uuid()); + case COMPOSE_CYCLE_ELEFRIEND: + return state + .set('elefriend', (state.get('elefriend') + 1) % totalElefriends); + case COMPOSE_REPLY: + return state.withMutations(map => { + map.set('id', null); + map.set('in_reply_to', action.status.get('id')); + map.set('text', statusToTextMentions(state, action.status)); + map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy'))); + map.update( + 'advanced_options', + map => map.merge(new ImmutableMap({ do_not_federate: !!action.status.get('local_only') })), + ); + map.set('focusDate', new Date()); + map.set('caretPosition', null); + map.set('preselectDate', new Date()); + map.set('idempotencyKey', uuid()); + + map.update('media_attachments', list => list.filter(media => media.get('unattached'))); + + if (action.status.get('language') && !action.status.has('translation')) { + map.set('language', action.status.get('language')); + } else { + map.set('language', state.get('default_language')); + } + + if (action.status.get('spoiler_text').length > 0) { + let spoiler_text = action.status.get('spoiler_text'); + if (action.prependCWRe && !spoiler_text.match(/^re[: ]/i)) { + spoiler_text = 're: '.concat(spoiler_text); + } + map.set('spoiler', true); + map.set('spoiler_text', spoiler_text); + } else { + map.set('spoiler', false); + map.set('spoiler_text', ''); + } + }); + case COMPOSE_REPLY_CANCEL: + state = state.setIn(['advanced_options', 'threaded_mode'], false); + // eslint-disable-next-line no-fallthrough -- fall-through to `COMPOSE_RESET` is intended + case COMPOSE_RESET: + return state.withMutations(map => { + map.set('in_reply_to', null); + if (defaultContentType) map.set('content_type', defaultContentType); + map.set('text', ''); + map.set('spoiler', false); + map.set('spoiler_text', ''); + map.set('privacy', state.get('default_privacy')); + map.set('id', null); + map.set('poll', null); + map.set('language', state.get('default_language')); + map.update( + 'advanced_options', + map => map.mergeWith(overwrite, state.get('default_advanced_options')), + ); + map.set('idempotencyKey', uuid()); + }); + case COMPOSE_SUBMIT_REQUEST: + return state.set('is_submitting', true); + case COMPOSE_UPLOAD_CHANGE_REQUEST: + return state.set('is_changing_upload', true); + case COMPOSE_SUBMIT_SUCCESS: + return action.status && state.getIn(['advanced_options', 'threaded_mode']) ? continueThread(state, action.status) : clearAll(state); + case COMPOSE_SUBMIT_FAIL: + return state.set('is_submitting', false); + case COMPOSE_UPLOAD_CHANGE_FAIL: + return state.set('is_changing_upload', false); + case COMPOSE_UPLOAD_REQUEST: + return state.set('is_uploading', true).update('pending_media_attachments', n => n + 1); + case COMPOSE_UPLOAD_PROCESSING: + return state.set('is_processing', true); + case COMPOSE_UPLOAD_SUCCESS: + return appendMedia(state, fromJS(action.media), action.file); + case COMPOSE_UPLOAD_FAIL: + return state.set('is_uploading', false).set('is_processing', false).update('pending_media_attachments', n => n - 1); + case COMPOSE_UPLOAD_UNDO: + return removeMedia(state, action.media_id); + case COMPOSE_UPLOAD_PROGRESS: + return state.set('progress', Math.round((action.loaded / action.total) * 100)); + case THUMBNAIL_UPLOAD_REQUEST: + return state.set('isUploadingThumbnail', true); + case THUMBNAIL_UPLOAD_PROGRESS: + return state.set('thumbnailProgress', Math.round((action.loaded / action.total) * 100)); + case THUMBNAIL_UPLOAD_FAIL: + return state.set('isUploadingThumbnail', false); + case THUMBNAIL_UPLOAD_SUCCESS: + return state + .set('isUploadingThumbnail', false) + .update('media_attachments', list => list.map(item => { + if (item.get('id') === action.media.id) { + return fromJS(action.media); + } + + return item; + })); + case INIT_MEDIA_EDIT_MODAL: + const media = state.get('media_attachments').find(item => item.get('id') === action.id); + return state.set('media_modal', ImmutableMap({ + id: action.id, + description: media.get('description') || '', + focusX: media.getIn(['meta', 'focus', 'x'], 0), + focusY: media.getIn(['meta', 'focus', 'y'], 0), + dirty: false, + })); + case COMPOSE_CHANGE_MEDIA_DESCRIPTION: + return state.setIn(['media_modal', 'description'], action.description).setIn(['media_modal', 'dirty'], true); + case COMPOSE_CHANGE_MEDIA_FOCUS: + return state.setIn(['media_modal', 'focusX'], action.focusX).setIn(['media_modal', 'focusY'], action.focusY).setIn(['media_modal', 'dirty'], true); + case COMPOSE_MENTION: + return state.withMutations(map => { + map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' ')); + map.set('focusDate', new Date()); + map.set('caretPosition', null); + map.set('idempotencyKey', uuid()); + }); + case COMPOSE_DIRECT: + return state.withMutations(map => { + map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' ')); + map.set('privacy', 'direct'); + map.set('focusDate', new Date()); + map.set('caretPosition', null); + map.set('idempotencyKey', uuid()); + }); + case COMPOSE_SUGGESTIONS_CLEAR: + return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null); + case COMPOSE_SUGGESTIONS_READY: + return state.set('suggestions', ImmutableList(normalizeSuggestions(state, action))).set('suggestion_token', action.token); + case COMPOSE_SUGGESTION_SELECT: + return insertSuggestion(state, action.position, action.token, action.completion, action.path); + case COMPOSE_SUGGESTION_IGNORE: + return ignoreSuggestion(state, action.position, action.token, action.completion, action.path); + case COMPOSE_SUGGESTION_TAGS_UPDATE: + return updateSuggestionTags(state, action.token); + case COMPOSE_TAG_HISTORY_UPDATE: + return state.set('tagHistory', fromJS(action.tags)); + case TIMELINE_DELETE: + if (action.id === state.get('in_reply_to')) { + return state.set('in_reply_to', null); + } else if (action.id === state.get('id')) { + return state.set('id', null); + } else { + return state; + } + case COMPOSE_EMOJI_INSERT: + return insertEmoji(state, action.position, action.emoji); + case COMPOSE_UPLOAD_CHANGE_SUCCESS: + return state + .set('is_changing_upload', false) + .setIn(['media_modal', 'dirty'], false) + .update('media_attachments', list => list.map(item => { + if (item.get('id') === action.media.id) { + return fromJS(action.media).set('unattached', !action.attached); + } + + return item; + })); + case COMPOSE_DOODLE_SET: + return state.mergeIn(['doodle'], action.options); + case REDRAFT: + const do_not_federate = !!action.status.get('local_only'); + let text = action.raw_text || unescapeHTML(expandMentions(action.status)); + if (do_not_federate) text = text.replace(/ ?👁\ufe0f?\u200b?$/, ''); + return state.withMutations(map => { + map.set('text', text); + map.set('content_type', action.content_type || 'text/plain'); + map.set('in_reply_to', action.status.get('in_reply_to_id')); + map.set('privacy', action.status.get('visibility')); + map.set('media_attachments', action.status.get('media_attachments').map((media) => media.set('unattached', true))); + map.set('focusDate', new Date()); + map.set('caretPosition', null); + map.set('idempotencyKey', uuid()); + map.set('sensitive', action.status.get('sensitive')); + map.set('language', action.status.get('language')); + map.update( + 'advanced_options', + map => map.merge(new ImmutableMap({ do_not_federate })), + ); + map.set('id', null); + + if (action.status.get('spoiler_text').length > 0) { + map.set('spoiler', true); + map.set('spoiler_text', action.status.get('spoiler_text')); + + if (map.get('media_attachments').size >= 1) { + map.set('sensitive', true); + } + } else { + map.set('spoiler', false); + map.set('spoiler_text', ''); + } + + if (action.status.get('poll')) { + map.set('poll', ImmutableMap({ + options: action.status.getIn(['poll', 'options']).map(x => x.get('title')), + multiple: action.status.getIn(['poll', 'multiple']), + expires_in: expiresInFromExpiresAt(action.status.getIn(['poll', 'expires_at'])), + })); + } + }); + case COMPOSE_SET_STATUS: + return state.withMutations(map => { + map.set('id', action.status.get('id')); + map.set('text', action.text); + map.set('content_type', action.content_type || 'text/plain'); + map.set('in_reply_to', action.status.get('in_reply_to_id')); + map.set('privacy', action.status.get('visibility')); + map.set('media_attachments', action.status.get('media_attachments')); + map.set('focusDate', new Date()); + map.set('caretPosition', null); + map.set('idempotencyKey', uuid()); + map.set('sensitive', action.status.get('sensitive')); + map.set('language', action.status.get('language')); + + if (action.spoiler_text.length > 0) { + map.set('spoiler', true); + map.set('spoiler_text', action.spoiler_text); + } else { + map.set('spoiler', false); + map.set('spoiler_text', ''); + } + + if (action.status.get('poll')) { + map.set('poll', ImmutableMap({ + options: action.status.getIn(['poll', 'options']).map(x => x.get('title')), + multiple: action.status.getIn(['poll', 'multiple']), + expires_in: expiresInFromExpiresAt(action.status.getIn(['poll', 'expires_at'])), + })); + } + }); + case COMPOSE_POLL_ADD: + return state.set('poll', initialPoll); + case COMPOSE_POLL_REMOVE: + return state.set('poll', null); + case COMPOSE_POLL_OPTION_ADD: + return state.updateIn(['poll', 'options'], options => options.push(action.title)); + case COMPOSE_POLL_OPTION_CHANGE: + return state.setIn(['poll', 'options', action.index], action.title); + case COMPOSE_POLL_OPTION_REMOVE: + return state.updateIn(['poll', 'options'], options => options.delete(action.index)); + case COMPOSE_POLL_SETTINGS_CHANGE: + return state.update('poll', poll => poll.set('expires_in', action.expiresIn).set('multiple', action.isMultiple)); + case COMPOSE_LANGUAGE_CHANGE: + return state.set('language', action.language); + case COMPOSE_FOCUS: + return state.set('focusDate', new Date()).update('text', text => text.length > 0 ? text : action.defaultText); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/contexts.js b/app/javascript/flavours/blobfox/reducers/contexts.js new file mode 100644 index 00000000000000..f7d7419a4e3ab9 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/contexts.js @@ -0,0 +1,107 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; + +import { + blockAccountSuccess, + muteAccountSuccess, +} from '../actions/accounts'; +import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses'; +import { TIMELINE_DELETE, TIMELINE_UPDATE } from '../actions/timelines'; +import { compareId } from '../compare_id'; + +const initialState = ImmutableMap({ + inReplyTos: ImmutableMap(), + replies: ImmutableMap(), +}); + +const normalizeContext = (immutableState, id, ancestors, descendants) => immutableState.withMutations(state => { + state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => { + state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => { + function addReply({ id, in_reply_to_id }) { + if (in_reply_to_id && !inReplyTos.has(id)) { + + replies.update(in_reply_to_id, ImmutableList(), siblings => { + const index = siblings.findLastIndex(sibling => compareId(sibling, id) < 0); + return siblings.insert(index + 1, id); + }); + + inReplyTos.set(id, in_reply_to_id); + } + } + + // We know in_reply_to_id of statuses but `id` itself. + // So we assume that the status of the id replies to last ancestors. + + ancestors.forEach(addReply); + + if (ancestors[0]) { + addReply({ id, in_reply_to_id: ancestors[ancestors.length - 1].id }); + } + + descendants.forEach(addReply); + })); + })); +}); + +const deleteFromContexts = (immutableState, ids) => immutableState.withMutations(state => { + state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => { + state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => { + ids.forEach(id => { + const inReplyToIdOfId = inReplyTos.get(id); + const repliesOfId = replies.get(id); + const siblings = replies.get(inReplyToIdOfId); + + if (siblings) { + replies.set(inReplyToIdOfId, siblings.filterNot(sibling => sibling === id)); + } + + + if (repliesOfId) { + repliesOfId.forEach(reply => inReplyTos.delete(reply)); + } + + inReplyTos.delete(id); + replies.delete(id); + }); + })); + })); +}); + +const filterContexts = (state, relationship, statuses) => { + const ownedStatusIds = statuses + .filter(status => status.get('account') === relationship.id) + .map(status => status.get('id')); + + return deleteFromContexts(state, ownedStatusIds); +}; + +const updateContext = (state, status) => { + if (status.in_reply_to_id) { + return state.withMutations(mutable => { + const replies = mutable.getIn(['replies', status.in_reply_to_id], ImmutableList()); + + mutable.setIn(['inReplyTos', status.id], status.in_reply_to_id); + + if (!replies.includes(status.id)) { + mutable.setIn(['replies', status.in_reply_to_id], replies.push(status.id)); + } + }); + } + + return state; +}; + +export default function replies(state = initialState, action) { + switch(action.type) { + case blockAccountSuccess.type: + case muteAccountSuccess.type: + return filterContexts(state, action.payload.relationship, action.payload.statuses); + case CONTEXT_FETCH_SUCCESS: + return normalizeContext(state, action.id, action.ancestors, action.descendants); + case TIMELINE_DELETE: + return deleteFromContexts(state, [action.id]); + case TIMELINE_UPDATE: + return updateContext(state, action.status); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/conversations.js b/app/javascript/flavours/blobfox/reducers/conversations.js new file mode 100644 index 00000000000000..f772305d71a457 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/conversations.js @@ -0,0 +1,118 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; + +import { blockAccountSuccess, muteAccountSuccess } from 'flavours/blobfox/actions/accounts'; +import { blockDomainSuccess } from 'flavours/blobfox/actions/domain_blocks'; + +import { + CONVERSATIONS_MOUNT, + CONVERSATIONS_UNMOUNT, + CONVERSATIONS_FETCH_REQUEST, + CONVERSATIONS_FETCH_SUCCESS, + CONVERSATIONS_FETCH_FAIL, + CONVERSATIONS_UPDATE, + CONVERSATIONS_READ, + CONVERSATIONS_DELETE_SUCCESS, +} from '../actions/conversations'; +import { compareId } from '../compare_id'; + +const initialState = ImmutableMap({ + items: ImmutableList(), + isLoading: false, + hasMore: true, + mounted: 0, +}); + +const conversationToMap = item => ImmutableMap({ + id: item.id, + unread: item.unread, + accounts: ImmutableList(item.accounts.map(a => a.id)), + last_status: item.last_status ? item.last_status.id : null, +}); + +const updateConversation = (state, item) => state.update('items', list => { + const index = list.findIndex(x => x.get('id') === item.id); + const newItem = conversationToMap(item); + + if (index === -1) { + return list.unshift(newItem); + } else { + return list.set(index, newItem); + } +}); + +const expandNormalizedConversations = (state, conversations, next, isLoadingRecent) => { + let items = ImmutableList(conversations.map(conversationToMap)); + + return state.withMutations(mutable => { + if (!items.isEmpty()) { + mutable.update('items', list => { + list = list.map(oldItem => { + const newItemIndex = items.findIndex(x => x.get('id') === oldItem.get('id')); + + if (newItemIndex === -1) { + return oldItem; + } + + const newItem = items.get(newItemIndex); + items = items.delete(newItemIndex); + + return newItem; + }); + + list = list.concat(items); + + return list.sortBy(x => x.get('last_status'), (a, b) => { + if(a === null || b === null) { + return -1; + } + + return compareId(a, b) * -1; + }); + }); + } + + if (!next && !isLoadingRecent) { + mutable.set('hasMore', false); + } + + mutable.set('isLoading', false); + }); +}; + +const filterConversations = (state, accountIds) => { + return state.update('items', list => list.filterNot(item => item.get('accounts').some(accountId => accountIds.includes(accountId)))); +}; + +export default function conversations(state = initialState, action) { + switch (action.type) { + case CONVERSATIONS_FETCH_REQUEST: + return state.set('isLoading', true); + case CONVERSATIONS_FETCH_FAIL: + return state.set('isLoading', false); + case CONVERSATIONS_FETCH_SUCCESS: + return expandNormalizedConversations(state, action.conversations, action.next, action.isLoadingRecent); + case CONVERSATIONS_UPDATE: + return updateConversation(state, action.conversation); + case CONVERSATIONS_MOUNT: + return state.update('mounted', count => count + 1); + case CONVERSATIONS_UNMOUNT: + return state.update('mounted', count => count - 1); + case CONVERSATIONS_READ: + return state.update('items', list => list.map(item => { + if (item.get('id') === action.id) { + return item.set('unread', false); + } + + return item; + })); + case blockAccountSuccess.type: + case muteAccountSuccess.type: + return filterConversations(state, [action.payload.relationship.id]); + case blockDomainSuccess.type: + return filterConversations(state, action.payload.accounts); + case CONVERSATIONS_DELETE_SUCCESS: + return state.update('items', list => list.filterNot(item => item.get('id') === action.id)); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/custom_emojis.js b/app/javascript/flavours/blobfox/reducers/custom_emojis.js new file mode 100644 index 00000000000000..56ec80f2ffce22 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/custom_emojis.js @@ -0,0 +1,16 @@ +import { List as ImmutableList, fromJS as ConvertToImmutable } from 'immutable'; + +import { CUSTOM_EMOJIS_FETCH_SUCCESS } from '../actions/custom_emojis'; +import { buildCustomEmojis } from '../features/emoji/emoji'; +import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light'; + +const initialState = ImmutableList([]); + +export default function custom_emojis(state = initialState, action) { + if(action.type === CUSTOM_EMOJIS_FETCH_SUCCESS) { + state = ConvertToImmutable(action.custom_emojis); + emojiSearch('', { custom: buildCustomEmojis(state) }); + } + + return state; +} diff --git a/app/javascript/flavours/blobfox/reducers/domain_lists.js b/app/javascript/flavours/blobfox/reducers/domain_lists.js new file mode 100644 index 00000000000000..5f63c77f5d4200 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/domain_lists.js @@ -0,0 +1,26 @@ +import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; + +import { + DOMAIN_BLOCKS_FETCH_SUCCESS, + DOMAIN_BLOCKS_EXPAND_SUCCESS, + unblockDomainSuccess +} from '../actions/domain_blocks'; + +const initialState = ImmutableMap({ + blocks: ImmutableMap({ + items: ImmutableOrderedSet(), + }), +}); + +export default function domainLists(state = initialState, action) { + switch(action.type) { + case DOMAIN_BLOCKS_FETCH_SUCCESS: + return state.setIn(['blocks', 'items'], ImmutableOrderedSet(action.domains)).setIn(['blocks', 'next'], action.next); + case DOMAIN_BLOCKS_EXPAND_SUCCESS: + return state.updateIn(['blocks', 'items'], set => set.union(action.domains)).setIn(['blocks', 'next'], action.next); + case unblockDomainSuccess.type: + return state.updateIn(['blocks', 'items'], set => set.delete(action.payload.domain)); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/dropdown_menu.ts b/app/javascript/flavours/blobfox/reducers/dropdown_menu.ts new file mode 100644 index 00000000000000..59e19bb16d28c2 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/dropdown_menu.ts @@ -0,0 +1,33 @@ +import { createReducer } from '@reduxjs/toolkit'; + +import { closeDropdownMenu, openDropdownMenu } from '../actions/dropdown_menu'; + +interface DropdownMenuState { + openId: string | null; + keyboard: boolean; + scrollKey: string | null; +} + +const initialState: DropdownMenuState = { + openId: null, + keyboard: false, + scrollKey: null, +}; + +export const dropdownMenuReducer = createReducer(initialState, (builder) => { + builder + .addCase( + openDropdownMenu, + (state, { payload: { id, keyboard, scrollKey } }) => { + state.openId = id; + state.keyboard = keyboard; + state.scrollKey = scrollKey; + }, + ) + .addCase(closeDropdownMenu, (state, { payload: { id } }) => { + if (state.openId === id) { + state.openId = null; + state.scrollKey = null; + } + }); +}); diff --git a/app/javascript/flavours/blobfox/reducers/filters.js b/app/javascript/flavours/blobfox/reducers/filters.js new file mode 100644 index 00000000000000..566ad0c6ca3486 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/filters.js @@ -0,0 +1,45 @@ +import { Map as ImmutableMap, is, fromJS } from 'immutable'; + +import { FILTERS_FETCH_SUCCESS, FILTERS_CREATE_SUCCESS } from '../actions/filters'; +import { FILTERS_IMPORT } from '../actions/importer'; + +const normalizeFilter = (state, filter) => { + const normalizedFilter = fromJS({ + id: filter.id, + title: filter.title, + context: filter.context, + filter_action: filter.filter_action, + keywords: filter.keywords, + expires_at: filter.expires_at ? Date.parse(filter.expires_at) : null, + }); + + if (is(state.get(filter.id), normalizedFilter)) { + return state; + } else { + // Do not overwrite keywords when receiving a partial filter + return state.update(filter.id, ImmutableMap(), (old) => ( + old.mergeWith(((old_value, new_value) => (new_value === undefined ? old_value : new_value)), normalizedFilter) + )); + } +}; + +const normalizeFilters = (state, filters) => { + filters.forEach(filter => { + state = normalizeFilter(state, filter); + }); + + return state; +}; + +export default function filters(state = ImmutableMap(), action) { + switch(action.type) { + case FILTERS_CREATE_SUCCESS: + return normalizeFilter(state, action.filter); + case FILTERS_FETCH_SUCCESS: + return normalizeFilters(ImmutableMap(), action.filters); + case FILTERS_IMPORT: + return normalizeFilters(state, action.filters); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/followed_tags.js b/app/javascript/flavours/blobfox/reducers/followed_tags.js new file mode 100644 index 00000000000000..fa20c9ad6e4ce4 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/followed_tags.js @@ -0,0 +1,43 @@ +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; + +import { + FOLLOWED_HASHTAGS_FETCH_REQUEST, + FOLLOWED_HASHTAGS_FETCH_SUCCESS, + FOLLOWED_HASHTAGS_FETCH_FAIL, + FOLLOWED_HASHTAGS_EXPAND_REQUEST, + FOLLOWED_HASHTAGS_EXPAND_SUCCESS, + FOLLOWED_HASHTAGS_EXPAND_FAIL, +} from 'flavours/blobfox/actions/tags'; + +const initialState = ImmutableMap({ + items: ImmutableList(), + isLoading: false, + next: null, +}); + +export default function followed_tags(state = initialState, action) { + switch(action.type) { + case FOLLOWED_HASHTAGS_FETCH_REQUEST: + return state.set('isLoading', true); + case FOLLOWED_HASHTAGS_FETCH_SUCCESS: + return state.withMutations(map => { + map.set('items', fromJS(action.followed_tags)); + map.set('isLoading', false); + map.set('next', action.next); + }); + case FOLLOWED_HASHTAGS_FETCH_FAIL: + return state.set('isLoading', false); + case FOLLOWED_HASHTAGS_EXPAND_REQUEST: + return state.set('isLoading', true); + case FOLLOWED_HASHTAGS_EXPAND_SUCCESS: + return state.withMutations(map => { + map.update('items', set => set.concat(fromJS(action.followed_tags))); + map.set('isLoading', false); + map.set('next', action.next); + }); + case FOLLOWED_HASHTAGS_EXPAND_FAIL: + return state.set('isLoading', false); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/height_cache.js b/app/javascript/flavours/blobfox/reducers/height_cache.js new file mode 100644 index 00000000000000..2664d4f82463f7 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/height_cache.js @@ -0,0 +1,24 @@ +import { Map as ImmutableMap } from 'immutable'; + +import { HEIGHT_CACHE_SET, HEIGHT_CACHE_CLEAR } from '../actions/height_cache'; + +const initialState = ImmutableMap(); + +const setHeight = (state, key, id, height) => { + return state.update(key, ImmutableMap(), map => map.set(id, height)); +}; + +const clearHeights = () => { + return ImmutableMap(); +}; + +export default function statuses(state = initialState, action) { + switch(action.type) { + case HEIGHT_CACHE_SET: + return setHeight(state, action.key, action.id, action.height); + case HEIGHT_CACHE_CLEAR: + return clearHeights(); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/history.js b/app/javascript/flavours/blobfox/reducers/history.js new file mode 100644 index 00000000000000..023e5ecedd8af0 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/history.js @@ -0,0 +1,29 @@ +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; + +import { HISTORY_FETCH_REQUEST, HISTORY_FETCH_SUCCESS, HISTORY_FETCH_FAIL } from 'flavours/blobfox/actions/history'; + +const initialHistory = ImmutableMap({ + loading: false, + items: ImmutableList(), +}); + +const initialState = ImmutableMap(); + +export default function history(state = initialState, action) { + switch(action.type) { + case HISTORY_FETCH_REQUEST: + return state.update(action.statusId, initialHistory, history => history.withMutations(map => { + map.set('loading', true); + map.set('items', ImmutableList()); + })); + case HISTORY_FETCH_SUCCESS: + return state.update(action.statusId, initialHistory, history => history.withMutations(map => { + map.set('loading', false); + map.set('items', fromJS(action.history.map((x, i) => ({ ...x, account: x.account.id, original: i === 0 })).reverse())); + })); + case HISTORY_FETCH_FAIL: + return state.update(action.statusId, initialHistory, history => history.set('loading', false)); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/index.ts b/app/javascript/flavours/blobfox/reducers/index.ts new file mode 100644 index 00000000000000..4775c076e7837d --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/index.ts @@ -0,0 +1,111 @@ +import { Record as ImmutableRecord } from 'immutable'; + +import { loadingBarReducer } from 'react-redux-loading-bar'; +import { combineReducers } from 'redux-immutable'; + +import { accountsReducer } from './accounts'; +import accounts_map from './accounts_map'; +import alerts from './alerts'; +import announcements from './announcements'; +import blocks from './blocks'; +import boosts from './boosts'; +import compose from './compose'; +import contexts from './contexts'; +import conversations from './conversations'; +import custom_emojis from './custom_emojis'; +import domain_lists from './domain_lists'; +import { dropdownMenuReducer } from './dropdown_menu'; +import filters from './filters'; +import followed_tags from './followed_tags'; +import height_cache from './height_cache'; +import history from './history'; +import listAdder from './list_adder'; +import listEditor from './list_editor'; +import lists from './lists'; +import local_settings from './local_settings'; +import markers from './markers'; +import media_attachments from './media_attachments'; +import meta from './meta'; +import { modalReducer } from './modal'; +import mutes from './mutes'; +import notifications from './notifications'; +import picture_in_picture from './picture_in_picture'; +import pinnedAccountsEditor from './pinned_accounts_editor'; +import polls from './polls'; +import push_notifications from './push_notifications'; +import { relationshipsReducer } from './relationships'; +import search from './search'; +import server from './server'; +import settings from './settings'; +import status_lists from './status_lists'; +import statuses from './statuses'; +import suggestions from './suggestions'; +import tags from './tags'; +import timelines from './timelines'; +import trends from './trends'; +import user_lists from './user_lists'; + +const reducers = { + announcements, + dropdownMenu: dropdownMenuReducer, + timelines, + meta, + alerts, + loadingBar: loadingBarReducer, + modal: modalReducer, + user_lists, + domain_lists, + status_lists, + accounts: accountsReducer, + accounts_map, + statuses, + relationships: relationshipsReducer, + settings, + local_settings, + push_notifications, + mutes, + blocks, + boosts, + server, + contexts, + compose, + search, + media_attachments, + notifications, + height_cache, + custom_emojis, + lists, + listEditor, + listAdder, + filters, + conversations, + suggestions, + pinnedAccountsEditor, + polls, + trends, + markers, + picture_in_picture, + history, + tags, + followed_tags, +}; + +// We want the root state to be an ImmutableRecord, which is an object with a defined list of keys, +// so it is properly typed and keys can be accessed using `state.<key>` syntax. +// This will allow an easy conversion to a plain object once we no longer call `get` or `getIn` on the root state + +// By default with `combineReducers` it is a Collection, so we provide our own implementation to get a Record +const initialRootState = Object.fromEntries( + Object.entries(reducers).map(([name, reducer]) => [ + name, + reducer(undefined, { + // empty action + }), + ]), +); + +const RootStateRecord = ImmutableRecord(initialRootState, 'RootState'); + +const rootReducer = combineReducers(reducers, RootStateRecord); + +export { rootReducer }; diff --git a/app/javascript/flavours/blobfox/reducers/list_adder.js b/app/javascript/flavours/blobfox/reducers/list_adder.js new file mode 100644 index 00000000000000..0f61273aa6c574 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/list_adder.js @@ -0,0 +1,48 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; + +import { + LIST_ADDER_RESET, + LIST_ADDER_SETUP, + LIST_ADDER_LISTS_FETCH_REQUEST, + LIST_ADDER_LISTS_FETCH_SUCCESS, + LIST_ADDER_LISTS_FETCH_FAIL, + LIST_EDITOR_ADD_SUCCESS, + LIST_EDITOR_REMOVE_SUCCESS, +} from '../actions/lists'; + +const initialState = ImmutableMap({ + accountId: null, + + lists: ImmutableMap({ + items: ImmutableList(), + loaded: false, + isLoading: false, + }), +}); + +export default function listAdderReducer(state = initialState, action) { + switch(action.type) { + case LIST_ADDER_RESET: + return initialState; + case LIST_ADDER_SETUP: + return state.withMutations(map => { + map.set('accountId', action.account.get('id')); + }); + case LIST_ADDER_LISTS_FETCH_REQUEST: + return state.setIn(['lists', 'isLoading'], true); + case LIST_ADDER_LISTS_FETCH_FAIL: + return state.setIn(['lists', 'isLoading'], false); + case LIST_ADDER_LISTS_FETCH_SUCCESS: + return state.update('lists', lists => lists.withMutations(map => { + map.set('isLoading', false); + map.set('loaded', true); + map.set('items', ImmutableList(action.lists.map(item => item.id))); + })); + case LIST_EDITOR_ADD_SUCCESS: + return state.updateIn(['lists', 'items'], list => list.unshift(action.listId)); + case LIST_EDITOR_REMOVE_SUCCESS: + return state.updateIn(['lists', 'items'], list => list.filterNot(item => item === action.listId)); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/list_editor.js b/app/javascript/flavours/blobfox/reducers/list_editor.js new file mode 100644 index 00000000000000..d3fd62adecbced --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/list_editor.js @@ -0,0 +1,99 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; + +import { + LIST_CREATE_REQUEST, + LIST_CREATE_FAIL, + LIST_CREATE_SUCCESS, + LIST_UPDATE_REQUEST, + LIST_UPDATE_FAIL, + LIST_UPDATE_SUCCESS, + LIST_EDITOR_RESET, + LIST_EDITOR_SETUP, + LIST_EDITOR_TITLE_CHANGE, + LIST_ACCOUNTS_FETCH_REQUEST, + LIST_ACCOUNTS_FETCH_SUCCESS, + LIST_ACCOUNTS_FETCH_FAIL, + LIST_EDITOR_SUGGESTIONS_READY, + LIST_EDITOR_SUGGESTIONS_CLEAR, + LIST_EDITOR_SUGGESTIONS_CHANGE, + LIST_EDITOR_ADD_SUCCESS, + LIST_EDITOR_REMOVE_SUCCESS, +} from '../actions/lists'; + +const initialState = ImmutableMap({ + listId: null, + isSubmitting: false, + isChanged: false, + title: '', + isExclusive: false, + + accounts: ImmutableMap({ + items: ImmutableList(), + loaded: false, + isLoading: false, + }), + + suggestions: ImmutableMap({ + value: '', + items: ImmutableList(), + }), +}); + +export default function listEditorReducer(state = initialState, action) { + switch(action.type) { + case LIST_EDITOR_RESET: + return initialState; + case LIST_EDITOR_SETUP: + return state.withMutations(map => { + map.set('listId', action.list.get('id')); + map.set('title', action.list.get('title')); + map.set('isExclusive', action.list.get('is_exclusive')); + map.set('isSubmitting', false); + }); + case LIST_EDITOR_TITLE_CHANGE: + return state.withMutations(map => { + map.set('title', action.value); + map.set('isChanged', true); + }); + case LIST_CREATE_REQUEST: + case LIST_UPDATE_REQUEST: + return state.withMutations(map => { + map.set('isSubmitting', true); + map.set('isChanged', false); + }); + case LIST_CREATE_FAIL: + case LIST_UPDATE_FAIL: + return state.set('isSubmitting', false); + case LIST_CREATE_SUCCESS: + case LIST_UPDATE_SUCCESS: + return state.withMutations(map => { + map.set('isSubmitting', false); + map.set('listId', action.list.id); + }); + case LIST_ACCOUNTS_FETCH_REQUEST: + return state.setIn(['accounts', 'isLoading'], true); + case LIST_ACCOUNTS_FETCH_FAIL: + return state.setIn(['accounts', 'isLoading'], false); + case LIST_ACCOUNTS_FETCH_SUCCESS: + return state.update('accounts', accounts => accounts.withMutations(map => { + map.set('isLoading', false); + map.set('loaded', true); + map.set('items', ImmutableList(action.accounts.map(item => item.id))); + })); + case LIST_EDITOR_SUGGESTIONS_CHANGE: + return state.setIn(['suggestions', 'value'], action.value); + case LIST_EDITOR_SUGGESTIONS_READY: + return state.setIn(['suggestions', 'items'], ImmutableList(action.accounts.map(item => item.id))); + case LIST_EDITOR_SUGGESTIONS_CLEAR: + return state.update('suggestions', suggestions => suggestions.withMutations(map => { + map.set('items', ImmutableList()); + map.set('value', ''); + })); + case LIST_EDITOR_ADD_SUCCESS: + return state.updateIn(['accounts', 'items'], list => list.unshift(action.accountId)); + case LIST_EDITOR_REMOVE_SUCCESS: + return state.updateIn(['accounts', 'items'], list => list.filterNot(item => item === action.accountId)); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/lists.js b/app/javascript/flavours/blobfox/reducers/lists.js new file mode 100644 index 00000000000000..2a797772b30437 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/lists.js @@ -0,0 +1,38 @@ +import { Map as ImmutableMap, fromJS } from 'immutable'; + +import { + LIST_FETCH_SUCCESS, + LIST_FETCH_FAIL, + LISTS_FETCH_SUCCESS, + LIST_CREATE_SUCCESS, + LIST_UPDATE_SUCCESS, + LIST_DELETE_SUCCESS, +} from '../actions/lists'; + +const initialState = ImmutableMap(); + +const normalizeList = (state, list) => state.set(list.id, fromJS(list)); + +const normalizeLists = (state, lists) => { + lists.forEach(list => { + state = normalizeList(state, list); + }); + + return state; +}; + +export default function lists(state = initialState, action) { + switch(action.type) { + case LIST_FETCH_SUCCESS: + case LIST_CREATE_SUCCESS: + case LIST_UPDATE_SUCCESS: + return normalizeList(state, action.list); + case LISTS_FETCH_SUCCESS: + return normalizeLists(state, action.lists); + case LIST_DELETE_SUCCESS: + case LIST_FETCH_FAIL: + return state.set(action.id, false); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/local_settings.js b/app/javascript/flavours/blobfox/reducers/local_settings.js new file mode 100644 index 00000000000000..26da423839502e --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/local_settings.js @@ -0,0 +1,79 @@ +// Package imports. +import { Map as ImmutableMap } from 'immutable'; + +// Our imports. +import { LOCAL_SETTING_CHANGE, LOCAL_SETTING_DELETE } from 'flavours/blobfox/actions/local_settings'; +import { STORE_HYDRATE } from 'flavours/blobfox/actions/store'; + +const initialState = ImmutableMap({ + stretch : true, + side_arm : 'none', + side_arm_reply_mode : 'keep', + show_reply_count : false, + always_show_spoilers_field: false, + confirm_missing_media_description: false, + confirm_boost_missing_media_description: false, + confirm_before_clearing_draft: true, + prepend_cw_re: true, + preselect_on_reply: true, + inline_preview_cards: true, + hicolor_privacy_icons: false, + show_content_type_choice: false, + tag_misleading_links: true, + rewrite_mentions: 'no', + content_warnings : ImmutableMap({ + filter : null, + media_outside: false, + shared_state : false, + }), + collapsed : ImmutableMap({ + enabled : true, + auto : ImmutableMap({ + all : false, + notifications : true, + lengthy : true, + reblogs : false, + replies : false, + media : false, + height : 400, + }), + backgrounds : ImmutableMap({ + user_backgrounds : false, + preview_images : false, + }), + show_action_bar : true, + }), + media : ImmutableMap({ + letterbox : true, + fullwidth : true, + reveal_behind_cw : false, + pop_in_player : true, + pop_in_position : 'right', + }), + notifications : ImmutableMap({ + favicon_badge : false, + tab_badge : true, + }), + status_icons : ImmutableMap({ + language: true, + reply: true, + local_only: true, + media: true, + visibility: true, + }), +}); + +const hydrate = (state, localSettings) => state.mergeDeep(localSettings); + +export default function localSettings(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return hydrate(state, action.state.get('local_settings')); + case LOCAL_SETTING_CHANGE: + return state.setIn(action.key, action.value); + case LOCAL_SETTING_DELETE: + return state.deleteIn(action.key); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/markers.js b/app/javascript/flavours/blobfox/reducers/markers.js new file mode 100644 index 00000000000000..c7c5d99f6143f4 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/markers.js @@ -0,0 +1,26 @@ +import { Map as ImmutableMap } from 'immutable'; + +import { + MARKERS_SUBMIT_SUCCESS, +} from '../actions/markers'; + + +const initialState = ImmutableMap({ + home: '0', + notifications: '0', +}); + +export default function markers(state = initialState, action) { + switch(action.type) { + case MARKERS_SUBMIT_SUCCESS: + if (action.home) { + state = state.set('home', action.home); + } + if (action.notifications) { + state = state.set('notifications', action.notifications); + } + return state; + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/media_attachments.js b/app/javascript/flavours/blobfox/reducers/media_attachments.js new file mode 100644 index 00000000000000..cbb4933bc7efa8 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/media_attachments.js @@ -0,0 +1,16 @@ +import { Map as ImmutableMap } from 'immutable'; + +import { STORE_HYDRATE } from '../actions/store'; + +const initialState = ImmutableMap({ + accept_content_types: [], +}); + +export default function meta(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return state.merge(action.state.get('media_attachments')); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/meta.js b/app/javascript/flavours/blobfox/reducers/meta.js new file mode 100644 index 00000000000000..2680969fc98f92 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/meta.js @@ -0,0 +1,25 @@ +import { Map as ImmutableMap } from 'immutable'; + +import { changeLayout } from 'flavours/blobfox/actions/app'; +import { STORE_HYDRATE } from 'flavours/blobfox/actions/store'; +import { layoutFromWindow } from 'flavours/blobfox/is_mobile'; + +const initialState = ImmutableMap({ + streaming_api_base_url: null, + access_token: null, + layout: layoutFromWindow(), + permissions: '0', +}); + +export default function meta(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return state.merge(action.state.get('meta')) + .set('permissions', action.state.getIn(['role', 'permissions'])) + .set('layout', layoutFromWindow(action.state.getIn(['local_settings', 'layout']))); + case changeLayout.type: + return state.set('layout', action.payload.layout); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/modal.ts b/app/javascript/flavours/blobfox/reducers/modal.ts new file mode 100644 index 00000000000000..73a2afb916c509 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/modal.ts @@ -0,0 +1,83 @@ +import { Record as ImmutableRecord, Stack } from 'immutable'; + +import type { Reducer } from '@reduxjs/toolkit'; + +import { COMPOSE_UPLOAD_CHANGE_SUCCESS } from '../actions/compose'; +import type { ModalType } from '../actions/modal'; +import { openModal, closeModal } from '../actions/modal'; +import { TIMELINE_DELETE } from '../actions/timelines'; + +export type ModalProps = Record<string, unknown>; +interface Modal { + modalType: ModalType; + modalProps: ModalProps; +} + +const Modal = ImmutableRecord<Modal>({ + modalType: 'ACTIONS', + modalProps: ImmutableRecord({})(), +}); + +interface ModalState { + ignoreFocus: boolean; + stack: Stack<ImmutableRecord<Modal>>; +} + +const initialState = ImmutableRecord<ModalState>({ + ignoreFocus: false, + stack: Stack(), +})(); +type State = typeof initialState; + +interface PopModalOption { + modalType: ModalType | undefined; + ignoreFocus: boolean; +} +const popModal = ( + state: State, + { modalType, ignoreFocus }: PopModalOption, +): State => { + if ( + modalType === undefined || + modalType === state.get('stack').get(0)?.get('modalType') + ) { + return state + .set('ignoreFocus', !!ignoreFocus) + .update('stack', (stack) => stack.shift()); + } else { + return state; + } +}; + +const pushModal = ( + state: State, + modalType: ModalType, + modalProps: ModalProps, +): State => { + return state.withMutations((record) => { + record.set('ignoreFocus', false); + record.update('stack', (stack) => + stack.unshift(Modal({ modalType, modalProps })), + ); + }); +}; + +export const modalReducer: Reducer<State> = (state = initialState, action) => { + if (openModal.match(action)) + return pushModal( + state, + action.payload.modalType, + action.payload.modalProps, + ); + else if (closeModal.match(action)) return popModal(state, action.payload); + // TODO: type those actions + else if (action.type === COMPOSE_UPLOAD_CHANGE_SUCCESS) + return popModal(state, { modalType: 'FOCAL_POINT', ignoreFocus: false }); + else if (action.type === TIMELINE_DELETE) + return state.update('stack', (stack) => + stack.filterNot( + (modal) => modal.get('modalProps').statusId === action.id, + ), + ); + else return state; +}; diff --git a/app/javascript/flavours/blobfox/reducers/mutes.js b/app/javascript/flavours/blobfox/reducers/mutes.js new file mode 100644 index 00000000000000..a9eb61ff834cbc --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/mutes.js @@ -0,0 +1,31 @@ +import Immutable from 'immutable'; + +import { + MUTES_INIT_MODAL, + MUTES_TOGGLE_HIDE_NOTIFICATIONS, + MUTES_CHANGE_DURATION, +} from '../actions/mutes'; + +const initialState = Immutable.Map({ + new: Immutable.Map({ + account: null, + notifications: true, + duration: 0, + }), +}); + +export default function mutes(state = initialState, action) { + switch (action.type) { + case MUTES_INIT_MODAL: + return state.withMutations((state) => { + state.setIn(['new', 'account'], action.account); + state.setIn(['new', 'notifications'], true); + }); + case MUTES_TOGGLE_HIDE_NOTIFICATIONS: + return state.updateIn(['new', 'notifications'], (old) => !old); + case MUTES_CHANGE_DURATION: + return state.setIn(['new', 'duration'], Number(action.duration)); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/notifications.js b/app/javascript/flavours/blobfox/reducers/notifications.js new file mode 100644 index 00000000000000..9c377319e350cd --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/notifications.js @@ -0,0 +1,376 @@ +import { fromJS, Map as ImmutableMap, List as ImmutableList } from 'immutable'; + +import { blockDomainSuccess } from 'flavours/blobfox/actions/domain_blocks'; + +import { + authorizeFollowRequestSuccess, + blockAccountSuccess, + muteAccountSuccess, + rejectFollowRequestSuccess, +} from '../actions/accounts'; +import { + MARKERS_FETCH_SUCCESS, +} from '../actions/markers'; +import { + NOTIFICATIONS_MOUNT, + NOTIFICATIONS_UNMOUNT, + NOTIFICATIONS_SET_VISIBILITY, + notificationsUpdate, + NOTIFICATIONS_EXPAND_SUCCESS, + NOTIFICATIONS_EXPAND_REQUEST, + NOTIFICATIONS_EXPAND_FAIL, + NOTIFICATIONS_FILTER_SET, + NOTIFICATIONS_CLEAR, + NOTIFICATIONS_SCROLL_TOP, + NOTIFICATIONS_LOAD_PENDING, + NOTIFICATIONS_DELETE_MARKED_REQUEST, + NOTIFICATIONS_DELETE_MARKED_SUCCESS, + NOTIFICATION_MARK_FOR_DELETE, + NOTIFICATIONS_DELETE_MARKED_FAIL, + NOTIFICATIONS_ENTER_CLEARING_MODE, + NOTIFICATIONS_MARK_ALL_FOR_DELETE, + NOTIFICATIONS_MARK_AS_READ, + NOTIFICATIONS_SET_BROWSER_SUPPORT, + NOTIFICATIONS_SET_BROWSER_PERMISSION, +} from '../actions/notifications'; +import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines'; +import { compareId } from '../compare_id'; + +const initialState = ImmutableMap({ + pendingItems: ImmutableList(), + items: ImmutableList(), + hasMore: true, + top: false, + mounted: 0, + unread: 0, + lastReadId: '0', + readMarkerId: '0', + isLoading: 0, + cleaningMode: false, + isTabVisible: true, + browserSupport: false, + browserPermission: 'default', + // notification removal mark of new notifs loaded whilst cleaningMode is true. + markNewForDelete: false, +}); + +const notificationToMap = (notification, markForDelete) => ImmutableMap({ + id: notification.id, + type: notification.type, + account: notification.account.id, + markedForDelete: markForDelete, + status: notification.status ? notification.status.id : null, + report: notification.report ? fromJS(notification.report) : null, +}); + +const normalizeNotification = (state, notification, usePendingItems) => { + const markNewForDelete = state.get('markNewForDelete'); + const top = state.get('top'); + + // Under currently unknown conditions, the client may receive duplicates from the server + if (state.get('pendingItems').some((item) => item?.get('id') === notification.id) || state.get('items').some((item) => item?.get('id') === notification.id)) { + return state; + } + + if (usePendingItems || !state.get('pendingItems').isEmpty()) { + return state.update('pendingItems', list => list.unshift(notificationToMap(notification, markNewForDelete))).update('unread', unread => unread + 1); + } + + if (shouldCountUnreadNotifications(state)) { + state = state.update('unread', unread => unread + 1); + } else { + state = state.set('lastReadId', notification.id); + } + + return state.update('items', list => { + if (top && list.size > 40) { + list = list.take(20); + } + + return list.unshift(notificationToMap(notification, markNewForDelete)); + }); +}; + +const expandNormalizedNotifications = (state, notifications, next, isLoadingMore, isLoadingRecent, usePendingItems) => { + // This method is pretty tricky because: + // - existing notifications might be out of order + // - the existing notifications may have gaps, most often explicitly noted with a `null` item + // - ideally, we don't want it to reorder existing items + // - `notifications` may include items that are already included + // - this function can be called either to fill in a gap, or load newer items + + const markNewForDelete = state.get('markNewForDelete'); + const lastReadId = state.get('lastReadId'); + const newItems = ImmutableList(notifications.map((notification) => notificationToMap(notification, markNewForDelete))); + + return state.withMutations(mutable => { + if (!newItems.isEmpty()) { + usePendingItems = isLoadingRecent && (usePendingItems || !mutable.get('pendingItems').isEmpty()); + + mutable.update(usePendingItems ? 'pendingItems' : 'items', oldItems => { + // If called to poll *new* notifications, we just need to add them on top without duplicates + if (isLoadingRecent) { + const idsToCheck = oldItems.map(item => item?.get('id')).toSet(); + const insertedItems = newItems.filterNot(item => idsToCheck.includes(item.get('id'))); + return insertedItems.concat(oldItems); + } + + // If called to expand more (presumably older than any known to the WebUI), we just have to + // add them to the bottom without duplicates + if (isLoadingMore) { + const idsToCheck = oldItems.map(item => item?.get('id')).toSet(); + const insertedItems = newItems.filterNot(item => idsToCheck.includes(item.get('id'))); + return oldItems.concat(insertedItems); + } + + // Now this gets tricky, as we don't necessarily know for sure where the gap to fill is, + // and some items in the timeline may not be properly ordered. + + // However, we know that `newItems.last()` is the oldest item that was requested and that + // there is no “hole” between `newItems.last()` and `newItems.first()`. + + // First, find the furthest (if properly sorted, oldest) item in the notifications that is + // newer than the oldest fetched one, as it's most likely that it delimits the gap. + // Start the gap *after* that item. + const lastIndex = oldItems.findLastIndex(item => item !== null && compareId(item.get('id'), newItems.last().get('id')) >= 0) + 1; + + // Then, try to find the furthest (if properly sorted, oldest) item in the notifications that + // is newer than the most recent fetched one, as it delimits a section comprised of only + // items older or within `newItems` (or that were deleted from the server, so should be removed + // anyway). + // Stop the gap *after* that item. + const firstIndex = oldItems.take(lastIndex).findLastIndex(item => item !== null && compareId(item.get('id'), newItems.first().get('id')) > 0) + 1; + + // At this point: + // - no `oldItems` after `firstIndex` is newer than any of the `newItems` + // - all `oldItems` after `lastIndex` are older than every of the `newItems` + // - it is possible for items in the replaced slice to be older than every `newItems` + // - it is possible for items before `firstIndex` to be in the `newItems` range + // Therefore: + // - to avoid losing items, items from the replaced slice that are older than `newItems` + // should be added in the back. + // - to avoid duplicates, `newItems` should be checked the first `firstIndex` items of + // `oldItems` + const idsToCheck = oldItems.take(firstIndex).map(item => item?.get('id')).toSet(); + const insertedItems = newItems.filterNot(item => idsToCheck.includes(item.get('id'))); + const olderItems = oldItems.slice(firstIndex, lastIndex).filter(item => item !== null && compareId(item.get('id'), newItems.last().get('id')) < 0); + + return oldItems.take(firstIndex).concat( + insertedItems, + olderItems, + oldItems.skip(lastIndex), + ); + }); + } + + if (!next) { + mutable.set('hasMore', false); + } + + if (shouldCountUnreadNotifications(state)) { + mutable.set('unread', mutable.get('pendingItems').count(item => item !== null) + mutable.get('items').count(item => item && compareId(item.get('id'), lastReadId) > 0)); + } else { + const mostRecent = newItems.find(item => item !== null); + if (mostRecent && compareId(lastReadId, mostRecent.get('id')) < 0) { + mutable.set('lastReadId', mostRecent.get('id')); + } + } + + mutable.update('isLoading', (nbLoading) => nbLoading - 1); + }); +}; + +const filterNotifications = (state, accountIds, type) => { + const helper = list => list.filterNot(item => item !== null && accountIds.includes(item.get('account')) && (type === undefined || type === item.get('type'))); + return state.update('items', helper).update('pendingItems', helper); +}; + +const clearUnread = (state) => { + state = state.set('unread', state.get('pendingItems').size); + const lastNotification = state.get('items').find(item => item !== null); + return state.set('lastReadId', lastNotification ? lastNotification.get('id') : '0'); +}; + +const updateTop = (state, top) => { + state = state.set('top', top); + + if (!shouldCountUnreadNotifications(state)) { + state = clearUnread(state); + } + + return state; +}; + +const deleteByStatus = (state, statusId) => { + const lastReadId = state.get('lastReadId'); + + if (shouldCountUnreadNotifications(state)) { + const deletedUnread = state.get('items').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0); + state = state.update('unread', unread => unread - deletedUnread.size); + } + + const helper = list => list.filterNot(item => item !== null && item.get('status') === statusId); + const deletedUnread = state.get('pendingItems').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0); + state = state.update('unread', unread => unread - deletedUnread.size); + return state.update('items', helper).update('pendingItems', helper); +}; + +const markForDelete = (state, notificationId, yes) => { + return state.update('items', list => list.map(item => { + if (item === null) { + return null; + } else if(item.get('id') === notificationId) { + return item.set('markedForDelete', yes); + } else { + return item; + } + })); +}; + +const markAllForDelete = (state, yes) => { + return state.update('items', list => list.map(item => { + if (item === null) { + return null; + } else if(yes !== null) { + return item.set('markedForDelete', yes); + } else { + return item.set('markedForDelete', !item.get('markedForDelete')); + } + })); +}; + +const unmarkAllForDelete = (state) => { + return state.update('items', list => list.map(item => item === null ? item : item.set('markedForDelete', false))); +}; + +const deleteMarkedNotifs = (state) => { + return state.update('items', list => list.filterNot(item => item === null ? item : item.get('markedForDelete'))); +}; + +const updateMounted = (state) => { + state = state.update('mounted', count => count + 1); + if (!shouldCountUnreadNotifications(state, state.get('mounted') === 1)) { + state = state.set('readMarkerId', state.get('lastReadId')); + state = clearUnread(state); + } + return state; +}; + +const updateVisibility = (state, visibility) => { + state = state.set('isTabVisible', visibility); + if (!shouldCountUnreadNotifications(state)) { + state = state.set('readMarkerId', state.get('lastReadId')); + state = clearUnread(state); + } + return state; +}; + +const shouldCountUnreadNotifications = (state, ignoreScroll = false) => { + const isTabVisible = state.get('isTabVisible'); + const isOnTop = state.get('top'); + const isMounted = state.get('mounted') > 0; + const lastReadId = state.get('lastReadId'); + const lastItem = state.get('items').findLast(item => item !== null); + const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (lastItem && compareId(lastItem.get('id'), lastReadId) <= 0); + + return !(isTabVisible && (ignoreScroll || isOnTop) && isMounted && lastItemReached); +}; + +const recountUnread = (state, last_read_id) => { + return state.withMutations(mutable => { + if (compareId(last_read_id, mutable.get('lastReadId')) > 0) { + mutable.set('lastReadId', last_read_id); + } + + if (compareId(last_read_id, mutable.get('readMarkerId')) > 0) { + mutable.set('readMarkerId', last_read_id); + } + + if (state.get('unread') > 0 || shouldCountUnreadNotifications(state)) { + mutable.set('unread', mutable.get('pendingItems').count(item => item !== null) + mutable.get('items').count(item => item && compareId(item.get('id'), last_read_id) > 0)); + } + }); +}; + +export default function notifications(state = initialState, action) { + let st; + + switch(action.type) { + case MARKERS_FETCH_SUCCESS: + return action.markers.notifications ? recountUnread(state, action.markers.notifications.last_read_id) : state; + case NOTIFICATIONS_MOUNT: + return updateMounted(state); + case NOTIFICATIONS_UNMOUNT: + return state.update('mounted', count => count - 1); + case NOTIFICATIONS_SET_VISIBILITY: + return updateVisibility(state, action.visibility); + case NOTIFICATIONS_LOAD_PENDING: + return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0); + case NOTIFICATIONS_EXPAND_REQUEST: + case NOTIFICATIONS_DELETE_MARKED_REQUEST: + return state.update('isLoading', (nbLoading) => nbLoading + 1); + case NOTIFICATIONS_DELETE_MARKED_FAIL: + case NOTIFICATIONS_EXPAND_FAIL: + return state.update('isLoading', (nbLoading) => nbLoading - 1); + case NOTIFICATIONS_FILTER_SET: + return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', true); + case NOTIFICATIONS_SCROLL_TOP: + return updateTop(state, action.top); + case notificationsUpdate.type: + return normalizeNotification(state, action.payload.notification, action.payload.usePendingItems); + case NOTIFICATIONS_EXPAND_SUCCESS: + return expandNormalizedNotifications(state, action.notifications, action.next, action.isLoadingMore, action.isLoadingRecent, action.usePendingItems); + case blockAccountSuccess.type: + return filterNotifications(state, [action.payload.relationship.id]); + case muteAccountSuccess.type: + return action.payload.relationship.muting_notifications ? filterNotifications(state, [action.payload.relationship.id]) : state; + case blockDomainSuccess.type: + return filterNotifications(state, action.payload.accounts); + case authorizeFollowRequestSuccess.type: + case rejectFollowRequestSuccess.type: + return filterNotifications(state, [action.payload.id], 'follow_request'); + case NOTIFICATIONS_CLEAR: + return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false); + case TIMELINE_DELETE: + return deleteByStatus(state, action.id); + case TIMELINE_DISCONNECT: + return action.timeline === 'home' ? + state.update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) : + state; + case NOTIFICATIONS_SET_BROWSER_SUPPORT: + return state.set('browserSupport', action.value); + case NOTIFICATIONS_SET_BROWSER_PERMISSION: + return state.set('browserPermission', action.value); + + case NOTIFICATION_MARK_FOR_DELETE: + return markForDelete(state, action.id, action.yes); + + case NOTIFICATIONS_DELETE_MARKED_SUCCESS: + return deleteMarkedNotifs(state).update('isLoading', (nbLoading) => nbLoading - 1); + + case NOTIFICATIONS_ENTER_CLEARING_MODE: + st = state.set('cleaningMode', action.yes); + if (!action.yes) { + return unmarkAllForDelete(st).set('markNewForDelete', false); + } else { + return st; + } + + case NOTIFICATIONS_MARK_ALL_FOR_DELETE: + st = state; + if (action.yes === null) { + // Toggle - this is a bit confusing, as it toggles the all-none mode + //st = st.set('markNewForDelete', !st.get('markNewForDelete')); + } else { + st = st.set('markNewForDelete', action.yes); + } + return markAllForDelete(st, action.yes); + + case NOTIFICATIONS_MARK_AS_READ: + const lastNotification = state.get('items').find(item => item !== null); + return lastNotification ? recountUnread(state, lastNotification.get('id')) : state; + + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/picture_in_picture.js b/app/javascript/flavours/blobfox/reducers/picture_in_picture.js new file mode 100644 index 00000000000000..edf98eb9fefc10 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/picture_in_picture.js @@ -0,0 +1,26 @@ +import { PICTURE_IN_PICTURE_DEPLOY, PICTURE_IN_PICTURE_REMOVE } from 'flavours/blobfox/actions/picture_in_picture'; + +import { TIMELINE_DELETE } from '../actions/timelines'; + +const initialState = { + statusId: null, + accountId: null, + type: null, + src: null, + muted: false, + volume: 0, + currentTime: 0, +}; + +export default function pictureInPicture(state = initialState, action) { + switch(action.type) { + case PICTURE_IN_PICTURE_DEPLOY: + return { statusId: action.statusId, accountId: action.accountId, type: action.playerType, ...action.props }; + case PICTURE_IN_PICTURE_REMOVE: + return { ...initialState }; + case TIMELINE_DELETE: + return (state.statusId === action.id) ? { ...initialState } : state; + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/pinned_accounts_editor.js b/app/javascript/flavours/blobfox/reducers/pinned_accounts_editor.js new file mode 100644 index 00000000000000..352db5733bc2d6 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/pinned_accounts_editor.js @@ -0,0 +1,58 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; + +import { + PINNED_ACCOUNTS_EDITOR_RESET, + PINNED_ACCOUNTS_FETCH_REQUEST, + PINNED_ACCOUNTS_FETCH_SUCCESS, + PINNED_ACCOUNTS_FETCH_FAIL, + PINNED_ACCOUNTS_SUGGESTIONS_FETCH_SUCCESS, + PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR, + PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE, + pinAccountSuccess, + unpinAccountSuccess, +} from '../actions/accounts'; + +const initialState = ImmutableMap({ + accounts: ImmutableMap({ + items: ImmutableList(), + loaded: false, + isLoading: false, + }), + + suggestions: ImmutableMap({ + value: '', + items: ImmutableList(), + }), +}); + +export default function listEditorReducer(state = initialState, action) { + switch(action.type) { + case PINNED_ACCOUNTS_EDITOR_RESET: + return initialState; + case PINNED_ACCOUNTS_FETCH_REQUEST: + return state.setIn(['accounts', 'isLoading'], true); + case PINNED_ACCOUNTS_FETCH_FAIL: + return state.setIn(['accounts', 'isLoading'], false); + case PINNED_ACCOUNTS_FETCH_SUCCESS: + return state.update('accounts', accounts => accounts.withMutations(map => { + map.set('isLoading', false); + map.set('loaded', true); + map.set('items', ImmutableList(action.accounts.map(item => item.id))); + })); + case PINNED_ACCOUNTS_SUGGESTIONS_FETCH_SUCCESS: + return state.setIn(['suggestions', 'items'], ImmutableList(action.accounts.map(item => item.id))); + case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE: + return state.setIn(['suggestions', 'value'], action.value); + case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR: + return state.update('suggestions', suggestions => suggestions.withMutations(map => { + map.set('items', ImmutableList()); + map.set('value', ''); + })); + case pinAccountSuccess.type: + return state.updateIn(['accounts', 'items'], list => list.unshift(action.payload.relationship.id)); + case unpinAccountSuccess.type: + return state.updateIn(['accounts', 'items'], list => list.filterNot(item => item === action.payload.relationship.id)); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/polls.js b/app/javascript/flavours/blobfox/reducers/polls.js new file mode 100644 index 00000000000000..67e204c53c1fac --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/polls.js @@ -0,0 +1,45 @@ +import { Map as ImmutableMap, fromJS } from 'immutable'; + +import { POLLS_IMPORT } from 'flavours/blobfox/actions/importer'; + +import { normalizePollOptionTranslation } from '../actions/importer/normalizer'; +import { STATUS_TRANSLATE_SUCCESS, STATUS_TRANSLATE_UNDO } from '../actions/statuses'; + +const importPolls = (state, polls) => state.withMutations(map => polls.forEach(poll => map.set(poll.id, fromJS(poll)))); + +const statusTranslateSuccess = (state, pollTranslation) => { + return state.withMutations(map => { + if (pollTranslation) { + const poll = state.get(pollTranslation.id); + + pollTranslation.options.forEach((item, index) => { + map.setIn([pollTranslation.id, 'options', index, 'translation'], fromJS(normalizePollOptionTranslation(item, poll))); + }); + } + }); +}; + +const statusTranslateUndo = (state, id) => { + return state.withMutations(map => { + const options = map.getIn([id, 'options']); + + if (options) { + options.forEach((item, index) => map.deleteIn([id, 'options', index, 'translation'])); + } + }); +}; + +const initialState = ImmutableMap(); + +export default function polls(state = initialState, action) { + switch(action.type) { + case POLLS_IMPORT: + return importPolls(state, action.polls); + case STATUS_TRANSLATE_SUCCESS: + return statusTranslateSuccess(state, action.translation.poll); + case STATUS_TRANSLATE_UNDO: + return statusTranslateUndo(state, action.pollId); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/push_notifications.js b/app/javascript/flavours/blobfox/reducers/push_notifications.js new file mode 100644 index 00000000000000..fa8af0e8ccbdaf --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/push_notifications.js @@ -0,0 +1,54 @@ +import Immutable from 'immutable'; + +import { SET_BROWSER_SUPPORT, SET_SUBSCRIPTION, CLEAR_SUBSCRIPTION, SET_ALERTS } from '../actions/push_notifications'; +import { STORE_HYDRATE } from '../actions/store'; + +const initialState = Immutable.Map({ + subscription: null, + alerts: new Immutable.Map({ + follow: false, + follow_request: false, + favourite: false, + reblog: false, + mention: false, + poll: false, + }), + isSubscribed: false, + browserSupport: false, +}); + +export default function push_subscriptions(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: { + const push_subscription = action.state.get('push_subscription'); + + if (push_subscription) { + return state + .set('subscription', new Immutable.Map({ + id: push_subscription.get('id'), + endpoint: push_subscription.get('endpoint'), + })) + .set('alerts', push_subscription.get('alerts') || initialState.get('alerts')) + .set('isSubscribed', true); + } + + return state; + } + case SET_SUBSCRIPTION: + return state + .set('subscription', new Immutable.Map({ + id: action.subscription.id, + endpoint: action.subscription.endpoint, + })) + .set('alerts', new Immutable.Map(action.subscription.alerts)) + .set('isSubscribed', true); + case SET_BROWSER_SUPPORT: + return state.set('browserSupport', action.value); + case CLEAR_SUBSCRIPTION: + return initialState; + case SET_ALERTS: + return state.setIn(action.path, action.value); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/relationships.ts b/app/javascript/flavours/blobfox/reducers/relationships.ts new file mode 100644 index 00000000000000..997f7fef793e75 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/relationships.ts @@ -0,0 +1,123 @@ +import { Map as ImmutableMap } from 'immutable'; + +import { isFulfilled } from '@reduxjs/toolkit'; +import type { Reducer } from 'redux'; + +import type { ApiRelationshipJSON } from 'flavours/blobfox/api_types/relationships'; +import type { Account } from 'flavours/blobfox/models/account'; +import { createRelationship } from 'flavours/blobfox/models/relationship'; +import type { Relationship } from 'flavours/blobfox/models/relationship'; + +import { submitAccountNote } from '../actions/account_notes'; +import { + followAccountSuccess, + unfollowAccountSuccess, + authorizeFollowRequestSuccess, + rejectFollowRequestSuccess, + followAccountRequest, + followAccountFail, + unfollowAccountRequest, + unfollowAccountFail, + blockAccountSuccess, + unblockAccountSuccess, + muteAccountSuccess, + unmuteAccountSuccess, + pinAccountSuccess, + unpinAccountSuccess, + fetchRelationshipsSuccess, +} from '../actions/accounts_typed'; +import { + blockDomainSuccess, + unblockDomainSuccess, +} from '../actions/domain_blocks_typed'; +import { notificationsUpdate } from '../actions/notifications_typed'; + +const initialState = ImmutableMap<string, Relationship>(); +type State = typeof initialState; + +const normalizeRelationship = ( + state: State, + relationship: ApiRelationshipJSON, +) => state.set(relationship.id, createRelationship(relationship)); + +const normalizeRelationships = ( + state: State, + relationships: ApiRelationshipJSON[], +) => { + relationships.forEach((relationship) => { + state = normalizeRelationship(state, relationship); + }); + + return state; +}; + +const setDomainBlocking = ( + state: State, + accounts: Account[], + blocking: boolean, +) => { + return state.withMutations((map) => { + accounts.forEach((id) => { + map.setIn([id, 'domain_blocking'], blocking); + }); + }); +}; + +export const relationshipsReducer: Reducer<State> = ( + state = initialState, + action, +) => { + if (authorizeFollowRequestSuccess.match(action)) + return state + .setIn([action.payload.id, 'followed_by'], true) + .setIn([action.payload.id, 'requested_by'], false); + else if (rejectFollowRequestSuccess.match(action)) + return state + .setIn([action.payload.id, 'followed_by'], false) + .setIn([action.payload.id, 'requested_by'], false); + else if (notificationsUpdate.match(action)) + return action.payload.notification.type === 'follow_request' + ? state.setIn( + [action.payload.notification.account.id, 'requested_by'], + true, + ) + : state; + else if (followAccountRequest.match(action)) + return state.getIn([action.payload.id, 'following']) + ? state + : state.setIn( + [ + action.payload.id, + action.payload.locked ? 'requested' : 'following', + ], + true, + ); + else if (followAccountFail.match(action)) + return state.setIn( + [action.payload.id, action.payload.locked ? 'requested' : 'following'], + false, + ); + else if (unfollowAccountRequest.match(action)) + return state.setIn([action.payload.id, 'following'], false); + else if (unfollowAccountFail.match(action)) + return state.setIn([action.payload.id, 'following'], true); + else if ( + followAccountSuccess.match(action) || + unfollowAccountSuccess.match(action) || + blockAccountSuccess.match(action) || + unblockAccountSuccess.match(action) || + muteAccountSuccess.match(action) || + unmuteAccountSuccess.match(action) || + pinAccountSuccess.match(action) || + unpinAccountSuccess.match(action) || + isFulfilled(submitAccountNote)(action) + ) + return normalizeRelationship(state, action.payload.relationship); + else if (fetchRelationshipsSuccess.match(action)) + return normalizeRelationships(state, action.payload.relationships); + else if (blockDomainSuccess.match(action)) + return setDomainBlocking(state, action.payload.accounts, true); + else if (unblockDomainSuccess.match(action)) + return setDomainBlocking(state, action.payload.accounts, false); + else return state; +}; diff --git a/app/javascript/flavours/blobfox/reducers/search.js b/app/javascript/flavours/blobfox/reducers/search.js new file mode 100644 index 00000000000000..72835eb91745f3 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/search.js @@ -0,0 +1,82 @@ +import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; + +import { + COMPOSE_MENTION, + COMPOSE_REPLY, + COMPOSE_DIRECT, +} from '../actions/compose'; +import { + SEARCH_CHANGE, + SEARCH_CLEAR, + SEARCH_FETCH_REQUEST, + SEARCH_FETCH_FAIL, + SEARCH_FETCH_SUCCESS, + SEARCH_SHOW, + SEARCH_EXPAND_REQUEST, + SEARCH_EXPAND_SUCCESS, + SEARCH_EXPAND_FAIL, + SEARCH_HISTORY_UPDATE, +} from '../actions/search'; + +const initialState = ImmutableMap({ + value: '', + submitted: false, + hidden: false, + results: ImmutableMap(), + isLoading: false, + searchTerm: '', + type: null, + recent: ImmutableOrderedSet(), +}); + +export default function search(state = initialState, action) { + switch(action.type) { + case SEARCH_CHANGE: + return state.set('value', action.value); + case SEARCH_CLEAR: + return state.withMutations(map => { + map.set('value', ''); + map.set('results', ImmutableMap()); + map.set('submitted', false); + map.set('hidden', false); + map.set('searchTerm', ''); + map.set('type', null); + }); + case SEARCH_SHOW: + return state.set('hidden', false); + case COMPOSE_REPLY: + case COMPOSE_MENTION: + case COMPOSE_DIRECT: + return state.set('hidden', true); + case SEARCH_FETCH_REQUEST: + return state.withMutations(map => { + map.set('isLoading', true); + map.set('submitted', true); + map.set('type', action.searchType); + }); + case SEARCH_FETCH_FAIL: + case SEARCH_EXPAND_FAIL: + return state.set('isLoading', false); + case SEARCH_FETCH_SUCCESS: + return state.withMutations(map => { + map.set('results', ImmutableMap({ + accounts: ImmutableOrderedSet(action.results.accounts.map(item => item.id)), + statuses: ImmutableOrderedSet(action.results.statuses.map(item => item.id)), + hashtags: ImmutableOrderedSet(fromJS(action.results.hashtags)), + })); + + map.set('searchTerm', action.searchTerm); + map.set('type', action.searchType); + map.set('isLoading', false); + }); + case SEARCH_EXPAND_REQUEST: + return state.set('type', action.searchType).set('isLoading', true); + case SEARCH_EXPAND_SUCCESS: + const results = action.searchType === 'hashtags' ? ImmutableOrderedSet(fromJS(action.results.hashtags)) : action.results[action.searchType].map(item => item.id); + return state.updateIn(['results', action.searchType], list => list.union(results)).set('isLoading', false); + case SEARCH_HISTORY_UPDATE: + return state.set('recent', ImmutableOrderedSet(fromJS(action.recent))); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/server.js b/app/javascript/flavours/blobfox/reducers/server.js new file mode 100644 index 00000000000000..4442e1b1141094 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/server.js @@ -0,0 +1,63 @@ +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; + +import { + SERVER_FETCH_REQUEST, + SERVER_FETCH_SUCCESS, + SERVER_FETCH_FAIL, + SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST, + SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS, + SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL, + EXTENDED_DESCRIPTION_REQUEST, + EXTENDED_DESCRIPTION_SUCCESS, + EXTENDED_DESCRIPTION_FAIL, + SERVER_DOMAIN_BLOCKS_FETCH_REQUEST, + SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS, + SERVER_DOMAIN_BLOCKS_FETCH_FAIL, +} from 'flavours/blobfox/actions/server'; + +const initialState = ImmutableMap({ + server: ImmutableMap({ + isLoading: false, + }), + + extendedDescription: ImmutableMap({ + isLoading: false, + }), + + domainBlocks: ImmutableMap({ + isLoading: false, + isAvailable: true, + items: ImmutableList(), + }), +}); + +export default function server(state = initialState, action) { + switch (action.type) { + case SERVER_FETCH_REQUEST: + return state.setIn(['server', 'isLoading'], true); + case SERVER_FETCH_SUCCESS: + return state.set('server', fromJS(action.server)).setIn(['server', 'isLoading'], false); + case SERVER_FETCH_FAIL: + return state.setIn(['server', 'isLoading'], false); + case SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST: + return state.setIn(['translationLanguages', 'isLoading'], true); + case SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS: + return state.setIn(['translationLanguages', 'items'], fromJS(action.translationLanguages)).setIn(['translationLanguages', 'isLoading'], false); + case SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL: + return state.setIn(['translationLanguages', 'isLoading'], false); + case EXTENDED_DESCRIPTION_REQUEST: + return state.setIn(['extendedDescription', 'isLoading'], true); + case EXTENDED_DESCRIPTION_SUCCESS: + return state.set('extendedDescription', fromJS(action.description)).setIn(['extendedDescription', 'isLoading'], false); + case EXTENDED_DESCRIPTION_FAIL: + return state.setIn(['extendedDescription', 'isLoading'], false); + case SERVER_DOMAIN_BLOCKS_FETCH_REQUEST: + return state.setIn(['domainBlocks', 'isLoading'], true); + case SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS: + return state.setIn(['domainBlocks', 'items'], fromJS(action.blocks)).setIn(['domainBlocks', 'isLoading'], false).setIn(['domainBlocks', 'isAvailable'], action.isAvailable); + case SERVER_DOMAIN_BLOCKS_FETCH_FAIL: + return state.setIn(['domainBlocks', 'isLoading'], false); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/settings.js b/app/javascript/flavours/blobfox/reducers/settings.js new file mode 100644 index 00000000000000..e695d3bf9d714b --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/settings.js @@ -0,0 +1,203 @@ +import { Map as ImmutableMap, fromJS } from 'immutable'; + +import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE, COLUMN_PARAMS_CHANGE } from '../actions/columns'; +import { EMOJI_USE } from '../actions/emojis'; +import { LANGUAGE_USE } from '../actions/languages'; +import { LIST_DELETE_SUCCESS, LIST_FETCH_FAIL } from '../actions/lists'; +import { NOTIFICATIONS_FILTER_SET } from '../actions/notifications'; +import { SETTING_CHANGE, SETTING_SAVE } from '../actions/settings'; +import { STORE_HYDRATE } from '../actions/store'; +import { uuid } from '../uuid'; + +const initialState = ImmutableMap({ + saved: true, + + onboarded: false, + layout: 'auto', + + skinTone: 1, + + trends: ImmutableMap({ + show: true, + }), + + home: ImmutableMap({ + shows: ImmutableMap({ + reblog: true, + reply: true, + direct: true, + }), + + regex: ImmutableMap({ + body: '', + }), + }), + + notifications: ImmutableMap({ + alerts: ImmutableMap({ + follow: false, + follow_request: false, + favourite: false, + reaction: false, + reblog: false, + mention: false, + poll: false, + status: false, + update: false, + 'admin.sign_up': false, + 'admin.report': false, + }), + + quickFilter: ImmutableMap({ + active: 'all', + show: true, + advanced: false, + }), + + dismissPermissionBanner: false, + showUnread: true, + + shows: ImmutableMap({ + follow: true, + follow_request: false, + favourite: true, + reaction: true, + reblog: true, + reaction: true, + mention: true, + poll: true, + status: true, + update: true, + 'admin.sign_up': true, + 'admin.report': true, + }), + + sounds: ImmutableMap({ + follow: true, + follow_request: false, + favourite: true, + reaction: true, + reblog: true, + reaction: true, + mention: true, + poll: true, + status: true, + update: true, + 'admin.sign_up': true, + 'admin.report': true, + }), + }), + + firehose: ImmutableMap({ + onlyMedia: false, + allowLocalOnly: true, + + regex: ImmutableMap({ + body: '', + }), + }), + + community: ImmutableMap({ + regex: ImmutableMap({ + body: '', + }), + }), + + public: ImmutableMap({ + regex: ImmutableMap({ + body: '', + }), + }), + + direct: ImmutableMap({ + conversations: true, + regex: ImmutableMap({ + body: '', + }), + }), + + dismissed_banners: ImmutableMap({ + 'public_timeline': false, + 'community_timeline': false, + 'home.explore_prompt': false, + 'explore/links': false, + 'explore/statuses': false, + 'explore/tags': false, + }), +}); + +const defaultColumns = fromJS([ + { id: 'COMPOSE', uuid: uuid(), params: {} }, + { id: 'HOME', uuid: uuid(), params: {} }, + { id: 'NOTIFICATIONS', uuid: uuid(), params: {} }, +]); + +const hydrate = (state, settings) => state.mergeDeep(settings).update('columns', (val = defaultColumns) => val); + +const moveColumn = (state, uuid, direction) => { + const columns = state.get('columns'); + const index = columns.findIndex(item => item.get('uuid') === uuid); + const newIndex = index + direction; + + let newColumns; + + newColumns = columns.splice(index, 1); + newColumns = newColumns.splice(newIndex, 0, columns.get(index)); + + return state + .set('columns', newColumns) + .set('saved', false); +}; + +const changeColumnParams = (state, uuid, path, value) => { + const columns = state.get('columns'); + const index = columns.findIndex(item => item.get('uuid') === uuid); + + const newColumns = columns.update(index, column => column.updateIn(['params', ...path], () => value)); + + return state + .set('columns', newColumns) + .set('saved', false); +}; + +const updateFrequentEmojis = (state, emoji) => state.update('frequentlyUsedEmojis', ImmutableMap(), map => map.update(emoji.id, 0, count => count + 1)).set('saved', false); + +const updateFrequentLanguages = (state, language) => state.update('frequentlyUsedLanguages', ImmutableMap(), map => map.update(language, 0, count => count + 1)).set('saved', false); + +const filterDeadListColumns = (state, listId) => state.update('columns', columns => columns.filterNot(column => column.get('id') === 'LIST' && column.get('params').get('id') === listId)); + +export default function settings(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return hydrate(state, action.state.get('settings')); + case NOTIFICATIONS_FILTER_SET: + case SETTING_CHANGE: + return state + .setIn(action.path, action.value) + .set('saved', false); + case COLUMN_ADD: + return state + .update('columns', list => list.push(fromJS({ id: action.id, uuid: uuid(), params: action.params }))) + .set('saved', false); + case COLUMN_REMOVE: + return state + .update('columns', list => list.filterNot(item => item.get('uuid') === action.uuid)) + .set('saved', false); + case COLUMN_MOVE: + return moveColumn(state, action.uuid, action.direction); + case COLUMN_PARAMS_CHANGE: + return changeColumnParams(state, action.uuid, action.path, action.value); + case EMOJI_USE: + return updateFrequentEmojis(state, action.emoji); + case LANGUAGE_USE: + return updateFrequentLanguages(state, action.language); + case SETTING_SAVE: + return state.set('saved', true); + case LIST_FETCH_FAIL: + return action.error.response.status === 404 ? filterDeadListColumns(state, action.id) : state; + case LIST_DELETE_SUCCESS: + return filterDeadListColumns(state, action.id); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/status_lists.js b/app/javascript/flavours/blobfox/reducers/status_lists.js new file mode 100644 index 00000000000000..6cb6a937bb915e --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/status_lists.js @@ -0,0 +1,151 @@ +import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; + +import { + blockAccountSuccess, + muteAccountSuccess, +} from '../actions/accounts'; +import { + BOOKMARKED_STATUSES_FETCH_REQUEST, + BOOKMARKED_STATUSES_FETCH_SUCCESS, + BOOKMARKED_STATUSES_FETCH_FAIL, + BOOKMARKED_STATUSES_EXPAND_REQUEST, + BOOKMARKED_STATUSES_EXPAND_SUCCESS, + BOOKMARKED_STATUSES_EXPAND_FAIL, +} from '../actions/bookmarks'; +import { + FAVOURITED_STATUSES_FETCH_REQUEST, + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_FETCH_FAIL, + FAVOURITED_STATUSES_EXPAND_REQUEST, + FAVOURITED_STATUSES_EXPAND_SUCCESS, + FAVOURITED_STATUSES_EXPAND_FAIL, +} from '../actions/favourites'; +import { + FAVOURITE_SUCCESS, + UNFAVOURITE_SUCCESS, + BOOKMARK_SUCCESS, + UNBOOKMARK_SUCCESS, + PIN_SUCCESS, + UNPIN_SUCCESS, +} from '../actions/interactions'; +import { + PINNED_STATUSES_FETCH_SUCCESS, +} from '../actions/pin_statuses'; +import { + TRENDS_STATUSES_FETCH_REQUEST, + TRENDS_STATUSES_FETCH_SUCCESS, + TRENDS_STATUSES_FETCH_FAIL, + TRENDS_STATUSES_EXPAND_REQUEST, + TRENDS_STATUSES_EXPAND_SUCCESS, + TRENDS_STATUSES_EXPAND_FAIL, +} from '../actions/trends'; + + + +const initialState = ImmutableMap({ + favourites: ImmutableMap({ + next: null, + loaded: false, + items: ImmutableOrderedSet(), + }), + bookmarks: ImmutableMap({ + next: null, + loaded: false, + items: ImmutableOrderedSet(), + }), + pins: ImmutableMap({ + next: null, + loaded: false, + items: ImmutableOrderedSet(), + }), + trending: ImmutableMap({ + next: null, + loaded: false, + items: ImmutableOrderedSet(), + }), +}); + +const normalizeList = (state, listType, statuses, next) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('next', next); + map.set('loaded', true); + map.set('isLoading', false); + map.set('items', ImmutableOrderedSet(statuses.map(item => item.id))); + })); +}; + +const appendToList = (state, listType, statuses, next) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('next', next); + map.set('isLoading', false); + map.set('items', map.get('items').union(statuses.map(item => item.id))); + })); +}; + +const prependOneToList = (state, listType, status) => { + return state.updateIn([listType, 'items'], (list) => { + if (list.includes(status.get('id'))) { + return list; + } else { + return ImmutableOrderedSet([status.get('id')]).union(list); + } + }); +}; + +const removeOneFromList = (state, listType, status) => { + return state.updateIn([listType, 'items'], (list) => list.delete(status.get('id'))); +}; + +export default function statusLists(state = initialState, action) { + switch(action.type) { + case FAVOURITED_STATUSES_FETCH_REQUEST: + case FAVOURITED_STATUSES_EXPAND_REQUEST: + return state.setIn(['favourites', 'isLoading'], true); + case FAVOURITED_STATUSES_FETCH_FAIL: + case FAVOURITED_STATUSES_EXPAND_FAIL: + return state.setIn(['favourites', 'isLoading'], false); + case FAVOURITED_STATUSES_FETCH_SUCCESS: + return normalizeList(state, 'favourites', action.statuses, action.next); + case FAVOURITED_STATUSES_EXPAND_SUCCESS: + return appendToList(state, 'favourites', action.statuses, action.next); + case BOOKMARKED_STATUSES_FETCH_REQUEST: + case BOOKMARKED_STATUSES_EXPAND_REQUEST: + return state.setIn(['bookmarks', 'isLoading'], true); + case BOOKMARKED_STATUSES_FETCH_FAIL: + case BOOKMARKED_STATUSES_EXPAND_FAIL: + return state.setIn(['bookmarks', 'isLoading'], false); + case BOOKMARKED_STATUSES_FETCH_SUCCESS: + return normalizeList(state, 'bookmarks', action.statuses, action.next); + case BOOKMARKED_STATUSES_EXPAND_SUCCESS: + return appendToList(state, 'bookmarks', action.statuses, action.next); + case TRENDS_STATUSES_FETCH_REQUEST: + case TRENDS_STATUSES_EXPAND_REQUEST: + return state.setIn(['trending', 'isLoading'], true); + case TRENDS_STATUSES_FETCH_FAIL: + case TRENDS_STATUSES_EXPAND_FAIL: + return state.setIn(['trending', 'isLoading'], false); + case TRENDS_STATUSES_FETCH_SUCCESS: + return normalizeList(state, 'trending', action.statuses, action.next); + case TRENDS_STATUSES_EXPAND_SUCCESS: + return appendToList(state, 'trending', action.statuses, action.next); + case FAVOURITE_SUCCESS: + return prependOneToList(state, 'favourites', action.status); + case UNFAVOURITE_SUCCESS: + return removeOneFromList(state, 'favourites', action.status); + case BOOKMARK_SUCCESS: + return prependOneToList(state, 'bookmarks', action.status); + case UNBOOKMARK_SUCCESS: + return removeOneFromList(state, 'bookmarks', action.status); + case PINNED_STATUSES_FETCH_SUCCESS: + return normalizeList(state, 'pins', action.statuses, action.next); + case PIN_SUCCESS: + return prependOneToList(state, 'pins', action.status); + case UNPIN_SUCCESS: + return removeOneFromList(state, 'pins', action.status); + case blockAccountSuccess.type: + case muteAccountSuccess.type: + return state.updateIn(['trending', 'items'], ImmutableOrderedSet(), list => list.filterNot(statusId => action.payload.statuses.getIn([statusId, 'account']) === action.payload.relationship.id)); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/statuses.js b/app/javascript/flavours/blobfox/reducers/statuses.js new file mode 100644 index 00000000000000..340291594fa537 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/statuses.js @@ -0,0 +1,191 @@ +import { Map as ImmutableMap, fromJS } from 'immutable'; + +import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer'; +import { normalizeStatusTranslation } from '../actions/importer/normalizer'; +import { + REBLOG_REQUEST, + REBLOG_FAIL, + UNREBLOG_REQUEST, + UNREBLOG_FAIL, + FAVOURITE_REQUEST, + FAVOURITE_FAIL, + UNFAVOURITE_REQUEST, + UNFAVOURITE_FAIL, + BOOKMARK_REQUEST, + BOOKMARK_FAIL, + UNBOOKMARK_REQUEST, + UNBOOKMARK_FAIL, + REACTION_UPDATE, + REACTION_ADD_FAIL, + REACTION_REMOVE_FAIL, + REACTION_ADD_REQUEST, + REACTION_REMOVE_REQUEST, +} from '../actions/interactions'; +import { + STATUS_MUTE_SUCCESS, + STATUS_UNMUTE_SUCCESS, + STATUS_REVEAL, + STATUS_HIDE, + STATUS_COLLAPSE, + STATUS_TRANSLATE_SUCCESS, + STATUS_TRANSLATE_UNDO, + STATUS_FETCH_REQUEST, + STATUS_FETCH_FAIL, +} from '../actions/statuses'; +import { TIMELINE_DELETE } from '../actions/timelines'; + +const importStatus = (state, status) => state.set(status.id, fromJS(status)); + +const importStatuses = (state, statuses) => + state.withMutations(mutable => statuses.forEach(status => importStatus(mutable, status))); + +const deleteStatus = (state, id, references) => { + references.forEach(ref => { + state = deleteStatus(state, ref, []); + }); + + return state.delete(id); +}; + +const statusTranslateSuccess = (state, id, translation) => { + return state.withMutations(map => { + map.setIn([id, 'translation'], fromJS(normalizeStatusTranslation(translation, map.get(id)))); + + const list = map.getIn([id, 'media_attachments']); + if (translation.media_attachments && list) { + translation.media_attachments.forEach(item => { + const index = list.findIndex(i => i.get('id') === item.id); + map.setIn([id, 'media_attachments', index, 'translation'], fromJS({ description: item.description })); + }); + } + }); +}; + +const statusTranslateUndo = (state, id) => { + return state.withMutations(map => { + map.deleteIn([id, 'translation']); + map.getIn([id, 'media_attachments']).forEach((item, index) => map.deleteIn([id, 'media_attachments', index, 'translation'])); + }); +}; + +const updateReaction = (state, id, name, updater) => state.update( + id, + status => status.update( + 'reactions', + reactions => { + const index = reactions.findIndex(reaction => reaction.get('name') === name); + if (index > -1) { + return reactions.update(index, reaction => updater(reaction)); + } else { + return reactions.push(updater(fromJS({ name, count: 0 }))); + } + }, + ), +); + +const updateReactionCount = (state, reaction) => updateReaction(state, reaction.status_id, reaction.name, x => x.set('count', reaction.count)); + +// The url parameter is only used when adding a new custom emoji reaction +// (one that wasn't in the reactions list before) because we don't have its +// URL yet. In all other cases, it's undefined. +const addReaction = (state, id, name, url) => updateReaction( + state, + id, + name, + x => x.set('me', true) + .update('count', n => n + 1) + .update('url', old => old ? old : url) + .update('static_url', old => old ? old : url), +); + +const removeReaction = (state, id, name) => updateReaction( + state, + id, + name, + x => x.set('me', false).update('count', n => n - 1), +); + +const initialState = ImmutableMap(); + +export default function statuses(state = initialState, action) { + switch(action.type) { + case STATUS_FETCH_REQUEST: + return state.setIn([action.id, 'isLoading'], true); + case STATUS_FETCH_FAIL: + return state.delete(action.id); + case STATUS_IMPORT: + return importStatus(state, action.status); + case STATUSES_IMPORT: + return importStatuses(state, action.statuses); + case FAVOURITE_REQUEST: + return state.setIn([action.status.get('id'), 'favourited'], true); + case FAVOURITE_FAIL: + return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'favourited'], false); + case UNFAVOURITE_REQUEST: + return state.setIn([action.status.get('id'), 'favourited'], false); + case UNFAVOURITE_FAIL: + return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'favourited'], true); + case BOOKMARK_REQUEST: + return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], true); + case BOOKMARK_FAIL: + return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], false); + case UNBOOKMARK_REQUEST: + return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], false); + case UNBOOKMARK_FAIL: + return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], true); + case REBLOG_REQUEST: + return state.setIn([action.status.get('id'), 'reblogged'], true); + case REBLOG_FAIL: + return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'reblogged'], false); + case REACTION_UPDATE: + return updateReactionCount(state, action.reaction); + case REACTION_ADD_REQUEST: + case REACTION_REMOVE_FAIL: + return addReaction(state, action.id, action.name, action.url); + case REACTION_REMOVE_REQUEST: + case REACTION_ADD_FAIL: + return removeReaction(state, action.id, action.name); + case UNREBLOG_REQUEST: + return state.setIn([action.status.get('id'), 'reblogged'], false); + case UNREBLOG_FAIL: + return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'reblogged'], true); + case REACTION_UPDATE: + return updateReactionCount(state, action.reaction); + case REACTION_ADD_REQUEST: + case REACTION_REMOVE_FAIL: + return addReaction(state, action.id, action.name, action.url); + case REACTION_REMOVE_REQUEST: + case REACTION_ADD_FAIL: + return removeReaction(state, action.id, action.name); + case STATUS_MUTE_SUCCESS: + return state.setIn([action.id, 'muted'], true); + case STATUS_UNMUTE_SUCCESS: + return state.setIn([action.id, 'muted'], false); + case STATUS_REVEAL: + return state.withMutations(map => { + action.ids.forEach(id => { + if (!(state.get(id) === undefined)) { + map.setIn([id, 'hidden'], false); + } + }); + }); + case STATUS_HIDE: + return state.withMutations(map => { + action.ids.forEach(id => { + if (!(state.get(id) === undefined)) { + map.setIn([id, 'hidden'], true); + } + }); + }); + case STATUS_COLLAPSE: + return state.setIn([action.id, 'collapsed'], action.isCollapsed); + case TIMELINE_DELETE: + return deleteStatus(state, action.id, action.references); + case STATUS_TRANSLATE_SUCCESS: + return statusTranslateSuccess(state, action.id, action.translation); + case STATUS_TRANSLATE_UNDO: + return statusTranslateUndo(state, action.id); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/suggestions.js b/app/javascript/flavours/blobfox/reducers/suggestions.js new file mode 100644 index 00000000000000..3ab190a9682b7f --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/suggestions.js @@ -0,0 +1,40 @@ +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; + +import { blockAccountSuccess, muteAccountSuccess } from 'flavours/blobfox/actions/accounts'; +import { blockDomainSuccess } from 'flavours/blobfox/actions/domain_blocks'; + +import { + SUGGESTIONS_FETCH_REQUEST, + SUGGESTIONS_FETCH_SUCCESS, + SUGGESTIONS_FETCH_FAIL, + SUGGESTIONS_DISMISS, +} from '../actions/suggestions'; + + +const initialState = ImmutableMap({ + items: ImmutableList(), + isLoading: false, +}); + +export default function suggestionsReducer(state = initialState, action) { + switch(action.type) { + case SUGGESTIONS_FETCH_REQUEST: + return state.set('isLoading', true); + case SUGGESTIONS_FETCH_SUCCESS: + return state.withMutations(map => { + map.set('items', fromJS(action.suggestions.map(x => ({ ...x, account: x.account.id })))); + map.set('isLoading', false); + }); + case SUGGESTIONS_FETCH_FAIL: + return state.set('isLoading', false); + case SUGGESTIONS_DISMISS: + return state.update('items', list => list.filterNot(x => x.account === action.id)); + case blockAccountSuccess.type: + case muteAccountSuccess.type: + return state.update('items', list => list.filterNot(x => x.account === action.payload.relationship.id)); + case blockDomainSuccess.type: + return state.update('items', list => list.filterNot(x => action.payload.accounts.includes(x.account))); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/tags.js b/app/javascript/flavours/blobfox/reducers/tags.js new file mode 100644 index 00000000000000..e56dd9fd8046bb --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/tags.js @@ -0,0 +1,26 @@ +import { Map as ImmutableMap, fromJS } from 'immutable'; + +import { + HASHTAG_FETCH_SUCCESS, + HASHTAG_FOLLOW_REQUEST, + HASHTAG_FOLLOW_FAIL, + HASHTAG_UNFOLLOW_REQUEST, + HASHTAG_UNFOLLOW_FAIL, +} from 'flavours/blobfox/actions/tags'; + +const initialState = ImmutableMap(); + +export default function tags(state = initialState, action) { + switch(action.type) { + case HASHTAG_FETCH_SUCCESS: + return state.set(action.name, fromJS(action.tag)); + case HASHTAG_FOLLOW_REQUEST: + case HASHTAG_UNFOLLOW_FAIL: + return state.setIn([action.name, 'following'], true); + case HASHTAG_FOLLOW_FAIL: + case HASHTAG_UNFOLLOW_REQUEST: + return state.setIn([action.name, 'following'], false); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/timelines.js b/app/javascript/flavours/blobfox/reducers/timelines.js new file mode 100644 index 00000000000000..6ff83aa7f0f662 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/timelines.js @@ -0,0 +1,233 @@ +import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; + +import { + blockAccountSuccess, + muteAccountSuccess, + unfollowAccountSuccess +} from '../actions/accounts'; +import { + TIMELINE_UPDATE, + TIMELINE_DELETE, + TIMELINE_CLEAR, + TIMELINE_EXPAND_SUCCESS, + TIMELINE_EXPAND_REQUEST, + TIMELINE_EXPAND_FAIL, + TIMELINE_SCROLL_TOP, + TIMELINE_CONNECT, + TIMELINE_DISCONNECT, + TIMELINE_LOAD_PENDING, + TIMELINE_MARK_AS_PARTIAL, +} from '../actions/timelines'; +import { compareId } from '../compare_id'; + +const initialState = ImmutableMap(); + +const initialTimeline = ImmutableMap({ + unread: 0, + online: false, + top: true, + isLoading: false, + hasMore: true, + pendingItems: ImmutableList(), + items: ImmutableList(), +}); + +const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent, usePendingItems) => { + // This method is pretty tricky because: + // - existing items in the timeline might be out of order + // - the existing timeline may have gaps, most often explicitly noted with a `null` item + // - ideally, we don't want it to reorder existing items of the timeline + // - `statuses` may include items that are already included in the timeline + // - this function can be called either to fill in a gap, or load newer items + + return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { + mMap.set('isLoading', false); + mMap.set('isPartial', isPartial); + + if (!next && !isLoadingRecent) mMap.set('hasMore', false); + + if (timeline.endsWith(':pinned')) { + mMap.set('items', statuses.map(status => status.get('id'))); + } else if (!statuses.isEmpty()) { + usePendingItems = isLoadingRecent && (usePendingItems || !mMap.get('pendingItems').isEmpty()); + + mMap.update(usePendingItems ? 'pendingItems' : 'items', ImmutableList(), oldIds => { + const newIds = statuses.map(status => status.get('id')); + + // Now this gets tricky, as we don't necessarily know for sure where the gap to fill is + // and some items in the timeline may not be properly ordered. + + // However, we know that `newIds.last()` is the oldest item that was requested and that + // there is no “hole” between `newIds.last()` and `newIds.first()`. + + // First, find the furthest (if properly sorted, oldest) item in the timeline that is + // newer than the oldest fetched one, as it's most likely that it delimits the gap. + // Start the gap *after* that item. + const lastIndex = oldIds.findLastIndex(id => id !== null && compareId(id, newIds.last()) >= 0) + 1; + + // Then, try to find the furthest (if properly sorted, oldest) item in the timeline that + // is newer than the most recent fetched one, as it delimits a section comprised of only + // items older or within `newIds` (or that were deleted from the server, so should be removed + // anyway). + // Stop the gap *after* that item. + const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && compareId(id, newIds.first()) > 0) + 1; + + let insertedIds = ImmutableOrderedSet(newIds).withMutations(insertedIds => { + // It is possible, though unlikely, that the slice we are replacing contains items older + // than the elements we got from the API. Get them and add them back at the back of the + // slice. + const olderIds = oldIds.slice(firstIndex, lastIndex).filter(id => id !== null && compareId(id, newIds.last()) < 0); + insertedIds.union(olderIds); + + // Make sure we aren't inserting duplicates + insertedIds.subtract(oldIds.take(firstIndex), oldIds.skip(lastIndex)); + }).toList(); + + // Finally, insert a gap marker if the data is marked as partial by the server + if (isPartial && (firstIndex === 0 || oldIds.get(firstIndex - 1) !== null)) { + insertedIds = insertedIds.unshift(null); + } + + return oldIds.take(firstIndex).concat( + insertedIds, + oldIds.skip(lastIndex), + ); + }); + } + })); +}; + +const updateTimeline = (state, timeline, status, usePendingItems, filtered) => { + const top = state.getIn([timeline, 'top']); + + if (usePendingItems || !state.getIn([timeline, 'pendingItems']).isEmpty()) { + if (state.getIn([timeline, 'pendingItems'], ImmutableList()).includes(status.get('id')) || state.getIn([timeline, 'items'], ImmutableList()).includes(status.get('id'))) { + return state; + } + + state = state.update(timeline, initialTimeline, map => map.update('pendingItems', list => list.unshift(status.get('id')))); + + if (!filtered) { + state = state.updateIn([timeline, 'unread'], unread => unread + 1); + } + + return state; + } + + const ids = state.getIn([timeline, 'items'], ImmutableList()); + const includesId = ids.includes(status.get('id')); + const unread = state.getIn([timeline, 'unread'], 0); + + if (includesId) { + return state; + } + + let newIds = ids; + + return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { + if (!top && !filtered) mMap.set('unread', unread + 1); + if (top && ids.size > 40) newIds = newIds.take(20); + mMap.set('items', newIds.unshift(status.get('id'))); + })); +}; + +const deleteStatus = (state, id, references, exclude_account = null) => { + state.keySeq().forEach(timeline => { + if (exclude_account === null || (timeline !== `account:${exclude_account}` && !timeline.startsWith(`account:${exclude_account}:`))) { + const helper = list => list.filterNot(item => item === id); + state = state.updateIn([timeline, 'items'], helper).updateIn([timeline, 'pendingItems'], helper); + } + }); + + // Remove reblogs of deleted status + references.forEach(ref => { + state = deleteStatus(state, ref, [], exclude_account); + }); + + return state; +}; + +const clearTimeline = (state, timeline) => { + return state.set(timeline, initialTimeline); +}; + +const filterTimelines = (state, relationship, statuses) => { + let references; + + statuses.forEach(status => { + if (status.get('account') !== relationship.id) { + return; + } + + references = statuses.filter(item => item.get('reblog') === status.get('id')).map(item => item.get('id')); + state = deleteStatus(state, status.get('id'), references, relationship.id); + }); + + return state; +}; + +const filterTimeline = (timeline, state, relationship, statuses) => { + const helper = list => list.filterNot(statusId => statuses.getIn([statusId, 'account']) === relationship.id); + return state.updateIn([timeline, 'items'], ImmutableList(), helper).updateIn([timeline, 'pendingItems'], ImmutableList(), helper); +}; + +const updateTop = (state, timeline, top) => { + return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { + if (top) mMap.set('unread', mMap.get('pendingItems').size); + mMap.set('top', top); + })); +}; + +const reconnectTimeline = (state, usePendingItems) => { + if (state.get('online')) { + return state; + } + + return state.withMutations(mMap => { + mMap.update(usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items); + mMap.set('online', true); + }); +}; + +export default function timelines(state = initialState, action) { + switch(action.type) { + case TIMELINE_LOAD_PENDING: + return state.update(action.timeline, initialTimeline, map => + map.update('items', list => map.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0)); + case TIMELINE_EXPAND_REQUEST: + return state.update(action.timeline, initialTimeline, map => map.set('isLoading', true)); + case TIMELINE_EXPAND_FAIL: + return state.update(action.timeline, initialTimeline, map => map.set('isLoading', false)); + case TIMELINE_EXPAND_SUCCESS: + return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial, action.isLoadingRecent, action.usePendingItems); + case TIMELINE_UPDATE: + return updateTimeline(state, action.timeline, fromJS(action.status), action.usePendingItems, action.filtered); + case TIMELINE_DELETE: + return deleteStatus(state, action.id, action.references, action.reblogOf); + case TIMELINE_CLEAR: + return clearTimeline(state, action.timeline); + case blockAccountSuccess.type: + case muteAccountSuccess.type: + return filterTimelines(state, action.payload.relationship, action.payload.statuses); + case unfollowAccountSuccess.type: + return filterTimeline('home', state, action.payload.relationship, action.payload.statuses); + case TIMELINE_SCROLL_TOP: + return updateTop(state, action.timeline, action.top); + case TIMELINE_CONNECT: + return state.update(action.timeline, initialTimeline, map => reconnectTimeline(map, action.usePendingItems)); + case TIMELINE_DISCONNECT: + return state.update( + action.timeline, + initialTimeline, + map => map.set('online', false).update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items), + ); + case TIMELINE_MARK_AS_PARTIAL: + return state.update( + action.timeline, + initialTimeline, + map => map.set('isPartial', true).set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('unread', 0), + ); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/trends.js b/app/javascript/flavours/blobfox/reducers/trends.js new file mode 100644 index 00000000000000..8f6733ceb310c6 --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/trends.js @@ -0,0 +1,47 @@ +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; + +import { + TRENDS_TAGS_FETCH_REQUEST, + TRENDS_TAGS_FETCH_SUCCESS, + TRENDS_TAGS_FETCH_FAIL, + TRENDS_LINKS_FETCH_REQUEST, + TRENDS_LINKS_FETCH_SUCCESS, + TRENDS_LINKS_FETCH_FAIL, +} from 'flavours/blobfox/actions/trends'; + +const initialState = ImmutableMap({ + tags: ImmutableMap({ + items: ImmutableList(), + isLoading: false, + }), + + links: ImmutableMap({ + items: ImmutableList(), + isLoading: false, + }), +}); + +export default function trendsReducer(state = initialState, action) { + switch(action.type) { + case TRENDS_TAGS_FETCH_REQUEST: + return state.setIn(['tags', 'isLoading'], true); + case TRENDS_TAGS_FETCH_SUCCESS: + return state.withMutations(map => { + map.setIn(['tags', 'items'], fromJS(action.trends)); + map.setIn(['tags', 'isLoading'], false); + }); + case TRENDS_TAGS_FETCH_FAIL: + return state.setIn(['tags', 'isLoading'], false); + case TRENDS_LINKS_FETCH_REQUEST: + return state.setIn(['links', 'isLoading'], true); + case TRENDS_LINKS_FETCH_SUCCESS: + return state.withMutations(map => { + map.setIn(['links', 'items'], fromJS(action.trends)); + map.setIn(['links', 'isLoading'], false); + }); + case TRENDS_LINKS_FETCH_FAIL: + return state.setIn(['links', 'isLoading'], false); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/reducers/user_lists.js b/app/javascript/flavours/blobfox/reducers/user_lists.js new file mode 100644 index 00000000000000..a2bd2e8a17373f --- /dev/null +++ b/app/javascript/flavours/blobfox/reducers/user_lists.js @@ -0,0 +1,216 @@ +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; + +import { + DIRECTORY_FETCH_REQUEST, + DIRECTORY_FETCH_SUCCESS, + DIRECTORY_FETCH_FAIL, + DIRECTORY_EXPAND_REQUEST, + DIRECTORY_EXPAND_SUCCESS, + DIRECTORY_EXPAND_FAIL, +} from 'flavours/blobfox/actions/directory'; +import { + FEATURED_TAGS_FETCH_REQUEST, + FEATURED_TAGS_FETCH_SUCCESS, + FEATURED_TAGS_FETCH_FAIL, +} from 'flavours/blobfox/actions/featured_tags'; + +import { + FOLLOWERS_FETCH_REQUEST, + FOLLOWERS_FETCH_SUCCESS, + FOLLOWERS_FETCH_FAIL, + FOLLOWERS_EXPAND_REQUEST, + FOLLOWERS_EXPAND_SUCCESS, + FOLLOWERS_EXPAND_FAIL, + FOLLOWING_FETCH_REQUEST, + FOLLOWING_FETCH_SUCCESS, + FOLLOWING_FETCH_FAIL, + FOLLOWING_EXPAND_REQUEST, + FOLLOWING_EXPAND_SUCCESS, + FOLLOWING_EXPAND_FAIL, + FOLLOW_REQUESTS_FETCH_REQUEST, + FOLLOW_REQUESTS_FETCH_SUCCESS, + FOLLOW_REQUESTS_FETCH_FAIL, + FOLLOW_REQUESTS_EXPAND_REQUEST, + FOLLOW_REQUESTS_EXPAND_SUCCESS, + FOLLOW_REQUESTS_EXPAND_FAIL, + authorizeFollowRequestSuccess, + rejectFollowRequestSuccess, +} from '../actions/accounts'; +import { + BLOCKS_FETCH_REQUEST, + BLOCKS_FETCH_SUCCESS, + BLOCKS_FETCH_FAIL, + BLOCKS_EXPAND_REQUEST, + BLOCKS_EXPAND_SUCCESS, + BLOCKS_EXPAND_FAIL, +} from '../actions/blocks'; +import { + REBLOGS_FETCH_REQUEST, + REBLOGS_FETCH_SUCCESS, + REBLOGS_FETCH_FAIL, + REBLOGS_EXPAND_REQUEST, + REBLOGS_EXPAND_SUCCESS, + REBLOGS_EXPAND_FAIL, + FAVOURITES_FETCH_REQUEST, + FAVOURITES_FETCH_SUCCESS, + FAVOURITES_FETCH_FAIL, + FAVOURITES_EXPAND_REQUEST, + FAVOURITES_EXPAND_SUCCESS, + FAVOURITES_EXPAND_FAIL, +} from '../actions/interactions'; +import { + MUTES_FETCH_REQUEST, + MUTES_FETCH_SUCCESS, + MUTES_FETCH_FAIL, + MUTES_EXPAND_REQUEST, + MUTES_EXPAND_SUCCESS, + MUTES_EXPAND_FAIL, +} from '../actions/mutes'; +import { notificationsUpdate } from '../actions/notifications'; + +const initialListState = ImmutableMap({ + next: null, + isLoading: false, + items: ImmutableList(), +}); + +const initialState = ImmutableMap({ + followers: initialListState, + following: initialListState, + reblogged_by: initialListState, + favourited_by: initialListState, + follow_requests: initialListState, + blocks: initialListState, + mutes: initialListState, + featured_tags: initialListState, +}); + +const normalizeList = (state, path, accounts, next) => { + return state.setIn(path, ImmutableMap({ + next, + items: ImmutableList(accounts.map(item => item.id)), + isLoading: false, + })); +}; + +const appendToList = (state, path, accounts, next) => { + return state.updateIn(path, map => { + return map.set('next', next).set('isLoading', false).update('items', list => list.concat(accounts.map(item => item.id))); + }); +}; + +const normalizeFollowRequest = (state, notification) => { + return state.updateIn(['follow_requests', 'items'], list => { + return list.filterNot(item => item === notification.account.id).unshift(notification.account.id); + }); +}; + +const normalizeFeaturedTag = (featuredTags, accountId) => { + const normalizeFeaturedTag = { ...featuredTags, accountId: accountId }; + return fromJS(normalizeFeaturedTag); +}; + +const normalizeFeaturedTags = (state, path, featuredTags, accountId) => { + return state.setIn(path, ImmutableMap({ + items: ImmutableList(featuredTags.map(featuredTag => normalizeFeaturedTag(featuredTag, accountId)).sort((a, b) => b.get('statuses_count') - a.get('statuses_count'))), + isLoading: false, + })); +}; + +export default function userLists(state = initialState, action) { + switch(action.type) { + case FOLLOWERS_FETCH_SUCCESS: + return normalizeList(state, ['followers', action.id], action.accounts, action.next); + case FOLLOWERS_EXPAND_SUCCESS: + return appendToList(state, ['followers', action.id], action.accounts, action.next); + case FOLLOWERS_FETCH_REQUEST: + case FOLLOWERS_EXPAND_REQUEST: + return state.setIn(['followers', action.id, 'isLoading'], true); + case FOLLOWERS_FETCH_FAIL: + case FOLLOWERS_EXPAND_FAIL: + return state.setIn(['followers', action.id, 'isLoading'], false); + case FOLLOWING_FETCH_SUCCESS: + return normalizeList(state, ['following', action.id], action.accounts, action.next); + case FOLLOWING_EXPAND_SUCCESS: + return appendToList(state, ['following', action.id], action.accounts, action.next); + case FOLLOWING_FETCH_REQUEST: + case FOLLOWING_EXPAND_REQUEST: + return state.setIn(['following', action.id, 'isLoading'], true); + case FOLLOWING_FETCH_FAIL: + case FOLLOWING_EXPAND_FAIL: + return state.setIn(['following', action.id, 'isLoading'], false); + case REBLOGS_FETCH_SUCCESS: + return normalizeList(state, ['reblogged_by', action.id], action.accounts, action.next); + case REBLOGS_EXPAND_SUCCESS: + return appendToList(state, ['reblogged_by', action.id], action.accounts, action.next); + case REBLOGS_FETCH_REQUEST: + case REBLOGS_EXPAND_REQUEST: + return state.setIn(['reblogged_by', action.id, 'isLoading'], true); + case REBLOGS_FETCH_FAIL: + case REBLOGS_EXPAND_FAIL: + return state.setIn(['reblogged_by', action.id, 'isLoading'], false); + case FAVOURITES_FETCH_SUCCESS: + return normalizeList(state, ['favourited_by', action.id], action.accounts, action.next); + case FAVOURITES_EXPAND_SUCCESS: + return appendToList(state, ['favourited_by', action.id], action.accounts, action.next); + case FAVOURITES_FETCH_REQUEST: + case FAVOURITES_EXPAND_REQUEST: + return state.setIn(['favourited_by', action.id, 'isLoading'], true); + case FAVOURITES_FETCH_FAIL: + case FAVOURITES_EXPAND_FAIL: + return state.setIn(['favourited_by', action.id, 'isLoading'], false); + case notificationsUpdate.type: + return action.payload.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.payload.notification) : state; + case FOLLOW_REQUESTS_FETCH_SUCCESS: + return normalizeList(state, ['follow_requests'], action.accounts, action.next); + case FOLLOW_REQUESTS_EXPAND_SUCCESS: + return appendToList(state, ['follow_requests'], action.accounts, action.next); + case FOLLOW_REQUESTS_FETCH_REQUEST: + case FOLLOW_REQUESTS_EXPAND_REQUEST: + return state.setIn(['follow_requests', 'isLoading'], true); + case FOLLOW_REQUESTS_FETCH_FAIL: + case FOLLOW_REQUESTS_EXPAND_FAIL: + return state.setIn(['follow_requests', 'isLoading'], false); + case authorizeFollowRequestSuccess.type: + case rejectFollowRequestSuccess.type: + return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.payload.id)); + case BLOCKS_FETCH_SUCCESS: + return normalizeList(state, ['blocks'], action.accounts, action.next); + case BLOCKS_EXPAND_SUCCESS: + return appendToList(state, ['blocks'], action.accounts, action.next); + case BLOCKS_FETCH_REQUEST: + case BLOCKS_EXPAND_REQUEST: + return state.setIn(['blocks', 'isLoading'], true); + case BLOCKS_FETCH_FAIL: + case BLOCKS_EXPAND_FAIL: + return state.setIn(['blocks', 'isLoading'], false); + case MUTES_FETCH_SUCCESS: + return normalizeList(state, ['mutes'], action.accounts, action.next); + case MUTES_EXPAND_SUCCESS: + return appendToList(state, ['mutes'], action.accounts, action.next); + case MUTES_FETCH_REQUEST: + case MUTES_EXPAND_REQUEST: + return state.setIn(['mutes', 'isLoading'], true); + case MUTES_FETCH_FAIL: + case MUTES_EXPAND_FAIL: + return state.setIn(['mutes', 'isLoading'], false); + case DIRECTORY_FETCH_SUCCESS: + return normalizeList(state, ['directory'], action.accounts, action.next); + case DIRECTORY_EXPAND_SUCCESS: + return appendToList(state, ['directory'], action.accounts, action.next); + case DIRECTORY_FETCH_REQUEST: + case DIRECTORY_EXPAND_REQUEST: + return state.setIn(['directory', 'isLoading'], true); + case DIRECTORY_FETCH_FAIL: + case DIRECTORY_EXPAND_FAIL: + return state.setIn(['directory', 'isLoading'], false); + case FEATURED_TAGS_FETCH_SUCCESS: + return normalizeFeaturedTags(state, ['featured_tags', action.id], action.tags, action.id); + case FEATURED_TAGS_FETCH_REQUEST: + return state.setIn(['featured_tags', action.id, 'isLoading'], true); + case FEATURED_TAGS_FETCH_FAIL: + return state.setIn(['featured_tags', action.id, 'isLoading'], false); + default: + return state; + } +} diff --git a/app/javascript/flavours/blobfox/scroll.ts b/app/javascript/flavours/blobfox/scroll.ts new file mode 100644 index 00000000000000..35e13a4527d1d3 --- /dev/null +++ b/app/javascript/flavours/blobfox/scroll.ts @@ -0,0 +1,50 @@ +const easingOutQuint = ( + x: number, + t: number, + b: number, + c: number, + d: number, +) => c * ((t = t / d - 1) * t * t * t * t + 1) + b; +const scroll = ( + node: Element, + key: 'scrollTop' | 'scrollLeft', + target: number, +) => { + const startTime = Date.now(); + const offset = node[key]; + const gap = target - offset; + const duration = 1000; + let interrupt = false; + + const step = () => { + const elapsed = Date.now() - startTime; + const percentage = elapsed / duration; + + if (percentage > 1 || interrupt) { + return; + } + + node[key] = easingOutQuint(0, elapsed, offset, gap, duration); + requestAnimationFrame(step); + }; + + step(); + + return () => { + interrupt = true; + }; +}; + +const isScrollBehaviorSupported = + 'scrollBehavior' in document.documentElement.style; + +export const scrollRight = (node: Element, position: number) => { + if (isScrollBehaviorSupported) + node.scrollTo({ left: position, behavior: 'smooth' }); + else scroll(node, 'scrollLeft', position); +}; + +export const scrollTop = (node: Element) => { + if (isScrollBehaviorSupported) node.scrollTo({ top: 0, behavior: 'smooth' }); + else scroll(node, 'scrollTop', 0); +}; diff --git a/app/javascript/flavours/blobfox/selectors/accounts.ts b/app/javascript/flavours/blobfox/selectors/accounts.ts new file mode 100644 index 00000000000000..af2319405df219 --- /dev/null +++ b/app/javascript/flavours/blobfox/selectors/accounts.ts @@ -0,0 +1,47 @@ +import { Record as ImmutableRecord } from 'immutable'; +import { createSelector } from 'reselect'; + +import { accountDefaultValues } from 'flavours/blobfox/models/account'; +import type { Account, AccountShape } from 'flavours/blobfox/models/account'; +import type { Relationship } from 'flavours/blobfox/models/relationship'; +import type { RootState } from 'flavours/blobfox/store'; + +const getAccountBase = (state: RootState, id: string) => + state.accounts.get(id, null); + +const getAccountRelationship = (state: RootState, id: string) => + state.relationships.get(id, null); + +const getAccountMoved = (state: RootState, id: string) => { + const movedToId = state.accounts.get(id)?.moved; + + if (!movedToId) return undefined; + + return state.accounts.get(movedToId); +}; + +interface FullAccountShape extends Omit<AccountShape, 'moved'> { + relationship: Relationship | null; + moved: Account | null; +} + +const FullAccountFactory = ImmutableRecord<FullAccountShape>({ + ...accountDefaultValues, + moved: null, + relationship: null, +}); + +export function makeGetAccount() { + return createSelector( + [getAccountBase, getAccountRelationship, getAccountMoved], + (base, relationship, moved) => { + if (base === null) { + return null; + } + + return FullAccountFactory(base) + .set('relationship', relationship) + .set('moved', moved ?? null); + }, + ); +} diff --git a/app/javascript/flavours/blobfox/selectors/index.js b/app/javascript/flavours/blobfox/selectors/index.js new file mode 100644 index 00000000000000..ceb940031940b0 --- /dev/null +++ b/app/javascript/flavours/blobfox/selectors/index.js @@ -0,0 +1,118 @@ +import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; +import { createSelector } from 'reselect'; + +import { toServerSideType } from 'flavours/blobfox/utils/filters'; + +import { me } from '../initial_state'; + +export { makeGetAccount } from "./accounts"; + +const getFilters = (state, { contextType }) => { + if (!contextType) return null; + + const serverSideType = toServerSideType(contextType); + const now = new Date(); + + return state.get('filters').filter((filter) => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || filter.get('expires_at') > now)); +}; + +export const makeGetStatus = () => { + return createSelector( + [ + (state, { id }) => state.getIn(['statuses', id]), + (state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]), + (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]), + (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]), + getFilters, + ], + + (statusBase, statusReblog, accountBase, accountReblog, filters) => { + if (!statusBase || statusBase.get('isLoading')) { + return null; + } + + let filtered = false; + if ((accountReblog || accountBase).get('id') !== me && filters) { + let filterResults = statusReblog?.get('filtered') || statusBase.get('filtered') || ImmutableList(); + if (filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) { + return null; + } + filterResults = filterResults.filter(result => filters.has(result.get('filter'))); + if (!filterResults.isEmpty()) { + filtered = filterResults.map(result => filters.getIn([result.get('filter'), 'title'])); + } + } + + if (statusReblog) { + statusReblog = statusReblog.set('account', accountReblog); + statusReblog = statusReblog.set('matched_filters', filtered); + } else { + statusReblog = null; + } + + return statusBase.withMutations(map => { + map.set('reblog', statusReblog); + map.set('account', accountBase); + map.set('matched_filters', filtered); + }); + }, + ); +}; + +export const makeGetPictureInPicture = () => { + return createSelector([ + (state, { id }) => state.get('picture_in_picture').statusId === id, + (state) => state.getIn(['meta', 'layout']) !== 'mobile', + ], (inUse, available) => ImmutableMap({ + inUse: inUse && available, + available, + })); +}; + +const ALERT_DEFAULTS = { + dismissAfter: 5000, + style: false, +}; + +export const getAlerts = createSelector(state => state.get('alerts'), alerts => + alerts.map(item => ({ + ...ALERT_DEFAULTS, + ...item, + })).toArray()); + +export const makeGetNotification = () => createSelector([ + (_, base) => base, + (state, _, accountId) => state.getIn(['accounts', accountId]), +], (base, account) => base.set('account', account)); + +export const makeGetReport = () => createSelector([ + (_, base) => base, + (state, _, targetAccountId) => state.getIn(['accounts', targetAccountId]), +], (base, targetAccount) => base.set('target_account', targetAccount)); + +export const getAccountGallery = createSelector([ + (state, id) => state.getIn(['timelines', `account:${id}:media`, 'items'], ImmutableList()), + state => state.get('statuses'), + (state, id) => state.getIn(['accounts', id]), +], (statusIds, statuses, account) => { + let medias = ImmutableList(); + + statusIds.forEach(statusId => { + const status = statuses.get(statusId); + medias = medias.concat(status.get('media_attachments').map(media => media.set('status', status).set('account', account))); + }); + + return medias; +}); + +export const getAccountHidden = createSelector([ + (state, id) => state.getIn(['accounts', id, 'hidden']), + (state, id) => state.getIn(['relationships', id, 'following']) || state.getIn(['relationships', id, 'requested']), + (state, id) => id === me, +], (hidden, followingOrRequested, isSelf) => { + return hidden && !(isSelf || followingOrRequested); +}); + +export const getStatusList = createSelector([ + (state, type) => state.getIn(['status_lists', type, 'items']), +], (items) => items.toList()); diff --git a/app/javascript/flavours/blobfox/settings.js b/app/javascript/flavours/blobfox/settings.js new file mode 100644 index 00000000000000..f31aee0afc0725 --- /dev/null +++ b/app/javascript/flavours/blobfox/settings.js @@ -0,0 +1,50 @@ +export default class Settings { + + constructor(keyBase = null) { + this.keyBase = keyBase; + } + + generateKey(id) { + return this.keyBase ? [this.keyBase, `id${id}`].join('.') : id; + } + + set(id, data) { + const key = this.generateKey(id); + try { + const encodedData = JSON.stringify(data); + localStorage.setItem(key, encodedData); + return data; + } catch (e) { + return null; + } + } + + get(id) { + const key = this.generateKey(id); + try { + const rawData = localStorage.getItem(key); + return JSON.parse(rawData); + } catch (e) { + return null; + } + } + + remove(id) { + const data = this.get(id); + if (data) { + const key = this.generateKey(id); + try { + localStorage.removeItem(key); + } catch (e) { + } + } + return data; + } + +} + +export const pushNotificationsSetting = new Settings('mastodon_push_notification_data'); +export const tagHistory = new Settings('mastodon_tag_history'); +export const bannerSettings = new Settings('mastodon_banner_settings'); +export const searchHistory = new Settings('mastodon_search_history'); +export const playerSettings = new Settings('mastodon_player'); diff --git a/app/javascript/flavours/blobfox/store/index.ts b/app/javascript/flavours/blobfox/store/index.ts new file mode 100644 index 00000000000000..c2629b0ed74050 --- /dev/null +++ b/app/javascript/flavours/blobfox/store/index.ts @@ -0,0 +1,8 @@ +export { store } from './store'; +export type { GetState, AppDispatch, RootState } from './store'; + +export { + createAppAsyncThunk, + useAppDispatch, + useAppSelector, +} from './typed_functions'; diff --git a/app/javascript/flavours/blobfox/store/middlewares/errors.ts b/app/javascript/flavours/blobfox/store/middlewares/errors.ts new file mode 100644 index 00000000000000..9f28f5ff53f92b --- /dev/null +++ b/app/javascript/flavours/blobfox/store/middlewares/errors.ts @@ -0,0 +1,21 @@ +import type { AnyAction, Middleware } from 'redux'; + +import type { RootState } from '..'; +import { showAlertForError } from '../../actions/alerts'; + +const defaultFailSuffix = 'FAIL'; + +export const errorsMiddleware: Middleware<unknown, RootState> = + ({ dispatch }) => + (next) => + (action: AnyAction & { skipAlert?: boolean; skipNotFound?: boolean }) => { + if (action.type && !action.skipAlert) { + const isFail = new RegExp(`${defaultFailSuffix}$`, 'g'); + + if (typeof action.type === 'string' && action.type.match(isFail)) { + dispatch(showAlertForError(action.error, action.skipNotFound)); + } + } + + return next(action); + }; diff --git a/app/javascript/flavours/blobfox/store/middlewares/loading_bar.ts b/app/javascript/flavours/blobfox/store/middlewares/loading_bar.ts new file mode 100644 index 00000000000000..83056ee49f44a9 --- /dev/null +++ b/app/javascript/flavours/blobfox/store/middlewares/loading_bar.ts @@ -0,0 +1,69 @@ +import { + isAsyncThunkAction, + isPending as isThunkActionPending, + isFulfilled as isThunkActionFulfilled, + isRejected as isThunkActionRejected, +} from '@reduxjs/toolkit'; +import { showLoading, hideLoading } from 'react-redux-loading-bar'; +import type { AnyAction, Middleware } from 'redux'; + +import type { RootState } from '..'; + +interface Config { + promiseTypeSuffixes?: string[]; +} + +const defaultTypeSuffixes: Config['promiseTypeSuffixes'] = [ + 'PENDING', + 'FULFILLED', + 'REJECTED', +]; + +export const loadingBarMiddleware = ( + config: Config = {}, +): Middleware<unknown, RootState> => { + const promiseTypeSuffixes = config.promiseTypeSuffixes ?? defaultTypeSuffixes; + + return ({ dispatch }) => + (next) => + (action: AnyAction) => { + let isPending = false; + let isFulfilled = false; + let isRejected = false; + + if ( + isAsyncThunkAction(action) + // TODO: once we get the first use-case for it, add a check for skipLoading + ) { + if (isThunkActionPending(action)) isPending = true; + else if (isThunkActionFulfilled(action)) isFulfilled = true; + else if (isThunkActionRejected(action)) isRejected = true; + } else if ( + action.type && + !action.skipLoading && + typeof action.type === 'string' + ) { + const [PENDING, FULFILLED, REJECTED] = promiseTypeSuffixes; + + const isPendingRegexp = new RegExp(`${PENDING}$`, 'g'); + const isFulfilledRegexp = new RegExp(`${FULFILLED}$`, 'g'); + const isRejectedRegexp = new RegExp(`${REJECTED}$`, 'g'); + + if (action.type.match(isPendingRegexp)) { + isPending = true; + } else if (action.type.match(isFulfilledRegexp)) { + isFulfilled = true; + } else if (action.type.match(isRejectedRegexp)) { + isRejected = true; + } + } + + if (isPending) { + dispatch(showLoading()); + } else if (isFulfilled || isRejected) { + dispatch(hideLoading()); + } + + return next(action); + }; +}; diff --git a/app/javascript/flavours/blobfox/store/middlewares/sounds.ts b/app/javascript/flavours/blobfox/store/middlewares/sounds.ts new file mode 100644 index 00000000000000..3b0556c9c29db4 --- /dev/null +++ b/app/javascript/flavours/blobfox/store/middlewares/sounds.ts @@ -0,0 +1,64 @@ +import type { Middleware, AnyAction } from 'redux'; + +import ready from 'flavours/blobfox/ready'; +import { assetHost } from 'flavours/blobfox/utils/config'; + +import type { RootState } from '..'; + +interface AudioSource { + src: string; + type: string; +} + +const createAudio = (sources: AudioSource[]) => { + const audio = new Audio(); + sources.forEach(({ type, src }) => { + const source = document.createElement('source'); + source.type = type; + source.src = src; + audio.appendChild(source); + }); + return audio; +}; + +const play = (audio: HTMLAudioElement) => { + if (!audio.paused) { + audio.pause(); + if (typeof audio.fastSeek === 'function') { + audio.fastSeek(0); + } else { + audio.currentTime = 0; + } + } + + void audio.play(); +}; + +export const soundsMiddleware = (): Middleware<unknown, RootState> => { + const soundCache: Record<string, HTMLAudioElement> = {}; + + void ready(() => { + soundCache.boop = createAudio([ + { + src: `${assetHost}/sounds/boop.ogg`, + type: 'audio/ogg', + }, + { + src: `${assetHost}/sounds/boop.mp3`, + type: 'audio/mpeg', + }, + ]); + }); + + return () => + (next) => + (action: AnyAction & { meta?: { sound?: string } }) => { + const sound = action.meta?.sound; + + if (sound && Object.hasOwn(soundCache, sound)) { + play(soundCache[sound]); + } + + return next(action); + }; +}; diff --git a/app/javascript/flavours/blobfox/store/store.ts b/app/javascript/flavours/blobfox/store/store.ts new file mode 100644 index 00000000000000..9f43f58a43dfa4 --- /dev/null +++ b/app/javascript/flavours/blobfox/store/store.ts @@ -0,0 +1,39 @@ +import { configureStore } from '@reduxjs/toolkit'; + +import { rootReducer } from '../reducers'; + +import { errorsMiddleware } from './middlewares/errors'; +import { loadingBarMiddleware } from './middlewares/loading_bar'; +import { soundsMiddleware } from './middlewares/sounds'; + +export const store = configureStore({ + reducer: rootReducer, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + // In development, Redux Toolkit enables 2 default middlewares to detect + // common issues with states. Unfortunately, our use of ImmutableJS for state + // triggers both, so lets disable them until our state is fully refactored + + // https://redux-toolkit.js.org/api/serializabilityMiddleware + // This checks recursively that every values in the state are serializable in JSON + // Which is not the case, as we use ImmutableJS structures, but also File objects + serializableCheck: false, + + // https://redux-toolkit.js.org/api/immutabilityMiddleware + // This checks recursively if every value in the state is immutable (ie, a JS primitive type) + // But this is not the case, as our Root State is an ImmutableJS map, which is an object + immutableCheck: false, + }) + .concat( + loadingBarMiddleware({ + promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'], + }), + ) + .concat(errorsMiddleware) + .concat(soundsMiddleware()), +}); + +// Infer the `RootState` and `AppDispatch` types from the store itself +export type RootState = ReturnType<typeof rootReducer>; +export type AppDispatch = typeof store.dispatch; +export type GetState = typeof store.getState; diff --git a/app/javascript/flavours/blobfox/store/typed_functions.ts b/app/javascript/flavours/blobfox/store/typed_functions.ts new file mode 100644 index 00000000000000..f1e71385a88ff5 --- /dev/null +++ b/app/javascript/flavours/blobfox/store/typed_functions.ts @@ -0,0 +1,15 @@ +import type { TypedUseSelectorHook } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; + +import { createAsyncThunk } from '@reduxjs/toolkit'; + +import type { AppDispatch, RootState } from './store'; + +export const useAppDispatch: () => AppDispatch = useDispatch; +export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector; + +export const createAppAsyncThunk = createAsyncThunk.withTypes<{ + state: RootState; + dispatch: AppDispatch; + rejectValue: string; +}>(); diff --git a/app/javascript/flavours/blobfox/stream.js b/app/javascript/flavours/blobfox/stream.js new file mode 100644 index 00000000000000..ff3af5fd885c96 --- /dev/null +++ b/app/javascript/flavours/blobfox/stream.js @@ -0,0 +1,275 @@ +// @ts-check + +import WebSocketClient from '@gamestdio/websocket'; + +/** + * @type {WebSocketClient | undefined} + */ +let sharedConnection; + +/** + * @typedef Subscription + * @property {string} channelName + * @property {Object.<string, string>} params + * @property {function(): void} onConnect + * @property {function(StreamEvent): void} onReceive + * @property {function(): void} onDisconnect + */ + +/** + * @typedef StreamEvent + * @property {string} event + * @property {object} payload + */ + +/** + * @type {Array.<Subscription>} + */ +const subscriptions = []; + +/** + * @type {Object.<string, number>} + */ +const subscriptionCounters = {}; + +/** + * @param {Subscription} subscription + */ +const addSubscription = subscription => { + subscriptions.push(subscription); +}; + +/** + * @param {Subscription} subscription + */ +const removeSubscription = subscription => { + const index = subscriptions.indexOf(subscription); + + if (index !== -1) { + subscriptions.splice(index, 1); + } +}; + +/** + * @param {Subscription} subscription + */ +const subscribe = ({ channelName, params, onConnect }) => { + const key = channelNameWithInlineParams(channelName, params); + + subscriptionCounters[key] = subscriptionCounters[key] || 0; + + if (subscriptionCounters[key] === 0) { + // @ts-expect-error + sharedConnection.send(JSON.stringify({ type: 'subscribe', stream: channelName, ...params })); + } + + subscriptionCounters[key] += 1; + onConnect(); +}; + +/** + * @param {Subscription} subscription + */ +const unsubscribe = ({ channelName, params, onDisconnect }) => { + const key = channelNameWithInlineParams(channelName, params); + + subscriptionCounters[key] = subscriptionCounters[key] || 1; + + // @ts-expect-error + if (subscriptionCounters[key] === 1 && sharedConnection.readyState === WebSocketClient.OPEN) { + // @ts-expect-error + sharedConnection.send(JSON.stringify({ type: 'unsubscribe', stream: channelName, ...params })); + } + + subscriptionCounters[key] -= 1; + onDisconnect(); +}; + +const sharedCallbacks = { + connected() { + subscriptions.forEach(subscription => subscribe(subscription)); + }, + + // @ts-expect-error + received(data) { + const { stream } = data; + + subscriptions.filter(({ channelName, params }) => { + const streamChannelName = stream[0]; + + if (stream.length === 1) { + return channelName === streamChannelName; + } + + const streamIdentifier = stream[1]; + + if (['hashtag', 'hashtag:local'].includes(channelName)) { + return channelName === streamChannelName && params.tag === streamIdentifier; + } else if (channelName === 'list') { + return channelName === streamChannelName && params.list === streamIdentifier; + } + + return false; + }).forEach(subscription => { + subscription.onReceive(data); + }); + }, + + disconnected() { + subscriptions.forEach(subscription => unsubscribe(subscription)); + }, + + reconnected() { + }, +}; + +/** + * @param {string} channelName + * @param {Object.<string, string>} params + * @returns {string} + */ +const channelNameWithInlineParams = (channelName, params) => { + if (Object.keys(params).length === 0) { + return channelName; + } + + return `${channelName}&${Object.keys(params).map(key => `${key}=${params[key]}`).join('&')}`; +}; + +/** + * @param {string} channelName + * @param {Object.<string, string>} params + * @param {function(Function, Function): { onConnect: (function(): void), onReceive: (function(StreamEvent): void), onDisconnect: (function(): void) }} callbacks + * @returns {function(): void} + */ +// @ts-expect-error +export const connectStream = (channelName, params, callbacks) => (dispatch, getState) => { + const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']); + const accessToken = getState().getIn(['meta', 'access_token']); + const { onConnect, onReceive, onDisconnect } = callbacks(dispatch, getState); + + // If we cannot use a websockets connection, we must fall back + // to using individual connections for each channel + if (!streamingAPIBaseURL.startsWith('ws')) { + const connection = createConnection(streamingAPIBaseURL, accessToken, channelNameWithInlineParams(channelName, params), { + connected() { + onConnect(); + }, + + received(data) { + onReceive(data); + }, + + disconnected() { + onDisconnect(); + }, + + reconnected() { + onConnect(); + }, + }); + + return () => { + connection.close(); + }; + } + + const subscription = { + channelName, + params, + onConnect, + onReceive, + onDisconnect, + }; + + addSubscription(subscription); + + // If a connection is open, we can execute the subscription right now. Otherwise, + // because we have already registered it, it will be executed on connect + + if (!sharedConnection) { + sharedConnection = /** @type {WebSocketClient} */ (createConnection(streamingAPIBaseURL, accessToken, '', sharedCallbacks)); + } else if (sharedConnection.readyState === WebSocketClient.OPEN) { + subscribe(subscription); + } + + return () => { + removeSubscription(subscription); + unsubscribe(subscription); + }; +}; + +const KNOWN_EVENT_TYPES = [ + 'update', + 'delete', + 'notification', + 'conversation', + 'filters_changed', + 'encrypted_message', + 'announcement', + 'announcement.delete', + 'announcement.reaction', +]; + +/** + * @param {MessageEvent} e + * @param {function(StreamEvent): void} received + */ +const handleEventSourceMessage = (e, received) => { + received({ + event: e.type, + payload: e.data, + }); +}; + +/** + * @param {string} streamingAPIBaseURL + * @param {string} accessToken + * @param {string} channelName + * @param {{ connected: Function, received: function(StreamEvent): void, disconnected: Function, reconnected: Function }} callbacks + * @returns {WebSocketClient | EventSource} + */ +const createConnection = (streamingAPIBaseURL, accessToken, channelName, { connected, received, disconnected, reconnected }) => { + const params = channelName.split('&'); + + // @ts-expect-error + channelName = params.shift(); + + if (streamingAPIBaseURL.startsWith('ws')) { + // @ts-expect-error + const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`, accessToken); + + // @ts-expect-error + ws.onopen = connected; + ws.onmessage = e => received(JSON.parse(e.data)); + // @ts-expect-error + ws.onclose = disconnected; + // @ts-expect-error + ws.onreconnect = reconnected; + + return ws; + } + + channelName = channelName.replace(/:/g, '/'); + + if (channelName.endsWith(':media')) { + channelName = channelName.replace('/media', ''); + params.push('only_media=true'); + } + + params.push(`access_token=${accessToken}`); + + const es = new EventSource(`${streamingAPIBaseURL}/api/v1/streaming/${channelName}?${params.join('&')}`); + + es.onopen = () => { + connected(); + }; + + KNOWN_EVENT_TYPES.forEach(type => { + es.addEventListener(type, e => handleEventSourceMessage(/** @type {MessageEvent} */(e), received)); + }); + + es.onerror = /** @type {function(): void} */ (disconnected); + + return es; +}; diff --git a/app/javascript/flavours/blobfox/styles/_mixins.scss b/app/javascript/flavours/blobfox/styles/_mixins.scss new file mode 100644 index 00000000000000..6643cd1aa515da --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/_mixins.scss @@ -0,0 +1,97 @@ +@mixin avatar-radius() { + border-radius: $ui-avatar-border-size; + background-position: 50%; + background-clip: padding-box; +} + +@mixin avatar-size($size: 48px) { + width: $size; + height: $size; + background-size: $size $size; +} + +@mixin single-column($media, $parent: '&') { + .auto-columns #{$parent} { + @media #{$media} { + @content; + } + } + .single-column #{$parent} { + @content; + } +} + +@mixin limited-single-column($media, $parent: '&') { + .auto-columns #{$parent}, + .single-column #{$parent} { + @media #{$media} { + @content; + } + } +} + +@mixin multi-columns($media, $parent: '&') { + .auto-columns #{$parent} { + @media #{$media} { + @content; + } + } + .multi-columns #{$parent} { + @content; + } +} + +@mixin fullwidth-gallery { + &.full-width { + margin-left: -14px; + margin-right: -14px; + width: inherit; + max-width: none; + border-radius: 0; + } +} + +@mixin search-input() { + outline: 0; + box-sizing: border-box; + width: 100%; + border: 0; + box-shadow: none; + font-family: inherit; + background: $ui-base-color; + color: $darker-text-color; + border-radius: 4px; + font-size: 14px; + margin: 0; +} + +@mixin search-popout() { + background: $simple-background-color; + border-radius: 4px; + padding: 10px 14px; + padding-bottom: 14px; + margin-top: 10px; + color: $light-text-color; + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + + h4 { + text-transform: uppercase; + color: $light-text-color; + font-size: 13px; + font-weight: 500; + margin-bottom: 10px; + } + + li { + padding: 4px 0; + } + + ul { + margin-bottom: 10px; + } + + em { + font-weight: 500; + color: $inverted-text-color; + } +} diff --git a/app/javascript/flavours/blobfox/styles/about.scss b/app/javascript/flavours/blobfox/styles/about.scss new file mode 100644 index 00000000000000..0f02563b48d31e --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/about.scss @@ -0,0 +1,56 @@ +$maximum-width: 1235px; +$fluid-breakpoint: $maximum-width + 20px; + +.container { + box-sizing: border-box; + max-width: $maximum-width; + margin: 0 auto; + position: relative; + + @media screen and (max-width: $fluid-breakpoint) { + width: 100%; + padding: 0 10px; + } +} + +.brand { + position: relative; + text-decoration: none; +} + +.rules-list { + font-size: 15px; + line-height: 22px; + color: $primary-text-color; + counter-reset: list-counter; + + li { + position: relative; + border-bottom: 1px solid lighten($ui-base-color, 8%); + padding: 1em 1.75em; + padding-inline-start: 3em; + font-weight: 500; + counter-increment: list-counter; + + &::before { + content: counter(list-counter); + position: absolute; + inset-inline-start: 0; + top: 50%; + transform: translateY(-50%); + background: $highlight-text-color; + color: $ui-base-color; + border-radius: 50%; + width: 4ch; + height: 4ch; + font-weight: 500; + display: flex; + justify-content: center; + align-items: center; + } + + &:last-child { + border-bottom: 0; + } + } +} diff --git a/app/javascript/flavours/blobfox/styles/accessibility.scss b/app/javascript/flavours/blobfox/styles/accessibility.scss new file mode 100644 index 00000000000000..68f4f8f1e2b3fb --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/accessibility.scss @@ -0,0 +1,55 @@ +$emojis-requiring-inversion: 'back' 'copyright' 'curly_loop' 'currency_exchange' + 'end' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' + 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'on' + 'registered' 'soon' 'spider' 'telephone_receiver' 'tm' 'top' 'wavy_dash' !default; + +%emoji-color-inversion { + filter: invert(1); +} + +.emojione { + @each $emoji in $emojis-requiring-inversion { + &[title=':#{$emoji}:'] { + @extend %emoji-color-inversion; + } + } +} + +// Display a checkmark on active UI elements otherwise differing only by color +.status__action-bar-button, +.detailed-status__button .icon-button { + position: relative; + + &.active::after { + position: absolute; + content: '\F00C'; + font-size: 50%; + inset-inline-end: -0.55em; + top: -0.44em; + + /* stylelint-disable-next-line font-family-no-missing-generic-family-keyword -- this is an icon font, this can't use a generic font */ + font-family: FontAwesome; + } +} + +.hicolor-privacy-icons { + .status__visibility-icon.fa-globe, + .privacy-dropdown__option .fa-globe { + color: #1976d2; + } + + .status__visibility-icon.fa-unlock, + .privacy-dropdown__option .fa-unlock { + color: #388e3c; + } + + .status__visibility-icon.fa-lock, + .privacy-dropdown__option .fa-lock { + color: #ffa000; + } + + .status__visibility-icon.fa-envelope, + .privacy-dropdown__option .fa-envelope { + color: #d32f2f; + } +} diff --git a/app/javascript/flavours/blobfox/styles/accounts.scss b/app/javascript/flavours/blobfox/styles/accounts.scss new file mode 100644 index 00000000000000..2bc0150ef45a90 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/accounts.scss @@ -0,0 +1,381 @@ +.card { + & > a { + display: block; + text-decoration: none; + color: inherit; + overflow: hidden; + border-radius: 4px; + + &:hover, + &:active, + &:focus { + .card__bar { + background: lighten($ui-base-color, 8%); + } + } + } + + &__img { + height: 130px; + position: relative; + background: darken($ui-base-color, 12%); + + img { + display: block; + width: 100%; + height: 100%; + margin: 0; + object-fit: cover; + } + + @media screen and (width <= 600px) { + height: 200px; + } + } + + &__bar { + position: relative; + padding: 15px; + display: flex; + justify-content: flex-start; + align-items: center; + background: lighten($ui-base-color, 4%); + + .avatar { + flex: 0 0 auto; + width: 48px; + height: 48px; + @include avatar-size(48px); + + padding-top: 2px; + + img { + width: 100%; + height: 100%; + display: block; + margin: 0; + border-radius: 4px; + @include avatar-radius; + + background: darken($ui-base-color, 8%); + object-fit: cover; + } + } + + .display-name { + margin-inline-start: 15px; + text-align: start; + + i[data-hidden] { + display: none; + } + + strong { + font-size: 15px; + color: $primary-text-color; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + } + + span { + display: block; + font-size: 14px; + color: $darker-text-color; + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + } + } + } +} + +.pagination { + padding: 30px 0; + text-align: center; + overflow: hidden; + + a, + .current, + .newer, + .older, + .page, + .gap { + font-size: 14px; + color: $primary-text-color; + font-weight: 500; + display: inline-block; + padding: 6px 10px; + text-decoration: none; + } + + .current { + background: $simple-background-color; + border-radius: 100px; + color: $inverted-text-color; + cursor: default; + margin: 0 10px; + } + + .gap { + cursor: default; + } + + .older, + .newer { + text-transform: uppercase; + color: $secondary-text-color; + } + + .older { + float: left; + padding-inline-start: 0; + + .fa { + display: inline-block; + margin-inline-end: 5px; + } + } + + .newer { + float: right; + padding-inline-start: 0; + + .fa { + display: inline-block; + margin-inline-start: 5px; + } + } + + .disabled { + cursor: default; + color: lighten($inverted-text-color, 10%); + } + + @media screen and (width <= 700px) { + padding: 30px 20px; + + .page { + display: none; + } + + .newer, + .older { + display: inline-block; + } + } +} + +.nothing-here { + background: $ui-base-color; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + color: $light-text-color; + font-size: 14px; + font-weight: 500; + text-align: center; + display: flex; + justify-content: center; + align-items: center; + cursor: default; + border-radius: 4px; + padding: 20px; + min-height: 30vh; + + &--under-tabs { + border-radius: 0 0 4px 4px; + } + + &--flexible { + box-sizing: border-box; + min-height: 100%; + } +} + +.account-role, +.information-badge, +.simple_form .overridden, +.simple_form .recommended, +.simple_form .not_recommended, +.simple_form .blobfox_only { + display: inline-block; + padding: 4px 6px; + cursor: default; + border-radius: 3px; + font-size: 12px; + line-height: 12px; + font-weight: 500; + color: $ui-secondary-color; + background-color: var(--user-role-background, rgba($ui-secondary-color, 0.1)); + border: 1px solid var(--user-role-border, rgba($ui-secondary-color, 0.5)); + + &.moderator { + color: $success-green; + background-color: rgba($success-green, 0.1); + border-color: rgba($success-green, 0.5); + } + + &.admin { + color: lighten($error-red, 12%); + background-color: rgba(lighten($error-red, 12%), 0.1); + border-color: rgba(lighten($error-red, 12%), 0.5); + } +} + +.simple_form .not_recommended { + color: lighten($error-red, 12%); + background-color: rgba(lighten($error-red, 12%), 0.1); + border-color: rgba(lighten($error-red, 12%), 0.5); +} + +.simple_form .blobfox_only { + color: lighten($warning-red, 12%); + background-color: rgba(lighten($warning-red, 12%), 0.1); + border-color: rgba(lighten($warning-red, 12%), 0.5); +} + +.account__header__fields { + max-width: 100vw; + padding: 0; + margin: 15px -15px -15px; + border: 0 none; + border-top: 1px solid lighten($ui-base-color, 12%); + border-bottom: 1px solid lighten($ui-base-color, 12%); + font-size: 14px; + line-height: 20px; + + dl { + display: flex; + border-bottom: 1px solid lighten($ui-base-color, 12%); + } + + dt, + dd { + box-sizing: border-box; + padding: 14px; + text-align: center; + max-height: 48px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + dt { + font-weight: 500; + width: 120px; + flex: 0 0 auto; + color: $secondary-text-color; + background: rgba(darken($ui-base-color, 8%), 0.5); + } + + dd { + flex: 1 1 auto; + color: $darker-text-color; + } + + a { + color: $highlight-text-color; + text-decoration: none; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } + + .verified { + border: 1px solid rgba($valid-value-color, 0.5); + background: rgba($valid-value-color, 0.25); + + a { + color: $valid-value-color; + font-weight: 500; + } + + &__mark { + color: $valid-value-color; + } + } + + dl:last-child { + border-bottom: 0; + } +} + +.directory__tag .trends__item__current { + width: auto; +} + +.pending-account { + &__header { + color: $darker-text-color; + + a { + color: $ui-secondary-color; + text-decoration: none; + + &:hover, + &:active, + &:focus { + text-decoration: underline; + } + } + + strong { + color: $primary-text-color; + font-weight: 700; + } + } + + &__body { + margin-top: 10px; + } +} + +.batch-table__row--muted { + color: lighten($ui-base-color, 26%); +} + +.batch-table__row--muted .pending-account__header, +.batch-table__row--muted .accounts-table, +.batch-table__row--muted .name-tag { + &, + a, + strong { + color: lighten($ui-base-color, 26%); + } +} + +.batch-table__row--muted .name-tag .avatar { + opacity: 0.5; +} + +.batch-table__row--muted .accounts-table { + tbody td.accounts-table__extra, + &__count, + &__count small { + color: lighten($ui-base-color, 26%); + } +} + +.batch-table__row--attention { + color: $gold-star; +} + +.batch-table__row--attention .pending-account__header, +.batch-table__row--attention .accounts-table, +.batch-table__row--attention .name-tag { + &, + a, + strong { + color: $gold-star; + } +} + +.batch-table__row--attention .accounts-table { + tbody td.accounts-table__extra, + &__count, + &__count small { + color: $gold-star; + } +} diff --git a/app/javascript/flavours/blobfox/styles/admin.scss b/app/javascript/flavours/blobfox/styles/admin.scss new file mode 100644 index 00000000000000..2f4027b03fb6ea --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/admin.scss @@ -0,0 +1,1884 @@ +@use 'sass:math'; + +$no-columns-breakpoint: 600px; +$sidebar-width: 240px; +$content-width: 840px; + +.admin-wrapper { + display: flex; + justify-content: center; + width: 100%; + min-height: 100vh; + + .sidebar-wrapper { + min-height: 100vh; + overflow: hidden; + pointer-events: none; + flex: 1 1 auto; + + &__inner { + display: flex; + justify-content: flex-end; + background: $ui-base-color; + height: 100%; + } + } + + .sidebar { + width: $sidebar-width; + padding: 0; + pointer-events: auto; + + &__toggle { + display: none; + background: darken($ui-base-color, 4%); + border-bottom: 1px solid lighten($ui-base-color, 4%); + align-items: center; + + &__logo { + flex: 1 1 auto; + + a { + display: block; + padding: 15px; + } + } + + &__icon { + display: block; + color: $darker-text-color; + text-decoration: none; + flex: 0 0 auto; + font-size: 18px; + padding: 10px; + margin: 5px 10px; + border-radius: 4px; + + &:focus { + background: $ui-base-color; + } + + .fa-times { + display: none; + } + + &.active { + .fa-times { + display: block; + } + + .fa-bars { + display: none; + } + } + } + } + + .logo { + display: block; + margin: 40px auto; + width: 100px; + height: 100px; + } + + .logo--wordmark { + display: inherit; + margin: inherit; + width: inherit; + height: 25px; + } + + @media screen and (max-width: $no-columns-breakpoint) { + & > a:first-child { + display: none; + } + } + + ul { + list-style: none; + border-radius: 4px 0 0 4px; + overflow: hidden; + margin-bottom: 20px; + + @media screen and (max-width: $no-columns-breakpoint) { + margin-bottom: 0; + } + + a { + display: block; + padding: 15px; + color: $darker-text-color; + text-decoration: none; + transition: all 200ms linear; + transition-property: color, background-color; + border-radius: 4px 0 0 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + i.fa { + margin-inline-end: 5px; + } + + &:hover { + color: $primary-text-color; + background-color: darken($ui-base-color, 5%); + transition: all 100ms linear; + transition-property: color, background-color; + } + + &.selected { + border-radius: 4px 0 0; + } + } + + ul { + background: darken($ui-base-color, 4%); + border-radius: 0 0 0 4px; + margin: 0; + + a { + border: 0; + padding: 15px 35px; + } + } + + .warning a { + color: $gold-star; + font-weight: 700; + } + + .simple-navigation-active-leaf a { + color: $primary-text-color; + background-color: $ui-highlight-color; + border-bottom: 0; + border-radius: 0; + } + } + + & > ul > .simple-navigation-active-leaf a { + border-radius: 4px 0 0 4px; + } + } + + .content-wrapper { + box-sizing: border-box; + width: 100%; + max-width: $content-width; + flex: 1 1 auto; + } + + @media screen and (max-width: $content-width + $sidebar-width) { + .sidebar-wrapper--empty { + display: none; + } + + .sidebar-wrapper { + width: $sidebar-width; + flex: 0 0 auto; + } + } + + @media screen and (max-width: $no-columns-breakpoint) { + .sidebar-wrapper { + width: 100%; + } + } + + .content { + padding-top: 55px; + padding-bottom: 20px; + padding-inline-start: 25px; + padding-inline-end: 15px; + + @media screen and (max-width: $no-columns-breakpoint) { + max-width: none; + padding: 15px; + padding-top: 30px; + } + + &__heading { + margin-bottom: 45px; + + &__row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + margin-top: -15px; + margin-inline-end: -15px; + + & > * { + margin-top: 15px; + margin-inline-end: 15px; + } + } + + &__tabs { + margin-top: 30px; + width: 100%; + + & > div { + display: flex; + flex-wrap: wrap; + gap: 5px; + } + + a { + font-size: 14px; + display: inline-flex; + align-items: center; + padding: 7px 10px; + border-radius: 4px; + color: $darker-text-color; + text-decoration: none; + font-weight: 500; + gap: 5px; + white-space: nowrap; + + &:hover, + &:focus, + &:active { + background: lighten($ui-base-color, 4%); + } + + &.selected { + font-weight: 700; + color: $primary-text-color; + background: $ui-highlight-color; + } + } + } + + &__actions { + display: inline-flex; + flex-flow: wrap; + gap: 5px; + } + + h2 small { + font-size: 12px; + display: block; + font-weight: 500; + color: $darker-text-color; + line-height: 18px; + } + + @media screen and (max-width: $no-columns-breakpoint) { + border-bottom: 0; + padding-bottom: 0; + } + } + + h2 { + color: $secondary-text-color; + font-size: 24px; + line-height: 36px; + font-weight: 700; + } + + h3 { + color: $secondary-text-color; + font-size: 20px; + line-height: 28px; + font-weight: 400; + margin-bottom: 30px; + } + + h4 { + text-transform: uppercase; + font-size: 13px; + font-weight: 700; + color: $darker-text-color; + padding-bottom: 8px; + margin-bottom: 8px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + } + + h6 { + font-size: 16px; + color: $secondary-text-color; + line-height: 28px; + font-weight: 500; + } + + .fields-group h6 { + color: $primary-text-color; + font-weight: 500; + } + + .directory__tag > a, + .directory__tag > div { + box-shadow: none; + } + + .directory__tag .table-action-link .fa { + color: inherit; + } + + .directory__tag h4 { + font-size: 18px; + font-weight: 700; + color: $primary-text-color; + text-transform: none; + padding-bottom: 0; + margin-bottom: 0; + border-bottom: 0; + } + + & > p { + font-size: 14px; + line-height: 21px; + color: $secondary-text-color; + margin-bottom: 20px; + + strong { + color: $primary-text-color; + font-weight: 500; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + } + + hr { + width: 100%; + height: 0; + border: 0; + border-bottom: 1px solid rgba($ui-base-lighter-color, 0.6); + margin: 20px 0; + + &.spacer { + height: 1px; + border: 0; + } + } + } + + @media screen and (max-width: $no-columns-breakpoint) { + display: block; + + .sidebar-wrapper { + min-height: 0; + } + + .sidebar { + width: 100%; + padding: 0; + height: auto; + + &__toggle { + display: flex; + } + + & > ul { + display: none; + + &.visible { + display: block; + position: fixed; + z-index: 10; + width: 100%; + height: calc(100% - 56px); + inset-inline-start: 0; + bottom: 0; + overflow-y: auto; + background: $ui-base-color; + } + } + + ul a, + ul ul a { + border-radius: 0; + border-bottom: 1px solid lighten($ui-base-color, 4%); + transition: none; + + &:hover { + transition: none; + } + } + + ul ul { + border-radius: 0; + } + + ul .simple-navigation-active-leaf a { + border-bottom-color: $ui-highlight-color; + } + } + } +} + +hr.spacer { + width: 100%; + border: 0; + margin: 20px 0; + height: 1px; +} + +body, +.admin-wrapper .content { + .muted-hint { + color: $darker-text-color; + + a { + color: $highlight-text-color; + } + } + + .positive-hint, + .negative-hint, + .neutral-hint { + a { + color: inherit; + text-decoration: underline; + + &:focus, + &:hover, + &:active { + text-decoration: none; + } + } + } + + .positive-hint { + color: $valid-value-color; + font-weight: 500; + } + + .negative-hint { + color: $error-value-color; + font-weight: 500; + } + + .neutral-hint { + color: $dark-text-color; + font-weight: 500; + } + + .warning-hint { + color: $gold-star; + font-weight: 500; + } +} + +.filters { + display: flex; + flex-wrap: wrap; + gap: 40px; + + .filter-subset { + flex: 0 0 auto; + margin-bottom: 20px; + + &:last-child { + margin-bottom: 30px; + } + + ul { + margin-top: 5px; + list-style: none; + + li { + display: inline-block; + margin-inline-end: 5px; + } + } + + & > div { + display: flex; + gap: 5px; + } + + strong { + font-weight: 500; + text-transform: uppercase; + font-size: 12px; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + + &--with-select strong { + display: block; + margin-bottom: 10px; + } + + a { + display: inline-block; + color: $darker-text-color; + text-decoration: none; + text-transform: uppercase; + font-size: 12px; + font-weight: 500; + border-bottom: 2px solid $ui-base-color; + + &:hover { + color: $primary-text-color; + border-bottom: 2px solid lighten($ui-base-color, 5%); + } + + &.selected { + color: $highlight-text-color; + border-bottom: 2px solid $ui-highlight-color; + } + } + } +} + +.flavour-screen { + display: block; + margin: 10px auto; + max-width: 100%; +} + +.flavour-description { + display: block; + font-size: 16px; + margin: 10px 0; + + & > p { + margin: 10px 0; + } +} + +.report-accounts { + display: flex; + flex-wrap: wrap; + margin-bottom: 20px; +} + +.report-accounts__item { + display: flex; + flex: 250px; + flex-direction: column; + margin: 0 5px; + + & > strong { + display: block; + margin-top: 0; + margin-bottom: 10px; + margin-inline-end: 0; + margin-inline-start: -5px; + font-weight: 500; + font-size: 14px; + line-height: 18px; + color: $secondary-text-color; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + + .account-card { + flex: 1 1 auto; + } +} + +.report-status, +.account-status { + display: flex; + margin-bottom: 10px; + + .activity-stream { + flex: 2 0 0; + margin-inline-end: 20px; + max-width: calc(100% - 60px); + + .entry { + border-radius: 4px; + } + } +} + +.report-status__actions, +.account-status__actions { + flex: 0 0 auto; + display: flex; + flex-direction: column; + + .icon-button { + font-size: 24px; + width: 24px; + text-align: center; + margin-bottom: 10px; + } +} + +.simple_form.new_report_note, +.simple_form.new_account_moderation_note { + max-width: 100%; +} + +.simple_form { + .actions { + margin-top: 15px; + } + + .button { + font-size: 15px; + } +} + +.batch-form-box { + display: flex; + flex-wrap: wrap; + margin-bottom: 5px; + + #form_status_batch_action { + margin-bottom: 5px; + margin-inline-end: 5px; + font-size: 14px; + } + + input.button { + margin-bottom: 5px; + margin-inline-end: 5px; + } + + .media-spoiler-toggle-buttons { + margin-inline-start: auto; + + .button { + overflow: visible; + margin-bottom: 5px; + margin-inline-start: 5px; + float: right; + } + } +} + +.back-link { + margin-bottom: 10px; + font-size: 14px; + + a { + color: $highlight-text-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.special-action-button, +.back-link { + text-align: end; + flex: 1 1 auto; +} + +.action-buttons { + display: flex; + overflow: hidden; + justify-content: space-between; +} + +.spacer { + flex: 1 1 auto; +} + +.log-entry { + display: block; + line-height: 20px; + padding: 15px; + padding-inline-start: 15px * 2 + 40px; + background: $ui-base-color; + border-bottom: 1px solid darken($ui-base-color, 8%); + position: relative; + text-decoration: none; + color: $darker-text-color; + font-size: 14px; + + &:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } + + &:last-child { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom: 0; + } + + &:hover, + &:focus, + &:active { + background: lighten($ui-base-color, 4%); + } + + &__avatar { + position: absolute; + inset-inline-start: 15px; + top: 15px; + + .avatar { + border-radius: 4px; + width: 40px; + height: 40px; + } + } + + &__title { + word-wrap: break-word; + } + + &__timestamp { + color: $dark-text-color; + } + + a, + .username, + .target { + color: $secondary-text-color; + text-decoration: none; + font-weight: 500; + } + + a { + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } +} + +a.name-tag, +.name-tag, +a.inline-name-tag, +.inline-name-tag { + text-decoration: none; + color: $secondary-text-color; + + .username { + font-weight: 500; + } + + &.suspended { + .username { + text-decoration: line-through; + color: lighten($error-red, 12%); + } + + .avatar { + filter: grayscale(100%); + opacity: 0.8; + } + } +} + +a.name-tag, +.name-tag { + display: inline-flex; + align-items: center; + vertical-align: top; + + .avatar { + display: block; + margin: 0; + margin-inline-end: 5px; + border-radius: 50%; + } + + &.suspended { + .avatar { + filter: grayscale(100%); + opacity: 0.8; + } + } +} + +.speech-bubble { + margin-bottom: 20px; + border-inline-start: 4px solid $ui-highlight-color; + + &.positive { + border-left-color: $success-green; + } + + &.negative { + border-left-color: lighten($error-red, 12%); + } + + &.warning { + border-left-color: $gold-star; + } + + &__bubble { + padding: 16px; + padding-inline-start: 14px; + font-size: 15px; + line-height: 20px; + border-radius: 4px 4px 4px 0; + position: relative; + font-weight: 500; + + a { + color: $darker-text-color; + } + } + + &__owner { + padding: 8px; + padding-inline-start: 12px; + } + + time { + color: $dark-text-color; + } +} + +.report-card { + background: $ui-base-color; + border-radius: 4px; + margin-bottom: 20px; + + &__profile { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px; + + .account { + padding: 0; + border: 0; + + &__avatar-wrapper { + margin-inline-start: 0; + } + } + + &__stats { + flex: 0 0 auto; + font-weight: 500; + color: $darker-text-color; + text-transform: uppercase; + text-align: end; + + a { + color: inherit; + text-decoration: none; + + &:focus, + &:hover, + &:active { + color: lighten($darker-text-color, 8%); + } + } + + .red { + color: $error-value-color; + } + } + } + + &__summary { + &__item { + display: flex; + justify-content: flex-start; + border-top: 1px solid darken($ui-base-color, 4%); + + &:hover { + background: lighten($ui-base-color, 2%); + } + + &__reported-by, + &__assigned { + padding: 15px; + flex: 0 0 auto; + box-sizing: border-box; + width: 150px; + color: $darker-text-color; + + &, + .username { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + &__content { + flex: 1 1 auto; + max-width: calc(100% - 300px); + + &__icon { + color: $dark-text-color; + margin-inline-end: 4px; + font-weight: 500; + } + } + + &__content a { + display: block; + box-sizing: border-box; + width: 100%; + padding: 15px; + text-decoration: none; + color: $darker-text-color; + } + } + } +} + +.one-line { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ellipsized-ip { + display: inline-block; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; +} + +.admin-account-bio { + display: flex; + flex-wrap: wrap; + margin: 0 -5px; + margin-top: 20px; + + > div { + box-sizing: border-box; + padding: 0 5px; + margin-bottom: 10px; + flex: 1 0 50%; + max-width: 100%; + } + + .account__header__fields, + .account__header__content { + background: lighten($ui-base-color, 8%); + border-radius: 4px; + height: 100%; + } + + .account__header__fields { + margin: 0; + border: 0; + + a { + color: $highlight-text-color; + } + + dl:first-child .verified { + border-radius: 0 4px 0 0; + } + + .verified a { + color: $valid-value-color; + } + } + + .account__header__content { + box-sizing: border-box; + padding: 20px; + color: $primary-text-color; + } +} + +.center-text { + text-align: center; +} + +.applications-list__item, +.filters-list__item { + padding: 15px 0; + background: $ui-base-color; + border: 1px solid lighten($ui-base-color, 4%); + border-radius: 4px; + margin-top: 15px; +} + +.user-role { + color: var(--user-role-accent); +} + +.announcements-list, +.filters-list { + border: 1px solid lighten($ui-base-color, 4%); + border-radius: 4px; + + &__item { + padding: 15px 0; + background: $ui-base-color; + border-bottom: 1px solid lighten($ui-base-color, 4%); + + &__title { + padding: 0 15px; + display: block; + font-weight: 500; + font-size: 18px; + line-height: 1.5; + color: $secondary-text-color; + text-decoration: none; + margin-bottom: 10px; + + .account-role { + vertical-align: middle; + } + } + + a.announcements-list__item__title { + &:hover, + &:focus, + &:active { + color: $primary-text-color; + } + } + + &__meta { + padding: 0 15px; + color: $dark-text-color; + + a { + color: inherit; + text-decoration: underline; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + } + } + + &__action-bar { + display: flex; + justify-content: space-between; + align-items: center; + } + + &__permissions { + margin-top: 10px; + } + + &:last-child { + border-bottom: 0; + } + } +} + +.filters-list__item { + &__title { + display: flex; + justify-content: space-between; + margin-bottom: 0; + } + + &__permissions { + margin-top: 0; + margin-bottom: 10px; + } + + .expiration { + font-size: 13px; + } + + &.expired { + .expiration { + color: lighten($error-red, 12%); + } + + .permissions-list__item__icon { + color: $dark-text-color; + } + } +} + +.dashboard__counters.admin-account-counters { + margin-top: 10px; +} + +.account-badges { + margin: -2px 0; +} + +.retention { + overflow: auto; + + > h4 { + position: sticky; + inset-inline-start: 0; + } + + &__table { + &__number { + color: $secondary-text-color; + padding: 10px; + } + + &__date { + white-space: nowrap; + padding: 10px 0; + text-align: start; + min-width: 120px; + + &.retention__table__average { + font-weight: 700; + } + } + + &__size { + text-align: center; + padding: 10px; + } + + &__label { + font-weight: 700; + color: $darker-text-color; + } + + &__box { + box-sizing: border-box; + background: $ui-highlight-color; + padding: 10px; + font-weight: 500; + color: $primary-text-color; + width: 52px; + margin: 1px; + + @for $i from 0 through 10 { + &--#{10 * $i} { + background-color: rgba( + $ui-highlight-color, + 1 * (math.div(max(1, $i), 10)) + ); + } + } + } + } +} + +.sparkline { + display: block; + text-decoration: none; + background: lighten($ui-base-color, 4%); + border-radius: 4px; + padding: 0; + position: relative; + padding-bottom: 55px + 20px; + overflow: hidden; + + &__value { + display: flex; + line-height: 33px; + align-items: flex-end; + padding: 20px; + padding-bottom: 10px; + + &__total { + display: block; + margin-inline-end: 10px; + font-weight: 500; + font-size: 28px; + color: $primary-text-color; + } + + &__change { + display: block; + font-weight: 500; + font-size: 18px; + color: $darker-text-color; + margin-bottom: -3px; + + &.positive { + color: $valid-value-color; + } + + &.negative { + color: $error-value-color; + } + } + } + + &__label { + padding: 0 20px; + padding-bottom: 10px; + text-transform: uppercase; + color: $darker-text-color; + font-weight: 500; + } + + &__graph { + position: absolute; + bottom: 0; + width: 100%; + + svg { + display: block; + margin: 0; + } + + path:first-child { + fill: rgba($highlight-text-color, 0.25) !important; + fill-opacity: 1 !important; + } + + path:last-child { + stroke: lighten($highlight-text-color, 6%) !important; + fill: none !important; + } + } +} + +a.sparkline { + &:hover, + &:focus, + &:active { + background: lighten($ui-base-color, 6%); + } +} + +.skeleton { + background-color: lighten($ui-base-color, 8%); + background-image: linear-gradient( + 90deg, + lighten($ui-base-color, 8%), + lighten($ui-base-color, 12%), + lighten($ui-base-color, 8%) + ); + background-size: 200px 100%; + background-repeat: no-repeat; + border-radius: 4px; + display: inline-block; + line-height: 1; + width: 100%; + animation: skeleton 1.2s ease-in-out infinite; +} + +@keyframes skeleton { + 0% { + background-position: -200px 0; + } + + 100% { + background-position: calc(200px + 100%) 0; + } +} + +.dimension { + table { + width: 100%; + } + + &__item { + border-bottom: 1px solid lighten($ui-base-color, 4%); + + &__key { + font-weight: 500; + padding: 11px 10px; + } + + &__value { + text-align: end; + color: $darker-text-color; + padding: 11px 10px; + } + + &__indicator { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: $ui-highlight-color; + margin-inline-end: 10px; + + @for $i from 0 through 10 { + &--#{10 * $i} { + background-color: rgba( + $ui-highlight-color, + 1 * (math.div(max(1, $i), 10)) + ); + } + } + } + + &:last-child { + border-bottom: 0; + } + + &.negative { + color: $error-value-color; + font-weight: 700; + + .dimension__item__value { + color: $error-value-color; + } + } + } +} + +.report-reason-selector { + border-radius: 4px; + background: $ui-base-color; + margin-bottom: 20px; + + &__category { + cursor: pointer; + border-bottom: 1px solid darken($ui-base-color, 8%); + + &:last-child { + border-bottom: 0; + } + + &__label { + padding: 15px; + } + + &__rules { + margin-inline-start: 30px; + } + } + + &__rule { + cursor: pointer; + padding: 15px; + } +} + +.report-header { + display: grid; + grid-gap: 15px; + grid-template-columns: minmax(0, 1fr) 300px; + + &__details { + &__item { + border-bottom: 1px solid lighten($ui-base-color, 8%); + padding: 15px 0; + + &:last-child { + border-bottom: 0; + } + + &__header { + font-weight: 600; + padding: 4px 0; + } + } + + &--horizontal { + display: grid; + grid-auto-columns: minmax(0, 1fr); + grid-auto-flow: column; + + .report-header__details__item { + border-bottom: 0; + } + } + } + + @media screen and (width <= 930px) { + grid-template-columns: minmax(0, 1fr); + } +} + +.account-card { + background: $ui-base-color; + border-radius: 4px; + + &__permalink { + color: inherit; + text-decoration: none; + } + + &__header { + padding: 4px; + border-radius: 4px; + height: 128px; + + img { + display: block; + margin: 0; + width: 100%; + height: 100%; + object-fit: cover; + background: darken($ui-base-color, 8%); + } + } + + &__title { + margin-top: -(15px + 8px); + display: flex; + align-items: flex-end; + + &__avatar { + padding: 14px; + + img, + .account__avatar { + display: block; + margin: 0; + width: 56px; + height: 56px; + background-color: darken($ui-base-color, 8%); + border-radius: 8px; + border: 1px solid $ui-base-color; + } + } + + .display-name { + color: $darker-text-color; + padding-bottom: 15px; + font-size: 15px; + line-height: 20px; + + bdi { + display: block; + color: $primary-text-color; + font-weight: 700; + } + } + } + + &__bio { + padding: 0 15px; + margin: 8px 0; + overflow: hidden; + text-overflow: ellipsis; + word-wrap: break-word; + max-height: 21px * 2; + position: relative; + font-size: 15px; + line-height: 21px; + + &::after { + display: block; + content: ''; + width: 50px; + height: 21px; + position: absolute; + bottom: 0; + inset-inline-end: 15px; + background: linear-gradient(to left, $ui-base-color, transparent); + pointer-events: none; + } + + a { + color: $secondary-text-color; + text-decoration: none; + unicode-bidi: isolate; + + &:hover { + text-decoration: underline; + } + + &.mention { + &:hover { + text-decoration: none; + + span { + text-decoration: underline; + } + } + } + } + } + + &__actions { + display: flex; + justify-content: space-between; + align-items: center; + + &__button { + flex-shrink: 1; + padding: 0 15px; + overflow: hidden; + + .button { + min-width: 0; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + max-width: 100%; + } + } + } + + &__counters { + flex: 1 1 auto; + display: grid; + grid-auto-columns: minmax(0, 1fr); + grid-auto-flow: column; + max-width: 340px; + min-width: 65px * 3; + + &__item { + padding: 15px 0; + text-align: center; + color: $primary-text-color; + font-weight: 600; + font-size: 15px; + line-height: 21px; + + small { + display: block; + color: $darker-text-color; + font-weight: 400; + font-size: 13px; + line-height: 18px; + } + } + } +} + +.report-notes { + margin-bottom: 20px; + + &__item { + background: $ui-base-color; + position: relative; + padding: 15px; + padding-inline-start: 15px * 2 + 40px; + border-bottom: 1px solid darken($ui-base-color, 8%); + + &:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } + + &:last-child { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom: 0; + } + + &:hover { + background-color: lighten($ui-base-color, 4%); + } + + &__avatar { + position: absolute; + inset-inline-start: 15px; + top: 15px; + border-radius: 4px; + width: 40px; + height: 40px; + } + + &__header { + color: $darker-text-color; + font-size: 15px; + line-height: 20px; + margin-bottom: 4px; + + .username { + color: $primary-text-color; + font-weight: 500; + margin-inline-end: 5px; + + a { + color: inherit; + text-decoration: none; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } + } + + time { + margin-inline-start: 5px; + vertical-align: baseline; + } + } + + &__content { + font-size: 15px; + line-height: 20px; + word-wrap: break-word; + font-weight: 400; + color: $primary-text-color; + + p { + margin-bottom: 20px; + white-space: pre-wrap; + unicode-bidi: plaintext; + + &:last-child { + margin-bottom: 0; + } + } + + a { + color: $highlight-text-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + &__actions { + position: absolute; + top: 15px; + inset-inline-end: 15px; + text-align: end; + } + } +} + +.report-actions { + border: 1px solid darken($ui-base-color, 8%); + + &__item { + display: flex; + align-items: center; + line-height: 18px; + border-bottom: 1px solid darken($ui-base-color, 8%); + + &:last-child { + border-bottom: 0; + } + + &__button { + box-sizing: border-box; + flex: 0 0 auto; + width: 200px; + padding: 15px; + padding-inline-end: 0; + + .button { + display: block; + width: 100%; + } + } + + &__description { + padding: 15px; + font-size: 14px; + color: $dark-text-color; + } + } + + @media screen and (width <= 800px) { + border: 0; + + &__item { + flex-direction: column; + border: 0; + + &__button { + width: 100%; + padding: 15px 0; + } + + &__description { + padding: 0; + padding-bottom: 15px; + } + } + } +} + +.section-skip-link { + float: right; + + a { + color: $ui-highlight-color; + text-decoration: none; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } +} + +.strike-card { + padding: 15px; + border-radius: 4px; + background: $ui-base-color; + font-size: 15px; + line-height: 20px; + word-wrap: break-word; + font-weight: 400; + color: $primary-text-color; + box-sizing: border-box; + min-height: 100%; + + a { + color: $highlight-text-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + p { + margin-bottom: 20px; + unicode-bidi: plaintext; + + &:last-child { + margin-bottom: 0; + } + + strong { + font-weight: 700; + } + } + + &__rules { + list-style: disc; + padding-inline-start: 15px; + margin-bottom: 20px; + color: $darker-text-color; + + &:last-child { + margin-bottom: 0; + } + + &__text { + color: $primary-text-color; + } + } + + &__statuses-list { + border-radius: 4px; + border: 1px solid darken($ui-base-color, 8%); + font-size: 13px; + line-height: 18px; + overflow: hidden; + + &__item { + padding: 16px; + background: lighten($ui-base-color, 2%); + border-bottom: 1px solid darken($ui-base-color, 8%); + + &:last-child { + border-bottom: 0; + } + + &__meta { + color: $darker-text-color; + } + + a { + color: inherit; + text-decoration: none; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } + } + } +} + +.availability-indicator { + display: flex; + align-items: center; + margin-bottom: 30px; + font-size: 14px; + line-height: 21px; + + &__hint { + padding: 0 15px; + } + + &__graphic { + display: flex; + margin: 0 -2px; + + &__item { + display: block; + flex: 0 0 auto; + width: 4px; + height: 21px; + background: lighten($ui-base-color, 8%); + margin: 0 2px; + border-radius: 2px; + + &.positive { + background: $valid-value-color; + } + + &.negative { + background: $error-value-color; + } + } + } +} + +.history { + counter-reset: step 0; + font-size: 15px; + line-height: 22px; + + li { + counter-increment: step 1; + padding-inline-start: 2.5rem; + padding-bottom: 8px; + position: relative; + margin-bottom: 8px; + + &::before { + position: absolute; + content: counter(step); + font-size: 0.625rem; + font-weight: 500; + inset-inline-start: 0; + display: flex; + justify-content: center; + align-items: center; + width: calc(1.375rem + 1px); + height: calc(1.375rem + 1px); + background: $ui-base-color; + border: 1px solid $highlight-text-color; + color: $highlight-text-color; + border-radius: 8px; + } + + &::after { + position: absolute; + content: ''; + width: 1px; + background: $highlight-text-color; + bottom: 0; + top: calc(1.875rem + 1px); + inset-inline-start: 0.6875rem; + } + + &:last-child { + margin-bottom: 0; + + &::after { + display: none; + } + } + } + + &__entry { + h5 { + font-weight: 500; + color: $primary-text-color; + line-height: 25px; + margin-bottom: 16px; + } + + .status { + border: 1px solid lighten($ui-base-color, 4%); + background: $ui-base-color; + border-radius: 4px; + } + } +} diff --git a/app/javascript/flavours/blobfox/styles/basics.scss b/app/javascript/flavours/blobfox/styles/basics.scss new file mode 100644 index 00000000000000..ff00c797c8e4dc --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/basics.scss @@ -0,0 +1,294 @@ +@function hex-color($color) { + @if type-of($color) == 'color' { + $color: str-slice(ie-hex-str($color), 4); + } + + @return '%23' + unquote($color); +} + +body { + font-family: $font-sans-serif, sans-serif; + background: darken($ui-base-color, 7%); + font-size: 13px; + line-height: 18px; + font-weight: 400; + color: $primary-text-color; + text-rendering: optimizelegibility; + font-feature-settings: 'kern'; + text-size-adjust: none; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0%); + -webkit-tap-highlight-color: transparent; + + &.system-font { + // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+) + // -apple-system => Safari <11 specific + // BlinkMacSystemFont => Chrome <56 on macOS specific + // Segoe UI => Windows 7/8/10 + // Oxygen => KDE + // Ubuntu => Unity/Ubuntu + // Cantarell => GNOME + // Fira Sans => Firefox OS + // Droid Sans => Older Androids (<4.0) + // Helvetica Neue => Older macOS <10.11 + // $font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0) + font-family: + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Oxygen, + Ubuntu, + Cantarell, + 'Fira Sans', + 'Droid Sans', + 'Helvetica Neue', + $font-sans-serif, + sans-serif; + } + + &.app-body { + padding: 0; + + &.layout-single-column { + height: auto; + min-height: 100vh; + overflow-y: scroll; + } + + &.layout-multiple-columns { + position: absolute; + width: 100%; + height: 100%; + } + + &.with-modals--active { + overflow-y: hidden; + } + } + + &.lighter { + background: $ui-base-color; + } + + &.with-modals { + overflow-x: hidden; + overflow-y: scroll; + + &--active { + overflow-y: hidden; + } + } + + &.player { + padding: 0; + margin: 0; + position: absolute; + width: 100%; + height: 100%; + overflow: hidden; + + & > div { + height: 100%; + } + + .video-player video { + width: 100%; + height: 100%; + max-height: 100vh; + } + + .media-gallery { + margin-top: 0; + height: 100% !important; + border-radius: 0; + } + + .media-gallery__item { + border-radius: 0; + } + } + + &.embed { + background: lighten($ui-base-color, 4%); + margin: 0; + padding-bottom: 0; + + .container { + position: absolute; + width: 100%; + height: 100%; + overflow: hidden; + } + } + + &.admin { + background: darken($ui-base-color, 4%); + padding: 0; + } + + &.error { + position: absolute; + text-align: center; + color: $darker-text-color; + background: $ui-base-color; + width: 100%; + height: 100%; + padding: 0; + display: flex; + justify-content: center; + align-items: center; + + .dialog { + vertical-align: middle; + margin: 20px; + + &__illustration { + img { + display: block; + max-width: 470px; + width: 100%; + height: auto; + margin-top: -120px; + } + } + + h1 { + font-size: 20px; + line-height: 28px; + font-weight: 400; + } + } + } +} + +button { + font-family: inherit; + cursor: pointer; + + &:focus { + outline: none; + } +} + +.app-holder { + &, + & > div, + & > noscript { + display: flex; + width: 100%; + align-items: center; + justify-content: center; + outline: 0 !important; + } + + & > noscript { + height: 100vh; + } +} + +.layout-single-column .app-holder { + &, + & > div { + min-height: 100vh; + } +} + +.layout-multiple-columns .app-holder { + &, + & > div { + height: 100%; + } +} + +.error-boundary, +.app-holder noscript { + flex-direction: column; + font-size: 16px; + font-weight: 400; + line-height: 1.7; + color: lighten($error-red, 4%); + text-align: center; + + & > div { + max-width: 500px; + } + + p { + margin-bottom: 0.85em; + + &:last-child { + margin-bottom: 0; + } + } + + a { + color: $highlight-text-color; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + } + + &__footer { + color: $dark-text-color; + font-size: 13px; + + a { + color: $dark-text-color; + } + } + + button { + display: inline; + border: 0; + background: transparent; + color: $dark-text-color; + font: inherit; + padding: 0; + margin: 0; + line-height: inherit; + cursor: pointer; + outline: 0; + transition: color 300ms linear; + text-decoration: underline; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + + &.copied { + color: $valid-value-color; + transition: none; + } + } +} + +.logo-resources { + // Not using display: none because of https://bugs.chromium.org/p/chromium/issues/detail?id=258029 + visibility: hidden; + user-select: none; + pointer-events: none; + width: 0; + height: 0; + overflow: hidden; + position: absolute; + top: 0; + inset-inline-start: 0; + z-index: -1000; +} + +// NoScript adds a __ns__pop2top class to the full ancestry of blocked elements, +// to set the z-index to a high value, which messes with modals and dropdowns. +// Blocked elements can in theory only be media and frames/embeds, so they +// should only appear in statuses, under divs and articles. +body, +div, +article { + .__ns__pop2top { + z-index: unset !important; + } +} diff --git a/app/javascript/flavours/blobfox/styles/branding.scss b/app/javascript/flavours/blobfox/styles/branding.scss new file mode 100644 index 00000000000000..d1bddc68b0d7f9 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/branding.scss @@ -0,0 +1,3 @@ +.logo { + color: $primary-text-color; +} diff --git a/app/javascript/flavours/blobfox/styles/components/about.scss b/app/javascript/flavours/blobfox/styles/components/about.scss new file mode 100644 index 00000000000000..98ff91ad18c207 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/about.scss @@ -0,0 +1,295 @@ +.image { + position: relative; + overflow: hidden; + + &__preview { + position: absolute; + top: 0; + inset-inline-start: 0; + width: 100%; + height: 100%; + object-fit: cover; + } + + &.loaded &__preview { + display: none; + } + + img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + border: 0; + background: transparent; + opacity: 0; + } + + &.loaded img { + opacity: 1; + } +} + +.link-footer { + flex: 0 0 auto; + padding: 10px; + padding-top: 20px; + z-index: 1; + font-size: 13px; + + p { + color: $dark-text-color; + margin-bottom: 20px; + + .version { + white-space: nowrap; + } + + strong { + font-weight: 500; + } + + a { + color: $dark-text-color; + text-decoration: underline; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + } + } +} + +.about { + padding: 20px; + + @media screen and (min-width: $no-gap-breakpoint) { + border-radius: 4px; + } + + &__footer { + color: $dark-text-color; + text-align: center; + font-size: 15px; + line-height: 22px; + margin-top: 20px; + } + + &__header { + margin-bottom: 30px; + + &__hero { + width: 100%; + height: auto; + aspect-ratio: 1.9; + background: lighten($ui-base-color, 4%); + border-radius: 8px; + margin-bottom: 30px; + } + + h1, + p { + text-align: center; + } + + h1 { + font-size: 24px; + line-height: 1.5; + font-weight: 700; + margin-bottom: 10px; + } + + p { + font-size: 16px; + line-height: 24px; + font-weight: 400; + color: $darker-text-color; + } + } + + &__meta { + background: lighten($ui-base-color, 4%); + border-radius: 4px; + display: flex; + margin-bottom: 30px; + font-size: 15px; + + &__column { + box-sizing: border-box; + width: 50%; + padding: 20px; + } + + &__divider { + width: 0; + border: 0; + border-style: solid; + border-color: lighten($ui-base-color, 8%); + border-left-width: 1px; + min-height: calc(100% - 60px); + flex: 0 0 auto; + } + + h4 { + font-size: 15px; + text-transform: uppercase; + color: $darker-text-color; + font-weight: 500; + margin-bottom: 20px; + } + + @media screen and (width <= 600px) { + display: block; + + h4 { + text-align: center; + } + + &__column { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + } + + &__divider { + min-height: 0; + width: 100%; + border-left-width: 0; + border-top-width: 1px; + } + } + + .layout-multiple-columns & { + display: block; + + h4 { + text-align: center; + } + + &__column { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + } + + &__divider { + min-height: 0; + width: 100%; + border-left-width: 0; + border-top-width: 1px; + } + } + } + + &__mail { + color: $primary-text-color; + text-decoration: none; + font-weight: 500; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } + + .link-footer { + padding: 0; + margin-top: 60px; + text-align: center; + font-size: 15px; + line-height: 22px; + + @media screen and (min-width: $no-gap-breakpoint) { + display: none; + } + } + + .account { + padding: 0; + border: 0; + } + + .account__avatar-wrapper { + margin-inline-start: 0; + } + + .account__relationship { + display: none; + } + + &__section { + margin-bottom: 10px; + + &__title { + font-size: 17px; + font-weight: 600; + line-height: 22px; + padding: 20px; + border-radius: 4px; + background: lighten($ui-base-color, 4%); + color: $highlight-text-color; + cursor: pointer; + } + + &.active &__title { + border-radius: 4px 4px 0 0; + } + + &__body { + border: 1px solid lighten($ui-base-color, 4%); + border-top: 0; + padding: 20px; + font-size: 15px; + line-height: 22px; + } + } + + &__domain-blocks { + margin-top: 30px; + background: darken($ui-base-color, 4%); + border: 1px solid lighten($ui-base-color, 4%); + border-radius: 4px; + + &__domain { + border-bottom: 1px solid lighten($ui-base-color, 4%); + padding: 10px; + font-size: 15px; + color: $darker-text-color; + + &:nth-child(2n) { + background: darken($ui-base-color, 2%); + } + + &:last-child { + border-bottom: 0; + } + + &__header { + display: flex; + gap: 10px; + justify-content: space-between; + font-weight: 500; + margin-bottom: 4px; + } + + h6 { + color: $secondary-text-color; + font-size: inherit; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + p { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + } +} diff --git a/app/javascript/flavours/blobfox/styles/components/accounts.scss b/app/javascript/flavours/blobfox/styles/components/accounts.scss new file mode 100644 index 00000000000000..685421c0a87ae8 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/accounts.scss @@ -0,0 +1,807 @@ +.account { + padding: 10px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + color: inherit; + text-decoration: none; + + .account__display-name { + flex: 1 1 auto; + display: flex; + align-items: center; + gap: 10px; + color: $darker-text-color; + overflow: hidden; + text-decoration: none; + font-size: 14px; + + .display-name { + margin-bottom: 4px; + } + + .display-name strong { + display: inline; + } + } + + &--minimal { + .account__display-name { + .display-name { + margin-bottom: 0; + } + + .display-name strong { + display: block; + } + } + } + + &__note { + font-size: 14px; + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + margin-top: 10px; + color: $darker-text-color; + + &--missing { + color: $dark-text-color; + } + + p { + margin-bottom: 10px; + + &:last-child { + margin-bottom: 0; + } + } + + a { + color: inherit; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + } + } +} + +.account__wrapper { + display: flex; + gap: 10px; + align-items: center; +} + +.account__avatar-wrapper { + float: left; +} + +.account__avatar { + @include avatar-radius; + + display: block; + position: relative; + overflow: hidden; + + img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + + &-inline { + display: inline-block; + vertical-align: middle; + margin-inline-end: 5px; + } + + &-composite { + @include avatar-radius; + + overflow: hidden; + position: relative; + + & > div { + @include avatar-radius; + + float: left; + position: relative; + box-sizing: border-box; + } + + .account__avatar { + width: 100% !important; + height: 100% !important; + } + + &__label { + display: block; + position: absolute; + top: 50%; + inset-inline-start: 50%; + transform: translate(-50%, -50%); + color: $primary-text-color; + text-shadow: 1px 1px 2px $base-shadow-color; + font-weight: 700; + font-size: 15px; + } + } +} + +.account__avatar-overlay { + position: relative; + + &-overlay { + position: absolute; + bottom: 0; + inset-inline-end: 0; + z-index: 1; + } +} + +.account__relationship { + white-space: nowrap; + display: flex; + align-items: center; + gap: 4px; +} + +.account__header__wrapper { + flex: 0 0 auto; + background: lighten($ui-base-color, 4%); +} + +.account__disclaimer { + padding: 10px; + color: $dark-text-color; + + strong { + font-weight: 500; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + + a { + font-weight: 500; + color: inherit; + text-decoration: underline; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + } +} + +.account__action-bar { + border-top: 1px solid lighten($ui-base-color, 8%); + border-bottom: 1px solid lighten($ui-base-color, 8%); + line-height: 36px; + overflow: hidden; + flex: 0 0 auto; + display: flex; +} + +.account__action-bar-links { + display: flex; + flex: 1 1 auto; + line-height: 18px; + text-align: center; +} + +.account__action-bar__tab { + text-decoration: none; + overflow: hidden; + flex: 0 1 100%; + border-inline-start: 1px solid lighten($ui-base-color, 8%); + padding: 10px 0; + border-bottom: 4px solid transparent; + + &:first-child { + border-inline-start: 0; + } + + &.active { + border-bottom: 4px solid $ui-highlight-color; + } + + & > span { + display: block; + text-transform: uppercase; + font-size: 11px; + color: $darker-text-color; + } + + strong { + display: block; + font-size: 15px; + font-weight: 500; + color: $primary-text-color; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + + abbr { + color: $highlight-text-color; + } +} + +.account-authorize { + padding: 14px 10px; + + .detailed-status__display-name { + display: block; + margin-bottom: 15px; + overflow: hidden; + } +} + +.account-authorize__avatar { + float: left; + margin-inline-end: 10px; +} + +.notification__report { + padding: 8px 10px; + padding-inline-start: 68px; + position: relative; + border-bottom: 1px solid lighten($ui-base-color, 8%); + min-height: 54px; + + &__details { + display: flex; + justify-content: space-between; + align-items: center; + color: $darker-text-color; + font-size: 15px; + line-height: 22px; + + strong { + font-weight: 500; + } + } + + &__avatar { + position: absolute; + inset-inline-start: 10px; + top: 10px; + } +} + +.notification__message { + margin-inline-start: 42px; + padding-top: 8px; + padding-inline-start: 26px; + cursor: default; + color: $darker-text-color; + font-size: 15px; + position: relative; + + .fa { + color: $highlight-text-color; + } + + > span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.account--panel { + background: lighten($ui-base-color, 4%); + border-top: 1px solid lighten($ui-base-color, 8%); + border-bottom: 1px solid lighten($ui-base-color, 8%); + display: flex; + flex-direction: row; + padding: 10px 0; +} + +.account--panel__button, +.detailed-status__button { + flex: 1 1 auto; + text-align: center; +} + +.detailed-status__button .emoji-button { + padding: 0; +} + +.relationship-tag { + color: $white; + margin-bottom: 4px; + display: block; + background-color: rgba($black, 0.45); + text-transform: uppercase; + font-size: 11px; + font-weight: 500; + padding: 4px; + border-radius: 4px; + opacity: 0.7; + + &:hover { + opacity: 1; + } +} + +.account-gallery__container { + display: flex; + flex-wrap: wrap; + padding: 4px 2px; +} + +.account-gallery__item { + border: 0; + box-sizing: border-box; + display: block; + position: relative; + border-radius: 4px; + overflow: hidden; + margin: 2px; + + &__icons { + position: absolute; + top: 50%; + inset-inline-start: 50%; + transform: translate(-50%, -50%); + font-size: 24px; + } +} + +.notification__filter-bar, +.account__section-headline { + background: darken($ui-base-color, 4%); + border-bottom: 1px solid lighten($ui-base-color, 8%); + cursor: default; + display: flex; + flex-shrink: 0; + + button { + background: transparent; + border: 0; + margin: 0; + } + + button, + a { + display: block; + flex: 1 1 auto; + color: $darker-text-color; + padding: 15px 0; + font-size: 14px; + font-weight: 500; + text-align: center; + text-decoration: none; + position: relative; + + &.active { + color: $primary-text-color; + + &::before { + display: block; + content: ''; + position: absolute; + bottom: -1px; + left: 0; + width: 100%; + height: 3px; + border-radius: 4px; + background: $highlight-text-color; + } + } + } + + &.directory__section-headline { + background: darken($ui-base-color, 2%); + border-bottom-color: transparent; + + a, + button { + &.active { + &::before { + display: none; + } + + &::after { + border-color: transparent transparent darken($ui-base-color, 7%); + } + } + } + } +} + +.account__moved-note { + padding: 14px 10px; + padding-bottom: 16px; + background: lighten($ui-base-color, 4%); + border-top: 1px solid lighten($ui-base-color, 8%); + border-bottom: 1px solid lighten($ui-base-color, 8%); + + &__message { + position: relative; + margin-inline-start: 58px; + color: $dark-text-color; + padding: 8px 0; + padding-top: 0; + padding-bottom: 4px; + font-size: 14px; + + > span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + } + } + + &__icon-wrapper { + inset-inline-start: -26px; + position: absolute; + } + + .detailed-status__display-avatar { + position: relative; + } + + .detailed-status__display-name { + margin-bottom: 0; + } +} + +.account__header__content { + color: $darker-text-color; + font-size: 14px; + font-weight: 400; + overflow: hidden; + word-break: normal; + word-wrap: break-word; + + p { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + } + + a { + color: inherit; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } +} + +.account__header { + overflow: hidden; + + &.inactive { + opacity: 0.5; + + .account__header__image, + .account__avatar { + filter: grayscale(100%); + } + } + + &__info { + position: absolute; + top: 10px; + inset-inline-start: 10px; + } + + &__image { + overflow: hidden; + height: 145px; + position: relative; + background: darken($ui-base-color, 4%); + + img { + object-fit: cover; + display: block; + width: 100%; + height: 100%; + margin: 0; + } + } + + &__bar { + position: relative; + background: lighten($ui-base-color, 4%); + padding: 5px; + border-bottom: 1px solid lighten($ui-base-color, 12%); + + .avatar { + display: block; + flex: 0 0 auto; + width: 94px; + + .account__avatar { + background: darken($ui-base-color, 8%); + border: 2px solid lighten($ui-base-color, 4%); + } + } + } + + &__tabs { + display: flex; + align-items: flex-end; + justify-content: space-between; + padding: 7px 10px; + margin-top: -81px; + height: 130px; + overflow: hidden; + margin-inline-start: -2px; // aligns the pfp with content below + + .account-role { + margin: 0 2px; + padding: 4px 0; + box-sizing: border-box; + min-width: 90px; + text-align: center; + } + + &__buttons { + display: flex; + align-items: center; + gap: 8px; + padding-top: 55px; + overflow: hidden; + + .button { + flex-shrink: 1; + white-space: nowrap; + + @media screen and (max-width: $no-gap-breakpoint) { + min-width: 0; + } + } + + .icon-button { + border: 1px solid lighten($ui-base-color, 12%); + border-radius: 4px; + box-sizing: content-box; + padding: 2px; + } + } + + &__name { + padding: 5px 10px; + + .account-role { + vertical-align: top; + } + + .emojione { + width: 22px; + height: 22px; + } + + h1 { + font-size: 16px; + line-height: 24px; + color: $primary-text-color; + font-weight: 500; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + small { + display: block; + font-size: 14px; + color: $darker-text-color; + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + + span { + user-select: all; + } + } + } + } + + .spacer { + flex: 1 1 auto; + } + } + + &__bio { + overflow: hidden; + margin: 0 -5px; + + .account__header__content { + padding: 20px 15px; + padding-bottom: 5px; + color: $primary-text-color; + } + + .account__header__joined { + font-size: 14px; + padding: 5px 15px; + color: $darker-text-color; + + .columns-area--mobile & { + padding-inline-start: 20px; + padding-inline-end: 20px; + } + } + + .account__header__fields { + margin: 0; + border-top: 1px solid lighten($ui-base-color, 12%); + + a { + color: lighten($ui-highlight-color, 8%); + } + + dl:first-child .verified { + border-radius: 0 4px 0 0; + } + + .verified a { + color: $valid-value-color; + } + } + } + + &__extra { + margin-top: 4px; + + &__links { + font-size: 14px; + color: $darker-text-color; + padding: 10px 0; + + a { + display: inline-block; + color: $darker-text-color; + text-decoration: none; + padding: 5px 10px; + font-weight: 500; + + strong { + font-weight: 700; + color: $primary-text-color; + } + } + } + } + + &__account-note { + margin: 0 -5px; + padding: 10px 15px; + display: flex; + flex-direction: column; + font-size: 14px; + font-weight: 400; + border-top: 1px solid lighten($ui-base-color, 12%); + + label { + display: block; + font-size: 12px; + font-weight: 500; + color: $darker-text-color; + text-transform: uppercase; + margin-bottom: 5px; + } + + &__content { + white-space: pre-wrap; + padding: 10px 0; + } + + strong { + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + } + + textarea { + display: block; + box-sizing: border-box; + width: calc(100% + 20px); + color: $secondary-text-color; + background: $ui-base-color; + padding: 10px; + margin: 0 -10px; + font-family: inherit; + font-size: 14px; + resize: none; + border: 0; + outline: 0; + border-radius: 4px; + + &::placeholder { + color: $dark-text-color; + opacity: 1; + } + } + } +} + +.account__contents { + overflow: hidden; +} + +.account__details { + display: flex; + flex-wrap: wrap; + column-gap: 1em; +} + +.verified-badge { + display: inline-flex; + align-items: center; + color: $valid-value-color; + gap: 4px; + overflow: hidden; + white-space: nowrap; + + > span { + overflow: hidden; + text-overflow: ellipsis; + } + + a { + color: inherit; + font-weight: 500; + text-decoration: none; + } +} + +.moved-account-banner, +.follow-request-banner, +.account-memorial-banner { + padding: 20px; + background: lighten($ui-base-color, 4%); + display: flex; + align-items: center; + flex-direction: column; + + &__message { + color: $darker-text-color; + padding: 8px 0; + padding-top: 0; + padding-bottom: 4px; + font-size: 14px; + font-weight: 500; + text-align: center; + margin-bottom: 16px; + } + + &__action { + display: flex; + justify-content: space-between; + align-items: center; + gap: 15px; + width: 100%; + } + + .detailed-status__display-name { + margin-bottom: 0; + } +} + +.follow-request-banner .button { + width: 100%; +} + +.account-memorial-banner__message { + margin-bottom: 0; +} diff --git a/app/javascript/flavours/blobfox/styles/components/announcements.scss b/app/javascript/flavours/blobfox/styles/components/announcements.scss new file mode 100644 index 00000000000000..be27120a7d21ce --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/announcements.scss @@ -0,0 +1,233 @@ +.announcements__item__content { + word-wrap: break-word; + overflow-y: auto; + + .emojione { + width: 20px; + height: 20px; + margin: -3px 0 0; + } + + p { + margin-bottom: 10px; + white-space: pre-wrap; + + &:last-child { + margin-bottom: 0; + } + } + + a { + color: $secondary-text-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + + &.mention { + &:hover { + text-decoration: none; + + span { + text-decoration: underline; + } + } + } + + &.unhandled-link { + color: $highlight-text-color; + } + } +} + +.announcements { + background: lighten($ui-base-color, 8%); + font-size: 13px; + display: flex; + align-items: flex-end; + + &__mastodon { + width: 124px; + flex: 0 0 auto; + + @media screen and (max-width: 124px + 300px) { + display: none; + } + } + + &__container { + width: calc(100% - 124px); + flex: 0 0 auto; + position: relative; + + @media screen and (max-width: 124px + 300px) { + width: 100%; + } + } + + &__item { + box-sizing: border-box; + width: 100%; + padding: 15px; + position: relative; + font-size: 15px; + line-height: 20px; + word-wrap: break-word; + font-weight: 400; + max-height: 50vh; + overflow: hidden; + display: flex; + flex-direction: column; + + &__range { + display: block; + font-weight: 500; + margin-bottom: 10px; + padding-inline-end: 18px; + } + + &__unread { + position: absolute; + top: 19px; + inset-inline-end: 19px; + display: block; + background: $highlight-text-color; + border-radius: 50%; + width: 0.625rem; + height: 0.625rem; + } + } + + &__pagination { + padding: 15px; + color: $darker-text-color; + position: absolute; + bottom: 3px; + inset-inline-end: 0; + } +} + +.layout-multiple-columns .announcements__mastodon { + display: none; +} + +.layout-multiple-columns .announcements__container { + width: 100%; +} + +.reactions-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + margin-top: 15px; + margin-inline-start: -2px; + width: calc(100% - (90px - 33px)); + + &__item { + flex-shrink: 0; + background: lighten($ui-base-color, 12%); + border: 0; + border-radius: 3px; + margin: 2px; + cursor: pointer; + user-select: none; + padding: 0 6px; + display: flex; + align-items: center; + transition: all 100ms ease-in; + transition-property: background-color, color; + + &__emoji { + display: block; + margin: 3px 0; + width: 16px; + height: 16px; + + img { + display: block; + margin: 0; + width: 100%; + height: 100%; + min-width: auto; + min-height: auto; + vertical-align: bottom; + object-fit: contain; + } + } + + &__count { + display: block; + min-width: 9px; + font-size: 13px; + font-weight: 500; + text-align: center; + margin-inline-start: 6px; + color: $darker-text-color; + } + + &:hover, + &:focus, + &:active { + background: lighten($ui-base-color, 16%); + transition: all 200ms ease-out; + transition-property: background-color, color; + + &__count { + color: lighten($darker-text-color, 4%); + } + } + + &.active { + transition: all 100ms ease-in; + transition-property: background-color, color; + background-color: mix( + lighten($ui-base-color, 12%), + $ui-highlight-color, + 80% + ); + + .reactions-bar__item__count { + color: lighten($highlight-text-color, 8%); + } + } + } + + .emoji-picker-dropdown { + margin: 2px; + } + + &:hover .emoji-button { + opacity: 0.85; + } + + .emoji-button { + color: $darker-text-color; + margin: 0; + font-size: 16px; + width: auto; + flex-shrink: 0; + padding: 0 6px; + height: 22px; + display: flex; + align-items: center; + opacity: 0.5; + transition: all 100ms ease-in; + transition-property: background-color, color; + + &:hover, + &:active, + &:focus { + opacity: 1; + color: lighten($darker-text-color, 4%); + transition: all 200ms ease-out; + transition-property: background-color, color; + } + } + + &--empty { + .emoji-button { + padding: 0; + } + } +} diff --git a/app/javascript/flavours/blobfox/styles/components/boost.scss b/app/javascript/flavours/blobfox/styles/components/boost.scss new file mode 100644 index 00000000000000..2969958e227261 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/boost.scss @@ -0,0 +1,44 @@ +button.icon-button { + i.fa-retweet { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color($action-button-color)}' stroke-width='0'/><path d='M7.78 19.66c-.24.02-.44.25-.44.5v2.46h-.06c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v4.47c0 4.26-.56 3.62 3.65 3.62H8.5l-1.3-1.06c-.1-.08-.18-.2-.2-.3-.02-.17.06-.35.2-.45l1.33-1.1H7.28c-.44 0-.72-.3-.72-.7v-4.48c0-.44.28-.72.72-.72h.06v2.5c0 .38.54.63.82.38l4.9-3.93c.25-.18.25-.6 0-.78l-4.9-3.92c-.1-.1-.24-.14-.38-.12zm9.34 2.93c-.54-.02-1.3.02-2.4.02h-1.25l1.3 1.07c.1.07.18.2.2.33.02.16-.06.3-.2.4l-1.33 1.1h1.28c.42 0 .72.28.72.72v4.47c0 .42-.3.72-.72.72h-.1v-2.47c0-.3-.3-.53-.6-.47-.07 0-.14.05-.2.1l-4.9 3.93c-.26.18-.26.6 0 .78l4.9 3.92c.27.25.82 0 .8-.38v-2.5h.1c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.15.4-3.62-1.25-3.66zM10.34 38.66c-.24.02-.44.25-.43.5v2.47H7.3c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.47c0 3.66-.23 3.7 2.34 3.66l-1.34-1.1c-.1-.08-.18-.2-.2-.3 0-.17.07-.35.2-.45l1.96-1.6c-.03-.06-.04-.13-.04-.2v-4.48c0-.44.28-.72.72-.72H9.9v2.5c0 .36.5.6.8.38l4.93-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.08-.23-.13-.36-.12zm5.63 2.93l1.34 1.1c.1.07.18.2.2.33.02.16-.03.3-.16.4l-1.96 1.6c.02.07.06.13.06.22v4.47c0 .42-.3.72-.72.72h-2.66v-2.47c0-.3-.3-.53-.6-.47-.06.02-.12.05-.18.1l-4.94 3.93c-.24.18-.24.6 0 .78l4.94 3.92c.28.22.78-.02.78-.38v-2.5h2.66c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.66.34-3.7-2.4-3.66zM13.06 57.66c-.23.03-.4.26-.4.5v2.47H7.28c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.87l2.93-2.37v-2.5c0-.44.28-.72.72-.72h5.38v2.5c0 .36.5.6.78.38l4.94-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.1-.24-.14-.38-.12zm5.3 6.15l-2.92 2.4v2.52c0 .42-.3.72-.72.72h-5.4v-2.47c0-.3-.32-.53-.6-.47-.07.02-.13.05-.2.1L3.6 70.52c-.25.18-.25.6 0 .78l4.93 3.92c.28.22.78-.02.78-.38v-2.5h5.42c4.27 0 3.65.67 3.65-3.62v-4.47-.44zM19.25 78.8c-.1.03-.2.1-.28.17l-.9.9c-.44-.3-1.36-.25-3.35-.25H7.28c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v.7l2.93.3v-1c0-.44.28-.72.72-.72h7.44c.2 0 .37.08.5.2l-1.8 1.8c-.25.26-.08.76.27.8l6.27.7c.28.03.56-.25.53-.53l-.7-6.25c0-.27-.3-.48-.55-.44zm-17.2 6.1c-.2.07-.36.3-.33.54l.7 6.25c.02.36.58.55.83.27l.8-.8c.02 0 .04-.02.04 0 .46.24 1.37.17 3.18.17h7.44c4.27 0 3.65.67 3.65-3.62v-.75l-2.93-.3v1.05c0 .42-.3.72-.72.72H7.28c-.15 0-.3-.03-.4-.1L8.8 86.4c.3-.24.1-.8-.27-.84l-6.28-.65h-.2zM4.88 98.6c-1.33 0-1.34.48-1.3 2.3l1.14-1.37c.08-.1.22-.17.34-.2.16 0 .34.08.44.2l1.66 2.03c.04 0 .07-.03.12-.03h7.44c.34 0 .57.2.65.5h-2.43c-.34.05-.53.52-.3.78l3.92 4.95c.18.24.6.24.78 0l3.94-4.94c.22-.27-.02-.76-.37-.77H18.4c.02-3.9.6-3.4-3.66-3.4H7.28c-1.08 0-1.86-.04-2.4-.04zm.15 2.46c-.1.03-.2.1-.28.2l-3.94 4.9c-.2.28.03.77.4.78H3.6c-.02 3.94-.45 3.4 3.66 3.4h7.44c3.65 0 3.74.3 3.7-2.25l-1.1 1.34c-.1.1-.2.17-.32.2-.16 0-.34-.08-.44-.2l-1.65-2.03c-.06.02-.1.04-.18.04H7.28c-.35 0-.57-.2-.66-.5h2.44c.37 0 .63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.23-.47-.2zM4.88 117.6c-1.16 0-1.3.3-1.3 1.56l1.14-1.38c.08-.1.22-.14.34-.16.16 0 .34.04.44.16l2.22 2.75h7c.42 0 .72.28.72.72v.53h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-.53c0-4.2.72-3.63-3.66-3.63H7.28c-1.08 0-1.86-.03-2.4-.03zm.1 1.74c-.1.03-.17.1-.23.16L.8 124.44c-.2.28.03.77.4.78H3.6v.5c0 4.26-.55 3.62 3.66 3.62h7.44c1.03 0 1.74.02 2.28 0-.16.02-.34-.03-.44-.15l-2.22-2.76H7.28c-.44 0-.72-.3-.72-.72v-.5h2.5c.37.02.63-.5.4-.78L5.5 119.5c-.12-.15-.34-.22-.53-.16zm12.02 10c1.2-.02 1.4-.25 1.4-1.53l-1.1 1.36c-.07.1-.17.17-.3.18zM5.94 136.6l2.37 2.93h6.42c.42 0 .72.28.72.72v1.25h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.25c0-4.2.72-3.63-3.66-3.63H7.28c-.6 0-.92-.02-1.34-.03zm-1.72.06c-.4.08-.54.3-.6.75l.6-.74zm.84.93c-.12 0-.24.08-.3.18l-3.95 4.9c-.24.3 0 .83.4.82H3.6v1.22c0 4.26-.55 3.62 3.66 3.62h7.44c.63 0 .97.02 1.4.03l-2.37-2.93H7.28c-.44 0-.72-.3-.72-.72v-1.22h2.5c.4.04.67-.53.4-.8l-3.96-4.92c-.1-.13-.27-.2-.44-.2zm13.28 10.03l-.56.7c.36-.07.5-.3.56-.7zM17.13 155.6c-.55-.02-1.32.03-2.4.03h-8.2l2.38 2.9h5.82c.42 0 .72.28.72.72v1.97H12.9c-.32.06-.48.52-.28.78l3.94 4.94c.2.23.6.22.78-.03l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.97c0-3.15.4-3.62-1.25-3.66zm-12.1.28c-.1.02-.2.1-.28.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v1.96c0 4.26-.55 3.62 3.66 3.62h8.24l-2.36-2.9H7.28c-.44 0-.72-.3-.72-.72v-1.97h2.5c.37.02.63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.22-.47-.2zM5.13 174.5c-.15 0-.3.07-.38.2L.8 179.6c-.24.27 0 .82.4.8H3.6v2.32c0 4.26-.55 3.62 3.66 3.62h7.94l-2.35-2.9h-5.6c-.43 0-.7-.3-.7-.72v-2.3h2.5c.38.03.66-.54.4-.83l-3.97-4.9c-.1-.13-.23-.2-.38-.2zm12 .1c-.55-.02-1.32.03-2.4.03H6.83l2.35 2.9h5.52c.42 0 .72.28.72.72v2.34h-2.6c-.3.1-.43.53-.2.78l3.92 4.9c.18.24.6.24.78 0l3.94-4.9c.22-.3-.02-.78-.37-.8H18.4v-2.33c0-3.15.4-3.62-1.25-3.66zM4.97 193.16c-.1.03-.17.1-.22.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77l-3.96-4.9c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.03-2.4.03H7.1l2.32 2.84.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color($highlight-text-color)}' stroke-width='0'/></svg>"); + } + + &:hover i.fa-retweet { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color(lighten($action-button-color, 7%))}' stroke-width='0'/><path d='M7.78 19.66c-.24.02-.44.25-.44.5v2.46h-.06c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v4.47c0 4.26-.56 3.62 3.65 3.62H8.5l-1.3-1.06c-.1-.08-.18-.2-.2-.3-.02-.17.06-.35.2-.45l1.33-1.1H7.28c-.44 0-.72-.3-.72-.7v-4.48c0-.44.28-.72.72-.72h.06v2.5c0 .38.54.63.82.38l4.9-3.93c.25-.18.25-.6 0-.78l-4.9-3.92c-.1-.1-.24-.14-.38-.12zm9.34 2.93c-.54-.02-1.3.02-2.4.02h-1.25l1.3 1.07c.1.07.18.2.2.33.02.16-.06.3-.2.4l-1.33 1.1h1.28c.42 0 .72.28.72.72v4.47c0 .42-.3.72-.72.72h-.1v-2.47c0-.3-.3-.53-.6-.47-.07 0-.14.05-.2.1l-4.9 3.93c-.26.18-.26.6 0 .78l4.9 3.92c.27.25.82 0 .8-.38v-2.5h.1c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.15.4-3.62-1.25-3.66zM10.34 38.66c-.24.02-.44.25-.43.5v2.47H7.3c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.47c0 3.66-.23 3.7 2.34 3.66l-1.34-1.1c-.1-.08-.18-.2-.2-.3 0-.17.07-.35.2-.45l1.96-1.6c-.03-.06-.04-.13-.04-.2v-4.48c0-.44.28-.72.72-.72H9.9v2.5c0 .36.5.6.8.38l4.93-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.08-.23-.13-.36-.12zm5.63 2.93l1.34 1.1c.1.07.18.2.2.33.02.16-.03.3-.16.4l-1.96 1.6c.02.07.06.13.06.22v4.47c0 .42-.3.72-.72.72h-2.66v-2.47c0-.3-.3-.53-.6-.47-.06.02-.12.05-.18.1l-4.94 3.93c-.24.18-.24.6 0 .78l4.94 3.92c.28.22.78-.02.78-.38v-2.5h2.66c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.66.34-3.7-2.4-3.66zM13.06 57.66c-.23.03-.4.26-.4.5v2.47H7.28c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.87l2.93-2.37v-2.5c0-.44.28-.72.72-.72h5.38v2.5c0 .36.5.6.78.38l4.94-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.1-.24-.14-.38-.12zm5.3 6.15l-2.92 2.4v2.52c0 .42-.3.72-.72.72h-5.4v-2.47c0-.3-.32-.53-.6-.47-.07.02-.13.05-.2.1L3.6 70.52c-.25.18-.25.6 0 .78l4.93 3.92c.28.22.78-.02.78-.38v-2.5h5.42c4.27 0 3.65.67 3.65-3.62v-4.47-.44zM19.25 78.8c-.1.03-.2.1-.28.17l-.9.9c-.44-.3-1.36-.25-3.35-.25H7.28c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v.7l2.93.3v-1c0-.44.28-.72.72-.72h7.44c.2 0 .37.08.5.2l-1.8 1.8c-.25.26-.08.76.27.8l6.27.7c.28.03.56-.25.53-.53l-.7-6.25c0-.27-.3-.48-.55-.44zm-17.2 6.1c-.2.07-.36.3-.33.54l.7 6.25c.02.36.58.55.83.27l.8-.8c.02 0 .04-.02.04 0 .46.24 1.37.17 3.18.17h7.44c4.27 0 3.65.67 3.65-3.62v-.75l-2.93-.3v1.05c0 .42-.3.72-.72.72H7.28c-.15 0-.3-.03-.4-.1L8.8 86.4c.3-.24.1-.8-.27-.84l-6.28-.65h-.2zM4.88 98.6c-1.33 0-1.34.48-1.3 2.3l1.14-1.37c.08-.1.22-.17.34-.2.16 0 .34.08.44.2l1.66 2.03c.04 0 .07-.03.12-.03h7.44c.34 0 .57.2.65.5h-2.43c-.34.05-.53.52-.3.78l3.92 4.95c.18.24.6.24.78 0l3.94-4.94c.22-.27-.02-.76-.37-.77H18.4c.02-3.9.6-3.4-3.66-3.4H7.28c-1.08 0-1.86-.04-2.4-.04zm.15 2.46c-.1.03-.2.1-.28.2l-3.94 4.9c-.2.28.03.77.4.78H3.6c-.02 3.94-.45 3.4 3.66 3.4h7.44c3.65 0 3.74.3 3.7-2.25l-1.1 1.34c-.1.1-.2.17-.32.2-.16 0-.34-.08-.44-.2l-1.65-2.03c-.06.02-.1.04-.18.04H7.28c-.35 0-.57-.2-.66-.5h2.44c.37 0 .63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.23-.47-.2zM4.88 117.6c-1.16 0-1.3.3-1.3 1.56l1.14-1.38c.08-.1.22-.14.34-.16.16 0 .34.04.44.16l2.22 2.75h7c.42 0 .72.28.72.72v.53h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-.53c0-4.2.72-3.63-3.66-3.63H7.28c-1.08 0-1.86-.03-2.4-.03zm.1 1.74c-.1.03-.17.1-.23.16L.8 124.44c-.2.28.03.77.4.78H3.6v.5c0 4.26-.55 3.62 3.66 3.62h7.44c1.03 0 1.74.02 2.28 0-.16.02-.34-.03-.44-.15l-2.22-2.76H7.28c-.44 0-.72-.3-.72-.72v-.5h2.5c.37.02.63-.5.4-.78L5.5 119.5c-.12-.15-.34-.22-.53-.16zm12.02 10c1.2-.02 1.4-.25 1.4-1.53l-1.1 1.36c-.07.1-.17.17-.3.18zM5.94 136.6l2.37 2.93h6.42c.42 0 .72.28.72.72v1.25h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.25c0-4.2.72-3.63-3.66-3.63H7.28c-.6 0-.92-.02-1.34-.03zm-1.72.06c-.4.08-.54.3-.6.75l.6-.74zm.84.93c-.12 0-.24.08-.3.18l-3.95 4.9c-.24.3 0 .83.4.82H3.6v1.22c0 4.26-.55 3.62 3.66 3.62h7.44c.63 0 .97.02 1.4.03l-2.37-2.93H7.28c-.44 0-.72-.3-.72-.72v-1.22h2.5c.4.04.67-.53.4-.8l-3.96-4.92c-.1-.13-.27-.2-.44-.2zm13.28 10.03l-.56.7c.36-.07.5-.3.56-.7zM17.13 155.6c-.55-.02-1.32.03-2.4.03h-8.2l2.38 2.9h5.82c.42 0 .72.28.72.72v1.97H12.9c-.32.06-.48.52-.28.78l3.94 4.94c.2.23.6.22.78-.03l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.97c0-3.15.4-3.62-1.25-3.66zm-12.1.28c-.1.02-.2.1-.28.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v1.96c0 4.26-.55 3.62 3.66 3.62h8.24l-2.36-2.9H7.28c-.44 0-.72-.3-.72-.72v-1.97h2.5c.37.02.63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.22-.47-.2zM5.13 174.5c-.15 0-.3.07-.38.2L.8 179.6c-.24.27 0 .82.4.8H3.6v2.32c0 4.26-.55 3.62 3.66 3.62h7.94l-2.35-2.9h-5.6c-.43 0-.7-.3-.7-.72v-2.3h2.5c.38.03.66-.54.4-.83l-3.97-4.9c-.1-.13-.23-.2-.38-.2zm12 .1c-.55-.02-1.32.03-2.4.03H6.83l2.35 2.9h5.52c.42 0 .72.28.72.72v2.34h-2.6c-.3.1-.43.53-.2.78l3.92 4.9c.18.24.6.24.78 0l3.94-4.9c.22-.3-.02-.78-.37-.8H18.4v-2.33c0-3.15.4-3.62-1.25-3.66zM4.97 193.16c-.1.03-.17.1-.22.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77l-3.96-4.9c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.03-2.4.03H7.1l2.32 2.84.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color($highlight-text-color)}' stroke-width='0'/></svg>"); + } + + &.reblogPrivate { + i.fa-retweet { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' height='209' width='22'><path d='M 4.9707031 3.1503906 L 4.9707031 3.1601562 C 4.8707031 3.1901563 4.8 3.2598438 4.75 3.3398438 L 0.80078125 8.2402344 C 0.60078125 8.5402344 0.8292187 9.0190625 1.1992188 9.0390625 L 3.5996094 9.0390625 L 3.5996094 11.720703 C 3.5996094 15.980703 3.0497656 15.339844 7.2597656 15.339844 L 11.869141 15.339844 L 11.869141 14.119141 L 11.869141 13.523438 L 11.869141 12.441406 C 11.869141 12.441406 11.869141 12.439453 11.869141 12.439453 L 7.2695312 12.439453 C 6.8295312 12.439453 6.5507814 12.140703 6.5507812 11.720703 L 6.5507812 9.0195312 L 9.0507812 9.0195312 C 9.4207813 9.0495313 9.6792188 8.54 9.4492188 8.25 L 5.5 3.3496094 C 5.38 3.1796094 5.1607031 3.1003906 4.9707031 3.1503906 z M 17.150391 3.5800781 L 17.130859 3.5898438 C 16.580859 3.5698436 15.810469 3.609375 14.730469 3.609375 L 7.0996094 3.609375 L 9.4199219 6.4609375 L 9.4492188 6.5195312 L 14.699219 6.5195312 C 15.106887 6.5195312 15.397113 6.7872181 15.414062 7.2050781 C 15.738375 7.0991315 16.077769 7.0273437 16.435547 7.0273438 L 16.578125 7.0273438 C 17.24903 7.0273438 17.874081 7.2325787 18.400391 7.578125 L 18.400391 7.2402344 C 18.400391 4.0902344 18.800391 3.6200781 17.150391 3.5800781 z M 16.435547 8.0273438 C 15.143818 8.0273438 14.083984 9.0851838 14.083984 10.376953 L 14.083984 11.607422 L 13.570312 11.607422 C 13.375448 11.607422 13.210603 11.704118 13.119141 11.791016 C 13.027691 11.877916 12.983569 11.958238 12.951172 12.03125 C 12.886382 12.177277 12.867187 12.304789 12.867188 12.441406 L 12.867188 13.523438 L 12.867188 14.119141 L 12.867188 15.677734 L 12.867188 16.509766 L 13.570312 16.509766 L 19.472656 16.509766 L 20.173828 16.509766 L 20.173828 15.677734 L 20.173828 13.523438 L 20.173828 12.441406 C 20.173828 12.304794 20.156597 12.177281 20.091797 12.03125 C 20.059397 11.95824 20.015299 11.877916 19.923828 11.791016 C 19.832368 11.704116 19.667509 11.607422 19.472656 11.607422 L 18.927734 11.607422 L 18.927734 10.376953 C 18.927734 9.0851838 17.867902 8.0273438 16.576172 8.0273438 L 16.435547 8.0273438 z M 16.435547 9.2207031 L 16.576172 9.2207031 C 17.22782 9.2207031 17.734375 9.7251013 17.734375 10.376953 L 17.734375 11.607422 L 15.277344 11.607422 L 15.277344 10.376953 C 15.277344 9.7251013 15.7839 9.2207031 16.435547 9.2207031 z M 12.919922 9.9394531 C 12.559922 9.9594531 12.359141 10.480234 12.619141 10.740234 L 12.751953 10.904297 C 12.862211 10.870135 12.980058 10.842244 13.085938 10.802734 L 13.085938 10.378906 C 13.085938 10.228632 13.111295 10.084741 13.130859 9.9394531 L 12.919922 9.9394531 z M 19.882812 9.9394531 C 19.902378 10.084741 19.927734 10.228632 19.927734 10.378906 L 19.927734 10.791016 C 20.168811 10.875098 20.455966 10.916935 20.613281 11.066406 C 20.691227 11.140457 20.749315 11.223053 20.806641 11.302734 L 21.259766 10.740234 C 21.519766 10.460234 21.260625 9.9094531 20.890625 9.9394531 L 19.882812 9.9394531 z M 16.435547 10.220703 C 16.301234 10.220703 16.277344 10.244432 16.277344 10.378906 L 16.277344 10.607422 L 16.734375 10.607422 L 16.734375 10.378906 C 16.734375 10.244433 16.712442 10.220703 16.578125 10.220703 L 16.435547 10.220703 z ' fill='#{hex-color($action-button-color)}' stroke-width='0'/><path d='M 7.7792969 19.650391 L 7.7792969 19.660156 C 7.5392969 19.680156 7.3398437 19.910156 7.3398438 20.160156 L 7.3398438 22.619141 L 7.2792969 22.619141 C 6.1992969 22.619141 5.4208594 22.589844 4.8808594 22.589844 C 3.2408594 22.589844 3.6308594 23.020234 3.6308594 26.240234 L 3.6308594 30.710938 C 3.6308594 34.970937 3.0692969 34.330078 7.2792969 34.330078 L 8.5 34.330078 L 7.1992188 33.269531 C 7.0992188 33.189531 7.02 33.070703 7 32.970703 C 6.98 32.800703 7.0592186 32.619531 7.1992188 32.519531 L 8.5292969 31.419922 L 7.2792969 31.419922 C 6.8392969 31.419922 6.5605469 31.120703 6.5605469 30.720703 L 6.5605469 26.240234 C 6.5605469 25.800234 6.8392969 25.519531 7.2792969 25.519531 L 7.3398438 25.519531 L 7.3398438 28.019531 C 7.3398438 28.399531 7.8801564 28.650391 8.1601562 28.400391 L 13.060547 24.470703 C 13.310547 24.290703 13.310547 23.869453 13.060547 23.689453 L 8.1601562 19.769531 C 8.0601563 19.669531 7.9192969 19.630391 7.7792969 19.650391 z M 17.119141 22.580078 L 17.119141 22.589844 C 16.579141 22.569844 15.820703 22.609375 14.720703 22.609375 L 13.470703 22.609375 L 14.769531 23.679688 C 14.869531 23.749688 14.950703 23.879766 14.970703 24.009766 C 14.990703 24.169766 14.909531 24.310156 14.769531 24.410156 L 13.439453 25.509766 L 14.720703 25.509766 C 15.129702 25.509766 15.41841 25.778986 15.433594 26.199219 C 15.752266 26.097283 16.084896 26.027344 16.435547 26.027344 L 16.578125 26.027344 C 17.236645 26.027344 17.848901 26.228565 18.369141 26.5625 L 18.369141 26.240234 C 18.369141 23.090234 18.769141 22.620078 17.119141 22.580078 z M 16.435547 27.027344 C 15.143818 27.027344 14.083984 28.085184 14.083984 29.376953 L 14.083984 30.607422 L 13.570312 30.607422 C 13.375452 30.607422 13.210603 30.704118 13.119141 30.791016 C 13.027691 30.877916 12.983569 30.958238 12.951172 31.03125 C 12.886382 31.177277 12.867184 31.304789 12.867188 31.441406 L 12.867188 32.523438 L 12.867188 33.119141 L 12.867188 34.677734 L 12.867188 35.509766 L 13.570312 35.509766 L 19.472656 35.509766 L 20.173828 35.509766 L 20.173828 34.677734 L 20.173828 32.523438 L 20.173828 31.441406 C 20.173828 31.304794 20.156597 31.177281 20.091797 31.03125 C 20.059397 30.95824 20.015299 30.877916 19.923828 30.791016 C 19.832368 30.704116 19.667509 30.607422 19.472656 30.607422 L 18.927734 30.607422 L 18.927734 29.376953 C 18.927734 28.085184 17.867902 27.027344 16.576172 27.027344 L 16.435547 27.027344 z M 16.435547 28.220703 L 16.576172 28.220703 C 17.22782 28.220703 17.734375 28.725101 17.734375 29.376953 L 17.734375 30.607422 L 15.277344 30.607422 L 15.277344 29.376953 C 15.277344 28.725101 15.7839 28.220703 16.435547 28.220703 z M 13.109375 29.150391 L 8.9199219 32.509766 C 8.6599219 32.689766 8.6599219 33.109063 8.9199219 33.289062 L 11.869141 35.648438 L 11.869141 34.677734 L 11.869141 33.119141 L 11.869141 32.523438 L 11.869141 31.441406 C 11.869141 31.217489 11.912641 30.907486 12.037109 30.626953 C 12.093758 30.499284 12.228597 30.257492 12.429688 30.066406 C 12.580253 29.92335 12.859197 29.887344 13.085938 29.802734 L 13.085938 29.378906 C 13.085938 29.300761 13.104 29.227272 13.109375 29.150391 z M 16.435547 29.220703 C 16.301234 29.220703 16.277344 29.244432 16.277344 29.378906 L 16.277344 29.607422 L 16.734375 29.607422 L 16.734375 29.378906 C 16.734375 29.244433 16.712442 29.220703 16.578125 29.220703 L 16.435547 29.220703 z M 12.943359 36.509766 L 13.820312 37.210938 C 14.090314 37.460938 14.639141 37.210078 14.619141 36.830078 L 14.619141 36.509766 L 13.570312 36.509766 L 12.943359 36.509766 z M 10.330078 38.650391 L 10.339844 38.660156 C 10.099844 38.680156 9.9001562 38.910156 9.9101562 39.160156 L 9.9101562 41.630859 L 7.3007812 41.630859 C 6.2207812 41.630859 5.4403906 41.589844 4.9003906 41.589844 C 3.2603906 41.589844 3.6503906 42.020234 3.6503906 45.240234 L 3.6503906 49.710938 C 3.6503906 53.370936 3.4202344 53.409141 5.9902344 53.369141 L 4.6503906 52.269531 C 4.5503906 52.189531 4.4692187 52.070703 4.4492188 51.970703 C 4.4492188 51.800703 4.5203906 51.619531 4.6503906 51.519531 L 6.609375 49.919922 C 6.579375 49.859922 6.5703125 49.790703 6.5703125 49.720703 L 6.5703125 45.240234 C 6.5703125 44.800234 6.8490625 44.519531 7.2890625 44.519531 L 9.9003906 44.519531 L 9.9003906 47.019531 C 9.9003906 47.379531 10.399219 47.620391 10.699219 47.400391 L 15.630859 43.470703 C 15.870859 43.290703 15.870859 42.869453 15.630859 42.689453 L 10.689453 38.769531 C 10.589453 38.689531 10.460078 38.640391 10.330078 38.650391 z M 16.869141 41.585938 C 16.616211 41.581522 16.322969 41.584844 15.980469 41.589844 L 15.970703 41.589844 L 17.310547 42.689453 C 17.410547 42.759453 17.489766 42.889531 17.509766 43.019531 C 17.529766 43.179531 17.479609 43.319922 17.349609 43.419922 L 15.390625 45.019531 C 15.406724 45.075878 15.427133 45.132837 15.4375 45.197266 C 15.754974 45.096169 16.086404 45.027344 16.435547 45.027344 L 16.578125 45.027344 C 17.24129 45.027344 17.858323 45.230088 18.380859 45.568359 L 18.380859 45.25 C 18.380859 42.0475 18.639648 41.616836 16.869141 41.585938 z M 16.435547 46.027344 C 15.143818 46.027344 14.083984 47.085184 14.083984 48.376953 L 14.083984 49.607422 L 13.570312 49.607422 C 13.375448 49.607422 13.210603 49.704118 13.119141 49.791016 C 13.027691 49.877916 12.983569 49.958238 12.951172 50.03125 C 12.886382 50.177277 12.867187 50.304789 12.867188 50.441406 L 12.867188 51.523438 L 12.867188 52.119141 L 12.867188 53.677734 L 12.867188 54.509766 L 13.570312 54.509766 L 19.472656 54.509766 L 20.173828 54.509766 L 20.173828 53.677734 L 20.173828 51.523438 L 20.173828 50.441406 C 20.173828 50.304794 20.156597 50.177281 20.091797 50.03125 C 20.059397 49.95824 20.015299 49.877916 19.923828 49.791016 C 19.832368 49.704116 19.667509 49.607422 19.472656 49.607422 L 18.927734 49.607422 L 18.927734 48.376953 C 18.927734 47.085184 17.867902 46.027344 16.576172 46.027344 L 16.435547 46.027344 z M 16.435547 47.220703 L 16.576172 47.220703 C 17.22782 47.220703 17.734375 47.725101 17.734375 48.376953 L 17.734375 49.607422 L 15.277344 49.607422 L 15.277344 48.376953 C 15.277344 47.725101 15.7839 47.220703 16.435547 47.220703 z M 11.470703 47.490234 C 11.410703 47.510234 11.349063 47.539844 11.289062 47.589844 L 6.3496094 51.519531 C 6.1096094 51.699531 6.1096094 52.120781 6.3496094 52.300781 L 11.289062 56.220703 C 11.569064 56.440703 12.070312 56.199844 12.070312 55.839844 L 12.070312 55.509766 L 11.869141 55.509766 L 11.869141 53.677734 L 11.869141 52.119141 L 11.869141 51.523438 L 11.869141 50.441406 C 11.869141 50.217489 11.912641 49.907486 12.037109 49.626953 C 12.043809 49.611855 12.061451 49.584424 12.070312 49.566406 L 12.070312 47.960938 C 12.070312 47.660938 11.770703 47.430234 11.470703 47.490234 z M 16.435547 48.220703 C 16.301234 48.220703 16.277344 48.244432 16.277344 48.378906 L 16.277344 48.607422 L 16.734375 48.607422 L 16.734375 48.378906 C 16.734375 48.244433 16.712442 48.220703 16.578125 48.220703 L 16.435547 48.220703 z M 13.060547 57.650391 L 13.060547 57.660156 C 12.830547 57.690156 12.660156 57.920156 12.660156 58.160156 L 12.660156 60.630859 L 7.2792969 60.630859 C 6.1992969 60.630859 5.4208594 60.589844 4.8808594 60.589844 C 3.2408594 60.589844 3.6308594 61.020234 3.6308594 64.240234 L 3.6308594 69.109375 L 6.5605469 66.740234 L 6.5605469 64.240234 C 6.5605469 63.800234 6.8392969 63.519531 7.2792969 63.519531 L 12.660156 63.519531 L 12.660156 66.019531 C 12.660156 66.299799 12.960394 66.500006 13.226562 66.474609 C 13.625751 65.076914 14.904956 64.035678 16.421875 64.029297 L 18.380859 62.470703 C 18.620859 62.290703 18.620859 61.869453 18.380859 61.689453 L 13.439453 57.769531 C 13.339453 57.669531 13.200547 57.630391 13.060547 57.650391 z M 18.359375 63.810547 L 17.800781 64.269531 C 18.004793 64.350836 18.198411 64.450249 18.380859 64.568359 L 18.380859 64.25 L 18.380859 63.810547 L 18.359375 63.810547 z M 16.435547 65.027344 C 15.143818 65.027344 14.083984 66.085184 14.083984 67.376953 L 14.083984 68.607422 L 13.570312 68.607422 C 13.375448 68.607422 13.210603 68.704118 13.119141 68.791016 C 13.027691 68.877916 12.983569 68.958238 12.951172 69.03125 C 12.886382 69.177277 12.867187 69.304789 12.867188 69.441406 L 12.867188 70.523438 L 12.867188 71.119141 L 12.867188 72.677734 L 12.867188 73.509766 L 13.570312 73.509766 L 19.472656 73.509766 L 20.173828 73.509766 L 20.173828 72.677734 L 20.173828 70.523438 L 20.173828 69.441406 C 20.173828 69.304794 20.156597 69.177281 20.091797 69.03125 C 20.059397 68.95824 20.015299 68.877916 19.923828 68.791016 C 19.832368 68.704116 19.667509 68.607422 19.472656 68.607422 L 18.927734 68.607422 L 18.927734 67.376953 C 18.927734 66.085184 17.867902 65.027344 16.576172 65.027344 L 16.435547 65.027344 z M 16.435547 66.220703 L 16.576172 66.220703 C 17.22782 66.220703 17.734375 66.725101 17.734375 67.376953 L 17.734375 68.607422 L 15.277344 68.607422 L 15.277344 67.376953 C 15.277344 66.725101 15.7839 66.220703 16.435547 66.220703 z M 8.7207031 66.509766 C 8.6507031 66.529766 8.5895312 66.559375 8.5195312 66.609375 L 3.5996094 70.519531 C 3.3496094 70.699531 3.3496094 71.120781 3.5996094 71.300781 L 8.5292969 75.220703 C 8.8092969 75.440703 9.3105469 75.199844 9.3105469 74.839844 L 9.3105469 72.339844 L 11.869141 72.339844 L 11.869141 71.119141 L 11.869141 70.523438 L 11.869141 69.449219 L 9.3203125 69.449219 L 9.3203125 66.980469 C 9.3203125 66.680469 9.0007031 66.449766 8.7207031 66.509766 z M 16.435547 67.220703 C 16.301234 67.220703 16.277344 67.244432 16.277344 67.378906 L 16.277344 67.607422 L 16.734375 67.607422 L 16.734375 67.378906 C 16.734375 67.244433 16.712442 67.220703 16.578125 67.220703 L 16.435547 67.220703 z M 19.248047 78.800781 C 19.148558 78.831033 19.050295 78.90106 18.970703 78.970703 L 18.070312 79.869141 C 17.630312 79.569141 16.710703 79.619141 14.720703 79.619141 L 7.2792969 79.619141 C 6.1992969 79.619141 5.4208594 79.589844 4.8808594 79.589844 C 3.2408594 79.589844 3.6308594 80.020234 3.6308594 83.240234 L 3.6308594 83.939453 L 6.5605469 84.240234 L 6.5605469 83.240234 C 6.5605469 82.800234 6.8392969 82.519531 7.2792969 82.519531 L 14.720703 82.519531 C 14.920703 82.519531 15.090703 82.600703 15.220703 82.720703 L 13.419922 84.519531 C 13.279464 84.665607 13.281282 84.881022 13.363281 85.054688 C 13.880838 83.867655 15.067337 83.027344 16.435547 83.027344 L 16.578125 83.027344 C 18.290465 83.027344 19.703357 84.345788 19.890625 86.011719 L 19.960938 86.019531 C 20.240938 86.049531 20.520234 85.770234 20.490234 85.490234 L 19.789062 79.240234 C 19.789062 78.973661 19.498025 78.767523 19.25 78.800781 L 19.248047 78.800781 z M 16.435547 84.027344 C 15.143818 84.027344 14.083984 85.085184 14.083984 86.376953 L 14.083984 87.607422 L 13.570312 87.607422 C 13.375448 87.607422 13.210603 87.704118 13.119141 87.791016 C 13.027691 87.877916 12.983569 87.958238 12.951172 88.03125 C 12.886382 88.177277 12.867187 88.304789 12.867188 88.441406 L 12.867188 89.523438 L 12.867188 90.119141 L 12.867188 91.677734 L 12.867188 92.509766 L 13.570312 92.509766 L 19.472656 92.509766 L 20.173828 92.509766 L 20.173828 91.677734 L 20.173828 89.523438 L 20.173828 88.441406 C 20.173828 88.304794 20.156597 88.177281 20.091797 88.03125 C 20.059397 87.95824 20.015299 87.877916 19.923828 87.791016 C 19.832368 87.704116 19.667509 87.607422 19.472656 87.607422 L 18.927734 87.607422 L 18.927734 86.376953 C 18.927734 85.085184 17.867902 84.027344 16.576172 84.027344 L 16.435547 84.027344 z M 2.0507812 84.900391 C 1.8507824 84.970391 1.6907031 85.199453 1.7207031 85.439453 L 2.4199219 91.689453 C 2.4399219 92.049453 3 92.240929 3.25 91.960938 L 4.0507812 91.160156 C 4.0707812 91.160156 4.0898437 91.140156 4.0898438 91.160156 C 4.5498437 91.400156 5.4595313 91.330078 7.2695312 91.330078 L 11.869141 91.330078 L 11.869141 90.119141 L 11.869141 89.523438 L 11.869141 88.441406 C 11.869141 88.437991 11.871073 88.433136 11.871094 88.429688 L 7.2792969 88.429688 C 7.1292969 88.429688 6.9808594 88.400078 6.8808594 88.330078 L 8.8007812 86.400391 C 9.1007822 86.160391 8.8992969 85.600547 8.5292969 85.560547 L 2.25 84.910156 L 2.0507812 84.910156 L 2.0507812 84.900391 z M 16.435547 85.220703 L 16.576172 85.220703 C 17.22782 85.220703 17.734375 85.725101 17.734375 86.376953 L 17.734375 87.607422 L 15.277344 87.607422 L 15.277344 86.376953 C 15.277344 85.725101 15.7839 85.220703 16.435547 85.220703 z M 4.8808594 98.599609 C 3.5508594 98.599609 3.5400781 99.080402 3.5800781 100.90039 L 4.7207031 99.529297 C 4.8007031 99.429297 4.9405469 99.360078 5.0605469 99.330078 C 5.2205469 99.330078 5.4 99.409297 5.5 99.529297 L 7.1601562 101.56055 C 7.2001563 101.56055 7.2292969 101.5293 7.2792969 101.5293 L 14.720703 101.5293 C 15.060703 101.5293 15.289141 101.7293 15.369141 102.0293 L 12.939453 102.0293 C 12.599453 102.0793 12.410625 102.55055 12.640625 102.81055 L 13.470703 103.85742 C 14.029941 102.77899 15.146801 102.02734 16.435547 102.02734 L 16.578125 102.02734 C 18.158418 102.02734 19.491598 103.14879 19.835938 104.63086 L 21.279297 102.82031 C 21.499297 102.55031 21.260156 102.06078 20.910156 102.05078 L 18.400391 102.05078 C 18.420391 98.150792 19.000234 98.650391 14.740234 98.650391 L 7.2792969 98.650391 C 6.1992969 98.650391 5.4208594 98.609375 4.8808594 98.609375 L 4.8808594 98.599609 z M 5.0292969 101.06055 C 4.9292969 101.09055 4.83 101.15977 4.75 101.25977 L 0.81054688 106.16016 C 0.61054688 106.44016 0.8409375 106.92945 1.2109375 106.93945 L 3.5996094 106.93945 C 3.5796094 110.87945 3.1497656 110.33984 7.2597656 110.33984 L 11.869141 110.33984 L 11.869141 109.11914 L 11.869141 108.52344 L 11.869141 107.44141 L 11.869141 107.43945 L 7.2792969 107.43945 C 6.9292969 107.43945 6.7091406 107.23945 6.6191406 106.93945 L 9.0605469 106.93945 C 9.4305469 106.93945 9.6909375 106.44016 9.4609375 106.16016 L 5.5 101.25977 C 5.4 101.10977 5.1992969 101.03055 5.0292969 101.06055 z M 16.435547 103.02734 C 15.143818 103.02734 14.083984 104.08518 14.083984 105.37695 L 14.083984 106.60742 L 13.570312 106.60742 C 13.375448 106.60742 13.210603 106.70409 13.119141 106.79102 C 13.027691 106.87792 12.983569 106.95823 12.951172 107.03125 C 12.886382 107.17727 12.867187 107.30479 12.867188 107.44141 L 12.867188 108.52344 L 12.867188 109.11914 L 12.867188 110.67773 L 12.867188 111.50977 L 13.570312 111.50977 L 19.472656 111.50977 L 20.173828 111.50977 L 20.173828 110.67773 L 20.173828 108.52344 L 20.173828 107.44141 C 20.173828 107.3048 20.156597 107.17728 20.091797 107.03125 C 20.059397 106.95825 20.015299 106.87792 19.923828 106.79102 C 19.832368 106.70412 19.667509 106.60742 19.472656 106.60742 L 18.927734 106.60742 L 18.927734 105.37695 C 18.927734 104.08518 17.867902 103.02734 16.576172 103.02734 L 16.435547 103.02734 z M 16.435547 104.2207 L 16.576172 104.2207 C 17.22782 104.2207 17.734375 104.7251 17.734375 105.37695 L 17.734375 106.60742 L 15.277344 106.60742 L 15.277344 105.37695 C 15.277344 104.7251 15.7839 104.2207 16.435547 104.2207 z M 16.435547 105.2207 C 16.301234 105.2207 16.277344 105.24444 16.277344 105.37891 L 16.277344 105.60742 L 16.734375 105.60742 L 16.734375 105.37891 C 16.734375 105.24441 16.712442 105.2207 16.578125 105.2207 L 16.435547 105.2207 z M 4.8808594 117.58984 L 4.8808594 117.59961 C 3.7208594 117.59961 3.5800781 117.90016 3.5800781 119.16016 L 4.7207031 117.7793 C 4.8007031 117.6793 4.9405469 117.63914 5.0605469 117.61914 C 5.2205469 117.61914 5.4 117.6593 5.5 117.7793 L 7.7207031 120.5293 L 14.720703 120.5293 C 15.123595 120.5293 15.408576 120.79174 15.431641 121.20117 C 15.750992 121.09876 16.08404 121.02734 16.435547 121.02734 L 16.578125 121.02734 C 17.24903 121.02734 17.874081 121.23262 18.400391 121.57812 L 18.400391 121.25 C 18.400391 117.05 19.120234 117.61914 14.740234 117.61914 L 7.2792969 117.61914 C 6.1992969 117.61914 5.4208594 117.58984 4.8808594 117.58984 z M 4.9804688 119.33984 C 4.8804688 119.36984 4.81 119.44 4.75 119.5 L 0.80078125 124.43945 C 0.60078125 124.71945 0.8292182 125.2107 1.1992188 125.2207 L 3.5996094 125.2207 L 3.5996094 125.7207 C 3.5996094 129.9807 3.0497656 129.33984 7.2597656 129.33984 L 11.869141 129.33984 L 11.869141 128.11914 L 11.869141 127.52344 L 11.869141 126.44141 C 11.869141 126.43799 11.871073 126.43314 11.871094 126.42969 L 7.2792969 126.42969 C 6.8392969 126.42969 6.5605469 126.13094 6.5605469 125.71094 L 6.5605469 125.21094 L 9.0605469 125.21094 C 9.4305469 125.23094 9.6909375 124.70969 9.4609375 124.42969 L 5.5 119.5 C 5.3820133 119.35252 5.1682348 119.28513 4.9804688 119.33984 z M 12.839844 121.7793 C 12.539844 121.8793 12.410625 122.32055 12.640625 122.56055 L 13.267578 123.34375 C 13.473522 122.72168 13.852237 122.1828 14.353516 121.7793 L 12.839844 121.7793 z M 18.658203 121.7793 C 19.393958 122.37155 19.878978 123.25738 19.916016 124.25781 L 21.279297 122.56055 C 21.499297 122.28055 21.260156 121.7893 20.910156 121.7793 L 18.658203 121.7793 z M 16.435547 122.02734 C 15.143818 122.02734 14.083984 123.08518 14.083984 124.37695 L 14.083984 125.60742 L 13.570312 125.60742 C 13.375448 125.60742 13.210603 125.70409 13.119141 125.79102 C 13.027691 125.87792 12.983569 125.95823 12.951172 126.03125 C 12.886382 126.17727 12.867187 126.30479 12.867188 126.44141 L 12.867188 127.52344 L 12.867188 128.11914 L 12.867188 129.67773 L 12.867188 130.50977 L 13.570312 130.50977 L 19.472656 130.50977 L 20.173828 130.50977 L 20.173828 129.67773 L 20.173828 127.52344 L 20.173828 126.44141 C 20.173828 126.3048 20.156597 126.17728 20.091797 126.03125 C 20.059397 125.95825 20.015299 125.87792 19.923828 125.79102 C 19.832368 125.70412 19.667509 125.60742 19.472656 125.60742 L 18.927734 125.60742 L 18.927734 124.37695 C 18.927734 123.08518 17.867902 122.02734 16.576172 122.02734 L 16.435547 122.02734 z M 16.435547 123.2207 L 16.576172 123.2207 C 17.22782 123.2207 17.734375 123.7251 17.734375 124.37695 L 17.734375 125.60742 L 15.277344 125.60742 L 15.277344 124.37695 C 15.277344 123.7251 15.7839 123.2207 16.435547 123.2207 z M 16.435547 124.2207 C 16.301234 124.2207 16.277344 124.24444 16.277344 124.37891 L 16.277344 124.60742 L 16.734375 124.60742 L 16.734375 124.37891 C 16.734375 124.24441 16.712442 124.2207 16.578125 124.2207 L 16.435547 124.2207 z M 5.9394531 136.58984 L 5.9394531 136.59961 L 8.3105469 139.5293 L 14.730469 139.5293 C 15.131912 139.5293 15.414551 139.79039 15.439453 140.19727 C 15.756409 140.09653 16.087055 140.02734 16.435547 140.02734 L 16.578125 140.02734 C 17.24903 140.02734 17.874081 140.23261 18.400391 140.57812 L 18.400391 140.25 C 18.400391 136.05 19.120234 136.61914 14.740234 136.61914 L 7.2792969 136.61914 C 6.6792969 136.61914 6.3594531 136.59984 5.9394531 136.58984 z M 4.2207031 136.66016 C 3.8207031 136.74016 3.6791406 136.96016 3.6191406 137.41016 L 4.2207031 136.66992 L 4.2207031 136.66016 z M 5.0605469 137.57031 L 5.0605469 137.58984 C 4.9405469 137.58984 4.8197656 137.66953 4.7597656 137.76953 L 0.81054688 142.66992 C 0.57054688 142.96992 0.8109375 143.50023 1.2109375 143.49023 L 3.5996094 143.49023 L 3.5996094 144.71094 C 3.5996094 148.97094 3.0497656 148.33008 7.2597656 148.33008 L 11.869141 148.33008 L 11.869141 147.11914 L 11.869141 146.52344 L 11.869141 145.44141 C 11.869141 145.43799 11.871073 145.43314 11.871094 145.42969 L 7.2792969 145.42969 C 6.8392969 145.42969 6.5605469 145.13094 6.5605469 144.71094 L 6.5605469 143.49023 L 9.0605469 143.49023 C 9.4605469 143.53023 9.7309375 142.95945 9.4609375 142.68945 L 5.5 137.76953 C 5.4 137.63953 5.2305469 137.57031 5.0605469 137.57031 z M 16.435547 141.02734 C 15.143818 141.02734 14.083984 142.08518 14.083984 143.37695 L 14.083984 144.60742 L 13.570312 144.60742 C 13.375448 144.60742 13.210603 144.70409 13.119141 144.79102 C 13.027691 144.87792 12.983569 144.95823 12.951172 145.03125 C 12.886382 145.17727 12.867187 145.30479 12.867188 145.44141 L 12.867188 146.52344 L 12.867188 147.11914 L 12.867188 148.67773 L 12.867188 149.50977 L 13.570312 149.50977 L 19.472656 149.50977 L 20.173828 149.50977 L 20.173828 148.67773 L 20.173828 146.52344 L 20.173828 145.44141 C 20.173828 145.3048 20.156597 145.17728 20.091797 145.03125 C 20.059397 144.95825 20.015299 144.87792 19.923828 144.79102 C 19.832368 144.70412 19.667509 144.60742 19.472656 144.60742 L 18.927734 144.60742 L 18.927734 143.37695 C 18.927734 142.08518 17.867902 141.02734 16.576172 141.02734 L 16.435547 141.02734 z M 12.849609 141.5 C 12.549609 141.6 12.420391 142.0393 12.650391 142.2793 L 13.136719 142.88672 C 13.213026 142.38119 13.390056 141.90696 13.667969 141.5 L 12.849609 141.5 z M 19.34375 141.5 C 19.710704 142.03735 19.927734 142.68522 19.927734 143.37891 L 19.927734 143.79102 C 19.965561 143.80421 20.005506 143.81448 20.044922 143.82617 L 21.289062 142.2793 C 21.509062 141.9993 21.269922 141.51 20.919922 141.5 L 19.34375 141.5 z M 16.435547 142.2207 L 16.576172 142.2207 C 17.22782 142.2207 17.734375 142.7251 17.734375 143.37695 L 17.734375 144.60742 L 15.277344 144.60742 L 15.277344 143.37695 C 15.277344 142.7251 15.7839 142.2207 16.435547 142.2207 z M 16.435547 143.2207 C 16.301234 143.2207 16.277344 143.24444 16.277344 143.37891 L 16.277344 143.60742 L 16.734375 143.60742 L 16.734375 143.37891 C 16.734375 143.24441 16.712442 143.2207 16.578125 143.2207 L 16.435547 143.2207 z M 17.130859 155.59961 C 16.580859 155.57961 15.810469 155.63086 14.730469 155.63086 L 6.5292969 155.63086 L 8.9101562 158.5293 L 14.730469 158.5293 C 15.131912 158.5293 15.414551 158.79039 15.439453 159.19727 C 15.756409 159.09653 16.087055 159.02734 16.435547 159.02734 L 16.578125 159.02734 C 17.24903 159.02734 17.874081 159.23261 18.400391 159.57812 L 18.400391 159.25977 C 18.400391 156.10977 18.800391 155.63961 17.150391 155.59961 L 17.130859 155.59961 z M 5.0292969 155.86914 L 5.0292969 155.88086 C 4.9292969 155.90086 4.83 155.98055 4.75 156.06055 L 0.81054688 160.96094 C 0.61054688 161.26094 0.8409375 161.73977 1.2109375 161.75977 L 3.5996094 161.75977 L 3.5996094 163.7207 C 3.5996094 167.9807 3.0497656 167.33984 7.2597656 167.33984 L 11.869141 167.33984 L 11.869141 166.11914 L 11.869141 165.52344 L 11.869141 164.44141 L 11.869141 164.43945 L 7.2792969 164.43945 C 6.8392969 164.43945 6.5605469 164.1407 6.5605469 163.7207 L 6.5605469 161.75 L 9.0605469 161.75 C 9.4305469 161.77 9.6909375 161.2507 9.4609375 160.9707 L 5.5 156.07031 C 5.4 155.92031 5.1992969 155.84914 5.0292969 155.86914 z M 16.435547 160.02734 C 15.143818 160.02734 14.083984 161.08518 14.083984 162.37695 L 14.083984 163.60742 L 13.570312 163.60742 C 13.375448 163.60742 13.210603 163.70409 13.119141 163.79102 C 13.027691 163.87792 12.983569 163.95823 12.951172 164.03125 C 12.886382 164.17727 12.867187 164.30479 12.867188 164.44141 L 12.867188 165.52344 L 12.867188 166.11914 L 12.867188 167.67773 L 12.867188 168.50977 L 13.570312 168.50977 L 19.472656 168.50977 L 20.173828 168.50977 L 20.173828 167.67773 L 20.173828 165.52344 L 20.173828 164.44141 C 20.173828 164.3048 20.156597 164.17728 20.091797 164.03125 C 20.059397 163.95825 20.015299 163.87792 19.923828 163.79102 C 19.832368 163.70412 19.667509 163.60742 19.472656 163.60742 L 18.927734 163.60742 L 18.927734 162.37695 C 18.927734 161.08518 17.867902 160.02734 16.576172 160.02734 L 16.435547 160.02734 z M 12.900391 161.2207 C 12.580391 161.2807 12.419141 161.74 12.619141 162 L 13.085938 162.58594 L 13.085938 162.37891 C 13.085938 161.97087 13.170592 161.58376 13.306641 161.2207 L 12.900391 161.2207 z M 16.435547 161.2207 L 16.576172 161.2207 C 17.22782 161.2207 17.734375 161.7251 17.734375 162.37695 L 17.734375 163.60742 L 15.277344 163.60742 L 15.277344 162.37695 C 15.277344 161.7251 15.7839 161.2207 16.435547 161.2207 z M 19.708984 161.23047 C 19.842743 161.59081 19.927734 161.97449 19.927734 162.37891 L 19.927734 162.79102 C 20.119162 162.85779 20.322917 162.91147 20.484375 163 L 21.279297 162.00977 C 21.499297 161.72977 21.260156 161.24047 20.910156 161.23047 L 19.708984 161.23047 z M 16.435547 162.2207 C 16.301234 162.2207 16.277344 162.24444 16.277344 162.37891 L 16.277344 162.60742 L 16.734375 162.60742 L 16.734375 162.37891 C 16.734375 162.24441 16.712442 162.2207 16.578125 162.2207 L 16.435547 162.2207 z M 5.0996094 174.49023 L 5.1308594 174.5 C 4.9808594 174.5 4.83 174.56922 4.75 174.69922 L 0.80078125 179.59961 C 0.56078125 179.86961 0.7992182 180.42039 1.1992188 180.40039 L 3.5996094 180.40039 L 3.5996094 182.7207 C 3.5996094 186.9807 3.0497656 186.33984 7.2597656 186.33984 L 11.869141 186.33984 L 11.869141 185.11914 L 11.869141 184.52344 L 11.869141 183.44141 L 11.869141 183.43945 L 7.25 183.43945 C 6.82 183.43945 6.5507814 183.1407 6.5507812 182.7207 L 6.5507812 180.41992 L 9.0507812 180.41992 C 9.4307824 180.44992 9.7092187 179.87984 9.4492188 179.58984 L 5.4804688 174.68945 C 5.3804688 174.55945 5.2496094 174.49023 5.0996094 174.49023 z M 17.150391 174.58008 L 17.130859 174.59961 C 16.580859 174.57961 15.810469 174.63086 14.730469 174.63086 L 6.8300781 174.63086 L 9.1796875 177.5293 L 14.699219 177.5293 C 15.104107 177.5293 15.391475 177.79407 15.412109 178.20703 C 15.737096 178.1006 16.076913 178.02734 16.435547 178.02734 L 16.578125 178.02734 C 17.24903 178.02734 17.874081 178.2326 18.400391 178.57812 L 18.400391 178.24023 C 18.400391 175.09023 18.800391 174.62008 17.150391 174.58008 z M 16.435547 179.02734 C 15.143818 179.02734 14.083984 180.08518 14.083984 181.37695 L 14.083984 182.60742 L 13.570312 182.60742 C 13.375448 182.60742 13.210603 182.70409 13.119141 182.79102 C 13.027691 182.87792 12.983569 182.95823 12.951172 183.03125 C 12.886382 183.17727 12.867187 183.30479 12.867188 183.44141 L 12.867188 184.52344 L 12.867188 185.11914 L 12.867188 186.67773 L 12.867188 187.50977 L 13.570312 187.50977 L 19.472656 187.50977 L 20.173828 187.50977 L 20.173828 186.67773 L 20.173828 184.52344 L 20.173828 183.44141 C 20.173828 183.3048 20.156597 183.17728 20.091797 183.03125 C 20.059397 182.95825 20.015299 182.87792 19.923828 182.79102 C 19.832368 182.70412 19.667509 182.60742 19.472656 182.60742 L 18.927734 182.60742 L 18.927734 181.37695 C 18.927734 180.08518 17.867902 179.02734 16.576172 179.02734 L 16.435547 179.02734 z M 16.435547 180.2207 L 16.576172 180.2207 C 17.22782 180.2207 17.734375 180.7251 17.734375 181.37695 L 17.734375 182.60742 L 15.277344 182.60742 L 15.277344 181.37695 C 15.277344 180.7251 15.7839 180.2207 16.435547 180.2207 z M 19.816406 180.57031 C 19.882311 180.83091 19.927734 181.09907 19.927734 181.37891 L 19.927734 181.79102 C 20.168811 181.87511 20.455966 181.91694 20.613281 182.06641 C 20.630645 182.0829 20.639883 182.10199 20.65625 182.11914 L 21.259766 181.36914 C 21.479766 181.06914 21.240625 180.59031 20.890625 180.57031 L 19.816406 180.57031 z M 12.820312 180.58984 C 12.520316 180.68984 12.389141 181.11914 12.619141 181.36914 L 12.990234 181.83203 C 13.022029 181.82207 13.055579 181.81406 13.085938 181.80273 L 13.085938 181.37891 C 13.085938 181.10616 13.128698 180.84442 13.191406 180.58984 L 12.820312 180.58984 z M 16.435547 181.2207 C 16.301234 181.2207 16.277344 181.24444 16.277344 181.37891 L 16.277344 181.60742 L 16.734375 181.60742 L 16.734375 181.37891 C 16.734375 181.24441 16.712442 181.2207 16.578125 181.2207 L 16.435547 181.2207 z M 4.9609375 193.15039 L 4.9707031 193.16016 C 4.8707031 193.19016 4.8 193.25984 4.75 193.33984 L 0.81054688 198.24023 C 0.61054688 198.54023 0.8409375 199.01906 1.2109375 199.03906 L 3.5996094 199.03906 L 3.5996094 201.7207 C 3.5996094 205.9807 3.0497656 205.33984 7.2597656 205.33984 L 11.869141 205.33984 L 11.869141 204.11914 L 11.869141 203.52344 L 11.869141 202.44141 C 11.869141 202.44141 11.869141 202.43945 11.869141 202.43945 L 7.2695312 202.43945 C 6.8295312 202.43945 6.5507814 202.1407 6.5507812 201.7207 L 6.5507812 199.01953 L 9.0507812 199.01953 C 9.4207814 199.04953 9.6792188 198.54 9.4492188 198.25 L 5.4902344 193.34961 C 5.3702344 193.17961 5.1509375 193.10039 4.9609375 193.15039 z M 17.150391 193.58008 L 17.130859 193.58984 C 16.580859 193.56984 15.810469 193.61914 14.730469 193.61914 L 7.0996094 193.61914 L 9.4199219 196.46094 L 9.4492188 196.51953 L 14.699219 196.51953 C 15.106887 196.51953 15.397075 196.78718 15.414062 197.20508 C 15.738375 197.09913 16.077769 197.02734 16.435547 197.02734 L 16.578125 197.02734 C 17.24903 197.02734 17.874081 197.23259 18.400391 197.57812 L 18.400391 197.24023 C 18.400391 194.09023 18.800391 193.62008 17.150391 193.58008 z M 16.435547 198.02734 C 15.143818 198.02734 14.083984 199.08518 14.083984 200.37695 L 14.083984 201.60742 L 13.570312 201.60742 C 13.375448 201.60742 13.210603 201.70409 13.119141 201.79102 C 13.027691 201.87792 12.983569 201.95823 12.951172 202.03125 C 12.886382 202.17727 12.867187 202.30479 12.867188 202.44141 L 12.867188 203.52344 L 12.867188 204.11914 L 12.867188 205.67773 L 12.867188 206.50977 L 13.570312 206.50977 L 19.472656 206.50977 L 20.173828 206.50977 L 20.173828 205.67773 L 20.173828 203.52344 L 20.173828 202.44141 C 20.173828 202.3048 20.156597 202.17728 20.091797 202.03125 C 20.059397 201.95825 20.015299 201.87792 19.923828 201.79102 C 19.832368 201.70412 19.667509 201.60742 19.472656 201.60742 L 18.927734 201.60742 L 18.927734 200.37695 C 18.927734 199.08518 17.867902 198.02734 16.576172 198.02734 L 16.435547 198.02734 z M 16.435547 199.2207 L 16.576172 199.2207 C 17.22782 199.2207 17.734375 199.7251 17.734375 200.37695 L 17.734375 201.60742 L 15.277344 201.60742 L 15.277344 200.37695 C 15.277344 199.7251 15.7839 199.2207 16.435547 199.2207 z M 12.919922 199.93945 C 12.559922 199.95945 12.359141 200.48023 12.619141 200.74023 L 12.751953 200.9043 C 12.862211 200.87013 12.980058 200.84224 13.085938 200.80273 L 13.085938 200.37891 C 13.085938 200.22863 13.111295 200.08474 13.130859 199.93945 L 12.919922 199.93945 z M 19.882812 199.93945 C 19.902378 200.08474 19.927734 200.22863 19.927734 200.37891 L 19.927734 200.79102 C 20.168811 200.87511 20.455966 200.91694 20.613281 201.06641 C 20.691227 201.14046 20.749315 201.22305 20.806641 201.30273 L 21.259766 200.74023 C 21.519766 200.46023 21.260625 199.90945 20.890625 199.93945 L 19.882812 199.93945 z M 16.435547 200.2207 C 16.301234 200.2207 16.277344 200.24444 16.277344 200.37891 L 16.277344 200.60742 L 16.734375 200.60742 L 16.734375 200.37891 C 16.734375 200.24441 16.712442 200.2207 16.578125 200.2207 L 16.435547 200.2207 z ' fill='#{hex-color($highlight-text-color)}' stroke-width='0' /></svg>"); + } + + &:hover i.fa-retweet { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' height='209' width='22'><path d='M 4.9707031 3.1503906 L 4.9707031 3.1601562 C 4.8707031 3.1901563 4.8 3.2598438 4.75 3.3398438 L 0.80078125 8.2402344 C 0.60078125 8.5402344 0.8292187 9.0190625 1.1992188 9.0390625 L 3.5996094 9.0390625 L 3.5996094 11.720703 C 3.5996094 15.980703 3.0497656 15.339844 7.2597656 15.339844 L 11.869141 15.339844 L 11.869141 14.119141 L 11.869141 13.523438 L 11.869141 12.441406 C 11.869141 12.441406 11.869141 12.439453 11.869141 12.439453 L 7.2695312 12.439453 C 6.8295312 12.439453 6.5507814 12.140703 6.5507812 11.720703 L 6.5507812 9.0195312 L 9.0507812 9.0195312 C 9.4207813 9.0495313 9.6792188 8.54 9.4492188 8.25 L 5.5 3.3496094 C 5.38 3.1796094 5.1607031 3.1003906 4.9707031 3.1503906 z M 17.150391 3.5800781 L 17.130859 3.5898438 C 16.580859 3.5698436 15.810469 3.609375 14.730469 3.609375 L 7.0996094 3.609375 L 9.4199219 6.4609375 L 9.4492188 6.5195312 L 14.699219 6.5195312 C 15.106887 6.5195312 15.397113 6.7872181 15.414062 7.2050781 C 15.738375 7.0991315 16.077769 7.0273437 16.435547 7.0273438 L 16.578125 7.0273438 C 17.24903 7.0273438 17.874081 7.2325787 18.400391 7.578125 L 18.400391 7.2402344 C 18.400391 4.0902344 18.800391 3.6200781 17.150391 3.5800781 z M 16.435547 8.0273438 C 15.143818 8.0273438 14.083984 9.0851838 14.083984 10.376953 L 14.083984 11.607422 L 13.570312 11.607422 C 13.375448 11.607422 13.210603 11.704118 13.119141 11.791016 C 13.027691 11.877916 12.983569 11.958238 12.951172 12.03125 C 12.886382 12.177277 12.867187 12.304789 12.867188 12.441406 L 12.867188 13.523438 L 12.867188 14.119141 L 12.867188 15.677734 L 12.867188 16.509766 L 13.570312 16.509766 L 19.472656 16.509766 L 20.173828 16.509766 L 20.173828 15.677734 L 20.173828 13.523438 L 20.173828 12.441406 C 20.173828 12.304794 20.156597 12.177281 20.091797 12.03125 C 20.059397 11.95824 20.015299 11.877916 19.923828 11.791016 C 19.832368 11.704116 19.667509 11.607422 19.472656 11.607422 L 18.927734 11.607422 L 18.927734 10.376953 C 18.927734 9.0851838 17.867902 8.0273438 16.576172 8.0273438 L 16.435547 8.0273438 z M 16.435547 9.2207031 L 16.576172 9.2207031 C 17.22782 9.2207031 17.734375 9.7251013 17.734375 10.376953 L 17.734375 11.607422 L 15.277344 11.607422 L 15.277344 10.376953 C 15.277344 9.7251013 15.7839 9.2207031 16.435547 9.2207031 z M 12.919922 9.9394531 C 12.559922 9.9594531 12.359141 10.480234 12.619141 10.740234 L 12.751953 10.904297 C 12.862211 10.870135 12.980058 10.842244 13.085938 10.802734 L 13.085938 10.378906 C 13.085938 10.228632 13.111295 10.084741 13.130859 9.9394531 L 12.919922 9.9394531 z M 19.882812 9.9394531 C 19.902378 10.084741 19.927734 10.228632 19.927734 10.378906 L 19.927734 10.791016 C 20.168811 10.875098 20.455966 10.916935 20.613281 11.066406 C 20.691227 11.140457 20.749315 11.223053 20.806641 11.302734 L 21.259766 10.740234 C 21.519766 10.460234 21.260625 9.9094531 20.890625 9.9394531 L 19.882812 9.9394531 z M 16.435547 10.220703 C 16.301234 10.220703 16.277344 10.244432 16.277344 10.378906 L 16.277344 10.607422 L 16.734375 10.607422 L 16.734375 10.378906 C 16.734375 10.244433 16.712442 10.220703 16.578125 10.220703 L 16.435547 10.220703 z ' fill='#{hex-color(lighten($action-button-color, 7%))}' stroke-width='0'/><path d='M 7.7792969 19.650391 L 7.7792969 19.660156 C 7.5392969 19.680156 7.3398437 19.910156 7.3398438 20.160156 L 7.3398438 22.619141 L 7.2792969 22.619141 C 6.1992969 22.619141 5.4208594 22.589844 4.8808594 22.589844 C 3.2408594 22.589844 3.6308594 23.020234 3.6308594 26.240234 L 3.6308594 30.710938 C 3.6308594 34.970937 3.0692969 34.330078 7.2792969 34.330078 L 8.5 34.330078 L 7.1992188 33.269531 C 7.0992188 33.189531 7.02 33.070703 7 32.970703 C 6.98 32.800703 7.0592186 32.619531 7.1992188 32.519531 L 8.5292969 31.419922 L 7.2792969 31.419922 C 6.8392969 31.419922 6.5605469 31.120703 6.5605469 30.720703 L 6.5605469 26.240234 C 6.5605469 25.800234 6.8392969 25.519531 7.2792969 25.519531 L 7.3398438 25.519531 L 7.3398438 28.019531 C 7.3398438 28.399531 7.8801564 28.650391 8.1601562 28.400391 L 13.060547 24.470703 C 13.310547 24.290703 13.310547 23.869453 13.060547 23.689453 L 8.1601562 19.769531 C 8.0601563 19.669531 7.9192969 19.630391 7.7792969 19.650391 z M 17.119141 22.580078 L 17.119141 22.589844 C 16.579141 22.569844 15.820703 22.609375 14.720703 22.609375 L 13.470703 22.609375 L 14.769531 23.679688 C 14.869531 23.749688 14.950703 23.879766 14.970703 24.009766 C 14.990703 24.169766 14.909531 24.310156 14.769531 24.410156 L 13.439453 25.509766 L 14.720703 25.509766 C 15.129702 25.509766 15.41841 25.778986 15.433594 26.199219 C 15.752266 26.097283 16.084896 26.027344 16.435547 26.027344 L 16.578125 26.027344 C 17.236645 26.027344 17.848901 26.228565 18.369141 26.5625 L 18.369141 26.240234 C 18.369141 23.090234 18.769141 22.620078 17.119141 22.580078 z M 16.435547 27.027344 C 15.143818 27.027344 14.083984 28.085184 14.083984 29.376953 L 14.083984 30.607422 L 13.570312 30.607422 C 13.375452 30.607422 13.210603 30.704118 13.119141 30.791016 C 13.027691 30.877916 12.983569 30.958238 12.951172 31.03125 C 12.886382 31.177277 12.867184 31.304789 12.867188 31.441406 L 12.867188 32.523438 L 12.867188 33.119141 L 12.867188 34.677734 L 12.867188 35.509766 L 13.570312 35.509766 L 19.472656 35.509766 L 20.173828 35.509766 L 20.173828 34.677734 L 20.173828 32.523438 L 20.173828 31.441406 C 20.173828 31.304794 20.156597 31.177281 20.091797 31.03125 C 20.059397 30.95824 20.015299 30.877916 19.923828 30.791016 C 19.832368 30.704116 19.667509 30.607422 19.472656 30.607422 L 18.927734 30.607422 L 18.927734 29.376953 C 18.927734 28.085184 17.867902 27.027344 16.576172 27.027344 L 16.435547 27.027344 z M 16.435547 28.220703 L 16.576172 28.220703 C 17.22782 28.220703 17.734375 28.725101 17.734375 29.376953 L 17.734375 30.607422 L 15.277344 30.607422 L 15.277344 29.376953 C 15.277344 28.725101 15.7839 28.220703 16.435547 28.220703 z M 13.109375 29.150391 L 8.9199219 32.509766 C 8.6599219 32.689766 8.6599219 33.109063 8.9199219 33.289062 L 11.869141 35.648438 L 11.869141 34.677734 L 11.869141 33.119141 L 11.869141 32.523438 L 11.869141 31.441406 C 11.869141 31.217489 11.912641 30.907486 12.037109 30.626953 C 12.093758 30.499284 12.228597 30.257492 12.429688 30.066406 C 12.580253 29.92335 12.859197 29.887344 13.085938 29.802734 L 13.085938 29.378906 C 13.085938 29.300761 13.104 29.227272 13.109375 29.150391 z M 16.435547 29.220703 C 16.301234 29.220703 16.277344 29.244432 16.277344 29.378906 L 16.277344 29.607422 L 16.734375 29.607422 L 16.734375 29.378906 C 16.734375 29.244433 16.712442 29.220703 16.578125 29.220703 L 16.435547 29.220703 z M 12.943359 36.509766 L 13.820312 37.210938 C 14.090314 37.460938 14.639141 37.210078 14.619141 36.830078 L 14.619141 36.509766 L 13.570312 36.509766 L 12.943359 36.509766 z M 10.330078 38.650391 L 10.339844 38.660156 C 10.099844 38.680156 9.9001562 38.910156 9.9101562 39.160156 L 9.9101562 41.630859 L 7.3007812 41.630859 C 6.2207812 41.630859 5.4403906 41.589844 4.9003906 41.589844 C 3.2603906 41.589844 3.6503906 42.020234 3.6503906 45.240234 L 3.6503906 49.710938 C 3.6503906 53.370936 3.4202344 53.409141 5.9902344 53.369141 L 4.6503906 52.269531 C 4.5503906 52.189531 4.4692187 52.070703 4.4492188 51.970703 C 4.4492188 51.800703 4.5203906 51.619531 4.6503906 51.519531 L 6.609375 49.919922 C 6.579375 49.859922 6.5703125 49.790703 6.5703125 49.720703 L 6.5703125 45.240234 C 6.5703125 44.800234 6.8490625 44.519531 7.2890625 44.519531 L 9.9003906 44.519531 L 9.9003906 47.019531 C 9.9003906 47.379531 10.399219 47.620391 10.699219 47.400391 L 15.630859 43.470703 C 15.870859 43.290703 15.870859 42.869453 15.630859 42.689453 L 10.689453 38.769531 C 10.589453 38.689531 10.460078 38.640391 10.330078 38.650391 z M 16.869141 41.585938 C 16.616211 41.581522 16.322969 41.584844 15.980469 41.589844 L 15.970703 41.589844 L 17.310547 42.689453 C 17.410547 42.759453 17.489766 42.889531 17.509766 43.019531 C 17.529766 43.179531 17.479609 43.319922 17.349609 43.419922 L 15.390625 45.019531 C 15.406724 45.075878 15.427133 45.132837 15.4375 45.197266 C 15.754974 45.096169 16.086404 45.027344 16.435547 45.027344 L 16.578125 45.027344 C 17.24129 45.027344 17.858323 45.230088 18.380859 45.568359 L 18.380859 45.25 C 18.380859 42.0475 18.639648 41.616836 16.869141 41.585938 z M 16.435547 46.027344 C 15.143818 46.027344 14.083984 47.085184 14.083984 48.376953 L 14.083984 49.607422 L 13.570312 49.607422 C 13.375448 49.607422 13.210603 49.704118 13.119141 49.791016 C 13.027691 49.877916 12.983569 49.958238 12.951172 50.03125 C 12.886382 50.177277 12.867187 50.304789 12.867188 50.441406 L 12.867188 51.523438 L 12.867188 52.119141 L 12.867188 53.677734 L 12.867188 54.509766 L 13.570312 54.509766 L 19.472656 54.509766 L 20.173828 54.509766 L 20.173828 53.677734 L 20.173828 51.523438 L 20.173828 50.441406 C 20.173828 50.304794 20.156597 50.177281 20.091797 50.03125 C 20.059397 49.95824 20.015299 49.877916 19.923828 49.791016 C 19.832368 49.704116 19.667509 49.607422 19.472656 49.607422 L 18.927734 49.607422 L 18.927734 48.376953 C 18.927734 47.085184 17.867902 46.027344 16.576172 46.027344 L 16.435547 46.027344 z M 16.435547 47.220703 L 16.576172 47.220703 C 17.22782 47.220703 17.734375 47.725101 17.734375 48.376953 L 17.734375 49.607422 L 15.277344 49.607422 L 15.277344 48.376953 C 15.277344 47.725101 15.7839 47.220703 16.435547 47.220703 z M 11.470703 47.490234 C 11.410703 47.510234 11.349063 47.539844 11.289062 47.589844 L 6.3496094 51.519531 C 6.1096094 51.699531 6.1096094 52.120781 6.3496094 52.300781 L 11.289062 56.220703 C 11.569064 56.440703 12.070312 56.199844 12.070312 55.839844 L 12.070312 55.509766 L 11.869141 55.509766 L 11.869141 53.677734 L 11.869141 52.119141 L 11.869141 51.523438 L 11.869141 50.441406 C 11.869141 50.217489 11.912641 49.907486 12.037109 49.626953 C 12.043809 49.611855 12.061451 49.584424 12.070312 49.566406 L 12.070312 47.960938 C 12.070312 47.660938 11.770703 47.430234 11.470703 47.490234 z M 16.435547 48.220703 C 16.301234 48.220703 16.277344 48.244432 16.277344 48.378906 L 16.277344 48.607422 L 16.734375 48.607422 L 16.734375 48.378906 C 16.734375 48.244433 16.712442 48.220703 16.578125 48.220703 L 16.435547 48.220703 z M 13.060547 57.650391 L 13.060547 57.660156 C 12.830547 57.690156 12.660156 57.920156 12.660156 58.160156 L 12.660156 60.630859 L 7.2792969 60.630859 C 6.1992969 60.630859 5.4208594 60.589844 4.8808594 60.589844 C 3.2408594 60.589844 3.6308594 61.020234 3.6308594 64.240234 L 3.6308594 69.109375 L 6.5605469 66.740234 L 6.5605469 64.240234 C 6.5605469 63.800234 6.8392969 63.519531 7.2792969 63.519531 L 12.660156 63.519531 L 12.660156 66.019531 C 12.660156 66.299799 12.960394 66.500006 13.226562 66.474609 C 13.625751 65.076914 14.904956 64.035678 16.421875 64.029297 L 18.380859 62.470703 C 18.620859 62.290703 18.620859 61.869453 18.380859 61.689453 L 13.439453 57.769531 C 13.339453 57.669531 13.200547 57.630391 13.060547 57.650391 z M 18.359375 63.810547 L 17.800781 64.269531 C 18.004793 64.350836 18.198411 64.450249 18.380859 64.568359 L 18.380859 64.25 L 18.380859 63.810547 L 18.359375 63.810547 z M 16.435547 65.027344 C 15.143818 65.027344 14.083984 66.085184 14.083984 67.376953 L 14.083984 68.607422 L 13.570312 68.607422 C 13.375448 68.607422 13.210603 68.704118 13.119141 68.791016 C 13.027691 68.877916 12.983569 68.958238 12.951172 69.03125 C 12.886382 69.177277 12.867187 69.304789 12.867188 69.441406 L 12.867188 70.523438 L 12.867188 71.119141 L 12.867188 72.677734 L 12.867188 73.509766 L 13.570312 73.509766 L 19.472656 73.509766 L 20.173828 73.509766 L 20.173828 72.677734 L 20.173828 70.523438 L 20.173828 69.441406 C 20.173828 69.304794 20.156597 69.177281 20.091797 69.03125 C 20.059397 68.95824 20.015299 68.877916 19.923828 68.791016 C 19.832368 68.704116 19.667509 68.607422 19.472656 68.607422 L 18.927734 68.607422 L 18.927734 67.376953 C 18.927734 66.085184 17.867902 65.027344 16.576172 65.027344 L 16.435547 65.027344 z M 16.435547 66.220703 L 16.576172 66.220703 C 17.22782 66.220703 17.734375 66.725101 17.734375 67.376953 L 17.734375 68.607422 L 15.277344 68.607422 L 15.277344 67.376953 C 15.277344 66.725101 15.7839 66.220703 16.435547 66.220703 z M 8.7207031 66.509766 C 8.6507031 66.529766 8.5895312 66.559375 8.5195312 66.609375 L 3.5996094 70.519531 C 3.3496094 70.699531 3.3496094 71.120781 3.5996094 71.300781 L 8.5292969 75.220703 C 8.8092969 75.440703 9.3105469 75.199844 9.3105469 74.839844 L 9.3105469 72.339844 L 11.869141 72.339844 L 11.869141 71.119141 L 11.869141 70.523438 L 11.869141 69.449219 L 9.3203125 69.449219 L 9.3203125 66.980469 C 9.3203125 66.680469 9.0007031 66.449766 8.7207031 66.509766 z M 16.435547 67.220703 C 16.301234 67.220703 16.277344 67.244432 16.277344 67.378906 L 16.277344 67.607422 L 16.734375 67.607422 L 16.734375 67.378906 C 16.734375 67.244433 16.712442 67.220703 16.578125 67.220703 L 16.435547 67.220703 z M 19.248047 78.800781 C 19.148558 78.831033 19.050295 78.90106 18.970703 78.970703 L 18.070312 79.869141 C 17.630312 79.569141 16.710703 79.619141 14.720703 79.619141 L 7.2792969 79.619141 C 6.1992969 79.619141 5.4208594 79.589844 4.8808594 79.589844 C 3.2408594 79.589844 3.6308594 80.020234 3.6308594 83.240234 L 3.6308594 83.939453 L 6.5605469 84.240234 L 6.5605469 83.240234 C 6.5605469 82.800234 6.8392969 82.519531 7.2792969 82.519531 L 14.720703 82.519531 C 14.920703 82.519531 15.090703 82.600703 15.220703 82.720703 L 13.419922 84.519531 C 13.279464 84.665607 13.281282 84.881022 13.363281 85.054688 C 13.880838 83.867655 15.067337 83.027344 16.435547 83.027344 L 16.578125 83.027344 C 18.290465 83.027344 19.703357 84.345788 19.890625 86.011719 L 19.960938 86.019531 C 20.240938 86.049531 20.520234 85.770234 20.490234 85.490234 L 19.789062 79.240234 C 19.789062 78.973661 19.498025 78.767523 19.25 78.800781 L 19.248047 78.800781 z M 16.435547 84.027344 C 15.143818 84.027344 14.083984 85.085184 14.083984 86.376953 L 14.083984 87.607422 L 13.570312 87.607422 C 13.375448 87.607422 13.210603 87.704118 13.119141 87.791016 C 13.027691 87.877916 12.983569 87.958238 12.951172 88.03125 C 12.886382 88.177277 12.867187 88.304789 12.867188 88.441406 L 12.867188 89.523438 L 12.867188 90.119141 L 12.867188 91.677734 L 12.867188 92.509766 L 13.570312 92.509766 L 19.472656 92.509766 L 20.173828 92.509766 L 20.173828 91.677734 L 20.173828 89.523438 L 20.173828 88.441406 C 20.173828 88.304794 20.156597 88.177281 20.091797 88.03125 C 20.059397 87.95824 20.015299 87.877916 19.923828 87.791016 C 19.832368 87.704116 19.667509 87.607422 19.472656 87.607422 L 18.927734 87.607422 L 18.927734 86.376953 C 18.927734 85.085184 17.867902 84.027344 16.576172 84.027344 L 16.435547 84.027344 z M 2.0507812 84.900391 C 1.8507824 84.970391 1.6907031 85.199453 1.7207031 85.439453 L 2.4199219 91.689453 C 2.4399219 92.049453 3 92.240929 3.25 91.960938 L 4.0507812 91.160156 C 4.0707812 91.160156 4.0898437 91.140156 4.0898438 91.160156 C 4.5498437 91.400156 5.4595313 91.330078 7.2695312 91.330078 L 11.869141 91.330078 L 11.869141 90.119141 L 11.869141 89.523438 L 11.869141 88.441406 C 11.869141 88.437991 11.871073 88.433136 11.871094 88.429688 L 7.2792969 88.429688 C 7.1292969 88.429688 6.9808594 88.400078 6.8808594 88.330078 L 8.8007812 86.400391 C 9.1007822 86.160391 8.8992969 85.600547 8.5292969 85.560547 L 2.25 84.910156 L 2.0507812 84.910156 L 2.0507812 84.900391 z M 16.435547 85.220703 L 16.576172 85.220703 C 17.22782 85.220703 17.734375 85.725101 17.734375 86.376953 L 17.734375 87.607422 L 15.277344 87.607422 L 15.277344 86.376953 C 15.277344 85.725101 15.7839 85.220703 16.435547 85.220703 z M 4.8808594 98.599609 C 3.5508594 98.599609 3.5400781 99.080402 3.5800781 100.90039 L 4.7207031 99.529297 C 4.8007031 99.429297 4.9405469 99.360078 5.0605469 99.330078 C 5.2205469 99.330078 5.4 99.409297 5.5 99.529297 L 7.1601562 101.56055 C 7.2001563 101.56055 7.2292969 101.5293 7.2792969 101.5293 L 14.720703 101.5293 C 15.060703 101.5293 15.289141 101.7293 15.369141 102.0293 L 12.939453 102.0293 C 12.599453 102.0793 12.410625 102.55055 12.640625 102.81055 L 13.470703 103.85742 C 14.029941 102.77899 15.146801 102.02734 16.435547 102.02734 L 16.578125 102.02734 C 18.158418 102.02734 19.491598 103.14879 19.835938 104.63086 L 21.279297 102.82031 C 21.499297 102.55031 21.260156 102.06078 20.910156 102.05078 L 18.400391 102.05078 C 18.420391 98.150792 19.000234 98.650391 14.740234 98.650391 L 7.2792969 98.650391 C 6.1992969 98.650391 5.4208594 98.609375 4.8808594 98.609375 L 4.8808594 98.599609 z M 5.0292969 101.06055 C 4.9292969 101.09055 4.83 101.15977 4.75 101.25977 L 0.81054688 106.16016 C 0.61054688 106.44016 0.8409375 106.92945 1.2109375 106.93945 L 3.5996094 106.93945 C 3.5796094 110.87945 3.1497656 110.33984 7.2597656 110.33984 L 11.869141 110.33984 L 11.869141 109.11914 L 11.869141 108.52344 L 11.869141 107.44141 L 11.869141 107.43945 L 7.2792969 107.43945 C 6.9292969 107.43945 6.7091406 107.23945 6.6191406 106.93945 L 9.0605469 106.93945 C 9.4305469 106.93945 9.6909375 106.44016 9.4609375 106.16016 L 5.5 101.25977 C 5.4 101.10977 5.1992969 101.03055 5.0292969 101.06055 z M 16.435547 103.02734 C 15.143818 103.02734 14.083984 104.08518 14.083984 105.37695 L 14.083984 106.60742 L 13.570312 106.60742 C 13.375448 106.60742 13.210603 106.70409 13.119141 106.79102 C 13.027691 106.87792 12.983569 106.95823 12.951172 107.03125 C 12.886382 107.17727 12.867187 107.30479 12.867188 107.44141 L 12.867188 108.52344 L 12.867188 109.11914 L 12.867188 110.67773 L 12.867188 111.50977 L 13.570312 111.50977 L 19.472656 111.50977 L 20.173828 111.50977 L 20.173828 110.67773 L 20.173828 108.52344 L 20.173828 107.44141 C 20.173828 107.3048 20.156597 107.17728 20.091797 107.03125 C 20.059397 106.95825 20.015299 106.87792 19.923828 106.79102 C 19.832368 106.70412 19.667509 106.60742 19.472656 106.60742 L 18.927734 106.60742 L 18.927734 105.37695 C 18.927734 104.08518 17.867902 103.02734 16.576172 103.02734 L 16.435547 103.02734 z M 16.435547 104.2207 L 16.576172 104.2207 C 17.22782 104.2207 17.734375 104.7251 17.734375 105.37695 L 17.734375 106.60742 L 15.277344 106.60742 L 15.277344 105.37695 C 15.277344 104.7251 15.7839 104.2207 16.435547 104.2207 z M 16.435547 105.2207 C 16.301234 105.2207 16.277344 105.24444 16.277344 105.37891 L 16.277344 105.60742 L 16.734375 105.60742 L 16.734375 105.37891 C 16.734375 105.24441 16.712442 105.2207 16.578125 105.2207 L 16.435547 105.2207 z M 4.8808594 117.58984 L 4.8808594 117.59961 C 3.7208594 117.59961 3.5800781 117.90016 3.5800781 119.16016 L 4.7207031 117.7793 C 4.8007031 117.6793 4.9405469 117.63914 5.0605469 117.61914 C 5.2205469 117.61914 5.4 117.6593 5.5 117.7793 L 7.7207031 120.5293 L 14.720703 120.5293 C 15.123595 120.5293 15.408576 120.79174 15.431641 121.20117 C 15.750992 121.09876 16.08404 121.02734 16.435547 121.02734 L 16.578125 121.02734 C 17.24903 121.02734 17.874081 121.23262 18.400391 121.57812 L 18.400391 121.25 C 18.400391 117.05 19.120234 117.61914 14.740234 117.61914 L 7.2792969 117.61914 C 6.1992969 117.61914 5.4208594 117.58984 4.8808594 117.58984 z M 4.9804688 119.33984 C 4.8804688 119.36984 4.81 119.44 4.75 119.5 L 0.80078125 124.43945 C 0.60078125 124.71945 0.8292182 125.2107 1.1992188 125.2207 L 3.5996094 125.2207 L 3.5996094 125.7207 C 3.5996094 129.9807 3.0497656 129.33984 7.2597656 129.33984 L 11.869141 129.33984 L 11.869141 128.11914 L 11.869141 127.52344 L 11.869141 126.44141 C 11.869141 126.43799 11.871073 126.43314 11.871094 126.42969 L 7.2792969 126.42969 C 6.8392969 126.42969 6.5605469 126.13094 6.5605469 125.71094 L 6.5605469 125.21094 L 9.0605469 125.21094 C 9.4305469 125.23094 9.6909375 124.70969 9.4609375 124.42969 L 5.5 119.5 C 5.3820133 119.35252 5.1682348 119.28513 4.9804688 119.33984 z M 12.839844 121.7793 C 12.539844 121.8793 12.410625 122.32055 12.640625 122.56055 L 13.267578 123.34375 C 13.473522 122.72168 13.852237 122.1828 14.353516 121.7793 L 12.839844 121.7793 z M 18.658203 121.7793 C 19.393958 122.37155 19.878978 123.25738 19.916016 124.25781 L 21.279297 122.56055 C 21.499297 122.28055 21.260156 121.7893 20.910156 121.7793 L 18.658203 121.7793 z M 16.435547 122.02734 C 15.143818 122.02734 14.083984 123.08518 14.083984 124.37695 L 14.083984 125.60742 L 13.570312 125.60742 C 13.375448 125.60742 13.210603 125.70409 13.119141 125.79102 C 13.027691 125.87792 12.983569 125.95823 12.951172 126.03125 C 12.886382 126.17727 12.867187 126.30479 12.867188 126.44141 L 12.867188 127.52344 L 12.867188 128.11914 L 12.867188 129.67773 L 12.867188 130.50977 L 13.570312 130.50977 L 19.472656 130.50977 L 20.173828 130.50977 L 20.173828 129.67773 L 20.173828 127.52344 L 20.173828 126.44141 C 20.173828 126.3048 20.156597 126.17728 20.091797 126.03125 C 20.059397 125.95825 20.015299 125.87792 19.923828 125.79102 C 19.832368 125.70412 19.667509 125.60742 19.472656 125.60742 L 18.927734 125.60742 L 18.927734 124.37695 C 18.927734 123.08518 17.867902 122.02734 16.576172 122.02734 L 16.435547 122.02734 z M 16.435547 123.2207 L 16.576172 123.2207 C 17.22782 123.2207 17.734375 123.7251 17.734375 124.37695 L 17.734375 125.60742 L 15.277344 125.60742 L 15.277344 124.37695 C 15.277344 123.7251 15.7839 123.2207 16.435547 123.2207 z M 16.435547 124.2207 C 16.301234 124.2207 16.277344 124.24444 16.277344 124.37891 L 16.277344 124.60742 L 16.734375 124.60742 L 16.734375 124.37891 C 16.734375 124.24441 16.712442 124.2207 16.578125 124.2207 L 16.435547 124.2207 z M 5.9394531 136.58984 L 5.9394531 136.59961 L 8.3105469 139.5293 L 14.730469 139.5293 C 15.131912 139.5293 15.414551 139.79039 15.439453 140.19727 C 15.756409 140.09653 16.087055 140.02734 16.435547 140.02734 L 16.578125 140.02734 C 17.24903 140.02734 17.874081 140.23261 18.400391 140.57812 L 18.400391 140.25 C 18.400391 136.05 19.120234 136.61914 14.740234 136.61914 L 7.2792969 136.61914 C 6.6792969 136.61914 6.3594531 136.59984 5.9394531 136.58984 z M 4.2207031 136.66016 C 3.8207031 136.74016 3.6791406 136.96016 3.6191406 137.41016 L 4.2207031 136.66992 L 4.2207031 136.66016 z M 5.0605469 137.57031 L 5.0605469 137.58984 C 4.9405469 137.58984 4.8197656 137.66953 4.7597656 137.76953 L 0.81054688 142.66992 C 0.57054688 142.96992 0.8109375 143.50023 1.2109375 143.49023 L 3.5996094 143.49023 L 3.5996094 144.71094 C 3.5996094 148.97094 3.0497656 148.33008 7.2597656 148.33008 L 11.869141 148.33008 L 11.869141 147.11914 L 11.869141 146.52344 L 11.869141 145.44141 C 11.869141 145.43799 11.871073 145.43314 11.871094 145.42969 L 7.2792969 145.42969 C 6.8392969 145.42969 6.5605469 145.13094 6.5605469 144.71094 L 6.5605469 143.49023 L 9.0605469 143.49023 C 9.4605469 143.53023 9.7309375 142.95945 9.4609375 142.68945 L 5.5 137.76953 C 5.4 137.63953 5.2305469 137.57031 5.0605469 137.57031 z M 16.435547 141.02734 C 15.143818 141.02734 14.083984 142.08518 14.083984 143.37695 L 14.083984 144.60742 L 13.570312 144.60742 C 13.375448 144.60742 13.210603 144.70409 13.119141 144.79102 C 13.027691 144.87792 12.983569 144.95823 12.951172 145.03125 C 12.886382 145.17727 12.867187 145.30479 12.867188 145.44141 L 12.867188 146.52344 L 12.867188 147.11914 L 12.867188 148.67773 L 12.867188 149.50977 L 13.570312 149.50977 L 19.472656 149.50977 L 20.173828 149.50977 L 20.173828 148.67773 L 20.173828 146.52344 L 20.173828 145.44141 C 20.173828 145.3048 20.156597 145.17728 20.091797 145.03125 C 20.059397 144.95825 20.015299 144.87792 19.923828 144.79102 C 19.832368 144.70412 19.667509 144.60742 19.472656 144.60742 L 18.927734 144.60742 L 18.927734 143.37695 C 18.927734 142.08518 17.867902 141.02734 16.576172 141.02734 L 16.435547 141.02734 z M 12.849609 141.5 C 12.549609 141.6 12.420391 142.0393 12.650391 142.2793 L 13.136719 142.88672 C 13.213026 142.38119 13.390056 141.90696 13.667969 141.5 L 12.849609 141.5 z M 19.34375 141.5 C 19.710704 142.03735 19.927734 142.68522 19.927734 143.37891 L 19.927734 143.79102 C 19.965561 143.80421 20.005506 143.81448 20.044922 143.82617 L 21.289062 142.2793 C 21.509062 141.9993 21.269922 141.51 20.919922 141.5 L 19.34375 141.5 z M 16.435547 142.2207 L 16.576172 142.2207 C 17.22782 142.2207 17.734375 142.7251 17.734375 143.37695 L 17.734375 144.60742 L 15.277344 144.60742 L 15.277344 143.37695 C 15.277344 142.7251 15.7839 142.2207 16.435547 142.2207 z M 16.435547 143.2207 C 16.301234 143.2207 16.277344 143.24444 16.277344 143.37891 L 16.277344 143.60742 L 16.734375 143.60742 L 16.734375 143.37891 C 16.734375 143.24441 16.712442 143.2207 16.578125 143.2207 L 16.435547 143.2207 z M 17.130859 155.59961 C 16.580859 155.57961 15.810469 155.63086 14.730469 155.63086 L 6.5292969 155.63086 L 8.9101562 158.5293 L 14.730469 158.5293 C 15.131912 158.5293 15.414551 158.79039 15.439453 159.19727 C 15.756409 159.09653 16.087055 159.02734 16.435547 159.02734 L 16.578125 159.02734 C 17.24903 159.02734 17.874081 159.23261 18.400391 159.57812 L 18.400391 159.25977 C 18.400391 156.10977 18.800391 155.63961 17.150391 155.59961 L 17.130859 155.59961 z M 5.0292969 155.86914 L 5.0292969 155.88086 C 4.9292969 155.90086 4.83 155.98055 4.75 156.06055 L 0.81054688 160.96094 C 0.61054688 161.26094 0.8409375 161.73977 1.2109375 161.75977 L 3.5996094 161.75977 L 3.5996094 163.7207 C 3.5996094 167.9807 3.0497656 167.33984 7.2597656 167.33984 L 11.869141 167.33984 L 11.869141 166.11914 L 11.869141 165.52344 L 11.869141 164.44141 L 11.869141 164.43945 L 7.2792969 164.43945 C 6.8392969 164.43945 6.5605469 164.1407 6.5605469 163.7207 L 6.5605469 161.75 L 9.0605469 161.75 C 9.4305469 161.77 9.6909375 161.2507 9.4609375 160.9707 L 5.5 156.07031 C 5.4 155.92031 5.1992969 155.84914 5.0292969 155.86914 z M 16.435547 160.02734 C 15.143818 160.02734 14.083984 161.08518 14.083984 162.37695 L 14.083984 163.60742 L 13.570312 163.60742 C 13.375448 163.60742 13.210603 163.70409 13.119141 163.79102 C 13.027691 163.87792 12.983569 163.95823 12.951172 164.03125 C 12.886382 164.17727 12.867187 164.30479 12.867188 164.44141 L 12.867188 165.52344 L 12.867188 166.11914 L 12.867188 167.67773 L 12.867188 168.50977 L 13.570312 168.50977 L 19.472656 168.50977 L 20.173828 168.50977 L 20.173828 167.67773 L 20.173828 165.52344 L 20.173828 164.44141 C 20.173828 164.3048 20.156597 164.17728 20.091797 164.03125 C 20.059397 163.95825 20.015299 163.87792 19.923828 163.79102 C 19.832368 163.70412 19.667509 163.60742 19.472656 163.60742 L 18.927734 163.60742 L 18.927734 162.37695 C 18.927734 161.08518 17.867902 160.02734 16.576172 160.02734 L 16.435547 160.02734 z M 12.900391 161.2207 C 12.580391 161.2807 12.419141 161.74 12.619141 162 L 13.085938 162.58594 L 13.085938 162.37891 C 13.085938 161.97087 13.170592 161.58376 13.306641 161.2207 L 12.900391 161.2207 z M 16.435547 161.2207 L 16.576172 161.2207 C 17.22782 161.2207 17.734375 161.7251 17.734375 162.37695 L 17.734375 163.60742 L 15.277344 163.60742 L 15.277344 162.37695 C 15.277344 161.7251 15.7839 161.2207 16.435547 161.2207 z M 19.708984 161.23047 C 19.842743 161.59081 19.927734 161.97449 19.927734 162.37891 L 19.927734 162.79102 C 20.119162 162.85779 20.322917 162.91147 20.484375 163 L 21.279297 162.00977 C 21.499297 161.72977 21.260156 161.24047 20.910156 161.23047 L 19.708984 161.23047 z M 16.435547 162.2207 C 16.301234 162.2207 16.277344 162.24444 16.277344 162.37891 L 16.277344 162.60742 L 16.734375 162.60742 L 16.734375 162.37891 C 16.734375 162.24441 16.712442 162.2207 16.578125 162.2207 L 16.435547 162.2207 z M 5.0996094 174.49023 L 5.1308594 174.5 C 4.9808594 174.5 4.83 174.56922 4.75 174.69922 L 0.80078125 179.59961 C 0.56078125 179.86961 0.7992182 180.42039 1.1992188 180.40039 L 3.5996094 180.40039 L 3.5996094 182.7207 C 3.5996094 186.9807 3.0497656 186.33984 7.2597656 186.33984 L 11.869141 186.33984 L 11.869141 185.11914 L 11.869141 184.52344 L 11.869141 183.44141 L 11.869141 183.43945 L 7.25 183.43945 C 6.82 183.43945 6.5507814 183.1407 6.5507812 182.7207 L 6.5507812 180.41992 L 9.0507812 180.41992 C 9.4307824 180.44992 9.7092187 179.87984 9.4492188 179.58984 L 5.4804688 174.68945 C 5.3804688 174.55945 5.2496094 174.49023 5.0996094 174.49023 z M 17.150391 174.58008 L 17.130859 174.59961 C 16.580859 174.57961 15.810469 174.63086 14.730469 174.63086 L 6.8300781 174.63086 L 9.1796875 177.5293 L 14.699219 177.5293 C 15.104107 177.5293 15.391475 177.79407 15.412109 178.20703 C 15.737096 178.1006 16.076913 178.02734 16.435547 178.02734 L 16.578125 178.02734 C 17.24903 178.02734 17.874081 178.2326 18.400391 178.57812 L 18.400391 178.24023 C 18.400391 175.09023 18.800391 174.62008 17.150391 174.58008 z M 16.435547 179.02734 C 15.143818 179.02734 14.083984 180.08518 14.083984 181.37695 L 14.083984 182.60742 L 13.570312 182.60742 C 13.375448 182.60742 13.210603 182.70409 13.119141 182.79102 C 13.027691 182.87792 12.983569 182.95823 12.951172 183.03125 C 12.886382 183.17727 12.867187 183.30479 12.867188 183.44141 L 12.867188 184.52344 L 12.867188 185.11914 L 12.867188 186.67773 L 12.867188 187.50977 L 13.570312 187.50977 L 19.472656 187.50977 L 20.173828 187.50977 L 20.173828 186.67773 L 20.173828 184.52344 L 20.173828 183.44141 C 20.173828 183.3048 20.156597 183.17728 20.091797 183.03125 C 20.059397 182.95825 20.015299 182.87792 19.923828 182.79102 C 19.832368 182.70412 19.667509 182.60742 19.472656 182.60742 L 18.927734 182.60742 L 18.927734 181.37695 C 18.927734 180.08518 17.867902 179.02734 16.576172 179.02734 L 16.435547 179.02734 z M 16.435547 180.2207 L 16.576172 180.2207 C 17.22782 180.2207 17.734375 180.7251 17.734375 181.37695 L 17.734375 182.60742 L 15.277344 182.60742 L 15.277344 181.37695 C 15.277344 180.7251 15.7839 180.2207 16.435547 180.2207 z M 19.816406 180.57031 C 19.882311 180.83091 19.927734 181.09907 19.927734 181.37891 L 19.927734 181.79102 C 20.168811 181.87511 20.455966 181.91694 20.613281 182.06641 C 20.630645 182.0829 20.639883 182.10199 20.65625 182.11914 L 21.259766 181.36914 C 21.479766 181.06914 21.240625 180.59031 20.890625 180.57031 L 19.816406 180.57031 z M 12.820312 180.58984 C 12.520316 180.68984 12.389141 181.11914 12.619141 181.36914 L 12.990234 181.83203 C 13.022029 181.82207 13.055579 181.81406 13.085938 181.80273 L 13.085938 181.37891 C 13.085938 181.10616 13.128698 180.84442 13.191406 180.58984 L 12.820312 180.58984 z M 16.435547 181.2207 C 16.301234 181.2207 16.277344 181.24444 16.277344 181.37891 L 16.277344 181.60742 L 16.734375 181.60742 L 16.734375 181.37891 C 16.734375 181.24441 16.712442 181.2207 16.578125 181.2207 L 16.435547 181.2207 z M 4.9609375 193.15039 L 4.9707031 193.16016 C 4.8707031 193.19016 4.8 193.25984 4.75 193.33984 L 0.81054688 198.24023 C 0.61054688 198.54023 0.8409375 199.01906 1.2109375 199.03906 L 3.5996094 199.03906 L 3.5996094 201.7207 C 3.5996094 205.9807 3.0497656 205.33984 7.2597656 205.33984 L 11.869141 205.33984 L 11.869141 204.11914 L 11.869141 203.52344 L 11.869141 202.44141 C 11.869141 202.44141 11.869141 202.43945 11.869141 202.43945 L 7.2695312 202.43945 C 6.8295312 202.43945 6.5507814 202.1407 6.5507812 201.7207 L 6.5507812 199.01953 L 9.0507812 199.01953 C 9.4207814 199.04953 9.6792188 198.54 9.4492188 198.25 L 5.4902344 193.34961 C 5.3702344 193.17961 5.1509375 193.10039 4.9609375 193.15039 z M 17.150391 193.58008 L 17.130859 193.58984 C 16.580859 193.56984 15.810469 193.61914 14.730469 193.61914 L 7.0996094 193.61914 L 9.4199219 196.46094 L 9.4492188 196.51953 L 14.699219 196.51953 C 15.106887 196.51953 15.397075 196.78718 15.414062 197.20508 C 15.738375 197.09913 16.077769 197.02734 16.435547 197.02734 L 16.578125 197.02734 C 17.24903 197.02734 17.874081 197.23259 18.400391 197.57812 L 18.400391 197.24023 C 18.400391 194.09023 18.800391 193.62008 17.150391 193.58008 z M 16.435547 198.02734 C 15.143818 198.02734 14.083984 199.08518 14.083984 200.37695 L 14.083984 201.60742 L 13.570312 201.60742 C 13.375448 201.60742 13.210603 201.70409 13.119141 201.79102 C 13.027691 201.87792 12.983569 201.95823 12.951172 202.03125 C 12.886382 202.17727 12.867187 202.30479 12.867188 202.44141 L 12.867188 203.52344 L 12.867188 204.11914 L 12.867188 205.67773 L 12.867188 206.50977 L 13.570312 206.50977 L 19.472656 206.50977 L 20.173828 206.50977 L 20.173828 205.67773 L 20.173828 203.52344 L 20.173828 202.44141 C 20.173828 202.3048 20.156597 202.17728 20.091797 202.03125 C 20.059397 201.95825 20.015299 201.87792 19.923828 201.79102 C 19.832368 201.70412 19.667509 201.60742 19.472656 201.60742 L 18.927734 201.60742 L 18.927734 200.37695 C 18.927734 199.08518 17.867902 198.02734 16.576172 198.02734 L 16.435547 198.02734 z M 16.435547 199.2207 L 16.576172 199.2207 C 17.22782 199.2207 17.734375 199.7251 17.734375 200.37695 L 17.734375 201.60742 L 15.277344 201.60742 L 15.277344 200.37695 C 15.277344 199.7251 15.7839 199.2207 16.435547 199.2207 z M 12.919922 199.93945 C 12.559922 199.95945 12.359141 200.48023 12.619141 200.74023 L 12.751953 200.9043 C 12.862211 200.87013 12.980058 200.84224 13.085938 200.80273 L 13.085938 200.37891 C 13.085938 200.22863 13.111295 200.08474 13.130859 199.93945 L 12.919922 199.93945 z M 19.882812 199.93945 C 19.902378 200.08474 19.927734 200.22863 19.927734 200.37891 L 19.927734 200.79102 C 20.168811 200.87511 20.455966 200.91694 20.613281 201.06641 C 20.691227 201.14046 20.749315 201.22305 20.806641 201.30273 L 21.259766 200.74023 C 21.519766 200.46023 21.260625 199.90945 20.890625 199.93945 L 19.882812 199.93945 z M 16.435547 200.2207 C 16.301234 200.2207 16.277344 200.24444 16.277344 200.37891 L 16.277344 200.60742 L 16.734375 200.60742 L 16.734375 200.37891 C 16.734375 200.24441 16.712442 200.2207 16.578125 200.2207 L 16.435547 200.2207 z ' fill='#{hex-color($highlight-text-color)}' stroke-width='0' /></svg>"); + } + } + + &.disabled { + i.fa-retweet, + &:hover i.fa-retweet { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' height='209' width='22'><path d='M 18.972656 1.2011719 C 18.829825 1.1881782 18.685932 1.2302188 18.572266 1.3300781 L 15.990234 3.5996094 C 15.58109 3.6070661 15.297269 3.609375 14.730469 3.609375 L 7.0996094 3.609375 L 9.4199219 6.4609375 L 9.4492188 6.5195312 L 12.664062 6.5195312 L 6.5761719 11.867188 C 6.5674697 11.818249 6.5507813 11.773891 6.5507812 11.720703 L 6.5507812 9.0195312 L 9.0507812 9.0195312 C 9.4207813 9.0495313 9.6792188 8.54 9.4492188 8.25 L 5.5 3.3496094 C 5.38 3.1796094 5.1607031 3.1003906 4.9707031 3.1503906 L 4.9707031 3.1601562 C 4.8707031 3.1901563 4.8 3.2598438 4.75 3.3398438 L 0.80078125 8.2402344 C 0.60078125 8.5402344 0.8292187 9.0190625 1.1992188 9.0390625 L 3.5996094 9.0390625 L 3.5996094 11.720703 C 3.5996094 13.045739 3.5690668 13.895038 3.6503906 14.4375 L 2.6152344 15.347656 C 2.3879011 15.547375 2.3754917 15.901081 2.5859375 16.140625 L 3.1464844 16.78125 C 3.3569308 17.020794 3.7101667 17.053234 3.9375 16.853516 L 19.892578 2.8359375 C 20.119911 2.6362188 20.134275 2.282513 19.923828 2.0429688 L 19.361328 1.4023438 C 19.256105 1.282572 19.115488 1.2141655 18.972656 1.2011719 z M 18.410156 6.7753906 L 15.419922 9.4042969 L 15.419922 9.9394531 L 14.810547 9.9394531 L 13.148438 11.400391 L 16.539062 15.640625 C 16.719062 15.890625 17.140313 15.890625 17.320312 15.640625 L 21.259766 10.740234 C 21.519766 10.460234 21.260625 9.9094531 20.890625 9.9394531 L 18.400391 9.9394531 L 18.400391 7.2402344 C 18.400391 7.0470074 18.407711 6.9489682 18.410156 6.7753906 z M 11.966797 12.439453 L 8.6679688 15.339844 L 14.919922 15.339844 L 12.619141 12.5 C 12.589141 12.48 12.590313 12.459453 12.570312 12.439453 L 11.966797 12.439453 z' fill='#{hex-color(darken($action-button-color, 13%))}' stroke-width='0'/></svg>"); + } + } + + .media-modal__overlay .picture-in-picture__footer & { + i.fa-retweet { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color($white)}' stroke-width='0'/><path d='M7.78 19.66c-.24.02-.44.25-.44.5v2.46h-.06c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v4.47c0 4.26-.56 3.62 3.65 3.62H8.5l-1.3-1.06c-.1-.08-.18-.2-.2-.3-.02-.17.06-.35.2-.45l1.33-1.1H7.28c-.44 0-.72-.3-.72-.7v-4.48c0-.44.28-.72.72-.72h.06v2.5c0 .38.54.63.82.38l4.9-3.93c.25-.18.25-.6 0-.78l-4.9-3.92c-.1-.1-.24-.14-.38-.12zm9.34 2.93c-.54-.02-1.3.02-2.4.02h-1.25l1.3 1.07c.1.07.18.2.2.33.02.16-.06.3-.2.4l-1.33 1.1h1.28c.42 0 .72.28.72.72v4.47c0 .42-.3.72-.72.72h-.1v-2.47c0-.3-.3-.53-.6-.47-.07 0-.14.05-.2.1l-4.9 3.93c-.26.18-.26.6 0 .78l4.9 3.92c.27.25.82 0 .8-.38v-2.5h.1c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.15.4-3.62-1.25-3.66zM10.34 38.66c-.24.02-.44.25-.43.5v2.47H7.3c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.47c0 3.66-.23 3.7 2.34 3.66l-1.34-1.1c-.1-.08-.18-.2-.2-.3 0-.17.07-.35.2-.45l1.96-1.6c-.03-.06-.04-.13-.04-.2v-4.48c0-.44.28-.72.72-.72H9.9v2.5c0 .36.5.6.8.38l4.93-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.08-.23-.13-.36-.12zm5.63 2.93l1.34 1.1c.1.07.18.2.2.33.02.16-.03.3-.16.4l-1.96 1.6c.02.07.06.13.06.22v4.47c0 .42-.3.72-.72.72h-2.66v-2.47c0-.3-.3-.53-.6-.47-.06.02-.12.05-.18.1l-4.94 3.93c-.24.18-.24.6 0 .78l4.94 3.92c.28.22.78-.02.78-.38v-2.5h2.66c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.66.34-3.7-2.4-3.66zM13.06 57.66c-.23.03-.4.26-.4.5v2.47H7.28c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.87l2.93-2.37v-2.5c0-.44.28-.72.72-.72h5.38v2.5c0 .36.5.6.78.38l4.94-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.1-.24-.14-.38-.12zm5.3 6.15l-2.92 2.4v2.52c0 .42-.3.72-.72.72h-5.4v-2.47c0-.3-.32-.53-.6-.47-.07.02-.13.05-.2.1L3.6 70.52c-.25.18-.25.6 0 .78l4.93 3.92c.28.22.78-.02.78-.38v-2.5h5.42c4.27 0 3.65.67 3.65-3.62v-4.47-.44zM19.25 78.8c-.1.03-.2.1-.28.17l-.9.9c-.44-.3-1.36-.25-3.35-.25H7.28c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v.7l2.93.3v-1c0-.44.28-.72.72-.72h7.44c.2 0 .37.08.5.2l-1.8 1.8c-.25.26-.08.76.27.8l6.27.7c.28.03.56-.25.53-.53l-.7-6.25c0-.27-.3-.48-.55-.44zm-17.2 6.1c-.2.07-.36.3-.33.54l.7 6.25c.02.36.58.55.83.27l.8-.8c.02 0 .04-.02.04 0 .46.24 1.37.17 3.18.17h7.44c4.27 0 3.65.67 3.65-3.62v-.75l-2.93-.3v1.05c0 .42-.3.72-.72.72H7.28c-.15 0-.3-.03-.4-.1L8.8 86.4c.3-.24.1-.8-.27-.84l-6.28-.65h-.2zM4.88 98.6c-1.33 0-1.34.48-1.3 2.3l1.14-1.37c.08-.1.22-.17.34-.2.16 0 .34.08.44.2l1.66 2.03c.04 0 .07-.03.12-.03h7.44c.34 0 .57.2.65.5h-2.43c-.34.05-.53.52-.3.78l3.92 4.95c.18.24.6.24.78 0l3.94-4.94c.22-.27-.02-.76-.37-.77H18.4c.02-3.9.6-3.4-3.66-3.4H7.28c-1.08 0-1.86-.04-2.4-.04zm.15 2.46c-.1.03-.2.1-.28.2l-3.94 4.9c-.2.28.03.77.4.78H3.6c-.02 3.94-.45 3.4 3.66 3.4h7.44c3.65 0 3.74.3 3.7-2.25l-1.1 1.34c-.1.1-.2.17-.32.2-.16 0-.34-.08-.44-.2l-1.65-2.03c-.06.02-.1.04-.18.04H7.28c-.35 0-.57-.2-.66-.5h2.44c.37 0 .63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.23-.47-.2zM4.88 117.6c-1.16 0-1.3.3-1.3 1.56l1.14-1.38c.08-.1.22-.14.34-.16.16 0 .34.04.44.16l2.22 2.75h7c.42 0 .72.28.72.72v.53h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-.53c0-4.2.72-3.63-3.66-3.63H7.28c-1.08 0-1.86-.03-2.4-.03zm.1 1.74c-.1.03-.17.1-.23.16L.8 124.44c-.2.28.03.77.4.78H3.6v.5c0 4.26-.55 3.62 3.66 3.62h7.44c1.03 0 1.74.02 2.28 0-.16.02-.34-.03-.44-.15l-2.22-2.76H7.28c-.44 0-.72-.3-.72-.72v-.5h2.5c.37.02.63-.5.4-.78L5.5 119.5c-.12-.15-.34-.22-.53-.16zm12.02 10c1.2-.02 1.4-.25 1.4-1.53l-1.1 1.36c-.07.1-.17.17-.3.18zM5.94 136.6l2.37 2.93h6.42c.42 0 .72.28.72.72v1.25h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.25c0-4.2.72-3.63-3.66-3.63H7.28c-.6 0-.92-.02-1.34-.03zm-1.72.06c-.4.08-.54.3-.6.75l.6-.74zm.84.93c-.12 0-.24.08-.3.18l-3.95 4.9c-.24.3 0 .83.4.82H3.6v1.22c0 4.26-.55 3.62 3.66 3.62h7.44c.63 0 .97.02 1.4.03l-2.37-2.93H7.28c-.44 0-.72-.3-.72-.72v-1.22h2.5c.4.04.67-.53.4-.8l-3.96-4.92c-.1-.13-.27-.2-.44-.2zm13.28 10.03l-.56.7c.36-.07.5-.3.56-.7zM17.13 155.6c-.55-.02-1.32.03-2.4.03h-8.2l2.38 2.9h5.82c.42 0 .72.28.72.72v1.97H12.9c-.32.06-.48.52-.28.78l3.94 4.94c.2.23.6.22.78-.03l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.97c0-3.15.4-3.62-1.25-3.66zm-12.1.28c-.1.02-.2.1-.28.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v1.96c0 4.26-.55 3.62 3.66 3.62h8.24l-2.36-2.9H7.28c-.44 0-.72-.3-.72-.72v-1.97h2.5c.37.02.63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.22-.47-.2zM5.13 174.5c-.15 0-.3.07-.38.2L.8 179.6c-.24.27 0 .82.4.8H3.6v2.32c0 4.26-.55 3.62 3.66 3.62h7.94l-2.35-2.9h-5.6c-.43 0-.7-.3-.7-.72v-2.3h2.5c.38.03.66-.54.4-.83l-3.97-4.9c-.1-.13-.23-.2-.38-.2zm12 .1c-.55-.02-1.32.03-2.4.03H6.83l2.35 2.9h5.52c.42 0 .72.28.72.72v2.34h-2.6c-.3.1-.43.53-.2.78l3.92 4.9c.18.24.6.24.78 0l3.94-4.9c.22-.3-.02-.78-.37-.8H18.4v-2.33c0-3.15.4-3.62-1.25-3.66zM4.97 193.16c-.1.03-.17.1-.22.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77l-3.96-4.9c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.03-2.4.03H7.1l2.32 2.84.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color($highlight-text-color)}' stroke-width='0'/></svg>"); + } + + &.reblogPrivate { + i.fa-retweet { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' height='209' width='22'><path d='M 4.9707031 3.1503906 L 4.9707031 3.1601562 C 4.8707031 3.1901563 4.8 3.2598438 4.75 3.3398438 L 0.80078125 8.2402344 C 0.60078125 8.5402344 0.8292187 9.0190625 1.1992188 9.0390625 L 3.5996094 9.0390625 L 3.5996094 11.720703 C 3.5996094 15.980703 3.0497656 15.339844 7.2597656 15.339844 L 11.869141 15.339844 L 11.869141 14.119141 L 11.869141 13.523438 L 11.869141 12.441406 C 11.869141 12.441406 11.869141 12.439453 11.869141 12.439453 L 7.2695312 12.439453 C 6.8295312 12.439453 6.5507814 12.140703 6.5507812 11.720703 L 6.5507812 9.0195312 L 9.0507812 9.0195312 C 9.4207813 9.0495313 9.6792188 8.54 9.4492188 8.25 L 5.5 3.3496094 C 5.38 3.1796094 5.1607031 3.1003906 4.9707031 3.1503906 z M 17.150391 3.5800781 L 17.130859 3.5898438 C 16.580859 3.5698436 15.810469 3.609375 14.730469 3.609375 L 7.0996094 3.609375 L 9.4199219 6.4609375 L 9.4492188 6.5195312 L 14.699219 6.5195312 C 15.106887 6.5195312 15.397113 6.7872181 15.414062 7.2050781 C 15.738375 7.0991315 16.077769 7.0273437 16.435547 7.0273438 L 16.578125 7.0273438 C 17.24903 7.0273438 17.874081 7.2325787 18.400391 7.578125 L 18.400391 7.2402344 C 18.400391 4.0902344 18.800391 3.6200781 17.150391 3.5800781 z M 16.435547 8.0273438 C 15.143818 8.0273438 14.083984 9.0851838 14.083984 10.376953 L 14.083984 11.607422 L 13.570312 11.607422 C 13.375448 11.607422 13.210603 11.704118 13.119141 11.791016 C 13.027691 11.877916 12.983569 11.958238 12.951172 12.03125 C 12.886382 12.177277 12.867187 12.304789 12.867188 12.441406 L 12.867188 13.523438 L 12.867188 14.119141 L 12.867188 15.677734 L 12.867188 16.509766 L 13.570312 16.509766 L 19.472656 16.509766 L 20.173828 16.509766 L 20.173828 15.677734 L 20.173828 13.523438 L 20.173828 12.441406 C 20.173828 12.304794 20.156597 12.177281 20.091797 12.03125 C 20.059397 11.95824 20.015299 11.877916 19.923828 11.791016 C 19.832368 11.704116 19.667509 11.607422 19.472656 11.607422 L 18.927734 11.607422 L 18.927734 10.376953 C 18.927734 9.0851838 17.867902 8.0273438 16.576172 8.0273438 L 16.435547 8.0273438 z M 16.435547 9.2207031 L 16.576172 9.2207031 C 17.22782 9.2207031 17.734375 9.7251013 17.734375 10.376953 L 17.734375 11.607422 L 15.277344 11.607422 L 15.277344 10.376953 C 15.277344 9.7251013 15.7839 9.2207031 16.435547 9.2207031 z M 12.919922 9.9394531 C 12.559922 9.9594531 12.359141 10.480234 12.619141 10.740234 L 12.751953 10.904297 C 12.862211 10.870135 12.980058 10.842244 13.085938 10.802734 L 13.085938 10.378906 C 13.085938 10.228632 13.111295 10.084741 13.130859 9.9394531 L 12.919922 9.9394531 z M 19.882812 9.9394531 C 19.902378 10.084741 19.927734 10.228632 19.927734 10.378906 L 19.927734 10.791016 C 20.168811 10.875098 20.455966 10.916935 20.613281 11.066406 C 20.691227 11.140457 20.749315 11.223053 20.806641 11.302734 L 21.259766 10.740234 C 21.519766 10.460234 21.260625 9.9094531 20.890625 9.9394531 L 19.882812 9.9394531 z M 16.435547 10.220703 C 16.301234 10.220703 16.277344 10.244432 16.277344 10.378906 L 16.277344 10.607422 L 16.734375 10.607422 L 16.734375 10.378906 C 16.734375 10.244433 16.712442 10.220703 16.578125 10.220703 L 16.435547 10.220703 z ' fill='#{hex-color($white)}' stroke-width='0'/><path d='M 7.7792969 19.650391 L 7.7792969 19.660156 C 7.5392969 19.680156 7.3398437 19.910156 7.3398438 20.160156 L 7.3398438 22.619141 L 7.2792969 22.619141 C 6.1992969 22.619141 5.4208594 22.589844 4.8808594 22.589844 C 3.2408594 22.589844 3.6308594 23.020234 3.6308594 26.240234 L 3.6308594 30.710938 C 3.6308594 34.970937 3.0692969 34.330078 7.2792969 34.330078 L 8.5 34.330078 L 7.1992188 33.269531 C 7.0992188 33.189531 7.02 33.070703 7 32.970703 C 6.98 32.800703 7.0592186 32.619531 7.1992188 32.519531 L 8.5292969 31.419922 L 7.2792969 31.419922 C 6.8392969 31.419922 6.5605469 31.120703 6.5605469 30.720703 L 6.5605469 26.240234 C 6.5605469 25.800234 6.8392969 25.519531 7.2792969 25.519531 L 7.3398438 25.519531 L 7.3398438 28.019531 C 7.3398438 28.399531 7.8801564 28.650391 8.1601562 28.400391 L 13.060547 24.470703 C 13.310547 24.290703 13.310547 23.869453 13.060547 23.689453 L 8.1601562 19.769531 C 8.0601563 19.669531 7.9192969 19.630391 7.7792969 19.650391 z M 17.119141 22.580078 L 17.119141 22.589844 C 16.579141 22.569844 15.820703 22.609375 14.720703 22.609375 L 13.470703 22.609375 L 14.769531 23.679688 C 14.869531 23.749688 14.950703 23.879766 14.970703 24.009766 C 14.990703 24.169766 14.909531 24.310156 14.769531 24.410156 L 13.439453 25.509766 L 14.720703 25.509766 C 15.129702 25.509766 15.41841 25.778986 15.433594 26.199219 C 15.752266 26.097283 16.084896 26.027344 16.435547 26.027344 L 16.578125 26.027344 C 17.236645 26.027344 17.848901 26.228565 18.369141 26.5625 L 18.369141 26.240234 C 18.369141 23.090234 18.769141 22.620078 17.119141 22.580078 z M 16.435547 27.027344 C 15.143818 27.027344 14.083984 28.085184 14.083984 29.376953 L 14.083984 30.607422 L 13.570312 30.607422 C 13.375452 30.607422 13.210603 30.704118 13.119141 30.791016 C 13.027691 30.877916 12.983569 30.958238 12.951172 31.03125 C 12.886382 31.177277 12.867184 31.304789 12.867188 31.441406 L 12.867188 32.523438 L 12.867188 33.119141 L 12.867188 34.677734 L 12.867188 35.509766 L 13.570312 35.509766 L 19.472656 35.509766 L 20.173828 35.509766 L 20.173828 34.677734 L 20.173828 32.523438 L 20.173828 31.441406 C 20.173828 31.304794 20.156597 31.177281 20.091797 31.03125 C 20.059397 30.95824 20.015299 30.877916 19.923828 30.791016 C 19.832368 30.704116 19.667509 30.607422 19.472656 30.607422 L 18.927734 30.607422 L 18.927734 29.376953 C 18.927734 28.085184 17.867902 27.027344 16.576172 27.027344 L 16.435547 27.027344 z M 16.435547 28.220703 L 16.576172 28.220703 C 17.22782 28.220703 17.734375 28.725101 17.734375 29.376953 L 17.734375 30.607422 L 15.277344 30.607422 L 15.277344 29.376953 C 15.277344 28.725101 15.7839 28.220703 16.435547 28.220703 z M 13.109375 29.150391 L 8.9199219 32.509766 C 8.6599219 32.689766 8.6599219 33.109063 8.9199219 33.289062 L 11.869141 35.648438 L 11.869141 34.677734 L 11.869141 33.119141 L 11.869141 32.523438 L 11.869141 31.441406 C 11.869141 31.217489 11.912641 30.907486 12.037109 30.626953 C 12.093758 30.499284 12.228597 30.257492 12.429688 30.066406 C 12.580253 29.92335 12.859197 29.887344 13.085938 29.802734 L 13.085938 29.378906 C 13.085938 29.300761 13.104 29.227272 13.109375 29.150391 z M 16.435547 29.220703 C 16.301234 29.220703 16.277344 29.244432 16.277344 29.378906 L 16.277344 29.607422 L 16.734375 29.607422 L 16.734375 29.378906 C 16.734375 29.244433 16.712442 29.220703 16.578125 29.220703 L 16.435547 29.220703 z M 12.943359 36.509766 L 13.820312 37.210938 C 14.090314 37.460938 14.639141 37.210078 14.619141 36.830078 L 14.619141 36.509766 L 13.570312 36.509766 L 12.943359 36.509766 z M 10.330078 38.650391 L 10.339844 38.660156 C 10.099844 38.680156 9.9001562 38.910156 9.9101562 39.160156 L 9.9101562 41.630859 L 7.3007812 41.630859 C 6.2207812 41.630859 5.4403906 41.589844 4.9003906 41.589844 C 3.2603906 41.589844 3.6503906 42.020234 3.6503906 45.240234 L 3.6503906 49.710938 C 3.6503906 53.370936 3.4202344 53.409141 5.9902344 53.369141 L 4.6503906 52.269531 C 4.5503906 52.189531 4.4692187 52.070703 4.4492188 51.970703 C 4.4492188 51.800703 4.5203906 51.619531 4.6503906 51.519531 L 6.609375 49.919922 C 6.579375 49.859922 6.5703125 49.790703 6.5703125 49.720703 L 6.5703125 45.240234 C 6.5703125 44.800234 6.8490625 44.519531 7.2890625 44.519531 L 9.9003906 44.519531 L 9.9003906 47.019531 C 9.9003906 47.379531 10.399219 47.620391 10.699219 47.400391 L 15.630859 43.470703 C 15.870859 43.290703 15.870859 42.869453 15.630859 42.689453 L 10.689453 38.769531 C 10.589453 38.689531 10.460078 38.640391 10.330078 38.650391 z M 16.869141 41.585938 C 16.616211 41.581522 16.322969 41.584844 15.980469 41.589844 L 15.970703 41.589844 L 17.310547 42.689453 C 17.410547 42.759453 17.489766 42.889531 17.509766 43.019531 C 17.529766 43.179531 17.479609 43.319922 17.349609 43.419922 L 15.390625 45.019531 C 15.406724 45.075878 15.427133 45.132837 15.4375 45.197266 C 15.754974 45.096169 16.086404 45.027344 16.435547 45.027344 L 16.578125 45.027344 C 17.24129 45.027344 17.858323 45.230088 18.380859 45.568359 L 18.380859 45.25 C 18.380859 42.0475 18.639648 41.616836 16.869141 41.585938 z M 16.435547 46.027344 C 15.143818 46.027344 14.083984 47.085184 14.083984 48.376953 L 14.083984 49.607422 L 13.570312 49.607422 C 13.375448 49.607422 13.210603 49.704118 13.119141 49.791016 C 13.027691 49.877916 12.983569 49.958238 12.951172 50.03125 C 12.886382 50.177277 12.867187 50.304789 12.867188 50.441406 L 12.867188 51.523438 L 12.867188 52.119141 L 12.867188 53.677734 L 12.867188 54.509766 L 13.570312 54.509766 L 19.472656 54.509766 L 20.173828 54.509766 L 20.173828 53.677734 L 20.173828 51.523438 L 20.173828 50.441406 C 20.173828 50.304794 20.156597 50.177281 20.091797 50.03125 C 20.059397 49.95824 20.015299 49.877916 19.923828 49.791016 C 19.832368 49.704116 19.667509 49.607422 19.472656 49.607422 L 18.927734 49.607422 L 18.927734 48.376953 C 18.927734 47.085184 17.867902 46.027344 16.576172 46.027344 L 16.435547 46.027344 z M 16.435547 47.220703 L 16.576172 47.220703 C 17.22782 47.220703 17.734375 47.725101 17.734375 48.376953 L 17.734375 49.607422 L 15.277344 49.607422 L 15.277344 48.376953 C 15.277344 47.725101 15.7839 47.220703 16.435547 47.220703 z M 11.470703 47.490234 C 11.410703 47.510234 11.349063 47.539844 11.289062 47.589844 L 6.3496094 51.519531 C 6.1096094 51.699531 6.1096094 52.120781 6.3496094 52.300781 L 11.289062 56.220703 C 11.569064 56.440703 12.070312 56.199844 12.070312 55.839844 L 12.070312 55.509766 L 11.869141 55.509766 L 11.869141 53.677734 L 11.869141 52.119141 L 11.869141 51.523438 L 11.869141 50.441406 C 11.869141 50.217489 11.912641 49.907486 12.037109 49.626953 C 12.043809 49.611855 12.061451 49.584424 12.070312 49.566406 L 12.070312 47.960938 C 12.070312 47.660938 11.770703 47.430234 11.470703 47.490234 z M 16.435547 48.220703 C 16.301234 48.220703 16.277344 48.244432 16.277344 48.378906 L 16.277344 48.607422 L 16.734375 48.607422 L 16.734375 48.378906 C 16.734375 48.244433 16.712442 48.220703 16.578125 48.220703 L 16.435547 48.220703 z M 13.060547 57.650391 L 13.060547 57.660156 C 12.830547 57.690156 12.660156 57.920156 12.660156 58.160156 L 12.660156 60.630859 L 7.2792969 60.630859 C 6.1992969 60.630859 5.4208594 60.589844 4.8808594 60.589844 C 3.2408594 60.589844 3.6308594 61.020234 3.6308594 64.240234 L 3.6308594 69.109375 L 6.5605469 66.740234 L 6.5605469 64.240234 C 6.5605469 63.800234 6.8392969 63.519531 7.2792969 63.519531 L 12.660156 63.519531 L 12.660156 66.019531 C 12.660156 66.299799 12.960394 66.500006 13.226562 66.474609 C 13.625751 65.076914 14.904956 64.035678 16.421875 64.029297 L 18.380859 62.470703 C 18.620859 62.290703 18.620859 61.869453 18.380859 61.689453 L 13.439453 57.769531 C 13.339453 57.669531 13.200547 57.630391 13.060547 57.650391 z M 18.359375 63.810547 L 17.800781 64.269531 C 18.004793 64.350836 18.198411 64.450249 18.380859 64.568359 L 18.380859 64.25 L 18.380859 63.810547 L 18.359375 63.810547 z M 16.435547 65.027344 C 15.143818 65.027344 14.083984 66.085184 14.083984 67.376953 L 14.083984 68.607422 L 13.570312 68.607422 C 13.375448 68.607422 13.210603 68.704118 13.119141 68.791016 C 13.027691 68.877916 12.983569 68.958238 12.951172 69.03125 C 12.886382 69.177277 12.867187 69.304789 12.867188 69.441406 L 12.867188 70.523438 L 12.867188 71.119141 L 12.867188 72.677734 L 12.867188 73.509766 L 13.570312 73.509766 L 19.472656 73.509766 L 20.173828 73.509766 L 20.173828 72.677734 L 20.173828 70.523438 L 20.173828 69.441406 C 20.173828 69.304794 20.156597 69.177281 20.091797 69.03125 C 20.059397 68.95824 20.015299 68.877916 19.923828 68.791016 C 19.832368 68.704116 19.667509 68.607422 19.472656 68.607422 L 18.927734 68.607422 L 18.927734 67.376953 C 18.927734 66.085184 17.867902 65.027344 16.576172 65.027344 L 16.435547 65.027344 z M 16.435547 66.220703 L 16.576172 66.220703 C 17.22782 66.220703 17.734375 66.725101 17.734375 67.376953 L 17.734375 68.607422 L 15.277344 68.607422 L 15.277344 67.376953 C 15.277344 66.725101 15.7839 66.220703 16.435547 66.220703 z M 8.7207031 66.509766 C 8.6507031 66.529766 8.5895312 66.559375 8.5195312 66.609375 L 3.5996094 70.519531 C 3.3496094 70.699531 3.3496094 71.120781 3.5996094 71.300781 L 8.5292969 75.220703 C 8.8092969 75.440703 9.3105469 75.199844 9.3105469 74.839844 L 9.3105469 72.339844 L 11.869141 72.339844 L 11.869141 71.119141 L 11.869141 70.523438 L 11.869141 69.449219 L 9.3203125 69.449219 L 9.3203125 66.980469 C 9.3203125 66.680469 9.0007031 66.449766 8.7207031 66.509766 z M 16.435547 67.220703 C 16.301234 67.220703 16.277344 67.244432 16.277344 67.378906 L 16.277344 67.607422 L 16.734375 67.607422 L 16.734375 67.378906 C 16.734375 67.244433 16.712442 67.220703 16.578125 67.220703 L 16.435547 67.220703 z M 19.248047 78.800781 C 19.148558 78.831033 19.050295 78.90106 18.970703 78.970703 L 18.070312 79.869141 C 17.630312 79.569141 16.710703 79.619141 14.720703 79.619141 L 7.2792969 79.619141 C 6.1992969 79.619141 5.4208594 79.589844 4.8808594 79.589844 C 3.2408594 79.589844 3.6308594 80.020234 3.6308594 83.240234 L 3.6308594 83.939453 L 6.5605469 84.240234 L 6.5605469 83.240234 C 6.5605469 82.800234 6.8392969 82.519531 7.2792969 82.519531 L 14.720703 82.519531 C 14.920703 82.519531 15.090703 82.600703 15.220703 82.720703 L 13.419922 84.519531 C 13.279464 84.665607 13.281282 84.881022 13.363281 85.054688 C 13.880838 83.867655 15.067337 83.027344 16.435547 83.027344 L 16.578125 83.027344 C 18.290465 83.027344 19.703357 84.345788 19.890625 86.011719 L 19.960938 86.019531 C 20.240938 86.049531 20.520234 85.770234 20.490234 85.490234 L 19.789062 79.240234 C 19.789062 78.973661 19.498025 78.767523 19.25 78.800781 L 19.248047 78.800781 z M 16.435547 84.027344 C 15.143818 84.027344 14.083984 85.085184 14.083984 86.376953 L 14.083984 87.607422 L 13.570312 87.607422 C 13.375448 87.607422 13.210603 87.704118 13.119141 87.791016 C 13.027691 87.877916 12.983569 87.958238 12.951172 88.03125 C 12.886382 88.177277 12.867187 88.304789 12.867188 88.441406 L 12.867188 89.523438 L 12.867188 90.119141 L 12.867188 91.677734 L 12.867188 92.509766 L 13.570312 92.509766 L 19.472656 92.509766 L 20.173828 92.509766 L 20.173828 91.677734 L 20.173828 89.523438 L 20.173828 88.441406 C 20.173828 88.304794 20.156597 88.177281 20.091797 88.03125 C 20.059397 87.95824 20.015299 87.877916 19.923828 87.791016 C 19.832368 87.704116 19.667509 87.607422 19.472656 87.607422 L 18.927734 87.607422 L 18.927734 86.376953 C 18.927734 85.085184 17.867902 84.027344 16.576172 84.027344 L 16.435547 84.027344 z M 2.0507812 84.900391 C 1.8507824 84.970391 1.6907031 85.199453 1.7207031 85.439453 L 2.4199219 91.689453 C 2.4399219 92.049453 3 92.240929 3.25 91.960938 L 4.0507812 91.160156 C 4.0707812 91.160156 4.0898437 91.140156 4.0898438 91.160156 C 4.5498437 91.400156 5.4595313 91.330078 7.2695312 91.330078 L 11.869141 91.330078 L 11.869141 90.119141 L 11.869141 89.523438 L 11.869141 88.441406 C 11.869141 88.437991 11.871073 88.433136 11.871094 88.429688 L 7.2792969 88.429688 C 7.1292969 88.429688 6.9808594 88.400078 6.8808594 88.330078 L 8.8007812 86.400391 C 9.1007822 86.160391 8.8992969 85.600547 8.5292969 85.560547 L 2.25 84.910156 L 2.0507812 84.910156 L 2.0507812 84.900391 z M 16.435547 85.220703 L 16.576172 85.220703 C 17.22782 85.220703 17.734375 85.725101 17.734375 86.376953 L 17.734375 87.607422 L 15.277344 87.607422 L 15.277344 86.376953 C 15.277344 85.725101 15.7839 85.220703 16.435547 85.220703 z M 4.8808594 98.599609 C 3.5508594 98.599609 3.5400781 99.080402 3.5800781 100.90039 L 4.7207031 99.529297 C 4.8007031 99.429297 4.9405469 99.360078 5.0605469 99.330078 C 5.2205469 99.330078 5.4 99.409297 5.5 99.529297 L 7.1601562 101.56055 C 7.2001563 101.56055 7.2292969 101.5293 7.2792969 101.5293 L 14.720703 101.5293 C 15.060703 101.5293 15.289141 101.7293 15.369141 102.0293 L 12.939453 102.0293 C 12.599453 102.0793 12.410625 102.55055 12.640625 102.81055 L 13.470703 103.85742 C 14.029941 102.77899 15.146801 102.02734 16.435547 102.02734 L 16.578125 102.02734 C 18.158418 102.02734 19.491598 103.14879 19.835938 104.63086 L 21.279297 102.82031 C 21.499297 102.55031 21.260156 102.06078 20.910156 102.05078 L 18.400391 102.05078 C 18.420391 98.150792 19.000234 98.650391 14.740234 98.650391 L 7.2792969 98.650391 C 6.1992969 98.650391 5.4208594 98.609375 4.8808594 98.609375 L 4.8808594 98.599609 z M 5.0292969 101.06055 C 4.9292969 101.09055 4.83 101.15977 4.75 101.25977 L 0.81054688 106.16016 C 0.61054688 106.44016 0.8409375 106.92945 1.2109375 106.93945 L 3.5996094 106.93945 C 3.5796094 110.87945 3.1497656 110.33984 7.2597656 110.33984 L 11.869141 110.33984 L 11.869141 109.11914 L 11.869141 108.52344 L 11.869141 107.44141 L 11.869141 107.43945 L 7.2792969 107.43945 C 6.9292969 107.43945 6.7091406 107.23945 6.6191406 106.93945 L 9.0605469 106.93945 C 9.4305469 106.93945 9.6909375 106.44016 9.4609375 106.16016 L 5.5 101.25977 C 5.4 101.10977 5.1992969 101.03055 5.0292969 101.06055 z M 16.435547 103.02734 C 15.143818 103.02734 14.083984 104.08518 14.083984 105.37695 L 14.083984 106.60742 L 13.570312 106.60742 C 13.375448 106.60742 13.210603 106.70409 13.119141 106.79102 C 13.027691 106.87792 12.983569 106.95823 12.951172 107.03125 C 12.886382 107.17727 12.867187 107.30479 12.867188 107.44141 L 12.867188 108.52344 L 12.867188 109.11914 L 12.867188 110.67773 L 12.867188 111.50977 L 13.570312 111.50977 L 19.472656 111.50977 L 20.173828 111.50977 L 20.173828 110.67773 L 20.173828 108.52344 L 20.173828 107.44141 C 20.173828 107.3048 20.156597 107.17728 20.091797 107.03125 C 20.059397 106.95825 20.015299 106.87792 19.923828 106.79102 C 19.832368 106.70412 19.667509 106.60742 19.472656 106.60742 L 18.927734 106.60742 L 18.927734 105.37695 C 18.927734 104.08518 17.867902 103.02734 16.576172 103.02734 L 16.435547 103.02734 z M 16.435547 104.2207 L 16.576172 104.2207 C 17.22782 104.2207 17.734375 104.7251 17.734375 105.37695 L 17.734375 106.60742 L 15.277344 106.60742 L 15.277344 105.37695 C 15.277344 104.7251 15.7839 104.2207 16.435547 104.2207 z M 16.435547 105.2207 C 16.301234 105.2207 16.277344 105.24444 16.277344 105.37891 L 16.277344 105.60742 L 16.734375 105.60742 L 16.734375 105.37891 C 16.734375 105.24441 16.712442 105.2207 16.578125 105.2207 L 16.435547 105.2207 z M 4.8808594 117.58984 L 4.8808594 117.59961 C 3.7208594 117.59961 3.5800781 117.90016 3.5800781 119.16016 L 4.7207031 117.7793 C 4.8007031 117.6793 4.9405469 117.63914 5.0605469 117.61914 C 5.2205469 117.61914 5.4 117.6593 5.5 117.7793 L 7.7207031 120.5293 L 14.720703 120.5293 C 15.123595 120.5293 15.408576 120.79174 15.431641 121.20117 C 15.750992 121.09876 16.08404 121.02734 16.435547 121.02734 L 16.578125 121.02734 C 17.24903 121.02734 17.874081 121.23262 18.400391 121.57812 L 18.400391 121.25 C 18.400391 117.05 19.120234 117.61914 14.740234 117.61914 L 7.2792969 117.61914 C 6.1992969 117.61914 5.4208594 117.58984 4.8808594 117.58984 z M 4.9804688 119.33984 C 4.8804688 119.36984 4.81 119.44 4.75 119.5 L 0.80078125 124.43945 C 0.60078125 124.71945 0.8292182 125.2107 1.1992188 125.2207 L 3.5996094 125.2207 L 3.5996094 125.7207 C 3.5996094 129.9807 3.0497656 129.33984 7.2597656 129.33984 L 11.869141 129.33984 L 11.869141 128.11914 L 11.869141 127.52344 L 11.869141 126.44141 C 11.869141 126.43799 11.871073 126.43314 11.871094 126.42969 L 7.2792969 126.42969 C 6.8392969 126.42969 6.5605469 126.13094 6.5605469 125.71094 L 6.5605469 125.21094 L 9.0605469 125.21094 C 9.4305469 125.23094 9.6909375 124.70969 9.4609375 124.42969 L 5.5 119.5 C 5.3820133 119.35252 5.1682348 119.28513 4.9804688 119.33984 z M 12.839844 121.7793 C 12.539844 121.8793 12.410625 122.32055 12.640625 122.56055 L 13.267578 123.34375 C 13.473522 122.72168 13.852237 122.1828 14.353516 121.7793 L 12.839844 121.7793 z M 18.658203 121.7793 C 19.393958 122.37155 19.878978 123.25738 19.916016 124.25781 L 21.279297 122.56055 C 21.499297 122.28055 21.260156 121.7893 20.910156 121.7793 L 18.658203 121.7793 z M 16.435547 122.02734 C 15.143818 122.02734 14.083984 123.08518 14.083984 124.37695 L 14.083984 125.60742 L 13.570312 125.60742 C 13.375448 125.60742 13.210603 125.70409 13.119141 125.79102 C 13.027691 125.87792 12.983569 125.95823 12.951172 126.03125 C 12.886382 126.17727 12.867187 126.30479 12.867188 126.44141 L 12.867188 127.52344 L 12.867188 128.11914 L 12.867188 129.67773 L 12.867188 130.50977 L 13.570312 130.50977 L 19.472656 130.50977 L 20.173828 130.50977 L 20.173828 129.67773 L 20.173828 127.52344 L 20.173828 126.44141 C 20.173828 126.3048 20.156597 126.17728 20.091797 126.03125 C 20.059397 125.95825 20.015299 125.87792 19.923828 125.79102 C 19.832368 125.70412 19.667509 125.60742 19.472656 125.60742 L 18.927734 125.60742 L 18.927734 124.37695 C 18.927734 123.08518 17.867902 122.02734 16.576172 122.02734 L 16.435547 122.02734 z M 16.435547 123.2207 L 16.576172 123.2207 C 17.22782 123.2207 17.734375 123.7251 17.734375 124.37695 L 17.734375 125.60742 L 15.277344 125.60742 L 15.277344 124.37695 C 15.277344 123.7251 15.7839 123.2207 16.435547 123.2207 z M 16.435547 124.2207 C 16.301234 124.2207 16.277344 124.24444 16.277344 124.37891 L 16.277344 124.60742 L 16.734375 124.60742 L 16.734375 124.37891 C 16.734375 124.24441 16.712442 124.2207 16.578125 124.2207 L 16.435547 124.2207 z M 5.9394531 136.58984 L 5.9394531 136.59961 L 8.3105469 139.5293 L 14.730469 139.5293 C 15.131912 139.5293 15.414551 139.79039 15.439453 140.19727 C 15.756409 140.09653 16.087055 140.02734 16.435547 140.02734 L 16.578125 140.02734 C 17.24903 140.02734 17.874081 140.23261 18.400391 140.57812 L 18.400391 140.25 C 18.400391 136.05 19.120234 136.61914 14.740234 136.61914 L 7.2792969 136.61914 C 6.6792969 136.61914 6.3594531 136.59984 5.9394531 136.58984 z M 4.2207031 136.66016 C 3.8207031 136.74016 3.6791406 136.96016 3.6191406 137.41016 L 4.2207031 136.66992 L 4.2207031 136.66016 z M 5.0605469 137.57031 L 5.0605469 137.58984 C 4.9405469 137.58984 4.8197656 137.66953 4.7597656 137.76953 L 0.81054688 142.66992 C 0.57054688 142.96992 0.8109375 143.50023 1.2109375 143.49023 L 3.5996094 143.49023 L 3.5996094 144.71094 C 3.5996094 148.97094 3.0497656 148.33008 7.2597656 148.33008 L 11.869141 148.33008 L 11.869141 147.11914 L 11.869141 146.52344 L 11.869141 145.44141 C 11.869141 145.43799 11.871073 145.43314 11.871094 145.42969 L 7.2792969 145.42969 C 6.8392969 145.42969 6.5605469 145.13094 6.5605469 144.71094 L 6.5605469 143.49023 L 9.0605469 143.49023 C 9.4605469 143.53023 9.7309375 142.95945 9.4609375 142.68945 L 5.5 137.76953 C 5.4 137.63953 5.2305469 137.57031 5.0605469 137.57031 z M 16.435547 141.02734 C 15.143818 141.02734 14.083984 142.08518 14.083984 143.37695 L 14.083984 144.60742 L 13.570312 144.60742 C 13.375448 144.60742 13.210603 144.70409 13.119141 144.79102 C 13.027691 144.87792 12.983569 144.95823 12.951172 145.03125 C 12.886382 145.17727 12.867187 145.30479 12.867188 145.44141 L 12.867188 146.52344 L 12.867188 147.11914 L 12.867188 148.67773 L 12.867188 149.50977 L 13.570312 149.50977 L 19.472656 149.50977 L 20.173828 149.50977 L 20.173828 148.67773 L 20.173828 146.52344 L 20.173828 145.44141 C 20.173828 145.3048 20.156597 145.17728 20.091797 145.03125 C 20.059397 144.95825 20.015299 144.87792 19.923828 144.79102 C 19.832368 144.70412 19.667509 144.60742 19.472656 144.60742 L 18.927734 144.60742 L 18.927734 143.37695 C 18.927734 142.08518 17.867902 141.02734 16.576172 141.02734 L 16.435547 141.02734 z M 12.849609 141.5 C 12.549609 141.6 12.420391 142.0393 12.650391 142.2793 L 13.136719 142.88672 C 13.213026 142.38119 13.390056 141.90696 13.667969 141.5 L 12.849609 141.5 z M 19.34375 141.5 C 19.710704 142.03735 19.927734 142.68522 19.927734 143.37891 L 19.927734 143.79102 C 19.965561 143.80421 20.005506 143.81448 20.044922 143.82617 L 21.289062 142.2793 C 21.509062 141.9993 21.269922 141.51 20.919922 141.5 L 19.34375 141.5 z M 16.435547 142.2207 L 16.576172 142.2207 C 17.22782 142.2207 17.734375 142.7251 17.734375 143.37695 L 17.734375 144.60742 L 15.277344 144.60742 L 15.277344 143.37695 C 15.277344 142.7251 15.7839 142.2207 16.435547 142.2207 z M 16.435547 143.2207 C 16.301234 143.2207 16.277344 143.24444 16.277344 143.37891 L 16.277344 143.60742 L 16.734375 143.60742 L 16.734375 143.37891 C 16.734375 143.24441 16.712442 143.2207 16.578125 143.2207 L 16.435547 143.2207 z M 17.130859 155.59961 C 16.580859 155.57961 15.810469 155.63086 14.730469 155.63086 L 6.5292969 155.63086 L 8.9101562 158.5293 L 14.730469 158.5293 C 15.131912 158.5293 15.414551 158.79039 15.439453 159.19727 C 15.756409 159.09653 16.087055 159.02734 16.435547 159.02734 L 16.578125 159.02734 C 17.24903 159.02734 17.874081 159.23261 18.400391 159.57812 L 18.400391 159.25977 C 18.400391 156.10977 18.800391 155.63961 17.150391 155.59961 L 17.130859 155.59961 z M 5.0292969 155.86914 L 5.0292969 155.88086 C 4.9292969 155.90086 4.83 155.98055 4.75 156.06055 L 0.81054688 160.96094 C 0.61054688 161.26094 0.8409375 161.73977 1.2109375 161.75977 L 3.5996094 161.75977 L 3.5996094 163.7207 C 3.5996094 167.9807 3.0497656 167.33984 7.2597656 167.33984 L 11.869141 167.33984 L 11.869141 166.11914 L 11.869141 165.52344 L 11.869141 164.44141 L 11.869141 164.43945 L 7.2792969 164.43945 C 6.8392969 164.43945 6.5605469 164.1407 6.5605469 163.7207 L 6.5605469 161.75 L 9.0605469 161.75 C 9.4305469 161.77 9.6909375 161.2507 9.4609375 160.9707 L 5.5 156.07031 C 5.4 155.92031 5.1992969 155.84914 5.0292969 155.86914 z M 16.435547 160.02734 C 15.143818 160.02734 14.083984 161.08518 14.083984 162.37695 L 14.083984 163.60742 L 13.570312 163.60742 C 13.375448 163.60742 13.210603 163.70409 13.119141 163.79102 C 13.027691 163.87792 12.983569 163.95823 12.951172 164.03125 C 12.886382 164.17727 12.867187 164.30479 12.867188 164.44141 L 12.867188 165.52344 L 12.867188 166.11914 L 12.867188 167.67773 L 12.867188 168.50977 L 13.570312 168.50977 L 19.472656 168.50977 L 20.173828 168.50977 L 20.173828 167.67773 L 20.173828 165.52344 L 20.173828 164.44141 C 20.173828 164.3048 20.156597 164.17728 20.091797 164.03125 C 20.059397 163.95825 20.015299 163.87792 19.923828 163.79102 C 19.832368 163.70412 19.667509 163.60742 19.472656 163.60742 L 18.927734 163.60742 L 18.927734 162.37695 C 18.927734 161.08518 17.867902 160.02734 16.576172 160.02734 L 16.435547 160.02734 z M 12.900391 161.2207 C 12.580391 161.2807 12.419141 161.74 12.619141 162 L 13.085938 162.58594 L 13.085938 162.37891 C 13.085938 161.97087 13.170592 161.58376 13.306641 161.2207 L 12.900391 161.2207 z M 16.435547 161.2207 L 16.576172 161.2207 C 17.22782 161.2207 17.734375 161.7251 17.734375 162.37695 L 17.734375 163.60742 L 15.277344 163.60742 L 15.277344 162.37695 C 15.277344 161.7251 15.7839 161.2207 16.435547 161.2207 z M 19.708984 161.23047 C 19.842743 161.59081 19.927734 161.97449 19.927734 162.37891 L 19.927734 162.79102 C 20.119162 162.85779 20.322917 162.91147 20.484375 163 L 21.279297 162.00977 C 21.499297 161.72977 21.260156 161.24047 20.910156 161.23047 L 19.708984 161.23047 z M 16.435547 162.2207 C 16.301234 162.2207 16.277344 162.24444 16.277344 162.37891 L 16.277344 162.60742 L 16.734375 162.60742 L 16.734375 162.37891 C 16.734375 162.24441 16.712442 162.2207 16.578125 162.2207 L 16.435547 162.2207 z M 5.0996094 174.49023 L 5.1308594 174.5 C 4.9808594 174.5 4.83 174.56922 4.75 174.69922 L 0.80078125 179.59961 C 0.56078125 179.86961 0.7992182 180.42039 1.1992188 180.40039 L 3.5996094 180.40039 L 3.5996094 182.7207 C 3.5996094 186.9807 3.0497656 186.33984 7.2597656 186.33984 L 11.869141 186.33984 L 11.869141 185.11914 L 11.869141 184.52344 L 11.869141 183.44141 L 11.869141 183.43945 L 7.25 183.43945 C 6.82 183.43945 6.5507814 183.1407 6.5507812 182.7207 L 6.5507812 180.41992 L 9.0507812 180.41992 C 9.4307824 180.44992 9.7092187 179.87984 9.4492188 179.58984 L 5.4804688 174.68945 C 5.3804688 174.55945 5.2496094 174.49023 5.0996094 174.49023 z M 17.150391 174.58008 L 17.130859 174.59961 C 16.580859 174.57961 15.810469 174.63086 14.730469 174.63086 L 6.8300781 174.63086 L 9.1796875 177.5293 L 14.699219 177.5293 C 15.104107 177.5293 15.391475 177.79407 15.412109 178.20703 C 15.737096 178.1006 16.076913 178.02734 16.435547 178.02734 L 16.578125 178.02734 C 17.24903 178.02734 17.874081 178.2326 18.400391 178.57812 L 18.400391 178.24023 C 18.400391 175.09023 18.800391 174.62008 17.150391 174.58008 z M 16.435547 179.02734 C 15.143818 179.02734 14.083984 180.08518 14.083984 181.37695 L 14.083984 182.60742 L 13.570312 182.60742 C 13.375448 182.60742 13.210603 182.70409 13.119141 182.79102 C 13.027691 182.87792 12.983569 182.95823 12.951172 183.03125 C 12.886382 183.17727 12.867187 183.30479 12.867188 183.44141 L 12.867188 184.52344 L 12.867188 185.11914 L 12.867188 186.67773 L 12.867188 187.50977 L 13.570312 187.50977 L 19.472656 187.50977 L 20.173828 187.50977 L 20.173828 186.67773 L 20.173828 184.52344 L 20.173828 183.44141 C 20.173828 183.3048 20.156597 183.17728 20.091797 183.03125 C 20.059397 182.95825 20.015299 182.87792 19.923828 182.79102 C 19.832368 182.70412 19.667509 182.60742 19.472656 182.60742 L 18.927734 182.60742 L 18.927734 181.37695 C 18.927734 180.08518 17.867902 179.02734 16.576172 179.02734 L 16.435547 179.02734 z M 16.435547 180.2207 L 16.576172 180.2207 C 17.22782 180.2207 17.734375 180.7251 17.734375 181.37695 L 17.734375 182.60742 L 15.277344 182.60742 L 15.277344 181.37695 C 15.277344 180.7251 15.7839 180.2207 16.435547 180.2207 z M 19.816406 180.57031 C 19.882311 180.83091 19.927734 181.09907 19.927734 181.37891 L 19.927734 181.79102 C 20.168811 181.87511 20.455966 181.91694 20.613281 182.06641 C 20.630645 182.0829 20.639883 182.10199 20.65625 182.11914 L 21.259766 181.36914 C 21.479766 181.06914 21.240625 180.59031 20.890625 180.57031 L 19.816406 180.57031 z M 12.820312 180.58984 C 12.520316 180.68984 12.389141 181.11914 12.619141 181.36914 L 12.990234 181.83203 C 13.022029 181.82207 13.055579 181.81406 13.085938 181.80273 L 13.085938 181.37891 C 13.085938 181.10616 13.128698 180.84442 13.191406 180.58984 L 12.820312 180.58984 z M 16.435547 181.2207 C 16.301234 181.2207 16.277344 181.24444 16.277344 181.37891 L 16.277344 181.60742 L 16.734375 181.60742 L 16.734375 181.37891 C 16.734375 181.24441 16.712442 181.2207 16.578125 181.2207 L 16.435547 181.2207 z M 4.9609375 193.15039 L 4.9707031 193.16016 C 4.8707031 193.19016 4.8 193.25984 4.75 193.33984 L 0.81054688 198.24023 C 0.61054688 198.54023 0.8409375 199.01906 1.2109375 199.03906 L 3.5996094 199.03906 L 3.5996094 201.7207 C 3.5996094 205.9807 3.0497656 205.33984 7.2597656 205.33984 L 11.869141 205.33984 L 11.869141 204.11914 L 11.869141 203.52344 L 11.869141 202.44141 C 11.869141 202.44141 11.869141 202.43945 11.869141 202.43945 L 7.2695312 202.43945 C 6.8295312 202.43945 6.5507814 202.1407 6.5507812 201.7207 L 6.5507812 199.01953 L 9.0507812 199.01953 C 9.4207814 199.04953 9.6792188 198.54 9.4492188 198.25 L 5.4902344 193.34961 C 5.3702344 193.17961 5.1509375 193.10039 4.9609375 193.15039 z M 17.150391 193.58008 L 17.130859 193.58984 C 16.580859 193.56984 15.810469 193.61914 14.730469 193.61914 L 7.0996094 193.61914 L 9.4199219 196.46094 L 9.4492188 196.51953 L 14.699219 196.51953 C 15.106887 196.51953 15.397075 196.78718 15.414062 197.20508 C 15.738375 197.09913 16.077769 197.02734 16.435547 197.02734 L 16.578125 197.02734 C 17.24903 197.02734 17.874081 197.23259 18.400391 197.57812 L 18.400391 197.24023 C 18.400391 194.09023 18.800391 193.62008 17.150391 193.58008 z M 16.435547 198.02734 C 15.143818 198.02734 14.083984 199.08518 14.083984 200.37695 L 14.083984 201.60742 L 13.570312 201.60742 C 13.375448 201.60742 13.210603 201.70409 13.119141 201.79102 C 13.027691 201.87792 12.983569 201.95823 12.951172 202.03125 C 12.886382 202.17727 12.867187 202.30479 12.867188 202.44141 L 12.867188 203.52344 L 12.867188 204.11914 L 12.867188 205.67773 L 12.867188 206.50977 L 13.570312 206.50977 L 19.472656 206.50977 L 20.173828 206.50977 L 20.173828 205.67773 L 20.173828 203.52344 L 20.173828 202.44141 C 20.173828 202.3048 20.156597 202.17728 20.091797 202.03125 C 20.059397 201.95825 20.015299 201.87792 19.923828 201.79102 C 19.832368 201.70412 19.667509 201.60742 19.472656 201.60742 L 18.927734 201.60742 L 18.927734 200.37695 C 18.927734 199.08518 17.867902 198.02734 16.576172 198.02734 L 16.435547 198.02734 z M 16.435547 199.2207 L 16.576172 199.2207 C 17.22782 199.2207 17.734375 199.7251 17.734375 200.37695 L 17.734375 201.60742 L 15.277344 201.60742 L 15.277344 200.37695 C 15.277344 199.7251 15.7839 199.2207 16.435547 199.2207 z M 12.919922 199.93945 C 12.559922 199.95945 12.359141 200.48023 12.619141 200.74023 L 12.751953 200.9043 C 12.862211 200.87013 12.980058 200.84224 13.085938 200.80273 L 13.085938 200.37891 C 13.085938 200.22863 13.111295 200.08474 13.130859 199.93945 L 12.919922 199.93945 z M 19.882812 199.93945 C 19.902378 200.08474 19.927734 200.22863 19.927734 200.37891 L 19.927734 200.79102 C 20.168811 200.87511 20.455966 200.91694 20.613281 201.06641 C 20.691227 201.14046 20.749315 201.22305 20.806641 201.30273 L 21.259766 200.74023 C 21.519766 200.46023 21.260625 199.90945 20.890625 199.93945 L 19.882812 199.93945 z M 16.435547 200.2207 C 16.301234 200.2207 16.277344 200.24444 16.277344 200.37891 L 16.277344 200.60742 L 16.734375 200.60742 L 16.734375 200.37891 C 16.734375 200.24441 16.712442 200.2207 16.578125 200.2207 L 16.435547 200.2207 z ' fill='#{hex-color($highlight-text-color)}' stroke-width='0' /></svg>"); + } + } + + &.disabled { + i.fa-retweet { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' height='209' width='22'><path d='M 18.972656 1.2011719 C 18.829825 1.1881782 18.685932 1.2302188 18.572266 1.3300781 L 15.990234 3.5996094 C 15.58109 3.6070661 15.297269 3.609375 14.730469 3.609375 L 7.0996094 3.609375 L 9.4199219 6.4609375 L 9.4492188 6.5195312 L 12.664062 6.5195312 L 6.5761719 11.867188 C 6.5674697 11.818249 6.5507813 11.773891 6.5507812 11.720703 L 6.5507812 9.0195312 L 9.0507812 9.0195312 C 9.4207813 9.0495313 9.6792188 8.54 9.4492188 8.25 L 5.5 3.3496094 C 5.38 3.1796094 5.1607031 3.1003906 4.9707031 3.1503906 L 4.9707031 3.1601562 C 4.8707031 3.1901563 4.8 3.2598438 4.75 3.3398438 L 0.80078125 8.2402344 C 0.60078125 8.5402344 0.8292187 9.0190625 1.1992188 9.0390625 L 3.5996094 9.0390625 L 3.5996094 11.720703 C 3.5996094 13.045739 3.5690668 13.895038 3.6503906 14.4375 L 2.6152344 15.347656 C 2.3879011 15.547375 2.3754917 15.901081 2.5859375 16.140625 L 3.1464844 16.78125 C 3.3569308 17.020794 3.7101667 17.053234 3.9375 16.853516 L 19.892578 2.8359375 C 20.119911 2.6362188 20.134275 2.282513 19.923828 2.0429688 L 19.361328 1.4023438 C 19.256105 1.282572 19.115488 1.2141655 18.972656 1.2011719 z M 18.410156 6.7753906 L 15.419922 9.4042969 L 15.419922 9.9394531 L 14.810547 9.9394531 L 13.148438 11.400391 L 16.539062 15.640625 C 16.719062 15.890625 17.140313 15.890625 17.320312 15.640625 L 21.259766 10.740234 C 21.519766 10.460234 21.260625 9.9094531 20.890625 9.9394531 L 18.400391 9.9394531 L 18.400391 7.2402344 C 18.400391 7.0470074 18.407711 6.9489682 18.410156 6.7753906 z M 11.966797 12.439453 L 8.6679688 15.339844 L 14.919922 15.339844 L 12.619141 12.5 C 12.589141 12.48 12.590313 12.459453 12.570312 12.439453 L 11.966797 12.439453 z' fill='#{hex-color($white)}' stroke-width='0'/></svg>"); + } + } + } +} diff --git a/app/javascript/flavours/blobfox/styles/components/columns.scss b/app/javascript/flavours/blobfox/styles/components/columns.scss new file mode 100644 index 00000000000000..ac1658a2de9933 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/columns.scss @@ -0,0 +1,1364 @@ +.column__wrapper { + display: flex; + flex: 1 1 auto; + position: relative; +} + +.columns-area { + display: flex; + flex: 1 1 auto; + flex-direction: row; + justify-content: flex-start; + overflow-x: auto; + position: relative; + + &__panels { + display: flex; + justify-content: center; + width: 100%; + height: 100%; + min-height: 100vh; + + &__pane { + height: 100%; + overflow: hidden; + pointer-events: none; + display: flex; + justify-content: flex-end; + min-width: 285px; + + &--start { + justify-content: flex-start; + } + + &__inner { + position: fixed; + width: 285px; + pointer-events: auto; + height: 100%; + } + } + + &__main { + box-sizing: border-box; + width: 100%; + flex: 0 0 auto; + display: flex; + flex-direction: column; + + @media screen and (min-width: $no-gap-breakpoint) { + padding: 0 10px; + max-width: 600px; + } + } + } +} + +$ui-header-height: 55px; + +.ui__header { + display: none; + box-sizing: border-box; + height: $ui-header-height; + position: sticky; + top: 0; + z-index: 3; + justify-content: space-between; + align-items: center; + + &__logo { + display: inline-flex; + padding: 15px; + + .logo { + height: $ui-header-height - 30px; + width: auto; + } + + .logo--wordmark { + display: none; + } + + @media screen and (width >= 320px) { + .logo--wordmark { + display: block; + } + + .logo--icon { + display: none; + } + } + } + + &__links { + display: flex; + align-items: center; + gap: 10px; + padding: 0 10px; + overflow: hidden; + + .button { + flex: 0 0 auto; + } + + .button-tertiary { + flex-shrink: 1; + } + } +} + +.tabs-bar__wrapper { + background: darken($ui-base-color, 8%); + position: sticky; + top: $ui-header-height; + z-index: 2; + padding-top: 0; + + @media screen and (min-width: $no-gap-breakpoint) { + padding-top: 10px; + top: 0; + } + + .tabs-bar { + margin-bottom: 0; + + @media screen and (min-width: $no-gap-breakpoint) { + margin-bottom: 10px; + } + } +} + +.react-swipeable-view-container { + &, + .columns-area, + .column { + height: 100%; + } +} + +.react-swipeable-view-container > * { + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} + +.column { + width: 330px; + position: relative; + box-sizing: border-box; + display: flex; + flex-direction: column; + + > .scrollable { + background: $ui-base-color; + } +} + +.ui { + flex: 0 0 auto; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; +} + +.column { + overflow: hidden; +} + +.column-back-button { + box-sizing: border-box; + width: 100%; + background: lighten($ui-base-color, 4%); + border-radius: 4px 4px 0 0; + color: $highlight-text-color; + cursor: pointer; + flex: 0 0 auto; + font-size: 16px; + border: 0; + text-align: unset; + padding: 15px; + margin: 0; + z-index: 3; + + &:hover { + text-decoration: underline; + } +} + +.column-header__back-button { + background: lighten($ui-base-color, 4%); + border: 0; + font-family: inherit; + color: $highlight-text-color; + cursor: pointer; + flex: 0 0 auto; + font-size: 16px; + padding: 0; + padding-inline-end: 5px; + z-index: 3; + + &:hover { + text-decoration: underline; + } + + &:last-child { + padding: 0; + padding-inline-end: 15px; + } +} + +.column-back-button__icon { + display: inline-block; + margin-inline-end: 5px; +} + +.column-back-button--slim { + position: relative; +} + +.column-back-button--slim-button { + cursor: pointer; + flex: 0 0 auto; + font-size: 16px; + padding: 15px; + position: absolute; + inset-inline-end: 0; + top: -48px; +} + +.switch-to-advanced { + color: $light-text-color; + background-color: $ui-base-color; + padding: 15px; + border-radius: 4px; + margin-top: 4px; + margin-bottom: 12px; + font-size: 13px; + line-height: 18px; + + .switch-to-advanced__toggle { + color: $ui-button-tertiary-color; + font-weight: bold; + } +} + +.column-link { + background: lighten($ui-base-color, 8%); + color: $primary-text-color; + display: block; + font-size: 16px; + padding: 15px; + text-decoration: none; + overflow: hidden; + white-space: nowrap; + + &:hover, + &:focus, + &:active { + background: lighten($ui-base-color, 11%); + } + + &:focus { + outline: 0; + } + + &--transparent { + background: transparent; + color: $ui-secondary-color; + + &:hover, + &:focus, + &:active { + background: transparent; + color: $primary-text-color; + } + + &.active { + color: $highlight-text-color; + } + } + + &--logo { + background: transparent; + padding: 10px; + + &:hover, + &:focus, + &:active { + background: transparent; + } + } +} + +.column-link__icon { + display: inline-block; + margin-inline-end: 5px; +} + +.column-subheading { + background: $ui-base-color; + color: $dark-text-color; + padding: 8px 20px; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + cursor: default; +} + +.column-header__wrapper { + position: relative; + flex: 0 0 auto; + z-index: 1; + + &.active { + box-shadow: 0 1px 0 rgba($highlight-text-color, 0.3); + + &::before { + display: block; + content: ''; + position: absolute; + bottom: -13px; + inset-inline-start: 0; + inset-inline-end: 0; + margin: 0 auto; + width: 60%; + pointer-events: none; + height: 28px; + z-index: 1; + background: radial-gradient( + ellipse, + rgba($ui-highlight-color, 0.23) 0%, + rgba($ui-highlight-color, 0) 60% + ); + } + } + + .announcements { + z-index: 1; + position: relative; + } +} + +.column-header { + display: flex; + font-size: 16px; + background: lighten($ui-base-color, 4%); + border-radius: 4px 4px 0 0; + flex: 0 0 auto; + cursor: pointer; + position: relative; + z-index: 2; + outline: 0; + overflow: hidden; + + & > button { + margin: 0; + border: 0; + padding: 15px; + color: inherit; + background: transparent; + font: inherit; + text-align: start; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + flex: 1; + } + + & > .column-header__back-button { + color: $highlight-text-color; + } + + &.active { + .column-header__icon { + color: $highlight-text-color; + text-shadow: 0 0 10px rgba($ui-highlight-color, 0.4); + } + } + + &:focus, + &:active { + outline: 0; + } +} + +.column { + width: 330px; + position: relative; + box-sizing: border-box; + display: flex; + flex-direction: column; + overflow: hidden; + + .wide .columns-area:not(.columns-area--mobile) & { + flex: auto; + min-width: 330px; + max-width: 600px; + } + + > .scrollable { + background: $ui-base-color; + border-radius: 0 0 4px 4px; + } +} + +.column-header__buttons { + height: 48px; + display: flex; + margin-inline-start: 0; +} + +.column-header__links { + margin-bottom: 14px; +} + +.column-header__links .text-btn { + margin-inline-end: 10px; +} + +.column-header__button { + background: lighten($ui-base-color, 4%); + border: 0; + color: $darker-text-color; + cursor: pointer; + font-size: 16px; + padding: 0 15px; + + &:hover { + color: lighten($darker-text-color, 7%); + } + + &.active { + color: $primary-text-color; + background: lighten($ui-base-color, 8%); + + &:hover { + color: $primary-text-color; + background: lighten($ui-base-color, 8%); + } + } + + // blobfox - added focus ring for keyboard navigation + &:focus { + text-shadow: 0 0 4px darken($ui-highlight-color, 5%); + } + + &:disabled { + color: $dark-text-color; + cursor: default; + } +} + +.column-header__notif-cleaning-buttons { + display: flex; + align-items: stretch; + justify-content: space-around; + + .column-header__button { + background: transparent; + text-align: center; + padding: 10px 5px; + font-size: 14px; + } + + b { + font-weight: bold; + } +} + +.layout-single-column .column-header__notif-cleaning-buttons { + @media screen and (min-width: $no-gap-breakpoint) { + b, + i { + margin-inline-end: 5px; + } + + br { + display: none; + } + + button { + padding: 15px 5px; + } + } +} + +// The notifs drawer with no padding to have more space for the buttons +.column-header__collapsible-inner.nopad-drawer { + padding: 0; +} + +.column-header__collapsible { + max-height: 70vh; + overflow: hidden; + overflow-y: auto; + color: $darker-text-color; + transition: + max-height 150ms ease-in-out, + opacity 300ms linear; + opacity: 1; + z-index: 1; + position: relative; + + &.collapsed { + max-height: 0; + opacity: 0.5; + } + + &.animating { + overflow-y: hidden; + } + + hr { + height: 0; + background: transparent; + border: 0; + border-top: 1px solid lighten($ui-base-color, 12%); + margin: 10px 0; + } + + // notif cleaning drawer + &.ncd { + transition: none; + + &.collapsed { + max-height: 0; + opacity: 0.7; + } + } +} + +.column-header__collapsible-inner { + background: lighten($ui-base-color, 8%); + padding: 15px; +} + +.column-header__setting-btn { + &:hover, + &:focus { + color: $darker-text-color; + text-decoration: underline; + } +} + +.column-header__collapsible__extra + .column-header__setting-btn { + padding-top: 5px; +} + +.column-header__permission-btn { + display: inline; + font-weight: inherit; + text-decoration: underline; +} + +.column-header__setting-arrows { + float: right; + + .column-header__setting-btn { + padding: 5px; + + &:first-child { + padding-inline-end: 7px; + } + + &:last-child { + padding-inline-start: 7px; + margin-inline-start: 5px; + } + } +} + +.column-header__title { + display: inline-block; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + flex: 1; +} + +.column-header__issue-btn { + color: $warning-red; + + &:hover { + color: $error-red; + text-decoration: underline; + } +} + +.column-header__icon { + display: inline-block; + margin-inline-end: 5px; +} + +.column-settings__pillbar { + display: flex; + overflow: hidden; + background-color: transparent; + border: 0; + border-radius: 4px; + margin-bottom: 10px; + align-items: stretch; + gap: 2px; +} + +.pillbar-button { + border: 0; + color: #fafafa; + padding: 2px; + margin: 0; + font-size: inherit; + flex: auto; + background-color: $ui-base-color; + transition: all 0.2s ease; + transition-property: background-color, box-shadow; + + &[disabled] { + cursor: not-allowed; + opacity: 0.5; + } + + &:not([disabled]) { + &:hover, + &:focus { + background-color: darken($ui-base-color, 10%); + } + + &.active { + background-color: darken($ui-highlight-color, 2%); + + &:hover, + &:focus { + background-color: $ui-highlight-color; + } + } + } +} + +.limited-account-hint { + p { + color: $secondary-text-color; + font-size: 15px; + font-weight: 500; + margin-bottom: 20px; + } +} + +.empty-column-indicator, +.follow_requests-unlocked_explanation { + color: $dark-text-color; + background: $ui-base-color; + text-align: center; + padding: 20px; + font-size: 15px; + font-weight: 400; + cursor: default; + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: center; + + & > span { + max-width: 500px; + } + + a { + color: $highlight-text-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.follow_requests-unlocked_explanation { + background: darken($ui-base-color, 4%); + contain: initial; + flex-grow: 0; +} + +.error-column { + padding: 20px; + background: $ui-base-color; + border-radius: 4px; + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: center; + flex-direction: column; + cursor: default; + + &__image { + width: 70%; + max-width: 350px; + margin-top: -50px; + } + + &__message { + text-align: center; + color: $darker-text-color; + font-size: 15px; + line-height: 22px; + + h1 { + font-size: 28px; + line-height: 33px; + font-weight: 700; + margin-bottom: 15px; + color: $primary-text-color; + } + + p { + max-width: 48ch; + } + + &__actions { + margin-top: 30px; + display: flex; + gap: 10px; + align-items: center; + justify-content: center; + } + } +} + +.column-inline-form { + padding: 7px 15px; + padding-inline-end: 5px; + display: flex; + justify-content: flex-start; + align-items: center; + background: lighten($ui-base-color, 4%); + + label { + flex: 1 1 auto; + + input { + width: 100%; + margin-bottom: 6px; + + &:focus { + outline: 0; + } + } + } + + .icon-button { + flex: 0 0 auto; + margin: 0 5px; + } +} + +.column-settings__outer { + background: lighten($ui-base-color, 8%); + padding: 15px; +} + +.column-settings__section { + color: $darker-text-color; + cursor: default; + display: block; + font-weight: 500; + margin-bottom: 10px; +} + +.column-settings__row--with-margin { + margin-bottom: 15px; +} + +.column-settings__hashtags { + .column-settings__row { + margin-bottom: 15px; + } + + .column-select { + &__control { + @include search-input; + + &::placeholder { + color: lighten($darker-text-color, 4%); + } + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + + &:focus { + background: lighten($ui-base-color, 4%); + } + + @media screen and (width <= 600px) { + font-size: 16px; + } + } + + &__placeholder { + color: $dark-text-color; + padding-inline-start: 2px; + font-size: 12px; + } + + &__value-container { + padding-inline-start: 6px; + } + + &__multi-value { + background: lighten($ui-base-color, 8%); + + &__remove { + cursor: pointer; + + &:hover, + &:active, + &:focus { + background: lighten($ui-base-color, 12%); + color: lighten($darker-text-color, 4%); + } + } + } + + &__multi-value__label, + &__input, + &__input-container { + color: $darker-text-color; + } + + &__clear-indicator, + &__dropdown-indicator { + cursor: pointer; + transition: none; + color: $dark-text-color; + + &:hover, + &:active, + &:focus { + color: lighten($dark-text-color, 4%); + } + } + + &__indicator-separator { + background-color: lighten($ui-base-color, 8%); + } + + &__menu { + @include search-popout; + + padding: 0; + background: $ui-secondary-color; + } + + &__menu-list { + padding: 6px; + } + + &__option { + color: $inverted-text-color; + border-radius: 4px; + font-size: 14px; + + &--is-focused, + &--is-selected { + background: darken($ui-secondary-color, 10%); + } + } + } +} + +.column-settings__row { + .text-btn:not(.column-header__permission-btn) { + margin-bottom: 15px; + } +} + +.notifications-permission-banner { + padding: 30px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: relative; + + &__close { + position: absolute; + top: 10px; + inset-inline-end: 10px; + } + + h2 { + font-size: 16px; + font-weight: 500; + margin-bottom: 15px; + text-align: center; + } + + p { + color: $darker-text-color; + margin-bottom: 15px; + text-align: center; + } +} + +.column-title { + text-align: center; + padding-bottom: 40px; + + h3 { + font-size: 24px; + line-height: 1.5; + font-weight: 700; + margin-bottom: 10px; + } + + p { + font-size: 16px; + line-height: 24px; + font-weight: 400; + color: $darker-text-color; + } + + @media screen and (width >= 600px) { + padding: 40px; + } +} + +.onboarding__footer { + margin-top: 30px; + color: $dark-text-color; + text-align: center; + font-size: 14px; + + .link-button { + display: inline-block; + color: inherit; + font-size: inherit; + } +} + +.onboarding__link { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + color: $highlight-text-color; + background: lighten($ui-base-color, 4%); + border-radius: 8px; + padding: 10px 15px; + box-sizing: border-box; + font-size: 14px; + font-weight: 500; + height: 56px; + text-decoration: none; + + svg { + height: 1.5em; + } + + &:hover, + &:focus, + &:active { + background: lighten($ui-base-color, 8%); + } +} + +.onboarding__illustration { + display: block; + margin: 0 auto; + margin-bottom: 10px; + max-height: 200px; + width: auto; +} + +.onboarding__lead { + font-size: 16px; + line-height: 24px; + font-weight: 400; + color: $darker-text-color; + text-align: center; + margin-bottom: 30px; + + strong { + font-weight: 700; + color: $secondary-text-color; + } +} + +.onboarding__links { + margin-bottom: 30px; + + & > * { + margin-bottom: 2px; + + &:last-child { + margin-bottom: 0; + } + } +} + +.onboarding__steps { + margin-bottom: 30px; + + &__item { + background: lighten($ui-base-color, 4%); + border: 0; + border-radius: 8px; + display: flex; + width: 100%; + box-sizing: border-box; + align-items: center; + gap: 10px; + padding: 10px; + padding-inline-end: 15px; + margin-bottom: 2px; + text-decoration: none; + text-align: start; + + &:hover, + &:focus, + &:active { + background: lighten($ui-base-color, 8%); + } + + &__icon { + flex: 0 0 auto; + border-radius: 50%; + display: none; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + color: $highlight-text-color; + font-size: 1.2rem; + + @media screen and (width >= 600px) { + display: flex; + } + } + + &__progress { + flex: 0 0 auto; + background: $valid-value-color; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + width: 21px; + height: 21px; + color: $primary-text-color; + + svg { + height: 14px; + width: auto; + } + } + + &__go { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + width: 21px; + height: 21px; + color: $highlight-text-color; + font-size: 17px; + + svg { + height: 1.5em; + width: auto; + } + } + + &__description { + flex: 1 1 auto; + line-height: 20px; + + h6 { + color: $highlight-text-color; + font-weight: 500; + font-size: 14px; + } + + p { + color: $darker-text-color; + overflow: hidden; + } + } + } +} + +.onboarding__progress-indicator { + display: flex; + align-items: center; + margin-bottom: 30px; + position: sticky; + background: $ui-base-color; + + @media screen and (width >= 600) { + padding: 0 40px; + } + + &__line { + height: 4px; + flex: 1 1 auto; + background: lighten($ui-base-color, 4%); + } + + &__step { + flex: 0 0 auto; + width: 30px; + height: 30px; + background: lighten($ui-base-color, 4%); + border-radius: 50%; + color: $primary-text-color; + display: flex; + align-items: center; + justify-content: center; + + svg { + width: 15px; + height: auto; + } + + &.active { + background: $valid-value-color; + } + } + + &__step.active, + &__line.active { + background: $valid-value-color; + background-image: linear-gradient( + 90deg, + $valid-value-color, + lighten($valid-value-color, 8%), + $valid-value-color + ); + background-size: 200px 100%; + animation: skeleton 1.2s ease-in-out infinite; + } +} + +.follow-recommendations { + background: darken($ui-base-color, 4%); + border-radius: 8px; + margin-bottom: 30px; + + .account:last-child { + border-bottom: 0; + } + + &__empty { + text-align: center; + color: $darker-text-color; + font-weight: 500; + padding: 40px; + } +} + +.tip-carousel { + border: 1px solid transparent; + border-radius: 8px; + padding: 16px; + margin-bottom: 30px; + + &:focus { + outline: 0; + border-color: $highlight-text-color; + } + + .media-modal__pagination { + margin-bottom: 0; + } +} + +.copy-paste-text { + background: lighten($ui-base-color, 4%); + border-radius: 8px; + border: 1px solid lighten($ui-base-color, 8%); + padding: 16px; + color: $primary-text-color; + font-size: 15px; + line-height: 22px; + display: flex; + flex-direction: column; + align-items: flex-end; + transition: border-color 300ms linear; + margin-bottom: 30px; + + &:focus, + &.focused { + transition: none; + outline: 0; + border-color: $highlight-text-color; + } + + &.copied { + border-color: $valid-value-color; + transition: none; + } + + textarea { + width: 100%; + height: auto; + background: transparent; + color: inherit; + font: inherit; + border: 0; + padding: 0; + margin-bottom: 30px; + resize: none; + + &:focus { + outline: 0; + } + } +} + +.compose-form__highlightable { + display: flex; + flex-direction: column; + flex: 0 1 auto; + border-radius: 4px; + transition: box-shadow 300ms linear; + min-height: 0; + position: relative; + + &.active { + transition: none; + box-shadow: 0 0 0 6px rgba(lighten($highlight-text-color, 8%), 0.7); + } +} + +.dismissable-banner, +.warning-banner { + position: relative; + margin: 10px; + margin-bottom: 5px; + border-radius: 8px; + border: 1px solid $highlight-text-color; + background: rgba($highlight-text-color, 0.15); + overflow: hidden; + + &__background-image { + width: 125%; + position: absolute; + bottom: -25%; + inset-inline-end: -25%; + z-index: -1; + opacity: 0.15; + mix-blend-mode: luminosity; + } + + &__message { + flex: 1 1 auto; + padding: 15px; + font-size: 15px; + line-height: 22px; + font-weight: 500; + color: $primary-text-color; + + p { + margin-bottom: 15px; + + &:last-child { + margin-bottom: 0; + } + } + + h1 { + color: $highlight-text-color; + font-size: 22px; + line-height: 33px; + font-weight: 700; + margin-bottom: 15px; + } + + &__actions { + display: flex; + flex-wrap: wrap; + gap: 4px; + + &__wrapper { + display: flex; + margin-top: 30px; + } + + .button { + display: block; + flex-grow: 1; + } + } + + .button-tertiary { + background: rgba($ui-base-color, 0.15); + backdrop-filter: blur(8px); + } + } + + &__action { + float: right; + padding: 15px 10px; + + .icon-button { + color: $highlight-text-color; + } + } +} + +.warning-banner { + border: 1px solid $warning-red; + background: rgba($warning-red, 0.15); + + &__message { + h1 { + color: $warning-red; + } + + a { + color: $primary-text-color; + } + } +} + +.hashtag-header { + border-bottom: 1px solid lighten($ui-base-color, 8%); + padding: 15px; + font-size: 17px; + line-height: 22px; + color: $darker-text-color; + + strong { + font-weight: 700; + } + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + gap: 15px; + + h1 { + color: $primary-text-color; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + font-size: 22px; + line-height: 33px; + font-weight: 700; + } + } + + &:focus { + outline: 0; + background-color: $highlight-text-color; + } +} diff --git a/app/javascript/flavours/blobfox/styles/components/compose_form.scss b/app/javascript/flavours/blobfox/styles/components/compose_form.scss new file mode 100644 index 00000000000000..0f64c0dcc10f5a --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/compose_form.scss @@ -0,0 +1,685 @@ +.compose-form { + padding: 10px; + + .emoji-picker-dropdown { + position: absolute; + top: 0; + inset-inline-end: 0; + + ::-webkit-scrollbar-track:hover, + ::-webkit-scrollbar-track:active { + background-color: rgba($base-overlay-background, 0.3); + } + } +} + +.character-counter { + cursor: default; + font-family: $font-sans-serif, sans-serif; + font-size: 14px; + font-weight: 600; + color: $lighter-text-color; + + &.character-counter--over { + color: $warning-red; + } +} + +.no-reduce-motion .spoiler-input { + transition: + height 0.4s ease, + opacity 0.4s ease; +} + +.spoiler-input { + height: 0; + transform-origin: bottom; + opacity: 0; + + &.spoiler-input--visible { + height: 36px; + margin-bottom: 11px; + opacity: 1; + } + + input { + display: block; + box-sizing: border-box; + margin: 0; + border: 0; + border-radius: 4px; + padding: 10px; + width: 100%; + outline: 0; + color: $inverted-text-color; + background: $simple-background-color; + font-size: 14px; + font-family: inherit; + resize: vertical; + + &::placeholder { + color: $dark-text-color; + } + + &:focus { + outline: 0; + } + @include single-column('screen and (max-width: 630px)') { + font-size: 16px; + } + } +} + +.compose-form__warning { + color: $inverted-text-color; + margin-bottom: 15px; + background: $ui-primary-color; + box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3); + padding: 8px 10px; + border-radius: 4px; + font-size: 13px; + font-weight: 400; + + a { + color: $lighter-text-color; + font-weight: 500; + text-decoration: underline; + + &:active, + &:focus, + &:hover { + text-decoration: none; + } + } +} + +.compose-form__sensitive-button { + padding: 10px; + padding-top: 0; + font-size: 14px; + font-weight: 500; + + &.active { + color: $highlight-text-color; + } + + input[type='checkbox'] { + appearance: none; + display: inline-block; + position: relative; + border: 1px solid $ui-primary-color; + box-sizing: border-box; + width: 18px; + height: 18px; + flex: 0 0 auto; + margin-inline-start: 5px; + margin-inline-end: 10px; + top: -1px; + border-radius: 4px; + vertical-align: middle; + cursor: inherit; + + &:checked { + border-color: $highlight-text-color; + background: $highlight-text-color + url("data:image/svg+xml;utf8,<svg width='18' height='18' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M4.5 8.5L8 12l6-6' stroke='white' stroke-width='1.5'/></svg>") + center center no-repeat; + } + } +} + +.reply-indicator { + margin: 0 0 10px; + border-radius: 4px; + padding: 10px; + background: $ui-primary-color; + min-height: 23px; + overflow-y: auto; + flex: 0 2 auto; +} + +.reply-indicator__header { + margin-bottom: 5px; + overflow: hidden; + + & > .account.small { + color: $inverted-text-color; + } +} + +.reply-indicator__cancel { + float: right; + line-height: 24px; +} + +.reply-indicator__content { + position: relative; + font-size: 14px; + line-height: 20px; + word-wrap: break-word; + font-weight: 400; + overflow: hidden; + padding-top: 5px; + color: $inverted-text-color; + white-space: pre-wrap; + + p, + pre { + margin-bottom: 20px; + white-space: pre-wrap; + + &:last-child { + margin-bottom: 0; + } + } + + a { + color: $lighter-text-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + + &.mention { + &:hover { + text-decoration: none; + + span { + text-decoration: underline; + } + } + } + } + + .emojione { + width: 20px; + height: 20px; + margin: -5px 0 0; + } +} + +.compose-form .compose-form__autosuggest-wrapper { + position: relative; +} + +.compose-form .autosuggest-textarea, +.compose-form .autosuggest-input { + position: relative; + width: 100%; + + label { + .autosuggest-textarea__textarea { + display: block; + box-sizing: border-box; + margin: 0; + border: 0; + border-radius: 4px 4px 0 0; + padding: 10px 32px 0 10px; + width: 100%; + min-height: 100px; + outline: 0; + color: $inverted-text-color; + background: $simple-background-color; + font-size: 14px; + font-family: inherit; + resize: none; + scrollbar-color: initial; + + &::placeholder { + color: $dark-text-color; + } + + &::-webkit-scrollbar { + all: unset; + } + + &:focus { + outline: 0; + } + + @include single-column('screen and (max-width: 630px)') { + font-size: 16px; + } + + @include limited-single-column('screen and (max-width: 600px)') { + height: 100px !important; // prevent auto-resize textarea + resize: vertical; + } + } + } +} + +.compose-form__textarea-icons { + display: block; + position: absolute; + top: 29px; + inset-inline-end: 5px; + bottom: 5px; + overflow: hidden; + + & > .textarea_icon { + display: block; + margin-top: 2px; + margin-inline-start: 2px; + width: 24px; + height: 24px; + color: $lighter-text-color; + font-size: 18px; + line-height: 24px; + text-align: center; + opacity: 0.8; + } +} + +.autosuggest-textarea__suggestions-wrapper { + position: relative; + height: 0; +} + +.autosuggest-textarea__suggestions { + box-sizing: border-box; + display: none; + position: absolute; + top: 100%; + width: 100%; + z-index: 99; + box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4); + background: $ui-secondary-color; + border-radius: 0 0 4px 4px; + color: $inverted-text-color; + font-size: 14px; + padding: 6px; +} + +.autosuggest-textarea__suggestions--visible { + display: block; +} + +.autosuggest-textarea__suggestions__item { + padding: 10px; + cursor: pointer; + border-radius: 4px; + + &:hover, + &:focus, + &:active, + &.selected { + background: darken($ui-secondary-color, 10%); + } + + .autosuggest-account, + .autosuggest-emoji, + .autosuggest-hashtag { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + line-height: 18px; + font-size: 14px; + } + + .autosuggest-hashtag { + justify-content: space-between; + + &__name { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + strong { + font-weight: 500; + } + + &__uses { + flex: 0 0 auto; + text-align: end; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .autosuggest-account-icon, + .autosuggest-emoji img { + margin-inline-end: 8px; + } + + .autosuggest-account .display-name > span { + color: $lighter-text-color; + } +} + +.compose-form__upload-wrapper { + overflow: hidden; +} + +.compose-form__uploads-wrapper { + display: flex; + flex-direction: row; + flex-wrap: wrap; + font-family: inherit; + padding: 5px; + overflow: hidden; +} + +.compose-form__upload { + flex: 1 1 0; + margin: 5px; + min-width: 40%; + + .compose-form__upload-thumbnail { + position: relative; + border-radius: 4px; + height: 140px; + width: 100%; + background-color: $base-shadow-color; + background-position: center; + background-size: cover; + background-repeat: no-repeat; + overflow: hidden; + + & > .close { + mix-blend-mode: difference; + } + } + + .icon-button { + flex: 0 1 auto; + color: $secondary-text-color; + font-size: 14px; + font-weight: 500; + padding: 10px; + font-family: inherit; + + &:hover, + &:focus, + &:active { + color: lighten($secondary-text-color, 7%); + } + } + + &__warning { + position: absolute; + z-index: 2; + bottom: 0; + inset-inline-start: 0; + inset-inline-end: 0; + box-sizing: border-box; + background: linear-gradient( + 0deg, + rgba($base-shadow-color, 0.8) 0, + rgba($base-shadow-color, 0.35) 80%, + transparent + ); + } +} + +.compose-form__upload__actions { + background: linear-gradient( + 180deg, + rgba($base-shadow-color, 0.8) 0, + rgba($base-shadow-color, 0.35) 80%, + transparent + ); + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.upload-progress { + display: flex; + padding: 10px; + color: $darker-text-color; + overflow: hidden; + + .fa { + font-size: 34px; + margin-inline-end: 10px; + } + + span { + display: block; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + } +} + +.upload-progress__message { + flex: 1 1 auto; +} + +.upload-progress__backdrop { + position: relative; + margin-top: 5px; + border-radius: 6px; + width: 100%; + height: 6px; + background: darken($simple-background-color, 8%); +} + +.upload-progress__tracker { + position: absolute; + top: 0; + inset-inline-start: 0; + height: 6px; + border-radius: 6px; + background: $ui-highlight-color; +} + +.compose-form__modifiers { + color: $inverted-text-color; + font-family: inherit; + font-size: 14px; + background: $simple-background-color; +} + +.compose-form__buttons-wrapper { + padding: 10px; + background: darken($simple-background-color, 8%); + border-radius: 0 0 4px 4px; + height: 27px; + display: flex; + justify-content: space-between; + flex: 0 0 auto; +} + +.compose-form__buttons { + display: flex; + flex: 0 0 auto; + + & .icon-button, + & .text-icon-button { + display: inline-block; + box-sizing: content-box; + padding: 0 3px; + height: 27px; + line-height: 27px; + vertical-align: bottom; + } + + & > hr { + display: inline-block; + margin: 0 3px; + border-width: 0 0 0 1px; + border-style: none none none solid; + border-color: transparent transparent transparent + darken($simple-background-color, 24%); + padding: 0; + width: 0; + height: 27px; + background: transparent; + } +} + +.character-counter__wrapper { + align-self: center; + margin-inline-end: 4px; +} + +.privacy-dropdown.active { + .privacy-dropdown__value { + background: $simple-background-color; + border-radius: 4px 4px 0 0; + box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1); + + .icon-button { + transition: none; + } + + &.active { + background: $ui-highlight-color; + + .icon-button { + color: $primary-text-color; + } + } + } + + &.top .privacy-dropdown__value { + border-radius: 0 0 4px 4px; + } + + .privacy-dropdown__dropdown { + display: block; + box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1); + } +} + +.privacy-dropdown__dropdown { + border-radius: 4px; + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + background: $simple-background-color; + overflow: hidden; + transform-origin: 50% 0; +} + +.privacy-dropdown__option { + display: flex; + align-items: center; + padding: 10px; + color: $inverted-text-color; + cursor: pointer; + + .privacy-dropdown__option__content { + flex: 1 1 auto; + color: $lighter-text-color; + + &:not(:first-child) { + margin-inline-start: 10px; + } + + strong { + display: block; + color: $inverted-text-color; + font-weight: 500; + } + } + + &:hover, + &.active { + background: $ui-highlight-color; + color: $primary-text-color; + + .privacy-dropdown__option__content { + color: $primary-text-color; + + strong { + color: $primary-text-color; + } + } + } + + &.active:hover { + background: lighten($ui-highlight-color, 4%); + } +} + +.compose-form__publish { + display: flex; + justify-content: flex-end; + min-width: 0; + flex: 0 0 auto; + column-gap: 5px; + + .compose-form__publish-button-wrapper { + overflow: hidden; + padding-top: 10px; + + button { + padding: 7px 10px; + text-align: center; + } + + & > .side_arm { + width: 36px; + } + } +} + +.language-dropdown { + &__dropdown { + background: $simple-background-color; + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + border-radius: 4px; + overflow: hidden; + z-index: 2; + + &.top { + transform-origin: 50% 100%; + } + + &.bottom { + transform-origin: 50% 0; + } + + .emoji-mart-search { + padding-inline-end: 10px; + } + + .emoji-mart-search-icon { + inset-inline-end: 10px + 5px; + } + + .emoji-mart-scroll { + padding: 0 10px 10px; + } + + &__results { + &__item { + cursor: pointer; + color: $inverted-text-color; + font-weight: 500; + padding: 10px; + border-radius: 4px; + + &:focus, + &:active, + &:hover { + background: $ui-secondary-color; + } + + &__common-name { + color: $darker-text-color; + } + + &.active { + background: $ui-highlight-color; + color: $primary-text-color; + outline: 0; + + .language-dropdown__dropdown__results__item__common-name { + color: $secondary-text-color; + } + + &:hover { + background: lighten($ui-highlight-color, 4%); + } + } + } + } + } +} diff --git a/app/javascript/flavours/blobfox/styles/components/directory.scss b/app/javascript/flavours/blobfox/styles/components/directory.scss new file mode 100644 index 00000000000000..db9a23bce2f550 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/directory.scss @@ -0,0 +1,68 @@ +.scrollable .account-card { + margin: 10px; + background: lighten($ui-base-color, 8%); +} + +.scrollable .account-card__title__avatar { + img, + .account__avatar { + border-color: lighten($ui-base-color, 8%); + } +} + +.scrollable .account-card__bio::after { + background: linear-gradient( + to left, + lighten($ui-base-color, 8%), + transparent + ); +} + +.filter-form { + background: $ui-base-color; + + &__column { + padding: 10px 15px; + padding-bottom: 0; + } + + .radio-button { + display: block; + } +} + +.radio-button { + font-size: 14px; + position: relative; + display: inline-block; + padding: 6px 0; + line-height: 18px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + + input[type='radio'], + input[type='checkbox'] { + display: none; + } + + &__input { + display: inline-block; + position: relative; + border: 1px solid $ui-primary-color; + box-sizing: border-box; + width: 18px; + height: 18px; + flex: 0 0 auto; + margin-inline-end: 10px; + top: -1px; + border-radius: 50%; + vertical-align: middle; + + &.checked { + border-color: lighten($ui-highlight-color, 4%); + background: lighten($ui-highlight-color, 4%); + } + } +} diff --git a/app/javascript/flavours/blobfox/styles/components/domains.scss b/app/javascript/flavours/blobfox/styles/components/domains.scss new file mode 100644 index 00000000000000..a99ccd02b60693 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/domains.scss @@ -0,0 +1,23 @@ +.domain { + padding: 10px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + + .domain__domain-name { + flex: 1 1 auto; + display: block; + color: $primary-text-color; + text-decoration: none; + font-size: 14px; + font-weight: 500; + } +} + +.domain__wrapper { + display: flex; +} + +.domain_buttons { + height: 18px; + padding: 10px; + white-space: nowrap; +} diff --git a/app/javascript/flavours/blobfox/styles/components/doodle.scss b/app/javascript/flavours/blobfox/styles/components/doodle.scss new file mode 100644 index 00000000000000..eb053c14db53f1 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/doodle.scss @@ -0,0 +1,86 @@ +$doodle-background: #d9e1e8; + +.doodle-modal { + width: unset; +} + +.doodle-modal__container { + background: $doodle-background; + text-align: center; + line-height: 0; // remove weird gap under canvas + canvas { + border: 5px solid $doodle-background; + } +} + +.doodle-modal__action-bar { + .filler { + flex-grow: 1; + margin: 0; + padding: 0; + } + + .doodle-toolbar { + line-height: 1; + display: flex; + flex-direction: column; + flex-grow: 0; + justify-content: space-around; + + &.with-inputs { + label { + display: inline-block; + width: 70px; + text-align: end; + margin-inline-end: 2px; + } + + input[type='number'], + input[type='text'] { + width: 40px; + } + + span.val { + display: inline-block; + text-align: start; + width: 50px; + } + } + } + + .doodle-palette { + padding-inline-end: 0 !important; + border: 1px solid black; + line-height: 0.2rem; + flex-grow: 0; + background: white; + + button { + appearance: none; + width: 1rem; + height: 1rem; + margin: 0; + padding: 0; + text-align: center; + color: black; + text-shadow: 0 0 1px white; + cursor: pointer; + box-shadow: inset 0 0 1px rgba(white, 0.5); + border: 1px solid black; + outline-offset: -1px; + + &.foreground { + outline: 1px dashed white; + } + + &.background { + outline: 1px dashed red; + } + + &.foreground.background { + outline: 1px dashed red; + border-color: white; + } + } + } +} diff --git a/app/javascript/flavours/blobfox/styles/components/drawer.scss b/app/javascript/flavours/blobfox/styles/components/drawer.scss new file mode 100644 index 00000000000000..dcccc0acbb175e --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/drawer.scss @@ -0,0 +1,298 @@ +.drawer { + width: 300px; + box-sizing: border-box; + display: flex; + flex-direction: column; + overflow-y: hidden; + padding: 10px 5px; + flex: none; + + &:first-child { + padding-inline-start: 10px; + } + + &:last-child { + padding-inline-end: 10px; + } + + @include single-column('screen and (max-width: 630px)') { + flex: auto; + } + + @include limited-single-column('screen and (max-width: 630px)') { + &, + &:first-child, + &:last-child { + padding: 0; + } + } + + .wide & { + min-width: 300px; + max-width: 400px; + flex: 1 1 200px; + } + + @include single-column('screen and (max-width: 630px)') { + :root & { + // Overrides `.wide` for single-column view + flex: auto; + width: 100%; + min-width: 0; + max-width: none; + padding: 0; + } + } + + .react-swipeable-view-container & { + height: 100%; + } +} + +.drawer__header { + flex: none; + font-size: 16px; + background: lighten($ui-base-color, 8%); + margin-bottom: 10px; + display: flex; + flex-direction: row; + border-radius: 4px; + overflow: hidden; + + & > * { + display: block; + box-sizing: border-box; + border-bottom: 2px solid transparent; + padding: 15px 5px 13px; + height: 48px; + flex: 1 1 auto; + color: $darker-text-color; + text-align: center; + text-decoration: none; + cursor: pointer; + } + + a { + transition: background 100ms ease-in; + + &:focus, + &:hover { + outline: none; + background: lighten($ui-base-color, 3%); + transition: background 200ms ease-out; + } + } +} + +.search { + position: relative; + margin-bottom: 10px; + flex: none; + + @include limited-single-column( + 'screen and (max-width: #{$no-gap-breakpoint})' + ) { + margin-bottom: 0; + } + @include single-column('screen and (max-width: 630px)') { + font-size: 16px; + } +} + +.navigation-bar { + padding: 10px; + color: $darker-text-color; + display: flex; + align-items: center; + + a { + color: inherit; + text-decoration: none; + } + + .acct { + display: block; + color: $secondary-text-color; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.navigation-bar__profile { + flex: 1 1 auto; + margin-inline-start: 8px; + overflow: hidden; +} + +.drawer--results { + overflow-x: hidden; + overflow-y: scroll; +} + +.search-results__section { + border-bottom: 1px solid lighten($ui-base-color, 8%); + + &:last-child { + border-bottom: 0; + } + + &__header { + background: darken($ui-base-color, 4%); + border-bottom: 1px solid lighten($ui-base-color, 8%); + padding: 15px; + font-weight: 500; + font-size: 14px; + color: $darker-text-color; + display: flex; + justify-content: space-between; + + h3 .fa { + margin-inline-end: 5px; + } + + button { + color: $highlight-text-color; + padding: 0; + border: 0; + background: 0; + font: inherit; + + &:hover, + &:active, + &:focus { + text-decoration: underline; + } + } + } + + .account:last-child, + & > div:last-child .status { + border-bottom: 0; + } + + & > .hashtag { + display: block; + padding: 10px; + color: $secondary-text-color; + text-decoration: none; + + &:hover, + &:active, + &:focus { + color: lighten($secondary-text-color, 4%); + text-decoration: underline; + } + } +} + +.drawer__pager { + box-sizing: border-box; + padding: 0; + flex-grow: 1; + position: relative; + overflow: hidden; + display: flex; + border-radius: 4px; +} + +.drawer__inner { + position: absolute; + top: 0; + inset-inline-start: 0; + background: lighten($ui-base-color, 13%); + box-sizing: border-box; + padding: 0; + display: flex; + flex-direction: column; + overflow: hidden; + overflow-y: auto; + width: 100%; + height: 100%; + + &.darker { + background: $ui-base-color; + } +} + +.drawer__inner__mastodon { + background: lighten($ui-base-color, 13%) + url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color($ui-base-color)}"/></svg>') + no-repeat bottom / 100% auto; + flex: 1; + min-height: 47px; + display: none; + + > img { + display: block; + object-fit: contain; + object-position: bottom left; + width: 85%; + height: 100%; + pointer-events: none; + user-select: none; + } + + > .mastodon { + display: block; + width: 100%; + height: 100%; + border: 0; + cursor: inherit; + } + + @media screen and (height >= 640px) { + display: block; + } +} + +.pseudo-drawer { + background: lighten($ui-base-color, 13%); + font-size: 13px; + text-align: start; +} + +.drawer__backdrop { + cursor: pointer; + position: absolute; + top: 0; + inset-inline-start: 0; + width: 100%; + height: 100%; + background: rgba($base-overlay-background, 0.5); +} + +@for $i from 0 through 3 { + .mbstobon-#{$i} .drawer__inner__mastodon { + @if $i == 3 { + background: + url('~flavours/blobfox/images/wave-drawer.png') + no-repeat + bottom / + 100% + auto, + lighten($ui-base-color, 13%); + } @else { + background: + url('~flavours/blobfox/images/wave-drawer-blobfoxed.png') + no-repeat + bottom / + 100% + auto, + lighten($ui-base-color, 13%); + } + + & > .mastodon { + background: url('~flavours/blobfox/images/mbstobon-ui-#{$i}.png') + no-repeat + left + bottom / + contain; + + @if $i != 3 { + filter: contrast(50%) brightness(50%); + } + } + } +} diff --git a/app/javascript/flavours/blobfox/styles/components/emoji.scss b/app/javascript/flavours/blobfox/styles/components/emoji.scss new file mode 100644 index 00000000000000..f76288978d630d --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/emoji.scss @@ -0,0 +1,104 @@ +.emojione { + font-size: inherit; + vertical-align: middle; + object-fit: contain; + margin: -0.2ex 0.15em 0.2ex; + width: 16px; + height: 16px; + + img { + width: auto; + } +} + +.emoji-picker-dropdown__menu { + background: $simple-background-color; + position: relative; + box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4); + border-radius: 4px; + margin-top: 5px; + z-index: 2; + + .emoji-mart-scroll { + transition: opacity 200ms ease; + } + + &.selecting .emoji-mart-scroll { + opacity: 0.5; + } +} + +.emoji-picker-dropdown__modifiers { + position: absolute; + top: 60px; + inset-inline-end: 11px; + cursor: pointer; +} + +.emoji-picker-dropdown__modifiers__menu { + position: absolute; + z-index: 4; + top: -4px; + inset-inline-start: -8px; + background: $simple-background-color; + border-radius: 4px; + box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2); + overflow: hidden; + + button { + display: block; + cursor: pointer; + border: 0; + padding: 4px 8px; + background: transparent; + + &:hover, + &:focus, + &:active { + background: rgba($ui-secondary-color, 0.4); + } + } + + .emoji-mart-emoji { + height: 22px; + } +} + +.emoji-mart-emoji { + span { + background-repeat: no-repeat; + } +} + +.emoji-button { + display: block; + padding-top: 5px; + padding-bottom: 2px; + padding-inline-start: 2px; + padding-inline-end: 5px; + outline: 0; + cursor: pointer; + + &:active, + &:focus { + outline: 0 !important; + } + + img { + filter: grayscale(100%); + opacity: 0.8; + display: block; + margin: 0; + width: 22px; + height: 22px; + } + + &:hover, + &:active, + &:focus { + img { + opacity: 1; + filter: none; + } + } +} diff --git a/app/javascript/flavours/blobfox/styles/components/emoji_picker.scss b/app/javascript/flavours/blobfox/styles/components/emoji_picker.scss new file mode 100644 index 00000000000000..e402838dbf9641 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/emoji_picker.scss @@ -0,0 +1,261 @@ +.emoji-mart { + &, + * { + box-sizing: border-box; + line-height: 1.15; + } + + font-size: 13px; + display: inline-block; + color: $inverted-text-color; + + .emoji-mart-emoji { + padding: 6px; + } +} + +.emoji-mart-bar { + border: 0 solid darken($ui-secondary-color, 8%); + + &:first-child { + border-bottom-width: 1px; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + background: $ui-secondary-color; + } + + &:last-child { + border-top-width: 1px; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; + display: none; + } +} + +.emoji-mart-anchors { + display: flex; + justify-content: space-between; + padding: 0 6px; + color: $lighter-text-color; + line-height: 0; +} + +.emoji-mart-anchor { + position: relative; + flex: 1; + text-align: center; + padding: 12px 4px; + overflow: hidden; + transition: color 0.1s ease-out; + cursor: pointer; + background: transparent; + border: 0; + + &:hover { + color: darken($lighter-text-color, 4%); + } +} + +.emoji-mart-anchor-selected { + color: $highlight-text-color; + + &:hover { + color: darken($highlight-text-color, 4%); + } + + .emoji-mart-anchor-bar { + bottom: 0; + } +} + +.emoji-mart-anchor-bar { + position: absolute; + bottom: -3px; + inset-inline-start: 0; + width: 100%; + height: 3px; + background-color: darken($ui-highlight-color, 3%); +} + +.emoji-mart-anchors { + i { + display: inline-block; + width: 100%; + max-width: 22px; + } + + svg { + fill: currentColor; + max-height: 18px; + } +} + +.emoji-mart-scroll { + overflow-y: scroll; + height: 270px; + max-height: 35vh; + padding: 0 6px 6px; + background: $simple-background-color; + will-change: transform; + + &::-webkit-scrollbar-track:hover, + &::-webkit-scrollbar-track:active { + background-color: rgba($base-overlay-background, 0.3); + } +} + +.emoji-mart-search { + padding: 10px; + padding-inline-end: 45px; + background: $simple-background-color; + position: relative; + + input { + font-size: 16px; + font-weight: 400; + padding: 7px 9px; + padding-inline-end: 25px; + font-family: inherit; + display: block; + width: 100%; + background: rgba($ui-secondary-color, 0.3); + color: $inverted-text-color; + border: 1px solid $ui-secondary-color; + border-radius: 4px; + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + + &::-webkit-search-cancel-button { + display: none; + } + } +} + +.emoji-mart-search-icon { + position: absolute; + top: 18px; + inset-inline-end: 45px + 5px; + z-index: 2; + padding: 2px 5px 1px; + border: 0; + background: none; + transition: all 100ms linear; + transition-property: opacity; + pointer-events: auto; + opacity: 0.7; + + &:disabled { + cursor: default; + pointer-events: none; + opacity: 0.3; + } + + svg { + fill: $action-button-color; + } +} + +.emoji-mart-category .emoji-mart-emoji { + cursor: pointer; + + span { + z-index: 1; + position: relative; + text-align: center; + } + + &:hover::before { + z-index: 0; + content: ''; + position: absolute; + top: 0; + inset-inline-start: 0; + width: 100%; + height: 100%; + background-color: rgba($ui-secondary-color, 0.7); + border-radius: 100%; + } +} + +.emoji-mart-category-label { + z-index: 2; + position: relative; + position: -webkit-sticky; + position: sticky; + top: 0; + + span { + display: block; + width: 100%; + font-weight: 500; + padding: 5px 6px; + background: $simple-background-color; + } +} + +/* For screenreaders only, via https://stackoverflow.com/a/19758620 */ +.emoji-mart-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +.emoji-mart-category-list { + margin: 0; + padding: 0; +} + +.emoji-mart-category-list li { + list-style: none; + margin: 0; + padding: 0; + display: inline-block; +} + +.emoji-mart-emoji { + position: relative; + display: inline-block; + background: transparent; + border: 0; + padding: 0; + font-size: 0; + + span { + width: 22px; + height: 22px; + } +} + +.emoji-mart-no-results { + font-size: 14px; + color: $light-text-color; + text-align: center; + padding: 5px 6px; + padding-top: 70px; + + .emoji-mart-no-results-label { + margin-top: 0.2em; + } + + .emoji-mart-emoji:hover::before { + cursor: default; + content: none; + } +} + +.emoji-mart-preview { + display: none; +} diff --git a/app/javascript/flavours/blobfox/styles/components/explore.scss b/app/javascript/flavours/blobfox/styles/components/explore.scss new file mode 100644 index 00000000000000..79da9f21668365 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/explore.scss @@ -0,0 +1,147 @@ +.account-card__header { + position: relative; +} + +.explore__search-header { + background: darken($ui-base-color, 4%); + justify-content: center; + align-items: center; + padding: 15px; + + .search { + width: 100%; + margin-bottom: 0; + } + + .search__input { + border: 1px solid lighten($ui-base-color, 8%); + padding: 10px; + } + + .search__popout { + border: 1px solid lighten($ui-base-color, 8%); + } + + .search .fa { + top: 10px; + inset-inline-end: 10px; + color: $dark-text-color; + } + + .search .fa-times-circle { + top: 12px; + } +} + +.explore__search-results { + flex: 1 1 auto; + display: flex; + flex-direction: column; + background: $ui-base-color; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; +} + +.story { + display: flex; + align-items: center; + color: $primary-text-color; + text-decoration: none; + padding: 15px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + gap: 15px; + + &:last-child { + border-bottom: 0; + } + + &:hover, + &:active, + &:focus { + color: $highlight-text-color; + + .story__details__publisher, + .story__details__shared { + color: $highlight-text-color; + } + } + + &__details { + flex: 1 1 auto; + + &__publisher { + color: $darker-text-color; + margin-bottom: 8px; + } + + &__title { + font-size: 19px; + line-height: 24px; + font-weight: 500; + margin-bottom: 8px; + } + + &__shared { + color: $darker-text-color; + } + + strong { + font-weight: 500; + } + } + + &__thumbnail { + flex: 0 0 auto; + position: relative; + width: 120px; + height: 120px; + + .skeleton { + width: 100%; + height: 100%; + } + + img { + border-radius: 8px; + display: block; + margin: 0; + width: 100%; + height: 100%; + object-fit: cover; + } + + &__preview { + border-radius: 8px; + display: block; + margin: 0; + width: 100%; + height: 100%; + object-fit: fill; + position: absolute; + top: 0; + inset-inline-start: 0; + z-index: 0; + + &--hidden { + display: none; + } + } + } + + &.expanded { + flex-direction: column; + + .story__thumbnail { + order: 1; + width: 100%; + height: auto; + aspect-ratio: 1.91 / 1; + } + + .story__details { + order: 2; + width: 100%; + flex: 0 0 auto; + } + } +} diff --git a/app/javascript/flavours/blobfox/styles/components/index.scss b/app/javascript/flavours/blobfox/styles/components/index.scss new file mode 100644 index 00000000000000..d94f1236483ff8 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/index.scss @@ -0,0 +1,25 @@ +@import 'misc'; +@import 'boost'; +@import 'accounts'; +@import 'domains'; +@import 'status'; +@import 'modal'; +@import 'compose_form'; +@import 'columns'; +@import 'regeneration_indicator'; +@import 'directory'; +@import 'search'; +@import 'emoji'; +@import 'doodle'; +@import 'drawer'; +@import 'media'; +@import 'sensitive'; +@import 'lists'; +@import 'emoji_picker'; +@import 'local_settings'; +@import 'single_column'; +@import 'announcements'; +@import 'explore'; +@import 'signed_out'; +@import 'privacy_policy'; +@import 'about'; diff --git a/app/javascript/flavours/blobfox/styles/components/lists.scss b/app/javascript/flavours/blobfox/styles/components/lists.scss new file mode 100644 index 00000000000000..e173016b6784d7 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/lists.scss @@ -0,0 +1,94 @@ +.list-editor { + background: $ui-base-color; + flex-direction: column; + border-radius: 8px; + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + width: 380px; + overflow: hidden; + + @media screen and (width <= 420px) { + width: 90%; + } + + h4 { + padding: 15px 0; + background: lighten($ui-base-color, 13%); + font-weight: 500; + font-size: 16px; + text-align: center; + border-radius: 8px 8px 0 0; + } + + .drawer__pager { + height: 50vh; + } + + .drawer__inner { + border-radius: 0 0 8px 8px; + + &.backdrop { + width: calc(100% - 60px); + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + border-radius: 0 0 0 8px; + } + } + + &__accounts { + overflow-y: auto; + } + + .account__display-name { + &:hover strong { + text-decoration: none; + } + } + + .account__avatar { + cursor: default; + } + + .search { + margin-bottom: 0; + } +} + +.list-adder { + background: $ui-base-color; + flex-direction: column; + border-radius: 8px; + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + width: 380px; + overflow: hidden; + + @media screen and (width <= 420px) { + width: 90%; + } + + &__account { + background: lighten($ui-base-color, 13%); + } + + &__lists { + background: lighten($ui-base-color, 13%); + height: 50vh; + border-radius: 0 0 8px 8px; + overflow-y: auto; + } + + .list { + padding: 10px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + } + + .list__wrapper { + display: flex; + } + + .list__display-name { + flex: 1 1 auto; + overflow: hidden; + text-decoration: none; + font-size: 16px; + padding: 10px; + } +} diff --git a/app/javascript/flavours/blobfox/styles/components/local_settings.scss b/app/javascript/flavours/blobfox/styles/components/local_settings.scss new file mode 100644 index 00000000000000..7fa1529cbaa760 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/local_settings.scss @@ -0,0 +1,173 @@ +.blobfox.local-settings { + position: relative; + display: flex; + flex-direction: row; + background: $ui-secondary-color; + color: $inverted-text-color; + border-radius: 8px; + height: 80vh; + width: 80vw; + max-width: 740px; + max-height: 450px; + overflow: hidden; + + label, + legend { + display: block; + font-size: 14px; + } + + .boolean label, + .radio_buttons label { + position: relative; + padding-inline-start: 28px; + padding-top: 3px; + + input { + position: absolute; + inset-inline-start: 0; + top: 0; + } + } + + span.hint { + display: block; + color: $lighter-text-color; + } + + h1 { + font-size: 18px; + font-weight: 500; + line-height: 24px; + margin-bottom: 20px; + } + + h2 { + font-size: 15px; + font-weight: 500; + line-height: 20px; + margin-top: 20px; + margin-bottom: 10px; + } +} + +.blobfox.local-settings__navigation__item { + display: block; + padding: 15px 20px; + color: inherit; + background: lighten($ui-secondary-color, 8%); + border: 0; + border-bottom: 1px $ui-secondary-color solid; + cursor: pointer; + text-decoration: none; + outline: none; + transition: background 0.3s; + box-sizing: border-box; + width: 100%; + text-align: start; + font-size: inherit; + + .text-icon-button { + color: inherit; + transition: unset; + unicode-bidi: embed; + } + + &:hover { + background: $ui-secondary-color; + } + + &.active { + background: $ui-highlight-color; + color: $primary-text-color; + } + + &.close, + &.close:hover { + background: $error-value-color; + color: $primary-text-color; + } +} + +.blobfox.local-settings__navigation { + background: lighten($ui-secondary-color, 8%); + width: 212px; + font-size: 15px; + line-height: 20px; + overflow-y: auto; +} + +.blobfox.local-settings__page { + display: block; + flex: auto; + padding: 15px 20px; + width: 360px; + overflow-y: auto; +} + +.blobfox.local-settings__page__item { + margin-bottom: 2px; + + .hint a { + color: $lighter-text-color; + font-weight: 500; + text-decoration: underline; + + &:active, + &:focus, + &:hover { + text-decoration: none; + } + } + + #mastodon-settings--collapsed-auto-height { + width: calc(4ch + 20px); + } +} + +.blobfox.local-settings__page__item.string, +.blobfox.local-settings__page__item.radio_buttons { + margin-top: 10px; + margin-bottom: 10px; +} + +@media screen and (width <= 630px) { + .blobfox.local-settings__navigation { + width: 40px; + flex-shrink: 0; + } + + .blobfox.local-settings__navigation__item { + padding: 10px; + + span:last-of-type { + display: none; + } + } +} + +.deprecated-settings-label { + white-space: nowrap; +} + +.deprecated-settings-info { + text-align: start; + + ul { + padding: 10px; + margin-inline-start: 12px; + list-style: disc inside; + } + + a { + color: $lighter-text-color; + font-weight: 500; + text-decoration: underline; + + &:active, + &:focus, + &:hover { + text-decoration: none; + } + } +} diff --git a/app/javascript/flavours/blobfox/styles/components/media.scss b/app/javascript/flavours/blobfox/styles/components/media.scss new file mode 100644 index 00000000000000..535af9d0f25ce1 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/media.scss @@ -0,0 +1,795 @@ +.video-error-cover { + align-items: center; + background: $base-overlay-background; + color: $primary-text-color; + cursor: pointer; + display: flex; + flex-direction: column; + height: 100%; + justify-content: center; + margin-top: 8px; + position: relative; + text-align: center; + z-index: 100; +} + +.media-spoiler { + background: $base-overlay-background; + color: $darker-text-color; + border: 0; + width: 100%; + height: 100%; + + &:hover, + &:active, + &:focus { + color: lighten($darker-text-color, 8%); + } + + .status__content > & { + margin-top: 15px; // Add margin when used bare for NSFW video player + } + @include fullwidth-gallery; +} + +.media-spoiler__warning { + display: block; + font-size: 14px; +} + +.media-spoiler__trigger { + display: block; + font-size: 11px; + font-weight: 500; +} + +.media-gallery__item__badges { + position: absolute; + bottom: 6px; + inset-inline-start: 6px; + display: flex; + gap: 2px; +} + +.media-gallery__gifv__label { + display: block; + color: $white; + background: rgba($black, 0.65); + padding: 2px 6px; + border-radius: 4px; + font-size: 11px; + font-weight: 700; + z-index: 1; + pointer-events: none; + line-height: 18px; +} + +.media-gallery { + box-sizing: border-box; + margin-top: 8px; + overflow: hidden; + border-radius: 4px; + position: relative; + width: 100%; + min-height: 64px; + display: grid; + grid-template-columns: 50% 50%; + grid-template-rows: 50% 50%; + gap: 2px; + + @include fullwidth-gallery; +} + +.media-gallery__item { + border: 0; + box-sizing: border-box; + display: block; + position: relative; + border-radius: 4px; + overflow: hidden; + + &--tall { + grid-row: span 2; + } + + &--wide { + grid-column: span 2; + } + + .full-width & { + border-radius: 0; + } + + &.letterbox { + background: $base-shadow-color; + } +} + +.media-gallery__item-thumbnail { + cursor: zoom-in; + display: block; + text-decoration: none; + color: $secondary-text-color; + position: relative; + z-index: 1; + + &, + img { + height: 100%; + width: 100%; + object-fit: contain; + + &:not(.letterbox) { + height: 100%; + object-fit: cover; + } + } +} + +.media-gallery__preview { + width: 100%; + height: 100%; + object-fit: cover; + position: absolute; + top: 0; + inset-inline-start: 0; + z-index: 0; + background: $base-overlay-background; + + &--hidden { + display: none; + } +} + +.media-gallery__gifv { + height: 100%; + overflow: hidden; + position: relative; + width: 100%; + display: flex; + justify-content: center; +} + +.media-gallery__item-gifv-thumbnail { + cursor: zoom-in; + height: 100%; + width: 100%; + object-fit: contain; + user-select: none; + + &:not(.letterbox) { + height: 100%; + object-fit: cover; + } +} + +.media-gallery__item-thumbnail-label { + clip: rect(1px 1px 1px 1px); /* IE6, IE7 */ + clip: rect(1px, 1px, 1px, 1px); + overflow: hidden; + position: absolute; +} + +.video-modal__container { + max-width: 100vw; + max-height: 100vh; +} + +.audio-modal__container { + width: 50vw; +} + +.media-modal { + width: 100%; + height: 100%; + position: relative; + + &__close, + &__zoom-button { + color: rgba($white, 0.7); + + &:hover, + &:focus, + &:active { + color: $white; + background-color: rgba($white, 0.15); + } + + &:focus { + background-color: rgba($white, 0.3); + } + } +} + +.media-modal__closer { + position: absolute; + top: 0; + inset-inline-start: 0; + inset-inline-end: 0; + bottom: 0; +} + +.media-modal__navigation { + position: absolute; + top: 0; + inset-inline-start: 0; + inset-inline-end: 0; + bottom: 0; + pointer-events: none; + transition: opacity 0.3s linear; + will-change: opacity; + + * { + pointer-events: auto; + } + + &.media-modal__navigation--hidden { + opacity: 0; + + * { + pointer-events: none; + } + } +} + +.media-modal__nav { + background: transparent; + box-sizing: border-box; + border: 0; + color: rgba($white, 0.7); + cursor: pointer; + display: flex; + align-items: center; + font-size: 24px; + height: 20vmax; + margin: auto 0; + padding: 30px 15px; + position: absolute; + top: 0; + bottom: 0; + + &:hover, + &:focus, + &:active { + color: $white; + } +} + +.media-modal__nav--left { + inset-inline-start: 0; +} + +.media-modal__nav--right { + inset-inline-end: 0; +} + +.media-modal__overlay { + max-width: 600px; + position: absolute; + inset-inline-start: 0; + inset-inline-end: 0; + bottom: 0; + margin: 0 auto; + + .picture-in-picture__footer { + border-radius: 0; + background: transparent; + padding: 20px 0; + + .icon-button { + color: $white; + + &:hover, + &:focus, + &:active { + color: $white; + background-color: rgba($white, 0.15); + } + + &:focus { + background-color: rgba($white, 0.3); + } + + &.active { + color: $highlight-text-color; + + &:hover, + &:focus, + &:active { + background: rgba($highlight-text-color, 0.15); + } + + &:focus { + background: rgba($highlight-text-color, 0.3); + } + } + + &.star-icon.active { + color: $gold-star; + + &:hover, + &:focus, + &:active { + background: rgba($gold-star, 0.15); + } + + &:focus { + background: rgba($gold-star, 0.3); + } + } + + &.disabled { + color: $white; + background-color: transparent; + cursor: default; + opacity: 0.4; + } + } + } +} + +.media-modal__pagination { + display: flex; + justify-content: center; + margin-bottom: 20px; +} + +.media-modal__page-dot { + flex: 0 0 auto; + background-color: $white; + opacity: 0.4; + height: 6px; + width: 6px; + border-radius: 50%; + margin: 0 4px; + padding: 0; + border: 0; + font-size: 0; + transition: opacity 0.2s ease-in-out; + + &.active { + opacity: 1; + } +} + +.media-modal__close { + position: absolute; + inset-inline-end: 8px; + top: 8px; + z-index: 100; +} + +.detailed, +.fullscreen { + .video-player__volume__current, + .video-player__volume::before { + bottom: 27px; + } + + .video-player__volume__handle { + bottom: 23px; + } +} + +.audio-player { + overflow: hidden; + box-sizing: border-box; + position: relative; + background: darken($ui-base-color, 8%); + border-radius: 4px; + padding-bottom: 44px; + width: 100%; + + &.editable { + border-radius: 0; + height: 100%; + } + + &.inactive { + audio, + .video-player__controls { + visibility: hidden; + } + } + + .video-player__volume::before, + .video-player__seek::before { + background: currentColor; + opacity: 0.15; + } + + .video-player__seek__buffer { + background: currentColor; + opacity: 0.2; + } + + .video-player__buttons button, + .video-player__buttons a { + color: currentColor; + opacity: 0.75; + + &:active, + &:hover, + &:focus { + color: currentColor; + opacity: 1; + } + } + + .video-player__time-sep, + .video-player__time-total, + .video-player__time-current { + color: currentColor; + } + + .video-player__seek::before, + .video-player__seek__buffer, + .video-player__seek__progress { + top: 0; + } + + .video-player__seek__handle { + top: -4px; + } + + .video-player__controls { + padding-top: 10px; + background: transparent; + } +} + +.video-player { + overflow: hidden; + position: relative; + background: $base-shadow-color; + max-width: 100%; + border-radius: 4px; + box-sizing: border-box; + color: $white; + display: flex; + align-items: center; + + &.editable { + border-radius: 0; + height: 100% !important; + } + + &:focus { + outline: 0; + } + + .detailed-status & { + width: 100%; + height: 100%; + } + + @include fullwidth-gallery; + + video { + display: block; + max-width: 100vw; + max-height: 80vh; + z-index: 1; + position: relative; + } + + &.fullscreen { + width: 100% !important; + height: 100% !important; + margin: 0; + + video { + max-width: 100% !important; + max-height: 100% !important; + width: 100% !important; + height: 100% !important; + outline: 0; + } + } + + &.inline { + video { + object-fit: contain; + } + } + + &__controls { + position: absolute; + direction: ltr; + z-index: 2; + bottom: 0; + inset-inline-start: 0; + inset-inline-end: 0; + box-sizing: border-box; + background: linear-gradient( + 0deg, + rgba($base-shadow-color, 0.85) 0, + rgba($base-shadow-color, 0.45) 60%, + transparent + ); + padding: 0 15px; + opacity: 0; + transition: opacity 0.1s ease; + + &.active { + opacity: 1; + } + } + + &.inactive { + video, + .video-player__controls { + visibility: hidden; + } + } + + &__spoiler { + display: none; + position: absolute; + top: 0; + inset-inline-start: 0; + width: 100%; + height: 100%; + z-index: 4; + border: 0; + background: $base-shadow-color; + color: $darker-text-color; + transition: none; + pointer-events: none; + + &.active { + display: block; + pointer-events: auto; + + &:hover, + &:active, + &:focus { + color: lighten($darker-text-color, 7%); + } + } + + &__title { + display: block; + font-size: 14px; + } + + &__subtitle { + display: block; + font-size: 11px; + font-weight: 500; + } + } + + &__buttons-bar { + display: flex; + justify-content: space-between; + padding-bottom: 8px; + margin: 0 -5px; + + .video-player__download__icon { + color: inherit; + + .fa, + &:active .fa, + &:hover .fa, + &:focus .fa { + color: inherit; + } + } + } + + &__buttons { + display: flex; + flex: 0 1 auto; + min-width: 30px; + align-items: center; + font-size: 16px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + .player-button { + display: inline-block; + outline: 0; + flex: 0 0 auto; + background: transparent; + padding: 5px; + font-size: 16px; + border: 0; + color: rgba($white, 0.75); + + &:active, + &:hover, + &:focus { + color: $white; + } + } + } + + &__time { + display: inline; + flex: 0 1 auto; + overflow: hidden; + text-overflow: ellipsis; + margin: 0 5px; + } + + &__time-sep, + &__time-total, + &__time-current { + font-size: 14px; + font-weight: 500; + } + + &__time-current { + color: $white; + } + + &__time-sep { + display: inline-block; + margin: 0 6px; + } + + &__time-sep, + &__time-total { + color: $white; + } + + &__volume { + flex: 0 0 auto; + display: inline-flex; + cursor: pointer; + height: 24px; + position: relative; + overflow: hidden; + + .no-reduce-motion & { + transition: all 100ms linear; + } + + &.active { + overflow: visible; + width: 50px; + margin-inline-end: 16px; + } + + &::before { + content: ''; + width: 50px; + background: rgba($white, 0.35); + border-radius: 4px; + display: block; + position: absolute; + height: 4px; + inset-inline-start: 0; + top: 50%; + transform: translate(0, -50%); + } + + &__current { + display: block; + position: absolute; + height: 4px; + border-radius: 4px; + inset-inline-start: 0; + top: 50%; + transform: translate(0, -50%); + background: lighten($ui-highlight-color, 8%); + } + + &__handle { + position: absolute; + z-index: 3; + border-radius: 50%; + width: 12px; + height: 12px; + top: 50%; + inset-inline-start: 0; + margin-inline-start: -6px; + transform: translate(0, -50%); + background: lighten($ui-highlight-color, 8%); + box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2); + opacity: 0; + + .no-reduce-motion & { + transition: opacity 100ms linear; + } + } + + &.active &__handle { + opacity: 1; + } + } + + &__link { + padding: 2px 10px; + + a { + text-decoration: none; + font-size: 14px; + font-weight: 500; + color: $white; + + &:hover, + &:active, + &:focus { + text-decoration: underline; + } + } + } + + &__seek { + cursor: pointer; + height: 24px; + position: relative; + + &::before { + content: ''; + width: 100%; + background: rgba($white, 0.35); + border-radius: 4px; + display: block; + position: absolute; + height: 4px; + top: 14px; + } + + &__progress, + &__buffer { + display: block; + position: absolute; + height: 4px; + border-radius: 4px; + top: 14px; + background: lighten($ui-highlight-color, 8%); + } + + &__buffer { + background: rgba($white, 0.2); + } + + &__handle { + position: absolute; + z-index: 3; + opacity: 0; + border-radius: 50%; + width: 12px; + height: 12px; + top: 10px; + margin-inline-start: -6px; + background: lighten($ui-highlight-color, 8%); + box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2); + + .no-reduce-motion & { + transition: opacity 0.1s ease; + } + + &.active { + opacity: 1; + } + } + + &:hover { + .video-player__seek__handle { + opacity: 1; + } + } + } + + &.detailed, + &.fullscreen { + .video-player__buttons { + .player-button { + padding-top: 10px; + padding-bottom: 10px; + } + } + } +} + +.gifv { + video { + max-width: 100vw; + max-height: 80vh; + } +} diff --git a/app/javascript/flavours/blobfox/styles/components/misc.scss b/app/javascript/flavours/blobfox/styles/components/misc.scss new file mode 100644 index 00000000000000..f6d0f5b0731907 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/misc.scss @@ -0,0 +1,1740 @@ +.app-body { + -webkit-overflow-scrolling: touch; + -ms-overflow-style: -ms-autohiding-scrollbar; +} + +.animated-number { + display: inline-flex; + flex-direction: column; + align-items: stretch; + overflow: hidden; + position: relative; +} + +.inline-alert { + color: $valid-value-color; + font-weight: 400; + + .no-reduce-motion & { + transition: opacity 200ms ease; + } +} + +.link-button { + display: block; + font-size: 15px; + line-height: 20px; + color: $highlight-text-color; + border: 0; + background: transparent; + padding: 0; + cursor: pointer; + text-decoration: none; + + &--destructive { + color: $error-value-color; + } + + &:hover, + &:active { + text-decoration: underline; + } + + &:disabled { + color: $ui-primary-color; + cursor: default; + } +} + +.button { + background-color: $ui-button-background-color; + border: 10px none; + border-radius: 4px; + box-sizing: border-box; + color: $ui-button-color; + cursor: pointer; + display: inline-block; + font-family: inherit; + font-size: 15px; + font-weight: 500; + letter-spacing: 0; + line-height: 22px; + overflow: hidden; + padding: 7px 18px; + position: relative; + text-align: center; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + width: auto; + + &:active, + &:focus, + &:hover { + background-color: $ui-button-focus-background-color; + } + + &--destructive { + &:active, + &:focus, + &:hover { + background-color: $ui-button-destructive-focus-background-color; + transition: none; + } + } + + &:disabled { + background-color: $ui-primary-color; + cursor: default; + } + + &.button-secondary { + color: $ui-button-secondary-color; + background: transparent; + padding: 6px 17px; + border: 1px solid $ui-button-secondary-border-color; + + &:active, + &:focus, + &:hover { + border-color: $ui-button-secondary-focus-background-color; + color: $ui-button-secondary-focus-color; + background-color: $ui-button-secondary-focus-background-color; + text-decoration: none; + } + + &:disabled { + opacity: 0.5; + } + } + + &.button-tertiary { + background: transparent; + padding: 6px 17px; + color: $ui-button-tertiary-color; + border: 1px solid $ui-button-tertiary-border-color; + + &:active, + &:focus, + &:hover { + background-color: $ui-button-tertiary-focus-background-color; + color: $ui-button-tertiary-focus-color; + border: 0; + padding: 7px 18px; + } + + &:disabled { + opacity: 0.5; + } + + &.button--confirmation { + color: $valid-value-color; + border-color: $valid-value-color; + + &:active, + &:focus, + &:hover { + background: $valid-value-color; + color: $primary-text-color; + } + } + + &.button--destructive { + color: $error-value-color; + border-color: $error-value-color; + + &:active, + &:focus, + &:hover { + background: $error-value-color; + color: $primary-text-color; + } + } + } + + &.button--block { + display: block; + width: 100%; + } +} + +.icon-button { + display: inline-block; + padding: 0; + color: $action-button-color; + border: 0; + border-radius: 4px; + background: transparent; + cursor: pointer; + transition: all 100ms ease-in; + transition-property: background-color, color; + text-decoration: none; + + a { + color: inherit; + text-decoration: none; + } + + &:hover, + &:active, + &:focus { + color: lighten($action-button-color, 7%); + background-color: rgba($action-button-color, 0.15); + transition: all 200ms ease-out; + transition-property: background-color, color; + } + + &:focus { + background-color: rgba($action-button-color, 0.3); + } + + &.disabled { + color: darken($action-button-color, 13%); + background-color: transparent; + cursor: default; + } + + &.active { + color: $highlight-text-color; + } + + &.copyable { + transition: background 300ms linear; + } + + &.copied { + background: $valid-value-color; + transition: none; + } + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + + &.inverted { + color: $lighter-text-color; + + &:hover, + &:active, + &:focus { + color: darken($lighter-text-color, 7%); + background-color: rgba($lighter-text-color, 0.15); + } + + &:focus { + background-color: rgba($lighter-text-color, 0.3); + } + + &.disabled { + color: lighten($lighter-text-color, 7%); + background-color: transparent; + } + + &.active { + color: $highlight-text-color; + + &.disabled { + color: lighten($highlight-text-color, 13%); + } + } + } + + &.overlayed { + box-sizing: content-box; + background: rgba($base-overlay-background, 0.6); + color: rgba($primary-text-color, 0.7); + border-radius: 4px; + padding: 2px; + + &:hover { + background: rgba($base-overlay-background, 0.9); + } + } + + &--with-counter { + display: inline-flex; + align-items: center; + width: auto !important; + padding: 0; + padding-inline-end: 4px; + padding-inline-start: 2px; + } + + &__counter { + display: inline-block; + width: auto; + margin-inline-start: 4px; + font-size: 12px; + font-weight: 500; + } +} + +.text-icon, +.text-icon-button { + font-weight: 600; + font-size: 11px; + line-height: 27px; + cursor: default; +} + +.text-icon-button { + color: $lighter-text-color; + border: 0; + border-radius: 4px; + background: transparent; + cursor: pointer; + padding: 0 3px; + white-space: nowrap; + outline: 0; + transition: all 100ms ease-in; + transition-property: background-color, color; + + &:hover, + &:active, + &:focus { + color: darken($lighter-text-color, 7%); + background-color: rgba($lighter-text-color, 0.15); + transition: all 200ms ease-out; + transition-property: background-color, color; + } + + &:focus { + background-color: rgba($lighter-text-color, 0.3); + } + + &.disabled { + color: lighten($lighter-text-color, 20%); + background-color: transparent; + cursor: default; + } + + &.active { + color: $highlight-text-color; + } + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } +} + +body > [data-popper-placement] { + z-index: 3; +} + +.invisible { + font-size: 0; + line-height: 0; + display: inline-block; + width: 0; + height: 0; + position: absolute; + + img, + svg { + margin: 0 !important; + border: 0 !important; + padding: 0 !important; + width: 0 !important; + height: 0 !important; + } +} + +.ellipsis { + &::after { + content: '…'; + } +} + +.notification__favourite-icon-wrapper { + inset-inline-start: 0; + position: absolute; + + .fa.star-icon { + color: $gold-star; + } +} + +.icon-button.star-icon.active { + color: $gold-star; +} + +.icon-button.bookmark-icon.active { + color: $red-bookmark; +} + +.no-reduce-motion .icon-button.star-icon { + &.activate { + & > .fa-star { + animation: spring-rotate-in 1s linear; + } + } + + &.deactivate { + & > .fa-star { + animation: spring-rotate-out 1s linear; + } + } +} + +.notification__display-name { + color: inherit; + font-weight: 500; + text-decoration: none; + + &:hover { + color: $primary-text-color; + text-decoration: underline; + } +} + +.display-name { + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &__account { + text-overflow: ellipsis; + overflow: hidden; + } + + a { + color: inherit; + text-decoration: inherit; + } + + strong { + display: block; + } + + > a:hover { + strong { + text-decoration: underline; + } + } + + &.inline { + padding: 0; + height: 18px; + font-size: 15px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + + strong { + display: inline; + height: auto; + font-size: inherit; + line-height: inherit; + } + + span { + display: inline; + height: auto; + font-size: inherit; + line-height: inherit; + } + } +} + +.display-name__html { + font-weight: 500; +} + +.display-name__account { + font-size: 14px; +} + +.image-loader { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE 10+ */ + + * { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE 10+ */ + } + + &::-webkit-scrollbar, + *::-webkit-scrollbar { + width: 0; + height: 0; + background: transparent; /* Chrome/Safari/Webkit */ + } + + .image-loader__preview-canvas { + max-width: $media-modal-media-max-width; + max-height: $media-modal-media-max-height; + background: url('~images/void.png') repeat; + object-fit: contain; + } + + .loading-bar__container { + position: relative; + } + + .loading-bar { + position: absolute; + } + + &.image-loader--amorphous .image-loader__preview-canvas { + display: none; + } +} + +.zoomable-image { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + + img { + max-width: $media-modal-media-max-width; + max-height: $media-modal-media-max-height; + width: auto; + height: auto; + object-fit: contain; + } +} + +.dropdown-animation { + animation: dropdown 150ms cubic-bezier(0.1, 0.7, 0.1, 1); + + @keyframes dropdown { + from { + opacity: 0; + } + + to { + opacity: 1; + } + } + + .reduce-motion & { + animation: none; + } +} + +.dropdown { + display: inline-block; +} + +.dropdown__content { + display: none; + position: absolute; +} + +.dropdown-menu__separator { + border-bottom: 1px solid var(--dropdown-border-color); + margin: 2px 0; + height: 0; +} + +.dropdown-menu { + background: var(--dropdown-background-color); + border: 1px solid var(--dropdown-border-color); + padding: 2px; + border-radius: 4px; + box-shadow: var(--dropdown-shadow); + z-index: 9999; + + &__text-button { + display: inline; + color: inherit; + background: transparent; + border: 0; + margin: 0; + padding: 0; + font-family: inherit; + font-size: inherit; + line-height: inherit; + + &:focus { + outline: 1px dotted; + } + } + + &__container { + &__header { + border-bottom: 1px solid var(--dropdown-border-color); + padding: 6px 14px; + padding-bottom: 12px; + margin-bottom: 4px; + font-size: 13px; + line-height: 18px; + color: $darker-text-color; + } + + &__list { + list-style: none; + + &--scrollable { + max-height: 300px; + overflow-y: scroll; + } + } + + &--loading { + display: flex; + align-items: center; + justify-content: center; + padding: 30px 45px; + } + } +} + +.dropdown-menu__item { + font-size: 13px; + line-height: 18px; + font-weight: 500; + display: block; + + &--dangerous { + color: $error-value-color; + } + + a, + button { + font: inherit; + display: block; + width: 100%; + padding: 6px 14px; + border: 0; + margin: 0; + background: transparent; + box-sizing: border-box; + text-decoration: none; + color: inherit; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: inherit; + border-radius: 4px; + + &:focus, + &:hover, + &:active { + background: var(--dropdown-border-color); + outline: 0; + } + } +} + +.inline-account { + display: inline-flex; + align-items: center; + vertical-align: top; + + .account__avatar { + margin-inline-end: 5px; + border-radius: 50%; + } + + strong { + font-weight: 600; + } +} + +.static-content { + padding: 10px; + padding-top: 20px; + color: $dark-text-color; + + h1 { + font-size: 16px; + font-weight: 500; + margin-bottom: 40px; + text-align: center; + } + + p { + font-size: 13px; + margin-bottom: 20px; + } +} + +.column, +.drawer { + flex: 1 1 100%; + overflow: hidden; +} + +@media screen and (width >= 631px) { + .columns-area { + padding: 0; + } + + .column, + .drawer { + flex: 0 0 auto; + padding: 10px; + padding-inline-start: 5px; + padding-inline-end: 5px; + + &:first-child { + padding-inline-start: 10px; + } + + &:last-child { + padding-inline-end: 10px; + } + } + + .columns-area > div { + .column, + .drawer { + padding-inline-start: 5px; + padding-inline-end: 5px; + } + } +} + +.tabs-bar { + box-sizing: border-box; + display: flex; + background: lighten($ui-base-color, 8%); + flex: 0 0 auto; + overflow-y: auto; +} + +.tabs-bar__link { + display: block; + flex: 1 1 auto; + padding: 15px 10px; + padding-bottom: 13px; + color: $primary-text-color; + text-decoration: none; + text-align: center; + font-size: 14px; + font-weight: 500; + border-bottom: 2px solid lighten($ui-base-color, 8%); + transition: all 50ms linear; + transition-property: border-bottom, background, color; + + .fa { + font-weight: 400; + font-size: 16px; + } + + &:hover, + &:focus, + &:active { + @include multi-columns('screen and (min-width: 631px)') { + background: lighten($ui-base-color, 14%); + border-bottom-color: lighten($ui-base-color, 14%); + } + } + + &.active { + border-bottom: 2px solid $ui-highlight-color; + color: $highlight-text-color; + } + + span { + margin-inline-start: 5px; + display: none; + } + + span.icon { + margin-inline-start: 0; + display: inline; + } +} + +.icon-with-badge { + position: relative; + + &__badge { + position: absolute; + inset-inline-start: 9px; + top: -13px; + background: $ui-highlight-color; + border: 2px solid lighten($ui-base-color, 8%); + padding: 1px 6px; + border-radius: 6px; + font-size: 10px; + font-weight: 500; + line-height: 14px; + color: $primary-text-color; + } + + &__issue-badge { + position: absolute; + inset-inline-start: 11px; + bottom: 1px; + display: block; + background: $error-red; + border-radius: 50%; + width: 0.625rem; + height: 0.625rem; + } +} + +.column-link--transparent .icon-with-badge__badge { + border-color: darken($ui-base-color, 8%); +} + +.scrollable { + overflow-y: scroll; + overflow-x: hidden; + flex: 1 1 auto; + -webkit-overflow-scrolling: touch; + + &.optionally-scrollable { + overflow-y: auto; + } + + @supports (display: grid) { + // hack to fix Chrome <57 + contain: strict; + } + + &--flex { + display: flex; + flex-direction: column; + } + + &__append { + flex: 1 1 auto; + position: relative; + min-height: 120px; + } + + .scrollable { + flex: 1 1 auto; + } +} + +.scrollable.fullscreen { + @supports (display: grid) { + // hack to fix Chrome <57 + contain: none; + } +} + +.react-toggle { + display: inline-block; + position: relative; + cursor: pointer; + background-color: transparent; + border: 0; + padding: 0; + user-select: none; + -webkit-tap-highlight-color: rgba($base-overlay-background, 0); + -webkit-tap-highlight-color: transparent; +} + +.react-toggle-screenreader-only { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} + +.react-toggle--disabled { + cursor: not-allowed; + opacity: 0.5; + transition: opacity 0.25s; +} + +.react-toggle-track { + width: 50px; + height: 24px; + padding: 0; + border-radius: 30px; + background-color: $ui-base-color; + transition: background-color 0.2s ease; +} + +.react-toggle:is(:hover, :focus-within):not(.react-toggle--disabled) + .react-toggle-track { + background-color: darken($ui-base-color, 10%); +} + +.react-toggle--checked .react-toggle-track { + background-color: darken($ui-highlight-color, 2%); +} + +.react-toggle--checked:is(:hover, :focus-within):not(.react-toggle--disabled) + .react-toggle-track { + background-color: $ui-highlight-color; +} + +.react-toggle-track-check { + position: absolute; + width: 14px; + height: 10px; + top: 0; + bottom: 0; + margin-top: auto; + margin-bottom: auto; + line-height: 0; + inset-inline-start: 8px; + opacity: 0; + transition: opacity 0.25s ease; +} + +.react-toggle--checked .react-toggle-track-check { + opacity: 1; + transition: opacity 0.25s ease; +} + +.react-toggle-track-x { + position: absolute; + width: 10px; + height: 10px; + top: 0; + bottom: 0; + margin-top: auto; + margin-bottom: auto; + line-height: 0; + inset-inline-end: 10px; + opacity: 1; + transition: opacity 0.25s ease; +} + +.react-toggle--checked .react-toggle-track-x { + opacity: 0; +} + +.react-toggle-thumb { + position: absolute; + top: 1px; + inset-inline-start: 1px; + width: 22px; + height: 22px; + border: 1px solid $ui-base-color; + border-radius: 50%; + background-color: darken($simple-background-color, 2%); + box-sizing: border-box; + transition: all 0.25s ease; + transition-property: border-color, left; +} + +.react-toggle--checked .react-toggle-thumb { + inset-inline-start: 27px; + border-color: $ui-highlight-color; +} + +.getting-started__wrapper, +.getting_started, +.flex-spacer { + background: $ui-base-color; +} + +.getting-started__wrapper { + position: relative; + overflow-y: auto; +} + +.flex-spacer { + flex: 1 1 auto; +} + +.getting-started { + background: $ui-base-color; + flex: 1 0 auto; + + p { + color: $secondary-text-color; + } + + a { + color: $dark-text-color; + } + + &__trends { + flex: 0 1 auto; + opacity: 1; + animation: fade 150ms linear; + margin-top: 10px; + + h4 { + border-bottom: 1px solid lighten($ui-base-color, 8%); + padding: 10px; + font-size: 12px; + text-transform: uppercase; + font-weight: 500; + + a { + color: $darker-text-color; + text-decoration: none; + } + } + + @media screen and (height <= 810px) { + .trends__item:nth-of-type(3) { + display: none; + } + } + + @media screen and (height <= 720px) { + .trends__item:nth-of-type(2) { + display: none; + } + } + + @media screen and (height <= 670px) { + display: none; + } + + .trends__item { + border-bottom: 0; + padding: 10px; + + &__current { + color: $darker-text-color; + } + } + } +} + +.column-link__badge { + display: inline-block; + border-radius: 4px; + font-size: 12px; + line-height: 19px; + font-weight: 500; + background: $ui-base-color; + padding: 4px 8px; + margin: -6px 10px; +} + +.keyboard-shortcuts { + padding: 8px 0 0; + overflow: hidden; + + thead { + position: absolute; + inset-inline-start: -9999px; + } + + td { + padding: 0 10px 8px; + } + + kbd { + display: inline-block; + padding: 3px 5px; + background-color: lighten($ui-base-color, 8%); + border: 1px solid darken($ui-base-color, 4%); + } +} + +.setting-text { + color: $darker-text-color; + background: transparent; + border: 0; + border-bottom: 2px solid $ui-primary-color; + outline: 0; + box-sizing: border-box; + display: block; + font-family: inherit; + margin-bottom: 10px; + padding: 7px 0; + width: 100%; + + &:focus, + &:active { + color: $primary-text-color; + border-bottom-color: $ui-highlight-color; + } + + @include limited-single-column('screen and (max-width: 600px)') { + font-size: 16px; + } + + &.light { + color: $inverted-text-color; + border-bottom: 2px solid lighten($ui-base-color, 27%); + + &:focus, + &:active { + color: $inverted-text-color; + border-bottom-color: $ui-highlight-color; + } + } +} + +button.icon-button i.fa-retweet { + background-position: 0 0; + height: 19px; + transition: background-position 0.9s steps(10); + transition-duration: 0s; + vertical-align: middle; + width: 22px; + + &::before { + display: none !important; + } +} + +button.icon-button.active i.fa-retweet { + transition-duration: 0.9s; + background-position: 0 100%; +} + +.reduce-motion button.icon-button i.fa-retweet, +.reduce-motion button.icon-button.active i.fa-retweet { + transition: none; +} + +.reduce-motion button.icon-button.disabled i.fa-retweet { + color: darken($action-button-color, 13%); +} + +.load-more { + display: block; + color: $dark-text-color; + background-color: transparent; + border: 0; + font-size: inherit; + text-align: center; + line-height: inherit; + margin: 0; + padding: 15px; + box-sizing: border-box; + width: 100%; + clear: both; + text-decoration: none; + + &:hover { + background: lighten($ui-base-color, 2%); + } +} + +.load-gap { + border-bottom: 1px solid lighten($ui-base-color, 8%); +} + +.timeline-hint { + text-align: center; + color: $darker-text-color; + padding: 15px; + box-sizing: border-box; + width: 100%; + cursor: default; + + strong { + font-weight: 500; + } + + a { + color: $highlight-text-color; + text-decoration: none; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + color: lighten($highlight-text-color, 4%); + } + } +} + +.missing-indicator { + padding-top: 20px + 48px; + + .regeneration-indicator__figure { + background-image: url('~flavours/blobfox/images/elephant_ui_disappointed.svg'); + } +} + +.scrollable > div > :first-child .notification__dismiss-overlay > .wrappy { + border-top: 1px solid $ui-base-color; +} + +.notification__dismiss-overlay { + overflow: hidden; + position: absolute; + top: 0; + inset-inline-end: 0; + bottom: -1px; + padding-inline-start: 15px; // space for the box shadow to be visible + z-index: 999; + align-items: center; + justify-content: flex-end; + cursor: pointer; + display: flex; + + .wrappy { + width: $dismiss-overlay-width; + align-self: stretch; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: lighten($ui-base-color, 8%); + border-inline-start: 1px solid lighten($ui-base-color, 20%); + box-shadow: 0 0 5px black; + border-bottom: 1px solid $ui-base-color; + } + + .ckbox { + border: 2px solid $ui-primary-color; + border-radius: 2px; + width: 30px; + height: 30px; + font-size: 20px; + color: $darker-text-color; + text-shadow: 0 0 5px black; + display: flex; + justify-content: center; + align-items: center; + } + + &:focus { + outline: 0 !important; + + .ckbox { + box-shadow: 0 0 1px 1px $ui-highlight-color; + } + } +} + +.text-btn { + display: inline-block; + padding: 0; + font-family: inherit; + font-size: inherit; + color: inherit; + border: 0; + background: transparent; + cursor: pointer; +} + +.loading-indicator { + color: $dark-text-color; + font-size: 12px; + font-weight: 400; + text-transform: uppercase; + overflow: visible; + position: absolute; + top: 50%; + inset-inline-start: 50%; + transform: translate(-50%, -50%); + display: flex; + align-items: center; + justify-content: center; +} + +.circular-progress { + color: lighten($ui-base-color, 26%); + animation: 1.4s linear 0s infinite normal none running simple-rotate; + + circle { + stroke: currentColor; + stroke-dasharray: 80px, 200px; + stroke-dashoffset: 0; + animation: circular-progress 1.4s ease-in-out infinite; + } +} + +@keyframes circular-progress { + 0% { + stroke-dasharray: 1px, 200px; + stroke-dashoffset: 0; + } + + 50% { + stroke-dasharray: 100px, 200px; + stroke-dashoffset: -15px; + } + + 100% { + stroke-dasharray: 100px, 200px; + stroke-dashoffset: -125px; + } +} + +@keyframes simple-rotate { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +@keyframes spring-rotate-in { + 0% { + transform: rotate(0deg); + } + + 30% { + transform: rotate(-484.8deg); + } + + 60% { + transform: rotate(-316.7deg); + } + + 90% { + transform: rotate(-375deg); + } + + 100% { + transform: rotate(-360deg); + } +} + +@keyframes spring-rotate-out { + 0% { + transform: rotate(-360deg); + } + + 30% { + transform: rotate(124.8deg); + } + + 60% { + transform: rotate(-43.27deg); + } + + 90% { + transform: rotate(15deg); + } + + 100% { + transform: rotate(0deg); + } +} + +.spoiler-button { + top: 0; + inset-inline-start: 0; + width: 100%; + height: 100%; + position: absolute; + z-index: 100; + + &--minified { + display: flex; + inset-inline-start: 4px; + top: 4px; + width: auto; + height: auto; + align-items: center; + } + + &--click-thru { + pointer-events: none; + } + + &--hidden { + display: none; + } + + &__overlay { + display: flex; + align-items: center; + justify-content: center; + background: transparent; + width: 100%; + height: 100%; + padding: 0; + margin: 0; + border: 0; + color: $white; + + &__label { + background-color: rgba($black, 0.45); + backdrop-filter: blur(10px) saturate(180%) contrast(75%) brightness(70%); + border-radius: 6px; + padding: 10px 15px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-direction: column; + font-weight: 500; + font-size: 14px; + } + + &__action { + font-weight: 400; + font-size: 13px; + } + + &:hover, + &:focus { + .spoiler-button__overlay__label { + background-color: rgba($black, 0.9); + } + } + } +} + +.setting-toggle { + display: block; + line-height: 24px; +} + +.setting-toggle__label, +.setting-meta__label { + color: $darker-text-color; + display: inline-block; + margin-bottom: 14px; + margin-inline-start: 8px; + vertical-align: middle; +} + +.column-settings__row .radio-button { + display: block; +} + +.setting-meta__label { + float: right; +} + +@keyframes heartbeat { + 0% { + transform: scale(1); + transform-origin: center center; + animation-timing-function: ease-out; + } + + 10% { + transform: scale(0.91); + animation-timing-function: ease-in; + } + + 17% { + transform: scale(0.98); + animation-timing-function: ease-out; + } + + 33% { + transform: scale(0.87); + animation-timing-function: ease-in; + } + + 45% { + transform: scale(1); + animation-timing-function: ease-out; + } +} + +.pulse-loading { + animation: heartbeat 1.5s ease-in-out infinite both; +} + +.upload-area { + align-items: center; + background: rgba($base-overlay-background, 0.8); + display: flex; + height: 100vh; + justify-content: center; + inset-inline-start: 0; + opacity: 0; + position: fixed; + top: 0; + visibility: hidden; + width: 100vw; + z-index: 2000; + + * { + pointer-events: none; + } +} + +.upload-area__drop { + width: 320px; + height: 160px; + display: flex; + box-sizing: border-box; + position: relative; + padding: 8px; +} + +.upload-area__background { + position: absolute; + top: 0; + inset-inline-end: 0; + bottom: 0; + inset-inline-start: 0; + z-index: -1; + border-radius: 4px; + background: $ui-base-color; + box-shadow: 0 0 5px rgba($base-shadow-color, 0.2); +} + +.upload-area__content { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + color: $secondary-text-color; + font-size: 18px; + font-weight: 500; + border: 2px dashed $ui-base-lighter-color; + border-radius: 4px; +} + +.dropdown--active .emoji-button img { + opacity: 1; + filter: none; +} + +.loading-bar { + background-color: $ui-highlight-color; + height: 3px; + position: fixed; + top: 0; + inset-inline-start: 0; + z-index: 9999; +} + +.icon-badge-wrapper { + position: relative; +} + +.icon-badge { + position: absolute; + display: block; + inset-inline-end: -0.25em; + top: -0.25em; + background-color: $ui-highlight-color; + border-radius: 50%; + font-size: 75%; + width: 1em; + height: 1em; +} + +.conversation { + display: flex; + border-bottom: 1px solid lighten($ui-base-color, 8%); + padding: 5px; + padding-bottom: 0; + + &:focus { + background: lighten($ui-base-color, 2%); + outline: 0; + } + + &__avatar { + flex: 0 0 auto; + padding: 10px; + padding-top: 12px; + position: relative; + cursor: pointer; + } + + &__unread { + display: inline-block; + background: $highlight-text-color; + border-radius: 50%; + width: 0.625rem; + height: 0.625rem; + margin: -0.1ex 0.15em 0.1ex; + } + + &__content { + flex: 1 1 auto; + padding: 10px 5px; + padding-inline-end: 15px; + overflow: hidden; + + &__info { + overflow: hidden; + display: flex; + flex-direction: row-reverse; + justify-content: space-between; + } + + &__relative-time { + font-size: 15px; + color: $darker-text-color; + padding-inline-start: 15px; + } + + &__names { + color: $darker-text-color; + font-size: 15px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; + flex-basis: 90px; + flex-grow: 1; + + a { + color: $primary-text-color; + text-decoration: none; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } + } + + .status__content { + margin: 0; + } + } + + &--unread { + background: lighten($ui-base-color, 2%); + + &:focus { + background: lighten($ui-base-color, 4%); + } + + .conversation__content__info { + font-weight: 700; + } + + .conversation__content__relative-time { + color: $primary-text-color; + } + } +} + +.ui .flash-message { + margin-top: 10px; + margin-inline-start: auto; + margin-inline-end: auto; + margin-bottom: 0; + min-width: 75%; +} + +::-webkit-scrollbar-thumb { + border-radius: 0; +} + +noscript { + text-align: center; + + img { + width: 200px; + opacity: 0.5; + animation: flicker 4s infinite; + } + + div { + font-size: 14px; + margin: 30px auto; + color: $secondary-text-color; + max-width: 400px; + + a { + color: $highlight-text-color; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } + + a { + word-break: break-word; + } + } +} + +@keyframes flicker { + 0% { + opacity: 1; + } + + 30% { + opacity: 0.75; + } + + 100% { + opacity: 1; + } +} + +.notification-list { + position: fixed; + bottom: 2rem; + inset-inline-start: 0; + z-index: 999; + display: flex; + flex-direction: column; + gap: 4px; +} + +.notification-bar { + flex: 0 0 auto; + position: relative; + inset-inline-start: -100%; + width: auto; + padding: 15px; + margin: 0; + color: $white; + background: rgba($black, 0.85); + backdrop-filter: blur(8px); + border: 1px solid rgba(lighten($classic-base-color, 4%), 0.85); + border-radius: 8px; + box-shadow: + 0 10px 15px -3px rgba($base-shadow-color, 0.25), + 0 4px 6px -4px rgba($base-shadow-color, 0.25); + cursor: default; + font-size: 15px; + line-height: 21px; + + &.notification-bar-active { + inset-inline-start: 1rem; + } + + .no-reduce-motion & { + transition: 0.5s cubic-bezier(0.89, 0.01, 0.5, 1.1); + transform: translateZ(0); + } +} + +.notification-bar-title { + margin-inline-end: 5px; +} + +.notification-bar-title, +.notification-bar-action { + font-weight: 700; +} + +.notification-bar-action { + text-transform: uppercase; + margin-inline-start: 10px; + cursor: pointer; + color: $blurple-300; + border-radius: 4px; + padding: 0 4px; + + &:hover, + &:focus, + &:active { + background: rgba($ui-base-color, 0.85); + } +} diff --git a/app/javascript/flavours/blobfox/styles/components/modal.scss b/app/javascript/flavours/blobfox/styles/components/modal.scss new file mode 100644 index 00000000000000..5cbe405e0ea7ac --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/modal.scss @@ -0,0 +1,1502 @@ +.modal-container--preloader { + background: lighten($ui-base-color, 8%); +} + +.modal-root { + position: relative; + z-index: 9999; +} + +.modal-root__overlay { + position: fixed; + top: 0; + inset-inline-start: 0; + inset-inline-end: 0; + bottom: 0; + background: rgba($base-overlay-background, 0.7); + transition: background 0.5s; +} + +.modal-root__container { + position: fixed; + top: 0; + inset-inline-start: 0; + width: 100%; + height: 100%; + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + align-content: space-around; + z-index: 9999; + pointer-events: none; + user-select: none; +} + +.modal-root__modal { + pointer-events: auto; + user-select: text; + display: flex; +} + +.media-modal__zoom-button { + position: absolute; + inset-inline-end: 64px; + top: 8px; + z-index: 100; + pointer-events: auto; + transition: opacity 0.3s linear; + will-change: opacity; +} + +.media-modal__zoom-button--hidden { + pointer-events: none; + opacity: 0; +} + +.onboarding-modal, +.error-modal, +.embed-modal { + background: $ui-secondary-color; + color: $inverted-text-color; + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.onboarding-modal__pager { + height: 80vh; + width: 80vw; + max-width: 520px; + max-height: 470px; + + .react-swipeable-view-container > div { + width: 100%; + height: 100%; + box-sizing: border-box; + flex-direction: column; + align-items: center; + justify-content: center; + display: flex; + user-select: text; + } +} + +.error-modal__body { + height: 80vh; + width: 80vw; + max-width: 520px; + max-height: 420px; + position: relative; + + & > div { + position: absolute; + top: 0; + inset-inline-start: 0; + width: 100%; + height: 100%; + box-sizing: border-box; + padding: 25px; + flex-direction: column; + align-items: center; + justify-content: center; + display: flex; + opacity: 0; + user-select: text; + } +} + +.error-modal__body { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; +} + +@media screen and (width <= 550px) { + .onboarding-modal { + width: 100%; + height: 100%; + border-radius: 0; + } + + .onboarding-modal__pager { + width: 100%; + height: auto; + max-width: none; + max-height: none; + flex: 1 1 auto; + } +} + +.onboarding-modal__paginator, +.error-modal__footer { + flex: 0 0 auto; + background: darken($ui-secondary-color, 8%); + display: flex; + padding: 25px; + + & > div { + min-width: 33px; + } + + .onboarding-modal__nav, + .error-modal__nav { + color: $lighter-text-color; + border: 0; + font-size: 14px; + font-weight: 500; + padding: 10px 25px; + line-height: inherit; + height: auto; + margin: -10px; + border-radius: 4px; + background-color: transparent; + + &:hover, + &:focus, + &:active { + color: darken($lighter-text-color, 4%); + background-color: darken($ui-secondary-color, 16%); + } + + &.onboarding-modal__done, + &.onboarding-modal__next { + color: $inverted-text-color; + + &:hover, + &:focus, + &:active { + color: lighten($inverted-text-color, 4%); + } + } + } +} + +.error-modal__footer { + justify-content: center; +} + +.onboarding-modal__dots { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; +} + +.onboarding-modal__dot { + width: 14px; + height: 14px; + border-radius: 14px; + background: darken($ui-secondary-color, 16%); + margin: 0 3px; + cursor: pointer; + + &:hover { + background: darken($ui-secondary-color, 18%); + } + + &.active { + cursor: default; + background: darken($ui-secondary-color, 24%); + } +} + +.onboarding-modal__page__wrapper { + pointer-events: none; + padding: 25px; + padding-bottom: 0; + + &.onboarding-modal__page__wrapper--active { + pointer-events: auto; + } +} + +.onboarding-modal__page { + cursor: default; + line-height: 21px; + + h1 { + font-size: 18px; + font-weight: 500; + color: $inverted-text-color; + margin-bottom: 20px; + } + + a { + color: $highlight-text-color; + + &:hover, + &:focus, + &:active { + color: lighten($highlight-text-color, 4%); + } + } + + .navigation-bar a { + color: inherit; + } + + p { + font-size: 16px; + color: $lighter-text-color; + margin-top: 10px; + margin-bottom: 10px; + + &:last-child { + margin-bottom: 0; + } + + strong { + font-weight: 500; + background: $ui-base-color; + color: $secondary-text-color; + border-radius: 4px; + font-size: 14px; + padding: 3px 6px; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + } +} + +.onboarding-modal__page__wrapper-0 { + background: url('~images/elephant_ui_greeting.svg') no-repeat left bottom / + auto 250px; + height: 100%; + padding: 0; +} + +.onboarding-modal__page-one { + &__lead { + padding: 65px; + padding-top: 45px; + padding-bottom: 0; + margin-bottom: 10px; + + h1 { + font-size: 26px; + line-height: 36px; + margin-bottom: 8px; + } + + p { + margin-bottom: 0; + } + } + + &__extra { + padding-inline-end: 65px; + padding-inline-start: 185px; + text-align: center; + } +} + +.display-case { + text-align: center; + font-size: 15px; + margin-bottom: 15px; + + &__label { + font-weight: 500; + color: $inverted-text-color; + margin-bottom: 5px; + text-transform: uppercase; + font-size: 12px; + } + + &__case { + background: $ui-base-color; + color: $secondary-text-color; + font-weight: 500; + padding: 10px; + border-radius: 4px; + } +} + +.onboarding-modal__page-two, +.onboarding-modal__page-three, +.onboarding-modal__page-four, +.onboarding-modal__page-five { + p { + text-align: start; + } + + .figure { + background: darken($ui-base-color, 8%); + color: $secondary-text-color; + margin-bottom: 20px; + border-radius: 4px; + padding: 10px; + text-align: center; + font-size: 14px; + box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.3); + + .onboarding-modal__image { + border-radius: 4px; + margin-bottom: 10px; + } + + &.non-interactive { + pointer-events: none; + text-align: start; + } + } +} + +.onboarding-modal__page-four__columns { + .row { + display: flex; + margin-bottom: 20px; + + & > div { + flex: 1 1 0; + margin: 0 10px; + + &:first-child { + margin-inline-start: 0; + } + + &:last-child { + margin-inline-end: 0; + } + + p { + text-align: center; + } + } + + &:last-child { + margin-bottom: 0; + } + } + + .column-header { + color: $primary-text-color; + } +} + +@media screen and (width <= 320px) and (height <= 600px) { + .onboarding-modal__page p { + font-size: 14px; + line-height: 20px; + } + + .onboarding-modal__page-two .figure, + .onboarding-modal__page-three .figure, + .onboarding-modal__page-four .figure, + .onboarding-modal__page-five .figure { + font-size: 12px; + margin-bottom: 10px; + } + + .onboarding-modal__page-four__columns .row { + margin-bottom: 10px; + } + + .onboarding-modal__page-four__columns .column-header { + padding: 5px; + font-size: 12px; + } +} + +.onboard-sliders { + display: inline-block; + max-width: 30px; + max-height: auto; + margin-inline-start: 10px; +} + +.doodle-modal, +.boost-modal, +.confirmation-modal, +.report-modal, +.actions-modal, +.mute-modal, +.block-modal, +.compare-history-modal { + background: lighten($ui-secondary-color, 8%); + color: $inverted-text-color; + border-radius: 8px; + overflow: hidden; + max-width: 90vw; + width: 480px; + position: relative; + flex-direction: column; + + .status__relative-time { + color: $dark-text-color; + float: right; + font-size: 14px; + width: auto; + margin: initial; + padding: initial; + } + + .status__visibility-icon { + color: $dark-text-color; + font-size: 14px; + padding: 0 4px; + } + + .status__display-name { + display: flex; + } + + .status__avatar { + height: 48px; + width: 48px; + } + + .status__content__spoiler-link { + color: lighten($secondary-text-color, 8%); + } +} + +.boost-modal .status-direct { + background-color: inherit; +} + +.actions-modal { + .status { + background: $white; + border-bottom-color: $ui-secondary-color; + padding-top: 10px; + padding-bottom: 10px; + } + + .dropdown-menu__separator { + border-bottom-color: $ui-secondary-color; + } +} + +.boost-modal__container { + overflow-x: scroll; + padding: 10px; + + .status { + user-select: text; + border-bottom: 0; + } +} + +.doodle-modal__action-bar, +.boost-modal__action-bar, +.confirmation-modal__action-bar, +.mute-modal__action-bar, +.block-modal__action-bar { + display: flex; + justify-content: space-between; + background: $ui-secondary-color; + padding: 10px; + line-height: 36px; + + & > div { + flex: 1 1 auto; + text-align: end; + color: $lighter-text-color; + padding-inline-end: 10px; + } + + .button { + flex: 0 0 auto; + } +} + +.boost-modal__status-header { + font-size: 15px; +} + +.boost-modal__status-time { + float: right; + font-size: 14px; +} + +.mute-modal, +.block-modal { + line-height: 24px; +} + +.mute-modal .react-toggle, +.block-modal .react-toggle { + vertical-align: middle; +} + +.report-modal { + width: 90vw; + max-width: 700px; +} + +.report-dialog-modal { + max-width: 90vw; + width: 480px; + height: 80vh; + background: lighten($ui-secondary-color, 8%); + color: $inverted-text-color; + border-radius: 8px; + overflow: hidden; + position: relative; + flex-direction: column; + display: flex; + + &__container { + box-sizing: border-box; + border-top: 1px solid $ui-secondary-color; + padding: 20px; + flex-grow: 1; + display: flex; + flex-direction: column; + min-height: 0; + overflow: auto; + } + + &__title { + font-size: 28px; + line-height: 33px; + font-weight: 700; + margin-bottom: 15px; + + @media screen and (height <= 800px) { + font-size: 22px; + } + } + + &__subtitle { + font-size: 17px; + font-weight: 600; + line-height: 22px; + margin-bottom: 4px; + } + + &__lead { + font-size: 17px; + line-height: 22px; + color: lighten($inverted-text-color, 16%); + margin-bottom: 30px; + + a { + text-decoration: none; + color: $inverted-text-color; + font-weight: 500; + + &:hover { + text-decoration: underline; + } + } + } + + &__actions { + margin-top: 30px; + display: flex; + + .button { + flex: 1 1 auto; + } + } + + &__statuses { + flex-grow: 1; + min-height: 0; + overflow: auto; + } + + .status__content a { + color: $highlight-text-color; + } + + .status__content, + .status__content p { + color: $inverted-text-color; + } + + .status__content__spoiler-link { + color: $primary-text-color; + background: $ui-primary-color; + + &:hover { + background: lighten($ui-primary-color, 8%); + } + } + + .dialog-option .poll__input { + border-color: $inverted-text-color; + color: $ui-secondary-color; + display: inline-flex; + align-items: center; + justify-content: center; + + svg { + width: 8px; + height: auto; + } + + &:active, + &:focus, + &:hover { + border-color: lighten($inverted-text-color, 15%); + border-width: 4px; + } + + &.active { + border-color: $inverted-text-color; + background: $inverted-text-color; + } + } + + .poll__option.dialog-option { + padding: 15px 0; + flex: 0 0 auto; + border-bottom: 1px solid $ui-secondary-color; + + &:last-child { + border-bottom: 0; + } + + & > .poll__option__text { + font-size: 13px; + color: lighten($inverted-text-color, 16%); + + strong { + font-size: 17px; + font-weight: 500; + line-height: 22px; + color: $inverted-text-color; + display: block; + margin-bottom: 4px; + + &:last-child { + margin-bottom: 0; + } + } + } + } + + .flex-spacer { + background: transparent; + } + + &__textarea { + display: block; + box-sizing: border-box; + width: 100%; + color: $inverted-text-color; + background: $simple-background-color; + padding: 10px; + font-family: inherit; + font-size: 17px; + line-height: 22px; + resize: vertical; + border: 0; + outline: 0; + border-radius: 4px; + margin: 20px 0; + + &::placeholder { + color: $dark-text-color; + } + + &:focus { + outline: 0; + } + } + + &__toggle { + display: flex; + align-items: center; + margin-bottom: 10px; + + & > span { + font-size: 17px; + font-weight: 500; + margin-inline-start: 10px; + } + } + + .button.button-secondary { + border-color: $inverted-text-color; + color: $inverted-text-color; + flex: 0 0 auto; + + &:hover, + &:focus, + &:active { + background: transparent; + border-color: $ui-button-background-color; + color: $ui-button-background-color; + } + } + + hr { + border: 0; + background: transparent; + margin: 15px 0; + } + + .emoji-mart-search { + padding-inline-end: 10px; + } + + .emoji-mart-search-icon { + inset-inline-end: 10px + 5px; + } +} + +.report-modal__container { + display: flex; + border-top: 1px solid $ui-secondary-color; + + @media screen and (width <= 480px) { + flex-wrap: wrap; + overflow-y: auto; + } +} + +.report-modal__statuses, +.report-modal__comment { + box-sizing: border-box; + width: 50%; + + @media screen and (width <= 480px) { + width: 100%; + } +} + +.report-modal__statuses, +.focal-point-modal__content { + flex: 1 1 auto; + min-height: 20vh; + max-height: 80vh; + overflow-y: auto; + overflow-x: hidden; + + .status__content a { + color: $highlight-text-color; + } + + @media screen and (width <= 480px) { + max-height: 10vh; + } +} + +.focal-point-modal__content { + @media screen and (width <= 480px) { + max-height: 40vh; + } +} + +.setting-divider { + background: transparent; + border: 0; + margin: 0; + width: 100%; + height: 1px; + margin-bottom: 29px; +} + +.report-modal__comment { + padding: 20px; + border-inline-end: 1px solid $ui-secondary-color; + max-width: 320px; + + p { + font-size: 14px; + line-height: 20px; + margin-bottom: 20px; + } + + .setting-text { + display: block; + box-sizing: border-box; + width: 100%; + margin: 0; + color: $inverted-text-color; + background: $white; + padding: 10px; + font-family: inherit; + font-size: 14px; + resize: none; + outline: 0; + border-radius: 4px; + border: 1px solid $ui-secondary-color; + min-height: 100px; + max-height: 50vh; + margin-bottom: 10px; + + &:focus { + border: 1px solid darken($ui-secondary-color, 8%); + } + + &__wrapper { + background: $white; + border: 1px solid $ui-secondary-color; + margin-bottom: 10px; + border-radius: 4px; + + .setting-text { + border: 0; + margin-bottom: 0; + border-radius: 0; + + &:focus { + border: 0; + } + } + + &__modifiers { + color: $inverted-text-color; + font-family: inherit; + font-size: 14px; + background: $white; + } + } + + &__toolbar { + display: flex; + justify-content: space-between; + margin-bottom: 20px; + } + } + + .setting-text-label { + display: block; + color: $inverted-text-color; + font-size: 14px; + font-weight: 500; + margin-bottom: 10px; + } + + .setting-toggle { + margin-top: 20px; + margin-bottom: 24px; + + &__label { + color: $inverted-text-color; + font-size: 14px; + } + } + + @media screen and (width <= 480px) { + padding: 10px; + max-width: 100%; + order: 2; + + .setting-toggle { + margin-bottom: 4px; + } + } +} + +.actions-modal { + .status { + overflow-y: auto; + max-height: 300px; + } + + strong { + display: block; + font-weight: 500; + } + + max-height: 80vh; + max-width: 80vw; + + .actions-modal__item-label { + font-weight: 500; + } + + ul { + overflow-y: auto; + flex-shrink: 0; + max-height: 80vh; + + &.with-status { + max-height: calc(80vh - 75px); + } + + li:empty { + margin: 0; + } + + li:not(:empty) { + a { + color: $inverted-text-color; + display: flex; + padding: 12px 16px; + font-size: 15px; + align-items: center; + text-decoration: none; + + &, + button { + transition: none; + } + + &.active, + &:hover, + &:active, + &:focus { + &, + button { + background: $ui-highlight-color; + color: $primary-text-color; + } + } + + & > .react-toggle, + & > .icon, + button:first-child { + margin-inline-end: 10px; + } + } + } + } +} + +.confirmation-modal__action-bar, +.mute-modal__action-bar, +.block-modal__action-bar { + .confirmation-modal__secondary-button { + flex-shrink: 1; + } +} + +.confirmation-modal__secondary-button, +.confirmation-modal__cancel-button, +.mute-modal__cancel-button, +.block-modal__cancel-button { + background-color: transparent; + color: $lighter-text-color; + font-size: 14px; + font-weight: 500; + + &:hover, + &:focus, + &:active { + color: darken($lighter-text-color, 4%); + background-color: transparent; + } +} + +.confirmation-modal__do_not_ask_again { + padding-inline-start: 20px; + padding-inline-end: 20px; + padding-bottom: 10px; + font-size: 14px; + + label, + input { + vertical-align: middle; + } +} + +.confirmation-modal__container, +.mute-modal__container, +.block-modal__container, +.report-modal__target { + padding: 30px; + font-size: 16px; + + strong { + font-weight: 500; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + + select { + appearance: none; + box-sizing: border-box; + font-size: 14px; + color: $inverted-text-color; + display: inline-block; + width: auto; + outline: 0; + font-family: inherit; + background: $simple-background-color + url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(darken($simple-background-color, 14%))}'/></svg>") + no-repeat right 8px center / auto 16px; + border: 1px solid darken($simple-background-color, 14%); + border-radius: 4px; + padding: 6px 10px; + padding-inline-end: 30px; + } +} + +.confirmation-modal__container, +.report-modal__target { + text-align: center; +} + +.block-modal, +.mute-modal { + &__explanation { + margin-top: 20px; + } + + .setting-toggle { + margin-top: 20px; + margin-bottom: 24px; + display: flex; + align-items: center; + + &__label { + color: $inverted-text-color; + margin: 0; + margin-inline-start: 8px; + } + } +} + +.report-modal__target { + padding: 15px; + + .report-modal__close { + position: absolute; + top: 10px; + inset-inline-end: 10px; + } +} + +.compare-history-modal { + .report-modal__target { + border-bottom: 1px solid $ui-secondary-color; + } + + &__container { + padding: 30px; + pointer-events: all; + overflow-y: auto; + } + + .status__content { + color: $inverted-text-color; + font-size: 19px; + line-height: 24px; + + .emojione { + width: 24px; + height: 24px; + margin: -1px 0 0; + } + + a { + color: $highlight-text-color; + } + + hr { + height: 0.25rem; + padding: 0; + background-color: $ui-secondary-color; + border: 0; + margin: 20px 0; + } + } + + .media-gallery, + .audio-player, + .video-player { + margin-top: 15px; + } +} + +.embed-modal { + width: auto; + max-width: 80vw; + max-height: 80vh; + + h4 { + padding: 30px; + font-weight: 500; + font-size: 16px; + text-align: center; + } + + .embed-modal__container { + padding: 10px; + + .hint { + margin-bottom: 15px; + } + + .embed-modal__html { + outline: 0; + box-sizing: border-box; + display: block; + width: 100%; + border: 0; + padding: 10px; + font-family: mastodon-font-monospace, monospace; + background: $ui-base-color; + color: $primary-text-color; + font-size: 14px; + margin: 0; + margin-bottom: 15px; + border-radius: 4px; + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + + &:focus { + background: lighten($ui-base-color, 4%); + } + + @media screen and (width <= 600px) { + font-size: 16px; + } + } + + .embed-modal__iframe { + width: 400px; + max-width: 100%; + overflow: hidden; + border: 0; + border-radius: 4px; + } + } +} + +.focal-point { + position: relative; + cursor: move; + overflow: hidden; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + background: $base-shadow-color; + + img, + video, + canvas { + display: block; + max-height: 80vh; + width: 100%; + height: auto; + margin: 0; + object-fit: contain; + background: $base-shadow-color; + } + + &__reticle { + position: absolute; + width: 100px; + height: 100px; + transform: translate(-50%, -50%); + background: url('~images/reticle.png') no-repeat 0 0; + border-radius: 50%; + box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35); + } + + &__overlay { + position: absolute; + width: 100%; + height: 100%; + top: 0; + inset-inline-start: 0; + } + + &__preview { + position: absolute; + bottom: 10px; + inset-inline-end: 10px; + z-index: 2; + cursor: move; + transition: opacity 0.1s ease; + + &:hover { + opacity: 0.5; + } + + strong { + color: $primary-text-color; + font-size: 14px; + font-weight: 500; + display: block; + margin-bottom: 5px; + } + + div { + border-radius: 4px; + box-shadow: 0 0 14px rgba($base-shadow-color, 0.2); + } + } + + @media screen and (width <= 480px) { + img, + video { + max-height: 100%; + } + + &__preview { + display: none; + } + } +} + +.filtered-status-info { + text-align: start; + + .spoiler__text { + margin-top: 20px; + } + + .account { + border-bottom: 0; + } + + .account__display-name strong { + color: $inverted-text-color; + } + + .status__content__spoiler { + display: none; + + &--visible { + display: flex; + } + } + + ul { + padding: 10px; + margin-inline-start: 12px; + list-style: disc inside; + } + + .filtered-status-edit-link { + color: $action-button-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.modal-root__container .privacy-dropdown { + flex-grow: 0; +} + +.modal-root__container .privacy-dropdown__dropdown { + pointer-events: auto; + z-index: 9999; +} + +img.modal-warning { + display: block; + margin: auto; + margin-bottom: 15px; + width: 60px; +} + +.interaction-modal { + max-width: 90vw; + width: 600px; + background: var(--modal-background-color); + border: 1px solid var(--modal-border-color); + border-radius: 8px; + overflow: visible; + position: relative; + display: block; + padding: 40px; + + h3 { + font-size: 22px; + line-height: 33px; + font-weight: 700; + text-align: center; + } + + p { + font-size: 17px; + line-height: 22px; + color: $darker-text-color; + + strong { + color: $primary-text-color; + font-weight: 700; + } + } + + p.hint { + margin-bottom: 14px; + font-size: 14px; + } + + &__icon { + color: $highlight-text-color; + margin: 0 5px; + } + + &__lead { + margin-bottom: 20px; + + h3 { + margin-bottom: 15px; + } + } + + &__login { + position: relative; + margin-bottom: 20px; + + &__input { + @include search-input; + + border: 1px solid lighten($ui-base-color, 8%); + padding: 4px 6px; + color: $primary-text-color; + font-size: 16px; + line-height: 18px; + display: flex; + align-items: center; + + input { + background: transparent; + color: inherit; + font: inherit; + border: 0; + padding: 15px - 4px 15px - 6px; + flex: 1 1 auto; + + &::placeholder { + color: lighten($darker-text-color, 4%); + } + + &:focus { + outline: 0; + } + } + + .button { + flex: 0 0 auto; + } + } + + .search__popout { + margin-top: -1px; + padding-top: 5px; + padding-bottom: 5px; + border: 1px solid lighten($ui-base-color, 8%); + } + + &.focused &__input { + border-color: $highlight-text-color; + background: lighten($ui-base-color, 4%); + } + + &.invalid &__input { + border-color: $error-red; + } + + &.expanded .search__popout { + display: block; + } + + &.expanded &__input { + border-radius: 4px 4px 0 0; + } + } + + &__choices { + display: flex; + gap: 40px; + + &__choice { + flex: 1; + box-sizing: border-box; + + h3 { + margin-bottom: 20px; + } + + p { + color: $darker-text-color; + margin-bottom: 20px; + font-size: 15px; + } + + .button { + margin-bottom: 10px; + + &:last-child { + margin-bottom: 0; + } + } + } + } + + @media screen and (max-width: $no-gap-breakpoint - 1px) { + &__choices { + flex-direction: column; + + &__choice { + margin-top: 40px; + } + } + } + + .link-button { + font-size: inherit; + display: inline; + } +} + +.copypaste { + display: flex; + align-items: center; + gap: 10px; + + input { + display: block; + font-family: inherit; + background: darken($ui-base-color, 8%); + border: 1px solid $highlight-text-color; + color: $darker-text-color; + border-radius: 4px; + padding: 6px 9px; + line-height: 22px; + font-size: 14px; + transition: border-color 300ms linear; + flex: 1 1 auto; + overflow: hidden; + + &:focus { + outline: 0; + background: darken($ui-base-color, 4%); + } + } + + .button { + flex: 0 0 auto; + transition: background 300ms linear; + } + + &.copied { + input { + border: 1px solid $valid-value-color; + transition: none; + } + + .button { + background: $valid-value-color; + transition: none; + } + } +} diff --git a/app/javascript/flavours/blobfox/styles/components/privacy_policy.scss b/app/javascript/flavours/blobfox/styles/components/privacy_policy.scss new file mode 100644 index 00000000000000..cab78402b214da --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/privacy_policy.scss @@ -0,0 +1,209 @@ +.privacy-policy { + background: $ui-base-color; + padding: 20px; + + @media screen and (min-width: $no-gap-breakpoint) { + border-radius: 4px; + } + + &__body { + margin-top: 20px; + } +} + +.prose { + color: $secondary-text-color; + font-size: 15px; + line-height: 22px; + + p, + ul, + ol { + margin-top: 1.25em; + margin-bottom: 1.25em; + } + + img { + margin-top: 2em; + margin-bottom: 2em; + max-width: 100%; + } + + video { + margin-top: 2em; + margin-bottom: 2em; + max-width: 100%; + } + + figure { + margin-top: 2em; + margin-bottom: 2em; + + figcaption { + font-size: 0.875em; + line-height: 1.4285714; + margin-top: 0.8571429em; + } + } + + figure > * { + margin-top: 0; + margin-bottom: 0; + } + + h1 { + font-size: 1.5em; + margin-top: 0; + margin-bottom: 1em; + line-height: 1.33; + } + + h2 { + font-size: 1.25em; + margin-top: 1.6em; + margin-bottom: 0.6em; + line-height: 1.6; + } + + h3, + h4, + h5, + h6 { + margin-top: 1.5em; + margin-bottom: 0.5em; + line-height: 1.5; + } + + ol { + counter-reset: list-counter; + } + + li { + margin-top: 0.5em; + margin-bottom: 0.5em; + } + + ol > li { + counter-increment: list-counter; + + &::before { + content: counter(list-counter) '.'; + position: absolute; + inset-inline-start: 0; + } + } + + ul > li::before { + content: ''; + position: absolute; + background-color: $darker-text-color; + border-radius: 50%; + width: 0.375em; + height: 0.375em; + top: 0.5em; + inset-inline-start: 0.25em; + } + + ul > li, + ol > li { + position: relative; + padding-inline-start: 1.75em; + } + + & > ul > li p { + margin-top: 0.75em; + margin-bottom: 0.75em; + } + + & > ul > li > *:first-child { + margin-top: 1.25em; + } + + & > ul > li > *:last-child { + margin-bottom: 1.25em; + } + + & > ol > li > *:first-child { + margin-top: 1.25em; + } + + & > ol > li > *:last-child { + margin-bottom: 1.25em; + } + + ul ul, + ul ol, + ol ul, + ol ol { + margin-top: 0.75em; + margin-bottom: 0.75em; + } + + h1, + h2, + h3, + h4, + h5, + h6, + strong, + b { + color: $primary-text-color; + font-weight: 700; + } + + em, + i { + font-style: italic; + } + + a { + color: $highlight-text-color; + text-decoration: underline; + + &:focus, + &:hover, + &:active { + text-decoration: none; + } + } + + code { + font-size: 0.875em; + background: darken($ui-base-color, 8%); + border-radius: 4px; + padding: 0.2em 0.3em; + } + + hr { + border: 0; + border-top: 1px solid lighten($ui-base-color, 4%); + margin-top: 3em; + margin-bottom: 3em; + } + + hr + * { + margin-top: 0; + } + + h2 + * { + margin-top: 0; + } + + h3 + * { + margin-top: 0; + } + + h4 + *, + h5 + *, + h6 + * { + margin-top: 0; + } + + & > :first-child { + margin-top: 0; + } + + & > :last-child { + margin-bottom: 0; + } +} diff --git a/app/javascript/flavours/blobfox/styles/components/regeneration_indicator.scss b/app/javascript/flavours/blobfox/styles/components/regeneration_indicator.scss new file mode 100644 index 00000000000000..c65e6a9afcda8c --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/regeneration_indicator.scss @@ -0,0 +1,43 @@ +.regeneration-indicator { + text-align: center; + font-size: 16px; + font-weight: 500; + color: $dark-text-color; + background: $ui-base-color; + cursor: default; + display: flex; + flex: 1 1 auto; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + + &__figure { + &, + img { + display: block; + width: auto; + height: 160px; + margin: 0; + } + } + + &--without-header { + padding-top: 20px + 48px; + } + + &__label { + margin-top: 30px; + + strong { + display: block; + margin-bottom: 10px; + color: $dark-text-color; + } + + span { + font-size: 15px; + font-weight: 400; + } + } +} diff --git a/app/javascript/flavours/blobfox/styles/components/search.scss b/app/javascript/flavours/blobfox/styles/components/search.scss new file mode 100644 index 00000000000000..aa54fc26db3a71 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/search.scss @@ -0,0 +1,337 @@ +.search { + margin-bottom: 10px; + position: relative; + + &__popout { + box-sizing: border-box; + display: none; + position: absolute; + inset-inline-start: 0; + margin-top: -2px; + width: 100%; + background: $ui-base-color; + border-radius: 0 0 4px 4px; + box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4); + z-index: 99; + font-size: 13px; + padding: 15px 5px; + + h4 { + text-transform: uppercase; + color: $dark-text-color; + font-weight: 500; + padding: 0 10px; + margin-bottom: 10px; + } + + &__menu { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + + &__message { + color: $dark-text-color; + padding: 0 10px; + } + + &__item { + display: block; + box-sizing: border-box; + width: 100%; + border: 0; + font: inherit; + background: transparent; + color: $darker-text-color; + padding: 10px; + cursor: pointer; + border-radius: 4px; + text-align: start; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + + &--flex { + display: flex; + justify-content: space-between; + } + + .icon-button { + transition: none; + } + + &:hover, + &:focus, + &:active, + &.selected { + background: $ui-highlight-color; + color: $primary-text-color; + + .icon-button { + color: $primary-text-color; + } + } + + mark { + background: transparent; + font-weight: 700; + color: $primary-text-color; + } + + span { + overflow: inherit; + text-overflow: inherit; + } + } + } + } + + &.active { + .search__popout { + display: block; + } + } +} + +.search__input { + @include search-input; + + display: block; + padding: 15px; + padding-inline-end: 30px; + line-height: 18px; + font-size: 16px; + + &::placeholder { + color: lighten($darker-text-color, 4%); + } + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + + &:focus { + background: lighten($ui-base-color, 4%); + } +} + +.search__icon { + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus { + outline: 0 !important; + } + + .fa { + position: absolute; + top: 16px; + inset-inline-end: 10px; + display: inline-block; + opacity: 0; + transition: all 100ms linear; + transition-property: color, transform, opacity; + font-size: 18px; + width: 18px; + height: 18px; + color: $secondary-text-color; + cursor: default; + pointer-events: none; + + &.active { + pointer-events: auto; + opacity: 0.3; + } + } + + .fa-search { + transform: rotate(0deg); + + &.active { + pointer-events: auto; + opacity: 0.3; + } + } + + .fa-times-circle { + top: 17px; + transform: rotate(0deg); + color: $action-button-color; + cursor: pointer; + + &.active { + transform: rotate(90deg); + opacity: 1; + } + + &:hover { + color: lighten($action-button-color, 7%); + } + } +} + +.search-results__header { + color: $dark-text-color; + background: lighten($ui-base-color, 2%); + padding: 15px; + font-weight: 500; + font-size: 16px; + cursor: default; + + .fa { + display: inline-block; + margin-inline-end: 5px; + } +} + +.search-results__info { + padding: 20px; + color: $darker-text-color; + text-align: center; +} + +.trends { + &__header { + color: $dark-text-color; + background: lighten($ui-base-color, 2%); + border-bottom: 1px solid darken($ui-base-color, 4%); + font-weight: 500; + padding: 15px; + font-size: 16px; + cursor: default; + + .fa { + display: inline-block; + margin-inline-end: 5px; + } + } + + &__item { + display: flex; + align-items: center; + padding: 15px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + gap: 15px; + + &:last-child { + border-bottom: 0; + } + + &__name { + flex: 1 1 auto; + color: $dark-text-color; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + strong { + font-weight: 500; + } + + a { + color: $darker-text-color; + text-decoration: none; + font-size: 14px; + font-weight: 500; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &:hover, + &:focus, + &:active { + span { + text-decoration: underline; + } + } + } + } + + &__current { + flex: 0 0 auto; + font-size: 24px; + font-weight: 500; + text-align: end; + color: $secondary-text-color; + text-decoration: none; + } + + &__sparkline { + flex: 0 0 auto; + width: 50px; + + path:first-child { + fill: rgba($highlight-text-color, 0.25) !important; + fill-opacity: 1 !important; + } + + path:last-child { + stroke: lighten($highlight-text-color, 6%) !important; + fill: none !important; + } + } + + &--requires-review { + .trends__item__name { + color: $gold-star; + + a { + color: $gold-star; + } + } + + .trends__item__current { + color: $gold-star; + } + + .trends__item__sparkline { + path:first-child { + fill: rgba($gold-star, 0.25) !important; + } + + path:last-child { + stroke: lighten($gold-star, 6%) !important; + } + } + } + + &--disabled { + .trends__item__name { + color: lighten($ui-base-color, 12%); + + a { + color: lighten($ui-base-color, 12%); + } + } + + .trends__item__current { + color: lighten($ui-base-color, 12%); + } + + .trends__item__sparkline { + path:first-child { + fill: rgba(lighten($ui-base-color, 12%), 0.25) !important; + } + + path:last-child { + stroke: lighten(lighten($ui-base-color, 12%), 6%) !important; + } + } + } + } + + &--compact &__item { + padding: 10px; + padding-inline-end: 28px; + } +} diff --git a/app/javascript/flavours/blobfox/styles/components/sensitive.scss b/app/javascript/flavours/blobfox/styles/components/sensitive.scss new file mode 100644 index 00000000000000..c77515eb70fd18 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/sensitive.scss @@ -0,0 +1,26 @@ +.sensitive-info { + display: flex; + flex-direction: row; + align-items: center; + position: absolute; + top: 4px; + inset-inline-start: 4px; + z-index: 100; +} + +.sensitive-marker { + margin: 0 3px; + border-radius: 2px; + padding: 2px 6px; + color: rgba($primary-text-color, 0.8); + background: rgba($base-overlay-background, 0.5); + font-size: 12px; + line-height: 18px; + text-transform: uppercase; + opacity: 0.9; + transition: opacity 0.1s ease; + + .media-gallery:hover & { + opacity: 1; + } +} diff --git a/app/javascript/flavours/blobfox/styles/components/signed_out.scss b/app/javascript/flavours/blobfox/styles/components/signed_out.scss new file mode 100644 index 00000000000000..18492983e5887b --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/signed_out.scss @@ -0,0 +1,110 @@ +.sign-in-banner { + padding: 10px; + + p { + color: $darker-text-color; + margin-bottom: 20px; + + a { + color: $secondary-text-color; + text-decoration: none; + unicode-bidi: isolate; + + &:hover { + text-decoration: underline; + + .fa { + color: lighten($dark-text-color, 7%); + } + } + } + } + + .button { + margin-bottom: 10px; + } +} + +.server-banner { + padding: 20px 0; + + &__introduction { + color: $darker-text-color; + margin-bottom: 20px; + + strong { + font-weight: 600; + } + + a { + color: inherit; + text-decoration: underline; + + &:hover, + &:active, + &:focus { + text-decoration: none; + } + } + } + + &__hero { + display: block; + border-radius: 4px; + width: 100%; + height: auto; + margin-bottom: 20px; + aspect-ratio: 1.9; + border: 0; + background: $ui-base-color; + object-fit: cover; + } + + &__description { + margin-bottom: 20px; + } + + &__meta { + display: flex; + gap: 10px; + max-width: 100%; + + &__column { + flex: 0 0 auto; + width: calc(50% - 5px); + overflow: hidden; + } + } + + &__number { + font-weight: 600; + color: $primary-text-color; + font-size: 14px; + } + + &__number-label { + color: $darker-text-color; + font-weight: 500; + font-size: 14px; + } + + h4 { + text-transform: uppercase; + color: $darker-text-color; + margin-bottom: 10px; + font-weight: 600; + } + + .account { + padding: 0; + border: 0; + } + + .account__avatar-wrapper { + margin-inline-start: 0; + } + + .spacer { + margin: 10px 0; + } +} diff --git a/app/javascript/flavours/blobfox/styles/components/single_column.scss b/app/javascript/flavours/blobfox/styles/components/single_column.scss new file mode 100644 index 00000000000000..87fdd170d3b7e7 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/single_column.scss @@ -0,0 +1,335 @@ +.compose-panel { + width: 285px; + margin-top: 10px; + display: flex; + flex-direction: column; + height: calc(100% - 10px); + overflow-y: hidden; + + .hero-widget { + box-shadow: none; + + &__text, + &__img, + &__img img { + border-radius: 0; + } + + &__text { + padding: 15px; + color: $secondary-text-color; + + strong { + font-weight: 700; + color: $primary-text-color; + } + } + } + + .search__input { + line-height: 18px; + font-size: 16px; + padding: 15px; + padding-inline-end: 30px; + } + + .search__icon .fa { + top: 15px; + } + + .navigation-bar { + flex: 0 1 48px; + } + + .compose-form { + flex: 1; + display: flex; + flex-direction: column; + min-height: 310px; + } + + .compose-form__autosuggest-wrapper { + overflow-y: auto; + background-color: $white; + border-radius: 4px 4px 0 0; + flex: 0 1 auto; + } + + .autosuggest-textarea__textarea { + overflow-y: hidden; + } +} + +.navigation-panel { + margin-top: 10px; + margin-bottom: 10px; + height: calc(100% - 20px); + overflow-y: auto; + display: flex; + flex-direction: column; + + & > a { + flex: 0 0 auto; + } + + .logo { + height: 30px; + width: auto; + } +} + +.navigation-panel, +.compose-panel { + hr { + flex: 0 0 auto; + border: 0; + background: transparent; + border-top: 1px solid lighten($ui-base-color, 4%); + margin: 10px 0; + } + + .flex-spacer { + background: transparent; + } +} + +@media screen and (width >= 600px) { + .tabs-bar__link { + span { + display: inline; + } + } +} + +.columns-area--mobile { + flex-direction: column; + width: 100%; + margin: 0 auto; + + .column, + .drawer { + width: 100%; + height: 100%; + padding: 0; + } + + .account-card { + margin-bottom: 0; + } + + .filter-form { + display: flex; + flex-wrap: wrap; + } + + .autosuggest-textarea__textarea { + font-size: 16px; + } + + .search__input { + line-height: 18px; + font-size: 16px; + padding: 15px; + padding-inline-end: 30px; + } + + .search__icon .fa { + top: 15px; + } + + .scrollable { + overflow: visible; + + @supports (display: grid) { + contain: content; + } + } + + @media screen and (min-width: $no-gap-breakpoint) { + padding: 10px 0; + padding-top: 0; + } + + .detailed-status { + padding: 15px; + + .media-gallery, + .video-player, + .audio-player { + margin-top: 15px; + } + } + + .account__header__bar { + padding: 5px 10px; + } + + .navigation-bar, + .compose-form { + padding: 15px; + } + + .compose-form .compose-form__publish .compose-form__publish-button-wrapper { + padding-top: 15px; + } + + .notification__report { + padding: 15px; + padding-inline-start: (48px + 15px * 2); + min-height: 48px + 2px; + + &__avatar { + inset-inline-start: 15px; + top: 17px; + } + } + + .status { + padding: 15px; + min-height: 48px + 2px; + + .media-gallery, + &__action-bar, + .video-player, + .audio-player { + margin-top: 10px; + } + } + + .account { + padding: 15px 10px; + + &__header__bio { + margin: 0 -10px; + } + } + + .notification { + &__message { + padding-top: 15px; + } + + .status { + padding-top: 8px; + } + + .account { + padding-top: 8px; + } + } +} + +@media screen and (min-width: $no-gap-breakpoint) { + .tabs-bar { + width: 100%; + } + + .react-swipeable-view-container .columns-area--mobile { + height: calc(100% - 10px) !important; + } + + .getting-started__wrapper { + margin-bottom: 10px; + } + + .tabs-bar__link.optional { + display: none; + } + + .search-page .search { + display: none; + } + + .navigation-panel__legal { + display: none; + } +} + +@media screen and (max-width: $no-gap-breakpoint - 1px) { + $sidebar-width: 285px; + + .columns-area__panels__main { + width: calc(100% - $sidebar-width); + } + + .columns-area__panels { + min-height: calc(100vh - $ui-header-height); + } + + .columns-area__panels__pane--navigational { + min-width: $sidebar-width; + + .columns-area__panels__pane__inner { + width: $sidebar-width; + } + + .navigation-panel { + margin: 0; + background: $ui-base-color; + border-inline-start: 1px solid lighten($ui-base-color, 8%); + height: 100vh; + } + + .navigation-panel__sign-in-banner, + .navigation-panel__logo, + .navigation-panel__banner, + .getting-started__trends { + display: none; + } + + .column-link__icon { + font-size: 18px; + } + } + + .layout-single-column .ui__header { + display: flex; + background: $ui-base-color; + border-bottom: 1px solid lighten($ui-base-color, 8%); + } + + .column-header, + .column-back-button, + .scrollable, + .error-column { + border-radius: 0 !important; + } +} + +@media screen and (max-width: $no-gap-breakpoint - 285px - 1px) { + $sidebar-width: 55px; + + .columns-area__panels__main { + width: calc(100% - $sidebar-width); + } + + .columns-area__panels__pane--navigational { + min-width: $sidebar-width; + + .columns-area__panels__pane__inner { + width: $sidebar-width; + } + + .column-link span { + display: none; + } + + .list-panel { + display: none; + } + } +} + +.explore__search-header { + display: none; +} + +@media screen and (max-width: $no-gap-breakpoint - 1px) { + .columns-area__panels__pane--compositional { + display: none; + } + + .explore__search-header { + display: flex; + } +} diff --git a/app/javascript/flavours/blobfox/styles/components/status.scss b/app/javascript/flavours/blobfox/styles/components/status.scss new file mode 100644 index 00000000000000..cb7d72e5ccf42a --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/components/status.scss @@ -0,0 +1,1212 @@ +@keyframes spring-flip-in { + 0% { + transform: rotate(0deg); + } + + 30% { + transform: rotate(-242.4deg); + } + + 60% { + transform: rotate(-158.35deg); + } + + 90% { + transform: rotate(-187.5deg); + } + + 100% { + transform: rotate(-180deg); + } +} + +@keyframes spring-flip-out { + 0% { + transform: rotate(-180deg); + } + + 30% { + transform: rotate(62.4deg); + } + + 60% { + transform: rotate(-21.635deg); + } + + 90% { + transform: rotate(7.5deg); + } + + 100% { + transform: rotate(0deg); + } +} + +.status__content--with-action { + cursor: pointer; +} + +.status__content { + position: relative; + margin: 10px 0; + font-size: 15px; + line-height: 20px; + word-wrap: break-word; + font-weight: 400; + overflow: visible; + padding-top: 5px; + clear: both; + + &:focus { + outline: 0; + } + + .emojione { + width: 20px; + height: 20px; + margin: -3px 0 0; + } + + p, + pre { + margin-bottom: 20px; + white-space: pre-wrap; + unicode-bidi: plaintext; + + &:last-child { + margin-bottom: 0; + } + } + + a { + color: $secondary-text-color; + text-decoration: none; + unicode-bidi: isolate; + + &:hover { + text-decoration: underline; + + .fa { + color: lighten($dark-text-color, 7%); + } + } + + &.mention { + &:hover { + text-decoration: none; + + span { + text-decoration: underline; + } + } + } + + .fa { + color: $dark-text-color; + } + } + + .status__content__spoiler { + display: none; + + &.status__content__spoiler--visible { + display: block; + } + } + + a.unhandled-link { + color: $highlight-text-color; + + .link-origin-tag { + color: $gold-star; + font-size: 0.8em; + } + } + + .status__content__spoiler-link { + background: lighten($ui-base-color, 30%); + + &:hover, + &:focus { + background: lighten($ui-base-color, 33%); + text-decoration: none; + } + } +} + +.translate-button { + margin-top: 16px; + font-size: 15px; + line-height: 20px; + display: flex; + justify-content: space-between; + color: $dark-text-color; +} + +.status__content__spoiler-link { + display: inline-block; + border-radius: 2px; + background: lighten($ui-base-color, 30%); + border: 0; + color: $inverted-text-color; + font-weight: 700; + font-size: 11px; + padding: 0 5px; + text-transform: uppercase; + line-height: inherit; + cursor: pointer; + vertical-align: top; + + &:hover { + background: lighten($ui-base-color, 33%); + text-decoration: none; + } + + .status__content__spoiler-icon { + display: inline-block; + margin-inline-start: 5px; + border-inline-start: 1px solid currentColor; + padding: 0; + padding-inline-start: 4px; + font-size: 16px; + vertical-align: -2px; + } +} + +.notif-cleaning { + .status, + .notification { + padding-inline-end: ($dismiss-overlay-width + 0.5rem); + } +} + +.status__wrapper--filtered { + color: $dark-text-color; + border: 0; + font-size: inherit; + text-align: center; + line-height: inherit; + margin: 0; + padding: 15px; + box-sizing: border-box; + width: 100%; + clear: both; + border-bottom: 1px solid lighten($ui-base-color, 8%); +} + +.status__prepend-icon-wrapper { + inset-inline-start: -26px; + position: absolute; +} + +.notification-follow, +.notification-follow-request { + position: relative; + + // same like Status + border-bottom: 1px solid lighten($ui-base-color, 8%); + + .account { + border-bottom: 0 none; + } +} + +.focusable { + &:focus { + outline: 0; + background: lighten($ui-base-color, 4%); + + &.status.status-direct { + background: mix(lighten($ui-base-color, 4%), $ui-highlight-color, 95%); + + &.muted { + background: transparent; + } + } + + .detailed-status, + .detailed-status__action-bar { + background: lighten($ui-base-color, 8%); + } + } +} + +.status { + padding: 10px 14px; + position: relative; + height: auto; + border-bottom: 1px solid lighten($ui-base-color, 8%); + cursor: auto; + + @supports (-ms-overflow-style: -ms-autohiding-scrollbar) { + // Add margin to avoid Edge auto-hiding scrollbar appearing over content. + // On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px. + padding-inline-end: 28px; // 12px + 16px + } + + @keyframes fade { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } + } + + opacity: 1; + animation: fade 150ms linear; + + .video-player, + .audio-player { + margin-top: 8px; + } + + &.status-direct { + background: mix($ui-base-color, $ui-highlight-color, 95%); + border-bottom-color: lighten($ui-base-color, 12%); + } + + &.light { + .status__relative-time { + color: $lighter-text-color; + } + + .status__display-name { + color: $inverted-text-color; + } + + .display-name { + color: $light-text-color; + + strong { + color: $inverted-text-color; + } + } + + .status__content { + color: $inverted-text-color; + + a { + color: $highlight-text-color; + } + + a.status__content__spoiler-link { + color: $primary-text-color; + background: $ui-primary-color; + + &:hover { + background: lighten($ui-primary-color, 8%); + } + } + } + } + + &.collapsed { + background-position: center; + background-size: cover; + user-select: none; + + &.has-background::before { + display: block; + position: absolute; + inset-inline-start: 0; + inset-inline-end: 0; + top: 0; + bottom: 0; + background-image: linear-gradient( + to bottom, + rgba($base-shadow-color, 0.75), + rgba($base-shadow-color, 0.65) 24px, + rgba($base-shadow-color, 0.8) + ); + pointer-events: none; + content: ''; + } + + .display-name:hover .display-name__html { + text-decoration: none; + } + + .status__content { + height: 20px; + overflow: hidden; + text-overflow: ellipsis; + padding-top: 0; + + &::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + inset-inline-start: 0; + inset-inline-end: 0; + background: linear-gradient( + rgba($ui-base-color, 0), + rgba($ui-base-color, 1) + ); + pointer-events: none; + } + + a:hover { + text-decoration: none; + } + } + + &:focus > .status__content::after { + background: linear-gradient( + rgba(lighten($ui-base-color, 4%), 0), + rgba(lighten($ui-base-color, 4%), 1) + ); + } + + &.status-direct > .status__content::after { + background: linear-gradient( + rgba(mix($ui-base-color, $ui-highlight-color, 95%), 0), + rgba(mix($ui-base-color, $ui-highlight-color, 95%), 1) + ); + } + + .notification__message { + margin-bottom: 0; + } + + .status__info .notification__message > span { + white-space: nowrap; + } + } + + .notification__message { + margin: -10px 0 10px; + } + + .reactions-bar--empty { + display: none; + } +} + +.notification-favourite { + .status.status-direct { + background: transparent; + + .icon-button.disabled { + color: lighten($action-button-color, 13%); + } + } +} + +.status__relative-time { + display: inline-block; + color: $dark-text-color; + font-size: 14px; + text-align: end; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.status__display-name { + color: $dark-text-color; + overflow: hidden; +} + +.status__info__account .status__display-name { + display: block; + max-width: 100%; +} + +.status__info { + display: flex; + justify-content: space-between; + font-size: 15px; + + > span { + text-overflow: ellipsis; + overflow: hidden; + } + + .notification__message > span { + word-wrap: break-word; + } +} + +.status__info__icons { + display: flex; + align-items: center; + height: 1em; + color: $action-button-color; + + .status__media-icon, + .status__visibility-icon, + .status__reply-icon, + .text-icon { + padding-inline-start: 2px; + padding-inline-end: 2px; + } + + .status__collapse-button.active > .fa-angle-double-up { + transform: rotate(-180deg); + } +} + +.no-reduce-motion .status__collapse-button { + &.activate { + & > .fa-angle-double-up { + animation: spring-flip-in 1s linear; + } + } + + &.deactivate { + & > .fa-angle-double-up { + animation: spring-flip-out 1s linear; + } + } +} + +.status__info__account { + display: flex; + align-items: center; + justify-content: flex-start; +} + +.status-check-box__status { + display: block; + box-sizing: border-box; + width: 100%; + padding: 0 10px; + + .detailed-status__display-name { + color: lighten($inverted-text-color, 16%); + + span { + display: inline; + } + + &:hover strong { + text-decoration: none; + } + } + + .media-gallery, + .audio-player, + .video-player { + margin-top: 15px; + max-width: 250px; + } + + .status__content { + padding: 0; + white-space: normal; + } + + .media-gallery__item-thumbnail { + cursor: default; + } +} + +.status__prepend { + margin-top: -2px; + margin-bottom: 8px; + margin-inline-start: 58px; + color: $dark-text-color; + font-size: 14px; + position: relative; + + .status__display-name strong { + color: $dark-text-color; + } + + > span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.status__action-bar { + align-items: center; + display: flex; + margin-top: 8px; + + & > .emoji-picker-dropdown > .emoji-button { + padding: 0; + } +} + +.status__action-bar-button { + margin-inline-end: 18px; + + &.icon-button--with-counter { + margin-inline-end: 14px; + } + + .fa-plus { + padding-top: 1px; + } +} + +.status__action-bar-dropdown { + height: 23.15px; + width: 23.15px; +} + +.status__action-bar-spacer { + flex-grow: 1; +} + +.detailed-status__action-bar-dropdown { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.detailed-status { + background: lighten($ui-base-color, 4%); + padding: 14px 10px; + border-top: 1px solid lighten($ui-base-color, 8%); + + &--flex { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-start; + + .status__content, + .detailed-status__meta { + flex: 100%; + } + } + + .status__content { + font-size: 19px; + line-height: 24px; + + .emojione { + width: 24px; + height: 24px; + margin: -1px 0 0; + } + } + + .video-player, + .audio-player { + margin-top: 8px; + } +} + +.detailed-status__meta { + margin-top: 15px; + color: $dark-text-color; + font-size: 14px; + line-height: 18px; +} + +.detailed-status__action-bar { + background: lighten($ui-base-color, 4%); + border-top: 1px solid lighten($ui-base-color, 8%); + border-bottom: 1px solid lighten($ui-base-color, 8%); + display: flex; + flex-direction: row; + padding: 10px 0; + + .fa-plus { + padding-top: 2px; + } +} + +.detailed-status__link { + color: inherit; + text-decoration: none; + white-space: nowrap; +} + +.detailed-status__favorites, +.detailed-status__reblogs { + display: inline-block; + font-weight: 500; + font-size: 12px; + line-height: 17px; + margin-inline-start: 6px; +} + +.status__display-name, +.status__relative-time, +.detailed-status__display-name, +.detailed-status__datetime, +.detailed-status__application, +.account__display-name { + text-decoration: none; +} + +.status__display-name, +.account__display-name { + .display-name strong { + color: $primary-text-color; + } +} + +.muted { + .emojione { + opacity: 0.5; + } +} + +a.status__display-name, +.reply-indicator__display-name, +.detailed-status__display-name, +.account__display-name { + &:hover .display-name strong { + text-decoration: underline; + } +} + +.account__display-name .display-name strong { + display: block; + overflow: hidden; + text-overflow: ellipsis; +} + +.detailed-status__application, +.detailed-status__datetime { + color: inherit; +} + +.detailed-status__display-name { + color: $secondary-text-color; + display: block; + line-height: 24px; + margin-bottom: 15px; + overflow: hidden; + + strong, + span { + display: block; + text-overflow: ellipsis; + overflow: hidden; + } + + strong { + font-size: 16px; + color: $primary-text-color; + } +} + +.detailed-status__display-avatar { + float: left; + margin-inline-end: 10px; +} + +.status__avatar { + flex: none; + margin-inline-end: 10px; +} + +.muted { + .status__content, + .status__content p, + .status__content a, + .status__content__text { + color: $dark-text-color; + } + + .status__display-name strong { + color: $dark-text-color; + } + + .status__avatar { + opacity: 0.5; + } + + a.status__content__spoiler-link { + background: $ui-base-lighter-color; + color: $inverted-text-color; + + &:hover, + &:focus { + background: lighten($ui-base-color, 29%); + text-decoration: none; + } + } +} + +.status__relative-time, +.detailed-status__datetime { + &:hover { + text-decoration: underline; + } +} + +.status-card { + position: relative; + display: flex; + font-size: 14px; + border: 1px solid lighten($ui-base-color, 8%); + border-radius: 4px; + color: $dark-text-color; + margin-top: 14px; + text-decoration: none; + overflow: hidden; + + &__actions { + bottom: 0; + inset-inline-start: 0; + position: absolute; + inset-inline-end: 0; + top: 0; + display: flex; + justify-content: center; + align-items: center; + + & > div { + background: rgba($base-shadow-color, 0.6); + border-radius: 8px; + padding: 12px 9px; + flex: 0 0 auto; + display: flex; + justify-content: center; + align-items: center; + } + + button, + a { + display: inline; + color: $secondary-text-color; + background: transparent; + border: 0; + padding: 0 8px; + text-decoration: none; + font-size: 18px; + line-height: 18px; + + &:hover, + &:active, + &:focus { + color: $primary-text-color; + } + } + + a { + font-size: 19px; + position: relative; + bottom: -1px; + } + + a .fa, + a:hover .fa { + color: inherit; + } + } +} + +a.status-card { + cursor: pointer; + + &:hover { + background: lighten($ui-base-color, 8%); + } +} + +.status-card-photo { + cursor: zoom-in; + display: block; + text-decoration: none; + width: 100%; + height: auto; + margin: 0; +} + +.status-card-video { + // Firefox has a bug where frameborder=0 iframes add some extra blank space + // see https://bugzilla.mozilla.org/show_bug.cgi?id=155174 + overflow: hidden; + + iframe { + width: 100%; + height: 100%; + } +} + +.status-card__title { + display: block; + font-weight: 500; + margin-bottom: 5px; + color: $darker-text-color; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-decoration: none; +} + +.status-card__content { + flex: 1 1 auto; + overflow: hidden; + padding: 14px; + padding-inline-start: 8px; +} + +.status-card__description { + color: $darker-text-color; + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.status-card__host { + display: block; + margin-top: 5px; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status-card__image { + flex: 0 0 100px; + background: lighten($ui-base-color, 8%); + position: relative; + + & > .fa { + font-size: 21px; + position: absolute; + transform-origin: 50% 50%; + top: 50%; + inset-inline-start: 50%; + transform: translate(-50%, -50%); + } +} + +.status-card.horizontal { + display: block; + + .status-card__image { + width: 100%; + } + + .status-card__image-image, + .status-card__image-preview { + border-radius: 4px 4px 0 0; + } + + .status-card__title { + white-space: inherit; + } +} + +.status-card.compact { + border-color: lighten($ui-base-color, 4%); + + &.interactive { + border: 0; + } + + .status-card__content { + padding: 8px; + padding-top: 10px; + } + + .status-card__title { + white-space: nowrap; + } + + .status-card__image { + flex: 0 0 60px; + } +} + +a.status-card.compact:hover { + background-color: lighten($ui-base-color, 4%); +} + +.status-card__image-image { + border-radius: 4px 0 0 4px; + display: block; + margin: 0; + width: 100%; + height: 100%; + object-fit: cover; + background-size: cover; + background-position: center center; +} + +.status-card__image-preview { + border-radius: 4px 0 0 4px; + display: block; + margin: 0; + width: 100%; + height: 100%; + object-fit: fill; + position: absolute; + top: 0; + inset-inline-start: 0; + z-index: 0; + background: $base-overlay-background; + + &--hidden { + display: none; + } +} + +.attachment-list { + display: flex; + font-size: 14px; + border: 1px solid lighten($ui-base-color, 8%); + border-radius: 4px; + margin-top: 14px; + overflow: hidden; + + &__icon { + flex: 0 0 auto; + color: $dark-text-color; + padding: 8px 18px; + cursor: default; + border-inline-end: 1px solid lighten($ui-base-color, 8%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: 26px; + + .fa { + display: block; + } + } + + &__list { + list-style: none; + padding: 4px 0; + padding-inline-start: 8px; + display: flex; + flex-direction: column; + justify-content: center; + + li { + display: block; + padding: 4px 0; + } + + a { + text-decoration: none; + color: $dark-text-color; + font-weight: 500; + + &:hover { + text-decoration: underline; + } + } + } + + &.compact { + border: 0; + margin-top: 4px; + + .attachment-list__list { + padding: 0; + display: block; + } + + .fa { + color: $dark-text-color; + } + } +} + +.status__wrapper--filtered__button { + display: inline; + color: lighten($ui-highlight-color, 8%); + border: 0; + background: transparent; + padding: 0; + font-size: inherit; + line-height: inherit; + + &:hover, + &:active { + text-decoration: underline; + } +} + +.notification, +.status { + position: relative; + + &.unread { + &::before { + content: ''; + position: absolute; + top: 0; + inset-inline-start: 0; + width: 100%; + height: 100%; + border-inline-start: 4px solid $highlight-text-color; + pointer-events: none; + } + } + + &--in-thread { + border-bottom: 0; + + .status__content, + .status__action-bar, + .reactions-bar { + margin-inline-start: 46px + 10px; + width: calc(100% - (46px + 10px)); + } + } + + &--first-in-thread { + border-top: 1px solid lighten($ui-base-color, 8%); + } + + &__line { + height: 10px - 4px; + border-inline-start: 2px solid lighten($ui-base-color, 8%); + width: 0; + position: absolute; + top: 0; + inset-inline-start: 14px + ((46px - 2px) * 0.5); + + &--full { + top: 0; + height: 100%; + + &::before { + content: ''; + display: block; + position: absolute; + top: 10px - 4px; + height: 46px + 4px + 4px; + width: 2px; + background: $ui-base-color; + inset-inline-start: -2px; + } + } + + &--first { + top: 10px + 46px + 4px; + height: calc(100% - (10px + 46px + 4px)); + + &::before { + display: none; + } + } + } +} + +.picture-in-picture { + position: fixed; + bottom: 20px; + inset-inline-end: 20px; + width: 300px; + + &.left { + inset-inline-end: unset; + inset-inline-start: 20px; + } + + &__footer { + border-radius: 0 0 4px 4px; + background: lighten($ui-base-color, 4%); + padding: 10px; + padding-top: 12px; + display: flex; + justify-content: space-between; + } + + &__header { + border-radius: 4px 4px 0 0; + background: lighten($ui-base-color, 4%); + padding: 10px; + display: flex; + justify-content: space-between; + + &__account { + display: flex; + text-decoration: none; + overflow: hidden; + } + + .account__avatar { + margin-inline-end: 10px; + } + + .display-name { + color: $primary-text-color; + text-decoration: none; + + strong, + span { + display: block; + text-overflow: ellipsis; + overflow: hidden; + } + + span { + color: $darker-text-color; + } + } + } + + .video-player, + .audio-player { + border-radius: 0; + } +} + +.picture-in-picture-placeholder { + box-sizing: border-box; + border: 2px dashed lighten($ui-base-color, 8%); + background: $base-shadow-color; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin-top: 10px; + font-size: 16px; + font-weight: 500; + cursor: pointer; + color: $darker-text-color; + aspect-ratio: 16 / 9; + + i { + display: block; + font-size: 24px; + font-weight: 400; + margin-bottom: 10px; + } + + &:hover, + &:focus, + &:active { + border-color: lighten($ui-base-color, 12%); + } +} + +.hashtag-bar { + margin-top: 16px; + display: flex; + flex-wrap: wrap; + font-size: 14px; + line-height: 18px; + gap: 4px; + color: $darker-text-color; + + a { + display: inline-flex; + color: inherit; + text-decoration: none; + + &:hover span { + text-decoration: underline; + } + } + + .link-button { + color: inherit; + font-size: inherit; + line-height: inherit; + padding: 0; + } +} diff --git a/app/javascript/flavours/blobfox/styles/containers.scss b/app/javascript/flavours/blobfox/styles/containers.scss new file mode 100644 index 00000000000000..4d3d4c546c1ffe --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/containers.scss @@ -0,0 +1,109 @@ +.container-alt { + width: 700px; + margin: 0 auto; + + @media screen and (width <= 740px) { + width: 100%; + margin: 0; + } +} + +.logo-container { + margin: 50px auto; + + h1 { + display: flex; + justify-content: center; + align-items: center; + + .logo { + height: 42px; + margin-inline-end: 10px; + } + + a { + display: flex; + justify-content: center; + align-items: center; + color: $primary-text-color; + text-decoration: none; + outline: 0; + padding: 12px 16px; + line-height: 32px; + font-weight: 500; + font-size: 14px; + } + } +} + +.compose-standalone { + .compose-form { + width: 400px; + margin: 0 auto; + padding: 20px 0; + margin-top: 40px; + box-sizing: border-box; + + @media screen and (width <= 400px) { + width: 100%; + margin-top: 0; + padding: 20px; + } + } +} + +.account-header { + width: 400px; + margin: 0 auto; + display: flex; + font-size: 13px; + line-height: 18px; + box-sizing: border-box; + padding: 20px 0; + margin-top: 40px; + margin-bottom: 10px; + border-bottom: 1px solid $ui-base-color; + + @media screen and (width <= 440px) { + width: 100%; + margin: 0; + padding: 20px; + } + + .avatar { + width: 40px; + height: 40px; + @include avatar-size(40px); + + margin-inline-end: 10px; + + img { + width: 100%; + height: 100%; + display: block; + margin: 0; + border-radius: 4px; + @include avatar-radius; + } + } + + .name { + flex: 1 1 auto; + color: $secondary-text-color; + width: calc(100% - 90px); + + .username { + display: block; + font-weight: 500; + text-overflow: ellipsis; + overflow: hidden; + } + } + + .logout-link { + display: block; + font-size: 32px; + line-height: 40px; + margin-inline-start: 10px; + } +} diff --git a/app/javascript/flavours/blobfox/styles/contrast.scss b/app/javascript/flavours/blobfox/styles/contrast.scss new file mode 100644 index 00000000000000..4de31db9aec577 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/contrast.scss @@ -0,0 +1,3 @@ +@import 'contrast/variables'; +@import 'index'; +@import 'contrast/diff'; diff --git a/app/javascript/flavours/blobfox/styles/contrast/diff.scss b/app/javascript/flavours/blobfox/styles/contrast/diff.scss new file mode 100644 index 00000000000000..1c2386f02d2df9 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/contrast/diff.scss @@ -0,0 +1,79 @@ +.compose-form { + .compose-form__modifiers { + .compose-form__upload { + &-description { + input { + &::placeholder { + opacity: 1; + } + } + } + } + } +} + +.status__content a, +.link-footer a, +.reply-indicator__content a, +.status__content__read-more-button, +.status__content__translate-button { + text-decoration: underline; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + + &.mention { + text-decoration: none; + + span { + text-decoration: underline; + } + + &:hover, + &:focus, + &:active { + span { + text-decoration: none; + } + } + } +} + +.status__content a { + color: $highlight-text-color; +} + +.nothing-here { + color: $darker-text-color; +} + +.compose-form__poll-wrapper .button.button-secondary, +.compose-form .autosuggest-textarea__textarea::placeholder, +.compose-form .spoiler-input__input::placeholder, +.report-dialog-modal__textarea::placeholder, +.language-dropdown__dropdown__results__item__common-name, +.compose-form .icon-button { + color: $inverted-text-color; +} + +.text-icon-button.active { + color: $ui-highlight-color; +} + +.language-dropdown__dropdown__results__item.active { + background: $ui-highlight-color; + font-weight: 500; +} + +.link-button:disabled { + cursor: not-allowed; + + &:hover, + &:focus, + &:active { + text-decoration: none !important; + } +} diff --git a/app/javascript/flavours/blobfox/styles/contrast/variables.scss b/app/javascript/flavours/blobfox/styles/contrast/variables.scss new file mode 100644 index 00000000000000..e38d24b271cf8e --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/contrast/variables.scss @@ -0,0 +1,22 @@ +// Dependent colors +$black: #000000; + +$classic-base-color: #282c37; +$classic-primary-color: #9baec8; +$classic-secondary-color: #d9e1e8; +$classic-highlight-color: #6364ff; + +$ui-base-color: $classic-base-color !default; +$ui-primary-color: $classic-primary-color !default; +$ui-secondary-color: $classic-secondary-color !default; +$ui-highlight-color: $classic-highlight-color !default; + +$darker-text-color: lighten($ui-primary-color, 20%) !default; +$dark-text-color: lighten($ui-primary-color, 12%) !default; +$secondary-text-color: lighten($ui-secondary-color, 6%) !default; +$highlight-text-color: lighten($ui-highlight-color, 10%) !default; +$action-button-color: lighten($ui-base-color, 50%); + +$inverted-text-color: $black !default; +$lighter-text-color: darken($ui-base-color, 6%) !default; +$light-text-color: darken($ui-primary-color, 40%) !default; diff --git a/app/javascript/flavours/blobfox/styles/dashboard.scss b/app/javascript/flavours/blobfox/styles/dashboard.scss new file mode 100644 index 00000000000000..36a7f44253f423 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/dashboard.scss @@ -0,0 +1,123 @@ +.dashboard__counters { + display: flex; + flex-wrap: wrap; + margin: 0 -5px; + margin-bottom: 20px; + + & > div { + box-sizing: border-box; + flex: 0 0 33.333%; + padding: 0 5px; + margin-bottom: 10px; + + & > div, + & > a { + padding: 20px; + background: lighten($ui-base-color, 4%); + border-radius: 4px; + box-sizing: border-box; + height: 100%; + } + + & > a { + text-decoration: none; + color: inherit; + display: block; + + &:hover, + &:focus, + &:active { + background: lighten($ui-base-color, 8%); + } + } + } + + &__num, + &__text { + text-align: center; + font-weight: 500; + font-size: 24px; + color: $primary-text-color; + margin-bottom: 20px; + line-height: 30px; + } + + &__text { + font-size: 18px; + } + + &__label { + font-size: 14px; + color: $darker-text-color; + text-align: center; + font-weight: 500; + } +} + +.dashboard { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); + grid-gap: 10px; + + @media screen and (width <= 1350px) { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } + + &__item { + &--span-double-column { + grid-column: span 2; + } + + &--span-double-row { + grid-row: span 2; + } + + h4 { + padding-top: 20px; + } + } + + &__quick-access { + display: flex; + align-items: baseline; + border-radius: 4px; + background: $ui-button-background-color; + color: $primary-text-color; + transition: all 100ms ease-in; + font-size: 14px; + padding: 0 16px; + line-height: 36px; + height: 36px; + text-decoration: none; + margin-bottom: 4px; + + &:active, + &:focus, + &:hover { + background-color: $ui-button-focus-background-color; + transition: all 200ms ease-out; + } + + &.positive { + background: lighten($ui-base-color, 4%); + color: $valid-value-color; + } + + &.negative { + background: lighten($ui-base-color, 4%); + color: $error-value-color; + } + + span { + flex: 1 1 auto; + } + + .fa { + flex: 0 0 auto; + } + + strong { + font-weight: 700; + } + } +} diff --git a/app/javascript/flavours/blobfox/styles/forms.scss b/app/javascript/flavours/blobfox/styles/forms.scss new file mode 100644 index 00000000000000..614f7fc6a08df2 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/forms.scss @@ -0,0 +1,1224 @@ +$no-columns-breakpoint: 600px; + +code { + font-family: $font-monospace, monospace; + font-weight: 400; +} + +.form-container { + max-width: 450px; + padding: 20px; + padding-bottom: 50px; + margin: 50px auto; +} + +.indicator-icon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + color: $primary-text-color; + + &.success { + background: $success-green; + } + + &.failure { + background: $error-red; + } +} + +.simple_form { + &.hidden { + display: none; + } + + .input { + margin-bottom: 15px; + overflow: hidden; + + &.hidden { + margin: 0; + } + + &.radio_buttons { + .radio { + margin-bottom: 15px; + + &:last-child { + margin-bottom: 0; + } + } + + .radio > label { + position: relative; + padding-inline-start: 28px; + + input { + position: absolute; + top: -2px; + inset-inline-start: 0; + } + } + } + + &.boolean { + position: relative; + margin-bottom: 0; + + .label_input > label { + font-family: inherit; + font-size: 14px; + padding-top: 5px; + color: $primary-text-color; + display: block; + width: auto; + } + + .label_input, + .hint { + padding-inline-start: 28px; + } + + .label_input__wrapper { + position: static; + } + + label.checkbox { + position: absolute; + top: 2px; + inset-inline-start: 0; + } + + label a { + color: $highlight-text-color; + text-decoration: underline; + + &:hover, + &:active, + &:focus { + text-decoration: none; + } + } + + .overridden, + .recommended, + .not_recommended, + .blobfox_only { + position: absolute; + margin: 0 4px; + margin-top: -2px; + } + } + } + + .row { + display: flex; + margin: 0 -5px; + + .input { + box-sizing: border-box; + flex: 1 1 auto; + width: 50%; + padding: 0 5px; + } + } + + .title { + font-size: 28px; + line-height: 33px; + font-weight: 700; + margin-bottom: 15px; + } + + .lead { + font-size: 17px; + line-height: 22px; + color: $secondary-text-color; + margin-bottom: 30px; + + &.invited-by { + margin-bottom: 15px; + } + + a { + color: $highlight-text-color; + } + } + + .rules-list { + font-size: 17px; + line-height: 22px; + margin-bottom: 30px; + } + + .hint { + color: $darker-text-color; + + a { + color: $highlight-text-color; + } + + code { + border-radius: 3px; + padding: 0.2em 0.4em; + background: darken($ui-base-color, 12%); + } + + li { + list-style: disc; + margin-inline-start: 18px; + } + } + + ul.hint { + margin-bottom: 15px; + } + + span.hint { + display: block; + font-size: 12px; + margin-top: 4px; + } + + p.hint { + margin-bottom: 15px; + color: $darker-text-color; + + &.subtle-hint { + text-align: center; + font-size: 12px; + line-height: 18px; + margin-top: 15px; + margin-bottom: 0; + } + } + + .authentication-hint { + margin-bottom: 25px; + } + + .card { + margin-bottom: 15px; + } + + strong { + font-weight: 500; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + + .input.with_floating_label { + .label_input { + display: flex; + + & > label { + font-family: inherit; + font-size: 14px; + color: $primary-text-color; + font-weight: 500; + min-width: 150px; + flex: 0 0 auto; + } + + input, + select { + flex: 1 1 auto; + } + } + + &.select .hint { + margin-top: 6px; + margin-inline-start: 150px; + } + } + + .input.with_label { + .label_input > label { + font-family: inherit; + font-size: 14px; + color: $primary-text-color; + display: block; + margin-bottom: 8px; + word-wrap: break-word; + font-weight: 500; + } + + .hint { + margin-top: 6px; + } + + ul { + flex: 390px; + } + } + + .input.with_block_label { + max-width: none; + + & > label { + font-family: inherit; + font-size: 14px; + color: $primary-text-color; + display: block; + font-weight: 500; + padding-top: 5px; + } + + .hint { + margin-bottom: 15px; + } + + ul { + columns: 2; + } + } + + .input.with_block_label.user_role_permissions_as_keys ul { + columns: unset; + } + + .input.datetime .label_input select { + display: inline-block; + width: auto; + flex: 0; + } + + .required abbr { + text-decoration: none; + color: lighten($error-value-color, 12%); + } + + .fields-group { + margin-bottom: 25px; + + .input:last-child { + margin-bottom: 0; + } + + &__thumbnail { + display: block; + margin: 0; + margin-bottom: 10px; + max-width: 100%; + height: auto; + border-radius: 4px; + background: url('images/void.png'); + + &[src$='missing.png'] { + visibility: hidden; + } + + &:last-child { + margin-bottom: 0; + } + + &#account_avatar-preview { + width: 90px; + height: 90px; + object-fit: cover; + } + } + } + + .fields-row { + display: flex; + margin: 0 -10px; + padding-top: 5px; + margin-bottom: 25px; + + .input { + max-width: none; + } + + &__column { + box-sizing: border-box; + padding: 0 10px; + flex: 1 1 auto; + min-height: 1px; + + &-6 { + max-width: 50%; + } + + .actions { + margin-top: 27px; + } + } + + .fields-group:last-child, + .fields-row__column.fields-group { + margin-bottom: 0; + } + + @media screen and (max-width: $no-columns-breakpoint) { + display: block; + margin-bottom: 0; + + &__column { + max-width: none; + } + + .fields-group:last-child, + .fields-row__column.fields-group, + .fields-row__column { + margin-bottom: 25px; + } + } + + .fields-group.invited-by { + margin-bottom: 30px; + + .hint { + text-align: center; + } + } + } + + .input.radio_buttons .radio label { + margin-bottom: 5px; + font-family: inherit; + font-size: 14px; + color: $primary-text-color; + display: block; + width: auto; + } + + .check_boxes { + .checkbox { + label { + font-family: inherit; + font-size: 14px; + color: $primary-text-color; + display: inline-block; + width: auto; + position: relative; + padding-top: 5px; + padding-inline-start: 25px; + flex: 1 1 auto; + } + + input[type='checkbox'] { + position: absolute; + inset-inline-start: 0; + top: 5px; + margin: 0; + } + } + } + + .input.static .label_input__wrapper { + font-size: 16px; + padding: 10px; + border: 1px solid $dark-text-color; + border-radius: 4px; + } + + input[type='text'], + input[type='number'], + input[type='email'], + input[type='password'], + input[type='url'], + input[type='datetime-local'], + textarea { + box-sizing: border-box; + font-size: 16px; + color: $primary-text-color; + display: block; + width: 100%; + outline: 0; + font-family: inherit; + resize: vertical; + background: darken($ui-base-color, 10%); + border: 1px solid darken($ui-base-color, 14%); + border-radius: 4px; + padding: 10px; + + &::placeholder { + color: lighten($darker-text-color, 4%); + } + + &:invalid { + box-shadow: none; + } + + &:required:valid { + border-color: $valid-value-color; + } + + &:hover { + border-color: darken($ui-base-color, 20%); + } + + &:active, + &:focus { + border-color: $highlight-text-color; + background: darken($ui-base-color, 8%); + } + } + + input[type='text'], + input[type='number'], + input[type='email'], + input[type='password'], + input[type='datetime-local'] { + &:focus:invalid:not(:placeholder-shown), + &:required:invalid:not(:placeholder-shown) { + border-color: lighten($error-red, 12%); + } + } + + .input.field_with_errors { + label { + color: lighten($error-red, 12%); + } + + input[type='text'], + input[type='number'], + input[type='email'], + input[type='password'], + input[type='datetime-local'], + textarea, + select { + border-color: lighten($error-red, 12%); + } + + .error { + display: block; + font-weight: 500; + color: lighten($error-red, 12%); + margin-top: 4px; + } + } + + .input.disabled { + opacity: 0.5; + } + + .actions { + margin-top: 30px; + display: flex; + + &.actions--top { + margin-top: 0; + margin-bottom: 30px; + } + } + + .stacked-actions { + margin-top: 30px; + margin-bottom: 15px; + } + + button, + .button, + .block-button { + display: block; + width: 100%; + border: 0; + border-radius: 4px; + background: $ui-button-background-color; + color: $ui-button-color; + font-size: 18px; + line-height: inherit; + height: auto; + padding: 10px; + text-decoration: none; + text-transform: uppercase; + text-align: center; + box-sizing: border-box; + cursor: pointer; + font-weight: 500; + outline: 0; + margin-bottom: 10px; + margin-inline-end: 10px; + + &:last-child { + margin-inline-end: 0; + } + + &:active, + &:focus, + &:hover { + background-color: $ui-button-focus-background-color; + } + + &:disabled:hover { + background-color: $ui-primary-color; + } + + &.negative { + background: $ui-button-destructive-background-color; + + &:hover, + &:active, + &:focus { + background-color: $ui-button-destructive-focus-background-color; + } + } + } + + .button.button-tertiary { + padding: 9px; + + &:hover, + &:focus, + &:active { + padding: 10px; + } + } + + select { + appearance: none; + box-sizing: border-box; + font-size: 16px; + color: $primary-text-color; + display: block; + width: 100%; + outline: 0; + font-family: inherit; + resize: vertical; + background: darken($ui-base-color, 10%) + url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 12%))}'/></svg>") + no-repeat right 8px center / auto 16px; + border: 1px solid darken($ui-base-color, 14%); + border-radius: 4px; + padding-inline-start: 10px; + padding-inline-end: 30px; + height: 41px; + } + + h4 { + margin-bottom: 15px !important; + } + + .label_input { + &__wrapper { + position: relative; + } + + &__append { + position: absolute; + inset-inline-end: 3px; + top: 1px; + padding: 10px; + padding-bottom: 9px; + font-size: 16px; + color: $dark-text-color; + font-family: inherit; + pointer-events: none; + cursor: default; + max-width: 140px; + white-space: nowrap; + overflow: hidden; + + &::after { + content: ''; + display: block; + position: absolute; + top: 0; + inset-inline-end: 0; + bottom: 1px; + width: 5px; + background-image: linear-gradient( + to right, + rgba(darken($ui-base-color, 10%), 0), + darken($ui-base-color, 10%) + ); + } + } + } +} + +.block-icon { + display: block; + margin: 0 auto; + margin-bottom: 10px; + font-size: 24px; +} + +.flash-message { + background: lighten($ui-base-color, 8%); + color: $darker-text-color; + border-radius: 4px; + padding: 15px 10px; + margin-bottom: 30px; + text-align: center; + + &.notice { + border: 1px solid rgba($valid-value-color, 0.5); + background: rgba($valid-value-color, 0.25); + color: $valid-value-color; + } + + &.warning { + border: 1px solid rgba($gold-star, 0.5); + background: rgba($gold-star, 0.25); + color: $gold-star; + } + + &.alert { + border: 1px solid rgba($error-value-color, 0.5); + background: rgba($error-value-color, 0.1); + color: $error-value-color; + } + + &.hidden { + display: none; + } + + a { + display: inline-block; + color: $darker-text-color; + text-decoration: none; + + &:hover { + color: $primary-text-color; + text-decoration: underline; + } + } + + &.warning a { + font-weight: 700; + color: inherit; + text-decoration: underline; + + &:hover, + &:focus, + &:active { + text-decoration: none; + color: inherit; + } + } + + p { + margin-bottom: 15px; + } + + .oauth-code { + outline: 0; + box-sizing: border-box; + display: block; + width: 100%; + border: 0; + padding: 10px; + font-family: $font-monospace, monospace; + background: $ui-base-color; + color: $primary-text-color; + font-size: 14px; + margin: 0; + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + + &:focus { + background: lighten($ui-base-color, 4%); + } + } + + strong { + font-weight: 500; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + + @media screen and (width <= 740px) and (width >= 441px) { + margin-top: 40px; + } + + &.translation-prompt { + text-align: unset; + color: unset; + + a { + text-decoration: underline; + } + } +} + +.flash-message-stack { + margin-bottom: 30px; + + .flash-message { + border-radius: 0; + margin-bottom: 0; + border-top-width: 0; + + &:first-child { + border-radius: 4px 4px 0 0; + border-top-width: 1px; + } + + &:last-child { + border-radius: 0 0 4px 4px; + + &:first-child { + border-radius: 4px; + } + } + } +} + +.form-footer { + margin-top: 30px; + text-align: center; + + a { + color: $darker-text-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.quick-nav { + list-style: none; + margin-bottom: 25px; + font-size: 14px; + + li { + display: inline-block; + margin-inline-end: 10px; + } + + a { + color: $highlight-text-color; + text-transform: uppercase; + text-decoration: none; + font-weight: 700; + + &:hover, + &:focus, + &:active { + color: lighten($highlight-text-color, 8%); + } + } +} + +.oauth-prompt, +.follow-prompt { + margin-bottom: 30px; + color: $darker-text-color; + + h2 { + font-size: 16px; + margin-bottom: 30px; + text-align: center; + } + + strong { + color: $secondary-text-color; + font-weight: 500; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } +} + +.oauth-prompt { + h3 { + color: $ui-secondary-color; + font-size: 17px; + line-height: 22px; + font-weight: 500; + margin-bottom: 30px; + } + + p { + font-size: 14px; + line-height: 18px; + margin-bottom: 30px; + } + + .permissions-list { + border: 1px solid $ui-base-color; + border-radius: 4px; + background: darken($ui-base-color, 4%); + margin-bottom: 30px; + } + + .actions { + margin: 0 -10px; + display: flex; + + form { + box-sizing: border-box; + padding: 0 10px; + flex: 1 1 auto; + min-height: 1px; + width: 50%; + } + } +} + +.qr-wrapper { + display: flex; + flex-wrap: wrap; + align-items: flex-start; +} + +.qr-code { + flex: 0 0 auto; + background: $simple-background-color; + padding: 4px; + margin-inline-end: 10px; + margin-bottom: 20px; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + display: inline-block; + + svg { + display: block; + margin: 0; + } +} + +.qr-alternative { + margin-bottom: 20px; + color: $secondary-text-color; + flex: 150px; + + samp { + display: block; + font-size: 14px; + } +} + +.simple_form { + .warning { + box-sizing: border-box; + background: rgba($error-value-color, 0.5); + color: $primary-text-color; + text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3); + box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4); + border-radius: 4px; + padding: 10px; + margin-bottom: 15px; + + a { + color: $primary-text-color; + text-decoration: underline; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + } + + strong { + font-weight: 600; + display: block; + margin-bottom: 5px; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + + .fa { + font-weight: 400; + } + } + } +} + +.action-pagination { + display: flex; + flex-wrap: wrap; + align-items: center; + + .actions, + .pagination { + flex: 1 1 auto; + } + + .actions { + padding: 30px 0; + padding-inline-end: 20px; + flex: 0 0 auto; + } +} + +.post-follow-actions { + text-align: center; + color: $darker-text-color; + + div { + margin-bottom: 4px; + } +} + +.alternative-login { + margin-top: 20px; + margin-bottom: 20px; + + h4 { + font-size: 16px; + color: $primary-text-color; + text-align: center; + margin-bottom: 20px; + border: 0; + padding: 0; + } + + .button { + display: block; + } +} + +.scope-danger { + color: $warning-red; +} + +.form_admin_settings_site_short_description, +.form_admin_settings_site_description, +.form_admin_settings_site_extended_description, +.form_admin_settings_site_terms, +.form_admin_settings_custom_css, +.form_admin_settings_closed_registrations_message { + textarea { + font-family: $font-monospace, monospace; + } +} + +.input-copy { + background: darken($ui-base-color, 10%); + border: 1px solid darken($ui-base-color, 14%); + border-radius: 4px; + display: flex; + align-items: center; + padding-inline-end: 4px; + position: relative; + top: 1px; + transition: border-color 300ms linear; + + &__wrapper { + flex: 1 1 auto; + } + + input[type='text'] { + background: transparent; + border: 0; + padding: 10px; + font-size: 14px; + font-family: $font-monospace, monospace; + } + + button { + flex: 0 0 auto; + margin: 4px; + text-transform: none; + font-weight: 400; + font-size: 14px; + padding: 7px 18px; + padding-bottom: 6px; + width: auto; + transition: background 300ms linear; + } + + &.copied { + border-color: $valid-value-color; + transition: none; + + button { + background: $valid-value-color; + transition: none; + } + } +} + +.input.user_confirm_password, +.input.user_website { + &:not(.field_with_errors) { + display: none; + } +} + +.simple_form .h-captcha { + display: flex; + justify-content: center; + margin-bottom: 30px; +} + +.permissions-list { + &__item { + padding: 15px; + color: $ui-secondary-color; + border-bottom: 1px solid lighten($ui-base-color, 4%); + display: flex; + align-items: center; + + &__text { + flex: 1 1 auto; + + &__title { + font-weight: 500; + } + + &__type { + color: $darker-text-color; + } + } + + &__icon { + flex: 0 0 auto; + font-size: 18px; + width: 30px; + color: $valid-value-color; + display: flex; + align-items: center; + } + + &:last-child { + border-bottom: 0; + } + } +} + +// Only remove padding when listing applications, to prevent styling issues on +// the Authorization page. +.applications-list { + .permissions-list__item:last-child { + padding-bottom: 0; + } +} + +.keywords-table { + thead { + th { + white-space: nowrap; + } + + th:first-child { + width: 100%; + } + } + + tfoot { + td { + border: 0; + } + } + + .input.string { + margin-bottom: 0; + } + + .label_input__wrapper { + margin-top: 10px; + } + + .table-action-link { + margin-top: 10px; + white-space: nowrap; + } +} + +.progress-tracker { + display: flex; + align-items: center; + padding-bottom: 30px; + margin-bottom: 30px; + + li { + flex: 0 0 auto; + position: relative; + } + + .separator { + height: 2px; + background: $ui-base-lighter-color; + flex: 1 1 auto; + + &.completed { + background: $highlight-text-color; + } + } + + .circle { + box-sizing: border-box; + position: relative; + width: 30px; + height: 30px; + border-radius: 50%; + border: 2px solid $ui-base-lighter-color; + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + + svg { + width: 16px; + } + } + + .label { + position: absolute; + font-size: 14px; + font-weight: 500; + color: $secondary-text-color; + padding-top: 10px; + text-align: center; + width: 100px; + left: 50%; + transform: translateX(-50%); + } + + li:first-child .label { + inset-inline-start: 0; + inset-inline-end: auto; + text-align: start; + transform: none; + } + + li:last-child .label { + inset-inline-start: auto; + inset-inline-end: 0; + text-align: end; + transform: none; + } + + .active .circle { + border-color: $highlight-text-color; + + &::before { + content: ''; + width: 10px; + height: 10px; + border-radius: 50%; + background: $highlight-text-color; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + } + } + + .completed .circle { + border-color: $highlight-text-color; + background: $highlight-text-color; + } +} diff --git a/app/javascript/flavours/blobfox/styles/index.scss b/app/javascript/flavours/blobfox/styles/index.scss new file mode 100644 index 00000000000000..1cb913c8b832ec --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/index.scss @@ -0,0 +1,24 @@ +@import 'mixins'; +@import 'variables'; +@import 'styles/fonts/roboto'; +@import 'styles/fonts/roboto-mono'; + +@import 'reset'; +@import 'basics'; +@import 'branding'; +@import 'containers'; +@import 'lists'; +@import 'modal'; +@import 'widgets'; +@import 'forms'; +@import 'accounts'; +@import 'statuses'; +@import 'components/index'; +@import 'polls'; +@import 'about'; +@import 'tables'; +@import 'admin'; +@import 'accessibility'; +@import 'rtl'; +@import 'dashboard'; +@import 'rich_text'; diff --git a/app/javascript/flavours/blobfox/styles/lists.scss b/app/javascript/flavours/blobfox/styles/lists.scss new file mode 100644 index 00000000000000..6019cd800283d7 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/lists.scss @@ -0,0 +1,19 @@ +.no-list { + list-style: none; + + li { + display: inline-block; + margin: 0 5px; + } +} + +.recovery-codes { + list-style: none; + margin: 0 auto; + + li { + font-size: 125%; + line-height: 1.5; + letter-spacing: 1px; + } +} diff --git a/app/javascript/flavours/blobfox/styles/mastodon-light.scss b/app/javascript/flavours/blobfox/styles/mastodon-light.scss new file mode 100644 index 00000000000000..8fc132651bdf67 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/mastodon-light.scss @@ -0,0 +1,3 @@ +@import 'mastodon-light/variables'; +@import 'index'; +@import 'mastodon-light/diff'; diff --git a/app/javascript/flavours/blobfox/styles/mastodon-light/diff.scss b/app/javascript/flavours/blobfox/styles/mastodon-light/diff.scss new file mode 100644 index 00000000000000..30425afba022ce --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/mastodon-light/diff.scss @@ -0,0 +1,743 @@ +// Notes! +// Sass color functions, "darken" and "lighten" are automatically replaced. + +html { + scrollbar-color: $ui-base-color rgba($ui-base-color, 0.25); +} + +.simple_form .button.button-tertiary { + color: $highlight-text-color; +} + +.status-card__actions button, +.status-card__actions a { + color: rgba($white, 0.8); + + &:hover, + &:active, + &:focus { + color: $white; + } +} + +// Change default background colors of columns +.column > .scrollable, +.getting-started, +.column-inline-form, +.regeneration-indicator { + background: $white; + border: 1px solid lighten($ui-base-color, 8%); + border-top: 0; +} + +.error-column { + border: 1px solid lighten($ui-base-color, 8%); +} + +.column > .scrollable.about { + border-top: 1px solid lighten($ui-base-color, 8%); +} + +.about__meta, +.about__section__title, +.interaction-modal { + background: $white; + border: 1px solid lighten($ui-base-color, 8%); +} + +.rules-list li::before { + background: $ui-highlight-color; +} + +.directory__card__img { + background: lighten($ui-base-color, 12%); +} + +.filter-form { + background: $white; + border-bottom: 1px solid lighten($ui-base-color, 8%); +} + +.column-back-button, +.column-header { + background: $white; + border: 1px solid lighten($ui-base-color, 8%); + + @media screen and (max-width: $no-gap-breakpoint) { + border-top: 0; + } + + &--slim-button { + top: -50px; + right: 0; + } +} + +.column-header__back-button, +.column-header__button, +.column-header__button.active, +.account__header { + background: $white; +} + +.column-header__button.active { + color: $ui-highlight-color; + + &:hover, + &:active, + &:focus { + color: $ui-highlight-color; + background: $white; + } +} + +.account__header__bar .avatar .account__avatar { + border-color: $white; +} + +.getting-started__footer a { + color: $ui-secondary-color; + text-decoration: underline; +} + +.confirmation-modal__secondary-button, +.confirmation-modal__cancel-button, +.mute-modal__cancel-button, +.block-modal__cancel-button { + color: lighten($ui-base-color, 26%); + + &:hover, + &:focus, + &:active { + color: $primary-text-color; + } +} + +.column-subheading { + background: darken($ui-base-color, 4%); + border-bottom: 1px solid lighten($ui-base-color, 8%); +} + +.getting-started, +.scrollable { + .column-link { + background: $white; + border-bottom: 1px solid lighten($ui-base-color, 8%); + + &:hover, + &:active, + &:focus { + background: $ui-base-color; + } + } +} + +.getting-started .navigation-bar { + border-top: 1px solid lighten($ui-base-color, 8%); + border-bottom: 1px solid lighten($ui-base-color, 8%); + + @media screen and (max-width: $no-gap-breakpoint) { + border-top: 0; + } +} + +.compose-form__autosuggest-wrapper, +.poll__option input[type='text'], +.compose-form .spoiler-input__input, +.compose-form__poll-wrapper select, +.search__input, +.setting-text, +.report-dialog-modal__textarea, +.audio-player { + border: 1px solid lighten($ui-base-color, 8%); +} + +.report-dialog-modal .dialog-option .poll__input { + color: $white; +} + +.search__input { + @media screen and (max-width: $no-gap-breakpoint) { + border-top: 0; + border-bottom: 0; + } +} + +.list-editor .search .search__input { + border-top: 0; + border-bottom: 0; +} + +.compose-form__poll-wrapper select { + background: $simple-background-color + url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 8%))}'/></svg>") + no-repeat right 8px center / auto 16px; +} + +.compose-form__poll-wrapper, +.compose-form__poll-wrapper .poll__footer { + border-top-color: lighten($ui-base-color, 8%); +} + +.notification__filter-bar { + border: 1px solid lighten($ui-base-color, 8%); + border-top: 0; +} + +.compose-form .compose-form__buttons-wrapper { + background: $ui-base-color; + border: 1px solid lighten($ui-base-color, 8%); + border-top: 0; +} + +.drawer__header, +.drawer__inner { + background: $white; + border: 1px solid lighten($ui-base-color, 8%); +} + +.drawer__inner__mastodon { + background: $white + url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color($ui-base-color)}"/></svg>') + no-repeat bottom / 100% auto; +} + +// Change the colors used in compose-form +.compose-form { + .compose-form__modifiers { + .compose-form__upload__actions .icon-button, + .compose-form__upload__warning .icon-button { + color: lighten($white, 7%); + + &:active, + &:focus, + &:hover { + color: $white; + } + } + } + + .compose-form__buttons-wrapper { + background: darken($ui-base-color, 6%); + } + + .autosuggest-textarea__suggestions { + background: darken($ui-base-color, 6%); + } + + .autosuggest-textarea__suggestions__item { + &:hover, + &:focus, + &:active, + &.selected { + background: lighten($ui-base-color, 4%); + } + } +} + +.emoji-mart-bar { + border-color: lighten($ui-base-color, 4%); + + &:first-child { + background: darken($ui-base-color, 6%); + } +} + +.emoji-mart-search input { + background: rgba($ui-base-color, 0.3); + border-color: $ui-base-color; +} + +.upload-progress__backdrop { + background: $ui-base-color; +} + +// Change the background colors of statuses +.focusable:focus { + background: lighten($white, 4%); +} + +.detailed-status, +.detailed-status__action-bar { + background: $white; +} + +// Change the background colors of status__content__spoiler-link +.reply-indicator__content .status__content__spoiler-link, +.status__content .status__content__spoiler-link { + background: $ui-base-color; + + &:hover, + &:focus { + background: lighten($ui-base-color, 4%); + } +} + +// Change the background colors of media and video spoilers +.media-spoiler, +.video-player__spoiler { + background: $ui-base-color; +} + +.privacy-dropdown.active .privacy-dropdown__value.active .icon-button { + color: $white; +} + +.account-gallery__item a { + background-color: $ui-base-color; +} + +// Change the colors used in the dropdown menu +.dropdown-menu { + background: $white; + + &__arrow::before { + background-color: $white; + } + + &__item { + color: $darker-text-color; + + &--dangerous { + color: $error-value-color; + } + + a, + button { + background: $white; + } + } +} + +// Change the text colors on inverted background +.privacy-dropdown__option.active, +.privacy-dropdown__option:hover, +.privacy-dropdown__option.active .privacy-dropdown__option__content, +.privacy-dropdown__option.active .privacy-dropdown__option__content strong, +.privacy-dropdown__option:hover .privacy-dropdown__option__content, +.privacy-dropdown__option:hover .privacy-dropdown__option__content strong, +.dropdown-menu__item:not(.dropdown-menu__item--dangerous) a:active, +.dropdown-menu__item:not(.dropdown-menu__item--dangerous) a:focus, +.dropdown-menu__item:not(.dropdown-menu__item--dangerous) a:hover, +.actions-modal ul li:not(:empty) a.active, +.actions-modal ul li:not(:empty) a.active button, +.actions-modal ul li:not(:empty) a:active, +.actions-modal ul li:not(:empty) a:active button, +.actions-modal ul li:not(:empty) a:focus, +.actions-modal ul li:not(:empty) a:focus button, +.actions-modal ul li:not(:empty) a:hover, +.actions-modal ul li:not(:empty) a:hover button, +.language-dropdown__dropdown__results__item.active, +.admin-wrapper .sidebar ul .simple-navigation-active-leaf a, +.simple_form .block-button, +.simple_form .button, +.simple_form button { + color: $white; +} + +.language-dropdown__dropdown__results__item + .language-dropdown__dropdown__results__item__common-name { + color: lighten($ui-base-color, 8%); +} + +.language-dropdown__dropdown__results__item.active + .language-dropdown__dropdown__results__item__common-name { + color: darken($ui-base-color, 12%); +} + +.dropdown-menu__separator, +.dropdown-menu__item.edited-timestamp__history__item, +.dropdown-menu__container__header, +.compare-history-modal .report-modal__target, +.report-dialog-modal .poll__option.dialog-option { + border-bottom-color: lighten($ui-base-color, 4%); +} + +.report-dialog-modal__container { + border-top-color: lighten($ui-base-color, 4%); +} + +// Change the background colors of modals +.actions-modal, +.boost-modal, +.confirmation-modal, +.mute-modal, +.block-modal, +.report-modal, +.report-dialog-modal, +.embed-modal, +.error-modal, +.onboarding-modal, +.compare-history-modal, +.report-modal__comment .setting-text__wrapper, +.report-modal__comment .setting-text, +.announcements, +.picture-in-picture__header, +.picture-in-picture__footer, +.reactions-bar__item { + background: $white; + border: 1px solid lighten($ui-base-color, 8%); +} + +.reactions-bar__item:hover, +.reactions-bar__item:focus, +.reactions-bar__item:active, +.language-dropdown__dropdown__results__item:hover, +.language-dropdown__dropdown__results__item:focus, +.language-dropdown__dropdown__results__item:active { + background-color: $ui-base-color; +} + +.reactions-bar__item.active { + background-color: mix($white, $ui-highlight-color, 80%); + border-color: mix(lighten($ui-base-color, 8%), $ui-highlight-color, 80%); +} + +.media-modal__overlay .picture-in-picture__footer { + border: 0; +} + +.picture-in-picture__header { + border-bottom: 0; +} + +.announcements, +.picture-in-picture__footer { + border-top: 0; +} + +.icon-with-badge__badge { + border-color: $white; + color: $white; +} + +.report-modal__comment { + border-right-color: lighten($ui-base-color, 8%); +} + +.report-modal__container { + border-top-color: lighten($ui-base-color, 8%); +} + +.column-header__collapsible-inner { + background: darken($ui-base-color, 4%); + border: 1px solid lighten($ui-base-color, 8%); + border-top: 0; +} + +.column-settings__hashtags .column-select__option { + color: $white; +} + +.dashboard__quick-access, +.focal-point__preview strong, +.admin-wrapper .content__heading__tabs a.selected { + color: $white; +} + +.flash-message.warning { + color: lighten($gold-star, 16%); +} + +.boost-modal__action-bar, +.confirmation-modal__action-bar, +.mute-modal__action-bar, +.block-modal__action-bar, +.onboarding-modal__paginator, +.error-modal__footer { + background: darken($ui-base-color, 6%); + + .onboarding-modal__nav, + .error-modal__nav { + &:hover, + &:focus, + &:active { + background-color: darken($ui-base-color, 12%); + } + } +} + +.display-case__case { + background: $white; +} + +.embed-modal .embed-modal__container .embed-modal__html { + background: $white; + border: 1px solid lighten($ui-base-color, 8%); + + &:focus { + border-color: lighten($ui-base-color, 12%); + background: $white; + } +} + +.react-toggle-track { + background: $ui-secondary-color; +} + +.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track { + background: darken($ui-secondary-color, 10%); +} + +.react-toggle.react-toggle--checked:hover:not(.react-toggle--disabled) + .react-toggle-track { + background: lighten($ui-highlight-color, 10%); +} + +// Change the default color used for the text in an empty column or on the error column +.empty-column-indicator, +.error-column { + color: $primary-text-color; + background: $white; +} + +// Change the default colors used on some parts of the profile pages +.activity-stream-tabs { + background: $account-background-color; + border-bottom-color: lighten($ui-base-color, 8%); +} + +.nothing-here, +.page-header, +.directory__tag > a, +.directory__tag > div { + background: $white; + border: 1px solid lighten($ui-base-color, 8%); + + @media screen and (max-width: $no-gap-breakpoint) { + border-left: 0; + border-right: 0; + border-top: 0; + } +} + +.simple_form { + input[type='text'], + input[type='number'], + input[type='email'], + input[type='password'], + textarea { + &:hover { + border-color: lighten($ui-base-color, 12%); + } + } +} + +.picture-in-picture-placeholder { + background: $white; + border-color: lighten($ui-base-color, 8%); + color: lighten($ui-base-color, 8%); +} + +.directory__tag > a { + &:hover, + &:active, + &:focus { + background: $ui-base-color; + } + + @media screen and (max-width: $no-gap-breakpoint) { + border: 0; + } +} + +.batch-table { + &__toolbar, + &__row, + .nothing-here { + border-color: lighten($ui-base-color, 8%); + } +} + +.activity-stream { + border: 1px solid lighten($ui-base-color, 8%); + + &--under-tabs { + border-top: 0; + } + + .entry { + background: $account-background-color; + + .detailed-status.light, + .more.light, + .status.light { + border-bottom-color: lighten($ui-base-color, 8%); + } + } + + .status.light { + .status__content { + color: $primary-text-color; + } + + .display-name { + strong { + color: $primary-text-color; + } + } + } +} + +.accounts-grid { + .account-grid-card { + .controls { + .icon-button { + color: $darker-text-color; + } + } + + .name { + a { + color: $primary-text-color; + } + } + + .username { + color: $darker-text-color; + } + + .account__header__content { + color: $primary-text-color; + } + } +} + +.simple_form { + .warning { + box-shadow: none; + background: rgba($error-red, 0.5); + text-shadow: none; + } + + .recommended { + border-color: $ui-highlight-color; + color: $ui-highlight-color; + background-color: rgba($ui-highlight-color, 0.1); + } +} + +.compose-form .compose-form__warning { + border-color: $ui-highlight-color; + background-color: rgba($ui-highlight-color, 0.1); + + &, + a { + color: $ui-highlight-color; + } +} + +.reply-indicator { + background: transparent; + border: 1px solid lighten($ui-base-color, 8%); +} + +.status__content, +.reply-indicator__content { + a { + color: $highlight-text-color; + } +} + +.notification__filter-bar button.active::after, +.account__section-headline a.active::after { + border-color: transparent transparent $white; +} + +.hero-widget, +.moved-account-widget, +.memoriam-widget, +.activity-stream, +.nothing-here, +.directory__tag > a, +.directory__tag > div, +.card > a, +.page-header, +.compose-form .compose-form__warning { + box-shadow: none; +} + +.mute-modal select { + border: 1px solid lighten($ui-base-color, 8%); + background: $simple-background-color + url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 8%))}'/></svg>") + no-repeat right 8px center / auto 16px; +} + +// blobfox-soc-specific changes + +.pillbar-button { + background: $ui-secondary-color; + + &:not([disabled]) { + &:hover, + &:focus { + background: darken($ui-secondary-color, 10%); + } + + &.active { + background-color: darken($ui-highlight-color, 2%); + + &:hover, + &:focus { + background: lighten($ui-highlight-color, 10%); + } + } + } +} + +.blobfox.local-settings { + background: $ui-base-color; + border: 1px solid lighten($ui-base-color, 8%); +} + +.blobfox.local-settings__navigation { + background: darken($ui-base-color, 8%); +} + +.blobfox.local-settings__navigation__item { + background: darken($ui-base-color, 8%); + border-bottom: 1px lighten($ui-base-color, 8%) solid; + + &:hover { + background: $ui-base-color; + } + + &.active { + background: $ui-highlight-color; + color: $white; + } + + &.close, + &.close:hover { + background: $error-value-color; + color: $primary-text-color; + } +} + +.notification__dismiss-overlay { + .wrappy { + box-shadow: unset; + + .ckbox { + text-shadow: unset; + } + } +} + +.status.collapsed .status__content::after { + background: linear-gradient( + rgba(darken($ui-base-color, 13%), 0), + rgba(darken($ui-base-color, 13%), 1) + ); +} + +.drawer__inner__mastodon { + background: $white + url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color($ui-base-color)}"/></svg>') + no-repeat bottom / 100% auto !important; + + .mastodon { + filter: contrast(75%) brightness(75%) !important; + } +} diff --git a/app/javascript/flavours/blobfox/styles/mastodon-light/variables.scss b/app/javascript/flavours/blobfox/styles/mastodon-light/variables.scss new file mode 100644 index 00000000000000..250e200fc6d258 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/mastodon-light/variables.scss @@ -0,0 +1,57 @@ +// Dependent colors +$black: #000000; +$white: #ffffff; + +$classic-base-color: #282c37; +$classic-primary-color: #9baec8; +$classic-secondary-color: #d9e1e8; +$classic-highlight-color: #6364ff; + +$blurple-600: #563acc; // Iris +$blurple-500: #6364ff; // Brand purple +$blurple-300: #858afa; // Faded Blue +$grey-600: #4e4c5a; // Trout +$grey-100: #dadaf3; // Topaz + +// Differences +$success-green: lighten(#3c754d, 8%); + +$base-overlay-background: $white !default; +$valid-value-color: $success-green !default; + +$ui-base-color: $classic-secondary-color !default; +$ui-base-lighter-color: #b0c0cf; +$ui-primary-color: #9bcbed; +$ui-secondary-color: $classic-base-color !default; +$ui-highlight-color: $classic-highlight-color !default; + +$ui-button-secondary-color: $grey-600 !default; +$ui-button-secondary-border-color: $grey-600 !default; +$ui-button-secondary-focus-color: $white !default; + +$ui-button-tertiary-color: $blurple-500 !default; +$ui-button-tertiary-border-color: $blurple-500 !default; + +$primary-text-color: $black !default; +$darker-text-color: $classic-base-color !default; +$highlight-text-color: darken($ui-highlight-color, 8%) !default; +$dark-text-color: #444b5d; +$action-button-color: #606984; + +$inverted-text-color: $black !default; +$lighter-text-color: $classic-base-color !default; +$light-text-color: #444b5d; + +// Newly added colors +$account-background-color: $white !default; + +// Invert darkened and lightened colors +@function darken($color, $amount) { + @return hsl(hue($color), saturation($color), lightness($color) + $amount); +} + +@function lighten($color, $amount) { + @return hsl(hue($color), saturation($color), lightness($color) - $amount); +} + +$emojis-requiring-inversion: 'chains'; diff --git a/app/javascript/flavours/blobfox/styles/modal.scss b/app/javascript/flavours/blobfox/styles/modal.scss new file mode 100644 index 00000000000000..0b7220b21d1115 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/modal.scss @@ -0,0 +1,37 @@ +.modal-layout { + background: $ui-base-color + url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color($ui-base-lighter-color)}33"/></svg>') + repeat-x bottom fixed; + display: flex; + flex-direction: column; + height: 100vh; + padding: 0; +} + +.modal-layout__mastodon { + display: flex; + flex: 1; + flex-direction: column; + justify-content: flex-end; + + > div { + flex: 1; + max-height: 235px; + position: relative; + + img { + max-height: 100%; + max-width: 100%; + height: 100%; + position: absolute; + bottom: 0; + inset-inline-start: 0; + } + } +} + +@media screen and (width <= 600px) { + .account-header { + margin-top: 0; + } +} diff --git a/app/javascript/flavours/blobfox/styles/polls.scss b/app/javascript/flavours/blobfox/styles/polls.scss new file mode 100644 index 00000000000000..4566a013a630fb --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/polls.scss @@ -0,0 +1,314 @@ +.poll { + margin-top: 16px; + font-size: 14px; + + ul, + .e-content & ul { + margin: 0; + list-style: none; + } + + li { + margin-bottom: 10px; + position: relative; + } + + &__chart { + border-radius: 4px; + display: block; + background: darken($ui-primary-color, 5%); + height: 5px; + min-width: 1%; + + &.leading { + background: $ui-highlight-color; + } + } + + progress { + border: 0; + display: block; + width: 100%; + height: 5px; + appearance: none; + background: transparent; + + &::-webkit-progress-bar { + background: transparent; + } + + // Those rules need to be entirely separate or they won't work, hence the + // duplication + &::-moz-progress-bar { + border-radius: 4px; + background: darken($ui-primary-color, 5%); + } + + &::-ms-fill { + border-radius: 4px; + background: darken($ui-primary-color, 5%); + } + + &::-webkit-progress-value { + border-radius: 4px; + background: darken($ui-primary-color, 5%); + } + } + + &__option { + position: relative; + display: flex; + padding: 6px 0; + line-height: 18px; + cursor: default; + overflow: hidden; + + &__text { + display: inline-block; + word-wrap: break-word; + overflow-wrap: break-word; + max-width: calc(100% - 45px - 25px); + } + + input[type='radio'], + input[type='checkbox'] { + display: none; + } + + .autosuggest-input { + flex: 1 1 auto; + } + + input[type='text'] { + display: block; + box-sizing: border-box; + width: 100%; + font-size: 14px; + color: $inverted-text-color; + outline: 0; + font-family: inherit; + background: $simple-background-color; + border: 1px solid darken($simple-background-color, 14%); + border-radius: 4px; + padding: 6px 10px; + + &:focus { + border-color: $highlight-text-color; + } + } + + &.selectable { + cursor: pointer; + } + + &.editable { + display: flex; + align-items: center; + overflow: visible; + } + } + + &__input { + display: inline-block; + position: relative; + border: 1px solid $ui-primary-color; + box-sizing: border-box; + width: 18px; + height: 18px; + margin-inline-end: 10px; + top: -1px; + border-radius: 50%; + vertical-align: middle; + margin-top: auto; + margin-bottom: auto; + flex: 0 0 18px; + + &.checkbox { + border-radius: 4px; + } + + &:active, + &:focus, + &:hover { + border-color: lighten($valid-value-color, 15%); + border-width: 4px; + } + + &.active { + background-color: $valid-value-color; + border-color: $valid-value-color; + } + + &::-moz-focus-inner { + outline: 0 !important; + border: 0; + } + + &:focus, + &:active { + outline: 0 !important; + } + + &.disabled { + border-color: $dark-text-color; + + &.active { + background: $dark-text-color; + } + + &:active, + &:focus, + &:hover { + border-color: $dark-text-color; + border-width: 1px; + } + } + } + + &__number { + display: inline-block; + width: 45px; + font-weight: 700; + flex: 0 0 45px; + } + + &__voted { + padding: 0 5px; + display: inline-block; + + &__mark { + font-size: 18px; + } + } + + &__footer { + padding-top: 6px; + padding-bottom: 5px; + color: $dark-text-color; + } + + &__link { + display: inline; + background: transparent; + padding: 0; + margin: 0; + border: 0; + color: $dark-text-color; + text-decoration: underline; + font-size: inherit; + + &:hover { + text-decoration: none; + } + + &:active, + &:focus { + background-color: rgba($dark-text-color, 0.1); + } + } + + .button { + height: 36px; + padding: 0 16px; + margin-inline-end: 10px; + font-size: 14px; + } +} + +.compose-form__poll-wrapper { + border-top: 1px solid darken($simple-background-color, 8%); + overflow-x: hidden; + + ul { + padding: 10px; + } + + .poll__input { + &:active, + &:focus, + &:hover { + border-color: $ui-button-focus-background-color; + } + } + + .poll__footer { + border-top: 1px solid darken($simple-background-color, 8%); + padding: 10px; + display: flex; + align-items: center; + + button, + select { + width: 100%; + flex: 1 1 50%; + + &:focus { + border-color: $highlight-text-color; + } + } + } + + .button.button-secondary { + font-size: 14px; + font-weight: 400; + padding: 6px 10px; + height: auto; + line-height: inherit; + color: $action-button-color; + border-color: $action-button-color; + margin-inline-end: 5px; + + &:hover, + &:focus, + &.active { + border-color: $action-button-color; + background-color: $action-button-color; + color: $ui-button-color; + } + } + + li { + display: flex; + align-items: center; + + .poll__option { + flex: 0 0 auto; + width: calc(100% - (23px + 6px)); + margin-inline-end: 6px; + } + } + + select { + appearance: none; + box-sizing: border-box; + font-size: 14px; + color: $inverted-text-color; + display: inline-block; + width: auto; + outline: 0; + font-family: inherit; + background: $simple-background-color + url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(darken($simple-background-color, 14%))}'/></svg>") + no-repeat right 8px center / auto 16px; + border: 1px solid darken($simple-background-color, 14%); + border-radius: 4px; + padding: 6px 10px; + padding-inline-end: 30px; + } + + .icon-button.disabled { + color: darken($simple-background-color, 14%); + } +} + +.muted .poll { + color: $dark-text-color; + + &__chart { + background: rgba(darken($ui-primary-color, 14%), 0.7); + + &.leading { + background: rgba($ui-highlight-color, 0.5); + } + } +} diff --git a/app/javascript/flavours/blobfox/styles/reset.scss b/app/javascript/flavours/blobfox/styles/reset.scss new file mode 100644 index 00000000000000..f54ed5bc79b1a7 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/reset.scss @@ -0,0 +1,95 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} + +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} + +body { + line-height: 1; +} + +ol, ul { + list-style: none; +} + +blockquote, q { + quotes: none; +} + +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} + +table { + border-collapse: collapse; + border-spacing: 0; +} + +html { + scrollbar-color: lighten($ui-base-color, 4%) rgba($base-overlay-background, 0.1); +} + +::-webkit-scrollbar { + width: 12px; + height: 12px; +} + +::-webkit-scrollbar-thumb { + background: lighten($ui-base-color, 4%); + border: 0px none $base-border-color; + border-radius: 50px; +} + +::-webkit-scrollbar-thumb:hover { + background: lighten($ui-base-color, 6%); +} + +::-webkit-scrollbar-thumb:active { + background: lighten($ui-base-color, 4%); +} + +::-webkit-scrollbar-track { + border: 0px none $base-border-color; + border-radius: 0; + background: rgba($base-overlay-background, 0.1); +} + +::-webkit-scrollbar-track:hover { + background: $ui-base-color; +} + +::-webkit-scrollbar-track:active { + background: $ui-base-color; +} + +::-webkit-scrollbar-corner { + background: transparent; +} diff --git a/app/javascript/flavours/blobfox/styles/rich_text.scss b/app/javascript/flavours/blobfox/styles/rich_text.scss new file mode 100644 index 00000000000000..6224302ee3f5f9 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/rich_text.scss @@ -0,0 +1,99 @@ +.status__content__text, +.e-content, +.reply-indicator__content { + pre, + blockquote { + margin-bottom: 20px; + white-space: pre-wrap; + unicode-bidi: plaintext; + + &:last-child { + margin-bottom: 0; + } + } + + blockquote { + padding-inline-start: 10px; + border-inline-start: 3px solid $darker-text-color; + color: $darker-text-color; + white-space: normal; + + p:last-child { + margin-bottom: 0; + } + } + + & > ul, + & > ol { + margin-bottom: 20px; + } + + h1, + h2, + h3, + h4, + h5 { + margin-top: 20px; + margin-bottom: 20px; + } + + h1, + h2 { + font-weight: 700; + font-size: 1.2em; + } + + h2 { + font-size: 1.1em; + } + + h3, + h4, + h5 { + font-weight: 500; + } + + b, + strong { + font-weight: 700; + } + + em, + i { + font-style: italic; + } + + sub { + font-size: smaller; + vertical-align: sub; + } + + sup { + font-size: smaller; + vertical-align: super; + } + + ul, + ol { + margin-inline-start: 2em; + + p { + margin: 0; + } + } + + ul { + list-style-type: disc; + } + + ol { + list-style-type: decimal; + } +} + +.reply-indicator__content { + blockquote { + border-inline-start-color: $inverted-text-color; + color: $inverted-text-color; + } +} diff --git a/app/javascript/flavours/blobfox/styles/rtl.scss b/app/javascript/flavours/blobfox/styles/rtl.scss new file mode 100644 index 00000000000000..e69d5d789118cf --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/rtl.scss @@ -0,0 +1,123 @@ +body.rtl { + direction: rtl; + + .reactions-bar { + direction: rtl; + } + + .drawer__inner__mastodon > img { + transform: scaleX(-1); + } + + .boost-modal__status-time { + float: left; + } + + .compose-form .autosuggest-textarea__textarea { + padding-right: 10px; + padding-left: 10px + 22px; + } + + .columns-area { + direction: rtl; + } + + .react-swipeable-view-container > * { + direction: rtl; + } + + .account__avatar-wrapper { + float: right; + } + + .column-header__setting-arrows { + float: left; + } + + .setting-meta__label { + float: left; + } + + .activity-stream .status.light { + padding-left: 10px; + padding-right: 68px; + } + + .status__info .status__display-name, + .activity-stream .status.light .status__display-name { + padding-left: 25px; + padding-right: 0; + } + + .activity-stream .pre-header { + padding-right: 68px; + padding-left: 0; + } + + .activity-stream .pre-header .pre-header__icon { + left: auto; + right: 42px; + } + + .account__header__tabs__buttons > .icon-button { + margin-right: 0; + margin-left: 8px; + } + + .status__relative-time, + .activity-stream .status.light .status__header .status__meta { + float: left; + text-align: left; + } + + .status__action-bar-button { + float: right; + } + + .status__action-bar-dropdown { + float: right; + } + + .detailed-status__display-name .display-name { + text-align: right; + } + + .detailed-status__display-avatar { + float: right; + } + + .admin-wrapper { + direction: rtl; + } + + .simple_form .label_input__append { + &::after { + background-image: linear-gradient( + to left, + rgba(darken($ui-base-color, 10%), 0), + darken($ui-base-color, 10%) + ); + } + } + + .simple_form select { + background: darken($ui-base-color, 10%) + url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 12%))}'/></svg>") + no-repeat left 8px center / auto 16px; + } + + .fa-chevron-left::before { + content: '\F054'; + } + + .fa-chevron-right::before { + content: '\F053'; + } + + .dismissable-banner, + .warning-banner { + &__action { + float: left; + } + } +} diff --git a/app/javascript/flavours/blobfox/styles/statuses.scss b/app/javascript/flavours/blobfox/styles/statuses.scss new file mode 100644 index 00000000000000..0a46cf855fb74b --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/statuses.scss @@ -0,0 +1,232 @@ +.activity-stream { + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + border-radius: 4px; + overflow: hidden; + margin-bottom: 10px; + + @media screen and (max-width: $no-gap-breakpoint) { + margin-bottom: 0; + border-radius: 0; + box-shadow: none; + } + + &--headless { + border-radius: 0; + margin: 0; + box-shadow: none; + + .detailed-status, + .status { + border-radius: 0 !important; + } + } + + div[data-component] { + width: 100%; + } + + .entry { + background: $ui-base-color; + + .detailed-status, + .status, + .load-more { + animation: none; + } + + &:last-child { + .detailed-status, + .status, + .load-more { + border-bottom: 0; + border-radius: 0 0 4px 4px; + } + } + + &:first-child { + .detailed-status, + .status, + .load-more { + border-radius: 4px 4px 0 0; + } + + &:last-child { + .detailed-status, + .status, + .load-more { + border-radius: 4px; + } + } + } + + @media screen and (width <= 740px) { + .detailed-status, + .status, + .load-more { + border-radius: 0 !important; + } + } + } + + &--highlighted .entry { + background: lighten($ui-base-color, 8%); + } +} + +.button.logo-button svg { + width: 20px; + height: auto; + vertical-align: middle; + margin-inline-end: 5px; + fill: $primary-text-color; + + @media screen and (max-width: $no-gap-breakpoint) { + display: none; + } +} + +.embed { + .status__content[data-spoiler='folded'] { + .e-content { + display: none; + } + + p:first-child { + margin-bottom: 0; + } + } + + .detailed-status { + padding: 15px; + + .detailed-status__display-avatar .account__avatar { + width: 48px; + height: 48px; + } + } + + .status { + padding: 15px; + padding-inline-start: (48px + 15px * 2); + min-height: 48px + 2px; + + &__avatar { + inset-inline-start: 15px; + top: 17px; + + .account__avatar { + width: 48px; + height: 48px; + } + } + + &__content { + padding-top: 5px; + } + + &__prepend { + padding: 8px 0; + padding-bottom: 2px; + margin: initial; + margin-inline-start: 48px + 15px * 2; + padding-top: 15px; + } + + &__prepend-icon-wrapper { + position: absolute; + margin: initial; + float: initial; + width: auto; + inset-inline-start: -32px; + } + + .media-gallery, + &__action-bar, + .video-player { + margin-top: 10px; + } + + &__action-bar-button { + font-size: 18px; + width: 23.1429px; + height: 23.1429px; + line-height: 23.15px; + } + } +} + +// Styling from upstream's WebUI, as public pages use the same layout +.embed { + .status { + .status__info { + font-size: 15px; + display: initial; + } + + .status__relative-time { + color: $dark-text-color; + float: right; + font-size: 14px; + width: auto; + margin: initial; + padding: initial; + padding-bottom: 1px; + } + + .status__visibility-icon { + padding: 0 4px; + } + + .status__info .status__display-name { + display: block; + max-width: 100%; + padding: 6px 0; + padding-right: 25px; + margin: initial; + } + + .status__avatar { + height: 48px; + position: absolute; + width: 48px; + margin: initial; + } + } +} + +.rtl { + .embed { + .status { + padding-left: 10px; + padding-right: 68px; + + .status__info .status__display-name { + padding-left: 25px; + padding-right: 0; + } + + .status__relative-time, + .status__visibility-icon { + float: left; + } + } + } +} + +.status__content__read-more-button, +.status__content__translate-button { + display: block; + font-size: 15px; + line-height: 20px; + color: $highlight-text-color; + border: 0; + background: transparent; + padding: 0; + padding-top: 16px; + text-decoration: none; + + &:hover, + &:active { + text-decoration: underline; + } +} diff --git a/app/javascript/flavours/blobfox/styles/tables.scss b/app/javascript/flavours/blobfox/styles/tables.scss new file mode 100644 index 00000000000000..44ef00ba7378ed --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/tables.scss @@ -0,0 +1,376 @@ +.table { + width: 100%; + max-width: 100%; + border-spacing: 0; + border-collapse: collapse; + + th, + td { + padding: 8px; + line-height: 18px; + vertical-align: top; + border-top: 1px solid $ui-base-color; + text-align: start; + background: darken($ui-base-color, 4%); + + &.critical { + font-weight: 700; + color: $gold-star; + } + } + + & > thead > tr > th { + vertical-align: bottom; + border-bottom: 2px solid $ui-base-color; + border-top: 0; + font-weight: 500; + } + + & > tbody > tr > th { + font-weight: 500; + } + + & > tbody > tr:nth-child(odd) > td, + & > tbody > tr:nth-child(odd) > th { + background: $ui-base-color; + } + + a { + color: $highlight-text-color; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } + + strong { + font-weight: 500; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + + &.inline-table { + & > tbody > tr:nth-child(odd) { + & > td, + & > th { + background: transparent; + } + } + + & > tbody > tr:first-child { + & > td, + & > th { + border-top: 0; + } + } + } + + &.horizontal-table { + border-collapse: collapse; + border-style: hidden; + + & > tbody > tr > th, + & > tbody > tr > td { + padding: 11px 10px; + background: transparent; + border: 1px solid lighten($ui-base-color, 8%); + color: $secondary-text-color; + } + + & > tbody > tr > th { + color: $darker-text-color; + font-weight: 600; + } + } + + &.batch-table { + & > thead > tr > th { + background: $ui-base-color; + border-top: 1px solid darken($ui-base-color, 8%); + border-bottom: 1px solid darken($ui-base-color, 8%); + + &:first-child { + border-radius: 4px 0 0; + border-inline-start: 1px solid darken($ui-base-color, 8%); + } + + &:last-child { + border-radius: 0 4px 0 0; + border-inline-end: 1px solid darken($ui-base-color, 8%); + } + } + } + + &--invites tbody td { + vertical-align: middle; + } +} + +.table-wrapper { + overflow: auto; + margin-bottom: 20px; +} + +samp { + font-family: $font-monospace, monospace; +} + +button.table-action-link { + background: transparent; + border: 0; + font: inherit; +} + +button.table-action-link, +a.table-action-link { + text-decoration: none; + display: inline-block; + margin-inline-end: 5px; + padding: 0 10px; + color: $darker-text-color; + font-weight: 500; + + &:hover { + color: $primary-text-color; + } + + i.fa { + font-weight: 400; + margin-inline-end: 5px; + } + + &:first-child { + padding-inline-start: 0; + } +} + +.batch-table { + &__toolbar, + &__row { + display: flex; + + &__select { + box-sizing: border-box; + padding: 8px 16px; + cursor: pointer; + min-height: 100%; + + input { + margin-top: 8px; + } + + &--aligned { + display: flex; + align-items: center; + + input { + margin-top: 0; + } + } + } + + &__actions, + &__content { + padding: 8px 0; + padding-inline-end: 16px; + flex: 1 1 auto; + } + } + + &__toolbar { + position: sticky; + top: 0; + z-index: 1; + border: 1px solid darken($ui-base-color, 8%); + background: $ui-base-color; + border-radius: 4px 0 0; + height: 47px; + align-items: center; + + &__actions { + text-align: end; + padding-inline-end: 16px - 5px; + } + } + + &__select-all { + background: $ui-base-color; + height: 47px; + align-items: center; + justify-content: center; + border: 1px solid darken($ui-base-color, 8%); + border-top: 0; + color: $secondary-text-color; + display: none; + + &.active { + display: flex; + } + + .selected, + .not-selected { + display: none; + + &.active { + display: block; + } + } + + strong { + font-weight: 700; + } + + span { + padding: 8px; + display: inline-block; + } + + button { + background: transparent; + border: 0; + font: inherit; + color: $highlight-text-color; + border-radius: 4px; + font-weight: 700; + padding: 8px; + + &:hover, + &:focus, + &:active { + background: lighten($ui-base-color, 8%); + } + } + } + + &__form { + padding: 16px; + border: 1px solid darken($ui-base-color, 8%); + border-top: 0; + background: $ui-base-color; + + .fields-row { + padding-top: 0; + margin-bottom: 0; + } + } + + &__row { + border: 1px solid darken($ui-base-color, 8%); + border-top: 0; + background: darken($ui-base-color, 4%); + + @media screen and (max-width: $no-gap-breakpoint) { + .optional &:first-child { + border-top: 1px solid darken($ui-base-color, 8%); + } + } + + &:hover { + background: darken($ui-base-color, 2%); + } + + &:nth-child(even) { + background: $ui-base-color; + + &:hover { + background: lighten($ui-base-color, 2%); + } + } + + &__content { + padding-top: 12px; + padding-bottom: 16px; + overflow: hidden; + + &--unpadded { + padding: 0; + } + + &--with-image { + display: flex; + align-items: center; + } + + &__image { + flex: 0 0 auto; + display: flex; + justify-content: center; + align-items: center; + margin-inline-end: 10px; + + .emojione { + width: 32px; + height: 32px; + } + } + + &__text { + flex: 1 1 auto; + } + + &__quote { + padding: 12px; + padding-top: 0; + } + + &__extra { + flex: 0 0 auto; + text-align: end; + color: $darker-text-color; + font-weight: 500; + } + } + + .directory__tag { + margin: 0; + width: 100%; + + a { + background: transparent; + border-radius: 0; + } + } + } + + &.optional .batch-table__toolbar, + &.optional .batch-table__row__select { + @media screen and (max-width: $no-gap-breakpoint) { + display: none; + } + } + + .status__content { + padding-top: 0; + + strong { + font-weight: 700; + } + } + + .nothing-here { + border: 1px solid darken($ui-base-color, 8%); + border-top: 0; + box-shadow: none; + + @media screen and (max-width: $no-gap-breakpoint) { + border-top: 1px solid darken($ui-base-color, 8%); + } + } + + @media screen and (width <= 870px) { + .accounts-table tbody td.optional { + display: none; + } + } +} + +.one-liner { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/app/javascript/flavours/blobfox/styles/variables.scss b/app/javascript/flavours/blobfox/styles/variables.scss new file mode 100644 index 00000000000000..0b5d6f4067b4d6 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/variables.scss @@ -0,0 +1,103 @@ +// Commonly used web colors +$black: #000000; // Black +$white: #ffffff; // White +$red-600: #b7253d !default; // Deep Carmine +$red-500: #df405a !default; // Cerise +$blurple-600: #563acc; // Iris +$blurple-500: #6364ff; // Brand purple +$blurple-300: #858afa; // Faded Blue +$grey-600: #4e4c5a; // Trout +$grey-100: #dadaf3; // Topaz + +$success-green: #79bd9a !default; // Padua +$error-red: $red-500 !default; // Cerise +$warning-red: #ff5050 !default; // Sunset Orange +$gold-star: #ca8f04 !default; // Dark Goldenrod + +$red-bookmark: $warning-red; + +// Values from the classic Mastodon UI +$classic-base-color: #282c37; // Midnight Express +$classic-primary-color: #9baec8; // Echo Blue +$classic-secondary-color: #d9e1e8; // Pattens Blue +$classic-highlight-color: #6364ff; // Brand purple + +// Variables for defaults in UI +$base-shadow-color: $black !default; +$base-overlay-background: $black !default; +$base-border-color: $white !default; +$simple-background-color: $white !default; +$valid-value-color: $success-green !default; +$error-value-color: $error-red !default; + +// Tell UI to use selected colors +$ui-base-color: $classic-base-color !default; // Darkest +$ui-base-lighter-color: lighten( + $ui-base-color, + 26% +) !default; // Lighter darkest +$ui-primary-color: $classic-primary-color !default; // Lighter +$ui-secondary-color: $classic-secondary-color !default; // Lightest +$ui-highlight-color: $classic-highlight-color !default; +$ui-button-color: $white !default; +$ui-button-background-color: $blurple-500 !default; +$ui-button-focus-background-color: $blurple-600 !default; + +$ui-button-secondary-color: $grey-100 !default; +$ui-button-secondary-border-color: $grey-100 !default; +$ui-button-secondary-focus-background-color: $grey-600 !default; +$ui-button-secondary-focus-color: $white !default; + +$ui-button-tertiary-color: $blurple-300 !default; +$ui-button-tertiary-border-color: $blurple-300 !default; +$ui-button-tertiary-focus-background-color: $blurple-600 !default; +$ui-button-tertiary-focus-color: $white !default; + +$ui-button-destructive-background-color: $red-500 !default; +$ui-button-destructive-focus-background-color: $red-600 !default; + +// Variables for texts +$primary-text-color: $white !default; +$darker-text-color: $ui-primary-color !default; +$dark-text-color: $ui-base-lighter-color !default; +$secondary-text-color: $ui-secondary-color !default; +$highlight-text-color: lighten($ui-highlight-color, 8%) !default; +$action-button-color: $ui-base-lighter-color !default; +$action-button-focus-color: lighten($ui-base-lighter-color, 4%) !default; +$passive-text-color: $gold-star !default; +$active-passive-text-color: $success-green !default; + +// For texts on inverted backgrounds +$inverted-text-color: $ui-base-color !default; +$lighter-text-color: $ui-base-lighter-color !default; +$light-text-color: $ui-primary-color !default; + +// Language codes that uses CJK fonts +$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW; + +// Variables for components +$media-modal-media-max-width: 100%; + +// put margins on top and bottom of image to avoid the screen covered by image. +$media-modal-media-max-height: 80%; + +$no-gap-breakpoint: 1175px; + +$font-sans-serif: 'mastodon-font-sans-serif' !default; +$font-display: 'mastodon-font-display' !default; +$font-monospace: 'mastodon-font-monospace' !default; + +// Avatar border size (8% default, 100% for rounded avatars) +$ui-avatar-border-size: 8%; + +// More variables +$dismiss-overlay-width: 4rem; + +:root { + --dropdown-border-color: #{lighten($ui-base-color, 12%)}; + --dropdown-background-color: #{lighten($ui-base-color, 4%)}; + --dropdown-shadow: 0 20px 25px -5px #{rgba($base-shadow-color, 0.25)}, + 0 8px 10px -6px #{rgba($base-shadow-color, 0.25)}; + --modal-background-color: #{darken($ui-base-color, 4%)}; + --modal-border-color: #{lighten($ui-base-color, 4%)}; +} diff --git a/app/javascript/flavours/blobfox/styles/widgets.scss b/app/javascript/flavours/blobfox/styles/widgets.scss new file mode 100644 index 00000000000000..f54d2f2e587620 --- /dev/null +++ b/app/javascript/flavours/blobfox/styles/widgets.scss @@ -0,0 +1,402 @@ +@use 'sass:math'; + +.hero-widget { + margin-bottom: 10px; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + + &__img { + width: 100%; + position: relative; + overflow: hidden; + border-radius: 4px 4px 0 0; + background: $base-shadow-color; + + img { + object-fit: cover; + display: block; + width: 100%; + height: 100%; + margin: 0; + border-radius: 4px 4px 0 0; + } + } + + &__text { + background: $ui-base-color; + padding: 20px; + border-radius: 0 0 4px 4px; + font-size: 15px; + color: $darker-text-color; + line-height: 20px; + word-wrap: break-word; + font-weight: 400; + + .emojione { + width: 20px; + height: 20px; + margin: -3px 0 0; + } + + p { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + } + + em { + display: inline; + margin: 0; + padding: 0; + font-weight: 700; + background: transparent; + font-family: inherit; + font-size: inherit; + line-height: inherit; + color: lighten($darker-text-color, 10%); + } + + a { + color: $secondary-text-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + @media screen and (max-width: $no-gap-breakpoint) { + display: none; + } +} + +.endorsements-widget { + margin-bottom: 10px; + padding-bottom: 10px; + + h4 { + padding: 10px; + text-transform: uppercase; + font-weight: 700; + font-size: 13px; + color: $darker-text-color; + } + + .account { + padding: 10px 0; + + &:last-child { + border-bottom: 0; + } + + .account__display-name { + display: flex; + align-items: center; + } + } + + .trends__item { + padding: 10px; + } +} + +.trends-widget { + h4 { + color: $darker-text-color; + } +} + +.placeholder-widget { + padding: 16px; + border-radius: 4px; + border: 2px dashed $dark-text-color; + text-align: center; + color: $darker-text-color; + margin-bottom: 10px; +} + +.moved-account-widget { + padding: 15px; + padding-bottom: 20px; + border-radius: 4px; + background: $ui-base-color; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + color: $secondary-text-color; + font-weight: 400; + margin-bottom: 10px; + + strong, + a { + font-weight: 500; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + + a { + color: inherit; + text-decoration: underline; + + &.mention { + text-decoration: none; + + span { + text-decoration: none; + } + + &:focus, + &:hover, + &:active { + text-decoration: none; + + span { + text-decoration: underline; + } + } + } + } + + &__message { + margin-bottom: 15px; + + .fa { + margin-inline-end: 5px; + color: $darker-text-color; + } + } + + &__card { + .detailed-status__display-avatar { + position: relative; + cursor: pointer; + } + + .detailed-status__display-name { + margin-bottom: 0; + text-decoration: none; + + span { + font-weight: 400; + } + } + } +} + +.memoriam-widget { + padding: 20px; + border-radius: 4px; + background: $base-shadow-color; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + font-size: 14px; + color: $darker-text-color; + margin-bottom: 10px; +} + +.directory { + background: $ui-base-color; + border-radius: 4px; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + + &__tag { + box-sizing: border-box; + margin-bottom: 10px; + + & > a, + & > div { + display: flex; + align-items: center; + justify-content: space-between; + background: $ui-base-color; + border-radius: 4px; + padding: 15px; + text-decoration: none; + color: inherit; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + } + + & > a { + &:hover, + &:active, + &:focus { + background: lighten($ui-base-color, 8%); + } + } + + &.active > a { + background: $ui-highlight-color; + cursor: default; + } + + &.disabled > div { + opacity: 0.5; + cursor: default; + } + + h4 { + flex: 1 1 auto; + font-size: 18px; + font-weight: 700; + color: $primary-text-color; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + .fa { + color: $darker-text-color; + } + + small { + display: block; + font-weight: 400; + font-size: 15px; + margin-top: 8px; + color: $darker-text-color; + } + } + + &.active h4 { + &, + .fa, + small { + color: $primary-text-color; + } + } + + .avatar-stack { + flex: 0 0 auto; + width: (36px + 4px) * 3; + } + + &.active .avatar-stack .account__avatar { + border-color: $ui-highlight-color; + } + } +} + +.accounts-table { + width: 100%; + + .account { + padding: 0; + border: 0; + } + + strong { + font-weight: 700; + } + + thead th { + text-align: center; + text-transform: uppercase; + color: $darker-text-color; + font-weight: 700; + padding: 10px; + + &:first-child { + text-align: start; + } + } + + tbody td { + padding: 15px 0; + vertical-align: middle; + border-bottom: 1px solid lighten($ui-base-color, 8%); + } + + tbody tr:last-child td { + border-bottom: 0; + } + + &__count { + width: 120px; + text-align: center; + font-size: 15px; + font-weight: 500; + color: $primary-text-color; + + small { + display: block; + color: $darker-text-color; + font-weight: 400; + font-size: 14px; + } + } + + tbody td.accounts-table__extra { + width: 120px; + text-align: end; + color: $darker-text-color; + padding-inline-end: 16px; + + a { + text-decoration: none; + color: inherit; + + &:focus, + &:hover, + &:active { + text-decoration: underline; + } + } + } + + &__comment { + width: 50%; + vertical-align: initial !important; + } + + &__interrelationships { + width: 21px; + } + + .fa { + font-size: 16px; + + &.active { + color: $highlight-text-color; + } + + &.passive { + color: $passive-text-color; + } + + &.active.passive { + color: $active-passive-text-color; + } + } + + @media screen and (max-width: $no-gap-breakpoint) { + tbody td.optional { + display: none; + } + } +} + +.moved-account-widget, +.memoriam-widget, +.directory { + @media screen and (max-width: $no-gap-breakpoint) { + margin-bottom: 0; + box-shadow: none; + border-radius: 0; + } +} + +.placeholder-widget { + a { + text-decoration: none; + font-weight: 500; + color: $ui-highlight-color; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } +} diff --git a/app/javascript/flavours/blobfox/theme.yml b/app/javascript/flavours/blobfox/theme.yml new file mode 100644 index 00000000000000..fc7a21dcc63e03 --- /dev/null +++ b/app/javascript/flavours/blobfox/theme.yml @@ -0,0 +1,48 @@ +# (REQUIRED) The location of the pack files. +pack: + admin: + - packs/admin.jsx + - packs/public.jsx + auth: packs/public.jsx + common: + filename: packs/common.js + stylesheet: true + embed: packs/public.jsx + error: packs/error.js + home: + filename: packs/home.js + preload: + - flavours/blobfox/async/compose + - flavours/blobfox/async/home_timeline + - flavours/blobfox/async/notifications + mailer: + modal: + public: packs/public.jsx + settings: packs/settings.js + sign_up: packs/sign_up.js + share: packs/share.jsx + +# (OPTIONAL) The directory which contains localization files for +# the flavour, relative to this directory. The contents of this +# directory must be `.json` files whose names correspond to +# language tags and whose default exports are a messages object. +locales: locales + +# (OPTIONAL) Which flavour to inherit locales from +inherit_locales: vanilla + +# (OPTIONAL) A file to use as the preview screenshot for the flavour, +# or an array thereof. These are the full path from `app/javascript/`. +screenshot: flavours/blobfox/images/blobfox-preview.jpg + +# (OPTIONAL) The directory which contains the pack files. +# Defaults to the theme directory (`app/javascript/themes/[theme]`), +# which should be sufficient for like 99% of use-cases lol. + +# pack_directory: app/javascript/packs + +# (OPTIONAL) By default the theme will fallback to the default theme +# if a particular pack is not provided. You can specify different +# fallbacks here, or disable fallback behaviours altogether by +# specifying a `null` value. +fallback: diff --git a/app/javascript/flavours/blobfox/types/util.ts b/app/javascript/flavours/blobfox/types/util.ts new file mode 100644 index 00000000000000..5f2cf2cf07f3f9 --- /dev/null +++ b/app/javascript/flavours/blobfox/types/util.ts @@ -0,0 +1 @@ +export type ValueOf<T> = T[keyof T]; diff --git a/app/javascript/flavours/blobfox/utils/backend_links.js b/app/javascript/flavours/blobfox/utils/backend_links.js new file mode 100644 index 00000000000000..2028a1e60852b7 --- /dev/null +++ b/app/javascript/flavours/blobfox/utils/backend_links.js @@ -0,0 +1,18 @@ +export const preferencesLink = '/settings/preferences'; +export const profileLink = '/settings/profile'; +export const signOutLink = '/auth/sign_out'; +export const privacyPolicyLink = '/privacy-policy'; +export const accountAdminLink = (id) => `/admin/accounts/${id}`; +export const statusAdminLink = (account_id, status_id) => `/admin/accounts/${account_id}/statuses/${status_id}`; +export const filterEditLink = (id) => `/filters/${id}/edit`; +export const relationshipsLink = '/relationships'; +export const securityLink = '/auth/edit'; +export const preferenceLink = (setting_name) => { + switch (setting_name) { + case 'user_setting_expand_spoilers': + case 'user_setting_disable_swiping': + return `/settings/preferences/appearance#${setting_name}`; + default: + return preferencesLink; + } +}; diff --git a/app/javascript/flavours/blobfox/utils/base64.ts b/app/javascript/flavours/blobfox/utils/base64.ts new file mode 100644 index 00000000000000..5a595ee12b5098 --- /dev/null +++ b/app/javascript/flavours/blobfox/utils/base64.ts @@ -0,0 +1,10 @@ +export const decode = (base64: string): Uint8Array => { + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + + return outputArray; +}; diff --git a/app/javascript/flavours/blobfox/utils/config.js b/app/javascript/flavours/blobfox/utils/config.js new file mode 100644 index 00000000000000..932cd0cbf543e1 --- /dev/null +++ b/app/javascript/flavours/blobfox/utils/config.js @@ -0,0 +1,10 @@ +import ready from '../ready'; + +export let assetHost = ''; + +ready(() => { + const cdnHost = document.querySelector('meta[name=cdn-host]'); + if (cdnHost) { + assetHost = cdnHost.content || ''; + } +}); diff --git a/app/javascript/flavours/blobfox/utils/content_warning.js b/app/javascript/flavours/blobfox/utils/content_warning.js new file mode 100644 index 00000000000000..18c21e795f819c --- /dev/null +++ b/app/javascript/flavours/blobfox/utils/content_warning.js @@ -0,0 +1,31 @@ +import { expandSpoilers } from 'flavours/blobfox/initial_state'; + +function _autoUnfoldCW(spoiler_text, skip_unfold_regex) { + if (!expandSpoilers) + return false; + + if (!skip_unfold_regex) + return true; + + let regex = null; + + try { + regex = new RegExp(skip_unfold_regex.trim(), 'i'); + } catch (e) { + // Bad regex, skip filters + return true; + } + + return !regex.test(spoiler_text); +} + +export function autoHideCW(settings, spoiler_text) { + return !_autoUnfoldCW(spoiler_text, settings.getIn(['content_warnings', 'filter'])); +} + +export function autoUnfoldCW(settings, status) { + if (!status) + return false; + + return _autoUnfoldCW(status.get('spoiler_text'), settings.getIn(['content_warnings', 'filter'])); +} diff --git a/app/javascript/flavours/blobfox/utils/filters.ts b/app/javascript/flavours/blobfox/utils/filters.ts new file mode 100644 index 00000000000000..d299e80c40a91e --- /dev/null +++ b/app/javascript/flavours/blobfox/utils/filters.ts @@ -0,0 +1,16 @@ +export const toServerSideType = (columnType: string) => { + switch (columnType) { + case 'home': + case 'notifications': + case 'public': + case 'thread': + case 'account': + return columnType; + default: + if (columnType.includes('list:')) { + return 'home'; + } else { + return 'public'; // community, account, hashtag + } + } +}; diff --git a/app/javascript/flavours/blobfox/utils/hashtag.js b/app/javascript/flavours/blobfox/utils/hashtag.js new file mode 100644 index 00000000000000..6c529dce8e5d72 --- /dev/null +++ b/app/javascript/flavours/blobfox/utils/hashtag.js @@ -0,0 +1,8 @@ +export function recoverHashtags (recognizedTags, text) { + return recognizedTags.map(tag => { + const re = new RegExp(`(?:^|[^/)\\w])#(${tag.name})`, 'i'); + const matched_hashtag = text.match(re); + return matched_hashtag ? matched_hashtag[1] : null; + }, + ).filter(x => x !== null); +} diff --git a/app/javascript/flavours/blobfox/utils/hashtags.ts b/app/javascript/flavours/blobfox/utils/hashtags.ts new file mode 100644 index 00000000000000..0c5505c6c9a088 --- /dev/null +++ b/app/javascript/flavours/blobfox/utils/hashtags.ts @@ -0,0 +1,29 @@ +const HASHTAG_SEPARATORS = '_\\u00b7\\u200c'; +const ALPHA = '\\p{L}\\p{M}'; +const WORD = '\\p{L}\\p{M}\\p{N}\\p{Pc}'; + +const buildHashtagPatternRegex = () => { + try { + 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 buildHashtagRegex = () => { + try { + return new RegExp( + `^(([${WORD}_][${WORD}${HASHTAG_SEPARATORS}]*[${ALPHA}${HASHTAG_SEPARATORS}][${WORD}${HASHTAG_SEPARATORS}]*[${WORD}_])|([${WORD}_]*[${ALPHA}][${WORD}_]*))$`, + 'iu', + ); + } catch { + return /^(\w*[a-zA-Z·]\w*)$/i; + } +}; + +export const HASHTAG_PATTERN_REGEX = buildHashtagPatternRegex(); + +export const HASHTAG_REGEX = buildHashtagRegex(); diff --git a/app/javascript/flavours/blobfox/utils/html.js b/app/javascript/flavours/blobfox/utils/html.js new file mode 100644 index 00000000000000..247e98c88a7f31 --- /dev/null +++ b/app/javascript/flavours/blobfox/utils/html.js @@ -0,0 +1,6 @@ +// NB: This function can still return unsafe HTML +export const unescapeHTML = (html) => { + const wrapper = document.createElement('div'); + wrapper.innerHTML = html.replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n').replace(/<[^>]*>/g, ''); + return wrapper.textContent; +}; diff --git a/app/javascript/flavours/blobfox/utils/icons.jsx b/app/javascript/flavours/blobfox/utils/icons.jsx new file mode 100644 index 00000000000000..be566032e06445 --- /dev/null +++ b/app/javascript/flavours/blobfox/utils/icons.jsx @@ -0,0 +1,13 @@ +// Copied from emoji-mart for consistency with emoji picker and since +// they don't export the icons in the package +export const loupeIcon = ( + <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'> + <path d='M12.9 14.32a8 8 0 1 1 1.41-1.41l5.35 5.33-1.42 1.42-5.33-5.34zM8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12z' /> + </svg> +); + +export const deleteIcon = ( + <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'> + <path d='M10 8.586L2.929 1.515 1.515 2.929 8.586 10l-7.071 7.071 1.414 1.414L10 11.414l7.071 7.071 1.414-1.414L11.414 10l7.071-7.071-1.414-1.414L10 8.586z' /> + </svg> +); diff --git a/app/javascript/flavours/blobfox/utils/idna.js b/app/javascript/flavours/blobfox/utils/idna.js new file mode 100644 index 00000000000000..efab5bacf77a0c --- /dev/null +++ b/app/javascript/flavours/blobfox/utils/idna.js @@ -0,0 +1,10 @@ +import punycode from 'punycode'; + +const IDNA_PREFIX = 'xn--'; + +export const decode = domain => { + return domain + .split('.') + .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part) + .join('.'); +}; diff --git a/app/javascript/flavours/blobfox/utils/js_helpers.js b/app/javascript/flavours/blobfox/utils/js_helpers.js new file mode 100644 index 00000000000000..2ebd5b6c55ea68 --- /dev/null +++ b/app/javascript/flavours/blobfox/utils/js_helpers.js @@ -0,0 +1,5 @@ +// This function returns the new value unless it is `null` or +// `undefined`, in which case it returns the old one. +export function overwrite (oldVal, newVal) { + return newVal === null || typeof newVal === 'undefined' ? oldVal : newVal; +} diff --git a/app/javascript/flavours/blobfox/utils/log_out.js b/app/javascript/flavours/blobfox/utils/log_out.js new file mode 100644 index 00000000000000..28123890f1480a --- /dev/null +++ b/app/javascript/flavours/blobfox/utils/log_out.js @@ -0,0 +1,35 @@ +import Rails from '@rails/ujs'; + +import { signOutLink } from 'flavours/blobfox/utils/backend_links'; + +export const logOut = () => { + const form = document.createElement('form'); + + const methodInput = document.createElement('input'); + methodInput.setAttribute('name', '_method'); + methodInput.setAttribute('value', 'delete'); + methodInput.setAttribute('type', 'hidden'); + form.appendChild(methodInput); + + const csrfToken = Rails.csrfToken(); + const csrfParam = Rails.csrfParam(); + + if (csrfParam && csrfToken) { + const csrfInput = document.createElement('input'); + csrfInput.setAttribute('name', csrfParam); + csrfInput.setAttribute('value', csrfToken); + csrfInput.setAttribute('type', 'hidden'); + form.appendChild(csrfInput); + } + + const submitButton = document.createElement('input'); + submitButton.setAttribute('type', 'submit'); + form.appendChild(submitButton); + + form.method = 'post'; + form.action = signOutLink; + form.style.display = 'none'; + + document.body.appendChild(form); + submitButton.click(); +}; diff --git a/app/javascript/flavours/blobfox/utils/notifications.js b/app/javascript/flavours/blobfox/utils/notifications.js new file mode 100644 index 00000000000000..42623ac7c6898c --- /dev/null +++ b/app/javascript/flavours/blobfox/utils/notifications.js @@ -0,0 +1,30 @@ +// Handles browser quirks, based on +// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API + +const checkNotificationPromise = () => { + try { + // eslint-disable-next-line promise/valid-params, promise/catch-or-return + Notification.requestPermission().then(); + } catch(e) { + return false; + } + + return true; +}; + +const handlePermission = (permission, callback) => { + // Whatever the user answers, we make sure Chrome stores the information + if(!('permission' in Notification)) { + Notification.permission = permission; + } + + callback(Notification.permission); +}; + +export const requestNotificationPermission = (callback) => { + if (checkNotificationPromise()) { + Notification.requestPermission().then((permission) => handlePermission(permission, callback)).catch(console.warn); + } else { + Notification.requestPermission((permission) => handlePermission(permission, callback)); + } +}; diff --git a/app/javascript/flavours/blobfox/utils/numbers.ts b/app/javascript/flavours/blobfox/utils/numbers.ts new file mode 100644 index 00000000000000..35bcde83e2491a --- /dev/null +++ b/app/javascript/flavours/blobfox/utils/numbers.ts @@ -0,0 +1,71 @@ +import type { ValueOf } from '../types/util'; + +export const DECIMAL_UNITS = Object.freeze({ + ONE: 1, + TEN: 10, + HUNDRED: 100, + THOUSAND: 1_000, + MILLION: 1_000_000, + BILLION: 1_000_000_000, + TRILLION: 1_000_000_000_000, +}); +export type DecimalUnits = ValueOf<typeof DECIMAL_UNITS>; + +const TEN_THOUSAND = DECIMAL_UNITS.THOUSAND * 10; +const TEN_MILLIONS = DECIMAL_UNITS.MILLION * 10; + +export type ShortNumber = [number, DecimalUnits, 0 | 1]; // Array of: shorten number, unit of shorten number and maximum fraction digits + +/** + * @param sourceNumber Number to convert to short number + * @returns Calculated short number + * @example + * shortNumber(5936); + * // => [5.936, 1000, 1] + */ +export function toShortNumber(sourceNumber: number): ShortNumber { + if (sourceNumber < DECIMAL_UNITS.THOUSAND) { + return [sourceNumber, DECIMAL_UNITS.ONE, 0]; + } else if (sourceNumber < DECIMAL_UNITS.MILLION) { + return [ + sourceNumber / DECIMAL_UNITS.THOUSAND, + DECIMAL_UNITS.THOUSAND, + sourceNumber < TEN_THOUSAND ? 1 : 0, + ]; + } else if (sourceNumber < DECIMAL_UNITS.BILLION) { + return [ + sourceNumber / DECIMAL_UNITS.MILLION, + DECIMAL_UNITS.MILLION, + sourceNumber < TEN_MILLIONS ? 1 : 0, + ]; + } else if (sourceNumber < DECIMAL_UNITS.TRILLION) { + return [sourceNumber / DECIMAL_UNITS.BILLION, DECIMAL_UNITS.BILLION, 0]; + } + + return [sourceNumber, DECIMAL_UNITS.ONE, 0]; +} + +/** + * @param sourceNumber Original number that is shortened + * @param division The scale in which short number is displayed + * @returns Number that can be used for plurals when short form used + * @example + * pluralReady(1793, DECIMAL_UNITS.THOUSAND) + * // => 1790 + */ +export function pluralReady( + sourceNumber: number, + division: DecimalUnits | null, +): number { + if (division == null || division < DECIMAL_UNITS.HUNDRED) { + return sourceNumber; + } + + const closestScale = division / DECIMAL_UNITS.TEN; + + return Math.trunc(sourceNumber / closestScale) * closestScale; +} + +export function roundTo10(num: number): number { + return Math.round(num * 0.1) / 0.1; +} diff --git a/app/javascript/flavours/blobfox/utils/privacy_preference.js b/app/javascript/flavours/blobfox/utils/privacy_preference.js new file mode 100644 index 00000000000000..51bdf072d78e84 --- /dev/null +++ b/app/javascript/flavours/blobfox/utils/privacy_preference.js @@ -0,0 +1,5 @@ +export const order = ['public', 'unlisted', 'private', 'direct']; + +export function privacyPreference (a, b) { + return order[Math.max(order.indexOf(a), order.indexOf(b), 0)]; +} diff --git a/app/javascript/flavours/blobfox/utils/react_helpers.js b/app/javascript/flavours/blobfox/utils/react_helpers.js new file mode 100644 index 00000000000000..ea11acdb6188e2 --- /dev/null +++ b/app/javascript/flavours/blobfox/utils/react_helpers.js @@ -0,0 +1,21 @@ +// This function binds the given `handlers` to the `target`. +export function assignHandlers (target, handlers) { + if (!target || !handlers) { + return; + } + + // We just bind each handler to the `target`. + const handle = target.handlers = {}; + Object.keys(handlers).forEach( + key => handle[key] = handlers[key].bind(target), + ); +} + +// This function only returns the component if the result of calling +// `test` with `data` is `true`. Useful with funciton binding. +export function conditionalRender (test, data, component) { + return test(data) ? component : null; +} + +// This object provides props to make the component not visible. +export const hiddenComponent = { style: { display: 'none' } }; diff --git a/app/javascript/flavours/blobfox/utils/react_router.jsx b/app/javascript/flavours/blobfox/utils/react_router.jsx new file mode 100644 index 00000000000000..fa8f0db2b5cbd6 --- /dev/null +++ b/app/javascript/flavours/blobfox/utils/react_router.jsx @@ -0,0 +1,61 @@ +import PropTypes from "prop-types"; + +import { __RouterContext } from "react-router"; + +import hoistStatics from "hoist-non-react-statics"; + +export const WithRouterPropTypes = { + match: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + history: PropTypes.object.isRequired, +}; + +export const WithOptionalRouterPropTypes = { + match: PropTypes.object, + location: PropTypes.object, + history: PropTypes.object, +}; + +// This is copied from https://github.com/remix-run/react-router/blob/v5.3.4/packages/react-router/modules/withRouter.js +// but does not fail if called outside of a React Router context +export function withOptionalRouter(Component) { + const displayName = `withRouter(${Component.displayName || Component.name})`; + const C = props => { + const { wrappedComponentRef, ...remainingProps } = props; + + return ( + <__RouterContext.Consumer> + {context => { + if(context) + return ( + <Component + {...remainingProps} + {...context} + ref={wrappedComponentRef} + /> + ); + else + return ( + <Component + {...remainingProps} + ref={wrappedComponentRef} + /> + ); + }} + </__RouterContext.Consumer> + ); + }; + + C.displayName = displayName; + C.WrappedComponent = Component; + C.propTypes = { + ...Component.propTypes, + wrappedComponentRef: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.func, + PropTypes.object + ]) + }; + + return hoistStatics(C, Component); +} diff --git a/app/javascript/flavours/blobfox/utils/resize_image.js b/app/javascript/flavours/blobfox/utils/resize_image.js new file mode 100644 index 00000000000000..e3d4e6a354b9e9 --- /dev/null +++ b/app/javascript/flavours/blobfox/utils/resize_image.js @@ -0,0 +1,191 @@ +import EXIF from 'exif-js'; + +const MAX_IMAGE_PIXELS = 2073600; // 1920x1080px + +const _browser_quirks = {}; + +// Some browsers will automatically draw images respecting their EXIF orientation +// while others won't, and the safest way to detect that is to examine how it +// is done on a known image. +// See https://github.com/w3c/csswg-drafts/issues/4666 +// and https://github.com/blueimp/JavaScript-Load-Image/commit/1e4df707821a0afcc11ea0720ee403b8759f3881 +const dropOrientationIfNeeded = (orientation) => new Promise(resolve => { + switch (_browser_quirks['image-orientation-automatic']) { + case true: + resolve(1); + break; + case false: + resolve(orientation); + break; + default: + // black 2x1 JPEG, with the following meta information set: + // - EXIF Orientation: 6 (Rotated 90° CCW) + const testImageURL = + 'data:image/jpeg;base64,/9j/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAA' + + 'AAAD/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBA' + + 'QEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE' + + 'BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAf/AABEIAAEAAgMBEQACEQEDEQH/x' + + 'ABKAAEAAAAAAAAAAAAAAAAAAAALEAEAAAAAAAAAAAAAAAAAAAAAAQEAAAAAAAAAAAAAAAA' + + 'AAAAAEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8H//2Q=='; + const img = new Image(); + img.onload = () => { + const automatic = (img.width === 1 && img.height === 2); + _browser_quirks['image-orientation-automatic'] = automatic; + resolve(automatic ? 1 : orientation); + }; + img.onerror = () => { + _browser_quirks['image-orientation-automatic'] = false; + resolve(orientation); + }; + img.src = testImageURL; + } +}); + +// Some browsers don't allow reading from a canvas and instead return all-white +// or randomized data. Use a pre-defined image to check if reading the canvas +// works. +const checkCanvasReliability = () => new Promise((resolve, reject) => { + switch(_browser_quirks['canvas-read-unreliable']) { + case true: + reject('Canvas reading unreliable'); + break; + case false: + resolve(); + break; + default: + // 2×2 GIF with white, red, green and blue pixels + const testImageURL = + 'data:image/gif;base64,R0lGODdhAgACAKEDAAAA//8AAAD/AP///ywAAAAAAgACAAACA1wEBQA7'; + const refData = + [255, 255, 255, 255, 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255]; + const img = new Image(); + img.onload = () => { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + context.drawImage(img, 0, 0, 2, 2); + const imageData = context.getImageData(0, 0, 2, 2); + if (imageData.data.every((x, i) => refData[i] === x)) { + _browser_quirks['canvas-read-unreliable'] = false; + resolve(); + } else { + _browser_quirks['canvas-read-unreliable'] = true; + reject('Canvas reading unreliable'); + } + }; + img.onerror = () => { + _browser_quirks['canvas-read-unreliable'] = true; + reject('Failed to load test image'); + }; + img.src = testImageURL; + } +}); + +const getImageUrl = inputFile => new Promise((resolve, reject) => { + if (window.URL && URL.createObjectURL) { + try { + resolve(URL.createObjectURL(inputFile)); + } catch (error) { + reject(error); + } + return; + } + + const reader = new FileReader(); + reader.onerror = (...args) => reject(...args); + reader.onload = ({ target }) => resolve(target.result); + + reader.readAsDataURL(inputFile); +}); + +const loadImage = inputFile => new Promise((resolve, reject) => { + getImageUrl(inputFile).then(url => { + const img = new Image(); + + img.onerror = (...args) => reject(...args); + img.onload = () => resolve(img); + + img.src = url; + }).catch(reject); +}); + +const getOrientation = (img, type = 'image/png') => new Promise(resolve => { + if (!['image/jpeg', 'image/webp'].includes(type)) { + resolve(1); + return; + } + + EXIF.getData(img, () => { + const orientation = EXIF.getTag(img, 'Orientation'); + if (orientation !== 1) { + dropOrientationIfNeeded(orientation).then(resolve).catch(() => resolve(orientation)); + } else { + resolve(orientation); + } + }); +}); + +const processImage = (img, { width, height, orientation, type = 'image/png' }) => new Promise(resolve => { + const canvas = document.createElement('canvas'); + + if (4 < orientation && orientation < 9) { + canvas.width = height; + canvas.height = width; + } else { + canvas.width = width; + canvas.height = height; + } + + const context = canvas.getContext('2d'); + + switch (orientation) { + case 2: context.transform(-1, 0, 0, 1, width, 0); break; + case 3: context.transform(-1, 0, 0, -1, width, height); break; + case 4: context.transform(1, 0, 0, -1, 0, height); break; + case 5: context.transform(0, 1, 1, 0, 0, 0); break; + case 6: context.transform(0, 1, -1, 0, height, 0); break; + case 7: context.transform(0, -1, -1, 0, height, width); break; + case 8: context.transform(0, -1, 1, 0, 0, width); break; + } + + context.drawImage(img, 0, 0, width, height); + + canvas.toBlob(resolve, type); +}); + +const resizeImage = (img, type = 'image/png') => new Promise((resolve, reject) => { + const { width, height } = img; + + const newWidth = Math.round(Math.sqrt(MAX_IMAGE_PIXELS * (width / height))); + const newHeight = Math.round(Math.sqrt(MAX_IMAGE_PIXELS * (height / width))); + + checkCanvasReliability() + .then(getOrientation(img, type)) + .then(orientation => processImage(img, { + width: newWidth, + height: newHeight, + orientation, + type, + })) + .then(resolve) + .catch(reject); +}); + +const resizeFile = (inputFile) => new Promise((resolve) => { + if (!inputFile.type.match(/image.*/) || inputFile.type === 'image/gif') { + resolve(inputFile); + return; + } + + loadImage(inputFile).then(img => { + if (img.width * img.height < MAX_IMAGE_PIXELS) { + resolve(inputFile); + return; + } + + resizeImage(img, inputFile.type) + .then(resolve) + .catch(() => resolve(inputFile)); + }).catch(() => resolve(inputFile)); +}); + +export default resizeFile; diff --git a/app/javascript/flavours/blobfox/utils/scrollbar.js b/app/javascript/flavours/blobfox/utils/scrollbar.js new file mode 100644 index 00000000000000..b3f543ffb38a2e --- /dev/null +++ b/app/javascript/flavours/blobfox/utils/scrollbar.js @@ -0,0 +1,34 @@ +/** @type {number | null} */ +let cachedScrollbarWidth = null; + +/** + * @returns {number} + */ +const getActualScrollbarWidth = () => { + const outer = document.createElement('div'); + outer.style.visibility = 'hidden'; + outer.style.overflow = 'scroll'; + document.body.appendChild(outer); + + const inner = document.createElement('div'); + outer.appendChild(inner); + + const scrollbarWidth = outer.offsetWidth - inner.offsetWidth; + outer.parentNode.removeChild(outer); + + return scrollbarWidth; +}; + +/** + * @returns {number} + */ +export const getScrollbarWidth = () => { + if (cachedScrollbarWidth !== null) { + return cachedScrollbarWidth; + } + + const scrollbarWidth = getActualScrollbarWidth(); + cachedScrollbarWidth = scrollbarWidth; + + return scrollbarWidth; +}; diff --git a/app/javascript/flavours/blobfox/uuid.ts b/app/javascript/flavours/blobfox/uuid.ts new file mode 100644 index 00000000000000..4d0a8a803637ba --- /dev/null +++ b/app/javascript/flavours/blobfox/uuid.ts @@ -0,0 +1,8 @@ +export function uuid(a?: string): string { + return a + ? ( + (a as unknown as number) ^ + ((Math.random() * 16) >> ((a as unknown as number) / 4)) + ).toString(16) + : ('' + 1e7 + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, uuid); +} diff --git a/app/javascript/skins/blobfox/contrast/common.scss b/app/javascript/skins/blobfox/contrast/common.scss new file mode 100644 index 00000000000000..cc090db08813e1 --- /dev/null +++ b/app/javascript/skins/blobfox/contrast/common.scss @@ -0,0 +1 @@ +@import 'flavours/blobfox/styles/contrast'; diff --git a/app/javascript/skins/blobfox/contrast/names.yml b/app/javascript/skins/blobfox/contrast/names.yml new file mode 100644 index 00000000000000..b78cc72b9c935a --- /dev/null +++ b/app/javascript/skins/blobfox/contrast/names.yml @@ -0,0 +1,12 @@ +en: + skins: + blobfox: + contrast: High contrast +cs: + skins: + blobfox: + contrast: Vysoký kontrast +es: + skins: + blobfox: + contrast: Alto contraste diff --git a/app/javascript/skins/blobfox/mastodon-light/common.scss b/app/javascript/skins/blobfox/mastodon-light/common.scss new file mode 100644 index 00000000000000..be496e0c4213d0 --- /dev/null +++ b/app/javascript/skins/blobfox/mastodon-light/common.scss @@ -0,0 +1 @@ +@import 'flavours/blobfox/styles/mastodon-light'; diff --git a/app/javascript/skins/blobfox/mastodon-light/names.yml b/app/javascript/skins/blobfox/mastodon-light/names.yml new file mode 100644 index 00000000000000..8b9b81374c12b1 --- /dev/null +++ b/app/javascript/skins/blobfox/mastodon-light/names.yml @@ -0,0 +1,12 @@ +en: + skins: + blobfox: + mastodon-light: Mastodon (light) +cs: + skins: + blobfox: + mastodon-light: Mastodon (světlý) +es: + skins: + blobfox: + mastodon-light: Mastodon (claro) diff --git a/app/javascript/skins/blobfox/queens-pink-contrast/common.scss b/app/javascript/skins/blobfox/queens-pink-contrast/common.scss new file mode 100644 index 00000000000000..c1475586fa558b --- /dev/null +++ b/app/javascript/skins/blobfox/queens-pink-contrast/common.scss @@ -0,0 +1,3 @@ +@import 'variables'; // var data +@import 'flavours/blobfox/styles/index'; // vanilla style +@import 'diff'; // modifications diff --git a/app/javascript/skins/blobfox/queens-pink-contrast/diff.scss b/app/javascript/skins/blobfox/queens-pink-contrast/diff.scss new file mode 100644 index 00000000000000..08812be8035c3d --- /dev/null +++ b/app/javascript/skins/blobfox/queens-pink-contrast/diff.scss @@ -0,0 +1,381 @@ +@import 'variables'; + +// This file houses all of the modifications to the base style. Ergo, anything omitted will use the default mastodon styling. This is useful if we just want to change a few things. + +body { + background-color: darken($ui-base-color, 5%); +} + +@mixin pane-style { + @media screen and (max-width: 600px) { + // small screen + margin: .1rem; + padding: .1rem; + } + @media screen and (min-width: 600px) { + margin: 1rem; + padding: 1rem; + } +} + +.icon-with-badge { + .icon-with-badge__badge { + border-radius: $queen-radius; + transform: scale(1.0); + transition: transform .5s ease-in-out; + } + .icon-with-badge__badge:hover { + transform: scale(1.5); + } +} + + +.drawer__inner__mastodon { + background-color: transparent !important; +} + +.single-column, +.auto-columns { + background: darken($ui-base-color, 5%); + margin: .1rem; + + .column-header { + background: $ui-base-color; + border-radius: $queen-radius; + margin: .1rem; + } + + .tabs-bar__wrapper { + background-color: transparent; + } + + + .columns-area__panels__pane__inner { + // left & right panels + @include pane-style; + + background-color: $black; + border-radius: $queen-radius; + color: $queen-highlight-color; + } + .columns-area { + background-color: transparent; + } + + .search { + margin: 1rem; + .search__input { + border-radius: $queen-radius; + } + } + + .report-modal { + background-color: $ui-base-color; + color: $white; + border-radius: $queen-radius; + } + + .column-header__button, + .column-header__back-button, + .button, + .content__heading__actions.button { + background-color: $ui-base-color; + border-radius: $queen-radius; + padding: .7rem; + border-width: 2px; + border-style: outset; + border-color: lighten($queen-highlight-color, 5%); + color: $white; + + box-shadow: inset 0 0 0 0 $queen-highlight-color; + transition: box-shadow 1s ease-in-out; + // transition: background-color .3s; + // transition: color .3s; + } + + .column-header__button:hover, + .column-header__back-button:hover, + .button:hover, + .content__heading__actions.button:hover { + box-shadow: inset 100px 0 0 0 $queen-highlight-color; + // background-color: $queen-highlight-color; + // color: $black; + } + + .drawer__header { + background-color: $ui-base-color; + border-radius: $queen-radius; + i.fa { + color: $white; + } + } + + .drawer__inner { + background-color: transparent; + } + + .scrollable { + background-color: transparent; + } + .columns-area__panels__main { + background-color: transparent; + border-bottom-left-radius: $queen-radius; + border-bottom-right-radius: $queen-radius; + padding: 0%; + } + + .status__info { + padding-bottom: .5rem; + } + + // .account__avatar{ + // border-radius: $queen-radius-pfp; + // border-style: solid; + // border-color: $queen-highlight-color; + // // border-width: 3px; + // border-top-width: $queen-border-thickness; + // border-left-width: $queen-border-thickness; + // transform: none; + // } + + .status__avatar { + // margin: 2rem; + border-radius: $queen-radius-pfp; + background-image: linear-gradient(45deg, $queen-highlight-color, $ui-base-color); + padding: .5rem; + border-width: $queen-border-thickness; + border-style: solid; + border-color: $queen-highlight-color; + } + + .status__display-name { + .display-name { + background-image: linear-gradient(to right, $queen-highlight-color, $ui-base-color); + padding: .5rem; + padding-left: 1rem; + padding-right: 1rem; + border-radius: $queen-radius; + .display-name__account { + color: $white; + } + } + } + + .account__avatar, + .account__avatar-overlay-base, + .account__avatar-overlay-overlay { + border-radius: $queen-radius-pfp; + } + + .tabs-bar__wrapper { + background-color: transparent; + } + + .status, + .detailed-status { + background-color: $black; + border-radius: $queen-radius; + border-style: solid; + border-color: desaturate($queen-highlight-color, 25%); + // border-width: 3px; + border-bottom-width: $queen-border-thickness; + border-right-width: $queen-border-thickness; + padding: 1rem; + margin: 1rem; + + } + + .status__content { + text-align: center; + align-content: center; + vertical-align: middle; + span { + padding: 1rem; + } + } + .status__content__text { + text-align: left; + } + + .status__content--with-spoiler { + background-color: $ui-base-color; + padding: 1rem; + border-radius: $queen-radius; + } + + .status__content__spoiler-link { + // dear god why isn't there a class for the spoiler text!?!? + padding: .5rem; + border-radius: $queen-radius; + background-color: $queen-highlight-color; + color: $white; + transition: background-color 1s; + } + .status__content__spoiler-link:hover { + background-color: $black; + } + + .dropdown-animation { + padding: .5rem; + border-radius: $queen-radius; + background-color: $queen-highlight-color; + color: $white; + } + .privacy-dropdown__option { + border-radius: $queen-radius; + background-color: $queen-highlight-color; + color: $white; + } + + .detailed-status-direct, + .status-direct, + .status-direct::selection, + .detailed-status-direct::selection { + padding-left: 2rem; + border-radius: $queen-radius; + border: .3rem solid $queen-highlight-color; + border-top-right-radius: 0%; + background-color: transparent; + background-image: linear-gradient(140deg, darken($queen-highlight-color, 15%), $black, $black); + } + + .compose-form { + background-color: transparent; + + .compose-form__autosuggest-wrapper { + background-color: transparent; + + .autosuggest-textarea { + background-color: transparent; + + .autosuggest-textarea__textarea { + background-color: $ui-base-color; + color: lighten($queen-highlight-color, 25%); + border-radius: $queen-radius; + padding: 1rem; + } + } + } + } + .spoiler-input__input { + background-color: $ui-base-color; + color: lighten($queen-highlight-color, 25%); + border-radius: $queen-radius; + // padding: 1rem; + } + + .navigation-bar { + padding: .5rem; + background-color: $ui-base-color; + border-radius: $queen-radius; + } +.autosuggest-textarea__suggestions-wrapper { + background-color: lighten($ui-base-color, 5%); +} +.compose-form__buttons-wrapper { + background-color: lighten($ui-base-color, 5%); + padding: .5rem; + margin-top: .5rem; + border-radius: $queen-radius; +} + + .hashtag, + .status-link, + .mention { + background-color: $queen-highlight-color; + border-radius: $queen-radius; + + color: $white; + text-decoration-color: $white; + text-emphasis-color: $white; + transition: background-color 1s; + // clear values from the spoi + padding: .2rem; + margin: .3rem; + border: 2px outset $white; + } + .hashtag:hover, + .mention:hover { + background-color: $black; + } + + .unhandled-link { + background-color: $black; + border-color: $queen-highlight-color; + } + .mention { + border-color: lighten($queen-highlight-color, 25%); + } + .hashtag { + border-color: darken($queen-highlight-color, 25%); + } + .status-card { + background-color: $ui-base-color; + border-radius: $queen-radius; + border-color: $queen-highlight-color; + margin: .5rem; + .status-card__image { + border-radius: $queen-radius; + border-color: $queen-highlight-color; + border-width: .1rem; + border-style: solid; + // padding: .5rem; + } + } + + .reactions-bar__item, + .status__content__text, + .display-name { + .emojione { + transition: transform .3s; + transform: scale(1.0); + } + .emojione:hover { + transform: scale(3); + } + } + + .display-name { + clip-path: none; + } + + .status__action-bar, + .status__info__icons, + .columns-area__panels__pane__inner, + .notification__message { + i.fa, + .text-icon { + transform: scale(1); + transition: transform .3s ease-in-out; + } + + i.fa:hover, + .text-icon:hover, + i.fa:active { + transform: scale(1.3); + } + } + + .column-link { + background-color: transparent; + box-shadow: inset 0 0 0 0 $queen-highlight-color; + transition: box-shadow .3s; + border-radius: $queen-radius; + } + .column-link:hover { + box-shadow: inset 10px 0px 30px 0px $queen-highlight-color; + } + + .getting-started__wrapper { + border-radius: $queen-radius; + margin: .5rem; + } + + .reply-indicator, + .status__content__text { + p { + line-height: 2rem; + } + } + +} diff --git a/app/javascript/skins/blobfox/queens-pink-contrast/names.yml b/app/javascript/skins/blobfox/queens-pink-contrast/names.yml new file mode 100644 index 00000000000000..729cf44e755adc --- /dev/null +++ b/app/javascript/skins/blobfox/queens-pink-contrast/names.yml @@ -0,0 +1,4 @@ +en: + skins: + blobfox: + queens-pink-contrast: Queen's Pink Contrast diff --git a/app/javascript/skins/blobfox/queens-pink-contrast/screenshot.jpg b/app/javascript/skins/blobfox/queens-pink-contrast/screenshot.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a31cfac43068b76d45a03a6609ba11464fee19e7 GIT binary patch literal 173565 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 J=>y~cn*d?ZOQ!$; literal 0 HcmV?d00001 diff --git a/app/javascript/skins/blobfox/queens-pink-contrast/variables.scss b/app/javascript/skins/blobfox/queens-pink-contrast/variables.scss new file mode 100644 index 00000000000000..c1de386ce702a8 --- /dev/null +++ b/app/javascript/skins/blobfox/queens-pink-contrast/variables.scss @@ -0,0 +1,81 @@ +// my theme customizations +$queen-highlight-color: #b82493; +$queen-bg: #3a002f; +$queen-radius: 16px; +$queen-radius-pfp: 25%; +$queen-border-thickness: .1rem; + + + +// built-in defaults +$black: #000000; +$white: #ffffff; +$success-green: #79bd9a; +$error-red: #df405a; +$warning-red: #ff5050; +$gold-star: #ca8f04; // favorite colour + + +// Values from the classic Mastodon UI +$classic-base-color: $queen-bg; // Midnight Express +$classic-primary-color: desaturate($queen-highlight-color, 15%); // Echo Blue +$classic-secondary-color: #d9e1e8; // Pattens Blue +$classic-highlight-color: #2b90d9; // Summer Sky + + +// Variables for defaults in UI +$base-shadow-color: $black !default; +$base-overlay-background: $black !default; +$base-border-color: $white !default; +$simple-background-color: $white !default; +$valid-value-color: $success-green !default; +$error-value-color: $error-red !default; + +$ui-base-color: $queen-bg !default; +$ui-base-lighter-color: lighten($queen-highlight-color, 25%) !default; // Lighter darkest +$ui-primary-color: $classic-primary-color !default; +$ui-secondary-color: $classic-secondary-color !default; +$ui-highlight-color: $classic-highlight-color !default; + +$ui-highlight-color: $queen-highlight-color; + + +// Variables for texts +$primary-text-color: $white !default; +$darker-text-color: $ui-primary-color !default; +$dark-text-color: $ui-base-lighter-color !default; +$secondary-text-color: $ui-secondary-color !default; +$highlight-text-color: $ui-highlight-color !default; +$action-button-color: $ui-base-lighter-color !default; +$action-button-color-lighter: lighten($ui-base-lighter-color, 10%) !default; +$action-button-color-darker: darken($ui-base-lighter-color, 5%) !default; + +$passive-text-color: $gold-star !default; +$active-passive-text-color: $success-green !default; +// For texts on inverted backgrounds +$inverted-text-color: $ui-base-color !default; +$lighter-text-color: $ui-base-lighter-color !default; +$light-text-color: $ui-primary-color !default; + +$solar-disabled: darken($action-button-color, 10%) !default; + +// Language codes that uses CJK fonts +$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW; + +// Variables for components +$media-modal-media-max-width: 100%; +// put margins on top and bottom of image to avoid the screen covered by image. +$media-modal-media-max-height: 80%; + +$no-gap-breakpoint: 415px; + +$font-sans-serif: 'mastodon-font-sans-serif' !default; +$font-display: 'mastodon-font-display' !default; +$font-monospace: 'mastodon-font-monospace' !default; + +// Avatar border size (8% default, 100% for rounded avatars) +$ui-avatar-border-size: 8%; + +// More variables +$dismiss-overlay-width: 4rem; + diff --git a/app/javascript/skins/blobfox/solarpunk/common.scss b/app/javascript/skins/blobfox/solarpunk/common.scss new file mode 100644 index 00000000000000..8dcb5f04f0386a --- /dev/null +++ b/app/javascript/skins/blobfox/solarpunk/common.scss @@ -0,0 +1,3 @@ +@import 'variables'; +@import 'flavours/blobfox/styles/index'; +@import 'diff'; diff --git a/app/javascript/skins/blobfox/solarpunk/diff.scss b/app/javascript/skins/blobfox/solarpunk/diff.scss new file mode 100644 index 00000000000000..bac39ce234f104 --- /dev/null +++ b/app/javascript/skins/blobfox/solarpunk/diff.scss @@ -0,0 +1,143 @@ +@import 'variables'; + +body { + background-color: $solar-bg; +} + +.media-modal__overlay +.picture-in-picture__footer +button.icon-button { + i.fa-retweet { + background: url("data:image/svg+xml;utf8,<svg width='18.7' height='94' enable-background='new 0 0 628.254 613.516' version='1.1' viewBox='0 0 657.07 3289.9' xml:space='preserve' xmlns='http://www.w3.org/2000/svg'><g transform='translate(-.77735 -6.0669)'><g transform='translate(29.186 28.067)' fill='#{hex-color($white)}'><g fill='#{hex-color($white)}'><g fill='#{hex-color($white)}'><path d='m99.777 446.09c-6.699 12.031-12.031 30.133-12.031 41.539 0 2.648 0 6.016 0.656 10.688l-83.726-143.99c-2.68-4.672-4.676-11.375-4.676-17.414 0-6.047 1.996-13.398 4.676-18.078l40.195-70.328-44.871-25.43 146.02-2.703 70.984 127.91-45.527-26.117zm64.313-405.86c12.715-22.125 33.496-34.18 58.926-34.18 27.48 0 48.918 12.742 64.312 38.828l22.777 38.172-79.051 136.66-127.91-74.352zm9.351 521.72c-38.172 0-69.645-31.477-69.645-69.648 0-10.719 4.703-28.82 11.402-40.195l21.41-38.172h158.76v148.02h-121.92zm127.29-525.76c-10.036-17.391-23.434-29.477-39.512-36.18h164.75c14.738 0 26.113 6.047 32.84 17.445l40.852 69.648 44.191-26.141-71.016 127.28-145.3-2.047 44.871-25.43zm253.86 379.09c20.07 0 36.832-5.359 50.887-16.055l-83.07 144.65c-6.699 11.375-18.73 18.078-32.789 18.078h-78.395v51.57l-75.004-125.23 75.004-125.27v52.258h143.37zm64.258-120.56c6.043 10.719 9.406 22.094 9.406 34.156 0 24.117-15.422 49.57-36.832 61.602-10.062 5.391-24.145 8.75-38.172 8.75h-44.242l-79-136.66 127.92-73.008z' fill='#{hex-color($white)}'/></g></g></g><g fill='#{hex-color($solar-recycling)}'><g transform='translate(27.733 1347)'><g fill='#{hex-color($solar-recycling)}'><g fill='#{hex-color($solar-recycling)}'><path d='m99.777 446.09c-6.699 12.031-12.031 30.133-12.031 41.539 0 2.648 0 6.016 0.656 10.688l-83.726-143.99c-2.68-4.672-4.676-11.375-4.676-17.414 0-6.047 1.996-13.398 4.676-18.078l97.831-167.4-44.871-25.43 146.02-2.703 70.984 127.91-45.527-26.117zm64.313-405.86c12.715-22.125 33.496-34.18 58.926-34.18 27.48 0 48.918 12.742 64.312 38.828l22.777 38.172-21.415 39.593-127.91-74.352zm9.351 521.72c-38.172 0-69.645-31.477-69.645-69.648 0-10.719 4.703-28.82 11.402-40.195l21.41-38.172 40.453-1.5167v148.02l-3.6203 1.5167zm127.29-525.76c-10.036-17.391-23.434-29.477-39.512-36.18h164.75c14.738 0 26.113 6.047 32.84 17.445l110.62 189.47 44.191-26.141-71.016 127.28-145.3-2.047 44.871-25.43zm253.86 379.09c20.07 0 36.832-5.359 50.887-16.055l-83.07 144.65c-6.699 11.375-18.73 18.078-32.789 18.078l-196.7-1.5167v51.57l-75.004-125.23 75.004-125.27v52.258l261.67 1.5167zm64.258-120.56c6.043 10.719 9.406 22.094 9.406 34.156 0 24.117-15.422 49.57-36.832 61.602-10.062 5.391-24.145 8.75-38.172 8.75h-44.242l-9.2306-16.843 127.92-73.008z' fill='#{hex-color($solar-recycling)}'/></g></g></g><g transform='translate(691.01 5483.9)'><g fill='#{hex-color($solar-recycling)}'/></g><g transform='translate(30.324 2009.8)'><g fill='#{hex-color($solar-recycling)}'><g fill='#{hex-color($solar-recycling)}'><path d='m99.777 446.09c-6.699 12.031-12.031 30.133-12.031 41.539 0 2.648 0 6.016 0.656 10.688l-83.726-143.99c-2.68-4.672-4.676-11.375-4.676-17.414 0-6.047 1.996-13.398 4.676-18.078l11.377-17.243-44.871-25.43 146.02-2.703 70.984 127.91-45.527-26.117zm64.313-405.86c12.715-22.125 33.496-34.18 58.926-34.18 27.48 0 48.918 12.742 64.312 38.828l22.777 38.172-107.87 189.75-127.91-74.352zm9.351 521.72c-38.172 0-69.645-31.477-69.645-69.648 0-10.719 4.703-28.82 11.402-40.195l21.41-38.172h216.39v148.02h-179.56zm127.29-525.76c-10.036-17.391-23.434-29.477-39.512-36.18h164.75c14.738 0 26.113 6.047 32.84 17.445l13.551 2.912 44.191-26.141-71.016 127.28-145.3-2.047 44.871-25.43zm253.86 379.09c20.07 0 36.832-5.359 50.887-16.055l-83.07 144.65c-6.699 11.375-18.73 18.078-32.789 18.078h-20.759v51.57l-75.004-125.23 75.004-125.27v52.258h85.731zm64.258-120.56c6.043 10.719 9.406 22.094 9.406 34.156 0 24.117-15.422 49.57-36.832 61.602-10.062 5.391-24.145 8.75-38.172 8.75h-44.242l-116.92-210.98 127.92-73.008z' fill='#{hex-color($solar-recycling)}'/></g></g></g><g transform='translate(27.915 2669.7)'><g fill='#{hex-color($solar-recycling)}'><g fill='#{hex-color($solar-recycling)}'><path d='m99.777 446.09c-6.699 12.031-12.031 30.133-12.031 41.539 0 2.648 0 6.016 0.656 10.688l-83.726-143.99c-2.68-4.672-4.676-11.375-4.676-17.414 0-6.047 1.996-13.398 4.676-18.078l40.195-70.328-44.871-25.43 146.02-2.703 70.984 127.91-45.527-26.117zm64.313-405.86c12.715-22.125 33.496-34.18 58.926-34.18 27.48 0 48.918 12.742 64.312 38.828l22.777 38.172-79.051 136.66-127.91-74.352zm9.351 521.72c-38.172 0-69.645-31.477-69.645-69.648 0-10.719 4.703-28.82 11.402-40.195l21.41-38.172h158.76v148.02h-121.92zm127.29-525.76c-10.036-17.391-23.434-29.477-39.512-36.18h164.75c14.738 0 26.113 6.047 32.84 17.445l40.852 69.648 44.191-26.141-71.016 127.28-145.3-2.047 44.871-25.43zm253.86 379.09c20.07 0 36.832-5.359 50.887-16.055l-83.07 144.65c-6.699 11.375-18.73 18.078-32.789 18.078h-78.395v51.57l-75.004-125.23 75.004-125.27v52.258h143.37zm64.258-120.56c6.043 10.719 9.406 22.094 9.406 34.156 0 24.117-15.422 49.57-36.832 61.602-10.062 5.391-24.145 8.75-38.172 8.75h-44.242l-79-136.66 127.92-73.008z' fill='#{hex-color($solar-recycling)}'/></g></g></g></g><g transform='translate(29.186 687.36)' fill='#{hex-color($white)}'><g fill='#{hex-color($white)}'><g fill='#{hex-color($white)}'><path d='m99.777 446.09c-6.699 12.031-12.031 30.133-12.031 41.539 0 2.648 0 6.016 0.656 10.688l-83.726-143.99c-2.68-4.672-4.676-11.375-4.676-17.414 0-6.047 1.996-13.398 4.676-18.078l76.66-134.68-44.871-25.43 146.02-2.703 70.984 127.91-45.527-26.117zm64.313-405.86c12.715-22.125 33.496-34.18 58.926-34.18 27.48 0 48.918 12.742 64.312 38.828l22.777 38.172-42.586 72.315-127.91-74.352zm9.351 521.72c-38.172 0-69.645-31.477-69.645-69.648 0-10.719 4.703-28.82 11.402-40.195l21.41-38.172h111.57v148.02h-74.736zm127.29-525.76c-10.036-17.391-23.434-29.477-39.512-36.18h164.75c14.738 0 26.113 6.047 32.84 17.445l73.027 129.71 44.191-26.141-71.016 127.28-145.3-2.047 44.871-25.43zm253.86 379.09c20.07 0 36.832-5.359 50.887-16.055l-83.07 144.65c-6.699 11.375-18.73 18.078-32.789 18.078h-125.58v51.57l-75.004-125.23 75.004-125.27v52.258h190.56zm64.258-120.56c6.043 10.719 9.406 22.094 9.406 34.156 0 24.117-15.422 49.57-36.832 61.602-10.062 5.391-24.145 8.75-38.172 8.75h-44.242l-46.825-76.605 127.92-73.008z' fill='#{hex-color($solar-recycling)}'/></g></g></g></g></svg>") no-repeat; + } + + &.reblogPrivate i.fa-retweet { + background: url("data:image/svg+xml;utf8,<svg width='19' height='19' enable-background='new 0 0 628.254 613.516' version='1.1' viewBox='0 0 630.25 615.51' xml:space='preserve' xmlns='http://www.w3.org/2000/svg'><defs><mask id='mask-powermask-path-effect929' maskUnits='userSpaceOnUse'><path id='mask-powermask-path-effect929_box' d='m-1-1h630.25v615.51h-630.25z' fill='%23fff'/><g transform='matrix(1.8197 0 0 1.8197 467.68 255.09)' style=''><rect x='-219.52' y='91.198' width='114.31' height='71.892' fill='none' stroke='%23000' stroke-width='34.869'/><path d='m-211.31 76.273c0-2.2289-0.45078-28.654-0.20046-30.833 1.9095-16.621 12.096-32.827 25.749-41.276 15.443-9.5572 34.438-9.4492 49.787 0.28295 13.762 8.7258 22.704 23.991 24.271 40.915 0.18075 1.9519-0.0259 28.68-0.0453 30.668' fill='none' stroke='%23000' stroke-width='28.408'/></g></mask></defs><g transform='translate(-28.186 -5.0669)'><g transform='translate(29.186 6.0669)' fill='#{hex-color($white)}' mask='url(%23mask-powermask-path-effect929)'><path d='m99.777 446.09c-6.699 12.031-12.031 30.133-12.031 41.539 0 2.648 0 6.016 0.656 10.688l-83.726-143.99c-2.68-4.672-4.676-11.375-4.676-17.414 0-6.047 1.996-13.398 4.676-18.078l40.195-70.328-44.871-25.43 146.02-2.703 70.984 127.91-45.527-26.117zm64.313-405.86c12.715-22.125 33.496-34.18 58.926-34.18 27.48 0 48.918 12.742 64.312 38.828l22.777 38.172-79.051 136.66-127.91-74.352zm9.351 521.72c-38.172 0-69.645-31.477-69.645-69.648 0-10.719 4.703-28.82 11.402-40.195l21.41-38.172h158.76v148.02h-121.92zm127.29-525.76c-10.036-17.391-23.434-29.477-39.512-36.18h164.75c14.738 0 26.113 6.047 32.84 17.445l40.852 69.648 44.191-26.141-71.016 127.28-145.3-2.047 44.871-25.43zm253.86 379.09c20.07 0 36.832-5.359 50.887-16.055l-83.07 144.65c-6.699 11.375-18.73 18.078-32.789 18.078h-78.395v51.57l-75.004-125.23 75.004-125.27v52.258h143.37zm64.258-120.56c6.043 10.719 9.406 22.094 9.406 34.156 0 24.117-15.422 49.57-36.832 61.602-10.062 5.391-24.145 8.75-38.172 8.75h-44.242l-79-136.66 127.92-73.008z' fill='#{hex-color($white)}' mask='none'/></g><path d='m28.186 5.0669h630.25v615.51h-630.25z' fill='none'/><g transform='translate(691.01 5483.9)' fill='%23009700'><g fill='%23009700'/></g><g transform='matrix(1.8197 0 0 1.8197 537.9 285.64)' stroke='#{hex-color($white)}'><rect x='-219.52' y='91.198' width='114.31' height='71.892' fill='#{hex-color($white)}' stroke-width='34.869'/><path d='m-211.31 76.273c0-2.2289-0.45078-28.654-0.20046-30.833 1.9095-16.621 12.096-32.827 25.749-41.276 15.443-9.5572 34.438-9.4492 49.787 0.28295 13.762 8.7258 22.704 23.991 24.271 40.915 0.18075 1.9519-0.0259 28.68-0.0453 30.668' fill='none' stroke-width='28.408'/></g></g></svg>"); + } + + i.fa-star { + background: url("data:image/svg+xml;utf8,<svg width='6.299mm' height='28.591mm' version='1.1' viewBox='0 0 4.9132 22.301' xmlns='http://www.w3.org/2000/svg'><g transform='translate(-72.638 -93.656)'><g transform='translate(.47668 5.3239)'><path transform='matrix(.04592 .26057 -.26057 .04592 76.244 95.826)' d='m-0.09375 11.156-3.1493-2.2515-1.9189 3.3623-0.63485-3.819-3.7344 1.0206 2.2515-3.1493-3.3623-1.9189 3.819-0.63485-1.0206-3.7344 3.1493 2.2515 1.9189-3.3623 0.63485 3.819 3.7344-1.0206-2.2515 3.1493 3.3623 1.9189-3.819 0.63485z' fill='%23ffb271'/><g fill='%23ff8e7c'><path d='m74.63 92.871-0.44504 1.5146 0.31204 0.77128 0.57329-0.77889z' stroke-width='1.0644'/><path d='m74.205 95.719 0.41002-0.27757 0.41153 0.30391-0.37017 1.3919' stroke-width='1.1036'/><path d='m72.271 95.052 1.6527 0.40144 0.90943-0.37981-0.85337-0.45872z'/><path d='m76.865 95.04-1.5894-0.43145-0.8651 0.42185 0.90715 0.47274z'/></g><circle cx='74.637' cy='95.054' r='.86816' fill='%23f9e59a' stroke-width='.20427'/></g><g transform='translate(.025125 .41834)'><g transform='translate(.49227 .42869)'><path transform='matrix(.04592 .26057 -.26057 .04592 76.244 95.826)' d='m-0.09375 11.156-3.0678-2.4605c-1.0077-0.58067-1.6372-0.66239-2.5754-0.46052l-3.7943 1.2335 2.2515-3.1493-3.3623-1.9189 3.819-0.63485-1.0206-3.7344 3.1493 2.2515c1.573 0.58073 1.5464 0.26082 2.5538 0.4567l3.7344-1.0206-2.2515 3.1493c-1.1607 1.0484-0.49695 1.609-0.4567 2.5538z' fill='#{hex-color($white)}'/><g fill='#{hex-color($white)}'><path d='m74.612 93.227-0.34726 1.0005 0.25632 0.51263 0.43822-0.52917z'/><path d='m74.249 95.856 0.27765-0.50436 0.42515 0.5209-0.34112 1.0253'/><path d='m72.802 95.054 1.0348 0.36811 0.59405-0.35157-0.59987-0.37789z'/><path d='m76.403 95.054-0.95912-0.339-0.66973 0.32246 0.66973 0.37207z'/></g><circle cx='74.637' cy='95.054' r='.86816' fill='#{hex-color($white)}' stroke-width='.20427'/></g></g><g transform='translate(.51611 14.249)'><path transform='matrix(.04592 .26057 -.26057 .04592 76.244 95.826)' d='m2.4281 13.801-6.2849-5.3807-1.3051 3.847-0.17157-4.0062-6.9762 2.8655 5.1741-5.3193-3.5064-1.4065 3.8314-0.094818-3.2175-7.6125 5.8984 5.7429 1.3543-3.5156 0.19915 3.8497 7.3536-2.8701-5.754 5.5572 3.6813 1.3297-4.0124-0.09844z' fill='%23ffb271'/><g fill='%23ff8e7c'><path d='m74.612 93.227-0.34726 1.0005 0.25632 0.51263 0.43822-0.52917z'/><path d='m74.249 95.856 0.27765-0.50436 0.42515 0.5209-0.34112 1.0253'/><path d='m72.802 95.054 0.95912 0.339 0.66973-0.32246-0.66973-0.37207z'/><path d='m76.403 95.054-0.95912-0.339-0.66973 0.32246 0.66973 0.37207z'/></g><circle cx='74.637' cy='95.054' r='.86816' fill='%23f9e59a' stroke-width='.20427'/></g><g transform='translate(.51271 18.705)'><path transform='matrix(.04592 .26057 -.26057 .04592 76.244 95.826)' d='m-0.09375 11.156-3.1493-2.2515-1.9189 3.3623-0.63485-3.819-3.7344 1.0206 2.2515-3.1493-3.3623-1.9189 3.819-0.63485-1.0206-3.7344 3.1493 2.2515 1.9189-3.3623 0.63485 3.819 3.7344-1.0206-2.2515 3.1493 3.3623 1.9189-3.819 0.63485z' fill='%23ffb271'/><g fill='%23ff8e7c'><path d='m74.612 93.227-0.34726 1.0005 0.25632 0.51263 0.43822-0.52917z'/><path d='m74.249 95.856 0.27765-0.50436 0.42515 0.5209-0.34112 1.0253'/><path d='m72.802 95.054 0.95912 0.339 0.66973-0.32246-0.66973-0.37207z'/><path d='m76.403 95.054-0.95912-0.339-0.66973 0.32246 0.66973 0.37207z'/></g><circle cx='74.637' cy='95.054' r='.86816' fill='%23f9e59a' stroke-width='.20427'/></g><g transform='translate(.50609 9.7746)'><path transform='matrix(.04592 .26057 -.26057 .04592 76.244 95.826)' d='m-0.09375 11.156-3.1493-2.2515-1.9189 3.3623-0.63485-3.819-3.7344 1.0206 2.2515-3.1493-3.3623-1.9189 3.819-0.63485-1.0206-3.7344 3.1493 2.2515 1.9189-3.3623 0.63485 3.819 3.7344-1.0206-2.2515 3.1493 3.3623 1.9189-3.819 0.63485z' fill='%23ffb271'/><g fill='%23ff8e7c'><path d='m74.612 93.227-0.34726 1.0005 0.25632 0.51263 0.43822-0.52917z'/><path d='m74.249 95.856 0.27765-0.50436 0.42515 0.5209-0.34112 1.0428'/><path d='m72.802 95.054 0.95912 0.339 0.66973-0.32246-0.66973-0.37207z'/><path d='m76.403 95.054-0.95912-0.339-0.66973 0.32246 0.66973 0.37207z'/></g><circle cx='74.637' cy='95.054' r='.86816' fill='%23f9e59a' stroke-width='.20427'/></g></g></svg>") no-repeat top; + } +} + +.media-modal__overlay +.picture-in-picture__footer +button.icon-button.active { + &.reblogPrivate.active i.fa-retweet, + i.fa-retweet { + background: url("data:image/svg+xml;utf8,<svg width='18.7' height='94' enable-background='new 0 0 628.254 613.516' version='1.1' viewBox='0 0 657.07 3289.9' xml:space='preserve' xmlns='http://www.w3.org/2000/svg'><g transform='translate(-.77735 -6.0669)'><g transform='translate(29.186 28.067)' fill='#{hex-color($white)}'><g fill='#{hex-color($white)}'><g fill='#{hex-color($white)}'><path d='m99.777 446.09c-6.699 12.031-12.031 30.133-12.031 41.539 0 2.648 0 6.016 0.656 10.688l-83.726-143.99c-2.68-4.672-4.676-11.375-4.676-17.414 0-6.047 1.996-13.398 4.676-18.078l40.195-70.328-44.871-25.43 146.02-2.703 70.984 127.91-45.527-26.117zm64.313-405.86c12.715-22.125 33.496-34.18 58.926-34.18 27.48 0 48.918 12.742 64.312 38.828l22.777 38.172-79.051 136.66-127.91-74.352zm9.351 521.72c-38.172 0-69.645-31.477-69.645-69.648 0-10.719 4.703-28.82 11.402-40.195l21.41-38.172h158.76v148.02h-121.92zm127.29-525.76c-10.036-17.391-23.434-29.477-39.512-36.18h164.75c14.738 0 26.113 6.047 32.84 17.445l40.852 69.648 44.191-26.141-71.016 127.28-145.3-2.047 44.871-25.43zm253.86 379.09c20.07 0 36.832-5.359 50.887-16.055l-83.07 144.65c-6.699 11.375-18.73 18.078-32.789 18.078h-78.395v51.57l-75.004-125.23 75.004-125.27v52.258h143.37zm64.258-120.56c6.043 10.719 9.406 22.094 9.406 34.156 0 24.117-15.422 49.57-36.832 61.602-10.062 5.391-24.145 8.75-38.172 8.75h-44.242l-79-136.66 127.92-73.008z' fill='#{hex-color($white)}'/></g></g></g><g fill='#{hex-color($solar-recycling)}'><g transform='translate(27.733 1347)'><g fill='#{hex-color($solar-recycling)}'><g fill='#{hex-color($solar-recycling)}'><path d='m99.777 446.09c-6.699 12.031-12.031 30.133-12.031 41.539 0 2.648 0 6.016 0.656 10.688l-83.726-143.99c-2.68-4.672-4.676-11.375-4.676-17.414 0-6.047 1.996-13.398 4.676-18.078l97.831-167.4-44.871-25.43 146.02-2.703 70.984 127.91-45.527-26.117zm64.313-405.86c12.715-22.125 33.496-34.18 58.926-34.18 27.48 0 48.918 12.742 64.312 38.828l22.777 38.172-21.415 39.593-127.91-74.352zm9.351 521.72c-38.172 0-69.645-31.477-69.645-69.648 0-10.719 4.703-28.82 11.402-40.195l21.41-38.172 40.453-1.5167v148.02l-3.6203 1.5167zm127.29-525.76c-10.036-17.391-23.434-29.477-39.512-36.18h164.75c14.738 0 26.113 6.047 32.84 17.445l110.62 189.47 44.191-26.141-71.016 127.28-145.3-2.047 44.871-25.43zm253.86 379.09c20.07 0 36.832-5.359 50.887-16.055l-83.07 144.65c-6.699 11.375-18.73 18.078-32.789 18.078l-196.7-1.5167v51.57l-75.004-125.23 75.004-125.27v52.258l261.67 1.5167zm64.258-120.56c6.043 10.719 9.406 22.094 9.406 34.156 0 24.117-15.422 49.57-36.832 61.602-10.062 5.391-24.145 8.75-38.172 8.75h-44.242l-9.2306-16.843 127.92-73.008z' fill='#{hex-color($solar-recycling)}'/></g></g></g><g transform='translate(691.01 5483.9)'><g fill='#{hex-color($solar-recycling)}'/></g><g transform='translate(30.324 2009.8)'><g fill='#{hex-color($solar-recycling)}'><g fill='#{hex-color($solar-recycling)}'><path d='m99.777 446.09c-6.699 12.031-12.031 30.133-12.031 41.539 0 2.648 0 6.016 0.656 10.688l-83.726-143.99c-2.68-4.672-4.676-11.375-4.676-17.414 0-6.047 1.996-13.398 4.676-18.078l11.377-17.243-44.871-25.43 146.02-2.703 70.984 127.91-45.527-26.117zm64.313-405.86c12.715-22.125 33.496-34.18 58.926-34.18 27.48 0 48.918 12.742 64.312 38.828l22.777 38.172-107.87 189.75-127.91-74.352zm9.351 521.72c-38.172 0-69.645-31.477-69.645-69.648 0-10.719 4.703-28.82 11.402-40.195l21.41-38.172h216.39v148.02h-179.56zm127.29-525.76c-10.036-17.391-23.434-29.477-39.512-36.18h164.75c14.738 0 26.113 6.047 32.84 17.445l13.551 2.912 44.191-26.141-71.016 127.28-145.3-2.047 44.871-25.43zm253.86 379.09c20.07 0 36.832-5.359 50.887-16.055l-83.07 144.65c-6.699 11.375-18.73 18.078-32.789 18.078h-20.759v51.57l-75.004-125.23 75.004-125.27v52.258h85.731zm64.258-120.56c6.043 10.719 9.406 22.094 9.406 34.156 0 24.117-15.422 49.57-36.832 61.602-10.062 5.391-24.145 8.75-38.172 8.75h-44.242l-116.92-210.98 127.92-73.008z' fill='#{hex-color($solar-recycling)}'/></g></g></g><g transform='translate(27.915 2669.7)'><g fill='#{hex-color($solar-recycling)}'><g fill='#{hex-color($solar-recycling)}'><path d='m99.777 446.09c-6.699 12.031-12.031 30.133-12.031 41.539 0 2.648 0 6.016 0.656 10.688l-83.726-143.99c-2.68-4.672-4.676-11.375-4.676-17.414 0-6.047 1.996-13.398 4.676-18.078l40.195-70.328-44.871-25.43 146.02-2.703 70.984 127.91-45.527-26.117zm64.313-405.86c12.715-22.125 33.496-34.18 58.926-34.18 27.48 0 48.918 12.742 64.312 38.828l22.777 38.172-79.051 136.66-127.91-74.352zm9.351 521.72c-38.172 0-69.645-31.477-69.645-69.648 0-10.719 4.703-28.82 11.402-40.195l21.41-38.172h158.76v148.02h-121.92zm127.29-525.76c-10.036-17.391-23.434-29.477-39.512-36.18h164.75c14.738 0 26.113 6.047 32.84 17.445l40.852 69.648 44.191-26.141-71.016 127.28-145.3-2.047 44.871-25.43zm253.86 379.09c20.07 0 36.832-5.359 50.887-16.055l-83.07 144.65c-6.699 11.375-18.73 18.078-32.789 18.078h-78.395v51.57l-75.004-125.23 75.004-125.27v52.258h143.37zm64.258-120.56c6.043 10.719 9.406 22.094 9.406 34.156 0 24.117-15.422 49.57-36.832 61.602-10.062 5.391-24.145 8.75-38.172 8.75h-44.242l-79-136.66 127.92-73.008z' fill='#{hex-color($solar-recycling)}'/></g></g></g></g><g transform='translate(29.186 687.36)' fill='#{hex-color($white)}'><g fill='#{hex-color($white)}'><g fill='#{hex-color($white)}'><path d='m99.777 446.09c-6.699 12.031-12.031 30.133-12.031 41.539 0 2.648 0 6.016 0.656 10.688l-83.726-143.99c-2.68-4.672-4.676-11.375-4.676-17.414 0-6.047 1.996-13.398 4.676-18.078l76.66-134.68-44.871-25.43 146.02-2.703 70.984 127.91-45.527-26.117zm64.313-405.86c12.715-22.125 33.496-34.18 58.926-34.18 27.48 0 48.918 12.742 64.312 38.828l22.777 38.172-42.586 72.315-127.91-74.352zm9.351 521.72c-38.172 0-69.645-31.477-69.645-69.648 0-10.719 4.703-28.82 11.402-40.195l21.41-38.172h111.57v148.02h-74.736zm127.29-525.76c-10.036-17.391-23.434-29.477-39.512-36.18h164.75c14.738 0 26.113 6.047 32.84 17.445l73.027 129.71 44.191-26.141-71.016 127.28-145.3-2.047 44.871-25.43zm253.86 379.09c20.07 0 36.832-5.359 50.887-16.055l-83.07 144.65c-6.699 11.375-18.73 18.078-32.789 18.078h-125.58v51.57l-75.004-125.23 75.004-125.27v52.258h190.56zm64.258-120.56c6.043 10.719 9.406 22.094 9.406 34.156 0 24.117-15.422 49.57-36.832 61.602-10.062 5.391-24.145 8.75-38.172 8.75h-44.242l-46.825-76.605 127.92-73.008z' fill='#{hex-color($solar-recycling)}'/></g></g></g></g></svg>") no-repeat; + background-position: 50% 100%; + } + + i.fa-star { + background-position: 50% 100%; + } +} + +button.icon-button { + &:hover i.fa-retweet, + &.reblogPrivate.active i.fa-retweet, + &:hover.reblogPrivate.active i.fa-retweet, + i.fa-retweet { + background: url("data:image/svg+xml;utf8,<svg width='18.7' height='94' enable-background='new 0 0 628.254 613.516' version='1.1' viewBox='0 0 657.07 3289.9' xml:space='preserve' xmlns='http://www.w3.org/2000/svg'><g transform='translate(-.77735 -6.0669)'><g transform='translate(29.186 28.067)' fill='#{hex-color($action-button-color)}'><g fill='#{hex-color($action-button-color)}'><g fill='#{hex-color($action-button-color)}'><path d='m99.777 446.09c-6.699 12.031-12.031 30.133-12.031 41.539 0 2.648 0 6.016 0.656 10.688l-83.726-143.99c-2.68-4.672-4.676-11.375-4.676-17.414 0-6.047 1.996-13.398 4.676-18.078l40.195-70.328-44.871-25.43 146.02-2.703 70.984 127.91-45.527-26.117zm64.313-405.86c12.715-22.125 33.496-34.18 58.926-34.18 27.48 0 48.918 12.742 64.312 38.828l22.777 38.172-79.051 136.66-127.91-74.352zm9.351 521.72c-38.172 0-69.645-31.477-69.645-69.648 0-10.719 4.703-28.82 11.402-40.195l21.41-38.172h158.76v148.02h-121.92zm127.29-525.76c-10.036-17.391-23.434-29.477-39.512-36.18h164.75c14.738 0 26.113 6.047 32.84 17.445l40.852 69.648 44.191-26.141-71.016 127.28-145.3-2.047 44.871-25.43zm253.86 379.09c20.07 0 36.832-5.359 50.887-16.055l-83.07 144.65c-6.699 11.375-18.73 18.078-32.789 18.078h-78.395v51.57l-75.004-125.23 75.004-125.27v52.258h143.37zm64.258-120.56c6.043 10.719 9.406 22.094 9.406 34.156 0 24.117-15.422 49.57-36.832 61.602-10.062 5.391-24.145 8.75-38.172 8.75h-44.242l-79-136.66 127.92-73.008z' fill='#{hex-color($action-button-color)}'/></g></g></g><g fill='#{hex-color($solar-recycling)}'><g transform='translate(27.733 1347)'><g fill='#{hex-color($solar-recycling)}'><g fill='#{hex-color($solar-recycling)}'><path d='m99.777 446.09c-6.699 12.031-12.031 30.133-12.031 41.539 0 2.648 0 6.016 0.656 10.688l-83.726-143.99c-2.68-4.672-4.676-11.375-4.676-17.414 0-6.047 1.996-13.398 4.676-18.078l97.831-167.4-44.871-25.43 146.02-2.703 70.984 127.91-45.527-26.117zm64.313-405.86c12.715-22.125 33.496-34.18 58.926-34.18 27.48 0 48.918 12.742 64.312 38.828l22.777 38.172-21.415 39.593-127.91-74.352zm9.351 521.72c-38.172 0-69.645-31.477-69.645-69.648 0-10.719 4.703-28.82 11.402-40.195l21.41-38.172 40.453-1.5167v148.02l-3.6203 1.5167zm127.29-525.76c-10.036-17.391-23.434-29.477-39.512-36.18h164.75c14.738 0 26.113 6.047 32.84 17.445l110.62 189.47 44.191-26.141-71.016 127.28-145.3-2.047 44.871-25.43zm253.86 379.09c20.07 0 36.832-5.359 50.887-16.055l-83.07 144.65c-6.699 11.375-18.73 18.078-32.789 18.078l-196.7-1.5167v51.57l-75.004-125.23 75.004-125.27v52.258l261.67 1.5167zm64.258-120.56c6.043 10.719 9.406 22.094 9.406 34.156 0 24.117-15.422 49.57-36.832 61.602-10.062 5.391-24.145 8.75-38.172 8.75h-44.242l-9.2306-16.843 127.92-73.008z' fill='#{hex-color($solar-recycling)}'/></g></g></g><g transform='translate(691.01 5483.9)'><g fill='#{hex-color($solar-recycling)}'/></g><g transform='translate(30.324 2009.8)'><g fill='#{hex-color($solar-recycling)}'><g fill='#{hex-color($solar-recycling)}'><path d='m99.777 446.09c-6.699 12.031-12.031 30.133-12.031 41.539 0 2.648 0 6.016 0.656 10.688l-83.726-143.99c-2.68-4.672-4.676-11.375-4.676-17.414 0-6.047 1.996-13.398 4.676-18.078l11.377-17.243-44.871-25.43 146.02-2.703 70.984 127.91-45.527-26.117zm64.313-405.86c12.715-22.125 33.496-34.18 58.926-34.18 27.48 0 48.918 12.742 64.312 38.828l22.777 38.172-107.87 189.75-127.91-74.352zm9.351 521.72c-38.172 0-69.645-31.477-69.645-69.648 0-10.719 4.703-28.82 11.402-40.195l21.41-38.172h216.39v148.02h-179.56zm127.29-525.76c-10.036-17.391-23.434-29.477-39.512-36.18h164.75c14.738 0 26.113 6.047 32.84 17.445l13.551 2.912 44.191-26.141-71.016 127.28-145.3-2.047 44.871-25.43zm253.86 379.09c20.07 0 36.832-5.359 50.887-16.055l-83.07 144.65c-6.699 11.375-18.73 18.078-32.789 18.078h-20.759v51.57l-75.004-125.23 75.004-125.27v52.258h85.731zm64.258-120.56c6.043 10.719 9.406 22.094 9.406 34.156 0 24.117-15.422 49.57-36.832 61.602-10.062 5.391-24.145 8.75-38.172 8.75h-44.242l-116.92-210.98 127.92-73.008z' fill='#{hex-color($solar-recycling)}'/></g></g></g><g transform='translate(27.915 2669.7)'><g fill='#{hex-color($solar-recycling)}'><g fill='#{hex-color($solar-recycling)}'><path d='m99.777 446.09c-6.699 12.031-12.031 30.133-12.031 41.539 0 2.648 0 6.016 0.656 10.688l-83.726-143.99c-2.68-4.672-4.676-11.375-4.676-17.414 0-6.047 1.996-13.398 4.676-18.078l40.195-70.328-44.871-25.43 146.02-2.703 70.984 127.91-45.527-26.117zm64.313-405.86c12.715-22.125 33.496-34.18 58.926-34.18 27.48 0 48.918 12.742 64.312 38.828l22.777 38.172-79.051 136.66-127.91-74.352zm9.351 521.72c-38.172 0-69.645-31.477-69.645-69.648 0-10.719 4.703-28.82 11.402-40.195l21.41-38.172h158.76v148.02h-121.92zm127.29-525.76c-10.036-17.391-23.434-29.477-39.512-36.18h164.75c14.738 0 26.113 6.047 32.84 17.445l40.852 69.648 44.191-26.141-71.016 127.28-145.3-2.047 44.871-25.43zm253.86 379.09c20.07 0 36.832-5.359 50.887-16.055l-83.07 144.65c-6.699 11.375-18.73 18.078-32.789 18.078h-78.395v51.57l-75.004-125.23 75.004-125.27v52.258h143.37zm64.258-120.56c6.043 10.719 9.406 22.094 9.406 34.156 0 24.117-15.422 49.57-36.832 61.602-10.062 5.391-24.145 8.75-38.172 8.75h-44.242l-79-136.66 127.92-73.008z' fill='#{hex-color($solar-recycling)}'/></g></g></g></g><g transform='translate(29.186 687.36)' fill='#{hex-color($action-button-color)}'><g fill='#{hex-color($action-button-color)}'><g fill='#{hex-color($action-button-color)}'><path d='m99.777 446.09c-6.699 12.031-12.031 30.133-12.031 41.539 0 2.648 0 6.016 0.656 10.688l-83.726-143.99c-2.68-4.672-4.676-11.375-4.676-17.414 0-6.047 1.996-13.398 4.676-18.078l76.66-134.68-44.871-25.43 146.02-2.703 70.984 127.91-45.527-26.117zm64.313-405.86c12.715-22.125 33.496-34.18 58.926-34.18 27.48 0 48.918 12.742 64.312 38.828l22.777 38.172-42.586 72.315-127.91-74.352zm9.351 521.72c-38.172 0-69.645-31.477-69.645-69.648 0-10.719 4.703-28.82 11.402-40.195l21.41-38.172h111.57v148.02h-74.736zm127.29-525.76c-10.036-17.391-23.434-29.477-39.512-36.18h164.75c14.738 0 26.113 6.047 32.84 17.445l73.027 129.71 44.191-26.141-71.016 127.28-145.3-2.047 44.871-25.43zm253.86 379.09c20.07 0 36.832-5.359 50.887-16.055l-83.07 144.65c-6.699 11.375-18.73 18.078-32.789 18.078h-125.58v51.57l-75.004-125.23 75.004-125.27v52.258h190.56zm64.258-120.56c6.043 10.719 9.406 22.094 9.406 34.156 0 24.117-15.422 49.57-36.832 61.602-10.062 5.391-24.145 8.75-38.172 8.75h-44.242l-46.825-76.605 127.92-73.008z' fill='#{hex-color($solar-recycling)}'/></g></g></g></g></svg>") no-repeat; + width: 19px; + height: 18.5px; + transition: background-position .6s steps(4); + } + + &.reblogPrivate:hover i.fa-retweet, + &.reblogPrivate i.fa-retweet { + background: url("data:image/svg+xml;utf8,<svg width='19' height='19' enable-background='new 0 0 628.254 613.516' version='1.1' viewBox='0 0 630.25 615.51' xml:space='preserve' xmlns='http://www.w3.org/2000/svg'><defs><mask id='mask-powermask-path-effect929' maskUnits='userSpaceOnUse'><path id='mask-powermask-path-effect929_box' d='m-1-1h630.25v615.51h-630.25z' fill='%23fff'/><g transform='matrix(1.8197 0 0 1.8197 467.68 255.09)' style=''><rect x='-219.52' y='91.198' width='114.31' height='71.892' fill='none' stroke='%23000' stroke-width='34.869'/><path d='m-211.31 76.273c0-2.2289-0.45078-28.654-0.20046-30.833 1.9095-16.621 12.096-32.827 25.749-41.276 15.443-9.5572 34.438-9.4492 49.787 0.28295 13.762 8.7258 22.704 23.991 24.271 40.915 0.18075 1.9519-0.0259 28.68-0.0453 30.668' fill='none' stroke='%23000' stroke-width='28.408'/></g></mask></defs><g transform='translate(-28.186 -5.0669)'><g transform='translate(29.186 6.0669)' fill='#{hex-color($action-button-color)}' mask='url(%23mask-powermask-path-effect929)'><path d='m99.777 446.09c-6.699 12.031-12.031 30.133-12.031 41.539 0 2.648 0 6.016 0.656 10.688l-83.726-143.99c-2.68-4.672-4.676-11.375-4.676-17.414 0-6.047 1.996-13.398 4.676-18.078l40.195-70.328-44.871-25.43 146.02-2.703 70.984 127.91-45.527-26.117zm64.313-405.86c12.715-22.125 33.496-34.18 58.926-34.18 27.48 0 48.918 12.742 64.312 38.828l22.777 38.172-79.051 136.66-127.91-74.352zm9.351 521.72c-38.172 0-69.645-31.477-69.645-69.648 0-10.719 4.703-28.82 11.402-40.195l21.41-38.172h158.76v148.02h-121.92zm127.29-525.76c-10.036-17.391-23.434-29.477-39.512-36.18h164.75c14.738 0 26.113 6.047 32.84 17.445l40.852 69.648 44.191-26.141-71.016 127.28-145.3-2.047 44.871-25.43zm253.86 379.09c20.07 0 36.832-5.359 50.887-16.055l-83.07 144.65c-6.699 11.375-18.73 18.078-32.789 18.078h-78.395v51.57l-75.004-125.23 75.004-125.27v52.258h143.37zm64.258-120.56c6.043 10.719 9.406 22.094 9.406 34.156 0 24.117-15.422 49.57-36.832 61.602-10.062 5.391-24.145 8.75-38.172 8.75h-44.242l-79-136.66 127.92-73.008z' fill='#{hex-color($action-button-color)}' mask='none'/></g><path d='m28.186 5.0669h630.25v615.51h-630.25z' fill='none'/><g transform='translate(691.01 5483.9)' fill='%23009700'><g fill='%23009700'/></g><g transform='matrix(1.8197 0 0 1.8197 537.9 285.64)' stroke='#{hex-color($action-button-color)}'><rect x='-219.52' y='91.198' width='114.31' height='71.892' fill='#{hex-color($action-button-color)}' stroke-width='34.869'/><path d='m-211.31 76.273c0-2.2289-0.45078-28.654-0.20046-30.833 1.9095-16.621 12.096-32.827 25.749-41.276 15.443-9.5572 34.438-9.4492 49.787 0.28295 13.762 8.7258 22.704 23.991 24.271 40.915 0.18075 1.9519-0.0259 28.68-0.0453 30.668' fill='none' stroke-width='28.408'/></g></g></svg>"); + } + + &.reblogPrivate.active i.fa-retweet, + &:hover.reblogPrivate.active i.fa-retweet { + background-position: bottom; + } + + &:hover i.fa-star, + i.fa-star { + background: url("data:image/svg+xml;utf8,<svg width='6.299mm' height='28.591mm' version='1.1' viewBox='0 0 4.9132 22.301' xmlns='http://www.w3.org/2000/svg'><g transform='translate(-72.638 -93.656)'><g transform='translate(.47668 5.3239)'><path transform='matrix(.04592 .26057 -.26057 .04592 76.244 95.826)' d='m-0.09375 11.156-3.1493-2.2515-1.9189 3.3623-0.63485-3.819-3.7344 1.0206 2.2515-3.1493-3.3623-1.9189 3.819-0.63485-1.0206-3.7344 3.1493 2.2515 1.9189-3.3623 0.63485 3.819 3.7344-1.0206-2.2515 3.1493 3.3623 1.9189-3.819 0.63485z' fill='%23ffb271'/><g fill='%23ff8e7c'><path d='m74.63 92.871-0.44504 1.5146 0.31204 0.77128 0.57329-0.77889z' stroke-width='1.0644'/><path d='m74.205 95.719 0.41002-0.27757 0.41153 0.30391-0.37017 1.3919' stroke-width='1.1036'/><path d='m72.271 95.052 1.6527 0.40144 0.90943-0.37981-0.85337-0.45872z'/><path d='m76.865 95.04-1.5894-0.43145-0.8651 0.42185 0.90715 0.47274z'/></g><circle cx='74.637' cy='95.054' r='.86816' fill='%23f9e59a' stroke-width='.20427'/></g><g transform='translate(.025125 .41834)'><g transform='translate(.49227 .42869)'><path transform='matrix(.04592 .26057 -.26057 .04592 76.244 95.826)' d='m-0.09375 11.156-3.0678-2.4605c-1.0077-0.58067-1.6372-0.66239-2.5754-0.46052l-3.7943 1.2335 2.2515-3.1493-3.3623-1.9189 3.819-0.63485-1.0206-3.7344 3.1493 2.2515c1.573 0.58073 1.5464 0.26082 2.5538 0.4567l3.7344-1.0206-2.2515 3.1493c-1.1607 1.0484-0.49695 1.609-0.4567 2.5538z' fill='#{hex-color($action-button-color)}'/><g fill='#{hex-color($action-button-color-darker)}'><path d='m74.612 93.227-0.34726 1.0005 0.25632 0.51263 0.43822-0.52917z'/><path d='m74.249 95.856 0.27765-0.50436 0.42515 0.5209-0.34112 1.0253'/><path d='m72.802 95.054 1.0348 0.36811 0.59405-0.35157-0.59987-0.37789z'/><path d='m76.403 95.054-0.95912-0.339-0.66973 0.32246 0.66973 0.37207z'/></g><circle cx='74.637' cy='95.054' r='.86816' fill='#{hex-color($action-button-color-lighter)}' stroke-width='.20427'/></g></g><g transform='translate(.51611 14.249)'><path transform='matrix(.04592 .26057 -.26057 .04592 76.244 95.826)' d='m2.4281 13.801-6.2849-5.3807-1.3051 3.847-0.17157-4.0062-6.9762 2.8655 5.1741-5.3193-3.5064-1.4065 3.8314-0.094818-3.2175-7.6125 5.8984 5.7429 1.3543-3.5156 0.19915 3.8497 7.3536-2.8701-5.754 5.5572 3.6813 1.3297-4.0124-0.09844z' fill='%23ffb271'/><g fill='%23ff8e7c'><path d='m74.612 93.227-0.34726 1.0005 0.25632 0.51263 0.43822-0.52917z'/><path d='m74.249 95.856 0.27765-0.50436 0.42515 0.5209-0.34112 1.0253'/><path d='m72.802 95.054 0.95912 0.339 0.66973-0.32246-0.66973-0.37207z'/><path d='m76.403 95.054-0.95912-0.339-0.66973 0.32246 0.66973 0.37207z'/></g><circle cx='74.637' cy='95.054' r='.86816' fill='%23f9e59a' stroke-width='.20427'/></g><g transform='translate(.51271 18.705)'><path transform='matrix(.04592 .26057 -.26057 .04592 76.244 95.826)' d='m-0.09375 11.156-3.1493-2.2515-1.9189 3.3623-0.63485-3.819-3.7344 1.0206 2.2515-3.1493-3.3623-1.9189 3.819-0.63485-1.0206-3.7344 3.1493 2.2515 1.9189-3.3623 0.63485 3.819 3.7344-1.0206-2.2515 3.1493 3.3623 1.9189-3.819 0.63485z' fill='%23ffb271'/><g fill='%23ff8e7c'><path d='m74.612 93.227-0.34726 1.0005 0.25632 0.51263 0.43822-0.52917z'/><path d='m74.249 95.856 0.27765-0.50436 0.42515 0.5209-0.34112 1.0253'/><path d='m72.802 95.054 0.95912 0.339 0.66973-0.32246-0.66973-0.37207z'/><path d='m76.403 95.054-0.95912-0.339-0.66973 0.32246 0.66973 0.37207z'/></g><circle cx='74.637' cy='95.054' r='.86816' fill='%23f9e59a' stroke-width='.20427'/></g><g transform='translate(.50609 9.7746)'><path transform='matrix(.04592 .26057 -.26057 .04592 76.244 95.826)' d='m-0.09375 11.156-3.1493-2.2515-1.9189 3.3623-0.63485-3.819-3.7344 1.0206 2.2515-3.1493-3.3623-1.9189 3.819-0.63485-1.0206-3.7344 3.1493 2.2515 1.9189-3.3623 0.63485 3.819 3.7344-1.0206-2.2515 3.1493 3.3623 1.9189-3.819 0.63485z' fill='%23ffb271'/><g fill='%23ff8e7c'><path d='m74.612 93.227-0.34726 1.0005 0.25632 0.51263 0.43822-0.52917z'/><path d='m74.249 95.856 0.27765-0.50436 0.42515 0.5209-0.34112 1.0428'/><path d='m72.802 95.054 0.95912 0.339 0.66973-0.32246-0.66973-0.37207z'/><path d='m76.403 95.054-0.95912-0.339-0.66973 0.32246 0.66973 0.37207z'/></g><circle cx='74.637' cy='95.054' r='.86816' fill='%23f9e59a' stroke-width='.20427'/></g></g></svg>") no-repeat top; + height: 21.4px; + margin: 1px 0; + transition: background-position .6s steps(4); + } + + &.active i.fa-star { + background-position: 50% 100%; + } + + /* Remove the font-awesome icon */ + .fa-star::before { + content: ""; + } + + &.disabled { + i.fa-retweet, + &:hover i.fa-retweet { + background-image: url("data:image/svg+xml;utf8,<svg width='18.7' height='18.7' enable-background='new 0 0 628.254 613.516' version='1.1' viewBox='0 0 628.25 613.51' xml:space='preserve' xmlns='http://www.w3.org/2000/svg'><defs><mask id='mask5629' maskUnits='userSpaceOnUse'><rect x='-156.41' y='-66.33' width='941.77' height='797.17' fill='#{hex-color($white)}' stroke-width='35'/><g transform='matrix(.33928 .4906 -.82248 .5688 533.91 -40.843)' fill='#{hex-color($solar-disabled)}'><rect x='285' y='13.387' width='105.67' height='767.51' fill='#{hex-color($black)}' stroke-width='35'/></g></mask></defs><g transform='translate(-29.186 -6.0669)'><g transform='translate(29.186 6.0669)'><g fill='#{hex-color($solar-disabled)}' mask='url(%23mask5629)'><g fill='#{hex-color($solar-disabled)}' mask='none'><path d='m99.777 446.09c-6.699 12.031-12.031 30.133-12.031 41.539 0 2.648 0 6.016 0.656 10.688l-83.726-143.99c-2.68-4.672-4.676-11.375-4.676-17.414 0-6.047 1.996-13.398 4.676-18.078l40.195-70.328-44.871-25.43 146.02-2.703 70.984 127.91-45.527-26.117zm64.313-405.86c12.715-22.125 33.496-34.18 58.926-34.18 27.48 0 48.918 12.742 64.312 38.828l22.777 38.172-79.051 136.66-127.91-74.352zm9.351 521.72c-38.172 0-69.645-31.477-69.645-69.648 0-10.719 4.703-28.82 11.402-40.195l21.41-38.172h158.76v148.02h-121.92zm127.29-525.76c-10.036-17.391-23.434-29.477-39.512-36.18h164.75c14.738 0 26.113 6.047 32.84 17.445l40.852 69.648 44.191-26.141-71.016 127.28-145.3-2.047 44.871-25.43zm253.86 379.09c20.07 0 36.832-5.359 50.887-16.055l-83.07 144.65c-6.699 11.375-18.73 18.078-32.789 18.078h-78.395v51.57l-75.004-125.23 75.004-125.27v52.258h143.37zm64.258-120.56c6.043 10.719 9.406 22.094 9.406 34.156 0 24.117-15.422 49.57-36.832 61.602-10.062 5.391-24.145 8.75-38.172 8.75h-44.242l-79-136.66 127.92-73.008z' fill='#{hex-color($solar-disabled)}'/></g></g><rect transform='matrix(.57488 .81824 -.81993 .57247 0 0)' x='409.24' y='-521.16' width='66.549' height='882.4' ry='6.6154' fill='#{hex-color($solar-disabled)}' stroke-width='46.779'/></g><g transform='translate(691.01 5483.9)'></g></g></svg>"); + } + } + + &.active i.fa-retweet { + background-position: 50% 100%; + } +} + +.no-reduce-motion .icon-button.star-icon { + &.deactivate > .fa-star, + &.activate > .fa-star { + animation: none; + -webkit-animation: none; + } +} + +.detailed-status__link { + .fa-star { + background: url("data:image/svg+xml;utf8,<svg width='5mm' height='20mm' version='1.1' viewBox='0 0 4.9132 22.301' xmlns='http://www.w3.org/2000/svg'><g transform='translate(-72.638 -93.656)'><g transform='translate(.47668 5.3239)'><path transform='matrix(.04592 .26057 -.26057 .04592 76.244 95.826)' d='m-0.09375 11.156-3.1493-2.2515-1.9189 3.3623-0.63485-3.819-3.7344 1.0206 2.2515-3.1493-3.3623-1.9189 3.819-0.63485-1.0206-3.7344 3.1493 2.2515 1.9189-3.3623 0.63485 3.819 3.7344-1.0206-2.2515 3.1493 3.3623 1.9189-3.819 0.63485z' fill='%23ffb271'/><g fill='%23ff8e7c'><path d='m74.63 92.871-0.44504 1.5146 0.31204 0.77128 0.57329-0.77889z' stroke-width='1.0644'/><path d='m74.205 95.719 0.41002-0.27757 0.41153 0.30391-0.37017 1.3919' stroke-width='1.1036'/><path d='m72.271 95.052 1.6527 0.40144 0.90943-0.37981-0.85337-0.45872z'/><path d='m76.865 95.04-1.5894-0.43145-0.8651 0.42185 0.90715 0.47274z'/></g><circle cx='74.637' cy='95.054' r='.86816' fill='%23f9e59a' stroke-width='.20427'/></g><g transform='translate(.025125 .41834)'><g transform='translate(.49227 .42869)'><path transform='matrix(.04592 .26057 -.26057 .04592 76.244 95.826)' d='m-0.09375 11.156-3.0678-2.4605c-1.0077-0.58067-1.6372-0.66239-2.5754-0.46052l-3.7943 1.2335 2.2515-3.1493-3.3623-1.9189 3.819-0.63485-1.0206-3.7344 3.1493 2.2515c1.573 0.58073 1.5464 0.26082 2.5538 0.4567l3.7344-1.0206-2.2515 3.1493c-1.1607 1.0484-0.49695 1.609-0.4567 2.5538z' fill='#{hex-color($action-button-color)}'/><g fill='#{hex-color($action-button-color-darker)}'><path d='m74.612 93.227-0.34726 1.0005 0.25632 0.51263 0.43822-0.52917z'/><path d='m74.249 95.856 0.27765-0.50436 0.42515 0.5209-0.34112 1.0253'/><path d='m72.802 95.054 1.0348 0.36811 0.59405-0.35157-0.59987-0.37789z'/><path d='m76.403 95.054-0.95912-0.339-0.66973 0.32246 0.66973 0.37207z'/></g><circle cx='74.637' cy='95.054' r='.86816' fill='#{hex-color($action-button-color-lighter)}' stroke-width='.20427'/></g></g><g transform='translate(.51611 14.249)'><path transform='matrix(.04592 .26057 -.26057 .04592 76.244 95.826)' d='m2.4281 13.801-6.2849-5.3807-1.3051 3.847-0.17157-4.0062-6.9762 2.8655 5.1741-5.3193-3.5064-1.4065 3.8314-0.094818-3.2175-7.6125 5.8984 5.7429 1.3543-3.5156 0.19915 3.8497 7.3536-2.8701-5.754 5.5572 3.6813 1.3297-4.0124-0.09844z' fill='%23ffb271'/><g fill='%23ff8e7c'><path d='m74.612 93.227-0.34726 1.0005 0.25632 0.51263 0.43822-0.52917z'/><path d='m74.249 95.856 0.27765-0.50436 0.42515 0.5209-0.34112 1.0253'/><path d='m72.802 95.054 0.95912 0.339 0.66973-0.32246-0.66973-0.37207z'/><path d='m76.403 95.054-0.95912-0.339-0.66973 0.32246 0.66973 0.37207z'/></g><circle cx='74.637' cy='95.054' r='.86816' fill='%23f9e59a' stroke-width='.20427'/></g><g transform='translate(.51271 18.705)'><path transform='matrix(.04592 .26057 -.26057 .04592 76.244 95.826)' d='m-0.09375 11.156-3.1493-2.2515-1.9189 3.3623-0.63485-3.819-3.7344 1.0206 2.2515-3.1493-3.3623-1.9189 3.819-0.63485-1.0206-3.7344 3.1493 2.2515 1.9189-3.3623 0.63485 3.819 3.7344-1.0206-2.2515 3.1493 3.3623 1.9189-3.819 0.63485z' fill='%23ffb271'/><g fill='%23ff8e7c'><path d='m74.612 93.227-0.34726 1.0005 0.25632 0.51263 0.43822-0.52917z'/><path d='m74.249 95.856 0.27765-0.50436 0.42515 0.5209-0.34112 1.0253'/><path d='m72.802 95.054 0.95912 0.339 0.66973-0.32246-0.66973-0.37207z'/><path d='m76.403 95.054-0.95912-0.339-0.66973 0.32246 0.66973 0.37207z'/></g><circle cx='74.637' cy='95.054' r='.86816' fill='%23f9e59a' stroke-width='.20427'/></g><g transform='translate(.50609 9.7746)'><path transform='matrix(.04592 .26057 -.26057 .04592 76.244 95.826)' d='m-0.09375 11.156-3.1493-2.2515-1.9189 3.3623-0.63485-3.819-3.7344 1.0206 2.2515-3.1493-3.3623-1.9189 3.819-0.63485-1.0206-3.7344 3.1493 2.2515 1.9189-3.3623 0.63485 3.819 3.7344-1.0206-2.2515 3.1493 3.3623 1.9189-3.819 0.63485z' fill='%23ffb271'/><g fill='%23ff8e7c'><path d='m74.612 93.227-0.34726 1.0005 0.25632 0.51263 0.43822-0.52917z'/><path d='m74.249 95.856 0.27765-0.50436 0.42515 0.5209-0.34112 1.0428'/><path d='m72.802 95.054 0.95912 0.339 0.66973-0.32246-0.66973-0.37207z'/><path d='m76.403 95.054-0.95912-0.339-0.66973 0.32246 0.66973 0.37207z'/></g><circle cx='74.637' cy='95.054' r='.86816' fill='%23f9e59a' stroke-width='.20427'/></g></g></svg>") no-repeat top; + width: 12px; + height: 13px; + margin-bottom: -1px; + + &::before { + content: ""; + } + } + + .fa-retweet { + background: url("data:image/svg+xml;utf8,<svg width='3.5mm' height='70' enable-background='new 0 0 628.254 613.516' version='1.1' viewBox='0 0 657.07 3289.9' xml:space='preserve' xmlns='http://www.w3.org/2000/svg'><g transform='translate(-.77735 -6.0669)'><g transform='translate(29.186 28.067)' fill='#{hex-color($action-button-color)}'><g fill='#{hex-color($action-button-color)}'><g fill='#{hex-color($action-button-color)}'><path d='m99.777 446.09c-6.699 12.031-12.031 30.133-12.031 41.539 0 2.648 0 6.016 0.656 10.688l-83.726-143.99c-2.68-4.672-4.676-11.375-4.676-17.414 0-6.047 1.996-13.398 4.676-18.078l40.195-70.328-44.871-25.43 146.02-2.703 70.984 127.91-45.527-26.117zm64.313-405.86c12.715-22.125 33.496-34.18 58.926-34.18 27.48 0 48.918 12.742 64.312 38.828l22.777 38.172-79.051 136.66-127.91-74.352zm9.351 521.72c-38.172 0-69.645-31.477-69.645-69.648 0-10.719 4.703-28.82 11.402-40.195l21.41-38.172h158.76v148.02h-121.92zm127.29-525.76c-10.036-17.391-23.434-29.477-39.512-36.18h164.75c14.738 0 26.113 6.047 32.84 17.445l40.852 69.648 44.191-26.141-71.016 127.28-145.3-2.047 44.871-25.43zm253.86 379.09c20.07 0 36.832-5.359 50.887-16.055l-83.07 144.65c-6.699 11.375-18.73 18.078-32.789 18.078h-78.395v51.57l-75.004-125.23 75.004-125.27v52.258h143.37zm64.258-120.56c6.043 10.719 9.406 22.094 9.406 34.156 0 24.117-15.422 49.57-36.832 61.602-10.062 5.391-24.145 8.75-38.172 8.75h-44.242l-79-136.66 127.92-73.008z' fill='#{hex-color($action-button-color)}'/></g></g></g><g fill='#{hex-color($solar-recycling)}'><g transform='translate(27.733 1347)'><g fill='#{hex-color($solar-recycling)}'><g fill='#{hex-color($solar-recycling)}'><path d='m99.777 446.09c-6.699 12.031-12.031 30.133-12.031 41.539 0 2.648 0 6.016 0.656 10.688l-83.726-143.99c-2.68-4.672-4.676-11.375-4.676-17.414 0-6.047 1.996-13.398 4.676-18.078l97.831-167.4-44.871-25.43 146.02-2.703 70.984 127.91-45.527-26.117zm64.313-405.86c12.715-22.125 33.496-34.18 58.926-34.18 27.48 0 48.918 12.742 64.312 38.828l22.777 38.172-21.415 39.593-127.91-74.352zm9.351 521.72c-38.172 0-69.645-31.477-69.645-69.648 0-10.719 4.703-28.82 11.402-40.195l21.41-38.172 40.453-1.5167v148.02l-3.6203 1.5167zm127.29-525.76c-10.036-17.391-23.434-29.477-39.512-36.18h164.75c14.738 0 26.113 6.047 32.84 17.445l110.62 189.47 44.191-26.141-71.016 127.28-145.3-2.047 44.871-25.43zm253.86 379.09c20.07 0 36.832-5.359 50.887-16.055l-83.07 144.65c-6.699 11.375-18.73 18.078-32.789 18.078l-196.7-1.5167v51.57l-75.004-125.23 75.004-125.27v52.258l261.67 1.5167zm64.258-120.56c6.043 10.719 9.406 22.094 9.406 34.156 0 24.117-15.422 49.57-36.832 61.602-10.062 5.391-24.145 8.75-38.172 8.75h-44.242l-9.2306-16.843 127.92-73.008z' fill='#{hex-color($solar-recycling)}'/></g></g></g><g transform='translate(691.01 5483.9)'><g fill='#{hex-color($solar-recycling)}'/></g><g transform='translate(30.324 2009.8)'><g fill='#{hex-color($solar-recycling)}'><g fill='#{hex-color($solar-recycling)}'><path d='m99.777 446.09c-6.699 12.031-12.031 30.133-12.031 41.539 0 2.648 0 6.016 0.656 10.688l-83.726-143.99c-2.68-4.672-4.676-11.375-4.676-17.414 0-6.047 1.996-13.398 4.676-18.078l11.377-17.243-44.871-25.43 146.02-2.703 70.984 127.91-45.527-26.117zm64.313-405.86c12.715-22.125 33.496-34.18 58.926-34.18 27.48 0 48.918 12.742 64.312 38.828l22.777 38.172-107.87 189.75-127.91-74.352zm9.351 521.72c-38.172 0-69.645-31.477-69.645-69.648 0-10.719 4.703-28.82 11.402-40.195l21.41-38.172h216.39v148.02h-179.56zm127.29-525.76c-10.036-17.391-23.434-29.477-39.512-36.18h164.75c14.738 0 26.113 6.047 32.84 17.445l13.551 2.912 44.191-26.141-71.016 127.28-145.3-2.047 44.871-25.43zm253.86 379.09c20.07 0 36.832-5.359 50.887-16.055l-83.07 144.65c-6.699 11.375-18.73 18.078-32.789 18.078h-20.759v51.57l-75.004-125.23 75.004-125.27v52.258h85.731zm64.258-120.56c6.043 10.719 9.406 22.094 9.406 34.156 0 24.117-15.422 49.57-36.832 61.602-10.062 5.391-24.145 8.75-38.172 8.75h-44.242l-116.92-210.98 127.92-73.008z' fill='#{hex-color($solar-recycling)}'/></g></g></g><g transform='translate(27.915 2669.7)'><g fill='#{hex-color($solar-recycling)}'><g fill='#{hex-color($solar-recycling)}'><path d='m99.777 446.09c-6.699 12.031-12.031 30.133-12.031 41.539 0 2.648 0 6.016 0.656 10.688l-83.726-143.99c-2.68-4.672-4.676-11.375-4.676-17.414 0-6.047 1.996-13.398 4.676-18.078l40.195-70.328-44.871-25.43 146.02-2.703 70.984 127.91-45.527-26.117zm64.313-405.86c12.715-22.125 33.496-34.18 58.926-34.18 27.48 0 48.918 12.742 64.312 38.828l22.777 38.172-79.051 136.66-127.91-74.352zm9.351 521.72c-38.172 0-69.645-31.477-69.645-69.648 0-10.719 4.703-28.82 11.402-40.195l21.41-38.172h158.76v148.02h-121.92zm127.29-525.76c-10.036-17.391-23.434-29.477-39.512-36.18h164.75c14.738 0 26.113 6.047 32.84 17.445l40.852 69.648 44.191-26.141-71.016 127.28-145.3-2.047 44.871-25.43zm253.86 379.09c20.07 0 36.832-5.359 50.887-16.055l-83.07 144.65c-6.699 11.375-18.73 18.078-32.789 18.078h-78.395v51.57l-75.004-125.23 75.004-125.27v52.258h143.37zm64.258-120.56c6.043 10.719 9.406 22.094 9.406 34.156 0 24.117-15.422 49.57-36.832 61.602-10.062 5.391-24.145 8.75-38.172 8.75h-44.242l-79-136.66 127.92-73.008z' fill='#{hex-color($solar-recycling)}'/></g></g></g></g><g transform='translate(29.186 687.36)' fill='#{hex-color($action-button-color)}'><g fill='#{hex-color($action-button-color)}'><g fill='#{hex-color($action-button-color)}'><path d='m99.777 446.09c-6.699 12.031-12.031 30.133-12.031 41.539 0 2.648 0 6.016 0.656 10.688l-83.726-143.99c-2.68-4.672-4.676-11.375-4.676-17.414 0-6.047 1.996-13.398 4.676-18.078l76.66-134.68-44.871-25.43 146.02-2.703 70.984 127.91-45.527-26.117zm64.313-405.86c12.715-22.125 33.496-34.18 58.926-34.18 27.48 0 48.918 12.742 64.312 38.828l22.777 38.172-42.586 72.315-127.91-74.352zm9.351 521.72c-38.172 0-69.645-31.477-69.645-69.648 0-10.719 4.703-28.82 11.402-40.195l21.41-38.172h111.57v148.02h-74.736zm127.29-525.76c-10.036-17.391-23.434-29.477-39.512-36.18h164.75c14.738 0 26.113 6.047 32.84 17.445l73.027 129.71 44.191-26.141-71.016 127.28-145.3-2.047 44.871-25.43zm253.86 379.09c20.07 0 36.832-5.359 50.887-16.055l-83.07 144.65c-6.699 11.375-18.73 18.078-32.789 18.078h-125.58v51.57l-75.004-125.23 75.004-125.27v52.258h190.56zm64.258-120.56c6.043 10.719 9.406 22.094 9.406 34.156 0 24.117-15.422 49.57-36.832 61.602-10.062 5.391-24.145 8.75-38.172 8.75h-44.242l-46.825-76.605 127.92-73.008z' fill='#{hex-color($solar-recycling)}'/></g></g></g></g></svg>") no-repeat; + width: 13px; + height: 14px; + margin-bottom: -1px; + + &::before { + content: ""; + } + } +} + +.drawer .drawer__inner { + background: lighten($ui-base-color, 13%) url("data:image/svg+xml;utf8,<svg width='391.91mm' height='62.185mm' version='1.1' viewBox='0 0 391.91 62.185' xmlns='http://www.w3.org/2000/svg'><defs><clipPath id='clipPath1458'><g display='none'><rect x='20.515' y='65.617' width='3.914' height='73.915' d='m 20.514772,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/><rect x='60.408' y='65.617' width='3.914' height='73.915' d='m 60.408058,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/><rect x='33.813' y='65.617' width='3.914' height='73.915' d='m 33.812534,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/><rect x='47.11' y='65.617' width='3.914' height='73.915' d='m 47.110298,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/></g><path class='powerclip' d='m9.7406 68.901h65.371v66.185h-65.371zm10.774-3.2844v73.915h3.914v-73.915zm39.893 0v73.915h3.914v-73.915zm-26.596 0v73.915h3.914v-73.915zm13.298 0v73.915h3.914v-73.915z'/></clipPath><clipPath id='clipath_lpe_path-effect2057'><g display='none'><rect x='20.515' y='65.617' width='3.914' height='73.915' d='m 20.514772,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/><rect x='60.408' y='65.617' width='3.914' height='73.915' d='m 60.408058,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/><rect x='33.813' y='65.617' width='3.914' height='73.915' d='m 33.812534,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/><rect x='47.11' y='65.617' width='3.914' height='73.915' d='m 47.110298,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/></g><path class='powerclip' d='m9.7406 68.901h65.371v66.185h-65.371zm10.774-3.2844v73.915h3.914v-73.915zm39.893 0v73.915h3.914v-73.915zm-26.596 0v73.915h3.914v-73.915zm13.298 0v73.915h3.914v-73.915z'/></clipPath><clipPath id='clipath_lpe_path-effect2076'><g display='none'><rect x='20.515' y='65.617' width='3.914' height='73.915' d='m 20.514772,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/><rect x='60.408' y='65.617' width='3.914' height='73.915' d='m 60.408058,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/><rect x='33.813' y='65.617' width='3.914' height='73.915' d='m 33.812534,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/><rect x='47.11' y='65.617' width='3.914' height='73.915' d='m 47.110298,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/></g><path class='powerclip' d='m9.7406 68.901h65.371v66.185h-65.371zm10.774-3.2844v73.915h3.914v-73.915zm39.893 0v73.915h3.914v-73.915zm-26.596 0v73.915h3.914v-73.915zm13.298 0v73.915h3.914v-73.915z'/></clipPath><clipPath id='clipath_lpe_path-effect2095'><g display='none'><rect x='20.515' y='65.617' width='3.914' height='73.915' d='m 20.514772,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/><rect x='60.408' y='65.617' width='3.914' height='73.915' d='m 60.408058,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/><rect x='33.813' y='65.617' width='3.914' height='73.915' d='m 33.812534,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/><rect x='47.11' y='65.617' width='3.914' height='73.915' d='m 47.110298,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/></g><path class='powerclip' d='m9.7406 68.901h65.371v66.185h-65.371zm10.774-3.2844v73.915h3.914v-73.915zm39.893 0v73.915h3.914v-73.915zm-26.596 0v73.915h3.914v-73.915zm13.298 0v73.915h3.914v-73.915z'/></clipPath><clipPath id='clipath_lpe_path-effect2114'><g display='none'><rect x='20.515' y='65.617' width='3.914' height='73.915' d='m 20.514772,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/><rect x='60.408' y='65.617' width='3.914' height='73.915' d='m 60.408058,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/><rect x='33.813' y='65.617' width='3.914' height='73.915' d='m 33.812534,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/><rect x='47.11' y='65.617' width='3.914' height='73.915' d='m 47.110298,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/></g><path class='powerclip' d='m9.7406 68.901h65.371v66.185h-65.371zm10.774-3.2844v73.915h3.914v-73.915zm39.893 0v73.915h3.914v-73.915zm-26.596 0v73.915h3.914v-73.915zm13.298 0v73.915h3.914v-73.915z'/></clipPath><clipPath id='clipath_lpe_path-effect2133'><g display='none'><rect x='20.515' y='65.617' width='3.914' height='73.915' d='m 20.514772,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/><rect x='60.408' y='65.617' width='3.914' height='73.915' d='m 60.408058,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/><rect x='33.813' y='65.617' width='3.914' height='73.915' d='m 33.812534,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/><rect x='47.11' y='65.617' width='3.914' height='73.915' d='m 47.110298,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/></g><path class='powerclip' d='m9.7406 68.901h65.371v66.185h-65.371zm10.774-3.2844v73.915h3.914v-73.915zm39.893 0v73.915h3.914v-73.915zm-26.596 0v73.915h3.914v-73.915zm13.298 0v73.915h3.914v-73.915z'/></clipPath></defs><g transform='translate(-4.7406 -70.901)' fill='%2321234a' stroke='%2321234a' stroke-linejoin='bevel' stroke-width='10.222'><path x='19.851646' y='79.012375' width='45.14875' height='45.963497' d='m19.852 79.012h45.149v45.963h-45.149z' clip-path='url(%23clipPath1458)' style='paint-order:normal'/><path transform='translate(63.308)' x='19.851646' y='79.012375' width='45.14875' height='45.963497' d='m19.852 79.012h45.149v45.963h-45.149z' clip-path='url(%23clipath_lpe_path-effect2057)' style='paint-order:normal'/><path transform='translate(126.62)' x='19.851646' y='79.012375' width='45.14875' height='45.963497' d='m19.852 79.012h45.149v45.963h-45.149z' clip-path='url(%23clipath_lpe_path-effect2076)' style='paint-order:normal'/><path transform='translate(189.92)' x='19.851646' y='79.012375' width='45.14875' height='45.963497' d='m19.852 79.012h45.149v45.963h-45.149z' clip-path='url(%23clipath_lpe_path-effect2095)' style='paint-order:normal'/><path transform='translate(253.23)' x='19.851646' y='79.012375' width='45.14875' height='45.963497' d='m19.852 79.012h45.149v45.963h-45.149z' clip-path='url(%23clipath_lpe_path-effect2114)' style='paint-order:normal'/><path transform='translate(316.54)' x='19.851646' y='79.012375' width='45.14875' height='45.963497' d='m19.852 79.012h45.149v45.963h-45.149z' clip-path='url(%23clipath_lpe_path-effect2133)' style='paint-order:normal'/></g></svg>") 0 9px; + background-size: 100%; + + &.darker { + background: $ui-base-color; + } +} + +.drawer__inner__mastodon { + background-color: transparent !important; +} + +.single-column, +.auto-columns { + .column-header { + background: lighten($ui-base-color, 4%) url("data:image/svg+xml;utf8,<svg width='61.371mm' height='62.185mm' version='1.1' viewBox='0 0 61.371 62.185' xmlns='http://www.w3.org/2000/svg'><defs><clipPath id='clipPath1458'><g display='none'><rect x='20.515' y='65.617' width='3.914' height='73.915' d='m 20.514772,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/><rect x='60.408' y='65.617' width='3.914' height='73.915' d='m 60.408058,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/><rect x='33.813' y='65.617' width='3.914' height='73.915' d='m 33.812534,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/><rect x='47.11' y='65.617' width='3.914' height='73.915' d='m 47.110298,65.61702 h 3.91404 v 73.91547 h -3.91404 z' stroke-width='7.5232'/></g><path class='powerclip' d='m9.7406 68.901h65.371v66.185h-65.371zm10.774-3.2844v73.915h3.914v-73.915zm39.893 0v73.915h3.914v-73.915zm-26.596 0v73.915h3.914v-73.915zm13.298 0v73.915h3.914v-73.915z'/></clipPath></defs><g transform='translate(-11.741 -70.901)'><path x='19.851646' y='79.012375' width='45.14875' height='45.963497' d='m19.852 79.012h45.149v45.963h-45.149z' clip-path='url(%23clipPath1458)' fill='#{hex-color($solar-panel-header)}' stroke='#{hex-color($solar-panel-header)}' stroke-linejoin='bevel' stroke-width='10.222' style='paint-order:normal'/></g></svg>") 2px 2px; + background-size: 44.25px; + } + + .column-header__button, + .column-header__back-button { + background-color: transparent; + } +} diff --git a/app/javascript/skins/blobfox/solarpunk/names.yml b/app/javascript/skins/blobfox/solarpunk/names.yml new file mode 100644 index 00000000000000..dd6e2af12deb2b --- /dev/null +++ b/app/javascript/skins/blobfox/solarpunk/names.yml @@ -0,0 +1,4 @@ +en: + skins: + blobfox: + solarpunk: Solarpunk diff --git a/app/javascript/skins/blobfox/solarpunk/variables.scss b/app/javascript/skins/blobfox/solarpunk/variables.scss new file mode 100644 index 00000000000000..038f6d15bfca87 --- /dev/null +++ b/app/javascript/skins/blobfox/solarpunk/variables.scss @@ -0,0 +1,75 @@ +$black: #000000; +$white: #ffffff; +$success-green: #79bd9a; +$error-red: #df405a; +$warning-red: #ff5050; +$gold-star: #ca8f04; + +// Values from the classic Mastodon UI +$classic-base-color: #282c37; // Midnight Express +$classic-primary-color: #9baec8; // Echo Blue +$classic-secondary-color: #d9e1e8; // Pattens Blue +$classic-highlight-color: #2b90d9; // Summer Sky + +// our theme customizations +$solar-highlight-color: #378d2a; +$solar-bg: #060e0c; // dark green +$solar-recycling: #009700; +$solar-panel-header: #202c4d; + +// Variables for defaults in UI +$base-shadow-color: $black !default; +$base-overlay-background: $black !default; +$base-border-color: $white !default; +$simple-background-color: $white !default; +$valid-value-color: $success-green !default; +$error-value-color: $error-red !default; + +$ui-base-color: #161c2c; +$ui-base-lighter-color: #5370a2 !default; // Lighter darkest +$ui-primary-color: $classic-primary-color !default; +$ui-secondary-color: $classic-secondary-color !default; +$ui-highlight-color: $classic-highlight-color !default; + +$ui-highlight-color: $solar-highlight-color; + + +// Variables for texts +$primary-text-color: $white !default; +$darker-text-color: $ui-primary-color !default; +$dark-text-color: $ui-base-lighter-color !default; +$secondary-text-color: $ui-secondary-color !default; +$highlight-text-color: $ui-highlight-color !default; +$action-button-color: $ui-base-lighter-color !default; +$action-button-color-lighter: lighten($ui-base-lighter-color, 10%) !default; +$action-button-color-darker: darken($ui-base-lighter-color, 5%) !default; + +$passive-text-color: $gold-star !default; +$active-passive-text-color: $success-green !default; +// For texts on inverted backgrounds +$inverted-text-color: $ui-base-color !default; +$lighter-text-color: $ui-base-lighter-color !default; +$light-text-color: $ui-primary-color !default; + +$solar-disabled: darken($action-button-color, 10%) !default; + +// Language codes that uses CJK fonts +$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW; + +// Variables for components +$media-modal-media-max-width: 100%; +// put margins on top and bottom of image to avoid the screen covered by image. +$media-modal-media-max-height: 80%; + +$no-gap-breakpoint: 415px; + +$font-sans-serif: 'mastodon-font-sans-serif' !default; +$font-display: 'mastodon-font-display' !default; +$font-monospace: 'mastodon-font-monospace' !default; + +// Avatar border size (8% default, 100% for rounded avatars) +$ui-avatar-border-size: 8%; + +// More variables +$dismiss-overlay-width: 4rem; +