Repository: nextcloud/spreed
Pull request #15202: fix: reset shared items from a deleted message
Author: login: DorraJaouad
Labels: name: bug
name: feature: frontend 🖌️
description: "Web UI" client
name: feature: upload & shares & voice 📤🎙️
description: Sharing files into a chat and audio recordings
name: feature: conversations 👥
Created: 2025-05-23T08:42:18Z | Merged: 2025-05-26T08:15:18Z

### ☑️ Resolves


## 🖌️ UI Checklist

### 🖼️ Screenshots / Screencasts

🏚️ Before | 🏡 After
-- | --
![image](https://github.com/user-attachments/assets/5d8673e6-62c8-493a-bbd4-f5292e5d6398) | ![image](https://github.com/user-attachments/assets/a63a380c-e3ba-4148-97cd-e7b81bddea87)
<!-- ☀️ Light theme | 🌑 Dark Theme -->



NOTE/ When to refresh for expiring messages?


### 🏁 Checklist

- [ ] 🌏 Tested with different browsers / clients:
  - [x] Chromium (Chrome / Edge / Opera / Brave)
  - [ ] Firefox
  - [ ] Safari
  - [ ] Talk Desktop
  - [ ] Not risky to browser differences / client
- [ ] 🖌️ Design was reviewed, approved or inspired by the design team
- [ ] ⛑️ Tests are included or not possible
- [ ] 📗 User documentation in https://github.com/nextcloud/documentation/tree/master/user_manual/talk has been updated or is not required




Diff:
diff --git a/src/store/conversationsStore.js b/src/store/conversationsStore.js
index d5a9373be07..0b29d925512 100644
--- a/src/store/conversationsStore.js
+++ b/src/store/conversationsStore.js
@@ -68,6 +68,7 @@ import { useChatExtrasStore } from '../stores/chatExtras.js'
 import { useFederationStore } from '../stores/federation.ts'
 import { useGroupwareStore } from '../stores/groupware.ts'
 import { useReactionsStore } from '../stores/reactions.js'
+import { useSharedItemsStore } from '../stores/sharedItems.js'
 import { useTalkHashStore } from '../stores/talkHash.js'
 import { convertToUnix } from '../utils/formattedTime.ts'
 import { getDisplayNamesList } from '../utils/getDisplayName.ts'
@@ -389,6 +390,8 @@ const actions = {
 		groupwareStore.purgeGroupwareStore(token)
 		const reactionsStore = useReactionsStore()
 		reactionsStore.purgeReactionsStore(token)
+		const sharedItemsStore = useSharedItemsStore()
+		sharedItemsStore.purgeSharedItemsStore(token)
 		context.dispatch('purgeMessagesStore', token)
 		context.commit('deleteConversation', token)
 		context.dispatch('purgeParticipantsStore', token)
@@ -514,6 +517,8 @@ const actions = {
 			chatExtrasStore.removeParentIdToReply(token)
 			const reactionsStore = useReactionsStore()
 			reactionsStore.purgeReactionsStore(token)
+			const sharedItemsStore = useSharedItemsStore()
+			sharedItemsStore.purgeSharedItemsStore(token)
 			context.dispatch('purgeMessagesStore', token)
 			return response
 		} catch (error) {
diff --git a/src/store/messagesStore.js b/src/store/messagesStore.js
index 4fc8a213c4b..3f2413f6248 100644
--- a/src/store/messagesStore.js
+++ b/src/store/messagesStore.js
@@ -561,6 +561,7 @@ const actions = {
 			const reactionsStore = useReactionsStore()
 			if (message.systemMessage === 'message_deleted') {
 				reactionsStore.resetReactions(token, message.parent.id)
+				sharedItemsStore.deleteSharedItemFromMessage(token, message.parent.id)
 			} else {
 				reactionsStore.processReaction(token, message)
 			}
@@ -626,6 +627,7 @@ const actions = {
 		}
 
 		if (message.systemMessage === 'history_cleared') {
+			sharedItemsStore.purgeSharedItemsStore(token, message.id)
 			context.commit('clearMessagesHistory', {
 				token,
 				id: message.id,
diff --git a/src/stores/__tests__/sharedItems.spec.js b/src/stores/__tests__/sharedItems.spec.js
index de2c758a43b..057d5576974 100644
--- a/src/stores/__tests__/sharedItems.spec.js
+++ b/src/stores/__tests__/sharedItems.spec.js
@@ -211,4 +211,76 @@ describe('sharedItemsStore', () => {
 			expect(sharedItemsStore.sharedItems(token)).toEqual(result)
 		})
 	})
+
+	describe('cleanup operations', () => {
+		it('deletes shared items of a message from store', () => {
+			// Arrange: add some shared items to the store
+			const message1 = {
+				id: 1,
+				token,
+				message: '{file}',
+				messageParameters: { file: { mimetype: 'image/jpeg' } },
+			}
+			const message2 = {
+				id: 2,
+				token,
+				message: '{file}',
+				messageParameters: { file: { mimetype: 'image/jpeg' } },
+			}
+			sharedItemsStore.addSharedItemFromMessage(token, message1)
+			sharedItemsStore.addSharedItemFromMessage(token, message2)
+			expect(sharedItemsStore.sharedItems(token)).toEqual({ media: { 1: message1, 2: message2 } })
+			// Act: delete shared items from the store
+			sharedItemsStore.deleteSharedItemFromMessage(token, message1.id)
+			// Assert: shared items store should not contain deleted item
+			expect(sharedItemsStore.sharedItems(token)).toEqual({ media: { 2: message2 } })
+		})
+
+		it('purges the store when e.g. a chat history is cleared', () => {
+			// Arrange: add some shared items to the store
+			const message1 = {
+				id: 1,
+				token,
+				message: '{file}',
+				messageParameters: { file: { mimetype: 'image/jpeg' } },
+			}
+			sharedItemsStore.addSharedItemFromMessage(token, message1)
+			expect(sharedItemsStore.sharedItems(token)).toEqual({ media: { 1: message1 } })
+			// Act: purge the store
+			sharedItemsStore.purgeSharedItemsStore(token)
+			// Assert: shared items store should be empty
+			expect(sharedItemsStore.sharedItems(token)).toEqual({})
+		})
+
+		it('purges shared items from store starting from a particular message id backwards', () => {
+			// Arrange:
+			const messages = [{
+				id: 1,
+				token,
+				message: '{file}',
+				messageParameters: { file: { mimetype: 'image/jpeg' } },
+			}, {
+				id: 2,
+				token,
+				message: '{file}',
+				messageParameters: { file: { mimetype: 'image/jpeg' } },
+			}, {
+				id: 3,
+				token,
+				message: 'history cleared',
+				systemMessage: 'history_cleared',
+			}, {
+				id: 4,
+				token,
+				message: '{file}',
+				messageParameters: { file: { mimetype: 'image/jpeg' } },
+			}]
+			messages.filter((message) => !message.systemMessage).forEach((message) => sharedItemsStore.addSharedItemFromMessage(token, message))
+			expect(sharedItemsStore.sharedItems(token)).toEqual({ media: { 1: messages[0], 2: messages[1], 4: messages[3] } })
+			// Act: purge shared items from the store by message id
+			sharedItemsStore.purgeSharedItemsStore(token, messages[2].id)
+			// Assert: shared items store should not contain items from the deleted message
+			expect(sharedItemsStore.sharedItems(token)).toEqual({ media: { 4: messages[3] } })
+		})
+	})
 })
diff --git a/src/stores/sharedItems.js b/src/stores/sharedItems.js
index 132369dbc97..4fb0cafa496 100644
--- a/src/stores/sharedItems.js
+++ b/src/stores/sharedItems.js
@@ -94,6 +94,25 @@ export const useSharedItemsStore = defineStore('sharedItems', {
 			}
 		},
 
+		/**
+		 * @param {Token} token conversation token
+		 * @param {string} messageId id of message to be deleted
+		 */
+		deleteSharedItemFromMessage(token, messageId) {
+			if (!this.sharedItemsPool[token]) {
+				return
+			}
+
+			for (const type of Object.keys(this.sharedItemsPool[token])) {
+				if (this.sharedItemsPool[token][type][messageId]) {
+					Vue.delete(this.sharedItemsPool[token][type], messageId)
+					if (Object.keys(this.sharedItemsPool[token][type]).length === 0) {
+						Vue.delete(this.sharedItemsPool[token], type)
+					}
+				}
+			}
+		},
+
 		/**
 		 * @param {Token} token conversation token
 		 * @param {Type} type type of shared item
@@ -109,6 +128,35 @@ export const useSharedItemsStore = defineStore('sharedItems', {
 			})
 		},
 
+		/**
+		 * @param {Token} token conversation token
+		 * @param {string} messageId starting message id to purge shared items from older messages
+		 * If messageId is not provided, all shared items in this conversation will be deleted.
+		 */
+		purgeSharedItemsStore(token, messageId = null) {
+			if (!this.sharedItemsPool[token]) {
+				return
+			}
+			if (messageId) {
+				// Delete older messages starting from messageId
+				for (const type of Object.keys(this.sharedItemsPool[token])) {
+					for (const id of Object.keys(this.sharedItemsPool[token][type])) {
+						if (+id < +messageId) {
+							Vue.delete(this.sharedItemsPool[token][type], id)
+						}
+					}
+					if (Object.keys(this.sharedItemsPool[token][type]).length === 0) {
+						Vue.delete(this.sharedItemsPool[token], type)
+					}
+				}
+				if (Object.keys(this.sharedItemsPool[token]).length === 0) {
+					Vue.delete(this.sharedItemsPool, token)
+				}
+			} else {
+				Vue.delete(this.sharedItemsPool, token)
+			}
+		},
+
 		/**
 		 * @param {Token} token conversation token
 		 * @param {Type} type type of shared item


Reviews:
user: login: Antreesy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T09:16:16Z
user: login: Antreesy
body: Good to go
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-26T07:46:31Z

Review comments:
diff_hunk: @@ -95,6 +95,25 @@ export const useSharedItemsStore = defineStore('sharedItems', {
 			}
 		},
 
+		/**
+		 * @param {Token} token conversation token
+		 * @param {string} messageId id of message to be deleted
+		 */
+		deleteSharedItemFromMessage(token, messageId) {
path: src/stores/sharedItems.js
user: login: Antreesy
body: Maybe, add a little test for new actions?
created_at: 2025-05-23T09:03:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -563,6 +563,7 @@ const actions = {
 			const reactionsStore = useReactionsStore()
 			if (message.systemMessage === 'message_deleted') {
path: src/store/messagesStore.js
user: login: Antreesy
body: Also need to clean sharedItemsStore for `message.systemMessage === 'history_cleared'`:
- not for yourself, but for other participants
- same as in messagesStore, it should not remove newer messages (`messageToRemoveId < systemMessage .id`)
created_at: 2025-05-23T09:13:12Z
author_association: CONTRIBUTOR

PR comments:
user: login: nickvergessen
created_at: 2025-05-23T17:25:56Z
body: /backport to stable31
author_association: MEMBER
user: login: nickvergessen
created_at: 2025-05-23T17:26:01Z
body: /backport to stable30
author_association: MEMBER