From 8b1e40d1c91c2a10f57e84aaff67261b37ef270a Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 26 Nov 2025 22:39:37 +0100 Subject: [PATCH 1/6] fix: remove rollback --- package/src/components/Channel/Channel.tsx | 74 ++++++++++++------- .../Channel/hooks/useCreateChannelContext.ts | 4 +- package/src/store/SqliteClient.ts | 1 - 3 files changed, 50 insertions(+), 29 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index b2674cdd79..76014e29d1 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -7,6 +7,7 @@ import throttle from 'lodash/throttle'; import { lookup } from 'mime-types'; import { Channel as ChannelClass, + ChannelResponse, ChannelState, Channel as ChannelType, DeleteMessageOptions, @@ -102,7 +103,6 @@ import { isImagePickerAvailable, NativeHandlers, } from '../../native'; -import * as dbApi from '../../store/apis'; import { ChannelUnreadState, FileTypes } from '../../types/types'; import { addReactionToLocalState } from '../../utils/addReactionToLocalState'; import { compressedImageURI } from '../../utils/compressImage'; @@ -1300,9 +1300,13 @@ const ChannelWithContext = (props: PropsWithChildren) = attachment.image_url = uploadResponse.file; delete attachment.originalFile; - await dbApi.updateMessage({ - message: { ...updatedMessage, cid: channel.cid }, - }); + client.offlineDb?.executeQuerySafely( + (db) => + db.updateMessage({ + message: { ...updatedMessage, cid: channel.cid }, + }), + { method: 'upsertAppSettings' }, + ); } if (attachment.type !== FileTypes.Image && file?.uri) { @@ -1321,9 +1325,13 @@ const ChannelWithContext = (props: PropsWithChildren) = } delete attachment.originalFile; - await dbApi.updateMessage({ - message: { ...updatedMessage, cid: channel.cid }, - }); + client.offlineDb?.executeQuerySafely( + (db) => + db.updateMessage({ + message: { ...updatedMessage, cid: channel.cid }, + }), + { method: 'upsertAppSettings' }, + ); } } } @@ -1344,7 +1352,7 @@ const ChannelWithContext = (props: PropsWithChildren) = retrying?: boolean; }) => { let failedMessageUpdated = false; - const handleFailedMessage = async () => { + const handleFailedMessage = () => { if (!failedMessageUpdated) { const updatedMessage = { ...localMessage, @@ -1355,11 +1363,13 @@ const ChannelWithContext = (props: PropsWithChildren) = threadInstance?.upsertReplyLocally?.({ message: updatedMessage }); optimisticallyUpdatedNewMessages.delete(localMessage.id); - if (enableOfflineSupport) { - await dbApi.updateMessage({ - message: updatedMessage, - }); - } + client.offlineDb?.executeQuerySafely( + (db) => + db.updateMessage({ + message: updatedMessage, + }), + { method: 'upsertAppSettings' }, + ); failedMessageUpdated = true; } @@ -1397,11 +1407,14 @@ const ChannelWithContext = (props: PropsWithChildren) = status: MessageStatusTypes.RECEIVED, }; - if (enableOfflineSupport) { - await dbApi.updateMessage({ - message: { ...newMessageResponse, cid: channel.cid }, - }); - } + client.offlineDb?.executeQuerySafely( + (db) => + db.updateMessage({ + message: { ...newMessageResponse, cid: channel.cid }, + }), + { method: 'upsertAppSettings' }, + ); + if (retrying) { replaceMessage(localMessage, newMessageResponse); } else { @@ -1425,15 +1438,22 @@ const ChannelWithContext = (props: PropsWithChildren) = threadInstance?.upsertReplyLocally?.({ message: localMessage }); optimisticallyUpdatedNewMessages.add(localMessage.id); - if (enableOfflineSupport) { - // While sending a message, we add the message to local db with failed status, so that - // if app gets closed before message gets sent and next time user opens the app - // then user can see that message in failed state and can retry. - // If succesfull, it will be updated with received status. - await dbApi.upsertMessages({ - messages: [{ ...localMessage, cid: channel.cid, status: MessageStatusTypes.FAILED }], - }); - } + // While sending a message, we add the message to local db with failed status, so that + // if app gets closed before message gets sent and next time user opens the app + // then user can see that message in failed state and can retry. + // If succesfull, it will be updated with received status. + client.offlineDb?.executeQuerySafely( + async (db) => { + if (channel.data) { + await db.upsertChannelData({ channel: channel.data as unknown as ChannelResponse }); + } + await db.upsertMessages({ + // @ts-ignore + messages: [{ ...localMessage, cid: channel.cid, status: MessageStatusTypes.FAILED }], + }); + }, + { method: 'upsertAppSettings' }, + ); await sendMessageRequest({ localMessage, message, options }); }, diff --git a/package/src/components/Channel/hooks/useCreateChannelContext.ts b/package/src/components/Channel/hooks/useCreateChannelContext.ts index 2abb66883e..b58b0dad60 100644 --- a/package/src/components/Channel/hooks/useCreateChannelContext.ts +++ b/package/src/components/Channel/hooks/useCreateChannelContext.ts @@ -43,7 +43,9 @@ export const useCreateChannelContext = ({ const readUsers = Object.values(read); const readUsersLength = readUsers.length; - const readUsersLastReads = readUsers.map(({ last_read }) => last_read.toISOString()).join(); + const readUsersLastReads = readUsers + .map(({ last_read }) => last_read?.toISOString() ?? '') + .join(); const stringifiedChannelUnreadState = JSON.stringify(channelUnreadState); const channelContext: ChannelContextValue = useMemo( diff --git a/package/src/store/SqliteClient.ts b/package/src/store/SqliteClient.ts index 103eaa25e0..d59f27e76d 100644 --- a/package/src/store/SqliteClient.ts +++ b/package/src/store/SqliteClient.ts @@ -96,7 +96,6 @@ export class SqliteClient { }); await this.db.executeBatch(finalQueries); } catch (e) { - this.db?.execute('ROLLBACK'); this.logger?.('error', 'SqlBatch queries failed', { error: e, queries, From 6421a45ae1a11b52ddd2ec3c08b63324f8dfd346 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 26 Nov 2025 23:25:27 +0100 Subject: [PATCH 2/6] fix: avoid upserting faulty channel --- package/src/components/Channel/Channel.tsx | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 76014e29d1..333f171202 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -1305,7 +1305,7 @@ const ChannelWithContext = (props: PropsWithChildren) = db.updateMessage({ message: { ...updatedMessage, cid: channel.cid }, }), - { method: 'upsertAppSettings' }, + { method: 'updateMessage' }, ); } @@ -1330,7 +1330,7 @@ const ChannelWithContext = (props: PropsWithChildren) = db.updateMessage({ message: { ...updatedMessage, cid: channel.cid }, }), - { method: 'upsertAppSettings' }, + { method: 'updateMessage' }, ); } } @@ -1368,7 +1368,7 @@ const ChannelWithContext = (props: PropsWithChildren) = db.updateMessage({ message: updatedMessage, }), - { method: 'upsertAppSettings' }, + { method: 'updateMessage' }, ); failedMessageUpdated = true; @@ -1412,7 +1412,7 @@ const ChannelWithContext = (props: PropsWithChildren) = db.updateMessage({ message: { ...newMessageResponse, cid: channel.cid }, }), - { method: 'upsertAppSettings' }, + { method: 'updateMessage' }, ); if (retrying) { @@ -1443,16 +1443,12 @@ const ChannelWithContext = (props: PropsWithChildren) = // then user can see that message in failed state and can retry. // If succesfull, it will be updated with received status. client.offlineDb?.executeQuerySafely( - async (db) => { - if (channel.data) { - await db.upsertChannelData({ channel: channel.data as unknown as ChannelResponse }); - } - await db.upsertMessages({ + (db) => + db.upsertMessages({ // @ts-ignore messages: [{ ...localMessage, cid: channel.cid, status: MessageStatusTypes.FAILED }], - }); - }, - { method: 'upsertAppSettings' }, + }), + { method: 'upsertMessages' }, ); await sendMessageRequest({ localMessage, message, options }); From 47063ca9deffc18bcadc8b608b1197fd7d5e3ea2 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 27 Nov 2025 00:08:43 +0100 Subject: [PATCH 3/6] chore: introduce prop to enable async attachments --- package/src/components/Channel/Channel.tsx | 8 ++++++-- .../hooks/useCreateInputMessageInputContext.ts | 10 +++++++++- .../messageInputContext/MessageInputContext.tsx | 17 +++++++++++++---- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 333f171202..ca2dc14308 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -7,7 +7,6 @@ import throttle from 'lodash/throttle'; import { lookup } from 'mime-types'; import { Channel as ChannelClass, - ChannelResponse, ChannelState, Channel as ChannelType, DeleteMessageOptions, @@ -495,7 +494,10 @@ export type ChannelPropsWithContext = Pick & } & Partial< Pick< InputMessageInputContextValue, - 'openPollCreationDialog' | 'CreatePollContent' | 'StopMessageStreamingButton' + | 'openPollCreationDialog' + | 'CreatePollContent' + | 'StopMessageStreamingButton' + | 'allowSendBeforeAttachmentsUpload' > >; @@ -571,6 +573,7 @@ const ChannelWithContext = (props: PropsWithChildren) = EmptyStateIndicator = EmptyStateIndicatorDefault, enableMessageGroupingByUser = true, enableOfflineSupport, + allowSendBeforeAttachmentsUpload = enableOfflineSupport, enableSwipeToReply = true, enforceUniqueReaction = false, FileAttachment = FileAttachmentDefault, @@ -1772,6 +1775,7 @@ const ChannelWithContext = (props: PropsWithChildren) = const inputMessageInputContext = useCreateInputMessageInputContext({ additionalTextInputProps, + allowSendBeforeAttachmentsUpload, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, asyncMessagesMultiSendEnabled, diff --git a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts index 247117f34a..5c5d4a0607 100644 --- a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts +++ b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts @@ -4,6 +4,7 @@ import type { InputMessageInputContextValue } from '../../../contexts/messageInp export const useCreateInputMessageInputContext = ({ additionalTextInputProps, + allowSendBeforeAttachmentsUpload, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, asyncMessagesMultiSendEnabled, @@ -70,6 +71,7 @@ export const useCreateInputMessageInputContext = ({ const inputMessageInputContext: InputMessageInputContextValue = useMemo( () => ({ additionalTextInputProps, + allowSendBeforeAttachmentsUpload, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, asyncMessagesMultiSendEnabled, @@ -128,7 +130,13 @@ export const useCreateInputMessageInputContext = ({ VideoRecorderSelectorIcon, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [compressImageQuality, channelId, CreatePollContent, showPollCreationDialog], + [ + compressImageQuality, + channelId, + CreatePollContent, + showPollCreationDialog, + allowSendBeforeAttachmentsUpload, + ], ); return inputMessageInputContext; diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index 64414cfa8e..9a59b95bda 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -328,6 +328,7 @@ export type InputMessageInputContextValue = { * @see See https://reactnative.dev/docs/textinput#reference */ additionalTextInputProps?: TextInputProps; + allowSendBeforeAttachmentsUpload?: boolean; closePollCreationDialog?: () => void; /** * Compress image with quality (from 0 to 1, where 1 is best quality). @@ -411,7 +412,7 @@ export const MessageInputProvider = ({ }>) => { const { closePicker, openPicker, selectedPicker, setSelectedPicker } = useAttachmentPickerContext(); - const { client, enableOfflineSupport } = useChatContext(); + const { client } = useChatContext(); const channelCapabilities = useOwnCapabilitiesContext(); const { uploadAbortControllerRef } = useChannelContext(); @@ -425,7 +426,10 @@ export const MessageInputProvider = ({ const defaultOpenPollCreationDialog = useCallback(() => setShowPollCreationDialog(true), []); const closePollCreationDialog = useCallback(() => setShowPollCreationDialog(false), []); - const { openPollCreationDialog: openPollCreationDialogFromContext } = value; + const { + openPollCreationDialog: openPollCreationDialogFromContext, + allowSendBeforeAttachmentsUpload, + } = value; const { endsAt: cooldownEndsAt, start: startCooldown } = useCooldown(); @@ -443,7 +447,7 @@ export const MessageInputProvider = ({ attachmentManager.setCustomUploadFn(value.doFileUploadRequest); } - if (enableOfflineSupport) { + if (allowSendBeforeAttachmentsUpload) { messageComposer.compositionMiddlewareExecutor.replace([ createAttachmentsCompositionMiddleware(messageComposer), ]); @@ -452,7 +456,12 @@ export const MessageInputProvider = ({ createDraftAttachmentsCompositionMiddleware(messageComposer), ]); } - }, [value.doFileUploadRequest, enableOfflineSupport, messageComposer, attachmentManager]); + }, [ + value.doFileUploadRequest, + allowSendBeforeAttachmentsUpload, + messageComposer, + attachmentManager, + ]); /** * Function for capturing a photo and uploading it From 858610cc1fb5bf06ed0c394fabb170c93a989ddd Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 27 Nov 2025 01:12:54 +0100 Subject: [PATCH 4/6] feat: add preSendMessageRequest --- package/src/components/Channel/Channel.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index ca2dc14308..149b93b529 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -432,6 +432,20 @@ export type ChannelPropsWithContext = Pick & messageData: StreamMessage, options?: SendMessageOptions, ) => Promise; + + /** + * A method invoked just after the first optimistic update of a new message, + * but before any other HTTP requests happen. Can be used to do extra work + * (such as creating a channel, or editing a message) before the local message + * is sent. + * @param channelId + * @param messageData Message object + */ + preSendMessageRequest?: (options: { + localMessage: LocalMessage; + message: StreamMessage; + options?: SendMessageOptions; + }) => Promise; /** * Overrides the Stream default update message request (Advanced usage only) * @param channelId @@ -569,6 +583,7 @@ const ChannelWithContext = (props: PropsWithChildren) = doFileUploadRequest, doMarkReadRequest, doSendMessageRequest, + preSendMessageRequest, doUpdateMessageRequest, EmptyStateIndicator = EmptyStateIndicatorDefault, enableMessageGroupingByUser = true, @@ -1454,6 +1469,9 @@ const ChannelWithContext = (props: PropsWithChildren) = { method: 'upsertMessages' }, ); + if (preSendMessageRequest) { + await preSendMessageRequest({ localMessage, message, options }); + } await sendMessageRequest({ localMessage, message, options }); }, ); From 7e01f19304b93d34e32dec9b2d65b6835d31f5d9 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 27 Nov 2025 14:30:18 +0100 Subject: [PATCH 5/6] fix: remove initialized checks where they make absolutely no sense --- package/src/components/Channel/Channel.tsx | 22 ++++++++++++++++--- .../MessageList/MessageFlashList.tsx | 2 +- .../components/MessageList/MessageList.tsx | 2 +- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 149b93b529..136588f320 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -505,6 +505,17 @@ export type ChannelPropsWithContext = Pick & * Tells if channel is rendering a thread list */ threadList?: boolean; + /** + * A boolean signifying whether the Channel component should run channel.watch() + * whenever it mounts up a new channel. If set to `false`, it is the integrator's + * responsibility to run channel.watch() if they wish to receive WebSocket events + * for that channel. + * + * Can be particularly useful whenever we are viewing channels in a read-only mode + * or perhaps want them in an ephemeral state (i.e not created until the first message + * is sent). + */ + initializeOnMount?: boolean; } & Partial< Pick< InputMessageInputContextValue, @@ -733,6 +744,7 @@ const ChannelWithContext = (props: PropsWithChildren) = VideoThumbnail = VideoThumbnailDefault, isOnline, maximumMessageLimit, + initializeOnMount = true, } = props; const { thread: threadProps, threadInstance } = threadFromProps; @@ -899,7 +911,7 @@ const ChannelWithContext = (props: PropsWithChildren) = } // only update channel state if the events are not the previously subscribed useEffect's subscription events - if (channel && channel.initialized) { + if (channel) { // we skip the new message events if we've already done an optimistic update for the new message if (event.type === 'message.new' || event.type === 'notification.message_new') { const messageId = event.message?.id ?? ''; @@ -933,13 +945,15 @@ const ChannelWithContext = (props: PropsWithChildren) = } let errored = false; - if (!channel.initialized || !channel.state.isUpToDate) { + if (!channel.initialized || !channel.state.isUpToDate || !initializeOnMount) { try { await channel?.watch(); + console.log('WATCHCALLED2'); } catch (err) { console.warn('Channel watch request failed with error:', err); setError(true); errored = true; + channel.offlineMode = true; } } @@ -1096,7 +1110,7 @@ const ChannelWithContext = (props: PropsWithChildren) = }); const resyncChannel = useStableCallback(async () => { - if (!channel || syncingChannelRef.current) { + if (!channel || syncingChannelRef.current || (!channel.initialized && !channel.offlineMode)) { return; } syncingChannelRef.current = true; @@ -1112,11 +1126,13 @@ const ChannelWithContext = (props: PropsWithChildren) = try { if (channelMessagesState?.messages) { + console.log('WATCHCALLED1'); await channel?.watch({ messages: { limit: channelMessagesState.messages.length + 30, }, }); + channel.offlineMode = false; } if (!thread) { diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index a5cdce21bc..efd775cd99 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -718,7 +718,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const renderItem = useCallback( ({ index, item: message }: { index: number; item: LocalMessage }) => { - if (!channel || channel.disconnected || (!channel.initialized && !channel.offlineMode)) { + if (!channel || channel.disconnected) { return null; } diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index ddc300cdb1..d6ed5a9285 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -781,7 +781,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const renderItem = useCallback( ({ index, item: message }: { index: number; item: LocalMessage }) => { - if (!channel || channel.disconnected || (!channel.initialized && !channel.offlineMode)) { + if (!channel || channel.disconnected) { return null; } From 51ac0e62afbb7d2485b772695a98b4af8d21e7ab Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 27 Nov 2025 14:36:43 +0100 Subject: [PATCH 6/6] chore: remove logs --- package/src/components/Channel/Channel.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 136588f320..14e64932d3 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -948,7 +948,6 @@ const ChannelWithContext = (props: PropsWithChildren) = if (!channel.initialized || !channel.state.isUpToDate || !initializeOnMount) { try { await channel?.watch(); - console.log('WATCHCALLED2'); } catch (err) { console.warn('Channel watch request failed with error:', err); setError(true); @@ -1126,7 +1125,6 @@ const ChannelWithContext = (props: PropsWithChildren) = try { if (channelMessagesState?.messages) { - console.log('WATCHCALLED1'); await channel?.watch({ messages: { limit: channelMessagesState.messages.length + 30,