From 3e21355594eece07bf08eca237a86aed745e9921 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Fri, 28 Nov 2025 14:28:59 +0530 Subject: [PATCH 1/3] fix: change message info bottom sheet modal (#3302) --- .../src/components/MessageInfoBottomSheet.tsx | 23 ++++++++-------- .../SampleApp/src/screens/ChannelScreen.tsx | 27 +++++++++++-------- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/examples/SampleApp/src/components/MessageInfoBottomSheet.tsx b/examples/SampleApp/src/components/MessageInfoBottomSheet.tsx index b989c32203..a4ad65728b 100644 --- a/examples/SampleApp/src/components/MessageInfoBottomSheet.tsx +++ b/examples/SampleApp/src/components/MessageInfoBottomSheet.tsx @@ -1,15 +1,14 @@ import React, { useMemo } from 'react'; -import BottomSheet, { BottomSheetFlatList } from '@gorhom/bottom-sheet'; -import { BottomSheetView } from '@gorhom/bottom-sheet'; import { Avatar, + BottomSheetModal, useChatContext, useMessageDeliveredData, useMessageReadData, useTheme, } from 'stream-chat-react-native'; import { LocalMessage, UserResponse } from 'stream-chat'; -import { StyleSheet, Text, View } from 'react-native'; +import { FlatList, StyleSheet, Text, View } from 'react-native'; const renderUserItem = ({ item }: { item: UserResponse }) => ( @@ -24,10 +23,12 @@ const renderEmptyText = ({ text }: { text: string }) => ( export const MessageInfoBottomSheet = ({ message, - ref, + visible, + onClose, }: { message?: LocalMessage; - ref: React.RefObject; + visible: boolean; + onClose: () => void; }) => { const { theme: { colors }, @@ -45,10 +46,10 @@ export const MessageInfoBottomSheet = ({ }, [readStatus, client?.user?.id]); return ( - - + + Read - item.id} @@ -56,15 +57,15 @@ export const MessageInfoBottomSheet = ({ ListEmptyComponent={renderEmptyText({ text: 'No one has read this message.' })} /> Delivered - item.id} style={styles.flatList} ListEmptyComponent={renderEmptyText({ text: 'The message was not delivered to anyone.' })} /> - - + + ); }; diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index c978ccfe5a..406938e40d 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import type { LocalMessage, Channel as StreamChatChannel } from 'stream-chat'; import { RouteProp, useFocusEffect, useNavigation } from '@react-navigation/native'; import { @@ -33,7 +33,6 @@ import { channelMessageActions } from '../utils/messageActions.tsx'; import { MessageLocation } from '../components/LocationSharing/MessageLocation.tsx'; import { useStreamChatContext } from '../context/StreamChatContext.tsx'; import { CustomAttachmentPickerSelectionBar } from '../components/AttachmentPickerSelectionBar.tsx'; -import BottomSheet from '@gorhom/bottom-sheet'; import { MessageInfoBottomSheet } from '../components/MessageInfoBottomSheet.tsx'; export type ChannelScreenNavigationProp = NativeStackNavigationProp< @@ -130,6 +129,7 @@ export const ChannelScreen: React.FC = ({ } = useTheme(); const { t } = useTranslationContext(); const { setThread } = useStreamChatContext(); + const [modalVisible, setModalVisible] = useState(false); const [selectedMessage, setSelectedMessage] = useState(undefined); const [channel, setChannel] = useState(channelFromProp); @@ -186,15 +186,14 @@ export const ChannelScreen: React.FC = ({ [channel, navigation, setThread], ); - const messageInfoBottomSheetRef = useRef(null); + const handleMessageInfo = useCallback((message: LocalMessage) => { + setSelectedMessage(message); + setModalVisible(true); + }, []); - const handleMessageInfo = useCallback( - (message: LocalMessage) => { - setSelectedMessage(message); - messageInfoBottomSheetRef.current?.snapToIndex(1); - }, - [messageInfoBottomSheetRef], - ); + const handleMessageInfoClose = useCallback(() => { + setModalVisible(false); + }, []); const messageActions = useCallback( (params: MessageActionsParams) => { @@ -249,7 +248,13 @@ export const ChannelScreen: React.FC = ({ )} - + {modalVisible && ( + + )} ); From d409eb8f17ab3a55666de03d54ca402242f20e96 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Fri, 28 Nov 2025 11:33:05 +0100 Subject: [PATCH 2/3] feat: add support for ephemeral channels (#3301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal This PR provides a variety of fixes in our Chat SDK that made it either impossible (or too difficult to do without hacks) for us to send a message in an ephemeral channel. At a certain point, this should be abstracted away in a more sophisticated fashion however it'll have to do for now. It also adds 2 new properties on the `Channel` component that can be used as utilities for this usecase. ## ๐Ÿ›  Implementation details ## ๐ŸŽจ UI Changes
iOS
Before After
Android
Before After
## ๐Ÿงช Testing ## โ˜‘๏ธ 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 --- package/src/components/Channel/Channel.tsx | 114 +++++++++++++----- .../Channel/hooks/useCreateChannelContext.ts | 4 +- .../useCreateInputMessageInputContext.ts | 10 +- .../MessageList/MessageFlashList.tsx | 2 +- .../components/MessageList/MessageList.tsx | 2 +- .../MessageInputContext.tsx | 17 ++- package/src/store/SqliteClient.ts | 1 - 7 files changed, 110 insertions(+), 40 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index b2674cdd79..14e64932d3 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -102,7 +102,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'; @@ -433,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 @@ -492,10 +505,24 @@ 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, - 'openPollCreationDialog' | 'CreatePollContent' | 'StopMessageStreamingButton' + | 'openPollCreationDialog' + | 'CreatePollContent' + | 'StopMessageStreamingButton' + | 'allowSendBeforeAttachmentsUpload' > >; @@ -567,10 +594,12 @@ const ChannelWithContext = (props: PropsWithChildren) = doFileUploadRequest, doMarkReadRequest, doSendMessageRequest, + preSendMessageRequest, doUpdateMessageRequest, EmptyStateIndicator = EmptyStateIndicatorDefault, enableMessageGroupingByUser = true, enableOfflineSupport, + allowSendBeforeAttachmentsUpload = enableOfflineSupport, enableSwipeToReply = true, enforceUniqueReaction = false, FileAttachment = FileAttachmentDefault, @@ -715,6 +744,7 @@ const ChannelWithContext = (props: PropsWithChildren) = VideoThumbnail = VideoThumbnailDefault, isOnline, maximumMessageLimit, + initializeOnMount = true, } = props; const { thread: threadProps, threadInstance } = threadFromProps; @@ -881,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 ?? ''; @@ -915,13 +945,14 @@ const ChannelWithContext = (props: PropsWithChildren) = } let errored = false; - if (!channel.initialized || !channel.state.isUpToDate) { + if (!channel.initialized || !channel.state.isUpToDate || !initializeOnMount) { try { await channel?.watch(); } catch (err) { console.warn('Channel watch request failed with error:', err); setError(true); errored = true; + channel.offlineMode = true; } } @@ -1078,7 +1109,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; @@ -1099,6 +1130,7 @@ const ChannelWithContext = (props: PropsWithChildren) = limit: channelMessagesState.messages.length + 30, }, }); + channel.offlineMode = false; } if (!thread) { @@ -1300,9 +1332,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: 'updateMessage' }, + ); } if (attachment.type !== FileTypes.Image && file?.uri) { @@ -1321,9 +1357,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: 'updateMessage' }, + ); } } } @@ -1344,7 +1384,7 @@ const ChannelWithContext = (props: PropsWithChildren) = retrying?: boolean; }) => { let failedMessageUpdated = false; - const handleFailedMessage = async () => { + const handleFailedMessage = () => { if (!failedMessageUpdated) { const updatedMessage = { ...localMessage, @@ -1355,11 +1395,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: 'updateMessage' }, + ); failedMessageUpdated = true; } @@ -1397,11 +1439,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: 'updateMessage' }, + ); + if (retrying) { replaceMessage(localMessage, newMessageResponse); } else { @@ -1425,16 +1470,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( + (db) => + db.upsertMessages({ + // @ts-ignore + messages: [{ ...localMessage, cid: channel.cid, status: MessageStatusTypes.FAILED }], + }), + { method: 'upsertMessages' }, + ); + if (preSendMessageRequest) { + await preSendMessageRequest({ localMessage, message, options }); + } await sendMessageRequest({ localMessage, message, options }); }, ); @@ -1756,6 +1807,7 @@ const ChannelWithContext = (props: PropsWithChildren) = const inputMessageInputContext = useCreateInputMessageInputContext({ additionalTextInputProps, + allowSendBeforeAttachmentsUpload, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, asyncMessagesMultiSendEnabled, 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/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/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; } 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 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 383b48feb1cf02892474b55249e43ce399234d15 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Fri, 28 Nov 2025 13:24:39 +0100 Subject: [PATCH 3/3] fix: properly use initializeOnMount (#3304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal ## ๐Ÿ›  Implementation details ## ๐ŸŽจ UI Changes
iOS
Before After
Android
Before After
## ๐Ÿงช Testing ## โ˜‘๏ธ 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 --- package/src/components/Channel/Channel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 14e64932d3..a731acd6a4 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -945,7 +945,7 @@ const ChannelWithContext = (props: PropsWithChildren) = } let errored = false; - if (!channel.initialized || !channel.state.isUpToDate || !initializeOnMount) { + if ((!channel.initialized || !channel.state.isUpToDate) && initializeOnMount) { try { await channel?.watch(); } catch (err) {