diff --git a/src/boot/routes.js b/src/boot/routes.js index beaa144e8..0dfb1e744 100644 --- a/src/boot/routes.js +++ b/src/boot/routes.js @@ -65,16 +65,6 @@ export default (store) => { component: ConversationPage, meta: { dontScroll: true }, }, - { - name: 'conversation2', - path: '/conversation/:statusId', - component: defineAsyncComponent( - () => 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', diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js index 516741a33..acc1ff31d 100644 --- a/src/components/chat_message/chat_message.js +++ b/src/components/chat_message/chat_message.js @@ -75,19 +75,14 @@ const ChatMessage = { // ChatMessage only has account_id while Status has full user data return !!this.message.user }, - authorId() { - return this.isStatus + author() { + const accountId = this.isStatus ? this.message.user.id : this.message.account_id - }, - author() { - return this.$store.getters.findUser(this.authorId) + + return this.$store.getters.findUser(accountId) }, isCurrentUser() { - // mini-hack/optimizaiton: - // - current user would always be in memory so if user is missing it's obviously not us - // - if anon views page then "us" pretty much doesn't exist - if (!this.author || !this.currentUser) return false return this.author.id === this.currentUser.id }, @@ -184,6 +179,11 @@ const ChatMessage = { menuOpened: false, } }, + watch: { + focused: function (value) { + this.scrollIfFocused(value) + }, + }, methods: { onHover(bool) { this.$emit('hover', { @@ -219,6 +219,22 @@ const ChatMessage = { this.hovered = false this.menuOpened = false }, + scrollIfFocused(focused) { + if (this.$el.getBoundingClientRect == null) return + if (focused) { + const rect = this.$el.getBoundingClientRect() + if (rect.top < 100) { + // Post is above screen, match its top to screen top + window.scrollBy(0, rect.top - 100) + } else if (rect.height >= window.innerHeight - 50) { + // Post we want to see is taller than screen so match its top to screen top + window.scrollBy(0, rect.top - 100) + } else if (rect.bottom > window.innerHeight - 50) { + // Post is below screen, match its bottom to screen bottom + window.scrollBy(0, rect.bottom - window.innerHeight + 50) + } + } + }, }, } diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue index c3deac0a5..a5c0627a2 100644 --- a/src/components/chat_message/chat_message.vue +++ b/src/components/chat_message/chat_message.vue @@ -3,7 +3,6 @@ v-if="isMessage" class="chat-message-wrapper" :class="[classnames, { 'hovered-message-chain': hoveredMessageChain }]" - :id="`chatmessage-${message.id}`" @mouseover="onHover(true)" @mouseleave="onHover(false)" > @@ -75,10 +74,9 @@ > @@ -204,7 +202,7 @@ {{ ' ' }} { if (bottomedOutBeforeUpdate) { this.scrollDown() } }) }, - async replyStatus(newVal) { - await nextTick() // wait for changes to propagate to postStatusForm - this.$refs.postStatusForm.update() - }, - $route: async function (newVal) { - if (this.messagesIndex[newVal.params.statusId]) { - const focused = document.getElementById(`chatmessage-${this.$route.params.statusId}`) - if (focused?.getBoundingClientRect == null) return - const bottomBoundary = window.innerHeight - this.$refs.footer.clientHeight - const topBoundary = this.$refs.header.clientHeight + document.getElementById('nav').clientHeight - const margin = Number(window.getComputedStyle(this.$refs.messageList.$el).gap.replace('px','')) - - const rect = focused.getBoundingClientRect() - const scrollAmount = (() => { - if (rect.top < topBoundary) { - // Post is above screen, match its top to screen top - return rect.top - topBoundary - margin - } else if (rect.height >= bottomBoundary) { - // Post we want to see is taller than screen so match its top to screen top - return rect.top - topBoundary - margin - } else if (rect.bottom > bottomBoundary) { - // Post is below screen, match its bottom to screen bottom - return rect.bottom - bottomBoundary + margin - } else { - return 0 - } - })() - - if (scrollAmount !== 0) { - window.scrollBy(0, scrollAmount) - } - - return - } - - this.clear() + $route: function () { this.startFetching() }, mastoUserSocketStatus(newValue) { @@ -219,9 +159,53 @@ const Chat = { }, }, methods: { - // Actions + 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() + } + }, async readChat() { - if (this.conversationId) return // Unsupported if (!this.maxId || document.hidden) { return } @@ -241,17 +225,11 @@ const Chat = { this.lastReadMessageId = this.maxId this.newMessageCount = 0 }, - scrollDown(options = {}) { - const { behavior = 'auto', forceRead = false } = options - this.$nextTick(() => { - window.scrollTo({ - top: document.documentElement.scrollHeight, - behavior, - }) - }) - if (forceRead) { - this.readChat() - } + bottomedOut(offset) { + return isBottomedOut(offset) + }, + reachedTop() { + return window.scrollY <= 0 }, cullOlder() { const maxIndex = this.messages.length @@ -270,6 +248,44 @@ const Chat = { this.messages = this.messages.slice(minIndex, maxIndex) }, + cullOlderCheck() { + window.setTimeout(() => { + if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) { + this.cullOlder() + } + }, 5000) + }, + handleScroll: throttle(function () { + if (!this.chat) { + return + } + this.lastScrollPosition = getScrollPosition() + + if (this.reachedTop()) { + this.fetchChat({ maxId: this.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), + }) + }, clear() { this.messages = this.messages.filter((m) => m.error) this.messagesIndex = this.messages.reduce( @@ -289,31 +305,12 @@ const Chat = { return } - let messages - if (this.isConversation) { - const [ - { data: status }, - { data: { ancestors, descendants } } - ] = await Promise.all([ - fetchStatus({ - id: this.statusId, - credentials: useOAuthStore().token, - }), - fetchConversation({ - id: this.statusId, - credentials: useOAuthStore().token, - }) - ]) - messages = [...ancestors, status, ...descendants] - } else { - const { data } = await chatMessages({ - id: this.chat.id, - maxId, - sinceId: fetchLatest ? this.maxId : null, - credentials: useOAuthStore().token, - }) - messages = data - } + const { data: messages } = await chatMessages({ + id: this.chat.id, + maxId, + sinceId: fetchLatest ? this.maxId : null, + credentials: useOAuthStore().token, + }) // Clear the current chat in case we're recovering from a ws connection loss. if (isFirstFetch) { @@ -324,9 +321,6 @@ const Chat = { this.addMessages({ messages }) await nextTick() - if (isFirstFetch) { - this.scrollDown() - } const fetchOlderMessages = !!maxId if (fetchOlderMessages) { @@ -344,22 +338,20 @@ const Chat = { } }, async startFetching() { - if (!this.isConversation) { - try { - const { data } = await getOrCreateChat({ - accountId: this.recipientId, - credentials: useOAuthStore().token, - }) - this.$store.commit('addNewUsers', [data.account]) - data.account = this.$store.getters.findUser(data.account.id) - this.chat = data - } catch (e) { - console.error('Error creating or getting a chat', e) - this.errorLoadingChat = true - } + try { + const { data } = await getOrCreateChat({ + accountId: this.recipientId, + credentials: useOAuthStore().token, + }) + this.$store.commit('addNewUsers', [data.account]) + data.account = this.$store.getters.findUser(data.account.id) + this.chat = data + } catch (e) { + console.error('Error creating or getting a chat', e) + this.errorLoadingChat = true } - if (this.isConversation || this.chat) { + if (this.chat) { this.$nextTick(() => { this.scrollDown({ forceRead: true }) }) @@ -373,12 +365,33 @@ const Chat = { ) this.fetchChat({ isFirstFetch: true }) }, + async deleteChatMessage({ chatId, messageId }) { + if (!this.testMode) + await deleteChatMessage({ + chatId, + messageId, + credentials: useOAuthStore().token, + }) + + this.messages = this.messages.filter((m) => m.id !== messageId) + delete this.messagesIndex[messageId] + + if (this.maxId === messageId) { + const lastMessage = maxBy(this.messages, 'id') + this.maxId = lastMessage.id + } + + if (this.minId === messageId) { + const firstMessage = minBy(this.messages, 'id') + this.minId = firstMessage.id + } + }, addMessages({ messages: newMessages }) { for (let i = 0; i < newMessages.length; i++) { const message = newMessages[i] // Sanity check - if (!this.isConversation && (message.chat_id !== this.chat.id)) { + if (message.chat_id !== this.chat.id) { console.warn( `Chat message doesn't belong to current chat (id: ${this.chat.id})!!`, message, @@ -415,11 +428,14 @@ const Chat = { } } }, - goBack() { - this.$router.back() + 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 }) + }) }, - - // Optimistic posting (chats only) async sendMessage({ status, media, idempotencyKey }) { const params = { id: this.chat.id, @@ -499,122 +515,12 @@ const Chat = { fakeMessage.pending = false } }, - - // Checks - hasReachedTop() { - return window.scrollY <= 0 - }, - cullOlderCheck() { - if (this.conversationId) return - window.setTimeout(() => { - if (isBottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) { - this.cullOlder() - } - }, 5000) - }, - - // Event handlers - onPosted(data) { - this.explicitReplyStatus = null - this.$router.push({ name: 'conversation2', params: { statusId: data.id } }) - }, - handleVisibilityChange() { - this.$nextTick(() => { - if (!document.hidden && isBottomedOut(BOTTOMED_OUT_OFFSET)) { - this.scrollDown({ forceRead: true }) - } + goBack() { + this.$router.push({ + name: 'chats', + params: { username: this.currentUser.screen_name }, }) }, - onFilesDropped() { - this.$nextTick(() => { - this.handleResize() - }) - }, - handleResize(opts = {}) { - // "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport - 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 && !isBottomedOut()) { - this.$nextTick(() => { - window.scrollBy({ top: -Math.trunc(diff) }) - }) - } - this.lastScrollPosition = getScrollPosition() - }) - }, - handleScroll: throttle(function () { - if (!this.chat) { - return - } - this.lastScrollPosition = getScrollPosition() - - if (this.hasReachedTop()) { - this.fetchChat({ maxId: this.minId }) - } else if (isBottomedOut(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), - }) - }, - 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 }) - }) - }, - - // Ugly - // TODO move to ChatMessage - async deleteChatMessage({ chatId, messageId }) { - if (!this.testMode) - await deleteChatMessage({ - chatId, - messageId, - credentials: useOAuthStore().token, - }) - - this.messages = this.messages.filter((m) => m.id !== messageId) - delete this.messagesIndex[messageId] - - if (this.maxId === messageId) { - const lastMessage = maxBy(this.messages, 'id') - this.maxId = lastMessage.id - } - - if (this.minId === messageId) { - const firstMessage = minBy(this.messages, 'id') - this.minId = firstMessage.id - } - }, }, } diff --git a/src/components/chat_view/chat_view.scss b/src/components/chat_view/chat_view.scss index d8031b1a7..8af710ae1 100644 --- a/src/components/chat_view/chat_view.scss +++ b/src/components/chat_view/chat_view.scss @@ -1,18 +1,6 @@ .chat-view { display: flex; - margin-bottom: -1em; - margin-top: -1em; - - .chat-list-wrapper { - display: flex; - flex-direction: column; - height: 100%; - } - - .top-spacer { - flex: 1 1 0; - min-height: 0; - } + height: 100%; .chat-view-inner { height: auto; @@ -48,10 +36,6 @@ .footer { position: sticky; - display: flex; - align-items: stretch; - flex-direction: column; - padding: 0; bottom: 0; z-index: 1; } @@ -111,11 +95,4 @@ } } } - - .reply-to-text { - text-align: center; - line-height: 1.2; - padding-top: 0.5em; - margin-bottom: -0.5em; - } } diff --git a/src/components/chat_view/chat_view.vue b/src/components/chat_view/chat_view.vue index 5ea2fd2b5..0255347a9 100644 --- a/src/components/chat_view/chat_view.vue +++ b/src/components/chat_view/chat_view.vue @@ -18,39 +18,22 @@ icon="chevron-left" /> -
- +
-
-
- -
+
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index 0bbf01e6a..6e868a049 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -114,6 +114,7 @@ const conversation = { inlineDivePosition: null, loadStatusError: null, unsuspendibleIds: new Set(), + explicitReplyStatus: null, } }, created() { @@ -129,6 +130,12 @@ const conversation = { const maxDepth = this.mergedConfig.maxDepthInThread - 2 return maxDepth >= 1 ? maxDepth : 1 }, + lastStatus() { + return this.conversation[this.conversation.length - 1] + }, + replyStatus() { + return this.explicitReplyStatus ?? this.lastStatus + }, streamingEnabled() { return ( this.mergedConfig.useStreamingApi && @@ -145,7 +152,10 @@ const conversation = { return this.displayStyle === 'tree' }, isLinearView() { - return this.displayStyle !== 'tree' + return this.displayStyle === 'linear' + }, + isChatView() { + return this.displayStyle === 'chat' }, shouldFadeAncestors() { return this.mergedConfig.conversationTreeFadeAncestors @@ -626,6 +636,7 @@ const conversation = { } }, onPosted(data) { + this.explicitReplyStatus = null if (this.isPage) { this.$router.push({ name: 'conversation', params: { id: data.id } }) } diff --git a/src/components/conversation/conversation.scss b/src/components/conversation/conversation.scss index 99ecb338a..cab0fdd55 100644 --- a/src/components/conversation/conversation.scss +++ b/src/components/conversation/conversation.scss @@ -92,4 +92,19 @@ backdrop-filter: var(--__panel-backdrop-filter); } } + + .chat-view-reply-form { + position: sticky; + display: flex; + flex-direction: column; + align-items: stretch; + bottom: 0; + padding: 0; + } + + .reply-to-text { + line-height: 1.2; + padding-top: 0.5em; + margin-bottom: -0.5em; + } } diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue index 945be9749..ea1891da5 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -176,7 +176,7 @@ />
@@ -202,6 +202,52 @@ />
+
+ +
+ +
{ - if (key === 'status') return - this.newStatus[key] = value - }) - }, onMentionsLineUpdate(e) { if (this.mentionsLineReadOnly) return // TODO diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index b188d1b34..c47e08a8b 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -231,12 +231,11 @@ >
{{ $t('settings.conversation_display_linear_quick') }}
+