diff --git a/src/components/MessagesList/MessagesList.vue b/src/components/MessagesList/MessagesList.vue index 1276ec70bc5..b92c8ffb2b9 100644 --- a/src/components/MessagesList/MessagesList.vue +++ b/src/components/MessagesList/MessagesList.vue @@ -670,7 +670,7 @@ export default { return } - if (!this.$store.getters.getFirstKnownMessageId(this.token)) { + if (!this.chatStore.chatBlocks[this.token]) { // This can happen if the browser is fast enough to close the sidebar // when switching from a one-to-one to a group conversation. console.debug('Ignoring handleScroll as the messages history is empty') diff --git a/src/composables/useDocumentTitle.ts b/src/composables/useDocumentTitle.ts index d0fcf38f1aa..1848bcb4add 100644 --- a/src/composables/useDocumentTitle.ts +++ b/src/composables/useDocumentTitle.ts @@ -12,6 +12,7 @@ import { useRoute, useRouter } from 'vue-router' import { useStore } from 'vuex' import { EventBus } from '../services/EventBus.ts' import { useActorStore } from '../stores/actor.ts' +import { useChatStore } from '../stores/chat.ts' import { hasCall, hasUnreadMentions } from '../utils/conversation.ts' import { useDocumentVisibility } from './useDocumentVisibility.ts' @@ -31,6 +32,8 @@ export function useDocumentTitle() { const route = useRoute() const isDocumentVisible = useDocumentVisibility() + const chatStore = useChatStore() + const defaultPageTitle = ref(getDefaultPageTitle()) const showAsterisk = ref(false) const savedLastMessageMap = ref({}) @@ -122,7 +125,7 @@ export function useDocumentTitle() { } else { // @ts-expect-error: Property 'id' does not exist on type ChatProxyMessage const lastMessageId = lastMessage.id ?? 0 - const lastKnownMessageId = store.getters.getLastKnownMessageId(token) ?? 0 + const lastKnownMessageId = Math.max(...chatStore.chatBlocks[token][0]) ?? 0 acc[token].lastMessageId = Math.max(lastMessageId, lastKnownMessageId) } acc[token].unreadMessages = unreadMessages diff --git a/src/composables/useGetMessages.ts b/src/composables/useGetMessages.ts index 69fe6555ebb..0e8eeff5197 100644 --- a/src/composables/useGetMessages.ts +++ b/src/composables/useGetMessages.ts @@ -22,6 +22,7 @@ import { START_LOCATION, useRoute, useRouter } from 'vue-router' import { useStore } from 'vuex' import { CHAT, MESSAGE } from '../constants.ts' import { EventBus } from '../services/EventBus.ts' +import { useChatStore } from '../stores/chat.ts' import { debugTimer } from '../utils/debugTimer.ts' import { useGetThreadId } from './useGetThreadId.ts' import { useGetToken } from './useGetToken.ts' @@ -55,6 +56,7 @@ export function useGetMessagesProvider() { const store = useStore() const router = useRouter() const route = useRoute() + const chatStore = useChatStore() const currentToken = useGetToken() const threadId = useGetThreadId() @@ -205,7 +207,7 @@ export function useGetMessagesProvider() { store.dispatch('setVisualLastReadMessageId', { token, id: conversation.value!.lastReadMessage }) - if (!store.getters.getFirstKnownMessageId(token)) { + if (!chatStore.chatBlocks[token]) { try { // Start from message hash or unread marker let startingMessageId = focusMessageId !== null ? focusMessageId : conversation.value!.lastReadMessage diff --git a/src/store/messagesStore.js b/src/store/messagesStore.js index 5b692868fd5..a11ed65a8dc 100644 --- a/src/store/messagesStore.js +++ b/src/store/messagesStore.js @@ -835,6 +835,8 @@ const actions = { */ purgeMessagesStore(context, token) { context.commit('purgeMessagesStore', token) + const chatStore = useChatStore() + chatStore.purgeChatStore(token) }, /** @@ -847,6 +849,8 @@ const actions = { */ clearMessagesHistory(context, { token, id }) { context.commit('clearMessagesHistory', { token, id }) + const chatStore = useChatStore() + chatStore.clearMessagesHistory(token, id) }, /** diff --git a/src/stores/__tests__/chat.spec.js b/src/stores/__tests__/chat.spec.js index 9817cbd549a..17eff5cb0a0 100644 --- a/src/stores/__tests__/chat.spec.js +++ b/src/stores/__tests__/chat.spec.js @@ -61,6 +61,22 @@ describe('chatStore', () => { jest.clearAllMocks() }) + describe('check for existence', () => { + it('returns false if chat blocks are not yet available', () => { + // Assert + expect(chatStore.hasMessage(TOKEN, { messageId: mockMessages[109].id })).toBeFalsy() + }) + + it('returns boolean whether message is known by the store', () => { + // Act + chatStore.processChatBlocks(TOKEN, chatBlockA) + + // Assert + expect(chatStore.hasMessage(TOKEN, { messageId: mockMessages[109].id })).toBeTruthy() + expect(chatStore.hasMessage(TOKEN, { messageId: mockMessages[101].id })).toBeFalsy() + }) + }) + describe('get a list of messages', () => { it('returns an array if both messages and blocks present', () => { // Arrange @@ -84,6 +100,44 @@ describe('chatStore', () => { }) }) + describe('get first and last known messages', () => { + it('returns given message id if chat blocks are not yet available', () => { + // Assert + expect(chatStore.getLastKnownId(TOKEN, { messageId: mockMessages[109].id })).toBe(mockMessages[109].id) + expect(chatStore.getFirstKnownId(TOKEN, { messageId: mockMessages[109].id })).toBe(mockMessages[109].id) + }) + + it('returns first / last known id of first block if no message id was given', () => { + // Act + chatStore.processChatBlocks(TOKEN, chatBlockA) + chatStore.processChatBlocks(TOKEN, chatBlockE) + + // Assert + expect(chatStore.getLastKnownId(TOKEN)).toBe(chatBlockA[0].id) + expect(chatStore.getFirstKnownId(TOKEN)).toBe(chatBlockE[2].id) + }) + + it('returns first / last known id of first block if no message id was given', () => { + // Act + chatStore.processChatBlocks(TOKEN, chatBlockA) + chatStore.processChatBlocks(TOKEN, chatBlockC) + + // Assert + expect(chatStore.getLastKnownId(TOKEN, { messageId: chatBlockB[0].id })).toBe(chatBlockA[0].id) + expect(chatStore.getFirstKnownId(TOKEN, { messageId: chatBlockB[0].id })).toBe(chatBlockA[1].id) + }) + + it('returns first / last known id of containing block if message id was given', () => { + // Act + chatStore.processChatBlocks(TOKEN, chatBlockA) + chatStore.processChatBlocks(TOKEN, chatBlockB) + + // Assert + expect(chatStore.getLastKnownId(TOKEN, { messageId: chatBlockB[0].id })).toBe(chatBlockB[0].id) + expect(chatStore.getFirstKnownId(TOKEN, { messageId: chatBlockB[0].id })).toBe(chatBlockB[1].id) + }) + }) + describe('process messages chunks', () => { it('creates a new block, if not created yet', () => { // Act @@ -250,4 +304,50 @@ describe('chatStore', () => { expect(chatStore.chatBlocks[TOKEN]).toBeUndefined() }) }) + + describe('cleanup messages', () => { + it('does nothing, if no blocks are created yet', () => { + // Act + chatStore.clearMessagesHistory(TOKEN, chatBlockA[0].id) + + // Assert + expect(chatStore.chatBlocks[TOKEN]).toBeUndefined() + }) + + it('does nothing, if no blocks are behind id to delete', () => { + // Arrange + chatStore.processChatBlocks(TOKEN, chatBlockA) + chatStore.processChatBlocks(TOKEN, chatBlockB) + + // Act + chatStore.clearMessagesHistory(TOKEN, chatBlockC[0].id) + + // Assert + expect(chatStore.chatBlocks[TOKEN]).toEqual([outputSet(chatBlockA), outputSet(chatBlockB)]) + }) + + it('purges a store, if all blocks are behind id to delete', () => { + // Arrange + chatStore.processChatBlocks(TOKEN, chatBlockB) + chatStore.processChatBlocks(TOKEN, chatBlockC) + + // Act + chatStore.clearMessagesHistory(TOKEN, chatBlockA[0].id) + + // Assert + expect(chatStore.chatBlocks[TOKEN]).toBeUndefined() + }) + + it('cleans up messages behind id to delete', () => { + // Arrange + chatStore.processChatBlocks(TOKEN, chatBlockB) + chatStore.processChatBlocks(TOKEN, chatBlockC) + + // Act + chatStore.clearMessagesHistory(TOKEN, chatBlockB[0].id) + + // Assert + expect(chatStore.chatBlocks[TOKEN]).toEqual([outputSet([chatBlockB[0]])]) + }) + }) }) diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 531e1a0a362..7f777b2fe7d 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -12,6 +12,13 @@ import { defineStore } from 'pinia' import { reactive } from 'vue' import { useStore } from 'vuex' +type GetMessagesListOptions = { + /** if given, look for Set that has it */ + messageId?: number + /** if given, look for thread Set */ + threadId?: number +} + type ProcessChatBlocksOptions = { /** if given, look for Set that has it */ mergeBy?: number @@ -46,8 +53,19 @@ export const useChatStore = defineStore('chat', () => { return [] } - // FIXME temporary show all messages from al blocks - no behaviour change - return Array.from(chatBlocks[token].flatMap((set) => Array.from(set))) + // FIXME temporary show all messages for given thread from all chat blocks - no behaviour change + const contextBlock = chatBlocks[token].reduce>((acc, set) => { + set.forEach((id) => acc.add(id)) + return acc + }, new Set()) + return prepareMessagesList(token, contextBlock) + } + + /** + * Returns list of messages from given set + */ + function prepareMessagesList(token: string, block: Set): ChatMessage[] { + return Array.from(block).sort((a, b) => a - b) .sort((a, b) => a - b) .reduce((acc, id) => { const message = store.state.messagesStore.messages[token][id] @@ -59,13 +77,89 @@ export const useChatStore = defineStore('chat', () => { }, []) } + /** + * Returns whether message is known in any of blocks (then it exists in store) + */ + function hasMessage( + token: string, + { messageId = 0, threadId = 0 }: GetMessagesListOptions = { messageId: 0, threadId: 0 }, + ): boolean { + if (!chatBlocks[token]) { + return false + } + + if (threadId) { + // FIXME temporary check all messages for given thread from all chat blocks + return chatBlocks[token].findIndex((set) => set.has(messageId)) !== -1 + } + + return chatBlocks[token].findIndex((set) => set.has(messageId)) !== -1 + } + + /** + * Returns first known message id, belonging to current context. Defaults to given messageId + */ + function getFirstKnownId( + token: string, + { messageId = 0, threadId = 0 }: GetMessagesListOptions = { messageId: 0, threadId: 0 }, + ): number { + if (!chatBlocks[token]) { + return messageId + } + + if (threadId) { + // If topmost message of thread is in the store, return its id + if (hasMessage(token, { messageId: threadId, threadId })) { + return threadId + } + // FIXME temporary check all messages for given thread from all chat blocks + return Math.min(...prepareMessagesList(token, new Set(Array.from(chatBlocks[token].flatMap((set) => Array.from(set))))) + .filter((message) => { + return message.threadId === threadId + }).map((message) => message.id)) + } + + if (messageId <= 0) { + return Math.min(...chatBlocks[token][0]) + } + + const contextBlock = chatBlocks[token].find((set) => set.has(messageId)) + return contextBlock ? Math.min(...contextBlock) : Math.min(...chatBlocks[token][0]) + } + + /** + * Returns last known message id, belonging to current context. Defaults to given messageId + */ + function getLastKnownId( + token: string, + { messageId = 0, threadId = 0 }: GetMessagesListOptions = { messageId: 0, threadId: 0 }, + ): number { + if (!chatBlocks[token]) { + return messageId + } + + if (threadId) { + // FIXME temporary check all messages for given thread from all chat blocks + return Math.max(...prepareMessagesList(token, new Set(Array.from(chatBlocks[token].flatMap((set) => Array.from(set))))) + .filter((message) => { + return message.threadId === threadId + }).map((message) => message.id)) + } + + if (messageId <= 0) { + return Math.max(...chatBlocks[token][0]) + } + + const contextBlock = chatBlocks[token].find((set) => set.has(messageId)) + return contextBlock ? Math.max(...contextBlock) : Math.max(...chatBlocks[token][0]) + } + /** * Populate chat blocks from given arrays of messages * If blocks already exist, try to extend them */ function processChatBlocks(token: string, messages: ChatMessage[], options?: ProcessChatBlocksOptions): void { - const newMessageIds = messages.map((message) => message.id) - const newMessageIdsSet = new Set(newMessageIds) + const newMessageIdsSet = new Set(messages.map((message) => message.id)) if (!chatBlocks[token]) { // If no blocks exist, create a new one with the first message. First in array will be considered main block @@ -161,16 +255,56 @@ export const useChatStore = defineStore('chat', () => { }, []) if (chatBlocks[token].length === 0) { - delete chatBlocks[token] + purgeChatStore(token) } } + /** + * Clears the messages entry from the store for the given token starting from defined id + */ + function clearMessagesHistory(token: string, idToDelete: number) { + if (!chatBlocks[token]) { + return + } + + const deleteIndex = chatBlocks[token].findIndex((block) => Math.max(...block) < idToDelete) + if (deleteIndex === -1) { + // Not found, nothing to delete + return + } else if (deleteIndex === 0) { + // If first block is to be deleted, remove all blocks + purgeChatStore(token) + return + } else { + // Remove all blocks with max id less than given id + chatBlocks[token] = chatBlocks[token].slice(0, deleteIndex) + const lastBlock = chatBlocks[token].at(-1)! + for (const id of lastBlock) { + if (id < idToDelete) { + lastBlock.delete(id) + } + } + } + } + + /** + * Clears the store for the given token + */ + function purgeChatStore(token: string) { + delete chatBlocks[token] + } + return { chatBlocks, getMessagesList, + hasMessage, + getFirstKnownId, + getLastKnownId, processChatBlocks, addMessageToChatBlocks, removeMessagesFromChatBlocks, + clearMessagesHistory, + purgeChatStore, } })