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"