diff --git a/changelog.d/chat_vew.add b/changelog.d/chat_vew.add new file mode 100644 index 000000000..7e649d454 --- /dev/null +++ b/changelog.d/chat_vew.add @@ -0,0 +1 @@ +Chat view for threads diff --git a/src/App.js b/src/App.js index 934b96713..4ce6f1abc 100644 --- a/src/App.js +++ b/src/App.js @@ -186,7 +186,11 @@ export default { return useShoutStore().joined }, isChats() { - return this.$route.name === 'chat' || this.$route.name === 'chats' + return ( + this.$route.name === 'chat' || + this.$route.name === 'chats' || + this.$route.name === 'conversation2' + ) }, isListEdit() { return this.$route.name === 'lists-edit' @@ -205,7 +209,7 @@ export default { ) }, hideShoutbox() { - return useMergedConfigStore().mergedConfig.hideShoutbox + return this.isChats || useMergedConfigStore().mergedConfig.hideShoutbox }, reverseLayout() { const { thirdColumnMode, sidebarRight: reverseSetting } = diff --git a/src/api/chats.js b/src/api/chats.js index 114038e52..5d766dec4 100644 --- a/src/api/chats.js +++ b/src/api/chats.js @@ -1,6 +1,9 @@ import { paramsString, promisedRequest } from './helpers.js' -import { parseChat } from 'src/services/entity_normalizer/entity_normalizer.service.js' +import { + parseChat, + parseChatMessage, +} from 'src/services/entity_normalizer/entity_normalizer.service.js' const PLEROMA_CHATS_URL = '/api/v1/pleroma/chats' const PLEROMA_CHAT_URL = (id) => `/api/v1/pleroma/chats/by-account-id/${id}` @@ -15,7 +18,7 @@ export const chats = ({ credentials }) => url: PLEROMA_CHATS_URL, credentials, }).then(({ data }) => ({ - chatList: data.map(parseChat).filter((c) => c), + data: data.map(parseChat).filter((c) => c), })) export const getOrCreateChat = ({ accountId, credentials }) => @@ -23,7 +26,7 @@ export const getOrCreateChat = ({ accountId, credentials }) => url: PLEROMA_CHAT_URL(accountId), method: 'POST', credentials, - }) + }).then(({ data }) => ({ data: parseChat(data) })) export const chatMessages = ({ id, @@ -36,7 +39,9 @@ export const chatMessages = ({ url: PLEROMA_CHAT_MESSAGES_URL(id, { maxId, sinceId, limit }), method: 'GET', credentials, - }) + }).then(({ data }) => ({ + data: data.map(parseChatMessage).filter((c) => c), + })) } export const sendChatMessage = ({ @@ -66,7 +71,9 @@ export const sendChatMessage = ({ payload, credentials, headers, - }) + }).then(({ data }) => ({ + data: parseChatMessage(data), + })) } export const readChat = ({ id, lastReadId, credentials }) => diff --git a/src/api/user.js b/src/api/user.js index 17e13195d..9a55a6b93 100644 --- a/src/api/user.js +++ b/src/api/user.js @@ -207,7 +207,7 @@ export const postStatus = ({ idempotencyKey, }) => { const form = new FormData() - const pollOptions = poll.options || [] + const pollOptions = poll?.options || [] form.append('status', status) form.append('source', 'Pleroma FE') @@ -266,7 +266,7 @@ export const editStatus = ({ contentType, }) => { const form = new FormData() - const pollOptions = poll.options || [] + const pollOptions = poll?.options || [] form.append('status', status) if (spoilerText) form.append('spoiler_text', spoilerText) diff --git a/src/boot/routes.js b/src/boot/routes.js index d50baab04..5897fad92 100644 --- a/src/boot/routes.js +++ b/src/boot/routes.js @@ -1,5 +1,3 @@ -import { defineAsyncComponent } from 'vue' - import AuthForm from 'src/components/auth_form/auth_form.js' import BookmarkTimeline from 'src/components/bookmark_timeline/bookmark_timeline.vue' import BubbleTimeline from 'src/components/bubble_timeline/bubble_timeline.vue' @@ -65,6 +63,14 @@ export default (store) => { component: ConversationPage, meta: { dontScroll: true }, }, + { + name: 'conversation2', + path: '/conversation/:statusId', + component: () => import('src/components/chat_view/chat_view.vue'), + props: true, + meta: { dontScroll: true }, + beforeEnter: validateAuthenticatedRoute, + }, { name: 'quotes', path: '/notice/:id/quotes', component: QuotesTimeline }, { name: 'remote-user-profile-acct', @@ -81,23 +87,18 @@ export default (store) => { { name: 'external-user-profile', path: '/users/$:id', - component: defineAsyncComponent( - () => import('src/components/user_profile/user_profile.vue'), - ), + component: () => import('src/components/user_profile/user_profile.vue'), }, { name: 'user-profile-admin-view', path: '/users/$:id/admin_view', - component: defineAsyncComponent( - () => import('src/components/user_profile/user_profile_admin_view.vue'), - ), + component: () => + import('src/components/user_profile/user_profile_admin_view.vue'), }, { name: 'interactions', path: '/users/:username/interactions', - component: defineAsyncComponent( - () => import('src/components/interactions/interactions.vue'), - ), + component: () => import('src/components/interactions/interactions.vue'), beforeEnter: validateAuthenticatedRoute, }, { @@ -109,39 +110,31 @@ export default (store) => { { name: 'registration', path: '/registration', - component: defineAsyncComponent( - () => import('src/components/registration/registration.vue'), - ), + component: () => import('src/components/registration/registration.vue'), }, { name: 'password-reset', path: '/password-reset', - component: defineAsyncComponent( - () => import('src/components/password_reset/password_reset.vue'), - ), + component: () => + import('src/components/password_reset/password_reset.vue'), props: true, }, { name: 'registration-token', path: '/registration/:token', - component: defineAsyncComponent( - () => import('src/components/registration/registration.vue'), - ), + component: () => import('src/components/registration/registration.vue'), }, { name: 'friend-requests', path: '/friend-requests', - component: defineAsyncComponent( - () => import('src/components/follow_requests/follow_requests.vue'), - ), + component: () => + import('src/components/follow_requests/follow_requests.vue'), beforeEnter: validateAuthenticatedRoute, }, { name: 'notifications', path: '/:username/notifications', - component: defineAsyncComponent( - () => import('src/components/notifications/notifications.vue'), - ), + component: () => import('src/components/notifications/notifications.vue'), props: () => ({ disableTeleport: true }), beforeEnter: validateAuthenticatedRoute, }, @@ -153,98 +146,74 @@ export default (store) => { { name: 'shout-panel', path: '/shout-panel', - component: defineAsyncComponent( - () => import('src/components/shout_panel/shout_panel.vue'), - ), + component: () => import('src/components/shout_panel/shout_panel.vue'), props: () => ({ floating: false }), }, { name: 'oauth-callback', path: '/oauth-callback', - component: defineAsyncComponent( - () => import('src/components/oauth_callback/oauth_callback.vue'), - ), + component: () => + import('src/components/oauth_callback/oauth_callback.vue'), props: (route) => ({ code: route.query.code }), }, { name: 'search', path: '/search', - component: defineAsyncComponent( - () => import('src/components/search/search.vue'), - ), + component: () => import('src/components/search/search.vue'), props: (route) => ({ query: route.query.query }), }, { name: 'who-to-follow', path: '/who-to-follow', - component: defineAsyncComponent( - () => import('src/components/who_to_follow/who_to_follow.vue'), - ), + component: () => import('src/components/who_to_follow/who_to_follow.vue'), beforeEnter: validateAuthenticatedRoute, }, { name: 'about', path: '/about', - component: defineAsyncComponent( - () => import('src/components/about/about.vue'), - ), + component: () => import('src/components/about/about.vue'), }, { name: 'announcements', path: '/announcements', - component: defineAsyncComponent( - () => - import('src/components/announcements_page/announcements_page.vue'), - ), + component: () => + import('src/components/announcements_page/announcements_page.vue'), }, { name: 'drafts', path: '/drafts', - component: defineAsyncComponent( - () => import('src/components/drafts/drafts.vue'), - ), + component: () => import('src/components/drafts/drafts.vue'), }, { name: 'user-profile', path: '/users/:name', - component: defineAsyncComponent( - () => import('src/components/user_profile/user_profile.vue'), - ), + component: () => import('src/components/user_profile/user_profile.vue'), }, { name: 'legacy-user-profile', path: '/:name', - component: defineAsyncComponent( - () => import('src/components/user_profile/user_profile.vue'), - ), + component: () => import('src/components/user_profile/user_profile.vue'), }, { name: 'lists', path: '/lists', - component: defineAsyncComponent( - () => import('src/components/lists/lists.vue'), - ), + component: () => import('src/components/lists/lists.vue'), }, { name: 'lists-timeline', path: '/lists/:id', - component: defineAsyncComponent( - () => import('src/components/lists_timeline/lists_timeline.vue'), - ), + component: () => + import('src/components/lists_timeline/lists_timeline.vue'), }, { name: 'lists-edit', path: '/lists/:id/edit', - component: defineAsyncComponent( - () => import('src/components/lists_edit/lists_edit.vue'), - ), + component: () => import('src/components/lists_edit/lists_edit.vue'), }, { name: 'lists-new', path: '/lists/new', - component: defineAsyncComponent( - () => import('src/components/lists_edit/lists_edit.vue'), - ), + component: () => import('src/components/lists_edit/lists_edit.vue'), }, { name: 'edit-navigation', @@ -256,19 +225,14 @@ export default (store) => { { name: 'bookmark-folders', path: '/bookmark_folders', - component: defineAsyncComponent( - () => import('src/components/bookmark_folders/bookmark_folders.vue'), - ), + component: () => + import('src/components/bookmark_folders/bookmark_folders.vue'), }, { name: 'bookmark-folder-new', path: '/bookmarks/new-folder', - component: defineAsyncComponent( - () => - import( - 'src/components/bookmark_folder_edit/bookmark_folder_edit.vue' - ), - ), + component: () => + import('src/components/bookmark_folder_edit/bookmark_folder_edit.vue'), }, { name: 'bookmark-folder', @@ -278,12 +242,8 @@ export default (store) => { { name: 'bookmark-folder-edit', path: '/bookmarks/:id/edit', - component: defineAsyncComponent( - () => - import( - 'src/components/bookmark_folder_edit/bookmark_folder_edit.vue' - ), - ), + component: () => + import('src/components/bookmark_folder_edit/bookmark_folder_edit.vue'), }, ] @@ -291,19 +251,16 @@ export default (store) => { routes = routes.concat([ { name: 'chat', - path: '/users/:username/chats/:recipient_id', - component: defineAsyncComponent( - () => import('src/components/chat/chat.vue'), - ), + path: '/users/:username/chats/:chatUserId', + component: () => import('src/components/chat_view/chat_view.vue'), meta: { dontScroll: false }, + props: true, beforeEnter: validateAuthenticatedRoute, }, { name: 'chats', path: '/users/:username/chats', - component: defineAsyncComponent( - () => import('src/components/chat_list/chat_list.vue'), - ), + component: () => import('src/components/chat_list/chat_list.vue'), meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute, }, diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js deleted file mode 100644 index ca7a025ec..000000000 --- a/src/components/chat/chat.js +++ /dev/null @@ -1,433 +0,0 @@ -import { throttle } from 'lodash' -import { mapState as mapPiniaState } from 'pinia' -import { mapGetters, mapState } from 'vuex' - -import ChatMessage from 'src/components/chat_message/chat_message.vue' -import ChatTitle from 'src/components/chat_title/chat_title.vue' -import PostStatusForm from 'src/components/post_status_form/post_status_form.vue' -import chatService from '../../services/chat_service/chat_service.js' -import { buildFakeMessage } from '../../services/chat_utils/chat_utils.js' -import { promiseInterval } from '../../services/promise_interval/promise_interval.js' -import { - getNewTopPosition, - getScrollPosition, - isBottomedOut, - isScrollable, -} from './chat_layout_utils.js' - -import { useInterfaceStore } from 'src/stores/interface.js' -import { useMergedConfigStore } from 'src/stores/merged_config.js' -import { useOAuthStore } from 'src/stores/oauth.js' - -import { - chatMessages, - getOrCreateChat, - sendChatMessage, -} from 'src/api/chats.js' -import { WSConnectionStatus } from 'src/api/websocket.js' - -import { library } from '@fortawesome/fontawesome-svg-core' -import { faChevronDown, faChevronLeft } from '@fortawesome/free-solid-svg-icons' - -library.add(faChevronDown, faChevronLeft) - -const BOTTOMED_OUT_OFFSET = 10 -const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 10 -const SAFE_RESIZE_TIME_OFFSET = 100 -const MARK_AS_READ_DELAY = 1500 -const MAX_RETRIES = 10 - -const Chat = { - components: { - ChatMessage, - ChatTitle, - PostStatusForm, - }, - data() { - return { - jumpToBottomButtonVisible: false, - hoveredMessageChainId: undefined, - lastScrollPosition: {}, - scrollableContainerHeight: '100%', - errorLoadingChat: false, - messageRetriers: {}, - } - }, - created() { - this.startFetching() - window.addEventListener('resize', this.handleResize) - }, - mounted() { - window.addEventListener('scroll', this.handleScroll) - if (typeof document.hidden !== 'undefined') { - document.addEventListener( - 'visibilitychange', - this.handleVisibilityChange, - false, - ) - } - - this.$nextTick(() => { - this.handleResize() - }) - }, - unmounted() { - window.removeEventListener('scroll', this.handleScroll) - window.removeEventListener('resize', this.handleResize) - if (typeof document.hidden !== 'undefined') - document.removeEventListener( - 'visibilitychange', - this.handleVisibilityChange, - false, - ) - this.$store.dispatch('clearCurrentChat') - }, - 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_ui, - }) - } 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', - ]), - ...mapPiniaState(useMergedConfigStore, ['mergedConfig']), - ...mapPiniaState(useInterfaceStore, { - mobileLayout: (store) => store.layoutType === 'mobile', - }), - ...mapState({ - mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus, - 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() - } - }) - }, - $route: function () { - this.startFetching() - }, - mastoUserSocketStatus(newValue) { - if (newValue === WSConnectionStatus.JOINED) { - this.fetchChat({ isFirstFetch: true }) - } - }, - }, - methods: { - // Used to animate the avatar near the first message of the message chain when any message belonging to the chain is hovered - onMessageHover({ isHovered, messageChainId }) { - this.hoveredMessageChainId = isHovered ? messageChainId : undefined - }, - onFilesDropped() { - this.$nextTick(() => { - this.handleResize() - }) - }, - handleVisibilityChange() { - this.$nextTick(() => { - if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) { - this.scrollDown({ forceRead: true }) - } - }) - }, - // "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport - handleResize(opts = {}) { - const { delayed = false } = opts - - if (delayed) { - setTimeout(() => { - this.handleResize({ ...opts, delayed: false }) - }, SAFE_RESIZE_TIME_OFFSET) - return - } - - this.$nextTick(() => { - const { offsetHeight = undefined } = getScrollPosition() - const diff = offsetHeight - this.lastScrollPosition.offsetHeight - if (diff !== 0 && !this.bottomedOut()) { - this.$nextTick(() => { - window.scrollBy({ top: -Math.trunc(diff) }) - }) - } - this.lastScrollPosition = getScrollPosition() - }) - }, - scrollDown(options = {}) { - const { behavior = 'auto', forceRead = false } = options - this.$nextTick(() => { - window.scrollTo({ - top: document.documentElement.scrollHeight, - behavior, - }) - }) - if (forceRead) { - this.readChat() - } - }, - readChat() { - if ( - !( - this.currentChatMessageService && this.currentChatMessageService.maxId - ) - ) { - return - } - if (document.hidden) { - return - } - const lastReadId = this.currentChatMessageService.maxId - this.$store.dispatch('readChat', { - id: this.currentChat.id, - lastReadId, - }) - }, - bottomedOut(offset) { - return isBottomedOut(offset) - }, - reachedTop() { - return window.scrollY <= 0 - }, - cullOlderCheck() { - window.setTimeout(() => { - if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) { - this.$store.dispatch( - 'cullOlderMessages', - this.currentChatMessageService.chatId, - ) - } - }, 5000) - }, - handleScroll: throttle(function () { - this.lastScrollPosition = getScrollPosition() - 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 - this.cullOlderCheck() - if (this.newMessageCount > 0) { - // Use a delay before marking as read to prevent situation where new messages - // arrive just as you're leaving the view and messages that you didn't actually - // get to see get marked as read. - window.setTimeout(() => { - // Don't mark as read if the element doesn't exist, user has left chat view - if (this.$el) this.readChat() - }, MARK_AS_READ_DELAY) - } - } else { - this.jumpToBottomButtonVisible = true - } - }, 200), - handleScrollUp(positionBeforeLoading) { - const positionAfterLoading = getScrollPosition() - window.scrollTo({ - top: getNewTopPosition(positionBeforeLoading, positionAfterLoading), - }) - }, - 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.maxId - - return chatMessages({ - id: chatId, - maxId, - sinceId, - credentials: useOAuthStore().token, - }).then(({ data: messages }) => { - // Clear the current chat in case we're recovering from a ws connection loss. - if (isFirstFetch) { - chatService.clear(chatMessageService) - } - - const positionBeforeUpdate = getScrollPosition() - this.$store - .dispatch('addChatMessages', { chatId, messages }) - .then(() => { - this.$nextTick(() => { - if (fetchOlderMessages) { - this.handleScrollUp(positionBeforeUpdate) - } - - // In vertical screens, the first batch of fetched messages may not always take the - // full height of the scrollable container. - // If this is the case, we want to fetch the messages until the scrollable container - // is fully populated so that the user has the ability to scroll up and load the history. - if (!isScrollable() && messages.length > 0) { - this.fetchChat({ - maxId: this.currentChatMessageService.minId, - }) - } - }) - }) - }) - }, - async startFetching() { - let chat = this.findOpenedChatByRecipientId(this.recipientId) - if (!chat) { - try { - const { data } = await getOrCreateChat({ - accountId: this.recipientId, - credentials: useOAuthStore().token, - }) - chat = data - } 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: () => - promiseInterval(() => this.fetchChat({ fetchLatest: true }), 5000), - }) - this.fetchChat({ isFirstFetch: true }) - }, - handleAttachmentPosting() { - this.$nextTick(() => { - this.handleResize() - // When the posting form size changes because of a media attachment, we need an extra resize - // to account for the potential delay in the DOM update. - this.scrollDown({ forceRead: true }) - }) - }, - sendMessage({ status, media, idempotencyKey }) { - const params = { - id: this.currentChat.id, - content: status, - idempotencyKey, - } - - if (media[0]) { - params.mediaId = media[0].id - } - - const fakeMessage = buildFakeMessage({ - attachments: media, - chatId: this.currentChat.id, - content: status, - userId: this.currentUser.id, - idempotencyKey, - }) - - this.$store - .dispatch('addChatMessages', { - chatId: this.currentChat.id, - messages: [fakeMessage], - }) - .then(() => { - this.handleAttachmentPosting() - }) - - return this.doSendMessage({ - params, - fakeMessage, - retriesLeft: MAX_RETRIES, - }) - }, - doSendMessage({ params, fakeMessage, retriesLeft = MAX_RETRIES }) { - if (retriesLeft <= 0) return - - sendChatMessage({ - ...params, - credentials: useOAuthStore().token, - }) - .then(({ data }) => { - this.$store.dispatch('addChatMessages', { - chatId: this.currentChat.id, - updateMaxId: false, - messages: [{ ...data, fakeId: fakeMessage.id }], - }) - - return data - }) - .catch((error) => { - console.error('Error sending message', error) - this.$store.dispatch('handleMessageError', { - chatId: this.currentChat.id, - fakeId: fakeMessage.id, - isRetry: retriesLeft !== MAX_RETRIES, - }) - if ( - (error.statusCode >= 500 && error.statusCode < 600) || - error.message === 'Failed to fetch' - ) { - this.messageRetriers[fakeMessage.id] = setTimeout( - () => { - this.doSendMessage({ - params, - fakeMessage, - retriesLeft: retriesLeft - 1, - }) - }, - 1000 * 2 ** (MAX_RETRIES - retriesLeft), - ) - } - return {} - }) - - return Promise.resolve(fakeMessage) - }, - goBack() { - this.$router.push({ - name: 'chats', - params: { username: this.currentUser.screen_name }, - }) - }, - }, -} - -export default Chat diff --git a/src/components/chat/chat.vue b/src/components/chat/chat.vue deleted file mode 100644 index cedbdce69..000000000 --- a/src/components/chat/chat.vue +++ /dev/null @@ -1,99 +0,0 @@ - - - - +