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 && (
+
+ )}
);
diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx
index b2674cdd79..a731acd6a4 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,