aboutsummaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/about/about.vue2
-rw-r--r--src/components/account_actions/account_actions.js7
-rw-r--r--src/components/account_actions/account_actions.vue12
-rw-r--r--src/components/attachment/attachment.js3
-rw-r--r--src/components/avatar_list/avatar_list.vue2
-rw-r--r--src/components/basic_user_card/basic_user_card.js16
-rw-r--r--src/components/basic_user_card/basic_user_card.vue40
-rw-r--r--src/components/chat/chat.js12
-rw-r--r--src/components/chat_list/chat_list.vue2
-rw-r--r--src/components/chat_message/chat_message.js8
-rw-r--r--src/components/chat_message/chat_message.vue16
-rw-r--r--src/components/chat_title/chat_title.js10
-rw-r--r--src/components/chat_title/chat_title.vue8
-rw-r--r--src/components/checkbox/checkbox.vue4
-rw-r--r--src/components/color_input/color_input.scss8
-rw-r--r--src/components/color_input/color_input.vue2
-rw-r--r--src/components/conversation/conversation.js24
-rw-r--r--src/components/conversation/conversation.vue70
-rw-r--r--src/components/desktop_nav/desktop_nav.js24
-rw-r--r--src/components/desktop_nav/desktop_nav.scss25
-rw-r--r--src/components/desktop_nav/desktop_nav.vue3
-rw-r--r--src/components/domain_mute_card/domain_mute_card.vue4
-rw-r--r--src/components/edit_status_modal/edit_status_modal.js75
-rw-r--r--src/components/edit_status_modal/edit_status_modal.vue48
-rw-r--r--src/components/emoji_input/emoji_input.js198
-rw-r--r--src/components/emoji_input/emoji_input.vue205
-rw-r--r--src/components/emoji_input/suggestor.js45
-rw-r--r--src/components/emoji_picker/emoji_picker.js310
-rw-r--r--src/components/emoji_picker/emoji_picker.scss59
-rw-r--r--src/components/emoji_picker/emoji_picker.vue197
-rw-r--r--src/components/emoji_reactions/emoji_reactions.vue84
-rw-r--r--src/components/extra_buttons/extra_buttons.js54
-rw-r--r--src/components/extra_buttons/extra_buttons.vue90
-rw-r--r--src/components/favorite_button/favorite_button.js17
-rw-r--r--src/components/favorite_button/favorite_button.vue63
-rw-r--r--src/components/features_panel/features_panel.vue2
-rw-r--r--src/components/flash/flash.js2
-rw-r--r--src/components/follow_card/follow_card.js4
-rw-r--r--src/components/follow_card/follow_card.vue11
-rw-r--r--src/components/font_control/font_control.vue2
-rw-r--r--src/components/global_notice_list/global_notice_list.vue4
-rw-r--r--src/components/hashtag_link/hashtag_link.vue4
-rw-r--r--src/components/image_cropper/image_cropper.js2
-rw-r--r--src/components/instance_specific_panel/instance_specific_panel.vue2
-rw-r--r--src/components/interactions/interactions.js5
-rw-r--r--src/components/interactions/interactions.vue9
-rw-r--r--src/components/interface_language_switcher/interface_language_switcher.vue1
-rw-r--r--src/components/lists/lists.js27
-rw-r--r--src/components/lists/lists.vue33
-rw-r--r--src/components/lists_card/lists_card.js16
-rw-r--r--src/components/lists_card/lists_card.vue51
-rw-r--r--src/components/lists_edit/lists_edit.js145
-rw-r--r--src/components/lists_edit/lists_edit.vue228
-rw-r--r--src/components/lists_menu/lists_menu_content.js22
-rw-r--r--src/components/lists_menu/lists_menu_content.vue12
-rw-r--r--src/components/lists_timeline/lists_timeline.js36
-rw-r--r--src/components/lists_timeline/lists_timeline.vue10
-rw-r--r--src/components/lists_user_search/lists_user_search.js51
-rw-r--r--src/components/lists_user_search/lists_user_search.vue47
-rw-r--r--src/components/login_form/login_form.js2
-rw-r--r--src/components/login_form/login_form.vue2
-rw-r--r--src/components/media_modal/media_modal.vue2
-rw-r--r--src/components/media_upload/media_upload.js5
-rw-r--r--src/components/media_upload/media_upload.vue2
-rw-r--r--src/components/mention_link/mention_link.js26
-rw-r--r--src/components/mention_link/mention_link.scss29
-rw-r--r--src/components/mention_link/mention_link.vue94
-rw-r--r--src/components/mentions_line/mentions_line.vue17
-rw-r--r--src/components/mfa_form/recovery_form.vue2
-rw-r--r--src/components/mobile_nav/mobile_nav.js32
-rw-r--r--src/components/mobile_nav/mobile_nav.vue76
-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.vue1
-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.vue17
-rw-r--r--src/components/mrf_transparency_panel/mrf_transparency_panel.js6
-rw-r--r--src/components/nav_panel/nav_panel.js80
-rw-r--r--src/components/nav_panel/nav_panel.vue221
-rw-r--r--src/components/navigation/filter.js18
-rw-r--r--src/components/navigation/navigation.js75
-rw-r--r--src/components/navigation/navigation_entry.js51
-rw-r--r--src/components/navigation/navigation_entry.vue133
-rw-r--r--src/components/navigation/navigation_pins.js88
-rw-r--r--src/components/navigation/navigation_pins.vue74
-rw-r--r--src/components/notification/notification.js10
-rw-r--r--src/components/notification/notification.vue70
-rw-r--r--src/components/notifications/notification_filters.vue23
-rw-r--r--src/components/notifications/notifications.js49
-rw-r--r--src/components/notifications/notifications.scss6
-rw-r--r--src/components/notifications/notifications.vue39
-rw-r--r--src/components/optional_router_link/optional_router_link.vue23
-rw-r--r--src/components/popover/popover.js290
-rw-r--r--src/components/popover/popover.vue56
-rw-r--r--src/components/post_status_form/post_status_form.js73
-rw-r--r--src/components/post_status_form/post_status_form.vue18
-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)43
-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.vue58
-rw-r--r--src/components/registration/registration.vue9
-rw-r--r--src/components/remote_follow/remote_follow.js2
-rw-r--r--src/components/remove_follower_button/remove_follower_button.js25
-rw-r--r--src/components/remove_follower_button/remove_follower_button.vue13
-rw-r--r--src/components/reply_button/reply_button.js15
-rw-r--r--src/components/reply_button/reply_button.vue45
-rw-r--r--src/components/report/report.js34
-rw-r--r--src/components/report/report.scss43
-rw-r--r--src/components/report/report.vue74
-rw-r--r--src/components/retweet_button/retweet_button.js17
-rw-r--r--src/components/retweet_button/retweet_button.vue63
-rw-r--r--src/components/search/search.js39
-rw-r--r--src/components/search/search.vue42
-rw-r--r--src/components/search_bar/search_bar.js2
-rw-r--r--src/components/search_bar/search_bar.vue2
-rw-r--r--src/components/selectable_list/selectable_list.vue4
-rw-r--r--src/components/settings_modal/helpers/boolean_setting.js3
-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.vue5
-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/modified_indicator.vue14
-rw-r--r--src/components/settings_modal/helpers/server_side_indicator.vue14
-rw-r--r--src/components/settings_modal/helpers/size_setting.js67
-rw-r--r--src/components/settings_modal/helpers/size_setting.vue54
-rw-r--r--src/components/settings_modal/settings_modal.vue4
-rw-r--r--src/components/settings_modal/tabs/general_tab.js21
-rw-r--r--src/components/settings_modal/tabs/general_tab.vue113
-rw-r--r--src/components/settings_modal/tabs/mutes_and_blocks_tab.vue34
-rw-r--r--src/components/settings_modal/tabs/profile_tab.js12
-rw-r--r--src/components/settings_modal/tabs/profile_tab.vue2
-rw-r--r--src/components/settings_modal/tabs/security_tab/mfa.js6
-rw-r--r--src/components/settings_modal/tabs/security_tab/mfa_totp.js2
-rw-r--r--src/components/settings_modal/tabs/security_tab/security_tab.js2
-rw-r--r--src/components/settings_modal/tabs/theme_tab/preview.vue5
-rw-r--r--src/components/settings_modal/tabs/theme_tab/theme_tab.js8
-rw-r--r--src/components/shadow_control/shadow_control.js8
-rw-r--r--src/components/shadow_control/shadow_control.vue2
-rw-r--r--src/components/shout_panel/shout_panel.js2
-rw-r--r--src/components/shout_panel/shout_panel.vue2
-rw-r--r--src/components/side_drawer/side_drawer.js21
-rw-r--r--src/components/side_drawer/side_drawer.vue30
-rw-r--r--src/components/staff_panel/staff_panel.js8
-rw-r--r--src/components/staff_panel/staff_panel.vue2
-rw-r--r--src/components/status/status.js21
-rw-r--r--src/components/status/status.scss3
-rw-r--r--src/components/status/status.vue65
-rw-r--r--src/components/status_body/status_body.vue2
-rw-r--r--src/components/status_content/status_content.vue2
-rw-r--r--src/components/status_history_modal/status_history_modal.js60
-rw-r--r--src/components/status_history_modal/status_history_modal.vue46
-rw-r--r--src/components/status_popover/status_popover.js7
-rw-r--r--src/components/status_popover/status_popover.vue10
-rw-r--r--src/components/sticker_picker/sticker_picker.js4
-rw-r--r--src/components/still-image/still-image.js27
-rw-r--r--src/components/still-image/still-image.vue5
-rw-r--r--src/components/tab_switcher/tab_switcher.scss1
-rw-r--r--src/components/terms_of_service_panel/terms_of_service_panel.vue2
-rw-r--r--src/components/thread_tree/thread_tree.vue4
-rw-r--r--src/components/timeago/timeago.vue21
-rw-r--r--src/components/timeline/timeline.js35
-rw-r--r--src/components/timeline/timeline.scss31
-rw-r--r--src/components/timeline/timeline.vue91
-rw-r--r--src/components/timeline_menu/timeline_menu.js25
-rw-r--r--src/components/timeline_menu/timeline_menu.vue71
-rw-r--r--src/components/timeline_menu/timeline_menu_content.js29
-rw-r--r--src/components/timeline_menu/timeline_menu_content.vue66
-rw-r--r--src/components/unicode_domain_indicator/unicode_domain_indicator.vue26
-rw-r--r--src/components/update_notification/update_notification.js69
-rw-r--r--src/components/update_notification/update_notification.scss113
-rw-r--r--src/components/update_notification/update_notification.vue103
-rw-r--r--src/components/user_card/user_card.js40
-rw-r--r--src/components/user_card/user_card.scss51
-rw-r--r--src/components/user_card/user_card.vue59
-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.vue8
-rw-r--r--src/components/user_panel/user_panel.vue4
-rw-r--r--src/components/user_popover/user_popover.js23
-rw-r--r--src/components/user_popover/user_popover.vue33
-rw-r--r--src/components/user_profile/user_profile.js15
-rw-r--r--src/components/user_profile/user_profile.vue17
-rw-r--r--src/components/user_reporting_modal/user_reporting_modal.js16
-rw-r--r--src/components/user_reporting_modal/user_reporting_modal.vue12
-rw-r--r--src/components/who_to_follow/who_to_follow.js2
-rw-r--r--src/components/who_to_follow_panel/who_to_follow_panel.js10
-rw-r--r--src/components/who_to_follow_panel/who_to_follow_panel.vue2
192 files changed, 5340 insertions, 1505 deletions
diff --git a/src/components/about/about.vue b/src/components/about/about.vue
index 5d5d6479..33586c97 100644
--- a/src/components/about/about.vue
+++ b/src/components/about/about.vue
@@ -8,7 +8,7 @@
</div>
</template>
-<script src="./about.js" ></script>
+<script src="./about.js"></script>
<style lang="scss">
</style>
diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js
index 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 c35d01af..218aa6b3 100644
--- a/src/components/account_actions/account_actions.vue
+++ b/src/components/account_actions/account_actions.vue
@@ -6,7 +6,7 @@
:bound-to="{ x: 'container' }"
remove-padding
>
- <template v-slot:content>
+ <template #content>
<div class="dropdown-menu">
<template v-if="relationship.following">
<button
@@ -28,6 +28,14 @@
class="dropdown-divider"
/>
</template>
+ <UserListMenu :user="user" />
+ <button
+ v-if="relationship.followed_by"
+ class="btn button-default btn-block dropdown-item"
+ @click="removeUserFromFollowers"
+ >
+ {{ $t('user_card.remove_follower') }}
+ </button>
<button
v-if="relationship.blocking"
class="btn button-default btn-block dropdown-item"
@@ -57,7 +65,7 @@
</button>
</div>
</template>
- <template v-slot:trigger>
+ <template #trigger>
<button class="button-unstyled ellipsis-button">
<FAIcon
class="icon"
diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js
index d62a4adc..5dc50475 100644
--- a/src/components/attachment/attachment.js
+++ b/src/components/attachment/attachment.js
@@ -129,6 +129,9 @@ const Attachment = {
...mapGetters(['mergedConfig'])
},
watch: {
+ 'attachment.description' (newVal) {
+ this.localDescription = newVal
+ },
localDescription (newVal) {
this.onEdit(newVal)
}
diff --git a/src/components/avatar_list/avatar_list.vue b/src/components/avatar_list/avatar_list.vue
index e1b6e971..9a6ca3f6 100644
--- a/src/components/avatar_list/avatar_list.vue
+++ b/src/components/avatar_list/avatar_list.vue
@@ -14,7 +14,7 @@
</div>
</template>
-<script src="./avatar_list.js" ></script>
+<script src="./avatar_list.js"></script>
<style lang="scss">
@import '../../_variables.scss';
diff --git a/src/components/basic_user_card/basic_user_card.js b/src/components/basic_user_card/basic_user_card.js
index 8f41e2fb..31de2d75 100644
--- a/src/components/basic_user_card/basic_user_card.js
+++ b/src/components/basic_user_card/basic_user_card.js
@@ -1,5 +1,6 @@
-import UserCard from '../user_card/user_card.vue'
+import UserPopover from '../user_popover/user_popover.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
+import UserLink from '../user_link/user_link.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@@ -7,20 +8,13 @@ const BasicUserCard = {
props: [
'user'
],
- data () {
- return {
- userExpanded: false
- }
- },
components: {
- UserCard,
+ UserPopover,
UserAvatar,
- RichContent
+ RichContent,
+ UserLink
},
methods: {
- toggleUserExpanded () {
- this.userExpanded = !this.userExpanded
- },
userProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
}
diff --git a/src/components/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue
index eeca7828..418de926 100644
--- a/src/components/basic_user_card/basic_user_card.vue
+++ b/src/components/basic_user_card/basic_user_card.vue
@@ -1,24 +1,22 @@
<template>
<div class="basic-user-card">
- <router-link :to="userProfileLink(user)">
- <UserAvatar
- class="avatar"
- :user="user"
- @click.prevent="toggleUserExpanded"
- />
- </router-link>
- <div
- v-if="userExpanded"
- class="basic-user-card-expanded-content"
+ <router-link
+ :to="userProfileLink(user)"
+ @click.prevent
>
- <UserCard
+ <UserPopover
:user-id="user.id"
- :rounded="true"
- :bordered="true"
- />
- </div>
+ :overlay-centers="true"
+ overlay-centers-selector=".avatar"
+ >
+ <UserAvatar
+ class="user-avatar avatar"
+ :user="user"
+ @click.prevent
+ />
+ </UserPopover>
+ </router-link>
<div
- v-else
class="basic-user-card-collapsed-content"
>
<div
@@ -32,12 +30,10 @@
/>
</div>
<div>
- <router-link
+ <user-link
class="basic-user-card-screen-name"
- :to="userProfileLink(user)"
- >
- @{{ user.screen_name_ui }}
- </router-link>
+ :user="user"
+ />
</div>
<slot />
</div>
@@ -53,6 +49,8 @@
margin: 0;
padding: 0.6em 1em;
+ --emoji-size: 14px;
+
&-collapsed-content {
margin-left: 0.7em;
text-align: left;
diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js
index 9f6e64e3..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')
},
@@ -107,7 +108,7 @@ const Chat = {
}
})
},
- '$route': function () {
+ $route: function () {
this.startFetching()
},
mastoUserSocketStatus (newValue) {
@@ -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_list/chat_list.vue b/src/components/chat_list/chat_list.vue
index 58e8d0b3..1248c4c8 100644
--- a/src/components/chat_list/chat_list.vue
+++ b/src/components/chat_list/chat_list.vue
@@ -23,7 +23,7 @@
class="timeline"
>
<List :items="sortedChatList">
- <template v-slot:item="{item}">
+ <template #item="{item}">
<ChatListItem
:key="item.id"
:compact="false"
diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
index 5bac7736..ebe09814 100644
--- a/src/components/chat_message/chat_message.js
+++ b/src/components/chat_message/chat_message.js
@@ -6,7 +6,7 @@ import Gallery from '../gallery/gallery.vue'
import LinkPreview from '../link-preview/link-preview.vue'
import StatusContent from '../status_content/status_content.vue'
import ChatMessageDate from '../chat_message_date/chat_message_date.vue'
-import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import { defineAsyncComponent } from 'vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faTimes,
@@ -35,7 +35,8 @@ const ChatMessage = {
UserAvatar,
Gallery,
LinkPreview,
- ChatMessageDate
+ ChatMessageDate,
+ UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue'))
},
computed: {
// Returns HH:MM (hours and minutes) in local time.
@@ -49,9 +50,6 @@ const ChatMessage = {
message () {
return this.chatViewItem.data
},
- userProfileLink () {
- return generateProfileLink(this.author.id, this.author.screen_name, this.$store.state.instance.restrictedNicknames)
- },
isMessage () {
return this.chatViewItem.type === 'message'
},
diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue
index d62b831d..d635c47e 100644
--- a/src/components/chat_message/chat_message.vue
+++ b/src/components/chat_message/chat_message.vue
@@ -14,16 +14,16 @@
v-if="!isCurrentUser"
class="avatar-wrapper"
>
- <router-link
+ <UserPopover
v-if="chatViewItem.isHead"
- :to="userProfileLink"
+ :user-id="author.id"
>
<UserAvatar
:compact="true"
:better-shadow="betterShadow"
:user="author"
/>
- </router-link>
+ </UserPopover>
</div>
<div class="chat-message-inner">
<div
@@ -44,13 +44,13 @@
<Popover
trigger="click"
placement="top"
- :bound-to-selector="isCurrentUser ? '' : '.scrollable-message-list'"
+ bound-to-selector=".chat-view-inner"
:bound-to="{ x: 'container' }"
:margin="popoverMarginStyle"
@show="menuOpened = true"
@close="menuOpened = false"
>
- <template v-slot:content>
+ <template #content>
<div class="dropdown-menu">
<button
class="button-default dropdown-item dropdown-item-icon"
@@ -60,7 +60,7 @@
</button>
</div>
</template>
- <template v-slot:trigger>
+ <template #trigger>
<button
class="button-default menu-icon"
:title="$t('chats.more')"
@@ -75,7 +75,7 @@
:status="messageForStatusContent"
:full-content="true"
>
- <template v-slot:footer>
+ <template #footer>
<span
class="created-at"
>
@@ -96,7 +96,7 @@
</div>
</template>
-<script src="./chat_message.js" ></script>
+<script src="./chat_message.js"></script>
<style lang="scss">
@import './chat_message.scss';
diff --git a/src/components/chat_title/chat_title.js b/src/components/chat_title/chat_title.js
index f6e299ad..b8721126 100644
--- a/src/components/chat_title/chat_title.js
+++ b/src/components/chat_title/chat_title.js
@@ -1,12 +1,13 @@
-import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import UserAvatar from '../user_avatar/user_avatar.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
+import { defineAsyncComponent } from 'vue'
export default {
name: 'ChatTitle',
components: {
UserAvatar,
- RichContent
+ RichContent,
+ UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue'))
},
props: [
'user', 'withAvatar'
@@ -18,10 +19,5 @@ export default {
htmlTitle () {
return this.user ? this.user.name_html : ''
}
- },
- methods: {
- getUserProfileLink (user) {
- return generateProfileLink(user.id, user.screen_name)
- }
}
}
diff --git a/src/components/chat_title/chat_title.vue b/src/components/chat_title/chat_title.vue
index 7f6aaaa4..ab7491fa 100644
--- a/src/components/chat_title/chat_title.vue
+++ b/src/components/chat_title/chat_title.vue
@@ -3,16 +3,16 @@
class="chat-title"
:title="title"
>
- <router-link
- class="avatar-container"
+ <UserPopover
v-if="withAvatar && user"
- :to="getUserProfileLink(user)"
+ class="avatar-container"
+ :user-id="user.id"
>
<UserAvatar
class="titlebar-avatar"
:user="user"
/>
- </router-link>
+ </UserPopover>
<RichContent
v-if="user"
class="username"
diff --git a/src/components/checkbox/checkbox.vue b/src/components/checkbox/checkbox.vue
index 83695912..b6768d67 100644
--- a/src/components/checkbox/checkbox.vue
+++ b/src/components/checkbox/checkbox.vue
@@ -22,12 +22,12 @@
<script>
export default {
- emits: ['update:modelValue'],
props: [
'modelValue',
'indeterminate',
'disabled'
- ]
+ ],
+ emits: ['update:modelValue']
}
</script>
diff --git a/src/components/color_input/color_input.scss b/src/components/color_input/color_input.scss
index 8e9923cf..3de31fde 100644
--- a/src/components/color_input/color_input.scss
+++ b/src/components/color_input/color_input.scss
@@ -27,16 +27,16 @@
&.nativeColor {
flex: 0 0 2em;
min-width: 2em;
- align-self: center;
- height: 100%;
+ align-self: stretch;
+ min-height: 100%;
}
}
.computedIndicator,
.transparentIndicator {
flex: 0 0 2em;
min-width: 2em;
- align-self: center;
- height: 100%;
+ align-self: stretch;
+ min-height: 100%;
}
.transparentIndicator {
// forgot to install counter-strike source, ooops
diff --git a/src/components/color_input/color_input.vue b/src/components/color_input/color_input.vue
index e84603c3..dfc084f9 100644
--- a/src/components/color_input/color_input.vue
+++ b/src/components/color_input/color_input.vue
@@ -46,7 +46,6 @@
</div>
</div>
</template>
-<style lang="scss" src="./color_input.scss"></style>
<script>
import Checkbox from '../checkbox/checkbox.vue'
import { hex2rgb } from '../../services/color_convert/color_convert.js'
@@ -108,6 +107,7 @@ export default {
}
}
</script>
+<style lang="scss" src="./color_input.scss"></style>
<style lang="scss">
.color-control {
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index 2ef2977a..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
},
@@ -271,7 +278,7 @@ const conversation = {
result[irid] = result[irid] || []
result[irid].push({
name: `#${i}`,
- id: id
+ id
})
}
i++
@@ -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 6088e1ca..afa04db0 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
@@ -31,8 +41,8 @@
keypath="status.show_all_conversation_with_icon"
tag="button"
class="button-unstyled -link"
- @click.prevent="diveToTopLevel"
scope="global"
+ @click.prevent="diveToTopLevel"
>
<template #icon>
<FAIcon
@@ -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>
diff --git a/src/components/desktop_nav/desktop_nav.js b/src/components/desktop_nav/desktop_nav.js
index e048f53d..08c0e44e 100644
--- a/src/components/desktop_nav/desktop_nav.js
+++ b/src/components/desktop_nav/desktop_nav.js
@@ -46,23 +46,27 @@ export default {
enableMask () { return this.supportsMask && this.$store.state.instance.logoMask },
logoStyle () {
return {
- 'visibility': this.enableMask ? 'hidden' : 'visible'
+ visibility: this.enableMask ? 'hidden' : 'visible'
}
},
logoMaskStyle () {
- return this.enableMask ? {
- 'mask-image': `url(${this.$store.state.instance.logo})`
- } : {
- 'background-color': this.enableMask ? '' : 'transparent'
- }
+ return this.enableMask
+ ? {
+ 'mask-image': `url(${this.$store.state.instance.logo})`
+ }
+ : {
+ 'background-color': this.enableMask ? '' : 'transparent'
+ }
},
logoBgStyle () {
return Object.assign({
- 'margin': `${this.$store.state.instance.logoMargin} 0`,
+ margin: `${this.$store.state.instance.logoMargin} 0`,
opacity: this.searchBarHidden ? 1 : 0
- }, this.enableMask ? {} : {
- 'background-color': this.enableMask ? '' : 'transparent'
- })
+ }, this.enableMask
+ ? {}
+ : {
+ 'background-color': this.enableMask ? '' : 'transparent'
+ })
},
logo () { return this.$store.state.instance.logo },
sitename () { return this.$store.state.instance.name },
diff --git a/src/components/desktop_nav/desktop_nav.scss b/src/components/desktop_nav/desktop_nav.scss
index eddd9707..1ec25385 100644
--- a/src/components/desktop_nav/desktop_nav.scss
+++ b/src/components/desktop_nav/desktop_nav.scss
@@ -2,6 +2,7 @@
.DesktopNav {
width: 100%;
+ z-index: var(--ZI_navbar);
input {
color: var(--inputTopbarText, var(--inputText));
@@ -22,6 +23,26 @@
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)
+ );
+ }
+
+ &.-column-stretch.-wide .inner-nav {
+ max-width: calc(
+ var(--sidebarColumnWidth, var(--miniColumn)) +
+ var(--contentColumnWidth, var(--maxiColumn)) +
+ var(--notifsColumnWidth, var(--miniColumn)) +
+ var(--columnGap)
+ );
+ }
+
&.-logoLeft .inner-nav {
grid-template-columns: auto 2fr 2fr;
grid-template-areas: "logo sitename actions";
@@ -116,4 +137,8 @@
text-align: right;
}
}
+
+ .spacer {
+ width: 1em;
+ }
}
diff --git a/src/components/desktop_nav/desktop_nav.vue b/src/components/desktop_nav/desktop_nav.vue
index bab3ca81..5db7fc79 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.stop="openSettingsModal"
+ @click="openSettingsModal"
>
<FAIcon
fixed-width
@@ -61,6 +61,7 @@
:title="$t('nav.administration')"
/>
</a>
+ <span class="spacer" />
<button
v-if="currentUser"
class="button-unstyled nav-icon"
diff --git a/src/components/domain_mute_card/domain_mute_card.vue b/src/components/domain_mute_card/domain_mute_card.vue
index 836688aa..28c61631 100644
--- a/src/components/domain_mute_card/domain_mute_card.vue
+++ b/src/components/domain_mute_card/domain_mute_card.vue
@@ -9,7 +9,7 @@
class="btn button-default"
>
{{ $t('domain_mute_card.unmute') }}
- <template v-slot:progress>
+ <template #progress>
{{ $t('domain_mute_card.unmute_progress') }}
</template>
</ProgressButton>
@@ -19,7 +19,7 @@
class="btn button-default"
>
{{ $t('domain_mute_card.mute') }}
- <template v-slot:progress>
+ <template #progress>
{{ $t('domain_mute_card.mute_progress') }}
</template>
</ProgressButton>
diff --git a/src/components/edit_status_modal/edit_status_modal.js b/src/components/edit_status_modal/edit_status_modal.js
new file mode 100644
index 00000000..75adfea7
--- /dev/null
+++ b/src/components/edit_status_modal/edit_status_modal.js
@@ -0,0 +1,75 @@
+import PostStatusForm from '../post_status_form/post_status_form.vue'
+import Modal from '../modal/modal.vue'
+import statusPosterService from '../../services/status_poster/status_poster.service.js'
+import get from 'lodash/get'
+
+const EditStatusModal = {
+ components: {
+ PostStatusForm,
+ Modal
+ },
+ data () {
+ return {
+ resettingForm: false
+ }
+ },
+ computed: {
+ isLoggedIn () {
+ return !!this.$store.state.users.currentUser
+ },
+ modalActivated () {
+ return this.$store.state.editStatus.modalActivated
+ },
+ isFormVisible () {
+ return this.isLoggedIn && !this.resettingForm && this.modalActivated
+ },
+ params () {
+ return this.$store.state.editStatus.params || {}
+ }
+ },
+ watch: {
+ params (newVal, oldVal) {
+ if (get(newVal, 'statusId') !== get(oldVal, 'statusId')) {
+ this.resettingForm = true
+ this.$nextTick(() => {
+ this.resettingForm = false
+ })
+ }
+ },
+ isFormVisible (val) {
+ if (val) {
+ this.$nextTick(() => this.$el && this.$el.querySelector('textarea').focus())
+ }
+ }
+ },
+ methods: {
+ doEditStatus ({ status, spoilerText, sensitive, media, contentType, poll }) {
+ const params = {
+ store: this.$store,
+ statusId: this.$store.state.editStatus.params.statusId,
+ status,
+ spoilerText,
+ sensitive,
+ poll,
+ media,
+ contentType
+ }
+
+ return statusPosterService.editStatus(params)
+ .then((data) => {
+ return data
+ })
+ .catch((err) => {
+ console.error('Error editing status', err)
+ return {
+ error: err.message
+ }
+ })
+ },
+ closeModal () {
+ this.$store.dispatch('closeEditStatusModal')
+ }
+ }
+}
+
+export default EditStatusModal
diff --git a/src/components/edit_status_modal/edit_status_modal.vue b/src/components/edit_status_modal/edit_status_modal.vue
new file mode 100644
index 00000000..1dbacaab
--- /dev/null
+++ b/src/components/edit_status_modal/edit_status_modal.vue
@@ -0,0 +1,48 @@
+<template>
+ <Modal
+ v-if="isFormVisible"
+ class="edit-form-modal-view"
+ @backdropClicked="closeModal"
+ >
+ <div class="edit-form-modal-panel panel">
+ <div class="panel-heading">
+ {{ $t('post_status.edit_status') }}
+ </div>
+ <PostStatusForm
+ class="panel-body"
+ v-bind="params"
+ :post-handler="doEditStatus"
+ :disable-polls="true"
+ :disable-visibility-selector="true"
+ @posted="closeModal"
+ />
+ </div>
+ </Modal>
+</template>
+
+<script src="./edit_status_modal.js"></script>
+
+<style lang="scss">
+.modal-view.edit-form-modal-view {
+ align-items: flex-start;
+}
+.edit-form-modal-panel {
+ flex-shrink: 0;
+ margin-top: 25%;
+ margin-bottom: 2em;
+ width: 100%;
+ max-width: 700px;
+
+ @media (orientation: landscape) {
+ margin-top: 8%;
+ }
+
+ .form-bottom-left {
+ max-width: 6.5em;
+
+ .emoji-icon {
+ justify-content: right;
+ }
+ }
+}
+</style>
diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js
index 391cc5b5..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) {
@@ -321,7 +397,7 @@ const EmojiInput = {
}
},
scrollIntoView () {
- const rootRef = this.$refs['picker'].$el
+ const rootRef = this.$refs.picker.$el
/* Scroller is either `window` (replies in TL), sidebar (main post form,
* replies in notifs) or mobile post form. Note that getting and setting
* scroll is different for `Window` and `Element`s
@@ -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..63bf856e 100644
--- a/src/components/emoji_input/emoji_input.vue
+++ b/src/components/emoji_input/emoji_input.vue
@@ -1,11 +1,16 @@
<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 class="hidden-overlay" :style="overlayStyle" ref="hiddenOverlay">
+ <span>{{ preText }}</span>
+ <span class="caret" ref="hiddenOverlayCaret">x</span>
+ <span>{{ postText }}</span>
+ </div>
<template v-if="enableEmojiPicker">
<button
v-if="!hideEmojiButton"
@@ -18,44 +23,61 @@
<EmojiPicker
v-if="enableEmojiPicker"
ref="picker"
- :class="{ hide: !showPicker }"
:enable-sticker-picker="enableStickerPicker"
class="emoji-picker-panel"
@emoji="insert"
@sticker-uploaded="onStickerUploaded"
@sticker-upload-failed="onStickerUploadFailed"
+ @show="onPickerShown"
+ @close="onPickerClosed"
/>
</template>
- <div
- ref="panel"
+ <Popover
class="autocomplete-panel"
- :class="{ hide: !showSuggestions }"
+ placement="bottom"
+ ref="suggestorPopover"
>
- <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>
@@ -87,6 +109,7 @@
color: var(--text, $fallback--text);
}
}
+
.emoji-picker-panel {
position: absolute;
z-index: 20;
@@ -97,89 +120,83 @@
}
}
- .autocomplete {
- &-panel {
- position: absolute;
- z-index: 20;
- margin-top: 2px;
-
- &.hide {
- display: none
- }
+ input, textarea {
+ flex: 1 0 auto;
+ }
- &-body {
- margin: 0 0.5em 0 0.5em;
- border-radius: $fallback--tooltipRadius;
- border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
- box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
- box-shadow: var(--popupShadow);
- min-width: 75%;
- background-color: $fallback--bg;
- background-color: var(--popover, $fallback--bg);
- color: $fallback--link;
- color: var(--popoverText, $fallback--link);
- --faint: var(--popoverFaintText, $fallback--faint);
- --faintLink: var(--popoverFaintLink, $fallback--faint);
- --lightText: var(--popoverLightText, $fallback--lightText);
- --postLink: var(--popoverPostLink, $fallback--link);
- --postFaintLink: var(--popoverPostFaintLink, $fallback--link);
- --icon: var(--popoverIcon, $fallback--icon);
- }
+ .hidden-overlay {
+ opacity: 0;
+ pointer-events: none;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 0;
+ left: 0;
+ overflow: hidden;
+ /* DEBUG STUFF */
+ color: red;
+ /* set opacity to non-zero to see the overlay */
+
+ .caret {
+ width: 0;
+ margin-right: calc(-1ch - 1px);
+ border: 1px solid red;
}
+ }
+}
+.autocomplete {
+ &-panel {
+ position: absolute;
+ }
- &-item {
- display: flex;
- cursor: pointer;
- padding: 0.2em 0.4em;
- border-bottom: 1px solid rgba(0, 0, 0, 0.4);
+ &-item {
+ display: flex;
+ cursor: pointer;
+ padding: 0.2em 0.4em;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.4);
+ height: 32px;
+
+ .image {
+ width: 32px;
height: 32px;
+ line-height: 32px;
+ text-align: center;
+ font-size: 32px;
+
+ 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 bd5c2e39..dd5e5217 100644
--- a/src/components/emoji_picker/emoji_picker.js
+++ b/src/components/emoji_picker/emoji_picker.js
@@ -1,33 +1,77 @@
import { defineAsyncComponent } from 'vue'
import Checkbox from '../checkbox/checkbox.vue'
+import Popover from 'src/components/popover/popover.vue'
+import StillImage from '../still-image/still-image.vue'
+import { ensureFinalFallback } from '../../i18n/languages.js'
+import lozad from 'lozad'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBoxOpen,
faStickyNote,
- faSmileBeam
+ faSmileBeam,
+ faSmile,
+ faUser,
+ faPaw,
+ faIceCream,
+ faBus,
+ faBasketballBall,
+ faLightbulb,
+ faCode,
+ faFlag
} from '@fortawesome/free-solid-svg-icons'
-import { trim } from 'lodash'
+import { debounce, trim } from 'lodash'
library.add(
faBoxOpen,
faStickyNote,
- faSmileBeam
+ faSmileBeam,
+ faSmile,
+ faUser,
+ faPaw,
+ faIceCream,
+ faBus,
+ faBasketballBall,
+ faLightbulb,
+ faCode,
+ faFlag
)
-// At widest, approximately 20 emoji are visible in a row,
-// loading 3 rows, could be overkill for narrow picker
-const LOAD_EMOJI_BY = 60
+const UNICODE_EMOJI_GROUP_ICON = {
+ 'smileys-and-emotion': 'smile',
+ 'people-and-body': 'user',
+ 'animals-and-nature': 'paw',
+ 'food-and-drink': 'ice-cream',
+ 'travel-and-places': 'bus',
+ activities: 'basketball-ball',
+ objects: 'lightbulb',
+ symbols: 'code',
+ flags: 'flag'
+}
-// When to start loading new batch emoji, in pixels
-const LOAD_EMOJI_MARGIN = 64
+const maybeLocalizedKeywords = (emoji, languages, nameLocalizer) => {
+ const res = [emoji.displayText, nameLocalizer(emoji)]
+ if (emoji.annotations) {
+ languages.forEach(lang => {
+ const keywords = emoji.annotations[lang]?.keywords || []
+ const name = emoji.annotations[lang]?.name
+ res.push(...(keywords.concat([name]).filter(k => k)))
+ })
+ }
+ return res
+}
-const filterByKeyword = (list, keyword = '') => {
+const filterByKeyword = (list, keyword = '', languages, nameLocalizer) => {
if (keyword === '') return list
const keywordLowercase = keyword.toLowerCase()
- let orderedEmojiList = []
+ const orderedEmojiList = []
for (const emoji of list) {
- const indexOfKeyword = emoji.displayText.toLowerCase().indexOf(keywordLowercase)
+ const indices = maybeLocalizedKeywords(emoji, languages, nameLocalizer)
+ .map(k => k.toLowerCase().indexOf(keywordLowercase))
+ .filter(k => k > -1)
+
+ const indexOfKeyword = indices.length ? Math.min(...indices) : -1
+
if (indexOfKeyword > -1) {
if (!Array.isArray(orderedEmojiList[indexOfKeyword])) {
orderedEmojiList[indexOfKeyword] = []
@@ -53,16 +97,43 @@ const EmojiPicker = {
showingStickers: false,
groupsScrolledClass: 'scrolled-top',
keepOpen: false,
- customEmojiBufferSlice: LOAD_EMOJI_BY,
customEmojiTimeout: null,
- customEmojiLoadAllConfirmed: false
+ // Lazy-load only after the first time `showing` becomes true.
+ contentLoaded: false,
+ groupRefs: {},
+ emojiRefs: {},
+ filteredEmojiGroups: []
}
},
components: {
StickerPicker: 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 }
+ },
+ setEmojiRef (name) {
+ return el => { this.emojiRefs[name] = el }
+ },
+ onPopoverShown () {
+ this.$emit('show')
+ },
+ onPopoverClosed () {
+ this.$emit('close')
+ },
onStickerUploaded (e) {
this.$emit('sticker-uploaded', e)
},
@@ -71,16 +142,47 @@ const EmojiPicker = {
},
onEmoji (emoji) {
const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement
+ if (!this.keepOpen) {
+ this.$refs.popover.hidePopover()
+ }
this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen })
},
onScroll (e) {
const target = (e && e.target) || this.$refs['emoji-groups']
this.updateScrolledClass(target)
this.scrolledGroup(target)
- this.triggerLoadMore(target)
+ },
+ scrolledGroup (target) {
+ const top = target.scrollTop + 5
+ this.$nextTick(() => {
+ this.allEmojiGroups.forEach(group => {
+ const ref = this.groupRefs['group-' + group.id]
+ if (ref && ref.offsetTop <= top) {
+ this.activeGroup = group.id
+ }
+ })
+ this.scrollHeader()
+ })
+ },
+ scrollHeader () {
+ // Scroll the active tab's header into view
+ const headerRef = this.groupRefs['group-header-' + this.activeGroup]
+ const left = headerRef.offsetLeft
+ const right = left + headerRef.offsetWidth
+ const headerCont = this.$refs.header
+ const currentScroll = headerCont.scrollLeft
+ const currentScrollRight = currentScroll + headerCont.clientWidth
+ const setScroll = s => { headerCont.scrollLeft = s }
+
+ const margin = 7 // .emoji-tabs-item: padding
+ if (left - margin < currentScroll) {
+ setScroll(left - margin)
+ } else if (right + margin > currentScrollRight) {
+ setScroll(right + margin - headerCont.clientWidth)
+ }
},
highlight (key) {
- const ref = this.$refs['group-' + key]
+ const ref = this.groupRefs['group-' + key]
const top = ref.offsetTop
this.setShowStickers(false)
this.activeGroup = key
@@ -97,73 +199,83 @@ 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
+ setShowStickers (value) {
+ this.showingStickers = value
+ },
+ filterByKeyword (list, keyword) {
+ return filterByKeyword(list, keyword, this.languages, this.maybeLocalizedEmojiName)
+ },
+ initializeLazyLoad () {
+ this.destroyLazyLoad()
this.$nextTick(() => {
- this.emojisView.forEach(group => {
- const ref = this.$refs['group-' + group.id]
- if (ref.offsetTop <= top) {
- this.activeGroup = group.id
+ this.$lozad = lozad('.still-image.emoji-picker-emoji', {
+ load: el => {
+ const name = el.getAttribute('data-emoji-name')
+ const vn = this.emojiRefs[name]
+ if (!vn) {
+ return
+ }
+
+ vn.loadLazy()
}
})
+ this.$lozad.observe()
})
},
- loadEmoji () {
- const allLoaded = this.customEmojiBuffer.length === this.filteredEmoji.length
-
- if (allLoaded) {
- return
- }
-
- this.customEmojiBufferSlice += LOAD_EMOJI_BY
+ waitForDomAndInitializeLazyLoad () {
+ this.$nextTick(() => this.initializeLazyLoad())
},
- startEmojiLoad (forceUpdate = false) {
- if (!forceUpdate) {
- this.keyword = ''
+ destroyLazyLoad () {
+ if (this.$lozad) {
+ if (this.$lozad.observer) {
+ this.$lozad.observer.disconnect()
+ }
+ if (this.$lozad.mutationObserver) {
+ this.$lozad.mutationObserver.disconnect()
+ }
}
+ },
+ onShowing () {
+ const oldContentLoaded = this.contentLoaded
this.$nextTick(() => {
- this.$refs['emoji-groups'].scrollTop = 0
+ this.$refs.search.focus()
})
- const bufferSize = this.customEmojiBuffer.length
- const bufferPrefilledAll = bufferSize === this.filteredEmoji.length
- if (bufferPrefilledAll && !forceUpdate) {
- return
+ this.contentLoaded = true
+ this.waitForDomAndInitializeLazyLoad()
+ this.filteredEmojiGroups = this.getFilteredEmojiGroups()
+ if (!oldContentLoaded) {
+ this.$nextTick(() => {
+ if (this.defaultGroup) {
+ this.highlight(this.defaultGroup)
+ }
+ })
}
- this.customEmojiBufferSlice = LOAD_EMOJI_BY
},
- toggleStickers () {
- this.showingStickers = !this.showingStickers
- },
- setShowStickers (value) {
- this.showingStickers = value
+ getFilteredEmojiGroups () {
+ return this.allEmojiGroups
+ .map(group => ({
+ ...group,
+ emojis: this.filterByKeyword(group.emojis, trim(this.keyword))
+ }))
+ .filter(group => group.emojis.length > 0)
}
},
watch: {
keyword () {
- this.customEmojiLoadAllConfirmed = false
this.onScroll()
- this.startEmojiLoad(true)
+ this.debouncedHandleKeywordChange()
+ },
+ allCustomGroups () {
+ this.waitForDomAndInitializeLazyLoad()
+ this.filteredEmojiGroups = this.getFilteredEmojiGroups()
}
},
+ destroyed () {
+ this.destroyLazyLoad()
+ },
computed: {
activeGroupView () {
return this.showingStickers ? '' : this.activeGroup
@@ -174,39 +286,55 @@ const EmojiPicker = {
}
return 0
},
- filteredEmoji () {
- return filterByKeyword(
- this.$store.state.instance.customEmoji || [],
- trim(this.keyword)
- )
+ allCustomGroups () {
+ return this.$store.getters.groupedCustomEmojis
},
- customEmojiBuffer () {
- return this.filteredEmoji.slice(0, this.customEmojiBufferSlice)
+ defaultGroup () {
+ return Object.keys(this.allCustomGroups)[0]
},
- emojis () {
- const standardEmojis = this.$store.state.instance.emoji || []
- const customEmojis = this.customEmojiBuffer
-
- return [
- {
- id: 'custom',
- text: this.$t('emoji.custom'),
- icon: 'smile-beam',
- emojis: customEmojis
- },
- {
- id: 'standard',
- text: this.$t('emoji.unicode'),
- icon: 'box-open',
- emojis: filterByKeyword(standardEmojis, 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.waitForDomAndInitializeLazyLoad()
+ this.filteredEmojiGroups = this.getFilteredEmojiGroups()
+ }, 500)
+ },
+ languages () {
+ return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage)
+ },
+ maybeLocalizedEmojiName () {
+ return emoji => {
+ if (!emoji.annotations) {
+ return emoji.displayText
+ }
+
+ if (emoji.displayTextI18n) {
+ return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)
+ }
+
+ for (const lang of this.languages) {
+ if (emoji.annotations[lang]?.name) {
+ return emoji.annotations[lang].name
+ }
+ }
+
+ return emoji.displayText
+ }
}
}
}
diff --git a/src/components/emoji_picker/emoji_picker.scss b/src/components/emoji_picker/emoji_picker.scss
index 2055e02e..53363ec1 100644
--- a/src/components/emoji_picker/emoji_picker.scss
+++ b/src/components/emoji_picker/emoji_picker.scss
@@ -1,13 +1,15 @@
@import '../../_variables.scss';
+$emoji-picker-header-height: 36px;
+$emoji-picker-header-picture-width: 32px;
+$emoji-picker-header-picture-height: 32px;
+$emoji-picker-emoji-size: 32px;
+
.emoji-picker {
+ width: 25em;
+ max-width: 100vw;
display: flex;
flex-direction: column;
- position: absolute;
- right: 0;
- left: 0;
- margin: 0 !important;
- z-index: 100;
background-color: $fallback--bg;
background-color: var(--popover, $fallback--bg);
color: $fallback--link;
@@ -18,6 +20,23 @@
--lightText: var(--popoverLightText, $fallback--lightText);
--icon: var(--popoverIcon, $fallback--icon);
+ &-header-image {
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ width: $emoji-picker-header-picture-width;
+ max-width: $emoji-picker-header-picture-width;
+ height: $emoji-picker-header-picture-height;
+ max-height: $emoji-picker-header-picture-height;
+ .still-image {
+ max-width: 100%;
+ max-height: 100%;
+ height: 100%;
+ width: 100%;
+ object-fit: contain;
+ }
+ }
+
.keep-open,
.too-many-emoji {
padding: 7px;
@@ -36,7 +55,6 @@
.heading {
display: flex;
- height: 32px;
padding: 10px 7px 5px;
}
@@ -49,6 +67,10 @@
.emoji-tabs {
flex-grow: 1;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ overflow-x: auto;
}
.emoji-groups {
@@ -56,6 +78,8 @@
}
.additional-tabs {
+ display: flex;
+ flex: 1;
border-left: 1px solid;
border-left-color: $fallback--icon;
border-left-color: var(--icon, $fallback--icon);
@@ -65,15 +89,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;
@@ -163,22 +192,26 @@
}
&-item {
- width: 32px;
- height: 32px;
+ width: $emoji-picker-emoji-size;
+ height: $emoji-picker-emoji-size;
box-sizing: border-box;
display: flex;
- font-size: 32px;
+ line-height: $emoji-picker-emoji-size;
align-items: center;
justify-content: center;
margin: 4px;
cursor: pointer;
- img {
+ .emoji-picker-emoji.-custom {
object-fit: contain;
max-width: 100%;
max-height: 100%;
}
+ .emoji-picker-emoji.-unicode {
+ font-size: 24px;
+ overflow: hidden;
+ }
}
}
diff --git a/src/components/emoji_picker/emoji_picker.vue b/src/components/emoji_picker/emoji_picker.vue
index a7269120..a2c99c16 100644
--- a/src/components/emoji_picker/emoji_picker.vue
+++ b/src/components/emoji_picker/emoji_picker.vue
@@ -1,105 +1,136 @@
<template>
- <div class="emoji-picker panel panel-default panel-body">
- <div class="heading">
- <span class="emoji-tabs">
+ <Popover
+ trigger="click"
+ popover-class="emoji-picker popover-default"
+ ref="popover"
+ @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 class="emoji-search">
+ <input
+ v-model="keyword"
+ type="text"
+ class="form-control"
+ :placeholder="$t('emoji.search_emoji')"
+ @input="$event.target.composing = false"
+ ref="search"
+ >
+ </div>
<div
- v-for="group in emojisView"
- :key="group.id"
- class="emoji-group"
+ ref="emoji-groups"
+ class="emoji-groups"
+ :class="groupsScrolledClass"
+ @scroll="onScroll"
>
- <h6
- :ref="'group-' + group.id"
- class="emoji-group-title"
- >
- {{ group.text }}
- </h6>
- <span
- v-for="emoji in group.emojis"
- :key="group.id + emoji.displayText"
- :title="emoji.displayText"
- class="emoji-item"
- @click.stop.prevent="onEmoji(emoji)"
+ <div
+ v-for="group in filteredEmojiGroups"
+ :key="group.id"
+ class="emoji-group"
>
- <span v-if="!emoji.imageUrl">{{ emoji.replacement }}</span>
- <img
- v-else
- :src="emoji.imageUrl"
+ <h6
+ :ref="setGroupRef('group-' + group.id)"
+ class="emoji-group-title"
>
- </span>
- <span :ref="'group-end-' + group.id" />
+ {{ group.text }}
+ </h6>
+ <span
+ v-for="emoji in group.emojis"
+ :key="group.id + emoji.displayText"
+ :title="maybeLocalizedEmojiName(emoji)"
+ class="emoji-item"
+ @click.stop.prevent="onEmoji(emoji)"
+ >
+ <span
+ v-if="!emoji.imageUrl"
+ class="emoji-picker-emoji -unicode"
+ >{{ emoji.replacement }}</span>
+ <still-image
+ v-else
+ :ref="setEmojiRef(group.id + emoji.displayText)"
+ class="emoji-picker-emoji -custom"
+ :data-src="emoji.imageUrl"
+ :data-emoji-name="group.id + emoji.displayText"
+ />
+ </span>
+ <span :ref="setGroupRef('group-end-' + group.id)" />
+ </div>
+ </div>
+ <div class="keep-open">
+ <Checkbox v-model="keepOpen">
+ {{ $t('emoji.keep_open') }}
+ </Checkbox>
</div>
</div>
- <div class="keep-open">
- <Checkbox v-model="keepOpen">
- {{ $t('emoji.keep_open') }}
- </Checkbox>
+ <div
+ v-if="showingStickers"
+ class="stickers-content"
+ >
+ <sticker-picker
+ @uploaded="onStickerUploaded"
+ @upload-failed="onStickerUploadFailed"
+ />
</div>
</div>
- <div
- v-if="showingStickers"
- class="stickers-content"
- >
- <sticker-picker
- @uploaded="onStickerUploaded"
- @upload-failed="onStickerUploadFailed"
- />
- </div>
- </div>
- </div>
+ </template>
+ </Popover>
</template>
<script src="./emoji_picker.js"></script>
diff --git a/src/components/emoji_reactions/emoji_reactions.vue b/src/components/emoji_reactions/emoji_reactions.vue
index 51d50359..4eb22a65 100644
--- a/src/components/emoji_reactions/emoji_reactions.vue
+++ b/src/components/emoji_reactions/emoji_reactions.vue
@@ -1,5 +1,5 @@
<template>
- <div class="emoji-reactions">
+ <div class="EmojiReactions">
<UserListPopover
v-for="(reaction) in emojiReactions"
:key="reaction.name"
@@ -7,7 +7,7 @@
>
<button
class="emoji-reaction btn button-default"
- :class="{ 'picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }"
+ :class="{ '-picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }"
@click="emojiOnClick(reaction.name, $event)"
@mouseenter="fetchEmojiReactionsByIfMissing()"
>
@@ -26,57 +26,59 @@
</div>
</template>
-<script src="./emoji_reactions.js" ></script>
+<script src="./emoji_reactions.js"></script>
<style lang="scss">
@import '../../_variables.scss';
-.emoji-reactions {
+.EmojiReactions {
display: flex;
margin-top: 0.25em;
flex-wrap: wrap;
-}
-.emoji-reaction {
- padding: 0 0.5em;
- margin-right: 0.5em;
- margin-top: 0.5em;
- display: flex;
- align-items: center;
- justify-content: center;
- box-sizing: border-box;
- .reaction-emoji {
- width: 1.25em;
- margin-right: 0.25em;
- }
- &:focus {
- outline: none;
- }
+ .emoji-reaction {
+ padding: 0 0.5em;
+ margin-right: 0.5em;
+ margin-top: 0.5em;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
- &.not-clickable {
- cursor: default;
- &:hover {
- box-shadow: $fallback--buttonShadow;
- box-shadow: var(--buttonShadow);
+ .reaction-emoji {
+ width: 1.25em;
+ margin-right: 0.25em;
+ }
+
+ &:focus {
+ outline: none;
+ }
+
+ &.not-clickable {
+ cursor: default;
+ &:hover {
+ box-shadow: $fallback--buttonShadow;
+ box-shadow: var(--buttonShadow);
+ }
+ }
+
+ &.-picked-reaction {
+ border: 1px solid var(--accent, $fallback--link);
+ margin-left: -1px; // offset the border, can't use inset shadows either
+ margin-right: calc(0.5em - 1px);
}
}
-}
-.emoji-reaction-expand {
- padding: 0 0.5em;
- margin-right: 0.5em;
- margin-top: 0.5em;
- display: flex;
- align-items: center;
- justify-content: center;
- &:hover {
- text-decoration: underline;
+ .emoji-reaction-expand {
+ padding: 0 0.5em;
+ margin-right: 0.5em;
+ margin-top: 0.5em;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ &:hover {
+ text-decoration: underline;
+ }
}
-}
-.picked-reaction {
- border: 1px solid var(--accent, $fallback--link);
- margin-left: -1px; // offset the border, can't use inset shadows either
- margin-right: calc(0.5em - 1px);
}
-
</style>
diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js
index dd45b6b9..3dc968c9 100644
--- a/src/components/extra_buttons/extra_buttons.js
+++ b/src/components/extra_buttons/extra_buttons.js
@@ -6,7 +6,10 @@ import {
faEyeSlash,
faThumbtack,
faShareAlt,
- faExternalLinkAlt
+ faExternalLinkAlt,
+ faHistory,
+ faPlus,
+ faTimes
} from '@fortawesome/free-solid-svg-icons'
import {
faBookmark as faBookmarkReg,
@@ -21,13 +24,27 @@ library.add(
faThumbtack,
faShareAlt,
faExternalLinkAlt,
- faFlag
+ faFlag,
+ faHistory,
+ faPlus,
+ faTimes
)
const ExtraButtons = {
- props: [ 'status' ],
+ props: ['status'],
components: { Popover },
+ data () {
+ return {
+ expanded: false
+ }
+ },
methods: {
+ onShow () {
+ this.expanded = true
+ },
+ onClose () {
+ this.expanded = false
+ },
deleteStatus () {
const confirmed = window.confirm(this.$t('status.delete_confirm'))
if (confirmed) {
@@ -71,14 +88,32 @@ const ExtraButtons = {
},
reportStatus () {
this.$store.dispatch('openUserReportingModal', { userId: this.status.user.id, statusIds: [this.status.id] })
+ },
+ editStatus () {
+ this.$store.dispatch('fetchStatusSource', { id: this.status.id })
+ .then(data => this.$store.dispatch('openEditStatusModal', {
+ statusId: this.status.id,
+ subject: data.spoiler_text,
+ statusText: data.text,
+ statusIsSensitive: this.status.nsfw,
+ statusPoll: this.status.poll,
+ statusFiles: [...this.status.attachments],
+ visibility: this.status.visibility,
+ statusContentType: data.content_type
+ }))
+ },
+ showStatusHistory () {
+ const originalStatus = { ...this.status }
+ const stripFieldsList = ['attachments', 'created_at', 'emojis', 'text', 'raw_html', 'nsfw', 'poll', 'summary', 'summary_raw_html']
+ stripFieldsList.forEach(p => delete originalStatus[p])
+ this.$store.dispatch('openStatusHistoryModal', originalStatus)
}
},
computed: {
currentUser () { return this.$store.state.users.currentUser },
canDelete () {
if (!this.currentUser) { return }
- const superuser = this.currentUser.rights.moderator || this.currentUser.rights.admin
- return superuser || this.status.user.id === this.currentUser.id
+ return this.currentUser.privileges.includes('messages_delete') || this.status.user.id === this.currentUser.id
},
ownStatus () {
return this.status.user.id === this.currentUser.id
@@ -89,9 +124,16 @@ const ExtraButtons = {
canMute () {
return !!this.currentUser
},
+ canBookmark () {
+ return !!this.currentUser
+ },
statusLink () {
return `${this.$store.state.instance.server}${this.$router.resolve({ name: 'conversation', params: { id: this.status.id } }).href}`
- }
+ },
+ isEdited () {
+ return this.status.edited_at !== null
+ },
+ editingAvailable () { return this.$store.state.instance.editingAvailable }
}
}
diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue
index a3c3c767..b2fad1c9 100644
--- a/src/components/extra_buttons/extra_buttons.vue
+++ b/src/components/extra_buttons/extra_buttons.vue
@@ -6,8 +6,10 @@
:offset="{ y: 5 }"
:bound-to="{ x: 'container' }"
remove-padding
+ @show="onShow"
+ @close="onClose"
>
- <template v-slot:content="{close}">
+ <template #content="{close}">
<div class="dropdown-menu">
<button
v-if="canMute && !status.thread_muted"
@@ -51,27 +53,51 @@
icon="thumbtack"
/><span>{{ $t("status.unpin") }}</span>
</button>
+ <template v-if="canBookmark">
+ <button
+ v-if="!status.bookmarked"
+ class="button-default dropdown-item dropdown-item-icon"
+ @click.prevent="bookmarkStatus"
+ @click="close"
+ >
+ <FAIcon
+ fixed-width
+ :icon="['far', 'bookmark']"
+ /><span>{{ $t("status.bookmark") }}</span>
+ </button>
+ <button
+ v-if="status.bookmarked"
+ class="button-default dropdown-item dropdown-item-icon"
+ @click.prevent="unbookmarkStatus"
+ @click="close"
+ >
+ <FAIcon
+ fixed-width
+ icon="bookmark"
+ /><span>{{ $t("status.unbookmark") }}</span>
+ </button>
+ </template>
<button
- v-if="!status.bookmarked"
+ v-if="ownStatus && editingAvailable"
class="button-default dropdown-item dropdown-item-icon"
- @click.prevent="bookmarkStatus"
+ @click.prevent="editStatus"
@click="close"
>
<FAIcon
fixed-width
- :icon="['far', 'bookmark']"
- /><span>{{ $t("status.bookmark") }}</span>
+ icon="pen"
+ /><span>{{ $t("status.edit") }}</span>
</button>
<button
- v-if="status.bookmarked"
+ v-if="isEdited && editingAvailable"
class="button-default dropdown-item dropdown-item-icon"
- @click.prevent="unbookmarkStatus"
+ @click.prevent="showStatusHistory"
@click="close"
>
<FAIcon
fixed-width
- icon="bookmark"
- /><span>{{ $t("status.unbookmark") }}</span>
+ icon="history"
+ /><span>{{ $t("status.status_history") }}</span>
</button>
<button
v-if="canDelete"
@@ -118,21 +144,36 @@
</button>
</div>
</template>
- <template v-slot:trigger>
- <button class="button-unstyled popover-trigger">
- <FAIcon
- class="fa-scale-110 fa-old-padding"
- icon="ellipsis-h"
- />
- </button>
+ <template #trigger>
+ <span class="button-unstyled popover-trigger">
+ <FALayers class="fa-old-padding-layer">
+ <FAIcon
+ class="fa-scale-110 "
+ icon="ellipsis-h"
+ />
+ <FAIcon
+ v-show="!expanded"
+ class="focus-marker"
+ transform="shrink-6 up-8 right-16"
+ icon="plus"
+ />
+ <FAIcon
+ v-show="expanded"
+ class="focus-marker"
+ transform="shrink-6 up-8 right-16"
+ icon="times"
+ />
+ </FALayers>
+ </span>
</template>
</Popover>
</template>
-<script src="./extra_buttons.js" ></script>
+<script src="./extra_buttons.js"></script>
<style lang="scss">
@import '../../_variables.scss';
+@import '../../_mixins.scss';
.ExtraButtons {
/* override of popover internal stuff */
@@ -149,6 +190,21 @@
color: $fallback--text;
color: var(--text, $fallback--text);
}
+
+ }
+
+ .popover-trigger-button {
+ @include unfocused-style {
+ .focus-marker {
+ visibility: hidden;
+ }
+ }
+
+ @include focused-style {
+ .focus-marker {
+ visibility: visible;
+ }
+ }
}
}
</style>
diff --git a/src/components/favorite_button/favorite_button.js b/src/components/favorite_button/favorite_button.js
index 5cd05f73..cf3378c9 100644
--- a/src/components/favorite_button/favorite_button.js
+++ b/src/components/favorite_button/favorite_button.js
@@ -1,13 +1,21 @@
import { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
-import { faStar } from '@fortawesome/free-solid-svg-icons'
+import {
+ faStar,
+ faPlus,
+ faMinus,
+ faCheck
+} from '@fortawesome/free-solid-svg-icons'
import {
faStar as faStarRegular
} from '@fortawesome/free-regular-svg-icons'
library.add(
faStar,
- faStarRegular
+ faStarRegular,
+ faPlus,
+ faMinus,
+ faCheck
)
const FavoriteButton = {
@@ -31,7 +39,10 @@ const FavoriteButton = {
}
},
computed: {
- ...mapGetters(['mergedConfig'])
+ ...mapGetters(['mergedConfig']),
+ remoteInteractionLink () {
+ return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
+ }
}
}
diff --git a/src/components/favorite_button/favorite_button.vue b/src/components/favorite_button/favorite_button.vue
index dce25e24..ea01720a 100644
--- a/src/components/favorite_button/favorite_button.vue
+++ b/src/components/favorite_button/favorite_button.vue
@@ -7,19 +7,45 @@
:title="$t('tool_tip.favorite')"
@click.prevent="favorite()"
>
- <FAIcon
- class="fa-scale-110 fa-old-padding"
- :icon="[status.favorited ? 'fas' : 'far', 'star']"
- :spin="animated"
- />
+ <FALayers class="fa-scale-110 fa-old-padding-layer">
+ <FAIcon
+ class="fa-scale-110"
+ :icon="[status.favorited ? 'fas' : 'far', 'star']"
+ :spin="animated"
+ />
+ <FAIcon
+ v-if="status.favorited"
+ class="active-marker"
+ transform="shrink-6 up-9 right-12"
+ icon="check"
+ />
+ <FAIcon
+ v-if="!status.favorited"
+ class="focus-marker"
+ transform="shrink-6 up-9 right-12"
+ icon="plus"
+ />
+ <FAIcon
+ v-else
+ class="focus-marker"
+ transform="shrink-6 up-9 right-12"
+ icon="minus"
+ />
+ </FALayers>
</button>
- <span v-else>
+ <a
+ v-else
+ class="button-unstyled interactive"
+ target="_blank"
+ role="button"
+ :href="remoteInteractionLink"
+ >
<FAIcon
class="fa-scale-110 fa-old-padding"
:title="$t('tool_tip.favorite')"
:icon="['far', 'star']"
/>
- </span>
+ </a>
<span
v-if="!mergedConfig.hidePostStats && status.fave_num > 0"
class="action-counter"
@@ -29,10 +55,11 @@
</div>
</template>
-<script src="./favorite_button.js" ></script>
+<script src="./favorite_button.js"></script>
<style lang="scss">
@import '../../_variables.scss';
+@import '../../_mixins.scss';
.FavoriteButton {
display: flex;
@@ -57,6 +84,26 @@
color: $fallback--cOrange;
color: var(--cOrange, $fallback--cOrange);
}
+
+ @include unfocused-style {
+ .focus-marker {
+ visibility: hidden;
+ }
+
+ .active-marker {
+ visibility: visible;
+ }
+ }
+
+ @include focused-style {
+ .focus-marker {
+ visibility: visible;
+ }
+
+ .active-marker {
+ visibility: hidden;
+ }
+ }
}
}
</style>
diff --git a/src/components/features_panel/features_panel.vue b/src/components/features_panel/features_panel.vue
index a58a99af..4cdf56d0 100644
--- a/src/components/features_panel/features_panel.vue
+++ b/src/components/features_panel/features_panel.vue
@@ -32,7 +32,7 @@
</div>
</template>
-<script src="./features_panel.js" ></script>
+<script src="./features_panel.js"></script>
<style lang="scss">
.features-panel li {
diff --git a/src/components/flash/flash.js b/src/components/flash/flash.js
index 87f940a7..87c1d650 100644
--- a/src/components/flash/flash.js
+++ b/src/components/flash/flash.js
@@ -11,7 +11,7 @@ library.add(
)
const Flash = {
- props: [ 'src' ],
+ props: ['src'],
data () {
return {
player: false, // can be true, "hidden", false. hidden = element exists
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..c919b11a 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>
@@ -40,6 +45,12 @@
line-height: 1.5em;
}
+ &-button {
+ margin-top: 0.5em;
+ padding: 0 1.5em;
+ margin-left: 1em;
+ }
+
&-follow-button {
margin-top: 0.5em;
margin-left: auto;
diff --git a/src/components/font_control/font_control.vue b/src/components/font_control/font_control.vue
index f100c3a9..83c1cef7 100644
--- a/src/components/font_control/font_control.vue
+++ b/src/components/font_control/font_control.vue
@@ -47,7 +47,7 @@
</div>
</template>
-<script src="./font_control.js" ></script>
+<script src="./font_control.js"></script>
<style lang="scss">
@import '../../_variables.scss';
diff --git a/src/components/global_notice_list/global_notice_list.vue b/src/components/global_notice_list/global_notice_list.vue
index ddc45b81..d828b819 100644
--- a/src/components/global_notice_list/global_notice_list.vue
+++ b/src/components/global_notice_list/global_notice_list.vue
@@ -29,10 +29,10 @@
.global-notice-list {
position: fixed;
- top: 50px;
+ top: calc(var(--navbar-height) + 0.5em);
width: 100%;
pointer-events: none;
- z-index: 1001;
+ z-index: var(--ZI_navbar_popovers);
display: flex;
flex-direction: column;
align-items: center;
diff --git a/src/components/hashtag_link/hashtag_link.vue b/src/components/hashtag_link/hashtag_link.vue
index 918ed26b..596851b9 100644
--- a/src/components/hashtag_link/hashtag_link.vue
+++ b/src/components/hashtag_link/hashtag_link.vue
@@ -14,6 +14,6 @@
</span>
</template>
-<script src="./hashtag_link.js"/>
+<script src="./hashtag_link.js" />
-<style lang="scss" src="./hashtag_link.scss"/>
+<style lang="scss" src="./hashtag_link.scss" />
diff --git a/src/components/image_cropper/image_cropper.js b/src/components/image_cropper/image_cropper.js
index 05f6fd4c..55e901a0 100644
--- a/src/components/image_cropper/image_cropper.js
+++ b/src/components/image_cropper/image_cropper.js
@@ -95,7 +95,7 @@ const ImageCropper = {
const fileInput = this.$refs.input
if (fileInput.files != null && fileInput.files[0] != null) {
this.file = fileInput.files[0]
- let reader = new window.FileReader()
+ const reader = new window.FileReader()
reader.onload = (e) => {
this.dataUrl = e.target.result
this.$emit('open')
diff --git a/src/components/instance_specific_panel/instance_specific_panel.vue b/src/components/instance_specific_panel/instance_specific_panel.vue
index 7448ca06..c8ed0a2d 100644
--- a/src/components/instance_specific_panel/instance_specific_panel.vue
+++ b/src/components/instance_specific_panel/instance_specific_panel.vue
@@ -10,4 +10,4 @@
</div>
</template>
-<script src="./instance_specific_panel.js" ></script>
+<script src="./instance_specific_panel.js"></script>
diff --git a/src/components/interactions/interactions.js b/src/components/interactions/interactions.js
index c5ceb63d..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/interface_language_switcher/interface_language_switcher.vue b/src/components/interface_language_switcher/interface_language_switcher.vue
index 7ad1fe2e..6997f149 100644
--- a/src/components/interface_language_switcher/interface_language_switcher.vue
+++ b/src/components/interface_language_switcher/interface_language_switcher.vue
@@ -25,6 +25,7 @@ import Select from '../select/select.vue'
export default {
components: {
+ // eslint-disable-next-line vue/no-reserved-component-names
Select
},
props: {
diff --git a/src/components/lists/lists.js b/src/components/lists/lists.js
new file mode 100644
index 00000000..56d68430
--- /dev/null
+++ b/src/components/lists/lists.js
@@ -0,0 +1,27 @@
+import ListsCard from '../lists_card/lists_card.vue'
+
+const Lists = {
+ data () {
+ return {
+ isNew: false
+ }
+ },
+ components: {
+ ListsCard
+ },
+ computed: {
+ lists () {
+ return this.$store.state.lists.allLists
+ }
+ },
+ methods: {
+ cancelNewList () {
+ this.isNew = false
+ },
+ newList () {
+ this.isNew = true
+ }
+ }
+}
+
+export default Lists
diff --git a/src/components/lists/lists.vue b/src/components/lists/lists.vue
new file mode 100644
index 00000000..b8bab0a0
--- /dev/null
+++ b/src/components/lists/lists.vue
@@ -0,0 +1,33 @@
+<template>
+ <div class="Lists panel panel-default">
+ <div class="panel-heading">
+ <div class="title">
+ {{ $t('lists.lists') }}
+ </div>
+ <router-link
+ :to="{ name: 'lists-new' }"
+ class="button-default btn new-list-button"
+ >
+ {{ $t("lists.new") }}
+ </router-link>
+ </div>
+ <div class="panel-body">
+ <ListsCard
+ v-for="list in lists.slice().reverse()"
+ :key="list"
+ :list="list"
+ class="list-item"
+ />
+ </div>
+ </div>
+</template>
+
+<script src="./lists.js"></script>
+
+<style lang="scss">
+.Lists {
+ .new-list-button {
+ padding: 0 0.5em;
+ }
+}
+</style>
diff --git a/src/components/lists_card/lists_card.js b/src/components/lists_card/lists_card.js
new file mode 100644
index 00000000..b503caec
--- /dev/null
+++ b/src/components/lists_card/lists_card.js
@@ -0,0 +1,16 @@
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faEllipsisH
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faEllipsisH
+)
+
+const ListsCard = {
+ props: [
+ 'list'
+ ]
+}
+
+export default ListsCard
diff --git a/src/components/lists_card/lists_card.vue b/src/components/lists_card/lists_card.vue
new file mode 100644
index 00000000..13866d8c
--- /dev/null
+++ b/src/components/lists_card/lists_card.vue
@@ -0,0 +1,51 @@
+<template>
+ <div class="list-card">
+ <router-link
+ :to="{ name: 'lists-timeline', params: { id: list.id } }"
+ class="list-name"
+ >
+ {{ list.title }}
+ </router-link>
+ <router-link
+ :to="{ name: 'lists-edit', params: { id: list.id } }"
+ class="button-list-edit"
+ >
+ <FAIcon
+ class="fa-scale-110 fa-old-padding"
+ icon="ellipsis-h"
+ />
+ </router-link>
+ </div>
+</template>
+
+<script src="./lists_card.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.list-card {
+ display: flex;
+}
+
+.list-name,
+.button-list-edit {
+ margin: 0;
+ padding: 1em;
+ color: $fallback--link;
+ color: var(--link, $fallback--link);
+
+ &:hover {
+ background-color: $fallback--lightBg;
+ background-color: var(--selectedMenu, $fallback--lightBg);
+ color: $fallback--link;
+ color: var(--selectedMenuText, $fallback--link);
+ --faint: var(--selectedMenuFaintText, $fallback--faint);
+ --faintLink: var(--selectedMenuFaintLink, $fallback--faint);
+ --lightText: var(--selectedMenuLightText, $fallback--lightText);
+ }
+}
+
+.list-name {
+ flex-grow: 1;
+}
+</style>
diff --git a/src/components/lists_edit/lists_edit.js b/src/components/lists_edit/lists_edit.js
new file mode 100644
index 00000000..c22d1323
--- /dev/null
+++ b/src/components/lists_edit/lists_edit.js
@@ -0,0 +1,145 @@
+import { mapState, mapGetters } from 'vuex'
+import BasicUserCard from '../basic_user_card/basic_user_card.vue'
+import ListsUserSearch from '../lists_user_search/lists_user_search.vue'
+import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
+import UserAvatar from '../user_avatar/user_avatar.vue'
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faSearch,
+ faChevronLeft
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faSearch,
+ faChevronLeft
+)
+
+const ListsNew = {
+ components: {
+ BasicUserCard,
+ UserAvatar,
+ ListsUserSearch,
+ TabSwitcher,
+ PanelLoading
+ },
+ data () {
+ return {
+ title: '',
+ titleDraft: '',
+ membersUserIds: [],
+ removedUserIds: new Set([]), // users we added for members, to undo
+ searchUserIds: [],
+ addedUserIds: new Set([]), // users we added from search, to undo
+ searchLoading: false,
+ reallyDelete: false
+ }
+ },
+ created () {
+ if (!this.id) return
+ this.$store.dispatch('fetchList', { listId: this.id })
+ .then(() => {
+ this.title = this.findListTitle(this.id)
+ this.titleDraft = this.title
+ })
+ this.$store.dispatch('fetchListAccounts', { listId: this.id })
+ .then(() => {
+ this.membersUserIds = this.findListAccounts(this.id)
+ this.membersUserIds.forEach(userId => {
+ this.$store.dispatch('fetchUserIfMissing', userId)
+ })
+ })
+ },
+ computed: {
+ id () {
+ return this.$route.params.id
+ },
+ membersUsers () {
+ return [...this.membersUserIds, ...this.addedUserIds]
+ .map(userId => this.findUser(userId)).filter(user => user)
+ },
+ searchUsers () {
+ return this.searchUserIds.map(userId => this.findUser(userId)).filter(user => user)
+ },
+ ...mapState({
+ currentUser: state => state.users.currentUser
+ }),
+ ...mapGetters(['findUser', 'findListTitle', 'findListAccounts'])
+ },
+ methods: {
+ onInput () {
+ this.search(this.query)
+ },
+ toggleRemoveMember (user) {
+ if (this.removedUserIds.has(user.id)) {
+ this.id && this.addUser(user)
+ this.removedUserIds.delete(user.id)
+ } else {
+ this.id && this.removeUser(user.id)
+ this.removedUserIds.add(user.id)
+ }
+ },
+ toggleAddFromSearch (user) {
+ if (this.addedUserIds.has(user.id)) {
+ this.id && this.removeUser(user.id)
+ this.addedUserIds.delete(user.id)
+ } else {
+ this.id && this.addUser(user)
+ this.addedUserIds.add(user.id)
+ }
+ },
+ isRemoved (user) {
+ return this.removedUserIds.has(user.id)
+ },
+ isAdded (user) {
+ return this.addedUserIds.has(user.id)
+ },
+ addUser (user) {
+ this.$store.dispatch('addListAccount', { accountId: this.user.id, listId: this.id })
+ },
+ removeUser (userId) {
+ this.$store.dispatch('removeListAccount', { accountId: this.user.id, listId: this.id })
+ },
+ onSearchLoading (results) {
+ this.searchLoading = true
+ },
+ onSearchLoadingDone (results) {
+ this.searchLoading = false
+ },
+ onSearchResults (results) {
+ this.searchLoading = false
+ this.searchUserIds = results
+ },
+ updateListTitle () {
+ this.$store.dispatch('setList', { listId: this.id, title: this.titleDraft })
+ .then(() => {
+ this.title = this.findListTitle(this.id)
+ })
+ },
+ createList () {
+ this.$store.dispatch('createList', { title: this.titleDraft })
+ .then((list) => {
+ return this
+ .$store
+ .dispatch('setListAccounts', { listId: list.id, accountIds: [...this.addedUserIds] })
+ .then(() => list.id)
+ })
+ .then((listId) => {
+ this.$router.push({ name: 'lists-timeline', params: { id: listId } })
+ })
+ .catch((e) => {
+ this.$store.dispatch('pushGlobalNotice', {
+ messageKey: 'lists.error',
+ messageArgs: [e.message],
+ level: 'error'
+ })
+ })
+ },
+ deleteList () {
+ this.$store.dispatch('deleteList', { listId: this.id })
+ this.$router.push({ name: 'lists' })
+ }
+ }
+}
+
+export default ListsNew
diff --git a/src/components/lists_edit/lists_edit.vue b/src/components/lists_edit/lists_edit.vue
new file mode 100644
index 00000000..6521aba6
--- /dev/null
+++ b/src/components/lists_edit/lists_edit.vue
@@ -0,0 +1,228 @@
+<template>
+ <div class="panel-default panel ListEdit">
+ <div
+ ref="header"
+ class="panel-heading list-edit-heading"
+ >
+ <button
+ class="button-unstyled go-back-button"
+ @click="$router.back"
+ >
+ <FAIcon
+ size="lg"
+ icon="chevron-left"
+ />
+ </button>
+ <div class="title">
+ <i18n-t
+ v-if="id"
+ keypath="lists.editing_list"
+ >
+ <template #listTitle>
+ {{ title }}
+ </template>
+ </i18n-t>
+ <i18n-t
+ v-else
+ keypath="lists.creating_list"
+ />
+ </div>
+ </div>
+ <div class="panel-body">
+ <div class="input-wrap">
+ <label for="list-edit-title">{{ $t('lists.title') }}</label>
+ {{ ' ' }}
+ <input
+ id="list-edit-title"
+ ref="title"
+ v-model="titleDraft"
+ >
+ <button
+ v-if="id"
+ class="btn button-default follow-button"
+ @click="updateListTitle"
+ >
+ {{ $t('lists.update_title') }}
+ </button>
+ </div>
+ <tab-switcher
+ class="list-member-management"
+ :scrollable-tabs="true"
+ >
+ <div
+ v-if="id || addedUserIds.size > 0"
+ :label="$t('lists.manage_members')"
+ class="members-list"
+ >
+ <div class="users-list">
+ <div
+ v-for="user in membersUsers"
+ :key="user.id"
+ class="member"
+ >
+ <BasicUserCard
+ :user="user"
+ >
+ <button
+ class="btn button-default follow-button"
+ @click="toggleRemoveMember(user)"
+ >
+ {{ isRemoved(user) ? $t('general.undo') : $t('lists.remove_from_list') }}
+ </button>
+ </BasicUserCard>
+ </div>
+ </div>
+ </div>
+
+ <div
+ class="search-list"
+ :label="$t('lists.add_members')"
+ >
+ <ListsUserSearch
+ @results="onSearchResults"
+ @loading="onSearchLoading"
+ @loadingDone="onSearchLoadingDone"
+ />
+ <div
+ v-if="searchLoading"
+ class="loading"
+ >
+ <PanelLoading />
+ </div>
+ <div
+ v-else
+ class="users-list"
+ >
+ <div
+ v-for="user in searchUsers"
+ :key="user.id"
+ class="member"
+ >
+ <BasicUserCard
+ :user="user"
+ >
+ <span
+ v-if="membersUserIds.includes(user.id)"
+ >
+ {{ $t('lists.is_in_list') }}
+ </span>
+ <button
+ v-if="!membersUserIds.includes(user.id)"
+ class="btn button-default follow-button"
+ @click="toggleAddFromSearch(user)"
+ >
+ {{ isAdded(user) ? $t('general.undo') : $t('lists.add_to_list') }}
+ </button>
+ <button
+ v-else
+ class="btn button-default follow-button"
+ @click="toggleRemoveMember(user)"
+ >
+ {{ isRemoved(user) ? $t('general.undo') : $t('lists.remove_from_list') }}
+ </button>
+ </BasicUserCard>
+ </div>
+ </div>
+ </div>
+ </tab-switcher>
+ </div>
+ <div class="panel-footer">
+ <span class="spacer" />
+ <button
+ v-if="!id"
+ class="btn button-default footer-button"
+ @click="createList"
+ >
+ {{ $t('lists.create') }}
+ </button>
+ <button
+ v-else-if="!reallyDelete"
+ class="btn button-default footer-button"
+ @click="reallyDelete = true"
+ >
+ {{ $t('lists.delete') }}
+ </button>
+ <template v-else>
+ {{ $t('lists.really_delete') }}
+ <button
+ class="btn button-default footer-button"
+ @click="deleteList"
+ >
+ {{ $t('general.yes') }}
+ </button>
+ <button
+ class="btn button-default footer-button"
+ @click="reallyDelete = false"
+ >
+ {{ $t('general.no') }}
+ </button>
+ </template>
+ </div>
+ </div>
+</template>
+
+<script src="./lists_edit.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.ListEdit {
+ --panel-body-padding: 0.5em;
+
+ height: calc(100vh - var(--navbar-height));
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+
+ .list-edit-heading {
+ grid-template-columns: auto minmax(50%, 1fr);
+ }
+
+ .panel-body {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ overflow: hidden;
+ }
+
+ .list-member-management {
+ flex: 1 0 auto;
+ }
+
+ .search-icon {
+ margin-right: 0.3em;
+ }
+
+ .users-list {
+ padding-bottom: 0.7rem;
+ overflow-y: auto;
+ }
+
+ & .search-list,
+ & .members-list {
+ overflow: hidden;
+ flex-direction: column;
+ min-height: 0;
+ }
+
+ .go-back-button {
+ text-align: center;
+ line-height: 1;
+ height: 100%;
+ align-self: start;
+ width: var(--__panel-heading-height-inner);
+ }
+
+ .btn {
+ margin: 0 0.5em;
+ }
+
+ .panel-footer {
+ grid-template-columns: minmax(10%, 1fr);
+
+ .footer-button {
+ min-width: 9em;
+ }
+ }
+}
+</style>
diff --git a/src/components/lists_menu/lists_menu_content.js b/src/components/lists_menu/lists_menu_content.js
new file mode 100644
index 00000000..97b32210
--- /dev/null
+++ b/src/components/lists_menu/lists_menu_content.js
@@ -0,0 +1,22 @@
+import { mapState } from 'vuex'
+import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
+import { getListEntries } from 'src/components/navigation/filter.js'
+
+export const ListsMenuContent = {
+ props: [
+ 'showPin'
+ ],
+ components: {
+ NavigationEntry
+ },
+ computed: {
+ ...mapState({
+ lists: getListEntries,
+ currentUser: state => state.users.currentUser,
+ privateMode: state => state.instance.private,
+ federating: state => state.instance.federating
+ })
+ }
+}
+
+export default ListsMenuContent
diff --git a/src/components/lists_menu/lists_menu_content.vue b/src/components/lists_menu/lists_menu_content.vue
new file mode 100644
index 00000000..f93e80c9
--- /dev/null
+++ b/src/components/lists_menu/lists_menu_content.vue
@@ -0,0 +1,12 @@
+<template>
+ <ul>
+ <NavigationEntry
+ v-for="item in lists"
+ :key="item.name"
+ :show-pin="showPin"
+ :item="item"
+ />
+ </ul>
+</template>
+
+<script src="./lists_menu_content.js"></script>
diff --git a/src/components/lists_timeline/lists_timeline.js b/src/components/lists_timeline/lists_timeline.js
new file mode 100644
index 00000000..c3f408bd
--- /dev/null
+++ b/src/components/lists_timeline/lists_timeline.js
@@ -0,0 +1,36 @@
+import Timeline from '../timeline/timeline.vue'
+const ListsTimeline = {
+ data () {
+ return {
+ listId: null
+ }
+ },
+ components: {
+ Timeline
+ },
+ computed: {
+ timeline () { return this.$store.state.statuses.timelines.list }
+ },
+ watch: {
+ $route: function (route) {
+ if (route.name === 'lists-timeline' && route.params.id !== this.listId) {
+ this.listId = route.params.id
+ this.$store.dispatch('stopFetchingTimeline', 'list')
+ this.$store.commit('clearTimeline', { timeline: 'list' })
+ this.$store.dispatch('fetchList', { listId: this.listId })
+ this.$store.dispatch('startFetchingTimeline', { timeline: 'list', listId: this.listId })
+ }
+ }
+ },
+ created () {
+ this.listId = this.$route.params.id
+ this.$store.dispatch('fetchList', { listId: this.listId })
+ this.$store.dispatch('startFetchingTimeline', { timeline: 'list', listId: this.listId })
+ },
+ unmounted () {
+ this.$store.dispatch('stopFetchingTimeline', 'list')
+ this.$store.commit('clearTimeline', { timeline: 'list' })
+ }
+}
+
+export default ListsTimeline
diff --git a/src/components/lists_timeline/lists_timeline.vue b/src/components/lists_timeline/lists_timeline.vue
new file mode 100644
index 00000000..18156b81
--- /dev/null
+++ b/src/components/lists_timeline/lists_timeline.vue
@@ -0,0 +1,10 @@
+<template>
+ <Timeline
+ title="list.name"
+ :timeline="timeline"
+ :list-id="listId"
+ timeline-name="list"
+ />
+</template>
+
+<script src="./lists_timeline.js"></script>
diff --git a/src/components/lists_user_search/lists_user_search.js b/src/components/lists_user_search/lists_user_search.js
new file mode 100644
index 00000000..c92ec0ee
--- /dev/null
+++ b/src/components/lists_user_search/lists_user_search.js
@@ -0,0 +1,51 @@
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faSearch,
+ faChevronLeft
+} from '@fortawesome/free-solid-svg-icons'
+import { debounce } from 'lodash'
+import Checkbox from '../checkbox/checkbox.vue'
+
+library.add(
+ faSearch,
+ faChevronLeft
+)
+
+const ListsUserSearch = {
+ components: {
+ Checkbox
+ },
+ emits: ['loading', 'loadingDone', 'results'],
+ data () {
+ return {
+ loading: false,
+ query: '',
+ followingOnly: true
+ }
+ },
+ methods: {
+ onInput: debounce(function () {
+ this.search(this.query)
+ }, 2000),
+ search (query) {
+ if (!query) {
+ this.loading = false
+ return
+ }
+
+ this.loading = true
+ this.$emit('loading')
+ this.userIds = []
+ this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts', following: this.followingOnly })
+ .then(data => {
+ this.$emit('results', data.accounts.map(a => a.id))
+ })
+ .finally(() => {
+ this.loading = false
+ this.$emit('loadingDone')
+ })
+ }
+ }
+}
+
+export default ListsUserSearch
diff --git a/src/components/lists_user_search/lists_user_search.vue b/src/components/lists_user_search/lists_user_search.vue
new file mode 100644
index 00000000..8633170c
--- /dev/null
+++ b/src/components/lists_user_search/lists_user_search.vue
@@ -0,0 +1,47 @@
+<template>
+ <div class="ListsUserSearch">
+ <div class="input-wrap">
+ <div class="input-search">
+ <FAIcon
+ class="search-icon fa-scale-110 fa-old-padding"
+ icon="search"
+ />
+ </div>
+ <input
+ ref="search"
+ v-model="query"
+ :placeholder="$t('lists.search')"
+ @input="onInput"
+ >
+ </div>
+ <div class="input-wrap">
+ <Checkbox
+ v-model="followingOnly"
+ @change="onInput"
+ >
+ {{ $t('lists.following_only') }}
+ </Checkbox>
+ </div>
+ </div>
+</template>
+
+<script src="./lists_user_search.js"></script>
+<style lang="scss">
+@import '../../_variables.scss';
+
+.ListsUserSearch {
+ .input-wrap {
+ display: flex;
+ margin: 0.7em 0.5em 0.7em 0.5em;
+
+ input {
+ width: 100%;
+ }
+ }
+
+ .search-icon {
+ margin-right: 0.3em;
+ }
+}
+
+</style>
diff --git a/src/components/login_form/login_form.js b/src/components/login_form/login_form.js
index 638bd812..b795640e 100644
--- a/src/components/login_form/login_form.js
+++ b/src/components/login_form/login_form.js
@@ -83,7 +83,7 @@ const LoginForm = {
},
clearError () { this.error = false },
focusOnPasswordInput () {
- let passwordInput = this.$refs.passwordInput
+ const passwordInput = this.$refs.passwordInput
passwordInput.focus()
passwordInput.setSelectionRange(0, passwordInput.value.length)
}
diff --git a/src/components/login_form/login_form.vue b/src/components/login_form/login_form.vue
index 21482977..7a430c51 100644
--- a/src/components/login_form/login_form.vue
+++ b/src/components/login_form/login_form.vue
@@ -90,7 +90,7 @@
</div>
</template>
-<script src="./login_form.js" ></script>
+<script src="./login_form.js"></script>
<style lang="scss">
@import '../../_variables.scss';
diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue
index 8b76aafb..d59055b3 100644
--- a/src/components/media_modal/media_modal.vue
+++ b/src/components/media_modal/media_modal.vue
@@ -121,7 +121,7 @@ $modal-view-button-icon-width: 3em;
$modal-view-button-icon-margin: 0.5em;
.modal-view.media-modal-view {
- z-index: 9000;
+ z-index: var(--ZI_media_modal);
flex-direction: column;
.modal-view-button-arrow,
diff --git a/src/components/media_upload/media_upload.js b/src/components/media_upload/media_upload.js
index 669d8190..cfd42d4c 100644
--- a/src/components/media_upload/media_upload.js
+++ b/src/components/media_upload/media_upload.js
@@ -42,7 +42,8 @@ const mediaUpload = {
.then((fileData) => {
self.$emit('uploaded', fileData)
self.decreaseUploadCount()
- }, (error) => { // eslint-disable-line handle-callback-err
+ }, (error) => {
+ console.error('Error uploading file', error)
self.$emit('upload-failed', 'default')
self.decreaseUploadCount()
})
@@ -73,7 +74,7 @@ const mediaUpload = {
'disabled'
],
watch: {
- 'dropFiles': function (fileInfos) {
+ dropFiles: function (fileInfos) {
if (!this.uploading) {
this.multiUpload(fileInfos)
}
diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue
index 7cc59f5a..a538a5ed 100644
--- a/src/components/media_upload/media_upload.vue
+++ b/src/components/media_upload/media_upload.vue
@@ -26,7 +26,7 @@
</label>
</template>
-<script src="./media_upload.js" ></script>
+<script src="./media_upload.js"></script>
<style lang="scss">
@import '../../_variables.scss';
diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js
index 55eea195..6515bd11 100644
--- a/src/components/mention_link/mention_link.js
+++ b/src/components/mention_link/mention_link.js
@@ -2,6 +2,8 @@ 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 {
faAt
@@ -14,7 +16,9 @@ library.add(
const MentionLink = {
name: 'MentionLink',
components: {
- UserAvatar
+ UserAvatar,
+ UnicodeDomainIndicator,
+ UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue'))
},
props: {
url: {
@@ -34,15 +38,30 @@ const MentionLink = {
type: String
}
},
+ data () {
+ return {
+ hasSelection: false
+ }
+ },
methods: {
onClick () {
+ if (this.shouldShowTooltip) return
const link = generateProfileLink(
this.userId || this.user.id,
this.userScreenName || this.user.screen_name
)
this.$router.push(link)
+ },
+ handleSelection () {
+ this.hasSelection = document.getSelection().containsNode(this.$refs.full, true)
}
},
+ mounted () {
+ document.addEventListener('selectionchange', this.handleSelection)
+ },
+ unmounted () {
+ document.removeEventListener('selectionchange', this.handleSelection)
+ },
computed: {
user () {
return this.url && this.$store && this.$store.getters.findUserByUrl(this.url)
@@ -88,7 +107,8 @@ const MentionLink = {
return [
{
'-you': this.isYou && this.shouldBoldenYou,
- '-highlighted': this.highlight
+ '-highlighted': this.highlight,
+ '-has-selection': this.hasSelection
},
this.highlightType
]
@@ -110,7 +130,7 @@ const MentionLink = {
}
},
shouldShowTooltip () {
- return this.mergedConfig.mentionLinkShowTooltip && this.mergedConfig.mentionLinkDisplay === 'short' && this.isRemote
+ return this.mergedConfig.mentionLinkShowTooltip
},
shouldShowAvatar () {
return this.mergedConfig.mentionLinkShowAvatar
diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss
index 1d856ff9..8b2af926 100644
--- a/src/components/mention_link/mention_link.scss
+++ b/src/components/mention_link/mention_link.scss
@@ -55,11 +55,14 @@
.new {
&.-you {
- & .shortName,
- & .full {
+ .shortName {
font-weight: 600;
}
}
+ &.-has-selection {
+ color: var(--alertNeutralText, $fallback--text);
+ background-color: var(--alertNeutral, $fallback--fg);
+ }
.at {
color: var(--link);
@@ -72,8 +75,7 @@
}
&.-striped {
- & .shortName,
- & .full {
+ & .shortName {
background-image:
repeating-linear-gradient(
135deg,
@@ -86,30 +88,29 @@
}
&.-solid {
- & .shortName,
- & .full {
+ .shortName {
background-image: linear-gradient(var(--____highlight-tintColor2), var(--____highlight-tintColor2));
}
}
&.-side {
- & .shortName,
- & .userNameFull {
+ .shortName {
box-shadow: 0 -5px 3px -4px inset var(--____highlight-solidColor);
}
}
}
- &:hover .new .full {
- opacity: 1;
- pointer-events: initial;
+ .full {
+ pointer-events: none;
}
.serverName.-faded {
color: var(--faintLink, $fallback--link);
}
+}
- .full .-faded {
- color: var(--faint, $fallback--faint);
- }
+.mention-link-popover {
+ max-width: 70ch;
+ max-height: 20rem;
+ overflow: hidden;
}
diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue
index 022f04c7..869a3257 100644
--- a/src/components/mention_link/mention_link.vue
+++ b/src/components/mention_link/mention_link.vue
@@ -9,69 +9,67 @@
class="original"
target="_blank"
v-html="content"
- /><!-- eslint-enable vue/no-v-html --><span
- v-if="user"
- class="new"
- :style="style"
- :class="classnames"
+ /><!-- eslint-enable vue/no-v-html -->
+ <UserPopover
+ v-else
+ :user-id="user.id"
+ :disabled="!shouldShowTooltip"
>
- <a
- class="short button-unstyled"
- :class="{ '-with-tooltip': shouldShowTooltip }"
- :href="url"
- @click.prevent="onClick"
+ <span
+ v-if="user"
+ class="new"
+ :style="style"
+ :class="classnames"
>
- <!-- eslint-disable vue/no-v-html -->
- <UserAvatar
- v-if="shouldShowAvatar"
- class="mention-avatar"
- :user="user"
- /><span
- class="shortName"
- ><FAIcon
- v-if="useAtIcon"
- size="sm"
- icon="at"
- class="at"
- />{{ !useAtIcon ? '@' : '' }}<span
- class="userName"
- v-html="userName"
- /><span
- v-if="shouldShowFullUserName"
- class="serverName"
- :class="{ '-faded': shouldFadeDomain }"
- v-html="'@' + serverName"
- />
- </span>
- <span
- v-if="isYou && shouldShowYous"
- :class="{ '-you': shouldBoldenYou }"
- > {{ ' ' + $t('status.you') }}</span>
- <!-- eslint-enable vue/no-v-html -->
- </a><span
- v-if="shouldShowTooltip"
- class="full popover-default"
- :class="[highlightType]"
- >
- <span
- class="userNameFull"
+ <a
+ class="short button-unstyled"
+ :class="{ '-with-tooltip': shouldShowTooltip }"
+ :href="url"
+ @click.prevent="onClick"
>
<!-- eslint-disable vue/no-v-html -->
- @<span
+ <UserAvatar
+ v-if="shouldShowAvatar"
+ class="mention-avatar"
+ :user="user"
+ /><span
+ class="shortName"
+ ><FAIcon
+ v-if="useAtIcon"
+ size="sm"
+ icon="at"
+ class="at"
+ />{{ !useAtIcon ? '@' : '' }}<span
class="userName"
v-html="userName"
/><span
+ v-if="shouldShowFullUserName"
class="serverName"
:class="{ '-faded': shouldFadeDomain }"
v-html="'@' + serverName"
+ /><UnicodeDomainIndicator
+ v-if="shouldShowFullUserName"
+ :user="user"
/>
+ </span>
+ <span
+ v-if="isYou && shouldShowYous"
+ :class="{ '-you': shouldBoldenYou }"
+ > {{ ' ' + $t('status.you') }}</span>
+ <!-- eslint-enable vue/no-v-html -->
+ </a><span
+ ref="full"
+ class="full"
+ >
+ <!-- eslint-disable vue/no-v-html -->
+ @<span v-html="userName" /><span v-html="'@' + serverName" />
<!-- eslint-enable vue/no-v-html -->
</span>
</span>
- </span>
+ </UserPopover>
</span>
</template>
-<script src="./mention_link.js"/>
+<script src="./mention_link.js" />
-<style lang="scss" src="./mention_link.scss"/>
+<style lang="scss" src="./mention_link.scss" />
diff --git a/src/components/mentions_line/mentions_line.vue b/src/components/mentions_line/mentions_line.vue
index 09b6a1d6..64c19bf1 100644
--- a/src/components/mentions_line/mentions_line.vue
+++ b/src/components/mentions_line/mentions_line.vue
@@ -13,14 +13,13 @@
<span
v-if="expanded"
class="fullExtraMentions"
- >
- <MentionLink
- v-for="mention in extraMentions"
- :key="mention.index"
- class="mention-link"
- :content="mention.content"
- :url="mention.url"
- />
+ >{{ ' ' }}<MentionLink
+ v-for="mention in extraMentions"
+ :key="mention.index"
+ class="mention-link"
+ :content="mention.content"
+ :url="mention.url"
+ />
</span><button
v-if="!expanded"
class="button-unstyled showMoreLess"
@@ -37,5 +36,5 @@
</span>
</span>
</template>
-<script src="./mentions_line.js" ></script>
+<script src="./mentions_line.js"></script>
<style lang="scss" src="./mentions_line.scss" />
diff --git a/src/components/mfa_form/recovery_form.vue b/src/components/mfa_form/recovery_form.vue
index a9cf39aa..5988fa51 100644
--- a/src/components/mfa_form/recovery_form.vue
+++ b/src/components/mfa_form/recovery_form.vue
@@ -69,4 +69,4 @@
</div>
</div>
</template>
-<script src="./recovery_form.js" ></script>
+<script src="./recovery_form.js"></script>
diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js
index 877d52a9..fb8ffa30 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']),
+ 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 d2d48a03..6e732d1f 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="this.$refs.sideDrawer && !this.$refs.sideDrawer.closed"
@click.stop.prevent="toggleMobileSidebar()"
>
<FAIcon
@@ -17,23 +19,16 @@
icon="bars"
/>
<div
- v-if="unreadChatCount"
+ v-if="unreadChatCount && !chatsPinned"
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,23 +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
- class="mobile-notifications"
id="mobile-notifications"
+ class="mobile-notifications"
+ ref="mobileNotifications"
@scroll="onScroll"
- >
- </div>
- </div>
+ />
+ </aside>
<SideDrawer
ref="sideDrawer"
:logout="logout"
@@ -86,6 +97,8 @@
@import '../../_variables.scss';
.MobileNav {
+ z-index: var(--ZI_navbar);
+
.mobile-nav {
display: grid;
line-height: var(--navbar-height);
@@ -93,6 +106,7 @@
grid-template-columns: 2fr auto;
width: 100%;
box-sizing: border-box;
+
a {
color: var(--topBarLink, $fallback--link);
}
@@ -147,7 +161,7 @@
transition-property: transform;
transition-duration: 0.25s;
transform: translateX(0);
- z-index: 1001;
+ z-index: var(--ZI_navbar);
-webkit-overflow-scrolling: touch;
&.-closed {
@@ -160,7 +174,7 @@
display: flex;
align-items: center;
justify-content: space-between;
- z-index: 1;
+ z-index: calc(var(--ZI_navbar) + 100);
width: 100%;
height: 50px;
line-height: 50px;
@@ -171,19 +185,30 @@
box-shadow: 0px 0px 4px rgba(0,0,0,.6);
box-shadow: var(--topBarShadow);
+ .spacer {
+ flex: 1;
+ }
+
.title {
font-size: 1.3em;
margin-left: 0.6em;
}
}
+ .pins {
+ flex: 1;
+
+ .pinned-item {
+ flex-grow: 1;
+ }
+ }
+
.mobile-notifications {
margin-top: 50px;
width: 100vw;
height: calc(100vh - var(--navbar-height));
overflow-x: hidden;
overflow-y: scroll;
-
color: $fallback--text;
color: var(--text, $fallback--text);
background-color: $fallback--bg;
@@ -193,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..28a2c440 100644
--- a/src/components/mobile_post_status_button/mobile_post_status_button.vue
+++ b/src/components/mobile_post_status_button/mobile_post_status_button.vue
@@ -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" />
diff --git a/src/components/modal/modal.vue b/src/components/modal/modal.vue
index 9394efff..2187f392 100644
--- a/src/components/modal/modal.vue
+++ b/src/components/modal/modal.vue
@@ -12,6 +12,9 @@
<script>
export default {
+ provide: {
+ popoversZLayer: 'modals'
+ },
props: {
isOpen: {
type: Boolean,
@@ -26,7 +29,7 @@ export default {
classes () {
return {
'modal-background': !this.noBackground,
- 'open': this.isOpen
+ open: this.isOpen
}
}
}
@@ -35,7 +38,7 @@ export default {
<style lang="scss">
.modal-view {
- z-index: 2000;
+ z-index: var(--ZI_modals);
position: fixed;
top: 0;
left: 0;
diff --git a/src/components/moderation_tools/moderation_tools.js b/src/components/moderation_tools/moderation_tools.js
index 2469327a..a5ce8656 100644
--- a/src/components/moderation_tools/moderation_tools.js
+++ b/src/components/moderation_tools/moderation_tools.js
@@ -41,14 +41,26 @@ const ModerationTools = {
tagsSet () {
return new Set(this.user.tags)
},
- hasTagPolicy () {
- return this.$store.state.instance.tagPolicyAvailable
+ canGrantRole () {
+ return this.user.is_local && !this.user.deactivated && this.$store.state.users.currentUser.role === 'admin'
+ },
+ canChangeActivationState () {
+ return this.privileged('users_manage_activation_state')
+ },
+ canDeleteAccount () {
+ return this.privileged('users_delete')
+ },
+ canUseTagPolicy () {
+ return this.$store.state.instance.tagPolicyAvailable && this.privileged('users_manage_tags')
}
},
methods: {
hasTag (tagName) {
return this.tagsSet.has(tagName)
},
+ privileged (privilege) {
+ return this.$store.state.users.currentUser.privileges.includes(privilege)
+ },
toggleTag (tag) {
const store = this.$store
if (this.tagsSet.has(tag)) {
diff --git a/src/components/moderation_tools/moderation_tools.vue b/src/components/moderation_tools/moderation_tools.vue
index 96b8c3a3..8535ef27 100644
--- a/src/components/moderation_tools/moderation_tools.vue
+++ b/src/components/moderation_tools/moderation_tools.vue
@@ -8,9 +8,9 @@
@show="setToggled(true)"
@close="setToggled(false)"
>
- <template v-slot:content>
+ <template #content>
<div class="dropdown-menu">
- <span v-if="user.is_local">
+ <span v-if="canGrantRole">
<button
class="button-default dropdown-item"
@click="toggleRight(&quot;admin&quot;)"
@@ -24,28 +24,31 @@
{{ $t(!!user.rights.moderator ? 'user_card.admin_menu.revoke_moderator' : 'user_card.admin_menu.grant_moderator') }}
</button>
<div
+ v-if="canChangeActivationState || canDeleteAccount"
role="separator"
class="dropdown-divider"
/>
</span>
<button
+ v-if="canChangeActivationState"
class="button-default dropdown-item"
@click="toggleActivationStatus()"
>
{{ $t(!!user.deactivated ? 'user_card.admin_menu.activate_account' : 'user_card.admin_menu.deactivate_account') }}
</button>
<button
+ v-if="canDeleteAccount"
class="button-default dropdown-item"
@click="deleteUserDialog(true)"
>
{{ $t('user_card.admin_menu.delete_account') }}
</button>
<div
- v-if="hasTagPolicy"
+ v-if="canUseTagPolicy"
role="separator"
class="dropdown-divider"
/>
- <span v-if="hasTagPolicy">
+ <span v-if="canUseTagPolicy">
<button
class="button-default dropdown-item"
@click="toggleTag(tags.FORCE_NSFW)"
@@ -122,7 +125,7 @@
</span>
</div>
</template>
- <template v-slot:trigger>
+ <template #trigger>
<button
class="btn button-default btn-block moderation-tools-button"
:class="{ toggled }"
@@ -137,11 +140,11 @@
v-if="showDeleteUserDialog"
:on-cancel="deleteUserDialog.bind(this, false)"
>
- <template v-slot:header>
+ <template #header>
{{ $t('user_card.admin_menu.delete_user') }}
</template>
<p>{{ $t('user_card.admin_menu.delete_user_confirmation') }}</p>
- <template v-slot:footer>
+ <template #footer>
<button
class="btn button-default"
@click="deleteUserDialog(false)"
diff --git a/src/components/mrf_transparency_panel/mrf_transparency_panel.js b/src/components/mrf_transparency_panel/mrf_transparency_panel.js
index 3fde8106..13cfb52e 100644
--- a/src/components/mrf_transparency_panel/mrf_transparency_panel.js
+++ b/src/components/mrf_transparency_panel/mrf_transparency_panel.js
@@ -9,10 +9,10 @@ import { get } from 'lodash'
*/
const toInstanceReasonObject = (instances, info, key) => {
return instances.map(instance => {
- if (info[key] && info[key][instance] && info[key][instance]['reason']) {
- return { instance: instance, reason: info[key][instance]['reason'] }
+ if (info[key] && info[key][instance] && info[key][instance].reason) {
+ return { instance, reason: info[key][instance].reason }
}
- return { instance: instance, reason: '' }
+ return { instance, reason: '' }
})
}
diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js
index 37bcb409..b54f2fa2 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,8 @@ import {
faComments,
faBell,
faInfoCircle,
- faStream
+ faStream,
+ faList
} from '@fortawesome/free-solid-svg-icons'
library.add(
@@ -25,26 +31,52 @@ library.add(
faComments,
faBell,
faInfoCircle,
- faStream
+ faStream,
+ faList
)
-
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,8 +85,36 @@ 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,
+ pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems),
+ collapsed: state => state.serverSideStorage.prefsStorage.simple.collapseNav
}),
+ timelinesItems () {
+ return filterNavigation(
+ Object
+ .entries({ ...TIMELINES })
+ .map(([k, v]) => ({ ...v, name: k })),
+ {
+ hasChats: this.pleromaChatMessagesAvailable,
+ 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,
+ isFederating: this.federating,
+ isPrivate: this.privateMode,
+ currentUser: this.currentUser
+ }
+ )
+ },
...mapGetters(['unreadChatCount'])
}
}
diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue
index 7ae7b1d6..d628c380 100644
--- a/src/components/nav_panel/nav_panel.vue
+++ b/src/components/nav_panel/nav_panel.vue
@@ -1,96 +1,105 @@
<template>
<div class="NavPanel">
<div class="panel panel-default">
- <ul>
- <li v-if="currentUser || !privateMode">
- <button
- class="button-unstyled menu-item"
- @click="toggleTimelines"
- >
- <FAIcon
- fixed-width
- class="fa-scale-110"
- icon="stream"
- />{{ $t("nav.timelines") }}
- <FAIcon
- class="timelines-chevron"
- fixed-width
- :icon="showTimelines ? 'chevron-up' : 'chevron-down'"
+ <div
+ v-if="!forceExpand"
+ class="panel-heading nav-panel-heading"
+ >
+ <NavigationPins :limit="6" />
+ <div class="spacer" />
+ <button
+ class="button-unstyled"
+ @click="toggleCollapse"
+ >
+ <FAIcon
+ class="navigation-chevron"
+ fixed-width
+ :icon="collapsed ? 'chevron-down' : 'chevron-up'"
+ />
+ </button>
+ </div>
+ <ul
+ v-if="!collapsed || forceExpand"
+ class="panel-body"
+ >
+ <NavigationEntry
+ v-if="currentUser || !privateMode"
+ :show-pin="false"
+ :item="{ icon: 'stream', label: 'nav.timelines' }"
+ :aria-expanded="showTimelines ? 'true' : 'false'"
+ @click="toggleTimelines"
+ >
+ <FAIcon
+ class="timelines-chevron"
+ fixed-width
+ :icon="showTimelines ? 'chevron-up' : 'chevron-down'"
+ />
+ </NavigationEntry>
+ <div
+ v-show="showTimelines"
+ class="timelines-background"
+ >
+ <div class="timelines">
+ <NavigationEntry
+ v-for="item in timelinesItems"
+ :key="item.name"
+ :show-pin="editMode || forceEditMode"
+ :item="item"
/>
- </button>
- <div
- v-show="showTimelines"
- class="timelines-background"
- >
- <TimelineMenuContent class="timelines" />
</div>
- </li>
- <li v-if="currentUser">
- <router-link
- class="menu-item"
- :to="{ name: 'interactions', params: { username: currentUser.screen_name } }"
- >
- <FAIcon
- fixed-width
- class="fa-scale-110"
- icon="bell"
- />{{ $t("nav.interactions") }}
- </router-link>
- </li>
- <li v-if="currentUser && pleromaChatMessagesAvailable">
- <router-link
- class="menu-item"
- :to="{ name: 'chats', params: { username: currentUser.screen_name } }"
- >
- <div
- v-if="unreadChatCount"
- class="badge badge-notification"
- >
- {{ unreadChatCount }}
- </div>
- <FAIcon
- fixed-width
- class="fa-scale-110"
- icon="comments"
- />{{ $t("nav.chats") }}
- </router-link>
- </li>
- <li v-if="currentUser && currentUser.locked">
- <router-link
- class="menu-item"
- :to="{ name: 'friend-requests' }"
- >
- <FAIcon
- fixed-width
- class="fa-scale-110"
- icon="user-plus"
- />{{ $t("nav.friend_requests") }}
- <span
- v-if="followRequestCount > 0"
- class="badge badge-notification"
- >
- {{ followRequestCount }}
- </span>
- </router-link>
- </li>
- <li>
+ </div>
+ <NavigationEntry
+ v-if="currentUser"
+ :show-pin="false"
+ :item="{ icon: 'list', label: 'nav.lists' }"
+ :aria-expanded="showLists ? 'true' : 'false'"
+ @click="toggleLists"
+ >
<router-link
- class="menu-item"
- :to="{ name: 'about' }"
+ :title="$t('lists.manage_lists')"
+ class="extra-button"
+ :to="{ name: 'lists' }"
+ @click.stop
>
<FAIcon
+ class="extra-button"
fixed-width
- class="fa-scale-110"
- icon="info-circle"
- />{{ $t("nav.about") }}
+ icon="wrench"
+ />
</router-link>
- </li>
+ <FAIcon
+ class="timelines-chevron"
+ fixed-width
+ :icon="showLists ? 'chevron-up' : 'chevron-down'"
+ />
+ </NavigationEntry>
+ <div
+ v-show="showLists"
+ class="timelines-background"
+ >
+ <ListsMenuContent
+ :show-pin="editMode || forceEditMode"
+ class="timelines"
+ />
+ </div>
+ <NavigationEntry
+ v-for="item in rootItems"
+ :key="item.name"
+ :show-pin="editMode || forceEditMode"
+ :item="item"
+ />
+ <NavigationEntry
+ v-if="!forceEditMode && currentUser"
+ :show-pin="false"
+ :item="{ label: editMode ? $t('nav.edit_finish') : $t('nav.edit_pinned'), icon: editMode ? 'check' : 'wrench' }"
+ @click="toggleEditMode"
+ />
</ul>
</div>
</div>
</template>
-<script src="./nav_panel.js" ></script>
+<script src="./nav_panel.js"></script>
<style lang="scss">
@import '../../_variables.scss';
@@ -112,8 +121,9 @@
border-bottom: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
- padding: 0;
+ }
+ > li {
&:first-child .menu-item {
border-top-right-radius: $fallback--panelRadius;
border-top-right-radius: var(--panelRadius, $fallback--panelRadius);
@@ -133,42 +143,10 @@
border: none;
}
- .menu-item {
- display: block;
- box-sizing: border-box;
- height: 3.5em;
- line-height: 3.5em;
- padding: 0 1em;
- width: 100%;
- color: $fallback--link;
- color: var(--link, $fallback--link);
-
- &:hover {
- background-color: $fallback--lightBg;
- background-color: var(--selectedMenu, $fallback--lightBg);
- color: $fallback--link;
- color: var(--selectedMenuText, $fallback--link);
- --faint: var(--selectedMenuFaintText, $fallback--faint);
- --faintLink: var(--selectedMenuFaintLink, $fallback--faint);
- --lightText: var(--selectedMenuLightText, $fallback--lightText);
- --icon: var(--selectedMenuIcon, $fallback--icon);
- }
-
- &.router-link-active {
- font-weight: bolder;
- background-color: $fallback--lightBg;
- background-color: var(--selectedMenu, $fallback--lightBg);
- color: $fallback--text;
- color: var(--selectedMenuText, $fallback--text);
- --faint: var(--selectedMenuFaintText, $fallback--faint);
- --faintLink: var(--selectedMenuFaintLink, $fallback--faint);
- --lightText: var(--selectedMenuLightText, $fallback--lightText);
- --icon: var(--selectedMenuIcon, $fallback--icon);
-
- &:hover {
- text-decoration: underline;
- }
- }
+ .navigation-chevron {
+ margin-left: 0.8em;
+ margin-right: 0.8em;
+ font-size: 1.1em;
}
.timelines-chevron {
@@ -180,7 +158,7 @@
padding: 0 0 0 0.6em;
background-color: $fallback--lightBg;
background-color: var(--selectedMenu, $fallback--lightBg);
- border-top: 1px solid;
+ border-bottom: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
}
@@ -190,14 +168,9 @@
background-color: var(--bg, $fallback--bg);
}
- .fa-scale-110 {
- margin-right: 0.8em;
- }
-
- .badge {
- position: absolute;
- right: 0.6rem;
- top: 1.25em;
+ .nav-panel-heading {
+ // breaks without a unit
+ --panel-heading-height-padding: 0em;
}
}
</style>
diff --git a/src/components/navigation/filter.js b/src/components/navigation/filter.js
new file mode 100644
index 00000000..31b55486
--- /dev/null
+++ b/src/components/navigation/filter.js
@@ -0,0 +1,18 @@
+export const filterNavigation = (list = [], { hasChats, isFederating, isPrivate, currentUser }) => {
+ return list.filter(({ criteria, anon, anonRoute }) => {
+ const set = new Set(criteria || [])
+ if (!isFederating && set.has('federating')) return false
+ if (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
+ 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..f66dd981
--- /dev/null
+++ b/src/components/navigation/navigation.js
@@ -0,0 +1,75 @@
+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'
+ }
+}
diff --git a/src/components/navigation/navigation_entry.js b/src/components/navigation/navigation_entry.js
new file mode 100644
index 00000000..81cc936a
--- /dev/null
+++ b/src/components/navigation/navigation_entry.js
@@ -0,0 +1,51 @@
+import { mapState } from 'vuex'
+import { USERNAME_ROUTES } from 'src/components/navigation/navigation.js'
+import OptionalRouterLink from 'src/components/optional_router_link/optional_router_link.vue'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import { faThumbtack } from '@fortawesome/free-solid-svg-icons'
+
+library.add(faThumbtack)
+
+const NavigationEntry = {
+ props: ['item', 'showPin'],
+ components: {
+ OptionalRouterLink
+ },
+ methods: {
+ isPinned (value) {
+ return this.pinnedItems.has(value)
+ },
+ togglePin (value) {
+ if (this.isPinned(value)) {
+ this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedNavItems', value })
+ } else {
+ this.$store.commit('addCollectionPreference', { path: 'collections.pinnedNavItems', value })
+ }
+ this.$store.dispatch('pushServerSideStorage')
+ }
+ },
+ computed: {
+ routeTo () {
+ if (!this.item.route && !this.item.routeObject) return null
+ let route
+ if (this.item.routeObject) {
+ route = this.item.routeObject
+ } else {
+ route = { name: (this.item.anon || this.currentUser) ? this.item.route : this.item.anonRoute }
+ }
+ if (USERNAME_ROUTES.has(route.name)) {
+ route.params = { username: this.currentUser.screen_name, name: this.currentUser.screen_name }
+ }
+ return route
+ },
+ getters () {
+ return this.$store.getters
+ },
+ ...mapState({
+ currentUser: state => state.users.currentUser,
+ pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems)
+ })
+ }
+}
+
+export default NavigationEntry
diff --git a/src/components/navigation/navigation_entry.vue b/src/components/navigation/navigation_entry.vue
new file mode 100644
index 00000000..f4d53836
--- /dev/null
+++ b/src/components/navigation/navigation_entry.vue
@@ -0,0 +1,133 @@
+<template>
+ <OptionalRouterLink
+ v-slot="{ isActive, href, navigate } = {}"
+ ass="ass"
+ :to="routeTo"
+ >
+ <li
+ class="NavigationEntry menu-item"
+ :class="{ '-active': isActive }"
+ v-bind="$attrs"
+ >
+ <component
+ :is="routeTo ? 'a' : 'button'"
+ class="main-link button-unstyled"
+ :href="href"
+ @click="navigate"
+ >
+ <span>
+ <FAIcon
+ v-if="item.icon"
+ fixed-width
+ class="fa-scale-110 menu-icon"
+ :icon="item.icon"
+ />
+ </span>
+ <span
+ v-if="item.iconLetter"
+ class="icon iconLetter fa-scale-110 menu-icon"
+ >{{ item.iconLetter }}
+ </span>
+ <span class="label">
+ {{ item.labelRaw || $t(item.label) }}
+ </span>
+ </component>
+ <slot />
+ <div
+ v-if="item.badgeGetter && getters[item.badgeGetter]"
+ class="badge badge-notification"
+ >
+ {{ getters[item.badgeGetter] }}
+ </div>
+ <button
+ v-if="showPin && currentUser"
+ type="button"
+ class="button-unstyled extra-button"
+ :title="$t(isPinned ? 'general.unpin' : 'general.pin' )"
+ :aria-pressed="!!isPinned"
+ @click.stop.prevent="togglePin(item.name)"
+ >
+ <FAIcon
+ v-if="showPin && currentUser"
+ fixed-width
+ class="fa-scale-110"
+ :class="{ 'veryfaint': !isPinned(item.name) }"
+ :transform="!isPinned(item.name) ? 'rotate-45' : ''"
+ icon="thumbtack"
+ />
+ </button>
+ </li>
+ </OptionalRouterLink>
+</template>
+
+<script src="./navigation_entry.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.NavigationEntry {
+ display: flex;
+ box-sizing: border-box;
+ align-items: baseline;
+ height: 3.5em;
+ line-height: 3.5em;
+ padding: 0 1em;
+ width: 100%;
+ color: $fallback--link;
+ color: var(--link, $fallback--link);
+
+ .timelines-chevron {
+ margin-right: 0;
+ }
+
+ .main-link {
+ flex: 1;
+ }
+
+ .menu-icon {
+ margin-right: 0.8em;
+ }
+
+ .extra-button {
+ width: 3em;
+ text-align: center;
+
+ &:last-child {
+ margin-right: -0.8em;
+ }
+ }
+
+ &:hover {
+ background-color: $fallback--lightBg;
+ background-color: var(--selectedMenu, $fallback--lightBg);
+ color: $fallback--link;
+ color: var(--selectedMenuText, $fallback--link);
+ --faint: var(--selectedMenuFaintText, $fallback--faint);
+ --faintLink: var(--selectedMenuFaintLink, $fallback--faint);
+ --lightText: var(--selectedMenuLightText, $fallback--lightText);
+
+ .menu-icon {
+ --icon: var(--text, $fallback--icon);
+ }
+ }
+
+ &.-active {
+ font-weight: bolder;
+ background-color: $fallback--lightBg;
+ background-color: var(--selectedMenu, $fallback--lightBg);
+ color: $fallback--text;
+ color: var(--selectedMenuText, $fallback--text);
+ --faint: var(--selectedMenuFaintText, $fallback--faint);
+ --faintLink: var(--selectedMenuFaintLink, $fallback--faint);
+ --lightText: var(--selectedMenuLightText, $fallback--lightText);
+
+ .menu-icon {
+ --icon: var(--text, $fallback--icon);
+ }
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+}
+</style>
diff --git a/src/components/navigation/navigation_pins.js b/src/components/navigation/navigation_pins.js
new file mode 100644
index 00000000..57b8d589
--- /dev/null
+++ b/src/components/navigation/navigation_pins.js
@@ -0,0 +1,88 @@
+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 [
+ { ...TIMELINES.public, name: 'public' },
+ { ...TIMELINES.twkn, name: 'twkn' },
+ { ...ROOT_ITEMS.about, name: 'about' }
+ ]
+ }
+ return filterNavigation(
+ [
+ ...Object
+ .entries({ ...TIMELINES })
+ .filter(([k]) => this.pinnedItems.has(k))
+ .map(([k, v]) => ({ ...v, name: k })),
+ ...this.lists.filter((k) => this.pinnedItems.has(k.name)),
+ ...Object
+ .entries({ ...ROOT_ITEMS })
+ .filter(([k]) => this.pinnedItems.has(k))
+ .map(([k, v]) => ({ ...v, name: k }))
+ ],
+ {
+ hasChats: this.pleromaChatMessagesAvailable,
+ isFederating: this.federating,
+ isPrivate: this.privateMode,
+ currentUser: this.currentUser
+ }
+ ).slice(0, this.limit)
+ }
+ }
+}
+
+export default NavPanel
diff --git a/src/components/navigation/navigation_pins.vue b/src/components/navigation/navigation_pins.vue
new file mode 100644
index 00000000..6a9ed6f5
--- /dev/null
+++ b/src/components/navigation/navigation_pins.vue
@@ -0,0 +1,74 @@
+<template>
+ <span class="NavigationPins">
+ <router-link
+ v-for="item in pinnedList"
+ :key="item.name"
+ class="pinned-item"
+ :to="getRouteTo(item)"
+ :title="item.labelRaw || $t(item.label)"
+ >
+ <FAIcon
+ v-if="item.icon"
+ fixed-width
+ :icon="item.icon"
+ />
+ <span
+ v-if="item.iconLetter"
+ class="iconLetter fa-scale-110 fa-old-padding"
+ >{{ item.iconLetter }}</span>
+ <div
+ v-if="item.badgeGetter && getters[item.badgeGetter]"
+ class="alert-dot"
+ />
+ </router-link>
+ </span>
+</template>
+
+<script src="./navigation_pins.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+.NavigationPins {
+ display: flex;
+ flex-wrap: wrap;
+ overflow: hidden;
+ height: 100%;
+
+ .alert-dot {
+ border-radius: 100%;
+ height: 0.5em;
+ width: 0.5em;
+ position: absolute;
+ right: calc(50% - 0.75em);
+ top: calc(50% - 0.5em);
+ background-color: $fallback--cRed;
+ background-color: var(--badgeNotification, $fallback--cRed);
+ }
+
+ .pinned-item {
+ position: relative;
+ flex: 1 0 3em;
+ min-width: 2em;
+ text-align: center;
+ overflow: visible;
+ box-sizing: border-box;
+ height: 100%;
+
+ & .svg-inline--fa,
+ & .iconLetter {
+ margin: 0;
+ }
+
+ &.router-link-active {
+ color: $fallback--text;
+ color: var(--panelText, $fallback--text);
+ border-bottom: 4px solid;
+
+ & .svg-inline--fa,
+ & .iconLetter {
+ color: inherit;
+ }
+ }
+ }
+}
+</style>
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index 398bb7a9..ddba560e 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -4,7 +4,10 @@ import Status from '../status/status.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue'
import Timeago from '../timeago/timeago.vue'
+import Report from '../report/report.vue'
+import UserLink from '../user_link/user_link.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
+import UserPopover from '../user_popover/user_popover.vue'
import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@@ -39,14 +42,17 @@ const Notification = {
unmuted: false
}
},
- props: [ 'notification' ],
+ props: ['notification'],
components: {
StatusContent,
UserAvatar,
UserCard,
Timeago,
Status,
- RichContent
+ Report,
+ RichContent,
+ UserPopover,
+ UserLink
},
methods: {
toggleUserExpanded () {
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index 7d3d0c69..84f3f7de 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"
@@ -34,21 +38,22 @@
<a
class="avatar-container"
:href="$router.resolve(userProfileLink).href"
- @click.stop.prevent.capture="toggleUserExpanded"
+ @click.prevent
>
- <UserAvatar
- :compact="true"
- :better-shadow="betterShadow"
- :user="notification.from_profile"
- />
+ <UserPopover
+ :user-id="notification.from_profile.id"
+ :overlay-centers="true"
+ >
+ <UserAvatar
+ class="post-avatar"
+ :bot="botIndicator"
+ :compact="true"
+ :better-shadow="betterShadow"
+ :user="notification.from_profile"
+ />
+ </UserPopover>
</a>
<div class="notification-right">
- <UserCard
- v-if="userExpanded"
- :user-id="getUser(notification).id"
- :rounded="true"
- :bordered="true"
- />
<span class="notification-details">
<div class="name-and-action">
<!-- eslint-disable vue/no-v-html -->
@@ -120,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"
@@ -170,12 +178,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;"
@@ -206,10 +212,14 @@
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"
@@ -219,7 +229,7 @@
</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 00a531b3..1315b51a 100644
--- a/src/components/notifications/notification_filters.vue
+++ b/src/components/notifications/notification_filters.vue
@@ -5,7 +5,7 @@
placement="bottom"
:bound-to="{ x: 'container' }"
>
- <template v-slot:content>
+ <template #content>
<div class="dropdown-menu">
<button
class="button-default dropdown-item"
@@ -72,7 +72,7 @@
</button>
</div>
</template>
- <template v-slot:trigger>
+ <template #trigger>
<button class="filter-trigger-button button-unstyled">
<FAIcon icon="filter" />
</button>
@@ -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 82aa1489..c3acd9e0 100644
--- a/src/components/notifications/notifications.js
+++ b/src/components/notifications/notifications.js
@@ -1,3 +1,4 @@
+import { computed } from 'vue'
import { mapGetters } from 'vuex'
import Notification from '../notification/notification.vue'
import NotificationFilters from './notification_filters.vue'
@@ -9,10 +10,12 @@ import {
} from '../../services/notification_utils/notification_utils.js'
import FaviconService from '../../services/favicon_service/favicon_service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
-import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
+import { faCircleNotch, faArrowUp, faMinus } from '@fortawesome/free-solid-svg-icons'
library.add(
- faCircleNotch
+ faCircleNotch,
+ faArrowUp,
+ faMinus
)
const DEFAULT_SEEN_TO_DISPLAY_COUNT = 30
@@ -33,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
@@ -40,6 +44,11 @@ const Notifications = {
seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT
}
},
+ provide () {
+ return {
+ popoversZLayer: computed(() => this.popoversZLayer)
+ }
+ },
computed: {
mainClass () {
return this.minimalMode ? '' : 'panel panel-default'
@@ -77,11 +86,27 @@ const Notifications = {
}
return map[layoutType] || '#notifs-sidebar'
},
+ popoversZLayer () {
+ const { layoutType } = this.$store.state.interface
+ return layoutType === 'mobile' ? 'navbar' : null
+ },
notificationsToDisplay () {
return this.filteredNotifications.slice(0, this.unseenCount + this.seenToDisplayCount)
},
+ noSticky () { return this.$store.getters.mergedConfig.disableStickyHeaders },
...mapGetters(['unreadChatCount'])
},
+ mounted () {
+ 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)
+ },
+ unmounted () {
+ if (!this.scrollerRef) return
+ this.scrollerRef.removeEventListener('scroll', this.updateScrollPosition)
+ },
watch: {
unseenCountTitle (count) {
if (count > 0) {
@@ -91,9 +116,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..f71f9b76 100644
--- a/src/components/notifications/notifications.scss
+++ b/src/components/notifications/notifications.scss
@@ -59,8 +59,10 @@
height: 32px;
}
- --link: var(--faintLink);
- --text: var(--faint);
+ .faint {
+ --link: var(--faintLink);
+ --text: var(--faint);
+ }
}
.follow-request-accept {
diff --git a/src/components/notifications/notifications.vue b/src/components/notifications/notifications.vue
index b46c06aa..3d5878d4 100644
--- a/src/components/notifications/notifications.vue
+++ b/src/components/notifications/notifications.vue
@@ -1,6 +1,11 @@
<template>
- <teleport :disabled="minimalMode || disableTeleport" :to="teleportTarget">
- <div
+ <teleport
+ :disabled="minimalMode || disableTeleport"
+ :to="teleportTarget"
+ >
+ <component
+ :is="noHeading ? 'div' : 'aside'"
+ ref="root"
:class="{ minimal: minimalMode }"
class="Notifications"
>
@@ -16,19 +21,43 @@
class="badge badge-notification unseen-count"
>{{ unseenCount }}</span>
</div>
+ <div
+ class="rightside-button"
+ v-if="showScrollTop"
+ >
+ <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}"
>
@@ -64,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/popover/popover.js b/src/components/popover/popover.js
index a30a37c9..d44b266b 100644
--- a/src/components/popover/popover.js
+++ b/src/components/popover/popover.js
@@ -4,7 +4,7 @@ const Popover = {
// Action to trigger popover: either 'hover' or 'click'
trigger: String,
- // Either 'top' or 'bottom'
+ // 'top', 'bottom', 'left', 'right'
placement: String,
// Takes object with properties 'x' and 'y', values of these can be
@@ -31,40 +31,88 @@ const Popover = {
// If true, subtract padding when calculating position for the popover,
// use it when popover offset looks to be different on top vs bottom.
- removePadding: Boolean
+ removePadding: Boolean,
+
+ // self-explanatory (i hope)
+ disabled: Boolean,
+
+ // Instead of putting popover next to anchor, overlay popover's center on top of anchor's center
+ overlayCenters: Boolean,
+
+ // What selector (witin popover!) to use for determining center of popover
+ overlayCentersSelector: String,
+
+ // Lets hover popover stay when clicking inside of it
+ stayOnClick: Boolean,
+
+ triggerAttrs: {
+ type: Object,
+ default: {}
+ }
},
+ inject: ['popoversZLayer'], // override popover z layer
data () {
return {
+ // lockReEntry is a flag that is set when mouse cursor is leaving the popover's content
+ // so that if mouse goes back into popover it won't be re-shown again to prevent annoyance
+ // with popovers refusing to be hidden when user wants to interact with something in below popover
+ anchorEl: null,
+ // There's an issue where having teleport enabled by default causes things just...
+ // not render at all, i.e. main post status form and its emoji inputs
+ teleport: false,
+ lockReEntry: false,
hidden: true,
- styles: { opacity: 0 },
- oldSize: { width: 0, height: 0 }
+ styles: {},
+ oldSize: { width: 0, height: 0 },
+ scrollable: null,
+ // used to avoid blinking if hovered onto popover
+ graceTimeout: null,
+ parentPopover: null,
+ disableClickOutside: false,
+ childrenShown: new Set()
}
},
methods: {
+ setAnchorEl (el) {
+ this.anchorEl = el
+ this.updateStyles()
+ },
containerBoundingClientRect () {
const container = this.boundToSelector ? this.$el.closest(this.boundToSelector) : this.$el.offsetParent
return container.getBoundingClientRect()
},
updateStyles () {
if (this.hidden) {
- this.styles = {
- opacity: 0
- }
+ this.styles = {}
return
}
// Popover will be anchored around this element, trigger ref is the container, so
// its children are what are inside the slot. Expect only one v-slot:trigger.
- const anchorEl = (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el
+ const anchorEl = this.anchorEl || (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el
// SVGs don't have offsetWidth/Height, use fallback
- const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth
const anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight
- const screenBox = anchorEl.getBoundingClientRect()
- // Screen position of the origin point for popover
- const origin = { x: screenBox.left + screenBox.width * 0.5, y: screenBox.top }
+ const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth
+ const anchorScreenBox = anchorEl.getBoundingClientRect()
+
+ const anchorStyle = getComputedStyle(anchorEl)
+ const topPadding = parseFloat(anchorStyle.paddingTop)
+ const bottomPadding = parseFloat(anchorStyle.paddingBottom)
+ const rightPadding = parseFloat(anchorStyle.paddingRight)
+ const leftPadding = parseFloat(anchorStyle.paddingLeft)
+
+ // Screen position of the origin point for popover = center of the anchor
+ const origin = {
+ x: anchorScreenBox.left + anchorWidth * 0.5,
+ y: anchorScreenBox.top + anchorHeight * 0.5
+ }
const content = this.$refs.content
+ const overlayCenter = this.overlayCenters
+ ? this.$refs.content.querySelector(this.overlayCentersSelector)
+ : null
+
// Minor optimization, don't call a slow reflow call if we don't have to
- const parentBounds = this.boundTo &&
+ const parentScreenBox = this.boundTo &&
(this.boundTo.x === 'container' || this.boundTo.y === 'container') &&
this.containerBoundingClientRect()
@@ -72,82 +120,179 @@ const Popover = {
// What are the screen bounds for the popover? Viewport vs container
// when using viewport, using default margin values to dodge the navbar
- const xBounds = this.boundTo && this.boundTo.x === 'container' ? {
- min: parentBounds.left + (margin.left || 0),
- max: parentBounds.right - (margin.right || 0)
- } : {
- min: 0 + (margin.left || 10),
- max: window.innerWidth - (margin.right || 10)
- }
+ const xBounds = this.boundTo && this.boundTo.x === 'container'
+ ? {
+ min: parentScreenBox.left + (margin.left || 0),
+ max: parentScreenBox.right - (margin.right || 0)
+ }
+ : {
+ min: 0 + (margin.left || 10),
+ max: window.innerWidth - (margin.right || 10)
+ }
- const yBounds = this.boundTo && this.boundTo.y === 'container' ? {
- min: parentBounds.top + (margin.top || 0),
- max: parentBounds.bottom - (margin.bottom || 0)
- } : {
- min: 0 + (margin.top || 50),
- max: window.innerHeight - (margin.bottom || 5)
- }
+ const yBounds = this.boundTo && this.boundTo.y === 'container'
+ ? {
+ min: parentScreenBox.top + (margin.top || 0),
+ max: parentScreenBox.bottom - (margin.bottom || 0)
+ }
+ : {
+ min: 0 + (margin.top || 50),
+ max: window.innerHeight - (margin.bottom || 5)
+ }
let horizOffset = 0
+ let vertOffset = 0
+
+ if (overlayCenter) {
+ const box = content.getBoundingClientRect()
+ const overlayCenterScreenBox = overlayCenter.getBoundingClientRect()
+ const leftInnerOffset = overlayCenterScreenBox.left - box.left
+ const topInnerOffset = overlayCenterScreenBox.top - box.top
+ horizOffset = -leftInnerOffset - overlayCenter.offsetWidth * 0.5
+ vertOffset = -topInnerOffset - overlayCenter.offsetHeight * 0.5
+ } else {
+ horizOffset = content.offsetWidth * -0.5
+ vertOffset = content.offsetHeight * -0.5
+ }
+
+ const leftBorder = origin.x + horizOffset
+ const rightBorder = leftBorder + content.offsetWidth
+ const topBorder = origin.y + vertOffset
+ const bottomBorder = topBorder + content.offsetHeight
// If overflowing from left, move it so that it doesn't
- if ((origin.x - content.offsetWidth * 0.5) < xBounds.min) {
- horizOffset += -(origin.x - content.offsetWidth * 0.5) + xBounds.min
+ if (leftBorder < xBounds.min) {
+ horizOffset += xBounds.min - leftBorder
}
// If overflowing from right, move it so that it doesn't
- if ((origin.x + horizOffset + content.offsetWidth * 0.5) > xBounds.max) {
- horizOffset -= (origin.x + horizOffset + content.offsetWidth * 0.5) - xBounds.max
+ if (rightBorder > xBounds.max) {
+ horizOffset -= rightBorder - xBounds.max
}
- // Default to whatever user wished with placement prop
- let usingTop = this.placement !== 'bottom'
-
- // Handle special cases, first force to displaying on top if there's not space on bottom,
- // regardless of what placement value was. Then check if there's not space on top, and
- // force to bottom, again regardless of what placement value was.
- if (origin.y + content.offsetHeight > yBounds.max) usingTop = true
- if (origin.y - content.offsetHeight < yBounds.min) usingTop = false
+ // If overflowing from top, move it so that it doesn't
+ if (topBorder < yBounds.min) {
+ vertOffset += yBounds.min - topBorder
+ }
- let vPadding = 0
- if (this.removePadding && usingTop) {
- const anchorStyle = getComputedStyle(anchorEl)
- vPadding = parseFloat(anchorStyle.paddingTop) + parseFloat(anchorStyle.paddingBottom)
+ // If overflowing from bottom, move it so that it doesn't
+ if (bottomBorder > yBounds.max) {
+ vertOffset -= bottomBorder - yBounds.max
}
- const yOffset = (this.offset && this.offset.y) || 0
- const translateY = usingTop
- ? -anchorHeight + vPadding - yOffset - content.offsetHeight
- : yOffset
+ let translateX = 0
+ let translateY = 0
+
+ if (overlayCenter) {
+ translateX = origin.x + horizOffset
+ translateY = origin.y + vertOffset
+ } else if (this.placement !== 'right' && this.placement !== 'left') {
+ // Default to whatever user wished with placement prop
+ let usingTop = this.placement !== 'bottom'
+
+ // Handle special cases, first force to displaying on top if there's not space on bottom,
+ // regardless of what placement value was. Then check if there's not space on top, and
+ // force to bottom, again regardless of what placement value was.
+ const topBoundary = origin.y - anchorHeight * 0.5 + (this.removePadding ? topPadding : 0)
+ const bottomBoundary = origin.y + anchorHeight * 0.5 - (this.removePadding ? bottomPadding : 0)
+ if (bottomBoundary + content.offsetHeight > yBounds.max) usingTop = true
+ if (topBoundary - content.offsetHeight < yBounds.min) usingTop = false
- const xOffset = (this.offset && this.offset.x) || 0
- const translateX = anchorWidth * 0.5 - content.offsetWidth * 0.5 + horizOffset + xOffset
+ const yOffset = (this.offset && this.offset.y) || 0
+ translateY = usingTop
+ ? topBoundary - yOffset - content.offsetHeight
+ : bottomBoundary + yOffset
+
+ const xOffset = (this.offset && this.offset.x) || 0
+ translateX = origin.x + horizOffset + xOffset
+ } else {
+ // Default to whatever user wished with placement prop
+ let usingRight = this.placement !== 'left'
+
+ // Handle special cases, first force to displaying on top if there's not space on bottom,
+ // regardless of what placement value was. Then check if there's not space on top, and
+ // force to bottom, again regardless of what placement value was.
+ const rightBoundary = origin.x - anchorWidth * 0.5 + (this.removePadding ? rightPadding : 0)
+ const leftBoundary = origin.x + anchorWidth * 0.5 - (this.removePadding ? leftPadding : 0)
+ if (leftBoundary + content.offsetWidth > xBounds.max) usingRight = true
+ if (rightBoundary - content.offsetWidth < xBounds.min) usingRight = false
+
+ const xOffset = (this.offset && this.offset.x) || 0
+ translateX = usingRight
+ ? rightBoundary - xOffset - content.offsetWidth
+ : leftBoundary + xOffset
+
+ const yOffset = (this.offset && this.offset.y) || 0
+ translateY = origin.y + vertOffset + yOffset
+ }
- // Note, separate translateX and translateY avoids blurry text on chromium,
- // single translate or translate3d resulted in blurry text.
this.styles = {
- opacity: 1,
- transform: `translateX(${Math.round(translateX)}px) translateY(${Math.round(translateY)}px)`
+ left: `${Math.round(translateX)}px`,
+ top: `${Math.round(translateY)}px`
+ }
+
+ if (this.popoversZLayer) {
+ this.styles['--ZI_popover_override'] = `var(--ZI_${this.popoversZLayer}_popovers)`
+ }
+ if (parentScreenBox) {
+ this.styles.maxWidth = `${Math.round(parentScreenBox.width)}px`
}
},
showPopover () {
+ if (this.disabled) return
+ this.disableClickOutside = true
+ setTimeout(() => {
+ this.disableClickOutside = false
+ }, 0)
const wasHidden = this.hidden
this.hidden = false
+ this.parentPopover && this.parentPopover.onChildPopoverState(this, true)
+ if (this.trigger === 'click' || this.stayOnClick) {
+ document.addEventListener('click', this.onClickOutside)
+ }
+ this.scrollable.addEventListener('scroll', this.onScroll)
+ this.scrollable.addEventListener('resize', this.onResize)
this.$nextTick(() => {
if (wasHidden) this.$emit('show')
this.updateStyles()
})
},
hidePopover () {
+ if (this.disabled) return
if (!this.hidden) this.$emit('close')
this.hidden = true
- this.styles = { opacity: 0 }
+ this.parentPopover && this.parentPopover.onChildPopoverState(this, false)
+ if (this.trigger === 'click') {
+ document.removeEventListener('click', this.onClickOutside)
+ }
+ this.scrollable.removeEventListener('scroll', this.onScroll)
+ this.scrollable.removeEventListener('resize', this.onResize)
},
onMouseenter (e) {
- if (this.trigger === 'hover') this.showPopover()
+ if (this.trigger === 'hover') {
+ this.lockReEntry = false
+ clearTimeout(this.graceTimeout)
+ this.graceTimeout = null
+ this.showPopover()
+ }
},
onMouseleave (e) {
- if (this.trigger === 'hover') this.hidePopover()
+ if (this.trigger === 'hover' && this.childrenShown.size === 0) {
+ this.graceTimeout = setTimeout(() => this.hidePopover(), 1)
+ }
+ },
+ onMouseenterContent (e) {
+ if (this.trigger === 'hover' && !this.lockReEntry) {
+ this.lockReEntry = true
+ clearTimeout(this.graceTimeout)
+ this.graceTimeout = null
+ this.showPopover()
+ }
+ },
+ onMouseleaveContent (e) {
+ if (this.trigger === 'hover' && this.childrenShown.size === 0) {
+ this.graceTimeout = setTimeout(() => this.hidePopover(), 1)
+ }
},
onClick (e) {
if (this.trigger === 'click') {
@@ -159,9 +304,26 @@ const Popover = {
}
},
onClickOutside (e) {
+ if (this.disableClickOutside) return
if (this.hidden) return
+ if (this.$refs.content && this.$refs.content.contains(e.target)) return
if (this.$el.contains(e.target)) return
+ if (this.childrenShown.size > 0) return
this.hidePopover()
+ if (this.parentPopover) this.parentPopover.onClickOutside(e)
+ },
+ onScroll (e) {
+ this.updateStyles()
+ },
+ onResize (e) {
+ this.updateStyles()
+ },
+ onChildPopoverState (childRef, state) {
+ if (state) {
+ this.childrenShown.add(childRef)
+ } else {
+ this.childrenShown.delete(childRef)
+ }
}
},
updated () {
@@ -175,11 +337,19 @@ const Popover = {
this.oldSize = { width: content.offsetWidth, height: content.offsetHeight }
}
},
- created () {
- document.addEventListener('click', this.onClickOutside)
+ mounted () {
+ this.teleport = true
+ let scrollable = this.$refs.trigger.closest('.column.-scrollable') ||
+ this.$refs.trigger.closest('.mobile-notifications')
+ if (!scrollable) scrollable = window
+ this.scrollable = scrollable
+ let parent = this.$parent
+ while (parent && parent.$.type.name !== 'Popover') {
+ parent = parent.$parent
+ }
+ this.parentPopover = parent
},
- unmounted () {
- document.removeEventListener('click', this.onClickOutside)
+ beforeUnmount () {
this.hidePopover()
}
}
diff --git a/src/components/popover/popover.vue b/src/components/popover/popover.vue
index c2a3e801..c2cf2327 100644
--- a/src/components/popover/popover.vue
+++ b/src/components/popover/popover.vue
@@ -1,5 +1,5 @@
<template>
- <div
+ <span
@mouseenter="onMouseenter"
@mouseleave="onMouseleave"
>
@@ -7,24 +7,32 @@
ref="trigger"
class="button-unstyled popover-trigger-button"
type="button"
+ v-bind="triggerAttrs"
@click="onClick"
>
<slot name="trigger" />
</button>
- <div
- v-if="!hidden"
- ref="content"
- :style="styles"
- class="popover"
- :class="popoverClass || 'popover-default'"
- >
- <slot
- name="content"
- class="popover-inner"
- :close="hidePopover"
- />
- </div>
- </div>
+ <teleport :disabled="!teleport" to="#popovers">
+ <transition name="fade">
+ <div
+ v-if="!hidden"
+ ref="content"
+ :style="styles"
+ class="popover"
+ :class="popoverClass || 'popover-default'"
+ @mouseenter="onMouseenterContent"
+ @mouseleave="onMouseleaveContent"
+ @click="onClickContent"
+ >
+ <slot
+ name="content"
+ class="popover-inner"
+ :close="hidePopover"
+ />
+ </div>
+ </transition>
+ </teleport>
+ </span>
</template>
<script src="./popover.js" />
@@ -37,14 +45,15 @@
}
.popover {
- z-index: 500;
- position: absolute;
+ z-index: var(--ZI_popover_override, var(--ZI_popovers));
+ position: fixed;
min-width: 0;
+ max-width: calc(100vw - 20px);
+ box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5);
+ box-shadow: var(--popupShadow);
}
.popover-default {
- transition: opacity 0.3s;
-
&:after {
content: '';
position: absolute;
@@ -80,7 +89,7 @@
text-align: left;
list-style: none;
max-width: 100vw;
- z-index: 200;
+ z-index: var(--ZI_popover_override, var(--ZI_popovers));
white-space: nowrap;
.dropdown-divider {
@@ -118,6 +127,13 @@
}
}
+ &.-has-submenu {
+ .chevron-icon {
+ margin-right: 0.25rem;
+ margin-left: 2rem;
+ }
+ }
+
&:active, &:hover {
background-color: $fallback--lightBg;
background-color: var(--selectedMenuPopover, $fallback--lightBg);
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index 2febf226..eb55cfcc 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -41,7 +41,7 @@ const buildMentionsString = ({ user, attentions = [] }, currentUser) => {
allAttentions = uniqBy(allAttentions, 'id')
allAttentions = reject(allAttentions, { id: currentUser.id })
- let mentions = map(allAttentions, (attention) => {
+ const mentions = map(allAttentions, (attention) => {
return `@${attention.screen_name}`
})
@@ -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,13 +261,16 @@ const PostStatusForm = {
uploadFileLimitReached () {
return this.newStatus.files.length >= this.fileLimit
},
+ isEdit () {
+ return typeof this.statusId !== 'undefined' && this.statusId.trim() !== ''
+ },
...mapGetters(['mergedConfig']),
...mapState({
mobileLayout: state => state.interface.mobileLayout
})
},
watch: {
- 'newStatus': {
+ newStatus: {
deep: true,
handler () {
this.statusChanged()
@@ -273,7 +301,7 @@ const PostStatusForm = {
this.$refs.textarea.focus()
})
}
- let el = this.$el.querySelector('textarea')
+ const el = this.$el.querySelector('textarea')
el.style.height = 'auto'
el.style.height = undefined
this.error = null
@@ -392,7 +420,7 @@ const PostStatusForm = {
this.$emit('resize', { delayed: true })
},
removeMediaFile (fileInfo) {
- let index = this.newStatus.files.indexOf(fileInfo)
+ const index = this.newStatus.files.indexOf(fileInfo)
this.newStatus.files.splice(index, 1)
this.$emit('resize')
},
@@ -462,7 +490,7 @@ const PostStatusForm = {
},
onEmojiInputInput (e) {
this.$nextTick(() => {
- this.resize(this.$refs['textarea'])
+ this.resize(this.$refs.textarea)
})
},
resize (e) {
@@ -473,12 +501,11 @@ const PostStatusForm = {
if (target.value === '') {
target.style.height = null
this.$emit('resize')
- this.$refs['emoji-input'].resize()
return
}
- const formRef = this.$refs['form']
- const bottomRef = this.$refs['bottom']
+ const formRef = this.$refs.form
+ const bottomRef = this.$refs.bottom
/* Scroller is either `window` (replies in TL), sidebar (main post form,
* replies in notifs) or mobile post form. Note that getting and setting
* scroll is different for `Window` and `Element`s
@@ -560,11 +587,9 @@ const PostStatusForm = {
} else {
scrollerRef.scrollTop = targetScroll
}
-
- this.$refs['emoji-input'].resize()
},
showEmojiPicker () {
- this.$refs['textarea'].focus()
+ this.$refs.textarea.focus()
this.$refs['emoji-input'].triggerShowPicker()
},
clearError () {
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index 62613bd1..f65058f4 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"
@@ -410,6 +418,16 @@
align-items: baseline;
}
+ .visibility-notice.edit-warning {
+ > :first-child {
+ margin-top: 0;
+ }
+
+ > :last-child {
+ margin-bottom: 0;
+ }
+ }
+
.media-upload-icon, .poll-icon, .emoji-icon {
font-size: 1.85em;
line-height: 1.1;
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 98fab926..87fcd716 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' }"
+ :triggerAttrs="{ title: $t('timeline.quick_filter_settings') }"
>
- <template v-slot:content>
+ <template #content>
<div class="dropdown-menu">
<div v-if="loggedIn">
<button
+ v-if="!conversation"
class="button-default dropdown-item"
@click="replyVisibilityAll = true"
>
@@ -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 v-slot:trigger>
- <button class="button-unstyled">
- <FAIcon icon="filter" />
- </button>
+ <template #trigger>
+ <FAIcon icon="filter" />
</template>
</Popover>
</template>
-<script src="./timeline_quick_settings.js"></script>
-
-<style lang="scss">
-
-.TimelineQuickSettings {
-
- > 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..d7c9bf3b
--- /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' }"
+ :triggerAttrs="{ 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 e6f9dbff..2a0dac85 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')
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())
- let orderedEmojiList = []
- for (const emoji of this.$store.state.instance.emoji) {
- if (emoji.replacement === this.filterWord) return [emoji]
+ const keywordLowercase = trim(this.filterWord.toLowerCase())
+
+ const orderedEmojiList = []
+ for (const emoji of this.$store.getters.standardEmojiList) {
+ const indices = this.maybeLocalizedEmojiNamesAndKeywords(emoji)
+ .keywords
+ .map(k => k.toLowerCase().indexOf(keywordLowercase))
+ .filter(k => k > -1)
+
+ const indexOfKeyword = indices.length ? Math.min(...indices) : -1
- const indexOfFilterWord = emoji.displayText.toLowerCase().indexOf(filterWordLowercase)
- if (indexOfFilterWord > -1) {
- if (!Array.isArray(orderedEmojiList[indexOfFilterWord])) {
- orderedEmojiList[indexOfFilterWord] = []
+ if (indexOfKeyword > -1) {
+ if (!Array.isArray(orderedEmojiList[indexOfKeyword])) {
+ orderedEmojiList[indexOfKeyword] = []
}
- orderedEmojiList[indexOfFilterWord].push(emoji)
+ orderedEmojiList[indexOfKeyword].push(emoji)
}
}
return orderedEmojiList.flat()
}
- return this.$store.state.instance.emoji || []
+ return this.$store.getters.standardEmojiList || []
},
mergedConfig () {
return this.$store.getters.mergedConfig
diff --git a/src/components/react_button/react_button.vue b/src/components/react_button/react_button.vue
index 8a4b4d3b..0c5fe321 100644
--- a/src/components/react_button/react_button.vue
+++ b/src/components/react_button/react_button.vue
@@ -6,15 +6,17 @@
:offset="{ y: 5 }"
:bound-to="{ x: 'container' }"
remove-padding
- @show="focusInput"
+ popover-class="ReactButton popover-default"
+ @show="onShow"
+ @close="onClose"
>
- <template v-slot:content="{close}">
+ <template #content="{close}">
<div class="reaction-picker-filter">
<input
v-model="filterWord"
- @input="$event.target.composing = false"
size="1"
:placeholder="$t('emoji.search_emoji')"
+ @input="$event.target.composing = false"
>
</div>
<div class="reaction-picker">
@@ -22,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 }}
@@ -32,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 }}
@@ -40,24 +42,39 @@
<div class="reaction-bottom-fader" />
</div>
</template>
- <template v-slot:trigger>
- <button
+ <template #trigger>
+ <span
class="button-unstyled popover-trigger"
:title="$t('tool_tip.add_reaction')"
>
- <FAIcon
- class="fa-scale-110 fa-old-padding"
- :icon="['far', 'smile-beam']"
- />
- </button>
+ <FALayers>
+ <FAIcon
+ class="fa-scale-110 fa-old-padding"
+ :icon="['far', 'smile-beam']"
+ />
+ <FAIcon
+ v-show="!expanded"
+ class="focus-marker"
+ transform="shrink-6 up-9 right-17"
+ icon="plus"
+ />
+ <FAIcon
+ v-show="expanded"
+ class="focus-marker"
+ transform="shrink-6 up-9 right-17"
+ icon="times"
+ />
+ </FALayers>
+ </span>
</template>
</Popover>
</template>
-<script src="./react_button.js" ></script>
+<script src="./react_button.js"></script>
<style lang="scss">
@import '../../_variables.scss';
+@import '../../_mixins.scss';
.ReactButton {
.reaction-picker-filter {
@@ -124,6 +141,21 @@
color: $fallback--text;
color: var(--text, $fallback--text);
}
+
+ }
+
+ .popover-trigger-button {
+ @include unfocused-style {
+ .focus-marker {
+ visibility: hidden;
+ }
+ }
+
+ @include focused-style {
+ .focus-marker {
+ visibility: visible;
+ }
+ }
}
}
diff --git a/src/components/registration/registration.vue b/src/components/registration/registration.vue
index cc655c0b..d78d8da9 100644
--- a/src/components/registration/registration.vue
+++ b/src/components/registration/registration.vue
@@ -23,6 +23,7 @@
v-model.trim="v$.user.username.$model"
:disabled="isPending"
class="form-control"
+ :aria-required="true"
:placeholder="$t('registration.username_placeholder')"
>
</div>
@@ -50,6 +51,7 @@
v-model.trim="v$.user.fullname.$model"
:disabled="isPending"
class="form-control"
+ :aria-required="true"
:placeholder="$t('registration.fullname_placeholder')"
>
</div>
@@ -71,13 +73,14 @@
<label
class="form--label"
for="email"
- >{{ $t('registration.email') }}</label>
+ >{{ accountActivationRequired ? $t('registration.email') : $t('registration.email_optional') }}</label>
<input
id="email"
v-model="v$.user.email.$model"
:disabled="isPending"
class="form-control"
type="email"
+ :aria-required="accountActivationRequired"
>
</div>
<div
@@ -95,7 +98,7 @@
<label
class="form--label"
for="bio"
- >{{ $t('registration.bio') }} ({{ $t('general.optional') }})</label>
+ >{{ $t('registration.bio_optional') }}</label>
<textarea
id="bio"
v-model="user.bio"
@@ -119,6 +122,7 @@
:disabled="isPending"
class="form-control"
type="password"
+ :aria-required="true"
>
</div>
<div
@@ -146,6 +150,7 @@
:disabled="isPending"
class="form-control"
type="password"
+ :aria-required="true"
>
</div>
<div
diff --git a/src/components/remote_follow/remote_follow.js b/src/components/remote_follow/remote_follow.js
index 461d58c9..56b264fc 100644
--- a/src/components/remote_follow/remote_follow.js
+++ b/src/components/remote_follow/remote_follow.js
@@ -1,5 +1,5 @@
export default {
- props: [ 'user' ],
+ props: ['user'],
computed: {
subscribeUrl () {
// eslint-disable-next-line no-undef
diff --git a/src/components/remove_follower_button/remove_follower_button.js b/src/components/remove_follower_button/remove_follower_button.js
new file mode 100644
index 00000000..e1a7531b
--- /dev/null
+++ b/src/components/remove_follower_button/remove_follower_button.js
@@ -0,0 +1,25 @@
+export default {
+ props: ['relationship'],
+ data () {
+ return {
+ inProgress: false
+ }
+ },
+ computed: {
+ label () {
+ if (this.inProgress) {
+ return this.$t('user_card.follow_progress')
+ } else {
+ return this.$t('user_card.remove_follower')
+ }
+ }
+ },
+ methods: {
+ onClick () {
+ this.inProgress = true
+ this.$store.dispatch('removeUserFromFollowers', this.relationship.id).then(() => {
+ this.inProgress = false
+ })
+ }
+ }
+}
diff --git a/src/components/remove_follower_button/remove_follower_button.vue b/src/components/remove_follower_button/remove_follower_button.vue
new file mode 100644
index 00000000..a3a4c242
--- /dev/null
+++ b/src/components/remove_follower_button/remove_follower_button.vue
@@ -0,0 +1,13 @@
+<template>
+ <button
+ class="btn button-default follow-button"
+ :class="{ toggled: inProgress }"
+ :disabled="inProgress"
+ :title="$t('user_card.remove_follower')"
+ @click="onClick"
+ >
+ {{ label }}
+ </button>
+</template>
+
+<script src="./remove_follower_button.js"></script>
diff --git a/src/components/reply_button/reply_button.js b/src/components/reply_button/reply_button.js
index c7bd2a2b..543d25ac 100644
--- a/src/components/reply_button/reply_button.js
+++ b/src/components/reply_button/reply_button.js
@@ -1,7 +1,15 @@
import { library } from '@fortawesome/fontawesome-svg-core'
-import { faReply } from '@fortawesome/free-solid-svg-icons'
+import {
+ faReply,
+ faPlus,
+ faTimes
+} from '@fortawesome/free-solid-svg-icons'
-library.add(faReply)
+library.add(
+ faReply,
+ faPlus,
+ faTimes
+)
const ReplyButton = {
name: 'ReplyButton',
@@ -9,6 +17,9 @@ const ReplyButton = {
computed: {
loggedIn () {
return !!this.$store.state.users.currentUser
+ },
+ remoteInteractionLink () {
+ return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
}
}
}
diff --git a/src/components/reply_button/reply_button.vue b/src/components/reply_button/reply_button.vue
index c17041da..dada511b 100644
--- a/src/components/reply_button/reply_button.vue
+++ b/src/components/reply_button/reply_button.vue
@@ -7,18 +7,38 @@
:title="$t('tool_tip.reply')"
@click.prevent="$emit('toggle')"
>
- <FAIcon
- class="fa-scale-110 fa-old-padding"
- icon="reply"
- />
+ <FALayers class="fa-old-padding-layer">
+ <FAIcon
+ class="fa-scale-110"
+ icon="reply"
+ />
+ <FAIcon
+ v-if="!replying"
+ class="focus-marker"
+ transform="shrink-6 up-8 right-11"
+ icon="plus"
+ />
+ <FAIcon
+ v-else
+ class="focus-marker"
+ transform="shrink-6 up-8 right-11"
+ icon="times"
+ />
+ </FALayers>
</button>
- <span v-else>
+ <a
+ v-else
+ class="button-unstyled interactive"
+ target="_blank"
+ role="button"
+ :href="remoteInteractionLink"
+ >
<FAIcon
icon="reply"
class="fa-scale-110 fa-old-padding"
:title="$t('tool_tip.reply')"
/>
- </span>
+ </a>
<span
v-if="status.replies_count > 0"
class="action-counter"
@@ -32,6 +52,7 @@
<style lang="scss">
@import '../../_variables.scss';
+@import '../../_mixins.scss';
.ReplyButton {
display: flex;
@@ -52,6 +73,18 @@
color: $fallback--cBlue;
color: var(--cBlue, $fallback--cBlue);
}
+
+ @include unfocused-style {
+ .focus-marker {
+ visibility: hidden;
+ }
+ }
+
+ @include focused-style {
+ .focus-marker {
+ visibility: visible;
+ }
+ }
}
}
diff --git a/src/components/report/report.js b/src/components/report/report.js
new file mode 100644
index 00000000..76055764
--- /dev/null
+++ b/src/components/report/report.js
@@ -0,0 +1,34 @@
+import Select from '../select/select.vue'
+import StatusContent from '../status_content/status_content.vue'
+import Timeago from '../timeago/timeago.vue'
+import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+
+const Report = {
+ props: [
+ 'reportId'
+ ],
+ components: {
+ Select,
+ StatusContent,
+ Timeago
+ },
+ computed: {
+ report () {
+ return this.$store.state.reports.reports[this.reportId] || {}
+ },
+ state: {
+ get: function () { return this.report.state },
+ set: function (val) { this.setReportState(val) }
+ }
+ },
+ methods: {
+ generateUserProfileLink (user) {
+ return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
+ },
+ setReportState (state) {
+ return this.$store.dispatch('setReportState', { id: this.report.id, state })
+ }
+ }
+}
+
+export default Report
diff --git a/src/components/report/report.scss b/src/components/report/report.scss
new file mode 100644
index 00000000..578b4eb1
--- /dev/null
+++ b/src/components/report/report.scss
@@ -0,0 +1,43 @@
+@import '../../_variables.scss';
+
+.Report {
+ .report-content {
+ margin: 0.5em 0 1em;
+ }
+
+ .report-state {
+ margin: 0.5em 0 1em;
+ }
+
+ .reported-status {
+ border: 1px solid $fallback--faint;
+ border-color: var(--faint, $fallback--faint);
+ border-radius: $fallback--inputRadius;
+ border-radius: var(--inputRadius, $fallback--inputRadius);
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ display: block;
+ padding: 0.5em;
+ margin: 0.5em 0;
+
+ .status-content {
+ pointer-events: none;
+ }
+
+ .reported-status-heading {
+ display: flex;
+ width: 100%;
+ justify-content: space-between;
+ margin-bottom: 0.2em;
+ }
+
+ .reported-status-name {
+ font-weight: bold;
+ }
+ }
+
+ .note {
+ width: 100%;
+ margin-bottom: 0.5em;
+ }
+}
diff --git a/src/components/report/report.vue b/src/components/report/report.vue
new file mode 100644
index 00000000..1f19cc25
--- /dev/null
+++ b/src/components/report/report.vue
@@ -0,0 +1,74 @@
+<template>
+ <div class="Report">
+ <div class="reported-user">
+ <span>{{ $t('report.reported_user') }}</span>
+ <router-link :to="generateUserProfileLink(report.acct)">
+ @{{ report.acct.screen_name }}
+ </router-link>
+ </div>
+ <div class="reporter">
+ <span>{{ $t('report.reporter') }}</span>
+ <router-link :to="generateUserProfileLink(report.actor)">
+ @{{ report.actor.screen_name }}
+ </router-link>
+ </div>
+ <div class="report-state">
+ <span>{{ $t('report.state') }}</span>
+ <Select
+ :id="report-state"
+ v-model="state"
+ class="form-control"
+ >
+ <option
+ v-for="state in ['open', 'closed', 'resolved']"
+ :key="state"
+ :value="state"
+ >
+ {{ $t('report.state_' + state) }}
+ </option>
+ </Select>
+ </div>
+ <RichContent
+ class="report-content"
+ :html="report.content"
+ :emoji="[]"
+ />
+ <div v-if="report.statuses.length">
+ <small>{{ $t('report.reported_statuses') }}</small>
+ <router-link
+ v-for="status in report.statuses"
+ :key="status.id"
+ :to="{ name: 'conversation', params: { id: status.id } }"
+ class="reported-status"
+ >
+ <div class="reported-status-heading">
+ <span class="reported-status-name">{{ status.user.name }}</span>
+ <Timeago
+ :time="status.created_at"
+ :auto-update="240"
+ class="faint"
+ />
+ </div>
+ <status-content :status="status" />
+ </router-link>
+ </div>
+ <div v-if="report.notes.length">
+ <small>{{ $t('report.notes') }}</small>
+ <div
+ v-for="note in report.notes"
+ :key="note.id"
+ class="note"
+ >
+ <span>{{ note.content }}</span>
+ <Timeago
+ :time="note.created_at"
+ :auto-update="240"
+ class="faint"
+ />
+ </div>
+ </div>
+ </div>
+</template>
+
+<script src="./report.js"></script>
+<style src="./report.scss" lang="scss"></style>
diff --git a/src/components/retweet_button/retweet_button.js b/src/components/retweet_button/retweet_button.js
index 2103fd0b..4d92b5fa 100644
--- a/src/components/retweet_button/retweet_button.js
+++ b/src/components/retweet_button/retweet_button.js
@@ -1,7 +1,17 @@
import { library } from '@fortawesome/fontawesome-svg-core'
-import { faRetweet } from '@fortawesome/free-solid-svg-icons'
+import {
+ faRetweet,
+ faPlus,
+ faMinus,
+ faCheck
+} from '@fortawesome/free-solid-svg-icons'
-library.add(faRetweet)
+library.add(
+ faRetweet,
+ faPlus,
+ faMinus,
+ faCheck
+)
const RetweetButton = {
props: ['status', 'loggedIn', 'visibility'],
@@ -26,6 +36,9 @@ const RetweetButton = {
computed: {
mergedConfig () {
return this.$store.getters.mergedConfig
+ },
+ remoteInteractionLink () {
+ return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
}
}
}
diff --git a/src/components/retweet_button/retweet_button.vue b/src/components/retweet_button/retweet_button.vue
index 859ce499..240828e3 100644
--- a/src/components/retweet_button/retweet_button.vue
+++ b/src/components/retweet_button/retweet_button.vue
@@ -7,11 +7,31 @@
:title="$t('tool_tip.repeat')"
@click.prevent="retweet()"
>
- <FAIcon
- class="fa-scale-110 fa-old-padding"
- icon="retweet"
- :spin="animated"
- />
+ <FALayers class="fa-old-padding-layer">
+ <FAIcon
+ class="fa-scale-110"
+ icon="retweet"
+ :spin="animated"
+ />
+ <FAIcon
+ v-if="status.repeated"
+ class="active-marker"
+ transform="shrink-6 up-9 right-12"
+ icon="check"
+ />
+ <FAIcon
+ v-if="!status.repeated"
+ class="focus-marker"
+ transform="shrink-6 up-9 right-12"
+ icon="plus"
+ />
+ <FAIcon
+ v-else
+ class="focus-marker"
+ transform="shrink-6 up-9 right-12"
+ icon="minus"
+ />
+ </FALayers>
</button>
<span v-else-if="loggedIn">
<FAIcon
@@ -20,13 +40,19 @@
:title="$t('timeline.no_retweet_hint')"
/>
</span>
- <span v-else>
+ <a
+ v-else
+ class="button-unstyled interactive"
+ target="_blank"
+ role="button"
+ :href="remoteInteractionLink"
+ >
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="retweet"
:title="$t('tool_tip.repeat')"
/>
- </span>
+ </a>
<span
v-if="!mergedConfig.hidePostStats && status.repeat_num > 0"
class="no-event"
@@ -36,10 +62,11 @@
</div>
</template>
-<script src="./retweet_button.js" ></script>
+<script src="./retweet_button.js"></script>
<style lang="scss">
@import '../../_variables.scss';
+@import '../../_mixins.scss';
.RetweetButton {
display: flex;
@@ -64,6 +91,26 @@
color: $fallback--cGreen;
color: var(--cGreen, $fallback--cGreen);
}
+
+ @include unfocused-style {
+ .focus-marker {
+ visibility: hidden;
+ }
+
+ .active-marker {
+ visibility: visible;
+ }
+ }
+
+ @include focused-style {
+ .focus-marker {
+ visibility: visible;
+ }
+
+ .active-marker {
+ visibility: hidden;
+ }
+ }
}
}
</style>
diff --git a/src/components/search/search.js b/src/components/search/search.js
index 76ac30ef..8d4212cd 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
+
+ let oldLength = this.statuses.length
+
+ // Always append to old results. If new results are empty, this doesn't change anything
+ this.userIds = this.userIds.concat(map(data.accounts, 'id'))
+ this.statuses = uniqBy(this.statuses.concat(data.statuses), 'id')
+ this.hashtags = this.hashtags.concat(data.hashtags)
+
this.currenResultTab = this.getActiveTab()
this.loaded = true
+
+ // Offset from whatever we already have
+ this.statusesOffset = this.statuses.length
+ // Because the amount of new statuses can actually be zero, compare to old lenght instead
+ this.lastStatusFetchCount = this.statuses.length - oldLength
+ this.lastQuery = query
})
},
resultCount (tabName) {
diff --git a/src/components/search/search.vue b/src/components/search/search.vue
index b7bfc1f3..6fc6a0de 100644
--- a/src/components/search/search.vue
+++ b/src/components/search/search.vue
@@ -22,7 +22,7 @@
</button>
</div>
<div
- v-if="loading"
+ v-if="loading && statusesOffset == 0"
class="text-center loading-icon"
>
<FAIcon
@@ -55,12 +55,6 @@
</div>
<div class="panel-body">
<div v-if="currenResultTab === 'statuses'">
- <div
- v-if="visibleStatuses.length === 0 && !loading && loaded"
- class="search-result-heading"
- >
- <h4>{{ $t('search.no_results') }}</h4>
- </div>
<Status
v-for="status in visibleStatuses"
:key="status.id"
@@ -71,6 +65,33 @@
:statusoid="status"
:no-heading="false"
/>
+ <button
+ v-if="!loading && loaded && lastStatusFetchCount > 0"
+ class="more-statuses-button button-unstyled -link -fullwidth"
+ @click.prevent="search(searchTerm, 'statuses')"
+ >
+ <div class="new-status-notification text-center">
+ {{ $t('search.load_more') }}
+ </div>
+ </button>
+ <div
+ v-else-if="loading && statusesOffset > 0"
+ class="text-center loading-icon"
+ >
+ <FAIcon
+ icon="circle-notch"
+ spin
+ size="lg"
+ />
+ </div>
+ <div
+ v-if="(visibleStatuses.length === 0 || lastStatusFetchCount === 0) && !loading && loaded"
+ class="search-result-heading"
+ >
+ <h4>
+ {{ visibleStatuses.length === 0 ? $t('search.no_results') : $t('search.no_more_results') }}
+ </h4>
+ </div>
</div>
<div v-else-if="currenResultTab === 'people'">
<div
@@ -208,6 +229,11 @@
color: $fallback--text;
color: var(--text, $fallback--text);
}
-}
+ }
+
+ .more-statuses-button {
+ height: 3.5em;
+ line-height: 3.5em;
+ }
</style>
diff --git a/src/components/search_bar/search_bar.js b/src/components/search_bar/search_bar.js
index 551649c7..3b297f09 100644
--- a/src/components/search_bar/search_bar.js
+++ b/src/components/search_bar/search_bar.js
@@ -16,7 +16,7 @@ const SearchBar = {
error: false
}),
watch: {
- '$route': function (route) {
+ $route: function (route) {
if (route.name === 'search') {
this.searchTerm = route.query.query
}
diff --git a/src/components/search_bar/search_bar.vue b/src/components/search_bar/search_bar.vue
index 222f57ba..199a7500 100644
--- a/src/components/search_bar/search_bar.vue
+++ b/src/components/search_bar/search_bar.vue
@@ -47,6 +47,8 @@
class="cancel-icon fa-scale-110 fa-old-padding"
/>
</button>
+ <span class="spacer" />
+ <span class="spacer" />
</template>
</div>
</template>
diff --git a/src/components/selectable_list/selectable_list.vue b/src/components/selectable_list/selectable_list.vue
index 3c80660e..1f7683ab 100644
--- a/src/components/selectable_list/selectable_list.vue
+++ b/src/components/selectable_list/selectable_list.vue
@@ -24,7 +24,7 @@
:items="items"
:get-key="getKey"
>
- <template v-slot:item="{item}">
+ <template #item="{item}">
<div
class="selectable-list-item-inner"
:class="{ 'selectable-list-item-selected-inner': isSelected(item) }"
@@ -41,7 +41,7 @@
/>
</div>
</template>
- <template v-slot:empty>
+ <template #empty>
<slot name="empty" />
</template>
</List>
diff --git a/src/components/settings_modal/helpers/boolean_setting.js b/src/components/settings_modal/helpers/boolean_setting.js
index 353e551c..dc832044 100644
--- a/src/components/settings_modal/helpers/boolean_setting.js
+++ b/src/components/settings_modal/helpers/boolean_setting.js
@@ -42,6 +42,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/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..d141a0d6 100644
--- a/src/components/settings_modal/helpers/choice_setting.vue
+++ b/src/components/settings_modal/helpers/choice_setting.vue
@@ -19,7 +19,10 @@
{{ option.value === defaultState ? $t('settings.instance_default_simple') : '' }}
</option>
</Select>
- <ModifiedIndicator :changed="isChanged" />
+ <ModifiedIndicator
+ :changed="isChanged"
+ :onclick="reset"
+ />
<ServerSideIndicator :server-side="isServerSide" />
</label>
</template>
diff --git a/src/components/settings_modal/helpers/integer_setting.js b/src/components/settings_modal/helpers/integer_setting.js
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/modified_indicator.vue b/src/components/settings_modal/helpers/modified_indicator.vue
index ad212db9..8311533a 100644
--- a/src/components/settings_modal/helpers/modified_indicator.vue
+++ b/src/components/settings_modal/helpers/modified_indicator.vue
@@ -6,14 +6,14 @@
<Popover
trigger="hover"
>
- <template v-slot:trigger>
+ <template #trigger>
&nbsp;
<FAIcon
icon="wrench"
:aria-label="$t('settings.setting_changed')"
/>
</template>
- <template v-slot:content>
+ <template #content>
<div class="modified-tooltip">
{{ $t('settings.setting_changed') }}
</div>
@@ -41,11 +41,11 @@ export default {
.ModifiedIndicator {
display: inline-block;
position: relative;
+}
- .modified-tooltip {
- margin: 0.5em 1em;
- min-width: 10em;
- text-align: center;
- }
+.modified-tooltip {
+ margin: 0.5em 1em;
+ min-width: 10em;
+ text-align: center;
}
</style>
diff --git a/src/components/settings_modal/helpers/server_side_indicator.vue b/src/components/settings_modal/helpers/server_side_indicator.vue
index 143a86a1..bf181959 100644
--- a/src/components/settings_modal/helpers/server_side_indicator.vue
+++ b/src/components/settings_modal/helpers/server_side_indicator.vue
@@ -6,14 +6,14 @@
<Popover
trigger="hover"
>
- <template v-slot:trigger>
+ <template #trigger>
&nbsp;
<FAIcon
icon="server"
:aria-label="$t('settings.setting_server_side')"
/>
</template>
- <template v-slot:content>
+ <template #content>
<div class="serverside-tooltip">
{{ $t('settings.setting_server_side') }}
</div>
@@ -41,11 +41,11 @@ export default {
.ServerSideIndicator {
display: inline-block;
position: relative;
+}
- .serverside-tooltip {
- margin: 0.5em 1em;
- min-width: 10em;
- text-align: center;
- }
+.serverside-tooltip {
+ margin: 0.5em 1em;
+ min-width: 10em;
+ text-align: center;
}
</style>
diff --git a/src/components/settings_modal/helpers/size_setting.js b/src/components/settings_modal/helpers/size_setting.js
new file mode 100644
index 00000000..58697412
--- /dev/null
+++ b/src/components/settings_modal/helpers/size_setting.js
@@ -0,0 +1,67 @@
+import { get, set } from 'lodash'
+import ModifiedIndicator from './modified_indicator.vue'
+import Select from 'src/components/select/select.vue'
+
+export const allCssUnits = ['cm', 'mm', 'in', 'px', 'pt', 'pc', 'em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax', '%']
+export const defaultHorizontalUnits = ['px', 'rem', 'vw']
+export const defaultVerticalUnits = ['px', 'rem', 'vh']
+
+export default {
+ components: {
+ ModifiedIndicator,
+ Select
+ },
+ props: {
+ path: String,
+ disabled: Boolean,
+ min: Number,
+ units: {
+ type: [String],
+ default: () => allCssUnits
+ },
+ expert: [Number, String]
+ },
+ computed: {
+ pathDefault () {
+ const [firstSegment, ...rest] = this.path.split('.')
+ return [firstSegment + 'DefaultValue', ...rest].join('.')
+ },
+ stateUnit () {
+ return (this.state || '').replace(/\d+/, '')
+ },
+ stateValue () {
+ return (this.state || '').replace(/\D+/, '')
+ },
+ state () {
+ const value = get(this.$parent, this.path)
+ if (value === undefined) {
+ return this.defaultState
+ } else {
+ return value
+ }
+ },
+ defaultState () {
+ return get(this.$parent, this.pathDefault)
+ },
+ isChanged () {
+ return this.state !== this.defaultState
+ },
+ matchesExpertLevel () {
+ return (this.expert || 0) <= this.$parent.expertLevel
+ }
+ },
+ methods: {
+ update (e) {
+ set(this.$parent, this.path, e)
+ },
+ reset () {
+ set(this.$parent, this.path, this.defaultState)
+ },
+ updateValue (e) {
+ set(this.$parent, this.path, parseInt(e.target.value) + this.stateUnit)
+ },
+ updateUnit (e) {
+ set(this.$parent, this.path, this.stateValue + e.target.value)
+ }
+ }
+}
diff --git a/src/components/settings_modal/helpers/size_setting.vue b/src/components/settings_modal/helpers/size_setting.vue
new file mode 100644
index 00000000..90c9f538
--- /dev/null
+++ b/src/components/settings_modal/helpers/size_setting.vue
@@ -0,0 +1,54 @@
+<template>
+ <span
+ v-if="matchesExpertLevel"
+ class="SizeSetting"
+ >
+ <label
+ :for="path"
+ class="size-label"
+ >
+ <slot />
+ </label>
+ <input
+ :id="path"
+ class="number-input"
+ type="number"
+ step="1"
+ :disabled="disabled"
+ :min="min || 0"
+ :value="stateValue"
+ @change="updateValue"
+ >
+ <Select
+ :id="path"
+ :model-value="stateUnit"
+ :disabled="disabled"
+ class="css-unit-input"
+ @change="updateUnit"
+ >
+ <option
+ v-for="option in units"
+ :key="option"
+ :value="option"
+ >
+ {{ option }}
+ </option>
+ </Select>
+ {{ ' ' }}
+ <ModifiedIndicator
+ :changed="isChanged"
+ :onclick="reset"
+ />
+ </span>
+</template>
+
+<script src="./size_setting.js"></script>
+
+<style lang="scss">
+.css-unit-input, .css-unit-input select {
+ margin-left: 0.5em;
+ width: 4em !important;
+ max-width: 4em !important;
+ min-width: 4em !important;
+}
+</style>
diff --git a/src/components/settings_modal/settings_modal.vue b/src/components/settings_modal/settings_modal.vue
index d3bed061..7b457371 100644
--- a/src/components/settings_modal/settings_modal.vue
+++ b/src/components/settings_modal/settings_modal.vue
@@ -53,7 +53,7 @@
:bound-to="{ x: 'container' }"
remove-padding
>
- <template v-slot:trigger>
+ <template #trigger>
<button
class="btn button-default"
:title="$t('general.close')"
@@ -65,7 +65,7 @@
/>
</button>
</template>
- <template v-slot:content="{close}">
+ <template #content="{close}">
<div class="dropdown-menu">
<button
class="button-default dropdown-item dropdown-item-icon"
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 1fe51b6d..8561647b 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,27 +60,25 @@
</BooleanSetting>
</li>
<li>
- <BooleanSetting path="disableStickyHeaders">
- {{ $t('settings.disable_sticky_headers') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting path="showScrollbars">
- {{ $t('settings.show_scrollbars') }}
- </BooleanSetting>
- </li>
- <li>
<ChoiceSetting
- v-if="user"
- id="thirdColumnMode"
- path="thirdColumnMode"
- :options="thirdColumnModeOptions"
+ id="userPopoverAvatarAction"
+ path="userPopoverAvatarAction"
+ :options="userPopoverAvatarActionOptions"
+ expert="1"
>
- {{ $t('settings.third_column_mode') }}
+ {{ $t('settings.user_popover_avatar_action') }}
</ChoiceSetting>
</li>
<li>
<BooleanSetting
+ path="userPopoverOverlay"
+ expert="1"
+ >
+ {{ $t('settings.user_popover_avatar_overlay') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
path="alwaysShowNewPostButton"
expert="1"
>
@@ -108,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">
@@ -261,18 +301,14 @@
{{ $t('settings.mention_link_display') }}
</ChoiceSetting>
</li>
- <ul
- class="setting-list suboptions"
- >
- <li v-if="mentionLinkDisplay === 'short'">
- <BooleanSetting
- path="mentionLinkShowTooltip"
- expert="1"
- >
- {{ $t('settings.mention_link_show_tooltip') }}
- </BooleanSetting>
- </li>
- </ul>
+ <li>
+ <BooleanSetting
+ path="mentionLinkShowTooltip"
+ expert="1"
+ >
+ {{ $t('settings.mention_link_use_tooltip') }}
+ </BooleanSetting>
+ </li>
<li>
<BooleanSetting
path="useAtIcon"
@@ -421,3 +457,16 @@
</template>
<script src="./general_tab.js"></script>
+
+<style lang="scss">
+.column-settings {
+ display: flex;
+ justify-content: space-evenly;
+ flex-wrap: wrap;
+}
+.column-settings .size-label {
+ display: block;
+ margin-bottom: 0.5em;
+ margin-top: 0.5em;
+}
+</style>
diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue b/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue
index 32a21415..c515d542 100644
--- a/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue
+++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue
@@ -10,7 +10,7 @@
:query="queryUserIds"
:placeholder="$t('settings.search_user_to_block')"
>
- <template v-slot="row">
+ <template #default="row">
<BlockCard
:user-id="row.item"
/>
@@ -21,7 +21,7 @@
:refresh="true"
:get-key="i => i"
>
- <template v-slot:header="{selected}">
+ <template #header="{selected}">
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
@@ -29,7 +29,7 @@
:click="() => blockUsers(selected)"
>
{{ $t('user_card.block') }}
- <template v-slot:progress>
+ <template #progress>
{{ $t('user_card.block_progress') }}
</template>
</ProgressButton>
@@ -39,16 +39,16 @@
:click="() => unblockUsers(selected)"
>
{{ $t('user_card.unblock') }}
- <template v-slot:progress>
+ <template #progress>
{{ $t('user_card.unblock_progress') }}
</template>
</ProgressButton>
</div>
</template>
- <template v-slot:item="{item}">
+ <template #item="{item}">
<BlockCard :user-id="item" />
</template>
- <template v-slot:empty>
+ <template #empty>
{{ $t('settings.no_blocks') }}
</template>
</BlockList>
@@ -63,7 +63,7 @@
:query="queryUserIds"
:placeholder="$t('settings.search_user_to_mute')"
>
- <template v-slot="row">
+ <template #default="row">
<MuteCard
:user-id="row.item"
/>
@@ -74,7 +74,7 @@
:refresh="true"
:get-key="i => i"
>
- <template v-slot:header="{selected}">
+ <template #header="{selected}">
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
@@ -82,7 +82,7 @@
:click="() => muteUsers(selected)"
>
{{ $t('user_card.mute') }}
- <template v-slot:progress>
+ <template #progress>
{{ $t('user_card.mute_progress') }}
</template>
</ProgressButton>
@@ -92,16 +92,16 @@
:click="() => unmuteUsers(selected)"
>
{{ $t('user_card.unmute') }}
- <template v-slot:progress>
+ <template #progress>
{{ $t('user_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
</template>
- <template v-slot:item="{item}">
+ <template #item="{item}">
<MuteCard :user-id="item" />
</template>
- <template v-slot:empty>
+ <template #empty>
{{ $t('settings.no_mutes') }}
</template>
</MuteList>
@@ -114,7 +114,7 @@
:query="queryKnownDomains"
:placeholder="$t('settings.type_domains_to_mute')"
>
- <template v-slot="row">
+ <template #default="row">
<DomainMuteCard
:domain="row.item"
/>
@@ -125,7 +125,7 @@
:refresh="true"
:get-key="i => i"
>
- <template v-slot:header="{selected}">
+ <template #header="{selected}">
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
@@ -133,16 +133,16 @@
:click="() => unmuteDomains(selected)"
>
{{ $t('domain_mute_card.unmute') }}
- <template v-slot:progress>
+ <template #progress>
{{ $t('domain_mute_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
</template>
- <template v-slot:item="{item}">
+ <template #item="{item}">
<DomainMuteCard :domain="item" />
</template>
- <template v-slot:empty>
+ <template #empty>
{{ $t('settings.no_mutes') }}
</template>
</DomainMuteList>
diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js
index 8781bb91..b86faef0 100644
--- a/src/components/settings_modal/tabs/profile_tab.js
+++ b/src/components/settings_modal/tabs/profile_tab.js
@@ -64,17 +64,19 @@ const ProfileTab = {
emojiUserSuggestor () {
return suggestor({
emoji: [
- ...this.$store.state.instance.emoji,
+ ...this.$store.getters.standardEmojiList,
...this.$store.state.instance.customEmoji
],
store: this.$store
})
},
emojiSuggestor () {
- return suggestor({ emoji: [
- ...this.$store.state.instance.emoji,
- ...this.$store.state.instance.customEmoji
- ] })
+ return suggestor({
+ emoji: [
+ ...this.$store.getters.standardEmojiList,
+ ...this.$store.state.instance.customEmoji
+ ]
+ })
},
userSuggestor () {
return suggestor({ store: this.$store })
diff --git a/src/components/settings_modal/tabs/profile_tab.vue b/src/components/settings_modal/tabs/profile_tab.vue
index 4cd93772..642d54ca 100644
--- a/src/components/settings_modal/tabs/profile_tab.vue
+++ b/src/components/settings_modal/tabs/profile_tab.vue
@@ -117,8 +117,8 @@
<button
v-if="!isDefaultAvatar && pickAvatarBtnVisible"
:title="$t('settings.reset_avatar')"
- @click="resetAvatar"
class="button-unstyled reset-button"
+ @click="resetAvatar"
>
<FAIcon
icon="times"
diff --git a/src/components/settings_modal/tabs/security_tab/mfa.js b/src/components/settings_modal/tabs/security_tab/mfa.js
index abf37062..5337d150 100644
--- a/src/components/settings_modal/tabs/security_tab/mfa.js
+++ b/src/components/settings_modal/tabs/security_tab/mfa.js
@@ -32,8 +32,8 @@ const Mfa = {
components: {
'recovery-codes': RecoveryCodes,
'totp-item': TOTP,
- 'qrcode': VueQrcode,
- 'confirm': Confirm
+ qrcode: VueQrcode,
+ confirm: Confirm
},
computed: {
canSetupOTP () {
@@ -139,7 +139,7 @@ const Mfa = {
// fetch settings from server
async fetchSettings () {
- let result = await this.backendInteractor.settingsMFA()
+ const result = await this.backendInteractor.settingsMFA()
if (result.error) return
this.settings = result.settings
this.settings.available = true
diff --git a/src/components/settings_modal/tabs/security_tab/mfa_totp.js b/src/components/settings_modal/tabs/security_tab/mfa_totp.js
index 8408d8e9..b0adb530 100644
--- a/src/components/settings_modal/tabs/security_tab/mfa_totp.js
+++ b/src/components/settings_modal/tabs/security_tab/mfa_totp.js
@@ -10,7 +10,7 @@ export default {
inProgress: false // progress peform request to disable otp method
}),
components: {
- 'confirm': Confirm
+ confirm: Confirm
},
computed: {
isActivated () {
diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.js b/src/components/settings_modal/tabs/security_tab/security_tab.js
index fc732936..d253bc79 100644
--- a/src/components/settings_modal/tabs/security_tab/security_tab.js
+++ b/src/components/settings_modal/tabs/security_tab/security_tab.js
@@ -13,7 +13,7 @@ const SecurityTab = {
deletingAccount: false,
deleteAccountConfirmPasswordInput: '',
deleteAccountError: false,
- changePasswordInputs: [ '', '', '' ],
+ changePasswordInputs: ['', '', ''],
changedPassword: false,
changePasswordError: false,
moveAccountTarget: '',
diff --git a/src/components/settings_modal/tabs/theme_tab/preview.vue b/src/components/settings_modal/tabs/theme_tab/preview.vue
index f266b603..ba6bd529 100644
--- a/src/components/settings_modal/tabs/theme_tab/preview.vue
+++ b/src/components/settings_modal/tabs/theme_tab/preview.vue
@@ -29,7 +29,10 @@
{{ $t('settings.style.preview.content') }}
</h4>
- <i18n-t scope="global" keypath="settings.style.preview.text">
+ <i18n-t
+ scope="global"
+ keypath="settings.style.preview.text"
+ >
<code style="font-family: var(--postCodeFont)">
{{ $t('settings.style.preview.mono') }}
</code>
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 7e1da7ab..282cb384 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
@@ -95,11 +95,11 @@ export default {
...Object.keys(SLOT_INHERITANCE)
.map(key => [key, ''])
- .reduce((acc, [key, val]) => ({ ...acc, [ key + 'ColorLocal' ]: val }), {}),
+ .reduce((acc, [key, val]) => ({ ...acc, [key + 'ColorLocal']: val }), {}),
...Object.keys(OPACITIES)
.map(key => [key, ''])
- .reduce((acc, [key, val]) => ({ ...acc, [ key + 'OpacityLocal' ]: val }), {}),
+ .reduce((acc, [key, val]) => ({ ...acc, [key + 'OpacityLocal']: val }), {}),
shadowSelected: undefined,
shadowsLocal: {},
@@ -212,12 +212,12 @@ export default {
currentColors () {
return Object.keys(SLOT_INHERITANCE)
.map(key => [key, this[key + 'ColorLocal']])
- .reduce((acc, [key, val]) => ({ ...acc, [ key ]: val }), {})
+ .reduce((acc, [key, val]) => ({ ...acc, [key]: val }), {})
},
currentOpacity () {
return Object.keys(OPACITIES)
.map(key => [key, this[key + 'OpacityLocal']])
- .reduce((acc, [key, val]) => ({ ...acc, [ key ]: val }), {})
+ .reduce((acc, [key, val]) => ({ ...acc, [key]: val }), {})
},
currentRadii () {
return {
diff --git a/src/components/shadow_control/shadow_control.js b/src/components/shadow_control/shadow_control.js
index 386a5756..a1d1012b 100644
--- a/src/components/shadow_control/shadow_control.js
+++ b/src/components/shadow_control/shadow_control.js
@@ -112,9 +112,11 @@ export default {
return hex2rgb(this.selected.color)
},
style () {
- return this.ready ? {
- boxShadow: getCssShadow(this.fallback)
- } : {}
+ return this.ready
+ ? {
+ boxShadow: getCssShadow(this.fallback)
+ }
+ : {}
}
}
}
diff --git a/src/components/shadow_control/shadow_control.vue b/src/components/shadow_control/shadow_control.vue
index f2fc7b99..669cac71 100644
--- a/src/components/shadow_control/shadow_control.vue
+++ b/src/components/shadow_control/shadow_control.vue
@@ -215,7 +215,7 @@
</div>
</template>
-<script src="./shadow_control.js" ></script>
+<script src="./shadow_control.js"></script>
<style lang="scss">
@import '../../_variables.scss';
diff --git a/src/components/shout_panel/shout_panel.js b/src/components/shout_panel/shout_panel.js
index a6168971..fb0c5aa2 100644
--- a/src/components/shout_panel/shout_panel.js
+++ b/src/components/shout_panel/shout_panel.js
@@ -11,7 +11,7 @@ library.add(
)
const shoutPanel = {
- props: [ 'floating' ],
+ props: ['floating'],
data () {
return {
currentMessage: '',
diff --git a/src/components/shout_panel/shout_panel.vue b/src/components/shout_panel/shout_panel.vue
index 1eca88a7..688c2d61 100644
--- a/src/components/shout_panel/shout_panel.vue
+++ b/src/components/shout_panel/shout_panel.vue
@@ -80,7 +80,7 @@
.floating-shout {
position: fixed;
bottom: 0.5em;
- z-index: 1000;
+ z-index: var(--ZI_popovers);
max-width: 25em;
&.-left {
diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js
index bad1806b..bb22446b 100644
--- a/src/components/side_drawer/side_drawer.js
+++ b/src/components/side_drawer/side_drawer.js
@@ -2,6 +2,7 @@ import { mapState, mapGetters } from 'vuex'
import UserCard from '../user_card/user_card.vue'
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
import GestureService from '../../services/gesture_service/gesture_service'
+import { USERNAME_ROUTES } from 'src/components/navigation/navigation.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faSignInAlt,
@@ -14,7 +15,9 @@ import {
faSearch,
faTachometerAlt,
faCog,
- faInfoCircle
+ faInfoCircle,
+ faCompass,
+ faList
} from '@fortawesome/free-solid-svg-icons'
library.add(
@@ -28,11 +31,13 @@ library.add(
faSearch,
faTachometerAlt,
faCog,
- faInfoCircle
+ faInfoCircle,
+ faCompass,
+ faList
)
const SideDrawer = {
- props: [ 'logout' ],
+ props: ['logout'],
data: () => ({
closed: true,
closeGesture: undefined
@@ -78,10 +83,16 @@ 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
diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue
index dd88de7d..cbeafdd2 100644
--- a/src/components/side_drawer/side_drawer.vue
+++ b/src/components/side_drawer/side_drawer.vue
@@ -47,7 +47,7 @@
v-if="currentUser || !privateMode"
@click="toggleDrawer"
>
- <router-link :to="{ name: timelinesRoute }">
+ <router-link :to="timelinesRoute">
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
@@ -56,6 +56,18 @@
</router-link>
</li>
<li
+ v-if="currentUser"
+ @click="toggleDrawer"
+ >
+ <router-link :to="{ name: 'lists' }">
+ <FAIcon
+ fixed-width
+ class="fa-scale-110 fa-old-padding"
+ icon="list"
+ /> {{ $t("nav.lists") }}
+ </router-link>
+ </li>
+ <li
v-if="currentUser && pleromaChatMessagesAvailable"
@click="toggleDrawer"
>
@@ -183,6 +195,18 @@
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"
+ >
<button
class="button-unstyled -link -fullwidth"
@click="doLogout"
@@ -204,14 +228,14 @@
</div>
</template>
-<script src="./side_drawer.js" ></script>
+<script src="./side_drawer.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.side-drawer-container {
position: fixed;
- z-index: 1000;
+ z-index: var(--ZI_navbar);
top: 0;
left: 0;
width: 100%;
diff --git a/src/components/staff_panel/staff_panel.js b/src/components/staff_panel/staff_panel.js
index b9561bf1..46a92ac7 100644
--- a/src/components/staff_panel/staff_panel.js
+++ b/src/components/staff_panel/staff_panel.js
@@ -13,16 +13,16 @@ const StaffPanel = {
},
computed: {
groupedStaffAccounts () {
- const staffAccounts = map(this.staffAccounts, this.findUser).filter(_ => _)
+ const staffAccounts = map(this.staffAccounts, this.findUserByName).filter(_ => _)
const groupedStaffAccounts = groupBy(staffAccounts, 'role')
return [
- { role: 'admin', users: groupedStaffAccounts['admin'] },
- { role: 'moderator', users: groupedStaffAccounts['moderator'] }
+ { role: 'admin', users: groupedStaffAccounts.admin },
+ { role: 'moderator', users: groupedStaffAccounts.moderator }
].filter(group => group.users)
},
...mapGetters([
- 'findUser'
+ 'findUserByName'
]),
...mapState({
staffAccounts: state => state.instance.staffAccounts
diff --git a/src/components/staff_panel/staff_panel.vue b/src/components/staff_panel/staff_panel.vue
index c52ade42..6b9e61f2 100644
--- a/src/components/staff_panel/staff_panel.vue
+++ b/src/components/staff_panel/staff_panel.vue
@@ -24,7 +24,7 @@
</div>
</template>
-<script src="./staff_panel.js" ></script>
+<script src="./staff_panel.js"></script>
<style lang="scss">
diff --git a/src/components/status/status.js b/src/components/status/status.js
index a925f30b..9a9bca7a 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -4,15 +4,16 @@ import ReactButton from '../react_button/react_button.vue'
import RetweetButton from '../retweet_button/retweet_button.vue'
import ExtraButtons from '../extra_buttons/extra_buttons.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue'
-import UserCard from '../user_card/user_card.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import AvatarList from '../avatar_list/avatar_list.vue'
import Timeago from '../timeago/timeago.vue'
import StatusContent from '../status_content/status_content.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import StatusPopover from '../status_popover/status_popover.vue'
+import UserPopover from '../user_popover/user_popover.vue'
import UserListPopover from '../user_list_popover/user_list_popover.vue'
import EmojiReactions from '../emoji_reactions/emoji_reactions.vue'
+import UserLink from '../user_link/user_link.vue'
import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import MentionLink from 'src/components/mention_link/mention_link.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@@ -105,7 +106,6 @@ const Status = {
RetweetButton,
ExtraButtons,
PostStatusForm,
- UserCard,
UserAvatar,
AvatarList,
Timeago,
@@ -115,7 +115,9 @@ const Status = {
StatusContent,
RichContent,
MentionLink,
- MentionsLine
+ MentionsLine,
+ UserPopover,
+ UserLink
},
props: [
'statusoid',
@@ -361,6 +363,7 @@ const Status = {
return uniqBy(combinedUsers, 'id')
},
tags () {
+ // eslint-disable-next-line no-prototype-builtins
return this.status.tags.filter(tagObj => tagObj.hasOwnProperty('name')).map(tagObj => tagObj.name).join(' ')
},
hidePostStats () {
@@ -392,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: {
@@ -448,7 +457,7 @@ const Status = {
scrollIfHighlighted (highlightId) {
const id = highlightId
if (this.status.id === id) {
- let rect = this.$el.getBoundingClientRect()
+ const rect = this.$el.getBoundingClientRect()
if (rect.top < 100) {
// Post is above screen, match its top to screen top
window.scrollBy(0, rect.top - 100)
@@ -463,7 +472,7 @@ const Status = {
}
},
watch: {
- 'highlight': function (id) {
+ highlight: function (id) {
this.scrollIfHighlighted(id)
},
'status.repeat_num': function (num) {
@@ -478,7 +487,7 @@ const Status = {
this.$store.dispatch('fetchFavs', this.status.id)
}
},
- 'isSuspendable': function (val) {
+ isSuspendable: function (val) {
this.suspendable = val
}
}
diff --git a/src/components/status/status.scss b/src/components/status/status.scss
index b3ad3818..ada9841e 100644
--- a/src/components/status/status.scss
+++ b/src/components/status/status.scss
@@ -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;
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 67ce999a..82eb7ac6 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"
@@ -124,25 +125,23 @@
>
<a
:href="$router.resolve(userProfileLink).href"
- @click.stop.prevent.capture="toggleUserExpanded"
+ @click.prevent
>
- <UserAvatar
- class="post-avatar"
- :bot="botIndicator"
- :compact="compact"
- :better-shadow="betterShadow"
- :user="status.user"
- />
+ <UserPopover
+ :user-id="status.user.id"
+ :overlay-centers="true"
+ >
+ <UserAvatar
+ class="post-avatar"
+ :bot="botIndicator"
+ :compact="compact"
+ :better-shadow="betterShadow"
+ :user="status.user"
+ />
+ </UserPopover>
</a>
</div>
<div class="right-side">
- <UserCard
- v-if="userExpanded"
- :user-id="status.user.id"
- :rounded="true"
- :bordered="true"
- class="usercard"
- />
<div
v-if="!noHeading"
class="status-heading"
@@ -166,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"
@@ -322,12 +320,31 @@
class="mentions-line-first"
/>
</span>
+ {{ ' ' }}
<MentionsLine
v-if="hasMentionsLine"
:mentions="mentionsLine.slice(1)"
class="mentions-line"
/>
</div>
+ <div
+ v-if="isEdited && editingAvailable && !isPreview"
+ class="heading-edited-row"
+ >
+ <i18n-t
+ keypath="status.edited_at"
+ tag="span"
+ >
+ <template #time>
+ <Timeago
+ template-key="time.in_past"
+ :time="status.edited_at"
+ :auto-update="60"
+ :long-format="true"
+ />
+ </template>
+ </i18n-t>
+ </div>
</div>
<StatusContent
@@ -492,6 +509,6 @@
</div>
</template>
-<script src="./status.js" ></script>
+<script src="./status.js"></script>
<style src="./status.scss" lang="scss"></style>
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
index 976fe98c..fb356360 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -96,5 +96,5 @@
<slot v-if="!hideSubjectStatus" />
</div>
</template>
-<script src="./status_body.js" ></script>
+<script src="./status_body.js"></script>
<style lang="scss" src="./status_body.scss" />
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index 9e7d7956..e2120f7a 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -56,7 +56,7 @@
</div>
</template>
-<script src="./status_content.js" ></script>
+<script src="./status_content.js"></script>
<style lang="scss">
.StatusContent {
flex: 1;
diff --git a/src/components/status_history_modal/status_history_modal.js b/src/components/status_history_modal/status_history_modal.js
new file mode 100644
index 00000000..3941a56f
--- /dev/null
+++ b/src/components/status_history_modal/status_history_modal.js
@@ -0,0 +1,60 @@
+import { get } from 'lodash'
+import Modal from '../modal/modal.vue'
+import Status from '../status/status.vue'
+
+const StatusHistoryModal = {
+ components: {
+ Modal,
+ Status
+ },
+ data () {
+ return {
+ statuses: []
+ }
+ },
+ computed: {
+ modalActivated () {
+ return this.$store.state.statusHistory.modalActivated
+ },
+ params () {
+ return this.$store.state.statusHistory.params
+ },
+ statusId () {
+ return this.params.id
+ },
+ historyCount () {
+ return this.statuses.length
+ },
+ history () {
+ return this.statuses
+ }
+ },
+ watch: {
+ params (newVal, oldVal) {
+ const newStatusId = get(newVal, 'id') !== get(oldVal, 'id')
+ if (newStatusId) {
+ this.resetHistory()
+ }
+
+ if (newStatusId || get(newVal, 'edited_at') !== get(oldVal, 'edited_at')) {
+ this.fetchStatusHistory()
+ }
+ }
+ },
+ methods: {
+ resetHistory () {
+ this.statuses = []
+ },
+ fetchStatusHistory () {
+ this.$store.dispatch('fetchStatusHistory', this.params)
+ .then(data => {
+ this.statuses = data
+ })
+ },
+ closeModal () {
+ this.$store.dispatch('closeStatusHistoryModal')
+ }
+ }
+}
+
+export default StatusHistoryModal
diff --git a/src/components/status_history_modal/status_history_modal.vue b/src/components/status_history_modal/status_history_modal.vue
new file mode 100644
index 00000000..990be35b
--- /dev/null
+++ b/src/components/status_history_modal/status_history_modal.vue
@@ -0,0 +1,46 @@
+<template>
+ <Modal
+ v-if="modalActivated"
+ class="status-history-modal-view"
+ @backdropClicked="closeModal"
+ >
+ <div class="status-history-modal-panel panel">
+ <div class="panel-heading">
+ {{ $t('status.status_history') }} ({{ historyCount }})
+ </div>
+ <div class="panel-body">
+ <div
+ v-if="historyCount > 0"
+ class="history-body"
+ >
+ <status
+ v-for="status in history"
+ :key="status.id"
+ :statusoid="status"
+ :is-preview="true"
+ class="conversation-status status-fadein panel-body"
+ />
+ </div>
+ </div>
+ </div>
+ </Modal>
+</template>
+
+<script src="./status_history_modal.js"></script>
+
+<style lang="scss">
+.modal-view.status-history-modal-view {
+ align-items: flex-start;
+}
+.status-history-modal-panel {
+ flex-shrink: 0;
+ margin-top: 25%;
+ margin-bottom: 2em;
+ width: 100%;
+ max-width: 700px;
+
+ @media (orientation: landscape) {
+ margin-top: 8%;
+ }
+}
+</style>
diff --git a/src/components/status_popover/status_popover.js b/src/components/status_popover/status_popover.js
index e0962ccd..c55bd85b 100644
--- a/src/components/status_popover/status_popover.js
+++ b/src/components/status_popover/status_popover.js
@@ -38,6 +38,13 @@ const StatusPopover = {
.catch(e => (this.error = true))
}
}
+ },
+ watch: {
+ status (newStatus, oldStatus) {
+ if (newStatus !== oldStatus) {
+ this.$nextTick(() => this.$refs.popover.updateStyles())
+ }
+ }
}
}
diff --git a/src/components/status_popover/status_popover.vue b/src/components/status_popover/status_popover.vue
index fdca8c9c..f4ab357b 100644
--- a/src/components/status_popover/status_popover.vue
+++ b/src/components/status_popover/status_popover.vue
@@ -1,14 +1,16 @@
<template>
<Popover
+ ref="popover"
trigger="hover"
+ :stay-on-click="true"
popover-class="popover-default status-popover"
:bound-to="{ x: 'container' }"
@show="enter"
>
- <template v-slot:trigger>
+ <template #trigger>
<slot />
</template>
- <template v-slot:content>
+ <template #content>
<Status
v-if="status"
:is-preview="true"
@@ -35,7 +37,7 @@
</Popover>
</template>
-<script src="./status_popover.js" ></script>
+<script src="./status_popover.js"></script>
<style lang="scss">
@import '../../_variables.scss';
@@ -52,8 +54,6 @@
border-width: 1px;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
- box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5);
- box-shadow: var(--popupShadow);
/* TODO cleanup this */
.Status.Status {
diff --git a/src/components/sticker_picker/sticker_picker.js b/src/components/sticker_picker/sticker_picker.js
index 3a2d3914..b06384e5 100644
--- a/src/components/sticker_picker/sticker_picker.js
+++ b/src/components/sticker_picker/sticker_picker.js
@@ -31,8 +31,8 @@ const StickerPicker = {
fetch(sticker)
.then((res) => {
res.blob().then((blob) => {
- var file = new File([blob], name, { mimetype: 'image/png' })
- var formData = new FormData()
+ const file = new File([blob], name, { mimetype: 'image/png' })
+ const formData = new FormData()
formData.append('file', file)
statusPosterService.uploadMedia({ store, formData })
.then((fileData) => {
diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js
index d7abbcb5..200ef147 100644
--- a/src/components/still-image/still-image.js
+++ b/src/components/still-image/still-image.js
@@ -7,16 +7,23 @@ const StillImage = {
'imageLoadHandler',
'alt',
'height',
- 'width'
+ 'width',
+ 'dataSrc'
],
data () {
return {
+ // for lazy loading, see loadLazy()
+ realSrc: this.src,
stopGifs: this.$store.getters.mergedConfig.stopGifs
}
},
computed: {
animated () {
- return this.stopGifs && (this.mimetype === 'image/gif' || this.src.endsWith('.gif'))
+ if (!this.realSrc) {
+ return false
+ }
+
+ return this.stopGifs && (this.mimetype === 'image/gif' || this.realSrc.endsWith('.gif'))
},
style () {
const appendPx = (str) => /\d$/.test(str) ? str + 'px' : str
@@ -27,7 +34,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 +57,14 @@ const StillImage = {
onError () {
this.imageLoadError && this.imageLoadError()
}
+ },
+ watch: {
+ src () {
+ this.realSrc = this.src
+ },
+ dataSrc () {
+ this.$el.removeAttribute('data-loaded')
+ }
}
}
diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue
index ab3080c8..633fb229 100644
--- a/src/components/still-image/still-image.vue
+++ b/src/components/still-image/still-image.vue
@@ -11,10 +11,11 @@
<!-- NOTE: key is required to force to re-render img tag when src is changed -->
<img
ref="src"
- :key="src"
+ :key="realSrc"
:alt="alt"
:title="alt"
- :src="src"
+ :data-src="dataSrc"
+ :src="realSrc"
:referrerpolicy="referrerpolicy"
@load="onLoad"
@error="onError"
diff --git a/src/components/tab_switcher/tab_switcher.scss b/src/components/tab_switcher/tab_switcher.scss
index 7a086b26..d930368c 100644
--- a/src/components/tab_switcher/tab_switcher.scss
+++ b/src/components/tab_switcher/tab_switcher.scss
@@ -17,6 +17,7 @@
overflow-x: auto;
padding-top: 5px;
flex-direction: row;
+ flex: 0 0 auto;
&::after, &::before {
content: '';
diff --git a/src/components/terms_of_service_panel/terms_of_service_panel.vue b/src/components/terms_of_service_panel/terms_of_service_panel.vue
index 63dc58b8..1df41d70 100644
--- a/src/components/terms_of_service_panel/terms_of_service_panel.vue
+++ b/src/components/terms_of_service_panel/terms_of_service_panel.vue
@@ -13,7 +13,7 @@
</div>
</template>
-<script src="./terms_of_service_panel.js" ></script>
+<script src="./terms_of_service_panel.js"></script>
<style lang="scss">
.tos-content {
diff --git a/src/components/thread_tree/thread_tree.vue b/src/components/thread_tree/thread_tree.vue
index 4eaf597d..c6fffc71 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,7 +113,7 @@
</template>
</i18n-t>
</div>
- </div>
+ </article>
</template>
<script src="./thread_tree.js"></script>
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..c6fb1ca7 100644
--- a/src/components/timeline/timeline.scss
+++ b/src/components/timeline/timeline.scss
@@ -1,8 +1,35 @@
@import '../../_variables.scss';
.Timeline {
- .loadmore-text {
- opacity: 1;
+ .alert-dot {
+ border-radius: 100%;
+ height: 8px;
+ width: 8px;
+ position: absolute;
+ left: calc(50% - 4px);
+ top: calc(50% - 4px);
+ margin-left: 6px;
+ margin-top: -6px;
+ background-color: var(--badgeNeutral);
+ }
+
+ .alert-badge {
+ font-size: 0.75em;
+ line-height: 1;
+ text-align: right;
+ border-radius: var(--tooltipRadius);
+ position: absolute;
+ left: calc(50% - 0.5em);
+ top: calc(50% - 0.4em);
+ padding: 0.2em;
+ margin-left: 0.7em;
+ margin-top: -1em;
+ background-color: var(--badgeNeutral);
+ color: var(--badgeNeutralText);
+ }
+
+ .loadmore-button {
+ position: relative;
}
&.-blocked {
diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue
index f65881b6..877a0cc0 100644
--- a/src/components/timeline/timeline.vue
+++ b/src/components/timeline/timeline.vue
@@ -1,31 +1,90 @@
<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
+ class="rightside-button"
+ v-if="showScrollTop && !embedded"
>
- {{ $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
+ class="rightside-button"
+ v-if="showLoadButton"
+ >
+ <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 +95,7 @@
<conversation
v-for="status in filteredVisibleStatuses"
:key="status.id"
+ role="listitem"
class="status-fadein"
:status-id="status.id"
:collapsable="true"
@@ -46,7 +106,10 @@
</div>
</div>
<div :class="classes.footer">
- <teleport :to="footerSlipgate" :disabled="!embedded || !footerSlipgate">
+ <teleport
+ :to="footerSlipgate"
+ :disabled="!embedded || !footerSlipgate"
+ >
<div
v-if="count===0"
class="new-status-notification text-center faint"
diff --git a/src/components/timeline_menu/timeline_menu.js b/src/components/timeline_menu/timeline_menu.js
index bab51e75..d74fbf4e 100644
--- a/src/components/timeline_menu/timeline_menu.js
+++ b/src/components/timeline_menu/timeline_menu.js
@@ -1,6 +1,8 @@
import Popover from '../popover/popover.vue'
-import TimelineMenuContent from './timeline_menu_content.vue'
+import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
+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 {
faChevronDown
} from '@fortawesome/free-solid-svg-icons'
@@ -11,9 +13,9 @@ library.add(faChevronDown)
// because nav panel benefits from the same information.
export const timelineNames = () => {
return {
- 'friends': 'nav.home_timeline',
- 'bookmarks': 'nav.bookmarks',
- 'dms': 'nav.dms',
+ friends: 'nav.home_timeline',
+ bookmarks: 'nav.bookmarks',
+ dms: 'nav.dms',
'public-timeline': 'nav.public_tl',
'public-external-timeline': 'nav.twkn'
}
@@ -22,11 +24,13 @@ export const timelineNames = () => {
const TimelineMenu = {
components: {
Popover,
- TimelineMenuContent
+ NavigationEntry,
+ ListsMenuContent
},
data () {
return {
- isOpen: false
+ isOpen: false,
+ timelinesList: Object.entries(TIMELINES).map(([k, v]) => ({ ...v, name: k }))
}
},
created () {
@@ -34,6 +38,12 @@ const TimelineMenu = {
this.$store.dispatch('setLastTimeline', this.$route.name)
}
},
+ computed: {
+ useListsMenu () {
+ const route = this.$route.name
+ return route === 'lists-timeline'
+ }
+ },
methods: {
openMenu () {
// $nextTick is too fast, animation won't play back but
@@ -58,6 +68,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 61119482..e7250282 100644
--- a/src/components/timeline_menu/timeline_menu.vue
+++ b/src/components/timeline_menu/timeline_menu.vue
@@ -3,19 +3,29 @@
trigger="click"
class="TimelineMenu"
:class="{ 'open': isOpen }"
- :margin="{ left: -15, right: -200 }"
:bound-to="{ x: 'container' }"
- popover-class="timeline-menu-popover-wrap"
+ bound-to-selector=".Timeline"
+ popover-class="timeline-menu-popover popover-default"
@show="openMenu"
@close="() => isOpen = false"
>
- <template v-slot:content>
- <div class="timeline-menu-popover popover-default">
- <TimelineMenuContent />
- </div>
+ <template #content>
+ <ListsMenuContent
+ v-if="useListsMenu"
+ :show-pin="false"
+ class="timelines"
+ />
+ <ul v-else>
+ <NavigationEntry
+ v-for="item in timelinesList"
+ :key="item.name"
+ :show-pin="false"
+ :item="item"
+ />
+ </ul>
</template>
- <template v-slot:trigger>
- <button class="button-unstyled title timeline-menu-title">
+ <template #trigger>
+ <span class="button-unstyled title timeline-menu-title">
<span class="timeline-title">{{ timelineName() }}</span>
<span>
<FAIcon
@@ -27,53 +37,29 @@
class="click-blocker"
@click="blockOpen"
/>
- </button>
+ </span>
</template>
</Popover>
</template>
-<script src="./timeline_menu.js" ></script>
+<script src="./timeline_menu.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.TimelineMenu {
- flex-shrink: 1;
margin-right: auto;
min-width: 0;
- width: 24rem;
.popover-trigger-button {
vertical-align: bottom;
}
- .timeline-menu-popover-wrap {
- overflow: hidden;
- // Match panel heading padding to line up menu with bottom of heading
- margin-top: 0.6rem;
- padding: 0 15px 15px 15px;
- }
-
- .timeline-menu-popover {
- width: 24rem;
- max-width: 100vw;
- margin: 0;
- font-size: 1rem;
- border-top-right-radius: 0;
- border-top-left-radius: 0;
- transform: translateY(-100%);
- transition: transform 100ms;
- }
-
.panel::after {
border-top-right-radius: 0;
border-top-left-radius: 0;
}
- &.open .timeline-menu-popover {
- transform: translateY(0);
- }
-
.timeline-menu-title {
margin: 0;
cursor: pointer;
@@ -108,6 +94,16 @@
box-shadow: var(--popoverShadow);
}
+}
+
+.timeline-menu-popover {
+ min-width: 24rem;
+ max-width: 100vw;
+ margin-top: 0.6rem;
+ font-size: 1rem;
+ border-top-right-radius: 0;
+ border-top-left-radius: 0;
+
ul {
list-style: none;
margin: 0;
@@ -134,7 +130,9 @@
a {
display: block;
- padding: 0.6em 0.65em;
+ padding: 0 0.65em;
+ height: 3.5em;
+ line-height: 3.5em;
&:hover {
background-color: $fallback--lightBg;
@@ -152,8 +150,7 @@
background-color: $fallback--lightBg;
background-color: var(--selectedMenu, $fallback--lightBg);
color: $fallback--text;
- color: var(--selectedMenuText, $fallback--text);
- --faint: var(--selectedMenuFaintText, $fallback--faint);
+ color: var(--selectedMenuText, $fallback--text); --faint: var(--selectedMenuFaintText, $fallback--faint);
--faintLink: var(--selectedMenuFaintLink, $fallback--faint);
--lightText: var(--selectedMenuLightText, $fallback--lightText);
--icon: var(--selectedMenuIcon, $fallback--icon);
diff --git a/src/components/timeline_menu/timeline_menu_content.js b/src/components/timeline_menu/timeline_menu_content.js
deleted file mode 100644
index 671570dd..00000000
--- a/src/components/timeline_menu/timeline_menu_content.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import { mapState } from 'vuex'
-import { library } from '@fortawesome/fontawesome-svg-core'
-import {
- faUsers,
- faGlobe,
- faBookmark,
- faEnvelope,
- faHome
-} from '@fortawesome/free-solid-svg-icons'
-
-library.add(
- faUsers,
- faGlobe,
- faBookmark,
- faEnvelope,
- faHome
-)
-
-const TimelineMenuContent = {
- computed: {
- ...mapState({
- currentUser: state => state.users.currentUser,
- privateMode: state => state.instance.private,
- federating: state => state.instance.federating
- })
- }
-}
-
-export default TimelineMenuContent
diff --git a/src/components/timeline_menu/timeline_menu_content.vue b/src/components/timeline_menu/timeline_menu_content.vue
deleted file mode 100644
index bed1b679..00000000
--- a/src/components/timeline_menu/timeline_menu_content.vue
+++ /dev/null
@@ -1,66 +0,0 @@
-<template>
- <ul>
- <li v-if="currentUser">
- <router-link
- class="menu-item"
- :to="{ name: 'friends' }"
- >
- <FAIcon
- fixed-width
- class="fa-scale-110 fa-old-padding "
- icon="home"
- />{{ $t("nav.home_timeline") }}
- </router-link>
- </li>
- <li v-if="currentUser || !privateMode">
- <router-link
- class="menu-item"
- :to="{ name: 'public-timeline' }"
- >
- <FAIcon
- fixed-width
- class="fa-scale-110 fa-old-padding "
- icon="users"
- />{{ $t("nav.public_tl") }}
- </router-link>
- </li>
- <li v-if="federating && (currentUser || !privateMode)">
- <router-link
- class="menu-item"
- :to="{ name: 'public-external-timeline' }"
- >
- <FAIcon
- fixed-width
- class="fa-scale-110 fa-old-padding "
- icon="globe"
- />{{ $t("nav.twkn") }}
- </router-link>
- </li>
- <li v-if="currentUser">
- <router-link
- class="menu-item"
- :to="{ name: 'bookmarks'}"
- >
- <FAIcon
- fixed-width
- class="fa-scale-110 fa-old-padding "
- icon="bookmark"
- />{{ $t("nav.bookmarks") }}
- </router-link>
- </li>
- <li v-if="currentUser">
- <router-link
- class="menu-item"
- :to="{ name: 'dms', params: { username: currentUser.screen_name } }"
- >
- <FAIcon
- fixed-width
- class="fa-scale-110 fa-old-padding "
- icon="envelope"
- />{{ $t("nav.dms") }}
- </router-link>
- </li>
- </ul>
-</template>
-
-<script src="./timeline_menu_content.js" ></script>
diff --git a/src/components/unicode_domain_indicator/unicode_domain_indicator.vue b/src/components/unicode_domain_indicator/unicode_domain_indicator.vue
new file mode 100644
index 00000000..8f35245f
--- /dev/null
+++ b/src/components/unicode_domain_indicator/unicode_domain_indicator.vue
@@ -0,0 +1,26 @@
+<template>
+ <FAIcon
+ v-if="user && user.screen_name_ui_contains_non_ascii"
+ icon="code"
+ :title="$t('unicode_domain_indicator.tooltip')"
+ />
+</template>
+
+<script>
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faCode
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faCode
+)
+
+const UnicodeDomainIndicator = {
+ props: {
+ user: Object
+ }
+}
+
+export default UnicodeDomainIndicator
+</script>
diff --git a/src/components/update_notification/update_notification.js b/src/components/update_notification/update_notification.js
new file mode 100644
index 00000000..ddf379f5
--- /dev/null
+++ b/src/components/update_notification/update_notification.js
@@ -0,0 +1,69 @@
+import Modal from 'src/components/modal/modal.vue'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import pleromaTan from 'src/assets/pleromatan_apology.png'
+import pleromaTanFox from 'src/assets/pleromatan_apology_fox.png'
+import pleromaTanMask from 'src/assets/pleromatan_apology_mask.png'
+import pleromaTanFoxMask from 'src/assets/pleromatan_apology_fox_mask.png'
+
+import {
+ faTimes
+} from '@fortawesome/free-solid-svg-icons'
+library.add(
+ faTimes
+)
+
+export const CURRENT_UPDATE_COUNTER = 1
+
+const UpdateNotification = {
+ data () {
+ return {
+ showingImage: false,
+ pleromaTanVariant: Math.random() > 0.5 ? pleromaTan : pleromaTanFox,
+ showingMore: false
+ }
+ },
+ components: {
+ Modal
+ },
+ computed: {
+ pleromaTanStyles () {
+ const mask = this.pleromaTanVariant === pleromaTan ? pleromaTanMask : pleromaTanFoxMask
+ return {
+ 'shape-outside': 'url(' + mask + ')'
+ }
+ },
+ shouldShow () {
+ return !this.$store.state.instance.disableUpdateNotification &&
+ this.$store.state.users.currentUser &&
+ this.$store.state.serverSideStorage.flagStorage.updateCounter < CURRENT_UPDATE_COUNTER &&
+ !this.$store.state.serverSideStorage.prefsStorage.simple.dontShowUpdateNotifs
+ }
+ },
+ methods: {
+ toggleShow () {
+ this.showingMore = !this.showingMore
+ },
+ neverShowAgain () {
+ this.toggleShow()
+ this.$store.commit('setFlag', { flag: 'updateCounter', value: CURRENT_UPDATE_COUNTER })
+ this.$store.commit('setPreference', { path: 'simple.dontShowUpdateNotifs', value: true })
+ this.$store.dispatch('pushServerSideStorage')
+ },
+ dismiss () {
+ this.$store.commit('setFlag', { flag: 'updateCounter', value: CURRENT_UPDATE_COUNTER })
+ this.$store.dispatch('pushServerSideStorage')
+ }
+ },
+ mounted () {
+ this.contentHeightNoImage = this.$refs.animatedText.scrollHeight
+
+ // Workaround to get the text height only after mask loaded. A bit hacky.
+ const newImg = new Image()
+ newImg.onload = () => {
+ setTimeout(() => { this.showingImage = true }, 100)
+ }
+ newImg.src = this.pleromaTanVariant === pleromaTan ? pleromaTanMask : pleromaTanFoxMask
+ }
+}
+
+export default UpdateNotification
diff --git a/src/components/update_notification/update_notification.scss b/src/components/update_notification/update_notification.scss
new file mode 100644
index 00000000..ce8129d0
--- /dev/null
+++ b/src/components/update_notification/update_notification.scss
@@ -0,0 +1,113 @@
+@import 'src/_variables.scss';
+.UpdateNotification {
+ overflow: hidden;
+}
+
+.UpdateNotificationModal {
+ --__top-fringe: 15em; // how much pleroma-tan should stick her head above
+ --__bottom-fringe: 80em; // just reserving as much as we can, number is mostly irrelevant
+ --__right-fringe: 8em;
+
+ font-size: 15px;
+ position: relative;
+ transition: transform;
+ transition-timing-function: ease-in-out;
+ transition-duration: 500ms;
+
+ .text {
+ max-width: 40em;
+ padding-left: 1em;
+ }
+
+ @media all and (max-width: 800px) {
+ /* For mobile, the modal takes 100% of the available screen.
+ This ensures the minimized modal is always 50px above the browser bottom bar regardless of whether or not it is visible.
+ */
+ width: 100vw;
+ }
+
+ @media all and (max-height: 600px) {
+ display: none;
+ }
+
+ .content {
+ overflow: hidden;
+ margin-top: calc(-1 * var(--__top-fringe));
+ margin-bottom: calc(-1 * var(--__bottom-fringe));
+ margin-right: calc(-1 * var(--__right-fringe));
+
+ &.-noImage {
+ .text {
+ padding-right: var(--__right-fringe);
+ }
+ }
+ }
+
+ .panel-body {
+ border-width: 0 0 1px 0;
+ border-style: solid;
+ border-color: var(--border, $fallback--border);
+ }
+
+ .panel-footer {
+ z-index: 22;
+ position: relative;
+ border-width: 0;
+ grid-template-columns: auto;
+ }
+
+ .pleroma-tan {
+ object-fit: cover;
+ object-position: top;
+ transition: position, left, right, top, bottom, max-width, max-height;
+ transition-timing-function: ease-in-out;
+ transition-duration: 500ms;
+ width: 25em;
+ float: right;
+ z-index: 20;
+ position: relative;
+ shape-margin: 0.5em;
+ filter: drop-shadow(5px 5px 10px rgba(0,0,0,0.5));
+ pointer-events: none;
+ }
+
+ .spacer-top {
+ min-height: var(--__top-fringe);
+ }
+
+ .spacer-bottom {
+ min-height: var(--__bottom-fringe);
+ }
+
+ .extra-info-group {
+ transition: max-height, padding, height;
+ transition-timing-function: ease-in;
+ transition-duration: 700ms;
+ max-height: 70vh;
+ mask:
+ linear-gradient(to top, white, transparent) bottom/100% 2px no-repeat,
+ linear-gradient(to top, white, white);
+ }
+
+ .art-credit {
+ text-align: right;
+ }
+
+ &.-peek {
+ /* Explanation:
+ * 100vh - 100% = Distance between modal's top+bottom boundaries and screen
+ * (100vh - 100%) / 2 = Distance between bottom (or top) boundary and screen
+ */
+ transform: translateY(calc(((100vh - 100%) / 2)));
+
+ .pleroma-tan {
+ float: right;
+ z-index: 10;
+ shape-image-threshold: 0.7;
+ }
+
+ .extra-info-group {
+ max-height: 0;
+ }
+ }
+}
diff --git a/src/components/update_notification/update_notification.vue b/src/components/update_notification/update_notification.vue
new file mode 100644
index 00000000..78e70a74
--- /dev/null
+++ b/src/components/update_notification/update_notification.vue
@@ -0,0 +1,103 @@
+<template>
+ <Modal
+ :is-open="!!shouldShow"
+ class="UpdateNotification"
+ :no-background="true"
+ >
+ <div
+ class="UpdateNotificationModal panel"
+ :class="{ '-peek': !showingMore }"
+ >
+ <div class="panel-heading">
+ <span class="title">
+ {{ $t('update.big_update_title') }}
+ </span>
+ </div>
+ <div class="panel-body">
+ <div
+ class="content"
+ :class="{ '-noImage': !showingImage }"
+ >
+ <img
+ v-if="showingImage"
+ class="pleroma-tan"
+ :src="pleromaTanVariant"
+ :style="pleromaTanStyles"
+ >
+ <div class="spacer-top" />
+ <div class="text">
+ <p>
+ {{ $t('update.big_update_content') }}
+ </p>
+ <div
+ ref="animatedText"
+ class="extra-info-group"
+ >
+ <i18n-t
+ keypath="update.update_bugs"
+ tag="p"
+ >
+ <template #pleromaGitlab>
+ <a
+ target="_blank"
+ href="https://git.pleroma.social/"
+ >{{ $t('update.update_bugs_gitlab') }}</a>
+ </template>
+ </i18n-t>
+ <i18n-t
+ keypath="update.update_changelog"
+ tag="p"
+ >
+ <template #theFullChangelog>
+ <a
+ target="_blank"
+ href="https://pleroma.social/announcements/"
+ >{{ $t('update.update_changelog_here') }}</a>
+ </template>
+ </i18n-t>
+ <p class="art-credit">
+ <i18n-t
+ keypath="update.art_by"
+ tag="small"
+ >
+ <template #linkToArtist>
+ <a
+ target="_blank"
+ href="https://post.ebin.club/users/pipivovott"
+ >pipivovott</a>
+ </template>
+ </i18n-t>
+ </p>
+ </div>
+ </div>
+ <div class="spacer-bottom" />
+ </div>
+ </div>
+ <div class="panel-footer">
+ <button
+ class="button-default"
+ @click.prevent="neverShowAgain"
+ >
+ {{ $t("general.never_show_again") }}
+ </button>
+ <button
+ v-if="!showingMore"
+ class="button-default"
+ @click.prevent="toggleShow"
+ >
+ {{ $t("general.show_more") }}
+ </button>
+ <button
+ class="button-default"
+ @click.prevent="dismiss"
+ >
+ {{ $t("general.dismiss") }}
+ </button>
+ </div>
+ </div>
+ </Modal>
+</template>
+
+<script src="./update_notification.js"></script>
+
+<style src="./update_notification.scss" lang="scss"></style>
diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js
index 4168c54a..8b64a07e 100644
--- a/src/components/user_card/user_card.js
+++ b/src/components/user_card/user_card.js
@@ -5,6 +5,7 @@ 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 Select from '../select/select.vue'
+import UserLink from '../user_link/user_link.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { mapGetters } from 'vuex'
@@ -14,7 +15,9 @@ import {
faRss,
faSearchPlus,
faExternalLinkAlt,
- faEdit
+ faEdit,
+ faTimes,
+ faExpandAlt
} from '@fortawesome/free-solid-svg-icons'
library.add(
@@ -22,12 +25,21 @@ library.add(
faBell,
faSearchPlus,
faExternalLinkAlt,
- faEdit
+ faEdit,
+ faTimes,
+ faExpandAlt
)
export default {
props: [
- 'userId', 'switcher', 'selected', 'hideBio', 'rounded', 'bordered', 'allowZoomingAvatar'
+ 'userId',
+ 'switcher',
+ 'selected',
+ 'hideBio',
+ 'rounded',
+ 'bordered',
+ 'avatarAction', // default - open profile, 'zoom' - zoom, function - call function
+ 'onClose'
],
data () {
return {
@@ -47,15 +59,16 @@ export default {
},
classes () {
return [{
- 'user-card-rounded-t': this.rounded === 'top', // set border-top-left-radius and border-top-right-radius
- 'user-card-rounded': this.rounded === true, // set border-radius for all sides
- 'user-card-bordered': this.bordered === true // set border for all sides
+ '-rounded-t': this.rounded === 'top', // set border-top-left-radius and border-top-right-radius
+ '-rounded': this.rounded === true, // set border-radius for all sides
+ '-bordered': this.bordered === true, // set border for all sides
+ '-popover': !!this.onClose // set popover rounding
}]
},
style () {
return {
backgroundImage: [
- `linear-gradient(to bottom, var(--profileTint), var(--profileTint))`,
+ 'linear-gradient(to bottom, var(--profileTint), var(--profileTint))',
`url(${this.user.cover_photo})`
].join(', ')
}
@@ -112,6 +125,10 @@ 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')
+ },
...mapGetters(['mergedConfig'])
},
components: {
@@ -122,7 +139,8 @@ export default {
ProgressButton,
FollowButton,
Select,
- RichContent
+ RichContent,
+ UserLink
},
methods: {
muteUser () {
@@ -170,6 +188,12 @@ export default {
},
mentionUser () {
this.$store.dispatch('openPostStatusModal', { replyTo: true, repliedUser: this.user })
+ },
+ onAvatarClickHandler (e) {
+ if (this.onAvatarClick) {
+ e.preventDefault()
+ this.onAvatarClick()
+ }
}
}
}
diff --git a/src/components/user_card/user_card.scss b/src/components/user_card/user_card.scss
index 2e153120..a0bbc6a6 100644
--- a/src/components/user_card/user_card.scss
+++ b/src/components/user_card/user_card.scss
@@ -42,8 +42,10 @@
mask-composite: exclude;
background-size: cover;
mask-size: 100% 60%;
- border-top-left-radius: calc(var(--panelRadius) - 1px);
- border-top-right-radius: calc(var(--panelRadius) - 1px);
+ border-top-left-radius: calc(var(--__roundnessTop, --panelRadius) - 1px);
+ border-top-right-radius: calc(var(--__roundnessTop, --panelRadius) - 1px);
+ border-bottom-left-radius: calc(var(--__roundnessBottom, --panelRadius) - 1px);
+ border-bottom-right-radius: calc(var(--__roundnessBottom, --panelRadius) - 1px);
background-color: var(--profileBg);
z-index: -2;
@@ -72,21 +74,33 @@
}
}
- // Modifiers
-
- &-rounded-t {
+ &.-rounded-t {
border-top-left-radius: $fallback--panelRadius;
border-top-left-radius: var(--panelRadius, $fallback--panelRadius);
border-top-right-radius: $fallback--panelRadius;
border-top-right-radius: var(--panelRadius, $fallback--panelRadius);
+
+ --__roundnessTop: var(--panelRadius);
+ --__roundnessBottom: 0;
}
- &-rounded {
+ &.-rounded {
border-radius: $fallback--panelRadius;
border-radius: var(--panelRadius, $fallback--panelRadius);
+
+ --__roundnessTop: var(--panelRadius);
+ --__roundnessBottom: var(--panelRadius);
}
- &-bordered {
+ &.-popover {
+ border-radius: $fallback--tooltipRadius;
+ border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
+
+ --__roundnessTop: var(--tooltipRadius);
+ --__roundnessBottom: var(--tooltipRadius);
+ }
+
+ &.-bordered {
border-width: 1px;
border-style: solid;
border-color: $fallback--border;
@@ -99,6 +113,15 @@
color: var(--lightText, $fallback--lightText);
padding: 0 26px;
+ a {
+ color: $fallback--lightText;
+ color: var(--lightText, $fallback--lightText);
+
+ &:hover {
+ color: var(--icon);
+ }
+ }
+
.container {
min-width: 0;
padding: 16px 0 6px;
@@ -110,23 +133,27 @@
min-width: 0;
}
+ > a {
+ vertical-align: middle;
+ display: flex;
+ }
+
.Avatar {
--_avatarShadowBox: var(--avatarShadow);
--_avatarShadowFilter: var(--avatarShadowFilter);
--_avatarShadowInset: var(--avatarShadowInset);
- flex: 1 0 100%;
width: 56px;
height: 56px;
object-fit: cover;
}
}
- &-avatar-link {
+ &-avatar {
position: relative;
cursor: pointer;
- &-overlay {
+ &.-overlay {
position: absolute;
left: 0;
top: 0;
@@ -146,7 +173,7 @@
}
}
- &:hover &-overlay {
+ &:hover &.-overlay {
opacity: 1;
}
}
@@ -206,8 +233,6 @@
flex: 0 1 auto;
text-overflow: ellipsis;
overflow: hidden;
- color: $fallback--lightText;
- color: var(--lightText, $fallback--lightText);
}
.dailyAvg {
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index 67837845..897d89f9 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -8,25 +8,32 @@
:style="style"
class="background-image"
/>
- <div class="panel-heading -flexible-height">
+ <div :class="onClose ? '' : panel-heading -flexible-height">
<div class="user-info">
<div class="container">
<a
- v-if="allowZoomingAvatar"
- class="user-info-avatar-link"
+ v-if="avatarAction === 'zoom'"
+ class="user-info-avatar -link"
@click="zoomAvatar"
>
<UserAvatar
:better-shadow="betterShadow"
:user="user"
/>
- <div class="user-info-avatar-link-overlay">
+ <div class="user-info-avatar -link -overlay">
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="search-plus"
/>
</div>
</a>
+ <UserAvatar
+ v-else-if="typeof avatarAction === 'function'"
+ class="user-info-avatar"
+ :better-shadow="betterShadow"
+ :user="user"
+ @click="avatarAction"
+ />
<router-link
v-else
:to="userProfileLink(user)"
@@ -38,12 +45,16 @@
</router-link>
<div class="user-summary">
<div class="top-line">
- <RichContent
- :title="user.name"
+ <router-link
+ :to="userProfileLink(user)"
class="user-name"
- :html="user.name"
- :emoji="user.emoji"
- />
+ >
+ <RichContent
+ :title="user.name"
+ :html="user.name"
+ :emoji="user.emoji"
+ />
+ </router-link>
<button
v-if="!isOtherUser && user.is_local"
class="button-unstyled edit-profile-button"
@@ -72,15 +83,33 @@
:user="user"
:relationship="relationship"
/>
- </div>
- <div class="bottom-line">
<router-link
- class="user-screen-name"
- :title="user.screen_name_ui"
+ v-if="onClose"
:to="userProfileLink(user)"
+ class="button-unstyled external-link-button"
+ @click="onClose"
>
- @{{ user.screen_name_ui }}
+ <FAIcon
+ class="icon"
+ icon="expand-alt"
+ />
</router-link>
+ <button
+ v-if="onClose"
+ class="button-unstyled external-link-button"
+ @click="onClose"
+ >
+ <FAIcon
+ class="icon"
+ icon="times"
+ />
+ </button>
+ </div>
+ <div class="bottom-line">
+ <user-link
+ class="user-screen-name"
+ :user="user"
+ />
<template v-if="!hideBio">
<span
v-if="user.deactivated"
@@ -229,7 +258,7 @@
</button>
</div>
<ModerationTools
- v-if="loggedIn.role === &quot;admin&quot;"
+ v-if="showModerationMenu"
:user="user"
/>
</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 bdc3aa92..635dc7f6 100644
--- a/src/components/user_list_popover/user_list_popover.vue
+++ b/src/components/user_list_popover/user_list_popover.vue
@@ -4,10 +4,10 @@
placement="top"
:offset="{ y: 5 }"
>
- <template v-slot:trigger>
+ <template #trigger>
<slot />
</template>
- <template v-slot:content>
+ <template #content>
<div class="user-list-popover">
<template v-if="users.length">
<div
@@ -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>
@@ -45,7 +45,7 @@
</Popover>
</template>
-<script src="./user_list_popover.js" ></script>
+<script src="./user_list_popover.js"></script>
<style lang="scss">
@import '../../_variables.scss';
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
new file mode 100644
index 00000000..3b12aa1e
--- /dev/null
+++ b/src/components/user_popover/user_popover.js
@@ -0,0 +1,23 @@
+import UserCard from '../user_card/user_card.vue'
+import { defineAsyncComponent } from 'vue'
+
+const UserPopover = {
+ name: 'UserPopover',
+ props: [
+ 'userId', 'overlayCenters', 'disabled', 'overlayCentersSelector'
+ ],
+ components: {
+ UserCard,
+ Popover: defineAsyncComponent(() => import('../popover/popover.vue'))
+ },
+ computed: {
+ userPopoverAvatarAction () {
+ return this.$store.getters.mergedConfig.userPopoverAvatarAction
+ },
+ userPopoverOverlay () {
+ return this.$store.getters.mergedConfig.userPopoverOverlay
+ }
+ }
+}
+
+export default UserPopover
diff --git a/src/components/user_popover/user_popover.vue b/src/components/user_popover/user_popover.vue
new file mode 100644
index 00000000..53d51fc4
--- /dev/null
+++ b/src/components/user_popover/user_popover.vue
@@ -0,0 +1,33 @@
+<template>
+ <Popover
+ trigger="click"
+ popover-class="popover-default user-popover"
+ :overlay-centers-selector="overlayCentersSelector || '.user-info .Avatar'"
+ :overlay-centers="overlayCenters && userPopoverOverlay"
+ :disabled="disabled"
+ >
+ <template #trigger>
+ <slot />
+ </template>
+ <template #content="{close}">
+ <UserCard
+ class="user-popover"
+ :user-id="userId"
+ :hide-bio="true"
+ :avatar-action="userPopoverAvatarAction == 'close' ? close : userPopoverAvatarAction"
+ :on-close="close"
+ />
+ </template>
+ </Popover>
+</template>
+
+<script src="./user_popover.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+/* popover styles load on-demand, so we need to override */
+.user-popover.popover {
+}
+
+</style>
diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index f779b823..08adaeab 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -45,7 +45,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 () {
@@ -106,12 +106,17 @@ const UserProfile = {
this.userId = null
this.error = false
+ const maybeId = userNameOrId.id
+ const maybeName = userNameOrId.name
+
// Check if user data is already loaded in store
- const user = this.$store.getters.findUser(userNameOrId)
+ const user = maybeId ? this.$store.getters.findUser(maybeId) : this.$store.getters.findUserByName(maybeName)
if (user) {
loadById(user.id)
} else {
- this.$store.dispatch('fetchUser', userNameOrId)
+ (maybeId
+ ? this.$store.dispatch('fetchUser', maybeId)
+ : this.$store.dispatch('fetchUserByName', maybeName))
.then(({ id }) => loadById(id))
.catch((reason) => {
const errorMessage = get(reason, 'error.error')
@@ -150,12 +155,12 @@ const UserProfile = {
watch: {
'$route.params.id': function (newVal) {
if (newVal) {
- this.switchUser(newVal)
+ this.switchUser({ id: newVal })
}
},
'$route.params.name': function (newVal) {
if (newVal) {
- this.switchUser(newVal)
+ this.switchUser({ name: newVal })
}
},
'$route.query': function (newVal) {
diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index 62792599..d0da2b5b 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -8,7 +8,7 @@
:user-id="userId"
:switcher="true"
:selected="timeline.viewing"
- :allow-zooming-avatar="true"
+ avatar-action="zoom"
rounded="top"
/>
<div
@@ -56,7 +56,7 @@
:user-id="userId"
:pinned-status-ids="user.pinnedStatusIds"
:in-profile="true"
- :footerSlipgate="footerRef"
+ :footer-slipgate="footerRef"
/>
<div
v-if="followsTabVisible"
@@ -65,7 +65,7 @@
:disabled="!user.friends_count"
>
<FriendList :user-id="userId">
- <template v-slot:item="{item}">
+ <template #item="{item}">
<FollowCard :user="item" />
</template>
</FriendList>
@@ -77,7 +77,7 @@
:disabled="!user.followers_count"
>
<FollowerList :user-id="userId">
- <template v-slot:item="{item}">
+ <template #item="{item}">
<FollowCard
:user="item"
:no-follows-you="isUs"
@@ -95,7 +95,7 @@
:timeline="media"
:user-id="userId"
:in-profile="true"
- :footerSlipgate="footerRef"
+ :footer-slipgate="footerRef"
/>
<Timeline
v-if="isUs"
@@ -107,10 +107,13 @@
timeline-name="favorites"
:timeline="favorites"
:in-profile="true"
- :footerSlipgate="footerRef"
+ :footer-slipgate="footerRef"
/>
</tab-switcher>
- <div class="panel-footer" :ref="setFooterRef"></div>
+ <div
+ :ref="setFooterRef"
+ class="panel-footer"
+ />
</div>
<div
v-else
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 030ce2c4..8c42ab7b 100644
--- a/src/components/user_reporting_modal/user_reporting_modal.vue
+++ b/src/components/user_reporting_modal/user_reporting_modal.vue
@@ -5,9 +5,13 @@
>
<div class="user-reporting-panel panel">
<div class="panel-heading">
- <div class="title">
- {{ $t('user_reporting.title', [user.screen_name_ui]) }}
- </div>
+ <i18n-t
+ tag="div"
+ keypath="user_reporting.title"
+ class="title"
+ >
+ <UserLink :user="user" />
+ </i18n-t>
</div>
<div class="panel-body">
<div class="user-reporting-panel-left">
@@ -45,7 +49,7 @@
</div>
<div class="user-reporting-panel-right">
<List :items="statuses">
- <template v-slot:item="{item}">
+ <template #item="{item}">
<div class="status-fadein user-reporting-panel-sitem">
<Status
:in-conversation="false"
diff --git a/src/components/who_to_follow/who_to_follow.js b/src/components/who_to_follow/who_to_follow.js
index ecd97dd7..53f05272 100644
--- a/src/components/who_to_follow/who_to_follow.js
+++ b/src/components/who_to_follow/who_to_follow.js
@@ -28,7 +28,7 @@ const WhoToFollow = {
getWhoToFollow () {
const credentials = this.$store.state.users.currentUser.credentials
if (credentials) {
- apiService.suggestions({ credentials: credentials })
+ apiService.suggestions({ credentials })
.then((reply) => {
this.showWhoToFollow(reply)
})
diff --git a/src/components/who_to_follow_panel/who_to_follow_panel.js b/src/components/who_to_follow_panel/who_to_follow_panel.js
index 818e8bd5..f19ba948 100644
--- a/src/components/who_to_follow_panel/who_to_follow_panel.js
+++ b/src/components/who_to_follow_panel/who_to_follow_panel.js
@@ -6,9 +6,9 @@ function showWhoToFollow (panel, reply) {
const shuffled = shuffle(reply)
panel.usersToFollow.forEach((toFollow, index) => {
- let user = shuffled[index]
- let img = user.avatar || this.$store.state.instance.defaultAvatar
- let name = user.acct
+ const user = shuffled[index]
+ const img = user.avatar || this.$store.state.instance.defaultAvatar
+ const name = user.acct
toFollow.img = img
toFollow.name = name
@@ -24,12 +24,12 @@ function showWhoToFollow (panel, reply) {
}
function getWhoToFollow (panel) {
- var credentials = panel.$store.state.users.currentUser.credentials
+ const credentials = panel.$store.state.users.currentUser.credentials
if (credentials) {
panel.usersToFollow.forEach(toFollow => {
toFollow.name = 'Loading...'
})
- apiService.suggestions({ credentials: credentials })
+ apiService.suggestions({ credentials })
.then((reply) => {
showWhoToFollow(panel, reply)
})
diff --git a/src/components/who_to_follow_panel/who_to_follow_panel.vue b/src/components/who_to_follow_panel/who_to_follow_panel.vue
index 518acd97..c1ba6fb1 100644
--- a/src/components/who_to_follow_panel/who_to_follow_panel.vue
+++ b/src/components/who_to_follow_panel/who_to_follow_panel.vue
@@ -27,7 +27,7 @@
</div>
</template>
-<script src="./who_to_follow_panel.js" ></script>
+<script src="./who_to_follow_panel.js"></script>
<style lang="scss">
.who-to-follow * {