Date: Sun, 28 Jun 2020 12:16:41 +0300
Subject: [PATCH 21/94] add rich text preview
---
.../post_status_form/post_status_form.js | 52 +++++++-
.../post_status_form/post_status_form.vue | 113 ++++++++++++++++--
src/i18n/en.json | 5 +-
src/services/api/api.service.js | 14 +--
.../status_poster/status_poster.service.js | 19 ++-
5 files changed, 180 insertions(+), 23 deletions(-)
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index 9027566f..3f7e36a6 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -3,6 +3,7 @@ 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 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'
@@ -38,7 +39,8 @@ const PostStatusForm = {
EmojiInput,
PollForm,
ScopeSelector,
- Checkbox
+ Checkbox,
+ StatusContent
},
mounted () {
this.resize(this.$refs.textarea)
@@ -84,7 +86,9 @@ const PostStatusForm = {
caret: 0,
pollFormVisible: false,
showDropIcon: 'hide',
- dropStopTimeout: null
+ dropStopTimeout: null,
+ preview: null,
+ previewLoading: false
}
},
computed: {
@@ -163,8 +167,20 @@ const PostStatusForm = {
this.newStatus.poll &&
this.newStatus.poll.error
},
+ showPreview () {
+ return !!this.preview || this.previewLoading
+ },
...mapGetters(['mergedConfig'])
},
+ watch: {
+ 'newStatus.contentType': function (newType) {
+ if (newType === 'text/plain') {
+ this.closePreview()
+ } else if (this.preview) {
+ this.previewStatus(this.newStatus)
+ }
+ }
+ },
methods: {
postStatus (newStatus) {
if (this.posting) { return }
@@ -218,6 +234,38 @@ const PostStatusForm = {
this.posting = false
})
},
+ previewStatus (newStatus) {
+ this.previewLoading = true
+ statusPoster.postStatus({
+ status: newStatus.status,
+ spoilerText: newStatus.spoilerText || null,
+ visibility: newStatus.visibility,
+ sensitive: newStatus.nsfw,
+ media: newStatus.files,
+ 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
+ })
+ },
+ closePreview () {
+ this.preview = null
+ this.previewLoading = false
+ },
addMediaFile (fileInfo) {
this.newStatus.files.push(fileInfo)
},
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index e3d8d087..9931f5ca 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -16,6 +16,58 @@
@drop.stop="fileDrop"
/>
{
+ 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.updateScrollableContainerHeight()
+ })
+ },
+ handleVisibilityChange () {
+ this.$nextTick(() => {
+ if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) {
+ this.scrollDown({ forceRead: true })
+ }
+ })
+ },
+ handleLayoutChange () {
+ this.updateScrollableContainerHeight()
+ if (this.mobileLayout) {
+ this.setMobileChatLayout()
+ } else {
+ this.unsetMobileChatLayout()
+ }
+ 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) {
+ this.$nextTick(() => {
+ this.updateScrollableContainerHeight()
+
+ const { offsetHeight = undefined } = this.scrollPositionBeforeResize
+ this.scrollPositionBeforeResize = getScrollPosition(this.$refs.scrollable)
+
+ const diff = this.scrollPositionBeforeResize.offsetHeight - offsetHeight
+ if (diff < 0 || (!this.bottomedOut() && opts && opts.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.updateScrollableContainerHeight()
+ 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..13c52ea3
--- /dev/null
+++ b/src/components/chat/chat.scss
@@ -0,0 +1,161 @@
+.chat-view {
+ display: flex;
+ height: calc(100vh - 60px);
+ width: 100%;
+
+ .chat-view-inner {
+ height: auto;
+ width: 100%;
+ overflow: visible;
+ display: flex;
+ margin-top: 0.5em;
+ margin-left: 0.5em;
+ margin-right: 0.5em;
+ }
+
+ .chat-view-body {
+ background-color: var(--chatBg, $fallback--bg);
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ overflow: visible;
+ border-radius: none;
+ min-height: 100%;
+ margin-left: 0;
+ margin-right: 0;
+ margin-bottom: 0em;
+ margin-top: 0em;
+ border-radius: 10px 10px 0 0;
+ border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0 ;
+
+ &::after {
+ border-radius: none;
+ box-shadow: none;
+ }
+ }
+
+ .scrollable-message-list {
+ padding: 0 10px;
+ height: 100%;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .footer {
+ position: sticky;
+ bottom: 0px;
+ }
+
+ .chat-view-heading {
+ align-items: center;
+ justify-content: space-between;
+ top: 50px;
+ display: flex;
+ z-index: 2;
+ border-radius: none;
+ position: sticky;
+ display: flex;
+ overflow: hidden;
+ }
+
+ .go-back-button {
+ margin-right: 1.2em;
+ cursor: pointer;
+ }
+
+ .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 !important;
+ }
+
+ .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..d8c91dbe
--- /dev/null
+++ b/src/components/chat/chat.vue
@@ -0,0 +1,99 @@
+
+
+
+
+
+
diff --git a/src/components/chat/chat_layout.js b/src/components/chat/chat_layout.js
new file mode 100644
index 00000000..07ae3abf
--- /dev/null
+++ b/src/components/chat/chat_layout.js
@@ -0,0 +1,100 @@
+const ChatLayout = {
+ methods: {
+ setChatLayout () {
+ if (this.mobileLayout) {
+ this.setMobileChatLayout()
+ }
+ },
+ unsetChatLayout () {
+ this.unsetMobileChatLayout()
+ },
+ setMobileChatLayout () {
+ // 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.style.overflow = 'hidden'
+ html.style.height = '100%'
+ }
+
+ let body = document.querySelector('body')
+ if (body) {
+ body.style.height = '100%'
+ }
+
+ let app = document.getElementById('app')
+ if (app) {
+ app.style.height = '100%'
+ app.style.overflow = 'hidden'
+ app.style.minHeight = 'auto'
+ }
+
+ let appBgWrapper = window.document.getElementById('app_bg_wrapper')
+ if (appBgWrapper) {
+ appBgWrapper.style.overflow = 'hidden'
+ }
+
+ let main = document.getElementsByClassName('main')[0]
+ if (main) {
+ main.style.overflow = 'hidden'
+ main.style.height = '100%'
+ }
+
+ let content = document.getElementById('content')
+ if (content) {
+ content.style.paddingTop = '0'
+ content.style.height = '100%'
+ content.style.overflow = 'visible'
+ }
+
+ this.$nextTick(() => {
+ this.updateScrollableContainerHeight()
+ })
+ },
+ unsetMobileChatLayout () {
+ let html = document.querySelector('html')
+ if (html) {
+ html.style.overflow = 'visible'
+ html.style.height = 'unset'
+ }
+
+ let body = document.querySelector('body')
+ if (body) {
+ body.style.height = 'unset'
+ }
+
+ let app = document.getElementById('app')
+ if (app) {
+ app.style.height = '100%'
+ app.style.overflow = 'visible'
+ app.style.minHeight = '100vh'
+ }
+
+ let appBgWrapper = document.getElementById('app_bg_wrapper')
+ if (appBgWrapper) {
+ appBgWrapper.style.overflow = 'visible'
+ }
+
+ let main = document.getElementsByClassName('main')[0]
+ if (main) {
+ main.style.overflow = 'visible'
+ main.style.height = 'unset'
+ }
+
+ let content = document.getElementById('content')
+ if (content) {
+ content.style.paddingTop = '60px'
+ content.style.height = 'unset'
+ content.style.overflow = 'unset'
+ }
+ }
+ }
+}
+
+export default ChatLayout
diff --git a/src/components/chat/chat_layout_utils.js b/src/components/chat/chat_layout_utils.js
new file mode 100644
index 00000000..f07ba2a1
--- /dev/null
+++ b/src/components/chat/chat_layout_utils.js
@@ -0,0 +1,27 @@
+// 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) => {
+ const height = parseFloat(getComputedStyle(inner, null).height.replace('px', ''))
+ return height - header.clientHeight - footer.clientHeight
+}
diff --git a/src/components/chat_avatar/chat_avatar.js b/src/components/chat_avatar/chat_avatar.js
new file mode 100644
index 00000000..7b26e07c
--- /dev/null
+++ b/src/components/chat_avatar/chat_avatar.js
@@ -0,0 +1,23 @@
+import StillImage from '../still-image/still-image.vue'
+import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import { mapState } from 'vuex'
+
+const ChatAvatar = {
+ props: ['user', 'width', 'height'],
+ components: {
+ StillImage
+ },
+ methods: {
+ getUserProfileLink (user) {
+ if (!user) { return }
+ return generateProfileLink(user.id, user.screen_name)
+ }
+ },
+ computed: {
+ ...mapState({
+ betterShadow: state => state.interface.browserSupport.cssFilter
+ })
+ }
+}
+
+export default ChatAvatar
diff --git a/src/components/chat_avatar/chat_avatar.vue b/src/components/chat_avatar/chat_avatar.vue
new file mode 100644
index 00000000..f54a7151
--- /dev/null
+++ b/src/components/chat_avatar/chat_avatar.vue
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
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..e62f58e5
--- /dev/null
+++ b/src/components/chat_list/chat_list.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+ {{ $t("chats.chats") }}
+
+
+
+
+
+
+
+
+
+
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..1c27088c
--- /dev/null
+++ b/src/components/chat_list_item/chat_list_item.js
@@ -0,0 +1,65 @@
+import { mapState } from 'vuex'
+import StatusContent from '../status_content/status_content.vue'
+import fileType from 'src/services/file_type/file_type.service'
+import ChatAvatar from '../chat_avatar/chat_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: {
+ ChatAvatar,
+ 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 content = this.chat.lastMessage ? (this.attachmentInfo || this.chat.lastMessage.content) : ''
+
+ return {
+ summary: '',
+ statusnet_html: content,
+ text: content,
+ 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..12269f89
--- /dev/null
+++ b/src/components/chat_list_item/chat_list_item.scss
@@ -0,0 +1,94 @@
+.chat-list-item {
+ &:hover .animated.avatar {
+ canvas {
+ display: none;
+ }
+ img {
+ visibility: visible;
+ }
+ }
+
+ display: flex;
+ flex-direction: row;
+ padding: 0.75em;
+ height: 4.85em;
+ overflow: hidden;
+ box-sizing: border-box;
+ cursor: pointer;
+
+ :focus {
+ outline: none;
+ }
+
+ &:hover {
+ background-color: var(--selectedPost, $fallback--lightBg);
+ box-shadow: 0 0px 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;
+ }
+
+ .member-count {
+ color: $fallback--text;
+ color: var(--faintText, $fallback--text);
+ margin-right: 2px;
+ }
+
+ .name-and-account-name {
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ flex-shrink: 1;
+ }
+
+ .chat-preview {
+ display: inline-flex;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ margin: 0.35rem 0;
+ height: 1.2em;
+ line-height: 1.2em;
+ color: $fallback--text;
+ color: var(--faint, $fallback--text);
+ }
+
+ a {
+ color: var(--faintLink, $fallback--link);
+ text-decoration: none;
+ pointer-events: none;
+ }
+
+ .unread-indicator-wrapper {
+ display: flex;
+ align-items: center;
+ margin-left: 10px;
+ }
+
+ .unread-indicator {
+ border-radius: 100%;
+ height: 8px;
+ width: 8px;
+ background-color: $fallback--link;
+ background-color: var(--link, $fallback--link);
+ }
+}
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..26ad581b
--- /dev/null
+++ b/src/components/chat_list_item/chat_list_item.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ chat.unread }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
new file mode 100644
index 00000000..aba95074
--- /dev/null
+++ b/src/components/chat_message/chat_message.js
@@ -0,0 +1,109 @@
+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
+ }),
+ ellipsisButtonWrapperStyle () {
+ let res = {
+ 'opacity': this.hovered || this.menuOpened ? '1' : '0'
+ }
+
+ if (this.isCurrentUser) {
+ res.right = '5px'
+ } else {
+ res.left = '5px'
+ }
+
+ return res
+ },
+ 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..e4028537
--- /dev/null
+++ b/src/components/chat_message/chat_message.scss
@@ -0,0 +1,157 @@
+@import '../../_variables.scss';
+
+.chat-message-wrapper {
+ &.hovered-message-chain {
+ .animated.avatar {
+ canvas {
+ display: none;
+ }
+ img {
+ visibility: visible;
+ }
+ }
+ }
+
+ &:last-child {
+ margin-bottom: 16px;
+ }
+
+ .chat-message-menu {
+ transition: opacity 0.1s;
+ opacity: 0;
+ position: absolute;
+ top: -10px;
+
+ button {
+ padding-top: 3px;
+ padding-bottom: 3px;
+ }
+ }
+
+ .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: 12rem;
+ }
+
+ .chat-message {
+ display: flex;
+ padding-bottom: 7px;
+ }
+
+ .avatar-wrapper {
+ margin-right: 10px;
+ width: 32px;
+ }
+
+ .link-preview, .attachments {
+ margin-bottom: 0.9em;
+ }
+
+ .chat-message-inner {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ max-width: 80%;
+ min-width: 10rem;
+ 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 {
+ float: right;
+ font-size: 0.8em;
+ margin: -10px 0 -5px 4px;
+ font-style: italic;
+ opacity: 0.8;
+ }
+
+ .without-attachment {
+ .status-content {
+ white-space: normal;
+
+ &::after {
+ margin-right: 75px;
+ 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);
+ }
+ }
+ }
+
+ .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-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..872ddf70
--- /dev/null
+++ b/src/components/chat_message/chat_message.vue
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ createdAt }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
diff --git a/src/components/chat_new/chat_new.js b/src/components/chat_new/chat_new.js
new file mode 100644
index 00000000..0da681f7
--- /dev/null
+++ b/src/components/chat_new/chat_new.js
@@ -0,0 +1,74 @@
+import { throttle } from 'lodash'
+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: throttle(function (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..39216677
--- /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.67rem;
+ }
+
+ .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 @@
+
+
+
+
+
+
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-window {
- overflow-y: auto;
- overflow-x: hidden;
- max-height: 20em;
-}
-
-.chat-window-container {
- height: 100%;
-}
-
-.chat-message {
- display: flex;
- padding: 0.2em 0.5em
-}
-
-.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-input {
- display: flex;
- textarea {
- flex: 1;
- margin: 0.6em;
- min-height: 3.5em;
- resize: none;
- }
-}
-
.chat-panel {
- .title {
+ .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-message {
display: flex;
- justify-content: space-between;
+ padding: 0.2em 0.5em
+ }
+
+ .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-input {
+ display: flex;
+ textarea {
+ flex: 1;
+ margin: 0.6em;
+ min-height: 3.5em;
+ resize: none;
+ }
+ }
+
+ .chat-panel {
+ .title {
+ display: flex;
+ justify-content: space-between;
+ }
}
}
diff --git a/src/components/chat_title/chat_title.js b/src/components/chat_title/chat_title.js
new file mode 100644
index 00000000..2723d5f5
--- /dev/null
+++ b/src/components/chat_title/chat_title.js
@@ -0,0 +1,20 @@
+import Vue from 'vue'
+import ChatAvatar from '../chat_avatar/chat_avatar.vue'
+
+export default Vue.component('chat-title', {
+ name: 'ChatTitle',
+ components: {
+ ChatAvatar
+ },
+ props: [
+ 'user', 'withAvatar'
+ ],
+ computed: {
+ title () {
+ return this.user ? this.user.screen_name : ''
+ },
+ htmlTitle () {
+ return this.user ? this.user.name_html : ''
+ }
+ }
+})
diff --git a/src/components/chat_title/chat_title.vue b/src/components/chat_title/chat_title.vue
new file mode 100644
index 00000000..fd42d125
--- /dev/null
+++ b/src/components/chat_title/chat_title.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js
index 7974a66d..a27da090 100644
--- a/src/components/emoji_input/emoji_input.js
+++ b/src/components/emoji_input/emoji_input.js
@@ -79,6 +79,15 @@ 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'
}
},
data () {
@@ -162,6 +171,11 @@ const EmojiInput = {
input.elm.removeEventListener('input', this.onInput)
}
},
+ watch: {
+ showSuggestions: function (newValue) {
+ this.$emit('shown', newValue)
+ }
+ },
methods: {
triggerShowPicker () {
this.showPicker = true
@@ -425,15 +439,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'
- if (!picker) return
- 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 }"
>
-
+
{{ $t('features_panel.chat') }}
+
+ {{ $t('features_panel.pleroma_chat_messages') }}
+
{{ $t('features_panel.gopher') }}
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..d719eae1 100644
--- a/src/components/media_upload/media_upload.vue
+++ b/src/components/media_upload/media_upload.vue
@@ -1,5 +1,8 @@
-
+
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 0ac53b34..4fdb3d13 100644
--- a/src/components/side_drawer/side_drawer.vue
+++ b/src/components/side_drawer/side_drawer.vue
@@ -40,12 +40,24 @@
{{ $t("nav.dms") }}
+
+ {{ $t("nav.chats") }}
+
+ {{ unreadChatCount }}
+
+
{{ $t("nav.twkn") }}
-
-
- {{ $t("nav.chat") }}
-
-
{
try {
- const { state, dispatch, rootState } = store
+ const { state, commit, dispatch, rootState } = store
const timelineData = rootState.statuses.timelines.friends
state.mastoUserSocket = state.backendInteractor.startUserSocket({ store })
state.mastoUserSocket.addEventListener(
@@ -66,11 +71,22 @@ const api = {
showImmediately: timelineData.visibleStatuses.length === 0,
timeline: 'friends'
})
+ } else if (message.event === 'pleroma:chat_update') {
+ dispatch('addChatMessages', {
+ chatId: message.chatUpdate.id,
+ messages: [message.chatUpdate.lastMessage]
+ })
+ dispatch('updateChat', { chat: message.chatUpdate })
}
}
)
+ state.mastoUserSocket.addEventListener('open', () => {
+ commit('setMastoUserSocketStatus', WSConnectionStatus.JOINED)
+ })
state.mastoUserSocket.addEventListener('error', ({ detail: error }) => {
console.error('Error in MastoAPI websocket:', error)
+ commit('setMastoUserSocketStatus', WSConnectionStatus.ERROR)
+ dispatch('clearOpenedChats')
})
state.mastoUserSocket.addEventListener('close', ({ detail: closeEvent }) => {
const ignoreCodes = new Set([
@@ -84,8 +100,11 @@ const api = {
console.warn(`MastoAPI websocket disconnected, restarting. CloseEvent code: ${code}`)
dispatch('startFetchingTimeline', { timeline: 'friends' })
dispatch('startFetchingNotifications')
+ dispatch('startFetchingChats')
dispatch('restartMastoUserSocket')
}
+ commit('setMastoUserSocketStatus', WSConnectionStatus.CLOSED)
+ dispatch('clearOpenedChats')
})
resolve()
} catch (e) {
@@ -99,12 +118,13 @@ const api = {
return dispatch('startMastoUserSocket').then(() => {
dispatch('stopFetchingTimeline', { timeline: 'friends' })
dispatch('stopFetchingNotifications')
+ dispatch('stopFetchingChats')
})
},
stopMastoUserSocket ({ state, dispatch }) {
dispatch('startFetchingTimeline', { timeline: 'friends' })
dispatch('startFetchingNotifications')
- console.log(state.mastoUserSocket)
+ dispatch('startFetchingChats')
state.mastoUserSocket.close()
},
diff --git a/src/modules/chats.js b/src/modules/chats.js
new file mode 100644
index 00000000..f868ca0c
--- /dev/null
+++ b/src/modules/chats.js
@@ -0,0 +1,228 @@
+import Vue from 'vue'
+import { find, omitBy, orderBy, sumBy } from 'lodash'
+import chatService from '../services/chat_service/chat_service.js'
+import { parseChat, parseChatMessage } from '../services/entity_normalizer/entity_normalizer.service.js'
+
+const emptyChatList = () => ({
+ data: [],
+ idStore: {}
+})
+
+const defaultState = {
+ chatList: emptyChatList(),
+ chatListFetcher: null,
+ openedChats: {},
+ openedChatMessageServices: {},
+ fetcher: undefined,
+ currentChatId: null
+}
+
+const getChatById = (state, id) => {
+ return find(state.chatList.data, { id })
+}
+
+const sortedChatList = (state) => {
+ return orderBy(state.chatList.data, ['updated_at'], ['desc'])
+}
+
+const unreadChatCount = (state) => {
+ return sumBy(state.chatList.data, 'unread')
+}
+
+const chats = {
+ state: { ...defaultState },
+ getters: {
+ currentChat: state => state.openedChats[state.currentChatId],
+ currentChatMessageService: state => state.openedChatMessageServices[state.currentChatId],
+ findOpenedChatByRecipientId: state => recipientId => find(state.openedChats, c => c.account.id === recipientId),
+ sortedChatList,
+ unreadChatCount
+ },
+ actions: {
+ // Chat list
+ startFetchingChats ({ dispatch, commit }) {
+ const fetcher = () => {
+ dispatch('fetchChats', { latest: true })
+ }
+ fetcher()
+ commit('setChatListFetcher', {
+ fetcher: () => setInterval(() => { fetcher() }, 5000)
+ })
+ },
+ stopFetchingChats ({ commit }) {
+ commit('setChatListFetcher', { fetcher: undefined })
+ },
+ fetchChats ({ dispatch, rootState, commit }, params = {}) {
+ return rootState.api.backendInteractor.chats()
+ .then(({ chats }) => {
+ dispatch('addNewChats', { chats })
+ return chats
+ })
+ },
+ addNewChats ({ rootState, commit, dispatch, rootGetters }, { chats }) {
+ commit('addNewChats', { dispatch, chats, rootGetters })
+ },
+ updateChat ({ commit }, { chat }) {
+ commit('updateChat', { chat })
+ },
+
+ // Opened Chats
+ startFetchingCurrentChat ({ commit, dispatch }, { fetcher }) {
+ dispatch('setCurrentChatFetcher', { fetcher })
+ },
+ setCurrentChatFetcher ({ rootState, commit }, { fetcher }) {
+ commit('setCurrentChatFetcher', { fetcher })
+ },
+ addOpenedChat ({ rootState, commit, dispatch }, { chat }) {
+ commit('addOpenedChat', { dispatch, chat: parseChat(chat) })
+ dispatch('addNewUsers', [chat.account])
+ },
+ addChatMessages ({ commit }, value) {
+ commit('addChatMessages', { commit, ...value })
+ },
+ resetChatNewMessageCount ({ commit }, value) {
+ commit('resetChatNewMessageCount', value)
+ },
+ removeFromCurrentChatStatuses ({ commit }, { id }) {
+ commit('removeFromCurrentChatStatuses', id)
+ },
+ clearCurrentChat ({ rootState, commit, dispatch }, value) {
+ commit('setCurrentChatId', { chatId: undefined })
+ commit('setCurrentChatFetcher', { fetcher: undefined })
+ },
+ readChat ({ rootState, commit, dispatch }, { id, lastReadId }) {
+ dispatch('resetChatNewMessageCount')
+ commit('readChat', { id })
+ rootState.api.backendInteractor.readChat({ id, lastReadId })
+ },
+ deleteChatMessage ({ rootState, commit }, value) {
+ rootState.api.backendInteractor.deleteChatMessage(value)
+ commit('deleteChatMessage', { commit, ...value })
+ },
+ resetChats ({ commit, dispatch }) {
+ dispatch('clearCurrentChat')
+ commit('resetChats', { commit })
+ },
+ clearOpenedChats ({ rootState, commit, dispatch, rootGetters }) {
+ commit('clearOpenedChats', { commit })
+ }
+ },
+ mutations: {
+ setChatListFetcher (state, { commit, fetcher }) {
+ const prevFetcher = state.chatListFetcher
+ if (prevFetcher) {
+ clearInterval(prevFetcher)
+ }
+ state.chatListFetcher = fetcher && fetcher()
+ },
+ setCurrentChatFetcher (state, { fetcher }) {
+ const prevFetcher = state.fetcher
+ if (prevFetcher) {
+ clearInterval(prevFetcher)
+ }
+ state.fetcher = fetcher && fetcher()
+ },
+ addOpenedChat (state, { _dispatch, chat }) {
+ state.currentChatId = chat.id
+ Vue.set(state.openedChats, chat.id, chat)
+
+ if (!state.openedChatMessageServices[chat.id]) {
+ Vue.set(state.openedChatMessageServices, chat.id, chatService.empty(chat.id))
+ }
+ },
+ setCurrentChatId (state, { chatId }) {
+ state.currentChatId = chatId
+ },
+ addNewChats (state, { _dispatch, chats, _rootGetters }) {
+ chats.forEach((updatedChat) => {
+ const chat = getChatById(state, updatedChat.id)
+
+ if (chat) {
+ chat.lastMessage = updatedChat.lastMessage
+ chat.unread = updatedChat.unread
+ } else {
+ state.chatList.data.push(updatedChat)
+ Vue.set(state.chatList.idStore, updatedChat.id, updatedChat)
+ }
+ })
+ },
+ updateChat (state, { _dispatch, chat: updatedChat, _rootGetters }) {
+ const chat = getChatById(state, updatedChat.id)
+ if (chat) {
+ chat.lastMessage = updatedChat.lastMessage
+ chat.unread = updatedChat.unread
+ chat.updated_at = updatedChat.updated_at
+ }
+ if (!chat) { state.chatList.data.unshift(updatedChat) }
+ Vue.set(state.chatList.idStore, updatedChat.id, updatedChat)
+ },
+ deleteChat (state, { _dispatch, id, _rootGetters }) {
+ state.chats.data = state.chats.data.filter(conversation =>
+ conversation.last_status.id !== id
+ )
+ state.chats.idStore = omitBy(state.chats.idStore, conversation => conversation.last_status.id === id)
+ },
+ resetChats (state, { commit }) {
+ state.chatList = emptyChatList()
+ state.currentChatId = null
+ commit('setChatListFetcher', { fetcher: undefined })
+ for (const chatId in state.openedChats) {
+ chatService.clear(state.openedChatMessageServices[chatId])
+ Vue.delete(state.openedChats, chatId)
+ Vue.delete(state.openedChatMessageServices, chatId)
+ }
+ },
+ setChatsLoading (state, { value }) {
+ state.chats.loading = value
+ },
+ addChatMessages (state, { commit, chatId, messages }) {
+ const chatMessageService = state.openedChatMessageServices[chatId]
+ if (chatMessageService) {
+ chatService.add(chatMessageService, { messages: messages.map(parseChatMessage) })
+ commit('refreshLastMessage', { chatId })
+ }
+ },
+ refreshLastMessage (state, { chatId }) {
+ const chatMessageService = state.openedChatMessageServices[chatId]
+ if (chatMessageService) {
+ const chat = getChatById(state, chatId)
+ if (chat) {
+ chat.lastMessage = chatMessageService.lastMessage
+ if (chatMessageService.lastMessage) {
+ chat.updated_at = chatMessageService.lastMessage.created_at
+ }
+ }
+ }
+ },
+ deleteChatMessage (state, { commit, chatId, messageId }) {
+ const chatMessageService = state.openedChatMessageServices[chatId]
+ if (chatMessageService) {
+ chatService.deleteMessage(chatMessageService, messageId)
+ commit('refreshLastMessage', { chatId })
+ }
+ },
+ resetChatNewMessageCount (state, _value) {
+ const chatMessageService = state.openedChatMessageServices[state.currentChatId]
+ chatService.resetNewMessageCount(chatMessageService)
+ },
+ // Used when a connection loss occurs
+ clearOpenedChats (state) {
+ const currentChatId = state.currentChatId
+ for (const chatId in state.openedChats) {
+ if (currentChatId !== chatId) {
+ chatService.clear(state.openedChatMessageServices[chatId])
+ Vue.delete(state.openedChats, chatId)
+ Vue.delete(state.openedChatMessageServices, chatId)
+ }
+ }
+ },
+ readChat (state, { id }) {
+ const chat = getChatById(state, id)
+ if (chat) {
+ chat.unread = 0
+ }
+ }
+ }
+}
+
+export default chats
diff --git a/src/modules/config.js b/src/modules/config.js
index 47b24d77..e0fe72df 100644
--- a/src/modules/config.js
+++ b/src/modules/config.js
@@ -46,7 +46,8 @@ export const defaultState = {
repeats: true,
moves: true,
emojiReactions: false,
- followRequest: true
+ followRequest: true,
+ chatMention: true
},
webPushNotifications: false,
muteWords: [],
diff --git a/src/modules/instance.js b/src/modules/instance.js
index 45a8eeca..3fe3bbf3 100644
--- a/src/modules/instance.js
+++ b/src/modules/instance.js
@@ -55,6 +55,7 @@ const defaultState = {
// Feature-set, apparently, not everything here is reported...
chatAvailable: false,
+ pleromaChatMessagesAvailable: false,
gopherAvailable: false,
mediaProxyAvailable: false,
suggestionsEnabled: false,
diff --git a/src/modules/interface.js b/src/modules/interface.js
index e31630fc..ec08ac0a 100644
--- a/src/modules/interface.js
+++ b/src/modules/interface.js
@@ -15,7 +15,8 @@ const defaultState = {
)
},
mobileLayout: false,
- globalNotices: []
+ globalNotices: [],
+ layoutHeight: 0
}
const interfaceMod = {
@@ -65,6 +66,9 @@ const interfaceMod = {
},
removeGlobalNotice (state, notice) {
state.globalNotices = state.globalNotices.filter(n => n !== notice)
+ },
+ setLayoutHeight (state, value) {
+ state.layoutHeight = value
}
},
actions: {
@@ -110,6 +114,9 @@ const interfaceMod = {
},
removeGlobalNotice ({ commit }, notice) {
commit('removeGlobalNotice', notice)
+ },
+ setLayoutHeight ({ commit }, value) {
+ commit('setLayoutHeight', value)
}
}
}
diff --git a/src/modules/statuses.js b/src/modules/statuses.js
index 7fbf685c..64f5b587 100644
--- a/src/modules/statuses.js
+++ b/src/modules/statuses.js
@@ -478,7 +478,7 @@ export const mutations = {
},
setDeleted (state, { status }) {
const newStatus = state.allStatusesObject[status.id]
- newStatus.deleted = true
+ if (newStatus) newStatus.deleted = true
},
setManyDeleted (state, condition) {
Object.values(state.allStatusesObject).forEach(status => {
@@ -521,6 +521,9 @@ export const mutations = {
dismissNotification (state, { id }) {
state.notifications.data = state.notifications.data.filter(n => n.id !== id)
},
+ dismissNotifications (state, { finder }) {
+ state.notifications.data = state.notifications.data.filter(n => finder)
+ },
updateNotification (state, { id, updater }) {
const notification = find(state.notifications.data, n => n.id === id)
notification && updater(notification)
diff --git a/src/modules/users.js b/src/modules/users.js
index 7e136c61..16c1e566 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -498,6 +498,7 @@ const users = {
store.dispatch('stopFetchingFollowRequests')
store.commit('clearNotifications')
store.commit('resetStatuses')
+ store.dispatch('resetChats')
})
},
loginUser (store, accessToken) {
@@ -537,6 +538,9 @@ const users = {
// Start fetching notifications
store.dispatch('startFetchingNotifications')
+
+ // Start fetching chats
+ store.dispatch('startFetchingChats')
}
if (store.getters.mergedConfig.useStreamingApi) {
@@ -544,6 +548,7 @@ const users = {
console.error('Failed initializing MastoAPI Streaming socket', error)
startPolling()
}).then(() => {
+ store.dispatch('fetchChats', { latest: true })
setTimeout(() => store.dispatch('setNotificationsSilence', false), 10000)
})
} else {
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index 14e63e4f..5428cc2a 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -1,5 +1,5 @@
import { each, map, concat, last, get } from 'lodash'
-import { parseStatus, parseUser, parseNotification, parseAttachment, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js'
+import { parseStatus, parseUser, parseNotification, parseAttachment, parseChat, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js'
import { RegistrationError, StatusCodeError } from '../errors/errors'
/* eslint-env browser */
@@ -81,6 +81,11 @@ const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers'
const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/reactions`
const PLEROMA_EMOJI_REACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
const PLEROMA_EMOJI_UNREACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
+const PLEROMA_CHATS_URL = `/api/v1/pleroma/chats`
+const PLEROMA_CHAT_URL = id => `/api/v1/pleroma/chats/by-account-id/${id}`
+const PLEROMA_CHAT_MESSAGES_URL = id => `/api/v1/pleroma/chats/${id}/messages`
+const PLEROMA_CHAT_READ_URL = id => `/api/v1/pleroma/chats/${id}/read`
+const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) => `/api/v1/pleroma/chats/${chatId}/messages/${messageId}`
const oldfetch = window.fetch
@@ -117,13 +122,18 @@ const promisedRequest = ({ method, url, params, payload, credentials, headers =
}
return fetch(url, options)
.then((response) => {
- return new Promise((resolve, reject) => response.json()
- .then((json) => {
- if (!response.ok) {
- return reject(new StatusCodeError(response.status, json, { url, options }, response))
- }
- return resolve(json)
- }))
+ return new Promise((resolve, reject) => {
+ response.json()
+ .then((json) => {
+ if (!response.ok) {
+ return reject(new StatusCodeError(response.status, json, { url, options }, response))
+ }
+ return resolve(json)
+ })
+ .catch((error) => {
+ return reject(new StatusCodeError(response.status, error.message, { url, options }, response))
+ })
+ })
})
}
@@ -1067,6 +1077,10 @@ const MASTODON_STREAMING_EVENTS = new Set([
'filters_changed'
])
+const PLEROMA_STREAMING_EVENTS = new Set([
+ 'pleroma:chat_update'
+])
+
// A thin wrapper around WebSocket API that allows adding a pre-processor to it
// Uses EventTarget and a CustomEvent to proxy events
export const ProcessedWS = ({
@@ -1123,7 +1137,7 @@ export const handleMastoWS = (wsEvent) => {
if (!data) return
const parsedEvent = JSON.parse(data)
const { event, payload } = parsedEvent
- if (MASTODON_STREAMING_EVENTS.has(event)) {
+ if (MASTODON_STREAMING_EVENTS.has(event) || PLEROMA_STREAMING_EVENTS.has(event)) {
// MastoBE and PleromaBE both send payload for delete as a PLAIN string
if (event === 'delete') {
return { event, id: payload }
@@ -1133,6 +1147,8 @@ export const handleMastoWS = (wsEvent) => {
return { event, status: parseStatus(data) }
} else if (event === 'notification') {
return { event, notification: parseNotification(data) }
+ } else if (event === 'pleroma:chat_update') {
+ return { event, chatUpdate: parseChat(data) }
}
} else {
console.warn('Unknown event', wsEvent)
@@ -1140,6 +1156,81 @@ export const handleMastoWS = (wsEvent) => {
}
}
+export const WSConnectionStatus = Object.freeze({
+ 'JOINED': 1,
+ 'CLOSED': 2,
+ 'ERROR': 3
+})
+
+const chats = ({ credentials }) => {
+ return fetch(PLEROMA_CHATS_URL, { headers: authHeaders(credentials) })
+ .then((data) => data.json())
+ .then((data) => {
+ return { chats: data.map(parseChat).filter(c => c) }
+ })
+}
+
+const getOrCreateChat = ({ accountId, credentials }) => {
+ return promisedRequest({
+ url: PLEROMA_CHAT_URL(accountId),
+ method: 'POST',
+ credentials
+ })
+}
+
+const chatMessages = ({ id, credentials, maxId, sinceId, limit = 20 }) => {
+ let url = PLEROMA_CHAT_MESSAGES_URL(id)
+ const args = [
+ maxId && `max_id=${maxId}`,
+ sinceId && `since_id=${sinceId}`,
+ limit && `limit=${limit}`
+ ].filter(_ => _).join('&')
+
+ url = url + (args ? '?' + args : '')
+
+ return promisedRequest({
+ url,
+ method: 'GET',
+ credentials
+ })
+}
+
+const sendChatMessage = ({ id, content, mediaId = null, credentials }) => {
+ const payload = {
+ 'content': content
+ }
+
+ if (mediaId) {
+ payload['media_id'] = mediaId
+ }
+
+ return promisedRequest({
+ url: PLEROMA_CHAT_MESSAGES_URL(id),
+ method: 'POST',
+ payload: payload,
+ credentials
+ })
+}
+
+const readChat = ({ id, lastReadId, credentials }) => {
+ return promisedRequest({
+ url: PLEROMA_CHAT_READ_URL(id),
+ method: 'POST',
+ payload: {
+ 'last_read_id': lastReadId
+ },
+ credentials
+ })
+}
+
+const deleteChatMessage = ({ chatId, messageId, credentials }) => {
+ return promisedRequest({
+ url: PLEROMA_DELETE_CHAT_MESSAGE_URL(chatId, messageId),
+ method: 'DELETE',
+ credentials
+ })
+}
+
const apiService = {
verifyCredentials,
fetchTimeline,
@@ -1218,7 +1309,13 @@ const apiService = {
fetchKnownDomains,
fetchDomainMutes,
muteDomain,
- unmuteDomain
+ unmuteDomain,
+ chats,
+ getOrCreateChat,
+ chatMessages,
+ sendChatMessage,
+ readChat,
+ deleteChatMessage
}
export default apiService
diff --git a/src/services/chat_service/chat_service.js b/src/services/chat_service/chat_service.js
new file mode 100644
index 00000000..763a7607
--- /dev/null
+++ b/src/services/chat_service/chat_service.js
@@ -0,0 +1,150 @@
+import _ from 'lodash'
+
+const empty = (chatId) => {
+ return {
+ idIndex: {},
+ messages: [],
+ newMessageCount: 0,
+ lastSeenTimestamp: 0,
+ chatId: chatId,
+ minId: undefined,
+ lastMessage: undefined
+ }
+}
+
+const clear = (storage) => {
+ storage.idIndex = {}
+ storage.messages.splice(0, storage.messages.length)
+ storage.newMessageCount = 0
+ storage.lastSeenTimestamp = 0
+ storage.minId = undefined
+ storage.lastMessage = undefined
+}
+
+const deleteMessage = (storage, messageId) => {
+ if (!storage) { return }
+ storage.messages = storage.messages.filter(m => m.id !== messageId)
+ delete storage.idIndex[messageId]
+
+ if (storage.lastMessage && (storage.lastMessage.id === messageId)) {
+ storage.lastMessage = _.maxBy(storage.messages, 'id')
+ }
+
+ if (storage.minId === messageId) {
+ storage.minId = _.minBy(storage.messages, 'id')
+ }
+}
+
+const add = (storage, { messages: newMessages }) => {
+ if (!storage) { return }
+ for (let i = 0; i < newMessages.length; i++) {
+ const message = newMessages[i]
+
+ // sanity check
+ if (message.chat_id !== storage.chatId) { return }
+
+ if (!storage.minId || message.id < storage.minId) {
+ storage.minId = message.id
+ }
+
+ if (!storage.lastMessage || message.id > storage.lastMessage.id) {
+ storage.lastMessage = message
+ }
+
+ if (!storage.idIndex[message.id]) {
+ if (storage.lastSeenTimestamp < message.created_at) {
+ storage.newMessageCount++
+ }
+ storage.messages.push(message)
+ storage.idIndex[message.id] = message
+ }
+ }
+}
+
+const resetNewMessageCount = (storage) => {
+ if (!storage) { return }
+ storage.newMessageCount = 0
+ storage.lastSeenTimestamp = new Date()
+}
+
+// Inserts date separators and marks the head and tail if it's the chain of messages made by the same user
+const getView = (storage) => {
+ if (!storage) { return [] }
+
+ const result = []
+ const messages = _.sortBy(storage.messages, ['id', 'desc'])
+ const firstMessages = messages[0]
+ let prev = messages[messages.length - 1]
+ let currentMessageChainId
+
+ if (firstMessages) {
+ const date = new Date(firstMessages.created_at)
+ date.setHours(0, 0, 0, 0)
+ result.push({
+ type: 'date',
+ date,
+ id: date.getTime().toString()
+ })
+ }
+
+ let afterDate = false
+
+ for (let i = 0; i < messages.length; i++) {
+ const message = messages[i]
+ const nextMessage = messages[i + 1]
+
+ const date = new Date(message.created_at)
+ date.setHours(0, 0, 0, 0)
+
+ // insert date separator and start a new message chain
+ if (prev && prev.date < date) {
+ result.push({
+ type: 'date',
+ date,
+ id: date.getTime().toString()
+ })
+
+ prev['isTail'] = true
+ currentMessageChainId = undefined
+ afterDate = true
+ }
+
+ const object = {
+ type: 'message',
+ data: message,
+ date,
+ id: message.id,
+ messageChainId: currentMessageChainId
+ }
+
+ // end a message chian
+ if ((nextMessage && nextMessage.account_id) !== message.account_id) {
+ object['isTail'] = true
+ currentMessageChainId = undefined
+ }
+
+ // start a new message chain
+ if ((prev && prev.data && prev.data.account_id) !== message.account_id || afterDate) {
+ currentMessageChainId = _.uniqueId()
+ object['isHead'] = true
+ object['messageChainId'] = currentMessageChainId
+ }
+
+ result.push(object)
+ prev = object
+ afterDate = false
+ }
+
+ return result
+}
+
+const ChatService = {
+ add,
+ empty,
+ getView,
+ deleteMessage,
+ resetNewMessageCount,
+ clear
+}
+
+export default ChatService
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index ec83c02a..7ea8a16c 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -183,6 +183,7 @@ export const parseUser = (data) => {
output.deactivated = data.pleroma.deactivated
output.notification_settings = data.pleroma.notification_settings
+ output.unread_chat_count = data.pleroma.unread_chat_count
}
output.tags = output.tags || []
@@ -372,7 +373,7 @@ export const parseNotification = (data) => {
? parseStatus(data.notice.favorited_status)
: parsedNotice
output.action = parsedNotice
- output.from_profile = parseUser(data.from_profile)
+ output.from_profile = output.type === 'pleroma:chat_mention' ? parseUser(data.account) : parseUser(data.from_profile)
}
output.created_at = new Date(data.created_at)
@@ -398,3 +399,34 @@ export const parseLinkHeaderPagination = (linkHeader, opts = {}) => {
minId: flakeId ? minId : parseInt(minId, 10)
}
}
+
+export const parseChat = (chat) => {
+ const output = {}
+ output.id = chat.id
+ output.account = parseUser(chat.account)
+ output.unread = chat.unread
+ output.lastMessage = parseChatMessage(chat.last_message)
+ output.updated_at = new Date(chat.updated_at)
+ return output
+}
+
+export const parseChatMessage = (message) => {
+ if (!message) { return }
+ if (message.isNormalized) { return message }
+ const output = message
+ output.id = message.id
+ output.created_at = new Date(message.created_at)
+ output.chat_id = message.chat_id
+ if (message.content) {
+ output.content = addEmojis(message.content, message.emojis)
+ } else {
+ output.content = ''
+ }
+ if (message.attachment) {
+ output.attachments = [parseAttachment(message.attachment)]
+ } else {
+ output.attachments = []
+ }
+ output.isNormalized = true
+ return output
+}
diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js
index fbdcf562..07425abd 100644
--- a/src/services/style_setter/style_setter.js
+++ b/src/services/style_setter/style_setter.js
@@ -106,7 +106,8 @@ export const generateRadii = (input) => {
avatar: 5,
avatarAlt: 50,
tooltip: 2,
- attachment: 5
+ attachment: 5,
+ chatMessage: inputRadii.panel
})
return {
diff --git a/src/services/theme_data/pleromafe.js b/src/services/theme_data/pleromafe.js
index 6b25cd6f..b58ca9be 100644
--- a/src/services/theme_data/pleromafe.js
+++ b/src/services/theme_data/pleromafe.js
@@ -23,7 +23,9 @@ export const LAYERS = {
inputTopBar: 'topBar',
alert: 'bg',
alertPanel: 'panel',
- poll: 'bg'
+ poll: 'bg',
+ chatBg: 'underlay',
+ chatMessage: 'chatBg'
}
/* By default opacity slots have 1 as default opacity
@@ -667,5 +669,54 @@ export const SLOT_INHERITANCE = {
layer: 'badge',
variant: 'badgeNotification',
textColor: 'bw'
+ },
+
+ chatBg: {
+ depends: ['bg']
+ },
+
+ chatMessage: {
+ depends: ['chatBg']
+ },
+
+ chatMessageIncomingBg: {
+ depends: ['chatMessage'],
+ layer: 'chatMessage'
+ },
+
+ chatMessageIncomingText: {
+ depends: ['text'],
+ layer: 'text'
+ },
+
+ chatMessageIncomingLink: {
+ depends: ['link'],
+ layer: 'link'
+ },
+
+ chatMessageIncomingBorder: {
+ depends: ['border'],
+ opacity: 'border',
+ color: (mod, border) => brightness(2 * mod, border).rgb
+ },
+
+ chatMessageOutgoingBg: {
+ depends: ['chatMessage'],
+ color: (mod, chatMessage) => brightness(5 * mod, chatMessage).rgb
+ },
+
+ chatMessageOutgoingText: {
+ depends: ['text'],
+ layer: 'text'
+ },
+
+ chatMessageOutgoingLink: {
+ depends: ['link'],
+ layer: 'link'
+ },
+
+ chatMessageOutgoingBorder: {
+ depends: ['chatMessage'],
+ opacity: 'chatMessage'
}
}
diff --git a/src/services/window_utils/window_utils.js b/src/services/window_utils/window_utils.js
index faff6cb9..909088db 100644
--- a/src/services/window_utils/window_utils.js
+++ b/src/services/window_utils/window_utils.js
@@ -3,3 +3,8 @@ export const windowWidth = () =>
window.innerWidth ||
document.documentElement.clientWidth ||
document.body.clientWidth
+
+export const windowHeight = () =>
+ window.innerHeight ||
+ document.documentElement.clientHeight ||
+ document.body.clientHeight
diff --git a/static/fontello.json b/static/fontello.json
index 5ef8544e..706800cd 100644
--- a/static/fontello.json
+++ b/static/fontello.json
@@ -399,6 +399,12 @@
"css": "doc",
"code": 59433,
"src": "fontawesome"
+ },
+ {
+ "uid": "98d9c83c1ee7c2c25af784b518c522c5",
+ "css": "block",
+ "code": 59434,
+ "src": "fontawesome"
}
]
}
\ No newline at end of file
diff --git a/test/unit/specs/boot/routes.spec.js b/test/unit/specs/boot/routes.spec.js
index a415aeaf..3673256f 100644
--- a/test/unit/specs/boot/routes.spec.js
+++ b/test/unit/specs/boot/routes.spec.js
@@ -1,14 +1,22 @@
+import Vuex from 'vuex'
import routes from 'src/boot/routes'
import { createLocalVue } from '@vue/test-utils'
import VueRouter from 'vue-router'
const localVue = createLocalVue()
+localVue.use(Vuex)
localVue.use(VueRouter)
+const store = new Vuex.Store({
+ state: {
+ instance: {}
+ }
+})
+
describe('routes', () => {
const router = new VueRouter({
mode: 'abstract',
- routes: routes({})
+ routes: routes(store)
})
it('root path', () => {
From f05f832bff58034d78de9478ae2dbb06284dea75 Mon Sep 17 00:00:00 2001
From: eugenijm
Date: Sun, 21 Jun 2020 17:13:29 +0300
Subject: [PATCH 78/94] Address feedback
Use more specific css rules for the emoji dimensions in the chat list status preview.
Use more round em value for chat list item height.
Add global html overflow and height for smoother chat navigation in
the desktop Safari.
Use offsetHeight instad of a computed style when setting the window height on resize.
Remove margin-bottom from the last message to avoid occasional layout shift in the desktop Safari
Use break-word to prevent chat message text overflow
Resize and scroll the textarea when inserting a new line on ctrl+enter
Remove fade transition on route change
Ensure proper border radius at the bottom of the chat, remove unused border-radius
Prevent the chat header "jumping" on the avatar load.
---
src/App.js | 12 +--
src/App.scss | 39 ++++++-
src/App.vue | 4 +-
src/components/chat/chat.js | 63 ++++++++---
src/components/chat/chat.scss | 32 +++---
src/components/chat/chat.vue | 2 +-
src/components/chat/chat_layout.js | 100 ------------------
src/components/chat/chat_layout_utils.js | 3 +-
src/components/chat_avatar/chat_avatar.js | 23 ----
src/components/chat_avatar/chat_avatar.vue | 53 ----------
.../chat_list_item/chat_list_item.js | 4 +-
.../chat_list_item/chat_list_item.scss | 48 ++++-----
.../chat_list_item/chat_list_item.vue | 2 +-
src/components/chat_message/chat_message.js | 4 +-
src/components/chat_message/chat_message.scss | 27 ++---
src/components/chat_new/chat_new.js | 5 +-
src/components/chat_new/chat_new.scss | 2 +-
src/components/chat_title/chat_title.js | 10 +-
src/components/chat_title/chat_title.vue | 39 ++++---
src/components/emoji_input/emoji_input.js | 23 +++-
src/components/media_upload/media_upload.vue | 13 ---
.../post_status_form/post_status_form.js | 19 ++--
.../post_status_form/post_status_form.vue | 20 +++-
src/services/chat_service/chat_service.js | 19 ++--
.../chat_service/chat_service.spec.js | 89 ++++++++++++++++
25 files changed, 317 insertions(+), 338 deletions(-)
delete mode 100644 src/components/chat/chat_layout.js
delete mode 100644 src/components/chat_avatar/chat_avatar.js
delete mode 100644 src/components/chat_avatar/chat_avatar.vue
create mode 100644 test/unit/specs/services/chat_service/chat_service.spec.js
diff --git a/src/App.js b/src/App.js
index 84300e00..ded772fa 100644
--- a/src/App.js
+++ b/src/App.js
@@ -45,8 +45,7 @@ export default {
window.CSS.supports('-moz-mask-size', 'contain') ||
window.CSS.supports('-ms-mask-size', 'contain') ||
window.CSS.supports('-o-mask-size', 'contain')
- ),
- transitionName: 'fade'
+ )
}),
created () {
// Load the locale from the storage
@@ -135,14 +134,5 @@ export default {
}
this.$store.dispatch('setLayoutHeight', layoutHeight)
}
- },
- watch: {
- '$route' (to, from) {
- if ((to.name === 'chat' && from.name === 'chats') || (to.name === 'chats' && from.name === 'chat')) {
- this.transitionName = 'none'
- } else {
- this.transitionName = 'fade'
- }
- }
}
}
diff --git a/src/App.scss b/src/App.scss
index 29ce73a8..e2e2d079 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -47,6 +47,7 @@ html {
}
body {
+ overscroll-behavior-y: none;
font-family: sans-serif;
font-family: var(--interfaceFont, sans-serif);
margin: 0;
@@ -56,7 +57,6 @@ body {
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
- overscroll-behavior: none;
&.hidden {
display: none;
@@ -320,7 +320,7 @@ option {
i[class*=icon-] {
color: $fallback--icon;
- color: var(--icon, $fallback--icon)
+ color: var(--icon, $fallback--icon);
}
.btn-block {
@@ -942,3 +942,38 @@ nav {
max-height: 1.3rem;
line-height: 1.3rem;
}
+
+.chat-layout {
+ // Needed for smoother chat navigation in the desktop Safari (otherwise the chat layout "jumps" as the chat opens).
+ overflow: hidden;
+ height: 100%;
+
+ // Ensures the fixed position of the mobile browser bars on scroll up / down events.
+ // Prevents the mobile browser bars from overlapping or hiding the message posting form.
+ @media all and (max-width: 800px) {
+ body {
+ height: 100%;
+ }
+
+ #app {
+ height: 100%;
+ overflow: hidden;
+ min-height: auto;
+ }
+
+ #app_bg_wrapper {
+ overflow: hidden;
+ }
+
+ .main {
+ overflow: hidden;
+ height: 100%;
+ }
+
+ #content {
+ padding-top: 0;
+ height: 100%;
+ overflow: visible;
+ }
+ }
+}
diff --git a/src/App.vue b/src/App.vue
index 5d429934..0276c6a6 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -113,9 +113,7 @@
{{ $t("login.hint") }}
-
-
-
+
diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js
index 6e23c20c..9c4e5b05 100644
--- a/src/components/chat/chat.js
+++ b/src/components/chat/chat.js
@@ -2,29 +2,26 @@ 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 ChatAvatar from '../chat_avatar/chat_avatar.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 ChatLayout from './chat_layout.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,
- ChatAvatar,
PostStatusForm
},
- mixins: [ChatLayout],
data () {
return {
jumpToBottomButtonVisible: false,
hoveredMessageChainId: undefined,
- scrollPositionBeforeResize: {},
+ lastScrollPosition: {},
scrollableContainerHeight: '100%',
errorLoadingChat: false
}
@@ -119,6 +116,7 @@ const Chat = {
},
onFilesDropped () {
this.$nextTick(() => {
+ this.handleResize()
this.updateScrollableContainerHeight()
})
},
@@ -129,13 +127,30 @@ const Chat = {
}
})
},
- handleLayoutChange () {
- this.updateScrollableContainerHeight()
- if (this.mobileLayout) {
- this.setMobileChatLayout()
- } else {
- this.unsetMobileChatLayout()
+ 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()
@@ -149,15 +164,24 @@ const Chat = {
this.scrollableContainerHeight = scrollableContainerHeight(inner, header, footer) + 'px'
},
// Preserves the scroll position when OSK appears or the posting form changes its height.
- handleResize (opts) {
+ 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.scrollPositionBeforeResize
- this.scrollPositionBeforeResize = getScrollPosition(this.$refs.scrollable)
+ const { offsetHeight = undefined } = this.lastScrollPosition
+ this.lastScrollPosition = getScrollPosition(this.$refs.scrollable)
- const diff = this.scrollPositionBeforeResize.offsetHeight - offsetHeight
- if (diff < 0 || (!this.bottomedOut() && opts && opts.expand)) {
+ const diff = this.lastScrollPosition.offsetHeight - offsetHeight
+ if (diff < 0 || (!this.bottomedOut() && expand)) {
this.$nextTick(() => {
this.updateScrollableContainerHeight()
this.$refs.scrollable.scrollTo({
@@ -281,7 +305,12 @@ const Chat = {
.then(data => {
this.$store.dispatch('addChatMessages', { chatId: this.currentChat.id, messages: [data] }).then(() => {
this.$nextTick(() => {
- this.updateScrollableContainerHeight()
+ this.handleResize()
+ // When the posting form size changes because of a media attachment, we need an extra resize
+ // to account for the potential delay in the DOM update.
+ setTimeout(() => {
+ this.updateScrollableContainerHeight()
+ }, SAFE_RESIZE_TIME_OFFSET)
this.scrollDown({ forceRead: true })
})
})
diff --git a/src/components/chat/chat.scss b/src/components/chat/chat.scss
index 13c52ea3..6ae7ebc9 100644
--- a/src/components/chat/chat.scss
+++ b/src/components/chat/chat.scss
@@ -3,14 +3,17 @@
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-top: 0.5em;
- margin-left: 0.5em;
- margin-right: 0.5em;
+ margin: 0.5em 0.5em 0 0.5em;
}
.chat-view-body {
@@ -19,23 +22,18 @@
flex-direction: column;
width: 100%;
overflow: visible;
- border-radius: none;
min-height: 100%;
- margin-left: 0;
- margin-right: 0;
- margin-bottom: 0em;
- margin-top: 0em;
+ margin: 0 0 0 0;
border-radius: 10px 10px 0 0;
border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0 ;
&::after {
- border-radius: none;
- box-shadow: none;
+ border-radius: 0;
}
}
.scrollable-message-list {
- padding: 0 10px;
+ padding: 0 0.8em;
height: 100%;
overflow-y: scroll;
overflow-x: hidden;
@@ -45,7 +43,7 @@
.footer {
position: sticky;
- bottom: 0px;
+ bottom: 0;
}
.chat-view-heading {
@@ -54,15 +52,19 @@
top: 50px;
display: flex;
z-index: 2;
- border-radius: none;
position: sticky;
display: flex;
overflow: hidden;
}
.go-back-button {
- margin-right: 1.2em;
cursor: pointer;
+ margin-right: 1.4em;
+
+ i {
+ display: flex;
+ align-items: center;
+ }
}
.jump-to-bottom-button {
@@ -135,7 +137,7 @@
overflow: hidden;
height: 100%;
margin: 0;
- border-radius: 0 !important;
+ border-radius: 0;
}
.chat-view-heading {
diff --git a/src/components/chat/chat.vue b/src/components/chat/chat.vue
index d8c91dbe..62b72e14 100644
--- a/src/components/chat/chat.vue
+++ b/src/components/chat/chat.vue
@@ -75,7 +75,7 @@
:disable-polls="true"
:disable-sensitivity-checkbox="true"
:disable-submit="errorLoadingChat || !currentChat"
- :request="sendMessage"
+ :post-handler="sendMessage"
:submit-on-enter="!mobileLayout"
:preserve-focus="!mobileLayout"
:auto-focus="!mobileLayout"
diff --git a/src/components/chat/chat_layout.js b/src/components/chat/chat_layout.js
deleted file mode 100644
index 07ae3abf..00000000
--- a/src/components/chat/chat_layout.js
+++ /dev/null
@@ -1,100 +0,0 @@
-const ChatLayout = {
- methods: {
- setChatLayout () {
- if (this.mobileLayout) {
- this.setMobileChatLayout()
- }
- },
- unsetChatLayout () {
- this.unsetMobileChatLayout()
- },
- setMobileChatLayout () {
- // 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.style.overflow = 'hidden'
- html.style.height = '100%'
- }
-
- let body = document.querySelector('body')
- if (body) {
- body.style.height = '100%'
- }
-
- let app = document.getElementById('app')
- if (app) {
- app.style.height = '100%'
- app.style.overflow = 'hidden'
- app.style.minHeight = 'auto'
- }
-
- let appBgWrapper = window.document.getElementById('app_bg_wrapper')
- if (appBgWrapper) {
- appBgWrapper.style.overflow = 'hidden'
- }
-
- let main = document.getElementsByClassName('main')[0]
- if (main) {
- main.style.overflow = 'hidden'
- main.style.height = '100%'
- }
-
- let content = document.getElementById('content')
- if (content) {
- content.style.paddingTop = '0'
- content.style.height = '100%'
- content.style.overflow = 'visible'
- }
-
- this.$nextTick(() => {
- this.updateScrollableContainerHeight()
- })
- },
- unsetMobileChatLayout () {
- let html = document.querySelector('html')
- if (html) {
- html.style.overflow = 'visible'
- html.style.height = 'unset'
- }
-
- let body = document.querySelector('body')
- if (body) {
- body.style.height = 'unset'
- }
-
- let app = document.getElementById('app')
- if (app) {
- app.style.height = '100%'
- app.style.overflow = 'visible'
- app.style.minHeight = '100vh'
- }
-
- let appBgWrapper = document.getElementById('app_bg_wrapper')
- if (appBgWrapper) {
- appBgWrapper.style.overflow = 'visible'
- }
-
- let main = document.getElementsByClassName('main')[0]
- if (main) {
- main.style.overflow = 'visible'
- main.style.height = 'unset'
- }
-
- let content = document.getElementById('content')
- if (content) {
- content.style.paddingTop = '60px'
- content.style.height = 'unset'
- content.style.overflow = 'unset'
- }
- }
- }
-}
-
-export default ChatLayout
diff --git a/src/components/chat/chat_layout_utils.js b/src/components/chat/chat_layout_utils.js
index f07ba2a1..609dc0c9 100644
--- a/src/components/chat/chat_layout_utils.js
+++ b/src/components/chat/chat_layout_utils.js
@@ -22,6 +22,5 @@ export const isBottomedOut = (el, offset = 0) => {
// 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) => {
- const height = parseFloat(getComputedStyle(inner, null).height.replace('px', ''))
- return height - header.clientHeight - footer.clientHeight
+ return inner.offsetHeight - header.clientHeight - footer.clientHeight
}
diff --git a/src/components/chat_avatar/chat_avatar.js b/src/components/chat_avatar/chat_avatar.js
deleted file mode 100644
index 7b26e07c..00000000
--- a/src/components/chat_avatar/chat_avatar.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import StillImage from '../still-image/still-image.vue'
-import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
-import { mapState } from 'vuex'
-
-const ChatAvatar = {
- props: ['user', 'width', 'height'],
- components: {
- StillImage
- },
- methods: {
- getUserProfileLink (user) {
- if (!user) { return }
- return generateProfileLink(user.id, user.screen_name)
- }
- },
- computed: {
- ...mapState({
- betterShadow: state => state.interface.browserSupport.cssFilter
- })
- }
-}
-
-export default ChatAvatar
diff --git a/src/components/chat_avatar/chat_avatar.vue b/src/components/chat_avatar/chat_avatar.vue
deleted file mode 100644
index f54a7151..00000000
--- a/src/components/chat_avatar/chat_avatar.vue
+++ /dev/null
@@ -1,53 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/src/components/chat_list_item/chat_list_item.js b/src/components/chat_list_item/chat_list_item.js
index 1c27088c..b6b0519a 100644
--- a/src/components/chat_list_item/chat_list_item.js
+++ b/src/components/chat_list_item/chat_list_item.js
@@ -1,7 +1,7 @@
import { mapState } from 'vuex'
import StatusContent from '../status_content/status_content.vue'
import fileType from 'src/services/file_type/file_type.service'
-import ChatAvatar from '../chat_avatar/chat_avatar.vue'
+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'
@@ -12,7 +12,7 @@ const ChatListItem = {
'chat'
],
components: {
- ChatAvatar,
+ UserAvatar,
AvatarList,
Timeago,
ChatTitle,
diff --git a/src/components/chat_list_item/chat_list_item.scss b/src/components/chat_list_item/chat_list_item.scss
index 12269f89..3ec59ea2 100644
--- a/src/components/chat_list_item/chat_list_item.scss
+++ b/src/components/chat_list_item/chat_list_item.scss
@@ -1,17 +1,8 @@
.chat-list-item {
- &:hover .animated.avatar {
- canvas {
- display: none;
- }
- img {
- visibility: visible;
- }
- }
-
display: flex;
flex-direction: row;
padding: 0.75em;
- height: 4.85em;
+ height: 5em;
overflow: hidden;
box-sizing: border-box;
cursor: pointer;
@@ -22,7 +13,7 @@
&:hover {
background-color: var(--selectedPost, $fallback--lightBg);
- box-shadow: 0 0px 3px 1px rgba(0, 0, 0, 0.1);
+ box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.1);
}
.chat-list-item-left {
@@ -47,12 +38,6 @@
white-space: nowrap;
}
- .member-count {
- color: $fallback--text;
- color: var(--faintText, $fallback--text);
- margin-right: 2px;
- }
-
.name-and-account-name {
text-overflow: ellipsis;
white-space: nowrap;
@@ -65,7 +50,7 @@
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
- margin: 0.35rem 0;
+ margin: 0.35em 0;
height: 1.2em;
line-height: 1.2em;
color: $fallback--text;
@@ -78,17 +63,24 @@
pointer-events: none;
}
- .unread-indicator-wrapper {
- display: flex;
- align-items: center;
- margin-left: 10px;
+ &:hover .animated.avatar {
+ canvas {
+ display: none;
+ }
+ img {
+ visibility: visible;
+ }
}
- .unread-indicator {
- border-radius: 100%;
- height: 8px;
- width: 8px;
- background-color: $fallback--link;
- background-color: var(--link, $fallback--link);
+ .avatar.still-image {
+ border-radius: $fallback--avatarAltRadius;
+ border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+ }
+
+ .status-body {
+ img.emoji {
+ width: 1.4em;
+ height: 1.4em;
+ }
}
}
diff --git a/src/components/chat_list_item/chat_list_item.vue b/src/components/chat_list_item/chat_list_item.vue
index 26ad581b..640426b8 100644
--- a/src/components/chat_list_item/chat_list_item.vue
+++ b/src/components/chat_list_item/chat_list_item.vue
@@ -4,7 +4,7 @@
@click.capture.prevent="openChat"
>
-
id !== userId)
},
- search: throttle(function (query) {
+ search (query) {
if (!query) {
this.loading = false
return
@@ -67,7 +66,7 @@ const chatNew = {
this.loading = false
this.userIds = data.accounts.map(a => a.id)
})
- })
+ }
}
}
diff --git a/src/components/chat_new/chat_new.scss b/src/components/chat_new/chat_new.scss
index 39216677..11305444 100644
--- a/src/components/chat_new/chat_new.scss
+++ b/src/components/chat_new/chat_new.scss
@@ -15,7 +15,7 @@
}
.member-list {
- padding-bottom: 0.67rem;
+ padding-bottom: 0.7rem;
}
.basic-user-card:hover {
diff --git a/src/components/chat_title/chat_title.js b/src/components/chat_title/chat_title.js
index 2723d5f5..e424bb1f 100644
--- a/src/components/chat_title/chat_title.js
+++ b/src/components/chat_title/chat_title.js
@@ -1,10 +1,11 @@
import Vue from 'vue'
-import ChatAvatar from '../chat_avatar/chat_avatar.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: {
- ChatAvatar
+ UserAvatar
},
props: [
'user', 'withAvatar'
@@ -16,5 +17,10 @@ export default Vue.component('chat-title', {
htmlTitle () {
return this.user ? this.user.name_html : ''
}
+ },
+ methods: {
+ getUserProfileLink (user) {
+ return generateProfileLink(user.id, user.screen_name)
+ }
}
})
diff --git a/src/components/chat_title/chat_title.vue b/src/components/chat_title/chat_title.vue
index fd42d125..cfd1e6d1 100644
--- a/src/components/chat_title/chat_title.vue
+++ b/src/components/chat_title/chat_title.vue
@@ -4,16 +4,16 @@
class="chat-title"
:title="title"
>
-
-
+
+
+
diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js
index a27da090..f0123447 100644
--- a/src/components/emoji_input/emoji_input.js
+++ b/src/components/emoji_input/emoji_input.js
@@ -88,6 +88,11 @@ const EmojiInput = {
required: false,
type: String, // 'auto', 'top', 'bottom'
default: 'auto'
+ },
+ newlineOnCtrlEnter: {
+ required: false,
+ type: Boolean,
+ default: false
}
},
data () {
@@ -204,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) || ''
@@ -223,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,
@@ -381,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') {
diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue
index d719eae1..c8865d77 100644
--- a/src/components/media_upload/media_upload.vue
+++ b/src/components/media_upload/media_upload.vue
@@ -33,19 +33,6 @@
@import '../../_variables.scss';
.media-upload {
- &.disabled {
- .new-icon {
- cursor: not-allowed;
- }
-
- &:hover {
- i, label {
- color: $fallback--faint;
- color: var(--faint, $fallback--faint);
- }
- }
- }
-
.label {
display: inline-block;
}
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index 90d0fa81..59e4dc26 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -43,7 +43,7 @@ const PostStatusForm = {
'disableSubmit',
'placeholder',
'maxHeight',
- 'request',
+ 'postHandler',
'preserveFocus',
'autoFocus',
'fileLimit',
@@ -221,10 +221,6 @@ const PostStatusForm = {
event.stopPropagation()
event.preventDefault()
}
- if (opts.control && this.submitOnEnter) {
- newStatus.status = `${newStatus.status}\n`
- return
- }
if (this.emptyStatus) {
this.error = this.$t('post_status.empty_status_error')
@@ -259,9 +255,9 @@ const PostStatusForm = {
poll
}
- const request = this.request ? this.request : statusPoster.postStatus
+ const postHandler = this.postHandler ? this.postHandler : statusPoster.postStatus
- request(postingOptions).then((data) => {
+ postHandler(postingOptions).then((data) => {
if (!data.error) {
this.newStatus = {
status: '',
@@ -345,11 +341,7 @@ const PostStatusForm = {
},
addMediaFile (fileInfo) {
this.newStatus.files.push(fileInfo)
-
- // TODO: use fixed dimensions instead so relying on timeout
- setTimeout(() => {
- this.$emit('resize')
- }, 150)
+ this.$emit('resize', { delayed: true })
},
removeMediaFile (fileInfo) {
let index = this.newStatus.files.indexOf(fileInfo)
@@ -364,6 +356,7 @@ const PostStatusForm = {
this.uploadingFiles = true
},
finishedUploadingFiles () {
+ this.$emit('resize')
this.uploadingFiles = false
},
type (fileInfo) {
@@ -417,7 +410,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', null)
+ this.$emit('resize')
this.$refs['emoji-input'].resize()
return
}
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index d8df68d6..7454958b 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -131,6 +131,7 @@
class="form-control main-input"
enable-emoji-picker
hide-emoji-button
+ :newline-on-ctrl-enter="submitOnEnter"
enable-sticker-picker
@input="onEmojiInputInput"
@sticker-uploaded="addMediaFile"
@@ -146,8 +147,8 @@
class="form-post-body"
:class="{ 'scrollable-form': !!maxHeight }"
@keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
- @keydown.meta.enter="postStatus($event, newStatus, { control: true })"
- @keydown.ctrl.enter="postStatus($event, newStatus)"
+ @keydown.meta.enter="postStatus($event, newStatus)"
+ @keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)"
@input="resize"
@compositionupdate="resize"
@paste="paste"
@@ -435,6 +436,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
@@ -628,7 +642,7 @@
}
// todo: unify with attachment.vue (otherwise the uploaded images are not minified unless a status with an attachment was displayed before)
-img.media-upload {
+img.media-upload, .media-upload-container > video {
line-height: 0;
max-height: 200px;
max-width: 100%;
diff --git a/src/services/chat_service/chat_service.js b/src/services/chat_service/chat_service.js
index 763a7607..b60a889b 100644
--- a/src/services/chat_service/chat_service.js
+++ b/src/services/chat_service/chat_service.js
@@ -31,7 +31,8 @@ const deleteMessage = (storage, messageId) => {
}
if (storage.minId === messageId) {
- storage.minId = _.minBy(storage.messages, 'id')
+ const firstMessage = _.minBy(storage.messages, 'id')
+ storage.minId = firstMessage.id
}
}
@@ -73,12 +74,12 @@ const getView = (storage) => {
const result = []
const messages = _.sortBy(storage.messages, ['id', 'desc'])
- const firstMessages = messages[0]
- let prev = messages[messages.length - 1]
+ const firstMessage = messages[0]
+ let previousMessage = messages[messages.length - 1]
let currentMessageChainId
- if (firstMessages) {
- const date = new Date(firstMessages.created_at)
+ if (firstMessage) {
+ const date = new Date(firstMessage.created_at)
date.setHours(0, 0, 0, 0)
result.push({
type: 'date',
@@ -97,14 +98,14 @@ const getView = (storage) => {
date.setHours(0, 0, 0, 0)
// insert date separator and start a new message chain
- if (prev && prev.date < date) {
+ if (previousMessage && previousMessage.date < date) {
result.push({
type: 'date',
date,
id: date.getTime().toString()
})
- prev['isTail'] = true
+ previousMessage['isTail'] = true
currentMessageChainId = undefined
afterDate = true
}
@@ -124,14 +125,14 @@ const getView = (storage) => {
}
// start a new message chain
- if ((prev && prev.data && prev.data.account_id) !== message.account_id || afterDate) {
+ if ((previousMessage && previousMessage.data && previousMessage.data.account_id) !== message.account_id || afterDate) {
currentMessageChainId = _.uniqueId()
object['isHead'] = true
object['messageChainId'] = currentMessageChainId
}
result.push(object)
- prev = object
+ previousMessage = object
afterDate = false
}
diff --git a/test/unit/specs/services/chat_service/chat_service.spec.js b/test/unit/specs/services/chat_service/chat_service.spec.js
new file mode 100644
index 00000000..4e8e566b
--- /dev/null
+++ b/test/unit/specs/services/chat_service/chat_service.spec.js
@@ -0,0 +1,89 @@
+import chatService from '../../../../../src/services/chat_service/chat_service.js'
+
+const message1 = {
+ id: '9wLkdcmQXD21Oy8lEX',
+ created_at: (new Date('2020-06-22T18:45:53.000Z'))
+}
+
+const message2 = {
+ id: '9wLkdp6ihaOVdNj8Wu',
+ account_id: '9vmRb29zLQReckr5ay',
+ created_at: (new Date('2020-06-22T18:45:56.000Z'))
+}
+
+const message3 = {
+ id: '9wLke9zL4Dy4OZR2RM',
+ account_id: '9vmRb29zLQReckr5ay',
+ created_at: (new Date('2020-07-22T18:45:59.000Z'))
+}
+
+// TODO: only
+describe.only('chatService', () => {
+ describe('.add', () => {
+ it("Doesn't add duplicates", () => {
+ const chat = chatService.empty()
+ chatService.add(chat, { messages: [ message1 ] })
+ chatService.add(chat, { messages: [ message1 ] })
+ expect(chat.messages.length).to.eql(1)
+
+ chatService.add(chat, { messages: [ message2 ] })
+ expect(chat.messages.length).to.eql(2)
+ })
+
+ it('Updates minId and lastMessage and newMessageCount', () => {
+ const chat = chatService.empty()
+
+ chatService.add(chat, { messages: [ message1 ] })
+ expect(chat.lastMessage.id).to.eql(message1.id)
+ expect(chat.minId).to.eql(message1.id)
+ expect(chat.newMessageCount).to.eql(1)
+
+ chatService.add(chat, { messages: [ message2 ] })
+ expect(chat.lastMessage.id).to.eql(message2.id)
+ expect(chat.minId).to.eql(message1.id)
+ expect(chat.newMessageCount).to.eql(2)
+
+ chatService.resetNewMessageCount(chat)
+ expect(chat.newMessageCount).to.eql(0)
+
+ const createdAt = new Date()
+ createdAt.setSeconds(createdAt.getSeconds() + 10)
+ chatService.add(chat, { messages: [ { message3, created_at: createdAt } ] })
+ expect(chat.newMessageCount).to.eql(1)
+ })
+ })
+
+ describe('.delete', () => {
+ it('Updates minId and lastMessage', () => {
+ const chat = chatService.empty()
+
+ chatService.add(chat, { messages: [ message1 ] })
+ chatService.add(chat, { messages: [ message2 ] })
+ chatService.add(chat, { messages: [ message3 ] })
+
+ expect(chat.lastMessage.id).to.eql(message3.id)
+ expect(chat.minId).to.eql(message1.id)
+
+ chatService.deleteMessage(chat, message3.id)
+ expect(chat.lastMessage.id).to.eql(message2.id)
+ expect(chat.minId).to.eql(message1.id)
+
+ chatService.deleteMessage(chat, message1.id)
+ expect(chat.lastMessage.id).to.eql(message2.id)
+ expect(chat.minId).to.eql(message2.id)
+ })
+ })
+
+ describe('.getView', () => {
+ it('Inserts date separators', () => {
+ const chat = chatService.empty()
+
+ chatService.add(chat, { messages: [ message1 ] })
+ chatService.add(chat, { messages: [ message2 ] })
+ chatService.add(chat, { messages: [ message3 ] })
+
+ const view = chatService.getView(chat)
+ expect(view.map(i => i.type)).to.eql(['date', 'message', 'message', 'date', 'message'])
+ })
+ })
+})
From 45901c8da654bbeaae71cc484ea08f39a332baa7 Mon Sep 17 00:00:00 2001
From: eugenijm
Date: Mon, 6 Jul 2020 16:55:29 +0300
Subject: [PATCH 79/94] Disable status preview in the chat posting form
---
src/components/chat/chat.vue | 1 +
src/components/post_status_form/post_status_form.js | 1 +
src/components/post_status_form/post_status_form.vue | 5 ++++-
3 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/src/components/chat/chat.vue b/src/components/chat/chat.vue
index 62b72e14..2e4538c8 100644
--- a/src/components/chat/chat.vue
+++ b/src/components/chat/chat.vue
@@ -75,6 +75,7 @@
:disable-polls="true"
:disable-sensitivity-checkbox="true"
:disable-submit="errorLoadingChat || !currentChat"
+ :disable-preview="true"
:post-handler="sendMessage"
:submit-on-enter="!mobileLayout"
:preserve-focus="!mobileLayout"
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index 59e4dc26..9e7cce0f 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -41,6 +41,7 @@ const PostStatusForm = {
'disablePolls',
'disableSensitivityCheckbox',
'disableSubmit',
+ 'disablePreview',
'placeholder',
'maxHeight',
'postHandler',
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index 7454958b..3dcf1f79 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -70,7 +70,10 @@
{{ $t('post_status.direct_warning_to_first_only') }}
{{ $t('post_status.direct_warning_to_all') }}
-
+
-
+
+
+ {{ $t('chats.empty_chat_list_placeholder') }}
+
@@ -39,10 +48,24 @@
@import '../../_variables.scss';
.chat-list {
- min-height: calc(100vh - 67px);
+ min-height: 25em;
margin-bottom: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
+
+ &::after {
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+}
+
+.emtpy-chat-list-alert {
+ padding: 3em;
+ font-size: 1.2em;
+ display: flex;
+ justify-content: center;
+ color: $fallback--text;
+ color: var(--faint, $fallback--text);
}
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index 9e7cce0f..b0d94555 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -192,7 +192,7 @@ const PostStatusForm = {
this.newStatus.poll.error
},
showPreview () {
- return !!this.preview || this.previewLoading
+ return !this.disablePreview && (!!this.preview || this.previewLoading)
},
emptyStatus () {
return this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
diff --git a/src/i18n/en.json b/src/i18n/en.json
index c9a34556..5cc75460 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -794,7 +794,8 @@
"more": "More",
"delete_confirm": "Do you really want to delete this message?",
"error_loading_chat": "Something went wrong when loading the chat.",
- "error_sending_message": "Something went wrong when sending the message."
+ "error_sending_message": "Something went wrong when sending the message.",
+ "empty_chat_list_placeholder": "You don't have any chats yet. Start a new chat!"
},
"file_type": {
"audio": "Audio",
diff --git a/src/modules/chats.js b/src/modules/chats.js
index f868ca0c..228d6256 100644
--- a/src/modules/chats.js
+++ b/src/modules/chats.js
@@ -83,9 +83,6 @@ const chats = {
resetChatNewMessageCount ({ commit }, value) {
commit('resetChatNewMessageCount', value)
},
- removeFromCurrentChatStatuses ({ commit }, { id }) {
- commit('removeFromCurrentChatStatuses', id)
- },
clearCurrentChat ({ rootState, commit, dispatch }, value) {
commit('setCurrentChatId', { chatId: undefined })
commit('setCurrentChatFetcher', { fetcher: undefined })
From ed7310c04b3e36f1256db296784b6240023786a1 Mon Sep 17 00:00:00 2001
From: eugenijm
Date: Tue, 7 Jul 2020 21:28:10 +0300
Subject: [PATCH 81/94] Undo the promise rejection on the json parser error in
promisedRequest
to keep the existing behavior in case some parts of the code rely on it
and to limit the overall scope of the changes.
---
src/services/api/api.service.js | 19 +++++++------------
1 file changed, 7 insertions(+), 12 deletions(-)
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index 5428cc2a..40ea5bd9 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -122,18 +122,13 @@ const promisedRequest = ({ method, url, params, payload, credentials, headers =
}
return fetch(url, options)
.then((response) => {
- return new Promise((resolve, reject) => {
- response.json()
- .then((json) => {
- if (!response.ok) {
- return reject(new StatusCodeError(response.status, json, { url, options }, response))
- }
- return resolve(json)
- })
- .catch((error) => {
- return reject(new StatusCodeError(response.status, error.message, { url, options }, response))
- })
- })
+ return new Promise((resolve, reject) => response.json()
+ .then((json) => {
+ if (!response.ok) {
+ return reject(new StatusCodeError(response.status, json, { url, options }, response))
+ }
+ return resolve(json)
+ }))
})
}
From fc865d3a129a7d5eabf1490a82eefbdea07e3b47 Mon Sep 17 00:00:00 2001
From: eugenijm
Date: Tue, 7 Jul 2020 21:43:46 +0300
Subject: [PATCH 82/94] Remove direct style manipulations in favor of classes
---
src/components/chat_message/chat_message.js | 13 -------------
src/components/chat_message/chat_message.scss | 12 ++++++++++++
src/components/chat_message/chat_message.vue | 2 +-
src/components/mobile_nav/mobile_nav.js | 4 ++--
src/components/mobile_nav/mobile_nav.vue | 2 +-
5 files changed, 16 insertions(+), 17 deletions(-)
diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
index 4d737e42..be4a7c89 100644
--- a/src/components/chat_message/chat_message.js
+++ b/src/components/chat_message/chat_message.js
@@ -60,19 +60,6 @@ const ChatMessage = {
currentUser: state => state.users.currentUser,
restrictedNicknames: state => state.instance.restrictedNicknames
}),
- ellipsisButtonWrapperStyle () {
- let res = {
- 'opacity': this.hovered || this.menuOpened ? '1' : '0'
- }
-
- if (this.isCurrentUser) {
- res.right = '0.4rem'
- } else {
- res.left = '0.4rem'
- }
-
- return res
- },
popoverMarginStyle () {
if (this.isCurrentUser) {
return {}
diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss
index 9d7b7936..240beea4 100644
--- a/src/components/chat_message/chat_message.scss
+++ b/src/components/chat_message/chat_message.scss
@@ -117,6 +117,10 @@
color: var(--chatMessageIncomingText, $fallback--text);
}
}
+
+ .chat-message-menu {
+ left: 0.4rem;
+ }
}
.outgoing {
@@ -139,6 +143,14 @@
.chat-message-inner {
align-items: flex-end;
}
+
+ .chat-message-menu {
+ right: 0.4rem;
+ }
+ }
+
+ .visible {
+ opacity: 1;
}
}
diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue
index 872ddf70..e923d694 100644
--- a/src/components/chat_message/chat_message.vue
+++ b/src/components/chat_message/chat_message.vue
@@ -39,7 +39,7 @@
>