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,