Skip to content

Commit d409eb8

Browse files
authored
feat: add support for ephemeral channels (#3301)
## 🎯 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 <!-- Provide a description of the implementation --> ## 🎨 UI Changes <!-- Add relevant screenshots --> <details> <summary>iOS</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> <details> <summary>Android</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> ## 🧪 Testing <!-- Explain how this change can be tested (or why it can't be tested) --> ## ☑️ 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
1 parent 3e21355 commit d409eb8

File tree

7 files changed

+110
-40
lines changed

7 files changed

+110
-40
lines changed

package/src/components/Channel/Channel.tsx

Lines changed: 83 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,6 @@ import {
102102
isImagePickerAvailable,
103103
NativeHandlers,
104104
} from '../../native';
105-
import * as dbApi from '../../store/apis';
106105
import { ChannelUnreadState, FileTypes } from '../../types/types';
107106
import { addReactionToLocalState } from '../../utils/addReactionToLocalState';
108107
import { compressedImageURI } from '../../utils/compressImage';
@@ -433,6 +432,20 @@ export type ChannelPropsWithContext = Pick<ChannelContextValue, 'channel'> &
433432
messageData: StreamMessage,
434433
options?: SendMessageOptions,
435434
) => Promise<SendMessageAPIResponse>;
435+
436+
/**
437+
* A method invoked just after the first optimistic update of a new message,
438+
* but before any other HTTP requests happen. Can be used to do extra work
439+
* (such as creating a channel, or editing a message) before the local message
440+
* is sent.
441+
* @param channelId
442+
* @param messageData Message object
443+
*/
444+
preSendMessageRequest?: (options: {
445+
localMessage: LocalMessage;
446+
message: StreamMessage;
447+
options?: SendMessageOptions;
448+
}) => Promise<SendMessageAPIResponse>;
436449
/**
437450
* Overrides the Stream default update message request (Advanced usage only)
438451
* @param channelId
@@ -492,10 +505,24 @@ export type ChannelPropsWithContext = Pick<ChannelContextValue, 'channel'> &
492505
* Tells if channel is rendering a thread list
493506
*/
494507
threadList?: boolean;
508+
/**
509+
* A boolean signifying whether the Channel component should run channel.watch()
510+
* whenever it mounts up a new channel. If set to `false`, it is the integrator's
511+
* responsibility to run channel.watch() if they wish to receive WebSocket events
512+
* for that channel.
513+
*
514+
* Can be particularly useful whenever we are viewing channels in a read-only mode
515+
* or perhaps want them in an ephemeral state (i.e not created until the first message
516+
* is sent).
517+
*/
518+
initializeOnMount?: boolean;
495519
} & Partial<
496520
Pick<
497521
InputMessageInputContextValue,
498-
'openPollCreationDialog' | 'CreatePollContent' | 'StopMessageStreamingButton'
522+
| 'openPollCreationDialog'
523+
| 'CreatePollContent'
524+
| 'StopMessageStreamingButton'
525+
| 'allowSendBeforeAttachmentsUpload'
499526
>
500527
>;
501528

@@ -567,10 +594,12 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
567594
doFileUploadRequest,
568595
doMarkReadRequest,
569596
doSendMessageRequest,
597+
preSendMessageRequest,
570598
doUpdateMessageRequest,
571599
EmptyStateIndicator = EmptyStateIndicatorDefault,
572600
enableMessageGroupingByUser = true,
573601
enableOfflineSupport,
602+
allowSendBeforeAttachmentsUpload = enableOfflineSupport,
574603
enableSwipeToReply = true,
575604
enforceUniqueReaction = false,
576605
FileAttachment = FileAttachmentDefault,
@@ -715,6 +744,7 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
715744
VideoThumbnail = VideoThumbnailDefault,
716745
isOnline,
717746
maximumMessageLimit,
747+
initializeOnMount = true,
718748
} = props;
719749

720750
const { thread: threadProps, threadInstance } = threadFromProps;
@@ -881,7 +911,7 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
881911
}
882912

883913
// only update channel state if the events are not the previously subscribed useEffect's subscription events
884-
if (channel && channel.initialized) {
914+
if (channel) {
885915
// we skip the new message events if we've already done an optimistic update for the new message
886916
if (event.type === 'message.new' || event.type === 'notification.message_new') {
887917
const messageId = event.message?.id ?? '';
@@ -915,13 +945,14 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
915945
}
916946
let errored = false;
917947

918-
if (!channel.initialized || !channel.state.isUpToDate) {
948+
if (!channel.initialized || !channel.state.isUpToDate || !initializeOnMount) {
919949
try {
920950
await channel?.watch();
921951
} catch (err) {
922952
console.warn('Channel watch request failed with error:', err);
923953
setError(true);
924954
errored = true;
955+
channel.offlineMode = true;
925956
}
926957
}
927958

@@ -1078,7 +1109,7 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
10781109
});
10791110

10801111
const resyncChannel = useStableCallback(async () => {
1081-
if (!channel || syncingChannelRef.current) {
1112+
if (!channel || syncingChannelRef.current || (!channel.initialized && !channel.offlineMode)) {
10821113
return;
10831114
}
10841115
syncingChannelRef.current = true;
@@ -1099,6 +1130,7 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
10991130
limit: channelMessagesState.messages.length + 30,
11001131
},
11011132
});
1133+
channel.offlineMode = false;
11021134
}
11031135

11041136
if (!thread) {
@@ -1300,9 +1332,13 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
13001332
attachment.image_url = uploadResponse.file;
13011333
delete attachment.originalFile;
13021334

1303-
await dbApi.updateMessage({
1304-
message: { ...updatedMessage, cid: channel.cid },
1305-
});
1335+
client.offlineDb?.executeQuerySafely(
1336+
(db) =>
1337+
db.updateMessage({
1338+
message: { ...updatedMessage, cid: channel.cid },
1339+
}),
1340+
{ method: 'updateMessage' },
1341+
);
13061342
}
13071343

13081344
if (attachment.type !== FileTypes.Image && file?.uri) {
@@ -1321,9 +1357,13 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
13211357
}
13221358

13231359
delete attachment.originalFile;
1324-
await dbApi.updateMessage({
1325-
message: { ...updatedMessage, cid: channel.cid },
1326-
});
1360+
client.offlineDb?.executeQuerySafely(
1361+
(db) =>
1362+
db.updateMessage({
1363+
message: { ...updatedMessage, cid: channel.cid },
1364+
}),
1365+
{ method: 'updateMessage' },
1366+
);
13271367
}
13281368
}
13291369
}
@@ -1344,7 +1384,7 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
13441384
retrying?: boolean;
13451385
}) => {
13461386
let failedMessageUpdated = false;
1347-
const handleFailedMessage = async () => {
1387+
const handleFailedMessage = () => {
13481388
if (!failedMessageUpdated) {
13491389
const updatedMessage = {
13501390
...localMessage,
@@ -1355,11 +1395,13 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
13551395
threadInstance?.upsertReplyLocally?.({ message: updatedMessage });
13561396
optimisticallyUpdatedNewMessages.delete(localMessage.id);
13571397

1358-
if (enableOfflineSupport) {
1359-
await dbApi.updateMessage({
1360-
message: updatedMessage,
1361-
});
1362-
}
1398+
client.offlineDb?.executeQuerySafely(
1399+
(db) =>
1400+
db.updateMessage({
1401+
message: updatedMessage,
1402+
}),
1403+
{ method: 'updateMessage' },
1404+
);
13631405

13641406
failedMessageUpdated = true;
13651407
}
@@ -1397,11 +1439,14 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
13971439
status: MessageStatusTypes.RECEIVED,
13981440
};
13991441

1400-
if (enableOfflineSupport) {
1401-
await dbApi.updateMessage({
1402-
message: { ...newMessageResponse, cid: channel.cid },
1403-
});
1404-
}
1442+
client.offlineDb?.executeQuerySafely(
1443+
(db) =>
1444+
db.updateMessage({
1445+
message: { ...newMessageResponse, cid: channel.cid },
1446+
}),
1447+
{ method: 'updateMessage' },
1448+
);
1449+
14051450
if (retrying) {
14061451
replaceMessage(localMessage, newMessageResponse);
14071452
} else {
@@ -1425,16 +1470,22 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
14251470
threadInstance?.upsertReplyLocally?.({ message: localMessage });
14261471
optimisticallyUpdatedNewMessages.add(localMessage.id);
14271472

1428-
if (enableOfflineSupport) {
1429-
// While sending a message, we add the message to local db with failed status, so that
1430-
// if app gets closed before message gets sent and next time user opens the app
1431-
// then user can see that message in failed state and can retry.
1432-
// If succesfull, it will be updated with received status.
1433-
await dbApi.upsertMessages({
1434-
messages: [{ ...localMessage, cid: channel.cid, status: MessageStatusTypes.FAILED }],
1435-
});
1436-
}
1473+
// While sending a message, we add the message to local db with failed status, so that
1474+
// if app gets closed before message gets sent and next time user opens the app
1475+
// then user can see that message in failed state and can retry.
1476+
// If succesfull, it will be updated with received status.
1477+
client.offlineDb?.executeQuerySafely(
1478+
(db) =>
1479+
db.upsertMessages({
1480+
// @ts-ignore
1481+
messages: [{ ...localMessage, cid: channel.cid, status: MessageStatusTypes.FAILED }],
1482+
}),
1483+
{ method: 'upsertMessages' },
1484+
);
14371485

1486+
if (preSendMessageRequest) {
1487+
await preSendMessageRequest({ localMessage, message, options });
1488+
}
14381489
await sendMessageRequest({ localMessage, message, options });
14391490
},
14401491
);
@@ -1756,6 +1807,7 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
17561807

17571808
const inputMessageInputContext = useCreateInputMessageInputContext({
17581809
additionalTextInputProps,
1810+
allowSendBeforeAttachmentsUpload,
17591811
asyncMessagesLockDistance,
17601812
asyncMessagesMinimumPressDuration,
17611813
asyncMessagesMultiSendEnabled,

package/src/components/Channel/hooks/useCreateChannelContext.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ export const useCreateChannelContext = ({
4343

4444
const readUsers = Object.values(read);
4545
const readUsersLength = readUsers.length;
46-
const readUsersLastReads = readUsers.map(({ last_read }) => last_read.toISOString()).join();
46+
const readUsersLastReads = readUsers
47+
.map(({ last_read }) => last_read?.toISOString() ?? '')
48+
.join();
4749
const stringifiedChannelUnreadState = JSON.stringify(channelUnreadState);
4850

4951
const channelContext: ChannelContextValue = useMemo(

package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { InputMessageInputContextValue } from '../../../contexts/messageInp
44

55
export const useCreateInputMessageInputContext = ({
66
additionalTextInputProps,
7+
allowSendBeforeAttachmentsUpload,
78
asyncMessagesLockDistance,
89
asyncMessagesMinimumPressDuration,
910
asyncMessagesMultiSendEnabled,
@@ -70,6 +71,7 @@ export const useCreateInputMessageInputContext = ({
7071
const inputMessageInputContext: InputMessageInputContextValue = useMemo(
7172
() => ({
7273
additionalTextInputProps,
74+
allowSendBeforeAttachmentsUpload,
7375
asyncMessagesLockDistance,
7476
asyncMessagesMinimumPressDuration,
7577
asyncMessagesMultiSendEnabled,
@@ -128,7 +130,13 @@ export const useCreateInputMessageInputContext = ({
128130
VideoRecorderSelectorIcon,
129131
}),
130132
// eslint-disable-next-line react-hooks/exhaustive-deps
131-
[compressImageQuality, channelId, CreatePollContent, showPollCreationDialog],
133+
[
134+
compressImageQuality,
135+
channelId,
136+
CreatePollContent,
137+
showPollCreationDialog,
138+
allowSendBeforeAttachmentsUpload,
139+
],
132140
);
133141

134142
return inputMessageInputContext;

package/src/components/MessageList/MessageFlashList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -718,7 +718,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
718718

719719
const renderItem = useCallback(
720720
({ index, item: message }: { index: number; item: LocalMessage }) => {
721-
if (!channel || channel.disconnected || (!channel.initialized && !channel.offlineMode)) {
721+
if (!channel || channel.disconnected) {
722722
return null;
723723
}
724724

package/src/components/MessageList/MessageList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -781,7 +781,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
781781

782782
const renderItem = useCallback(
783783
({ index, item: message }: { index: number; item: LocalMessage }) => {
784-
if (!channel || channel.disconnected || (!channel.initialized && !channel.offlineMode)) {
784+
if (!channel || channel.disconnected) {
785785
return null;
786786
}
787787

package/src/contexts/messageInputContext/MessageInputContext.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,7 @@ export type InputMessageInputContextValue = {
328328
* @see See https://reactnative.dev/docs/textinput#reference
329329
*/
330330
additionalTextInputProps?: TextInputProps;
331+
allowSendBeforeAttachmentsUpload?: boolean;
331332
closePollCreationDialog?: () => void;
332333
/**
333334
* Compress image with quality (from 0 to 1, where 1 is best quality).
@@ -411,7 +412,7 @@ export const MessageInputProvider = ({
411412
}>) => {
412413
const { closePicker, openPicker, selectedPicker, setSelectedPicker } =
413414
useAttachmentPickerContext();
414-
const { client, enableOfflineSupport } = useChatContext();
415+
const { client } = useChatContext();
415416
const channelCapabilities = useOwnCapabilitiesContext();
416417

417418
const { uploadAbortControllerRef } = useChannelContext();
@@ -425,7 +426,10 @@ export const MessageInputProvider = ({
425426
const defaultOpenPollCreationDialog = useCallback(() => setShowPollCreationDialog(true), []);
426427
const closePollCreationDialog = useCallback(() => setShowPollCreationDialog(false), []);
427428

428-
const { openPollCreationDialog: openPollCreationDialogFromContext } = value;
429+
const {
430+
openPollCreationDialog: openPollCreationDialogFromContext,
431+
allowSendBeforeAttachmentsUpload,
432+
} = value;
429433

430434
const { endsAt: cooldownEndsAt, start: startCooldown } = useCooldown();
431435

@@ -443,7 +447,7 @@ export const MessageInputProvider = ({
443447
attachmentManager.setCustomUploadFn(value.doFileUploadRequest);
444448
}
445449

446-
if (enableOfflineSupport) {
450+
if (allowSendBeforeAttachmentsUpload) {
447451
messageComposer.compositionMiddlewareExecutor.replace([
448452
createAttachmentsCompositionMiddleware(messageComposer),
449453
]);
@@ -452,7 +456,12 @@ export const MessageInputProvider = ({
452456
createDraftAttachmentsCompositionMiddleware(messageComposer),
453457
]);
454458
}
455-
}, [value.doFileUploadRequest, enableOfflineSupport, messageComposer, attachmentManager]);
459+
}, [
460+
value.doFileUploadRequest,
461+
allowSendBeforeAttachmentsUpload,
462+
messageComposer,
463+
attachmentManager,
464+
]);
456465

457466
/**
458467
* Function for capturing a photo and uploading it

package/src/store/SqliteClient.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,6 @@ export class SqliteClient {
9696
});
9797
await this.db.executeBatch(finalQueries);
9898
} catch (e) {
99-
this.db?.execute('ROLLBACK');
10099
this.logger?.('error', 'SqlBatch queries failed', {
101100
error: e,
102101
queries,

0 commit comments

Comments
 (0)