aboutsummaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/account_actions/account_actions.js12
-rw-r--r--src/components/account_actions/account_actions.vue8
-rw-r--r--src/components/async_component_error/async_component_error.vue41
-rw-r--r--src/components/attachment/attachment.js32
-rw-r--r--src/components/attachment/attachment.vue39
-rw-r--r--src/components/bookmark_timeline/bookmark_timeline.js17
-rw-r--r--src/components/bookmark_timeline/bookmark_timeline.vue9
-rw-r--r--src/components/chat/chat.js333
-rw-r--r--src/components/chat/chat.scss162
-rw-r--r--src/components/chat/chat.vue100
-rw-r--r--src/components/chat/chat_layout_utils.js26
-rw-r--r--src/components/chat_list/chat_list.js37
-rw-r--r--src/components/chat_list/chat_list.vue64
-rw-r--r--src/components/chat_list_item/chat_list_item.js67
-rw-r--r--src/components/chat_list_item/chat_list_item.scss94
-rw-r--r--src/components/chat_list_item/chat_list_item.vue52
-rw-r--r--src/components/chat_message/chat_message.js96
-rw-r--r--src/components/chat_message/chat_message.scss164
-rw-r--r--src/components/chat_message/chat_message.vue99
-rw-r--r--src/components/chat_message_date/chat_message_date.vue24
-rw-r--r--src/components/chat_new/chat_new.js73
-rw-r--r--src/components/chat_new/chat_new.scss29
-rw-r--r--src/components/chat_new/chat_new.vue46
-rw-r--r--src/components/chat_panel/chat_panel.vue84
-rw-r--r--src/components/chat_title/chat_title.js26
-rw-r--r--src/components/chat_title/chat_title.vue67
-rw-r--r--src/components/checkbox/checkbox.vue2
-rw-r--r--src/components/conversation/conversation.vue33
-rw-r--r--src/components/domain_mute_card/domain_mute_card.js11
-rw-r--r--src/components/domain_mute_card/domain_mute_card.vue15
-rw-r--r--src/components/emoji_input/emoji_input.js60
-rw-r--r--src/components/emoji_input/emoji_input.vue5
-rw-r--r--src/components/emoji_input/suggestor.js6
-rw-r--r--src/components/emoji_reactions/emoji_reactions.js4
-rw-r--r--src/components/emoji_reactions/emoji_reactions.vue65
-rw-r--r--src/components/extra_buttons/extra_buttons.js10
-rw-r--r--src/components/extra_buttons/extra_buttons.vue17
-rw-r--r--src/components/features_panel/features_panel.js1
-rw-r--r--src/components/features_panel/features_panel.vue3
-rw-r--r--src/components/follow_card/follow_card.vue4
-rw-r--r--src/components/gallery/gallery.vue5
-rw-r--r--src/components/global_notice_list/global_notice_list.js15
-rw-r--r--src/components/global_notice_list/global_notice_list.vue77
-rw-r--r--src/components/interface_language_switcher/interface_language_switcher.vue3
-rw-r--r--src/components/media_modal/media_modal.js2
-rw-r--r--src/components/media_modal/media_modal.vue10
-rw-r--r--src/components/media_upload/media_upload.js42
-rw-r--r--src/components/media_upload/media_upload.vue7
-rw-r--r--src/components/mobile_nav/mobile_nav.js9
-rw-r--r--src/components/mobile_nav/mobile_nav.vue5
-rw-r--r--src/components/mobile_post_status_button/mobile_post_status_button.js7
-rw-r--r--src/components/modal/modal.vue31
-rw-r--r--src/components/nav_panel/nav_panel.js29
-rw-r--r--src/components/nav_panel/nav_panel.vue31
-rw-r--r--src/components/notification/notification.js12
-rw-r--r--src/components/notification/notification.scss52
-rw-r--r--src/components/notification/notification.vue9
-rw-r--r--src/components/notifications/notifications.js19
-rw-r--r--src/components/notifications/notifications.scss37
-rw-r--r--src/components/panel_loading/panel_loading.vue29
-rw-r--r--src/components/poll/poll.vue3
-rw-r--r--src/components/poll/poll_form.js1
-rw-r--r--src/components/popover/popover.js17
-rw-r--r--src/components/popover/popover.vue5
-rw-r--r--src/components/post_status_form/post_status_form.js281
-rw-r--r--src/components/post_status_form/post_status_form.vue272
-rw-r--r--src/components/react_button/react_button.js7
-rw-r--r--src/components/settings/settings.js128
-rw-r--r--src/components/settings/settings.vue424
-rw-r--r--src/components/settings_modal/helpers/shared_computed_object.js58
-rw-r--r--src/components/settings_modal/settings_modal.js42
-rw-r--r--src/components/settings_modal/settings_modal.scss51
-rw-r--r--src/components/settings_modal/settings_modal.vue54
-rw-r--r--src/components/settings_modal/settings_modal_content.js34
-rw-r--r--src/components/settings_modal/settings_modal_content.scss43
-rw-r--r--src/components/settings_modal/settings_modal_content.vue73
-rw-r--r--src/components/settings_modal/tabs/data_import_export_tab.js65
-rw-r--r--src/components/settings_modal/tabs/data_import_export_tab.vue43
-rw-r--r--src/components/settings_modal/tabs/filtering_tab.js47
-rw-r--r--src/components/settings_modal/tabs/filtering_tab.vue86
-rw-r--r--src/components/settings_modal/tabs/general_tab.js31
-rw-r--r--src/components/settings_modal/tabs/general_tab.vue262
-rw-r--r--src/components/settings_modal/tabs/mutes_and_blocks_tab.js136
-rw-r--r--src/components/settings_modal/tabs/mutes_and_blocks_tab.scss29
-rw-r--r--src/components/settings_modal/tabs/mutes_and_blocks_tab.vue171
-rw-r--r--src/components/settings_modal/tabs/notifications_tab.js27
-rw-r--r--src/components/settings_modal/tabs/notifications_tab.vue34
-rw-r--r--src/components/settings_modal/tabs/profile_tab.js253
-rw-r--r--src/components/settings_modal/tabs/profile_tab.scss128
-rw-r--r--src/components/settings_modal/tabs/profile_tab.vue289
-rw-r--r--src/components/settings_modal/tabs/security_tab/confirm.js (renamed from src/components/user_settings/confirm.js)0
-rw-r--r--src/components/settings_modal/tabs/security_tab/confirm.vue (renamed from src/components/user_settings/confirm.vue)0
-rw-r--r--src/components/settings_modal/tabs/security_tab/mfa.js (renamed from src/components/user_settings/mfa.js)0
-rw-r--r--src/components/settings_modal/tabs/security_tab/mfa.vue (renamed from src/components/user_settings/mfa.vue)12
-rw-r--r--src/components/settings_modal/tabs/security_tab/mfa_backup_codes.js (renamed from src/components/user_settings/mfa_backup_codes.js)0
-rw-r--r--src/components/settings_modal/tabs/security_tab/mfa_backup_codes.vue (renamed from src/components/user_settings/mfa_backup_codes.vue)18
-rw-r--r--src/components/settings_modal/tabs/security_tab/mfa_totp.js (renamed from src/components/user_settings/mfa_totp.js)0
-rw-r--r--src/components/settings_modal/tabs/security_tab/mfa_totp.vue (renamed from src/components/user_settings/mfa_totp.vue)0
-rw-r--r--src/components/settings_modal/tabs/security_tab/security_tab.js106
-rw-r--r--src/components/settings_modal/tabs/security_tab/security_tab.vue143
-rw-r--r--src/components/settings_modal/tabs/theme_tab/preview.vue (renamed from src/components/style_switcher/preview.vue)0
-rw-r--r--src/components/settings_modal/tabs/theme_tab/theme_tab.js (renamed from src/components/style_switcher/style_switcher.js)33
-rw-r--r--src/components/settings_modal/tabs/theme_tab/theme_tab.scss (renamed from src/components/style_switcher/style_switcher.scss)38
-rw-r--r--src/components/settings_modal/tabs/theme_tab/theme_tab.vue (renamed from src/components/style_switcher/style_switcher.vue)106
-rw-r--r--src/components/settings_modal/tabs/version_tab.js24
-rw-r--r--src/components/settings_modal/tabs/version_tab.vue31
-rw-r--r--src/components/side_drawer/side_drawer.js16
-rw-r--r--src/components/side_drawer/side_drawer.vue59
-rw-r--r--src/components/staff_panel/staff_panel.js4
-rw-r--r--src/components/status/status.js118
-rw-r--r--src/components/status/status.scss414
-rw-r--r--src/components/status/status.vue525
-rw-r--r--src/components/status_content/status_content.js32
-rw-r--r--src/components/status_content/status_content.vue179
-rw-r--r--src/components/status_popover/status_popover.js4
-rw-r--r--src/components/status_popover/status_popover.vue8
-rw-r--r--src/components/still-image/still-image.js3
-rw-r--r--src/components/still-image/still-image.vue57
-rw-r--r--src/components/tab_switcher/tab_switcher.js54
-rw-r--r--src/components/tab_switcher/tab_switcher.scss268
-rw-r--r--src/components/timeline/timeline.js25
-rw-r--r--src/components/timeline/timeline.vue24
-rw-r--r--src/components/timeline_menu/timeline_menu.js63
-rw-r--r--src/components/timeline_menu/timeline_menu.vue180
-rw-r--r--src/components/user_avatar/user_avatar.js16
-rw-r--r--src/components/user_avatar/user_avatar.vue8
-rw-r--r--src/components/user_card/user_card.vue44
-rw-r--r--src/components/user_list_popover/user_list_popover.js18
-rw-r--r--src/components/user_list_popover/user_list_popover.vue71
-rw-r--r--src/components/user_panel/user_panel.vue4
-rw-r--r--src/components/user_profile/user_profile.js10
-rw-r--r--src/components/user_profile/user_profile.vue75
-rw-r--r--src/components/user_reporting_modal/user_reporting_modal.vue3
-rw-r--r--src/components/user_settings/user_settings.js393
-rw-r--r--src/components/user_settings/user_settings.vue728
-rw-r--r--src/components/video_attachment/video_attachment.vue2
-rw-r--r--src/components/who_to_follow_panel/who_to_follow_panel.js17
137 files changed, 6603 insertions, 2892 deletions
diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js
index 0826c275..6d345bc7 100644
--- a/src/components/account_actions/account_actions.js
+++ b/src/components/account_actions/account_actions.js
@@ -1,3 +1,4 @@
+import { mapState } from 'vuex'
import ProgressButton from '../progress_button/progress_button.vue'
import Popover from '../popover/popover.vue'
@@ -27,7 +28,18 @@ const AccountActions = {
},
reportUser () {
this.$store.dispatch('openUserReportingModal', this.user.id)
+ },
+ openChat () {
+ this.$router.push({
+ name: 'chat',
+ params: { recipient_id: this.user.id }
+ })
}
+ },
+ computed: {
+ ...mapState({
+ pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
+ })
}
}
diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue
index 744b77d5..987e94b7 100644
--- a/src/components/account_actions/account_actions.vue
+++ b/src/components/account_actions/account_actions.vue
@@ -3,6 +3,7 @@
<Popover
trigger="click"
placement="bottom"
+ :bound-to="{ x: 'container' }"
>
<div
slot="content"
@@ -49,6 +50,13 @@
>
{{ $t('user_card.report') }}
</button>
+ <button
+ v-if="pleromaChatMessagesAvailable"
+ class="btn btn-default btn-block dropdown-item"
+ @click="openChat"
+ >
+ {{ $t('user_card.message') }}
+ </button>
</div>
</div>
<div
diff --git a/src/components/async_component_error/async_component_error.vue b/src/components/async_component_error/async_component_error.vue
new file mode 100644
index 00000000..b68b98f9
--- /dev/null
+++ b/src/components/async_component_error/async_component_error.vue
@@ -0,0 +1,41 @@
+<template>
+ <div class="async-component-error">
+ <div>
+ <h4>
+ {{ $t('general.generic_error') }}
+ </h4>
+ <p>
+ {{ $t('general.error_retry') }}
+ </p>
+ <button
+ class="btn"
+ @click="retry"
+ >
+ {{ $t('general.retry') }}
+ </button>
+ </div>
+ </div>
+</template>
+
+<script>
+export default {
+ methods: {
+ retry () {
+ this.$emit('resetAsyncComponent')
+ }
+ }
+}
+</script>
+
+<style lang="scss">
+.async-component-error {
+ display: flex;
+ height: 100%;
+ align-items: center;
+ justify-content: center;
+ .btn {
+ margin: .5em;
+ padding: .5em 2em;
+ }
+}
+</style>
diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js
index b832e10f..cb31020d 100644
--- a/src/components/attachment/attachment.js
+++ b/src/components/attachment/attachment.js
@@ -8,7 +8,6 @@ const Attachment = {
props: [
'attachment',
'nsfw',
- 'statusId',
'size',
'allowPlay',
'setMedia',
@@ -30,9 +29,21 @@ const Attachment = {
VideoAttachment
},
computed: {
- usePlaceHolder () {
+ usePlaceholder () {
return this.size === 'hide' || this.type === 'unknown'
},
+ placeholderName () {
+ if (this.attachment.description === '' || !this.attachment.description) {
+ return this.type.toUpperCase()
+ }
+ return this.attachment.description
+ },
+ placeholderIconClass () {
+ if (this.type === 'image') return 'icon-picture'
+ if (this.type === 'video') return 'icon-video'
+ if (this.type === 'audio') return 'icon-music'
+ return 'icon-doc'
+ },
referrerpolicy () {
return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer'
},
@@ -49,7 +60,15 @@ const Attachment = {
return this.size === 'small'
},
fullwidth () {
- return this.type === 'html' || this.type === 'audio'
+ if (this.size === 'hide') return false
+ return this.type === 'html' || this.type === 'audio' || this.type === 'unknown'
+ },
+ useModal () {
+ const modalTypes = this.size === 'hide' ? ['image', 'video', 'audio']
+ : this.mergedConfig.playVideosInModal
+ ? ['image', 'video']
+ : ['image']
+ return modalTypes.includes(this.type)
},
...mapGetters(['mergedConfig'])
},
@@ -60,12 +79,7 @@ const Attachment = {
}
},
openModal (event) {
- const modalTypes = this.mergedConfig.playVideosInModal
- ? ['image', 'video']
- : ['image']
- if (fileTypeService.fileMatchesSomeType(modalTypes, this.attachment) ||
- this.usePlaceHolder
- ) {
+ if (this.useModal) {
event.stopPropagation()
event.preventDefault()
this.setMedia()
diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue
index a7e217c1..63e0ceba 100644
--- a/src/components/attachment/attachment.vue
+++ b/src/components/attachment/attachment.vue
@@ -1,6 +1,7 @@
<template>
<div
- v-if="usePlaceHolder"
+ v-if="usePlaceholder"
+ :class="{ 'fullwidth': fullwidth }"
@click="openModal"
>
<a
@@ -8,8 +9,11 @@
class="placeholder"
target="_blank"
:href="attachment.url"
+ :alt="attachment.description"
+ :title="attachment.description"
>
- [{{ nsfw ? "NSFW/" : "" }}{{ type.toUpperCase() }}]
+ <span :class="placeholderIconClass" />
+ <b>{{ nsfw ? "NSFW / " : "" }}</b>{{ placeholderName }}
</a>
</div>
<div
@@ -22,6 +26,8 @@
v-if="hidden"
class="image-attachment"
:href="attachment.url"
+ :alt="attachment.description"
+ :title="attachment.description"
@click.prevent="toggleHidden"
>
<img
@@ -51,14 +57,15 @@
:class="{'hidden': hidden && preloadImage }"
:href="attachment.url"
target="_blank"
- :title="attachment.description"
@click="openModal"
>
<StillImage
+ class="image"
:referrerpolicy="referrerpolicy"
:mimetype="attachment.mimetype"
:src="attachment.large_thumb_url || attachment.url"
:image-load-handler="onImageLoad"
+ :alt="attachment.description"
/>
</a>
@@ -83,6 +90,8 @@
<audio
v-if="type === 'audio'"
:src="attachment.url"
+ :alt="attachment.description"
+ :title="attachment.description"
controls
/>
@@ -116,22 +125,19 @@
display: flex;
flex-wrap: wrap;
- .attachment.media-upload-container {
- flex: 0 0 auto;
- max-height: 200px;
+ .non-gallery {
max-width: 100%;
- display: flex;
- align-items: center;
- video {
- max-width: 100%;
- }
}
.placeholder {
- margin-right: 8px;
- margin-bottom: 4px;
+ display: inline-block;
+ padding: 0.3em 1em 0.3em 0;
color: $fallback--link;
color: var(--postLink, $fallback--link);
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ max-width: 100%;
}
.nsfw-placeholder {
@@ -276,8 +282,11 @@
}
.image-attachment {
- width: 100%;
- height: 100%;
+ &,
+ & .image {
+ width: 100%;
+ height: 100%;
+ }
&.hidden {
display: none;
diff --git a/src/components/bookmark_timeline/bookmark_timeline.js b/src/components/bookmark_timeline/bookmark_timeline.js
new file mode 100644
index 00000000..64b69e5d
--- /dev/null
+++ b/src/components/bookmark_timeline/bookmark_timeline.js
@@ -0,0 +1,17 @@
+import Timeline from '../timeline/timeline.vue'
+
+const Bookmarks = {
+ computed: {
+ timeline () {
+ return this.$store.state.statuses.timelines.bookmarks
+ }
+ },
+ components: {
+ Timeline
+ },
+ destroyed () {
+ this.$store.commit('clearTimeline', { timeline: 'bookmarks' })
+ }
+}
+
+export default Bookmarks
diff --git a/src/components/bookmark_timeline/bookmark_timeline.vue b/src/components/bookmark_timeline/bookmark_timeline.vue
new file mode 100644
index 00000000..8da6884b
--- /dev/null
+++ b/src/components/bookmark_timeline/bookmark_timeline.vue
@@ -0,0 +1,9 @@
+<template>
+ <Timeline
+ :title="$t('nav.bookmarks')"
+ :timeline="timeline"
+ :timeline-name="'bookmarks'"
+ />
+</template>
+
+<script src="./bookmark_timeline.js"></script>
diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js
new file mode 100644
index 00000000..9c4e5b05
--- /dev/null
+++ b/src/components/chat/chat.js
@@ -0,0 +1,333 @@
+import _ from 'lodash'
+import { WSConnectionStatus } from '../../services/api/api.service.js'
+import { mapGetters, mapState } from 'vuex'
+import ChatMessage from '../chat_message/chat_message.vue'
+import PostStatusForm from '../post_status_form/post_status_form.vue'
+import ChatTitle from '../chat_title/chat_title.vue'
+import chatService from '../../services/chat_service/chat_service.js'
+import { getScrollPosition, getNewTopPosition, isBottomedOut, scrollableContainerHeight } from './chat_layout_utils.js'
+
+const BOTTOMED_OUT_OFFSET = 10
+const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 150
+const SAFE_RESIZE_TIME_OFFSET = 100
+
+const Chat = {
+ components: {
+ ChatMessage,
+ ChatTitle,
+ PostStatusForm
+ },
+ data () {
+ return {
+ jumpToBottomButtonVisible: false,
+ hoveredMessageChainId: undefined,
+ lastScrollPosition: {},
+ scrollableContainerHeight: '100%',
+ errorLoadingChat: false
+ }
+ },
+ created () {
+ this.startFetching()
+ window.addEventListener('resize', this.handleLayoutChange)
+ },
+ mounted () {
+ window.addEventListener('scroll', this.handleScroll)
+ if (typeof document.hidden !== 'undefined') {
+ document.addEventListener('visibilitychange', this.handleVisibilityChange, false)
+ }
+
+ this.$nextTick(() => {
+ this.updateScrollableContainerHeight()
+ this.handleResize()
+ })
+ this.setChatLayout()
+ },
+ destroyed () {
+ window.removeEventListener('scroll', this.handleScroll)
+ window.removeEventListener('resize', this.handleLayoutChange)
+ this.unsetChatLayout()
+ if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false)
+ this.$store.dispatch('clearCurrentChat')
+ },
+ computed: {
+ recipient () {
+ return this.currentChat && this.currentChat.account
+ },
+ recipientId () {
+ return this.$route.params.recipient_id
+ },
+ formPlaceholder () {
+ if (this.recipient) {
+ return this.$t('chats.message_user', { nickname: this.recipient.screen_name })
+ } else {
+ return ''
+ }
+ },
+ chatViewItems () {
+ return chatService.getView(this.currentChatMessageService)
+ },
+ newMessageCount () {
+ return this.currentChatMessageService && this.currentChatMessageService.newMessageCount
+ },
+ streamingEnabled () {
+ return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED
+ },
+ ...mapGetters([
+ 'currentChat',
+ 'currentChatMessageService',
+ 'findOpenedChatByRecipientId',
+ 'mergedConfig'
+ ]),
+ ...mapState({
+ backendInteractor: state => state.api.backendInteractor,
+ mastoUserSocketStatus: state => state.api.mastoUserSocketStatus,
+ mobileLayout: state => state.interface.mobileLayout,
+ layoutHeight: state => state.interface.layoutHeight,
+ currentUser: state => state.users.currentUser
+ })
+ },
+ watch: {
+ chatViewItems () {
+ // We don't want to scroll to the bottom on a new message when the user is viewing older messages.
+ // Therefore we need to know whether the scroll position was at the bottom before the DOM update.
+ const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET)
+ this.$nextTick(() => {
+ if (bottomedOutBeforeUpdate) {
+ this.scrollDown({ forceRead: !document.hidden })
+ }
+ })
+ },
+ '$route': function () {
+ this.startFetching()
+ },
+ layoutHeight () {
+ this.handleResize({ expand: true })
+ },
+ mastoUserSocketStatus (newValue) {
+ if (newValue === WSConnectionStatus.JOINED) {
+ this.fetchChat({ isFirstFetch: true })
+ }
+ }
+ },
+ methods: {
+ // Used to animate the avatar near the first message of the message chain when any message belonging to the chain is hovered
+ onMessageHover ({ isHovered, messageChainId }) {
+ this.hoveredMessageChainId = isHovered ? messageChainId : undefined
+ },
+ onFilesDropped () {
+ this.$nextTick(() => {
+ this.handleResize()
+ this.updateScrollableContainerHeight()
+ })
+ },
+ handleVisibilityChange () {
+ this.$nextTick(() => {
+ if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) {
+ this.scrollDown({ forceRead: true })
+ }
+ })
+ },
+ setChatLayout () {
+ // This is a hacky way to adjust the global layout to the mobile chat (without modifying the rest of the app).
+ // This layout prevents empty spaces from being visible at the bottom
+ // of the chat on iOS Safari (`safe-area-inset`) when
+ // - the on-screen keyboard appears and the user starts typing
+ // - the user selects the text inside the input area
+ // - the user selects and deletes the text that is multiple lines long
+ // TODO: unify the chat layout with the global layout.
+ let html = document.querySelector('html')
+ if (html) {
+ html.classList.add('chat-layout')
+ }
+
+ this.$nextTick(() => {
+ this.updateScrollableContainerHeight()
+ })
+ },
+ unsetChatLayout () {
+ let html = document.querySelector('html')
+ if (html) {
+ html.classList.remove('chat-layout')
+ }
+ },
+ handleLayoutChange () {
+ this.$nextTick(() => {
+ this.updateScrollableContainerHeight()
+ this.scrollDown()
+ })
+ },
+ // Ensures the proper position of the posting form in the mobile layout (the mobile browser panel does not overlap or hide it)
+ updateScrollableContainerHeight () {
+ const header = this.$refs.header
+ const footer = this.$refs.footer
+ const inner = this.mobileLayout ? window.document.body : this.$refs.inner
+ this.scrollableContainerHeight = scrollableContainerHeight(inner, header, footer) + 'px'
+ },
+ // Preserves the scroll position when OSK appears or the posting form changes its height.
+ handleResize (opts = {}) {
+ const { expand = false, delayed = false } = opts
+
+ if (delayed) {
+ setTimeout(() => {
+ this.handleResize({ ...opts, delayed: false })
+ }, SAFE_RESIZE_TIME_OFFSET)
+ return
+ }
+
+ this.$nextTick(() => {
+ this.updateScrollableContainerHeight()
+
+ const { offsetHeight = undefined } = this.lastScrollPosition
+ this.lastScrollPosition = getScrollPosition(this.$refs.scrollable)
+
+ const diff = this.lastScrollPosition.offsetHeight - offsetHeight
+ if (diff < 0 || (!this.bottomedOut() && expand)) {
+ this.$nextTick(() => {
+ this.updateScrollableContainerHeight()
+ this.$refs.scrollable.scrollTo({
+ top: this.$refs.scrollable.scrollTop - diff,
+ left: 0
+ })
+ })
+ }
+ })
+ },
+ scrollDown (options = {}) {
+ const { behavior = 'auto', forceRead = false } = options
+ const scrollable = this.$refs.scrollable
+ if (!scrollable) { return }
+ this.$nextTick(() => {
+ scrollable.scrollTo({ top: scrollable.scrollHeight, left: 0, behavior })
+ })
+ if (forceRead || this.newMessageCount > 0) {
+ this.readChat()
+ }
+ },
+ readChat () {
+ if (!(this.currentChatMessageService && this.currentChatMessageService.lastMessage)) { return }
+ if (document.hidden) { return }
+ const lastReadId = this.currentChatMessageService.lastMessage.id
+ this.$store.dispatch('readChat', { id: this.currentChat.id, lastReadId })
+ },
+ bottomedOut (offset) {
+ return isBottomedOut(this.$refs.scrollable, offset)
+ },
+ reachedTop () {
+ const scrollable = this.$refs.scrollable
+ return scrollable && scrollable.scrollTop <= 0
+ },
+ handleScroll: _.throttle(function () {
+ if (!this.currentChat) { return }
+
+ if (this.reachedTop()) {
+ this.fetchChat({ maxId: this.currentChatMessageService.minId })
+ } else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
+ this.jumpToBottomButtonVisible = false
+ if (this.newMessageCount > 0) {
+ this.readChat()
+ }
+ } else {
+ this.jumpToBottomButtonVisible = true
+ }
+ }, 100),
+ handleScrollUp (positionBeforeLoading) {
+ const positionAfterLoading = getScrollPosition(this.$refs.scrollable)
+ this.$refs.scrollable.scrollTo({
+ top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
+ left: 0
+ })
+ },
+ fetchChat ({ isFirstFetch = false, fetchLatest = false, maxId }) {
+ const chatMessageService = this.currentChatMessageService
+ if (!chatMessageService) { return }
+ if (fetchLatest && this.streamingEnabled) { return }
+
+ const chatId = chatMessageService.chatId
+ const fetchOlderMessages = !!maxId
+ const sinceId = fetchLatest && chatMessageService.lastMessage && chatMessageService.lastMessage.id
+
+ this.backendInteractor.chatMessages({ id: chatId, maxId, sinceId })
+ .then((messages) => {
+ // Clear the current chat in case we're recovering from a ws connection loss.
+ if (isFirstFetch) {
+ chatService.clear(chatMessageService)
+ }
+
+ const positionBeforeUpdate = getScrollPosition(this.$refs.scrollable)
+ this.$store.dispatch('addChatMessages', { chatId, messages }).then(() => {
+ this.$nextTick(() => {
+ if (fetchOlderMessages) {
+ this.handleScrollUp(positionBeforeUpdate)
+ }
+
+ if (isFirstFetch) {
+ this.updateScrollableContainerHeight()
+ }
+ })
+ })
+ })
+ },
+ async startFetching () {
+ let chat = this.findOpenedChatByRecipientId(this.recipientId)
+ if (!chat) {
+ try {
+ chat = await this.backendInteractor.getOrCreateChat({ accountId: this.recipientId })
+ } catch (e) {
+ console.error('Error creating or getting a chat', e)
+ this.errorLoadingChat = true
+ }
+ }
+ if (chat) {
+ this.$nextTick(() => {
+ this.scrollDown({ forceRead: true })
+ })
+ this.$store.dispatch('addOpenedChat', { chat })
+ this.doStartFetching()
+ }
+ },
+ doStartFetching () {
+ this.$store.dispatch('startFetchingCurrentChat', {
+ fetcher: () => setInterval(() => this.fetchChat({ fetchLatest: true }), 5000)
+ })
+ this.fetchChat({ isFirstFetch: true })
+ },
+ sendMessage ({ status, media }) {
+ const params = {
+ id: this.currentChat.id,
+ content: status
+ }
+
+ if (media[0]) {
+ params.mediaId = media[0].id
+ }
+
+ return this.backendInteractor.sendChatMessage(params)
+ .then(data => {
+ this.$store.dispatch('addChatMessages', { chatId: this.currentChat.id, messages: [data] }).then(() => {
+ this.$nextTick(() => {
+ this.handleResize()
+ // When the posting form size changes because of a media attachment, we need an extra resize
+ // to account for the potential delay in the DOM update.
+ setTimeout(() => {
+ this.updateScrollableContainerHeight()
+ }, SAFE_RESIZE_TIME_OFFSET)
+ this.scrollDown({ forceRead: true })
+ })
+ })
+
+ return data
+ })
+ .catch(error => {
+ console.error('Error sending message', error)
+ return {
+ error: this.$t('chats.error_sending_message')
+ }
+ })
+ },
+ goBack () {
+ this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } })
+ }
+ }
+}
+
+export default Chat
diff --git a/src/components/chat/chat.scss b/src/components/chat/chat.scss
new file mode 100644
index 00000000..012a1b1d
--- /dev/null
+++ b/src/components/chat/chat.scss
@@ -0,0 +1,162 @@
+.chat-view {
+ display: flex;
+ height: calc(100vh - 60px);
+ width: 100%;
+
+ .chat-title {
+ // prevents chat header jumping on when the user avatar loads
+ height: 28px;
+ }
+
+ .chat-view-inner {
+ height: auto;
+ width: 100%;
+ overflow: visible;
+ display: flex;
+ margin: 0.5em 0.5em 0 0.5em;
+ }
+
+ .chat-view-body {
+ background-color: var(--chatBg, $fallback--bg);
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ overflow: visible;
+ min-height: 100%;
+ margin: 0 0 0 0;
+ border-radius: 10px 10px 0 0;
+ border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0 ;
+
+ &::after {
+ border-radius: 0;
+ }
+ }
+
+ .scrollable-message-list {
+ padding: 0 0.8em;
+ height: 100%;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .footer {
+ position: sticky;
+ bottom: 0;
+ }
+
+ .chat-view-heading {
+ align-items: center;
+ justify-content: space-between;
+ top: 50px;
+ display: flex;
+ z-index: 2;
+ position: sticky;
+ overflow: hidden;
+ }
+
+ .go-back-button {
+ cursor: pointer;
+ margin-right: 1.4em;
+
+ i {
+ display: flex;
+ align-items: center;
+ }
+ }
+
+ .jump-to-bottom-button {
+ width: 2.5em;
+ height: 2.5em;
+ border-radius: 100%;
+ position: absolute;
+ right: 1.3em;
+ top: -3.2em;
+ background-color: $fallback--fg;
+ background-color: var(--btn, $fallback--fg);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.3), 0px 2px 4px rgba(0, 0, 0, 0.3);
+ z-index: 10;
+ transition: 0.35s all;
+ transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
+ opacity: 0;
+ visibility: hidden;
+ cursor: pointer;
+
+ &.visible {
+ opacity: 1;
+ visibility: visible;
+ }
+
+ i {
+ font-size: 1em;
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ }
+
+ .unread-message-count {
+ font-size: 0.8em;
+ left: 50%;
+ transform: translate(-50%, 0);
+ border-radius: 100%;
+ margin-top: -1rem;
+ padding: 0;
+ }
+
+ .chat-loading-error {
+ width: 100%;
+ display: flex;
+ align-items: flex-end;
+ height: 100%;
+
+ .error {
+ width: 100%;
+ }
+ }
+ }
+
+ @media all and (max-width: 800px) {
+ height: 100%;
+ overflow: hidden;
+
+ .chat-view-inner {
+ overflow: hidden;
+ height: 100%;
+ margin-top: 0;
+ margin-left: 0;
+ margin-right: 0;
+ }
+
+ .chat-view-body {
+ display: flex;
+ min-height: auto;
+ overflow: hidden;
+ height: 100%;
+ margin: 0;
+ border-radius: 0;
+ }
+
+ .chat-view-heading {
+ position: static;
+ z-index: 9999;
+ top: 0;
+ margin-top: 0;
+ border-radius: 0;
+ }
+
+ .scrollable-message-list {
+ display: unset;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ .footer {
+ position: sticky;
+ bottom: auto;
+ }
+ }
+}
diff --git a/src/components/chat/chat.vue b/src/components/chat/chat.vue
new file mode 100644
index 00000000..2e4538c8
--- /dev/null
+++ b/src/components/chat/chat.vue
@@ -0,0 +1,100 @@
+<template>
+ <div class="chat-view">
+ <div class="chat-view-inner">
+ <div
+ id="nav"
+ ref="inner"
+ class="panel-default panel chat-view-body"
+ >
+ <div
+ ref="header"
+ class="panel-heading chat-view-heading mobile-hidden"
+ >
+ <a
+ class="go-back-button"
+ @click="goBack"
+ >
+ <i class="button-icon icon-left-open" />
+ </a>
+ <div class="title text-center">
+ <ChatTitle
+ :user="recipient"
+ :with-avatar="true"
+ />
+ </div>
+ </div>
+ <template>
+ <div
+ ref="scrollable"
+ class="scrollable-message-list"
+ :style="{ height: scrollableContainerHeight }"
+ @scroll="handleScroll"
+ >
+ <template v-if="!errorLoadingChat">
+ <ChatMessage
+ v-for="chatViewItem in chatViewItems"
+ :key="chatViewItem.id"
+ :author="recipient"
+ :chat-view-item="chatViewItem"
+ :hovered-message-chain="chatViewItem.messageChainId === hoveredMessageChainId"
+ @hover="onMessageHover"
+ />
+ </template>
+ <div
+ v-else
+ class="chat-loading-error"
+ >
+ <div class="alert error">
+ {{ $t('chats.error_loading_chat') }}
+ </div>
+ </div>
+ </div>
+ <div
+ ref="footer"
+ class="panel-body footer"
+ >
+ <div
+ class="jump-to-bottom-button"
+ :class="{ 'visible': jumpToBottomButtonVisible }"
+ @click="scrollDown({ behavior: 'smooth' })"
+ >
+ <i class="icon-down-open">
+ <div
+ v-if="newMessageCount"
+ class="badge badge-notification unread-chat-count unread-message-count"
+ >
+ {{ newMessageCount }}
+ </div>
+ </i>
+ </div>
+ <PostStatusForm
+ :disable-subject="true"
+ :disable-scope-selector="true"
+ :disable-notice="true"
+ :disable-lock-warning="true"
+ :disable-polls="true"
+ :disable-sensitivity-checkbox="true"
+ :disable-submit="errorLoadingChat || !currentChat"
+ :disable-preview="true"
+ :post-handler="sendMessage"
+ :submit-on-enter="!mobileLayout"
+ :preserve-focus="!mobileLayout"
+ :auto-focus="!mobileLayout"
+ :placeholder="formPlaceholder"
+ :file-limit="1"
+ max-height="160"
+ emoji-picker-placement="top"
+ @resize="handleResize"
+ />
+ </div>
+ </template>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script src="./chat.js"></script>
+<style lang="scss">
+@import '../../_variables.scss';
+@import './chat.scss';
+</style>
diff --git a/src/components/chat/chat_layout_utils.js b/src/components/chat/chat_layout_utils.js
new file mode 100644
index 00000000..609dc0c9
--- /dev/null
+++ b/src/components/chat/chat_layout_utils.js
@@ -0,0 +1,26 @@
+// Captures a scroll position
+export const getScrollPosition = (el) => {
+ return {
+ scrollTop: el.scrollTop,
+ scrollHeight: el.scrollHeight,
+ offsetHeight: el.offsetHeight
+ }
+}
+
+// A helper function that is used to keep the scroll position fixed as the new elements are added to the top
+// Takes two scroll positions, before and after the update.
+export const getNewTopPosition = (previousPosition, newPosition) => {
+ return previousPosition.scrollTop + (newPosition.scrollHeight - previousPosition.scrollHeight)
+}
+
+export const isBottomedOut = (el, offset = 0) => {
+ if (!el) { return }
+ const scrollHeight = el.scrollTop + offset
+ const totalHeight = el.scrollHeight - el.offsetHeight
+ return totalHeight <= scrollHeight
+}
+
+// Height of the scrollable container. The dynamic height is needed to ensure the mobile browser panel doesn't overlap or hide the posting form.
+export const scrollableContainerHeight = (inner, header, footer) => {
+ return inner.offsetHeight - header.clientHeight - footer.clientHeight
+}
diff --git a/src/components/chat_list/chat_list.js b/src/components/chat_list/chat_list.js
new file mode 100644
index 00000000..95708d1d
--- /dev/null
+++ b/src/components/chat_list/chat_list.js
@@ -0,0 +1,37 @@
+import { mapState, mapGetters } from 'vuex'
+import ChatListItem from '../chat_list_item/chat_list_item.vue'
+import ChatNew from '../chat_new/chat_new.vue'
+import List from '../list/list.vue'
+
+const ChatList = {
+ components: {
+ ChatListItem,
+ List,
+ ChatNew
+ },
+ computed: {
+ ...mapState({
+ currentUser: state => state.users.currentUser
+ }),
+ ...mapGetters(['sortedChatList'])
+ },
+ data () {
+ return {
+ isNew: false
+ }
+ },
+ created () {
+ this.$store.dispatch('fetchChats', { latest: true })
+ },
+ methods: {
+ cancelNewChat () {
+ this.isNew = false
+ this.$store.dispatch('fetchChats', { latest: true })
+ },
+ newChat () {
+ this.isNew = true
+ }
+ }
+}
+
+export default ChatList
diff --git a/src/components/chat_list/chat_list.vue b/src/components/chat_list/chat_list.vue
new file mode 100644
index 00000000..17e2f795
--- /dev/null
+++ b/src/components/chat_list/chat_list.vue
@@ -0,0 +1,64 @@
+<template>
+ <div v-if="isNew">
+ <ChatNew @cancel="cancelNewChat" />
+ </div>
+ <div
+ v-else
+ class="chat-list panel panel-default"
+ >
+ <div class="panel-heading">
+ <span class="title">
+ {{ $t("chats.chats") }}
+ </span>
+ <button @click="newChat">
+ {{ $t("chats.new") }}
+ </button>
+ </div>
+ <div class="panel-body">
+ <div
+ v-if="sortedChatList.length > 0"
+ class="timeline"
+ >
+ <List :items="sortedChatList">
+ <template
+ slot="item"
+ slot-scope="{item}"
+ >
+ <ChatListItem
+ :key="item.id"
+ :compact="false"
+ :chat="item"
+ />
+ </template>
+ </List>
+ </div>
+ <div
+ v-else
+ class="emtpy-chat-list-alert"
+ >
+ <span>{{ $t('chats.empty_chat_list_placeholder') }}</span>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script src="./chat_list.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.chat-list {
+ min-height: 25em;
+ margin-bottom: 0;
+}
+
+.emtpy-chat-list-alert {
+ padding: 3em;
+ font-size: 1.2em;
+ display: flex;
+ justify-content: center;
+ color: $fallback--text;
+ color: var(--faint, $fallback--text);
+}
+
+</style>
diff --git a/src/components/chat_list_item/chat_list_item.js b/src/components/chat_list_item/chat_list_item.js
new file mode 100644
index 00000000..bee1ad53
--- /dev/null
+++ b/src/components/chat_list_item/chat_list_item.js
@@ -0,0 +1,67 @@
+import { mapState } from 'vuex'
+import StatusContent from '../status_content/status_content.vue'
+import fileType from 'src/services/file_type/file_type.service'
+import UserAvatar from '../user_avatar/user_avatar.vue'
+import AvatarList from '../avatar_list/avatar_list.vue'
+import Timeago from '../timeago/timeago.vue'
+import ChatTitle from '../chat_title/chat_title.vue'
+
+const ChatListItem = {
+ name: 'ChatListItem',
+ props: [
+ 'chat'
+ ],
+ components: {
+ UserAvatar,
+ AvatarList,
+ Timeago,
+ ChatTitle,
+ StatusContent
+ },
+ computed: {
+ ...mapState({
+ currentUser: state => state.users.currentUser
+ }),
+ attachmentInfo () {
+ if (this.chat.lastMessage.attachments.length === 0) { return }
+
+ const types = this.chat.lastMessage.attachments.map(file => fileType.fileType(file.mimetype))
+ if (types.includes('video')) {
+ return this.$t('file_type.video')
+ } else if (types.includes('audio')) {
+ return this.$t('file_type.audio')
+ } else if (types.includes('image')) {
+ return this.$t('file_type.image')
+ } else {
+ return this.$t('file_type.file')
+ }
+ },
+ messageForStatusContent () {
+ const message = this.chat.lastMessage
+ const isYou = message && message.account_id === this.currentUser.id
+ const content = message ? (this.attachmentInfo || message.content) : ''
+ const messagePreview = isYou ? `<i>${this.$t('chats.you')}</i> ${content}` : content
+ return {
+ summary: '',
+ statusnet_html: messagePreview,
+ text: messagePreview,
+ attachments: []
+ }
+ }
+ },
+ methods: {
+ openChat (_e) {
+ if (this.chat.id) {
+ this.$router.push({
+ name: 'chat',
+ params: {
+ username: this.currentUser.screen_name,
+ recipient_id: this.chat.account.id
+ }
+ })
+ }
+ }
+ }
+}
+
+export default ChatListItem
diff --git a/src/components/chat_list_item/chat_list_item.scss b/src/components/chat_list_item/chat_list_item.scss
new file mode 100644
index 00000000..9e97b28e
--- /dev/null
+++ b/src/components/chat_list_item/chat_list_item.scss
@@ -0,0 +1,94 @@
+.chat-list-item {
+ display: flex;
+ flex-direction: row;
+ padding: 0.75em;
+ height: 5em;
+ overflow: hidden;
+ box-sizing: border-box;
+ cursor: pointer;
+
+ :focus {
+ outline: none;
+ }
+
+ &:hover {
+ background-color: var(--selectedPost, $fallback--lightBg);
+ box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.1);
+ }
+
+ .chat-list-item-left {
+ margin-right: 1em;
+ }
+
+ .chat-list-item-center {
+ width: 100%;
+ box-sizing: border-box;
+ overflow: hidden;
+ word-wrap: break-word;
+ }
+
+ .heading {
+ width: 100%;
+ display: inline-flex;
+ justify-content: space-between;
+ line-height: 1em;
+ }
+
+ .heading-right {
+ white-space: nowrap;
+ }
+
+ .name-and-account-name {
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ flex-shrink: 1;
+ line-height: 1.4em;
+ }
+
+ .chat-preview {
+ display: inline-flex;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ margin: 0.35em 0;
+ color: $fallback--text;
+ color: var(--faint, $fallback--text);
+ width: 100%;
+ }
+
+ a {
+ color: var(--faintLink, $fallback--link);
+ text-decoration: none;
+ pointer-events: none;
+ }
+
+ &:hover .animated.avatar {
+ canvas {
+ display: none;
+ }
+ img {
+ visibility: visible;
+ }
+ }
+
+ .Avatar {
+ border-radius: $fallback--avatarAltRadius;
+ border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+ }
+
+ .StatusContent {
+ img.emoji {
+ width: 1.4em;
+ height: 1.4em;
+ }
+ }
+
+ .time-wrapper {
+ line-height: 1.4em;
+ }
+
+ .single-line {
+ padding-right: 1em;
+ }
+}
diff --git a/src/components/chat_list_item/chat_list_item.vue b/src/components/chat_list_item/chat_list_item.vue
new file mode 100644
index 00000000..1f8ecdf6
--- /dev/null
+++ b/src/components/chat_list_item/chat_list_item.vue
@@ -0,0 +1,52 @@
+<template>
+ <div
+ class="chat-list-item"
+ @click.capture.prevent="openChat"
+ >
+ <div class="chat-list-item-left">
+ <UserAvatar
+ :user="chat.account"
+ height="48px"
+ width="48px"
+ />
+ </div>
+ <div class="chat-list-item-center">
+ <div class="heading">
+ <span
+ v-if="chat.account"
+ class="name-and-account-name"
+ >
+ <ChatTitle
+ :user="chat.account"
+ />
+ </span>
+ <span class="heading-right" />
+ </div>
+ <div class="chat-preview">
+ <StatusContent
+ :status="messageForStatusContent"
+ :single-line="true"
+ />
+ <div
+ v-if="chat.unread > 0"
+ class="badge badge-notification unread-chat-count"
+ >
+ {{ chat.unread }}
+ </div>
+ </div>
+ </div>
+ <div class="time-wrapper">
+ <Timeago
+ :time="chat.updated_at"
+ :auto-update="60"
+ />
+ </div>
+ </div>
+</template>
+
+<script src="./chat_list_item.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+@import './chat_list_item.scss';
+</style>
diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
new file mode 100644
index 00000000..be4a7c89
--- /dev/null
+++ b/src/components/chat_message/chat_message.js
@@ -0,0 +1,96 @@
+import { mapState, mapGetters } from 'vuex'
+import Popover from '../popover/popover.vue'
+import Attachment from '../attachment/attachment.vue'
+import UserAvatar from '../user_avatar/user_avatar.vue'
+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'
+
+const ChatMessage = {
+ name: 'ChatMessage',
+ props: [
+ 'author',
+ 'edited',
+ 'noHeading',
+ 'chatViewItem',
+ 'hoveredMessageChain'
+ ],
+ components: {
+ Popover,
+ Attachment,
+ StatusContent,
+ UserAvatar,
+ Gallery,
+ LinkPreview,
+ ChatMessageDate
+ },
+ computed: {
+ // Returns HH:MM (hours and minutes) in local time.
+ createdAt () {
+ const time = this.chatViewItem.data.created_at
+ return time.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false })
+ },
+ isCurrentUser () {
+ return this.message.account_id === this.currentUser.id
+ },
+ 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'
+ },
+ messageForStatusContent () {
+ return {
+ summary: '',
+ statusnet_html: this.message.content,
+ text: this.message.content,
+ attachments: this.message.attachments
+ }
+ },
+ hasAttachment () {
+ return this.message.attachments.length > 0
+ },
+ ...mapState({
+ betterShadow: state => state.interface.browserSupport.cssFilter,
+ currentUser: state => state.users.currentUser,
+ restrictedNicknames: state => state.instance.restrictedNicknames
+ }),
+ popoverMarginStyle () {
+ if (this.isCurrentUser) {
+ return {}
+ } else {
+ return { left: 50 }
+ }
+ },
+ ...mapGetters(['mergedConfig', 'findUser'])
+ },
+ data () {
+ return {
+ hovered: false,
+ menuOpened: false
+ }
+ },
+ methods: {
+ onHover (bool) {
+ this.$emit('hover', { isHovered: bool, messageChainId: this.chatViewItem.messageChainId })
+ },
+ async deleteMessage () {
+ const confirmed = window.confirm(this.$t('chats.delete_confirm'))
+ if (confirmed) {
+ await this.$store.dispatch('deleteChatMessage', {
+ messageId: this.chatViewItem.data.id,
+ chatId: this.chatViewItem.data.chat_id
+ })
+ }
+ this.hovered = false
+ this.menuOpened = false
+ }
+ }
+}
+
+export default ChatMessage
diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss
new file mode 100644
index 00000000..7d4ff60c
--- /dev/null
+++ b/src/components/chat_message/chat_message.scss
@@ -0,0 +1,164 @@
+@import '../../_variables.scss';
+
+.chat-message-wrapper {
+ &.hovered-message-chain {
+ .animated.Avatar {
+ canvas {
+ display: none;
+ }
+ img {
+ visibility: visible;
+ }
+ }
+ }
+
+ .chat-message-menu {
+ transition: opacity 0.1s;
+ opacity: 0;
+ position: absolute;
+ top: -0.8em;
+
+ button {
+ padding-top: 0.2em;
+ padding-bottom: 0.2em;
+ }
+ }
+
+ .icon-ellipsis {
+ cursor: pointer;
+
+ &:hover, .extra-button-popover.open & {
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ }
+
+ border-radius: $fallback--chatMessageRadius;
+ border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius);
+ }
+
+ .popover {
+ width: 12em;
+ }
+
+ .chat-message {
+ display: flex;
+ padding-bottom: 0.5em;
+ }
+
+ .avatar-wrapper {
+ margin-right: 0.72em;
+ width: 32px;
+ }
+
+ .link-preview, .attachments {
+ margin-bottom: 1em;
+ }
+
+ .chat-message-inner {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ max-width: 80%;
+ min-width: 10em;
+ width: 100%;
+
+ &.with-media {
+ width: 100%;
+
+ .gallery-row {
+ overflow: hidden;
+ }
+
+ .status {
+ width: 100%;
+ }
+ }
+ }
+
+ .status {
+ border-radius: $fallback--chatMessageRadius;
+ border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius);
+ display: flex;
+ padding: 0.75em;
+ }
+
+ .created-at {
+ position: relative;
+ float: right;
+ font-size: 0.8em;
+ margin: -1em 0 -0.5em 0;
+ font-style: italic;
+ opacity: 0.8;
+ }
+
+ .without-attachment {
+ .status-content {
+ &::after {
+ margin-right: 5.4em;
+ content: " ";
+ display: inline-block;
+ }
+ }
+ }
+
+ .incoming {
+ a {
+ color: var(--chatMessageIncomingLink, $fallback--link);
+ }
+
+ .status {
+ color: var(--chatMessageIncomingText, $fallback--text);
+ background-color: var(--chatMessageIncomingBg, $fallback--bg);
+ border: 1px solid var(--chatMessageIncomingBorder, --border);
+ }
+
+ .created-at {
+ a {
+ color: var(--chatMessageIncomingText, $fallback--text);
+ }
+ }
+
+ .chat-message-menu {
+ left: 0.4rem;
+ }
+ }
+
+ .outgoing {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-content: end;
+ justify-content: flex-end;
+
+ a {
+ color: var(--chatMessageOutgoingLink, $fallback--link);
+ }
+
+ .status {
+ color: var(--chatMessageOutgoingText, $fallback--text);
+ background-color: var(--chatMessageOutgoingBg, $fallback--lightBg);
+ border: 1px solid var(--chatMessageOutgoingBorder, --lightBg);
+ }
+
+ .chat-message-inner {
+ align-items: flex-end;
+ }
+
+ .chat-message-menu {
+ right: 0.4rem;
+ }
+ }
+
+ .visible {
+ opacity: 1;
+ }
+}
+
+.chat-message-date-separator {
+ text-align: center;
+ margin: 1.4em 0;
+ font-size: 0.9em;
+ user-select: none;
+ color: $fallback--text;
+ color: var(--faintedText, $fallback--text);
+}
diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue
new file mode 100644
index 00000000..e923d694
--- /dev/null
+++ b/src/components/chat_message/chat_message.vue
@@ -0,0 +1,99 @@
+<template>
+ <div
+ v-if="isMessage"
+ class="chat-message-wrapper"
+ :class="{ 'hovered-message-chain': hoveredMessageChain }"
+ @mouseover="onHover(true)"
+ @mouseleave="onHover(false)"
+ >
+ <div
+ class="chat-message"
+ :class="[{ 'outgoing': isCurrentUser, 'incoming': !isCurrentUser }]"
+ >
+ <div
+ v-if="!isCurrentUser"
+ class="avatar-wrapper"
+ >
+ <router-link
+ v-if="chatViewItem.isHead"
+ :to="userProfileLink"
+ >
+ <UserAvatar
+ :compact="true"
+ :better-shadow="betterShadow"
+ :user="author"
+ />
+ </router-link>
+ </div>
+ <div class="chat-message-inner">
+ <div
+ class="status-body"
+ :style="{ 'min-width': message.attachment ? '80%' : '' }"
+ >
+ <div
+ class="media status"
+ :class="{ 'without-attachment': !hasAttachment }"
+ style="position: relative"
+ @mouseenter="hovered = true"
+ @mouseleave="hovered = false"
+ >
+ <div
+ class="chat-message-menu"
+ :class="{ 'visible': hovered || menuOpened }"
+ >
+ <Popover
+ trigger="click"
+ placement="top"
+ :bound-to-selector="isCurrentUser ? '' : '.scrollable-message-list'"
+ :bound-to="{ x: 'container' }"
+ :margin="popoverMarginStyle"
+ @show="menuOpened = true"
+ @close="menuOpened = false"
+ >
+ <div slot="content">
+ <div class="dropdown-menu">
+ <button
+ class="dropdown-item dropdown-item-icon"
+ @click="deleteMessage"
+ >
+ <i class="icon-cancel" /> {{ $t("chats.delete") }}
+ </button>
+ </div>
+ </div>
+ <button
+ slot="trigger"
+ :title="$t('chats.more')"
+ >
+ <i class="icon-ellipsis" />
+ </button>
+ </Popover>
+ </div>
+ <StatusContent
+ :status="messageForStatusContent"
+ :full-content="true"
+ >
+ <span
+ slot="footer"
+ class="created-at"
+ >
+ {{ createdAt }}
+ </span>
+ </StatusContent>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div
+ v-else
+ class="chat-message-date-separator"
+ >
+ <ChatMessageDate :date="chatViewItem.date" />
+ </div>
+</template>
+
+<script src="./chat_message.js" ></script>
+<style lang="scss">
+@import './chat_message.scss';
+
+</style>
diff --git a/src/components/chat_message_date/chat_message_date.vue b/src/components/chat_message_date/chat_message_date.vue
new file mode 100644
index 00000000..79c346b6
--- /dev/null
+++ b/src/components/chat_message_date/chat_message_date.vue
@@ -0,0 +1,24 @@
+<template>
+ <time>
+ {{ displayDate }}
+ </time>
+</template>
+
+<script>
+export default {
+ name: 'Timeago',
+ props: ['date'],
+ computed: {
+ displayDate () {
+ const today = new Date()
+ today.setHours(0, 0, 0, 0)
+
+ if (this.date.getTime() === today.getTime()) {
+ return this.$t('display_date.today')
+ } else {
+ return this.date.toLocaleDateString('en', { day: 'numeric', month: 'long' })
+ }
+ }
+ }
+}
+</script>
diff --git a/src/components/chat_new/chat_new.js b/src/components/chat_new/chat_new.js
new file mode 100644
index 00000000..d023efc0
--- /dev/null
+++ b/src/components/chat_new/chat_new.js
@@ -0,0 +1,73 @@
+import { mapState, mapGetters } from 'vuex'
+import BasicUserCard from '../basic_user_card/basic_user_card.vue'
+import UserAvatar from '../user_avatar/user_avatar.vue'
+
+const chatNew = {
+ components: {
+ BasicUserCard,
+ UserAvatar
+ },
+ data () {
+ return {
+ suggestions: [],
+ userIds: [],
+ loading: false,
+ query: ''
+ }
+ },
+ async created () {
+ const { chats } = await this.backendInteractor.chats()
+ chats.forEach(chat => this.suggestions.push(chat.account))
+ },
+ computed: {
+ users () {
+ return this.userIds.map(userId => this.findUser(userId))
+ },
+ availableUsers () {
+ if (this.query.length !== 0) {
+ return this.users
+ } else {
+ return this.suggestions
+ }
+ },
+ ...mapState({
+ currentUser: state => state.users.currentUser,
+ backendInteractor: state => state.api.backendInteractor
+ }),
+ ...mapGetters(['findUser'])
+ },
+ methods: {
+ goBack () {
+ this.$emit('cancel')
+ },
+ goToChat (user) {
+ this.$router.push({ name: 'chat', params: { recipient_id: user.id } })
+ },
+ onInput () {
+ this.search(this.query)
+ },
+ addUser (user) {
+ this.selectedUserIds.push(user.id)
+ this.query = ''
+ },
+ removeUser (userId) {
+ this.selectedUserIds = this.selectedUserIds.filter(id => id !== userId)
+ },
+ search (query) {
+ if (!query) {
+ this.loading = false
+ return
+ }
+
+ this.loading = true
+ this.userIds = []
+ this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts' })
+ .then(data => {
+ this.loading = false
+ this.userIds = data.accounts.map(a => a.id)
+ })
+ }
+ }
+}
+
+export default chatNew
diff --git a/src/components/chat_new/chat_new.scss b/src/components/chat_new/chat_new.scss
new file mode 100644
index 00000000..11305444
--- /dev/null
+++ b/src/components/chat_new/chat_new.scss
@@ -0,0 +1,29 @@
+.chat-new {
+ .input-wrap {
+ display: flex;
+ margin: 0.7em 0.5em 0.7em 0.5em;
+
+ input {
+ width: 100%;
+ }
+ }
+
+ .icon-search {
+ font-size: 1.5em;
+ float: right;
+ margin-right: 0.3em;
+ }
+
+ .member-list {
+ padding-bottom: 0.7rem;
+ }
+
+ .basic-user-card:hover {
+ cursor: pointer;
+ background-color: var(--selectedPost, $fallback--lightBg);
+ }
+
+ .go-back-button {
+ cursor: pointer;
+ }
+}
diff --git a/src/components/chat_new/chat_new.vue b/src/components/chat_new/chat_new.vue
new file mode 100644
index 00000000..3333dbf9
--- /dev/null
+++ b/src/components/chat_new/chat_new.vue
@@ -0,0 +1,46 @@
+<template>
+ <div
+ id="nav"
+ class="panel-default panel chat-new"
+ >
+ <div
+ ref="header"
+ class="panel-heading"
+ >
+ <a
+ class="go-back-button"
+ @click="goBack"
+ >
+ <i class="button-icon icon-left-open" />
+ </a>
+ </div>
+ <div class="input-wrap">
+ <div class="input-search">
+ <i class="button-icon icon-search" />
+ </div>
+ <input
+ ref="search"
+ v-model="query"
+ placeholder="Search people"
+ @input="onInput"
+ >
+ </div>
+ <div class="member-list">
+ <div
+ v-for="user in availableUsers"
+ :key="user.id"
+ class="member"
+ >
+ <div @click.capture.prevent="goToChat(user)">
+ <BasicUserCard :user="user" />
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script src="./chat_new.js"></script>
+<style lang="scss">
+@import '../../_variables.scss';
+@import './chat_new.scss';
+</style>
diff --git a/src/components/chat_panel/chat_panel.vue b/src/components/chat_panel/chat_panel.vue
index 3677722f..ca529b5a 100644
--- a/src/components/chat_panel/chat_panel.vue
+++ b/src/components/chat_panel/chat_panel.vue
@@ -10,7 +10,7 @@
@click.stop.prevent="togglePanel"
>
<div class="title">
- <span>{{ $t('chat.title') }}</span>
+ <span>{{ $t('shoutbox.title') }}</span>
<i
v-if="floating"
class="icon-cancel"
@@ -64,7 +64,7 @@
>
<div class="title">
<i class="icon-comment-empty" />
- {{ $t('chat.title') }}
+ {{ $t('shoutbox.title') }}
</div>
</div>
</div>
@@ -84,54 +84,56 @@
max-width: 25em;
}
-.chat-heading {
- cursor: pointer;
- .icon-comment-empty {
- color: $fallback--text;
- color: var(--text, $fallback--text);
+.chat-panel {
+ .chat-heading {
+ cursor: pointer;
+ .icon-comment-empty {
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ }
}
-}
-
-.chat-window {
- overflow-y: auto;
- overflow-x: hidden;
- max-height: 20em;
-}
-.chat-window-container {
- height: 100%;
-}
+ .chat-window {
+ overflow-y: auto;
+ overflow-x: hidden;
+ max-height: 20em;
+ }
-.chat-message {
- display: flex;
- padding: 0.2em 0.5em
-}
+ .chat-window-container {
+ height: 100%;
+ }
-.chat-avatar {
- img {
- height: 24px;
- width: 24px;
- border-radius: $fallback--avatarRadius;
- border-radius: var(--avatarRadius, $fallback--avatarRadius);
- margin-right: 0.5em;
- margin-top: 0.25em;
+ .chat-message {
+ display: flex;
+ padding: 0.2em 0.5em
}
-}
-.chat-input {
- display: flex;
- textarea {
- flex: 1;
- margin: 0.6em;
- min-height: 3.5em;
- resize: none;
+ .chat-avatar {
+ img {
+ height: 24px;
+ width: 24px;
+ border-radius: $fallback--avatarRadius;
+ border-radius: var(--avatarRadius, $fallback--avatarRadius);
+ margin-right: 0.5em;
+ margin-top: 0.25em;
+ }
}
-}
-.chat-panel {
- .title {
+ .chat-input {
display: flex;
- justify-content: space-between;
+ textarea {
+ flex: 1;
+ margin: 0.6em;
+ min-height: 3.5em;
+ resize: none;
+ }
+ }
+
+ .chat-panel {
+ .title {
+ display: flex;
+ justify-content: space-between;
+ }
}
}
</style>
diff --git a/src/components/chat_title/chat_title.js b/src/components/chat_title/chat_title.js
new file mode 100644
index 00000000..e424bb1f
--- /dev/null
+++ b/src/components/chat_title/chat_title.js
@@ -0,0 +1,26 @@
+import Vue from 'vue'
+import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import UserAvatar from '../user_avatar/user_avatar.vue'
+
+export default Vue.component('chat-title', {
+ name: 'ChatTitle',
+ components: {
+ UserAvatar
+ },
+ props: [
+ 'user', 'withAvatar'
+ ],
+ computed: {
+ title () {
+ return this.user ? this.user.screen_name : ''
+ },
+ 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
new file mode 100644
index 00000000..b16ed39d
--- /dev/null
+++ b/src/components/chat_title/chat_title.vue
@@ -0,0 +1,67 @@
+<template>
+ <!-- eslint-disable vue/no-v-html -->
+ <div
+ class="chat-title"
+ :title="title"
+ >
+ <router-link
+ v-if="withAvatar && user"
+ :to="getUserProfileLink(user)"
+ >
+ <UserAvatar
+ :user="user"
+ width="23px"
+ height="23px"
+ />
+ </router-link>
+ <span
+ class="username"
+ v-html="htmlTitle"
+ />
+ </div>
+ <!-- eslint-enable vue/no-v-html -->
+</template>
+
+<script src="./chat_title.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.chat-title {
+ display: flex;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ align-items: center;
+
+ .username {
+ max-width: 100%;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ display: inline;
+ word-wrap: break-word;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ .emoji {
+ width: 14px;
+ height: 14px;
+ vertical-align: middle;
+ object-fit: contain
+ }
+ }
+
+ .Avatar {
+ width: 23px;
+ height: 23px;
+ margin-right: 0.5em;
+
+ border-radius: $fallback--avatarAltRadius;
+ border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+
+ &.animated::before {
+ display: none;
+ }
+ }
+}
+</style>
diff --git a/src/components/checkbox/checkbox.vue b/src/components/checkbox/checkbox.vue
index 03375b2f..d28c2cfd 100644
--- a/src/components/checkbox/checkbox.vue
+++ b/src/components/checkbox/checkbox.vue
@@ -52,7 +52,7 @@ export default {
right: 0;
top: 0;
display: block;
- content: '✔';
+ content: '✓';
transition: color 200ms;
width: 1.1em;
height: 1.1em;
diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue
index 2e48240a..997a4d10 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -1,7 +1,7 @@
<template>
<div
- class="timeline panel-default"
- :class="[isExpanded ? 'panel' : 'panel-disabled']"
+ class="Conversation"
+ :class="{ '-expanded' : isExpanded, 'panel' : isExpanded }"
>
<div
v-if="isExpanded"
@@ -28,7 +28,7 @@
:replies="getReplies(status.id)"
:in-profile="inProfile"
:profile-user-id="profileUserId"
- class="status-fadein panel-body"
+ class="conversation-status status-fadein panel-body"
@goto="setHighlight"
@toggleExpanded="toggleExpanded"
/>
@@ -40,14 +40,27 @@
<style lang="scss">
@import '../../_variables.scss';
-.timeline {
- .panel-disabled {
- .status-el {
- border-left: none;
- border-bottom-width: 1px;
- border-bottom-style: solid;
+.Conversation {
+ .conversation-status {
+ border-left: none;
+ border-bottom-width: 1px;
+ border-bottom-style: solid;
+ border-bottom-color: var(--border, $fallback--border);
+ border-radius: 0;
+ }
+
+ &.-expanded {
+ .conversation-status {
+ border-color: $fallback--border;
border-color: var(--border, $fallback--border);
- border-radius: 0;
+ border-left: 4px solid $fallback--cRed;
+ border-left: 4px solid var(--cRed, $fallback--cRed);
+ }
+
+ .conversation-status:last-child {
+ border-bottom: none;
+ border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
+ border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
}
}
}
diff --git a/src/components/domain_mute_card/domain_mute_card.js b/src/components/domain_mute_card/domain_mute_card.js
index c8e838ba..f234dcb0 100644
--- a/src/components/domain_mute_card/domain_mute_card.js
+++ b/src/components/domain_mute_card/domain_mute_card.js
@@ -5,9 +5,20 @@ const DomainMuteCard = {
components: {
ProgressButton
},
+ computed: {
+ user () {
+ return this.$store.state.users.currentUser
+ },
+ muted () {
+ return this.user.domainMutes.includes(this.domain)
+ }
+ },
methods: {
unmuteDomain () {
return this.$store.dispatch('unmuteDomain', this.domain)
+ },
+ muteDomain () {
+ return this.$store.dispatch('muteDomain', this.domain)
}
}
}
diff --git a/src/components/domain_mute_card/domain_mute_card.vue b/src/components/domain_mute_card/domain_mute_card.vue
index 567d81c5..97aee243 100644
--- a/src/components/domain_mute_card/domain_mute_card.vue
+++ b/src/components/domain_mute_card/domain_mute_card.vue
@@ -4,6 +4,7 @@
{{ domain }}
</div>
<ProgressButton
+ v-if="muted"
:click="unmuteDomain"
class="btn btn-default"
>
@@ -12,6 +13,16 @@
{{ $t('domain_mute_card.unmute_progress') }}
</template>
</ProgressButton>
+ <ProgressButton
+ v-else
+ :click="muteDomain"
+ class="btn btn-default"
+ >
+ {{ $t('domain_mute_card.mute') }}
+ <template slot="progress">
+ {{ $t('domain_mute_card.mute_progress') }}
+ </template>
+ </ProgressButton>
</div>
</template>
@@ -34,5 +45,9 @@
button {
width: 10em;
}
+
+ .autosuggest-results & {
+ padding-left: 1em;
+ }
}
</style>
diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js
index f4c3479c..f0123447 100644
--- a/src/components/emoji_input/emoji_input.js
+++ b/src/components/emoji_input/emoji_input.js
@@ -79,6 +79,20 @@ const EmojiInput = {
required: false,
type: Boolean,
default: false
+ },
+ placement: {
+ /**
+ * Forces the panel to take a specific position relative to the input element.
+ * The 'auto' placement chooses either bottom or top depending on which has the available space (when both have available space, bottom is preferred).
+ */
+ required: false,
+ type: String, // 'auto', 'top', 'bottom'
+ default: 'auto'
+ },
+ newlineOnCtrlEnter: {
+ required: false,
+ type: Boolean,
+ default: false
}
},
data () {
@@ -162,6 +176,11 @@ const EmojiInput = {
input.elm.removeEventListener('input', this.onInput)
}
},
+ watch: {
+ showSuggestions: function (newValue) {
+ this.$emit('shown', newValue)
+ }
+ },
methods: {
triggerShowPicker () {
this.showPicker = true
@@ -190,7 +209,7 @@ const EmojiInput = {
this.$emit('input', newValue)
this.caret = 0
},
- insert ({ insertion, keepOpen }) {
+ insert ({ insertion, keepOpen, surroundingSpace = true }) {
const before = this.value.substring(0, this.caret) || ''
const after = this.value.substring(this.caret) || ''
@@ -209,8 +228,8 @@ const EmojiInput = {
* them, masto seem to be rendering :emoji::emoji: correctly now so why not
*/
const isSpaceRegex = /\s/
- const spaceBefore = !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0 ? ' ' : ''
- const spaceAfter = !isSpaceRegex.exec(after[0]) && this.padEmoji ? ' ' : ''
+ const spaceBefore = (surroundingSpace && !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0) ? ' ' : ''
+ const spaceAfter = (surroundingSpace && !isSpaceRegex.exec(after[0]) && this.padEmoji) ? ' ' : ''
const newValue = [
before,
@@ -367,6 +386,18 @@ const EmojiInput = {
},
onKeyDown (e) {
const { ctrlKey, shiftKey, key } = e
+ if (this.newlineOnCtrlEnter && ctrlKey && key === 'Enter') {
+ this.insert({ insertion: '\n', surroundingSpace: false })
+ // Ensure only one new line is added on macos
+ e.stopPropagation()
+ e.preventDefault()
+
+ // Scroll the input element to the position of the cursor
+ this.$nextTick(() => {
+ this.input.elm.blur()
+ this.input.elm.focus()
+ })
+ }
// Disable suggestions hotkeys if suggestions are hidden
if (!this.temporarilyHideSuggestions) {
if (key === 'Tab') {
@@ -425,14 +456,29 @@ const EmojiInput = {
this.caret = selectionStart
},
resize () {
- const { panel, picker } = this.$refs
+ 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.elm
const offsetBottom = offsetTop + offsetHeight
- panel.style.top = offsetBottom + 'px'
- picker.$el.style.top = offsetBottom + 'px'
- picker.$el.style.bottom = 'auto'
+ 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.elm.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 e9ac09c3..b9a74572 100644
--- a/src/components/emoji_input/emoji_input.vue
+++ b/src/components/emoji_input/emoji_input.vue
@@ -29,7 +29,10 @@
class="autocomplete-panel"
:class="{ hide: !showSuggestions }"
>
- <div class="autocomplete-panel-body">
+ <div
+ ref="panel-body"
+ class="autocomplete-panel-body"
+ >
<div
v-for="(suggestion, index) in suggestions"
:key="index"
diff --git a/src/components/emoji_input/suggestor.js b/src/components/emoji_input/suggestor.js
index 15a71eff..8330345b 100644
--- a/src/components/emoji_input/suggestor.js
+++ b/src/components/emoji_input/suggestor.js
@@ -13,7 +13,7 @@ import { debounce } from 'lodash'
const debounceUserSearch = debounce((data, input) => {
data.updateUsersList(input)
-}, 500, { leading: true, trailing: false })
+}, 500)
export default data => input => {
const firstChar = input[0]
@@ -97,8 +97,8 @@ export const suggestUsers = data => input => {
replacement: '@' + screen_name + ' '
}))
- // BE search users if there are no matches
- if (newUsers.length === 0 && data.updateUsersList) {
+ // BE search users to get more comprehensive results
+ if (data.updateUsersList) {
debounceUserSearch(data, noPrefix)
}
return newUsers
diff --git a/src/components/emoji_reactions/emoji_reactions.js b/src/components/emoji_reactions/emoji_reactions.js
index ae7f53be..bb11b840 100644
--- a/src/components/emoji_reactions/emoji_reactions.js
+++ b/src/components/emoji_reactions/emoji_reactions.js
@@ -1,5 +1,5 @@
import UserAvatar from '../user_avatar/user_avatar.vue'
-import Popover from '../popover/popover.vue'
+import UserListPopover from '../user_list_popover/user_list_popover.vue'
const EMOJI_REACTION_COUNT_CUTOFF = 12
@@ -7,7 +7,7 @@ const EmojiReactions = {
name: 'EmojiReactions',
components: {
UserAvatar,
- Popover
+ UserListPopover
},
props: ['status'],
data: () => ({
diff --git a/src/components/emoji_reactions/emoji_reactions.vue b/src/components/emoji_reactions/emoji_reactions.vue
index bac4c605..2f14b5b2 100644
--- a/src/components/emoji_reactions/emoji_reactions.vue
+++ b/src/components/emoji_reactions/emoji_reactions.vue
@@ -1,44 +1,11 @@
<template>
<div class="emoji-reactions">
- <Popover
+ <UserListPopover
v-for="(reaction) in emojiReactions"
:key="reaction.name"
- trigger="hover"
- placement="top"
- :offset="{ y: 5 }"
+ :users="accountsForEmoji[reaction.name]"
>
- <div
- slot="content"
- class="reacted-users"
- >
- <div v-if="accountsForEmoji[reaction.name].length">
- <div
- v-for="(account) in accountsForEmoji[reaction.name]"
- :key="account.id"
- class="reacted-user"
- >
- <UserAvatar
- :user="account"
- class="avatar-small"
- :compact="true"
- />
- <div class="reacted-user-names">
- <!-- eslint-disable vue/no-v-html -->
- <span
- class="reacted-user-name"
- v-html="account.name_html"
- />
- <!-- eslint-enable vue/no-v-html -->
- <span class="reacted-user-screen-name">{{ account.screen_name }}</span>
- </div>
- </div>
- </div>
- <div v-else>
- <i class="icon-spin4 animate-spin" />
- </div>
- </div>
<button
- slot="trigger"
class="emoji-reaction btn btn-default"
:class="{ 'picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }"
@click="emojiOnClick(reaction.name, $event)"
@@ -47,7 +14,7 @@
<span class="reaction-emoji">{{ reaction.name }}</span>
<span>{{ reaction.count }}</span>
</button>
- </Popover>
+ </UserListPopover>
<a
v-if="tooManyReactions"
class="emoji-reaction-expand faint"
@@ -69,32 +36,6 @@
flex-wrap: wrap;
}
-.reacted-users {
- padding: 0.5em;
-}
-
-.reacted-user {
- padding: 0.25em;
- display: flex;
- flex-direction: row;
-
- .reacted-user-names {
- display: flex;
- flex-direction: column;
- margin-left: 0.5em;
- min-width: 5em;
-
- img {
- width: 1em;
- height: 1em;
- }
- }
-
- .reacted-user-screen-name {
- font-size: 9px;
- }
-}
-
.emoji-reaction {
padding: 0 0.5em;
margin-right: 0.5em;
diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js
index e4b19d01..5e0c36bb 100644
--- a/src/components/extra_buttons/extra_buttons.js
+++ b/src/components/extra_buttons/extra_buttons.js
@@ -34,6 +34,16 @@ const ExtraButtons = {
navigator.clipboard.writeText(this.statusLink)
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
+ },
+ bookmarkStatus () {
+ this.$store.dispatch('bookmark', { id: this.status.id })
+ .then(() => this.$emit('onSuccess'))
+ .catch(err => this.$emit('onError', err.error.error))
+ },
+ unbookmarkStatus () {
+ this.$store.dispatch('unbookmark', { id: this.status.id })
+ .then(() => this.$emit('onSuccess'))
+ .catch(err => this.$emit('onError', err.error.error))
}
},
computed: {
diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue
index bca93ea7..7a4e8642 100644
--- a/src/components/extra_buttons/extra_buttons.vue
+++ b/src/components/extra_buttons/extra_buttons.vue
@@ -3,6 +3,7 @@
trigger="click"
placement="top"
class="extra-button-popover"
+ :bound-to="{ x: 'container' }"
>
<div
slot="content"
@@ -40,6 +41,22 @@
<i class="icon-pin" /><span>{{ $t("status.unpin") }}</span>
</button>
<button
+ v-if="!status.bookmarked"
+ class="dropdown-item dropdown-item-icon"
+ @click.prevent="bookmarkStatus"
+ @click="close"
+ >
+ <i class="icon-bookmark-empty" /><span>{{ $t("status.bookmark") }}</span>
+ </button>
+ <button
+ v-if="status.bookmarked"
+ class="dropdown-item dropdown-item-icon"
+ @click.prevent="unbookmarkStatus"
+ @click="close"
+ >
+ <i class="icon-bookmark" /><span>{{ $t("status.unbookmark") }}</span>
+ </button>
+ <button
v-if="canDelete"
class="dropdown-item dropdown-item-icon"
@click.prevent="deleteStatus"
diff --git a/src/components/features_panel/features_panel.js b/src/components/features_panel/features_panel.js
index 5f80a079..620a85ea 100644
--- a/src/components/features_panel/features_panel.js
+++ b/src/components/features_panel/features_panel.js
@@ -1,6 +1,7 @@
const FeaturesPanel = {
computed: {
chat: function () { return this.$store.state.instance.chatAvailable },
+ pleromaChatMessages: function () { return this.$store.state.instance.pleromaChatMessagesAvailable },
gopher: function () { return this.$store.state.instance.gopherAvailable },
whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled },
mediaProxy: function () { return this.$store.state.instance.mediaProxyAvailable },
diff --git a/src/components/features_panel/features_panel.vue b/src/components/features_panel/features_panel.vue
index 3e5939a6..608b11c8 100644
--- a/src/components/features_panel/features_panel.vue
+++ b/src/components/features_panel/features_panel.vue
@@ -11,6 +11,9 @@
<li v-if="chat">
{{ $t('features_panel.chat') }}
</li>
+ <li v-if="pleromaChatMessages">
+ {{ $t('features_panel.pleroma_chat_messages') }}
+ </li>
<li v-if="gopher">
{{ $t('features_panel.gopher') }}
</li>
diff --git a/src/components/follow_card/follow_card.vue b/src/components/follow_card/follow_card.vue
index 76a70730..b503783f 100644
--- a/src/components/follow_card/follow_card.vue
+++ b/src/components/follow_card/follow_card.vue
@@ -2,7 +2,7 @@
<basic-user-card :user="user">
<div class="follow-card-content-container">
<span
- v-if="!noFollowsYou && relationship.followed_by"
+ v-if="isMe || (!noFollowsYou && relationship.followed_by)"
class="faint"
>
{{ isMe ? $t('user_card.its_you') : $t('user_card.follows_you') }}
@@ -15,7 +15,7 @@
<RemoteFollow :user="user" />
</div>
</template>
- <template v-else>
+ <template v-else-if="!isMe">
<FollowButton
:relationship="relationship"
:label-following="$t('user_card.follow_unfollow')"
diff --git a/src/components/gallery/gallery.vue b/src/components/gallery/gallery.vue
index 7abc2161..ca91c9c1 100644
--- a/src/components/gallery/gallery.vue
+++ b/src/components/gallery/gallery.vue
@@ -50,9 +50,7 @@
align-content: stretch;
}
- // FIXME: specificity problem with this and .attachments.attachment
- // we shouldn't have the need for .image here
- .attachment.image {
+ .gallery-row-inner .attachment {
margin: 0 0.5em 0 0;
flex-grow: 1;
height: 100%;
@@ -78,6 +76,7 @@
video,
canvas {
object-fit: contain;
+ height: 100%;
}
}
diff --git a/src/components/global_notice_list/global_notice_list.js b/src/components/global_notice_list/global_notice_list.js
new file mode 100644
index 00000000..3af29c23
--- /dev/null
+++ b/src/components/global_notice_list/global_notice_list.js
@@ -0,0 +1,15 @@
+
+const GlobalNoticeList = {
+ computed: {
+ notices () {
+ return this.$store.state.interface.globalNotices
+ }
+ },
+ methods: {
+ closeNotice (notice) {
+ this.$store.dispatch('removeGlobalNotice', notice)
+ }
+ }
+}
+
+export default GlobalNoticeList
diff --git a/src/components/global_notice_list/global_notice_list.vue b/src/components/global_notice_list/global_notice_list.vue
new file mode 100644
index 00000000..0e4285cc
--- /dev/null
+++ b/src/components/global_notice_list/global_notice_list.vue
@@ -0,0 +1,77 @@
+<template>
+ <div class="global-notice-list">
+ <div
+ v-for="(notice, index) in notices"
+ :key="index"
+ class="alert global-notice"
+ :class="{ ['global-' + notice.level]: true }"
+ >
+ <div class="notice-message">
+ {{ $t(notice.messageKey, notice.messageArgs) }}
+ </div>
+ <i
+ class="button-icon icon-cancel"
+ @click="closeNotice(notice)"
+ />
+ </div>
+ </div>
+</template>
+
+<script src="./global_notice_list.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.global-notice-list {
+ position: fixed;
+ top: 50px;
+ width: 100%;
+ pointer-events: none;
+ z-index: 1001;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+
+ .global-notice {
+ pointer-events: auto;
+ text-align: center;
+ width: 40em;
+ max-width: calc(100% - 3em);
+ display: flex;
+ padding-left: 1.5em;
+ line-height: 2em;
+ .notice-message {
+ flex: 1 1 100%;
+ }
+ i {
+ flex: 0 0;
+ width: 1.5em;
+ cursor: pointer;
+ }
+ }
+
+ .global-error {
+ background-color: var(--alertPopupError, $fallback--cRed);
+ color: var(--alertPopupErrorText, $fallback--text);
+ i {
+ color: var(--alertPopupErrorText, $fallback--text);
+ }
+ }
+
+ .global-warning {
+ background-color: var(--alertPopupWarning, $fallback--cOrange);
+ color: var(--alertPopupWarningText, $fallback--text);
+ i {
+ color: var(--alertPopupWarningText, $fallback--text);
+ }
+ }
+
+ .global-info {
+ background-color: var(--alertPopupNeutral, $fallback--fg);
+ color: var(--alertPopupNeutralText, $fallback--text);
+ i {
+ color: var(--alertPopupNeutralText, $fallback--text);
+ }
+ }
+}
+</style>
diff --git a/src/components/interface_language_switcher/interface_language_switcher.vue b/src/components/interface_language_switcher/interface_language_switcher.vue
index f5ace0cc..dd6800a3 100644
--- a/src/components/interface_language_switcher/interface_language_switcher.vue
+++ b/src/components/interface_language_switcher/interface_language_switcher.vue
@@ -32,7 +32,7 @@ import _ from 'lodash'
export default {
computed: {
languageCodes () {
- return Object.keys(languagesObject)
+ return languagesObject.languages
},
languageNames () {
@@ -43,7 +43,6 @@ export default {
get: function () { return this.$store.getters.mergedConfig.interfaceLanguage },
set: function (val) {
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
- this.$i18n.locale = val
}
}
},
diff --git a/src/components/media_modal/media_modal.js b/src/components/media_modal/media_modal.js
index abb18c7d..24764e80 100644
--- a/src/components/media_modal/media_modal.js
+++ b/src/components/media_modal/media_modal.js
@@ -84,10 +84,12 @@ const MediaModal = {
}
},
mounted () {
+ window.addEventListener('popstate', this.hide)
document.addEventListener('keyup', this.handleKeyupEvent)
document.addEventListener('keydown', this.handleKeydownEvent)
},
destroyed () {
+ window.removeEventListener('popstate', this.hide)
document.removeEventListener('keyup', this.handleKeyupEvent)
document.removeEventListener('keydown', this.handleKeydownEvent)
}
diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue
index 80d2a8b9..46931667 100644
--- a/src/components/media_modal/media_modal.vue
+++ b/src/components/media_modal/media_modal.vue
@@ -8,6 +8,8 @@
v-if="type === 'image'"
class="modal-image"
:src="currentMedia.url"
+ :alt="currentMedia.description"
+ :title="currentMedia.description"
@touchstart.stop="mediaTouchStart"
@touchmove.stop="mediaTouchMove"
@click="hide"
@@ -18,6 +20,14 @@
:attachment="currentMedia"
:controls="true"
/>
+ <audio
+ v-if="type === 'audio'"
+ class="modal-image"
+ :src="currentMedia.url"
+ :alt="currentMedia.description"
+ :title="currentMedia.description"
+ controls
+ />
<button
v-if="canNavigate"
:title="$t('media_modal.previous')"
diff --git a/src/components/media_upload/media_upload.js b/src/components/media_upload/media_upload.js
index f457d022..7b8a76cc 100644
--- a/src/components/media_upload/media_upload.js
+++ b/src/components/media_upload/media_upload.js
@@ -5,10 +5,15 @@ import fileSizeFormatService from '../../services/file_size_format/file_size_for
const mediaUpload = {
data () {
return {
- uploading: false,
+ uploadCount: 0,
uploadReady: true
}
},
+ computed: {
+ uploading () {
+ return this.uploadCount > 0
+ }
+ },
methods: {
uploadFile (file) {
const self = this
@@ -23,29 +28,21 @@ const mediaUpload = {
formData.append('file', file)
self.$emit('uploading')
- self.uploading = true
+ self.uploadCount++
statusPosterService.uploadMedia({ store, formData })
.then((fileData) => {
self.$emit('uploaded', fileData)
- self.uploading = false
+ self.decreaseUploadCount()
}, (error) => { // eslint-disable-line handle-callback-err
self.$emit('upload-failed', 'default')
- self.uploading = false
+ self.decreaseUploadCount()
})
},
- fileDrop (e) {
- if (e.dataTransfer.files.length > 0) {
- e.preventDefault() // allow dropping text like before
- this.uploadFile(e.dataTransfer.files[0])
- }
- },
- fileDrag (e) {
- let types = e.dataTransfer.types
- if (types.contains('Files')) {
- e.dataTransfer.dropEffect = 'copy'
- } else {
- e.dataTransfer.dropEffect = 'none'
+ decreaseUploadCount () {
+ this.uploadCount--
+ if (this.uploadCount === 0) {
+ this.$emit('all-uploaded')
}
},
clearFile () {
@@ -54,20 +51,23 @@ const mediaUpload = {
this.uploadReady = true
})
},
- change ({ target }) {
- for (var i = 0; i < target.files.length; i++) {
- let file = target.files[i]
+ multiUpload (files) {
+ for (const file of files) {
this.uploadFile(file)
}
+ },
+ change ({ target }) {
+ this.multiUpload(target.files)
}
},
props: [
- 'dropFiles'
+ 'dropFiles',
+ 'disabled'
],
watch: {
'dropFiles': function (fileInfos) {
if (!this.uploading) {
- this.uploadFile(fileInfos[0])
+ this.multiUpload(fileInfos)
}
}
}
diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue
index 0fc305ac..c8865d77 100644
--- a/src/components/media_upload/media_upload.vue
+++ b/src/components/media_upload/media_upload.vue
@@ -1,9 +1,7 @@
<template>
<div
class="media-upload"
- @drop.prevent
- @dragover.prevent="fileDrag"
- @drop="fileDrop"
+ :class="{ disabled: disabled }"
>
<label
class="label"
@@ -19,6 +17,7 @@
/>
<input
v-if="uploadReady"
+ :disabled="disabled"
type="file"
style="position: fixed; top: -100em"
multiple="true"
@@ -31,6 +30,8 @@
<script src="./media_upload.js" ></script>
<style lang="scss">
+@import '../../_variables.scss';
+
.media-upload {
.label {
display: inline-block;
diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js
index c1166a0c..b2b5d264 100644
--- a/src/components/mobile_nav/mobile_nav.js
+++ b/src/components/mobile_nav/mobile_nav.js
@@ -2,6 +2,7 @@ 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 { mapGetters } from 'vuex'
const MobileNav = {
components: {
@@ -30,7 +31,11 @@ const MobileNav = {
return this.unseenNotifications.length
},
hideSitename () { return this.$store.state.instance.hideSitename },
- sitename () { return this.$store.state.instance.name }
+ sitename () { return this.$store.state.instance.name },
+ isChat () {
+ return this.$route.name === 'chat'
+ },
+ ...mapGetters(['unreadChatCount'])
},
methods: {
toggleMobileSidebar () {
@@ -64,7 +69,7 @@ const MobileNav = {
this.$refs.notifications.markAsSeen()
},
onScroll ({ target: { scrollTop, clientHeight, scrollHeight } }) {
- if (this.$store.getters.mergedConfig.autoLoad && scrollTop + clientHeight >= scrollHeight) {
+ 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 51f1d636..abd95f09 100644
--- a/src/components/mobile_nav/mobile_nav.vue
+++ b/src/components/mobile_nav/mobile_nav.vue
@@ -3,6 +3,7 @@
<nav
id="nav"
class="nav-bar container"
+ :class="{ 'mobile-hidden': isChat }"
>
<div
class="mobile-inner-nav"
@@ -15,6 +16,10 @@
@click.stop.prevent="toggleMobileSidebar()"
>
<i class="button-icon icon-menu" />
+ <div
+ v-if="unreadChatCount"
+ class="alert-dot"
+ />
</a>
<router-link
v-if="!hideSitename"
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 0ad12bb1..6348277b 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
@@ -1,5 +1,10 @@
import { debounce } from 'lodash'
+const HIDDEN_FOR_PAGES = new Set([
+ 'chats',
+ 'chat'
+])
+
const MobilePostStatusButton = {
data () {
return {
@@ -27,6 +32,8 @@ const MobilePostStatusButton = {
return !!this.$store.state.users.currentUser
},
isHidden () {
+ if (HIDDEN_FOR_PAGES.has(this.$route.name)) { return true }
+
return this.autohideFloatingPostButton && (this.hidden || this.inputActive)
},
autohideFloatingPostButton () {
diff --git a/src/components/modal/modal.vue b/src/components/modal/modal.vue
index cee24241..2b58913f 100644
--- a/src/components/modal/modal.vue
+++ b/src/components/modal/modal.vue
@@ -1,8 +1,9 @@
<template>
<div
v-show="isOpen"
- v-body-scroll-lock="isOpen"
+ v-body-scroll-lock="isOpen && !noBackground"
class="modal-view"
+ :class="classes"
@click.self="$emit('backdropClicked')"
>
<slot />
@@ -15,6 +16,18 @@ export default {
isOpen: {
type: Boolean,
default: true
+ },
+ noBackground: {
+ type: Boolean,
+ default: false
+ }
+ },
+ computed: {
+ classes () {
+ return {
+ 'modal-background': !this.noBackground,
+ 'open': this.isOpen
+ }
}
}
}
@@ -32,12 +45,22 @@ export default {
justify-content: center;
align-items: center;
overflow: auto;
+ pointer-events: none;
animation-duration: 0.2s;
- background-color: rgba(0, 0, 0, 0.5);
animation-name: modal-background-fadein;
+ opacity: 0;
+
+ > * {
+ pointer-events: initial;
+ }
+
+ &.modal-background {
+ pointer-events: initial;
+ background-color: rgba(0, 0, 0, 0.5);
+ }
- body:not(.scroll-locked) & {
- opacity: 0;
+ &.open {
+ opacity: 1;
}
}
diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js
index 8f7edb7f..623dfaec 100644
--- a/src/components/nav_panel/nav_panel.js
+++ b/src/components/nav_panel/nav_panel.js
@@ -1,4 +1,5 @@
-import { mapState } from 'vuex'
+import { timelineNames } from '../timeline_menu/timeline_menu.js'
+import { mapState, mapGetters } from 'vuex'
const NavPanel = {
created () {
@@ -6,13 +7,25 @@ const NavPanel = {
this.$store.dispatch('startFetchingFollowRequests')
}
},
- computed: mapState({
- currentUser: state => state.users.currentUser,
- chat: state => state.chat.channel,
- followRequestCount: state => state.api.followRequests.length,
- privateMode: state => state.instance.private,
- federating: state => state.instance.federating
- })
+ computed: {
+ onTimelineRoute () {
+ return !!timelineNames()[this.$route.name]
+ },
+ timelinesRoute () {
+ if (this.$store.state.interface.lastTimeline) {
+ return this.$store.state.interface.lastTimeline
+ }
+ return this.currentUser ? 'friends' : 'public-timeline'
+ },
+ ...mapState({
+ 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
+ }),
+ ...mapGetters(['unreadChatCount'])
+ }
}
export default NavPanel
diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue
index 8cd04dc7..f8459fd1 100644
--- a/src/components/nav_panel/nav_panel.vue
+++ b/src/components/nav_panel/nav_panel.vue
@@ -2,9 +2,12 @@
<div class="nav-panel">
<div class="panel panel-default">
<ul>
- <li v-if="currentUser">
- <router-link :to="{ name: 'friends' }">
- <i class="button-icon icon-home-2" /> {{ $t("nav.timeline") }}
+ <li v-if="currentUser || !privateMode">
+ <router-link
+ :to="{ name: timelinesRoute }"
+ :class="onTimelineRoute && 'router-link-active'"
+ >
+ <i class="button-icon icon-home-2" /> {{ $t("nav.timelines") }}
</router-link>
</li>
<li v-if="currentUser">
@@ -12,9 +15,15 @@
<i class="button-icon icon-bell-alt" /> {{ $t("nav.interactions") }}
</router-link>
</li>
- <li v-if="currentUser">
- <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
- <i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }}
+ <li v-if="currentUser && pleromaChatMessagesAvailable">
+ <router-link :to="{ name: 'chats', params: { username: currentUser.screen_name } }">
+ <div
+ v-if="unreadChatCount"
+ class="badge badge-notification unread-chat-count"
+ >
+ {{ unreadChatCount }}
+ </div>
+ <i class="button-icon icon-chat" /> {{ $t("nav.chats") }}
</router-link>
</li>
<li v-if="currentUser && currentUser.locked">
@@ -28,16 +37,6 @@
</span>
</router-link>
</li>
- <li v-if="currentUser || !privateMode">
- <router-link :to="{ name: 'public-timeline' }">
- <i class="button-icon icon-users" /> {{ $t("nav.public_tl") }}
- </router-link>
- </li>
- <li v-if="federating && (currentUser || !privateMode)">
- <router-link :to="{ name: 'public-external-timeline' }">
- <i class="button-icon icon-globe" /> {{ $t("nav.twkn") }}
- </router-link>
- </li>
<li>
<router-link :to="{ name: 'about' }">
<i class="button-icon icon-info-circled" /> {{ $t("nav.about") }}
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index 1cf4c9bc..bb906b50 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -1,3 +1,5 @@
+import StatusContent from '../status_content/status_content.vue'
+import { mapState } from 'vuex'
import Status from '../status/status.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue'
@@ -16,10 +18,11 @@ const Notification = {
},
props: [ 'notification' ],
components: {
- Status,
+ StatusContent,
UserAvatar,
UserCard,
- Timeago
+ Timeago,
+ Status
},
methods: {
toggleUserExpanded () {
@@ -79,7 +82,10 @@ const Notification = {
},
isStatusNotification () {
return isStatusNotification(this.notification.type)
- }
+ },
+ ...mapState({
+ currentUser: state => state.users.currentUser
+ })
}
}
diff --git a/src/components/notification/notification.scss b/src/components/notification/notification.scss
new file mode 100644
index 00000000..d0e63d81
--- /dev/null
+++ b/src/components/notification/notification.scss
@@ -0,0 +1,52 @@
+// TODO Copypaste from Status, should unify it somehow
+.Notification {
+ &.-muted {
+ padding: 0.25em 0.6em;
+ height: 1.2em;
+ line-height: 1.2em;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ display: flex;
+ flex-wrap: nowrap;
+
+ & .status-username,
+ & .mute-thread,
+ & .mute-words {
+ word-wrap: normal;
+ word-break: normal;
+ white-space: nowrap;
+ }
+
+ & .status-username,
+ & .mute-words {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ .status-username {
+ font-weight: normal;
+ flex: 0 1 auto;
+ margin-right: 0.2em;
+ font-size: smaller;
+ }
+
+ .mute-thread {
+ flex: 0 0 auto;
+ }
+
+ .mute-words {
+ flex: 1 0 5em;
+ margin-left: 0.2em;
+
+ &::before {
+ content: ' ';
+ }
+ }
+
+ .unmute {
+ flex: 0 0 auto;
+ margin-left: auto;
+ display: block;
+ }
+ }
+}
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index 0e46a2a7..7fac3840 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -7,7 +7,7 @@
<div v-else>
<div
v-if="needMute && !unmuted"
- class="container muted"
+ class="Notification container -muted"
>
<small>
<router-link :to="userProfileLink">
@@ -157,11 +157,9 @@
</router-link>
</div>
<template v-else>
- <status
+ <status-content
class="faint"
- :compact="true"
- :statusoid="notification.action"
- :no-heading="true"
+ :status="notification.action"
/>
</template>
</div>
@@ -170,3 +168,4 @@
</template>
<script src="./notification.js"></script>
+<style src="./notification.scss" lang="scss"></style>
diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js
index 26ffbab6..d951e2a8 100644
--- a/src/components/notifications/notifications.js
+++ b/src/components/notifications/notifications.js
@@ -1,3 +1,4 @@
+import { mapGetters } from 'vuex'
import Notification from '../notification/notification.vue'
import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js'
import {
@@ -27,6 +28,11 @@ const Notifications = {
seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT
}
},
+ created () {
+ const store = this.$store
+ const credentials = store.state.users.currentUser.credentials
+ notificationsFetcher.fetchAndUpdate({ store, credentials })
+ },
computed: {
mainClass () {
return this.minimalMode ? '' : 'panel panel-default'
@@ -46,23 +52,22 @@ const Notifications = {
unseenCount () {
return this.unseenNotifications.length
},
+ unseenCountTitle () {
+ return this.unseenCount + (this.unreadChatCount)
+ },
loading () {
return this.$store.state.statuses.notifications.loading
},
notificationsToDisplay () {
return this.filteredNotifications.slice(0, this.unseenCount + this.seenToDisplayCount)
- }
+ },
+ ...mapGetters(['unreadChatCount'])
},
components: {
Notification
},
- created () {
- const { dispatch } = this.$store
-
- dispatch('fetchAndUpdateNotifications')
- },
watch: {
- unseenCount (count) {
+ unseenCountTitle (count) {
if (count > 0) {
this.$store.dispatch('setPageTitle', `(${count})`)
} else {
diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss
index 9efcfcf8..c6b2a5b5 100644
--- a/src/components/notifications/notifications.scss
+++ b/src/components/notifications/notifications.scss
@@ -36,8 +36,10 @@
border-bottom: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
+ word-wrap: break-word;
+ word-break: break-word;
- &:hover .animated.avatar {
+ &:hover .animated.Avatar {
canvas {
display: none;
}
@@ -46,37 +48,20 @@
}
}
- .muted {
- padding: .25em .6em;
- }
-
.non-mention {
display: flex;
flex: 1;
flex-wrap: nowrap;
padding: 0.6em;
min-width: 0;
+
.avatar-container {
width: 32px;
height: 32px;
}
- .status-el {
- .status {
- padding: 0.25em 0;
- color: $fallback--faint;
- color: var(--faint, $fallback--faint);
- a {
- color: var(--faintLink);
- }
- .status-content a {
- color: var(--postFaintLink);
- }
- }
- padding: 0;
- .media-body {
- margin: 0;
- }
- }
+
+ --link: var(--faintLink);
+ --text: var(--faint);
}
.follow-request-accept {
@@ -113,7 +98,8 @@
}
}
- .status-el {
+ /* TODO cleanup this */
+ .Status {
flex: 1;
}
@@ -125,6 +111,11 @@
flex: 1;
padding-left: 0.8em;
min-width: 0;
+
+ .timeago {
+ min-width: 3em;
+ text-align: right;
+ }
}
.emoji-reaction-emoji {
diff --git a/src/components/panel_loading/panel_loading.vue b/src/components/panel_loading/panel_loading.vue
new file mode 100644
index 00000000..4efebb3c
--- /dev/null
+++ b/src/components/panel_loading/panel_loading.vue
@@ -0,0 +1,29 @@
+<template>
+ <div class="panel-loading">
+ <span class="loading-text">
+ <i class="icon-spin4 animate-spin" />
+ {{ $t('general.loading') }}
+ </span>
+ </div>
+</template>
+
+<style lang="scss">
+@import 'src/_variables.scss';
+
+.panel-loading {
+ display: flex;
+ height: 100%;
+ align-items: center;
+ justify-content: center;
+ font-size: 2em;
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ .loading-text i {
+ font-size: 3em;
+ line-height: 0;
+ vertical-align: middle;
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ }
+}
+</style>
diff --git a/src/components/poll/poll.vue b/src/components/poll/poll.vue
index 56e91cca..1858f3e1 100644
--- a/src/components/poll/poll.vue
+++ b/src/components/poll/poll.vue
@@ -17,7 +17,7 @@
<span class="result-percentage">
{{ percentageForOption(option.votes_count) }}%
</span>
- <span>{{ option.title }}</span>
+ <span v-html="option.title_html" />
</div>
<div
class="result-fill"
@@ -96,6 +96,7 @@
align-items: center;
padding: 0.1em 0.25em;
z-index: 1;
+ word-break: break-word;
}
.result-percentage {
width: 3.5em;
diff --git a/src/components/poll/poll_form.js b/src/components/poll/poll_form.js
index c0c1ccf7..df93f038 100644
--- a/src/components/poll/poll_form.js
+++ b/src/components/poll/poll_form.js
@@ -75,6 +75,7 @@ export default {
deleteOption (index, event) {
if (this.options.length > 2) {
this.options.splice(index, 1)
+ this.updatePollToParent()
}
},
convertExpiryToUnit (unit, amount) {
diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js
index 5881d266..695f73b9 100644
--- a/src/components/popover/popover.js
+++ b/src/components/popover/popover.js
@@ -1,4 +1,3 @@
-
const Popover = {
name: 'Popover',
props: {
@@ -10,13 +9,18 @@ const Popover = {
// 'container' for using offsetParent as boundaries for either axis
// or 'viewport'
boundTo: Object,
+ // Takes a selector to use as a replacement for the parent container
+ // for getting boundaries for x an y axis
+ boundToSelector: String,
// Takes a top/bottom/left/right object, how much space to leave
// between boundary and popover element
margin: Object,
// Takes a x/y object and tells how many pixels to offset from
// anchor point on either axis
offset: Object,
- // Additional styles you may want for the popover container
+ // Replaces the classes you may want for the popover container.
+ // Use 'popover-default' in addition to get the default popover
+ // styles with your custom class.
popoverClass: String
},
data () {
@@ -27,6 +31,10 @@ const Popover = {
}
},
methods: {
+ containerBoundingClientRect () {
+ const container = this.boundToSelector ? this.$el.closest(this.boundToSelector) : this.$el.offsetParent
+ return container.getBoundingClientRect()
+ },
updateStyles () {
if (this.hidden) {
this.styles = {
@@ -45,7 +53,8 @@ const Popover = {
// Minor optimization, don't call a slow reflow call if we don't have to
const parentBounds = this.boundTo &&
(this.boundTo.x === 'container' || this.boundTo.y === 'container') &&
- this.$el.offsetParent.getBoundingClientRect()
+ this.containerBoundingClientRect()
+
const margin = this.margin || {}
// What are the screen bounds for the popover? Viewport vs container
@@ -99,7 +108,7 @@ const Popover = {
// single translate or translate3d resulted in blurry text.
this.styles = {
opacity: 1,
- transform: `translateX(${Math.floor(translateX)}px) translateY(${Math.floor(translateY)}px)`
+ transform: `translateX(${Math.round(translateX)}px) translateY(${Math.round(translateY)}px)`
}
},
showPopover () {
diff --git a/src/components/popover/popover.vue b/src/components/popover/popover.vue
index a271cb1b..5c99c509 100644
--- a/src/components/popover/popover.vue
+++ b/src/components/popover/popover.vue
@@ -14,7 +14,7 @@
ref="content"
:style="styles"
class="popover"
- :class="popoverClass"
+ :class="popoverClass || 'popover-default'"
>
<slot
name="content"
@@ -34,6 +34,9 @@
z-index: 8;
position: absolute;
min-width: 0;
+}
+
+.popover-default {
transition: opacity 0.3s;
box-shadow: 1px 1px 4px rgba(0,0,0,.6);
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index 74067fef..e7094bec 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -3,11 +3,13 @@ import MediaUpload from '../media_upload/media_upload.vue'
import ScopeSelector from '../scope_selector/scope_selector.vue'
import EmojiInput from '../emoji_input/emoji_input.vue'
import PollForm from '../poll/poll_form.vue'
+import Attachment from '../attachment/attachment.vue'
+import StatusContent from '../status_content/status_content.vue'
import fileTypeService from '../../services/file_type/file_type.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
-import { reject, map, uniqBy } from 'lodash'
+import { reject, map, uniqBy, debounce } from 'lodash'
import suggestor from '../emoji_input/suggestor.js'
-import { mapGetters } from 'vuex'
+import { mapGetters, mapState } from 'vuex'
import Checkbox from '../checkbox/checkbox.vue'
const buildMentionsString = ({ user, attentions = [] }, currentUser) => {
@@ -25,27 +27,54 @@ const buildMentionsString = ({ user, attentions = [] }, currentUser) => {
return mentions.length > 0 ? mentions.join(' ') + ' ' : ''
}
+// Converts a string with px to a number like '2px' -> 2
+const pxStringToNumber = (str) => {
+ return Number(str.substring(0, str.length - 2))
+}
+
const PostStatusForm = {
props: [
'replyTo',
'repliedUser',
'attentions',
'copyMessageScope',
- 'subject'
+ 'subject',
+ 'disableSubject',
+ 'disableScopeSelector',
+ 'disableNotice',
+ 'disableLockWarning',
+ 'disablePolls',
+ 'disableSensitivityCheckbox',
+ 'disableSubmit',
+ 'disablePreview',
+ 'placeholder',
+ 'maxHeight',
+ 'postHandler',
+ 'preserveFocus',
+ 'autoFocus',
+ 'fileLimit',
+ 'submitOnEnter',
+ 'emojiPickerPlacement'
],
components: {
MediaUpload,
EmojiInput,
PollForm,
ScopeSelector,
- Checkbox
+ Checkbox,
+ Attachment,
+ StatusContent
},
mounted () {
+ this.updateIdempotencyKey()
this.resize(this.$refs.textarea)
- const textLength = this.$refs.textarea.value.length
- this.$refs.textarea.setSelectionRange(textLength, textLength)
if (this.replyTo) {
+ const textLength = this.$refs.textarea.value.length
+ this.$refs.textarea.setSelectionRange(textLength, textLength)
+ }
+
+ if (this.replyTo || this.autoFocus) {
this.$refs.textarea.focus()
}
},
@@ -68,7 +97,7 @@ const PostStatusForm = {
return {
dropFiles: [],
- submitDisabled: false,
+ uploadingFiles: false,
error: null,
posting: false,
highlighted: 0,
@@ -78,11 +107,18 @@ const PostStatusForm = {
nsfw: false,
files: [],
poll: {},
+ mediaDescriptions: {},
visibility: scope,
contentType
},
caret: 0,
- pollFormVisible: false
+ pollFormVisible: false,
+ showDropIcon: 'hide',
+ dropStopTimeout: null,
+ preview: null,
+ previewLoading: false,
+ emojiInputShown: false,
+ idempotencyKey: ''
}
},
computed: {
@@ -102,7 +138,7 @@ const PostStatusForm = {
...this.$store.state.instance.customEmoji
],
users: this.$store.state.users.users,
- updateUsersList: (input) => this.$store.dispatch('searchUsers', input)
+ updateUsersList: (query) => this.$store.dispatch('searchUsers', { query })
})
},
emojiSuggestor () {
@@ -151,28 +187,81 @@ const PostStatusForm = {
},
pollsAvailable () {
return this.$store.state.instance.pollsAvailable &&
- this.$store.state.instance.pollLimits.max_options >= 2
+ this.$store.state.instance.pollLimits.max_options >= 2 &&
+ this.disablePolls !== true
},
hideScopeNotice () {
- return this.$store.getters.mergedConfig.hideScopeNotice
+ return this.disableNotice || this.$store.getters.mergedConfig.hideScopeNotice
},
pollContentError () {
return this.pollFormVisible &&
this.newStatus.poll &&
this.newStatus.poll.error
},
- ...mapGetters(['mergedConfig'])
+ showPreview () {
+ return !this.disablePreview && (!!this.preview || this.previewLoading)
+ },
+ emptyStatus () {
+ return this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
+ },
+ uploadFileLimitReached () {
+ return this.newStatus.files.length >= this.fileLimit
+ },
+ ...mapGetters(['mergedConfig']),
+ ...mapState({
+ mobileLayout: state => state.interface.mobileLayout
+ })
+ },
+ watch: {
+ 'newStatus': {
+ deep: true,
+ handler () {
+ this.statusChanged()
+ }
+ }
},
methods: {
- postStatus (newStatus) {
+ statusChanged () {
+ this.autoPreview()
+ this.updateIdempotencyKey()
+ },
+ clearStatus () {
+ const newStatus = this.newStatus
+ this.newStatus = {
+ status: '',
+ spoilerText: '',
+ files: [],
+ visibility: newStatus.visibility,
+ contentType: newStatus.contentType,
+ poll: {},
+ mediaDescriptions: {}
+ }
+ this.pollFormVisible = false
+ this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
+ this.clearPollForm()
+ if (this.preserveFocus) {
+ this.$nextTick(() => {
+ this.$refs.textarea.focus()
+ })
+ }
+ let el = this.$el.querySelector('textarea')
+ el.style.height = 'auto'
+ el.style.height = undefined
+ this.error = null
+ if (this.preview) this.previewStatus()
+ },
+ async postStatus (event, newStatus, opts = {}) {
if (this.posting) { return }
- if (this.submitDisabled) { return }
+ if (this.disableSubmit) { return }
+ if (this.emojiInputShown) { return }
+ if (this.submitOnEnter) {
+ event.stopPropagation()
+ event.preventDefault()
+ }
- if (this.newStatus.status === '') {
- if (this.newStatus.files.length === 0) {
- this.error = 'Cannot post an empty status with no files'
- return
- }
+ if (this.emptyStatus) {
+ this.error = this.$t('post_status.empty_status_error')
+ return
}
const poll = this.pollFormVisible ? this.newStatus.poll : {}
@@ -182,7 +271,16 @@ const PostStatusForm = {
}
this.posting = true
- statusPoster.postStatus({
+
+ try {
+ await this.setAllMediaDescriptions()
+ } catch (e) {
+ this.error = this.$t('post_status.media_description_error')
+ this.posting = false
+ return
+ }
+
+ const postingOptions = {
status: newStatus.status,
spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility,
@@ -191,54 +289,98 @@ const PostStatusForm = {
store: this.$store,
inReplyToStatusId: this.replyTo,
contentType: newStatus.contentType,
- poll
- }).then((data) => {
+ poll,
+ idempotencyKey: this.idempotencyKey
+ }
+
+ const postHandler = this.postHandler ? this.postHandler : statusPoster.postStatus
+
+ postHandler(postingOptions).then((data) => {
if (!data.error) {
- this.newStatus = {
- status: '',
- spoilerText: '',
- files: [],
- visibility: newStatus.visibility,
- contentType: newStatus.contentType,
- poll: {}
- }
- this.pollFormVisible = false
- this.$refs.mediaUpload.clearFile()
- this.clearPollForm()
- this.$emit('posted')
- let el = this.$el.querySelector('textarea')
- el.style.height = 'auto'
- el.style.height = undefined
- this.error = null
+ this.clearStatus()
+ this.$emit('posted', data)
} else {
this.error = data.error
}
this.posting = false
})
},
+ previewStatus () {
+ if (this.emptyStatus && this.newStatus.spoilerText.trim() === '') {
+ this.preview = { error: this.$t('post_status.preview_empty') }
+ this.previewLoading = false
+ return
+ }
+ const newStatus = this.newStatus
+ this.previewLoading = true
+ statusPoster.postStatus({
+ status: newStatus.status,
+ spoilerText: newStatus.spoilerText || null,
+ visibility: newStatus.visibility,
+ sensitive: newStatus.nsfw,
+ media: [],
+ store: this.$store,
+ inReplyToStatusId: this.replyTo,
+ contentType: newStatus.contentType,
+ poll: {},
+ preview: true
+ }).then((data) => {
+ // Don't apply preview if not loading, because it means
+ // user has closed the preview manually.
+ if (!this.previewLoading) return
+ if (!data.error) {
+ this.preview = data
+ } else {
+ this.preview = { error: data.error }
+ }
+ }).catch((error) => {
+ this.preview = { error }
+ }).finally(() => {
+ this.previewLoading = false
+ })
+ },
+ debouncePreviewStatus: debounce(function () { this.previewStatus() }, 500),
+ autoPreview () {
+ if (!this.preview) return
+ this.previewLoading = true
+ this.debouncePreviewStatus()
+ },
+ closePreview () {
+ this.preview = null
+ this.previewLoading = false
+ },
+ togglePreview () {
+ if (this.showPreview) {
+ this.closePreview()
+ } else {
+ this.previewStatus()
+ }
+ },
addMediaFile (fileInfo) {
this.newStatus.files.push(fileInfo)
- this.enableSubmit()
+ this.$emit('resize', { delayed: true })
},
removeMediaFile (fileInfo) {
let index = this.newStatus.files.indexOf(fileInfo)
this.newStatus.files.splice(index, 1)
+ this.$emit('resize')
},
uploadFailed (errString, templateArgs) {
templateArgs = templateArgs || {}
this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs)
- this.enableSubmit()
},
- disableSubmit () {
- this.submitDisabled = true
+ startedUploadingFiles () {
+ this.uploadingFiles = true
},
- enableSubmit () {
- this.submitDisabled = false
+ finishedUploadingFiles () {
+ this.$emit('resize')
+ this.uploadingFiles = false
},
type (fileInfo) {
return fileTypeService.fileType(fileInfo.mimetype)
},
paste (e) {
+ this.autoPreview()
this.resize(e)
if (e.clipboardData.files.length > 0) {
// prevent pasting of file as text
@@ -250,13 +392,27 @@ const PostStatusForm = {
}
},
fileDrop (e) {
- if (e.dataTransfer.files.length > 0) {
+ if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
e.preventDefault() // allow dropping text like before
this.dropFiles = e.dataTransfer.files
+ clearTimeout(this.dropStopTimeout)
+ this.showDropIcon = 'hide'
}
},
+ fileDragStop (e) {
+ // The false-setting is done with delay because just using leave-events
+ // directly caused unwanted flickering, this is not perfect either but
+ // much less noticable.
+ clearTimeout(this.dropStopTimeout)
+ this.showDropIcon = 'fade'
+ this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500)
+ },
fileDrag (e) {
- e.dataTransfer.dropEffect = 'copy'
+ e.dataTransfer.dropEffect = this.uploadFileLimitReached ? 'none' : 'copy'
+ if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
+ clearTimeout(this.dropStopTimeout)
+ this.showDropIcon = 'show'
+ }
},
onEmojiInputInput (e) {
this.$nextTick(() => {
@@ -270,6 +426,7 @@ const PostStatusForm = {
// Reset to default height for empty form, nothing else to do here.
if (target.value === '') {
target.style.height = null
+ this.$emit('resize')
this.$refs['emoji-input'].resize()
return
}
@@ -281,7 +438,7 @@ const PostStatusForm = {
* scroll is different for `Window` and `Element`s
*/
const bottomBottomPaddingStr = window.getComputedStyle(bottomRef)['padding-bottom']
- const bottomBottomPadding = Number(bottomBottomPaddingStr.substring(0, bottomBottomPaddingStr.length - 2))
+ const bottomBottomPadding = pxStringToNumber(bottomBottomPaddingStr)
const scrollerRef = this.$el.closest('.sidebar-scroller') ||
this.$el.closest('.post-form-modal-view') ||
@@ -290,10 +447,12 @@ const PostStatusForm = {
// Getting info about padding we have to account for, removing 'px' part
const topPaddingStr = window.getComputedStyle(target)['padding-top']
const bottomPaddingStr = window.getComputedStyle(target)['padding-bottom']
- const topPadding = Number(topPaddingStr.substring(0, topPaddingStr.length - 2))
- const bottomPadding = Number(bottomPaddingStr.substring(0, bottomPaddingStr.length - 2))
+ const topPadding = pxStringToNumber(topPaddingStr)
+ const bottomPadding = pxStringToNumber(bottomPaddingStr)
const vertPadding = topPadding + bottomPadding
+ const oldHeight = pxStringToNumber(target.style.height)
+
/* Explanation:
*
* https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight
@@ -322,8 +481,15 @@ const PostStatusForm = {
// BEGIN content size update
target.style.height = 'auto'
- const newHeight = target.scrollHeight - vertPadding
+ const heightWithoutPadding = Math.floor(target.scrollHeight - vertPadding)
+ let newHeight = this.maxHeight ? Math.min(heightWithoutPadding, this.maxHeight) : heightWithoutPadding
+ // This is a bit of a hack to combat target.scrollHeight being different on every other input
+ // on some browsers for whatever reason. Don't change the height if difference is 1px or less.
+ if (Math.abs(newHeight - oldHeight) <= 1) {
+ newHeight = oldHeight
+ }
target.style.height = `${newHeight}px`
+ this.$emit('resize', newHeight)
// END content size update
// We check where the bottom border of form-bottom element is, this uses findOffset
@@ -374,6 +540,21 @@ const PostStatusForm = {
},
dismissScopeNotice () {
this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true })
+ },
+ setMediaDescription (id) {
+ const description = this.newStatus.mediaDescriptions[id]
+ if (!description || description.trim() === '') return
+ return statusPoster.setMediaDescription({ store: this.$store, id, description })
+ },
+ setAllMediaDescriptions () {
+ const ids = this.newStatus.files.map(file => file.id)
+ return Promise.all(ids.map(id => this.setMediaDescription(id)))
+ },
+ handleEmojiInputShow (value) {
+ this.emojiInputShown = value
+ },
+ updateIdempotencyKey () {
+ this.idempotencyKey = Date.now().toString()
}
}
}
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index 9789a481..520c03ea 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -5,11 +5,20 @@
>
<form
autocomplete="off"
- @submit.prevent="postStatus(newStatus)"
+ @submit.prevent
+ @dragover.prevent="fileDrag"
>
+ <div
+ v-show="showDropIcon !== 'hide'"
+ :style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }"
+ class="drop-indicator"
+ :class="[uploadFileLimitReached ? 'icon-block' : 'icon-upload']"
+ @dragleave="fileDragStop"
+ @drop.stop="fileDrop"
+ />
<div class="form-group">
<i18n
- v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'"
+ v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private' && !disableLockWarning"
path="post_status.account_not_locked_warning"
tag="p"
class="visibility-notice"
@@ -61,18 +70,56 @@
<span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span>
<span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
</p>
+ <div
+ v-if="!disablePreview"
+ class="preview-heading faint"
+ >
+ <a
+ class="preview-toggle faint"
+ @click.stop.prevent="togglePreview"
+ >
+ {{ $t('post_status.preview') }}
+ <i :class="showPreview ? 'icon-left-open' : 'icon-right-open'" />
+ </a>
+ <i
+ v-show="previewLoading"
+ class="icon-spin3 animate-spin"
+ />
+ </div>
+ <div
+ v-if="showPreview"
+ class="preview-container"
+ >
+ <div
+ v-if="!preview"
+ class="preview-status"
+ >
+ {{ $t('general.loading') }}
+ </div>
+ <div
+ v-else-if="preview.error"
+ class="preview-status preview-error"
+ >
+ {{ preview.error }}
+ </div>
+ <StatusContent
+ v-else
+ :status="preview"
+ class="preview-status"
+ />
+ </div>
<EmojiInput
- v-if="newStatus.spoilerText || alwaysShowSubject"
+ v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)"
v-model="newStatus.spoilerText"
enable-emoji-picker
:suggest="emojiSuggestor"
class="form-control"
>
<input
-
v-model="newStatus.spoilerText"
type="text"
:placeholder="$t('post_status.content_warning')"
+ :disabled="posting"
class="form-post-subject"
>
</EmojiInput>
@@ -80,25 +127,29 @@
ref="emoji-input"
v-model="newStatus.status"
:suggest="emojiUserSuggestor"
+ :placement="emojiPickerPlacement"
class="form-control main-input"
enable-emoji-picker
hide-emoji-button
+ :newline-on-ctrl-enter="submitOnEnter"
enable-sticker-picker
@input="onEmojiInputInput"
@sticker-uploaded="addMediaFile"
@sticker-upload-failed="uploadFailed"
+ @shown="handleEmojiInputShow"
>
<textarea
ref="textarea"
v-model="newStatus.status"
- :placeholder="$t('post_status.default')"
+ :placeholder="placeholder || $t('post_status.default')"
rows="1"
+ cols="1"
:disabled="posting"
class="form-post-body"
- @keydown.meta.enter="postStatus(newStatus)"
- @keyup.ctrl.enter="postStatus(newStatus)"
- @drop="fileDrop"
- @dragover.prevent="fileDrag"
+ :class="{ 'scrollable-form': !!maxHeight }"
+ @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
+ @keydown.meta.enter="postStatus($event, newStatus)"
+ @keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)"
@input="resize"
@compositionupdate="resize"
@paste="paste"
@@ -111,7 +162,10 @@
{{ charactersLeft }}
</p>
</EmojiInput>
- <div class="visibility-tray">
+ <div
+ v-if="!disableScopeSelector"
+ class="visibility-tray"
+ >
<scope-selector
:show-all="showAllScopes"
:user-default="userDefaultScope"
@@ -169,9 +223,11 @@
ref="mediaUpload"
class="media-upload-icon"
:drop-files="dropFiles"
- @uploading="disableSubmit"
+ :disabled="uploadFileLimitReached"
+ @uploading="startedUploadingFiles"
@uploaded="addMediaFile"
@upload-failed="uploadFailed"
+ @all-uploaded="finishedUploadingFiles"
/>
<div
class="emoji-icon"
@@ -208,11 +264,13 @@
>
{{ $t('general.submit') }}
</button>
+ <!-- touchstart is used to keep the OSK at the same position after a message send -->
<button
v-else
- :disabled="submitDisabled"
- type="submit"
+ :disabled="uploadingFiles || disableSubmit"
class="btn btn-default"
+ @touchstart.stop.prevent="postStatus($event, newStatus)"
+ @click.stop.prevent="postStatus($event, newStatus)"
>
{{ $t('general.submit') }}
</button>
@@ -237,31 +295,22 @@
class="fa button-icon icon-cancel"
@click="removeMediaFile(file)"
/>
- <div class="media-upload-container attachment">
- <img
- v-if="type(file) === 'image'"
- class="thumbnail media-upload"
- :src="file.url"
- >
- <video
- v-if="type(file) === 'video'"
- :src="file.url"
- controls
- />
- <audio
- v-if="type(file) === 'audio'"
- :src="file.url"
- controls
- />
- <a
- v-if="type(file) === 'unknown'"
- :href="file.url"
- >{{ file.url }}</a>
- </div>
+ <attachment
+ :attachment="file"
+ :set-media="() => $store.dispatch('setMedia', newStatus.files)"
+ size="small"
+ allow-play="false"
+ />
+ <input
+ v-model="newStatus.mediaDescriptions[file.id]"
+ type="text"
+ :placeholder="$t('post_status.media_description')"
+ @keydown.enter.prevent=""
+ >
</div>
</div>
<div
- v-if="newStatus.files.length > 0"
+ v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox"
class="upload_settings"
>
<Checkbox v-model="newStatus.nsfw">
@@ -295,14 +344,8 @@
}
.post-status-form {
- .visibility-tray {
- display: flex;
- justify-content: space-between;
- padding-top: 5px;
- }
-}
+ position: relative;
-.post-status-form {
.form-bottom {
display: flex;
justify-content: space-between;
@@ -328,6 +371,51 @@
max-width: 10em;
}
+ .preview-heading {
+ padding-left: 0.5em;
+ display: flex;
+ width: 100%;
+
+ .icon-spin3 {
+ margin-left: auto;
+ }
+ }
+
+ .preview-toggle {
+ display: flex;
+ cursor: pointer;
+ user-select: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ i {
+ margin-left: 0.2em;
+ font-size: 0.8em;
+ transform: rotate(90deg);
+ }
+ }
+
+ .preview-container {
+ margin-bottom: 1em;
+ }
+
+ .preview-error {
+ font-style: italic;
+ color: $fallback--faint;
+ color: var(--faint, $fallback--faint);
+ }
+
+ .preview-status {
+ border: 1px solid $fallback--border;
+ border: 1px solid var(--border, $fallback--border);
+ border-radius: $fallback--tooltipRadius;
+ border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
+ padding: 0.5em;
+ margin: 0;
+ line-height: 1.4em;
+ }
+
.text-format {
.only-format {
color: $fallback--faint;
@@ -335,6 +423,12 @@
}
}
+ .visibility-tray {
+ display: flex;
+ justify-content: space-between;
+ padding-top: 5px;
+ }
+
.media-upload-icon, .poll-icon, .emoji-icon {
font-size: 26px;
flex: 1;
@@ -346,6 +440,19 @@
color: var(--lightText, $fallback--lightText);
}
}
+
+ &.disabled {
+ i {
+ cursor: not-allowed;
+ color: $fallback--icon;
+ color: var(--btnDisabledText, $fallback--icon);
+
+ &:hover {
+ color: $fallback--icon;
+ color: var(--btnDisabledText, $fallback--icon);
+ }
+ }
+ }
}
// Order is not necessary but a good indicator
@@ -373,11 +480,9 @@
}
.media-upload-wrapper {
- flex: 0 0 auto;
- max-width: 100%;
- min-width: 50px;
margin-right: .2em;
margin-bottom: .5em;
+ width: 18em;
.icon-cancel {
display: inline-block;
@@ -391,6 +496,20 @@
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
+
+ img, video {
+ object-fit: contain;
+ max-height: 10em;
+ }
+
+ .video {
+ max-height: 10em;
+ }
+
+ input {
+ flex: 1;
+ width: 100%;
+ }
}
.status-input-wrapper {
@@ -400,28 +519,13 @@
flex-direction: column;
}
- .attachments {
+ .media-upload-wrapper .attachments {
padding: 0 0.5em;
.attachment {
margin: 0;
+ padding: 0;
position: relative;
- flex: 0 0 auto;
- border: 1px solid $fallback--border;
- border: 1px solid var(--border, $fallback--border);
- text-align: center;
-
- audio {
- min-width: 300px;
- flex: 1 0 auto;
- }
-
- a {
- display: block;
- text-align: left;
- line-height: 1.2;
- padding: .5em;
- }
}
i {
@@ -446,7 +550,8 @@
form {
display: flex;
flex-direction: column;
- padding: 0.6em;
+ margin: 0.6em;
+ position: relative;
}
.form-group {
@@ -473,6 +578,10 @@
padding-bottom: 1.75em;
min-height: 1px;
box-sizing: content-box;
+
+ &.scrollable-form {
+ overflow-y: auto;
+ }
}
.main-input {
@@ -504,5 +613,42 @@
cursor: pointer;
z-index: 4;
}
+
+ @keyframes fade-in {
+ from { opacity: 0; }
+ to { opacity: 0.6; }
+ }
+
+ @keyframes fade-out {
+ from { opacity: 0.6; }
+ to { opacity: 0; }
+ }
+
+ .drop-indicator {
+ position: absolute;
+ z-index: 1;
+ width: 100%;
+ height: 100%;
+ font-size: 5em;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0.6;
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ background-color: $fallback--bg;
+ background-color: var(--bg, $fallback--bg);
+ border-radius: $fallback--tooltipRadius;
+ border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
+ border: 2px dashed $fallback--text;
+ border: 2px dashed var(--text, $fallback--text);
+ }
+}
+
+// todo: unify with attachment.vue (otherwise the uploaded images are not minified unless a status with an attachment was displayed before)
+img.media-upload, .media-upload-container > video {
+ line-height: 0;
+ max-height: 200px;
+ max-width: 100%;
}
</style>
diff --git a/src/components/react_button/react_button.js b/src/components/react_button/react_button.js
index abc3bf07..abcf0455 100644
--- a/src/components/react_button/react_button.js
+++ b/src/components/react_button/react_button.js
@@ -24,11 +24,14 @@ const ReactButton = {
},
computed: {
commonEmojis () {
- return ['❤️', '😠', '👀', '😂', '🔥']
+ return ['👍', '😠', '👀', '😂', '🔥']
},
emojis () {
if (this.filterWord !== '') {
- return this.$store.state.instance.emoji.filter(emoji => emoji.displayText.includes(this.filterWord))
+ const filterWordLowercase = this.filterWord.toLowerCase()
+ return this.$store.state.instance.emoji.filter(emoji =>
+ emoji.displayText.toLowerCase().includes(filterWordLowercase)
+ )
}
return this.$store.state.instance.emoji || []
},
diff --git a/src/components/settings/settings.js b/src/components/settings/settings.js
deleted file mode 100644
index 31a9e9be..00000000
--- a/src/components/settings/settings.js
+++ /dev/null
@@ -1,128 +0,0 @@
-/* eslint-env browser */
-import { filter, trim } from 'lodash'
-
-import TabSwitcher from '../tab_switcher/tab_switcher.js'
-import StyleSwitcher from '../style_switcher/style_switcher.vue'
-import InterfaceLanguageSwitcher from '../interface_language_switcher/interface_language_switcher.vue'
-import { extractCommit } from '../../services/version/version.service'
-import { instanceDefaultProperties, defaultState as configDefaultState } from '../../modules/config.js'
-import Checkbox from '../checkbox/checkbox.vue'
-
-const pleromaFeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma-fe/commit/'
-const pleromaBeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma/commit/'
-
-const multiChoiceProperties = [
- 'postContentType',
- 'subjectLineBehavior'
-]
-
-const settings = {
- data () {
- const instance = this.$store.state.instance
-
- return {
- loopSilentAvailable:
- // Firefox
- Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') ||
- // Chrome-likes
- Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'webkitAudioDecodedByteCount') ||
- // Future spec, still not supported in Nightly 63 as of 08/2018
- Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'audioTracks'),
-
- backendVersion: instance.backendVersion,
- frontendVersion: instance.frontendVersion
- }
- },
- components: {
- TabSwitcher,
- StyleSwitcher,
- InterfaceLanguageSwitcher,
- Checkbox
- },
- computed: {
- user () {
- return this.$store.state.users.currentUser
- },
- currentSaveStateNotice () {
- return this.$store.state.interface.settings.currentSaveStateNotice
- },
- postFormats () {
- return this.$store.state.instance.postFormats || []
- },
- instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel },
- frontendVersionLink () {
- return pleromaFeCommitUrl + this.frontendVersion
- },
- backendVersionLink () {
- return pleromaBeCommitUrl + extractCommit(this.backendVersion)
- },
- // Getting localized values for instance-default properties
- ...instanceDefaultProperties
- .filter(key => multiChoiceProperties.includes(key))
- .map(key => [
- key + 'DefaultValue',
- function () {
- return this.$store.getters.instanceDefaultConfig[key]
- }
- ])
- .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
- ...instanceDefaultProperties
- .filter(key => !multiChoiceProperties.includes(key))
- .map(key => [
- key + 'LocalizedValue',
- function () {
- return this.$t('settings.values.' + this.$store.getters.instanceDefaultConfig[key])
- }
- ])
- .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
- // Generating computed values for vuex properties
- ...Object.keys(configDefaultState)
- .map(key => [key, {
- get () { return this.$store.getters.mergedConfig[key] },
- set (value) {
- this.$store.dispatch('setOption', { name: key, value })
- }
- }])
- .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
- // Special cases (need to transform values or perform actions first)
- muteWordsString: {
- get () { return this.$store.getters.mergedConfig.muteWords.join('\n') },
- set (value) {
- this.$store.dispatch('setOption', {
- name: 'muteWords',
- value: filter(value.split('\n'), (word) => trim(word).length > 0)
- })
- }
- },
- useStreamingApi: {
- get () { return this.$store.getters.mergedConfig.useStreamingApi },
- set (value) {
- const promise = value
- ? this.$store.dispatch('enableMastoSockets')
- : this.$store.dispatch('disableMastoSockets')
-
- promise.then(() => {
- this.$store.dispatch('setOption', { name: 'useStreamingApi', value })
- }).catch((e) => {
- console.error('Failed starting MastoAPI Streaming socket', e)
- this.$store.dispatch('disableMastoSockets')
- this.$store.dispatch('setOption', { name: 'useStreamingApi', value: false })
- })
- }
- }
- },
- // Updating nested properties
- watch: {
- notificationVisibility: {
- handler (value) {
- this.$store.dispatch('setOption', {
- name: 'notificationVisibility',
- value: this.$store.getters.mergedConfig.notificationVisibility
- })
- },
- deep: true
- }
- }
-}
-
-export default settings
diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue
deleted file mode 100644
index 9e14b449..00000000
--- a/src/components/settings/settings.vue
+++ /dev/null
@@ -1,424 +0,0 @@
-<template>
- <div class="settings panel panel-default">
- <div class="panel-heading">
- <div class="title">
- {{ $t('settings.settings') }}
- </div>
-
- <transition name="fade">
- <template v-if="currentSaveStateNotice">
- <div
- v-if="currentSaveStateNotice.error"
- class="alert error"
- @click.prevent
- >
- {{ $t('settings.saving_err') }}
- </div>
-
- <div
- v-if="!currentSaveStateNotice.error"
- class="alert transparent"
- @click.prevent
- >
- {{ $t('settings.saving_ok') }}
- </div>
- </template>
- </transition>
- </div>
- <div class="panel-body">
- <keep-alive>
- <tab-switcher>
- <div :label="$t('settings.general')">
- <div class="setting-item">
- <h2>{{ $t('settings.interface') }}</h2>
- <ul class="setting-list">
- <li>
- <interface-language-switcher />
- </li>
- <li v-if="instanceSpecificPanelPresent">
- <Checkbox v-model="hideISP">
- {{ $t('settings.hide_isp') }}
- </Checkbox>
- </li>
- </ul>
- </div>
- <div class="setting-item">
- <h2>{{ $t('nav.timeline') }}</h2>
- <ul class="setting-list">
- <li>
- <Checkbox v-model="hideMutedPosts">
- {{ $t('settings.hide_muted_posts') }} {{ $t('settings.instance_default', { value: hideMutedPostsLocalizedValue }) }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="collapseMessageWithSubject">
- {{ $t('settings.collapse_subject') }} {{ $t('settings.instance_default', { value: collapseMessageWithSubjectLocalizedValue }) }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="streaming">
- {{ $t('settings.streaming') }}
- </Checkbox>
- <ul
- class="setting-list suboptions"
- :class="[{disabled: !streaming}]"
- >
- <li>
- <Checkbox
- v-model="pauseOnUnfocused"
- :disabled="!streaming"
- >
- {{ $t('settings.pause_on_unfocused') }}
- </Checkbox>
- </li>
- </ul>
- </li>
- <li>
- <Checkbox v-model="useStreamingApi">
- {{ $t('settings.useStreamingApi') }}
- <br>
- <small>
- {{ $t('settings.useStreamingApiWarning') }}
- </small>
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="autoLoad">
- {{ $t('settings.autoload') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="hoverPreview">
- {{ $t('settings.reply_link_preview') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="emojiReactionsOnTimeline">
- {{ $t('settings.emoji_reactions_on_timeline') }}
- </Checkbox>
- </li>
- </ul>
- </div>
-
- <div class="setting-item">
- <h2>{{ $t('settings.composing') }}</h2>
- <ul class="setting-list">
- <li>
- <Checkbox v-model="scopeCopy">
- {{ $t('settings.scope_copy') }} {{ $t('settings.instance_default', { value: scopeCopyLocalizedValue }) }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="alwaysShowSubjectInput">
- {{ $t('settings.subject_input_always_show') }} {{ $t('settings.instance_default', { value: alwaysShowSubjectInputLocalizedValue }) }}
- </Checkbox>
- </li>
- <li>
- <div>
- {{ $t('settings.subject_line_behavior') }}
- <label
- for="subjectLineBehavior"
- class="select"
- >
- <select
- id="subjectLineBehavior"
- v-model="subjectLineBehavior"
- >
- <option value="email">
- {{ $t('settings.subject_line_email') }}
- {{ subjectLineBehaviorDefaultValue == 'email' ? $t('settings.instance_default_simple') : '' }}
- </option>
- <option value="masto">
- {{ $t('settings.subject_line_mastodon') }}
- {{ subjectLineBehaviorDefaultValue == 'mastodon' ? $t('settings.instance_default_simple') : '' }}
- </option>
- <option value="noop">
- {{ $t('settings.subject_line_noop') }}
- {{ subjectLineBehaviorDefaultValue == 'noop' ? $t('settings.instance_default_simple') : '' }}
- </option>
- </select>
- <i class="icon-down-open" />
- </label>
- </div>
- </li>
- <li v-if="postFormats.length > 0">
- <div>
- {{ $t('settings.post_status_content_type') }}
- <label
- for="postContentType"
- class="select"
- >
- <select
- id="postContentType"
- v-model="postContentType"
- >
- <option
- v-for="postFormat in postFormats"
- :key="postFormat"
- :value="postFormat"
- >
- {{ $t(`post_status.content_type["${postFormat}"]`) }}
- {{ postContentTypeDefaultValue === postFormat ? $t('settings.instance_default_simple') : '' }}
- </option>
- </select>
- <i class="icon-down-open" />
- </label>
- </div>
- </li>
- <li>
- <Checkbox v-model="minimalScopesMode">
- {{ $t('settings.minimal_scopes_mode') }} {{ $t('settings.instance_default', { value: minimalScopesModeLocalizedValue }) }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="autohideFloatingPostButton">
- {{ $t('settings.autohide_floating_post_button') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="padEmoji">
- {{ $t('settings.pad_emoji') }}
- </Checkbox>
- </li>
- </ul>
- </div>
-
- <div class="setting-item">
- <h2>{{ $t('settings.attachments') }}</h2>
- <ul class="setting-list">
- <li>
- <Checkbox v-model="hideAttachments">
- {{ $t('settings.hide_attachments_in_tl') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="hideAttachmentsInConv">
- {{ $t('settings.hide_attachments_in_convo') }}
- </Checkbox>
- </li>
- <li>
- <label for="maxThumbnails">
- {{ $t('settings.max_thumbnails') }}
- </label>
- <input
- id="maxThumbnails"
- v-model.number="maxThumbnails"
- class="number-input"
- type="number"
- min="0"
- step="1"
- >
- </li>
- <li>
- <Checkbox v-model="hideNsfw">
- {{ $t('settings.nsfw_clickthrough') }}
- </Checkbox>
- </li>
- <ul class="setting-list suboptions">
- <li>
- <Checkbox
- v-model="preloadImage"
- :disabled="!hideNsfw"
- >
- {{ $t('settings.preload_images') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox
- v-model="useOneClickNsfw"
- :disabled="!hideNsfw"
- >
- {{ $t('settings.use_one_click_nsfw') }}
- </Checkbox>
- </li>
- </ul>
- <li>
- <Checkbox v-model="stopGifs">
- {{ $t('settings.stop_gifs') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="loopVideo">
- {{ $t('settings.loop_video') }}
- </Checkbox>
- <ul
- class="setting-list suboptions"
- :class="[{disabled: !streaming}]"
- >
- <li>
- <Checkbox
- v-model="loopVideoSilentOnly"
- :disabled="!loopVideo || !loopSilentAvailable"
- >
- {{ $t('settings.loop_video_silent_only') }}
- </Checkbox>
- <div
- v-if="!loopSilentAvailable"
- class="unavailable"
- >
- <i class="icon-globe" />! {{ $t('settings.limited_availability') }}
- </div>
- </li>
- </ul>
- </li>
- <li>
- <Checkbox v-model="playVideosInModal">
- {{ $t('settings.play_videos_in_modal') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="useContainFit">
- {{ $t('settings.use_contain_fit') }}
- </Checkbox>
- </li>
- </ul>
- </div>
-
- <div class="setting-item">
- <h2>{{ $t('settings.notifications') }}</h2>
- <ul class="setting-list">
- <li>
- <Checkbox v-model="webPushNotifications">
- {{ $t('settings.enable_web_push_notifications') }}
- </Checkbox>
- </li>
- </ul>
- </div>
-
- <div class="setting-item">
- <h2>{{ $t('settings.fun') }}</h2>
- <ul class="setting-list">
- <li>
- <Checkbox v-model="greentext">
- {{ $t('settings.greentext') }} {{ $t('settings.instance_default', { value: greentextLocalizedValue }) }}
- </Checkbox>
- </li>
- </ul>
- </div>
- </div>
-
- <div :label="$t('settings.theme')">
- <div class="setting-item">
- <style-switcher />
- </div>
- </div>
-
- <div :label="$t('settings.filtering')">
- <div class="setting-item">
- <div class="select-multiple">
- <span class="label">{{ $t('settings.notification_visibility') }}</span>
- <ul class="option-list">
- <li>
- <Checkbox v-model="notificationVisibility.likes">
- {{ $t('settings.notification_visibility_likes') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="notificationVisibility.repeats">
- {{ $t('settings.notification_visibility_repeats') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="notificationVisibility.follows">
- {{ $t('settings.notification_visibility_follows') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="notificationVisibility.mentions">
- {{ $t('settings.notification_visibility_mentions') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="notificationVisibility.moves">
- {{ $t('settings.notification_visibility_moves') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="notificationVisibility.emojiReactions">
- {{ $t('settings.notification_visibility_emoji_reactions') }}
- </Checkbox>
- </li>
- </ul>
- </div>
- <div>
- {{ $t('settings.replies_in_timeline') }}
- <label
- for="replyVisibility"
- class="select"
- >
- <select
- id="replyVisibility"
- v-model="replyVisibility"
- >
- <option
- value="all"
- selected
- >{{ $t('settings.reply_visibility_all') }}</option>
- <option value="following">{{ $t('settings.reply_visibility_following') }}</option>
- <option value="self">{{ $t('settings.reply_visibility_self') }}</option>
- </select>
- <i class="icon-down-open" />
- </label>
- </div>
- <div>
- <Checkbox v-model="hidePostStats">
- {{ $t('settings.hide_post_stats') }} {{ $t('settings.instance_default', { value: hidePostStatsLocalizedValue }) }}
- </Checkbox>
- </div>
- <div>
- <Checkbox v-model="hideUserStats">
- {{ $t('settings.hide_user_stats') }} {{ $t('settings.instance_default', { value: hideUserStatsLocalizedValue }) }}
- </Checkbox>
- </div>
- </div>
- <div class="setting-item">
- <div>
- <p>{{ $t('settings.filtering_explanation') }}</p>
- <textarea
- id="muteWords"
- v-model="muteWordsString"
- />
- </div>
- <div>
- <Checkbox v-model="hideFilteredStatuses">
- {{ $t('settings.hide_filtered_statuses') }} {{ $t('settings.instance_default', { value: hideFilteredStatusesLocalizedValue }) }}
- </Checkbox>
- </div>
- </div>
- </div>
- <div :label="$t('settings.version.title')">
- <div class="setting-item">
- <ul class="setting-list">
- <li>
- <p>{{ $t('settings.version.backend_version') }}</p>
- <ul class="option-list">
- <li>
- <a
- :href="backendVersionLink"
- target="_blank"
- >{{ backendVersion }}</a>
- </li>
- </ul>
- </li>
- <li>
- <p>{{ $t('settings.version.frontend_version') }}</p>
- <ul class="option-list">
- <li>
- <a
- :href="frontendVersionLink"
- target="_blank"
- >{{ frontendVersion }}</a>
- </li>
- </ul>
- </li>
- </ul>
- </div>
- </div>
- </tab-switcher>
- </keep-alive>
- </div>
- </div>
-</template>
-
-<script src="./settings.js">
-</script>
diff --git a/src/components/settings_modal/helpers/shared_computed_object.js b/src/components/settings_modal/helpers/shared_computed_object.js
new file mode 100644
index 00000000..86703697
--- /dev/null
+++ b/src/components/settings_modal/helpers/shared_computed_object.js
@@ -0,0 +1,58 @@
+import {
+ instanceDefaultProperties,
+ multiChoiceProperties,
+ defaultState as configDefaultState
+} from 'src/modules/config.js'
+
+const SharedComputedObject = () => ({
+ user () {
+ return this.$store.state.users.currentUser
+ },
+ // Getting localized values for instance-default properties
+ ...instanceDefaultProperties
+ .filter(key => multiChoiceProperties.includes(key))
+ .map(key => [
+ key + 'DefaultValue',
+ function () {
+ return this.$store.getters.instanceDefaultConfig[key]
+ }
+ ])
+ .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
+ ...instanceDefaultProperties
+ .filter(key => !multiChoiceProperties.includes(key))
+ .map(key => [
+ key + 'LocalizedValue',
+ function () {
+ return this.$t('settings.values.' + this.$store.getters.instanceDefaultConfig[key])
+ }
+ ])
+ .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
+ // Generating computed values for vuex properties
+ ...Object.keys(configDefaultState)
+ .map(key => [key, {
+ get () { return this.$store.getters.mergedConfig[key] },
+ set (value) {
+ this.$store.dispatch('setOption', { name: key, value })
+ }
+ }])
+ .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
+ // Special cases (need to transform values or perform actions first)
+ useStreamingApi: {
+ get () { return this.$store.getters.mergedConfig.useStreamingApi },
+ set (value) {
+ const promise = value
+ ? this.$store.dispatch('enableMastoSockets')
+ : this.$store.dispatch('disableMastoSockets')
+
+ promise.then(() => {
+ this.$store.dispatch('setOption', { name: 'useStreamingApi', value })
+ }).catch((e) => {
+ console.error('Failed starting MastoAPI Streaming socket', e)
+ this.$store.dispatch('disableMastoSockets')
+ this.$store.dispatch('setOption', { name: 'useStreamingApi', value: false })
+ })
+ }
+ }
+})
+
+export default SharedComputedObject
diff --git a/src/components/settings_modal/settings_modal.js b/src/components/settings_modal/settings_modal.js
new file mode 100644
index 00000000..f0d49c91
--- /dev/null
+++ b/src/components/settings_modal/settings_modal.js
@@ -0,0 +1,42 @@
+import Modal from 'src/components/modal/modal.vue'
+import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
+import AsyncComponentError from 'src/components/async_component_error/async_component_error.vue'
+import getResettableAsyncComponent from 'src/services/resettable_async_component.js'
+
+const SettingsModal = {
+ components: {
+ Modal,
+ SettingsModalContent: getResettableAsyncComponent(
+ () => import('./settings_modal_content.vue'),
+ {
+ loading: PanelLoading,
+ error: AsyncComponentError,
+ delay: 0
+ }
+ )
+ },
+ methods: {
+ closeModal () {
+ this.$store.dispatch('closeSettingsModal')
+ },
+ peekModal () {
+ this.$store.dispatch('togglePeekSettingsModal')
+ }
+ },
+ computed: {
+ currentSaveStateNotice () {
+ return this.$store.state.interface.settings.currentSaveStateNotice
+ },
+ modalActivated () {
+ return this.$store.state.interface.settingsModalState !== 'hidden'
+ },
+ modalOpenedOnce () {
+ return this.$store.state.interface.settingsModalLoaded
+ },
+ modalPeeked () {
+ return this.$store.state.interface.settingsModalState === 'minimized'
+ }
+ }
+}
+
+export default SettingsModal
diff --git a/src/components/settings_modal/settings_modal.scss b/src/components/settings_modal/settings_modal.scss
new file mode 100644
index 00000000..90446b36
--- /dev/null
+++ b/src/components/settings_modal/settings_modal.scss
@@ -0,0 +1,51 @@
+@import 'src/_variables.scss';
+.settings-modal {
+ overflow: hidden;
+
+ &.peek {
+ .settings-modal-panel {
+ /* Explanation:
+ * Modal is positioned vertically centered.
+ * 100vh - 100% = Distance between modal's top+bottom boundaries and screen
+ * (100vh - 100%) / 2 = Distance between bottom (or top) boundary and screen
+ * + 100% - we move modal completely off-screen, it's top boundary touches
+ * bottom of the screen
+ * - 50px - leaving tiny amount of space so that titlebar + tiny amount of modal is visible
+ */
+ transform: translateY(calc(((100vh - 100%) / 2 + 100%) - 50px));
+
+ @media all and (max-width: 800px) {
+ /* For mobile, the modal takes 100% of the available screen.
+ This ensures the minimized modal is always 50px above the browser bottom bar regardless of whether or not it is visible.
+ */
+ transform: translateY(calc(100% - 50px));
+ }
+ }
+ }
+
+ .settings-modal-panel {
+ overflow: hidden;
+ transition: transform;
+ transition-timing-function: ease-in-out;
+ transition-duration: 300ms;
+ width: 1000px;
+ max-width: 90vw;
+ height: 90vh;
+
+ @media all and (max-width: 800px) {
+ max-width: 100vw;
+ height: 100%;
+ }
+
+ >.panel-body {
+ height: 100%;
+ overflow-y: hidden;
+
+ .btn {
+ min-height: 28px;
+ min-width: 10em;
+ padding: 0 2em;
+ }
+ }
+ }
+}
diff --git a/src/components/settings_modal/settings_modal.vue b/src/components/settings_modal/settings_modal.vue
new file mode 100644
index 00000000..6bc64ed0
--- /dev/null
+++ b/src/components/settings_modal/settings_modal.vue
@@ -0,0 +1,54 @@
+<template>
+ <Modal
+ :is-open="modalActivated"
+ class="settings-modal"
+ :class="{ peek: modalPeeked }"
+ :no-background="modalPeeked"
+ >
+ <div class="settings-modal-panel panel">
+ <div class="panel-heading">
+ <span class="title">
+ {{ $t('settings.settings') }}
+ </span>
+ <transition name="fade">
+ <template v-if="currentSaveStateNotice">
+ <div
+ v-if="currentSaveStateNotice.error"
+ class="alert error"
+ @click.prevent
+ >
+ {{ $t('settings.saving_err') }}
+ </div>
+
+ <div
+ v-if="!currentSaveStateNotice.error"
+ class="alert transparent"
+ @click.prevent
+ >
+ {{ $t('settings.saving_ok') }}
+ </div>
+ </template>
+ </transition>
+ <button
+ class="btn"
+ @click="peekModal"
+ >
+ {{ $t('general.peek') }}
+ </button>
+ <button
+ class="btn"
+ @click="closeModal"
+ >
+ {{ $t('general.close') }}
+ </button>
+ </div>
+ <div class="panel-body">
+ <SettingsModalContent v-if="modalOpenedOnce" />
+ </div>
+ </div>
+ </Modal>
+</template>
+
+<script src="./settings_modal.js"></script>
+
+<style src="./settings_modal.scss" lang="scss"></style>
diff --git a/src/components/settings_modal/settings_modal_content.js b/src/components/settings_modal/settings_modal_content.js
new file mode 100644
index 00000000..48101a90
--- /dev/null
+++ b/src/components/settings_modal/settings_modal_content.js
@@ -0,0 +1,34 @@
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
+
+import DataImportExportTab from './tabs/data_import_export_tab.vue'
+import MutesAndBlocksTab from './tabs/mutes_and_blocks_tab.vue'
+import NotificationsTab from './tabs/notifications_tab.vue'
+import FilteringTab from './tabs/filtering_tab.vue'
+import SecurityTab from './tabs/security_tab/security_tab.vue'
+import ProfileTab from './tabs/profile_tab.vue'
+import GeneralTab from './tabs/general_tab.vue'
+import VersionTab from './tabs/version_tab.vue'
+import ThemeTab from './tabs/theme_tab/theme_tab.vue'
+
+const SettingsModalContent = {
+ components: {
+ TabSwitcher,
+
+ DataImportExportTab,
+ MutesAndBlocksTab,
+ NotificationsTab,
+ FilteringTab,
+ SecurityTab,
+ ProfileTab,
+ GeneralTab,
+ VersionTab,
+ ThemeTab
+ },
+ computed: {
+ isLoggedIn () {
+ return !!this.$store.state.users.currentUser
+ }
+ }
+}
+
+export default SettingsModalContent
diff --git a/src/components/settings_modal/settings_modal_content.scss b/src/components/settings_modal/settings_modal_content.scss
new file mode 100644
index 00000000..a3fef1cf
--- /dev/null
+++ b/src/components/settings_modal/settings_modal_content.scss
@@ -0,0 +1,43 @@
+@import 'src/_variables.scss';
+.settings_tab-switcher {
+ height: 100%;
+
+ .setting-item {
+ border-bottom: 2px solid var(--fg, $fallback--fg);
+ margin: 1em 1em 1.4em;
+ padding-bottom: 1.4em;
+
+ > div {
+ margin-bottom: .5em;
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ &:last-child {
+ border-bottom: none;
+ padding-bottom: 0;
+ margin-bottom: 1em;
+ }
+
+ select {
+ min-width: 10em;
+ }
+
+ textarea {
+ width: 100%;
+ max-width: 100%;
+ height: 100px;
+ }
+
+ .unavailable,
+ .unavailable i {
+ color: var(--cRed, $fallback--cRed);
+ color: $fallback--cRed;
+ }
+
+ .number-input {
+ max-width: 6em;
+ }
+ }
+}
diff --git a/src/components/settings_modal/settings_modal_content.vue b/src/components/settings_modal/settings_modal_content.vue
new file mode 100644
index 00000000..2156844f
--- /dev/null
+++ b/src/components/settings_modal/settings_modal_content.vue
@@ -0,0 +1,73 @@
+<template>
+ <tab-switcher
+ ref="tabSwitcher"
+ class="settings_tab-switcher"
+ :side-tab-bar="true"
+ :scrollable-tabs="true"
+ >
+ <div
+ :label="$t('settings.general')"
+ icon="wrench"
+ >
+ <GeneralTab />
+ </div>
+ <div
+ v-if="isLoggedIn"
+ :label="$t('settings.profile_tab')"
+ icon="user"
+ >
+ <ProfileTab />
+ </div>
+ <div
+ v-if="isLoggedIn"
+ :label="$t('settings.security_tab')"
+ icon="lock"
+ >
+ <SecurityTab />
+ </div>
+ <div
+ :label="$t('settings.filtering')"
+ icon="filter"
+ >
+ <FilteringTab />
+ </div>
+ <div
+ :label="$t('settings.theme')"
+ icon="brush"
+ >
+ <ThemeTab />
+ </div>
+ <div
+ v-if="isLoggedIn"
+ :label="$t('settings.notifications')"
+ icon="bell-ringing-o"
+ >
+ <NotificationsTab />
+ </div>
+ <div
+ v-if="isLoggedIn"
+ :label="$t('settings.data_import_export_tab')"
+ icon="download"
+ >
+ <DataImportExportTab />
+ </div>
+ <div
+ v-if="isLoggedIn"
+ :label="$t('settings.mutes_and_blocks')"
+ :fullHeight="true"
+ icon="eye-off"
+ >
+ <MutesAndBlocksTab />
+ </div>
+ <div
+ :label="$t('settings.version.title')"
+ icon="info-circled"
+ >
+ <VersionTab />
+ </div>
+ </tab-switcher>
+</template>
+
+<script src="./settings_modal_content.js"></script>
+
+<style src="./settings_modal_content.scss" lang="scss"></style>
diff --git a/src/components/settings_modal/tabs/data_import_export_tab.js b/src/components/settings_modal/tabs/data_import_export_tab.js
new file mode 100644
index 00000000..168f89e1
--- /dev/null
+++ b/src/components/settings_modal/tabs/data_import_export_tab.js
@@ -0,0 +1,65 @@
+import Importer from 'src/components/importer/importer.vue'
+import Exporter from 'src/components/exporter/exporter.vue'
+import Checkbox from 'src/components/checkbox/checkbox.vue'
+
+const DataImportExportTab = {
+ data () {
+ return {
+ activeTab: 'profile',
+ newDomainToMute: ''
+ }
+ },
+ created () {
+ this.$store.dispatch('fetchTokens')
+ },
+ components: {
+ Importer,
+ Exporter,
+ Checkbox
+ },
+ computed: {
+ user () {
+ return this.$store.state.users.currentUser
+ }
+ },
+ methods: {
+ getFollowsContent () {
+ return this.$store.state.api.backendInteractor.exportFriends({ id: this.$store.state.users.currentUser.id })
+ .then(this.generateExportableUsersContent)
+ },
+ getBlocksContent () {
+ return this.$store.state.api.backendInteractor.fetchBlocks()
+ .then(this.generateExportableUsersContent)
+ },
+ importFollows (file) {
+ return this.$store.state.api.backendInteractor.importFollows({ file })
+ .then((status) => {
+ if (!status) {
+ throw new Error('failed')
+ }
+ })
+ },
+ importBlocks (file) {
+ return this.$store.state.api.backendInteractor.importBlocks({ file })
+ .then((status) => {
+ if (!status) {
+ throw new Error('failed')
+ }
+ })
+ },
+ generateExportableUsersContent (users) {
+ // Get addresses
+ return users.map((user) => {
+ // check is it's a local user
+ if (user && user.is_local) {
+ // append the instance address
+ // eslint-disable-next-line no-undef
+ return user.screen_name + '@' + location.hostname
+ }
+ return user.screen_name
+ }).join('\n')
+ }
+ }
+}
+
+export default DataImportExportTab
diff --git a/src/components/settings_modal/tabs/data_import_export_tab.vue b/src/components/settings_modal/tabs/data_import_export_tab.vue
new file mode 100644
index 00000000..b5d0f5ed
--- /dev/null
+++ b/src/components/settings_modal/tabs/data_import_export_tab.vue
@@ -0,0 +1,43 @@
+<template>
+ <div
+ :label="$t('settings.data_import_export_tab')"
+ >
+ <div class="setting-item">
+ <h2>{{ $t('settings.follow_import') }}</h2>
+ <p>{{ $t('settings.import_followers_from_a_csv_file') }}</p>
+ <Importer
+ :submit-handler="importFollows"
+ :success-message="$t('settings.follows_imported')"
+ :error-message="$t('settings.follow_import_error')"
+ />
+ </div>
+ <div class="setting-item">
+ <h2>{{ $t('settings.follow_export') }}</h2>
+ <Exporter
+ :get-content="getFollowsContent"
+ filename="friends.csv"
+ :export-button-label="$t('settings.follow_export_button')"
+ />
+ </div>
+ <div class="setting-item">
+ <h2>{{ $t('settings.block_import') }}</h2>
+ <p>{{ $t('settings.import_blocks_from_a_csv_file') }}</p>
+ <Importer
+ :submit-handler="importBlocks"
+ :success-message="$t('settings.blocks_imported')"
+ :error-message="$t('settings.block_import_error')"
+ />
+ </div>
+ <div class="setting-item">
+ <h2>{{ $t('settings.block_export') }}</h2>
+ <Exporter
+ :get-content="getBlocksContent"
+ filename="blocks.csv"
+ :export-button-label="$t('settings.block_export_button')"
+ />
+ </div>
+ </div>
+</template>
+
+<script src="./data_import_export_tab.js"></script>
+<!-- <style lang="scss" src="./profile.scss"></style> -->
diff --git a/src/components/settings_modal/tabs/filtering_tab.js b/src/components/settings_modal/tabs/filtering_tab.js
new file mode 100644
index 00000000..3b2df556
--- /dev/null
+++ b/src/components/settings_modal/tabs/filtering_tab.js
@@ -0,0 +1,47 @@
+import { filter, trim } from 'lodash'
+import Checkbox from 'src/components/checkbox/checkbox.vue'
+
+import SharedComputedObject from '../helpers/shared_computed_object.js'
+
+const FilteringTab = {
+ data () {
+ return {
+ muteWordsStringLocal: this.$store.getters.mergedConfig.muteWords.join('\n')
+ }
+ },
+ components: {
+ Checkbox
+ },
+ computed: {
+ ...SharedComputedObject(),
+ muteWordsString: {
+ get () {
+ return this.muteWordsStringLocal
+ },
+ set (value) {
+ this.muteWordsStringLocal = value
+ this.$store.dispatch('setOption', {
+ name: 'muteWords',
+ value: filter(value.split('\n'), (word) => trim(word).length > 0)
+ })
+ }
+ }
+ },
+ // Updating nested properties
+ watch: {
+ notificationVisibility: {
+ handler (value) {
+ this.$store.dispatch('setOption', {
+ name: 'notificationVisibility',
+ value: this.$store.getters.mergedConfig.notificationVisibility
+ })
+ },
+ deep: true
+ },
+ replyVisibility () {
+ this.$store.dispatch('queueFlushAll')
+ }
+ }
+}
+
+export default FilteringTab
diff --git a/src/components/settings_modal/tabs/filtering_tab.vue b/src/components/settings_modal/tabs/filtering_tab.vue
new file mode 100644
index 00000000..eea41514
--- /dev/null
+++ b/src/components/settings_modal/tabs/filtering_tab.vue
@@ -0,0 +1,86 @@
+<template>
+ <div :label="$t('settings.filtering')">
+ <div class="setting-item">
+ <div class="select-multiple">
+ <span class="label">{{ $t('settings.notification_visibility') }}</span>
+ <ul class="option-list">
+ <li>
+ <Checkbox v-model="notificationVisibility.likes">
+ {{ $t('settings.notification_visibility_likes') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="notificationVisibility.repeats">
+ {{ $t('settings.notification_visibility_repeats') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="notificationVisibility.follows">
+ {{ $t('settings.notification_visibility_follows') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="notificationVisibility.mentions">
+ {{ $t('settings.notification_visibility_mentions') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="notificationVisibility.moves">
+ {{ $t('settings.notification_visibility_moves') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="notificationVisibility.emojiReactions">
+ {{ $t('settings.notification_visibility_emoji_reactions') }}
+ </Checkbox>
+ </li>
+ </ul>
+ </div>
+ <div>
+ {{ $t('settings.replies_in_timeline') }}
+ <label
+ for="replyVisibility"
+ class="select"
+ >
+ <select
+ id="replyVisibility"
+ v-model="replyVisibility"
+ >
+ <option
+ value="all"
+ selected
+ >{{ $t('settings.reply_visibility_all') }}</option>
+ <option value="following">{{ $t('settings.reply_visibility_following') }}</option>
+ <option value="self">{{ $t('settings.reply_visibility_self') }}</option>
+ </select>
+ <i class="icon-down-open" />
+ </label>
+ </div>
+ <div>
+ <Checkbox v-model="hidePostStats">
+ {{ $t('settings.hide_post_stats') }} {{ $t('settings.instance_default', { value: hidePostStatsLocalizedValue }) }}
+ </Checkbox>
+ </div>
+ <div>
+ <Checkbox v-model="hideUserStats">
+ {{ $t('settings.hide_user_stats') }} {{ $t('settings.instance_default', { value: hideUserStatsLocalizedValue }) }}
+ </Checkbox>
+ </div>
+ </div>
+ <div class="setting-item">
+ <div>
+ <p>{{ $t('settings.filtering_explanation') }}</p>
+ <textarea
+ id="muteWords"
+ v-model="muteWordsString"
+ />
+ </div>
+ <div>
+ <Checkbox v-model="hideFilteredStatuses">
+ {{ $t('settings.hide_filtered_statuses') }} {{ $t('settings.instance_default', { value: hideFilteredStatusesLocalizedValue }) }}
+ </Checkbox>
+ </div>
+ </div>
+ </div>
+</template>
+<script src="./filtering_tab.js"></script>
diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js
new file mode 100644
index 00000000..0eb37e44
--- /dev/null
+++ b/src/components/settings_modal/tabs/general_tab.js
@@ -0,0 +1,31 @@
+import Checkbox from 'src/components/checkbox/checkbox.vue'
+import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
+
+import SharedComputedObject from '../helpers/shared_computed_object.js'
+
+const GeneralTab = {
+ data () {
+ return {
+ loopSilentAvailable:
+ // Firefox
+ Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') ||
+ // Chrome-likes
+ Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'webkitAudioDecodedByteCount') ||
+ // Future spec, still not supported in Nightly 63 as of 08/2018
+ Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'audioTracks')
+ }
+ },
+ components: {
+ Checkbox,
+ InterfaceLanguageSwitcher
+ },
+ computed: {
+ postFormats () {
+ return this.$store.state.instance.postFormats || []
+ },
+ instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel },
+ ...SharedComputedObject()
+ }
+}
+
+export default GeneralTab
diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue
new file mode 100644
index 00000000..7f06d0bd
--- /dev/null
+++ b/src/components/settings_modal/tabs/general_tab.vue
@@ -0,0 +1,262 @@
+<template>
+ <div :label="$t('settings.general')">
+ <div class="setting-item">
+ <h2>{{ $t('settings.interface') }}</h2>
+ <ul class="setting-list">
+ <li>
+ <interface-language-switcher />
+ </li>
+ <li v-if="instanceSpecificPanelPresent">
+ <Checkbox v-model="hideISP">
+ {{ $t('settings.hide_isp') }}
+ </Checkbox>
+ </li>
+ </ul>
+ </div>
+ <div class="setting-item">
+ <h2>{{ $t('nav.timeline') }}</h2>
+ <ul class="setting-list">
+ <li>
+ <Checkbox v-model="hideMutedPosts">
+ {{ $t('settings.hide_muted_posts') }} {{ $t('settings.instance_default', { value: hideMutedPostsLocalizedValue }) }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="collapseMessageWithSubject">
+ {{ $t('settings.collapse_subject') }} {{ $t('settings.instance_default', { value: collapseMessageWithSubjectLocalizedValue }) }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="streaming">
+ {{ $t('settings.streaming') }}
+ </Checkbox>
+ <ul
+ class="setting-list suboptions"
+ :class="[{disabled: !streaming}]"
+ >
+ <li>
+ <Checkbox
+ v-model="pauseOnUnfocused"
+ :disabled="!streaming"
+ >
+ {{ $t('settings.pause_on_unfocused') }}
+ </Checkbox>
+ </li>
+ </ul>
+ </li>
+ <li>
+ <Checkbox v-model="useStreamingApi">
+ {{ $t('settings.useStreamingApi') }}
+ <br>
+ <small>
+ {{ $t('settings.useStreamingApiWarning') }}
+ </small>
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="emojiReactionsOnTimeline">
+ {{ $t('settings.emoji_reactions_on_timeline') }}
+ </Checkbox>
+ </li>
+ </ul>
+ </div>
+
+ <div class="setting-item">
+ <h2>{{ $t('settings.composing') }}</h2>
+ <ul class="setting-list">
+ <li>
+ <Checkbox v-model="scopeCopy">
+ {{ $t('settings.scope_copy') }} {{ $t('settings.instance_default', { value: scopeCopyLocalizedValue }) }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="alwaysShowSubjectInput">
+ {{ $t('settings.subject_input_always_show') }} {{ $t('settings.instance_default', { value: alwaysShowSubjectInputLocalizedValue }) }}
+ </Checkbox>
+ </li>
+ <li>
+ <div>
+ {{ $t('settings.subject_line_behavior') }}
+ <label
+ for="subjectLineBehavior"
+ class="select"
+ >
+ <select
+ id="subjectLineBehavior"
+ v-model="subjectLineBehavior"
+ >
+ <option value="email">
+ {{ $t('settings.subject_line_email') }}
+ {{ subjectLineBehaviorDefaultValue == 'email' ? $t('settings.instance_default_simple') : '' }}
+ </option>
+ <option value="masto">
+ {{ $t('settings.subject_line_mastodon') }}
+ {{ subjectLineBehaviorDefaultValue == 'mastodon' ? $t('settings.instance_default_simple') : '' }}
+ </option>
+ <option value="noop">
+ {{ $t('settings.subject_line_noop') }}
+ {{ subjectLineBehaviorDefaultValue == 'noop' ? $t('settings.instance_default_simple') : '' }}
+ </option>
+ </select>
+ <i class="icon-down-open" />
+ </label>
+ </div>
+ </li>
+ <li v-if="postFormats.length > 0">
+ <div>
+ {{ $t('settings.post_status_content_type') }}
+ <label
+ for="postContentType"
+ class="select"
+ >
+ <select
+ id="postContentType"
+ v-model="postContentType"
+ >
+ <option
+ v-for="postFormat in postFormats"
+ :key="postFormat"
+ :value="postFormat"
+ >
+ {{ $t(`post_status.content_type["${postFormat}"]`) }}
+ {{ postContentTypeDefaultValue === postFormat ? $t('settings.instance_default_simple') : '' }}
+ </option>
+ </select>
+ <i class="icon-down-open" />
+ </label>
+ </div>
+ </li>
+ <li>
+ <Checkbox v-model="minimalScopesMode">
+ {{ $t('settings.minimal_scopes_mode') }} {{ $t('settings.instance_default', { value: minimalScopesModeLocalizedValue }) }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="autohideFloatingPostButton">
+ {{ $t('settings.autohide_floating_post_button') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="padEmoji">
+ {{ $t('settings.pad_emoji') }}
+ </Checkbox>
+ </li>
+ </ul>
+ </div>
+
+ <div class="setting-item">
+ <h2>{{ $t('settings.attachments') }}</h2>
+ <ul class="setting-list">
+ <li>
+ <Checkbox v-model="hideAttachments">
+ {{ $t('settings.hide_attachments_in_tl') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="hideAttachmentsInConv">
+ {{ $t('settings.hide_attachments_in_convo') }}
+ </Checkbox>
+ </li>
+ <li>
+ <label for="maxThumbnails">
+ {{ $t('settings.max_thumbnails') }}
+ </label>
+ <input
+ id="maxThumbnails"
+ v-model.number="maxThumbnails"
+ class="number-input"
+ type="number"
+ min="0"
+ step="1"
+ >
+ </li>
+ <li>
+ <Checkbox v-model="hideNsfw">
+ {{ $t('settings.nsfw_clickthrough') }}
+ </Checkbox>
+ </li>
+ <ul class="setting-list suboptions">
+ <li>
+ <Checkbox
+ v-model="preloadImage"
+ :disabled="!hideNsfw"
+ >
+ {{ $t('settings.preload_images') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox
+ v-model="useOneClickNsfw"
+ :disabled="!hideNsfw"
+ >
+ {{ $t('settings.use_one_click_nsfw') }}
+ </Checkbox>
+ </li>
+ </ul>
+ <li>
+ <Checkbox v-model="stopGifs">
+ {{ $t('settings.stop_gifs') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="loopVideo">
+ {{ $t('settings.loop_video') }}
+ </Checkbox>
+ <ul
+ class="setting-list suboptions"
+ :class="[{disabled: !streaming}]"
+ >
+ <li>
+ <Checkbox
+ v-model="loopVideoSilentOnly"
+ :disabled="!loopVideo || !loopSilentAvailable"
+ >
+ {{ $t('settings.loop_video_silent_only') }}
+ </Checkbox>
+ <div
+ v-if="!loopSilentAvailable"
+ class="unavailable"
+ >
+ <i class="icon-globe" />! {{ $t('settings.limited_availability') }}
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li>
+ <Checkbox v-model="playVideosInModal">
+ {{ $t('settings.play_videos_in_modal') }}
+ </Checkbox>
+ </li>
+ <li>
+ <Checkbox v-model="useContainFit">
+ {{ $t('settings.use_contain_fit') }}
+ </Checkbox>
+ </li>
+ </ul>
+ </div>
+
+ <div class="setting-item">
+ <h2>{{ $t('settings.notifications') }}</h2>
+ <ul class="setting-list">
+ <li>
+ <Checkbox v-model="webPushNotifications">
+ {{ $t('settings.enable_web_push_notifications') }}
+ </Checkbox>
+ </li>
+ </ul>
+ </div>
+
+ <div class="setting-item">
+ <h2>{{ $t('settings.fun') }}</h2>
+ <ul class="setting-list">
+ <li>
+ <Checkbox v-model="greentext">
+ {{ $t('settings.greentext') }} {{ $t('settings.instance_default', { value: greentextLocalizedValue }) }}
+ </Checkbox>
+ </li>
+ </ul>
+ </div>
+ </div>
+</template>
+
+<script src="./general_tab.js"></script>
diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.js b/src/components/settings_modal/tabs/mutes_and_blocks_tab.js
new file mode 100644
index 00000000..40a87b81
--- /dev/null
+++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.js
@@ -0,0 +1,136 @@
+import get from 'lodash/get'
+import map from 'lodash/map'
+import reject from 'lodash/reject'
+import Autosuggest from 'src/components/autosuggest/autosuggest.vue'
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
+import BlockCard from 'src/components/block_card/block_card.vue'
+import MuteCard from 'src/components/mute_card/mute_card.vue'
+import DomainMuteCard from 'src/components/domain_mute_card/domain_mute_card.vue'
+import SelectableList from 'src/components/selectable_list/selectable_list.vue'
+import ProgressButton from 'src/components/progress_button/progress_button.vue'
+import withSubscription from 'src/components/../hocs/with_subscription/with_subscription'
+import Checkbox from 'src/components/checkbox/checkbox.vue'
+
+const BlockList = withSubscription({
+ fetch: (props, $store) => $store.dispatch('fetchBlocks'),
+ select: (props, $store) => get($store.state.users.currentUser, 'blockIds', []),
+ childPropName: 'items'
+})(SelectableList)
+
+const MuteList = withSubscription({
+ fetch: (props, $store) => $store.dispatch('fetchMutes'),
+ select: (props, $store) => get($store.state.users.currentUser, 'muteIds', []),
+ childPropName: 'items'
+})(SelectableList)
+
+const DomainMuteList = withSubscription({
+ fetch: (props, $store) => $store.dispatch('fetchDomainMutes'),
+ select: (props, $store) => get($store.state.users.currentUser, 'domainMutes', []),
+ childPropName: 'items'
+})(SelectableList)
+
+const MutesAndBlocks = {
+ data () {
+ return {
+ activeTab: 'profile'
+ }
+ },
+ created () {
+ this.$store.dispatch('fetchTokens')
+ this.$store.dispatch('getKnownDomains')
+ },
+ components: {
+ TabSwitcher,
+ BlockList,
+ MuteList,
+ DomainMuteList,
+ BlockCard,
+ MuteCard,
+ DomainMuteCard,
+ ProgressButton,
+ Autosuggest,
+ Checkbox
+ },
+ computed: {
+ knownDomains () {
+ return this.$store.state.instance.knownDomains
+ },
+ user () {
+ return this.$store.state.users.currentUser
+ }
+ },
+ methods: {
+ importFollows (file) {
+ return this.$store.state.api.backendInteractor.importFollows({ file })
+ .then((status) => {
+ if (!status) {
+ throw new Error('failed')
+ }
+ })
+ },
+ importBlocks (file) {
+ return this.$store.state.api.backendInteractor.importBlocks({ file })
+ .then((status) => {
+ if (!status) {
+ throw new Error('failed')
+ }
+ })
+ },
+ generateExportableUsersContent (users) {
+ // Get addresses
+ return users.map((user) => {
+ // check is it's a local user
+ if (user && user.is_local) {
+ // append the instance address
+ // eslint-disable-next-line no-undef
+ return user.screen_name + '@' + location.hostname
+ }
+ return user.screen_name
+ }).join('\n')
+ },
+ activateTab (tabName) {
+ this.activeTab = tabName
+ },
+ filterUnblockedUsers (userIds) {
+ return reject(userIds, (userId) => {
+ const relationship = this.$store.getters.relationship(this.userId)
+ return relationship.blocking || userId === this.user.id
+ })
+ },
+ filterUnMutedUsers (userIds) {
+ return reject(userIds, (userId) => {
+ const relationship = this.$store.getters.relationship(this.userId)
+ return relationship.muting || userId === this.user.id
+ })
+ },
+ queryUserIds (query) {
+ return this.$store.dispatch('searchUsers', { query })
+ .then((users) => map(users, 'id'))
+ },
+ blockUsers (ids) {
+ return this.$store.dispatch('blockUsers', ids)
+ },
+ unblockUsers (ids) {
+ return this.$store.dispatch('unblockUsers', ids)
+ },
+ muteUsers (ids) {
+ return this.$store.dispatch('muteUsers', ids)
+ },
+ unmuteUsers (ids) {
+ return this.$store.dispatch('unmuteUsers', ids)
+ },
+ filterUnMutedDomains (urls) {
+ return urls.filter(url => !this.user.domainMutes.includes(url))
+ },
+ queryKnownDomains (query) {
+ return new Promise((resolve, reject) => {
+ resolve(this.knownDomains.filter(url => url.toLowerCase().includes(query)))
+ })
+ },
+ unmuteDomains (domains) {
+ return this.$store.dispatch('unmuteDomains', domains)
+ }
+ }
+}
+
+export default MutesAndBlocks
diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.scss b/src/components/settings_modal/tabs/mutes_and_blocks_tab.scss
new file mode 100644
index 00000000..ceb64efb
--- /dev/null
+++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.scss
@@ -0,0 +1,29 @@
+.mutes-and-blocks-tab {
+ height: 100%;
+
+ .usersearch-wrapper {
+ padding: 1em;
+ }
+
+ .bulk-actions {
+ text-align: right;
+ padding: 0 1em;
+ min-height: 28px;
+ }
+
+ .bulk-action-button {
+ width: 10em
+ }
+
+ .domain-mute-form {
+ padding: 1em;
+ display: flex;
+ flex-direction: column
+ }
+
+ .domain-mute-button {
+ align-self: flex-end;
+ margin-top: 1em;
+ width: 10em
+ }
+}
diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue b/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue
new file mode 100644
index 00000000..5a1cf2c0
--- /dev/null
+++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue
@@ -0,0 +1,171 @@
+<template>
+ <tab-switcher
+ :scrollable-tabs="true"
+ class="mutes-and-blocks-tab"
+ >
+ <div :label="$t('settings.blocks_tab')">
+ <div class="usersearch-wrapper">
+ <Autosuggest
+ :filter="filterUnblockedUsers"
+ :query="queryUserIds"
+ :placeholder="$t('settings.search_user_to_block')"
+ >
+ <BlockCard
+ slot-scope="row"
+ :user-id="row.item"
+ />
+ </Autosuggest>
+ </div>
+ <BlockList
+ :refresh="true"
+ :get-key="i => i"
+ >
+ <template
+ slot="header"
+ slot-scope="{selected}"
+ >
+ <div class="bulk-actions">
+ <ProgressButton
+ v-if="selected.length > 0"
+ class="btn btn-default bulk-action-button"
+ :click="() => blockUsers(selected)"
+ >
+ {{ $t('user_card.block') }}
+ <template slot="progress">
+ {{ $t('user_card.block_progress') }}
+ </template>
+ </ProgressButton>
+ <ProgressButton
+ v-if="selected.length > 0"
+ class="btn btn-default"
+ :click="() => unblockUsers(selected)"
+ >
+ {{ $t('user_card.unblock') }}
+ <template slot="progress">
+ {{ $t('user_card.unblock_progress') }}
+ </template>
+ </ProgressButton>
+ </div>
+ </template>
+ <template
+ slot="item"
+ slot-scope="{item}"
+ >
+ <BlockCard :user-id="item" />
+ </template>
+ <template slot="empty">
+ {{ $t('settings.no_blocks') }}
+ </template>
+ </BlockList>
+ </div>
+
+ <div :label="$t('settings.mutes_tab')">
+ <tab-switcher>
+ <div label="Users">
+ <div class="usersearch-wrapper">
+ <Autosuggest
+ :filter="filterUnMutedUsers"
+ :query="queryUserIds"
+ :placeholder="$t('settings.search_user_to_mute')"
+ >
+ <MuteCard
+ slot-scope="row"
+ :user-id="row.item"
+ />
+ </Autosuggest>
+ </div>
+ <MuteList
+ :refresh="true"
+ :get-key="i => i"
+ >
+ <template
+ slot="header"
+ slot-scope="{selected}"
+ >
+ <div class="bulk-actions">
+ <ProgressButton
+ v-if="selected.length > 0"
+ class="btn btn-default"
+ :click="() => muteUsers(selected)"
+ >
+ {{ $t('user_card.mute') }}
+ <template slot="progress">
+ {{ $t('user_card.mute_progress') }}
+ </template>
+ </ProgressButton>
+ <ProgressButton
+ v-if="selected.length > 0"
+ class="btn btn-default"
+ :click="() => unmuteUsers(selected)"
+ >
+ {{ $t('user_card.unmute') }}
+ <template slot="progress">
+ {{ $t('user_card.unmute_progress') }}
+ </template>
+ </ProgressButton>
+ </div>
+ </template>
+ <template
+ slot="item"
+ slot-scope="{item}"
+ >
+ <MuteCard :user-id="item" />
+ </template>
+ <template slot="empty">
+ {{ $t('settings.no_mutes') }}
+ </template>
+ </MuteList>
+ </div>
+
+ <div :label="$t('settings.domain_mutes')">
+ <div class="domain-mute-form">
+ <Autosuggest
+ :filter="filterUnMutedDomains"
+ :query="queryKnownDomains"
+ :placeholder="$t('settings.type_domains_to_mute')"
+ >
+ <DomainMuteCard
+ slot-scope="row"
+ :domain="row.item"
+ />
+ </Autosuggest>
+ </div>
+ <DomainMuteList
+ :refresh="true"
+ :get-key="i => i"
+ >
+ <template
+ slot="header"
+ slot-scope="{selected}"
+ >
+ <div class="bulk-actions">
+ <ProgressButton
+ v-if="selected.length > 0"
+ class="btn btn-default"
+ :click="() => unmuteDomains(selected)"
+ >
+ {{ $t('domain_mute_card.unmute') }}
+ <template slot="progress">
+ {{ $t('domain_mute_card.unmute_progress') }}
+ </template>
+ </ProgressButton>
+ </div>
+ </template>
+ <template
+ slot="item"
+ slot-scope="{item}"
+ >
+ <DomainMuteCard :domain="item" />
+ </template>
+ <template slot="empty">
+ {{ $t('settings.no_mutes') }}
+ </template>
+ </DomainMuteList>
+ </div>
+ </tab-switcher>
+ </div>
+ </tab-switcher>
+</template>
+
+<script src="./mutes_and_blocks_tab.js"></script>
+<style lang="scss" src="./mutes_and_blocks_tab.scss"></style>
diff --git a/src/components/settings_modal/tabs/notifications_tab.js b/src/components/settings_modal/tabs/notifications_tab.js
new file mode 100644
index 00000000..3e44c95d
--- /dev/null
+++ b/src/components/settings_modal/tabs/notifications_tab.js
@@ -0,0 +1,27 @@
+import Checkbox from 'src/components/checkbox/checkbox.vue'
+
+const NotificationsTab = {
+ data () {
+ return {
+ activeTab: 'profile',
+ notificationSettings: this.$store.state.users.currentUser.notification_settings,
+ newDomainToMute: ''
+ }
+ },
+ components: {
+ Checkbox
+ },
+ computed: {
+ user () {
+ return this.$store.state.users.currentUser
+ }
+ },
+ methods: {
+ updateNotificationSettings () {
+ this.$store.state.api.backendInteractor
+ .updateNotificationSettings({ settings: this.notificationSettings })
+ }
+ }
+}
+
+export default NotificationsTab
diff --git a/src/components/settings_modal/tabs/notifications_tab.vue b/src/components/settings_modal/tabs/notifications_tab.vue
new file mode 100644
index 00000000..86eed3f5
--- /dev/null
+++ b/src/components/settings_modal/tabs/notifications_tab.vue
@@ -0,0 +1,34 @@
+<template>
+ <div :label="$t('settings.notifications')">
+ <div class="setting-item">
+ <h2>{{ $t('settings.notification_setting_filters') }}</h2>
+ <p>
+ <Checkbox v-model="notificationSettings.block_from_strangers">
+ {{ $t('settings.notification_setting_block_from_strangers') }}
+ </Checkbox>
+ </p>
+ </div>
+
+ <div class="setting-item">
+ <h2>{{ $t('settings.notification_setting_privacy') }}</h2>
+ <p>
+ <Checkbox v-model="notificationSettings.hide_notification_contents">
+ {{ $t('settings.notification_setting_hide_notification_contents') }}
+ </Checkbox>
+ </p>
+ </div>
+ <div class="setting-item">
+ <p>{{ $t('settings.notification_mutes') }}</p>
+ <p>{{ $t('settings.notification_blocks') }}</p>
+ <button
+ class="btn btn-default"
+ @click="updateNotificationSettings"
+ >
+ {{ $t('general.submit') }}
+ </button>
+ </div>
+ </div>
+</template>
+
+<script src="./notifications_tab.js"></script>
+<!-- <style lang="scss" src="./profile.scss"></style> -->
diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js
new file mode 100644
index 00000000..bd6bef6a
--- /dev/null
+++ b/src/components/settings_modal/tabs/profile_tab.js
@@ -0,0 +1,253 @@
+import unescape from 'lodash/unescape'
+import merge from 'lodash/merge'
+import ImageCropper from 'src/components/image_cropper/image_cropper.vue'
+import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
+import fileSizeFormatService from 'src/components/../services/file_size_format/file_size_format.js'
+import ProgressButton from 'src/components/progress_button/progress_button.vue'
+import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
+import suggestor from 'src/components/emoji_input/suggestor.js'
+import Autosuggest from 'src/components/autosuggest/autosuggest.vue'
+import Checkbox from 'src/components/checkbox/checkbox.vue'
+
+const ProfileTab = {
+ data () {
+ return {
+ newName: this.$store.state.users.currentUser.name,
+ newBio: unescape(this.$store.state.users.currentUser.description),
+ newLocked: this.$store.state.users.currentUser.locked,
+ newNoRichText: this.$store.state.users.currentUser.no_rich_text,
+ newDefaultScope: this.$store.state.users.currentUser.default_scope,
+ newFields: this.$store.state.users.currentUser.fields.map(field => ({ name: field.name, value: field.value })),
+ hideFollows: this.$store.state.users.currentUser.hide_follows,
+ hideFollowers: this.$store.state.users.currentUser.hide_followers,
+ hideFollowsCount: this.$store.state.users.currentUser.hide_follows_count,
+ hideFollowersCount: this.$store.state.users.currentUser.hide_followers_count,
+ showRole: this.$store.state.users.currentUser.show_role,
+ role: this.$store.state.users.currentUser.role,
+ discoverable: this.$store.state.users.currentUser.discoverable,
+ bot: this.$store.state.users.currentUser.bot,
+ allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
+ pickAvatarBtnVisible: true,
+ bannerUploading: false,
+ backgroundUploading: false,
+ banner: null,
+ bannerPreview: null,
+ background: null,
+ backgroundPreview: null,
+ bannerUploadError: null,
+ backgroundUploadError: null
+ }
+ },
+ components: {
+ ScopeSelector,
+ ImageCropper,
+ EmojiInput,
+ Autosuggest,
+ ProgressButton,
+ Checkbox
+ },
+ computed: {
+ user () {
+ return this.$store.state.users.currentUser
+ },
+ emojiUserSuggestor () {
+ return suggestor({
+ emoji: [
+ ...this.$store.state.instance.emoji,
+ ...this.$store.state.instance.customEmoji
+ ],
+ users: this.$store.state.users.users,
+ updateUsersList: (query) => this.$store.dispatch('searchUsers', { query })
+ })
+ },
+ emojiSuggestor () {
+ return suggestor({ emoji: [
+ ...this.$store.state.instance.emoji,
+ ...this.$store.state.instance.customEmoji
+ ] })
+ },
+ userSuggestor () {
+ return suggestor({
+ users: this.$store.state.users.users,
+ updateUsersList: (query) => this.$store.dispatch('searchUsers', { query })
+ })
+ },
+ fieldsLimits () {
+ return this.$store.state.instance.fieldsLimits
+ },
+ maxFields () {
+ return this.fieldsLimits ? this.fieldsLimits.maxFields : 0
+ },
+ defaultAvatar () {
+ return this.$store.state.instance.server + this.$store.state.instance.defaultAvatar
+ },
+ defaultBanner () {
+ return this.$store.state.instance.server + this.$store.state.instance.defaultBanner
+ },
+ isDefaultAvatar () {
+ const baseAvatar = this.$store.state.instance.defaultAvatar
+ return !(this.$store.state.users.currentUser.profile_image_url) ||
+ this.$store.state.users.currentUser.profile_image_url.includes(baseAvatar)
+ },
+ isDefaultBanner () {
+ const baseBanner = this.$store.state.instance.defaultBanner
+ return !(this.$store.state.users.currentUser.cover_photo) ||
+ this.$store.state.users.currentUser.cover_photo.includes(baseBanner)
+ },
+ isDefaultBackground () {
+ return !(this.$store.state.users.currentUser.background_image)
+ },
+ avatarImgSrc () {
+ const src = this.$store.state.users.currentUser.profile_image_url_original
+ return (!src) ? this.defaultAvatar : src
+ },
+ bannerImgSrc () {
+ const src = this.$store.state.users.currentUser.cover_photo
+ return (!src) ? this.defaultBanner : src
+ }
+ },
+ methods: {
+ updateProfile () {
+ this.$store.state.api.backendInteractor
+ .updateProfile({
+ params: {
+ note: this.newBio,
+ locked: this.newLocked,
+ // Backend notation.
+ /* eslint-disable camelcase */
+ display_name: this.newName,
+ fields_attributes: this.newFields.filter(el => el != null),
+ default_scope: this.newDefaultScope,
+ no_rich_text: this.newNoRichText,
+ hide_follows: this.hideFollows,
+ hide_followers: this.hideFollowers,
+ discoverable: this.discoverable,
+ bot: this.bot,
+ allow_following_move: this.allowFollowingMove,
+ hide_follows_count: this.hideFollowsCount,
+ hide_followers_count: this.hideFollowersCount,
+ show_role: this.showRole
+ /* eslint-enable camelcase */
+ } }).then((user) => {
+ this.newFields.splice(user.fields.length)
+ merge(this.newFields, user.fields)
+ this.$store.commit('addNewUsers', [user])
+ this.$store.commit('setCurrentUser', user)
+ })
+ },
+ changeVis (visibility) {
+ this.newDefaultScope = visibility
+ },
+ addField () {
+ if (this.newFields.length < this.maxFields) {
+ this.newFields.push({ name: '', value: '' })
+ return true
+ }
+ return false
+ },
+ deleteField (index, event) {
+ this.$delete(this.newFields, index)
+ },
+ uploadFile (slot, e) {
+ const file = e.target.files[0]
+ if (!file) { return }
+ if (file.size > this.$store.state.instance[slot + 'limit']) {
+ const filesize = fileSizeFormatService.fileSizeFormat(file.size)
+ const allowedsize = fileSizeFormatService.fileSizeFormat(this.$store.state.instance[slot + 'limit'])
+ this[slot + 'UploadError'] = [
+ this.$t('upload.error.base'),
+ this.$t(
+ 'upload.error.file_too_big',
+ {
+ filesize: filesize.num,
+ filesizeunit: filesize.unit,
+ allowedsize: allowedsize.num,
+ allowedsizeunit: allowedsize.unit
+ }
+ )
+ ].join(' ')
+ return
+ }
+ // eslint-disable-next-line no-undef
+ const reader = new FileReader()
+ reader.onload = ({ target }) => {
+ const img = target.result
+ this[slot + 'Preview'] = img
+ this[slot] = file
+ }
+ reader.readAsDataURL(file)
+ },
+ resetAvatar () {
+ const confirmed = window.confirm(this.$t('settings.reset_avatar_confirm'))
+ if (confirmed) {
+ this.submitAvatar(undefined, '')
+ }
+ },
+ resetBanner () {
+ const confirmed = window.confirm(this.$t('settings.reset_banner_confirm'))
+ if (confirmed) {
+ this.submitBanner('')
+ }
+ },
+ resetBackground () {
+ const confirmed = window.confirm(this.$t('settings.reset_background_confirm'))
+ if (confirmed) {
+ this.submitBackground('')
+ }
+ },
+ submitAvatar (cropper, file) {
+ const that = this
+ return new Promise((resolve, reject) => {
+ function updateAvatar (avatar) {
+ that.$store.state.api.backendInteractor.updateProfileImages({ avatar })
+ .then((user) => {
+ that.$store.commit('addNewUsers', [user])
+ that.$store.commit('setCurrentUser', user)
+ resolve()
+ })
+ .catch((err) => {
+ reject(new Error(that.$t('upload.error.base') + ' ' + err.message))
+ })
+ }
+
+ if (cropper) {
+ cropper.getCroppedCanvas().toBlob(updateAvatar, file.type)
+ } else {
+ updateAvatar(file)
+ }
+ })
+ },
+ submitBanner (banner) {
+ if (!this.bannerPreview && banner !== '') { return }
+
+ this.bannerUploading = true
+ this.$store.state.api.backendInteractor.updateProfileImages({ banner })
+ .then((user) => {
+ this.$store.commit('addNewUsers', [user])
+ this.$store.commit('setCurrentUser', user)
+ this.bannerPreview = null
+ })
+ .catch((err) => {
+ this.bannerUploadError = this.$t('upload.error.base') + ' ' + err.message
+ })
+ .then(() => { this.bannerUploading = false })
+ },
+ submitBackground (background) {
+ if (!this.backgroundPreview && background !== '') { return }
+
+ this.backgroundUploading = true
+ this.$store.state.api.backendInteractor.updateProfileImages({ background }).then((data) => {
+ if (!data.error) {
+ this.$store.commit('addNewUsers', [data])
+ this.$store.commit('setCurrentUser', data)
+ this.backgroundPreview = null
+ } else {
+ this.backgroundUploadError = this.$t('upload.error.base') + data.error
+ }
+ this.backgroundUploading = false
+ })
+ }
+ }
+}
+
+export default ProfileTab
diff --git a/src/components/settings_modal/tabs/profile_tab.scss b/src/components/settings_modal/tabs/profile_tab.scss
new file mode 100644
index 00000000..e14cf054
--- /dev/null
+++ b/src/components/settings_modal/tabs/profile_tab.scss
@@ -0,0 +1,128 @@
+@import '../../../_variables.scss';
+.profile-tab {
+ .bio {
+ margin: 0;
+ }
+
+ .visibility-tray {
+ padding-top: 5px;
+ }
+
+ input[type=file] {
+ padding: 5px;
+ height: auto;
+ }
+
+ .banner-background-preview {
+ max-width: 100%;
+ width: 300px;
+ position: relative;
+
+ img {
+ width: 100%;
+ }
+ }
+
+ .uploading {
+ font-size: 1.5em;
+ margin: 0.25em;
+ }
+
+ .name-changer {
+ width: 100%;
+ }
+
+ .current-avatar-container {
+ position: relative;
+ width: 150px;
+ height: 150px;
+ }
+
+ .current-avatar {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border-radius: $fallback--avatarRadius;
+ border-radius: var(--avatarRadius, $fallback--avatarRadius);
+ }
+
+ .reset-button {
+ position: absolute;
+ top: 0.2em;
+ right: 0.2em;
+ border-radius: $fallback--tooltipRadius;
+ border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
+ background-color: rgba(0, 0, 0, 0.6);
+ opacity: 0.7;
+ color: white;
+ width: 1.5em;
+ height: 1.5em;
+ text-align: center;
+ line-height: 1.5em;
+ font-size: 1.5em;
+ cursor: pointer;
+ &:hover {
+ opacity: 1;
+ }
+ }
+
+ .oauth-tokens {
+ width: 100%;
+
+ th {
+ text-align: left;
+ }
+
+ .actions {
+ text-align: right;
+ }
+ }
+
+ &-usersearch-wrapper {
+ padding: 1em;
+ }
+
+ &-bulk-actions {
+ text-align: right;
+ padding: 0 1em;
+ min-height: 28px;
+
+ button {
+ width: 10em;
+ }
+ }
+
+ &-domain-mute-form {
+ padding: 1em;
+ display: flex;
+ flex-direction: column;
+
+ button {
+ align-self: flex-end;
+ margin-top: 1em;
+ width: 10em;
+ }
+ }
+
+ .setting-subitem {
+ margin-left: 1.75em;
+ }
+
+ .profile-fields {
+ display: flex;
+
+ &>.emoji-input {
+ flex: 1 1 auto;
+ margin: 0 .2em .5em;
+ min-width: 0;
+ }
+
+ &>.icon-container {
+ width: 20px;
+
+ &>.icon-cancel {
+ vertical-align: sub;
+ }
+ }
+ }
+}
diff --git a/src/components/settings_modal/tabs/profile_tab.vue b/src/components/settings_modal/tabs/profile_tab.vue
new file mode 100644
index 00000000..cf88c4e4
--- /dev/null
+++ b/src/components/settings_modal/tabs/profile_tab.vue
@@ -0,0 +1,289 @@
+<template>
+ <div class="profile-tab">
+ <div class="setting-item">
+ <h2>{{ $t('settings.name_bio') }}</h2>
+ <p>{{ $t('settings.name') }}</p>
+ <EmojiInput
+ v-model="newName"
+ enable-emoji-picker
+ :suggest="emojiSuggestor"
+ >
+ <input
+ id="username"
+ v-model="newName"
+ classname="name-changer"
+ >
+ </EmojiInput>
+ <p>{{ $t('settings.bio') }}</p>
+ <EmojiInput
+ v-model="newBio"
+ enable-emoji-picker
+ :suggest="emojiUserSuggestor"
+ >
+ <textarea
+ v-model="newBio"
+ classname="bio"
+ />
+ </EmojiInput>
+ <p>
+ <Checkbox v-model="newLocked">
+ {{ $t('settings.lock_account_description') }}
+ </Checkbox>
+ </p>
+ <div>
+ <label for="default-vis">{{ $t('settings.default_vis') }}</label>
+ <div
+ id="default-vis"
+ class="visibility-tray"
+ >
+ <scope-selector
+ :show-all="true"
+ :user-default="newDefaultScope"
+ :initial-scope="newDefaultScope"
+ :on-scope-change="changeVis"
+ />
+ </div>
+ </div>
+ <p>
+ <Checkbox v-model="newNoRichText">
+ {{ $t('settings.no_rich_text_description') }}
+ </Checkbox>
+ </p>
+ <p>
+ <Checkbox v-model="hideFollows">
+ {{ $t('settings.hide_follows_description') }}
+ </Checkbox>
+ </p>
+ <p class="setting-subitem">
+ <Checkbox
+ v-model="hideFollowsCount"
+ :disabled="!hideFollows"
+ >
+ {{ $t('settings.hide_follows_count_description') }}
+ </Checkbox>
+ </p>
+ <p>
+ <Checkbox v-model="hideFollowers">
+ {{ $t('settings.hide_followers_description') }}
+ </Checkbox>
+ </p>
+ <p class="setting-subitem">
+ <Checkbox
+ v-model="hideFollowersCount"
+ :disabled="!hideFollowers"
+ >
+ {{ $t('settings.hide_followers_count_description') }}
+ </Checkbox>
+ </p>
+ <p>
+ <Checkbox v-model="allowFollowingMove">
+ {{ $t('settings.allow_following_move') }}
+ </Checkbox>
+ </p>
+ <p v-if="role === 'admin' || role === 'moderator'">
+ <Checkbox v-model="showRole">
+ <template v-if="role === 'admin'">
+ {{ $t('settings.show_admin_badge') }}
+ </template>
+ <template v-if="role === 'moderator'">
+ {{ $t('settings.show_moderator_badge') }}
+ </template>
+ </Checkbox>
+ </p>
+ <p>
+ <Checkbox v-model="discoverable">
+ {{ $t('settings.discoverable') }}
+ </Checkbox>
+ </p>
+ <div v-if="maxFields > 0">
+ <p>{{ $t('settings.profile_fields.label') }}</p>
+ <div
+ v-for="(_, i) in newFields"
+ :key="i"
+ class="profile-fields"
+ >
+ <EmojiInput
+ v-model="newFields[i].name"
+ enable-emoji-picker
+ hide-emoji-button
+ :suggest="userSuggestor"
+ >
+ <input
+ v-model="newFields[i].name"
+ :placeholder="$t('settings.profile_fields.name')"
+ >
+ </EmojiInput>
+ <EmojiInput
+ v-model="newFields[i].value"
+ enable-emoji-picker
+ hide-emoji-button
+ :suggest="userSuggestor"
+ >
+ <input
+ v-model="newFields[i].value"
+ :placeholder="$t('settings.profile_fields.value')"
+ >
+ </EmojiInput>
+ <div
+ class="icon-container"
+ >
+ <i
+ v-show="newFields.length > 1"
+ class="icon-cancel"
+ @click="deleteField(i)"
+ />
+ </div>
+ </div>
+ <a
+ v-if="newFields.length < maxFields"
+ class="add-field faint"
+ @click="addField"
+ >
+ <i class="icon-plus" />
+ {{ $t("settings.profile_fields.add_field") }}
+ </a>
+ </div>
+ <p>
+ <Checkbox v-model="bot">
+ {{ $t('settings.bot') }}
+ </Checkbox>
+ </p>
+ <button
+ :disabled="newName && newName.length === 0"
+ class="btn btn-default"
+ @click="updateProfile"
+ >
+ {{ $t('general.submit') }}
+ </button>
+ </div>
+ <div class="setting-item">
+ <h2>{{ $t('settings.avatar') }}</h2>
+ <p class="visibility-notice">
+ {{ $t('settings.avatar_size_instruction') }}
+ </p>
+ <div class="current-avatar-container">
+ <img
+ :src="user.profile_image_url_original"
+ class="current-avatar"
+ >
+ <i
+ v-if="!isDefaultAvatar && pickAvatarBtnVisible"
+ :title="$t('settings.reset_avatar')"
+ class="reset-button icon-cancel"
+ type="button"
+ @click="resetAvatar"
+ />
+ </div>
+ <p>{{ $t('settings.set_new_avatar') }}</p>
+ <button
+ v-show="pickAvatarBtnVisible"
+ id="pick-avatar"
+ class="btn"
+ type="button"
+ >
+ {{ $t('settings.upload_a_photo') }}
+ </button>
+ <image-cropper
+ trigger="#pick-avatar"
+ :submit-handler="submitAvatar"
+ @open="pickAvatarBtnVisible=false"
+ @close="pickAvatarBtnVisible=true"
+ />
+ </div>
+ <div class="setting-item">
+ <h2>{{ $t('settings.profile_banner') }}</h2>
+ <div class="banner-background-preview">
+ <img :src="user.cover_photo">
+ <i
+ v-if="!isDefaultBanner"
+ :title="$t('settings.reset_profile_banner')"
+ class="reset-button icon-cancel"
+ type="button"
+ @click="resetBanner"
+ />
+ </div>
+ <p>{{ $t('settings.set_new_profile_banner') }}</p>
+ <img
+ v-if="bannerPreview"
+ class="banner-background-preview"
+ :src="bannerPreview"
+ >
+ <div>
+ <input
+ type="file"
+ @change="uploadFile('banner', $event)"
+ >
+ </div>
+ <i
+ v-if="bannerUploading"
+ class=" icon-spin4 animate-spin uploading"
+ />
+ <button
+ v-else-if="bannerPreview"
+ class="btn btn-default"
+ @click="submitBanner(banner)"
+ >
+ {{ $t('general.submit') }}
+ </button>
+ <div
+ v-if="bannerUploadError"
+ class="alert error"
+ >
+ Error: {{ bannerUploadError }}
+ <i
+ class="button-icon icon-cancel"
+ @click="clearUploadError('banner')"
+ />
+ </div>
+ </div>
+ <div class="setting-item">
+ <h2>{{ $t('settings.profile_background') }}</h2>
+ <div class="banner-background-preview">
+ <img :src="user.background_image">
+ <i
+ v-if="!isDefaultBackground"
+ :title="$t('settings.reset_profile_background')"
+ class="reset-button icon-cancel"
+ type="button"
+ @click="resetBackground"
+ />
+ </div>
+ <p>{{ $t('settings.set_new_profile_background') }}</p>
+ <img
+ v-if="backgroundPreview"
+ class="banner-background-preview"
+ :src="backgroundPreview"
+ >
+ <div>
+ <input
+ type="file"
+ @change="uploadFile('background', $event)"
+ >
+ </div>
+ <i
+ v-if="backgroundUploading"
+ class=" icon-spin4 animate-spin uploading"
+ />
+ <button
+ v-else-if="backgroundPreview"
+ class="btn btn-default"
+ @click="submitBackground(background)"
+ >
+ {{ $t('general.submit') }}
+ </button>
+ <div
+ v-if="backgroundUploadError"
+ class="alert error"
+ >
+ Error: {{ backgroundUploadError }}
+ <i
+ class="button-icon icon-cancel"
+ @click="clearUploadError('background')"
+ />
+ </div>
+ </div>
+ </div>
+</template>
+
+<script src="./profile_tab.js"></script>
+<style lang="scss" src="./profile_tab.scss"></style>
diff --git a/src/components/user_settings/confirm.js b/src/components/settings_modal/tabs/security_tab/confirm.js
index 0f4ddfc9..0f4ddfc9 100644
--- a/src/components/user_settings/confirm.js
+++ b/src/components/settings_modal/tabs/security_tab/confirm.js
diff --git a/src/components/user_settings/confirm.vue b/src/components/settings_modal/tabs/security_tab/confirm.vue
index 69b3811b..69b3811b 100644
--- a/src/components/user_settings/confirm.vue
+++ b/src/components/settings_modal/tabs/security_tab/confirm.vue
diff --git a/src/components/user_settings/mfa.js b/src/components/settings_modal/tabs/security_tab/mfa.js
index abf37062..abf37062 100644
--- a/src/components/user_settings/mfa.js
+++ b/src/components/settings_modal/tabs/security_tab/mfa.js
diff --git a/src/components/user_settings/mfa.vue b/src/components/settings_modal/tabs/security_tab/mfa.vue
index 14ea10a1..7aca3c8d 100644
--- a/src/components/user_settings/mfa.vue
+++ b/src/components/settings_modal/tabs/security_tab/mfa.vue
@@ -137,20 +137,20 @@
<script src="./mfa.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
-.warning {
- color: $fallback--cOrange;
- color: var(--cOrange, $fallback--cOrange);
-}
+@import '../../../../_variables.scss';
.mfa-settings {
.mfa-heading, .method-item {
- overflow: hidden;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: baseline;
}
+ .warning {
+ color: $fallback--cOrange;
+ color: var(--cOrange, $fallback--cOrange);
+ }
+
.setup-otp {
display: flex;
justify-content: center;
diff --git a/src/components/user_settings/mfa_backup_codes.js b/src/components/settings_modal/tabs/security_tab/mfa_backup_codes.js
index f0a984ec..f0a984ec 100644
--- a/src/components/user_settings/mfa_backup_codes.js
+++ b/src/components/settings_modal/tabs/security_tab/mfa_backup_codes.js
diff --git a/src/components/user_settings/mfa_backup_codes.vue b/src/components/settings_modal/tabs/security_tab/mfa_backup_codes.vue
index e6c8ede2..d7e98b3c 100644
--- a/src/components/user_settings/mfa_backup_codes.vue
+++ b/src/components/settings_modal/tabs/security_tab/mfa_backup_codes.vue
@@ -1,5 +1,5 @@
<template>
- <div>
+ <div class="mfa-backup-codes">
<h4 v-if="displayTitle">
{{ $t('settings.mfa.recovery_codes') }}
</h4>
@@ -21,13 +21,15 @@
</template>
<script src="./mfa_backup_codes.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
+@import '../../../../_variables.scss';
-.warning {
- color: $fallback--cOrange;
- color: var(--cOrange, $fallback--cOrange);
-}
-.backup-codes {
- font-family: var(--postCodeFont, monospace);
+.mfa-backup-codes {
+ .warning {
+ color: $fallback--cOrange;
+ color: var(--cOrange, $fallback--cOrange);
+ }
+ .backup-codes {
+ font-family: var(--postCodeFont, monospace);
+ }
}
</style>
diff --git a/src/components/user_settings/mfa_totp.js b/src/components/settings_modal/tabs/security_tab/mfa_totp.js
index 8408d8e9..8408d8e9 100644
--- a/src/components/user_settings/mfa_totp.js
+++ b/src/components/settings_modal/tabs/security_tab/mfa_totp.js
diff --git a/src/components/user_settings/mfa_totp.vue b/src/components/settings_modal/tabs/security_tab/mfa_totp.vue
index c6f2cc7b..c6f2cc7b 100644
--- a/src/components/user_settings/mfa_totp.vue
+++ b/src/components/settings_modal/tabs/security_tab/mfa_totp.vue
diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.js b/src/components/settings_modal/tabs/security_tab/security_tab.js
new file mode 100644
index 00000000..811161a5
--- /dev/null
+++ b/src/components/settings_modal/tabs/security_tab/security_tab.js
@@ -0,0 +1,106 @@
+import ProgressButton from 'src/components/progress_button/progress_button.vue'
+import Checkbox from 'src/components/checkbox/checkbox.vue'
+import Mfa from './mfa.vue'
+
+const SecurityTab = {
+ data () {
+ return {
+ newEmail: '',
+ changeEmailError: false,
+ changeEmailPassword: '',
+ changedEmail: false,
+ deletingAccount: false,
+ deleteAccountConfirmPasswordInput: '',
+ deleteAccountError: false,
+ changePasswordInputs: [ '', '', '' ],
+ changedPassword: false,
+ changePasswordError: false
+ }
+ },
+ created () {
+ this.$store.dispatch('fetchTokens')
+ },
+ components: {
+ ProgressButton,
+ Mfa,
+ Checkbox
+ },
+ computed: {
+ user () {
+ return this.$store.state.users.currentUser
+ },
+ pleromaBackend () {
+ return this.$store.state.instance.pleromaBackend
+ },
+ oauthTokens () {
+ return this.$store.state.oauthTokens.tokens.map(oauthToken => {
+ return {
+ id: oauthToken.id,
+ appName: oauthToken.app_name,
+ validUntil: new Date(oauthToken.valid_until).toLocaleDateString()
+ }
+ })
+ }
+ },
+ methods: {
+ confirmDelete () {
+ this.deletingAccount = true
+ },
+ deleteAccount () {
+ this.$store.state.api.backendInteractor.deleteAccount({ password: this.deleteAccountConfirmPasswordInput })
+ .then((res) => {
+ if (res.status === 'success') {
+ this.$store.dispatch('logout')
+ this.$router.push({ name: 'root' })
+ } else {
+ this.deleteAccountError = res.error
+ }
+ })
+ },
+ changePassword () {
+ const params = {
+ password: this.changePasswordInputs[0],
+ newPassword: this.changePasswordInputs[1],
+ newPasswordConfirmation: this.changePasswordInputs[2]
+ }
+ this.$store.state.api.backendInteractor.changePassword(params)
+ .then((res) => {
+ if (res.status === 'success') {
+ this.changedPassword = true
+ this.changePasswordError = false
+ this.logout()
+ } else {
+ this.changedPassword = false
+ this.changePasswordError = res.error
+ }
+ })
+ },
+ changeEmail () {
+ const params = {
+ email: this.newEmail,
+ password: this.changeEmailPassword
+ }
+ this.$store.state.api.backendInteractor.changeEmail(params)
+ .then((res) => {
+ if (res.status === 'success') {
+ this.changedEmail = true
+ this.changeEmailError = false
+ } else {
+ this.changedEmail = false
+ this.changeEmailError = res.error
+ }
+ })
+ },
+ logout () {
+ this.$store.dispatch('logout')
+ this.$router.replace('/')
+ },
+ revokeToken (id) {
+ if (window.confirm(`${this.$i18n.t('settings.revoke_token')}?`)) {
+ this.$store.dispatch('revokeToken', id)
+ }
+ }
+ }
+}
+
+export default SecurityTab
diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.vue b/src/components/settings_modal/tabs/security_tab/security_tab.vue
new file mode 100644
index 00000000..3d32d73d
--- /dev/null
+++ b/src/components/settings_modal/tabs/security_tab/security_tab.vue
@@ -0,0 +1,143 @@
+<template>
+ <div :label="$t('settings.security_tab')">
+ <div class="setting-item">
+ <h2>{{ $t('settings.change_email') }}</h2>
+ <div>
+ <p>{{ $t('settings.new_email') }}</p>
+ <input
+ v-model="newEmail"
+ type="email"
+ autocomplete="email"
+ >
+ </div>
+ <div>
+ <p>{{ $t('settings.current_password') }}</p>
+ <input
+ v-model="changeEmailPassword"
+ type="password"
+ autocomplete="current-password"
+ >
+ </div>
+ <button
+ class="btn btn-default"
+ @click="changeEmail"
+ >
+ {{ $t('general.submit') }}
+ </button>
+ <p v-if="changedEmail">
+ {{ $t('settings.changed_email') }}
+ </p>
+ <template v-if="changeEmailError !== false">
+ <p>{{ $t('settings.change_email_error') }}</p>
+ <p>{{ changeEmailError }}</p>
+ </template>
+ </div>
+
+ <div class="setting-item">
+ <h2>{{ $t('settings.change_password') }}</h2>
+ <div>
+ <p>{{ $t('settings.current_password') }}</p>
+ <input
+ v-model="changePasswordInputs[0]"
+ type="password"
+ >
+ </div>
+ <div>
+ <p>{{ $t('settings.new_password') }}</p>
+ <input
+ v-model="changePasswordInputs[1]"
+ type="password"
+ >
+ </div>
+ <div>
+ <p>{{ $t('settings.confirm_new_password') }}</p>
+ <input
+ v-model="changePasswordInputs[2]"
+ type="password"
+ >
+ </div>
+ <button
+ class="btn btn-default"
+ @click="changePassword"
+ >
+ {{ $t('general.submit') }}
+ </button>
+ <p v-if="changedPassword">
+ {{ $t('settings.changed_password') }}
+ </p>
+ <p v-else-if="changePasswordError !== false">
+ {{ $t('settings.change_password_error') }}
+ </p>
+ <p v-if="changePasswordError">
+ {{ changePasswordError }}
+ </p>
+ </div>
+
+ <div class="setting-item">
+ <h2>{{ $t('settings.oauth_tokens') }}</h2>
+ <table class="oauth-tokens">
+ <thead>
+ <tr>
+ <th>{{ $t('settings.app_name') }}</th>
+ <th>{{ $t('settings.valid_until') }}</th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ <tr
+ v-for="oauthToken in oauthTokens"
+ :key="oauthToken.id"
+ >
+ <td>{{ oauthToken.appName }}</td>
+ <td>{{ oauthToken.validUntil }}</td>
+ <td class="actions">
+ <button
+ class="btn btn-default"
+ @click="revokeToken(oauthToken.id)"
+ >
+ {{ $t('settings.revoke_token') }}
+ </button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ <mfa />
+ <div class="setting-item">
+ <h2>{{ $t('settings.delete_account') }}</h2>
+ <p v-if="!deletingAccount">
+ {{ $t('settings.delete_account_description') }}
+ </p>
+ <div v-if="deletingAccount">
+ <p>{{ $t('settings.delete_account_instructions') }}</p>
+ <p>{{ $t('login.password') }}</p>
+ <input
+ v-model="deleteAccountConfirmPasswordInput"
+ type="password"
+ >
+ <button
+ class="btn btn-default"
+ @click="deleteAccount"
+ >
+ {{ $t('settings.delete_account') }}
+ </button>
+ </div>
+ <p v-if="deleteAccountError !== false">
+ {{ $t('settings.delete_account_error') }}
+ </p>
+ <p v-if="deleteAccountError">
+ {{ deleteAccountError }}
+ </p>
+ <button
+ v-if="!deletingAccount"
+ class="btn btn-default"
+ @click="confirmDelete"
+ >
+ {{ $t('general.submit') }}
+ </button>
+ </div>
+ </div>
+</template>
+
+<script src="./security_tab.js"></script>
+<!-- <style lang="scss" src="./profile.scss"></style> -->
diff --git a/src/components/style_switcher/preview.vue b/src/components/settings_modal/tabs/theme_tab/preview.vue
index 9d984659..9d984659 100644
--- a/src/components/style_switcher/preview.vue
+++ b/src/components/settings_modal/tabs/theme_tab/preview.vue
diff --git a/src/components/style_switcher/style_switcher.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
index a7f586f4..e3c5e80a 100644
--- a/src/components/style_switcher/style_switcher.js
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
@@ -3,7 +3,7 @@ import {
rgb2hex,
hex2rgb,
getContrastRatioLayers
-} from '../../services/color_convert/color_convert.js'
+} from 'src/services/color_convert/color_convert.js'
import {
DEFAULT_SHADOWS,
generateColors,
@@ -14,26 +14,27 @@ import {
getThemes,
shadows2to3,
colors2to3
-} from '../../services/style_setter/style_setter.js'
+} from 'src/services/style_setter/style_setter.js'
import {
SLOT_INHERITANCE
-} from '../../services/theme_data/pleromafe.js'
+} from 'src/services/theme_data/pleromafe.js'
import {
CURRENT_VERSION,
OPACITIES,
getLayers,
getOpacitySlot
-} from '../../services/theme_data/theme_data.service.js'
-import ColorInput from '../color_input/color_input.vue'
-import RangeInput from '../range_input/range_input.vue'
-import OpacityInput from '../opacity_input/opacity_input.vue'
-import ShadowControl from '../shadow_control/shadow_control.vue'
-import FontControl from '../font_control/font_control.vue'
-import ContrastRatio from '../contrast_ratio/contrast_ratio.vue'
-import TabSwitcher from '../tab_switcher/tab_switcher.js'
+} from 'src/services/theme_data/theme_data.service.js'
+import ColorInput from 'src/components/color_input/color_input.vue'
+import RangeInput from 'src/components/range_input/range_input.vue'
+import OpacityInput from 'src/components/opacity_input/opacity_input.vue'
+import ShadowControl from 'src/components/shadow_control/shadow_control.vue'
+import FontControl from 'src/components/font_control/font_control.vue'
+import ContrastRatio from 'src/components/contrast_ratio/contrast_ratio.vue'
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
+import ExportImport from 'src/components/export_import/export_import.vue'
+import Checkbox from 'src/components/checkbox/checkbox.vue'
+
import Preview from './preview.vue'
-import ExportImport from '../export_import/export_import.vue'
-import Checkbox from '../checkbox/checkbox.vue'
// List of color values used in v1
const v1OnlyNames = [
@@ -98,7 +99,8 @@ export default {
avatarRadiusLocal: '',
avatarAltRadiusLocal: '',
attachmentRadiusLocal: '',
- tooltipRadiusLocal: ''
+ tooltipRadiusLocal: '',
+ chatMessageRadiusLocal: ''
}
},
created () {
@@ -213,7 +215,8 @@ export default {
avatar: this.avatarRadiusLocal,
avatarAlt: this.avatarAltRadiusLocal,
tooltip: this.tooltipRadiusLocal,
- attachment: this.attachmentRadiusLocal
+ attachment: this.attachmentRadiusLocal,
+ chatMessage: this.chatMessageRadiusLocal
}
},
preview () {
diff --git a/src/components/style_switcher/style_switcher.scss b/src/components/settings_modal/tabs/theme_tab/theme_tab.scss
index d2a40d13..926eceff 100644
--- a/src/components/style_switcher/style_switcher.scss
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.scss
@@ -1,5 +1,6 @@
-@import '../../_variables.scss';
-.style-switcher {
+@import 'src/_variables.scss';
+.theme-tab {
+ padding-bottom: 2em;
.theme-warning {
display: flex;
align-items: baseline;
@@ -54,10 +55,6 @@
}
}
- .tab-switcher {
- margin: 0 -1em;
- }
-
.reset-container {
flex-wrap: wrap;
}
@@ -98,20 +95,25 @@
align-items: baseline;
width: 100%;
min-height: 30px;
-
- .btn {
- min-width: 1px;
- flex: 0 auto;
- padding: 0 1em;
- }
+ margin-bottom: 1em;
p {
flex: 1;
margin: 0;
margin-right: .5em;
}
+ }
- margin-bottom: 1em;
+ .tab-header-buttons {
+ display: flex;
+ flex-direction: column;
+
+ .btn {
+ min-width: 1px;
+ flex: 0 auto;
+ padding: 0 1em;
+ margin-bottom: .5em;
+ }
}
.shadow-selector {
@@ -161,7 +163,7 @@
border-bottom: 1px dashed;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
- margin: 1em -1em 0;
+ margin: 1em 0;
padding: 1em;
background: var(--body-background-image);
background-size: cover;
@@ -328,6 +330,14 @@
padding: 20px;
}
+ .apply-container {
+ .btn {
+ min-height: 28px;
+ min-width: 10em;
+ padding: 0 2em;
+ }
+ }
+
.btn {
margin-left: .25em;
margin-right: .25em;
diff --git a/src/components/style_switcher/style_switcher.vue b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
index 62c8e634..d57894de 100644
--- a/src/components/style_switcher/style_switcher.vue
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
@@ -1,5 +1,5 @@
<template>
- <div class="style-switcher">
+ <div class="theme-tab">
<div class="presets-container">
<div class="save-load">
<div
@@ -126,18 +126,20 @@
>
<div class="tab-header">
<p>{{ $t('settings.theme_help') }}</p>
- <button
- class="btn"
- @click="clearOpacity"
- >
- {{ $t('settings.style.switcher.clear_opacity') }}
- </button>
- <button
- class="btn"
- @click="clearV1"
- >
- {{ $t('settings.style.switcher.clear_all') }}
- </button>
+ <div class="tab-header-buttons">
+ <button
+ class="btn"
+ @click="clearOpacity"
+ >
+ {{ $t('settings.style.switcher.clear_opacity') }}
+ </button>
+ <button
+ class="btn"
+ @click="clearV1"
+ >
+ {{ $t('settings.style.switcher.clear_all') }}
+ </button>
+ </div>
</div>
<p>{{ $t('settings.theme_help_v2_1') }}</p>
<h4>{{ $t('settings.style.common_colors.main') }}</h4>
@@ -254,6 +256,13 @@
:label="$t('settings.links')"
/>
<ContrastRatio :contrast="previewContrast.postLink" />
+ <ColorInput
+ v-model="postGreentextColorLocal"
+ name="postGreentextColor"
+ :fallback="previewTheme.colors.cGreen"
+ :label="$t('settings.greentext')"
+ />
+ <ContrastRatio :contrast="previewContrast.postGreentext" />
<h4>{{ $t('settings.style.advanced_colors.alert') }}</h4>
<ColorInput
v-model="alertErrorColorLocal"
@@ -726,6 +735,65 @@
/>
<ContrastRatio :contrast="previewContrast.selectedMenuLink" />
</div>
+ <div class="color-item">
+ <h4>{{ $t('chats.chats') }}</h4>
+ <ColorInput
+ v-model="chatBgColorLocal"
+ name="chatBgColor"
+ :fallback="previewTheme.colors.bg || 1"
+ :label="$t('settings.background')"
+ />
+ <h5>{{ $t('settings.style.advanced_colors.chat.incoming') }}</h5>
+ <ColorInput
+ v-model="chatMessageIncomingBgColorLocal"
+ name="chatMessageIncomingBgColor"
+ :fallback="previewTheme.colors.bg || 1"
+ :label="$t('settings.background')"
+ />
+ <ColorInput
+ v-model="chatMessageIncomingTextColorLocal"
+ name="chatMessageIncomingTextColor"
+ :fallback="previewTheme.colors.text || 1"
+ :label="$t('settings.text')"
+ />
+ <ColorInput
+ v-model="chatMessageIncomingLinkColorLocal"
+ name="chatMessageIncomingLinkColor"
+ :fallback="previewTheme.colors.link || 1"
+ :label="$t('settings.links')"
+ />
+ <ColorInput
+ v-model="chatMessageIncomingBorderColorLocal"
+ name="chatMessageIncomingBorderLinkColor"
+ :fallback="previewTheme.colors.fg || 1"
+ :label="$t('settings.style.advanced_colors.chat.border')"
+ />
+ <h5>{{ $t('settings.style.advanced_colors.chat.outgoing') }}</h5>
+ <ColorInput
+ v-model="chatMessageOutgoingBgColorLocal"
+ name="chatMessageOutgoingBgColor"
+ :fallback="previewTheme.colors.bg || 1"
+ :label="$t('settings.background')"
+ />
+ <ColorInput
+ v-model="chatMessageOutgoingTextColorLocal"
+ name="chatMessageOutgoingTextColor"
+ :fallback="previewTheme.colors.text || 1"
+ :label="$t('settings.text')"
+ />
+ <ColorInput
+ v-model="chatMessageOutgoingLinkColorLocal"
+ name="chatMessageOutgoingLinkColor"
+ :fallback="previewTheme.colors.link || 1"
+ :label="$t('settings.links')"
+ />
+ <ColorInput
+ v-model="chatMessageOutgoingBorderColorLocal"
+ name="chatMessageOutgoingBorderLinkColor"
+ :fallback="previewTheme.colors.bg || 1"
+ :label="$t('settings.style.advanced_colors.chat.border')"
+ />
+ </div>
</div>
<div
@@ -805,6 +873,14 @@
max="50"
hard-min="0"
/>
+ <RangeInput
+ v-model="chatMessageRadiusLocal"
+ name="chatMessageRadius"
+ :label="$t('settings.chatMessageRadius')"
+ :fallback="previewTheme.radii.chatMessage || 2"
+ max="50"
+ hard-min="0"
+ />
</div>
<div
@@ -951,6 +1027,6 @@
</div>
</template>
-<script src="./style_switcher.js"></script>
+<script src="./theme_tab.js"></script>
-<style src="./style_switcher.scss" lang="scss"></style>
+<style src="./theme_tab.scss" lang="scss"></style>
diff --git a/src/components/settings_modal/tabs/version_tab.js b/src/components/settings_modal/tabs/version_tab.js
new file mode 100644
index 00000000..616bdadf
--- /dev/null
+++ b/src/components/settings_modal/tabs/version_tab.js
@@ -0,0 +1,24 @@
+import { extractCommit } from 'src/services/version/version.service'
+
+const pleromaFeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma-fe/commit/'
+const pleromaBeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma/commit/'
+
+const VersionTab = {
+ data () {
+ const instance = this.$store.state.instance
+ return {
+ backendVersion: instance.backendVersion,
+ frontendVersion: instance.frontendVersion
+ }
+ },
+ computed: {
+ frontendVersionLink () {
+ return pleromaFeCommitUrl + this.frontendVersion
+ },
+ backendVersionLink () {
+ return pleromaBeCommitUrl + extractCommit(this.backendVersion)
+ }
+ }
+}
+
+export default VersionTab
diff --git a/src/components/settings_modal/tabs/version_tab.vue b/src/components/settings_modal/tabs/version_tab.vue
new file mode 100644
index 00000000..d35ff25e
--- /dev/null
+++ b/src/components/settings_modal/tabs/version_tab.vue
@@ -0,0 +1,31 @@
+<template>
+ <div :label="$t('settings.version.title')">
+ <div class="setting-item">
+ <ul class="setting-list">
+ <li>
+ <p>{{ $t('settings.version.backend_version') }}</p>
+ <ul class="option-list">
+ <li>
+ <a
+ :href="backendVersionLink"
+ target="_blank"
+ >{{ backendVersion }}</a>
+ </li>
+ </ul>
+ </li>
+ <li>
+ <p>{{ $t('settings.version.frontend_version') }}</p>
+ <ul class="option-list">
+ <li>
+ <a
+ :href="frontendVersionLink"
+ target="_blank"
+ >{{ frontendVersion }}</a>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </div>
+ </div>
+</template>
+<script src="./version_tab.js">
diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js
index 2181ecc7..281052e5 100644
--- a/src/components/side_drawer/side_drawer.js
+++ b/src/components/side_drawer/side_drawer.js
@@ -1,3 +1,4 @@
+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'
@@ -47,7 +48,17 @@ const SideDrawer = {
},
federating () {
return this.$store.state.instance.federating
- }
+ },
+ timelinesRoute () {
+ if (this.$store.state.interface.lastTimeline) {
+ return this.$store.state.interface.lastTimeline
+ }
+ return this.currentUser ? 'friends' : 'public-timeline'
+ },
+ ...mapState({
+ pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
+ }),
+ ...mapGetters(['unreadChatCount'])
},
methods: {
toggleDrawer () {
@@ -62,6 +73,9 @@ const SideDrawer = {
},
touchMove (e) {
GestureService.updateSwipe(e, this.closeGesture)
+ },
+ openSettingsModal () {
+ this.$store.dispatch('openSettingsModal')
}
}
}
diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue
index 2958a386..0587ee02 100644
--- a/src/components/side_drawer/side_drawer.vue
+++ b/src/components/side_drawer/side_drawer.vue
@@ -40,33 +40,39 @@
</router-link>
</li>
<li
- v-if="currentUser"
+ v-if="currentUser || !privateMode"
@click="toggleDrawer"
>
- <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
- <i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }}
+ <router-link :to="{ name: timelinesRoute }">
+ <i class="button-icon icon-home-2" /> {{ $t("nav.timelines") }}
</router-link>
</li>
<li
- v-if="currentUser"
+ v-if="currentUser && pleromaChatMessagesAvailable"
@click="toggleDrawer"
>
- <router-link :to="{ name: 'interactions', params: { username: currentUser.screen_name } }">
- <i class="button-icon icon-bell-alt" /> {{ $t("nav.interactions") }}
+ <router-link
+ :to="{ name: 'chats', params: { username: currentUser.screen_name } }"
+ style="position: relative"
+ >
+ <i class="button-icon icon-chat" /> {{ $t("nav.chats") }}
+ <span
+ v-if="unreadChatCount"
+ class="badge badge-notification unread-chat-count"
+ >
+ {{ unreadChatCount }}
+ </span>
</router-link>
</li>
</ul>
- <ul>
- <li
- v-if="currentUser"
- @click="toggleDrawer"
- >
- <router-link :to="{ name: 'friends' }">
- <i class="button-icon icon-home-2" /> {{ $t("nav.timeline") }}
+ <ul v-if="currentUser">
+ <li @click="toggleDrawer">
+ <router-link :to="{ name: 'interactions', params: { username: currentUser.screen_name } }">
+ <i class="button-icon icon-bell-alt" /> {{ $t("nav.interactions") }}
</router-link>
</li>
<li
- v-if="currentUser && currentUser.locked"
+ v-if="currentUser.locked"
@click="toggleDrawer"
>
<router-link to="/friend-requests">
@@ -80,23 +86,7 @@
</router-link>
</li>
<li
- v-if="currentUser || !privateMode"
- @click="toggleDrawer"
- >
- <router-link to="/main/public">
- <i class="button-icon icon-users" /> {{ $t("nav.public_tl") }}
- </router-link>
- </li>
- <li
- v-if="federating && (currentUser || !privateMode)"
- @click="toggleDrawer"
- >
- <router-link to="/main/all">
- <i class="button-icon icon-globe" /> {{ $t("nav.twkn") }}
- </router-link>
- </li>
- <li
- v-if="currentUser && chat"
+ v-if="chat"
@click="toggleDrawer"
>
<router-link :to="{ name: 'chat' }">
@@ -122,9 +112,12 @@
</router-link>
</li>
<li @click="toggleDrawer">
- <router-link :to="{ name: 'settings' }">
+ <a
+ href="#"
+ @click="openSettingsModal"
+ >
<i class="button-icon icon-cog" /> {{ $t("settings.settings") }}
- </router-link>
+ </a>
</li>
<li @click="toggleDrawer">
<router-link :to="{ name: 'about'}">
diff --git a/src/components/staff_panel/staff_panel.js b/src/components/staff_panel/staff_panel.js
index 4f98fff6..8665648a 100644
--- a/src/components/staff_panel/staff_panel.js
+++ b/src/components/staff_panel/staff_panel.js
@@ -2,6 +2,10 @@ import map from 'lodash/map'
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
const StaffPanel = {
+ created () {
+ const nicknames = this.$store.state.instance.staffAccounts
+ nicknames.forEach(nickname => this.$store.dispatch('fetchUserIfMissing', nickname))
+ },
components: {
BasicUserCard
},
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 9cd9d61c..d263da68 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -9,14 +9,31 @@ import AvatarList from '../avatar_list/avatar_list.vue'
import Timeago from '../timeago/timeago.vue'
import StatusContent from '../status_content/status_content.vue'
import StatusPopover from '../status_popover/status_popover.vue'
+import UserListPopover from '../user_list_popover/user_list_popover.vue'
import EmojiReactions from '../emoji_reactions/emoji_reactions.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
-import { filter, unescape, uniqBy } from 'lodash'
+import { muteWordHits } from '../../services/status_parser/status_parser.js'
+import { unescape, uniqBy } from 'lodash'
import { mapGetters, mapState } from 'vuex'
const Status = {
name: 'Status',
+ components: {
+ FavoriteButton,
+ ReactButton,
+ RetweetButton,
+ ExtraButtons,
+ PostStatusForm,
+ UserCard,
+ UserAvatar,
+ AvatarList,
+ Timeago,
+ StatusPopover,
+ UserListPopover,
+ EmojiReactions,
+ StatusContent
+ },
props: [
'statusoid',
'expandable',
@@ -44,6 +61,12 @@ const Status = {
muteWords () {
return this.mergedConfig.muteWords
},
+ showReasonMutedThread () {
+ return (
+ this.status.thread_muted ||
+ (this.status.reblog && this.status.reblog.thread_muted)
+ ) && !this.inConversation
+ },
repeaterClass () {
const user = this.statusoid.user
return highlightClass(user)
@@ -93,26 +116,48 @@ const Status = {
return !!this.currentUser
},
muteWordHits () {
- const statusText = this.status.text.toLowerCase()
- const statusSummary = this.status.summary.toLowerCase()
- const hits = filter(this.muteWords, (muteWord) => {
- return statusText.includes(muteWord.toLowerCase()) || statusSummary.includes(muteWord.toLowerCase())
- })
-
- return hits
+ return muteWordHits(this.status, this.muteWords)
},
muted () {
- const relationship = this.$store.getters.relationship(this.status.user.id)
- return !this.unmuted && (
- (!(this.inProfile && this.status.user.id === this.profileUserId) && relationship.muting) ||
- (!this.inConversation && this.status.thread_muted) ||
- this.muteWordHits.length > 0)
+ const { status } = this
+ const { reblog } = status
+ const relationship = this.$store.getters.relationship(status.user.id)
+ const relationshipReblog = reblog && this.$store.getters.relationship(reblog.user.id)
+ const reasonsToMute = (
+ // Post is muted according to BE
+ status.muted ||
+ // Reprööt of a muted post according to BE
+ (reblog && reblog.muted) ||
+ // Muted user
+ relationship.muting ||
+ // Muted user of a reprööt
+ (relationshipReblog && relationshipReblog.muting) ||
+ // Thread is muted
+ status.thread_muted ||
+ // Wordfiltered
+ this.muteWordHits.length > 0
+ )
+ const excusesNotToMute = (
+ (
+ this.inProfile && (
+ // Don't mute user's posts on user timeline (except reblogs)
+ (!reblog && status.user.id === this.profileUserId) ||
+ // Same as above but also allow self-reblogs
+ (reblog && reblog.user.id === this.profileUserId)
+ )
+ ) ||
+ // Don't mute statuses in muted conversation when said conversation is opened
+ (this.inConversation && status.thread_muted)
+ // No excuses if post has muted words
+ ) && !this.muteWordHits.length > 0
+
+ return !this.unmuted && !excusesNotToMute && reasonsToMute
},
hideFilteredStatuses () {
return this.mergedConfig.hideFilteredStatuses
},
hideStatus () {
- return (this.hideReply || this.deleted) || (this.muted && this.hideFilteredStatuses)
+ return this.deleted || (this.muted && this.hideFilteredStatuses)
},
isFocused () {
// retweet or root of an expanded conversation
@@ -135,37 +180,6 @@ const Status = {
return user && user.screen_name
}
},
- hideReply () {
- if (this.mergedConfig.replyVisibility === 'all') {
- return false
- }
- if (this.inConversation || !this.isReply) {
- return false
- }
- if (this.status.user.id === this.currentUser.id) {
- return false
- }
- if (this.status.type === 'retweet') {
- return false
- }
- const checkFollowing = this.mergedConfig.replyVisibility === 'following'
- for (var i = 0; i < this.status.attentions.length; ++i) {
- if (this.status.user.id === this.status.attentions[i].id) {
- continue
- }
- // There's zero guarantee of this working. If we happen to have that user and their
- // relationship in store then it will work, but there's kinda little chance of having
- // them for people you're not following.
- const relationship = this.$store.state.users.relationships[this.status.attentions[i].id]
- if (checkFollowing && relationship && relationship.following) {
- return false
- }
- if (this.status.attentions[i].id === this.currentUser.id) {
- return false
- }
- }
- return this.status.attentions.length > 0
- },
replySubject () {
if (!this.status.summary) return ''
const decodedSummary = unescape(this.status.summary)
@@ -199,20 +213,6 @@ const Status = {
currentUser: state => state.users.currentUser
})
},
- components: {
- FavoriteButton,
- ReactButton,
- RetweetButton,
- ExtraButtons,
- PostStatusForm,
- UserCard,
- UserAvatar,
- AvatarList,
- Timeago,
- StatusPopover,
- EmojiReactions,
- StatusContent
- },
methods: {
visibilityIcon (visibility) {
switch (visibility) {
diff --git a/src/components/status/status.scss b/src/components/status/status.scss
new file mode 100644
index 00000000..8d292d3f
--- /dev/null
+++ b/src/components/status/status.scss
@@ -0,0 +1,414 @@
+
+@import '../../_variables.scss';
+
+$status-margin: 0.75em;
+
+.Status {
+ min-width: 0;
+
+ &:hover {
+ --still-image-img: visible;
+ --still-image-canvas: hidden;
+ }
+
+ &.-focused {
+ background-color: $fallback--lightBg;
+ background-color: var(--selectedPost, $fallback--lightBg);
+ color: $fallback--text;
+ color: var(--selectedPostText, $fallback--text);
+
+ --lightText: var(--selectedPostLightText, $fallback--light);
+ --faint: var(--selectedPostFaintText, $fallback--faint);
+ --faintLink: var(--selectedPostFaintLink, $fallback--faint);
+ --postLink: var(--selectedPostPostLink, $fallback--faint);
+ --postFaintLink: var(--selectedPostFaintPostLink, $fallback--faint);
+ --icon: var(--selectedPostIcon, $fallback--icon);
+ }
+
+ .status-container {
+ display: flex;
+ padding: $status-margin;
+
+ &.-repeat {
+ padding-top: 0;
+ }
+ }
+
+ .pin {
+ padding: $status-margin $status-margin 0;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ }
+
+ .left-side {
+ margin-right: $status-margin;
+ }
+
+ .right-side {
+ flex: 1;
+ min-width: 0;
+ }
+
+ .usercard {
+ margin-bottom: $status-margin;
+ }
+
+ .status-username {
+ white-space: nowrap;
+ font-size: 14px;
+ overflow: hidden;
+ max-width: 85%;
+ font-weight: bold;
+ flex-shrink: 1;
+ margin-right: 0.4em;
+ text-overflow: ellipsis;
+
+ .emoji {
+ width: 14px;
+ height: 14px;
+ vertical-align: middle;
+ object-fit: contain;
+ }
+ }
+
+ .status-favicon {
+ height: 18px;
+ width: 18px;
+ margin-right: 0.4em;
+ }
+
+ .status-heading {
+ margin-bottom: 0.5em;
+ }
+
+ .heading-name-row {
+ display: flex;
+ justify-content: space-between;
+ line-height: 18px;
+
+ a {
+ display: inline-block;
+ word-break: break-all;
+ }
+ }
+
+ .account-name {
+ min-width: 1.6em;
+ margin-right: 0.4em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex: 1 1 0;
+ }
+
+ .heading-left {
+ display: flex;
+ min-width: 0;
+ }
+
+ .heading-right {
+ display: flex;
+ flex-shrink: 0;
+ }
+
+ .timeago {
+ margin-right: 0.2em;
+ }
+
+ .heading-reply-row {
+ position: relative;
+ align-content: baseline;
+ font-size: 12px;
+ line-height: 18px;
+ max-width: 100%;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: stretch;
+ }
+
+ .reply-to-and-accountname {
+ display: flex;
+ height: 18px;
+ margin-right: 0.5em;
+ max-width: 100%;
+
+ .reply-to-link {
+ white-space: nowrap;
+ word-break: break-word;
+ text-overflow: ellipsis;
+ overflow-x: hidden;
+ }
+
+ .icon-reply {
+ // mirror the icon
+ transform: scaleX(-1);
+ }
+ }
+
+ & .reply-to-popover,
+ & .reply-to-no-popover {
+ min-width: 0;
+ margin-right: 0.4em;
+ flex-shrink: 0;
+ }
+
+ .reply-to-popover {
+ .reply-to:hover::before {
+ content: '';
+ display: block;
+ position: absolute;
+ bottom: 0;
+ width: 100%;
+ border-bottom: 1px solid var(--faint);
+ pointer-events: none;
+ }
+
+ .faint-link:hover {
+ // override default
+ text-decoration: none;
+ }
+
+ &.-strikethrough {
+ .reply-to::after {
+ content: '';
+ display: block;
+ position: absolute;
+ top: 50%;
+ width: 100%;
+ border-bottom: 1px solid var(--faint);
+ pointer-events: none;
+ }
+ }
+ }
+
+ .reply-to {
+ display: flex;
+ position: relative;
+ }
+
+ .reply-to-text {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ margin-left: 0.2em;
+ }
+
+ .replies-separator {
+ margin-left: 0.4em;
+ }
+
+ .replies {
+ line-height: 18px;
+ font-size: 12px;
+ display: flex;
+ flex-wrap: wrap;
+
+ & > * {
+ margin-right: 0.4em;
+ }
+ }
+
+ .reply-link {
+ height: 17px;
+ }
+
+ .repeat-info {
+ padding: 0.4em $status-margin;
+ line-height: 22px;
+
+ .right-side {
+ display: flex;
+ align-content: center;
+ flex-wrap: wrap;
+ }
+
+ i {
+ padding: 0 0.2em;
+ }
+ }
+
+ .repeater-avatar {
+ border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+ margin-left: 28px;
+ width: 20px;
+ height: 20px;
+ }
+
+ .repeater-name {
+ text-overflow: ellipsis;
+ margin-right: 0;
+
+ .emoji {
+ width: 14px;
+ height: 14px;
+ vertical-align: middle;
+ object-fit: contain;
+ }
+ }
+
+ .status-fadein {
+ animation-duration: 0.4s;
+ animation-name: fadein;
+ }
+
+ @keyframes fadein {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+ }
+
+ .status-actions {
+ position: relative;
+ width: 100%;
+ display: flex;
+ margin-top: $status-margin;
+
+ > * {
+ max-width: 4em;
+ flex: 1;
+ }
+ }
+
+ .button-reply {
+ &:not(.-disabled) {
+ cursor: pointer;
+ }
+
+ &:not(.-disabled):hover,
+ &.-active {
+ color: $fallback--cBlue;
+ color: var(--cBlue, $fallback--cBlue);
+ }
+ }
+
+ .muted {
+ padding: 0.25em 0.6em;
+ height: 1.2em;
+ line-height: 1.2em;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ display: flex;
+ flex-wrap: nowrap;
+
+ & .status-username,
+ & .mute-thread,
+ & .mute-words {
+ word-wrap: normal;
+ word-break: normal;
+ white-space: nowrap;
+ }
+
+ & .status-username,
+ & .mute-words {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ .status-username {
+ font-weight: normal;
+ flex: 0 1 auto;
+ margin-right: 0.2em;
+ font-size: smaller;
+ }
+
+ .mute-thread {
+ flex: 0 0 auto;
+ }
+
+ .mute-words {
+ flex: 1 0 5em;
+ margin-left: 0.2em;
+
+ &::before {
+ content: ' ';
+ }
+ }
+
+ .unmute {
+ flex: 0 0 auto;
+ margin-left: auto;
+ display: block;
+ }
+ }
+
+ .reply-form {
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+
+ .reply-body {
+ flex: 1;
+ }
+
+ .favs-repeated-users {
+ margin-top: $status-margin;
+ }
+
+ .stats {
+ width: 100%;
+ display: flex;
+ line-height: 1em;
+ }
+
+ .avatar-row {
+ flex: 1;
+ overflow: hidden;
+ position: relative;
+ display: flex;
+ align-items: center;
+
+ &::before {
+ content: '';
+ position: absolute;
+ height: 100%;
+ width: 1px;
+ left: 0;
+ background-color: var(--faint, $fallback--faint);
+ }
+ }
+
+ .stat-count {
+ margin-right: $status-margin;
+ user-select: none;
+
+ .stat-title {
+ color: var(--faint, $fallback--faint);
+ font-size: 12px;
+ text-transform: uppercase;
+ position: relative;
+ }
+
+ .stat-number {
+ font-weight: bolder;
+ font-size: 16px;
+ line-height: 1em;
+ }
+
+ &:hover .stat-title {
+ text-decoration: underline;
+ }
+ }
+
+ @media all and (max-width: 800px) {
+ .repeater-avatar {
+ margin-left: 20px;
+ }
+
+ .avatar:not(.repeater-avatar) {
+ width: 40px;
+ height: 40px;
+
+ // TODO define those other way somehow?
+ // stylelint-disable rscss/class-format
+ &.avatar-compact {
+ width: 32px;
+ height: 32px;
+ }
+ }
+ }
+}
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index e4c7545b..282ad37d 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -2,8 +2,8 @@
<!-- eslint-disable vue/no-v-html -->
<div
v-if="!hideStatus"
- class="status-el"
- :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]"
+ class="Status"
+ :class="[{ '-focused': isFocused }, { '-conversation': inlineExpanded }]"
>
<div
v-if="error"
@@ -16,13 +16,34 @@
/>
</div>
<template v-if="muted && !isPreview">
- <div class="media status container muted">
- <small>
+ <div class="status-csontainer muted">
+ <small class="status-username">
+ <i
+ v-if="muted && retweet"
+ class="button-icon icon-retweet"
+ />
<router-link :to="userProfileLink">
{{ status.user.screen_name }}
</router-link>
</small>
- <small class="muteWords">{{ muteWordHits.join(', ') }}</small>
+ <small
+ v-if="showReasonMutedThread"
+ class="mute-thread"
+ >
+ {{ $t('status.thread_muted') }}
+ </small>
+ <small
+ v-if="showReasonMutedThread && muteWordHits.length > 0"
+ class="mute-thread"
+ >
+ {{ $t('status.thread_muted_and_words') }}
+ </small>
+ <small
+ class="mute-words"
+ :title="muteWordHits.join(', ')"
+ >
+ {{ muteWordHits.join(', ') }}
+ </small>
<a
href="#"
class="unmute"
@@ -33,7 +54,7 @@
<template v-else>
<div
v-if="showPinned"
- class="status-pin"
+ class="pin"
>
<i class="fa icon-pin faint" />
<span class="faint">{{ $t('status.pinned') }}</span>
@@ -42,16 +63,19 @@
v-if="retweet && !noHeading && !inConversation"
:class="[repeaterClass, { highlighted: repeaterStyle }]"
:style="[repeaterStyle]"
- class="media container retweet-info"
+ class="status-container repeat-info"
>
<UserAvatar
v-if="retweet"
- class="media-left"
+ class="left-side repeater-avatar"
:better-shadow="betterShadow"
:user="statusoid.user"
/>
- <div class="media-body faint">
- <span class="user-name">
+ <div class="right-side faint">
+ <span
+ class="status-username repeater-name"
+ :title="retweeter"
+ >
<router-link
v-if="retweeterHtml"
:to="retweeterProfileLink"
@@ -71,14 +95,14 @@
</div>
<div
- :class="[userClass, { highlighted: userStyle, 'is-retweet': retweet && !inConversation }]"
+ :class="[userClass, { highlighted: userStyle, '-repeat': retweet && !inConversation }]"
:style="[ userStyle ]"
- class="media status"
+ class="status-container"
:data-tags="tags"
>
<div
v-if="!noHeading"
- class="media-left"
+ class="left-side"
>
<router-link
:to="userProfileLink"
@@ -91,37 +115,45 @@
/>
</router-link>
</div>
- <div class="status-body">
+ <div class="right-side">
<UserCard
v-if="userExpanded"
:user-id="status.user.id"
:rounded="true"
:bordered="true"
- class="status-usercard"
+ class="usercard"
/>
<div
v-if="!noHeading"
- class="media-heading"
+ class="status-heading"
>
<div class="heading-name-row">
- <div class="name-and-account-name">
+ <div class="heading-left">
<h4
v-if="status.user.name_html"
- class="user-name"
+ class="status-username"
+ :title="status.user.name"
v-html="status.user.name_html"
/>
<h4
v-else
- class="user-name"
+ class="status-username"
+ :title="status.user.name"
>
{{ status.user.name }}
</h4>
<router-link
class="account-name"
+ :title="status.user.screen_name"
:to="userProfileLink"
>
{{ status.user.screen_name }}
</router-link>
+ <img
+ v-if="!!(status.user && status.user.favicon)"
+ class="status-favicon"
+ :src="status.user.favicon"
+ >
</div>
<span class="heading-right">
@@ -176,9 +208,10 @@
>
<StatusPopover
v-if="!isPreview"
- :status-id="status.in_reply_to_status_id"
+ :status-id="status.parent_visible && status.in_reply_to_status_id"
class="reply-to-popover"
style="min-width: 0"
+ :class="{ '-strikethrough': !status.parent_visible }"
>
<a
class="reply-to"
@@ -186,17 +219,25 @@
:aria-label="$t('tool_tip.reply')"
@click.prevent="gotoOriginal(status.in_reply_to_status_id)"
>
- <i class="button-icon icon-reply" />
- <span class="faint-link reply-to-text">{{ $t('status.reply_to') }}</span>
+ <i class="button-icon reply-button icon-reply" />
+ <span
+ class="faint-link reply-to-text"
+ >
+ {{ $t('status.reply_to') }}
+ </span>
</a>
</StatusPopover>
<span
v-else
- class="reply-to"
+ class="reply-to-no-popover"
>
<span class="reply-to-text">{{ $t('status.reply_to') }}</span>
</span>
- <router-link :to="replyProfileLink">
+ <router-link
+ class="reply-to-link"
+ :title="replyToName"
+ :to="replyProfileLink"
+ >
{{ replyToName }}
</router-link>
<span
@@ -239,24 +280,30 @@
class="favs-repeated-users"
>
<div class="stats">
- <div
+ <UserListPopover
v-if="statusFromGlobalRepository.rebloggedBy && statusFromGlobalRepository.rebloggedBy.length > 0"
- class="stat-count"
+ :users="statusFromGlobalRepository.rebloggedBy"
>
- <a class="stat-title">{{ $t('status.repeats') }}</a>
- <div class="stat-number">
- {{ statusFromGlobalRepository.rebloggedBy.length }}
+ <div class="stat-count">
+ <a class="stat-title">{{ $t('status.repeats') }}</a>
+ <div class="stat-number">
+ {{ statusFromGlobalRepository.rebloggedBy.length }}
+ </div>
</div>
- </div>
- <div
+ </UserListPopover>
+ <UserListPopover
v-if="statusFromGlobalRepository.favoritedBy && statusFromGlobalRepository.favoritedBy.length > 0"
- class="stat-count"
+ :users="statusFromGlobalRepository.favoritedBy"
>
- <a class="stat-title">{{ $t('status.favorites') }}</a>
- <div class="stat-number">
- {{ statusFromGlobalRepository.favoritedBy.length }}
+ <div
+ class="stat-count"
+ >
+ <a class="stat-title">{{ $t('status.favorites') }}</a>
+ <div class="stat-number">
+ {{ statusFromGlobalRepository.favoritedBy.length }}
+ </div>
</div>
- </div>
+ </UserListPopover>
<div class="avatar-row">
<AvatarList :users="combinedFavsAndRepeatsUsers" />
</div>
@@ -271,19 +318,19 @@
<div
v-if="!noHeading && !isPreview"
- class="status-actions media-body"
+ class="status-actions"
>
<div>
<i
v-if="loggedIn"
- class="button-icon icon-reply"
+ class="button-icon button-reply icon-reply"
:title="$t('tool_tip.reply')"
- :class="{'button-icon-active': replying}"
+ :class="{'-active': replying}"
@click.prevent="toggleReplying"
/>
<i
v-else
- class="button-icon button-icon-disabled icon-reply"
+ class="button-icon button-reply -disabled icon-reply"
:title="$t('tool_tip.reply')"
/>
<span v-if="status.replies_count > 0">{{ status.replies_count }}</span>
@@ -311,7 +358,7 @@
</div>
<div
v-if="replying"
- class="container"
+ class="status-container reply-form"
>
<PostStatusForm
class="reply-body"
@@ -329,398 +376,4 @@
</template>
<script src="./status.js" ></script>
-<style lang="scss">
-@import '../../_variables.scss';
-
-$status-margin: 0.75em;
-
-.status-body {
- flex: 1;
- min-width: 0;
-}
-
-.status-pin {
- padding: $status-margin $status-margin 0;
- display: flex;
- align-items: center;
- justify-content: flex-end;
-}
-
-.media-left {
- margin-right: $status-margin;
-}
-
-.status-el {
- overflow-wrap: break-word;
- word-wrap: break-word;
- word-break: break-word;
- border-left-width: 0px;
- min-width: 0;
- border-color: $fallback--border;
- border-color: var(--border, $fallback--border);
-
- border-left: 4px $fallback--cRed;
- border-left: 4px var(--cRed, $fallback--cRed);
-
- &_focused {
- background-color: $fallback--lightBg;
- background-color: var(--selectedPost, $fallback--lightBg);
- color: $fallback--text;
- color: var(--selectedPostText, $fallback--text);
- --lightText: var(--selectedPostLightText, $fallback--light);
- --faint: var(--selectedPostFaintText, $fallback--faint);
- --faintLink: var(--selectedPostFaintLink, $fallback--faint);
- --postLink: var(--selectedPostPostLink, $fallback--faint);
- --postFaintLink: var(--selectedPostFaintPostLink, $fallback--faint);
- --icon: var(--selectedPostIcon, $fallback--icon);
- }
-
- .timeline & {
- border-bottom-width: 1px;
- border-bottom-style: solid;
- }
-
- .media-body {
- flex: 1;
- padding: 0;
- }
-
- .status-usercard {
- margin-bottom: $status-margin;
- }
-
- .user-name {
- white-space: nowrap;
- font-size: 14px;
- overflow: hidden;
- flex-shrink: 0;
- max-width: 85%;
- font-weight: bold;
-
- img {
- width: 14px;
- height: 14px;
- vertical-align: middle;
- object-fit: contain
- }
- }
-
- .media-heading {
- padding: 0;
- vertical-align: bottom;
- flex-basis: 100%;
- margin-bottom: 0.5em;
-
- small {
- font-weight: lighter;
- }
-
- .heading-name-row {
- padding: 0;
- display: flex;
- justify-content: space-between;
- line-height: 18px;
-
- a {
- display: inline-block;
- word-break: break-all;
- }
-
- .name-and-account-name {
- display: flex;
- min-width: 0;
- }
-
- .user-name {
- flex-shrink: 1;
- margin-right: 0.4em;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- .account-name {
- min-width: 1.6em;
- margin-right: 0.4em;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- flex: 1 1 0;
- }
- }
-
- .heading-right {
- display: flex;
- flex-shrink: 0;
- }
-
- .timeago {
- margin-right: 0.2em;
- }
-
- .heading-reply-row {
- position: relative;
- align-content: baseline;
- font-size: 12px;
- line-height: 18px;
- max-width: 100%;
- display: flex;
- flex-wrap: wrap;
- align-items: stretch;
-
- > .reply-to-and-accountname > a {
- overflow: hidden;
- max-width: 100%;
- text-overflow: ellipsis;
- white-space: nowrap;
- word-break: break-all;
- }
- }
-
- .reply-to-and-accountname {
- display: flex;
- height: 18px;
- margin-right: 0.5em;
- max-width: 100%;
- .icon-reply {
- transform: scaleX(-1);
- }
- }
-
- .reply-info {
- display: flex;
- }
-
- .reply-to-popover {
- min-width: 0;
- }
-
- .reply-to {
- display: flex;
- }
-
- .reply-to-text {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- margin: 0 0.4em 0 0.2em;
- }
-
- .replies-separator {
- margin-left: 0.4em;
- }
-
- .replies {
- line-height: 18px;
- font-size: 12px;
- display: flex;
- flex-wrap: wrap;
- & > * {
- margin-right: 0.4em;
- }
- }
-
- .reply-link {
- height: 17px;
- }
- }
-
- .retweet-info {
- padding: 0.4em $status-margin;
- margin: 0;
-
- .avatar.still-image {
- border-radius: $fallback--avatarAltRadius;
- border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
- margin-left: 28px;
- width: 20px;
- height: 20px;
- }
-
- .media-body {
- font-size: 1em;
- line-height: 22px;
-
- display: flex;
- align-content: center;
- flex-wrap: wrap;
-
- .user-name {
- font-weight: bold;
- overflow: hidden;
- text-overflow: ellipsis;
-
- img {
- width: 14px;
- height: 14px;
- vertical-align: middle;
- object-fit: contain
- }
- }
-
- i {
- padding: 0 0.2em;
- }
-
- a {
- max-width: 100%;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- }
- }
-}
-
-.status-fadein {
- animation-duration: 0.4s;
- animation-name: fadein;
-}
-
-@keyframes fadein {
- from {
- opacity: 0;
- }
- to {
- opacity: 1;
- }
-}
-
-.status-conversation {
- border-left-style: solid;
-}
-
-.status-actions {
- position: relative;
- width: 100%;
- display: flex;
- margin-top: $status-margin;
-
- > * {
- max-width: 4em;
- flex: 1;
- }
-}
-
-.button-icon.icon-reply {
- &:not(.button-icon-disabled):hover,
- &.button-icon-active {
- color: $fallback--cBlue;
- color: var(--cBlue, $fallback--cBlue);
- }
-}
-
-.button-icon.icon-reply {
- &:not(.button-icon-disabled) {
- cursor: pointer;
- }
-}
-
-.status:hover .animated.avatar {
- canvas {
- display: none;
- }
- img {
- visibility: visible;
- }
-}
-
-.status {
- display: flex;
- padding: $status-margin;
- &.is-retweet {
- padding-top: 0;
- }
-}
-
-.status-conversation:last-child {
- border-bottom: none;
-}
-
-.muted {
- padding: 0.25em 0.5em;
- button {
- margin-left: auto;
- }
-
- .muteWords {
- margin-left: 10px;
- }
-}
-
-a.unmute {
- display: block;
- margin-left: auto;
-}
-
-.reply-body {
- flex: 1;
-}
-
-.favs-repeated-users {
- margin-top: $status-margin;
-
- .stats {
- width: 100%;
- display: flex;
- line-height: 1em;
-
- .stat-count {
- margin-right: $status-margin;
-
- .stat-title {
- color: var(--faint, $fallback--faint);
- font-size: 12px;
- text-transform: uppercase;
- position: relative;
- }
-
- .stat-number {
- font-weight: bolder;
- font-size: 16px;
- line-height: 1em;
- }
- }
-
- .avatar-row {
- flex: 1;
- overflow: hidden;
- position: relative;
- display: flex;
- align-items: center;
-
- &::before {
- content: '';
- position: absolute;
- height: 100%;
- width: 1px;
- left: 0;
- background-color: var(--faint, $fallback--faint);
- }
- }
- }
-}
-
-@media all and (max-width: 800px) {
- .status-el {
- .retweet-info {
- .avatar.still-image {
- margin-left: 20px;
- }
- }
- }
- .status {
- max-width: 100%;
- }
-
- .status .avatar.still-image {
- width: 40px;
- height: 40px;
-
- &.avatar-compact {
- width: 32px;
- height: 32px;
- }
- }
-}
-
-</style>
+<style src="./status.scss" lang="scss"></style>
diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js
index ccc01b6f..df095de3 100644
--- a/src/components/status_content/status_content.js
+++ b/src/components/status_content/status_content.js
@@ -14,11 +14,12 @@ const StatusContent = {
'status',
'focused',
'noHeading',
- 'fullContent'
+ 'fullContent',
+ 'singleLine'
],
data () {
return {
- showingTall: this.inConversation && this.focused,
+ showingTall: this.fullContent || (this.inConversation && this.focused),
showingLongSubject: false,
// not as computed because it sets the initial state which will be changed later
expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject
@@ -44,14 +45,14 @@ const StatusContent = {
return lengthScore > 20
},
longSubject () {
- return this.status.summary.length > 900
+ return this.status.summary.length > 240
},
// When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status.
mightHideBecauseSubject () {
- return this.status.summary && (!this.tallStatus || this.localCollapseSubjectDefault)
+ return !!this.status.summary && this.localCollapseSubjectDefault
},
mightHideBecauseTall () {
- return this.tallStatus && (!this.status.summary || !this.localCollapseSubjectDefault)
+ return this.tallStatus && !(this.status.summary && this.localCollapseSubjectDefault)
},
hideSubjectStatus () {
return this.mightHideBecauseSubject && !this.expandingSubject
@@ -99,15 +100,8 @@ const StatusContent = {
file => !fileType.fileMatchesSomeType(this.galleryTypes, file)
)
},
- hasImageAttachments () {
- return this.status.attachments.some(
- file => fileType.fileType(file.mimetype) === 'image'
- )
- },
- hasVideoAttachments () {
- return this.status.attachments.some(
- file => fileType.fileType(file.mimetype) === 'video'
- )
+ attachmentTypes () {
+ return this.status.attachments.map(file => fileType.fileType(file.mimetype))
},
maxThumbnails () {
return this.mergedConfig.maxThumbnails
@@ -142,12 +136,6 @@ const StatusContent = {
return html
}
},
- contentHtml () {
- if (!this.status.summary_html) {
- return this.postBodyHtml
- }
- return this.status.summary_html + '<br />' + this.postBodyHtml
- },
...mapGetters(['mergedConfig']),
...mapState({
betterShadow: state => state.interface.browserSupport.cssFilter,
@@ -176,8 +164,8 @@ const StatusContent = {
}
}
if (target.rel.match(/(?:^|\s)tag(?:$|\s)/) || target.className.match(/hashtag/)) {
- // Extract tag name from link url
- const tag = extractTagFromUrl(target.href)
+ // Extract tag name from dataset or link url
+ const tag = target.dataset.tag || extractTagFromUrl(target.href)
if (tag) {
const link = this.generateTagLink(tag)
this.$router.push(link)
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index 8c2e8749..fb469a2f 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -1,47 +1,34 @@
<template>
<!-- eslint-disable vue/no-v-html -->
- <div class="status-body">
+ <div class="StatusContent">
<slot name="header" />
<div
- v-if="longSubject"
- class="status-content-wrapper"
- :class="{ 'tall-status': !showingLongSubject }"
+ v-if="status.summary_html"
+ class="summary-wrapper"
+ :class="{ 'tall-subject': (longSubject && !showingLongSubject) }"
>
- <a
- v-if="!showingLongSubject"
- class="tall-status-hider"
- :class="{ 'tall-status-hider_focused': focused }"
- href="#"
- @click.prevent="showingLongSubject=true"
- >
- {{ $t("general.show_more") }}
- <span
- v-if="hasImageAttachments"
- class="icon-picture"
- />
- <span
- v-if="hasVideoAttachments"
- class="icon-video"
- />
- <span
- v-if="status.card"
- class="icon-link"
- />
- </a>
<div
- class="status-content media-body"
+ class="media-body summary"
@click.prevent="linkClicked"
- v-html="contentHtml"
+ v-html="status.summary_html"
/>
<a
- v-if="showingLongSubject"
+ v-if="longSubject && showingLongSubject"
href="#"
- class="status-unhider"
+ class="tall-subject-hider"
@click.prevent="showingLongSubject=false"
- >{{ $t("general.show_less") }}</a>
+ >{{ $t("status.hide_full_subject") }}</a>
+ <a
+ v-else-if="longSubject"
+ class="tall-subject-hider"
+ :class="{ 'tall-subject-hider_focused': focused }"
+ href="#"
+ @click.prevent="showingLongSubject=true"
+ >
+ {{ $t("status.show_full_subject") }}
+ </a>
</div>
<div
- v-else
:class="{'tall-status': hideTallStatus}"
class="status-content-wrapper"
>
@@ -51,31 +38,52 @@
:class="{ 'tall-status-hider_focused': focused }"
href="#"
@click.prevent="toggleShowMore"
- >{{ $t("general.show_more") }}</a>
+ >
+ {{ $t("general.show_more") }}
+ </a>
<div
v-if="!hideSubjectStatus"
+ :class="{ 'single-line': singleLine }"
class="status-content media-body"
@click.prevent="linkClicked"
- v-html="contentHtml"
- />
- <div
- v-else
- class="status-content media-body"
- @click.prevent="linkClicked"
- v-html="status.summary_html"
+ v-html="postBodyHtml"
/>
<a
v-if="hideSubjectStatus"
href="#"
class="cw-status-hider"
@click.prevent="toggleShowMore"
- >{{ $t("general.show_more") }}</a>
+ >
+ {{ $t("status.show_content") }}
+ <span
+ v-if="attachmentTypes.includes('image')"
+ class="icon-picture"
+ />
+ <span
+ v-if="attachmentTypes.includes('video')"
+ class="icon-video"
+ />
+ <span
+ v-if="attachmentTypes.includes('audio')"
+ class="icon-music"
+ />
+ <span
+ v-if="attachmentTypes.includes('unknown')"
+ class="icon-doc"
+ />
+ <span
+ v-if="status.card"
+ class="icon-link"
+ />
+ </a>
<a
- v-if="showingMore"
+ v-if="showingMore && !fullContent"
href="#"
class="status-unhider"
@click.prevent="toggleShowMore"
- >{{ $t("general.show_less") }}</a>
+ >
+ {{ tallStatus ? $t("general.show_less") : $t("status.hide_content") }}
+ </a>
</div>
<div v-if="status.poll && status.poll.options">
@@ -125,10 +133,16 @@
$status-margin: 0.75em;
-.status-body {
+.StatusContent {
flex: 1;
min-width: 0;
+ .status-content-wrapper {
+ display: flex;
+ flex-direction: column;
+ flex-wrap: nowrap;
+ }
+
.tall-status {
position: relative;
height: 220px;
@@ -136,7 +150,7 @@ $status-margin: 0.75em;
overflow-y: hidden;
z-index: 1;
.status-content {
- height: 100%;
+ min-height: 0;
mask: linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
linear-gradient(to top, white, white);
/* Autoprefixed seem to ignore this one, and also syntax is different */
@@ -164,22 +178,57 @@ $status-margin: 0.75em;
word-break: break-all;
}
+ img, video {
+ max-width: 100%;
+ max-height: 400px;
+ vertical-align: middle;
+ object-fit: contain;
+
+ &.emoji {
+ width: 32px;
+ height: 32px;
+ }
+ }
+
+ .summary-wrapper {
+ margin-bottom: 0.5em;
+ border-style: solid;
+ border-width: 0 0 1px 0;
+ border-color: var(--border, $fallback--border);
+ flex-grow: 0;
+ }
+
+ .summary {
+ font-style: italic;
+ padding-bottom: 0.5em;
+ }
+
+ .tall-subject {
+ position: relative;
+ .summary {
+ max-height: 2em;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ }
+
+ .tall-subject-hider {
+ display: inline-block;
+ word-break: break-all;
+ // position: absolute;
+ width: 100%;
+ text-align: center;
+ padding-bottom: 0.5em;
+ }
+
.status-content {
font-family: var(--postFont, sans-serif);
line-height: 1.4em;
white-space: pre-wrap;
-
- img, video {
- max-width: 100%;
- max-height: 400px;
- vertical-align: middle;
- object-fit: contain;
-
- &.emoji {
- width: 32px;
- height: 32px;
- }
- }
+ overflow-wrap: break-word;
+ word-wrap: break-word;
+ word-break: break-word;
blockquote {
margin: 0.2em 0 0.2em 2em;
@@ -221,20 +270,18 @@ $status-margin: 0.75em;
h4 {
margin: 1.1em 0;
}
+
+ &.single-line {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ height: 1.4em;
+ }
}
}
.greentext {
color: $fallback--cGreen;
- color: var(--cGreen, $fallback--cGreen);
+ color: var(--postGreentext, $fallback--cGreen);
}
-
-.timeline :not(.panel-disabled) > {
- .status-el:last-child {
- border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
- border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
- border-bottom: none;
- }
-}
-
</style>
diff --git a/src/components/status_popover/status_popover.js b/src/components/status_popover/status_popover.js
index 159132a9..51e7680c 100644
--- a/src/components/status_popover/status_popover.js
+++ b/src/components/status_popover/status_popover.js
@@ -22,6 +22,10 @@ const StatusPopover = {
methods: {
enter () {
if (!this.status) {
+ if (!this.statusId) {
+ this.error = true
+ return
+ }
this.$store.dispatch('fetchStatus', this.statusId)
.then(data => (this.error = false))
.catch(e => (this.error = true))
diff --git a/src/components/status_popover/status_popover.vue b/src/components/status_popover/status_popover.vue
index f5948207..162eb210 100644
--- a/src/components/status_popover/status_popover.vue
+++ b/src/components/status_popover/status_popover.vue
@@ -1,7 +1,7 @@
<template>
<Popover
trigger="hover"
- popover-class="status-popover"
+ popover-class="popover-default status-popover"
:bound-to="{ x: 'container' }"
@show="enter"
>
@@ -38,7 +38,8 @@
<style lang="scss">
@import '../../_variables.scss';
-.status-popover {
+/* popover styles load on-demand, so we need to override */
+.status-popover.popover {
font-size: 1rem;
min-width: 15em;
max-width: 95%;
@@ -52,7 +53,8 @@
box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5);
box-shadow: var(--popupShadow);
- .status-el.status-el {
+ /* TODO cleanup this */
+ .Status.Status {
border: none;
}
diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js
index e48fef47..ab40bbd7 100644
--- a/src/components/still-image/still-image.js
+++ b/src/components/still-image/still-image.js
@@ -4,7 +4,8 @@ const StillImage = {
'referrerpolicy',
'mimetype',
'imageLoadError',
- 'imageLoadHandler'
+ 'imageLoadHandler',
+ 'alt'
],
data () {
return {
diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue
index 4137bd59..ad82210d 100644
--- a/src/components/still-image/still-image.vue
+++ b/src/components/still-image/still-image.vue
@@ -11,6 +11,8 @@
<img
ref="src"
:key="src"
+ :alt="alt"
+ :title="alt"
:src="src"
:referrerpolicy="referrerpolicy"
@load="onLoad"
@@ -23,33 +25,33 @@
<style lang="scss">
@import '../../_variables.scss';
+
.still-image {
position: relative;
line-height: 0;
overflow: hidden;
- width: 100%;
- height: 100%;
+ display: flex;
+ align-items: center;
- &:hover canvas {
- display: none;
+ canvas {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ visibility: var(--still-image-canvas, visible);
}
img {
width: 100%;
- height: 100%;
+ min-height: 100%;
object-fit: contain;
}
&.animated {
- &:hover::before,
- img {
- visibility: hidden;
- }
-
- &:hover img {
- visibility: visible
- }
-
&::before {
content: 'gif';
position: absolute;
@@ -57,25 +59,28 @@
font-size: 10px;
top: 5px;
left: 5px;
- background: rgba(127,127,127,.5);
- color: #FFF;
+ background: rgba(127, 127, 127, 0.5);
+ color: #fff;
display: block;
padding: 2px 4px;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
z-index: 2;
+ visibility: var(--still-image-label-visibility, visible);
}
- }
- canvas {
- position: absolute;
- top: 0;
- bottom: 0;
- left: 0;
- right: 0;
- width: 100%;
- height: 100%;
- object-fit: contain;
+ &:hover canvas {
+ display: none;
+ }
+
+ &:hover::before,
+ img {
+ visibility: var(--still-image-img, hidden);
+ }
+
+ &:hover img {
+ visibility: visible;
+ }
}
}
</style>
diff --git a/src/components/tab_switcher/tab_switcher.js b/src/components/tab_switcher/tab_switcher.js
index 008e1e95..40b5b3ca 100644
--- a/src/components/tab_switcher/tab_switcher.js
+++ b/src/components/tab_switcher/tab_switcher.js
@@ -1,4 +1,5 @@
import Vue from 'vue'
+import { mapState } from 'vuex'
import './tab_switcher.scss'
@@ -24,6 +25,11 @@ export default Vue.component('tab-switcher', {
required: false,
type: Boolean,
default: false
+ },
+ sideTabBar: {
+ required: false,
+ type: Boolean,
+ default: false
}
},
data () {
@@ -39,7 +45,13 @@ export default Vue.component('tab-switcher', {
} else {
return this.active
}
- }
+ },
+ settingsModalVisible () {
+ return this.settingsModalState === 'visible'
+ },
+ ...mapState({
+ settingsModalState: state => state.interface.settingsModalState
+ })
},
beforeUpdate () {
const currentSlot = this.$slots.default[this.active]
@@ -55,6 +67,9 @@ export default Vue.component('tab-switcher', {
this.onSwitch.call(null, this.$slots.default[index].key)
}
this.active = index
+ if (this.scrollableTabs) {
+ this.$refs.contents.scrollTop = 0
+ }
}
}
},
@@ -64,7 +79,6 @@ export default Vue.component('tab-switcher', {
if (!slot.tag) return
const classesTab = ['tab']
const classesWrapper = ['tab-wrapper']
-
if (this.activeIndex === index) {
classesTab.push('active')
classesWrapper.push('active')
@@ -87,8 +101,14 @@ export default Vue.component('tab-switcher', {
<button
disabled={slot.data.attrs.disabled}
onClick={this.activateTab(index)}
- class={classesTab.join(' ')}>
- {slot.data.attrs.label}</button>
+ class={classesTab.join(' ')}
+ type="button"
+ >
+ {!slot.data.attrs.icon ? '' : (<i class={'tab-icon icon-' + slot.data.attrs.icon}/>)}
+ <span class="text">
+ {slot.data.attrs.label}
+ </span>
+ </button>
</div>
)
})
@@ -96,20 +116,32 @@ export default Vue.component('tab-switcher', {
const contents = this.$slots.default.map((slot, index) => {
if (!slot.tag) return
const active = this.activeIndex === index
- if (this.renderOnlyFocused) {
- return active
- ? <div class="active">{slot}</div>
- : <div class="hidden"></div>
+ const classes = [ active ? 'active' : 'hidden' ]
+ if (slot.data.attrs.fullHeight) {
+ classes.push('full-height')
}
- return <div class={active ? 'active' : 'hidden' }>{slot}</div>
+ const renderSlot = (!this.renderOnlyFocused || active)
+ ? slot
+ : ''
+
+ return (
+ <div class={classes}>
+ {
+ this.sideTabBar
+ ? <h1 class="mobile-label">{slot.data.attrs.label}</h1>
+ : ''
+ }
+ {renderSlot}
+ </div>
+ )
})
return (
- <div class="tab-switcher">
+ <div class={'tab-switcher ' + (this.sideTabBar ? 'side-tabs' : 'top-tabs')}>
<div class="tabs">
{tabs}
</div>
- <div class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')}>
+ <div ref="contents" class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')} v-body-scroll-lock={this.settingsModalVisible}>
{contents}
</div>
</div>
diff --git a/src/components/tab_switcher/tab_switcher.scss b/src/components/tab_switcher/tab_switcher.scss
index df585faa..d2ef4857 100644
--- a/src/components/tab_switcher/tab_switcher.scss
+++ b/src/components/tab_switcher/tab_switcher.scss
@@ -2,7 +2,144 @@
.tab-switcher {
display: flex;
- flex-direction: column;
+
+ .tab-icon {
+ font-size: 2em;
+ display: block;
+ }
+
+ &.top-tabs {
+ flex-direction: column;
+
+ > .tabs {
+ width: 100%;
+ overflow-y: hidden;
+ overflow-x: auto;
+ padding-top: 5px;
+ flex-direction: row;
+
+ &::after, &::before {
+ content: '';
+ flex: 1 1 auto;
+ border-bottom: 1px solid;
+ border-bottom-color: $fallback--border;
+ border-bottom-color: var(--border, $fallback--border);
+ }
+ .tab-wrapper {
+ height: 28px;
+
+ &:not(.active)::after {
+ left: 0;
+ right: 0;
+ bottom: 0;
+ border-bottom: 1px solid;
+ border-bottom-color: $fallback--border;
+ border-bottom-color: var(--border, $fallback--border);
+ }
+ }
+ .tab {
+ width: 100%;
+ min-width: 1px;
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ padding-bottom: 99px;
+ margin-bottom: 6px - 99px;
+ }
+ }
+ .contents.scrollable-tabs {
+ flex-basis: 0;
+ }
+ }
+
+ &.side-tabs {
+ flex-direction: row;
+
+ @media all and (max-width: 800px) {
+ overflow-x: auto;
+ }
+
+ > .contents {
+ flex: 1 1 auto;
+ }
+
+ > .tabs {
+ flex: 0 0 auto;
+ overflow-y: auto;
+ overflow-x: hidden;
+ flex-direction: column;
+
+ &::after, &::before {
+ flex-shrink: 0;
+ flex-basis: .5em;
+ content: '';
+ border-right: 1px solid;
+ border-right-color: $fallback--border;
+ border-right-color: var(--border, $fallback--border);
+ }
+
+ &::after {
+ flex-grow: 1;
+ }
+
+ &::before {
+ flex-grow: 0;
+ }
+
+ .tab-wrapper {
+ min-width: 10em;
+ display: flex;
+ flex-direction: column;
+
+ @media all and (max-width: 800px) {
+ min-width: 1em;
+ }
+
+ &:not(.active)::after {
+ top: 0;
+ right: 0;
+ bottom: 0;
+ border-right: 1px solid;
+ border-right-color: $fallback--border;
+ border-right-color: var(--border, $fallback--border);
+ }
+
+ &::before {
+ flex: 0 0 6px;
+ content: '';
+ border-right: 1px solid;
+ border-right-color: $fallback--border;
+ border-right-color: var(--border, $fallback--border);
+ }
+
+ &:last-child .tab {
+ margin-bottom: 0;
+ }
+ }
+
+ .tab {
+ flex: 1;
+ box-sizing: content-box;
+ min-width: 10em;
+ min-width: 1px;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ padding-left: 1em;
+ padding-right: calc(1em + 200px);
+ margin-right: -200px;
+ margin-left: 1em;
+
+ @media all and (max-width: 800px) {
+ padding-left: .25em;
+ padding-right: calc(.25em + 200px);
+ margin-right: calc(.25em - 200px);
+ margin-left: .25em;
+ .text {
+ display: none
+ }
+ }
+ }
+ }
+ }
.contents {
flex: 1 0 auto;
@@ -11,88 +148,89 @@
.hidden {
display: none;
}
+ .full-height:not(.hidden) {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ > *:not(.mobile-label) {
+ flex: 1;
+ }
+ }
&.scrollable-tabs {
- flex-basis: 0;
overflow-y: auto;
}
}
- .tabs {
- display: flex;
+
+ .tab {
position: relative;
- width: 100%;
- overflow-y: hidden;
- overflow-x: auto;
- padding-top: 5px;
- box-sizing: border-box;
+ white-space: nowrap;
+ padding: 6px 1em;
+ background-color: $fallback--fg;
+ background-color: var(--tab, $fallback--fg);
- &::after, &::before {
- display: block;
- content: '';
- flex: 1 1 auto;
- border-bottom: 1px solid;
- border-bottom-color: $fallback--border;
- border-bottom-color: var(--border, $fallback--border);
+ &, &:active .tab-icon {
+ color: $fallback--text;
+ color: var(--tabText, $fallback--text);
}
- .tab-wrapper {
- height: 28px;
- position: relative;
- display: flex;
- flex: 0 0 auto;
+ &:not(.active) {
+ z-index: 4;
- .tab {
- width: 100%;
- min-width: 1px;
- position: relative;
- border-bottom-left-radius: 0;
- border-bottom-right-radius: 0;
- padding: 6px 1em;
- padding-bottom: 99px;
- margin-bottom: 6px - 99px;
- white-space: nowrap;
+ &:hover {
+ z-index: 6;
+ }
+ }
- color: $fallback--text;
- color: var(--tabText, $fallback--text);
- background-color: $fallback--fg;
- background-color: var(--tab, $fallback--fg);
+ &.active {
+ background: transparent;
+ z-index: 5;
+ color: $fallback--text;
+ color: var(--tabActiveText, $fallback--text);
+ }
- &:not(.active) {
- z-index: 4;
+ img {
+ max-height: 26px;
+ vertical-align: top;
+ margin-top: -5px;
+ }
+ }
- &:hover {
- z-index: 6;
- }
- }
+ .tabs {
+ display: flex;
+ position: relative;
+ box-sizing: border-box;
- &.active {
- background: transparent;
- z-index: 5;
- color: $fallback--text;
- color: var(--tabActiveText, $fallback--text);
- }
+ &::after, &::before {
+ display: block;
+ flex: 1 1 auto;
+ }
+ }
- img {
- max-height: 26px;
- vertical-align: top;
- margin-top: -5px;
- }
- }
+ .tab-wrapper {
+ position: relative;
+ display: flex;
+ flex: 0 0 auto;
- &:not(.active) {
- &::after {
- content: '';
- position: absolute;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 7;
- border-bottom: 1px solid;
- border-bottom-color: $fallback--border;
- border-bottom-color: var(--border, $fallback--border);
- }
+ &:not(.active) {
+ &::after {
+ content: '';
+ position: absolute;
+ z-index: 7;
}
}
+ }
+ .mobile-label {
+ padding-left: .3em;
+ padding-bottom: .25em;
+ margin-top: .5em;
+ margin-left: .2em;
+ margin-bottom: .25em;
+ border-bottom: 1px solid var(--border, $fallback--border);
+
+ @media all and (min-width: 800px) {
+ display: none;
+ }
}
}
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index 9a53acd6..5a7f7a78 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -1,6 +1,7 @@
import Status from '../status/status.vue'
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 { throttle, keyBy } from 'lodash'
export const getExcludedStatusIdsByPinning = (statuses, pinnedStatusIds) => {
@@ -35,6 +36,11 @@ const Timeline = {
bottomedOut: false
}
},
+ components: {
+ Status,
+ Conversation,
+ TimelineMenu
+ },
computed: {
timelineError () {
return this.$store.state.statuses.error
@@ -45,11 +51,15 @@ const Timeline = {
newStatusCount () {
return this.timeline.newStatusCount
},
- newStatusCountStr () {
+ showLoadButton () {
+ if (this.timelineError || this.errorData) return false
+ return this.timeline.newStatusCount > 0 || this.timeline.flushMarker !== 0
+ },
+ loadButtonString () {
if (this.timeline.flushMarker !== 0) {
- return ''
+ return this.$t('timeline.reload')
} else {
- return ` (${this.newStatusCount})`
+ return `${this.$t('timeline.show_new')} (${this.newStatusCount})`
}
},
classes () {
@@ -70,10 +80,6 @@ const Timeline = {
return keyBy(this.pinnedStatusIds)
}
},
- components: {
- Status,
- Conversation
- },
created () {
const store = this.$store
const credentials = store.state.users.currentUser.credentials
@@ -112,8 +118,6 @@ const Timeline = {
if (e.key === '.') this.showNewStatuses()
},
showNewStatuses () {
- if (this.newStatusCount === 0) return
-
if (this.timeline.flushMarker !== 0) {
this.$store.commit('clearTimeline', { timeline: this.timelineName, excludeUserId: true })
this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 })
@@ -135,7 +139,7 @@ const Timeline = {
showImmediately: true,
userId: this.userId,
tag: this.tag
- }).then(statuses => {
+ }).then(({ statuses }) => {
store.commit('setLoading', { timeline: this.timelineName, value: false })
if (statuses && statuses.length === 0) {
this.bottomedOut = true
@@ -146,7 +150,6 @@ const Timeline = {
const bodyBRect = document.body.getBoundingClientRect()
const height = Math.max(bodyBRect.height, -(bodyBRect.y))
if (this.timeline.loading === false &&
- this.$store.getters.mergedConfig.autoLoad &&
this.$el.offsetHeight > 0 &&
(window.innerHeight + window.pageYOffset) >= (height - 750)) {
this.fetchOlderStatuses()
diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue
index 9777bd0c..2ff933e9 100644
--- a/src/components/timeline/timeline.vue
+++ b/src/components/timeline/timeline.vue
@@ -1,9 +1,7 @@
<template>
- <div :class="classes.root">
+ <div :class="[classes.root, 'timeline']">
<div :class="classes.header">
- <div class="title">
- {{ title }}
- </div>
+ <TimelineMenu v-if="!embedded" />
<div
v-if="timelineError"
class="loadmore-error alert error"
@@ -19,14 +17,14 @@
{{ errorData.statusText }}
</div>
<button
- v-if="timeline.newStatusCount > 0 && !timelineError && !errorData"
+ v-else-if="showLoadButton"
class="loadmore-button"
@click.prevent="showNewStatuses"
>
- {{ $t('timeline.show_new') }}{{ newStatusCountStr }}
+ {{ loadButtonString }}
</button>
<div
- v-if="!timeline.newStatusCount > 0 && !timelineError && !errorData"
+ v-else
class="loadmore-text faint"
@click.prevent
>
@@ -106,4 +104,16 @@
opacity: 1;
}
}
+
+.timeline-heading {
+ max-width: 100%;
+ flex-wrap: nowrap;
+ .loadmore-button {
+ flex-shrink: 0;
+ }
+ .loadmore-text {
+ flex-shrink: 0;
+ line-height: 1em;
+ }
+}
</style>
diff --git a/src/components/timeline_menu/timeline_menu.js b/src/components/timeline_menu/timeline_menu.js
new file mode 100644
index 00000000..2be75b06
--- /dev/null
+++ b/src/components/timeline_menu/timeline_menu.js
@@ -0,0 +1,63 @@
+import Popover from '../popover/popover.vue'
+import { mapState } from 'vuex'
+
+// Route -> i18n key mapping, exported andnot in the computed
+// because nav panel benefits from the same information.
+export const timelineNames = () => {
+ return {
+ 'friends': 'nav.timeline',
+ 'bookmarks': 'nav.bookmarks',
+ 'dms': 'nav.dms',
+ 'public-timeline': 'nav.public_tl',
+ 'public-external-timeline': 'nav.twkn',
+ 'tag-timeline': 'tag'
+ }
+}
+
+const TimelineMenu = {
+ components: {
+ Popover
+ },
+ data () {
+ return {
+ isOpen: false
+ }
+ },
+ created () {
+ if (this.currentUser && this.currentUser.locked) {
+ this.$store.dispatch('startFetchingFollowRequests')
+ }
+ if (timelineNames()[this.$route.name]) {
+ this.$store.dispatch('setLastTimeline', this.$route.name)
+ }
+ },
+ methods: {
+ openMenu () {
+ // $nextTick is too fast, animation won't play back but
+ // instead starts in fully open position. Low values
+ // like 1-5 work on fast machines but not on mobile, 25
+ // seems like a good compromise that plays without significant
+ // added lag.
+ setTimeout(() => {
+ this.isOpen = true
+ }, 25)
+ },
+ timelineName () {
+ const route = this.$route.name
+ if (route === 'tag-timeline') {
+ return '#' + this.$route.params.tag
+ }
+ const i18nkey = timelineNames()[this.$route.name]
+ return i18nkey ? this.$t(i18nkey) : route
+ }
+ },
+ computed: {
+ ...mapState({
+ currentUser: state => state.users.currentUser,
+ privateMode: state => state.instance.private,
+ federating: state => state.instance.federating
+ })
+ }
+}
+
+export default TimelineMenu
diff --git a/src/components/timeline_menu/timeline_menu.vue b/src/components/timeline_menu/timeline_menu.vue
new file mode 100644
index 00000000..be512d60
--- /dev/null
+++ b/src/components/timeline_menu/timeline_menu.vue
@@ -0,0 +1,180 @@
+<template>
+ <Popover
+ trigger="click"
+ class="timeline-menu"
+ :class="{ 'open': isOpen }"
+ :margin="{ left: -15, right: -200 }"
+ :bound-to="{ x: 'container' }"
+ popover-class="timeline-menu-popover-wrap"
+ @show="openMenu"
+ @close="() => isOpen = false"
+ >
+ <div
+ slot="content"
+ class="timeline-menu-popover panel panel-default"
+ >
+ <ul>
+ <li v-if="currentUser">
+ <router-link :to="{ name: 'friends' }">
+ <i class="button-icon icon-home-2" />{{ $t("nav.timeline") }}
+ </router-link>
+ </li>
+ <li v-if="currentUser">
+ <router-link :to="{ name: 'bookmarks'}">
+ <i class="button-icon icon-bookmark" />{{ $t("nav.bookmarks") }}
+ </router-link>
+ </li>
+ <li v-if="currentUser">
+ <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
+ <i class="button-icon icon-mail-alt" />{{ $t("nav.dms") }}
+ </router-link>
+ </li>
+ <li v-if="currentUser || !privateMode">
+ <router-link :to="{ name: 'public-timeline' }">
+ <i class="button-icon icon-users" />{{ $t("nav.public_tl") }}
+ </router-link>
+ </li>
+ <li v-if="federating && (currentUser || !privateMode)">
+ <router-link :to="{ name: 'public-external-timeline' }">
+ <i class="button-icon icon-globe" />{{ $t("nav.twkn") }}
+ </router-link>
+ </li>
+ </ul>
+ </div>
+ <div
+ slot="trigger"
+ class="title timeline-menu-title"
+ >
+ <span>{{ timelineName() }}</span>
+ <i class="icon-down-open" />
+ </div>
+ </Popover>
+</template>
+
+<script src="./timeline_menu.js" ></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.timeline-menu {
+ flex-shrink: 1;
+ margin-right: auto;
+ min-width: 0;
+ width: 24rem;
+ .timeline-menu-popover-wrap {
+ overflow: hidden;
+ // Match panel heading padding to line up menu with bottom of heading
+ margin-top: 0.6rem;
+ padding: 0 15px 15px 15px;
+ }
+ .timeline-menu-popover {
+ width: 24rem;
+ max-width: 100vw;
+ margin: 0;
+ font-size: 1rem;
+ border-top-right-radius: 0;
+ border-top-left-radius: 0;
+ transform: translateY(-100%);
+ transition: transform 100ms;
+ }
+ .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;
+ display: flex;
+ user-select: none;
+ width: 100%;
+
+ span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ i {
+ margin-left: 0.6em;
+ flex-shrink: 0;
+ font-size: 1rem;
+ transition: transform 100ms;
+ }
+ }
+
+ &.open .timeline-menu-title i {
+ color: $fallback--text;
+ color: var(--panelText, $fallback--text);
+ transform: rotate(180deg);
+ }
+
+ .panel {
+ box-shadow: var(--popoverShadow);
+ }
+
+ ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ }
+
+ li {
+ border-bottom: 1px solid;
+ border-color: $fallback--border;
+ border-color: var(--border, $fallback--border);
+ padding: 0;
+
+ &:last-child a {
+ border-bottom-right-radius: $fallback--panelRadius;
+ border-bottom-right-radius: var(--panelRadius, $fallback--panelRadius);
+ border-bottom-left-radius: $fallback--panelRadius;
+ border-bottom-left-radius: var(--panelRadius, $fallback--panelRadius);
+ }
+
+ &:last-child {
+ border: none;
+ }
+
+ i {
+ margin: 0 0.5em;
+ }
+ }
+
+ a {
+ display: block;
+ padding: 0.6em 0;
+
+ &: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;
+ }
+ }
+ }
+}
+
+</style>
diff --git a/src/components/user_avatar/user_avatar.js b/src/components/user_avatar/user_avatar.js
index 4adf8211..94653004 100644
--- a/src/components/user_avatar/user_avatar.js
+++ b/src/components/user_avatar/user_avatar.js
@@ -8,26 +8,20 @@ const UserAvatar = {
],
data () {
return {
- showPlaceholder: false
+ showPlaceholder: false,
+ defaultAvatar: `${this.$store.state.instance.server + this.$store.state.instance.defaultAvatar}`
}
},
components: {
StillImage
},
- computed: {
- imgSrc () {
- return this.showPlaceholder ? '/images/avi.png' : this.user.profile_image_url_original
- }
- },
methods: {
+ imgSrc (src) {
+ return (!src || this.showPlaceholder) ? this.defaultAvatar : src
+ },
imageLoadError () {
this.showPlaceholder = true
}
- },
- watch: {
- src () {
- this.showPlaceholder = false
- }
}
}
diff --git a/src/components/user_avatar/user_avatar.vue b/src/components/user_avatar/user_avatar.vue
index 9ffb28d8..e4e4127c 100644
--- a/src/components/user_avatar/user_avatar.vue
+++ b/src/components/user_avatar/user_avatar.vue
@@ -1,9 +1,9 @@
<template>
<StillImage
- class="avatar"
+ class="Avatar"
:alt="user.screen_name"
:title="user.screen_name"
- :src="imgSrc"
+ :src="imgSrc(user.profile_image_url_original)"
:class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }"
:image-load-error="imageLoadError"
/>
@@ -13,7 +13,9 @@
<style lang="scss">
@import '../../_variables.scss';
-.avatar.still-image {
+.Avatar {
+ --still-image-label-visibility: hidden;
+
width: 48px;
height: 48px;
box-shadow: var(--avatarStatusShadow);
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index b40435cd..041bb80f 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -50,15 +50,6 @@
>
{{ user.name }}
</div>
- <router-link
- v-if="!isOtherUser"
- :to="{ name: 'user-settings' }"
- >
- <i
- class="button-icon icon-wrench usersettings"
- :title="$t('tool_tip.user_settings')"
- />
- </router-link>
<a
v-if="isOtherUser && !user.is_local"
:href="user.statusnet_profile_url"
@@ -75,14 +66,25 @@
<div class="bottom-line">
<router-link
class="user-screen-name"
+ :title="user.screen_name"
:to="userProfileLink(user)"
>
@{{ user.screen_name }}
</router-link>
- <span
- v-if="!hideBio && !!visibleRole"
- class="alert staff"
- >{{ visibleRole }}</span>
+ <template v-if="!hideBio">
+ <span
+ v-if="!!visibleRole"
+ class="alert user-role"
+ >
+ {{ visibleRole }}
+ </span>
+ <span
+ v-if="user.bot"
+ class="alert user-role"
+ >
+ bot
+ </span>
+ </template>
<span v-if="user.locked"><i class="icon icon-lock" /></span>
<span
v-if="!mergedConfig.hideUserStats && !hideBio"
@@ -118,7 +120,7 @@
type="color"
>
<label
- for="style-switcher"
+ for="theme_tab"
class="userHighlightSel select"
>
<select
@@ -352,7 +354,7 @@
align-items: flex-start;
max-height: 56px;
- .avatar {
+ .Avatar {
flex: 1 0 100%;
width: 56px;
height: 56px;
@@ -362,13 +364,9 @@
}
}
- &:hover .animated.avatar {
- canvas {
- display: none;
- }
- img {
- visibility: visible;
- }
+ &:hover .Avatar {
+ --still-image-img: visible;
+ --still-image-canvas: hidden;
}
&-avatar-link {
@@ -467,7 +465,7 @@
color: var(--text, $fallback--text);
}
- .staff {
+ .user-role {
flex: none;
text-transform: capitalize;
color: $fallback--text;
diff --git a/src/components/user_list_popover/user_list_popover.js b/src/components/user_list_popover/user_list_popover.js
new file mode 100644
index 00000000..b60f2c4c
--- /dev/null
+++ b/src/components/user_list_popover/user_list_popover.js
@@ -0,0 +1,18 @@
+
+const UserListPopover = {
+ name: 'UserListPopover',
+ props: [
+ 'users'
+ ],
+ components: {
+ Popover: () => import('../popover/popover.vue'),
+ UserAvatar: () => import('../user_avatar/user_avatar.vue')
+ },
+ computed: {
+ usersCapped () {
+ return this.users.slice(0, 16)
+ }
+ }
+}
+
+export default UserListPopover
diff --git a/src/components/user_list_popover/user_list_popover.vue b/src/components/user_list_popover/user_list_popover.vue
new file mode 100644
index 00000000..185c73ca
--- /dev/null
+++ b/src/components/user_list_popover/user_list_popover.vue
@@ -0,0 +1,71 @@
+<template>
+ <Popover
+ trigger="hover"
+ placement="top"
+ :offset="{ y: 5 }"
+ >
+ <template slot="trigger">
+ <slot />
+ </template>
+ <div
+ slot="content"
+ class="user-list-popover"
+ >
+ <div v-if="users.length">
+ <div
+ v-for="(user) in usersCapped"
+ :key="user.id"
+ class="user-list-row"
+ >
+ <UserAvatar
+ :user="user"
+ class="avatar-small"
+ :compact="true"
+ />
+ <div class="user-list-names">
+ <!-- eslint-disable vue/no-v-html -->
+ <span v-html="user.name_html" />
+ <!-- eslint-enable vue/no-v-html -->
+ <span class="user-list-screen-name">{{ user.screen_name }}</span>
+ </div>
+ </div>
+ </div>
+ <div v-else>
+ <i class="icon-spin4 animate-spin" />
+ </div>
+ </div>
+ </Popover>
+</template>
+
+<script src="./user_list_popover.js" ></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.user-list-popover {
+ padding: 0.5em;
+
+ .user-list-row {
+ padding: 0.25em;
+ display: flex;
+ flex-direction: row;
+
+ .user-list-names {
+ display: flex;
+ flex-direction: column;
+ margin-left: 0.5em;
+ min-width: 5em;
+
+ img {
+ width: 1em;
+ height: 1em;
+ }
+ }
+
+ .user-list-screen-name {
+ font-size: 9px;
+ }
+ }
+}
+
+</style>
diff --git a/src/components/user_panel/user_panel.vue b/src/components/user_panel/user_panel.vue
index 1db4f648..5685916a 100644
--- a/src/components/user_panel/user_panel.vue
+++ b/src/components/user_panel/user_panel.vue
@@ -10,9 +10,7 @@
:hide-bio="true"
rounded="top"
/>
- <div class="panel-footer">
- <PostStatusForm />
- </div>
+ <PostStatusForm />
</div>
<auth-form
v-else
diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index 9558a0bd..201727d4 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -3,6 +3,7 @@ import UserCard from '../user_card/user_card.vue'
import FollowCard from '../follow_card/follow_card.vue'
import Timeline from '../timeline/timeline.vue'
import Conversation from '../conversation/conversation.vue'
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
import List from '../list/list.vue'
import withLoadMore from '../../hocs/with_load_more/with_load_more'
@@ -123,6 +124,14 @@ const UserProfile = {
onTabSwitch (tab) {
this.tab = tab
this.$router.replace({ query: { tab } })
+ },
+ linkClicked ({ target }) {
+ if (target.tagName === 'SPAN') {
+ target = target.parentNode
+ }
+ if (target.tagName === 'A') {
+ window.open(target.href, '_blank')
+ }
}
},
watch: {
@@ -146,6 +155,7 @@ const UserProfile = {
FollowerList,
FriendList,
FollowCard,
+ TabSwitcher,
Conversation
}
}
diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index 1871d46c..c7c67c0a 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -11,6 +11,32 @@
:allow-zooming-avatar="true"
rounded="top"
/>
+ <div
+ v-if="user.fields_html && user.fields_html.length > 0"
+ class="user-profile-fields"
+ >
+ <dl
+ v-for="(field, index) in user.fields_html"
+ :key="index"
+ class="user-profile-field"
+ >
+ <dt
+ :title="user.fields_text[index].name"
+ class="user-profile-field-name"
+ @click.prevent="linkClicked"
+ >
+ {{ field.name }}
+ </dt>
+ <!-- eslint-disable vue/no-v-html -->
+ <dd
+ :title="user.fields_text[index].value"
+ class="user-profile-field-value"
+ @click.prevent="linkClicked"
+ v-html="field.value"
+ />
+ <!-- eslint-enable vue/no-v-html -->
+ </dl>
+ </div>
<tab-switcher
:active-tab="tab"
:render-only-focused="true"
@@ -108,11 +134,60 @@
<script src="./user_profile.js"></script>
<style lang="scss">
+@import '../../_variables.scss';
.user-profile {
flex: 2;
flex-basis: 500px;
+ .user-profile-fields {
+ margin: 0 0.5em;
+ img {
+ object-fit: contain;
+ vertical-align: middle;
+ max-width: 100%;
+ max-height: 400px;
+
+ &.emoji {
+ width: 18px;
+ height: 18px;
+ }
+ }
+
+ .user-profile-field {
+ display: flex;
+ margin: 0.25em auto;
+ max-width: 32em;
+ border: 1px solid var(--border, $fallback--border);
+ border-radius: $fallback--inputRadius;
+ border-radius: var(--inputRadius, $fallback--inputRadius);
+
+ .user-profile-field-name {
+ flex: 0 1 30%;
+ font-weight: 500;
+ text-align: right;
+ color: var(--lightText);
+ min-width: 120px;
+ border-right: 1px solid var(--border, $fallback--border);
+ }
+
+ .user-profile-field-value {
+ flex: 1 1 70%;
+ color: var(--text);
+ margin: 0 0 0 0.25em;
+ }
+
+ .user-profile-field-name, .user-profile-field-value {
+ line-height: 18px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ padding: 0.5em 1.5em;
+ box-sizing: border-box;
+ }
+ }
+ }
+
.userlist-placeholder {
display: flex;
justify-content: center;
diff --git a/src/components/user_reporting_modal/user_reporting_modal.vue b/src/components/user_reporting_modal/user_reporting_modal.vue
index 6ee53461..2a8d8d48 100644
--- a/src/components/user_reporting_modal/user_reporting_modal.vue
+++ b/src/components/user_reporting_modal/user_reporting_modal.vue
@@ -146,7 +146,8 @@
display: flex;
justify-content: space-between;
- > .status-el {
+ /* TODO cleanup this */
+ > .Status {
flex: 1;
}
diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js
deleted file mode 100644
index 5338c974..00000000
--- a/src/components/user_settings/user_settings.js
+++ /dev/null
@@ -1,393 +0,0 @@
-import unescape from 'lodash/unescape'
-import get from 'lodash/get'
-import map from 'lodash/map'
-import reject from 'lodash/reject'
-import TabSwitcher from '../tab_switcher/tab_switcher.js'
-import ImageCropper from '../image_cropper/image_cropper.vue'
-import StyleSwitcher from '../style_switcher/style_switcher.vue'
-import ScopeSelector from '../scope_selector/scope_selector.vue'
-import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
-import BlockCard from '../block_card/block_card.vue'
-import MuteCard from '../mute_card/mute_card.vue'
-import DomainMuteCard from '../domain_mute_card/domain_mute_card.vue'
-import SelectableList from '../selectable_list/selectable_list.vue'
-import ProgressButton from '../progress_button/progress_button.vue'
-import EmojiInput from '../emoji_input/emoji_input.vue'
-import suggestor from '../emoji_input/suggestor.js'
-import Autosuggest from '../autosuggest/autosuggest.vue'
-import Importer from '../importer/importer.vue'
-import Exporter from '../exporter/exporter.vue'
-import withSubscription from '../../hocs/with_subscription/with_subscription'
-import Checkbox from '../checkbox/checkbox.vue'
-import Mfa from './mfa.vue'
-
-const BlockList = withSubscription({
- fetch: (props, $store) => $store.dispatch('fetchBlocks'),
- select: (props, $store) => get($store.state.users.currentUser, 'blockIds', []),
- childPropName: 'items'
-})(SelectableList)
-
-const MuteList = withSubscription({
- fetch: (props, $store) => $store.dispatch('fetchMutes'),
- select: (props, $store) => get($store.state.users.currentUser, 'muteIds', []),
- childPropName: 'items'
-})(SelectableList)
-
-const DomainMuteList = withSubscription({
- fetch: (props, $store) => $store.dispatch('fetchDomainMutes'),
- select: (props, $store) => get($store.state.users.currentUser, 'domainMutes', []),
- childPropName: 'items'
-})(SelectableList)
-
-const UserSettings = {
- data () {
- return {
- newEmail: '',
- newName: this.$store.state.users.currentUser.name,
- newBio: unescape(this.$store.state.users.currentUser.description),
- newLocked: this.$store.state.users.currentUser.locked,
- newNoRichText: this.$store.state.users.currentUser.no_rich_text,
- newDefaultScope: this.$store.state.users.currentUser.default_scope,
- hideFollows: this.$store.state.users.currentUser.hide_follows,
- hideFollowers: this.$store.state.users.currentUser.hide_followers,
- hideFollowsCount: this.$store.state.users.currentUser.hide_follows_count,
- hideFollowersCount: this.$store.state.users.currentUser.hide_followers_count,
- showRole: this.$store.state.users.currentUser.show_role,
- role: this.$store.state.users.currentUser.role,
- discoverable: this.$store.state.users.currentUser.discoverable,
- allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
- pickAvatarBtnVisible: true,
- bannerUploading: false,
- backgroundUploading: false,
- banner: null,
- bannerPreview: null,
- background: null,
- backgroundPreview: null,
- bannerUploadError: null,
- backgroundUploadError: null,
- changeEmailError: false,
- changeEmailPassword: '',
- changedEmail: false,
- deletingAccount: false,
- deleteAccountConfirmPasswordInput: '',
- deleteAccountError: false,
- changePasswordInputs: [ '', '', '' ],
- changedPassword: false,
- changePasswordError: false,
- activeTab: 'profile',
- notificationSettings: this.$store.state.users.currentUser.notification_settings,
- newDomainToMute: ''
- }
- },
- created () {
- this.$store.dispatch('fetchTokens')
- },
- components: {
- StyleSwitcher,
- ScopeSelector,
- TabSwitcher,
- ImageCropper,
- BlockList,
- MuteList,
- DomainMuteList,
- EmojiInput,
- Autosuggest,
- BlockCard,
- MuteCard,
- DomainMuteCard,
- ProgressButton,
- Importer,
- Exporter,
- Mfa,
- Checkbox
- },
- computed: {
- user () {
- return this.$store.state.users.currentUser
- },
- emojiUserSuggestor () {
- return suggestor({
- emoji: [
- ...this.$store.state.instance.emoji,
- ...this.$store.state.instance.customEmoji
- ],
- users: this.$store.state.users.users,
- updateUsersList: (input) => this.$store.dispatch('searchUsers', input)
- })
- },
- emojiSuggestor () {
- return suggestor({ emoji: [
- ...this.$store.state.instance.emoji,
- ...this.$store.state.instance.customEmoji
- ] })
- },
- pleromaBackend () {
- return this.$store.state.instance.pleromaBackend
- },
- minimalScopesMode () {
- return this.$store.state.instance.minimalScopesMode
- },
- vis () {
- return {
- public: { selected: this.newDefaultScope === 'public' },
- unlisted: { selected: this.newDefaultScope === 'unlisted' },
- private: { selected: this.newDefaultScope === 'private' },
- direct: { selected: this.newDefaultScope === 'direct' }
- }
- },
- currentSaveStateNotice () {
- return this.$store.state.interface.settings.currentSaveStateNotice
- },
- oauthTokens () {
- return this.$store.state.oauthTokens.tokens.map(oauthToken => {
- return {
- id: oauthToken.id,
- appName: oauthToken.app_name,
- validUntil: new Date(oauthToken.valid_until).toLocaleDateString()
- }
- })
- }
- },
- methods: {
- updateProfile () {
- this.$store.state.api.backendInteractor
- .updateProfile({
- params: {
- note: this.newBio,
- locked: this.newLocked,
- // Backend notation.
- /* eslint-disable camelcase */
- display_name: this.newName,
- default_scope: this.newDefaultScope,
- no_rich_text: this.newNoRichText,
- hide_follows: this.hideFollows,
- hide_followers: this.hideFollowers,
- discoverable: this.discoverable,
- allow_following_move: this.allowFollowingMove,
- hide_follows_count: this.hideFollowsCount,
- hide_followers_count: this.hideFollowersCount,
- show_role: this.showRole
- /* eslint-enable camelcase */
- } }).then((user) => {
- this.$store.commit('addNewUsers', [user])
- this.$store.commit('setCurrentUser', user)
- })
- },
- updateNotificationSettings () {
- this.$store.state.api.backendInteractor
- .updateNotificationSettings({ settings: this.notificationSettings })
- },
- changeVis (visibility) {
- this.newDefaultScope = visibility
- },
- uploadFile (slot, e) {
- const file = e.target.files[0]
- if (!file) { return }
- if (file.size > this.$store.state.instance[slot + 'limit']) {
- const filesize = fileSizeFormatService.fileSizeFormat(file.size)
- const allowedsize = fileSizeFormatService.fileSizeFormat(this.$store.state.instance[slot + 'limit'])
- this[slot + 'UploadError'] = this.$t('upload.error.base') + ' ' + this.$t('upload.error.file_too_big', { filesize: filesize.num, filesizeunit: filesize.unit, allowedsize: allowedsize.num, allowedsizeunit: allowedsize.unit })
- return
- }
- // eslint-disable-next-line no-undef
- const reader = new FileReader()
- reader.onload = ({ target }) => {
- const img = target.result
- this[slot + 'Preview'] = img
- this[slot] = file
- }
- reader.readAsDataURL(file)
- },
- submitAvatar (cropper, file) {
- const that = this
- return new Promise((resolve, reject) => {
- function updateAvatar (avatar) {
- that.$store.state.api.backendInteractor.updateAvatar({ avatar })
- .then((user) => {
- that.$store.commit('addNewUsers', [user])
- that.$store.commit('setCurrentUser', user)
- resolve()
- })
- .catch((err) => {
- reject(new Error(that.$t('upload.error.base') + ' ' + err.message))
- })
- }
-
- if (cropper) {
- cropper.getCroppedCanvas().toBlob(updateAvatar, file.type)
- } else {
- updateAvatar(file)
- }
- })
- },
- clearUploadError (slot) {
- this[slot + 'UploadError'] = null
- },
- submitBanner () {
- if (!this.bannerPreview) { return }
-
- this.bannerUploading = true
- this.$store.state.api.backendInteractor.updateBanner({ banner: this.banner })
- .then((user) => {
- this.$store.commit('addNewUsers', [user])
- this.$store.commit('setCurrentUser', user)
- this.bannerPreview = null
- })
- .catch((err) => {
- this.bannerUploadError = this.$t('upload.error.base') + ' ' + err.message
- })
- .then(() => { this.bannerUploading = false })
- },
- submitBg () {
- if (!this.backgroundPreview) { return }
- let background = this.background
- this.backgroundUploading = true
- this.$store.state.api.backendInteractor.updateBg({ background }).then((data) => {
- if (!data.error) {
- this.$store.commit('addNewUsers', [data])
- this.$store.commit('setCurrentUser', data)
- this.backgroundPreview = null
- } else {
- this.backgroundUploadError = this.$t('upload.error.base') + data.error
- }
- this.backgroundUploading = false
- })
- },
- importFollows (file) {
- return this.$store.state.api.backendInteractor.importFollows({ file })
- .then((status) => {
- if (!status) {
- throw new Error('failed')
- }
- })
- },
- importBlocks (file) {
- return this.$store.state.api.backendInteractor.importBlocks({ file })
- .then((status) => {
- if (!status) {
- throw new Error('failed')
- }
- })
- },
- generateExportableUsersContent (users) {
- // Get addresses
- return users.map((user) => {
- // check is it's a local user
- if (user && user.is_local) {
- // append the instance address
- // eslint-disable-next-line no-undef
- return user.screen_name + '@' + location.hostname
- }
- return user.screen_name
- }).join('\n')
- },
- getFollowsContent () {
- return this.$store.state.api.backendInteractor.exportFriends({ id: this.$store.state.users.currentUser.id })
- .then(this.generateExportableUsersContent)
- },
- getBlocksContent () {
- return this.$store.state.api.backendInteractor.fetchBlocks()
- .then(this.generateExportableUsersContent)
- },
- confirmDelete () {
- this.deletingAccount = true
- },
- deleteAccount () {
- this.$store.state.api.backendInteractor.deleteAccount({ password: this.deleteAccountConfirmPasswordInput })
- .then((res) => {
- if (res.status === 'success') {
- this.$store.dispatch('logout')
- this.$router.push({ name: 'root' })
- } else {
- this.deleteAccountError = res.error
- }
- })
- },
- changePassword () {
- const params = {
- password: this.changePasswordInputs[0],
- newPassword: this.changePasswordInputs[1],
- newPasswordConfirmation: this.changePasswordInputs[2]
- }
- this.$store.state.api.backendInteractor.changePassword(params)
- .then((res) => {
- if (res.status === 'success') {
- this.changedPassword = true
- this.changePasswordError = false
- this.logout()
- } else {
- this.changedPassword = false
- this.changePasswordError = res.error
- }
- })
- },
- changeEmail () {
- const params = {
- email: this.newEmail,
- password: this.changeEmailPassword
- }
- this.$store.state.api.backendInteractor.changeEmail(params)
- .then((res) => {
- if (res.status === 'success') {
- this.changedEmail = true
- this.changeEmailError = false
- } else {
- this.changedEmail = false
- this.changeEmailError = res.error
- }
- })
- },
- activateTab (tabName) {
- this.activeTab = tabName
- },
- logout () {
- this.$store.dispatch('logout')
- this.$router.replace('/')
- },
- revokeToken (id) {
- if (window.confirm(`${this.$i18n.t('settings.revoke_token')}?`)) {
- this.$store.dispatch('revokeToken', id)
- }
- },
- filterUnblockedUsers (userIds) {
- return reject(userIds, (userId) => {
- const relationship = this.$store.getters.relationship(this.userId)
- return relationship.blocking || userId === this.$store.state.users.currentUser.id
- })
- },
- filterUnMutedUsers (userIds) {
- return reject(userIds, (userId) => {
- const relationship = this.$store.getters.relationship(this.userId)
- return relationship.muting || userId === this.$store.state.users.currentUser.id
- })
- },
- queryUserIds (query) {
- return this.$store.dispatch('searchUsers', query)
- .then((users) => map(users, 'id'))
- },
- blockUsers (ids) {
- return this.$store.dispatch('blockUsers', ids)
- },
- unblockUsers (ids) {
- return this.$store.dispatch('unblockUsers', ids)
- },
- muteUsers (ids) {
- return this.$store.dispatch('muteUsers', ids)
- },
- unmuteUsers (ids) {
- return this.$store.dispatch('unmuteUsers', ids)
- },
- unmuteDomains (domains) {
- return this.$store.dispatch('unmuteDomains', domains)
- },
- muteDomain () {
- return this.$store.dispatch('muteDomain', this.newDomainToMute)
- .then(() => { this.newDomainToMute = '' })
- },
- identity (value) {
- return value
- }
- }
-}
-
-export default UserSettings
diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue
deleted file mode 100644
index ad184520..00000000
--- a/src/components/user_settings/user_settings.vue
+++ /dev/null
@@ -1,728 +0,0 @@
-<template>
- <div class="settings panel panel-default">
- <div class="panel-heading">
- <div class="title">
- {{ $t('settings.user_settings') }}
- </div>
- <transition name="fade">
- <template v-if="currentSaveStateNotice">
- <div
- v-if="currentSaveStateNotice.error"
- class="alert error"
- @click.prevent
- >
- {{ $t('settings.saving_err') }}
- </div>
-
- <div
- v-if="!currentSaveStateNotice.error"
- class="alert transparent"
- @click.prevent
- >
- {{ $t('settings.saving_ok') }}
- </div>
- </template>
- </transition>
- </div>
- <div class="panel-body profile-edit">
- <tab-switcher>
- <div :label="$t('settings.profile_tab')">
- <div class="setting-item">
- <h2>{{ $t('settings.name_bio') }}</h2>
- <p>{{ $t('settings.name') }}</p>
- <EmojiInput
- v-model="newName"
- enable-emoji-picker
- :suggest="emojiSuggestor"
- >
- <input
- id="username"
- v-model="newName"
- classname="name-changer"
- >
- </EmojiInput>
- <p>{{ $t('settings.bio') }}</p>
- <EmojiInput
- v-model="newBio"
- enable-emoji-picker
- :suggest="emojiUserSuggestor"
- >
- <textarea
- v-model="newBio"
- classname="bio"
- />
- </EmojiInput>
- <p>
- <Checkbox v-model="newLocked">
- {{ $t('settings.lock_account_description') }}
- </Checkbox>
- </p>
- <div>
- <label for="default-vis">{{ $t('settings.default_vis') }}</label>
- <div
- id="default-vis"
- class="visibility-tray"
- >
- <scope-selector
- :show-all="true"
- :user-default="newDefaultScope"
- :initial-scope="newDefaultScope"
- :on-scope-change="changeVis"
- />
- </div>
- </div>
- <p>
- <Checkbox v-model="newNoRichText">
- {{ $t('settings.no_rich_text_description') }}
- </Checkbox>
- </p>
- <p>
- <Checkbox v-model="hideFollows">
- {{ $t('settings.hide_follows_description') }}
- </Checkbox>
- </p>
- <p class="setting-subitem">
- <Checkbox
- v-model="hideFollowsCount"
- :disabled="!hideFollows"
- >
- {{ $t('settings.hide_follows_count_description') }}
- </Checkbox>
- </p>
- <p>
- <Checkbox v-model="hideFollowers">
- {{ $t('settings.hide_followers_description') }}
- </Checkbox>
- </p>
- <p class="setting-subitem">
- <Checkbox
- v-model="hideFollowersCount"
- :disabled="!hideFollowers"
- >
- {{ $t('settings.hide_followers_count_description') }}
- </Checkbox>
- </p>
- <p>
- <Checkbox v-model="allowFollowingMove">
- {{ $t('settings.allow_following_move') }}
- </Checkbox>
- </p>
- <p v-if="role === 'admin' || role === 'moderator'">
- <Checkbox v-model="showRole">
- <template v-if="role === 'admin'">
- {{ $t('settings.show_admin_badge') }}
- </template>
- <template v-if="role === 'moderator'">
- {{ $t('settings.show_moderator_badge') }}
- </template>
- </Checkbox>
- </p>
- <p>
- <Checkbox v-model="discoverable">
- {{ $t('settings.discoverable') }}
- </Checkbox>
- </p>
- <button
- :disabled="newName && newName.length === 0"
- class="btn btn-default"
- @click="updateProfile"
- >
- {{ $t('general.submit') }}
- </button>
- </div>
- <div class="setting-item">
- <h2>{{ $t('settings.avatar') }}</h2>
- <p class="visibility-notice">
- {{ $t('settings.avatar_size_instruction') }}
- </p>
- <p>{{ $t('settings.current_avatar') }}</p>
- <img
- :src="user.profile_image_url_original"
- class="current-avatar"
- >
- <p>{{ $t('settings.set_new_avatar') }}</p>
- <button
- v-show="pickAvatarBtnVisible"
- id="pick-avatar"
- class="btn"
- type="button"
- >
- {{ $t('settings.upload_a_photo') }}
- </button>
- <image-cropper
- trigger="#pick-avatar"
- :submit-handler="submitAvatar"
- @open="pickAvatarBtnVisible=false"
- @close="pickAvatarBtnVisible=true"
- />
- </div>
- <div class="setting-item">
- <h2>{{ $t('settings.profile_banner') }}</h2>
- <p>{{ $t('settings.current_profile_banner') }}</p>
- <img
- :src="user.cover_photo"
- class="banner"
- >
- <p>{{ $t('settings.set_new_profile_banner') }}</p>
- <img
- v-if="bannerPreview"
- class="banner"
- :src="bannerPreview"
- >
- <div>
- <input
- type="file"
- @change="uploadFile('banner', $event)"
- >
- </div>
- <i
- v-if="bannerUploading"
- class=" icon-spin4 animate-spin uploading"
- />
- <button
- v-else-if="bannerPreview"
- class="btn btn-default"
- @click="submitBanner"
- >
- {{ $t('general.submit') }}
- </button>
- <div
- v-if="bannerUploadError"
- class="alert error"
- >
- Error: {{ bannerUploadError }}
- <i
- class="button-icon icon-cancel"
- @click="clearUploadError('banner')"
- />
- </div>
- </div>
- <div class="setting-item">
- <h2>{{ $t('settings.profile_background') }}</h2>
- <p>{{ $t('settings.set_new_profile_background') }}</p>
- <img
- v-if="backgroundPreview"
- class="bg"
- :src="backgroundPreview"
- >
- <div>
- <input
- type="file"
- @change="uploadFile('background', $event)"
- >
- </div>
- <i
- v-if="backgroundUploading"
- class=" icon-spin4 animate-spin uploading"
- />
- <button
- v-else-if="backgroundPreview"
- class="btn btn-default"
- @click="submitBg"
- >
- {{ $t('general.submit') }}
- </button>
- <div
- v-if="backgroundUploadError"
- class="alert error"
- >
- Error: {{ backgroundUploadError }}
- <i
- class="button-icon icon-cancel"
- @click="clearUploadError('background')"
- />
- </div>
- </div>
- </div>
-
- <div :label="$t('settings.security_tab')">
- <div class="setting-item">
- <h2>{{ $t('settings.change_email') }}</h2>
- <div>
- <p>{{ $t('settings.new_email') }}</p>
- <input
- v-model="newEmail"
- type="email"
- autocomplete="email"
- >
- </div>
- <div>
- <p>{{ $t('settings.current_password') }}</p>
- <input
- v-model="changeEmailPassword"
- type="password"
- autocomplete="current-password"
- >
- </div>
- <button
- class="btn btn-default"
- @click="changeEmail"
- >
- {{ $t('general.submit') }}
- </button>
- <p v-if="changedEmail">
- {{ $t('settings.changed_email') }}
- </p>
- <template v-if="changeEmailError !== false">
- <p>{{ $t('settings.change_email_error') }}</p>
- <p>{{ changeEmailError }}</p>
- </template>
- </div>
-
- <div class="setting-item">
- <h2>{{ $t('settings.change_password') }}</h2>
- <div>
- <p>{{ $t('settings.current_password') }}</p>
- <input
- v-model="changePasswordInputs[0]"
- type="password"
- >
- </div>
- <div>
- <p>{{ $t('settings.new_password') }}</p>
- <input
- v-model="changePasswordInputs[1]"
- type="password"
- >
- </div>
- <div>
- <p>{{ $t('settings.confirm_new_password') }}</p>
- <input
- v-model="changePasswordInputs[2]"
- type="password"
- >
- </div>
- <button
- class="btn btn-default"
- @click="changePassword"
- >
- {{ $t('general.submit') }}
- </button>
- <p v-if="changedPassword">
- {{ $t('settings.changed_password') }}
- </p>
- <p v-else-if="changePasswordError !== false">
- {{ $t('settings.change_password_error') }}
- </p>
- <p v-if="changePasswordError">
- {{ changePasswordError }}
- </p>
- </div>
-
- <div class="setting-item">
- <h2>{{ $t('settings.oauth_tokens') }}</h2>
- <table class="oauth-tokens">
- <thead>
- <tr>
- <th>{{ $t('settings.app_name') }}</th>
- <th>{{ $t('settings.valid_until') }}</th>
- <th />
- </tr>
- </thead>
- <tbody>
- <tr
- v-for="oauthToken in oauthTokens"
- :key="oauthToken.id"
- >
- <td>{{ oauthToken.appName }}</td>
- <td>{{ oauthToken.validUntil }}</td>
- <td class="actions">
- <button
- class="btn btn-default"
- @click="revokeToken(oauthToken.id)"
- >
- {{ $t('settings.revoke_token') }}
- </button>
- </td>
- </tr>
- </tbody>
- </table>
- </div>
- <mfa />
- <div class="setting-item">
- <h2>{{ $t('settings.delete_account') }}</h2>
- <p v-if="!deletingAccount">
- {{ $t('settings.delete_account_description') }}
- </p>
- <div v-if="deletingAccount">
- <p>{{ $t('settings.delete_account_instructions') }}</p>
- <p>{{ $t('login.password') }}</p>
- <input
- v-model="deleteAccountConfirmPasswordInput"
- type="password"
- >
- <button
- class="btn btn-default"
- @click="deleteAccount"
- >
- {{ $t('settings.delete_account') }}
- </button>
- </div>
- <p v-if="deleteAccountError !== false">
- {{ $t('settings.delete_account_error') }}
- </p>
- <p v-if="deleteAccountError">
- {{ deleteAccountError }}
- </p>
- <button
- v-if="!deletingAccount"
- class="btn btn-default"
- @click="confirmDelete"
- >
- {{ $t('general.submit') }}
- </button>
- </div>
- </div>
-
- <div
- v-if="pleromaBackend"
- :label="$t('settings.notifications')"
- >
- <div class="setting-item">
- <h2>{{ $t('settings.notification_setting_filters') }}</h2>
- <div class="select-multiple">
- <span class="label">{{ $t('settings.notification_setting') }}</span>
- <ul class="option-list">
- <li>
- <Checkbox v-model="notificationSettings.follows">
- {{ $t('settings.notification_setting_follows') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="notificationSettings.followers">
- {{ $t('settings.notification_setting_followers') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="notificationSettings.non_follows">
- {{ $t('settings.notification_setting_non_follows') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="notificationSettings.non_followers">
- {{ $t('settings.notification_setting_non_followers') }}
- </Checkbox>
- </li>
- </ul>
- </div>
- </div>
-
- <div class="setting-item">
- <h2>{{ $t('settings.notification_setting_privacy') }}</h2>
- <p>
- <Checkbox v-model="notificationSettings.privacy_option">
- {{ $t('settings.notification_setting_privacy_option') }}
- </Checkbox>
- </p>
- </div>
- <div class="setting-item">
- <p>{{ $t('settings.notification_mutes') }}</p>
- <p>{{ $t('settings.notification_blocks') }}</p>
- <button
- class="btn btn-default"
- @click="updateNotificationSettings"
- >
- {{ $t('general.submit') }}
- </button>
- </div>
- </div>
-
- <div
- v-if="pleromaBackend"
- :label="$t('settings.data_import_export_tab')"
- >
- <div class="setting-item">
- <h2>{{ $t('settings.follow_import') }}</h2>
- <p>{{ $t('settings.import_followers_from_a_csv_file') }}</p>
- <Importer
- :submit-handler="importFollows"
- :success-message="$t('settings.follows_imported')"
- :error-message="$t('settings.follow_import_error')"
- />
- </div>
- <div class="setting-item">
- <h2>{{ $t('settings.follow_export') }}</h2>
- <Exporter
- :get-content="getFollowsContent"
- filename="friends.csv"
- :export-button-label="$t('settings.follow_export_button')"
- />
- </div>
- <div class="setting-item">
- <h2>{{ $t('settings.block_import') }}</h2>
- <p>{{ $t('settings.import_blocks_from_a_csv_file') }}</p>
- <Importer
- :submit-handler="importBlocks"
- :success-message="$t('settings.blocks_imported')"
- :error-message="$t('settings.block_import_error')"
- />
- </div>
- <div class="setting-item">
- <h2>{{ $t('settings.block_export') }}</h2>
- <Exporter
- :get-content="getBlocksContent"
- filename="blocks.csv"
- :export-button-label="$t('settings.block_export_button')"
- />
- </div>
- </div>
-
- <div :label="$t('settings.blocks_tab')">
- <div class="profile-edit-usersearch-wrapper">
- <Autosuggest
- :filter="filterUnblockedUsers"
- :query="queryUserIds"
- :placeholder="$t('settings.search_user_to_block')"
- >
- <BlockCard
- slot-scope="row"
- :user-id="row.item"
- />
- </Autosuggest>
- </div>
- <BlockList
- :refresh="true"
- :get-key="identity"
- >
- <template
- slot="header"
- slot-scope="{selected}"
- >
- <div class="profile-edit-bulk-actions">
- <ProgressButton
- v-if="selected.length > 0"
- class="btn btn-default"
- :click="() => blockUsers(selected)"
- >
- {{ $t('user_card.block') }}
- <template slot="progress">
- {{ $t('user_card.block_progress') }}
- </template>
- </ProgressButton>
- <ProgressButton
- v-if="selected.length > 0"
- class="btn btn-default"
- :click="() => unblockUsers(selected)"
- >
- {{ $t('user_card.unblock') }}
- <template slot="progress">
- {{ $t('user_card.unblock_progress') }}
- </template>
- </ProgressButton>
- </div>
- </template>
- <template
- slot="item"
- slot-scope="{item}"
- >
- <BlockCard :user-id="item" />
- </template>
- <template slot="empty">
- {{ $t('settings.no_blocks') }}
- </template>
- </BlockList>
- </div>
-
- <div :label="$t('settings.mutes_tab')">
- <tab-switcher>
- <div label="Users">
- <div class="profile-edit-usersearch-wrapper">
- <Autosuggest
- :filter="filterUnMutedUsers"
- :query="queryUserIds"
- :placeholder="$t('settings.search_user_to_mute')"
- >
- <MuteCard
- slot-scope="row"
- :user-id="row.item"
- />
- </Autosuggest>
- </div>
- <MuteList
- :refresh="true"
- :get-key="identity"
- >
- <template
- slot="header"
- slot-scope="{selected}"
- >
- <div class="profile-edit-bulk-actions">
- <ProgressButton
- v-if="selected.length > 0"
- class="btn btn-default"
- :click="() => muteUsers(selected)"
- >
- {{ $t('user_card.mute') }}
- <template slot="progress">
- {{ $t('user_card.mute_progress') }}
- </template>
- </ProgressButton>
- <ProgressButton
- v-if="selected.length > 0"
- class="btn btn-default"
- :click="() => unmuteUsers(selected)"
- >
- {{ $t('user_card.unmute') }}
- <template slot="progress">
- {{ $t('user_card.unmute_progress') }}
- </template>
- </ProgressButton>
- </div>
- </template>
- <template
- slot="item"
- slot-scope="{item}"
- >
- <MuteCard :user-id="item" />
- </template>
- <template slot="empty">
- {{ $t('settings.no_mutes') }}
- </template>
- </MuteList>
- </div>
-
- <div :label="$t('settings.domain_mutes')">
- <div class="profile-edit-domain-mute-form">
- <input
- v-model="newDomainToMute"
- :placeholder="$t('settings.type_domains_to_mute')"
- type="text"
- @keyup.enter="muteDomain"
- >
- <ProgressButton
- class="btn btn-default"
- :click="muteDomain"
- >
- {{ $t('domain_mute_card.mute') }}
- <template slot="progress">
- {{ $t('domain_mute_card.mute_progress') }}
- </template>
- </ProgressButton>
- </div>
- <DomainMuteList
- :refresh="true"
- :get-key="identity"
- >
- <template
- slot="header"
- slot-scope="{selected}"
- >
- <div class="profile-edit-bulk-actions">
- <ProgressButton
- v-if="selected.length > 0"
- class="btn btn-default"
- :click="() => unmuteDomains(selected)"
- >
- {{ $t('domain_mute_card.unmute') }}
- <template slot="progress">
- {{ $t('domain_mute_card.unmute_progress') }}
- </template>
- </ProgressButton>
- </div>
- </template>
- <template
- slot="item"
- slot-scope="{item}"
- >
- <DomainMuteCard :domain="item" />
- </template>
- <template slot="empty">
- {{ $t('settings.no_mutes') }}
- </template>
- </DomainMuteList>
- </div>
- </tab-switcher>
- </div>
- </tab-switcher>
- </div>
- </div>
-</template>
-
-<script src="./user_settings.js">
-</script>
-
-<style lang="scss">
-@import '../../_variables.scss';
-
-.profile-edit {
- .bio {
- margin: 0;
- }
-
- .visibility-tray {
- padding-top: 5px;
- }
-
- input[type=file] {
- padding: 5px;
- height: auto;
- }
-
- .banner {
- max-width: 100%;
- }
-
- .uploading {
- font-size: 1.5em;
- margin: 0.25em;
- }
-
- .name-changer {
- width: 100%;
- }
-
- .bg {
- max-width: 100%;
- }
-
- .current-avatar {
- display: block;
- width: 150px;
- height: 150px;
- border-radius: $fallback--avatarRadius;
- border-radius: var(--avatarRadius, $fallback--avatarRadius);
- }
-
- .oauth-tokens {
- width: 100%;
-
- th {
- text-align: left;
- }
-
- .actions {
- text-align: right;
- }
- }
-
- &-usersearch-wrapper {
- padding: 1em;
- }
-
- &-bulk-actions {
- text-align: right;
- padding: 0 1em;
- min-height: 28px;
-
- button {
- width: 10em;
- }
- }
-
- &-domain-mute-form {
- padding: 1em;
- display: flex;
- flex-direction: column;
-
- button {
- align-self: flex-end;
- margin-top: 1em;
- width: 10em;
- }
- }
-
- .setting-subitem {
- margin-left: 1.75em;
- }
-}
-</style>
diff --git a/src/components/video_attachment/video_attachment.vue b/src/components/video_attachment/video_attachment.vue
index 97ddf1cd..1ffed4e0 100644
--- a/src/components/video_attachment/video_attachment.vue
+++ b/src/components/video_attachment/video_attachment.vue
@@ -4,6 +4,8 @@
:src="attachment.url"
:loop="loopVideo"
:controls="controls"
+ :alt="attachment.description"
+ :title="attachment.description"
playsinline
@loadeddata="onVideoDataLoad"
/>
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 dcb56106..818e8bd5 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
@@ -7,7 +7,7 @@ function showWhoToFollow (panel, reply) {
panel.usersToFollow.forEach((toFollow, index) => {
let user = shuffled[index]
- let img = user.avatar || '/images/avi.png'
+ let img = user.avatar || this.$store.state.instance.defaultAvatar
let name = user.acct
toFollow.img = img
@@ -38,13 +38,7 @@ function getWhoToFollow (panel) {
const WhoToFollowPanel = {
data: () => ({
- usersToFollow: new Array(3).fill().map(x => (
- {
- img: '/images/avi.png',
- name: '',
- id: 0
- }
- ))
+ usersToFollow: []
}),
computed: {
user: function () {
@@ -68,6 +62,13 @@ const WhoToFollowPanel = {
},
mounted:
function () {
+ this.usersToFollow = new Array(3).fill().map(x => (
+ {
+ img: this.$store.state.instance.defaultAvatar,
+ name: '',
+ id: 0
+ }
+ ))
if (this.suggestionsEnabled) {
getWhoToFollow(this)
}