diff --git a/package/package.json b/package/package.json index e5efe35685..0576178df9 100644 --- a/package/package.json +++ b/package/package.json @@ -84,7 +84,7 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", - "stream-chat": "^9.36.1", + "stream-chat": "^9.40.0", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { diff --git a/package/src/__tests__/offline-support/optimistic-update.js b/package/src/__tests__/offline-support/optimistic-update.js index 6ea1be1062..299a97d0fe 100644 --- a/package/src/__tests__/offline-support/optimistic-update.js +++ b/package/src/__tests__/offline-support/optimistic-update.js @@ -15,6 +15,7 @@ import { getOrCreateChannelApi } from '../../mock-builders/api/getOrCreateChanne import { sendMessageApi } from '../../mock-builders/api/sendMessage'; import { sendReactionApi } from '../../mock-builders/api/sendReaction'; import { useMockedApis } from '../../mock-builders/api/useMockedApis'; +import { generateFileReference } from '../../mock-builders/attachments'; import dispatchConnectionChangedEvent from '../../mock-builders/event/connectionChanged'; import { generateChannelResponse } from '../../mock-builders/generator/channel'; import { generateMember } from '../../mock-builders/generator/member'; @@ -25,6 +26,7 @@ import { getTestClientWithUser } from '../../mock-builders/mock'; import { upsertChannels } from '../../store/apis'; import { SqliteClient } from '../../store/SqliteClient'; import { BetterSqlite } from '../../test-utils/BetterSqlite'; +import { MessageStatusTypes } from '../../utils/utils'; test('Workaround to allow exporting tests', () => expect(true).toBe(true)); @@ -397,6 +399,229 @@ export const OptimisticUpdates = () => { }); }); + describe('edit message', () => { + it('should keep the optimistic edit in state and DB if the LLC queues the edit', async () => { + const message = channel.state.messages[0]; + const editedText = 'edited while offline'; + + render( + + { + await chatClient.offlineDb.addPendingTask({ + channelId: channel.id, + channelType: channel.type, + messageId: message.id, + payload: [localMessage, undefined, options], + type: 'update-message', + }); + return { + message: { + ...localMessage, + message_text_updated_at: new Date(), + updated_at: new Date(), + }, + }; + }} + > + { + await editMessage({ + localMessage: { + ...message, + cid: channel.cid, + text: editedText, + }, + options: {}, + }); + }} + context={MessageInputContext} + > + + + + , + ); + + await waitFor(() => expect(screen.getByTestId('children')).toBeTruthy()); + + await waitFor(async () => { + const updatedMessage = channel.state.findMessage(message.id); + const pendingTasksRows = await BetterSqlite.selectFromTable('pendingTasks'); + const dbMessages = await BetterSqlite.selectFromTable('messages'); + const dbMessage = dbMessages.find((row) => row.id === message.id); + + expect(updatedMessage.text).toBe(editedText); + expect(updatedMessage.message_text_updated_at).toBeTruthy(); + expect(pendingTasksRows).toHaveLength(1); + expect(pendingTasksRows[0].type).toBe('update-message'); + expect(dbMessage.text).toBe(editedText); + expect(dbMessage.messageTextUpdatedAt).toBeTruthy(); + }); + }); + + it('should keep the optimistic edit if the request fails', async () => { + const message = channel.state.messages[0]; + const editedText = 'should stay optimistic'; + + render( + + { + throw new Error('validation'); + }} + > + { + try { + await editMessage({ + localMessage: { + ...message, + cid: channel.cid, + text: editedText, + }, + options: {}, + }); + } catch (e) { + // do nothing + } + }} + context={MessageInputContext} + > + + + + , + ); + + await waitFor(() => expect(screen.getByTestId('children')).toBeTruthy()); + + await waitFor(async () => { + const updatedMessage = channel.state.findMessage(message.id); + const pendingTasksRows = await BetterSqlite.selectFromTable('pendingTasks'); + const dbMessages = await BetterSqlite.selectFromTable('messages'); + const dbMessage = dbMessages.find((row) => row.id === message.id); + + expect(updatedMessage.text).toBe(editedText); + expect(pendingTasksRows).toHaveLength(0); + expect(dbMessage.text).toBe(editedText); + }); + }); + + it('should not set message_text_updated_at during optimistic edit of a failed message', async () => { + const message = channel.state.messages[0]; + const optimisticStateSpy = jest.fn(); + + render( + + { + const optimisticMessage = channel.state.findMessage(message.id); + optimisticStateSpy(optimisticMessage); + + return { + message: { + ...optimisticMessage, + }, + }; + }} + > + { + await editMessage({ + localMessage: { + ...message, + cid: channel.cid, + status: MessageStatusTypes.FAILED, + text: 'edited failed message', + }, + options: {}, + }); + }} + context={MessageInputContext} + > + + + + , + ); + + await waitFor(() => expect(screen.getByTestId('children')).toBeTruthy()); + + await waitFor(() => { + expect(optimisticStateSpy).toHaveBeenCalled(); + expect(optimisticStateSpy.mock.calls[0][0].message_text_updated_at).toBeUndefined(); + }); + }); + + it('should keep the optimistic edit for attachment updates without auto-queueing', async () => { + const message = channel.state.messages[0]; + const editedText = 'edited attachment message'; + const localUri = 'file://edited-attachment.png'; + + render( + + { + throw new Error('offline'); + }} + > + { + try { + await editMessage({ + localMessage: { + ...message, + attachments: [ + { + asset_url: localUri, + originalFile: generateFileReference({ + name: 'edited-attachment.png', + type: 'image/png', + uri: localUri, + }), + type: 'file', + }, + ], + cid: channel.cid, + text: editedText, + }, + options: {}, + }); + } catch (e) { + // do nothing + } + }} + context={MessageInputContext} + > + + + + , + ); + + await waitFor(() => expect(screen.getByTestId('children')).toBeTruthy()); + + await waitFor(async () => { + const updatedMessage = channel.state.findMessage(message.id); + const pendingTasksRows = await BetterSqlite.selectFromTable('pendingTasks'); + const dbMessages = await BetterSqlite.selectFromTable('messages'); + const dbMessage = dbMessages.find((row) => row.id === message.id); + const storedAttachments = JSON.parse(dbMessage.attachments); + + expect(updatedMessage.text).toBe(editedText); + expect(updatedMessage.attachments[0].asset_url).toBe(localUri); + expect(pendingTasksRows).toHaveLength(0); + expect(dbMessage.text).toBe(editedText); + expect(storedAttachments[0].asset_url).toBe(localUri); + }); + }); + }); + describe('pending task execution', () => { it('pending task should be executed after connection is recovered', async () => { const message = channel.state.messages[0]; diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index ec851251d1..b726a4e272 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -1560,10 +1560,56 @@ const ChannelWithContext = (props: PropsWithChildren) = ); const editMessage: InputMessageInputContextValue['editMessage'] = useStableCallback( - ({ localMessage, options }) => - doUpdateMessageRequest - ? doUpdateMessageRequest(channel?.cid || '', localMessage, options) - : client.updateMessage(localMessage, undefined, options), + async ({ localMessage, options }) => { + if (!channel) { + throw new Error('Channel has not been initialized'); + } + + const cid = channel.cid; + const currentMessage = channel.state.findMessage(localMessage.id, localMessage.parent_id); + const isFailedMessage = + currentMessage?.status === MessageStatusTypes.FAILED || + localMessage.status === MessageStatusTypes.FAILED; + const optimisticEditedAt = new Date(); + const optimisticEditedAtString = optimisticEditedAt.toISOString(); + const optimisticMessage = { + ...currentMessage, + ...localMessage, + cid, + message_text_updated_at: isFailedMessage ? undefined : optimisticEditedAtString, + updated_at: optimisticEditedAt, + } as unknown as LocalMessage; + + updateMessage(optimisticMessage); + threadInstance?.updateParentMessageOrReplyLocally( + optimisticMessage as unknown as MessageResponse, + ); + client.offlineDb?.executeQuerySafely( + (db) => + db.updateMessage({ + message: { ...optimisticMessage, cid }, + }), + { method: 'updateMessage' }, + ); + + const response = doUpdateMessageRequest + ? await doUpdateMessageRequest(cid, localMessage, options) + : await client.updateMessage(localMessage, undefined, options); + + if (response?.message) { + updateMessage(response.message); + threadInstance?.updateParentMessageOrReplyLocally(response.message); + client.offlineDb?.executeQuerySafely( + (db) => + db.updateMessage({ + message: { ...response.message, cid }, + }), + { method: 'updateMessage' }, + ); + } + + return response; + }, ); /** diff --git a/package/src/store/OfflineDB.ts b/package/src/store/OfflineDB.ts index 254a5cf2de..262d73273f 100644 --- a/package/src/store/OfflineDB.ts +++ b/package/src/store/OfflineDB.ts @@ -67,6 +67,8 @@ export class OfflineDB extends AbstractOfflineDB { addPendingTask = api.addPendingTask; + updatePendingTask = api.updatePendingTask; + deletePendingTask = api.deletePendingTask; deleteReaction = api.deleteReaction; diff --git a/package/src/store/apis/__tests__/updatePendingTask.test.ts b/package/src/store/apis/__tests__/updatePendingTask.test.ts new file mode 100644 index 0000000000..4b12803372 --- /dev/null +++ b/package/src/store/apis/__tests__/updatePendingTask.test.ts @@ -0,0 +1,66 @@ +import { v4 as uuidv4 } from 'uuid'; + +import { addPendingTask, getPendingTasks, updatePendingTask } from '..'; +import { generateMessage } from '../../../mock-builders/generator/message'; +import { BetterSqlite } from '../../../test-utils/BetterSqlite'; +import { SqliteClient } from '../../SqliteClient'; + +describe('updatePendingTask', () => { + beforeEach(async () => { + await SqliteClient.initializeDatabase(); + await BetterSqlite.openDB(); + }); + + afterEach(() => { + BetterSqlite.dropAllTables(); + BetterSqlite.closeDB(); + jest.clearAllMocks(); + }); + + it('should replace an existing pending task row by id without changing its createdAt ordering', async () => { + const channelId = uuidv4(); + const originalMessage = generateMessage({ + cid: `messaging:${channelId}`, + id: uuidv4(), + text: 'original text', + }); + + await addPendingTask({ + channelId, + channelType: 'messaging', + messageId: originalMessage.id, + payload: [originalMessage, {}], + type: 'send-message', + }); + + const [originalRow] = await BetterSqlite.selectFromTable('pendingTasks'); + const [originalTask] = await getPendingTasks({ messageId: originalMessage.id }); + + const editedMessage = { + ...originalMessage, + text: 'edited text', + }; + + await updatePendingTask({ + id: originalTask.id, + task: { + channelId, + channelType: 'messaging', + messageId: originalMessage.id, + payload: [editedMessage, {}], + type: 'send-message', + }, + }); + + const [updatedRow] = await BetterSqlite.selectFromTable('pendingTasks'); + const [updatedTask] = await getPendingTasks({ messageId: originalMessage.id }); + + expect(updatedRow.id).toBe(originalRow.id); + expect(updatedRow.createdAt).toBe(originalRow.createdAt); + expect(updatedRow.type).toBe('send-message'); + expect(JSON.parse(updatedRow.payload)[0].text).toBe('edited text'); + expect(updatedTask.id).toBe(originalTask.id); + expect(updatedTask.type).toBe('send-message'); + expect(updatedTask.payload[0].text).toBe('edited text'); + }); +}); diff --git a/package/src/store/apis/index.ts b/package/src/store/apis/index.ts index 1d1d66686c..5bd625feda 100644 --- a/package/src/store/apis/index.ts +++ b/package/src/store/apis/index.ts @@ -13,6 +13,7 @@ export * from './getLastSyncedAt'; export * from './getMembers'; export * from './getReads'; export * from './updateMessage'; +export * from './updatePendingTask'; export * from './updateReaction'; export * from './insertReaction'; export * from './deleteReaction'; diff --git a/package/src/store/apis/updatePendingTask.ts b/package/src/store/apis/updatePendingTask.ts new file mode 100644 index 0000000000..af0ec2baae --- /dev/null +++ b/package/src/store/apis/updatePendingTask.ts @@ -0,0 +1,24 @@ +import type { DBUpdatePendingTaskType } from 'stream-chat'; + +import { mapTaskToStorable } from '../mappers/mapTaskToStorable'; +import { createUpdateQuery } from '../sqlite-utils/createUpdateQuery'; +import { SqliteClient } from '../SqliteClient'; + +export const updatePendingTask = async ({ id, task }: DBUpdatePendingTaskType) => { + const storableTask = mapTaskToStorable(task); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { createdAt, id: taskId, ...nextTask } = storableTask; + + const query = createUpdateQuery('pendingTasks', nextTask, { + id, + }); + + SqliteClient.logger?.('info', 'updatePendingTask', { + id, + task: nextTask, + }); + + await SqliteClient.executeSql.apply(null, query); + + return [query]; +}; diff --git a/package/yarn.lock b/package/yarn.lock index e350903d62..8fdba707ec 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -8352,10 +8352,10 @@ stdin-discarder@^0.2.2: resolved "https://registry.yarnpkg.com/stdin-discarder/-/stdin-discarder-0.2.2.tgz#390037f44c4ae1a1ae535c5fe38dc3aba8d997be" integrity sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ== -stream-chat@^9.36.1: - version "9.36.2" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.36.2.tgz#cd2cfac1f8d7b045c34dce51e2de1cb66bf288f5" - integrity sha512-sSCxTXJOf0BLDMZ2/cqvFged/LLbiWoIhs7v3UsRj0EM0T8tTam7zpU77TSccNDlK5j1C1/llSUVyMLc7aCDsA== +stream-chat@^9.40.0: + version "9.40.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.40.0.tgz#32ee29ae3442744fb7068b0ecaa3cc1a3b456b97" + integrity sha512-IH3MdxS1zGwOob1dBqRTIqS7wB2Y6Spu4ufo4/yVKW/IFEYRs38BSLHcMsJISvUbPpBleXKIrUOQZu6VsgJpdw== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14"