Skip to content

Commit 6db76b8

Browse files
committed
feat: optimistic updates for message updating proper queueing
1 parent 0a11c5b commit 6db76b8

File tree

6 files changed

+318
-4
lines changed

6 files changed

+318
-4
lines changed

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

Lines changed: 177 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';
@@ -397,6 +398,182 @@ export const OptimisticUpdates = () => {
397398
});
398399
});
399400

401+
describe('edit message', () => {
402+
it('should keep the optimistic edit in state and DB if the LLC queues the edit', async () => {
403+
const message = channel.state.messages[0];
404+
const editedText = 'edited while offline';
405+
406+
render(
407+
<Chat client={chatClient} enableOfflineSupport>
408+
<Channel
409+
channel={channel}
410+
doUpdateMessageRequest={async (_channelId, localMessage, options) => {
411+
await chatClient.offlineDb.addPendingTask({
412+
channelId: channel.id,
413+
channelType: channel.type,
414+
messageId: message.id,
415+
payload: [localMessage, undefined, options],
416+
type: 'update-message',
417+
});
418+
return {
419+
message: {
420+
...localMessage,
421+
message_text_updated_at: new Date(),
422+
updated_at: new Date(),
423+
},
424+
};
425+
}}
426+
>
427+
<CallbackEffectWithContext
428+
callback={async ({ editMessage }) => {
429+
await editMessage({
430+
localMessage: {
431+
...message,
432+
cid: channel.cid,
433+
text: editedText,
434+
},
435+
options: {},
436+
});
437+
}}
438+
context={MessageInputContext}
439+
>
440+
<View testID='children' />
441+
</CallbackEffectWithContext>
442+
</Channel>
443+
</Chat>,
444+
);
445+
446+
await waitFor(() => expect(screen.getByTestId('children')).toBeTruthy());
447+
448+
await waitFor(async () => {
449+
const updatedMessage = channel.state.findMessage(message.id);
450+
const pendingTasksRows = await BetterSqlite.selectFromTable('pendingTasks');
451+
const dbMessages = await BetterSqlite.selectFromTable('messages');
452+
const dbMessage = dbMessages.find((row) => row.id === message.id);
453+
454+
expect(updatedMessage.text).toBe(editedText);
455+
expect(updatedMessage.message_text_updated_at).toBeTruthy();
456+
expect(pendingTasksRows).toHaveLength(1);
457+
expect(pendingTasksRows[0].type).toBe('update-message');
458+
expect(dbMessage.text).toBe(editedText);
459+
expect(dbMessage.messageTextUpdatedAt).toBeTruthy();
460+
});
461+
});
462+
463+
it('should rollback the optimistic edit if the request fails and no replayable task exists', async () => {
464+
const message = channel.state.messages[0];
465+
const originalText = message.text;
466+
467+
render(
468+
<Chat client={chatClient} enableOfflineSupport>
469+
<Channel
470+
channel={channel}
471+
doUpdateMessageRequest={() => {
472+
throw new Error('validation');
473+
}}
474+
>
475+
<CallbackEffectWithContext
476+
callback={async ({ editMessage }) => {
477+
try {
478+
await editMessage({
479+
localMessage: {
480+
...message,
481+
cid: channel.cid,
482+
text: 'should rollback',
483+
},
484+
options: {},
485+
});
486+
} catch (e) {
487+
// do nothing
488+
}
489+
}}
490+
context={MessageInputContext}
491+
>
492+
<View testID='children' />
493+
</CallbackEffectWithContext>
494+
</Channel>
495+
</Chat>,
496+
);
497+
498+
await waitFor(() => expect(screen.getByTestId('children')).toBeTruthy());
499+
500+
await waitFor(async () => {
501+
const updatedMessage = channel.state.findMessage(message.id);
502+
const pendingTasksRows = await BetterSqlite.selectFromTable('pendingTasks');
503+
const dbMessages = await BetterSqlite.selectFromTable('messages');
504+
const dbMessage = dbMessages.find((row) => row.id === message.id);
505+
506+
expect(updatedMessage.text).toBe(originalText);
507+
expect(pendingTasksRows).toHaveLength(0);
508+
expect(dbMessage.text).toBe(originalText);
509+
});
510+
});
511+
512+
it('should keep the optimistic edit for attachment updates without auto-queueing', async () => {
513+
const message = channel.state.messages[0];
514+
const editedText = 'edited attachment message';
515+
const localUri = 'file://edited-attachment.png';
516+
517+
render(
518+
<Chat client={chatClient} enableOfflineSupport>
519+
<Channel
520+
channel={channel}
521+
doUpdateMessageRequest={() => {
522+
throw new Error('offline');
523+
}}
524+
>
525+
<CallbackEffectWithContext
526+
callback={async ({ editMessage }) => {
527+
try {
528+
await editMessage({
529+
localMessage: {
530+
...message,
531+
attachments: [
532+
{
533+
asset_url: localUri,
534+
originalFile: generateFileReference({
535+
name: 'edited-attachment.png',
536+
type: 'image/png',
537+
uri: localUri,
538+
}),
539+
type: 'file',
540+
},
541+
],
542+
cid: channel.cid,
543+
text: editedText,
544+
},
545+
options: {},
546+
});
547+
} catch (e) {
548+
// do nothing
549+
}
550+
}}
551+
context={MessageInputContext}
552+
>
553+
<View testID='children' />
554+
</CallbackEffectWithContext>
555+
</Channel>
556+
</Chat>,
557+
);
558+
559+
await waitFor(() => expect(screen.getByTestId('children')).toBeTruthy());
560+
561+
await waitFor(async () => {
562+
const updatedMessage = channel.state.findMessage(message.id);
563+
const pendingTasksRows = await BetterSqlite.selectFromTable('pendingTasks');
564+
const dbMessages = await BetterSqlite.selectFromTable('messages');
565+
const dbMessage = dbMessages.find((row) => row.id === message.id);
566+
const storedAttachments = JSON.parse(dbMessage.attachments);
567+
568+
expect(updatedMessage.text).toBe(editedText);
569+
expect(updatedMessage.attachments[0].asset_url).toBe(localUri);
570+
expect(pendingTasksRows).toHaveLength(0);
571+
expect(dbMessage.text).toBe(editedText);
572+
expect(storedAttachments[0].asset_url).toBe(localUri);
573+
});
574+
});
575+
});
576+
400577
describe('pending task execution', () => {
401578
it('pending task should be executed after connection is recovered', async () => {
402579
const message = channel.state.messages[0];

package/src/components/Channel/Channel.tsx

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1560,10 +1560,53 @@ 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 optimisticEditedAt = new Date();
1571+
const optimisticEditedAtString = optimisticEditedAt.toISOString();
1572+
const optimisticMessage = {
1573+
...currentMessage,
1574+
...localMessage,
1575+
cid,
1576+
message_text_updated_at: optimisticEditedAtString,
1577+
updated_at: optimisticEditedAt,
1578+
} as unknown as LocalMessage;
1579+
1580+
updateMessage(optimisticMessage);
1581+
threadInstance?.updateParentMessageOrReplyLocally(
1582+
optimisticMessage as unknown as MessageResponse,
1583+
);
1584+
client.offlineDb?.executeQuerySafely(
1585+
(db) =>
1586+
db.updateMessage({
1587+
message: { ...optimisticMessage, cid },
1588+
}),
1589+
{ method: 'updateMessage' },
1590+
);
1591+
1592+
const response = doUpdateMessageRequest
1593+
? await doUpdateMessageRequest(cid, localMessage, options)
1594+
: await client.updateMessage(localMessage, undefined, options);
1595+
1596+
if (response?.message) {
1597+
updateMessage(response.message);
1598+
threadInstance?.updateParentMessageOrReplyLocally(response.message);
1599+
client.offlineDb?.executeQuerySafely(
1600+
(db) =>
1601+
db.updateMessage({
1602+
message: { ...response.message, cid },
1603+
}),
1604+
{ method: 'updateMessage' },
1605+
);
1606+
}
1607+
1608+
return response;
1609+
},
15671610
);
15681611

15691612
/**

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';
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { DBUpdatePendingTaskType } from 'stream-chat';
2+
3+
import { mapTaskToStorable } from '../mappers/mapTaskToStorable';
4+
import { createUpdateQuery } from '../sqlite-utils/createUpdateQuery';
5+
import { SqliteClient } from '../SqliteClient';
6+
7+
export const updatePendingTask = async ({ id, task }: DBUpdatePendingTaskType) => {
8+
const storableTask = mapTaskToStorable(task);
9+
const { createdAt, id: taskId, ...nextTask } = storableTask;
10+
void createdAt;
11+
void taskId;
12+
13+
const query = createUpdateQuery('pendingTasks', nextTask, {
14+
id,
15+
});
16+
17+
SqliteClient.logger?.('info', 'updatePendingTask', {
18+
id,
19+
task: nextTask,
20+
});
21+
22+
await SqliteClient.executeSql.apply(null, query);
23+
24+
return [query];
25+
};

0 commit comments

Comments
 (0)