diff --git a/src/components/TopBar/TopBar.vue b/src/components/TopBar/TopBar.vue index 48a70d8fe67..0fb667ac982 100644 --- a/src/components/TopBar/TopBar.vue +++ b/src/components/TopBar/TopBar.vue @@ -17,7 +17,7 @@ :tabindex="0" :title="t('spreed', 'Back')" :aria-label="t('spreed', 'Back')" - @click="currentThread ? threadId = 0 : openConversationSettings()"> + @click="handleClickAvatar"> (() => { + if (contextThreadId.value) { + const threadInfo = chatExtrasStore.threads[currentToken.value]?.[contextThreadId.value] + // If threadId is set, we should compare with last message from the thread + if (threadInfo) { + return threadInfo.last?.id ?? contextThreadId.value + } + } + if (conversation.value?.lastMessage && 'id' in conversation.value.lastMessage) { return conversation.value.lastMessage.id } @@ -123,14 +131,6 @@ export function useGetMessagesProvider() { const lastKnownMessageId = chatStore.getLastKnownId(currentToken.value, { messageId: contextMessageId.value, threadId: contextThreadId.value }) - if (contextThreadId.value) { - const threadInfo = chatExtrasStore.threads[currentToken.value]?.[contextThreadId.value] - // If threadId is set, we should compare with last message from the thread - if (threadInfo?.last) { - return lastKnownMessageId >= threadInfo.last.id - } - } - return lastKnownMessageId >= conversationLastMessageId.value }) @@ -223,15 +223,17 @@ export function useGetMessagesProvider() { } const focusMessageId = getMessageIdFromHash(to.hash) - if (from.hash !== to.hash && focusMessageId !== null) { - // the hash changed, need to focus/highlight another message + if (focusMessageId !== null) { + // the hash is non-empty, need to focus/highlight another message contextMessageId.value = focusMessageId - } else if (conversation.value?.lastReadMessage && conversation.value.lastReadMessage > contextMessageId.value) { - // focus last read message first - contextMessageId.value = conversation.value.lastReadMessage } else { - // last known message in the most recent block store - contextMessageId.value = conversationLastMessageId.value + // try to focus last read message first, otherwise scroll to last known message in the most recent block store + const hasLastReadMessageInContextBelow = conversation.value?.lastReadMessage && conversation.value.lastReadMessage > contextMessageId.value + && (!contextThreadId.value || chatStore.hasMessage(to.params.token, { messageId: conversation.value.lastReadMessage, threadId: contextThreadId.value })) + + contextMessageId.value = hasLastReadMessageInContextBelow + ? conversation.value.lastReadMessage + : conversationLastMessageId.value } await checkContextAndFocusMessage(to.params.token, contextMessageId.value, contextThreadId.value) diff --git a/src/store/messagesStore.js b/src/store/messagesStore.js index b97b7a9a7f5..435e53b6ff0 100644 --- a/src/store/messagesStore.js +++ b/src/store/messagesStore.js @@ -904,6 +904,7 @@ const actions = { && message.systemMessage !== 'reaction_deleted' && message.systemMessage !== 'reaction_revoked' && message.systemMessage !== 'poll_voted' + // FIXME filter thread messages in general view ) { minimumVisible-- } @@ -995,6 +996,7 @@ const actions = { && message.systemMessage !== 'reaction_deleted' && message.systemMessage !== 'reaction_revoked' && message.systemMessage !== 'poll_voted' + // FIXME filter thread messages in general view ) { minimumVisible-- } @@ -1260,7 +1262,6 @@ const actions = { context.dispatch('processMessage', { token, message: response.data.ocs.data }) chatStore.processChatBlocks(token, [response.data.ocs.data], { mergeBy: conversationLastMessageId, - threadId: response.data.ocs.data.threadId, }) } diff --git a/src/stores/chat.ts b/src/stores/chat.ts index a6079a61c83..218fa187524 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -5,6 +5,8 @@ import type { ChatMessage, + IdMap, + TokenIdMap, TokenMap, } from '../types/index.ts' @@ -54,6 +56,7 @@ export const useChatStore = defineStore('chat', () => { const store = useStore() const chatBlocks = reactive[]>>({}) + const threadBlocks = reactive[]>>({}) /** * Returns list of messages, belonging to current context @@ -67,13 +70,13 @@ export const useChatStore = defineStore('chat', () => { } if (threadId) { - // FIXME temporary show all messages for given thread from all chat blocks - no behaviour change - const contextBlock = (messageId <= 0 || store.state.messagesStore.messages[token][messageId]?.threadId !== threadId) - ? new Set([...chatBlocks[token].flatMap((set) => [...set])]) - : chatBlocks[token].find((set) => set.has(messageId)) ?? chatBlocks[token][0] - return prepareMessagesList(token, contextBlock).filter((message) => { - return message.threadId === threadId - }) + if (!threadBlocks[token]?.[threadId]) { + return [] + } + const contextBlock = (messageId <= 0) + ? threadBlocks[token][threadId][0] + : threadBlocks[token][threadId].find((set) => set.has(messageId)) ?? threadBlocks[token][threadId][0] + return prepareMessagesList(token, contextBlock, threadId) } // Look for a set containing given context id (return first block as fallback for not found / constants) @@ -86,12 +89,16 @@ export const useChatStore = defineStore('chat', () => { /** * Returns list of messages from given set */ - function prepareMessagesList(token: string, block: Set): ChatMessage[] { + function prepareMessagesList(token: string, block: Set, threadId?: number): ChatMessage[] { return Array.from(block).sort((a, b) => a - b) .reduce((acc, id) => { const message = store.state.messagesStore.messages[token][id] + // Check for exceptions (message should not be added to the displayed list): + // - non-visible system message + // - completely deleted (expired) message + // - thread message in general view (apart from the topmost one) if (message) { - // If message is not found in the store, it's an invisible system or expired message + // FIXME filter thread messages in general view (message.id === message.threadId) acc.push(message) } return acc @@ -105,14 +112,15 @@ export const useChatStore = defineStore('chat', () => { token: string, { messageId = 0, threadId = 0 }: GetMessagesListOptions = { messageId: 0, threadId: 0 }, ): boolean { - if (!chatBlocks[token]) { - return false + if (threadId) { + if (!threadBlocks[token]?.[threadId]) { + return false + } + return threadBlocks[token][threadId].findIndex((set) => set.has(messageId)) !== -1 } - if (threadId) { - // FIXME temporary check all messages for given thread from all chat blocks - return chatBlocks[token].findIndex((set) => set.has(messageId)) !== -1 - && store.state.messagesStore.messages[token][messageId]?.threadId === threadId + if (!chatBlocks[token]) { + return false } return chatBlocks[token].findIndex((set) => set.has(messageId)) !== -1 @@ -134,14 +142,10 @@ export const useChatStore = defineStore('chat', () => { if (hasMessage(token, { messageId: threadId, threadId })) { return threadId } - // FIXME temporary check all messages for given thread from all chat blocks - const contextBlock = (messageId <= 0 || store.state.messagesStore.messages[token][messageId]?.threadId !== threadId) - ? new Set([...chatBlocks[token].flatMap((set) => [...set])]) - : chatBlocks[token].find((set) => set.has(messageId)) ?? chatBlocks[token][0] - const threadMessagesList = prepareMessagesList(token, contextBlock).filter((message) => { - return message.threadId === threadId && Number.isInteger(message.id) - }) - return Math.min(...threadMessagesList.map((message) => message.id)) + const contextBlock = (messageId <= 0) + ? threadBlocks[token][threadId][0] + : threadBlocks[token][threadId].find((set) => set.has(messageId)) ?? threadBlocks[token][threadId][0] + return Math.min(...filterNumericIds(contextBlock)) } const contextBlock = (messageId <= 0) @@ -162,14 +166,13 @@ export const useChatStore = defineStore('chat', () => { } if (threadId) { - // FIXME temporary check all messages for given thread from all chat blocks - const contextBlock = (messageId <= 0 || store.state.messagesStore.messages[token][messageId]?.threadId !== threadId) - ? new Set([...chatBlocks[token].flatMap((set) => [...set])]) - : chatBlocks[token].find((set) => set.has(messageId)) ?? chatBlocks[token][0] - const threadMessagesList = prepareMessagesList(token, contextBlock).filter((message) => { - return message.threadId === threadId && Number.isInteger(message.id) - }) - return Math.max(...threadMessagesList.map((message) => message.id)) + if (!threadBlocks[token]?.[threadId]) { + return threadId + } + const contextBlock = (messageId <= 0) + ? threadBlocks[token][threadId][0] + : threadBlocks[token][threadId].find((set) => set.has(messageId)) ?? threadBlocks[token][threadId][0] + return Math.max(...filterNumericIds(contextBlock)) } const contextBlock = (messageId <= 0) @@ -183,30 +186,80 @@ export const useChatStore = defineStore('chat', () => { * If blocks already exist, try to extend them */ function processChatBlocks(token: string, messages: ChatMessage[], options?: ProcessChatBlocksOptions): void { - const newMessageIdsSet = new Set(messages.map((message) => message.id)) + const threadIdSetsToUpdate: IdMap> = {} + const newMessageIdsSet = messages.reduce((acc, message) => { + acc.add(message.id) + if (message.isThread && message.threadId) { + if (!threadIdSetsToUpdate[message.threadId]) { + threadIdSetsToUpdate[message.threadId] = new Set() + } + threadIdSetsToUpdate[message.threadId].add(message.id) + } + return acc + }, new Set()) if (options?.threadId) { - // FIXME handle thread messages separately + processThreadBlocks(token, options.threadId, newMessageIdsSet, options) + return } - if (!chatBlocks[token]) { + if (options?.mergeBy) { + newMessageIdsSet.add(options.mergeBy) + + const threadIds = Object.keys(threadIdSetsToUpdate) + if (threadIds.length) { + const chatBlockWithMergeBy: Set | undefined = chatBlocks[token]?.find((set) => set.has(options.mergeBy!)) + if (chatBlockWithMergeBy) { + // Populate thread blocks from chat blocks + threadIds.forEach((threadId) => { + for (const messageId of chatBlockWithMergeBy) { + const message = store.state.messagesStore.messages[token][messageId] + if (message && message.threadId === +threadId) { + threadIdSetsToUpdate[threadId].add(messageId) + break + } + } + }) + } + } + } + + chatBlocks[token] = mergeAndSortChatBlocks(chatBlocks[token], newMessageIdsSet) + Object.entries(threadIdSetsToUpdate).forEach(([threadId, threadMessageIdsSet]) => { + processThreadBlocks(token, threadId, threadMessageIdsSet) + }) + } + + /** + * Populate chat blocks from given arrays of messages + * If blocks already exist, try to extend them + */ + function processThreadBlocks(token: string, threadId: string | number, threadMessagesSet: Set, options?: ProcessChatBlocksOptions): void { + if (!threadBlocks[token]) { + threadBlocks[token] = {} + } + if (!threadBlocks[token][threadId]) { // If no blocks exist, create a new one with the first message. First in array will be considered main block - chatBlocks[token] = [newMessageIdsSet] + threadBlocks[token][threadId] = [threadMessagesSet] return } if (options?.mergeBy) { - newMessageIdsSet.add(options.mergeBy) + threadMessagesSet.add(options.mergeBy) } - chatBlocks[token] = mergeAndSortChatBlocks(chatBlocks[token], newMessageIdsSet) + threadBlocks[token][threadId] = mergeAndSortChatBlocks(threadBlocks[token][threadId], threadMessagesSet) } /** * Check, if blocks are intersecting with each other, and merge them in this case * Otherwise, sort them to expected position (sorted by max id in set) */ - function mergeAndSortChatBlocks(blocks: Set[], unsortedBlock: Set): Set[] { + function mergeAndSortChatBlocks(blocks: Set[] | undefined, unsortedBlock: Set): Set[] { + if (!blocks || blocks.length === 0) { + return [unsortedBlock] + } + let isUnsortedBlockUsed = false const mergedBlocks = blocks.reduce[]>((acc, block) => { @@ -258,10 +311,22 @@ export const useChatStore = defineStore('chat', () => { */ function addMessageToChatBlocks(token: string, message: ChatMessage) { if (!chatBlocks[token]) { + // FIXME only add thread first messages chatBlocks[token] = [new Set([message.id])] } else { chatBlocks[token][0].add(message.id) } + + if (message.threadId && message.isThread) { + if (!threadBlocks[token]) { + threadBlocks[token] = {} + } + if (!threadBlocks[token][message.threadId]) { + threadBlocks[token][message.threadId] = [new Set([message.id])] + } else { + threadBlocks[token][message.threadId][0].add(message.id) + } + } } /** @@ -283,7 +348,28 @@ export const useChatStore = defineStore('chat', () => { }, []) if (chatBlocks[token].length === 0) { - purgeChatStore(token) + delete chatBlocks[token] + } + + const knownThreadIds = Object.keys(threadBlocks[token] || {}) + const newThreadBlocks: IdMap[]> = {} + for (const threadId of knownThreadIds) { + newThreadBlocks[threadId] = threadBlocks[token][threadId].reduce[]>((acc, block) => { + messageIdArray.forEach((id) => block.delete(id)) + if (block.size > 0) { + acc.push(block) + } + return acc + }, []) + if (newThreadBlocks[threadId].length === 0) { + delete newThreadBlocks[threadId] + } + } + + if (Object.keys(newThreadBlocks).length === 0) { + delete threadBlocks[token] + } else { + threadBlocks[token] = newThreadBlocks } } @@ -296,14 +382,10 @@ export const useChatStore = defineStore('chat', () => { } const deleteIndex = chatBlocks[token].findIndex((block) => Math.max(...block) < idToDelete) - if (deleteIndex === -1) { - // Not found, nothing to delete - return - } else if (deleteIndex === 0) { + if (deleteIndex === 0) { // If first block is to be deleted, remove all blocks - purgeChatStore(token) - return - } else { + delete chatBlocks[token] + } else if (deleteIndex !== -1) { // Remove all blocks with max id less than given id chatBlocks[token] = chatBlocks[token].slice(0, deleteIndex) const lastBlock = chatBlocks[token].at(-1)! @@ -313,6 +395,33 @@ export const useChatStore = defineStore('chat', () => { } } } + + const knownThreadIds = Object.keys(threadBlocks[token] || {}) + const newThreadBlocks: IdMap[]> = {} + for (const threadId of knownThreadIds) { + const deleteIndex = threadBlocks[token][threadId].findIndex((block) => Math.max(...block) < idToDelete) + if (deleteIndex === 0) { + // If first block is to be deleted, remove all blocks (simply not copying) + } else if (deleteIndex !== -1) { + // Remove all blocks with max id less than given id + newThreadBlocks[threadId] = threadBlocks[token][threadId].slice(0, deleteIndex) + const lastBlock = newThreadBlocks[threadId].at(-1)! + for (const id of lastBlock) { + if (id < idToDelete) { + lastBlock.delete(id) + } + } + } else { + // Not found, nothing to delete (copying as-is) + newThreadBlocks[threadId] = threadBlocks[token][threadId] + } + } + + if (Object.keys(newThreadBlocks).length === 0) { + delete threadBlocks[token] + } else { + threadBlocks[token] = newThreadBlocks + } } /** @@ -320,10 +429,12 @@ export const useChatStore = defineStore('chat', () => { */ function purgeChatStore(token: string) { delete chatBlocks[token] + delete threadBlocks[token] } return { chatBlocks, + threadBlocks, getMessagesList, hasMessage, diff --git a/src/types/index.ts b/src/types/index.ts index 51e63f21b5b..d60fef1c703 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -36,6 +36,8 @@ export type ApiErrorResponse = AxiosError<{ }> export type TokenMap = Record +export type IdMap = Record +export type TokenIdMap = TokenMap> type SpreedCapabilities = components['schemas']['Capabilities']