fix(useGetMessages): extract shared methods

Signed-off-by: Maksim Sukharev <antreesy.web@gmail.com>
This commit is contained in:
Maksim Sukharev
2025-07-16 17:03:43 +02:00
parent e30e7afce7
commit 3d80c13cdd
2 changed files with 232 additions and 198 deletions

View File

@ -66,7 +66,6 @@
</template>
<script>
import Axios from '@nextcloud/axios'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { n, t } from '@nextcloud/l10n'
import moment from '@nextcloud/moment'
@ -85,10 +84,9 @@ import { useDocumentVisibility } from '../../composables/useDocumentVisibility.t
import { useGetMessages } from '../../composables/useGetMessages.ts'
import { useGetThreadId } from '../../composables/useGetThreadId.ts'
import { useIsInCall } from '../../composables/useIsInCall.js'
import { ATTENDEE, CHAT, CONVERSATION, MESSAGE } from '../../constants.ts'
import { ATTENDEE, CONVERSATION } from '../../constants.ts'
import { EventBus } from '../../services/EventBus.ts'
import { useChatExtrasStore } from '../../stores/chatExtras.ts'
import { debugTimer } from '../../utils/debugTimer.ts'
import { convertToUnix, ONE_DAY_IN_MS } from '../../utils/formattedTime.ts'
const SCROLL_TOLERANCE = 10
@ -135,15 +133,16 @@ export default {
setup(props) {
const {
pollingErrorTimeout,
loadingOldMessages,
isInitialisingMessages,
destroying,
stopFetchingOldMessages,
isParticipant,
isInLobby,
chatIdentifier,
isChatBeginningReached,
handleStartGettingMessagesPreconditions,
getMessageContext,
getOldMessages,
pollNewMessages,
} = useGetMessages()
const isDocumentVisible = useDocumentVisibility()
@ -156,15 +155,16 @@ export default {
isChatVisible,
threadId,
pollingErrorTimeout,
loadingOldMessages,
isInitialisingMessages,
destroying,
stopFetchingOldMessages,
isParticipant,
isInLobby,
chatIdentifier,
isChatBeginningReached,
handleStartGettingMessagesPreconditions,
getMessageContext,
getOldMessages,
pollNewMessages,
}
},
@ -271,6 +271,16 @@ export default {
}
},
isInitialisingMessages(newValue, oldValue) {
if (oldValue && !newValue) { // switching true -> false
this.$nextTick(() => {
// basically scrolling to either the last read message or the message in the URL anchor
// and there is a fallback to scroll to the bottom if the message is not found
this.scrollToFocusedMessage(this.getMessageIdFromHash())
})
}
},
chatIdentifier: {
immediate: true,
handler(newValue, oldValue) {
@ -660,187 +670,6 @@ export default {
this.debounceUpdateReadMarkerPosition()
},
async handleStartGettingMessagesPreconditions(token) {
if (token && this.isParticipant && !this.isInLobby) {
// prevent sticky mode before we have loaded anything
this.isInitialisingMessages = true
const focusMessageId = this.getMessageIdFromHash()
this.$store.dispatch('setVisualLastReadMessageId', { token, id: this.conversation.lastReadMessage })
if (!this.$store.getters.getFirstKnownMessageId(token)) {
try {
// Start from message hash or unread marker
let startingMessageId = focusMessageId !== null ? focusMessageId : this.conversation.lastReadMessage
// Check if thread is initially opened
if (this.threadId) {
// FIXME temporary get thread messages from the start
startingMessageId = this.threadId
}
// First time load, initialize important properties
if (!startingMessageId) {
throw new Error(`[DEBUG] spreed: context message ID is ${startingMessageId}`)
}
this.$store.dispatch('setFirstKnownMessageId', { token, id: startingMessageId })
this.$store.dispatch('setLastKnownMessageId', { token, id: startingMessageId })
// If MESSAGE.CHAT_BEGIN_ID we need to get the context from the beginning
// using 0 as the API does not support negative values
// Get chat messages before last read message and after it
await this.getMessageContext(token, startingMessageId !== MESSAGE.CHAT_BEGIN_ID ? startingMessageId : 0)
} catch (exception) {
console.debug(exception)
// Request was cancelled, stop getting preconditions and restore initial state
this.$store.dispatch('setFirstKnownMessageId', { token, id: null })
this.$store.dispatch('setLastKnownMessageId', { token, id: null })
return
}
}
this.$nextTick(() => {
// basically scrolling to either the last read message or the message in the URL anchor
// and there is a fallback to scroll to the bottom if the message is not found
this.scrollToFocusedMessage(focusMessageId)
})
this.isInitialisingMessages = false
// Once the history is received, starts looking for new messages.
await this.pollNewMessages(token)
} else {
this.$store.dispatch('cancelPollNewMessages', { requestId: this.chatIdentifier })
}
},
async getMessageContext(token, messageId) {
// Make the request
this.loadingOldMessages = true
try {
debugTimer.start(`${token} | get context`)
await this.$store.dispatch('getMessageContext', {
token,
messageId,
minimumVisible: CHAT.MINIMUM_VISIBLE,
})
debugTimer.end(`${token} | get context`, 'status 200')
this.loadingOldMessages = false
} catch (exception) {
if (Axios.isCancel(exception)) {
console.debug('The request has been canceled', exception)
debugTimer.end(`${token} | get context`, 'cancelled')
this.loadingOldMessages = false
throw exception
}
if (exception?.response?.status === 304 && exception?.response?.data === '') {
// 304 - Not modified
// Empty chat, no messages to load
debugTimer.end(`${token} | get context`, 'status 304')
this.$store.dispatch('loadedMessagesOfConversation', { token: this.token })
this.stopFetchingOldMessages = true
}
}
this.loadingOldMessages = false
},
/**
* Get messages history.
*
* @param {boolean} includeLastKnown Include or exclude the last known message in the response
*/
async getOldMessages(includeLastKnown) {
if (this.isChatBeginningReached) {
// Beginning of the chat reached, no more messages to load
return
}
// Make the request
this.loadingOldMessages = true
try {
debugTimer.start(`${this.token} | fetch history`)
await this.$store.dispatch('fetchMessages', {
token: this.token,
lastKnownMessageId: this.$store.getters.getFirstKnownMessageId(this.token),
includeLastKnown,
minimumVisible: CHAT.MINIMUM_VISIBLE,
})
debugTimer.end(`${this.token} | fetch history`, 'status 200')
} catch (exception) {
if (Axios.isCancel(exception)) {
debugTimer.end(`${this.token} | fetch history`, 'cancelled')
console.debug('The request has been canceled', exception)
}
if (exception?.response?.status === 304) {
// 304 - Not modified
debugTimer.end(`${this.token} | fetch history`, 'status 304')
this.stopFetchingOldMessages = true
}
}
this.loadingOldMessages = false
},
/**
* Fetches the messages of a conversation given the conversation token.
* Creates a long polling request for new messages.
* @param token token of conversation where a method was called
*/
async pollNewMessages(token) {
if (this.destroying) {
console.debug('Prevent polling new messages on MessagesList being destroyed')
return
}
// Check that the token has not changed
if (this.token !== token) {
console.debug(`token has changed to ${this.token}, breaking the loop for ${token}`)
return
}
// Make the request
try {
debugTimer.start(`${token} | long polling`)
// TODO: move polling logic to the store and also cancel timers on cancel
this.pollingErrorTimeout = 1
await this.$store.dispatch('pollNewMessages', {
token,
lastKnownMessageId: this.$store.getters.getLastKnownMessageId(token),
requestId: this.chatIdentifier,
})
debugTimer.end(`${token} | long polling`, 'status 200')
} catch (exception) {
if (Axios.isCancel(exception)) {
debugTimer.end(`${token} | long polling`, 'cancelled')
console.debug('The request has been canceled', exception)
return
}
if (exception?.response?.status === 304) {
debugTimer.end(`${token} | long polling`, 'status 304')
// 304 - Not modified
// This is not an error, so reset error timeout and poll again
this.pollingErrorTimeout = 1
setTimeout(() => {
this.pollNewMessages(token)
}, 500)
return
}
if (this.pollingErrorTimeout < 30) {
// Delay longer after each error
this.pollingErrorTimeout += 5
}
debugTimer.end(`${token} | long polling`, `status ${exception?.response?.status}`)
console.debug('Error happened while getting chat messages. Trying again in ', this.pollingErrorTimeout, exception)
setTimeout(() => {
this.pollNewMessages(token)
}, this.pollingErrorTimeout * 1000)
return
}
setTimeout(() => {
this.pollNewMessages(token)
}, 500)
},
checkSticky() {
const ulElements = this.$refs['dateGroup-' + this.token]
if (!ulElements) {

View File

@ -3,27 +3,43 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { AxiosError } from '@nextcloud/axios'
import type {
ChatMessage,
Conversation,
} from '../types/index.ts'
import Axios from '@nextcloud/axios'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useStore } from 'vuex'
import { MESSAGE } from '../constants.ts'
import { CHAT, MESSAGE } from '../constants.ts'
import { debugTimer } from '../utils/debugTimer.ts'
import { useGetThreadId } from './useGetThreadId.ts'
import { useGetToken } from './useGetToken.ts'
/**
* Check whether caught error is from OCS API
*/
function isAxiosErrorResponse(exception: unknown): exception is AxiosError<string> {
return exception !== null && typeof exception === 'object' && 'response' in exception
}
let isUnmounting = false
/**
* Composable to provide control logic for fetching messages list
*/
export function useGetMessagesProvider() {
const store = useStore()
const route = useRoute()
const currentToken = useGetToken()
const threadId = useGetThreadId()
const conversation = computed<Conversation | undefined>(() => store.getters.conversation(currentToken.value))
const isInLobby = computed<boolean>(() => store.getters.isInLobby)
const pollingErrorTimeout = ref(1)
const destroying = ref(false)
const loadingOldMessages = ref(false)
const isInitialisingMessages = ref(false)
@ -51,16 +67,205 @@ export function useGetMessagesProvider() {
&& ['conversation_created', 'history_cleared'].includes(firstKnownMessage.value.systemMessage))
})
/**
* Initialize chat context borders and start fetching messages
* @param token token of conversation where a method was called
*/
async function handleStartGettingMessagesPreconditions(token: string) {
if (token && isParticipant.value && !isInLobby.value) {
// prevent sticky mode before we have loaded anything
isInitialisingMessages.value = true
const focusMessageId = route?.hash?.startsWith('#message_') ? parseInt(route.hash.slice(9), 10) : null
store.dispatch('setVisualLastReadMessageId', { token, id: conversation.value!.lastReadMessage })
if (!store.getters.getFirstKnownMessageId(token)) {
try {
// Start from message hash or unread marker
let startingMessageId = focusMessageId !== null ? focusMessageId : conversation.value!.lastReadMessage
// Check if thread is initially opened
if (threadId.value) {
// FIXME temporary get thread messages from the start
startingMessageId = threadId.value
}
// First time load, initialize important properties
if (!startingMessageId) {
throw new Error(`[DEBUG] spreed: context message ID is ${startingMessageId}`)
}
store.dispatch('setFirstKnownMessageId', { token, id: startingMessageId })
store.dispatch('setLastKnownMessageId', { token, id: startingMessageId })
// If MESSAGE.CHAT_BEGIN_ID we need to get the context from the beginning
// using 0 as the API does not support negative values
// Get chat messages before last read message and after it
await getMessageContext(token, startingMessageId !== MESSAGE.CHAT_BEGIN_ID ? startingMessageId : 0)
} catch (exception) {
console.debug(exception)
// Request was cancelled, stop getting preconditions and restore initial state
store.dispatch('setFirstKnownMessageId', { token, id: null })
store.dispatch('setLastKnownMessageId', { token, id: null })
return
}
}
isInitialisingMessages.value = false
// Once the history is received, starts looking for new messages.
await pollNewMessages(token)
} else {
store.dispatch('cancelPollNewMessages', { requestId: chatIdentifier.value })
}
}
/**
* Fetches the messages of a conversation given the conversation token.
* Creates a long polling request for new messages.
* @param token token of conversation where a method was called
* @param messageId messageId
*/
async function getMessageContext(token: string, messageId: number) {
// Make the request
loadingOldMessages.value = true
try {
debugTimer.start(`${token} | get context`)
await store.dispatch('getMessageContext', {
token,
messageId,
minimumVisible: CHAT.MINIMUM_VISIBLE,
})
debugTimer.end(`${token} | get context`, 'status 200')
loadingOldMessages.value = false
} catch (exception) {
if (Axios.isCancel(exception)) {
console.debug('The request has been canceled', exception)
debugTimer.end(`${token} | get context`, 'cancelled')
loadingOldMessages.value = false
throw exception
}
if (isAxiosErrorResponse(exception) && exception.response?.status === 304) {
// 304 - Not modified
// Empty chat, no messages to load
debugTimer.end(`${token} | get context`, 'status 304')
store.dispatch('loadedMessagesOfConversation', { token })
stopFetchingOldMessages.value = true
}
}
loadingOldMessages.value = false
}
/**
* Get messages history.
*
* @param includeLastKnown Include or exclude the last known message in the response
*/
async function getOldMessages(includeLastKnown: boolean) {
if (isChatBeginningReached.value) {
// Beginning of the chat reached, no more messages to load
return
}
// Make the request
loadingOldMessages.value = true
try {
debugTimer.start(`${currentToken.value} | fetch history`)
await store.dispatch('fetchMessages', {
token: currentToken.value,
lastKnownMessageId: store.getters.getFirstKnownMessageId(currentToken.value),
includeLastKnown,
minimumVisible: CHAT.MINIMUM_VISIBLE,
})
debugTimer.end(`${currentToken.value} | fetch history`, 'status 200')
} catch (exception) {
if (Axios.isCancel(exception)) {
debugTimer.end(`${currentToken.value} | fetch history`, 'cancelled')
console.debug('The request has been canceled', exception)
}
if (isAxiosErrorResponse(exception) && exception?.response?.status === 304) {
// 304 - Not modified
debugTimer.end(`${currentToken.value} | fetch history`, 'status 304')
stopFetchingOldMessages.value = true
}
}
loadingOldMessages.value = false
}
/**
* Fetches the messages of a conversation given the conversation token.
* Creates a long polling request for new messages.
* @param token token of conversation where a method was called
*/
async function pollNewMessages(token: string) {
if (isUnmounting) {
console.debug('Prevent polling new messages on MessagesList being destroyed')
return
}
// Check that the token has not changed
if (currentToken.value !== token) {
console.debug(`token has changed to ${currentToken.value}, breaking the loop for ${token}`)
return
}
// Make the request
try {
debugTimer.start(`${token} | long polling`)
// TODO: move polling logic to the store and also cancel timers on cancel
pollingErrorTimeout.value = 1
await store.dispatch('pollNewMessages', {
token,
lastKnownMessageId: store.getters.getLastKnownMessageId(token),
requestId: chatIdentifier.value,
})
debugTimer.end(`${token} | long polling`, 'status 200')
} catch (exception) {
if (Axios.isCancel(exception)) {
debugTimer.end(`${token} | long polling`, 'cancelled')
console.debug('The request has been canceled', exception)
return
}
if (isAxiosErrorResponse(exception) && exception?.response?.status === 304) {
debugTimer.end(`${token} | long polling`, 'status 304')
// 304 - Not modified
// This is not an error, so reset error timeout and poll again
pollingErrorTimeout.value = 1
setTimeout(() => {
pollNewMessages(token)
}, 500)
return
}
if (pollingErrorTimeout.value < 30) {
// Delay longer after each error
pollingErrorTimeout.value += 5
}
debugTimer.end(`${token} | long polling`, `status ${isAxiosErrorResponse(exception) ? exception?.response?.status : 'unknown'}`)
console.debug('Error happened while getting chat messages. Trying again in ', pollingErrorTimeout.value, exception)
setTimeout(() => {
pollNewMessages(token)
}, pollingErrorTimeout.value * 1000)
return
}
setTimeout(() => {
pollNewMessages(token)
}, 500)
}
return {
pollingErrorTimeout,
loadingOldMessages,
isInitialisingMessages,
destroying,
stopFetchingOldMessages,
isParticipant,
isInLobby,
chatIdentifier,
isChatBeginningReached,
handleStartGettingMessagesPreconditions,
getMessageContext,
getOldMessages,
pollNewMessages,
}
}