aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.js69
-rw-r--r--src/App.scss886
-rw-r--r--src/App.vue64
-rw-r--r--src/_mixins.scss17
-rw-r--r--src/_variables.scss2
-rw-r--r--src/assets/pleromatan_apology.pngbin0 -> 405742 bytes
-rw-r--r--src/assets/pleromatan_apology_fox.pngbin0 -> 533320 bytes
-rw-r--r--src/assets/pleromatan_apology_fox_mask.pngbin0 -> 2827 bytes
-rw-r--r--src/assets/pleromatan_apology_mask.pngbin0 -> 2366 bytes
-rw-r--r--src/boot/after_store.js62
-rw-r--r--src/boot/routes.js29
-rw-r--r--src/components/about/about.vue4
-rw-r--r--src/components/account_actions/account_actions.js9
-rw-r--r--src/components/account_actions/account_actions.vue16
-rw-r--r--src/components/announcement/announcement.js105
-rw-r--r--src/components/announcement/announcement.vue136
-rw-r--r--src/components/announcement_editor/announcement_editor.js13
-rw-r--r--src/components/announcement_editor/announcement_editor.vue60
-rw-r--r--src/components/announcements_page/announcements_page.js55
-rw-r--r--src/components/announcements_page/announcements_page.vue79
-rw-r--r--src/components/async_component_error/async_component_error.vue1
-rw-r--r--src/components/attachment/attachment.js115
-rw-r--r--src/components/attachment/attachment.scss268
-rw-r--r--src/components/attachment/attachment.vue534
-rw-r--r--src/components/auth_form/auth_form.js5
-rw-r--r--src/components/avatar_list/avatar_list.vue2
-rw-r--r--src/components/basic_user_card/basic_user_card.js16
-rw-r--r--src/components/basic_user_card/basic_user_card.vue40
-rw-r--r--src/components/bookmark_timeline/bookmark_timeline.js2
-rw-r--r--src/components/chat/chat.js107
-rw-r--r--src/components/chat/chat.scss88
-rw-r--r--src/components/chat/chat.vue125
-rw-r--r--src/components/chat/chat_layout_utils.js27
-rw-r--r--src/components/chat_list/chat_list.vue4
-rw-r--r--src/components/chat_list_item/chat_list_item.scss4
-rw-r--r--src/components/chat_message/chat_message.js9
-rw-r--r--src/components/chat_message/chat_message.scss11
-rw-r--r--src/components/chat_message/chat_message.vue16
-rw-r--r--src/components/chat_new/chat_new.scss8
-rw-r--r--src/components/chat_new/chat_new.vue7
-rw-r--r--src/components/chat_title/chat_title.js17
-rw-r--r--src/components/chat_title/chat_title.vue40
-rw-r--r--src/components/checkbox/checkbox.vue15
-rw-r--r--src/components/color_input/color_input.scss8
-rw-r--r--src/components/color_input/color_input.vue25
-rw-r--r--src/components/conversation/conversation.js396
-rw-r--r--src/components/conversation/conversation.vue277
-rw-r--r--src/components/desktop_nav/desktop_nav.js24
-rw-r--r--src/components/desktop_nav/desktop_nav.scss41
-rw-r--r--src/components/desktop_nav/desktop_nav.vue6
-rw-r--r--src/components/dialog_modal/dialog_modal.vue9
-rw-r--r--src/components/domain_mute_card/domain_mute_card.vue4
-rw-r--r--src/components/edit_status_modal/edit_status_modal.js75
-rw-r--r--src/components/edit_status_modal/edit_status_modal.vue48
-rw-r--r--src/components/emoji_input/emoji_input.js219
-rw-r--r--src/components/emoji_input/emoji_input.vue214
-rw-r--r--src/components/emoji_input/suggestor.js45
-rw-r--r--src/components/emoji_picker/emoji_picker.js314
-rw-r--r--src/components/emoji_picker/emoji_picker.scss65
-rw-r--r--src/components/emoji_picker/emoji_picker.vue196
-rw-r--r--src/components/emoji_reactions/emoji_reactions.vue84
-rw-r--r--src/components/exporter/exporter.js14
-rw-r--r--src/components/exporter/exporter.vue4
-rw-r--r--src/components/extra_buttons/extra_buttons.js54
-rw-r--r--src/components/extra_buttons/extra_buttons.vue90
-rw-r--r--src/components/favorite_button/favorite_button.js17
-rw-r--r--src/components/favorite_button/favorite_button.vue63
-rw-r--r--src/components/features_panel/features_panel.vue2
-rw-r--r--src/components/flash/flash.js7
-rw-r--r--src/components/flash/flash.vue28
-rw-r--r--src/components/follow_button/follow_button.js5
-rw-r--r--src/components/follow_button/follow_button.vue2
-rw-r--r--src/components/follow_card/follow_card.js4
-rw-r--r--src/components/follow_card/follow_card.vue12
-rw-r--r--src/components/font_control/font_control.js11
-rw-r--r--src/components/font_control/font_control.vue5
-rw-r--r--src/components/gallery/gallery.js97
-rw-r--r--src/components/gallery/gallery.vue201
-rw-r--r--src/components/global_notice_list/global_notice_list.vue15
-rw-r--r--src/components/hashtag_link/hashtag_link.vue4
-rw-r--r--src/components/image_cropper/image_cropper.js4
-rw-r--r--src/components/importer/importer.js21
-rw-r--r--src/components/importer/importer.vue28
-rw-r--r--src/components/instance_specific_panel/instance_specific_panel.vue2
-rw-r--r--src/components/interactions/interactions.js9
-rw-r--r--src/components/interactions/interactions.vue9
-rw-r--r--src/components/interface_language_switcher/interface_language_switcher.vue40
-rw-r--r--src/components/link-preview/link-preview.vue2
-rw-r--r--src/components/lists/lists.js27
-rw-r--r--src/components/lists/lists.vue33
-rw-r--r--src/components/lists_card/lists_card.js16
-rw-r--r--src/components/lists_card/lists_card.vue51
-rw-r--r--src/components/lists_edit/lists_edit.js145
-rw-r--r--src/components/lists_edit/lists_edit.vue228
-rw-r--r--src/components/lists_menu/lists_menu_content.js22
-rw-r--r--src/components/lists_menu/lists_menu_content.vue12
-rw-r--r--src/components/lists_timeline/lists_timeline.js36
-rw-r--r--src/components/lists_timeline/lists_timeline.vue10
-rw-r--r--src/components/lists_user_search/lists_user_search.js51
-rw-r--r--src/components/lists_user_search/lists_user_search.vue47
-rw-r--r--src/components/login_form/login_form.js2
-rw-r--r--src/components/login_form/login_form.vue16
-rw-r--r--src/components/media_modal/media_modal.js100
-rw-r--r--src/components/media_modal/media_modal.vue271
-rw-r--r--src/components/media_upload/media_upload.js5
-rw-r--r--src/components/media_upload/media_upload.vue10
-rw-r--r--src/components/mention_link/mention_link.js63
-rw-r--r--src/components/mention_link/mention_link.scss55
-rw-r--r--src/components/mention_link/mention_link.vue93
-rw-r--r--src/components/mentions_line/mentions_line.scss12
-rw-r--r--src/components/mentions_line/mentions_line.vue19
-rw-r--r--src/components/mfa_form/recovery_form.vue14
-rw-r--r--src/components/mfa_form/totp_form.vue14
-rw-r--r--src/components/mobile_nav/mobile_nav.js35
-rw-r--r--src/components/mobile_nav/mobile_nav.vue94
-rw-r--r--src/components/mobile_post_status_button/mobile_post_status_button.js7
-rw-r--r--src/components/mobile_post_status_button/mobile_post_status_button.vue58
-rw-r--r--src/components/modal/modal.vue7
-rw-r--r--src/components/moderation_tools/moderation_tools.js16
-rw-r--r--src/components/moderation_tools/moderation_tools.vue21
-rw-r--r--src/components/mrf_transparency_panel/mrf_transparency_panel.js6
-rw-r--r--src/components/nav_panel/nav_panel.js87
-rw-r--r--src/components/nav_panel/nav_panel.vue221
-rw-r--r--src/components/navigation/filter.js19
-rw-r--r--src/components/navigation/navigation.js82
-rw-r--r--src/components/navigation/navigation_entry.js51
-rw-r--r--src/components/navigation/navigation_entry.vue133
-rw-r--r--src/components/navigation/navigation_pins.js94
-rw-r--r--src/components/navigation/navigation_pins.vue74
-rw-r--r--src/components/notification/notification.js24
-rw-r--r--src/components/notification/notification.scss13
-rw-r--r--src/components/notification/notification.vue141
-rw-r--r--src/components/notifications/notification_filters.vue35
-rw-r--r--src/components/notifications/notifications.js74
-rw-r--r--src/components/notifications/notifications.scss44
-rw-r--r--src/components/notifications/notifications.vue141
-rw-r--r--src/components/opacity_input/opacity_input.vue13
-rw-r--r--src/components/optional_router_link/optional_router_link.vue23
-rw-r--r--src/components/password_reset/password_reset.vue10
-rw-r--r--src/components/pinch_zoom/pinch_zoom.js13
-rw-r--r--src/components/pinch_zoom/pinch_zoom.vue11
-rw-r--r--src/components/poll/poll.js2
-rw-r--r--src/components/poll/poll.vue19
-rw-r--r--src/components/poll/poll_form.vue3
-rw-r--r--src/components/popover/popover.js290
-rw-r--r--src/components/popover/popover.vue120
-rw-r--r--src/components/post_status_form/post_status_form.js100
-rw-r--r--src/components/post_status_form/post_status_form.vue181
-rw-r--r--src/components/public_and_external_timeline/public_and_external_timeline.js2
-rw-r--r--src/components/public_timeline/public_timeline.js2
-rw-r--r--src/components/quick_filter_settings/quick_filter_settings.js (renamed from src/components/timeline/timeline_quick_settings.js)17
-rw-r--r--src/components/quick_filter_settings/quick_filter_settings.vue (renamed from src/components/timeline/timeline_quick_settings.vue)66
-rw-r--r--src/components/quick_view_settings/quick_view_settings.js69
-rw-r--r--src/components/quick_view_settings/quick_view_settings.vue75
-rw-r--r--src/components/range_input/range_input.vue15
-rw-r--r--src/components/react_button/react_button.js95
-rw-r--r--src/components/react_button/react_button.vue59
-rw-r--r--src/components/registration/registration.js26
-rw-r--r--src/components/registration/registration.vue72
-rw-r--r--src/components/remote_follow/remote_follow.js2
-rw-r--r--src/components/remote_follow/remote_follow.vue2
-rw-r--r--src/components/remove_follower_button/remove_follower_button.js25
-rw-r--r--src/components/remove_follower_button/remove_follower_button.vue13
-rw-r--r--src/components/reply_button/reply_button.js15
-rw-r--r--src/components/reply_button/reply_button.vue45
-rw-r--r--src/components/report/report.js34
-rw-r--r--src/components/report/report.scss43
-rw-r--r--src/components/report/report.vue74
-rw-r--r--src/components/retweet_button/retweet_button.js17
-rw-r--r--src/components/retweet_button/retweet_button.vue63
-rw-r--r--src/components/rich_content/rich_content.jsx40
-rw-r--r--src/components/scope_selector/scope_selector.vue3
-rw-r--r--src/components/search/search.js43
-rw-r--r--src/components/search/search.vue42
-rw-r--r--src/components/search_bar/search_bar.js2
-rw-r--r--src/components/search_bar/search_bar.vue2
-rw-r--r--src/components/select/select.js7
-rw-r--r--src/components/select/select.vue16
-rw-r--r--src/components/selectable_list/selectable_list.vue12
-rw-r--r--src/components/settings_modal/helpers/boolean_setting.js24
-rw-r--r--src/components/settings_modal/helpers/boolean_setting.vue12
-rw-r--r--src/components/settings_modal/helpers/choice_setting.js18
-rw-r--r--src/components/settings_modal/helpers/choice_setting.vue12
-rw-r--r--src/components/settings_modal/helpers/integer_setting.js44
-rw-r--r--src/components/settings_modal/helpers/integer_setting.vue27
-rw-r--r--src/components/settings_modal/helpers/modified_indicator.vue14
-rw-r--r--src/components/settings_modal/helpers/server_side_indicator.vue51
-rw-r--r--src/components/settings_modal/helpers/shared_computed_object.js9
-rw-r--r--src/components/settings_modal/helpers/size_setting.js67
-rw-r--r--src/components/settings_modal/helpers/size_setting.vue54
-rw-r--r--src/components/settings_modal/settings_modal.js15
-rw-r--r--src/components/settings_modal/settings_modal.scss26
-rw-r--r--src/components/settings_modal/settings_modal.vue43
-rw-r--r--src/components/settings_modal/settings_modal_content.js9
-rw-r--r--src/components/settings_modal/settings_modal_content.vue1
-rw-r--r--src/components/settings_modal/tabs/data_import_export_tab.js29
-rw-r--r--src/components/settings_modal/tabs/data_import_export_tab.vue61
-rw-r--r--src/components/settings_modal/tabs/filtering_tab.js13
-rw-r--r--src/components/settings_modal/tabs/filtering_tab.vue169
-rw-r--r--src/components/settings_modal/tabs/general_tab.js60
-rw-r--r--src/components/settings_modal/tabs/general_tab.vue422
-rw-r--r--src/components/settings_modal/tabs/mutes_and_blocks_tab.js2
-rw-r--r--src/components/settings_modal/tabs/mutes_and_blocks_tab.scss2
-rw-r--r--src/components/settings_modal/tabs/mutes_and_blocks_tab.vue36
-rw-r--r--src/components/settings_modal/tabs/notifications_tab.js8
-rw-r--r--src/components/settings_modal/tabs/notifications_tab.vue86
-rw-r--r--src/components/settings_modal/tabs/profile_tab.js79
-rw-r--r--src/components/settings_modal/tabs/profile_tab.scss8
-rw-r--r--src/components/settings_modal/tabs/profile_tab.vue170
-rw-r--r--src/components/settings_modal/tabs/security_tab/mfa.js6
-rw-r--r--src/components/settings_modal/tabs/security_tab/mfa_totp.js2
-rw-r--r--src/components/settings_modal/tabs/security_tab/security_tab.js57
-rw-r--r--src/components/settings_modal/tabs/security_tab/security_tab.vue110
-rw-r--r--src/components/settings_modal/tabs/theme_tab/preview.vue14
-rw-r--r--src/components/settings_modal/tabs/theme_tab/theme_tab.js31
-rw-r--r--src/components/settings_modal/tabs/theme_tab/theme_tab.scss49
-rw-r--r--src/components/settings_modal/tabs/theme_tab/theme_tab.vue58
-rw-r--r--src/components/settings_modal/tabs/version_tab.vue2
-rw-r--r--src/components/shadow_control/shadow_control.js19
-rw-r--r--src/components/shadow_control/shadow_control.vue9
-rw-r--r--src/components/shout_panel/shout_panel.js2
-rw-r--r--src/components/shout_panel/shout_panel.vue23
-rw-r--r--src/components/side_drawer/side_drawer.js28
-rw-r--r--src/components/side_drawer/side_drawer.vue50
-rw-r--r--src/components/staff_panel/staff_panel.js8
-rw-r--r--src/components/staff_panel/staff_panel.vue2
-rw-r--r--src/components/status/status.js187
-rw-r--r--src/components/status/status.scss64
-rw-r--r--src/components/status/status.vue130
-rw-r--r--src/components/status_body/status_body.js18
-rw-r--r--src/components/status_body/status_body.scss58
-rw-r--r--src/components/status_body/status_body.vue21
-rw-r--r--src/components/status_content/status_content.js76
-rw-r--r--src/components/status_content/status_content.vue58
-rw-r--r--src/components/status_history_modal/status_history_modal.js60
-rw-r--r--src/components/status_history_modal/status_history_modal.vue46
-rw-r--r--src/components/status_popover/status_popover.js12
-rw-r--r--src/components/status_popover/status_popover.vue10
-rw-r--r--src/components/sticker_picker/sticker_picker.js6
-rw-r--r--src/components/still-image/still-image.js36
-rw-r--r--src/components/still-image/still-image.vue15
-rw-r--r--src/components/swipe_click/swipe_click.js84
-rw-r--r--src/components/swipe_click/swipe_click.vue14
-rw-r--r--src/components/tab_switcher/tab_switcher.jsx (renamed from src/components/tab_switcher/tab_switcher.js)75
-rw-r--r--src/components/tab_switcher/tab_switcher.scss11
-rw-r--r--src/components/tag_timeline/tag_timeline.js2
-rw-r--r--src/components/terms_of_service_panel/terms_of_service_panel.vue2
-rw-r--r--src/components/thread_tree/thread_tree.js90
-rw-r--r--src/components/thread_tree/thread_tree.vue135
-rw-r--r--src/components/timeago/timeago.vue23
-rw-r--r--src/components/timeline/timeline.js74
-rw-r--r--src/components/timeline/timeline.scss59
-rw-r--r--src/components/timeline/timeline.vue201
-rw-r--r--src/components/timeline_menu/timeline_menu.js40
-rw-r--r--src/components/timeline_menu/timeline_menu.vue71
-rw-r--r--src/components/timeline_menu/timeline_menu_content.js29
-rw-r--r--src/components/timeline_menu/timeline_menu_content.vue66
-rw-r--r--src/components/unicode_domain_indicator/unicode_domain_indicator.vue26
-rw-r--r--src/components/update_notification/update_notification.js69
-rw-r--r--src/components/update_notification/update_notification.scss113
-rw-r--r--src/components/update_notification/update_notification.vue103
-rw-r--r--src/components/user_avatar/user_avatar.js13
-rw-r--r--src/components/user_avatar/user_avatar.vue98
-rw-r--r--src/components/user_card/user_card.js51
-rw-r--r--src/components/user_card/user_card.scss352
-rw-r--r--src/components/user_card/user_card.vue399
-rw-r--r--src/components/user_link/user_link.vue38
-rw-r--r--src/components/user_list_menu/user_list_menu.js93
-rw-r--r--src/components/user_list_menu/user_list_menu.vue38
-rw-r--r--src/components/user_list_popover/user_list_popover.js10
-rw-r--r--src/components/user_list_popover/user_list_popover.vue19
-rw-r--r--src/components/user_note/user_note.js45
-rw-r--r--src/components/user_note/user_note.vue88
-rw-r--r--src/components/user_panel/user_panel.vue7
-rw-r--r--src/components/user_popover/user_popover.js23
-rw-r--r--src/components/user_popover/user_popover.vue33
-rw-r--r--src/components/user_profile/user_profile.js25
-rw-r--r--src/components/user_profile/user_profile.vue37
-rw-r--r--src/components/user_reporting_modal/user_reporting_modal.js16
-rw-r--r--src/components/user_reporting_modal/user_reporting_modal.vue29
-rw-r--r--src/components/who_to_follow/who_to_follow.js2
-rw-r--r--src/components/who_to_follow_panel/who_to_follow_panel.js10
-rw-r--r--src/components/who_to_follow_panel/who_to_follow_panel.vue2
-rw-r--r--src/directives/body_scroll_lock.js6
-rw-r--r--src/hocs/with_load_more/with_load_more.jsx (renamed from src/hocs/with_load_more/with_load_more.js)25
-rw-r--r--src/hocs/with_load_more/with_load_more.scss2
-rw-r--r--src/hocs/with_subscription/with_subscription.jsx (renamed from src/hocs/with_subscription/with_subscription.js)19
-rw-r--r--src/hocs/with_subscription/with_subscription.scss2
-rw-r--r--src/i18n/ar.json17
-rw-r--r--src/i18n/ca.json45
-rw-r--r--src/i18n/de.json1
-rw-r--r--src/i18n/en.json322
-rw-r--r--src/i18n/eo.json278
-rw-r--r--src/i18n/es.json13
-rw-r--r--src/i18n/eu.json3
-rw-r--r--src/i18n/fi.json3
-rw-r--r--src/i18n/fr.json271
-rw-r--r--src/i18n/he.json3
-rw-r--r--src/i18n/id.json19
-rw-r--r--src/i18n/it.json15
-rw-r--r--src/i18n/ja_easy.json3
-rw-r--r--src/i18n/ja_pedantic.json28
-rw-r--r--src/i18n/ko.json830
-rw-r--r--src/i18n/languages.js53
-rw-r--r--src/i18n/messages.js49
-rw-r--r--src/i18n/nb.json3
-rw-r--r--src/i18n/nl.json417
-rw-r--r--src/i18n/oc.json3
-rw-r--r--src/i18n/pl.json3
-rw-r--r--src/i18n/pt.json1
-rw-r--r--src/i18n/ru.json12
-rw-r--r--src/i18n/service_worker_messages.js1
-rw-r--r--src/i18n/sk.json512
-rw-r--r--src/i18n/te.json2
-rw-r--r--src/i18n/uk.json73
-rw-r--r--src/i18n/vi.json444
-rw-r--r--src/i18n/zh.json107
-rw-r--r--src/i18n/zh_Hant.json1
-rw-r--r--src/lib/notification-i18n-loader.js4
-rw-r--r--src/lib/persisted_state.js17
-rw-r--r--src/main.js47
-rw-r--r--src/modules/announcements.js135
-rw-r--r--src/modules/api.js33
-rw-r--r--src/modules/chats.js22
-rw-r--r--src/modules/config.js69
-rw-r--r--src/modules/editStatus.js25
-rw-r--r--src/modules/errors.js4
-rw-r--r--src/modules/instance.js187
-rw-r--r--src/modules/interface.js50
-rw-r--r--src/modules/lists.js130
-rw-r--r--src/modules/media_viewer.js9
-rw-r--r--src/modules/oauth.js4
-rw-r--r--src/modules/polls.js13
-rw-r--r--src/modules/reports.js44
-rw-r--r--src/modules/serverSideConfig.js140
-rw-r--r--src/modules/serverSideStorage.js436
-rw-r--r--src/modules/shout.js15
-rw-r--r--src/modules/statusHistory.js25
-rw-r--r--src/modules/statuses.js50
-rw-r--r--src/modules/users.js101
-rw-r--r--src/panel.scss240
-rw-r--r--src/services/api/api.service.js507
-rw-r--r--src/services/backend_interactor_service/backend_interactor_service.js9
-rw-r--r--src/services/chat_service/chat_service.js12
-rw-r--r--src/services/chat_utils/chat_utils.js2
-rw-r--r--src/services/color_convert/color_convert.js12
-rw-r--r--src/services/completion/completion.js2
-rw-r--r--src/services/date_utils/date_utils.js16
-rw-r--r--src/services/entity_normalizer/entity_normalizer.service.js77
-rw-r--r--src/services/errors/errors.js1
-rw-r--r--src/services/export_import/export_import.js4
-rw-r--r--src/services/file_size_format/file_size_format.js13
-rw-r--r--src/services/gesture_service/gesture_service.js137
-rw-r--r--src/services/html_converter/html_line_converter.service.js4
-rw-r--r--src/services/html_converter/html_tree_converter.service.js3
-rw-r--r--src/services/html_converter/utility.service.js14
-rw-r--r--src/services/lists_fetcher/lists_fetcher.service.js22
-rw-r--r--src/services/locale/locale.service.js29
-rw-r--r--src/services/new_api/password_reset.js2
-rw-r--r--src/services/notification_utils/notification_utils.js12
-rw-r--r--src/services/notifications_fetcher/notifications_fetcher.service.js29
-rw-r--r--src/services/offset_finder/offset_finder.service.js2
-rw-r--r--src/services/push/push.js6
-rw-r--r--src/services/resettable_async_component.js22
-rw-r--r--src/services/status_poster/status_poster.service.js42
-rw-r--r--src/services/style_setter/style_setter.js39
-rw-r--r--src/services/theme_data/pleromafe.js8
-rw-r--r--src/services/theme_data/theme_data.service.js3
-rw-r--r--src/services/timeline_fetcher/timeline_fetcher.service.js30
-rw-r--r--src/services/user_highlighter/user_highlighter.js2
-rw-r--r--src/sw.js10
371 files changed, 17307 insertions, 5182 deletions
diff --git a/src/App.js b/src/App.js
index f5e0b9e9..b7eb2f72 100644
--- a/src/App.js
+++ b/src/App.js
@@ -1,28 +1,29 @@
import UserPanel from './components/user_panel/user_panel.vue'
import NavPanel from './components/nav_panel/nav_panel.vue'
-import Notifications from './components/notifications/notifications.vue'
import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue'
import FeaturesPanel from './components/features_panel/features_panel.vue'
import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
import ShoutPanel from './components/shout_panel/shout_panel.vue'
-import SettingsModal from './components/settings_modal/settings_modal.vue'
import MediaModal from './components/media_modal/media_modal.vue'
import SideDrawer from './components/side_drawer/side_drawer.vue'
import MobilePostStatusButton from './components/mobile_post_status_button/mobile_post_status_button.vue'
import MobileNav from './components/mobile_nav/mobile_nav.vue'
import DesktopNav from './components/desktop_nav/desktop_nav.vue'
import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue'
+import EditStatusModal from './components/edit_status_modal/edit_status_modal.vue'
import PostStatusModal from './components/post_status_modal/post_status_modal.vue'
+import StatusHistoryModal from './components/status_history_modal/status_history_modal.vue'
import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
import { windowWidth, windowHeight } from './services/window_utils/window_utils'
import { mapGetters } from 'vuex'
+import { defineAsyncComponent } from 'vue'
export default {
name: 'app',
components: {
UserPanel,
NavPanel,
- Notifications,
+ Notifications: defineAsyncComponent(() => import('./components/notifications/notifications.vue')),
InstanceSpecificPanel,
FeaturesPanel,
WhoToFollowPanel,
@@ -32,9 +33,12 @@ export default {
MobilePostStatusButton,
MobileNav,
DesktopNav,
- SettingsModal,
+ SettingsModal: defineAsyncComponent(() => import('./components/settings_modal/settings_modal.vue')),
+ UpdateNotification: defineAsyncComponent(() => import('./components/update_notification/update_notification.vue')),
UserReportingModal,
PostStatusModal,
+ EditStatusModal,
+ StatusHistoryModal,
GlobalNoticeList
},
data: () => ({
@@ -46,10 +50,27 @@ export default {
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
window.addEventListener('resize', this.updateMobileState)
},
- destroyed () {
+ unmounted () {
window.removeEventListener('resize', this.updateMobileState)
},
computed: {
+ classes () {
+ return [
+ {
+ '-reverse': this.reverseLayout,
+ '-no-sticky-headers': this.noSticky,
+ '-has-new-post-button': this.newPostButtonShown
+ },
+ '-' + this.layoutType
+ ]
+ },
+ navClasses () {
+ const { navbarColumnStretch } = this.$store.getters.mergedConfig
+ return [
+ '-' + this.layoutType,
+ ...(navbarColumnStretch ? ['-column-stretch'] : [])
+ ]
+ },
currentUser () { return this.$store.state.users.currentUser },
userBackground () { return this.currentUser.background_image },
instanceBackground () {
@@ -65,38 +86,50 @@ export default {
}
}
},
- shout () { return this.$store.state.shout.channel.state === 'joined' },
+ shout () { return this.$store.state.shout.joined },
suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled },
showInstanceSpecificPanel () {
return this.$store.state.instance.showInstanceSpecificPanel &&
!this.$store.getters.mergedConfig.hideISP &&
this.$store.state.instance.instanceSpecificPanelContent
},
+ isChats () {
+ return this.$route.name === 'chat' || this.$route.name === 'chats'
+ },
+ isListEdit () {
+ return this.$route.name === 'lists-edit'
+ },
+ newPostButtonShown () {
+ if (this.isChats) return false
+ if (this.isListEdit) return false
+ return this.$store.getters.mergedConfig.alwaysShowNewPostButton || this.layoutType === 'mobile'
+ },
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
+ editingAvailable () { return this.$store.state.instance.editingAvailable },
shoutboxPosition () {
- return this.$store.getters.mergedConfig.showNewPostButton || false
+ return this.$store.getters.mergedConfig.alwaysShowNewPostButton || false
},
hideShoutbox () {
return this.$store.getters.mergedConfig.hideShoutbox
},
- isMobileLayout () { return this.$store.state.interface.mobileLayout },
+ layoutType () { return this.$store.state.interface.layoutType },
privateMode () { return this.$store.state.instance.private },
- sidebarAlign () {
- return {
- 'order': this.$store.getters.mergedConfig.sidebarRight ? 99 : 0
+ reverseLayout () {
+ const { thirdColumnMode, sidebarRight: reverseSetting } = this.$store.getters.mergedConfig
+ if (this.layoutType !== 'wide') {
+ return reverseSetting
+ } else {
+ return thirdColumnMode === 'notifications' ? reverseSetting : !reverseSetting
}
},
+ noSticky () { return this.$store.getters.mergedConfig.disableStickyHeaders },
+ showScrollbars () { return this.$store.getters.mergedConfig.showScrollbars },
...mapGetters(['mergedConfig'])
},
methods: {
updateMobileState () {
- const mobileLayout = windowWidth() <= 800
- const layoutHeight = windowHeight()
- const changed = mobileLayout !== this.isMobileLayout
- if (changed) {
- this.$store.dispatch('setMobileLayout', mobileLayout)
- }
- this.$store.dispatch('setLayoutHeight', layoutHeight)
+ this.$store.dispatch('setLayoutWidth', windowWidth())
+ this.$store.dispatch('setLayoutHeight', windowHeight())
}
}
}
diff --git a/src/App.scss b/src/App.scss
index bc027f4f..75b2667c 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -1,77 +1,374 @@
+// stylelint-disable rscss/class-format
@import './_variables.scss';
-#app {
- min-height: 100vh;
- max-width: 100%;
- overflow: hidden;
-}
-
-.app-bg-wrapper {
- position: fixed;
- z-index: -1;
- height: 100%;
- left: 0;
- right: -20px;
- background-size: cover;
- background-repeat: no-repeat;
- background-color: var(--wallpaper);
- background-image: var(--body-background-image);
- background-position: 50% 50px;
-}
-
-i[class^='icon-'] {
- user-select: none;
-}
-
-h4 {
- margin: 0;
-}
-
-#content {
- box-sizing: border-box;
- padding-top: 60px;
- margin: auto;
- min-height: 100vh;
- max-width: 980px;
- align-content: flex-start;
-}
-
-.underlay {
- background-color: rgba(0,0,0,0.15);
- background-color: var(--underlay, rgba(0,0,0,0.15));
-}
-
-.text-center {
- text-align: center;
+:root {
+ --navbar-height: 3.5rem;
+ --post-line-height: 1.4;
+ // Z-Index stuff
+ --ZI_media_modal: 9000;
+ --ZI_modals_popovers: 8500;
+ --ZI_modals: 8000;
+ --ZI_navbar_popovers: 7500;
+ --ZI_navbar: 7000;
+ --ZI_popovers: 6000;
}
html {
font-size: 14px;
+ // overflow-x: clip causes my browser's tab to crash with SIGILL lul
}
body {
- overscroll-behavior-y: none;
font-family: sans-serif;
font-family: var(--interfaceFont, sans-serif);
margin: 0;
color: $fallback--text;
color: var(--text, $fallback--text);
- max-width: 100vw;
- overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
+ overscroll-behavior-y: none;
+ overflow-x: clip;
+ overflow-y: scroll;
&.hidden {
display: none;
}
}
+// ## Custom scrollbars
+// Only show custom scrollbars on devices which
+// have a cursor/pointer to operate them
+@media (any-pointer: fine) {
+ * {
+ scrollbar-color: var(--btn) transparent;
+
+ &::-webkit-scrollbar {
+ background: transparent;
+ }
+
+ &::-webkit-scrollbar-button,
+ &::-webkit-scrollbar-thumb {
+ background-color: var(--btn);
+ box-shadow: var(--buttonShadow);
+ border-radius: var(--btnRadius);
+ }
+
+ // horizontal/vertical/increment/decrement are webkit-specific stuff
+ // that indicates whether we're affecting vertical scrollbar, increase button etc
+ // stylelint-disable selector-pseudo-class-no-unknown
+ &::-webkit-scrollbar-button {
+ --___bgPadding: 2px;
+
+ color: var(--btnText);
+ background-repeat: no-repeat, no-repeat;
+
+ &:horizontal {
+ background-size: 50% calc(50% - var(--___bgPadding)), 50% calc(50% - var(--___bgPadding));
+
+ &:increment {
+ background-image:
+ linear-gradient(45deg, var(--btnText) 50%, transparent 51%),
+ linear-gradient(-45deg, transparent 50%, var(--btnText) 51%);
+ background-position: top var(--___bgPadding) left 50%, right 50% bottom var(--___bgPadding);
+ }
+
+ &:decrement {
+ background-image:
+ linear-gradient(45deg, transparent 50%, var(--btnText) 51%),
+ linear-gradient(-45deg, var(--btnText) 50%, transparent 51%);
+ background-position: bottom var(--___bgPadding) right 50%, left 50% top var(--___bgPadding);
+ }
+ }
+
+ &:vertical {
+ background-size: calc(50% - var(--___bgPadding)) 50%, calc(50% - var(--___bgPadding)) 50%;
+
+ &:increment {
+ background-image:
+ linear-gradient(-45deg, transparent 50%, var(--btnText) 51%),
+ linear-gradient(45deg, transparent 50%, var(--btnText) 51%);
+ background-position: right var(--___bgPadding) top 50%, left var(--___bgPadding) top 50%;
+ }
+
+ &:decrement {
+ background-image:
+ linear-gradient(-45deg, var(--btnText) 50%, transparent 51%),
+ linear-gradient(45deg, var(--btnText) 50%, transparent 51%);
+ background-position: left var(--___bgPadding) top 50%, right var(--___bgPadding) top 50%;
+ }
+ }
+ }
+ // stylelint-enable selector-pseudo-class-no-unknown
+ }
+ // Body should have background to scrollbar otherwise it will use white (body color?)
+ html {
+ scrollbar-color: var(--selectedMenu) var(--wallpaper);
+ background: var(--wallpaper);
+ }
+}
+
a {
text-decoration: none;
color: $fallback--link;
color: var(--link, $fallback--link);
}
+h4 {
+ margin: 0;
+}
+
+.iconLetter {
+ display: inline-block;
+ text-align: center;
+ font-weight: 1000;
+}
+
+i[class*=icon-],
+.svg-inline--fa,
+.iconLetter {
+ color: $fallback--icon;
+ color: var(--icon, $fallback--icon);
+}
+
+.button-unstyled:hover,
+a:hover {
+ > i[class*=icon-],
+ > .svg-inline--fa,
+ > .iconLetter {
+ color: var(--text);
+ }
+}
+
+nav {
+ z-index: var(--ZI_navbar);
+ color: var(--topBarText);
+ background-color: $fallback--fg;
+ background-color: var(--topBar, $fallback--fg);
+ color: $fallback--faint;
+ color: var(--faint, $fallback--faint);
+ box-shadow: 0 0 4px rgba(0, 0, 0, 0.6);
+ box-shadow: var(--topBarShadow);
+ box-sizing: border-box;
+ height: var(--navbar-height);
+ position: fixed;
+}
+
+#sidebar {
+ grid-area: sidebar;
+}
+
+#modal {
+ position: absolute;
+ z-index: var(--ZI_modals);
+}
+
+.column.-scrollable {
+ top: var(--navbar-height);
+ position: sticky;
+}
+
+#main-scroller {
+ grid-area: content;
+ position: relative;
+}
+
+#notifs-column {
+ grid-area: notifs;
+}
+
+.app-bg-wrapper {
+ position: fixed;
+ height: 100%;
+ top: var(--navbar-height);
+ z-index: -1000;
+ left: 0;
+ right: -20px;
+ background-size: cover;
+ background-repeat: no-repeat;
+ background-color: var(--wallpaper);
+ background-image: var(--body-background-image);
+ background-position: 50%;
+}
+
+.underlay {
+ grid-column-start: 1;
+ grid-column-end: span 3;
+ grid-row-start: 1;
+ grid-row-end: 1;
+ pointer-events: none;
+ background-color: rgba(0, 0, 0, 0.15);
+ background-color: var(--underlay, rgba(0, 0, 0, 0.15));
+ z-index: -1000;
+}
+
+.app-layout {
+ --miniColumn: 25rem;
+ --maxiColumn: 45rem;
+ --columnGap: 1em;
+ --status-margin: 0.75em;
+ --effectiveSidebarColumnWidth: minmax(var(--miniColumn), var(--sidebarColumnWidth, var(--miniColumn)));
+ --effectiveNotifsColumnWidth: minmax(var(--miniColumn), var(--notifsColumnWidth, var(--miniColumn)));
+ --effectiveContentColumnWidth: minmax(var(--miniColumn), var(--contentColumnWidth, var(--maxiColumn)));
+
+ position: relative;
+ display: grid;
+ grid-template-columns:
+ var(--effectiveSidebarColumnWidth)
+ var(--effectiveContentColumnWidth);
+ grid-template-areas: "sidebar content";
+ grid-template-rows: 1fr;
+ box-sizing: border-box;
+ margin: 0 auto;
+ align-content: flex-start;
+ flex-wrap: wrap;
+ justify-content: center;
+ min-height: 100vh;
+ overflow-x: clip;
+
+ .column {
+ --___columnMargin: var(--columnGap);
+
+ display: grid;
+ grid-template-columns: 100%;
+ box-sizing: border-box;
+ grid-row-start: 1;
+ grid-row-end: 1;
+ margin: 0 calc(var(--___columnMargin) / 2);
+ padding: calc(var(--___columnMargin)) 0;
+ row-gap: var(--___columnMargin);
+ align-content: start;
+
+ &:not(.-scrollable) {
+ margin-top: var(--navbar-height);
+ }
+
+ &:hover {
+ z-index: 2;
+ }
+
+ &.-full-height {
+ margin-bottom: 0;
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+
+ &.-scrollable {
+ --___paddingIncrease: calc(var(--columnGap) / 2);
+
+ position: sticky;
+ top: var(--navbar-height);
+ max-height: calc(100vh - var(--navbar-height));
+ overflow-y: auto;
+ overflow-x: hidden;
+ margin-left: calc(var(--___paddingIncrease) * -1);
+ padding-left: calc(var(--___paddingIncrease) + var(--___columnMargin) / 2);
+
+ // On browsers that don't support hiding scrollbars we enforce "show scrolbars" mode
+ // might implement old style of hiding scrollbars later if there's demand
+ @supports (scrollbar-width: none) or (-webkit-text-fill-color: initial) {
+ &:not(.-show-scrollbar) {
+ scrollbar-width: none;
+ margin-right: calc(var(--___paddingIncrease) * -1);
+ padding-right: calc(var(--___paddingIncrease) + var(--___columnMargin) / 2);
+
+ &::-webkit-scrollbar {
+ display: block;
+ width: 0;
+ }
+ }
+ }
+
+ .panel-heading.-sticky {
+ top: calc(var(--columnGap) / -1);
+ }
+ }
+ }
+
+ &.-has-new-post-button {
+ .column {
+ padding-bottom: 10rem;
+ }
+ }
+
+ &.-no-sticky-headers {
+ .column {
+ .panel-heading.-sticky {
+ position: relative;
+ top: 0;
+ }
+ }
+ }
+
+ .column-inner {
+ display: grid;
+ grid-template-columns: 100%;
+ box-sizing: border-box;
+ row-gap: 1em;
+ align-content: start;
+ }
+
+ &.-reverse:not(.-wide):not(.-mobile) {
+ grid-template-columns:
+ var(--effectiveContentColumnWidth)
+ var(--effectiveSidebarColumnWidth);
+ grid-template-areas: "content sidebar";
+ }
+
+ &.-wide {
+ grid-template-columns:
+ var(--effectiveSidebarColumnWidth)
+ var(--effectiveContentColumnWidth)
+ var(--effectiveNotifsColumnWidth);
+ grid-template-areas: "sidebar content notifs";
+
+ &.-reverse {
+ grid-template-columns:
+ var(--effectiveNotifsColumnWidth)
+ var(--effectiveContentColumnWidth)
+ var(--effectiveSidebarColumnWidth);
+ grid-template-areas: "notifs content sidebar";
+ }
+ }
+
+ &.-mobile {
+ grid-template-columns: 100vw;
+ grid-template-areas: "content";
+ padding: 0;
+
+ .column {
+ margin-left: 0;
+ margin-right: 0;
+ padding-top: 0;
+ margin-top: var(--navbar-height);
+ margin-bottom: 0;
+ }
+
+ .panel-heading,
+ .panel-heading::after,
+ .panel-heading::before,
+ .panel,
+ .panel::after {
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ }
+
+ #sidebar,
+ #notifs-column {
+ display: none;
+ }
+ }
+
+ &.-normal {
+ #notifs-column {
+ display: none;
+ }
+ }
+}
+
+.text-center {
+ text-align: center;
+}
+
.button-default {
user-select: none;
color: $fallback--text;
@@ -84,7 +381,7 @@ a {
cursor: pointer;
box-shadow: $fallback--buttonShadow;
box-shadow: var(--buttonShadow);
- font-size: 14px;
+ font-size: 1em;
font-family: sans-serif;
font-family: var(--interfaceFont, sans-serif);
@@ -103,12 +400,12 @@ a {
}
&:hover {
- box-shadow: 0px 0px 4px rgba(255, 255, 255, 0.3);
+ box-shadow: 0 0 4px rgba(255, 255, 255, 0.3);
box-shadow: var(--buttonHoverShadow);
}
&:active {
- box-shadow: 0px 0px 4px 0px rgba(255, 255, 255, 0.3), 0px 1px 0px 0px rgba(0, 0, 0, 0.2) inset, 0px -1px 0px 0px rgba(255, 255, 255, 0.2) inset;
+ box-shadow: 0 0 4px 0 rgba(255, 255, 255, 0.3), 0 1px 0 0 rgba(0, 0, 0, 0.2) inset, 0 -1px 0 0 rgba(255, 255, 255, 0.2) inset;
box-shadow: var(--buttonPressedShadow);
color: $fallback--text;
color: var(--btnPressedText, $fallback--text);
@@ -141,7 +438,7 @@ a {
color: var(--btnToggledText, $fallback--text);
background-color: $fallback--fg;
background-color: var(--btnToggled, $fallback--fg);
- box-shadow: 0px 0px 4px 0px rgba(255, 255, 255, 0.3), 0px 1px 0px 0px rgba(0, 0, 0, 0.2) inset, 0px -1px 0px 0px rgba(255, 255, 255, 0.2) inset;
+ box-shadow: 0 0 4px 0 rgba(255, 255, 255, 0.3), 0 1px 0 0 rgba(0, 0, 0, 0.2) inset, 0 -1px 0 0 rgba(255, 255, 255, 0.2) inset;
box-shadow: var(--buttonPressedShadow);
svg,
@@ -191,8 +488,9 @@ a {
}
}
-input, textarea, .input {
-
+input,
+textarea,
+.input {
&.unstyled {
border-radius: 0;
background: none;
@@ -200,10 +498,12 @@ input, textarea, .input {
height: unset;
}
+ --_padding: 0.5em;
+
border: none;
border-radius: $fallback--inputRadius;
border-radius: var(--inputRadius, $fallback--inputRadius);
- box-shadow: 0px 1px 0px 0px rgba(0, 0, 0, 0.2) inset, 0px -1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px 0px 2px 0px rgba(0, 0, 0, 1) inset;
+ box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.2) inset, 0 -1px 0 0 rgba(255, 255, 255, 0.2) inset, 0 0 2px 0 rgba(0, 0, 0, 1) inset;
box-shadow: var(--inputShadow);
background-color: $fallback--fg;
background-color: var(--input, $fallback--fg);
@@ -211,17 +511,18 @@ input, textarea, .input {
color: var(--inputText, $fallback--lightText);
font-family: sans-serif;
font-family: var(--inputFont, sans-serif);
- font-size: 14px;
+ font-size: 1em;
margin: 0;
box-sizing: border-box;
display: inline-block;
position: relative;
- height: 28px;
- line-height: 16px;
+ line-height: 2;
hyphens: none;
- padding: 8px .5em;
+ padding: 0 var(--_padding);
- &:disabled, &[disabled=disabled], &.disabled {
+ &:disabled,
+ &[disabled=disabled],
+ &.disabled {
cursor: not-allowed;
opacity: 0.5;
}
@@ -236,18 +537,21 @@ input, textarea, .input {
&[type=radio] {
display: none;
+
&:checked + label::before {
- box-shadow: 0px 0px 2px black inset, 0px 0px 0px 4px $fallback--fg inset;
- box-shadow: var(--inputShadow), 0px 0px 0px 4px var(--fg, $fallback--fg) inset;
+ box-shadow: 0 0 2px black inset, 0 0 0 4px $fallback--fg inset;
+ box-shadow: var(--inputShadow), 0 0 0 4px var(--fg, $fallback--fg) inset;
background-color: var(--accent, $fallback--link);
}
+
&:disabled {
&,
& + label,
& + label::before {
- opacity: .5;
+ opacity: 0.5;
}
}
+
+ label::before {
flex-shrink: 0;
display: inline-block;
@@ -256,35 +560,37 @@ input, textarea, .input {
width: 1.1em;
height: 1.1em;
border-radius: 100%; // Radio buttons should always be circle
- box-shadow: 0px 0px 2px black inset;
+ box-shadow: 0 0 2px black inset;
box-shadow: var(--inputShadow);
- margin-right: .5em;
+ margin-right: 0.5em;
background-color: $fallback--fg;
background-color: var(--input, $fallback--fg);
vertical-align: top;
text-align: center;
- line-height: 1.1em;
+ line-height: 1.1;
font-size: 1.1em;
box-sizing: border-box;
color: transparent;
overflow: hidden;
- box-sizing: border-box;
}
}
&[type=checkbox] {
display: none;
+
&:checked + label::before {
color: $fallback--text;
color: var(--inputText, $fallback--text);
}
+
&:disabled {
&,
& + label,
& + label::before {
- opacity: .5;
+ opacity: 0.5;
}
}
+
+ label::before {
flex-shrink: 0;
display: inline-block;
@@ -294,19 +600,18 @@ input, textarea, .input {
height: 1.1em;
border-radius: $fallback--checkboxRadius;
border-radius: var(--checkboxRadius, $fallback--checkboxRadius);
- box-shadow: 0px 0px 2px black inset;
+ box-shadow: 0 0 2px black inset;
box-shadow: var(--inputShadow);
- margin-right: .5em;
+ margin-right: 0.5em;
background-color: $fallback--fg;
background-color: var(--input, $fallback--fg);
vertical-align: top;
text-align: center;
- line-height: 1.1em;
+ line-height: 1.1;
font-size: 1.1em;
box-sizing: border-box;
color: transparent;
overflow: hidden;
- box-sizing: border-box;
}
}
@@ -315,6 +620,12 @@ input, textarea, .input {
}
}
+// Textareas should have stock line-height + vertical padding instead of huge line-height
+textarea {
+ padding: var(--_padding);
+ line-height: var(--post-line-height);
+}
+
option {
color: $fallback--text;
color: var(--text, $fallback--text);
@@ -324,6 +635,7 @@ option {
.hide-number-spinner {
-moz-appearance: textfield;
+
&[type=number]::-webkit-inner-spin-button,
&[type=number]::-webkit-outer-spin-button {
opacity: 0;
@@ -331,11 +643,6 @@ option {
}
}
-i[class*=icon-], .svg-inline--fa {
- color: $fallback--icon;
- color: var(--icon, $fallback--icon);
-}
-
.btn-block {
display: block;
width: 100%;
@@ -362,273 +669,16 @@ i[class*=icon-], .svg-inline--fa {
}
}
-.container {
- display: flex;
- flex-wrap: wrap;
- margin: 0;
- padding: 0 10px 0 10px;
-}
-
-.auto-size {
- flex: 1
-}
-
-main-router {
- flex: 1;
-}
-
-.status.compact {
- color: rgba(0, 0, 0, 0.42);
- font-weight: 300;
-
- p {
- margin: 0;
- font-size: 0.8em
- }
-}
-
-/* Panel */
-
-.panel {
- display: flex;
- position: relative;
-
- flex-direction: column;
- margin: 0.5em;
-
- background-color: $fallback--bg;
- background-color: var(--bg, $fallback--bg);
-
- &::after, & {
- border-radius: $fallback--panelRadius;
- border-radius: var(--panelRadius, $fallback--panelRadius);
- }
-
- &::after {
- content: '';
- position: absolute;
-
- top: 0;
- bottom: 0;
- left: 0;
- right: 0;
-
- pointer-events: none;
-
- box-shadow: 1px 1px 4px rgba(0,0,0,.6);
- box-shadow: var(--panelShadow);
- }
-}
-
-.panel-body:empty::before {
- content: "¯\\_(ツ)_/¯"; // Could use words but it'd require translations
- display: block;
- margin: 1em;
- text-align: center;
-}
-
-.panel-heading {
- display: flex;
- flex: none;
- border-radius: $fallback--panelRadius $fallback--panelRadius 0 0;
- border-radius: var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius) 0 0;
- background-size: cover;
- padding: .6em .6em;
- text-align: left;
- line-height: 28px;
- color: var(--panelText);
- background-color: $fallback--fg;
- background-color: var(--panel, $fallback--fg);
- align-items: baseline;
- box-shadow: var(--panelHeaderShadow);
-
- .title {
- flex: 1 0 auto;
- font-size: 1.3em;
- }
-
- .faint {
- background-color: transparent;
- color: $fallback--faint;
- color: var(--panelFaint, $fallback--faint);
- }
-
- .faint-link {
- color: $fallback--faint;
- color: var(--faintLink, $fallback--faint);
- }
-
- .alert {
- white-space: nowrap;
- text-overflow: ellipsis;
- overflow-x: hidden;
- }
-
- .button-default,
- .alert {
- // height: 100%;
- line-height: 21px;
- min-height: 0;
- box-sizing: border-box;
- margin: 0;
- margin-left: .5em;
- min-width: 1px;
- align-self: stretch;
- }
-
- .button-default {
- flex-shrink: 0;
-
- &,
- i[class*=icon-] {
- color: $fallback--text;
- color: var(--btnPanelText, $fallback--text);
- }
-
- &:active {
- background-color: $fallback--fg;
- background-color: var(--btnPressedPanel, $fallback--fg);
- color: $fallback--text;
- color: var(--btnPressedPanelText, $fallback--text);
- }
-
- &:disabled {
- color: $fallback--text;
- color: var(--btnDisabledPanelText, $fallback--text);
- }
-
- &.toggled {
- color: $fallback--text;
- color: var(--btnToggledPanelText, $fallback--text);
- }
- }
-
- a,
- .-link {
- color: $fallback--link;
- color: var(--panelLink, $fallback--link)
- }
-}
-
-.panel-heading.stub {
- border-radius: $fallback--panelRadius;
- border-radius: var(--panelRadius, $fallback--panelRadius);
-}
-
-/* TODO Should remove timeline-footer from here when we refactor panels into
- * separate component and utilize slots
- */
-.panel-footer, .timeline-footer {
- display: flex;
- border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
- border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
- flex: none;
- padding: 0.6em 0.6em;
- text-align: left;
- line-height: 28px;
- align-items: baseline;
- border-width: 1px 0 0 0;
- border-style: solid;
- border-color: var(--border, $fallback--border);
-
- .faint {
- color: $fallback--faint;
- color: var(--panelFaint, $fallback--faint);
- }
-
- a,
- .-link {
- color: $fallback--link;
- color: var(--panelLink, $fallback--link);
- }
-}
-
-.panel-body > p {
- line-height: 18px;
- padding: 1em;
- margin: 0;
-}
-
-.container > * {
- min-width: 0px;
-}
+@import './panel.scss';
.fa {
color: grey;
}
-nav {
- z-index: 1000;
- color: var(--topBarText);
- background-color: $fallback--fg;
- background-color: var(--topBar, $fallback--fg);
- color: $fallback--faint;
- color: var(--faint, $fallback--faint);
- box-shadow: 0px 0px 4px rgba(0,0,0,.6);
- box-shadow: var(--topBarShadow);
- box-sizing: border-box;
-}
-
-.fade-enter-active, .fade-leave-active {
- transition: opacity .2s
-}
-.fade-enter, .fade-leave-active {
- opacity: 0
-}
-
-.main {
- flex-basis: 50%;
- flex-grow: 1;
- flex-shrink: 1;
-}
-
-.sidebar-bounds {
- flex: 0;
- flex-basis: 35%;
-}
-
-.sidebar-flexer {
- flex: 1;
- flex-basis: 345px;
- width: 365px;
-}
-
.mobile-shown {
display: none;
}
-@media all and (min-width: 800px) {
- body {
- overflow-y: scroll;
- }
-
- .sidebar-bounds {
- overflow: hidden;
- max-height: 100vh;
- width: 345px;
- position: fixed;
- margin-top: -10px;
-
- .sidebar-scroller {
- height: 96vh;
- width: 365px;
- padding-top: 10px;
- padding-right: 50px;
- overflow-x: hidden;
- overflow-y: scroll;
- }
-
- .sidebar {
- width: 345px;
- }
- }
- .sidebar-flexer {
- max-height: 96vh;
- flex-shrink: 0;
- flex-grow: 0;
- }
-}
-
.badge {
box-sizing: border-box;
display: inline-block;
@@ -656,12 +706,10 @@ nav {
}
.alert {
- margin: 0.35em;
- padding: 0.25em;
+ margin: 0 0.35em;
+ padding: 0 0.25em;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
- min-height: 28px;
- line-height: 28px;
&.error {
background-color: $fallback--alertError;
@@ -712,7 +760,7 @@ nav {
}
.visibility-notice {
- padding: .5em;
+ padding: 0.5em;
border: 1px solid $fallback--faint;
border: 1px solid var(--faint, $fallback--faint);
border-radius: $fallback--inputRadius;
@@ -727,87 +775,27 @@ nav {
position: absolute;
top: 0;
right: 0;
- padding: .5em;
+ padding: 0.5em;
color: inherit;
}
}
.fa-scale-110 {
- &.svg-inline--fa {
+ &.svg-inline--fa,
+ &.iconLetter {
font-size: 1.1em;
}
}
.fa-old-padding {
- &.svg-inline--fa {
+ &.iconLetter,
+ &.svg-inline--fa, &-layer {
padding: 0 0.3em;
}
}
-@keyframes shakeError {
- 0% {
- transform: translateX(0);
- }
- 15% {
- transform: translateX(0.375rem);
- }
- 30% {
- transform: translateX(-0.375rem);
- }
- 45% {
- transform: translateX(0.375rem);
- }
- 60% {
- transform: translateX(-0.375rem);
- }
- 75% {
- transform: translateX(0.375rem);
- }
- 90% {
- transform: translateX(-0.375rem);
- }
- 100% {
- transform: translateX(0);
- }
-}
-
-@media all and (max-width: 800px) {
- .mobile-hidden {
- display: none;
- }
-
- .panel-switcher {
- display: flex;
- }
-
- .container {
- padding: 0;
- }
-
- .panel {
- margin: 0.5em 0 0.5em 0;
- }
-
- .menu-button {
- display: block;
- margin-right: 0.8em;
- }
-
- .main {
- margin-bottom: 7em;
- }
-}
-
-.setting-list,
-.option-list{
- list-style-type: none;
- padding-left: 2em;
- li {
- margin-bottom: 0.5em;
- }
- .suboptions {
- margin-top: 0.3em
- }
+.veryfaint {
+ opacity: 0.25;
}
.login-hint {
@@ -819,18 +807,26 @@ nav {
a {
display: inline-block;
- padding: 1em 0px;
+ padding: 1em 0;
width: 100%;
}
}
.btn.button-default {
- min-height: 28px;
+ min-height: 2em;
}
-.animate-spin {
- animation: spin 2s infinite linear;
- display: inline-block;
+.new-status-notification {
+ position: relative;
+ font-size: 1.1em;
+ z-index: 1;
+ flex: 1;
+}
+
+@media all and (max-width: 800px) {
+ .mobile-hidden {
+ display: none;
+ }
}
@keyframes spin {
@@ -843,49 +839,47 @@ nav {
}
}
-.new-status-notification {
- position: relative;
- font-size: 1.1em;
- z-index: 1;
- flex: 1;
-}
+@keyframes shakeError {
+ 0% {
+ transform: translateX(0);
+ }
-.chat-layout {
- // Needed for smoother chat navigation in the desktop Safari (otherwise the chat layout "jumps" as the chat opens).
- overflow: hidden;
- height: 100%;
+ 15% {
+ transform: translateX(0.375rem);
+ }
- // Get rid of scrollbar on body as scrolling happens on different element
- body {
- overflow: hidden;
+ 30% {
+ transform: translateX(-0.375rem);
}
- // Ensures the fixed position of the mobile browser bars on scroll up / down events.
- // Prevents the mobile browser bars from overlapping or hiding the message posting form.
- @media all and (max-width: 800px) {
- body {
- height: 100%;
- }
+ 45% {
+ transform: translateX(0.375rem);
+ }
- #app {
- height: 100%;
- overflow: hidden;
- min-height: auto;
- }
+ 60% {
+ transform: translateX(-0.375rem);
+ }
- #app_bg_wrapper {
- overflow: hidden;
- }
+ 75% {
+ transform: translateX(0.375rem);
+ }
- .main {
- overflow: hidden;
- height: 100%;
- }
+ 90% {
+ transform: translateX(-0.375rem);
+ }
- #content {
- padding-top: 0;
- height: 100%;
- overflow: visible;
- }
+ 100% {
+ transform: translateX(0);
}
}
+
+// Vue transitions
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity 0.3s;
+}
+
+.fade-enter-from,
+.fade-leave-active {
+ opacity: 0;
+}
diff --git a/src/App.vue b/src/App.vue
index eb65b548..23a388a6 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,39 +1,43 @@
<template>
<div
- id="app"
+ id="app-loaded"
:style="bgStyle"
>
<div
id="app_bg_wrapper"
class="app-bg-wrapper"
/>
- <MobileNav v-if="isMobileLayout" />
- <DesktopNav v-else />
- <div class="app-bg-wrapper app-container-wrapper" />
+ <MobileNav v-if="layoutType === 'mobile'" />
+ <DesktopNav
+ v-else
+ :class="navClasses"
+ />
+ <Notifications v-if="currentUser" />
<div
id="content"
- class="container underlay"
+ class="app-layout container"
+ :class="classes"
>
+ <div class="underlay" />
<div
- class="sidebar-flexer mobile-hidden"
- :style="sidebarAlign"
+ id="sidebar"
+ class="column -scrollable"
+ :class="{ '-show-scrollbar': showScrollbars }"
>
- <div class="sidebar-bounds">
- <div class="sidebar-scroller">
- <div class="sidebar">
- <user-panel />
- <div v-if="!isMobileLayout">
- <nav-panel />
- <instance-specific-panel v-if="showInstanceSpecificPanel" />
- <features-panel v-if="!currentUser && showFeaturesPanel" />
- <who-to-follow-panel v-if="currentUser && suggestionsEnabled" />
- <notifications v-if="currentUser" />
- </div>
- </div>
- </div>
- </div>
+ <user-panel />
+ <template v-if="layoutType !== 'mobile'">
+ <nav-panel />
+ <instance-specific-panel v-if="showInstanceSpecificPanel" />
+ <features-panel v-if="!currentUser && showFeaturesPanel" />
+ <who-to-follow-panel v-if="currentUser && suggestionsEnabled" />
+ <div id="notifs-sidebar" />
+ </template>
</div>
- <div class="main">
+ <main
+ id="main-scroller"
+ class="column main"
+ :class="{ '-full-height': isChats || isListEdit }"
+ >
<div
v-if="!currentUser"
class="login-hint panel panel-default"
@@ -46,20 +50,28 @@
</router-link>
</div>
<router-view />
- </div>
- <media-modal />
+ </main>
+ <div
+ id="notifs-column"
+ class="column -scrollable"
+ :class="{ '-show-scrollbar': showScrollbars }"
+ />
</div>
+ <MediaModal />
<shout-panel
v-if="currentUser && shout && !hideShoutbox"
:floating="true"
class="floating-shout mobile-hidden"
- :class="{ 'left': shoutboxPosition }"
+ :class="{ '-left': shoutboxPosition }"
/>
<MobilePostStatusButton />
<UserReportingModal />
<PostStatusModal />
+ <EditStatusModal v-if="editingAvailable" />
+ <StatusHistoryModal v-if="editingAvailable" />
<SettingsModal />
- <portal-target name="modal" />
+ <UpdateNotification />
+ <div id="modal" />
<GlobalNoticeList />
</div>
</template>
diff --git a/src/_mixins.scss b/src/_mixins.scss
new file mode 100644
index 00000000..1fed16c3
--- /dev/null
+++ b/src/_mixins.scss
@@ -0,0 +1,17 @@
+@mixin unfocused-style {
+ @content;
+
+ &:focus:not(:focus-visible):not(:hover) {
+ @content;
+ }
+}
+
+@mixin focused-style {
+ &:hover, &:focus {
+ @content;
+ }
+
+ &:focus-visible {
+ @content;
+ }
+}
diff --git a/src/_variables.scss b/src/_variables.scss
index 9004d551..099d3606 100644
--- a/src/_variables.scss
+++ b/src/_variables.scss
@@ -30,3 +30,5 @@ $fallback--attachmentRadius: 10px;
$fallback--chatMessageRadius: 10px;
$fallback--buttonShadow: 0px 0px 2px 0px rgba(0, 0, 0, 1), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
+
+$status-margin: 0.75em;
diff --git a/src/assets/pleromatan_apology.png b/src/assets/pleromatan_apology.png
new file mode 100644
index 00000000..36ad7aeb
--- /dev/null
+++ b/src/assets/pleromatan_apology.png
Binary files differ
diff --git a/src/assets/pleromatan_apology_fox.png b/src/assets/pleromatan_apology_fox.png
new file mode 100644
index 00000000..17f87694
--- /dev/null
+++ b/src/assets/pleromatan_apology_fox.png
Binary files differ
diff --git a/src/assets/pleromatan_apology_fox_mask.png b/src/assets/pleromatan_apology_fox_mask.png
new file mode 100644
index 00000000..4d1990d5
--- /dev/null
+++ b/src/assets/pleromatan_apology_fox_mask.png
Binary files differ
diff --git a/src/assets/pleromatan_apology_mask.png b/src/assets/pleromatan_apology_mask.png
new file mode 100644
index 00000000..18adafff
--- /dev/null
+++ b/src/assets/pleromatan_apology_mask.png
Binary files differ
diff --git a/src/boot/after_store.js b/src/boot/after_store.js
index cc0c7c5e..7a4672b6 100644
--- a/src/boot/after_store.js
+++ b/src/boot/after_store.js
@@ -1,12 +1,18 @@
-import Vue from 'vue'
-import VueRouter from 'vue-router'
-import routes from './routes'
+import { createApp } from 'vue'
+import { createRouter, createWebHistory } from 'vue-router'
+import vClickOutside from 'click-outside-vue3'
+
+import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
+
import App from '../App.vue'
-import { windowWidth } from '../services/window_utils/window_utils'
+import routes from './routes'
+import VBodyScrollLock from 'src/directives/body_scroll_lock'
+
+import { windowWidth, windowHeight } from '../services/window_utils/window_utils'
import { getOrCreateApp, getClientToken } from '../services/new_api/oauth.js'
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js'
-import { applyTheme } from '../services/style_setter/style_setter.js'
+import { applyTheme, applyConfig } from '../services/style_setter/style_setter.js'
import FaviconService from '../services/favicon_service/favicon_service.js'
let staticInitialResults = null
@@ -115,6 +121,7 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
copyInstanceOption('nsfwCensorImage')
copyInstanceOption('background')
copyInstanceOption('hidePostStats')
+ copyInstanceOption('hideBotIndication')
copyInstanceOption('hideUserStats')
copyInstanceOption('hideFilteredStatuses')
copyInstanceOption('logo')
@@ -149,7 +156,7 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
copyInstanceOption('hideSitename')
copyInstanceOption('sidebarRight')
- return store.dispatch('setTheme', config['theme'])
+ return store.dispatch('setTheme', config.theme)
}
const getTOS = async ({ store }) => {
@@ -190,7 +197,7 @@ const getStickers = async ({ store }) => {
const stickers = (await Promise.all(
Object.entries(values).map(async ([name, path]) => {
const resPack = await window.fetch(path + 'pack.json')
- var meta = {}
+ let meta = {}
if (resPack.ok) {
meta = await resPack.json()
}
@@ -244,6 +251,7 @@ const getNodeInfo = async ({ store }) => {
store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
+ store.dispatch('setInstanceOption', { name: 'editingAvailable', value: features.includes('editing') })
store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled })
@@ -312,6 +320,7 @@ const setConfig = async ({ store }) => {
}
const checkOAuthToken = async ({ store }) => {
+ // eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
if (store.getters.getUserToken()) {
try {
@@ -325,8 +334,8 @@ const checkOAuthToken = async ({ store }) => {
}
const afterStoreSetup = async ({ store, i18n }) => {
- const width = windowWidth()
- store.dispatch('setMobileLayout', width <= 800)
+ store.dispatch('setLayoutWidth', windowWidth())
+ store.dispatch('setLayoutHeight', windowHeight())
FaviconService.initFaviconService()
@@ -352,6 +361,8 @@ const afterStoreSetup = async ({ store, i18n }) => {
console.error('Failed to load any theme!')
}
+ applyConfig(store.state.config)
+
// Now we can try getting the server settings and logging in
// Most of these are preloaded into the index.html so blocking is minimized
await Promise.all([
@@ -363,28 +374,39 @@ const afterStoreSetup = async ({ store, i18n }) => {
// Start fetching things that don't need to block the UI
store.dispatch('fetchMutes')
+ store.dispatch('startFetchingAnnouncements')
getTOS({ store })
getStickers({ store })
- const router = new VueRouter({
- mode: 'history',
+ const router = createRouter({
+ history: createWebHistory(),
routes: routes(store),
scrollBehavior: (to, _from, savedPosition) => {
if (to.matched.some(m => m.meta.dontScroll)) {
return false
}
- return savedPosition || { x: 0, y: 0 }
+ return savedPosition || { left: 0, top: 0 }
}
})
- /* eslint-disable no-new */
- return new Vue({
- router,
- store,
- i18n,
- el: '#app',
- render: h => h(App)
- })
+ const app = createApp(App)
+
+ app.use(router)
+ app.use(store)
+ app.use(i18n)
+
+ app.use(vClickOutside)
+ app.use(VBodyScrollLock)
+
+ app.component('FAIcon', FontAwesomeIcon)
+ app.component('FALayers', FontAwesomeLayers)
+
+ // remove after vue 3.3
+ app.config.unwrapInjectedRef = true
+
+ app.mount('#app')
+
+ return app
}
export default afterStoreSetup
diff --git a/src/boot/routes.js b/src/boot/routes.js
index 1bc1f9f7..2dc900e7 100644
--- a/src/boot/routes.js
+++ b/src/boot/routes.js
@@ -20,6 +20,11 @@ import ShoutPanel from 'components/shout_panel/shout_panel.vue'
import WhoToFollow from 'components/who_to_follow/who_to_follow.vue'
import About from 'components/about/about.vue'
import RemoteUserResolver from 'components/remote_user_resolver/remote_user_resolver.vue'
+import Lists from 'components/lists/lists.vue'
+import ListsTimeline from 'components/lists_timeline/lists_timeline.vue'
+import ListsEdit from 'components/lists_edit/lists_edit.vue'
+import NavPanel from 'src/components/nav_panel/nav_panel.vue'
+import AnnouncementsPage from 'components/announcements_page/announcements_page.vue'
export default (store) => {
const validateAuthenticatedRoute = (to, from, next) => {
@@ -31,7 +36,8 @@ export default (store) => {
}
let routes = [
- { name: 'root',
+ {
+ name: 'root',
path: '/',
redirect: _to => {
return (store.state.users.currentUser
@@ -45,31 +51,40 @@ export default (store) => {
{ name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
{ name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline },
{ name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
- { name: 'remote-user-profile-acct',
- path: '/remote-users/(@?):username([^/@]+)@:hostname([^/@]+)',
+ {
+ name: 'remote-user-profile-acct',
+ path: '/remote-users/:_(@)?:username([^/@]+)@:hostname([^/@]+)',
component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute
},
- { name: 'remote-user-profile',
+ {
+ name: 'remote-user-profile',
path: '/remote-users/:hostname/:username',
component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute
},
- { name: 'external-user-profile', path: '/users/:id', component: UserProfile },
+ { name: 'external-user-profile', path: '/users/$:id', component: UserProfile },
{ name: 'interactions', path: '/users/:username/interactions', component: Interactions, beforeEnter: validateAuthenticatedRoute },
{ name: 'dms', path: '/users/:username/dms', component: DMs, beforeEnter: validateAuthenticatedRoute },
{ name: 'registration', path: '/registration', component: Registration },
{ name: 'password-reset', path: '/password-reset', component: PasswordReset, props: true },
{ name: 'registration-token', path: '/registration/:token', component: Registration },
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute },
- { name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute },
+ { name: 'notifications', path: '/:username/notifications', component: Notifications, props: () => ({ disableTeleport: true }), beforeEnter: validateAuthenticatedRoute },
{ name: 'login', path: '/login', component: AuthForm },
{ name: 'shout-panel', path: '/shout-panel', component: ShoutPanel, props: () => ({ floating: false }) },
{ name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) },
{ name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) },
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute },
{ name: 'about', path: '/about', component: About },
- { name: 'user-profile', path: '/(users/)?:name', component: UserProfile }
+ { name: 'announcements', path: '/announcements', component: AnnouncementsPage },
+ { name: 'user-profile', path: '/users/:name', component: UserProfile },
+ { name: 'legacy-user-profile', path: '/:name', component: UserProfile },
+ { name: 'lists', path: '/lists', component: Lists },
+ { name: 'lists-timeline', path: '/lists/:id', component: ListsTimeline },
+ { name: 'lists-edit', path: '/lists/:id/edit', component: ListsEdit },
+ { name: 'lists-new', path: '/lists/new', component: ListsEdit },
+ { name: 'edit-navigation', path: '/nav-edit', component: NavPanel, props: () => ({ forceExpand: true, forceEditMode: true }), beforeEnter: validateAuthenticatedRoute }
]
if (store.state.instance.pleromaChatMessagesAvailable) {
diff --git a/src/components/about/about.vue b/src/components/about/about.vue
index 518f6184..33586c97 100644
--- a/src/components/about/about.vue
+++ b/src/components/about/about.vue
@@ -1,5 +1,5 @@
<template>
- <div class="sidebar">
+ <div class="column-inner">
<instance-specific-panel v-if="showInstanceSpecificPanel" />
<staff-panel />
<terms-of-service-panel />
@@ -8,7 +8,7 @@
</div>
</template>
-<script src="./about.js" ></script>
+<script src="./about.js"></script>
<style lang="scss">
</style>
diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js
index e53c4f77..c23407f9 100644
--- a/src/components/account_actions/account_actions.js
+++ b/src/components/account_actions/account_actions.js
@@ -1,6 +1,7 @@
import { mapState } from 'vuex'
import ProgressButton from '../progress_button/progress_button.vue'
import Popover from '../popover/popover.vue'
+import UserListMenu from 'src/components/user_list_menu/user_list_menu.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faEllipsisV
@@ -19,7 +20,8 @@ const AccountActions = {
},
components: {
ProgressButton,
- Popover
+ Popover,
+ UserListMenu
},
methods: {
showRepeats () {
@@ -34,13 +36,16 @@ const AccountActions = {
unblockUser () {
this.$store.dispatch('unblockUser', this.user.id)
},
+ removeUserFromFollowers () {
+ this.$store.dispatch('removeUserFromFollowers', this.user.id)
+ },
reportUser () {
this.$store.dispatch('openUserReportingModal', { userId: this.user.id })
},
openChat () {
this.$router.push({
name: 'chat',
- params: { recipient_id: this.user.id }
+ params: { username: this.$store.state.users.currentUser.screen_name, recipient_id: this.user.id }
})
}
},
diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue
index 1e31151c..218aa6b3 100644
--- a/src/components/account_actions/account_actions.vue
+++ b/src/components/account_actions/account_actions.vue
@@ -6,7 +6,7 @@
:bound-to="{ x: 'container' }"
remove-padding
>
- <template v-slot:content>
+ <template #content>
<div class="dropdown-menu">
<template v-if="relationship.following">
<button
@@ -28,6 +28,14 @@
class="dropdown-divider"
/>
</template>
+ <UserListMenu :user="user" />
+ <button
+ v-if="relationship.followed_by"
+ class="btn button-default btn-block dropdown-item"
+ @click="removeUserFromFollowers"
+ >
+ {{ $t('user_card.remove_follower') }}
+ </button>
<button
v-if="relationship.blocking"
class="btn button-default btn-block dropdown-item"
@@ -57,7 +65,7 @@
</button>
</div>
</template>
- <template v-slot:trigger>
+ <template #trigger>
<button class="button-unstyled ellipsis-button">
<FAIcon
class="icon"
@@ -74,10 +82,6 @@
<style lang="scss">
@import '../../_variables.scss';
.AccountActions {
- button.dropdown-item {
- margin-left: 0;
- }
-
.ellipsis-button {
width: 2.5em;
margin: -0.5em 0;
diff --git a/src/components/announcement/announcement.js b/src/components/announcement/announcement.js
new file mode 100644
index 00000000..c10c7d90
--- /dev/null
+++ b/src/components/announcement/announcement.js
@@ -0,0 +1,105 @@
+import { mapState } from 'vuex'
+import AnnouncementEditor from '../announcement_editor/announcement_editor.vue'
+import RichContent from '../rich_content/rich_content.jsx'
+import localeService from '../../services/locale/locale.service.js'
+
+const Announcement = {
+ components: {
+ AnnouncementEditor,
+ RichContent
+ },
+ data () {
+ return {
+ editing: false,
+ editedAnnouncement: {
+ content: '',
+ startsAt: undefined,
+ endsAt: undefined,
+ allDay: undefined
+ },
+ editError: ''
+ }
+ },
+ props: {
+ announcement: Object
+ },
+ computed: {
+ ...mapState({
+ currentUser: state => state.users.currentUser
+ }),
+ content () {
+ return this.announcement.content
+ },
+ isRead () {
+ return this.announcement.read
+ },
+ publishedAt () {
+ const time = this.announcement.published_at
+ if (!time) {
+ return
+ }
+
+ return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale))
+ },
+ startsAt () {
+ const time = this.announcement.starts_at
+ if (!time) {
+ return
+ }
+
+ return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale))
+ },
+ endsAt () {
+ const time = this.announcement.ends_at
+ if (!time) {
+ return
+ }
+
+ return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale))
+ },
+ inactive () {
+ return this.announcement.inactive
+ }
+ },
+ methods: {
+ markAsRead () {
+ if (!this.isRead) {
+ return this.$store.dispatch('markAnnouncementAsRead', this.announcement.id)
+ }
+ },
+ deleteAnnouncement () {
+ return this.$store.dispatch('deleteAnnouncement', this.announcement.id)
+ },
+ formatTimeOrDate (time, locale) {
+ const d = new Date(time)
+ return this.announcement.all_day ? d.toLocaleDateString(locale) : d.toLocaleString(locale)
+ },
+ enterEditMode () {
+ this.editedAnnouncement.content = this.announcement.pleroma.raw_content
+ this.editedAnnouncement.startsAt = this.announcement.starts_at
+ this.editedAnnouncement.endsAt = this.announcement.ends_at
+ this.editedAnnouncement.allDay = this.announcement.all_day
+ this.editing = true
+ },
+ submitEdit () {
+ this.$store.dispatch('editAnnouncement', {
+ id: this.announcement.id,
+ ...this.editedAnnouncement
+ })
+ .then(() => {
+ this.editing = false
+ })
+ .catch(error => {
+ this.editError = error.error
+ })
+ },
+ cancelEdit () {
+ this.editing = false
+ },
+ clearError () {
+ this.editError = undefined
+ }
+ }
+}
+
+export default Announcement
diff --git a/src/components/announcement/announcement.vue b/src/components/announcement/announcement.vue
new file mode 100644
index 00000000..5f64232a
--- /dev/null
+++ b/src/components/announcement/announcement.vue
@@ -0,0 +1,136 @@
+<template>
+ <div class="announcement">
+ <div class="heading">
+ <h4>{{ $t('announcements.title') }}</h4>
+ </div>
+ <div class="body">
+ <rich-content
+ v-if="!editing"
+ :html="content"
+ :emoji="announcement.emojis"
+ :handle-links="true"
+ />
+ <announcement-editor
+ v-else
+ :announcement="editedAnnouncement"
+ />
+ </div>
+ <div class="footer">
+ <div
+ v-if="!editing"
+ class="times"
+ >
+ <span v-if="publishedAt">
+ {{ $t('announcements.published_time_display', { time: publishedAt }) }}
+ </span>
+ <span v-if="startsAt">
+ {{ $t('announcements.start_time_display', { time: startsAt }) }}
+ </span>
+ <span v-if="endsAt">
+ {{ $t('announcements.end_time_display', { time: endsAt }) }}
+ </span>
+ </div>
+ <div
+ v-if="!editing"
+ class="actions"
+ >
+ <button
+ v-if="currentUser"
+ class="btn button-default"
+ :class="{ toggled: isRead }"
+ :disabled="inactive"
+ :title="inactive ? $t('announcements.inactive_message') : ''"
+ @click="markAsRead"
+ >
+ {{ $t('announcements.mark_as_read_action') }}
+ </button>
+ <button
+ v-if="currentUser && currentUser.role === 'admin'"
+ class="btn button-default"
+ @click="enterEditMode"
+ >
+ {{ $t('announcements.edit_action') }}
+ </button>
+ <button
+ v-if="currentUser && currentUser.role === 'admin'"
+ class="btn button-default"
+ @click="deleteAnnouncement"
+ >
+ {{ $t('announcements.delete_action') }}
+ </button>
+ </div>
+ <div
+ v-else
+ class="actions"
+ >
+ <button
+ class="btn button-default"
+ @click="submitEdit"
+ >
+ {{ $t('announcements.submit_edit_action') }}
+ </button>
+ <button
+ class="btn button-default"
+ @click="cancelEdit"
+ >
+ {{ $t('announcements.cancel_edit_action') }}
+ </button>
+ <div
+ v-if="editing && editError"
+ class="alert error"
+ >
+ {{ $t('announcements.edit_error', { error }) }}
+ <button
+ class="button-unstyled"
+ @click="clearError"
+ >
+ <FAIcon
+ class="fa-scale-110 fa-old-padding"
+ icon="times"
+ :title="$t('announcements.close_error')"
+ />
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script src="./announcement.js"></script>
+
+<style lang="scss">
+@import "../../variables";
+
+.announcement {
+ border-bottom-width: 1px;
+ border-bottom-style: solid;
+ border-bottom-color: var(--border, $fallback--border);
+ border-radius: 0;
+ padding: var(--status-margin, $status-margin);
+
+ .heading, .body {
+ margin-bottom: var(--status-margin, $status-margin);
+ }
+
+ .footer {
+ display: flex;
+ flex-direction: column;
+ .times {
+ display: flex;
+ flex-direction: column;
+ }
+ }
+
+ .footer .actions {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-evenly;
+
+ .btn {
+ flex: 1;
+ margin: 1em;
+ max-width: 10em;
+ }
+ }
+}
+</style>
diff --git a/src/components/announcement_editor/announcement_editor.js b/src/components/announcement_editor/announcement_editor.js
new file mode 100644
index 00000000..79a03afe
--- /dev/null
+++ b/src/components/announcement_editor/announcement_editor.js
@@ -0,0 +1,13 @@
+import Checkbox from '../checkbox/checkbox.vue'
+
+const AnnouncementEditor = {
+ components: {
+ Checkbox
+ },
+ props: {
+ announcement: Object,
+ disabled: Boolean
+ }
+}
+
+export default AnnouncementEditor
diff --git a/src/components/announcement_editor/announcement_editor.vue b/src/components/announcement_editor/announcement_editor.vue
new file mode 100644
index 00000000..0f29f9f7
--- /dev/null
+++ b/src/components/announcement_editor/announcement_editor.vue
@@ -0,0 +1,60 @@
+<template>
+ <div class="announcement-editor">
+ <textarea
+ ref="textarea"
+ v-model="announcement.content"
+ class="post-textarea"
+ rows="1"
+ cols="1"
+ :placeholder="$t('announcements.post_placeholder')"
+ :disabled="disabled"
+ />
+ <span class="announcement-metadata">
+ <label for="announcement-start-time">{{ $t('announcements.start_time_prompt') }}</label>
+ <input
+ id="announcement-start-time"
+ v-model="announcement.startsAt"
+ :type="announcement.allDay ? 'date' : 'datetime-local'"
+ :disabled="disabled"
+ >
+ </span>
+ <span class="announcement-metadata">
+ <label for="announcement-end-time">{{ $t('announcements.end_time_prompt') }}</label>
+ <input
+ id="announcement-end-time"
+ v-model="announcement.endsAt"
+ :type="announcement.allDay ? 'date' : 'datetime-local'"
+ :disabled="disabled"
+ >
+ </span>
+ <span class="announcement-metadata">
+ <Checkbox
+ id="announcement-all-day"
+ v-model="announcement.allDay"
+ :disabled="disabled"
+ />
+ <label for="announcement-all-day">{{ $t('announcements.all_day_prompt') }}</label>
+ </span>
+ </div>
+</template>
+
+<script src="./announcement_editor.js"></script>
+
+<style lang="scss">
+.announcement-editor {
+ display: flex;
+ align-items: stretch;
+ flex-direction: column;
+
+ .announcement-metadata {
+ margin-top: 0.5em;
+ }
+
+ .post-textarea {
+ resize: vertical;
+ height: 10em;
+ overflow: none;
+ box-sizing: content-box;
+ }
+}
+</style>
diff --git a/src/components/announcements_page/announcements_page.js b/src/components/announcements_page/announcements_page.js
new file mode 100644
index 00000000..0bb4892e
--- /dev/null
+++ b/src/components/announcements_page/announcements_page.js
@@ -0,0 +1,55 @@
+import { mapState } from 'vuex'
+import Announcement from '../announcement/announcement.vue'
+import AnnouncementEditor from '../announcement_editor/announcement_editor.vue'
+
+const AnnouncementsPage = {
+ components: {
+ Announcement,
+ AnnouncementEditor
+ },
+ data () {
+ return {
+ newAnnouncement: {
+ content: '',
+ startsAt: undefined,
+ endsAt: undefined,
+ allDay: false
+ },
+ posting: false,
+ error: undefined
+ }
+ },
+ mounted () {
+ this.$store.dispatch('fetchAnnouncements')
+ },
+ computed: {
+ ...mapState({
+ currentUser: state => state.users.currentUser
+ }),
+ announcements () {
+ return this.$store.state.announcements.announcements
+ }
+ },
+ methods: {
+ postAnnouncement () {
+ this.posting = true
+ this.$store.dispatch('postAnnouncement', this.newAnnouncement)
+ .then(() => {
+ this.newAnnouncement.content = ''
+ this.startsAt = undefined
+ this.endsAt = undefined
+ })
+ .catch(error => {
+ this.error = error.error
+ })
+ .finally(() => {
+ this.posting = false
+ })
+ },
+ clearError () {
+ this.error = undefined
+ }
+ }
+}
+
+export default AnnouncementsPage
diff --git a/src/components/announcements_page/announcements_page.vue b/src/components/announcements_page/announcements_page.vue
new file mode 100644
index 00000000..b1489dec
--- /dev/null
+++ b/src/components/announcements_page/announcements_page.vue
@@ -0,0 +1,79 @@
+<template>
+ <div class="panel panel-default announcements-page">
+ <div class="panel-heading">
+ <span>
+ {{ $t('announcements.page_header') }}
+ </span>
+ </div>
+ <div class="panel-body">
+ <section
+ v-if="currentUser && currentUser.role === 'admin'"
+ >
+ <div class="post-form">
+ <div class="heading">
+ <h4>{{ $t('announcements.post_form_header') }}</h4>
+ </div>
+ <div class="body">
+ <announcement-editor
+ :announcement="newAnnouncement"
+ :disabled="posting"
+ />
+ </div>
+ <div class="footer">
+ <button
+ class="btn button-default post-button"
+ :disabled="posting"
+ @click.prevent="postAnnouncement"
+ >
+ {{ $t('announcements.post_action') }}
+ </button>
+ <div
+ v-if="error"
+ class="alert error"
+ >
+ {{ $t('announcements.post_error', { error }) }}
+ <button
+ class="button-unstyled"
+ @click="clearError"
+ >
+ <FAIcon
+ class="fa-scale-110 fa-old-padding"
+ icon="times"
+ :title="$t('announcements.close_error')"
+ />
+ </button>
+ </div>
+ </div>
+ </div>
+ </section>
+ <section
+ v-for="announcement in announcements"
+ :key="announcement.id"
+ >
+ <announcement
+ :announcement="announcement"
+ />
+ </section>
+ </div>
+ </div>
+</template>
+
+<script src="./announcements_page.js"></script>
+
+<style lang="scss">
+@import "../../variables";
+
+.announcements-page {
+ .post-form {
+ padding: var(--status-margin, $status-margin);
+
+ .heading, .body {
+ margin-bottom: var(--status-margin, $status-margin);
+ }
+
+ .post-button {
+ min-width: 10em;
+ }
+ }
+}
+</style>
diff --git a/src/components/async_component_error/async_component_error.vue b/src/components/async_component_error/async_component_error.vue
index b1b59638..26ab5d21 100644
--- a/src/components/async_component_error/async_component_error.vue
+++ b/src/components/async_component_error/async_component_error.vue
@@ -19,6 +19,7 @@
<script>
export default {
+ emits: ['resetAsyncComponent'],
methods: {
retry () {
this.$emit('resetAsyncComponent')
diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js
index 8849f501..5dc50475 100644
--- a/src/components/attachment/attachment.js
+++ b/src/components/attachment/attachment.js
@@ -11,7 +11,12 @@ import {
faImage,
faVideo,
faPlayCircle,
- faTimes
+ faTimes,
+ faStop,
+ faSearchPlus,
+ faTrashAlt,
+ faPencilAlt,
+ faAlignRight
} from '@fortawesome/free-solid-svg-icons'
library.add(
@@ -20,27 +25,39 @@ library.add(
faImage,
faVideo,
faPlayCircle,
- faTimes
+ faTimes,
+ faStop,
+ faSearchPlus,
+ faTrashAlt,
+ faPencilAlt,
+ faAlignRight
)
const Attachment = {
props: [
'attachment',
+ 'description',
+ 'hideDescription',
'nsfw',
'size',
- 'allowPlay',
'setMedia',
- 'naturalSizeLoad'
+ 'remove',
+ 'shiftUp',
+ 'shiftDn',
+ 'edit'
],
data () {
return {
+ localDescription: this.description || this.attachment.description,
nsfwImage: this.$store.state.instance.nsfwCensorImage || nsfwImage,
hideNsfwLocal: this.$store.getters.mergedConfig.hideNsfw,
preloadImage: this.$store.getters.mergedConfig.preloadImage,
loading: false,
img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'),
modalOpen: false,
- showHidden: false
+ showHidden: false,
+ flashLoaded: false,
+ showDescription: false
}
},
components: {
@@ -49,8 +66,23 @@ const Attachment = {
VideoAttachment
},
computed: {
+ classNames () {
+ return [
+ {
+ '-loading': this.loading,
+ '-nsfw-placeholder': this.hidden,
+ '-editable': this.edit !== undefined
+ },
+ '-type-' + this.type,
+ this.size && '-size-' + this.size,
+ `-${this.useContainFit ? 'contain' : 'cover'}-fit`
+ ]
+ },
usePlaceholder () {
- return this.size === 'hide' || this.type === 'unknown'
+ return this.size === 'hide'
+ },
+ useContainFit () {
+ return this.$store.getters.mergedConfig.useContainFit
},
placeholderName () {
if (this.attachment.description === '' || !this.attachment.description) {
@@ -74,24 +106,36 @@ const Attachment = {
return this.nsfw && this.hideNsfwLocal && !this.showHidden
},
isEmpty () {
- return (this.type === 'html' && !this.attachment.oembed) || this.type === 'unknown'
- },
- isSmall () {
- return this.size === 'small'
- },
- fullwidth () {
- if (this.size === 'hide') return false
- return this.type === 'html' || this.type === 'audio' || this.type === 'unknown'
+ return (this.type === 'html' && !this.attachment.oembed)
},
useModal () {
- const modalTypes = this.size === 'hide' ? ['image', 'video', 'audio']
- : this.mergedConfig.playVideosInModal
- ? ['image', 'video']
- : ['image']
+ let modalTypes = []
+ switch (this.size) {
+ case 'hide':
+ case 'small':
+ modalTypes = ['image', 'video', 'audio', 'flash']
+ break
+ default:
+ modalTypes = this.mergedConfig.playVideosInModal
+ ? ['image', 'video', 'flash']
+ : ['image']
+ break
+ }
return modalTypes.includes(this.type)
},
+ videoTag () {
+ return this.useModal ? 'button' : 'span'
+ },
...mapGetters(['mergedConfig'])
},
+ watch: {
+ 'attachment.description' (newVal) {
+ this.localDescription = newVal
+ },
+ localDescription (newVal) {
+ this.onEdit(newVal)
+ }
+ },
methods: {
linkClicked ({ target }) {
if (target.tagName === 'A') {
@@ -100,12 +144,37 @@ const Attachment = {
},
openModal (event) {
if (this.useModal) {
- event.stopPropagation()
- event.preventDefault()
- this.setMedia()
- this.$store.dispatch('setCurrent', this.attachment)
+ this.$emit('setMedia')
+ this.$store.dispatch('setCurrentMedia', this.attachment)
+ } else if (this.type === 'unknown') {
+ window.open(this.attachment.url)
}
},
+ openModalForce (event) {
+ this.$emit('setMedia')
+ this.$store.dispatch('setCurrentMedia', this.attachment)
+ },
+ onEdit (event) {
+ this.edit && this.edit(this.attachment, event)
+ },
+ onRemove () {
+ this.remove && this.remove(this.attachment)
+ },
+ onShiftUp () {
+ this.shiftUp && this.shiftUp(this.attachment)
+ },
+ onShiftDn () {
+ this.shiftDn && this.shiftDn(this.attachment)
+ },
+ stopFlash () {
+ this.$refs.flash.closePlayer()
+ },
+ setFlashLoaded (event) {
+ this.flashLoaded = event
+ },
+ toggleDescription () {
+ this.showDescription = !this.showDescription
+ },
toggleHidden (event) {
if (
(this.mergedConfig.useOneClickNsfw && !this.showHidden) &&
@@ -132,7 +201,7 @@ const Attachment = {
onImageLoad (image) {
const width = image.naturalWidth
const height = image.naturalHeight
- this.naturalSizeLoad && this.naturalSizeLoad({ width, height })
+ this.$emit('naturalSizeLoad', { id: this.attachment.id, width, height })
}
}
}
diff --git a/src/components/attachment/attachment.scss b/src/components/attachment/attachment.scss
new file mode 100644
index 00000000..b2dea98d
--- /dev/null
+++ b/src/components/attachment/attachment.scss
@@ -0,0 +1,268 @@
+@import '../../_variables.scss';
+
+.Attachment {
+ display: inline-flex;
+ flex-direction: column;
+ position: relative;
+ align-self: flex-start;
+ line-height: 0;
+ height: 100%;
+ border-style: solid;
+ border-width: 1px;
+ border-radius: $fallback--attachmentRadius;
+ border-radius: var(--attachmentRadius, $fallback--attachmentRadius);
+ border-color: $fallback--border;
+ border-color: var(--border, $fallback--border);
+
+ .attachment-wrapper {
+ flex: 1 1 auto;
+ height: 100%;
+ position: relative;
+ overflow: hidden;
+ }
+
+ .description-container {
+ flex: 0 1 0;
+ display: flex;
+ padding-top: 0.5em;
+ z-index: 1;
+
+ p {
+ flex: 1;
+ text-align: center;
+ line-height: 1.5;
+ padding: 0.5em;
+ margin: 0;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ &.-static {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ padding-top: 0;
+ background: var(--popover);
+ box-shadow: var(--popupShadow);
+ }
+ }
+
+ .description-field {
+ flex: 1;
+ min-width: 0;
+ }
+
+ & .placeholder-container,
+ & .image-container,
+ & .audio-container,
+ & .video-container,
+ & .flash-container,
+ & .oembed-container {
+ display: flex;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ }
+
+ .image-container {
+ .image {
+ width: 100%;
+ height: 100%;
+ }
+ }
+
+ & .flash-container,
+ & .video-container {
+ & .flash,
+ & video {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ align-self: center;
+ }
+ }
+
+ .audio-container {
+ display: flex;
+ align-items: flex-end;
+
+ audio {
+ width: 100%;
+ height: 100%;
+ }
+ }
+
+ .placeholder-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding-top: 0.5em;
+ }
+
+
+ .play-icon {
+ position: absolute;
+ font-size: 64px;
+ top: calc(50% - 32px);
+ left: calc(50% - 32px);
+ color: rgba(255, 255, 255, 0.75);
+ text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
+
+ &::before {
+ margin: 0;
+ }
+ }
+
+ .attachment-buttons {
+ display: flex;
+ position: absolute;
+ right: 0;
+ top: 0;
+ margin-top: 0.5em;
+ margin-right: 0.5em;
+ z-index: 1;
+
+ .attachment-button {
+ padding: 0;
+ border-radius: $fallback--tooltipRadius;
+ border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
+ text-align: center;
+ width: 2em;
+ height: 2em;
+ margin-left: 0.5em;
+ font-size: 1.25em;
+ // TODO: theming? hard to theme with unknown background image color
+ background: rgba(230, 230, 230, 0.7);
+
+ .svg-inline--fa {
+ color: rgba(0, 0, 0, 0.6);
+ }
+
+ &:hover .svg-inline--fa {
+ color: rgba(0, 0, 0, 0.9);
+ }
+ }
+ }
+
+ .oembed-container {
+ line-height: 1.2em;
+ flex: 1 0 100%;
+ width: 100%;
+ margin-right: 15px;
+ display: flex;
+
+ img {
+ width: 100%;
+ }
+
+ .image {
+ flex: 1;
+ img {
+ border: 0px;
+ border-radius: 5px;
+ height: 100%;
+ object-fit: cover;
+ }
+ }
+
+ .text {
+ flex: 2;
+ margin: 8px;
+ word-break: break-all;
+ h1 {
+ font-size: 1rem;
+ margin: 0px;
+ }
+ }
+ }
+
+ &.-size-small {
+ .play-icon {
+ zoom: 0.5;
+ opacity: 0.7;
+ }
+
+ .attachment-buttons {
+ zoom: 0.7;
+ opacity: 0.5;
+ }
+ }
+
+ &.-editable {
+ padding: 0.5em;
+
+ & .description-container,
+ & .attachment-buttons {
+ margin: 0;
+ }
+ }
+
+ &.-placeholder {
+ display: inline-block;
+ color: $fallback--link;
+ color: var(--postLink, $fallback--link);
+ overflow: hidden;
+ white-space: nowrap;
+ height: auto;
+ line-height: 1.5;
+
+ &:not(.-editable) {
+ border: none;
+ }
+
+ &.-editable {
+ display: flex;
+ flex-direction: row;
+ align-items: baseline;
+
+ & .description-container,
+ & .attachment-buttons {
+ margin: 0;
+ padding: 0;
+ position: relative;
+ }
+
+ .description-container {
+ flex: 1;
+ padding-left: 0.5em;
+ }
+
+ .attachment-buttons {
+ order: 99;
+ align-self: center;
+ }
+ }
+
+ a {
+ display: inline-block;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ svg {
+ color: inherit;
+ }
+ }
+
+ &.-loading {
+ cursor: progress;
+ }
+
+ &.-contain-fit {
+ img,
+ canvas {
+ object-fit: contain;
+ }
+ }
+
+ &.-cover-fit {
+ img,
+ canvas {
+ object-fit: cover;
+ }
+ }
+}
diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue
index f80badfd..2a89886d 100644
--- a/src/components/attachment/attachment.vue
+++ b/src/components/attachment/attachment.vue
@@ -1,7 +1,8 @@
<template>
- <div
+ <button
v-if="usePlaceholder"
- :class="{ 'fullwidth': fullwidth }"
+ class="Attachment -placeholder button-unstyled"
+ :class="classNames"
@click="openModal"
>
<a
@@ -11,318 +12,257 @@
:href="attachment.url"
:alt="attachment.description"
:title="attachment.description"
+ @click.prevent
>
<FAIcon :icon="placeholderIconClass" />
- <b>{{ nsfw ? "NSFW / " : "" }}</b>{{ placeholderName }}
+ <b>{{ nsfw ? "NSFW / " : "" }}</b>{{ edit ? '' : placeholderName }}
</a>
- </div>
- <div
- v-else
- v-show="!isEmpty"
- class="attachment"
- :class="{[type]: true, loading, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}"
- >
- <a
- v-if="hidden"
- class="image-attachment"
- :href="attachment.url"
- :alt="attachment.description"
- :title="attachment.description"
- @click.prevent.stop="toggleHidden"
+ <div
+ v-if="edit || remove"
+ class="attachment-buttons"
>
- <img
- :key="nsfwImage"
- class="nsfw"
- :src="nsfwImage"
- :class="{'small': isSmall}"
+ <button
+ v-if="remove"
+ class="button-unstyled attachment-button"
+ @click.prevent="onRemove"
>
- <FAIcon
- v-if="type === 'video'"
- class="play-icon"
- icon="play-circle"
- />
- </a>
- <button
- v-if="nsfw && hideNsfwLocal && !hidden"
- class="button-unstyled hider"
- @click.prevent="toggleHidden"
+ <FAIcon icon="trash-alt" />
+ </button>
+ </div>
+ <div
+ v-if="size !== 'hide' && !hideDescription && (edit || localDescription || showDescription)"
+ class="description-container"
+ :class="{ '-static': !edit }"
>
- <FAIcon icon="times" />
- </button>
-
- <a
- v-if="type === 'image' && (!hidden || preloadImage)"
- class="image-attachment"
- :class="{'hidden': hidden && preloadImage }"
- :href="attachment.url"
- target="_blank"
- @click="openModal"
+ <input
+ v-if="edit"
+ v-model="localDescription"
+ type="text"
+ class="description-field"
+ :placeholder="$t('post_status.media_description')"
+ @keydown.enter.prevent=""
+ >
+ <p v-else>
+ {{ localDescription }}
+ </p>
+ </div>
+ </button>
+ <div
+ v-else
+ class="Attachment"
+ :class="classNames"
+ >
+ <div
+ v-show="!isEmpty"
+ class="attachment-wrapper"
>
- <StillImage
- class="image"
- :referrerpolicy="referrerpolicy"
- :mimetype="attachment.mimetype"
- :src="attachment.large_thumb_url || attachment.url"
- :image-load-handler="onImageLoad"
+ <a
+ v-if="hidden"
+ class="image-container"
+ :href="attachment.url"
:alt="attachment.description"
- />
- </a>
+ :title="attachment.description"
+ @click.prevent.stop="toggleHidden"
+ >
+ <img
+ :key="nsfwImage"
+ class="nsfw"
+ :src="nsfwImage"
+ >
+ <FAIcon
+ v-if="type === 'video'"
+ class="play-icon"
+ icon="play-circle"
+ />
+ </a>
+ <div
+ v-if="!hidden"
+ class="attachment-buttons"
+ >
+ <button
+ v-if="type === 'flash' && flashLoaded"
+ class="button-unstyled attachment-button"
+ :title="$t('status.attachment_stop_flash')"
+ @click.prevent="stopFlash"
+ >
+ <FAIcon icon="stop" />
+ </button>
+ <button
+ v-if="attachment.description && size !== 'small' && !edit && type !== 'unknown'"
+ class="button-unstyled attachment-button"
+ :title="$t('status.show_attachment_description')"
+ @click.prevent="toggleDescription"
+ >
+ <FAIcon icon="align-right" />
+ </button>
+ <button
+ v-if="!useModal && type !== 'unknown'"
+ class="button-unstyled attachment-button"
+ :title="$t('status.show_attachment_in_modal')"
+ @click.prevent="openModalForce"
+ >
+ <FAIcon icon="search-plus" />
+ </button>
+ <button
+ v-if="nsfw && hideNsfwLocal"
+ class="button-unstyled attachment-button"
+ :title="$t('status.hide_attachment')"
+ @click.prevent="toggleHidden"
+ >
+ <FAIcon icon="times" />
+ </button>
+ <button
+ v-if="shiftUp"
+ class="button-unstyled attachment-button"
+ :title="$t('status.move_up')"
+ @click.prevent="onShiftUp"
+ >
+ <FAIcon icon="chevron-left" />
+ </button>
+ <button
+ v-if="shiftDn"
+ class="button-unstyled attachment-button"
+ :title="$t('status.move_down')"
+ @click.prevent="onShiftDn"
+ >
+ <FAIcon icon="chevron-right" />
+ </button>
+ <button
+ v-if="remove"
+ class="button-unstyled attachment-button"
+ :title="$t('status.remove_attachment')"
+ @click.prevent="onRemove"
+ >
+ <FAIcon icon="trash-alt" />
+ </button>
+ </div>
- <a
- v-if="type === 'video' && !hidden"
- class="video-container"
- :class="{'small': isSmall}"
- :href="allowPlay ? undefined : attachment.url"
- @click="openModal"
- >
- <VideoAttachment
- class="video"
- :attachment="attachment"
- :controls="allowPlay"
- @play="$emit('play')"
- @pause="$emit('pause')"
- />
- <FAIcon
- v-if="!allowPlay"
- class="play-icon"
- icon="play-circle"
- />
- </a>
+ <a
+ v-if="type === 'image' && (!hidden || preloadImage)"
+ class="image-container"
+ :class="{'-hidden': hidden && preloadImage }"
+ :href="attachment.url"
+ target="_blank"
+ @click.stop.prevent="openModal"
+ >
+ <StillImage
+ class="image"
+ :referrerpolicy="referrerpolicy"
+ :mimetype="attachment.mimetype"
+ :src="attachment.large_thumb_url || attachment.url"
+ :image-load-handler="onImageLoad"
+ :alt="attachment.description"
+ />
+ </a>
+
+ <a
+ v-if="type === 'unknown' && !hidden"
+ class="placeholder-container"
+ :href="attachment.url"
+ target="_blank"
+ >
+ <FAIcon
+ size="5x"
+ :icon="placeholderIconClass"
+ />
+ <p>
+ {{ localDescription }}
+ </p>
+ </a>
+
+ <component
+ :is="videoTag"
+ v-if="type === 'video' && !hidden"
+ class="video-container"
+ :class="{ 'button-unstyled': 'isModal' }"
+ :href="attachment.url"
+ @click.stop.prevent="openModal"
+ >
+ <VideoAttachment
+ class="video"
+ :attachment="attachment"
+ :controls="!useModal"
+ @play="$emit('play')"
+ @pause="$emit('pause')"
+ />
+ <FAIcon
+ v-if="useModal"
+ class="play-icon"
+ icon="play-circle"
+ />
+ </component>
+
+ <span
+ v-if="type === 'audio' && !hidden"
+ class="audio-container"
+ :href="attachment.url"
+ @click.stop.prevent="openModal"
+ >
+ <audio
+ v-if="type === 'audio'"
+ :src="attachment.url"
+ :alt="attachment.description"
+ :title="attachment.description"
+ controls
+ @play="$emit('play')"
+ @pause="$emit('pause')"
+ />
+ </span>
- <audio
- v-if="type === 'audio'"
- :src="attachment.url"
- :alt="attachment.description"
- :title="attachment.description"
- controls
- @play="$emit('play')"
- @pause="$emit('pause')"
- />
+ <div
+ v-if="type === 'html' && attachment.oembed"
+ class="oembed-container"
+ @click.prevent="linkClicked"
+ >
+ <div
+ v-if="attachment.thumb_url"
+ class="image"
+ >
+ <img :src="attachment.thumb_url">
+ </div>
+ <div class="text">
+ <!-- eslint-disable vue/no-v-html -->
+ <h1><a :href="attachment.url">{{ attachment.oembed.title }}</a></h1>
+ <div v-html="attachment.oembed.oembedHTML" />
+ <!-- eslint-enable vue/no-v-html -->
+ </div>
+ </div>
+ <span
+ v-if="type === 'flash' && !hidden"
+ class="flash-container"
+ :href="attachment.url"
+ @click.stop.prevent="openModal"
+ >
+ <Flash
+ ref="flash"
+ class="flash"
+ :src="attachment.large_thumb_url || attachment.url"
+ @playerOpened="setFlashLoaded(true)"
+ @playerClosed="setFlashLoaded(false)"
+ />
+ </span>
+ </div>
<div
- v-if="type === 'html' && attachment.oembed"
- class="oembed"
- @click.prevent="linkClicked"
+ v-if="size !== 'hide' && !hideDescription && (edit || (localDescription && showDescription))"
+ class="description-container"
+ :class="{ '-static': !edit }"
>
- <div
- v-if="attachment.thumb_url"
- class="image"
+ <input
+ v-if="edit"
+ v-model="localDescription"
+ type="text"
+ class="description-field"
+ :placeholder="$t('post_status.media_description')"
+ @keydown.enter.prevent=""
>
- <img :src="attachment.thumb_url">
- </div>
- <div class="text">
- <!-- eslint-disable vue/no-v-html -->
- <h1><a :href="attachment.url">{{ attachment.oembed.title }}</a></h1>
- <div v-html="attachment.oembed.oembedHTML" />
- <!-- eslint-enable vue/no-v-html -->
- </div>
+ <p v-else>
+ {{ localDescription }}
+ </p>
</div>
-
- <Flash
- v-if="type === 'flash'"
- :src="attachment.large_thumb_url || attachment.url"
- />
</div>
</template>
<script src="./attachment.js"></script>
-<style lang="scss">
-@import '../../_variables.scss';
-
-.attachments {
- display: flex;
- flex-wrap: wrap;
-
- .non-gallery {
- max-width: 100%;
- }
-
- .placeholder {
- display: inline-block;
- padding: 0.3em 1em 0.3em 0;
- color: $fallback--link;
- color: var(--postLink, $fallback--link);
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- max-width: 100%;
-
- svg {
- color: inherit;
- }
- }
-
- .nsfw-placeholder {
- cursor: pointer;
-
- &.loading {
- cursor: progress;
- }
- }
-
- .attachment {
- position: relative;
- margin-top: 0.5em;
- align-self: flex-start;
- line-height: 0;
-
- border-style: solid;
- border-width: 1px;
- border-radius: $fallback--attachmentRadius;
- border-radius: var(--attachmentRadius, $fallback--attachmentRadius);
- border-color: $fallback--border;
- border-color: var(--border, $fallback--border);
- overflow: hidden;
- }
-
- .non-gallery.attachment {
- &.flash,
- &.video {
- flex: 1 0 40%;
- }
- .nsfw {
- height: 260px;
- }
- .small {
- height: 120px;
- flex-grow: 0;
- }
- .video {
- height: 260px;
- display: flex;
- }
- video {
- max-height: 100%;
- object-fit: contain;
- }
- }
-
- .fullwidth {
- flex-basis: 100%;
- }
- // fixes small gap below video
- &.video {
- line-height: 0;
- }
-
- .video-container {
- display: flex;
- max-height: 100%;
- }
-
- .video {
- width: 100%;
- height: 100%;
- }
-
- .play-icon {
- position: absolute;
- font-size: 64px;
- top: calc(50% - 32px);
- left: calc(50% - 32px);
- color: rgba(255, 255, 255, 0.75);
- text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
- }
-
- .play-icon::before {
- margin: 0;
- }
-
- &.html {
- flex-basis: 90%;
- width: 100%;
- display: flex;
- }
-
- .hider {
- position: absolute;
- right: 0;
- margin: 10px;
- padding: 0;
- z-index: 4;
- border-radius: $fallback--tooltipRadius;
- border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
- text-align: center;
- width: 2em;
- height: 2em;
- font-size: 1.25em;
- // TODO: theming? hard to theme with unknown background image color
- background: rgba(230, 230, 230, 0.7);
- .svg-inline--fa {
- color: rgba(0, 0, 0, 0.6);
- }
- &:hover .svg-inline--fa {
- color: rgba(0, 0, 0, 0.9);
- }
- }
-
- video {
- z-index: 0;
- }
-
- audio {
- width: 100%;
- }
-
- img.media-upload {
- line-height: 0;
- max-height: 200px;
- max-width: 100%;
- }
-
- .oembed {
- line-height: 1.2em;
- flex: 1 0 100%;
- width: 100%;
- margin-right: 15px;
- display: flex;
-
- img {
- width: 100%;
- }
-
- .image {
- flex: 1;
- img {
- border: 0px;
- border-radius: 5px;
- height: 100%;
- object-fit: cover;
- }
- }
-
- .text {
- flex: 2;
- margin: 8px;
- word-break: break-all;
- h1 {
- font-size: 14px;
- margin: 0px;
- }
- }
- }
-
- .image-attachment {
- &,
- & .image {
- width: 100%;
- height: 100%;
- }
-
- &.hidden {
- display: none;
- }
-
- .nsfw {
- object-fit: cover;
- width: 100%;
- height: 100%;
- }
-
- img {
- image-orientation: from-image; // NOTE: only FF supports this
- }
- }
-}
-</style>
+<style src="./attachment.scss" lang="scss"></style>
diff --git a/src/components/auth_form/auth_form.js b/src/components/auth_form/auth_form.js
index e9a6e2d5..a86a3dca 100644
--- a/src/components/auth_form/auth_form.js
+++ b/src/components/auth_form/auth_form.js
@@ -1,3 +1,4 @@
+import { h, resolveComponent } from 'vue'
import LoginForm from '../login_form/login_form.vue'
import MFARecoveryForm from '../mfa_form/recovery_form.vue'
import MFATOTPForm from '../mfa_form/totp_form.vue'
@@ -5,8 +6,8 @@ import { mapGetters } from 'vuex'
const AuthForm = {
name: 'AuthForm',
- render (createElement) {
- return createElement('component', { is: this.authForm })
+ render () {
+ return h(resolveComponent(this.authForm))
},
computed: {
authForm () {
diff --git a/src/components/avatar_list/avatar_list.vue b/src/components/avatar_list/avatar_list.vue
index e1b6e971..9a6ca3f6 100644
--- a/src/components/avatar_list/avatar_list.vue
+++ b/src/components/avatar_list/avatar_list.vue
@@ -14,7 +14,7 @@
</div>
</template>
-<script src="./avatar_list.js" ></script>
+<script src="./avatar_list.js"></script>
<style lang="scss">
@import '../../_variables.scss';
diff --git a/src/components/basic_user_card/basic_user_card.js b/src/components/basic_user_card/basic_user_card.js
index 8f41e2fb..31de2d75 100644
--- a/src/components/basic_user_card/basic_user_card.js
+++ b/src/components/basic_user_card/basic_user_card.js
@@ -1,5 +1,6 @@
-import UserCard from '../user_card/user_card.vue'
+import UserPopover from '../user_popover/user_popover.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
+import UserLink from '../user_link/user_link.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@@ -7,20 +8,13 @@ const BasicUserCard = {
props: [
'user'
],
- data () {
- return {
- userExpanded: false
- }
- },
components: {
- UserCard,
+ UserPopover,
UserAvatar,
- RichContent
+ RichContent,
+ UserLink
},
methods: {
- toggleUserExpanded () {
- this.userExpanded = !this.userExpanded
- },
userProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
}
diff --git a/src/components/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue
index 53deb1df..418de926 100644
--- a/src/components/basic_user_card/basic_user_card.vue
+++ b/src/components/basic_user_card/basic_user_card.vue
@@ -1,24 +1,22 @@
<template>
<div class="basic-user-card">
- <router-link :to="userProfileLink(user)">
- <UserAvatar
- class="avatar"
- :user="user"
- @click.prevent.native="toggleUserExpanded"
- />
- </router-link>
- <div
- v-if="userExpanded"
- class="basic-user-card-expanded-content"
+ <router-link
+ :to="userProfileLink(user)"
+ @click.prevent
>
- <UserCard
+ <UserPopover
:user-id="user.id"
- :rounded="true"
- :bordered="true"
- />
- </div>
+ :overlay-centers="true"
+ overlay-centers-selector=".avatar"
+ >
+ <UserAvatar
+ class="user-avatar avatar"
+ :user="user"
+ @click.prevent
+ />
+ </UserPopover>
+ </router-link>
<div
- v-else
class="basic-user-card-collapsed-content"
>
<div
@@ -32,12 +30,10 @@
/>
</div>
<div>
- <router-link
+ <user-link
class="basic-user-card-screen-name"
- :to="userProfileLink(user)"
- >
- @{{ user.screen_name_ui }}
- </router-link>
+ :user="user"
+ />
</div>
<slot />
</div>
@@ -53,6 +49,8 @@
margin: 0;
padding: 0.6em 1em;
+ --emoji-size: 14px;
+
&-collapsed-content {
margin-left: 0.7em;
text-align: left;
diff --git a/src/components/bookmark_timeline/bookmark_timeline.js b/src/components/bookmark_timeline/bookmark_timeline.js
index 64b69e5d..5ac43d90 100644
--- a/src/components/bookmark_timeline/bookmark_timeline.js
+++ b/src/components/bookmark_timeline/bookmark_timeline.js
@@ -9,7 +9,7 @@ const Bookmarks = {
components: {
Timeline
},
- destroyed () {
+ unmounted () {
this.$store.commit('clearTimeline', { timeline: 'bookmarks' })
}
}
diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js
index b54f5fb2..79f24771 100644
--- a/src/components/chat/chat.js
+++ b/src/components/chat/chat.js
@@ -6,7 +6,7 @@ import PostStatusForm from '../post_status_form/post_status_form.vue'
import ChatTitle from '../chat_title/chat_title.vue'
import chatService from '../../services/chat_service/chat_service.js'
import { promiseInterval } from '../../services/promise_interval/promise_interval.js'
-import { getScrollPosition, getNewTopPosition, isBottomedOut, scrollableContainerHeight, isScrollable } from './chat_layout_utils.js'
+import { getScrollPosition, getNewTopPosition, isBottomedOut, isScrollable } from './chat_layout_utils.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faChevronDown,
@@ -20,7 +20,7 @@ library.add(
)
const BOTTOMED_OUT_OFFSET = 10
-const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 150
+const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 10
const SAFE_RESIZE_TIME_OFFSET = 100
const MARK_AS_READ_DELAY = 1500
const MAX_RETRIES = 10
@@ -43,7 +43,7 @@ const Chat = {
},
created () {
this.startFetching()
- window.addEventListener('resize', this.handleLayoutChange)
+ window.addEventListener('resize', this.handleResize)
},
mounted () {
window.addEventListener('scroll', this.handleScroll)
@@ -52,15 +52,12 @@ const Chat = {
}
this.$nextTick(() => {
- this.updateScrollableContainerHeight()
this.handleResize()
})
- this.setChatLayout()
},
- destroyed () {
+ unmounted () {
window.removeEventListener('scroll', this.handleScroll)
- window.removeEventListener('resize', this.handleLayoutChange)
- this.unsetChatLayout()
+ window.removeEventListener('resize', this.handleResize)
if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false)
this.$store.dispatch('clearCurrentChat')
},
@@ -96,8 +93,7 @@ const Chat = {
...mapState({
backendInteractor: state => state.api.backendInteractor,
mastoUserSocketStatus: state => state.api.mastoUserSocketStatus,
- mobileLayout: state => state.interface.mobileLayout,
- layoutHeight: state => state.interface.layoutHeight,
+ mobileLayout: state => state.interface.layoutType === 'mobile',
currentUser: state => state.users.currentUser
})
},
@@ -112,12 +108,9 @@ const Chat = {
}
})
},
- '$route': function () {
+ $route: function () {
this.startFetching()
},
- layoutHeight () {
- this.handleResize({ expand: true })
- },
mastoUserSocketStatus (newValue) {
if (newValue === WSConnectionStatus.JOINED) {
this.fetchChat({ isFirstFetch: true })
@@ -132,7 +125,6 @@ const Chat = {
onFilesDropped () {
this.$nextTick(() => {
this.handleResize()
- this.updateScrollableContainerHeight()
})
},
handleVisibilityChange () {
@@ -142,45 +134,9 @@ const Chat = {
}
})
},
- setChatLayout () {
- // This is a hacky way to adjust the global layout to the mobile chat (without modifying the rest of the app).
- // This layout prevents empty spaces from being visible at the bottom
- // of the chat on iOS Safari (`safe-area-inset`) when
- // - the on-screen keyboard appears and the user starts typing
- // - the user selects the text inside the input area
- // - the user selects and deletes the text that is multiple lines long
- // TODO: unify the chat layout with the global layout.
- let html = document.querySelector('html')
- if (html) {
- html.classList.add('chat-layout')
- }
-
- this.$nextTick(() => {
- this.updateScrollableContainerHeight()
- })
- },
- unsetChatLayout () {
- let html = document.querySelector('html')
- if (html) {
- html.classList.remove('chat-layout')
- }
- },
- handleLayoutChange () {
- this.$nextTick(() => {
- this.updateScrollableContainerHeight()
- this.scrollDown()
- })
- },
- // Ensures the proper position of the posting form in the mobile layout (the mobile browser panel does not overlap or hide it)
- updateScrollableContainerHeight () {
- const header = this.$refs.header
- const footer = this.$refs.footer
- const inner = this.mobileLayout ? window.document.body : this.$refs.inner
- this.scrollableContainerHeight = scrollableContainerHeight(inner, header, footer) + 'px'
- },
- // Preserves the scroll position when OSK appears or the posting form changes its height.
+ // "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport
handleResize (opts = {}) {
- const { expand = false, delayed = false } = opts
+ const { delayed = false } = opts
if (delayed) {
setTimeout(() => {
@@ -190,29 +146,20 @@ const Chat = {
}
this.$nextTick(() => {
- this.updateScrollableContainerHeight()
-
- const { offsetHeight = undefined } = this.lastScrollPosition
- this.lastScrollPosition = getScrollPosition(this.$refs.scrollable)
-
- const diff = this.lastScrollPosition.offsetHeight - offsetHeight
- if (diff < 0 || (!this.bottomedOut() && expand)) {
+ const { offsetHeight = undefined } = getScrollPosition()
+ const diff = offsetHeight - this.lastScrollPosition.offsetHeight
+ if (diff !== 0 && !this.bottomedOut()) {
this.$nextTick(() => {
- this.updateScrollableContainerHeight()
- this.$refs.scrollable.scrollTo({
- top: this.$refs.scrollable.scrollTop - diff,
- left: 0
- })
+ window.scrollBy({ top: -Math.trunc(diff) })
})
}
+ this.lastScrollPosition = getScrollPosition()
})
},
scrollDown (options = {}) {
const { behavior = 'auto', forceRead = false } = options
- const scrollable = this.$refs.scrollable
- if (!scrollable) { return }
this.$nextTick(() => {
- scrollable.scrollTo({ top: scrollable.scrollHeight, left: 0, behavior })
+ window.scrollTo({ top: document.documentElement.scrollHeight, behavior })
})
if (forceRead) {
this.readChat()
@@ -228,11 +175,10 @@ const Chat = {
})
},
bottomedOut (offset) {
- return isBottomedOut(this.$refs.scrollable, offset)
+ return isBottomedOut(offset)
},
reachedTop () {
- const scrollable = this.$refs.scrollable
- return scrollable && scrollable.scrollTop <= 0
+ return window.scrollY <= 0
},
cullOlderCheck () {
window.setTimeout(() => {
@@ -242,6 +188,7 @@ const Chat = {
}, 5000)
},
handleScroll: _.throttle(function () {
+ this.lastScrollPosition = getScrollPosition()
if (!this.currentChat) { return }
if (this.reachedTop()) {
@@ -263,10 +210,9 @@ const Chat = {
}
}, 200),
handleScrollUp (positionBeforeLoading) {
- const positionAfterLoading = getScrollPosition(this.$refs.scrollable)
- this.$refs.scrollable.scrollTo({
- top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
- left: 0
+ const positionAfterLoading = getScrollPosition()
+ window.scrollTo({
+ top: getNewTopPosition(positionBeforeLoading, positionAfterLoading)
})
},
fetchChat ({ isFirstFetch = false, fetchLatest = false, maxId }) {
@@ -285,22 +231,18 @@ const Chat = {
chatService.clear(chatMessageService)
}
- const positionBeforeUpdate = getScrollPosition(this.$refs.scrollable)
+ const positionBeforeUpdate = getScrollPosition()
this.$store.dispatch('addChatMessages', { chatId, messages }).then(() => {
this.$nextTick(() => {
if (fetchOlderMessages) {
this.handleScrollUp(positionBeforeUpdate)
}
- if (isFirstFetch) {
- this.updateScrollableContainerHeight()
- }
-
// In vertical screens, the first batch of fetched messages may not always take the
// full height of the scrollable container.
// If this is the case, we want to fetch the messages until the scrollable container
// is fully populated so that the user has the ability to scroll up and load the history.
- if (!isScrollable(this.$refs.scrollable) && messages.length > 0) {
+ if (!isScrollable() && messages.length > 0) {
this.fetchChat({ maxId: this.currentChatMessageService.minId })
}
})
@@ -336,9 +278,6 @@ const Chat = {
this.handleResize()
// When the posting form size changes because of a media attachment, we need an extra resize
// to account for the potential delay in the DOM update.
- setTimeout(() => {
- this.updateScrollableContainerHeight()
- }, SAFE_RESIZE_TIME_OFFSET)
this.scrollDown({ forceRead: true })
})
},
diff --git a/src/components/chat/chat.scss b/src/components/chat/chat.scss
index 3a26686c..f2e154ab 100644
--- a/src/components/chat/chat.scss
+++ b/src/components/chat/chat.scss
@@ -1,28 +1,22 @@
.chat-view {
display: flex;
- height: calc(100vh - 60px);
- width: 100%;
-
- .chat-title {
- // prevents chat header jumping on when the user avatar loads
- height: 28px;
- }
+ height: 100%;
.chat-view-inner {
height: auto;
width: 100%;
overflow: visible;
display: flex;
- margin: 0.5em 0.5em 0 0.5em;
}
.chat-view-body {
+ box-sizing: border-box;
background-color: var(--chatBg, $fallback--bg);
display: flex;
flex-direction: column;
width: 100%;
overflow: visible;
- min-height: 100%;
+ min-height: calc(100vh - var(--navbar-height));
margin: 0 0 0 0;
border-radius: 10px 10px 0 0;
border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0;
@@ -32,36 +26,32 @@
}
}
- .scrollable-message-list {
+ .message-list {
padding: 0 0.8em;
height: 100%;
- overflow-y: scroll;
- overflow-x: hidden;
display: flex;
flex-direction: column;
+ justify-content: end;
}
.footer {
position: sticky;
bottom: 0;
+ background-color: $fallback--bg;
+ background-color: var(--bg, $fallback--bg);
+ z-index: 1;
}
.chat-view-heading {
- align-items: center;
- justify-content: space-between;
- top: 50px;
- display: flex;
- z-index: 2;
- position: sticky;
- overflow: hidden;
+ grid-template-columns: auto minmax(50%, 1fr);
}
.go-back-button {
- cursor: pointer;
- width: 28px;
text-align: center;
- padding: 0.6em;
- margin: -0.6em 0.6em -0.6em -0.6em;
+ line-height: 1;
+ height: 100%;
+ align-self: start;
+ width: var(--__panel-heading-height-inner);
}
.jump-to-bottom-button {
@@ -115,56 +105,4 @@
}
}
}
-
- @media all and (max-width: 800px) {
- height: 100%;
- overflow: hidden;
-
- .chat-view-inner {
- overflow: hidden;
- height: 100%;
- margin-top: 0;
- margin-left: 0;
- margin-right: 0;
- }
-
- .chat-view-body {
- display: flex;
- min-height: auto;
- overflow: hidden;
- height: 100%;
- margin: 0;
- border-radius: 0;
- }
-
- .chat-view-heading {
- box-sizing: border-box;
- position: static;
- z-index: 9999;
- top: 0;
- margin-top: 0;
- border-radius: 0;
-
- /* This practically overlays the panel heading color over panel background
- * color. This is needed because we allow transparent panel background and
- * it doesn't work well in this "disjointed panel header" case
- */
- background:
- linear-gradient(to top, var(--panel), var(--panel)),
- linear-gradient(to top, var(--bg), var(--bg));
- height: 50px;
- }
-
- .scrollable-message-list {
- display: unset;
- overflow-y: scroll;
- overflow-x: hidden;
- -webkit-overflow-scrolling: touch;
- }
-
- .footer {
- position: sticky;
- bottom: auto;
- }
- }
}
diff --git a/src/components/chat/chat.vue b/src/components/chat/chat.vue
index 94a0097c..2e7df7bd 100644
--- a/src/components/chat/chat.vue
+++ b/src/components/chat/chat.vue
@@ -2,23 +2,22 @@
<div class="chat-view">
<div class="chat-view-inner">
<div
- id="nav"
ref="inner"
class="panel-default panel chat-view-body"
>
<div
ref="header"
- class="panel-heading chat-view-heading mobile-hidden"
+ class="panel-heading -sticky chat-view-heading"
>
- <a
- class="go-back-button"
+ <button
+ class="button-unstyled go-back-button"
@click="goBack"
>
<FAIcon
size="lg"
icon="chevron-left"
/>
- </a>
+ </button>
<div class="title text-center">
<ChatTitle
:user="recipient"
@@ -26,73 +25,69 @@
/>
</div>
</div>
- <template>
+ <div
+ class="message-list"
+ :style="{ height: scrollableContainerHeight }"
+ >
+ <template v-if="!errorLoadingChat">
+ <ChatMessage
+ v-for="chatViewItem in chatViewItems"
+ :key="chatViewItem.id"
+ :author="recipient"
+ :chat-view-item="chatViewItem"
+ :hovered-message-chain="chatViewItem.messageChainId === hoveredMessageChainId"
+ @hover="onMessageHover"
+ />
+ </template>
<div
- ref="scrollable"
- class="scrollable-message-list"
- :style="{ height: scrollableContainerHeight }"
- @scroll="handleScroll"
+ v-else
+ class="chat-loading-error"
>
- <template v-if="!errorLoadingChat">
- <ChatMessage
- v-for="chatViewItem in chatViewItems"
- :key="chatViewItem.id"
- :author="recipient"
- :chat-view-item="chatViewItem"
- :hovered-message-chain="chatViewItem.messageChainId === hoveredMessageChainId"
- @hover="onMessageHover"
- />
- </template>
- <div
- v-else
- class="chat-loading-error"
- >
- <div class="alert error">
- {{ $t('chats.error_loading_chat') }}
- </div>
+ <div class="alert error">
+ {{ $t('chats.error_loading_chat') }}
</div>
</div>
+ </div>
+ <div
+ ref="footer"
+ class="panel-body footer"
+ >
<div
- ref="footer"
- class="panel-body footer"
+ class="jump-to-bottom-button"
+ :class="{ 'visible': jumpToBottomButtonVisible }"
+ @click="scrollDown({ behavior: 'smooth' })"
>
- <div
- class="jump-to-bottom-button"
- :class="{ 'visible': jumpToBottomButtonVisible }"
- @click="scrollDown({ behavior: 'smooth' })"
- >
- <span>
- <FAIcon icon="chevron-down" />
- <div
- v-if="newMessageCount"
- class="badge badge-notification unread-chat-count unread-message-count"
- >
- {{ newMessageCount }}
- </div>
- </span>
- </div>
- <PostStatusForm
- :disable-subject="true"
- :disable-scope-selector="true"
- :disable-notice="true"
- :disable-lock-warning="true"
- :disable-polls="true"
- :disable-sensitivity-checkbox="true"
- :disable-submit="errorLoadingChat || !currentChat"
- :disable-preview="true"
- :optimistic-posting="true"
- :post-handler="sendMessage"
- :submit-on-enter="!mobileLayout"
- :preserve-focus="!mobileLayout"
- :auto-focus="!mobileLayout"
- :placeholder="formPlaceholder"
- :file-limit="1"
- max-height="160"
- emoji-picker-placement="top"
- @resize="handleResize"
- />
+ <span>
+ <FAIcon icon="chevron-down" />
+ <div
+ v-if="newMessageCount"
+ class="badge badge-notification unread-chat-count unread-message-count"
+ >
+ {{ newMessageCount }}
+ </div>
+ </span>
</div>
- </template>
+ <PostStatusForm
+ :disable-subject="true"
+ :disable-scope-selector="true"
+ :disable-notice="true"
+ :disable-lock-warning="true"
+ :disable-polls="true"
+ :disable-sensitivity-checkbox="true"
+ :disable-submit="errorLoadingChat || !currentChat"
+ :disable-preview="true"
+ :optimistic-posting="true"
+ :post-handler="sendMessage"
+ :submit-on-enter="!mobileLayout"
+ :preserve-focus="!mobileLayout"
+ :auto-focus="!mobileLayout"
+ :placeholder="formPlaceholder"
+ :file-limit="1"
+ max-height="160"
+ emoji-picker-placement="top"
+ @resize="handleResize"
+ />
+ </div>
</div>
</div>
</div>
diff --git a/src/components/chat/chat_layout_utils.js b/src/components/chat/chat_layout_utils.js
index 50a933ac..c187892d 100644
--- a/src/components/chat/chat_layout_utils.js
+++ b/src/components/chat/chat_layout_utils.js
@@ -1,9 +1,9 @@
// Captures a scroll position
-export const getScrollPosition = (el) => {
+export const getScrollPosition = () => {
return {
- scrollTop: el.scrollTop,
- scrollHeight: el.scrollHeight,
- offsetHeight: el.offsetHeight
+ scrollTop: window.scrollY,
+ scrollHeight: document.documentElement.scrollHeight,
+ offsetHeight: window.innerHeight
}
}
@@ -13,21 +13,12 @@ export const getNewTopPosition = (previousPosition, newPosition) => {
return previousPosition.scrollTop + (newPosition.scrollHeight - previousPosition.scrollHeight)
}
-export const isBottomedOut = (el, offset = 0) => {
- if (!el) { return }
- const scrollHeight = el.scrollTop + offset
- const totalHeight = el.scrollHeight - el.offsetHeight
+export const isBottomedOut = (offset = 0) => {
+ const scrollHeight = window.scrollY + offset
+ const totalHeight = document.documentElement.scrollHeight - window.innerHeight
return totalHeight <= scrollHeight
}
-
-// Height of the scrollable container. The dynamic height is needed to ensure the mobile browser panel doesn't overlap or hide the posting form.
-export const scrollableContainerHeight = (inner, header, footer) => {
- return inner.offsetHeight - header.clientHeight - footer.clientHeight
-}
-
// Returns whether or not the scrollbar is visible.
-export const isScrollable = (el) => {
- if (!el) return
-
- return el.scrollHeight > el.clientHeight
+export const isScrollable = () => {
+ return document.documentElement.scrollHeight > window.innerHeight
}
diff --git a/src/components/chat_list/chat_list.vue b/src/components/chat_list/chat_list.vue
index f98b7ed2..1248c4c8 100644
--- a/src/components/chat_list/chat_list.vue
+++ b/src/components/chat_list/chat_list.vue
@@ -6,7 +6,7 @@
v-else
class="chat-list panel panel-default"
>
- <div class="panel-heading">
+ <div class="panel-heading -sticky">
<span class="title">
{{ $t("chats.chats") }}
</span>
@@ -23,7 +23,7 @@
class="timeline"
>
<List :items="sortedChatList">
- <template v-slot:item="{item}">
+ <template #item="{item}">
<ChatListItem
:key="item.id"
:compact="false"
diff --git a/src/components/chat_list_item/chat_list_item.scss b/src/components/chat_list_item/chat_list_item.scss
index 57332bed..c6b45c34 100644
--- a/src/components/chat_list_item/chat_list_item.scss
+++ b/src/components/chat_list_item/chat_list_item.scss
@@ -43,7 +43,7 @@
white-space: nowrap;
overflow: hidden;
flex-shrink: 1;
- line-height: 1.4em;
+ line-height: var(--post-line-height);
}
.chat-preview {
@@ -82,7 +82,7 @@
}
.time-wrapper {
- line-height: 1.4em;
+ line-height: var(--post-line-height);
}
.chat-preview-body {
diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
index eb195bc1..ebe09814 100644
--- a/src/components/chat_message/chat_message.js
+++ b/src/components/chat_message/chat_message.js
@@ -6,7 +6,7 @@ import Gallery from '../gallery/gallery.vue'
import LinkPreview from '../link-preview/link-preview.vue'
import StatusContent from '../status_content/status_content.vue'
import ChatMessageDate from '../chat_message_date/chat_message_date.vue'
-import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import { defineAsyncComponent } from 'vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faTimes,
@@ -27,6 +27,7 @@ const ChatMessage = {
'chatViewItem',
'hoveredMessageChain'
],
+ emits: ['hover'],
components: {
Popover,
Attachment,
@@ -34,7 +35,8 @@ const ChatMessage = {
UserAvatar,
Gallery,
LinkPreview,
- ChatMessageDate
+ ChatMessageDate,
+ UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue'))
},
computed: {
// Returns HH:MM (hours and minutes) in local time.
@@ -48,9 +50,6 @@ const ChatMessage = {
message () {
return this.chatViewItem.data
},
- userProfileLink () {
- return generateProfileLink(this.author.id, this.author.screen_name, this.$store.state.instance.restrictedNicknames)
- },
isMessage () {
return this.chatViewItem.type === 'message'
},
diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss
index fcfa7c8a..1913479f 100644
--- a/src/components/chat_message/chat_message.scss
+++ b/src/components/chat_message/chat_message.scss
@@ -1,6 +1,7 @@
@import '../../_variables.scss';
.chat-message-wrapper {
+
&.hovered-message-chain {
.animated.Avatar {
canvas {
@@ -40,6 +41,12 @@
.chat-message {
display: flex;
padding-bottom: 0.5em;
+
+ .status-body:hover {
+ --_still-image-img-visibility: visible;
+ --_still-image-canvas-visibility: hidden;
+ --_still-image-label-visibility: hidden;
+ }
}
.avatar-wrapper {
@@ -62,10 +69,6 @@
&.with-media {
width: 100%;
- .gallery-row {
- overflow: hidden;
- }
-
.status {
width: 100%;
}
diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue
index d62b831d..d635c47e 100644
--- a/src/components/chat_message/chat_message.vue
+++ b/src/components/chat_message/chat_message.vue
@@ -14,16 +14,16 @@
v-if="!isCurrentUser"
class="avatar-wrapper"
>
- <router-link
+ <UserPopover
v-if="chatViewItem.isHead"
- :to="userProfileLink"
+ :user-id="author.id"
>
<UserAvatar
:compact="true"
:better-shadow="betterShadow"
:user="author"
/>
- </router-link>
+ </UserPopover>
</div>
<div class="chat-message-inner">
<div
@@ -44,13 +44,13 @@
<Popover
trigger="click"
placement="top"
- :bound-to-selector="isCurrentUser ? '' : '.scrollable-message-list'"
+ bound-to-selector=".chat-view-inner"
:bound-to="{ x: 'container' }"
:margin="popoverMarginStyle"
@show="menuOpened = true"
@close="menuOpened = false"
>
- <template v-slot:content>
+ <template #content>
<div class="dropdown-menu">
<button
class="button-default dropdown-item dropdown-item-icon"
@@ -60,7 +60,7 @@
</button>
</div>
</template>
- <template v-slot:trigger>
+ <template #trigger>
<button
class="button-default menu-icon"
:title="$t('chats.more')"
@@ -75,7 +75,7 @@
:status="messageForStatusContent"
:full-content="true"
>
- <template v-slot:footer>
+ <template #footer>
<span
class="created-at"
>
@@ -96,7 +96,7 @@
</div>
</template>
-<script src="./chat_message.js" ></script>
+<script src="./chat_message.js"></script>
<style lang="scss">
@import './chat_message.scss';
diff --git a/src/components/chat_new/chat_new.scss b/src/components/chat_new/chat_new.scss
index 5506143d..240e1a38 100644
--- a/src/components/chat_new/chat_new.scss
+++ b/src/components/chat_new/chat_new.scss
@@ -22,10 +22,10 @@
}
.go-back-button {
- cursor: pointer;
- width: 28px;
text-align: center;
- padding: 0.6em;
- margin: -0.6em 0.6em -0.6em -0.6em;
+ line-height: 1;
+ height: 100%;
+ align-self: start;
+ width: var(--__panel-heading-height-inner);
}
}
diff --git a/src/components/chat_new/chat_new.vue b/src/components/chat_new/chat_new.vue
index f3894a3a..bf09a379 100644
--- a/src/components/chat_new/chat_new.vue
+++ b/src/components/chat_new/chat_new.vue
@@ -1,21 +1,20 @@
<template>
<div
- id="nav"
class="panel-default panel chat-new"
>
<div
ref="header"
class="panel-heading"
>
- <a
- class="go-back-button"
+ <button
+ class="button-unstyled go-back-button"
@click="goBack"
>
<FAIcon
size="lg"
icon="chevron-left"
/>
- </a>
+ </button>
</div>
<div class="input-wrap">
<div class="input-search">
diff --git a/src/components/chat_title/chat_title.js b/src/components/chat_title/chat_title.js
index edfbe7a4..b8721126 100644
--- a/src/components/chat_title/chat_title.js
+++ b/src/components/chat_title/chat_title.js
@@ -1,11 +1,13 @@
-import Vue from 'vue'
-import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import UserAvatar from '../user_avatar/user_avatar.vue'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
+import { defineAsyncComponent } from 'vue'
-export default Vue.component('chat-title', {
+export default {
name: 'ChatTitle',
components: {
- UserAvatar
+ UserAvatar,
+ RichContent,
+ UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue'))
},
props: [
'user', 'withAvatar'
@@ -17,10 +19,5 @@ export default Vue.component('chat-title', {
htmlTitle () {
return this.user ? this.user.name_html : ''
}
- },
- methods: {
- getUserProfileLink (user) {
- return generateProfileLink(user.id, user.screen_name)
- }
}
-})
+}
diff --git a/src/components/chat_title/chat_title.vue b/src/components/chat_title/chat_title.vue
index b16ed39d..ab7491fa 100644
--- a/src/components/chat_title/chat_title.vue
+++ b/src/components/chat_title/chat_title.vue
@@ -1,25 +1,26 @@
<template>
- <!-- eslint-disable vue/no-v-html -->
<div
class="chat-title"
:title="title"
>
- <router-link
+ <UserPopover
v-if="withAvatar && user"
- :to="getUserProfileLink(user)"
+ class="avatar-container"
+ :user-id="user.id"
>
<UserAvatar
+ class="titlebar-avatar"
:user="user"
- width="23px"
- height="23px"
/>
- </router-link>
- <span
+ </UserPopover>
+ <RichContent
+ v-if="user"
class="username"
- v-html="htmlTitle"
+ :title="'@'+(user && user.screen_name_ui)"
+ :html="htmlTitle"
+ :emoji="user.emoji || []"
/>
</div>
- <!-- eslint-enable vue/no-v-html -->
</template>
<script src="./chat_title.js"></script>
@@ -32,7 +33,8 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
- align-items: center;
+
+ --emoji-size: 14px;
.username {
max-width: 100%;
@@ -41,21 +43,17 @@
display: inline;
word-wrap: break-word;
overflow: hidden;
- text-overflow: ellipsis;
+ }
- .emoji {
- width: 14px;
- height: 14px;
- vertical-align: middle;
- object-fit: contain
- }
+ .avatar-container {
+ align-self: center;
+ line-height: 1;
}
- .Avatar {
- width: 23px;
- height: 23px;
+ .titlebar-avatar {
margin-right: 0.5em;
-
+ height: 1.5em;
+ width: 1.5em;
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
diff --git a/src/components/checkbox/checkbox.vue b/src/components/checkbox/checkbox.vue
index d28c2cfd..b6768d67 100644
--- a/src/components/checkbox/checkbox.vue
+++ b/src/components/checkbox/checkbox.vue
@@ -6,9 +6,9 @@
<input
type="checkbox"
:disabled="disabled"
- :checked="checked"
- :indeterminate.prop="indeterminate"
- @change="$emit('change', $event.target.checked)"
+ :checked="modelValue"
+ :indeterminate="indeterminate"
+ @change="$emit('update:modelValue', $event.target.checked)"
>
<i class="checkbox-indicator" />
<span
@@ -22,15 +22,12 @@
<script>
export default {
- model: {
- prop: 'checked',
- event: 'change'
- },
props: [
- 'checked',
+ 'modelValue',
'indeterminate',
'disabled'
- ]
+ ],
+ emits: ['update:modelValue']
}
</script>
diff --git a/src/components/color_input/color_input.scss b/src/components/color_input/color_input.scss
index 8e9923cf..3de31fde 100644
--- a/src/components/color_input/color_input.scss
+++ b/src/components/color_input/color_input.scss
@@ -27,16 +27,16 @@
&.nativeColor {
flex: 0 0 2em;
min-width: 2em;
- align-self: center;
- height: 100%;
+ align-self: stretch;
+ min-height: 100%;
}
}
.computedIndicator,
.transparentIndicator {
flex: 0 0 2em;
min-width: 2em;
- align-self: center;
- height: 100%;
+ align-self: stretch;
+ min-height: 100%;
}
.transparentIndicator {
// forgot to install counter-strike source, ooops
diff --git a/src/components/color_input/color_input.vue b/src/components/color_input/color_input.vue
index 8fb16113..dfc084f9 100644
--- a/src/components/color_input/color_input.vue
+++ b/src/components/color_input/color_input.vue
@@ -11,28 +11,28 @@
</label>
<Checkbox
v-if="typeof fallback !== 'undefined' && showOptionalTickbox"
- :checked="present"
+ :model-value="present"
:disabled="disabled"
class="opt"
- @change="$emit('input', typeof value === 'undefined' ? fallback : undefined)"
+ @update:modelValue="$emit('update:modelValue', typeof modelValue === 'undefined' ? fallback : undefined)"
/>
<div class="input color-input-field">
<input
:id="name + '-t'"
class="textColor unstyled"
type="text"
- :value="value || fallback"
+ :value="modelValue || fallback"
:disabled="!present || disabled"
- @input="$emit('input', $event.target.value)"
+ @input="$emit('update:modelValue', $event.target.value)"
>
<input
v-if="validColor"
:id="name"
class="nativeColor unstyled"
type="color"
- :value="value || fallback"
+ :value="modelValue || fallback"
:disabled="!present || disabled"
- @input="$emit('input', $event.target.value)"
+ @input="$emit('update:modelValue', $event.target.value)"
>
<div
v-if="transparentColor"
@@ -46,7 +46,6 @@
</div>
</div>
</template>
-<style lang="scss" src="./color_input.scss"></style>
<script>
import Checkbox from '../checkbox/checkbox.vue'
import { hex2rgb } from '../../services/color_convert/color_convert.js'
@@ -67,7 +66,7 @@ export default {
},
// Color value, should be required but vue cannot tell the difference
// between "property missing" and "property set to undefined"
- value: {
+ modelValue: {
required: false,
type: String,
default: undefined
@@ -91,22 +90,24 @@ export default {
default: true
}
},
+ emits: ['update:modelValue'],
computed: {
present () {
- return typeof this.value !== 'undefined'
+ return typeof this.modelValue !== 'undefined'
},
validColor () {
- return hex2rgb(this.value || this.fallback)
+ return hex2rgb(this.modelValue || this.fallback)
},
transparentColor () {
- return this.value === 'transparent'
+ return this.modelValue === 'transparent'
},
computedColor () {
- return this.value && this.value.startsWith('--')
+ return this.modelValue && this.modelValue.startsWith('--')
}
}
}
</script>
+<style lang="scss" src="./color_input.scss"></style>
<style lang="scss">
.color-control {
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index 069c0b40..85e6d8ad 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,5 +1,23 @@
import { reduce, filter, findIndex, clone, get } from 'lodash'
import Status from '../status/status.vue'
+import ThreadTree from '../thread_tree/thread_tree.vue'
+import { WSConnectionStatus } from '../../services/api/api.service.js'
+import { mapGetters, mapState } from 'vuex'
+import QuickFilterSettings from '../quick_filter_settings/quick_filter_settings.vue'
+import QuickViewSettings from '../quick_view_settings/quick_view_settings.vue'
+
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faAngleDoubleDown,
+ faAngleDoubleLeft,
+ faChevronLeft
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faAngleDoubleDown,
+ faAngleDoubleLeft,
+ faChevronLeft
+)
const sortById = (a, b) => {
const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
@@ -35,7 +53,10 @@ const conversation = {
data () {
return {
highlight: null,
- expanded: false
+ expanded: false,
+ threadDisplayStatusObject: {}, // id => 'showing' | 'hidden'
+ statusContentPropertiesObject: {},
+ inlineDivePosition: null
}
},
props: [
@@ -53,13 +74,54 @@ const conversation = {
}
},
computed: {
- hideStatus () {
+ maxDepthToShowByDefault () {
+ // maxDepthInThread = max number of depths that is *visible*
+ // since our depth starts with 0 and "showing" means "showing children"
+ // there is a -2 here
+ const maxDepth = this.$store.getters.mergedConfig.maxDepthInThread - 2
+ return maxDepth >= 1 ? maxDepth : 1
+ },
+ streamingEnabled () {
+ return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED
+ },
+ displayStyle () {
+ return this.$store.getters.mergedConfig.conversationDisplay
+ },
+ isTreeView () {
+ return !this.isLinearView
+ },
+ treeViewIsSimple () {
+ return !this.$store.getters.mergedConfig.conversationTreeAdvanced
+ },
+ isLinearView () {
+ return this.displayStyle === 'linear'
+ },
+ shouldFadeAncestors () {
+ return this.$store.getters.mergedConfig.conversationTreeFadeAncestors
+ },
+ otherRepliesButtonPosition () {
+ return this.$store.getters.mergedConfig.conversationOtherRepliesButton
+ },
+ showOtherRepliesButtonBelowStatus () {
+ return this.otherRepliesButtonPosition === 'below'
+ },
+ showOtherRepliesButtonInsideStatus () {
+ return this.otherRepliesButtonPosition === 'inside'
+ },
+ suspendable () {
+ if (this.isTreeView) {
+ return Object.entries(this.statusContentProperties)
+ .every(([k, prop]) => !prop.replying && prop.mediaPlaying.length === 0)
+ }
if (this.$refs.statusComponent && this.$refs.statusComponent[0]) {
- return this.virtualHidden && this.$refs.statusComponent[0].suspendable
+ return this.$refs.statusComponent.every(s => s.suspendable)
} else {
- return this.virtualHidden
+ return true
}
},
+ hideStatus () {
+ return this.virtualHidden && this.suspendable
+ },
status () {
return this.$store.state.statuses.allStatusesObject[this.statusId]
},
@@ -90,6 +152,121 @@ const conversation = {
return sortAndFilterConversation(conversation, this.status)
},
+ statusMap () {
+ return this.conversation.reduce((res, s) => {
+ res[s.id] = s
+ return res
+ }, {})
+ },
+ threadTree () {
+ const reverseLookupTable = this.conversation.reduce((table, status, index) => {
+ table[status.id] = index
+ return table
+ }, {})
+
+ const threads = this.conversation.reduce((a, cur) => {
+ const id = cur.id
+ a.forest[id] = this.getReplies(id)
+ .map(s => s.id)
+
+ return a
+ }, {
+ forest: {}
+ })
+
+ const walk = (forest, topLevel, depth = 0, processed = {}) => topLevel.map(id => {
+ if (processed[id]) {
+ return []
+ }
+
+ processed[id] = true
+ return [{
+ status: this.conversation[reverseLookupTable[id]],
+ id,
+ depth
+ }, walk(forest, forest[id], depth + 1, processed)].reduce((a, b) => a.concat(b), [])
+ }).reduce((a, b) => a.concat(b), [])
+
+ const linearized = walk(threads.forest, this.topLevel.map(k => k.id))
+
+ return linearized
+ },
+ replyIds () {
+ return this.conversation.map(k => k.id)
+ .reduce((res, id) => {
+ res[id] = (this.replies[id] || []).map(k => k.id)
+ return res
+ }, {})
+ },
+ totalReplyCount () {
+ const sizes = {}
+ const subTreeSizeFor = (id) => {
+ if (sizes[id]) {
+ return sizes[id]
+ }
+ sizes[id] = 1 + this.replyIds[id].map(cid => subTreeSizeFor(cid)).reduce((a, b) => a + b, 0)
+ return sizes[id]
+ }
+ this.conversation.map(k => k.id).map(subTreeSizeFor)
+ return Object.keys(sizes).reduce((res, id) => {
+ res[id] = sizes[id] - 1 // exclude itself
+ return res
+ }, {})
+ },
+ totalReplyDepth () {
+ const depths = {}
+ const subTreeDepthFor = (id) => {
+ if (depths[id]) {
+ return depths[id]
+ }
+ depths[id] = 1 + this.replyIds[id].map(cid => subTreeDepthFor(cid)).reduce((a, b) => a > b ? a : b, 0)
+ return depths[id]
+ }
+ this.conversation.map(k => k.id).map(subTreeDepthFor)
+ return Object.keys(depths).reduce((res, id) => {
+ res[id] = depths[id] - 1 // exclude itself
+ return res
+ }, {})
+ },
+ depths () {
+ return this.threadTree.reduce((a, k) => {
+ a[k.id] = k.depth
+ return a
+ }, {})
+ },
+ topLevel () {
+ const topLevel = this.conversation.reduce((tl, cur) =>
+ tl.filter(k => this.getReplies(cur.id).map(v => v.id).indexOf(k.id) === -1), this.conversation)
+ return topLevel
+ },
+ otherTopLevelCount () {
+ return this.topLevel.length - 1
+ },
+ showingTopLevel () {
+ if (this.canDive && this.diveRoot) {
+ return [this.statusMap[this.diveRoot]]
+ }
+ return this.topLevel
+ },
+ diveRoot () {
+ const statusId = this.inlineDivePosition || this.statusId
+ const isTopLevel = !this.parentOf(statusId)
+ return isTopLevel ? null : statusId
+ },
+ diveDepth () {
+ return this.canDive && this.diveRoot ? this.depths[this.diveRoot] : 0
+ },
+ diveMode () {
+ return this.canDive && !!this.diveRoot
+ },
+ shouldShowAllConversationButton () {
+ // The "show all conversation" button tells the user that there exist
+ // other toplevel statuses, so do not show it if there is only a single root
+ return this.isTreeView && this.isExpanded && this.diveMode && this.topLevel.length > 1
+ },
+ shouldShowAncestors () {
+ return this.isTreeView && this.isExpanded && this.ancestorsOf(this.diveRoot).length
+ },
replies () {
let i = 1
// eslint-disable-next-line camelcase
@@ -101,7 +278,7 @@ const conversation = {
result[irid] = result[irid] || []
result[irid].push({
name: `#${i}`,
- id: id
+ id
})
}
i++
@@ -109,15 +286,77 @@ const conversation = {
}, {})
},
isExpanded () {
- return this.expanded || this.isPage
+ return !!(this.expanded || this.isPage)
},
hiddenStyle () {
const height = (this.status && this.status.virtualHeight) || '120px'
return this.virtualHidden ? { height } : {}
- }
+ },
+ threadDisplayStatus () {
+ return this.conversation.reduce((a, k) => {
+ const id = k.id
+ const depth = this.depths[id]
+ const status = (() => {
+ if (this.threadDisplayStatusObject[id]) {
+ return this.threadDisplayStatusObject[id]
+ }
+ if ((depth - this.diveDepth) <= this.maxDepthToShowByDefault) {
+ return 'showing'
+ } else {
+ return 'hidden'
+ }
+ })()
+
+ a[id] = status
+ return a
+ }, {})
+ },
+ statusContentProperties () {
+ return this.conversation.reduce((a, k) => {
+ const id = k.id
+ const props = (() => {
+ const def = {
+ showingTall: false,
+ expandingSubject: false,
+ showingLongSubject: false,
+ isReplying: false,
+ mediaPlaying: []
+ }
+
+ if (this.statusContentPropertiesObject[id]) {
+ return {
+ ...def,
+ ...this.statusContentPropertiesObject[id]
+ }
+ }
+ return def
+ })()
+
+ a[id] = props
+ return a
+ }, {})
+ },
+ canDive () {
+ return this.isTreeView && this.isExpanded
+ },
+ focused () {
+ return (id) => {
+ return (this.isExpanded) && id === this.highlight
+ }
+ },
+ maybeHighlight () {
+ return this.isExpanded ? this.highlight : null
+ },
+ ...mapGetters(['mergedConfig']),
+ ...mapState({
+ mastoUserSocketStatus: state => state.api.mastoUserSocketStatus
+ })
},
components: {
- Status
+ Status,
+ ThreadTree,
+ QuickFilterSettings,
+ QuickViewSettings
},
watch: {
statusId (newVal, oldVal) {
@@ -132,6 +371,8 @@ const conversation = {
expanded (value) {
if (value) {
this.fetchConversation()
+ } else {
+ this.resetDisplayState()
}
},
virtualHidden (value) {
@@ -161,24 +402,153 @@ const conversation = {
getReplies (id) {
return this.replies[id] || []
},
- focused (id) {
- return (this.isExpanded) && id === this.statusId
+ getHighlight () {
+ return this.isExpanded ? this.highlight : null
},
setHighlight (id) {
if (!id) return
this.highlight = id
+
+ if (!this.streamingEnabled) {
+ this.$store.dispatch('fetchStatus', id)
+ }
+
this.$store.dispatch('fetchFavsAndRepeats', id)
this.$store.dispatch('fetchEmojiReactionsBy', id)
},
- getHighlight () {
- return this.isExpanded ? this.highlight : null
- },
toggleExpanded () {
this.expanded = !this.expanded
},
getConversationId (statusId) {
const status = this.$store.state.statuses.allStatusesObject[statusId]
return get(status, 'retweeted_status.statusnet_conversation_id', get(status, 'statusnet_conversation_id'))
+ },
+ setThreadDisplay (id, nextStatus) {
+ this.threadDisplayStatusObject = {
+ ...this.threadDisplayStatusObject,
+ [id]: nextStatus
+ }
+ },
+ toggleThreadDisplay (id) {
+ const curStatus = this.threadDisplayStatus[id]
+ const nextStatus = curStatus === 'showing' ? 'hidden' : 'showing'
+ this.setThreadDisplay(id, nextStatus)
+ },
+ setThreadDisplayRecursively (id, nextStatus) {
+ this.setThreadDisplay(id, nextStatus)
+ this.getReplies(id).map(k => k.id).map(id => this.setThreadDisplayRecursively(id, nextStatus))
+ },
+ showThreadRecursively (id) {
+ this.setThreadDisplayRecursively(id, 'showing')
+ },
+ setStatusContentProperty (id, name, value) {
+ this.statusContentPropertiesObject = {
+ ...this.statusContentPropertiesObject,
+ [id]: {
+ ...this.statusContentPropertiesObject[id],
+ [name]: value
+ }
+ }
+ },
+ toggleStatusContentProperty (id, name) {
+ this.setStatusContentProperty(id, name, !this.statusContentProperties[id][name])
+ },
+ leastVisibleAncestor (id) {
+ let cur = id
+ let parent = this.parentOf(cur)
+ while (cur) {
+ // if the parent is showing it means cur is visible
+ if (this.threadDisplayStatus[parent] === 'showing') {
+ return cur
+ }
+ parent = this.parentOf(parent)
+ cur = this.parentOf(cur)
+ }
+ // nothing found, fall back to toplevel
+ return this.topLevel[0] ? this.topLevel[0].id : undefined
+ },
+ diveIntoStatus (id, preventScroll) {
+ this.tryScrollTo(id)
+ },
+ diveToTopLevel () {
+ this.tryScrollTo(this.topLevelAncestorOrSelfId(this.diveRoot) || this.topLevel[0].id)
+ },
+ // only used when we are not on a page
+ undive () {
+ this.inlineDivePosition = null
+ this.setHighlight(this.statusId)
+ },
+ tryScrollTo (id) {
+ if (!id) {
+ return
+ }
+ if (this.isPage) {
+ // set statusId
+ this.$router.push({ name: 'conversation', params: { id } })
+ } else {
+ this.inlineDivePosition = id
+ }
+ // Because the conversation can be unmounted when out of sight
+ // and mounted again when it comes into sight,
+ // the `mounted` or `created` function in `status` should not
+ // contain scrolling calls, as we do not want the page to jump
+ // when we scroll with an expanded conversation.
+ //
+ // Now the method is to rely solely on the `highlight` watcher
+ // in `status` components.
+ // In linear views, all statuses are rendered at all times, but
+ // in tree views, it is possible that a change in active status
+ // removes and adds status components (e.g. an originally child
+ // status becomes an ancestor status, and thus they will be
+ // different).
+ // Here, let the components be rendered first, in order to trigger
+ // the `highlight` watcher.
+ this.$nextTick(() => {
+ this.setHighlight(id)
+ })
+ },
+ goToCurrent () {
+ this.tryScrollTo(this.diveRoot || this.topLevel[0].id)
+ },
+ statusById (id) {
+ return this.statusMap[id]
+ },
+ parentOf (id) {
+ const status = this.statusById(id)
+ if (!status) {
+ return undefined
+ }
+ const { in_reply_to_status_id: parentId } = status
+ if (!this.statusMap[parentId]) {
+ return undefined
+ }
+ return parentId
+ },
+ parentOrSelf (id) {
+ return this.parentOf(id) || id
+ },
+ // Ancestors of some status, from top to bottom
+ ancestorsOf (id) {
+ const ancestors = []
+ let cur = this.parentOf(id)
+ while (cur) {
+ ancestors.unshift(this.statusMap[cur])
+ cur = this.parentOf(cur)
+ }
+ return ancestors
+ },
+ topLevelAncestorOrSelfId (id) {
+ let cur = id
+ let parent = this.parentOf(id)
+ while (parent) {
+ cur = this.parentOf(cur)
+ parent = this.parentOf(parent)
+ }
+ return cur
+ },
+ resetDisplayState () {
+ this.undive()
+ this.threadDisplayStatusObject = {}
}
}
}
diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue
index 3fb26d92..afa04db0 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -7,7 +7,7 @@
>
<div
v-if="isExpanded"
- class="panel-heading conversation-heading"
+ class="panel-heading conversation-heading -sticky"
>
<span class="title"> {{ $t('timeline.conversation') }} </span>
<button
@@ -17,25 +17,189 @@
>
{{ $t('timeline.collapse') }}
</button>
+ <QuickFilterSettings
+ v-if="!collapsable"
+ :conversation="true"
+ class="rightside-button"
+ />
+ <QuickViewSettings
+ v-if="!collapsable"
+ :conversation="true"
+ class="rightside-button"
+ />
+ </div>
+ <div class="conversation-body panel-body">
+ <div
+ v-if="isTreeView"
+ class="thread-body"
+ >
+ <div
+ v-if="shouldShowAllConversationButton"
+ class="conversation-dive-to-top-level-box"
+ >
+ <i18n-t
+ keypath="status.show_all_conversation_with_icon"
+ tag="button"
+ class="button-unstyled -link"
+ scope="global"
+ @click.prevent="diveToTopLevel"
+ >
+ <template #icon>
+ <FAIcon
+ icon="angle-double-left"
+ />
+ </template>
+ <template #text>
+ <span>
+ {{ $tc('status.show_all_conversation', otherTopLevelCount, { numStatus: otherTopLevelCount }) }}
+ </span>
+ </template>
+ </i18n-t>
+ </div>
+ <div
+ v-if="shouldShowAncestors"
+ class="thread-ancestors"
+ >
+ <article
+ v-for="status in ancestorsOf(diveRoot)"
+ :key="status.id"
+ class="thread-ancestor"
+ :class="{'thread-ancestor-has-other-replies': getReplies(status.id).length > 1, '-faded': shouldFadeAncestors}"
+ >
+ <status
+ ref="statusComponent"
+ :inline-expanded="collapsable && isExpanded"
+ :statusoid="status"
+ :expandable="!isExpanded"
+ :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
+ :focused="focused(status.id)"
+ :in-conversation="isExpanded"
+ :highlight="getHighlight()"
+ :replies="getReplies(status.id)"
+ :in-profile="inProfile"
+ :profile-user-id="profileUserId"
+ class="conversation-status status-fadein panel-body"
+
+ :simple-tree="treeViewIsSimple"
+ :toggle-thread-display="toggleThreadDisplay"
+ :thread-display-status="threadDisplayStatus"
+ :show-thread-recursively="showThreadRecursively"
+ :total-reply-count="totalReplyCount"
+ :total-reply-depth="totalReplyDepth"
+ :show-other-replies-as-button="showOtherRepliesButtonInsideStatus"
+ :dive="() => diveIntoStatus(status.id)"
+
+ :controlled-showing-tall="statusContentProperties[status.id].showingTall"
+ :controlled-expanding-subject="statusContentProperties[status.id].expandingSubject"
+ :controlled-showing-long-subject="statusContentProperties[status.id].showingLongSubject"
+ :controlled-replying="statusContentProperties[status.id].replying"
+ :controlled-media-playing="statusContentProperties[status.id].mediaPlaying"
+ :controlled-toggle-showing-tall="() => toggleStatusContentProperty(status.id, 'showingTall')"
+ :controlled-toggle-expanding-subject="() => toggleStatusContentProperty(status.id, 'expandingSubject')"
+ :controlled-toggle-showing-long-subject="() => toggleStatusContentProperty(status.id, 'showingLongSubject')"
+ :controlled-toggle-replying="() => toggleStatusContentProperty(status.id, 'replying')"
+ :controlled-set-media-playing="(newVal) => toggleStatusContentProperty(status.id, 'mediaPlaying', newVal)"
+
+ @goto="setHighlight"
+ @toggleExpanded="toggleExpanded"
+ />
+ <div
+ v-if="showOtherRepliesButtonBelowStatus && getReplies(status.id).length > 1"
+ class="thread-ancestor-dive-box"
+ >
+ <div
+ class="thread-ancestor-dive-box-inner"
+ >
+ <i18n-t
+ tag="button"
+ scope="global"
+ keypath="status.ancestor_follow_with_icon"
+ class="button-unstyled -link thread-tree-show-replies-button"
+ @click.prevent="diveIntoStatus(status.id)"
+ >
+ <template #icon>
+ <FAIcon
+ icon="angle-double-right"
+ />
+ </template>
+ <template #text>
+ <span>
+ {{ $tc('status.ancestor_follow', getReplies(status.id).length - 1, { numReplies: getReplies(status.id).length - 1 }) }}
+ </span>
+ </template>
+ </i18n-t>
+ </div>
+ </div>
+ </article>
+ </div>
+ <thread-tree
+ v-for="status in showingTopLevel"
+ :key="status.id"
+ ref="statusComponent"
+ :depth="0"
+
+ :status="status"
+ :in-profile="inProfile"
+ :conversation="conversation"
+ :collapsable="collapsable"
+ :is-expanded="isExpanded"
+ :pinned-status-ids-object="pinnedStatusIdsObject"
+ :profile-user-id="profileUserId"
+
+ :focused="focused"
+ :get-replies="getReplies"
+ :highlight="maybeHighlight"
+ :set-highlight="setHighlight"
+ :toggle-expanded="toggleExpanded"
+
+ :simple="treeViewIsSimple"
+ :toggle-thread-display="toggleThreadDisplay"
+ :thread-display-status="threadDisplayStatus"
+ :show-thread-recursively="showThreadRecursively"
+ :total-reply-count="totalReplyCount"
+ :total-reply-depth="totalReplyDepth"
+ :status-content-properties="statusContentProperties"
+ :set-status-content-property="setStatusContentProperty"
+ :toggle-status-content-property="toggleStatusContentProperty"
+ :dive="canDive ? diveIntoStatus : undefined"
+ />
+ </div>
+ <div
+ v-if="isLinearView"
+ class="thread-body"
+ >
+ <article>
+ <status
+ v-for="status in conversation"
+ :key="status.id"
+ ref="statusComponent"
+ :inline-expanded="collapsable && isExpanded"
+ :statusoid="status"
+ :expandable="!isExpanded"
+ :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
+ :focused="focused(status.id)"
+ :in-conversation="isExpanded"
+ :highlight="getHighlight()"
+ :replies="getReplies(status.id)"
+ :in-profile="inProfile"
+ :profile-user-id="profileUserId"
+ class="conversation-status status-fadein panel-body"
+
+ :toggle-thread-display="toggleThreadDisplay"
+ :thread-display-status="threadDisplayStatus"
+ :show-thread-recursively="showThreadRecursively"
+ :total-reply-count="totalReplyCount"
+ :total-reply-depth="totalReplyDepth"
+ :status-content-properties="statusContentProperties"
+ :set-status-content-property="setStatusContentProperty"
+ :toggle-status-content-property="toggleStatusContentProperty"
+
+ @goto="setHighlight"
+ @toggleExpanded="toggleExpanded"
+ />
+ </article>
+ </div>
</div>
- <status
- v-for="status in conversation"
- :key="status.id"
- ref="statusComponent"
- :inline-expanded="collapsable && isExpanded"
- :statusoid="status"
- :expandable="!isExpanded"
- :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
- :focused="focused(status.id)"
- :in-conversation="isExpanded"
- :highlight="getHighlight()"
- :replies="getReplies(status.id)"
- :in-profile="inProfile"
- :profile-user-id="profileUserId"
- class="conversation-status status-fadein panel-body"
- @goto="setHighlight"
- @toggleExpanded="toggleExpanded"
- />
</div>
<div
v-else
@@ -49,19 +213,82 @@
@import '../../_variables.scss';
.Conversation {
- .conversation-status {
+ z-index: 1;
+
+ .conversation-dive-to-top-level-box {
+ padding: var(--status-margin, $status-margin);
border-bottom-width: 1px;
border-bottom-style: solid;
border-bottom-color: var(--border, $fallback--border);
border-radius: 0;
+ /* Make the button stretch along the whole row */
+ display: flex;
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .thread-ancestors {
+ margin-left: var(--status-margin, $status-margin);
+ border-left: 2px solid var(--border, $fallback--border);
}
- &.-expanded {
- .conversation-status:last-child {
- border-bottom: none;
- border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
- border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
+ .thread-ancestor.-faded .StatusContent {
+ --link: var(--faintLink);
+ --text: var(--faint);
+ color: var(--text);
+ }
+
+ .thread-ancestor-dive-box {
+ padding-left: var(--status-margin, $status-margin);
+ border-bottom-width: 1px;
+ border-bottom-style: solid;
+ border-bottom-color: var(--border, $fallback--border);
+ border-radius: 0;
+ /* Make the button stretch along the whole row */
+ &, &-inner {
+ display: flex;
+ align-items: stretch;
+ flex-direction: column;
}
}
+ .thread-ancestor-dive-box-inner {
+ padding: var(--status-margin, $status-margin);
+ }
+
+ .conversation-status {
+ border-bottom-width: 1px;
+ border-bottom-style: solid;
+ border-bottom-color: var(--border, $fallback--border);
+ border-radius: 0;
+ }
+
+ .thread-ancestor-has-other-replies .conversation-status,
+ .thread-ancestor:last-child .conversation-status,
+ .thread-ancestor:last-child .thread-ancestor-dive-box,
+ &:last-child .conversation-status,
+ &.-expanded .thread-tree .conversation-status {
+ border-bottom: none;
+ }
+
+ .thread-ancestors + .thread-tree > .conversation-status {
+ border-top-width: 1px;
+ border-top-style: solid;
+ border-top-color: var(--border, $fallback--border);
+ }
+
+ /* expanded conversation in timeline */
+ &.status-fadein.-expanded .thread-body {
+ border-left-width: 4px;
+ border-left-style: solid;
+ border-left-color: $fallback--cRed;
+ border-left-color: var(--cRed, $fallback--cRed);
+ border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
+ border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
+ border-bottom: 1px solid var(--border, $fallback--border);
+ }
+
+ &.-expanded.status-fadein {
+ margin: calc(var(--status-margin, $status-margin) / 2);
+ }
}
</style>
diff --git a/src/components/desktop_nav/desktop_nav.js b/src/components/desktop_nav/desktop_nav.js
index e048f53d..08c0e44e 100644
--- a/src/components/desktop_nav/desktop_nav.js
+++ b/src/components/desktop_nav/desktop_nav.js
@@ -46,23 +46,27 @@ export default {
enableMask () { return this.supportsMask && this.$store.state.instance.logoMask },
logoStyle () {
return {
- 'visibility': this.enableMask ? 'hidden' : 'visible'
+ visibility: this.enableMask ? 'hidden' : 'visible'
}
},
logoMaskStyle () {
- return this.enableMask ? {
- 'mask-image': `url(${this.$store.state.instance.logo})`
- } : {
- 'background-color': this.enableMask ? '' : 'transparent'
- }
+ return this.enableMask
+ ? {
+ 'mask-image': `url(${this.$store.state.instance.logo})`
+ }
+ : {
+ 'background-color': this.enableMask ? '' : 'transparent'
+ }
},
logoBgStyle () {
return Object.assign({
- 'margin': `${this.$store.state.instance.logoMargin} 0`,
+ margin: `${this.$store.state.instance.logoMargin} 0`,
opacity: this.searchBarHidden ? 1 : 0
- }, this.enableMask ? {} : {
- 'background-color': this.enableMask ? '' : 'transparent'
- })
+ }, this.enableMask
+ ? {}
+ : {
+ 'background-color': this.enableMask ? '' : 'transparent'
+ })
},
logo () { return this.$store.state.instance.logo },
sitename () { return this.$store.state.instance.name },
diff --git a/src/components/desktop_nav/desktop_nav.scss b/src/components/desktop_nav/desktop_nav.scss
index 2d468588..1ec25385 100644
--- a/src/components/desktop_nav/desktop_nav.scss
+++ b/src/components/desktop_nav/desktop_nav.scss
@@ -1,9 +1,12 @@
@import '../../_variables.scss';
.DesktopNav {
- height: 50px;
width: 100%;
- position: fixed;
+ z-index: var(--ZI_navbar);
+
+ input {
+ color: var(--inputTopbarText, var(--inputText));
+ }
a {
color: var(--topBarLink, $fallback--link);
@@ -11,7 +14,7 @@
.inner-nav {
display: grid;
- grid-template-rows: 50px;
+ grid-template-rows: var(--navbar-height);
grid-template-columns: 2fr auto 2fr;
grid-template-areas: "sitename logo actions";
box-sizing: border-box;
@@ -20,7 +23,27 @@
max-width: 980px;
}
- &.-logoLeft {
+ &.-column-stretch .inner-nav {
+ --miniColumn: 25rem;
+ --maxiColumn: 45rem;
+ --columnGap: 1em;
+ max-width: calc(
+ var(--sidebarColumnWidth, var(--miniColumn)) +
+ var(--contentColumnWidth, var(--maxiColumn)) +
+ var(--columnGap)
+ );
+ }
+
+ &.-column-stretch.-wide .inner-nav {
+ max-width: calc(
+ var(--sidebarColumnWidth, var(--miniColumn)) +
+ var(--contentColumnWidth, var(--maxiColumn)) +
+ var(--notifsColumnWidth, var(--miniColumn)) +
+ var(--columnGap)
+ );
+ }
+
+ &.-logoLeft .inner-nav {
grid-template-columns: auto 2fr 2fr;
grid-template-areas: "logo sitename actions";
}
@@ -77,7 +100,7 @@
img {
display: inline-block;
- height: 50px;
+ height: var(--navbar-height);
}
}
@@ -103,8 +126,8 @@
.item {
flex: 1;
- line-height: 50px;
- height: 50px;
+ line-height: var(--navbar-height);
+ height: var(--navbar-height);
overflow: hidden;
display: flex;
flex-wrap: wrap;
@@ -114,4 +137,8 @@
text-align: right;
}
}
+
+ .spacer {
+ width: 1em;
+ }
}
diff --git a/src/components/desktop_nav/desktop_nav.vue b/src/components/desktop_nav/desktop_nav.vue
index 762aa610..5db7fc79 100644
--- a/src/components/desktop_nav/desktop_nav.vue
+++ b/src/components/desktop_nav/desktop_nav.vue
@@ -34,11 +34,11 @@
<search-bar
v-if="currentUser || !privateMode"
@toggled="onSearchBarToggled"
- @click.stop.native
+ @click.stop
/>
<button
class="button-unstyled nav-icon"
- @click.stop="openSettingsModal"
+ @click="openSettingsModal"
>
<FAIcon
fixed-width
@@ -52,6 +52,7 @@
href="/pleroma/admin/#/login-pleroma"
class="nav-icon"
target="_blank"
+ @click.stop
>
<FAIcon
fixed-width
@@ -60,6 +61,7 @@
:title="$t('nav.administration')"
/>
</a>
+ <span class="spacer" />
<button
v-if="currentUser"
class="button-unstyled nav-icon"
diff --git a/src/components/dialog_modal/dialog_modal.vue b/src/components/dialog_modal/dialog_modal.vue
index 3241ce3e..06b270c3 100644
--- a/src/components/dialog_modal/dialog_modal.vue
+++ b/src/components/dialog_modal/dialog_modal.vue
@@ -58,16 +58,7 @@
background-color: var(--bg, $fallback--bg);
.dialog-modal-heading {
- padding: .5em .5em;
- margin-right: auto;
- margin-bottom: 0;
- white-space: nowrap;
- color: var(--panelText);
- background-color: $fallback--fg;
- background-color: var(--panel, $fallback--fg);
-
.title {
- margin-bottom: 0;
text-align: center;
}
}
diff --git a/src/components/domain_mute_card/domain_mute_card.vue b/src/components/domain_mute_card/domain_mute_card.vue
index 836688aa..28c61631 100644
--- a/src/components/domain_mute_card/domain_mute_card.vue
+++ b/src/components/domain_mute_card/domain_mute_card.vue
@@ -9,7 +9,7 @@
class="btn button-default"
>
{{ $t('domain_mute_card.unmute') }}
- <template v-slot:progress>
+ <template #progress>
{{ $t('domain_mute_card.unmute_progress') }}
</template>
</ProgressButton>
@@ -19,7 +19,7 @@
class="btn button-default"
>
{{ $t('domain_mute_card.mute') }}
- <template v-slot:progress>
+ <template #progress>
{{ $t('domain_mute_card.mute_progress') }}
</template>
</ProgressButton>
diff --git a/src/components/edit_status_modal/edit_status_modal.js b/src/components/edit_status_modal/edit_status_modal.js
new file mode 100644
index 00000000..75adfea7
--- /dev/null
+++ b/src/components/edit_status_modal/edit_status_modal.js
@@ -0,0 +1,75 @@
+import PostStatusForm from '../post_status_form/post_status_form.vue'
+import Modal from '../modal/modal.vue'
+import statusPosterService from '../../services/status_poster/status_poster.service.js'
+import get from 'lodash/get'
+
+const EditStatusModal = {
+ components: {
+ PostStatusForm,
+ Modal
+ },
+ data () {
+ return {
+ resettingForm: false
+ }
+ },
+ computed: {
+ isLoggedIn () {
+ return !!this.$store.state.users.currentUser
+ },
+ modalActivated () {
+ return this.$store.state.editStatus.modalActivated
+ },
+ isFormVisible () {
+ return this.isLoggedIn && !this.resettingForm && this.modalActivated
+ },
+ params () {
+ return this.$store.state.editStatus.params || {}
+ }
+ },
+ watch: {
+ params (newVal, oldVal) {
+ if (get(newVal, 'statusId') !== get(oldVal, 'statusId')) {
+ this.resettingForm = true
+ this.$nextTick(() => {
+ this.resettingForm = false
+ })
+ }
+ },
+ isFormVisible (val) {
+ if (val) {
+ this.$nextTick(() => this.$el && this.$el.querySelector('textarea').focus())
+ }
+ }
+ },
+ methods: {
+ doEditStatus ({ status, spoilerText, sensitive, media, contentType, poll }) {
+ const params = {
+ store: this.$store,
+ statusId: this.$store.state.editStatus.params.statusId,
+ status,
+ spoilerText,
+ sensitive,
+ poll,
+ media,
+ contentType
+ }
+
+ return statusPosterService.editStatus(params)
+ .then((data) => {
+ return data
+ })
+ .catch((err) => {
+ console.error('Error editing status', err)
+ return {
+ error: err.message
+ }
+ })
+ },
+ closeModal () {
+ this.$store.dispatch('closeEditStatusModal')
+ }
+ }
+}
+
+export default EditStatusModal
diff --git a/src/components/edit_status_modal/edit_status_modal.vue b/src/components/edit_status_modal/edit_status_modal.vue
new file mode 100644
index 00000000..1dbacaab
--- /dev/null
+++ b/src/components/edit_status_modal/edit_status_modal.vue
@@ -0,0 +1,48 @@
+<template>
+ <Modal
+ v-if="isFormVisible"
+ class="edit-form-modal-view"
+ @backdropClicked="closeModal"
+ >
+ <div class="edit-form-modal-panel panel">
+ <div class="panel-heading">
+ {{ $t('post_status.edit_status') }}
+ </div>
+ <PostStatusForm
+ class="panel-body"
+ v-bind="params"
+ :post-handler="doEditStatus"
+ :disable-polls="true"
+ :disable-visibility-selector="true"
+ @posted="closeModal"
+ />
+ </div>
+ </Modal>
+</template>
+
+<script src="./edit_status_modal.js"></script>
+
+<style lang="scss">
+.modal-view.edit-form-modal-view {
+ align-items: flex-start;
+}
+.edit-form-modal-panel {
+ flex-shrink: 0;
+ margin-top: 25%;
+ margin-bottom: 2em;
+ width: 100%;
+ max-width: 700px;
+
+ @media (orientation: landscape) {
+ margin-top: 8%;
+ }
+
+ .form-bottom-left {
+ max-width: 6.5em;
+
+ .emoji-icon {
+ justify-content: right;
+ }
+ }
+}
+</style>
diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js
index 902ec384..ba5f7552 100644
--- a/src/components/emoji_input/emoji_input.js
+++ b/src/components/emoji_input/emoji_input.js
@@ -1,8 +1,10 @@
import Completion from '../../services/completion/completion.js'
import EmojiPicker from '../emoji_picker/emoji_picker.vue'
+import Popover from 'src/components/popover/popover.vue'
+import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
import { take } from 'lodash'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
-
+import { ensureFinalFallback } from '../../i18n/languages.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faSmileBeam
@@ -31,6 +33,7 @@ library.add(
*/
const EmojiInput = {
+ emits: ['update:modelValue', 'shown'],
props: {
suggest: {
/**
@@ -57,8 +60,7 @@ const EmojiInput = {
required: true,
type: Function
},
- // TODO VUE3: change to modelValue, change 'input' event to 'input'
- value: {
+ modelValue: {
/**
* Used for v-model
*/
@@ -108,46 +110,122 @@ const EmojiInput = {
data () {
return {
input: undefined,
+ caretEl: undefined,
highlighted: 0,
caret: 0,
focused: false,
blurTimeout: null,
- showPicker: false,
temporarilyHideSuggestions: false,
- keepOpen: false,
disableClickOutside: false,
- suggestions: []
+ suggestions: [],
+ overlayStyle: {},
+ pickerShown: false
}
},
components: {
- EmojiPicker
+ Popover,
+ EmojiPicker,
+ UnicodeDomainIndicator
},
computed: {
padEmoji () {
return this.$store.getters.mergedConfig.padEmoji
},
+ preText () {
+ return this.modelValue.slice(0, this.caret)
+ },
+ postText () {
+ return this.modelValue.slice(this.caret)
+ },
showSuggestions () {
return this.focused &&
this.suggestions &&
this.suggestions.length > 0 &&
- !this.showPicker &&
+ !this.pickerShown &&
!this.temporarilyHideSuggestions
},
textAtCaret () {
- return (this.wordAtCaret || {}).word || ''
+ return this.wordAtCaret?.word
},
wordAtCaret () {
- if (this.value && this.caret) {
- const word = Completion.wordAtPosition(this.value, this.caret - 1) || {}
+ if (this.modelValue && this.caret) {
+ const word = Completion.wordAtPosition(this.modelValue, this.caret - 1) || {}
return word
}
+ },
+ languages () {
+ return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage)
+ },
+ maybeLocalizedEmojiNamesAndKeywords () {
+ return emoji => {
+ const names = [emoji.displayText]
+ const keywords = []
+
+ if (emoji.displayTextI18n) {
+ names.push(this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args))
+ }
+
+ if (emoji.annotations) {
+ this.languages.forEach(lang => {
+ names.push(emoji.annotations[lang]?.name)
+
+ keywords.push(...(emoji.annotations[lang]?.keywords || []))
+ })
+ }
+
+ return {
+ names: names.filter(k => k),
+ keywords: keywords.filter(k => k)
+ }
+ }
+ },
+ maybeLocalizedEmojiName () {
+ return emoji => {
+ if (!emoji.annotations) {
+ return emoji.displayText
+ }
+
+ if (emoji.displayTextI18n) {
+ return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)
+ }
+
+ for (const lang of this.languages) {
+ if (emoji.annotations[lang]?.name) {
+ return emoji.annotations[lang].name
+ }
+ }
+
+ return emoji.displayText
+ }
+ },
+ onInputScroll () {
+ this.$refs.hiddenOverlay.scrollTo({
+ top: this.input.scrollTop,
+ left: this.input.scrollLeft
+ })
}
},
mounted () {
- const { root } = this.$refs
+ const { root, hiddenOverlayCaret, suggestorPopover } = this.$refs
const input = root.querySelector('.emoji-input > input') || root.querySelector('.emoji-input > textarea')
if (!input) return
this.input = input
+ this.caretEl = hiddenOverlayCaret
+ if (suggestorPopover.setAnchorEl) {
+ suggestorPopover.setAnchorEl(this.caretEl) // unit test compat
+ this.$refs.picker.setAnchorEl(this.caretEl)
+ } else {
+ console.warn('setAnchorEl not found, are we in a unit test?')
+ }
+ const style = getComputedStyle(this.input)
+ this.overlayStyle.padding = style.padding
+ this.overlayStyle.border = style.border
+ this.overlayStyle.margin = style.margin
+ this.overlayStyle.lineHeight = style.lineHeight
+ this.overlayStyle.fontFamily = style.fontFamily
+ this.overlayStyle.fontSize = style.fontSize
+ this.overlayStyle.wordWrap = style.wordWrap
+ this.overlayStyle.whiteSpace = style.whiteSpace
this.resize()
input.addEventListener('blur', this.onBlur)
input.addEventListener('focus', this.onFocus)
@@ -157,6 +235,7 @@ const EmojiInput = {
input.addEventListener('click', this.onClickInput)
input.addEventListener('transitionend', this.onTransition)
input.addEventListener('input', this.onInput)
+ input.addEventListener('scroll', this.onInputScroll)
},
unmounted () {
const { input } = this
@@ -169,43 +248,43 @@ const EmojiInput = {
input.removeEventListener('click', this.onClickInput)
input.removeEventListener('transitionend', this.onTransition)
input.removeEventListener('input', this.onInput)
+ input.removeEventListener('scroll', this.onInputScroll)
}
},
watch: {
- showSuggestions: function (newValue) {
+ showSuggestions: function (newValue, oldValue) {
this.$emit('shown', newValue)
+ if (newValue) {
+ this.$refs.suggestorPopover.showPopover()
+ } else {
+ this.$refs.suggestorPopover.hidePopover()
+ }
},
textAtCaret: async function (newWord) {
+ if (newWord === undefined) return
const firstchar = newWord.charAt(0)
- this.suggestions = []
- if (newWord === firstchar) return
- const matchedSuggestions = await this.suggest(newWord)
+ if (newWord === firstchar) {
+ this.suggestions = []
+ return
+ }
+ const matchedSuggestions = await this.suggest(newWord, this.maybeLocalizedEmojiNamesAndKeywords)
// Async: cancel if textAtCaret has changed during wait
- if (this.textAtCaret !== newWord) return
- if (matchedSuggestions.length <= 0) return
+ if (this.textAtCaret !== newWord || matchedSuggestions.length <= 0) {
+ this.suggestions = []
+ return
+ }
this.suggestions = take(matchedSuggestions, 5)
.map(({ imageUrl, ...rest }) => ({
...rest,
img: imageUrl || ''
}))
- },
- suggestions (newValue) {
- this.$nextTick(this.resize)
}
},
methods: {
- focusPickerInput () {
- const pickerEl = this.$refs.picker.$el
- if (!pickerEl) return
- const pickerInput = pickerEl.querySelector('input')
- if (pickerInput) pickerInput.focus()
- },
triggerShowPicker () {
- this.showPicker = true
- this.$refs.picker.startEmojiLoad()
this.$nextTick(() => {
+ this.$refs.picker.showPicker()
this.scrollIntoView()
- this.focusPickerInput()
})
// This temporarily disables "click outside" handler
// since external trigger also means click originates
@@ -217,21 +296,22 @@ const EmojiInput = {
},
togglePicker () {
this.input.focus()
- this.showPicker = !this.showPicker
- if (this.showPicker) {
+ if (!this.pickerShown) {
this.scrollIntoView()
+ this.$refs.picker.showPicker()
this.$refs.picker.startEmojiLoad()
- this.$nextTick(this.focusPickerInput)
+ } else {
+ this.$refs.picker.hidePicker()
}
},
replace (replacement) {
- const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
- this.$emit('input', newValue)
+ const newValue = Completion.replaceWord(this.modelValue, this.wordAtCaret, replacement)
+ this.$emit('update:modelValue', newValue)
this.caret = 0
},
insert ({ insertion, keepOpen, surroundingSpace = true }) {
- const before = this.value.substring(0, this.caret) || ''
- const after = this.value.substring(this.caret) || ''
+ const before = this.modelValue.substring(0, this.caret) || ''
+ const after = this.modelValue.substring(this.caret) || ''
/* Using a bit more smart approach to padding emojis with spaces:
* - put a space before cursor if there isn't one already, unless we
@@ -258,8 +338,7 @@ const EmojiInput = {
spaceAfter,
after
].join('')
- this.keepOpen = keepOpen
- this.$emit('input', newValue)
+ this.$emit('update:modelValue', newValue)
const position = this.caret + (insertion + spaceAfter + spaceBefore).length
if (!keepOpen) {
this.input.focus()
@@ -278,8 +357,8 @@ const EmojiInput = {
if (len > 0 || suggestion) {
const chosenSuggestion = suggestion || this.suggestions[this.highlighted]
const replacement = chosenSuggestion.replacement
- const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
- this.$emit('input', newValue)
+ const newValue = Completion.replaceWord(this.modelValue, this.wordAtCaret, replacement)
+ this.$emit('update:modelValue', newValue)
this.highlighted = 0
const position = this.wordAtCaret.start + replacement.length
@@ -318,7 +397,7 @@ const EmojiInput = {
}
},
scrollIntoView () {
- const rootRef = this.$refs['picker'].$el
+ const rootRef = this.$refs.picker.$el
/* Scroller is either `window` (replies in TL), sidebar (main post form,
* replies in notifs) or mobile post form. Note that getting and setting
* scroll is different for `Window` and `Element`s
@@ -358,8 +437,11 @@ const EmojiInput = {
}
})
},
- onTransition (e) {
- this.resize()
+ onPickerShown () {
+ this.pickerShown = true
+ },
+ onPickerClosed () {
+ this.pickerShown = false
},
onBlur (e) {
// Clicking on any suggestion removes focus from autocomplete,
@@ -367,7 +449,6 @@ const EmojiInput = {
this.blurTimeout = setTimeout(() => {
this.focused = false
this.setCaret(e)
- this.resize()
}, 200)
},
onClick (e, suggestion) {
@@ -379,18 +460,13 @@ const EmojiInput = {
this.blurTimeout = null
}
- if (!this.keepOpen) {
- this.showPicker = false
- }
this.focused = true
this.setCaret(e)
- this.resize()
this.temporarilyHideSuggestions = false
},
onKeyUp (e) {
const { key } = e
this.setCaret(e)
- this.resize()
// Setting hider in keyUp to prevent suggestions from blinking
// when moving away from suggested spot
@@ -402,7 +478,6 @@ const EmojiInput = {
},
onPaste (e) {
this.setCaret(e)
- this.resize()
},
onKeyDown (e) {
const { ctrlKey, shiftKey, key } = e
@@ -447,58 +522,24 @@ const EmojiInput = {
this.input.focus()
}
}
-
- this.showPicker = false
- this.resize()
},
onInput (e) {
- this.showPicker = false
this.setCaret(e)
- this.resize()
- this.$emit('input', e.target.value)
- },
- onClickInput (e) {
- this.showPicker = false
- },
- onClickOutside (e) {
- if (this.disableClickOutside) return
- this.showPicker = false
+ this.$emit('update:modelValue', e.target.value)
},
onStickerUploaded (e) {
- this.showPicker = false
this.$emit('sticker-uploaded', e)
},
onStickerUploadFailed (e) {
- this.showPicker = false
this.$emit('sticker-upload-Failed', e)
},
setCaret ({ target: { selectionStart } }) {
this.caret = selectionStart
+ this.$nextTick(() => {
+ this.$refs.suggestorPopover.updateStyles()
+ })
},
resize () {
- const panel = this.$refs.panel
- if (!panel) return
- const picker = this.$refs.picker.$el
- const panelBody = this.$refs['panel-body']
- const { offsetHeight, offsetTop } = this.input
- const offsetBottom = offsetTop + offsetHeight
-
- this.setPlacement(panelBody, panel, offsetBottom)
- this.setPlacement(picker, picker, offsetBottom)
- },
- setPlacement (container, target, offsetBottom) {
- if (!container || !target) return
-
- target.style.top = offsetBottom + 'px'
- target.style.bottom = 'auto'
-
- if (this.placement === 'top' || (this.placement === 'auto' && this.overflowsBottom(container))) {
- target.style.top = 'auto'
- target.style.bottom = this.input.offsetHeight + 'px'
- }
- },
- overflowsBottom (el) {
- return el.getBoundingClientRect().bottom > window.innerHeight
}
}
}
diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue
index aa2950ce..c9bbc18f 100644
--- a/src/components/emoji_input/emoji_input.vue
+++ b/src/components/emoji_input/emoji_input.vue
@@ -1,11 +1,23 @@
<template>
<div
ref="root"
- v-click-outside="onClickOutside"
class="emoji-input"
:class="{ 'with-picker': !hideEmojiButton }"
>
<slot />
+ <!-- TODO: make the 'x' disappear if at the end maybe? -->
+ <div
+ ref="hiddenOverlay"
+ class="hidden-overlay"
+ :style="overlayStyle"
+ >
+ <span>{{ preText }}</span>
+ <span
+ ref="hiddenOverlayCaret"
+ class="caret"
+ >x</span>
+ <span>{{ postText }}</span>
+ </div>
<template v-if="enableEmojiPicker">
<button
v-if="!hideEmojiButton"
@@ -18,44 +30,61 @@
<EmojiPicker
v-if="enableEmojiPicker"
ref="picker"
- :class="{ hide: !showPicker }"
:enable-sticker-picker="enableStickerPicker"
class="emoji-picker-panel"
@emoji="insert"
@sticker-uploaded="onStickerUploaded"
@sticker-upload-failed="onStickerUploadFailed"
+ @show="onPickerShown"
+ @close="onPickerClosed"
/>
</template>
- <div
- ref="panel"
+ <Popover
+ ref="suggestorPopover"
class="autocomplete-panel"
- :class="{ hide: !showSuggestions }"
+ placement="bottom"
>
- <div
- ref="panel-body"
- class="autocomplete-panel-body"
- >
+ <template #content>
<div
- v-for="(suggestion, index) in suggestions"
- :key="index"
- class="autocomplete-item"
- :class="{ highlighted: index === highlighted }"
- @click.stop.prevent="onClick($event, suggestion)"
+ ref="panel-body"
+ class="autocomplete-panel-body"
>
- <span class="image">
- <img
- v-if="suggestion.img"
- :src="suggestion.img"
- >
- <span v-else>{{ suggestion.replacement }}</span>
- </span>
- <div class="label">
- <span class="displayText">{{ suggestion.displayText }}</span>
- <span class="detailText">{{ suggestion.detailText }}</span>
+ <div
+ v-for="(suggestion, index) in suggestions"
+ :key="index"
+ class="autocomplete-item"
+ :class="{ highlighted: index === highlighted }"
+ @click.stop.prevent="onClick($event, suggestion)"
+ >
+ <span class="image">
+ <img
+ v-if="suggestion.img"
+ :src="suggestion.img"
+ >
+ <span v-else>{{ suggestion.replacement }}</span>
+ </span>
+ <div class="label">
+ <span
+ v-if="suggestion.user"
+ class="displayText"
+ >
+ {{ suggestion.displayText }}<UnicodeDomainIndicator
+ :user="suggestion.user"
+ :at="false"
+ />
+ </span>
+ <span
+ v-if="!suggestion.user"
+ class="displayText"
+ >
+ {{ maybeLocalizedEmojiName(suggestion) }}
+ </span>
+ <span class="detailText">{{ suggestion.detailText }}</span>
+ </div>
</div>
</div>
- </div>
- </div>
+ </template>
+ </Popover>
</div>
</template>
@@ -78,7 +107,7 @@
top: 0;
right: 0;
margin: .2em .25em;
- font-size: 16px;
+ font-size: 1.3em;
cursor: pointer;
line-height: 24px;
@@ -87,6 +116,7 @@
color: var(--text, $fallback--text);
}
}
+
.emoji-picker-panel {
position: absolute;
z-index: 20;
@@ -97,89 +127,83 @@
}
}
- .autocomplete {
- &-panel {
- position: absolute;
- z-index: 20;
- margin-top: 2px;
-
- &.hide {
- display: none
- }
+ input, textarea {
+ flex: 1 0 auto;
+ }
- &-body {
- margin: 0 0.5em 0 0.5em;
- border-radius: $fallback--tooltipRadius;
- border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
- box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
- box-shadow: var(--popupShadow);
- min-width: 75%;
- background-color: $fallback--bg;
- background-color: var(--popover, $fallback--bg);
- color: $fallback--link;
- color: var(--popoverText, $fallback--link);
- --faint: var(--popoverFaintText, $fallback--faint);
- --faintLink: var(--popoverFaintLink, $fallback--faint);
- --lightText: var(--popoverLightText, $fallback--lightText);
- --postLink: var(--popoverPostLink, $fallback--link);
- --postFaintLink: var(--popoverPostFaintLink, $fallback--link);
- --icon: var(--popoverIcon, $fallback--icon);
- }
+ .hidden-overlay {
+ opacity: 0;
+ pointer-events: none;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 0;
+ left: 0;
+ overflow: hidden;
+ /* DEBUG STUFF */
+ color: red;
+ /* set opacity to non-zero to see the overlay */
+
+ .caret {
+ width: 0;
+ margin-right: calc(-1ch - 1px);
+ border: 1px solid red;
}
+ }
+}
+.autocomplete {
+ &-panel {
+ position: absolute;
+ }
- &-item {
- display: flex;
- cursor: pointer;
- padding: 0.2em 0.4em;
- border-bottom: 1px solid rgba(0, 0, 0, 0.4);
+ &-item {
+ display: flex;
+ cursor: pointer;
+ padding: 0.2em 0.4em;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.4);
+ height: 32px;
+
+ .image {
+ width: 32px;
height: 32px;
+ line-height: 32px;
+ text-align: center;
+ font-size: 32px;
- .image {
+ margin-right: 4px;
+
+ img {
width: 32px;
height: 32px;
- line-height: 32px;
- text-align: center;
- font-size: 32px;
-
- margin-right: 4px;
-
- img {
- width: 32px;
- height: 32px;
- object-fit: contain;
- }
+ object-fit: contain;
}
+ }
- .label {
- display: flex;
- flex-direction: column;
- justify-content: center;
- margin: 0 0.1em 0 0.2em;
-
- .displayText {
- line-height: 1.5;
- }
+ .label {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ margin: 0 0.1em 0 0.2em;
- .detailText {
- font-size: 9px;
- line-height: 9px;
- }
+ .displayText {
+ line-height: 1.5;
}
- &.highlighted {
- background-color: $fallback--fg;
- background-color: var(--selectedMenuPopover, $fallback--fg);
- color: var(--selectedMenuPopoverText, $fallback--text);
- --faint: var(--selectedMenuPopoverFaintText, $fallback--faint);
- --faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint);
- --lightText: var(--selectedMenuPopoverLightText, $fallback--lightText);
- --icon: var(--selectedMenuPopoverIcon, $fallback--icon);
+ .detailText {
+ font-size: 9px;
+ line-height: 9px;
}
}
- }
- input, textarea {
- flex: 1 0 auto;
+ &.highlighted {
+ background-color: $fallback--fg;
+ background-color: var(--selectedMenuPopover, $fallback--fg);
+ color: var(--selectedMenuPopoverText, $fallback--text);
+ --faint: var(--selectedMenuPopoverFaintText, $fallback--faint);
+ --faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint);
+ --lightText: var(--selectedMenuPopoverLightText, $fallback--lightText);
+ --icon: var(--selectedMenuPopoverIcon, $fallback--icon);
+ }
}
}
</style>
diff --git a/src/components/emoji_input/suggestor.js b/src/components/emoji_input/suggestor.js
index e8efbd1e..adaa879e 100644
--- a/src/components/emoji_input/suggestor.js
+++ b/src/components/emoji_input/suggestor.js
@@ -2,7 +2,7 @@
* suggest - generates a suggestor function to be used by emoji-input
* data: object providing source information for specific types of suggestions:
* data.emoji - optional, an array of all emoji available i.e.
- * (state.instance.emoji + state.instance.customEmoji)
+ * (getters.standardEmojiList + state.instance.customEmoji)
* data.users - optional, an array of all known users
* updateUsersList - optional, a function to search and append to users
*
@@ -13,10 +13,10 @@
export default data => {
const emojiCurry = suggestEmoji(data.emoji)
const usersCurry = data.store && suggestUsers(data.store)
- return input => {
+ return (input, nameKeywordLocalizer) => {
const firstChar = input[0]
if (firstChar === ':' && data.emoji) {
- return emojiCurry(input)
+ return emojiCurry(input, nameKeywordLocalizer)
}
if (firstChar === '@' && usersCurry) {
return usersCurry(input)
@@ -25,34 +25,34 @@ export default data => {
}
}
-export const suggestEmoji = emojis => input => {
+export const suggestEmoji = emojis => (input, nameKeywordLocalizer) => {
const noPrefix = input.toLowerCase().substr(1)
return emojis
- .filter(({ displayText }) => displayText.toLowerCase().match(noPrefix))
- .sort((a, b) => {
- let aScore = 0
- let bScore = 0
+ .map(emoji => ({ ...emoji, ...nameKeywordLocalizer(emoji) }))
+ .filter((emoji) => (emoji.names.concat(emoji.keywords)).filter(kw => kw.toLowerCase().match(noPrefix)).length)
+ .map(k => {
+ let score = 0
// An exact match always wins
- aScore += a.displayText.toLowerCase() === noPrefix ? 200 : 0
- bScore += b.displayText.toLowerCase() === noPrefix ? 200 : 0
+ score += Math.max(...k.names.map(name => name.toLowerCase() === noPrefix ? 200 : 0), 0)
// Prioritize custom emoji a lot
- aScore += a.imageUrl ? 100 : 0
- bScore += b.imageUrl ? 100 : 0
+ score += k.imageUrl ? 100 : 0
// Prioritize prefix matches somewhat
- aScore += a.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0
- bScore += b.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0
+ score += Math.max(...k.names.map(kw => kw.toLowerCase().startsWith(noPrefix) ? 10 : 0), 0)
// Sort by length
- aScore -= a.displayText.length
- bScore -= b.displayText.length
+ score -= k.displayText.length
+ k.score = score
+ return k
+ })
+ .sort((a, b) => {
// Break ties alphabetically
const alphabetically = a.displayText > b.displayText ? 0.5 : -0.5
- return bScore - aScore + alphabetically
+ return b.score - a.score + alphabetically
})
}
@@ -116,11 +116,12 @@ export const suggestUsers = ({ dispatch, state }) => {
return diff + nameAlphabetically + screenNameAlphabetically
/* eslint-disable camelcase */
- }).map(({ screen_name, screen_name_ui, name, profile_image_url_original }) => ({
- displayText: screen_name_ui,
- detailText: name,
- imageUrl: profile_image_url_original,
- replacement: '@' + screen_name + ' '
+ }).map((user) => ({
+ user,
+ displayText: user.screen_name_ui,
+ detailText: user.name,
+ imageUrl: user.profile_image_url_original,
+ replacement: '@' + user.screen_name + ' '
}))
/* eslint-enable camelcase */
diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js
index 2716d93f..dd5e5217 100644
--- a/src/components/emoji_picker/emoji_picker.js
+++ b/src/components/emoji_picker/emoji_picker.js
@@ -1,31 +1,77 @@
+import { defineAsyncComponent } from 'vue'
import Checkbox from '../checkbox/checkbox.vue'
+import Popover from 'src/components/popover/popover.vue'
+import StillImage from '../still-image/still-image.vue'
+import { ensureFinalFallback } from '../../i18n/languages.js'
+import lozad from 'lozad'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBoxOpen,
faStickyNote,
- faSmileBeam
+ faSmileBeam,
+ faSmile,
+ faUser,
+ faPaw,
+ faIceCream,
+ faBus,
+ faBasketballBall,
+ faLightbulb,
+ faCode,
+ faFlag
} from '@fortawesome/free-solid-svg-icons'
+import { debounce, trim } from 'lodash'
library.add(
faBoxOpen,
faStickyNote,
- faSmileBeam
+ faSmileBeam,
+ faSmile,
+ faUser,
+ faPaw,
+ faIceCream,
+ faBus,
+ faBasketballBall,
+ faLightbulb,
+ faCode,
+ faFlag
)
-// At widest, approximately 20 emoji are visible in a row,
-// loading 3 rows, could be overkill for narrow picker
-const LOAD_EMOJI_BY = 60
+const UNICODE_EMOJI_GROUP_ICON = {
+ 'smileys-and-emotion': 'smile',
+ 'people-and-body': 'user',
+ 'animals-and-nature': 'paw',
+ 'food-and-drink': 'ice-cream',
+ 'travel-and-places': 'bus',
+ activities: 'basketball-ball',
+ objects: 'lightbulb',
+ symbols: 'code',
+ flags: 'flag'
+}
-// When to start loading new batch emoji, in pixels
-const LOAD_EMOJI_MARGIN = 64
+const maybeLocalizedKeywords = (emoji, languages, nameLocalizer) => {
+ const res = [emoji.displayText, nameLocalizer(emoji)]
+ if (emoji.annotations) {
+ languages.forEach(lang => {
+ const keywords = emoji.annotations[lang]?.keywords || []
+ const name = emoji.annotations[lang]?.name
+ res.push(...(keywords.concat([name]).filter(k => k)))
+ })
+ }
+ return res
+}
-const filterByKeyword = (list, keyword = '') => {
+const filterByKeyword = (list, keyword = '', languages, nameLocalizer) => {
if (keyword === '') return list
const keywordLowercase = keyword.toLowerCase()
- let orderedEmojiList = []
+ const orderedEmojiList = []
for (const emoji of list) {
- const indexOfKeyword = emoji.displayText.toLowerCase().indexOf(keywordLowercase)
+ const indices = maybeLocalizedKeywords(emoji, languages, nameLocalizer)
+ .map(k => k.toLowerCase().indexOf(keywordLowercase))
+ .filter(k => k > -1)
+
+ const indexOfKeyword = indices.length ? Math.min(...indices) : -1
+
if (indexOfKeyword > -1) {
if (!Array.isArray(orderedEmojiList[indexOfKeyword])) {
orderedEmojiList[indexOfKeyword] = []
@@ -51,16 +97,43 @@ const EmojiPicker = {
showingStickers: false,
groupsScrolledClass: 'scrolled-top',
keepOpen: false,
- customEmojiBufferSlice: LOAD_EMOJI_BY,
customEmojiTimeout: null,
- customEmojiLoadAllConfirmed: false
+ // Lazy-load only after the first time `showing` becomes true.
+ contentLoaded: false,
+ groupRefs: {},
+ emojiRefs: {},
+ filteredEmojiGroups: []
}
},
components: {
- StickerPicker: () => import('../sticker_picker/sticker_picker.vue'),
- Checkbox
+ StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')),
+ Checkbox,
+ StillImage,
+ Popover
},
methods: {
+ showPicker () {
+ this.$refs.popover.showPopover()
+ this.onShowing()
+ },
+ hidePicker () {
+ this.$refs.popover.hidePopover()
+ },
+ setAnchorEl (el) {
+ this.$refs.popover.setAnchorEl(el)
+ },
+ setGroupRef (name) {
+ return el => { this.groupRefs[name] = el }
+ },
+ setEmojiRef (name) {
+ return el => { this.emojiRefs[name] = el }
+ },
+ onPopoverShown () {
+ this.$emit('show')
+ },
+ onPopoverClosed () {
+ this.$emit('close')
+ },
onStickerUploaded (e) {
this.$emit('sticker-uploaded', e)
},
@@ -69,17 +142,48 @@ const EmojiPicker = {
},
onEmoji (emoji) {
const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement
+ if (!this.keepOpen) {
+ this.$refs.popover.hidePopover()
+ }
this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen })
},
onScroll (e) {
const target = (e && e.target) || this.$refs['emoji-groups']
this.updateScrolledClass(target)
this.scrolledGroup(target)
- this.triggerLoadMore(target)
+ },
+ scrolledGroup (target) {
+ const top = target.scrollTop + 5
+ this.$nextTick(() => {
+ this.allEmojiGroups.forEach(group => {
+ const ref = this.groupRefs['group-' + group.id]
+ if (ref && ref.offsetTop <= top) {
+ this.activeGroup = group.id
+ }
+ })
+ this.scrollHeader()
+ })
+ },
+ scrollHeader () {
+ // Scroll the active tab's header into view
+ const headerRef = this.groupRefs['group-header-' + this.activeGroup]
+ const left = headerRef.offsetLeft
+ const right = left + headerRef.offsetWidth
+ const headerCont = this.$refs.header
+ const currentScroll = headerCont.scrollLeft
+ const currentScrollRight = currentScroll + headerCont.clientWidth
+ const setScroll = s => { headerCont.scrollLeft = s }
+
+ const margin = 7 // .emoji-tabs-item: padding
+ if (left - margin < currentScroll) {
+ setScroll(left - margin)
+ } else if (right + margin > currentScrollRight) {
+ setScroll(right + margin - headerCont.clientWidth)
+ }
},
highlight (key) {
- const ref = this.$refs['group-' + key]
- const top = ref[0].offsetTop
+ const ref = this.groupRefs['group-' + key]
+ const top = ref.offsetTop
this.setShowStickers(false)
this.activeGroup = key
this.$nextTick(() => {
@@ -95,73 +199,83 @@ const EmojiPicker = {
this.groupsScrolledClass = 'scrolled-middle'
}
},
- triggerLoadMore (target) {
- const ref = this.$refs['group-end-custom'][0]
- if (!ref) return
- const bottom = ref.offsetTop + ref.offsetHeight
-
- const scrollerBottom = target.scrollTop + target.clientHeight
- const scrollerTop = target.scrollTop
- const scrollerMax = target.scrollHeight
-
- // Loads more emoji when they come into view
- const approachingBottom = bottom - scrollerBottom < LOAD_EMOJI_MARGIN
- // Always load when at the very top in case there's no scroll space yet
- const atTop = scrollerTop < 5
- // Don't load when looking at unicode category or at the very bottom
- const bottomAboveViewport = bottom < scrollerTop || scrollerBottom === scrollerMax
- if (!bottomAboveViewport && (approachingBottom || atTop)) {
- this.loadEmoji()
- }
+ toggleStickers () {
+ this.showingStickers = !this.showingStickers
},
- scrolledGroup (target) {
- const top = target.scrollTop + 5
+ setShowStickers (value) {
+ this.showingStickers = value
+ },
+ filterByKeyword (list, keyword) {
+ return filterByKeyword(list, keyword, this.languages, this.maybeLocalizedEmojiName)
+ },
+ initializeLazyLoad () {
+ this.destroyLazyLoad()
this.$nextTick(() => {
- this.emojisView.forEach(group => {
- const ref = this.$refs['group-' + group.id]
- if (ref[0].offsetTop <= top) {
- this.activeGroup = group.id
+ this.$lozad = lozad('.still-image.emoji-picker-emoji', {
+ load: el => {
+ const name = el.getAttribute('data-emoji-name')
+ const vn = this.emojiRefs[name]
+ if (!vn) {
+ return
+ }
+
+ vn.loadLazy()
}
})
+ this.$lozad.observe()
})
},
- loadEmoji () {
- const allLoaded = this.customEmojiBuffer.length === this.filteredEmoji.length
-
- if (allLoaded) {
- return
- }
-
- this.customEmojiBufferSlice += LOAD_EMOJI_BY
+ waitForDomAndInitializeLazyLoad () {
+ this.$nextTick(() => this.initializeLazyLoad())
},
- startEmojiLoad (forceUpdate = false) {
- if (!forceUpdate) {
- this.keyword = ''
+ destroyLazyLoad () {
+ if (this.$lozad) {
+ if (this.$lozad.observer) {
+ this.$lozad.observer.disconnect()
+ }
+ if (this.$lozad.mutationObserver) {
+ this.$lozad.mutationObserver.disconnect()
+ }
}
+ },
+ onShowing () {
+ const oldContentLoaded = this.contentLoaded
this.$nextTick(() => {
- this.$refs['emoji-groups'].scrollTop = 0
+ this.$refs.search.focus()
})
- const bufferSize = this.customEmojiBuffer.length
- const bufferPrefilledAll = bufferSize === this.filteredEmoji.length
- if (bufferPrefilledAll && !forceUpdate) {
- return
+ this.contentLoaded = true
+ this.waitForDomAndInitializeLazyLoad()
+ this.filteredEmojiGroups = this.getFilteredEmojiGroups()
+ if (!oldContentLoaded) {
+ this.$nextTick(() => {
+ if (this.defaultGroup) {
+ this.highlight(this.defaultGroup)
+ }
+ })
}
- this.customEmojiBufferSlice = LOAD_EMOJI_BY
},
- toggleStickers () {
- this.showingStickers = !this.showingStickers
- },
- setShowStickers (value) {
- this.showingStickers = value
+ getFilteredEmojiGroups () {
+ return this.allEmojiGroups
+ .map(group => ({
+ ...group,
+ emojis: this.filterByKeyword(group.emojis, trim(this.keyword))
+ }))
+ .filter(group => group.emojis.length > 0)
}
},
watch: {
keyword () {
- this.customEmojiLoadAllConfirmed = false
this.onScroll()
- this.startEmojiLoad(true)
+ this.debouncedHandleKeywordChange()
+ },
+ allCustomGroups () {
+ this.waitForDomAndInitializeLazyLoad()
+ this.filteredEmojiGroups = this.getFilteredEmojiGroups()
}
},
+ destroyed () {
+ this.destroyLazyLoad()
+ },
computed: {
activeGroupView () {
return this.showingStickers ? '' : this.activeGroup
@@ -172,39 +286,55 @@ const EmojiPicker = {
}
return 0
},
- filteredEmoji () {
- return filterByKeyword(
- this.$store.state.instance.customEmoji || [],
- this.keyword
- )
+ allCustomGroups () {
+ return this.$store.getters.groupedCustomEmojis
},
- customEmojiBuffer () {
- return this.filteredEmoji.slice(0, this.customEmojiBufferSlice)
+ defaultGroup () {
+ return Object.keys(this.allCustomGroups)[0]
},
- emojis () {
- const standardEmojis = this.$store.state.instance.emoji || []
- const customEmojis = this.customEmojiBuffer
-
- return [
- {
- id: 'custom',
- text: this.$t('emoji.custom'),
- icon: 'smile-beam',
- emojis: customEmojis
- },
- {
- id: 'standard',
- text: this.$t('emoji.unicode'),
- icon: 'box-open',
- emojis: filterByKeyword(standardEmojis, this.keyword)
- }
- ]
+ unicodeEmojiGroups () {
+ return this.$store.getters.standardEmojiGroupList.map(group => ({
+ id: `standard-${group.id}`,
+ text: this.$t(`emoji.unicode_groups.${group.id}`),
+ icon: UNICODE_EMOJI_GROUP_ICON[group.id],
+ emojis: group.emojis
+ }))
},
- emojisView () {
- return this.emojis.filter(value => value.emojis.length > 0)
+ allEmojiGroups () {
+ return Object.entries(this.allCustomGroups)
+ .map(([_, v]) => v)
+ .concat(this.unicodeEmojiGroups)
},
stickerPickerEnabled () {
return (this.$store.state.instance.stickers || []).length !== 0
+ },
+ debouncedHandleKeywordChange () {
+ return debounce(() => {
+ this.waitForDomAndInitializeLazyLoad()
+ this.filteredEmojiGroups = this.getFilteredEmojiGroups()
+ }, 500)
+ },
+ languages () {
+ return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage)
+ },
+ maybeLocalizedEmojiName () {
+ return emoji => {
+ if (!emoji.annotations) {
+ return emoji.displayText
+ }
+
+ if (emoji.displayTextI18n) {
+ return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)
+ }
+
+ for (const lang of this.languages) {
+ if (emoji.annotations[lang]?.name) {
+ return emoji.annotations[lang].name
+ }
+ }
+
+ return emoji.displayText
+ }
}
}
}
diff --git a/src/components/emoji_picker/emoji_picker.scss b/src/components/emoji_picker/emoji_picker.scss
index ec711758..53363ec1 100644
--- a/src/components/emoji_picker/emoji_picker.scss
+++ b/src/components/emoji_picker/emoji_picker.scss
@@ -1,13 +1,15 @@
@import '../../_variables.scss';
+$emoji-picker-header-height: 36px;
+$emoji-picker-header-picture-width: 32px;
+$emoji-picker-header-picture-height: 32px;
+$emoji-picker-emoji-size: 32px;
+
.emoji-picker {
+ width: 25em;
+ max-width: 100vw;
display: flex;
flex-direction: column;
- position: absolute;
- right: 0;
- left: 0;
- margin: 0 !important;
- z-index: 1;
background-color: $fallback--bg;
background-color: var(--popover, $fallback--bg);
color: $fallback--link;
@@ -18,6 +20,23 @@
--lightText: var(--popoverLightText, $fallback--lightText);
--icon: var(--popoverIcon, $fallback--icon);
+ &-header-image {
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ width: $emoji-picker-header-picture-width;
+ max-width: $emoji-picker-header-picture-width;
+ height: $emoji-picker-header-picture-height;
+ max-height: $emoji-picker-header-picture-height;
+ .still-image {
+ max-width: 100%;
+ max-height: 100%;
+ height: 100%;
+ width: 100%;
+ object-fit: contain;
+ }
+ }
+
.keep-open,
.too-many-emoji {
padding: 7px;
@@ -36,7 +55,6 @@
.heading {
display: flex;
- height: 32px;
padding: 10px 7px 5px;
}
@@ -49,6 +67,10 @@
.emoji-tabs {
flex-grow: 1;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ overflow-x: auto;
}
.emoji-groups {
@@ -56,6 +78,8 @@
}
.additional-tabs {
+ display: flex;
+ flex: 1;
border-left: 1px solid;
border-left-color: $fallback--icon;
border-left-color: var(--icon, $fallback--icon);
@@ -65,20 +89,26 @@
.additional-tabs,
.emoji-tabs {
- display: block;
- min-width: 0;
flex-basis: auto;
- flex-shrink: 1;
+ display: flex;
+ align-content: center;
&-item {
padding: 0 7px;
cursor: pointer;
- font-size: 24px;
+ font-size: 1.85em;
+ width: $emoji-picker-header-picture-width;
+ max-width: $emoji-picker-header-picture-width;
+ height: $emoji-picker-header-picture-height;
+ max-height: $emoji-picker-header-picture-height;
+ display: flex;
+ align-items: center;
&.disabled {
opacity: 0.5;
pointer-events: none;
}
+
&.active {
border-bottom: 4px solid;
@@ -151,9 +181,10 @@
justify-content: left;
&-title {
- font-size: 12px;
+ font-size: 0.85em;
width: 100%;
margin: 0;
+
&.disabled {
display: none;
}
@@ -161,22 +192,26 @@
}
&-item {
- width: 32px;
- height: 32px;
+ width: $emoji-picker-emoji-size;
+ height: $emoji-picker-emoji-size;
box-sizing: border-box;
display: flex;
- font-size: 32px;
+ line-height: $emoji-picker-emoji-size;
align-items: center;
justify-content: center;
margin: 4px;
cursor: pointer;
- img {
+ .emoji-picker-emoji.-custom {
object-fit: contain;
max-width: 100%;
max-height: 100%;
}
+ .emoji-picker-emoji.-unicode {
+ font-size: 24px;
+ overflow: hidden;
+ }
}
}
diff --git a/src/components/emoji_picker/emoji_picker.vue b/src/components/emoji_picker/emoji_picker.vue
index 3262a3d9..ff56d637 100644
--- a/src/components/emoji_picker/emoji_picker.vue
+++ b/src/components/emoji_picker/emoji_picker.vue
@@ -1,104 +1,136 @@
<template>
- <div class="emoji-picker panel panel-default panel-body">
- <div class="heading">
- <span class="emoji-tabs">
+ <Popover
+ ref="popover"
+ trigger="click"
+ popover-class="emoji-picker popover-default"
+ @show="onPopoverShown"
+ @close="onPopoverClosed"
+ >
+ <template #content>
+ <div class="heading">
<span
- v-for="group in emojis"
- :key="group.id"
- class="emoji-tabs-item"
- :class="{
- active: activeGroupView === group.id,
- disabled: group.emojis.length === 0
- }"
- :title="group.text"
- @click.prevent="highlight(group.id)"
+ ref="header"
+ class="emoji-tabs"
>
- <FAIcon
- :icon="group.icon"
- fixed-width
- />
+ <span
+ v-for="group in filteredEmojiGroups"
+ :ref="setGroupRef('group-header-' + group.id)"
+ :key="group.id"
+ class="emoji-tabs-item"
+ :class="{
+ active: activeGroupView === group.id
+ }"
+ :title="group.text"
+ @click.prevent="highlight(group.id)"
+ >
+ <span
+ v-if="group.image"
+ class="emoji-picker-header-image"
+ >
+ <still-image
+ :alt="group.text"
+ :src="group.image"
+ />
+ </span>
+ <FAIcon
+ v-else
+ :icon="group.icon"
+ fixed-width
+ />
+ </span>
</span>
- </span>
- <span
- v-if="stickerPickerEnabled"
- class="additional-tabs"
- >
<span
- class="stickers-tab-icon additional-tabs-item"
- :class="{active: showingStickers}"
- :title="$t('emoji.stickers')"
- @click.prevent="toggleStickers"
+ v-if="stickerPickerEnabled"
+ class="additional-tabs"
>
- <FAIcon
- icon="sticky-note"
- fixed-width
- />
+ <span
+ class="stickers-tab-icon additional-tabs-item"
+ :class="{active: showingStickers}"
+ :title="$t('emoji.stickers')"
+ @click.prevent="toggleStickers"
+ >
+ <FAIcon
+ icon="sticky-note"
+ fixed-width
+ />
+ </span>
</span>
- </span>
- </div>
- <div class="content">
+ </div>
<div
- class="emoji-content"
- :class="{hidden: showingStickers}"
+ v-if="contentLoaded"
+ class="content"
>
- <div class="emoji-search">
- <input
- v-model="keyword"
- type="text"
- class="form-control"
- :placeholder="$t('emoji.search_emoji')"
- >
- </div>
<div
- ref="emoji-groups"
- class="emoji-groups"
- :class="groupsScrolledClass"
- @scroll="onScroll"
+ class="emoji-content"
+ :class="{hidden: showingStickers}"
>
+ <div class="emoji-search">
+ <input
+ ref="search"
+ v-model="keyword"
+ type="text"
+ class="form-control"
+ :placeholder="$t('emoji.search_emoji')"
+ @input="$event.target.composing = false"
+ >
+ </div>
<div
- v-for="group in emojisView"
- :key="group.id"
- class="emoji-group"
+ ref="emoji-groups"
+ class="emoji-groups"
+ :class="groupsScrolledClass"
+ @scroll="onScroll"
>
- <h6
- :ref="'group-' + group.id"
- class="emoji-group-title"
- >
- {{ group.text }}
- </h6>
- <span
- v-for="emoji in group.emojis"
- :key="group.id + emoji.displayText"
- :title="emoji.displayText"
- class="emoji-item"
- @click.stop.prevent="onEmoji(emoji)"
+ <div
+ v-for="group in filteredEmojiGroups"
+ :key="group.id"
+ class="emoji-group"
>
- <span v-if="!emoji.imageUrl">{{ emoji.replacement }}</span>
- <img
- v-else
- :src="emoji.imageUrl"
+ <h6
+ :ref="setGroupRef('group-' + group.id)"
+ class="emoji-group-title"
>
- </span>
- <span :ref="'group-end-' + group.id" />
+ {{ group.text }}
+ </h6>
+ <span
+ v-for="emoji in group.emojis"
+ :key="group.id + emoji.displayText"
+ :title="maybeLocalizedEmojiName(emoji)"
+ class="emoji-item"
+ @click.stop.prevent="onEmoji(emoji)"
+ >
+ <span
+ v-if="!emoji.imageUrl"
+ class="emoji-picker-emoji -unicode"
+ >{{ emoji.replacement }}</span>
+ <still-image
+ v-else
+ :ref="setEmojiRef(group.id + emoji.displayText)"
+ class="emoji-picker-emoji -custom"
+ :data-src="emoji.imageUrl"
+ :data-emoji-name="group.id + emoji.displayText"
+ />
+ </span>
+ <span :ref="setGroupRef('group-end-' + group.id)" />
+ </div>
+ </div>
+ <div class="keep-open">
+ <Checkbox v-model="keepOpen">
+ {{ $t('emoji.keep_open') }}
+ </Checkbox>
</div>
</div>
- <div class="keep-open">
- <Checkbox v-model="keepOpen">
- {{ $t('emoji.keep_open') }}
- </Checkbox>
+ <div
+ v-if="showingStickers"
+ class="stickers-content"
+ >
+ <sticker-picker
+ @uploaded="onStickerUploaded"
+ @upload-failed="onStickerUploadFailed"
+ />
</div>
</div>
- <div
- v-if="showingStickers"
- class="stickers-content"
- >
- <sticker-picker
- @uploaded="onStickerUploaded"
- @upload-failed="onStickerUploadFailed"
- />
- </div>
- </div>
- </div>
+ </template>
+ </Popover>
</template>
<script src="./emoji_picker.js"></script>
diff --git a/src/components/emoji_reactions/emoji_reactions.vue b/src/components/emoji_reactions/emoji_reactions.vue
index 51d50359..4eb22a65 100644
--- a/src/components/emoji_reactions/emoji_reactions.vue
+++ b/src/components/emoji_reactions/emoji_reactions.vue
@@ -1,5 +1,5 @@
<template>
- <div class="emoji-reactions">
+ <div class="EmojiReactions">
<UserListPopover
v-for="(reaction) in emojiReactions"
:key="reaction.name"
@@ -7,7 +7,7 @@
>
<button
class="emoji-reaction btn button-default"
- :class="{ 'picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }"
+ :class="{ '-picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }"
@click="emojiOnClick(reaction.name, $event)"
@mouseenter="fetchEmojiReactionsByIfMissing()"
>
@@ -26,57 +26,59 @@
</div>
</template>
-<script src="./emoji_reactions.js" ></script>
+<script src="./emoji_reactions.js"></script>
<style lang="scss">
@import '../../_variables.scss';
-.emoji-reactions {
+.EmojiReactions {
display: flex;
margin-top: 0.25em;
flex-wrap: wrap;
-}
-.emoji-reaction {
- padding: 0 0.5em;
- margin-right: 0.5em;
- margin-top: 0.5em;
- display: flex;
- align-items: center;
- justify-content: center;
- box-sizing: border-box;
- .reaction-emoji {
- width: 1.25em;
- margin-right: 0.25em;
- }
- &:focus {
- outline: none;
- }
+ .emoji-reaction {
+ padding: 0 0.5em;
+ margin-right: 0.5em;
+ margin-top: 0.5em;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
- &.not-clickable {
- cursor: default;
- &:hover {
- box-shadow: $fallback--buttonShadow;
- box-shadow: var(--buttonShadow);
+ .reaction-emoji {
+ width: 1.25em;
+ margin-right: 0.25em;
+ }
+
+ &:focus {
+ outline: none;
+ }
+
+ &.not-clickable {
+ cursor: default;
+ &:hover {
+ box-shadow: $fallback--buttonShadow;
+ box-shadow: var(--buttonShadow);
+ }
+ }
+
+ &.-picked-reaction {
+ border: 1px solid var(--accent, $fallback--link);
+ margin-left: -1px; // offset the border, can't use inset shadows either
+ margin-right: calc(0.5em - 1px);
}
}
-}
-.emoji-reaction-expand {
- padding: 0 0.5em;
- margin-right: 0.5em;
- margin-top: 0.5em;
- display: flex;
- align-items: center;
- justify-content: center;
- &:hover {
- text-decoration: underline;
+ .emoji-reaction-expand {
+ padding: 0 0.5em;
+ margin-right: 0.5em;
+ margin-top: 0.5em;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ &:hover {
+ text-decoration: underline;
+ }
}
-}
-.picked-reaction {
- border: 1px solid var(--accent, $fallback--link);
- margin-left: -1px; // offset the border, can't use inset shadows either
- margin-right: calc(0.5em - 1px);
}
-
</style>
diff --git a/src/components/exporter/exporter.js b/src/components/exporter/exporter.js
index 51912ac3..fc75372e 100644
--- a/src/components/exporter/exporter.js
+++ b/src/components/exporter/exporter.js
@@ -15,18 +15,8 @@ const Exporter = {
type: String,
default: 'export.csv'
},
- exportButtonLabel: {
- type: String,
- default () {
- return this.$t('exporter.export')
- }
- },
- processingMessage: {
- type: String,
- default () {
- return this.$t('exporter.processing')
- }
- }
+ exportButtonLabel: { type: String },
+ processingMessage: { type: String }
},
data () {
return {
diff --git a/src/components/exporter/exporter.vue b/src/components/exporter/exporter.vue
index d6a03088..79defdf6 100644
--- a/src/components/exporter/exporter.vue
+++ b/src/components/exporter/exporter.vue
@@ -7,14 +7,14 @@
spin
/>
- <span>{{ processingMessage }}</span>
+ <span>{{ processingMessage || $t('exporter.processing') }}</span>
</div>
<button
v-else
class="btn button-default"
@click="process"
>
- {{ exportButtonLabel }}
+ {{ exportButtonLabel || $t('exporter.export') }}
</button>
</div>
</template>
diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js
index dd45b6b9..3dc968c9 100644
--- a/src/components/extra_buttons/extra_buttons.js
+++ b/src/components/extra_buttons/extra_buttons.js
@@ -6,7 +6,10 @@ import {
faEyeSlash,
faThumbtack,
faShareAlt,
- faExternalLinkAlt
+ faExternalLinkAlt,
+ faHistory,
+ faPlus,
+ faTimes
} from '@fortawesome/free-solid-svg-icons'
import {
faBookmark as faBookmarkReg,
@@ -21,13 +24,27 @@ library.add(
faThumbtack,
faShareAlt,
faExternalLinkAlt,
- faFlag
+ faFlag,
+ faHistory,
+ faPlus,
+ faTimes
)
const ExtraButtons = {
- props: [ 'status' ],
+ props: ['status'],
components: { Popover },
+ data () {
+ return {
+ expanded: false
+ }
+ },
methods: {
+ onShow () {
+ this.expanded = true
+ },
+ onClose () {
+ this.expanded = false
+ },
deleteStatus () {
const confirmed = window.confirm(this.$t('status.delete_confirm'))
if (confirmed) {
@@ -71,14 +88,32 @@ const ExtraButtons = {
},
reportStatus () {
this.$store.dispatch('openUserReportingModal', { userId: this.status.user.id, statusIds: [this.status.id] })
+ },
+ editStatus () {
+ this.$store.dispatch('fetchStatusSource', { id: this.status.id })
+ .then(data => this.$store.dispatch('openEditStatusModal', {
+ statusId: this.status.id,
+ subject: data.spoiler_text,
+ statusText: data.text,
+ statusIsSensitive: this.status.nsfw,
+ statusPoll: this.status.poll,
+ statusFiles: [...this.status.attachments],
+ visibility: this.status.visibility,
+ statusContentType: data.content_type
+ }))
+ },
+ showStatusHistory () {
+ const originalStatus = { ...this.status }
+ const stripFieldsList = ['attachments', 'created_at', 'emojis', 'text', 'raw_html', 'nsfw', 'poll', 'summary', 'summary_raw_html']
+ stripFieldsList.forEach(p => delete originalStatus[p])
+ this.$store.dispatch('openStatusHistoryModal', originalStatus)
}
},
computed: {
currentUser () { return this.$store.state.users.currentUser },
canDelete () {
if (!this.currentUser) { return }
- const superuser = this.currentUser.rights.moderator || this.currentUser.rights.admin
- return superuser || this.status.user.id === this.currentUser.id
+ return this.currentUser.privileges.includes('messages_delete') || this.status.user.id === this.currentUser.id
},
ownStatus () {
return this.status.user.id === this.currentUser.id
@@ -89,9 +124,16 @@ const ExtraButtons = {
canMute () {
return !!this.currentUser
},
+ canBookmark () {
+ return !!this.currentUser
+ },
statusLink () {
return `${this.$store.state.instance.server}${this.$router.resolve({ name: 'conversation', params: { id: this.status.id } }).href}`
- }
+ },
+ isEdited () {
+ return this.status.edited_at !== null
+ },
+ editingAvailable () { return this.$store.state.instance.editingAvailable }
}
}
diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue
index a3c3c767..b2fad1c9 100644
--- a/src/components/extra_buttons/extra_buttons.vue
+++ b/src/components/extra_buttons/extra_buttons.vue
@@ -6,8 +6,10 @@
:offset="{ y: 5 }"
:bound-to="{ x: 'container' }"
remove-padding
+ @show="onShow"
+ @close="onClose"
>
- <template v-slot:content="{close}">
+ <template #content="{close}">
<div class="dropdown-menu">
<button
v-if="canMute && !status.thread_muted"
@@ -51,27 +53,51 @@
icon="thumbtack"
/><span>{{ $t("status.unpin") }}</span>
</button>
+ <template v-if="canBookmark">
+ <button
+ v-if="!status.bookmarked"
+ class="button-default dropdown-item dropdown-item-icon"
+ @click.prevent="bookmarkStatus"
+ @click="close"
+ >
+ <FAIcon
+ fixed-width
+ :icon="['far', 'bookmark']"
+ /><span>{{ $t("status.bookmark") }}</span>
+ </button>
+ <button
+ v-if="status.bookmarked"
+ class="button-default dropdown-item dropdown-item-icon"
+ @click.prevent="unbookmarkStatus"
+ @click="close"
+ >
+ <FAIcon
+ fixed-width
+ icon="bookmark"
+ /><span>{{ $t("status.unbookmark") }}</span>
+ </button>
+ </template>
<button
- v-if="!status.bookmarked"
+ v-if="ownStatus && editingAvailable"
class="button-default dropdown-item dropdown-item-icon"
- @click.prevent="bookmarkStatus"
+ @click.prevent="editStatus"
@click="close"
>
<FAIcon
fixed-width
- :icon="['far', 'bookmark']"
- /><span>{{ $t("status.bookmark") }}</span>
+ icon="pen"
+ /><span>{{ $t("status.edit") }}</span>
</button>
<button
- v-if="status.bookmarked"
+ v-if="isEdited && editingAvailable"
class="button-default dropdown-item dropdown-item-icon"
- @click.prevent="unbookmarkStatus"
+ @click.prevent="showStatusHistory"
@click="close"
>
<FAIcon
fixed-width
- icon="bookmark"
- /><span>{{ $t("status.unbookmark") }}</span>
+ icon="history"
+ /><span>{{ $t("status.status_history") }}</span>
</button>
<button
v-if="canDelete"
@@ -118,21 +144,36 @@
</button>
</div>
</template>
- <template v-slot:trigger>
- <button class="button-unstyled popover-trigger">
- <FAIcon
- class="fa-scale-110 fa-old-padding"
- icon="ellipsis-h"
- />
- </button>
+ <template #trigger>
+ <span class="button-unstyled popover-trigger">
+ <FALayers class="fa-old-padding-layer">
+ <FAIcon
+ class="fa-scale-110 "
+ icon="ellipsis-h"
+ />
+ <FAIcon
+ v-show="!expanded"
+ class="focus-marker"
+ transform="shrink-6 up-8 right-16"
+ icon="plus"
+ />
+ <FAIcon
+ v-show="expanded"
+ class="focus-marker"
+ transform="shrink-6 up-8 right-16"
+ icon="times"
+ />
+ </FALayers>
+ </span>
</template>
</Popover>
</template>
-<script src="./extra_buttons.js" ></script>
+<script src="./extra_buttons.js"></script>
<style lang="scss">
@import '../../_variables.scss';
+@import '../../_mixins.scss';
.ExtraButtons {
/* override of popover internal stuff */
@@ -149,6 +190,21 @@
color: $fallback--text;
color: var(--text, $fallback--text);
}
+
+ }
+
+ .popover-trigger-button {
+ @include unfocused-style {
+ .focus-marker {
+ visibility: hidden;
+ }
+ }
+
+ @include focused-style {
+ .focus-marker {
+ visibility: visible;
+ }
+ }
}
}
</style>
diff --git a/src/components/favorite_button/favorite_button.js b/src/components/favorite_button/favorite_button.js
index 5cd05f73..cf3378c9 100644
--- a/src/components/favorite_button/favorite_button.js
+++ b/src/components/favorite_button/favorite_button.js
@@ -1,13 +1,21 @@
import { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
-import { faStar } from '@fortawesome/free-solid-svg-icons'
+import {
+ faStar,
+ faPlus,
+ faMinus,
+ faCheck
+} from '@fortawesome/free-solid-svg-icons'
import {
faStar as faStarRegular
} from '@fortawesome/free-regular-svg-icons'
library.add(
faStar,
- faStarRegular
+ faStarRegular,
+ faPlus,
+ faMinus,
+ faCheck
)
const FavoriteButton = {
@@ -31,7 +39,10 @@ const FavoriteButton = {
}
},
computed: {
- ...mapGetters(['mergedConfig'])
+ ...mapGetters(['mergedConfig']),
+ remoteInteractionLink () {
+ return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
+ }
}
}
diff --git a/src/components/favorite_button/favorite_button.vue b/src/components/favorite_button/favorite_button.vue
index dce25e24..ea01720a 100644
--- a/src/components/favorite_button/favorite_button.vue
+++ b/src/components/favorite_button/favorite_button.vue
@@ -7,19 +7,45 @@
:title="$t('tool_tip.favorite')"
@click.prevent="favorite()"
>
- <FAIcon
- class="fa-scale-110 fa-old-padding"
- :icon="[status.favorited ? 'fas' : 'far', 'star']"
- :spin="animated"
- />
+ <FALayers class="fa-scale-110 fa-old-padding-layer">
+ <FAIcon
+ class="fa-scale-110"
+ :icon="[status.favorited ? 'fas' : 'far', 'star']"
+ :spin="animated"
+ />
+ <FAIcon
+ v-if="status.favorited"
+ class="active-marker"
+ transform="shrink-6 up-9 right-12"
+ icon="check"
+ />
+ <FAIcon
+ v-if="!status.favorited"
+ class="focus-marker"
+ transform="shrink-6 up-9 right-12"
+ icon="plus"
+ />
+ <FAIcon
+ v-else
+ class="focus-marker"
+ transform="shrink-6 up-9 right-12"
+ icon="minus"
+ />
+ </FALayers>
</button>
- <span v-else>
+ <a
+ v-else
+ class="button-unstyled interactive"
+ target="_blank"
+ role="button"
+ :href="remoteInteractionLink"
+ >
<FAIcon
class="fa-scale-110 fa-old-padding"
:title="$t('tool_tip.favorite')"
:icon="['far', 'star']"
/>
- </span>
+ </a>
<span
v-if="!mergedConfig.hidePostStats && status.fave_num > 0"
class="action-counter"
@@ -29,10 +55,11 @@
</div>
</template>
-<script src="./favorite_button.js" ></script>
+<script src="./favorite_button.js"></script>
<style lang="scss">
@import '../../_variables.scss';
+@import '../../_mixins.scss';
.FavoriteButton {
display: flex;
@@ -57,6 +84,26 @@
color: $fallback--cOrange;
color: var(--cOrange, $fallback--cOrange);
}
+
+ @include unfocused-style {
+ .focus-marker {
+ visibility: hidden;
+ }
+
+ .active-marker {
+ visibility: visible;
+ }
+ }
+
+ @include focused-style {
+ .focus-marker {
+ visibility: visible;
+ }
+
+ .active-marker {
+ visibility: hidden;
+ }
+ }
}
}
</style>
diff --git a/src/components/features_panel/features_panel.vue b/src/components/features_panel/features_panel.vue
index a58a99af..4cdf56d0 100644
--- a/src/components/features_panel/features_panel.vue
+++ b/src/components/features_panel/features_panel.vue
@@ -32,7 +32,7 @@
</div>
</template>
-<script src="./features_panel.js" ></script>
+<script src="./features_panel.js"></script>
<style lang="scss">
.features-panel li {
diff --git a/src/components/flash/flash.js b/src/components/flash/flash.js
index d03384c7..87c1d650 100644
--- a/src/components/flash/flash.js
+++ b/src/components/flash/flash.js
@@ -11,7 +11,7 @@ library.add(
)
const Flash = {
- props: [ 'src' ],
+ props: ['src'],
data () {
return {
player: false, // can be true, "hidden", false. hidden = element exists
@@ -39,12 +39,13 @@ const Flash = {
this.player = 'error'
})
this.ruffleInstance = player
+ this.$emit('playerOpened')
})
},
closePlayer () {
- console.log(this.ruffleInstance)
- this.ruffleInstance.remove()
+ this.ruffleInstance && this.ruffleInstance.remove()
this.player = false
+ this.$emit('playerClosed')
}
}
}
diff --git a/src/components/flash/flash.vue b/src/components/flash/flash.vue
index d20d037b..95f71950 100644
--- a/src/components/flash/flash.vue
+++ b/src/components/flash/flash.vue
@@ -36,13 +36,6 @@
</p>
</span>
</button>
- <button
- v-if="player"
- class="button-unstyled hider"
- @click="closePlayer"
- >
- <FAIcon icon="stop" />
- </button>
</div>
</template>
@@ -51,8 +44,9 @@
<style lang="scss">
@import '../../_variables.scss';
.Flash {
+ display: inline-block;
width: 100%;
- height: 260px;
+ height: 100%;
position: relative;
.player {
@@ -60,6 +54,16 @@
width: 100%;
}
+ .placeholder {
+ height: 100%;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--bg);
+ color: var(--link);
+ }
+
.hider {
top: 0;
}
@@ -76,13 +80,5 @@
display: none;
visibility: 'hidden';
}
-
- .placeholder {
- height: 100%;
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- }
}
</style>
diff --git a/src/components/follow_button/follow_button.js b/src/components/follow_button/follow_button.js
index df42692b..3edbcb86 100644
--- a/src/components/follow_button/follow_button.js
+++ b/src/components/follow_button/follow_button.js
@@ -1,6 +1,6 @@
import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
export default {
- props: ['relationship', 'labelFollowing', 'buttonClass'],
+ props: ['relationship', 'user', 'labelFollowing', 'buttonClass'],
data () {
return {
inProgress: false
@@ -29,6 +29,9 @@ export default {
} else {
return this.$t('user_card.follow')
}
+ },
+ disabled () {
+ return this.inProgress || this.user.deactivated
}
},
methods: {
diff --git a/src/components/follow_button/follow_button.vue b/src/components/follow_button/follow_button.vue
index 7f85f1d7..965d5256 100644
--- a/src/components/follow_button/follow_button.vue
+++ b/src/components/follow_button/follow_button.vue
@@ -2,7 +2,7 @@
<button
class="btn button-default follow-button"
:class="{ toggled: isPressed }"
- :disabled="inProgress"
+ :disabled="disabled"
:title="title"
@click="onClick"
>
diff --git a/src/components/follow_card/follow_card.js b/src/components/follow_card/follow_card.js
index 6dcb6d47..b26b27a7 100644
--- a/src/components/follow_card/follow_card.js
+++ b/src/components/follow_card/follow_card.js
@@ -1,6 +1,7 @@
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import RemoteFollow from '../remote_follow/remote_follow.vue'
import FollowButton from '../follow_button/follow_button.vue'
+import RemoveFollowerButton from '../remove_follower_button/remove_follower_button.vue'
const FollowCard = {
props: [
@@ -10,7 +11,8 @@ const FollowCard = {
components: {
BasicUserCard,
RemoteFollow,
- FollowButton
+ FollowButton,
+ RemoveFollowerButton
},
computed: {
isMe () {
diff --git a/src/components/follow_card/follow_card.vue b/src/components/follow_card/follow_card.vue
index b503783f..c919b11a 100644
--- a/src/components/follow_card/follow_card.vue
+++ b/src/components/follow_card/follow_card.vue
@@ -20,6 +20,12 @@
:relationship="relationship"
:label-following="$t('user_card.follow_unfollow')"
class="follow-card-follow-button"
+ :user="user"
+ />
+ <RemoveFollowerButton
+ v-if="noFollowsYou && relationship.followed_by"
+ :relationship="relationship"
+ class="follow-card-button"
/>
</template>
</div>
@@ -39,6 +45,12 @@
line-height: 1.5em;
}
+ &-button {
+ margin-top: 0.5em;
+ padding: 0 1.5em;
+ margin-left: 1em;
+ }
+
&-follow-button {
margin-top: 0.5em;
margin-left: auto;
diff --git a/src/components/font_control/font_control.js b/src/components/font_control/font_control.js
index 137ef9c0..92ee3f30 100644
--- a/src/components/font_control/font_control.js
+++ b/src/components/font_control/font_control.js
@@ -1,4 +1,4 @@
-import { set } from 'vue'
+import { set } from 'lodash'
import Select from '../select/select.vue'
export default {
@@ -6,11 +6,12 @@ export default {
Select
},
props: [
- 'name', 'label', 'value', 'fallback', 'options', 'no-inherit'
+ 'name', 'label', 'modelValue', 'fallback', 'options', 'no-inherit'
],
+ emits: ['update:modelValue'],
data () {
return {
- lValue: this.value,
+ lValue: this.modelValue,
availableOptions: [
this.noInherit ? '' : 'inherit',
'custom',
@@ -22,7 +23,7 @@ export default {
}
},
beforeUpdate () {
- this.lValue = this.value
+ this.lValue = this.modelValue
},
computed: {
present () {
@@ -37,7 +38,7 @@ export default {
},
set (v) {
set(this.lValue, 'family', v)
- this.$emit('input', this.lValue)
+ this.$emit('update:modelValue', this.lValue)
}
},
isCustom () {
diff --git a/src/components/font_control/font_control.vue b/src/components/font_control/font_control.vue
index 29605084..83c1cef7 100644
--- a/src/components/font_control/font_control.vue
+++ b/src/components/font_control/font_control.vue
@@ -15,13 +15,14 @@
class="opt exlcude-disabled"
type="checkbox"
:checked="present"
- @input="$emit('input', typeof value === 'undefined' ? fallback : undefined)"
+ @change="$emit('update:modelValue', typeof modelValue === 'undefined' ? fallback : undefined)"
>
<label
v-if="typeof fallback !== 'undefined'"
class="opt-l"
:for="name + '-o'"
/>
+ {{ ' ' }}
<Select
:id="name + '-font-switcher'"
v-model="preset"
@@ -46,7 +47,7 @@
</div>
</template>
-<script src="./font_control.js" ></script>
+<script src="./font_control.js"></script>
<style lang="scss">
@import '../../_variables.scss';
diff --git a/src/components/gallery/gallery.js b/src/components/gallery/gallery.js
index f856fd0a..4e1bda55 100644
--- a/src/components/gallery/gallery.js
+++ b/src/components/gallery/gallery.js
@@ -1,15 +1,26 @@
import Attachment from '../attachment/attachment.vue'
-import { chunk, last, dropRight, sumBy } from 'lodash'
+import { sumBy, set } from 'lodash'
const Gallery = {
props: [
'attachments',
+ 'limitRows',
+ 'descriptions',
+ 'limit',
'nsfw',
- 'setMedia'
+ 'setMedia',
+ 'size',
+ 'editable',
+ 'removeAttachment',
+ 'shiftUpAttachment',
+ 'shiftDnAttachment',
+ 'editAttachment',
+ 'grid'
],
data () {
return {
- sizes: {}
+ sizes: {},
+ hidingLong: true
}
},
components: { Attachment },
@@ -18,26 +29,70 @@ const Gallery = {
if (!this.attachments) {
return []
}
- const rows = chunk(this.attachments, 3)
- if (last(rows).length === 1 && rows.length > 1) {
- // if 1 attachment on last row -> add it to the previous row instead
- const lastAttachment = last(rows)[0]
- const allButLastRow = dropRight(rows)
- last(allButLastRow).push(lastAttachment)
- return allButLastRow
+ const attachments = this.limit > 0
+ ? this.attachments.slice(0, this.limit)
+ : this.attachments
+ if (this.size === 'hide') {
+ return attachments.map(item => ({ minimal: true, items: [item] }))
}
+ const rows = this.grid
+ ? [{ grid: true, items: attachments }]
+ : attachments.reduce((acc, attachment, i) => {
+ if (attachment.mimetype.includes('audio')) {
+ return [...acc, { audio: true, items: [attachment] }, { items: [] }]
+ }
+ if (!(
+ attachment.mimetype.includes('image') ||
+ attachment.mimetype.includes('video') ||
+ attachment.mimetype.includes('flash')
+ )) {
+ return [...acc, { minimal: true, items: [attachment] }, { items: [] }]
+ }
+ const maxPerRow = 3
+ const attachmentsRemaining = this.attachments.length - i + 1
+ const currentRow = acc[acc.length - 1].items
+ currentRow.push(attachment)
+ if (currentRow.length >= maxPerRow && attachmentsRemaining > maxPerRow) {
+ return [...acc, { items: [] }]
+ } else {
+ return acc
+ }
+ }, [{ items: [] }]).filter(_ => _.items.length > 0)
return rows
},
- useContainFit () {
- return this.$store.getters.mergedConfig.useContainFit
+ attachmentsDimensionalScore () {
+ return this.rows.reduce((acc, row) => {
+ let size = 0
+ if (row.minimal) {
+ size += 1 / 8
+ } else if (row.audio) {
+ size += 1 / 4
+ } else {
+ size += 1 / (row.items.length + 0.6)
+ }
+ return acc + size
+ }, 0)
+ },
+ tooManyAttachments () {
+ if (this.editable || this.size === 'small') {
+ return false
+ } else if (this.size === 'hide') {
+ return this.attachments.length > 8
+ } else {
+ return this.attachmentsDimensionalScore > 1
+ }
}
},
methods: {
- onNaturalSizeLoad (id, size) {
- this.$set(this.sizes, id, size)
+ onNaturalSizeLoad ({ id, width, height }) {
+ set(this.sizes, id, { width, height })
},
- rowStyle (itemsPerRow) {
- return { 'padding-bottom': `${(100 / (itemsPerRow + 0.6))}%` }
+ rowStyle (row) {
+ if (row.audio) {
+ return { 'padding-bottom': '25%' } // fixed reduced height for audio
+ } else if (!row.minimal && !row.grid) {
+ return { 'padding-bottom': `${(100 / (row.items.length + 0.6))}%` }
+ }
},
itemStyle (id, row) {
const total = sumBy(row, item => this.getAspectRatio(item.id))
@@ -46,6 +101,16 @@ const Gallery = {
getAspectRatio (id) {
const size = this.sizes[id]
return size ? size.width / size.height : 1
+ },
+ toggleHidingLong (event) {
+ this.hidingLong = event
+ },
+ openGallery () {
+ this.$store.dispatch('setMedia', this.attachments)
+ this.$store.dispatch('setCurrentMedia', this.attachments[0])
+ },
+ onMedia () {
+ this.$store.dispatch('setMedia', this.attachments)
}
}
}
diff --git a/src/components/gallery/gallery.vue b/src/components/gallery/gallery.vue
index ca91c9c1..ccf6e3e2 100644
--- a/src/components/gallery/gallery.vue
+++ b/src/components/gallery/gallery.vue
@@ -1,26 +1,83 @@
<template>
<div
ref="galleryContainer"
- style="width: 100%;"
+ class="Gallery"
+ :class="{ '-long': tooManyAttachments && hidingLong }"
>
+ <div class="gallery-rows">
+ <div
+ v-for="(row, rowIndex) in rows"
+ :key="rowIndex"
+ class="gallery-row"
+ :style="rowStyle(row)"
+ :class="{ '-audio': row.audio, '-minimal': row.minimal, '-grid': grid }"
+ >
+ <div
+ class="gallery-row-inner"
+ :class="{ '-grid': grid }"
+ >
+ <Attachment
+ v-for="(attachment, attachmentIndex) in row.items"
+ :key="attachment.id"
+ class="gallery-item"
+ :nsfw="nsfw"
+ :attachment="attachment"
+ :size="size"
+ :editable="editable"
+ :remove="removeAttachment"
+ :shift-up="!(attachmentIndex === 0 && rowIndex === 0) && shiftUpAttachment"
+ :shift-dn="!(attachmentIndex === row.items.length - 1 && rowIndex === rows.length - 1) && shiftDnAttachment"
+ :edit="editAttachment"
+ :description="descriptions && descriptions[attachment.id]"
+ :hide-description="size === 'small' || tooManyAttachments && hidingLong"
+ :style="itemStyle(attachment.id, row.items)"
+ @setMedia="onMedia"
+ @naturalSizeLoad="onNaturalSizeLoad"
+ />
+ </div>
+ </div>
+ </div>
<div
- v-for="(row, index) in rows"
- :key="index"
- class="gallery-row"
- :style="rowStyle(row.length)"
- :class="{ 'contain-fit': useContainFit, 'cover-fit': !useContainFit }"
+ v-if="tooManyAttachments"
+ class="many-attachments"
>
- <div class="gallery-row-inner">
- <attachment
- v-for="attachment in row"
- :key="attachment.id"
- :set-media="setMedia"
- :nsfw="nsfw"
- :attachment="attachment"
- :allow-play="false"
- :natural-size-load="onNaturalSizeLoad.bind(null, attachment.id)"
- :style="itemStyle(attachment.id, row)"
- />
+ <div class="many-attachments-text">
+ {{ $t("status.many_attachments", { number: attachments.length }) }}
+ </div>
+ <div class="many-attachments-buttons">
+ <span
+ v-if="!hidingLong"
+ class="many-attachments-button"
+ >
+ <button
+ class="button-unstyled -link"
+ @click="toggleHidingLong(true)"
+ >
+ {{ $t("status.collapse_attachments") }}
+ </button>
+ </span>
+ <span
+ v-if="hidingLong"
+ class="many-attachments-button"
+ >
+ <button
+ class="button-unstyled -link"
+ @click="toggleHidingLong(false)"
+ >
+ {{ $t("status.show_all_attachments") }}
+ </button>
+ </span>
+ <span
+ v-if="hidingLong"
+ class="many-attachments-button"
+ >
+ <button
+ class="button-unstyled -link"
+ @click="openGallery"
+ >
+ {{ $t("status.open_gallery") }}
+ </button>
+ </span>
</div>
</div>
</div>
@@ -31,12 +88,66 @@
<style lang="scss">
@import '../../_variables.scss';
-.gallery-row {
- position: relative;
- height: 0;
- width: 100%;
- flex-grow: 1;
- margin-top: 0.5em;
+.Gallery {
+ .gallery-rows {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .gallery-row {
+ position: relative;
+ height: 0;
+ width: 100%;
+ flex-grow: 1;
+
+ &:not(:first-child) {
+ margin-top: 0.5em;
+ }
+ }
+
+ &.-long {
+ .gallery-rows {
+ max-height: 25em;
+ overflow: hidden;
+ mask:
+ linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
+ linear-gradient(to top, white, white);
+
+ /* Autoprefixed seem to ignore this one, and also syntax is different */
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ }
+ }
+
+ .many-attachments-text {
+ text-align: center;
+ line-height: 2;
+ }
+
+ .many-attachments-buttons {
+ display: flex;
+ }
+
+ .many-attachments-button {
+ display: flex;
+ flex: 1;
+ justify-content: center;
+ line-height: 2;
+
+ button {
+ padding: 0 2em;
+ }
+ }
+
+ .gallery-row {
+ &.-grid,
+ &.-minimal {
+ height: auto;
+ .gallery-row-inner {
+ position: relative;
+ }
+ }
+ }
.gallery-row-inner {
position: absolute;
@@ -48,9 +159,24 @@
flex-direction: row;
flex-wrap: nowrap;
align-content: stretch;
+
+ &.-grid {
+ width: 100%;
+ height: auto;
+ position: relative;
+ display: grid;
+ grid-column-gap: 0.5em;
+ grid-row-gap: 0.5em;
+ grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
+
+ .gallery-item {
+ margin: 0;
+ height: 200px;
+ }
+ }
}
- .gallery-row-inner .attachment {
+ .gallery-item {
margin: 0 0.5em 0 0;
flex-grow: 1;
height: 100%;
@@ -61,32 +187,5 @@
margin: 0;
}
}
-
- .image-attachment {
- width: 100%;
- height: 100%;
- }
-
- .video-container {
- height: 100%;
- }
-
- &.contain-fit {
- img,
- video,
- canvas {
- object-fit: contain;
- height: 100%;
- }
- }
-
- &.cover-fit {
- img,
- video,
- canvas {
- object-fit: cover;
- }
- }
}
-
</style>
diff --git a/src/components/global_notice_list/global_notice_list.vue b/src/components/global_notice_list/global_notice_list.vue
index a45f4586..d828b819 100644
--- a/src/components/global_notice_list/global_notice_list.vue
+++ b/src/components/global_notice_list/global_notice_list.vue
@@ -29,10 +29,10 @@
.global-notice-list {
position: fixed;
- top: 50px;
+ top: calc(var(--navbar-height) + 0.5em);
width: 100%;
pointer-events: none;
- z-index: 1001;
+ z-index: var(--ZI_navbar_popovers);
display: flex;
flex-direction: column;
align-items: center;
@@ -44,20 +44,18 @@
max-width: calc(100% - 3em);
display: flex;
padding-left: 1.5em;
- line-height: 2em;
+ line-height: 2;
+ margin-bottom: 0.5em;
+
.notice-message {
flex: 1 1 100%;
}
- i {
- flex: 0 0;
- width: 1.5em;
- cursor: pointer;
- }
}
.global-error {
background-color: var(--alertPopupError, $fallback--cRed);
color: var(--alertPopupErrorText, $fallback--text);
+
.svg-inline--fa {
color: var(--alertPopupErrorText, $fallback--text);
}
@@ -66,6 +64,7 @@
.global-warning {
background-color: var(--alertPopupWarning, $fallback--cOrange);
color: var(--alertPopupWarningText, $fallback--text);
+
.svg-inline--fa {
color: var(--alertPopupWarningText, $fallback--text);
}
diff --git a/src/components/hashtag_link/hashtag_link.vue b/src/components/hashtag_link/hashtag_link.vue
index 918ed26b..596851b9 100644
--- a/src/components/hashtag_link/hashtag_link.vue
+++ b/src/components/hashtag_link/hashtag_link.vue
@@ -14,6 +14,6 @@
</span>
</template>
-<script src="./hashtag_link.js"/>
+<script src="./hashtag_link.js" />
-<style lang="scss" src="./hashtag_link.scss"/>
+<style lang="scss" src="./hashtag_link.scss" />
diff --git a/src/components/image_cropper/image_cropper.js b/src/components/image_cropper/image_cropper.js
index e8d5ec6d..55e901a0 100644
--- a/src/components/image_cropper/image_cropper.js
+++ b/src/components/image_cropper/image_cropper.js
@@ -95,7 +95,7 @@ const ImageCropper = {
const fileInput = this.$refs.input
if (fileInput.files != null && fileInput.files[0] != null) {
this.file = fileInput.files[0]
- let reader = new window.FileReader()
+ const reader = new window.FileReader()
reader.onload = (e) => {
this.dataUrl = e.target.result
this.$emit('open')
@@ -117,7 +117,7 @@ const ImageCropper = {
const fileInput = this.$refs.input
fileInput.addEventListener('change', this.readFile)
},
- beforeDestroy: function () {
+ beforeUnmount: function () {
// remove the event listeners
const trigger = this.getTriggerDOM()
if (trigger) {
diff --git a/src/components/importer/importer.js b/src/components/importer/importer.js
index 59f9beb1..da86a223 100644
--- a/src/components/importer/importer.js
+++ b/src/components/importer/importer.js
@@ -15,24 +15,9 @@ const Importer = {
type: Function,
required: true
},
- submitButtonLabel: {
- type: String,
- default () {
- return this.$t('importer.submit')
- }
- },
- successMessage: {
- type: String,
- default () {
- return this.$t('importer.success')
- }
- },
- errorMessage: {
- type: String,
- default () {
- return this.$t('importer.error')
- }
- }
+ submitButtonLabel: { type: String },
+ successMessage: { type: String },
+ errorMessage: { type: String }
},
data () {
return {
diff --git a/src/components/importer/importer.vue b/src/components/importer/importer.vue
index 210823f5..2a63b31a 100644
--- a/src/components/importer/importer.vue
+++ b/src/components/importer/importer.vue
@@ -18,21 +18,31 @@
class="btn button-default"
@click="submit"
>
- {{ submitButtonLabel }}
+ {{ submitButtonLabel || $t('importer.submit') }}
</button>
<div v-if="success">
- <FAIcon
- icon="times"
+ <button
+ class="button-unstyled"
@click="dismiss"
- />
- <p>{{ successMessage }}</p>
+ >
+ <FAIcon
+ icon="times"
+ />
+ </button>
+ {{ ' ' }}
+ <span>{{ successMessage || $t('importer.success') }}</span>
</div>
<div v-else-if="error">
- <FAIcon
- icon="times"
+ <button
+ class="button-unstyled"
@click="dismiss"
- />
- <p>{{ errorMessage }}</p>
+ >
+ <FAIcon
+ icon="times"
+ />
+ </button>
+ {{ ' ' }}
+ <span>{{ errorMessage || $t('importer.error') }}</span>
</div>
</div>
</template>
diff --git a/src/components/instance_specific_panel/instance_specific_panel.vue b/src/components/instance_specific_panel/instance_specific_panel.vue
index 7448ca06..c8ed0a2d 100644
--- a/src/components/instance_specific_panel/instance_specific_panel.vue
+++ b/src/components/instance_specific_panel/instance_specific_panel.vue
@@ -10,4 +10,4 @@
</div>
</template>
-<script src="./instance_specific_panel.js" ></script>
+<script src="./instance_specific_panel.js"></script>
diff --git a/src/components/interactions/interactions.js b/src/components/interactions/interactions.js
index 7fe5e76d..1ae1d01c 100644
--- a/src/components/interactions/interactions.js
+++ b/src/components/interactions/interactions.js
@@ -1,9 +1,12 @@
import Notifications from '../notifications/notifications.vue'
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
const tabModeDict = {
mentions: ['mention'],
'likes+repeats': ['repeat', 'like'],
follows: ['follow'],
+ reactions: ['pleroma:emoji_reaction'],
+ reports: ['pleroma:report'],
moves: ['move']
}
@@ -11,7 +14,8 @@ const Interactions = {
data () {
return {
allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
- filterMode: tabModeDict['mentions']
+ filterMode: tabModeDict.mentions,
+ canSeeReports: this.$store.state.users.currentUser.privileges.includes('reports_manage_reports')
}
},
methods: {
@@ -20,7 +24,8 @@ const Interactions = {
}
},
components: {
- Notifications
+ Notifications,
+ TabSwitcher
}
}
diff --git a/src/components/interactions/interactions.vue b/src/components/interactions/interactions.vue
index 57d5d87c..b7291c02 100644
--- a/src/components/interactions/interactions.vue
+++ b/src/components/interactions/interactions.vue
@@ -22,6 +22,15 @@
:label="$t('interactions.follows')"
/>
<span
+ key="reactions"
+ :label="$t('interactions.emoji_reactions')"
+ />
+ <span
+ v-if="canSeeReports"
+ key="reports"
+ :label="$t('interactions.reports')"
+ />
+ <span
v-if="!allowFollowingMove"
key="moves"
:label="$t('interactions.moves')"
diff --git a/src/components/interface_language_switcher/interface_language_switcher.vue b/src/components/interface_language_switcher/interface_language_switcher.vue
index cf307a24..6997f149 100644
--- a/src/components/interface_language_switcher/interface_language_switcher.vue
+++ b/src/components/interface_language_switcher/interface_language_switcher.vue
@@ -1,11 +1,12 @@
<template>
<div>
<label for="interface-language-switcher">
- {{ $t('settings.interfaceLanguage') }}
+ {{ promptText }}
</label>
+ {{ ' ' }}
<Select
id="interface-language-switcher"
- v-model="language"
+ v-model="controlledLanguage"
>
<option
v-for="lang in languages"
@@ -19,39 +20,44 @@
</template>
<script>
-import languagesObject from '../../i18n/messages'
import localeService from '../../services/locale/locale.service.js'
-import ISO6391 from 'iso-639-1'
-import _ from 'lodash'
import Select from '../select/select.vue'
export default {
components: {
+ // eslint-disable-next-line vue/no-reserved-component-names
Select
},
+ props: {
+ promptText: {
+ type: String,
+ required: true
+ },
+ language: {
+ type: String,
+ required: true
+ },
+ setLanguage: {
+ type: Function,
+ required: true
+ }
+ },
computed: {
languages () {
- return _.map(languagesObject.languages, (code) => ({ code: code, name: this.getLanguageName(code) })).sort((a, b) => a.name.localeCompare(b.name))
+ return localeService.languages
},
- language: {
- get: function () { return this.$store.getters.mergedConfig.interfaceLanguage },
+ controlledLanguage: {
+ get: function () { return this.language },
set: function (val) {
- this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
+ this.setLanguage(val)
}
}
},
methods: {
getLanguageName (code) {
- const specialLanguageNames = {
- 'ja_easy': 'やさしいãĢãģんご',
- 'zh': 'įŽ€äŊ“中文',
- 'zh_Hant': 'įšéĢ”ä¸­æ–‡'
- }
- const languageName = specialLanguageNames[code] || ISO6391.getNativeName(code)
- const browserLocale = localeService.internalToBrowserLocale(code)
- return languageName.charAt(0).toLocaleUpperCase(browserLocale) + languageName.slice(1)
+ return localeService.getLanguageName(code)
}
}
}
diff --git a/src/components/link-preview/link-preview.vue b/src/components/link-preview/link-preview.vue
index d3ca39b8..220527f2 100644
--- a/src/components/link-preview/link-preview.vue
+++ b/src/components/link-preview/link-preview.vue
@@ -63,7 +63,7 @@
}
.card-host {
- font-size: 12px;
+ font-size: 0.85em;
}
.card-description {
diff --git a/src/components/lists/lists.js b/src/components/lists/lists.js
new file mode 100644
index 00000000..56d68430
--- /dev/null
+++ b/src/components/lists/lists.js
@@ -0,0 +1,27 @@
+import ListsCard from '../lists_card/lists_card.vue'
+
+const Lists = {
+ data () {
+ return {
+ isNew: false
+ }
+ },
+ components: {
+ ListsCard
+ },
+ computed: {
+ lists () {
+ return this.$store.state.lists.allLists
+ }
+ },
+ methods: {
+ cancelNewList () {
+ this.isNew = false
+ },
+ newList () {
+ this.isNew = true
+ }
+ }
+}
+
+export default Lists
diff --git a/src/components/lists/lists.vue b/src/components/lists/lists.vue
new file mode 100644
index 00000000..b8bab0a0
--- /dev/null
+++ b/src/components/lists/lists.vue
@@ -0,0 +1,33 @@
+<template>
+ <div class="Lists panel panel-default">
+ <div class="panel-heading">
+ <div class="title">
+ {{ $t('lists.lists') }}
+ </div>
+ <router-link
+ :to="{ name: 'lists-new' }"
+ class="button-default btn new-list-button"
+ >
+ {{ $t("lists.new") }}
+ </router-link>
+ </div>
+ <div class="panel-body">
+ <ListsCard
+ v-for="list in lists.slice().reverse()"
+ :key="list"
+ :list="list"
+ class="list-item"
+ />
+ </div>
+ </div>
+</template>
+
+<script src="./lists.js"></script>
+
+<style lang="scss">
+.Lists {
+ .new-list-button {
+ padding: 0 0.5em;
+ }
+}
+</style>
diff --git a/src/components/lists_card/lists_card.js b/src/components/lists_card/lists_card.js
new file mode 100644
index 00000000..b503caec
--- /dev/null
+++ b/src/components/lists_card/lists_card.js
@@ -0,0 +1,16 @@
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faEllipsisH
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faEllipsisH
+)
+
+const ListsCard = {
+ props: [
+ 'list'
+ ]
+}
+
+export default ListsCard
diff --git a/src/components/lists_card/lists_card.vue b/src/components/lists_card/lists_card.vue
new file mode 100644
index 00000000..13866d8c
--- /dev/null
+++ b/src/components/lists_card/lists_card.vue
@@ -0,0 +1,51 @@
+<template>
+ <div class="list-card">
+ <router-link
+ :to="{ name: 'lists-timeline', params: { id: list.id } }"
+ class="list-name"
+ >
+ {{ list.title }}
+ </router-link>
+ <router-link
+ :to="{ name: 'lists-edit', params: { id: list.id } }"
+ class="button-list-edit"
+ >
+ <FAIcon
+ class="fa-scale-110 fa-old-padding"
+ icon="ellipsis-h"
+ />
+ </router-link>
+ </div>
+</template>
+
+<script src="./lists_card.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.list-card {
+ display: flex;
+}
+
+.list-name,
+.button-list-edit {
+ margin: 0;
+ padding: 1em;
+ color: $fallback--link;
+ color: var(--link, $fallback--link);
+
+ &:hover {
+ background-color: $fallback--lightBg;
+ background-color: var(--selectedMenu, $fallback--lightBg);
+ color: $fallback--link;
+ color: var(--selectedMenuText, $fallback--link);
+ --faint: var(--selectedMenuFaintText, $fallback--faint);
+ --faintLink: var(--selectedMenuFaintLink, $fallback--faint);
+ --lightText: var(--selectedMenuLightText, $fallback--lightText);
+ }
+}
+
+.list-name {
+ flex-grow: 1;
+}
+</style>
diff --git a/src/components/lists_edit/lists_edit.js b/src/components/lists_edit/lists_edit.js
new file mode 100644
index 00000000..c22d1323
--- /dev/null
+++ b/src/components/lists_edit/lists_edit.js
@@ -0,0 +1,145 @@
+import { mapState, mapGetters } from 'vuex'
+import BasicUserCard from '../basic_user_card/basic_user_card.vue'
+import ListsUserSearch from '../lists_user_search/lists_user_search.vue'
+import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
+import UserAvatar from '../user_avatar/user_avatar.vue'
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faSearch,
+ faChevronLeft
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faSearch,
+ faChevronLeft
+)
+
+const ListsNew = {
+ components: {
+ BasicUserCard,
+ UserAvatar,
+ ListsUserSearch,
+ TabSwitcher,
+ PanelLoading
+ },
+ data () {
+ return {
+ title: '',
+ titleDraft: '',
+ membersUserIds: [],
+ removedUserIds: new Set([]), // users we added for members, to undo
+ searchUserIds: [],
+ addedUserIds: new Set([]), // users we added from search, to undo
+ searchLoading: false,
+ reallyDelete: false
+ }
+ },
+ created () {
+ if (!this.id) return
+ this.$store.dispatch('fetchList', { listId: this.id })
+ .then(() => {
+ this.title = this.findListTitle(this.id)
+ this.titleDraft = this.title
+ })
+ this.$store.dispatch('fetchListAccounts', { listId: this.id })
+ .then(() => {
+ this.membersUserIds = this.findListAccounts(this.id)
+ this.membersUserIds.forEach(userId => {
+ this.$store.dispatch('fetchUserIfMissing', userId)
+ })
+ })
+ },
+ computed: {
+ id () {
+ return this.$route.params.id
+ },
+ membersUsers () {
+ return [...this.membersUserIds, ...this.addedUserIds]
+ .map(userId => this.findUser(userId)).filter(user => user)
+ },
+ searchUsers () {
+ return this.searchUserIds.map(userId => this.findUser(userId)).filter(user => user)
+ },
+ ...mapState({
+ currentUser: state => state.users.currentUser
+ }),
+ ...mapGetters(['findUser', 'findListTitle', 'findListAccounts'])
+ },
+ methods: {
+ onInput () {
+ this.search(this.query)
+ },
+ toggleRemoveMember (user) {
+ if (this.removedUserIds.has(user.id)) {
+ this.id && this.addUser(user)
+ this.removedUserIds.delete(user.id)
+ } else {
+ this.id && this.removeUser(user.id)
+ this.removedUserIds.add(user.id)
+ }
+ },
+ toggleAddFromSearch (user) {
+ if (this.addedUserIds.has(user.id)) {
+ this.id && this.removeUser(user.id)
+ this.addedUserIds.delete(user.id)
+ } else {
+ this.id && this.addUser(user)
+ this.addedUserIds.add(user.id)
+ }
+ },
+ isRemoved (user) {
+ return this.removedUserIds.has(user.id)
+ },
+ isAdded (user) {
+ return this.addedUserIds.has(user.id)
+ },
+ addUser (user) {
+ this.$store.dispatch('addListAccount', { accountId: this.user.id, listId: this.id })
+ },
+ removeUser (userId) {
+ this.$store.dispatch('removeListAccount', { accountId: this.user.id, listId: this.id })
+ },
+ onSearchLoading (results) {
+ this.searchLoading = true
+ },
+ onSearchLoadingDone (results) {
+ this.searchLoading = false
+ },
+ onSearchResults (results) {
+ this.searchLoading = false
+ this.searchUserIds = results
+ },
+ updateListTitle () {
+ this.$store.dispatch('setList', { listId: this.id, title: this.titleDraft })
+ .then(() => {
+ this.title = this.findListTitle(this.id)
+ })
+ },
+ createList () {
+ this.$store.dispatch('createList', { title: this.titleDraft })
+ .then((list) => {
+ return this
+ .$store
+ .dispatch('setListAccounts', { listId: list.id, accountIds: [...this.addedUserIds] })
+ .then(() => list.id)
+ })
+ .then((listId) => {
+ this.$router.push({ name: 'lists-timeline', params: { id: listId } })
+ })
+ .catch((e) => {
+ this.$store.dispatch('pushGlobalNotice', {
+ messageKey: 'lists.error',
+ messageArgs: [e.message],
+ level: 'error'
+ })
+ })
+ },
+ deleteList () {
+ this.$store.dispatch('deleteList', { listId: this.id })
+ this.$router.push({ name: 'lists' })
+ }
+ }
+}
+
+export default ListsNew
diff --git a/src/components/lists_edit/lists_edit.vue b/src/components/lists_edit/lists_edit.vue
new file mode 100644
index 00000000..6521aba6
--- /dev/null
+++ b/src/components/lists_edit/lists_edit.vue
@@ -0,0 +1,228 @@
+<template>
+ <div class="panel-default panel ListEdit">
+ <div
+ ref="header"
+ class="panel-heading list-edit-heading"
+ >
+ <button
+ class="button-unstyled go-back-button"
+ @click="$router.back"
+ >
+ <FAIcon
+ size="lg"
+ icon="chevron-left"
+ />
+ </button>
+ <div class="title">
+ <i18n-t
+ v-if="id"
+ keypath="lists.editing_list"
+ >
+ <template #listTitle>
+ {{ title }}
+ </template>
+ </i18n-t>
+ <i18n-t
+ v-else
+ keypath="lists.creating_list"
+ />
+ </div>
+ </div>
+ <div class="panel-body">
+ <div class="input-wrap">
+ <label for="list-edit-title">{{ $t('lists.title') }}</label>
+ {{ ' ' }}
+ <input
+ id="list-edit-title"
+ ref="title"
+ v-model="titleDraft"
+ >
+ <button
+ v-if="id"
+ class="btn button-default follow-button"
+ @click="updateListTitle"
+ >
+ {{ $t('lists.update_title') }}
+ </button>
+ </div>
+ <tab-switcher
+ class="list-member-management"
+ :scrollable-tabs="true"
+ >
+ <div
+ v-if="id || addedUserIds.size > 0"
+ :label="$t('lists.manage_members')"
+ class="members-list"
+ >
+ <div class="users-list">
+ <div
+ v-for="user in membersUsers"
+ :key="user.id"
+ class="member"
+ >
+ <BasicUserCard
+ :user="user"
+ >
+ <button
+ class="btn button-default follow-button"
+ @click="toggleRemoveMember(user)"
+ >
+ {{ isRemoved(user) ? $t('general.undo') : $t('lists.remove_from_list') }}
+ </button>
+ </BasicUserCard>
+ </div>
+ </div>
+ </div>
+
+ <div
+ class="search-list"
+ :label="$t('lists.add_members')"
+ >
+ <ListsUserSearch
+ @results="onSearchResults"
+ @loading="onSearchLoading"
+ @loadingDone="onSearchLoadingDone"
+ />
+ <div
+ v-if="searchLoading"
+ class="loading"
+ >
+ <PanelLoading />
+ </div>
+ <div
+ v-else
+ class="users-list"
+ >
+ <div
+ v-for="user in searchUsers"
+ :key="user.id"
+ class="member"
+ >
+ <BasicUserCard
+ :user="user"
+ >
+ <span
+ v-if="membersUserIds.includes(user.id)"
+ >
+ {{ $t('lists.is_in_list') }}
+ </span>
+ <button
+ v-if="!membersUserIds.includes(user.id)"
+ class="btn button-default follow-button"
+ @click="toggleAddFromSearch(user)"
+ >
+ {{ isAdded(user) ? $t('general.undo') : $t('lists.add_to_list') }}
+ </button>
+ <button
+ v-else
+ class="btn button-default follow-button"
+ @click="toggleRemoveMember(user)"
+ >
+ {{ isRemoved(user) ? $t('general.undo') : $t('lists.remove_from_list') }}
+ </button>
+ </BasicUserCard>
+ </div>
+ </div>
+ </div>
+ </tab-switcher>
+ </div>
+ <div class="panel-footer">
+ <span class="spacer" />
+ <button
+ v-if="!id"
+ class="btn button-default footer-button"
+ @click="createList"
+ >
+ {{ $t('lists.create') }}
+ </button>
+ <button
+ v-else-if="!reallyDelete"
+ class="btn button-default footer-button"
+ @click="reallyDelete = true"
+ >
+ {{ $t('lists.delete') }}
+ </button>
+ <template v-else>
+ {{ $t('lists.really_delete') }}
+ <button
+ class="btn button-default footer-button"
+ @click="deleteList"
+ >
+ {{ $t('general.yes') }}
+ </button>
+ <button
+ class="btn button-default footer-button"
+ @click="reallyDelete = false"
+ >
+ {{ $t('general.no') }}
+ </button>
+ </template>
+ </div>
+ </div>
+</template>
+
+<script src="./lists_edit.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.ListEdit {
+ --panel-body-padding: 0.5em;
+
+ height: calc(100vh - var(--navbar-height));
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+
+ .list-edit-heading {
+ grid-template-columns: auto minmax(50%, 1fr);
+ }
+
+ .panel-body {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ overflow: hidden;
+ }
+
+ .list-member-management {
+ flex: 1 0 auto;
+ }
+
+ .search-icon {
+ margin-right: 0.3em;
+ }
+
+ .users-list {
+ padding-bottom: 0.7rem;
+ overflow-y: auto;
+ }
+
+ & .search-list,
+ & .members-list {
+ overflow: hidden;
+ flex-direction: column;
+ min-height: 0;
+ }
+
+ .go-back-button {
+ text-align: center;
+ line-height: 1;
+ height: 100%;
+ align-self: start;
+ width: var(--__panel-heading-height-inner);
+ }
+
+ .btn {
+ margin: 0 0.5em;
+ }
+
+ .panel-footer {
+ grid-template-columns: minmax(10%, 1fr);
+
+ .footer-button {
+ min-width: 9em;
+ }
+ }
+}
+</style>
diff --git a/src/components/lists_menu/lists_menu_content.js b/src/components/lists_menu/lists_menu_content.js
new file mode 100644
index 00000000..97b32210
--- /dev/null
+++ b/src/components/lists_menu/lists_menu_content.js
@@ -0,0 +1,22 @@
+import { mapState } from 'vuex'
+import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
+import { getListEntries } from 'src/components/navigation/filter.js'
+
+export const ListsMenuContent = {
+ props: [
+ 'showPin'
+ ],
+ components: {
+ NavigationEntry
+ },
+ computed: {
+ ...mapState({
+ lists: getListEntries,
+ currentUser: state => state.users.currentUser,
+ privateMode: state => state.instance.private,
+ federating: state => state.instance.federating
+ })
+ }
+}
+
+export default ListsMenuContent
diff --git a/src/components/lists_menu/lists_menu_content.vue b/src/components/lists_menu/lists_menu_content.vue
new file mode 100644
index 00000000..f93e80c9
--- /dev/null
+++ b/src/components/lists_menu/lists_menu_content.vue
@@ -0,0 +1,12 @@
+<template>
+ <ul>
+ <NavigationEntry
+ v-for="item in lists"
+ :key="item.name"
+ :show-pin="showPin"
+ :item="item"
+ />
+ </ul>
+</template>
+
+<script src="./lists_menu_content.js"></script>
diff --git a/src/components/lists_timeline/lists_timeline.js b/src/components/lists_timeline/lists_timeline.js
new file mode 100644
index 00000000..c3f408bd
--- /dev/null
+++ b/src/components/lists_timeline/lists_timeline.js
@@ -0,0 +1,36 @@
+import Timeline from '../timeline/timeline.vue'
+const ListsTimeline = {
+ data () {
+ return {
+ listId: null
+ }
+ },
+ components: {
+ Timeline
+ },
+ computed: {
+ timeline () { return this.$store.state.statuses.timelines.list }
+ },
+ watch: {
+ $route: function (route) {
+ if (route.name === 'lists-timeline' && route.params.id !== this.listId) {
+ this.listId = route.params.id
+ this.$store.dispatch('stopFetchingTimeline', 'list')
+ this.$store.commit('clearTimeline', { timeline: 'list' })
+ this.$store.dispatch('fetchList', { listId: this.listId })
+ this.$store.dispatch('startFetchingTimeline', { timeline: 'list', listId: this.listId })
+ }
+ }
+ },
+ created () {
+ this.listId = this.$route.params.id
+ this.$store.dispatch('fetchList', { listId: this.listId })
+ this.$store.dispatch('startFetchingTimeline', { timeline: 'list', listId: this.listId })
+ },
+ unmounted () {
+ this.$store.dispatch('stopFetchingTimeline', 'list')
+ this.$store.commit('clearTimeline', { timeline: 'list' })
+ }
+}
+
+export default ListsTimeline
diff --git a/src/components/lists_timeline/lists_timeline.vue b/src/components/lists_timeline/lists_timeline.vue
new file mode 100644
index 00000000..18156b81
--- /dev/null
+++ b/src/components/lists_timeline/lists_timeline.vue
@@ -0,0 +1,10 @@
+<template>
+ <Timeline
+ title="list.name"
+ :timeline="timeline"
+ :list-id="listId"
+ timeline-name="list"
+ />
+</template>
+
+<script src="./lists_timeline.js"></script>
diff --git a/src/components/lists_user_search/lists_user_search.js b/src/components/lists_user_search/lists_user_search.js
new file mode 100644
index 00000000..c92ec0ee
--- /dev/null
+++ b/src/components/lists_user_search/lists_user_search.js
@@ -0,0 +1,51 @@
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faSearch,
+ faChevronLeft
+} from '@fortawesome/free-solid-svg-icons'
+import { debounce } from 'lodash'
+import Checkbox from '../checkbox/checkbox.vue'
+
+library.add(
+ faSearch,
+ faChevronLeft
+)
+
+const ListsUserSearch = {
+ components: {
+ Checkbox
+ },
+ emits: ['loading', 'loadingDone', 'results'],
+ data () {
+ return {
+ loading: false,
+ query: '',
+ followingOnly: true
+ }
+ },
+ methods: {
+ onInput: debounce(function () {
+ this.search(this.query)
+ }, 2000),
+ search (query) {
+ if (!query) {
+ this.loading = false
+ return
+ }
+
+ this.loading = true
+ this.$emit('loading')
+ this.userIds = []
+ this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts', following: this.followingOnly })
+ .then(data => {
+ this.$emit('results', data.accounts.map(a => a.id))
+ })
+ .finally(() => {
+ this.loading = false
+ this.$emit('loadingDone')
+ })
+ }
+ }
+}
+
+export default ListsUserSearch
diff --git a/src/components/lists_user_search/lists_user_search.vue b/src/components/lists_user_search/lists_user_search.vue
new file mode 100644
index 00000000..8633170c
--- /dev/null
+++ b/src/components/lists_user_search/lists_user_search.vue
@@ -0,0 +1,47 @@
+<template>
+ <div class="ListsUserSearch">
+ <div class="input-wrap">
+ <div class="input-search">
+ <FAIcon
+ class="search-icon fa-scale-110 fa-old-padding"
+ icon="search"
+ />
+ </div>
+ <input
+ ref="search"
+ v-model="query"
+ :placeholder="$t('lists.search')"
+ @input="onInput"
+ >
+ </div>
+ <div class="input-wrap">
+ <Checkbox
+ v-model="followingOnly"
+ @change="onInput"
+ >
+ {{ $t('lists.following_only') }}
+ </Checkbox>
+ </div>
+ </div>
+</template>
+
+<script src="./lists_user_search.js"></script>
+<style lang="scss">
+@import '../../_variables.scss';
+
+.ListsUserSearch {
+ .input-wrap {
+ display: flex;
+ margin: 0.7em 0.5em 0.7em 0.5em;
+
+ input {
+ width: 100%;
+ }
+ }
+
+ .search-icon {
+ margin-right: 0.3em;
+ }
+}
+
+</style>
diff --git a/src/components/login_form/login_form.js b/src/components/login_form/login_form.js
index 638bd812..b795640e 100644
--- a/src/components/login_form/login_form.js
+++ b/src/components/login_form/login_form.js
@@ -83,7 +83,7 @@ const LoginForm = {
},
clearError () { this.error = false },
focusOnPasswordInput () {
- let passwordInput = this.$refs.passwordInput
+ const passwordInput = this.$refs.passwordInput
passwordInput.focus()
passwordInput.setSelectionRange(0, passwordInput.value.length)
}
diff --git a/src/components/login_form/login_form.vue b/src/components/login_form/login_form.vue
index bfabb946..7a430c51 100644
--- a/src/components/login_form/login_form.vue
+++ b/src/components/login_form/login_form.vue
@@ -76,17 +76,21 @@
>
<div class="alert error">
{{ error }}
- <FAIcon
- class="fa-scale-110 fa-old-padding"
- icon="times"
+ <button
+ class="button-unstyled"
@click="clearError"
- />
+ >
+ <FAIcon
+ class="fa-scale-110 fa-old-padding"
+ icon="times"
+ />
+ </button>
</div>
</div>
</div>
</template>
-<script src="./login_form.js" ></script>
+<script src="./login_form.js"></script>
<style lang="scss">
@import '../../_variables.scss';
@@ -97,7 +101,7 @@
padding: 0.6em;
.btn {
- min-height: 28px;
+ min-height: 2em;
width: 10em;
}
diff --git a/src/components/media_modal/media_modal.js b/src/components/media_modal/media_modal.js
index e7384c93..ff993664 100644
--- a/src/components/media_modal/media_modal.js
+++ b/src/components/media_modal/media_modal.js
@@ -1,24 +1,46 @@
import StillImage from '../still-image/still-image.vue'
import VideoAttachment from '../video_attachment/video_attachment.vue'
import Modal from '../modal/modal.vue'
-import fileTypeService from '../../services/file_type/file_type.service.js'
+import PinchZoom from '../pinch_zoom/pinch_zoom.vue'
+import SwipeClick from '../swipe_click/swipe_click.vue'
import GestureService from '../../services/gesture_service/gesture_service'
+import Flash from 'src/components/flash/flash.vue'
+import fileTypeService from '../../services/file_type/file_type.service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faChevronLeft,
- faChevronRight
+ faChevronRight,
+ faCircleNotch,
+ faTimes
} from '@fortawesome/free-solid-svg-icons'
library.add(
faChevronLeft,
- faChevronRight
+ faChevronRight,
+ faCircleNotch,
+ faTimes
)
const MediaModal = {
components: {
StillImage,
VideoAttachment,
- Modal
+ PinchZoom,
+ SwipeClick,
+ Modal,
+ Flash
+ },
+ data () {
+ return {
+ loading: false,
+ swipeDirection: GestureService.DIRECTION_LEFT,
+ swipeThreshold: () => {
+ const considerableMoveRatio = 1 / 4
+ return window.innerWidth * considerableMoveRatio
+ },
+ pinchZoomMinScale: 1,
+ pinchZoomScaleResetLimit: 1.2
+ }
},
computed: {
showing () {
@@ -27,6 +49,9 @@ const MediaModal = {
media () {
return this.$store.state.mediaViewer.media
},
+ description () {
+ return this.currentMedia.description
+ },
currentIndex () {
return this.$store.state.mediaViewer.currentIndex
},
@@ -37,43 +62,62 @@ const MediaModal = {
return this.media.length > 1
},
type () {
- return this.currentMedia ? fileTypeService.fileType(this.currentMedia.mimetype) : null
+ return this.currentMedia ? this.getType(this.currentMedia) : null
}
},
- created () {
- this.mediaSwipeGestureRight = GestureService.swipeGesture(
- GestureService.DIRECTION_RIGHT,
- this.goPrev,
- 50
- )
- this.mediaSwipeGestureLeft = GestureService.swipeGesture(
- GestureService.DIRECTION_LEFT,
- this.goNext,
- 50
- )
- },
methods: {
- mediaTouchStart (e) {
- GestureService.beginSwipe(e, this.mediaSwipeGestureRight)
- GestureService.beginSwipe(e, this.mediaSwipeGestureLeft)
- },
- mediaTouchMove (e) {
- GestureService.updateSwipe(e, this.mediaSwipeGestureRight)
- GestureService.updateSwipe(e, this.mediaSwipeGestureLeft)
+ getType (media) {
+ return fileTypeService.fileType(media.mimetype)
},
hide () {
- this.$store.dispatch('closeMediaViewer')
+ // HACK: Closing immediately via a touch will cause the click
+ // to be processed on the content below the overlay
+ const transitionTime = 100 // ms
+ setTimeout(() => {
+ this.$store.dispatch('closeMediaViewer')
+ }, transitionTime)
+ },
+ hideIfNotSwiped (event) {
+ // If we have swiped over SwipeClick, do not trigger hide
+ const comp = this.$refs.swipeClick
+ if (!comp) {
+ this.hide()
+ } else {
+ comp.$gesture.click(event)
+ }
},
goPrev () {
if (this.canNavigate) {
const prevIndex = this.currentIndex === 0 ? this.media.length - 1 : (this.currentIndex - 1)
- this.$store.dispatch('setCurrent', this.media[prevIndex])
+ const newMedia = this.media[prevIndex]
+ if (this.getType(newMedia) === 'image') {
+ this.loading = true
+ }
+ this.$store.dispatch('setCurrentMedia', newMedia)
}
},
goNext () {
if (this.canNavigate) {
const nextIndex = this.currentIndex === this.media.length - 1 ? 0 : (this.currentIndex + 1)
- this.$store.dispatch('setCurrent', this.media[nextIndex])
+ const newMedia = this.media[nextIndex]
+ if (this.getType(newMedia) === 'image') {
+ this.loading = true
+ }
+ this.$store.dispatch('setCurrentMedia', newMedia)
+ }
+ },
+ onImageLoaded () {
+ this.loading = false
+ },
+ handleSwipePreview (offsets) {
+ this.$refs.pinchZoom.setTransform({ scale: 1, x: offsets[0], y: 0 })
+ },
+ handleSwipeEnd (sign) {
+ this.$refs.pinchZoom.setTransform({ scale: 1, x: 0, y: 0 })
+ if (sign > 0) {
+ this.goNext()
+ } else if (sign < 0) {
+ this.goPrev()
}
},
handleKeyupEvent (e) {
@@ -98,7 +142,7 @@ const MediaModal = {
document.addEventListener('keyup', this.handleKeyupEvent)
document.addEventListener('keydown', this.handleKeydownEvent)
},
- destroyed () {
+ unmounted () {
window.removeEventListener('popstate', this.hide)
document.removeEventListener('keyup', this.handleKeyupEvent)
document.removeEventListener('keydown', this.handleKeydownEvent)
diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue
index 54bc5335..d59055b3 100644
--- a/src/components/media_modal/media_modal.vue
+++ b/src/components/media_modal/media_modal.vue
@@ -2,18 +2,38 @@
<Modal
v-if="showing"
class="media-modal-view"
- @backdropClicked="hide"
+ @backdropClicked="hideIfNotSwiped"
>
- <img
+ <SwipeClick
v-if="type === 'image'"
- class="modal-image"
- :src="currentMedia.url"
- :alt="currentMedia.description"
- :title="currentMedia.description"
- @touchstart.stop="mediaTouchStart"
- @touchmove.stop="mediaTouchMove"
- @click="hide"
+ ref="swipeClick"
+ class="modal-image-container"
+ :direction="swipeDirection"
+ :threshold="swipeThreshold"
+ @preview-requested="handleSwipePreview"
+ @swipe-finished="handleSwipeEnd"
+ @swipeless-clicked="hide"
>
+ <PinchZoom
+ ref="pinchZoom"
+ class="modal-image-container-inner"
+ selector=".modal-image"
+ reach-min-scale-strategy="reset"
+ stop-propagate-handled="stop-propgate-handled"
+ :allow-pan-min-scale="pinchZoomMinScale"
+ :min-scale="pinchZoomMinScale"
+ :reset-to-min-scale-limit="pinchZoomScaleResetLimit"
+ >
+ <img
+ :class="{ loading }"
+ class="modal-image"
+ :src="currentMedia.url"
+ :alt="currentMedia.description"
+ :title="currentMedia.description"
+ @load="onImageLoaded"
+ >
+ </PinchZoom>
+ </SwipeClick>
<VideoAttachment
v-if="type === 'video'"
class="modal-image"
@@ -28,38 +48,84 @@
:title="currentMedia.description"
controls
/>
+ <Flash
+ v-if="type === 'flash'"
+ class="modal-image"
+ :src="currentMedia.url"
+ :alt="currentMedia.description"
+ :title="currentMedia.description"
+ />
<button
v-if="canNavigate"
:title="$t('media_modal.previous')"
- class="modal-view-button-arrow modal-view-button-arrow--prev"
+ class="modal-view-button modal-view-button-arrow modal-view-button-arrow--prev"
@click.stop.prevent="goPrev"
>
<FAIcon
- class="arrow-icon"
+ class="button-icon arrow-icon"
icon="chevron-left"
/>
</button>
<button
v-if="canNavigate"
:title="$t('media_modal.next')"
- class="modal-view-button-arrow modal-view-button-arrow--next"
+ class="modal-view-button modal-view-button-arrow modal-view-button-arrow--next"
@click.stop.prevent="goNext"
>
<FAIcon
- class="arrow-icon"
+ class="button-icon arrow-icon"
icon="chevron-right"
/>
</button>
+ <button
+ class="modal-view-button modal-view-button-hide"
+ :title="$t('media_modal.hide')"
+ @click.stop.prevent="hide"
+ >
+ <FAIcon
+ class="button-icon"
+ icon="times"
+ />
+ </button>
+
+ <span
+ v-if="description"
+ class="description"
+ >
+ {{ description }}
+ </span>
+ <span
+ class="counter"
+ >
+ {{ $tc('media_modal.counter', currentIndex + 1, { current: currentIndex + 1, total: media.length }) }}
+ </span>
+ <span
+ v-if="loading"
+ class="loading-spinner"
+ >
+ <FAIcon
+ spin
+ icon="circle-notch"
+ size="5x"
+ />
+ </span>
</Modal>
</template>
<script src="./media_modal.js"></script>
<style lang="scss">
+$modal-view-button-icon-height: 3em;
+$modal-view-button-icon-half-height: calc(#{$modal-view-button-icon-height} / 2);
+$modal-view-button-icon-width: 3em;
+$modal-view-button-icon-margin: 0.5em;
+
.modal-view.media-modal-view {
- z-index: 1001;
+ z-index: var(--ZI_media_modal);
+ flex-direction: column;
- .modal-view-button-arrow {
+ .modal-view-button-arrow,
+ .modal-view-button-hide {
opacity: 0.75;
&:focus,
@@ -67,69 +133,154 @@
outline: none;
box-shadow: none;
}
+
&:hover {
opacity: 1;
}
}
+ overflow: hidden;
}
-@keyframes media-fadein {
- from {
- opacity: 0;
+.media-modal-view {
+ @keyframes media-fadein {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
}
- to {
- opacity: 1;
+
+ .modal-image-container {
+ display: flex;
+ overflow: hidden;
+ align-items: center;
+ flex-direction: column;
+ max-width: 100%;
+ max-height: 100%;
+ width: 100%;
+ height: 100%;
+ flex-grow: 1;
+ justify-content: center;
+
+ &-inner {
+ width: 100%;
+ height: 100%;
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ }
}
-}
-.modal-image {
- max-width: 90%;
- max-height: 90%;
- box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5);
- image-orientation: from-image; // NOTE: only FF supports this
- animation: 0.1s cubic-bezier(0.7, 0, 1, 0.6) media-fadein;
-}
+ .description,
+ .counter {
+ /* Hardcoded since background is also hardcoded */
+ color: white;
+ margin-top: 1em;
+ text-shadow: 0 0 10px black, 0 0 10px black;
+ padding: 0.2em 2em;
+ }
+
+ .description {
+ flex: 0 0 auto;
+ overflow-y: auto;
+ min-height: 1em;
+ max-width: 500px;
+ max-height: 9.5em;
+ word-break: break-all;
+ }
-.modal-view-button-arrow {
- position: absolute;
- display: block;
- top: 50%;
- margin-top: -50px;
- width: 70px;
- height: 100px;
- border: 0;
- padding: 0;
- opacity: 0;
- box-shadow: none;
- background: none;
- appearance: none;
- overflow: visible;
- cursor: pointer;
- transition: opacity 333ms cubic-bezier(.4,0,.22,1);
-
- .arrow-icon {
+ .modal-image {
+ max-width: 100%;
+ max-height: 100%;
+ image-orientation: from-image; // NOTE: only FF supports this
+ animation: 0.1s cubic-bezier(0.7, 0, 1, 0.6) media-fadein;
+
+ &.loading {
+ opacity: 0.5;
+ }
+ }
+
+ .loading-spinner {
+ width: 100%;
+ height: 100%;
position: absolute;
- top: 35px;
- height: 30px;
- width: 32px;
- font-size: 14px;
- line-height: 30px;
- color: #FFF;
- text-align: center;
- background-color: rgba(0,0,0,.3);
+ pointer-events: none;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ svg {
+ color: white;
+ }
}
- &--prev {
- left: 0;
+ .modal-view-button {
+ border: 0;
+ padding: 0;
+ opacity: 0;
+ box-shadow: none;
+ background: none;
+ appearance: none;
+ overflow: visible;
+ cursor: pointer;
+ transition: opacity 333ms cubic-bezier(.4,0,.22,1);
+ height: $modal-view-button-icon-height;
+ width: $modal-view-button-icon-width;
+
+ .button-icon {
+ position: absolute;
+ height: $modal-view-button-icon-height;
+ width: $modal-view-button-icon-width;
+ font-size: 1rem;
+ line-height: $modal-view-button-icon-height;
+ color: #FFF;
+ text-align: center;
+ background-color: rgba(0,0,0,.3);
+ }
+ }
+
+ .modal-view-button-arrow {
+ position: absolute;
+ display: block;
+ top: 50%;
+ margin-top: $modal-view-button-icon-half-height;
+ width: $modal-view-button-icon-width;
+ height: $modal-view-button-icon-height;
+
.arrow-icon {
- left: 6px;
+ position: absolute;
+ top: 0;
+ line-height: $modal-view-button-icon-height;
+ color: #FFF;
+ text-align: center;
+ background-color: rgba(0,0,0,.3);
+ }
+
+ &--prev {
+ left: 0;
+ .arrow-icon {
+ left: $modal-view-button-icon-margin;
+ }
+ }
+
+ &--next {
+ right: 0;
+ .arrow-icon {
+ right: $modal-view-button-icon-margin;
+ }
}
}
- &--next {
+ .modal-view-button-hide {
+ position: absolute;
+ top: 0;
right: 0;
- .arrow-icon {
- right: 6px;
+ .button-icon {
+ top: $modal-view-button-icon-margin;
+ right: $modal-view-button-icon-margin;
}
}
}
diff --git a/src/components/media_upload/media_upload.js b/src/components/media_upload/media_upload.js
index 669d8190..cfd42d4c 100644
--- a/src/components/media_upload/media_upload.js
+++ b/src/components/media_upload/media_upload.js
@@ -42,7 +42,8 @@ const mediaUpload = {
.then((fileData) => {
self.$emit('uploaded', fileData)
self.decreaseUploadCount()
- }, (error) => { // eslint-disable-line handle-callback-err
+ }, (error) => {
+ console.error('Error uploading file', error)
self.$emit('upload-failed', 'default')
self.decreaseUploadCount()
})
@@ -73,7 +74,7 @@ const mediaUpload = {
'disabled'
],
watch: {
- 'dropFiles': function (fileInfos) {
+ dropFiles: function (fileInfos) {
if (!this.uploading) {
this.multiUpload(fileInfos)
}
diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue
index e955aa72..a538a5ed 100644
--- a/src/components/media_upload/media_upload.vue
+++ b/src/components/media_upload/media_upload.vue
@@ -17,21 +17,25 @@
/>
<input
v-if="uploadReady"
+ class="hidden-input-file"
:disabled="disabled"
type="file"
- style="position: fixed; top: -100em"
multiple="true"
@change="change"
>
</label>
</template>
-<script src="./media_upload.js" ></script>
+<script src="./media_upload.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.media-upload {
- cursor: pointer;
+ cursor: pointer; // We use <label> for interactivity... i wonder if it's fine
+
+ .hidden-input-file {
+ display: none;
+ }
}
</style>
diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js
index 65c62baa..6515bd11 100644
--- a/src/components/mention_link/mention_link.js
+++ b/src/components/mention_link/mention_link.js
@@ -1,6 +1,9 @@
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { mapGetters, mapState } from 'vuex'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
+import UserAvatar from '../user_avatar/user_avatar.vue'
+import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
+import { defineAsyncComponent } from 'vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAt
@@ -12,6 +15,11 @@ library.add(
const MentionLink = {
name: 'MentionLink',
+ components: {
+ UserAvatar,
+ UnicodeDomainIndicator,
+ UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue'))
+ },
props: {
url: {
required: true,
@@ -30,15 +38,30 @@ const MentionLink = {
type: String
}
},
+ data () {
+ return {
+ hasSelection: false
+ }
+ },
methods: {
onClick () {
+ if (this.shouldShowTooltip) return
const link = generateProfileLink(
this.userId || this.user.id,
this.userScreenName || this.user.screen_name
)
this.$router.push(link)
+ },
+ handleSelection () {
+ this.hasSelection = document.getSelection().containsNode(this.$refs.full, true)
}
},
+ mounted () {
+ document.addEventListener('selectionchange', this.handleSelection)
+ },
+ unmounted () {
+ document.removeEventListener('selectionchange', this.handleSelection)
+ },
computed: {
user () {
return this.url && this.$store && this.$store.getters.findUserByUrl(this.url)
@@ -50,6 +73,10 @@ const MentionLink = {
userName () {
return this.user && this.userNameFullUi.split('@')[0]
},
+ serverName () {
+ // XXX assumed that domain does not contain @
+ return this.user && (this.userNameFullUi.split('@')[1] || this.$store.getters.instanceDomain)
+ },
userNameFull () {
return this.user && this.user.screen_name
},
@@ -79,12 +106,44 @@ const MentionLink = {
classnames () {
return [
{
- '-you': this.isYou,
- '-highlighted': this.highlight
+ '-you': this.isYou && this.shouldBoldenYou,
+ '-highlighted': this.highlight,
+ '-has-selection': this.hasSelection
},
this.highlightType
]
},
+ useAtIcon () {
+ return this.mergedConfig.useAtIcon
+ },
+ isRemote () {
+ return this.userName !== this.userNameFull
+ },
+ shouldShowFullUserName () {
+ const conf = this.mergedConfig.mentionLinkDisplay
+ if (conf === 'short') {
+ return false
+ } else if (conf === 'full') {
+ return true
+ } else { // full_for_remote
+ return this.isRemote
+ }
+ },
+ shouldShowTooltip () {
+ return this.mergedConfig.mentionLinkShowTooltip
+ },
+ shouldShowAvatar () {
+ return this.mergedConfig.mentionLinkShowAvatar
+ },
+ shouldShowYous () {
+ return this.mergedConfig.mentionLinkShowYous
+ },
+ shouldBoldenYou () {
+ return this.mergedConfig.mentionLinkBoldenYou
+ },
+ shouldFadeDomain () {
+ return this.mergedConfig.mentionLinkFadeDomain
+ },
...mapGetters(['mergedConfig']),
...mapState({
currentUser: state => state.users.currentUser
diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss
index ec2689f8..8b2af926 100644
--- a/src/components/mention_link/mention_link.scss
+++ b/src/components/mention_link/mention_link.scss
@@ -1,15 +1,27 @@
+@import '../../_variables.scss';
+
.MentionLink {
position: relative;
white-space: normal;
- display: inline-block;
+ display: inline;
color: var(--link);
+ word-break: normal;
& .new,
& .original {
- display: inline-block;
+ display: inline;
border-radius: 2px;
}
+ .mention-avatar {
+ border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+ width: 1.5em;
+ height: 1.5em;
+ vertical-align: middle;
+ user-select: none;
+ margin-right: 0.2em;
+ }
+
.full {
position: absolute;
display: inline-block;
@@ -27,7 +39,8 @@
user-select: all;
}
- .short {
+ & .short.-with-tooltip,
+ & .you {
user-select: none;
}
@@ -36,19 +49,25 @@
white-space: nowrap;
}
+ .shortName {
+ white-space: normal;
+ }
+
.new {
&.-you {
- & .shortName,
- & .full {
+ .shortName {
font-weight: 600;
}
}
+ &.-has-selection {
+ color: var(--alertNeutralText, $fallback--text);
+ background-color: var(--alertNeutral, $fallback--fg);
+ }
.at {
color: var(--link);
opacity: 0.8;
display: inline-block;
- height: 50%;
line-height: 1;
padding: 0 0.1em;
vertical-align: -25%;
@@ -56,8 +75,7 @@
}
&.-striped {
- & .userName,
- & .full {
+ & .shortName {
background-image:
repeating-linear-gradient(
135deg,
@@ -70,22 +88,29 @@
}
&.-solid {
- & .userName,
- & .full {
+ .shortName {
background-image: linear-gradient(var(--____highlight-tintColor2), var(--____highlight-tintColor2));
}
}
&.-side {
- & .userName,
- & .userNameFull {
+ .shortName {
box-shadow: 0 -5px 3px -4px inset var(--____highlight-solidColor);
}
}
}
- &:hover .new .full {
- opacity: 1;
- pointer-events: initial;
+ .full {
+ pointer-events: none;
}
+
+ .serverName.-faded {
+ color: var(--faintLink, $fallback--link);
+ }
+}
+
+.mention-link-popover {
+ max-width: 70ch;
+ max-height: 20rem;
+ overflow: hidden;
}
diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue
index a22b486c..869a3257 100644
--- a/src/components/mention_link/mention_link.vue
+++ b/src/components/mention_link/mention_link.vue
@@ -9,48 +9,67 @@
class="original"
target="_blank"
v-html="content"
- />
- <!-- eslint-enable vue/no-v-html -->
- <span
- v-if="user"
- class="new"
- :style="style"
- :class="classnames"
+ /><!-- eslint-enable vue/no-v-html -->
+ <UserPopover
+ v-else
+ :user-id="user.id"
+ :disabled="!shouldShowTooltip"
>
- <a
- class="short button-unstyled"
- :href="url"
- @click.prevent="onClick"
- >
- <!-- eslint-disable vue/no-v-html -->
- <FAIcon
- size="sm"
- icon="at"
- class="at"
- /><span class="shortName"><span
- class="userName"
- v-html="userName"
- /></span>
- <span
- v-if="isYou"
- class="you"
- >{{ $t('status.you') }}</span>
- <!-- eslint-enable vue/no-v-html -->
- </a>
<span
- v-if="userName !== userNameFull"
- class="full popover-default"
- :class="[highlightType]"
+ v-if="user"
+ class="new"
+ :style="style"
+ :class="classnames"
>
- <span
- class="userNameFull"
- v-text="'@' + userNameFull"
- />
+ <a
+ class="short button-unstyled"
+ :class="{ '-with-tooltip': shouldShowTooltip }"
+ :href="url"
+ @click.prevent="onClick"
+ >
+ <!-- eslint-disable vue/no-v-html -->
+ <UserAvatar
+ v-if="shouldShowAvatar"
+ class="mention-avatar"
+ :user="user"
+ /><span
+ class="shortName"
+ ><FAIcon
+ v-if="useAtIcon"
+ size="sm"
+ icon="at"
+ class="at"
+ />{{ !useAtIcon ? '@' : '' }}<span
+ class="userName"
+ v-html="userName"
+ /><span
+ v-if="shouldShowFullUserName"
+ class="serverName"
+ :class="{ '-faded': shouldFadeDomain }"
+ v-html="'@' + serverName"
+ /><UnicodeDomainIndicator
+ v-if="shouldShowFullUserName"
+ :user="user"
+ />
+ </span>
+ <span
+ v-if="isYou && shouldShowYous"
+ :class="{ '-you': shouldBoldenYou }"
+ > {{ ' ' + $t('status.you') }}</span>
+ <!-- eslint-enable vue/no-v-html -->
+ </a><span
+ ref="full"
+ class="full"
+ >
+ <!-- eslint-disable vue/no-v-html -->
+ @<span v-html="userName" /><span v-html="'@' + serverName" />
+ <!-- eslint-enable vue/no-v-html -->
+ </span>
</span>
- </span>
+ </UserPopover>
</span>
</template>
-<script src="./mention_link.js"/>
+<script src="./mention_link.js" />
-<style lang="scss" src="./mention_link.scss"/>
+<style lang="scss" src="./mention_link.scss" />
diff --git a/src/components/mentions_line/mentions_line.scss b/src/components/mentions_line/mentions_line.scss
index b9d5c14a..9a622e75 100644
--- a/src/components/mentions_line/mentions_line.scss
+++ b/src/components/mentions_line/mentions_line.scss
@@ -1,11 +1,13 @@
.MentionsLine {
+ word-break: break-all;
+
+ .mention-link:not(:first-child)::before {
+ content: ' ';
+ }
+
.showMoreLess {
+ margin-left: 0.5em;
white-space: normal;
color: var(--link);
}
-
- .fullExtraMentions,
- .mention-link:not(:last-child) {
- margin-right: 0.25em;
- }
}
diff --git a/src/components/mentions_line/mentions_line.vue b/src/components/mentions_line/mentions_line.vue
index f375e3b0..64c19bf1 100644
--- a/src/components/mentions_line/mentions_line.vue
+++ b/src/components/mentions_line/mentions_line.vue
@@ -6,7 +6,6 @@
class="mention-link"
:content="mention.content"
:url="mention.url"
- :first-mention="false"
/><span
v-if="manyMentions"
class="extraMentions"
@@ -14,15 +13,13 @@
<span
v-if="expanded"
class="fullExtraMentions"
- >
- <MentionLink
- v-for="mention in extraMentions"
- :key="mention.index"
- class="mention-link"
- :content="mention.content"
- :url="mention.url"
- :first-mention="false"
- />
+ >{{ ' ' }}<MentionLink
+ v-for="mention in extraMentions"
+ :key="mention.index"
+ class="mention-link"
+ :content="mention.content"
+ :url="mention.url"
+ />
</span><button
v-if="!expanded"
class="button-unstyled showMoreLess"
@@ -39,5 +36,5 @@
</span>
</span>
</template>
-<script src="./mentions_line.js" ></script>
+<script src="./mentions_line.js"></script>
<style lang="scss" src="./mentions_line.scss" />
diff --git a/src/components/mfa_form/recovery_form.vue b/src/components/mfa_form/recovery_form.vue
index 7c594228..5988fa51 100644
--- a/src/components/mfa_form/recovery_form.vue
+++ b/src/components/mfa_form/recovery_form.vue
@@ -56,13 +56,17 @@
>
<div class="alert error">
{{ error }}
- <FAIcon
- class="fa-scale-110 fa-old-padding"
- icon="times"
+ <button
+ class="button-unstyled"
@click="clearError"
- />
+ >
+ <FAIcon
+ class="fa-scale-110 fa-old-padding"
+ icon="times"
+ />
+ </button>
</div>
</div>
</div>
</template>
-<script src="./recovery_form.js" ></script>
+<script src="./recovery_form.js"></script>
diff --git a/src/components/mfa_form/totp_form.vue b/src/components/mfa_form/totp_form.vue
index 4ee13992..709eb9b8 100644
--- a/src/components/mfa_form/totp_form.vue
+++ b/src/components/mfa_form/totp_form.vue
@@ -58,12 +58,16 @@
>
<div class="alert error">
{{ error }}
- <FAIcon
- size="lg"
- class="fa-scale-110 fa-old-padding"
- icon="times"
+ <button
+ class="button-unstyled"
@click="clearError"
- />
+ >
+ <FAIcon
+ size="lg"
+ class="fa-scale-110 fa-old-padding"
+ icon="times"
+ />
+ </button>
</div>
</div>
</div>
diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js
index 9e736cfb..cdbbb812 100644
--- a/src/components/mobile_nav/mobile_nav.js
+++ b/src/components/mobile_nav/mobile_nav.js
@@ -2,33 +2,40 @@ import SideDrawer from '../side_drawer/side_drawer.vue'
import Notifications from '../notifications/notifications.vue'
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
import GestureService from '../../services/gesture_service/gesture_service'
+import NavigationPins from 'src/components/navigation/navigation_pins.vue'
import { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faTimes,
faBell,
- faBars
+ faBars,
+ faArrowUp,
+ faMinus
} from '@fortawesome/free-solid-svg-icons'
library.add(
faTimes,
faBell,
- faBars
+ faBars,
+ faArrowUp,
+ faMinus
)
const MobileNav = {
components: {
SideDrawer,
- Notifications
+ Notifications,
+ NavigationPins
},
data: () => ({
notificationsCloseGesture: undefined,
- notificationsOpen: false
+ notificationsOpen: false,
+ notificationsAtTop: true
}),
created () {
this.notificationsCloseGesture = GestureService.swipeGesture(
GestureService.DIRECTION_RIGHT,
- this.closeMobileNotifications,
+ () => this.closeMobileNotifications(true),
50
)
},
@@ -47,7 +54,10 @@ const MobileNav = {
isChat () {
return this.$route.name === 'chat'
},
- ...mapGetters(['unreadChatCount'])
+ ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount']),
+ chatsPinned () {
+ return new Set(this.$store.state.serverSideStorage.prefsStorage.collections.pinnedNavItems).has('chats')
+ }
},
methods: {
toggleMobileSidebar () {
@@ -56,12 +66,14 @@ const MobileNav = {
openMobileNotifications () {
this.notificationsOpen = true
},
- closeMobileNotifications () {
+ closeMobileNotifications (markRead) {
if (this.notificationsOpen) {
// make sure to mark notifs seen only when the notifs were open and not
// from close-calls.
this.notificationsOpen = false
- this.markNotificationsAsSeen()
+ if (markRead) {
+ this.markNotificationsAsSeen()
+ }
}
},
notificationsTouchStart (e) {
@@ -73,14 +85,19 @@ const MobileNav = {
scrollToTop () {
window.scrollTo(0, 0)
},
+ scrollMobileNotificationsToTop () {
+ this.$refs.mobileNotifications.scrollTo(0, 0)
+ },
logout () {
this.$router.replace('/main/public')
this.$store.dispatch('logout')
},
markNotificationsAsSeen () {
- this.$refs.notifications.markAsSeen()
+ // this.$refs.notifications.markAsSeen()
+ this.$store.dispatch('markNotificationsAsSeen')
},
onScroll ({ target: { scrollTop, clientHeight, scrollHeight } }) {
+ this.notificationsAtTop = scrollTop > 0
if (scrollTop + clientHeight >= scrollHeight) {
this.$refs.notifications.fetchOlderNotifications()
}
diff --git a/src/components/mobile_nav/mobile_nav.vue b/src/components/mobile_nav/mobile_nav.vue
index 0f0ea457..0f1fe621 100644
--- a/src/components/mobile_nav/mobile_nav.vue
+++ b/src/components/mobile_nav/mobile_nav.vue
@@ -5,12 +5,13 @@
<nav
id="nav"
class="mobile-nav"
- :class="{ 'mobile-hidden': isChat }"
@click="scrollToTop()"
>
<div class="item">
<button
class="button-unstyled mobile-nav-button"
+ :title="$t('nav.mobile_sidebar')"
+ :aria-expanaded="$refs.sideDrawer && !$refs.sideDrawer.closed"
@click.stop.prevent="toggleMobileSidebar()"
>
<FAIcon
@@ -18,23 +19,16 @@
icon="bars"
/>
<div
- v-if="unreadChatCount"
+ v-if="(unreadChatCount && !chatsPinned) || unreadAnnouncementCount"
class="alert-dot"
/>
</button>
- <router-link
- v-if="!hideSitename"
- class="site-name"
- :to="{ name: 'root' }"
- active-class="home"
- >
- {{ sitename }}
- </router-link>
- </div>
- <div class="item right">
+ <NavigationPins class="pins" />
+ </div> <div class="item right">
<button
v-if="currentUser"
class="button-unstyled mobile-nav-button"
+ :title="unseenNotificationsCount ? $t('nav.mobile_notifications_unread_active') : $t('nav.mobile_notifications')"
@click.stop.prevent="openMobileNotifications()"
>
<FAIcon
@@ -48,35 +42,48 @@
</button>
</div>
</nav>
- <div
+ <aside
v-if="currentUser"
class="mobile-notifications-drawer"
- :class="{ 'closed': !notificationsOpen }"
+ :class="{ '-closed': !notificationsOpen }"
@touchstart.stop="notificationsTouchStart"
@touchmove.stop="notificationsTouchMove"
>
<div class="mobile-notifications-header">
<span class="title">{{ $t('notifications.notifications') }}</span>
- <a
- class="mobile-nav-button"
- @click.stop.prevent="closeMobileNotifications()"
+ <span class="spacer" />
+ <button
+ v-if="notificationsAtTop"
+ class="button-unstyled mobile-nav-button"
+ :title="$t('general.scroll_to_top')"
+ @click.stop.prevent="scrollMobileNotificationsToTop"
+ >
+ <FALayers class="fa-scale-110 fa-old-padding-layer">
+ <FAIcon icon="arrow-up" />
+ <FAIcon
+ icon="minus"
+ transform="up-7"
+ />
+ </FALayers>
+ </button>
+ <button
+ class="button-unstyled mobile-nav-button"
+ :title="$t('nav.mobile_notifications_close')"
+ @click.stop.prevent="closeMobileNotifications(true)"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
/>
- </a>
+ </button>
</div>
<div
+ id="mobile-notifications"
+ ref="mobileNotifications"
class="mobile-notifications"
@scroll="onScroll"
- >
- <Notifications
- ref="notifications"
- :no-heading="true"
- />
- </div>
- </div>
+ />
+ </aside>
<SideDrawer
ref="sideDrawer"
:logout="logout"
@@ -90,15 +97,19 @@
@import '../../_variables.scss';
.MobileNav {
+ z-index: var(--ZI_navbar);
+
.mobile-nav {
display: grid;
- line-height: 50px;
- height: 50px;
+ line-height: var(--navbar-height);
grid-template-rows: 50px;
grid-template-columns: 2fr auto;
width: 100%;
- position: fixed;
box-sizing: border-box;
+
+ a {
+ color: var(--topBarLink, $fallback--link);
+ }
}
.mobile-inner-nav {
@@ -150,11 +161,12 @@
transition-property: transform;
transition-duration: 0.25s;
transform: translateX(0);
- z-index: 1001;
+ z-index: var(--ZI_navbar);
-webkit-overflow-scrolling: touch;
- &.closed {
+ &.-closed {
transform: translateX(100%);
+ box-shadow: none;
}
}
@@ -162,7 +174,7 @@
display: flex;
align-items: center;
justify-content: space-between;
- z-index: 1;
+ z-index: calc(var(--ZI_navbar) + 100);
width: 100%;
height: 50px;
line-height: 50px;
@@ -173,19 +185,30 @@
box-shadow: 0px 0px 4px rgba(0,0,0,.6);
box-shadow: var(--topBarShadow);
+ .spacer {
+ flex: 1;
+ }
+
.title {
font-size: 1.3em;
margin-left: 0.6em;
}
}
+ .pins {
+ flex: 1;
+
+ .pinned-item {
+ flex-grow: 1;
+ }
+ }
+
.mobile-notifications {
margin-top: 50px;
width: 100vw;
- height: calc(100vh - 50px);
+ height: calc(100vh - var(--navbar-height));
overflow-x: hidden;
overflow-y: scroll;
-
color: $fallback--text;
color: var(--text, $fallback--text);
background-color: $fallback--bg;
@@ -195,14 +218,17 @@
padding: 0;
border-radius: 0;
box-shadow: none;
+
.panel {
border-radius: 0;
margin: 0;
box-shadow: none;
}
- .panel:after {
+
+ .panel::after {
border-radius: 0;
}
+
.panel .panel-heading {
border-radius: 0;
box-shadow: none;
diff --git a/src/components/mobile_post_status_button/mobile_post_status_button.js b/src/components/mobile_post_status_button/mobile_post_status_button.js
index d27fb3b8..f7f96cd6 100644
--- a/src/components/mobile_post_status_button/mobile_post_status_button.js
+++ b/src/components/mobile_post_status_button/mobile_post_status_button.js
@@ -10,7 +10,8 @@ library.add(
const HIDDEN_FOR_PAGES = new Set([
'chats',
- 'chat'
+ 'chat',
+ 'lists-edit'
])
const MobilePostStatusButton = {
@@ -29,7 +30,7 @@ const MobilePostStatusButton = {
}
window.addEventListener('resize', this.handleOSK)
},
- destroyed () {
+ unmounted () {
if (this.autohideFloatingPostButton) {
this.deactivateFloatingPostButtonAutohide()
}
@@ -45,7 +46,7 @@ const MobilePostStatusButton = {
return this.autohideFloatingPostButton && (this.hidden || this.inputActive)
},
isPersistent () {
- return !!this.$store.getters.mergedConfig.showNewPostButton
+ return !!this.$store.getters.mergedConfig.alwaysShowNewPostButton
},
autohideFloatingPostButton () {
return !!this.$store.getters.mergedConfig.autohideFloatingPostButton
diff --git a/src/components/mobile_post_status_button/mobile_post_status_button.vue b/src/components/mobile_post_status_button/mobile_post_status_button.vue
index 37becf4c..28a2c440 100644
--- a/src/components/mobile_post_status_button/mobile_post_status_button.vue
+++ b/src/components/mobile_post_status_button/mobile_post_status_button.vue
@@ -1,13 +1,13 @@
<template>
- <div v-if="isLoggedIn">
- <button
- class="button-default new-status-button"
- :class="{ 'hidden': isHidden, 'always-show': isPersistent }"
- @click="openPostForm"
- >
- <FAIcon icon="pen" />
- </button>
- </div>
+ <button
+ v-if="isLoggedIn"
+ class="MobilePostButton button-default new-status-button"
+ :class="{ 'hidden': isHidden, 'always-show': isPersistent }"
+ :title="$t('post_status.new_status')"
+ @click="openPostForm"
+ >
+ <FAIcon icon="pen" />
+ </button>
</template>
<script src="./mobile_post_status_button.js"></script>
@@ -15,25 +15,27 @@
<style lang="scss">
@import '../../_variables.scss';
-.new-status-button {
- width: 5em;
- height: 5em;
- border-radius: 100%;
- position: fixed;
- bottom: 1.5em;
- right: 1.5em;
- // TODO: this needs its own color, it has to stand out enough and link color
- // is not very optimal for this particular use.
- background-color: $fallback--fg;
- background-color: var(--btn, $fallback--fg);
- display: flex;
- justify-content: center;
- align-items: center;
- box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3), 0px 4px 6px rgba(0, 0, 0, 0.3);
- z-index: 10;
-
- transition: 0.35s transform;
- transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
+.MobilePostButton {
+ &.button-default {
+ width: 5em;
+ height: 5em;
+ border-radius: 100%;
+ position: fixed;
+ bottom: 1.5em;
+ right: 1.5em;
+ // TODO: this needs its own color, it has to stand out enough and link color
+ // is not very optimal for this particular use.
+ background-color: $fallback--fg;
+ background-color: var(--btn, $fallback--fg);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3), 0px 4px 6px rgba(0, 0, 0, 0.3);
+ z-index: 10;
+
+ transition: 0.35s transform;
+ transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
+ }
&.hidden {
transform: translateY(150%);
diff --git a/src/components/modal/modal.vue b/src/components/modal/modal.vue
index 2b58913f..2187f392 100644
--- a/src/components/modal/modal.vue
+++ b/src/components/modal/modal.vue
@@ -12,6 +12,9 @@
<script>
export default {
+ provide: {
+ popoversZLayer: 'modals'
+ },
props: {
isOpen: {
type: Boolean,
@@ -26,7 +29,7 @@ export default {
classes () {
return {
'modal-background': !this.noBackground,
- 'open': this.isOpen
+ open: this.isOpen
}
}
}
@@ -35,7 +38,7 @@ export default {
<style lang="scss">
.modal-view {
- z-index: 1000;
+ z-index: var(--ZI_modals);
position: fixed;
top: 0;
left: 0;
diff --git a/src/components/moderation_tools/moderation_tools.js b/src/components/moderation_tools/moderation_tools.js
index 2469327a..a5ce8656 100644
--- a/src/components/moderation_tools/moderation_tools.js
+++ b/src/components/moderation_tools/moderation_tools.js
@@ -41,14 +41,26 @@ const ModerationTools = {
tagsSet () {
return new Set(this.user.tags)
},
- hasTagPolicy () {
- return this.$store.state.instance.tagPolicyAvailable
+ canGrantRole () {
+ return this.user.is_local && !this.user.deactivated && this.$store.state.users.currentUser.role === 'admin'
+ },
+ canChangeActivationState () {
+ return this.privileged('users_manage_activation_state')
+ },
+ canDeleteAccount () {
+ return this.privileged('users_delete')
+ },
+ canUseTagPolicy () {
+ return this.$store.state.instance.tagPolicyAvailable && this.privileged('users_manage_tags')
}
},
methods: {
hasTag (tagName) {
return this.tagsSet.has(tagName)
},
+ privileged (privilege) {
+ return this.$store.state.users.currentUser.privileges.includes(privilege)
+ },
toggleTag (tag) {
const store = this.$store
if (this.tagsSet.has(tag)) {
diff --git a/src/components/moderation_tools/moderation_tools.vue b/src/components/moderation_tools/moderation_tools.vue
index 96476abe..8535ef27 100644
--- a/src/components/moderation_tools/moderation_tools.vue
+++ b/src/components/moderation_tools/moderation_tools.vue
@@ -8,9 +8,9 @@
@show="setToggled(true)"
@close="setToggled(false)"
>
- <template v-slot:content>
+ <template #content>
<div class="dropdown-menu">
- <span v-if="user.is_local">
+ <span v-if="canGrantRole">
<button
class="button-default dropdown-item"
@click="toggleRight(&quot;admin&quot;)"
@@ -24,28 +24,31 @@
{{ $t(!!user.rights.moderator ? 'user_card.admin_menu.revoke_moderator' : 'user_card.admin_menu.grant_moderator') }}
</button>
<div
+ v-if="canChangeActivationState || canDeleteAccount"
role="separator"
class="dropdown-divider"
/>
</span>
<button
+ v-if="canChangeActivationState"
class="button-default dropdown-item"
@click="toggleActivationStatus()"
>
{{ $t(!!user.deactivated ? 'user_card.admin_menu.activate_account' : 'user_card.admin_menu.deactivate_account') }}
</button>
<button
+ v-if="canDeleteAccount"
class="button-default dropdown-item"
@click="deleteUserDialog(true)"
>
{{ $t('user_card.admin_menu.delete_account') }}
</button>
<div
- v-if="hasTagPolicy"
+ v-if="canUseTagPolicy"
role="separator"
class="dropdown-divider"
/>
- <span v-if="hasTagPolicy">
+ <span v-if="canUseTagPolicy">
<button
class="button-default dropdown-item"
@click="toggleTag(tags.FORCE_NSFW)"
@@ -122,7 +125,7 @@
</span>
</div>
</template>
- <template v-slot:trigger>
+ <template #trigger>
<button
class="btn button-default btn-block moderation-tools-button"
:class="{ toggled }"
@@ -132,16 +135,16 @@
</button>
</template>
</Popover>
- <portal to="modal">
+ <teleport to="#modal">
<DialogModal
v-if="showDeleteUserDialog"
:on-cancel="deleteUserDialog.bind(this, false)"
>
- <template v-slot:header>
+ <template #header>
{{ $t('user_card.admin_menu.delete_user') }}
</template>
<p>{{ $t('user_card.admin_menu.delete_user_confirmation') }}</p>
- <template v-slot:footer>
+ <template #footer>
<button
class="btn button-default"
@click="deleteUserDialog(false)"
@@ -156,7 +159,7 @@
</button>
</template>
</DialogModal>
- </portal>
+ </teleport>
</div>
</template>
diff --git a/src/components/mrf_transparency_panel/mrf_transparency_panel.js b/src/components/mrf_transparency_panel/mrf_transparency_panel.js
index 3fde8106..13cfb52e 100644
--- a/src/components/mrf_transparency_panel/mrf_transparency_panel.js
+++ b/src/components/mrf_transparency_panel/mrf_transparency_panel.js
@@ -9,10 +9,10 @@ import { get } from 'lodash'
*/
const toInstanceReasonObject = (instances, info, key) => {
return instances.map(instance => {
- if (info[key] && info[key][instance] && info[key][instance]['reason']) {
- return { instance: instance, reason: info[key][instance]['reason'] }
+ if (info[key] && info[key][instance] && info[key][instance].reason) {
+ return { instance, reason: info[key][instance].reason }
}
- return { instance: instance, reason: '' }
+ return { instance, reason: '' }
})
}
diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js
index 37bcb409..8c9c3b11 100644
--- a/src/components/nav_panel/nav_panel.js
+++ b/src/components/nav_panel/nav_panel.js
@@ -1,5 +1,10 @@
-import TimelineMenuContent from '../timeline_menu/timeline_menu_content.vue'
+import ListsMenuContent from 'src/components/lists_menu/lists_menu_content.vue'
import { mapState, mapGetters } from 'vuex'
+import { TIMELINES, ROOT_ITEMS } from 'src/components/navigation/navigation.js'
+import { filterNavigation } from 'src/components/navigation/filter.js'
+import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
+import NavigationPins from 'src/components/navigation/navigation_pins.vue'
+import Checkbox from 'src/components/checkbox/checkbox.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
@@ -12,7 +17,9 @@ import {
faComments,
faBell,
faInfoCircle,
- faStream
+ faStream,
+ faList,
+ faBullhorn
} from '@fortawesome/free-solid-svg-icons'
library.add(
@@ -25,26 +32,53 @@ library.add(
faComments,
faBell,
faInfoCircle,
- faStream
+ faStream,
+ faList,
+ faBullhorn
)
-
const NavPanel = {
+ props: ['forceExpand', 'forceEditMode'],
created () {
- if (this.currentUser && this.currentUser.locked) {
- this.$store.dispatch('startFetchingFollowRequests')
- }
},
components: {
- TimelineMenuContent
+ ListsMenuContent,
+ NavigationEntry,
+ NavigationPins,
+ Checkbox
},
data () {
return {
- showTimelines: false
+ editMode: false,
+ showTimelines: false,
+ showLists: false,
+ timelinesList: Object.entries(TIMELINES).map(([k, v]) => ({ ...v, name: k })),
+ rootList: Object.entries(ROOT_ITEMS).map(([k, v]) => ({ ...v, name: k }))
}
},
methods: {
toggleTimelines () {
this.showTimelines = !this.showTimelines
+ },
+ toggleLists () {
+ this.showLists = !this.showLists
+ },
+ toggleEditMode () {
+ this.editMode = !this.editMode
+ },
+ toggleCollapse () {
+ this.$store.commit('setPreference', { path: 'simple.collapseNav', value: !this.collapsed })
+ this.$store.dispatch('pushServerSideStorage')
+ },
+ isPinned (item) {
+ return this.pinnedItems.has(item)
+ },
+ togglePin (item) {
+ if (this.isPinned(item)) {
+ this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedNavItems', value: item })
+ } else {
+ this.$store.commit('addCollectionPreference', { path: 'collections.pinnedNavItems', value: item })
+ }
+ this.$store.dispatch('pushServerSideStorage')
}
},
computed: {
@@ -53,9 +87,40 @@ const NavPanel = {
followRequestCount: state => state.api.followRequests.length,
privateMode: state => state.instance.private,
federating: state => state.instance.federating,
- pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
+ pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable,
+ supportsAnnouncements: state => state.announcements.supportsAnnouncements,
+ pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems),
+ collapsed: state => state.serverSideStorage.prefsStorage.simple.collapseNav
}),
- ...mapGetters(['unreadChatCount'])
+ timelinesItems () {
+ return filterNavigation(
+ Object
+ .entries({ ...TIMELINES })
+ .map(([k, v]) => ({ ...v, name: k })),
+ {
+ hasChats: this.pleromaChatMessagesAvailable,
+ hasAnnouncements: this.supportsAnnouncements,
+ isFederating: this.federating,
+ isPrivate: this.privateMode,
+ currentUser: this.currentUser
+ }
+ )
+ },
+ rootItems () {
+ return filterNavigation(
+ Object
+ .entries({ ...ROOT_ITEMS })
+ .map(([k, v]) => ({ ...v, name: k })),
+ {
+ hasChats: this.pleromaChatMessagesAvailable,
+ hasAnnouncements: this.supportsAnnouncements,
+ isFederating: this.federating,
+ isPrivate: this.privateMode,
+ currentUser: this.currentUser
+ }
+ )
+ },
+ ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount'])
}
}
diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue
index 7ae7b1d6..d628c380 100644
--- a/src/components/nav_panel/nav_panel.vue
+++ b/src/components/nav_panel/nav_panel.vue
@@ -1,96 +1,105 @@
<template>
<div class="NavPanel">
<div class="panel panel-default">
- <ul>
- <li v-if="currentUser || !privateMode">
- <button
- class="button-unstyled menu-item"
- @click="toggleTimelines"
- >
- <FAIcon
- fixed-width
- class="fa-scale-110"
- icon="stream"
- />{{ $t("nav.timelines") }}
- <FAIcon
- class="timelines-chevron"
- fixed-width
- :icon="showTimelines ? 'chevron-up' : 'chevron-down'"
+ <div
+ v-if="!forceExpand"
+ class="panel-heading nav-panel-heading"
+ >
+ <NavigationPins :limit="6" />
+ <div class="spacer" />
+ <button
+ class="button-unstyled"
+ @click="toggleCollapse"
+ >
+ <FAIcon
+ class="navigation-chevron"
+ fixed-width
+ :icon="collapsed ? 'chevron-down' : 'chevron-up'"
+ />
+ </button>
+ </div>
+ <ul
+ v-if="!collapsed || forceExpand"
+ class="panel-body"
+ >
+ <NavigationEntry
+ v-if="currentUser || !privateMode"
+ :show-pin="false"
+ :item="{ icon: 'stream', label: 'nav.timelines' }"
+ :aria-expanded="showTimelines ? 'true' : 'false'"
+ @click="toggleTimelines"
+ >
+ <FAIcon
+ class="timelines-chevron"
+ fixed-width
+ :icon="showTimelines ? 'chevron-up' : 'chevron-down'"
+ />
+ </NavigationEntry>
+ <div
+ v-show="showTimelines"
+ class="timelines-background"
+ >
+ <div class="timelines">
+ <NavigationEntry
+ v-for="item in timelinesItems"
+ :key="item.name"
+ :show-pin="editMode || forceEditMode"
+ :item="item"
/>
- </button>
- <div
- v-show="showTimelines"
- class="timelines-background"
- >
- <TimelineMenuContent class="timelines" />
</div>
- </li>
- <li v-if="currentUser">
- <router-link
- class="menu-item"
- :to="{ name: 'interactions', params: { username: currentUser.screen_name } }"
- >
- <FAIcon
- fixed-width
- class="fa-scale-110"
- icon="bell"
- />{{ $t("nav.interactions") }}
- </router-link>
- </li>
- <li v-if="currentUser && pleromaChatMessagesAvailable">
- <router-link
- class="menu-item"
- :to="{ name: 'chats', params: { username: currentUser.screen_name } }"
- >
- <div
- v-if="unreadChatCount"
- class="badge badge-notification"
- >
- {{ unreadChatCount }}
- </div>
- <FAIcon
- fixed-width
- class="fa-scale-110"
- icon="comments"
- />{{ $t("nav.chats") }}
- </router-link>
- </li>
- <li v-if="currentUser && currentUser.locked">
- <router-link
- class="menu-item"
- :to="{ name: 'friend-requests' }"
- >
- <FAIcon
- fixed-width
- class="fa-scale-110"
- icon="user-plus"
- />{{ $t("nav.friend_requests") }}
- <span
- v-if="followRequestCount > 0"
- class="badge badge-notification"
- >
- {{ followRequestCount }}
- </span>
- </router-link>
- </li>
- <li>
+ </div>
+ <NavigationEntry
+ v-if="currentUser"
+ :show-pin="false"
+ :item="{ icon: 'list', label: 'nav.lists' }"
+ :aria-expanded="showLists ? 'true' : 'false'"
+ @click="toggleLists"
+ >
<router-link
- class="menu-item"
- :to="{ name: 'about' }"
+ :title="$t('lists.manage_lists')"
+ class="extra-button"
+ :to="{ name: 'lists' }"
+ @click.stop
>
<FAIcon
+ class="extra-button"
fixed-width
- class="fa-scale-110"
- icon="info-circle"
- />{{ $t("nav.about") }}
+ icon="wrench"
+ />
</router-link>
- </li>
+ <FAIcon
+ class="timelines-chevron"
+ fixed-width
+ :icon="showLists ? 'chevron-up' : 'chevron-down'"
+ />
+ </NavigationEntry>
+ <div
+ v-show="showLists"
+ class="timelines-background"
+ >
+ <ListsMenuContent
+ :show-pin="editMode || forceEditMode"
+ class="timelines"
+ />
+ </div>
+ <NavigationEntry
+ v-for="item in rootItems"
+ :key="item.name"
+ :show-pin="editMode || forceEditMode"
+ :item="item"
+ />
+ <NavigationEntry
+ v-if="!forceEditMode && currentUser"
+ :show-pin="false"
+ :item="{ label: editMode ? $t('nav.edit_finish') : $t('nav.edit_pinned'), icon: editMode ? 'check' : 'wrench' }"
+ @click="toggleEditMode"
+ />
</ul>
</div>
</div>
</template>
-<script src="./nav_panel.js" ></script>
+<script src="./nav_panel.js"></script>
<style lang="scss">
@import '../../_variables.scss';
@@ -112,8 +121,9 @@
border-bottom: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
- padding: 0;
+ }
+ > li {
&:first-child .menu-item {
border-top-right-radius: $fallback--panelRadius;
border-top-right-radius: var(--panelRadius, $fallback--panelRadius);
@@ -133,42 +143,10 @@
border: none;
}
- .menu-item {
- display: block;
- box-sizing: border-box;
- height: 3.5em;
- line-height: 3.5em;
- padding: 0 1em;
- width: 100%;
- color: $fallback--link;
- color: var(--link, $fallback--link);
-
- &:hover {
- background-color: $fallback--lightBg;
- background-color: var(--selectedMenu, $fallback--lightBg);
- color: $fallback--link;
- color: var(--selectedMenuText, $fallback--link);
- --faint: var(--selectedMenuFaintText, $fallback--faint);
- --faintLink: var(--selectedMenuFaintLink, $fallback--faint);
- --lightText: var(--selectedMenuLightText, $fallback--lightText);
- --icon: var(--selectedMenuIcon, $fallback--icon);
- }
-
- &.router-link-active {
- font-weight: bolder;
- background-color: $fallback--lightBg;
- background-color: var(--selectedMenu, $fallback--lightBg);
- color: $fallback--text;
- color: var(--selectedMenuText, $fallback--text);
- --faint: var(--selectedMenuFaintText, $fallback--faint);
- --faintLink: var(--selectedMenuFaintLink, $fallback--faint);
- --lightText: var(--selectedMenuLightText, $fallback--lightText);
- --icon: var(--selectedMenuIcon, $fallback--icon);
-
- &:hover {
- text-decoration: underline;
- }
- }
+ .navigation-chevron {
+ margin-left: 0.8em;
+ margin-right: 0.8em;
+ font-size: 1.1em;
}
.timelines-chevron {
@@ -180,7 +158,7 @@
padding: 0 0 0 0.6em;
background-color: $fallback--lightBg;
background-color: var(--selectedMenu, $fallback--lightBg);
- border-top: 1px solid;
+ border-bottom: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
}
@@ -190,14 +168,9 @@
background-color: var(--bg, $fallback--bg);
}
- .fa-scale-110 {
- margin-right: 0.8em;
- }
-
- .badge {
- position: absolute;
- right: 0.6rem;
- top: 1.25em;
+ .nav-panel-heading {
+ // breaks without a unit
+ --panel-heading-height-padding: 0em;
}
}
</style>
diff --git a/src/components/navigation/filter.js b/src/components/navigation/filter.js
new file mode 100644
index 00000000..e8e77f8f
--- /dev/null
+++ b/src/components/navigation/filter.js
@@ -0,0 +1,19 @@
+export const filterNavigation = (list = [], { hasChats, hasAnnouncements, isFederating, isPrivate, currentUser }) => {
+ return list.filter(({ criteria, anon, anonRoute }) => {
+ const set = new Set(criteria || [])
+ if (!isFederating && set.has('federating')) return false
+ if (!currentUser && isPrivate && set.has('!private')) return false
+ if (!currentUser && !(anon || anonRoute)) return false
+ if ((!currentUser || !currentUser.locked) && set.has('lockedUser')) return false
+ if (!hasChats && set.has('chats')) return false
+ if (!hasAnnouncements && set.has('announcements')) return false
+ return true
+ })
+}
+
+export const getListEntries = state => state.lists.allLists.map(list => ({
+ name: 'list-' + list.id,
+ routeObject: { name: 'lists-timeline', params: { id: list.id } },
+ labelRaw: list.title,
+ iconLetter: list.title[0]
+}))
diff --git a/src/components/navigation/navigation.js b/src/components/navigation/navigation.js
new file mode 100644
index 00000000..7f096316
--- /dev/null
+++ b/src/components/navigation/navigation.js
@@ -0,0 +1,82 @@
+export const USERNAME_ROUTES = new Set([
+ 'bookmarks',
+ 'dms',
+ 'interactions',
+ 'notifications',
+ 'chat',
+ 'chats',
+ 'user-profile'
+])
+
+export const TIMELINES = {
+ home: {
+ route: 'friends',
+ icon: 'home',
+ label: 'nav.home_timeline',
+ criteria: ['!private']
+ },
+ public: {
+ route: 'public-timeline',
+ anon: true,
+ icon: 'users',
+ label: 'nav.public_tl',
+ criteria: ['!private']
+ },
+ twkn: {
+ route: 'public-external-timeline',
+ anon: true,
+ icon: 'globe',
+ label: 'nav.twkn',
+ criteria: ['!private', 'federating']
+ },
+ bookmarks: {
+ route: 'bookmarks',
+ icon: 'bookmark',
+ label: 'nav.bookmarks'
+ },
+ favorites: {
+ routeObject: { name: 'user-profile', query: { tab: 'favorites' } },
+ icon: 'star',
+ label: 'user_card.favorites'
+ },
+ dms: {
+ route: 'dms',
+ icon: 'envelope',
+ label: 'nav.dms'
+ }
+}
+
+export const ROOT_ITEMS = {
+ interactions: {
+ route: 'interactions',
+ icon: 'bell',
+ label: 'nav.interactions'
+ },
+ chats: {
+ route: 'chats',
+ icon: 'comments',
+ label: 'nav.chats',
+ badgeGetter: 'unreadChatCount',
+ criteria: ['chats']
+ },
+ friendRequests: {
+ route: 'friend-requests',
+ icon: 'user-plus',
+ label: 'nav.friend_requests',
+ criteria: ['lockedUser'],
+ badgeGetter: 'followRequestCount'
+ },
+ about: {
+ route: 'about',
+ anon: true,
+ icon: 'info-circle',
+ label: 'nav.about'
+ },
+ announcements: {
+ route: 'announcements',
+ icon: 'bullhorn',
+ label: 'nav.announcements',
+ badgeGetter: 'unreadAnnouncementCount',
+ criteria: ['announcements']
+ }
+}
diff --git a/src/components/navigation/navigation_entry.js b/src/components/navigation/navigation_entry.js
new file mode 100644
index 00000000..81cc936a
--- /dev/null
+++ b/src/components/navigation/navigation_entry.js
@@ -0,0 +1,51 @@
+import { mapState } from 'vuex'
+import { USERNAME_ROUTES } from 'src/components/navigation/navigation.js'
+import OptionalRouterLink from 'src/components/optional_router_link/optional_router_link.vue'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import { faThumbtack } from '@fortawesome/free-solid-svg-icons'
+
+library.add(faThumbtack)
+
+const NavigationEntry = {
+ props: ['item', 'showPin'],
+ components: {
+ OptionalRouterLink
+ },
+ methods: {
+ isPinned (value) {
+ return this.pinnedItems.has(value)
+ },
+ togglePin (value) {
+ if (this.isPinned(value)) {
+ this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedNavItems', value })
+ } else {
+ this.$store.commit('addCollectionPreference', { path: 'collections.pinnedNavItems', value })
+ }
+ this.$store.dispatch('pushServerSideStorage')
+ }
+ },
+ computed: {
+ routeTo () {
+ if (!this.item.route && !this.item.routeObject) return null
+ let route
+ if (this.item.routeObject) {
+ route = this.item.routeObject
+ } else {
+ route = { name: (this.item.anon || this.currentUser) ? this.item.route : this.item.anonRoute }
+ }
+ if (USERNAME_ROUTES.has(route.name)) {
+ route.params = { username: this.currentUser.screen_name, name: this.currentUser.screen_name }
+ }
+ return route
+ },
+ getters () {
+ return this.$store.getters
+ },
+ ...mapState({
+ currentUser: state => state.users.currentUser,
+ pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems)
+ })
+ }
+}
+
+export default NavigationEntry
diff --git a/src/components/navigation/navigation_entry.vue b/src/components/navigation/navigation_entry.vue
new file mode 100644
index 00000000..f4d53836
--- /dev/null
+++ b/src/components/navigation/navigation_entry.vue
@@ -0,0 +1,133 @@
+<template>
+ <OptionalRouterLink
+ v-slot="{ isActive, href, navigate } = {}"
+ ass="ass"
+ :to="routeTo"
+ >
+ <li
+ class="NavigationEntry menu-item"
+ :class="{ '-active': isActive }"
+ v-bind="$attrs"
+ >
+ <component
+ :is="routeTo ? 'a' : 'button'"
+ class="main-link button-unstyled"
+ :href="href"
+ @click="navigate"
+ >
+ <span>
+ <FAIcon
+ v-if="item.icon"
+ fixed-width
+ class="fa-scale-110 menu-icon"
+ :icon="item.icon"
+ />
+ </span>
+ <span
+ v-if="item.iconLetter"
+ class="icon iconLetter fa-scale-110 menu-icon"
+ >{{ item.iconLetter }}
+ </span>
+ <span class="label">
+ {{ item.labelRaw || $t(item.label) }}
+ </span>
+ </component>
+ <slot />
+ <div
+ v-if="item.badgeGetter && getters[item.badgeGetter]"
+ class="badge badge-notification"
+ >
+ {{ getters[item.badgeGetter] }}
+ </div>
+ <button
+ v-if="showPin && currentUser"
+ type="button"
+ class="button-unstyled extra-button"
+ :title="$t(isPinned ? 'general.unpin' : 'general.pin' )"
+ :aria-pressed="!!isPinned"
+ @click.stop.prevent="togglePin(item.name)"
+ >
+ <FAIcon
+ v-if="showPin && currentUser"
+ fixed-width
+ class="fa-scale-110"
+ :class="{ 'veryfaint': !isPinned(item.name) }"
+ :transform="!isPinned(item.name) ? 'rotate-45' : ''"
+ icon="thumbtack"
+ />
+ </button>
+ </li>
+ </OptionalRouterLink>
+</template>
+
+<script src="./navigation_entry.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.NavigationEntry {
+ display: flex;
+ box-sizing: border-box;
+ align-items: baseline;
+ height: 3.5em;
+ line-height: 3.5em;
+ padding: 0 1em;
+ width: 100%;
+ color: $fallback--link;
+ color: var(--link, $fallback--link);
+
+ .timelines-chevron {
+ margin-right: 0;
+ }
+
+ .main-link {
+ flex: 1;
+ }
+
+ .menu-icon {
+ margin-right: 0.8em;
+ }
+
+ .extra-button {
+ width: 3em;
+ text-align: center;
+
+ &:last-child {
+ margin-right: -0.8em;
+ }
+ }
+
+ &:hover {
+ background-color: $fallback--lightBg;
+ background-color: var(--selectedMenu, $fallback--lightBg);
+ color: $fallback--link;
+ color: var(--selectedMenuText, $fallback--link);
+ --faint: var(--selectedMenuFaintText, $fallback--faint);
+ --faintLink: var(--selectedMenuFaintLink, $fallback--faint);
+ --lightText: var(--selectedMenuLightText, $fallback--lightText);
+
+ .menu-icon {
+ --icon: var(--text, $fallback--icon);
+ }
+ }
+
+ &.-active {
+ font-weight: bolder;
+ background-color: $fallback--lightBg;
+ background-color: var(--selectedMenu, $fallback--lightBg);
+ color: $fallback--text;
+ color: var(--selectedMenuText, $fallback--text);
+ --faint: var(--selectedMenuFaintText, $fallback--faint);
+ --faintLink: var(--selectedMenuFaintLink, $fallback--faint);
+ --lightText: var(--selectedMenuLightText, $fallback--lightText);
+
+ .menu-icon {
+ --icon: var(--text, $fallback--icon);
+ }
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+}
+</style>
diff --git a/src/components/navigation/navigation_pins.js b/src/components/navigation/navigation_pins.js
new file mode 100644
index 00000000..9dd795aa
--- /dev/null
+++ b/src/components/navigation/navigation_pins.js
@@ -0,0 +1,94 @@
+import { mapState } from 'vuex'
+import { TIMELINES, ROOT_ITEMS, USERNAME_ROUTES } from 'src/components/navigation/navigation.js'
+import { getListEntries, filterNavigation } from 'src/components/navigation/filter.js'
+
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faUsers,
+ faGlobe,
+ faBookmark,
+ faEnvelope,
+ faComments,
+ faBell,
+ faInfoCircle,
+ faStream,
+ faList
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faUsers,
+ faGlobe,
+ faBookmark,
+ faEnvelope,
+ faComments,
+ faBell,
+ faInfoCircle,
+ faStream,
+ faList
+)
+
+const NavPanel = {
+ props: ['limit'],
+ methods: {
+ getRouteTo (item) {
+ if (item.routeObject) {
+ return item.routeObject
+ }
+ const route = { name: (item.anon || this.currentUser) ? item.route : item.anonRoute }
+ if (USERNAME_ROUTES.has(route.name)) {
+ route.params = { username: this.currentUser.screen_name }
+ }
+ return route
+ }
+ },
+ computed: {
+ getters () {
+ return this.$store.getters
+ },
+ ...mapState({
+ lists: getListEntries,
+ currentUser: state => state.users.currentUser,
+ followRequestCount: state => state.api.followRequests.length,
+ privateMode: state => state.instance.private,
+ federating: state => state.instance.federating,
+ pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable,
+ pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems)
+ }),
+ pinnedList () {
+ if (!this.currentUser) {
+ return filterNavigation([
+ { ...TIMELINES.public, name: 'public' },
+ { ...TIMELINES.twkn, name: 'twkn' },
+ { ...ROOT_ITEMS.about, name: 'about' }
+ ],
+ {
+ hasChats: this.pleromaChatMessagesAvailable,
+ isFederating: this.federating,
+ isPrivate: this.privateMode,
+ currentUser: this.currentUser
+ })
+ }
+ return filterNavigation(
+ [
+ ...Object
+ .entries({ ...TIMELINES })
+ .filter(([k]) => this.pinnedItems.has(k))
+ .map(([k, v]) => ({ ...v, name: k })),
+ ...this.lists.filter((k) => this.pinnedItems.has(k.name)),
+ ...Object
+ .entries({ ...ROOT_ITEMS })
+ .filter(([k]) => this.pinnedItems.has(k))
+ .map(([k, v]) => ({ ...v, name: k }))
+ ],
+ {
+ hasChats: this.pleromaChatMessagesAvailable,
+ isFederating: this.federating,
+ isPrivate: this.privateMode,
+ currentUser: this.currentUser
+ }
+ ).slice(0, this.limit)
+ }
+ }
+}
+
+export default NavPanel
diff --git a/src/components/navigation/navigation_pins.vue b/src/components/navigation/navigation_pins.vue
new file mode 100644
index 00000000..6a9ed6f5
--- /dev/null
+++ b/src/components/navigation/navigation_pins.vue
@@ -0,0 +1,74 @@
+<template>
+ <span class="NavigationPins">
+ <router-link
+ v-for="item in pinnedList"
+ :key="item.name"
+ class="pinned-item"
+ :to="getRouteTo(item)"
+ :title="item.labelRaw || $t(item.label)"
+ >
+ <FAIcon
+ v-if="item.icon"
+ fixed-width
+ :icon="item.icon"
+ />
+ <span
+ v-if="item.iconLetter"
+ class="iconLetter fa-scale-110 fa-old-padding"
+ >{{ item.iconLetter }}</span>
+ <div
+ v-if="item.badgeGetter && getters[item.badgeGetter]"
+ class="alert-dot"
+ />
+ </router-link>
+ </span>
+</template>
+
+<script src="./navigation_pins.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+.NavigationPins {
+ display: flex;
+ flex-wrap: wrap;
+ overflow: hidden;
+ height: 100%;
+
+ .alert-dot {
+ border-radius: 100%;
+ height: 0.5em;
+ width: 0.5em;
+ position: absolute;
+ right: calc(50% - 0.75em);
+ top: calc(50% - 0.5em);
+ background-color: $fallback--cRed;
+ background-color: var(--badgeNotification, $fallback--cRed);
+ }
+
+ .pinned-item {
+ position: relative;
+ flex: 1 0 3em;
+ min-width: 2em;
+ text-align: center;
+ overflow: visible;
+ box-sizing: border-box;
+ height: 100%;
+
+ & .svg-inline--fa,
+ & .iconLetter {
+ margin: 0;
+ }
+
+ &.router-link-active {
+ color: $fallback--text;
+ color: var(--panelText, $fallback--text);
+ border-bottom: 4px solid;
+
+ & .svg-inline--fa,
+ & .iconLetter {
+ color: inherit;
+ }
+ }
+ }
+}
+</style>
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index 398bb7a9..265aaee0 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -4,7 +4,10 @@ import Status from '../status/status.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue'
import Timeago from '../timeago/timeago.vue'
+import Report from '../report/report.vue'
+import UserLink from '../user_link/user_link.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
+import UserPopover from '../user_popover/user_popover.vue'
import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@@ -17,7 +20,9 @@ import {
faUserPlus,
faEyeSlash,
faUser,
- faSuitcaseRolling
+ faSuitcaseRolling,
+ faExpandAlt,
+ faCompressAlt
} from '@fortawesome/free-solid-svg-icons'
library.add(
@@ -28,29 +33,34 @@ library.add(
faUserPlus,
faUser,
faEyeSlash,
- faSuitcaseRolling
+ faSuitcaseRolling,
+ faExpandAlt,
+ faCompressAlt
)
const Notification = {
data () {
return {
- userExpanded: false,
+ statusExpanded: false,
betterShadow: this.$store.state.interface.browserSupport.cssFilter,
unmuted: false
}
},
- props: [ 'notification' ],
+ props: ['notification'],
components: {
StatusContent,
UserAvatar,
UserCard,
Timeago,
Status,
- RichContent
+ Report,
+ RichContent,
+ UserPopover,
+ UserLink
},
methods: {
- toggleUserExpanded () {
- this.userExpanded = !this.userExpanded
+ toggleStatusExpanded () {
+ this.statusExpanded = !this.statusExpanded
},
generateUserProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
diff --git a/src/components/notification/notification.scss b/src/components/notification/notification.scss
index ec291547..38978137 100644
--- a/src/components/notification/notification.scss
+++ b/src/components/notification/notification.scss
@@ -2,7 +2,18 @@
// TODO Copypaste from Status, should unify it somehow
.Notification {
- --emoji-size: 14px;
+ border-bottom: 1px solid;
+ border-color: $fallback--border;
+ border-color: var(--border, $fallback--border);
+ word-wrap: break-word;
+ word-break: break-word;
+ --emoji-size: 14px;
+
+ &:hover {
+ --_still-image-img-visibility: visible;
+ --_still-image-canvas-visibility: hidden;
+ --_still-image-label-visibility: hidden;
+ }
&.-muted {
padding: 0.25em 0.6em;
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index 634ec8ee..f1aa5420 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -1,18 +1,23 @@
<template>
- <Status
+ <article
v-if="notification.type === 'mention'"
- :compact="true"
- :statusoid="notification.status"
- />
- <div v-else>
+ >
+ <Status
+ class="Notification"
+ :compact="true"
+ :statusoid="notification.status"
+ />
+ </article>
+ <article v-else>
<div
v-if="needMute && !unmuted"
class="Notification container -muted"
>
<small>
- <router-link :to="userProfileLink">
- {{ notification.from_profile.screen_name_ui }}
- </router-link>
+ <user-link
+ :user="notification.from_profile"
+ :at="false"
+ />
</small>
<button
class="button-unstyled unmute"
@@ -32,22 +37,23 @@
>
<a
class="avatar-container"
- :href="notification.from_profile.statusnet_profile_url"
- @click.stop.prevent.capture="toggleUserExpanded"
+ :href="$router.resolve(userProfileLink).href"
+ @click.prevent
>
- <UserAvatar
- :compact="true"
- :better-shadow="betterShadow"
- :user="notification.from_profile"
- />
+ <UserPopover
+ :user-id="notification.from_profile.id"
+ :overlay-centers="true"
+ >
+ <UserAvatar
+ class="post-avatar"
+ :bot="botIndicator"
+ :compact="true"
+ :better-shadow="betterShadow"
+ :user="notification.from_profile"
+ />
+ </UserPopover>
</a>
<div class="notification-right">
- <UserCard
- v-if="userExpanded"
- :user-id="getUser(notification).id"
- :rounded="true"
- :bordered="true"
- />
<span class="notification-details">
<div class="name-and-action">
<!-- eslint-disable vue/no-v-html -->
@@ -64,12 +70,16 @@
v-else
class="username"
:title="'@'+notification.from_profile.screen_name_ui"
- >{{ notification.from_profile.name }}</span>
+ >
+ {{ notification.from_profile.name }}
+ </span>
+ {{ ' ' }}
<span v-if="notification.type === 'like'">
<FAIcon
class="type-icon"
icon="star"
/>
+ {{ ' ' }}
<small>{{ $t('notifications.favorited_you') }}</small>
</span>
<span v-if="notification.type === 'repeat'">
@@ -78,6 +88,7 @@
icon="retweet"
:title="$t('tool_tip.repeat')"
/>
+ {{ ' ' }}
<small>{{ $t('notifications.repeated_you') }}</small>
</span>
<span v-if="notification.type === 'follow'">
@@ -85,6 +96,7 @@
class="type-icon"
icon="user-plus"
/>
+ {{ ' ' }}
<small>{{ $t('notifications.followed_you') }}</small>
</span>
<span v-if="notification.type === 'follow_request'">
@@ -92,6 +104,7 @@
class="type-icon"
icon="user"
/>
+ {{ ' ' }}
<small>{{ $t('notifications.follow_request') }}</small>
</span>
<span v-if="notification.type === 'move'">
@@ -99,15 +112,30 @@
class="type-icon"
icon="suitcase-rolling"
/>
+ {{ ' ' }}
<small>{{ $t('notifications.migrated_to') }}</small>
</span>
<span v-if="notification.type === 'pleroma:emoji_reaction'">
<small>
- <i18n path="notifications.reacted_with">
+ <i18n-t
+ scope="global"
+ keypath="notifications.reacted_with"
+ >
<span class="emoji-reaction-emoji">{{ notification.emoji }}</span>
- </i18n>
+ </i18n-t>
</small>
</span>
+ <span v-if="notification.type === 'pleroma:report'">
+ <small>{{ $t('notifications.submitted_report') }}</small>
+ </span>
+ <span v-if="notification.type === 'poll'">
+ <FAIcon
+ class="type-icon"
+ icon="poll-h"
+ />
+ {{ ' ' }}
+ <small>{{ $t('notifications.poll_ended') }}</small>
+ </span>
</div>
<div
v-if="isStatusNotification"
@@ -116,13 +144,25 @@
<router-link
v-if="notification.status"
:to="{ name: 'conversation', params: { id: notification.status.id } }"
- class="faint-link"
+ class="timeago-link faint-link"
>
<Timeago
:time="notification.created_at"
:auto-update="240"
/>
</router-link>
+ <button
+ class="button-unstyled expand-icon"
+ @click.prevent="toggleStatusExpanded"
+ :title="$t('tool_tip.toggle_expand')"
+ :aria-expanded="statusExpanded"
+ >
+ <FAIcon
+ class="fa-scale-110"
+ fixed-width
+ :icon="statusExpanded ? 'compress-alt' : 'expand-alt'"
+ />
+ </button>
</div>
<div
v-else
@@ -138,6 +178,8 @@
<button
v-if="needMute"
class="button-unstyled"
+ :title="$t('tool_tip.toggle_mute')"
+ :aria-expanded="!unmuted"
@click.prevent="toggleMute"
>
<FAIcon
@@ -150,47 +192,58 @@
v-if="notification.type === 'follow' || notification.type === 'follow_request'"
class="follow-text"
>
- <router-link
- :to="userProfileLink"
+ <user-link
class="follow-name"
- >
- @{{ notification.from_profile.screen_name_ui }}
- </router-link>
+ :user="notification.from_profile"
+ />
<div
v-if="notification.type === 'follow_request'"
style="white-space: nowrap;"
>
- <FAIcon
- icon="check"
- class="fa-scale-110 fa-old-padding follow-request-accept"
+ <button
+ class="button-unstyled"
:title="$t('tool_tip.accept_follow_request')"
@click="approveUser()"
- />
- <FAIcon
- icon="times"
- class="fa-scale-110 fa-old-padding follow-request-reject"
+ >
+ <FAIcon
+ icon="check"
+ class="fa-scale-110 fa-old-padding follow-request-accept"
+ />
+ </button>
+ <button
+ class="button-unstyled"
:title="$t('tool_tip.reject_follow_request')"
@click="denyUser()"
- />
+ >
+ <FAIcon
+ icon="times"
+ class="fa-scale-110 fa-old-padding follow-request-reject"
+ />
+ </button>
</div>
</div>
<div
v-else-if="notification.type === 'move'"
class="move-text"
>
- <router-link :to="targetUserProfileLink">
- @{{ notification.target.screen_name_ui }}
- </router-link>
+ <user-link
+ :user="notification.target"
+ />
</div>
+ <Report
+ v-else-if="notification.type === 'pleroma:report'"
+ :report-id="notification.report.id"
+ />
<template v-else>
- <status-content
- class="faint"
+ <StatusContent
+ :class="{ faint: !statusExpanded }"
+ :compact="!statusExpanded"
:status="notification.action"
/>
</template>
</div>
</div>
- </div>
+ </article>
</template>
<script src="./notification.js"></script>
diff --git a/src/components/notifications/notification_filters.vue b/src/components/notifications/notification_filters.vue
index ba0e90a0..1315b51a 100644
--- a/src/components/notifications/notification_filters.vue
+++ b/src/components/notifications/notification_filters.vue
@@ -5,7 +5,7 @@
placement="bottom"
:bound-to="{ x: 'container' }"
>
- <template v-slot:content>
+ <template #content>
<div class="dropdown-menu">
<button
class="button-default dropdown-item"
@@ -61,10 +61,19 @@
:class="{ 'menu-checkbox-checked': filters.moves }"
/>{{ $t('settings.notification_visibility_moves') }}
</button>
+ <button
+ class="button-default dropdown-item"
+ @click="toggleNotificationFilter('polls')"
+ >
+ <span
+ class="menu-checkbox"
+ :class="{ 'menu-checkbox-checked': filters.polls }"
+ />{{ $t('settings.notification_visibility_polls') }}
+ </button>
</div>
</template>
- <template v-slot:trigger>
- <button class="button-unstyled">
+ <template #trigger>
+ <button class="filter-trigger-button button-unstyled">
<FAIcon icon="filter" />
</button>
</template>
@@ -100,23 +109,3 @@ export default {
}
}
</script>
-
-<style lang="scss">
-
-.NotificationFilters {
- align-self: stretch;
-
- > button {
- font-size: 1.2em;
- padding-left: 0.7em;
- padding-right: 0.2em;
- line-height: 100%;
- height: 100%;
- }
-
- .dropdown-item {
- margin: 0;
- }
-}
-
-</style>
diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js
index c8f1ebcb..d499d3d6 100644
--- a/src/components/notifications/notifications.js
+++ b/src/components/notifications/notifications.js
@@ -1,3 +1,4 @@
+import { computed } from 'vue'
import { mapGetters } from 'vuex'
import Notification from '../notification/notification.vue'
import NotificationFilters from './notification_filters.vue'
@@ -9,10 +10,12 @@ import {
} from '../../services/notification_utils/notification_utils.js'
import FaviconService from '../../services/favicon_service/favicon_service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
-import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
+import { faCircleNotch, faArrowUp, faMinus } from '@fortawesome/free-solid-svg-icons'
library.add(
- faCircleNotch
+ faCircleNotch,
+ faArrowUp,
+ faMinus
)
const DEFAULT_SEEN_TO_DISPLAY_COUNT = 30
@@ -23,16 +26,17 @@ const Notifications = {
NotificationFilters
},
props: {
- // Disables display of panel header
- noHeading: Boolean,
// Disables panel styles, unread mark, potentially other notification-related actions
// meant for "Interactions" timeline
minimalMode: Boolean,
// Custom filter mode, an array of strings, possible values 'mention', 'repeat', 'like', 'follow', used to override global filter for use in "Interactions" timeline
- filterMode: Array
+ filterMode: Array,
+ // Disable teleporting (i.e. for /users/user/notifications)
+ disableTeleport: Boolean
},
data () {
return {
+ showScrollTop: false,
bottomedOut: false,
// How many seen notifications to display in the list. The more there are,
// the heavier the page becomes. This count is increased when loading
@@ -40,6 +44,11 @@ const Notifications = {
seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT
}
},
+ provide () {
+ return {
+ popoversZLayer: computed(() => this.popoversZLayer)
+ }
+ },
computed: {
mainClass () {
return this.minimalMode ? '' : 'panel panel-default'
@@ -60,15 +69,46 @@ const Notifications = {
return this.unseenNotifications.length
},
unseenCountTitle () {
- return this.unseenCount + (this.unreadChatCount)
+ return this.unseenCount + (this.unreadChatCount) + this.unreadAnnouncementCount
},
loading () {
return this.$store.state.statuses.notifications.loading
},
+ noHeading () {
+ const { layoutType } = this.$store.state.interface
+ return this.minimalMode || layoutType === 'mobile'
+ },
+ teleportTarget () {
+ const { layoutType } = this.$store.state.interface
+ const map = {
+ wide: '#notifs-column',
+ mobile: '#mobile-notifications'
+ }
+ return map[layoutType] || '#notifs-sidebar'
+ },
+ popoversZLayer () {
+ const { layoutType } = this.$store.state.interface
+ return layoutType === 'mobile' ? 'navbar' : null
+ },
notificationsToDisplay () {
return this.filteredNotifications.slice(0, this.unseenCount + this.seenToDisplayCount)
},
- ...mapGetters(['unreadChatCount'])
+ noSticky () { return this.$store.getters.mergedConfig.disableStickyHeaders },
+ ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount'])
+ },
+ mounted () {
+ this.scrollerRef = this.$refs.root.closest('.column.-scrollable')
+ if (!this.scrollerRef) {
+ this.scrollerRef = this.$refs.root.closest('.mobile-notifications')
+ }
+ if (!this.scrollerRef) {
+ this.scrollerRef = this.$refs.root.closest('.column.main')
+ }
+ this.scrollerRef.addEventListener('scroll', this.updateScrollPosition)
+ },
+ unmounted () {
+ if (!this.scrollerRef) return
+ this.scrollerRef.removeEventListener('scroll', this.updateScrollPosition)
},
watch: {
unseenCountTitle (count) {
@@ -79,9 +119,29 @@ const Notifications = {
FaviconService.clearFaviconBadge()
this.$store.dispatch('setPageTitle', '')
}
+ },
+ teleportTarget () {
+ // handle scroller change
+ this.$nextTick(() => {
+ this.scrollerRef.removeEventListener('scroll', this.updateScrollPosition)
+ this.scrollerRef = this.$refs.root.closest('.column.-scrollable')
+ if (!this.scrollerRef) {
+ this.scrollerRef = this.$refs.root.closest('.mobile-notifications')
+ }
+ this.scrollerRef.addEventListener('scroll', this.updateScrollPosition)
+ this.updateScrollPosition()
+ })
}
},
methods: {
+ scrollToTop () {
+ const scrollable = this.scrollerRef
+ scrollable.scrollTo({ top: this.$refs.root.offsetTop })
+ // this.$refs.root.scrollIntoView({ behavior: 'smooth', block: 'start' })
+ },
+ updateScrollPosition () {
+ this.showScrollTop = this.$refs.root.offsetTop < this.scrollerRef.scrollTop
+ },
markAsSeen () {
this.$store.dispatch('markNotificationsAsSeen')
this.seenToDisplayCount = DEFAULT_SEEN_TO_DISPLAY_COUNT
diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss
index 77b3c438..9b241565 100644
--- a/src/components/notifications/notifications.scss
+++ b/src/components/notifications/notifications.scss
@@ -11,10 +11,6 @@
color: var(--text, $fallback--text);
}
- .notifications-footer {
- border: none;
- }
-
.notification {
position: relative;
@@ -37,11 +33,6 @@
.notification {
box-sizing: border-box;
- border-bottom: 1px solid;
- border-color: $fallback--border;
- border-color: var(--border, $fallback--border);
- word-wrap: break-word;
- word-break: break-word;
&:hover .animated.Avatar {
canvas {
@@ -52,6 +43,10 @@
}
}
+ &:last-child .Notification {
+ border-bottom: none;
+ }
+
.non-mention {
display: flex;
flex: 1;
@@ -64,13 +59,13 @@
height: 32px;
}
- --link: var(--faintLink);
- --text: var(--faint);
+ .faint {
+ --link: var(--faintLink);
+ --text: var(--faint);
+ }
}
.follow-request-accept {
- cursor: pointer;
-
&:hover {
color: $fallback--text;
color: var(--text, $fallback--text);
@@ -78,8 +73,6 @@
}
.follow-request-reject {
- cursor: pointer;
-
&:hover {
color: $fallback--cRed;
color: var(--cRed, $fallback--cRed);
@@ -119,16 +112,26 @@
min-width: 3em;
text-align: right;
}
+
+ .timeago-link {
+ margin-right: 0.2em;
+ }
+
+ .expand-icon {
+ .svg-inline--fa {
+ margin-left: 0.25em;
+ }
+ }
}
.emoji-reaction-emoji {
- font-size: 16px;
+ font-size: 1.3em;
}
.notification-details {
- min-width: 0px;
+ min-width: 0;
word-wrap: break-word;
- line-height:18px;
+ line-height: var(--post-line-height);
position: relative;
overflow: hidden;
width: 100%;
@@ -151,7 +154,7 @@
}
.timeago {
- margin-right: .2em;
+ margin-right: 0.2em;
}
.status-content {
@@ -164,7 +167,8 @@
margin: 0 0 0.3em;
padding: 0;
font-size: 1em;
- line-height:20px;
+ line-height: 1.5;
+
small {
font-weight: lighter;
}
diff --git a/src/components/notifications/notifications.vue b/src/components/notifications/notifications.vue
index 2ce5d56f..633efca6 100644
--- a/src/components/notifications/notifications.vue
+++ b/src/components/notifications/notifications.vue
@@ -1,69 +1,100 @@
<template>
- <div
- :class="{ minimal: minimalMode }"
- class="Notifications"
+ <teleport
+ :disabled="minimalMode || disableTeleport"
+ :to="teleportTarget"
>
- <div :class="mainClass">
- <div
- v-if="!noHeading"
- class="panel-heading"
- >
- <div class="title">
- {{ $t('notifications.notifications') }}
- <span
- v-if="unseenCount"
- class="badge badge-notification unseen-count"
- >{{ unseenCount }}</span>
- </div>
- <button
- v-if="unseenCount"
- class="button-default read-button"
- @click.prevent="markAsSeen"
- >
- {{ $t('notifications.read') }}
- </button>
- <NotificationFilters />
- </div>
- <div class="panel-body">
+ <component
+ :is="noHeading ? 'div' : 'aside'"
+ ref="root"
+ :class="{ minimal: minimalMode }"
+ class="Notifications"
+ >
+ <div :class="mainClass">
<div
- v-for="notification in notificationsToDisplay"
- :key="notification.id"
- class="notification"
- :class="{&quot;unseen&quot;: !minimalMode && !notification.seen}"
+ v-if="!noHeading"
+ class="notifications-heading panel-heading -sticky"
>
- <div class="notification-overlay" />
- <notification :notification="notification" />
+ <div class="title">
+ {{ $t('notifications.notifications') }}
+ <span
+ v-if="unseenCount"
+ class="badge badge-notification unseen-count"
+ >{{ unseenCount }}</span>
+ </div>
+ <div
+ v-if="showScrollTop"
+ class="rightside-button"
+ >
+ <button
+ class="button-unstyled scroll-to-top-button"
+ type="button"
+ :title="$t('general.scroll_to_top')"
+ @click="scrollToTop"
+ >
+ <FALayers class="fa-scale-110 fa-old-padding-layer">
+ <FAIcon icon="arrow-up" />
+ <FAIcon
+ icon="minus"
+ transform="up-7"
+ />
+ </FALayers>
+ </button>
+ </div>
+ <button
+ v-if="unseenCount"
+ class="button-default read-button"
+ type="button"
+ @click.prevent="markAsSeen"
+ >
+ {{ $t('notifications.read') }}
+ </button>
+ <NotificationFilters class="rightside-button" />
</div>
- </div>
- <div class="panel-footer notifications-footer">
<div
- v-if="bottomedOut"
- class="new-status-notification text-center faint"
+ class="panel-body"
+ role="feed"
>
- {{ $t('notifications.no_more_notifications') }}
+ <div
+ v-for="notification in notificationsToDisplay"
+ :key="notification.id"
+ role="listitem"
+ class="notification"
+ :class="{unseen: !minimalMode && !notification.seen}"
+ >
+ <div class="notification-overlay" />
+ <notification :notification="notification" />
+ </div>
</div>
- <button
- v-else-if="!loading"
- class="button-unstyled -link -fullwidth"
- @click.prevent="fetchOlderNotifications()"
- >
- <div class="new-status-notification text-center">
- {{ minimalMode ? $t('interactions.load_older') : $t('notifications.load_older') }}
+ <div class="panel-footer">
+ <div
+ v-if="bottomedOut"
+ class="new-status-notification text-center faint"
+ >
+ {{ $t('notifications.no_more_notifications') }}
+ </div>
+ <button
+ v-else-if="!loading"
+ class="button-unstyled -link -fullwidth"
+ @click.prevent="fetchOlderNotifications()"
+ >
+ <div class="new-status-notification text-center">
+ {{ minimalMode ? $t('interactions.load_older') : $t('notifications.load_older') }}
+ </div>
+ </button>
+ <div
+ v-else
+ class="new-status-notification text-center"
+ >
+ <FAIcon
+ icon="circle-notch"
+ spin
+ size="lg"
+ />
</div>
- </button>
- <div
- v-else
- class="new-status-notification text-center"
- >
- <FAIcon
- icon="circle-notch"
- spin
- size="lg"
- />
</div>
</div>
- </div>
- </div>
+ </component>
+ </teleport>
</template>
<script src="./notifications.js"></script>
diff --git a/src/components/opacity_input/opacity_input.vue b/src/components/opacity_input/opacity_input.vue
index 3cc3942b..15d08e04 100644
--- a/src/components/opacity_input/opacity_input.vue
+++ b/src/components/opacity_input/opacity_input.vue
@@ -11,21 +11,21 @@
</label>
<Checkbox
v-if="typeof fallback !== 'undefined'"
- :checked="present"
+ :model-value="present"
:disabled="disabled"
class="opt"
- @change="$emit('input', !present ? fallback : undefined)"
+ @update:modelValue="$emit('update:modelValue', !present ? fallback : undefined)"
/>
<input
:id="name"
class="input-number"
type="number"
- :value="value || fallback"
+ :value="modelValue || fallback"
:disabled="!present || disabled"
max="1"
min="0"
step=".05"
- @input="$emit('input', $event.target.value)"
+ @input="$emit('update:modelValue', $event.target.value)"
>
</div>
</template>
@@ -37,11 +37,12 @@ export default {
Checkbox
},
props: [
- 'name', 'value', 'fallback', 'disabled'
+ 'name', 'modelValue', 'fallback', 'disabled'
],
+ emits: ['update:modelValue'],
computed: {
present () {
- return typeof this.value !== 'undefined'
+ return typeof this.modelValue !== 'undefined'
}
}
}
diff --git a/src/components/optional_router_link/optional_router_link.vue b/src/components/optional_router_link/optional_router_link.vue
new file mode 100644
index 00000000..d56ad268
--- /dev/null
+++ b/src/components/optional_router_link/optional_router_link.vue
@@ -0,0 +1,23 @@
+<template>
+ <!-- eslint-disable vue/no-multiple-template-root -->
+ <router-link
+ v-if="to"
+ v-slot="props"
+ :to="to"
+ custom
+ >
+ <slot
+ v-bind="props"
+ />
+ </router-link>
+ <slot
+ v-else
+ v-bind="{}"
+ />
+</template>
+
+<script>
+export default {
+ props: ['to']
+}
+</script>
diff --git a/src/components/password_reset/password_reset.vue b/src/components/password_reset/password_reset.vue
index 3ffa5425..90673f44 100644
--- a/src/components/password_reset/password_reset.vue
+++ b/src/components/password_reset/password_reset.vue
@@ -91,14 +91,18 @@
flex-direction: column;
margin-top: 0.6em;
max-width: 18rem;
+
+ > * {
+ min-width: 0;
+ }
}
.form-group {
display: flex;
flex-direction: column;
margin-bottom: 1em;
- padding: 0.3em 0.0em 0.3em;
- line-height: 24px;
+ padding: 0.3em 0;
+ line-height: 1.85em;
}
.error {
@@ -110,7 +114,7 @@
.alert {
padding: 0.5em;
- margin: 0.3em 0.0em 1em;
+ margin: 0.3em 0 1em;
}
.password-reset-required {
diff --git a/src/components/pinch_zoom/pinch_zoom.js b/src/components/pinch_zoom/pinch_zoom.js
new file mode 100644
index 00000000..82670ddf
--- /dev/null
+++ b/src/components/pinch_zoom/pinch_zoom.js
@@ -0,0 +1,13 @@
+import PinchZoom from '@kazvmoe-infra/pinch-zoom-element'
+
+export default {
+ methods: {
+ setTransform ({ scale, x, y }) {
+ this.$el.setTransform({ scale, x, y })
+ }
+ },
+ created () {
+ // Make lint happy
+ (() => PinchZoom)()
+ }
+}
diff --git a/src/components/pinch_zoom/pinch_zoom.vue b/src/components/pinch_zoom/pinch_zoom.vue
new file mode 100644
index 00000000..18d69719
--- /dev/null
+++ b/src/components/pinch_zoom/pinch_zoom.vue
@@ -0,0 +1,11 @@
+<template>
+ <pinch-zoom
+ class="pinch-zoom-parent"
+ v-bind="$attrs"
+ v-on="$listeners"
+ >
+ <slot />
+ </pinch-zoom>
+</template>
+
+<script src="./pinch_zoom.js"></script>
diff --git a/src/components/poll/poll.js b/src/components/poll/poll.js
index a69b7886..eda1733a 100644
--- a/src/components/poll/poll.js
+++ b/src/components/poll/poll.js
@@ -21,7 +21,7 @@ export default {
}
this.$store.dispatch('trackPoll', this.pollId)
},
- destroyed () {
+ unmounted () {
this.$store.dispatch('untrackPoll', this.pollId)
},
computed: {
diff --git a/src/components/poll/poll.vue b/src/components/poll/poll.vue
index 63b44e4f..f6b12a54 100644
--- a/src/components/poll/poll.vue
+++ b/src/components/poll/poll.vue
@@ -71,13 +71,18 @@
{{ $tc("polls.votes_count", poll.votes_count, { count: poll.votes_count }) }}&nbsp;¡&nbsp;
</template>
</div>
- <i18n :path="expired ? 'polls.expired' : 'polls.expires_in'">
- <Timeago
- :time="expiresAt"
- :auto-update="60"
- :now-threshold="0"
- />
- </i18n>
+ <span>
+ <i18n-t
+ scope="global"
+ :keypath="expired ? 'polls.expired' : 'polls.expires_in'"
+ >
+ <Timeago
+ :time="expiresAt"
+ :auto-update="60"
+ :now-threshold="0"
+ />
+ </i18n-t>
+ </span>
</div>
</div>
</template>
diff --git a/src/components/poll/poll_form.vue b/src/components/poll/poll_form.vue
index 3620075a..146754db 100644
--- a/src/components/poll/poll_form.vue
+++ b/src/components/poll/poll_form.vue
@@ -72,6 +72,7 @@
:max="maxExpirationInCurrentUnit"
@change="expiryAmountChange"
>
+ {{ ' ' }}
<Select
v-model="expiryUnit"
unstyled="true"
@@ -83,7 +84,7 @@
:key="unit"
:value="unit"
>
- {{ $t(`time.${unit}_short`, ['']) }}
+ {{ $tc(`time.unit.${unit}_short`, expiryAmount, ['']) }}
</option>
</Select>
</div>
diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js
index 6ccf32f0..d44b266b 100644
--- a/src/components/popover/popover.js
+++ b/src/components/popover/popover.js
@@ -4,7 +4,7 @@ const Popover = {
// Action to trigger popover: either 'hover' or 'click'
trigger: String,
- // Either 'top' or 'bottom'
+ // 'top', 'bottom', 'left', 'right'
placement: String,
// Takes object with properties 'x' and 'y', values of these can be
@@ -31,40 +31,88 @@ const Popover = {
// If true, subtract padding when calculating position for the popover,
// use it when popover offset looks to be different on top vs bottom.
- removePadding: Boolean
+ removePadding: Boolean,
+
+ // self-explanatory (i hope)
+ disabled: Boolean,
+
+ // Instead of putting popover next to anchor, overlay popover's center on top of anchor's center
+ overlayCenters: Boolean,
+
+ // What selector (witin popover!) to use for determining center of popover
+ overlayCentersSelector: String,
+
+ // Lets hover popover stay when clicking inside of it
+ stayOnClick: Boolean,
+
+ triggerAttrs: {
+ type: Object,
+ default: {}
+ }
},
+ inject: ['popoversZLayer'], // override popover z layer
data () {
return {
+ // lockReEntry is a flag that is set when mouse cursor is leaving the popover's content
+ // so that if mouse goes back into popover it won't be re-shown again to prevent annoyance
+ // with popovers refusing to be hidden when user wants to interact with something in below popover
+ anchorEl: null,
+ // There's an issue where having teleport enabled by default causes things just...
+ // not render at all, i.e. main post status form and its emoji inputs
+ teleport: false,
+ lockReEntry: false,
hidden: true,
- styles: { opacity: 0 },
- oldSize: { width: 0, height: 0 }
+ styles: {},
+ oldSize: { width: 0, height: 0 },
+ scrollable: null,
+ // used to avoid blinking if hovered onto popover
+ graceTimeout: null,
+ parentPopover: null,
+ disableClickOutside: false,
+ childrenShown: new Set()
}
},
methods: {
+ setAnchorEl (el) {
+ this.anchorEl = el
+ this.updateStyles()
+ },
containerBoundingClientRect () {
const container = this.boundToSelector ? this.$el.closest(this.boundToSelector) : this.$el.offsetParent
return container.getBoundingClientRect()
},
updateStyles () {
if (this.hidden) {
- this.styles = {
- opacity: 0
- }
+ this.styles = {}
return
}
// Popover will be anchored around this element, trigger ref is the container, so
// its children are what are inside the slot. Expect only one v-slot:trigger.
- const anchorEl = (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el
+ const anchorEl = this.anchorEl || (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el
// SVGs don't have offsetWidth/Height, use fallback
- const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth
const anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight
- const screenBox = anchorEl.getBoundingClientRect()
- // Screen position of the origin point for popover
- const origin = { x: screenBox.left + screenBox.width * 0.5, y: screenBox.top }
+ const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth
+ const anchorScreenBox = anchorEl.getBoundingClientRect()
+
+ const anchorStyle = getComputedStyle(anchorEl)
+ const topPadding = parseFloat(anchorStyle.paddingTop)
+ const bottomPadding = parseFloat(anchorStyle.paddingBottom)
+ const rightPadding = parseFloat(anchorStyle.paddingRight)
+ const leftPadding = parseFloat(anchorStyle.paddingLeft)
+
+ // Screen position of the origin point for popover = center of the anchor
+ const origin = {
+ x: anchorScreenBox.left + anchorWidth * 0.5,
+ y: anchorScreenBox.top + anchorHeight * 0.5
+ }
const content = this.$refs.content
+ const overlayCenter = this.overlayCenters
+ ? this.$refs.content.querySelector(this.overlayCentersSelector)
+ : null
+
// Minor optimization, don't call a slow reflow call if we don't have to
- const parentBounds = this.boundTo &&
+ const parentScreenBox = this.boundTo &&
(this.boundTo.x === 'container' || this.boundTo.y === 'container') &&
this.containerBoundingClientRect()
@@ -72,82 +120,179 @@ const Popover = {
// What are the screen bounds for the popover? Viewport vs container
// when using viewport, using default margin values to dodge the navbar
- const xBounds = this.boundTo && this.boundTo.x === 'container' ? {
- min: parentBounds.left + (margin.left || 0),
- max: parentBounds.right - (margin.right || 0)
- } : {
- min: 0 + (margin.left || 10),
- max: window.innerWidth - (margin.right || 10)
- }
+ const xBounds = this.boundTo && this.boundTo.x === 'container'
+ ? {
+ min: parentScreenBox.left + (margin.left || 0),
+ max: parentScreenBox.right - (margin.right || 0)
+ }
+ : {
+ min: 0 + (margin.left || 10),
+ max: window.innerWidth - (margin.right || 10)
+ }
- const yBounds = this.boundTo && this.boundTo.y === 'container' ? {
- min: parentBounds.top + (margin.top || 0),
- max: parentBounds.bottom - (margin.bottom || 0)
- } : {
- min: 0 + (margin.top || 50),
- max: window.innerHeight - (margin.bottom || 5)
- }
+ const yBounds = this.boundTo && this.boundTo.y === 'container'
+ ? {
+ min: parentScreenBox.top + (margin.top || 0),
+ max: parentScreenBox.bottom - (margin.bottom || 0)
+ }
+ : {
+ min: 0 + (margin.top || 50),
+ max: window.innerHeight - (margin.bottom || 5)
+ }
let horizOffset = 0
+ let vertOffset = 0
+
+ if (overlayCenter) {
+ const box = content.getBoundingClientRect()
+ const overlayCenterScreenBox = overlayCenter.getBoundingClientRect()
+ const leftInnerOffset = overlayCenterScreenBox.left - box.left
+ const topInnerOffset = overlayCenterScreenBox.top - box.top
+ horizOffset = -leftInnerOffset - overlayCenter.offsetWidth * 0.5
+ vertOffset = -topInnerOffset - overlayCenter.offsetHeight * 0.5
+ } else {
+ horizOffset = content.offsetWidth * -0.5
+ vertOffset = content.offsetHeight * -0.5
+ }
+
+ const leftBorder = origin.x + horizOffset
+ const rightBorder = leftBorder + content.offsetWidth
+ const topBorder = origin.y + vertOffset
+ const bottomBorder = topBorder + content.offsetHeight
// If overflowing from left, move it so that it doesn't
- if ((origin.x - content.offsetWidth * 0.5) < xBounds.min) {
- horizOffset += -(origin.x - content.offsetWidth * 0.5) + xBounds.min
+ if (leftBorder < xBounds.min) {
+ horizOffset += xBounds.min - leftBorder
}
// If overflowing from right, move it so that it doesn't
- if ((origin.x + horizOffset + content.offsetWidth * 0.5) > xBounds.max) {
- horizOffset -= (origin.x + horizOffset + content.offsetWidth * 0.5) - xBounds.max
+ if (rightBorder > xBounds.max) {
+ horizOffset -= rightBorder - xBounds.max
}
- // Default to whatever user wished with placement prop
- let usingTop = this.placement !== 'bottom'
-
- // Handle special cases, first force to displaying on top if there's not space on bottom,
- // regardless of what placement value was. Then check if there's not space on top, and
- // force to bottom, again regardless of what placement value was.
- if (origin.y + content.offsetHeight > yBounds.max) usingTop = true
- if (origin.y - content.offsetHeight < yBounds.min) usingTop = false
+ // If overflowing from top, move it so that it doesn't
+ if (topBorder < yBounds.min) {
+ vertOffset += yBounds.min - topBorder
+ }
- let vPadding = 0
- if (this.removePadding && usingTop) {
- const anchorStyle = getComputedStyle(anchorEl)
- vPadding = parseFloat(anchorStyle.paddingTop) + parseFloat(anchorStyle.paddingBottom)
+ // If overflowing from bottom, move it so that it doesn't
+ if (bottomBorder > yBounds.max) {
+ vertOffset -= bottomBorder - yBounds.max
}
- const yOffset = (this.offset && this.offset.y) || 0
- const translateY = usingTop
- ? -anchorHeight + vPadding - yOffset - content.offsetHeight
- : yOffset
+ let translateX = 0
+ let translateY = 0
+
+ if (overlayCenter) {
+ translateX = origin.x + horizOffset
+ translateY = origin.y + vertOffset
+ } else if (this.placement !== 'right' && this.placement !== 'left') {
+ // Default to whatever user wished with placement prop
+ let usingTop = this.placement !== 'bottom'
+
+ // Handle special cases, first force to displaying on top if there's not space on bottom,
+ // regardless of what placement value was. Then check if there's not space on top, and
+ // force to bottom, again regardless of what placement value was.
+ const topBoundary = origin.y - anchorHeight * 0.5 + (this.removePadding ? topPadding : 0)
+ const bottomBoundary = origin.y + anchorHeight * 0.5 - (this.removePadding ? bottomPadding : 0)
+ if (bottomBoundary + content.offsetHeight > yBounds.max) usingTop = true
+ if (topBoundary - content.offsetHeight < yBounds.min) usingTop = false
- const xOffset = (this.offset && this.offset.x) || 0
- const translateX = anchorWidth * 0.5 - content.offsetWidth * 0.5 + horizOffset + xOffset
+ const yOffset = (this.offset && this.offset.y) || 0
+ translateY = usingTop
+ ? topBoundary - yOffset - content.offsetHeight
+ : bottomBoundary + yOffset
+
+ const xOffset = (this.offset && this.offset.x) || 0
+ translateX = origin.x + horizOffset + xOffset
+ } else {
+ // Default to whatever user wished with placement prop
+ let usingRight = this.placement !== 'left'
+
+ // Handle special cases, first force to displaying on top if there's not space on bottom,
+ // regardless of what placement value was. Then check if there's not space on top, and
+ // force to bottom, again regardless of what placement value was.
+ const rightBoundary = origin.x - anchorWidth * 0.5 + (this.removePadding ? rightPadding : 0)
+ const leftBoundary = origin.x + anchorWidth * 0.5 - (this.removePadding ? leftPadding : 0)
+ if (leftBoundary + content.offsetWidth > xBounds.max) usingRight = true
+ if (rightBoundary - content.offsetWidth < xBounds.min) usingRight = false
+
+ const xOffset = (this.offset && this.offset.x) || 0
+ translateX = usingRight
+ ? rightBoundary - xOffset - content.offsetWidth
+ : leftBoundary + xOffset
+
+ const yOffset = (this.offset && this.offset.y) || 0
+ translateY = origin.y + vertOffset + yOffset
+ }
- // Note, separate translateX and translateY avoids blurry text on chromium,
- // single translate or translate3d resulted in blurry text.
this.styles = {
- opacity: 1,
- transform: `translateX(${Math.round(translateX)}px) translateY(${Math.round(translateY)}px)`
+ left: `${Math.round(translateX)}px`,
+ top: `${Math.round(translateY)}px`
+ }
+
+ if (this.popoversZLayer) {
+ this.styles['--ZI_popover_override'] = `var(--ZI_${this.popoversZLayer}_popovers)`
+ }
+ if (parentScreenBox) {
+ this.styles.maxWidth = `${Math.round(parentScreenBox.width)}px`
}
},
showPopover () {
+ if (this.disabled) return
+ this.disableClickOutside = true
+ setTimeout(() => {
+ this.disableClickOutside = false
+ }, 0)
const wasHidden = this.hidden
this.hidden = false
+ this.parentPopover && this.parentPopover.onChildPopoverState(this, true)
+ if (this.trigger === 'click' || this.stayOnClick) {
+ document.addEventListener('click', this.onClickOutside)
+ }
+ this.scrollable.addEventListener('scroll', this.onScroll)
+ this.scrollable.addEventListener('resize', this.onResize)
this.$nextTick(() => {
if (wasHidden) this.$emit('show')
this.updateStyles()
})
},
hidePopover () {
+ if (this.disabled) return
if (!this.hidden) this.$emit('close')
this.hidden = true
- this.styles = { opacity: 0 }
+ this.parentPopover && this.parentPopover.onChildPopoverState(this, false)
+ if (this.trigger === 'click') {
+ document.removeEventListener('click', this.onClickOutside)
+ }
+ this.scrollable.removeEventListener('scroll', this.onScroll)
+ this.scrollable.removeEventListener('resize', this.onResize)
},
onMouseenter (e) {
- if (this.trigger === 'hover') this.showPopover()
+ if (this.trigger === 'hover') {
+ this.lockReEntry = false
+ clearTimeout(this.graceTimeout)
+ this.graceTimeout = null
+ this.showPopover()
+ }
},
onMouseleave (e) {
- if (this.trigger === 'hover') this.hidePopover()
+ if (this.trigger === 'hover' && this.childrenShown.size === 0) {
+ this.graceTimeout = setTimeout(() => this.hidePopover(), 1)
+ }
+ },
+ onMouseenterContent (e) {
+ if (this.trigger === 'hover' && !this.lockReEntry) {
+ this.lockReEntry = true
+ clearTimeout(this.graceTimeout)
+ this.graceTimeout = null
+ this.showPopover()
+ }
+ },
+ onMouseleaveContent (e) {
+ if (this.trigger === 'hover' && this.childrenShown.size === 0) {
+ this.graceTimeout = setTimeout(() => this.hidePopover(), 1)
+ }
},
onClick (e) {
if (this.trigger === 'click') {
@@ -159,9 +304,26 @@ const Popover = {
}
},
onClickOutside (e) {
+ if (this.disableClickOutside) return
if (this.hidden) return
+ if (this.$refs.content && this.$refs.content.contains(e.target)) return
if (this.$el.contains(e.target)) return
+ if (this.childrenShown.size > 0) return
this.hidePopover()
+ if (this.parentPopover) this.parentPopover.onClickOutside(e)
+ },
+ onScroll (e) {
+ this.updateStyles()
+ },
+ onResize (e) {
+ this.updateStyles()
+ },
+ onChildPopoverState (childRef, state) {
+ if (state) {
+ this.childrenShown.add(childRef)
+ } else {
+ this.childrenShown.delete(childRef)
+ }
}
},
updated () {
@@ -175,11 +337,19 @@ const Popover = {
this.oldSize = { width: content.offsetWidth, height: content.offsetHeight }
}
},
- created () {
- document.addEventListener('click', this.onClickOutside)
+ mounted () {
+ this.teleport = true
+ let scrollable = this.$refs.trigger.closest('.column.-scrollable') ||
+ this.$refs.trigger.closest('.mobile-notifications')
+ if (!scrollable) scrollable = window
+ this.scrollable = scrollable
+ let parent = this.$parent
+ while (parent && parent.$.type.name !== 'Popover') {
+ parent = parent.$parent
+ }
+ this.parentPopover = parent
},
- destroyed () {
- document.removeEventListener('click', this.onClickOutside)
+ beforeUnmount () {
this.hidePopover()
}
}
diff --git a/src/components/popover/popover.vue b/src/components/popover/popover.vue
index 2e78a09e..2869d736 100644
--- a/src/components/popover/popover.vue
+++ b/src/components/popover/popover.vue
@@ -1,30 +1,41 @@
<template>
- <div
+ <span
@mouseenter="onMouseenter"
@mouseleave="onMouseleave"
>
<button
ref="trigger"
- class="button-unstyled -fullwidth popover-trigger-button"
+ class="button-unstyled popover-trigger-button"
type="button"
+ v-bind="triggerAttrs"
@click="onClick"
>
<slot name="trigger" />
</button>
- <div
- v-if="!hidden"
- ref="content"
- :style="styles"
- class="popover"
- :class="popoverClass || 'popover-default'"
+ <teleport
+ :disabled="!teleport"
+ to="#popovers"
>
- <slot
- name="content"
- class="popover-inner"
- :close="hidePopover"
- />
- </div>
- </div>
+ <transition name="fade">
+ <div
+ v-if="!hidden"
+ ref="content"
+ :style="styles"
+ class="popover"
+ :class="popoverClass || 'popover-default'"
+ @mouseenter="onMouseenterContent"
+ @mouseleave="onMouseleaveContent"
+ @click="onClickContent"
+ >
+ <slot
+ name="content"
+ class="popover-inner"
+ :close="hidePopover"
+ />
+ </div>
+ </transition>
+ </teleport>
+ </span>
</template>
<script src="./popover.js" />
@@ -33,20 +44,32 @@
@import '../../_variables.scss';
.popover-trigger-button {
- display: block;
+ display: inline-block;
}
.popover {
- z-index: 8;
- position: absolute;
+ z-index: var(--ZI_popover_override, var(--ZI_popovers));
+ position: fixed;
min-width: 0;
+ max-width: calc(100vw - 20px);
+ box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5);
+ box-shadow: var(--popupShadow);
}
.popover-default {
- transition: opacity 0.3s;
+ &:after {
+ content: '';
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: 3;
+ box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6);
+ box-shadow: var(--panelShadow);
+ pointer-events: none;
+ }
- box-shadow: 1px 1px 4px rgba(0,0,0,.6);
- box-shadow: var(--panelShadow);
border-radius: $fallback--btnRadius;
border-radius: var(--btnRadius, $fallback--btnRadius);
@@ -65,11 +88,11 @@
.dropdown-menu {
display: block;
padding: .5rem 0;
- font-size: 1rem;
+ font-size: 1em;
text-align: left;
list-style: none;
max-width: 100vw;
- z-index: 10;
+ z-index: var(--ZI_popover_override, var(--ZI_popovers));
white-space: nowrap;
.dropdown-divider {
@@ -82,9 +105,9 @@
.dropdown-item {
line-height: 21px;
- overflow: auto;
+ overflow: hidden;
display: block;
- padding: .5em 0.75em;
+ padding: 0.5em 0.75em;
clear: both;
font-weight: 400;
text-align: inherit;
@@ -107,17 +130,25 @@
}
}
+ &.-has-submenu {
+ .chevron-icon {
+ margin-right: 0.25rem;
+ margin-left: 2rem;
+ }
+ }
+
&:active, &:hover {
background-color: $fallback--lightBg;
background-color: var(--selectedMenuPopover, $fallback--lightBg);
- color: $fallback--link;
- color: var(--selectedMenuPopoverText, $fallback--link);
+ box-shadow: none;
+ --btnText: var(--selectedMenuPopoverText, $fallback--link);
--faint: var(--selectedMenuPopoverFaintText, $fallback--faint);
--faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint);
--lightText: var(--selectedMenuPopoverLightText, $fallback--lightText);
--icon: var(--selectedMenuPopoverIcon, $fallback--icon);
svg {
color: var(--selectedMenuPopoverIcon, $fallback--icon);
+ --icon: var(--selectedMenuPopoverIcon, $fallback--icon);
}
}
@@ -142,12 +173,41 @@
content: '✓';
}
- &.menu-checkbox-radio::after {
- font-size: 2em;
- content: 'â€ĸ';
+ &.-radio {
+ border-radius: 9999px;
+
+ &.menu-checkbox-checked::after {
+ font-size: 2em;
+ content: 'â€ĸ';
+ }
}
}
}
+
+ .button-default.dropdown-item {
+ &,
+ i[class*=icon-] {
+ color: $fallback--text;
+ color: var(--btnText, $fallback--text);
+ }
+
+ &:active {
+ background-color: $fallback--lightBg;
+ background-color: var(--selectedMenuPopover, $fallback--lightBg);
+ color: $fallback--link;
+ color: var(--selectedMenuPopoverText, $fallback--link);
+ }
+
+ &:disabled {
+ color: $fallback--text;
+ color: var(--btnDisabledText, $fallback--text);
+ }
+
+ &.toggled {
+ color: $fallback--text;
+ color: var(--btnToggledText, $fallback--text);
+ }
+ }
}
</style>
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index 5342894f..eb55cfcc 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -4,6 +4,7 @@ import ScopeSelector from '../scope_selector/scope_selector.vue'
import EmojiInput from '../emoji_input/emoji_input.vue'
import PollForm from '../poll/poll_form.vue'
import Attachment from '../attachment/attachment.vue'
+import Gallery from 'src/components/gallery/gallery.vue'
import StatusContent from '../status_content/status_content.vue'
import fileTypeService from '../../services/file_type/file_type.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
@@ -40,7 +41,7 @@ const buildMentionsString = ({ user, attentions = [] }, currentUser) => {
allAttentions = uniqBy(allAttentions, 'id')
allAttentions = reject(allAttentions, { id: currentUser.id })
- let mentions = map(allAttentions, (attention) => {
+ const mentions = map(allAttentions, (attention) => {
return `@${attention.screen_name}`
})
@@ -54,6 +55,14 @@ const pxStringToNumber = (str) => {
const PostStatusForm = {
props: [
+ 'statusId',
+ 'statusText',
+ 'statusIsSensitive',
+ 'statusPoll',
+ 'statusFiles',
+ 'statusMediaDescriptions',
+ 'statusScope',
+ 'statusContentType',
'replyTo',
'repliedUser',
'attentions',
@@ -61,6 +70,7 @@ const PostStatusForm = {
'subject',
'disableSubject',
'disableScopeSelector',
+ 'disableVisibilitySelector',
'disableNotice',
'disableLockWarning',
'disablePolls',
@@ -77,6 +87,12 @@ const PostStatusForm = {
'emojiPickerPlacement',
'optimisticPosting'
],
+ emits: [
+ 'posted',
+ 'resize',
+ 'mediaplay',
+ 'mediapause'
+ ],
components: {
MediaUpload,
EmojiInput,
@@ -85,7 +101,8 @@ const PostStatusForm = {
Checkbox,
Select,
Attachment,
- StatusContent
+ StatusContent,
+ Gallery
},
mounted () {
this.updateIdempotencyKey()
@@ -117,22 +134,38 @@ const PostStatusForm = {
const { postContentType: contentType, sensitiveByDefault } = this.$store.getters.mergedConfig
+ let statusParams = {
+ spoilerText: this.subject || '',
+ status: statusText,
+ nsfw: !!sensitiveByDefault,
+ files: [],
+ poll: {},
+ mediaDescriptions: {},
+ visibility: scope,
+ contentType
+ }
+
+ if (this.statusId) {
+ const statusContentType = this.statusContentType || contentType
+ statusParams = {
+ spoilerText: this.subject || '',
+ status: this.statusText || '',
+ nsfw: this.statusIsSensitive || !!sensitiveByDefault,
+ files: this.statusFiles || [],
+ poll: this.statusPoll || {},
+ mediaDescriptions: this.statusMediaDescriptions || {},
+ visibility: this.statusScope || scope,
+ contentType: statusContentType
+ }
+ }
+
return {
dropFiles: [],
uploadingFiles: false,
error: null,
posting: false,
highlighted: 0,
- newStatus: {
- spoilerText: this.subject || '',
- status: statusText,
- nsfw: !!sensitiveByDefault,
- files: [],
- poll: {},
- mediaDescriptions: {},
- visibility: scope,
- contentType
- },
+ newStatus: statusParams,
caret: 0,
pollFormVisible: false,
showDropIcon: 'hide',
@@ -156,7 +189,7 @@ const PostStatusForm = {
emojiUserSuggestor () {
return suggestor({
emoji: [
- ...this.$store.state.instance.emoji,
+ ...this.$store.getters.standardEmojiList,
...this.$store.state.instance.customEmoji
],
store: this.$store
@@ -165,13 +198,13 @@ const PostStatusForm = {
emojiSuggestor () {
return suggestor({
emoji: [
- ...this.$store.state.instance.emoji,
+ ...this.$store.getters.standardEmojiList,
...this.$store.state.instance.customEmoji
]
})
},
emoji () {
- return this.$store.state.instance.emoji || []
+ return this.$store.getters.standardEmojiList || []
},
customEmoji () {
return this.$store.state.instance.customEmoji || []
@@ -228,13 +261,16 @@ const PostStatusForm = {
uploadFileLimitReached () {
return this.newStatus.files.length >= this.fileLimit
},
+ isEdit () {
+ return typeof this.statusId !== 'undefined' && this.statusId.trim() !== ''
+ },
...mapGetters(['mergedConfig']),
...mapState({
mobileLayout: state => state.interface.mobileLayout
})
},
watch: {
- 'newStatus': {
+ newStatus: {
deep: true,
handler () {
this.statusChanged()
@@ -265,7 +301,7 @@ const PostStatusForm = {
this.$refs.textarea.focus()
})
}
- let el = this.$el.querySelector('textarea')
+ const el = this.$el.querySelector('textarea')
el.style.height = 'auto'
el.style.height = undefined
this.error = null
@@ -384,10 +420,25 @@ const PostStatusForm = {
this.$emit('resize', { delayed: true })
},
removeMediaFile (fileInfo) {
- let index = this.newStatus.files.indexOf(fileInfo)
+ const index = this.newStatus.files.indexOf(fileInfo)
this.newStatus.files.splice(index, 1)
this.$emit('resize')
},
+ editAttachment (fileInfo, newText) {
+ this.newStatus.mediaDescriptions[fileInfo.id] = newText
+ },
+ shiftUpMediaFile (fileInfo) {
+ const { files } = this.newStatus
+ const index = this.newStatus.files.indexOf(fileInfo)
+ files.splice(index, 1)
+ files.splice(index - 1, 0, fileInfo)
+ },
+ shiftDnMediaFile (fileInfo) {
+ const { files } = this.newStatus
+ const index = this.newStatus.files.indexOf(fileInfo)
+ files.splice(index, 1)
+ files.splice(index + 1, 0, fileInfo)
+ },
uploadFailed (errString, templateArgs) {
templateArgs = templateArgs || {}
this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs)
@@ -439,7 +490,7 @@ const PostStatusForm = {
},
onEmojiInputInput (e) {
this.$nextTick(() => {
- this.resize(this.$refs['textarea'])
+ this.resize(this.$refs.textarea)
})
},
resize (e) {
@@ -450,12 +501,11 @@ const PostStatusForm = {
if (target.value === '') {
target.style.height = null
this.$emit('resize')
- this.$refs['emoji-input'].resize()
return
}
- const formRef = this.$refs['form']
- const bottomRef = this.$refs['bottom']
+ const formRef = this.$refs.form
+ const bottomRef = this.$refs.bottom
/* Scroller is either `window` (replies in TL), sidebar (main post form,
* replies in notifs) or mobile post form. Note that getting and setting
* scroll is different for `Window` and `Element`s
@@ -463,7 +513,7 @@ const PostStatusForm = {
const bottomBottomPaddingStr = window.getComputedStyle(bottomRef)['padding-bottom']
const bottomBottomPadding = pxStringToNumber(bottomBottomPaddingStr)
- const scrollerRef = this.$el.closest('.sidebar-scroller') ||
+ const scrollerRef = this.$el.closest('.column.-scrollable') ||
this.$el.closest('.post-form-modal-view') ||
window
@@ -537,11 +587,9 @@ const PostStatusForm = {
} else {
scrollerRef.scrollTop = targetScroll
}
-
- this.$refs['emoji-input'].resize()
},
showEmojiPicker () {
- this.$refs['textarea'].focus()
+ this.$refs.textarea.focus()
this.$refs['emoji-input'].triggerShowPicker()
},
clearError () {
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index fbda41d6..f65058f4 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -8,21 +8,13 @@
@submit.prevent
@dragover.prevent="fileDrag"
>
- <div
- v-show="showDropIcon !== 'hide'"
- :style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }"
- class="drop-indicator"
- @dragleave="fileDragStop"
- @drop.stop="fileDrop"
- >
- <FAIcon :icon="uploadFileLimitReached ? 'ban' : 'upload'" />
- </div>
<div class="form-group">
- <i18n
+ <i18n-t
v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private' && !disableLockWarning"
- path="post_status.account_not_locked_warning"
+ keypath="post_status.account_not_locked_warning"
tag="p"
class="visibility-notice"
+ scope="global"
>
<button
class="button-unstyled -link"
@@ -30,7 +22,7 @@
>
{{ $t('post_status.account_not_locked_warning_link') }}
</button>
- </i18n>
+ </i18n-t>
<p
v-if="!hideScopeNotice && newStatus.visibility === 'public'"
class="visibility-notice notice-dismissible"
@@ -75,6 +67,13 @@
<span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
</p>
<div
+ v-if="isEdit"
+ class="visibility-notice edit-warning"
+ >
+ <p>{{ $t('post_status.edit_remote_warning') }}</p>
+ <p>{{ $t('post_status.edit_unsupported_warning') }}</p>
+ </div>
+ <div
v-if="!disablePreview"
class="preview-heading faint"
>
@@ -178,6 +177,7 @@
class="visibility-tray"
>
<scope-selector
+ v-if="!disableVisibilitySelector"
:show-all="showAllScopes"
:user-default="userDefaultScope"
:original-scope="copyMessageScope"
@@ -277,42 +277,45 @@
</button>
</div>
<div
+ v-show="showDropIcon !== 'hide'"
+ :style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }"
+ class="drop-indicator"
+ @dragleave="fileDragStop"
+ @drop.stop="fileDrop"
+ >
+ <FAIcon :icon="uploadFileLimitReached ? 'ban' : 'upload'" />
+ </div>
+ <div
v-if="error"
class="alert error"
>
Error: {{ error }}
- <FAIcon
- class="fa-scale-110 fa-old-padding"
- icon="times"
+ <button
+ class="button-unstyled"
@click="clearError"
- />
- </div>
- <div class="attachments">
- <div
- v-for="file in newStatus.files"
- :key="file.url"
- class="media-upload-wrapper"
>
- <button
- class="button-unstyled hider"
- @click="removeMediaFile(file)"
- >
- <FAIcon icon="times" />
- </button>
- <attachment
- :attachment="file"
- :set-media="() => $store.dispatch('setMedia', newStatus.files)"
- size="small"
- allow-play="false"
+ <FAIcon
+ class="fa-scale-110 fa-old-padding"
+ icon="times"
/>
- <input
- v-model="newStatus.mediaDescriptions[file.id]"
- type="text"
- :placeholder="$t('post_status.media_description')"
- @keydown.enter.prevent=""
- >
- </div>
+ </button>
</div>
+ <gallery
+ v-if="newStatus.files && newStatus.files.length > 0"
+ class="attachments"
+ :grid="true"
+ :nsfw="false"
+ :attachments="newStatus.files"
+ :descriptions="newStatus.mediaDescriptions"
+ :set-media="() => $store.dispatch('setMedia', newStatus.files)"
+ :editable="true"
+ :edit-attachment="editAttachment"
+ :remove-attachment="removeMediaFile"
+ :shift-up-attachment="newStatus.files.length > 1 && shiftUpMediaFile"
+ :shift-dn-attachment="newStatus.files.length > 1 && shiftDnMediaFile"
+ @play="$emit('mediaplay', attachment.id)"
+ @pause="$emit('mediapause', attachment.id)"
+ />
<div
v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox"
class="upload_settings"
@@ -330,31 +333,18 @@
<style lang="scss">
@import '../../_variables.scss';
-.tribute-container {
- ul {
- padding: 0px;
- li {
- display: flex;
- align-items: center;
- }
- }
- img {
- padding: 3px;
- width: 16px;
- height: 16px;
- border-radius: $fallback--avatarAltRadius;
- border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
- }
-}
-
.post-status-form {
position: relative;
+ .attachments {
+ margin-bottom: 0.5em;
+ }
+
.form-bottom {
display: flex;
justify-content: space-between;
padding: 0.5em;
- height: 32px;
+ height: 2.5em;
button {
width: 10em;
@@ -412,7 +402,6 @@
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
padding: 0.5em;
margin: 0;
- line-height: 1.4em;
}
.text-format {
@@ -426,13 +415,26 @@
display: flex;
justify-content: space-between;
padding-top: 5px;
+ align-items: baseline;
+ }
+
+ .visibility-notice.edit-warning {
+ > :first-child {
+ margin-top: 0;
+ }
+
+ > :last-child {
+ margin-bottom: 0;
+ }
}
.media-upload-icon, .poll-icon, .emoji-icon {
- font-size: 26px;
+ font-size: 1.85em;
line-height: 1.1;
flex: 1;
padding: 0 0.1em;
+ display: flex;
+ align-items: center;
&.selected, &:hover {
// needs to be specific to override icon default color
@@ -459,21 +461,17 @@
// Order is not necessary but a good indicator
.media-upload-icon {
order: 1;
- text-align: left;
+ justify-content: left;
}
.emoji-icon {
order: 2;
- text-align: center;
+ justify-content: center;
}
.poll-icon {
order: 3;
- text-align: right;
- }
-
- .poll-icon {
- cursor: pointer;
+ justify-content: right;
}
.error {
@@ -507,19 +505,6 @@
flex-direction: column;
}
- .attachments .media-upload-wrapper {
- position: relative;
-
- .attachment {
- margin: 0;
- padding: 0;
- }
- }
-
- .btn {
- cursor: pointer;
- }
-
.btn[disabled] {
cursor: not-allowed;
}
@@ -535,26 +520,20 @@
display: flex;
flex-direction: column;
padding: 0.25em 0.5em 0.5em;
- line-height:24px;
- }
-
- form textarea.form-cw {
- line-height:16px;
- resize: none;
- overflow: hidden;
- transition: min-height 200ms 100ms;
- min-height: 1px;
+ line-height: 1.85;
}
.form-post-body {
- height: 16px; // Only affects the empty-height
- line-height: 16px;
- resize: none;
+ // TODO: make a resizable textarea component?
+ box-sizing: content-box; // needed for easier computation of dynamic size
overflow: hidden;
transition: min-height 200ms 100ms;
- padding-bottom: 1.75em;
- min-height: 1px;
- box-sizing: content-box;
+ // stock padding + 1 line of text (for counter)
+ padding-bottom: calc(var(--_padding) + var(--post-line-height) * 1em);
+ // two lines of text
+ height: calc(var(--post-line-height) * 1em);
+ min-height: calc(var(--post-line-height) * 1em);
+ resize: none;
&.scrollable-form {
overflow-y: auto;
@@ -578,10 +557,6 @@
}
}
- .btn {
- cursor: pointer;
- }
-
.btn[disabled] {
cursor: not-allowed;
}
@@ -598,7 +573,6 @@
.drop-indicator {
position: absolute;
- z-index: 1;
width: 100%;
height: 100%;
font-size: 5em;
@@ -616,11 +590,4 @@
border: 2px dashed var(--text, $fallback--text);
}
}
-
-// todo: unify with attachment.vue (otherwise the uploaded images are not minified unless a status with an attachment was displayed before)
-img.media-upload, .media-upload-container > video {
- line-height: 0;
- max-height: 200px;
- max-width: 100%;
-}
</style>
diff --git a/src/components/public_and_external_timeline/public_and_external_timeline.js b/src/components/public_and_external_timeline/public_and_external_timeline.js
index cbd4491b..bfcce6ae 100644
--- a/src/components/public_and_external_timeline/public_and_external_timeline.js
+++ b/src/components/public_and_external_timeline/public_and_external_timeline.js
@@ -9,7 +9,7 @@ const PublicAndExternalTimeline = {
created () {
this.$store.dispatch('startFetchingTimeline', { timeline: 'publicAndExternal' })
},
- destroyed () {
+ unmounted () {
this.$store.dispatch('stopFetchingTimeline', 'publicAndExternal')
}
}
diff --git a/src/components/public_timeline/public_timeline.js b/src/components/public_timeline/public_timeline.js
index 66c40d3a..30693544 100644
--- a/src/components/public_timeline/public_timeline.js
+++ b/src/components/public_timeline/public_timeline.js
@@ -9,7 +9,7 @@ const PublicTimeline = {
created () {
this.$store.dispatch('startFetchingTimeline', { timeline: 'public' })
},
- destroyed () {
+ unmounted () {
this.$store.dispatch('stopFetchingTimeline', 'public')
}
diff --git a/src/components/timeline/timeline_quick_settings.js b/src/components/quick_filter_settings/quick_filter_settings.js
index eae65a55..e67e3a4b 100644
--- a/src/components/timeline/timeline_quick_settings.js
+++ b/src/components/quick_filter_settings/quick_filter_settings.js
@@ -9,7 +9,10 @@ library.add(
faWrench
)
-const TimelineQuickSettings = {
+const QuickFilterSettings = {
+ props: {
+ conversation: Boolean
+ },
components: {
Popover
},
@@ -48,14 +51,20 @@ const TimelineQuickSettings = {
}
},
hideMutedPosts: {
- get () { return this.mergedConfig.hideMutedPosts || this.mergedConfig.hideFilteredStatuses },
+ get () { return this.mergedConfig.hideFilteredStatuses },
set () {
const value = !this.hideMutedPosts
- this.$store.dispatch('setOption', { name: 'hideMutedPosts', value })
this.$store.dispatch('setOption', { name: 'hideFilteredStatuses', value })
}
+ },
+ muteBotStatuses: {
+ get () { return this.mergedConfig.muteBotStatuses },
+ set () {
+ const value = !this.muteBotStatuses
+ this.$store.dispatch('setOption', { name: 'muteBotStatuses', value })
+ }
}
}
}
-export default TimelineQuickSettings
+export default QuickFilterSettings
diff --git a/src/components/timeline/timeline_quick_settings.vue b/src/components/quick_filter_settings/quick_filter_settings.vue
index 98996ebd..f2aa61ee 100644
--- a/src/components/timeline/timeline_quick_settings.vue
+++ b/src/components/quick_filter_settings/quick_filter_settings.vue
@@ -1,46 +1,60 @@
<template>
<Popover
trigger="click"
- class="TimelineQuickSettings"
+ class="QuickFilterSettings"
:bound-to="{ x: 'container' }"
+ :trigger-attrs="{ title: $t('timeline.quick_filter_settings') }"
>
- <template v-slot:content>
+ <template #content>
<div class="dropdown-menu">
<div v-if="loggedIn">
<button
+ v-if="!conversation"
class="button-default dropdown-item"
@click="replyVisibilityAll = true"
>
<span
- class="menu-checkbox"
- :class="{ 'menu-checkbox-radio': replyVisibilityAll }"
+ class="menu-checkbox -radio"
+ :class="{ 'menu-checkbox-checked': replyVisibilityAll }"
/>{{ $t('settings.reply_visibility_all') }}
</button>
<button
+ v-if="!conversation"
class="button-default dropdown-item"
@click="replyVisibilityFollowing = true"
>
<span
- class="menu-checkbox"
- :class="{ 'menu-checkbox-radio': replyVisibilityFollowing }"
+ class="menu-checkbox -radio"
+ :class="{ 'menu-checkbox-checked': replyVisibilityFollowing }"
/>{{ $t('settings.reply_visibility_following_short') }}
</button>
<button
+ v-if="!conversation"
class="button-default dropdown-item"
@click="replyVisibilitySelf = true"
>
<span
- class="menu-checkbox"
- :class="{ 'menu-checkbox-radio': replyVisibilitySelf }"
+ class="menu-checkbox -radio"
+ :class="{ 'menu-checkbox-checked': replyVisibilitySelf }"
/>{{ $t('settings.reply_visibility_self_short') }}
</button>
<div
+ v-if="!conversation"
role="separator"
class="dropdown-divider"
/>
</div>
<button
class="button-default dropdown-item"
+ @click="muteBotStatuses = !muteBotStatuses"
+ >
+ <span
+ class="menu-checkbox"
+ :class="{ 'menu-checkbox-checked': muteBotStatuses }"
+ />{{ $t('settings.mute_bot_posts') }}
+ </button>
+ <button
+ class="button-default dropdown-item"
@click="hideMedia = !hideMedia"
>
<span
@@ -61,42 +75,14 @@
class="button-default dropdown-item dropdown-item-icon"
@click="openTab('filtering')"
>
- <FAIcon icon="font" />{{ $t('settings.word_filter') }}
- </button>
- <button
- class="button-default dropdown-item dropdown-item-icon"
- @click="openTab('general')"
- >
- <FAIcon icon="wrench" />{{ $t('settings.more_settings') }}
+ <FAIcon icon="font" />{{ $t('settings.word_filter_and_more') }}
</button>
</div>
</template>
- <template v-slot:trigger>
- <button class="button-unstyled">
- <FAIcon icon="filter" />
- </button>
+ <template #trigger>
+ <FAIcon icon="filter" />
</template>
</Popover>
</template>
-<script src="./timeline_quick_settings.js"></script>
-
-<style lang="scss">
-
-.TimelineQuickSettings {
- align-self: stretch;
-
- > button {
- font-size: 1.2em;
- padding-left: 0.7em;
- padding-right: 0.2em;
- line-height: 100%;
- height: 100%;
- }
-
- .dropdown-item {
- margin: 0;
- }
-}
-
-</style>
+<script src="./quick_filter_settings.js"></script>
diff --git a/src/components/quick_view_settings/quick_view_settings.js b/src/components/quick_view_settings/quick_view_settings.js
new file mode 100644
index 00000000..2798f37a
--- /dev/null
+++ b/src/components/quick_view_settings/quick_view_settings.js
@@ -0,0 +1,69 @@
+import Popover from '../popover/popover.vue'
+import { mapGetters } from 'vuex'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import { faList, faFolderTree, faBars, faWrench } from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faList,
+ faFolderTree,
+ faBars,
+ faWrench
+)
+
+const QuickViewSettings = {
+ props: {
+ conversation: Boolean
+ },
+ components: {
+ Popover
+ },
+ methods: {
+ setConversationDisplay (visibility) {
+ this.$store.dispatch('setOption', { name: 'conversationDisplay', value: visibility })
+ },
+ openTab (tab) {
+ this.$store.dispatch('openSettingsModalTab', tab)
+ }
+ },
+ computed: {
+ ...mapGetters(['mergedConfig']),
+ loggedIn () {
+ return !!this.$store.state.users.currentUser
+ },
+ conversationDisplay: {
+ get () { return this.mergedConfig.conversationDisplay },
+ set (newVal) { this.setConversationDisplay(newVal) }
+ },
+ autoUpdate: {
+ get () { return this.mergedConfig.streaming },
+ set () {
+ const value = !this.autoUpdate
+ this.$store.dispatch('setOption', { name: 'streaming', value })
+ }
+ },
+ collapseWithSubjects: {
+ get () { return this.mergedConfig.collapseMessageWithSubject },
+ set () {
+ const value = !this.collapseWithSubjects
+ this.$store.dispatch('setOption', { name: 'collapseMessageWithSubject', value })
+ }
+ },
+ showUserAvatars: {
+ get () { return this.mergedConfig.mentionLinkShowAvatar },
+ set () {
+ const value = !this.showUserAvatars
+ console.log(value)
+ this.$store.dispatch('setOption', { name: 'mentionLinkShowAvatar', value })
+ }
+ },
+ muteBotStatuses: {
+ get () { return this.mergedConfig.muteBotStatuses },
+ set () {
+ const value = !this.muteBotStatuses
+ this.$store.dispatch('setOption', { name: 'muteBotStatuses', value })
+ }
+ }
+ }
+}
+
+export default QuickViewSettings
diff --git a/src/components/quick_view_settings/quick_view_settings.vue b/src/components/quick_view_settings/quick_view_settings.vue
new file mode 100644
index 00000000..4bd81c5b
--- /dev/null
+++ b/src/components/quick_view_settings/quick_view_settings.vue
@@ -0,0 +1,75 @@
+<template>
+ <Popover
+ trigger="click"
+ class="QuickViewSettings"
+ :bound-to="{ x: 'container' }"
+ :trigger-attrs="{ title: $t('timeline.quick_view_settings') }"
+ >
+ <template #content>
+ <div class="dropdown-menu">
+ <button
+ class="button-default dropdown-item"
+ @click="conversationDisplay = 'tree'"
+ >
+ <span
+ class="menu-checkbox -radio"
+ :class="{ 'menu-checkbox-checked': conversationDisplay === 'tree' }"
+ /><FAIcon icon="folder-tree" /> {{ $t('settings.conversation_display_tree_quick') }}
+ </button>
+ <button
+ class="button-default dropdown-item"
+ @click="conversationDisplay = 'linear'"
+ >
+ <span
+ class="menu-checkbox -radio"
+ :class="{ 'menu-checkbox-checked': conversationDisplay === 'linear' }"
+ /><FAIcon icon="list" /> {{ $t('settings.conversation_display_linear_quick') }}
+ </button>
+ <div
+ role="separator"
+ class="dropdown-divider"
+ />
+ <button
+ class="button-default dropdown-item"
+ @click="showUserAvatars = !showUserAvatars"
+ >
+ <span
+ class="menu-checkbox"
+ :class="{ 'menu-checkbox-checked': showUserAvatars }"
+ />{{ $t('settings.mention_link_show_avatar_quick') }}
+ </button>
+ <button
+ v-if="!conversation"
+ class="button-default dropdown-item"
+ @click="autoUpdate = !autoUpdate"
+ >
+ <span
+ class="menu-checkbox"
+ :class="{ 'menu-checkbox-checked': autoUpdate }"
+ />{{ $t('settings.auto_update') }}
+ </button>
+ <button
+ v-if="!conversation"
+ class="button-default dropdown-item"
+ @click="collapseWithSubjects = !collapseWithSubjects"
+ >
+ <span
+ class="menu-checkbox"
+ :class="{ 'menu-checkbox-checked': collapseWithSubjects }"
+ />{{ $t('settings.collapse_subject') }}
+ </button>
+ <button
+ class="button-default dropdown-item dropdown-item-icon"
+ @click="openTab('general')"
+ >
+ <FAIcon icon="wrench" />{{ $t('settings.more_settings') }}
+ </button>
+ </div>
+ </template>
+ <template #trigger>
+ <FAIcon icon="bars" />
+ </template>
+ </Popover>
+</template>
+
+<script src="./quick_view_settings.js"></script>
diff --git a/src/components/range_input/range_input.vue b/src/components/range_input/range_input.vue
index 5857a5c1..1e7e42d5 100644
--- a/src/components/range_input/range_input.vue
+++ b/src/components/range_input/range_input.vue
@@ -15,7 +15,7 @@
class="opt"
type="checkbox"
:checked="present"
- @input="$emit('input', !present ? fallback : undefined)"
+ @change="$emit('update:modelValue', !present ? fallback : undefined)"
>
<label
v-if="typeof fallback !== 'undefined'"
@@ -26,23 +26,23 @@
:id="name"
class="input-number"
type="range"
- :value="value || fallback"
+ :value="modelValue || fallback"
:disabled="!present || disabled"
:max="max || hardMax || 100"
:min="min || hardMin || 0"
:step="step || 1"
- @input="$emit('input', $event.target.value)"
+ @input="$emit('update:modelValue', $event.target.value)"
>
<input
:id="name"
class="input-number"
type="number"
- :value="value || fallback"
+ :value="modelValue || fallback"
:disabled="!present || disabled"
:max="hardMax"
:min="hardMin"
:step="step || 1"
- @input="$emit('input', $event.target.value)"
+ @input="$emit('update:modelValue', $event.target.value)"
>
</div>
</template>
@@ -50,11 +50,12 @@
<script>
export default {
props: [
- 'name', 'value', 'fallback', 'disabled', 'label', 'max', 'min', 'step', 'hardMin', 'hardMax'
+ 'name', 'modelValue', 'fallback', 'disabled', 'label', 'max', 'min', 'step', 'hardMin', 'hardMax'
],
+ emits: ['update:modelValue'],
computed: {
present () {
- return typeof this.value !== 'undefined'
+ return typeof this.modelValue !== 'undefined'
}
}
}
diff --git a/src/components/react_button/react_button.js b/src/components/react_button/react_button.js
index ce82c90d..2a0dac85 100644
--- a/src/components/react_button/react_button.js
+++ b/src/components/react_button/react_button.js
@@ -1,14 +1,22 @@
import Popover from '../popover/popover.vue'
+import { ensureFinalFallback } from '../../i18n/languages.js'
import { library } from '@fortawesome/fontawesome-svg-core'
+import { faPlus, faTimes } from '@fortawesome/free-solid-svg-icons'
import { faSmileBeam } from '@fortawesome/free-regular-svg-icons'
+import { trim } from 'lodash'
-library.add(faSmileBeam)
+library.add(
+ faPlus,
+ faTimes,
+ faSmileBeam
+)
const ReactButton = {
props: ['status'],
data () {
return {
- filterWord: ''
+ filterWord: '',
+ expanded: false
}
},
components: {
@@ -24,41 +32,90 @@ const ReactButton = {
}
close()
},
+ onShow () {
+ this.expanded = true
+ this.focusInput()
+ },
+ onClose () {
+ this.expanded = false
+ },
focusInput () {
this.$nextTick(() => {
const input = this.$el.querySelector('input')
if (input) input.focus()
})
+ },
+ // Vaguely adjusted copypaste from emoji_input and emoji_picker!
+ maybeLocalizedEmojiNamesAndKeywords (emoji) {
+ const names = [emoji.displayText]
+ const keywords = []
+
+ if (emoji.displayTextI18n) {
+ names.push(this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args))
+ }
+
+ if (emoji.annotations) {
+ this.languages.forEach(lang => {
+ names.push(emoji.annotations[lang]?.name)
+
+ keywords.push(...(emoji.annotations[lang]?.keywords || []))
+ })
+ }
+
+ return {
+ names: names.filter(k => k),
+ keywords: keywords.filter(k => k)
+ }
+ },
+ maybeLocalizedEmojiName (emoji) {
+ if (!emoji.annotations) {
+ return emoji.displayText
+ }
+
+ if (emoji.displayTextI18n) {
+ return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)
+ }
+
+ for (const lang of this.languages) {
+ if (emoji.annotations[lang]?.name) {
+ return emoji.annotations[lang].name
+ }
+ }
+
+ return emoji.displayText
}
},
computed: {
commonEmojis () {
- return [
- { displayText: 'thumbsup', replacement: '👍' },
- { displayText: 'angry', replacement: '😠' },
- { displayText: 'eyes', replacement: '👀' },
- { displayText: 'joy', replacement: '😂' },
- { displayText: 'fire', replacement: 'đŸ”Ĩ' }
- ]
+ const hardcodedSet = new Set(['👍', '😠', '👀', '😂', 'đŸ”Ĩ'])
+ return this.$store.getters.standardEmojiList.filter(emoji => hardcodedSet.has(emoji.replacement))
+ },
+ languages () {
+ return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage)
},
emojis () {
if (this.filterWord !== '') {
- const filterWordLowercase = this.filterWord.toLowerCase()
- let orderedEmojiList = []
- for (const emoji of this.$store.state.instance.emoji) {
- if (emoji.replacement === this.filterWord) return [emoji]
+ const keywordLowercase = trim(this.filterWord.toLowerCase())
+
+ const orderedEmojiList = []
+ for (const emoji of this.$store.getters.standardEmojiList) {
+ const indices = this.maybeLocalizedEmojiNamesAndKeywords(emoji)
+ .keywords
+ .map(k => k.toLowerCase().indexOf(keywordLowercase))
+ .filter(k => k > -1)
+
+ const indexOfKeyword = indices.length ? Math.min(...indices) : -1
- const indexOfFilterWord = emoji.displayText.toLowerCase().indexOf(filterWordLowercase)
- if (indexOfFilterWord > -1) {
- if (!Array.isArray(orderedEmojiList[indexOfFilterWord])) {
- orderedEmojiList[indexOfFilterWord] = []
+ if (indexOfKeyword > -1) {
+ if (!Array.isArray(orderedEmojiList[indexOfKeyword])) {
+ orderedEmojiList[indexOfKeyword] = []
}
- orderedEmojiList[indexOfFilterWord].push(emoji)
+ orderedEmojiList[indexOfKeyword].push(emoji)
}
}
return orderedEmojiList.flat()
}
- return this.$store.state.instance.emoji || []
+ return this.$store.getters.standardEmojiList || []
},
mergedConfig () {
return this.$store.getters.mergedConfig
diff --git a/src/components/react_button/react_button.vue b/src/components/react_button/react_button.vue
index c69c315b..0c5fe321 100644
--- a/src/components/react_button/react_button.vue
+++ b/src/components/react_button/react_button.vue
@@ -6,14 +6,17 @@
:offset="{ y: 5 }"
:bound-to="{ x: 'container' }"
remove-padding
- @show="focusInput"
+ popover-class="ReactButton popover-default"
+ @show="onShow"
+ @close="onClose"
>
- <template v-slot:content="{close}">
+ <template #content="{close}">
<div class="reaction-picker-filter">
<input
v-model="filterWord"
size="1"
:placeholder="$t('emoji.search_emoji')"
+ @input="$event.target.composing = false"
>
</div>
<div class="reaction-picker">
@@ -21,7 +24,7 @@
v-for="emoji in commonEmojis"
:key="emoji.replacement"
class="emoji-button"
- :title="emoji.displayText"
+ :title="maybeLocalizedEmojiName(emoji)"
@click="addReaction($event, emoji.replacement, close)"
>
{{ emoji.replacement }}
@@ -31,7 +34,7 @@
v-for="(emoji, key) in emojis"
:key="key"
class="emoji-button"
- :title="emoji.displayText"
+ :title="maybeLocalizedEmojiName(emoji)"
@click="addReaction($event, emoji.replacement, close)"
>
{{ emoji.replacement }}
@@ -39,24 +42,39 @@
<div class="reaction-bottom-fader" />
</div>
</template>
- <template v-slot:trigger>
- <button
+ <template #trigger>
+ <span
class="button-unstyled popover-trigger"
:title="$t('tool_tip.add_reaction')"
>
- <FAIcon
- class="fa-scale-110 fa-old-padding"
- :icon="['far', 'smile-beam']"
- />
- </button>
+ <FALayers>
+ <FAIcon
+ class="fa-scale-110 fa-old-padding"
+ :icon="['far', 'smile-beam']"
+ />
+ <FAIcon
+ v-show="!expanded"
+ class="focus-marker"
+ transform="shrink-6 up-9 right-17"
+ icon="plus"
+ />
+ <FAIcon
+ v-show="expanded"
+ class="focus-marker"
+ transform="shrink-6 up-9 right-17"
+ icon="times"
+ />
+ </FALayers>
+ </span>
</template>
</Popover>
</template>
-<script src="./react_button.js" ></script>
+<script src="./react_button.js"></script>
<style lang="scss">
@import '../../_variables.scss';
+@import '../../_mixins.scss';
.ReactButton {
.reaction-picker-filter {
@@ -101,7 +119,7 @@
cursor: pointer;
flex-basis: 20%;
- line-height: 1.5em;
+ line-height: 1.5;
align-content: center;
&:hover {
@@ -123,6 +141,21 @@
color: $fallback--text;
color: var(--text, $fallback--text);
}
+
+ }
+
+ .popover-trigger-button {
+ @include unfocused-style {
+ .focus-marker {
+ visibility: hidden;
+ }
+ }
+
+ @include focused-style {
+ .focus-marker {
+ visibility: visible;
+ }
+ }
}
}
diff --git a/src/components/registration/registration.js b/src/components/registration/registration.js
index 1ac8e8be..6eb316d0 100644
--- a/src/components/registration/registration.js
+++ b/src/components/registration/registration.js
@@ -1,9 +1,11 @@
-import { validationMixin } from 'vuelidate'
-import { required, requiredIf, sameAs } from 'vuelidate/lib/validators'
+import useVuelidate from '@vuelidate/core'
+import { required, requiredIf, sameAs } from '@vuelidate/validators'
import { mapActions, mapState } from 'vuex'
+import InterfaceLanguageSwitcher from '../interface_language_switcher/interface_language_switcher.vue'
+import localeService from '../../services/locale/locale.service.js'
const registration = {
- mixins: [validationMixin],
+ setup () { return { v$: useVuelidate() } },
data: () => ({
user: {
email: '',
@@ -11,10 +13,14 @@ const registration = {
username: '',
password: '',
confirm: '',
- reason: ''
+ reason: '',
+ language: ''
},
captcha: {}
}),
+ components: {
+ InterfaceLanguageSwitcher
+ },
validations () {
return {
user: {
@@ -24,9 +30,10 @@ const registration = {
password: { required },
confirm: {
required,
- sameAsPassword: sameAs('password')
+ sameAs: sameAs(this.user.password)
},
- reason: { required: requiredIf(() => this.accountApprovalRequired) }
+ reason: { required: requiredIf(() => this.accountApprovalRequired) },
+ language: {}
}
}
},
@@ -64,10 +71,13 @@ const registration = {
this.user.captcha_solution = this.captcha.solution
this.user.captcha_token = this.captcha.token
this.user.captcha_answer_data = this.captcha.answer_data
+ if (this.user.language) {
+ this.user.language = localeService.internalToBackendLocale(this.user.language)
+ }
- this.$v.$touch()
+ this.v$.$touch()
- if (!this.$v.$invalid) {
+ if (!this.v$.$invalid) {
try {
await this.signUp(this.user)
this.$router.push({ name: 'friends' })
diff --git a/src/components/registration/registration.vue b/src/components/registration/registration.vue
index 65b4bb33..24d9b59b 100644
--- a/src/components/registration/registration.vue
+++ b/src/components/registration/registration.vue
@@ -12,7 +12,7 @@
<div class="text-fields">
<div
class="form-group"
- :class="{ 'form-group--error': $v.user.username.$error }"
+ :class="{ 'form-group--error': v$.user.username.$error }"
>
<label
class="form--label"
@@ -20,18 +20,19 @@
>{{ $t('login.username') }}</label>
<input
id="sign-up-username"
- v-model.trim="$v.user.username.$model"
+ v-model.trim="v$.user.username.$model"
:disabled="isPending"
class="form-control"
+ :aria-required="true"
:placeholder="$t('registration.username_placeholder')"
>
</div>
<div
- v-if="$v.user.username.$dirty"
+ v-if="v$.user.username.$dirty"
class="form-error"
>
<ul>
- <li v-if="!$v.user.username.required">
+ <li v-if="!v$.user.username.required">
<span>{{ $t('registration.validations.username_required') }}</span>
</li>
</ul>
@@ -39,7 +40,7 @@
<div
class="form-group"
- :class="{ 'form-group--error': $v.user.fullname.$error }"
+ :class="{ 'form-group--error': v$.user.fullname.$error }"
>
<label
class="form--label"
@@ -47,18 +48,19 @@
>{{ $t('registration.fullname') }}</label>
<input
id="sign-up-fullname"
- v-model.trim="$v.user.fullname.$model"
+ v-model.trim="v$.user.fullname.$model"
:disabled="isPending"
class="form-control"
+ :aria-required="true"
:placeholder="$t('registration.fullname_placeholder')"
>
</div>
<div
- v-if="$v.user.fullname.$dirty"
+ v-if="v$.user.fullname.$dirty"
class="form-error"
>
<ul>
- <li v-if="!$v.user.fullname.required">
+ <li v-if="!v$.user.fullname.required">
<span>{{ $t('registration.validations.fullname_required') }}</span>
</li>
</ul>
@@ -66,26 +68,27 @@
<div
class="form-group"
- :class="{ 'form-group--error': $v.user.email.$error }"
+ :class="{ 'form-group--error': v$.user.email.$error }"
>
<label
class="form--label"
for="email"
- >{{ $t('registration.email') }}</label>
+ >{{ accountActivationRequired ? $t('registration.email') : $t('registration.email_optional') }}</label>
<input
id="email"
- v-model="$v.user.email.$model"
+ v-model="v$.user.email.$model"
:disabled="isPending"
class="form-control"
type="email"
+ :aria-required="accountActivationRequired"
>
</div>
<div
- v-if="$v.user.email.$dirty"
+ v-if="v$.user.email.$dirty"
class="form-error"
>
<ul>
- <li v-if="!$v.user.email.required">
+ <li v-if="!v$.user.email.required">
<span>{{ $t('registration.validations.email_required') }}</span>
</li>
</ul>
@@ -95,7 +98,7 @@
<label
class="form--label"
for="bio"
- >{{ $t('registration.bio') }} ({{ $t('general.optional') }})</label>
+ >{{ $t('registration.bio_optional') }}</label>
<textarea
id="bio"
v-model="user.bio"
@@ -107,7 +110,7 @@
<div
class="form-group"
- :class="{ 'form-group--error': $v.user.password.$error }"
+ :class="{ 'form-group--error': v$.user.password.$error }"
>
<label
class="form--label"
@@ -119,14 +122,15 @@
:disabled="isPending"
class="form-control"
type="password"
+ :aria-required="true"
>
</div>
<div
- v-if="$v.user.password.$dirty"
+ v-if="v$.user.password.$dirty"
class="form-error"
>
<ul>
- <li v-if="!$v.user.password.required">
+ <li v-if="!v$.user.password.required">
<span>{{ $t('registration.validations.password_required') }}</span>
</li>
</ul>
@@ -134,7 +138,7 @@
<div
class="form-group"
- :class="{ 'form-group--error': $v.user.confirm.$error }"
+ :class="{ 'form-group--error': v$.user.confirm.$error }"
>
<label
class="form--label"
@@ -146,23 +150,36 @@
:disabled="isPending"
class="form-control"
type="password"
+ :aria-required="true"
>
</div>
<div
- v-if="$v.user.confirm.$dirty"
+ v-if="v$.user.confirm.$dirty"
class="form-error"
>
<ul>
- <li v-if="!$v.user.confirm.required">
+ <li v-if="v$.user.confirm.required.$invalid">
<span>{{ $t('registration.validations.password_confirmation_required') }}</span>
</li>
- <li v-if="!$v.user.confirm.sameAsPassword">
+ <li v-if="v$.user.confirm.sameAs.$invalid">
<span>{{ $t('registration.validations.password_confirmation_match') }}</span>
</li>
</ul>
</div>
<div
+ class="form-group"
+ :class="{ 'form-group--error': v$.user.language.$error }"
+ >
+ <interface-language-switcher
+ for="email-language"
+ :prompt-text="$t('registration.email_language')"
+ :language="v$.user.language.$model"
+ :set-language="val => v$.user.language.$model = val"
+ />
+ </div>
+
+ <div
v-if="accountApprovalRequired"
class="form-group"
>
@@ -271,7 +288,10 @@ $validations-cRed: #f04124;
.container {
display: flex;
flex-direction: row;
- //margin-bottom: 1em;
+
+ > * {
+ min-width: 0;
+ }
}
.terms-of-service {
@@ -294,8 +314,8 @@ $validations-cRed: #f04124;
.form-group {
display: flex;
flex-direction: column;
- padding: 0.3em 0.0em 0.3em;
- line-height:24px;
+ padding: 0.3em 0;
+ line-height: 2;
margin-bottom: 1em;
}
@@ -315,7 +335,7 @@ $validations-cRed: #f04124;
text-align: left;
span {
- font-size: 12px;
+ font-size: 0.85em;
}
}
@@ -341,7 +361,7 @@ $validations-cRed: #f04124;
.btn {
margin-top: 0.6em;
- height: 28px;
+ height: 2em;
}
.error {
diff --git a/src/components/remote_follow/remote_follow.js b/src/components/remote_follow/remote_follow.js
index 461d58c9..56b264fc 100644
--- a/src/components/remote_follow/remote_follow.js
+++ b/src/components/remote_follow/remote_follow.js
@@ -1,5 +1,5 @@
export default {
- props: [ 'user' ],
+ props: ['user'],
computed: {
subscribeUrl () {
// eslint-disable-next-line no-undef
diff --git a/src/components/remote_follow/remote_follow.vue b/src/components/remote_follow/remote_follow.vue
index be827400..e17aa2e9 100644
--- a/src/components/remote_follow/remote_follow.vue
+++ b/src/components/remote_follow/remote_follow.vue
@@ -32,7 +32,7 @@
.remote-button {
width: 100%;
- min-height: 28px;
+ min-height: 2em;
}
}
</style>
diff --git a/src/components/remove_follower_button/remove_follower_button.js b/src/components/remove_follower_button/remove_follower_button.js
new file mode 100644
index 00000000..e1a7531b
--- /dev/null
+++ b/src/components/remove_follower_button/remove_follower_button.js
@@ -0,0 +1,25 @@
+export default {
+ props: ['relationship'],
+ data () {
+ return {
+ inProgress: false
+ }
+ },
+ computed: {
+ label () {
+ if (this.inProgress) {
+ return this.$t('user_card.follow_progress')
+ } else {
+ return this.$t('user_card.remove_follower')
+ }
+ }
+ },
+ methods: {
+ onClick () {
+ this.inProgress = true
+ this.$store.dispatch('removeUserFromFollowers', this.relationship.id).then(() => {
+ this.inProgress = false
+ })
+ }
+ }
+}
diff --git a/src/components/remove_follower_button/remove_follower_button.vue b/src/components/remove_follower_button/remove_follower_button.vue
new file mode 100644
index 00000000..a3a4c242
--- /dev/null
+++ b/src/components/remove_follower_button/remove_follower_button.vue
@@ -0,0 +1,13 @@
+<template>
+ <button
+ class="btn button-default follow-button"
+ :class="{ toggled: inProgress }"
+ :disabled="inProgress"
+ :title="$t('user_card.remove_follower')"
+ @click="onClick"
+ >
+ {{ label }}
+ </button>
+</template>
+
+<script src="./remove_follower_button.js"></script>
diff --git a/src/components/reply_button/reply_button.js b/src/components/reply_button/reply_button.js
index c7bd2a2b..543d25ac 100644
--- a/src/components/reply_button/reply_button.js
+++ b/src/components/reply_button/reply_button.js
@@ -1,7 +1,15 @@
import { library } from '@fortawesome/fontawesome-svg-core'
-import { faReply } from '@fortawesome/free-solid-svg-icons'
+import {
+ faReply,
+ faPlus,
+ faTimes
+} from '@fortawesome/free-solid-svg-icons'
-library.add(faReply)
+library.add(
+ faReply,
+ faPlus,
+ faTimes
+)
const ReplyButton = {
name: 'ReplyButton',
@@ -9,6 +17,9 @@ const ReplyButton = {
computed: {
loggedIn () {
return !!this.$store.state.users.currentUser
+ },
+ remoteInteractionLink () {
+ return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
}
}
}
diff --git a/src/components/reply_button/reply_button.vue b/src/components/reply_button/reply_button.vue
index c17041da..dada511b 100644
--- a/src/components/reply_button/reply_button.vue
+++ b/src/components/reply_button/reply_button.vue
@@ -7,18 +7,38 @@
:title="$t('tool_tip.reply')"
@click.prevent="$emit('toggle')"
>
- <FAIcon
- class="fa-scale-110 fa-old-padding"
- icon="reply"
- />
+ <FALayers class="fa-old-padding-layer">
+ <FAIcon
+ class="fa-scale-110"
+ icon="reply"
+ />
+ <FAIcon
+ v-if="!replying"
+ class="focus-marker"
+ transform="shrink-6 up-8 right-11"
+ icon="plus"
+ />
+ <FAIcon
+ v-else
+ class="focus-marker"
+ transform="shrink-6 up-8 right-11"
+ icon="times"
+ />
+ </FALayers>
</button>
- <span v-else>
+ <a
+ v-else
+ class="button-unstyled interactive"
+ target="_blank"
+ role="button"
+ :href="remoteInteractionLink"
+ >
<FAIcon
icon="reply"
class="fa-scale-110 fa-old-padding"
:title="$t('tool_tip.reply')"
/>
- </span>
+ </a>
<span
v-if="status.replies_count > 0"
class="action-counter"
@@ -32,6 +52,7 @@
<style lang="scss">
@import '../../_variables.scss';
+@import '../../_mixins.scss';
.ReplyButton {
display: flex;
@@ -52,6 +73,18 @@
color: $fallback--cBlue;
color: var(--cBlue, $fallback--cBlue);
}
+
+ @include unfocused-style {
+ .focus-marker {
+ visibility: hidden;
+ }
+ }
+
+ @include focused-style {
+ .focus-marker {
+ visibility: visible;
+ }
+ }
}
}
diff --git a/src/components/report/report.js b/src/components/report/report.js
new file mode 100644
index 00000000..76055764
--- /dev/null
+++ b/src/components/report/report.js
@@ -0,0 +1,34 @@
+import Select from '../select/select.vue'
+import StatusContent from '../status_content/status_content.vue'
+import Timeago from '../timeago/timeago.vue'
+import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+
+const Report = {
+ props: [
+ 'reportId'
+ ],
+ components: {
+ Select,
+ StatusContent,
+ Timeago
+ },
+ computed: {
+ report () {
+ return this.$store.state.reports.reports[this.reportId] || {}
+ },
+ state: {
+ get: function () { return this.report.state },
+ set: function (val) { this.setReportState(val) }
+ }
+ },
+ methods: {
+ generateUserProfileLink (user) {
+ return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
+ },
+ setReportState (state) {
+ return this.$store.dispatch('setReportState', { id: this.report.id, state })
+ }
+ }
+}
+
+export default Report
diff --git a/src/components/report/report.scss b/src/components/report/report.scss
new file mode 100644
index 00000000..578b4eb1
--- /dev/null
+++ b/src/components/report/report.scss
@@ -0,0 +1,43 @@
+@import '../../_variables.scss';
+
+.Report {
+ .report-content {
+ margin: 0.5em 0 1em;
+ }
+
+ .report-state {
+ margin: 0.5em 0 1em;
+ }
+
+ .reported-status {
+ border: 1px solid $fallback--faint;
+ border-color: var(--faint, $fallback--faint);
+ border-radius: $fallback--inputRadius;
+ border-radius: var(--inputRadius, $fallback--inputRadius);
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ display: block;
+ padding: 0.5em;
+ margin: 0.5em 0;
+
+ .status-content {
+ pointer-events: none;
+ }
+
+ .reported-status-heading {
+ display: flex;
+ width: 100%;
+ justify-content: space-between;
+ margin-bottom: 0.2em;
+ }
+
+ .reported-status-name {
+ font-weight: bold;
+ }
+ }
+
+ .note {
+ width: 100%;
+ margin-bottom: 0.5em;
+ }
+}
diff --git a/src/components/report/report.vue b/src/components/report/report.vue
new file mode 100644
index 00000000..1f19cc25
--- /dev/null
+++ b/src/components/report/report.vue
@@ -0,0 +1,74 @@
+<template>
+ <div class="Report">
+ <div class="reported-user">
+ <span>{{ $t('report.reported_user') }}</span>
+ <router-link :to="generateUserProfileLink(report.acct)">
+ @{{ report.acct.screen_name }}
+ </router-link>
+ </div>
+ <div class="reporter">
+ <span>{{ $t('report.reporter') }}</span>
+ <router-link :to="generateUserProfileLink(report.actor)">
+ @{{ report.actor.screen_name }}
+ </router-link>
+ </div>
+ <div class="report-state">
+ <span>{{ $t('report.state') }}</span>
+ <Select
+ :id="report-state"
+ v-model="state"
+ class="form-control"
+ >
+ <option
+ v-for="state in ['open', 'closed', 'resolved']"
+ :key="state"
+ :value="state"
+ >
+ {{ $t('report.state_' + state) }}
+ </option>
+ </Select>
+ </div>
+ <RichContent
+ class="report-content"
+ :html="report.content"
+ :emoji="[]"
+ />
+ <div v-if="report.statuses.length">
+ <small>{{ $t('report.reported_statuses') }}</small>
+ <router-link
+ v-for="status in report.statuses"
+ :key="status.id"
+ :to="{ name: 'conversation', params: { id: status.id } }"
+ class="reported-status"
+ >
+ <div class="reported-status-heading">
+ <span class="reported-status-name">{{ status.user.name }}</span>
+ <Timeago
+ :time="status.created_at"
+ :auto-update="240"
+ class="faint"
+ />
+ </div>
+ <status-content :status="status" />
+ </router-link>
+ </div>
+ <div v-if="report.notes.length">
+ <small>{{ $t('report.notes') }}</small>
+ <div
+ v-for="note in report.notes"
+ :key="note.id"
+ class="note"
+ >
+ <span>{{ note.content }}</span>
+ <Timeago
+ :time="note.created_at"
+ :auto-update="240"
+ class="faint"
+ />
+ </div>
+ </div>
+ </div>
+</template>
+
+<script src="./report.js"></script>
+<style src="./report.scss" lang="scss"></style>
diff --git a/src/components/retweet_button/retweet_button.js b/src/components/retweet_button/retweet_button.js
index 2103fd0b..4d92b5fa 100644
--- a/src/components/retweet_button/retweet_button.js
+++ b/src/components/retweet_button/retweet_button.js
@@ -1,7 +1,17 @@
import { library } from '@fortawesome/fontawesome-svg-core'
-import { faRetweet } from '@fortawesome/free-solid-svg-icons'
+import {
+ faRetweet,
+ faPlus,
+ faMinus,
+ faCheck
+} from '@fortawesome/free-solid-svg-icons'
-library.add(faRetweet)
+library.add(
+ faRetweet,
+ faPlus,
+ faMinus,
+ faCheck
+)
const RetweetButton = {
props: ['status', 'loggedIn', 'visibility'],
@@ -26,6 +36,9 @@ const RetweetButton = {
computed: {
mergedConfig () {
return this.$store.getters.mergedConfig
+ },
+ remoteInteractionLink () {
+ return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
}
}
}
diff --git a/src/components/retweet_button/retweet_button.vue b/src/components/retweet_button/retweet_button.vue
index 859ce499..240828e3 100644
--- a/src/components/retweet_button/retweet_button.vue
+++ b/src/components/retweet_button/retweet_button.vue
@@ -7,11 +7,31 @@
:title="$t('tool_tip.repeat')"
@click.prevent="retweet()"
>
- <FAIcon
- class="fa-scale-110 fa-old-padding"
- icon="retweet"
- :spin="animated"
- />
+ <FALayers class="fa-old-padding-layer">
+ <FAIcon
+ class="fa-scale-110"
+ icon="retweet"
+ :spin="animated"
+ />
+ <FAIcon
+ v-if="status.repeated"
+ class="active-marker"
+ transform="shrink-6 up-9 right-12"
+ icon="check"
+ />
+ <FAIcon
+ v-if="!status.repeated"
+ class="focus-marker"
+ transform="shrink-6 up-9 right-12"
+ icon="plus"
+ />
+ <FAIcon
+ v-else
+ class="focus-marker"
+ transform="shrink-6 up-9 right-12"
+ icon="minus"
+ />
+ </FALayers>
</button>
<span v-else-if="loggedIn">
<FAIcon
@@ -20,13 +40,19 @@
:title="$t('timeline.no_retweet_hint')"
/>
</span>
- <span v-else>
+ <a
+ v-else
+ class="button-unstyled interactive"
+ target="_blank"
+ role="button"
+ :href="remoteInteractionLink"
+ >
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="retweet"
:title="$t('tool_tip.repeat')"
/>
- </span>
+ </a>
<span
v-if="!mergedConfig.hidePostStats && status.repeat_num > 0"
class="no-event"
@@ -36,10 +62,11 @@
</div>
</template>
-<script src="./retweet_button.js" ></script>
+<script src="./retweet_button.js"></script>
<style lang="scss">
@import '../../_variables.scss';
+@import '../../_mixins.scss';
.RetweetButton {
display: flex;
@@ -64,6 +91,26 @@
color: $fallback--cGreen;
color: var(--cGreen, $fallback--cGreen);
}
+
+ @include unfocused-style {
+ .focus-marker {
+ visibility: hidden;
+ }
+
+ .active-marker {
+ visibility: visible;
+ }
+ }
+
+ @include focused-style {
+ .focus-marker {
+ visibility: visible;
+ }
+
+ .active-marker {
+ visibility: hidden;
+ }
+ }
}
}
</style>
diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index c0d20c5e..7881e365 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -1,4 +1,3 @@
-import Vue from 'vue'
import { unescape, flattenDeep } from 'lodash'
import { getTagName, processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js'
import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js'
@@ -27,8 +26,12 @@ import './rich_content.scss'
*
* Apart from that one small hiccup with emit in render this _should_ be vue3-ready
*/
-export default Vue.component('RichContent', {
+export default {
name: 'RichContent',
+ components: {
+ MentionsLine,
+ HashtagLink
+ },
props: {
// Original html content
html: {
@@ -58,7 +61,7 @@ export default Vue.component('RichContent', {
}
},
// NEVER EVER TOUCH DATA INSIDE RENDER
- render (h) {
+ render () {
// Pre-process HTML
const { newHtml: html } = preProcessPerLine(this.html, this.greentext)
let currentMentions = null // Current chain of mentions, we group all mentions together
@@ -76,18 +79,19 @@ export default Vue.component('RichContent', {
const renderImage = (tag) => {
return <StillImage
- {...{ attrs: getAttrs(tag) }}
+ {...getAttrs(tag)}
class="img"
/>
}
const renderHashtag = (attrs, children, encounteredTextReverse) => {
- const linkData = getLinkData(attrs, children, tagsIndex++)
+ const { index, ...linkData } = getLinkData(attrs, children, tagsIndex++)
writtenTags.push(linkData)
if (!encounteredTextReverse) {
lastTags.push(linkData)
}
- return <HashtagLink {...{ props: linkData }}/>
+ const { url, tag, content } = linkData
+ return <HashtagLink url={url} tag={tag} content={content}/>
}
const renderMention = (attrs, children) => {
@@ -120,7 +124,8 @@ export default Vue.component('RichContent', {
// don't include spaces when processing mentions - we'll include them
// in MentionsLine
lastSpacing = item
- return currentMentions !== null ? item.trim() : item
+ // Don't remove last space in a container (fixes poast mentions)
+ return (index !== array.length - 1) && (currentMentions !== null) ? item.trim() : item
}
currentMentions = null
@@ -145,6 +150,7 @@ export default Vue.component('RichContent', {
if (Array.isArray(item)) {
const [opener, children, closer] = item
const Tag = getTagName(opener)
+ const fullAttrs = getAttrs(opener, () => true)
const attrs = getAttrs(opener)
const previouslyMentions = currentMentions !== null
/* During grouping of mentions we trim all the empty text elements
@@ -166,7 +172,7 @@ export default Vue.component('RichContent', {
return ['', [mentionsLinePadding, renderImage(opener)], '']
case 'a': // replace mentions with MentionLink
if (!this.handleLinks) break
- if (attrs['class'] && attrs['class'].includes('mention')) {
+ if (fullAttrs.class && fullAttrs.class.includes('mention')) {
// Handling mentions here
return renderMention(attrs, children)
} else {
@@ -174,7 +180,7 @@ export default Vue.component('RichContent', {
break
}
case 'span':
- if (this.handleLinks && attrs['class'] && attrs['class'].includes('h-card')) {
+ if (this.handleLinks && fullAttrs.class && fullAttrs.class.includes('h-card')) {
return ['', children.map(processItem), '']
}
}
@@ -208,23 +214,25 @@ export default Vue.component('RichContent', {
const [opener, children] = item
const Tag = opener === '' ? '' : getTagName(opener)
switch (Tag) {
- case 'a': // replace mentions with MentionLink
+ case 'a': { // replace mentions with MentionLink
if (!this.handleLinks) break
- const attrs = getAttrs(opener)
+ const fullAttrs = getAttrs(opener, () => true)
+ const attrs = getAttrs(opener, () => true)
// should only be this
if (
- (attrs['class'] && attrs['class'].includes('hashtag')) || // Pleroma style
- (attrs['rel'] === 'tag') // Mastodon style
+ (fullAttrs.class && fullAttrs.class.includes('hashtag')) || // Pleroma style
+ (fullAttrs.rel === 'tag') // Mastodon style
) {
return renderHashtag(attrs, children, encounteredTextReverse)
} else {
attrs.target = '_blank'
const newChildren = [...children].reverse().map(processItemReverse).reverse()
- return <a {...{ attrs }}>
+ return <a {...attrs}>
{ newChildren }
</a>
}
+ }
case '':
return [...children].reverse().map(processItemReverse).reverse()
}
@@ -234,7 +242,7 @@ export default Vue.component('RichContent', {
const newChildren = Array.isArray(children)
? [...children].reverse().map(processItemReverse).reverse()
: children
- return <Tag {...{ attrs: getAttrs(opener) }}>
+ return <Tag {...getAttrs(opener)}>
{ newChildren }
</Tag>
} else {
@@ -265,7 +273,7 @@ export default Vue.component('RichContent', {
return result
}
-})
+}
const getLinkData = (attrs, children, index) => {
const stripTags = (item) => {
diff --git a/src/components/scope_selector/scope_selector.vue b/src/components/scope_selector/scope_selector.vue
index a01242fc..f3bee183 100644
--- a/src/components/scope_selector/scope_selector.vue
+++ b/src/components/scope_selector/scope_selector.vue
@@ -16,6 +16,7 @@
class="fa-scale-110 fa-old-padding"
/>
</button>
+ {{ ' ' }}
<button
v-if="showPrivate"
class="button-unstyled scope"
@@ -29,6 +30,7 @@
class="fa-scale-110 fa-old-padding"
/>
</button>
+ {{ ' ' }}
<button
v-if="showUnlisted"
class="button-unstyled scope"
@@ -42,6 +44,7 @@
class="fa-scale-110 fa-old-padding"
/>
</button>
+ {{ ' ' }}
<button
v-if="showPublic"
class="button-unstyled scope"
diff --git a/src/components/search/search.js b/src/components/search/search.js
index b62bc2c5..877d6f30 100644
--- a/src/components/search/search.js
+++ b/src/components/search/search.js
@@ -1,12 +1,14 @@
import FollowCard from '../follow_card/follow_card.vue'
import Conversation from '../conversation/conversation.vue'
import Status from '../status/status.vue'
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import map from 'lodash/map'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faCircleNotch,
faSearch
} from '@fortawesome/free-solid-svg-icons'
+import { uniqBy } from 'lodash'
library.add(
faCircleNotch,
@@ -17,7 +19,8 @@ const Search = {
components: {
FollowCard,
Conversation,
- Status
+ Status,
+ TabSwitcher
},
props: [
'query'
@@ -30,7 +33,11 @@ const Search = {
userIds: [],
statuses: [],
hashtags: [],
- currenResultTab: 'statuses'
+ currenResultTab: 'statuses',
+
+ statusesOffset: 0,
+ lastStatusFetchCount: 0,
+ lastQuery: ''
}
},
computed: {
@@ -59,26 +66,42 @@ const Search = {
this.$router.push({ name: 'search', query: { query } })
this.$refs.searchInput.focus()
},
- search (query) {
+ search (query, searchType = null) {
if (!query) {
this.loading = false
return
}
this.loading = true
- this.userIds = []
- this.statuses = []
- this.hashtags = []
this.$refs.searchInput.blur()
+ if (this.lastQuery !== query) {
+ this.userIds = []
+ this.hashtags = []
+ this.statuses = []
+
+ this.statusesOffset = 0
+ this.lastStatusFetchCount = 0
+ }
- this.$store.dispatch('search', { q: query, resolve: true })
+ this.$store.dispatch('search', { q: query, resolve: true, offset: this.statusesOffset, type: searchType })
.then(data => {
this.loading = false
- this.userIds = map(data.accounts, 'id')
- this.statuses = data.statuses
- this.hashtags = data.hashtags
+
+ const oldLength = this.statuses.length
+
+ // Always append to old results. If new results are empty, this doesn't change anything
+ this.userIds = this.userIds.concat(map(data.accounts, 'id'))
+ this.statuses = uniqBy(this.statuses.concat(data.statuses), 'id')
+ this.hashtags = this.hashtags.concat(data.hashtags)
+
this.currenResultTab = this.getActiveTab()
this.loaded = true
+
+ // Offset from whatever we already have
+ this.statusesOffset = this.statuses.length
+ // Because the amount of new statuses can actually be zero, compare to old lenght instead
+ this.lastStatusFetchCount = this.statuses.length - oldLength
+ this.lastQuery = query
})
},
resultCount (tabName) {
diff --git a/src/components/search/search.vue b/src/components/search/search.vue
index b7bfc1f3..6fc6a0de 100644
--- a/src/components/search/search.vue
+++ b/src/components/search/search.vue
@@ -22,7 +22,7 @@
</button>
</div>
<div
- v-if="loading"
+ v-if="loading && statusesOffset == 0"
class="text-center loading-icon"
>
<FAIcon
@@ -55,12 +55,6 @@
</div>
<div class="panel-body">
<div v-if="currenResultTab === 'statuses'">
- <div
- v-if="visibleStatuses.length === 0 && !loading && loaded"
- class="search-result-heading"
- >
- <h4>{{ $t('search.no_results') }}</h4>
- </div>
<Status
v-for="status in visibleStatuses"
:key="status.id"
@@ -71,6 +65,33 @@
:statusoid="status"
:no-heading="false"
/>
+ <button
+ v-if="!loading && loaded && lastStatusFetchCount > 0"
+ class="more-statuses-button button-unstyled -link -fullwidth"
+ @click.prevent="search(searchTerm, 'statuses')"
+ >
+ <div class="new-status-notification text-center">
+ {{ $t('search.load_more') }}
+ </div>
+ </button>
+ <div
+ v-else-if="loading && statusesOffset > 0"
+ class="text-center loading-icon"
+ >
+ <FAIcon
+ icon="circle-notch"
+ spin
+ size="lg"
+ />
+ </div>
+ <div
+ v-if="(visibleStatuses.length === 0 || lastStatusFetchCount === 0) && !loading && loaded"
+ class="search-result-heading"
+ >
+ <h4>
+ {{ visibleStatuses.length === 0 ? $t('search.no_results') : $t('search.no_more_results') }}
+ </h4>
+ </div>
</div>
<div v-else-if="currenResultTab === 'people'">
<div
@@ -208,6 +229,11 @@
color: $fallback--text;
color: var(--text, $fallback--text);
}
-}
+ }
+
+ .more-statuses-button {
+ height: 3.5em;
+ line-height: 3.5em;
+ }
</style>
diff --git a/src/components/search_bar/search_bar.js b/src/components/search_bar/search_bar.js
index 551649c7..3b297f09 100644
--- a/src/components/search_bar/search_bar.js
+++ b/src/components/search_bar/search_bar.js
@@ -16,7 +16,7 @@ const SearchBar = {
error: false
}),
watch: {
- '$route': function (route) {
+ $route: function (route) {
if (route.name === 'search') {
this.searchTerm = route.query.query
}
diff --git a/src/components/search_bar/search_bar.vue b/src/components/search_bar/search_bar.vue
index 222f57ba..199a7500 100644
--- a/src/components/search_bar/search_bar.vue
+++ b/src/components/search_bar/search_bar.vue
@@ -47,6 +47,8 @@
class="cancel-icon fa-scale-110 fa-old-padding"
/>
</button>
+ <span class="spacer" />
+ <span class="spacer" />
</template>
</div>
</template>
diff --git a/src/components/select/select.js b/src/components/select/select.js
index 49535d07..ec571a14 100644
--- a/src/components/select/select.js
+++ b/src/components/select/select.js
@@ -8,12 +8,9 @@ library.add(
)
export default {
- model: {
- prop: 'value',
- event: 'change'
- },
+ emits: ['update:modelValue'],
props: [
- 'value',
+ 'modelValue',
'disabled',
'unstyled',
'kind'
diff --git a/src/components/select/select.vue b/src/components/select/select.vue
index 5ade1fa6..92493b0b 100644
--- a/src/components/select/select.vue
+++ b/src/components/select/select.vue
@@ -1,4 +1,3 @@
-
<template>
<label
class="Select input"
@@ -6,11 +5,12 @@
>
<select
:disabled="disabled"
- :value="value"
- @change="$emit('change', $event.target.value)"
+ :value="modelValue"
+ @change="$emit('update:modelValue', $event.target.value)"
>
<slot />
</select>
+ {{ ' ' }}
<FAIcon
class="select-down-icon"
icon="chevron-down"
@@ -23,7 +23,8 @@
<style lang="scss">
@import '../../_variables.scss';
-.Select {
+/* TODO fix order of styles */
+label.Select {
padding: 0;
select {
@@ -38,10 +39,10 @@
padding: 0 2em 0 .2em;
font-family: sans-serif;
font-family: var(--inputFont, sans-serif);
- font-size: 14px;
+ font-size: 1em;
width: 100%;
z-index: 1;
- height: 28px;
+ height: 2em;
line-height: 16px;
}
@@ -51,9 +52,10 @@
bottom: 0;
right: 5px;
height: 100%;
+ width: 0.875em;
color: $fallback--text;
color: var(--inputText, $fallback--text);
- line-height: 28px;
+ line-height: 2;
z-index: 0;
pointer-events: none;
}
diff --git a/src/components/selectable_list/selectable_list.vue b/src/components/selectable_list/selectable_list.vue
index 3f885881..1f7683ab 100644
--- a/src/components/selectable_list/selectable_list.vue
+++ b/src/components/selectable_list/selectable_list.vue
@@ -6,9 +6,9 @@
>
<div class="selectable-list-checkbox-wrapper">
<Checkbox
- :checked="allSelected"
+ :model-value="allSelected"
:indeterminate="someSelected"
- @change="toggleAll"
+ @update:model-value="toggleAll"
>
{{ $t('selectable_list.select_all') }}
</Checkbox>
@@ -24,15 +24,15 @@
:items="items"
:get-key="getKey"
>
- <template v-slot:item="{item}">
+ <template #item="{item}">
<div
class="selectable-list-item-inner"
:class="{ 'selectable-list-item-selected-inner': isSelected(item) }"
>
<div class="selectable-list-checkbox-wrapper">
<Checkbox
- :checked="isSelected(item)"
- @change="checked => toggle(checked, item)"
+ :model-value="isSelected(item)"
+ @update:model-value="checked => toggle(checked, item)"
/>
</div>
<slot
@@ -41,7 +41,7 @@
/>
</div>
</template>
- <template v-slot:empty>
+ <template #empty>
<slot name="empty" />
</template>
</List>
diff --git a/src/components/settings_modal/helpers/boolean_setting.js b/src/components/settings_modal/helpers/boolean_setting.js
index 5c52f697..2e6992cb 100644
--- a/src/components/settings_modal/helpers/boolean_setting.js
+++ b/src/components/settings_modal/helpers/boolean_setting.js
@@ -1,14 +1,17 @@
import { get, set } from 'lodash'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ModifiedIndicator from './modified_indicator.vue'
+import ServerSideIndicator from './server_side_indicator.vue'
export default {
components: {
Checkbox,
- ModifiedIndicator
+ ModifiedIndicator,
+ ServerSideIndicator
},
props: [
'path',
- 'disabled'
+ 'disabled',
+ 'expert'
],
computed: {
pathDefault () {
@@ -26,13 +29,28 @@ export default {
defaultState () {
return get(this.$parent, this.pathDefault)
},
+ isServerSide () {
+ return this.path.startsWith('serverSide_')
+ },
isChanged () {
- return this.state !== this.defaultState
+ return !this.path.startsWith('serverSide_') && this.state !== this.defaultState
+ },
+ matchesExpertLevel () {
+ return (this.expert || 0) <= this.$parent.expertLevel
}
},
methods: {
update (e) {
+ const [firstSegment, ...rest] = this.path.split('.')
set(this.$parent, this.path, e)
+ // Updating nested properties does not trigger update on its parent.
+ // probably still not as reliable, but works for depth=1 at least
+ if (rest.length > 0) {
+ set(this.$parent, firstSegment, { ...get(this.$parent, firstSegment) })
+ }
+ },
+ reset () {
+ set(this.$parent, this.path, this.defaultState)
}
}
}
diff --git a/src/components/settings_modal/helpers/boolean_setting.vue b/src/components/settings_modal/helpers/boolean_setting.vue
index c3ee6583..41142966 100644
--- a/src/components/settings_modal/helpers/boolean_setting.vue
+++ b/src/components/settings_modal/helpers/boolean_setting.vue
@@ -1,11 +1,12 @@
<template>
<label
+ v-if="matchesExpertLevel"
class="BooleanSetting"
>
<Checkbox
- :checked="state"
+ :model-value="state"
:disabled="disabled"
- @change="update"
+ @update:modelValue="update"
>
<span
v-if="!!$slots.default"
@@ -13,7 +14,12 @@
>
<slot />
</span>
- <ModifiedIndicator :changed="isChanged" />
+ {{ ' ' }}
+ <ModifiedIndicator
+ :changed="isChanged"
+ :onclick="reset"
+ />
+ <ServerSideIndicator :server-side="isServerSide" />
</Checkbox>
</label>
</template>
diff --git a/src/components/settings_modal/helpers/choice_setting.js b/src/components/settings_modal/helpers/choice_setting.js
index a15f6bac..3da559fe 100644
--- a/src/components/settings_modal/helpers/choice_setting.js
+++ b/src/components/settings_modal/helpers/choice_setting.js
@@ -1,15 +1,18 @@
import { get, set } from 'lodash'
import Select from 'src/components/select/select.vue'
import ModifiedIndicator from './modified_indicator.vue'
+import ServerSideIndicator from './server_side_indicator.vue'
export default {
components: {
Select,
- ModifiedIndicator
+ ModifiedIndicator,
+ ServerSideIndicator
},
props: [
'path',
'disabled',
- 'options'
+ 'options',
+ 'expert'
],
computed: {
pathDefault () {
@@ -27,13 +30,22 @@ export default {
defaultState () {
return get(this.$parent, this.pathDefault)
},
+ isServerSide () {
+ return this.path.startsWith('serverSide_')
+ },
isChanged () {
- return this.state !== this.defaultState
+ return !this.path.startsWith('serverSide_') && this.state !== this.defaultState
+ },
+ matchesExpertLevel () {
+ return (this.expert || 0) <= this.$parent.expertLevel
}
},
methods: {
update (e) {
set(this.$parent, this.path, e)
+ },
+ reset () {
+ set(this.$parent, this.path, this.defaultState)
}
}
}
diff --git a/src/components/settings_modal/helpers/choice_setting.vue b/src/components/settings_modal/helpers/choice_setting.vue
index fa17661b..d141a0d6 100644
--- a/src/components/settings_modal/helpers/choice_setting.vue
+++ b/src/components/settings_modal/helpers/choice_setting.vue
@@ -1,12 +1,14 @@
<template>
<label
+ v-if="matchesExpertLevel"
class="ChoiceSetting"
>
<slot />
+ {{ ' ' }}
<Select
- :value="state"
+ :model-value="state"
:disabled="disabled"
- @change="update"
+ @update:modelValue="update"
>
<option
v-for="option in options"
@@ -17,7 +19,11 @@
{{ option.value === defaultState ? $t('settings.instance_default_simple') : '' }}
</option>
</Select>
- <ModifiedIndicator :changed="isChanged" />
+ <ModifiedIndicator
+ :changed="isChanged"
+ :onclick="reset"
+ />
+ <ServerSideIndicator :server-side="isServerSide" />
</label>
</template>
diff --git a/src/components/settings_modal/helpers/integer_setting.js b/src/components/settings_modal/helpers/integer_setting.js
new file mode 100644
index 00000000..e64d0cee
--- /dev/null
+++ b/src/components/settings_modal/helpers/integer_setting.js
@@ -0,0 +1,44 @@
+import { get, set } from 'lodash'
+import ModifiedIndicator from './modified_indicator.vue'
+export default {
+ components: {
+ ModifiedIndicator
+ },
+ props: {
+ path: String,
+ disabled: Boolean,
+ min: Number,
+ expert: [Number, String]
+ },
+ computed: {
+ pathDefault () {
+ const [firstSegment, ...rest] = this.path.split('.')
+ return [firstSegment + 'DefaultValue', ...rest].join('.')
+ },
+ state () {
+ const value = get(this.$parent, this.path)
+ if (value === undefined) {
+ return this.defaultState
+ } else {
+ return value
+ }
+ },
+ defaultState () {
+ return get(this.$parent, this.pathDefault)
+ },
+ isChanged () {
+ return this.state !== this.defaultState
+ },
+ matchesExpertLevel () {
+ return (this.expert || 0) <= this.$parent.expertLevel
+ }
+ },
+ methods: {
+ update (e) {
+ set(this.$parent, this.path, parseInt(e.target.value))
+ },
+ reset () {
+ set(this.$parent, this.path, this.defaultState)
+ }
+ }
+}
diff --git a/src/components/settings_modal/helpers/integer_setting.vue b/src/components/settings_modal/helpers/integer_setting.vue
new file mode 100644
index 00000000..695e2673
--- /dev/null
+++ b/src/components/settings_modal/helpers/integer_setting.vue
@@ -0,0 +1,27 @@
+<template>
+ <span
+ v-if="matchesExpertLevel"
+ class="IntegerSetting"
+ >
+ <label :for="path">
+ <slot />
+ </label>
+ <input
+ :id="path"
+ class="number-input"
+ type="number"
+ step="1"
+ :disabled="disabled"
+ :min="min || 0"
+ :value="state"
+ @change="update"
+ >
+ {{ ' ' }}
+ <ModifiedIndicator
+ :changed="isChanged"
+ :onclick="reset"
+ />
+ </span>
+</template>
+
+<script src="./integer_setting.js"></script>
diff --git a/src/components/settings_modal/helpers/modified_indicator.vue b/src/components/settings_modal/helpers/modified_indicator.vue
index ad212db9..8311533a 100644
--- a/src/components/settings_modal/helpers/modified_indicator.vue
+++ b/src/components/settings_modal/helpers/modified_indicator.vue
@@ -6,14 +6,14 @@
<Popover
trigger="hover"
>
- <template v-slot:trigger>
+ <template #trigger>
&nbsp;
<FAIcon
icon="wrench"
:aria-label="$t('settings.setting_changed')"
/>
</template>
- <template v-slot:content>
+ <template #content>
<div class="modified-tooltip">
{{ $t('settings.setting_changed') }}
</div>
@@ -41,11 +41,11 @@ export default {
.ModifiedIndicator {
display: inline-block;
position: relative;
+}
- .modified-tooltip {
- margin: 0.5em 1em;
- min-width: 10em;
- text-align: center;
- }
+.modified-tooltip {
+ margin: 0.5em 1em;
+ min-width: 10em;
+ text-align: center;
}
</style>
diff --git a/src/components/settings_modal/helpers/server_side_indicator.vue b/src/components/settings_modal/helpers/server_side_indicator.vue
new file mode 100644
index 00000000..bf181959
--- /dev/null
+++ b/src/components/settings_modal/helpers/server_side_indicator.vue
@@ -0,0 +1,51 @@
+<template>
+ <span
+ v-if="serverSide"
+ class="ServerSideIndicator"
+ >
+ <Popover
+ trigger="hover"
+ >
+ <template #trigger>
+ &nbsp;
+ <FAIcon
+ icon="server"
+ :aria-label="$t('settings.setting_server_side')"
+ />
+ </template>
+ <template #content>
+ <div class="serverside-tooltip">
+ {{ $t('settings.setting_server_side') }}
+ </div>
+ </template>
+ </Popover>
+ </span>
+</template>
+
+<script>
+import Popover from 'src/components/popover/popover.vue'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import { faServer } from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faServer
+)
+
+export default {
+ components: { Popover },
+ props: ['serverSide']
+}
+</script>
+
+<style lang="scss">
+.ServerSideIndicator {
+ display: inline-block;
+ position: relative;
+}
+
+.serverside-tooltip {
+ margin: 0.5em 1em;
+ min-width: 10em;
+ text-align: center;
+}
+</style>
diff --git a/src/components/settings_modal/helpers/shared_computed_object.js b/src/components/settings_modal/helpers/shared_computed_object.js
index 2c833c0c..12431dca 100644
--- a/src/components/settings_modal/helpers/shared_computed_object.js
+++ b/src/components/settings_modal/helpers/shared_computed_object.js
@@ -1,4 +1,5 @@
import { defaultState as configDefaultState } from 'src/modules/config.js'
+import { defaultState as serverSideConfigDefaultState } from 'src/modules/serverSideConfig.js'
const SharedComputedObject = () => ({
user () {
@@ -22,6 +23,14 @@ const SharedComputedObject = () => ({
}
}])
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
+ ...Object.keys(serverSideConfigDefaultState)
+ .map(key => ['serverSide_' + key, {
+ get () { return this.$store.state.serverSideConfig[key] },
+ set (value) {
+ this.$store.dispatch('setServerSideOption', { name: key, value })
+ }
+ }])
+ .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
// Special cases (need to transform values or perform actions first)
useStreamingApi: {
get () { return this.$store.getters.mergedConfig.useStreamingApi },
diff --git a/src/components/settings_modal/helpers/size_setting.js b/src/components/settings_modal/helpers/size_setting.js
new file mode 100644
index 00000000..58697412
--- /dev/null
+++ b/src/components/settings_modal/helpers/size_setting.js
@@ -0,0 +1,67 @@
+import { get, set } from 'lodash'
+import ModifiedIndicator from './modified_indicator.vue'
+import Select from 'src/components/select/select.vue'
+
+export const allCssUnits = ['cm', 'mm', 'in', 'px', 'pt', 'pc', 'em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax', '%']
+export const defaultHorizontalUnits = ['px', 'rem', 'vw']
+export const defaultVerticalUnits = ['px', 'rem', 'vh']
+
+export default {
+ components: {
+ ModifiedIndicator,
+ Select
+ },
+ props: {
+ path: String,
+ disabled: Boolean,
+ min: Number,
+ units: {
+ type: [String],
+ default: () => allCssUnits
+ },
+ expert: [Number, String]
+ },
+ computed: {
+ pathDefault () {
+ const [firstSegment, ...rest] = this.path.split('.')
+ return [firstSegment + 'DefaultValue', ...rest].join('.')
+ },
+ stateUnit () {
+ return (this.state || '').replace(/\d+/, '')
+ },
+ stateValue () {
+ return (this.state || '').replace(/\D+/, '')
+ },
+ state () {
+ const value = get(this.$parent, this.path)
+ if (value === undefined) {
+ return this.defaultState
+ } else {
+ return value
+ }
+ },
+ defaultState () {
+ return get(this.$parent, this.pathDefault)
+ },
+ isChanged () {
+ return this.state !== this.defaultState
+ },
+ matchesExpertLevel () {
+ return (this.expert || 0) <= this.$parent.expertLevel
+ }
+ },
+ methods: {
+ update (e) {
+ set(this.$parent, this.path, e)
+ },
+ reset () {
+ set(this.$parent, this.path, this.defaultState)
+ },
+ updateValue (e) {
+ set(this.$parent, this.path, parseInt(e.target.value) + this.stateUnit)
+ },
+ updateUnit (e) {
+ set(this.$parent, this.path, this.stateValue + e.target.value)
+ }
+ }
+}
diff --git a/src/components/settings_modal/helpers/size_setting.vue b/src/components/settings_modal/helpers/size_setting.vue
new file mode 100644
index 00000000..90c9f538
--- /dev/null
+++ b/src/components/settings_modal/helpers/size_setting.vue
@@ -0,0 +1,54 @@
+<template>
+ <span
+ v-if="matchesExpertLevel"
+ class="SizeSetting"
+ >
+ <label
+ :for="path"
+ class="size-label"
+ >
+ <slot />
+ </label>
+ <input
+ :id="path"
+ class="number-input"
+ type="number"
+ step="1"
+ :disabled="disabled"
+ :min="min || 0"
+ :value="stateValue"
+ @change="updateValue"
+ >
+ <Select
+ :id="path"
+ :model-value="stateUnit"
+ :disabled="disabled"
+ class="css-unit-input"
+ @change="updateUnit"
+ >
+ <option
+ v-for="option in units"
+ :key="option"
+ :value="option"
+ >
+ {{ option }}
+ </option>
+ </Select>
+ {{ ' ' }}
+ <ModifiedIndicator
+ :changed="isChanged"
+ :onclick="reset"
+ />
+ </span>
+</template>
+
+<script src="./size_setting.js"></script>
+
+<style lang="scss">
+.css-unit-input, .css-unit-input select {
+ margin-left: 0.5em;
+ width: 4em !important;
+ max-width: 4em !important;
+ min-width: 4em !important;
+}
+</style>
diff --git a/src/components/settings_modal/settings_modal.js b/src/components/settings_modal/settings_modal.js
index 04043483..0a72dca1 100644
--- a/src/components/settings_modal/settings_modal.js
+++ b/src/components/settings_modal/settings_modal.js
@@ -3,6 +3,7 @@ import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
import AsyncComponentError from 'src/components/async_component_error/async_component_error.vue'
import getResettableAsyncComponent from 'src/services/resettable_async_component.js'
import Popover from '../popover/popover.vue'
+import Checkbox from 'src/components/checkbox/checkbox.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { cloneDeep } from 'lodash'
import {
@@ -51,11 +52,12 @@ const SettingsModal = {
components: {
Modal,
Popover,
+ Checkbox,
SettingsModalContent: getResettableAsyncComponent(
() => import('./settings_modal_content.vue'),
{
- loading: PanelLoading,
- error: AsyncComponentError,
+ loadingComponent: PanelLoading,
+ errorComponent: AsyncComponentError,
delay: 0
}
)
@@ -159,6 +161,15 @@ const SettingsModal = {
},
modalPeeked () {
return this.$store.state.interface.settingsModalState === 'minimized'
+ },
+ expertLevel: {
+ get () {
+ return this.$store.state.config.expertLevel > 0
+ },
+ set (value) {
+ console.log(value)
+ this.$store.dispatch('setOption', { name: 'expertLevel', value: value ? 1 : 0 })
+ }
}
}
}
diff --git a/src/components/settings_modal/settings_modal.scss b/src/components/settings_modal/settings_modal.scss
index 90446b36..13cb0e65 100644
--- a/src/components/settings_modal/settings_modal.scss
+++ b/src/components/settings_modal/settings_modal.scss
@@ -2,6 +2,18 @@
.settings-modal {
overflow: hidden;
+ .setting-list,
+ .option-list {
+ list-style-type: none;
+ padding-left: 2em;
+ li {
+ margin-bottom: 0.5em;
+ }
+ .suboptions {
+ margin-top: 0.3em
+ }
+ }
+
&.peek {
.settings-modal-panel {
/* Explanation:
@@ -42,10 +54,22 @@
overflow-y: hidden;
.btn {
- min-height: 28px;
+ min-height: 2em;
min-width: 10em;
padding: 0 2em;
}
}
}
+
+ .settings-footer {
+ display: flex;
+ >* {
+ margin-right: 0.5em;
+ }
+
+ .extra-content {
+ display: flex;
+ flex-grow: 1;
+ }
+ }
}
diff --git a/src/components/settings_modal/settings_modal.vue b/src/components/settings_modal/settings_modal.vue
index 583c2ecc..7b457371 100644
--- a/src/components/settings_modal/settings_modal.vue
+++ b/src/components/settings_modal/settings_modal.vue
@@ -11,23 +11,14 @@
{{ $t('settings.settings') }}
</span>
<transition name="fade">
- <template v-if="currentSaveStateNotice">
- <div
- v-if="currentSaveStateNotice.error"
- class="alert error"
- @click.prevent
- >
- {{ $t('settings.saving_err') }}
- </div>
-
- <div
- v-if="!currentSaveStateNotice.error"
- class="alert transparent"
- @click.prevent
- >
- {{ $t('settings.saving_ok') }}
- </div>
- </template>
+ <div
+ v-if="currentSaveStateNotice"
+ class="alert"
+ :class="{ transparent: !currentSaveStateNotice.error, error: currentSaveStateNotice.error}"
+ @click.prevent
+ >
+ {{ currentSaveStateNotice.error ? $t('settings.saving_err') : $t('settings.saving_ok') }}
+ </div>
</transition>
<button
class="btn button-default"
@@ -53,7 +44,7 @@
<div class="panel-body">
<SettingsModalContent v-if="modalOpenedOnce" />
</div>
- <div class="panel-footer">
+ <div class="panel-footer settings-footer">
<Popover
class="export"
trigger="click"
@@ -62,18 +53,19 @@
:bound-to="{ x: 'container' }"
remove-padding
>
- <template v-slot:trigger>
+ <template #trigger>
<button
class="btn button-default"
:title="$t('general.close')"
>
<span>{{ $t("settings.file_export_import.backup_restore") }}</span>
+ {{ ' ' }}
<FAIcon
icon="chevron-down"
/>
</button>
</template>
- <template v-slot:content="{close}">
+ <template #content="{close}">
<div class="dropdown-menu">
<button
class="button-default dropdown-item dropdown-item-icon"
@@ -108,6 +100,17 @@
</div>
</template>
</Popover>
+
+ <Checkbox
+ :model-value="!!expertLevel"
+ @update:modelValue="expertLevel = Number($event)"
+ >
+ {{ $t("settings.expert_mode") }}
+ </Checkbox>
+ <span
+ id="unscrolled-content"
+ class="extra-content"
+ />
</div>
</div>
</Modal>
diff --git a/src/components/settings_modal/settings_modal_content.js b/src/components/settings_modal/settings_modal_content.js
index 9dcf1b5a..9ac0301f 100644
--- a/src/components/settings_modal/settings_modal_content.js
+++ b/src/components/settings_modal/settings_modal_content.js
@@ -1,4 +1,4 @@
-import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import DataImportExportTab from './tabs/data_import_export_tab.vue'
import MutesAndBlocksTab from './tabs/mutes_and_blocks_tab.vue'
@@ -53,6 +53,9 @@ const SettingsModalContent = {
},
open () {
return this.$store.state.interface.settingsModalState !== 'hidden'
+ },
+ bodyLock () {
+ return this.$store.state.interface.settingsModalState === 'visible'
}
},
methods: {
@@ -60,8 +63,8 @@ const SettingsModalContent = {
const targetTab = this.$store.state.interface.settingsModalTargetTab
// We're being told to open in specific tab
if (targetTab) {
- const tabIndex = this.$refs.tabSwitcher.$slots.default.findIndex(elm => {
- return elm.data && elm.data.attrs['data-tab-name'] === targetTab
+ const tabIndex = this.$refs.tabSwitcher.$slots.default().findIndex(elm => {
+ return elm.props && elm.props['data-tab-name'] === targetTab
})
if (tabIndex >= 0) {
this.$refs.tabSwitcher.setTab(tabIndex)
diff --git a/src/components/settings_modal/settings_modal_content.vue b/src/components/settings_modal/settings_modal_content.vue
index c9ed2a38..0be76d22 100644
--- a/src/components/settings_modal/settings_modal_content.vue
+++ b/src/components/settings_modal/settings_modal_content.vue
@@ -4,6 +4,7 @@
class="settings_tab-switcher"
:side-tab-bar="true"
:scrollable-tabs="true"
+ :body-scroll-lock="bodyLock"
>
<div
:label="$t('settings.general')"
diff --git a/src/components/settings_modal/tabs/data_import_export_tab.js b/src/components/settings_modal/tabs/data_import_export_tab.js
index f4b736d2..4895733c 100644
--- a/src/components/settings_modal/tabs/data_import_export_tab.js
+++ b/src/components/settings_modal/tabs/data_import_export_tab.js
@@ -7,11 +7,16 @@ const DataImportExportTab = {
data () {
return {
activeTab: 'profile',
- newDomainToMute: ''
+ newDomainToMute: '',
+ listBackupsError: false,
+ addBackupError: false,
+ addedBackup: false,
+ backups: []
}
},
created () {
this.$store.dispatch('fetchTokens')
+ this.fetchBackups()
},
components: {
Importer,
@@ -72,6 +77,28 @@ const DataImportExportTab = {
}
return user.screen_name
}).join('\n')
+ },
+ addBackup () {
+ this.$store.state.api.backendInteractor.addBackup()
+ .then((res) => {
+ this.addedBackup = true
+ this.addBackupError = false
+ })
+ .catch((error) => {
+ this.addedBackup = false
+ this.addBackupError = error
+ })
+ .then(() => this.fetchBackups())
+ },
+ fetchBackups () {
+ this.$store.state.api.backendInteractor.listBackups()
+ .then((res) => {
+ this.backups = res
+ this.listBackupsError = false
+ })
+ .catch((error) => {
+ this.listBackupsError = error.error
+ })
}
}
}
diff --git a/src/components/settings_modal/tabs/data_import_export_tab.vue b/src/components/settings_modal/tabs/data_import_export_tab.vue
index a406077d..e3b7f407 100644
--- a/src/components/settings_modal/tabs/data_import_export_tab.vue
+++ b/src/components/settings_modal/tabs/data_import_export_tab.vue
@@ -53,6 +53,67 @@
:export-button-label="$t('settings.mute_export_button')"
/>
</div>
+ <div class="setting-item">
+ <h2>{{ $t('settings.account_backup') }}</h2>
+ <p>{{ $t('settings.account_backup_description') }}</p>
+ <table>
+ <thead>
+ <tr>
+ <th>{{ $t('settings.account_backup_table_head') }}</th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ <tr
+ v-for="backup in backups"
+ :key="backup.id"
+ >
+ <td>{{ backup.inserted_at }}</td>
+ <td class="actions">
+ <a
+ v-if="backup.processed"
+ target="_blank"
+ :href="backup.url"
+ >
+ {{ $t('settings.download_backup') }}
+ </a>
+ <span
+ v-else
+ >
+ {{ $t('settings.backup_not_ready') }}
+ </span>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ <div
+ v-if="listBackupsError"
+ class="alert error"
+ >
+ {{ $t('settings.list_backups_error', { error }) }}
+ <button
+ :title="$t('settings.hide_list_backups_error_action')"
+ @click="listBackupsError = false"
+ >
+ <FAIcon
+ class="fa-scale-110 fa-old-padding"
+ icon="times"
+ />
+ </button>
+ </div>
+ <button
+ class="btn button-default"
+ @click="addBackup"
+ >
+ {{ $t('settings.add_backup') }}
+ </button>
+ <p v-if="addedBackup">
+ {{ $t('settings.added_backup') }}
+ </p>
+ <template v-if="addBackupError !== false">
+ <p>{{ $t('settings.add_backup_error', { error: addBackupError }) }}</p>
+ </template>
+ </div>
</div>
</template>
diff --git a/src/components/settings_modal/tabs/filtering_tab.js b/src/components/settings_modal/tabs/filtering_tab.js
index 4eaf4217..5354e5db 100644
--- a/src/components/settings_modal/tabs/filtering_tab.js
+++ b/src/components/settings_modal/tabs/filtering_tab.js
@@ -1,6 +1,7 @@
import { filter, trim } from 'lodash'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
+import IntegerSetting from '../helpers/integer_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
@@ -17,7 +18,8 @@ const FilteringTab = {
},
components: {
BooleanSetting,
- ChoiceSetting
+ ChoiceSetting,
+ IntegerSetting
},
computed: {
...SharedComputedObject(),
@@ -36,15 +38,6 @@ const FilteringTab = {
},
// Updating nested properties
watch: {
- notificationVisibility: {
- handler (value) {
- this.$store.dispatch('setOption', {
- name: 'notificationVisibility',
- value: this.$store.getters.mergedConfig.notificationVisibility
- })
- },
- deep: true
- },
replyVisibility () {
this.$store.dispatch('queueFlushAll')
}
diff --git a/src/components/settings_modal/tabs/filtering_tab.vue b/src/components/settings_modal/tabs/filtering_tab.vue
index 6fc9ceaa..97046ff0 100644
--- a/src/components/settings_modal/tabs/filtering_tab.vue
+++ b/src/components/settings_modal/tabs/filtering_tab.vue
@@ -1,73 +1,110 @@
<template>
<div :label="$t('settings.filtering')">
<div class="setting-item">
- <div class="select-multiple">
- <span class="label">{{ $t('settings.notification_visibility') }}</span>
- <ul class="option-list">
- <li>
- <BooleanSetting path="notificationVisibility.likes">
- {{ $t('settings.notification_visibility_likes') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting path="notificationVisibility.repeats">
- {{ $t('settings.notification_visibility_repeats') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting path="notificationVisibility.follows">
- {{ $t('settings.notification_visibility_follows') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting path="notificationVisibility.mentions">
- {{ $t('settings.notification_visibility_mentions') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting path="notificationVisibility.moves">
- {{ $t('settings.notification_visibility_moves') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting path="notificationVisibility.emojiReactions">
- {{ $t('settings.notification_visibility_emoji_reactions') }}
- </BooleanSetting>
- </li>
- </ul>
- </div>
- <ChoiceSetting
- id="replyVisibility"
- path="replyVisibility"
- :options="replyVisibilityOptions"
- >
- {{ $t('settings.replies_in_timeline') }}
- </ChoiceSetting>
- <div>
- <BooleanSetting path="hidePostStats">
- {{ $t('settings.hide_post_stats') }}
- </BooleanSetting>
- </div>
- <div>
- <BooleanSetting path="hideUserStats">
- {{ $t('settings.hide_user_stats') }}
- </BooleanSetting>
- </div>
+ <h2>{{ $t('settings.posts') }}</h2>
+ <ul class="setting-list">
+ <li>
+ <BooleanSetting path="hideFilteredStatuses">
+ {{ $t('settings.hide_filtered_statuses') }}
+ </BooleanSetting>
+ <ul
+ class="setting-list suboptions"
+ :class="[{disabled: !streaming}]"
+ >
+ <li>
+ <BooleanSetting
+ :disabled="hideFilteredStatuses"
+ path="hideWordFilteredPosts"
+ >
+ {{ $t('settings.hide_wordfiltered_statuses') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ v-if="user"
+ :disabled="hideFilteredStatuses"
+ path="hideMutedThreads"
+ >
+ {{ $t('settings.hide_muted_threads') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ v-if="user"
+ :disabled="hideFilteredStatuses"
+ path="hideMutedPosts"
+ >
+ {{ $t('settings.hide_muted_posts') }}
+ </BooleanSetting>
+ </li>
+ </ul>
+ </li>
+ <li>
+ <BooleanSetting path="muteBotStatuses">
+ {{ $t('settings.mute_bot_posts') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="hidePostStats">
+ {{ $t('settings.hide_post_stats') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="hideBotIndication">
+ {{ $t('settings.hide_bot_indication') }}
+ </BooleanSetting>
+ </li>
+ <ChoiceSetting
+ v-if="user"
+ id="replyVisibility"
+ path="replyVisibility"
+ :options="replyVisibilityOptions"
+ >
+ {{ $t('settings.replies_in_timeline') }}
+ </ChoiceSetting>
+ <li>
+ <h3>{{ $t('settings.wordfilter') }}</h3>
+ <textarea
+ id="muteWords"
+ v-model="muteWordsString"
+ class="resize-height"
+ />
+ <div>{{ $t('settings.filtering_explanation') }}</div>
+ </li>
+ <h3>{{ $t('settings.attachments') }}</h3>
+ <li>
+ <IntegerSetting
+ path="maxThumbnails"
+ expert="1"
+ :min="0"
+ >
+ {{ $t('settings.max_thumbnails') }}
+ </IntegerSetting>
+ </li>
+ <li>
+ <BooleanSetting path="hideAttachments">
+ {{ $t('settings.hide_attachments_in_tl') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="hideAttachmentsInConv">
+ {{ $t('settings.hide_attachments_in_convo') }}
+ </BooleanSetting>
+ </li>
+ </ul>
</div>
- <div class="setting-item">
- <div>
- <p>{{ $t('settings.filtering_explanation') }}</p>
- <textarea
- id="muteWords"
- v-model="muteWordsString"
- class="resize-height"
- />
- </div>
- <div>
- <BooleanSetting path="hideFilteredStatuses">
- {{ $t('settings.hide_filtered_statuses') }}
- </BooleanSetting>
- </div>
+ <div
+ v-if="expertLevel > 0"
+ class="setting-item"
+ >
+ <h2>{{ $t('settings.user_profiles') }}</h2>
+ <ul class="setting-list">
+ <li>
+ <BooleanSetting path="hideUserStats">
+ {{ $t('settings.hide_user_stats') }}
+ </BooleanSetting>
+ </li>
+ </ul>
</div>
</div>
</template>
diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js
index eeda61bf..ea24d6ad 100644
--- a/src/components/settings_modal/tabs/general_tab.js
+++ b/src/components/settings_modal/tabs/general_tab.js
@@ -1,8 +1,12 @@
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
+import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
+import IntegerSetting from '../helpers/integer_setting.vue'
+import SizeSetting, { defaultHorizontalUnits } from '../helpers/size_setting.vue'
import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
+import ServerSideIndicator from '../helpers/server_side_indicator.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faGlobe
@@ -20,6 +24,31 @@ const GeneralTab = {
value: mode,
label: this.$t(`settings.subject_line_${mode === 'masto' ? 'mastodon' : mode}`)
})),
+ conversationDisplayOptions: ['tree', 'linear'].map(mode => ({
+ key: mode,
+ value: mode,
+ label: this.$t(`settings.conversation_display_${mode}`)
+ })),
+ conversationOtherRepliesButtonOptions: ['below', 'inside'].map(mode => ({
+ key: mode,
+ value: mode,
+ label: this.$t(`settings.conversation_other_replies_button_${mode}`)
+ })),
+ mentionLinkDisplayOptions: ['short', 'full_for_remote', 'full'].map(mode => ({
+ key: mode,
+ value: mode,
+ label: this.$t(`settings.mention_link_display_${mode}`)
+ })),
+ thirdColumnModeOptions: ['none', 'notifications', 'postform'].map(mode => ({
+ key: mode,
+ value: mode,
+ label: this.$t(`settings.third_column_mode_${mode}`)
+ })),
+ userPopoverAvatarActionOptions: ['close', 'zoom', 'open'].map(mode => ({
+ key: mode,
+ value: mode,
+ label: this.$t(`settings.user_popover_avatar_action_${mode}`)
+ })),
loopSilentAvailable:
// Firefox
Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') ||
@@ -32,9 +61,16 @@ const GeneralTab = {
components: {
BooleanSetting,
ChoiceSetting,
- InterfaceLanguageSwitcher
+ IntegerSetting,
+ SizeSetting,
+ InterfaceLanguageSwitcher,
+ ScopeSelector,
+ ServerSideIndicator
},
computed: {
+ horizontalUnits () {
+ return defaultHorizontalUnits
+ },
postFormats () {
return this.$store.state.instance.postFormats || []
},
@@ -45,13 +81,35 @@ const GeneralTab = {
label: this.$t(`post_status.content_type["${format}"]`)
}))
},
+ columns () {
+ const mode = this.$store.getters.mergedConfig.thirdColumnMode
+
+ const notif = mode === 'none' ? [] : ['notifs']
+
+ if (this.$store.getters.mergedConfig.sidebarRight || mode === 'postform') {
+ return [...notif, 'content', 'sidebar']
+ } else {
+ return ['sidebar', 'content', ...notif]
+ }
+ },
instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel },
instanceWallpaperUsed () {
return this.$store.state.instance.background &&
!this.$store.state.users.currentUser.background_image
},
instanceShoutboxPresent () { return this.$store.state.instance.shoutAvailable },
+ language: {
+ get: function () { return this.$store.getters.mergedConfig.interfaceLanguage },
+ set: function (val) {
+ this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
+ }
+ },
...SharedComputedObject()
+ },
+ methods: {
+ changeDefaultScope (value) {
+ this.$store.dispatch('setServerSideOption', { name: 'defaultScope', value })
+ }
}
}
diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue
index f2ec7d64..8561647b 100644
--- a/src/components/settings_modal/tabs/general_tab.vue
+++ b/src/components/settings_modal/tabs/general_tab.vue
@@ -4,41 +4,25 @@
<h2>{{ $t('settings.interface') }}</h2>
<ul class="setting-list">
<li>
- <interface-language-switcher />
+ <interface-language-switcher
+ :prompt-text="$t('settings.interfaceLanguage')"
+ :language="language"
+ :set-language="val => language = val"
+ />
</li>
<li v-if="instanceSpecificPanelPresent">
<BooleanSetting path="hideISP">
{{ $t('settings.hide_isp') }}
</BooleanSetting>
</li>
- <li>
- <BooleanSetting path="sidebarRight">
- {{ $t('settings.right_sidebar') }}
- </BooleanSetting>
- </li>
<li v-if="instanceWallpaperUsed">
<BooleanSetting path="hideInstanceWallpaper">
{{ $t('settings.hide_wallpaper') }}
</BooleanSetting>
</li>
- <li v-if="instanceShoutboxPresent">
- <BooleanSetting path="hideShoutbox">
- {{ $t('settings.hide_shoutbox') }}
- </BooleanSetting>
- </li>
- </ul>
- </div>
- <div class="setting-item">
- <h2>{{ $t('nav.timeline') }}</h2>
- <ul class="setting-list">
- <li>
- <BooleanSetting path="hideMutedPosts">
- {{ $t('settings.hide_muted_posts') }}
- </BooleanSetting>
- </li>
<li>
- <BooleanSetting path="collapseMessageWithSubject">
- {{ $t('settings.collapse_subject') }}
+ <BooleanSetting path="stopGifs">
+ {{ $t('settings.stop_gifs') }}
</BooleanSetting>
</li>
<li>
@@ -60,111 +44,191 @@
</ul>
</li>
<li>
- <BooleanSetting path="useStreamingApi">
+ <BooleanSetting
+ path="useStreamingApi"
+ expert="1"
+ >
{{ $t('settings.useStreamingApi') }}
- <br>
- <small>
- {{ $t('settings.useStreamingApiWarning') }}
- </small>
</BooleanSetting>
</li>
<li>
- <BooleanSetting path="emojiReactionsOnTimeline">
- {{ $t('settings.emoji_reactions_on_timeline') }}
+ <BooleanSetting
+ path="virtualScrolling"
+ expert="1"
+ >
+ {{ $t('settings.virtual_scrolling') }}
</BooleanSetting>
</li>
<li>
- <BooleanSetting path="virtualScrolling">
- {{ $t('settings.virtual_scrolling') }}
- </BooleanSetting>
+ <ChoiceSetting
+ id="userPopoverAvatarAction"
+ path="userPopoverAvatarAction"
+ :options="userPopoverAvatarActionOptions"
+ expert="1"
+ >
+ {{ $t('settings.user_popover_avatar_action') }}
+ </ChoiceSetting>
</li>
- </ul>
- </div>
-
- <div class="setting-item">
- <h2>{{ $t('settings.composing') }}</h2>
- <ul class="setting-list">
<li>
- <BooleanSetting path="scopeCopy">
- {{ $t('settings.scope_copy') }}
+ <BooleanSetting
+ path="userPopoverOverlay"
+ expert="1"
+ >
+ {{ $t('settings.user_popover_avatar_overlay') }}
</BooleanSetting>
</li>
<li>
- <BooleanSetting path="alwaysShowSubjectInput">
- {{ $t('settings.subject_input_always_show') }}
+ <BooleanSetting
+ path="alwaysShowNewPostButton"
+ expert="1"
+ >
+ {{ $t('settings.always_show_post_button') }}
</BooleanSetting>
</li>
<li>
- <ChoiceSetting
- id="subjectLineBehavior"
- path="subjectLineBehavior"
- :options="subjectLineOptions"
+ <BooleanSetting
+ path="autohideFloatingPostButton"
+ expert="1"
>
- {{ $t('settings.subject_line_behavior') }}
- </ChoiceSetting>
+ {{ $t('settings.autohide_floating_post_button') }}
+ </BooleanSetting>
</li>
- <li v-if="postFormats.length > 0">
- <ChoiceSetting
- id="postContentType"
- path="postContentType"
- :options="postContentOptions"
+ <li v-if="instanceShoutboxPresent">
+ <BooleanSetting
+ path="hideShoutbox"
+ expert="1"
>
- {{ $t('settings.post_status_content_type') }}
- </ChoiceSetting>
+ {{ $t('settings.hide_shoutbox') }}
+ </BooleanSetting>
</li>
<li>
- <BooleanSetting path="minimalScopesMode">
- {{ $t('settings.minimal_scopes_mode') }}
- </BooleanSetting>
+ <h3>{{ $t('settings.columns') }}</h3>
</li>
<li>
- <BooleanSetting path="sensitiveByDefault">
- {{ $t('settings.sensitive_by_default') }}
+ <BooleanSetting path="disableStickyHeaders">
+ {{ $t('settings.disable_sticky_headers') }}
</BooleanSetting>
</li>
<li>
- <BooleanSetting path="alwaysShowNewPostButton">
- {{ $t('settings.always_show_post_button') }}
+ <BooleanSetting path="showScrollbars">
+ {{ $t('settings.show_scrollbars') }}
</BooleanSetting>
</li>
<li>
- <BooleanSetting path="autohideFloatingPostButton">
- {{ $t('settings.autohide_floating_post_button') }}
+ <BooleanSetting path="sidebarRight">
+ {{ $t('settings.right_sidebar') }}
</BooleanSetting>
</li>
<li>
- <BooleanSetting path="padEmoji">
- {{ $t('settings.pad_emoji') }}
+ <BooleanSetting path="navbarColumnStretch">
+ {{ $t('settings.navbar_column_stretch') }}
</BooleanSetting>
</li>
+ <li>
+ <ChoiceSetting
+ v-if="user"
+ id="thirdColumnMode"
+ path="thirdColumnMode"
+ :options="thirdColumnModeOptions"
+ >
+ {{ $t('settings.third_column_mode') }}
+ </ChoiceSetting>
+ </li>
+ <li v-if="expertLevel > 0">
+ {{ $t('settings.column_sizes') }}
+ <div class="column-settings">
+ <SizeSetting
+ v-for="column in columns"
+ :key="column"
+ :path="column + 'ColumnWidth'"
+ :units="horizontalUnits"
+ expert="1"
+ >
+ {{ $t('settings.column_sizes_' + column) }}
+ </SizeSetting>
+ </div>
+ </li>
</ul>
</div>
-
<div class="setting-item">
- <h2>{{ $t('settings.attachments') }}</h2>
+ <h2>{{ $t('settings.post_look_feel') }}</h2>
<ul class="setting-list">
<li>
- <BooleanSetting path="hideAttachments">
- {{ $t('settings.hide_attachments_in_tl') }}
+ <ChoiceSetting
+ id="conversationDisplay"
+ path="conversationDisplay"
+ :options="conversationDisplayOptions"
+ >
+ {{ $t('settings.conversation_display') }}
+ </ChoiceSetting>
+ </li>
+ <ul
+ v-if="conversationDisplay !== 'linear'"
+ class="setting-list suboptions"
+ >
+ <li>
+ <BooleanSetting path="conversationTreeAdvanced">
+ {{ $t('settings.tree_advanced') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="conversationTreeFadeAncestors"
+ :expert="1"
+ >
+ {{ $t('settings.tree_fade_ancestors') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <IntegerSetting
+ path="maxDepthInThread"
+ :min="3"
+ :expert="1"
+ >
+ {{ $t('settings.max_depth_in_thread') }}
+ </IntegerSetting>
+ </li>
+ <li>
+ <ChoiceSetting
+ id="conversationOtherRepliesButton"
+ path="conversationOtherRepliesButton"
+ :options="conversationOtherRepliesButtonOptions"
+ :expert="1"
+ >
+ {{ $t('settings.conversation_other_replies_button') }}
+ </ChoiceSetting>
+ </li>
+ </ul>
+ <li>
+ <BooleanSetting path="collapseMessageWithSubject">
+ {{ $t('settings.collapse_subject') }}
</BooleanSetting>
</li>
<li>
- <BooleanSetting path="hideAttachmentsInConv">
- {{ $t('settings.hide_attachments_in_convo') }}
+ <BooleanSetting
+ path="emojiReactionsOnTimeline"
+ expert="1"
+ >
+ {{ $t('settings.emoji_reactions_on_timeline') }}
</BooleanSetting>
</li>
<li>
- <label for="maxThumbnails">
- {{ $t('settings.max_thumbnails') }}
- </label>
- <input
- id="maxThumbnails"
- path.number="maxThumbnails"
- class="number-input"
- type="number"
- min="0"
- step="1"
+ <BooleanSetting
+ v-if="user"
+ path="serverSide_stripRichContent"
+ expert="1"
>
+ {{ $t('settings.no_rich_text_description') }}
+ </BooleanSetting>
+ </li>
+ <h3>{{ $t('settings.attachments') }}</h3>
+ <li>
+ <BooleanSetting
+ path="useContainFit"
+ expert="1"
+ >
+ {{ $t('settings.use_contain_fit') }}
+ </BooleanSetting>
</li>
<li>
<BooleanSetting path="hideNsfw">
@@ -175,6 +239,7 @@
<li>
<BooleanSetting
path="preloadImage"
+ expert="1"
:disabled="!hideNsfw"
>
{{ $t('settings.preload_images') }}
@@ -183,6 +248,7 @@
<li>
<BooleanSetting
path="useOneClickNsfw"
+ expert="1"
:disabled="!hideNsfw"
>
{{ $t('settings.use_one_click_nsfw') }}
@@ -190,12 +256,10 @@
</li>
</ul>
<li>
- <BooleanSetting path="stopGifs">
- {{ $t('settings.stop_gifs') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting path="loopVideo">
+ <BooleanSetting
+ path="loopVideo"
+ expert="1"
+ >
{{ $t('settings.loop_video') }}
</BooleanSetting>
<ul
@@ -205,6 +269,7 @@
<li>
<BooleanSetting
path="loopVideoSilentOnly"
+ expert="1"
:disabled="!loopVideo || !loopSilentAvailable"
>
{{ $t('settings.loop_video_silent_only') }}
@@ -219,35 +284,171 @@
</ul>
</li>
<li>
- <BooleanSetting path="playVideosInModal">
+ <BooleanSetting
+ path="playVideosInModal"
+ expert="1"
+ >
{{ $t('settings.play_videos_in_modal') }}
</BooleanSetting>
</li>
+ <h3>{{ $t('settings.mention_links') }}</h3>
<li>
- <BooleanSetting path="useContainFit">
- {{ $t('settings.use_contain_fit') }}
+ <ChoiceSetting
+ id="mentionLinkDisplay"
+ path="mentionLinkDisplay"
+ :options="mentionLinkDisplayOptions"
+ >
+ {{ $t('settings.mention_link_display') }}
+ </ChoiceSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="mentionLinkShowTooltip"
+ expert="1"
+ >
+ {{ $t('settings.mention_link_use_tooltip') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="useAtIcon"
+ expert="1"
+ >
+ {{ $t('settings.use_at_icon') }}
</BooleanSetting>
</li>
- </ul>
- </div>
-
- <div class="setting-item">
- <h2>{{ $t('settings.notifications') }}</h2>
- <ul class="setting-list">
<li>
- <BooleanSetting path="webPushNotifications">
- {{ $t('settings.enable_web_push_notifications') }}
+ <BooleanSetting path="mentionLinkShowAvatar">
+ {{ $t('settings.mention_link_show_avatar') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="mentionLinkFadeDomain"
+ expert="1"
+ >
+ {{ $t('settings.mention_link_fade_domain') }}
+ </BooleanSetting>
+ </li>
+ <li v-if="user">
+ <BooleanSetting
+ path="mentionLinkBoldenYou"
+ expert="1"
+ >
+ {{ $t('settings.mention_link_bolden_you') }}
+ </BooleanSetting>
+ </li>
+ <h3 v-if="expertLevel > 0">
+ {{ $t('settings.fun') }}
+ </h3>
+ <li>
+ <BooleanSetting
+ path="greentext"
+ expert="1"
+ >
+ {{ $t('settings.greentext') }}
+ </BooleanSetting>
+ </li>
+ <li v-if="user">
+ <BooleanSetting
+ path="mentionLinkShowYous"
+ expert="1"
+ >
+ {{ $t('settings.show_yous') }}
</BooleanSetting>
</li>
</ul>
</div>
- <div class="setting-item">
- <h2>{{ $t('settings.fun') }}</h2>
+ <div
+ v-if="user"
+ class="setting-item"
+ >
+ <h2>{{ $t('settings.composing') }}</h2>
<ul class="setting-list">
<li>
- <BooleanSetting path="greentext">
- {{ $t('settings.greentext') }}
+ <label for="default-vis">
+ {{ $t('settings.default_vis') }} <ServerSideIndicator :server-side="true" />
+ <ScopeSelector
+ class="scope-selector"
+ :show-all="true"
+ :user-default="serverSide_defaultScope"
+ :initial-scope="serverSide_defaultScope"
+ :on-scope-change="changeDefaultScope"
+ />
+ </label>
+ </li>
+ <li>
+ <!-- <BooleanSetting path="serverSide_defaultNSFW"> -->
+ <BooleanSetting path="sensitiveByDefault">
+ {{ $t('settings.sensitive_by_default') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="scopeCopy"
+ expert="1"
+ >
+ {{ $t('settings.scope_copy') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="alwaysShowSubjectInput"
+ expert="1"
+ >
+ {{ $t('settings.subject_input_always_show') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <ChoiceSetting
+ id="subjectLineBehavior"
+ path="subjectLineBehavior"
+ :options="subjectLineOptions"
+ expert="1"
+ >
+ {{ $t('settings.subject_line_behavior') }}
+ </ChoiceSetting>
+ </li>
+ <li v-if="postFormats.length > 0">
+ <ChoiceSetting
+ id="postContentType"
+ path="postContentType"
+ :options="postContentOptions"
+ >
+ {{ $t('settings.post_status_content_type') }}
+ </ChoiceSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="minimalScopesMode"
+ expert="1"
+ >
+ {{ $t('settings.minimal_scopes_mode') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="alwaysShowNewPostButton"
+ expert="1"
+ >
+ {{ $t('settings.always_show_post_button') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="autohideFloatingPostButton"
+ expert="1"
+ >
+ {{ $t('settings.autohide_floating_post_button') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="padEmoji"
+ expert="1"
+ >
+ {{ $t('settings.pad_emoji') }}
</BooleanSetting>
</li>
</ul>
@@ -256,3 +457,16 @@
</template>
<script src="./general_tab.js"></script>
+
+<style lang="scss">
+.column-settings {
+ display: flex;
+ justify-content: space-evenly;
+ flex-wrap: wrap;
+}
+.column-settings .size-label {
+ display: block;
+ margin-bottom: 0.5em;
+ margin-top: 0.5em;
+}
+</style>
diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.js b/src/components/settings_modal/tabs/mutes_and_blocks_tab.js
index 40a87b81..6cfeea35 100644
--- a/src/components/settings_modal/tabs/mutes_and_blocks_tab.js
+++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.js
@@ -2,7 +2,7 @@ import get from 'lodash/get'
import map from 'lodash/map'
import reject from 'lodash/reject'
import Autosuggest from 'src/components/autosuggest/autosuggest.vue'
-import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import BlockCard from 'src/components/block_card/block_card.vue'
import MuteCard from 'src/components/mute_card/mute_card.vue'
import DomainMuteCard from 'src/components/domain_mute_card/domain_mute_card.vue'
diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.scss b/src/components/settings_modal/tabs/mutes_and_blocks_tab.scss
index ceb64efb..2adff847 100644
--- a/src/components/settings_modal/tabs/mutes_and_blocks_tab.scss
+++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.scss
@@ -8,7 +8,7 @@
.bulk-actions {
text-align: right;
padding: 0 1em;
- min-height: 28px;
+ min-height: 2em;
}
.bulk-action-button {
diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue b/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue
index 32a21415..ed4b15a4 100644
--- a/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue
+++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue
@@ -10,7 +10,7 @@
:query="queryUserIds"
:placeholder="$t('settings.search_user_to_block')"
>
- <template v-slot="row">
+ <template #default="row">
<BlockCard
:user-id="row.item"
/>
@@ -21,7 +21,7 @@
:refresh="true"
:get-key="i => i"
>
- <template v-slot:header="{selected}">
+ <template #header="{selected}">
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
@@ -29,7 +29,7 @@
:click="() => blockUsers(selected)"
>
{{ $t('user_card.block') }}
- <template v-slot:progress>
+ <template #progress>
{{ $t('user_card.block_progress') }}
</template>
</ProgressButton>
@@ -39,16 +39,16 @@
:click="() => unblockUsers(selected)"
>
{{ $t('user_card.unblock') }}
- <template v-slot:progress>
+ <template #progress>
{{ $t('user_card.unblock_progress') }}
</template>
</ProgressButton>
</div>
</template>
- <template v-slot:item="{item}">
+ <template #item="{item}">
<BlockCard :user-id="item" />
</template>
- <template v-slot:empty>
+ <template #empty>
{{ $t('settings.no_blocks') }}
</template>
</BlockList>
@@ -56,14 +56,14 @@
<div :label="$t('settings.mutes_tab')">
<tab-switcher>
- <div label="Users">
+ <div :label="$t('settings.user_mutes')">
<div class="usersearch-wrapper">
<Autosuggest
:filter="filterUnMutedUsers"
:query="queryUserIds"
:placeholder="$t('settings.search_user_to_mute')"
>
- <template v-slot="row">
+ <template #default="row">
<MuteCard
:user-id="row.item"
/>
@@ -74,7 +74,7 @@
:refresh="true"
:get-key="i => i"
>
- <template v-slot:header="{selected}">
+ <template #header="{selected}">
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
@@ -82,7 +82,7 @@
:click="() => muteUsers(selected)"
>
{{ $t('user_card.mute') }}
- <template v-slot:progress>
+ <template #progress>
{{ $t('user_card.mute_progress') }}
</template>
</ProgressButton>
@@ -92,16 +92,16 @@
:click="() => unmuteUsers(selected)"
>
{{ $t('user_card.unmute') }}
- <template v-slot:progress>
+ <template #progress>
{{ $t('user_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
</template>
- <template v-slot:item="{item}">
+ <template #item="{item}">
<MuteCard :user-id="item" />
</template>
- <template v-slot:empty>
+ <template #empty>
{{ $t('settings.no_mutes') }}
</template>
</MuteList>
@@ -114,7 +114,7 @@
:query="queryKnownDomains"
:placeholder="$t('settings.type_domains_to_mute')"
>
- <template v-slot="row">
+ <template #default="row">
<DomainMuteCard
:domain="row.item"
/>
@@ -125,7 +125,7 @@
:refresh="true"
:get-key="i => i"
>
- <template v-slot:header="{selected}">
+ <template #header="{selected}">
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
@@ -133,16 +133,16 @@
:click="() => unmuteDomains(selected)"
>
{{ $t('domain_mute_card.unmute') }}
- <template v-slot:progress>
+ <template #progress>
{{ $t('domain_mute_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
</template>
- <template v-slot:item="{item}">
+ <template #item="{item}">
<DomainMuteCard :domain="item" />
</template>
- <template v-slot:empty>
+ <template #empty>
{{ $t('settings.no_mutes') }}
</template>
</DomainMuteList>
diff --git a/src/components/settings_modal/tabs/notifications_tab.js b/src/components/settings_modal/tabs/notifications_tab.js
index 3e44c95d..3c6ab87f 100644
--- a/src/components/settings_modal/tabs/notifications_tab.js
+++ b/src/components/settings_modal/tabs/notifications_tab.js
@@ -1,4 +1,5 @@
-import Checkbox from 'src/components/checkbox/checkbox.vue'
+import BooleanSetting from '../helpers/boolean_setting.vue'
+import SharedComputedObject from '../helpers/shared_computed_object.js'
const NotificationsTab = {
data () {
@@ -9,12 +10,13 @@ const NotificationsTab = {
}
},
components: {
- Checkbox
+ BooleanSetting
},
computed: {
user () {
return this.$store.state.users.currentUser
- }
+ },
+ ...SharedComputedObject()
},
methods: {
updateNotificationSettings () {
diff --git a/src/components/settings_modal/tabs/notifications_tab.vue b/src/components/settings_modal/tabs/notifications_tab.vue
index 7e0568ea..dd3806ed 100644
--- a/src/components/settings_modal/tabs/notifications_tab.vue
+++ b/src/components/settings_modal/tabs/notifications_tab.vue
@@ -2,30 +2,82 @@
<div :label="$t('settings.notifications')">
<div class="setting-item">
<h2>{{ $t('settings.notification_setting_filters') }}</h2>
- <p>
- <Checkbox v-model="notificationSettings.block_from_strangers">
- {{ $t('settings.notification_setting_block_from_strangers') }}
- </Checkbox>
- </p>
+ <ul class="setting-list">
+ <li>
+ <BooleanSetting path="serverSide_blockNotificationsFromStrangers">
+ {{ $t('settings.notification_setting_block_from_strangers') }}
+ </BooleanSetting>
+ </li>
+ <li class="select-multiple">
+ <span class="label">{{ $t('settings.notification_visibility') }}</span>
+ <ul class="option-list">
+ <li>
+ <BooleanSetting path="notificationVisibility.likes">
+ {{ $t('settings.notification_visibility_likes') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="notificationVisibility.repeats">
+ {{ $t('settings.notification_visibility_repeats') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="notificationVisibility.follows">
+ {{ $t('settings.notification_visibility_follows') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="notificationVisibility.mentions">
+ {{ $t('settings.notification_visibility_mentions') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="notificationVisibility.moves">
+ {{ $t('settings.notification_visibility_moves') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="notificationVisibility.emojiReactions">
+ {{ $t('settings.notification_visibility_emoji_reactions') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="notificationVisibility.polls">
+ {{ $t('settings.notification_visibility_polls') }}
+ </BooleanSetting>
+ </li>
+ </ul>
+ </li>
+ </ul>
</div>
- <div class="setting-item">
+ <div
+ v-if="expertLevel > 0"
+ class="setting-item"
+ >
<h2>{{ $t('settings.notification_setting_privacy') }}</h2>
- <p>
- <Checkbox v-model="notificationSettings.hide_notification_contents">
- {{ $t('settings.notification_setting_hide_notification_contents') }}
- </Checkbox>
- </p>
+ <ul class="setting-list">
+ <li>
+ <BooleanSetting
+ path="webPushNotifications"
+ expert="1"
+ >
+ {{ $t('settings.enable_web_push_notifications') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="serverSide_webPushHideContents"
+ expert="1"
+ >
+ {{ $t('settings.notification_setting_hide_notification_contents') }}
+ </BooleanSetting>
+ </li>
+ </ul>
</div>
<div class="setting-item">
<p>{{ $t('settings.notification_mutes') }}</p>
<p>{{ $t('settings.notification_blocks') }}</p>
- <button
- class="btn button-default"
- @click="updateNotificationSettings"
- >
- {{ $t('settings.save') }}
- </button>
</div>
</div>
</template>
diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js
index 64079fcd..b86faef0 100644
--- a/src/components/settings_modal/tabs/profile_tab.js
+++ b/src/components/settings_modal/tabs/profile_tab.js
@@ -8,6 +8,11 @@ import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
import suggestor from 'src/components/emoji_input/suggestor.js'
import Autosuggest from 'src/components/autosuggest/autosuggest.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
+import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
+import BooleanSetting from '../helpers/boolean_setting.vue'
+import SharedComputedObject from '../helpers/shared_computed_object.js'
+import localeService from 'src/services/locale/locale.service.js'
+
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faTimes,
@@ -27,25 +32,18 @@ const ProfileTab = {
newName: this.$store.state.users.currentUser.name_unescaped,
newBio: unescape(this.$store.state.users.currentUser.description),
newLocked: this.$store.state.users.currentUser.locked,
- newNoRichText: this.$store.state.users.currentUser.no_rich_text,
- newDefaultScope: this.$store.state.users.currentUser.default_scope,
newFields: this.$store.state.users.currentUser.fields.map(field => ({ name: field.name, value: field.value })),
- hideFollows: this.$store.state.users.currentUser.hide_follows,
- hideFollowers: this.$store.state.users.currentUser.hide_followers,
- hideFollowsCount: this.$store.state.users.currentUser.hide_follows_count,
- hideFollowersCount: this.$store.state.users.currentUser.hide_followers_count,
showRole: this.$store.state.users.currentUser.show_role,
role: this.$store.state.users.currentUser.role,
- discoverable: this.$store.state.users.currentUser.discoverable,
bot: this.$store.state.users.currentUser.bot,
- allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
pickAvatarBtnVisible: true,
bannerUploading: false,
backgroundUploading: false,
banner: null,
bannerPreview: null,
background: null,
- backgroundPreview: null
+ backgroundPreview: null,
+ emailLanguage: this.$store.state.users.currentUser.language || ''
}
},
components: {
@@ -54,26 +52,31 @@ const ProfileTab = {
EmojiInput,
Autosuggest,
ProgressButton,
- Checkbox
+ Checkbox,
+ BooleanSetting,
+ InterfaceLanguageSwitcher
},
computed: {
user () {
return this.$store.state.users.currentUser
},
+ ...SharedComputedObject(),
emojiUserSuggestor () {
return suggestor({
emoji: [
- ...this.$store.state.instance.emoji,
+ ...this.$store.getters.standardEmojiList,
...this.$store.state.instance.customEmoji
],
store: this.$store
})
},
emojiSuggestor () {
- return suggestor({ emoji: [
- ...this.$store.state.instance.emoji,
- ...this.$store.state.instance.customEmoji
- ] })
+ return suggestor({
+ emoji: [
+ ...this.$store.getters.standardEmojiList,
+ ...this.$store.state.instance.customEmoji
+ ]
+ })
},
userSuggestor () {
return suggestor({ store: this.$store })
@@ -114,27 +117,25 @@ const ProfileTab = {
},
methods: {
updateProfile () {
+ const params = {
+ note: this.newBio,
+ locked: this.newLocked,
+ // Backend notation.
+ /* eslint-disable camelcase */
+ display_name: this.newName,
+ fields_attributes: this.newFields.filter(el => el != null),
+ bot: this.bot,
+ show_role: this.showRole
+ /* eslint-enable camelcase */
+ }
+
+ if (this.emailLanguage) {
+ params.language = localeService.internalToBackendLocale(this.emailLanguage)
+ }
+
this.$store.state.api.backendInteractor
- .updateProfile({
- params: {
- note: this.newBio,
- locked: this.newLocked,
- // Backend notation.
- /* eslint-disable camelcase */
- display_name: this.newName,
- fields_attributes: this.newFields.filter(el => el != null),
- default_scope: this.newDefaultScope,
- no_rich_text: this.newNoRichText,
- hide_follows: this.hideFollows,
- hide_followers: this.hideFollowers,
- discoverable: this.discoverable,
- bot: this.bot,
- allow_following_move: this.allowFollowingMove,
- hide_follows_count: this.hideFollowsCount,
- hide_followers_count: this.hideFollowersCount,
- show_role: this.showRole
- /* eslint-enable camelcase */
- } }).then((user) => {
+ .updateProfile({ params })
+ .then((user) => {
this.newFields.splice(user.fields.length)
merge(this.newFields, user.fields)
this.$store.commit('addNewUsers', [user])
@@ -204,8 +205,8 @@ const ProfileTab = {
submitAvatar (cropper, file) {
const that = this
return new Promise((resolve, reject) => {
- function updateAvatar (avatar) {
- that.$store.state.api.backendInteractor.updateProfileImages({ avatar })
+ function updateAvatar (avatar, avatarName) {
+ that.$store.state.api.backendInteractor.updateProfileImages({ avatar, avatarName })
.then((user) => {
that.$store.commit('addNewUsers', [user])
that.$store.commit('setCurrentUser', user)
@@ -218,9 +219,9 @@ const ProfileTab = {
}
if (cropper) {
- cropper.getCroppedCanvas().toBlob(updateAvatar, file.type)
+ cropper.getCroppedCanvas().toBlob((data) => updateAvatar(data, file.name), file.type)
} else {
- updateAvatar(file)
+ updateAvatar(file, file.name)
}
})
},
diff --git a/src/components/settings_modal/tabs/profile_tab.scss b/src/components/settings_modal/tabs/profile_tab.scss
index 111eaed3..201f1a76 100644
--- a/src/components/settings_modal/tabs/profile_tab.scss
+++ b/src/components/settings_modal/tabs/profile_tab.scss
@@ -54,16 +54,20 @@
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
background-color: rgba(0, 0, 0, 0.6);
opacity: 0.7;
- color: white;
width: 1.5em;
height: 1.5em;
text-align: center;
line-height: 1.5em;
font-size: 1.5em;
cursor: pointer;
+
&:hover {
opacity: 1;
}
+
+ svg {
+ color: white;
+ }
}
.oauth-tokens {
@@ -85,7 +89,7 @@
&-bulk-actions {
text-align: right;
padding: 0 1em;
- min-height: 28px;
+ min-height: 2em;
button {
width: 10em;
diff --git a/src/components/settings_modal/tabs/profile_tab.vue b/src/components/settings_modal/tabs/profile_tab.vue
index bb3c301d..642d54ca 100644
--- a/src/components/settings_modal/tabs/profile_tab.vue
+++ b/src/components/settings_modal/tabs/profile_tab.vue
@@ -25,61 +25,6 @@
class="bio resize-height"
/>
</EmojiInput>
- <p>
- <Checkbox v-model="newLocked">
- {{ $t('settings.lock_account_description') }}
- </Checkbox>
- </p>
- <div>
- <label for="default-vis">{{ $t('settings.default_vis') }}</label>
- <div
- id="default-vis"
- class="visibility-tray"
- >
- <scope-selector
- :show-all="true"
- :user-default="newDefaultScope"
- :initial-scope="newDefaultScope"
- :on-scope-change="changeVis"
- />
- </div>
- </div>
- <p>
- <Checkbox v-model="newNoRichText">
- {{ $t('settings.no_rich_text_description') }}
- </Checkbox>
- </p>
- <p>
- <Checkbox v-model="hideFollows">
- {{ $t('settings.hide_follows_description') }}
- </Checkbox>
- </p>
- <p class="setting-subitem">
- <Checkbox
- v-model="hideFollowsCount"
- :disabled="!hideFollows"
- >
- {{ $t('settings.hide_follows_count_description') }}
- </Checkbox>
- </p>
- <p>
- <Checkbox v-model="hideFollowers">
- {{ $t('settings.hide_followers_description') }}
- </Checkbox>
- </p>
- <p class="setting-subitem">
- <Checkbox
- v-model="hideFollowersCount"
- :disabled="!hideFollowers"
- >
- {{ $t('settings.hide_followers_count_description') }}
- </Checkbox>
- </p>
- <p>
- <Checkbox v-model="allowFollowingMove">
- {{ $t('settings.allow_following_move') }}
- </Checkbox>
- </p>
<p v-if="role === 'admin' || role === 'moderator'">
<Checkbox v-model="showRole">
<template v-if="role === 'admin'">
@@ -90,11 +35,6 @@
</template>
</Checkbox>
</p>
- <p>
- <Checkbox v-model="discoverable">
- {{ $t('settings.discoverable') }}
- </Checkbox>
- </p>
<div v-if="maxFields > 0">
<p>{{ $t('settings.profile_fields.label') }}</p>
<div
@@ -128,8 +68,9 @@
class="delete-field button-unstyled -hover-highlight"
@click="deleteField(i)"
>
+ <!-- TODO something is wrong with v-show here -->
<FAIcon
- v-show="newFields.length > 1"
+ v-if="newFields.length > 1"
icon="times"
/>
</button>
@@ -148,6 +89,13 @@
{{ $t('settings.bot') }}
</Checkbox>
</p>
+ <p>
+ <interface-language-switcher
+ :prompt-text="$t('settings.email_language')"
+ :language="emailLanguage"
+ :set-language="val => emailLanguage = val"
+ />
+ </p>
<button
:disabled="newName && newName.length === 0"
class="btn button-default"
@@ -166,14 +114,17 @@
:src="user.profile_image_url_original"
class="current-avatar"
>
- <FAIcon
+ <button
v-if="!isDefaultAvatar && pickAvatarBtnVisible"
:title="$t('settings.reset_avatar')"
- class="reset-button"
- icon="times"
- type="button"
+ class="button-unstyled reset-button"
@click="resetAvatar"
- />
+ >
+ <FAIcon
+ icon="times"
+ type="button"
+ />
+ </button>
</div>
<p>{{ $t('settings.set_new_avatar') }}</p>
<button
@@ -195,14 +146,17 @@
<h2>{{ $t('settings.profile_banner') }}</h2>
<div class="banner-background-preview">
<img :src="user.cover_photo">
- <FAIcon
+ <button
v-if="!isDefaultBanner"
+ class="button-unstyled reset-button"
:title="$t('settings.reset_profile_banner')"
- class="reset-button"
- icon="times"
- type="button"
@click="resetBanner"
- />
+ >
+ <FAIcon
+ icon="times"
+ type="button"
+ />
+ </button>
</div>
<p>{{ $t('settings.set_new_profile_banner') }}</p>
<img
@@ -234,14 +188,17 @@
<h2>{{ $t('settings.profile_background') }}</h2>
<div class="banner-background-preview">
<img :src="user.background_image">
- <FAIcon
+ <button
v-if="!isDefaultBackground"
+ class="button-unstyled reset-button"
:title="$t('settings.reset_profile_background')"
- class="reset-button"
- icon="times"
- type="button"
@click="resetBackground"
- />
+ >
+ <FAIcon
+ icon="times"
+ type="button"
+ />
+ </button>
</div>
<p>{{ $t('settings.set_new_profile_background') }}</p>
<img
@@ -269,6 +226,67 @@
{{ $t('settings.save') }}
</button>
</div>
+ <div class="setting-item">
+ <h2>{{ $t('settings.account_privacy') }}</h2>
+ <ul class="setting-list">
+ <li>
+ <BooleanSetting path="serverSide_locked">
+ {{ $t('settings.lock_account_description') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="serverSide_discoverable">
+ {{ $t('settings.discoverable') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="serverSide_allowFollowingMove">
+ {{ $t('settings.allow_following_move') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="serverSide_hideFavorites">
+ {{ $t('settings.hide_favorites_description') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="serverSide_hideFollowers">
+ {{ $t('settings.hide_followers_description') }}
+ </BooleanSetting>
+ <ul
+ class="setting-list suboptions"
+ :class="[{disabled: !serverSide_hideFollowers}]"
+ >
+ <li>
+ <BooleanSetting
+ path="serverSide_hideFollowersCount"
+ :disabled="!serverSide_hideFollowers"
+ >
+ {{ $t('settings.hide_followers_count_description') }}
+ </BooleanSetting>
+ </li>
+ </ul>
+ </li>
+ <li>
+ <BooleanSetting path="serverSide_hideFollows">
+ {{ $t('settings.hide_follows_description') }}
+ </BooleanSetting>
+ <ul
+ class="setting-list suboptions"
+ :class="[{disabled: !serverSide_hideFollows}]"
+ >
+ <li>
+ <BooleanSetting
+ path="serverSide_hideFollowsCount"
+ :disabled="!serverSide_hideFollows"
+ >
+ {{ $t('settings.hide_follows_count_description') }}
+ </BooleanSetting>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </div>
</div>
</template>
diff --git a/src/components/settings_modal/tabs/security_tab/mfa.js b/src/components/settings_modal/tabs/security_tab/mfa.js
index abf37062..5337d150 100644
--- a/src/components/settings_modal/tabs/security_tab/mfa.js
+++ b/src/components/settings_modal/tabs/security_tab/mfa.js
@@ -32,8 +32,8 @@ const Mfa = {
components: {
'recovery-codes': RecoveryCodes,
'totp-item': TOTP,
- 'qrcode': VueQrcode,
- 'confirm': Confirm
+ qrcode: VueQrcode,
+ confirm: Confirm
},
computed: {
canSetupOTP () {
@@ -139,7 +139,7 @@ const Mfa = {
// fetch settings from server
async fetchSettings () {
- let result = await this.backendInteractor.settingsMFA()
+ const result = await this.backendInteractor.settingsMFA()
if (result.error) return
this.settings = result.settings
this.settings.available = true
diff --git a/src/components/settings_modal/tabs/security_tab/mfa_totp.js b/src/components/settings_modal/tabs/security_tab/mfa_totp.js
index 8408d8e9..b0adb530 100644
--- a/src/components/settings_modal/tabs/security_tab/mfa_totp.js
+++ b/src/components/settings_modal/tabs/security_tab/mfa_totp.js
@@ -10,7 +10,7 @@ export default {
inProgress: false // progress peform request to disable otp method
}),
components: {
- 'confirm': Confirm
+ confirm: Confirm
},
computed: {
isActivated () {
diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.js b/src/components/settings_modal/tabs/security_tab/security_tab.js
index 65d20fc0..d253bc79 100644
--- a/src/components/settings_modal/tabs/security_tab/security_tab.js
+++ b/src/components/settings_modal/tabs/security_tab/security_tab.js
@@ -13,13 +13,23 @@ const SecurityTab = {
deletingAccount: false,
deleteAccountConfirmPasswordInput: '',
deleteAccountError: false,
- changePasswordInputs: [ '', '', '' ],
+ changePasswordInputs: ['', '', ''],
changedPassword: false,
- changePasswordError: false
+ changePasswordError: false,
+ moveAccountTarget: '',
+ moveAccountPassword: '',
+ movedAccount: false,
+ moveAccountError: false,
+ aliases: [],
+ listAliasesError: false,
+ addAliasTarget: '',
+ addedAlias: false,
+ addAliasError: false
}
},
created () {
this.$store.dispatch('fetchTokens')
+ this.fetchAliases()
},
components: {
ProgressButton,
@@ -92,6 +102,49 @@ const SecurityTab = {
}
})
},
+ moveAccount () {
+ const params = {
+ targetAccount: this.moveAccountTarget,
+ password: this.moveAccountPassword
+ }
+ this.$store.state.api.backendInteractor.moveAccount(params)
+ .then((res) => {
+ if (res.status === 'success') {
+ this.movedAccount = true
+ this.moveAccountError = false
+ } else {
+ this.movedAccount = false
+ this.moveAccountError = res.error
+ }
+ })
+ },
+ removeAlias (alias) {
+ this.$store.state.api.backendInteractor.deleteAlias({ alias })
+ .then(() => this.fetchAliases())
+ },
+ addAlias () {
+ this.$store.state.api.backendInteractor.addAlias({ alias: this.addAliasTarget })
+ .then((res) => {
+ this.addedAlias = true
+ this.addAliasError = false
+ this.addAliasTarget = ''
+ })
+ .catch((error) => {
+ this.addedAlias = false
+ this.addAliasError = error
+ })
+ .then(() => this.fetchAliases())
+ },
+ fetchAliases () {
+ this.$store.state.api.backendInteractor.listAliases()
+ .then((res) => {
+ this.aliases = res.aliases
+ this.listAliasesError = false
+ })
+ .catch((error) => {
+ this.listAliasesError = error.error
+ })
+ },
logout () {
this.$store.dispatch('logout')
this.$router.replace('/')
diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.vue b/src/components/settings_modal/tabs/security_tab/security_tab.vue
index 275d4616..6e03bef4 100644
--- a/src/components/settings_modal/tabs/security_tab/security_tab.vue
+++ b/src/components/settings_modal/tabs/security_tab/security_tab.vue
@@ -103,6 +103,114 @@
</table>
</div>
<mfa />
+
+ <div class="setting-item">
+ <h2>{{ $t('settings.account_alias') }}</h2>
+ <table>
+ <thead>
+ <tr>
+ <th>{{ $t('settings.account_alias_table_head') }}</th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ <tr
+ v-for="alias in aliases"
+ :key="alias"
+ >
+ <td>{{ alias }}</td>
+ <td class="actions">
+ <button
+ class="btn button-default"
+ @click="removeAlias(alias)"
+ >
+ {{ $t('settings.remove_alias') }}
+ </button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ <div
+ v-if="listAliasesError"
+ class="alert error"
+ >
+ {{ $t('settings.list_aliases_error', { error }) }}
+ <FAIcon
+ class="fa-scale-110 fa-old-padding"
+ icon="times"
+ :title="$t('settings.hide_list_aliases_error_action')"
+ @click="listAliasesError = false"
+ />
+ </div>
+ <div>
+ <i18n
+ path="settings.new_alias_target"
+ tag="p"
+ >
+ <code
+ place="example"
+ >
+ foo@example.org
+ </code>
+ </i18n>
+ <input
+ v-model="addAliasTarget"
+ >
+ </div>
+ <button
+ class="btn button-default"
+ @click="addAlias"
+ >
+ {{ $t('settings.save') }}
+ </button>
+ <p v-if="addedAlias">
+ {{ $t('settings.added_alias') }}
+ </p>
+ <template v-if="addAliasError !== false">
+ <p>{{ $t('settings.add_alias_error', { error: addAliasError }) }}</p>
+ </template>
+ </div>
+
+ <div class="setting-item">
+ <h2>{{ $t('settings.move_account') }}</h2>
+ <p>{{ $t('settings.move_account_notes') }}</p>
+ <div>
+ <i18n
+ path="settings.move_account_target"
+ tag="p"
+ >
+ <code
+ place="example"
+ >
+ foo@example.org
+ </code>
+ </i18n>
+ <input
+ v-model="moveAccountTarget"
+ >
+ </div>
+ <div>
+ <p>{{ $t('settings.current_password') }}</p>
+ <input
+ v-model="moveAccountPassword"
+ type="password"
+ autocomplete="current-password"
+ >
+ </div>
+ <button
+ class="btn button-default"
+ @click="moveAccount"
+ >
+ {{ $t('settings.save') }}
+ </button>
+ <p v-if="movedAccount">
+ {{ $t('settings.moved_account') }}
+ </p>
+ <template v-if="moveAccountError !== false">
+ <p>{{ $t('settings.move_account_error', { error: moveAccountError }) }}</p>
+ </template>
+ </div>
+
<div class="setting-item">
<h2>{{ $t('settings.delete_account') }}</h2>
<p v-if="!deletingAccount">
@@ -133,7 +241,7 @@
class="btn button-default"
@click="confirmDelete"
>
- {{ $t('settings.save') }}
+ {{ $t('settings.delete_account') }}
</button>
</div>
</div>
diff --git a/src/components/settings_modal/tabs/theme_tab/preview.vue b/src/components/settings_modal/tabs/theme_tab/preview.vue
index 7ac7b9d3..ba6bd529 100644
--- a/src/components/settings_modal/tabs/theme_tab/preview.vue
+++ b/src/components/settings_modal/tabs/theme_tab/preview.vue
@@ -29,14 +29,17 @@
{{ $t('settings.style.preview.content') }}
</h4>
- <i18n path="settings.style.preview.text">
+ <i18n-t
+ scope="global"
+ keypath="settings.style.preview.text"
+ >
<code style="font-family: var(--postCodeFont)">
{{ $t('settings.style.preview.mono') }}
</code>
<a style="color: var(--link)">
{{ $t('settings.style.preview.link') }}
</a>
- </i18n>
+ </i18n-t>
<div class="icons">
<FAIcon
@@ -72,15 +75,16 @@
:^)
</div>
<div class="content">
- <i18n
- path="settings.style.preview.fine_print"
+ <i18n-t
+ keypath="settings.style.preview.fine_print"
tag="span"
class="faint"
+ scope="global"
>
<a style="color: var(--faintLink)">
{{ $t('settings.style.preview.faint_link') }}
</a>
- </i18n>
+ </i18n-t>
</div>
</div>
<div class="separator" />
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
index 0b6669fc..4a739f73 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
@@ -1,4 +1,3 @@
-import { set, delete as del } from 'vue'
import {
rgb2hex,
hex2rgb,
@@ -34,7 +33,7 @@ import OpacityInput from 'src/components/opacity_input/opacity_input.vue'
import ShadowControl from 'src/components/shadow_control/shadow_control.vue'
import FontControl from 'src/components/font_control/font_control.vue'
import ContrastRatio from 'src/components/contrast_ratio/contrast_ratio.vue'
-import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Select from 'src/components/select/select.vue'
@@ -96,11 +95,11 @@ export default {
...Object.keys(SLOT_INHERITANCE)
.map(key => [key, ''])
- .reduce((acc, [key, val]) => ({ ...acc, [ key + 'ColorLocal' ]: val }), {}),
+ .reduce((acc, [key, val]) => ({ ...acc, [key + 'ColorLocal']: val }), {}),
...Object.keys(OPACITIES)
.map(key => [key, ''])
- .reduce((acc, [key, val]) => ({ ...acc, [ key + 'OpacityLocal' ]: val }), {}),
+ .reduce((acc, [key, val]) => ({ ...acc, [key + 'OpacityLocal']: val }), {}),
shadowSelected: undefined,
shadowsLocal: {},
@@ -213,12 +212,12 @@ export default {
currentColors () {
return Object.keys(SLOT_INHERITANCE)
.map(key => [key, this[key + 'ColorLocal']])
- .reduce((acc, [key, val]) => ({ ...acc, [ key ]: val }), {})
+ .reduce((acc, [key, val]) => ({ ...acc, [key]: val }), {})
},
currentOpacity () {
return Object.keys(OPACITIES)
.map(key => [key, this[key + 'OpacityLocal']])
- .reduce((acc, [key, val]) => ({ ...acc, [ key ]: val }), {})
+ .reduce((acc, [key, val]) => ({ ...acc, [key]: val }), {})
},
currentRadii () {
return {
@@ -280,6 +279,9 @@ export default {
opacity
)
+ // Temporary patch for null-y value errors
+ if (layers.flat().some(v => v == null)) return acc
+
return {
...acc,
...textColors.reduce((acc, textColorKey) => {
@@ -301,6 +303,7 @@ export default {
return Object.entries(ratios).reduce((acc, [k, v]) => { acc[k] = hints(v); return acc }, {})
} catch (e) {
console.warn('Failure computing contrasts', e)
+ return {}
}
},
previewRules () {
@@ -320,9 +323,9 @@ export default {
},
set (val) {
if (val) {
- set(this.shadowsLocal, this.shadowSelected, this.currentShadowFallback.map(_ => Object.assign({}, _)))
+ this.shadowsLocal[this.shadowSelected] = this.currentShadowFallback.map(_ => Object.assign({}, _))
} else {
- del(this.shadowsLocal, this.shadowSelected)
+ delete this.shadowsLocal[this.shadowSelected]
}
}
},
@@ -334,7 +337,7 @@ export default {
return this.shadowsLocal[this.shadowSelected]
},
set (v) {
- set(this.shadowsLocal, this.shadowSelected, v)
+ this.shadowsLocal[this.shadowSelected] = v
}
},
themeValid () {
@@ -378,6 +381,10 @@ export default {
// To separate from other random JSON files and possible future source formats
_pleroma_theme_version: 2, theme, source
}
+ },
+ isActive () {
+ const tabSwitcher = this.$parent
+ return tabSwitcher ? tabSwitcher.isActive('theme') : false
}
},
components: {
@@ -557,7 +564,7 @@ export default {
.filter(_ => _.endsWith('ColorLocal') || _.endsWith('OpacityLocal'))
.filter(_ => !v1OnlyNames.includes(_))
.forEach(key => {
- set(this.$data, key, undefined)
+ this.$data[key] = undefined
})
},
@@ -565,7 +572,7 @@ export default {
Object.keys(this.$data)
.filter(_ => _.endsWith('RadiusLocal'))
.forEach(key => {
- set(this.$data, key, undefined)
+ this.$data[key] = undefined
})
},
@@ -573,7 +580,7 @@ export default {
Object.keys(this.$data)
.filter(_ => _.endsWith('OpacityLocal'))
.forEach(key => {
- set(this.$data, key, undefined)
+ this.$data[key] = undefined
})
},
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.scss b/src/components/settings_modal/tabs/theme_tab/theme_tab.scss
index 0db21537..bad6f51b 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.scss
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.scss
@@ -245,36 +245,12 @@
border-color: var(--border, $fallback--border);
}
- .panel-heading {
- .badge, .alert, .btn, .faint {
- margin-left: 1em;
- white-space: nowrap;
- }
- .faint {
- text-overflow: ellipsis;
- min-width: 2em;
- overflow-x: hidden;
- }
- .flex-spacer {
- flex: 1;
- }
- }
.btn {
- margin-left: 0;
- padding: 0 1em;
min-width: 3em;
- min-height: 30px;
}
}
}
- .apply-container {
- justify-content: center;
- position: absolute;
- bottom: 8px;
- right: 5px;
- }
-
.radius-item,
.color-item {
min-width: 20em;
@@ -334,16 +310,25 @@
padding: 20px;
}
- .apply-container {
- .btn {
- min-height: 28px;
- min-width: 10em;
- padding: 0 2em;
- }
- }
-
.btn {
margin-left: .25em;
margin-right: .25em;
}
}
+
+.extra-content {
+ .apply-container {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-around;
+ flex-grow: 1;
+
+ .btn {
+ flex-grow: 1;
+ min-height: 2em;
+ min-width: 0;
+ max-width: 10em;
+ padding: 0;
+ }
+ }
+}
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
index c02986ed..ff2fece9 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
@@ -903,6 +903,7 @@
<div class="tab-header shadow-selector">
<div class="select-container">
{{ $t('settings.style.shadows.component') }}
+ {{ ' ' }}
<Select
id="shadow-switcher"
v-model="shadowSelected"
@@ -924,6 +925,7 @@
>
{{ $t('settings.style.shadows.override') }}
</label>
+ {{ ' ' }}
<input
id="override"
v-model="currentShadowOverriden"
@@ -949,27 +951,30 @@
:fallback="currentShadowFallback"
/>
<div v-if="shadowSelected === 'avatar' || shadowSelected === 'avatarStatus'">
- <i18n
- path="settings.style.shadows.filter_hint.always_drop_shadow"
+ <i18n-t
+ scope="global"
+ keypath="settings.style.shadows.filter_hint.always_drop_shadow"
tag="p"
>
<code>filter: drop-shadow()</code>
- </i18n>
+ </i18n-t>
<p>{{ $t('settings.style.shadows.filter_hint.avatar_inset') }}</p>
- <i18n
- path="settings.style.shadows.filter_hint.drop_shadow_syntax"
+ <i18n-t
+ scope="global"
+ keypath="settings.style.shadows.filter_hint.drop_shadow_syntax"
tag="p"
>
<code>drop-shadow</code>
<code>spread-radius</code>
<code>inset</code>
- </i18n>
- <i18n
- path="settings.style.shadows.filter_hint.inset_classic"
+ </i18n-t>
+ <i18n-t
+ scope="global"
+ keypath="settings.style.shadows.filter_hint.inset_classic"
tag="p"
>
<code>box-shadow</code>
- </i18n>
+ </i18n-t>
<p>{{ $t('settings.style.shadows.filter_hint.spread_zero') }}</p>
</div>
</div>
@@ -1016,21 +1021,26 @@
</tab-switcher>
</keep-alive>
- <div class="apply-container">
- <button
- class="btn button-default submit"
- :disabled="!themeValid"
- @click="setCustomTheme"
- >
- {{ $t('general.apply') }}
- </button>
- <button
- class="btn button-default"
- @click="clearAll"
- >
- {{ $t('settings.style.switcher.reset') }}
- </button>
- </div>
+ <teleport
+ v-if="isActive"
+ to="#unscrolled-content"
+ >
+ <div class="apply-container">
+ <button
+ class="btn button-default submit"
+ :disabled="!themeValid"
+ @click="setCustomTheme"
+ >
+ {{ $t('general.apply') }}
+ </button>
+ <button
+ class="btn button-default"
+ @click="clearAll"
+ >
+ {{ $t('settings.style.switcher.reset') }}
+ </button>
+ </div>
+ </teleport>
</div>
</template>
diff --git a/src/components/settings_modal/tabs/version_tab.vue b/src/components/settings_modal/tabs/version_tab.vue
index d35ff25e..0330d49f 100644
--- a/src/components/settings_modal/tabs/version_tab.vue
+++ b/src/components/settings_modal/tabs/version_tab.vue
@@ -28,4 +28,4 @@
</div>
</div>
</template>
-<script src="./version_tab.js">
+<script src="./version_tab.js" />
diff --git a/src/components/shadow_control/shadow_control.js b/src/components/shadow_control/shadow_control.js
index 2d5d6eb1..a1d1012b 100644
--- a/src/components/shadow_control/shadow_control.js
+++ b/src/components/shadow_control/shadow_control.js
@@ -30,18 +30,19 @@ const toModel = (object = {}) => ({
})
export default {
- // 'Value' and 'Fallback' can be undefined, but if they are
+ // 'modelValue' and 'Fallback' can be undefined, but if they are
// initially vue won't detect it when they become something else
// therefore i'm using "ready" which should be passed as true when
// data becomes available
props: [
- 'value', 'fallback', 'ready'
+ 'modelValue', 'fallback', 'ready'
],
+ emits: ['update:modelValue'],
data () {
return {
selectedId: 0,
// TODO there are some bugs regarding display of array (it's not getting updated when deleting for some reason)
- cValue: (this.value || this.fallback || []).map(toModel)
+ cValue: (this.modelValue || this.fallback || []).map(toModel)
}
},
components: {
@@ -70,7 +71,7 @@ export default {
}
},
beforeUpdate () {
- this.cValue = this.value || this.fallback
+ this.cValue = this.modelValue || this.fallback
},
computed: {
anyShadows () {
@@ -105,15 +106,17 @@ export default {
!this.usingFallback
},
usingFallback () {
- return typeof this.value === 'undefined'
+ return typeof this.modelValue === 'undefined'
},
rgb () {
return hex2rgb(this.selected.color)
},
style () {
- return this.ready ? {
- boxShadow: getCssShadow(this.fallback)
- } : {}
+ return this.ready
+ ? {
+ boxShadow: getCssShadow(this.fallback)
+ }
+ : {}
}
}
}
diff --git a/src/components/shadow_control/shadow_control.vue b/src/components/shadow_control/shadow_control.vue
index 511e07f3..669cac71 100644
--- a/src/components/shadow_control/shadow_control.vue
+++ b/src/components/shadow_control/shadow_control.vue
@@ -204,17 +204,18 @@
v-model="selected.alpha"
:disabled="!present"
/>
- <i18n
- path="settings.style.shadows.hintV3"
+ <i18n-t
+ scope="global"
+ keypath="settings.style.shadows.hintV3"
tag="p"
>
<code>--variable,mod</code>
- </i18n>
+ </i18n-t>
</div>
</div>
</template>
-<script src="./shadow_control.js" ></script>
+<script src="./shadow_control.js"></script>
<style lang="scss">
@import '../../_variables.scss';
diff --git a/src/components/shout_panel/shout_panel.js b/src/components/shout_panel/shout_panel.js
index a6168971..fb0c5aa2 100644
--- a/src/components/shout_panel/shout_panel.js
+++ b/src/components/shout_panel/shout_panel.js
@@ -11,7 +11,7 @@ library.add(
)
const shoutPanel = {
- props: [ 'floating' ],
+ props: ['floating'],
data () {
return {
currentMessage: '',
diff --git a/src/components/shout_panel/shout_panel.vue b/src/components/shout_panel/shout_panel.vue
index c88797d1..688c2d61 100644
--- a/src/components/shout_panel/shout_panel.vue
+++ b/src/components/shout_panel/shout_panel.vue
@@ -57,7 +57,7 @@
>
<div class="panel panel-default">
<div
- class="panel-heading stub timeline-heading shout-heading"
+ class="panel-heading -stub timeline-heading shout-heading"
@click.stop.prevent="togglePanel"
>
<div class="title">
@@ -79,17 +79,17 @@
.floating-shout {
position: fixed;
- bottom: 0px;
- z-index: 1000;
+ bottom: 0.5em;
+ z-index: var(--ZI_popovers);
max-width: 25em;
-}
-.floating-shout.left {
- left: 0px;
-}
+ &.-left {
+ left: 0.5em;
+ }
-.floating-shout:not(.left) {
- right: 0px;
+ &:not(.-left) {
+ right: 0.5em;
+ }
}
.shout-panel {
@@ -98,7 +98,7 @@
.icon {
color: $fallback--text;
- color: var(--text, $fallback--text);
+ color: var(--panelText, $fallback--text);
margin-right: 0.5em;
}
@@ -121,7 +121,7 @@
.shout-message {
display: flex;
- padding: 0.2em 0.5em
+ padding: 0.2em 0.5em;
}
.shout-avatar {
@@ -137,6 +137,7 @@
.shout-input {
display: flex;
+
textarea {
flex: 1;
margin: 0.6em;
diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js
index 89719df3..27019577 100644
--- a/src/components/side_drawer/side_drawer.js
+++ b/src/components/side_drawer/side_drawer.js
@@ -2,6 +2,7 @@ import { mapState, mapGetters } from 'vuex'
import UserCard from '../user_card/user_card.vue'
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
import GestureService from '../../services/gesture_service/gesture_service'
+import { USERNAME_ROUTES } from 'src/components/navigation/navigation.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faSignInAlt,
@@ -14,7 +15,9 @@ import {
faSearch,
faTachometerAlt,
faCog,
- faInfoCircle
+ faInfoCircle,
+ faCompass,
+ faList
} from '@fortawesome/free-solid-svg-icons'
library.add(
@@ -28,11 +31,13 @@ library.add(
faSearch,
faTachometerAlt,
faCog,
- faInfoCircle
+ faInfoCircle,
+ faCompass,
+ faList
)
const SideDrawer = {
- props: [ 'logout' ],
+ props: ['logout'],
data: () => ({
closed: true,
closeGesture: undefined
@@ -49,7 +54,7 @@ const SideDrawer = {
currentUser () {
return this.$store.state.users.currentUser
},
- shout () { return this.$store.state.shout.channel.state === 'joined' },
+ shout () { return this.$store.state.shout.joined },
unseenNotifications () {
return unseenNotificationsFromStore(this.$store)
},
@@ -78,15 +83,22 @@ const SideDrawer = {
return this.$store.state.instance.federating
},
timelinesRoute () {
+ let name
if (this.$store.state.interface.lastTimeline) {
- return this.$store.state.interface.lastTimeline
+ name = this.$store.state.interface.lastTimeline
+ }
+ name = this.currentUser ? 'friends' : 'public-timeline'
+ if (USERNAME_ROUTES.has(name)) {
+ return { name, params: { username: this.currentUser.screen_name } }
+ } else {
+ return { name }
}
- return this.currentUser ? 'friends' : 'public-timeline'
},
...mapState({
- pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
+ pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable,
+ supportsAnnouncements: state => state.announcements.supportsAnnouncements
}),
- ...mapGetters(['unreadChatCount'])
+ ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount'])
},
methods: {
toggleDrawer () {
diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue
index dd88de7d..887596f8 100644
--- a/src/components/side_drawer/side_drawer.vue
+++ b/src/components/side_drawer/side_drawer.vue
@@ -47,7 +47,7 @@
v-if="currentUser || !privateMode"
@click="toggleDrawer"
>
- <router-link :to="{ name: timelinesRoute }">
+ <router-link :to="timelinesRoute">
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
@@ -56,6 +56,18 @@
</router-link>
</li>
<li
+ v-if="currentUser"
+ @click="toggleDrawer"
+ >
+ <router-link :to="{ name: 'lists' }">
+ <FAIcon
+ fixed-width
+ class="fa-scale-110 fa-old-padding"
+ icon="list"
+ /> {{ $t("nav.lists") }}
+ </router-link>
+ </li>
+ <li
v-if="currentUser && pleromaChatMessagesAvailable"
@click="toggleDrawer"
>
@@ -180,6 +192,38 @@
</a>
</li>
<li
+ v-if="currentUser && supportsAnnouncements"
+ @click="toggleDrawer"
+ >
+ <router-link
+ :to="{ name: 'announcements' }"
+ >
+ <FAIcon
+ fixed-width
+ class="fa-scale-110 fa-old-padding"
+ icon="bullhorn"
+ /> {{ $t("nav.announcements") }}
+ <span
+ v-if="unreadAnnouncementCount"
+ class="badge badge-notification"
+ >
+ {{ unreadAnnouncementCount }}
+ </span>
+ </router-link>
+ </li>
+ <li
+ v-if="currentUser"
+ @click="toggleDrawer"
+ >
+ <router-link :to="{ name: 'edit-navigation' }">
+ <FAIcon
+ fixed-width
+ class="fa-scale-110 fa-old-padding"
+ icon="compass"
+ /> {{ $t("nav.edit_nav_mobile") }}
+ </router-link>
+ </li>
+ <li
v-if="currentUser"
@click="toggleDrawer"
>
@@ -204,14 +248,14 @@
</div>
</template>
-<script src="./side_drawer.js" ></script>
+<script src="./side_drawer.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.side-drawer-container {
position: fixed;
- z-index: 1000;
+ z-index: var(--ZI_navbar);
top: 0;
left: 0;
width: 100%;
diff --git a/src/components/staff_panel/staff_panel.js b/src/components/staff_panel/staff_panel.js
index b9561bf1..46a92ac7 100644
--- a/src/components/staff_panel/staff_panel.js
+++ b/src/components/staff_panel/staff_panel.js
@@ -13,16 +13,16 @@ const StaffPanel = {
},
computed: {
groupedStaffAccounts () {
- const staffAccounts = map(this.staffAccounts, this.findUser).filter(_ => _)
+ const staffAccounts = map(this.staffAccounts, this.findUserByName).filter(_ => _)
const groupedStaffAccounts = groupBy(staffAccounts, 'role')
return [
- { role: 'admin', users: groupedStaffAccounts['admin'] },
- { role: 'moderator', users: groupedStaffAccounts['moderator'] }
+ { role: 'admin', users: groupedStaffAccounts.admin },
+ { role: 'moderator', users: groupedStaffAccounts.moderator }
].filter(group => group.users)
},
...mapGetters([
- 'findUser'
+ 'findUserByName'
]),
...mapState({
staffAccounts: state => state.instance.staffAccounts
diff --git a/src/components/staff_panel/staff_panel.vue b/src/components/staff_panel/staff_panel.vue
index c52ade42..6b9e61f2 100644
--- a/src/components/staff_panel/staff_panel.vue
+++ b/src/components/staff_panel/staff_panel.vue
@@ -24,7 +24,7 @@
</div>
</template>
-<script src="./staff_panel.js" ></script>
+<script src="./staff_panel.js"></script>
<style lang="scss">
diff --git a/src/components/status/status.js b/src/components/status/status.js
index ac481534..9a9bca7a 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -4,15 +4,16 @@ import ReactButton from '../react_button/react_button.vue'
import RetweetButton from '../retweet_button/retweet_button.vue'
import ExtraButtons from '../extra_buttons/extra_buttons.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue'
-import UserCard from '../user_card/user_card.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import AvatarList from '../avatar_list/avatar_list.vue'
import Timeago from '../timeago/timeago.vue'
import StatusContent from '../status_content/status_content.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import StatusPopover from '../status_popover/status_popover.vue'
+import UserPopover from '../user_popover/user_popover.vue'
import UserListPopover from '../user_list_popover/user_list_popover.vue'
import EmojiReactions from '../emoji_reactions/emoji_reactions.vue'
+import UserLink from '../user_link/user_link.vue'
import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import MentionLink from 'src/components/mention_link/mention_link.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@@ -35,7 +36,10 @@ import {
faStar,
faEyeSlash,
faEye,
- faThumbtack
+ faThumbtack,
+ faChevronUp,
+ faChevronDown,
+ faAngleDoubleRight
} from '@fortawesome/free-solid-svg-icons'
library.add(
@@ -52,9 +56,47 @@ library.add(
faEllipsisH,
faEyeSlash,
faEye,
- faThumbtack
+ faThumbtack,
+ faChevronUp,
+ faChevronDown,
+ faAngleDoubleRight
)
+const camelCase = name => name.charAt(0).toUpperCase() + name.slice(1)
+
+const controlledOrUncontrolledGetters = list => list.reduce((res, name) => {
+ const camelized = camelCase(name)
+ const toggle = `controlledToggle${camelized}`
+ const controlledName = `controlled${camelized}`
+ const uncontrolledName = `uncontrolled${camelized}`
+ res[name] = function () {
+ return ((this.$data[toggle] !== undefined || this.$props[toggle] !== undefined) && this[toggle]) ? this[controlledName] : this[uncontrolledName]
+ }
+ return res
+}, {})
+
+const controlledOrUncontrolledToggle = (obj, name) => {
+ const camelized = camelCase(name)
+ const toggle = `controlledToggle${camelized}`
+ const uncontrolledName = `uncontrolled${camelized}`
+ if (obj[toggle]) {
+ obj[toggle]()
+ } else {
+ obj[uncontrolledName] = !obj[uncontrolledName]
+ }
+}
+
+const controlledOrUncontrolledSet = (obj, name, val) => {
+ const camelized = camelCase(name)
+ const set = `controlledSet${camelized}`
+ const uncontrolledName = `uncontrolled${camelized}`
+ if (obj[set]) {
+ obj[set](val)
+ } else {
+ obj[uncontrolledName] = val
+ }
+}
+
const Status = {
name: 'Status',
components: {
@@ -64,7 +106,6 @@ const Status = {
RetweetButton,
ExtraButtons,
PostStatusForm,
- UserCard,
UserAvatar,
AvatarList,
Timeago,
@@ -74,7 +115,9 @@ const Status = {
StatusContent,
RichContent,
MentionLink,
- MentionsLine
+ MentionsLine,
+ UserPopover,
+ UserLink
},
props: [
'statusoid',
@@ -89,20 +132,38 @@ const Status = {
'inlineExpanded',
'showPinned',
'inProfile',
- 'profileUserId'
+ 'profileUserId',
+
+ 'simpleTree',
+ 'controlledThreadDisplayStatus',
+ 'controlledToggleThreadDisplay',
+ 'showOtherRepliesAsButton',
+
+ 'controlledShowingTall',
+ 'controlledToggleShowingTall',
+ 'controlledExpandingSubject',
+ 'controlledToggleExpandingSubject',
+ 'controlledShowingLongSubject',
+ 'controlledToggleShowingLongSubject',
+ 'controlledReplying',
+ 'controlledToggleReplying',
+ 'controlledMediaPlaying',
+ 'controlledSetMediaPlaying',
+ 'dive'
],
data () {
return {
- replying: false,
+ uncontrolledReplying: false,
unmuted: false,
userExpanded: false,
- mediaPlaying: [],
+ uncontrolledMediaPlaying: [],
suspendable: true,
error: null,
headTailLinks: null
}
},
computed: {
+ ...controlledOrUncontrolledGetters(['replying', 'mediaPlaying']),
muteWords () {
return this.mergedConfig.muteWords
},
@@ -166,6 +227,18 @@ const Status = {
muteWordHits () {
return muteWordHits(this.status, this.muteWords)
},
+ rtBotStatus () {
+ return this.statusoid.user.bot
+ },
+ botStatus () {
+ return this.status.user.bot
+ },
+ botIndicator () {
+ return this.botStatus && !this.hideBotIndication
+ },
+ rtBotIndicator () {
+ return this.rtBotStatus && !this.hideBotIndication
+ },
mentionsLine () {
if (!this.headTailLinks) return []
const writtenSet = new Set(this.headTailLinks.writtenMentions.map(_ => _.url))
@@ -187,25 +260,33 @@ const Status = {
},
muted () {
if (this.statusoid.user.id === this.currentUser.id) return false
+ const reasonsToMute = this.userIsMuted ||
+ // Thread is muted
+ status.thread_muted ||
+ // Wordfiltered
+ this.muteWordHits.length > 0 ||
+ // bot status
+ (this.muteBotStatuses && this.botStatus && !this.compact)
+ return !this.unmuted && !this.shouldNotMute && reasonsToMute
+ },
+ userIsMuted () {
+ if (this.statusoid.user.id === this.currentUser.id) return false
const { status } = this
const { reblog } = status
const relationship = this.$store.getters.relationship(status.user.id)
const relationshipReblog = reblog && this.$store.getters.relationship(reblog.user.id)
- const reasonsToMute = (
- // Post is muted according to BE
- status.muted ||
+ return status.muted ||
// ReprÃļÃļt of a muted post according to BE
(reblog && reblog.muted) ||
// Muted user
relationship.muting ||
// Muted user of a reprÃļÃļt
- (relationshipReblog && relationshipReblog.muting) ||
- // Thread is muted
- status.thread_muted ||
- // Wordfiltered
- this.muteWordHits.length > 0
- )
- const excusesNotToMute = (
+ (relationshipReblog && relationshipReblog.muting)
+ },
+ shouldNotMute () {
+ const { status } = this
+ const { reblog } = status
+ return (
(
this.inProfile && (
// Don't mute user's posts on user timeline (except reblogs)
@@ -218,14 +299,26 @@ const Status = {
(this.inConversation && status.thread_muted)
// No excuses if post has muted words
) && !this.muteWordHits.length > 0
-
- return !this.unmuted && !excusesNotToMute && reasonsToMute
+ },
+ hideMutedUsers () {
+ return this.mergedConfig.hideMutedPosts
+ },
+ hideMutedThreads () {
+ return this.mergedConfig.hideMutedThreads
},
hideFilteredStatuses () {
return this.mergedConfig.hideFilteredStatuses
},
+ hideWordFilteredPosts () {
+ return this.mergedConfig.hideWordFilteredPosts
+ },
hideStatus () {
- return (this.muted && this.hideFilteredStatuses) || this.virtualHidden
+ return (!this.shouldNotMute) && (
+ (this.muted && this.hideFilteredStatuses) ||
+ (this.userIsMuted && this.hideMutedUsers) ||
+ (this.status.thread_muted && this.hideMutedThreads) ||
+ (this.muteWordHits.length > 0 && this.hideWordFilteredPosts)
+ )
},
isFocused () {
// retweet or root of an expanded conversation
@@ -270,11 +363,18 @@ const Status = {
return uniqBy(combinedUsers, 'id')
},
tags () {
+ // eslint-disable-next-line no-prototype-builtins
return this.status.tags.filter(tagObj => tagObj.hasOwnProperty('name')).map(tagObj => tagObj.name).join(' ')
},
hidePostStats () {
return this.mergedConfig.hidePostStats
},
+ muteBotStatuses () {
+ return this.mergedConfig.muteBotStatuses
+ },
+ hideBotIndication () {
+ return this.mergedConfig.hideBotIndication
+ },
currentUser () {
return this.$store.state.users.currentUser
},
@@ -286,6 +386,21 @@ const Status = {
},
isSuspendable () {
return !this.replying && this.mediaPlaying.length === 0
+ },
+ inThreadForest () {
+ return !!this.controlledThreadDisplayStatus
+ },
+ threadShowing () {
+ return this.controlledThreadDisplayStatus === 'showing'
+ },
+ visibilityLocalized () {
+ return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility)
+ },
+ isEdited () {
+ return this.status.edited_at !== null
+ },
+ editingAvailable () {
+ return this.$store.state.instance.editingAvailable
}
},
methods: {
@@ -308,7 +423,7 @@ const Status = {
this.error = undefined
},
toggleReplying () {
- this.replying = !this.replying
+ controlledOrUncontrolledToggle(this, 'replying')
},
gotoOriginal (id) {
if (this.inConversation) {
@@ -328,19 +443,21 @@ const Status = {
return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
},
addMediaPlaying (id) {
- this.mediaPlaying.push(id)
+ controlledOrUncontrolledSet(this, 'mediaPlaying', this.mediaPlaying.concat(id))
},
removeMediaPlaying (id) {
- this.mediaPlaying = this.mediaPlaying.filter(mediaId => mediaId !== id)
+ controlledOrUncontrolledSet(this, 'mediaPlaying', this.mediaPlaying.filter(mediaId => mediaId !== id))
},
setHeadTailLinks (headTailLinks) {
this.headTailLinks = headTailLinks
- }
- },
- watch: {
- 'highlight': function (id) {
+ },
+ toggleThreadDisplay () {
+ this.controlledToggleThreadDisplay()
+ },
+ scrollIfHighlighted (highlightId) {
+ const id = highlightId
if (this.status.id === id) {
- let rect = this.$el.getBoundingClientRect()
+ const rect = this.$el.getBoundingClientRect()
if (rect.top < 100) {
// Post is above screen, match its top to screen top
window.scrollBy(0, rect.top - 100)
@@ -352,6 +469,11 @@ const Status = {
window.scrollBy(0, rect.bottom - window.innerHeight + 50)
}
}
+ }
+ },
+ watch: {
+ highlight: function (id) {
+ this.scrollIfHighlighted(id)
},
'status.repeat_num': function (num) {
// refetch repeats when repeat_num is changed in any way
@@ -365,14 +487,9 @@ const Status = {
this.$store.dispatch('fetchFavs', this.status.id)
}
},
- 'isSuspendable': function (val) {
+ isSuspendable: function (val) {
this.suspendable = val
}
- },
- filters: {
- capitalize: function (str) {
- return str.charAt(0).toUpperCase() + str.slice(1)
- }
}
}
diff --git a/src/components/status/status.scss b/src/components/status/status.scss
index 71305dd7..ada9841e 100644
--- a/src/components/status/status.scss
+++ b/src/components/status/status.scss
@@ -1,10 +1,10 @@
@import '../../_variables.scss';
-$status-margin: 0.75em;
-
.Status {
min-width: 0;
white-space: normal;
+ word-wrap: break-word;
+ word-break: break-word;
&:hover {
--_still-image-img-visibility: visible;
@@ -26,15 +26,8 @@ $status-margin: 0.75em;
--icon: var(--selectedPostIcon, $fallback--icon);
}
- &.-conversation {
- border-left-width: 4px;
- border-left-style: solid;
- border-left-color: $fallback--cRed;
- border-left-color: var(--cRed, $fallback--cRed);
- }
-
.gravestone {
- padding: $status-margin;
+ padding: var(--status-margin, $status-margin);
color: $fallback--faint;
color: var(--faint, $fallback--faint);
display: flex;
@@ -47,7 +40,11 @@ $status-margin: 0.75em;
.status-container {
display: flex;
- padding: $status-margin;
+ padding: var(--status-margin, $status-margin);
+
+ > * {
+ min-width: 0;
+ }
&.-repeat {
padding-top: 0;
@@ -55,7 +52,7 @@ $status-margin: 0.75em;
}
.pin {
- padding: $status-margin $status-margin 0;
+ padding: var(--status-margin, $status-margin) var(--status-margin, $status-margin) 0;
display: flex;
align-items: center;
justify-content: flex-end;
@@ -71,7 +68,7 @@ $status-margin: 0.75em;
}
.left-side {
- margin-right: $status-margin;
+ margin-right: var(--status-margin, $status-margin);
}
.right-side {
@@ -80,12 +77,11 @@ $status-margin: 0.75em;
}
.usercard {
- margin-bottom: $status-margin;
+ margin-bottom: var(--status-margin, $status-margin);
}
.status-username {
white-space: nowrap;
- font-size: 14px;
overflow: hidden;
max-width: 85%;
font-weight: bold;
@@ -110,7 +106,7 @@ $status-margin: 0.75em;
.heading-name-row {
display: flex;
justify-content: space-between;
- line-height: 18px;
+ line-height: 1.3;
a {
display: inline-block;
@@ -160,22 +156,29 @@ $status-margin: 0.75em;
margin-right: 0.2em;
}
- & .heading-reply-row {
+ & .heading-reply-row,
+ & .heading-edited-row {
position: relative;
align-content: baseline;
- font-size: 12px;
- line-height: 160%;
+ font-size: 0.85em;
+ margin-top: 0.2em;
+ line-height: 130%;
max-width: 100%;
align-items: stretch;
}
& .reply-to-popover,
- & .reply-to-no-popover {
+ & .reply-to-no-popover,
+ & .mentions {
min-width: 0;
margin-right: 0.4em;
flex-shrink: 0;
}
+ .reply-glued-label {
+ margin-right: 0.5em;
+ }
+
.reply-to-popover {
.reply-to:hover::before {
content: '';
@@ -209,7 +212,6 @@ $status-margin: 0.75em;
& .reply-to {
white-space: nowrap;
position: relative;
- padding-right: 0.25em;
}
& .mentions-text,
@@ -226,8 +228,8 @@ $status-margin: 0.75em;
.replies {
margin-top: 0.25em;
- line-height: 18px;
- font-size: 12px;
+ line-height: 1.3;
+ font-size: 0.85em;
display: flex;
flex-wrap: wrap;
@@ -241,7 +243,7 @@ $status-margin: 0.75em;
}
.repeat-info {
- padding: 0.4em $status-margin;
+ padding: 0.4em var(--status-margin, $status-margin);
.repeat-icon {
color: $fallback--cGreen;
@@ -287,7 +289,7 @@ $status-margin: 0.75em;
position: relative;
width: 100%;
display: flex;
- margin-top: $status-margin;
+ margin-top: var(--status-margin, $status-margin);
> * {
max-width: 4em;
@@ -355,7 +357,7 @@ $status-margin: 0.75em;
}
.favs-repeated-users {
- margin-top: $status-margin;
+ margin-top: var(--status-margin, $status-margin);
}
.stats {
@@ -382,19 +384,19 @@ $status-margin: 0.75em;
}
.stat-count {
- margin-right: $status-margin;
+ margin-right: var(--status-margin, $status-margin);
user-select: none;
.stat-title {
color: var(--faint, $fallback--faint);
- font-size: 12px;
+ font-size: 0.85em;
text-transform: uppercase;
position: relative;
}
.stat-number {
font-weight: bolder;
- font-size: 16px;
+ font-size: 1.1em;
line-height: 1em;
}
@@ -408,13 +410,13 @@ $status-margin: 0.75em;
margin-left: 20px;
}
- .avatar:not(.repeater-avatar) {
+ .post-avatar {
width: 40px;
height: 40px;
// TODO define those other way somehow?
// stylelint-disable rscss/class-format
- &.avatar-compact {
+ &.-compact {
width: 32px;
height: 32px;
}
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 2684e415..82eb7ac6 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -1,6 +1,7 @@
<template>
<div
v-if="!hideStatus"
+ ref="root"
class="Status"
:class="[{ '-focused': isFocused }, { '-conversation': inlineExpanded }]"
>
@@ -24,9 +25,10 @@
class="fa-scale-110 fa-old-padding repeat-icon"
icon="retweet"
/>
- <router-link :to="userProfileLink">
- {{ status.user.screen_name_ui }}
- </router-link>
+ <user-link
+ :user="status.user"
+ :at="false"
+ />
</small>
<small
v-if="showReasonMutedThread"
@@ -77,6 +79,7 @@
<UserAvatar
v-if="retweet"
class="left-side repeater-avatar"
+ :bot="rtBotIndicator"
:better-shadow="betterShadow"
:user="statusoid.user"
/>
@@ -99,6 +102,7 @@
:to="retweeterProfileLink"
>{{ retweeter }}</router-link>
</span>
+ {{ ' ' }}
<FAIcon
icon="retweet"
class="repeat-icon"
@@ -119,25 +123,25 @@
v-if="!noHeading"
class="left-side"
>
- <router-link
- :to="userProfileLink"
- @click.stop.prevent.capture.native="toggleUserExpanded"
+ <a
+ :href="$router.resolve(userProfileLink).href"
+ @click.prevent
>
- <UserAvatar
- :compact="compact"
- :better-shadow="betterShadow"
- :user="status.user"
- />
- </router-link>
+ <UserPopover
+ :user-id="status.user.id"
+ :overlay-centers="true"
+ >
+ <UserAvatar
+ class="post-avatar"
+ :bot="botIndicator"
+ :compact="compact"
+ :better-shadow="betterShadow"
+ :user="status.user"
+ />
+ </UserPopover>
+ </a>
</div>
<div class="right-side">
- <UserCard
- v-if="userExpanded"
- :user-id="status.user.id"
- :rounded="true"
- :bordered="true"
- class="usercard"
- />
<div
v-if="!noHeading"
class="status-heading"
@@ -161,13 +165,12 @@
>
{{ status.user.name }}
</h4>
- <router-link
+ <user-link
class="account-name"
:title="status.user.screen_name_ui"
- :to="userProfileLink"
- >
- {{ status.user.screen_name_ui }}
- </router-link>
+ :user="status.user"
+ :at="false"
+ />
<img
v-if="!!(status.user && status.user.favicon)"
class="status-favicon"
@@ -188,7 +191,7 @@
<span
v-if="status.visibility"
class="visibility-icon"
- :title="status.visibility | capitalize"
+ :title="visibilityLocalized"
>
<FAIcon
fixed-width
@@ -219,6 +222,31 @@
class="fa-scale-110"
/>
</button>
+ <button
+ v-if="inThreadForest && replies && replies.length && !simpleTree"
+ class="button-unstyled"
+ :title="threadShowing ? $t('status.thread_hide') : $t('status.thread_show')"
+ :aria-expanded="threadShowing ? 'true' : 'false'"
+ @click.prevent="toggleThreadDisplay"
+ >
+ <FAIcon
+ fixed-width
+ class="fa-scale-110"
+ :icon="threadShowing ? 'chevron-up' : 'chevron-down'"
+ />
+ </button>
+ <button
+ v-if="dive && !simpleTree"
+ class="button-unstyled"
+ :title="$t('status.show_only_conversation_under_this')"
+ @click.prevent="dive"
+ >
+ <FAIcon
+ fixed-width
+ class="fa-scale-110"
+ :icon="'angle-double-right'"
+ />
+ </button>
</span>
</div>
<div
@@ -227,7 +255,7 @@
>
<span
v-if="isReply"
- class="glued-label"
+ class="glued-label reply-glued-label"
>
<StatusPopover
v-if="!isPreview"
@@ -246,6 +274,7 @@
icon="reply"
flip="horizontal"
/>
+ {{ ' ' }}
<span
class="reply-to-text"
>
@@ -265,7 +294,6 @@
:url="replyProfileLink"
:user-id="status.in_reply_to_user_id"
:user-screen-name="status.in_reply_to_screen_name"
- :first-mention="false"
/>
</span>
@@ -292,12 +320,31 @@
class="mentions-line-first"
/>
</span>
+ {{ ' ' }}
<MentionsLine
v-if="hasMentionsLine"
:mentions="mentionsLine.slice(1)"
class="mentions-line"
/>
</div>
+ <div
+ v-if="isEdited && editingAvailable && !isPreview"
+ class="heading-edited-row"
+ >
+ <i18n-t
+ keypath="status.edited_at"
+ tag="span"
+ >
+ <template #time>
+ <Timeago
+ template-key="time.in_past"
+ :time="status.edited_at"
+ :auto-update="60"
+ :long-format="true"
+ />
+ </template>
+ </i18n-t>
+ </div>
</div>
<StatusContent
@@ -306,6 +353,12 @@
:no-heading="noHeading"
:highlight="highlight"
:focused="isFocused"
+ :controlled-showing-tall="controlledShowingTall"
+ :controlled-expanding-subject="controlledExpandingSubject"
+ :controlled-showing-long-subject="controlledShowingLongSubject"
+ :controlled-toggle-showing-tall="controlledToggleShowingTall"
+ :controlled-toggle-expanding-subject="controlledToggleExpandingSubject"
+ :controlled-toggle-showing-long-subject="controlledToggleShowingLongSubject"
@mediaplay="addMediaPlaying($event)"
@mediapause="removeMediaPlaying($event)"
@parseReady="setHeadTailLinks"
@@ -315,7 +368,20 @@
v-if="inConversation && !isPreview && replies && replies.length"
class="replies"
>
- <span class="faint">{{ $t('status.replies_list') }}</span>
+ <button
+ v-if="showOtherRepliesAsButton && replies.length > 1"
+ class="button-unstyled -link faint"
+ :title="$tc('status.ancestor_follow', replies.length - 1, { numReplies: replies.length - 1 })"
+ @click.prevent="dive"
+ >
+ {{ $tc('status.replies_list_with_others', replies.length - 1, { numReplies: replies.length - 1 }) }}
+ </button>
+ <span
+ v-else
+ class="faint"
+ >
+ {{ $t('status.replies_list') }}
+ </span>
<StatusPopover
v-for="reply in replies"
:key="reply.id"
@@ -407,7 +473,11 @@
class="gravestone"
>
<div class="left-side">
- <UserAvatar :compact="compact" />
+ <UserAvatar
+ class="post-avatar"
+ :compact="compact"
+ :bot="botIndicator"
+ />
</div>
<div class="right-side">
<div class="deleted-text">
@@ -439,6 +509,6 @@
</div>
</template>
-<script src="./status.js" ></script>
+<script src="./status.js"></script>
<style src="./status.scss" lang="scss"></style>
diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js
index ef542307..b8f6f9a0 100644
--- a/src/components/status_body/status_body.js
+++ b/src/components/status_body/status_body.js
@@ -21,18 +21,21 @@ library.add(
const StatusContent = {
name: 'StatusContent',
props: [
+ 'compact',
'status',
'focused',
'noHeading',
'fullContent',
- 'singleLine'
+ 'singleLine',
+ 'showingTall',
+ 'expandingSubject',
+ 'showingLongSubject',
+ 'toggleShowingTall',
+ 'toggleExpandingSubject',
+ 'toggleShowingLongSubject'
],
data () {
return {
- showingTall: this.fullContent || (this.inConversation && this.focused),
- showingLongSubject: false,
- // not as computed because it sets the initial state which will be changed later
- expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject,
postLength: this.status.text.length,
parseReadyDone: false
}
@@ -49,6 +52,7 @@ const StatusContent = {
// Using max-height + overflow: auto for status components resulted in false positives
// very often with japanese characters, and it was very annoying.
tallStatus () {
+ if (this.singleLine || this.compact) return false
const lengthScore = this.status.raw_html.split(/<p|<br/).length + this.postLength / 80
return lengthScore > 20
},
@@ -113,9 +117,9 @@ const StatusContent = {
},
toggleShowMore () {
if (this.mightHideBecauseTall) {
- this.showingTall = !this.showingTall
+ this.toggleShowingTall()
} else if (this.mightHideBecauseSubject) {
- this.expandingSubject = !this.expandingSubject
+ this.toggleExpandingSubject()
}
},
generateTagLink (tag) {
diff --git a/src/components/status_body/status_body.scss b/src/components/status_body/status_body.scss
index c7732bfe..039d4c7f 100644
--- a/src/components/status_body/status_body.scss
+++ b/src/components/status_body/status_body.scss
@@ -1,11 +1,17 @@
@import '../../_variables.scss';
.StatusBody {
+ display: flex;
+ flex-direction: column;
.emoji {
--_still_image-label-scale: 0.5;
}
+ .attachments {
+ margin-top: 0.5em;
+ }
+
& .text,
& .summary {
font-family: var(--postFont, sans-serif);
@@ -13,7 +19,7 @@
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
- line-height: 1.4em;
+ line-height: var(--post-line-height);
}
.summary {
@@ -115,4 +121,54 @@
.cyantext {
color: var(--postCyantext, $fallback--cBlue);
}
+
+ &.-compact {
+ align-items: top;
+ flex-direction: row;
+
+ --emoji-size: 16px;
+
+ & .body,
+ & .attachments {
+ max-height: 3.25em;
+ }
+
+ .body {
+ overflow: hidden;
+ white-space: normal;
+ min-width: 5em;
+ flex: 5 1 auto;
+ mask-size: auto 3.5em, auto auto;
+ mask-position: 0 0, 0 0;
+ mask-repeat: repeat-x, repeat;
+ mask-image: linear-gradient(to bottom, white 2em, transparent 3em);
+
+ /* Autoprefixed seem to ignore this one, and also syntax is different */
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ }
+
+ .attachments {
+ margin-top: 0;
+ flex: 1 1 0;
+ min-width: 5em;
+ height: 100%;
+ margin-left: 0.5em;
+ }
+
+ .summary-wrapper {
+ .summary::after {
+ content: ': ';
+ }
+
+ line-height: inherit;
+ margin: 0;
+ border: none;
+ display: inline-block;
+ }
+
+ .text-wrapper {
+ display: inline-block;
+ }
+ }
}
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
index 9f01c470..fb356360 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -1,5 +1,8 @@
<template>
- <div class="StatusBody">
+ <div
+ class="StatusBody"
+ :class="{ '-compact': compact }"
+ >
<div class="body">
<div
v-if="status.summary_raw_html"
@@ -12,16 +15,16 @@
:emoji="status.emojis"
/>
<button
- v-if="longSubject && showingLongSubject"
+ v-show="longSubject && showingLongSubject"
class="button-unstyled -link tall-subject-hider"
- @click.prevent="showingLongSubject=false"
+ @click.prevent="toggleShowingLongSubject"
>
{{ $t("status.hide_full_subject") }}
</button>
<button
- v-else-if="longSubject"
+ v-show="longSubject && !showingLongSubject"
class="button-unstyled -link tall-subject-hider"
- @click.prevent="showingLongSubject=true"
+ @click.prevent="toggleShowingLongSubject"
>
{{ $t("status.show_full_subject") }}
</button>
@@ -31,7 +34,7 @@
class="text-wrapper"
>
<button
- v-if="hideTallStatus"
+ v-show="hideTallStatus"
class="button-unstyled -link tall-status-hider"
:class="{ '-focused': focused }"
@click.prevent="toggleShowMore"
@@ -51,7 +54,7 @@
/>
<button
- v-if="hideSubjectStatus"
+ v-show="hideSubjectStatus"
class="button-unstyled -link cw-status-hider"
@click.prevent="toggleShowMore"
>
@@ -82,7 +85,7 @@
/>
</button>
<button
- v-if="showingMore && !fullContent"
+ v-show="showingMore && !fullContent"
class="button-unstyled -link status-unhider"
@click.prevent="toggleShowMore"
>
@@ -93,5 +96,5 @@
<slot v-if="!hideSubjectStatus" />
</div>
</template>
-<script src="./status_body.js" ></script>
+<script src="./status_body.js"></script>
<style lang="scss" src="./status_body.scss" />
diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js
index 1b80ee09..89f0aa51 100644
--- a/src/components/status_content/status_content.js
+++ b/src/components/status_content/status_content.js
@@ -3,7 +3,6 @@ import Poll from '../poll/poll.vue'
import Gallery from '../gallery/gallery.vue'
import StatusBody from 'src/components/status_body/status_body.vue'
import LinkPreview from '../link-preview/link-preview.vue'
-import fileType from 'src/services/file_type/file_type.service'
import { mapGetters, mapState } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
@@ -24,16 +23,56 @@ library.add(
faPollH
)
+const camelCase = name => name.charAt(0).toUpperCase() + name.slice(1)
+
+const controlledOrUncontrolledGetters = list => list.reduce((res, name) => {
+ const camelized = camelCase(name)
+ const toggle = `controlledToggle${camelized}`
+ const controlledName = `controlled${camelized}`
+ const uncontrolledName = `uncontrolled${camelized}`
+ res[name] = function () {
+ return ((this.$data[toggle] !== undefined || this.$props[toggle] !== undefined) && this[toggle]) ? this[controlledName] : this[uncontrolledName]
+ }
+ return res
+}, {})
+
+const controlledOrUncontrolledToggle = (obj, name) => {
+ const camelized = camelCase(name)
+ const toggle = `controlledToggle${camelized}`
+ const uncontrolledName = `uncontrolled${camelized}`
+ if (obj[toggle]) {
+ obj[toggle]()
+ } else {
+ obj[uncontrolledName] = !obj[uncontrolledName]
+ }
+}
+
const StatusContent = {
name: 'StatusContent',
props: [
'status',
+ 'compact',
'focused',
'noHeading',
'fullContent',
- 'singleLine'
+ 'singleLine',
+ 'controlledShowingTall',
+ 'controlledExpandingSubject',
+ 'controlledToggleShowingTall',
+ 'controlledToggleExpandingSubject',
+ 'controlledShowingLongSubject',
+ 'controlledToggleShowingLongSubject'
],
+ data () {
+ return {
+ uncontrolledShowingTall: this.fullContent || (this.inConversation && this.focused),
+ uncontrolledShowingLongSubject: false,
+ // not as computed because it sets the initial state which will be changed later
+ uncontrolledExpandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject
+ }
+ },
computed: {
+ ...controlledOrUncontrolledGetters(['showingTall', 'expandingSubject', 'showingLongSubject']),
hideAttachments () {
return (this.mergedConfig.hideAttachments && !this.inConversation) ||
(this.mergedConfig.hideAttachmentsInConv && this.inConversation)
@@ -48,33 +87,15 @@ const StatusContent = {
return true
},
attachmentSize () {
- if ((this.mergedConfig.hideAttachments && !this.inConversation) ||
+ if (this.compact) {
+ return 'small'
+ } else if ((this.mergedConfig.hideAttachments && !this.inConversation) ||
(this.mergedConfig.hideAttachmentsInConv && this.inConversation) ||
(this.status.attachments.length > this.maxThumbnails)) {
return 'hide'
- } else if (this.compact) {
- return 'small'
}
return 'normal'
},
- galleryTypes () {
- if (this.attachmentSize === 'hide') {
- return []
- }
- return this.mergedConfig.playVideosInModal
- ? ['image', 'video']
- : ['image']
- },
- galleryAttachments () {
- return this.status.attachments.filter(
- file => fileType.fileMatchesSomeType(this.galleryTypes, file)
- )
- },
- nonGalleryAttachments () {
- return this.status.attachments.filter(
- file => !fileType.fileMatchesSomeType(this.galleryTypes, file)
- )
- },
maxThumbnails () {
return this.mergedConfig.maxThumbnails
},
@@ -91,6 +112,15 @@ const StatusContent = {
StatusBody
},
methods: {
+ toggleShowingTall () {
+ controlledOrUncontrolledToggle(this, 'showingTall')
+ },
+ toggleExpandingSubject () {
+ controlledOrUncontrolledToggle(this, 'expandingSubject')
+ },
+ toggleShowingLongSubject () {
+ controlledOrUncontrolledToggle(this, 'showingLongSubject')
+ },
setMedia () {
const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments
return () => this.$store.dispatch('setMedia', attachments)
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index 5cebc697..e2120f7a 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -1,44 +1,48 @@
<template>
- <div class="StatusContent">
+ <div
+ class="StatusContent"
+ :class="{ '-compact': compact }"
+ >
<slot name="header" />
<StatusBody
:status="status"
+ :compact="compact"
:single-line="singleLine"
+ :showing-tall="showingTall"
+ :expanding-subject="expandingSubject"
+ :showing-long-subject="showingLongSubject"
+ :toggle-showing-tall="toggleShowingTall"
+ :toggle-expanding-subject="toggleExpandingSubject"
+ :toggle-showing-long-subject="toggleShowingLongSubject"
@parseReady="$emit('parseReady', $event)"
>
- <div v-if="status.poll && status.poll.options">
+ <div v-if="status.poll && status.poll.options && !compact">
<Poll
:base-poll="status.poll"
:emoji="status.emojis"
/>
</div>
- <div
- v-if="status.attachments.length !== 0"
- class="attachments media-body"
- >
- <attachment
- v-for="attachment in nonGalleryAttachments"
- :key="attachment.id"
- class="non-gallery"
- :size="attachmentSize"
- :nsfw="nsfwClickthrough"
- :attachment="attachment"
- :allow-play="true"
- :set-media="setMedia()"
- @play="$emit('mediaplay', attachment.id)"
- @pause="$emit('mediapause', attachment.id)"
- />
- <gallery
- v-if="galleryAttachments.length > 0"
- :nsfw="nsfwClickthrough"
- :attachments="galleryAttachments"
- :set-media="setMedia()"
+ <div v-else-if="status.poll && status.poll.options && compact">
+ <FAIcon
+ icon="poll-h"
+ size="2x"
/>
</div>
+ <gallery
+ v-if="status.attachments.length !== 0"
+ class="attachments media-body"
+ :nsfw="nsfwClickthrough"
+ :attachments="status.attachments"
+ :limit="compact ? 1 : 0"
+ :size="attachmentSize"
+ @play="$emit('mediaplay', attachment.id)"
+ @pause="$emit('mediapause', attachment.id)"
+ />
+
<div
- v-if="status.card && !noHeading"
+ v-if="status.card && !noHeading && !compact"
class="link-preview media-body"
>
<link-preview
@@ -52,12 +56,8 @@
</div>
</template>
-<script src="./status_content.js" ></script>
+<script src="./status_content.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
-
-$status-margin: 0.75em;
-
.StatusContent {
flex: 1;
min-width: 0;
diff --git a/src/components/status_history_modal/status_history_modal.js b/src/components/status_history_modal/status_history_modal.js
new file mode 100644
index 00000000..3941a56f
--- /dev/null
+++ b/src/components/status_history_modal/status_history_modal.js
@@ -0,0 +1,60 @@
+import { get } from 'lodash'
+import Modal from '../modal/modal.vue'
+import Status from '../status/status.vue'
+
+const StatusHistoryModal = {
+ components: {
+ Modal,
+ Status
+ },
+ data () {
+ return {
+ statuses: []
+ }
+ },
+ computed: {
+ modalActivated () {
+ return this.$store.state.statusHistory.modalActivated
+ },
+ params () {
+ return this.$store.state.statusHistory.params
+ },
+ statusId () {
+ return this.params.id
+ },
+ historyCount () {
+ return this.statuses.length
+ },
+ history () {
+ return this.statuses
+ }
+ },
+ watch: {
+ params (newVal, oldVal) {
+ const newStatusId = get(newVal, 'id') !== get(oldVal, 'id')
+ if (newStatusId) {
+ this.resetHistory()
+ }
+
+ if (newStatusId || get(newVal, 'edited_at') !== get(oldVal, 'edited_at')) {
+ this.fetchStatusHistory()
+ }
+ }
+ },
+ methods: {
+ resetHistory () {
+ this.statuses = []
+ },
+ fetchStatusHistory () {
+ this.$store.dispatch('fetchStatusHistory', this.params)
+ .then(data => {
+ this.statuses = data
+ })
+ },
+ closeModal () {
+ this.$store.dispatch('closeStatusHistoryModal')
+ }
+ }
+}
+
+export default StatusHistoryModal
diff --git a/src/components/status_history_modal/status_history_modal.vue b/src/components/status_history_modal/status_history_modal.vue
new file mode 100644
index 00000000..990be35b
--- /dev/null
+++ b/src/components/status_history_modal/status_history_modal.vue
@@ -0,0 +1,46 @@
+<template>
+ <Modal
+ v-if="modalActivated"
+ class="status-history-modal-view"
+ @backdropClicked="closeModal"
+ >
+ <div class="status-history-modal-panel panel">
+ <div class="panel-heading">
+ {{ $t('status.status_history') }} ({{ historyCount }})
+ </div>
+ <div class="panel-body">
+ <div
+ v-if="historyCount > 0"
+ class="history-body"
+ >
+ <status
+ v-for="status in history"
+ :key="status.id"
+ :statusoid="status"
+ :is-preview="true"
+ class="conversation-status status-fadein panel-body"
+ />
+ </div>
+ </div>
+ </div>
+ </Modal>
+</template>
+
+<script src="./status_history_modal.js"></script>
+
+<style lang="scss">
+.modal-view.status-history-modal-view {
+ align-items: flex-start;
+}
+.status-history-modal-panel {
+ flex-shrink: 0;
+ margin-top: 25%;
+ margin-bottom: 2em;
+ width: 100%;
+ max-width: 700px;
+
+ @media (orientation: landscape) {
+ margin-top: 8%;
+ }
+}
+</style>
diff --git a/src/components/status_popover/status_popover.js b/src/components/status_popover/status_popover.js
index c47f5631..c55bd85b 100644
--- a/src/components/status_popover/status_popover.js
+++ b/src/components/status_popover/status_popover.js
@@ -1,6 +1,7 @@
import { find } from 'lodash'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
+import { defineAsyncComponent } from 'vue'
library.add(
faCircleNotch
@@ -22,8 +23,8 @@ const StatusPopover = {
}
},
components: {
- Status: () => import('../status/status.vue'),
- Popover: () => import('../popover/popover.vue')
+ Status: defineAsyncComponent(() => import('../status/status.vue')),
+ Popover: defineAsyncComponent(() => import('../popover/popover.vue'))
},
methods: {
enter () {
@@ -37,6 +38,13 @@ const StatusPopover = {
.catch(e => (this.error = true))
}
}
+ },
+ watch: {
+ status (newStatus, oldStatus) {
+ if (newStatus !== oldStatus) {
+ this.$nextTick(() => this.$refs.popover.updateStyles())
+ }
+ }
}
}
diff --git a/src/components/status_popover/status_popover.vue b/src/components/status_popover/status_popover.vue
index fdca8c9c..f4ab357b 100644
--- a/src/components/status_popover/status_popover.vue
+++ b/src/components/status_popover/status_popover.vue
@@ -1,14 +1,16 @@
<template>
<Popover
+ ref="popover"
trigger="hover"
+ :stay-on-click="true"
popover-class="popover-default status-popover"
:bound-to="{ x: 'container' }"
@show="enter"
>
- <template v-slot:trigger>
+ <template #trigger>
<slot />
</template>
- <template v-slot:content>
+ <template #content>
<Status
v-if="status"
:is-preview="true"
@@ -35,7 +37,7 @@
</Popover>
</template>
-<script src="./status_popover.js" ></script>
+<script src="./status_popover.js"></script>
<style lang="scss">
@import '../../_variables.scss';
@@ -52,8 +54,6 @@
border-width: 1px;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
- box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5);
- box-shadow: var(--popupShadow);
/* TODO cleanup this */
.Status.Status {
diff --git a/src/components/sticker_picker/sticker_picker.js b/src/components/sticker_picker/sticker_picker.js
index 8daf3f07..b06384e5 100644
--- a/src/components/sticker_picker/sticker_picker.js
+++ b/src/components/sticker_picker/sticker_picker.js
@@ -1,6 +1,6 @@
/* eslint-env browser */
import statusPosterService from '../../services/status_poster/status_poster.service.js'
-import TabSwitcher from '../tab_switcher/tab_switcher.js'
+import TabSwitcher from '../tab_switcher/tab_switcher.jsx'
const StickerPicker = {
components: {
@@ -31,8 +31,8 @@ const StickerPicker = {
fetch(sticker)
.then((res) => {
res.blob().then((blob) => {
- var file = new File([blob], name, { mimetype: 'image/png' })
- var formData = new FormData()
+ const file = new File([blob], name, { mimetype: 'image/png' })
+ const formData = new FormData()
formData.append('file', file)
statusPosterService.uploadMedia({ store, formData })
.then((fileData) => {
diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js
index 8044e994..200ef147 100644
--- a/src/components/still-image/still-image.js
+++ b/src/components/still-image/still-image.js
@@ -5,20 +5,44 @@ const StillImage = {
'mimetype',
'imageLoadError',
'imageLoadHandler',
- 'alt'
+ 'alt',
+ 'height',
+ 'width',
+ 'dataSrc'
],
data () {
return {
+ // for lazy loading, see loadLazy()
+ realSrc: this.src,
stopGifs: this.$store.getters.mergedConfig.stopGifs
}
},
computed: {
animated () {
- return this.stopGifs && (this.mimetype === 'image/gif' || this.src.endsWith('.gif'))
+ if (!this.realSrc) {
+ return false
+ }
+
+ return this.stopGifs && (this.mimetype === 'image/gif' || this.realSrc.endsWith('.gif'))
+ },
+ style () {
+ const appendPx = (str) => /\d$/.test(str) ? str + 'px' : str
+ return {
+ height: this.height ? appendPx(this.height) : null,
+ width: this.width ? appendPx(this.width) : null
+ }
}
},
methods: {
+ loadLazy () {
+ if (this.dataSrc) {
+ this.realSrc = this.dataSrc
+ }
+ },
onLoad () {
+ if (!this.realSrc) {
+ return
+ }
const image = this.$refs.src
if (!image) return
this.imageLoadHandler && this.imageLoadHandler(image)
@@ -33,6 +57,14 @@ const StillImage = {
onError () {
this.imageLoadError && this.imageLoadError()
}
+ },
+ watch: {
+ src () {
+ this.realSrc = this.src
+ },
+ dataSrc () {
+ this.$el.removeAttribute('data-loaded')
+ }
}
}
diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue
index 0623b42e..633fb229 100644
--- a/src/components/still-image/still-image.vue
+++ b/src/components/still-image/still-image.vue
@@ -2,6 +2,7 @@
<div
class="still-image"
:class="{ animated: animated }"
+ :style="style"
>
<canvas
v-if="animated"
@@ -10,14 +11,16 @@
<!-- NOTE: key is required to force to re-render img tag when src is changed -->
<img
ref="src"
- :key="src"
+ :key="realSrc"
:alt="alt"
:title="alt"
- :src="src"
+ :data-src="dataSrc"
+ :src="realSrc"
:referrerpolicy="referrerpolicy"
@load="onLoad"
@error="onError"
>
+ <slot />
</div>
</template>
@@ -56,10 +59,10 @@
zoom: var(--_still_image-label-scale, 1);
content: 'gif';
position: absolute;
- line-height: 10px;
- font-size: 10px;
- top: 5px;
- left: 5px;
+ line-height: 1;
+ font-size: 0.7em;
+ top: 0.5em;
+ left: 0.5em;
background: rgba(127, 127, 127, 0.5);
color: #fff;
display: block;
diff --git a/src/components/swipe_click/swipe_click.js b/src/components/swipe_click/swipe_click.js
new file mode 100644
index 00000000..238e6df8
--- /dev/null
+++ b/src/components/swipe_click/swipe_click.js
@@ -0,0 +1,84 @@
+import GestureService from '../../services/gesture_service/gesture_service'
+
+/**
+ * props:
+ * direction: a vector that indicates the direction of the intended swipe
+ * threshold: the minimum distance in pixels the swipe has moved on `direction'
+ * for swipe-finished() to have a non-zero sign
+ * perpendicularTolerance: see gesture_service
+ *
+ * Events:
+ * preview-requested(offsets)
+ * Emitted when the pointer has moved.
+ * offsets: the offsets from the start of the swipe to the current cursor position
+ *
+ * swipe-canceled()
+ * Emitted when the swipe has been canceled due to a pointercancel event.
+ *
+ * swipe-finished(sign: 0|-1|1)
+ * Emitted when the swipe has finished.
+ * sign: if the swipe does not meet the threshold, 0
+ * if the swipe meets the threshold in the positive direction, 1
+ * if the swipe meets the threshold in the negative direction, -1
+ *
+ * swipeless-clicked()
+ * Emitted when there is a click without swipe.
+ * This and swipe-finished() cannot be emitted for the same pointerup event.
+ */
+const SwipeClick = {
+ props: {
+ direction: {
+ type: Array
+ },
+ threshold: {
+ type: Function,
+ default: () => 30
+ },
+ perpendicularTolerance: {
+ type: Number,
+ default: 1.0
+ }
+ },
+ methods: {
+ handlePointerDown (event) {
+ this.$gesture.start(event)
+ },
+ handlePointerMove (event) {
+ this.$gesture.move(event)
+ },
+ handlePointerUp (event) {
+ this.$gesture.end(event)
+ },
+ handlePointerCancel (event) {
+ this.$gesture.cancel(event)
+ },
+ handleNativeClick (event) {
+ this.$gesture.click(event)
+ },
+ preview (offsets) {
+ this.$emit('preview-requested', offsets)
+ },
+ end (sign) {
+ this.$emit('swipe-finished', sign)
+ },
+ click () {
+ this.$emit('swipeless-clicked')
+ },
+ cancel () {
+ this.$emit('swipe-canceled')
+ }
+ },
+ created () {
+ this.$gesture = new GestureService.SwipeAndClickGesture({
+ direction: this.direction,
+ threshold: this.threshold,
+ perpendicularTolerance: this.perpendicularTolerance,
+ swipePreviewCallback: this.preview,
+ swipeEndCallback: this.end,
+ swipeCancelCallback: this.cancel,
+ swipelessClickCallback: this.click
+ })
+ }
+}
+
+export default SwipeClick
diff --git a/src/components/swipe_click/swipe_click.vue b/src/components/swipe_click/swipe_click.vue
new file mode 100644
index 00000000..5372071d
--- /dev/null
+++ b/src/components/swipe_click/swipe_click.vue
@@ -0,0 +1,14 @@
+<template>
+ <div
+ v-bind="$attrs"
+ @pointerdown="handlePointerDown"
+ @pointermove="handlePointerMove"
+ @pointerup="handlePointerUp"
+ @pointercancel="handlePointerCancel"
+ @click="handleNativeClick"
+ >
+ <slot />
+ </div>
+</template>
+
+<script src="./swipe_click.js"></script>
diff --git a/src/components/tab_switcher/tab_switcher.js b/src/components/tab_switcher/tab_switcher.jsx
index 12aac8e6..c8d390bc 100644
--- a/src/components/tab_switcher/tab_switcher.js
+++ b/src/components/tab_switcher/tab_switcher.jsx
@@ -1,10 +1,13 @@
-import Vue from 'vue'
+// eslint-disable-next-line no-unused
+import { h, Fragment } from 'vue'
import { mapState } from 'vuex'
import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome'
import './tab_switcher.scss'
-export default Vue.component('tab-switcher', {
+const findFirstUsable = (slots) => slots.findIndex(_ => _.props)
+
+export default {
name: 'TabSwitcher',
props: {
renderOnlyFocused: {
@@ -31,22 +34,33 @@ export default Vue.component('tab-switcher', {
required: false,
type: Boolean,
default: false
+ },
+ bodyScrollLock: {
+ required: false,
+ type: Boolean,
+ default: false
}
},
data () {
return {
- active: this.$slots.default.findIndex(_ => _.tag)
+ active: findFirstUsable(this.slots())
}
},
computed: {
activeIndex () {
// In case of controlled component
if (this.activeTab) {
- return this.$slots.default.findIndex(slot => this.activeTab === slot.key)
+ return this.slots().findIndex(slot => slot && slot.props && this.activeTab === slot.props.key)
} else {
return this.active
}
},
+ isActive () {
+ return tabName => {
+ const isWanted = slot => slot.props && slot.props['data-tab-name'] === tabName
+ return this.$slots.default().findIndex(isWanted) === this.activeIndex
+ }
+ },
settingsModalVisible () {
return this.settingsModalState === 'visible'
},
@@ -55,9 +69,9 @@ export default Vue.component('tab-switcher', {
})
},
beforeUpdate () {
- const currentSlot = this.$slots.default[this.active]
- if (!currentSlot.tag) {
- this.active = this.$slots.default.findIndex(_ => _.tag)
+ const currentSlot = this.slots()[this.active]
+ if (!currentSlot.props) {
+ this.active = findFirstUsable(this.slots())
}
},
methods: {
@@ -67,9 +81,16 @@ export default Vue.component('tab-switcher', {
this.setTab(index)
}
},
+ // DO NOT put it to computed, it doesn't work (caching?)
+ slots () {
+ if (this.$slots.default()[0].type === Fragment) {
+ return this.$slots.default()[0].children
+ }
+ return this.$slots.default()
+ },
setTab (index) {
if (typeof this.onSwitch === 'function') {
- this.onSwitch.call(null, this.$slots.default[index].key)
+ this.onSwitch.call(null, this.slots()[index].key)
}
this.active = index
if (this.scrollableTabs) {
@@ -77,27 +98,28 @@ export default Vue.component('tab-switcher', {
}
}
},
- render (h) {
- const tabs = this.$slots.default
+ render () {
+ const tabs = this.slots()
.map((slot, index) => {
- if (!slot.tag) return
+ const props = slot.props
+ if (!props) return
const classesTab = ['tab', 'button-default']
const classesWrapper = ['tab-wrapper']
if (this.activeIndex === index) {
classesTab.push('active')
classesWrapper.push('active')
}
- if (slot.data.attrs.image) {
+ if (props.image) {
return (
<div class={classesWrapper.join(' ')}>
<button
- disabled={slot.data.attrs.disabled}
+ disabled={props.disabled}
onClick={this.clickTab(index)}
class={classesTab.join(' ')}
type="button"
>
- <img src={slot.data.attrs.image} title={slot.data.attrs['image-tooltip']}/>
- {slot.data.attrs.label ? '' : slot.data.attrs.label}
+ <img src={props.image} title={props['image-tooltip']}/>
+ {props.label ? '' : props.label}
</button>
</div>
)
@@ -105,25 +127,26 @@ export default Vue.component('tab-switcher', {
return (
<div class={classesWrapper.join(' ')}>
<button
- disabled={slot.data.attrs.disabled}
+ disabled={props.disabled}
onClick={this.clickTab(index)}
class={classesTab.join(' ')}
type="button"
>
- {!slot.data.attrs.icon ? '' : (<FAIcon class="tab-icon" size="2x" fixed-width icon={slot.data.attrs.icon}/>)}
+ {!props.icon ? '' : (<FAIcon class="tab-icon" size="2x" fixed-width icon={props.icon}/>)}
<span class="text">
- {slot.data.attrs.label}
+ {props.label}
</span>
</button>
</div>
)
})
- const contents = this.$slots.default.map((slot, index) => {
- if (!slot.tag) return
+ const contents = this.slots().map((slot, index) => {
+ const props = slot.props
+ if (!props) return
const active = this.activeIndex === index
const classes = [ active ? 'active' : 'hidden' ]
- if (slot.data.attrs.fullHeight) {
+ if (props.fullHeight) {
classes.push('full-height')
}
const renderSlot = (!this.renderOnlyFocused || active)
@@ -134,7 +157,7 @@ export default Vue.component('tab-switcher', {
<div class={classes}>
{
this.sideTabBar
- ? <h1 class="mobile-label">{slot.data.attrs.label}</h1>
+ ? <h1 class="mobile-label">{props.label}</h1>
: ''
}
{renderSlot}
@@ -147,10 +170,14 @@ export default Vue.component('tab-switcher', {
<div class="tabs">
{tabs}
</div>
- <div ref="contents" class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')} v-body-scroll-lock={this.settingsModalVisible}>
+ <div
+ ref="contents"
+ class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')}
+ v-body-scroll-lock={this.bodyScrollLock}
+ >
{contents}
</div>
</div>
)
}
-})
+}
diff --git a/src/components/tab_switcher/tab_switcher.scss b/src/components/tab_switcher/tab_switcher.scss
index 0ed614b7..d930368c 100644
--- a/src/components/tab_switcher/tab_switcher.scss
+++ b/src/components/tab_switcher/tab_switcher.scss
@@ -17,6 +17,7 @@
overflow-x: auto;
padding-top: 5px;
flex-direction: row;
+ flex: 0 0 auto;
&::after, &::before {
content: '';
@@ -25,8 +26,9 @@
border-bottom-color: $fallback--border;
border-bottom-color: var(--border, $fallback--border);
}
+
.tab-wrapper {
- height: 28px;
+ height: 2em;
&:not(.active)::after {
left: 0;
@@ -166,13 +168,6 @@
position: relative;
white-space: nowrap;
padding: 6px 1em;
- background-color: $fallback--fg;
- background-color: var(--tab, $fallback--fg);
-
- &, &:active .tab-icon {
- color: $fallback--text;
- color: var(--tabText, $fallback--text);
- }
&:not(.active) {
z-index: 4;
diff --git a/src/components/tag_timeline/tag_timeline.js b/src/components/tag_timeline/tag_timeline.js
index 400c6a4b..bda61ae0 100644
--- a/src/components/tag_timeline/tag_timeline.js
+++ b/src/components/tag_timeline/tag_timeline.js
@@ -18,7 +18,7 @@ const TagTimeline = {
this.$store.dispatch('startFetchingTimeline', { timeline: 'tag', tag: this.tag })
}
},
- destroyed () {
+ unmounted () {
this.$store.dispatch('stopFetchingTimeline', 'tag')
}
}
diff --git a/src/components/terms_of_service_panel/terms_of_service_panel.vue b/src/components/terms_of_service_panel/terms_of_service_panel.vue
index 63dc58b8..1df41d70 100644
--- a/src/components/terms_of_service_panel/terms_of_service_panel.vue
+++ b/src/components/terms_of_service_panel/terms_of_service_panel.vue
@@ -13,7 +13,7 @@
</div>
</template>
-<script src="./terms_of_service_panel.js" ></script>
+<script src="./terms_of_service_panel.js"></script>
<style lang="scss">
.tos-content {
diff --git a/src/components/thread_tree/thread_tree.js b/src/components/thread_tree/thread_tree.js
new file mode 100644
index 00000000..71e63725
--- /dev/null
+++ b/src/components/thread_tree/thread_tree.js
@@ -0,0 +1,90 @@
+import Status from '../status/status.vue'
+
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faAngleDoubleDown,
+ faAngleDoubleRight
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faAngleDoubleDown,
+ faAngleDoubleRight
+)
+
+const ThreadTree = {
+ components: {
+ Status
+ },
+ name: 'ThreadTree',
+ props: {
+ depth: Number,
+ status: Object,
+ inProfile: Boolean,
+ conversation: Array,
+ collapsable: Boolean,
+ isExpanded: Boolean,
+ pinnedStatusIdsObject: Object,
+ profileUserId: String,
+
+ focused: Function,
+ highlight: String,
+ getReplies: Function,
+ setHighlight: Function,
+ toggleExpanded: Function,
+
+ simple: Boolean,
+ // to control display of the whole thread forest
+ toggleThreadDisplay: Function,
+ threadDisplayStatus: Object,
+ showThreadRecursively: Function,
+ totalReplyCount: Object,
+ totalReplyDepth: Object,
+ statusContentProperties: Object,
+ setStatusContentProperty: Function,
+ toggleStatusContentProperty: Function,
+ dive: Function
+ },
+ computed: {
+ suspendable () {
+ const selfSuspendable = this.$refs.statusComponent ? this.$refs.statusComponent.suspendable : true
+ if (this.$refs.childComponent) {
+ return selfSuspendable && this.$refs.childComponent.every(s => s.suspendable)
+ }
+ return selfSuspendable
+ },
+ reverseLookupTable () {
+ return this.conversation.reduce((table, status, index) => {
+ table[status.id] = index
+ return table
+ }, {})
+ },
+ currentReplies () {
+ return this.getReplies(this.status.id).map(({ id }) => this.statusById(id))
+ },
+ threadShowing () {
+ return this.threadDisplayStatus[this.status.id] === 'showing'
+ },
+ currentProp () {
+ return this.statusContentProperties[this.status.id]
+ }
+ },
+ methods: {
+ statusById (id) {
+ return this.conversation[this.reverseLookupTable[id]]
+ },
+ collapseThread () {
+ },
+ showThread () {
+ },
+ showAllSubthreads () {
+ },
+ toggleCurrentProp (name) {
+ this.toggleStatusContentProperty(this.status.id, name)
+ },
+ setCurrentProp (name, newVal) {
+ this.setStatusContentProperty(this.status.id, name)
+ }
+ }
+}
+
+export default ThreadTree
diff --git a/src/components/thread_tree/thread_tree.vue b/src/components/thread_tree/thread_tree.vue
new file mode 100644
index 00000000..c6fffc71
--- /dev/null
+++ b/src/components/thread_tree/thread_tree.vue
@@ -0,0 +1,135 @@
+<template>
+ <article class="thread-tree">
+ <status
+ :key="status.id"
+ ref="statusComponent"
+ :inline-expanded="collapsable && isExpanded"
+ :statusoid="status"
+ :expandable="!isExpanded"
+ :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
+ :focused="focused(status.id)"
+ :in-conversation="isExpanded"
+ :highlight="highlight"
+ :replies="getReplies(status.id)"
+ :in-profile="inProfile"
+ :profile-user-id="profileUserId"
+ class="conversation-status conversation-status-treeview status-fadein panel-body"
+
+ :simple-tree="simple"
+ :controlled-thread-display-status="threadDisplayStatus[status.id]"
+ :controlled-toggle-thread-display="() => toggleThreadDisplay(status.id)"
+
+ :controlled-showing-tall="currentProp.showingTall"
+ :controlled-expanding-subject="currentProp.expandingSubject"
+ :controlled-showing-long-subject="currentProp.showingLongSubject"
+ :controlled-replying="currentProp.replying"
+ :controlled-media-playing="currentProp.mediaPlaying"
+ :controlled-toggle-showing-tall="() => toggleCurrentProp('showingTall')"
+ :controlled-toggle-expanding-subject="() => toggleCurrentProp('expandingSubject')"
+ :controlled-toggle-showing-long-subject="() => toggleCurrentProp('showingLongSubject')"
+ :controlled-toggle-replying="() => toggleCurrentProp('replying')"
+ :controlled-set-media-playing="(newVal) => setCurrentProp('mediaPlaying', newVal)"
+ :dive="dive ? () => dive(status.id) : undefined"
+
+ @goto="setHighlight"
+ @toggleExpanded="toggleExpanded"
+ />
+ <div
+ v-if="currentReplies.length && threadShowing"
+ class="thread-tree-replies"
+ >
+ <thread-tree
+ v-for="replyStatus in currentReplies"
+ :key="replyStatus.id"
+ ref="childComponent"
+ :depth="depth + 1"
+ :status="replyStatus"
+
+ :in-profile="inProfile"
+ :conversation="conversation"
+ :collapsable="collapsable"
+ :is-expanded="isExpanded"
+ :pinned-status-ids-object="pinnedStatusIdsObject"
+ :profile-user-id="profileUserId"
+
+ :focused="focused"
+ :get-replies="getReplies"
+ :highlight="highlight"
+ :set-highlight="setHighlight"
+ :toggle-expanded="toggleExpanded"
+
+ :simple="simple"
+ :toggle-thread-display="toggleThreadDisplay"
+ :thread-display-status="threadDisplayStatus"
+ :show-thread-recursively="showThreadRecursively"
+ :total-reply-count="totalReplyCount"
+ :total-reply-depth="totalReplyDepth"
+ :status-content-properties="statusContentProperties"
+ :set-status-content-property="setStatusContentProperty"
+ :toggle-status-content-property="toggleStatusContentProperty"
+ :dive="dive"
+ />
+ </div>
+ <div
+ v-if="currentReplies.length && !threadShowing"
+ class="thread-tree-replies thread-tree-replies-hidden"
+ >
+ <i18n-t
+ v-if="simple"
+ scope="global"
+ tag="button"
+ keypath="status.thread_follow_with_icon"
+ class="button-unstyled -link thread-tree-show-replies-button"
+ @click.prevent="dive(status.id)"
+ >
+ <template #icon>
+ <FAIcon
+ icon="angle-double-right"
+ />
+ </template>
+ <template #text>
+ <span>
+ {{ $tc('status.thread_follow', totalReplyCount[status.id], { numStatus: totalReplyCount[status.id] }) }}
+ </span>
+ </template>
+ </i18n-t>
+ <i18n-t
+ v-else
+ scope="global"
+ tag="button"
+ keypath="status.thread_show_full_with_icon"
+ class="button-unstyled -link thread-tree-show-replies-button"
+ @click.prevent="showThreadRecursively(status.id)"
+ >
+ <template #icon>
+ <FAIcon
+ icon="angle-double-down"
+ />
+ </template>
+ <template #text>
+ <span>
+ {{ $tc('status.thread_show_full', totalReplyCount[status.id], { numStatus: totalReplyCount[status.id], depth: totalReplyDepth[status.id] }) }}
+ </span>
+ </template>
+ </i18n-t>
+ </div>
+ </article>
+</template>
+
+<script src="./thread_tree.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+.thread-tree-replies {
+ margin-left: var(--status-margin, $status-margin);
+ border-left: 2px solid var(--border, $fallback--border);
+}
+
+.thread-tree-replies-hidden {
+ padding: var(--status-margin, $status-margin);
+ /* Make the button stretch along the whole row */
+ display: flex;
+ align-items: stretch;
+ flex-direction: column;
+}
+</style>
diff --git a/src/components/timeago/timeago.vue b/src/components/timeago/timeago.vue
index 55a2dd94..b5f49515 100644
--- a/src/components/timeago/timeago.vue
+++ b/src/components/timeago/timeago.vue
@@ -3,7 +3,7 @@
:datetime="time"
:title="localeDateString"
>
- {{ $t(relativeTime.key, [relativeTime.num]) }}
+ {{ relativeTimeString }}
</time>
</template>
@@ -13,7 +13,7 @@ import localeService from 'src/services/locale/locale.service.js'
export default {
name: 'Timeago',
- props: ['time', 'autoUpdate', 'longFormat', 'nowThreshold'],
+ props: ['time', 'autoUpdate', 'longFormat', 'nowThreshold', 'templateKey'],
data () {
return {
relativeTime: { key: 'time.now', num: 0 },
@@ -26,12 +26,29 @@ export default {
return typeof this.time === 'string'
? new Date(Date.parse(this.time)).toLocaleString(browserLocale)
: this.time.toLocaleString(browserLocale)
+ },
+ relativeTimeString () {
+ const timeString = this.$i18n.tc(this.relativeTime.key, this.relativeTime.num, [this.relativeTime.num])
+
+ if (typeof this.templateKey === 'string' && this.relativeTime.key !== 'time.now') {
+ return this.$i18n.t(this.templateKey, [timeString])
+ }
+
+ return timeString
+ }
+ },
+ watch: {
+ time (newVal, oldVal) {
+ if (oldVal !== newVal) {
+ clearTimeout(this.interval)
+ this.refreshRelativeTimeObject()
+ }
}
},
created () {
this.refreshRelativeTimeObject()
},
- destroyed () {
+ unmounted () {
clearTimeout(this.interval)
},
methods: {
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index 44f749c3..b7414610 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -1,44 +1,40 @@
import Status from '../status/status.vue'
+import { mapState } from 'vuex'
import timelineFetcher from '../../services/timeline_fetcher/timeline_fetcher.service.js'
import Conversation from '../conversation/conversation.vue'
import TimelineMenu from '../timeline_menu/timeline_menu.vue'
-import TimelineQuickSettings from './timeline_quick_settings.vue'
+import QuickFilterSettings from '../quick_filter_settings/quick_filter_settings.vue'
+import QuickViewSettings from '../quick_view_settings/quick_view_settings.vue'
import { debounce, throttle, keyBy } from 'lodash'
import { library } from '@fortawesome/fontawesome-svg-core'
-import { faCircleNotch, faCog } from '@fortawesome/free-solid-svg-icons'
+import { faCircleNotch, faCirclePlus, faCog, faMinus, faArrowUp, faCheck } from '@fortawesome/free-solid-svg-icons'
library.add(
faCircleNotch,
- faCog
+ faCog,
+ faMinus,
+ faArrowUp,
+ faCirclePlus,
+ faCheck
)
-export const getExcludedStatusIdsByPinning = (statuses, pinnedStatusIds) => {
- const ids = []
- if (pinnedStatusIds && pinnedStatusIds.length > 0) {
- for (let status of statuses) {
- if (!pinnedStatusIds.includes(status.id)) {
- break
- }
- ids.push(status.id)
- }
- }
- return ids
-}
-
const Timeline = {
props: [
'timeline',
'timelineName',
'title',
'userId',
+ 'listId',
'tag',
'embedded',
'count',
'pinnedStatusIds',
- 'inProfile'
+ 'inProfile',
+ 'footerSlipgate' // reference to an element where we should put our footer
],
data () {
return {
+ showScrollTop: false,
paused: false,
unfocused: false,
bottomedOut: false,
@@ -50,9 +46,16 @@ const Timeline = {
Status,
Conversation,
TimelineMenu,
- TimelineQuickSettings
+ QuickFilterSettings,
+ QuickViewSettings
},
computed: {
+ filteredVisibleStatuses () {
+ return this.timeline.visibleStatuses.filter(status => this.timelineName !== 'user' || (status.id >= this.timeline.minId && status.id <= this.timeline.maxId))
+ },
+ filteredPinnedStatusIds () {
+ return (this.pinnedStatusIds || []).filter(statusId => this.timeline.statusesObject[statusId])
+ },
newStatusCount () {
return this.timeline.newStatusCount
},
@@ -66,35 +69,41 @@ const Timeline = {
return `${this.$t('timeline.show_new')} (${this.newStatusCount})`
}
},
+ mobileLoadButtonString () {
+ if (this.timeline.flushMarker !== 0) {
+ return '+'
+ } else {
+ return this.newStatusCount > 99 ? '∞' : this.newStatusCount
+ }
+ },
classes () {
- let rootClasses = !this.embedded ? ['panel', 'panel-default'] : []
+ let rootClasses = !this.embedded ? ['panel', 'panel-default'] : ['-nonpanel']
if (this.blockingClicks) rootClasses = rootClasses.concat(['-blocked', '_misclick-prevention'])
return {
root: rootClasses,
- header: ['timeline-heading'].concat(!this.embedded ? ['panel-heading'] : []),
+ header: ['timeline-heading'].concat(!this.embedded ? ['panel-heading', '-sticky'] : []),
body: ['timeline-body'].concat(!this.embedded ? ['panel-body'] : []),
footer: ['timeline-footer'].concat(!this.embedded ? ['panel-footer'] : [])
}
},
// id map of statuses which need to be hidden in the main list due to pinning logic
- excludedStatusIdsObject () {
- const ids = getExcludedStatusIdsByPinning(this.timeline.visibleStatuses, this.pinnedStatusIds)
- // Convert id array to object
- return keyBy(ids)
- },
pinnedStatusIdsObject () {
return keyBy(this.pinnedStatusIds)
},
statusesToDisplay () {
const amount = this.timeline.visibleStatuses.length
const statusesPerSide = Math.ceil(Math.max(3, window.innerHeight / 80))
- const min = Math.max(0, this.virtualScrollIndex - statusesPerSide)
- const max = Math.min(amount, this.virtualScrollIndex + statusesPerSide)
+ const nonPinnedIndex = this.virtualScrollIndex - this.filteredPinnedStatusIds.length
+ const min = Math.max(0, nonPinnedIndex - statusesPerSide)
+ const max = Math.min(amount, nonPinnedIndex + statusesPerSide)
return this.timeline.visibleStatuses.slice(min, max).map(_ => _.id)
},
virtualScrollingEnabled () {
return this.$store.getters.mergedConfig.virtualScrolling
- }
+ },
+ ...mapState({
+ mobileLayout: state => state.interface.layoutType === 'mobile'
+ })
},
created () {
const store = this.$store
@@ -111,6 +120,7 @@ const Timeline = {
timeline: this.timelineName,
showImmediately,
userId: this.userId,
+ listId: this.listId,
tag: this.tag
})
},
@@ -122,13 +132,16 @@ const Timeline = {
window.addEventListener('keydown', this.handleShortKey)
setTimeout(this.determineVisibleStatuses, 250)
},
- destroyed () {
+ unmounted () {
window.removeEventListener('scroll', this.handleScroll)
window.removeEventListener('keydown', this.handleShortKey)
if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false)
this.$store.commit('setLoading', { timeline: this.timelineName, value: false })
},
methods: {
+ scrollToTop () {
+ window.scrollTo({ top: this.$el.offsetTop })
+ },
stopBlockingClicks: debounce(function () {
this.blockingClicks = false
}, 1000),
@@ -153,6 +166,7 @@ const Timeline = {
this.$store.commit('showNewStatuses', { timeline: this.timelineName })
this.paused = false
}
+ window.scrollTo({ top: 0 })
},
fetchOlderStatuses: throttle(function () {
const store = this.$store
@@ -165,6 +179,7 @@ const Timeline = {
older: true,
showImmediately: true,
userId: this.userId,
+ listId: this.listId,
tag: this.tag
}).then(({ statuses }) => {
if (statuses && statuses.length === 0) {
@@ -226,6 +241,7 @@ const Timeline = {
}
},
handleScroll: throttle(function (e) {
+ this.showScrollTop = this.$el.offsetTop < window.scrollY
this.determineVisibleStatuses()
this.scrollLoad(e)
}, 200),
diff --git a/src/components/timeline/timeline.scss b/src/components/timeline/timeline.scss
index 2c5a67e2..c6fb1ca7 100644
--- a/src/components/timeline/timeline.scss
+++ b/src/components/timeline/timeline.scss
@@ -1,31 +1,58 @@
@import '../../_variables.scss';
.Timeline {
- .loadmore-text {
- opacity: 1;
+ .alert-dot {
+ border-radius: 100%;
+ height: 8px;
+ width: 8px;
+ position: absolute;
+ left: calc(50% - 4px);
+ top: calc(50% - 4px);
+ margin-left: 6px;
+ margin-top: -6px;
+ background-color: var(--badgeNeutral);
+ }
+
+ .alert-badge {
+ font-size: 0.75em;
+ line-height: 1;
+ text-align: right;
+ border-radius: var(--tooltipRadius);
+ position: absolute;
+ left: calc(50% - 0.5em);
+ top: calc(50% - 0.4em);
+ padding: 0.2em;
+ margin-left: 0.7em;
+ margin-top: -1em;
+ background-color: var(--badgeNeutral);
+ color: var(--badgeNeutralText);
+ }
+
+ .loadmore-button {
+ position: relative;
}
&.-blocked {
cursor: progress;
}
- .timeline-heading {
- max-width: 100%;
- flex-wrap: nowrap;
- align-items: center;
- position: relative;
+ .conversation-heading {
+ top: calc(var(--__panel-heading-height) * var(--currentPanelStack, 2));
+ z-index: 2;
+ }
- .loadmore-button {
- flex-shrink: 0;
+ &.-nonpanel {
+ .timeline-heading {
+ text-align: center;
+ line-height: 2.75em;
+ padding: 0 0.5em;
}
- .loadmore-text {
- flex-shrink: 0;
- line-height: 1em;
+ .timeline-heading {
+ .button-default, .alert {
+ line-height: 2em;
+ width: 100%;
+ }
}
}
-
- .timeline-footer {
- border: none;
- }
}
diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue
index 767428f0..2279f21a 100644
--- a/src/components/timeline/timeline.vue
+++ b/src/components/timeline/timeline.vue
@@ -1,86 +1,153 @@
<template>
- <div :class="[classes.root, 'Timeline']">
+ <div :class="['Timeline', classes.root]">
<div :class="classes.header">
- <TimelineMenu v-if="!embedded" />
- <button
- v-if="showLoadButton"
- class="button-default loadmore-button"
- @click.prevent="showNewStatuses"
- >
- {{ loadButtonString }}
- </button>
+ <TimelineMenu
+ v-if="!embedded"
+ :timeline-name="timelineName"
+ />
<div
- v-else
- class="loadmore-text faint"
- @click.prevent
+ v-if="showScrollTop && !embedded"
+ class="rightside-button"
>
- {{ $t('timeline.up_to_date') }}
+ <button
+ class="button-unstyled scroll-to-top-button"
+ type="button"
+ :title="$t('general.scroll_to_top')"
+ @click="scrollToTop"
+ >
+ <FALayers class="fa-scale-110 fa-old-padding-layer">
+ <FAIcon icon="arrow-up" />
+ <FAIcon
+ icon="minus"
+ transform="up-7"
+ />
+ </FALayers>
+ </button>
</div>
- <TimelineQuickSettings v-if="!embedded" />
+ <template v-if="mobileLayout && !embedded">
+ <div
+ v-if="showLoadButton"
+ class="rightside-button"
+ >
+ <button
+ class="button-unstyled loadmore-button"
+ :title="loadButtonString"
+ @click.prevent="showNewStatuses"
+ >
+ <FAIcon
+ fixed-width
+ icon="circle-plus"
+ />
+ <div class="alert-badge">
+ {{ mobileLoadButtonString }}
+ </div>
+ </button>
+ </div>
+ <div
+ v-else-if="!embedded"
+ class="loadmore-text faint veryfaint rightside-icon"
+ :title="$t('timeline.up_to_date')"
+ :aria-disabled="true"
+ @click.prevent
+ >
+ <FAIcon
+ fixed-width
+ icon="check"
+ />
+ </div>
+ </template>
+ <template v-else>
+ <button
+ v-if="showLoadButton"
+ class="button-default loadmore-button"
+ @click.prevent="showNewStatuses"
+ >
+ {{ loadButtonString }}
+ </button>
+ <div
+ v-else-if="!embedded"
+ class="loadmore-text faint"
+ @click.prevent
+ >
+ {{ $t('timeline.up_to_date') }}
+ </div>
+ </template>
+ <QuickFilterSettings
+ v-if="!embedded"
+ class="rightside-button"
+ />
+ <QuickViewSettings
+ v-if="!embedded"
+ class="rightside-button"
+ />
</div>
<div :class="classes.body">
<div
ref="timeline"
class="timeline"
+ role="feed"
>
- <template v-for="statusId in pinnedStatusIds">
- <conversation
- v-if="timeline.statusesObject[statusId]"
- :key="statusId + '-pinned'"
- class="status-fadein"
- :status-id="statusId"
- :collapsable="true"
- :pinned-status-ids-object="pinnedStatusIdsObject"
- :in-profile="inProfile"
- :profile-user-id="userId"
- />
- </template>
- <template v-for="status in timeline.visibleStatuses">
- <conversation
- v-if="!excludedStatusIdsObject[status.id]"
- :key="status.id"
- class="status-fadein"
- :status-id="status.id"
- :collapsable="true"
- :in-profile="inProfile"
- :profile-user-id="userId"
- :virtual-hidden="virtualScrollingEnabled && !statusesToDisplay.includes(status.id)"
- />
- </template>
+ <conversation
+ v-for="statusId in filteredPinnedStatusIds"
+ :key="statusId + '-pinned'"
+ role="listitem"
+ class="status-fadein"
+ :status-id="statusId"
+ :collapsable="true"
+ :pinned-status-ids-object="pinnedStatusIdsObject"
+ :in-profile="inProfile"
+ :profile-user-id="userId"
+ />
+ <conversation
+ v-for="status in filteredVisibleStatuses"
+ :key="status.id"
+ role="listitem"
+ class="status-fadein"
+ :status-id="status.id"
+ :collapsable="true"
+ :in-profile="inProfile"
+ :profile-user-id="userId"
+ :virtual-hidden="virtualScrollingEnabled && !statusesToDisplay.includes(status.id)"
+ />
</div>
</div>
<div :class="classes.footer">
- <div
- v-if="count===0"
- class="new-status-notification text-center faint"
- >
- {{ $t('timeline.no_statuses') }}
- </div>
- <div
- v-else-if="bottomedOut"
- class="new-status-notification text-center faint"
- >
- {{ $t('timeline.no_more_statuses') }}
- </div>
- <button
- v-else-if="!timeline.loading"
- class="button-unstyled -link -fullwidth"
- @click.prevent="fetchOlderStatuses()"
+ <teleport
+ :to="footerSlipgate"
+ :disabled="!embedded || !footerSlipgate"
>
- <div class="new-status-notification text-center">
- {{ $t('timeline.load_older') }}
+ <div
+ v-if="count===0"
+ class="new-status-notification text-center faint"
+ >
+ {{ $t('timeline.no_statuses') }}
</div>
- </button>
- <div
- v-else
- class="new-status-notification text-center"
- >
- <FAIcon
- icon="circle-notch"
- spin
- size="lg"
- />
- </div>
+ <div
+ v-else-if="bottomedOut"
+ class="new-status-notification text-center faint"
+ >
+ {{ $t('timeline.no_more_statuses') }}
+ </div>
+ <button
+ v-else-if="!timeline.loading"
+ class="button-unstyled -link"
+ @click.prevent="fetchOlderStatuses()"
+ >
+ <div class="new-status-notification text-center">
+ {{ $t('timeline.load_older') }}
+ </div>
+ </button>
+ <div
+ v-else
+ class="new-status-notification text-center"
+ >
+ <FAIcon
+ icon="circle-notch"
+ spin
+ size="lg"
+ />
+ </div>
+ </teleport>
</div>
</div>
</template>
diff --git a/src/components/timeline_menu/timeline_menu.js b/src/components/timeline_menu/timeline_menu.js
index bab51e75..5a2a86c2 100644
--- a/src/components/timeline_menu/timeline_menu.js
+++ b/src/components/timeline_menu/timeline_menu.js
@@ -1,6 +1,10 @@
import Popover from '../popover/popover.vue'
-import TimelineMenuContent from './timeline_menu_content.vue'
+import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
+import { mapState } from 'vuex'
+import { ListsMenuContent } from '../lists_menu/lists_menu_content.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
+import { TIMELINES } from 'src/components/navigation/navigation.js'
+import { filterNavigation } from 'src/components/navigation/filter.js'
import {
faChevronDown
} from '@fortawesome/free-solid-svg-icons'
@@ -11,9 +15,9 @@ library.add(faChevronDown)
// because nav panel benefits from the same information.
export const timelineNames = () => {
return {
- 'friends': 'nav.home_timeline',
- 'bookmarks': 'nav.bookmarks',
- 'dms': 'nav.dms',
+ friends: 'nav.home_timeline',
+ bookmarks: 'nav.bookmarks',
+ dms: 'nav.dms',
'public-timeline': 'nav.public_tl',
'public-external-timeline': 'nav.twkn'
}
@@ -22,7 +26,8 @@ export const timelineNames = () => {
const TimelineMenu = {
components: {
Popover,
- TimelineMenuContent
+ NavigationEntry,
+ ListsMenuContent
},
data () {
return {
@@ -34,6 +39,28 @@ const TimelineMenu = {
this.$store.dispatch('setLastTimeline', this.$route.name)
}
},
+ computed: {
+ useListsMenu () {
+ const route = this.$route.name
+ return route === 'lists-timeline'
+ },
+ ...mapState({
+ currentUser: state => state.users.currentUser,
+ privateMode: state => state.instance.private,
+ federating: state => state.instance.federating
+ }),
+ timelinesList () {
+ return filterNavigation(
+ Object.entries(TIMELINES).map(([k, v]) => ({ ...v, name: k })),
+ {
+ hasChats: this.pleromaChatMessagesAvailable,
+ isFederating: this.federating,
+ isPrivate: this.privateMode,
+ currentUser: this.currentUser
+ }
+ )
+ }
+ },
methods: {
openMenu () {
// $nextTick is too fast, animation won't play back but
@@ -58,6 +85,9 @@ const TimelineMenu = {
if (route === 'tag-timeline') {
return '#' + this.$route.params.tag
}
+ if (route === 'lists-timeline') {
+ return this.$store.getters.findListTitle(this.$route.params.id)
+ }
const i18nkey = timelineNames()[this.$route.name]
return i18nkey ? this.$t(i18nkey) : route
}
diff --git a/src/components/timeline_menu/timeline_menu.vue b/src/components/timeline_menu/timeline_menu.vue
index 8f14093f..e7250282 100644
--- a/src/components/timeline_menu/timeline_menu.vue
+++ b/src/components/timeline_menu/timeline_menu.vue
@@ -3,19 +3,29 @@
trigger="click"
class="TimelineMenu"
:class="{ 'open': isOpen }"
- :margin="{ left: -15, right: -200 }"
:bound-to="{ x: 'container' }"
- popover-class="timeline-menu-popover-wrap"
+ bound-to-selector=".Timeline"
+ popover-class="timeline-menu-popover popover-default"
@show="openMenu"
@close="() => isOpen = false"
>
- <template v-slot:content>
- <div class="timeline-menu-popover popover-default">
- <TimelineMenuContent />
- </div>
+ <template #content>
+ <ListsMenuContent
+ v-if="useListsMenu"
+ :show-pin="false"
+ class="timelines"
+ />
+ <ul v-else>
+ <NavigationEntry
+ v-for="item in timelinesList"
+ :key="item.name"
+ :show-pin="false"
+ :item="item"
+ />
+ </ul>
</template>
- <template v-slot:trigger>
- <button class="button-unstyled title timeline-menu-title">
+ <template #trigger>
+ <span class="button-unstyled title timeline-menu-title">
<span class="timeline-title">{{ timelineName() }}</span>
<span>
<FAIcon
@@ -27,38 +37,22 @@
class="click-blocker"
@click="blockOpen"
/>
- </button>
+ </span>
</template>
</Popover>
</template>
-<script src="./timeline_menu.js" ></script>
+<script src="./timeline_menu.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.TimelineMenu {
- flex-shrink: 1;
margin-right: auto;
min-width: 0;
- width: 24rem;
-
- .timeline-menu-popover-wrap {
- overflow: hidden;
- // Match panel heading padding to line up menu with bottom of heading
- margin-top: 0.6rem;
- padding: 0 15px 15px 15px;
- }
- .timeline-menu-popover {
- width: 24rem;
- max-width: 100vw;
- margin: 0;
- font-size: 1rem;
- border-top-right-radius: 0;
- border-top-left-radius: 0;
- transform: translateY(-100%);
- transition: transform 100ms;
+ .popover-trigger-button {
+ vertical-align: bottom;
}
.panel::after {
@@ -66,10 +60,6 @@
border-top-left-radius: 0;
}
- &.open .timeline-menu-popover {
- transform: translateY(0);
- }
-
.timeline-menu-title {
margin: 0;
cursor: pointer;
@@ -104,6 +94,16 @@
box-shadow: var(--popoverShadow);
}
+}
+
+.timeline-menu-popover {
+ min-width: 24rem;
+ max-width: 100vw;
+ margin-top: 0.6rem;
+ font-size: 1rem;
+ border-top-right-radius: 0;
+ border-top-left-radius: 0;
+
ul {
list-style: none;
margin: 0;
@@ -130,7 +130,9 @@
a {
display: block;
- padding: 0.6em 0.65em;
+ padding: 0 0.65em;
+ height: 3.5em;
+ line-height: 3.5em;
&:hover {
background-color: $fallback--lightBg;
@@ -148,8 +150,7 @@
background-color: $fallback--lightBg;
background-color: var(--selectedMenu, $fallback--lightBg);
color: $fallback--text;
- color: var(--selectedMenuText, $fallback--text);
- --faint: var(--selectedMenuFaintText, $fallback--faint);
+ color: var(--selectedMenuText, $fallback--text); --faint: var(--selectedMenuFaintText, $fallback--faint);
--faintLink: var(--selectedMenuFaintLink, $fallback--faint);
--lightText: var(--selectedMenuLightText, $fallback--lightText);
--icon: var(--selectedMenuIcon, $fallback--icon);
diff --git a/src/components/timeline_menu/timeline_menu_content.js b/src/components/timeline_menu/timeline_menu_content.js
deleted file mode 100644
index 671570dd..00000000
--- a/src/components/timeline_menu/timeline_menu_content.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import { mapState } from 'vuex'
-import { library } from '@fortawesome/fontawesome-svg-core'
-import {
- faUsers,
- faGlobe,
- faBookmark,
- faEnvelope,
- faHome
-} from '@fortawesome/free-solid-svg-icons'
-
-library.add(
- faUsers,
- faGlobe,
- faBookmark,
- faEnvelope,
- faHome
-)
-
-const TimelineMenuContent = {
- computed: {
- ...mapState({
- currentUser: state => state.users.currentUser,
- privateMode: state => state.instance.private,
- federating: state => state.instance.federating
- })
- }
-}
-
-export default TimelineMenuContent
diff --git a/src/components/timeline_menu/timeline_menu_content.vue b/src/components/timeline_menu/timeline_menu_content.vue
deleted file mode 100644
index bed1b679..00000000
--- a/src/components/timeline_menu/timeline_menu_content.vue
+++ /dev/null
@@ -1,66 +0,0 @@
-<template>
- <ul>
- <li v-if="currentUser">
- <router-link
- class="menu-item"
- :to="{ name: 'friends' }"
- >
- <FAIcon
- fixed-width
- class="fa-scale-110 fa-old-padding "
- icon="home"
- />{{ $t("nav.home_timeline") }}
- </router-link>
- </li>
- <li v-if="currentUser || !privateMode">
- <router-link
- class="menu-item"
- :to="{ name: 'public-timeline' }"
- >
- <FAIcon
- fixed-width
- class="fa-scale-110 fa-old-padding "
- icon="users"
- />{{ $t("nav.public_tl") }}
- </router-link>
- </li>
- <li v-if="federating && (currentUser || !privateMode)">
- <router-link
- class="menu-item"
- :to="{ name: 'public-external-timeline' }"
- >
- <FAIcon
- fixed-width
- class="fa-scale-110 fa-old-padding "
- icon="globe"
- />{{ $t("nav.twkn") }}
- </router-link>
- </li>
- <li v-if="currentUser">
- <router-link
- class="menu-item"
- :to="{ name: 'bookmarks'}"
- >
- <FAIcon
- fixed-width
- class="fa-scale-110 fa-old-padding "
- icon="bookmark"
- />{{ $t("nav.bookmarks") }}
- </router-link>
- </li>
- <li v-if="currentUser">
- <router-link
- class="menu-item"
- :to="{ name: 'dms', params: { username: currentUser.screen_name } }"
- >
- <FAIcon
- fixed-width
- class="fa-scale-110 fa-old-padding "
- icon="envelope"
- />{{ $t("nav.dms") }}
- </router-link>
- </li>
- </ul>
-</template>
-
-<script src="./timeline_menu_content.js" ></script>
diff --git a/src/components/unicode_domain_indicator/unicode_domain_indicator.vue b/src/components/unicode_domain_indicator/unicode_domain_indicator.vue
new file mode 100644
index 00000000..8f35245f
--- /dev/null
+++ b/src/components/unicode_domain_indicator/unicode_domain_indicator.vue
@@ -0,0 +1,26 @@
+<template>
+ <FAIcon
+ v-if="user && user.screen_name_ui_contains_non_ascii"
+ icon="code"
+ :title="$t('unicode_domain_indicator.tooltip')"
+ />
+</template>
+
+<script>
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faCode
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faCode
+)
+
+const UnicodeDomainIndicator = {
+ props: {
+ user: Object
+ }
+}
+
+export default UnicodeDomainIndicator
+</script>
diff --git a/src/components/update_notification/update_notification.js b/src/components/update_notification/update_notification.js
new file mode 100644
index 00000000..ddf379f5
--- /dev/null
+++ b/src/components/update_notification/update_notification.js
@@ -0,0 +1,69 @@
+import Modal from 'src/components/modal/modal.vue'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import pleromaTan from 'src/assets/pleromatan_apology.png'
+import pleromaTanFox from 'src/assets/pleromatan_apology_fox.png'
+import pleromaTanMask from 'src/assets/pleromatan_apology_mask.png'
+import pleromaTanFoxMask from 'src/assets/pleromatan_apology_fox_mask.png'
+
+import {
+ faTimes
+} from '@fortawesome/free-solid-svg-icons'
+library.add(
+ faTimes
+)
+
+export const CURRENT_UPDATE_COUNTER = 1
+
+const UpdateNotification = {
+ data () {
+ return {
+ showingImage: false,
+ pleromaTanVariant: Math.random() > 0.5 ? pleromaTan : pleromaTanFox,
+ showingMore: false
+ }
+ },
+ components: {
+ Modal
+ },
+ computed: {
+ pleromaTanStyles () {
+ const mask = this.pleromaTanVariant === pleromaTan ? pleromaTanMask : pleromaTanFoxMask
+ return {
+ 'shape-outside': 'url(' + mask + ')'
+ }
+ },
+ shouldShow () {
+ return !this.$store.state.instance.disableUpdateNotification &&
+ this.$store.state.users.currentUser &&
+ this.$store.state.serverSideStorage.flagStorage.updateCounter < CURRENT_UPDATE_COUNTER &&
+ !this.$store.state.serverSideStorage.prefsStorage.simple.dontShowUpdateNotifs
+ }
+ },
+ methods: {
+ toggleShow () {
+ this.showingMore = !this.showingMore
+ },
+ neverShowAgain () {
+ this.toggleShow()
+ this.$store.commit('setFlag', { flag: 'updateCounter', value: CURRENT_UPDATE_COUNTER })
+ this.$store.commit('setPreference', { path: 'simple.dontShowUpdateNotifs', value: true })
+ this.$store.dispatch('pushServerSideStorage')
+ },
+ dismiss () {
+ this.$store.commit('setFlag', { flag: 'updateCounter', value: CURRENT_UPDATE_COUNTER })
+ this.$store.dispatch('pushServerSideStorage')
+ }
+ },
+ mounted () {
+ this.contentHeightNoImage = this.$refs.animatedText.scrollHeight
+
+ // Workaround to get the text height only after mask loaded. A bit hacky.
+ const newImg = new Image()
+ newImg.onload = () => {
+ setTimeout(() => { this.showingImage = true }, 100)
+ }
+ newImg.src = this.pleromaTanVariant === pleromaTan ? pleromaTanMask : pleromaTanFoxMask
+ }
+}
+
+export default UpdateNotification
diff --git a/src/components/update_notification/update_notification.scss b/src/components/update_notification/update_notification.scss
new file mode 100644
index 00000000..ce8129d0
--- /dev/null
+++ b/src/components/update_notification/update_notification.scss
@@ -0,0 +1,113 @@
+@import 'src/_variables.scss';
+.UpdateNotification {
+ overflow: hidden;
+}
+
+.UpdateNotificationModal {
+ --__top-fringe: 15em; // how much pleroma-tan should stick her head above
+ --__bottom-fringe: 80em; // just reserving as much as we can, number is mostly irrelevant
+ --__right-fringe: 8em;
+
+ font-size: 15px;
+ position: relative;
+ transition: transform;
+ transition-timing-function: ease-in-out;
+ transition-duration: 500ms;
+
+ .text {
+ max-width: 40em;
+ padding-left: 1em;
+ }
+
+ @media all and (max-width: 800px) {
+ /* For mobile, the modal takes 100% of the available screen.
+ This ensures the minimized modal is always 50px above the browser bottom bar regardless of whether or not it is visible.
+ */
+ width: 100vw;
+ }
+
+ @media all and (max-height: 600px) {
+ display: none;
+ }
+
+ .content {
+ overflow: hidden;
+ margin-top: calc(-1 * var(--__top-fringe));
+ margin-bottom: calc(-1 * var(--__bottom-fringe));
+ margin-right: calc(-1 * var(--__right-fringe));
+
+ &.-noImage {
+ .text {
+ padding-right: var(--__right-fringe);
+ }
+ }
+ }
+
+ .panel-body {
+ border-width: 0 0 1px 0;
+ border-style: solid;
+ border-color: var(--border, $fallback--border);
+ }
+
+ .panel-footer {
+ z-index: 22;
+ position: relative;
+ border-width: 0;
+ grid-template-columns: auto;
+ }
+
+ .pleroma-tan {
+ object-fit: cover;
+ object-position: top;
+ transition: position, left, right, top, bottom, max-width, max-height;
+ transition-timing-function: ease-in-out;
+ transition-duration: 500ms;
+ width: 25em;
+ float: right;
+ z-index: 20;
+ position: relative;
+ shape-margin: 0.5em;
+ filter: drop-shadow(5px 5px 10px rgba(0,0,0,0.5));
+ pointer-events: none;
+ }
+
+ .spacer-top {
+ min-height: var(--__top-fringe);
+ }
+
+ .spacer-bottom {
+ min-height: var(--__bottom-fringe);
+ }
+
+ .extra-info-group {
+ transition: max-height, padding, height;
+ transition-timing-function: ease-in;
+ transition-duration: 700ms;
+ max-height: 70vh;
+ mask:
+ linear-gradient(to top, white, transparent) bottom/100% 2px no-repeat,
+ linear-gradient(to top, white, white);
+ }
+
+ .art-credit {
+ text-align: right;
+ }
+
+ &.-peek {
+ /* Explanation:
+ * 100vh - 100% = Distance between modal's top+bottom boundaries and screen
+ * (100vh - 100%) / 2 = Distance between bottom (or top) boundary and screen
+ */
+ transform: translateY(calc(((100vh - 100%) / 2)));
+
+ .pleroma-tan {
+ float: right;
+ z-index: 10;
+ shape-image-threshold: 0.7;
+ }
+
+ .extra-info-group {
+ max-height: 0;
+ }
+ }
+}
diff --git a/src/components/update_notification/update_notification.vue b/src/components/update_notification/update_notification.vue
new file mode 100644
index 00000000..78e70a74
--- /dev/null
+++ b/src/components/update_notification/update_notification.vue
@@ -0,0 +1,103 @@
+<template>
+ <Modal
+ :is-open="!!shouldShow"
+ class="UpdateNotification"
+ :no-background="true"
+ >
+ <div
+ class="UpdateNotificationModal panel"
+ :class="{ '-peek': !showingMore }"
+ >
+ <div class="panel-heading">
+ <span class="title">
+ {{ $t('update.big_update_title') }}
+ </span>
+ </div>
+ <div class="panel-body">
+ <div
+ class="content"
+ :class="{ '-noImage': !showingImage }"
+ >
+ <img
+ v-if="showingImage"
+ class="pleroma-tan"
+ :src="pleromaTanVariant"
+ :style="pleromaTanStyles"
+ >
+ <div class="spacer-top" />
+ <div class="text">
+ <p>
+ {{ $t('update.big_update_content') }}
+ </p>
+ <div
+ ref="animatedText"
+ class="extra-info-group"
+ >
+ <i18n-t
+ keypath="update.update_bugs"
+ tag="p"
+ >
+ <template #pleromaGitlab>
+ <a
+ target="_blank"
+ href="https://git.pleroma.social/"
+ >{{ $t('update.update_bugs_gitlab') }}</a>
+ </template>
+ </i18n-t>
+ <i18n-t
+ keypath="update.update_changelog"
+ tag="p"
+ >
+ <template #theFullChangelog>
+ <a
+ target="_blank"
+ href="https://pleroma.social/announcements/"
+ >{{ $t('update.update_changelog_here') }}</a>
+ </template>
+ </i18n-t>
+ <p class="art-credit">
+ <i18n-t
+ keypath="update.art_by"
+ tag="small"
+ >
+ <template #linkToArtist>
+ <a
+ target="_blank"
+ href="https://post.ebin.club/users/pipivovott"
+ >pipivovott</a>
+ </template>
+ </i18n-t>
+ </p>
+ </div>
+ </div>
+ <div class="spacer-bottom" />
+ </div>
+ </div>
+ <div class="panel-footer">
+ <button
+ class="button-default"
+ @click.prevent="neverShowAgain"
+ >
+ {{ $t("general.never_show_again") }}
+ </button>
+ <button
+ v-if="!showingMore"
+ class="button-default"
+ @click.prevent="toggleShow"
+ >
+ {{ $t("general.show_more") }}
+ </button>
+ <button
+ class="button-default"
+ @click.prevent="dismiss"
+ >
+ {{ $t("general.dismiss") }}
+ </button>
+ </div>
+ </div>
+ </Modal>
+</template>
+
+<script src="./update_notification.js"></script>
+
+<style src="./update_notification.scss" lang="scss"></style>
diff --git a/src/components/user_avatar/user_avatar.js b/src/components/user_avatar/user_avatar.js
index 94653004..33d9a258 100644
--- a/src/components/user_avatar/user_avatar.js
+++ b/src/components/user_avatar/user_avatar.js
@@ -1,10 +1,21 @@
import StillImage from '../still-image/still-image.vue'
+import { library } from '@fortawesome/fontawesome-svg-core'
+
+import {
+ faRobot
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faRobot
+)
+
const UserAvatar = {
props: [
'user',
'betterShadow',
- 'compact'
+ 'compact',
+ 'bot'
],
data () {
return {
diff --git a/src/components/user_avatar/user_avatar.vue b/src/components/user_avatar/user_avatar.vue
index 4040e263..f4d294df 100644
--- a/src/components/user_avatar/user_avatar.vue
+++ b/src/components/user_avatar/user_avatar.vue
@@ -1,18 +1,28 @@
<template>
- <StillImage
- v-if="user"
+ <span
class="Avatar"
- :alt="user.screen_name_ui"
- :title="user.screen_name_ui"
- :src="imgSrc(user.profile_image_url_original)"
- :class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }"
- :image-load-error="imageLoadError"
- />
- <div
- v-else
- class="Avatar -placeholder"
- :class="{ 'avatar-compact': compact }"
- />
+ :class="{ '-compact': compact }"
+ >
+ <StillImage
+ v-if="user"
+ class="avatar"
+ :alt="user.screen_name_ui"
+ :title="user.screen_name_ui"
+ :src="imgSrc(user.profile_image_url_original)"
+ :image-load-error="imageLoadError"
+ :class="{ '-compact': compact, '-better-shadow': betterShadow }"
+ />
+ <div
+ v-else
+ class="avatar -placeholder"
+ :class="{ '-compact': compact }"
+ />
+ <FAIcon
+ v-if="bot"
+ icon="robot"
+ class="bot-indicator"
+ />
+ </span>
</template>
<script src="./user_avatar.js"></script>
@@ -25,36 +35,60 @@
--_avatarShadowInset: var(--avatarStatusShadowInset);
--_still-image-label-visibility: hidden;
+ display: inline-block;
+ position: relative;
width: 48px;
height: 48px;
- box-shadow: var(--_avatarShadowBox);
- border-radius: $fallback--avatarRadius;
- border-radius: var(--avatarRadius, $fallback--avatarRadius);
- img {
+ &.-compact {
+ width: 32px;
+ height: 32px;
+ border-radius: $fallback--avatarAltRadius;
+ border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+ }
+
+ .avatar {
width: 100%;
height: 100%;
- }
+ box-shadow: var(--_avatarShadowBox);
+ border-radius: $fallback--avatarRadius;
+ border-radius: var(--avatarRadius, $fallback--avatarRadius);
- &.better-shadow {
- box-shadow: var(--_avatarShadowInset);
- filter: var(--_avatarShadowFilter);
- }
+ &.-better-shadow {
+ box-shadow: var(--_avatarShadowInset);
+ filter: var(--_avatarShadowFilter);
+ }
+
+ &.-animated::before {
+ display: none;
+ }
- &.animated::before {
- display: none;
+ &.-compact {
+ border-radius: $fallback--avatarAltRadius;
+ border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+ }
+
+ &.-placeholder {
+ background-color: $fallback--fg;
+ background-color: var(--fg, $fallback--fg);
+ }
}
- &.avatar-compact {
- width: 32px;
- height: 32px;
- border-radius: $fallback--avatarAltRadius;
- border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+ img {
+ width: 100%;
+ height: 100%;
}
- &.-placeholder {
- background-color: $fallback--fg;
- background-color: var(--fg, $fallback--fg);
+ .bot-indicator {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ margin: -0.2em;
+ padding: 0.2em;
+ background: rgba(127, 127, 127, 0.5);
+ color: #fff;
+ border-radius: var(--tooltipRadius);
}
+
}
</style>
diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js
index cd8ca420..67879307 100644
--- a/src/components/user_card/user_card.js
+++ b/src/components/user_card/user_card.js
@@ -4,7 +4,9 @@ import ProgressButton from '../progress_button/progress_button.vue'
import FollowButton from '../follow_button/follow_button.vue'
import ModerationTools from '../moderation_tools/moderation_tools.vue'
import AccountActions from '../account_actions/account_actions.vue'
+import UserNote from '../user_note/user_note.vue'
import Select from '../select/select.vue'
+import UserLink from '../user_link/user_link.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { mapGetters } from 'vuex'
@@ -14,7 +16,9 @@ import {
faRss,
faSearchPlus,
faExternalLinkAlt,
- faEdit
+ faEdit,
+ faTimes,
+ faExpandAlt
} from '@fortawesome/free-solid-svg-icons'
library.add(
@@ -22,12 +26,22 @@ library.add(
faBell,
faSearchPlus,
faExternalLinkAlt,
- faEdit
+ faEdit,
+ faTimes,
+ faExpandAlt
)
export default {
props: [
- 'userId', 'switcher', 'selected', 'hideBio', 'rounded', 'bordered', 'allowZoomingAvatar'
+ 'userId',
+ 'switcher',
+ 'selected',
+ 'hideBio',
+ 'rounded',
+ 'bordered',
+ 'avatarAction', // default - open profile, 'zoom' - zoom, function - call function
+ 'onClose',
+ 'hasNoteEditor'
],
data () {
return {
@@ -47,15 +61,16 @@ export default {
},
classes () {
return [{
- 'user-card-rounded-t': this.rounded === 'top', // set border-top-left-radius and border-top-right-radius
- 'user-card-rounded': this.rounded === true, // set border-radius for all sides
- 'user-card-bordered': this.bordered === true // set border for all sides
+ '-rounded-t': this.rounded === 'top', // set border-top-left-radius and border-top-right-radius
+ '-rounded': this.rounded === true, // set border-radius for all sides
+ '-bordered': this.bordered === true, // set border for all sides
+ '-popover': !!this.onClose // set popover rounding
}]
},
style () {
return {
backgroundImage: [
- `linear-gradient(to bottom, var(--profileTint), var(--profileTint))`,
+ 'linear-gradient(to bottom, var(--profileTint), var(--profileTint))',
`url(${this.user.cover_photo})`
].join(', ')
}
@@ -112,6 +127,16 @@ export default {
hideFollowersCount () {
return this.isOtherUser && this.user.hide_followers_count
},
+ showModerationMenu () {
+ const privileges = this.loggedIn.privileges
+ return this.loggedIn.role === 'admin' || privileges.includes('users_manage_activation_state') || privileges.includes('users_delete') || privileges.includes('users_manage_tags')
+ },
+ hasNote () {
+ return this.relationship.note
+ },
+ supportsNote () {
+ return 'note' in this.relationship
+ },
...mapGetters(['mergedConfig'])
},
components: {
@@ -122,7 +147,9 @@ export default {
ProgressButton,
FollowButton,
Select,
- RichContent
+ RichContent,
+ UserLink,
+ UserNote
},
methods: {
muteUser () {
@@ -166,10 +193,16 @@ export default {
mimetype: 'image'
}
this.$store.dispatch('setMedia', [attachment])
- this.$store.dispatch('setCurrent', attachment)
+ this.$store.dispatch('setCurrentMedia', attachment)
},
mentionUser () {
this.$store.dispatch('openPostStatusModal', { replyTo: true, repliedUser: this.user })
+ },
+ onAvatarClickHandler (e) {
+ if (this.onAvatarClick) {
+ e.preventDefault()
+ this.onAvatarClick()
+ }
}
}
}
diff --git a/src/components/user_card/user_card.scss b/src/components/user_card/user_card.scss
new file mode 100644
index 00000000..cdb8cb57
--- /dev/null
+++ b/src/components/user_card/user_card.scss
@@ -0,0 +1,352 @@
+@import '../../_variables.scss';
+
+.user-card {
+ position: relative;
+ z-index: 1;
+
+ &:hover {
+ --_still-image-img-visibility: visible;
+ --_still-image-canvas-visibility: hidden;
+ --_still-image-label-visibility: hidden;
+ }
+
+ .panel-heading {
+ padding: .5em 0;
+ text-align: center;
+ box-shadow: none;
+ background: transparent;
+ flex-direction: column;
+ align-items: stretch;
+ // create new stacking context
+ position: relative;
+ }
+
+ .panel-body {
+ word-wrap: break-word;
+ border-bottom-right-radius: inherit;
+ border-bottom-left-radius: inherit;
+ // create new stacking context
+ position: relative;
+ }
+
+ .background-image {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ mask: linear-gradient(to top, white, transparent) bottom no-repeat,
+ linear-gradient(to top, white, white);
+ // Autoprefixer seem to ignore this one, and also syntax is different
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ background-size: cover;
+ mask-size: 100% 60%;
+ border-top-left-radius: calc(var(--__roundnessTop, --panelRadius) - 1px);
+ border-top-right-radius: calc(var(--__roundnessTop, --panelRadius) - 1px);
+ border-bottom-left-radius: calc(var(--__roundnessBottom, --panelRadius) - 1px);
+ border-bottom-right-radius: calc(var(--__roundnessBottom, --panelRadius) - 1px);
+ background-color: var(--profileBg);
+ z-index: -2;
+
+ &.hide-bio {
+ mask-size: 100% 40px;
+ }
+ }
+
+ &-bio {
+ text-align: center;
+ display: block;
+ line-height: 1.3;
+ padding: 1em;
+ margin: 0;
+
+ a {
+ color: $fallback--link;
+ color: var(--postLink, $fallback--link);
+ }
+
+ img {
+ object-fit: contain;
+ vertical-align: middle;
+ max-width: 100%;
+ max-height: 400px;
+ }
+ }
+
+ &.-rounded-t {
+ border-top-left-radius: $fallback--panelRadius;
+ border-top-left-radius: var(--panelRadius, $fallback--panelRadius);
+ border-top-right-radius: $fallback--panelRadius;
+ border-top-right-radius: var(--panelRadius, $fallback--panelRadius);
+
+ --__roundnessTop: var(--panelRadius);
+ --__roundnessBottom: 0;
+ }
+
+ &.-rounded {
+ border-radius: $fallback--panelRadius;
+ border-radius: var(--panelRadius, $fallback--panelRadius);
+
+ --__roundnessTop: var(--panelRadius);
+ --__roundnessBottom: var(--panelRadius);
+ }
+
+ &.-popover {
+ border-radius: $fallback--tooltipRadius;
+ border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
+
+ --__roundnessTop: var(--tooltipRadius);
+ --__roundnessBottom: var(--tooltipRadius);
+ }
+
+ &.-bordered {
+ border-width: 1px;
+ border-style: solid;
+ border-color: $fallback--border;
+ border-color: var(--border, $fallback--border);
+ }
+}
+
+.user-info {
+ color: $fallback--lightText;
+ color: var(--lightText, $fallback--lightText);
+ padding: 0 26px;
+
+ a {
+ color: $fallback--lightText;
+ color: var(--lightText, $fallback--lightText);
+
+ &:hover {
+ color: var(--icon);
+ }
+ }
+
+ .container {
+ min-width: 0;
+ padding: 16px 0 6px;
+ display: flex;
+ align-items: flex-start;
+ max-height: 56px;
+
+ > * {
+ min-width: 0;
+ }
+
+ > a {
+ vertical-align: middle;
+ display: flex;
+ }
+
+ .Avatar {
+ --_avatarShadowBox: var(--avatarShadow);
+ --_avatarShadowFilter: var(--avatarShadowFilter);
+ --_avatarShadowInset: var(--avatarShadowInset);
+
+ width: 56px;
+ height: 56px;
+ object-fit: cover;
+ }
+ }
+
+ &-avatar {
+ position: relative;
+ cursor: pointer;
+
+ &.-overlay {
+ position: absolute;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.3);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border-radius: $fallback--avatarRadius;
+ border-radius: var(--avatarRadius, $fallback--avatarRadius);
+ opacity: 0;
+ transition: opacity .2s ease;
+
+ svg {
+ color: #FFF;
+ }
+ }
+
+ &:hover &.-overlay {
+ opacity: 1;
+ }
+ }
+
+ .external-link-button, .edit-profile-button {
+ cursor: pointer;
+ width: 2.5em;
+ text-align: center;
+ margin: -0.5em 0;
+ padding: 0.5em 0;
+
+ &:not(:hover) .icon {
+ color: $fallback--lightText;
+ color: var(--lightText, $fallback--lightText);
+ }
+ }
+
+ .user-summary {
+ display: block;
+ margin-left: 0.6em;
+ text-align: left;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex: 1 1 0;
+ // This is so that text doesn't get overlapped by avatar's shadow if it has
+ // big one
+ z-index: 1;
+ line-height: 2em;
+
+ --emoji-size: 1.7em;
+
+ .top-line,
+ .bottom-line {
+ display: flex;
+ }
+ }
+
+ .user-name {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ flex: 1 1 auto;
+ margin-right: 1em;
+ font-size: 1.1em;
+ }
+
+ .bottom-line {
+ font-weight: light;
+ font-size: 1.1em;
+ align-items: baseline;
+
+ .lock-icon {
+ margin-left: 0.5em;
+ }
+
+ .user-screen-name {
+ min-width: 1px;
+ flex: 0 1 auto;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ .dailyAvg {
+ min-width: 1px;
+ flex: 0 0 auto;
+ margin-left: 1em;
+ font-size: 0.7em;
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ }
+
+ .user-role {
+ flex: none;
+ color: $fallback--text;
+ color: var(--alertNeutralText, $fallback--text);
+ background-color: $fallback--fg;
+ background-color: var(--alertNeutral, $fallback--fg);
+ }
+ }
+
+ .user-meta {
+ margin-bottom: .15em;
+ display: flex;
+ align-items: baseline;
+ line-height: 22px;
+ flex-wrap: wrap;
+
+ .following {
+ flex: 1 0 auto;
+ margin: 0;
+ margin-bottom: .25em;
+ text-align: left;
+ }
+
+ .highlighter {
+ flex: 0 1 auto;
+ display: flex;
+ flex-wrap: wrap;
+ margin-right: -.5em;
+ align-self: start;
+
+ .userHighlightCl {
+ padding: 2px 10px;
+ flex: 1 0 auto;
+ }
+
+ .userHighlightSel {
+ padding-top: 0;
+ padding-bottom: 0;
+ flex: 1 0 auto;
+ }
+
+ .userHighlightText {
+ width: 70px;
+ flex: 1 0 auto;
+ }
+
+ .userHighlightCl,
+ .userHighlightText,
+ .userHighlightSel {
+ vertical-align: top;
+ margin-right: .5em;
+ margin-bottom: .25em;
+ }
+ }
+ }
+ .user-interactions {
+ position: relative;
+ display: flex;
+ flex-flow: row wrap;
+ margin-right: -.75em;
+
+ > * {
+ margin: 0 .75em .6em 0;
+ white-space: nowrap;
+ min-width: 95px;
+ }
+
+ button {
+ margin: 0;
+ }
+ }
+
+ .user-note {
+ margin: 0 .75em .6em 0;
+ }
+}
+
+.sidebar .edit-profile-button {
+ display: none;
+}
+
+.user-counts {
+ display: flex;
+ line-height:16px;
+ padding: .5em 1.5em 0em 1.5em;
+ text-align: center;
+ justify-content: space-between;
+ color: $fallback--lightText;
+ color: var(--lightText, $fallback--lightText);
+ flex-wrap: wrap;
+}
+
+.user-count {
+ flex: 1 0 auto;
+ padding: .5em 0 .5em 0;
+ margin: 0 .5em;
+
+ h5 {
+ font-size:1em;
+ font-weight: bolder;
+ margin: 0 0 0.25em;
+ }
+ a {
+ text-decoration: none;
+ }
+}
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index 6b69d15a..349c7cb1 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -8,25 +8,32 @@
:style="style"
class="background-image"
/>
- <div class="panel-heading">
+ <div :class="onClose ? '' : panel-heading -flexible-height">
<div class="user-info">
<div class="container">
<a
- v-if="allowZoomingAvatar"
- class="user-info-avatar-link"
+ v-if="avatarAction === 'zoom'"
+ class="user-info-avatar -link"
@click="zoomAvatar"
>
<UserAvatar
:better-shadow="betterShadow"
:user="user"
/>
- <div class="user-info-avatar-link-overlay">
+ <div class="user-info-avatar -link -overlay">
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="search-plus"
/>
</div>
</a>
+ <UserAvatar
+ v-else-if="typeof avatarAction === 'function'"
+ class="user-info-avatar"
+ :better-shadow="betterShadow"
+ :user="user"
+ @click="avatarAction"
+ />
<router-link
v-else
:to="userProfileLink(user)"
@@ -38,12 +45,16 @@
</router-link>
<div class="user-summary">
<div class="top-line">
- <RichContent
- :title="user.name"
+ <router-link
+ :to="userProfileLink(user)"
class="user-name"
- :html="user.name"
- :emoji="user.emoji"
- />
+ >
+ <RichContent
+ :title="user.name"
+ :html="user.name"
+ :emoji="user.emoji"
+ />
+ </router-link>
<button
v-if="!isOtherUser && user.is_local"
class="button-unstyled edit-profile-button"
@@ -72,17 +83,41 @@
:user="user"
:relationship="relationship"
/>
- </div>
- <div class="bottom-line">
<router-link
- class="user-screen-name"
- :title="user.screen_name_ui"
+ v-if="onClose"
:to="userProfileLink(user)"
+ class="button-unstyled external-link-button"
+ @click="onClose"
>
- @{{ user.screen_name_ui }}
+ <FAIcon
+ class="icon"
+ icon="expand-alt"
+ />
</router-link>
+ <button
+ v-if="onClose"
+ class="button-unstyled external-link-button"
+ @click="onClose"
+ >
+ <FAIcon
+ class="icon"
+ icon="times"
+ />
+ </button>
+ </div>
+ <div class="bottom-line">
+ <user-link
+ class="user-screen-name"
+ :user="user"
+ />
<template v-if="!hideBio">
<span
+ v-if="user.deactivated"
+ class="alert user-role"
+ >
+ {{ $t('user_card.deactivated') }}
+ </span>
+ <span
v-if="!!visibleRole"
class="alert user-role"
>
@@ -135,6 +170,7 @@
class="userHighlightCl"
type="color"
>
+ {{ ' ' }}
<Select
:id="'userHighlightSel'+user.id"
v-model="userHighlightType"
@@ -160,7 +196,10 @@
class="user-interactions"
>
<div class="btn-group">
- <FollowButton :relationship="relationship" />
+ <FollowButton
+ :relationship="relationship"
+ :user="user"
+ />
<template v-if="relationship.following">
<ProgressButton
v-if="!relationship.subscribing"
@@ -195,6 +234,7 @@
<button
v-if="relationship.muting"
class="btn button-default btn-block toggled"
+ :disabled="user.deactivated"
@click="unmuteUser"
>
{{ $t('user_card.muted') }}
@@ -202,6 +242,7 @@
<button
v-else
class="btn button-default btn-block"
+ :disabled="user.deactivated"
@click="muteUser"
>
{{ $t('user_card.mute') }}
@@ -210,13 +251,14 @@
<div>
<button
class="btn button-default btn-block"
+ :disabled="user.deactivated"
@click="mentionUser"
>
{{ $t('user_card.mention') }}
</button>
</div>
<ModerationTools
- v-if="loggedIn.role === &quot;admin&quot;"
+ v-if="showModerationMenu"
:user="user"
/>
</div>
@@ -226,6 +268,12 @@
>
<RemoteFollow :user="user" />
</div>
+ <UserNote
+ v-if="loggedIn && isOtherUser && (hasNote || (hasNoteEditor && supportsNote))"
+ :user="user"
+ :relationship="relationship"
+ :editable="hasNoteEditor"
+ />
</div>
</div>
<div
@@ -263,6 +311,7 @@
class="user-card-bio"
:html="user.description_html"
:emoji="user.emoji"
+ :handle-links="true"
/>
</div>
</div>
@@ -270,320 +319,4 @@
<script src="./user_card.js"></script>
-<style lang="scss">
-@import '../../_variables.scss';
-
-.user-card {
- position: relative;
-
- &:hover {
- --_still-image-img-visibility: visible;
- --_still-image-canvas-visibility: hidden;
- --_still-image-label-visibility: hidden;
- }
-
- .panel-heading {
- padding: .5em 0;
- text-align: center;
- box-shadow: none;
- background: transparent;
- flex-direction: column;
- align-items: stretch;
- // create new stacking context
- position: relative;
- }
-
- .panel-body {
- word-wrap: break-word;
- border-bottom-right-radius: inherit;
- border-bottom-left-radius: inherit;
- // create new stacking context
- position: relative;
- }
-
- .background-image {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- mask: linear-gradient(to top, white, transparent) bottom no-repeat,
- linear-gradient(to top, white, white);
- // Autoprefixed seem to ignore this one, and also syntax is different
- -webkit-mask-composite: xor;
- mask-composite: exclude;
- background-size: cover;
- mask-size: 100% 60%;
- border-top-left-radius: calc(var(--panelRadius) - 1px);
- border-top-right-radius: calc(var(--panelRadius) - 1px);
- background-color: var(--profileBg);
-
- &.hide-bio {
- mask-size: 100% 40px;
- }
- }
-
- &-bio {
- text-align: center;
- display: block;
- line-height: 18px;
- padding: 1em;
- margin: 0;
-
- a {
- color: $fallback--link;
- color: var(--postLink, $fallback--link);
- }
-
- img {
- object-fit: contain;
- vertical-align: middle;
- max-width: 100%;
- max-height: 400px;
- }
- }
-
- // Modifiers
-
- &-rounded-t {
- border-top-left-radius: $fallback--panelRadius;
- border-top-left-radius: var(--panelRadius, $fallback--panelRadius);
- border-top-right-radius: $fallback--panelRadius;
- border-top-right-radius: var(--panelRadius, $fallback--panelRadius);
- }
-
- &-rounded {
- border-radius: $fallback--panelRadius;
- border-radius: var(--panelRadius, $fallback--panelRadius);
- }
-
- &-bordered {
- border-width: 1px;
- border-style: solid;
- border-color: $fallback--border;
- border-color: var(--border, $fallback--border);
- }
-}
-
-.user-info {
- color: $fallback--lightText;
- color: var(--lightText, $fallback--lightText);
- padding: 0 26px;
-
- .container {
- padding: 16px 0 6px;
- display: flex;
- align-items: flex-start;
- max-height: 56px;
-
- .Avatar {
- --_avatarShadowBox: var(--avatarShadow);
- --_avatarShadowFilter: var(--avatarShadowFilter);
- --_avatarShadowInset: var(--avatarShadowInset);
-
- flex: 1 0 100%;
- width: 56px;
- height: 56px;
- object-fit: cover;
- }
- }
-
- &-avatar-link {
- position: relative;
- cursor: pointer;
-
- &-overlay {
- position: absolute;
- left: 0;
- top: 0;
- right: 0;
- bottom: 0;
- background-color: rgba(0, 0, 0, 0.3);
- display: flex;
- justify-content: center;
- align-items: center;
- border-radius: $fallback--avatarRadius;
- border-radius: var(--avatarRadius, $fallback--avatarRadius);
- opacity: 0;
- transition: opacity .2s ease;
-
- svg {
- color: #FFF;
- }
- }
-
- &:hover &-overlay {
- opacity: 1;
- }
- }
-
- .external-link-button, .edit-profile-button {
- cursor: pointer;
- width: 2.5em;
- text-align: center;
- margin: -0.5em 0;
- padding: 0.5em 0;
-
- &:not(:hover) .icon {
- color: $fallback--lightText;
- color: var(--lightText, $fallback--lightText);
- }
- }
-
- .user-summary {
- display: block;
- margin-left: 0.6em;
- text-align: left;
- text-overflow: ellipsis;
- white-space: nowrap;
- flex: 1 1 0;
- // This is so that text doesn't get overlapped by avatar's shadow if it has
- // big one
- z-index: 1;
-
- .top-line {
- display: flex;
- }
- }
-
- .user-name {
- text-overflow: ellipsis;
- overflow: hidden;
- flex: 1 1 auto;
- margin-right: 1em;
- font-size: 15px;
-
- --emoji-size: 14px;
- }
-
- .bottom-line {
- display: flex;
- font-weight: light;
- font-size: 15px;
-
- .lock-icon {
- margin-left: 0.5em;
- }
-
- .user-screen-name {
- min-width: 1px;
- flex: 0 1 auto;
- text-overflow: ellipsis;
- overflow: hidden;
- color: $fallback--lightText;
- color: var(--lightText, $fallback--lightText);
- }
-
- .dailyAvg {
- min-width: 1px;
- flex: 0 0 auto;
- margin-left: 1em;
- font-size: 0.7em;
- color: $fallback--text;
- color: var(--text, $fallback--text);
- }
-
- .user-role {
- flex: none;
- color: $fallback--text;
- color: var(--alertNeutralText, $fallback--text);
- background-color: $fallback--fg;
- background-color: var(--alertNeutral, $fallback--fg);
- }
- }
-
- .user-meta {
- margin-bottom: .15em;
- display: flex;
- align-items: baseline;
- font-size: 14px;
- line-height: 22px;
- flex-wrap: wrap;
-
- .following {
- flex: 1 0 auto;
- margin: 0;
- margin-bottom: .25em;
- text-align: left;
- }
-
- .highlighter {
- flex: 0 1 auto;
- display: flex;
- flex-wrap: wrap;
- margin-right: -.5em;
- align-self: start;
-
- .userHighlightCl {
- padding: 2px 10px;
- flex: 1 0 auto;
- }
-
- .userHighlightSel {
- padding-top: 0;
- padding-bottom: 0;
- flex: 1 0 auto;
- }
-
- .userHighlightText {
- width: 70px;
- flex: 1 0 auto;
- }
-
- .userHighlightCl,
- .userHighlightText,
- .userHighlightSel {
- vertical-align: top;
- margin-right: .5em;
- margin-bottom: .25em;
- }
- }
- }
- .user-interactions {
- position: relative;
- display: flex;
- flex-flow: row wrap;
- margin-right: -.75em;
-
- > * {
- margin: 0 .75em .6em 0;
- white-space: nowrap;
- min-width: 95px;
- }
-
- button {
- margin: 0;
- }
- }
-}
-
-.sidebar .edit-profile-button {
- display: none;
-}
-
-.user-counts {
- display: flex;
- line-height:16px;
- padding: .5em 1.5em 0em 1.5em;
- text-align: center;
- justify-content: space-between;
- color: $fallback--lightText;
- color: var(--lightText, $fallback--lightText);
- flex-wrap: wrap;
-}
-
-.user-count {
- flex: 1 0 auto;
- padding: .5em 0 .5em 0;
- margin: 0 .5em;
-
- h5 {
- font-size:1em;
- font-weight: bolder;
- margin: 0 0 0.25em;
- }
- a {
- text-decoration: none;
- }
-}
-</style>
+<style lang="scss" src="./user_card.scss" />
diff --git a/src/components/user_link/user_link.vue b/src/components/user_link/user_link.vue
new file mode 100644
index 00000000..efd96e12
--- /dev/null
+++ b/src/components/user_link/user_link.vue
@@ -0,0 +1,38 @@
+<template>
+ <router-link
+ :title="user.screen_name_ui"
+ :to="userProfileLink(user)"
+ >
+ {{ at ? '@' : '' }}{{ user.screen_name_ui }}<UnicodeDomainIndicator
+ :user="user"
+ />
+ </router-link>
+</template>
+
+<script>
+import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
+import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+
+const UserLink = {
+ props: {
+ user: Object,
+ at: {
+ type: Boolean,
+ default: true
+ }
+ },
+ components: {
+ UnicodeDomainIndicator
+ },
+ methods: {
+ userProfileLink (user) {
+ return generateProfileLink(
+ user.id, user.screen_name,
+ this.$store.state.instance.restrictedNicknames
+ )
+ }
+ }
+}
+
+export default UserLink
+</script>
diff --git a/src/components/user_list_menu/user_list_menu.js b/src/components/user_list_menu/user_list_menu.js
new file mode 100644
index 00000000..21996031
--- /dev/null
+++ b/src/components/user_list_menu/user_list_menu.js
@@ -0,0 +1,93 @@
+import { library } from '@fortawesome/fontawesome-svg-core'
+import { faChevronRight } from '@fortawesome/free-solid-svg-icons'
+import { mapState } from 'vuex'
+
+import DialogModal from '../dialog_modal/dialog_modal.vue'
+import Popover from '../popover/popover.vue'
+
+library.add(faChevronRight)
+
+const UserListMenu = {
+ props: [
+ 'user'
+ ],
+ data () {
+ return {}
+ },
+ components: {
+ DialogModal,
+ Popover
+ },
+ created () {
+ this.$store.dispatch('fetchUserInLists', this.user.id)
+ },
+ computed: {
+ ...mapState({
+ allLists: state => state.lists.allLists
+ }),
+ inListsSet () {
+ return new Set(this.user.inLists.map(x => x.id))
+ },
+ lists () {
+ if (!this.user.inLists) return []
+ return this.allLists.map(list => ({
+ ...list,
+ inList: this.inListsSet.has(list.id)
+ }))
+ }
+ },
+ methods: {
+ toggleList (listId) {
+ if (this.inListsSet.has(listId)) {
+ this.$store.dispatch('removeListAccount', { accountId: this.user.id, listId }).then((response) => {
+ if (!response.ok) { return }
+ this.$store.dispatch('fetchUserInLists', this.user.id)
+ })
+ } else {
+ this.$store.dispatch('addListAccount', { accountId: this.user.id, listId }).then((response) => {
+ if (!response.ok) { return }
+ this.$store.dispatch('fetchUserInLists', this.user.id)
+ })
+ }
+ },
+ toggleRight (right) {
+ const store = this.$store
+ if (this.user.rights[right]) {
+ store.state.api.backendInteractor.deleteRight({ user: this.user, right }).then(response => {
+ if (!response.ok) { return }
+ store.commit('updateRight', { user: this.user, right, value: false })
+ })
+ } else {
+ store.state.api.backendInteractor.addRight({ user: this.user, right }).then(response => {
+ if (!response.ok) { return }
+ store.commit('updateRight', { user: this.user, right, value: true })
+ })
+ }
+ },
+ toggleActivationStatus () {
+ this.$store.dispatch('toggleActivationStatus', { user: this.user })
+ },
+ deleteUserDialog (show) {
+ this.showDeleteUserDialog = show
+ },
+ deleteUser () {
+ const store = this.$store
+ const user = this.user
+ const { id, name } = user
+ store.state.api.backendInteractor.deleteUser({ user })
+ .then(e => {
+ this.$store.dispatch('markStatusesAsDeleted', status => user.id === status.user.id)
+ const isProfile = this.$route.name === 'external-user-profile' || this.$route.name === 'user-profile'
+ const isTargetUser = this.$route.params.name === name || this.$route.params.id === id
+ if (isProfile && isTargetUser) {
+ window.history.back()
+ }
+ })
+ },
+ setToggled (value) {
+ this.toggled = value
+ }
+ }
+}
+
+export default UserListMenu
diff --git a/src/components/user_list_menu/user_list_menu.vue b/src/components/user_list_menu/user_list_menu.vue
new file mode 100644
index 00000000..06947ab7
--- /dev/null
+++ b/src/components/user_list_menu/user_list_menu.vue
@@ -0,0 +1,38 @@
+<template>
+ <div class="UserListMenu">
+ <Popover
+ trigger="hover"
+ placement="left"
+ remove-padding
+ >
+ <template #content>
+ <div class="dropdown-menu">
+ <button
+ v-for="list in lists"
+ :key="list.id"
+ class="button-default dropdown-item"
+ @click="toggleList(list.id)"
+ >
+ <span
+ class="menu-checkbox"
+ :class="{ 'menu-checkbox-checked': list.inList }"
+ />
+ {{ list.title }}
+ </button>
+ </div>
+ </template>
+ <template #trigger>
+ <button class="btn button-default dropdown-item -has-submenu">
+ {{ $t('lists.manage_lists') }}
+ <FAIcon
+ class="chevron-icon"
+ size="lg"
+ icon="chevron-right"
+ />
+ </button>
+ </template>
+ </Popover>
+ </div>
+</template>
+
+<script src="./user_list_menu.js"></script>
diff --git a/src/components/user_list_popover/user_list_popover.js b/src/components/user_list_popover/user_list_popover.js
index 32ca2b8d..046e0abd 100644
--- a/src/components/user_list_popover/user_list_popover.js
+++ b/src/components/user_list_popover/user_list_popover.js
@@ -1,3 +1,7 @@
+import { defineAsyncComponent } from 'vue'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
+import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
+
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
@@ -11,8 +15,10 @@ const UserListPopover = {
'users'
],
components: {
- Popover: () => import('../popover/popover.vue'),
- UserAvatar: () => import('../user_avatar/user_avatar.vue')
+ RichContent,
+ UnicodeDomainIndicator,
+ Popover: defineAsyncComponent(() => import('../popover/popover.vue')),
+ UserAvatar: defineAsyncComponent(() => import('../user_avatar/user_avatar.vue'))
},
computed: {
usersCapped () {
diff --git a/src/components/user_list_popover/user_list_popover.vue b/src/components/user_list_popover/user_list_popover.vue
index f4b93c9a..635dc7f6 100644
--- a/src/components/user_list_popover/user_list_popover.vue
+++ b/src/components/user_list_popover/user_list_popover.vue
@@ -4,10 +4,10 @@
placement="top"
:offset="{ y: 5 }"
>
- <template v-slot:trigger>
+ <template #trigger>
<slot />
</template>
- <template v-slot:content>
+ <template #content>
<div class="user-list-popover">
<template v-if="users.length">
<div
@@ -22,9 +22,14 @@
/>
<div class="user-list-names">
<!-- eslint-disable vue/no-v-html -->
- <span v-html="user.name_html" />
+ <RichContent
+ class="username"
+ :title="'@'+user.screen_name_ui"
+ :html="user.name_html"
+ :emoji="user.emoji"
+ />
<!-- eslint-enable vue/no-v-html -->
- <span class="user-list-screen-name">{{ user.screen_name_ui }}</span>
+ <span class="user-list-screen-name">{{ user.screen_name_ui }}</span><UnicodeDomainIndicator :user="user" />
</div>
</div>
</template>
@@ -40,7 +45,7 @@
</Popover>
</template>
-<script src="./user_list_popover.js" ></script>
+<script src="./user_list_popover.js"></script>
<style lang="scss">
@import '../../_variables.scss';
@@ -48,6 +53,8 @@
.user-list-popover {
padding: 0.5em;
+ --emoji-size: 16px;
+
.user-list-row {
padding: 0.25em;
display: flex;
@@ -66,7 +73,7 @@
}
.user-list-screen-name {
- font-size: 9px;
+ font-size: 0.65em;
}
}
}
diff --git a/src/components/user_note/user_note.js b/src/components/user_note/user_note.js
new file mode 100644
index 00000000..830b2e59
--- /dev/null
+++ b/src/components/user_note/user_note.js
@@ -0,0 +1,45 @@
+const UserNote = {
+ props: {
+ user: Object,
+ relationship: Object,
+ editable: Boolean
+ },
+ data () {
+ return {
+ localNote: '',
+ editing: false,
+ frozen: false
+ }
+ },
+ computed: {
+ shouldShow () {
+ return this.relationship.note || this.editing
+ }
+ },
+ methods: {
+ startEditing () {
+ this.localNote = this.relationship.note
+ this.editing = true
+ },
+ cancelEditing () {
+ this.editing = false
+ },
+ finalizeEditing () {
+ this.frozen = true
+
+ this.$store.dispatch('editUserNote', {
+ id: this.user.id,
+ comment: this.localNote
+ })
+ .then(() => {
+ this.frozen = false
+ this.editing = false
+ })
+ .catch(() => {
+ this.frozen = false
+ })
+ }
+ }
+}
+
+export default UserNote
diff --git a/src/components/user_note/user_note.vue b/src/components/user_note/user_note.vue
new file mode 100644
index 00000000..4286e017
--- /dev/null
+++ b/src/components/user_note/user_note.vue
@@ -0,0 +1,88 @@
+<template>
+ <div
+ class="user-note"
+ >
+ <div class="heading">
+ <span>{{ $t('user_card.note') }}</span>
+ <div class="buttons">
+ <button
+ v-show="!editing && editable"
+ class="button-default btn"
+ @click="startEditing"
+ >
+ {{ $t('user_card.edit_note') }}
+ </button>
+ <button
+ v-show="editing"
+ class="button-default btn"
+ :disabled="frozen"
+ @click="finalizeEditing"
+ >
+ {{ $t('user_card.edit_note_apply') }}
+ </button>
+ <button
+ v-show="editing"
+ class="button-default btn"
+ :disabled="frozen"
+ @click="cancelEditing"
+ >
+ {{ $t('user_card.edit_note_cancel') }}
+ </button>
+ </div>
+ </div>
+ <textarea
+ v-show="editing"
+ v-model="localNote"
+ class="note-text"
+ />
+ <span
+ v-show="!editing"
+ class="note-text"
+ :class="{ '-blank': !relationship.note }"
+ >
+ {{ relationship.note || $t('user_card.note_blank') }}
+ </span>
+ </div>
+</template>
+
+<script src="./user_note.js"></script>
+
+<style lang="scss">
+@import '../../variables';
+
+.user-note {
+ display: flex;
+ flex-direction: column;
+
+ .heading {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.75em;
+
+ .btn {
+ min-width: 95px;
+ }
+
+ .buttons {
+ display: flex;
+ flex-direction: row;
+ justify-content: right;
+
+ .btn {
+ margin-left: 0.5em;
+ }
+ }
+ }
+
+ .note-text {
+ align-self: stretch;
+ }
+
+ .note-text.-blank {
+ font-style: italic;
+ color: var(--faint, $fallback--faint);
+ }
+}
+</style>
diff --git a/src/components/user_panel/user_panel.vue b/src/components/user_panel/user_panel.vue
index 5685916a..95ec97af 100644
--- a/src/components/user_panel/user_panel.vue
+++ b/src/components/user_panel/user_panel.vue
@@ -1,8 +1,8 @@
<template>
- <div class="user-panel">
+ <aside class="user-panel">
<div
v-if="signedIn"
- key="user-panel"
+ key="user-panel-signed"
class="panel panel-default signed-in"
>
<UserCard
@@ -16,7 +16,7 @@
v-else
key="user-panel"
/>
- </div>
+ </aside>
</template>
<script src="./user_panel.js"></script>
@@ -24,5 +24,6 @@
<style lang="scss">
.user-panel .signed-in {
overflow: visible;
+ z-index: 10;
}
</style>
diff --git a/src/components/user_popover/user_popover.js b/src/components/user_popover/user_popover.js
new file mode 100644
index 00000000..3b12aa1e
--- /dev/null
+++ b/src/components/user_popover/user_popover.js
@@ -0,0 +1,23 @@
+import UserCard from '../user_card/user_card.vue'
+import { defineAsyncComponent } from 'vue'
+
+const UserPopover = {
+ name: 'UserPopover',
+ props: [
+ 'userId', 'overlayCenters', 'disabled', 'overlayCentersSelector'
+ ],
+ components: {
+ UserCard,
+ Popover: defineAsyncComponent(() => import('../popover/popover.vue'))
+ },
+ computed: {
+ userPopoverAvatarAction () {
+ return this.$store.getters.mergedConfig.userPopoverAvatarAction
+ },
+ userPopoverOverlay () {
+ return this.$store.getters.mergedConfig.userPopoverOverlay
+ }
+ }
+}
+
+export default UserPopover
diff --git a/src/components/user_popover/user_popover.vue b/src/components/user_popover/user_popover.vue
new file mode 100644
index 00000000..53d51fc4
--- /dev/null
+++ b/src/components/user_popover/user_popover.vue
@@ -0,0 +1,33 @@
+<template>
+ <Popover
+ trigger="click"
+ popover-class="popover-default user-popover"
+ :overlay-centers-selector="overlayCentersSelector || '.user-info .Avatar'"
+ :overlay-centers="overlayCenters && userPopoverOverlay"
+ :disabled="disabled"
+ >
+ <template #trigger>
+ <slot />
+ </template>
+ <template #content="{close}">
+ <UserCard
+ class="user-popover"
+ :user-id="userId"
+ :hide-bio="true"
+ :avatar-action="userPopoverAvatarAction == 'close' ? close : userPopoverAvatarAction"
+ :on-close="close"
+ />
+ </template>
+ </Popover>
+</template>
+
+<script src="./user_popover.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+/* popover styles load on-demand, so we need to override */
+.user-popover.popover {
+}
+
+</style>
diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index 7a475609..08adaeab 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -3,7 +3,7 @@ import UserCard from '../user_card/user_card.vue'
import FollowCard from '../follow_card/follow_card.vue'
import Timeline from '../timeline/timeline.vue'
import Conversation from '../conversation/conversation.vue'
-import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import List from '../list/list.vue'
import withLoadMore from '../../hocs/with_load_more/with_load_more'
@@ -39,15 +39,16 @@ const UserProfile = {
return {
error: false,
userId: null,
- tab: defaultTabKey
+ tab: defaultTabKey,
+ footerRef: null
}
},
created () {
const routeParams = this.$route.params
- this.load(routeParams.name || routeParams.id)
+ this.load({ name: routeParams.name, id: routeParams.id })
this.tab = get(this.$route, 'query.tab', defaultTabKey)
},
- destroyed () {
+ unmounted () {
this.stopFetching()
},
computed: {
@@ -78,6 +79,9 @@ const UserProfile = {
}
},
methods: {
+ setFooterRef (el) {
+ this.footerRef = el
+ },
load (userNameOrId) {
const startFetchingTimeline = (timeline, userId) => {
// Clear timeline only if load another user's profile
@@ -102,12 +106,17 @@ const UserProfile = {
this.userId = null
this.error = false
+ const maybeId = userNameOrId.id
+ const maybeName = userNameOrId.name
+
// Check if user data is already loaded in store
- const user = this.$store.getters.findUser(userNameOrId)
+ const user = maybeId ? this.$store.getters.findUser(maybeId) : this.$store.getters.findUserByName(maybeName)
if (user) {
loadById(user.id)
} else {
- this.$store.dispatch('fetchUser', userNameOrId)
+ (maybeId
+ ? this.$store.dispatch('fetchUser', maybeId)
+ : this.$store.dispatch('fetchUserByName', maybeName))
.then(({ id }) => loadById(id))
.catch((reason) => {
const errorMessage = get(reason, 'error.error')
@@ -146,12 +155,12 @@ const UserProfile = {
watch: {
'$route.params.id': function (newVal) {
if (newVal) {
- this.switchUser(newVal)
+ this.switchUser({ id: newVal })
}
},
'$route.params.name': function (newVal) {
if (newVal) {
- this.switchUser(newVal)
+ this.switchUser({ name: newVal })
}
},
'$route.query': function (newVal) {
diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index 726216ff..d5e8d230 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -8,8 +8,9 @@
:user-id="userId"
:switcher="true"
:selected="timeline.viewing"
- :allow-zooming-avatar="true"
+ avatar-action="zoom"
rounded="top"
+ :has-note-editor="true"
/>
<div
v-if="user.fields_html && user.fields_html.length > 0"
@@ -56,6 +57,7 @@
:user-id="userId"
:pinned-status-ids="user.pinnedStatusIds"
:in-profile="true"
+ :footer-slipgate="footerRef"
/>
<div
v-if="followsTabVisible"
@@ -64,7 +66,7 @@
:disabled="!user.friends_count"
>
<FriendList :user-id="userId">
- <template v-slot:item="{item}">
+ <template #item="{item}">
<FollowCard :user="item" />
</template>
</FriendList>
@@ -76,7 +78,7 @@
:disabled="!user.followers_count"
>
<FollowerList :user-id="userId">
- <template v-slot:item="{item}">
+ <template #item="{item}">
<FollowCard
:user="item"
:no-follows-you="isUs"
@@ -94,6 +96,7 @@
:timeline="media"
:user-id="userId"
:in-profile="true"
+ :footer-slipgate="footerRef"
/>
<Timeline
v-if="isUs"
@@ -105,8 +108,13 @@
timeline-name="favorites"
:timeline="favorites"
:in-profile="true"
+ :footer-slipgate="footerRef"
/>
</tab-switcher>
+ <div
+ :ref="setFooterRef"
+ class="panel-footer"
+ />
</div>
<div
v-else
@@ -138,6 +146,9 @@
flex: 2;
flex-basis: 500px;
+ // No sticky header on user profile
+ --currentPanelStack: 1;
+
.user-profile-fields {
margin: 0 0.5em;
@@ -176,7 +187,7 @@
}
.user-profile-field-name, .user-profile-field-value {
- line-height: 18px;
+ line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
@@ -192,24 +203,6 @@
align-items: middle;
padding: 2em;
}
-
- .timeline-heading {
- display: flex;
- justify-content: center;
-
- .loadmore-button, .alert {
- flex: 1;
- }
-
- .loadmore-button {
- height: 28px;
- margin: 10px .6em;
- }
-
- .title, .loadmore-text {
- display: none
- }
- }
}
.user-profile-placeholder {
.panel-body {
diff --git a/src/components/user_reporting_modal/user_reporting_modal.js b/src/components/user_reporting_modal/user_reporting_modal.js
index 8d171b2d..67fde084 100644
--- a/src/components/user_reporting_modal/user_reporting_modal.js
+++ b/src/components/user_reporting_modal/user_reporting_modal.js
@@ -1,15 +1,16 @@
-
import Status from '../status/status.vue'
import List from '../list/list.vue'
import Checkbox from '../checkbox/checkbox.vue'
import Modal from '../modal/modal.vue'
+import UserLink from '../user_link/user_link.vue'
const UserReportingModal = {
components: {
Status,
List,
Checkbox,
- Modal
+ Modal,
+ UserLink
},
data () {
return {
@@ -21,14 +22,17 @@ const UserReportingModal = {
}
},
computed: {
+ reportModal () {
+ return this.$store.state.reports.reportModal
+ },
isLoggedIn () {
return !!this.$store.state.users.currentUser
},
isOpen () {
- return this.isLoggedIn && this.$store.state.reports.modalActivated
+ return this.isLoggedIn && this.reportModal.activated
},
userId () {
- return this.$store.state.reports.userId
+ return this.reportModal.userId
},
user () {
return this.$store.getters.findUser(this.userId)
@@ -37,10 +41,10 @@ const UserReportingModal = {
return !this.user.is_local && this.user.screen_name.substr(this.user.screen_name.indexOf('@') + 1)
},
statuses () {
- return this.$store.state.reports.statuses
+ return this.reportModal.statuses
},
preTickedIds () {
- return this.$store.state.reports.preTickedIds
+ return this.reportModal.preTickedIds
}
},
watch: {
diff --git a/src/components/user_reporting_modal/user_reporting_modal.vue b/src/components/user_reporting_modal/user_reporting_modal.vue
index 1f67a5cc..8c42ab7b 100644
--- a/src/components/user_reporting_modal/user_reporting_modal.vue
+++ b/src/components/user_reporting_modal/user_reporting_modal.vue
@@ -5,9 +5,13 @@
>
<div class="user-reporting-panel panel">
<div class="panel-heading">
- <div class="title">
- {{ $t('user_reporting.title', [user.screen_name_ui]) }}
- </div>
+ <i18n-t
+ tag="div"
+ keypath="user_reporting.title"
+ class="title"
+ >
+ <UserLink :user="user" />
+ </i18n-t>
</div>
<div class="panel-body">
<div class="user-reporting-panel-left">
@@ -45,7 +49,7 @@
</div>
<div class="user-reporting-panel-right">
<List :items="statuses">
- <template v-slot:item="{item}">
+ <template #item="{item}">
<div class="status-fadein user-reporting-panel-sitem">
<Status
:in-conversation="false"
@@ -53,8 +57,8 @@
:statusoid="item"
/>
<Checkbox
- :checked="isChecked(item.id)"
- @change="checked => toggleStatus(checked, item.id)"
+ :model-value="isChecked(item.id)"
+ @update:model-value="checked => toggleStatus(checked, item.id)"
/>
</div>
</template>
@@ -76,17 +80,6 @@
min-height: 20vh;
max-height: 80vh;
- .panel-heading {
- .title {
- text-align: center;
- // TODO: Consider making these as default of panel
- flex: 1;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- }
-
.panel-body {
display: flex;
flex-direction: column-reverse;
@@ -98,7 +91,7 @@
&-left {
padding: 1.1em 0.7em 0.7em;
- line-height: 1.4em;
+ line-height: var(--post-line-height);
box-sizing: border-box;
> div {
diff --git a/src/components/who_to_follow/who_to_follow.js b/src/components/who_to_follow/who_to_follow.js
index ecd97dd7..53f05272 100644
--- a/src/components/who_to_follow/who_to_follow.js
+++ b/src/components/who_to_follow/who_to_follow.js
@@ -28,7 +28,7 @@ const WhoToFollow = {
getWhoToFollow () {
const credentials = this.$store.state.users.currentUser.credentials
if (credentials) {
- apiService.suggestions({ credentials: credentials })
+ apiService.suggestions({ credentials })
.then((reply) => {
this.showWhoToFollow(reply)
})
diff --git a/src/components/who_to_follow_panel/who_to_follow_panel.js b/src/components/who_to_follow_panel/who_to_follow_panel.js
index 818e8bd5..f19ba948 100644
--- a/src/components/who_to_follow_panel/who_to_follow_panel.js
+++ b/src/components/who_to_follow_panel/who_to_follow_panel.js
@@ -6,9 +6,9 @@ function showWhoToFollow (panel, reply) {
const shuffled = shuffle(reply)
panel.usersToFollow.forEach((toFollow, index) => {
- let user = shuffled[index]
- let img = user.avatar || this.$store.state.instance.defaultAvatar
- let name = user.acct
+ const user = shuffled[index]
+ const img = user.avatar || this.$store.state.instance.defaultAvatar
+ const name = user.acct
toFollow.img = img
toFollow.name = name
@@ -24,12 +24,12 @@ function showWhoToFollow (panel, reply) {
}
function getWhoToFollow (panel) {
- var credentials = panel.$store.state.users.currentUser.credentials
+ const credentials = panel.$store.state.users.currentUser.credentials
if (credentials) {
panel.usersToFollow.forEach(toFollow => {
toFollow.name = 'Loading...'
})
- apiService.suggestions({ credentials: credentials })
+ apiService.suggestions({ credentials })
.then((reply) => {
showWhoToFollow(panel, reply)
})
diff --git a/src/components/who_to_follow_panel/who_to_follow_panel.vue b/src/components/who_to_follow_panel/who_to_follow_panel.vue
index 518acd97..c1ba6fb1 100644
--- a/src/components/who_to_follow_panel/who_to_follow_panel.vue
+++ b/src/components/who_to_follow_panel/who_to_follow_panel.vue
@@ -27,7 +27,7 @@
</div>
</template>
-<script src="./who_to_follow_panel.js" ></script>
+<script src="./who_to_follow_panel.js"></script>
<style lang="scss">
.who-to-follow * {
diff --git a/src/directives/body_scroll_lock.js b/src/directives/body_scroll_lock.js
index 13a6de1c..b6d16790 100644
--- a/src/directives/body_scroll_lock.js
+++ b/src/directives/body_scroll_lock.js
@@ -50,12 +50,12 @@ const enableBodyScroll = (el) => {
}
const directive = {
- inserted: (el, binding) => {
+ mounted: (el, binding) => {
if (binding.value) {
disableBodyScroll(el)
}
},
- componentUpdated: (el, binding) => {
+ updated: (el, binding) => {
if (binding.oldValue === binding.value) {
return
}
@@ -66,7 +66,7 @@ const directive = {
enableBodyScroll(el)
}
},
- unbind: (el) => {
+ unmounted: (el) => {
enableBodyScroll(el)
}
}
diff --git a/src/hocs/with_load_more/with_load_more.js b/src/hocs/with_load_more/with_load_more.jsx
index 671b2b6f..c0ae1856 100644
--- a/src/hocs/with_load_more/with_load_more.js
+++ b/src/hocs/with_load_more/with_load_more.jsx
@@ -1,4 +1,5 @@
-import Vue from 'vue'
+// eslint-disable-next-line no-unused
+import { h } from 'vue'
import isEmpty from 'lodash/isEmpty'
import { getComponentProps } from '../../services/component_utils/component_utils'
import './with_load_more.scss'
@@ -16,14 +17,14 @@ library.add(
const withLoadMore = ({
fetch, // function to fetch entries and return a promise
select, // function to select data from store
- destroy, // function called at "destroyed" lifecycle
+ unmounted, // function called at "destroyed" lifecycle
childPropName = 'entries', // name of the prop to be passed into the wrapped component
additionalPropNames = [] // additional prop name list of the wrapper component
}) => (WrappedComponent) => {
const originalProps = Object.keys(getComponentProps(WrappedComponent))
const props = originalProps.filter(v => v !== childPropName).concat(additionalPropNames)
- return Vue.component('withLoadMore', {
+ return {
props,
data () {
return {
@@ -39,9 +40,9 @@ const withLoadMore = ({
this.fetchEntries()
}
},
- destroyed () {
+ unmounted () {
window.removeEventListener('scroll', this.scrollLoad)
- destroy && destroy(this.$props, this.$store)
+ unmounted && unmounted(this.$props, this.$store)
},
methods: {
// Entries is not a computed because computed can't track the dynamic
@@ -79,16 +80,12 @@ const withLoadMore = ({
}
}
},
- render (h) {
+ render () {
const props = {
- props: {
- ...this.$props,
- [childPropName]: this.entries
- },
- on: this.$listeners,
- scopedSlots: this.$scopedSlots
+ ...this.$props,
+ [childPropName]: this.entries
}
- const children = Object.entries(this.$slots).map(([key, value]) => h('template', { slot: key }, value))
+ const children = this.$slots
return (
<div class="with-load-more">
<WrappedComponent {...props}>
@@ -106,7 +103,7 @@ const withLoadMore = ({
</div>
)
}
- })
+ }
}
export default withLoadMore
diff --git a/src/hocs/with_load_more/with_load_more.scss b/src/hocs/with_load_more/with_load_more.scss
index 1a26eb8d..de86ed4a 100644
--- a/src/hocs/with_load_more/with_load_more.scss
+++ b/src/hocs/with_load_more/with_load_more.scss
@@ -10,7 +10,7 @@
border-top-color: var(--border, $fallback--border);
.error {
- font-size: 14px;
+ font-size: 1rem;
}
a {
diff --git a/src/hocs/with_subscription/with_subscription.js b/src/hocs/with_subscription/with_subscription.jsx
index b1244276..d3f5506a 100644
--- a/src/hocs/with_subscription/with_subscription.js
+++ b/src/hocs/with_subscription/with_subscription.jsx
@@ -1,4 +1,5 @@
-import Vue from 'vue'
+// eslint-disable-next-line no-unused
+import { h } from 'vue'
import isEmpty from 'lodash/isEmpty'
import { getComponentProps } from '../../services/component_utils/component_utils'
import './with_subscription.scss'
@@ -22,7 +23,7 @@ const withSubscription = ({
const originalProps = Object.keys(getComponentProps(WrappedComponent))
const props = originalProps.filter(v => v !== childPropName).concat(additionalPropNames)
- return Vue.component('withSubscription', {
+ return {
props: [
...props,
'refresh' // boolean saying to force-fetch data whenever created
@@ -59,17 +60,13 @@ const withSubscription = ({
}
}
},
- render (h) {
+ render () {
if (!this.error && !this.loading) {
const props = {
- props: {
- ...this.$props,
- [childPropName]: this.fetchedData
- },
- on: this.$listeners,
- scopedSlots: this.$scopedSlots
+ ...this.$props,
+ [childPropName]: this.fetchedData
}
- const children = Object.entries(this.$slots).map(([key, value]) => h('template', { slot: key }, value))
+ const children = this.$slots
return (
<div class="with-subscription">
<WrappedComponent {...props}>
@@ -88,7 +85,7 @@ const withSubscription = ({
)
}
}
- })
+ }
}
export default withSubscription
diff --git a/src/hocs/with_subscription/with_subscription.scss b/src/hocs/with_subscription/with_subscription.scss
index 52c7d94c..7fd83802 100644
--- a/src/hocs/with_subscription/with_subscription.scss
+++ b/src/hocs/with_subscription/with_subscription.scss
@@ -4,7 +4,7 @@
text-align: center;
.error {
- font-size: 14px;
+ font-size: 1rem;
}
}
} \ No newline at end of file
diff --git a/src/i18n/ar.json b/src/i18n/ar.json
index a475d291..cd9a410a 100644
--- a/src/i18n/ar.json
+++ b/src/i18n/ar.json
@@ -200,5 +200,22 @@
"who_to_follow": {
"more": "Ø§Ų„Ų…Ø˛ŲŠØ¯",
"who_to_follow": "Ų„Ų„Ų…ØĒابؚ؊"
+ },
+ "about": {
+ "mrf": {
+ "keyword": {
+ "ftl_removal": "ØĨØ˛Ø§Ų„ØŠ Ų…Ų† Ø§Ų„ØŽØˇ Ø§Ų„Ø˛Ų…Ų†ŲŠ Ø§Ų„ØŽØ§Øĩ بØŦŲ…ŲŠØš Ø§Ų„Ø´Ø¨ŲƒØ§ØĒ Ø§Ų„Ų…ØšØąŲˆŲØŠ",
+ "reject": "ØąŲØļ",
+ "replace": "ØĨØŗØĒØ¨Ø¯Ø§Ų„",
+ "is_replaced_by": "←",
+ "keyword_policies": "ØŗŲŠØ§ØŗØŠ Ø§Ų„ŲƒŲ„Ų…Ø§ØĒ Ø§Ų„Ø¯Ų„Ø§Ų„ŲŠØŠ"
+ },
+ "simple": {
+ "simple_policies": "ØŗŲŠØ§ØŗØ§ØĒ Ø§Ų„ØŽØ§Ø¯Ų…"
+ },
+ "federation": "Ø§Ų„Ø§ØĒحاد",
+ "mrf_policies": "ØĒŲØšŲŠŲ„ ØŗŲŠØ§ØŗØ§ØĒ ØĨؚاد؊ ؃ØĒاب؊ Ø§Ų„Ų…Ų†Ø´ŲˆØą",
+ "mrf_policies_desc": "؎اØĩŲŠØŠ ØĨؚاد؊ ؃ØĒاب؊ Ø§Ų„Ų…Ų†Ø§Ø´ŲŠØą ØĒŲ‚ŲˆŲ… بØĒØšØ¯ŲŠŲ„ ØĒŲØ§ØšŲ„ Ø§Ų„Ø§ØĒحاد Ų…Øš Ų‡Ø°Ø§ Ø§Ų„ØŽØ§Ø¯Ų…. Ø§Ų„ØŗŲŠØ§ØŗØ§ØĒ Ø§Ų„ØĒØ§Ų„ŲŠØŠ Ų…ŲØšŲ‘Ų„ØŠ:"
+ }
}
}
diff --git a/src/i18n/ca.json b/src/i18n/ca.json
index 1f5392f3..5f2795a8 100644
--- a/src/i18n/ca.json
+++ b/src/i18n/ca.json
@@ -323,7 +323,10 @@
"play_videos_in_modal": "Reproduir vídeos en un marc emergent",
"file_export_import": {
"errors": {
- "invalid_file": "El fitxer seleccionat no Ês vàlid com a cÃ˛pia de seguretat de la configuraciÃŗ. No s'ha realitzat cap canvi."
+ "invalid_file": "El fitxer seleccionat no Ês vàlid com a cÃ˛pia de seguretat de la configuraciÃŗ. No s'ha realitzat cap canvi.",
+ "file_too_new": "VersiÃŗ important incompatible: {fileMajor}, aquest PleromaFE (configuraciÃŗ versiÃŗ {feMajor}) Ês massa antiga per gestionar-lo",
+ "file_too_old": "VersiÃŗ important incompatible: {fileMajor}, la versiÃŗ del fitxer Ês massa antiga i no està implementada (s'ha establert un mínim ver. {feMajor})",
+ "file_slightly_new": "La versiÃŗ menor del fitxer Ês diferent, alguns paràmetres podrien no carregar-se"
},
"backup_settings": "CÃ˛pia de seguretat de la configuraciÃŗ a un fitxer",
"backup_settings_theme": "CÃ˛pia de seguretat de la configuraciÃŗ i tema a un fitxer",
@@ -382,7 +385,8 @@
"postCode": "Text monoespai en publicaciÃŗ (text enriquit)",
"input": "Camps d'entrada",
"interface": "Interfície"
- }
+ },
+ "weight": "Pes (negreta)"
},
"preview": {
"input": "Acabo d'aterrar a Los Angeles.",
@@ -394,7 +398,9 @@
"error": "Exemple d'error",
"faint_link": "Manual d'ajuda",
"checkbox": "He llegit els termes i condicions",
- "link": "un bonic enllaç"
+ "link": "un bonic enllaç",
+ "fine_print": "Llegiu el nostre {0} per no aprendre res Ãētil!",
+ "text": "Un grapat mÊs de {0} i {1}"
},
"shadows": {
"spread": "Difon",
@@ -438,7 +444,8 @@
"snapshot_missing": "No hi havia cap instantània del tema al fitxer, per tant podria veure's diferent del previst originalment.",
"upgraded_from_v2": "PleromaFE s'ha actualitzat, el tema pot veure's un poc diferent de com recordes.",
"fe_downgraded": "VersiÃŗ de PleromaFE revertida.",
- "older_version_imported": "El fitxer que has importat va ser creat en una versiÃŗ del front-end mÊs antiga."
+ "older_version_imported": "El fitxer que has importat va ser creat en una versiÃŗ del front-end mÊs antiga.",
+ "snapshot_present": "S'ha carregat la instantània del tema, de manera que tots els valors estan sobreescrits. En canvi, podeu carregar les dades reals del tema."
},
"keep_as_is": "Mantindre com està",
"save_load_hint": "Les opcions \"Mantindre\" conserven les opcions configurades actualment al seleccionar o carregar temes, tambÊ emmagatzema aquestes opcions quan s'exporta un tema. Quan es desactiven totes les caselles de verificaciÃŗ, el tema exportat ho guardarà tot.",
@@ -532,7 +539,13 @@
"notification_setting_hide_notification_contents": "Amagar el remitent i els continguts de les notificacions push",
"notifications": "Notificacions",
"notification_mutes": "Per a deixar de rebre notificacions d'un usuari en concret, silencia'l-ho.",
- "theme_help_v2_2": "Les icones per baix d'algunes entrades sÃŗn indicadors del contrast del fons/text, desplaça el ratolí per a mÊs informaciÃŗ. Tingues en compte que quan s'utilitzen indicadors de contrast de transparència es mostra el pitjor cas possible."
+ "theme_help_v2_2": "Les icones per baix d'algunes entrades sÃŗn indicadors del contrast del fons/text, desplaça el ratolí per a mÊs informaciÃŗ. Tingues en compte que quan s'utilitzen indicadors de contrast de transparència es mostra el pitjor cas possible.",
+ "hide_shoutbox": "Oculta la casella de gàbia de grills",
+ "always_show_post_button": "Mostra sempre el botÃŗ flotant de publicaciÃŗ nova",
+ "pad_emoji": "Acompanya els emojis amb espais en afegir des del selector",
+ "mentions_new_style": "Enllaços d'esment mÊs elegants",
+ "mentions_new_place": "Posa les mencions en una línia separada",
+ "post_status_content_type": "Format de publicaciÃŗ"
},
"time": {
"day": "{0} dia",
@@ -608,7 +621,6 @@
"disable_any_subscription": "Deshabilita completament seguir algÃē",
"quarantine": "Deshabilita la federaciÃŗ a les entrades de les usuàries",
"moderation": "ModeraciÃŗ",
- "delete_user_confirmation": "Estàs completament segur/a? Aquesta acciÃŗ no es pot desfer.",
"revoke_admin": "Revoca l'Admin",
"activate_account": "Activa el compte",
"deactivate_account": "Desactiva el compte",
@@ -617,7 +629,9 @@
"disable_remote_subscription": "Deshabilita seguir algÃē des d'una instància remota",
"delete_user": "Esborra la usuària",
"grant_admin": "Concedir permisos d'AdministraciÃŗ",
- "grant_moderator": "Concedir permisos de ModeraciÃŗ"
+ "grant_moderator": "Concedir permisos de ModeraciÃŗ",
+ "force_unlisted": "Força que les publicacions no estiguin llistades",
+ "sandbox": "Força que els missatges siguin nomÊs seguidors"
},
"edit_profile": "Edita el perfil",
"hidden": "Amagat",
@@ -642,7 +656,8 @@
"solid": "Fons sÃ˛lid",
"striped": "Fons a ratlles",
"side": "Ratlla lateral"
- }
+ },
+ "media": "Media"
},
"user_profile": {
"timeline_title": "Flux personal",
@@ -658,12 +673,14 @@
},
"remote_user_resolver": {
"error": "No trobat.",
- "searching_for": "Cercant per"
+ "searching_for": "Cercant per",
+ "remote_user_resolver": "ResoluciÃŗ d'usuari remot"
},
"interactions": {
"load_older": "Carrega antigues interaccions",
"favs_repeats": "Repeticions i favorits",
- "follows": "Nous seguidors"
+ "follows": "Nous seguidors",
+ "moves": "MigraciÃŗ d'usuaris"
},
"emoji": {
"stickers": "Adhesius",
@@ -775,7 +792,10 @@
"pinned": "Destacat",
"reply_to": "Contesta a",
"pin": "Destaca al perfil",
- "unmute_conversation": "Deixa de silenciar la conversa"
+ "unmute_conversation": "Deixa de silenciar la conversa",
+ "mentions": "Mencions",
+ "you": "(Tu)",
+ "plus_more": "+{number} mÊs"
},
"user_reporting": {
"additional_comments": "Comentaris addicionals",
@@ -801,7 +821,8 @@
"no_results": "No hi ha resultats",
"people": "Persones",
"hashtags": "Etiquetes",
- "people_talking": "{count} persones parlant"
+ "people_talking": "{count} persones parlant",
+ "person_talking": "{count} persones parlant"
},
"upload": {
"file_size_units": {
diff --git a/src/i18n/de.json b/src/i18n/de.json
index b6599594..4bf897ef 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -582,7 +582,6 @@
"statuses": "Beiträge",
"admin_menu": {
"sandbox": "Erzwinge Beiträge nur fÃŧr Follower sichtbar zu sein",
- "delete_user_confirmation": "Achtung! Diese Entscheidung kann nicht rÃŧckgängig gemacht werden! Trotzdem durchfÃŧhren?",
"grant_admin": "Administratorprivilegien gewähren",
"delete_user": "Nutzer lÃļschen",
"strip_media": "Medien von Beiträgen entfernen",
diff --git a/src/i18n/en.json b/src/i18n/en.json
index eef8d701..59ee1c17 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -32,6 +32,27 @@
},
"staff": "Staff"
},
+ "announcements": {
+ "page_header": "Announcements",
+ "title": "Announcement",
+ "mark_as_read_action": "Mark as read",
+ "post_form_header": "Post announcement",
+ "post_placeholder": "Type your announcement content here...",
+ "post_action": "Post",
+ "post_error": "Error: {error}",
+ "close_error": "Close",
+ "delete_action": "Delete",
+ "start_time_prompt": "Start time: ",
+ "end_time_prompt": "End time: ",
+ "all_day_prompt": "This is an all-day event",
+ "published_time_display": "Published at {time}",
+ "start_time_display": "Starts at {time}",
+ "end_time_display": "Ends at {time}",
+ "edit_action": "Edit",
+ "submit_edit_action": "Submit",
+ "cancel_edit_action": "Cancel",
+ "inactive_message": "This announcement is inactive"
+ },
"shoutbox": {
"title": "Shoutbox"
},
@@ -46,7 +67,7 @@
"processing": "Processing, you'll soon be asked to download your file"
},
"features_panel": {
- "chat": "Chat",
+ "shout": "Shoutbox",
"pleroma_chat_messages": "Pleroma Chat",
"gopher": "Gopher",
"media_proxy": "Media proxy",
@@ -66,11 +87,13 @@
"more": "More",
"loading": "Loadingâ€Ļ",
"generic_error": "An error occured",
+ "generic_error_message": "An error occured: {0}",
"error_retry": "Please try again",
"retry": "Try again",
"optional": "optional",
"show_more": "Show more",
"show_less": "Show less",
+ "never_show_again": "Never show again",
"dismiss": "Dismiss",
"cancel": "Cancel",
"disable": "Disable",
@@ -78,14 +101,26 @@
"confirm": "Confirm",
"verify": "Verify",
"close": "Close",
+ "undo": "Undo",
+ "yes": "Yes",
+ "no": "No",
"peek": "Peek",
+ "scroll_to_top": "Scroll to top",
"role": {
"admin": "Admin",
"moderator": "Moderator"
},
+ "unpin": "Unpin item",
+ "pin": "Pin item",
"flash_content": "Click to show Flash content using Ruffle (Experimental, may not work).",
"flash_security": "Note that this can be potentially dangerous since Flash content is still arbitrary code.",
- "flash_fail": "Failed to load flash content, see console for details."
+ "flash_fail": "Failed to load flash content, see console for details.",
+ "scope_in_timeline": {
+ "direct": "Direct",
+ "private": "Followers-only",
+ "public": "Public",
+ "unlisted": "Unlisted"
+ }
},
"image_cropper": {
"crop_picture": "Crop picture",
@@ -118,7 +153,9 @@
},
"media_modal": {
"previous": "Previous",
- "next": "Next"
+ "next": "Next",
+ "counter": "{current} / {total}",
+ "hide": "Close media viewer"
},
"nav": {
"about": "About",
@@ -138,7 +175,16 @@
"who_to_follow": "Who to follow",
"preferences": "Preferences",
"timelines": "Timelines",
- "chats": "Chats"
+ "chats": "Chats",
+ "lists": "Lists",
+ "edit_nav_mobile": "Customize navigation bar",
+ "edit_pinned": "Edit pinned items",
+ "edit_finish": "Done editing",
+ "mobile_sidebar": "Toggle mobile sidebar",
+ "mobile_notifications": "Open notifications",
+ "mobile_notifications": "Open notifications (there are unread ones)",
+ "mobile_notifications_close": "Close notifications",
+ "announcements": "Announcements"
},
"notifications": {
"broken_favorite": "Unknown status, searching for itâ€Ļ",
@@ -152,7 +198,9 @@
"repeated_you": "repeated your status",
"no_more_notifications": "No more notifications",
"migrated_to": "migrated to",
- "reacted_with": "reacted with {0}"
+ "reacted_with": "reacted with {0}",
+ "submitted_report": "submitted a report",
+ "poll_ended": "poll has ended"
},
"polls": {
"add_poll": "Add poll",
@@ -178,8 +226,20 @@
"add_emoji": "Insert emoji",
"custom": "Custom emoji",
"unicode": "Unicode emoji",
+ "unicode_groups": {
+ "activities": "Activities",
+ "animals-and-nature": "Animals & Nature",
+ "flags": "Flags",
+ "food-and-drink": "Food & Drink",
+ "objects": "Objects",
+ "people-and-body": "People & Body",
+ "smileys-and-emotion": "Smileys & Emotion",
+ "symbols": "Symbols",
+ "travel-and-places": "Travel & Places"
+ },
"load_all_hint": "Loaded first {saneAmount} emoji, loading all emoji may cause performance issues.",
- "load_all": "Loading all {emojiAmount} emoji"
+ "load_all": "Loading all {emojiAmount} emoji",
+ "regional_indicator": "Regional indicator {letter}"
},
"errors": {
"storage_unavailable": "Pleroma could not access browser storage. Your login or your local settings won't be saved and you might encounter unexpected issues. Try enabling cookies."
@@ -187,10 +247,13 @@
"interactions": {
"favs_repeats": "Repeats and favorites",
"follows": "New follows",
+ "emoji_reactions": "Emoji Reactions",
+ "reports": "Reports",
"moves": "User migrates",
"load_older": "Load older interactions"
},
"post_status": {
+ "edit_status": "Edit status",
"new_status": "Post new status",
"account_not_locked_warning": "Your account is not {0}. Anyone can follow you to view your follower-only posts.",
"account_not_locked_warning_link": "locked",
@@ -206,6 +269,8 @@
"default": "Just landed in L.A.",
"direct_warning_to_all": "This post will be visible to all the mentioned users.",
"direct_warning_to_first_only": "This post will only be visible to the mentioned users at the beginning of the message.",
+ "edit_remote_warning": "Other remote instances may not support editing and unable to receive the latest version of your post.",
+ "edit_unsupported_warning": "Pleroma does not support editing mentions or polls.",
"posting": "Posting",
"post": "Post",
"preview": "Preview",
@@ -225,8 +290,9 @@
}
},
"registration": {
- "bio": "Bio",
+ "bio_optional": "Bio (optional)",
"email": "Email",
+ "email_optional": "Email (optional)",
"fullname": "Display name",
"password_confirm": "Password confirmation",
"registration": "Registration",
@@ -246,24 +312,37 @@
"password_required": "cannot be left blank",
"password_confirmation_required": "cannot be left blank",
"password_confirmation_match": "should be the same as password"
- }
+ },
+ "email_language": "In which language do you want to receive emails from the server?"
},
"remote_user_resolver": {
"remote_user_resolver": "Remote user resolver",
"searching_for": "Searching for",
"error": "Not found."
},
+ "report": {
+ "reporter": "Reporter:",
+ "reported_user": "Reported user:",
+ "reported_statuses": "Reported statuses:",
+ "notes": "Notes:",
+ "state": "State:",
+ "state_open": "Open",
+ "state_closed": "Closed",
+ "state_resolved": "Resolved"
+ },
"selectable_list": {
"select_all": "Select all"
},
"settings": {
"app_name": "App name",
+ "expert_mode": "Show advanced",
"save": "Save changes",
"security": "Security",
"setting_changed": "Setting is different from default",
+ "setting_server_side": "This setting is tied to your profile and affects all sessions and clients",
"enter_current_password_to_confirm": "Enter your current password to confirm your identity",
- "mentions_new_style": "Fancier mention links",
- "mentions_new_place": "Put mentions on a separate line",
+ "post_look_feel": "Posts Look & Feel",
+ "mention_links": "Mention links",
"mfa": {
"otp": "OTP",
"setup_otp": "Setup OTP",
@@ -285,6 +364,7 @@
"desc": "To enable two-factor authentication, enter the code from your two-factor app:"
}
},
+ "lists_navigation": "Show lists in navigation",
"allow_following_move": "Allow auto-follow when following account moves",
"attachmentRadius": "Attachments",
"attachments": "Attachments",
@@ -293,6 +373,7 @@
"avatarRadius": "Avatars",
"background": "Background",
"bio": "Bio",
+ "email_language": "Language for receiving emails from the server",
"block_export": "Block export",
"block_export_button": "Export your blocks to a csv file",
"block_import": "Block import",
@@ -304,6 +385,16 @@
"mute_import_error": "Error importing mutes",
"mutes_imported": "Mutes imported! Processing them will take a while.",
"import_mutes_from_a_csv_file": "Import mutes from a csv file",
+ "account_backup": "Account backup",
+ "account_backup_description": "This allows you to download an archive of your account information and your posts, but they cannot yet be imported into a Pleroma account.",
+ "account_backup_table_head": "Backup",
+ "download_backup": "Download",
+ "backup_not_ready": "This backup is not ready yet.",
+ "remove_backup": "Remove",
+ "list_backups_error": "Error fetching backup list: {error}",
+ "add_backup": "Create a new backup",
+ "added_backup": "Added a new backup.",
+ "add_backup_error": "Error adding a new backup: {error}",
"blocks_tab": "Blocks",
"bot": "This is a bot account",
"btnRadius": "Buttons",
@@ -329,6 +420,19 @@
"delete_account_description": "Permanently delete your data and deactivate your account.",
"delete_account_error": "There was an issue deleting your account. If this persists please contact your instance administrator.",
"delete_account_instructions": "Type your password in the input below to confirm account deletion.",
+ "account_alias": "Account aliases",
+ "account_alias_table_head": "Alias",
+ "list_aliases_error": "Error fetching aliases: {error}",
+ "hide_list_aliases_error_action": "Close",
+ "remove_alias": "Remove this alias",
+ "new_alias_target": "Add a new alias (e.g. {example})",
+ "added_alias": "Alias is added.",
+ "add_alias_error": "Error adding alias: {error}",
+ "move_account": "Move account",
+ "move_account_notes": "If you want to move the account somewhere else, you must go to your target account and add an alias pointing here.",
+ "move_account_target": "Target account (e.g. {example})",
+ "moved_account": "Account is moved.",
+ "move_account_error": "Error moving account: {error}",
"discoverable": "Allow discovery of this account in search results and other services",
"domain_mutes": "Domains",
"avatar_size_instruction": "The recommended minimum size for avatar images is 150x150 pixels.",
@@ -336,8 +440,9 @@
"emoji_reactions_on_timeline": "Show emoji reactions on timeline",
"export_theme": "Save preset",
"filtering": "Filtering",
+ "wordfilter": "Wordfilter",
"filtering_explanation": "All statuses containing these words will be muted, one per line",
- "word_filter": "Word filter",
+ "word_filter_and_more": "Word filter and more...",
"follow_export": "Follow export",
"follow_export_button": "Export your follows to a csv file",
"follow_import": "Follow import",
@@ -350,18 +455,23 @@
"hide_attachments_in_tl": "Hide attachments in timeline",
"hide_media_previews": "Hide media previews",
"hide_muted_posts": "Hide posts of muted users",
+ "mute_bot_posts": "Mute bot posts",
+ "hide_bot_indication": "Hide bot indication in posts",
"hide_all_muted_posts": "Hide muted posts",
- "max_thumbnails": "Maximum amount of thumbnails per post",
+ "max_thumbnails": "Maximum amount of thumbnails per post (empty = no limit)",
"hide_isp": "Hide instance-specific panel",
"hide_shoutbox": "Hide instance shoutbox",
- "right_sidebar": "Show sidebar on the right side",
+ "right_sidebar": "Reverse order of columns",
+ "navbar_column_stretch": "Stretch navbar to columns width",
"always_show_post_button": "Always show floating New Post button",
"hide_wallpaper": "Hide instance wallpaper",
"preload_images": "Preload images",
"use_one_click_nsfw": "Open NSFW attachments with just one click",
"hide_post_stats": "Hide post statistics (e.g. the number of favorites)",
"hide_user_stats": "Hide user statistics (e.g. the number of followers)",
- "hide_filtered_statuses": "Hide filtered statuses",
+ "hide_filtered_statuses": "Hide all filtered posts",
+ "hide_wordfiltered_statuses": "Hide word-filtered statuses",
+ "hide_muted_threads": "Hide muted threads",
"import_blocks_from_a_csv_file": "Import blocks from a csv file",
"import_followers_from_a_csv_file": "Import follows from a csv file",
"import_theme": "Load preset",
@@ -397,11 +507,14 @@
"name": "Label",
"value": "Content"
},
+ "account_privacy": "Privacy",
"use_contain_fit": "Don't crop the attachment in thumbnails",
"name": "Name",
"name_bio": "Name & bio",
"new_email": "New email",
"new_password": "New password",
+ "posts": "Posts",
+ "user_profiles": "User Profiles",
"notification_visibility": "Types of notifications to show",
"notification_visibility_follows": "Follows",
"notification_visibility_likes": "Favorites",
@@ -409,23 +522,25 @@
"notification_visibility_repeats": "Repeats",
"notification_visibility_moves": "User Migrates",
"notification_visibility_emoji_reactions": "Reactions",
+ "notification_visibility_polls": "Ends of polls you voted in",
"no_rich_text_description": "Strip rich text formatting from all posts",
"no_blocks": "No blocks",
"no_mutes": "No mutes",
+ "hide_favorites_description": "Don't show list of my favorites (people still get notified)",
"hide_follows_description": "Don't show who I'm following",
"hide_followers_description": "Don't show who's following me",
"hide_follows_count_description": "Don't show follow count",
"hide_followers_count_description": "Don't show follower count",
"show_admin_badge": "Show \"Admin\" badge in my profile",
"show_moderator_badge": "Show \"Moderator\" badge in my profile",
- "nsfw_clickthrough": "Enable clickthrough attachment and link preview image hiding for NSFW statuses",
+ "nsfw_clickthrough": "Hide sensitive/NSFW media",
"oauth_tokens": "OAuth tokens",
"token": "Token",
"refresh_token": "Refresh token",
"valid_until": "Valid until",
"revoke_token": "Revoke",
"panelRadius": "Panels",
- "pause_on_unfocused": "Pause streaming when tab is not focused",
+ "pause_on_unfocused": "Pause when tab is not focused",
"presets": "Presets",
"profile_background": "Profile background",
"profile_banner": "Profile banner",
@@ -460,13 +575,36 @@
"subject_line_email": "Like email: \"re: subject\"",
"subject_line_mastodon": "Like mastodon: copy as is",
"subject_line_noop": "Do not copy",
+ "conversation_display": "Conversation display style",
+ "conversation_display_tree": "Tree-style",
+ "conversation_display_tree_quick": "Tree view",
+ "disable_sticky_headers": "Don't stick column headers to top of the screen",
+ "show_scrollbars": "Show side column's scrollbars",
+ "third_column_mode": "When there's enough space, show third column containing",
+ "third_column_mode_none": "Don't show third column at all",
+ "third_column_mode_notifications": "Notifications column",
+ "third_column_mode_postform": "Main post form and navigation",
+ "columns": "Columns",
+ "column_sizes": "Column sizes",
+ "column_sizes_sidebar": "Sidebar",
+ "column_sizes_content": "Content",
+ "column_sizes_notifs": "Notifications",
+ "tree_advanced": "Allow more flexible navigation in tree view",
+ "tree_fade_ancestors": "Display ancestors of the current status in faint text",
+ "conversation_display_linear": "Linear-style",
+ "conversation_display_linear_quick": "Linear view",
+ "conversation_other_replies_button": "Show the \"other replies\" button",
+ "conversation_other_replies_button_below": "Below statuses",
+ "conversation_other_replies_button_inside": "Inside statuses",
+ "max_depth_in_thread": "Maximum number of levels in thread to display by default",
"post_status_content_type": "Post status content type",
"sensitive_by_default": "Mark posts as sensitive by default",
- "stop_gifs": "Play-on-hover GIFs",
- "streaming": "Enable automatic streaming of new posts when scrolled to the top",
+ "stop_gifs": "Pause animated images until you hover on them",
+ "streaming": "Automatically show new posts when scrolled to the top",
+ "auto_update": "Show new posts automatically",
"user_mutes": "Users",
"useStreamingApi": "Receive posts and notifications real-time",
- "useStreamingApiWarning": "(Not recommended, experimental, known to skip posts)",
+ "use_websockets": "Use websockets (Realtime updates)",
"text": "Text",
"theme": "Theme",
"theme_help": "Use hex color codes (#rrggbb) to customize your color theme.",
@@ -481,8 +619,24 @@
"true": "yes"
},
"virtual_scrolling": "Optimize timeline rendering",
+ "use_at_icon": "Display {'@'} symbol as an icon instead of text",
+ "mention_link_display": "Display mention links",
+ "mention_link_display_short": "always as short names (e.g. {'@'}foo)",
+ "mention_link_display_full_for_remote": "as full names only for remote users (e.g. {'@'}foo{'@'}example.org)",
+ "mention_link_display_full": "always as full names (e.g. {'@'}foo{'@'}example.org)",
+ "mention_link_use_tooltip": "Show user card when clicking mention links",
+ "mention_link_show_avatar": "Show user avatar beside the link",
+ "mention_link_show_avatar_quick": "Show user avatar next to mentions",
+ "mention_link_fade_domain": "Fade domains (e.g. {'@'}example.org in {'@'}foo{'@'}example.org)",
+ "mention_link_bolden_you": "Highlight mention of you when you are mentioned",
+ "user_popover_avatar_action": "Popover avatar click action",
+ "user_popover_avatar_action_zoom": "Zoom the avatar",
+ "user_popover_avatar_action_close": "Close the popover",
+ "user_popover_avatar_action_open": "Open profile",
+ "user_popover_avatar_overlay": "Show user popover over user avatar",
"fun": "Fun",
"greentext": "Meme arrows",
+ "show_yous": "Show (You)s",
"notifications": "Notifications",
"notification_setting_filters": "Filters",
"notification_setting_block_from_strangers": "Block notifications from users who you do not follow",
@@ -645,38 +799,26 @@
}
},
"time": {
- "day": "{0} day",
- "days": "{0} days",
- "day_short": "{0}d",
- "days_short": "{0}d",
- "hour": "{0} hour",
- "hours": "{0} hours",
- "hour_short": "{0}h",
- "hours_short": "{0}h",
+ "unit": {
+ "days": "{0} day | {0} days",
+ "days_short": "{0}d",
+ "hours": "{0} hour | {0} hours",
+ "hours_short": "{0}h",
+ "minutes": "{0} minute | {0} minutes",
+ "minutes_short": "{0}min",
+ "months": "{0} month | {0} months",
+ "months_short": "{0}mo",
+ "seconds": "{0} second | {0} seconds",
+ "seconds_short": "{0}s",
+ "weeks": "{0} week | {0} weeks",
+ "weeks_short": "{0}w",
+ "years": "{0} year | {0} years",
+ "years_short": "{0}y"
+ },
"in_future": "in {0}",
"in_past": "{0} ago",
- "minute": "{0} minute",
- "minutes": "{0} minutes",
- "minute_short": "{0}min",
- "minutes_short": "{0}min",
- "month": "{0} month",
- "months": "{0} months",
- "month_short": "{0}mo",
- "months_short": "{0}mo",
"now": "just now",
- "now_short": "now",
- "second": "{0} second",
- "seconds": "{0} seconds",
- "second_short": "{0}s",
- "seconds_short": "{0}s",
- "week": "{0} week",
- "weeks": "{0} weeks",
- "week_short": "{0}w",
- "weeks_short": "{0}w",
- "year": "{0} year",
- "years": "{0} years",
- "year_short": "{0}y",
- "years_short": "{0}y"
+ "now_short": "now"
},
"timeline": {
"collapse": "Collapse",
@@ -691,12 +833,16 @@
"no_more_statuses": "No more statuses",
"no_statuses": "No statuses",
"socket_reconnected": "Realtime connection established",
- "socket_broke": "Realtime connection lost: CloseEvent code {0}"
+ "socket_broke": "Realtime connection lost: CloseEvent code {0}",
+ "quick_view_settings": "Quick view settings",
+ "quick_filter_settings": "Quick filter settings"
},
"status": {
"favorites": "Favorites",
"repeats": "Repeats",
"delete": "Delete status",
+ "edit": "Edit status",
+ "edited_at": "(last edited {time})",
"pin": "Pin on profile",
"unpin": "Unpin from profile",
"pinned": "Pinned",
@@ -706,6 +852,7 @@
"reply_to": "Reply to",
"mentions": "Mentions",
"replies_list": "Replies:",
+ "replies_list_with_others": "Replies (+{numReplies} other): | Replies (+{numReplies} others):",
"mute_conversation": "Mute conversation",
"unmute_conversation": "Unmute conversation",
"status_unavailable": "Status unavailable",
@@ -721,12 +868,36 @@
"nsfw": "NSFW",
"expand": "Expand",
"you": "(You)",
- "plus_more": "+{number} more"
+ "plus_more": "+{number} more",
+ "many_attachments": "Post has {number} attachment(s)",
+ "collapse_attachments": "Collapse attachments",
+ "show_all_attachments": "Show all attachments",
+ "show_attachment_in_modal": "Show in media modal",
+ "show_attachment_description": "Preview description (open attachment for full description)",
+ "hide_attachment": "Hide attachment",
+ "remove_attachment": "Remove attachment",
+ "attachment_stop_flash": "Stop Flash player",
+ "move_up": "Shift attachment left",
+ "move_down": "Shift attachment right",
+ "open_gallery": "Open gallery",
+ "thread_hide": "Hide this thread",
+ "thread_show": "Show this thread",
+ "thread_show_full": "Show everything under this thread ({numStatus} status in total, max depth {depth}) | Show everything under this thread ({numStatus} statuses in total, max depth {depth})",
+ "thread_show_full_with_icon": "{icon} {text}",
+ "thread_follow": "See the remaining part of this thread ({numStatus} status in total) | See the remaining part of this thread ({numStatus} statuses in total)",
+ "thread_follow_with_icon": "{icon} {text}",
+ "ancestor_follow": "See {numReplies} other reply under this status | See {numReplies} other replies under this status",
+ "ancestor_follow_with_icon": "{icon} {text}",
+ "show_all_conversation_with_icon": "{icon} {text}",
+ "show_all_conversation": "Show full conversation ({numStatus} other status) | Show full conversation ({numStatus} other statuses)",
+ "show_only_conversation_under_this": "Only show replies to this status",
+ "status_history": "Status history"
},
"user_card": {
"approve": "Approve",
"block": "Block",
"blocked": "Blocked!",
+ "deactivated": "Deactivated",
"deny": "Deny",
"edit_profile": "Edit profile",
"favorites": "Favorites",
@@ -748,6 +919,7 @@
"muted": "Muted",
"per_day": "per day",
"remote_follow": "Remote follow",
+ "remove_follower": "Remove follower",
"report": "Report",
"statuses": "Statuses",
"subscribe": "Subscribe",
@@ -778,14 +950,19 @@
"disable_any_subscription": "Disallow following user at all",
"quarantine": "Disallow user posts from federating",
"delete_user": "Delete user",
- "delete_user_confirmation": "Are you absolutely sure? This action cannot be undone."
+ "delete_user_data_and_deactivate_confirmation": "This will permanently delete the data from this account and deactivate it. Are you absolutely sure?"
},
"highlight": {
"disabled": "No highlight",
"solid": "Solid bg",
"striped": "Striped bg",
"side": "Side stripe"
- }
+ },
+ "note": "Note",
+ "note_blank": "(None)",
+ "edit_note": "Edit note",
+ "edit_note_apply": "Apply",
+ "edit_note_cancel": "Cancel"
},
"user_profile": {
"timeline_title": "User timeline",
@@ -814,7 +991,9 @@
"user_settings": "User Settings",
"accept_follow_request": "Accept follow request",
"reject_follow_request": "Reject follow request",
- "bookmark": "Bookmark"
+ "bookmark": "Bookmark",
+ "toggle_expand": "Expand or collapse notification to show post in full",
+ "toggle_mute": "Expand or collapse notification to reveal muted content"
},
"upload": {
"error": {
@@ -836,7 +1015,9 @@
"hashtags": "Hashtags",
"person_talking": "{count} person talking",
"people_talking": "{count} people talking",
- "no_results": "No results"
+ "no_results": "No results",
+ "no_more_results": "No more results",
+ "load_more": "Load more results"
},
"password_reset": {
"forgot_password": "Forgot password?",
@@ -863,6 +1044,27 @@
"error_sending_message": "Something went wrong when sending the message.",
"empty_chat_list_placeholder": "You don't have any chats yet. Start a new chat!"
},
+ "lists": {
+ "lists": "Lists",
+ "new": "New List",
+ "title": "List title",
+ "search": "Search users",
+ "create": "Create",
+ "save": "Save changes",
+ "delete": "Delete list",
+ "following_only": "Limit to Following",
+ "manage_lists": "Manage lists",
+ "manage_members": "Manage list members",
+ "add_members": "Search for more users",
+ "remove_from_list": "Remove from list",
+ "add_to_list": "Add to list",
+ "is_in_list": "Already in list",
+ "editing_list": "Editing list {listTitle}",
+ "creating_list": "Creating new list",
+ "update_title": "Save Title",
+ "really_delete": "Really delete list?",
+ "error": "Error manipulating lists: {0}"
+ },
"file_type": {
"audio": "Audio",
"video": "Video",
@@ -871,5 +1073,17 @@
},
"display_date": {
"today": "Today"
+ },
+ "update": {
+ "big_update_title": "Please bear with us",
+ "big_update_content": "We haven't had a release in a while, so things might look and feel different than what you're used to.",
+ "update_bugs": "Please report any issues and bugs on {pleromaGitlab}, as we have changed a lot, and although we test thoroughly and use development versions ourselves, we may have missed some things. We welcome your feedback and suggestions on issues you might encounter, or how to improve Pleroma and Pleroma-FE.",
+ "update_bugs_gitlab": "Pleroma GitLab",
+ "update_changelog": "For more details on what's changed, see {theFullChangelog}.",
+ "update_changelog_here": "the full changelog",
+ "art_by": "Art by {linkToArtist}"
+ },
+ "unicode_domain_indicator": {
+ "tooltip": "This domain contains non-ascii characters."
}
}
diff --git a/src/i18n/eo.json b/src/i18n/eo.json
index 169248bc..5a2c8afb 100644
--- a/src/i18n/eo.json
+++ b/src/i18n/eo.json
@@ -11,7 +11,8 @@
"title": "Funkcioj",
"who_to_follow": "Kiun aboni",
"pleroma_chat_messages": "Babilejo de Pleroma",
- "upload_limit": "Limo de alŝutoj"
+ "upload_limit": "Limo de alŝutoj",
+ "shout": "Kriujo"
},
"finder": {
"error_fetching_user": "Eraris alporto de uzanto",
@@ -42,7 +43,21 @@
},
"flash_content": "Klaku por montri enhavon de Flash per Ruffle. (Eksperimente, eble ne funkcios.)",
"flash_security": "Sciu, ke tio povas esti danĝera, ĉar la enhavo de Flash ja estas arbitra programo.",
- "flash_fail": "Malsukcesis enlegi enhavon de Flash; vidu detalojn en konzolo."
+ "flash_fail": "Malsukcesis enlegi enhavon de Flash; vidu detalojn en konzolo.",
+ "scope_in_timeline": {
+ "direct": "Persona",
+ "private": "Nur abonantoj",
+ "public": "Publika",
+ "unlisted": "Nelistigita"
+ },
+ "generic_error_message": "Eraris: {0}",
+ "never_show_again": "Neniam remontri",
+ "undo": "Malfari",
+ "yes": "Jes",
+ "no": "Ne",
+ "unpin": "Malfiksi eron",
+ "pin": "Fiksi eron",
+ "scroll_to_top": "Rulumi supren"
},
"image_cropper": {
"crop_picture": "Tondi bildon",
@@ -70,7 +85,9 @@
},
"media_modal": {
"previous": "AntaÅ­a",
- "next": "Sekva"
+ "next": "Sekva",
+ "counter": "{current} / {total}",
+ "hide": "Fermi vidilon de vidaÅ­daÄĩoj"
},
"nav": {
"about": "Pri",
@@ -79,9 +96,9 @@
"friend_requests": "Petoj pri abono",
"mentions": "Mencioj",
"dms": "Rektaj mesaĝoj",
- "public_tl": "Publika historio",
+ "public_tl": "Loka historio",
"timeline": "Historio",
- "twkn": "Konata reto",
+ "twkn": "Federa historio",
"user_search": "Serĉi uzantojn",
"who_to_follow": "Kiun aboni",
"preferences": "Agordoj",
@@ -91,7 +108,11 @@
"administration": "Administrado",
"bookmarks": "Legosignoj",
"timelines": "Historioj",
- "home_timeline": "Hejma historio"
+ "home_timeline": "Hejma historio",
+ "edit_pinned": "Redakti fiksitajn erojn",
+ "lists": "Listoj",
+ "edit_nav_mobile": "Adapti navigan breton",
+ "edit_finish": "Fini redakton"
},
"notifications": {
"broken_favorite": "Nekonata stato, serĉante ĝinâ€Ļ",
@@ -105,7 +126,9 @@
"reacted_with": "reagis per {0}",
"migrated_to": "migris al",
"follow_request": "volas vin aboni",
- "error": "Eraris akirado de sciigoj: {0}"
+ "error": "Eraris akirado de sciigoj: {0}",
+ "submitted_report": "sendis raporton",
+ "poll_ended": "enketo finiĝis"
},
"post_status": {
"new_status": "Afiŝi novan staton",
@@ -129,7 +152,7 @@
"unlisted": "Nelistigita – ne afiŝi al publikaj historioj"
},
"scope_notice": {
- "unlisted": "Ĉi tiu afiŝo ne estos videbla en la Publika historio kaj La tuta konata reto",
+ "unlisted": "Ĉi tiu afiŝo ne estos videbla en la Loka historio kaj la Federa historio",
"private": "Ĉi tiu afiŝo estos videbla nur al viaj abonantoj",
"public": "Ĉi tiu afiŝo estos videbla al ĉiuj"
},
@@ -140,7 +163,10 @@
"direct_warning_to_first_only": "Ĉi tiu afiŝo estas nur videbla al uzantoj menciitaj je la komenco de la mesaĝo.",
"direct_warning_to_all": "Ĉi tiu afiŝo estos videbla al ĉiuj menciitaj uzantoj.",
"media_description": "Priskribo de vidaÅ­daÄĩo",
- "post": "Afiŝo"
+ "post": "Afiŝo",
+ "edit_remote_warning": "Aliaj foraj nodoj eble ne subtenas redaktadon, kaj ne povos ricevi pli novan version de via afiŝo.",
+ "edit_unsupported_warning": "Pleroma ne subtenas redaktadon de mencioj aÅ­ enketoj.",
+ "edit_status": "Stato de redakto"
},
"registration": {
"bio": "Priskribo",
@@ -164,7 +190,10 @@
},
"reason_placeholder": "Ĉi-node oni aprobas registriĝojn permane.\nSciigu la administrantojn kial vi volas registriĝi.",
"reason": "Kialo registriĝi",
- "register": "Registriĝi"
+ "register": "Registriĝi",
+ "bio_optional": "Prio (malnepra)",
+ "email_optional": "Retpoŝtadreso (malnepra)",
+ "email_language": "En kiu lingvo vi volus ricevi retleterojn de la servilo?"
},
"settings": {
"app_name": "Nomo de aplikaÄĩo",
@@ -553,7 +582,87 @@
},
"right_sidebar": "Montri flankan breton dekstre",
"save": "Konservi ŝanĝojn",
- "hide_shoutbox": "Kaŝi kriujon de nodo"
+ "hide_shoutbox": "Kaŝi kriujon de nodo",
+ "always_show_post_button": "Ĉiam montri ŝvebantan butonon por nova afiŝo",
+ "mentions_new_style": "Pli mojosaj menciligiloj",
+ "mentions_new_place": "Meti menciojn sur apartan linion",
+ "lists_navigation": "Montri listojn en navigiloj",
+ "account_backup": "Savkopio de konto",
+ "account_backup_description": "Ĉi tio povigas vin elŝuti arÄĨivon de viaj afiŝoj kaj ĉiuj informoj pri via konto, sed ili ne jam povas enportiĝi en konton de Pleroma.",
+ "list_aliases_error": "Eraris akirado de kromnomoj: {error}",
+ "move_account_notes": "Se vi volas movi la konton aliloken, vi devas iri al via celata konto, kaj aldoni kromnomon ligitan al tie ĉi.",
+ "navbar_column_stretch": "Etendi navigan breton laŭ larĝeco de kolumnoj",
+ "posts": "Afiŝoj",
+ "notification_visibility_polls": "Finoj de enketoj kun via voĉo",
+ "conversation_display": "Aspekto de interparoloj",
+ "disable_sticky_headers": "Ne alglui kapojn de kolumnoj al supro de la ekrano",
+ "conversation_display_linear_quick": "Linia vido",
+ "use_websockets": "Uzi teÄĨnikaron ÂĢwebsocketsÂģ (tuja ĝisdatigo)",
+ "mention_link_display_full_for_remote": "plene nur je uzantoj foraj (ekz. {'@'}zozo{'@'}ekzemplo.org)",
+ "expert_mode": "Montri altnivelajn",
+ "setting_server_side": "Ĉi tiu agordo estas ligita al via profilo, kaj efektiviĝon en ĉiuj viaj salutoj kaj klientoj",
+ "post_look_feel": "Aspekto de afiŝoj",
+ "mention_links": "Menciaj ligiloj",
+ "email_language": "Lingvo de leteroj ricevotaj de la servilo",
+ "account_backup_table_head": "Savkopio",
+ "download_backup": "Elŝuti",
+ "backup_not_ready": "Ĉi tiu savkopio ne jam pretas.",
+ "remove_backup": "Forigi",
+ "list_backups_error": "Eraris akirado de listo de savkopioj: {error}",
+ "add_backup": "Fari novan savkopion",
+ "added_backup": "Aldonis novan savkopion.",
+ "add_backup_error": "Eraris aldono de nova savkopio: {error}",
+ "account_alias": "Kromnomoj de konto",
+ "account_alias_table_head": "Kromnomo",
+ "hide_list_aliases_error_action": "Fermi",
+ "remove_alias": "Forigi ĉi tiun kromnomon",
+ "new_alias_target": "Aldoni novan kromnomon (ekz. {example})",
+ "added_alias": "Kromnomo estas aldonita.",
+ "add_alias_error": "Eraris aldono de kromnomo: {error}",
+ "move_account": "Movi konton",
+ "move_account_target": "Celata konto (ekz. {example})",
+ "moved_account": "Konto moviĝis.",
+ "move_account_error": "Eraris movado de konto: {error}",
+ "wordfilter": "Vortofiltrado",
+ "word_filter_and_more": "Vortofiltrado kaj pliâ€Ļ",
+ "mute_bot_posts": "Silentigi afiŝojn de robotoj",
+ "hide_bot_indication": "Kaŝi markon de roboteco en afiŝoj",
+ "hide_wordfiltered_statuses": "Kaŝi vorte filtritajn statojn",
+ "hide_muted_threads": "Kaŝi silentigitajn fadenojn",
+ "account_privacy": "Privateco",
+ "user_profiles": "Profiloj de uzantoj",
+ "hide_favorites_description": "Ne montri liston de miaj ŝatatoj (oni tamen sciiĝas)",
+ "conversation_display_tree": "Arba stilo",
+ "conversation_display_tree_quick": "Arba vido",
+ "show_scrollbars": "Montri rulumajn bretojn de flankaj kolumnoj",
+ "third_column_mode_none": "Neniam montri trian kolumnon",
+ "third_column_mode_notifications": "Kolumno de sciigoj",
+ "columns": "Kolumnoj",
+ "column_sizes": "Grandeco de kolumnoj",
+ "column_sizes_sidebar": "Flanka breto",
+ "column_sizes_content": "Enhavo",
+ "column_sizes_notifs": "Sciigoj",
+ "tree_advanced": "Permesi pli flekseblan navigadon en arba vido",
+ "conversation_display_linear": "Linia stilo",
+ "conversation_other_replies_button": "Montri la butonon ÂĢaliaj respondojÂģ",
+ "conversation_other_replies_button_below": "Sub statoj",
+ "conversation_other_replies_button_inside": "En statoj",
+ "max_depth_in_thread": "Maksimuma nombro de niveloj implicite montrataj en fadeno",
+ "auto_update": "Montri novajn afiŝojn memage",
+ "use_at_icon": "Montri simbolon {'@'} kiel bildon anstataÅ­ teksto",
+ "mention_link_display": "Montri menciajn ligilojn",
+ "mention_link_display_short": "ĉiam mallonge (ekz. {'@'}zozo)",
+ "mention_link_display_full": "ĉiam plene (ekz. {'@'}zozo{'@'}ekzemplo.org)",
+ "mention_link_show_avatar": "Montri profilbildon de uzanto apud la ligilo",
+ "mention_link_show_avatar_quick": "Montri profilbildon de uzanto apud mencioj",
+ "mention_link_fade_domain": "Malvigligi retnomojn (ekz. {'@'}ekzemplo.org en {'@'}zozo{'@'}ekzemplo.org)",
+ "mention_link_bolden_you": "Emfazi vian mencion, se vi estas menciita",
+ "mention_link_use_tooltip": "Montri karton de uzanto per klako al mencia ligilo",
+ "user_popover_avatar_action_close": "Fermi la ŝprucaÄĩon",
+ "user_popover_avatar_action_open": "Malfermi la profilon",
+ "user_popover_avatar_overlay": "Aperigi ŝprucaÄĩon pri uzanto sur profilbildo",
+ "show_yous": "Montri la markon ÂĢ(Vi)Âģ",
+ "user_popover_avatar_action_zoom": "Zomi la profilbildon"
},
"timeline": {
"collapse": "Maletendi",
@@ -603,7 +712,6 @@
"mention": "Mencio",
"hidden": "Kaŝita",
"admin_menu": {
- "delete_user_confirmation": "Ĉu vi tute certas? Ĉi tiu ago ne estas malfarebla.",
"delete_user": "Forigi uzanton",
"quarantine": "Malpermesi federadon de afiŝoj de uzanto",
"disable_any_subscription": "Malpermesi ĉian abonadon al uzanto",
@@ -619,7 +727,8 @@
"grant_moderator": "Nomumi reguligiston",
"revoke_admin": "Malnomumi administranton",
"grant_admin": "Nomumi administranton",
- "moderation": "Reguligado"
+ "moderation": "Reguligado",
+ "delete_user_data_and_deactivate_confirmation": "Tio ĉi por ĉiam forigos datumojn de tiu ĉi konto, kaj malaktivigos ĝin. Ĉu vi plene certas?"
},
"show_repeats": "Montri ripetojn",
"hide_repeats": "Kaŝi ripetojn",
@@ -631,7 +740,11 @@
"striped": "Stria fono",
"solid": "Unueca fono",
"disabled": "Senemfaze"
- }
+ },
+ "edit_profile": "Redakti profilon",
+ "deactivated": "Malaktiva",
+ "follow_cancel": "Nuligi peton",
+ "remove_follower": "Forigi abonanton"
},
"user_profile": {
"timeline_title": "Historio de uzanto",
@@ -677,7 +790,19 @@
"load_all": "Enlegante ĉiujn {emojiAmount} bildosignojn",
"load_all_hint": "Enlegis la {saneAmount} unuajn bildosignojn; enlego de ĉiuj povus kaŭzi problemojn pri efikeco.",
"unicode": "Unikoda bildosigno",
- "custom": "Propra bildosigno"
+ "custom": "Propra bildosigno",
+ "unicode_groups": {
+ "activities": "Agado",
+ "animals-and-nature": "Bestoj kaj naturo",
+ "flags": "Flagoj",
+ "food-and-drink": "ManĝaÄĩoj kaj trinkaÄĩoj",
+ "objects": "AÄĩoj",
+ "people-and-body": "Homoj kaj korpo",
+ "smileys-and-emotion": "Mienbildoj kaj sentoj",
+ "symbols": "Simboloj",
+ "travel-and-places": "Vojaĝoj kaj lokoj"
+ },
+ "regional_indicator": "Regiona marko {letter}"
},
"polls": {
"not_enough_options": "Tro malmultaj unikaj elektebloj en la enketo",
@@ -718,7 +843,7 @@
"media_nsfw": "Devige marki vidaÅ­daÄĩojn konsternaj",
"media_removal_desc": "Ĉi tiu nodo forigas vidaÅ­daÄĩojn de afiŝoj el la jenaj nodoj:",
"media_removal": "Forigo de vidaÅ­daÄĩoj",
- "ftl_removal": "Forigo el la historio de ÂĢKonata retoÂģ",
+ "ftl_removal": "Forigo el la ÂĢFedera historioÂģ",
"quarantine_desc": "Ĉi tiu nodo sendos nur publikajn aīŦÅojn al la jenaj nodoj:",
"quarantine": "Kvaranteno",
"reject_desc": "Ĉi tiu nodo ne akceptos mesaĝojn de la jenaj nodoj:",
@@ -726,14 +851,16 @@
"accept_desc": "Ĉi tiu nodo nur akceptas mesaĝojn de la jenaj nodoj:",
"accept": "Akcepti",
"simple_policies": "Specialaj politikoj de la nodo",
- "ftl_removal_desc": "Ĉi tiu nodo forigas la jenajn nodojn el la historio de ÂĢKonata retoÂģ:"
+ "ftl_removal_desc": "Ĉi tiu nodo forigas la jenajn nodojn el la ÂĢFedera historioÂģ:",
+ "instance": "Nodo",
+ "reason": "Kialo"
},
"mrf_policies": "Ŝaltis politikon de Mesaĝa ŝanĝilaro (MRF)",
"keyword": {
"is_replaced_by": "→",
"replace": "AnstataÅ­igi",
"reject": "Rifuzi",
- "ftl_removal": "Forigo el la historio de ÂĢLa tuta konata retoÂģ",
+ "ftl_removal": "Forigo el la historio de la ÂĢFedera historioÂģ",
"keyword_policies": "Politiko pri ĉefvortoj"
},
"federation": "Federado",
@@ -752,7 +879,9 @@
"load_older": "Enlegi pli malnovajn interagojn",
"moves": "Migrado de uzantoj",
"follows": "Novaj abonoj",
- "favs_repeats": "Ripetoj kaj ŝatoj"
+ "favs_repeats": "Ripetoj kaj ŝatoj",
+ "emoji_reactions": "Bildosignaj reagoj",
+ "reports": "Raportoj"
},
"errors": {
"storage_unavailable": "Pleroma ne povis aliri deponejon de la foliumilo. Via saluto kaj viaj lokaj agordoj ne estos konservitaj, kaj vi eble renkontos neatenditajn problemojn. Provu permesi kuketojn."
@@ -782,7 +911,35 @@
"status_deleted": "Ĉi tiu afiŝo foriĝis",
"nsfw": "Konsterna",
"expand": "Etendi",
- "external_source": "Ekstera fonto"
+ "external_source": "Ekstera fonto",
+ "mentions": "Mencioj",
+ "you": "(Vi)",
+ "plus_more": "+{number} pli",
+ "show_all_attachments": "Montri ĉiujn kunsendaÄĩojn",
+ "collapse_attachments": "Kaŝi iujn kunsendaÄĩojn",
+ "many_attachments": "Afiŝo havas {number} kunsendaÄĩo(j)n",
+ "show_attachment_in_modal": "Montri en vidilo de vidaÅ­daÄĩoj",
+ "edit": "Redakti afiŝon",
+ "replies_list_with_others": "Respondoj (+{numReplies} alia): | Respondoj (+{numReplies} aliaj):",
+ "thread_show": "Malkaŝi ĉi tiun fadenon",
+ "thread_show_full": "Montri ĉion en ĉi tiu fadeno ({numStatus} afiŝon sume, maksimume en profundeco {depth}) | Montri ĉion en ĉi tiu fadeno ({numStatus} afiŝojn sume, maksimume en profundeco {depth})",
+ "show_all_conversation": "Montri plenan interparolon ({numStatus} alian afiŝon) | Montri plenan interparolon ({numStatus} aliajn afiŝojn)",
+ "edited_at": "(lastafoje redaktita je {time})",
+ "remove_attachment": "Forigi kunsendaÄĩon",
+ "show_attachment_description": "AntaÅ­vidi priskribon (malfermu kunsendaÄĩon por vidi plenan priskribon)",
+ "hide_attachment": "Kaŝi kunsendaÄĩon",
+ "attachment_stop_flash": "Ĉesigi ludilon de [Flash]",
+ "move_up": "Ŝovi kunsendaÄĩon antaÅ­en",
+ "move_down": "Ŝovi kunsendaÄĩon posten",
+ "thread_hide": "Kaŝi ĉi tiun fadenon",
+ "thread_show_full_with_icon": "{icon} {text}",
+ "thread_follow": "Montri ceteron de ĉi tiu fadeno ({numStatus} afiŝon sume) | Montri ceteron de ĉi tiu fadeno ({numStatus} afiŝojn sume)",
+ "thread_follow_with_icon": "{icon} {text}",
+ "ancestor_follow": "Vidi {numReplies} alian respondon sub ĉi tiu afiŝo | Vidi {numReplies} aliajn respondojn sub ĉi tiu afiŝo",
+ "ancestor_follow_with_icon": "{icon} {text}",
+ "show_all_conversation_with_icon": "{icon} {text}",
+ "show_only_conversation_under_this": "Montri nur respondojn al ĉi tiu afiŝo",
+ "status_history": "Historio de afiŝo"
},
"time": {
"years_short": "{0}j",
@@ -816,7 +973,23 @@
"days_short": "{0}t",
"day_short": "{0}t",
"days": "{0} tagoj",
- "day": "{0} tago"
+ "day": "{0} tago",
+ "unit": {
+ "days": "{0} tago | {0} tagoj",
+ "minutes": "{0} minuto | {0} minutoj",
+ "days_short": "{0}t",
+ "hours": "{0} horo | {0} horoj",
+ "hours_short": "{0}h",
+ "minutes_short": "{0}min",
+ "months": "{0} monato | {0} monatoj",
+ "months_short": "{0}mo",
+ "seconds": "{0} sekundo | {0} sekundoj",
+ "seconds_short": "{0}sek",
+ "weeks": "{0} semajno | {0} semajnoj",
+ "weeks_short": "{0}sem",
+ "years": "{0} jaro | {0} jaroj",
+ "years_short": "{0}j"
+ }
},
"search": {
"people": "Personoj",
@@ -870,5 +1043,68 @@
},
"shoutbox": {
"title": "Kriujo"
+ },
+ "report": {
+ "reporter": "Raportinto:",
+ "reported_user": "Raportito:",
+ "reported_statuses": "Raportitaj statoj:",
+ "notes": "Notoj:",
+ "state": "Stato:",
+ "state_open": "Malfermita",
+ "state_closed": "Fermita",
+ "state_resolved": "Solvita"
+ },
+ "lists": {
+ "editing_list": "Redaktado de listo {listTitle}",
+ "lists": "Listoj",
+ "new": "Nova listo",
+ "title": "Nomo de listo",
+ "search": "Serĉi uzantojn",
+ "create": "Krei",
+ "save": "Konservi ŝanĝojn",
+ "delete": "Forigi liston",
+ "following_only": "Limigi al abonatoj",
+ "manage_lists": "Mastrumi listojn",
+ "manage_members": "Mastrumi listanojn",
+ "add_members": "Serĉi pliajn uzantojn",
+ "remove_from_list": "Forigi de listo",
+ "add_to_list": "Aldoni al listo",
+ "is_in_list": "Jam en listo",
+ "creating_list": "Kreado de nova listo",
+ "update_title": "Konservi nomon",
+ "really_delete": "Ĉu vi certe volas forigi la liston?",
+ "error": "Eraris umado je listoj: {0}"
+ },
+ "update": {
+ "big_update_content": "Ni longe ne eldonis novan version, kaj tial aferoj eble aspektos iom malsame, ol antaÅ­e.",
+ "update_bugs": "Bonvolu raporti problemojn kaj erarojn ĉe {pleromaGitlab}, ĉar ni ŝanĝis multon, kaj kvankam ni zorge testas kaj mem uzas la prilaboratajn versiojn, ni tamen povas preteratenti ion. Ni bonvenigas viajn rimarkojn kaj proponojn pri renkontitaj eraroj aŭ proponoj plibonigi Pleromon.",
+ "big_update_title": "Bonvolu pacienci",
+ "update_bugs_gitlab": "GitLab de Pleroma",
+ "update_changelog": "Por legi detalojn pri ŝanĝoj, vidu {theFullChangelog}.",
+ "update_changelog_here": "la plenan ŝanĝaron",
+ "art_by": "Arto de {linkToArtist}"
+ },
+ "unicode_domain_indicator": {
+ "tooltip": "Ĉi tiu retnomo enhavas signojn ekster ASCII."
+ },
+ "announcements": {
+ "all_day_prompt": "Ĉi tio estas tuttaga okazo",
+ "page_header": "Anoncoj",
+ "title": "Anonco",
+ "mark_as_read_action": "Marki legita",
+ "post_placeholder": "Entajpu vian anoncon tie ĉiâ€Ļ",
+ "post_action": "Afiŝi",
+ "post_error": "Eraro: {error}",
+ "close_error": "Fermi",
+ "delete_action": "Forigi",
+ "start_time_prompt": "Komenca tempo: ",
+ "end_time_prompt": "Fina tempo: ",
+ "published_time_display": "Publikigita je {time}",
+ "start_time_display": "Komenciĝas je {time}",
+ "end_time_display": "Finiĝas je {time}",
+ "edit_action": "Redakti",
+ "submit_edit_action": "Afiŝi",
+ "cancel_edit_action": "Nuligi",
+ "inactive_message": "Ĉi tiu anonco estas neaktiva"
}
}
diff --git a/src/i18n/es.json b/src/i18n/es.json
index 5f4db163..9887f007 100644
--- a/src/i18n/es.json
+++ b/src/i18n/es.json
@@ -599,7 +599,10 @@
"backup_restore": "Copia de seguridad de la configuraciÃŗn"
},
"hide_shoutbox": "Ocultar cuadro de diÃĄlogo de la instancia",
- "right_sidebar": "Mostrar la barra lateral a la derecha"
+ "right_sidebar": "Mostrar la barra lateral a la derecha",
+ "always_show_post_button": "Muestra siempre el botÃŗn flotante de Nueva PlubicaciÃŗn",
+ "mentions_new_style": "Enlaces de menciones mÃĄs elegantes",
+ "mentions_new_place": "Situa las menciones en una línea separada"
},
"time": {
"day": "{0} día",
@@ -676,7 +679,10 @@
"status_deleted": "Esta publicaciÃŗn ha sido eliminada",
"nsfw": "NSFW (No apropiado para el trabajo)",
"expand": "Expandir",
- "external_source": "Fuente externa"
+ "external_source": "Fuente externa",
+ "mentions": "Menciones",
+ "you": "(TÃē)",
+ "plus_more": "+{number} mÃĄs"
},
"user_card": {
"approve": "Aprobar",
@@ -725,8 +731,7 @@
"disable_remote_subscription": "No permitir que usuarios de instancias remotas te siga",
"disable_any_subscription": "No permitir que ningÃēn usuario te siga",
"quarantine": "No permitir publicaciones de usuarios de instancias remotas",
- "delete_user": "Eliminar usuario",
- "delete_user_confirmation": "ÂŋEstÃĄs completamente seguro? Esta acciÃŗn no se puede deshacer."
+ "delete_user": "Eliminar usuario"
},
"show_repeats": "Mostrar repetidos",
"hide_repeats": "Ocultar repetidos",
diff --git a/src/i18n/eu.json b/src/i18n/eu.json
index 539ee1bd..4e6ea550 100644
--- a/src/i18n/eu.json
+++ b/src/i18n/eu.json
@@ -609,8 +609,7 @@
"disable_remote_subscription": "Ez utzi istantzia kanpoko erabiltzaileak zuri jarraitzea",
"disable_any_subscription": "Ez utzi beste erabiltzaileak zuri jarraitzea",
"quarantine": "Ez onartu mezuak beste instantzietatik",
- "delete_user": "Erabiltzailea ezabatu",
- "delete_user_confirmation": "Erabat ziur zaude? Ekintza hau ezin da desegin."
+ "delete_user": "Erabiltzailea ezabatu"
}
},
"user_profile": {
diff --git a/src/i18n/fi.json b/src/i18n/fi.json
index 7b5244cb..f8c3b4ae 100644
--- a/src/i18n/fi.json
+++ b/src/i18n/fi.json
@@ -620,8 +620,7 @@
"sandbox": "Pakota viestit vain seuraajille",
"disable_remote_subscription": "Estä seuraaminen ulkopuolisilta sivuilta",
"quarantine": "Estä käyttäjän viestin federoituminen",
- "delete_user": "Poista käyttäjä",
- "delete_user_confirmation": "Oletko aivan varma? Tätä ei voi kumota."
+ "delete_user": "Poista käyttäjä"
},
"favorites": "Tykkäykset",
"mention": "Mainitse",
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index 6d3c75d1..f86d1821 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -15,7 +15,8 @@
"title": "FonctionnalitÊs",
"who_to_follow": "Suggestions de suivis",
"pleroma_chat_messages": "Chat Pleroma",
- "upload_limit": "Limite de tÊlÊversement"
+ "upload_limit": "Limite de tÊlÊversement",
+ "shout": "Shoutbox"
},
"finder": {
"error_fetching_user": "Erreur lors de la recherche du compte",
@@ -44,9 +45,23 @@
"moderator": "Modo'",
"admin": "Admin"
},
- "flash_content": "Clique pour afficher le contenu Flash avec Ruffle (ExpÊrimental, peut ne pas fonctionner).",
+ "flash_content": "Cliquer pour afficher le contenu Flash avec Ruffle (ExpÊrimental, peut ne pas fonctionner).",
"flash_security": "Cela reste potentiellement dangereux, Flash restant du code arbitraire.",
- "flash_fail": "Échec de chargement du contenu Flash, voir la console pour les dÊtails."
+ "flash_fail": "Échec de chargement du contenu Flash, voir la console pour les dÊtails.",
+ "scope_in_timeline": {
+ "direct": "Direct",
+ "public": "Publique",
+ "private": "AbonnÊ⋅e⋅s seulement",
+ "unlisted": "Non-listÊ"
+ },
+ "undo": "DÊfaire",
+ "yes": "Oui",
+ "no": "Non",
+ "unpin": "DÊgrafer l'ÊlÊment",
+ "scroll_to_top": "DÊfiler au dÊbut",
+ "pin": "Agrafer l'ÊlÊment",
+ "generic_error_message": "Une erreur est apparue : {0}",
+ "never_show_again": "Ne plus afficher"
},
"image_cropper": {
"crop_picture": "Rogner l'image",
@@ -79,7 +94,9 @@
},
"media_modal": {
"previous": "PrÊcÊdent",
- "next": "Suivant"
+ "next": "Suivant",
+ "counter": "{current} / {total}",
+ "hide": "Fermer le visualiseur multimÊdia"
},
"nav": {
"about": "À propos",
@@ -100,7 +117,14 @@
"chats": "Chats",
"bookmarks": "Marques-Pages",
"timelines": "Flux",
- "home_timeline": "Flux personnel"
+ "home_timeline": "Flux personnel",
+ "edit_nav_mobile": "Personnaliser la barre de navigation",
+ "mobile_notifications": "Ouvrir les notifications (il y en a de nouvelles)",
+ "lists": "Listes",
+ "edit_pinned": "Éditer les ÊlÊments agrafÊs",
+ "edit_finish": "Édition terminÊe",
+ "mobile_sidebar": "(DÊs)activer le panneau latÊral",
+ "mobile_notifications_close": "Fermer les notifications"
},
"notifications": {
"broken_favorite": "Message inconnu, recherche en coursâ€Ļ",
@@ -114,13 +138,17 @@
"migrated_to": "a migrÊ à",
"reacted_with": "a rÊagi avec {0}",
"follow_request": "veut vous suivre",
- "error": "Erreur de chargement des notifications : {0}"
+ "error": "Erreur de chargement des notifications : {0}",
+ "poll_ended": "Sondage terminÊ",
+ "submitted_report": "Rapport envoyÊ"
},
"interactions": {
"favs_repeats": "Partages et favoris",
"follows": "Nouveaux suivis",
"load_older": "Chargez d'anciennes interactions",
- "moves": "Migrations de comptes"
+ "moves": "Migrations de comptes",
+ "emoji_reactions": "Émoticônes de rÊaction",
+ "reports": "Rapports"
},
"post_status": {
"new_status": "Poster un nouveau statut",
@@ -154,7 +182,10 @@
"preview_empty": "Vide",
"preview": "PrÊvisualisation",
"media_description": "Description de la pièce-jointe",
- "post": "Post"
+ "post": "Post",
+ "edit_status": "Éditer le status",
+ "edit_remote_warning": "Des instances distantes pourraient ne pas supporter l'Êdition et seront incapables de recevoir la nouvelle version de votre post.",
+ "edit_unsupported_warning": "Pleroma ne supporte pas l'Êdition de mentions ni de sondages."
},
"registration": {
"bio": "Biographie",
@@ -178,7 +209,10 @@
},
"reason_placeholder": "Cette instance modère les inscriptions manuellement.\nExpliquer ce qui motive votre inscription à l'administration.",
"reason": "Motivation d'inscription",
- "register": "Enregistrer"
+ "register": "Enregistrer",
+ "email_language": "Dans quelle langue voulez-vous recevoir les emails du server ?",
+ "bio_optional": "Biographie (optionnelle)",
+ "email_optional": "Courriel (optionnel)"
},
"selectable_list": {
"select_all": "Tout selectionner"
@@ -267,8 +301,8 @@
"import_theme": "Charger le thème",
"inputRadius": "Champs de texte",
"checkboxRadius": "Cases à cocher",
- "instance_default": "(default : {value})",
- "instance_default_simple": "(default)",
+ "instance_default": "(dÊfaut : {value})",
+ "instance_default_simple": "(dÊfaut)",
"interface": "Interface",
"interfaceLanguage": "Langue de l'interface",
"invalid_theme_imported": "Le fichier sÊlectionnÊ n'est pas un thème Pleroma pris en charge. Aucun changement n'a ÊtÊ apportÊ à votre thème.",
@@ -570,7 +604,87 @@
"restore_settings": "Restaurer les paramètres depuis un fichier"
},
"hide_shoutbox": "Cacher la shoutbox de l'instance",
- "right_sidebar": "Afficher le paneau latÊral à droite"
+ "right_sidebar": "Afficher le paneau latÊral à droite",
+ "expert_mode": "PrÊfÊrences AvancÊes",
+ "post_look_feel": "Affichage des messages",
+ "mention_links": "Liens des mentions",
+ "email_language": "Langue pour recevoir les emails du server",
+ "account_backup_table_head": "Sauvegarde",
+ "download_backup": "TÊlÊcharger",
+ "backup_not_ready": "La sauvegarde n'est pas encore prÃĒte.",
+ "remove_backup": "Supprimer",
+ "list_backups_error": "Erreur d'obtention de la liste des sauvegardes : {error}",
+ "add_backup": "CrÊer une nouvelle sauvegarde",
+ "added_backup": "Ajouter une nouvelle sauvegarde.",
+ "account_alias": "Alias du compte",
+ "account_alias_table_head": "Alias",
+ "list_aliases_error": "Erreur à l'obtention des alias : {error}",
+ "hide_list_aliases_error_action": "Fermer",
+ "remove_alias": "Supprimer cet alias",
+ "new_alias_target": "Ajouter un nouvel alias (ex. {example})",
+ "added_alias": "L'alias à ÊtÊ ajoutÊ.",
+ "add_alias_error": "Erreur à l'ajout de l'alias : {error}",
+ "move_account_target": "Compte cible (ex. {example})",
+ "moved_account": "Compte dÊplacÊ.",
+ "move_account_error": "Erreur au dÊplacement du compte : {error}",
+ "wordfilter": "Filtrage de mots",
+ "mute_bot_posts": "Masquer les messages des robots",
+ "hide_bot_indication": "Cacher l'indication d'un robot avec les messages",
+ "always_show_post_button": "Toujours montrer le bouton flottant Nouveau Message",
+ "hide_muted_threads": "Cacher les fils masquÊs",
+ "account_privacy": "IntimitÊ",
+ "posts": "Messages",
+ "disable_sticky_headers": "Ne pas coller les en-tÃĒtes des colonnes en haut de l'Êcran",
+ "show_scrollbars": "Montrer les ascenseurs des colonnes",
+ "third_column_mode_none": "Jamais afficher la troisième colonne",
+ "third_column_mode_notifications": "Colonne de notifications",
+ "third_column_mode_postform": "Édition de messages et navigation",
+ "tree_advanced": "Permettre une navigation plus flexible dans l'arborescence",
+ "conversation_display_linear": "Style linÊaire",
+ "conversation_other_replies_button": "Montrer le bouton \"autres rÊponses\"",
+ "conversation_other_replies_button_below": "En-dessous des messages",
+ "conversation_other_replies_button_inside": "Dans les messages",
+ "max_depth_in_thread": "Profondeur maximum à afficher par dÊfaut dans un fil",
+ "mention_link_display": "Afficher les mentions",
+ "mention_link_display_full_for_remote": "complet pour les comptes distants (ex. {'@'}foo{'@'}example.org)",
+ "mention_link_display_full": "toujours complet (ex. {'@'}foo{'@'}example.org)",
+ "mention_link_show_avatar": "Afficher les avatars à cotÊ du lien",
+ "mention_link_fade_domain": "Estomper les domaines (ex. {'@'}example.org en {'@'}foo{'@'}example.org)",
+ "mention_link_bolden_you": "Surligner les mentions qui vous sont destinÊes",
+ "show_yous": "Afficher (Vous)",
+ "setting_server_side": "Cette prÊfÊrence est liÊe au profile et affecte toutes les sessions et clients",
+ "account_backup": "Sauvegarde de compte",
+ "account_backup_description": "Ceci permet de tÊlÊcharger une archive des informations du compte et vos messages, mais ils ne peuvent pas actuellement ÃĒtre importÊ dans un compte Pleroma.",
+ "add_backup_error": "Erreur à l'ajout d'une nouvelle sauvegarde : {error}",
+ "move_account": "DÊplacer le compte",
+ "move_account_notes": "Si vous voulez dÊplacer le compte ailleurs, vous devez aller sur votre compte cible et y crÊer un alias pointant ici.",
+ "hide_wordfiltered_statuses": "Cacher les messages filtrÊ par un mot",
+ "user_profiles": "Profils des utilisateur⋅ice⋅s",
+ "notification_visibility_polls": "Fins de sondage auquel vous avez votÃŠÂˇe",
+ "hide_favorites_description": "Ne pas montrer ma liste de favoris (les personnes sont quand mÃĒme notifiÊs)",
+ "conversation_display": "Style d'affichage des conversations",
+ "conversation_display_tree": "Arborescence",
+ "third_column_mode": "Quand il-y-a assez d'espace, afficher une troisième colonne avec",
+ "tree_fade_ancestors": "Montrer les parents du message courant en texte lÊger",
+ "use_at_icon": "Montrer le symbole {'@'} comme une icône au lieu de textuelle",
+ "mention_link_display_short": "toujours raccourcies (ex. {'@'}foo)",
+ "mention_link_show_tooltip": "Montrer le nom complet pour les comptes distants dans une info-bulle",
+ "lists_navigation": "Afficher les listes dans la navigation",
+ "word_filter_and_more": "Filtrer par mots et plus ...",
+ "columns": "Colonnes",
+ "auto_update": "Afficher automatiquement les nouveaux posts",
+ "mention_link_use_tooltip": "Montrer le profil utilisateur en cliquant sur les liens de mentions",
+ "mention_link_show_avatar_quick": "Afficher l'avatar de l'utilisateur à côtÊ des mentions",
+ "navbar_column_stretch": "Élargir la barre de navigation à la taille des colonnes",
+ "column_sizes": "Taille des colonnes",
+ "column_sizes_sidebar": "Panneau latÊral",
+ "column_sizes_content": "Contenu",
+ "column_sizes_notifs": "Notifications",
+ "conversation_display_linear_quick": "Vue linÊaire",
+ "use_websockets": "Utiliser les websockets (mises à jour en temps rÊel)",
+ "user_popover_avatar_action_zoom": "Zoomer sur l'avatar",
+ "user_popover_avatar_action_open": "Ouvrir le profil",
+ "conversation_display_tree_quick": "Vue arborescente"
},
"timeline": {
"collapse": "Fermer",
@@ -586,7 +700,9 @@
"reload": "Recharger",
"error": "Erreur lors de l'affichage du flux : {0}",
"socket_broke": "Connexion temps-rÊel perdue : CloseEvent code {0}",
- "socket_reconnected": "Connexion temps-rÊel Êtablie"
+ "socket_reconnected": "Connexion temps-rÊel Êtablie",
+ "quick_view_settings": "Afficher les rÊglages rapides",
+ "quick_filter_settings": "Afficher les filtres rapides"
},
"status": {
"favorites": "Favoris",
@@ -613,7 +729,36 @@
"thread_muted": "Fil de discussion masquÊ",
"external_source": "Source externe",
"unbookmark": "Supprimer des favoris",
- "bookmark": "Ajouter aux favoris"
+ "bookmark": "Ajouter aux favoris",
+ "plus_more": "plus +{number}",
+ "many_attachments": "Message avec {number} pièce(s)-jointe(s)",
+ "collapse_attachments": "RÊduire les pièces jointes",
+ "show_attachment_in_modal": "Montrer dans le visionneur de mÊdias",
+ "hide_attachment": "Cacher la pièce jointe",
+ "you": "(Vous)",
+ "attachment_stop_flash": "ArrÃĒter Flash Player",
+ "move_down": "DÊcaler la pièce-jointe à droite",
+ "thread_hide": "Cacher ce fil",
+ "thread_show": "Montrer ce fil",
+ "thread_show_full_with_icon": "{icon} {text}",
+ "thread_follow": "Montrer le reste du fil ({numStatus} message) | Montrer le reste du fil ({numStatus} messages)",
+ "thread_follow_with_icon": "{icon} {text}",
+ "ancestor_follow": "Monter les {numReplies} autres rÊponses après ce message | Monter les {numReplies} autres rÊponses après ce message",
+ "ancestor_follow_with_icon": "{icon} {text}",
+ "show_all_conversation_with_icon": "{icon} {text}",
+ "show_only_conversation_under_this": "Montrer uniquement les rÊponses à ce message",
+ "mentions": "Mentions",
+ "replies_list_with_others": "RÊponses (+{numReplies} autres) : | RÊponses (+{numReplies} autres) :",
+ "show_all_attachments": "Montrer toutes les pièces jointes",
+ "show_attachment_description": "PrÊvisualiser la description (ouvrir la pièce-jointe pour la description complète)",
+ "remove_attachment": "Enlever la pièce jointe",
+ "move_up": "DÊcaler la pièce-jointe à gauche",
+ "open_gallery": "Ouvrir la galerie",
+ "thread_show_full": "Montrer tout le fil ({numStatus} message, {depth} niveaux maximum) | Montrer tout le fil ({numStatus} messages, {depth} niveaux maximum)",
+ "show_all_conversation": "Montrer tout le fil ({numStatus} autre message) | Montrer tout le fil ({numStatus} autre messages)",
+ "edit": "Éditer le status",
+ "edited_at": "(dernière Êdition {time})",
+ "status_history": "Historique du status"
},
"user_card": {
"approve": "Accepter",
@@ -644,11 +789,11 @@
"unmute_progress": "DÊmasquageâ€Ļ",
"mute_progress": "Masquageâ€Ļ",
"admin_menu": {
- "moderation": "Moderation",
+ "moderation": "ModÊration",
"grant_admin": "Promouvoir Administrateur⋅ice",
- "revoke_admin": "DÊgrader Administrateur⋅ice",
+ "revoke_admin": "DÊgrader L'administrateur⋅ice",
"grant_moderator": "Promouvoir ModÊrateur⋅ice",
- "revoke_moderator": "DÊgrader ModÊrateur⋅ice",
+ "revoke_moderator": "DÊgrader la¡e modÊrateur⋅ice",
"activate_account": "Activer le compte",
"deactivate_account": "DÊsactiver le compte",
"delete_account": "Supprimer le compte",
@@ -660,7 +805,7 @@
"disable_any_subscription": "Interdir de s'abonner à l'utilisateur tout court",
"quarantine": "Interdir les statuts de l'utilisateur à fÊdÊrer",
"delete_user": "Supprimer l'utilisateur",
- "delete_user_confirmation": "Êtes-vous absolument-sÃģr⋅e ? Cette action ne peut ÃĒtre annulÊe."
+ "delete_user_data_and_deactivate_confirmation": "Ceci va supprimer les donnÊes du compte de manière permanente et le dÊsactivÊ. Êtes-vous vraiment sÃģr ?"
},
"mention": "Mention",
"hidden": "CachÊ",
@@ -680,7 +825,10 @@
"striped": "Fond rayÊ"
},
"bot": "Robot",
- "edit_profile": "Éditer le profil"
+ "edit_profile": "Éditer le profil",
+ "deactivated": "DÊsactivÊ",
+ "follow_cancel": "Annuler la requÃĒte",
+ "remove_follower": "Retirer l'abonnÃŠÂˇe"
},
"user_profile": {
"timeline_title": "Flux du compte",
@@ -748,13 +896,16 @@
"media_removal_desc": "Cette instance supprime le contenu multimÊdia des instances suivantes :",
"media_nsfw": "Force le contenu multimÊdia comme sensible",
"ftl_removal": "SupprimÊes du flux fÊdÊrÊ",
- "media_nsfw_desc": "Cette instance force les pièce-jointes comme sensible pour les messages des instances suivantes :"
+ "media_nsfw_desc": "Cette instance force les pièce-jointes comme sensible pour les messages des instances suivantes :",
+ "reason": "Raison",
+ "not_applicable": "N/A",
+ "instance": "Instance"
},
"federation": "FÊdÊration",
"mrf_policies": "Politiques MRF actives",
"mrf_policies_desc": "Les politiques MRF modifient la fÊdÊration entre les instances. Les politiques suivantes sont activÊes :"
},
- "staff": "Staff"
+ "staff": "Équipe"
},
"domain_mute_card": {
"mute": "MasquÊ",
@@ -787,7 +938,19 @@
"load_all": "Charger tout les {emojiAmount} Êmojis",
"load_all_hint": "{saneAmount} Êmojis chargÊ, charger tout les Êmojis peuvent causer des problèmes de performances.",
"stickers": "Stickers",
- "keep_open": "Garder ouvert"
+ "keep_open": "Garder ouvert",
+ "unicode_groups": {
+ "activities": "ActivitÊs",
+ "animals-and-nature": "Animaux & nature",
+ "flags": "Drapeaux",
+ "food-and-drink": "Nourriture & boissons",
+ "objects": "Objets",
+ "people-and-body": "Personnes & Corps",
+ "smileys-and-emotion": "Emoticônes",
+ "symbols": "Symboles",
+ "travel-and-places": "Voyages & lieux"
+ },
+ "regional_indicator": "Indicateur rÊgional {letter}"
},
"remote_user_resolver": {
"error": "Non trouvÊ.",
@@ -826,14 +989,32 @@
"year": "{0} annÊe",
"years": "{0} annÊes",
"year_short": "{0}a",
- "years_short": "{0}a"
+ "years_short": "{0}a",
+ "unit": {
+ "years": "{0} annÊe | {0} annÊes",
+ "years_short": "{0}ans",
+ "days_short": "{0}j",
+ "hours": "{0} heure | {0} heures",
+ "hours_short": "{0}h",
+ "minutes": "{0} minute | {0} minutes",
+ "minutes_short": "{0}min",
+ "months_short": "{0}mois",
+ "seconds": "{0} seconde | {0} secondes",
+ "seconds_short": "{0}s",
+ "weeks": "{0} semaine | {0} semaines",
+ "days": "{0} jour | {0} jours",
+ "months": "{0} mois | {0} mois",
+ "weeks_short": "{0}semaine"
+ }
},
"search": {
"people": "Comptes",
"person_talking": "{count} personnes discutant",
"hashtags": "Mot-dièses",
"people_talking": "{count} personnes discutant",
- "no_results": "Aucun rÊsultats"
+ "no_results": "Aucun rÊsultats",
+ "no_more_results": "Pas de rÊsultats supplÊmentaires",
+ "load_more": "Charger plus de rÊsultats"
},
"password_reset": {
"forgot_password": "Mot de passe oubliÊ ?",
@@ -874,5 +1055,47 @@
"delete": "Effacer",
"message_user": "Message à {nickname}",
"you": "Vous :"
+ },
+ "lists": {
+ "new": "Nouvelle liste",
+ "title": "Titre de la liste",
+ "create": "CrÊer",
+ "save": "Sauvegarder les changements",
+ "delete": "Supprimer la liste",
+ "following_only": "Limiter aux abonnÃŠÂˇe¡s",
+ "manage_lists": "GÊrer les listes",
+ "add_members": "Rechercher plus d'utilisateurs",
+ "remove_from_list": "Retirer de la liste",
+ "add_to_list": "Ajouter à la liste",
+ "is_in_list": "DÊjà dans la liste",
+ "editing_list": "Édition de la liste {listTitle}",
+ "creating_list": "CrÊation d'une nouvelle liste",
+ "really_delete": "Êtes-vous sÃģr¡e de vouloir supprimer la liste ?",
+ "error": "Erreur en manipulant les listes : {0}",
+ "lists": "Listes",
+ "search": "Rechercher des utilisateurs",
+ "manage_members": "GÊrer les membres des listes",
+ "update_title": "Sauvegarder le titre"
+ },
+ "update": {
+ "update_bugs_gitlab": "GitLab du projet Pleroma",
+ "update_changelog": "Pour plus de dÊtails sur les changements, consultez {theFullChangelog}.",
+ "update_changelog_here": "Liste compète des changements",
+ "art_by": "Œuvre par {linkToArtist}",
+ "big_update_content": "Nous n'avons pas fait de nouvelle version depuis un moment, les choses peuvent vous paraitre diffÊrentes de vos habitudes.",
+ "update_bugs": "Veuillez rapporter les problèmes sur {pleromaGitlab}, comme beaucoup de changements on ÊtÊ fait, mÃĒme si nous testons entièrement et utilisons la version de dÊvelopement nous-mÃĒme, nous avons pu en louper. Les retours et suggestions sont bienvenues sur ce que vous avez pu rencontrer, ou sur comment amÊliorer Pleroma (BE) et Pleroma-FE."
+ },
+ "unicode_domain_indicator": {
+ "tooltip": "Ce domaine contient des caractères non ascii."
+ },
+ "report": {
+ "reporter": "Rapporteur¡euse :",
+ "reported_user": "Compte rapportÊ :",
+ "reported_statuses": "Status rapportÊs :",
+ "notes": "Notes :",
+ "state": "Status :",
+ "state_open": "Ouvert",
+ "state_closed": "FermÊ",
+ "state_resolved": "RÊsolut"
}
}
diff --git a/src/i18n/he.json b/src/i18n/he.json
index b0c59a30..6c62acc4 100644
--- a/src/i18n/he.json
+++ b/src/i18n/he.json
@@ -347,8 +347,7 @@
"disable_remote_subscription": "אל ×Ēאפ׊ר ×ĸקיבה של המש×Ēמ׊ מאינסטנס אחר",
"disable_any_subscription": "אל ×Ēאפ׊ר ×ĸקיבה של המש×Ēמ׊ בכלל",
"quarantine": "אל ×Ēאפ׊ר פדר×Ļיה של ההוד×ĸו×Ē ×Š×œ המש×Ēמ׊",
- "delete_user": "מחק מ׊×Ēמ׊",
- "delete_user_confirmation": "בטוח? פ×ĸולה זו הינה בל×Ēי הפיכה."
+ "delete_user": "מחק מ׊×Ēמ׊"
}
},
"user_profile": {
diff --git a/src/i18n/id.json b/src/i18n/id.json
index e6b5eb94..73cc2a71 100644
--- a/src/i18n/id.json
+++ b/src/i18n/id.json
@@ -208,7 +208,13 @@
"enable_web_push_notifications": "Aktifkan notifikasi push web",
"more_settings": "Lebih banyak pengaturan",
"reply_visibility_all": "Tampilkan semua balasan",
- "reply_visibility_self": "Hanya tampilkan balasan yang ditujukan kepada saya"
+ "reply_visibility_self": "Hanya tampilkan balasan yang ditujukan kepada saya",
+ "hide_muted_posts": "Sembunyikan postingan-postingan dari pengguna yang dibisukan",
+ "import_blocks_from_a_csv_file": "Impor blokiran dari berkas csv",
+ "domain_mutes": "Domain",
+ "composing": "Menulis",
+ "no_blocks": "Tidak ada yang diblokir",
+ "no_mutes": "Tidak ada yang dibisukan"
},
"about": {
"mrf": {
@@ -222,7 +228,9 @@
"reject_desc": "Instansi ini tidak akan menerima pesan dari instansi-instansi berikut:",
"reject": "Tolak",
"accept_desc": "Instansi ini hanya menerima pesan dari instansi-instansi berikut:",
- "accept": "Terima"
+ "accept": "Terima",
+ "media_removal": "Penghapusan Media",
+ "media_removal_desc": "Instansi ini menghapus media dari postingan yang berasal dari instansi-instansi berikut:"
},
"federation": "Federasi",
"mrf_policies": "Kebijakan MRF yang diaktifkan"
@@ -319,8 +327,7 @@
"delete_account": "Hapus akun",
"force_nsfw": "Tandai semua postingan sebagai NSFW",
"strip_media": "Hapus media dari postingan-postingan",
- "delete_user": "Hapus pengguna",
- "delete_user_confirmation": "Apakah Anda benar-benar yakin? Tindakan ini tidak dapat dibatalkan."
+ "delete_user": "Hapus pengguna"
},
"follow_unfollow": "Berhenti mengikuti",
"followees": "Mengikuti",
@@ -334,7 +341,9 @@
"message": "Kirimkan pesan"
},
"user_profile": {
- "timeline_title": "Linimasa pengguna"
+ "timeline_title": "Linimasa pengguna",
+ "profile_does_not_exist": "Maaf, profil ini tidak ada.",
+ "profile_loading_error": "Maaf, terjadi kesalahan ketika memuat profil ini."
},
"user_reporting": {
"title": "Melaporkan {0}",
diff --git a/src/i18n/it.json b/src/i18n/it.json
index 6fc1d05a..c8c74b70 100644
--- a/src/i18n/it.json
+++ b/src/i18n/it.json
@@ -448,7 +448,10 @@
"backup_restore": "Archiviazione impostazioni"
},
"right_sidebar": "Mostra barra laterale a destra",
- "hide_shoutbox": "Nascondi muro dei graffiti"
+ "hide_shoutbox": "Nascondi muro dei graffiti",
+ "mentions_new_style": "Menzioni abbreviate",
+ "mentions_new_place": "Segrega le menzioni",
+ "always_show_post_button": "Non nascondere il pulsante di composizione"
},
"timeline": {
"error_fetching": "Errore nell'aggiornamento",
@@ -482,7 +485,6 @@
"deny": "Nega",
"remote_follow": "Segui da remoto",
"admin_menu": {
- "delete_user_confirmation": "Ne sei completamente sicuro? Non potrai tornare indietro.",
"delete_user": "Elimina utente",
"quarantine": "I messaggi non arriveranno alle altre stanze",
"disable_any_subscription": "Rendi utente non seguibile",
@@ -757,7 +759,10 @@
"status_deleted": "Questo messagio è stato cancellato",
"nsfw": "DISDICEVOLE",
"external_source": "Vai all'origine",
- "expand": "Espandi"
+ "expand": "Espandi",
+ "mentions": "Menzioni",
+ "you": "(Tu)",
+ "plus_more": "+{number} altri"
},
"time": {
"years_short": "{0} a",
@@ -774,8 +779,8 @@
"second": "{0} secondo",
"now_short": "adesso",
"now": "adesso",
- "months_short": "{0} ms",
- "month_short": "{0} ms",
+ "months_short": "{0} mes",
+ "month_short": "{0} mes",
"months": "{0} mesi",
"month": "{0} mese",
"minutes_short": "{0} min",
diff --git a/src/i18n/ja_easy.json b/src/i18n/ja_easy.json
index f64943d9..abca262b 100644
--- a/src/i18n/ja_easy.json
+++ b/src/i18n/ja_easy.json
@@ -608,8 +608,7 @@
"disable_remote_subscription": "ãģã‹ãŽã‚¤ãƒŗã‚šã‚ŋãƒŗã‚šã‹ã‚‰ãƒ•ã‚Šãƒ­ãƒŧされãĒいようãĢする",
"disable_any_subscription": "フりロãƒŧされãĒいようãĢする",
"quarantine": "ãģã‹ãŽã‚¤ãƒŗã‚šã‚ŋãƒŗã‚šãŽãƒĻãƒŧã‚ļãƒŧぎとうこうをとめる",
- "delete_user": "ãƒĻãƒŧã‚ļãƒŧをけす",
- "delete_user_confirmation": "あãĒたは、ãģんとうãĢ、きはたしかですかīŧŸ これは、とりけすことが、できぞせん。"
+ "delete_user": "ãƒĻãƒŧã‚ļãƒŧをけす"
}
},
"user_profile": {
diff --git a/src/i18n/ja_pedantic.json b/src/i18n/ja_pedantic.json
index 7241c9ac..fddf24db 100644
--- a/src/i18n/ja_pedantic.json
+++ b/src/i18n/ja_pedantic.json
@@ -43,7 +43,10 @@
"role": {
"moderator": "ãƒĸデãƒŦãƒŧã‚ŋãƒŧ",
"admin": "įŽĄį†č€…"
- }
+ },
+ "flash_security": "Flashã‚ŗãƒŗãƒ†ãƒŗãƒ„ãŒäģģæ„ãŽå‘Ŋäģ¤ã‚’åŽŸčĄŒã•ã›ã‚‹ã“ã¨ãĢã‚ˆã‚Šã€ã‚ŗãƒŗãƒ”ãƒĨãƒŧã‚ŋãƒŧãŒåąé™ēãĢさらされることがありぞす。",
+ "flash_fail": "Flashã‚ŗãƒŗãƒ†ãƒŗãƒ„ãŽčĒ­ãŋčžŧãŋãĢå¤ąæ•—ã—ãžã—ãŸã€‚ã‚ŗãƒŗã‚ŊãƒŧãƒĢã§čŠŗį´°ã‚’įĸēčĒã§ããžã™ã€‚",
+ "flash_content": "īŧˆčŠĻé¨“įš„æŠŸčƒŊīŧ‰ã‚¯ãƒĒックしãĻFlashã‚ŗãƒŗãƒ†ãƒŗãƒ„ã‚’å†į”Ÿã—ãžã™ã€‚"
},
"image_cropper": {
"crop_picture": "į”ģ像を切り抜く",
@@ -586,14 +589,18 @@
"word_filter": "単čĒžãƒ•ã‚ŖãƒĢã‚ŋ",
"file_export_import": {
"errors": {
- "invalid_file": "これはPleromaãŽč¨­åŽšã‚’ãƒãƒƒã‚¯ã‚ĸãƒƒãƒ—ã—ãŸãƒ•ã‚Ąã‚¤ãƒĢではありぞせん。"
+ "invalid_file": "これはPleromaãŽč¨­åŽšã‚’ãƒãƒƒã‚¯ã‚ĸãƒƒãƒ—ã—ãŸãƒ•ã‚Ąã‚¤ãƒĢではありぞせん。",
+ "file_slightly_new": "ãƒ•ã‚Ąã‚¤ãƒĢぎマイナãƒŧバãƒŧã‚¸ãƒ§ãƒŗãŒį•°ãĒã‚Šã€ä¸€éƒ¨ãŽč¨­åŽšãŒčĒ­ãŋčžŧぞれãĒいことがありぞす"
},
"restore_settings": "č¨­åŽšã‚’ãƒ•ã‚Ąã‚¤ãƒĢから垊元する",
"backup_settings_theme": "テãƒŧマをåĢã‚€č¨­åŽšã‚’ãƒ•ã‚Ąã‚¤ãƒĢãĢバックã‚ĸップする",
"backup_settings": "č¨­åŽšã‚’ãƒ•ã‚Ąã‚¤ãƒĢãĢバックã‚ĸップする",
"backup_restore": "č¨­åŽšã‚’ãƒãƒƒã‚¯ã‚ĸップ"
},
- "save": "変更をäŋå­˜"
+ "save": "変更をäŋå­˜",
+ "hide_shoutbox": "Shoutboxã‚’čĄ¨į¤ēしãĒい",
+ "always_show_post_button": "投į¨ŋボã‚ŋãƒŗã‚’å¸¸ãĢ襨į¤ē",
+ "right_sidebar": "ã‚ĩイドバãƒŧã‚’åŗãĢ襨į¤ē"
},
"time": {
"day": "{0}æ—Ĩ",
@@ -641,7 +648,9 @@
"no_more_statuses": "これでįĩ‚わりです",
"no_statuses": "゚テãƒŧã‚ŋ゚はありぞせん",
"reload": "再čĒ­ãŋčžŧãŋ",
- "error": "ã‚ŋã‚¤ãƒ ãƒŠã‚¤ãƒŗãŽčĒ­ãŋčžŧãŋãĢå¤ąæ•—ã—ãžã—ãŸ: {0}"
+ "error": "ã‚ŋã‚¤ãƒ ãƒŠã‚¤ãƒŗãŽčĒ­ãŋčžŧãŋãĢå¤ąæ•—ã—ãžã—ãŸ: {0}",
+ "socket_reconnected": "ãƒĒã‚ĸãƒĢã‚ŋイムæŽĨįļšãŒįĸēįĢ‹ã•ã‚Œãžã—ãŸ",
+ "socket_broke": "ã‚ŗãƒŧド{0}ãĢよりãƒĒã‚ĸãƒĢã‚ŋイムæŽĨįļšãŒåˆ‡æ–­ã•れぞした"
},
"status": {
"favorites": "お気ãĢå…Ĩり",
@@ -668,7 +677,10 @@
"copy_link": "ãƒĒãƒŗã‚¯ã‚’ã‚ŗãƒ”ãƒŧ",
"status_unavailable": "åˆŠį”¨ã§ããžã›ã‚“",
"unbookmark": "ブックマãƒŧã‚¯č§Ŗé™¤",
- "bookmark": "ブックマãƒŧク"
+ "bookmark": "ブックマãƒŧク",
+ "mentions": "ãƒĄãƒŗã‚ˇãƒ§ãƒŗ",
+ "you": "īŧˆã‚ãĒたīŧ‰",
+ "plus_more": "ãģか{number}äģļ"
},
"user_card": {
"approve": "受けå…Ĩれ",
@@ -717,8 +729,7 @@
"disable_remote_subscription": "äģ–ãŽã‚¤ãƒŗã‚šã‚ŋãƒŗã‚šã‹ã‚‰ãƒ•ã‚Šãƒ­ãƒŧされãĒいようãĢする",
"disable_any_subscription": "フりロãƒŧされãĒいようãĢする",
"quarantine": "äģ–ãŽã‚¤ãƒŗã‚šã‚ŋãƒŗã‚šã‹ã‚‰ãŽæŠ•į¨ŋをæ­ĸめる",
- "delete_user": "ãƒĻãƒŧã‚ļãƒŧを削除",
- "delete_user_confirmation": "あãĒãŸãŽį˛žįĨžįŠļ態ãĢäŊ•ã‹å•éĄŒã¯ã”ã–ã„ãžã›ã‚“ã‹īŧŸ こぎ操äŊœã‚’取りæļˆã™ã“とはできぞせん。"
+ "delete_user": "ãƒĻãƒŧã‚ļãƒŧを削除"
},
"roles": {
"moderator": "ãƒĸデãƒŦãƒŧã‚ŋãƒŧ",
@@ -734,7 +745,8 @@
"striped": "čƒŒæ™¯ã‚’į¸žæ¨Ąæ§˜ãĢする",
"side": "į̝ãĢįˇšã‚’äģ˜ã‘ã‚‹",
"disabled": "åŧˇčĒŋしãĒい"
- }
+ },
+ "edit_profile": "ãƒ—ãƒ­ãƒ•ã‚ŖãƒŧãƒĢã‚’įˇ¨é›†"
},
"user_profile": {
"timeline_title": "ãƒĻãƒŧã‚ļãƒŧã‚ŋã‚¤ãƒ ãƒŠã‚¤ãƒŗ",
diff --git a/src/i18n/ko.json b/src/i18n/ko.json
index 6386438a..657bb079 100644
--- a/src/i18n/ko.json
+++ b/src/i18n/ko.json
@@ -4,14 +4,15 @@
},
"features_panel": {
"chat": "ėą—",
- "gopher": "ęŗ íŧ",
+ "gopher": "Gopher",
"media_proxy": "ë¯¸ë””ė–´ í”„ëĄė‹œ",
"scope_options": "ë˛”ėœ„ ė˜ĩė…˜",
"text_limit": "í…ėŠ¤íŠ¸ ė œí•œ",
"title": "기ëŠĨ",
"who_to_follow": "íŒ”ëĄœėš° ėļ”ė˛œ",
"upload_limit": "ėĩœëŒ€ 파ėŧėšŠëŸ‰",
- "pleroma_chat_messages": "Pleroma ėą„íŠ¸"
+ "pleroma_chat_messages": "Pleroma ėą„íŒ…",
+ "shout": "ė™¸ėš˜ę¸°"
},
"finder": {
"error_fetching_user": "ė‚ŦėšŠėž ė •ëŗ´ ëļˆëŸŦ똤揰 ė‹¤íŒ¨",
@@ -21,12 +22,12 @@
"apply": "ė ėšŠ",
"submit": "ëŗ´ë‚´ę¸°",
"loading": "ëĄœë”Šė¤‘â€Ļ",
- "peek": "눍揰揰",
+ "peek": "ė‚´ė§ ëŗ´ę¸°",
"close": "ë‹Ģ기",
"verify": "검ė‚Ŧ",
"confirm": "í™•ė¸",
- "enable": "ėœ íš¨",
- "disable": "ëŦ´íš¨",
+ "enable": "í™œė„ąí™”",
+ "disable": "ëš„í™œė„ąí™”",
"cancel": "ėˇ¨ė†Œ",
"dismiss": "ëŦ´ė‹œ",
"show_less": "렑揰",
@@ -34,18 +35,35 @@
"optional": "í•„ėˆ˜ ė•„ë‹˜",
"retry": "ë‹¤ė‹œ ė‹œë„í•˜ė‹­ė‹œė˜¤",
"error_retry": "ë‹¤ė‹œ ė‹œë„í•˜ė‹­ė‹œė˜¤",
- "generic_error": "ėž˜ëĒģë˜ė—ˆėŠĩ니다",
+ "generic_error": "뗐ëŸŦ가 ë°œėƒí–ˆėŠĩ니다",
"more": "더 ëŗ´ę¸°",
"role": {
"moderator": "뤑ėžŦėž",
"admin": "관ëĻŦėž"
- }
+ },
+ "undo": "ėˇ¨ė†Œ",
+ "yes": "네",
+ "no": "ė•„ë‹ˆė˜¤",
+ "unpin": "ęŗ ė • í•´ė œ",
+ "pin": "ęŗ ė •",
+ "scope_in_timeline": {
+ "private": "íŒ”ëĄœė›Œ ė „ėšŠ",
+ "public": "ęŗĩ氜",
+ "unlisted": "ëš„í‘œė‹œ",
+ "direct": "ë‹¤ė´ë ‰íŠ¸"
+ },
+ "flash_content": "클ëĻ­í•´ė„œ í”Œëž˜ė‹œ ėģ¨í…ė¸  ëŗ´ę¸° (Ruffle ė‚ŦėšŠ, ėž‘ë™í•˜ė§€ ė•Šė„ 눘 ėžˆėŠĩ니다).",
+ "flash_security": "í”Œëž˜ė‹œ ėģ¨í…ė¸ ëŠ” ėž„ė˜ ėŊ”드 ė‹¤í–‰ė´ ė•„ė§ë„ 가ëŠĨ할 눘 ėžˆę¸°ė— ėœ„í—˜í•  눘 ėžˆėŠĩ니다.",
+ "flash_fail": "í”Œëž˜ė‹œëĨŧ ëĄœë“œí•˜ė§€ ëĒģ했ėŠĩ니다, ėŊ˜ė†”ëĄœ ėžė„¸í•œ ë‚´ėšŠė„ í™•ė¸í•˜ė„¸ėš”.",
+ "scroll_to_top": "맨 ėœ„ëĄœ ė˜Ŧëŧ가기",
+ "generic_error_message": "뗐ëŸŦ가 ë°œėƒí–ˆėŠĩ니다: {0}",
+ "never_show_again": "ë‹¤ė‹œ ëŗ´ė§€ ė•Šę¸°"
},
"login": {
"login": "ëĄœęˇ¸ė¸",
"description": "OAuth로 ëĄœęˇ¸ė¸",
"logout": "ëĄœęˇ¸ė•„ė›ƒ",
- "password": "ė•”í˜¸",
+ "password": "íŒ¨ėŠ¤ė›Œë“œ",
"placeholder": "ė˜ˆė‹œ: lain",
"register": "ę°€ėž…",
"username": "ė‚ŦėšŠėž ė´ëĻ„",
@@ -57,7 +75,7 @@
"enter_two_factor_code": "2ë‹¨ęŗ„ė¸ėĻ ėŊ”드ëĨŧ ėž…ë Ĩí•˜ė‹­ė‹œė˜¤",
"enter_recovery_code": "ëŗĩęĩŦ ėŊ”드ëĨŧ ėž…ë Ĩí•˜ė‹­ė‹œė˜¤",
"authentication_code": "ė¸ėĻ ėŊ”드",
- "hint": "ëĄœęˇ¸ė¸í•˜ė—Ŧ ëŒ€í™”ė— ė°¸ę°€í•Šė‹œë‹¤"
+ "hint": "ëĄœęˇ¸ė¸í•´ė„œ ëŒ€í™”ė— ė°¸ė—Ŧ"
},
"nav": {
"about": "ė¸ėŠ¤í„´ėŠ¤ ė†Œę°œ",
@@ -71,68 +89,86 @@
"twkn": "ė•Œë ¤ė§„ ë„¤íŠ¸ė›ŒíŦ",
"user_search": "ė‚ŦėšŠėž ę˛€ėƒ‰",
"preferences": "환ę˛Ŋ네렕",
- "chats": "ėą„íŠ¸",
+ "chats": "ėą„íŒ…",
"timelines": "íƒ€ėž„ëŧė¸",
"who_to_follow": "ėļ”ė˛œëœ ė‚ŦėšŠėž",
"search": "ę˛€ėƒ‰",
"bookmarks": "ëļë§ˆíŦ",
- "interactions": "대화",
+ "interactions": "ė•ŒëĻŧ",
"administration": "관ëĻŦ",
- "home_timeline": "홈 íƒ€ėž„ëŧė¸"
+ "home_timeline": "홈 íƒ€ėž„ëŧė¸",
+ "mobile_notifications": "ė•ŒëĻŧ 뗴揰 (ėŊė§€ ė•Šė€ ė•ŒëĻŧė´ ėžˆėŠĩ니다)",
+ "lists": "ëĻŦėŠ¤íŠ¸",
+ "edit_nav_mobile": "ë„¤ëš„ę˛Œė´ė…˜ 바 ėģ¤ėŠ¤í…€í•˜ę¸°",
+ "edit_pinned": "ėƒë‹¨ ęŗ ė • íŽ¸ė§‘",
+ "edit_finish": "íŽ¸ė§‘ ėĸ…ëŖŒ",
+ "mobile_notifications_close": "ė•ŒëĻŧ ë‹Ģ기",
+ "mobile_sidebar": "ëĒ¨ë°”ėŧ ė‚Ŧė´ë“œë°” 토글",
+ "announcements": "ęŗĩė§€ė‚Ŧ항"
},
"notifications": {
"broken_favorite": "ė•Œ 눘 ė—†ëŠ” ę˛Œė‹œëŦŧėž…ë‹ˆë‹¤, ę˛€ėƒ‰í•Šë‹ˆë‹¤â€Ļ",
- "favorited_you": "ë‹šė‹ ė˜ ę˛Œė‹œëŦŧė„ ėĻę˛¨ė°žę¸°",
- "followed_you": "ë‹šė‹ ė„ íŒ”ëĄœėš°",
- "load_older": "ė˜¤ëž˜ 된 ė•ŒëĻŧ ëļˆëŸŦ똤揰",
+ "favorited_you": "관ė‹Ŧė„ 氀말",
+ "followed_you": "íŒ”ëĄœėš°í•¨",
+ "load_older": "ė´ė „ ė•ŒëĻŧ ëļˆëŸŦ똤揰",
"notifications": "ė•ŒëĻŧ",
"read": "ėŊėŒ!",
- "repeated_you": "ë‹šė‹ ė˜ ę˛Œė‹œëŦŧė„ ëĻŦ핏",
+ "repeated_you": "ëĻŦ핏함",
"no_more_notifications": "ė•ŒëĻŧė´ ė—†ėŠĩ니다",
"migrated_to": "ė´ė‚Ŧ했ėŠĩ니다",
"reacted_with": "{0} 로 ë°˜ė‘í–ˆėŠĩ니다",
"error": "ė•ŒëĻŧ ëļˆëŸŦ똤揰 ė‹¤íŒ¨: {0}",
- "follow_request": "ë‹šė‹ ė—ę˛Œ íŒ”ëĄœėš° 닠랭"
+ "follow_request": "íŒ”ëĄœėš° ėš”ė˛­",
+ "submitted_report": "ė‹ ęŗ  ë‚´ėšŠė„ ė „ė†Ąí•¨",
+ "poll_ended": "íˆŦ표가 끝남"
},
"post_status": {
"new_status": "냈 ę˛Œė‹œëŦŧ ę˛Œė‹œ",
- "account_not_locked_warning": "ë‹šė‹ ė˜ ęŗ„ė •ė€ {0} ėƒíƒœę°€ ė•„ë‹™ë‹ˆë‹¤. 누ęĩŦ나 ë‹šė‹ ė„ íŒ”ëĄœėš° í•˜ęŗ  íŒ”ëĄœė›Œ ė „ėšŠ ę˛Œė‹œëŦŧė„ ëŗŧ 눘 ėžˆėŠĩ니다.",
+ "account_not_locked_warning": "ęŗ„ė •ė´ {0} ėƒíƒœę°€ ė•„ë‹™ë‹ˆë‹¤. 누ęĩŦ나 ë‹šė‹ ė„ íŒ”ëĄœėš° í•˜ęŗ  íŒ”ëĄœė›Œ ė „ėšŠ ę˛Œė‹œëŦŧė„ ëŗŧ 눘 ėžˆėŠĩ니다.",
"account_not_locked_warning_link": "ėž ęš€",
"attachments_sensitive": "랍ëļ€ëŦŧė„ ë¯ŧ감함ėœŧ로 네렕",
"content_type": {
"text/plain": "평ëŦ¸",
"text/bbcode": "BBCode",
- "text/markdown": "Markdown",
+ "text/markdown": "마íŦë‹¤ėš´",
"text/html": "HTML"
},
- "content_warning": "ėŖŧ렜 (í•„ėˆ˜ ė•„ë‹˜)",
+ "content_warning": "렜ëĒŠ (ė„ íƒ)",
"default": "ė¸ė˛œęŗĩí•­ė— ë„ė°Ší–ˆėŠĩ니다.",
"direct_warning": "ė´ ę˛Œė‹œëŦŧė„ ëŠ˜ė…˜ 된 ė‚ŦėšŠėžë“¤ė—ę˛Œë§Œ ëŗ´ė—Ŧė§‘ë‹ˆë‹¤",
- "posting": "ę˛Œė‹œ",
+ "posting": "ę˛Œė‹œ 뤑",
"scope": {
"direct": "ë‹¤ė´ë ‰íŠ¸ - ëŠ˜ė…˜ 된 ė‚ŦėšŠėžë“¤ė—ę˛Œë§Œ",
"private": "íŒ”ëĄœė›Œ ė „ėšŠ - íŒ”ëĄœė›Œë“¤ė—ę˛Œë§Œ",
"public": "ęŗĩ氜 - ęŗĩ氜 íƒ€ėž„ëŧė¸ėœŧ로",
- "unlisted": "비ęŗĩ氜 - ęŗĩ氜 íƒ€ėž„ëŧė¸ė— ę˛Œė‹œ ė•ˆ 함"
+ "unlisted": "ëš„í‘œė‹œ - ęŗĩ氜 íƒ€ėž„ëŧė¸ė—ëŠ” ė•ˆ ëŗ´ė´ę˛Œ"
},
- "preview_empty": "ė•„ëŦ´ę˛ƒë„ ė—†ėŠĩ니다",
+ "preview_empty": "ëš„ė–´ėžˆėŒ",
"preview": "미ëĻŦëŗ´ę¸°",
"scope_notice": {
- "public": "ė´ ę¸€ė€ 누ęĩŦ나 ëŗŧ 눘 ėžˆėŠĩ니다"
+ "public": "누ęĩŦ나 ëŗŧ 눘 ėžˆėŠĩ니다",
+ "private": "íŒ”ëĄœė›Œė—ę˛Œë§Œ ëŗ´ė—Ŧė§‘ë‹ˆë‹¤",
+ "unlisted": "ęŗĩ氜 íƒ€ėž„ëŧė¸ė´ë‚˜ ė•Œë ¤ė§„ ë„¤íŠ¸ė›ŒíŦė—ëŠ” ëŗ´ė—Ŧ맀맀 ė•ŠėŠĩ니다"
},
- "media_description_error": "파ėŧė„ ė˜ŦëĻŦė§€ ëĒģí•˜ė˜€ėŠĩ니다. ë‹¤ė‹œí•œë˛ˆ ė‹œë„í•˜ė—Ŧ ėŖŧė‹­ė‹œė˜¤",
- "empty_status_error": "ę¸€ė„ ėž…ë Ĩí•˜ė‹­ė‹œė˜¤",
- "media_description": "랍ëļ€íŒŒėŧ 네ëĒ…"
+ "media_description_error": "파ėŧė„ ė˜ŦëĻŦė§€ ëĒģ했ėŠĩ니다, ë‹¤ė‹œ ė‹œë„í•´ ëŗ´ė„¸ėš”",
+ "empty_status_error": "ę˛Œė‹œëŦŧė´ ëš„ė–´ ėžˆėŠĩ니다",
+ "media_description": "랍ëļ€íŒŒėŧ 네ëĒ…",
+ "direct_warning_to_all": "ëŠ˜ė…˜í•œ ëĒ¨ë“  ė‚ŦėšŠėžė—ę˛Œ ëŗ´ė—Ŧė§‘ë‹ˆë‹¤.",
+ "edit_unsupported_warning": "Pleroma는 ëŠ˜ė…˜ė´ë‚˜ íˆŦ표ëĨŧ ėˆ˜ė •í•˜ëŠ” 기ëŠĨė„ ė§€ė›í•˜ė§€ ė•ŠėŠĩ니다.",
+ "edit_status": "ėˆ˜ė •",
+ "edit_remote_warning": "ėˆ˜ė • 기ëŠĨė´ ė—†ëŠ” 다ëĨ¸ ė¸ėŠ¤í„´ėŠ¤ė—ė„œëŠ” ėˆ˜ė •í•œ ė‚Ŧí•­ė´ ë°˜ė˜ë˜ė§€ ė•Šė„ 눘 ėžˆėŠĩ니다.",
+ "post": "ę˛Œė‹œ",
+ "direct_warning_to_first_only": "맨 ė•žė— ëŠ˜ė…˜í•œ ė‚ŦėšŠėžë“¤ė—ę˛Œë§Œ ëŗ´ė—Ŧė§‘ë‹ˆë‹¤."
},
"registration": {
"bio": "ė†Œę°œ",
"email": "ė´ëŠ”ėŧ",
- "fullname": "í‘œė‹œ 되는 ė´ëĻ„",
- "password_confirm": "ė•”í˜¸ í™•ė¸",
+ "fullname": "í‘œė‹œë  ė´ëĻ„",
+ "password_confirm": "íŒ¨ėŠ¤ė›Œë“œ í™•ė¸",
"registration": "ę°€ėž…í•˜ę¸°",
"token": "ė´ˆëŒ€ 토큰",
"captcha": "ėēĄė°¨",
- "new_captcha": "ė´ë¯¸ė§€ëĨŧ 클ëĻ­í•´ė„œ ėƒˆëĄœėš´ ėēĄė°¨",
+ "new_captcha": "ė´ë¯¸ė§€ëĨŧ 클ëĻ­í•´ė„œ ėƒˆëĄœėš´ ėēĄė°¨ ę°€ė ¸ė˜¤ę¸°",
"validations": {
"username_required": "ęŗĩë°ąėœŧ로 둘 눘 ė—†ėŠĩ니다",
"fullname_required": "ęŗĩë°ąėœŧ로 둘 눘 ė—†ėŠĩ니다",
@@ -142,25 +178,32 @@
"password_confirmation_match": "íŒ¨ėŠ¤ė›Œë“œė™€ ėŧėš˜í•´ė•ŧ 합니다"
},
"fullname_placeholder": "똈: ęš€ëĄ€ė¸",
- "username_placeholder": "똈: lain"
+ "username_placeholder": "똈: lain",
+ "bio_placeholder": "ė˜ˆė‹œ\nė•ˆë…•í•˜ė„¸ėš”, ëĄ€ė¸ėž…ë‹ˆë‹¤.\nėŧëŗ¸ ė‹œė™¸ė—ė„œ ė• ë‹ˆëŠ”ė´ė…˜ ė•„ė´ëŒė„ í•˜ęŗ  ėžˆėŠĩ니다. Wiredė—ė„œ 레 ëŗ´ė…¨ė„ ęą°ė˜ˆėš”.",
+ "bio_optional": "ė†Œę°œ (ė„ íƒ)",
+ "email_optional": "ė´ëŠ”ėŧ (ė„ íƒ)",
+ "reason": "ę°€ėž…í•˜ë ¤ëŠ” ė´ėœ ",
+ "reason_placeholder": "ė´ ė¸ėŠ¤í„´ėŠ¤ëŠ” ėˆ˜ë™ėœŧ로 ę°€ėž…ė„ ėŠšė¸í•˜ęŗ  ėžˆėŠĩ니다.\nė™œ ę°€ėž…í•˜ęŗ  ė‹ļė€ė§€ 관ëĻŦėžė—ę˛Œ ė•Œë ¤ėŖŧė„¸ėš”.",
+ "register": "ę°€ėž…",
+ "email_language": "ëŦ´ėЍ ė–¸ė–´ëĄœ ė´ëŠ”ėŧė„ 받길 ė›í•˜ė‹œë‚˜ėš”?"
},
"settings": {
"attachmentRadius": "랍ëļ€ëŦŧ",
"attachments": "랍ëļ€ëŦŧ",
- "avatar": "ė•„ë°”íƒ€",
- "avatarAltRadius": "ė•„ë°”íƒ€ (ė•ŒëĻŧ)",
- "avatarRadius": "ė•„ë°”íƒ€",
+ "avatar": "프로필 ė‚Ŧė§„",
+ "avatarAltRadius": "프로필 ė‚Ŧė§„ (ė•ŒëĻŧė°Ŋ)",
+ "avatarRadius": "프로필 ė‚Ŧė§„",
"background": "ë°°ę˛Ŋ",
"bio": "ė†Œę°œ",
"btnRadius": "버íŠŧ",
"cBlue": "파랑 (ë‹ĩ글, íŒ”ëĄœėš°)",
"cGreen": "ė´ˆëĄ (ëĻŦíŠ¸ėœ—)",
- "cOrange": "ėŖŧ황 (ėĻę˛¨ė°žę¸°)",
+ "cOrange": "ėŖŧ황 (관ė‹Ŧ글)",
"cRed": "빨강 (ėˇ¨ė†Œ)",
- "change_password": "ė•”í˜¸ 바꾸기",
- "change_password_error": "ė•”í˜¸ëĨŧ 바꾸는 데 ëLJ 氀맀 ëŦ¸ė œę°€ ėžˆėŠĩ니다.",
- "changed_password": "ė•”í˜¸ëĨŧ ë°”ęž¸ė—ˆėŠĩ니다!",
- "collapse_subject": "ėŖŧ렜ëĨŧ 氀맄 ę˛Œė‹œëŦŧ 렑揰",
+ "change_password": "íŒ¨ėŠ¤ė›Œë“œ 바꾸기",
+ "change_password_error": "íŒ¨ėŠ¤ė›Œë“œëĨŧ 바꾸는 데 ëŦ¸ė œę°€ ėžˆėŠĩ니다.",
+ "changed_password": "íŒ¨ėŠ¤ė›Œë“œę°€ ë°”ë€Œė—ˆėŠĩ니다!",
+ "collapse_subject": "렜ëĒŠė´ ėžˆëŠ” ę˛Œė‹œëŦŧ 렑揰",
"composing": "ėž‘ė„ą",
"confirm_new_password": "냈 íŒ¨ėŠ¤ė›Œë“œ í™•ė¸",
"current_avatar": "현ėžŦ ė•„ë°”íƒ€",
@@ -169,27 +212,27 @@
"data_import_export_tab": "ë°ė´í„° ëļˆëŸŦ똤揰 / ë‚´ëŗ´ë‚´ę¸°",
"default_vis": "ę¸°ëŗ¸ ęŗĩ氜 ë˛”ėœ„",
"delete_account": "ęŗ„ė • ė‚­ė œ",
- "delete_account_description": "ë°ė´í„°ę°€ 똁ęĩŦ히 ė‚­ė œë˜ęŗ  ęŗ„ė •ė´ ëļˆí™œė„ąí™”됊니다.",
+ "delete_account_description": "ë°ė´í„°ę°€ 똁ęĩŦ히 ė‚­ė œë˜ęŗ  ęŗ„ė •ė´ ëš„í™œė„ąí™”ëŠë‹ˆë‹¤.",
"delete_account_error": "ęŗ„ė •ė„ ė‚­ė œí•˜ëŠ”ë° ëŦ¸ė œę°€ ėžˆėŠĩ니다. ęŗ„ė† ë°œėƒí•œë‹¤ëŠ´ ė¸ėŠ¤í„´ėŠ¤ 관ëĻŦėžė—ę˛Œ ëŦ¸ė˜í•˜ė„¸ėš”.",
- "delete_account_instructions": "ęŗ„ė • ė‚­ė œëĨŧ í™•ė¸í•˜ę¸° ėœ„í•´ ė•„ëž˜ė— íŒ¨ėŠ¤ė›Œë“œ ėž…ë Ĩ.",
+ "delete_account_instructions": "ė•„ëž˜ íŒ¨ėŠ¤ė›Œë“œëĨŧ ėž…ë Ĩí•˜ė‹œëŠ´ ęŗ„ė •ė´ ė‚­ė œëŠë‹ˆë‹¤.",
"export_theme": "프ëĻŦė…‹ ė €ėžĨ",
"filtering": "필터링",
- "filtering_explanation": "ė•„ëž˜ė˜ ë‹¨ė–´ëĨŧ 氀맄 ę˛Œė‹œëŦŧë“¤ė€ 뮤트 됩니다, 한 뤄뗐 í•˜ë‚˜ė”Š 렁ėœŧė„¸ėš”",
+ "filtering_explanation": "ė´ ë‹¨ė–´ëĨŧ 氀맄 ę˛Œė‹œëŦŧë“¤ė€ 뮤트됩니다, 한 뤄뗐 í•˜ë‚˜ė”Š 렁ėœŧė„¸ėš”",
"follow_export": "íŒ”ëĄœėš° ë‚´ëŗ´ë‚´ę¸°",
- "follow_export_button": "íŒ”ëĄœėš° ëĒŠëĄė„ csv로 ë‚´ëŗ´ë‚´ę¸°",
+ "follow_export_button": "íŒ”ëĄœėš° ëĒŠëĄė„ CSV 파ėŧ로 ë‚´ëŗ´ë‚´ę¸°",
"follow_export_processing": "ė§„í–‰ ė¤‘ėž…ë‹ˆë‹¤, ęŗ§ ë‹¤ėš´ëĄœë“œ 가ëŠĨ해 마 ę˛ƒėž…ë‹ˆë‹¤",
"follow_import": "íŒ”ëĄœėš° ëļˆëŸŦ똤揰",
"follow_import_error": "íŒ”ëĄœėš° ëļˆëŸŦ똤揰 ė‹¤íŒ¨",
"follows_imported": "íŒ”ëĄœėš° ëĒŠëĄė„ ëļˆëŸŦė™”ėŠĩ니다! 래ëĻŦė—ëŠ” ė‹œę°„ė´ 깸ëĻŊ니다.",
- "foreground": "ė „ę˛Ŋ",
+ "foreground": "표면",
"general": "ėŧ반",
"hide_attachments_in_convo": "ëŒ€í™”ė˜ 랍ëļ€ëŦŧ 눍揰揰",
"hide_attachments_in_tl": "íƒ€ėž„ëŧė¸ė˜ 랍ëļ€ëŦŧ 눍揰揰",
"hide_isp": "ė¸ėŠ¤í„´ėŠ¤ ė „ėšŠ 패널 눍揰揰",
"preload_images": "ė´ë¯¸ė§€ 미ëĻŦ ëļˆëŸŦ똤揰",
- "hide_post_stats": "ę˛Œė‹œëŦŧ í†ĩęŗ„ 눍揰揰 (ėĻę˛¨ė°žę¸° 눘 등)",
+ "hide_post_stats": "ę˛Œė‹œëŦŧ í†ĩęŗ„ 눍揰揰 (관ė‹Ŧ글 눘 등)",
"hide_user_stats": "ė‚ŦėšŠėž í†ĩęŗ„ 눍揰揰 (íŒ”ëĄœė›Œ 눘 등)",
- "import_followers_from_a_csv_file": "csv 파ėŧė—ė„œ íŒ”ëĄœėš° ëĒŠëĄ ëļˆëŸŦ똤揰",
+ "import_followers_from_a_csv_file": "CSV 파ėŧė—ė„œ íŒ”ëĄœėš° ëĒŠëĄ ëļˆëŸŦ똤揰",
"import_theme": "프ëĻŦė…‹ ëļˆëŸŦ똤揰",
"inputRadius": "ėž…ë Ĩ ėš¸",
"checkboxRadius": "랴íŦë°•ėŠ¤",
@@ -197,58 +240,58 @@
"instance_default_simple": "(ę¸°ëŗ¸)",
"interface": "ė¸í„°íŽ˜ė´ėŠ¤",
"interfaceLanguage": "ė¸í„°íŽ˜ė´ėŠ¤ 떏떴",
- "invalid_theme_imported": "ė„ íƒí•œ 파ėŧė€ ė§€ė›í•˜ëŠ” 플레로마 테마가 ė•„ë‹™ë‹ˆë‹¤. ė•„ëŦ´ëŸ° ëŗ€ę˛Ŋ도 ėŧė–´ë‚˜ė§€ ė•Šė•˜ėŠĩ니다.",
+ "invalid_theme_imported": "해당 파ėŧė€ ė§€ė›ë˜ė§€ ė•ŠëŠ” Pleroma í…Œë§ˆėž…ë‹ˆë‹¤. ė•„ëŦ´ ėŧ도 ėŧė–´ë‚˜ė§€ ė•Šė•˜ėŠĩ니다.",
"limited_availability": "ė´ 브ëŧėš°ė €ė—ė„œ ė‚ŦėšŠ ëļˆę°€",
"links": "링íŦ",
- "lock_account_description": "ęŗ„ė •ė„ ėŠšė¸ 된 íŒ”ëĄœė›Œë“¤ëĄœ ė œí•œ",
+ "lock_account_description": "íŒ”ëĄœė›ŒëĨŧ ėŠšė¸í•´ė„œ 받도록 ė œí•œ",
"loop_video": "ëš„ë””ė˜¤ 반ëŗĩėžŦėƒ",
- "loop_video_silent_only": "ė†ŒëĻŦ가 ė—†ëŠ” ëš„ë””ė˜¤ë§Œ 반ëŗĩ ėžŦėƒ (ë§ˆėŠ¤í† ëˆė˜ \"gifs\" ę°™ė€ 것들)",
+ "loop_video_silent_only": "ė†ŒëĻŦ가 ė—†ëŠ” ëš„ë””ė˜¤ë§Œ 반ëŗĩ ėžŦėƒ (ë§ˆėŠ¤í† ëˆė˜ \"GIF\" ę°™ė€ 것들)",
"name": "ė´ëĻ„",
"name_bio": "ė´ëĻ„ & ė†Œę°œ",
- "new_password": "냈 ė•”í˜¸",
- "notification_visibility": "ëŗ´ė—Ŧ 뤄 ė•ŒëĻŧ ėĸ…ëĨ˜",
+ "new_password": "냈 íŒ¨ėŠ¤ė›Œë“œ",
+ "notification_visibility": "ëŗ´ė—Ŧ마 ė•ŒëĻŧ ėĸ…ëĨ˜",
"notification_visibility_follows": "íŒ”ëĄœėš°",
- "notification_visibility_likes": "ėĸ‹ė•„함",
+ "notification_visibility_likes": "관ė‹Ŧ글",
"notification_visibility_mentions": "ëŠ˜ė…˜",
- "notification_visibility_repeats": "반ëŗĩ",
+ "notification_visibility_repeats": "ëĻŦ핏",
"no_rich_text_description": "ëĒ¨ë“  ę˛Œė‹œëŦŧė˜ ė„œė‹ė„ ė§€ėš°ę¸°",
- "hide_follows_description": "내가 íŒ”ëĄœėš°í•˜ëŠ” ė‚ŦëžŒė„ í‘œė‹œí•˜ė§€ ė•ŠėŒ",
- "hide_followers_description": "나ëĨŧ 따ëĨ´ëŠ” ė‚ŦëžŒė„ 눍揰揰",
- "nsfw_clickthrough": "NSFW ė´ë¯¸ė§€ \"클ëĻ­í•´ė„œ ëŗ´ė´ę¸°\"ëĨŧ í™œė„ąí™”",
+ "hide_follows_description": "íŒ”ëĄœėš° ė¤‘ė¸ ė‚Ŧ람 눍揰揰",
+ "hide_followers_description": "íŒ”ëĄœė›Œ 눍揰揰",
+ "nsfw_clickthrough": "ë¯ŧ감한 ė´ë¯¸ė§€ëĨŧ 눍揰揰",
"oauth_tokens": "OAuth 토큰",
"token": "토큰",
"refresh_token": "토큰 ėƒˆëĄœ ęŗ ėš¨",
- "valid_until": "ęšŒė§€ ėœ íš¨í•˜ë‹¤",
+ "valid_until": "ë§ŒëŖŒėŧ",
"revoke_token": "ėˇ¨ė†Œ",
"panelRadius": "패널",
- "pause_on_unfocused": "íƒ­ė´ í™œė„ą ėƒíƒœę°€ ė•„ë‹ 때 ėŠ¤íŠ¸ëĻŦ밍 늈ėļ”기",
+ "pause_on_unfocused": "íƒ­ė´ íŦėģ¤ėŠ¤ë˜ė§€ ė•Šė•˜ė„ 땐 늈ėļ”기",
"presets": "프ëĻŦė…‹",
"profile_background": "프로필 ë°°ę˛Ŋ",
"profile_banner": "프로필 배너",
"profile_tab": "프로필",
"radii_help": "ė¸í„°íŽ˜ė´ėŠ¤ ëĒ¨ė„œëĻŦ ë‘Ĩ글기 (í”Ŋė…€ ë‹¨ėœ„)",
- "replies_in_timeline": "ë‹ĩę¸€ė„ íƒ€ėž„ëŧė¸ė—",
+ "replies_in_timeline": "íƒ€ėž„ëŧė¸ė˜ ë‹ĩ글",
"reply_visibility_all": "ëĒ¨ë“  ë‹ĩ글 ëŗ´ę¸°",
- "reply_visibility_following": "ë‚˜ė—ę˛Œ 링렑 ė˜¤ëŠ” ë‹ĩę¸€ė´ë‚˜ 내가 íŒ”ëĄœėš° ė¤‘ė¸ ė‚ŦëžŒė—ę˛Œė„œ ė˜¤ëŠ” ë‹ĩ글만 í‘œė‹œ",
- "reply_visibility_self": "ë‚˜ė—ę˛Œ 링렑 ė „ė†Ą 된 ë‹ĩ글만 ëŗ´ė´ę¸°",
- "saving_err": "네렕 ė €ėžĨ ė‹¤íŒ¨",
+ "reply_visibility_following": "ë‚˜ė—ę˛Œ 링렑 ė˜¤ęą°ë‚˜ 내가 íŒ”ëĄœėš° ė¤‘ė¸ ė‚ŦëžŒė´ ëŗ´ë‚¸ ë‹ĩ글만 ëŗ´ę¸°",
+ "reply_visibility_self": "ë‚˜ė—ę˛Œ 링렑 똍 ë‹ĩ글만 ëŗ´ę¸°",
+ "saving_err": "ė„¤ė •ė„ ė €ėžĨ하는 데 뗐ëŸŦ가 ë°œėƒí–ˆėŠĩ니다",
"saving_ok": "네렕 ė €ėžĨ 됨",
"security_tab": "ëŗ´ė•ˆ",
- "scope_copy": "ë‹ĩę¸€ė„ ë‹Ŧ 때 ęŗĩ氜 ë˛”ėœ„ 따ëŧ가ëĻŦ (ë‹¤ė´ë ‰íŠ¸ ëŠ”ė‹œė§€ëŠ” ė–¸ė œë‚˜ 따ëŧ감)",
- "set_new_avatar": "냈 ė•„ë°”íƒ€ 네렕",
+ "scope_copy": "ë‹ĩę¸€ė„ ë‹Ŧ 때 ęŗĩ氜 ë˛”ėœ„ 따ëŧ가기 (ë‹¤ė´ë ‰íŠ¸ ëŠ”ė‹œė§€ëŠ” ė–¸ė œë‚˜ 따ëŧ감)",
+ "set_new_avatar": "냈 프로필 ė‚Ŧė§„ 네렕",
"set_new_profile_background": "냈 프로필 ë°°ę˛Ŋ 네렕",
"set_new_profile_banner": "냈 프로필 배너 네렕",
"settings": "네렕",
- "subject_input_always_show": "í•­ėƒ ėŖŧ렜 ėš¸ ëŗ´ė´ę¸°",
- "subject_line_behavior": "ë‹ĩę¸€ė„ ë‹Ŧ 때 ėŖŧ렜 ëŗĩė‚Ŧ하기",
- "subject_line_email": "ė´ëŠ”ėŧ래ëŸŧ: \"re: ėŖŧ렜\"",
+ "subject_input_always_show": "í•­ėƒ 렜ëĒŠ ėž…ë Ĩė°Ŋ ëŗ´ė´ę¸°",
+ "subject_line_behavior": "ë‹ĩę¸€ė„ ë‹Ŧ 때 렜ëĒŠ ëŗĩė‚Ŧ하기",
+ "subject_line_email": "ė´ëŠ”ėŧ래ëŸŧ: \"re: 렜ëĒŠ\"",
"subject_line_mastodon": "ë§ˆėŠ¤í† ëˆė˛˜ëŸŧ: 그대로 ëŗĩė‚Ŧ",
"subject_line_noop": "ëŗĩė‚Ŧ ė•ˆ 함",
- "stop_gifs": "GIF파ėŧ뗐 ë§ˆėš°ėŠ¤ëĨŧ ė˜Ŧë ¤ė„œ ėžŦėƒ",
- "streaming": "ėĩœėƒë‹¨ė— 도ë‹Ŧ하면 ėžë™ėœŧ로 냈 ę˛Œė‹œëŦŧ ėŠ¤íŠ¸ëĻŦ밍",
+ "stop_gifs": "ë§ˆėš°ėŠ¤ëĨŧ ė˜Ŧë ¤ė„œ GIF ėžŦėƒ",
+ "streaming": "ėĩœėƒë‹¨ė— 도ë‹Ŧ하면 ė•Œė•„ė„œ 냈 ę˛Œė‹œëŦŧ ę°€ė ¸ė˜¤ę¸°",
"text": "í…ėŠ¤íŠ¸",
"theme": "테마",
- "theme_help": "16ė§„ėˆ˜ ėƒ‰ėƒėŊ”드(#rrggbb)ëĨŧ ė‚ŦėšŠí•´ ėƒ‰ėƒ 테마ëĨŧ ėģ¤ėŠ¤í„°ë§ˆė´ėψ.",
+ "theme_help": "16ė§„ėˆ˜ ėƒ‰ėƒėŊ”드(#rrggbb)ëĨŧ ė‚ŦėšŠí•´ ėƒ‰ėƒė„ ėĄ°ė •í•˜ė„¸ėš”.",
"theme_help_v2_1": "랴íŦë°•ėŠ¤ëĨŧ í†ĩ해 ëLJëLJ ėģ´íŦë„ŒíŠ¸ė˜ ėƒ‰ėƒęŗŧ ëļˆíˆŦëĒ…ë„ëĨŧ ėĄ°ė ˆ 가ëŠĨ, \"ëĒ¨ë‘ ė§€ėš°ę¸°\" 버íŠŧėœŧ로 ëŽė–´ ė”Œėš´ ę˛ƒė„ ëĒ¨ë‘ ėˇ¨ė†Œ.",
"theme_help_v2_2": "ëLJëLJ ėž…ë Ĩėš¸ ë°‘ė˜ ė•„ė´ėŊ˜ė€ ė „ę˛Ŋ/ë°°ę˛Ŋ 대비 관련 í‘œė‹œë“ąėž…ë‹ˆë‹¤, ë§ˆėš°ėŠ¤ëĨŧ ė˜Ŧë ¤ ėžė„¸í•œ ė •ëŗ´ëĨŧ ëŗŧ 눘 ėžˆėŠĩ니다. íˆŦëĒ…ë„ 대비 í‘œė‹œë“ąė´ 가ėžĨ ėĩœė•…ė˜ ę˛Ŋ뚰ëĨŧ 나타낸다는 ę˛ƒė„ ėœ ė˜í•˜ė„¸ėš”.",
"tooltipRadius": "툴팁/ę˛Ŋęŗ ",
@@ -265,25 +308,42 @@
"keep_shadows": "꡸ëĻŧėž ėœ ė§€",
"keep_opacity": "ëļˆíˆŦëĒ…ë„ ėœ ė§€",
"keep_roundness": "ë‘Ĩ글기 ėœ ė§€",
- "keep_fonts": "ę¸€ėžė˛´ ėœ ė§€",
+ "keep_fonts": "글ęŧ´ ėœ ė§€",
"save_load_hint": "\"ėœ ė§€\" ė˜ĩė…˜ë“¤ė€ 다ëĨ¸ 테마ëĨŧ ęŗ ëĨ´ęą°ë‚˜ ëļˆëŸŦ ė˜Ŧ 때 현ėžŦ 네렕 된 ė˜ĩė…˜ë“¤ė„ 건드ëĻŦė§€ ė•Šę˛Œ 합니다, 테마ëĨŧ ë‚´ëŗ´ë‚´ę¸° 할 때도 ė´ ė˜ĩė…˜ė— 따ëŧ ė €ėžĨ합니다. ė•„ëŦ´ 것도 랴íŦ ë˜ė§€ ė•Šė•˜ë‹¤ëŠ´ ëĒ¨ë“  ė„¤ė •ė„ ë‚´ëŗ´ëƒ…ë‹ˆë‹¤.",
"reset": "ė´ˆę¸°í™”",
"clear_all": "ëĒ¨ë‘ ė§€ėš°ę¸°",
- "clear_opacity": "ëļˆíˆŦëĒ…ë„ ė§€ėš°ę¸°"
+ "clear_opacity": "ëļˆíˆŦëĒ…ë„ ė§€ėš°ę¸°",
+ "help": {
+ "upgraded_from_v2": "PleromaFE가 ė—…ęˇ¸ë ˆė´ë“œ ë˜ė—ˆę¸°ė—, 테마가 기ė–ĩí•˜ė‹œë˜ 것ęŗŧ ėĄ°ę¸ˆ 다ëĨŧ 눘 ėžˆėŠĩ니다.",
+ "v2_imported": "ëļˆëŸŦ똍 파ėŧė€ ė´ęŗŗëŗ´ë‹¤ ė´ė „ ë˛„ė „ė˜ FEė—ė„œ ë§Œë“¤ė–´ėĄŒėŠĩ니다. í˜¸í™˜ė„ąė„ ėœ ė§€í•˜ę˛ ė§€ë§Œ ęš¨ė§„ ëļ€ëļ„ė´ ėžˆė„ 눘 ėžˆėŠĩ니다.",
+ "migration_snapshot_ok": "í˜šė‹œë‚˜ ė‹ļė–´ė„œ, 테마 ėŠ¤ëƒ…ėƒˇė„ ëļˆëŸŦė™”ėŠĩ니다. 테마 ë°ė´í„°ëĨŧ ëļˆëŸŦė™€ë„ 됩니다.",
+ "snapshot_source_mismatch": "ë˛„ė „ė´ ėļŠëŒëŠë‹ˆë‹¤: ė•„ë§ˆ FE가 ëĄ¤ë°ąë˜ęŗ  ë‹¤ė‹œ ė—…ë°ė´íŠ¸ ë˜ė–´ė„œėŧ 건데, ė´ė „ ë˛„ė „ FE로 테마ëĨŧ ėˆ˜ė •í–ˆë‹¤ëŠ´ ė´ė „ ë˛„ė „ FEëĨŧ ė¨ëŗ´ė‹œëŠ” 枌 ėĸ‹ęŗ , ė•„ë‹ˆëŠ´ 냈 ë˛„ė „ė„ ė“°ė„¸ėš”.",
+ "future_version_imported": "ëļˆëŸŦ똍 파ėŧė€ ė´ęŗŗëŗ´ë‹¤ 냈 ë˛„ė „ė˜ FEė—ė„œ ë§Œë“¤ė–´ėĄŒėŠĩ니다.",
+ "older_version_imported": "ëļˆëŸŦ똍 파ėŧė€ ė´ęŗŗëŗ´ë‹¤ ė´ė „ ë˛„ė „ė˜ FEė—ė„œ ë§Œë“¤ė–´ėĄŒėŠĩ니다.",
+ "snapshot_present": "테마 ėŠ¤ëƒ…ėƒˇė´ ėžˆė–´ė„œ, ëĒ¨ë“  ę°’ė´ ëŽė–´ ė”Œė›ŒėĄŒėŠĩ니다. 링렑 í…Œë§ˆė˜ ė‹¤ė œ ë°ė´í„°ëĨŧ ëŒ€ė‹  ëļˆëŸŦė™€ë„ 됩니다.",
+ "snapshot_missing": "파ėŧ뗐 ėŠ¤ëƒ…ėƒˇė´ ė—†ė–´ė„œ ė›ëž˜ ëŗ´ė˜€ë˜ ę˛ƒëŗ´ë‹¤ 다ëĨ´ę˛Œ ëŗ´ėŧ 눘 ėžˆėŠĩ니다.",
+ "fe_upgraded": "ë˛„ė „ ė—…ë°ė´íŠ¸ëĄœ PleromaFEė˜ 테마 ė—”ė§„ė´ ė—…ęˇ¸ë ˆė´ë“œ ë˜ė—ˆėŠĩ니다.",
+ "fe_downgraded": "PleromaFEė˜ ë˛„ė „ė´ ëĄ¤ë°ąë˜ė—ˆėŠĩ니다.",
+ "migration_napshot_gone": "뭔 ėŧė¸ė§„ ëǍëĨ´ę˛ ė§€ë§Œ ėŠ¤ëƒ…ėƒˇė´ ė—†ė–´ė„œ, ëLJëLJ 개가 기ė–ĩí•˜ė‹  것ęŗŧ ë‹ŦëĻŦ ëŗ´ėŧ 눘 ėžˆėŠĩ니다."
+ },
+ "load_theme": "테마 ëļˆëŸŦ똤揰",
+ "keep_as_is": "그대로 두기",
+ "use_snapshot": "ė´ė „ ë˛„ė „",
+ "use_source": "냈 ë˛„ė „"
},
"common": {
"color": "ėƒ‰ėƒ",
"opacity": "ëļˆíˆŦëĒ…ë„",
"contrast": {
- "hint": "ëŒ€ëš„ėœ¨ė´ {ratio}ėž…ë‹ˆë‹¤, ė´ę˛ƒė€ {context} {level}",
+ "hint": "ėƒ‰ėƒ ëŒ€ëš„ėœ¨ė´ {ratio}ėž…ë‹ˆë‹¤, {context} {level}",
"level": {
- "aa": "AA등급 ę°€ė´ë“œëŧė¸ė— ëļ€í•Ší•Šë‹ˆë‹¤ (ėĩœė†Œí•œë„)",
- "aaa": "AAA등급 ę°€ė´ë“œëŧė¸ė— ëļ€í•Ší•Šë‹ˆë‹¤ (ęļŒėžĨ)",
- "bad": "ė•„ëŦ´ëŸ° ę°€ė´ë“œëŧė¸ ë“ąę¸‰ė—ë„ ë¯¸ėš˜ė§€ ëĒģ합니다"
+ "aa": "ė ‘ęˇŧė„ą ę°€ė´ë“œëŧė¸ AAë“ąę¸‰ė„ ėļŠėĄąí•Šë‹ˆë‹¤ (ėĩœė†Œ)",
+ "aaa": "ė ‘ęˇŧė„ą ę°€ė´ë“œëŧė¸ AAAë“ąę¸‰ė„ ėļŠėĄąí•Šë‹ˆë‹¤ (ęļŒėžĨ)",
+ "bad": "ė ‘ęˇŧė„ą ę°€ė´ë“œëŧė¸ė„ ėļŠėĄąí•˜ė§€ ëĒģ합니다"
},
"context": {
"18pt": "큰 (18pt ė´ėƒ) í…ėŠ¤íŠ¸ė— 대해",
- "text": "í…ėŠ¤íŠ¸ė— 대해"
+ "text": "ėŧ반 í…ėŠ¤íŠ¸ė— 대해"
}
}
},
@@ -307,13 +367,23 @@
"faint_text": "íë ¤ė§„ í…ėŠ¤íŠ¸",
"chat": {
"border": "ę˛Ŋęŗ„ė„ ",
- "outgoing": "ė†Ąė‹ ",
- "incoming": "ėˆ˜ė‹ "
+ "outgoing": "ëŗ´ëƒ„",
+ "incoming": "ë°›ėŒ"
},
"selectedMenu": "ė„ íƒëœ 메뉴 ėš”ė†Œ",
"selectedPost": "ė„ íƒëœ 글",
"icons": "ė•„ė´ėŊ˜",
- "alert_warning": "ę˛Ŋęŗ "
+ "alert_warning": "ę˛Ŋęŗ ",
+ "alert_neutral": "뤑ëĻŊ렁",
+ "post": "ę˛Œė‹œëŦŧ / ėœ ė € ė†Œę°œ",
+ "popover": "툴팁, 메뉴, 프로필 ėš´ë“œ",
+ "disabled": "ëš„í™œė„ąí™”",
+ "wallpaper": "ë°°ę˛Ŋė‚Ŧė§„",
+ "poll": "íˆŦ표 그래프",
+ "highlight": "ę°•ėĄ° ėš”ė†Œ",
+ "pressed": "ëˆŒë ¸ė„ 때",
+ "toggled": "토글됨",
+ "tabs": "탭"
},
"radii": {
"_tab_label": "ë‘Ĩ글기"
@@ -344,23 +414,24 @@
"button": "버íŠŧ",
"buttonHover": "버íŠŧ (ë§ˆėš°ėŠ¤ ė˜Ŧë ¸ė„ 때)",
"buttonPressed": "버íŠŧ (ëˆŒë ¸ė„ 때)",
- "buttonPressedHover": "Button (ë§ˆėš°ėŠ¤ ė˜ŦëĻŧ + 눌ëĻŧ)",
+ "buttonPressedHover": "버íŠŧ (ë§ˆėš°ėŠ¤ ė˜ŦëĻŧ + 눌ëĻŧ)",
"input": "ėž…ë Ĩėš¸"
- }
+ },
+ "hintV3": "꡸ëĻŧėžė˜ ę˛Ŋ뚰 {0} 표기법ėœŧ로 다ëĨ¸ ėģŦëŸŦ ėŠŦëĄ¯ė„ ė‚ŦėšŠí•  눘 ėžˆėŠĩ니다."
},
"fonts": {
- "_tab_label": "ę¸€ėžė˛´",
- "help": "ė¸í„°íŽ˜ė´ėŠ¤ė˜ ėš”ė†Œė— ė‚ŦėšŠ 될 ę¸€ėžė˛´ëĨŧ ęŗ ëĨ´ė„¸ėš”. \"ėģ¤ėŠ¤í…€\"ė€ ė‹œėŠ¤í…œė— ėžˆëŠ” 폰트 ė´ëĻ„ė„ ė •í™•ížˆ ėž…ë Ĩ해ė•ŧ 합니다.",
+ "_tab_label": "글ęŧ´",
+ "help": "í™”ëŠ´ė— ė ėšŠí•  글ęŧ´ė„ ęŗ ëĨ´ė„¸ėš”. \"링렑 ėž…ë Ĩ\"ė€ ė‹œėŠ¤í…œė— ėžˆëŠ” 글ęŧ´ ė´ëĻ„ė„ ė •í™•ížˆ ėž…ë Ĩ해ė•ŧ 합니다.",
"components": {
"interface": "ė¸í„°íŽ˜ė´ėŠ¤",
"input": "ėž…ë Ĩėš¸",
"post": "ę˛Œė‹œëŦŧ í…ėŠ¤íŠ¸",
"postCode": "ę˛Œė‹œëŦŧė˜ ęŗ ė •í­ í…ėŠ¤íŠ¸ (ė„œė‹ ėžˆëŠ” í…ėŠ¤íŠ¸)"
},
- "family": "ę¸€ėžė˛´ ė´ëĻ„",
+ "family": "글ęŧ´ ė´ëĻ„",
"size": "íŦ기 (px ë‹¨ėœ„)",
"weight": "ęĩĩ기",
- "custom": "ėģ¤ėŠ¤í…€"
+ "custom": "링렑 ėž…ë Ĩ"
},
"preview": {
"header": "미ëĻŦëŗ´ę¸°",
@@ -371,8 +442,8 @@
"mono": "ë‚´ėšŠ",
"input": "ė¸ė˛œęŗĩí•­ė— ë„ė°Ší–ˆėŠĩ니다.",
"faint_link": "ë„ė›€ 되는 네ëĒ…ė„œ",
- "fine_print": "뚰ëĻŦė˜ {0} ëĨŧ ėŊęŗ  ë„ė›€ ë˜ė§€ ė•ŠëŠ” ę˛ƒë“¤ė„ ë°°ėš°ėž!",
- "header_faint": "ė´ęą´ ę´œė°Žė•„",
+ "fine_print": "뚰ëĻŦė˜ {0}ëĨŧ ėŊęŗ  ë„ė›€ ë˜ė§€ ė•ŠëŠ” ę˛ƒë“¤ė„ ë°°ėš°ėž!",
+ "header_faint": "ę´œė°Žė€ í…ėŠ¤íŠ¸",
"checkbox": "나는 ė•Ŋę´€ė„ 대ėļŠ í›‘ė–´ëŗ´ė•˜ėŠĩ니다",
"link": "ėž‘ęŗ  귀ė—Ŧ뚴 링íŦ"
}
@@ -381,44 +452,224 @@
"mfa": {
"scan": {
"secret_code": "키",
- "title": "늤ėē”"
+ "title": "늤ėē”",
+ "desc": "2ë‹¨ęŗ„ ė¸ėĻ ė•ąė„ í†ĩ해 QR ėŊ”드ëĨŧ ė°ęą°ë‚˜ 키ëĨŧ ėž…ë Ĩí•˜ė„¸ėš”:"
},
"authentication_methods": "ė¸ėĻ 방법",
- "waiting_a_recovery_codes": "ė˜ˆëš„ ėŊ”드ëĨŧ ėˆ˜ė‹ í•˜ęŗ  ėžˆėŠĩ니다â€Ļ",
+ "waiting_a_recovery_codes": "ëŗĩęĩŦ ėŊ”드ëĨŧ ę°€ė ¸ė˜¤ęŗ  ėžˆėŠĩ니다â€Ļ",
"recovery_codes": "ëŗĩęĩŦ ėŊ”드.",
- "generate_new_recovery_codes": "ėƒˆëĄœėš´ ëŗĩęĩŦ ėŊ”드ëĨŧ ėž‘ė„ą",
- "title": "2ë‹¨ęŗ„ė¸ėĻ",
- "confirm_and_enable": "OTP í™•ė¸ęŗŧ í™œė„ąí™”",
- "setup_otp": "OTP ė„¤ėš˜",
- "otp": "OTP"
+ "generate_new_recovery_codes": "냈 ëŗĩęĩŦ ėŊ”드 ėƒė„ą",
+ "title": "2ë‹¨ęŗ„ ė¸ėĻ",
+ "confirm_and_enable": "í™•ė¸ & OTP í™œė„ąí™”",
+ "setup_otp": "OTP 네렕",
+ "otp": "OTP",
+ "warning_of_generate_new_codes": "냈 ëŗĩęĩŦ ėŊ”드ëĨŧ ėƒė„ąí•˜ëŠ´, ė´ė „ ėŊ”드는 ėž‘ë™í•˜ė§€ ė•Šę˛Œ 됩니다.",
+ "recovery_codes_warning": "ëŗĩęĩŦ ėŊ”드ëĨŧ ė–´ë”˜ę°€ ė•ˆė „í•œ 溺뗐 렁떴 놓ėœŧė„¸ėš” - 더 ė´ėƒ ė´ ėŊ”드ëĨŧ ëŗ´ė‹¤ 눜 ė—†ėŠĩ니다. 만ė•Ŋ 2ë‹¨ęŗ„ ė¸ėĻ ė•ąęŗŧ ëŗĩęĩŦ ėŊ”드 둘 다 ė ‘ęˇŧ할 눘 ė—†ę˛Œ 된다면 溄렕뗐 ëĄœęˇ¸ė¸í•  눘 ė—†ę˛Œ 됩니다.",
+ "verify": {
+ "desc": "í™œė„ąí™”í•˜ë ¤ëŠ´ 2ë‹¨ęŗ„ ė¸ėĻ ė•ąė—ė„œ ë°›ė€ ėŊ”드ëĨŧ ėž…ë Ĩí•˜ė„¸ėš”:"
+ }
},
"security": "ëŗ´ė•ˆ",
- "emoji_reactions_on_timeline": "ė´ëǍ맀 ë°˜ė‘ė„ íƒ€ėž„ëŧė¸ėœŧ로 í‘œė‹œ",
- "avatar_size_instruction": "íŦ기ëĨŧ 150x150 ė´ėƒėœŧ로 ė„¤ė •í•  ę˛ƒė„ ėļ”ėžĨ합니다.",
+ "emoji_reactions_on_timeline": "뗐ëǍ맀 ë°˜ė‘ė„ íƒ€ėž„ëŧė¸ė— í‘œė‹œ",
+ "avatar_size_instruction": "ėĩœė†Œ 150x150 í”Ŋė…€ëŗ´ë‹¤ 큰 ė‚Ŧė§„ė„ ė—…ëĄœë“œí•˜ė‹œëŠ´ ėĸ‹ėŠĩ니다.",
"blocks_tab": "ė°¨ë‹¨",
"notification_setting_privacy": "ëŗ´ė•ˆ",
"user_mutes": "ė‚ŦėšŠėž",
"notification_visibility_emoji_reactions": "ë°˜ė‘",
"profile_fields": {
- "value": "ë‚´ėšŠ"
+ "value": "ë‚´ėšŠ",
+ "label": "프로필 ėļ”ę°€ė •ëŗ´",
+ "add_field": "필드 ėļ”ę°€",
+ "name": "ëŧ벨"
},
- "mutes_and_blocks": "ėš¨ëŦĩęŗŧ ė°¨ë‹¨",
- "chatMessageRadius": "ėą— ëŠ”ė‹œė§€",
- "change_email": "메ėŧėŖŧė†Œ 바꾸기",
- "changed_email": "메ėŧėŖŧė†Œę°€ ę°ąė‹ ë˜ė—ˆėŠĩ니다!",
- "bot": "ė´ ęŗ„ė •ė€ botėž…ë‹ˆë‹¤",
- "mutes_tab": "ėš¨ëŦĩ",
- "app_name": "ė•ą ė´ëĻ„"
+ "mutes_and_blocks": "ëŽ¤íŠ¸ė™€ ė°¨ë‹¨",
+ "chatMessageRadius": "ėą„íŒ… ëŠ”ė‹œė§€",
+ "change_email": "메ėŧ ėŖŧė†Œ 바꾸기",
+ "changed_email": "메ėŧ ėŖŧė†Œę°€ ë°”ë€Œė—ˆėŠĩ니다!",
+ "bot": "ė´ ęŗ„ė •ė€ ėžë™ ë´‡ėž…ë‹ˆë‹¤",
+ "mutes_tab": "뮤트",
+ "app_name": "ė•ą ė´ëĻ„",
+ "notification_setting_block_from_strangers": "íŒ”ëĄœí•˜ė§€ ė•Šė€ ęŗ„ė •ė—ė„œ ëŗ´ë‚´ëŠ” ė•ŒëĻŧ ė°¨ë‹¨",
+ "autohide_floating_post_button": "ė•Œė•„ė„œ 냈 ę˛Œė‹œëŦŧ 버íŠŧ 눍揰揰 (ëĒ¨ë°”ėŧ)",
+ "blocks_imported": "ė°¨ë‹¨ ëĒŠëĄė„ 氀렏뙔ėŠĩ니다! 래ëĻŦ하는 ë°ė— ė‹œę°„ė´ 깸ëĻ´ 눘 ėžˆėŠĩ니다.",
+ "mutes_imported": "뮤트 ëĒŠëĄė„ 氀렏뙔ėŠĩ니다! 래ëĻŦ하는 ë°ė— ė‹œę°„ė´ 깸ëĻ´ 눘 ėžˆėŠĩ니다.",
+ "account_backup_description": "내 ęŗ„ė • ė •ëŗ´ė™€ ę˛Œė‹œëŦŧė´ 담긴 ė•„ėš´ė´ë¸ŒëĨŧ ë‹¤ėš´ëĄœë“œ ë°›ė„ 눘 ėžˆė§€ë§Œ, 땄링 Pleroma로 ë‹¤ė‹œ ëļˆëŸŦė˜¤ëŠ” 기ëŠĨė€ ė§€ė›í•˜ė§€ ė•ŠėŠĩ니다.",
+ "move_account_notes": "ęŗ„ė •ė„ 다ëĨ¸ ęŗŗėœŧ로 ė´ė‚Ŧ하려면, ė´ė‚Ŧ 갈 ęŗ„ė •ėœŧ로 ę°€ė…”ė„œ ëŗ„ėš­ė´ ė´ ęŗ„ė •ė„ 가ëĻŦ키도록 í•˜ė„¸ėš”.",
+ "hide_bot_indication": "ę˛Œė‹œëŦŧė—ė„œ 봇 ė•ŒëĻŧ 눍揰揰",
+ "navbar_column_stretch": "ėƒë‹¨ 바ëĨŧ ėģŦëŸŧ 너비만íŧ 늘ëĻŦ기",
+ "show_admin_badge": "내 í”„ëĄœí•„ė— \"관ëĻŦėž\" ë°°ė§€ ë‹Ŧ기",
+ "sensitive_by_default": "ę˛Œė‹œëŦŧė„ ë¯ŧ감함ėœŧ로 ę¸°ëŗ¸ 네렕",
+ "notification_mutes": "íŠšė • ė‚ŦėšŠėžė˜ ė•ŒëĻŧė„ ë°›ė§€ ė•Šėœŧ려늴, 뮤트ëĨŧ ė‚ŦėšŠí•˜ė„¸ėš”.",
+ "mention_link_fade_domain": "íë ¤ė§„ ë„ëŠ”ė¸ (똈: {'@'}foo{'@'}example.org ė—ė„œė˜ {'@'}example.org)",
+ "notification_blocks": "ė‚ŦėšŠėžëĨŧ ė°¨ë‹¨í•˜ëŠ´ ė•ŒëĻŧė„ ë°›ė§€ ė•ŠëŠ”ë°ë‹¤ ęĩŦë…ęšŒė§€ ėˇ¨ė†Œí•˜ę˛Œ 됩니다.",
+ "conversation_display_tree": "트ëĻŦ",
+ "save": "ëŗ€ę˛Ŋ ė‚Ŧí•­ė„ ė €ėžĨ",
+ "allow_following_move": "íŒ”ëĄœėš° ė¤‘ė¸ ęŗ„ė •ė´ ė´ė‚ŦëĨŧ 하면 ėžë™ėœŧ로 íŒ”ëĄœėš°í•˜ę¸°",
+ "expert_mode": "溠揉 네렕 ëŗ´ę¸°",
+ "setting_changed": "ę¸°ëŗ¸ 네렕ęŗŧ 다ëĻ…ë‹ˆë‹¤",
+ "setting_server_side": "ė´ ė„¤ė •ė€ ęŗ„ė •ęŗŧ ëŦļė—Ŧ ėžˆėœŧ늰 ė—°ę˛°ëœ ëĒ¨ë“  ė„¸ė…˜ęŗŧ 클ëŧė´ė–¸íŠ¸ė— 똁í–Ĩė„ ė¤ë‹ˆë‹¤",
+ "enter_current_password_to_confirm": "ëŗ¸ė¸ í™•ė¸ė„ ėœ„í•´ 현ėžŦ íŒ¨ėŠ¤ė›Œë“œëĨŧ ėž…ë Ĩí•˜ė„¸ėš”",
+ "post_look_feel": "ę˛Œė‹œëŦŧ ëĒ¨ė–‘ėƒˆ",
+ "mention_links": "ëŠ˜ė…˜ 링íŦ",
+ "lists_navigation": "ëŠ”ë‰´ė— ëĻŦėŠ¤íŠ¸ ëŗ´ė´ę¸°",
+ "email_language": "ė„œë˛„ëĄœëļ€í„° ė´ëŠ”ėŧė„ ë°›ė„ 떏떴",
+ "block_import": "ė°¨ë‹¨ ëĒŠëĄ ę°€ė ¸ė˜¤ę¸°",
+ "block_export_button": "ė°¨ë‹¨ ëĒŠëĄė„ CSV 파ėŧ로 ë‚´ëŗ´ë‚´ę¸°",
+ "block_import_error": "ė°¨ë‹¨ ëĒŠëĄė„ ę°€ė ¸ė˜¤ëŠ” ë°ė— ëŦ¸ė œę°€ ë°œėƒí–ˆėŠĩ니다",
+ "mute_export": "뮤트 ëĒŠëĄ ë‚´ëŗ´ë‚´ę¸°",
+ "mute_export_button": "뮤트 ëĒŠëĄė„ CSV 파ėŧ로 ë‚´ëŗ´ë‚´ę¸°",
+ "mute_import": "뮤트 ëĒŠëĄ ę°€ė ¸ė˜¤ę¸°",
+ "mute_import_error": "뮤트 ëĒŠëĄė„ ę°€ė ¸ė˜¤ëŠ” ë°ė— ëŦ¸ė œę°€ ë°œėƒí–ˆėŠĩ니다",
+ "import_mutes_from_a_csv_file": "뮤트 ëĒŠëĄė„ CSV 파ėŧė—ė„œ ę°€ė ¸ė˜¤ę¸°",
+ "account_backup": "ęŗ„ė • ë°ąė—…",
+ "account_backup_table_head": "ë°ąė—…",
+ "download_backup": "ë‹¤ėš´ëĄœë“œ",
+ "backup_not_ready": "ë°ąė—…ė´ 땄링 ė¤€ëš„ë˜ė§€ ė•Šė•˜ėŠĩ니다.",
+ "remove_backup": "ė‚­ė œ",
+ "list_backups_error": "ë°ąė—… ëĻŦėŠ¤íŠ¸ëĨŧ ę°€ė ¸ė˜¤ëŠ” 데 뗐ëŸŦ가 ë°œėƒí–ˆėŠĩ니다: {error}",
+ "add_backup": "냈 ë°ąė—… 만들기",
+ "added_backup": "냈 ë°ąė—… ėļ”가됨.",
+ "add_backup_error": "냈 ë°ąė—…ė„ ėļ”ę°€í•˜ëŠ” 데 뗐ëŸŦ가 ë°œėƒí–ˆėŠĩ니다: {error}",
+ "change_email_error": "메ėŧ ėŖŧė†ŒëĨŧ 바꾸는 데 ëŦ¸ė œę°€ ėžˆėŠĩ니다.",
+ "account_alias": "ęŗ„ė • ëŗ„ėš­",
+ "always_show_post_button": "í•­ėƒ 떠다니는 ę˛Œė‹œëŦŧ ėž‘ė„ą 버íŠŧ ëŗ´ę¸°",
+ "mute_bot_posts": "봇 ę˛Œė‹œëŦŧ 뮤트하기",
+ "hide_all_muted_posts": "뮤트한 ę˛Œė‹œëŦŧ 눍揰揰",
+ "account_alias_table_head": "ëŗ„ėš­",
+ "hide_list_aliases_error_action": "ë‹Ģ기",
+ "remove_alias": "ė´ ëŗ„ėš­ ė‚­ė œ",
+ "new_alias_target": "냈 ëŗ„ėš­ ėļ”ę°€ (ė˜ˆė‹œ. {example})",
+ "added_alias": "ëŗ„ėš­ė´ ėļ”ę°€ë˜ė—ˆėŠĩ니다.",
+ "move_account": "ęŗ„ė • ė´ė‚Ŧ",
+ "move_account_target": "ė´ė‚Ŧ 갈 ęŗ„ė • (ė˜ˆė‹œ. {example})",
+ "moved_account": "ęŗ„ė •ė„ ė´ė‚Ŧ했ėŠĩ니다.",
+ "discoverable": "ę˛€ėƒ‰ 결ęŗŧ나 다ëĨ¸ ė„œëš„ėŠ¤ë“¤ė—ė„œ ė´ ęŗ„ė •ė„ ė°žė„ 눘 ėžˆë„ëĄ í—ˆėšŠ",
+ "pad_emoji": "뗐ëǍ맀ëĨŧ ė„ íƒė°Ŋė—ė„œ ęŗ ëĨŧ 때 ë„ė–´ė“°ę¸°ëĨŧ ė§‘ė–´ë„Ŗę¸°",
+ "wordfilter": "ë‹¨ė–´ 필터",
+ "word_filter_and_more": "ë‹¨ė–´ 필터 ꡸ëĻŦęŗ  ë”ëŗ´ę¸°...",
+ "accent": "ę°•ėĄ°",
+ "hide_media_previews": "ë¯¸ë””ė–´ 미ëĻŦëŗ´ę¸° 눍揰揰",
+ "max_thumbnails": "ę˛Œė‹œëŦŧ 하나 당 ėĩœëŒ€ëĄœ ëŗ´ė—Ŧ마 ė„Ŧ네ėŧ 氜눘 (ëš„ė›Œë‘ëŠ´ ė œí•œė„ ë‘ė§€ ė•ŠėŠĩ니다)",
+ "hide_shoutbox": "ė¸ėŠ¤í„´ėŠ¤ ė™¸ėš˜ę¸° 눍揰揰",
+ "right_sidebar": "ėģŦëŸŧ ėˆœė„œ ë’¤ė§‘ę¸°",
+ "hide_wallpaper": "ė¸ėŠ¤í„´ėŠ¤ ë°°ę˛Ŋ화면 가ëĻŦ기",
+ "use_one_click_nsfw": "ë¯ŧ감한 랍ëļ€ëŦŧė„ 클ëĻ­ 한 번ėœŧ로 뗴揰",
+ "move_account_error": "ęŗ„ė •ė„ ė´ė‚Ŧ하는 데 뗐ëŸŦ가 ë°œėƒí–ˆėŠĩ니다: {error}",
+ "hide_muted_posts": "뮤트한 ė‚ŦėšŠėžė˜ ę˛Œė‹œëŦŧ 눍揰揰",
+ "hide_filtered_statuses": "필터된 ëĒ¨ë“  ę˛Œė‹œëŦŧ 눍揰揰",
+ "hide_wordfiltered_statuses": "ë‹¨ė–´ 필터된 ę˛Œė‹œëŦŧ 눍揰揰",
+ "use_contain_fit": "랍ëļ€íŒŒėŧė˜ ė„Ŧ네ėŧė„ ėžëĨ´ė§€ ė•ŠėŒ",
+ "hide_muted_threads": "뮤트한 ėŠ¤ë ˆë“œ 눍揰揰",
+ "import_blocks_from_a_csv_file": "CSV 파ėŧė—ė„œ ė°¨ë‹¨ ëĒŠëĄ ëļˆëŸŦ똤揰",
+ "play_videos_in_modal": "íŒė—… í”„ë ˆėž„ė—ė„œ ëš„ë””ė˜¤ëĨŧ ėžŦėƒ",
+ "file_export_import": {
+ "backup_restore": "네렕 ë°ąė—…",
+ "backup_settings": "ė„¤ė •ė„ 파ėŧ로 ë°ąė—…",
+ "backup_settings_theme": "네렕ęŗŧ 테마ëĨŧ 파ėŧ로 ë°ąė—…",
+ "restore_settings": "파ėŧė—ė„œ 네렕 ëŗĩęĩŦ하기",
+ "errors": {
+ "invalid_file": "해당 파ėŧė€ ė§€ė›ë˜ė§€ ė•ŠëŠ” Pleroma ë°ąė—…ėž…ë‹ˆë‹¤. ė•„ëŦ´ ėŧ도 ėŧė–´ë‚˜ė§€ ė•Šė•˜ėŠĩ니다.",
+ "file_too_new": "í˜¸í™˜ë˜ė§€ ė•ŠëŠ” ë˛„ė „: {fileMajor}, ė´ PleromaFE (네렕 ë˛„ė „ {feMajor}) 가 너ëŦ´ ë‚Ąė•„ė„œ 래ëĻŦ할 눘 ė—†ėŠĩ니다",
+ "file_too_old": "í˜¸í™˜ë˜ė§€ ė•ŠëŠ” ë˛„ė „: {fileMajor}, 파ėŧ ë˛„ė „ė´ 너ëŦ´ ë‚Ąė•„ė„œ 래ëĻŦ할 눘 ė—†ėŠĩ니다 (ė§€ė›ë˜ëŠ” ėĩœė†Œ 네렕 ë˛„ė „ {feMajor})",
+ "file_slightly_new": "파ėŧ ë§ˆė´ë„ˆ ë˛„ė „ė´ ë‹Ŧëŧė„œ, ëLJëLJ ė„¤ė •ë“¤ė´ ė ėšŠë˜ė§€ ė•Šė•˜ė„ 눘 ėžˆėŠĩ니다"
+ }
+ },
+ "account_privacy": "ė‚Ŧėƒí™œ ëŗ´ė•ˆ",
+ "new_email": "냈 메ėŧ ėŖŧė†Œ",
+ "hide_favorites_description": "내 관ė‹Ŧę¸€ė„ ëŗ´ė´ė§€ ė•ŠėŒ (ė•ŒëĻŧė€ 갑니다)",
+ "hide_follows_count_description": "íŒ”ëĄœėš° 뤑 ėˆĢėž 눍揰揰",
+ "hide_followers_count_description": "íŒ”ëĄœė›Œ ėˆĢėž 눍揰揰",
+ "no_mutes": "뮤트 ė—†ėŒ",
+ "search_user_to_block": "ė°¨ë‹¨í•  ė‚Ŧ람 ę˛€ėƒ‰í•˜ę¸°",
+ "search_user_to_mute": "뮤트할 ė‚Ŧ람 ę˛€ėƒ‰í•˜ę¸°",
+ "posts": "ę˛Œė‹œëŦŧ",
+ "notification_visibility_moves": "ęŗ„ė • ė´ė‚Ŧ",
+ "notification_visibility_polls": "ė°¸ė—Ŧ한 íˆŦ표가 끝남",
+ "no_blocks": "ė°¨ë‹¨ ė—†ėŒ",
+ "reply_visibility_self_short": "내 ë‹ĩ글만 ëŗ´ę¸°",
+ "reply_visibility_following_short": "íŒ”ëĄœėš° ė¤‘ė¸ ė‚Ŧ람들ëŧëĻŦė˜ ë‹ĩ글 ëŗ´ę¸°",
+ "user_profiles": "ė‚ŦėšŠėž 프로필",
+ "show_moderator_badge": "내 í”„ëĄœí•„ė— \"뤑ėžŦėž\" ë°°ė§€ ë‹Ŧ기",
+ "type_domains_to_mute": "뮤트할 ë„ëŠ”ė¸ ę˛€ėƒ‰í•˜ę¸°",
+ "disable_sticky_headers": "ėģŦëŸŧ 헤더ëĨŧ 화면 ėƒë‹¨ė— ęŗ ė •í•˜ė§€ ė•ŠėŒ",
+ "auto_update": "ė•Œė•„ė„œ 냈 ę˛Œė‹œëŦŧ ę°€ė ¸ė˜¤ę¸°",
+ "minimal_scopes_mode": "ęŗĩ氜 ë˛”ėœ„ ė„ íƒė§€ ė¤„ė´ę¸°",
+ "reset_avatar": "프로필 ė‚Ŧė§„ ė´ˆę¸°í™”",
+ "reset_avatar_confirm": "ė •ë§ 프로필 ė‚Ŧė§„ė„ ė´ˆę¸°í™”í• ęšŒėš”?",
+ "reset_profile_background": "프로필 ë°°ę˛Ŋ ė´ˆę¸°í™”",
+ "reset_profile_banner": "프로필 배너 ė´ˆę¸°í™”",
+ "reset_banner_confirm": "ė •ë§ 프로필 배너ëĨŧ ė´ˆę¸°í™”í• ęšŒėš”?",
+ "reset_background_confirm": "ė •ë§ 프로필 ë°°ę˛Ŋė„ ė´ˆę¸°í™”í• ęšŒėš”?",
+ "useStreamingApi": "ė‹¤ė‹œę°„ėœŧ로 ę˛Œė‹œëŦŧęŗŧ ė•ŒëĻŧ 받기",
+ "use_websockets": "ė›šė†Œėŧ“ ė‚ŦėšŠ (ė‹¤ė‹œę°„ ė—…ë°ė´íŠ¸)",
+ "upload_a_photo": "ė‚Ŧė§„ ė—…ëĄœë“œ",
+ "conversation_display": "대화 í‘œė‹œ ëǍ떑",
+ "conversation_display_tree_quick": "트ëĻŦ 롰",
+ "show_scrollbars": "ė¸ĄëŠ´ ėģŦëŸŧė˜ 늤íŦ롤바 ëŗ´ę¸°",
+ "conversation_other_replies_button_inside": "ę˛Œė‹œëŦŧ ė•ˆė— 놓기",
+ "notification_setting_hide_notification_contents": "í‘¸ė‹œ ė•ŒëĻŧė—ė„œ ëŗ´ë‚¸ ė‚Ŧ람ęŗŧ ë‚´ėšŠė„ ėˆ¨ęš€",
+ "virtual_scrolling": "íƒ€ėž„ëŧė¸ 렌더링 ėĩœė í™”",
+ "use_at_icon": "{'@'} ëŦ¸ėžëĨŧ í…ėŠ¤íŠ¸ ëŒ€ė‹  ė•„ė´ėŊ˜ėœŧ로 í‘œė‹œ",
+ "mention_link_display": "ëŠ˜ė…˜ė— 링íŦ í‘œė‹œ",
+ "mention_link_display_short": "í•­ėƒ ė§§ė€ ė´ëĻ„ ė‚ŦėšŠ (똈: {'@'}foo)",
+ "mention_link_display_full_for_remote": "다ëĨ¸ ė¸ėŠ¤í„´ėŠ¤ ė‚ŦėšŠėžë§Œ ė´ëĻ„ ė „ëļ€ ëŗ´ę¸° (똈: {'@'}foo{'@'}example.org)",
+ "mention_link_display_full": "í•­ėƒ ė´ëĻ„ ė „ëļ€ ëŗ´ę¸° (똈: {'@'}foo{'@'}example.org)",
+ "mention_link_use_tooltip": "ëŠ˜ė…˜ 링íŦëĨŧ 누ëĨ´ëŠ´ ė‚ŦėšŠėž ėš´ë“œ ëŗ´ę¸°",
+ "mention_link_show_avatar": "링íŦ ė˜†ė— 프로필 ė‚Ŧė§„ ëŗ´ę¸°",
+ "mention_link_bolden_you": "누가 날 ëŠ˜ė…˜í–ˆė„ 때 ëŠ˜ė…˜ė„ ę°•ėĄ° í‘œė‹œ",
+ "user_popover_avatar_action_zoom": "ė‚Ŧė§„ í‚¤ėš°ę¸°",
+ "greentext": "밈 í™”ė‚´í‘œ",
+ "show_yous": "\"(ë‹šė‹ )\" ëŗ´ė´ę¸°",
+ "notification_setting_filters": "필터",
+ "more_settings": "ėļ”ę°€ 네렕",
+ "user_popover_avatar_action_open": "프로필 뗴揰",
+ "version": {
+ "frontend_version": "í”„ëĄ íŠ¸ė—”ë“œ ë˛„ė „",
+ "title": "ë˛„ė „",
+ "backend_version": "ë°ąė—”ë“œ ë˛„ė „"
+ },
+ "fun": "ėĻę˛ë‹¤",
+ "domain_mutes": "ë„ëŠ”ė¸",
+ "third_column_mode": "ęŗĩę°„ė´ ėļŠëļ„하늴, 넏 ë˛ˆė§¸ ėģŦëŸŧ ėą„ėš°ę¸°",
+ "third_column_mode_none": "넏 ë˛ˆė§¸ ėģŦëŸŧ ė•ˆ ëŗ´ę¸°",
+ "third_column_mode_notifications": "ė•ŒëĻŧ ėģŦëŸŧ",
+ "third_column_mode_postform": "ę˛Œė‹œëŦŧ íŽ¸ė§‘ė°Ŋęŗŧ ë‚´ëš„ę˛Œė´ė…˜",
+ "columns": "ėģŦëŸŧ",
+ "column_sizes": "ėģŦëŸŧ íŦ기",
+ "column_sizes_sidebar": "ė‚Ŧė´ë“œë°”",
+ "column_sizes_content": "ë‚´ėšŠ",
+ "column_sizes_notifs": "ė•ŒëĻŧ",
+ "tree_advanced": "트ëĻŦ ëˇ°ė—ė„œ 더 ėœ ė—°í•œ íƒėƒ‰ė„ í—ˆėšŠ",
+ "tree_fade_ancestors": "현ėžŦ ę˛Œė‹œëŦŧëŗ´ë‹¤ ėƒë‹¨ė˜ ę˛Œė‹œëŦŧë“¤ė„ 흐ëϰ í…ėŠ¤íŠ¸ëĄœ í‘œė‹œ",
+ "conversation_display_linear": "ė„ í˜•",
+ "conversation_display_linear_quick": "ė„ í˜• 롰",
+ "conversation_other_replies_button": "\"ë‹ĩ글 더 ëŗ´ę¸°\" 버íŠŧė„",
+ "conversation_other_replies_button_below": "ę˛Œė‹œëŦŧ ė•„ëž˜ė— 놓기",
+ "max_depth_in_thread": "ę¸°ëŗ¸ė ėœŧ로 ëŗ´ėŧ ėĩœëŒ€ ęšŠė´",
+ "user_popover_avatar_action": "프로필 ėš´ë“œė˜ ė‚Ŧė§„ 클ëĻ­ ė‹œ",
+ "user_popover_avatar_action_close": "ėš´ë“œ ë‹Ģ기",
+ "user_popover_avatar_overlay": "프로필 ėš´ë“œëĨŧ 프로필 ė‚Ŧė§„ ėœ„ė— ë„ėš°ę¸°",
+ "post_status_content_type": "ę˛Œė‹œëŦŧ ë‚´ėšŠ í˜•ė‹",
+ "list_aliases_error": "ëŗ„ėš­ė„ ę°€ė ¸ė˜¤ëŠ” 뤑 뗐ëŸŦ ë°œėƒ: {error}",
+ "add_alias_error": "ëŗ„ėš­ė„ ėļ”ę°€í•˜ëŠ” 뤑 뗐ëŸŦ ë°œėƒ: {error}",
+ "mention_link_show_avatar_quick": "ëŠ˜ė…˜ ė˜†ė— ėœ ė € 프로필 ė‚Ŧė§„ė„ ëŗ´ėž„"
},
"timeline": {
"collapse": "렑揰",
"conversation": "대화",
"error_fetching": "ė—…ë°ė´íŠ¸ ëļˆëŸŦ똤揰 ė‹¤íŒ¨",
- "load_older": "더 ė˜¤ëž˜ 된 ę˛Œė‹œëŦŧ ëļˆëŸŦ똤揰",
- "no_retweet_hint": "íŒ”ëĄœė›Œ ė „ėšŠ, ë‹¤ė´ë ‰íŠ¸ ëŠ”ė‹œė§€ëŠ” 반ëŗĩ할 눘 ė—†ėŠĩ니다",
- "repeated": "반ëŗĩ 됨",
- "show_new": "ėƒˆëĄœėš´ 것 ëŗ´ę¸°",
- "up_to_date": "ėĩœė‹  ėƒíƒœ"
+ "load_older": "ė´ė „ ę˛Œė‹œëŦŧ ëļˆëŸŦ똤揰",
+ "no_retweet_hint": "íŒ”ëĄœė›Œ ė „ėšŠ ę˛Œė‹œëŦŧęŗŧ ë‹¤ė´ë ‰íŠ¸ ëŠ”ė‹œė§€ëŠ” ëĻŦ핏할 눘 ė—†ėŠĩ니다",
+ "repeated": "ëĻŦ핏함",
+ "show_new": "냈 ę˛Œė‹œëŦŧ ëŗ´ę¸°",
+ "up_to_date": "ėĩœė‹ ",
+ "error": "íƒ€ėž„ëŧė¸ė„ ę°€ė ¸ė˜¤ė§€ ëĒģ했ėŠĩ니다: {0}",
+ "reload": "ėƒˆëĄœęŗ ėš¨",
+ "no_statuses": "ę˛Œė‹œëŦŧ ė—†ėŒ",
+ "no_more_statuses": "냈 ę˛Œė‹œëŦŧ ė—†ėŒ",
+ "socket_reconnected": "ė‹¤ė‹œę°„ 뗰枰 됨",
+ "socket_broke": "ė‹¤ė‹œę°„ ė—°ę˛°ė´ ëŠė–´ė§: CloseEvent ėŊ”드 {0}",
+ "quick_filter_settings": "ëš ëĨ¸ 필터 네렕"
},
"user_card": {
"approve": "ėŠšė¸",
@@ -426,22 +677,70 @@
"blocked": "ė°¨ë‹¨ 됨!",
"deny": "ęą°ëļ€",
"follow": "íŒ”ëĄœėš°",
- "follow_sent": "ėš”ė˛­ ëŗ´ë‚´ė§!",
+ "follow_sent": "ėš”ė˛­ ëŗ´ëƒ„!",
"follow_progress": "ėš”ė˛­ 뤑â€Ļ",
- "follow_unfollow": "íŒ”ëĄœėš° 뤑맀",
+ "follow_unfollow": "ė–¸íŒ”ëĄœėš°",
"followees": "íŒ”ëĄœėš° 뤑",
"followers": "íŒ”ëĄœė›Œ",
"following": "íŒ”ëĄœėš° 뤑!",
- "follows_you": "ë‹šė‹ ė„ íŒ”ëĄœėš° 합니다!",
+ "follows_you": "나ëĨŧ íŒ”ëĄœėš° 합니다!",
"its_you": "ë‹šė‹ ėž…ë‹ˆë‹¤!",
- "mute": "ėš¨ëŦĩ",
- "muted": "ėš¨ëŦĩ 됨",
- "per_day": "/ í•˜ëŖ¨",
- "remote_follow": "ė›ę˛Š íŒ”ëĄœėš°",
- "statuses": "ę˛Œė‹œëŦŧ"
+ "mute": "뮤트",
+ "muted": "뮤트 됨",
+ "per_day": "氜 / ėŧ",
+ "remote_follow": "다ëĨ¸ ė¸ėŠ¤í„´ėŠ¤ė—ė„œ íŒ”ëĄœėš°",
+ "statuses": "ę˛Œė‹œëŦŧ",
+ "unmute_progress": "뮤트 í•´ė œ 뤑â€Ļ",
+ "unblock_progress": "ė°¨ë‹¨ í•´ė œ 뤑â€Ļ",
+ "admin_menu": {
+ "revoke_moderator": "뤑ėžŦėž 탄í•ĩ",
+ "sandbox": "ę˛Œė‹œëŦŧ ęŗĩ氜 ë˛”ėœ„ëĨŧ íŒ”ëĄœė›Œ ė „ėšŠėœŧ로 ę°•ė œ",
+ "disable_any_subscription": "누ęĩŦ도 íŒ”ëĄœėš°ëĨŧ ëĒģ하도록 막기",
+ "delete_user_data_and_deactivate_confirmation": "똁ęĩŦ렁ėœŧ로 ė´ ęŗ„ė •ė˜ ë°ė´í„°ę°€ ė‚­ė œë˜ęŗ  ëš„í™œė„ąí™” 됩니다. ė •ë§ëĄœ ę´œė°Žę˛ ėŠĩ니까?",
+ "moderation": "관ëĻŦ",
+ "grant_admin": "관ëĻŦėžëĄœ ėž„ëĒ…",
+ "grant_moderator": "뤑ėžŦėžëĄœ ėž„ëĒ…",
+ "disable_remote_subscription": "다ëĨ¸ ė¸ėŠ¤í„´ėŠ¤ė—ė„œ íŒ”ëĄœėš°í•˜ė§€ ëĒģ하도록 막기",
+ "activate_account": "ęŗ„ė • í™œė„ąí™”",
+ "deactivate_account": "ęŗ„ė • ëš„í™œė„ąí™”",
+ "delete_account": "ęŗ„ė • ė‚­ė œ",
+ "force_nsfw": "ëĒ¨ë“  ę˛Œė‹œëŦŧė„ ë¯ŧ감한 ë‚´ėšŠėœŧ로 í‘œė‹œ",
+ "strip_media": "ę˛Œė‹œëŦŧė—ė„œ ë¯¸ë””ė–´ ė œęą°",
+ "revoke_admin": "관ëĻŦėž 탄í•ĩ",
+ "force_unlisted": "ę˛Œė‹œëŦŧ ęŗĩ氜 ë˛”ėœ„ëĨŧ ëš„í‘œė‹œëĄœ ę°•ė œ",
+ "quarantine": "ė—°í•Š íƒ€ėž„ëŧė¸ė—ė„œ ė‚ŦėšŠėž ę˛Œė‹œëŦŧ ëš„í—ˆėšŠ",
+ "delete_user": "ė‚ŦėšŠėž ė‚­ė œ"
+ },
+ "deactivated": "ëš„í™œė„ąí™”ë¨",
+ "edit_profile": "프로필 íŽ¸ė§‘",
+ "favorites": "관ė‹Ŧ글",
+ "follow_cancel": "íŒ”ëĄœėš° ėš”ė˛­ ėˇ¨ė†Œ",
+ "unmute": "뮤트 í•´ė œ",
+ "mute_progress": "뮤트 뤑â€Ļ",
+ "hidden": "눍枍말",
+ "media": "ë¯¸ë””ė–´",
+ "mention": "ëŠ˜ė…˜",
+ "message": "ëŠ”ė‹œė§€",
+ "remove_follower": "íŒ”ëĄœė›Œ ė‚­ė œ",
+ "report": "ė‹ ęŗ ",
+ "subscribe": "ęĩŦ독",
+ "unsubscribe": "ęĩŦ독 í•´ė œ",
+ "unblock": "ė°¨ë‹¨ í•´ė œ",
+ "block_progress": "ė°¨ë‹¨ 뤑â€Ļ",
+ "hide_repeats": "ëĻŦ핏 눍揰揰",
+ "show_repeats": "ëĻŦ핏 ëŗ´ę¸°",
+ "bot": "봇",
+ "highlight": {
+ "disabled": "ę°•ėĄ° í‘œė‹œ ė—†ėŒ",
+ "striped": "뤄ëŦ´ëŠŦ ë°°ę˛Ŋ",
+ "solid": "ë‹¨ėƒ‰ ë°°ę˛Ŋ",
+ "side": "ė˜†íŠ¸ėž„"
+ }
},
"user_profile": {
- "timeline_title": "ė‚ŦėšŠėž íƒ€ėž„ëŧė¸"
+ "timeline_title": "ė‚ŦėšŠėž íƒ€ėž„ëŧė¸",
+ "profile_does_not_exist": "ėŖ„ė†Ąí•˜ė§€ë§Œ, ė´ í”„ëĄœí•„ė€ ėĄ´ėžŦí•˜ė§€ ė•ŠėŠĩ니다.",
+ "profile_loading_error": "ėŖ„ė†Ąí•˜ė§€ë§Œ, í”„ëĄœí•„ė„ ëļˆëŸŦė˜¤ëŠ” 데 뗐ëŸŦ가 ë°œėƒí–ˆėŠĩ니다."
},
"who_to_follow": {
"more": "더 ëŗ´ę¸°",
@@ -449,38 +748,60 @@
},
"tool_tip": {
"media_upload": "ë¯¸ë””ė–´ ė—…ëĄœë“œ",
- "repeat": "반ëŗĩ",
+ "repeat": "ëĻŦ핏",
"reply": "ë‹ĩ글",
- "favorite": "ėĻę˛¨ė°žę¸°",
- "user_settings": "ė‚ŦėšŠėž 네렕"
+ "favorite": "관ė‹Ŧ글",
+ "user_settings": "ė‚ŦėšŠėž 네렕",
+ "add_reaction": "ë°˜ė‘ ėļ”ę°€",
+ "accept_follow_request": "íŒ”ëĄœėš° ėš”ė˛­ ėŠšė¸",
+ "reject_follow_request": "íŒ”ëĄœėš° ėš”ė˛­ ęą°ė ˆ",
+ "bookmark": "ëļë§ˆíŦ"
},
"upload": {
"error": {
"base": "ė—…ëĄœë“œ ė‹¤íŒ¨.",
"file_too_big": "파ėŧė´ 너ëŦ´ ėģ¤ėš” [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
- "default": "ėž ė‹œ í›„ė— ë‹¤ė‹œ ė‹œë„í•´ ëŗ´ė„¸ėš”"
+ "default": "ėž ė‹œ í›„ė— ë‹¤ė‹œ ė‹œë„í•´ ëŗ´ė„¸ėš”",
+ "message": "ė—…ëĄœë“œ ė‹¤íŒ¨: {0}"
},
"file_size_units": {
- "B": "ë°”ė´íŠ¸",
- "KiB": "í‚¤ëš„ë°”ė´íŠ¸",
- "MiB": "ëŠ”ëš„ë°”ė´íŠ¸",
- "GiB": "ę¸°ëš„ë°”ė´íŠ¸",
- "TiB": "í…Œëš„ë°”ė´íŠ¸"
+ "B": "B",
+ "KiB": "KiB",
+ "MiB": "MiB",
+ "GiB": "GiB",
+ "TiB": "TiB"
}
},
"interactions": {
"follows": "냈 íŒ”ëĄœė›Œ",
- "favs_repeats": "반ëŗĩęŗŧ ėĻę˛¨ė°žę¸°",
- "moves": "ęŗ„ė • í†ĩ합"
+ "favs_repeats": "ëĻŦ핏ęŗŧ 관ė‹Ŧ",
+ "moves": "ęŗ„ė • ė´ë™",
+ "emoji_reactions": "뗐ëǍ맀 ë°˜ė‘",
+ "reports": "ė‹ ęŗ ",
+ "load_older": "ė´ė „ ë°˜ė‘ ëļˆëŸŦ똤揰"
},
"emoji": {
- "load_all": "렄랴 {emojiAmount} ė´ëǍ맀 ëļˆëŸŦ똤揰",
- "unicode": "Unicode ė´ëǍ맀",
- "custom": "ė „ėšŠ ė´ëǍ맀",
- "add_emoji": "ė´ëǍ맀 ë„Ŗę¸°",
- "search_emoji": "ė´ëǍ맀 ę˛€ėƒ‰",
- "emoji": "ė´ëǍ맀",
- "stickers": "ėŠ¤í‹°ėģ¤"
+ "load_all": "렄랴 {emojiAmount}ę°œė˜ 뗐ëǍ맀 ëļˆëŸŦ똤揰",
+ "unicode": "Unicode 뗐ëǍ맀",
+ "custom": "ė „ėšŠ 뗐ëǍ맀",
+ "add_emoji": "뗐ëǍ맀 ë„Ŗę¸°",
+ "search_emoji": "뗐ëǍ맀 ę˛€ėƒ‰",
+ "emoji": "뗐ëǍ맀",
+ "stickers": "ėŠ¤í‹°ėģ¤",
+ "load_all_hint": "ė˛Ģ {saneAmount}ę°œė˜ 뗐ëǍ맀ëĨŧ ëļˆëŸŦė™”ėŠĩ니다, 뗐ëǍ맀ëĨŧ ė „ëļ€ ëļˆëŸŦė˜¤ëŠ´ ė„ąëŠĨ ė €í•˜ę°€ ėžˆė„ 눘 ėžˆėŠĩ니다.",
+ "unicode_groups": {
+ "people-and-body": "ė‚Ŧ람 & ëǏ",
+ "smileys-and-emotion": "ė›ƒëŠ” ė–ŧęĩ´ & 氐렕",
+ "travel-and-places": "ė—Ŧ행 & ėžĨė†Œ",
+ "activities": "활동",
+ "animals-and-nature": "동ëŦŧ & ėžė—°",
+ "flags": "깃발",
+ "food-and-drink": "ėŒė‹ & ėŒëŖŒ",
+ "objects": "ė‚ŦëŦŧ",
+ "symbols": "기호"
+ },
+ "keep_open": "ė—´ëϰ ėą„ëĄœ 두기",
+ "regional_indicator": "맀뗭 í‘œė‹œę¸° {letter}"
},
"polls": {
"add_poll": "íˆŦ표ëĨŧ ėļ”ę°€",
@@ -491,11 +812,18 @@
"votes_count": "{count} 표 | {count} 표",
"people_voted_count": "{count} ëĒ… íˆŦ표 | {count} ëĒ… íˆŦ표",
"option": "ė„ íƒė§€",
- "add_option": "ė„ íƒė§€ ėļ”ę°€"
+ "add_option": "ė„ íƒė§€ ėļ”ę°€",
+ "expired": "íˆŦ표는 {0} 렄뗐 ë§ˆę°ë˜ė—ˆėŠĩ니다",
+ "expires_in": "íˆŦ표는 {0}뗐 마감됩니다",
+ "single_choice": "하나만 ė„ íƒ",
+ "multiple_choices": "ė—ŦëŸŦ 氜 ė„ íƒ",
+ "not_enough_options": "ė„ íƒė§€ę°€ 너ëŦ´ 렁ėŠĩ니다"
},
"media_modal": {
"next": "ë‹¤ėŒ",
- "previous": "ė´ė „"
+ "previous": "ė´ė „",
+ "counter": "{current} / {total}",
+ "hide": "ë¯¸ë””ė–´ ëˇ°ė–´ ë‹Ģ기"
},
"importer": {
"error": "ė´ 파ėŧė„ 氀렏ė˜Ŧ 때 똤ëĨ˜ę°€ ë°œėƒí•˜ė˜€ėŠĩ니다.",
@@ -509,14 +837,14 @@
"crop_picture": "ė‚Ŧė§„ ėžëĨ´ę¸°"
},
"exporter": {
- "processing": "래ëĻŦė¤‘ėž…ë‹ˆë‹¤, 래ëĻŦ가 끝나면 파ėŧė„ ë‹¤ėš´ëĄœë“œí•˜ëŧ는 ė§€ė‹œę°€ ėžˆę˛ ėŠĩ니다",
+ "processing": "래ëĻŦė¤‘ėž…ë‹ˆë‹¤, ęŗ§ 파ėŧė„ ë‹¤ėš´ëĄœë“œí•  눘 ėžˆėŠĩ니다",
"export": "ë‚´ëŗ´ë‚´ę¸°"
},
"domain_mute_card": {
- "unmute_progress": "ėš¨ëŦĩė„ í•´ė œė¤‘â€Ļ",
- "unmute": "ėš¨ëŦĩ í•´ė œ",
- "mute_progress": "ėš¨ëŦĩėœŧ로 네렕뤑â€Ļ",
- "mute": "ėš¨ëŦĩ"
+ "unmute_progress": "뮤트 í•´ė œ 뤑â€Ļ",
+ "unmute": "뮤트 í•´ė œ",
+ "mute_progress": "뮤트 뤑â€Ļ",
+ "mute": "뮤트"
},
"about": {
"staff": "ėš´ė˜ėž",
@@ -534,21 +862,25 @@
"accept_desc": "ė´ ė¸ėŠ¤í„´ėŠ¤ė—ė„œëŠ” ė•„ëž˜ė˜ ė¸ėŠ¤í„´ėŠ¤ëĄœëļ€í„° ëŗ´ë‚´ė˜¨ íˆŦęŗ ë§Œė´ ė ‘ėˆ˜ëŠë‹ˆë‹¤:",
"reject": "ęą°ëļ€",
"accept": "허가",
- "simple_policies": "ė¸ėŠ¤í„´ėŠ¤ íŠšėœ ė˜ 폴ëĻŦė‹œ"
+ "simple_policies": "ė¸ėŠ¤í„´ėŠ¤ íŠšėœ ė˜ ė •ėą…",
+ "instance": "ė¸ėŠ¤í„´ėŠ¤",
+ "reason": "ė‚Ŧ뜠",
+ "not_applicable": "ė—†ėŒ"
},
- "mrf_policies": "ė‚ŦėšŠë˜ëŠ” MRF 폴ëĻŦė‹œ",
+ "mrf_policies": "ė‚ŦėšŠë˜ëŠ” MRF ė •ėą…",
"keyword": {
"is_replaced_by": "→",
"replace": "바꾸기",
"reject": "ęą°ëļ€",
"ftl_removal": "\"ė•Œë ¤ė§„ ëĒ¨ë“  ë„¤íŠ¸ė›ŒíŦ\" íƒ€ėž„ëŧė¸ė—ė„œ ė œė™¸",
- "keyword_policies": "ë‹¨ė–´ 폴ëĻŦė‹œ"
+ "keyword_policies": "ë‹¨ė–´ ė •ėą…"
},
- "federation": "ė—°í•Š"
+ "federation": "ė—°í•Š",
+ "mrf_policies_desc": "MRF ė •ėą…ė€ ė´ ė¸ėŠ¤í„´ėŠ¤ė˜ íŽ˜ë”ë ˆė´ė…˜ ë™ėž‘ė„ ė œė–´í•˜ęŗ  ėžˆėŠĩ니다. ė ėšŠë˜ęŗ  ėžˆëŠ” ė •ėą…ė€ ë‹¤ėŒęŗŧ 같ėŠĩ니다:"
}
},
"shoutbox": {
- "title": "Shoutbox"
+ "title": "ė™¸ėš˜ę¸°"
},
"time": {
"years_short": "{0} 년",
@@ -563,8 +895,8 @@
"second_short": "{0} 봈",
"seconds": "{0} 봈",
"second": "{0} 봈",
- "now_short": "밊금",
- "now": "방끔",
+ "now_short": "ė§€ę¸ˆ",
+ "now": "밊금",
"months_short": "{0} ë‹Ŧ ė „",
"month_short": "{0} ë‹Ŧ ė „",
"months": "{0} ë‹Ŧ ė „",
@@ -581,13 +913,205 @@
"days_short": "{0} ėŧ",
"day_short": "{0} ėŧ",
"days": "{0} ėŧ",
- "day": "{0} ėŧ"
+ "day": "{0} ėŧ",
+ "unit": {
+ "weeks": "{0}ėŖŧ | {0}ėŖŧ",
+ "minutes": "{0}ëļ„ | {0}ëļ„",
+ "seconds": "{0}봈 | {0}봈",
+ "seconds_short": "{0}봈",
+ "weeks_short": "{0}ėŖŧ",
+ "years": "{0}년 | {0}년",
+ "years_short": "{0}년",
+ "days": "{0}ėŧ | {0}ėŧ",
+ "days_short": "{0}ėŧ",
+ "hours": "{0}ė‹œę°„ | {0}ė‹œę°„",
+ "hours_short": "{0}ė‹œę°„",
+ "minutes_short": "{0}ëļ„",
+ "months": "{0}ë‹Ŧ | {0}ë‹Ŧ",
+ "months_short": "{0}ë‹Ŧ"
+ },
+ "in_future": "{0} 후"
},
"remote_user_resolver": {
"error": "ė°žė„ 눘 ė—†ėŠĩ니다.",
- "searching_for": "ę˛€ėƒ‰ė¤‘"
+ "searching_for": "ę˛€ėƒ‰:",
+ "remote_user_resolver": "다ëĨ¸ ė¸ėŠ¤í„´ėŠ¤ ė‚ŦėšŠėž ė•ˆë‚´ę¸°"
},
"selectable_list": {
"select_all": "ëĒ¨ë‘ ė„ íƒ"
+ },
+ "lists": {
+ "title": "ëĻŦėŠ¤íŠ¸ 렜ëĒŠ",
+ "search": "ė‚ŦėšŠėž ę˛€ėƒ‰í•˜ę¸°",
+ "lists": "ëĻŦėŠ¤íŠ¸",
+ "new": "ëĻŦėŠ¤íŠ¸ 만들기",
+ "create": "만들기",
+ "delete": "ëĻŦėŠ¤íŠ¸ ė‚­ė œ",
+ "following_only": "íŒ”ëĄœėš° ė¤‘ė¸ ė‚Ŧ람들만",
+ "manage_lists": "ëĻŦėŠ¤íŠ¸ 관ëĻŦ",
+ "manage_members": "멤버 관ëĻŦ",
+ "remove_from_list": "ëĻŦėŠ¤íŠ¸ė—ė„œ ė œęą°",
+ "add_to_list": "ëĻŦėŠ¤íŠ¸ė— ėļ”ę°€",
+ "is_in_list": "ëĻŦėŠ¤íŠ¸ė— ė´ë¯¸ ėžˆėŒ",
+ "editing_list": "{listTitle} ëĻŦėŠ¤íŠ¸ íŽ¸ė§‘",
+ "update_title": "렜ëĒŠ ė €ėžĨ",
+ "really_delete": "ëĻŦėŠ¤íŠ¸ëĨŧ ė‚­ė œí•˜ė‹œę˛ ė–´ėš”?",
+ "save": "ëŗ€ę˛Ŋ ė‚Ŧí•­ė„ ė €ėžĨ",
+ "creating_list": "냈 ëĻŦėŠ¤íŠ¸ 만들기",
+ "add_members": "ė‚ŦėšŠėž ėļ”ę°€",
+ "error": "ëĻŦėŠ¤íŠ¸ëĨŧ ėĄ°ėž‘í•˜ëŠ” 데 똤ëĨ˜ę°€ ë°œėƒí–ˆėŠĩ니다: {0}"
+ },
+ "search": {
+ "no_more_results": "결ęŗŧ 더 ė—†ėŒ",
+ "load_more": "결ęŗŧ 더 ëļˆëŸŦ똤揰",
+ "people": "ė‚Ŧ람",
+ "hashtags": "í•´ė‹œíƒœęˇ¸",
+ "person_talking": "{count}ëĒ…ė´ 말하는 뤑",
+ "people_talking": "{count}ëĒ…ė´ 말하는 뤑",
+ "no_results": "결ęŗŧ ė—†ėŒ"
+ },
+ "password_reset": {
+ "forgot_password": "íŒ¨ėŠ¤ė›Œë“œëĨŧ ėžŠėœŧė…¨ë‚˜ėš”?",
+ "password_reset": "íŒ¨ėŠ¤ė›Œë“œ ėžŦ네렕",
+ "placeholder": "ė´ëŠ”ėŧ ėŖŧė†Œ 또는 ė‚ŦėšŠėž ė´ëĻ„",
+ "password_reset_required_but_mailer_is_disabled": "íŒ¨ėŠ¤ė›Œë“œ ė´ˆę¸°í™”ëĨŧ í•˜ė…”ė•ŧ í•˜ė§€ë§Œ, ëĒģ 하게 막혀 ėžˆėŠĩ니다. ė¸ėŠ¤í„´ėŠ¤ 관ëĻŦėžė—ę˛Œ ëŦ¸ė˜í•´ėŖŧė„¸ėš”.",
+ "check_email": "íŒ¨ėŠ¤ė›Œë“œ ė´ˆę¸°í™”ëĨŧ ėœ„í•´ ė´ëŠ”ėŧė„ í™•ė¸í•´ėŖŧė„¸ėš”.",
+ "return_home": "홈ėœŧ로 ëŒė•„ę°€ę¸°",
+ "password_reset_required": "ëĄœęˇ¸ė¸í•˜ë ¤ëŠ´ íŒ¨ėŠ¤ė›Œë“œëĨŧ ė´ˆę¸°í™”í•´ė•ŧ 합니다.",
+ "password_reset_disabled": "íŒ¨ėŠ¤ė›Œë“œ ė´ˆę¸°í™”ëĨŧ ëĒģ 하게 ë˜ė–´ ėžˆėŠĩ니다. ė¸ėŠ¤í„´ėŠ¤ 관ëĻŦėžė—ę˛Œ ëŦ¸ė˜í•´ėŖŧė„¸ėš”.",
+ "instruction": "ė´ëŠ”ėŧ ėŖŧė†Œ 또는 ė‚ŦėšŠėž ė´ëĻ„ė„ ėž…ë Ĩí•˜ė„¸ėš”. íŒ¨ėŠ¤ė›Œë“œ ė´ˆę¸°í™” 링íŦëĨŧ 메ėŧ로 ëŗ´ë‚´ë“œëĻŊ니다.",
+ "too_many_requests": "너ëŦ´ ë§Žė€ ė‹œë„ëĨŧ 했ėŠĩ니다, ë‚˜ė¤‘ė— ë‹¤ė‹œ 해ėŖŧė„¸ėš”."
+ },
+ "chats": {
+ "you": "ë‹šė‹ :",
+ "delete": "ė‚­ė œ",
+ "new": "냈 ėą„íŒ…",
+ "chats": "ėą„íŒ…",
+ "empty_message_error": "ëŠ”ė‹œė§€ę°€ ëš„ė–´ ėžˆėŠĩ니다",
+ "more": "더 ëŗ´ę¸°",
+ "error_loading_chat": "ė™œė¸ė§„ ëǍëĨ´ę˛ ëŠ”ë° ėą„íŒ…ė„ ëļˆëŸŦė˜¤ė§€ ëĒģ했ėŠĩ니다.",
+ "error_sending_message": "ė™œė¸ė§„ ëǍëĨ´ę˛ ëŠ”ë° ëŠ”ė‹œė§€ëĨŧ ė „ė†Ąí•˜ė§€ ëĒģ했ėŠĩ니다.",
+ "delete_confirm": "ė´ ëŠ”ė‹œė§€ëĨŧ ė •ë§ ė§€ėš¸ęšŒėš”?",
+ "empty_chat_list_placeholder": "ėą„íŒ…ė´ ė—†ë„¤ėš”. 냈 ėą„íŒ…ė„ ė‹œėž‘í•´ëŗ´ė„¸ėš”!",
+ "message_user": "{nickname}ė—ę˛Œ ëŠ”ė‹œė§€"
+ },
+ "file_type": {
+ "audio": "ė˜¤ë””ė˜¤",
+ "video": "똁냁",
+ "image": "ė‚Ŧė§„",
+ "file": "파ėŧ"
+ },
+ "display_date": {
+ "today": "ė˜¤ëŠ˜"
+ },
+ "update": {
+ "big_update_title": "ė–‘í•´í•´ėŖŧė„¸ėš”",
+ "update_bugs_gitlab": "Pleroma GitLab",
+ "update_changelog_here": "ëŗ€ę˛Ŋ ë‚´ė—­",
+ "update_changelog": "ëŦ´ė—‡ė´ ë°”ë€Œė—ˆëŠ”ė§€ ėžė„¸ížˆ ė•Œė•„ëŗ´ė‹œë ¤ëŠ´, {theFullChangelog}ė„ ė°¸ėĄ°í•˜ė„¸ėš”.",
+ "big_update_content": "ė €íŦ가 í•œë™ė•ˆ ëĻ´ëĻŦėψëĨŧ ė•ˆ í•´ė„œ, ėĩėˆ™í•˜ė…¨ë˜ ėƒęš€ėƒˆë‚˜ ę˛Ŋ험ęŗŧ ë§Žė´ ë‹ŦëŧėĄŒė„ 눘 ėžˆėŠĩ니다.",
+ "update_bugs": "ė €íŦ가 비록 í…ŒėŠ¤íŠ¸ëĨŧ ë§Žė´ í•˜ęŗ  링렑 개발 ë˛„ė „ė„ ė“°ę¸°ë„ í•˜ė§€ë§Œ, ë§Žė´ 바꾸기도 í–ˆęŗ , ëLJëLJ 氀맀 ë†“ėšœ ė ë“¤ė´ ėžˆė„ í„°ė´ë‹ˆ, ė‚ŦėšŠí•˜ëŠ´ė„œ ëļˆíŽ¸í•œ ė ė´ë‚˜ ëŦ¸ė œëŠ” {pleromaGitlab}뗐 ė œëŗ´í•´ėŖŧė‹œëŠ´ 감ė‚Ŧ하겠ėŠĩ니다. ė €íŦ는 ę˛Ēėœŧė‹  ëŦ¸ė œė ė´ë‚˜ Pleroma뙀 Pleroma-FE뗐 대한 í”ŧ드백ęŗŧ ė œė•ˆė„ í™˜ė˜í•Šë‹ˆë‹¤."
+ },
+ "unicode_domain_indicator": {
+ "tooltip": "ė´ ë„ëŠ”ė¸ė€ ė•„ėŠ¤í‚¤ ëŦ¸ėžę°€ ė•„ë‹Œ ëŦ¸ėžëĨŧ íŦí•¨í•˜ęŗ  ėžˆėŠĩ니다."
+ },
+ "status": {
+ "mute_conversation": "대화 뮤트",
+ "thread_muted_and_words": ", ë‹¨ė–´ íŦ함:",
+ "unpin": "í”„ëĄœí•„ė—ė„œ ęŗ ė • í•´ė œ",
+ "replies_list_with_others": "ë‹ĩ글 (+{numReplies}氜): | ë‹ĩ글 (+{numReplies}氜):",
+ "show_attachment_in_modal": "ë¯¸ë””ė–´ ëǍë‹Ŧė—ė„œ ëŗ´ę¸°",
+ "thread_hide": "ė´ ėŠ¤ë ˆë“œ 눍揰揰",
+ "show_attachment_description": "네ëĒ… 미ëĻŦëŗ´ę¸° (랍ëļ€ëŦŧė„ ė—´ė–´ė„œ 렄랴 네ëĒ… ëŗ´ę¸°)",
+ "thread_show_full": "ė´ ėŠ¤ë ˆë“œëĨŧ ė „ëļ€ ë“¤ėļ°ëŗ´ę¸° (ė´ {numStatus}氜 ėžˆėŒ, ėĩœëŒ€ ęšŠė´ {depth}) | ė´ ėŠ¤ë ˆë“œëĨŧ ė „ëļ€ ë“¤ėļ°ëŗ´ę¸° (ė´ {numStatus}氜 ėžˆėŒ, ėĩœëŒ€ ęšŠė´ {depth})",
+ "thread_follow": "ė´ ėŠ¤ë ˆë“œė˜ ë‚˜ë¨¸ė§€ ëļ€ëļ„ ëŗ´ę¸° (ė´ {numStatus}氜) | ė´ ėŠ¤ë ˆë“œė˜ ë‚˜ë¨¸ė§€ ëļ€ëļ„ ëŗ´ę¸° (ė´ {numStatus}氜)",
+ "status_history": "ę˛Œė‹œëŦŧ ė´ë Ĩ",
+ "show_all_conversation": "렄랴 대화 ëŗ´ę¸° ({numStatus}氜 더 ėžˆėŒ) | 렄랴 대화 ëŗ´ę¸° ({numStatus}氜 더 ėžˆėŒ)",
+ "repeats": "ëĻŦ핏",
+ "delete": "ė‚­ė œ",
+ "edit": "ėˆ˜ė •",
+ "favorites": "관ė‹Ŧ글",
+ "edited_at": "({time}뗐 ë§ˆė§€ë§‰ėœŧ로 ėˆ˜ė •ë¨)",
+ "pin": "í”„ëĄœí•„ė— ęŗ ė •",
+ "pinned": "ęŗ ė •ë¨",
+ "bookmark": "ëļë§ˆíŦ",
+ "unbookmark": "ëļë§ˆíŦ í•´ė œ",
+ "delete_confirm": "ė •ë§ ė§€ėš°ė‹œę˛ ė–´ėš”?",
+ "reply_to": "ë‹ĩ글",
+ "mentions": "ëŠ˜ė…˜",
+ "replies_list": "ë‹ĩ글:",
+ "unmute_conversation": "대화 뮤트 í•´ė œ",
+ "thread_muted": "ėŠ¤ë ˆë“œ 뮤트됨",
+ "status_unavailable": "ę˛Œė‹œëŦŧ ė ‘ęˇŧ ëļˆę°€",
+ "copy_link": "ę˛Œė‹œëŦŧ 링íŦ ëŗĩė‚Ŧ",
+ "external_source": "ė›ëŗ¸ íŽ˜ė´ė§€",
+ "show_full_subject": "렄랴 렜ëĒŠ ëŗ´ę¸°",
+ "hide_full_subject": "렄랴 렜ëĒŠ 눍揰揰",
+ "show_content": "ë‚´ėšŠ ëŗ´ę¸°",
+ "hide_content": "ë‚´ėšŠ 눍揰揰",
+ "status_deleted": "ė§€ė›Œė§„ ę˛Œė‹œëŦŧėž…ë‹ˆë‹¤",
+ "nsfw": "ë¯ŧ감한 ë‚´ėšŠ",
+ "expand": "íŽŧėš˜ę¸°",
+ "you": "(ë‹šė‹ )",
+ "plus_more": "+{number}氜 더 ėžˆėŒ",
+ "many_attachments": "{number}ę°œė˜ 랍ëļ€ëŦŧė„ 氀말",
+ "show_all_attachments": "랍ëļ€ëŦŧ ė „ëļ€ ëŗ´ė´ę¸°",
+ "hide_attachment": "랍ëļ€ëŦŧ 눍揰揰",
+ "collapse_attachments": "랍ëļ€ëŦŧ 렑揰",
+ "remove_attachment": "랍ëļ€ëŦŧ ė§€ėš°ę¸°",
+ "attachment_stop_flash": "í”Œëž˜ė‹œ í”Œë ˆė´ė–´ ė •ė§€",
+ "move_up": "랍ëļ€ëŦŧ ė™ŧėĒŊėœŧ로 밀기",
+ "move_down": "랍ëļ€ëŦŧ 똤ëĨ¸ėĒŊėœŧ로 밀기",
+ "open_gallery": "ę°¤ëŸŦëĻŦ 뗴揰",
+ "thread_show": "ė´ ėŠ¤ë ˆë“œ ëŗ´ė´ę¸°",
+ "thread_show_full_with_icon": "{icon} {text}",
+ "thread_follow_with_icon": "{icon} {text}",
+ "ancestor_follow_with_icon": "{icon} {text}",
+ "show_all_conversation_with_icon": "{icon} {text}",
+ "ancestor_follow": "ė´ ę˛Œė‹œëŦŧ ė•„ëž˜ {numReplies}氜 ë‹ĩ글 더 ëŗ´ę¸° | ė´ ę˛Œė‹œëŦŧ ė•„ëž˜ {numReplies}氜 ë‹ĩ글 더 ëŗ´ę¸°",
+ "show_only_conversation_under_this": "ė´ ę˛Œė‹œëŦŧė˜ ë‹ĩ글만 ëŗ´ę¸°"
+ },
+ "errors": {
+ "storage_unavailable": "Pleroma가 브ëŧėš°ė € ė €ėžĨė†Œė— ė ‘ęˇŧ할 눘 ė—†ėŠĩ니다. ëĄœęˇ¸ė¸ė´ 풀ëĻŦ거나 로ėģŦ ė„¤ė •ė´ ė´ˆę¸°í™” 되는 등 ė˜ˆėƒėš˜ ëĒģ한 ëŦ¸ė œëĨŧ ę˛Ēė„ 눘 ėžˆėŠĩ니다. ėŋ í‚¤ëĨŧ í™œė„ąí™” í•´ëŗ´ė„¸ėš”."
+ },
+ "report": {
+ "reporter": "ė‹ ęŗ ėž:",
+ "reported_statuses": "ė‹ ęŗ ëœ ę˛Œė‹œëŦŧ:",
+ "notes": "기타:",
+ "state": "ėƒíƒœ:",
+ "state_open": "ė—´ëĻŧ",
+ "state_closed": "ë‹Ģ힘",
+ "reported_user": "ė‹ ęŗ ëœ ė‚ŦėšŠėž:",
+ "state_resolved": "해결됨"
+ },
+ "user_reporting": {
+ "title": "{0} ė‹ ęŗ ",
+ "add_comment_description": "ė´ ė‹ ęŗ ė„œëŠ” 내 ė¸ėŠ¤í„´ėŠ¤ė˜ 뤑ėžŦėžė—ę˛Œ ė „ë‹Ŧ됩니다. ė™œ ė´ ęŗ„ė •ė„ ė‹ ęŗ í•˜ë ¤ëŠ”ė§€ ėĸ€ 더 ėžė„¸ížˆ ė•Œë ¤ėŖŧė„¸ėš”:",
+ "additional_comments": "ėļ”ę°€ 네ëĒ…",
+ "forward_description": "ė´ ęŗ„ė •ė€ 다ëĨ¸ ė„œë˛„ė— ėžˆëŠ” ęŗ„ė •ėž…ë‹ˆë‹¤. ꡸ėĒŊėœŧ로도 ė‹ ęŗ ëĨŧ ëŗ´ë‚ŧęšŒėš”?",
+ "forward_to": "{0}로 ė „ë‹Ŧ하기",
+ "submit": "ė „ė†Ą",
+ "generic_error": "ėš”ė˛­ė„ 래ëĻŦ하는 뤑 똤ëĨ˜ę°€ ë°œėƒí–ˆėŠĩ니다."
+ },
+ "announcements": {
+ "end_time_prompt": "끝나는 ė‹œę°: ",
+ "page_header": "ęŗĩė§€ė‚Ŧ항",
+ "title": "ęŗĩė§€ė‚Ŧ항",
+ "mark_as_read_action": "ėŊėŒėœŧ로 í‘œė‹œ",
+ "post_form_header": "ęŗĩė§€ė‚Ŧ항 ėž‘ė„ą",
+ "post_placeholder": "ęŗĩė§€ė‚Ŧ항 ë‚´ėšŠė„ ėž‘ė„ąí•˜ė„¸ėš”...",
+ "post_error": "똤ëĨ˜: {error}",
+ "close_error": "ë‹Ģ기",
+ "delete_action": "ė‚­ė œ",
+ "post_action": "ę˛Œė‹œ",
+ "start_time_prompt": "ė‹œėž‘ ė‹œę°: ",
+ "all_day_prompt": "똍ėĸ…ėŧ ėžˆëŠ” ė´ë˛¤íŠ¸ėž…ë‹ˆë‹¤",
+ "published_time_display": "{time}뗐 ę˛Œė‹œí•¨",
+ "start_time_display": "{time}뗐 ė‹œėž‘í•¨",
+ "end_time_display": "{time}뗐 끝남",
+ "edit_action": "íŽ¸ė§‘",
+ "submit_edit_action": "ėˆ˜ė •ëŗ¸ ë°˜ė˜",
+ "cancel_edit_action": "ėˇ¨ė†Œ",
+ "inactive_message": "ė´ ęŗĩė§€ė‚Ŧí•­ė€ ëš„í™œė„ąí™” ë˜ė—ˆėŠĩ니다"
}
}
diff --git a/src/i18n/languages.js b/src/i18n/languages.js
new file mode 100644
index 00000000..250b3b1a
--- /dev/null
+++ b/src/i18n/languages.js
@@ -0,0 +1,53 @@
+
+const languages = [
+ 'ar',
+ 'ca',
+ 'cs',
+ 'de',
+ 'eo',
+ 'en',
+ 'es',
+ 'et',
+ 'eu',
+ 'fi',
+ 'fr',
+ 'ga',
+ 'he',
+ 'hu',
+ 'it',
+ 'ja',
+ 'ja_easy',
+ 'ko',
+ 'nb',
+ 'nl',
+ 'oc',
+ 'pl',
+ 'pt',
+ 'ro',
+ 'ru',
+ 'sk',
+ 'te',
+ 'uk',
+ 'zh',
+ 'zh_Hant'
+]
+
+const specialJsonName = {
+ ja: 'ja_pedantic'
+}
+
+const langCodeToJsonName = (code) => specialJsonName[code] || code
+
+const langCodeToCldrName = (code) => code
+
+const ensureFinalFallback = codes => {
+ const codeList = Array.isArray(codes) ? codes : [codes]
+ return codeList.includes('en') ? codeList : codeList.concat(['en'])
+}
+
+module.exports = {
+ languages,
+ langCodeToJsonName,
+ langCodeToCldrName,
+ ensureFinalFallback
+}
diff --git a/src/i18n/messages.js b/src/i18n/messages.js
index 2a1161be..74a89ca8 100644
--- a/src/i18n/messages.js
+++ b/src/i18n/messages.js
@@ -7,46 +7,27 @@
// sed -i -e "s/'//gm" -e 's/"/\\"/gm' -re 's/^( +)(.+?): ((.+?))?(,?)(\{?)$/\1"\2": "\4"/gm' -e 's/\"\{\"/{/g' -e 's/,"$/",/g' file.json
// There's only problem that apostrophe character ' gets replaced by \\ so you have to fix it manually, sorry.
-const loaders = {
- ar: () => import('./ar.json'),
- ca: () => import('./ca.json'),
- cs: () => import('./cs.json'),
- de: () => import('./de.json'),
- eo: () => import('./eo.json'),
- es: () => import('./es.json'),
- et: () => import('./et.json'),
- eu: () => import('./eu.json'),
- fi: () => import('./fi.json'),
- fr: () => import('./fr.json'),
- ga: () => import('./ga.json'),
- he: () => import('./he.json'),
- hu: () => import('./hu.json'),
- it: () => import('./it.json'),
- ja: () => import('./ja_pedantic.json'),
- ja_easy: () => import('./ja_easy.json'),
- ko: () => import('./ko.json'),
- nb: () => import('./nb.json'),
- nl: () => import('./nl.json'),
- oc: () => import('./oc.json'),
- pl: () => import('./pl.json'),
- pt: () => import('./pt.json'),
- ro: () => import('./ro.json'),
- ru: () => import('./ru.json'),
- te: () => import('./te.json'),
- uk: () => import('./uk.json'),
- zh: () => import('./zh.json'),
- zh_Hant: () => import('./zh_Hant.json')
+import { languages, langCodeToJsonName } from './languages.js'
+
+const hasLanguageFile = (code) => languages.includes(code)
+
+const loadLanguageFile = (code) => {
+ return import(
+ /* webpackInclude: /\.json$/ */
+ /* webpackChunkName: "i18n/[request]" */
+ `./${langCodeToJsonName(code)}.json`
+ )
}
const messages = {
- languages: ['en', ...Object.keys(loaders)],
+ languages,
default: {
- en: require('./en.json')
+ en: require('./en.json').default
},
setLanguage: async (i18n, language) => {
- if (loaders[language]) {
- let messages = await loaders[language]()
- i18n.setLocaleMessage(language, messages)
+ if (hasLanguageFile(language)) {
+ const messages = await loadLanguageFile(language)
+ i18n.setLocaleMessage(language, messages.default)
}
i18n.locale = language
}
diff --git a/src/i18n/nb.json b/src/i18n/nb.json
index 5e3e8ef3..1c160afb 100644
--- a/src/i18n/nb.json
+++ b/src/i18n/nb.json
@@ -553,8 +553,7 @@
"disable_remote_subscription": "Fjern mulighet til ÃĨ følge brukeren fra andre instanser",
"disable_any_subscription": "Fjern mulighet til ÃĨ følge brukeren",
"quarantine": "Gjør at statuser fra brukeren ikke kan sendes til andre instanser",
- "delete_user": "Slett bruker",
- "delete_user_confirmation": "Er du helt sikker? Denne handlingen kan ikke omgjøres."
+ "delete_user": "Slett bruker"
}
},
"user_profile": {
diff --git a/src/i18n/nl.json b/src/i18n/nl.json
index b113ffe4..c0ffe1cd 100644
--- a/src/i18n/nl.json
+++ b/src/i18n/nl.json
@@ -8,10 +8,11 @@
"media_proxy": "Mediaproxy",
"scope_options": "Zichtbaarheidsopties",
"text_limit": "Tekstlimiet",
- "title": "Kenmerken",
+ "title": "Functies",
"who_to_follow": "Wie te volgen",
"upload_limit": "Upload limiet",
- "pleroma_chat_messages": "Pleroma Chat"
+ "pleroma_chat_messages": "Pleroma Chat",
+ "shout": "Shoutbox"
},
"finder": {
"error_fetching_user": "Fout tijdens ophalen gebruiker",
@@ -39,6 +40,15 @@
"role": {
"moderator": "Moderator",
"admin": "Beheerder"
+ },
+ "flash_content": "Klik om Flash-content te laten zien met Ruffle (Experimenteel, werkt mogelijk niet).",
+ "flash_security": "Let op: Flash-inhoud is niet gescreend en kan malware bevatten.",
+ "flash_fail": "Laden van Flash-content is mislukt, zie console voor details.",
+ "scope_in_timeline": {
+ "direct": "PrivÊ",
+ "private": "Alleen-volgers",
+ "public": "Openbaar",
+ "unlisted": "Niet-openbaar"
}
},
"login": {
@@ -60,7 +70,7 @@
}
},
"nav": {
- "about": "Over",
+ "about": "Over ons",
"back": "Terug",
"chat": "Lokale Chat",
"friend_requests": "Volgverzoeken",
@@ -68,7 +78,7 @@
"dms": "PrivÊberichten",
"public_tl": "Openbare tijdlijn",
"timeline": "Tijdlijn",
- "twkn": "Bekende Netwerk",
+ "twkn": "Globale Netwerk",
"user_search": "Gebruiker Zoeken",
"who_to_follow": "Wie te volgen",
"preferences": "Voorkeuren",
@@ -81,22 +91,23 @@
"bookmarks": "Bladwijzers"
},
"notifications": {
- "broken_favorite": "Onbekende status, aan het zoekenâ€Ļ",
- "favorited_you": "vond je status leuk",
+ "broken_favorite": "Onbekend bericht, aan het zoekenâ€Ļ",
+ "favorited_you": "vond je bericht leuk",
"followed_you": "volgt jou",
"load_older": "Oudere meldingen laden",
"notifications": "Meldingen",
"read": "Gelezen!",
- "repeated_you": "herhaalde je status",
+ "repeated_you": "herhaalde je bericht",
"no_more_notifications": "Geen meldingen meer",
"migrated_to": "is gemigreerd naar",
"follow_request": "wil je volgen",
"reacted_with": "reageerde met {0}",
- "error": "Fout bij ophalen van meldingen: {0}"
+ "error": "Fout bij ophalen van meldingen: {0}",
+ "poll_ended": "peiling is beÃĢindigd"
},
"post_status": {
- "new_status": "Nieuwe status plaatsen",
- "account_not_locked_warning": "Je account is niet {0}. Iedereen kan je volgen om je alleen-volgers-berichten te lezen.",
+ "new_status": "Nieuw bericht plaatsen",
+ "account_not_locked_warning": "Je account is niet {0}. Iedereen kan je volgen om je alleen-volgersberichten te lezen.",
"account_not_locked_warning_link": "gesloten",
"attachments_sensitive": "Bijlagen als gevoelig markeren",
"content_type": {
@@ -108,10 +119,10 @@
"content_warning": "Onderwerp (optioneel)",
"default": "Tijd voor anime!",
"direct_warning": "Deze post zal enkel zichtbaar zijn voor de personen die genoemd zijn.",
- "posting": "Plaatsen",
+ "posting": "Aan het plaatsen",
"scope": {
"direct": "PrivÊ - bericht enkel naar vermelde gebruikers sturen",
- "private": "Enkel volgers - bericht enkel naar volgers sturen",
+ "private": "Alleen-volgers - bericht is enkel leesbaar voor volgers",
"public": "Openbaar - bericht op openbare tijdlijnen plaatsen",
"unlisted": "Niet vermelden - niet tonen op openbare tijdlijnen"
},
@@ -119,11 +130,11 @@
"direct_warning_to_first_only": "Dit bericht zal alleen zichtbaar zijn voor de vermelde gebruikers aan het begin van het bericht.",
"scope_notice": {
"public": "Dit bericht zal voor iedereen zichtbaar zijn",
- "unlisted": "Dit bericht zal niet zichtbaar zijn in de Openbare Tijdlijn en Het Geheel Bekende Netwerk",
+ "unlisted": "Dit bericht zal niet zichtbaar zijn in de Openbare Tijdlijn en Het Globale Netwerk",
"private": "Dit bericht zal voor alleen je volgers zichtbaar zijn"
},
- "post": "Bericht",
- "empty_status_error": "Kan geen lege status zonder bijlagen plaatsen",
+ "post": "Plaatsen",
+ "empty_status_error": "Kan geen leeg bericht zonder bijlagen plaatsen",
"preview_empty": "Leeg",
"preview": "Voorbeeld",
"media_description": "Mediaomschrijving",
@@ -149,13 +160,14 @@
"username_placeholder": "bijv. lain",
"fullname_placeholder": "bijv. Lain Iwakura",
"bio_placeholder": "bijv.\nHallo, ik ben Lain.\nIk ben een animemeisje woonachtig in een buitenwijk in Japan. Je kent me misschien van the Wired.",
- "reason_placeholder": "Deze instantie keurt registraties handmatig goed.\nLaat de beheerder weten waarom je wilt registreren.",
+ "reason_placeholder": "Deze instantie keurt registraties handmatig goed.\nLaat de beheerder weten waarom je je wilt registreren.",
"reason": "Reden voor registratie",
- "register": "Registreren"
+ "register": "Registreren",
+ "email_language": "In welke taal wil je e-mails ontvangen van de server?"
},
"settings": {
- "attachmentRadius": "Bijlages",
- "attachments": "Bijlages",
+ "attachmentRadius": "Bijlagen",
+ "attachments": "Bijlagen",
"avatar": "Avatar",
"avatarAltRadius": "Avatars (meldingen)",
"avatarRadius": "Avatars",
@@ -169,7 +181,7 @@
"change_password": "Wachtwoord wijzigen",
"change_password_error": "Er is een fout opgetreden bij het wijzigen van je wachtwoord.",
"changed_password": "Wachtwoord succesvol gewijzigd!",
- "collapse_subject": "Klap berichten met een onderwerp in",
+ "collapse_subject": "Berichten met een onderwerp inklappen",
"composing": "Opstellen",
"confirm_new_password": "Nieuw wachtwoord bevestigen",
"current_avatar": "Je huidige avatar",
@@ -181,9 +193,9 @@
"delete_account_description": "Permanent je gegevens verwijderen en account deactiveren.",
"delete_account_error": "Er is een fout opgetreden bij het verwijderen van je account. Indien dit probleem zich voor blijft doen, neem dan contact op met de beheerder van deze instantie.",
"delete_account_instructions": "Voer je wachtwoord in het onderstaande invoerveld in om het verwijderen van je account te bevestigen.",
- "export_theme": "Voorinstelling opslaan",
+ "export_theme": "Preset opslaan",
"filtering": "Filtering",
- "filtering_explanation": "Alle statussen die deze woorden bevatten worden genegeerd, ÊÊn filter per regel",
+ "filtering_explanation": "Alle berichten die deze woorden bevatten worden genegeerd, ÊÊn filter per regel",
"follow_export": "Volgers exporteren",
"follow_export_button": "Exporteer je volgers naar een csv-bestand",
"follow_export_processing": "Aan het verwerken, binnen enkele ogenblikken wordt je gevraagd je bestand te downloaden",
@@ -192,13 +204,13 @@
"follows_imported": "Volgers geïmporteerd! Het kan even duren voordat deze verwerkt zijn.",
"foreground": "Voorgrond",
"general": "Algemeen",
- "hide_attachments_in_convo": "Bijlagen in conversaties verbergen",
+ "hide_attachments_in_convo": "Bijlagen in gesprekken verbergen",
"hide_attachments_in_tl": "Bijlagen in tijdlijn verbergen",
"hide_isp": "Instantie-specifiek paneel verbergen",
"preload_images": "Afbeeldingen vooraf laden",
- "hide_post_stats": "Bericht statistieken verbergen (bijv. het aantal favorieten)",
+ "hide_post_stats": "Bericht-statistieken verbergen (bijv. het aantal favorieten)",
"hide_user_stats": "Gebruikers-statistieken verbergen (bijv. het aantal volgers)",
- "import_followers_from_a_csv_file": "Gevolgden uit een csv bestand importeren",
+ "import_followers_from_a_csv_file": "Gevolgde gebruikers uit een csv bestand importeren",
"import_theme": "Preset laden",
"inputRadius": "Invoervelden",
"checkboxRadius": "Checkboxen",
@@ -216,13 +228,13 @@
"name_bio": "Naam & bio",
"new_password": "Nieuw wachtwoord",
"notification_visibility": "Type meldingen die getoond worden",
- "notification_visibility_follows": "Gevolgden",
+ "notification_visibility_follows": "Gevolgde gebruikers",
"notification_visibility_likes": "Favorieten",
"notification_visibility_mentions": "Vermeldingen",
"notification_visibility_repeats": "Herhalingen",
"no_rich_text_description": "Verwijder rich text formattering van alle berichten",
"hide_network_description": "Toon niet wie mij volgt en wie ik volg.",
- "nsfw_clickthrough": "Doorklikbaar verbergen van gevoelige bijlages en link voorbeelden inschakelen",
+ "nsfw_clickthrough": "Gevoelige media verbergen",
"oauth_tokens": "OAuth-tokens",
"token": "Token",
"refresh_token": "Token vernieuwen",
@@ -249,15 +261,15 @@
"settings": "Instellingen",
"subject_input_always_show": "Altijd onderwerpveld tonen",
"subject_line_behavior": "Onderwerp kopiÃĢren bij beantwoorden",
- "subject_line_email": "Zoals email: \"re: onderwerp\"",
- "subject_line_mastodon": "Zoals mastodon: kopieer zoals het is",
+ "subject_line_email": "Zoals e-mail: \"re: onderwerp\"",
+ "subject_line_mastodon": "Zoals mastodon: kopiÃĢren zoals het is",
"subject_line_noop": "Niet kopiÃĢren",
- "stop_gifs": "GIFs afspelen bij zweven",
+ "stop_gifs": "Geanimeerde afbeeldingen afspelen bij zweven",
"streaming": "Automatisch streamen van nieuwe berichten inschakelen wanneer tot boven gescrold is",
"text": "Tekst",
"theme": "Thema",
"theme_help": "Hex kleur codes (#rrggbb) gebruiken om je kleur thema te wijzigen.",
- "theme_help_v2_1": "Je kan ook de kleur en transparantie van bepaalde componenten overschrijven door de checkbox aan te vinken, gebruik de \"Alles wissen\" knop om alle overschrijvingen te annuleren.",
+ "theme_help_v2_1": "Je kan ook de kleur en transparantie van bepaalde componenten overschrijven door de checkbox aan te vinken, gebruik de \"Alles wissen\" knop om alle overschrijvingen te herstellen.",
"theme_help_v2_2": "Iconen onder sommige onderdelen zijn achtergrond/tekst contrast indicatoren, zweef er over voor gedetailleerde info. Hou er rekening mee dat bij doorzichtigheid de ergst mogelijke situatie wordt weer gegeven.",
"tooltipRadius": "Tooltips/alarmen",
"user_settings": "Gebruikersinstellingen",
@@ -275,10 +287,10 @@
"keep_roundness": "Rondingen behouden",
"keep_fonts": "Lettertypes behouden",
"save_load_hint": "\"Behoud\" opties behouden de momenteel ingestelde opties bij het selecteren of laden van thema's, maar slaan ook de genoemde opties op bij het exporteren van een thema. Wanneer alle selectievakjes zijn uitgeschakeld, zal het exporteren van thema's alles opslaan.",
- "reset": "Reset",
+ "reset": "Herstellen",
"clear_all": "Alles wissen",
"clear_opacity": "Transparantie wissen",
- "keep_as_is": "Hou zoals het is",
+ "keep_as_is": "Houden zoals het is",
"use_snapshot": "Oude versie",
"use_source": "Nieuwe versie",
"help": {
@@ -289,7 +301,7 @@
"snapshot_source_mismatch": "Versie conflict: waarschijnlijk was FE terug gerold en opnieuw bijgewerkt, indien je het thema aangepast hebt met de oudere versie van FE wil je waarschijnlijk de oude versie gebruiken, gebruik anders de nieuwe versie.",
"migration_napshot_gone": "Voor een onduidelijke reden mist de momentopname, dus sommige dingen kunnen anders uitzien dan je gewend bent.",
"migration_snapshot_ok": "Voor de zekerheid is een momentopname van het thema geladen. Je kunt proberen om de thema gegevens te laden.",
- "fe_downgraded": "PleromaFE's versie is terug gerold.",
+ "fe_downgraded": "PleromaFE's versie is terug gezet.",
"fe_upgraded": "De thema-engine van PleromaFE is bijgewerkt na de versie update.",
"snapshot_missing": "Het bestand bevat geen thema momentopname, dus het thema kan anders uitzien dan je oorspronkelijk bedacht had.",
"snapshot_present": "Thema momentopname is geladen, alle waarden zijn overschreven. Je kunt in plaats daarvan ook de daadwerkelijke data van het thema laden."
@@ -315,7 +327,7 @@
"common_colors": {
"_tab_label": "Algemeen",
"main": "Algemene kleuren",
- "foreground_hint": "Zie \"Geavanceerd\" tab voor meer gedetailleerde controle",
+ "foreground_hint": "Zie \"Geavanceerd\" tab voor meer gedetailleerde opties",
"rgbo": "Iconen, accenten, badges"
},
"advanced_colors": {
@@ -336,9 +348,9 @@
"selectedMenu": "Geselecteerd menu item",
"selectedPost": "Geselecteerd bericht",
"pressed": "Ingedrukt",
- "highlight": "Gemarkeerde elementen",
+ "highlight": "Uitgelichte elementen",
"icons": "Iconen",
- "poll": "Poll grafiek",
+ "poll": "Peiling grafiek",
"underlay": "Onderlaag",
"popover": "Tooltips, menu's, popovers",
"post": "Berichten / Gebruiker bios",
@@ -352,7 +364,7 @@
"wallpaper": "Achtergrond"
},
"radii": {
- "_tab_label": "Rondheid"
+ "_tab_label": "Rondingen"
},
"shadows": {
"_tab_label": "Schaduw en belichting",
@@ -374,8 +386,8 @@
"panel": "Paneel",
"panelHeader": "Paneel koptekst",
"topBar": "Top balk",
- "avatar": "Gebruikers avatar (in profiel weergave)",
- "avatarStatus": "Gebruikers avatar (in bericht weergave)",
+ "avatar": "Gebruikers-avatar (in profiel weergave)",
+ "avatarStatus": "Gebruikers-avatar (in bericht weergave)",
"popup": "Popups en tooltips",
"button": "Knop",
"buttonHover": "Knop (zweven)",
@@ -386,7 +398,7 @@
"hintV3": "Voor schaduwen kun je ook de {0} notatie gebruiken om de andere kleur invoer te gebruiken."
},
"fonts": {
- "_tab_label": "Lettertypes",
+ "_tab_label": "Lettertypen",
"help": "Selecteer het lettertype om te gebruiken voor elementen van de UI. Voor \"aangepast\" dien je de exacte naam van het lettertype in te voeren zoals die in het systeem wordt weergegeven.",
"components": {
"interface": "Interface",
@@ -426,10 +438,10 @@
"wait_pre_setup_otp": "OTP voorinstellen",
"confirm_and_enable": "Bevestig en schakel OTP in",
"title": "Twee-factorauthenticatie",
- "generate_new_recovery_codes": "Genereer nieuwe herstelcodes",
+ "generate_new_recovery_codes": "Nieuwe herstelcodes genereren",
"recovery_codes": "Herstelcodes.",
"waiting_a_recovery_codes": "Back-upcodes ontvangenâ€Ļ",
- "authentication_methods": "Authenticatiemethodes",
+ "authentication_methods": "Authenticatiemethoden",
"scan": {
"title": "Scannen",
"desc": "Scan de QR-code of voer een sleutel in met je twee-factorapplicatie:",
@@ -441,39 +453,39 @@
"warning_of_generate_new_codes": "Wanneer je nieuwe herstelcodes genereert, zullen je oude codes niet langer werken.",
"recovery_codes_warning": "Schrijf de codes op of sla ze op een veilige locatie op - anders kun je ze niet meer inzien. Als je toegang tot je 2FA-app en herstelcodes verliest, zal je buitengesloten zijn van je account."
},
- "allow_following_move": "Automatisch volgen toestaan wanneer een gevolgd account migreert",
- "block_export": "Blokkades exporteren",
- "block_import": "Blokkades importeren",
- "blocks_imported": "Blokkades geïmporteerd! Het kan even duren voordat deze verwerkt zijn.",
- "blocks_tab": "Blokkades",
+ "allow_following_move": "Automatisch volgen toestaan wanneer een gevolgd account verhuist",
+ "block_export": "Geblokkeerde gebruikers exporteren",
+ "block_import": "Geblokkeerde gebruikers importeren",
+ "blocks_imported": "Geblokkeerde gebruikers geïmporteerd! Het kan even duren voordat deze verwerkt zijn.",
+ "blocks_tab": "Geblokkeerde gebruikers",
"change_email": "E-mail wijzigen",
"change_email_error": "Er is een fout opgetreden tijdens het wijzigen van je e-mailadres.",
"changed_email": "E-mailadres succesvol gewijzigd!",
"domain_mutes": "Domeinen",
"avatar_size_instruction": "De aangeraden minimale afmeting voor avatar-afbeeldingen is 150x150 pixels.",
- "pad_emoji": "Vul emoji aan met spaties wanneer deze met de picker ingevoegd worden",
+ "pad_emoji": "Emoji aan met spaties aanvullen wanneer deze met de picker ingevoegd worden",
"emoji_reactions_on_timeline": "Toon emoji-reacties op de tijdlijn",
"accent": "Accent",
"hide_muted_posts": "Berichten van genegeerde gebruikers verbergen",
"max_thumbnails": "Maximaal aantal miniaturen per bericht",
"use_one_click_nsfw": "Gevoelige bijlagen met slechts ÊÊn klik openen",
- "hide_filtered_statuses": "Gefilterde statussen verbergen",
- "import_blocks_from_a_csv_file": "Blokkades van een csv bestand importeren",
- "mutes_tab": "Genegeerden",
- "play_videos_in_modal": "Video's in een popup frame afspelen",
+ "hide_filtered_statuses": "Gefilterde berichten verbergen",
+ "import_blocks_from_a_csv_file": "Geblokkeerde gebruikers van een csv bestand importeren",
+ "mutes_tab": "Genegeerde gebruikers",
+ "play_videos_in_modal": "Video's in een popup venster afspelen",
"new_email": "Nieuwe e-mail",
"notification_visibility_emoji_reactions": "Reacties",
- "no_blocks": "Geen blokkades",
- "no_mutes": "Geen genegeerden",
+ "no_blocks": "Geen geblokkeerde gebruikers",
+ "no_mutes": "Geen genegeerde gebruikers",
"hide_followers_description": "Niet tonen wie mij volgt",
"hide_followers_count_description": "Niet mijn volgers aantal tonen",
- "hide_follows_count_description": "Niet mijn gevolgde aantal tonen",
+ "hide_follows_count_description": "Niet mijn gevolgden aantal tonen",
"show_admin_badge": "\"Beheerder\" badge in mijn profiel tonen",
- "autohide_floating_post_button": "Nieuw Bericht knop automatisch verbergen (mobiel)",
+ "autohide_floating_post_button": "\"Bericht opstellen\"-knop automatisch verbergen (mobiel)",
"search_user_to_block": "Zoek wie je wilt blokkeren",
"search_user_to_mute": "Zoek wie je wilt negeren",
"minimal_scopes_mode": "Bericht bereik-opties minimaliseren",
- "post_status_content_type": "Bericht status content type",
+ "post_status_content_type": "Standaard bericht content type",
"user_mutes": "Gebruikers",
"useStreamingApi": "Berichten en meldingen in real-time ontvangen",
"useStreamingApiWarning": "(Afgeraden, experimenteel, kan berichten overslaan)",
@@ -482,7 +494,7 @@
"fun": "Plezier",
"greentext": "Meme pijlen",
"block_export_button": "Exporteer je geblokkeerde gebruikers naar een csv-bestand",
- "block_import_error": "Fout bij importeren blokkades",
+ "block_import_error": "Fout bij importeren geblokkeerde gebruikers",
"discoverable": "Sta toe dat dit account ontdekt kan worden in zoekresultaten en andere diensten",
"use_contain_fit": "Bijlage in miniaturen niet bijsnijden",
"notification_visibility_moves": "Gebruiker Migraties",
@@ -495,7 +507,7 @@
"backend_version": "Backend versie",
"title": "Versie"
},
- "mutes_and_blocks": "Negeringen en Blokkades",
+ "mutes_and_blocks": "Negeren en Blokkeren",
"profile_fields": {
"value": "Inhoud",
"name": "Label",
@@ -508,15 +520,15 @@
"hide_media_previews": "Media voorbeelden verbergen",
"word_filter": "Woord filter",
"chatMessageRadius": "Chatbericht",
- "mute_export": "Genegeerden export",
- "mute_export_button": "Exporteer je genegeerden naar een csv-bestand",
- "mute_import_error": "Fout tijdens het importeren van genegeerden",
- "mute_import": "Genegeerden import",
- "mutes_imported": "Genegeerden geïmporteerd! Het kan even duren voordat deze verwerkt zijn.",
+ "mute_export": "Genegeerde gebruikers export",
+ "mute_export_button": "Genegeerde gebruikers naar een csv-bestand exporteren",
+ "mute_import_error": "Fout tijdens het importeren van genegeerde gebruikers",
+ "mute_import": "Genegeerde gebruikers import",
+ "mutes_imported": "Genegeerde gebruikers geïmporteerd! Het kan even duren voordat deze verwerkt zijn.",
"more_settings": "Meer instellingen",
- "notification_setting_hide_notification_contents": "Afzender en inhoud van push meldingen verbergen",
+ "notification_setting_hide_notification_contents": "Afzender en inhoud van push-meldingen verbergen",
"notification_setting_block_from_strangers": "Meldingen van gebruikers die je niet volgt blokkeren",
- "virtual_scrolling": "Tijdlijn rendering optimaliseren",
+ "virtual_scrolling": "Tijdlijn weergave optimaliseren",
"sensitive_by_default": "Berichten standaard als gevoelig markeren",
"reset_avatar_confirm": "Wil je echt de avatar herstellen?",
"reset_banner_confirm": "Wil je echt de banner herstellen?",
@@ -528,7 +540,7 @@
"reply_visibility_following_short": "Antwoorden naar mijn gevolgden tonen",
"file_export_import": {
"errors": {
- "file_slightly_new": "Bestand minor versie is verschillend, sommige instellingen kunnen mogelijk niet worden geladen",
+ "file_slightly_new": "Minor versie van bestand is verschillend, sommige instellingen kunnen mogelijk niet worden geladen",
"file_too_old": "Incompatibele hoofdversie: {fileMajor}, bestandsversie is te oud en wordt niet ondersteund (minimale versie {feMajor})",
"file_too_new": "Incompatibele hoofdversie: {fileMajor}, deze PleromaFE (instellingen versie {feMajor}) is te oud om deze te ondersteunen",
"invalid_file": "Het geselecteerde bestand is niet een door Pleroma ondersteunde instellingen back-up. Er zijn geen wijzigingen gemaakt."
@@ -536,27 +548,95 @@
"restore_settings": "Instellingen uit bestand herstellen",
"backup_settings_theme": "Instellingen en thema naar bestand back-uppen",
"backup_settings": "Instellingen naar bestand back-uppen",
- "backup_restore": "Instellingen backup"
+ "backup_restore": "Instellingen back-up"
},
- "hide_wallpaper": "Instantie achtergrond verbergen",
+ "hide_wallpaper": "Achtergrond-afbeelding verbergen",
"hide_all_muted_posts": "Genegeerde berichten verbergen",
- "import_mutes_from_a_csv_file": "Importeer genegeerden van een csv bestand"
+ "import_mutes_from_a_csv_file": "Genegeerde gebruikers uit een csv bestand importeren",
+ "added_alias": "Alias is toegevoegd.",
+ "add_alias_error": "Fout bij het toevoegen van alias: {error}",
+ "move_account": "Account verhuizen",
+ "move_account_notes": "Indien je het account ergens anders heen wilt verplaatsen, dien je eerst een alias naar dit account te maken in het nieuwe account.",
+ "move_account_target": "Doelwit account (b.v. {example})",
+ "moved_account": "Het account is verhuisd.",
+ "move_account_error": "Fout tijdens account verhuizen: {error}",
+ "wordfilter": "Woordfilter",
+ "third_column_mode": "Indien er genoeg plaats is, derde kolom tonen met",
+ "third_column_mode_none": "GÊÊn derde kolom tonen",
+ "third_column_mode_notifications": "Meldingen",
+ "third_column_mode_postform": "Berichtformulier en navigatie",
+ "tree_advanced": "Flexibelere navigatie toestaan in boom weergave",
+ "tree_fade_ancestors": "Ouders van huidige bericht met gedempte tekst tonen",
+ "conversation_display_linear": "Lineaire weergave",
+ "mention_link_display_full_for_remote": "als volledige namen alleen voor externe gebruikers (b.v. {'@'}foo{'@'}example.org)",
+ "mention_link_display_full": "altijd als volledige namen (b.v. {'@'}foo{'@'}example.org)",
+ "mention_link_show_avatar": "Profielfoto naast link tonen",
+ "mention_link_fade_domain": "Domeinen vervagen (b.v. {'@'}example.org in {'@'}foo{'@'}example.org)",
+ "mention_link_bolden_you": "Vermeldingen naar jezelf uitlichten",
+ "expert_mode": "Geavanceerde opties tonen",
+ "setting_server_side": "Deze instelling is gebonden aan je profiel en beïnvloed alle sessies en clients",
+ "post_look_feel": "Berichten Look & Feel",
+ "mention_links": "Vermelding-links",
+ "email_language": "Taal voor e-mails van de server",
+ "account_backup": "Account back-up",
+ "account_backup_description": "Hiermee kun je een archief van je account gegevens en berichten downloaden, maar deze kunnen nog niet geïmporteerd worden in een Pleroma account.",
+ "account_backup_table_head": "Back-up",
+ "download_backup": "Downloaden",
+ "backup_not_ready": "Deze back-up is nog niet gereed.",
+ "remove_backup": "Verwijderen",
+ "list_backups_error": "Fout bij het ophalen van back-ups: {error}",
+ "add_backup": "Nieuwe back-up aanmaken",
+ "added_backup": "Nieuwe back-up is toegevoegd.",
+ "add_backup_error": "Fout bij het maken van back-up: {error}",
+ "account_alias": "Account aliassen",
+ "account_alias_table_head": "Alias",
+ "list_aliases_error": "Fout bij het ophalen van aliassen: {error}",
+ "hide_list_aliases_error_action": "Sluiten",
+ "remove_alias": "Deze alias verwijderen",
+ "new_alias_target": "Nieuwe alias toevoegen (b.v. {example})",
+ "mute_bot_posts": "Bot-berichten negeren",
+ "hide_bot_indication": "Bot-indicatie in berichten verbergen",
+ "hide_shoutbox": "Shoutbox verbergen",
+ "right_sidebar": "Kolom-volgorde omdraaien",
+ "always_show_post_button": "Altijd de zwevende \"Bericht opstellen\"-knop tonen",
+ "hide_wordfiltered_statuses": "Berichten met gefilterde woorden verbergen",
+ "hide_muted_threads": "Genegeerde gesprekken verbergen",
+ "account_privacy": "Privacy",
+ "posts": "Berichten",
+ "user_profiles": "Gebruikersprofielen",
+ "notification_visibility_polls": "Einde van peilingen waar je in gestemd hebt",
+ "hide_favorites_description": "Lijst van favorieten verbergen (mensen krijgen wel nog meldingen)",
+ "conversation_display": "Gespreksweergave stijl",
+ "conversation_display_tree": "Boom weergave",
+ "disable_sticky_headers": "Kolomkopteksten niet bovenaan het scherm plakken",
+ "show_scrollbars": "Scrollbalk tonen in zijkolommen",
+ "conversation_other_replies_button": "\"Andere antwoorden\"-knop tonen",
+ "conversation_other_replies_button_below": "Onder berichten",
+ "conversation_other_replies_button_inside": "Binnen in berichten",
+ "max_depth_in_thread": "Maximum lagen van een gesprek welke standaard getoond dienen te worden",
+ "use_at_icon": "{'@'} symbool als icoon tonen in plaats van tekst",
+ "mention_link_display": "Vermelding-links tonen",
+ "mention_link_display_short": "altijd als korte namen (b.v. {'@'}foo)",
+ "mention_link_use_tooltip": "Volledige namen in tooltip tonen voor externe gebruikers",
+ "show_yous": "(Jij)'s tonen",
+ "user_popover_avatar_zoom": "Gebruikers-avatar inzoomen wanneer hier op geklikt wordt in een popover in plaats van de popover te sluiten",
+ "user_popover_avatar_overlay": "Gebruikers-popover tonen over gebruikers-avatar"
},
"timeline": {
- "collapse": "Inklappen",
- "conversation": "Conversatie",
+ "collapse": "Invouwen",
+ "conversation": "Gesprek",
"error_fetching": "Fout bij ophalen van updates",
- "load_older": "Oudere statussen laden",
- "no_retweet_hint": "Bericht is gemarkeerd als enkel volgers of direct en kan niet worden herhaald",
+ "load_older": "Oudere berichten laden",
+ "no_retweet_hint": "Bericht is gemarkeerd als enkel-volgers of privÊ en kan niet worden herhaald of geciteerd",
"repeated": "herhaalde",
"show_new": "Nieuwe tonen",
"up_to_date": "Up-to-date",
- "no_statuses": "Geen statussen",
- "no_more_statuses": "Geen statussen meer",
+ "no_statuses": "Geen berichten",
+ "no_more_statuses": "Geen verdere berichten",
"socket_broke": "Realtime verbinding verloren: CloseEvent code {0}",
"socket_reconnected": "Realtime verbinding opgezet",
"reload": "Verversen",
- "error": "Fout tijdens het ophalen van tijdlijn: {0}"
+ "error": "Fout bij het ophalen van tijdlijn: {0}"
},
"user_card": {
"approve": "Goedkeuren",
@@ -565,28 +645,27 @@
"deny": "Weigeren",
"favorites": "Favorieten",
"follow": "Volgen",
- "follow_cancel": "Aanvraag annuleren",
- "follow_sent": "Aanvraag verzonden!",
+ "follow_cancel": "Verzoek annuleren",
+ "follow_sent": "Verzoek verzonden!",
"follow_progress": "Aanvragenâ€Ļ",
- "follow_unfollow": "Stop volgen",
- "followees": "Aan het volgen",
+ "follow_unfollow": "Ontvolgen",
+ "followees": "Volgen",
"followers": "Volgers",
- "following": "Aan het volgen!",
+ "following": "Gevolgd!",
"follows_you": "Volgt jou!",
"its_you": "'t is jij!",
"mute": "Negeren",
"muted": "Genegeerd",
"per_day": "per dag",
- "remote_follow": "Volg vanop afstand",
- "statuses": "Statussen",
+ "remote_follow": "Van afstand volgen",
+ "statuses": "Berichten",
"admin_menu": {
- "delete_user_confirmation": "Weet je het heel zeker? Deze uitvoering kan niet ongedaan worden gemaakt.",
"delete_user": "Gebruiker verwijderen",
- "quarantine": "Federeren van gebruikers berichten verbieden",
+ "quarantine": "Federeren van berichten verbieden",
"disable_any_subscription": "Volgen van gebruiker in zijn geheel verbieden",
"disable_remote_subscription": "Volgen van gebruiker vanaf andere instanties verbieden",
"sandbox": "Berichten forceren om alleen voor volgers zichtbaar te zijn",
- "force_unlisted": "Berichten forceren om niet publiekelijk getoond te worden",
+ "force_unlisted": "Berichten forceren om niet openbaar getoond te worden",
"strip_media": "Media van berichten verwijderen",
"force_nsfw": "Alle berichten als gevoelig markeren",
"delete_account": "Account verwijderen",
@@ -596,30 +675,33 @@
"grant_moderator": "Moderatorsrechten toekennen",
"revoke_admin": "Beheerdersrechten intrekken",
"grant_admin": "Beheerdersrechten toekennen",
- "moderation": "Moderatie"
+ "moderation": "Moderatie",
+ "delete_user_data_and_deactivate_confirmation": "Dit zal permanent alle data van dit account verwijderen en het account deactiveren. Weet je het zeker?"
},
"show_repeats": "Herhalingen tonen",
"hide_repeats": "Herhalingen verbergen",
"mute_progress": "Negerenâ€Ļ",
- "unmute_progress": "Negering opheffenâ€Ļ",
- "unmute": "Negering opheffen",
+ "unmute_progress": "Negeren opheffenâ€Ļ",
+ "unmute": "Negeren opheffen",
"block_progress": "Blokkerenâ€Ļ",
- "unblock_progress": "Blokkade opheffenâ€Ļ",
- "unblock": "Blokkade opheffen",
+ "unblock_progress": "Blokkeren opheffenâ€Ļ",
+ "unblock": "Blokkeren opheffen",
"unsubscribe": "Abonnement opzeggen",
"subscribe": "Abonneren",
- "report": "Aangeven",
- "mention": "Vermelding",
+ "report": "Rapporteren",
+ "mention": "Vermelden",
"media": "Media",
"hidden": "Verborgen",
"highlight": {
"side": "Zijstreep",
"striped": "Gestreepte achtergrond",
"solid": "Effen achtergrond",
- "disabled": "Geen highlight"
+ "disabled": "Geen uitlichting"
},
"bot": "Bot",
- "message": "Bericht"
+ "message": "Bericht",
+ "edit_profile": "Profiel wijzigen",
+ "deactivated": "Gedeactiveerd"
},
"user_profile": {
"timeline_title": "Gebruikerstijdlijn",
@@ -635,11 +717,11 @@
"repeat": "Herhalen",
"reply": "Beantwoorden",
"favorite": "Favoriet maken",
- "user_settings": "Gebruikers Instellingen",
- "reject_follow_request": "Volg-verzoek afwijzen",
- "accept_follow_request": "Volg-aanvraag accepteren",
+ "user_settings": "Gebruikersinstellingen",
+ "reject_follow_request": "Volgverzoek afwijzen",
+ "accept_follow_request": "Volgverzoek accepteren",
"add_reaction": "Reactie toevoegen",
- "bookmark": "Bladwijzer"
+ "bookmark": "Bladwijzer maken"
},
"upload": {
"error": {
@@ -664,27 +746,27 @@
"replace": "Vervangen",
"is_replaced_by": "→",
"keyword_policies": "Zoekwoordbeleid",
- "ftl_removal": "Verwijdering van \"Het Geheel Bekende Netwerk\" Tijdlijn"
+ "ftl_removal": "Verwijderen van \"Het Globale Netwerk\" Tijdlijn"
},
"mrf_policies_desc": "MRF-regels beïnvloeden het federatiegedrag van de instantie. De volgende regels zijn ingeschakeld:",
"mrf_policies": "Ingeschakelde MRF-regels",
"simple": {
- "simple_policies": "Instantiespecifieke regels",
+ "simple_policies": "Instantie-specifieke regels",
"instance": "Instantie",
"reason": "Reden",
"not_applicable": "n.v.t.",
"accept": "Accepteren",
"accept_desc": "Deze instantie accepteert alleen berichten van de volgende instanties:",
"reject": "Afwijzen",
- "reject_desc": "Deze instantie zal geen berichten accepteren van de volgende instanties:",
+ "reject_desc": "Deze instantie zal gÊÊn berichten accepteren van de volgende instanties:",
"quarantine": "Quarantaine",
- "quarantine_desc": "Deze instantie zal alleen openbare berichten sturen naar de volgende instanties:",
- "ftl_removal_desc": "Deze instantie verwijdert de volgende instanties van \"Bekende Netwerk\" tijdlijn:",
+ "quarantine_desc": "Deze instantie zal gÊÊn berichten sturen naar de volgende instanties:",
+ "ftl_removal_desc": "Deze instantie verwijdert de volgende instanties van \"Globale Netwerk\" tijdlijn:",
"media_removal_desc": "Deze instantie verwijdert media van berichten van de volgende instanties:",
- "media_nsfw_desc": "Deze instantie stelt media in als gevoelig in berichten van de volgende instanties:",
- "ftl_removal": "Verwijderen van \"Bekende Netwerk\" Tijdlijn",
- "media_removal": "Mediaverwijdering",
- "media_nsfw": "Forceer media als gevoelig"
+ "media_nsfw_desc": "Deze instantie markeert media als gevoelig in berichten van de volgende instanties:",
+ "ftl_removal": "Verwijderen van \"Globale Netwerk\" Tijdlijn",
+ "media_removal": "Verwijderen van media",
+ "media_nsfw": "Media als gevoelig markeren"
}
},
"staff": "Personeel"
@@ -692,8 +774,8 @@
"domain_mute_card": {
"mute": "Negeren",
"mute_progress": "Negerenâ€Ļ",
- "unmute": "Negering opheffen",
- "unmute_progress": "Negering wordt opgehevenâ€Ļ"
+ "unmute": "Negeren opheffen",
+ "unmute_progress": "Negeren wordt opgehevenâ€Ļ"
},
"exporter": {
"export": "Exporteren",
@@ -712,21 +794,23 @@
},
"media_modal": {
"previous": "Vorige",
- "next": "Volgende"
+ "next": "Volgende",
+ "counter": "{current} / {total}",
+ "hide": "Media venster sluiten"
},
"polls": {
- "add_poll": "Poll toevoegen",
+ "add_poll": "Peiling toevoegen",
"add_option": "Optie toevoegen",
"option": "Optie",
"votes": "stemmen",
- "vote": "Stem",
+ "vote": "Stemmen",
"single_choice": "Enkele keuze",
"multiple_choices": "Meerkeuze",
- "expiry": "Poll leeftijd",
- "expires_in": "Poll eindigt in {0}",
- "expired": "Poll is {0} geleden beÃĢindigd",
- "not_enough_options": "Te weinig opties in poll",
- "type": "Poll-type",
+ "expiry": "Peiling tijdsduur",
+ "expires_in": "Peiling eindigt in {0}",
+ "expired": "Peiling is {0} geleden beÃĢindigd",
+ "not_enough_options": "Te weinig opties in peiling",
+ "type": "Peiling-type",
"votes_count": "{count} stem | {count} stemmen",
"people_voted_count": "{count} persoon heeft gestemd | {count} personen hebben gestemd"
},
@@ -743,28 +827,41 @@
},
"interactions": {
"favs_repeats": "Herhalingen en favorieten",
- "follows": "Nieuwe gevolgden",
+ "follows": "Nieuwe volgs",
"moves": "Gebruikermigraties",
+ "emoji_reactions": "Emoji Reacties",
+ "reports": "Rapportages",
"load_older": "Oudere interacties laden"
},
"remote_user_resolver": {
"searching_for": "Zoeken naar",
"error": "Niet gevonden.",
- "remote_user_resolver": "Externe gebruikers-zoeker"
- },
+ "remote_user_resolver": "Externe gebruiker zoeker"
+ },
+ "report": {
+ "reporter": "Reporteerder:",
+ "reported_user": "Gerapporteerde gebruiker:",
+ "reported_statuses": "Gerapporteerde statussen:",
+ "notes": "Notas:",
+ "state": "Status:",
+ "state_open": "Open",
+ "state_closed": "Gesloten",
+ "state_resolved": "Opgelost"
+ },
+
"selectable_list": {
"select_all": "Alles selecteren"
},
"password_reset": {
- "password_reset_required_but_mailer_is_disabled": "Je dient je wachtwoord opnieuw in te stellen, maar wachtwoord reset is uitgeschakeld. Neem contact op met de beheerder van deze instantie.",
+ "password_reset_required_but_mailer_is_disabled": "Je dient je wachtwoord opnieuw in te stellen, maar wachtwoordherstel is uitgeschakeld. Neem contact op met de beheerder van deze instantie.",
"password_reset_required": "Je dient je wachtwoord opnieuw in te stellen om in te kunnen loggen.",
- "password_reset_disabled": "Wachtwoord reset is uitgeschakeld. Neem contact op met de beheerder van deze instantie.",
+ "password_reset_disabled": "Wachtwoordherstel is uitgeschakeld. Neem contact op met de beheerder van deze instantie.",
"too_many_requests": "Je hebt het maximaal aantal pogingen bereikt, probeer het later opnieuw.",
"return_home": "Terugkeren naar de home pagina",
"check_email": "Controleer je email inbox voor een link om je wachtwoord opnieuw in te stellen.",
"placeholder": "Je email of gebruikersnaam",
"instruction": "Voer je email adres of gebruikersnaam in. We sturen je een link om je wachtwoord opnieuw in te stellen.",
- "password_reset": "Wachtwoord opnieuw instellen",
+ "password_reset": "Wachtwoord herstellen",
"forgot_password": "Wachtwoord vergeten?"
},
"search": {
@@ -780,26 +877,26 @@
"forward_to": "Doorsturen naar {0}",
"forward_description": "Dit account hoort bij een andere server. Wil je een kopie van het rapport ook daarheen sturen?",
"additional_comments": "Aanvullende opmerkingen",
- "add_comment_description": "Het rapport zal naar de moderators van de instantie worden verstuurd. Je kunt hieronder uitleg bijvoegen waarom je dit account wilt aangeven:",
- "title": "{0} aangeven"
+ "add_comment_description": "Het rapport zal naar de moderators van de instantie worden verstuurd. Je kunt hieronder uitleg bijvoegen waarom je dit account wilt rapporteren:",
+ "title": "{0} rapporteren"
},
"status": {
- "copy_link": "Link naar status kopiÃĢren",
- "status_unavailable": "Status niet beschikbaar",
- "unmute_conversation": "Conversatie niet meer negeren",
- "mute_conversation": "Conversatie negeren",
+ "copy_link": "Link naar bericht kopiÃĢren",
+ "status_unavailable": "Bericht niet beschikbaar",
+ "unmute_conversation": "Gesprek niet meer negeren",
+ "mute_conversation": "Gesprek negeren",
"replies_list": "Antwoorden:",
"reply_to": "Antwoorden aan",
- "delete_confirm": "Wil je echt deze status verwijderen?",
+ "delete_confirm": "Wil je echt dit bericht verwijderen?",
"pin": "Aan profiel vastmaken",
"pinned": "Vastgezet",
"unpin": "Van profiel losmaken",
- "delete": "Status verwijderen",
+ "delete": "Bericht verwijderen",
"repeats": "Herhalingen",
"favorites": "Favorieten",
"thread_muted_and_words": ", heeft woorden:",
- "thread_muted": "Thread genegeerd",
- "expand": "Uitklappen",
+ "thread_muted": "Gesprek genegeerd",
+ "expand": "Uitvouwen",
"nsfw": "Gevoelig",
"status_deleted": "Dit bericht is verwijderd",
"hide_content": "Inhoud verbergen",
@@ -808,7 +905,33 @@
"show_full_subject": "Volledig onderwerp tonen",
"external_source": "Externe bron",
"unbookmark": "Bladwijzer verwijderen",
- "bookmark": "Bladwijzer toevoegen"
+ "bookmark": "Bladwijzer toevoegen",
+ "show_attachment_description": "Voorbeeld beschrijving (open bijlage om de volledige beschrijving te zien)",
+ "remove_attachment": "Bijlage verwijderen",
+ "attachment_stop_flash": "Flash speler stoppen",
+ "move_up": "Bijlage naar links schuiven",
+ "move_down": "Bijlage naar rechts schuiven",
+ "open_gallery": "Gallerij openen",
+ "thread_hide": "Gesprek verbergen",
+ "thread_show": "Gesprek tonen",
+ "show_all_conversation": "Volledig gesprek tonen ({numStatus} ander bericht) | Volledig gesprek tonen ({numStatus} andere berichten)",
+ "show_only_conversation_under_this": "Alleen antwoorden op dit bericht tonen",
+ "mentions": "Vermeldingen",
+ "replies_list_with_others": "Antwoorden (+{numReplies} andere): | Antwoorden (+{numReplies} anderen):",
+ "you": "(Jij)",
+ "plus_more": "+{number} meer",
+ "many_attachments": "Bericht heeft {number} bijlage | Bericht heeft {number} bijlagen",
+ "collapse_attachments": "Bijlagen invouwen",
+ "show_all_attachments": "Alle bijlagen tonen",
+ "show_attachment_in_modal": "In media venster tonen",
+ "hide_attachment": "Bijlage verbergen",
+ "thread_show_full": "Alle berichten in dit gesprek tonen ({numStatus} bericht in totaal, max. diepte {depth}) | Alle berichten in dit gesprek tonen ({numStatus} berichten in totaal, max. diepte {depth})",
+ "thread_show_full_with_icon": "{icon} {text}",
+ "thread_follow": "Rest van gesprek tonen ({numStatus} bericht in totaal) | Rest van gesprek tonen ({numStatus} berichten in totaal)",
+ "thread_follow_with_icon": "{icon} {text}",
+ "ancestor_follow": "{numReplies} ander antwoord onder dit bericht tonen | {numReplies} andere antwoorden onder dit bericht tonen",
+ "ancestor_follow_with_icon": "{icon} {text}",
+ "show_all_conversation_with_icon": "{icon} {text}"
},
"time": {
"years_short": "{0}j",
@@ -842,13 +965,29 @@
"days_short": "{0}d",
"day_short": "{0}d",
"days": "{0} dagen",
- "day": "{0} dag"
+ "day": "{0} dag",
+ "unit": {
+ "months": "{0} maand | {0} maanden",
+ "months_short": "{0}ma",
+ "seconds": "{0} seconde | {0} seconden",
+ "seconds_short": "{0}s",
+ "weeks": "{0} week | {0} weken",
+ "weeks_short": "{0}w",
+ "years": "{0} jaar | {0} jaren",
+ "years_short": "{0}j",
+ "days": "{0} dag | {0} dagen",
+ "days_short": "{0}d",
+ "hours": "{0} uur | {0} uren",
+ "hours_short": "{0}u",
+ "minutes": "{0} minuut | {0} minuten",
+ "minutes_short": "{0}min"
+ }
},
"shoutbox": {
"title": "Shoutbox"
},
"errors": {
- "storage_unavailable": "Pleroma kon browseropslag niet benaderen. Je login of lokale instellingen worden niet opgeslagen en je kunt onverwachte problemen ondervinden. Probeer cookies te accepteren."
+ "storage_unavailable": "Pleroma kan de browseropslag niet benaderen. Je login of lokale instellingen worden niet opgeslagen en je kunt onverwachte problemen ondervinden. Probeer cookies te accepteren."
},
"display_date": {
"today": "Vandaag"
diff --git a/src/i18n/oc.json b/src/i18n/oc.json
index 40f48149..556b3d0b 100644
--- a/src/i18n/oc.json
+++ b/src/i18n/oc.json
@@ -501,8 +501,7 @@
"disable_remote_subscription": "Desactivar lo seguiment d’utilizaire d’instàncias alonhadas",
"disable_any_subscription": "Desactivar tot seguiment",
"quarantine": "Defendre la federacion de las publicacions de l’utilizaire",
- "delete_user": "Suprimir l’utilizaire",
- "delete_user_confirmation": "Volètz vertadièrament far aquÃ˛â€¯? Aquesta accion se pÃ˛t pas anullar."
+ "delete_user": "Suprimir l’utilizaire"
}
},
"user_profile": {
diff --git a/src/i18n/pl.json b/src/i18n/pl.json
index 304a0349..efebcc83 100644
--- a/src/i18n/pl.json
+++ b/src/i18n/pl.json
@@ -762,8 +762,7 @@
"disable_remote_subscription": "ZakaÅŧ obserwowania uÅŧytkownika ze zdalnych instancji",
"disable_any_subscription": "ZakaÅŧ całkowicie obserwowania uÅŧytkownika",
"quarantine": "ZakaÅŧ federowania postÃŗw od tego uÅŧytkownika",
- "delete_user": "Usuń uÅŧytkownika",
- "delete_user_confirmation": "Czy jesteś absolutnie pewny(-a)? Ta operacja nie moÅŧe być cofnięta."
+ "delete_user": "Usuń uÅŧytkownika"
},
"message": "Napisz",
"edit_profile": "Edytuj profil",
diff --git a/src/i18n/pt.json b/src/i18n/pt.json
index e32a95e4..b997701c 100644
--- a/src/i18n/pt.json
+++ b/src/i18n/pt.json
@@ -594,7 +594,6 @@
"unmute_progress": "A retirar silÃĒncioâ€Ļ",
"mute_progress": "A silenciarâ€Ļ",
"admin_menu": {
- "delete_user_confirmation": "Tens a certeza? Esta aÃ§ÃŖo nÃŖo pode ser revertida.",
"delete_user": "Eliminar utilizador",
"quarantine": "NÃŖo permitir publicaçÃĩes de utilizadores de instÃĸncias remotas",
"disable_any_subscription": "NÃŖo permitir que nenhum utilizador te siga",
diff --git a/src/i18n/ru.json b/src/i18n/ru.json
index ba0cec28..02815f3e 100644
--- a/src/i18n/ru.json
+++ b/src/i18n/ru.json
@@ -456,6 +456,15 @@
"subject_line_mastodon": "КаĐē в Mastodon: ҁĐēĐžĐŋĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ ĐēаĐē ĐĩŅŅ‚ŅŒ",
"subject_line_email": "КаĐē в ŅĐģĐĩĐēŅ‚Ņ€ĐžĐŊĐŊОК ĐŋĐžŅ‡Ņ‚Đĩ: \"re: Ņ‚ĐĩĐŧа\"",
"subject_line_behavior": "КоĐŋĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ Ņ‚ĐĩĐŧ҃ в ĐžŅ‚Đ˛ĐĩŅ‚Đ°Ņ…",
+ "third_column_mode": "ĐšĐžĐŗĐ´Đ° ĐŊĐĩĐ´ĐžŅŅ‚Đ°Ņ‚ĐžŅ‡ĐŊĐž ĐŧĐĩŅŅ‚Đ°, ĐŋĐžĐēĐ°ĐˇŅ‹Đ˛Đ°Ņ‚ŅŒ ҂ҀĐĩŅ‚ŅŒŅŽ ĐēĐžĐģĐžĐŊĐē҃ ŅĐžĐ´ĐĩŅ€ĐļĐ°Ņ‰ŅƒŅŽ",
+ "third_column_mode_none": "НĐĩ ĐŋĐžĐēĐ°ĐˇŅ‹Đ˛Đ°Ņ‚ŅŒ ҂ҀĐĩŅ‚ŅŒŅŽ ĐēĐžĐģĐžĐŊĐē҃ ŅĐžĐ˛ŅĐĩĐŧ",
+ "third_column_mode_notifications": "КоĐģĐžĐŊĐē҃ ŅƒĐ˛ĐĩĐ´ĐžĐŧĐģĐĩĐŊиК",
+ "third_column_mode_postform": "Đ¤ĐžŅ€Đŧ҃ ĐžŅ‚ĐŋŅ€Đ°Đ˛Đēи ŅĐžĐžĐąŅ‰ĐĩĐŊĐ¸Ņ и ĐŊĐ°Đ˛Đ¸ĐŗĐ°Ņ†Đ¸ŅŽ",
+ "columns": "КоĐģĐžĐŊĐēи",
+ "column_sizes": "РаСĐŧĐĩҀҋ ĐēĐžĐģĐžĐŊĐžĐē",
+ "column_sizes_sidebar": "БоĐēОвОК",
+ "column_sizes_content": "ХОдĐĩŅ€ĐļиĐŧĐžĐŗĐž",
+ "column_sizes_notifs": "ĐŖĐ˛ĐĩĐ´ĐžĐŧĐģĐĩĐŊиК",
"no_mutes": "НĐĩŅ‚ Đ¸ĐŗĐŊĐžŅ€Đ¸Ņ€ŅƒĐĩĐŧҋ҅",
"no_blocks": "НĐĩŅ‚ ĐąĐģĐžĐēĐ¸Ņ€ĐžĐ˛ĐžĐē",
"notification_visibility_emoji_reactions": "Đ ĐĩаĐēŅ†Đ¸Đ¸",
@@ -576,8 +585,7 @@
"disable_remote_subscription": "ЗаĐŋŅ€ĐĩŅ‚Đ¸Ņ‚ŅŒ Ņ‡Đ¸Ņ‚Đ°Ņ‚ŅŒ ҁ Đ´Ņ€ŅƒĐŗĐ¸Ņ… ŅƒĐˇĐģОв",
"disable_any_subscription": "ЗаĐŋŅ€ĐĩŅ‚Đ¸Ņ‚ŅŒ Ņ‡Đ¸Ņ‚Đ°Ņ‚ŅŒ ĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ĐĩĐģŅ",
"quarantine": "НĐĩ Ņ„ĐĩĐ´ĐĩŅ€Đ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ ŅŅ‚Đ°Ņ‚ŅƒŅŅ‹ ĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ĐĩĐģŅ",
- "delete_user": "ĐŖĐ´Đ°ĐģĐ¸Ņ‚ŅŒ ĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ĐĩĐģŅ",
- "delete_user_confirmation": "Đ’Ņ‹ ŅƒĐ˛ĐĩŅ€ĐĩĐŊŅ‹? Đ­Ņ‚Đž Đ´ĐĩĐšŅŅ‚Đ˛Đ¸Đĩ ĐŊĐĩĐģŅŒĐˇŅ ĐžŅ‚ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ."
+ "delete_user": "ĐŖĐ´Đ°ĐģĐ¸Ņ‚ŅŒ ĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ĐĩĐģŅ"
},
"media": "ĐĄ вĐģĐžĐļĐĩĐŊĐ¸ŅĐŧи",
"mention": "ĐŖĐŋĐžĐŧŅĐŊŅƒŅ‚ŅŒ",
diff --git a/src/i18n/service_worker_messages.js b/src/i18n/service_worker_messages.js
index 270ed043..f691f1c4 100644
--- a/src/i18n/service_worker_messages.js
+++ b/src/i18n/service_worker_messages.js
@@ -27,6 +27,7 @@ const messages = {
pt: require('../lib/notification-i18n-loader.js!./pt.json'),
ro: require('../lib/notification-i18n-loader.js!./ro.json'),
ru: require('../lib/notification-i18n-loader.js!./ru.json'),
+ sk: require('../lib/notification-i18n-loader.js!./sk.json'),
te: require('../lib/notification-i18n-loader.js!./te.json'),
zh: require('../lib/notification-i18n-loader.js!./zh.json'),
en: require('../lib/notification-i18n-loader.js!./en.json')
diff --git a/src/i18n/sk.json b/src/i18n/sk.json
new file mode 100644
index 00000000..cee76f5e
--- /dev/null
+++ b/src/i18n/sk.json
@@ -0,0 +1,512 @@
+{
+ "about": {
+ "mrf": {
+ "federation": "FederÃĄcia",
+ "keyword": {
+ "keyword_policies": "PravidlÃĄ pre kÄžÃēčovÊ slovÃĄ",
+ "ftl_removal": "OdstrÃĄnenie z časovej osy \"Celej znÃĄmej siete\"",
+ "reject": "Odmietni",
+ "replace": "Nahraď",
+ "is_replaced_by": "→"
+ },
+ "mrf_policies": "PovoliÅĨ MRF pravidlÃĄ",
+ "mrf_policies_desc": "MRF pravidlÃĄ upravujÃē sprÃĄvanie servera v rÃĄmci federÃĄcie s inÃŊmi. NasledovnÊ pravidlÃĄ sÃē aktívne:",
+ "simple": {
+ "simple_policies": "PravidlÃĄ ÅĄpecifickÊ pre tento server",
+ "instance": "Server",
+ "reason": "Dôvod",
+ "not_applicable": "N/A",
+ "accept": "PrijaÅĨ",
+ "accept_desc": "Tento server preberÃĄ sprÃĄvy len z nasledovnÃŊch serverov:",
+ "reject": "OdmietnuÅĨ",
+ "reject_desc": "Tento server preberÃĄ sprÃĄvy spravy z nasledovnÃŊch serverov:",
+ "quarantine": "KarantÊna",
+ "quarantine_desc": "Tento server posiela verejnÊ oznamy len na nasledovnÊ servre:",
+ "ftl_removal": "OdstrÃĄnenie časovej osy \"ZnÃĄma sieÅĨ\"",
+ "ftl_removal_desc": "Tento server odstraňuje nasledovnÊ serverov zo svojej časovej osy \"ZnÃĄma sieÅĨ\":",
+ "media_removal": "OdstrÃĄnenie mÊdií",
+ "media_removal_desc": "Tento server odstraňuje mÊdiÃĄ zo sprÃĄv nasledovnÃŊch serverov:",
+ "media_nsfw": "Označenie mÊdií ako citlivÃŊch",
+ "media_nsfw_desc": "Tento server označuje mÊdia ako citlivÊ v sprÃĄvach z nasledovnÃŊch serverov:"
+ }
+ },
+ "staff": "PersonÃĄl"
+ },
+ "shoutbox": {
+ "title": "VerejnÊ fÃŗrum"
+ },
+ "domain_mute_card": {
+ "mute": "UtÃ­ÅĄ",
+ "mute_progress": "UtiÅĄujemâ€Ļ",
+ "unmute": "PovoÄž oznamy",
+ "unmute_progress": "PovoÄžujem oznamyâ€Ļ"
+ },
+ "exporter": {
+ "export": "Export",
+ "processing": "SpracovÃĄva sa, čoskoro sa ti ponÃēknu na stiahnutie sÃēbory s dÃĄtami exportu"
+ },
+ "features_panel": {
+ "shout": "VerejnÊ fÃŗrum",
+ "pleroma_chat_messages": "Pleroma Chat",
+ "gopher": "Gopher",
+ "media_proxy": "Proxy pre mÊdiÃĄ",
+ "scope_options": "Nastavenia rÃĄmca",
+ "text_limit": "Limit počtu znakov",
+ "title": "Vlastnosti",
+ "who_to_follow": "Koho nasledovaÅĨ",
+ "upload_limit": "Limit nahrÃĄvania"
+ },
+ "finder": {
+ "error_fetching_user": "Chyba načítavania uŞívateĞa",
+ "find_user": "NÃĄjsÅĨ uŞívateÄža"
+ },
+ "general": {
+ "apply": "PouÅžiÅĨ",
+ "submit": "OdoslaÅĨ",
+ "more": "Viac",
+ "loading": "NahrÃĄvamâ€Ļ",
+ "generic_error": "Nastala chyba",
+ "error_retry": "Zopakuj znova, prosím",
+ "retry": "Zopakuj znova",
+ "optional": "nepovinnÊ",
+ "show_more": "Zobraz viac",
+ "show_less": "Zobraz menej",
+ "dismiss": "Zahoď",
+ "cancel": "ZruÅĄ",
+ "disable": "Vypni",
+ "enable": "Zapni",
+ "confirm": "PotvrdiÅĨ",
+ "verify": "OveriÅĨ",
+ "close": "ZatvoriÅĨ",
+ "peek": "VybraÅĨ",
+ "role": {
+ "admin": "SprÃĄvca",
+ "moderator": "ModerÃĄtor"
+ },
+ "flash_content": "Klikni pre zobrazenie Flash obsahu prostredníctvom Ruffle (experimentÃĄlne, nemusí fungovaÅĨ).",
+ "flash_security": "Flash obsah je potencionÃĄlne nebezpečnÃŊ, keďŞe je to produkt s uzatvorenÃŊm kÃŗdom.",
+ "flash_fail": "Nepodarilo sa nahraÅĨ Flash obsah, pre detaily pozri konzolu prehliadača.",
+ "scope_in_timeline": {
+ "direct": "Priame",
+ "private": "Len pre nasledovníkov",
+ "public": "VerejnÊ",
+ "unlisted": "NezaradenÊ"
+ }
+ },
+ "image_cropper": {
+ "crop_picture": "OrezaÅĨ obrÃĄzok",
+ "save": "UloÅžiÅĨ",
+ "save_without_cropping": "UloÅž bez orezania",
+ "cancel": "ZruÅĄiÅĨ"
+ },
+ "importer": {
+ "submit": "OdoslaÅĨ",
+ "success": "Úspečne naimportovanÊ.",
+ "error": "Pri importe sÃēboru nastala chyba."
+ },
+ "login": {
+ "login": "PrihlÃĄsiÅĨ sa",
+ "description": "PrihlÃĄsiÅĨ pomocou OAuth",
+ "logout": "OdhlÃĄsiÅĨ sa",
+ "password": "Heslo",
+ "placeholder": "napr. peter",
+ "register": "RegistrÃĄcia",
+ "username": "Meno uŞívateĞa",
+ "hint": "PrihlÃĄs sa, aby si sa mohol zÃēčastniÅĨ konverzÃĄcie",
+ "authentication_code": "AutentifikačnÃŊ kÃŗd",
+ "enter_recovery_code": "Zadaj kÃŗd obnovenia",
+ "enter_two_factor_code": "Zadaj 2-fÃĄzovÃŊ validačnÃŊ kÃŗd",
+ "recovery_code": "KÃŗd obnovenia",
+ "heading": {
+ "totp": "2-fÃĄzovÊ overenie",
+ "recovery": "2-fÃĄzovÊ obnova"
+ }
+ },
+ "media_modal": {
+ "previous": "PredchÃĄdzajÃēce",
+ "next": "NasledujÃēce",
+ "counter": "{current} / {total}",
+ "hide": "ZatvoriÅĨ prehliadač mÊdií"
+ },
+ "nav": {
+ "about": "O strÃĄnke",
+ "administration": "AdministrÃĄcia",
+ "back": "SpäÅĨ",
+ "friend_requests": "ÅŊiadosti o priateÄžstvo",
+ "mentions": "Zmienky",
+ "interactions": "Interakcie",
+ "dms": "Priame sprÃĄvy",
+ "public_tl": "VerejnÃĄ časovÃĄ os",
+ "timeline": "ČasovÃĄ os",
+ "home_timeline": "DomÃĄca časovÃĄ os",
+ "twkn": "ZnÃĄma sieÅĨ",
+ "bookmarks": "ZÃĄloÅžky",
+ "user_search": "HĞadanie uŞívateĞa",
+ "search": "HladaÅĨ",
+ "who_to_follow": "Koho nasledovaÅĨ",
+ "preferences": "Nastavenia",
+ "timelines": "ČasovÊ osy",
+ "chats": "Chaty"
+ },
+ "notifications": {
+ "broken_favorite": "NeznÃĄma sprÃĄva, dohÄžadÃĄvam juâ€Ļ",
+ "error": "Chyba získavania upozornení: {0}",
+ "favorited_you": "si obÄžÃēbil tvoju sprÃĄvu",
+ "followed_you": "ÅĨa nasleduje",
+ "follow_request": "ÅĨa chce nasledovaÅĨ",
+ "load_older": "NahraÅĨ starÅĄie upozornenia",
+ "notifications": "Upozornenia",
+ "read": "PrečítanÊ!",
+ "repeated_you": "zopakoval tvoju sprÃĄvu",
+ "no_more_notifications": "ÅŊiadne ďalÅĄie upozornenia",
+ "migrated_to": "sa presÅĨahoval na",
+ "reacted_with": "reagoval nasledovne {0}"
+ },
+ "polls": {
+ "add_poll": "PridaÅĨ anketu",
+ "add_option": "PridaÅĨ moÅžnosÅĨ",
+ "option": "MoÅžnosÅĨ",
+ "votes": "hlasy",
+ "people_voted_count": "{count} volič | {count} voličov",
+ "votes_count": "{count} hlas | {count} hlasov",
+ "vote": "Hlas",
+ "type": "Typ ankety",
+ "single_choice": "VÃŊber jednej moÅžnosti",
+ "multiple_choices": "VÃŊber viacerÃŊch moÅžností",
+ "expiry": "Vek ankety",
+ "expires_in": "Anketa končí za {0}",
+ "expired": "Anketa skončila pre {0}",
+ "not_enough_options": "PríliÅĄ mÃĄlo jedinečnÃŊch moÅžností v ankete"
+ },
+ "emoji": {
+ "stickers": "NÃĄlepka",
+ "emoji": "Emotikon",
+ "keep_open": "Ponechaj okno vÃŊberu otvorenÊ",
+ "search_emoji": "VyhladaÅĨ emotikon",
+ "add_emoji": "VloÅžiÅĨ emotikon",
+ "custom": "VlastnÃŊ emotikon",
+ "unicode": "Unicode emotikon",
+ "load_all_hint": "Nahralo sa prvÃŊch {saneAmount} emotikonov, nahranie vÅĄetkÃŊch by mohlo spôsobiÅĨ zníŞenie vÃŊkonu.",
+ "load_all": "NahraÅĨ vÅĄetkÃŊch {emojiAmount} emotikonov"
+ },
+ "errors": {
+ "storage_unavailable": "Pleroma nemôŞe pouŞívaÅĨ ÃēloÅžisko prehliadača. Tvoje prihlasovacie meno a lokÃĄlne nastavenia nebudÃē uchovanÊ a môŞu sa vyskytnÃēÅĨ neočakÃĄvanÊ chyby. SkÃēs povoliÅĨ cookie."
+ },
+ "interactions": {
+ "favs_repeats": "Zopakovania a obÄžÃēbenÊ",
+ "follows": "NovÃŊ nasledovatelia",
+ "moves": "UŞívateÄž sa sÅĨahuje",
+ "load_older": "NahraÅĨ starÅĄiu komunikÃĄciu"
+ },
+ "post_status": {
+ "new_status": "PoslaÅĨ novÃē sprÃĄvu",
+ "account_not_locked_warning": "Tvoj Ãēčen nie je {0}. KtokoÄžvek ÅĨa môŞe začaÅĨ nasledovaÅĨ a tak vidieÅĨ sprÃĄvy určenÊ len pre nasledovateÄžov.",
+ "account_not_locked_warning_link": "uzamknutÊ",
+ "attachments_sensitive": "OznačiÅĨ prílohy ako citlivÊ",
+ "media_description": "Popis mÊdia",
+ "content_type": {
+ "text/plain": "ObyčajnÃŊ text",
+ "text/html": "HTML",
+ "text/markdown": "Markdown",
+ "text/bbcode": "BBCode"
+ },
+ "content_warning": "Nadpis (nepovinnÊ)",
+ "default": "PrÃĄve som ...",
+ "direct_warning_to_all": "TÃēto sprÃĄvu bude vidieÅĨ kaÅždÃŊ uŞívateÄž, ktorÊho v nej spomenieÅĄ.",
+ "direct_warning_to_first_only": "TÃĄto sprÃĄva bude viditeÄžnÃĄ len pre uŞívateÄžov, ktorÃŊch vymenujeÅĄ na začiatku sprÃĄvy.",
+ "posting": "Posielanie",
+ "post": "PoslaÅĨ",
+ "preview": "NÃĄhÄžad",
+ "preview_empty": "PrÃĄzdne",
+ "empty_status_error": "Nie je moÅžnÊ odoslaÅĨ prÃĄzdnu sprÃĄvu bez priloÅženÃŊch sÃēborov",
+ "media_description_error": "Nepodarilo sa aktualizovaÅĨ mÊdia, skÃēs znova",
+ "scope_notice": {
+ "public": "TÃēto sprÃĄvu bude vidieÅĨ kaÅždÃŊ",
+ "private": "TÃēto sprÃĄvu budÃē vidieÅĨ len tvoji nasledovníci",
+ "unlisted": "TÃĄto sprÃĄva nebude viditeÄžnÃĄ na verejnej časovej osi a v celej znÃĄmej sieti"
+ },
+ "scope": {
+ "direct": "Priama sprÃĄva - zobrazí sa len uŞívateÄžom spomenutÃŊm v sprÃĄve",
+ "private": "Pre nasledovníkov - zobrazí sa len tvojim nasledovníkom",
+ "public": "VerejnÊ - zobrazí sa vo vÅĄetkÃŊch časovÃŊch osiach",
+ "unlisted": "NezaradenÊ - nezobrazí sa v Şiadnej časovej osy"
+ }
+ },
+ "registration": {
+ "bio": "ÅŊivotopis",
+ "email": "Email",
+ "fullname": "ZobrazovanÊ meno",
+ "password_confirm": "Potvrdenie hesla",
+ "registration": "RegistrÃĄcia",
+ "token": "PozÃŊvací kÃŗd",
+ "captcha": "CAPTCHA",
+ "new_captcha": "Klikni na obrÃĄzok a vnikne novÃĄ captcha",
+ "username_placeholder": "napr. peter",
+ "fullname_placeholder": "napr. Peter Kukurica",
+ "bio_placeholder": "e.g.\nHi, I'm Lain.\nI’m an anime girl living in suburban Japan. You may know me from the Wired.",
+ "reason": "Dôvod registrÃĄcie",
+ "reason_placeholder": "Tento server schvaÄžuje registrÃĄcie manuÃĄlne.\nZanechaj sprÃĄvcom dôvod, prečo mÃĄÅĄ zÃĄujem vytvoriÅĨ si tu Ãēčet.",
+ "register": "RegistrÃĄcia",
+ "validations": {
+ "username_required": "nemôŞe byÅĨ prÃĄzdne",
+ "fullname_required": "nemôŞe byÅĨ prÃĄzdne",
+ "email_required": "nemôŞe byÅĨ prÃĄzdne",
+ "password_required": "nemôŞe byÅĨ prÃĄzdne",
+ "password_confirmation_required": "nemôŞe byÅĨ prÃĄzdne",
+ "password_confirmation_match": "musí byÅĨ rovnakÊ ako heslo"
+ }
+ },
+ "remote_user_resolver": {
+ "remote_user_resolver": "VzdialenÊ overenie uŞívateĞa",
+ "searching_for": "HÄžadÃĄm...",
+ "error": "NenÃĄjdenÊ."
+ },
+ "selectable_list": {
+ "select_all": "VybraÅĨ vÅĄetko"
+ },
+ "time": {
+ "day": "{0} deň",
+ "days": "{0} dní",
+ "day_short": "{0}d",
+ "days_short": "{0}d",
+ "hour": "{0} hodina",
+ "hours": "{0} hodín",
+ "hour_short": "{0}h",
+ "hours_short": "{0}h",
+ "in_future": "za {0}",
+ "in_past": "pred {0}",
+ "minute": "{0} minÃēta",
+ "minutes": "{0} minÃēt",
+ "minute_short": "{0}min",
+ "minutes_short": "{0}min",
+ "month": "{0} mesiac",
+ "months": "{0} mesiacov",
+ "month_short": "{0}mes",
+ "months_short": "{0}mes",
+ "now": "prÃĄve teraz",
+ "now_short": "teraz",
+ "second": "{0} sekunda",
+ "seconds": "{0} sekÃēnd",
+ "second_short": "{0}s",
+ "seconds_short": "{0}s",
+ "week": "{0} tÃŊÅždeň",
+ "weeks": "{0} tÃŊÅždňov",
+ "week_short": "{0}t",
+ "weeks_short": "{0}t",
+ "year": "{0} rok",
+ "years": "{0} rokov",
+ "year_short": "{0}r",
+ "years_short": "{0}r"
+ },
+ "timeline": {
+ "collapse": "ZbaliÅĨ",
+ "conversation": "KonverzÃĄcia",
+ "error": "Chyba pri nahrÃĄvaní časovej sprÃĄvy: {0}",
+ "load_older": "NahraÅĨ starÅĄie sprÃĄvy",
+ "no_retweet_hint": "SprÃĄva je označenÃĄ ako len-pre-nasledovateÄžov alebo ako priama a nemôŞe byÅĨ zopakovanÃĄ na tvojej časovej osy.",
+ "repeated": "zopakovanÊ",
+ "show_new": "ZobraziÅĨ novÊ",
+ "reload": "Znovu nahraÅĨ",
+ "up_to_date": "AktuÃĄlne",
+ "no_more_statuses": "ÅŊiadne ďalÅĄie sprÃĄvy",
+ "no_statuses": "ÅŊiadne sprÃĄvy",
+ "socket_reconnected": "Prepojenie v reÃĄlnom čase bolo ÃēspeÅĄne vytvorenÊ",
+ "socket_broke": "Strata prepojenia v reÃĄlnom čase: chyba CloseEvent kÃŗd {0}"
+ },
+ "status": {
+ "favorites": "ObÄžÃēbenÊ",
+ "repeats": "Opakovania",
+ "delete": "ZmazaÅĨ sprÃĄvu",
+ "pin": "PripnÃēÅĨ na strÃĄnku uŞívateÄža",
+ "unpin": "OdopnÃēÅĨ zo strÃĄnky uŞívateÄža",
+ "pinned": "PripnutÊ",
+ "bookmark": "VytvoriÅĨ zÃĄloÅžku",
+ "unbookmark": "ZmazaÅĨ zÃĄloÅžku",
+ "delete_confirm": "Skutočne chceÅĄ zmazaÅĨ tÃēto sprÃĄvu?",
+ "reply_to": "OdpovedaÅĨ komu",
+ "mentions": "Spomenutia",
+ "replies_list": "Odpovede:",
+ "replies_list_with_others": "Odpoveď (+{numReplies} inÃŊ): | Odpoveď (+{numReplies} inÃŊch):",
+ "mute_conversation": "StÃ­ÅĄiÅĨ konverzÃĄciu",
+ "unmute_conversation": "OznamovaÅĨ konverzÃĄciu",
+ "status_unavailable": "NeznÃĄmy status",
+ "copy_link": "SkopírovaÅĨ odkaz do sprÃĄvy",
+ "external_source": "VzdialenÃŊ zdroj",
+ "thread_muted": "KonverzÃĄcia stÃ­ÅĄenÃĄ",
+ "thread_muted_and_words": ", mÃĄ slovÃĄ:",
+ "show_full_subject": "ZobraziÅĨ celÃŊ nadpis",
+ "hide_full_subject": "Skry celÃŊ nadpis",
+ "show_content": "ZobraziÅĨ obsah",
+ "hide_content": "SkryÅĨ obsah",
+ "status_deleted": "TÃĄto sprÃĄva bola zmazanÃĄ",
+ "nsfw": "NSFW",
+ "expand": "RozbaliÅĨ sprÃĄvu",
+ "you": "(ty)",
+ "plus_more": "+{number} ďalÅĄÃ­ch",
+ "many_attachments": "SprÃĄva mÃĄ {number} príloh",
+ "collapse_attachments": "ZabaliÅĨ mÊdiÃĄ",
+ "show_all_attachments": "Zobraz vÅĄetky prílohy",
+ "show_attachment_in_modal": "Zobraz mÊdiÃĄ modÃĄlne",
+ "show_attachment_description": "NÃĄhÄžad popisku (otvor prílohu pre zobrazenie celÊho popisku)",
+ "hide_attachment": "SkryÅĨ prílohy",
+ "remove_attachment": "OdstrÃĄniÅĨ prílohy",
+ "attachment_stop_flash": "ZastaviÅĨ prehrÃĄvač Flashu",
+ "move_up": "Presuň prílohu doÄžava",
+ "move_down": "Presuň prílohu doprava",
+ "open_gallery": "OtvoriÅĨ galÊriu",
+ "thread_hide": "Skry tÃēto konverzÃĄciu",
+ "thread_show": "Zobraz tÃēto konverzÃĄciu",
+ "thread_show_full": "Zobraz vÅĄetko pod touto konverzÃĄciou (celkovo {numStatus} sprÃĄva, max hÄēbka {depth}) | Zobraz vÅĄetko pod touto konverzÃĄciou (celkovo {numStatus} sprÃĄv, max hÄēbka {depth})",
+ "thread_show_full_with_icon": "{icon} {text}",
+ "thread_follow": "Zobraz zvyÅĄnÃē časÅĨ tejto konverzÃĄcie (celkovo {numStatus} sprÃĄva) | Zobraz zvyÅĄnÃē časÅĨ tejto konverzÃĄcie (celkovo {numStatus} sprÃĄv)",
+ "thread_follow_with_icon": "{icon} {text}",
+ "ancestor_follow": "Pozri {numReplies} ďalÅĄiu odpoveď pod touto sprÃĄvou | Pozri {numReplies} ďalÅĄÃ­ch odpovedí pod touto sprÃĄvou",
+ "ancestor_follow_with_icon": "{icon} {text}",
+ "show_all_conversation_with_icon": "{icon} {text}",
+ "show_all_conversation": "Zobraz celÃē konverzÃĄciu ({numStatus} inÃĄ sprÃĄva) | Zobraz celÃē konverzÃĄciu ({numStatus} inÃŊch sprÃĄv)",
+ "show_only_conversation_under_this": "Zobraz len sprÃĄvy sÃēvisiace s touto sprÃĄvou"
+ },
+ "user_card": {
+ "approve": "SchvÃĄliÅĨ",
+ "block": "ZablokovaÅĨ",
+ "blocked": "BlokovanÊ!",
+ "deactivated": "Neaktívne",
+ "deny": "ZakÃĄzanÊ",
+ "edit_profile": "UraviÅĨ profil",
+ "favorites": "ObÄžÃēbenÊ",
+ "follow": "NasledovaÅĨ",
+ "follow_cancel": "PoÅžiadavka zruÅĄenÃĄ",
+ "follow_sent": "PoÅžiadavka zaslanÃĄ!",
+ "follow_progress": "ÅŊiadam o povolenieâ€Ļ",
+ "follow_unfollow": "PrestaÅĨ sledovaÅĨ",
+ "followees": "Nasleduje",
+ "followers": "Nasledovatelia",
+ "following": "NasledujeÅĄ!",
+ "follows_you": "Nasleduje teba!",
+ "hidden": "SkrytÊ",
+ "its_you": "To si ty!",
+ "media": "MÊdia",
+ "mention": "Spomenul",
+ "message": "SprÃĄva",
+ "mute": "StÃ­ÅĄiÅĨ",
+ "muted": "StÃ­ÅĄenÊ",
+ "per_day": "za deň",
+ "remote_follow": "Nasledovanie z ďaleka",
+ "report": "NahlÃĄsiÅĨ",
+ "statuses": "VytvorenÃŊch sprÃĄv",
+ "subscribe": "PrihlÃĄsiÅĨ k odberu",
+ "unsubscribe": "OdhlÃĄsiÅĨ z odberu",
+ "unblock": "OdblokovaÅĨ",
+ "unblock_progress": "OblokovÃĄva saâ€Ļ",
+ "block_progress": "Blokujemâ€Ļ",
+ "unmute": "PovoliÅĨ oznamy",
+ "unmute_progress": "PovoÄžujem oznamyâ€Ļ",
+ "mute_progress": "StiÅĄujemâ€Ļ",
+ "hide_repeats": "Skry zopakovania",
+ "show_repeats": "Zobraz zopakovania",
+ "bot": "Robot",
+ "admin_menu": {
+ "moderation": "Moderovanie",
+ "grant_admin": "PovoliÅĨ spravovanie",
+ "revoke_admin": "ZakÃĄzaÅĨ spravovanie",
+ "grant_moderator": "PovoliÅĨ moderovanie",
+ "revoke_moderator": "ZakÃĄzaÅĨ moderovanie",
+ "activate_account": "AktivovaÅĨ Ãēčet",
+ "deactivate_account": "DeaktivovaÅĨ Ãēčet",
+ "delete_account": "ZmazaÅĨ Ãēčet",
+ "force_nsfw": "Označ vÅĄetky sprÃĄvy ako NSFW",
+ "strip_media": "OdstrÃĄniÅĨ mÊdia zo sprÃĄvy",
+ "force_unlisted": "VynÃēÅĨ, aby sprÃĄvy neboli zobrazovanÊ",
+ "sandbox": "VynÃēÅĨ, aby sprÃĄvy boli len pre nasledovateÄžov",
+ "disable_remote_subscription": "OdstrÃĄniÅĨ prístup k serveru nasledovnÊmu vzdialenÊmu uŞívateÄžovi",
+ "disable_any_subscription": "ZakÃĄzaÅĨ nasledovanie uŞívateÄžov",
+ "quarantine": "ZakÃĄzaÅĨ federÃĄciu sprÃĄv uŞívateÄža",
+ "delete_user": "ZmazaÅĨ uŞívateÄža",
+ "delete_user_confirmation": "Si si Ãēplne istÃŊ? TÃĄto akcia sa nedÃĄ zobraÅĨ späÅĨ."
+ },
+ "highlight": {
+ "disabled": "Bez zvÃŊraznenia",
+ "solid": "Jednoliate pozadie",
+ "striped": "ŠrafovanÊ pozadie",
+ "side": "PÃĄsik na boku"
+ }
+ },
+ "user_profile": {
+ "timeline_title": "ČasovÃĄ os uŞívateÄža",
+ "profile_does_not_exist": "PrepÃĄÄ, tento profil neexistuje.",
+ "profile_loading_error": "PrepÃĄÄ, nastala chyba pri nahrÃĄvaní profilu."
+ },
+ "user_reporting": {
+ "title": "NahlÃĄsení {0}",
+ "add_comment_description": "HlÃĄsnenie bude zaslanÊ moderÃĄtorom servera. NiÅžÅĄie môŞeÅĄ napísaÅĨ dôvod prečo tento Ãēčet nahlasujeÅĄ:",
+ "additional_comments": "ĎalÅĄie poznÃĄmky",
+ "forward_description": "Účet je z inÊho servera. PoslaÅĨ kÃŗpiu tohto hlÃĄsenia aj tam?",
+ "forward_to": "PreposlaÅĨ komu {0}",
+ "submit": "OdoslaÅĨ",
+ "generic_error": "Nastala chyba pri vykonaní tvojej poŞiadavky."
+ },
+ "who_to_follow": {
+ "more": "Viac",
+ "who_to_follow": "Koho nasledovaÅĨ"
+ },
+ "tool_tip": {
+ "media_upload": "NahraÅĨ mÊdium",
+ "repeat": "ZopakovaÅĨ",
+ "reply": "OdpovedaÅĨ",
+ "favorite": "ObÄžÃēbenÊ",
+ "add_reaction": "ReagovaÅĨ",
+ "user_settings": "Nastavenia uŞívateĞa",
+ "accept_follow_request": "PrijaÅĨ poÅžiadavku nasledovníka",
+ "reject_follow_request": "OdmietnuÅĨ poÅžiadavku nasledovníka",
+ "bookmark": "ZÃĄloÅžka"
+ },
+ "upload": {
+ "error": {
+ "base": "NahrÃĄvanie bolo neÃēspeÅĄnÊ.",
+ "message": "NahrÃĄvanie bolo neÃēspeÅĄnÊ: {0}",
+ "file_too_big": "SÃēbor je príliÅĄ veÄžkÃŊ [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
+ "default": "VyskÃēÅĄaj opäÅĨ neskôr"
+ }
+ },
+ "search": {
+ "people": "ÄŊudia",
+ "hashtags": "HaÅĄtagy",
+ "person_talking": "{count} človek hovorí",
+ "people_talking": "{count} Ğudí hovorí",
+ "no_results": "ÅŊiadne vÃŊsledky"
+ },
+ "password_reset": {
+ "forgot_password": "Zabudol si heslo?",
+ "password_reset": "Obnovenie hesla",
+ "instruction": "Zadaj svoju emailovÃē adresu alebo uŞívateÄžskÊ meno. PoÅĄleme ti odkaz pomocou, ktorÊho môŞeÅĄ obnoviÅĨ svoje heslo.",
+ "placeholder": "Tvoj email alebo uŞívateĞskÊ meno",
+ "check_email": "V novom emaile ti bol doručenÃŊ odkaz na spôsob, ako obnovÃ­ÅĄ svoje heslo.",
+ "return_home": "NÃĄvrat na domÃĄcu strÃĄnku",
+ "too_many_requests": "Prekročil si limit pokusov, skÃēs znova neskôr.",
+ "password_reset_disabled": "Obnova hesla je vypnutÃĄ. Kontaktuj, prosím, sprÃĄvcu tohto servera.",
+ "password_reset_required": "MusÃ­ÅĄ najskôr obnoviÅĨ heslo, ak sa chceÅĄ prihlÃĄsiÅĨ.",
+ "password_reset_required_but_mailer_is_disabled": "MusÃ­ÅĄ obnoviÅĨ svoje heslo, ale obnova hesla je na serveri vypnutÃĄ. Kontaktuj, prosím, sprÃĄvcu tohto servera."
+ },
+ "chats": {
+ "you": "Ty:",
+ "message_user": "SprÃĄva {nickname}",
+ "delete": "ZmazaÅĨ",
+ "chats": "Rozhovor",
+ "new": "NovÃŊ rozhovor",
+ "empty_message_error": "Nie je moÅžnÊ odoslaÅĨ prÃĄzdnu sprÃĄvu",
+ "more": "Viac",
+ "delete_confirm": "Skutočne chceÅĄ zmazaÅĨ tÃēto sprÃĄvu?",
+ "error_loading_chat": "Nastala chyba pri nahrÃĄvaní rozhovoru.",
+ "error_sending_message": "Nastala chyba pri odosielaní sprÃĄv.",
+ "empty_chat_list_placeholder": "NemÃĄÅĄ za sebou Åžiadne rozhovory. Začni novÃŊ rozhovor!"
+ },
+ "file_type": {
+ "audio": "Audio",
+ "video": "Video",
+ "image": "ObrÃĄzok",
+ "file": "SÃēbor"
+ },
+ "display_date": {
+ "today": "Dnes"
+ }
+}
diff --git a/src/i18n/te.json b/src/i18n/te.json
index 1216de59..4f255505 100644
--- a/src/i18n/te.json
+++ b/src/i18n/te.json
@@ -49,7 +49,7 @@
"notifications.repeated_you": "ā°Žāą€ ā°¸āąā°Ĩā°ŋā°¤ā°ŋā°¨ā°ŋ ā°Ēāąā°¨ā°°ā°žā°ĩāąƒā°¤ā°‚ ā°šāą‡ā°¸ā°žā°°āą",
"notifications.no_more_notifications": "ⰇⰕ ā°¨āą‹ā°Ÿā°ŋā°Ģā°ŋā°•āą‡ā°ˇā°¨āąā°˛āą ā°˛āą‡ā°ĩāą",
"post_status.new_status": "ā°•āąā°°āąŠā°¤āąā°¤ ā°¸āąā°Ĩā°ŋā°¤ā°ŋā°¨ā°ŋ ā°Ēāą‹ā°¸āąā°Ÿāą ā°šāą‡ā°¯ā°‚ā°Ąā°ŋ",
- "post_status.account_not_locked_warning": "ā°Žāą€ ā°–ā°žā°¤ā°ž {āąĻ} ā°•ā°žā°Ļāą. ā°Žā°ĩā°°āąˆā°¨ā°ž ā°Žā°ŋā°Žāąā°Žā°˛āąā°¨ā°ŋ ā°…ā°¨āąā°¸ā°°ā°ŋā°‚ā°šā°ŋ ā°…ā°¨āąā°šā°°āąā°˛ā°•āą ā°Žā°žā°¤āąā°°ā°Žāą‡ ā°‰ā°Ļāąā°Ļāą‡ā°ļā°ŋā°‚ā°šā°ŋā°¨ ā°Ēāą‹ā°¸āąā°Ÿāąā°˛ā°¨āą ā°šāą‚ā°Ąā°ĩā°šāąā°šāą.",
+ "post_status.account_not_locked_warning": "ā°Žāą€ ā°–ā°žā°¤ā°ž {0} ā°•ā°žā°Ļāą. ā°Žā°ĩā°°āąˆā°¨ā°ž ā°Žā°ŋā°Žāąā°Žā°˛āąā°¨ā°ŋ ā°…ā°¨āąā°¸ā°°ā°ŋā°‚ā°šā°ŋ ā°…ā°¨āąā°šā°°āąā°˛ā°•āą ā°Žā°žā°¤āąā°°ā°Žāą‡ ā°‰ā°Ļāąā°Ļāą‡ā°ļā°ŋā°‚ā°šā°ŋā°¨ ā°Ēāą‹ā°¸āąā°Ÿāąā°˛ā°¨āą ā°šāą‚ā°Ąā°ĩā°šāąā°šāą.",
"post_status.account_not_locked_warning_link": "ā°¤ā°žā°ŗā°‚ ā°ĩāą‡ā°¯ā°Ŧā°Ąā°ŋā°¨ā°Ļā°ŋ",
"post_status.attachments_sensitive": "ā°œāą‹ā°Ąā°ŋā°‚ā°Ēāąā°˛ā°¨āą ā°¸āąā°¨āąā°¨ā°ŋā°¤ā°Žāąˆā°¨ā°ĩā°ŋā°—ā°ž ā°—āąā°°āąā°¤ā°ŋā°‚ā°šā°‚ā°Ąā°ŋ",
"post_status.content_type.text/plain": "ā°¸ā°žā°§ā°žā°°ā°Ŗ ā°…ā°•āąā°ˇā°°ā°žā°˛āą",
diff --git a/src/i18n/uk.json b/src/i18n/uk.json
index d9833087..c75ed197 100644
--- a/src/i18n/uk.json
+++ b/src/i18n/uk.json
@@ -24,7 +24,15 @@
},
"flash_content": "ĐĐ°Ņ‚Đ¸ŅĐŊŅ–Ņ‚ŅŒ Đ´ĐģŅ ĐŋĐĩŅ€ĐĩĐŗĐģŅĐ´Ņƒ СĐŧŅ–ŅŅ‚Ņƒ Flash Са Đ´ĐžĐŋĐžĐŧĐžĐŗĐžŅŽ Ruffle (ĐĩĐēҁĐŋĐĩŅ€Đ¸ĐŧĐĩĐŊŅ‚Đ°ĐģҌĐŊĐž, ĐŧĐžĐļĐĩ ĐŊĐĩ ĐŋŅ€Đ°Ņ†ŅŽĐ˛Đ°Ņ‚Đ¸).",
"flash_security": "ĐĻŅ Ņ„ŅƒĐŊĐēŅ†Ņ–Ņ ĐŧĐžĐļĐĩ ŅŅ‚Đ°ĐŊĐžĐ˛Đ¸Ņ‚Đ¸ Ņ€Đ¸ĐˇĐ¸Đē, ĐžŅĐēŅ–ĐģҌĐēи Flash-вĐŧҖҁ҂ Đ˛ŅĐĩ ҉Đĩ Ņ” ĐŋĐžŅ‚ĐĩĐŊŅ†Ņ–ĐšĐŊĐž ĐŊĐĩĐąĐĩСĐŋĐĩ҇ĐŊиĐŧ.",
- "flash_fail": "НĐĩ вдаĐģĐžŅŅ СаваĐŊŅ‚Đ°ĐļĐ¸Ņ‚Đ¸ Flash-вĐŧҖҁ҂, Đ´ĐžĐēĐģадĐŊŅ–ŅˆŅƒ Ņ–ĐŊŅ„ĐžŅ€ĐŧĐ°Ņ†Ņ–ŅŽ Đ´Đ¸Đ˛Đ¸ŅŅŒ ҃ ĐēĐžĐŊŅĐžĐģŅ–."
+ "flash_fail": "НĐĩ вдаĐģĐžŅŅ СаваĐŊŅ‚Đ°ĐļĐ¸Ņ‚Đ¸ Flash-вĐŧҖҁ҂, Đ´ĐžĐēĐģадĐŊŅ–ŅˆŅƒ Ņ–ĐŊŅ„ĐžŅ€ĐŧĐ°Ņ†Ņ–ŅŽ Đ´Đ¸Đ˛Đ¸ŅŅŒ ҃ ĐēĐžĐŊŅĐžĐģŅ–.",
+ "generic_error_message": "ВиĐŊиĐēĐģа ĐŋĐžĐŧиĐģĐēа: {0}",
+ "never_show_again": "ĐŅ–ĐēĐžĐģи ĐŊĐĩ ĐŋĐžĐēĐ°ĐˇŅƒĐ˛Đ°Ņ‚Đ¸ СĐŊĐžĐ˛Ņƒ",
+ "scope_in_timeline": {
+ "direct": "ĐŸŅ€Đ¸Đ˛Đ°Ņ‚ĐŊĐĩ",
+ "private": "Đ›Đ¸ŅˆĐĩ Ņ‡Đ¸Ņ‚Đ°Ņ‡Ņ–",
+ "public": "ĐŸŅƒĐąĐģҖ҇ĐŊĐĩ",
+ "unlisted": "НĐĩĐŋŅƒĐąĐģҖ҇ĐŊĐĩ"
+ }
},
"finder": {
"error_fetching_user": "ĐšĐžŅ€Đ¸ŅŅ‚ŅƒĐ˛Đ°Ņ‡Đ° ĐŊĐĩ СĐŊаКдĐĩĐŊĐž",
@@ -39,7 +47,8 @@
"scope_options": "ĐŸĐ°Ņ€Đ°ĐŧĐĩŅ‚Ņ€Đ¸ ĐžĐąŅŅĐŗŅƒ",
"media_proxy": "ĐŸĐžŅĐĩŅ€ĐĩĐ´ĐŊиĐē ĐŧĐĩĐ´Ņ–Đ°-даĐŊĐ¸Ņ…",
"text_limit": "Đ›Ņ–ĐŧŅ–Ņ‚ ŅĐ¸ĐŧвОĐģŅ–Đ˛",
- "upload_limit": "ОбĐŧĐĩĐļĐĩĐŊĐŊŅ СаваĐŊŅ‚Đ°ĐļĐĩĐŊҌ"
+ "upload_limit": "ОбĐŧĐĩĐļĐĩĐŊĐŊŅ СаваĐŊŅ‚Đ°ĐļĐĩĐŊҌ",
+ "shout": "ĐžĐŗĐžĐģĐžŅˆĐĩĐŊĐŊŅ"
},
"exporter": {
"processing": "ОĐŋŅ€Đ°Ņ†ŅŒĐžĐ˛ŅƒŅŽ, ҁĐēĐžŅ€Đž ви СĐŧĐžĐļĐĩŅ‚Đĩ СаваĐŊŅ‚Đ°ĐļĐ¸Ņ‚Đ¸ Ņ„Đ°ĐšĐģ",
@@ -70,7 +79,9 @@
"accept": "ĐŸŅ€Đ¸ĐšĐŊŅŅ‚Đ¸",
"reject": "Đ’Ņ–Đ´Ņ…Đ¸ĐģĐ¸Ņ‚Đ¸",
"accept_desc": "ĐŸĐžŅ‚ĐžŅ‡ĐŊиК Ņ–ĐŊŅŅ‚Đ°ĐŊҁ ĐŋŅ€Đ¸ĐšĐŧĐ°Ņ” ĐŋĐžĐ˛Ņ–Đ´ĐžĐŧĐģĐĩĐŊĐŊŅ ҂ҖĐģҌĐēи С ĐŋĐĩŅ€ĐĩĐģҖ҇ĐĩĐŊĐ¸Ņ… Ņ–ĐŊŅŅ‚Đ°ĐŊŅŅ–Đ˛:",
- "simple_policies": "ĐŸŅ€Đ°Đ˛Đ¸Đģа ĐŋĐžŅ‚ĐžŅ‡ĐŊĐžĐŗĐž Ņ–ĐŊŅŅ‚Đ°ĐŊҁ҃"
+ "simple_policies": "ĐŸŅ€Đ°Đ˛Đ¸Đģа ĐŋĐžŅ‚ĐžŅ‡ĐŊĐžĐŗĐž Ņ–ĐŊŅŅ‚Đ°ĐŊҁ҃",
+ "reason": "ĐŸŅ€Đ¸Ņ‡Đ¸ĐŊа",
+ "not_applicable": "ĐŊ/в"
},
"mrf_policies_desc": "ĐŸŅ€Đ°Đ˛Đ¸Đģа MRF Ņ€ĐžĐˇĐŋĐžĐ˛ŅŅŽĐ´ĐļŅƒŅŽŅ‚ŅŒŅŅ ĐŊа даĐŊиК Ņ–ĐŊŅŅ‚Đ°ĐŊҁ. ĐĐ°ŅŅ‚ŅƒĐŋĐŊŅ– ĐŋŅ€Đ°Đ˛Đ¸Đģа аĐēŅ‚Đ¸Đ˛ĐŊŅ–:",
"mrf_policies": "АĐēŅ‚Đ¸Đ˛ŅƒĐ˛Đ°Ņ‚Đ¸ ĐŋŅ€Đ°Đ˛Đ¸Đģа MRF (ĐŧĐžĐ´ŅƒĐģҌ ĐŋĐĩŅ€ĐĩĐŋĐ¸ŅŅƒĐ˛Đ°ĐŊĐŊŅ ĐŋĐžĐ˛Ņ–Đ´ĐžĐŧĐģĐĩĐŊҌ)",
@@ -141,7 +152,8 @@
"followed_you": "ĐŋŅ–Đ´ĐŋĐ¸ŅĐ°Đ˛ŅŅ(-ĐģĐ°ŅŅŒ) ĐŊа Đ˛Đ°Ņ",
"favorited_you": "вĐŋОдОйав(-Đģа) Đ˛Đ°Ņˆ Đ´ĐžĐŋĐ¸Ņ",
"broken_favorite": "НĐĩĐ˛Ņ–Đ´ĐžĐŧиК Đ´ĐžĐŋĐ¸Ņ, ҈҃ĐēĐ°ŅŽ ĐšĐžĐŗĐžâ€Ļ",
- "error": "ПоĐŧиĐģĐēа ĐŋŅ€Đ¸ ĐžĐŊОвĐģĐĩĐŊĐŊŅ– ҁĐŋĐžĐ˛Ņ–Ņ‰ĐĩĐŊҌ: {0}"
+ "error": "ПоĐŧиĐģĐēа ĐŋŅ€Đ¸ ĐžĐŊОвĐģĐĩĐŊĐŊŅ– ҁĐŋĐžĐ˛Ņ–Ņ‰ĐĩĐŊҌ: {0}",
+ "poll_ended": "ĐžĐŋĐ¸Ņ‚ŅƒĐ˛Đ°ĐŊĐŊŅ СаĐēŅ–ĐŊ҇ĐĩĐŊĐž"
},
"nav": {
"chats": "Đ§Đ°Ņ‚Đ¸",
@@ -161,11 +173,14 @@
"mentions": "Đ—ĐŗĐ°Đ´ŅƒĐ˛Đ°ĐŊĐŊŅ",
"back": "Назад",
"administration": "АдĐŧŅ–ĐŊŅ–ŅŅ‚Ņ€ŅƒĐ˛Đ°ĐŊĐŊŅ",
- "home_timeline": "ДоĐŧĐ°ŅˆĐŊŅ ҁ҂ҀҖ҇Đēа"
+ "home_timeline": "ДоĐŧĐ°ŅˆĐŊŅ ҁ҂ҀҖ҇Đēа",
+ "lists": "ĐĄĐŋĐ¸ŅĐēи"
},
"media_modal": {
"next": "ĐĐ°ŅŅ‚ŅƒĐŋĐŊа",
- "previous": "ПоĐŋĐĩŅ€ĐĩĐ´ĐŊŅ"
+ "previous": "ПоĐŋĐĩŅ€ĐĩĐ´ĐŊŅ",
+ "counter": "{current} / {total}",
+ "hide": "ЗаĐēŅ€Đ¸Ņ‚Đ¸ ĐŧĐĩĐ´Ņ–Đ°ĐŋĐĩŅ€ĐĩĐŗĐģŅĐ´Đ°Ņ‡"
},
"password_reset": {
"instruction": "ВвĐĩĐ´Ņ–Ņ‚ŅŒ ŅĐ˛ĐžŅŽ Đ°Đ´Ņ€Đĩҁ҃ ĐĩĐģĐĩĐēŅ‚Ņ€ĐžĐŊĐŊĐžŅ— ĐŋĐžŅˆŅ‚Đ¸ айО Ņ–Đŧâ€™Ņ ĐēĐžŅ€Đ¸ŅŅ‚ŅƒĐ˛Đ°Ņ‡Đ°. Ми ĐŊĐ°Đ´Ņ–ŅˆĐģĐĩĐŧĐž ваĐŧ ĐŋĐžŅĐ¸ĐģаĐŊĐŊŅ Đ´ĐģŅ ҁĐēидаĐŊĐŊŅ ĐŋĐ°Ņ€ĐžĐģŅ.",
@@ -205,7 +220,8 @@
"load_older": "ЗаваĐŊŅ‚Đ°ĐļĐ¸Ņ‚Đ¸ давĐŊŅ–ŅˆŅ– Đ˛ĐˇĐ°Ņ”ĐŧĐžĐ´Ņ–Ņ—",
"follows": "ĐĐžĐ˛Ņ– ĐŋŅ–Đ´ĐŋĐ¸ŅĐēи",
"favs_repeats": "ĐŸĐžŅˆĐ¸Ņ€ĐĩĐŊĐŊŅ Ņ‚Đ° вĐŋОдОйаКĐēи",
- "moves": "ĐœŅ–ĐŗŅ€Đ°Ņ†Ņ–Ņ— ĐēĐžŅ€Đ¸ŅŅ‚ŅƒĐ˛Đ°Ņ‡Ņ–Đ˛"
+ "moves": "ĐœŅ–ĐŗŅ€Đ°Ņ†Ņ–Ņ— ĐēĐžŅ€Đ¸ŅŅ‚ŅƒĐ˛Đ°Ņ‡Ņ–Đ˛",
+ "emoji_reactions": "ЕĐŧОдĐļŅ– Ņ€ĐĩаĐē҆Җҗ"
},
"errors": {
"storage_unavailable": "Pleroma ĐŊĐĩ СĐŧĐžĐŗĐģа ĐžŅ‚Ņ€Đ¸ĐŧĐ°Ņ‚Đ¸ Đ´ĐžŅŅ‚ŅƒĐŋ Đ´Đž ŅŅ…ĐžĐ˛Đ¸Ņ‰Đ° ĐąŅ€Đ°ŅƒĐˇĐĩŅ€Ņƒ. Đ’Đ°ŅˆĐ° ҁĐĩŅŅ–Ņ Ņ‚Đ° ĐŊаĐģĐ°ŅˆŅ‚ŅƒĐ˛Đ°ĐŊĐŊŅ ĐŊĐĩ ĐąŅƒĐ´ŅƒŅ‚ŅŒ СйĐĩŅ€ĐĩĐļĐĩĐŊŅ–, ҆Đĩ ĐŧĐžĐļĐĩ ҁĐŋŅ€Đ¸Ņ‡Đ¸ĐŊĐ¸Ņ‚Đ¸ ĐŊĐĩĐŋĐĩŅ€ĐĩĐ´ĐąĐ°Ņ‡ŅƒĐ˛Đ°ĐŊŅ– ĐŋŅ€ĐžĐąĐģĐĩĐŧи. ĐĄĐŋŅ€ĐžĐąŅƒĐšŅ‚Đĩ ŅƒĐ˛Ņ–ĐŧĐēĐŊŅƒŅ‚Đ¸ cookie."
@@ -638,7 +654,35 @@
"backup_restore": "Đ ĐĩСĐĩŅ€Đ˛ĐŊĐĩ ĐēĐžĐŋŅ–ŅŽĐ˛Đ°ĐŊĐŊŅ ĐŊаĐģĐ°ŅˆŅ‚ŅƒĐ˛Đ°ĐŊҌ"
},
"right_sidebar": "ПоĐēĐ°ĐˇŅƒĐ˛Đ°Ņ‚Đ¸ йОĐēĐžĐ˛Ņƒ ĐŋаĐŊĐĩĐģҌ ҁĐŋŅ€Đ°Đ˛Đ°",
- "hide_shoutbox": "ĐŸŅ€Đ¸Ņ…ĐžĐ˛Đ°Ņ‚Đ¸ ĐžĐŗĐžĐģĐžŅˆĐĩĐŊĐŊŅ Ņ–ĐŊŅŅ‚Đ°ĐŊҁ҃"
+ "hide_shoutbox": "ĐŸŅ€Đ¸Ņ…ĐžĐ˛Đ°Ņ‚Đ¸ ĐžĐŗĐžĐģĐžŅˆĐĩĐŊĐŊŅ Ņ–ĐŊŅŅ‚Đ°ĐŊҁ҃",
+ "setting_server_side": "ĐĻĐĩĐš ĐŋĐ°Ņ€Đ°ĐŧĐĩ҂Ҁ ĐŋŅ€Đ¸Đ˛â€™ŅĐˇĐ°ĐŊиК Đ´Đž Đ˛Đ°ŅˆĐžĐŗĐž ĐŋŅ€ĐžŅ„Ņ–ĐģŅŽ Ņ‚Đ° вĐŋĐģĐ¸Đ˛Đ°Ņ” ĐŊа Đ˛ŅŅ– ҁĐĩаĐŊŅĐ¸ Ņ‚Đ° ĐēĐģŅ–Ņ”ĐŊŅ‚Đ¸",
+ "lists_navigation": "ПоĐēĐ°ĐˇŅƒĐ˛Đ°Ņ‚Đ¸ ҁĐŋĐ¸ŅĐēи в ĐŊĐ°Đ˛Ņ–ĐŗĐ°Ņ†Ņ–Ņ—",
+ "account_backup": "Đ ĐĩСĐĩŅ€Đ˛ĐŊĐĩ ĐēĐžĐŋŅ–ŅŽĐ˛Đ°ĐŊĐŊŅ ОйĐģŅ–ĐēĐžĐ˛ĐžĐŗĐž СаĐŋĐ¸ŅŅƒ",
+ "account_backup_description": "ĐĻĐĩ дОСвОĐģŅŅ” СаваĐŊŅ‚Đ°ĐļĐ¸Ņ‚Đ¸ Đ°Ņ€Ņ…Ņ–Đ˛ даĐŊĐ¸Ņ… Đ˛Đ°ŅˆĐžĐŗĐž ОйĐģŅ–ĐēĐžĐ˛ĐžĐŗĐž СаĐŋĐ¸ŅŅƒ Ņ‚Đ° Đ˛Đ°ŅˆĐ¸Ņ… Đ´ĐžĐŋĐ¸ŅŅ–Đ˛, аĐģĐĩ Ņ—Ņ… ҉Đĩ ĐŊĐĩ ĐŧĐžĐļĐŊа Ņ–ĐŧĐŋĐžŅ€Ņ‚ŅƒĐ˛Đ°Ņ‚Đ¸ в ОйĐģŅ–ĐēОвиК СаĐŋĐ¸Ņ Pleroma.",
+ "add_backup_error": "НĐĩ вдаĐģĐžŅŅ Đ´ĐžĐ´Đ°Ņ‚Đ¸ ĐŊĐžĐ˛Ņƒ Ņ€ĐĩСĐĩŅ€Đ˛ĐŊ҃ ĐēĐžĐŋŅ–ŅŽ: {error}",
+ "account_alias": "ĐŸŅĐĩвдОĐŊŅ–Đŧи ОйĐģŅ–ĐēĐžĐ˛ĐžĐŗĐž СаĐŋĐ¸ŅŅƒ",
+ "new_alias_target": "Đ”ĐžĐ´Đ°Ņ‚Đ¸ ĐŊОвиК ĐŋҁĐĩвдОĐŊŅ–Đŧ (ĐŊаĐŋŅ€. {example})",
+ "move_account_notes": "Đ¯ĐēŅ‰Đž ви Ņ…ĐžŅ‡ĐĩŅ‚Đĩ ĐŋĐĩŅ€ĐĩĐŧŅ–ŅŅ‚Đ¸Ņ‚Đ¸ ОйĐģŅ–ĐēОвиК СаĐŋĐ¸Ņ ĐŊа Ņ–ĐŊŅˆĐ¸Đš Ņ–ĐŊŅŅ‚Đ°ĐŊҁ, ваĐŧ ĐŋĐžŅ‚Ņ€Ņ–ĐąĐŊĐž ĐŋĐĩŅ€ĐĩĐšŅ‚Đ¸ Đ´Đž ŅĐ˛ĐžĐŗĐž ҆ҖĐģŅŒĐžĐ˛ĐžĐŗĐž ОйĐģŅ–ĐēĐžĐ˛ĐžĐŗĐž СаĐŋĐ¸ŅŅƒ Ņ‚Đ° Đ´ĐžĐ´Đ°Ņ‚Đ¸ ĐŋҁĐĩвдОĐŊŅ–Đŧ, Ņ‰Đž вĐēĐ°ĐˇŅƒŅ” ҆ĐĩĐš ОйĐģŅ–ĐēОвиК СаĐŋĐ¸Ņ.",
+ "added_backup": "ДодаĐŊĐž ĐŊĐžĐ˛Ņƒ Ņ€ĐĩСĐĩŅ€Đ˛ĐŊ҃ ĐēĐžĐŋŅ–ŅŽ.",
+ "expert_mode": "ПоĐēĐ°ĐˇĐ°Ņ‚Đ¸ Đ´ĐžĐ´Đ°Ņ‚ĐēĐžĐ˛Ņ– ĐŋĐ°Ņ€Đ°ĐŧĐĩŅ‚Ņ€Đ¸",
+ "post_look_feel": "Đ’Ņ–Đ´ĐžĐąŅ€Đ°ĐļĐĩĐŊĐŊŅ Đ´ĐžĐŋĐ¸ŅŅ–Đ˛",
+ "email_language": "Мова Đ´ĐģŅ ĐžŅ‚Ņ€Đ¸ĐŧаĐŊĐŊŅ ĐĩĐģĐĩĐēŅ‚Ņ€ĐžĐŊĐŊĐ¸Ņ… ĐģĐ¸ŅŅ‚Ņ–Đ˛ Đ˛Ņ–Đ´ ҁĐĩŅ€Đ˛ĐĩŅ€Đ°",
+ "account_backup_table_head": "Đ ĐĩСĐĩŅ€Đ˛ĐŊĐĩ ĐēĐžĐŋŅ–ŅŽĐ˛Đ°ĐŊĐŊŅ",
+ "download_backup": "ЗаваĐŊŅ‚Đ°ĐļĐ¸Ņ‚Đ¸",
+ "backup_not_ready": "Đ ĐĩСĐĩŅ€Đ˛ĐŊа ĐēĐžĐŋŅ–Ņ ҉Đĩ ĐŊĐĩ ĐŗĐžŅ‚ĐžĐ˛Đ°.",
+ "remove_backup": "ВидаĐģĐ¸Ņ‚Đ¸",
+ "list_backups_error": "ПоĐŧиĐģĐēа ĐŋŅ–Đ´ Ņ‡Đ°Ņ ĐžŅ‚Ņ€Đ¸ĐŧаĐŊĐŊŅ ҁĐŋĐ¸ŅĐē҃ Ņ€ĐĩСĐĩŅ€Đ˛ĐŊĐ¸Ņ… ĐēĐžĐŋŅ–Đš: {error}",
+ "add_backup": "ĐĄŅ‚Đ˛ĐžŅ€Đ¸Ņ‚Đ¸ ĐŊĐžĐ˛Ņƒ Ņ€ĐĩСĐĩŅ€Đ˛ĐŊ҃ ĐēĐžĐŋŅ–ŅŽ",
+ "account_alias_table_head": "ĐŸŅĐĩвдОĐŊŅ–Đŧ",
+ "list_aliases_error": "ПоĐŧиĐģĐēа ĐŋŅ–Đ´ Ņ‡Đ°Ņ ĐžŅ‚Ņ€Đ¸ĐŧаĐŊĐŊŅ ĐŋҁĐĩвдОĐŊŅ–ĐŧŅ–Đ˛: {error}",
+ "hide_list_aliases_error_action": "ЗаĐēŅ€Đ¸Ņ‚Đ¸",
+ "remove_alias": "ВидаĐģĐ¸Ņ‚Đ¸ ҆ĐĩĐš ĐŋҁĐĩвдОĐŊŅ–Đŧ",
+ "added_alias": "ĐŸŅĐĩвдОĐŊŅ–Đŧ дОдаĐŊĐž.",
+ "add_alias_error": "ПоĐŧиĐģĐēа ĐŋŅ–Đ´ Ņ‡Đ°Ņ дОдаваĐŊĐŊŅ ĐŋҁĐĩвдОĐŊŅ–Đŧа: {error}",
+ "move_account": "ПĐĩŅ€ĐĩĐŧŅ–ŅŅ‚Đ¸Ņ‚Đ¸ ОйĐģŅ–ĐēОвиК СаĐŋĐ¸Ņ",
+ "move_account_target": "ĐĻŅ–ĐģŅŒĐžĐ˛Đ¸Đš ОйĐģŅ–ĐēОвиК СаĐŋĐ¸Ņ (ĐŊаĐŋŅ€. {example})",
+ "moved_account": "ОбĐģŅ–ĐēОвиК СаĐŋĐ¸Ņ ĐŋĐĩŅ€ĐĩĐŧҖ҉ĐĩĐŊĐž.",
+ "move_account_error": "ПоĐŧиĐģĐēа ĐŋŅ–Đ´ Ņ‡Đ°Ņ ĐŋĐĩŅ€ĐĩĐŧҖ҉ĐĩĐŊĐŊŅ ОйĐģŅ–ĐēĐžĐ˛ĐžĐŗĐž СаĐŋĐ¸ŅŅƒ: {error}"
},
"selectable_list": {
"select_all": "Đ’Đ¸ĐąŅ€Đ°Ņ‚Đ¸ Đ˛ŅĐĩ"
@@ -670,7 +714,10 @@
"captcha": "CAPTCHA",
"register": "Đ—Đ°Ņ€ĐĩŅ”ŅŅ‚Ņ€ŅƒĐ˛Đ°Ņ‚Đ¸ŅŅ",
"reason_placeholder": "ĐĻĐĩĐš Ņ–ĐŊŅŅ‚Đ°ĐŊҁ ĐžĐąŅ€ĐžĐąĐģŅŅ” СаĐŋĐ¸Ņ‚Đ¸ ĐŊа Ņ€ĐĩŅ”ŅŅ‚Ņ€Đ°Ņ†Ņ–ŅŽ Đ˛Ņ€ŅƒŅ‡ĐŊ҃.\nРОСĐēаĐļŅ–Ņ‚ŅŒ адĐŧŅ–ĐŊŅ–ŅŅ‚Ņ€Đ°Ņ†Ņ–Ņ— Ņ‡ĐžĐŧ҃ ви Ņ…ĐžŅ‡ĐĩŅ‚Đĩ ĐˇĐ°Ņ€ĐĩŅ”ŅŅ‚Ņ€ŅƒĐ˛Đ°Ņ‚Đ¸ŅŅ.",
- "reason": "ĐŸŅ€Đ¸Ņ‡Đ¸ĐŊа Ņ€ĐĩŅ”ŅŅ‚Ņ€Đ°Ņ†Ņ–Ņ—"
+ "reason": "ĐŸŅ€Đ¸Ņ‡Đ¸ĐŊа Ņ€ĐĩŅ”ŅŅ‚Ņ€Đ°Ņ†Ņ–Ņ—",
+ "bio_optional": "Đ‘Ņ–ĐžĐŗŅ€Đ°Ņ„Ņ–Ņ (ĐŊĐĩОйОв'ŅĐˇĐēОвО)",
+ "email_language": "Đ¯ĐēĐžŅŽ ĐŧĐžĐ˛ĐžŅŽ ви йаĐļĐ°Ņ”Ņ‚Đĩ ĐžŅ‚Ņ€Đ¸ĐŧŅƒĐ˛Đ°Ņ‚Đ¸ ĐĩĐģĐĩĐēŅ‚Ņ€ĐžĐŊĐŊŅ– ĐģĐ¸ŅŅ‚Đ¸ Đ˛Ņ–Đ´ ҁĐĩŅ€Đ˛ĐĩŅ€Đ°?",
+ "email_optional": "ЕĐģ. ĐŋĐžŅˆŅ‚Đ° (ĐŊĐĩОйОв'ŅĐˇĐēОвО)"
},
"who_to_follow": {
"who_to_follow": "На ĐēĐžĐŗĐž ĐŋŅ–Đ´ĐŋĐ¸ŅĐ°Ņ‚Đ¸ŅŅ",
@@ -755,7 +802,6 @@
"deactivate_account": "ДĐĩаĐēŅ‚Đ¸Đ˛ŅƒĐ˛Đ°Ņ‚Đ¸ ОйĐģŅ–ĐēОвиК СаĐŋĐ¸Ņ",
"delete_account": "ВидаĐģĐ¸Ņ‚Đ¸ ОйĐģŅ–ĐēОвиК СаĐŋĐ¸Ņ",
"moderation": "МодĐĩŅ€Đ°Ņ†Ņ–Ņ",
- "delete_user_confirmation": "Ви Đ°ĐąŅĐžĐģŅŽŅ‚ĐŊĐž вĐŋĐĩвĐŊĐĩĐŊŅ–? ĐĻŅŽ Đ´Ņ–ŅŽ ĐŊĐĩĐŧĐžĐļĐģивО ĐąŅƒĐ´Đĩ ҁĐēĐ°ŅĐžĐ˛ŅƒĐ˛Đ°Ņ‚Đ¸.",
"delete_user": "ВидаĐģĐ¸Ņ‚Đ¸ ОйĐģŅ–ĐēОвиК СаĐŋĐ¸Ņ",
"strip_media": "ВиĐģŅƒŅ‡Đ¸Ņ‚Đ¸ ĐŧĐĩĐ´Ņ–Đ° С Đ´ĐžĐŋĐ¸ŅŅ–Đ˛ ĐēĐžŅ€Đ¸ŅŅ‚ŅƒĐ˛Đ°Ņ‡Đ°",
"force_nsfw": "ПозĐŊĐ°Ņ‡Đ¸Ņ‚Đ¸ Đ˛ŅŅ– Đ´ĐžĐŋĐ¸ŅĐ¸ ŅĐē NSFW",
@@ -861,5 +907,12 @@
"profile_loading_error": "Đ’Đ¸ĐąĐ°Ņ‡Ņ‚Đĩ, ĐŋŅ–Đ´ Ņ‡Đ°Ņ СаваĐŊŅ‚Đ°ĐļĐĩĐŊĐŊŅ Ņ†ŅŒĐžĐŗĐž ĐŋŅ€ĐžŅ„Ņ–ĐģŅŽ виĐŊиĐēĐģа ĐŋĐžĐŧиĐģĐēа.",
"profile_does_not_exist": "Đ’Đ¸ĐąĐ°Ņ‡Ņ‚Đĩ, ҆ĐĩĐš ĐŋŅ€ĐžŅ„Ņ–ĐģҌ ĐąŅ–ĐģҌ҈Đĩ ĐŊĐĩ ҖҁĐŊŅƒŅ”.",
"timeline_title": "ĐĄŅ‚Ņ€Ņ–Ņ‡Đēа ĐēĐžŅ€Đ¸ŅŅ‚ŅƒĐ˛Đ°Ņ‡Đ°"
+ },
+ "report": {
+ "notes": "ĐŸŅ€Đ¸ĐŧŅ–Ņ‚Đēи:",
+ "state": "ĐĄŅ‚Đ°Ņ‚ŅƒŅ:",
+ "state_open": "Đ˛Ņ–Đ´ĐēŅ€Đ¸Ņ‚Đ¸Đš",
+ "state_closed": "СаĐēŅ€Đ¸Ņ‚Đ¸Đš",
+ "state_resolved": "Đ˛Đ¸Ņ€Ņ–ŅˆĐĩĐŊиК"
}
}
diff --git a/src/i18n/vi.json b/src/i18n/vi.json
index 088d73cc..fd7ae25c 100644
--- a/src/i18n/vi.json
+++ b/src/i18n/vi.json
@@ -51,7 +51,7 @@
"scope_options": "Đa dáēĄng kiáģƒu đăng"
},
"finder": {
- "error_fetching_user": "Láģ—i ngưáģi dÚng",
+ "error_fetching_user": "Láģ—i khi náēĄp ngưáģi dÚng",
"find_user": "TÃŦm ngưáģi dÚng"
},
"shoutbox": {
@@ -149,7 +149,7 @@
"no_more_notifications": "Không cÃ˛n thông bÃĄo nào",
"migrated_to": "chuyáģƒn sang",
"reacted_with": "cháēĄm táģ›i {0}",
- "error": "Láģ—i xáģ­ lÃŊ thông bÃĄo: {0}"
+ "error": "Láģ—i khi náēĄp thông bÃĄo {0}"
},
"polls": {
"add_poll": "TáēĄo bÃŦnh cháģn",
@@ -197,7 +197,7 @@
"text/bbcode": "BBCode"
},
"content_warning": "TiÃĒu đáģ (tÚy cháģn)",
- "default": "Just landed in L.A.",
+ "default": "Đáģi ngưáģi con gÃĄi không muáģ‘n yÃĒu ai đưáģŖc không?",
"direct_warning_to_first_only": "Ngưáģi đáē§u tiÃĒn đưáģŖc nháē¯c đáēŋn máģ›i cÃŗ tháģƒ tháēĨy tÃēt này.",
"posting": "Đang đăng tÃēt",
"post": "Đăng",
@@ -427,9 +427,445 @@
"no_rich_text_description": "Không hiáģ‡n rich text trong cÃĄc tÃēt",
"hide_follows_count_description": "áē¨n sáģ‘ lưáģŖng ngưáģi tôi theo dÃĩi",
"nsfw_clickthrough": "Cho phÊp nháēĨn vào xem cÃĄc tÃēt nháēĄy cáēŖm",
- "reply_visibility_following": "Cháģ‰ hiáģ‡n nháģ¯ng tráēŖ láģi cÃŗ nháē¯c táģ›i tôi hoáēˇc táģĢ nháģ¯ng ngưáģi mà tôi theo dÃĩi"
+ "reply_visibility_following": "Cháģ‰ hiáģ‡n nháģ¯ng tráēŖ láģi cÃŗ nháē¯c táģ›i tôi hoáēˇc táģĢ nháģ¯ng ngưáģi mà tôi theo dÃĩi",
+ "autohide_floating_post_button": "áē¨n nÃēt viáēŋt tÃēt khi xem báēŖng tin (di đáģ™ng)",
+ "saving_err": "Thiáēŋt láē­p láģ—i lưu",
+ "saving_ok": "ÄÃŖ lưu cÃĄc thay đáģ•i",
+ "search_user_to_block": "TÃŦm ngưáģi báēĄn muáģ‘n cháēˇn",
+ "search_user_to_mute": "TÃŦm ngưáģi báēĄn muáģ‘n áēŠn",
+ "security_tab": "BáēŖo máē­t",
+ "scope_copy": "ChÊp pháēĄm vi khi tráēŖ láģi (tin nháē¯n luôn đưáģŖc chÊp sáēĩn)",
+ "minimal_scopes_mode": "TÚy cháģn thu nháģ pháēĄm vi tÃēt",
+ "set_new_avatar": "Đáģ•i áēŖnh đáēĄi diáģ‡n",
+ "set_new_profile_background": "Đáģ•i áēŖnh náģn",
+ "set_new_profile_banner": "Đáģ•i áēŖnh bÃŦa",
+ "reset_profile_background": "Đáēˇt láēĄi áēŖnh náģn",
+ "reset_profile_banner": "Đáēˇt láēĄi áēŖnh bÃŦa",
+ "reset_banner_confirm": "BáēĄn cÃŗ cháē¯c cháē¯n muáģ‘n đáēˇt láēĄi áēŖnh bÃŦa?",
+ "reset_background_confirm": "BáēĄn cÃŗ cháē¯c cháē¯n muáģ‘n đáēˇt láēĄi áēŖnh náģn?",
+ "settings": "Cài đáēˇt",
+ "subject_input_always_show": "Luôn hiáģ‡n vÚng tiÃĒu đáģ",
+ "subject_line_behavior": "ChÊp tiÃĒu đáģ khi tráēŖ láģi",
+ "subject_line_email": "Giáģ‘ng email: \"re: subject\"",
+ "subject_line_mastodon": "Giáģ‘ng Mastodon: copy as is",
+ "subject_line_noop": "ĐáģĢng chÊp",
+ "sensitive_by_default": "Máēˇc đáģ‹nh tÃēt là nháēĄy cáēŖm",
+ "stop_gifs": "Cháģ‰ phÃĄt GIF khi cháēĄm vào",
+ "streaming": "Táģą Ä‘áģ™ng táēŖi tÃēt máģ›i khi cuáģ™n lÃĒn trÃĒn",
+ "user_mutes": "Ngưáģi dÚng",
+ "useStreamingApiWarning": "(Tính năng tháģ­ nghiáģ‡m, không đáģ xuáēĨt sáģ­ dáģĨng)",
+ "text": "Văn báēŖn",
+ "theme": "Theme",
+ "theme_help": "DÚng mÃŖ màu hex (#rrggbb) đáģƒ táģą cháēŋ theme.",
+ "tooltipRadius": "Tooltips/alerts",
+ "type_domains_to_mute": "TÃŦm mÃĄy cháģ§ Ä‘áģƒ áēŠn",
+ "upload_a_photo": "TáēŖi áēŖnh lÃĒn",
+ "user_settings": "Thiáēŋt láē­p ngưáģi dÚng",
+ "values": {
+ "false": "không",
+ "true": "cÃŗ"
+ },
+ "virtual_scrolling": "Render báēŖng tin",
+ "fun": "Vui nháģ™n",
+ "greentext": "MÅŠi tÃĒn meme",
+ "notifications": "Thông bÃĄo",
+ "notification_setting_filters": "Báģ™ láģc",
+ "notification_setting_block_from_strangers": "Cháēˇn thông bÃĄo táģĢ nháģ¯ng ngưáģi báēĄn không theo dÃĩi",
+ "notification_setting_privacy": "RiÃĒng tư",
+ "notification_setting_hide_notification_contents": "áē¨n ngưáģi gáģ­i và náģ™i dung thông bÃĄo đáēŠy",
+ "notification_mutes": "Sáģ­ dáģĨng áēŠn náēŋu muáģ‘n dáģĢng nháē­n thông bÃĄo táģĢ máģ™t ngưáģi cáģĨ tháģƒ.",
+ "notification_blocks": "Cháēˇn máģ™t ngưáģi ngáģĢng toàn báģ™ thông bÃĄo cÅŠng giáģ‘ng như háģ§y đăng kÃŊ háģ.",
+ "more_settings": "Cài đáēˇt khÃĄc",
+ "style": {
+ "switcher": {
+ "keep_shadows": "Giáģ¯ bÃŗng đáģ•",
+ "keep_color": "Giáģ¯ màu",
+ "keep_opacity": "Giáģ¯ trong suáģ‘t",
+ "keep_roundness": "Giáģ¯ bo trÃ˛n gÃŗc",
+ "reset": "Đáēˇt láēĄi",
+ "clear_all": "XÃŗa háēŋt",
+ "clear_opacity": "XÃŗa trong suáģ‘t",
+ "load_theme": "TáēŖi theme",
+ "keep_as_is": "Giáģ¯ như là",
+ "use_snapshot": "BáēŖn cÅŠ",
+ "use_source": "BáēŖn máģ›i",
+ "help": {
+ "upgraded_from_v2": "PleromaFE Ä‘ÃŖ đưáģŖc nÃĸng cáēĨp, theme cÃŗ tháģƒ khÃĄc hÆĄn máģ™t chÃēt so váģ›i báēŖn cÅŠ.",
+ "v2_imported": "Táē­p tin báēĄn nháē­p là táģĢ phiÃĒn báēŖn PleromaFE cÅŠ. ChÃēng tôi sáēŊ cáģ‘ làm nÃŗ tÆ°ÆĄng thích nhưng cÃŗ tháģƒ sáēŊ cÃŗ xung đáģ™t.",
+ "older_version_imported": "Táē­p tin báēĄn váģĢa nháē­p đưáģŖc táēĄo ra táģĢ phiÃĒn báēŖn PleromaFE cÅŠ.",
+ "snapshot_present": "ÄÃŖ táēŖi theme snapshot, máģi giÃĄ tráģ‹ sáēŊ báģ‹ chÊp đè. Thay vào Ä‘Ãŗ, báēĄn cÃŗ tháģƒ táēŖi dáģ¯ liáģ‡u cháē¯c cháē¯n cáģ§a theme.",
+ "fe_upgraded": "Theme cáģ§a PleromaFE đưáģŖc nÃĸng cáēĨp sau máģ—i phiÃĒn báēŖn.",
+ "fe_downgraded": "Theme cáģ§a phiÃĒn báēŖn PleromaFE Ä‘ÃŖ đưáģŖc háēĄ cáēĨp.",
+ "migration_snapshot_ok": "Theme snapshot Ä‘ÃŖ táēŖi xong. BáēĄn cÃŗ tháģƒ tháģ­ táēŖi dáģ¯ liáģ‡u theme.",
+ "migration_napshot_gone": "Náēŋu thiáēŋu snapshot, máģ™t sáģ‘ tháģŠ sáēŊ khÃĄc váģ›i ban đáē§u.",
+ "future_version_imported": "Táē­p tin báēĄn váģĢa nháē­p đưáģŖc táēĄo ra táģĢ phiÃĒn báēŖn PleromaFE máģ›i.",
+ "snapshot_missing": "Không cÃŗ theme snapshot trong táē­p tin cho nÃĒn cÃŗ tháģƒ nÃŗ sáēŊ khÃĄc váģ›i báēŖn gáģ‘c đôi chÃēt.",
+ "snapshot_source_mismatch": "Xung đáģ™t phiÃĒn báēŖn: háē§u háēŋt Pleroma FE Ä‘ÃŖ háēĄ cáēĨp và cáē­p nháē­t láēĄi, náēŋu báēĄn đáģ•i theme sáģ­ dáģĨng phiÃĒn báēŖn cÅŠ hÆĄn cáģ§a FE, báēĄn gáē§n như muáģ‘n sáģ­ dáģĨng phiÃĒn báēŖn cÅŠ, thay vào Ä‘Ãŗ sáģ­ dáģĨng phiÃĒn báēŖn máģ›i."
+ },
+ "keep_fonts": "Giáģ¯ phông cháģ¯",
+ "save_load_hint": "GiÃēp giáģ¯ nguyÃĒn cÃĄc tÚy cháģn hiáģ‡n táēĄi khi cháģn hoáēˇc táēŖi theme khÃĄc, nÃŗ cÅŠng lưu tráģ¯ cÃĄc tÚy cháģn Ä‘ÃŖ nÃŗi khi xuáēĨt máģ™t theme. Khi táēĨt cáēŖ cÃĄc háģ™p kiáģƒm báģ‹ báģ tráģ‘ng, viáģ‡c xuáēĨt theme sáēŊ lưu máģi tháģŠ."
+ },
+ "common": {
+ "color": "Màu sáē¯c",
+ "opacity": "Trong suáģ‘t",
+ "contrast": {
+ "hint": "Táģ‰ láģ‡ tÆ°ÆĄng pháēŖn là {ratio}, nÃŗ {level} {context}",
+ "level": {
+ "aa": "đáēĄt máģŠc AA (táģ‘i thiáģƒu)",
+ "aaa": "đáēĄt máģŠc AAA (đáģ xuáēĨt)",
+ "bad": "không đáēĄt yÃĒu cáē§u"
+ },
+ "context": {
+ "18pt": "cáģĄ cháģ¯ láģ›n (18pt+)",
+ "text": "cho cháģ¯"
+ }
+ }
+ },
+ "common_colors": {
+ "_tab_label": "Chung",
+ "main": "Màu sáē¯c chung",
+ "foreground_hint": "MáģŸ tab \"NÃĸng cao\" đáģƒ cÃŗ nhiáģu tÚy cháģn hÆĄn",
+ "rgbo": "Icons, accents, badges"
+ },
+ "advanced_colors": {
+ "_tab_label": "NÃĸng cao",
+ "alert": "Náģn cáēŖnh bÃĄo",
+ "alert_error": "Láģ—i",
+ "alert_warning": "CáēŖnh bÃĄo",
+ "alert_neutral": "Neutral",
+ "post": "TÃēt/Tiáģƒu sáģ­",
+ "badge": "Náģn huy hiáģ‡u",
+ "popover": "Tooltips, menus, popovers",
+ "badge_notification": "Thông bÃĄo",
+ "panel_header": "TiÃĒu đáģ panel",
+ "top_bar": "Thanh trÃĒn cÚng",
+ "borders": "Đưáģng biÃĒn",
+ "buttons": "NÃēt báēĨm",
+ "faint_text": "Cháģ¯ máģ",
+ "underlay": "Láģ›p dưáģ›i",
+ "wallpaper": "Wallpaper",
+ "poll": "Biáģƒu đáģ“ cuáģ™c bÃŦnh cháģn",
+ "icons": "Biáģƒu tưáģŖng",
+ "highlight": "Nháģ¯ng thành pháē§n náģ•i báē­t",
+ "pressed": "Khi nháēĨn xuáģ‘ng",
+ "selectedPost": "Cháģn tÃēt",
+ "selectedMenu": "Cháģn menu",
+ "toggled": "Toggled",
+ "tabs": "Tab",
+ "chat": {
+ "incoming": "Tin nháē¯n đáēŋn",
+ "outgoing": "Tin nháē¯n đi",
+ "border": "Đưáģng biÃĒn"
+ },
+ "inputs": "Khung soáēĄn tháēŖo",
+ "disabled": "Vô hiáģ‡u hÃŗa"
+ },
+ "radii": {
+ "_tab_label": "GÃŗc bo trÃ˛n"
+ },
+ "shadows": {
+ "component": "Thành pháē§n",
+ "shadow_id": "Đáģ• bÃŗng #{value}",
+ "blur": "Làm máģ",
+ "spread": "MáģŸ ráģ™ng",
+ "inset": "Thu vào",
+ "filter_hint": {
+ "always_drop_shadow": "ChÃē ÃŊ, màu bÃŗng đáģ• này luôn sáģ­ dáģĨng {0} náēŋu trÃŦnh duyáģ‡t háģ— tráģŖ.",
+ "drop_shadow_syntax": "{0} không háģ— tráģŖ {1} pháē§n và táģĢ khÃŗa {2}.",
+ "spread_zero": "BÃŗng đáģ• > 0 sáēŊ xuáēĨt hiáģ‡n náēŋu cháģn nÃŗ thành không",
+ "inset_classic": "BÃŗng đáģ• inset sáēŊ sáģ­ dáģĨng {0}",
+ "avatar_inset": "Náēŋu tráģ™n láēĢn bÃŗng đáģ• inset và non-inset trÃĒn áēŖnh đáēĄi diáģ‡n cÃŗ tháģƒ khiáēŋn áēŖnh đáēĄi diáģ‡n biáēŋn thành trong suáģ‘t."
+ },
+ "components": {
+ "panel": "Panel",
+ "panelHeader": "Panel áēŖnh bÃŦa",
+ "topBar": "Thanh trÃĒn cÚng",
+ "avatar": "áēĸnh đáēĄi diáģ‡n (áģŸ trang cÃĄ nhÃĸn)",
+ "avatarStatus": "áēĸnh đáēĄi diáģ‡n (áģŸ tÃēt)",
+ "popup": "Popups và tooltips",
+ "button": "NÃēt báēĨm",
+ "buttonHover": "NÃēt báēĨm (khi rÃĒ chuáģ™t)",
+ "buttonPressed": "NÃēt báēĨm (khi nháēĨn chuáģ™t)",
+ "buttonPressedHover": "NÃēt báēĨm (khi nháēĨn+giáģ¯)",
+ "input": "Khung soáēĄn tháēŖo"
+ },
+ "_tab_label": "Đáģ• bÃŗng và tô sÃĄng",
+ "override": "ChÊp đè",
+ "hintV3": "Váģ›i bÃŗng đáģ•, báēĄn cÃŗ tháģƒ sáģ­ dáģĨng kÃŊ hiáģ‡u {0} đáģƒ dÚng slot màu khÃĄc."
+ },
+ "fonts": {
+ "_tab_label": "Phông cháģ¯",
+ "components": {
+ "interface": "Giao diáģ‡n chung",
+ "input": "Khung soáēĄn tháēŖo",
+ "post": "TÃēt",
+ "postCode": "Cháģ¯ monospaced (rich text)"
+ },
+ "family": "TÃĒn phông",
+ "size": "Kích cáģĄ (px)",
+ "weight": "Đáģ™ Ä‘áē­m",
+ "custom": "TÚy cháģ‰nh",
+ "help": "Cháģn phông cháģ¯ hiáģƒn tháģ‹. Đáģƒ \"tÚy cháģn\", báēĄn pháēŖi nháē­p chính xÃĄc tÃĒn phông cháģ¯ trÃĒn háģ‡ tháģ‘ng."
+ },
+ "preview": {
+ "header": "Xem trưáģ›c",
+ "content": "Náģ™i dung",
+ "error": "Láģ—i máēĢu ví dáģĨ",
+ "button": "NÃēt báēĨm",
+ "text": "Máģ™t đáģ‘ng {0} và {1}",
+ "mono": "náģ™i dung",
+ "input": "Đáģi ngưáģi con gÃĄi không muáģ‘n yÃĒu ai đưáģŖc không?",
+ "faint_link": "tài liáģ‡u hưáģ›ng dáēĢn",
+ "checkbox": "Tôi Ä‘ÃŖ đáģc lưáģ›t qua quy táē¯c và chính sÃĄch báēŖo máē­t",
+ "link": "Link đáēšp Ä‘Ãŗ em yÃĒu",
+ "fine_print": "Đáģc {0} đáģƒ tÃŦm hiáģƒu thÃĒm!",
+ "header_faint": "OK nè"
+ }
+ },
+ "version": {
+ "title": "PhiÃĒn báēŖn",
+ "frontend_version": "Frontend",
+ "backend_version": "Backend"
+ },
+ "reset_avatar": "Đáēˇt láēĄi áēŖnh đáēĄi diáģ‡n",
+ "reset_avatar_confirm": "BáēĄn cÃŗ cháē¯c cháē¯n muáģ‘n đáēˇt láēĄi áēŖnh đáēĄi diáģ‡n?",
+ "post_status_content_type": "LoáēĄi tÃēt đăng",
+ "useStreamingApi": "Nháē­n tÃēt và thông bÃĄo theo tháģi gian tháģąc",
+ "theme_help_v2_1": "BáēĄn cÅŠng cÃŗ tháģƒ xÃŗa háēŋt màu thành pháē§n và làm theme trong suáģ‘t, cháģn nÃēt \"XÃŗa háēŋt\".",
+ "theme_help_v2_2": "CÃĄc biáģƒu tưáģŖng bÃĒn dưáģ›i cÃĄc máģĨc cÃŗ đáģ™ tÆ°ÆĄng pháēŖn náģn/văn báēŖn, hÃŖy rÃĒ chuáģ™t qua đáģƒ biáēŋt thông tin chi tiáēŋt. Xin lưu ÃŊ ráēąng, khi sáģ­ dáģĨng cÃĄc đáģ™ tÆ°ÆĄng pháēŖn trong suáģ‘t cÃŗ tháģƒ khiáēŋn đáģc cháģ¯ không ra.",
+ "enable_web_push_notifications": "Cho phÊp thông bÃĄo đáēŠy trÃĒn web",
+ "mentions_new_style": "LưáģŖt nháē¯c màu mè",
+ "mentions_new_place": "Đáēˇt lưáģŖt nháē¯c áģŸ dÃ˛ng riÃĒng",
+ "always_show_post_button": "Luôn hiáģ‡n nÃēt viáēŋt tÃēt máģ›i"
},
"errors": {
"storage_unavailable": "Pleroma không tháģƒ truy cáē­p lưu tráģ¯ trÃŦnh duyáģ‡t. Thông tin đăng nháē­p và nháģ¯ng thiáēŋt láē­p táēĄm tháģi sáēŊ báģ‹ máēĨt. HÃŖy cho phÊp cookies."
+ },
+ "time": {
+ "day": "{0} ngày",
+ "days": "{0} ngày",
+ "day_short": "{0} ngày",
+ "days_short": "{0} ngày",
+ "hour": "{0} giáģ",
+ "hours": "{0} giáģ",
+ "hour_short": "{0} giáģ",
+ "hours_short": "{0} giáģ",
+ "in_future": "lÃēc {0}",
+ "in_past": "{0} trưáģ›c",
+ "minute": "{0} phÃēt",
+ "minutes": "{0} phÃēt",
+ "minute_short": "{0} phÃēt",
+ "minutes_short": "{0} phÃēt",
+ "month": "{0} thÃĄng",
+ "months": "{0} thÃĄng",
+ "month_short": "{0} thÃĄng",
+ "months_short": "{0} thÃĄng",
+ "now": "váģĢa xong",
+ "second": "{0} giÃĸy",
+ "seconds": "{0} giÃĸy",
+ "second_short": "{0}s",
+ "seconds_short": "{0}s",
+ "week": "{0} tuáē§n",
+ "weeks": "{0} tuáē§n",
+ "week_short": "{0} tuáē§n",
+ "weeks_short": "{0} tuáē§n",
+ "year": "{0} năm",
+ "years": "{0} năm",
+ "year_short": "{0} năm",
+ "years_short": "{0} năm",
+ "now_short": "váģĢa xong"
+ },
+ "timeline": {
+ "collapse": "Thu gáģn",
+ "error": "Láģ—i khi náēĄp báēŖng tin {0}",
+ "load_older": "Xem tÃēt cÅŠ hÆĄn",
+ "repeated": "chia sáēģ",
+ "show_new": "Hiáģ‡n máģ›i",
+ "reload": "TáēŖi láēĄi",
+ "up_to_date": "ÄÃŖ táēŖi nháģ¯ng tÃēt máģ›i nháēĨt",
+ "no_more_statuses": "Không cÃ˛n tÃēt nào",
+ "no_statuses": "Tráģ‘ng trÆĄn!",
+ "socket_reconnected": "Thiáēŋt láē­p káēŋt náģ‘i tháģi gian tháģąc",
+ "conversation": "TháēŖo luáē­n",
+ "no_retweet_hint": "Không tháģƒ chia sáēģ tin nháē¯n và nháģ¯ng tÃēt riÃĒng tư",
+ "socket_broke": "MáēĨt káēŋt náģ‘i tháģi gian tháģąc: CloseEvent {0}"
+ },
+ "status": {
+ "repeats": "Chia sáēģ",
+ "delete": "XÃŗa tÃēt",
+ "unpin": "Báģ ghim trÃĒn trang cÃĄ nhÃĸn",
+ "pin": "Ghim trÃĒn trang cÃĄ nhÃĸn",
+ "pinned": "TÃēt đưáģŖc ghim",
+ "bookmark": "Lưu",
+ "unbookmark": "Báģ lưu",
+ "reply_to": "TráēŖ láģi",
+ "replies_list": "Nháģ¯ng tráēŖ láģi:",
+ "mute_conversation": "Không quan tÃĸm náģ¯a",
+ "unmute_conversation": "Quan tÃĸm",
+ "status_unavailable": "Không tÃŦm tháēĨy tÃēt",
+ "copy_link": "Sao chÊp URL",
+ "external_source": "Nguáģ“n bÃĒn ngoài",
+ "thread_muted": "ÄÃŖ áēŠn cháģ§ Ä‘áģ",
+ "thread_muted_and_words": ", cÃŗ táģĢ:",
+ "hide_full_subject": "áē¨n tiÃĒu đáģ",
+ "show_content": "Hiáģ‡n náģ™i dung",
+ "hide_content": "áē¨n náģ™i dung",
+ "status_deleted": "TÃēt này Ä‘ÃŖ báģ‹ xÃŗa",
+ "nsfw": "NháēĄy cáēŖm",
+ "expand": "Xem nguyÃĒn văn",
+ "favorites": "Thích",
+ "delete_confirm": "BáēĄn cÃŗ cháē¯c cháē¯n muáģ‘n xÃŗa tÃēt này?",
+ "show_full_subject": "Hiáģ‡n đáē§y đáģ§ tiÃĒu đáģ",
+ "you": "(BáēĄn)",
+ "mentions": "LưáģŖt nháē¯c",
+ "plus_more": "+{number} nhiáģu hÆĄn"
+ },
+ "user_card": {
+ "approve": "CháēĨp nháē­n",
+ "block": "Cháēˇn",
+ "blocked": "ÄÃŖ cháēˇn!",
+ "deny": "TáģĢ cháģ‘i",
+ "edit_profile": "Cháģ‰nh sáģ­a trang cÃĄ nhÃĸn",
+ "favorites": "Thích",
+ "follow": "Theo dÃĩi",
+ "follow_progress": "Đang yÃĒu cáē§uâ€Ļ",
+ "follow_again": "Gáģ­i láēĄi yÃĒu cáē§u?",
+ "follow_unfollow": "Ngưng theo dÃĩi",
+ "followees": "Đang theo dÃĩi",
+ "followers": "Ngưáģi theo dÃĩi",
+ "following": "Đang theo dÃĩi!",
+ "follows_you": "Theo dÃĩi báēĄn!",
+ "hidden": "áē¨n",
+ "media": "Media",
+ "mention": "LưáģŖt nháē¯c",
+ "message": "Tin nháē¯n",
+ "mute": "áē¨n",
+ "muted": "ÄÃŖ áēŠn",
+ "per_day": "tÃēt máģ—i ngày",
+ "remote_follow": "Theo dÃĩi táģĢ xa",
+ "report": "BÃĄo cÃĄo",
+ "statuses": "TÃēt",
+ "subscribe": "Đăng kÃŊ",
+ "unsubscribe": "Háģ§y đăng kÃŊ",
+ "unblock": "Báģ cháēˇn",
+ "unblock_progress": "Đang báģ cháēˇnâ€Ļ",
+ "block_progress": "Đang cháēˇnâ€Ļ",
+ "unmute": "Báģ áēŠn",
+ "unmute_progress": "Đang báģ áēŠnâ€Ļ",
+ "mute_progress": "Đang áēŠnâ€Ļ",
+ "hide_repeats": "áē¨n lưáģŖt chia sáēģ",
+ "show_repeats": "Hiáģ‡n lưáģŖt chia sáēģ",
+ "bot": "Bot",
+ "admin_menu": {
+ "moderation": "Kiáģƒm duyáģ‡t",
+ "grant_admin": "Cháģ‰ Ä‘áģ‹nh QuáēŖn tráģ‹ viÃĒn",
+ "revoke_admin": "GáģĄ báģ QuáēŖn tráģ‹ viÃĒn",
+ "grant_moderator": "Cháģ‰ Ä‘áģ‹nh Kiáģƒm duyáģ‡t viÃĒn",
+ "activate_account": "XÃĄc tháģąc ngưáģi dÚng",
+ "deactivate_account": "Vô hiáģ‡u hÃŗa ngưáģi dÚng",
+ "delete_account": "XÃŗa ngưáģi dÚng",
+ "force_nsfw": "ÄÃĄnh dáēĨu táēĨt cáēŖ tÃēt là nháēĄy cáēŖm",
+ "strip_media": "GáģĄ báģ media trong tÃēt",
+ "sandbox": "ÄÃĄnh dáēĨu táēĨt cáēŖ tÃēt là riÃĒng tư",
+ "disable_remote_subscription": "Không cho phÊp theo dÃĩi táģĢ mÃĄy cháģ§ khÃĄc",
+ "disable_any_subscription": "Không cho phÊp theo dÃĩi báēĨt cáģŠ ai",
+ "quarantine": "Không cho phÊp tÃēt liÃĒn háģŖp",
+ "delete_user": "XÃŗa ngưáģi dÚng",
+ "revoke_moderator": "GáģĄ báģ QuáēŖn tráģ‹ viÃĒn",
+ "force_unlisted": "ÄÃĄnh dáēĨu táēĨt cáēŖ tÃēt là háēĄn cháēŋ"
+ },
+ "highlight": {
+ "disabled": "Không náģ•i báē­t",
+ "solid": "Náģn 1 màu",
+ "striped": "Náģn 2 màu",
+ "side": "Sáģc bÃĒn"
+ },
+ "follow_sent": "ÄÃŖ gáģ­i yÃĒu cáē§u!",
+ "its_you": "ÄÃŗ là báēĄn!"
+ },
+ "user_profile": {
+ "timeline_title": "BáēŖng tin ngưáģi dÚng",
+ "profile_does_not_exist": "Xin láģ—i, tài khoáēŖn này không táģ“n táēĄi.",
+ "profile_loading_error": "Xin láģ—i, cÃŗ láģ—i xáēŖy ra khi xem trang cÃĄ nhÃĸn này."
+ },
+ "user_reporting": {
+ "title": "BÃĄo cÃĄo {0}",
+ "additional_comments": "Ghi chÃē",
+ "forward_description": "Ngưáģi này thuáģ™c mÃĄy cháģ§ khÃĄc. Gáģ­i máģ™t bÃĄo cÃĄo áēŠn danh táģ›i mÃĄy cháģ§ Ä‘Ãŗ?",
+ "forward_to": "Chuyáģƒn cho {0}",
+ "submit": "Gáģ­i",
+ "generic_error": "CÃŗ láģ—i xáēŖy ra khi xáģ­ lÃŊ yÃĒu cáē§u cáģ§a báēĄn.",
+ "add_comment_description": "HÃŖy cho quáēŖn tráģ‹ viÃĒn biáēŋt lÃŊ do vÃŦ sao báēĄn bÃĄo cÃĄo ngưáģi này:"
+ },
+ "who_to_follow": {
+ "more": "Nhiáģu hÆĄn náģ¯a",
+ "who_to_follow": "Nháģ¯ng ngưáģi dÚng náģ•i báē­t"
+ },
+ "tool_tip": {
+ "media_upload": "TáēŖi lÃĒn media",
+ "repeat": "Chia sáēģ",
+ "reply": "TráēŖ láģi",
+ "favorite": "Thích",
+ "add_reaction": "ThÃĒm tÆ°ÆĄng tÃĄc",
+ "accept_follow_request": "PhÃĒ duyáģ‡t yÃĒu cáē§u theo dÃĩi",
+ "reject_follow_request": "TáģĢ cháģ‘i yÃĒu cáē§u theo dÃĩi",
+ "bookmark": "Lưu",
+ "user_settings": "Thiáēŋt láē­p ngưáģi dÚng"
+ },
+ "upload": {
+ "error": {
+ "base": "TáēŖi lÃĒn tháēĨt báēĄi.",
+ "message": "TáēŖi lÃĒn tháēĨt báēĄi: {0}",
+ "file_too_big": "Táē­p tin quÃĄ láģ›n [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
+ "default": "HÃŖy tháģ­ láēĄi sau"
+ },
+ "file_size_units": {
+ "KiB": "KB",
+ "MiB": "MB",
+ "GiB": "GB",
+ "B": "byte",
+ "TiB": "TB"
+ }
+ },
+ "search": {
+ "people": "Ngưáģi",
+ "hashtags": "Hashtag",
+ "person_talking": "{count} ngưáģi đang trÃ˛ chuyáģ‡n",
+ "people_talking": "{count} ngưáģi đang trÃ˛ chuyáģ‡n",
+ "no_results": "Không tÃŦm tháēĨy"
+ },
+ "password_reset": {
+ "forgot_password": "QuÃĒn máē­t kháēŠu",
+ "password_reset": "Đáģ•i máē­t kháēŠu",
+ "placeholder": "Email hoáēˇc tÃĒn ngưáģi dÚng",
+ "check_email": "Kiáģƒm tra email cáģ§a báēĄn.",
+ "return_home": "Quay láēĄi Pleroma",
+ "too_many_requests": "BáēĄn Ä‘ÃŖ vưáģŖt giáģ›i háēĄn cho phÊp, hÃŖy tháģ­ láēĄi sau.",
+ "password_reset_disabled": "Reset máē­t kháēŠu báģ‹ táē¯t. HÃŖy liÃĒn háģ‡ quáēŖn tráģ‹ viÃĒn mÃĄy cháģ§.",
+ "password_reset_required": "BáēĄn pháēŖi đáģ•i máē­t kháēŠu đáģƒ Ä‘Äƒng nháē­p.",
+ "instruction": "Nháē­p email hoáēˇc tÃĒn ngưáģi dÚng. ChÃēng tôi sáēŊ gáģ­i email reset máē­t kháēŠu cho báēĄn.",
+ "password_reset_required_but_mailer_is_disabled": "BáēĄn cáē§n pháēŖi đáģ•i máē­t kháēŠu, nhưng tính năng báģ‹ táē¯t. HÃŖy liÃĒn háģ‡ quáēŖn tráģ‹ viÃĒn mÃĄy cháģ§."
+ },
+ "chats": {
+ "you": "BáēĄn:",
+ "message_user": "Nháē¯n tin {nickname}",
+ "delete": "XÃŗa",
+ "chats": "Chat",
+ "new": "Chat máģ›i",
+ "empty_message_error": "Không tháģƒ gáģ­i tin nháē¯n tráģ‘ng",
+ "more": "Nhiáģu hÆĄn",
+ "delete_confirm": "BáēĄn cÃŗ cháē¯c cháē¯n muáģ‘n xÃŗa tin nháē¯n này?",
+ "error_loading_chat": "CÃŗ váēĨn đáģ khi táēŖi giao diáģ‡n chat.",
+ "error_sending_message": "CÃŗ váēĨn đáģ khi gáģ­i tin nháē¯n.",
+ "empty_chat_list_placeholder": "BáēĄn không cÃŗ tin nháē¯n. HÃŖy báē¯t đáē§u nháē¯n cho ai Ä‘Ãŗ!"
+ },
+ "file_type": {
+ "audio": "Âm thanh",
+ "video": "Video",
+ "image": "HÃŦnh áēŖnh",
+ "file": "Táē­p tin"
+ },
+ "display_date": {
+ "today": "Hôm nay"
}
}
diff --git a/src/i18n/zh.json b/src/i18n/zh.json
index abba4be9..cf5f384c 100644
--- a/src/i18n/zh.json
+++ b/src/i18n/zh.json
@@ -15,7 +15,8 @@
"title": "功čƒŊ",
"who_to_follow": "æŽ¨čå…ŗæŗ¨",
"pleroma_chat_messages": "Pleroma čŠå¤Š",
- "upload_limit": "上äŧ é™åˆļ"
+ "upload_limit": "上äŧ é™åˆļ",
+ "shout": "ᕙ荀æŋ"
},
"finder": {
"error_fetching_user": "čŽˇå–į”¨æˆˇæ—ļå‘į”Ÿé”™č¯¯",
@@ -46,7 +47,13 @@
},
"flash_content": "į‚šå‡ģäģĨäŊŋᔍ Ruffle 昞į¤ē Flash 内厚īŧˆåŽžéĒŒæ€§īŧŒå¯čƒŊ无效īŧ‰ã€‚",
"flash_security": "æŗ¨æ„čŋ™å¯čƒŊ有æŊœåœ¨įš„åąé™ŠīŧŒå› ä¸ē Flash 内厚äģį„ļ是äģģæ„įš„äģŖį ã€‚",
- "flash_fail": "Flash 内厚加čŊŊå¤ąč´ĨīŧŒč¯ˇåœ¨æŽ§åˆļ台æŸĨįœ‹č¯Ļ情。"
+ "flash_fail": "Flash 内厚加čŊŊå¤ąč´ĨīŧŒč¯ˇåœ¨æŽ§åˆļ台æŸĨįœ‹č¯Ļ情。",
+ "scope_in_timeline": {
+ "public": "å…Ŧåŧ€",
+ "direct": "į§čŽ¯",
+ "private": "äģ…å…ŗæŗ¨č€…",
+ "unlisted": "列外"
+ }
},
"image_cropper": {
"crop_picture": "誁å‰Ēå›žį‰‡",
@@ -79,7 +86,9 @@
},
"media_modal": {
"previous": "垀前",
- "next": "垀后"
+ "next": "垀后",
+ "hide": "å…ŗé—­åĒ’äŊ“æŸĨįœ‹å™¨",
+ "counter": "{current} / {total}"
},
"nav": {
"about": "å…ŗäēŽ",
@@ -114,7 +123,8 @@
"reacted_with": "äŊœå‡ēäē† {0} įš„ååē”",
"migrated_to": "čŋį§ģ到äē†",
"follow_request": "æƒŗčĻå…ŗæŗ¨äŊ ",
- "error": "取垗通įŸĨæ—ļå‘į”Ÿé”™č¯¯īŧš{0}"
+ "error": "取垗通įŸĨæ—ļå‘į”Ÿé”™č¯¯īŧš{0}",
+ "poll_ended": "投įĨ¨į쓿Ÿäē†"
},
"polls": {
"add_poll": "åĸžåŠ æŠ•įĨ¨",
@@ -197,7 +207,8 @@
},
"reason_placeholder": "æ­¤åŽžäž‹įš„æŗ¨å†Œéœ€čĻæ‰‹åŠ¨æ‰šå‡†ã€‚\nč¯ˇčŽŠįŽĄį†å‘˜įŸĨ道您ä¸ēäģ€äšˆæƒŗčĻæŗ¨å†Œã€‚",
"reason": "æŗ¨å†Œį†į”ą",
- "register": "æŗ¨å†Œ"
+ "register": "æŗ¨å†Œ",
+ "email_language": "äŊ æƒŗäģŽæœåŠĄå™¨æ”ļ到äģ€äšˆč¯­č¨€įš„邎äģļīŧŸ"
},
"selectable_list": {
"select_all": "选拊全部"
@@ -589,7 +600,38 @@
"backup_restore": "莞įŊŽå¤‡äģŊ"
},
"right_sidebar": "åœ¨åŗäž§æ˜žį¤ēäž§čžšæ ",
- "hide_shoutbox": "éšč—åŽžäž‹į•™č¨€æŋ"
+ "hide_shoutbox": "éšč—åŽžäž‹į•™č¨€æŋ",
+ "expert_mode": "昞į¤ēé̘įē§",
+ "download_backup": "下čŊŊ",
+ "mention_links": "提及铞æŽĨ",
+ "account_backup": "č´Ļåˇå¤‡äģŊ",
+ "account_backup_table_head": "备äģŊ",
+ "remove_backup": "į§ģ除",
+ "list_backups_error": "čŽˇå–å¤‡äģŊåˆ—čĄ¨å‡ē错īŧš{error}",
+ "add_backup": "创åģē一ä¸Ē新备äģŊ",
+ "added_backup": "创åģēäē†ä¸€ä¸Ē新备äģŊ。",
+ "account_alias": "č´ĻåˇåˆĢ名",
+ "account_alias_table_head": "åˆĢ名",
+ "list_aliases_error": "čŽˇå–åˆĢ名æ—ļå‡ē错īŧš{error}",
+ "hide_list_aliases_error_action": "å…ŗé—­",
+ "remove_alias": "į§ģ除čŋ™ä¸ĒåˆĢ名",
+ "new_alias_target": "æˇģ加一ä¸Ē新åˆĢ名īŧˆäž‹åĻ‚ {example}īŧ‰",
+ "added_alias": "åˆĢ名æˇģ加åĨŊäē†ã€‚",
+ "move_account": "į§ģ动č´Ļåˇ",
+ "move_account_target": "į›Žæ ‡č´Ļåˇīŧˆäž‹åĻ‚ {example}īŧ‰",
+ "moved_account": "č´Ļåˇį§ģ动åĨŊäē†ã€‚",
+ "move_account_error": "į§ģ动č´Ļåˇæ—ļå‡ē错īŧš{error}",
+ "setting_server_side": "čŋ™ä¸Ē莞įŊŽæ˜¯æ†įģ‘到äŊ įš„ä¸Ēäēēčĩ„æ–™įš„īŧŒčƒŊåŊąå“æ‰€æœ‰äŧšč¯å’ŒåŽĸæˆˇį̝",
+ "post_look_feel": "文įĢ įš„æ ˇå­čˇŸæ„Ÿå—",
+ "email_language": "äģŽæœåŠĄå™¨æ”ļ邮äģļįš„č¯­č¨€",
+ "account_backup_description": "čŋ™ä¸Ēå…čŽ¸äŊ ä¸‹čŊŊ一äģŊč´ĻåˇäŋĄæ¯å’Œæ–‡įĢ įš„å­˜æĄŖīŧŒäŊ†æ˜¯įŽ°åœ¨čŋ˜ä¸čƒŊå¯ŧå…Ĩ到 Pleroma č´Ļåˇé‡Œã€‚",
+ "backup_not_ready": "备äģŊčŋ˜æ˛Ąå‡†å¤‡åĨŊ。",
+ "add_backup_error": "æˇģ加新备äģŊæ—ļå‡ē错īŧš{error}",
+ "add_alias_error": "æˇģ加åˆĢ名æ—ļå‡ē错īŧš{error}",
+ "move_account_notes": "åĻ‚æžœäŊ æƒŗæŠŠč´Ļåˇį§ģ动到åˆĢįš„åœ°æ–šīŧŒäŊ åŋ…éĄģåŽģį›Žæ ‡č´ĻåˇīŧŒį„ļ后加一ä¸Ē指向čŋ™é‡Œįš„åˆĢ名。",
+ "wordfilter": "č¯č¯­čŋ‡æģ¤å™¨",
+ "user_profiles": "į”¨æˆˇčĩ„æ–™",
+ "third_column_mode_notifications": "æļˆæ¯æ "
},
"time": {
"day": "{0} 夊",
@@ -623,7 +665,23 @@
"year": "{0} åš´",
"years": "{0} åš´",
"year_short": "{0}y",
- "years_short": "{0}y"
+ "years_short": "{0}y",
+ "unit": {
+ "days_short": "{0} 夊",
+ "hours": "{0} 小æ—ļ",
+ "hours_short": "{0} æ—ļ",
+ "minutes": "{0} 分",
+ "minutes_short": "{0} 分",
+ "months": "{0} ä¸Ē月",
+ "months_short": "{0} 月",
+ "seconds": "{0} į§’",
+ "seconds_short": "{0} į§’",
+ "weeks_short": "{0} 周",
+ "years": "{0} åš´",
+ "years_short": "{0} åš´",
+ "weeks": "{0} 周",
+ "days": "{0} 夊"
+ }
},
"timeline": {
"collapse": "折叠",
@@ -666,7 +724,32 @@
"status_deleted": "č¯ĨįŠļæ€åˇ˛čĸĢ删除",
"nsfw": "NSFW",
"external_source": "外部æĨæē",
- "expand": "åą•åŧ€"
+ "expand": "åą•åŧ€",
+ "you": "īŧˆäŊ īŧ‰",
+ "plus_more": "čŋ˜æœ‰ {number} ä¸Ē",
+ "many_attachments": "文įĢ æœ‰ {number} ä¸Ē附äģļ",
+ "collapse_attachments": "折čĩˇé™„äģļ",
+ "show_all_attachments": "昞į¤ē所有附äģļ",
+ "show_attachment_description": "éĸ„č§ˆæčŋ°īŧˆæ‰“åŧ€é™„äģļčƒŊįœ‹åŽŒæ•´æčŋ°īŧ‰",
+ "hide_attachment": "隐藏附äģļ",
+ "remove_attachment": "į§ģ除附äģļ",
+ "attachment_stop_flash": "停æ­ĸ Flash 播攞器",
+ "move_up": "把附äģļåˇĻį§ģ",
+ "open_gallery": "打åŧ€å›žåē“",
+ "thread_hide": "隐藏čŋ™ä¸Ēįēŋį´ĸ",
+ "thread_show": "昞į¤ēčŋ™ä¸Ēįēŋį´ĸ",
+ "thread_show_full_with_icon": "{icon} {text}",
+ "thread_follow": "æŸĨįœ‹čŋ™ä¸Ēįēŋį´ĸįš„å‰ŠäŊ™éƒ¨åˆ†īŧˆä¸€å…ąæœ‰ {numStatus} ä¸ĒįŠļ态īŧ‰",
+ "thread_follow_with_icon": "{icon} {text}",
+ "ancestor_follow": "æŸĨįœ‹čŋ™ä¸ĒįŠļæ€ä¸‹įš„åˆĢįš„ {numReplies} ä¸Ē回复",
+ "ancestor_follow_with_icon": "{icon} {text}",
+ "show_all_conversation_with_icon": "{icon} {text}",
+ "show_all_conversation": "昞į¤ēåŽŒæ•´å¯šč¯īŧˆčŋ˜æœ‰ {numStatus} ä¸ĒįŠļ态īŧ‰",
+ "mentions": "提及",
+ "replies_list_with_others": "回复īŧˆåĻ外 +{numReplies} ä¸Ēīŧ‰īŧš",
+ "move_down": "把附äģļåŗį§ģ",
+ "thread_show_full": "昞į¤ēčŋ™ä¸Ēįēŋį´ĸä¸‹įš„æ‰€æœ‰ä¸œčĨŋīŧˆä¸€å…ąæœ‰ {numStatus} ä¸ĒįŠļ态īŧŒæœ€å¤§æˇąåēĻ {depth}īŧ‰",
+ "show_only_conversation_under_this": "åĒ昞į¤ēčŋ™ä¸ĒįŠļæ€įš„å›žå¤"
},
"user_card": {
"approve": "核准",
@@ -714,8 +797,7 @@
"disable_remote_subscription": "įρæ­ĸäģŽčŋœį¨‹åŽžäž‹å…ŗæŗ¨į”¨æˆˇ",
"disable_any_subscription": "厌全įρæ­ĸå…ŗæŗ¨į”¨æˆˇ",
"quarantine": "äģŽč”合厞䞋中įρæ­ĸį”¨æˆˇå¸–å­",
- "delete_user": "åˆ é™¤į”¨æˆˇ",
- "delete_user_confirmation": "äŊ įĄŽåŽšå—īŧŸæ­¤æ“äŊœæ— æŗ•撤销。"
+ "delete_user": "åˆ é™¤į”¨æˆˇ"
},
"hidden": "åˇ˛éšč—",
"show_repeats": "昞į¤ēčŊŦ发",
@@ -825,7 +907,10 @@
"media_nsfw": "åŧēåˆļ莞įŊŽåĒ’äŊ“ä¸ē敏感内厚",
"media_removal_desc": "æœŦ厞䞋į§ģ除æĨč‡ĒäģĨä¸‹åŽžäž‹įš„åĒ’äŊ“内厚īŧš",
"ftl_removal_desc": "č¯Ĩ厞䞋在äģŽâ€œåˇ˛įŸĨįŊ‘įģœâ€æ—ļ间įēŋ上į§ģ除äē†ä¸‹åˆ—厞䞋īŧš",
- "ftl_removal": "äģŽâ€œåˇ˛įŸĨįŊ‘įģœâ€æ—ļ间įēŋ上į§ģ除"
+ "ftl_removal": "äģŽâ€œåˇ˛įŸĨįŊ‘įģœâ€æ—ļ间įēŋ上į§ģ除",
+ "reason": "į†į”ą",
+ "not_applicable": "无",
+ "instance": "厞䞋"
},
"mrf_policies_desc": "MRF į­–į•ĨäŧšåŊąå“æœŦåŽžäž‹įš„äē’é€ščĄŒä¸ē。äģĨ䏋᭖į•Ĩ厞吝ᔍīŧš",
"mrf_policies": "åˇ˛å¯į”¨įš„ MRF į­–į•Ĩ",
diff --git a/src/i18n/zh_Hant.json b/src/i18n/zh_Hant.json
index 2c4dc3fb..6f0f63b5 100644
--- a/src/i18n/zh_Hant.json
+++ b/src/i18n/zh_Hant.json
@@ -747,7 +747,6 @@
"admin_menu": {
"delete_account": "åˆĒ除čŗŦ號",
"delete_user": "åˆĒ除ᔍæˆļ",
- "delete_user_confirmation": "äŊ įĸēčĒå—ŽīŧŸæ­¤æ“äŊœį„Ąæŗ•æ’¤éŠˇã€‚",
"moderation": "čĒŋ停",
"grant_admin": "čŗĻäēˆįŽĄį†æŦŠé™",
"revoke_admin": "æ’¤éŠˇįŽĄį†æŦŠé™",
diff --git a/src/lib/notification-i18n-loader.js b/src/lib/notification-i18n-loader.js
index 71f9156a..d7a4430d 100644
--- a/src/lib/notification-i18n-loader.js
+++ b/src/lib/notification-i18n-loader.js
@@ -3,8 +3,8 @@
// meant to be used to load the partial i18n we need for
// the service worker.
module.exports = function (source) {
- var object = JSON.parse(source)
- var smol = {
+ const object = JSON.parse(source)
+ const smol = {
notifications: object.notifications || {}
}
diff --git a/src/lib/persisted_state.js b/src/lib/persisted_state.js
index 8ecb66a8..6d59c595 100644
--- a/src/lib/persisted_state.js
+++ b/src/lib/persisted_state.js
@@ -1,20 +1,23 @@
import merge from 'lodash.merge'
import localforage from 'localforage'
-import { each, get, set } from 'lodash'
+import { each, get, set, cloneDeep } from 'lodash'
let loaded = false
const defaultReducer = (state, paths) => (
- paths.length === 0 ? state : paths.reduce((substate, path) => {
- set(substate, path, get(state, path))
- return substate
- }, {})
+ paths.length === 0
+ ? state
+ : paths.reduce((substate, path) => {
+ set(substate, path, get(state, path))
+ return substate
+ }, {})
)
const saveImmedeatelyActions = [
'markNotificationsAsSeen',
'clearCurrentUser',
'setCurrentUser',
+ 'setServerSideStorage',
'setHighlight',
'setOption',
'setClientData',
@@ -30,7 +33,7 @@ export default function createPersistedState ({
key = 'vuex-lz',
paths = [],
getState = (key, storage) => {
- let value = storage.getItem(key)
+ const value = storage.getItem(key)
return value
},
setState = (key, state, storage) => {
@@ -69,7 +72,7 @@ export default function createPersistedState ({
subscriber(store)((mutation, state) => {
try {
if (saveImmedeatelyActions.includes(mutation.type)) {
- setState(key, reducer(state, paths), storage)
+ setState(key, reducer(cloneDeep(state), paths), storage)
.then(success => {
if (typeof success !== 'undefined') {
if (mutation.type === 'setOption' || mutation.type === 'setCurrentUser') {
diff --git a/src/main.js b/src/main.js
index 3895da89..d3e60a0f 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,6 +1,4 @@
-import Vue from 'vue'
-import VueRouter from 'vue-router'
-import Vuex from 'vuex'
+import { createStore } from 'vuex'
import 'custom-event-polyfill'
import './lib/event_target_polyfill.js'
@@ -8,9 +6,12 @@ import './lib/event_target_polyfill.js'
import interfaceModule from './modules/interface.js'
import instanceModule from './modules/instance.js'
import statusesModule from './modules/statuses.js'
+import listsModule from './modules/lists.js'
import usersModule from './modules/users.js'
import apiModule from './modules/api.js'
import configModule from './modules/config.js'
+import serverSideConfigModule from './modules/serverSideConfig.js'
+import serverSideStorageModule from './modules/serverSideStorage.js'
import shoutModule from './modules/shout.js'
import oauthModule from './modules/oauth.js'
import authFlowModule from './modules/auth_flow.js'
@@ -19,36 +20,24 @@ import oauthTokensModule from './modules/oauth_tokens.js'
import reportsModule from './modules/reports.js'
import pollsModule from './modules/polls.js'
import postStatusModule from './modules/postStatus.js'
+import editStatusModule from './modules/editStatus.js'
+import statusHistoryModule from './modules/statusHistory.js'
+
import chatsModule from './modules/chats.js'
+import announcementsModule from './modules/announcements.js'
-import VueI18n from 'vue-i18n'
+import { createI18n } from 'vue-i18n'
import createPersistedState from './lib/persisted_state.js'
import pushNotifications from './lib/push_notifications_plugin.js'
import messages from './i18n/messages.js'
-import VueClickOutside from 'v-click-outside'
-import PortalVue from 'portal-vue'
-import VBodyScrollLock from './directives/body_scroll_lock'
-
-import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
-
import afterStoreSetup from './boot/after_store.js'
const currentLocale = (window.navigator.language || 'en').split('-')[0]
-Vue.use(Vuex)
-Vue.use(VueRouter)
-Vue.use(VueI18n)
-Vue.use(VueClickOutside)
-Vue.use(PortalVue)
-Vue.use(VBodyScrollLock)
-
-Vue.component('FAIcon', FontAwesomeIcon)
-Vue.component('FALayers', FontAwesomeLayers)
-
-const i18n = new VueI18n({
+const i18n = createI18n({
// By default, use the browser locale, we will update it if neccessary
locale: 'en',
fallbackLocale: 'en',
@@ -59,6 +48,7 @@ messages.setLanguage(i18n, currentLocale)
const persistedStateOptions = {
paths: [
+ 'serverSideStorage.cache',
'config',
'users.lastLoginName',
'oauth'
@@ -75,19 +65,23 @@ const persistedStateOptions = {
console.error(e)
storageError = true
}
- const store = new Vuex.Store({
+ const store = createStore({
modules: {
i18n: {
getters: {
- i18n: () => i18n
+ i18n: () => i18n.global
}
},
interface: interfaceModule,
instance: instanceModule,
- statuses: statusesModule,
+ // TODO refactor users/statuses modules, they depend on each other
users: usersModule,
+ statuses: statusesModule,
+ lists: listsModule,
api: apiModule,
config: configModule,
+ serverSideConfig: serverSideConfigModule,
+ serverSideStorage: serverSideStorageModule,
shout: shoutModule,
oauth: oauthModule,
authFlow: authFlowModule,
@@ -96,7 +90,10 @@ const persistedStateOptions = {
reports: reportsModule,
polls: pollsModule,
postStatus: postStatusModule,
- chats: chatsModule
+ editStatus: editStatusModule,
+ statusHistory: statusHistoryModule,
+ chats: chatsModule,
+ announcements: announcementsModule
},
plugins,
strict: false // Socket modifies itself, let's ignore this for now.
diff --git a/src/modules/announcements.js b/src/modules/announcements.js
new file mode 100644
index 00000000..e4d2d2b0
--- /dev/null
+++ b/src/modules/announcements.js
@@ -0,0 +1,135 @@
+const FETCH_ANNOUNCEMENT_INTERVAL_MS = 1000 * 60 * 5
+
+export const defaultState = {
+ announcements: [],
+ supportsAnnouncements: true,
+ fetchAnnouncementsTimer: undefined
+}
+
+export const mutations = {
+ setAnnouncements (state, announcements) {
+ state.announcements = announcements
+ },
+ setAnnouncementRead (state, { id, read }) {
+ const index = state.announcements.findIndex(a => a.id === id)
+
+ if (index < 0) {
+ return
+ }
+
+ state.announcements[index].read = read
+ },
+ setFetchAnnouncementsTimer (state, timer) {
+ state.fetchAnnouncementsTimer = timer
+ },
+ setSupportsAnnouncements (state, supportsAnnouncements) {
+ state.supportsAnnouncements = supportsAnnouncements
+ }
+}
+
+export const getters = {
+ unreadAnnouncementCount (state, _getters, rootState) {
+ if (!rootState.users.currentUser) {
+ return 0
+ }
+
+ const unread = state.announcements.filter(announcement => !(announcement.inactive || announcement.read))
+ return unread.length
+ }
+}
+
+const announcements = {
+ state: defaultState,
+ mutations,
+ getters,
+ actions: {
+ fetchAnnouncements (store) {
+ if (!store.state.supportsAnnouncements) {
+ return Promise.resolve()
+ }
+
+ const currentUser = store.rootState.users.currentUser
+ const isAdmin = currentUser && currentUser.role === 'admin'
+
+ const getAnnouncements = async () => {
+ if (!isAdmin) {
+ return store.rootState.api.backendInteractor.fetchAnnouncements()
+ }
+
+ const all = await store.rootState.api.backendInteractor.adminFetchAnnouncements()
+ const visible = await store.rootState.api.backendInteractor.fetchAnnouncements()
+ const visibleObject = visible.reduce((a, c) => {
+ a[c.id] = c
+ return a
+ }, {})
+ const getWithinVisible = announcement => visibleObject[announcement.id]
+
+ all.forEach(announcement => {
+ const visibleAnnouncement = getWithinVisible(announcement)
+ if (!visibleAnnouncement) {
+ announcement.inactive = true
+ } else {
+ announcement.read = visibleAnnouncement.read
+ }
+ })
+
+ return all
+ }
+
+ return getAnnouncements()
+ .then(announcements => {
+ store.commit('setAnnouncements', announcements)
+ })
+ .catch(error => {
+ // If and only if backend does not support announcements, it would return 404.
+ // In this case, silently ignores it.
+ if (error && error.statusCode === 404) {
+ store.commit('setSupportsAnnouncements', false)
+ } else {
+ throw error
+ }
+ })
+ },
+ markAnnouncementAsRead (store, id) {
+ return store.rootState.api.backendInteractor.dismissAnnouncement({ id })
+ .then(() => {
+ store.commit('setAnnouncementRead', { id, read: true })
+ })
+ },
+ startFetchingAnnouncements (store) {
+ if (store.state.fetchAnnouncementsTimer) {
+ return
+ }
+
+ const interval = setInterval(() => store.dispatch('fetchAnnouncements'), FETCH_ANNOUNCEMENT_INTERVAL_MS)
+ store.commit('setFetchAnnouncementsTimer', interval)
+
+ return store.dispatch('fetchAnnouncements')
+ },
+ stopFetchingAnnouncements (store) {
+ const interval = store.state.fetchAnnouncementsTimer
+ store.commit('setFetchAnnouncementsTimer', undefined)
+ clearInterval(interval)
+ },
+ postAnnouncement (store, { content, startsAt, endsAt, allDay }) {
+ return store.rootState.api.backendInteractor.postAnnouncement({ content, startsAt, endsAt, allDay })
+ .then(() => {
+ return store.dispatch('fetchAnnouncements')
+ })
+ },
+ editAnnouncement (store, { id, content, startsAt, endsAt, allDay }) {
+ return store.rootState.api.backendInteractor.editAnnouncement({ id, content, startsAt, endsAt, allDay })
+ .then(() => {
+ return store.dispatch('fetchAnnouncements')
+ })
+ },
+ deleteAnnouncement (store, id) {
+ return store.rootState.api.backendInteractor.deleteAnnouncement({ id })
+ .then(() => {
+ return store.dispatch('fetchAnnouncements')
+ })
+ }
+ }
+}
+
+export default announcements
diff --git a/src/modules/api.js b/src/modules/api.js
index 54f94356..fee584e8 100644
--- a/src/modules/api.js
+++ b/src/modules/api.js
@@ -15,6 +15,9 @@ const api = {
mastoUserSocketStatus: null,
followRequests: []
},
+ getters: {
+ followRequestCount: state => state.followRequests.length
+ },
mutations: {
setBackendInteractor (state, backendInteractor) {
state.backendInteractor = backendInteractor
@@ -100,6 +103,13 @@ const api = {
showImmediately: timelineData.visibleStatuses.length === 0,
timeline: 'friends'
})
+ } else if (message.event === 'status.update') {
+ dispatch('addNewStatuses', {
+ statuses: [message.status],
+ userId: false,
+ showImmediately: message.status.id in timelineData.visibleStatusesObject,
+ timeline: 'friends'
+ })
} else if (message.event === 'delete') {
dispatch('deleteStatusById', message.id)
} else if (message.event === 'pleroma:chat_update') {
@@ -191,12 +201,13 @@ const api = {
startFetchingTimeline (store, {
timeline = 'friends',
tag = false,
- userId = false
+ userId = false,
+ listId = false
}) {
if (store.state.fetchers[timeline]) return
const fetcher = store.state.backendInteractor.startFetchingTimeline({
- timeline, store, userId, tag
+ timeline, store, userId, listId, tag
})
store.commit('addFetcher', { fetcherName: timeline, fetcher })
},
@@ -205,7 +216,7 @@ const api = {
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: timeline, fetcher })
},
- fetchTimeline (store, timeline, { ...rest }) {
+ fetchTimeline (store, { timeline, ...rest }) {
store.state.backendInteractor.fetchTimeline({
store,
timeline,
@@ -233,7 +244,7 @@ const api = {
// Follow requests
startFetchingFollowRequests (store) {
- if (store.state.fetchers['followRequests']) return
+ if (store.state.fetchers.followRequests) return
const fetcher = store.state.backendInteractor.startFetchingFollowRequests({ store })
store.commit('addFetcher', { fetcherName: 'followRequests', fetcher })
@@ -244,10 +255,22 @@ const api = {
store.commit('removeFetcher', { fetcherName: 'followRequests', fetcher })
},
removeFollowRequest (store, request) {
- let requests = store.state.followRequests.filter((it) => it !== request)
+ const requests = store.state.followRequests.filter((it) => it !== request)
store.commit('setFollowRequests', requests)
},
+ // Lists
+ startFetchingLists (store) {
+ if (store.state.fetchers.lists) return
+ const fetcher = store.state.backendInteractor.startFetchingLists({ store })
+ store.commit('addFetcher', { fetcherName: 'lists', fetcher })
+ },
+ stopFetchingLists (store) {
+ const fetcher = store.state.fetchers.lists
+ if (!fetcher) return
+ store.commit('removeFetcher', { fetcherName: 'lists', fetcher })
+ },
+
// Pleroma websocket
setWsToken (store, token) {
store.commit('setWsToken', token)
diff --git a/src/modules/chats.js b/src/modules/chats.js
index 69d683bd..f28c2603 100644
--- a/src/modules/chats.js
+++ b/src/modules/chats.js
@@ -1,4 +1,4 @@
-import Vue from 'vue'
+import { reactive } from 'vue'
import { find, omitBy, orderBy, sumBy } from 'lodash'
import chatService from '../services/chat_service/chat_service.js'
import { parseChat, parseChatMessage } from '../services/entity_normalizer/entity_normalizer.service.js'
@@ -13,8 +13,8 @@ const emptyChatList = () => ({
const defaultState = {
chatList: emptyChatList(),
chatListFetcher: null,
- openedChats: {},
- openedChatMessageServices: {},
+ openedChats: reactive({}),
+ openedChatMessageServices: reactive({}),
fetcher: undefined,
currentChatId: null,
lastReadMessageId: null
@@ -137,10 +137,10 @@ const chats = {
},
addOpenedChat (state, { _dispatch, chat }) {
state.currentChatId = chat.id
- Vue.set(state.openedChats, chat.id, chat)
+ state.openedChats[chat.id] = chat
if (!state.openedChatMessageServices[chat.id]) {
- Vue.set(state.openedChatMessageServices, chat.id, chatService.empty(chat.id))
+ state.openedChatMessageServices[chat.id] = chatService.empty(chat.id)
}
},
setCurrentChatId (state, { chatId }) {
@@ -160,7 +160,7 @@ const chats = {
}
} else {
state.chatList.data.push(updatedChat)
- Vue.set(state.chatList.idStore, updatedChat.id, updatedChat)
+ state.chatList.idStore[updatedChat.id] = updatedChat
}
})
},
@@ -172,7 +172,7 @@ const chats = {
chat.updated_at = updatedChat.updated_at
}
if (!chat) { state.chatList.data.unshift(updatedChat) }
- Vue.set(state.chatList.idStore, updatedChat.id, updatedChat)
+ state.chatList.idStore[updatedChat.id] = updatedChat
},
deleteChat (state, { _dispatch, id, _rootGetters }) {
state.chats.data = state.chats.data.filter(conversation =>
@@ -186,8 +186,8 @@ const chats = {
commit('setChatListFetcher', { fetcher: undefined })
for (const chatId in state.openedChats) {
chatService.clear(state.openedChatMessageServices[chatId])
- Vue.delete(state.openedChats, chatId)
- Vue.delete(state.openedChatMessageServices, chatId)
+ delete state.openedChats[chatId]
+ delete state.openedChatMessageServices[chatId]
}
},
setChatsLoading (state, { value }) {
@@ -215,8 +215,8 @@ const chats = {
for (const chatId in state.openedChats) {
if (currentChatId !== chatId) {
chatService.clear(state.openedChatMessageServices[chatId])
- Vue.delete(state.openedChats, chatId)
- Vue.delete(state.openedChatMessageServices, chatId)
+ delete state.openedChats[chatId]
+ delete state.openedChatMessageServices[chatId]
}
}
},
diff --git a/src/modules/config.js b/src/modules/config.js
index bc3db11b..3cd6888f 100644
--- a/src/modules/config.js
+++ b/src/modules/config.js
@@ -1,6 +1,9 @@
-import { set, delete as del } from 'vue'
-import { setPreset, applyTheme } from '../services/style_setter/style_setter.js'
+import Cookies from 'js-cookie'
+import { setPreset, applyTheme, applyConfig } from '../services/style_setter/style_setter.js'
import messages from '../i18n/messages'
+import localeService from '../services/locale/locale.service.js'
+
+const BACKEND_LANGUAGE_COOKIE_NAME = 'userLanguage'
const browserLocale = (window.navigator.language || 'en').split('-')[0]
@@ -11,10 +14,15 @@ const browserLocale = (window.navigator.language || 'en').split('-')[0]
*/
export const multiChoiceProperties = [
'postContentType',
- 'subjectLineBehavior'
+ 'subjectLineBehavior',
+ 'conversationDisplay', // tree | linear
+ 'conversationOtherRepliesButton', // below | inside
+ 'mentionLinkDisplay', // short | full_for_remote | full
+ 'userPopoverAvatarAction' // close | zoom | open
]
export const defaultState = {
+ expertLevel: 0, // used to track which settings to show and hide
colors: {},
theme: undefined,
customTheme: undefined,
@@ -24,6 +32,9 @@ export const defaultState = {
hideShoutbox: false,
// bad name: actually hides posts of muted USERS
hideMutedPosts: undefined, // instance default
+ hideMutedThreads: undefined, // instance default
+ hideWordFilteredPosts: undefined, // instance default
+ muteBotStatuses: undefined, // instance default
collapseMessageWithSubject: undefined, // instance default
padEmoji: true,
hideAttachments: false,
@@ -38,8 +49,9 @@ export const defaultState = {
alwaysShowNewPostButton: false,
autohideFloatingPostButton: false,
pauseOnUnfocused: true,
- stopGifs: false,
+ stopGifs: true,
replyVisibility: 'all',
+ thirdColumnMode: 'notifications',
notificationVisibility: {
follows: true,
mentions: true,
@@ -48,7 +60,9 @@ export const defaultState = {
moves: true,
emojiReactions: true,
followRequest: true,
- chatMention: true
+ reports: true,
+ chatMention: true,
+ polls: true
},
webPushNotifications: false,
muteWords: [],
@@ -66,12 +80,33 @@ export const defaultState = {
hideFilteredStatuses: undefined, // instance default
playVideosInModal: false,
useOneClickNsfw: false,
- useContainFit: false,
+ useContainFit: true,
+ disableStickyHeaders: false,
+ showScrollbars: false,
+ userPopoverAvatarAction: 'open',
+ userPopoverOverlay: false,
+ sidebarColumnWidth: '25rem',
+ contentColumnWidth: '45rem',
+ notifsColumnWidth: '25rem',
+ navbarColumnStretch: false,
greentext: undefined, // instance default
+ useAtIcon: undefined, // instance default
+ mentionLinkDisplay: undefined, // instance default
+ mentionLinkShowTooltip: undefined, // instance default
+ mentionLinkShowAvatar: undefined, // instance default
+ mentionLinkFadeDomain: undefined, // instance default
+ mentionLinkShowYous: undefined, // instance default
+ mentionLinkBoldenYou: undefined, // instance default
hidePostStats: undefined, // instance default
+ hideBotIndication: undefined, // instance default
hideUserStats: undefined, // instance default
virtualScrolling: undefined, // instance default
- sensitiveByDefault: undefined // instance default
+ sensitiveByDefault: undefined, // instance default
+ conversationDisplay: undefined, // instance default
+ conversationTreeAdvanced: undefined, // instance default
+ conversationOtherRepliesButton: undefined, // instance default
+ conversationTreeFadeAncestors: undefined, // instance default
+ maxDepthInThread: undefined // instance default
}
// caching the instance default properties
@@ -102,14 +137,14 @@ const config = {
},
mutations: {
setOption (state, { name, value }) {
- set(state, name, value)
+ state[name] = value
},
setHighlight (state, { user, color, type }) {
const data = this.state.config.highlight[user]
if (color || type) {
- set(state.highlight, user, { color: color || data.color, type: type || data.type })
+ state.highlight[user] = { color: color || data.color, type: type || data.type }
} else {
- del(state.highlight, user)
+ delete state.highlight[user]
}
}
},
@@ -118,7 +153,7 @@ const config = {
const knownKeys = new Set(Object.keys(defaultState))
const presentKeys = new Set(Object.keys(data))
const intersection = new Set()
- for (let elem of presentKeys) {
+ for (const elem of presentKeys) {
if (knownKeys.has(elem)) {
intersection.add(elem)
}
@@ -131,18 +166,28 @@ const config = {
setHighlight ({ commit, dispatch }, { user, color, type }) {
commit('setHighlight', { user, color, type })
},
- setOption ({ commit, dispatch }, { name, value }) {
+ setOption ({ commit, dispatch, state }, { name, value }) {
commit('setOption', { name, value })
switch (name) {
case 'theme':
setPreset(value)
break
+ case 'sidebarColumnWidth':
+ case 'contentColumnWidth':
+ case 'notifsColumnWidth':
+ applyConfig(state)
+ break
case 'customTheme':
case 'customThemeSource':
applyTheme(value)
break
case 'interfaceLanguage':
messages.setLanguage(this.getters.i18n, value)
+ dispatch('loadUnicodeEmojiData', value)
+ Cookies.set(BACKEND_LANGUAGE_COOKIE_NAME, localeService.internalToBackendLocale(value))
+ break
+ case 'thirdColumnMode':
+ dispatch('setLayoutWidth', undefined)
break
}
}
diff --git a/src/modules/editStatus.js b/src/modules/editStatus.js
new file mode 100644
index 00000000..fd316519
--- /dev/null
+++ b/src/modules/editStatus.js
@@ -0,0 +1,25 @@
+const editStatus = {
+ state: {
+ params: null,
+ modalActivated: false
+ },
+ mutations: {
+ openEditStatusModal (state, params) {
+ state.params = params
+ state.modalActivated = true
+ },
+ closeEditStatusModal (state) {
+ state.modalActivated = false
+ }
+ },
+ actions: {
+ openEditStatusModal ({ commit }, params) {
+ commit('openEditStatusModal', params)
+ },
+ closeEditStatusModal ({ commit }) {
+ commit('closeEditStatusModal')
+ }
+ }
+}
+
+export default editStatus
diff --git a/src/modules/errors.js b/src/modules/errors.js
index ca89dc0f..d2e24100 100644
--- a/src/modules/errors.js
+++ b/src/modules/errors.js
@@ -2,8 +2,8 @@ import { capitalize } from 'lodash'
export function humanizeErrors (errors) {
return Object.entries(errors).reduce((errs, [k, val]) => {
- let message = val.reduce((acc, message) => {
- let key = capitalize(k.replace(/_/g, ' '))
+ const message = val.reduce((acc, message) => {
+ const key = capitalize(k.replace(/_/g, ' '))
return acc + [key, message].join(' ') + '. '
}, '')
return [...errs, message]
diff --git a/src/modules/instance.js b/src/modules/instance.js
index 539b9c66..3b15e62e 100644
--- a/src/modules/instance.js
+++ b/src/modules/instance.js
@@ -1,8 +1,42 @@
-import { set } from 'vue'
import { getPreset, applyTheme } from '../services/style_setter/style_setter.js'
import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js'
import apiService from '../services/api/api.service.js'
import { instanceDefaultProperties } from './config.js'
+import { langCodeToCldrName, ensureFinalFallback } from '../i18n/languages.js'
+
+const SORTED_EMOJI_GROUP_IDS = [
+ 'smileys-and-emotion',
+ 'people-and-body',
+ 'animals-and-nature',
+ 'food-and-drink',
+ 'travel-and-places',
+ 'activities',
+ 'objects',
+ 'symbols',
+ 'flags'
+]
+
+const REGIONAL_INDICATORS = (() => {
+ const start = 0x1F1E6
+ const end = 0x1F1FF
+ const A = 'A'.codePointAt(0)
+ const res = new Array(end - start + 1)
+ for (let i = start; i <= end; ++i) {
+ const letter = String.fromCodePoint(A + i - start)
+ res[i - start] = {
+ replacement: String.fromCodePoint(i),
+ imageUrl: false,
+ displayText: 'regional_indicator_' + letter,
+ displayTextI18n: {
+ key: 'emoji.regional_indicator',
+ args: { letter }
+ }
+ }
+ }
+ return res
+})()
+
+const REMOTE_INTERACTION_URL = '/main/ostatus'
const defaultState = {
// Stuff from apiConfig
@@ -20,16 +54,29 @@ const defaultState = {
background: '/static/aurora_borealis.jpg',
collapseMessageWithSubject: false,
greentext: false,
+ useAtIcon: false,
+ mentionLinkDisplay: 'short',
+ mentionLinkShowTooltip: true,
+ mentionLinkShowAvatar: false,
+ mentionLinkFadeDomain: true,
+ mentionLinkShowYous: false,
+ mentionLinkBoldenYou: true,
hideFilteredStatuses: false,
+ // bad name: actually hides posts of muted USERS
hideMutedPosts: false,
+ hideMutedThreads: true,
+ hideWordFilteredPosts: false,
hidePostStats: false,
+ hideBotIndication: false,
hideSitename: false,
hideUserStats: false,
+ muteBotStatuses: false,
loginMethod: 'password',
logo: '/static/logo.svg',
logoMargin: '.2em',
logoMask: true,
logoLeft: false,
+ disableUpdateNotification: false,
minimalScopesMode: false,
nsfwCensorImage: undefined,
postContentType: 'text/plain',
@@ -43,12 +90,18 @@ const defaultState = {
theme: 'pleroma-dark',
virtualScrolling: true,
sensitiveByDefault: false,
+ conversationDisplay: 'linear',
+ conversationTreeAdvanced: false,
+ conversationOtherRepliesButton: 'below',
+ conversationTreeFadeAncestors: false,
+ maxDepthInThread: 6,
// Nasty stuff
customEmoji: [],
customEmojiFetched: false,
- emoji: [],
+ emoji: {},
emojiFetched: false,
+ unicodeEmojiAnnotations: {},
pleromaBackend: true,
postFormats: [],
restrictedNicknames: [],
@@ -80,16 +133,44 @@ const defaultState = {
}
}
+const loadAnnotations = (lang) => {
+ return import(
+ /* webpackChunkName: "emoji-annotations/[request]" */
+ `@kazvmoe-infra/unicode-emoji-json/annotations/${langCodeToCldrName(lang)}.json`
+ )
+ .then(k => k.default)
+}
+
+const injectAnnotations = (emoji, annotations) => {
+ const availableLangs = Object.keys(annotations)
+
+ return {
+ ...emoji,
+ annotations: availableLangs.reduce((acc, cur) => {
+ acc[cur] = annotations[cur][emoji.replacement]
+ return acc
+ }, {})
+ }
+}
+
+const injectRegionalIndicators = groups => {
+ groups.symbols.push(...REGIONAL_INDICATORS)
+ return groups
+}
+
const instance = {
state: defaultState,
mutations: {
setInstanceOption (state, { name, value }) {
if (typeof value !== 'undefined') {
- set(state, name, value)
+ state[name] = value
}
},
setKnownDomains (state, domains) {
state.knownDomains = domains
+ },
+ setUnicodeEmojiAnnotations (state, { lang, annotations }) {
+ state.unicodeEmojiAnnotations[lang] = annotations
}
},
getters: {
@@ -97,6 +178,56 @@ const instance = {
return instanceDefaultProperties
.map(key => [key, state[key]])
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {})
+ },
+ groupedCustomEmojis (state) {
+ const packsOf = emoji => {
+ return emoji.tags
+ .filter(k => k.startsWith('pack:'))
+ .map(k => k.slice(5)) // remove 'pack:' prefix
+ }
+
+ return state.customEmoji
+ .reduce((res, emoji) => {
+ packsOf(emoji).forEach(packName => {
+ const packId = `custom-${packName}`
+ if (!res[packId]) {
+ res[packId] = ({
+ id: packId,
+ text: packName,
+ image: emoji.imageUrl,
+ emojis: []
+ })
+ }
+ res[packId].emojis.push(emoji)
+ })
+ return res
+ }, {})
+ },
+ standardEmojiList (state) {
+ return SORTED_EMOJI_GROUP_IDS
+ .map(groupId => (state.emoji[groupId] || []).map(k => injectAnnotations(k, state.unicodeEmojiAnnotations)))
+ .reduce((a, b) => a.concat(b), [])
+ },
+ standardEmojiGroupList (state) {
+ return SORTED_EMOJI_GROUP_IDS.map(groupId => ({
+ id: groupId,
+ emojis: (state.emoji[groupId] || []).map(k => injectAnnotations(k, state.unicodeEmojiAnnotations))
+ }))
+ },
+ instanceDomain (state) {
+ return new URL(state.server).hostname
+ },
+ remoteInteractionLink (state) {
+ const server = state.server.endsWith('/') ? state.server.slice(0, -1) : state.server
+ const link = server + REMOTE_INTERACTION_URL
+
+ return ({ statusId, nickname }) => {
+ if (statusId) {
+ return `${link}?status_id=${statusId}`
+ } else {
+ return `${link}?nickname=${nickname}`
+ }
+ }
}
},
actions: {
@@ -118,32 +249,52 @@ const instance = {
},
async getStaticEmoji ({ commit }) {
try {
- const res = await window.fetch('/static/emoji.json')
- if (res.ok) {
- const values = await res.json()
- const emoji = Object.keys(values).map((key) => {
- return {
- displayText: key,
- imageUrl: false,
- replacement: values[key]
- }
- }).sort((a, b) => a.name > b.name ? 1 : -1)
- commit('setInstanceOption', { name: 'emoji', value: emoji })
- } else {
- throw (res)
- }
+ const values = (await import(/* webpackChunkName: 'emoji' */ '../../static/emoji.json')).default
+
+ const emoji = Object.keys(values).reduce((res, groupId) => {
+ res[groupId] = values[groupId].map(e => ({
+ displayText: e.slug,
+ imageUrl: false,
+ replacement: e.emoji
+ }))
+ return res
+ }, {})
+ commit('setInstanceOption', { name: 'emoji', value: injectRegionalIndicators(emoji) })
} catch (e) {
console.warn("Can't load static emoji")
console.warn(e)
}
},
+ loadUnicodeEmojiData ({ commit, state }, language) {
+ const langList = ensureFinalFallback(language)
+
+ return Promise.all(
+ langList
+ .map(async lang => {
+ if (!state.unicodeEmojiAnnotations[lang]) {
+ const annotations = await loadAnnotations(lang)
+ commit('setUnicodeEmojiAnnotations', { lang, annotations })
+ }
+ }))
+ },
+
async getCustomEmoji ({ commit, state }) {
try {
const res = await window.fetch('/api/pleroma/emoji.json')
if (res.ok) {
const result = await res.json()
const values = Array.isArray(result) ? Object.assign({}, ...result) : result
+ const caseInsensitiveStrCmp = (a, b) => {
+ const la = a.toLowerCase()
+ const lb = b.toLowerCase()
+ return la > lb ? 1 : (la < lb ? -1 : 0)
+ }
+ const byPackThenByName = (a, b) => {
+ const packOf = emoji => (emoji.tags.filter(k => k.startsWith('pack:'))[0] || '').slice(5)
+ return caseInsensitiveStrCmp(packOf(a), packOf(b)) || caseInsensitiveStrCmp(a.displayText, b.displayText)
+ }
+
const emoji = Object.entries(values).map(([key, value]) => {
const imageUrl = value.image_url
return {
@@ -154,7 +305,7 @@ const instance = {
}
// Technically could use tags but those are kinda useless right now,
// should have been "pack" field, that would be more useful
- }).sort((a, b) => a.displayText.toLowerCase() > b.displayText.toLowerCase() ? 1 : -1)
+ }).sort(byPackThenByName)
commit('setInstanceOption', { name: 'customEmoji', value: emoji })
} else {
throw (res)
diff --git a/src/modules/interface.js b/src/modules/interface.js
index d6db32fd..a86193ea 100644
--- a/src/modules/interface.js
+++ b/src/modules/interface.js
@@ -1,5 +1,3 @@
-import { set, delete as del } from 'vue'
-
const defaultState = {
settingsModalState: 'hidden',
settingsModalLoaded: false,
@@ -15,7 +13,7 @@ const defaultState = {
window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)')
)
},
- mobileLayout: false,
+ layoutType: 'normal',
globalNotices: [],
layoutHeight: 0,
lastTimeline: null
@@ -29,18 +27,17 @@ const interfaceMod = {
if (state.noticeClearTimeout) {
clearTimeout(state.noticeClearTimeout)
}
- set(state.settings, 'currentSaveStateNotice', { error: false, data: success })
- set(state.settings, 'noticeClearTimeout',
- setTimeout(() => del(state.settings, 'currentSaveStateNotice'), 2000))
+ state.settings.currentSaveStateNotice = { error: false, data: success }
+ state.settings.noticeClearTimeout = setTimeout(() => delete state.settings.currentSaveStateNotice, 2000)
} else {
- set(state.settings, 'currentSaveStateNotice', { error: true, errorData: error })
+ state.settings.currentSaveStateNotice = { error: true, errorData: error }
}
},
setNotificationPermission (state, permission) {
state.notificationPermission = permission
},
- setMobileLayout (state, value) {
- state.mobileLayout = value
+ setLayoutType (state, value) {
+ state.layoutType = value
},
closeSettingsModal (state) {
state.settingsModalState = 'hidden'
@@ -75,6 +72,9 @@ const interfaceMod = {
setLayoutHeight (state, value) {
state.layoutHeight = value
},
+ setLayoutWidth (state, value) {
+ state.layoutWidth = value
+ },
setLastTimeline (state, value) {
state.lastTimeline = value
}
@@ -89,9 +89,6 @@ const interfaceMod = {
setNotificationPermission ({ commit }, permission) {
commit('setNotificationPermission', permission)
},
- setMobileLayout ({ commit }, value) {
- commit('setMobileLayout', value)
- },
closeSettingsModal ({ commit }) {
commit('closeSettingsModal')
},
@@ -109,7 +106,7 @@ const interfaceMod = {
commit('openSettingsModal')
},
pushGlobalNotice (
- { commit, dispatch },
+ { commit, dispatch, state },
{
messageKey,
messageArgs = {},
@@ -121,11 +118,14 @@ const interfaceMod = {
messageArgs,
level
}
+ commit('pushGlobalNotice', notice)
+ // Adding a new element to array wraps it in a Proxy, which breaks the comparison
+ // TODO: Generate UUID or something instead or relying on !== operator?
+ const newNotice = state.globalNotices[state.globalNotices.length - 1]
if (timeout) {
- setTimeout(() => dispatch('removeGlobalNotice', notice), timeout)
+ setTimeout(() => dispatch('removeGlobalNotice', newNotice), timeout)
}
- commit('pushGlobalNotice', notice)
- return notice
+ return newNotice
},
removeGlobalNotice ({ commit }, notice) {
commit('removeGlobalNotice', notice)
@@ -133,6 +133,24 @@ const interfaceMod = {
setLayoutHeight ({ commit }, value) {
commit('setLayoutHeight', value)
},
+ // value is optional, assuming it was cached prior
+ setLayoutWidth ({ commit, state, rootGetters, rootState }, value) {
+ let width = value
+ if (value !== undefined) {
+ commit('setLayoutWidth', value)
+ } else {
+ width = state.layoutWidth
+ }
+ const mobileLayout = width <= 800
+ const normalOrMobile = mobileLayout ? 'mobile' : 'normal'
+ const { thirdColumnMode } = rootGetters.mergedConfig
+ if (thirdColumnMode === 'none' || !rootState.users.currentUser) {
+ commit('setLayoutType', normalOrMobile)
+ } else {
+ const wideLayout = width >= 1300
+ commit('setLayoutType', wideLayout ? 'wide' : normalOrMobile)
+ }
+ },
setLastTimeline ({ commit }, value) {
commit('setLastTimeline', value)
}
diff --git a/src/modules/lists.js b/src/modules/lists.js
new file mode 100644
index 00000000..22fed800
--- /dev/null
+++ b/src/modules/lists.js
@@ -0,0 +1,130 @@
+import { remove, find } from 'lodash'
+
+export const defaultState = {
+ allLists: [],
+ allListsObject: {}
+}
+
+export const mutations = {
+ setLists (state, value) {
+ state.allLists = value
+ },
+ setList (state, { listId, title }) {
+ if (!state.allListsObject[listId]) {
+ state.allListsObject[listId] = { accountIds: [] }
+ }
+ state.allListsObject[listId].title = title
+
+ const entry = find(state.allLists, { id: listId })
+ if (!entry) {
+ state.allLists.push({ id: listId, title })
+ } else {
+ entry.title = title
+ }
+ },
+ setListAccounts (state, { listId, accountIds }) {
+ if (!state.allListsObject[listId]) {
+ state.allListsObject[listId] = { accountIds: [] }
+ }
+ state.allListsObject[listId].accountIds = accountIds
+ },
+ addListAccount (state, { listId, accountId }) {
+ if (!state.allListsObject[listId]) {
+ state.allListsObject[listId] = { accountIds: [] }
+ }
+ state.allListsObject[listId].accountIds.push(accountId)
+ },
+ removeListAccount (state, { listId, accountId }) {
+ if (!state.allListsObject[listId]) {
+ state.allListsObject[listId] = { accountIds: [] }
+ }
+ const { accountIds } = state.allListsObject[listId]
+ const set = new Set(accountIds)
+ set.delete(accountId)
+ state.allListsObject[listId].accountIds = [...set]
+ },
+ deleteList (state, { listId }) {
+ delete state.allListsObject[listId]
+ remove(state.allLists, list => list.id === listId)
+ }
+}
+
+const actions = {
+ setLists ({ commit }, value) {
+ commit('setLists', value)
+ },
+ createList ({ rootState, commit }, { title }) {
+ return rootState.api.backendInteractor.createList({ title })
+ .then((list) => {
+ commit('setList', { listId: list.id, title })
+ return list
+ })
+ },
+ fetchList ({ rootState, commit }, { listId }) {
+ return rootState.api.backendInteractor.getList({ listId })
+ .then((list) => commit('setList', { listId: list.id, title: list.title }))
+ },
+ fetchListAccounts ({ rootState, commit }, { listId }) {
+ return rootState.api.backendInteractor.getListAccounts({ listId })
+ .then((accountIds) => commit('setListAccounts', { listId, accountIds }))
+ },
+ setList ({ rootState, commit }, { listId, title }) {
+ rootState.api.backendInteractor.updateList({ listId, title })
+ commit('setList', { listId, title })
+ },
+ setListAccounts ({ rootState, commit }, { listId, accountIds }) {
+ const saved = rootState.lists.allListsObject[listId].accountIds || []
+ const added = accountIds.filter(id => !saved.includes(id))
+ const removed = saved.filter(id => !accountIds.includes(id))
+ commit('setListAccounts', { listId, accountIds })
+ if (added.length > 0) {
+ rootState.api.backendInteractor.addAccountsToList({ listId, accountIds: added })
+ }
+ if (removed.length > 0) {
+ rootState.api.backendInteractor.removeAccountsFromList({ listId, accountIds: removed })
+ }
+ },
+ addListAccount ({ rootState, commit }, { listId, accountId }) {
+ return rootState
+ .api
+ .backendInteractor
+ .addAccountsToList({ listId, accountIds: [accountId] })
+ .then((result) => {
+ commit('addListAccount', { listId, accountId })
+ return result
+ })
+ },
+ removeListAccount ({ rootState, commit }, { listId, accountId }) {
+ return rootState
+ .api
+ .backendInteractor
+ .removeAccountsFromList({ listId, accountIds: [accountId] })
+ .then((result) => {
+ commit('removeListAccount', { listId, accountId })
+ return result
+ })
+ },
+ deleteList ({ rootState, commit }, { listId }) {
+ rootState.api.backendInteractor.deleteList({ listId })
+ commit('deleteList', { listId })
+ }
+}
+
+export const getters = {
+ findListTitle: state => id => {
+ if (!state.allListsObject[id]) return
+ return state.allListsObject[id].title
+ },
+ findListAccounts: state => id => {
+ return [...state.allListsObject[id].accountIds]
+ }
+}
+
+const lists = {
+ state: defaultState,
+ mutations,
+ actions,
+ getters
+}
+
+export default lists
diff --git a/src/modules/media_viewer.js b/src/modules/media_viewer.js
index 721c25e6..ebcba01d 100644
--- a/src/modules/media_viewer.js
+++ b/src/modules/media_viewer.js
@@ -1,4 +1,5 @@
import fileTypeService from '../services/file_type/file_type.service.js'
+const supportedTypes = new Set(['image', 'video', 'audio', 'flash'])
const mediaViewer = {
state: {
@@ -10,7 +11,7 @@ const mediaViewer = {
setMedia (state, media) {
state.media = media
},
- setCurrent (state, index) {
+ setCurrentMedia (state, index) {
state.activated = true
state.currentIndex = index
},
@@ -22,13 +23,13 @@ const mediaViewer = {
setMedia ({ commit }, attachments) {
const media = attachments.filter(attachment => {
const type = fileTypeService.fileType(attachment.mimetype)
- return type === 'image' || type === 'video' || type === 'audio'
+ return supportedTypes.has(type)
})
commit('setMedia', media)
},
- setCurrent ({ commit, state }, current) {
+ setCurrentMedia ({ commit, state }, current) {
const index = state.media.indexOf(current)
- commit('setCurrent', index || 0)
+ commit('setCurrentMedia', index || 0)
},
closeMediaViewer ({ commit }) {
commit('close')
diff --git a/src/modules/oauth.js b/src/modules/oauth.js
index a2a83450..038bc3f3 100644
--- a/src/modules/oauth.js
+++ b/src/modules/oauth.js
@@ -1,5 +1,3 @@
-import { delete as del } from 'vue'
-
const oauth = {
state: {
clientId: false,
@@ -29,7 +27,7 @@ const oauth = {
state.userToken = false
// state.token is userToken with older name, coming from persistent state
// let's clear it as well, since it is being used as a fallback of state.userToken
- del(state, 'token')
+ delete state.token
}
},
getters: {
diff --git a/src/modules/polls.js b/src/modules/polls.js
index 92b89a06..1c4f98a4 100644
--- a/src/modules/polls.js
+++ b/src/modules/polls.js
@@ -1,5 +1,4 @@
import { merge } from 'lodash'
-import { set } from 'vue'
const polls = {
state: {
@@ -13,25 +12,25 @@ const polls = {
// Make expired-state change trigger re-renders properly
poll.expired = Date.now() > Date.parse(poll.expires_at)
if (existingPoll) {
- set(state.pollsObject, poll.id, merge(existingPoll, poll))
+ state.pollsObject[poll.id] = merge(existingPoll, poll)
} else {
- set(state.pollsObject, poll.id, poll)
+ state.pollsObject[poll.id] = poll
}
},
trackPoll (state, pollId) {
const currentValue = state.trackedPolls[pollId]
if (currentValue) {
- set(state.trackedPolls, pollId, currentValue + 1)
+ state.trackedPolls[pollId] = currentValue + 1
} else {
- set(state.trackedPolls, pollId, 1)
+ state.trackedPolls[pollId] = 1
}
},
untrackPoll (state, pollId) {
const currentValue = state.trackedPolls[pollId]
if (currentValue) {
- set(state.trackedPolls, pollId, currentValue - 1)
+ state.trackedPolls[pollId] = currentValue - 1
} else {
- set(state.trackedPolls, pollId, 0)
+ state.trackedPolls[pollId] = 0
}
}
},
diff --git a/src/modules/reports.js b/src/modules/reports.js
index fea83e5f..925792c0 100644
--- a/src/modules/reports.js
+++ b/src/modules/reports.js
@@ -2,20 +2,29 @@ import filter from 'lodash/filter'
const reports = {
state: {
- userId: null,
- statuses: [],
- preTickedIds: [],
- modalActivated: false
+ reportModal: {
+ userId: null,
+ statuses: [],
+ preTickedIds: [],
+ activated: false
+ },
+ reports: {}
},
mutations: {
openUserReportingModal (state, { userId, statuses, preTickedIds }) {
- state.userId = userId
- state.statuses = statuses
- state.preTickedIds = preTickedIds
- state.modalActivated = true
+ state.reportModal.userId = userId
+ state.reportModal.statuses = statuses
+ state.reportModal.preTickedIds = preTickedIds
+ state.reportModal.activated = true
},
closeUserReportingModal (state) {
- state.modalActivated = false
+ state.reportModal.activated = false
+ },
+ setReportState (reportsState, { id, state }) {
+ reportsState.reports[id].state = state
+ },
+ addReport (state, report) {
+ state.reports[report.id] = report
}
},
actions: {
@@ -31,6 +40,23 @@ const reports = {
},
closeUserReportingModal ({ commit }) {
commit('closeUserReportingModal')
+ },
+ setReportState ({ commit, dispatch, rootState }, { id, state }) {
+ const oldState = rootState.reports.reports[id].state
+ commit('setReportState', { id, state })
+ rootState.api.backendInteractor.setReportState({ id, state }).catch(e => {
+ console.error('Failed to set report state', e)
+ dispatch('pushGlobalNotice', {
+ level: 'error',
+ messageKey: 'general.generic_error_message',
+ messageArgs: [e.message],
+ timeout: 5000
+ })
+ commit('setReportState', { id, state: oldState })
+ })
+ },
+ addReport ({ commit }, report) {
+ commit('addReport', report)
}
}
}
diff --git a/src/modules/serverSideConfig.js b/src/modules/serverSideConfig.js
new file mode 100644
index 00000000..476263bc
--- /dev/null
+++ b/src/modules/serverSideConfig.js
@@ -0,0 +1,140 @@
+import { get, set } from 'lodash'
+
+const defaultApi = ({ rootState, commit }, { path, value }) => {
+ const params = {}
+ set(params, path, value)
+ return rootState
+ .api
+ .backendInteractor
+ .updateProfile({ params })
+ .then(result => {
+ commit('addNewUsers', [result])
+ commit('setCurrentUser', result)
+ })
+}
+
+const notificationsApi = ({ rootState, commit }, { path, value, oldValue }) => {
+ const settings = {}
+ set(settings, path, value)
+ return rootState
+ .api
+ .backendInteractor
+ .updateNotificationSettings({ settings })
+ .then(result => {
+ if (result.status === 'success') {
+ commit('confirmServerSideOption', { name, value })
+ } else {
+ commit('confirmServerSideOption', { name, value: oldValue })
+ }
+ })
+}
+
+/**
+ * Map that stores relation between path for reading (from user profile),
+ * for writing (into API) an what API to use.
+ *
+ * Shorthand - instead of { get, set, api? } object it's possible to use string
+ * in case default api is used and get = set
+ *
+ * If no api is specified, defaultApi is used (see above)
+ */
+export const settingsMap = {
+ defaultScope: 'source.privacy',
+ defaultNSFW: 'source.sensitive', // BROKEN: pleroma/pleroma#2837
+ stripRichContent: {
+ get: 'source.pleroma.no_rich_text',
+ set: 'no_rich_text'
+ },
+ // Privacy
+ locked: 'locked',
+ acceptChatMessages: {
+ get: 'pleroma.accepts_chat_messages',
+ set: 'accepts_chat_messages'
+ },
+ allowFollowingMove: {
+ get: 'pleroma.allow_following_move',
+ set: 'allow_following_move'
+ },
+ discoverable: {
+ get: 'source.pleroma.discoverable',
+ set: 'discoverable'
+ },
+ hideFavorites: {
+ get: 'pleroma.hide_favorites',
+ set: 'hide_favorites'
+ },
+ hideFollowers: {
+ get: 'pleroma.hide_followers',
+ set: 'hide_followers'
+ },
+ hideFollows: {
+ get: 'pleroma.hide_follows',
+ set: 'hide_follows'
+ },
+ hideFollowersCount: {
+ get: 'pleroma.hide_followers_count',
+ set: 'hide_followers_count'
+ },
+ hideFollowsCount: {
+ get: 'pleroma.hide_follows_count',
+ set: 'hide_follows_count'
+ },
+ // NotificationSettingsAPIs
+ webPushHideContents: {
+ get: 'pleroma.notification_settings.hide_notification_contents',
+ set: 'hide_notification_contents',
+ api: notificationsApi
+ },
+ blockNotificationsFromStrangers: {
+ get: 'pleroma.notification_settings.block_from_strangers',
+ set: 'block_from_strangers',
+ api: notificationsApi
+ }
+}
+
+export const defaultState = Object.fromEntries(Object.keys(settingsMap).map(key => [key, null]))
+
+const serverSideConfig = {
+ state: { ...defaultState },
+ mutations: {
+ confirmServerSideOption (state, { name, value }) {
+ set(state, name, value)
+ },
+ wipeServerSideOption (state, { name }) {
+ set(state, name, null)
+ },
+ wipeAllServerSideOptions (state) {
+ Object.keys(settingsMap).forEach(key => {
+ set(state, key, null)
+ })
+ },
+ // Set the settings based on their path location
+ setCurrentUser (state, user) {
+ Object.entries(settingsMap).forEach((map) => {
+ const [name, value] = map
+ const { get: path = value } = value
+ set(state, name, get(user._original, path))
+ })
+ }
+ },
+ actions: {
+ setServerSideOption ({ rootState, state, commit, dispatch }, { name, value }) {
+ const oldValue = get(state, name)
+ const map = settingsMap[name]
+ if (!map) throw new Error('Invalid server-side setting')
+ const { set: path = map, api = defaultApi } = map
+ commit('wipeServerSideOption', { name })
+
+ api({ rootState, commit }, { path, value, oldValue })
+ .catch((e) => {
+ console.warn('Error setting server-side option:', e)
+ commit('confirmServerSideOption', { name, value: oldValue })
+ })
+ },
+ logout ({ commit }) {
+ commit('wipeAllServerSideOptions')
+ }
+ }
+}
+
+export default serverSideConfig
diff --git a/src/modules/serverSideStorage.js b/src/modules/serverSideStorage.js
new file mode 100644
index 00000000..c933ce8d
--- /dev/null
+++ b/src/modules/serverSideStorage.js
@@ -0,0 +1,436 @@
+import { toRaw } from 'vue'
+import { isEqual, cloneDeep, set, get, clamp, flatten, groupBy, findLastIndex, takeRight, uniqWith } from 'lodash'
+import { CURRENT_UPDATE_COUNTER } from 'src/components/update_notification/update_notification.js'
+
+export const VERSION = 1
+export const NEW_USER_DATE = new Date('2022-08-04') // date of writing this, basically
+
+export const COMMAND_TRIM_FLAGS = 1000
+export const COMMAND_TRIM_FLAGS_AND_RESET = 1001
+
+export const defaultState = {
+ // do we need to update data on server?
+ dirty: false,
+ // storage of flags - stuff that can only be set and incremented
+ flagStorage: {
+ updateCounter: 0, // Counter for most recent update notification seen
+ reset: 0 // special flag that can be used to force-reset all flags, debug purposes only
+ // special reset codes:
+ // 1000: trim keys to those known by currently running FE
+ // 1001: same as above + reset everything to 0
+ },
+ prefsStorage: {
+ _journal: [],
+ simple: {
+ dontShowUpdateNotifs: false,
+ collapseNav: false
+ },
+ collections: {
+ pinnedNavItems: ['home', 'dms', 'chats']
+ }
+ },
+ // raw data
+ raw: null,
+ // local cache
+ cache: null
+}
+
+export const newUserFlags = {
+ ...defaultState.flagStorage,
+ updateCounter: CURRENT_UPDATE_COUNTER // new users don't need to see update notification
+}
+
+export const _moveItemInArray = (array, value, movement) => {
+ const oldIndex = array.indexOf(value)
+ const newIndex = oldIndex + movement
+ const newArray = [...array]
+ // remove old
+ newArray.splice(oldIndex, 1)
+ // add new
+ newArray.splice(clamp(newIndex, 0, newArray.length + 1), 0, value)
+ return newArray
+}
+
+const _wrapData = (data, userName) => ({
+ ...data,
+ _user: userName,
+ _timestamp: Date.now(),
+ _version: VERSION
+})
+
+const _checkValidity = (data) => data._timestamp > 0 && data._version > 0
+
+const _verifyPrefs = (state) => {
+ state.prefsStorage = state.prefsStorage || {
+ simple: {},
+ collections: {}
+ }
+ Object.entries(defaultState.prefsStorage.simple).forEach(([k, v]) => {
+ if (typeof v === 'number' || typeof v === 'boolean') return
+ console.warn(`Preference simple.${k} as invalid type, reinitializing`)
+ set(state.prefsStorage.simple, k, defaultState.prefsStorage.simple[k])
+ })
+ Object.entries(defaultState.prefsStorage.collections).forEach(([k, v]) => {
+ if (Array.isArray(v)) return
+ console.warn(`Preference collections.${k} as invalid type, reinitializing`)
+ set(state.prefsStorage.collections, k, defaultState.prefsStorage.collections[k])
+ })
+}
+
+export const _getRecentData = (cache, live) => {
+ const result = { recent: null, stale: null, needUpload: false }
+ const cacheValid = _checkValidity(cache || {})
+ const liveValid = _checkValidity(live || {})
+ if (!liveValid && cacheValid) {
+ result.needUpload = true
+ console.debug('Nothing valid stored on server, assuming cache to be source of truth')
+ result.recent = cache
+ result.stale = live
+ } else if (!cacheValid && liveValid) {
+ console.debug('Valid storage on server found, no local cache found, using live as source of truth')
+ result.recent = live
+ result.stale = cache
+ } else if (cacheValid && liveValid) {
+ console.debug('Both sources have valid data, figuring things out...')
+ if (live._timestamp === cache._timestamp && live._version === cache._version) {
+ console.debug('Same version/timestamp on both source, source of truth irrelevant')
+ result.recent = cache
+ result.stale = live
+ } else {
+ console.debug('Different timestamp, figuring out which one is more recent')
+ if (live._timestamp < cache._timestamp) {
+ result.recent = cache
+ result.stale = live
+ } else {
+ result.recent = live
+ result.stale = cache
+ }
+ }
+ } else {
+ console.debug('Both sources are invalid, start from scratch')
+ result.needUpload = true
+ }
+ return result
+}
+
+export const _getAllFlags = (recent, stale) => {
+ return Array.from(new Set([
+ ...Object.keys(toRaw((recent || {}).flagStorage || {})),
+ ...Object.keys(toRaw((stale || {}).flagStorage || {}))
+ ]))
+}
+
+export const _mergeFlags = (recent, stale, allFlagKeys) => {
+ if (!stale.flagStorage) return recent.flagStorage
+ if (!recent.flagStorage) return stale.flagStorage
+ return Object.fromEntries(allFlagKeys.map(flag => {
+ const recentFlag = recent.flagStorage[flag]
+ const staleFlag = stale.flagStorage[flag]
+ // use flag that is of higher value
+ return [flag, Number((recentFlag > staleFlag ? recentFlag : staleFlag) || 0)]
+ }))
+}
+
+const _mergeJournal = (...journals) => {
+ // Ignore invalid journal entries
+ const allJournals = flatten(
+ journals.map(j => Array.isArray(j) ? j : [])
+ ).filter(entry =>
+ Object.prototype.hasOwnProperty.call(entry, 'path') &&
+ Object.prototype.hasOwnProperty.call(entry, 'operation') &&
+ Object.prototype.hasOwnProperty.call(entry, 'args') &&
+ Object.prototype.hasOwnProperty.call(entry, 'timestamp')
+ )
+ const grouped = groupBy(allJournals, 'path')
+ const trimmedGrouped = Object.entries(grouped).map(([path, journal]) => {
+ // side effect
+ journal.sort((a, b) => a.timestamp > b.timestamp ? 1 : -1)
+
+ if (path.startsWith('collections')) {
+ const lastRemoveIndex = findLastIndex(journal, ({ operation }) => operation === 'removeFromCollection')
+ // everything before last remove is unimportant
+ let remainder
+ if (lastRemoveIndex > 0) {
+ remainder = journal.slice(lastRemoveIndex)
+ } else {
+ // everything else doesn't need trimming
+ remainder = journal
+ }
+ return uniqWith(remainder, (a, b) => {
+ if (a.path !== b.path) { return false }
+ if (a.operation !== b.operation) { return false }
+ if (a.operation === 'addToCollection') {
+ return a.args[0] === b.args[0]
+ }
+ return false
+ })
+ } else if (path.startsWith('simple')) {
+ // Only the last record is important
+ return takeRight(journal)
+ } else {
+ return journal
+ }
+ })
+ return flatten(trimmedGrouped)
+ .sort((a, b) => a.timestamp > b.timestamp ? 1 : -1)
+}
+
+export const _mergePrefs = (recent, stale, allFlagKeys) => {
+ if (!stale) return recent
+ if (!recent) return stale
+ const { _journal: recentJournal, ...recentData } = recent
+ const { _journal: staleJournal } = stale
+ /** Journal entry format:
+ * path: path to entry in prefsStorage
+ * timestamp: timestamp of the change
+ * operation: operation type
+ * arguments: array of arguments, depends on operation type
+ *
+ * currently only supported operation type is "set" which just sets the value
+ * to requested one. Intended only to be used with simple preferences (boolean, number)
+ * shouldn't be used with collections!
+ */
+ const resultOutput = { ...recentData }
+ const totalJournal = _mergeJournal(staleJournal, recentJournal)
+ totalJournal.forEach(({ path, timestamp, operation, command, args }) => {
+ if (path.startsWith('_')) {
+ console.error(`journal contains entry to edit internal (starts with _) field '${path}', something is incorrect here, ignoring.`)
+ return
+ }
+ switch (operation) {
+ case 'set':
+ set(resultOutput, path, args[0])
+ break
+ case 'addToCollection':
+ set(resultOutput, path, Array.from(new Set(get(resultOutput, path)).add(args[0])))
+ break
+ case 'removeFromCollection': {
+ const newSet = new Set(get(resultOutput, path))
+ newSet.delete(args[0])
+ set(resultOutput, path, Array.from(newSet))
+ break
+ }
+ case 'reorderCollection': {
+ const [value, movement] = args
+ set(resultOutput, path, _moveItemInArray(get(resultOutput, path), value, movement))
+ break
+ }
+ default:
+ console.error(`Unknown journal operation: '${operation}', did we forget to run reverse migrations beforehand?`)
+ }
+ })
+ return { ...resultOutput, _journal: totalJournal }
+}
+
+export const _resetFlags = (totalFlags, knownKeys = defaultState.flagStorage) => {
+ let result = { ...totalFlags }
+ const allFlagKeys = Object.keys(totalFlags)
+ // flag reset functionality
+ if (totalFlags.reset >= COMMAND_TRIM_FLAGS && totalFlags.reset <= COMMAND_TRIM_FLAGS_AND_RESET) {
+ console.debug('Received command to trim the flags')
+ const knownKeysSet = new Set(Object.keys(knownKeys))
+
+ // Trim
+ result = {}
+ allFlagKeys.forEach(flag => {
+ if (knownKeysSet.has(flag)) {
+ result[flag] = totalFlags[flag]
+ }
+ })
+
+ // Reset
+ if (totalFlags.reset === COMMAND_TRIM_FLAGS_AND_RESET) {
+ // 1001 - and reset everything to 0
+ console.debug('Received command to reset the flags')
+ Object.keys(knownKeys).forEach(flag => { result[flag] = 0 })
+ }
+ } else if (totalFlags.reset > 0 && totalFlags.reset < 9000) {
+ console.debug('Received command to reset the flags')
+ allFlagKeys.forEach(flag => { result[flag] = 0 })
+ }
+ result.reset = 0
+ return result
+}
+
+export const _doMigrations = (cache) => {
+ if (!cache) return cache
+
+ if (cache._version < VERSION) {
+ console.debug('Local cached data has older version, seeing if there any migrations that can be applied')
+
+ // no migrations right now since we only have one version
+ console.debug('No migrations found')
+ }
+
+ if (cache._version > VERSION) {
+ console.debug('Local cached data has newer version, seeing if there any reverse migrations that can be applied')
+
+ // no reverse migrations right now but we leave a possibility of loading a hotpatch if need be
+ if (window._PLEROMA_HOTPATCH) {
+ if (window._PLEROMA_HOTPATCH.reverseMigrations) {
+ console.debug('Found hotpatch migration, applying')
+ return window._PLEROMA_HOTPATCH.reverseMigrations.call({}, 'serverSideStorage', { from: cache._version, to: VERSION }, cache)
+ }
+ }
+ }
+
+ return cache
+}
+
+export const mutations = {
+ clearServerSideStorage (state, userData) {
+ state = { ...cloneDeep(defaultState) }
+ },
+ setServerSideStorage (state, userData) {
+ const live = userData.storage
+ state.raw = live
+ let cache = state.cache
+ if (cache && cache._user !== userData.fqn) {
+ console.warn('cache belongs to another user! reinitializing local cache!')
+ cache = null
+ }
+
+ cache = _doMigrations(cache)
+
+ let { recent, stale, needsUpload } = _getRecentData(cache, live)
+
+ const userNew = userData.created_at > NEW_USER_DATE
+ const flagsTemplate = userNew ? newUserFlags : defaultState.flagStorage
+ let dirty = false
+
+ if (recent === null) {
+ console.debug(`Data is empty, initializing for ${userNew ? 'new' : 'existing'} user`)
+ recent = _wrapData({
+ flagStorage: { ...flagsTemplate },
+ prefsStorage: { ...defaultState.prefsStorage }
+ })
+ }
+
+ if (!needsUpload && recent && stale) {
+ console.debug('Checking if data needs merging...')
+ // discarding timestamps and versions
+ const { _timestamp: _0, _version: _1, ...recentData } = recent
+ const { _timestamp: _2, _version: _3, ...staleData } = stale
+ dirty = !isEqual(recentData, staleData)
+ console.debug(`Data ${dirty ? 'needs' : 'doesn\'t need'} merging`)
+ }
+
+ const allFlagKeys = _getAllFlags(recent, stale)
+ let totalFlags
+ let totalPrefs
+ if (dirty) {
+ // Merge the flags
+ console.debug('Merging the data...')
+ totalFlags = _mergeFlags(recent, stale, allFlagKeys)
+ _verifyPrefs(recent)
+ _verifyPrefs(stale)
+ totalPrefs = _mergePrefs(recent.prefsStorage, stale.prefsStorage)
+ } else {
+ totalFlags = recent.flagStorage
+ totalPrefs = recent.prefsStorage
+ }
+
+ totalFlags = _resetFlags(totalFlags)
+
+ recent.flagStorage = { ...flagsTemplate, ...totalFlags }
+ recent.prefsStorage = { ...defaultState.prefsStorage, ...totalPrefs }
+
+ state.dirty = dirty || needsUpload
+ state.cache = recent
+ // set local timestamp to smaller one if we don't have any changes
+ if (stale && recent && !state.dirty) {
+ state.cache._timestamp = Math.min(stale._timestamp, recent._timestamp)
+ }
+ state.flagStorage = state.cache.flagStorage
+ state.prefsStorage = state.cache.prefsStorage
+ },
+ setFlag (state, { flag, value }) {
+ state.flagStorage[flag] = value
+ state.dirty = true
+ },
+ setPreference (state, { path, value }) {
+ if (path.startsWith('_')) {
+ console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`)
+ return
+ }
+ set(state.prefsStorage, path, value)
+ state.prefsStorage._journal = [
+ ...state.prefsStorage._journal,
+ { operation: 'set', path, args: [value], timestamp: Date.now() }
+ ]
+ state.dirty = true
+ },
+ addCollectionPreference (state, { path, value }) {
+ if (path.startsWith('_')) {
+ console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`)
+ return
+ }
+ const collection = new Set(get(state.prefsStorage, path))
+ collection.add(value)
+ set(state.prefsStorage, path, [...collection])
+ state.prefsStorage._journal = [
+ ...state.prefsStorage._journal,
+ { operation: 'addToCollection', path, args: [value], timestamp: Date.now() }
+ ]
+ state.dirty = true
+ },
+ removeCollectionPreference (state, { path, value }) {
+ if (path.startsWith('_')) {
+ console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`)
+ return
+ }
+ const collection = new Set(get(state.prefsStorage, path))
+ collection.delete(value)
+ set(state.prefsStorage, path, [...collection])
+ state.prefsStorage._journal = [
+ ...state.prefsStorage._journal,
+ { operation: 'removeFromCollection', path, args: [value], timestamp: Date.now() }
+ ]
+ state.dirty = true
+ },
+ reorderCollectionPreference (state, { path, value, movement }) {
+ if (path.startsWith('_')) {
+ console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`)
+ return
+ }
+ const collection = get(state.prefsStorage, path)
+ const newCollection = _moveItemInArray(collection, value, movement)
+ set(state.prefsStorage, path, newCollection)
+ state.prefsStorage._journal = [
+ ...state.prefsStorage._journal,
+ { operation: 'arrangeCollection', path, args: [value], timestamp: Date.now() }
+ ]
+ state.dirty = true
+ },
+ updateCache (state, { username }) {
+ state.prefsStorage._journal = _mergeJournal(state.prefsStorage._journal)
+ state.cache = _wrapData({
+ flagStorage: toRaw(state.flagStorage),
+ prefsStorage: toRaw(state.prefsStorage)
+ }, username)
+ }
+}
+
+const serverSideStorage = {
+ state: {
+ ...cloneDeep(defaultState)
+ },
+ mutations,
+ actions: {
+ pushServerSideStorage ({ state, rootState, commit }, { force = false } = {}) {
+ const needPush = state.dirty || force
+ console.log(needPush)
+ if (!needPush) return
+ commit('updateCache', { username: rootState.users.currentUser.fqn })
+ const params = { pleroma_settings_store: { 'pleroma-fe': state.cache } }
+ rootState.api.backendInteractor
+ .updateProfile({ params })
+ .then((user) => {
+ commit('setServerSideStorage', user)
+ state.dirty = false
+ })
+ }
+ }
+}
+
+export default serverSideStorage
diff --git a/src/modules/shout.js b/src/modules/shout.js
index 507a4d83..88aefbfe 100644
--- a/src/modules/shout.js
+++ b/src/modules/shout.js
@@ -1,7 +1,8 @@
const shout = {
state: {
messages: [],
- channel: { state: '' }
+ channel: { state: '' },
+ joined: false
},
mutations: {
setChannel (state, channel) {
@@ -13,11 +14,23 @@ const shout = {
},
setMessages (state, messages) {
state.messages = messages.slice(-19, 20)
+ },
+ setJoined (state, joined) {
+ state.joined = joined
}
},
actions: {
initializeShout (store, socket) {
const channel = socket.channel('chat:public')
+ channel.joinPush.receive('ok', () => {
+ store.commit('setJoined', true)
+ })
+ channel.onClose(() => {
+ store.commit('setJoined', false)
+ })
+ channel.onError(() => {
+ store.commit('setJoined', false)
+ })
channel.on('new_msg', (msg) => {
store.commit('addMessage', msg)
})
diff --git a/src/modules/statusHistory.js b/src/modules/statusHistory.js
new file mode 100644
index 00000000..db3d6d4b
--- /dev/null
+++ b/src/modules/statusHistory.js
@@ -0,0 +1,25 @@
+const statusHistory = {
+ state: {
+ params: {},
+ modalActivated: false
+ },
+ mutations: {
+ openStatusHistoryModal (state, params) {
+ state.params = params
+ state.modalActivated = true
+ },
+ closeStatusHistoryModal (state) {
+ state.modalActivated = false
+ }
+ },
+ actions: {
+ openStatusHistoryModal ({ commit }, params) {
+ commit('openStatusHistoryModal', params)
+ },
+ closeStatusHistoryModal ({ commit }) {
+ commit('closeStatusHistoryModal')
+ }
+ }
+}
+
+export default statusHistory
diff --git a/src/modules/statuses.js b/src/modules/statuses.js
index ac5d25c4..5a5c7b1b 100644
--- a/src/modules/statuses.js
+++ b/src/modules/statuses.js
@@ -12,7 +12,6 @@ import {
isArray,
omitBy
} from 'lodash'
-import { set } from 'vue'
import {
isStatusNotification,
isValidNotification,
@@ -63,7 +62,8 @@ export const defaultState = () => ({
friends: emptyTl(),
tag: emptyTl(),
dms: emptyTl(),
- bookmarks: emptyTl()
+ bookmarks: emptyTl(),
+ list: emptyTl()
}
})
@@ -92,7 +92,7 @@ const mergeOrAdd = (arr, obj, item) => {
// This is a new item, prepare it
prepareStatus(item)
arr.push(item)
- set(obj, item.id, item)
+ obj[item.id] = item
return { item, new: true }
}
}
@@ -131,7 +131,7 @@ const addStatusToGlobalStorage = (state, data) => {
if (conversationsObject[conversationId]) {
conversationsObject[conversationId].push(status)
} else {
- set(conversationsObject, conversationId, [status])
+ conversationsObject[conversationId] = [status]
}
}
return result
@@ -246,10 +246,13 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
}
const processors = {
- 'status': (status) => {
+ status: (status) => {
addStatus(status, showImmediately)
},
- 'retweet': (status) => {
+ edit: (status) => {
+ addStatus(status, showImmediately)
+ },
+ retweet: (status) => {
// RetweetedStatuses are never shown immediately
const retweetedStatus = addStatus(status.retweeted_status, false, false)
@@ -271,7 +274,7 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
retweet.retweeted_status = retweetedStatus
},
- 'favorite': (favorite) => {
+ favorite: (favorite) => {
// Only update if this is a new favorite.
// Ignore our own favorites because we get info about likes as response to like request
if (!state.favorites.has(favorite.id)) {
@@ -279,7 +282,7 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
favoriteStatus(favorite)
}
},
- 'deletion': (deletion) => {
+ deletion: (deletion) => {
const uri = deletion.uri
const status = find(allStatuses, { uri })
if (!status) {
@@ -293,10 +296,10 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
remove(timelineObject.visibleStatuses, { uri })
}
},
- 'follow': (follow) => {
+ follow: (follow) => {
// NOOP, it is known status but we don't do anything about it for now
},
- 'default': (unknown) => {
+ default: (unknown) => {
console.log('unknown status type')
console.log(unknown)
}
@@ -304,7 +307,7 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
each(statuses, (status) => {
const type = status.type
- const processor = processors[type] || processors['default']
+ const processor = processors[type] || processors.default
processor(status)
})
@@ -337,11 +340,16 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
notification.status = notification.status && addStatusToGlobalStorage(state, notification.status).item
}
+ if (notification.type === 'pleroma:report') {
+ dispatch('addReport', notification.report)
+ }
+
if (notification.type === 'pleroma:emoji_reaction') {
dispatch('fetchEmojiReactionsBy', notification.status.id)
}
// Only add a new notification if we don't have one for the same action
+ // eslint-disable-next-line no-prototype-builtins
if (!state.notifications.idStore.hasOwnProperty(notification.id)) {
updateNotificationsMinMaxId(state, notification)
@@ -523,7 +531,7 @@ export const mutations = {
},
addEmojiReactionsBy (state, { id, emojiReactions, currentUser }) {
const status = state.allStatusesObject[id]
- set(status, 'emoji_reactions', emojiReactions)
+ status.emoji_reactions = emojiReactions
},
addOwnReaction (state, { id, emoji, currentUser }) {
const status = state.allStatusesObject[id]
@@ -542,9 +550,9 @@ export const mutations = {
// Update count of existing reaction if it exists, otherwise append at the end
if (reactionIndex >= 0) {
- set(status.emoji_reactions, reactionIndex, newReaction)
+ status.emoji_reactions[reactionIndex] = newReaction
} else {
- set(status, 'emoji_reactions', [...status.emoji_reactions, newReaction])
+ status.emoji_reactions = [...status.emoji_reactions, newReaction]
}
},
removeOwnReaction (state, { id, emoji, currentUser }) {
@@ -563,9 +571,9 @@ export const mutations = {
}
if (newReaction.count > 0) {
- set(status.emoji_reactions, reactionIndex, newReaction)
+ status.emoji_reactions[reactionIndex] = newReaction
} else {
- set(status, 'emoji_reactions', status.emoji_reactions.filter(r => r.name !== emoji))
+ status.emoji_reactions = status.emoji_reactions.filter(r => r.name !== emoji)
}
},
updateStatusWithPoll (state, { id, poll }) {
@@ -601,6 +609,12 @@ const statuses = {
return rootState.api.backendInteractor.fetchStatus({ id })
.then((status) => dispatch('addNewStatuses', { statuses: [status] }))
},
+ fetchStatusSource ({ rootState, dispatch }, status) {
+ return apiService.fetchStatusSource({ id: status.id, credentials: rootState.users.currentUser.credentials })
+ },
+ fetchStatusHistory ({ rootState, dispatch }, status) {
+ return apiService.fetchStatusHistory({ status })
+ },
deleteStatus ({ rootState, commit }, status) {
commit('setDeleted', { status })
apiService.deleteStatus({ id: status.id, credentials: rootState.users.currentUser.credentials })
@@ -747,8 +761,8 @@ const statuses = {
rootState.api.backendInteractor.fetchRebloggedByUsers({ id })
.then(rebloggedByUsers => commit('addRepeats', { id, rebloggedByUsers, currentUser: rootState.users.currentUser }))
},
- search (store, { q, resolve, limit, offset, following }) {
- return store.rootState.api.backendInteractor.search2({ q, resolve, limit, offset, following })
+ search (store, { q, resolve, limit, offset, following, type }) {
+ return store.rootState.api.backendInteractor.search2({ q, resolve, limit, offset, following, type })
.then((data) => {
store.commit('addNewUsers', data.accounts)
store.commit('addNewStatuses', { statuses: data.statuses })
diff --git a/src/modules/users.js b/src/modules/users.js
index fb92cc91..053e44b6 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -1,7 +1,7 @@
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
+import { windowWidth, windowHeight } from '../services/window_utils/window_utils'
import oauthApi from '../services/new_api/oauth.js'
import { compact, map, each, mergeWith, last, concat, uniq, isArray } from 'lodash'
-import { set } from 'vue'
import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js'
// TODO: Unify with mergeOrAdd in statuses.js
@@ -15,10 +15,7 @@ export const mergeOrAdd = (arr, obj, item) => {
} else {
// This is a new item, prepare it
arr.push(item)
- set(obj, item.id, item)
- if (item.screen_name && !item.screen_name.includes('@')) {
- set(obj, item.screen_name.toLowerCase(), item)
- }
+ obj[item.id] = item
return { item, new: true }
}
}
@@ -54,6 +51,16 @@ const unblockUser = (store, id) => {
.then((relationship) => store.commit('updateUserRelationship', [relationship]))
}
+const removeUserFromFollowers = (store, id) => {
+ return store.rootState.api.backendInteractor.removeUserFromFollowers({ id })
+ .then((relationship) => store.commit('updateUserRelationship', [relationship]))
+}
+
+const editUserNote = (store, { id, comment }) => {
+ return store.rootState.api.backendInteractor.editUserNote({ id, comment })
+ .then((relationship) => store.commit('updateUserRelationship', [relationship]))
+}
+
const muteUser = (store, id) => {
const predictedRelationship = store.state.relationships[id] || { id }
predictedRelationship.muting = true
@@ -103,23 +110,23 @@ export const mutations = {
const user = state.usersObject[id]
const tags = user.tags || []
const newTags = tags.concat([tag])
- set(user, 'tags', newTags)
+ user.tags = newTags
},
untagUser (state, { user: { id }, tag }) {
const user = state.usersObject[id]
const tags = user.tags || []
const newTags = tags.filter(t => t !== tag)
- set(user, 'tags', newTags)
+ user.tags = newTags
},
updateRight (state, { user: { id }, right, value }) {
const user = state.usersObject[id]
- let newRights = user.rights
+ const newRights = user.rights
newRights[right] = value
- set(user, 'rights', newRights)
+ user.rights = newRights
},
updateActivationStatus (state, { user: { id }, deactivated }) {
const user = state.usersObject[id]
- set(user, 'deactivated', deactivated)
+ user.deactivated = deactivated
},
setCurrentUser (state, user) {
state.lastLoginName = user.screen_name
@@ -148,28 +155,35 @@ export const mutations = {
clearFriends (state, userId) {
const user = state.usersObject[userId]
if (user) {
- set(user, 'friendIds', [])
+ user.friendIds = []
}
},
clearFollowers (state, userId) {
const user = state.usersObject[userId]
if (user) {
- set(user, 'followerIds', [])
+ user.followerIds = []
}
},
addNewUsers (state, users) {
each(users, (user) => {
if (user.relationship) {
- set(state.relationships, user.relationship.id, user.relationship)
+ state.relationships[user.relationship.id] = user.relationship
+ }
+ const res = mergeOrAdd(state.users, state.usersObject, user)
+ const item = res.item
+ if (res.new && item.screen_name && !item.screen_name.includes('@')) {
+ state.usersByNameObject[item.screen_name.toLowerCase()] = item
}
- mergeOrAdd(state.users, state.usersObject, user)
})
},
updateUserRelationship (state, relationships) {
relationships.forEach((relationship) => {
- set(state.relationships, relationship.id, relationship)
+ state.relationships[relationship.id] = relationship
})
},
+ updateUserInLists (state, { id, inLists }) {
+ state.usersObject[id].inLists = inLists
+ },
saveBlockIds (state, blockIds) {
state.currentUser.blockIds = blockIds
},
@@ -222,7 +236,7 @@ export const mutations = {
},
setColor (state, { user: { id }, highlighted }) {
const user = state.usersObject[id]
- set(user, 'highlight', highlighted)
+ user.highlight = highlighted
},
signUpPending (state) {
state.signUpPending = true
@@ -239,12 +253,10 @@ export const mutations = {
export const getters = {
findUser: state => query => {
- const result = state.usersObject[query]
- // In case it's a screen_name, we can try searching case-insensitive
- if (!result && typeof query === 'string') {
- return state.usersObject[query.toLowerCase()]
- }
- return result
+ return state.usersObject[query]
+ },
+ findUserByName: state => query => {
+ return state.usersByNameObject[query.toLowerCase()]
},
findUserByUrl: state => query => {
return state.users
@@ -263,6 +275,7 @@ export const defaultState = {
currentUser: false,
users: [],
usersObject: {},
+ usersByNameObject: {},
signUpPending: false,
signUpErrors: [],
relationships: {}
@@ -285,12 +298,25 @@ const users = {
return user
})
},
+ fetchUserByName (store, name) {
+ return store.rootState.api.backendInteractor.fetchUserByName({ name })
+ .then((user) => {
+ store.commit('addNewUsers', [user])
+ return user
+ })
+ },
fetchUserRelationship (store, id) {
if (store.state.currentUser) {
store.rootState.api.backendInteractor.fetchUserRelationship({ id })
.then((relationships) => store.commit('updateUserRelationship', relationships))
}
},
+ fetchUserInLists (store, id) {
+ if (store.state.currentUser) {
+ store.rootState.api.backendInteractor.fetchUserInLists({ id })
+ .then((inLists) => store.commit('updateUserInLists', { id, inLists }))
+ }
+ },
fetchBlocks (store) {
return store.rootState.api.backendInteractor.fetchBlocks()
.then((blocks) => {
@@ -305,12 +331,18 @@ const users = {
unblockUser (store, id) {
return unblockUser(store, id)
},
+ removeUserFromFollowers (store, id) {
+ return removeUserFromFollowers(store, id)
+ },
blockUsers (store, ids = []) {
return Promise.all(ids.map(id => blockUser(store, id)))
},
unblockUsers (store, ids = []) {
return Promise.all(ids.map(id => unblockUser(store, id)))
},
+ editUserNote (store, args) {
+ return editUserNote(store, args)
+ },
fetchMutes (store) {
return store.rootState.api.backendInteractor.fetchMutes()
.then((mutes) => {
@@ -393,7 +425,7 @@ const users = {
toggleActivationStatus ({ rootState, commit }, { user }) {
const api = user.deactivated ? rootState.api.backendInteractor.activateUser : rootState.api.backendInteractor.deactivateUser
api({ user })
- .then(({ deactivated }) => commit('updateActivationStatus', { user, deactivated }))
+ .then((user) => { const deactivated = !user.is_active; commit('updateActivationStatus', { user, deactivated }) })
},
registerPushNotifications (store) {
const token = store.state.currentUser.credentials
@@ -457,17 +489,17 @@ const users = {
async signUp (store, userInfo) {
store.commit('signUpPending')
- let rootState = store.rootState
+ const rootState = store.rootState
try {
- let data = await rootState.api.backendInteractor.register(
+ const data = await rootState.api.backendInteractor.register(
{ params: { ...userInfo } }
)
store.commit('signUpSuccess')
store.commit('setToken', data.access_token)
store.dispatch('loginUser', data.access_token)
} catch (e) {
- let errors = e.message
+ const errors = e.message
store.commit('signUpFailure', errors)
throw e
}
@@ -502,11 +534,15 @@ const users = {
store.dispatch('stopFetchingTimeline', 'friends')
store.commit('setBackendInteractor', backendInteractorService(store.getters.getToken()))
store.dispatch('stopFetchingNotifications')
+ store.dispatch('stopFetchingLists')
store.dispatch('stopFetchingFollowRequests')
store.commit('clearNotifications')
store.commit('resetStatuses')
store.dispatch('resetChats')
store.dispatch('setLastTimeline', 'public-timeline')
+ store.dispatch('setLayoutWidth', windowWidth())
+ store.dispatch('setLayoutHeight', windowHeight())
+ store.commit('clearServerSideStorage')
})
},
loginUser (store, accessToken) {
@@ -523,6 +559,7 @@ const users = {
user.muteIds = []
user.domainMutes = []
commit('setCurrentUser', user)
+ commit('setServerSideStorage', user)
commit('addNewUsers', [user])
store.dispatch('fetchEmoji')
@@ -532,6 +569,7 @@ const users = {
// Set our new backend interactor
commit('setBackendInteractor', backendInteractorService(accessToken))
+ store.dispatch('pushServerSideStorage')
if (user.token) {
store.dispatch('setWsToken', user.token)
@@ -551,8 +589,14 @@ const users = {
store.dispatch('startFetchingChats')
}
+ store.dispatch('startFetchingLists')
+
+ if (user.locked) {
+ store.dispatch('startFetchingFollowRequests')
+ }
+
if (store.getters.mergedConfig.useStreamingApi) {
- store.dispatch('fetchTimeline', 'friends', { since: null })
+ store.dispatch('fetchTimeline', { timeline: 'friends', since: null })
store.dispatch('fetchNotifications', { since: null })
store.dispatch('enableMastoSockets', true).catch((error) => {
console.error('Failed initializing MastoAPI Streaming socket', error)
@@ -567,6 +611,9 @@ const users = {
// Get user mutes
store.dispatch('fetchMutes')
+ store.dispatch('setLayoutWidth', windowWidth())
+ store.dispatch('setLayoutHeight', windowHeight())
+
// Fetch our friends
store.rootState.api.backendInteractor.fetchFriends({ id: user.id })
.then((friends) => commit('addNewUsers', friends))
diff --git a/src/panel.scss b/src/panel.scss
new file mode 100644
index 00000000..a53e47c6
--- /dev/null
+++ b/src/panel.scss
@@ -0,0 +1,240 @@
+.panel {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ background-color: $fallback--bg;
+ background-color: var(--bg, $fallback--bg);
+
+ &::after,
+ & {
+ border-radius: $fallback--panelRadius;
+ border-radius: var(--panelRadius, $fallback--panelRadius);
+ }
+
+ &::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: 5;
+ box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6);
+ box-shadow: var(--panelShadow);
+ pointer-events: none;
+ }
+}
+
+.panel-body {
+ padding: var(--panel-body-padding, 0);
+
+ &:empty::before {
+ content: "¯\\_(ツ)_/¯"; // Could use words but it'd require translations
+ display: block;
+ margin: 1em;
+ text-align: center;
+ }
+
+ > p {
+ line-height: 1.3;
+ padding: 1em;
+ margin: 0;
+ }
+}
+
+.panel-heading,
+.panel-footer {
+ --panel-heading-height-padding: 0.6em;
+ --__panel-heading-gap: 0.5em;
+ --__panel-heading-height: 3.2em;
+ --__panel-heading-height-inner: calc(var(--__panel-heading-height) - 2 * var(--panel-heading-height-padding, 0));
+
+ position: relative;
+ box-sizing: border-box;
+ display: grid;
+ grid-auto-flow: column;
+ grid-template-columns: minmax(50%, 1fr);
+ grid-auto-columns: auto;
+ grid-column-gap: var(--__panel-heading-gap);
+ flex: none;
+ background-size: cover;
+ padding: var(--panel-heading-height-padding);
+ height: var(--__panel-heading-height);
+ line-height: var(--__panel-heading-height-inner);
+ z-index: 4;
+
+ &.-flexible-height {
+ --__panel-heading-height: auto;
+
+ &::after,
+ &::before {
+ display: none;
+ }
+ }
+
+ &.-stub {
+ &,
+ &::after {
+ border-radius: $fallback--panelRadius;
+ border-radius: var(--panelRadius, $fallback--panelRadius);
+ }
+ }
+
+ &.-sticky {
+ position: sticky;
+ top: var(--navbar-height);
+ }
+
+ &::after,
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 0;
+ left: 0;
+ pointer-events: none;
+ }
+
+ .title {
+ font-size: 1.3em;
+ }
+
+ .alert {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow-x: hidden;
+ }
+
+ &:not(.-flexible-height) {
+ > .button-default,
+ > .alert {
+ height: var(--__panel-heading-height-inner);
+ min-height: 0;
+ box-sizing: border-box;
+ margin: 0;
+ min-width: 1px;
+ padding-top: 0;
+ padding-bottom: 0;
+ align-self: stretch;
+ }
+ }
+}
+
+// TODO Should refactor panels into separate component and utilize slots
+
+.panel-heading {
+ border-radius: $fallback--panelRadius $fallback--panelRadius 0 0;
+ border-radius: var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius) 0 0;
+ border-width: 0 0 1px 0;
+ align-items: start;
+ // panel theme
+ color: var(--panelText);
+ background-color: $fallback--bg;
+ background-color: var(--bg, $fallback--bg);
+
+ &::after {
+ background-color: $fallback--fg;
+ background-color: var(--panel, $fallback--fg);
+ z-index: -2;
+ border-radius: $fallback--panelRadius $fallback--panelRadius 0 0;
+ border-radius: var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius) 0 0;
+ box-shadow: var(--panelHeaderShadow);
+ }
+
+ a,
+ .-link {
+ color: $fallback--link;
+ color: var(--panelLink, $fallback--link);
+ }
+
+ .button-unstyled:hover,
+ a:hover {
+ i[class*=icon-],
+ .svg-inline--fa,
+ .iconLetter {
+ color: var(--panelText);
+ }
+ }
+
+ .faint {
+ background-color: transparent;
+ color: $fallback--faint;
+ color: var(--panelFaint, $fallback--faint);
+ }
+
+ .faint-link {
+ color: $fallback--faint;
+ color: var(--faintLink, $fallback--faint);
+ }
+
+ &:not(.-flexible-height) {
+ > .button-default {
+ flex-shrink: 0;
+
+ &,
+ i[class*=icon-] {
+ color: $fallback--text;
+ color: var(--btnPanelText, $fallback--text);
+ }
+
+ &:active {
+ background-color: $fallback--fg;
+ background-color: var(--btnPressedPanel, $fallback--fg);
+ color: $fallback--text;
+ color: var(--btnPressedPanelText, $fallback--text);
+ }
+
+ &:disabled {
+ color: $fallback--text;
+ color: var(--btnDisabledPanelText, $fallback--text);
+ }
+
+ &.toggled {
+ color: $fallback--text;
+ color: var(--btnToggledPanelText, $fallback--text);
+ }
+ }
+ }
+
+ .rightside-button {
+ align-self: stretch;
+ text-align: center;
+ width: var(--__panel-heading-height);
+ height: var(--__panel-heading-height);
+ margin: calc(-1 * var(--panel-heading-height-padding)) 0;
+ margin-right: calc(-1 * var(--__panel-heading-gap));
+
+ > button {
+ box-sizing: border-box;
+ padding: calc(1 * var(--panel-heading-height-padding)) 0;
+ height: 100%;
+ width: 100%;
+ text-align: center;
+
+ svg {
+ font-size: 1.2em;
+ }
+ }
+ }
+
+ .rightside-icon {
+ align-self: stretch;
+ text-align: center;
+ width: var(--__panel-heading-height);
+ margin-right: calc(-1 * var(--__panel-heading-gap));
+
+ svg {
+ font-size: 1.2em;
+ }
+ }
+}
+
+.panel-footer {
+ border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
+ border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
+ align-items: center;
+ border-width: 1px 0 0 0;
+ border-style: solid;
+ border-color: var(--border, $fallback--border);
+}
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index 436b8b0a..7174cc5d 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -1,5 +1,5 @@
import { each, map, concat, last, get } from 'lodash'
-import { parseStatus, parseUser, parseNotification, parseAttachment, parseChat, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js'
+import { parseStatus, parseSource, parseUser, parseNotification, parseAttachment, parseChat, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js'
import { RegistrationError, StatusCodeError } from '../errors/errors'
/* eslint-env browser */
@@ -9,6 +9,8 @@ const FOLLOW_IMPORT_URL = '/api/pleroma/follow_import'
const DELETE_ACCOUNT_URL = '/api/pleroma/delete_account'
const CHANGE_EMAIL_URL = '/api/pleroma/change_email'
const CHANGE_PASSWORD_URL = '/api/pleroma/change_password'
+const MOVE_ACCOUNT_URL = '/api/pleroma/move_account'
+const ALIASES_URL = '/api/pleroma/aliases'
const TAG_USER_URL = '/api/pleroma/admin/users/tag'
const PERMISSION_GROUP_URL = (screenName, right) => `/api/pleroma/admin/users/${screenName}/permission_group/${right}`
const ACTIVATE_USER_URL = '/api/pleroma/admin/users/activate'
@@ -47,9 +49,16 @@ const MASTODON_PUBLIC_TIMELINE = '/api/v1/timelines/public'
const MASTODON_USER_HOME_TIMELINE_URL = '/api/v1/timelines/home'
const MASTODON_STATUS_URL = id => `/api/v1/statuses/${id}`
const MASTODON_STATUS_CONTEXT_URL = id => `/api/v1/statuses/${id}/context`
+const MASTODON_STATUS_SOURCE_URL = id => `/api/v1/statuses/${id}/source`
+const MASTODON_STATUS_HISTORY_URL = id => `/api/v1/statuses/${id}/history`
const MASTODON_USER_URL = '/api/v1/accounts'
+const MASTODON_USER_LOOKUP_URL = '/api/v1/accounts/lookup'
const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships'
const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses`
+const MASTODON_USER_IN_LISTS = id => `/api/v1/accounts/${id}/lists`
+const MASTODON_LIST_URL = id => `/api/v1/lists/${id}`
+const MASTODON_LIST_TIMELINE_URL = id => `/api/v1/timelines/list/${id}`
+const MASTODON_LIST_ACCOUNTS_URL = id => `/api/v1/lists/${id}/accounts`
const MASTODON_TAG_TIMELINE_URL = tag => `/api/v1/timelines/tag/${tag}`
const MASTODON_BOOKMARK_TIMELINE_URL = '/api/v1/bookmarks'
const MASTODON_USER_BLOCKS_URL = '/api/v1/blocks/'
@@ -58,8 +67,10 @@ const MASTODON_BLOCK_USER_URL = id => `/api/v1/accounts/${id}/block`
const MASTODON_UNBLOCK_USER_URL = id => `/api/v1/accounts/${id}/unblock`
const MASTODON_MUTE_USER_URL = id => `/api/v1/accounts/${id}/mute`
const MASTODON_UNMUTE_USER_URL = id => `/api/v1/accounts/${id}/unmute`
+const MASTODON_REMOVE_USER_FROM_FOLLOWERS = id => `/api/v1/accounts/${id}/remove_from_followers`
const MASTODON_SUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/subscribe`
const MASTODON_UNSUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/unsubscribe`
+const MASTODON_USER_NOTE_URL = id => `/api/v1/accounts/${id}/note`
const MASTODON_BOOKMARK_STATUS_URL = id => `/api/v1/statuses/${id}/bookmark`
const MASTODON_UNBOOKMARK_STATUS_URL = id => `/api/v1/statuses/${id}/unbookmark`
const MASTODON_POST_STATUS_URL = '/api/v1/statuses'
@@ -74,23 +85,32 @@ const MASTODON_PIN_OWN_STATUS = id => `/api/v1/statuses/${id}/pin`
const MASTODON_UNPIN_OWN_STATUS = id => `/api/v1/statuses/${id}/unpin`
const MASTODON_MUTE_CONVERSATION = id => `/api/v1/statuses/${id}/mute`
const MASTODON_UNMUTE_CONVERSATION = id => `/api/v1/statuses/${id}/unmute`
-const MASTODON_SEARCH_2 = `/api/v2/search`
+const MASTODON_SEARCH_2 = '/api/v2/search'
const MASTODON_USER_SEARCH_URL = '/api/v1/accounts/search'
const MASTODON_DOMAIN_BLOCKS_URL = '/api/v1/domain_blocks'
+const MASTODON_LISTS_URL = '/api/v1/lists'
const MASTODON_STREAMING = '/api/v1/streaming'
const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers'
+const MASTODON_ANNOUNCEMENTS_URL = '/api/v1/announcements'
+const MASTODON_ANNOUNCEMENTS_DISMISS_URL = id => `/api/v1/announcements/${id}/dismiss`
const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/reactions`
const PLEROMA_EMOJI_REACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
const PLEROMA_EMOJI_UNREACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
-const PLEROMA_CHATS_URL = `/api/v1/pleroma/chats`
+const PLEROMA_CHATS_URL = '/api/v1/pleroma/chats'
const PLEROMA_CHAT_URL = id => `/api/v1/pleroma/chats/by-account-id/${id}`
const PLEROMA_CHAT_MESSAGES_URL = id => `/api/v1/pleroma/chats/${id}/messages`
const PLEROMA_CHAT_READ_URL = id => `/api/v1/pleroma/chats/${id}/read`
const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) => `/api/v1/pleroma/chats/${chatId}/messages/${messageId}`
+const PLEROMA_ADMIN_REPORTS = '/api/pleroma/admin/reports'
+const PLEROMA_BACKUP_URL = '/api/v1/pleroma/backups'
+const PLEROMA_ANNOUNCEMENTS_URL = '/api/v1/pleroma/admin/announcements'
+const PLEROMA_POST_ANNOUNCEMENT_URL = '/api/v1/pleroma/admin/announcements'
+const PLEROMA_EDIT_ANNOUNCEMENT_URL = id => `/api/v1/pleroma/admin/announcements/${id}`
+const PLEROMA_DELETE_ANNOUNCEMENT_URL = id => `/api/v1/pleroma/admin/announcements/${id}`
const oldfetch = window.fetch
-let fetch = (url, options) => {
+const fetch = (url, options) => {
options = options || {}
const baseUrl = ''
const fullUrl = baseUrl + url
@@ -102,7 +122,7 @@ const promisedRequest = ({ method, url, params, payload, credentials, headers =
const options = {
method,
headers: {
- 'Accept': 'application/json',
+ Accept: 'application/json',
'Content-Type': 'application/json',
...headers
}
@@ -151,9 +171,15 @@ const updateNotificationSettings = ({ credentials, settings }) => {
}).then((data) => data.json())
}
-const updateProfileImages = ({ credentials, avatar = null, banner = null, background = null }) => {
+const updateProfileImages = ({ credentials, avatar = null, avatarName = null, banner = null, background = null }) => {
const form = new FormData()
- if (avatar !== null) form.append('avatar', avatar)
+ if (avatar !== null) {
+ if (avatarName !== null) {
+ form.append('avatar', avatar, avatarName)
+ } else {
+ form.append('avatar', avatar)
+ }
+ }
if (banner !== null) form.append('header', banner)
if (background !== null) form.append('pleroma_background_image', background)
return fetch(MASTODON_PROFILE_UPDATE_URL, {
@@ -191,6 +217,7 @@ const updateProfile = ({ credentials, params }) => {
// homepage
// location
// token
+// language
const register = ({ params, credentials }) => {
const { nickname, ...rest } = params
return fetch(MASTODON_REGISTRATION_URL, {
@@ -219,16 +246,16 @@ const getCaptcha = () => fetch('/api/pleroma/captcha').then(resp => resp.json())
const authHeaders = (accessToken) => {
if (accessToken) {
- return { 'Authorization': `Bearer ${accessToken}` }
+ return { Authorization: `Bearer ${accessToken}` }
} else {
return { }
}
}
const followUser = ({ id, credentials, ...options }) => {
- let url = MASTODON_FOLLOW_URL(id)
+ const url = MASTODON_FOLLOW_URL(id)
const form = {}
- if (options.reblogs !== undefined) { form['reblogs'] = options.reblogs }
+ if (options.reblogs !== undefined) { form.reblogs = options.reblogs }
return fetch(url, {
body: JSON.stringify(form),
headers: {
@@ -240,13 +267,20 @@ const followUser = ({ id, credentials, ...options }) => {
}
const unfollowUser = ({ id, credentials }) => {
- let url = MASTODON_UNFOLLOW_URL(id)
+ const url = MASTODON_UNFOLLOW_URL(id)
return fetch(url, {
headers: authHeaders(credentials),
method: 'POST'
}).then((data) => data.json())
}
+const fetchUserInLists = ({ id, credentials }) => {
+ const url = MASTODON_USER_IN_LISTS(id)
+ return fetch(url, {
+ headers: authHeaders(credentials)
+ }).then((data) => data.json())
+}
+
const pinOwnStatus = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_PIN_OWN_STATUS(id), credentials, method: 'POST' })
.then((data) => parseStatus(data))
@@ -281,8 +315,26 @@ const unblockUser = ({ id, credentials }) => {
}).then((data) => data.json())
}
+const removeUserFromFollowers = ({ id, credentials }) => {
+ return fetch(MASTODON_REMOVE_USER_FROM_FOLLOWERS(id), {
+ headers: authHeaders(credentials),
+ method: 'POST'
+ }).then((data) => data.json())
+}
+
+const editUserNote = ({ id, credentials, comment }) => {
+ return promisedRequest({
+ url: MASTODON_USER_NOTE_URL(id),
+ credentials,
+ payload: {
+ comment
+ },
+ method: 'POST'
+ })
+}
+
const approveUser = ({ id, credentials }) => {
- let url = MASTODON_APPROVE_USER_URL(id)
+ const url = MASTODON_APPROVE_USER_URL(id)
return fetch(url, {
headers: authHeaders(credentials),
method: 'POST'
@@ -290,7 +342,7 @@ const approveUser = ({ id, credentials }) => {
}
const denyUser = ({ id, credentials }) => {
- let url = MASTODON_DENY_USER_URL(id)
+ const url = MASTODON_DENY_USER_URL(id)
return fetch(url, {
headers: authHeaders(credentials),
method: 'POST'
@@ -298,13 +350,32 @@ const denyUser = ({ id, credentials }) => {
}
const fetchUser = ({ id, credentials }) => {
- let url = `${MASTODON_USER_URL}/${id}`
+ const url = `${MASTODON_USER_URL}/${id}`
return promisedRequest({ url, credentials })
.then((data) => parseUser(data))
}
+const fetchUserByName = ({ name, credentials }) => {
+ return promisedRequest({
+ url: MASTODON_USER_LOOKUP_URL,
+ credentials,
+ params: { acct: name }
+ })
+ .then(data => data.id)
+ .catch(error => {
+ if (error && error.statusCode === 404) {
+ // Either the backend does not support lookup endpoint,
+ // or there is no user with such name. Fallback and treat name as id.
+ return name
+ } else {
+ throw error
+ }
+ })
+ .then(id => fetchUser({ id, credentials }))
+}
+
const fetchUserRelationship = ({ id, credentials }) => {
- let url = `${MASTODON_USER_RELATIONSHIPS_URL}/?id=${id}`
+ const url = `${MASTODON_USER_RELATIONSHIPS_URL}/?id=${id}`
return fetch(url, { headers: authHeaders(credentials) })
.then((response) => {
return new Promise((resolve, reject) => response.json()
@@ -323,7 +394,7 @@ const fetchFriends = ({ id, maxId, sinceId, limit = 20, credentials }) => {
maxId && `max_id=${maxId}`,
sinceId && `since_id=${sinceId}`,
limit && `limit=${limit}`,
- `with_relationships=true`
+ 'with_relationships=true'
].filter(_ => _).join('&')
url = url + (args ? '?' + args : '')
@@ -333,6 +404,7 @@ const fetchFriends = ({ id, maxId, sinceId, limit = 20, credentials }) => {
}
const exportFriends = ({ id, credentials }) => {
+ // eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
try {
let friends = []
@@ -358,7 +430,7 @@ const fetchFollowers = ({ id, maxId, sinceId, limit = 20, credentials }) => {
maxId && `max_id=${maxId}`,
sinceId && `since_id=${sinceId}`,
limit && `limit=${limit}`,
- `with_relationships=true`
+ 'with_relationships=true'
].filter(_ => _).join('&')
url += args ? '?' + args : ''
@@ -374,8 +446,83 @@ const fetchFollowRequests = ({ credentials }) => {
.then((data) => data.map(parseUser))
}
+const fetchLists = ({ credentials }) => {
+ const url = MASTODON_LISTS_URL
+ return fetch(url, { headers: authHeaders(credentials) })
+ .then((data) => data.json())
+}
+
+const createList = ({ title, credentials }) => {
+ const url = MASTODON_LISTS_URL
+ const headers = authHeaders(credentials)
+ headers['Content-Type'] = 'application/json'
+
+ return fetch(url, {
+ headers,
+ method: 'POST',
+ body: JSON.stringify({ title })
+ }).then((data) => data.json())
+}
+
+const getList = ({ listId, credentials }) => {
+ const url = MASTODON_LIST_URL(listId)
+ return fetch(url, { headers: authHeaders(credentials) })
+ .then((data) => data.json())
+}
+
+const updateList = ({ listId, title, credentials }) => {
+ const url = MASTODON_LIST_URL(listId)
+ const headers = authHeaders(credentials)
+ headers['Content-Type'] = 'application/json'
+
+ return fetch(url, {
+ headers,
+ method: 'PUT',
+ body: JSON.stringify({ title })
+ })
+}
+
+const getListAccounts = ({ listId, credentials }) => {
+ const url = MASTODON_LIST_ACCOUNTS_URL(listId)
+ return fetch(url, { headers: authHeaders(credentials) })
+ .then((data) => data.json())
+ .then((data) => data.map(({ id }) => id))
+}
+
+const addAccountsToList = ({ listId, accountIds, credentials }) => {
+ const url = MASTODON_LIST_ACCOUNTS_URL(listId)
+ const headers = authHeaders(credentials)
+ headers['Content-Type'] = 'application/json'
+
+ return fetch(url, {
+ headers,
+ method: 'POST',
+ body: JSON.stringify({ account_ids: accountIds })
+ })
+}
+
+const removeAccountsFromList = ({ listId, accountIds, credentials }) => {
+ const url = MASTODON_LIST_ACCOUNTS_URL(listId)
+ const headers = authHeaders(credentials)
+ headers['Content-Type'] = 'application/json'
+
+ return fetch(url, {
+ headers,
+ method: 'DELETE',
+ body: JSON.stringify({ account_ids: accountIds })
+ })
+}
+
+const deleteList = ({ listId, credentials }) => {
+ const url = MASTODON_LIST_URL(listId)
+ return fetch(url, {
+ method: 'DELETE',
+ headers: authHeaders(credentials)
+ })
+}
+
const fetchConversation = ({ id, credentials }) => {
- let urlContext = MASTODON_STATUS_CONTEXT_URL(id)
+ const urlContext = MASTODON_STATUS_CONTEXT_URL(id)
return fetch(urlContext, { headers: authHeaders(credentials) })
.then((data) => {
if (data.ok) {
@@ -391,7 +538,7 @@ const fetchConversation = ({ id, credentials }) => {
}
const fetchStatus = ({ id, credentials }) => {
- let url = MASTODON_STATUS_URL(id)
+ const url = MASTODON_STATUS_URL(id)
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => {
if (data.ok) {
@@ -403,6 +550,31 @@ const fetchStatus = ({ id, credentials }) => {
.then((data) => parseStatus(data))
}
+const fetchStatusSource = ({ id, credentials }) => {
+ const url = MASTODON_STATUS_SOURCE_URL(id)
+ return fetch(url, { headers: authHeaders(credentials) })
+ .then((data) => {
+ if (data.ok) {
+ return data
+ }
+ throw new Error('Error fetching source', data)
+ })
+ .then((data) => data.json())
+ .then((data) => parseSource(data))
+}
+
+const fetchStatusHistory = ({ status, credentials }) => {
+ const url = MASTODON_STATUS_HISTORY_URL(status.id)
+ return promisedRequest({ url, credentials })
+ .then((data) => {
+ data.reverse()
+ return data.map((item) => {
+ item.originalStatus = status
+ return parseStatus(item)
+ })
+ })
+}
+
const tagUser = ({ tag, credentials, user }) => {
const screenName = user.screen_name
const form = {
@@ -415,7 +587,7 @@ const tagUser = ({ tag, credentials, user }) => {
return fetch(TAG_USER_URL, {
method: 'PUT',
- headers: headers,
+ headers,
body: JSON.stringify(form)
})
}
@@ -432,7 +604,7 @@ const untagUser = ({ tag, credentials, user }) => {
return fetch(TAG_USER_URL, {
method: 'DELETE',
- headers: headers,
+ headers,
body: JSON.stringify(body)
})
}
@@ -485,7 +657,7 @@ const deleteUser = ({ credentials, user }) => {
return fetch(`${ADMIN_USERS_URL}?nickname=${screenName}`, {
method: 'DELETE',
- headers: headers
+ headers
})
}
@@ -495,18 +667,21 @@ const fetchTimeline = ({
since = false,
until = false,
userId = false,
+ listId = false,
tag = false,
withMuted = false,
- replyVisibility = 'all'
+ replyVisibility = 'all',
+ includeTypes = []
}) => {
const timelineUrls = {
public: MASTODON_PUBLIC_TIMELINE,
friends: MASTODON_USER_HOME_TIMELINE_URL,
dms: MASTODON_DIRECT_MESSAGES_TIMELINE_URL,
notifications: MASTODON_USER_NOTIFICATIONS_URL,
- 'publicAndExternal': MASTODON_PUBLIC_TIMELINE,
+ publicAndExternal: MASTODON_PUBLIC_TIMELINE,
user: MASTODON_USER_TIMELINE_URL,
media: MASTODON_USER_TIMELINE_URL,
+ list: MASTODON_LIST_TIMELINE_URL,
favorites: MASTODON_USER_FAVORITES_TIMELINE_URL,
tag: MASTODON_TAG_TIMELINE_URL,
bookmarks: MASTODON_BOOKMARK_TIMELINE_URL
@@ -520,6 +695,10 @@ const fetchTimeline = ({
url = url(userId)
}
+ if (timeline === 'list') {
+ url = url(listId)
+ }
+
if (since) {
params.push(['since_id', since])
}
@@ -544,6 +723,11 @@ const fetchTimeline = ({
if (replyVisibility !== 'all') {
params.push(['reply_visibility', replyVisibility])
}
+ if (includeTypes.length > 0) {
+ includeTypes.forEach(type => {
+ params.push(['include_types[]', type])
+ })
+ }
params.push(['limit', 20])
@@ -678,7 +862,7 @@ const postStatus = ({
form.append('preview', 'true')
}
- let postHeaders = authHeaders(credentials)
+ const postHeaders = authHeaders(credentials)
if (idempotencyKey) {
postHeaders['idempotency-key'] = idempotencyKey
}
@@ -694,6 +878,54 @@ const postStatus = ({
.then((data) => data.error ? data : parseStatus(data))
}
+const editStatus = ({
+ id,
+ credentials,
+ status,
+ spoilerText,
+ sensitive,
+ poll,
+ mediaIds = [],
+ contentType
+}) => {
+ const form = new FormData()
+ const pollOptions = poll.options || []
+
+ form.append('status', status)
+ if (spoilerText) form.append('spoiler_text', spoilerText)
+ if (sensitive) form.append('sensitive', sensitive)
+ if (contentType) form.append('content_type', contentType)
+ mediaIds.forEach(val => {
+ form.append('media_ids[]', val)
+ })
+
+ if (pollOptions.some(option => option !== '')) {
+ const normalizedPoll = {
+ expires_in: poll.expiresIn,
+ multiple: poll.multiple
+ }
+ Object.keys(normalizedPoll).forEach(key => {
+ form.append(`poll[${key}]`, normalizedPoll[key])
+ })
+
+ pollOptions.forEach(option => {
+ form.append('poll[options][]', option)
+ })
+ }
+
+ const putHeaders = authHeaders(credentials)
+
+ return fetch(MASTODON_STATUS_URL(id), {
+ body: form,
+ method: 'PUT',
+ headers: putHeaders
+ })
+ .then((response) => {
+ return response.json()
+ })
+ .then((data) => data.error ? data : parseStatus(data))
+}
+
const deleteStatus = ({ id, credentials }) => {
return fetch(MASTODON_DELETE_URL(id), {
headers: authHeaders(credentials),
@@ -782,6 +1014,49 @@ const changeEmail = ({ credentials, email, password }) => {
.then((response) => response.json())
}
+const moveAccount = ({ credentials, password, targetAccount }) => {
+ const form = new FormData()
+
+ form.append('password', password)
+ form.append('target_account', targetAccount)
+
+ return fetch(MOVE_ACCOUNT_URL, {
+ body: form,
+ method: 'POST',
+ headers: authHeaders(credentials)
+ })
+ .then((response) => response.json())
+}
+
+const addAlias = ({ credentials, alias }) => {
+ return promisedRequest({
+ url: ALIASES_URL,
+ method: 'PUT',
+ credentials,
+ payload: { alias }
+ })
+}
+
+const deleteAlias = ({ credentials, alias }) => {
+ return promisedRequest({
+ url: ALIASES_URL,
+ method: 'DELETE',
+ credentials,
+ payload: { alias }
+ })
+}
+
+const listAliases = ({ credentials }) => {
+ return promisedRequest({
+ url: ALIASES_URL,
+ method: 'GET',
+ credentials,
+ params: {
+ _cacheBooster: (new Date()).getTime()
+ }
+ })
+}
+
const changePassword = ({ credentials, password, newPassword, newPasswordConfirmation }) => {
const form = new FormData()
@@ -868,6 +1143,25 @@ const fetchBlocks = ({ credentials }) => {
.then((users) => users.map(parseUser))
}
+const addBackup = ({ credentials }) => {
+ return promisedRequest({
+ url: PLEROMA_BACKUP_URL,
+ method: 'POST',
+ credentials
+ })
+}
+
+const listBackups = ({ credentials }) => {
+ return promisedRequest({
+ url: PLEROMA_BACKUP_URL,
+ method: 'GET',
+ credentials,
+ params: {
+ _cacheBooster: (new Date()).getTime()
+ }
+ })
+}
+
const fetchOAuthTokens = ({ credentials }) => {
const url = '/api/oauth_tokens.json'
@@ -921,7 +1215,7 @@ const vote = ({ pollId, choices, credentials }) => {
method: 'POST',
credentials,
payload: {
- choices: choices
+ choices
}
})
}
@@ -981,8 +1275,8 @@ const reportUser = ({ credentials, userId, statusIds, comment, forward }) => {
url: MASTODON_REPORT_USER_URL,
method: 'POST',
payload: {
- 'account_id': userId,
- 'status_ids': statusIds,
+ account_id: userId,
+ status_ids: statusIds,
comment,
forward
},
@@ -1002,9 +1296,9 @@ const searchUsers = ({ credentials, query }) => {
.then((data) => data.map(parseUser))
}
-const search2 = ({ credentials, q, resolve, limit, offset, following }) => {
+const search2 = ({ credentials, q, resolve, limit, offset, following, type }) => {
let url = MASTODON_SEARCH_2
- let params = []
+ const params = []
if (q) {
params.push(['q', encodeURIComponent(q)])
@@ -1026,9 +1320,13 @@ const search2 = ({ credentials, q, resolve, limit, offset, following }) => {
params.push(['following', true])
}
+ if (type) {
+ params.push(['following', type])
+ }
+
params.push(['with_relationships', true])
- let queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&')
+ const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&')
url += `?${queryString}`
return fetch(url, { headers: authHeaders(credentials) })
@@ -1081,6 +1379,66 @@ const dismissNotification = ({ credentials, id }) => {
})
}
+const adminFetchAnnouncements = ({ credentials }) => {
+ return promisedRequest({ url: PLEROMA_ANNOUNCEMENTS_URL, credentials })
+}
+
+const fetchAnnouncements = ({ credentials }) => {
+ return promisedRequest({ url: MASTODON_ANNOUNCEMENTS_URL, credentials })
+}
+
+const dismissAnnouncement = ({ id, credentials }) => {
+ return promisedRequest({
+ url: MASTODON_ANNOUNCEMENTS_DISMISS_URL(id),
+ credentials,
+ method: 'POST'
+ })
+}
+
+const announcementToPayload = ({ content, startsAt, endsAt, allDay }) => {
+ const payload = { content }
+
+ if (typeof startsAt !== 'undefined') {
+ payload.starts_at = startsAt ? new Date(startsAt).toISOString() : null
+ }
+
+ if (typeof endsAt !== 'undefined') {
+ payload.ends_at = endsAt ? new Date(endsAt).toISOString() : null
+ }
+
+ if (typeof allDay !== 'undefined') {
+ payload.all_day = allDay
+ }
+
+ return payload
+}
+
+const postAnnouncement = ({ credentials, content, startsAt, endsAt, allDay }) => {
+ return promisedRequest({
+ url: PLEROMA_POST_ANNOUNCEMENT_URL,
+ credentials,
+ method: 'POST',
+ payload: announcementToPayload({ content, startsAt, endsAt, allDay })
+ })
+}
+
+const editAnnouncement = ({ id, credentials, content, startsAt, endsAt, allDay }) => {
+ return promisedRequest({
+ url: PLEROMA_EDIT_ANNOUNCEMENT_URL(id),
+ credentials,
+ method: 'PATCH',
+ payload: announcementToPayload({ content, startsAt, endsAt, allDay })
+ })
+}
+
+const deleteAnnouncement = ({ id, credentials }) => {
+ return promisedRequest({
+ url: PLEROMA_DELETE_ANNOUNCEMENT_URL(id),
+ credentials,
+ method: 'DELETE'
+ })
+}
+
export const getMastodonSocketURI = ({ credentials, stream, args = {} }) => {
return Object.entries({
...(credentials
@@ -1098,7 +1456,8 @@ const MASTODON_STREAMING_EVENTS = new Set([
'update',
'notification',
'delete',
- 'filters_changed'
+ 'filters_changed',
+ 'status.update'
])
const PLEROMA_STREAMING_EVENTS = new Set([
@@ -1170,6 +1529,8 @@ export const handleMastoWS = (wsEvent) => {
const data = payload ? JSON.parse(payload) : null
if (event === 'update') {
return { event, status: parseStatus(data) }
+ } else if (event === 'status.update') {
+ return { event, status: parseStatus(data) }
} else if (event === 'notification') {
return { event, notification: parseNotification(data) }
} else if (event === 'pleroma:chat_update') {
@@ -1182,12 +1543,12 @@ export const handleMastoWS = (wsEvent) => {
}
export const WSConnectionStatus = Object.freeze({
- 'JOINED': 1,
- 'CLOSED': 2,
- 'ERROR': 3,
- 'DISABLED': 4,
- 'STARTING': 5,
- 'STARTING_INITIAL': 6
+ JOINED: 1,
+ CLOSED: 2,
+ ERROR: 3,
+ DISABLED: 4,
+ STARTING: 5,
+ STARTING_INITIAL: 6
})
const chats = ({ credentials }) => {
@@ -1225,11 +1586,11 @@ const chatMessages = ({ id, credentials, maxId, sinceId, limit = 20 }) => {
const sendChatMessage = ({ id, content, mediaId = null, idempotencyKey, credentials }) => {
const payload = {
- 'content': content
+ content
}
if (mediaId) {
- payload['media_id'] = mediaId
+ payload.media_id = mediaId
}
const headers = {}
@@ -1241,7 +1602,7 @@ const sendChatMessage = ({ id, content, mediaId = null, idempotencyKey, credenti
return promisedRequest({
url: PLEROMA_CHAT_MESSAGES_URL(id),
method: 'POST',
- payload: payload,
+ payload,
credentials,
headers
})
@@ -1252,7 +1613,7 @@ const readChat = ({ id, lastReadId, credentials }) => {
url: PLEROMA_CHAT_READ_URL(id),
method: 'POST',
payload: {
- 'last_read_id': lastReadId
+ last_read_id: lastReadId
},
credentials
})
@@ -1266,12 +1627,46 @@ const deleteChatMessage = ({ chatId, messageId, credentials }) => {
})
}
+const setReportState = ({ id, state, credentials }) => {
+ // TODO: Can't use promisedRequest because on OK this does not return json
+ // See https://git.pleroma.social/pleroma/pleroma-fe/-/merge_requests/1322
+ return fetch(PLEROMA_ADMIN_REPORTS, {
+ headers: {
+ ...authHeaders(credentials),
+ Accept: 'application/json',
+ 'Content-Type': 'application/json'
+ },
+ method: 'PATCH',
+ body: JSON.stringify({
+ reports: [{
+ id,
+ state
+ }]
+ })
+ })
+ .then(data => {
+ if (data.status >= 500) {
+ throw Error(data.statusText)
+ } else if (data.status >= 400) {
+ return data.json()
+ }
+ return data
+ })
+ .then(data => {
+ if (data.errors) {
+ throw Error(data.errors[0].message)
+ }
+ })
+}
+
const apiService = {
verifyCredentials,
fetchTimeline,
fetchPinnedStatuses,
fetchConversation,
fetchStatus,
+ fetchStatusSource,
+ fetchStatusHistory,
fetchFriends,
exportFriends,
fetchFollowers,
@@ -1283,7 +1678,10 @@ const apiService = {
unmuteConversation,
blockUser,
unblockUser,
+ removeUserFromFollowers,
+ editUserNote,
fetchUser,
+ fetchUserByName,
fetchUserRelationship,
favorite,
unfavorite,
@@ -1292,6 +1690,7 @@ const apiService = {
bookmarkStatus,
unbookmarkStatus,
postStatus,
+ editStatus,
deleteStatus,
uploadMedia,
setMediaDescription,
@@ -1319,13 +1718,27 @@ const apiService = {
importFollows,
deleteAccount,
changeEmail,
+ moveAccount,
+ addAlias,
+ deleteAlias,
+ listAliases,
changePassword,
settingsMFA,
mfaDisableOTP,
generateMfaBackupCodes,
mfaSetupOTP,
mfaConfirmOTP,
+ addBackup,
+ listBackups,
fetchFollowRequests,
+ fetchLists,
+ createList,
+ getList,
+ updateList,
+ getListAccounts,
+ addAccountsToList,
+ removeAccountsFromList,
+ deleteList,
approveUser,
denyUser,
suggestions,
@@ -1351,7 +1764,15 @@ const apiService = {
chatMessages,
sendChatMessage,
readChat,
- deleteChatMessage
+ deleteChatMessage,
+ setReportState,
+ fetchUserInLists,
+ fetchAnnouncements,
+ dismissAnnouncement,
+ postAnnouncement,
+ editAnnouncement,
+ deleteAnnouncement,
+ adminFetchAnnouncements
}
export default apiService
diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js
index 4a40f5b5..62ee8549 100644
--- a/src/services/backend_interactor_service/backend_interactor_service.js
+++ b/src/services/backend_interactor_service/backend_interactor_service.js
@@ -2,10 +2,11 @@ import apiService, { getMastodonSocketURI, ProcessedWS } from '../api/api.servic
import timelineFetcher from '../timeline_fetcher/timeline_fetcher.service.js'
import notificationsFetcher from '../notifications_fetcher/notifications_fetcher.service.js'
import followRequestFetcher from '../../services/follow_request_fetcher/follow_request_fetcher.service'
+import listsFetcher from '../../services/lists_fetcher/lists_fetcher.service.js'
const backendInteractorService = credentials => ({
- startFetchingTimeline ({ timeline, store, userId = false, tag }) {
- return timelineFetcher.startFetching({ timeline, store, credentials, userId, tag })
+ startFetchingTimeline ({ timeline, store, userId = false, listId = false, tag }) {
+ return timelineFetcher.startFetching({ timeline, store, credentials, userId, listId, tag })
},
fetchTimeline (args) {
@@ -24,6 +25,10 @@ const backendInteractorService = credentials => ({
return followRequestFetcher.startFetching({ store, credentials })
},
+ startFetchingLists ({ store }) {
+ return listsFetcher.startFetching({ store, credentials })
+ },
+
startUserSocket ({ store }) {
const serv = store.rootState.instance.server.replace('http', 'ws')
const url = serv + getMastodonSocketURI({ credentials, stream: 'user' })
diff --git a/src/services/chat_service/chat_service.js b/src/services/chat_service/chat_service.js
index 92ff689d..eb26a0ab 100644
--- a/src/services/chat_service/chat_service.js
+++ b/src/services/chat_service/chat_service.js
@@ -7,7 +7,7 @@ const empty = (chatId) => {
messages: [],
newMessageCount: 0,
lastSeenMessageId: '0',
- chatId: chatId,
+ chatId,
minId: undefined,
maxId: undefined
}
@@ -101,7 +101,7 @@ const add = (storage, { messages: newMessages, updateMaxId = true }) => {
storage.messages = storage.messages.filter(msg => msg.id !== message.id)
}
Object.assign(fakeMessage, message, { error: false })
- delete fakeMessage['fakeId']
+ delete fakeMessage.fakeId
storage.idIndex[fakeMessage.id] = fakeMessage
delete storage.idIndex[message.fakeId]
@@ -178,7 +178,7 @@ const getView = (storage) => {
id: date.getTime().toString()
})
- previousMessage['isTail'] = true
+ previousMessage.isTail = true
currentMessageChainId = undefined
afterDate = true
}
@@ -193,15 +193,15 @@ const getView = (storage) => {
// end a message chian
if ((nextMessage && nextMessage.account_id) !== message.account_id) {
- object['isTail'] = true
+ object.isTail = true
currentMessageChainId = undefined
}
// start a new message chain
if ((previousMessage && previousMessage.data && previousMessage.data.account_id) !== message.account_id || afterDate) {
currentMessageChainId = _.uniqueId()
- object['isHead'] = true
- object['messageChainId'] = currentMessageChainId
+ object.isHead = true
+ object.messageChainId = currentMessageChainId
}
result.push(object)
diff --git a/src/services/chat_utils/chat_utils.js b/src/services/chat_utils/chat_utils.js
index de6e0625..a8da1eed 100644
--- a/src/services/chat_utils/chat_utils.js
+++ b/src/services/chat_utils/chat_utils.js
@@ -25,7 +25,7 @@ export const buildFakeMessage = ({ content, chatId, attachments, userId, idempot
chat_id: chatId,
created_at: new Date(),
id: `${new Date().getTime()}`,
- attachments: attachments,
+ attachments,
account_id: userId,
idempotency_key: idempotencyKey,
emojis: [],
diff --git a/src/services/color_convert/color_convert.js b/src/services/color_convert/color_convert.js
index ec104269..47d6344e 100644
--- a/src/services/color_convert/color_convert.js
+++ b/src/services/color_convert/color_convert.js
@@ -144,11 +144,13 @@ export const invert = (rgb) => {
*/
export const hex2rgb = (hex) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
- return result ? {
- r: parseInt(result[1], 16),
- g: parseInt(result[2], 16),
- b: parseInt(result[3], 16)
- } : null
+ return result
+ ? {
+ r: parseInt(result[1], 16),
+ g: parseInt(result[2], 16),
+ b: parseInt(result[3], 16)
+ }
+ : null
}
/**
diff --git a/src/services/completion/completion.js b/src/services/completion/completion.js
index 8a6eba7e..8fa4f75b 100644
--- a/src/services/completion/completion.js
+++ b/src/services/completion/completion.js
@@ -35,7 +35,7 @@ export const addPositionToWords = (words) => {
}
export const splitByWhitespaceBoundary = (str) => {
- let result = []
+ const result = []
let currentWord = ''
for (let i = 0; i < str.length; i++) {
const currentChar = str[i]
diff --git a/src/services/date_utils/date_utils.js b/src/services/date_utils/date_utils.js
index 32e13bca..c93d2176 100644
--- a/src/services/date_utils/date_utils.js
+++ b/src/services/date_utils/date_utils.js
@@ -10,31 +10,29 @@ export const relativeTime = (date, nowThreshold = 1) => {
if (typeof date === 'string') date = Date.parse(date)
const round = Date.now() > date ? Math.floor : Math.ceil
const d = Math.abs(Date.now() - date)
- let r = { num: round(d / YEAR), key: 'time.years' }
+ const r = { num: round(d / YEAR), key: 'time.unit.years' }
if (d < nowThreshold * SECOND) {
r.num = 0
r.key = 'time.now'
} else if (d < MINUTE) {
r.num = round(d / SECOND)
- r.key = 'time.seconds'
+ r.key = 'time.unit.seconds'
} else if (d < HOUR) {
r.num = round(d / MINUTE)
- r.key = 'time.minutes'
+ r.key = 'time.unit.minutes'
} else if (d < DAY) {
r.num = round(d / HOUR)
- r.key = 'time.hours'
+ r.key = 'time.unit.hours'
} else if (d < WEEK) {
r.num = round(d / DAY)
- r.key = 'time.days'
+ r.key = 'time.unit.days'
} else if (d < MONTH) {
r.num = round(d / WEEK)
- r.key = 'time.weeks'
+ r.key = 'time.unit.weeks'
} else if (d < YEAR) {
r.num = round(d / MONTH)
- r.key = 'time.months'
+ r.key = 'time.unit.months'
}
- // Remove plural form when singular
- if (r.num === 1) r.key = r.key.slice(0, -1)
return r
}
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 7025d803..ea138177 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -39,14 +39,17 @@ const qvitterStatusType = (status) => {
export const parseUser = (data) => {
const output = {}
- const masto = data.hasOwnProperty('acct')
+ const masto = Object.prototype.hasOwnProperty.call(data, 'acct')
// case for users in "mentions" property for statuses in MastoAPI
- const mastoShort = masto && !data.hasOwnProperty('avatar')
+ const mastoShort = masto && !Object.prototype.hasOwnProperty.call(data, 'avatar')
+ output.inLists = null
output.id = String(data.id)
+ output._original = data // used for server-side settings
if (masto) {
output.screen_name = data.acct
+ output.fqn = data.fqn
output.statusnet_profile_url = data.url
// There's nothing else to get
@@ -89,6 +92,9 @@ export const parseUser = (data) => {
output.bot = data.bot
if (data.pleroma) {
+ if (data.pleroma.settings_store) {
+ output.storage = data.pleroma.settings_store['pleroma-fe']
+ }
const relationship = data.pleroma.relationship
output.background_image = data.pleroma.background_image
@@ -118,6 +124,34 @@ export const parseUser = (data) => {
} else {
output.role = 'member'
}
+
+ if (data.pleroma.privileges) {
+ output.privileges = data.pleroma.privileges
+ } else if (data.pleroma.is_admin) {
+ output.privileges = [
+ 'users_read',
+ 'users_manage_invites',
+ 'users_manage_activation_state',
+ 'users_manage_tags',
+ 'users_manage_credentials',
+ 'users_delete',
+ 'messages_read',
+ 'messages_delete',
+ 'instances_delete',
+ 'reports_manage_reports',
+ 'moderation_log_read',
+ 'announcements_manage_announcements',
+ 'emoji_manage_emoji',
+ 'statistics_read'
+ ]
+ } else if (data.pleroma.is_moderator) {
+ output.privileges = [
+ 'messages_delete',
+ 'reports_manage_reports'
+ ]
+ } else {
+ output.privileges = []
+ }
}
if (data.source) {
@@ -210,12 +244,14 @@ export const parseUser = (data) => {
output.screen_name_ui = output.screen_name
if (output.screen_name && output.screen_name.includes('@')) {
const parts = output.screen_name.split('@')
- let unicodeDomain = punycode.toUnicode(parts[1])
+ const unicodeDomain = punycode.toUnicode(parts[1])
if (unicodeDomain !== parts[1]) {
// Add some identifier so users can potentially spot spoofing attempts:
// lain.com and xn--lin-6cd.com would appear identical otherwise.
- unicodeDomain = '🌏' + unicodeDomain
+ output.screen_name_ui_contains_non_ascii = true
output.screen_name_ui = [parts[0], unicodeDomain].join('@')
+ } else {
+ output.screen_name_ui_contains_non_ascii = false
}
}
@@ -224,7 +260,7 @@ export const parseUser = (data) => {
export const parseAttachment = (data) => {
const output = {}
- const masto = !data.hasOwnProperty('oembed')
+ const masto = !Object.prototype.hasOwnProperty.call(data, 'oembed')
if (masto) {
// Not exactly same...
@@ -243,9 +279,19 @@ export const parseAttachment = (data) => {
return output
}
+export const parseSource = (data) => {
+ const output = {}
+
+ output.text = data.text
+ output.spoiler_text = data.spoiler_text
+ output.content_type = data.content_type
+
+ return output
+}
+
export const parseStatus = (data) => {
const output = {}
- const masto = data.hasOwnProperty('account')
+ const masto = Object.prototype.hasOwnProperty.call(data, 'account')
if (masto) {
output.favorited = data.favourited
@@ -264,6 +310,8 @@ export const parseStatus = (data) => {
output.tags = data.tags
+ output.edited_at = data.edited_at
+
if (data.pleroma) {
const { pleroma } = data
output.text = pleroma.content ? data.pleroma.content['text/plain'] : data.content
@@ -365,15 +413,19 @@ export const parseStatus = (data) => {
output.favoritedBy = []
output.rebloggedBy = []
+ if (Object.prototype.hasOwnProperty.call(data, 'originalStatus')) {
+ Object.assign(output, data.originalStatus)
+ }
+
return output
}
export const parseNotification = (data) => {
const mastoDict = {
- 'favourite': 'like',
- 'reblog': 'repeat'
+ favourite: 'like',
+ reblog: 'repeat'
}
- const masto = !data.hasOwnProperty('ntype')
+ const masto = !Object.prototype.hasOwnProperty.call(data, 'ntype')
const output = {}
if (masto) {
@@ -386,6 +438,13 @@ export const parseNotification = (data) => {
: parseUser(data.target)
output.from_profile = parseUser(data.account)
output.emoji = data.emoji
+ if (data.report) {
+ output.report = data.report
+ output.report.content = data.report.content
+ output.report.acct = parseUser(data.report.account)
+ output.report.actor = parseUser(data.report.actor)
+ output.report.statuses = data.report.statuses.map(parseStatus)
+ }
} else {
const parsedNotice = parseStatus(data.notice)
output.type = data.ntype
diff --git a/src/services/errors/errors.js b/src/services/errors/errors.js
index d4cf9132..50372e5e 100644
--- a/src/services/errors/errors.js
+++ b/src/services/errors/errors.js
@@ -26,6 +26,7 @@ export class RegistrationError extends Error {
// the error is probably a JSON object with a single key, "errors", whose value is another JSON object containing the real errors
if (typeof error === 'string') {
error = JSON.parse(error)
+ // eslint-disable-next-line
if (error.hasOwnProperty('error')) {
error = JSON.parse(error.error)
}
diff --git a/src/services/export_import/export_import.js b/src/services/export_import/export_import.js
index ac67cf9c..7fee0ad3 100644
--- a/src/services/export_import/export_import.js
+++ b/src/services/export_import/export_import.js
@@ -1,9 +1,11 @@
+import utf8 from 'utf8'
+
export const newExporter = ({
filename = 'data',
getExportedObject
}) => ({
exportData () {
- const stringified = JSON.stringify(getExportedObject(), null, 2) // Pretty-print and indent with 2 spaces
+ const stringified = utf8.encode(JSON.stringify(getExportedObject(), null, 2)) // Pretty-print and indent with 2 spaces
// Create an invisible link with a data url and simulate a click
const e = document.createElement('a')
diff --git a/src/services/file_size_format/file_size_format.js b/src/services/file_size_format/file_size_format.js
index 7e6cd4d7..17deb09b 100644
--- a/src/services/file_size_format/file_size_format.js
+++ b/src/services/file_size_format/file_size_format.js
@@ -1,15 +1,14 @@
-const fileSizeFormat = (num) => {
- var exponent
- var unit
- var units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
+const fileSizeFormat = (numArg) => {
+ const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
+ let num = numArg
if (num < 1) {
return num + ' ' + units[0]
}
- exponent = Math.min(Math.floor(Math.log(num) / Math.log(1024)), units.length - 1)
+ const exponent = Math.min(Math.floor(Math.log(num) / Math.log(1024)), units.length - 1)
num = (num / Math.pow(1024, exponent)).toFixed(2) * 1
- unit = units[exponent]
- return { num: num, unit: unit }
+ const unit = units[exponent]
+ return { num, unit }
}
const fileSizeFormatService = {
fileSizeFormat
diff --git a/src/services/gesture_service/gesture_service.js b/src/services/gesture_service/gesture_service.js
index 88a328f3..265a7f25 100644
--- a/src/services/gesture_service/gesture_service.js
+++ b/src/services/gesture_service/gesture_service.js
@@ -4,9 +4,15 @@ const DIRECTION_RIGHT = [1, 0]
const DIRECTION_UP = [0, -1]
const DIRECTION_DOWN = [0, 1]
+const BUTTON_LEFT = 0
+
const deltaCoord = (oldCoord, newCoord) => [newCoord[0] - oldCoord[0], newCoord[1] - oldCoord[1]]
-const touchEventCoord = e => ([e.touches[0].screenX, e.touches[0].screenY])
+const touchCoord = touch => [touch.screenX, touch.screenY]
+
+const touchEventCoord = e => touchCoord(e.touches[0])
+
+const pointerEventCoord = e => [e.clientX, e.clientY]
const vectorLength = v => Math.sqrt(v[0] * v[0] + v[1] * v[1])
@@ -61,6 +67,132 @@ const updateSwipe = (event, gesture) => {
gesture._swiping = false
}
+class SwipeAndClickGesture {
+ // swipePreviewCallback(offsets: Array[Number])
+ // offsets: the offset vector which the underlying component should move, from the starting position
+ // swipeEndCallback(sign: 0|-1|1)
+ // sign: if the swipe does not meet the threshold, 0
+ // if the swipe meets the threshold in the positive direction, 1
+ // if the swipe meets the threshold in the negative direction, -1
+ constructor ({
+ direction,
+ // swipeStartCallback
+ swipePreviewCallback,
+ swipeEndCallback,
+ swipeCancelCallback,
+ swipelessClickCallback,
+ threshold = 30,
+ perpendicularTolerance = 1.0,
+ disableClickThreshold = 1
+ }) {
+ const nop = () => {}
+ this.direction = direction
+ this.swipePreviewCallback = swipePreviewCallback || nop
+ this.swipeEndCallback = swipeEndCallback || nop
+ this.swipeCancelCallback = swipeCancelCallback || nop
+ this.swipelessClickCallback = swipelessClickCallback || nop
+ this.threshold = typeof threshold === 'function' ? threshold : () => threshold
+ this.disableClickThreshold = typeof disableClickThreshold === 'function' ? disableClickThreshold : () => disableClickThreshold
+ this.perpendicularTolerance = perpendicularTolerance
+ this._reset()
+ }
+
+ _reset () {
+ this._startPos = [0, 0]
+ this._pointerId = -1
+ this._swiping = false
+ this._swiped = false
+ this._preventNextClick = false
+ }
+
+ start (event) {
+ // Only handle left click
+ if (event.button !== BUTTON_LEFT) {
+ return
+ }
+
+ this._startPos = pointerEventCoord(event)
+ this._pointerId = event.pointerId
+ this._swiping = true
+ this._swiped = false
+ }
+
+ move (event) {
+ if (this._swiping && this._pointerId === event.pointerId) {
+ this._swiped = true
+
+ const coord = pointerEventCoord(event)
+ const delta = deltaCoord(this._startPos, coord)
+
+ this.swipePreviewCallback(delta)
+ }
+ }
+
+ cancel (event) {
+ if (!this._swiping || this._pointerId !== event.pointerId) {
+ return
+ }
+
+ this.swipeCancelCallback()
+ }
+
+ end (event) {
+ if (!this._swiping) {
+ return
+ }
+
+ if (this._pointerId !== event.pointerId) {
+ return
+ }
+
+ this._swiping = false
+
+ // movement too small
+ const coord = pointerEventCoord(event)
+ const delta = deltaCoord(this._startPos, coord)
+
+ const sign = (() => {
+ if (vectorLength(delta) < this.threshold()) {
+ return 0
+ }
+ // movement is opposite from direction
+ const isPositive = dotProduct(delta, this.direction) > 0
+
+ // movement perpendicular to direction is too much
+ const towardsDir = project(delta, this.direction)
+ const perpendicularDir = perpendicular(this.direction)
+ const towardsPerpendicular = project(delta, perpendicularDir)
+ if (
+ vectorLength(towardsDir) * this.perpendicularTolerance <
+ vectorLength(towardsPerpendicular)
+ ) {
+ return 0
+ }
+
+ return isPositive ? 1 : -1
+ })()
+
+ if (this._swiped) {
+ this.swipeEndCallback(sign)
+ }
+ this._reset()
+ // Only a mouse will fire click event when
+ // the end point is far from the starting point
+ // so for other kinds of pointers do not check
+ // whether we have swiped
+ if (vectorLength(delta) >= this.disableClickThreshold() && event.pointerType === 'mouse') {
+ this._preventNextClick = true
+ }
+ }
+
+ click (event) {
+ if (!this._preventNextClick) {
+ this.swipelessClickCallback()
+ }
+ this._reset()
+ }
+}
+
const GestureService = {
DIRECTION_LEFT,
DIRECTION_RIGHT,
@@ -68,7 +200,8 @@ const GestureService = {
DIRECTION_DOWN,
swipeGesture,
beginSwipe,
- updateSwipe
+ updateSwipe,
+ SwipeAndClickGesture
}
export default GestureService
diff --git a/src/services/html_converter/html_line_converter.service.js b/src/services/html_converter/html_line_converter.service.js
index 5eeaa7cb..9c3d1f19 100644
--- a/src/services/html_converter/html_line_converter.service.js
+++ b/src/services/html_converter/html_line_converter.service.js
@@ -46,7 +46,7 @@ export const convertHtmlToLines = (html = '') => {
// All block-level elements that aren't empty elements, i.e. not <hr>
const nonEmptyElements = new Set(visualLineElements)
// Difference
- for (let elem of emptyElements) {
+ for (const elem of emptyElements) {
nonEmptyElements.delete(elem)
}
@@ -56,7 +56,7 @@ export const convertHtmlToLines = (html = '') => {
...emptyElements.values()
])
- let buffer = [] // Current output buffer
+ const buffer = [] // Current output buffer
const level = [] // How deep we are in tags and which tags were there
let textBuffer = '' // Current line content
let tagBuffer = null // Current tag buffer, if null = we are not currently reading a tag
diff --git a/src/services/html_converter/html_tree_converter.service.js b/src/services/html_converter/html_tree_converter.service.js
index 6a8796c4..247a8173 100644
--- a/src/services/html_converter/html_tree_converter.service.js
+++ b/src/services/html_converter/html_tree_converter.service.js
@@ -1,4 +1,5 @@
import { getTagName } from './utility.service.js'
+import { unescape } from 'lodash'
/**
* This is a not-so-tiny purpose-built HTML parser/processor. This parses html
@@ -49,7 +50,7 @@ export const convertHtmlToTree = (html = '') => {
const handleOpen = (tag) => {
const curBuf = getCurrentBuffer()
- const newLevel = [tag, []]
+ const newLevel = [unescape(tag), []]
levels.push(newLevel)
curBuf.push(newLevel)
}
diff --git a/src/services/html_converter/utility.service.js b/src/services/html_converter/utility.service.js
index 4d0c36c2..f1042971 100644
--- a/src/services/html_converter/utility.service.js
+++ b/src/services/html_converter/utility.service.js
@@ -16,7 +16,7 @@ export const getTagName = (tag) => {
* @return {Object} - map of attributes key = attribute name, value = attribute value
* attributes without values represented as boolean true
*/
-export const getAttrs = tag => {
+export const getAttrs = (tag, filter) => {
const innertag = tag
.substring(1, tag.length - 1)
.replace(new RegExp('^' + getTagName(tag)), '')
@@ -28,7 +28,15 @@ export const getAttrs = tag => {
if (!v) return [k, true]
return [k, v.substring(1, v.length - 1)]
})
- return Object.fromEntries(attrs)
+ const defaultFilter = ([k, v]) => {
+ const attrKey = k.toLowerCase()
+ if (attrKey === 'style') return false
+ if (attrKey === 'class') {
+ return v === 'greentext' || v === 'cyantext'
+ }
+ return true
+ }
+ return Object.fromEntries(attrs.filter(filter || defaultFilter))
}
/**
@@ -50,7 +58,7 @@ export const processTextForEmoji = (text, emojis, processor) => {
if (char === ':') {
const next = text.slice(i + 1)
let found = false
- for (let emoji of emojis) {
+ for (const emoji of emojis) {
if (next.slice(0, emoji.shortcode.length + 1) === (emoji.shortcode + ':')) {
found = emoji
break
diff --git a/src/services/lists_fetcher/lists_fetcher.service.js b/src/services/lists_fetcher/lists_fetcher.service.js
new file mode 100644
index 00000000..8d9dae66
--- /dev/null
+++ b/src/services/lists_fetcher/lists_fetcher.service.js
@@ -0,0 +1,22 @@
+import apiService from '../api/api.service.js'
+import { promiseInterval } from '../promise_interval/promise_interval.js'
+
+const fetchAndUpdate = ({ store, credentials }) => {
+ return apiService.fetchLists({ credentials })
+ .then(lists => {
+ store.commit('setLists', lists)
+ }, () => {})
+ .catch(() => {})
+}
+
+const startFetching = ({ credentials, store }) => {
+ const boundFetchAndUpdate = () => fetchAndUpdate({ credentials, store })
+ boundFetchAndUpdate()
+ return promiseInterval(boundFetchAndUpdate, 240000)
+}
+
+const listsFetcher = {
+ startFetching
+}
+
+export default listsFetcher
diff --git a/src/services/locale/locale.service.js b/src/services/locale/locale.service.js
index 5be99d81..d3389785 100644
--- a/src/services/locale/locale.service.js
+++ b/src/services/locale/locale.service.js
@@ -1,12 +1,35 @@
+import languagesObject from '../../i18n/messages'
+import ISO6391 from 'iso-639-1'
+import _ from 'lodash'
+
const specialLanguageCodes = {
- 'ja_easy': 'ja',
- 'zh_Hant': 'zh-HANT'
+ ja_easy: 'ja',
+ zh_Hant: 'zh-HANT',
+ zh: 'zh-Hans'
}
const internalToBrowserLocale = code => specialLanguageCodes[code] || code
+const internalToBackendLocale = code => internalToBrowserLocale(code).replace('_', '-')
+
+const getLanguageName = (code) => {
+ const specialLanguageNames = {
+ ja_easy: 'やさしいãĢãģんご',
+ zh: 'įŽ€äŊ“中文',
+ zh_Hant: 'įšéĢ”ä¸­æ–‡'
+ }
+ const languageName = specialLanguageNames[code] || ISO6391.getNativeName(code)
+ const browserLocale = internalToBrowserLocale(code)
+ return languageName.charAt(0).toLocaleUpperCase(browserLocale) + languageName.slice(1)
+}
+
+const languages = _.map(languagesObject.languages, (code) => ({ code, name: getLanguageName(code) })).sort((a, b) => a.name.localeCompare(b.name))
+
const localeService = {
- internalToBrowserLocale
+ internalToBrowserLocale,
+ internalToBackendLocale,
+ languages,
+ getLanguageName
}
export default localeService
diff --git a/src/services/new_api/password_reset.js b/src/services/new_api/password_reset.js
index 43199625..9f3c27b5 100644
--- a/src/services/new_api/password_reset.js
+++ b/src/services/new_api/password_reset.js
@@ -1,6 +1,6 @@
import { reduce } from 'lodash'
-const MASTODON_PASSWORD_RESET_URL = `/auth/password`
+const MASTODON_PASSWORD_RESET_URL = '/auth/password'
const resetPassword = ({ instance, email }) => {
const params = { email }
diff --git a/src/services/notification_utils/notification_utils.js b/src/services/notification_utils/notification_utils.js
index 6fef1022..0f8b9b02 100644
--- a/src/services/notification_utils/notification_utils.js
+++ b/src/services/notification_utils/notification_utils.js
@@ -14,11 +14,13 @@ export const visibleTypes = store => {
rootState.config.notificationVisibility.follows && 'follow',
rootState.config.notificationVisibility.followRequest && 'follow_request',
rootState.config.notificationVisibility.moves && 'move',
- rootState.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reaction'
+ rootState.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reaction',
+ rootState.config.notificationVisibility.reports && 'pleroma:report',
+ rootState.config.notificationVisibility.polls && 'poll'
].filter(_ => _))
}
-const statusNotifications = ['like', 'mention', 'repeat', 'pleroma:emoji_reaction']
+const statusNotifications = ['like', 'mention', 'repeat', 'pleroma:emoji_reaction', 'poll']
export const isStatusNotification = (type) => includes(statusNotifications, type)
@@ -98,6 +100,12 @@ export const prepareNotificationObject = (notification, i18n) => {
case 'follow_request':
i18nString = 'follow_request'
break
+ case 'pleroma:report':
+ i18nString = 'submitted_report'
+ break
+ case 'poll':
+ i18nString = 'poll_ended'
+ break
}
if (notification.type === 'pleroma:emoji_reaction') {
diff --git a/src/services/notifications_fetcher/notifications_fetcher.service.js b/src/services/notifications_fetcher/notifications_fetcher.service.js
index b66fcd67..6c247210 100644
--- a/src/services/notifications_fetcher/notifications_fetcher.service.js
+++ b/src/services/notifications_fetcher/notifications_fetcher.service.js
@@ -1,6 +1,18 @@
import apiService from '../api/api.service.js'
import { promiseInterval } from '../promise_interval/promise_interval.js'
+// For using include_types when fetching notifications.
+// Note: chat_mention excluded as pleroma-fe polls them separately
+const mastoApiNotificationTypes = [
+ 'mention',
+ 'favourite',
+ 'reblog',
+ 'follow',
+ 'move',
+ 'pleroma:emoji_reaction',
+ 'pleroma:report'
+]
+
const update = ({ store, notifications, older }) => {
store.dispatch('addNewNotifications', { notifications, older })
}
@@ -11,24 +23,22 @@ const fetchAndUpdate = ({ store, credentials, older = false, since }) => {
const rootState = store.rootState || store.state
const timelineData = rootState.statuses.notifications
const hideMutedPosts = getters.mergedConfig.hideMutedPosts
- const allowFollowingMove = rootState.users.currentUser.allow_following_move
-
- args['withMuted'] = !hideMutedPosts
- args['withMove'] = !allowFollowingMove
+ args.includeTypes = mastoApiNotificationTypes
+ args.withMuted = !hideMutedPosts
- args['timeline'] = 'notifications'
+ args.timeline = 'notifications'
if (older) {
if (timelineData.minId !== Number.POSITIVE_INFINITY) {
- args['until'] = timelineData.minId
+ args.until = timelineData.minId
}
return fetchNotifications({ store, args, older })
} else {
// fetch new notifications
if (since === undefined && timelineData.maxId !== Number.POSITIVE_INFINITY) {
- args['since'] = timelineData.maxId
+ args.since = timelineData.maxId
} else if (since !== null) {
- args['since'] = since
+ args.since = since
}
const result = fetchNotifications({ store, args, older })
@@ -41,7 +51,7 @@ const fetchAndUpdate = ({ store, credentials, older = false, since }) => {
const readNotifsIds = notifications.filter(n => n.seen).map(n => n.id)
const numUnseenNotifs = notifications.length - readNotifsIds.length
if (numUnseenNotifs > 0 && readNotifsIds.length > 0) {
- args['since'] = Math.max(...readNotifsIds)
+ args.since = Math.max(...readNotifsIds)
fetchNotifications({ store, args, older })
}
@@ -66,6 +76,7 @@ const fetchNotifications = ({ store, args, older }) => {
messageArgs: [error.message],
timeout: 5000
})
+ console.error(error)
})
}
diff --git a/src/services/offset_finder/offset_finder.service.js b/src/services/offset_finder/offset_finder.service.js
index 9034f8c8..5a904f08 100644
--- a/src/services/offset_finder/offset_finder.service.js
+++ b/src/services/offset_finder/offset_finder.service.js
@@ -9,7 +9,7 @@ export const findOffset = (child, parent, { top = 0, left = 0 } = {}, ignorePadd
result.left += ignorePadding ? 0 : leftPadding
}
- if (child.offsetParent && (parent === window || parent.contains(child.offsetParent) || parent === child.offsetParent)) {
+ if (child.offsetParent && window.getComputedStyle(child.offsetParent).position !== 'sticky' && (parent === window || parent.contains(child.offsetParent) || parent === child.offsetParent)) {
return findOffset(child.offsetParent, parent, result, false)
} else {
if (parent !== window) {
diff --git a/src/services/push/push.js b/src/services/push/push.js
index 5836fc26..1787ac36 100644
--- a/src/services/push/push.js
+++ b/src/services/push/push.js
@@ -1,4 +1,4 @@
-import runtime from 'serviceworker-webpack-plugin/lib/runtime'
+import runtime from 'serviceworker-webpack5-plugin/lib/runtime'
function urlBase64ToUint8Array (base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4)
@@ -43,7 +43,7 @@ function deleteSubscriptionFromBackEnd (token) {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
- 'Authorization': `Bearer ${token}`
+ Authorization: `Bearer ${token}`
}
}).then((response) => {
if (!response.ok) throw new Error('Bad status code from server.')
@@ -56,7 +56,7 @@ function sendSubscriptionToBackEnd (subscription, token, notificationVisibility)
method: 'POST',
headers: {
'Content-Type': 'application/json',
- 'Authorization': `Bearer ${token}`
+ Authorization: `Bearer ${token}`
},
body: JSON.stringify({
subscription,
diff --git a/src/services/resettable_async_component.js b/src/services/resettable_async_component.js
index 517bbd88..1c046ce7 100644
--- a/src/services/resettable_async_component.js
+++ b/src/services/resettable_async_component.js
@@ -1,4 +1,4 @@
-import Vue from 'vue'
+import { defineAsyncComponent, shallowReactive, h } from 'vue'
/* By default async components don't have any way to recover, if component is
* failed, it is failed forever. This helper tries to remedy that by recreating
@@ -8,23 +8,21 @@ import Vue from 'vue'
* actual target component itself if needs to be.
*/
function getResettableAsyncComponent (asyncComponent, options) {
- const asyncComponentFactory = () => () => ({
- component: asyncComponent(),
+ const asyncComponentFactory = () => () => defineAsyncComponent({
+ loader: asyncComponent,
...options
})
- const observe = Vue.observable({ c: asyncComponentFactory() })
+ const observe = shallowReactive({ c: asyncComponentFactory() })
return {
- functional: true,
- render (createElement, { data, children }) {
+ render () {
// emit event resetAsyncComponent to reloading
- data.on = {}
- data.on.resetAsyncComponent = () => {
- observe.c = asyncComponentFactory()
- // parent.$forceUpdate()
- }
- return createElement(observe.c, data, children)
+ return h(observe.c(), {
+ onResetAsyncComponent () {
+ observe.c = asyncComponentFactory()
+ }
+ })
}
}
}
diff --git a/src/services/status_poster/status_poster.service.js b/src/services/status_poster/status_poster.service.js
index f09196aa..1eb10bb6 100644
--- a/src/services/status_poster/status_poster.service.js
+++ b/src/services/status_poster/status_poster.service.js
@@ -47,6 +47,47 @@ const postStatus = ({
})
}
+const editStatus = ({
+ store,
+ statusId,
+ status,
+ spoilerText,
+ sensitive,
+ poll,
+ media = [],
+ contentType = 'text/plain'
+}) => {
+ const mediaIds = map(media, 'id')
+
+ return apiService.editStatus({
+ id: statusId,
+ credentials: store.state.users.currentUser.credentials,
+ status,
+ spoilerText,
+ sensitive,
+ poll,
+ mediaIds,
+ contentType
+ })
+ .then((data) => {
+ if (!data.error) {
+ store.dispatch('addNewStatuses', {
+ statuses: [data],
+ timeline: 'friends',
+ showImmediately: true,
+ noIdUpdate: true // To prevent missing notices on next pull.
+ })
+ }
+ return data
+ })
+ .catch((err) => {
+ console.error('Error editing status', err)
+ return {
+ error: err.message
+ }
+ })
+}
+
const uploadMedia = ({ store, formData }) => {
const credentials = store.state.users.currentUser.credentials
return apiService.uploadMedia({ credentials, formData })
@@ -59,6 +100,7 @@ const setMediaDescription = ({ store, id, description }) => {
const statusPosterService = {
postStatus,
+ editStatus,
uploadMedia,
setMediaDescription
}
diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js
index f75e6916..d6e973a1 100644
--- a/src/services/style_setter/style_setter.js
+++ b/src/services/style_setter/style_setter.js
@@ -1,6 +1,7 @@
import { convert } from 'chromatism'
import { rgb2hex, hex2rgb, rgba2css, getCssColor, relativeLuminance } from '../color_convert/color_convert.js'
import { getColors, computeDynamicColor, getOpacitySlot } from '../theme_data/theme_data.service.js'
+import { defaultState } from '../../modules/config.js'
export const applyTheme = (input) => {
const { rules } = generatePreset(input)
@@ -13,10 +14,40 @@ export const applyTheme = (input) => {
const styleSheet = styleEl.sheet
styleSheet.toString()
- styleSheet.insertRule(`body { ${rules.radii} }`, 'index-max')
- styleSheet.insertRule(`body { ${rules.colors} }`, 'index-max')
- styleSheet.insertRule(`body { ${rules.shadows} }`, 'index-max')
- styleSheet.insertRule(`body { ${rules.fonts} }`, 'index-max')
+ styleSheet.insertRule(`:root { ${rules.radii} }`, 'index-max')
+ styleSheet.insertRule(`:root { ${rules.colors} }`, 'index-max')
+ styleSheet.insertRule(`:root { ${rules.shadows} }`, 'index-max')
+ styleSheet.insertRule(`:root { ${rules.fonts} }`, 'index-max')
+ body.classList.remove('hidden')
+}
+
+const configColumns = ({ sidebarColumnWidth, contentColumnWidth, notifsColumnWidth }) =>
+ ({ sidebarColumnWidth, contentColumnWidth, notifsColumnWidth })
+
+const defaultConfigColumns = configColumns(defaultState)
+
+export const applyConfig = (config) => {
+ const columns = configColumns(config)
+
+ if (columns === defaultConfigColumns) {
+ return
+ }
+
+ const head = document.head
+ const body = document.body
+ body.classList.add('hidden')
+
+ const rules = Object
+ .entries(columns)
+ .filter(([k, v]) => v)
+ .map(([k, v]) => `--${k}: ${v}`).join(';')
+
+ const styleEl = document.createElement('style')
+ head.appendChild(styleEl)
+ const styleSheet = styleEl.sheet
+
+ styleSheet.toString()
+ styleSheet.insertRule(`:root { ${rules} }`, 'index-max')
body.classList.remove('hidden')
}
diff --git a/src/services/theme_data/pleromafe.js b/src/services/theme_data/pleromafe.js
index c2983be7..dc7a5d89 100644
--- a/src/services/theme_data/pleromafe.js
+++ b/src/services/theme_data/pleromafe.js
@@ -709,6 +709,14 @@ export const SLOT_INHERITANCE = {
textColor: 'bw'
},
+ badgeNeutral: '--cGreen',
+ badgeNeutralText: {
+ depends: ['text', 'badgeNeutral'],
+ layer: 'badge',
+ variant: 'badgeNeutral',
+ textColor: 'bw'
+ },
+
chatBg: {
depends: ['bg']
},
diff --git a/src/services/theme_data/theme_data.service.js b/src/services/theme_data/theme_data.service.js
index b619f810..b376ef4d 100644
--- a/src/services/theme_data/theme_data.service.js
+++ b/src/services/theme_data/theme_data.service.js
@@ -39,7 +39,7 @@ import { LAYERS, DEFAULT_OPACITY, SLOT_INHERITANCE } from './pleromafe.js'
export const CURRENT_VERSION = 3
export const getLayersArray = (layer, data = LAYERS) => {
- let array = [layer]
+ const array = [layer]
let parent = data[layer]
while (parent) {
array.unshift(parent)
@@ -138,6 +138,7 @@ export const topoSort = (
if (depsA === depsB || (depsB !== 0 && depsA !== 0)) return ai - bi
if (depsA === 0 && depsB !== 0) return -1
if (depsB === 0 && depsA !== 0) return 1
+ return 0 // failsafe, shouldn't happen?
}).map(({ data }) => data)
}
diff --git a/src/services/timeline_fetcher/timeline_fetcher.service.js b/src/services/timeline_fetcher/timeline_fetcher.service.js
index 46bba41a..8501907e 100644
--- a/src/services/timeline_fetcher/timeline_fetcher.service.js
+++ b/src/services/timeline_fetcher/timeline_fetcher.service.js
@@ -3,12 +3,13 @@ import { camelCase } from 'lodash'
import apiService from '../api/api.service.js'
import { promiseInterval } from '../promise_interval/promise_interval.js'
-const update = ({ store, statuses, timeline, showImmediately, userId, pagination }) => {
+const update = ({ store, statuses, timeline, showImmediately, userId, listId, pagination }) => {
const ccTimeline = camelCase(timeline)
store.dispatch('addNewStatuses', {
timeline: ccTimeline,
userId,
+ listId,
statuses,
showImmediately,
pagination
@@ -22,6 +23,7 @@ const fetchAndUpdate = ({
older = false,
showImmediately = false,
userId = false,
+ listId = false,
tag = false,
until,
since
@@ -34,20 +36,21 @@ const fetchAndUpdate = ({
const loggedIn = !!rootState.users.currentUser
if (older) {
- args['until'] = until || timelineData.minId
+ args.until = until || timelineData.minId
} else {
if (since === undefined) {
- args['since'] = timelineData.maxId
+ args.since = timelineData.maxId
} else if (since !== null) {
- args['since'] = since
+ args.since = since
}
}
- args['userId'] = userId
- args['tag'] = tag
- args['withMuted'] = !hideMutedPosts
+ args.userId = userId
+ args.listId = listId
+ args.tag = tag
+ args.withMuted = !hideMutedPosts
if (loggedIn && ['friends', 'public', 'publicAndExternal'].includes(timeline)) {
- args['replyVisibility'] = replyVisibility
+ args.replyVisibility = replyVisibility
}
const numStatusesBeforeFetch = timelineData.statuses.length
@@ -60,9 +63,9 @@ const fetchAndUpdate = ({
const { data: statuses, pagination } = response
if (!older && statuses.length >= 20 && !timelineData.loading && numStatusesBeforeFetch > 0) {
- store.dispatch('queueFlush', { timeline: timeline, id: timelineData.maxId })
+ store.dispatch('queueFlush', { timeline, id: timelineData.maxId })
}
- update({ store, statuses, timeline, showImmediately, userId, pagination })
+ update({ store, statuses, timeline, showImmediately, userId, listId, pagination })
return { statuses, pagination }
})
.catch((error) => {
@@ -75,14 +78,15 @@ const fetchAndUpdate = ({
})
}
-const startFetching = ({ timeline = 'friends', credentials, store, userId = false, tag = false }) => {
+const startFetching = ({ timeline = 'friends', credentials, store, userId = false, listId = false, tag = false }) => {
const rootState = store.rootState || store.state
const timelineData = rootState.statuses.timelines[camelCase(timeline)]
const showImmediately = timelineData.visibleStatuses.length === 0
timelineData.userId = userId
- fetchAndUpdate({ timeline, credentials, store, showImmediately, userId, tag })
+ timelineData.listId = listId
+ fetchAndUpdate({ timeline, credentials, store, showImmediately, userId, listId, tag })
const boundFetchAndUpdate = () =>
- fetchAndUpdate({ timeline, credentials, store, userId, tag })
+ fetchAndUpdate({ timeline, credentials, store, userId, listId, tag })
return promiseInterval(boundFetchAndUpdate, 10000)
}
const timelineFetcher = {
diff --git a/src/services/user_highlighter/user_highlighter.js b/src/services/user_highlighter/user_highlighter.js
index 3b07592e..b5f58040 100644
--- a/src/services/user_highlighter/user_highlighter.js
+++ b/src/services/user_highlighter/user_highlighter.js
@@ -36,7 +36,7 @@ const highlightStyle = (prefs) => {
'linear-gradient(to right,',
`${solidColor} ,`,
`${solidColor} 2px,`,
- `transparent 6px`
+ 'transparent 6px'
].join(' '),
backgroundPosition: '0 0',
...customProps
diff --git a/src/sw.js b/src/sw.js
index f5e34dd6..70fed44b 100644
--- a/src/sw.js
+++ b/src/sw.js
@@ -3,12 +3,10 @@
import localForage from 'localforage'
import { parseNotification } from './services/entity_normalizer/entity_normalizer.service.js'
import { prepareNotificationObject } from './services/notification_utils/notification_utils.js'
-import Vue from 'vue'
-import VueI18n from 'vue-i18n'
+import { createI18n } from 'vue-i18n'
import messages from './i18n/service_worker_messages.js'
-Vue.use(VueI18n)
-const i18n = new VueI18n({
+const i18n = createI18n({
// By default, use the browser locale, we will update it if neccessary
locale: 'en',
fallbackLocale: 'en',
@@ -59,8 +57,8 @@ self.addEventListener('notificationclick', (event) => {
event.notification.close()
event.waitUntil(getWindowClients().then((list) => {
- for (var i = 0; i < list.length; i++) {
- var client = list[i]
+ for (let i = 0; i < list.length; i++) {
+ const client = list[i]
if (client.url === '/' && 'focus' in client) { return client.focus() }
}