stream-chat-react-native-core 5.4.1 → 5.4.2-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/lib/commonjs/components/AttachmentPicker/AttachmentPicker.js +122 -433
  2. package/lib/commonjs/components/AttachmentPicker/AttachmentPicker.js.map +1 -1
  3. package/lib/commonjs/components/AttachmentPicker/components/AttachmentPickerItem.js +358 -0
  4. package/lib/commonjs/components/AttachmentPicker/components/AttachmentPickerItem.js.map +1 -0
  5. package/lib/commonjs/components/MessageList/MessageList.js +59 -40
  6. package/lib/commonjs/components/MessageList/MessageList.js.map +1 -1
  7. package/lib/commonjs/components/MessageList/NetworkDownIndicator.js +14 -6
  8. package/lib/commonjs/components/MessageList/NetworkDownIndicator.js.map +1 -1
  9. package/lib/commonjs/components/MessageList/hooks/useMessageList.js +4 -3
  10. package/lib/commonjs/components/MessageList/hooks/useMessageList.js.map +1 -1
  11. package/lib/commonjs/contexts/messageInputContext/MessageInputContext.js +134 -85
  12. package/lib/commonjs/contexts/messageInputContext/MessageInputContext.js.map +1 -1
  13. package/lib/commonjs/contexts/overlayContext/OverlayProvider.js +39 -17
  14. package/lib/commonjs/contexts/overlayContext/OverlayProvider.js.map +1 -1
  15. package/lib/commonjs/hooks/useAppStateListener.js +9 -15
  16. package/lib/commonjs/hooks/useAppStateListener.js.map +1 -1
  17. package/lib/commonjs/mock-builders/generator/message.js +1 -1
  18. package/lib/commonjs/mock-builders/generator/message.js.map +1 -1
  19. package/lib/commonjs/version.json +1 -1
  20. package/lib/module/components/AttachmentPicker/AttachmentPicker.js +122 -433
  21. package/lib/module/components/AttachmentPicker/AttachmentPicker.js.map +1 -1
  22. package/lib/module/components/AttachmentPicker/components/AttachmentPickerItem.js +358 -0
  23. package/lib/module/components/AttachmentPicker/components/AttachmentPickerItem.js.map +1 -0
  24. package/lib/module/components/MessageList/MessageList.js +59 -40
  25. package/lib/module/components/MessageList/MessageList.js.map +1 -1
  26. package/lib/module/components/MessageList/NetworkDownIndicator.js +14 -6
  27. package/lib/module/components/MessageList/NetworkDownIndicator.js.map +1 -1
  28. package/lib/module/components/MessageList/hooks/useMessageList.js +4 -3
  29. package/lib/module/components/MessageList/hooks/useMessageList.js.map +1 -1
  30. package/lib/module/contexts/messageInputContext/MessageInputContext.js +134 -85
  31. package/lib/module/contexts/messageInputContext/MessageInputContext.js.map +1 -1
  32. package/lib/module/contexts/overlayContext/OverlayProvider.js +39 -17
  33. package/lib/module/contexts/overlayContext/OverlayProvider.js.map +1 -1
  34. package/lib/module/hooks/useAppStateListener.js +9 -15
  35. package/lib/module/hooks/useAppStateListener.js.map +1 -1
  36. package/lib/module/mock-builders/generator/message.js +1 -1
  37. package/lib/module/mock-builders/generator/message.js.map +1 -1
  38. package/lib/module/version.json +1 -1
  39. package/lib/typescript/components/AttachmentPicker/AttachmentPicker.d.ts +0 -11
  40. package/lib/typescript/components/AttachmentPicker/components/AttachmentPickerItem.d.ts +16 -0
  41. package/lib/typescript/components/MessageList/__tests__/useMessageList.test.d.ts +1 -0
  42. package/lib/typescript/components/MessageList/hooks/useMessageList.d.ts +0 -1
  43. package/lib/typescript/contexts/messageInputContext/MessageInputContext.d.ts +1 -1
  44. package/lib/typescript/hooks/__tests__/useAppStateListener.test.d.ts +1 -0
  45. package/package.json +2 -2
  46. package/src/components/AttachmentPicker/AttachmentPicker.tsx +79 -322
  47. package/src/components/AttachmentPicker/components/AttachmentPickerItem.tsx +245 -0
  48. package/src/components/MessageList/MessageList.tsx +21 -20
  49. package/src/components/MessageList/NetworkDownIndicator.tsx +13 -10
  50. package/src/components/MessageList/__tests__/useMessageList.test.tsx +67 -0
  51. package/src/components/MessageList/hooks/useMessageList.ts +4 -7
  52. package/src/contexts/messageInputContext/MessageInputContext.tsx +27 -25
  53. package/src/contexts/overlayContext/OverlayProvider.tsx +28 -4
  54. package/src/hooks/__tests__/useAppStateListener.test.tsx +41 -0
  55. package/src/hooks/useAppStateListener.ts +9 -12
  56. package/src/mock-builders/generator/message.js +1 -1
  57. package/src/version.json +1 -1
@@ -0,0 +1,245 @@
1
+ import React from 'react';
2
+
3
+ import { Alert, ImageBackground, StyleSheet, Text, View } from 'react-native';
4
+
5
+ import { TouchableOpacity } from '@gorhom/bottom-sheet';
6
+ import dayjs from 'dayjs';
7
+ import { lookup } from 'mime-types';
8
+
9
+ import { useTheme } from '../../../contexts/themeContext/ThemeContext';
10
+ import { Recorder } from '../../../icons';
11
+ import { getLocalAssetUri } from '../../../native';
12
+ import type { Asset, File } from '../../../types/types';
13
+ import { vw } from '../../../utils/utils';
14
+
15
+ type AttachmentPickerItemType = {
16
+ asset: Asset;
17
+ ImageOverlaySelectedComponent: React.ComponentType;
18
+ maxNumberOfFiles: number;
19
+ numberOfUploads: number;
20
+ selected: boolean;
21
+ setSelectedFiles: React.Dispatch<React.SetStateAction<File[]>>;
22
+ setSelectedImages: React.Dispatch<React.SetStateAction<Asset[]>>;
23
+ numberOfAttachmentPickerImageColumns?: number;
24
+ };
25
+
26
+ type AttachmentImageProps = Omit<AttachmentPickerItemType, 'setSelectedFiles'>;
27
+
28
+ type AttachmentVideoProps = Omit<AttachmentPickerItemType, 'setSelectedImages'>;
29
+
30
+ const AttachmentVideo: React.FC<AttachmentVideoProps> = (props) => {
31
+ const {
32
+ asset,
33
+ ImageOverlaySelectedComponent,
34
+ maxNumberOfFiles,
35
+ numberOfAttachmentPickerImageColumns,
36
+ numberOfUploads,
37
+ selected,
38
+ setSelectedFiles,
39
+ } = props;
40
+
41
+ const {
42
+ theme: {
43
+ attachmentPicker: { durationText, image, imageOverlay },
44
+ colors: { overlay, white },
45
+ },
46
+ } = useTheme();
47
+
48
+ const { duration, playableDuration, uri } = asset;
49
+
50
+ const videoDuration = duration ? duration : playableDuration;
51
+
52
+ const ONE_HOUR_IN_SECONDS = 3600;
53
+
54
+ let durationLabel = '00:00';
55
+
56
+ if (videoDuration) {
57
+ const isDurationLongerThanHour = videoDuration / ONE_HOUR_IN_SECONDS >= 1;
58
+ const formattedDurationParam = isDurationLongerThanHour ? 'HH:mm:ss' : 'mm:ss';
59
+ const formattedVideoDuration = dayjs
60
+ .duration(videoDuration, 'second')
61
+ .format(formattedDurationParam);
62
+ durationLabel = formattedVideoDuration;
63
+ }
64
+
65
+ const size = vw(100) / (numberOfAttachmentPickerImageColumns || 3) - 2;
66
+
67
+ const onPressVideo = async () => {
68
+ // For the case of expo messaging app where you need to fetch the asset uri from asset id
69
+ const localAssetURI = asset.id && (await getLocalAssetUri(asset.id));
70
+ if (selected) {
71
+ setSelectedFiles((files) => files.filter((file) => file.uri !== asset.uri));
72
+ } else {
73
+ setSelectedFiles((files) => {
74
+ if (numberOfUploads >= maxNumberOfFiles) {
75
+ Alert.alert('Maximum number of files reached');
76
+ return files;
77
+ }
78
+ return [
79
+ ...files,
80
+ {
81
+ duration: durationLabel,
82
+ name: asset.filename,
83
+ size: asset.fileSize,
84
+ type: 'video',
85
+ uri: localAssetURI || asset.uri,
86
+ },
87
+ ];
88
+ });
89
+ }
90
+ };
91
+
92
+ return (
93
+ <TouchableOpacity onPress={onPressVideo}>
94
+ <ImageBackground
95
+ source={{ uri }}
96
+ style={[
97
+ {
98
+ height: size,
99
+ margin: 1,
100
+ width: size,
101
+ },
102
+ image,
103
+ ]}
104
+ >
105
+ {selected && (
106
+ <View style={[styles.overlay, { backgroundColor: overlay }, imageOverlay]}>
107
+ <ImageOverlaySelectedComponent />
108
+ </View>
109
+ )}
110
+ <View style={styles.videoView}>
111
+ <Recorder height={20} pathFill={white} width={25} />
112
+ {videoDuration ? (
113
+ <Text style={[styles.durationText, durationText, { color: white }]}>
114
+ {durationLabel}
115
+ </Text>
116
+ ) : null}
117
+ </View>
118
+ </ImageBackground>
119
+ </TouchableOpacity>
120
+ );
121
+ };
122
+
123
+ const AttachmentImage: React.FC<AttachmentImageProps> = (props) => {
124
+ const {
125
+ asset,
126
+ ImageOverlaySelectedComponent,
127
+ maxNumberOfFiles,
128
+ numberOfAttachmentPickerImageColumns,
129
+ numberOfUploads,
130
+ selected,
131
+ setSelectedImages,
132
+ } = props;
133
+ const {
134
+ theme: {
135
+ attachmentPicker: { image, imageOverlay },
136
+ colors: { overlay },
137
+ },
138
+ } = useTheme();
139
+
140
+ const size = vw(100) / (numberOfAttachmentPickerImageColumns || 3) - 2;
141
+
142
+ const { uri } = asset;
143
+
144
+ const onPressImage = () => {
145
+ if (selected) {
146
+ setSelectedImages((images) => images.filter((image) => image.uri !== asset.uri));
147
+ } else {
148
+ setSelectedImages((images) => {
149
+ if (numberOfUploads >= maxNumberOfFiles) {
150
+ Alert.alert('Maximum number of files reached');
151
+ return images;
152
+ }
153
+ return [...images, asset];
154
+ });
155
+ }
156
+ };
157
+
158
+ return (
159
+ <TouchableOpacity onPress={onPressImage}>
160
+ <ImageBackground
161
+ source={{ uri }}
162
+ style={[
163
+ {
164
+ height: size,
165
+ margin: 1,
166
+ width: size,
167
+ },
168
+ image,
169
+ ]}
170
+ >
171
+ {selected && (
172
+ <View style={[styles.overlay, { backgroundColor: overlay }, imageOverlay]}>
173
+ <ImageOverlaySelectedComponent />
174
+ </View>
175
+ )}
176
+ </ImageBackground>
177
+ </TouchableOpacity>
178
+ );
179
+ };
180
+
181
+ const getFileType = (asset: Asset) => {
182
+ const { filename } = asset;
183
+ if (filename) {
184
+ const contentType = lookup(filename) || 'multipart/form-data';
185
+ return contentType.startsWith('image/') ? 'image' : 'video';
186
+ } else {
187
+ return asset.type === 'video' ? 'video' : 'image';
188
+ }
189
+ };
190
+
191
+ export const renderAttachmentPickerItem = ({ item }: { item: AttachmentPickerItemType }) => {
192
+ const {
193
+ asset,
194
+ ImageOverlaySelectedComponent,
195
+ maxNumberOfFiles,
196
+ numberOfAttachmentPickerImageColumns,
197
+ numberOfUploads,
198
+ selected,
199
+ setSelectedFiles,
200
+ setSelectedImages,
201
+ } = item;
202
+
203
+ const fileType = getFileType(asset);
204
+
205
+ return fileType === 'image' ? (
206
+ <AttachmentImage
207
+ asset={asset}
208
+ ImageOverlaySelectedComponent={ImageOverlaySelectedComponent}
209
+ maxNumberOfFiles={maxNumberOfFiles}
210
+ numberOfAttachmentPickerImageColumns={numberOfAttachmentPickerImageColumns}
211
+ numberOfUploads={numberOfUploads}
212
+ selected={selected}
213
+ setSelectedImages={setSelectedImages}
214
+ />
215
+ ) : (
216
+ <AttachmentVideo
217
+ asset={asset}
218
+ ImageOverlaySelectedComponent={ImageOverlaySelectedComponent}
219
+ maxNumberOfFiles={maxNumberOfFiles}
220
+ numberOfAttachmentPickerImageColumns={numberOfAttachmentPickerImageColumns}
221
+ numberOfUploads={numberOfUploads}
222
+ selected={selected}
223
+ setSelectedFiles={setSelectedFiles}
224
+ />
225
+ );
226
+ };
227
+
228
+ const styles = StyleSheet.create({
229
+ durationText: {
230
+ fontWeight: 'bold',
231
+ },
232
+ overlay: {
233
+ alignItems: 'flex-end',
234
+ flex: 1,
235
+ },
236
+ videoView: {
237
+ bottom: 5,
238
+ display: 'flex',
239
+ flexDirection: 'row',
240
+ justifyContent: 'space-between',
241
+ paddingHorizontal: 5,
242
+ position: 'absolute',
243
+ width: '100%',
244
+ },
245
+ });
@@ -92,13 +92,12 @@ const keyExtractor = <
92
92
  StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
93
93
  >(
94
94
  item: MessageType<StreamChatGenerics>,
95
- ) =>
96
- item.id ||
97
- (item.created_at
98
- ? typeof item.created_at === 'string'
99
- ? item.created_at
100
- : item.created_at.toISOString()
101
- : Date.now().toString());
95
+ ) => {
96
+ if (item.id) return item.id;
97
+ if (item.created_at)
98
+ return typeof item.created_at === 'string' ? item.created_at : item.created_at.toISOString();
99
+ return Date.now().toString();
100
+ };
102
101
 
103
102
  const flatListViewabilityConfig = {
104
103
  viewAreaCoveragePercentThreshold: 1,
@@ -294,7 +293,6 @@ const MessageListWithContext = <
294
293
  );
295
294
 
296
295
  const messageList = useMessageList<StreamChatGenerics>({
297
- inverted,
298
296
  noGroupByUser,
299
297
  threadList,
300
298
  });
@@ -911,12 +909,13 @@ const MessageListWithContext = <
911
909
  stickyHeaderDate?.getFullYear() === new Date().getFullYear() ? 'MMM D' : 'MMM D, YYYY';
912
910
  const tStickyHeaderDate =
913
911
  stickyHeaderDate && !hideStickyDateHeader ? tDateTimeParser(stickyHeaderDate) : null;
914
- const stickyHeaderDateToRender =
915
- tStickyHeaderDate === null || hideStickyDateHeader
916
- ? null
917
- : isDayOrMoment(tStickyHeaderDate)
918
- ? tStickyHeaderDate.format(stickyHeaderFormatDate)
919
- : new Date(tStickyHeaderDate).toDateString();
912
+
913
+ const stickyHeaderDateString = useMemo(() => {
914
+ if (tStickyHeaderDate === null || hideStickyDateHeader) return null;
915
+ if (isDayOrMoment(tStickyHeaderDate)) return tStickyHeaderDate.format(stickyHeaderFormatDate);
916
+
917
+ return new Date(tStickyHeaderDate).toDateString();
918
+ }, [tStickyHeaderDate, stickyHeaderFormatDate, hideStickyDateHeader]);
920
919
 
921
920
  const dismissImagePicker = () => {
922
921
  if (!hasMoved && selectedPicker) {
@@ -964,6 +963,13 @@ const MessageListWithContext = <
964
963
  );
965
964
  }
966
965
 
966
+ const StickyHeaderComponent = () => {
967
+ if (!stickyHeaderDateString) return null;
968
+ if (StickyHeader) return <StickyHeader dateString={stickyHeaderDateString} />;
969
+ if (messageListLengthAfterUpdate) return <DateHeader dateString={stickyHeaderDateString} />;
970
+ return null;
971
+ };
972
+
967
973
  return (
968
974
  <View
969
975
  style={[styles.container, { backgroundColor: white_snow }, container]}
@@ -1002,12 +1008,7 @@ const MessageListWithContext = <
1002
1008
  {!loading && (
1003
1009
  <>
1004
1010
  <View style={styles.stickyHeader}>
1005
- {stickyHeaderDateToRender &&
1006
- (StickyHeader ? (
1007
- <StickyHeader dateString={stickyHeaderDateToRender} />
1008
- ) : messageListLengthAfterUpdate ? (
1009
- <DateHeader dateString={stickyHeaderDateToRender} />
1010
- ) : null)}
1011
+ <StickyHeaderComponent />
1011
1012
  </View>
1012
1013
  {!disableTypingIndicator && TypingIndicator && (
1013
1014
  <TypingIndicatorContainer>
@@ -33,22 +33,25 @@ export const NetworkDownIndicator = () => {
33
33
  } = useTheme();
34
34
  const { t } = useTranslationContext();
35
35
 
36
- if (isOnline && !error) {
37
- return null;
38
- }
36
+ const getText = () => {
37
+ if (!isOnline) {
38
+ return t('Reconnecting...');
39
+ } else if (error) {
40
+ return t('Error loading messages for this channel...');
41
+ }
42
+ return '';
43
+ };
44
+
45
+ const indicatorText = getText();
46
+
47
+ if (!indicatorText) return null;
39
48
 
40
49
  return (
41
50
  <View
42
51
  style={[styles.errorNotification, { backgroundColor: `${grey}E6` }, errorNotification]}
43
52
  testID='error-notification'
44
53
  >
45
- <Text style={[styles.errorNotificationText, errorNotificationText]}>
46
- {!isOnline
47
- ? t('Reconnecting...')
48
- : error
49
- ? t('Error loading messages for this channel...')
50
- : ''}
51
- </Text>
54
+ <Text style={[styles.errorNotificationText, errorNotificationText]}>{indicatorText}</Text>
52
55
  </View>
53
56
  );
54
57
  };
@@ -0,0 +1,67 @@
1
+ import React, { FC } from 'react';
2
+
3
+ import { renderHook } from '@testing-library/react-hooks';
4
+ import type { DefaultStreamChatGenerics } from 'src/types/types';
5
+ import type { DefaultGenerics, StreamChat } from 'stream-chat';
6
+
7
+ import { useCreatePaginatedMessageListContext } from '../../../components/Channel/hooks/useCreatePaginatedMessageListContext';
8
+ import {
9
+ ChatContext,
10
+ ChatContextValue,
11
+ PaginatedMessageListContextValue,
12
+ PaginatedMessageListProvider,
13
+ } from '../../../contexts';
14
+
15
+ import { generateMessage } from '../../../mock-builders/generator/message';
16
+ import { generateUser } from '../../../mock-builders/generator/user';
17
+ import { getTestClientWithUser } from '../../../mock-builders/mock';
18
+ import { useMessageList } from '../hooks/useMessageList';
19
+
20
+ jest.mock('react-native-fs', () => ({}));
21
+
22
+ const clientUser = generateUser();
23
+ let chatClient: StreamChat<DefaultGenerics> | StreamChat<DefaultStreamChatGenerics>;
24
+
25
+ beforeEach(async () => {
26
+ chatClient = await getTestClientWithUser(clientUser);
27
+ });
28
+
29
+ const messages = new Array(10)
30
+ .fill(undefined)
31
+ .map((_: undefined, id: number) => generateMessage({ id }));
32
+
33
+ const Providers: FC<{ children: React.ReactNode }> = ({ children }) => {
34
+ const messageListContext = useCreatePaginatedMessageListContext({
35
+ messages,
36
+ } as unknown as PaginatedMessageListContextValue);
37
+
38
+ return (
39
+ <ChatContext.Provider
40
+ value={
41
+ {
42
+ auto_translation_enabled: true,
43
+ client: chatClient,
44
+ } as unknown as ChatContextValue
45
+ }
46
+ >
47
+ <PaginatedMessageListProvider value={messageListContext}>
48
+ {children}
49
+ </PaginatedMessageListProvider>
50
+ </ChatContext.Provider>
51
+ );
52
+ };
53
+
54
+ describe('useMessageList', () => {
55
+ it('should always return a list of reversed messages', () => {
56
+ const { result } = renderHook(
57
+ () =>
58
+ useMessageList({
59
+ noGroupByUser: true,
60
+ threadList: false,
61
+ }),
62
+ { wrapper: Providers },
63
+ );
64
+ const reversedMessages = messages.reverse();
65
+ expect(result.current.map(({ id }) => id)).toEqual(reversedMessages.map(({ id }) => id));
66
+ });
67
+ });
@@ -18,7 +18,6 @@ import { getReadStates } from '../utils/getReadStates';
18
18
 
19
19
  export type UseMessageListParams = {
20
20
  deletedMessagesVisibilityType?: DeletedMessagesVisibilityType;
21
- inverted?: boolean;
22
21
  noGroupByUser?: boolean;
23
22
  threadList?: boolean;
24
23
  };
@@ -52,7 +51,7 @@ export const useMessageList = <
52
51
  >(
53
52
  params: UseMessageListParams,
54
53
  ) => {
55
- const { inverted, noGroupByUser, threadList } = params;
54
+ const { noGroupByUser, threadList } = params;
56
55
  const { client } = useChatContext<StreamChatGenerics>();
57
56
  const { hideDateSeparators, maxTimeBetweenGroupedMessages, read } =
58
57
  useChannelContext<StreamChatGenerics>();
@@ -104,9 +103,7 @@ export const useMessageList = <
104
103
  readBy: msg.id ? readData[msg.id] || false : false,
105
104
  }));
106
105
 
107
- return (
108
- inverted
109
- ? messagesWithStylesReadByAndDateSeparator.reverse()
110
- : messagesWithStylesReadByAndDateSeparator
111
- ) as MessageType<StreamChatGenerics>[];
106
+ return [
107
+ ...messagesWithStylesReadByAndDateSeparator,
108
+ ].reverse() as MessageType<StreamChatGenerics>[];
112
109
  };
@@ -211,7 +211,7 @@ export type LocalMessageInputContext<
211
211
  /** Function for attempting to upload an image */
212
212
  uploadImage: ({ newImage }: { newImage: ImageUpload }) => Promise<void>;
213
213
  uploadNewFile: (file: File) => Promise<void>;
214
- uploadNewImage: (image: Asset) => Promise<void>;
214
+ uploadNewImage: (image: Partial<Asset>) => Promise<void>;
215
215
  };
216
216
 
217
217
  export type InputMessageInputContextValue<
@@ -549,16 +549,8 @@ export const MessageInputProvider = <
549
549
  const openAttachmentPicker = () => {
550
550
  if (hasImagePicker) {
551
551
  Keyboard.dismiss();
552
- openPicker();
553
552
  setSelectedPicker('images');
554
- /**
555
- * TODO: Remove this, this is the result of
556
- * the bottom sheet now having some keyboard
557
- * handling baked in, creating an issue when
558
- * we call dismiss and open in short order.
559
- * https://github.com/gorhom/react-native-bottom-sheet/issues/446
560
- */
561
- setTimeout(openPicker, 600);
553
+ openPicker();
562
554
  } else if (hasFilePicker) {
563
555
  pickFile();
564
556
  }
@@ -834,19 +826,27 @@ export const MessageInputProvider = <
834
826
  }
835
827
  };
836
828
 
837
- const triggerSettings = channel
838
- ? value.autoCompleteTriggerSettings
839
- ? value.autoCompleteTriggerSettings({
829
+ const getTriggerSettings = () => {
830
+ let triggerSettings: TriggerSettings<StreamChatGenerics> = {};
831
+ if (channel) {
832
+ if (value.autoCompleteTriggerSettings) {
833
+ triggerSettings = value.autoCompleteTriggerSettings({
840
834
  channel,
841
835
  client,
842
836
  onMentionSelectItem: onSelectItem,
843
- })
844
- : ACITriggerSettings<StreamChatGenerics>({
837
+ });
838
+ } else {
839
+ triggerSettings = ACITriggerSettings<StreamChatGenerics>({
845
840
  channel,
846
841
  client,
847
842
  onMentionSelectItem: onSelectItem,
848
- })
849
- : ({} as TriggerSettings<StreamChatGenerics>);
843
+ });
844
+ }
845
+ }
846
+ return triggerSettings;
847
+ };
848
+
849
+ const triggerSettings = getTriggerSettings();
850
850
 
851
851
  const updateMessage = async () => {
852
852
  try {
@@ -946,13 +946,15 @@ export const MessageInputProvider = <
946
946
  * if the uri includes assets-library, this uses the CameraRoll.save
947
947
  * function to also create a local uri.
948
948
  */
949
- const localUri = file.id
950
- ? await getLocalAssetUri(file.id)
951
- : file.uri?.match(/assets-library/)
952
- ? await getLocalAssetUri(file.uri)
953
- : file.uri;
954
-
955
- const uri = file.name || localUri || '';
949
+ const getLocalUri = async () => {
950
+ if (file.id) {
951
+ return await getLocalAssetUri(file.id);
952
+ } else if (file.uri?.match(/assets-library/)) {
953
+ return await getLocalAssetUri(file.uri);
954
+ }
955
+ return file.uri;
956
+ };
957
+ const uri = file.name || (await getLocalUri()) || '';
956
958
  /**
957
959
  * We skip compression if:
958
960
  * - the file is from the camera as that should already be compressed
@@ -1053,7 +1055,7 @@ export const MessageInputProvider = <
1053
1055
  }
1054
1056
  };
1055
1057
 
1056
- const uploadNewImage = async (image: Asset) => {
1058
+ const uploadNewImage = async (image: Partial<Asset>) => {
1057
1059
  const id = generateRandomId();
1058
1060
 
1059
1061
  const isBlockedImageMimeType = blockedImageMimeTypes?.some((mimeType: string) =>
@@ -64,6 +64,7 @@ export const OverlayProvider = <
64
64
  >(
65
65
  props: PropsWithChildren<OverlayProviderProps<StreamChatGenerics>>,
66
66
  ) => {
67
+ const bottomSheetCloseTimeoutRef = useRef<NodeJS.Timeout>();
67
68
  const {
68
69
  AttachmentPickerBottomSheetHandle = DefaultAttachmentPickerBottomSheetHandle,
69
70
  attachmentPickerBottomSheetHandleHeight,
@@ -77,8 +78,19 @@ export const OverlayProvider = <
77
78
  CameraSelectorIcon = DefaultCameraSelectorIcon,
78
79
  children,
79
80
  closePicker = (ref) => {
80
- if (ref.current) {
81
+ if (ref.current?.close) {
82
+ if (bottomSheetCloseTimeoutRef.current) {
83
+ clearTimeout(bottomSheetCloseTimeoutRef.current);
84
+ }
81
85
  ref.current.close();
86
+ // Attempt to close the bottomsheet again to circumvent accidental opening on Android.
87
+ // Details: This to prevent a race condition where the close function is called during the point when a internal container layout happens within the bottomsheet due to keyboard affecting the layout
88
+ // If the container layout measures a shorter height than previous but if the close snapped to the previous height's position, the bottom sheet will show up
89
+ // this short delay ensures that close function is always called after a container layout due to keyboard change
90
+ // NOTE: this timeout has to be above 500 as the keyboardAnimationDuration is 500 in the bottomsheet library - see src/hooks/useKeyboard.ts there for more details
91
+ bottomSheetCloseTimeoutRef.current = setTimeout(() => {
92
+ ref.current?.close();
93
+ }, 600);
82
94
  }
83
95
  },
84
96
  FileSelectorIcon = DefaultFileSelectorIcon,
@@ -96,6 +108,9 @@ export const OverlayProvider = <
96
108
  numberOfAttachmentPickerImageColumns,
97
109
  numberOfImageGalleryGridColumns,
98
110
  openPicker = (ref) => {
111
+ if (bottomSheetCloseTimeoutRef.current) {
112
+ clearTimeout(bottomSheetCloseTimeoutRef.current);
113
+ }
99
114
  if (ref.current?.snapToIndex) {
100
115
  ref.current.snapToIndex(0);
101
116
  } else {
@@ -154,10 +169,19 @@ export const OverlayProvider = <
154
169
  return () => backHandler.remove();
155
170
  }, [overlay]);
156
171
 
172
+ useEffect(
173
+ () =>
174
+ // cleanup the timeout if the component unmounts
175
+ () => {
176
+ if (bottomSheetCloseTimeoutRef.current) {
177
+ clearTimeout(bottomSheetCloseTimeoutRef.current);
178
+ }
179
+ },
180
+ [],
181
+ );
182
+
157
183
  useEffect(() => {
158
- if (bottomSheetRef.current) {
159
- bottomSheetRef.current.close?.();
160
- }
184
+ closePicker(bottomSheetRef);
161
185
  cancelAnimation(overlayOpacity);
162
186
  if (overlay !== 'none') {
163
187
  overlayOpacity.value = withTiming(1);
@@ -0,0 +1,41 @@
1
+ import { AppState, AppStateStatus } from 'react-native';
2
+
3
+ import { renderHook } from '@testing-library/react-hooks/pure';
4
+ import { waitFor } from '@testing-library/react-native';
5
+
6
+ import { useAppStateListener } from '../useAppStateListener';
7
+
8
+ describe('useAppStateListener', () => {
9
+ const onForeground = jest.fn();
10
+ const onBackground = jest.fn();
11
+ const addEventListenerSpy = jest.spyOn(AppState, 'addEventListener');
12
+ AppState.currentState = 'active';
13
+ const { unmount } = renderHook(() => useAppStateListener(onForeground, onBackground));
14
+ const appStateOnChangeMockFunc = addEventListenerSpy.mock.calls[0][1];
15
+ const { remove: appStateOnChangeSubscriptionRemoveMockFunc } =
16
+ addEventListenerSpy.mock.results[0].value;
17
+ test.each<[AppStateStatus, jest.Mock, number]>([
18
+ ['background', onBackground, 1],
19
+ ['active', onForeground, 1],
20
+ ['inactive', onBackground, 2],
21
+ ['active', onForeground, 2],
22
+ ['background', onBackground, 3],
23
+ ])(
24
+ 'Appropriate callback called when appstate is changed to %s)',
25
+ async (newAppState, expectedCallback, times) => {
26
+ appStateOnChangeMockFunc(newAppState);
27
+ await waitFor(() => {
28
+ expect(expectedCallback).toHaveBeenCalledTimes(times);
29
+ });
30
+ },
31
+ );
32
+ test('check unmount behavior', async () => {
33
+ await waitFor(() => {
34
+ expect(appStateOnChangeSubscriptionRemoveMockFunc).not.toHaveBeenCalled();
35
+ });
36
+ unmount();
37
+ await waitFor(() => {
38
+ expect(appStateOnChangeSubscriptionRemoveMockFunc).toHaveBeenCalled();
39
+ });
40
+ });
41
+ });
@@ -1,22 +1,19 @@
1
- import { useCallback, useEffect, useState } from 'react';
1
+ import { useCallback, useEffect, useRef } from 'react';
2
2
  import { AppState, AppStateStatus } from 'react-native';
3
3
 
4
4
  export const useAppStateListener = (onForeground?: () => void, onBackground?: () => void) => {
5
- const [appState, setAppState] = useState(AppState.currentState);
5
+ const appStateRef = useRef(AppState.currentState);
6
6
  const handleAppStateChange = useCallback(
7
7
  (nextAppState: AppStateStatus) => {
8
- if (appState === 'background' && nextAppState === 'active' && onForeground) {
9
- onForeground();
10
- } else if (
11
- appState.match(/active|inactive/) &&
12
- nextAppState === 'background' &&
13
- onBackground
14
- ) {
15
- onBackground();
8
+ const prevAppState = appStateRef.current;
9
+ if (prevAppState.match(/inactive|background/) && nextAppState === 'active') {
10
+ onForeground?.();
11
+ } else if (prevAppState === 'active' && nextAppState.match(/inactive|background/)) {
12
+ onBackground?.();
16
13
  }
17
- setAppState(nextAppState);
14
+ appStateRef.current = nextAppState;
18
15
  },
19
- [onBackground, onForeground, appState],
16
+ [onBackground, onForeground],
20
17
  );
21
18
 
22
19
  useEffect(() => {