aboutsummaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/about/about.vue3
-rw-r--r--src/components/account_actions/account_actions.js7
-rw-r--r--src/components/account_actions/account_actions.vue11
-rw-r--r--src/components/announcement/announcement.js108
-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.js58
-rw-r--r--src/components/announcements_page/announcements_page.vue80
-rw-r--r--src/components/async_component_error/async_component_error.vue5
-rw-r--r--src/components/attachment/attachment.js7
-rw-r--r--src/components/attachment/attachment.scss47
-rw-r--r--src/components/attachment/attachment.vue5
-rw-r--r--src/components/autosuggest/autosuggest.vue4
-rw-r--r--src/components/avatar_list/avatar_list.vue2
-rw-r--r--src/components/basic_user_card/basic_user_card.js4
-rw-r--r--src/components/basic_user_card/basic_user_card.vue10
-rw-r--r--src/components/block_card/block_card.vue1
-rw-r--r--src/components/chat/chat.js10
-rw-r--r--src/components/chat/chat.scss4
-rw-r--r--src/components/chat/chat.vue4
-rw-r--r--src/components/chat_list/chat_list.vue2
-rw-r--r--src/components/chat_list_item/chat_list_item.scss9
-rw-r--r--src/components/chat_list_item/chat_list_item.vue4
-rw-r--r--src/components/chat_message/chat_message.scss94
-rw-r--r--src/components/chat_message/chat_message.vue4
-rw-r--r--src/components/chat_new/chat_new.scss2
-rw-r--r--src/components/chat_new/chat_new.vue4
-rw-r--r--src/components/chat_title/chat_title.vue2
-rw-r--r--src/components/checkbox/checkbox.vue16
-rw-r--r--src/components/color_input/color_input.scss27
-rw-r--r--src/components/contrast_ratio/contrast_ratio.vue1
-rw-r--r--src/components/conversation/conversation.js22
-rw-r--r--src/components/conversation/conversation.vue99
-rw-r--r--src/components/desktop_nav/desktop_nav.scss35
-rw-r--r--src/components/desktop_nav/desktop_nav.vue5
-rw-r--r--src/components/dialog_modal/dialog_modal.vue10
-rw-r--r--src/components/edit_status_modal/edit_status_modal.js75
-rw-r--r--src/components/edit_status_modal/edit_status_modal.vue49
-rw-r--r--src/components/emoji_input/emoji_input.js196
-rw-r--r--src/components/emoji_input/emoji_input.vue228
-rw-r--r--src/components/emoji_input/suggestor.js45
-rw-r--r--src/components/emoji_picker/emoji_picker.js341
-rw-r--r--src/components/emoji_picker/emoji_picker.scss88
-rw-r--r--src/components/emoji_picker/emoji_picker.vue213
-rw-r--r--src/components/emoji_reactions/emoji_reactions.vue87
-rw-r--r--src/components/extra_buttons/extra_buttons.js49
-rw-r--r--src/components/extra_buttons/extra_buttons.vue71
-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/flash/flash.vue5
-rw-r--r--src/components/follow_card/follow_card.js4
-rw-r--r--src/components/follow_card/follow_card.vue14
-rw-r--r--src/components/follow_request_card/follow_request_card.vue4
-rw-r--r--src/components/font_control/font_control.vue5
-rw-r--r--src/components/gallery/gallery.js1
-rw-r--r--src/components/gallery/gallery.vue101
-rw-r--r--src/components/global_notice_list/global_notice_list.vue9
-rw-r--r--src/components/interactions/interactions.js5
-rw-r--r--src/components/interactions/interactions.vue9
-rw-r--r--src/components/link-preview/link-preview.vue5
-rw-r--r--src/components/list/list.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.vue52
-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.vue7
-rw-r--r--src/components/media_modal/media_modal.js5
-rw-r--r--src/components/media_modal/media_modal.vue58
-rw-r--r--src/components/media_upload/media_upload.vue2
-rw-r--r--src/components/mention_link/mention_link.js2
-rw-r--r--src/components/mention_link/mention_link.scss7
-rw-r--r--src/components/mention_link/mention_link.vue3
-rw-r--r--src/components/mentions_line/mentions_line.scss2
-rw-r--r--src/components/mobile_nav/mobile_nav.js32
-rw-r--r--src/components/mobile_nav/mobile_nav.vue73
-rw-r--r--src/components/mobile_post_status_button/mobile_post_status_button.js3
-rw-r--r--src/components/mobile_post_status_button/mobile_post_status_button.vue6
-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.vue16
-rw-r--r--src/components/mrf_transparency_panel/mrf_transparency_panel.scss14
-rw-r--r--src/components/mrf_transparency_panel/mrf_transparency_panel.vue4
-rw-r--r--src/components/mute_card/mute_card.vue1
-rw-r--r--src/components/nav_panel/nav_panel.js87
-rw-r--r--src/components/nav_panel/nav_panel.vue220
-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.vue135
-rw-r--r--src/components/navigation/navigation_pins.js94
-rw-r--r--src/components/navigation/navigation_pins.vue75
-rw-r--r--src/components/notification/notification.js20
-rw-r--r--src/components/notification/notification.scss17
-rw-r--r--src/components/notification/notification.vue65
-rw-r--r--src/components/notifications/notification_filters.vue19
-rw-r--r--src/components/notifications/notifications.js46
-rw-r--r--src/components/notifications/notifications.scss25
-rw-r--r--src/components/notifications/notifications.vue34
-rw-r--r--src/components/optional_router_link/optional_router_link.vue23
-rw-r--r--src/components/panel_loading/panel_loading.vue3
-rw-r--r--src/components/password_reset/password_reset.vue2
-rw-r--r--src/components/poll/poll.vue12
-rw-r--r--src/components/poll/poll_form.vue3
-rw-r--r--src/components/popover/popover.js49
-rw-r--r--src/components/popover/popover.vue50
-rw-r--r--src/components/post_status_form/post_status_form.js57
-rw-r--r--src/components/post_status_form/post_status_form.vue85
-rw-r--r--src/components/quick_filter_settings/quick_filter_settings.js (renamed from src/components/timeline/timeline_quick_settings.js)7
-rw-r--r--src/components/quick_filter_settings/quick_filter_settings.vue (renamed from src/components/timeline/timeline_quick_settings.vue)39
-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/react_button/react_button.js94
-rw-r--r--src/components/react_button/react_button.vue65
-rw-r--r--src/components/registration/registration.vue10
-rw-r--r--src/components/remote_user_resolver/remote_user_resolver.vue3
-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.vue48
-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.jsx15
-rw-r--r--src/components/rich_content/rich_content.scss10
-rw-r--r--src/components/scope_selector/scope_selector.vue3
-rw-r--r--src/components/search/search.js39
-rw-r--r--src/components/search/search.vue44
-rw-r--r--src/components/search_bar/search_bar.vue4
-rw-r--r--src/components/select/select.vue7
-rw-r--r--src/components/selectable_list/selectable_list.vue3
-rw-r--r--src/components/settings_modal/helpers/boolean_setting.js9
-rw-r--r--src/components/settings_modal/helpers/boolean_setting.vue7
-rw-r--r--src/components/settings_modal/helpers/choice_setting.js3
-rw-r--r--src/components/settings_modal/helpers/choice_setting.vue10
-rw-r--r--src/components/settings_modal/helpers/integer_setting.js3
-rw-r--r--src/components/settings_modal/helpers/integer_setting.vue5
-rw-r--r--src/components/settings_modal/helpers/size_setting.js67
-rw-r--r--src/components/settings_modal/helpers/size_setting.vue55
-rw-r--r--src/components/settings_modal/settings_modal.scss51
-rw-r--r--src/components/settings_modal/settings_modal_content.scss8
-rw-r--r--src/components/settings_modal/tabs/data_import_export_tab.vue10
-rw-r--r--src/components/settings_modal/tabs/filtering_tab.js18
-rw-r--r--src/components/settings_modal/tabs/general_tab.js21
-rw-r--r--src/components/settings_modal/tabs/general_tab.vue96
-rw-r--r--src/components/settings_modal/tabs/mutes_and_blocks_tab.scss44
-rw-r--r--src/components/settings_modal/tabs/mutes_and_blocks_tab.vue2
-rw-r--r--src/components/settings_modal/tabs/profile_tab.js6
-rw-r--r--src/components/settings_modal/tabs/profile_tab.scss7
-rw-r--r--src/components/settings_modal/tabs/security_tab/mfa.vue11
-rw-r--r--src/components/settings_modal/tabs/security_tab/mfa_backup_codes.vue3
-rw-r--r--src/components/settings_modal/tabs/security_tab/security_tab.vue2
-rw-r--r--src/components/settings_modal/tabs/theme_tab/preview.vue15
-rw-r--r--src/components/settings_modal/tabs/theme_tab/theme_tab.js4
-rw-r--r--src/components/settings_modal/tabs/theme_tab/theme_tab.scss120
-rw-r--r--src/components/shadow_control/shadow_control.vue40
-rw-r--r--src/components/shout_panel/shout_panel.vue2
-rw-r--r--src/components/side_drawer/side_drawer.js24
-rw-r--r--src/components/side_drawer/side_drawer.vue64
-rw-r--r--src/components/staff_panel/staff_panel.js4
-rw-r--r--src/components/staff_panel/staff_panel.vue1
-rw-r--r--src/components/status/status.js10
-rw-r--r--src/components/status/status.scss13
-rw-r--r--src/components/status/status.vue40
-rw-r--r--src/components/status_body/status_body.scss13
-rw-r--r--src/components/status_content/status_content.vue1
-rw-r--r--src/components/status_history_modal/status_history_modal.js60
-rw-r--r--src/components/status_history_modal/status_history_modal.vue47
-rw-r--r--src/components/status_popover/status_popover.vue3
-rw-r--r--src/components/sticker_picker/sticker_picker.vue7
-rw-r--r--src/components/still-image/still-image.js28
-rw-r--r--src/components/still-image/still-image.vue12
-rw-r--r--src/components/swipe_click/swipe_click.js7
-rw-r--r--src/components/tab_switcher/tab_switcher.scss51
-rw-r--r--src/components/terms_of_service_panel/terms_of_service_panel.vue2
-rw-r--r--src/components/thread_tree/thread_tree.vue8
-rw-r--r--src/components/timeago/timeago.vue21
-rw-r--r--src/components/timeline/timeline.js35
-rw-r--r--src/components/timeline/timeline.scss38
-rw-r--r--src/components/timeline/timeline.vue92
-rw-r--r--src/components/timeline_menu/timeline_menu.js34
-rw-r--r--src/components/timeline_menu/timeline_menu.vue150
-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.scss115
-rw-r--r--src/components/update_notification/update_notification.vue103
-rw-r--r--src/components/user_avatar/user_avatar.vue5
-rw-r--r--src/components/user_card/user_card.js19
-rw-r--r--src/components/user_card/user_card.scss107
-rw-r--r--src/components/user_card/user_card.vue17
-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.js2
-rw-r--r--src/components/user_list_popover/user_list_popover.vue4
-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.vue4
-rw-r--r--src/components/user_popover/user_popover.js4
-rw-r--r--src/components/user_popover/user_popover.vue6
-rw-r--r--src/components/user_profile/user_profile.js15
-rw-r--r--src/components/user_profile/user_profile.vue7
-rw-r--r--src/components/user_reporting_modal/user_reporting_modal.js16
-rw-r--r--src/components/user_reporting_modal/user_reporting_modal.vue14
-rw-r--r--src/components/who_to_follow/who_to_follow.vue3
-rw-r--r--src/components/who_to_follow_panel/who_to_follow_panel.vue16
218 files changed, 6176 insertions, 1787 deletions
diff --git a/src/components/about/about.vue b/src/components/about/about.vue
index 33586c97..8a551485 100644
--- a/src/components/about/about.vue
+++ b/src/components/about/about.vue
@@ -9,6 +9,3 @@
</template>
<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 99762562..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,6 +36,9 @@ 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 })
},
diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue
index 23547f2c..973a5935 100644
--- a/src/components/account_actions/account_actions.vue
+++ b/src/components/account_actions/account_actions.vue
@@ -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"
@@ -72,7 +80,8 @@
<script src="./account_actions.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
+
.AccountActions {
.ellipsis-button {
width: 2.5em;
diff --git a/src/components/announcement/announcement.js b/src/components/announcement/announcement.js
new file mode 100644
index 00000000..30254926
--- /dev/null
+++ b/src/components/announcement/announcement.js
@@ -0,0 +1,108 @@
+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
+ }),
+ canEditAnnouncement () {
+ return this.currentUser && this.currentUser.privileges.includes('announcements_manage_announcements')
+ },
+ 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..a1c5791e
--- /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="canEditAnnouncement"
+ class="btn button-default"
+ @click="enterEditMode"
+ >
+ {{ $t('announcements.edit_action') }}
+ </button>
+ <button
+ v-if="canEditAnnouncement"
+ 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: 1px solid 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..8d1204d4
--- /dev/null
+++ b/src/components/announcements_page/announcements_page.js
@@ -0,0 +1,58 @@
+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
+ },
+ canPostAnnouncement () {
+ return this.currentUser && this.currentUser.privileges.includes('announcements_manage_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..78d3ecee
--- /dev/null
+++ b/src/components/announcements_page/announcements_page.vue
@@ -0,0 +1,80 @@
+<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="canPostAnnouncement"
+ >
+ <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 26ab5d21..2ff8974c 100644
--- a/src/components/async_component_error/async_component_error.vue
+++ b/src/components/async_component_error/async_component_error.vue
@@ -34,9 +34,10 @@ export default {
height: 100%;
align-items: center;
justify-content: center;
+
.btn {
- margin: .5em;
- padding: .5em 2em;
+ margin: 0.5em;
+ padding: 0.5em 2em;
}
}
</style>
diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js
index d62a4adc..6e14b24d 100644
--- a/src/components/attachment/attachment.js
+++ b/src/components/attachment/attachment.js
@@ -36,6 +36,7 @@ library.add(
const Attachment = {
props: [
'attachment',
+ 'compact',
'description',
'hideDescription',
'nsfw',
@@ -71,7 +72,8 @@ const Attachment = {
{
'-loading': this.loading,
'-nsfw-placeholder': this.hidden,
- '-editable': this.edit !== undefined
+ '-editable': this.edit !== undefined,
+ '-compact': this.compact
},
'-type-' + this.type,
this.size && '-size-' + this.size,
@@ -129,6 +131,9 @@ const Attachment = {
...mapGetters(['mergedConfig'])
},
watch: {
+ 'attachment.description' (newVal) {
+ this.localDescription = newVal
+ },
localDescription (newVal) {
this.onEdit(newVal)
}
diff --git a/src/components/attachment/attachment.scss b/src/components/attachment/attachment.scss
index b2dea98d..681bab29 100644
--- a/src/components/attachment/attachment.scss
+++ b/src/components/attachment/attachment.scss
@@ -1,4 +1,4 @@
-@import '../../_variables.scss';
+@import "../../variables";
.Attachment {
display: inline-flex;
@@ -102,14 +102,13 @@
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);
+ color: rgb(255 255 255 / 75%);
+ text-shadow: 0 0 2px rgb(0 0 0 / 40%);
&::before {
margin: 0;
@@ -135,18 +134,32 @@
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);
+ background: rgb(230 230 230 / 70%);
.svg-inline--fa {
- color: rgba(0, 0, 0, 0.6);
+ color: rgb(0 0 0 / 60%);
}
&:hover .svg-inline--fa {
- color: rgba(0, 0, 0, 0.9);
+ color: rgb(0 0 0 / 90%);
}
}
}
+ &.-contain-fit {
+ img,
+ canvas {
+ object-fit: contain;
+ }
+ }
+
+ &.-cover-fit {
+ img,
+ canvas {
+ object-fit: cover;
+ }
+ }
+
.oembed-container {
line-height: 1.2em;
flex: 1 0 100%;
@@ -160,8 +173,9 @@
.image {
flex: 1;
+
img {
- border: 0px;
+ border: 0;
border-radius: 5px;
height: 100%;
object-fit: cover;
@@ -172,9 +186,10 @@
flex: 2;
margin: 8px;
word-break: break-all;
+
h1 {
font-size: 1rem;
- margin: 0px;
+ margin: 0;
}
}
}
@@ -252,17 +267,9 @@
cursor: progress;
}
- &.-contain-fit {
- img,
- canvas {
- object-fit: contain;
- }
- }
-
- &.-cover-fit {
- img,
- canvas {
- object-fit: cover;
+ &.-compact {
+ .placeholder-container {
+ padding-bottom: 0.5em;
}
}
}
diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue
index 2a89886d..79f62806 100644
--- a/src/components/attachment/attachment.vue
+++ b/src/components/attachment/attachment.vue
@@ -162,10 +162,11 @@
target="_blank"
>
<FAIcon
- size="5x"
+ :size="compact ? '2x' : '5x'"
:icon="placeholderIconClass"
+ :title="localDescription"
/>
- <p>
+ <p v-if="!compact">
{{ localDescription }}
</p>
</a>
diff --git a/src/components/autosuggest/autosuggest.vue b/src/components/autosuggest/autosuggest.vue
index f283ab82..7b7102fd 100644
--- a/src/components/autosuggest/autosuggest.vue
+++ b/src/components/autosuggest/autosuggest.vue
@@ -24,7 +24,7 @@
<script src="./autosuggest.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.autosuggest {
position: relative;
@@ -50,7 +50,7 @@
border-radius: var(--inputRadius, $fallback--inputRadius);
border-top-left-radius: 0;
border-top-right-radius: 0;
- box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6);
+ box-shadow: 1px 1px 4px rgb(0 0 0 / 60%);
box-shadow: var(--panelShadow);
overflow-y: auto;
z-index: 1;
diff --git a/src/components/avatar_list/avatar_list.vue b/src/components/avatar_list/avatar_list.vue
index 9a6ca3f6..2d00cb7b 100644
--- a/src/components/avatar_list/avatar_list.vue
+++ b/src/components/avatar_list/avatar_list.vue
@@ -17,7 +17,7 @@
<script src="./avatar_list.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.avatars {
display: flex;
diff --git a/src/components/basic_user_card/basic_user_card.js b/src/components/basic_user_card/basic_user_card.js
index 8b1a2c38..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 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'
@@ -10,7 +11,8 @@ const BasicUserCard = {
components: {
UserPopover,
UserAvatar,
- RichContent
+ RichContent,
+ UserLink
},
methods: {
userProfileLink (user) {
diff --git a/src/components/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue
index 9cca7840..705e20f5 100644
--- a/src/components/basic_user_card/basic_user_card.vue
+++ b/src/components/basic_user_card/basic_user_card.vue
@@ -30,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>
@@ -51,7 +49,7 @@
margin: 0;
padding: 0.6em 1em;
- --emoji-size: 14px;
+ --emoji-size: 14px;
&-collapsed-content {
margin-left: 0.7em;
diff --git a/src/components/block_card/block_card.vue b/src/components/block_card/block_card.vue
index 2fe66d4c..b14ef844 100644
--- a/src/components/block_card/block_card.vue
+++ b/src/components/block_card/block_card.vue
@@ -37,6 +37,7 @@
.block-card-content-container {
margin-top: 0.5em;
text-align: right;
+
button {
width: 10em;
}
diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js
index 5a5c37b6..79f24771 100644
--- a/src/components/chat/chat.js
+++ b/src/components/chat/chat.js
@@ -57,6 +57,7 @@ const Chat = {
},
unmounted () {
window.removeEventListener('scroll', this.handleScroll)
+ window.removeEventListener('resize', this.handleResize)
if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false)
this.$store.dispatch('clearCurrentChat')
},
@@ -135,7 +136,7 @@ const Chat = {
},
// "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(() => {
@@ -146,10 +147,10 @@ const Chat = {
this.$nextTick(() => {
const { offsetHeight = undefined } = getScrollPosition()
- const diff = this.lastScrollPosition.offsetHeight - offsetHeight
- if (diff !== 0 || (!this.bottomedOut() && expand)) {
+ const diff = offsetHeight - this.lastScrollPosition.offsetHeight
+ if (diff !== 0 && !this.bottomedOut()) {
this.$nextTick(() => {
- window.scrollTo({ top: window.scrollY + diff })
+ window.scrollBy({ top: -Math.trunc(diff) })
})
}
this.lastScrollPosition = getScrollPosition()
@@ -187,6 +188,7 @@ const Chat = {
}, 5000)
},
handleScroll: _.throttle(function () {
+ this.lastScrollPosition = getScrollPosition()
if (!this.currentChat) { return }
if (this.reachedTop()) {
diff --git a/src/components/chat/chat.scss b/src/components/chat/chat.scss
index f2e154ab..43e7a5e4 100644
--- a/src/components/chat/chat.scss
+++ b/src/components/chat/chat.scss
@@ -17,7 +17,7 @@
width: 100%;
overflow: visible;
min-height: calc(100vh - var(--navbar-height));
- margin: 0 0 0 0;
+ margin: 0;
border-radius: 10px 10px 0 0;
border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0;
@@ -66,7 +66,7 @@
display: flex;
justify-content: center;
align-items: center;
- box-shadow: 0 1px 1px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.3);
+ box-shadow: 0 1px 1px rgb(0 0 0 / 30%), 0 2px 4px rgb(0 0 0 / 30%);
z-index: 10;
transition: 0.35s all;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
diff --git a/src/components/chat/chat.vue b/src/components/chat/chat.vue
index 2e7df7bd..b1e5468c 100644
--- a/src/components/chat/chat.vue
+++ b/src/components/chat/chat.vue
@@ -95,6 +95,6 @@
<script src="./chat.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
-@import './chat.scss';
+@import "../../variables";
+@import "./chat";
</style>
diff --git a/src/components/chat_list/chat_list.vue b/src/components/chat_list/chat_list.vue
index 1248c4c8..27a475ed 100644
--- a/src/components/chat_list/chat_list.vue
+++ b/src/components/chat_list/chat_list.vue
@@ -45,7 +45,7 @@
<script src="./chat_list.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.chat-list {
min-height: 25em;
diff --git a/src/components/chat_list_item/chat_list_item.scss b/src/components/chat_list_item/chat_list_item.scss
index c6b45c34..3a84672b 100644
--- a/src/components/chat_list_item/chat_list_item.scss
+++ b/src/components/chat_list_item/chat_list_item.scss
@@ -13,7 +13,7 @@
&:hover {
background-color: var(--selectedPost, $fallback--lightBg);
- box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.1);
+ box-shadow: 0 0 3px 1px rgb(0 0 0 / 10%);
}
.chat-list-item-left {
@@ -67,6 +67,7 @@
canvas {
display: none;
}
+
img {
visibility: visible;
}
@@ -79,13 +80,11 @@
.chat-preview-body {
--emoji-size: 1.4em;
+
+ padding-right: 1em;
}
.time-wrapper {
line-height: var(--post-line-height);
}
-
- .chat-preview-body {
- padding-right: 1em;
- }
}
diff --git a/src/components/chat_list_item/chat_list_item.vue b/src/components/chat_list_item/chat_list_item.vue
index c7c0e878..69ad609b 100644
--- a/src/components/chat_list_item/chat_list_item.vue
+++ b/src/components/chat_list_item/chat_list_item.vue
@@ -48,6 +48,6 @@
<script src="./chat_list_item.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
-@import './chat_list_item.scss';
+@import "../../variables";
+@import "./chat_list_item";
</style>
diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss
index 1913479f..fd5b7aa4 100644
--- a/src/components/chat_message/chat_message.scss
+++ b/src/components/chat_message/chat_message.scss
@@ -1,12 +1,12 @@
-@import '../../_variables.scss';
+@import "../../variables";
.chat-message-wrapper {
-
&.hovered-message-chain {
.animated.Avatar {
canvas {
display: none;
}
+
img {
visibility: visible;
}
@@ -28,7 +28,8 @@
.menu-icon {
cursor: pointer;
- &:hover, .extra-button-popover.open & {
+ &:hover,
+ .extra-button-popover.open & {
color: $fallback--text;
color: var(--text, $fallback--text);
}
@@ -54,27 +55,11 @@
width: 32px;
}
- .link-preview, .attachments {
+ .link-preview,
+ .attachments {
margin-bottom: 1em;
}
- .chat-message-inner {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- max-width: 80%;
- min-width: 10em;
- width: 100%;
-
- &.with-media {
- width: 100%;
-
- .status {
- width: 100%;
- }
- }
- }
-
.status {
border-radius: $fallback--chatMessageRadius;
border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius);
@@ -86,7 +71,7 @@
position: relative;
float: right;
font-size: 0.8em;
- margin: -1em 0 -0.5em 0;
+ margin: -1em 0 -0.5em;
font-style: italic;
opacity: 0.8;
}
@@ -103,18 +88,54 @@
}
.pending {
- .status-content.media-body, .created-at {
+ .status-content.media-body,
+ .created-at {
color: var(--faint);
}
}
.error {
- .status-content.media-body, .created-at {
+ .status-content.media-body,
+ .created-at {
color: $fallback--cRed;
color: var(--badgeNotification, $fallback--cRed);
}
}
+ .chat-message-inner {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ max-width: 80%;
+ min-width: 10em;
+ width: 100%;
+ }
+
+ .outgoing {
+ display: flex;
+ flex-flow: row wrap;
+ align-content: end;
+ justify-content: flex-end;
+
+ a {
+ color: var(--chatMessageOutgoingLink, $fallback--link);
+ }
+
+ .status {
+ color: var(--chatMessageOutgoingText, $fallback--text);
+ background-color: var(--chatMessageOutgoingBg, $fallback--lightBg);
+ border: 1px solid var(--chatMessageOutgoingBorder, --lightBg);
+ }
+
+ .chat-message-inner {
+ align-items: flex-end;
+ }
+
+ .chat-message-menu {
+ right: 0.4rem;
+ }
+ }
+
.incoming {
a {
color: var(--chatMessageIncomingLink, $fallback--link);
@@ -137,36 +158,17 @@
}
}
- .outgoing {
- display: flex;
- flex-direction: row;
- flex-wrap: wrap;
- align-content: end;
- justify-content: flex-end;
-
- a {
- color: var(--chatMessageOutgoingLink, $fallback--link);
- }
+ .chat-message-inner.with-media {
+ width: 100%;
.status {
- color: var(--chatMessageOutgoingText, $fallback--text);
- background-color: var(--chatMessageOutgoingBg, $fallback--lightBg);
- border: 1px solid var(--chatMessageOutgoingBorder, --lightBg);
- }
-
- .chat-message-inner {
- align-items: flex-end;
- }
-
- .chat-message-menu {
- right: 0.4rem;
+ width: 100%;
}
}
.visible {
opacity: 1;
}
-
}
.chat-message-date-separator {
diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue
index d635c47e..381574c3 100644
--- a/src/components/chat_message/chat_message.vue
+++ b/src/components/chat_message/chat_message.vue
@@ -33,7 +33,7 @@
<div
class="media status"
:class="{ 'without-attachment': !hasAttachment, 'pending': chatViewItem.data.pending, 'error': chatViewItem.data.error }"
- style="position: relative"
+ style="position: relative;"
@mouseenter="hovered = true"
@mouseleave="hovered = false"
>
@@ -98,6 +98,6 @@
<script src="./chat_message.js"></script>
<style lang="scss">
-@import './chat_message.scss';
+@import "./chat_message";
</style>
diff --git a/src/components/chat_new/chat_new.scss b/src/components/chat_new/chat_new.scss
index 240e1a38..b145ecf9 100644
--- a/src/components/chat_new/chat_new.scss
+++ b/src/components/chat_new/chat_new.scss
@@ -1,7 +1,7 @@
.chat-new {
.input-wrap {
display: flex;
- margin: 0.7em 0.5em 0.7em 0.5em;
+ margin: 0.7em 0.5em;
input {
width: 100%;
diff --git a/src/components/chat_new/chat_new.vue b/src/components/chat_new/chat_new.vue
index bf09a379..52306c1d 100644
--- a/src/components/chat_new/chat_new.vue
+++ b/src/components/chat_new/chat_new.vue
@@ -46,6 +46,6 @@
<script src="./chat_new.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
-@import './chat_new.scss';
+@import "../../variables";
+@import "./chat_new";
</style>
diff --git a/src/components/chat_title/chat_title.vue b/src/components/chat_title/chat_title.vue
index ab7491fa..93db4fa7 100644
--- a/src/components/chat_title/chat_title.vue
+++ b/src/components/chat_title/chat_title.vue
@@ -26,7 +26,7 @@
<script src="./chat_title.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.chat-title {
display: flex;
diff --git a/src/components/checkbox/checkbox.vue b/src/components/checkbox/checkbox.vue
index b6768d67..7139d4fc 100644
--- a/src/components/checkbox/checkbox.vue
+++ b/src/components/checkbox/checkbox.vue
@@ -32,7 +32,7 @@ export default {
</script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.checkbox {
position: relative;
@@ -49,13 +49,13 @@ export default {
right: 0;
top: 0;
display: block;
- content: '✓';
+ content: "✓";
transition: color 200ms;
width: 1.1em;
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);
background-color: $fallback--fg;
background-color: var(--input, $fallback--fg);
@@ -71,15 +71,16 @@ export default {
&.disabled {
.checkbox-indicator::before,
.label {
- opacity: .5;
+ opacity: 0.5;
}
+
.label {
color: $fallback--faint;
color: var(--faint, $fallback--faint);
}
}
- input[type=checkbox] {
+ input[type="checkbox"] {
display: none;
&:checked + .checkbox-indicator::before {
@@ -88,15 +89,14 @@ export default {
}
&:indeterminate + .checkbox-indicator::before {
- content: '–';
+ content: "–";
color: $fallback--text;
color: var(--inputText, $fallback--text);
}
-
}
& > span {
- margin-left: .5em;
+ margin-left: 0.5em;
}
}
</style>
diff --git a/src/components/color_input/color_input.scss b/src/components/color_input/color_input.scss
index 8e9923cf..ca46199a 100644
--- a/src/components/color_input/color_input.scss
+++ b/src/components/color_input/color_input.scss
@@ -1,4 +1,4 @@
-@import '../../_variables.scss';
+@import "../../variables";
.color-input {
display: inline-flex;
@@ -8,7 +8,7 @@
flex: 0 0 0;
max-width: 9em;
align-items: stretch;
- padding: .2em 8px;
+ padding: 0.2em 8px;
input {
background: none;
@@ -27,33 +27,39 @@
&.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
- background-color: #FF00FF;
+ background-color: #f0f;
position: relative;
- &::before, &::after {
+
+ &::before,
+ &::after {
display: block;
- content: '';
- background-color: #000000;
+ content: "";
+ background-color: #000;
position: absolute;
height: 50%;
width: 50%;
}
+
&::after {
top: 0;
left: 0;
}
+
&::before {
bottom: 0;
right: 0;
@@ -64,5 +70,4 @@
.label {
flex: 1 1 auto;
}
-
}
diff --git a/src/components/contrast_ratio/contrast_ratio.vue b/src/components/contrast_ratio/contrast_ratio.vue
index 374cb9ba..bbd6fd4a 100644
--- a/src/components/contrast_ratio/contrast_ratio.vue
+++ b/src/components/contrast_ratio/contrast_ratio.vue
@@ -87,7 +87,6 @@ export default {
.contrast-ratio {
display: flex;
justify-content: flex-end;
-
margin-top: -4px;
margin-bottom: 5px;
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index 3b540cac..85e6d8ad 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,6 +1,10 @@
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 {
@@ -77,6 +81,9 @@ const conversation = {
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
},
@@ -339,11 +346,17 @@ const conversation = {
},
maybeHighlight () {
return this.isExpanded ? this.highlight : null
- }
+ },
+ ...mapGetters(['mergedConfig']),
+ ...mapState({
+ mastoUserSocketStatus: state => state.api.mastoUserSocketStatus
+ })
},
components: {
Status,
- ThreadTree
+ ThreadTree,
+ QuickFilterSettings,
+ QuickViewSettings
},
watch: {
statusId (newVal, oldVal) {
@@ -395,6 +408,11 @@ const conversation = {
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)
},
diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue
index 1adbe250..7577129e 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -17,6 +17,16 @@
>
{{ $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
@@ -50,7 +60,7 @@
v-if="shouldShowAncestors"
class="thread-ancestors"
>
- <div
+ <article
v-for="status in ancestorsOf(diveRoot)"
:key="status.id"
class="thread-ancestor"
@@ -120,7 +130,7 @@
</i18n-t>
</div>
</div>
- </div>
+ </article>
</div>
<thread-tree
v-for="status in showingTopLevel"
@@ -158,34 +168,36 @@
v-if="isLinearView"
class="thread-body"
>
- <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"
+ <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"
+ :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"
- />
+ @goto="setHighlight"
+ @toggleExpanded="toggleExpanded"
+ />
+ </article>
</div>
</div>
</div>
@@ -198,17 +210,16 @@
<script src="./conversation.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.Conversation {
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-bottom: 1px solid var(--border, $fallback--border);
border-radius: 0;
+
/* Make the button stretch along the whole row */
display: flex;
align-items: stretch;
@@ -223,52 +234,48 @@
.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-bottom: 1px solid var(--border, $fallback--border);
border-radius: 0;
+
/* Make the button stretch along the whole row */
- &, &-inner {
+ &,
+ &-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-bottom: 1px solid var(--border, $fallback--border);
border-radius: 0;
}
.thread-ancestor-has-other-replies .conversation-status,
+ &:last-child .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);
+ border-top: 1px solid 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: 4px solid $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);
diff --git a/src/components/desktop_nav/desktop_nav.scss b/src/components/desktop_nav/desktop_nav.scss
index 71202244..c7e02936 100644
--- a/src/components/desktop_nav/desktop_nav.scss
+++ b/src/components/desktop_nav/desktop_nav.scss
@@ -1,4 +1,4 @@
-@import '../../_variables.scss';
+@import "../../variables";
.DesktopNav {
width: 100%;
@@ -23,13 +23,37 @@
max-width: 980px;
}
+ &.-column-stretch .inner-nav {
+ --miniColumn: 25rem;
+ --maxiColumn: 45rem;
+ --columnGap: 1em;
+
+ max-width:
+ calc(
+ var(--sidebarColumnWidth, var(--miniColumn)) +
+ var(--contentColumnWidth, var(--maxiColumn)) +
+ var(--columnGap)
+ );
+ }
+
&.-logoLeft .inner-nav {
grid-template-columns: auto 2fr 2fr;
grid-template-areas: "logo sitename actions";
}
+ &.-column-stretch.-wide .inner-nav {
+ max-width:
+ calc(
+ var(--sidebarColumnWidth, var(--miniColumn)) +
+ var(--contentColumnWidth, var(--maxiColumn)) +
+ var(--notifsColumnWidth, var(--miniColumn)) +
+ var(--columnGap)
+ );
+ }
+
.button-default {
- &, svg {
+ &,
+ svg {
color: $fallback--text;
color: var(--btnTopBarText, $fallback--text);
}
@@ -50,7 +74,7 @@
color: $fallback--text;
color: var(--btnToggledTopBarText, $fallback--text);
background-color: $fallback--fg;
- background-color: var(--btnToggledTopBar, $fallback--fg)
+ background-color: var(--btnToggledTopBar, $fallback--fg);
}
}
@@ -62,6 +86,7 @@
transition-duration: 100ms;
@media all and (min-width: 800px) {
+ /* stylelint-disable-next-line declaration-no-important */
opacity: 1 !important;
}
@@ -117,4 +142,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 f352c78c..07bf8005 100644
--- a/src/components/desktop_nav/desktop_nav.vue
+++ b/src/components/desktop_nav/desktop_nav.vue
@@ -38,7 +38,7 @@
/>
<button
class="button-unstyled nav-icon"
- @click="openSettingsModal"
+ @click.stop="openSettingsModal"
>
<FAIcon
fixed-width
@@ -61,10 +61,11 @@
:title="$t('nav.administration')"
/>
</a>
+ <span class="spacer" />
<button
v-if="currentUser"
class="button-unstyled nav-icon"
- @click.prevent="logout"
+ @click.stop.prevent="logout"
>
<FAIcon
fixed-width
diff --git a/src/components/dialog_modal/dialog_modal.vue b/src/components/dialog_modal/dialog_modal.vue
index 06b270c3..24d65142 100644
--- a/src/components/dialog_modal/dialog_modal.vue
+++ b/src/components/dialog_modal/dialog_modal.vue
@@ -25,7 +25,7 @@
<script src="./dialog_modal.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
// TODO: unify with other modals.
.dark-overlay {
@@ -38,7 +38,7 @@
position: fixed;
right: 0;
top: 0;
- background: rgba(27,31,35,.5);
+ background: rgb(27 31 35 / 50%);
z-index: 99;
}
}
@@ -65,7 +65,7 @@
.dialog-modal-content {
margin: 0;
- padding: 1rem 1rem;
+ padding: 1rem;
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
white-space: normal;
@@ -73,7 +73,7 @@
.dialog-modal-footer {
margin: 0;
- padding: .5em .5em;
+ padding: 0.5em;
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
border-top: 1px solid $fallback--border;
@@ -83,7 +83,7 @@
button {
width: auto;
- margin-left: .5rem;
+ margin-left: 0.5rem;
}
}
}
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..db62972d
--- /dev/null
+++ b/src/components/edit_status_modal/edit_status_modal.vue
@@ -0,0 +1,49 @@
+<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 5ba3907f..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
@@ -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.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,46 +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: {
- handler (newValue) {
- this.$nextTick(this.resize)
- },
- deep: true
}
},
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
@@ -220,11 +296,12 @@ 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) {
@@ -261,7 +338,6 @@ const EmojiInput = {
spaceAfter,
after
].join('')
- this.keepOpen = keepOpen
this.$emit('update:modelValue', newValue)
const position = this.caret + (insertion + spaceAfter + spaceBefore).length
if (!keepOpen) {
@@ -361,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,
@@ -370,7 +449,6 @@ const EmojiInput = {
this.blurTimeout = setTimeout(() => {
this.focused = false
this.setCaret(e)
- this.resize()
}, 200)
},
onClick (e, suggestion) {
@@ -382,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
@@ -405,7 +478,6 @@ const EmojiInput = {
},
onPaste (e) {
this.setCaret(e)
- this.resize()
},
onKeyDown (e) {
const { ctrlKey, shiftKey, key } = e
@@ -450,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('update:modelValue', e.target.value)
},
- onClickInput (e) {
- this.showPicker = false
- },
- onClickOutside (e) {
- if (this.disableClickOutside) return
- this.showPicker = false
- },
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 7d95ab7e..ccba0393 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,66 +30,79 @@
<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>
<script src="./emoji_input.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.emoji-input {
display: flex;
flex-direction: column;
position: relative;
- &.with-picker input {
- padding-right: 30px;
- }
-
.emoji-picker-icon {
position: absolute;
top: 0;
right: 0;
- margin: .2em .25em;
+ margin: 0.2em 0.25em;
font-size: 1.3em;
cursor: pointer;
line-height: 24px;
@@ -87,99 +112,102 @@
color: var(--text, $fallback--text);
}
}
+
.emoji-picker-panel {
position: absolute;
z-index: 20;
margin-top: 2px;
&.hide {
- display: none
+ display: none;
}
}
- .autocomplete {
- &-panel {
- position: absolute;
- z-index: 20;
- margin-top: 2px;
+ input,
+ textarea {
+ flex: 1 0 auto;
+ }
- &.hide {
- display: none
- }
+ &.with-picker input {
+ padding-right: 30px;
+ }
- &-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;
}
+ }
+}
- &-item {
- display: flex;
- cursor: pointer;
- padding: 0.2em 0.4em;
- border-bottom: 1px solid rgba(0, 0, 0, 0.4);
+.autocomplete {
+ &-panel {
+ position: absolute;
+ }
+
+ &-item {
+ display: flex;
+ cursor: pointer;
+ padding: 0.2em 0.4em;
+ border-bottom: 1px solid rgb(0 0 0 / 40%);
+ height: 32px;
+
+ .image {
+ width: 32px;
height: 32px;
+ line-height: 32px;
+ text-align: center;
+ font-size: 32px;
+ margin-right: 4px;
- .image {
+ 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 f6920208..0d7ca812 100644
--- a/src/components/emoji_picker/emoji_picker.js
+++ b/src/components/emoji_picker/emoji_picker.js
@@ -1,33 +1,76 @@
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 { 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 { trim } from 'lodash'
+import { debounce, trim, chunk } 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()
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] = []
@@ -38,6 +81,17 @@ const filterByKeyword = (list, keyword = '') => {
return orderedEmojiList.flat()
}
+const getOffset = (elem) => {
+ const style = elem.style.transform
+ const res = /translateY\((\d+)px\)/.exec(style)
+ if (!res) { return 0 }
+ return res[1]
+}
+
+const toHeaderId = id => {
+ return id.replace(/^row-\d+-/, '')
+}
+
const EmojiPicker = {
props: {
enableStickerPicker: {
@@ -53,16 +107,41 @@ 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: [],
+ width: 0
}
},
components: {
StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')),
- Checkbox
+ 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 }
+ },
+ onPopoverShown () {
+ this.$emit('show')
+ },
+ onPopoverClosed () {
+ this.$emit('close')
+ },
onStickerUploaded (e) {
this.$emit('sticker-uploaded', e)
},
@@ -71,23 +150,53 @@ 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)
+ onScroll (startIndex, endIndex, visibleStartIndex, visibleEndIndex) {
+ const target = this.$refs['emoji-groups'].$el
+ this.scrolledGroup(target, visibleStartIndex, visibleEndIndex)
},
- highlight (key) {
- const ref = this.$refs['group-' + key]
- const top = ref.offsetTop
- this.setShowStickers(false)
- this.activeGroup = key
+ scrolledGroup (target, start, end) {
+ const top = target.scrollTop + 5
this.$nextTick(() => {
- this.$refs['emoji-groups'].scrollTop = top + 1
+ this.emojiItems.slice(start, end + 1).forEach(group => {
+ const headerId = toHeaderId(group.id)
+ const ref = this.groupRefs['group-' + group.id]
+ if (!ref) { return }
+ const elem = ref.$el.parentElement
+ if (!elem) { return }
+ if (elem && getOffset(elem) <= top) {
+ this.activeGroup = headerId
+ }
+ })
+ 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 (groupId) {
+ this.setShowStickers(false)
+ const indexInList = this.emojiItems.findIndex(k => k.id === groupId)
+ this.$refs['emoji-groups'].scrollToItem(indexInList)
+ },
updateScrolledClass (target) {
if (target.scrollTop <= 5) {
this.groupsScrolledClass = 'scrolled-top'
@@ -97,74 +206,70 @@ const EmojiPicker = {
this.groupsScrolledClass = 'scrolled-middle'
}
},
- triggerLoadMore (target) {
- const ref = this.$refs['group-end-custom']
- 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
- this.$nextTick(() => {
- this.emojisView.forEach(group => {
- const ref = this.$refs['group-' + group.id]
- if (ref.offsetTop <= top) {
- this.activeGroup = group.id
- }
- })
- })
+ setShowStickers (value) {
+ this.showingStickers = value
},
- loadEmoji () {
- const allLoaded = this.customEmojiBuffer.length === this.filteredEmoji.length
-
- if (allLoaded) {
- return
- }
-
- this.customEmojiBufferSlice += LOAD_EMOJI_BY
+ filterByKeyword (list, keyword) {
+ return filterByKeyword(list, keyword, this.languages, this.maybeLocalizedEmojiName)
},
- startEmojiLoad (forceUpdate = false) {
- if (!forceUpdate) {
- this.keyword = ''
- }
+ onShowing () {
+ const oldContentLoaded = this.contentLoaded
+ this.recalculateItemPerRow()
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.filteredEmojiGroups = this.getFilteredEmojiGroups()
+ if (!oldContentLoaded) {
+ this.$nextTick(() => {
+ if (this.defaultGroup) {
+ this.highlight(this.defaultGroup)
+ }
+ })
}
- this.customEmojiBufferSlice = LOAD_EMOJI_BY
},
- toggleStickers () {
- this.showingStickers = !this.showingStickers
+ getFilteredEmojiGroups () {
+ return this.allEmojiGroups
+ .map(group => ({
+ ...group,
+ emojis: this.filterByKeyword(group.emojis, trim(this.keyword))
+ }))
+ .filter(group => group.emojis.length > 0)
},
- setShowStickers (value) {
- this.showingStickers = value
+ recalculateItemPerRow () {
+ this.$nextTick(() => {
+ if (!this.$refs['emoji-groups']) {
+ return
+ }
+ this.width = this.$refs['emoji-groups'].$el.clientWidth
+ })
}
},
watch: {
keyword () {
- this.customEmojiLoadAllConfirmed = false
this.onScroll()
- this.startEmojiLoad(true)
+ this.debouncedHandleKeywordChange()
+ },
+ allCustomGroups () {
+ this.filteredEmojiGroups = this.getFilteredEmojiGroups()
}
},
computed: {
+ minItemSize () {
+ return this.emojiHeight
+ },
+ emojiHeight () {
+ return 32 + 4
+ },
+ emojiWidth () {
+ return 32 + 4
+ },
+ itemPerRow () {
+ return this.width ? Math.floor(this.width / this.emojiWidth - 1) : 6
+ },
activeGroupView () {
return this.showingStickers ? '' : this.activeGroup
},
@@ -174,39 +279,69 @@ const EmojiPicker = {
}
return 0
},
- filteredEmoji () {
- return filterByKeyword(
- this.$store.state.instance.customEmoji || [],
- trim(this.keyword)
- )
+ allCustomGroups () {
+ const emojis = this.$store.getters.groupedCustomEmojis
+ if (emojis.unpacked) {
+ emojis.unpacked.text = this.$t('emoji.unpacked')
+ }
+ return emojis
},
- 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, trim(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.filteredEmojiGroups = this.getFilteredEmojiGroups()
+ }, 500)
+ },
+ emojiItems () {
+ return this.filteredEmojiGroups.map(group =>
+ chunk(group.emojis, this.itemPerRow)
+ .map((items, index) => ({
+ ...group,
+ id: index === 0 ? group.id : `row-${index}-${group.id}`,
+ emojis: items,
+ isFirstRow: index === 0
+ })))
+ .reduce((a, c) => a.concat(c), [])
+ },
+ 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 a2f17c51..5bcff33b 100644
--- a/src/components/emoji_picker/emoji_picker.scss
+++ b/src/components/emoji_picker/emoji_picker.scss
@@ -1,24 +1,43 @@
-@import '../../_variables.scss';
+@import "../../variables";
+
+$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: calc(100vw - 20px); // popover gives 10px margin from window edge
display: flex;
flex-direction: column;
- position: absolute;
- right: 0;
- left: 0;
- margin: 0 !important;
- // TODO: actually use popover in emoji picker
- z-index: var(--ZI_popovers);
background-color: $fallback--bg;
background-color: var(--popover, $fallback--bg);
color: $fallback--link;
color: var(--popoverText, $fallback--link);
- --lightText: var(--popoverLightText, $fallback--faint);
+
--faint: var(--popoverFaintText, $fallback--faint);
--faintLink: var(--popoverFaintLink, $fallback--faint);
--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;
@@ -37,7 +56,6 @@
.heading {
display: flex;
- height: 32px;
padding: 10px 7px 5px;
}
@@ -45,18 +63,18 @@
display: flex;
flex-direction: column;
flex: 1 1 auto;
- min-height: 0px;
+ min-height: 0;
}
.emoji-tabs {
flex-grow: 1;
- }
-
- .emoji-groups {
- min-height: 200px;
+ display: flex;
+ flex-flow: row nowrap;
+ overflow-x: auto;
}
.additional-tabs {
+ display: flex;
border-left: 1px solid;
border-left-color: $fallback--icon;
border-left-color: var(--icon, $fallback--icon);
@@ -66,15 +84,20 @@
.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: 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;
@@ -93,7 +116,7 @@
}
.sticker-picker {
- flex: 1 1 auto
+ flex: 1 1 auto;
}
.stickers,
@@ -123,22 +146,27 @@
}
&-groups {
+ height: 100%;
+ min-height: 200px;
flex: 1 1 1px;
position: relative;
overflow: auto;
user-select: none;
- mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat,
- linear-gradient(to bottom, white 0, transparent 100%) top no-repeat,
- linear-gradient(to top, white, white);
+ mask:
+ linear-gradient(to top, white 0, transparent 100%) bottom no-repeat,
+ linear-gradient(to bottom, white 0, transparent 100%) top no-repeat,
+ linear-gradient(to top, white, white);
transition: mask-size 150ms;
mask-size: 100% 20px, 100% 20px, auto;
// Autoprefixed seem to ignore this one, and also syntax is different
- -webkit-mask-composite: xor;
+ mask-composite: xor;
mask-composite: exclude;
+
&.scrolled {
&-top {
mask-size: 100% 20px, 100% 0, auto;
}
+
&-bottom {
mask-size: 100% 0, 100% 20px, auto;
}
@@ -164,24 +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 a7269120..ca90bf26 100644
--- a/src/components/emoji_picker/emoji_picker.vue
+++ b/src/components/emoji_picker/emoji_picker.vue
@@ -1,105 +1,148 @@
<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')"
- @input="$event.target.composing = false"
- >
- </div>
<div
- ref="emoji-groups"
- class="emoji-groups"
- :class="groupsScrolledClass"
- @scroll="onScroll"
+ class="emoji-content"
+ :class="{hidden: showingStickers}"
>
- <div
- v-for="group in emojisView"
- :key="group.id"
- class="emoji-group"
- >
- <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 class="emoji-search">
+ <input
+ ref="search"
+ v-model="keyword"
+ type="text"
+ class="form-control"
+ :placeholder="$t('emoji.search_emoji')"
+ @input="$event.target.composing = false"
>
- <span v-if="!emoji.imageUrl">{{ emoji.replacement }}</span>
- <img
- v-else
- :src="emoji.imageUrl"
+ </div>
+ <DynamicScroller
+ ref="emoji-groups"
+ class="emoji-groups"
+ :class="groupsScrolledClass"
+ :min-item-size="minItemSize"
+ :items="emojiItems"
+ :emit-update="true"
+ @update="onScroll"
+ @visible="recalculateItemPerRow"
+ @resize="recalculateItemPerRow"
+ >
+ <template #default="{ item: group, index, active }">
+ <DynamicScrollerItem
+ :ref="setGroupRef('group-' + group.id)"
+ :item="group"
+ :active="active"
+ :data-index="index"
+ :size-dependencies="[group.emojis.length]"
>
- </span>
- <span :ref="'group-end-' + group.id" />
+ <div
+ class="emoji-group"
+ >
+ <h6
+ v-if="group.isFirstRow"
+ class="emoji-group-title"
+ >
+ {{ 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
+ class="emoji-picker-emoji -custom"
+ loading="lazy"
+ :src="emoji.imageUrl"
+ :data-emoji-name="group.id + emoji.displayText"
+ />
+ </span>
+ </div>
+ </DynamicScrollerItem>
+ </template>
+ </DynamicScroller>
+ <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 4ea8b6a2..a63daa97 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()"
>
@@ -28,55 +28,58 @@
<script src="./emoji_reactions.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
-.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;
}
- }
-}
-.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;
+ &: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);
+ }
}
-}
-.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;
+ }
+ }
+}
</style>
diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js
index 22ffb65a..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'],
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
@@ -94,7 +129,11 @@ const ExtraButtons = {
},
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 2c893bf3..a84d47f6 100644
--- a/src/components/extra_buttons/extra_buttons.vue
+++ b/src/components/extra_buttons/extra_buttons.vue
@@ -6,6 +6,8 @@
:offset="{ y: 5 }"
:bound-to="{ x: 'container' }"
remove-padding
+ @show="onShow"
+ @close="onClose"
>
<template #content="{close}">
<div class="dropdown-menu">
@@ -76,6 +78,28 @@
</button>
</template>
<button
+ v-if="ownStatus && editingAvailable"
+ class="button-default dropdown-item dropdown-item-icon"
+ @click.prevent="editStatus"
+ @click="close"
+ >
+ <FAIcon
+ fixed-width
+ icon="pen"
+ /><span>{{ $t("status.edit") }}</span>
+ </button>
+ <button
+ v-if="isEdited && editingAvailable"
+ class="button-default dropdown-item dropdown-item-icon"
+ @click.prevent="showStatusHistory"
+ @click="close"
+ >
+ <FAIcon
+ fixed-width
+ icon="history"
+ /><span>{{ $t("status.status_history") }}</span>
+ </button>
+ <button
v-if="canDelete"
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="deleteStatus"
@@ -122,10 +146,24 @@
</template>
<template #trigger>
<span class="button-unstyled popover-trigger">
- <FAIcon
- class="fa-scale-110 fa-old-padding"
- icon="ellipsis-h"
- />
+ <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>
@@ -134,14 +172,10 @@
<script src="./extra_buttons.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
+@import "../../mixins";
.ExtraButtons {
- /* override of popover internal stuff */
- .popover-trigger-button {
- width: auto;
- }
-
.popover-trigger {
position: static;
padding: 10px;
@@ -152,5 +186,22 @@
color: var(--text, $fallback--text);
}
}
+
+ .popover-trigger-button {
+ /* override of popover internal stuff */
+ width: auto;
+
+ @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 d5c4c61e..58d14945 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"
@@ -32,7 +58,8 @@
<script src="./favorite_button.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
+@import "../../mixins";
.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/flash/flash.vue b/src/components/flash/flash.vue
index 95f71950..9f58d314 100644
--- a/src/components/flash/flash.vue
+++ b/src/components/flash/flash.vue
@@ -42,7 +42,8 @@
<script src="./flash.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
+
.Flash {
display: inline-block;
width: 100%;
@@ -78,7 +79,7 @@
.hidden {
display: none;
- visibility: 'hidden';
+ visibility: "hidden";
}
}
</style>
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 895a8fa3..eff69fb2 100644
--- a/src/components/follow_card/follow_card.vue
+++ b/src/components/follow_card/follow_card.vue
@@ -22,6 +22,11 @@
class="follow-card-follow-button"
:user="user"
/>
+ <RemoveFollowerButton
+ v-if="noFollowsYou && relationship.followed_by"
+ :relationship="relationship"
+ class="follow-card-button"
+ />
</template>
</div>
</basic-user-card>
@@ -34,12 +39,17 @@
&-content-container {
flex-shrink: 0;
display: flex;
- flex-direction: row;
+ flex-flow: row wrap;
justify-content: space-between;
- flex-wrap: wrap;
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/follow_request_card/follow_request_card.vue b/src/components/follow_request_card/follow_request_card.vue
index 1b12ba4b..eb222cc7 100644
--- a/src/components/follow_request_card/follow_request_card.vue
+++ b/src/components/follow_request_card/follow_request_card.vue
@@ -22,8 +22,8 @@
<style lang="scss">
.follow-request-card-content-container {
display: flex;
- flex-direction: row;
- flex-wrap: wrap;
+ flex-flow: row wrap;
+
button {
margin-top: 0.5em;
margin-right: 0.5em;
diff --git a/src/components/font_control/font_control.vue b/src/components/font_control/font_control.vue
index 83c1cef7..bb7e64bc 100644
--- a/src/components/font_control/font_control.vue
+++ b/src/components/font_control/font_control.vue
@@ -50,17 +50,20 @@
<script src="./font_control.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
+
.font-control {
input.custom-font {
min-width: 10em;
}
+
&.custom {
/* TODO Should make proper joiners... */
.font-switcher {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
+
.custom-font {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
diff --git a/src/components/gallery/gallery.js b/src/components/gallery/gallery.js
index 4e1bda55..e86a3eea 100644
--- a/src/components/gallery/gallery.js
+++ b/src/components/gallery/gallery.js
@@ -4,6 +4,7 @@ import { sumBy, set } from 'lodash'
const Gallery = {
props: [
'attachments',
+ 'compact',
'limitRows',
'descriptions',
'limit',
diff --git a/src/components/gallery/gallery.vue b/src/components/gallery/gallery.vue
index ccf6e3e2..96b554e3 100644
--- a/src/components/gallery/gallery.vue
+++ b/src/components/gallery/gallery.vue
@@ -20,6 +20,7 @@
v-for="(attachment, attachmentIndex) in row.items"
:key="attachment.id"
class="gallery-item"
+ :compact="compact"
:nsfw="nsfw"
:attachment="attachment"
:size="size"
@@ -86,7 +87,7 @@
<script src='./gallery.js'></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.Gallery {
.gallery-rows {
@@ -100,6 +101,53 @@
width: 100%;
flex-grow: 1;
+ .gallery-row-inner {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ flex-flow: row wrap;
+ align-content: stretch;
+
+ .gallery-item {
+ margin: 0 0.5em 0 0;
+ flex-grow: 1;
+ height: 100%;
+ box-sizing: border-box;
+ // to make failed images a bit more noticeable on chromium
+ min-width: 2em;
+
+ &:last-child {
+ margin: 0;
+ }
+ }
+
+ &.-grid {
+ width: 100%;
+ height: auto;
+ position: relative;
+ display: grid;
+ grid-gap: 0.5em;
+ grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
+
+ .gallery-item {
+ margin: 0;
+ height: 200px;
+ }
+ }
+ }
+
+ &.-grid,
+ &.-minimal {
+ height: auto;
+
+ .gallery-row-inner {
+ position: relative;
+ }
+ }
+
&:not(:first-child) {
margin-top: 0.5em;
}
@@ -114,7 +162,7 @@
linear-gradient(to top, white, white);
/* Autoprefixed seem to ignore this one, and also syntax is different */
- -webkit-mask-composite: xor;
+ mask-composite: xor;
mask-composite: exclude;
}
}
@@ -138,54 +186,5 @@
padding: 0 2em;
}
}
-
- .gallery-row {
- &.-grid,
- &.-minimal {
- height: auto;
- .gallery-row-inner {
- position: relative;
- }
- }
- }
-
- .gallery-row-inner {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- display: flex;
- 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-item {
- margin: 0 0.5em 0 0;
- flex-grow: 1;
- height: 100%;
- box-sizing: border-box;
- // to make failed images a bit more noticeable on chromium
- min-width: 2em;
- &:last-child {
- margin: 0;
- }
- }
}
</style>
diff --git a/src/components/global_notice_list/global_notice_list.vue b/src/components/global_notice_list/global_notice_list.vue
index 09904761..0e58476f 100644
--- a/src/components/global_notice_list/global_notice_list.vue
+++ b/src/components/global_notice_list/global_notice_list.vue
@@ -25,14 +25,14 @@
<script src="./global_notice_list.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.global-notice-list {
position: fixed;
- top: 50px;
+ top: calc(var(--navbar-height) + 0.5em);
width: 100%;
pointer-events: none;
- z-index: var(--ZI_popovers);
+ z-index: var(--ZI_navbar_popovers);
display: flex;
flex-direction: column;
align-items: center;
@@ -73,6 +73,7 @@
.global-success {
background-color: var(--alertPopupSuccess, $fallback--cGreen);
color: var(--alertPopupSuccessText, $fallback--text);
+
.svg-inline--fa {
color: var(--alertPopupSuccessText, $fallback--text);
}
@@ -81,6 +82,7 @@
.global-info {
background-color: var(--alertPopupNeutral, $fallback--fg);
color: var(--alertPopupNeutralText, $fallback--text);
+
.svg-inline--fa {
color: var(--alertPopupNeutralText, $fallback--text);
}
@@ -88,6 +90,7 @@
.close-notice {
padding-right: 0.2em;
+
.svg-inline--fa:hover {
opacity: 0.6;
}
diff --git a/src/components/interactions/interactions.js b/src/components/interactions/interactions.js
index cc6a15e1..1ae1d01c 100644
--- a/src/components/interactions/interactions.js
+++ b/src/components/interactions/interactions.js
@@ -5,6 +5,8 @@ const tabModeDict = {
mentions: ['mention'],
'likes+repeats': ['repeat', 'like'],
follows: ['follow'],
+ reactions: ['pleroma:emoji_reaction'],
+ reports: ['pleroma:report'],
moves: ['move']
}
@@ -12,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: {
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/link-preview/link-preview.vue b/src/components/link-preview/link-preview.vue
index 220527f2..09f341ac 100644
--- a/src/components/link-preview/link-preview.vue
+++ b/src/components/link-preview/link-preview.vue
@@ -33,7 +33,7 @@
<script src="./link-preview.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.link-preview-card {
display: flex;
@@ -46,6 +46,7 @@
flex-shrink: 0;
width: 120px;
max-width: 25%;
+
img {
width: 100%;
height: 100%;
@@ -67,7 +68,7 @@
}
.card-description {
- margin: 0.5em 0 0 0;
+ margin: 0.5em 0 0;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
diff --git a/src/components/list/list.vue b/src/components/list/list.vue
index a6223cce..f17766b4 100644
--- a/src/components/list/list.vue
+++ b/src/components/list/list.vue
@@ -35,7 +35,7 @@ export default {
</script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.list {
&-item:not(:last-child) {
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..925da3a5
--- /dev/null
+++ b/src/components/lists_card/lists_card.vue
@@ -0,0 +1,52 @@
+<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";
+
+.list-card {
+ display: flex;
+}
+
+.list-name {
+ flex-grow: 1;
+}
+
+.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);
+ }
+}
+</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..c33659df
--- /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: user.id, listId: this.id })
+ },
+ removeUser (userId) {
+ this.$store.dispatch('removeListAccount', { accountId: userId, 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..eec0f978
--- /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";
+
+.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..6ca107e6
--- /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";
+
+.ListsUserSearch {
+ .input-wrap {
+ display: flex;
+ margin: 0.7em 0.5em;
+
+ input {
+ width: 100%;
+ }
+ }
+
+ .search-icon {
+ margin-right: 0.3em;
+ }
+}
+
+</style>
diff --git a/src/components/login_form/login_form.vue b/src/components/login_form/login_form.vue
index 7a430c51..829e88ad 100644
--- a/src/components/login_form/login_form.vue
+++ b/src/components/login_form/login_form.vue
@@ -93,7 +93,7 @@
<script src="./login_form.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.login-form {
display: flex;
@@ -110,7 +110,7 @@
}
.login-bottom {
- margin-top: 1.0em;
+ margin-top: 1em;
display: flex;
flex-direction: row;
align-items: center;
@@ -121,7 +121,7 @@
display: flex;
flex-direction: column;
padding: 0.3em 0.5em 0.6em;
- line-height:24px;
+ line-height: 24px;
}
.form-bottom {
@@ -142,7 +142,6 @@
.error {
text-align: center;
-
animation-name: shakeError;
animation-duration: 0.4s;
animation-timing-function: ease-in-out;
diff --git a/src/components/media_modal/media_modal.js b/src/components/media_modal/media_modal.js
index ff993664..05ef9fbe 100644
--- a/src/components/media_modal/media_modal.js
+++ b/src/components/media_modal/media_modal.js
@@ -63,6 +63,11 @@ const MediaModal = {
},
type () {
return this.currentMedia ? this.getType(this.currentMedia) : null
+ },
+ swipeDisableClickThreshold () {
+ // If there is only one media, allow more mouse movements to close the modal
+ // because there is less chance that the user wants to switch to another image
+ return () => this.canNavigate ? 1 : 30
}
},
methods: {
diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue
index d59055b3..eb28f8be 100644
--- a/src/components/media_modal/media_modal.vue
+++ b/src/components/media_modal/media_modal.vue
@@ -10,6 +10,7 @@
class="modal-image-container"
:direction="swipeDirection"
:threshold="swipeThreshold"
+ :disable-click-threshold="swipeDisableClickThreshold"
@preview-requested="handleSwipePreview"
@swipe-finished="handleSwipeEnd"
@swipeless-clicked="hide"
@@ -120,32 +121,12 @@ $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: var(--ZI_media_modal);
- flex-direction: column;
-
- .modal-view-button-arrow,
- .modal-view-button-hide {
- opacity: 0.75;
-
- &:focus,
- &:hover {
- outline: none;
- box-shadow: none;
- }
-
- &:hover {
- opacity: 1;
- }
- }
- overflow: hidden;
-}
-
.media-modal-view {
@keyframes media-fadein {
from {
opacity: 0;
}
+
to {
opacity: 1;
}
@@ -226,7 +207,7 @@ $modal-view-button-icon-margin: 0.5em;
appearance: none;
overflow: visible;
cursor: pointer;
- transition: opacity 333ms cubic-bezier(.4,0,.22,1);
+ transition: opacity 333ms cubic-bezier(0.4, 0, 0.22, 1);
height: $modal-view-button-icon-height;
width: $modal-view-button-icon-width;
@@ -236,9 +217,9 @@ $modal-view-button-icon-margin: 0.5em;
width: $modal-view-button-icon-width;
font-size: 1rem;
line-height: $modal-view-button-icon-height;
- color: #FFF;
+ color: #fff;
text-align: center;
- background-color: rgba(0,0,0,.3);
+ background-color: rgb(0 0 0 / 30%);
}
}
@@ -254,13 +235,14 @@ $modal-view-button-icon-margin: 0.5em;
position: absolute;
top: 0;
line-height: $modal-view-button-icon-height;
- color: #FFF;
+ color: #fff;
text-align: center;
- background-color: rgba(0,0,0,.3);
+ background-color: rgb(0 0 0 / 30%);
}
&--prev {
left: 0;
+
.arrow-icon {
left: $modal-view-button-icon-margin;
}
@@ -268,6 +250,7 @@ $modal-view-button-icon-margin: 0.5em;
&--next {
right: 0;
+
.arrow-icon {
right: $modal-view-button-icon-margin;
}
@@ -278,10 +261,33 @@ $modal-view-button-icon-margin: 0.5em;
position: absolute;
top: 0;
right: 0;
+
.button-icon {
top: $modal-view-button-icon-margin;
right: $modal-view-button-icon-margin;
}
}
}
+
+.modal-view.media-modal-view {
+ z-index: var(--ZI_media_modal);
+ flex-direction: column;
+
+ .modal-view-button-arrow,
+ .modal-view-button-hide {
+ opacity: 0.75;
+
+ &:focus,
+ &:hover {
+ outline: none;
+ box-shadow: none;
+ }
+
+ &:hover {
+ opacity: 1;
+ }
+ }
+
+ overflow: hidden;
+}
</style>
diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue
index a538a5ed..2799495b 100644
--- a/src/components/media_upload/media_upload.vue
+++ b/src/components/media_upload/media_upload.vue
@@ -29,7 +29,7 @@
<script src="./media_upload.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.media-upload {
cursor: pointer; // We use <label> for interactivity... i wonder if it's fine
diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js
index 4a74fbe2..6515bd11 100644
--- a/src/components/mention_link/mention_link.js
+++ b/src/components/mention_link/mention_link.js
@@ -2,6 +2,7 @@ import generateProfileLink from 'src/services/user_profile_link_generator/user_p
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 {
@@ -16,6 +17,7 @@ const MentionLink = {
name: 'MentionLink',
components: {
UserAvatar,
+ UnicodeDomainIndicator,
UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue'))
},
props: {
diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss
index 8b2af926..69e9fed1 100644
--- a/src/components/mention_link/mention_link.scss
+++ b/src/components/mention_link/mention_link.scss
@@ -1,4 +1,4 @@
-@import '../../_variables.scss';
+@import "../../variables";
.MentionLink {
position: relative;
@@ -59,6 +59,7 @@
font-weight: 600;
}
}
+
&.-has-selection {
color: var(--alertNeutralText, $fallback--text);
background-color: var(--alertNeutral, $fallback--fg);
@@ -100,10 +101,6 @@
}
}
- .full {
- pointer-events: none;
- }
-
.serverName.-faded {
color: var(--faintLink, $fallback--link);
}
diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue
index 3af502ef..869a3257 100644
--- a/src/components/mention_link/mention_link.vue
+++ b/src/components/mention_link/mention_link.vue
@@ -47,6 +47,9 @@
class="serverName"
:class="{ '-faded': shouldFadeDomain }"
v-html="'@' + serverName"
+ /><UnicodeDomainIndicator
+ v-if="shouldShowFullUserName"
+ :user="user"
/>
</span>
<span
diff --git a/src/components/mentions_line/mentions_line.scss b/src/components/mentions_line/mentions_line.scss
index 9a622e75..b7dfbfb8 100644
--- a/src/components/mentions_line/mentions_line.scss
+++ b/src/components/mentions_line/mentions_line.scss
@@ -2,7 +2,7 @@
word-break: break-all;
.mention-link:not(:first-child)::before {
- content: ' ';
+ content: " ";
}
.showMoreLess {
diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js
index 877d52a9..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,6 +85,9 @@ const MobileNav = {
scrollToTop () {
window.scrollTo(0, 0)
},
+ scrollMobileNotificationsToTop () {
+ this.$refs.mobileNotifications.scrollTo(0, 0)
+ },
logout () {
this.$router.replace('/main/public')
this.$store.dispatch('logout')
@@ -82,6 +97,7 @@ const MobileNav = {
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 949cf17e..d6fe102c 100644
--- a/src/components/mobile_nav/mobile_nav.vue
+++ b/src/components/mobile_nav/mobile_nav.vue
@@ -10,6 +10,8 @@
<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
@@ -17,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
@@ -47,7 +42,7 @@
</button>
</div>
</nav>
- <div
+ <aside
v-if="currentUser"
class="mobile-notifications-drawer"
:class="{ '-closed': !notificationsOpen }"
@@ -56,22 +51,39 @@
>
<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"
/>
- </div>
+ </aside>
<SideDrawer
ref="sideDrawer"
:logout="logout"
@@ -82,7 +94,7 @@
<script src="./mobile_nav.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.MobileNav {
z-index: var(--ZI_navbar);
@@ -94,6 +106,7 @@
grid-template-columns: 2fr auto;
width: 100%;
box-sizing: border-box;
+
a {
color: var(--topBarLink, $fallback--link);
}
@@ -114,7 +127,7 @@
}
.site-name {
- padding: 0 .3em;
+ padding: 0 0.3em;
display: inline-block;
}
@@ -143,7 +156,7 @@
position: fixed;
top: 0;
left: 0;
- box-shadow: 1px 1px 4px rgba(0,0,0,.6);
+ box-shadow: 1px 1px 4px rgb(0 0 0 / 60%);
box-shadow: var(--panelShadow);
transition-property: transform;
transition-duration: 0.25s;
@@ -169,22 +182,33 @@
color: var(--topBarText);
background-color: $fallback--fg;
background-color: var(--topBar, $fallback--fg);
- box-shadow: 0px 0px 4px rgba(0,0,0,.6);
+ box-shadow: 0 0 4px rgb(0 0 0 / 60%);
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 - var(--navbar-height));
overflow-x: hidden;
overflow-y: scroll;
-
color: $fallback--text;
color: var(--text, $fallback--text);
background-color: $fallback--bg;
@@ -194,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 ecf79b64..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 = {
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 9fcdf6f8..ef0f51fe 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
@@ -3,6 +3,7 @@
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" />
@@ -12,7 +13,7 @@
<script src="./mobile_post_status_button.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.MobilePostButton {
&.button-default {
@@ -29,9 +30,8 @@
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);
+ box-shadow: 0 2px 2px rgb(0 0 0 / 30%), 0 4px 6px rgb(0 0 0 / 30%);
z-index: 10;
-
transition: 0.35s transform;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
}
diff --git a/src/components/modal/modal.vue b/src/components/modal/modal.vue
index 2187f392..9f767ede 100644
--- a/src/components/modal/modal.vue
+++ b/src/components/modal/modal.vue
@@ -59,7 +59,7 @@ export default {
&.modal-background {
pointer-events: initial;
- background-color: rgba(0, 0, 0, 0.5);
+ background-color: rgb(0 0 0 / 50%);
}
&.open {
@@ -69,10 +69,11 @@ export default {
@keyframes modal-background-fadein {
from {
- background-color: rgba(0, 0, 0, 0);
+ background-color: rgb(0 0 0 / 0%);
}
+
to {
- background-color: rgba(0, 0, 0, 0.5);
+ background-color: rgb(0 0 0 / 50%);
}
}
</style>
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 34fe2e7c..b708cdc8 100644
--- a/src/components/moderation_tools/moderation_tools.vue
+++ b/src/components/moderation_tools/moderation_tools.vue
@@ -10,7 +10,7 @@
>
<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)"
@@ -163,18 +166,21 @@
<script src="./moderation_tools.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.moderation-tools-popover {
height: 100%;
+
.trigger {
+ /* stylelint-disable-next-line declaration-no-important */
display: flex !important;
height: 100%;
}
}
.moderation-tools-button {
- svg,i {
+ svg,
+ i {
font-size: 0.8em;
}
}
diff --git a/src/components/mrf_transparency_panel/mrf_transparency_panel.scss b/src/components/mrf_transparency_panel/mrf_transparency_panel.scss
index 80ea01d4..a262ed1c 100644
--- a/src/components/mrf_transparency_panel/mrf_transparency_panel.scss
+++ b/src/components/mrf_transparency_panel/mrf_transparency_panel.scss
@@ -2,19 +2,21 @@
margin: 1em;
table {
- width:100%;
+ width: 100%;
text-align: left;
- padding-left:10px;
- padding-bottom:20px;
+ padding-left: 10px;
+ padding-bottom: 20px;
- th, td {
+ th,
+ td {
width: 180px;
max-width: 360px;
- overflow: hidden;
+ overflow: hidden;
vertical-align: text-top;
}
- th+th, td+td {
+ th + th,
+ td + td {
width: auto;
}
}
diff --git a/src/components/mrf_transparency_panel/mrf_transparency_panel.vue b/src/components/mrf_transparency_panel/mrf_transparency_panel.vue
index 1787fa07..97af4787 100644
--- a/src/components/mrf_transparency_panel/mrf_transparency_panel.vue
+++ b/src/components/mrf_transparency_panel/mrf_transparency_panel.vue
@@ -227,6 +227,6 @@
<script src="./mrf_transparency_panel.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
-@import './mrf_transparency_panel.scss';
+@import "../../variables";
+@import "./mrf_transparency_panel";
</style>
diff --git a/src/components/mute_card/mute_card.vue b/src/components/mute_card/mute_card.vue
index ca33c6c5..97e91cbc 100644
--- a/src/components/mute_card/mute_card.vue
+++ b/src/components/mute_card/mute_card.vue
@@ -37,6 +37,7 @@
.mute-card-content-container {
margin-top: 0.5em;
text-align: right;
+
button {
width: 10em;
}
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 3fd27d89..1a826cc4 100644
--- a/src/components/nav_panel/nav_panel.vue
+++ b/src/components/nav_panel/nav_panel.vue
@@ -1,90 +1,99 @@
<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">
+ </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: 'chats', params: { username: currentUser.screen_name } }"
+ :title="$t('lists.manage_lists')"
+ class="extra-button"
+ :to="{ name: 'lists' }"
+ @click.stop
>
- <div
- v-if="unreadChatCount"
- class="badge badge-notification"
- >
- {{ unreadChatCount }}
- </div>
<FAIcon
+ class="extra-button"
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>
- <router-link
- class="menu-item"
- :to="{ name: 'about' }"
- >
- <FAIcon
- 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>
@@ -93,7 +102,7 @@
<script src="./nav_panel.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.NavPanel {
.panel {
@@ -112,7 +121,6 @@
border-bottom: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
- padding: 0;
}
> li {
@@ -135,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 {
@@ -182,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);
}
@@ -192,14 +168,10 @@
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
+ // stylelint-disable-next-line length-zero-no-unit
+ --panel-heading-height-padding: 0px;
}
}
</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..411ca536
--- /dev/null
+++ b/src/components/navigation/navigation_entry.vue
@@ -0,0 +1,135 @@
+<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";
+
+.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..4fbb4f95
--- /dev/null
+++ b/src/components/navigation/navigation_pins.vue
@@ -0,0 +1,75 @@
+<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";
+
+.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 882b68f9..265aaee0 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -4,6 +4,8 @@ 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'
@@ -18,7 +20,9 @@ import {
faUserPlus,
faEyeSlash,
faUser,
- faSuitcaseRolling
+ faSuitcaseRolling,
+ faExpandAlt,
+ faCompressAlt
} from '@fortawesome/free-solid-svg-icons'
library.add(
@@ -29,13 +33,15 @@ 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
}
@@ -47,12 +53,14 @@ const Notification = {
UserCard,
Timeago,
Status,
+ Report,
RichContent,
- UserPopover
+ 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 38978137..654aca3c 100644
--- a/src/components/notification/notification.scss
+++ b/src/components/notification/notification.scss
@@ -1,13 +1,14 @@
-@import '../../_variables.scss';
+@import "../../variables";
// TODO Copypaste from Status, should unify it somehow
.Notification {
- 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;
+ 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;
@@ -54,7 +55,7 @@
margin-left: 0.2em;
&::before {
- content: ' ';
+ content: " ";
}
}
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index d2b903f6..f1aa5420 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -1,19 +1,23 @@
<template>
- <Status
+ <article
v-if="notification.type === 'mention'"
- class="Notification"
- :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"
@@ -121,6 +125,9 @@
</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"
@@ -137,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
@@ -159,6 +178,8 @@
<button
v-if="needMute"
class="button-unstyled"
+ :title="$t('tool_tip.toggle_mute')"
+ :aria-expanded="!unmuted"
@click.prevent="toggleMute"
>
<FAIcon
@@ -171,12 +192,10 @@
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;"
@@ -207,20 +226,24 @@
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>
<StatusContent
- class="faint"
- :compact="true"
+ :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 b0213167..1315b51a 100644
--- a/src/components/notifications/notification_filters.vue
+++ b/src/components/notifications/notification_filters.vue
@@ -109,22 +109,3 @@ export default {
}
}
</script>
-
-<style lang="scss">
-
-.NotificationFilters {
- align-self: stretch;
-
- > button {
- line-height: 100%;
- height: 100%;
- width: var(--__panel-heading-height-inner);
- text-align: center;
-
- svg {
- font-size: 1.2em;
- }
- }
-}
-
-</style>
diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js
index 0851f407..d499d3d6 100644
--- a/src/components/notifications/notifications.js
+++ b/src/components/notifications/notifications.js
@@ -10,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
@@ -34,6 +36,7 @@ const Notifications = {
},
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
@@ -66,7 +69,7 @@ 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
@@ -90,7 +93,22 @@ const Notifications = {
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) {
@@ -101,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 3d3408f7..41cfcef0 100644
--- a/src/components/notifications/notifications.scss
+++ b/src/components/notifications/notifications.scss
@@ -1,4 +1,4 @@
-@import '../../_variables.scss';
+@import "../../variables";
.Notifications {
&:not(.minimal) {
@@ -25,12 +25,13 @@
&.unseen {
.notification-overlay {
- background-image: linear-gradient(135deg, var(--badgeNotification, $fallback--cRed) 4px, transparent 10px)
+ background-image: linear-gradient(135deg, var(--badgeNotification, $fallback--cRed) 4px, transparent 10px);
}
}
}
}
+/* stylelint-disable-next-line no-descending-specificity */
.notification {
box-sizing: border-box;
@@ -38,6 +39,7 @@
canvas {
display: none;
}
+
img {
visibility: visible;
}
@@ -59,8 +61,10 @@
height: 32px;
}
- --link: var(--faintLink);
- --text: var(--faint);
+ .faint {
+ --link: var(--faintLink);
+ --text: var(--faint);
+ }
}
.follow-request-accept {
@@ -77,7 +81,8 @@
}
}
- .follow-text, .move-text {
+ .follow-text,
+ .move-text {
padding: 0.5em 0;
overflow-wrap: break-word;
display: flex;
@@ -110,6 +115,16 @@
min-width: 3em;
text-align: right;
}
+
+ .timeago-link {
+ margin-right: 0.2em;
+ }
+
+ .expand-icon {
+ .svg-inline--fa {
+ margin-left: 0.25em;
+ }
+ }
}
.emoji-reaction-emoji {
diff --git a/src/components/notifications/notifications.vue b/src/components/notifications/notifications.vue
index e778e27b..633efca6 100644
--- a/src/components/notifications/notifications.vue
+++ b/src/components/notifications/notifications.vue
@@ -3,7 +3,9 @@
:disabled="minimalMode || disableTeleport"
:to="teleportTarget"
>
- <div
+ <component
+ :is="noHeading ? 'div' : 'aside'"
+ ref="root"
:class="{ minimal: minimalMode }"
class="Notifications"
>
@@ -19,19 +21,43 @@
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 />
+ <NotificationFilters class="rightside-button" />
</div>
- <div class="panel-body">
+ <div
+ class="panel-body"
+ role="feed"
+ >
<div
v-for="notification in notificationsToDisplay"
:key="notification.id"
+ role="listitem"
class="notification"
:class="{unseen: !minimalMode && !notification.seen}"
>
@@ -67,7 +93,7 @@
</div>
</div>
</div>
- </div>
+ </component>
</teleport>
</template>
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/panel_loading/panel_loading.vue b/src/components/panel_loading/panel_loading.vue
index d916d8a6..17458e49 100644
--- a/src/components/panel_loading/panel_loading.vue
+++ b/src/components/panel_loading/panel_loading.vue
@@ -23,7 +23,7 @@ export default {}
</script>
<style lang="scss">
-@import 'src/_variables.scss';
+@import "src/variables";
.panel-loading {
display: flex;
@@ -33,6 +33,7 @@ export default {}
font-size: 2em;
color: $fallback--text;
color: var(--text, $fallback--text);
+
.loading-text svg {
line-height: 0;
vertical-align: middle;
diff --git a/src/components/password_reset/password_reset.vue b/src/components/password_reset/password_reset.vue
index 90673f44..d6e10250 100644
--- a/src/components/password_reset/password_reset.vue
+++ b/src/components/password_reset/password_reset.vue
@@ -77,7 +77,7 @@
<script src="./password_reset.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.password-reset-form {
display: flex;
diff --git a/src/components/poll/poll.vue b/src/components/poll/poll.vue
index f6b12a54..cacc3298 100644
--- a/src/components/poll/poll.vue
+++ b/src/components/poll/poll.vue
@@ -90,7 +90,7 @@
<script src="./poll.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.poll {
.votes {
@@ -98,9 +98,11 @@
flex-direction: column;
margin: 0 0 0.5em;
}
+
.poll-option {
margin: 0.75em 0.5em;
}
+
.option-result {
height: 100%;
display: flex;
@@ -109,6 +111,7 @@
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
+
.option-result-label {
display: flex;
align-items: center;
@@ -116,10 +119,12 @@
z-index: 1;
word-break: break-word;
}
+
.result-percentage {
width: 3.5em;
flex-shrink: 0;
}
+
.result-fill {
height: 100%;
position: absolute;
@@ -133,20 +138,25 @@
left: 0;
transition: width 0.5s;
}
+
.option-vote {
display: flex;
align-items: center;
}
+
input {
width: 3.5em;
}
+
.footer {
display: flex;
align-items: center;
}
+
&.loading * {
cursor: progress;
}
+
.poll-vote-button {
padding: 0 0.5em;
margin-right: 0.5em;
diff --git a/src/components/poll/poll_form.vue b/src/components/poll/poll_form.vue
index 146754db..09d411ca 100644
--- a/src/components/poll/poll_form.vue
+++ b/src/components/poll/poll_form.vue
@@ -95,7 +95,7 @@
<script src="./poll_form.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.poll-form {
display: flex;
@@ -117,6 +117,7 @@
.input-container {
width: 100%;
+
input {
// Hack: dodge the floating X icon
padding-right: 2.5em;
diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js
index d2af59fe..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
@@ -43,7 +43,12 @@ const Popover = {
overlayCentersSelector: String,
// Lets hover popover stay when clicking inside of it
- stayOnClick: Boolean
+ stayOnClick: Boolean,
+
+ triggerAttrs: {
+ type: Object,
+ default: {}
+ }
},
inject: ['popoversZLayer'], // override popover z layer
data () {
@@ -51,6 +56,10 @@ const Popover = {
// 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: {},
@@ -59,10 +68,15 @@ const Popover = {
// 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()
@@ -75,7 +89,7 @@ const Popover = {
// 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 anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight
const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth
@@ -84,6 +98,8 @@ const Popover = {
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 = {
@@ -170,7 +186,7 @@ const Popover = {
if (overlayCenter) {
translateX = origin.x + horizOffset
translateY = origin.y + vertOffset
- } else {
+ } else if (this.placement !== 'right' && this.placement !== 'left') {
// Default to whatever user wished with placement prop
let usingTop = this.placement !== 'bottom'
@@ -189,6 +205,25 @@ const Popover = {
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
}
this.styles = {
@@ -205,6 +240,10 @@ const Popover = {
},
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)
@@ -265,6 +304,7 @@ 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
@@ -298,6 +338,7 @@ const Popover = {
}
},
mounted () {
+ this.teleport = true
let scrollable = this.$refs.trigger.closest('.column.-scrollable') ||
this.$refs.trigger.closest('.mobile-notifications')
if (!scrollable) scrollable = window
diff --git a/src/components/popover/popover.vue b/src/components/popover/popover.vue
index bd59cade..fd0fd821 100644
--- a/src/components/popover/popover.vue
+++ b/src/components/popover/popover.vue
@@ -7,11 +7,15 @@
ref="trigger"
class="button-unstyled popover-trigger-button"
type="button"
+ v-bind="triggerAttrs"
@click="onClick"
>
<slot name="trigger" />
</button>
- <teleport to="#popovers">
+ <teleport
+ :disabled="!teleport"
+ to="#popovers"
+ >
<transition name="fade">
<div
v-if="!hidden"
@@ -37,7 +41,7 @@
<script src="./popover.js" />
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.popover-trigger-button {
display: inline-block;
@@ -48,31 +52,31 @@
position: fixed;
min-width: 0;
max-width: calc(100vw - 20px);
- box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5);
+ box-shadow: 2px 2px 3px rgb(0 0 0 / 50%);
box-shadow: var(--popupShadow);
}
.popover-default {
- &:after {
- content: '';
+ &::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: 1px 1px 4px rgb(0 0 0 / 60%);
box-shadow: var(--panelShadow);
pointer-events: none;
}
border-radius: $fallback--btnRadius;
border-radius: var(--btnRadius, $fallback--btnRadius);
-
background-color: $fallback--bg;
background-color: var(--popover, $fallback--bg);
color: $fallback--text;
color: var(--popoverText, $fallback--text);
+
--faint: var(--popoverFaintText, $fallback--faint);
--faintLink: var(--popoverFaintLink, $fallback--faint);
--lightText: var(--popoverLightText, $fallback--lightText);
@@ -83,7 +87,7 @@
.dropdown-menu {
display: block;
- padding: .5rem 0;
+ padding: 0.5rem 0;
font-size: 1em;
text-align: left;
list-style: none;
@@ -93,7 +97,7 @@
.dropdown-divider {
height: 0;
- margin: .5rem 0;
+ margin: 0.5rem 0;
overflow: hidden;
border-top: 1px solid $fallback--border;
border-top: 1px solid var(--border, $fallback--border);
@@ -109,7 +113,7 @@
text-align: inherit;
white-space: nowrap;
border: none;
- border-radius: 0px;
+ border-radius: 0;
background-color: transparent;
box-shadow: none;
width: 100%;
@@ -122,21 +126,32 @@
svg {
width: 22px;
margin-right: 0.75rem;
- color: var(--menuPopoverIcon, $fallback--icon)
+ color: var(--menuPopoverIcon, $fallback--icon);
+ }
+ }
+
+ &.-has-submenu {
+ .chevron-icon {
+ margin-right: 0.25rem;
+ margin-left: 2rem;
}
}
- &:active, &:hover {
+ &:active,
+ &:hover {
background-color: $fallback--lightBg;
background-color: var(--selectedMenuPopover, $fallback--lightBg);
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);
}
}
@@ -150,16 +165,16 @@
max-height: 22px;
line-height: 22px;
text-align: center;
- border-radius: 0px;
+ border-radius: 0;
background-color: $fallback--fg;
background-color: var(--input, $fallback--fg);
- box-shadow: 0px 0px 2px black inset;
+ box-shadow: 0 0 2px black inset;
box-shadow: var(--inputShadow);
margin-right: 0.75em;
&.menu-checkbox-checked::after {
font-size: 1.25em;
- content: '✓';
+ content: "✓";
}
&.-radio {
@@ -167,16 +182,15 @@
&.menu-checkbox-checked::after {
font-size: 2em;
- content: '•';
+ content: "•";
}
}
}
-
}
.button-default.dropdown-item {
&,
- i[class*=icon-] {
+ i[class*="icon-"] {
color: $fallback--text;
color: var(--btnText, $fallback--text);
}
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index c0d80b20..eb55cfcc 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -55,6 +55,14 @@ const pxStringToNumber = (str) => {
const PostStatusForm = {
props: [
+ 'statusId',
+ 'statusText',
+ 'statusIsSensitive',
+ 'statusPoll',
+ 'statusFiles',
+ 'statusMediaDescriptions',
+ 'statusScope',
+ 'statusContentType',
'replyTo',
'repliedUser',
'attentions',
@@ -62,6 +70,7 @@ const PostStatusForm = {
'subject',
'disableSubject',
'disableScopeSelector',
+ 'disableVisibilitySelector',
'disableNotice',
'disableLockWarning',
'disablePolls',
@@ -125,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',
@@ -164,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
@@ -173,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 || []
@@ -236,6 +261,9 @@ 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
@@ -473,7 +501,6 @@ const PostStatusForm = {
if (target.value === '') {
target.style.height = null
this.$emit('resize')
- this.$refs['emoji-input'].resize()
return
}
@@ -560,8 +587,6 @@ const PostStatusForm = {
} else {
scrollerRef.scrollTop = targetScroll
}
-
- this.$refs['emoji-input'].resize()
},
showEmojiPicker () {
this.$refs.textarea.focus()
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index 62613bd1..c51639db 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -67,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"
>
@@ -170,6 +177,7 @@
class="visibility-tray"
>
<scope-selector
+ v-if="!disableVisibilitySelector"
:show-all="showAllScopes"
:user-default="userDefaultScope"
:original-scope="copyMessageScope"
@@ -323,7 +331,7 @@
<script src="./post_status_form.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.post-status-form {
position: relative;
@@ -370,7 +378,9 @@
&:hover {
text-decoration: underline;
}
- svg, i {
+
+ svg,
+ i {
margin-left: 0.2em;
font-size: 0.8em;
transform: rotate(90deg);
@@ -410,7 +420,35 @@
align-items: baseline;
}
- .media-upload-icon, .poll-icon, .emoji-icon {
+ .visibility-notice.edit-warning {
+ > :first-child {
+ margin-top: 0;
+ }
+
+ > :last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ // Order is not necessary but a good indicator
+ .media-upload-icon {
+ order: 1;
+ justify-content: left;
+ }
+
+ .emoji-icon {
+ order: 2;
+ justify-content: center;
+ }
+
+ .poll-icon {
+ order: 3;
+ justify-content: right;
+ }
+
+ .media-upload-icon,
+ .poll-icon,
+ .emoji-icon {
font-size: 1.85em;
line-height: 1.1;
flex: 1;
@@ -418,16 +456,20 @@
display: flex;
align-items: center;
- &.selected, &:hover {
+ &.selected,
+ &:hover {
// needs to be specific to override icon default color
- svg, i, label {
+ svg,
+ i,
+ label {
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
}
&.disabled {
- svg, i {
+ svg,
+ i {
cursor: not-allowed;
color: $fallback--icon;
color: var(--btnDisabledText, $fallback--icon);
@@ -440,32 +482,17 @@
}
}
- // Order is not necessary but a good indicator
- .media-upload-icon {
- order: 1;
- justify-content: left;
- }
-
- .emoji-icon {
- order: 2;
- justify-content: center;
- }
-
- .poll-icon {
- order: 3;
- justify-content: right;
- }
-
.error {
text-align: center;
}
.media-upload-wrapper {
- margin-right: .2em;
- margin-bottom: .5em;
+ margin-right: 0.2em;
+ margin-bottom: 0.5em;
width: 18em;
- img, video {
+ img,
+ video {
object-fit: contain;
max-height: 10em;
}
@@ -539,18 +566,14 @@
}
}
- .btn[disabled] {
- cursor: not-allowed;
- }
-
@keyframes fade-in {
from { opacity: 0; }
- to { opacity: 0.6; }
+ to { opacity: 0.6; }
}
@keyframes fade-out {
from { opacity: 0.6; }
- to { opacity: 0; }
+ to { opacity: 0; }
}
.drop-indicator {
diff --git a/src/components/timeline/timeline_quick_settings.js b/src/components/quick_filter_settings/quick_filter_settings.js
index 92d5ac14..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
},
@@ -64,4 +67,4 @@ const TimelineQuickSettings = {
}
}
-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 297bc72a..f2aa61ee 100644
--- a/src/components/timeline/timeline_quick_settings.vue
+++ b/src/components/quick_filter_settings/quick_filter_settings.vue
@@ -1,13 +1,15 @@
<template>
<Popover
trigger="click"
- class="TimelineQuickSettings"
+ class="QuickFilterSettings"
:bound-to="{ x: 'container' }"
+ :trigger-attrs="{ title: $t('timeline.quick_filter_settings') }"
>
<template #content>
<div class="dropdown-menu">
<div v-if="loggedIn">
<button
+ v-if="!conversation"
class="button-default dropdown-item"
@click="replyVisibilityAll = true"
>
@@ -17,6 +19,7 @@
/>{{ $t('settings.reply_visibility_all') }}
</button>
<button
+ v-if="!conversation"
class="button-default dropdown-item"
@click="replyVisibilityFollowing = true"
>
@@ -26,6 +29,7 @@
/>{{ $t('settings.reply_visibility_following_short') }}
</button>
<button
+ v-if="!conversation"
class="button-default dropdown-item"
@click="replyVisibilitySelf = true"
>
@@ -35,6 +39,7 @@
/>{{ $t('settings.reply_visibility_self_short') }}
</button>
<div
+ v-if="!conversation"
role="separator"
class="dropdown-divider"
/>
@@ -70,40 +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 #trigger>
- <button class="button-unstyled">
- <FAIcon icon="filter" />
- </button>
+ <FAIcon icon="filter" />
</template>
</Popover>
</template>
-<script src="./timeline_quick_settings.js"></script>
-
-<style lang="scss">
-
-.TimelineQuickSettings {
-
- > button {
- line-height: 100%;
- height: 100%;
- width: var(--__panel-heading-height-inner);
- text-align: center;
-
- svg {
- font-size: 1.2em;
- }
- }
-}
-
-</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/react_button/react_button.js b/src/components/react_button/react_button.js
index 37d6e7d0..47a48623 100644
--- a/src/components/react_button/react_button.js
+++ b/src/components/react_button/react_button.js
@@ -1,15 +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: {
@@ -25,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')
+ const input = document.querySelector('.reaction-picker-filter > 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 = trim(this.filterWord.toLowerCase())
+ const keywordLowercase = trim(this.filterWord.toLowerCase())
+
const orderedEmojiList = []
- for (const emoji of this.$store.state.instance.emoji) {
- if (emoji.replacement === this.filterWord) return [emoji]
+ 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 5a809847..a813b6fd 100644
--- a/src/components/react_button/react_button.vue
+++ b/src/components/react_button/react_button.vue
@@ -7,7 +7,8 @@
:bound-to="{ x: 'container' }"
remove-padding
popover-class="ReactButton popover-default"
- @show="focusInput"
+ @show="onShow"
+ @close="onClose"
>
<template #content="{close}">
<div class="reaction-picker-filter">
@@ -23,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 }}
@@ -33,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 }}
@@ -46,10 +47,24 @@
class="button-unstyled popover-trigger"
:title="$t('tool_tip.add_reaction')"
>
- <FAIcon
- class="fa-scale-110 fa-old-padding"
- :icon="['far', 'smile-beam']"
- />
+ <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>
@@ -58,7 +73,8 @@
<script src="./react_button.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
+@import "../../mixins";
.ReactButton {
.reaction-picker-filter {
@@ -88,20 +104,19 @@
text-align: center;
align-content: flex-start;
user-select: none;
-
- mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat,
- linear-gradient(to bottom, white 0, transparent 100%) top no-repeat,
- linear-gradient(to top, white, white);
+ mask:
+ linear-gradient(to top, white 0, transparent 100%) bottom no-repeat,
+ linear-gradient(to bottom, white 0, transparent 100%) top no-repeat,
+ linear-gradient(to top, white, white);
transition: mask-size 150ms;
mask-size: 100% 20px, 100% 20px, auto;
/* Autoprefixed seem to ignore this one, and also syntax is different */
- -webkit-mask-composite: xor;
+ mask-composite: xor;
mask-composite: exclude;
.emoji-button {
cursor: pointer;
-
flex-basis: 20%;
line-height: 1.5;
align-content: center;
@@ -112,11 +127,6 @@
}
}
- /* override of popover internal stuff */
- .popover-trigger-button {
- width: auto;
- }
-
.popover-trigger {
padding: 10px;
margin: -10px;
@@ -126,6 +136,23 @@
color: var(--text, $fallback--text);
}
}
+
+ .popover-trigger-button {
+ /* override of popover internal stuff */
+ width: auto;
+
+ @include unfocused-style {
+ .focus-marker {
+ visibility: hidden;
+ }
+ }
+
+ @include focused-style {
+ .focus-marker {
+ visibility: visible;
+ }
+ }
+ }
}
</style>
diff --git a/src/components/registration/registration.vue b/src/components/registration/registration.vue
index d78d8da9..a26162f0 100644
--- a/src/components/registration/registration.vue
+++ b/src/components/registration/registration.vue
@@ -158,10 +158,10 @@
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>
@@ -277,7 +277,7 @@
<script src="./registration.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
$validations-cRed: #f04124;
.registration-form {
@@ -321,7 +321,7 @@ $validations-cRed: #f04124;
.form-group--error {
animation-name: shakeError;
- animation-duration: .6s;
+ animation-duration: 0.6s;
animation-timing-function: ease-in-out;
}
@@ -350,7 +350,7 @@ $validations-cRed: #f04124;
}
form textarea {
- line-height:16px;
+ line-height: 16px;
resize: vertical;
}
diff --git a/src/components/remote_user_resolver/remote_user_resolver.vue b/src/components/remote_user_resolver/remote_user_resolver.vue
index f8945225..07aac169 100644
--- a/src/components/remote_user_resolver/remote_user_resolver.vue
+++ b/src/components/remote_user_resolver/remote_user_resolver.vue
@@ -15,6 +15,3 @@
</template>
<script src="./remote_user_resolver.js"></script>
-
-<style lang="scss">
-</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..6e3964b7 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"
@@ -31,7 +51,8 @@
<script src="./reply_button.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
+@import "../../mixins";
.ReplyButton {
display: flex;
@@ -52,7 +73,18 @@
color: $fallback--cBlue;
color: var(--cBlue, $fallback--cBlue);
}
- }
+ @include unfocused-style {
+ .focus-marker {
+ visibility: hidden;
+ }
+ }
+
+ @include focused-style {
+ .focus-marker {
+ visibility: visible;
+ }
+ }
+ }
}
</style>
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..9762400b
--- /dev/null
+++ b/src/components/report/report.scss
@@ -0,0 +1,43 @@
+@import "../../variables";
+
+.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 5a15d387..7700ee0d 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"
@@ -39,7 +65,8 @@
<script src="./retweet_button.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
+@import "../../mixins";
.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 ca075270..7881e365 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -150,6 +150,7 @@ export default {
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
@@ -171,7 +172,7 @@ export default {
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 {
@@ -179,7 +180,7 @@ export default {
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), '']
}
}
@@ -213,13 +214,14 @@ export default {
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 {
@@ -230,6 +232,7 @@ export default {
{ newChildren }
</a>
}
+ }
case '':
return [...children].reverse().map(processItemReverse).reverse()
}
diff --git a/src/components/rich_content/rich_content.scss b/src/components/rich_content/rich_content.scss
index db08ef1e..e5d353ac 100644
--- a/src/components/rich_content/rich_content.scss
+++ b/src/components/rich_content/rich_content.scss
@@ -1,7 +1,11 @@
+@import "../../variables";
+
.RichContent {
blockquote {
- margin: 0.2em 0 0.2em 2em;
+ margin: 0.2em 0 0.2em 0.2em;
font-style: italic;
+ border-left: 0.2em solid var(--faint, $fallback--faint);
+ padding-left: 1em;
}
pre {
@@ -17,11 +21,11 @@
}
p {
- margin: 0 0 1em 0;
+ margin: 0 0 1em;
}
p:last-child {
- margin: 0 0 0 0;
+ margin: 0;
}
h1 {
diff --git a/src/components/scope_selector/scope_selector.vue b/src/components/scope_selector/scope_selector.vue
index f3bee183..d6e7265b 100644
--- a/src/components/scope_selector/scope_selector.vue
+++ b/src/components/scope_selector/scope_selector.vue
@@ -64,10 +64,9 @@
<script src="./scope_selector.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.ScopeSelector {
-
.scope {
display: inline-block;
cursor: pointer;
diff --git a/src/components/search/search.js b/src/components/search/search.js
index 76ac30ef..877d6f30 100644
--- a/src/components/search/search.js
+++ b/src/components/search/search.js
@@ -8,6 +8,7 @@ import {
faCircleNotch,
faSearch
} from '@fortawesome/free-solid-svg-icons'
+import { uniqBy } from 'lodash'
library.add(
faCircleNotch,
@@ -32,7 +33,11 @@ const Search = {
userIds: [],
statuses: [],
hashtags: [],
- currenResultTab: 'statuses'
+ currenResultTab: 'statuses',
+
+ statusesOffset: 0,
+ lastStatusFetchCount: 0,
+ lastQuery: ''
}
},
computed: {
@@ -61,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..19b9c577 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
@@ -127,7 +148,7 @@
<script src="./search.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.search-result-heading {
color: $fallback--faint;
@@ -155,7 +176,7 @@
}
.search-result-footer {
- border-width: 1px 0 0 0;
+ border-width: 1px 0 0;
border-style: solid;
border-color: var(--border, $fallback--border);
padding: 10px;
@@ -210,4 +231,9 @@
}
}
+.more-statuses-button {
+ height: 3.5em;
+ line-height: 3.5em;
+}
+
</style>
diff --git a/src/components/search_bar/search_bar.vue b/src/components/search_bar/search_bar.vue
index 222f57ba..3969d8de 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>
@@ -54,7 +56,7 @@
<script src="./search_bar.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.SearchBar {
display: inline-flex;
diff --git a/src/components/select/select.vue b/src/components/select/select.vue
index 92493b0b..0ce4ea82 100644
--- a/src/components/select/select.vue
+++ b/src/components/select/select.vue
@@ -21,22 +21,20 @@
<script src="./select.js"> </script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
/* TODO fix order of styles */
label.Select {
padding: 0;
select {
- -webkit-appearance: none;
- -moz-appearance: none;
appearance: none;
background: transparent;
border: none;
color: $fallback--text;
color: var(--inputText, --text, $fallback--text);
margin: 0;
- padding: 0 2em 0 .2em;
+ padding: 0 2em 0 0.2em;
font-family: sans-serif;
font-family: var(--inputFont, sans-serif);
font-size: 1em;
@@ -59,6 +57,5 @@ label.Select {
z-index: 0;
pointer-events: none;
}
-
}
</style>
diff --git a/src/components/selectable_list/selectable_list.vue b/src/components/selectable_list/selectable_list.vue
index 1f7683ab..14910fc5 100644
--- a/src/components/selectable_list/selectable_list.vue
+++ b/src/components/selectable_list/selectable_list.vue
@@ -51,7 +51,7 @@
<script src="./selectable_list.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.selectable-list {
&-item-inner {
@@ -67,6 +67,7 @@
background-color: $fallback--lightBg;
background-color: var(--selectedMenu, $fallback--lightBg);
color: var(--selectedMenuText, $fallback--text);
+
--faint: var(--selectedMenuFaintText, $fallback--faint);
--faintLink: var(--selectedMenuFaintLink, $fallback--faint);
--lightText: var(--selectedMenuLightText, $fallback--lightText);
diff --git a/src/components/settings_modal/helpers/boolean_setting.js b/src/components/settings_modal/helpers/boolean_setting.js
index 353e551c..2e6992cb 100644
--- a/src/components/settings_modal/helpers/boolean_setting.js
+++ b/src/components/settings_modal/helpers/boolean_setting.js
@@ -41,7 +41,16 @@ export default {
},
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 69584808..41142966 100644
--- a/src/components/settings_modal/helpers/boolean_setting.vue
+++ b/src/components/settings_modal/helpers/boolean_setting.vue
@@ -15,7 +15,12 @@
<slot />
</span>
{{ ' ' }}
- <ModifiedIndicator :changed="isChanged" /><ServerSideIndicator :server-side="isServerSide" /> </Checkbox>
+ <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 4677d4c1..3da559fe 100644
--- a/src/components/settings_modal/helpers/choice_setting.js
+++ b/src/components/settings_modal/helpers/choice_setting.js
@@ -43,6 +43,9 @@ export default {
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 258c7422..8fdbb5d3 100644
--- a/src/components/settings_modal/helpers/choice_setting.vue
+++ b/src/components/settings_modal/helpers/choice_setting.vue
@@ -19,14 +19,12 @@
{{ option.value === defaultState ? $t('settings.instance_default_simple') : '' }}
</option>
</Select>
- <ModifiedIndicator :changed="isChanged" />
+ <ModifiedIndicator
+ :changed="isChanged"
+ :onclick="reset"
+ />
<ServerSideIndicator :server-side="isServerSide" />
</label>
</template>
<script src="./choice_setting.js"></script>
-
-<style lang="scss">
-.ChoiceSetting {
-}
-</style>
diff --git a/src/components/settings_modal/helpers/integer_setting.js b/src/components/settings_modal/helpers/integer_setting.js
index 17dc0e7b..e64d0cee 100644
--- a/src/components/settings_modal/helpers/integer_setting.js
+++ b/src/components/settings_modal/helpers/integer_setting.js
@@ -36,6 +36,9 @@ export default {
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
index e661a025..695e2673 100644
--- a/src/components/settings_modal/helpers/integer_setting.vue
+++ b/src/components/settings_modal/helpers/integer_setting.vue
@@ -17,7 +17,10 @@
@change="update"
>
{{ ' ' }}
- <ModifiedIndicator :changed="isChanged" />
+ <ModifiedIndicator
+ :changed="isChanged"
+ :onclick="reset"
+ />
</span>
</template>
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..5a78f100
--- /dev/null
+++ b/src/components/settings_modal/helpers/size_setting.vue
@@ -0,0 +1,55 @@
+<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;
+ max-width: 4em;
+ min-width: 4em;
+}
+</style>
diff --git a/src/components/settings_modal/settings_modal.scss b/src/components/settings_modal/settings_modal.scss
index 13cb0e65..f5861229 100644
--- a/src/components/settings_modal/settings_modal.scss
+++ b/src/components/settings_modal/settings_modal.scss
@@ -1,4 +1,5 @@
-@import 'src/_variables.scss';
+@import "src/variables";
+
.settings-modal {
overflow: hidden;
@@ -6,32 +7,13 @@
.option-list {
list-style-type: none;
padding-left: 2em;
+
li {
margin-bottom: 0.5em;
}
- .suboptions {
- margin-top: 0.3em
- }
- }
-
- &.peek {
- .settings-modal-panel {
- /* Explanation:
- * Modal is positioned vertically centered.
- * 100vh - 100% = Distance between modal's top+bottom boundaries and screen
- * (100vh - 100%) / 2 = Distance between bottom (or top) boundary and screen
- * + 100% - we move modal completely off-screen, it's top boundary touches
- * bottom of the screen
- * - 50px - leaving tiny amount of space so that titlebar + tiny amount of modal is visible
- */
- transform: translateY(calc(((100vh - 100%) / 2 + 100%) - 50px));
- @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.
- */
- transform: translateY(calc(100% - 50px));
- }
+ .suboptions {
+ margin-top: 0.3em;
}
}
@@ -63,6 +45,7 @@
.settings-footer {
display: flex;
+
>* {
margin-right: 0.5em;
}
@@ -72,4 +55,26 @@
flex-grow: 1;
}
}
+
+ &.peek {
+ .settings-modal-panel {
+ /* Explanation:
+ * Modal is positioned vertically centered.
+ * 100vh - 100% = Distance between modal's top+bottom boundaries and screen
+ * (100vh - 100%) / 2 = Distance between bottom (or top) boundary and screen
+ * + 100% - we move modal completely off-screen, it's top boundary touches
+ * bottom of the screen
+ * - 50px - leaving tiny amount of space so that titlebar + tiny amount of modal is visible
+ */
+ transform: translateY(calc(((100vh - 100%) / 2 + 100%) - 50px));
+
+ @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.
+ */
+ transform: translateY(calc(100% - 50px));
+ }
+ }
+ }
}
diff --git a/src/components/settings_modal/settings_modal_content.scss b/src/components/settings_modal/settings_modal_content.scss
index 81ab434b..87df7982 100644
--- a/src/components/settings_modal/settings_modal_content.scss
+++ b/src/components/settings_modal/settings_modal_content.scss
@@ -1,4 +1,5 @@
-@import 'src/_variables.scss';
+@import "src/variables";
+
.settings_tab-switcher {
height: 100%;
@@ -10,7 +11,8 @@
> div,
> label {
display: block;
- margin-bottom: .5em;
+ margin-bottom: 0.5em;
+
&:last-child {
margin-bottom: 0;
}
@@ -21,7 +23,7 @@
.option-list {
margin: 0;
- padding-left: .5em;
+ padding-left: 0.5em;
}
}
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 e3b7f407..48356c9b 100644
--- a/src/components/settings_modal/tabs/data_import_export_tab.vue
+++ b/src/components/settings_modal/tabs/data_import_export_tab.vue
@@ -78,6 +78,16 @@
{{ $t('settings.download_backup') }}
</a>
<span
+ v-else-if="backup.state === 'running'"
+ >
+ {{ $tc('settings.backup_running', backup.processed_number, { number: backup.processed_number }) }}
+ </span>
+ <span
+ v-else-if="backup.state === 'failed'"
+ >
+ {{ $t('settings.backup_failed') }}
+ </span>
+ <span
v-else
>
{{ $t('settings.backup_not_ready') }}
diff --git a/src/components/settings_modal/tabs/filtering_tab.js b/src/components/settings_modal/tabs/filtering_tab.js
index 73413b48..7c37f0bc 100644
--- a/src/components/settings_modal/tabs/filtering_tab.js
+++ b/src/components/settings_modal/tabs/filtering_tab.js
@@ -1,4 +1,4 @@
-import { filter, trim } from 'lodash'
+import { filter, trim, debounce } from 'lodash'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
@@ -29,24 +29,20 @@ const FilteringTab = {
},
set (value) {
this.muteWordsStringLocal = value
+ this.debouncedSetMuteWords(value)
+ }
+ },
+ debouncedSetMuteWords () {
+ return debounce((value) => {
this.$store.dispatch('setOption', {
name: 'muteWords',
value: filter(value.split('\n'), (word) => trim(word).length > 0)
})
- }
+ }, 1000)
}
},
// 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/general_tab.js b/src/components/settings_modal/tabs/general_tab.js
index 1e11b9e0..ea24d6ad 100644
--- a/src/components/settings_modal/tabs/general_tab.js
+++ b/src/components/settings_modal/tabs/general_tab.js
@@ -2,6 +2,7 @@ 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'
@@ -43,6 +44,11 @@ const GeneralTab = {
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') ||
@@ -56,11 +62,15 @@ const GeneralTab = {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
+ SizeSetting,
InterfaceLanguageSwitcher,
ScopeSelector,
ServerSideIndicator
},
computed: {
+ horizontalUnits () {
+ return defaultHorizontalUnits
+ },
postFormats () {
return this.$store.state.instance.postFormats || []
},
@@ -71,6 +81,17 @@ 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 &&
diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue
index a2609200..582cb288 100644
--- a/src/components/settings_modal/tabs/general_tab.vue
+++ b/src/components/settings_modal/tabs/general_tab.vue
@@ -15,11 +15,6 @@
{{ $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') }}
@@ -65,22 +60,14 @@
</BooleanSetting>
</li>
<li>
- <BooleanSetting path="disableStickyHeaders">
- {{ $t('settings.disable_sticky_headers') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting path="showScrollbars">
- {{ $t('settings.show_scrollbars') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting
- path="userPopoverZoom"
+ <ChoiceSetting
+ id="userPopoverAvatarAction"
+ path="userPopoverAvatarAction"
+ :options="userPopoverAvatarActionOptions"
expert="1"
>
- {{ $t('settings.user_popover_avatar_zoom') }}
- </BooleanSetting>
+ {{ $t('settings.user_popover_avatar_action') }}
+ </ChoiceSetting>
</li>
<li>
<BooleanSetting
@@ -91,16 +78,6 @@
</BooleanSetting>
</li>
<li>
- <ChoiceSetting
- v-if="user"
- id="thirdColumnMode"
- path="thirdColumnMode"
- :options="thirdColumnModeOptions"
- >
- {{ $t('settings.third_column_mode') }}
- </ChoiceSetting>
- </li>
- <li>
<BooleanSetting
path="alwaysShowNewPostButton"
expert="1"
@@ -124,6 +101,53 @@
{{ $t('settings.hide_shoutbox') }}
</BooleanSetting>
</li>
+ <li>
+ <h3>{{ $t('settings.columns') }}</h3>
+ </li>
+ <li>
+ <BooleanSetting path="disableStickyHeaders">
+ {{ $t('settings.disable_sticky_headers') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="showScrollbars">
+ {{ $t('settings.show_scrollbars') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="sidebarRight">
+ {{ $t('settings.right_sidebar') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <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">
@@ -433,3 +457,17 @@
</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.scss b/src/components/settings_modal/tabs/mutes_and_blocks_tab.scss
index 2adff847..5fa3a27b 100644
--- a/src/components/settings_modal/tabs/mutes_and_blocks_tab.scss
+++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.scss
@@ -1,29 +1,29 @@
.mutes-and-blocks-tab {
- height: 100%;
+ height: 100%;
- .usersearch-wrapper {
- padding: 1em;
- }
+ .usersearch-wrapper {
+ padding: 1em;
+ }
- .bulk-actions {
- text-align: right;
- padding: 0 1em;
- min-height: 2em;
- }
+ .bulk-actions {
+ text-align: right;
+ padding: 0 1em;
+ min-height: 2em;
+ }
- .bulk-action-button {
- width: 10em
- }
+ .bulk-action-button {
+ width: 10em;
+ }
- .domain-mute-form {
- padding: 1em;
- display: flex;
- flex-direction: column
- }
+ .domain-mute-form {
+ padding: 1em;
+ display: flex;
+ flex-direction: column;
+ }
- .domain-mute-button {
- align-self: flex-end;
- margin-top: 1em;
- width: 10em
- }
+ .domain-mute-button {
+ align-self: flex-end;
+ margin-top: 1em;
+ width: 10em;
+ }
}
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 c515d542..ed4b15a4 100644
--- a/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue
+++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue
@@ -56,7 +56,7 @@
<div :label="$t('settings.mutes_tab')">
<tab-switcher>
- <div label="Users">
+ <div :label="$t('settings.user_mutes')">
<div class="usersearch-wrapper">
<Autosuggest
:filter="filterUnMutedUsers"
diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js
index af7206a7..df100162 100644
--- a/src/components/settings_modal/tabs/profile_tab.js
+++ b/src/components/settings_modal/tabs/profile_tab.js
@@ -66,7 +66,7 @@ const ProfileTab = {
emojiUserSuggestor () {
return suggestor({
emoji: [
- ...this.$store.state.instance.emoji,
+ ...this.$store.getters.standardEmojiList,
...this.$store.state.instance.customEmoji
],
store: this.$store
@@ -75,7 +75,7 @@ const ProfileTab = {
emojiSuggestor () {
return suggestor({
emoji: [
- ...this.$store.state.instance.emoji,
+ ...this.$store.getters.standardEmojiList,
...this.$store.state.instance.customEmoji
]
})
@@ -157,7 +157,7 @@ const ProfileTab = {
return false
},
deleteField (index, event) {
- this.$delete(this.newFields, index)
+ this.newFields.splice(index, 1)
},
uploadFile (slot, e) {
const file = e.target.files[0]
diff --git a/src/components/settings_modal/tabs/profile_tab.scss b/src/components/settings_modal/tabs/profile_tab.scss
index 2303f5e8..ee253ffe 100644
--- a/src/components/settings_modal/tabs/profile_tab.scss
+++ b/src/components/settings_modal/tabs/profile_tab.scss
@@ -1,4 +1,5 @@
-@import '../../../_variables.scss';
+@import "../../../variables";
+
.profile-tab {
.bio {
margin: 0;
@@ -8,7 +9,7 @@
padding-top: 5px;
}
- input[type=file] {
+ input[type="file"] {
padding: 5px;
height: auto;
}
@@ -52,7 +53,7 @@
right: 0.2em;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
- background-color: rgba(0, 0, 0, 0.6);
+ background-color: rgb(0 0 0 / 60%);
opacity: 0.7;
width: 1.5em;
height: 1.5em;
diff --git a/src/components/settings_modal/tabs/security_tab/mfa.vue b/src/components/settings_modal/tabs/security_tab/mfa.vue
index 455d17b6..ee5b6b13 100644
--- a/src/components/settings_modal/tabs/security_tab/mfa.vue
+++ b/src/components/settings_modal/tabs/security_tab/mfa.vue
@@ -137,9 +137,11 @@
<script src="./mfa.js"></script>
<style lang="scss">
-@import '../../../../_variables.scss';
+@import "../../../../variables";
+
.mfa-settings {
- .mfa-heading, .method-item {
+ .mfa-heading,
+ .method-item {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
@@ -155,18 +157,19 @@
display: flex;
justify-content: center;
flex-wrap: wrap;
+
.qr-code {
flex: 1;
padding-right: 10px;
}
.verify { flex: 1; }
- .error { margin: 4px 0 0 0; }
+ .error { margin: 4px 0 0; }
+
.confirm-otp-actions {
button {
width: 15em;
margin-top: 5px;
}
-
}
}
}
diff --git a/src/components/settings_modal/tabs/security_tab/mfa_backup_codes.vue b/src/components/settings_modal/tabs/security_tab/mfa_backup_codes.vue
index d7e98b3c..923161b2 100644
--- a/src/components/settings_modal/tabs/security_tab/mfa_backup_codes.vue
+++ b/src/components/settings_modal/tabs/security_tab/mfa_backup_codes.vue
@@ -21,13 +21,14 @@
</template>
<script src="./mfa_backup_codes.js"></script>
<style lang="scss">
-@import '../../../../_variables.scss';
+@import "../../../../variables";
.mfa-backup-codes {
.warning {
color: $fallback--cOrange;
color: var(--cOrange, $fallback--cOrange);
}
+
.backup-codes {
font-family: var(--postCodeFont, monospace);
}
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 c74a0c67..6e03bef4 100644
--- a/src/components/settings_modal/tabs/security_tab/security_tab.vue
+++ b/src/components/settings_modal/tabs/security_tab/security_tab.vue
@@ -241,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 ba6bd529..d755279a 100644
--- a/src/components/settings_modal/tabs/theme_tab/preview.vue
+++ b/src/components/settings_modal/tabs/theme_tab/preview.vue
@@ -33,10 +33,10 @@
scope="global"
keypath="settings.style.preview.text"
>
- <code style="font-family: var(--postCodeFont)">
+ <code style="font-family: var(--postCodeFont);">
{{ $t('settings.style.preview.mono') }}
</code>
- <a style="color: var(--link)">
+ <a style="color: var(--link);">
{{ $t('settings.style.preview.link') }}
</a>
</i18n-t>
@@ -44,25 +44,25 @@
<div class="icons">
<FAIcon
fixed-width
- style="color: var(--cBlue)"
+ style="color: var(--cBlue);"
class="fa-scale-110 fa-old-padding"
icon="reply"
/>
<FAIcon
fixed-width
- style="color: var(--cGreen)"
+ style="color: var(--cGreen);"
class="fa-scale-110 fa-old-padding"
icon="retweet"
/>
<FAIcon
fixed-width
- style="color: var(--cOrange)"
+ style="color: var(--cOrange);"
class="fa-scale-110 fa-old-padding"
icon="star"
/>
<FAIcon
fixed-width
- style="color: var(--cRed)"
+ style="color: var(--cRed);"
class="fa-scale-110 fa-old-padding"
icon="times"
/>
@@ -81,7 +81,7 @@
class="faint"
scope="global"
>
- <a style="color: var(--faintLink)">
+ <a style="color: var(--faintLink);">
{{ $t('settings.style.preview.faint_link') }}
</a>
</i18n-t>
@@ -138,6 +138,7 @@ export default {}
.preview-container {
position: relative;
}
+
.underlay-preview {
position: absolute;
top: 0;
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 282cb384..4a739f73 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
@@ -279,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) => {
@@ -300,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 () {
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 bad6f51b..9935c2e7 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.scss
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.scss
@@ -1,20 +1,17 @@
-@import 'src/_variables.scss';
+@import "src/variables";
+
.theme-tab {
padding-bottom: 2em;
- .theme-warning {
- display: flex;
- align-items: baseline;
- margin-bottom: .5em;
- .buttons {
- .btn {
- margin-bottom: .5em;
- }
- }
- }
+
.preset-switcher {
margin-right: 1em;
}
+ .btn {
+ margin-left: 0.25em;
+ margin-right: 0.25em;
+ }
+
.style-control {
display: flex;
align-items: baseline;
@@ -24,35 +21,37 @@
flex: 1;
}
- &.disabled {
- input, select {
- opacity: .5
- }
- }
-
.opt {
- margin: .5em;
+ margin: 0.5em;
}
.color-input {
flex: 0 0 0;
}
- input, select {
+ input,
+ select {
min-width: 3em;
margin: 0;
flex: 0;
- &[type=number] {
+ &[type="number"] {
min-width: 5em;
}
- &[type=range] {
+ &[type="range"] {
flex: 1;
min-width: 3em;
align-self: flex-start;
}
}
+
+ &.disabled {
+ input,
+ select {
+ opacity: 0.5;
+ }
+ }
}
.reset-container {
@@ -63,8 +62,7 @@
.reset-container,
.apply-container,
.radius-container,
- .color-container,
- {
+ .color-container, {
display: flex;
}
@@ -73,10 +71,11 @@
flex-direction: column;
}
- .color-container{
+ .color-container {
> h4 {
width: 99%;
}
+
flex-wrap: wrap;
justify-content: space-between;
}
@@ -100,7 +99,7 @@
p {
flex: 1;
margin: 0;
- margin-right: .5em;
+ margin-right: 0.5em;
}
}
@@ -112,15 +111,16 @@
min-width: 1px;
flex: 0 auto;
padding: 0 1em;
- margin-bottom: .5em;
+ margin-bottom: 0.5em;
}
}
.shadow-selector {
.override {
flex: 1;
- margin-left: .5em;
+ margin-left: 0.5em;
}
+
.select-container {
margin-top: -4px;
margin-bottom: -3px;
@@ -136,7 +136,7 @@
.presets,
.import-export {
- margin-bottom: .5em;
+ margin-bottom: 0.5em;
}
.import-export {
@@ -144,16 +144,17 @@
}
.override {
- margin-left: .5em;
+ margin-left: 0.5em;
}
}
.save-load-options {
flex-wrap: wrap;
- margin-top: .5em;
+ margin-top: 0.5em;
justify-content: center;
+
.keep-option {
- margin: 0 .5em .5em;
+ margin: 0 0.5em 0.5em;
min-width: 25%;
}
}
@@ -179,11 +180,11 @@
flex: 1;
h4 {
- margin-bottom: .25em;
+ margin-bottom: 0.25em;
}
.icons {
- margin-top: .5em;
+ margin-top: 0.5em;
display: flex;
i {
@@ -199,8 +200,20 @@
align-items: center;
}
- .avatar, .avatar-alt{
- background: linear-gradient(135deg, #b8e1fc 0%,#a9d2f3 10%,#90bae4 25%,#90bcea 37%,#90bff0 50%,#6ba8e5 51%,#a2daf5 83%,#bdf3fd 100%);
+ .avatar,
+ .avatar-alt {
+ background:
+ linear-gradient(
+ 135deg,
+ #b8e1fc 0%,
+ #a9d2f3 10%,
+ #90bae4 25%,
+ #90bcea 37%,
+ #90bff0 50%,
+ #6ba8e5 51%,
+ #a2daf5 83%,
+ #bdf3fd 100%
+ );
color: black;
font-family: sans-serif;
text-align: center;
@@ -251,33 +264,33 @@
}
}
+ .radius-item {
+ flex-basis: auto;
+ }
+
.radius-item,
.color-item {
min-width: 20em;
margin: 5px 6px 0 0;
- display:flex;
+ display: flex;
flex-direction: column;
flex: 1 1 0;
&.wide {
- min-width: 60%
+ min-width: 60%;
}
&:not(.wide):nth-child(2n+1) {
margin-right: 7px;
-
}
- .color, .opacity {
- display:flex;
+ .color,
+ .opacity {
+ display: flex;
align-items: baseline;
}
}
- .radius-item {
- flex-basis: auto;
- }
-
.theme-radius-rn,
.theme-color-cl {
border: 0;
@@ -295,14 +308,11 @@
.theme-radius-in {
min-width: 1em;
- }
-
- .theme-radius-in {
max-width: 7em;
flex: 1;
}
- .theme-radius-lb{
+ .theme-radius-lb {
max-width: 50em;
}
@@ -310,9 +320,16 @@
padding: 20px;
}
- .btn {
- margin-left: .25em;
- margin-right: .25em;
+ .theme-warning {
+ display: flex;
+ align-items: baseline;
+ margin-bottom: 0.5em;
+
+ .buttons {
+ .btn {
+ margin-bottom: 0.5em;
+ }
+ }
}
}
@@ -323,6 +340,7 @@
justify-content: space-around;
flex-grow: 1;
+ /* stylelint-disable-next-line no-descending-specificity */
.btn {
flex-grow: 1;
min-height: 2em;
diff --git a/src/components/shadow_control/shadow_control.vue b/src/components/shadow_control/shadow_control.vue
index 669cac71..7546535d 100644
--- a/src/components/shadow_control/shadow_control.vue
+++ b/src/components/shadow_control/shadow_control.vue
@@ -218,7 +218,8 @@
<script src="./shadow_control.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
+
.shadow-control {
display: flex;
flex-wrap: wrap;
@@ -229,6 +230,7 @@
.shadow-tweak {
margin: 5px 6px 0 0;
}
+
.shadow-preview-container {
flex: 0;
display: flex;
@@ -236,19 +238,19 @@
$side: 15em;
- input[type=number] {
+ input[type="number"] {
width: 5em;
min-width: 2em;
}
+
.x-shift-control,
.y-shift-control {
display: flex;
flex: 0;
- &[disabled=disabled] *{
- opacity: .5
+ &[disabled="disabled"] * {
+ opacity: 0.5;
}
-
}
.x-shift-control {
@@ -256,37 +258,40 @@
}
.x-shift-control .wrap,
- input[type=range] {
+ input[type="range"] {
margin: 0;
width: $side;
height: 2em;
}
+
.y-shift-control {
flex-direction: column;
align-items: flex-end;
+
.wrap {
width: 2em;
height: $side;
}
- input[type=range] {
+
+ input[type="range"] {
transform-origin: 1em 1em;
transform: rotate(90deg);
}
}
+
.preview-window {
flex: 1;
- background-color: #999999;
+ background-color: #999;
display: flex;
align-items: center;
justify-content: center;
background-image:
- linear-gradient(45deg, #666666 25%, transparent 25%),
- linear-gradient(-45deg, #666666 25%, transparent 25%),
- linear-gradient(45deg, transparent 75%, #666666 75%),
- linear-gradient(-45deg, transparent 75%, #666666 75%);
+ linear-gradient(45deg, #666 25%, transparent 25%),
+ linear-gradient(-45deg, #666 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, #666 75%),
+ linear-gradient(-45deg, transparent 75%, #666 75%);
background-size: 20px 20px;
- background-position:0 0, 0 10px, 10px -10px, -10px 0;
-
+ background-position: 0 0, 0 10px, 10px -10px, -10px 0;
border-radius: $fallback--inputRadius;
border-radius: var(--inputRadius, $fallback--inputRadius);
@@ -312,14 +317,15 @@
flex: 1;
}
- .shadow-switcher, .btn {
+ .shadow-switcher,
+ .btn {
min-width: 1px;
margin-right: 5px;
}
.btn {
- padding: 0 .4em;
- margin: 0 .1em;
+ padding: 0 0.4em;
+ margin: 0 0.1em;
}
}
}
diff --git a/src/components/shout_panel/shout_panel.vue b/src/components/shout_panel/shout_panel.vue
index 688c2d61..a7013469 100644
--- a/src/components/shout_panel/shout_panel.vue
+++ b/src/components/shout_panel/shout_panel.vue
@@ -75,7 +75,7 @@
<script src="./shout_panel.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.floating-shout {
position: fixed;
diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js
index f45f8def..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,7 +31,9 @@ library.add(
faSearch,
faTachometerAlt,
faCog,
- faInfoCircle
+ faInfoCircle,
+ faCompass,
+ faList
)
const SideDrawer = {
@@ -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 7547fb08..994ac953 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,12 +56,24 @@
</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"
>
<router-link
:to="{ name: 'chats', params: { username: currentUser.screen_name } }"
- style="position: relative"
+ style="position: relative;"
>
<FAIcon
fixed-width
@@ -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"
>
@@ -207,7 +251,7 @@
<script src="./side_drawer.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.side-drawer-container {
position: fixed;
@@ -240,11 +284,11 @@
z-index: -1;
transition: 0.35s;
transition-property: background-color;
- background-color: rgba(0, 0, 0, 0.5);
+ background-color: rgb(0 0 0 / 50%);
}
.side-drawer-darken-closed {
- background-color: rgba(0, 0, 0, 0);
+ background-color: rgb(0 0 0 / 0%);
}
.side-drawer-click-outside {
@@ -253,20 +297,21 @@
.side-drawer {
overflow-x: hidden;
- transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
transition: 0.35s;
+ transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
transition-property: transform;
margin: 0 0 0 -100px;
padding: 0 0 1em 100px;
width: 80%;
max-width: 20em;
flex: 0 0 80%;
- box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6);
+ box-shadow: 1px 1px 4px rgb(0 0 0 / 60%);
box-shadow: var(--panelShadow);
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);
@@ -316,7 +361,6 @@
list-style: none;
margin: 0;
padding: 0;
-
border-bottom: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
@@ -329,7 +373,8 @@
.side-drawer li {
padding: 0;
- a, button {
+ a,
+ button {
box-sizing: border-box;
display: block;
height: 3em;
@@ -341,6 +386,7 @@
background-color: var(--selectedMenuPopover, $fallback--lightBg);
color: $fallback--text;
color: var(--selectedMenuPopoverText, $fallback--text);
+
--faint: var(--selectedMenuPopoverFaintText, $fallback--faint);
--faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint);
--lightText: var(--selectedMenuPopoverLightText, $fallback--lightText);
diff --git a/src/components/staff_panel/staff_panel.js b/src/components/staff_panel/staff_panel.js
index a7fbc718..46a92ac7 100644
--- a/src/components/staff_panel/staff_panel.js
+++ b/src/components/staff_panel/staff_panel.js
@@ -13,7 +13,7 @@ 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 [
@@ -22,7 +22,7 @@ const StaffPanel = {
].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 6b9e61f2..77155e8b 100644
--- a/src/components/staff_panel/staff_panel.vue
+++ b/src/components/staff_panel/staff_panel.vue
@@ -27,7 +27,6 @@
<script src="./staff_panel.js"></script>
<style lang="scss">
-
.staff-group {
padding-left: 1em;
padding-top: 1em;
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 384063a7..9a9bca7a 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -13,6 +13,7 @@ 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'
@@ -115,7 +116,8 @@ const Status = {
RichContent,
MentionLink,
MentionsLine,
- UserPopover
+ UserPopover,
+ UserLink
},
props: [
'statusoid',
@@ -393,6 +395,12 @@ const Status = {
},
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: {
diff --git a/src/components/status/status.scss b/src/components/status/status.scss
index b3ad3818..44812867 100644
--- a/src/components/status/status.scss
+++ b/src/components/status/status.scss
@@ -1,4 +1,4 @@
-@import '../../_variables.scss';
+@import "../../variables";
.Status {
min-width: 0;
@@ -156,7 +156,8 @@
margin-right: 0.2em;
}
- & .heading-reply-row {
+ & .heading-reply-row,
+ & .heading-edited-row {
position: relative;
align-content: baseline;
font-size: 0.85em;
@@ -180,7 +181,7 @@
.reply-to-popover {
.reply-to:hover::before {
- content: '';
+ content: "";
display: block;
position: absolute;
bottom: 0;
@@ -196,7 +197,7 @@
&.-strikethrough {
.reply-to::after {
- content: '';
+ content: "";
display: block;
position: absolute;
top: 50%;
@@ -335,7 +336,7 @@
margin-left: 0.2em;
&::before {
- content: ' ';
+ content: " ";
}
}
@@ -373,7 +374,7 @@
align-items: center;
&::before {
- content: '';
+ content: "";
position: absolute;
height: 100%;
width: 1px;
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 967a966c..35b15362 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -25,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"
@@ -83,7 +84,7 @@
:user="statusoid.user"
/>
<div class="right-side faint">
- <span
+ <bdi
class="status-username repeater-name"
:title="retweeter"
>
@@ -100,7 +101,7 @@
v-else
:to="retweeterProfileLink"
>{{ retweeter }}</router-link>
- </span>
+ </bdi>
{{ ' ' }}
<FAIcon
icon="retweet"
@@ -164,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"
@@ -261,7 +261,7 @@
v-if="!isPreview"
:status-id="status.parent_visible && status.in_reply_to_status_id"
class="reply-to-popover"
- style="min-width: 0"
+ style="min-width: 0;"
:class="{ '-strikethrough': !status.parent_visible }"
>
<button
@@ -327,6 +327,24 @@
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
diff --git a/src/components/status_body/status_body.scss b/src/components/status_body/status_body.scss
index 039d4c7f..8fd60afc 100644
--- a/src/components/status_body/status_body.scss
+++ b/src/components/status_body/status_body.scss
@@ -1,4 +1,4 @@
-@import '../../_variables.scss';
+@import "../../variables";
.StatusBody {
display: flex;
@@ -40,7 +40,7 @@
.summary-wrapper {
margin-bottom: 0.5em;
border-style: solid;
- border-width: 0 0 1px 0;
+ border-width: 0 0 1px;
border-color: var(--border, $fallback--border);
flex-grow: 0;
@@ -58,8 +58,7 @@
.text-wrapper {
display: flex;
- flex-direction: column;
- flex-wrap: nowrap;
+ flex-flow: column nowrap;
&.-tall-status {
position: relative;
@@ -75,7 +74,7 @@
linear-gradient(to top, white, white);
/* Autoprefixed seem to ignore this one, and also syntax is different */
- -webkit-mask-composite: xor;
+ mask-composite: xor;
mask-composite: exclude;
}
}
@@ -144,7 +143,7 @@
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: xor;
mask-composite: exclude;
}
@@ -158,7 +157,7 @@
.summary-wrapper {
.summary::after {
- content: ': ';
+ content: ": ";
}
line-height: inherit;
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index e2120f7a..c0e5c0b9 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -33,6 +33,7 @@
<gallery
v-if="status.attachments.length !== 0"
class="attachments media-body"
+ :compact="compact"
:nsfw="nsfwClickthrough"
:attachments="status.attachments"
:limit="compact ? 1 : 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..79642e9c
--- /dev/null
+++ b/src/components/status_history_modal/status_history_modal.vue
@@ -0,0 +1,47 @@
+<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.vue b/src/components/status_popover/status_popover.vue
index f4ab357b..311ca099 100644
--- a/src/components/status_popover/status_popover.vue
+++ b/src/components/status_popover/status_popover.vue
@@ -40,14 +40,13 @@
<script src="./status_popover.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
/* popover styles load on-demand, so we need to override */
.status-popover.popover {
font-size: 1rem;
min-width: 15em;
max-width: 95%;
-
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
border-style: solid;
diff --git a/src/components/sticker_picker/sticker_picker.vue b/src/components/sticker_picker/sticker_picker.vue
index dc449ccb..904853c0 100644
--- a/src/components/sticker_picker/sticker_picker.vue
+++ b/src/components/sticker_picker/sticker_picker.vue
@@ -32,24 +32,29 @@
<script src="./sticker_picker.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.sticker-picker {
width: 100%;
+
.contents {
min-height: 250px;
+
.sticker-picker-content {
display: flex;
flex-wrap: wrap;
padding: 0 4px;
+
.sticker {
display: flex;
flex: 1 1 auto;
margin: 4px;
width: 56px;
height: 56px;
+
img {
height: 100%;
+
&:hover {
filter: drop-shadow(0 0 5px var(--accent, $fallback--link));
}
diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js
index d7abbcb5..56fd2fd9 100644
--- a/src/components/still-image/still-image.js
+++ b/src/components/still-image/still-image.js
@@ -7,16 +7,24 @@ const StillImage = {
'imageLoadHandler',
'alt',
'height',
- 'width'
+ 'width',
+ 'dataSrc',
+ 'loading'
],
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
@@ -27,7 +35,15 @@ const StillImage = {
}
},
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)
@@ -42,6 +58,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 ab3080c8..fc46fbe6 100644
--- a/src/components/still-image/still-image.vue
+++ b/src/components/still-image/still-image.vue
@@ -11,11 +11,13 @@
<!-- 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"
+ :loading="loading"
@load="onLoad"
@error="onError"
>
@@ -26,7 +28,7 @@
<script src="./still-image.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.still-image {
position: relative;
@@ -56,13 +58,13 @@
&.animated {
&::before {
zoom: var(--_still_image-label-scale, 1);
- content: 'gif';
+ content: "gif";
position: absolute;
line-height: 1;
font-size: 0.7em;
top: 0.5em;
left: 0.5em;
- background: rgba(127, 127, 127, 0.5);
+ background: rgb(127 127 127 / 50%);
color: #fff;
display: block;
padding: 2px 4px;
diff --git a/src/components/swipe_click/swipe_click.js b/src/components/swipe_click/swipe_click.js
index 238e6df8..2a423901 100644
--- a/src/components/swipe_click/swipe_click.js
+++ b/src/components/swipe_click/swipe_click.js
@@ -5,6 +5,8 @@ import GestureService from '../../services/gesture_service/gesture_service'
* 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
+ * disableClickThreshold: the minimum distance in pixels for the swipe to
+ * not trigger a click
* perpendicularTolerance: see gesture_service
*
* Events:
@@ -34,6 +36,10 @@ const SwipeClick = {
type: Function,
default: () => 30
},
+ disableClickThreshold: {
+ type: Function,
+ default: () => 1
+ },
perpendicularTolerance: {
type: Number,
default: 1.0
@@ -72,6 +78,7 @@ const SwipeClick = {
this.$gesture = new GestureService.SwipeAndClickGesture({
direction: this.direction,
threshold: this.threshold,
+ disableClickThreshold: this.disableClickThreshold,
perpendicularTolerance: this.perpendicularTolerance,
swipePreviewCallback: this.preview,
swipeEndCallback: this.end,
diff --git a/src/components/tab_switcher/tab_switcher.scss b/src/components/tab_switcher/tab_switcher.scss
index 7a086b26..705925c8 100644
--- a/src/components/tab_switcher/tab_switcher.scss
+++ b/src/components/tab_switcher/tab_switcher.scss
@@ -1,5 +1,6 @@
-@import '../../_variables.scss';
+@import "../../variables";
+/* stylelint-disable no-descending-specificity */
.tab-switcher {
display: flex;
@@ -17,9 +18,11 @@
overflow-x: auto;
padding-top: 5px;
flex-direction: row;
+ flex: 0 0 auto;
- &::after, &::before {
- content: '';
+ &::after,
+ &::before {
+ content: "";
flex: 1 1 auto;
border-bottom: 1px solid;
border-bottom-color: $fallback--border;
@@ -38,6 +41,7 @@
border-bottom-color: var(--border, $fallback--border);
}
}
+
.tab {
width: 100%;
min-width: 1px;
@@ -47,6 +51,7 @@
margin-bottom: 6px - 99px;
}
}
+
.contents.scrollable-tabs {
flex-basis: 0;
}
@@ -69,10 +74,11 @@
overflow-x: hidden;
flex-direction: column;
- &::after, &::before {
+ &::after,
+ &::before {
flex-shrink: 0;
- flex-basis: .5em;
- content: '';
+ flex-basis: 0.5em;
+ content: "";
border-right: 1px solid;
border-right-color: $fallback--border;
border-right-color: var(--border, $fallback--border);
@@ -106,7 +112,7 @@
&::before {
flex: 0 0 6px;
- content: '';
+ content: "";
border-right: 1px solid;
border-right-color: $fallback--border;
border-right-color: var(--border, $fallback--border);
@@ -130,12 +136,13 @@
margin-left: 1em;
@media all and (max-width: 800px) {
- padding-left: .25em;
- padding-right: calc(.25em + 200px);
- margin-right: calc(.25em - 200px);
- margin-left: .25em;
+ padding-left: 0.25em;
+ padding-right: calc(0.25em + 200px);
+ margin-right: calc(0.25em - 200px);
+ margin-left: 0.25em;
+
.text {
- display: none
+ display: none;
}
}
}
@@ -144,15 +151,17 @@
.contents {
flex: 1 0 auto;
- min-height: 0px;
+ min-height: 0;
.hidden {
display: none;
}
+
.full-height:not(.hidden) {
height: 100%;
display: flex;
flex-direction: column;
+
> *:not(.mobile-label) {
flex: 1;
}
@@ -195,7 +204,8 @@
position: relative;
box-sizing: border-box;
- &::after, &::before {
+ &::after,
+ &::before {
display: block;
flex: 1 1 auto;
}
@@ -208,7 +218,7 @@
&:not(.active) {
&::after {
- content: '';
+ content: "";
position: absolute;
z-index: 7;
}
@@ -216,11 +226,11 @@
}
.mobile-label {
- padding-left: .3em;
- padding-bottom: .25em;
- margin-top: .5em;
- margin-left: .2em;
- margin-bottom: .25em;
+ padding-left: 0.3em;
+ padding-bottom: 0.25em;
+ margin-top: 0.5em;
+ margin-left: 0.2em;
+ margin-bottom: 0.25em;
border-bottom: 1px solid var(--border, $fallback--border);
@media all and (min-width: 800px) {
@@ -228,3 +238,4 @@
}
}
}
+/* stylelint-enable no-descending-specificity */
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 1df41d70..bff0ae74 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
@@ -17,6 +17,6 @@
<style lang="scss">
.tos-content {
- margin: 1em
+ margin: 1em;
}
</style>
diff --git a/src/components/thread_tree/thread_tree.vue b/src/components/thread_tree/thread_tree.vue
index 4eaf597d..04727278 100644
--- a/src/components/thread_tree/thread_tree.vue
+++ b/src/components/thread_tree/thread_tree.vue
@@ -1,5 +1,5 @@
<template>
- <div class="thread-tree">
+ <article class="thread-tree">
<status
:key="status.id"
ref="statusComponent"
@@ -113,13 +113,14 @@
</template>
</i18n-t>
</div>
- </div>
+ </article>
</template>
<script src="./thread_tree.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
+
.thread-tree-replies {
margin-left: var(--status-margin, $status-margin);
border-left: 2px solid var(--border, $fallback--border);
@@ -127,6 +128,7 @@
.thread-tree-replies-hidden {
padding: var(--status-margin, $status-margin);
+
/* Make the button stretch along the whole row */
display: flex;
align-items: stretch;
diff --git a/src/components/timeago/timeago.vue b/src/components/timeago/timeago.vue
index 2b487dfd..b5f49515 100644
--- a/src/components/timeago/timeago.vue
+++ b/src/components/timeago/timeago.vue
@@ -3,7 +3,7 @@
:datetime="time"
:title="localeDateString"
>
- {{ $tc(relativeTime.key, relativeTime.num, [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,6 +26,23 @@ 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 () {
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index c575e876..b7414610 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -1,15 +1,21 @@
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
)
const Timeline = {
@@ -18,6 +24,7 @@ const Timeline = {
'timelineName',
'title',
'userId',
+ 'listId',
'tag',
'embedded',
'count',
@@ -27,6 +34,7 @@ const Timeline = {
],
data () {
return {
+ showScrollTop: false,
paused: false,
unfocused: false,
bottomedOut: false,
@@ -38,7 +46,8 @@ const Timeline = {
Status,
Conversation,
TimelineMenu,
- TimelineQuickSettings
+ QuickFilterSettings,
+ QuickViewSettings
},
computed: {
filteredVisibleStatuses () {
@@ -60,6 +69,13 @@ 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'] : ['-nonpanel']
if (this.blockingClicks) rootClasses = rootClasses.concat(['-blocked', '_misclick-prevention'])
@@ -84,7 +100,10 @@ const Timeline = {
},
virtualScrollingEnabled () {
return this.$store.getters.mergedConfig.virtualScrolling
- }
+ },
+ ...mapState({
+ mobileLayout: state => state.interface.layoutType === 'mobile'
+ })
},
created () {
const store = this.$store
@@ -101,6 +120,7 @@ const Timeline = {
timeline: this.timelineName,
showImmediately,
userId: this.userId,
+ listId: this.listId,
tag: this.tag
})
},
@@ -119,6 +139,9 @@ const Timeline = {
this.$store.commit('setLoading', { timeline: this.timelineName, value: false })
},
methods: {
+ scrollToTop () {
+ window.scrollTo({ top: this.$el.offsetTop })
+ },
stopBlockingClicks: debounce(function () {
this.blockingClicks = false
}, 1000),
@@ -156,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) {
@@ -217,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 9e009fd3..4371947d 100644
--- a/src/components/timeline/timeline.scss
+++ b/src/components/timeline/timeline.scss
@@ -1,8 +1,35 @@
-@import '../../_variables.scss';
+@import "../../variables";
.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 {
@@ -19,10 +46,9 @@
text-align: center;
line-height: 2.75em;
padding: 0 0.5em;
- }
- .timeline-heading {
- .button-default, .alert {
+ .button-default,
+ .alert {
line-height: 2em;
width: 100%;
}
diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue
index 266c1d9a..2279f21a 100644
--- a/src/components/timeline/timeline.vue
+++ b/src/components/timeline/timeline.vue
@@ -1,31 +1,96 @@
<template>
<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-if="!embedded"
- 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"
>
<conversation
v-for="statusId in filteredPinnedStatusIds"
:key="statusId + '-pinned'"
+ role="listitem"
class="status-fadein"
:status-id="statusId"
:collapsable="true"
@@ -36,6 +101,7 @@
<conversation
v-for="status in filteredVisibleStatuses"
:key="status.id"
+ role="listitem"
class="status-fadein"
:status-id="status.id"
:collapsable="true"
diff --git a/src/components/timeline_menu/timeline_menu.js b/src/components/timeline_menu/timeline_menu.js
index a11e7b7e..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'
@@ -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 c24b9d72..5f1da1f7 100644
--- a/src/components/timeline_menu/timeline_menu.vue
+++ b/src/components/timeline_menu/timeline_menu.vue
@@ -10,7 +10,19 @@
@close="() => isOpen = false"
>
<template #content>
- <TimelineMenuContent />
+ <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 #trigger>
<span class="button-unstyled title timeline-menu-title">
@@ -33,56 +45,7 @@
<script src="./timeline_menu.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
-
-.TimelineMenu {
- margin-right: auto;
- min-width: 0;
-
- .popover-trigger-button {
- vertical-align: bottom;
- }
-
- .panel::after {
- border-top-right-radius: 0;
- border-top-left-radius: 0;
- }
-
- .timeline-menu-title {
- margin: 0;
- cursor: pointer;
- user-select: none;
- width: 100%;
- display: flex;
-
- .timeline-menu-name {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- svg {
- margin-left: 0.6em;
- transition: transform 100ms;
- }
-
- .click-blocker {
- cursor: default;
- flex-grow: 1;
- }
- }
-
- &.open .timeline-menu-title svg {
- color: $fallback--text;
- color: var(--panelText, $fallback--text);
- transform: rotate(180deg);
- }
-
- .panel {
- box-shadow: var(--popoverShadow);
- }
-
-}
+@import "../../variables";
.timeline-menu-popover {
min-width: 24rem;
@@ -98,24 +61,6 @@
padding: 0;
}
- li {
- border-bottom: 1px solid;
- border-color: $fallback--border;
- border-color: var(--border, $fallback--border);
- padding: 0;
-
- &:last-child a {
- border-bottom-right-radius: $fallback--panelRadius;
- border-bottom-right-radius: var(--panelRadius, $fallback--panelRadius);
- border-bottom-left-radius: $fallback--panelRadius;
- border-bottom-left-radius: var(--panelRadius, $fallback--panelRadius);
- }
-
- &:last-child {
- border: none;
- }
- }
-
a {
display: block;
padding: 0 0.65em;
@@ -127,6 +72,7 @@
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);
@@ -139,6 +85,7 @@
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);
@@ -154,6 +101,71 @@
margin-left: -0.2em;
}
}
+
+ li {
+ border-bottom: 1px solid;
+ border-color: $fallback--border;
+ border-color: var(--border, $fallback--border);
+ padding: 0;
+
+ &:last-child a {
+ border-bottom-right-radius: $fallback--panelRadius;
+ border-bottom-right-radius: var(--panelRadius, $fallback--panelRadius);
+ border-bottom-left-radius: $fallback--panelRadius;
+ border-bottom-left-radius: var(--panelRadius, $fallback--panelRadius);
+ }
+
+ &:last-child {
+ border: none;
+ }
+ }
}
+.TimelineMenu {
+ margin-right: auto;
+ min-width: 0;
+
+ .popover-trigger-button {
+ vertical-align: bottom;
+ }
+
+ .panel::after {
+ border-top-right-radius: 0;
+ border-top-left-radius: 0;
+ }
+
+ .timeline-menu-title {
+ margin: 0;
+ cursor: pointer;
+ user-select: none;
+ width: 100%;
+ display: flex;
+
+ .timeline-menu-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ svg {
+ margin-left: 0.6em;
+ transition: transform 100ms;
+ }
+
+ .click-blocker {
+ cursor: default;
+ flex-grow: 1;
+ }
+ }
+
+ &.open .timeline-menu-title svg {
+ color: $fallback--text;
+ color: var(--panelText, $fallback--text);
+ transform: rotate(180deg);
+ }
+
+ .panel {
+ box-shadow: var(--popoverShadow);
+ }
+}
</style>
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 59e9e43c..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..4337acc4
--- /dev/null
+++ b/src/components/update_notification/update_notification.scss
@@ -0,0 +1,115 @@
+@import "src/variables";
+
+.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;
+ 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 rgb(0 0 0 / 50%));
+ 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: 70%;
+ }
+
+ .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.vue b/src/components/user_avatar/user_avatar.vue
index f4d294df..91c17611 100644
--- a/src/components/user_avatar/user_avatar.vue
+++ b/src/components/user_avatar/user_avatar.vue
@@ -27,7 +27,7 @@
<script src="./user_avatar.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.Avatar {
--_avatarShadowBox: var(--avatarStatusShadow);
@@ -85,10 +85,9 @@
right: 0;
margin: -0.2em;
padding: 0.2em;
- background: rgba(127, 127, 127, 0.5);
+ background: rgb(127 127 127 / 50%);
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 4c81e2c7..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'
@@ -38,7 +40,8 @@ export default {
'rounded',
'bordered',
'avatarAction', // default - open profile, 'zoom' - zoom, function - call function
- 'onClose'
+ 'onClose',
+ 'hasNoteEditor'
],
data () {
return {
@@ -124,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: {
@@ -134,7 +147,9 @@ export default {
ProgressButton,
FollowButton,
Select,
- RichContent
+ RichContent,
+ UserLink,
+ UserNote
},
methods: {
muteUser () {
diff --git a/src/components/user_card/user_card.scss b/src/components/user_card/user_card.scss
index a0bbc6a6..d56b6672 100644
--- a/src/components/user_card/user_card.scss
+++ b/src/components/user_card/user_card.scss
@@ -1,4 +1,4 @@
-@import '../../_variables.scss';
+@import "../../variables";
.user-card {
position: relative;
@@ -11,7 +11,7 @@
}
.panel-heading {
- padding: .5em 0;
+ padding: 0.5em 0;
text-align: center;
box-shadow: none;
background: transparent;
@@ -35,10 +35,11 @@
left: 0;
right: 0;
bottom: 0;
- mask: linear-gradient(to top, white, transparent) bottom no-repeat,
- linear-gradient(to top, white, white);
+ 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: xor;
mask-composite: exclude;
background-size: cover;
mask-size: 100% 60%;
@@ -159,17 +160,17 @@
top: 0;
right: 0;
bottom: 0;
- background-color: rgba(0, 0, 0, 0.3);
+ background-color: rgb(0 0 0 / 30%);
display: flex;
justify-content: center;
align-items: center;
border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $fallback--avatarRadius);
opacity: 0;
- transition: opacity .2s ease;
+ transition: opacity 0.2s ease;
svg {
- color: #FFF;
+ color: #fff;
}
}
@@ -178,7 +179,8 @@
}
}
- .external-link-button, .edit-profile-button {
+ .external-link-button,
+ .edit-profile-button {
cursor: pointer;
width: 2.5em;
text-align: center;
@@ -191,34 +193,6 @@
}
}
- .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;
@@ -253,8 +227,36 @@
}
}
+ .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;
+ }
+
.user-meta {
- margin-bottom: .15em;
+ margin-bottom: 0.15em;
display: flex;
align-items: baseline;
line-height: 22px;
@@ -263,7 +265,7 @@
.following {
flex: 1 0 auto;
margin: 0;
- margin-bottom: .25em;
+ margin-bottom: 0.25em;
text-align: left;
}
@@ -271,7 +273,7 @@
flex: 0 1 auto;
display: flex;
flex-wrap: wrap;
- margin-right: -.5em;
+ margin-right: -0.5em;
align-self: start;
.userHighlightCl {
@@ -294,19 +296,20 @@
.userHighlightText,
.userHighlightSel {
vertical-align: top;
- margin-right: .5em;
- margin-bottom: .25em;
+ margin-right: 0.5em;
+ margin-bottom: 0.25em;
}
}
}
+
.user-interactions {
position: relative;
display: flex;
flex-flow: row wrap;
- margin-right: -.75em;
+ margin-right: -0.75em;
> * {
- margin: 0 .75em .6em 0;
+ margin: 0 0.75em 0.6em 0;
white-space: nowrap;
min-width: 95px;
}
@@ -315,6 +318,10 @@
margin: 0;
}
}
+
+ .user-note {
+ margin: 0 0.75em 0.6em 0;
+ }
}
.sidebar .edit-profile-button {
@@ -323,8 +330,8 @@
.user-counts {
display: flex;
- line-height:16px;
- padding: .5em 1.5em 0em 1.5em;
+ line-height: 16px;
+ padding: 0.5em 1.5em 0;
text-align: center;
justify-content: space-between;
color: $fallback--lightText;
@@ -334,14 +341,16 @@
.user-count {
flex: 1 0 auto;
- padding: .5em 0 .5em 0;
- margin: 0 .5em;
+ padding: 0.5em 0;
+ margin: 0 0.5em;
h5 {
- font-size:1em;
+ font-size: 1em;
font-weight: bolder;
margin: 0 0 0.25em;
}
+
+ /* stylelint-disable-next-line no-descending-specificity */
a {
text-decoration: none;
}
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index ace89c51..349c7cb1 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -106,13 +106,10 @@
</button>
</div>
<div class="bottom-line">
- <router-link
+ <user-link
class="user-screen-name"
- :title="user.screen_name_ui"
- :to="userProfileLink(user)"
- >
- @{{ user.screen_name_ui }}
- </router-link>
+ :user="user"
+ />
<template v-if="!hideBio">
<span
v-if="user.deactivated"
@@ -261,7 +258,7 @@
</button>
</div>
<ModerationTools
- v-if="loggedIn.role === &quot;admin&quot;"
+ v-if="showModerationMenu"
:user="user"
/>
</div>
@@ -271,6 +268,12 @@
>
<RemoteFollow :user="user" />
</div>
+ <UserNote
+ v-if="loggedIn && isOtherUser && (hasNote || (hasNoteEditor && supportsNote))"
+ :user="user"
+ :relationship="relationship"
+ :editable="hasNoteEditor"
+ />
</div>
</div>
<div
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 e24eb9f7..046e0abd 100644
--- a/src/components/user_list_popover/user_list_popover.js
+++ b/src/components/user_list_popover/user_list_popover.js
@@ -1,5 +1,6 @@
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'
@@ -15,6 +16,7 @@ const UserListPopover = {
],
components: {
RichContent,
+ UnicodeDomainIndicator,
Popover: defineAsyncComponent(() => import('../popover/popover.vue')),
UserAvatar: defineAsyncComponent(() => import('../user_avatar/user_avatar.vue'))
},
diff --git a/src/components/user_list_popover/user_list_popover.vue b/src/components/user_list_popover/user_list_popover.vue
index a3ce54c3..8307cc8a 100644
--- a/src/components/user_list_popover/user_list_popover.vue
+++ b/src/components/user_list_popover/user_list_popover.vue
@@ -29,7 +29,7 @@
: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>
@@ -48,7 +48,7 @@
<script src="./user_list_popover.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.user-list-popover {
padding: 0.5em;
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..4e05951f
--- /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 243de387..95ec97af 100644
--- a/src/components/user_panel/user_panel.vue
+++ b/src/components/user_panel/user_panel.vue
@@ -1,5 +1,5 @@
<template>
- <div class="user-panel">
+ <aside class="user-panel">
<div
v-if="signedIn"
key="user-panel-signed"
@@ -16,7 +16,7 @@
v-else
key="user-panel"
/>
- </div>
+ </aside>
</template>
<script src="./user_panel.js"></script>
diff --git a/src/components/user_popover/user_popover.js b/src/components/user_popover/user_popover.js
index 69b25383..3b12aa1e 100644
--- a/src/components/user_popover/user_popover.js
+++ b/src/components/user_popover/user_popover.js
@@ -11,8 +11,8 @@ const UserPopover = {
Popover: defineAsyncComponent(() => import('../popover/popover.vue'))
},
computed: {
- userPopoverZoom () {
- return this.$store.getters.mergedConfig.userPopoverZoom
+ userPopoverAvatarAction () {
+ return this.$store.getters.mergedConfig.userPopoverAvatarAction
},
userPopoverOverlay () {
return this.$store.getters.mergedConfig.userPopoverOverlay
diff --git a/src/components/user_popover/user_popover.vue b/src/components/user_popover/user_popover.vue
index 4e999672..3b2bbc45 100644
--- a/src/components/user_popover/user_popover.vue
+++ b/src/components/user_popover/user_popover.vue
@@ -14,7 +14,7 @@
class="user-popover"
:user-id="userId"
:hide-bio="true"
- :avatar-action="userPopoverZoom ? 'zoom' : close"
+ :avatar-action="userPopoverAvatarAction == 'close' ? close : userPopoverAvatarAction"
:on-close="close"
/>
</template>
@@ -24,10 +24,12 @@
<script src="./user_popover.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
/* popover styles load on-demand, so we need to override */
+/* stylelint-disable block-no-empty */
.user-popover.popover {
}
+/* stylelint-enable block-no-empty */
</style>
diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index 16551624..acb612ed 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -48,7 +48,7 @@ const UserProfile = {
},
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)
},
unmounted () {
@@ -113,12 +113,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')
@@ -157,12 +162,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 6032abeb..c63a303c 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -10,6 +10,7 @@
:selected="timeline.viewing"
avatar-action="zoom"
rounded="top"
+ :has-note-editor="true"
/>
<span
v-if="!!user.birthday"
@@ -149,7 +150,7 @@
<script src="./user_profile.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.user-profile {
flex: 2;
@@ -199,7 +200,8 @@
margin: 0 0 0 0.25em;
}
- .user-profile-field-name, .user-profile-field-value {
+ .user-profile-field-name,
+ .user-profile-field-value {
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
@@ -217,6 +219,7 @@
padding: 2em;
}
}
+
.user-profile-placeholder {
.panel-body {
display: flex;
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 429a66e2..092c514e 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">
@@ -68,7 +72,7 @@
<script src="./user_reporting_modal.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import "../../variables";
.user-reporting-panel {
width: 90vw;
@@ -117,7 +121,7 @@
}
.alert {
- margin: 1em 0 0 0;
+ margin: 1em 0 0;
line-height: 1.3em;
}
}
diff --git a/src/components/who_to_follow/who_to_follow.vue b/src/components/who_to_follow/who_to_follow.vue
index 3a17d0e2..56543d91 100644
--- a/src/components/who_to_follow/who_to_follow.vue
+++ b/src/components/who_to_follow/who_to_follow.vue
@@ -15,6 +15,3 @@
</template>
<script src="./who_to_follow.js"></script>
-
-<style lang="scss">
-</style>
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 c1ba6fb1..0fecec0b 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
@@ -33,24 +33,28 @@
.who-to-follow * {
vertical-align: middle;
}
+
.who-to-follow img {
width: 32px;
height: 32px;
}
+
.who-to-follow {
- padding: 0em 1em;
- margin: 0px;
+ padding: 0 1em;
+ margin: 0;
}
+
.who-to-follow-items {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
- padding: 0px;
- margin: 1em 0em;
+ padding: 0;
+ margin: 1em 0;
}
+
.who-to-follow-more {
- padding: 0px;
- margin: 1em 0em;
+ padding: 0;
+ margin: 1em 0;
text-align: center;
}
</style>