diff --git a/src/boot/routes.js b/src/boot/routes.js index 0dfb1e744..beaa144e8 100644 --- a/src/boot/routes.js +++ b/src/boot/routes.js @@ -65,6 +65,16 @@ 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 acc1ff31d..516741a33 100644 --- a/src/components/chat_message/chat_message.js +++ b/src/components/chat_message/chat_message.js @@ -75,14 +75,19 @@ const ChatMessage = { // ChatMessage only has account_id while Status has full user data return !!this.message.user }, - author() { - const accountId = this.isStatus + authorId() { + return this.isStatus ? this.message.user.id : this.message.account_id - - return this.$store.getters.findUser(accountId) + }, + author() { + return this.$store.getters.findUser(this.authorId) }, 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 }, @@ -179,11 +184,6 @@ const ChatMessage = { menuOpened: false, } }, - watch: { - focused: function (value) { - this.scrollIfFocused(value) - }, - }, methods: { onHover(bool) { this.$emit('hover', { @@ -219,22 +219,6 @@ 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 a5c0627a2..c3deac0a5 100644 --- a/src/components/chat_message/chat_message.vue +++ b/src/components/chat_message/chat_message.vue @@ -3,6 +3,7 @@ v-if="isMessage" class="chat-message-wrapper" :class="[classnames, { 'hovered-message-chain': hoveredMessageChain }]" + :id="`chatmessage-${message.id}`" @mouseover="onHover(true)" @mouseleave="onHover(false)" > @@ -74,9 +75,10 @@ > @@ -202,7 +204,7 @@ {{ ' ' }} { if (bottomedOutBeforeUpdate) { this.scrollDown() } }) }, - $route: function () { + 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() this.startFetching() }, mastoUserSocketStatus(newValue) { @@ -159,53 +219,9 @@ const Chat = { }, }, methods: { - 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() - } - }, + // Actions async readChat() { + if (this.conversationId) return // Unsupported if (!this.maxId || document.hidden) { return } @@ -225,11 +241,17 @@ const Chat = { this.lastReadMessageId = this.maxId this.newMessageCount = 0 }, - bottomedOut(offset) { - return isBottomedOut(offset) - }, - reachedTop() { - return window.scrollY <= 0 + scrollDown(options = {}) { + const { behavior = 'auto', forceRead = false } = options + this.$nextTick(() => { + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior, + }) + }) + if (forceRead) { + this.readChat() + } }, cullOlder() { const maxIndex = this.messages.length @@ -248,44 +270,6 @@ 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( @@ -305,12 +289,31 @@ const Chat = { return } - const { data: messages } = await chatMessages({ - id: this.chat.id, - maxId, - sinceId: fetchLatest ? this.maxId : null, - credentials: useOAuthStore().token, - }) + 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 + } // Clear the current chat in case we're recovering from a ws connection loss. if (isFirstFetch) { @@ -321,6 +324,9 @@ const Chat = { this.addMessages({ messages }) await nextTick() + if (isFirstFetch) { + this.scrollDown() + } const fetchOlderMessages = !!maxId if (fetchOlderMessages) { @@ -338,20 +344,22 @@ const Chat = { } }, async startFetching() { - 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) { + 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.chat) { + if (this.isConversation || this.chat) { this.$nextTick(() => { this.scrollDown({ forceRead: true }) }) @@ -365,33 +373,12 @@ 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 (message.chat_id !== this.chat.id) { + if (!this.isConversation && (message.chat_id !== this.chat.id)) { console.warn( `Chat message doesn't belong to current chat (id: ${this.chat.id})!!`, message, @@ -428,14 +415,11 @@ const Chat = { } } }, - 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 }) - }) + goBack() { + this.$router.back() }, + + // Optimistic posting (chats only) async sendMessage({ status, media, idempotencyKey }) { const params = { id: this.chat.id, @@ -515,12 +499,122 @@ const Chat = { fakeMessage.pending = false } }, - goBack() { - this.$router.push({ - name: 'chats', - params: { username: this.currentUser.screen_name }, + + // 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 }) + } }) }, + 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 8af710ae1..d8031b1a7 100644 --- a/src/components/chat_view/chat_view.scss +++ b/src/components/chat_view/chat_view.scss @@ -1,6 +1,18 @@ .chat-view { display: flex; - height: 100%; + 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; + } .chat-view-inner { height: auto; @@ -36,6 +48,10 @@ .footer { position: sticky; + display: flex; + align-items: stretch; + flex-direction: column; + padding: 0; bottom: 0; z-index: 1; } @@ -95,4 +111,11 @@ } } } + + .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 0255347a9..5ea2fd2b5 100644 --- a/src/components/chat_view/chat_view.vue +++ b/src/components/chat_view/chat_view.vue @@ -18,22 +18,39 @@ icon="chevron-left" /> -
+
+
- +
+
+ +
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index 6e868a049..0bbf01e6a 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -114,7 +114,6 @@ const conversation = { inlineDivePosition: null, loadStatusError: null, unsuspendibleIds: new Set(), - explicitReplyStatus: null, } }, created() { @@ -130,12 +129,6 @@ 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 && @@ -152,10 +145,7 @@ const conversation = { return this.displayStyle === 'tree' }, isLinearView() { - return this.displayStyle === 'linear' - }, - isChatView() { - return this.displayStyle === 'chat' + return this.displayStyle !== 'tree' }, shouldFadeAncestors() { return this.mergedConfig.conversationTreeFadeAncestors @@ -636,7 +626,6 @@ 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 cab0fdd55..99ecb338a 100644 --- a/src/components/conversation/conversation.scss +++ b/src/components/conversation/conversation.scss @@ -92,19 +92,4 @@ 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 ea1891da5..945be9749 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -176,7 +176,7 @@ />
@@ -202,52 +202,6 @@ />
-
- -
- -
{ + 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 c47e08a8b..b188d1b34 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -231,11 +231,12 @@ >
{{ $t('settings.conversation_display_linear_quick') }}
-