From b5762a618d1430e04b7540d8de38f533b9f87c82 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Thu, 20 Aug 2026 15:26:53 +0200 Subject: [PATCH 1/4] fix(upload): construct referenceId for grouped uploads support - construct referenceId in the following format: - /[a-f0-9]{60}-[0-9]{3}/, where: - /[a-f0-9]{60}/ - uploadId hashed with SHA-256 algorithm - /-/ - mandatory delimiter - /[0-9]{3}/ - order of file in given upload (natural integers, padded with zeroes) Signed-off-by: Maksim Sukharev --- src/stores/upload.ts | 10 ++++--- .../__tests__/prepareTemporaryMessage.spec.js | 27 ++++++++++--------- src/utils/prepareTemporaryMessage.ts | 26 +++++++++++++++--- 3 files changed, 44 insertions(+), 19 deletions(-) diff --git a/src/stores/upload.ts b/src/stores/upload.ts index dcc01a1c99d..e1a3b4d84c8 100644 --- a/src/stores/upload.ts +++ b/src/stores/upload.ts @@ -325,6 +325,10 @@ export const useUploadStore = defineStore('upload', () => { function initialiseUpload({ uploadId, token, threadId, files, rename = false, isVoiceMessage }: { uploadId: string, token: string, threadId?: number, files: File[], rename?: boolean, isVoiceMessage?: boolean }) { // Set last upload id currentUploadId.value = uploadId + const stagedFilesMaxIndex = Math.max( + 0, + ...getUploadsArray(uploadId).map(([index, _uploadedFile]) => Number(index.split('_').pop())), + ) for (let i = 0; i < files.length; i++) { let file = files[i] @@ -342,9 +346,9 @@ export const useUploadStore = defineStore('upload', () => { ? URL.createObjectURL(file) : undefined - // Create a unique index for each file - const date = new Date() - const index = 'temp_' + date.getTime() + Math.random() + // Create a unique index for each file (append _1, _2, ... at the end to track file order) + const index = `temp_${uploadId}_${stagedFilesMaxIndex + 1 + i}` + // Create temporary message for the file and add it to the message list const temporaryMessage = createTemporaryMessage({ message: '{file}', diff --git a/src/utils/__tests__/prepareTemporaryMessage.spec.js b/src/utils/__tests__/prepareTemporaryMessage.spec.js index 07139d58d15..ac1742ee28b 100644 --- a/src/utils/__tests__/prepareTemporaryMessage.spec.js +++ b/src/utils/__tests__/prepareTemporaryMessage.spec.js @@ -10,6 +10,7 @@ import { prepareTemporaryMessage } from '../prepareTemporaryMessage.ts' describe('prepareTemporaryMessage', () => { const TOKEN = 'XXTOKENXX' + const UPLOAD_ID = String(new Date('2020-01-01T20:00:00').getTime()) beforeEach(() => { vi.useFakeTimers().setSystemTime(new Date('2020-01-01T20:00:00')) @@ -63,27 +64,28 @@ describe('prepareTemporaryMessage', () => { const textFilePayload = { ...defaultPayload, message: '{file}', - uploadId: 'upload-id-1', - index: 'upload-index-1', + uploadId: UPLOAD_ID, + index: `temp_${UPLOAD_ID}_1`, file: textFile, localUrl: 'local-url://original-name.txt', } const textFileResult = { ...defaultResult, - id: expect.stringMatching(/^temp-1577908800000-upload-id-1-0\.[0-9]*$/), + id: `temp-${UPLOAD_ID}-001`, message: '{file}', messageParameters: { file: { type: 'file', file: textFile, mimetype: 'text/plain', - id: expect.stringMatching(/^temp-1577908800000-upload-id-1-0\.[0-9]*$/), + id: `temp-${UPLOAD_ID}-001`, name: 'new-name.txt', - uploadId: 'upload-id-1', + uploadId: UPLOAD_ID, localUrl: 'local-url://original-name.txt', - index: 'upload-index-1', + index: `temp_${UPLOAD_ID}_1`, }, }, + referenceId: expect.stringMatching(/^[a-f0-9]{60}-[0-9]{3}$/), } const audioFile = { @@ -94,14 +96,14 @@ describe('prepareTemporaryMessage', () => { ...defaultPayload, message: '{file}', messageType: MESSAGE.TYPE.VOICE_MESSAGE, - uploadId: 'upload-id-1', - index: 'upload-index-1', + uploadId: UPLOAD_ID, + index: `temp_${UPLOAD_ID}_1`, file: audioFile, localUrl: 'local-url://original-name.txt', } const audioFileResult = { ...defaultResult, - id: expect.stringMatching(/^temp-1577908800000-upload-id-1-0\.[0-9]*$/), + id: `temp-${UPLOAD_ID}-001`, message: '{file}', messageType: MESSAGE.TYPE.VOICE_MESSAGE, messageParameters: { @@ -109,13 +111,14 @@ describe('prepareTemporaryMessage', () => { type: 'file', file: audioFile, mimetype: 'audio/wav', - id: expect.stringMatching(/^temp-1577908800000-upload-id-1-0\.[0-9]*$/), + id: `temp-${UPLOAD_ID}-001`, name: 'Voice message 2020-01-01 20-00-00 (Note to self).wav', - uploadId: 'upload-id-1', + uploadId: UPLOAD_ID, localUrl: 'local-url://original-name.txt', - index: 'upload-index-1', + index: `temp_${UPLOAD_ID}_1`, }, }, + referenceId: expect.stringMatching(/^[a-f0-9]{60}-[0-9]{3}$/), } const threadPayload = { ...defaultPayload, diff --git a/src/utils/prepareTemporaryMessage.ts b/src/utils/prepareTemporaryMessage.ts index 8f959e1b245..0a9f0a65fbb 100644 --- a/src/utils/prepareTemporaryMessage.ts +++ b/src/utils/prepareTemporaryMessage.ts @@ -65,7 +65,7 @@ export type TempChatMessageWithFile = Omit & { * @param payload.message message string; * @param payload.token conversation token; * @param payload.uploadId upload id; - * @param payload.index index; + * @param payload.index index of file. must be provided with file and uploadId; * @param payload.file file to upload; * @param payload.localUrl local URL of file to upload; * @param payload.messageType specify when the temporary file is a voice message @@ -98,10 +98,25 @@ export function prepareTemporaryMessage({ isThread, }: PrepareTemporaryMessagePayload): ChatMessage | TempChatMessageWithFile { const date = new Date() - let tempId = 'temp-' + date.getTime() + let tempId: string = 'temp-' + let referenceId: string const messageParameters: ChatMessage['messageParameters'] = {} if (file) { - tempId += '-' + uploadId + '-' + Math.random() + if (!index || !uploadId) { + throw new Error('[prepareTemporaryMessage]: index/uploadId is required for file messages') + } + const appendedIndex = index.split('_').pop()!.padStart(3, '0') + tempId += uploadId + '-' + appendedIndex + + /** + * Construct file share message referenceId in the following format: + * /[a-f0-9]{60}-[0-9]{3}/, where: + * /[a-f0-9]{60}/ - uploadId hashed with SHA-256 algorithm + * /-/ - mandatory delimiter + * /[0-9]{3}/ - order of file in given upload (natural integers, padded with zeroes) + */ + referenceId = Hex.stringify(SHA256(uploadId)).slice(0, 60) + '-' + appendedIndex + messageParameters.file = { type: 'file', // @ts-expect-error: 'file' does not exist in type RichObjectParameter @@ -114,6 +129,9 @@ export function prepareTemporaryMessage({ localUrl, index, } + } else { + tempId += date.getTime() + referenceId = Hex.stringify(SHA256(tempId)) } if (parent && 'token' in parent && parent.token !== token) { @@ -140,7 +158,7 @@ export function prepareTemporaryMessage({ parent, isReplyable: false, reactions: {}, - referenceId: Hex.stringify(SHA256(tempId)), + referenceId, actorId, actorType, actorDisplayName, From bcaddeafb527e718889b0fbb03487434475d70e5 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Thu, 20 Aug 2026 15:27:16 +0200 Subject: [PATCH 2/4] fix(chat): combine file messages by referenceId, not timestamp Signed-off-by: Maksim Sukharev --- src/utils/combineFileMessages.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/utils/combineFileMessages.ts b/src/utils/combineFileMessages.ts index d522d0a2918..bbe2ed48c88 100644 --- a/src/utils/combineFileMessages.ts +++ b/src/utils/combineFileMessages.ts @@ -49,6 +49,22 @@ function isCombinableFileMessage(message: ChatMessage): boolean { return file.mimetype !== 'text/vcard' && !file.mimetype.startsWith('audio/') } +/** + * Checks whether file share message referenceId follows the group pattern + * (see prepareTemporaryMessage.ts for the format) + * + * @param id referenceId + * @return uploadHash (repeating part for single context upload) or original referenceId + */ +function getUploadHashFromReferenceId(id: string): string { + const [uploadHash, order] = id.split('-') + if (uploadHash.length === 60 && Number.isInteger(+order)) { + return uploadHash + } else { + return id + } +} + /** * Check whether two file shares belong to each other: they are replies to the same message * (or no replies at all) and they were shared together in a single context @@ -58,10 +74,8 @@ function isCombinableFileMessage(message: ChatMessage): boolean { */ function canBeCombinedWith(message1: ChatMessage, message2: ChatMessage): boolean { return message1.parent?.id === message2.parent?.id - // FIXME the timestamp is not a reliable indicator here, should instead - // create referenceId differently (e.g. as `${SHA(uploadId) + SHA(Math.random())}`) - // and base splitting on this (should be aligned with mobile clients as well) - && message1.timestamp - message2.timestamp <= 30 + && !!message1.referenceId && !!message2.referenceId + && getUploadHashFromReferenceId(message1.referenceId) === getUploadHashFromReferenceId(message2.referenceId) } /** From 23578d455017910e5c0c3d41e755fb321683b26f Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Tue, 25 Aug 2026 10:44:18 +0200 Subject: [PATCH 3/4] docs: document new referenceId format in ResponseDefinitions.php Signed-off-by: Maksim Sukharev --- lib/ResponseDefinitions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 06ef67e8b49..ae5e78352ce 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -231,7 +231,7 @@ * reactions: array|\stdClass, * // When the user reacted this is the list of emojis the user reacted with * reactionsSelf?: list, - * // A reference string that was given while posting the message to be able to identify a sent message again (only available with `chat-reference-id` capability) + * // A reference string that was given while posting the message to be able to identify a sent message again (only available with `chat-reference-id` capability). For grouped file uploads support, expected format is `{sha256(uploadId)}-{order}`, matching /^[a-f0-9]{60}-[0-9]{3}$/ * referenceId: string, * // Timestamp in seconds and UTC time zone * timestamp: int, From 5cc9556e24d19e3857f96c37fe05d6dbeb09704f Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Tue, 25 Aug 2026 10:46:20 +0200 Subject: [PATCH 4/4] chore(assets): Recompile assets Signed-off-by: Maksim Sukharev --- openapi-backend-sipbridge.json | 2 +- openapi-federation.json | 2 +- openapi-full.json | 2 +- openapi.json | 2 +- src/types/openapi/openapi-backend-sipbridge.ts | 2 +- src/types/openapi/openapi-federation.ts | 2 +- src/types/openapi/openapi-full.ts | 2 +- src/types/openapi/openapi.ts | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/openapi-backend-sipbridge.json b/openapi-backend-sipbridge.json index e14c21d174d..8db562493ad 100644 --- a/openapi-backend-sipbridge.json +++ b/openapi-backend-sipbridge.json @@ -598,7 +598,7 @@ }, "referenceId": { "type": "string", - "description": "A reference string that was given while posting the message to be able to identify a sent message again (only available with `chat-reference-id` capability)" + "description": "A reference string that was given while posting the message to be able to identify a sent message again (only available with `chat-reference-id` capability). For grouped file uploads support, expected format is `{sha256(uploadId)}-{order}`, matching /^[a-f0-9]{60}-[0-9]{3}$/" }, "timestamp": { "type": "integer", diff --git a/openapi-federation.json b/openapi-federation.json index 83f8c612c5a..eb4237f3ebf 100644 --- a/openapi-federation.json +++ b/openapi-federation.json @@ -598,7 +598,7 @@ }, "referenceId": { "type": "string", - "description": "A reference string that was given while posting the message to be able to identify a sent message again (only available with `chat-reference-id` capability)" + "description": "A reference string that was given while posting the message to be able to identify a sent message again (only available with `chat-reference-id` capability). For grouped file uploads support, expected format is `{sha256(uploadId)}-{order}`, matching /^[a-f0-9]{60}-[0-9]{3}$/" }, "timestamp": { "type": "integer", diff --git a/openapi-full.json b/openapi-full.json index 4c7c3394d86..e0e48a2fc24 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -845,7 +845,7 @@ }, "referenceId": { "type": "string", - "description": "A reference string that was given while posting the message to be able to identify a sent message again (only available with `chat-reference-id` capability)" + "description": "A reference string that was given while posting the message to be able to identify a sent message again (only available with `chat-reference-id` capability). For grouped file uploads support, expected format is `{sha256(uploadId)}-{order}`, matching /^[a-f0-9]{60}-[0-9]{3}$/" }, "timestamp": { "type": "integer", diff --git a/openapi.json b/openapi.json index c357132b4ba..27e5bda1a4b 100644 --- a/openapi.json +++ b/openapi.json @@ -785,7 +785,7 @@ }, "referenceId": { "type": "string", - "description": "A reference string that was given while posting the message to be able to identify a sent message again (only available with `chat-reference-id` capability)" + "description": "A reference string that was given while posting the message to be able to identify a sent message again (only available with `chat-reference-id` capability). For grouped file uploads support, expected format is `{sha256(uploadId)}-{order}`, matching /^[a-f0-9]{60}-[0-9]{3}$/" }, "timestamp": { "type": "integer", diff --git a/src/types/openapi/openapi-backend-sipbridge.ts b/src/types/openapi/openapi-backend-sipbridge.ts index 7a480cd5f69..9a993d07425 100644 --- a/src/types/openapi/openapi-backend-sipbridge.ts +++ b/src/types/openapi/openapi-backend-sipbridge.ts @@ -393,7 +393,7 @@ export type components = { }; /** @description When the user reacted this is the list of emojis the user reacted with */ reactionsSelf?: string[]; - /** @description A reference string that was given while posting the message to be able to identify a sent message again (only available with `chat-reference-id` capability) */ + /** @description A reference string that was given while posting the message to be able to identify a sent message again (only available with `chat-reference-id` capability). For grouped file uploads support, expected format is `{sha256(uploadId)}-{order}`, matching /^[a-f0-9]{60}-[0-9]{3}$/ */ referenceId: string; /** * Format: int64 diff --git a/src/types/openapi/openapi-federation.ts b/src/types/openapi/openapi-federation.ts index a596dd3809f..0dd079c133d 100644 --- a/src/types/openapi/openapi-federation.ts +++ b/src/types/openapi/openapi-federation.ts @@ -404,7 +404,7 @@ export type components = { }; /** @description When the user reacted this is the list of emojis the user reacted with */ reactionsSelf?: string[]; - /** @description A reference string that was given while posting the message to be able to identify a sent message again (only available with `chat-reference-id` capability) */ + /** @description A reference string that was given while posting the message to be able to identify a sent message again (only available with `chat-reference-id` capability). For grouped file uploads support, expected format is `{sha256(uploadId)}-{order}`, matching /^[a-f0-9]{60}-[0-9]{3}$/ */ referenceId: string; /** * Format: int64 diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 74493fc5c85..aa5fa45ad9c 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -3040,7 +3040,7 @@ export type components = { }; /** @description When the user reacted this is the list of emojis the user reacted with */ reactionsSelf?: string[]; - /** @description A reference string that was given while posting the message to be able to identify a sent message again (only available with `chat-reference-id` capability) */ + /** @description A reference string that was given while posting the message to be able to identify a sent message again (only available with `chat-reference-id` capability). For grouped file uploads support, expected format is `{sha256(uploadId)}-{order}`, matching /^[a-f0-9]{60}-[0-9]{3}$/ */ referenceId: string; /** * Format: int64 diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 1e548991f2d..18b11cecc5e 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -2450,7 +2450,7 @@ export type components = { }; /** @description When the user reacted this is the list of emojis the user reacted with */ reactionsSelf?: string[]; - /** @description A reference string that was given while posting the message to be able to identify a sent message again (only available with `chat-reference-id` capability) */ + /** @description A reference string that was given while posting the message to be able to identify a sent message again (only available with `chat-reference-id` capability). For grouped file uploads support, expected format is `{sha256(uploadId)}-{order}`, matching /^[a-f0-9]{60}-[0-9]{3}$/ */ referenceId: string; /** * Format: int64