From 6b7bba488b57260263017d78159f455a5bf2ee90 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 2 Apr 2026 13:58:04 +0200 Subject: [PATCH 1/3] feat: introduce message update pending tasks --- src/client.ts | 33 ++++ src/offline-support/offline_support_api.ts | 124 +++++++++++- src/offline-support/types.ts | 18 ++ src/offline-support/util.ts | 32 +++ test/unit/client.test.js | 175 +++++++++++++++++ test/unit/offline-support/MockOfflineDB.ts | 1 + .../offline_support_api.test.ts | 185 +++++++++++++++++- test/unit/test-utils/generatePendingTask.js | 5 + yarn.lock | 27 +-- 9 files changed, 574 insertions(+), 26 deletions(-) create mode 100644 src/offline-support/util.ts diff --git a/src/client.ts b/src/client.ts index 7cff327af0..b321e860c8 100644 --- a/src/client.ts +++ b/src/client.ts @@ -265,6 +265,7 @@ import { ReminderManager } from './reminders'; import { StateStore } from './store'; import type { MessageComposer } from './messageComposer'; import type { AbstractOfflineDB } from './offline-support'; +import { getPendingTaskChannelData } from './offline-support/util'; function isString(x: unknown): x is string { return typeof x === 'string' || x instanceof String; @@ -3114,6 +3115,38 @@ export class StreamChat { throw Error('Please specify the message.id when calling updateMessage'); } + const messageId = message.id as string; + + try { + if (this.offlineDb) { + return await this.offlineDb.queueTask({ + task: { + ...getPendingTaskChannelData(message.cid), + messageId, + payload: [message, partialUserOrUserId, options], + type: 'update-message', + }, + }); + } + } catch (error) { + this.logger('error', `offlineDb:updateMessage`, { + tags: ['channel', 'offlineDb'], + error, + }); + } + + return await this._updateMessage(message, partialUserOrUserId, options); + } + + async _updateMessage( + message: LocalMessage | Partial, + partialUserOrUserId?: string | { id: string }, + options?: UpdateMessageOptions, + ) { + if (!message.id) { + throw Error('Please specify the message.id when calling updateMessage'); + } + // should not include user object const payload = toUpdatedMessagePayload(message); diff --git a/src/offline-support/offline_support_api.ts b/src/offline-support/offline_support_api.ts index 0ad35a8c74..6aa8db678e 100644 --- a/src/offline-support/offline_support_api.ts +++ b/src/offline-support/offline_support_api.ts @@ -1,4 +1,11 @@ -import type { APIErrorResponse, ChannelResponse, Event } from '../types'; +import type { + APIErrorResponse, + ChannelResponse, + Event, + LocalMessage, + Message, + MessageResponse, +} from '../types'; import type { OfflineDBApi, @@ -12,6 +19,7 @@ import type { AxiosError } from 'axios'; import { OfflineDBSyncManager } from './offline_sync_manager'; import { StateStore } from '../store'; import { runDetached } from '../utils'; +import { isMessageUpdateReplayable } from './util'; /** * Abstract base class for an offline database implementation used with StreamChat. @@ -310,6 +318,16 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { */ abstract addPendingTask: OfflineDBApi['addPendingTask']; + /** + * @abstract + * Updates a pending task in the DB, given its ID. + * Will return the prepared queries for delayed execution (even if they are + * already executed). + * @param {DBUpdatePendingTaskType} options + * @returns {Promise} + */ + abstract updatePendingTask: OfflineDBApi['updatePendingTask']; + /** * @abstract * Deletes a pending task from the DB, given its ID. @@ -1076,7 +1094,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { return await attemptTaskExecution(); } catch (e) { if (!this.shouldSkipQueueingTask(e as AxiosError)) { - await this.addPendingTask(task); + await this.handleAddPendingTask({ task }); } throw e; } @@ -1092,13 +1110,111 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { private shouldSkipQueueingTask = (error: AxiosError) => error?.response?.data?.code === 4 || error?.response?.data?.code === 17; + private stripOfflineFailedMessageEditMetadata = ( + message: LocalMessage | Partial, + ) => { + const normalizedMessage = { ...message }; + delete normalizedMessage.message_text_updated_at; + return normalizedMessage; + }; + + private mergeFailedMessageUpdateIntoPendingSendMessage = ({ + editedMessage, + pendingMessage, + }: { + editedMessage: LocalMessage | Partial; + pendingMessage: Message; + }) => { + const normalizedEditedMessage = + this.stripOfflineFailedMessageEditMetadata(editedMessage); + const pendingMessageStatus = (pendingMessage as { status?: string }).status; + + return { + ...pendingMessage, + ...normalizedEditedMessage, + ...(typeof pendingMessageStatus !== 'undefined' + ? { status: pendingMessageStatus } + : {}), + } as Message; + }; + + private isPendingSendMessageTask = ( + task: PendingTask, + ): task is Extract => + task.type === 'send-message'; + + private handleOfflineFailedUpdateMessagePendingTask = async ( + task: Extract, + ) => { + const [message] = task.payload; + if (!message.id) { + return; + } + + const pendingTasks = await this.getPendingTasks({ messageId: message.id }); + const pendingSendMessageTask = pendingTasks.find(this.isPendingSendMessageTask); + + if (!pendingSendMessageTask) { + return; + } + + const updatedPendingSendMessage = this.mergeFailedMessageUpdateIntoPendingSendMessage( + { + editedMessage: message, + pendingMessage: pendingSendMessageTask.payload[0], + }, + ); + + const updatedPendingTask: Extract = { + ...pendingSendMessageTask, + payload: [updatedPendingSendMessage, pendingSendMessageTask.payload[1]], + }; + + if (pendingSendMessageTask.id) { + await this.updatePendingTask({ + id: pendingSendMessageTask.id, + task: updatedPendingTask, + }); + return; + } + + await this.addPendingTask({ + ...updatedPendingTask, + id: undefined, + }); + }; + + /** + * Central ingress for persisting pending tasks. It either stores the task as-is + * or rewrites an existing pending `send-message` task for offline edits of failed messages. + */ + public handleAddPendingTask = async ({ task }: { task: PendingTask }) => { + if (task.type === 'update-message' && !isMessageUpdateReplayable(task.payload[0])) { + return; + } + + if ( + task.type === 'update-message' && + !this.client.wsConnection?.isHealthy && + task.payload[0].status === 'failed' + ) { + await this.handleOfflineFailedUpdateMessagePendingTask(task); + return; + } + + await this.addPendingTask(task); + }; + /** * Executes a task from the list of supported pending tasks. Currently supported pending tasks * are: + * - Updating a message * - Deleting a message * - Sending a reaction * - Removing a reaction * - Sending a message + * - Creating a draft + * - Deleting a draft * It will throw if we try to execute a pending task that is not supported. * @param task - The task we want to execute * @param isPendingTask - a control value telling us if it's an actual pending task being executed @@ -1108,6 +1224,10 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { { task }: { task: PendingTask }, isPendingTask = false, ) => { + if (task.type === 'update-message') { + return await this.client._updateMessage(...task.payload); + } + if (task.type === 'delete-message') { return await this.client._deleteMessage(...task.payload); } diff --git a/src/offline-support/types.ts b/src/offline-support/types.ts index 568ace21fc..a85b2526ab 100644 --- a/src/offline-support/types.ts +++ b/src/offline-support/types.ts @@ -227,6 +227,16 @@ export type DBDeletePendingTaskType = { id: number; }; +/** + * Update a pending task by ID. + */ +export type DBUpdatePendingTaskType = { + /** ID of the pending task. */ + id: number; + /** The next task payload to persist. */ + task: PendingTask; +}; + /** * Options to delete a reaction from a message. */ @@ -372,6 +382,9 @@ export interface OfflineDBApi { addPendingTask: (task: PendingTask) => Promise<() => Promise>; getPendingTasks: (conditions?: DBGetPendingTasksType) => Promise; deleteDraft: (options: DBDeleteDraftType) => Promise; + updatePendingTask: ( + options: DBUpdatePendingTaskType, + ) => Promise; deletePendingTask: ( options: DBDeletePendingTaskType, ) => Promise; @@ -397,6 +410,7 @@ export type OfflineDBState = { }; export type PendingTaskTypes = { + updateMessage: 'update-message'; deleteMessage: 'delete-message'; deleteReaction: 'delete-reaction'; sendReaction: 'send-reaction'; @@ -417,6 +431,10 @@ export type PendingTask = { payload: Parameters; type: PendingTaskTypes['sendReaction']; } + | { + payload: Parameters; + type: PendingTaskTypes['updateMessage']; + } | { payload: Parameters; type: PendingTaskTypes['deleteMessage']; diff --git a/src/offline-support/util.ts b/src/offline-support/util.ts new file mode 100644 index 0000000000..d5b069d033 --- /dev/null +++ b/src/offline-support/util.ts @@ -0,0 +1,32 @@ +import type { Attachment, LocalMessage, MessageResponse } from '../types'; + +export const isLocalUrl = (value: string | undefined) => + !!value && !value.startsWith('http'); + +export const isAttachmentReplayable = (attachment: Attachment) => { + if (!attachment || typeof attachment !== 'object') { + return true; + } + + return !isLocalUrl(attachment.asset_url) && !isLocalUrl(attachment.image_url); +}; + +export const isMessageUpdateReplayable = ( + message: LocalMessage | Partial, +) => !message.attachments?.some((attachment) => !isAttachmentReplayable(attachment)); + +export const getPendingTaskChannelData = (cid?: string) => { + if (!cid) { + return {}; + } + + const separatorIndex = cid.indexOf(':'); + if (separatorIndex <= 0 || separatorIndex === cid.length - 1) { + return {}; + } + + return { + channelId: cid.slice(separatorIndex + 1), + channelType: cid.slice(0, separatorIndex), + }; +}; diff --git a/test/unit/client.test.js b/test/unit/client.test.js index 2c64a5ab3a..8f4bbc2441 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -514,6 +514,181 @@ describe('updateMessage should maintain data integrity', () => { }); }); +describe('message update', () => { + let client; + let loggerSpy; + let queueTaskSpy; + let _updateMessageSpy; + + beforeEach(async () => { + client = await getClientWithUser(); + const offlineDb = new MockOfflineDB({ client }); + + client.setOfflineDBApi(offlineDb); + await client.offlineDb.init(client.userID); + + loggerSpy = vi.spyOn(client, 'logger').mockImplementation(vi.fn()); + queueTaskSpy = vi.spyOn(client.offlineDb, 'queueTask').mockResolvedValue({}); + _updateMessageSpy = vi.spyOn(client, '_updateMessage').mockResolvedValue({}); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('updateMessage', () => { + it('queues replayable updates through offlineDb', async () => { + const message = generateMsg({ + id: 'msg-123', + cid: 'messaging:channel-123', + text: 'edited', + }); + + await client.updateMessage(message, { id: 'user-123' }, { skip_enrich_url: true }); + + expect(queueTaskSpy).toHaveBeenCalledTimes(1); + expect(queueTaskSpy).toHaveBeenCalledWith({ + task: { + channelId: 'channel-123', + channelType: 'messaging', + messageId: 'msg-123', + payload: [message, { id: 'user-123' }, { skip_enrich_url: true }], + type: 'update-message', + }, + }); + expect(_updateMessageSpy).not.toHaveBeenCalled(); + }); + + it('queues replayable updates without channel data if cid is missing or invalid', async () => { + const message = generateMsg({ + id: 'msg-123', + cid: 'invalid-cid', + text: 'edited', + }); + + await client.updateMessage(message); + + expect(queueTaskSpy).toHaveBeenCalledWith({ + task: { + messageId: 'msg-123', + payload: [message, undefined, undefined], + type: 'update-message', + }, + }); + }); + + it('falls back to _updateMessage if offlineDb is not set', async () => { + const message = generateMsg({ + id: 'msg-123', + text: 'edited', + }); + + client.offlineDb = undefined; + + await client.updateMessage(message, 'user-123', { skip_enrich_url: true }); + + expect(_updateMessageSpy).toHaveBeenCalledTimes(1); + expect(_updateMessageSpy).toHaveBeenCalledWith(message, 'user-123', { + skip_enrich_url: true, + }); + }); + + it('routes updates with local attachment metadata through offlineDb queue handling', async () => { + const message = generateMsg({ + id: 'msg-123', + attachments: [ + { + type: 'image', + image_url: 'https://example.com/image.jpg', + localMetadata: { + file: { uri: 'file://test.jpg' }, + id: 'local-1', + uploadState: 'pending', + }, + }, + ], + }); + + await client.updateMessage(message); + + expect(queueTaskSpy).toHaveBeenCalledTimes(1); + expect(queueTaskSpy).toHaveBeenCalledWith({ + task: { + messageId: 'msg-123', + payload: [message, undefined, undefined], + type: 'update-message', + }, + }); + expect(_updateMessageSpy).not.toHaveBeenCalled(); + }); + + it('routes updates with originalFile attachments through offlineDb queue handling', async () => { + const message = generateMsg({ + id: 'msg-123', + attachments: [ + { + type: 'file', + asset_url: 'https://example.com/test.pdf', + originalFile: { uri: 'content://test.pdf' }, + }, + ], + }); + + await client.updateMessage(message); + + expect(queueTaskSpy).toHaveBeenCalledTimes(1); + expect(queueTaskSpy).toHaveBeenCalledWith({ + task: { + messageId: 'msg-123', + payload: [message, undefined, undefined], + type: 'update-message', + }, + }); + expect(_updateMessageSpy).not.toHaveBeenCalled(); + }); + + it('logs and falls back to _updateMessage if offline queueing throws', async () => { + const message = generateMsg({ + id: 'msg-123', + text: 'edited', + }); + queueTaskSpy.mockRejectedValue(new Error('Offline failure')); + + await client.updateMessage(message); + + expect(loggerSpy).toHaveBeenCalledTimes(1); + expect(_updateMessageSpy).toHaveBeenCalledTimes(1); + expect(_updateMessageSpy).toHaveBeenCalledWith(message, undefined, undefined); + }); + + it('logs and falls back to _updateMessage when queueTask rethrows for failed offline edits', async () => { + const failedEditedMessage = generateMsg({ + id: 'msg-123', + status: 'failed', + text: 'edited', + message_text_updated_at: '2026-04-01T20:48:43.886269Z', + }); + + client.wsConnection = { isHealthy: false }; + queueTaskSpy.mockRejectedValue(new Error('Offline failure')); + _updateMessageSpy.mockResolvedValue({ message: failedEditedMessage }); + + const response = await client.updateMessage(failedEditedMessage); + + expect(queueTaskSpy).toHaveBeenCalledTimes(1); + expect(loggerSpy).toHaveBeenCalledTimes(1); + expect(_updateMessageSpy).toHaveBeenCalledTimes(1); + expect(_updateMessageSpy).toHaveBeenCalledWith( + failedEditedMessage, + undefined, + undefined, + ); + expect(response.message.text).toBe('edited'); + expect(response.message.status).toBe('failed'); + }); + }); +}); + describe('Client search', async () => { const client = await getClientWithUser(); diff --git a/test/unit/offline-support/MockOfflineDB.ts b/test/unit/offline-support/MockOfflineDB.ts index b68efdda85..350fff7ae3 100644 --- a/test/unit/offline-support/MockOfflineDB.ts +++ b/test/unit/offline-support/MockOfflineDB.ts @@ -26,6 +26,7 @@ export class MockOfflineDB extends AbstractOfflineDB { getAppSettings = vi.fn(); getReactions = vi.fn(); addPendingTask = vi.fn(); + updatePendingTask = vi.fn(); deletePendingTask = vi.fn(); deleteReaction = vi.fn(); deleteMember = vi.fn(); diff --git a/test/unit/offline-support/offline_support_api.test.ts b/test/unit/offline-support/offline_support_api.test.ts index 88eef0e8ac..551b3d402a 100644 --- a/test/unit/offline-support/offline_support_api.test.ts +++ b/test/unit/offline-support/offline_support_api.test.ts @@ -1680,6 +1680,7 @@ describe('OfflineSupportApi', () => { let executeTaskSpy: MockInstance; let shouldSkipSpy: MockInstance; let addPendingTaskSpy: MockInstance; + let handleAddPendingTaskSpy: MockInstance; let mockResponse: { ok: boolean }; const task = generatePendingTask('send-message') as PendingTask; @@ -1691,6 +1692,7 @@ describe('OfflineSupportApi', () => { .mockResolvedValue(mockResponse); shouldSkipSpy = vi.spyOn(offlineDb as any, 'shouldSkipQueueingTask'); addPendingTaskSpy = vi.spyOn(offlineDb, 'addPendingTask'); + handleAddPendingTaskSpy = vi.spyOn(offlineDb, 'handleAddPendingTask'); client.wsConnection = { isHealthy: true } as StableWSConnection; }); @@ -1723,7 +1725,7 @@ describe('OfflineSupportApi', () => { expect(result).toBeUndefined(); expect(executeTaskSpy).not.toHaveBeenCalled(); - expect(addPendingTaskSpy).toHaveBeenCalledWith(task); + expect(handleAddPendingTaskSpy).toHaveBeenCalledWith({ task }); }); it('should add task and rethrow if executeTask throws non-skippable error', async () => { @@ -1737,7 +1739,7 @@ describe('OfflineSupportApi', () => { await expect(offlineDb.queueTask({ task })).rejects.toEqual(error); - expect(addPendingTaskSpy).toHaveBeenCalledWith(task); + expect(handleAddPendingTaskSpy).toHaveBeenCalledWith({ task }); expect(shouldSkipSpy).toHaveBeenCalledWith(error); }); @@ -1757,8 +1759,175 @@ describe('OfflineSupportApi', () => { }); }); + describe('handleAddPendingTask', () => { + it('adds regular pending tasks as-is', async () => { + const task = generatePendingTask('send-message') as PendingTask; + const addPendingTaskSpy = vi.spyOn(offlineDb, 'addPendingTask'); + + await offlineDb.handleAddPendingTask({ task }); + + expect(addPendingTaskSpy).toHaveBeenCalledWith(task); + }); + + it('adds replayable update-message tasks as-is', async () => { + const task = generatePendingTask('update-message') as PendingTask; + const addPendingTaskSpy = vi.spyOn(offlineDb, 'addPendingTask'); + const getPendingTasksSpy = vi.spyOn(offlineDb, 'getPendingTasks'); + + await offlineDb.handleAddPendingTask({ task }); + + expect(addPendingTaskSpy).toHaveBeenCalledWith(task); + expect(getPendingTasksSpy).not.toHaveBeenCalled(); + }); + + it('does not persist non-replayable update-message tasks', async () => { + const task = generatePendingTask( + 'update-message', + 1, + {}, + { + message: { + id: 'msg-123', + attachments: [{ type: 'image', image_url: 'file://local-image.jpg' }], + }, + }, + ) as PendingTask; + const addPendingTaskSpy = vi.spyOn(offlineDb, 'addPendingTask'); + const getPendingTasksSpy = vi.spyOn(offlineDb, 'getPendingTasks'); + const updatePendingTaskSpy = vi.spyOn(offlineDb, 'updatePendingTask'); + + await offlineDb.handleAddPendingTask({ task }); + + expect(addPendingTaskSpy).not.toHaveBeenCalled(); + expect(getPendingTasksSpy).not.toHaveBeenCalled(); + expect(updatePendingTaskSpy).not.toHaveBeenCalled(); + }); + + it('rewrites a queued send-message task for offline failed update-message tasks', async () => { + client.wsConnection = { isHealthy: false } as StableWSConnection; + const task = generatePendingTask( + 'update-message', + 1, + {}, + { + message: { + id: 'msg-123', + status: 'failed', + text: 'edited', + message_text_updated_at: '2026-04-01T20:48:43.886269Z', + }, + }, + ) as PendingTask; + const pendingSendOptions = { skip_enrich_url: true }; + vi.spyOn(offlineDb, 'getPendingTasks').mockResolvedValue([ + { + id: 7, + messageId: 'msg-123', + payload: [ + { id: 'msg-123', status: 'sending', text: 'original' }, + pendingSendOptions, + ], + type: 'send-message', + } as PendingTask, + ]); + const updatePendingTaskSpy = vi.spyOn(offlineDb, 'updatePendingTask'); + const addPendingTaskSpy = vi.spyOn(offlineDb, 'addPendingTask'); + + await offlineDb.handleAddPendingTask({ task }); + + expect(updatePendingTaskSpy).toHaveBeenCalledWith({ + id: 7, + task: expect.objectContaining({ + id: 7, + messageId: 'msg-123', + type: 'send-message', + }), + }); + expect(updatePendingTaskSpy.mock.calls[0][0].task.payload[0]).toMatchObject({ + id: 'msg-123', + status: 'sending', + text: 'edited', + }); + expect( + updatePendingTaskSpy.mock.calls[0][0].task.payload[0], + ).not.toHaveProperty('message_text_updated_at'); + expect(updatePendingTaskSpy.mock.calls[0][0].task.payload[1]).toBe( + pendingSendOptions, + ); + expect(addPendingTaskSpy).not.toHaveBeenCalled(); + }); + + it('re-adds the rewritten send-message task if the pending task does not have an id', async () => { + client.wsConnection = { isHealthy: false } as StableWSConnection; + const task = generatePendingTask( + 'update-message', + 1, + {}, + { + message: { + id: 'msg-123', + status: 'failed', + text: 'edited', + message_text_updated_at: '2026-04-01T20:48:43.886269Z', + }, + }, + ) as PendingTask; + vi.spyOn(offlineDb, 'getPendingTasks').mockResolvedValue([ + { + messageId: 'msg-123', + payload: [ + { id: 'msg-123', status: 'sending', text: 'original' }, + undefined, + ], + type: 'send-message', + } as PendingTask, + ]); + const updatePendingTaskSpy = vi.spyOn(offlineDb, 'updatePendingTask'); + const addPendingTaskSpy = vi.spyOn(offlineDb, 'addPendingTask'); + + await offlineDb.handleAddPendingTask({ task }); + + expect(updatePendingTaskSpy).not.toHaveBeenCalled(); + expect(addPendingTaskSpy).toHaveBeenCalledWith({ + messageId: 'msg-123', + payload: [{ id: 'msg-123', status: 'sending', text: 'edited' }, undefined], + type: 'send-message', + id: undefined, + }); + }); + + it('does nothing for failed offline update-message tasks without a matching pending send task', async () => { + client.wsConnection = { isHealthy: false } as StableWSConnection; + const task = generatePendingTask( + 'update-message', + 1, + {}, + { + message: { + id: 'msg-123', + status: 'failed', + text: 'edited', + }, + }, + ) as PendingTask; + vi.spyOn(offlineDb, 'getPendingTasks').mockResolvedValue([ + generatePendingTask('delete-message', 3, { + messageId: 'msg-123', + }) as PendingTask, + ]); + const addPendingTaskSpy = vi.spyOn(offlineDb, 'addPendingTask'); + const updatePendingTaskSpy = vi.spyOn(offlineDb, 'updatePendingTask'); + + await offlineDb.handleAddPendingTask({ task }); + + expect(addPendingTaskSpy).not.toHaveBeenCalled(); + expect(updatePendingTaskSpy).not.toHaveBeenCalled(); + }); + }); + describe('executeTask', () => { let mockChannel: Channel; + let _updateMessageSpy: MockInstance; let _deleteMessageSpy: MockInstance; let clientChannelSpy: MockInstance; @@ -1774,6 +1943,9 @@ describe('OfflineSupportApi', () => { state: { addMessageSorted: vi.fn() }, } as unknown as Channel; + _updateMessageSpy = vi + .spyOn(client, '_updateMessage') + .mockImplementation(vi.fn()); _deleteMessageSpy = vi .spyOn(client, '_deleteMessage') .mockImplementation(vi.fn()); @@ -1784,6 +1956,15 @@ describe('OfflineSupportApi', () => { vi.resetAllMocks(); }); + it('should call _updateMessage for update-message task', async () => { + const task = generatePendingTask('update-message') as PendingTask; + + await offlineDb['executeTask']({ task }); + + expect(_updateMessageSpy).toHaveBeenCalledWith(...task.payload); + expect(clientChannelSpy).not.toHaveBeenCalled(); + }); + it('should call _deleteMessage for delete-message task', async () => { const task = generatePendingTask('delete-message') as PendingTask; diff --git a/test/unit/test-utils/generatePendingTask.js b/test/unit/test-utils/generatePendingTask.js index 182c54c8c0..2a78e46624 100644 --- a/test/unit/test-utils/generatePendingTask.js +++ b/test/unit/test-utils/generatePendingTask.js @@ -30,6 +30,11 @@ export const generatePendingTaskPayload = (type, options = {}) => { return { type, payload: [messageId] }; } + if (type === 'update-message') { + const message = options.message ?? generateMsg({ id: options.messageId ?? '123' }); + return { type, payload: [message, options.user, options.updateOptions] }; + } + const message = options.message ?? generateMsg(); return { type, payload: [message] }; }; diff --git a/yarn.lock b/yarn.lock index ca599237a8..3da1c96ca7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -38,7 +38,7 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.24" -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.22.13", "@babel/code-frame@^7.26.2": +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.26.2": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== @@ -2688,7 +2688,7 @@ fastq@^1.6.0: dependencies: reusify "^1.0.4" -fdir@^6.4.4, fdir@^6.5.0: +fdir@^6.5.0: version "6.5.0" resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== @@ -2959,7 +2959,7 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" -glob@^10.3.7, glob@^10.4.1: +glob@^10.4.1: version "10.4.5" resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== @@ -3215,11 +3215,6 @@ indent-string@^5.0.0: resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-5.0.0.tgz#4fd2980fccaf8622d14c64d694f4cf33c81951a5" integrity sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg== -index-to-position@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/index-to-position/-/index-to-position-0.1.2.tgz#e11bfe995ca4d8eddb1ec43274488f3c201a7f09" - integrity sha512-MWDKS3AS1bGCHLBA2VLImJz42f7bJh8wQsTGCzI3j519/CASStoDONUBVz2I/VID0MpiX3SGSnbOD2xUalbE5g== - index-to-position@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/index-to-position/-/index-to-position-1.2.0.tgz#c800eb34dacf4dbf96b9b06c7eb78d5f704138b4" @@ -4284,11 +4279,6 @@ minizlib@^3.0.1, minizlib@^3.1.0: dependencies: minipass "^7.1.2" -mkdirp@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50" - integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg== - ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" @@ -4919,7 +4909,7 @@ picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== -picomatch@^4.0.2, picomatch@^4.0.3: +picomatch@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== @@ -5211,13 +5201,6 @@ rfdc@^1.3.0: resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.1.tgz#2b6d4df52dffe8bb346992a10ea9451f24373a8f" integrity sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg== -rimraf@^5.0.5: - version "5.0.10" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-5.0.10.tgz#23b9843d3dc92db71f96e1a2ce92e39fd2a8221c" - integrity sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ== - dependencies: - glob "^10.3.7" - rollup@^4.30.1: version "4.34.8" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.34.8.tgz#e859c1a51d899aba9bcf451d4eed1d11fb8e2a6e" @@ -6015,7 +5998,7 @@ type-fest@^3.0.0: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-3.13.1.tgz#bb744c1f0678bea7543a2d1ec24e83e68e8c8706" integrity sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g== -type-fest@^4.39.1, type-fest@^4.6.0, type-fest@^4.7.1: +type-fest@^4.39.1, type-fest@^4.6.0: version "4.41.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58" integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA== From 7a1f62d0be50b3dae46cb3c0396780127efc9446 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 2 Apr 2026 14:01:10 +0200 Subject: [PATCH 2/3] chore: add util tests as well --- test/unit/offline-support/util.test.ts | 102 +++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 test/unit/offline-support/util.test.ts diff --git a/test/unit/offline-support/util.test.ts b/test/unit/offline-support/util.test.ts new file mode 100644 index 0000000000..ee0938f50a --- /dev/null +++ b/test/unit/offline-support/util.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { + getPendingTaskChannelData, + isAttachmentReplayable, + isLocalUrl, + isMessageUpdateReplayable, +} from '../../../src/offline-support/util'; + +describe('offline-support util', () => { + describe('isLocalUrl', () => { + it.each([ + [undefined, false], + ['', false], + ['https://example.com/image.jpg', false], + ['http://example.com/image.jpg', false], + ['file://local-image.jpg', true], + ['content://local-image.jpg', true], + ])('returns %s => %s', (value, expected) => { + expect(isLocalUrl(value)).toBe(expected); + }); + }); + + describe('isAttachmentReplayable', () => { + it('treats missing attachments as replayable', () => { + expect(isAttachmentReplayable(undefined as never)).toBe(true); + }); + + it.each([ + [{ asset_url: 'https://example.com/file.pdf' }, true], + [{ image_url: 'https://example.com/image.jpg' }, true], + [ + { + asset_url: 'https://example.com/file.pdf', + image_url: 'https://example.com/image.jpg', + }, + true, + ], + [{ asset_url: 'file://local-file.pdf' }, false], + [{ image_url: 'file://local-image.jpg' }, false], + [ + { + asset_url: 'https://example.com/file.pdf', + image_url: 'file://local-image.jpg', + }, + false, + ], + [ + { + asset_url: 'file://local-file.pdf', + image_url: 'https://example.com/image.jpg', + }, + false, + ], + [{}, true], + ])('returns %s => %s', (attachment, expected) => { + expect(isAttachmentReplayable(attachment as never)).toBe(expected); + }); + }); + + describe('isMessageUpdateReplayable', () => { + it('returns true when the message has no attachments', () => { + expect(isMessageUpdateReplayable({ id: 'msg-1' })).toBe(true); + }); + + it('returns true when all attachments are replayable', () => { + expect( + isMessageUpdateReplayable({ + id: 'msg-1', + attachments: [ + { asset_url: 'https://example.com/file.pdf' }, + { image_url: 'https://example.com/image.jpg' }, + ], + }), + ).toBe(true); + }); + + it('returns false when any attachment is not replayable', () => { + expect( + isMessageUpdateReplayable({ + id: 'msg-1', + attachments: [ + { asset_url: 'https://example.com/file.pdf' }, + { image_url: 'file://local-image.jpg' }, + ], + }), + ).toBe(false); + }); + }); + + describe('getPendingTaskChannelData', () => { + it.each([ + [undefined, {}], + ['', {}], + ['invalid-cid', {}], + [':channel-123', {}], + ['messaging:', {}], + ['messaging:channel-123', { channelId: 'channel-123', channelType: 'messaging' }], + ])('returns %s => %j', (cid, expected) => { + expect(getPendingTaskChannelData(cid)).toEqual(expected); + }); + }); +}); From b957581d78ac9d419b20acbbfcf09e9537eda068 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 2 Apr 2026 16:39:36 +0200 Subject: [PATCH 3/3] fix: properly merge send msg payload --- src/offline-support/offline_support_api.ts | 23 +++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/offline-support/offline_support_api.ts b/src/offline-support/offline_support_api.ts index 6aa8db678e..95eb2f847e 100644 --- a/src/offline-support/offline_support_api.ts +++ b/src/offline-support/offline_support_api.ts @@ -18,7 +18,7 @@ import type { StreamChat } from '../client'; import type { AxiosError } from 'axios'; import { OfflineDBSyncManager } from './offline_sync_manager'; import { StateStore } from '../store'; -import { runDetached } from '../utils'; +import { localMessageToNewMessagePayload, runDetached } from '../utils'; import { isMessageUpdateReplayable } from './util'; /** @@ -1110,14 +1110,6 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { private shouldSkipQueueingTask = (error: AxiosError) => error?.response?.data?.code === 4 || error?.response?.data?.code === 17; - private stripOfflineFailedMessageEditMetadata = ( - message: LocalMessage | Partial, - ) => { - const normalizedMessage = { ...message }; - delete normalizedMessage.message_text_updated_at; - return normalizedMessage; - }; - private mergeFailedMessageUpdateIntoPendingSendMessage = ({ editedMessage, pendingMessage, @@ -1125,8 +1117,17 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { editedMessage: LocalMessage | Partial; pendingMessage: Message; }) => { - const normalizedEditedMessage = - this.stripOfflineFailedMessageEditMetadata(editedMessage); + const normalizedEditedMessageSource = { + ...editedMessage, + } as LocalMessage & { message_text_updated_at?: string }; + + if (editedMessage.status === 'failed') { + delete normalizedEditedMessageSource.message_text_updated_at; + } + + const normalizedEditedMessage = localMessageToNewMessagePayload( + normalizedEditedMessageSource, + ); const pendingMessageStatus = (pendingMessage as { status?: string }).status; return {