Skip to content

Commit 8d63792

Browse files
authored
feat: update message optimistic updates (#3528)
## 🎯 Goal This PR is the RN SDK counterpart of [this change](GetStream/stream-chat-js#1716). In addition to the description there, it also: - Includes optimistic updates for updating a message - Makes sure the DB is up to date ## 🛠 Implementation details <!-- Provide a description of the implementation --> ## 🎨 UI Changes <!-- Add relevant screenshots --> <details> <summary>iOS</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> <details> <summary>Android</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> ## 🧪 Testing <!-- Explain how this change can be tested (or why it can't be tested) --> ## ☑️ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android
1 parent 43968ab commit 8d63792

File tree

8 files changed

+373
-9
lines changed

8 files changed

+373
-9
lines changed

package/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@
8484
"path": "0.12.7",
8585
"react-native-markdown-package": "1.8.2",
8686
"react-native-url-polyfill": "^2.0.0",
87-
"stream-chat": "^9.36.1",
87+
"stream-chat": "^9.40.0",
8888
"use-sync-external-store": "^1.5.0"
8989
},
9090
"peerDependencies": {

package/src/__tests__/offline-support/optimistic-update.js

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { getOrCreateChannelApi } from '../../mock-builders/api/getOrCreateChanne
1515
import { sendMessageApi } from '../../mock-builders/api/sendMessage';
1616
import { sendReactionApi } from '../../mock-builders/api/sendReaction';
1717
import { useMockedApis } from '../../mock-builders/api/useMockedApis';
18+
import { generateFileReference } from '../../mock-builders/attachments';
1819
import dispatchConnectionChangedEvent from '../../mock-builders/event/connectionChanged';
1920
import { generateChannelResponse } from '../../mock-builders/generator/channel';
2021
import { generateMember } from '../../mock-builders/generator/member';
@@ -25,6 +26,7 @@ import { getTestClientWithUser } from '../../mock-builders/mock';
2526
import { upsertChannels } from '../../store/apis';
2627
import { SqliteClient } from '../../store/SqliteClient';
2728
import { BetterSqlite } from '../../test-utils/BetterSqlite';
29+
import { MessageStatusTypes } from '../../utils/utils';
2830

2931
test('Workaround to allow exporting tests', () => expect(true).toBe(true));
3032

@@ -397,6 +399,229 @@ export const OptimisticUpdates = () => {
397399
});
398400
});
399401

402+
describe('edit message', () => {
403+
it('should keep the optimistic edit in state and DB if the LLC queues the edit', async () => {
404+
const message = channel.state.messages[0];
405+
const editedText = 'edited while offline';
406+
407+
render(
408+
<Chat client={chatClient} enableOfflineSupport>
409+
<Channel
410+
channel={channel}
411+
doUpdateMessageRequest={async (_channelId, localMessage, options) => {
412+
await chatClient.offlineDb.addPendingTask({
413+
channelId: channel.id,
414+
channelType: channel.type,
415+
messageId: message.id,
416+
payload: [localMessage, undefined, options],
417+
type: 'update-message',
418+
});
419+
return {
420+
message: {
421+
...localMessage,
422+
message_text_updated_at: new Date(),
423+
updated_at: new Date(),
424+
},
425+
};
426+
}}
427+
>
428+
<CallbackEffectWithContext
429+
callback={async ({ editMessage }) => {
430+
await editMessage({
431+
localMessage: {
432+
...message,
433+
cid: channel.cid,
434+
text: editedText,
435+
},
436+
options: {},
437+
});
438+
}}
439+
context={MessageInputContext}
440+
>
441+
<View testID='children' />
442+
</CallbackEffectWithContext>
443+
</Channel>
444+
</Chat>,
445+
);
446+
447+
await waitFor(() => expect(screen.getByTestId('children')).toBeTruthy());
448+
449+
await waitFor(async () => {
450+
const updatedMessage = channel.state.findMessage(message.id);
451+
const pendingTasksRows = await BetterSqlite.selectFromTable('pendingTasks');
452+
const dbMessages = await BetterSqlite.selectFromTable('messages');
453+
const dbMessage = dbMessages.find((row) => row.id === message.id);
454+
455+
expect(updatedMessage.text).toBe(editedText);
456+
expect(updatedMessage.message_text_updated_at).toBeTruthy();
457+
expect(pendingTasksRows).toHaveLength(1);
458+
expect(pendingTasksRows[0].type).toBe('update-message');
459+
expect(dbMessage.text).toBe(editedText);
460+
expect(dbMessage.messageTextUpdatedAt).toBeTruthy();
461+
});
462+
});
463+
464+
it('should keep the optimistic edit if the request fails', async () => {
465+
const message = channel.state.messages[0];
466+
const editedText = 'should stay optimistic';
467+
468+
render(
469+
<Chat client={chatClient} enableOfflineSupport>
470+
<Channel
471+
channel={channel}
472+
doUpdateMessageRequest={() => {
473+
throw new Error('validation');
474+
}}
475+
>
476+
<CallbackEffectWithContext
477+
callback={async ({ editMessage }) => {
478+
try {
479+
await editMessage({
480+
localMessage: {
481+
...message,
482+
cid: channel.cid,
483+
text: editedText,
484+
},
485+
options: {},
486+
});
487+
} catch (e) {
488+
// do nothing
489+
}
490+
}}
491+
context={MessageInputContext}
492+
>
493+
<View testID='children' />
494+
</CallbackEffectWithContext>
495+
</Channel>
496+
</Chat>,
497+
);
498+
499+
await waitFor(() => expect(screen.getByTestId('children')).toBeTruthy());
500+
501+
await waitFor(async () => {
502+
const updatedMessage = channel.state.findMessage(message.id);
503+
const pendingTasksRows = await BetterSqlite.selectFromTable('pendingTasks');
504+
const dbMessages = await BetterSqlite.selectFromTable('messages');
505+
const dbMessage = dbMessages.find((row) => row.id === message.id);
506+
507+
expect(updatedMessage.text).toBe(editedText);
508+
expect(pendingTasksRows).toHaveLength(0);
509+
expect(dbMessage.text).toBe(editedText);
510+
});
511+
});
512+
513+
it('should not set message_text_updated_at during optimistic edit of a failed message', async () => {
514+
const message = channel.state.messages[0];
515+
const optimisticStateSpy = jest.fn();
516+
517+
render(
518+
<Chat client={chatClient} enableOfflineSupport>
519+
<Channel
520+
channel={channel}
521+
doUpdateMessageRequest={() => {
522+
const optimisticMessage = channel.state.findMessage(message.id);
523+
optimisticStateSpy(optimisticMessage);
524+
525+
return {
526+
message: {
527+
...optimisticMessage,
528+
},
529+
};
530+
}}
531+
>
532+
<CallbackEffectWithContext
533+
callback={async ({ editMessage }) => {
534+
await editMessage({
535+
localMessage: {
536+
...message,
537+
cid: channel.cid,
538+
status: MessageStatusTypes.FAILED,
539+
text: 'edited failed message',
540+
},
541+
options: {},
542+
});
543+
}}
544+
context={MessageInputContext}
545+
>
546+
<View testID='children' />
547+
</CallbackEffectWithContext>
548+
</Channel>
549+
</Chat>,
550+
);
551+
552+
await waitFor(() => expect(screen.getByTestId('children')).toBeTruthy());
553+
554+
await waitFor(() => {
555+
expect(optimisticStateSpy).toHaveBeenCalled();
556+
expect(optimisticStateSpy.mock.calls[0][0].message_text_updated_at).toBeUndefined();
557+
});
558+
});
559+
560+
it('should keep the optimistic edit for attachment updates without auto-queueing', async () => {
561+
const message = channel.state.messages[0];
562+
const editedText = 'edited attachment message';
563+
const localUri = 'file://edited-attachment.png';
564+
565+
render(
566+
<Chat client={chatClient} enableOfflineSupport>
567+
<Channel
568+
channel={channel}
569+
doUpdateMessageRequest={() => {
570+
throw new Error('offline');
571+
}}
572+
>
573+
<CallbackEffectWithContext
574+
callback={async ({ editMessage }) => {
575+
try {
576+
await editMessage({
577+
localMessage: {
578+
...message,
579+
attachments: [
580+
{
581+
asset_url: localUri,
582+
originalFile: generateFileReference({
583+
name: 'edited-attachment.png',
584+
type: 'image/png',
585+
uri: localUri,
586+
}),
587+
type: 'file',
588+
},
589+
],
590+
cid: channel.cid,
591+
text: editedText,
592+
},
593+
options: {},
594+
});
595+
} catch (e) {
596+
// do nothing
597+
}
598+
}}
599+
context={MessageInputContext}
600+
>
601+
<View testID='children' />
602+
</CallbackEffectWithContext>
603+
</Channel>
604+
</Chat>,
605+
);
606+
607+
await waitFor(() => expect(screen.getByTestId('children')).toBeTruthy());
608+
609+
await waitFor(async () => {
610+
const updatedMessage = channel.state.findMessage(message.id);
611+
const pendingTasksRows = await BetterSqlite.selectFromTable('pendingTasks');
612+
const dbMessages = await BetterSqlite.selectFromTable('messages');
613+
const dbMessage = dbMessages.find((row) => row.id === message.id);
614+
const storedAttachments = JSON.parse(dbMessage.attachments);
615+
616+
expect(updatedMessage.text).toBe(editedText);
617+
expect(updatedMessage.attachments[0].asset_url).toBe(localUri);
618+
expect(pendingTasksRows).toHaveLength(0);
619+
expect(dbMessage.text).toBe(editedText);
620+
expect(storedAttachments[0].asset_url).toBe(localUri);
621+
});
622+
});
623+
});
624+
400625
describe('pending task execution', () => {
401626
it('pending task should be executed after connection is recovered', async () => {
402627
const message = channel.state.messages[0];

package/src/components/Channel/Channel.tsx

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1560,10 +1560,56 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
15601560
);
15611561

15621562
const editMessage: InputMessageInputContextValue['editMessage'] = useStableCallback(
1563-
({ localMessage, options }) =>
1564-
doUpdateMessageRequest
1565-
? doUpdateMessageRequest(channel?.cid || '', localMessage, options)
1566-
: client.updateMessage(localMessage, undefined, options),
1563+
async ({ localMessage, options }) => {
1564+
if (!channel) {
1565+
throw new Error('Channel has not been initialized');
1566+
}
1567+
1568+
const cid = channel.cid;
1569+
const currentMessage = channel.state.findMessage(localMessage.id, localMessage.parent_id);
1570+
const isFailedMessage =
1571+
currentMessage?.status === MessageStatusTypes.FAILED ||
1572+
localMessage.status === MessageStatusTypes.FAILED;
1573+
const optimisticEditedAt = new Date();
1574+
const optimisticEditedAtString = optimisticEditedAt.toISOString();
1575+
const optimisticMessage = {
1576+
...currentMessage,
1577+
...localMessage,
1578+
cid,
1579+
message_text_updated_at: isFailedMessage ? undefined : optimisticEditedAtString,
1580+
updated_at: optimisticEditedAt,
1581+
} as unknown as LocalMessage;
1582+
1583+
updateMessage(optimisticMessage);
1584+
threadInstance?.updateParentMessageOrReplyLocally(
1585+
optimisticMessage as unknown as MessageResponse,
1586+
);
1587+
client.offlineDb?.executeQuerySafely(
1588+
(db) =>
1589+
db.updateMessage({
1590+
message: { ...optimisticMessage, cid },
1591+
}),
1592+
{ method: 'updateMessage' },
1593+
);
1594+
1595+
const response = doUpdateMessageRequest
1596+
? await doUpdateMessageRequest(cid, localMessage, options)
1597+
: await client.updateMessage(localMessage, undefined, options);
1598+
1599+
if (response?.message) {
1600+
updateMessage(response.message);
1601+
threadInstance?.updateParentMessageOrReplyLocally(response.message);
1602+
client.offlineDb?.executeQuerySafely(
1603+
(db) =>
1604+
db.updateMessage({
1605+
message: { ...response.message, cid },
1606+
}),
1607+
{ method: 'updateMessage' },
1608+
);
1609+
}
1610+
1611+
return response;
1612+
},
15671613
);
15681614

15691615
/**

package/src/store/OfflineDB.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ export class OfflineDB extends AbstractOfflineDB {
6767

6868
addPendingTask = api.addPendingTask;
6969

70+
updatePendingTask = api.updatePendingTask;
71+
7072
deletePendingTask = api.deletePendingTask;
7173

7274
deleteReaction = api.deleteReaction;
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { v4 as uuidv4 } from 'uuid';
2+
3+
import { addPendingTask, getPendingTasks, updatePendingTask } from '..';
4+
import { generateMessage } from '../../../mock-builders/generator/message';
5+
import { BetterSqlite } from '../../../test-utils/BetterSqlite';
6+
import { SqliteClient } from '../../SqliteClient';
7+
8+
describe('updatePendingTask', () => {
9+
beforeEach(async () => {
10+
await SqliteClient.initializeDatabase();
11+
await BetterSqlite.openDB();
12+
});
13+
14+
afterEach(() => {
15+
BetterSqlite.dropAllTables();
16+
BetterSqlite.closeDB();
17+
jest.clearAllMocks();
18+
});
19+
20+
it('should replace an existing pending task row by id without changing its createdAt ordering', async () => {
21+
const channelId = uuidv4();
22+
const originalMessage = generateMessage({
23+
cid: `messaging:${channelId}`,
24+
id: uuidv4(),
25+
text: 'original text',
26+
});
27+
28+
await addPendingTask({
29+
channelId,
30+
channelType: 'messaging',
31+
messageId: originalMessage.id,
32+
payload: [originalMessage, {}],
33+
type: 'send-message',
34+
});
35+
36+
const [originalRow] = await BetterSqlite.selectFromTable('pendingTasks');
37+
const [originalTask] = await getPendingTasks({ messageId: originalMessage.id });
38+
39+
const editedMessage = {
40+
...originalMessage,
41+
text: 'edited text',
42+
};
43+
44+
await updatePendingTask({
45+
id: originalTask.id,
46+
task: {
47+
channelId,
48+
channelType: 'messaging',
49+
messageId: originalMessage.id,
50+
payload: [editedMessage, {}],
51+
type: 'send-message',
52+
},
53+
});
54+
55+
const [updatedRow] = await BetterSqlite.selectFromTable('pendingTasks');
56+
const [updatedTask] = await getPendingTasks({ messageId: originalMessage.id });
57+
58+
expect(updatedRow.id).toBe(originalRow.id);
59+
expect(updatedRow.createdAt).toBe(originalRow.createdAt);
60+
expect(updatedRow.type).toBe('send-message');
61+
expect(JSON.parse(updatedRow.payload)[0].text).toBe('edited text');
62+
expect(updatedTask.id).toBe(originalTask.id);
63+
expect(updatedTask.type).toBe('send-message');
64+
expect(updatedTask.payload[0].text).toBe('edited text');
65+
});
66+
});

package/src/store/apis/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export * from './getLastSyncedAt';
1313
export * from './getMembers';
1414
export * from './getReads';
1515
export * from './updateMessage';
16+
export * from './updatePendingTask';
1617
export * from './updateReaction';
1718
export * from './insertReaction';
1819
export * from './deleteReaction';

0 commit comments

Comments
 (0)