Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
225 changes: 225 additions & 0 deletions package/src/__tests__/offline-support/optimistic-update.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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));

Expand Down Expand Up @@ -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(
<Chat client={chatClient} enableOfflineSupport>
<Channel
channel={channel}
doUpdateMessageRequest={async (_channelId, localMessage, options) => {
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(),
},
};
}}
>
<CallbackEffectWithContext
callback={async ({ editMessage }) => {
await editMessage({
localMessage: {
...message,
cid: channel.cid,
text: editedText,
},
options: {},
});
}}
context={MessageInputContext}
>
<View testID='children' />
</CallbackEffectWithContext>
</Channel>
</Chat>,
);

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(
<Chat client={chatClient} enableOfflineSupport>
<Channel
channel={channel}
doUpdateMessageRequest={() => {
throw new Error('validation');
}}
>
<CallbackEffectWithContext
callback={async ({ editMessage }) => {
try {
await editMessage({
localMessage: {
...message,
cid: channel.cid,
text: editedText,
},
options: {},
});
} catch (e) {
// do nothing
}
}}
context={MessageInputContext}
>
<View testID='children' />
</CallbackEffectWithContext>
</Channel>
</Chat>,
);

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(
<Chat client={chatClient} enableOfflineSupport>
<Channel
channel={channel}
doUpdateMessageRequest={() => {
const optimisticMessage = channel.state.findMessage(message.id);
optimisticStateSpy(optimisticMessage);

return {
message: {
...optimisticMessage,
},
};
}}
>
<CallbackEffectWithContext
callback={async ({ editMessage }) => {
await editMessage({
localMessage: {
...message,
cid: channel.cid,
status: MessageStatusTypes.FAILED,
text: 'edited failed message',
},
options: {},
});
}}
context={MessageInputContext}
>
<View testID='children' />
</CallbackEffectWithContext>
</Channel>
</Chat>,
);

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(
<Chat client={chatClient} enableOfflineSupport>
<Channel
channel={channel}
doUpdateMessageRequest={() => {
throw new Error('offline');
}}
>
<CallbackEffectWithContext
callback={async ({ editMessage }) => {
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}
>
<View testID='children' />
</CallbackEffectWithContext>
</Channel>
</Chat>,
);

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];
Expand Down
54 changes: 50 additions & 4 deletions package/src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1560,10 +1560,56 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
);

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;
},
);

/**
Expand Down
2 changes: 2 additions & 0 deletions package/src/store/OfflineDB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export class OfflineDB extends AbstractOfflineDB {

addPendingTask = api.addPendingTask;

updatePendingTask = api.updatePendingTask;

deletePendingTask = api.deletePendingTask;

deleteReaction = api.deleteReaction;
Expand Down
66 changes: 66 additions & 0 deletions package/src/store/apis/__tests__/updatePendingTask.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
1 change: 1 addition & 0 deletions package/src/store/apis/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading