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.vue7
-rw-r--r--src/components/attachment/attachment.js32
-rw-r--r--src/components/attachment/attachment.vue31
-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.vue80
-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/emoji_input/emoji_input.js60
-rw-r--r--src/components/emoji_input/emoji_input.vue5
-rw-r--r--src/components/extra_buttons/extra_buttons.js10
-rw-r--r--src/components/extra_buttons/extra_buttons.vue16
-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/gallery/gallery.vue4
-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/media_modal/media_modal.vue10
-rw-r--r--src/components/media_upload/media_upload.js3
-rw-r--r--src/components/media_upload/media_upload.vue8
-rw-r--r--src/components/mobile_nav/mobile_nav.js7
-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/nav_panel/nav_panel.js20
-rw-r--r--src/components/nav_panel/nav_panel.vue16
-rw-r--r--src/components/notification/notification.js6
-rw-r--r--src/components/notifications/notifications.js19
-rw-r--r--src/components/notifications/notifications.scss5
-rw-r--r--src/components/post_status_form/post_status_form.js200
-rw-r--r--src/components/post_status_form/post_status_form.vue230
-rw-r--r--src/components/settings_modal/settings_modal.scss2
-rw-r--r--src/components/settings_modal/tabs/filtering_tab.js3
-rw-r--r--src/components/settings_modal/tabs/profile_tab.js88
-rw-r--r--src/components/settings_modal/tabs/profile_tab.scss56
-rw-r--r--src/components/settings_modal/tabs/profile_tab.vue99
-rw-r--r--src/components/settings_modal/tabs/theme_tab/theme_tab.js6
-rw-r--r--src/components/settings_modal/tabs/theme_tab/theme_tab.vue67
-rw-r--r--src/components/side_drawer/side_drawer.js7
-rw-r--r--src/components/side_drawer/side_drawer.vue30
-rw-r--r--src/components/staff_panel/staff_panel.js4
-rw-r--r--src/components/status/status.js33
-rw-r--r--src/components/status/status.vue16
-rw-r--r--src/components/status_content/status_content.js28
-rw-r--r--src/components/status_content/status_content.vue139
-rw-r--r--src/components/status_popover/status_popover.js4
-rw-r--r--src/components/still-image/still-image.js3
-rw-r--r--src/components/still-image/still-image.vue2
-rw-r--r--src/components/timeline/timeline.js14
-rw-r--r--src/components/timeline/timeline.vue6
-rw-r--r--src/components/user_avatar/user_avatar.js16
-rw-r--r--src/components/user_avatar/user_avatar.vue2
-rw-r--r--src/components/user_panel/user_panel.vue4
-rw-r--r--src/components/video_attachment/video_attachment.vue2
-rw-r--r--src/components/who_to_follow_panel/who_to_follow_panel.js17
72 files changed, 2769 insertions, 355 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 029e7096..987e94b7 100644
--- a/src/components/account_actions/account_actions.vue
+++ b/src/components/account_actions/account_actions.vue
@@ -50,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/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..be7377e9 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,7 +57,6 @@
:class="{'hidden': hidden && preloadImage }"
:href="attachment.url"
target="_blank"
- :title="attachment.description"
@click="openModal"
>
<StillImage
@@ -59,6 +64,7 @@
:mimetype="attachment.mimetype"
:src="attachment.large_thumb_url || attachment.url"
:image-load-handler="onImageLoad"
+ :alt="attachment.description"
/>
</a>
@@ -83,6 +89,8 @@
<audio
v-if="type === 'audio'"
:src="attachment.url"
+ :alt="attachment.description"
+ :title="attachment.description"
controls
/>
@@ -116,22 +124,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 {
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..617054ec
--- /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.still-image {
+ border-radius: $fallback--avatarAltRadius;
+ border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+ }
+
+ .status-body {
+ 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..240beea4
--- /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..12968cfb 100644
--- a/src/components/chat_panel/chat_panel.vue
+++ b/src/components/chat_panel/chat_panel.vue
@@ -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..c375b10b
--- /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
+ }
+ }
+
+ .still-image.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/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/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 68db6fd8..7a4e8642 100644
--- a/src/components/extra_buttons/extra_buttons.vue
+++ b/src/components/extra_buttons/extra_buttons.vue
@@ -41,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/gallery/gallery.vue b/src/components/gallery/gallery.vue
index 1ffa7b3c..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%;
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/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 fbb2d03d..7b8a76cc 100644
--- a/src/components/media_upload/media_upload.js
+++ b/src/components/media_upload/media_upload.js
@@ -61,7 +61,8 @@ const mediaUpload = {
}
},
props: [
- 'dropFiles'
+ 'dropFiles',
+ 'disabled'
],
watch: {
'dropFiles': function (fileInfos) {
diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue
index 5e31730b..c8865d77 100644
--- a/src/components/media_upload/media_upload.vue
+++ b/src/components/media_upload/media_upload.vue
@@ -1,5 +1,8 @@
<template>
- <div class="media-upload">
+ <div
+ class="media-upload"
+ :class="{ disabled: disabled }"
+ >
<label
class="label"
:title="$t('tool_tip.media_upload')"
@@ -14,6 +17,7 @@
/>
<input
v-if="uploadReady"
+ :disabled="disabled"
type="file"
style="position: fixed; top: -100em"
multiple="true"
@@ -26,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..4613d090 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 () {
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/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js
index 8f7edb7f..b142ffe0 100644
--- a/src/components/nav_panel/nav_panel.js
+++ b/src/components/nav_panel/nav_panel.js
@@ -1,4 +1,4 @@
-import { mapState } from 'vuex'
+import { mapState, mapGetters } from 'vuex'
const NavPanel = {
created () {
@@ -6,13 +6,17 @@ 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: {
+ ...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,
+ 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..8a213d7e 100644
--- a/src/components/nav_panel/nav_panel.vue
+++ b/src/components/nav_panel/nav_panel.vue
@@ -17,6 +17,22 @@
<i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }}
</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 && 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">
<router-link :to="{ name: 'friend-requests' }">
<i class="button-icon icon-user-plus" /> {{ $t("nav.friend_requests") }}
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index 5aa40e98..bb906b50 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -1,4 +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'
@@ -81,7 +82,10 @@ const Notification = {
},
isStatusNotification () {
return isStatusNotification(this.notification.type)
- }
+ },
+ ...mapState({
+ currentUser: state => state.users.currentUser
+ })
}
}
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 20797cf9..b1a3ad70 100644
--- a/src/components/notifications/notifications.scss
+++ b/src/components/notifications/notifications.scss
@@ -118,6 +118,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/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index 9027566f..1c0accac 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) => {
@@ -31,21 +33,42 @@ const PostStatusForm = {
'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.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 +91,7 @@ const PostStatusForm = {
return {
dropFiles: [],
- submitDisabled: false,
+ uploadingFiles: false,
error: null,
posting: false,
highlighted: 0,
@@ -78,13 +101,17 @@ const PostStatusForm = {
nsfw: false,
files: [],
poll: {},
+ mediaDescriptions: {},
visibility: scope,
contentType
},
caret: 0,
pollFormVisible: false,
showDropIcon: 'hide',
- dropStopTimeout: null
+ dropStopTimeout: null,
+ preview: null,
+ previewLoading: false,
+ emojiInputShown: false
}
},
computed: {
@@ -153,28 +180,52 @@ 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.contentType': function () {
+ this.autoPreview()
+ },
+ 'newStatus.spoilerText': function () {
+ this.autoPreview()
+ }
},
methods: {
- postStatus (newStatus) {
+ 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 : {}
@@ -184,7 +235,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,
@@ -194,7 +254,11 @@ const PostStatusForm = {
inReplyToStatusId: this.replyTo,
contentType: newStatus.contentType,
poll
- }).then((data) => {
+ }
+
+ const postHandler = this.postHandler ? this.postHandler : statusPoster.postStatus
+
+ postHandler(postingOptions).then((data) => {
if (!data.error) {
this.newStatus = {
status: '',
@@ -202,43 +266,105 @@ const PostStatusForm = {
files: [],
visibility: newStatus.visibility,
contentType: newStatus.contentType,
- poll: {}
+ poll: {},
+ mediaDescriptions: {}
}
this.pollFormVisible = false
- this.$refs.mediaUpload.clearFile()
+ this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
this.clearPollForm()
- this.$emit('posted')
+ this.$emit('posted', data)
+ 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()
} 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.$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)
},
- 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
@@ -266,13 +392,14 @@ const PostStatusForm = {
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.autoPreview()
this.$nextTick(() => {
this.resize(this.$refs['textarea'])
})
@@ -284,6 +411,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
}
@@ -336,8 +464,10 @@ const PostStatusForm = {
// BEGIN content size update
target.style.height = 'auto'
- const newHeight = target.scrollHeight - vertPadding
+ const heightWithoutPadding = target.scrollHeight - vertPadding
+ const newHeight = this.maxHeight ? Math.min(heightWithoutPadding, this.maxHeight) : heightWithoutPadding
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
@@ -388,6 +518,18 @@ 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
}
}
}
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index e3d8d087..3dcf1f79 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -5,19 +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 icon-upload"
+ 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"
@@ -69,15 +70,55 @@
<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="icon-down-open"
+ :style="{ transform: showPreview ? 'rotate(0deg)' : 'rotate(-90deg)' }"
+ />
+ </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')"
@@ -89,23 +130,28 @@
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"
:disabled="posting"
class="form-post-body"
- @keydown.meta.enter="postStatus(newStatus)"
- @keydown.ctrl.enter="postStatus(newStatus)"
+ :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"
@@ -118,7 +164,10 @@
{{ charactersLeft }}
</p>
</EmojiInput>
- <div class="visibility-tray">
+ <div
+ v-if="!disableScopeSelector"
+ class="visibility-tray"
+ >
<scope-selector
:show-all="showAllScopes"
:user-default="userDefaultScope"
@@ -176,10 +225,11 @@
ref="mediaUpload"
class="media-upload-icon"
:drop-files="dropFiles"
- @uploading="disableSubmit"
+ :disabled="uploadFileLimitReached"
+ @uploading="startedUploadingFiles"
@uploaded="addMediaFile"
@upload-failed="uploadFailed"
- @all-uploaded="enableSubmit"
+ @all-uploaded="finishedUploadingFiles"
/>
<div
class="emoji-icon"
@@ -216,11 +266,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>
@@ -245,31 +297,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">
@@ -303,14 +346,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;
@@ -336,6 +373,48 @@
max-width: 10em;
}
+ .preview-heading {
+ display: flex;
+ width: 100%;
+
+ .icon-spin3 {
+ margin-left: auto;
+ }
+ }
+
+ .preview-toggle {
+ display: flex;
+ cursor: pointer;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ .icon-down-open {
+ transition: transform 0.1s;
+ }
+
+ .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;
@@ -343,6 +422,12 @@
}
}
+ .visibility-tray {
+ display: flex;
+ justify-content: space-between;
+ padding-top: 5px;
+ }
+
.media-upload-icon, .poll-icon, .emoji-icon {
font-size: 26px;
flex: 1;
@@ -354,6 +439,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
@@ -381,11 +479,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;
@@ -399,6 +495,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 {
@@ -408,28 +518,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 {
@@ -482,6 +577,10 @@
padding-bottom: 1.75em;
min-height: 1px;
box-sizing: content-box;
+
+ &.scrollable-form {
+ overflow-y: auto;
+ }
}
.main-input {
@@ -544,4 +643,11 @@
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/settings_modal/settings_modal.scss b/src/components/settings_modal/settings_modal.scss
index 833ff89a..0da4d9a8 100644
--- a/src/components/settings_modal/settings_modal.scss
+++ b/src/components/settings_modal/settings_modal.scss
@@ -30,7 +30,7 @@
height: 100vh;
}
- .panel-body {
+ >.panel-body {
height: 100%;
overflow-y: hidden;
diff --git a/src/components/settings_modal/tabs/filtering_tab.js b/src/components/settings_modal/tabs/filtering_tab.js
index 224a7f47..3b2df556 100644
--- a/src/components/settings_modal/tabs/filtering_tab.js
+++ b/src/components/settings_modal/tabs/filtering_tab.js
@@ -37,6 +37,9 @@ const FilteringTab = {
})
},
deep: true
+ },
+ replyVisibility () {
+ this.$store.dispatch('queueFlushAll')
}
}
}
diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js
index 56e956cd..bd6bef6a 100644
--- a/src/components/settings_modal/tabs/profile_tab.js
+++ b/src/components/settings_modal/tabs/profile_tab.js
@@ -1,4 +1,5 @@
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'
@@ -16,6 +17,7 @@ const ProfileTab = {
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,
@@ -63,6 +65,45 @@ const ProfileTab = {
...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: {
@@ -75,6 +116,7 @@ const ProfileTab = {
// 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,
@@ -87,6 +129,8 @@ const ProfileTab = {
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)
})
@@ -94,6 +138,16 @@ const ProfileTab = {
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 }
@@ -123,11 +177,29 @@ const ProfileTab = {
}
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.updateAvatar({ avatar })
+ that.$store.state.api.backendInteractor.updateProfileImages({ avatar })
.then((user) => {
that.$store.commit('addNewUsers', [user])
that.$store.commit('setCurrentUser', user)
@@ -145,11 +217,11 @@ const ProfileTab = {
}
})
},
- submitBanner () {
- if (!this.bannerPreview) { return }
+ submitBanner (banner) {
+ if (!this.bannerPreview && banner !== '') { return }
this.bannerUploading = true
- this.$store.state.api.backendInteractor.updateBanner({ banner: this.banner })
+ this.$store.state.api.backendInteractor.updateProfileImages({ banner })
.then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
@@ -160,11 +232,11 @@ const ProfileTab = {
})
.then(() => { this.bannerUploading = false })
},
- submitBg () {
- if (!this.backgroundPreview) { return }
- let background = this.background
+ submitBackground (background) {
+ if (!this.backgroundPreview && background !== '') { return }
+
this.backgroundUploading = true
- this.$store.state.api.backendInteractor.updateBg({ background }).then((data) => {
+ this.$store.state.api.backendInteractor.updateProfileImages({ background }).then((data) => {
if (!data.error) {
this.$store.commit('addNewUsers', [data])
this.$store.commit('setCurrentUser', data)
diff --git a/src/components/settings_modal/tabs/profile_tab.scss b/src/components/settings_modal/tabs/profile_tab.scss
index 4aab81eb..e14cf054 100644
--- a/src/components/settings_modal/tabs/profile_tab.scss
+++ b/src/components/settings_modal/tabs/profile_tab.scss
@@ -13,8 +13,14 @@
height: auto;
}
- .banner {
+ .banner-background-preview {
max-width: 100%;
+ width: 300px;
+ position: relative;
+
+ img {
+ width: 100%;
+ }
}
.uploading {
@@ -26,18 +32,40 @@
width: 100%;
}
- .bg {
- max-width: 100%;
+ .current-avatar-container {
+ position: relative;
+ width: 150px;
+ height: 150px;
}
.current-avatar {
display: block;
- width: 150px;
- height: 150px;
+ 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%;
@@ -79,4 +107,22 @@
.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
index decdb389..cf88c4e4 100644
--- a/src/components/settings_modal/tabs/profile_tab.vue
+++ b/src/components/settings_modal/tabs/profile_tab.vue
@@ -95,6 +95,54 @@
{{ $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') }}
@@ -113,11 +161,19 @@
<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"
- >
+ <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"
@@ -136,15 +192,20 @@
</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"
- >
+ <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"
+ class="banner-background-preview"
:src="bannerPreview"
>
<div>
@@ -160,7 +221,7 @@
<button
v-else-if="bannerPreview"
class="btn btn-default"
- @click="submitBanner"
+ @click="submitBanner(banner)"
>
{{ $t('general.submit') }}
</button>
@@ -177,10 +238,20 @@
</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="bg"
+ class="banner-background-preview"
:src="backgroundPreview"
>
<div>
@@ -196,7 +267,7 @@
<button
v-else-if="backgroundPreview"
class="btn btn-default"
- @click="submitBg"
+ @click="submitBackground(background)"
>
{{ $t('general.submit') }}
</button>
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
index 9d61b0c4..e3c5e80a 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
@@ -99,7 +99,8 @@ export default {
avatarRadiusLocal: '',
avatarAltRadiusLocal: '',
attachmentRadiusLocal: '',
- tooltipRadiusLocal: ''
+ tooltipRadiusLocal: '',
+ chatMessageRadiusLocal: ''
}
},
created () {
@@ -214,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/settings_modal/tabs/theme_tab/theme_tab.vue b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
index d14f854c..d57894de 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
@@ -735,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
@@ -814,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
diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js
index d1f044f6..3a9e9e8f 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,11 @@ const SideDrawer = {
},
federating () {
return this.$store.state.instance.federating
- }
+ },
+ ...mapState({
+ pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
+ }),
+ ...mapGetters(['unreadChatCount'])
},
methods: {
toggleDrawer () {
diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue
index f253742d..4fdb3d13 100644
--- a/src/components/side_drawer/side_drawer.vue
+++ b/src/components/side_drawer/side_drawer.vue
@@ -40,12 +40,24 @@
</router-link>
</li>
<li
- v-if="currentUser"
+ v-if="currentUser && pleromaChatMessagesAvailable"
@click="toggleDrawer"
>
<router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
<i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }}
</router-link>
+ <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>
<li
v-if="currentUser"
@@ -66,6 +78,14 @@
</router-link>
</li>
<li
+ v-if="currentUser"
+ @click="toggleDrawer"
+ >
+ <router-link :to="{ name: 'bookmarks'}">
+ <i class="button-icon icon-bookmark" /> {{ $t("nav.bookmarks") }}
+ </router-link>
+ </li>
+ <li
v-if="currentUser && currentUser.locked"
@click="toggleDrawer"
>
@@ -95,14 +115,6 @@
<i class="button-icon icon-globe" /> {{ $t("nav.twkn") }}
</router-link>
</li>
- <li
- v-if="currentUser && chat"
- @click="toggleDrawer"
- >
- <router-link :to="{ name: 'chat' }">
- <i class="button-icon icon-chat" /> {{ $t("nav.chat") }}
- </router-link>
- </li>
</ul>
<ul>
<li
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 73382521..ad0b72a9 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -141,7 +141,7 @@ const Status = {
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
@@ -164,37 +164,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)
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 7ec29b28..f6b5dd6f 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -197,7 +197,7 @@
>
<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"
>
@@ -208,7 +208,12 @@
@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>
+ <span
+ class="faint-link reply-to-text"
+ :class="{ 'strikethrough': !status.parent_visible }"
+ >
+ {{ $t('status.reply_to') }}
+ </span>
</a>
</StatusPopover>
<span
@@ -372,9 +377,6 @@ $status-margin: 0.75em;
}
.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;
@@ -526,6 +528,10 @@ $status-margin: 0.75em;
margin: 0 0.4em 0 0.2em;
}
+ .strikethrough {
+ text-decoration: line-through;
+ }
+
.replies-separator {
margin-left: 0.4em;
}
diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js
index c0a71e8f..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,
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index efc2485e..bf8d376e 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -3,45 +3,32 @@
<div class="status-body">
<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">
@@ -129,6 +137,12 @@ $status-margin: 0.75em;
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 */
@@ -176,10 +190,45 @@ $status-margin: 0.75em;
}
}
+ .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;
+ overflow-wrap: break-word;
+ word-wrap: break-word;
+ word-break: break-word;
blockquote {
margin: 0.2em 0 0.2em 2em;
@@ -221,6 +270,12 @@ $status-margin: 0.75em;
h4 {
margin: 1.1em 0;
}
+
+ &.single-line {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
}
}
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/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 f2ddeb7b..2ebf33ba 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"
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index 9a53acd6..bac73022 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -45,11 +45,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 () {
@@ -112,8 +116,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 +137,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
diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue
index 9777bd0c..111c0976 100644
--- a/src/components/timeline/timeline.vue
+++ b/src/components/timeline/timeline.vue
@@ -19,14 +19,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
>
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..3545b801 100644
--- a/src/components/user_avatar/user_avatar.vue
+++ b/src/components/user_avatar/user_avatar.vue
@@ -3,7 +3,7 @@
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"
/>
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/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)
}