@ethora/chat-component-rn 26.4.1 → 26.4.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 (46) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/LICENSE +21 -661
  3. package/README.md +253 -7
  4. package/index.js +7 -0
  5. package/package.json +47 -16
  6. package/src/components/Header/HeaderRoomList.tsx +8 -7
  7. package/src/components/InputComponents/Search.tsx +7 -4
  8. package/src/components/InputComponents/TextInput.tsx +8 -1
  9. package/src/components/MainComponents/ChatRoom.tsx +16 -4
  10. package/src/components/MainComponents/ChatWrapper.tsx +10 -14
  11. package/src/components/MainComponents/MessageList.tsx +3 -20
  12. package/src/components/MainComponents/RoomList.tsx +7 -2
  13. package/src/components/MessageBubble/Message.tsx +7 -3
  14. package/src/components/MessageBubble/MessageInteractions.tsx +67 -63
  15. package/src/components/Modals/AttachSheet/AttachSheet.tsx +195 -0
  16. package/src/components/Modals/FilePreviewModal/FilePreviewModal.tsx +3 -0
  17. package/src/components/Modals/FilePreviewModal/PdfView.tsx +71 -0
  18. package/src/components/Modals/Modal/Modal.tsx +2 -4
  19. package/src/components/Modals/ModalSelectMedia/ModalSelectMedia.tsx +0 -2
  20. package/src/components/styled/Button.tsx +9 -0
  21. package/src/components/styled/SendInput.tsx +191 -107
  22. package/src/components/styled/StyledInputComponents/StyledInputComponents.tsx +1 -1
  23. package/src/context/xmppProvider.tsx +51 -2
  24. package/src/helpers/constants/MODAL_COMPONENTS.tsx +35 -0
  25. package/src/helpers/constants/MODAL_TYPES.ts +10 -39
  26. package/src/helpers/historyPreloadScheduler.ts +15 -1
  27. package/src/helpers/updateMessagesTillLast.tsx +20 -17
  28. package/src/hooks/useComposing.tsx +0 -9
  29. package/src/hooks/useLogout.tsx +129 -9
  30. package/src/hooks/useSendMessage.tsx +114 -60
  31. package/src/networking/api-requests/auth.api.ts +1 -1
  32. package/src/networking/api-requests/user.api.ts +1 -0
  33. package/src/networking/apiClient.ts +74 -38
  34. package/src/networking/stanzaHandlers.ts +18 -7
  35. package/src/networking/xmpp/flushLastViewedToPrivateStore.ts +86 -0
  36. package/src/networking/xmpp/getHistory.xmpp.ts +6 -1
  37. package/src/networking/xmpp/presenceInRoom.xmpp.ts +8 -3
  38. package/src/networking/xmpp/sendMediaMessage.xmpp.ts +8 -10
  39. package/src/networking/xmpp/sendTextMessage.xmpp.ts +12 -12
  40. package/src/networking/xmppClient.ts +27 -0
  41. package/src/roomStore/Middleware/logoutMiddleware.tsx +17 -8
  42. package/src/roomStore/Middleware/unreadMidlleware.tsx +122 -28
  43. package/src/roomStore/persistence.ts +5 -2
  44. package/src/roomStore/roomsSlice.ts +115 -7
  45. package/src/types/types.ts +10 -9
  46. package/src/utils/devLogger.ts +11 -0
@@ -1,6 +1,6 @@
1
1
  /** @format */
2
2
 
3
- import React, { useState, useCallback, useEffect } from 'react';
3
+ import React, { useState, useCallback, useEffect, useRef } from 'react';
4
4
  import {
5
5
  MessageInputContainer,
6
6
  InputContainer,
@@ -10,11 +10,37 @@ import { IConfig, MediaFile } from '../../types/types';
10
10
  import Button from './Button';
11
11
  import { SendIcon, AttachIcon } from '../../assets/icons';
12
12
  import { KeyboardAvoidingView, Platform, View, TouchableOpacity, Alert, ActionSheetIOS, Linking } from 'react-native';
13
- import { ModalSelectMedia } from '../Modals/ModalSelectMedia/ModalSelectMedia.tsx';
13
+ import AttachSheet from '../Modals/AttachSheet/AttachSheet';
14
14
  import { MediaFilePreview } from './MediaFilePreview';
15
- import DocumentPicker from 'react-native-document-picker';
16
- import ImagePicker from 'react-native-image-crop-picker';
17
- import { check, request, PERMISSIONS, RESULTS, Permission } from 'react-native-permissions';
15
+ import * as ImagePicker from 'expo-image-picker';
16
+ import * as DocumentPicker from 'expo-document-picker';
17
+ import * as ImageManipulator from 'expo-image-manipulator';
18
+
19
+ // iOS photos default to HEIC; many web backends (incl. ours) 500 on
20
+ // HEIC uploads because they can't decode it. Convert to JPEG before
21
+ // upload — JPEG is universally supported. JPEGs pass through.
22
+ const normalizeImageAsset = async (asset: ImagePicker.ImagePickerAsset) => {
23
+ const mime = (asset.mimeType || '').toLowerCase();
24
+ const looksLikeHeic =
25
+ mime.includes('heic') ||
26
+ mime.includes('heif') ||
27
+ asset.uri.toLowerCase().endsWith('.heic') ||
28
+ asset.uri.toLowerCase().endsWith('.heif');
29
+ if (!looksLikeHeic) {
30
+ return {
31
+ uri: asset.uri,
32
+ mime: asset.mimeType || 'image/jpeg',
33
+ name: asset.fileName || asset.uri.split('/').pop() || `image_${Date.now()}.jpg`,
34
+ };
35
+ }
36
+ const result = await ImageManipulator.manipulateAsync(asset.uri, [], {
37
+ compress: 0.9,
38
+ format: ImageManipulator.SaveFormat.JPEG,
39
+ });
40
+ const baseName = (asset.fileName || asset.uri.split('/').pop() || `image_${Date.now()}`)
41
+ .replace(/\.(heic|heif)$/i, '.jpg');
42
+ return { uri: result.uri, mime: 'image/jpeg', name: baseName };
43
+ };
18
44
 
19
45
  interface SendInputProps {
20
46
  sendMessage: (message: string) => void;
@@ -49,111 +75,118 @@ const SendInput: React.FC<SendInputProps> = ({
49
75
  const [inputHeight, setInputHeight] = useState(40);
50
76
  const [showMediaMenu, setShowMediaMenu] = useState(false);
51
77
 
78
+ // Refs shadow `message` and `filePreviews` so handleSendClick can:
79
+ // (a) read the LATEST typed value even when React state hasn't
80
+ // flushed yet (rapid tap-burst race),
81
+ // (b) clear the ref SYNCHRONOUSLY before any await — so a follow-up
82
+ // tap on send that happens before the next render reads `''`
83
+ // and bails out (instead of re-sending the same content).
84
+ // Without these, fast spam-typing+sending used to collapse 10 user
85
+ // sends into 1 server message with all 10 contents stacked.
86
+ const messageRef = useRef(message);
87
+ const filePreviewsRef = useRef<MediaFile[]>(filePreviews);
88
+ // Guard flag so a 2nd tap during the first send's await is a no-op
89
+ // (versus duplicating the send with stale closure state).
90
+ const sendingRef = useRef(false);
91
+
52
92
  const handleFileSelect = (files: MediaFile[]) => {
53
- console.log('🔵 [SendInput] Files selected:', files);
93
+ filePreviewsRef.current = files;
54
94
  setFilePreviews([...files]);
55
95
  };
56
96
 
57
- const checkPermission = async (permission: Permission) => {
58
- const status = await check(permission);
59
- if (status === RESULTS.GRANTED) {
60
- return status;
61
- } else if (status === RESULTS.DENIED) {
62
- const requestStatus = await request(permission);
63
- return requestStatus;
64
- }
65
- return status;
97
+ // Both pickers use expo-image-picker / expo-document-picker. They
98
+ // own permission prompts internally and surface a `canceled` flag in
99
+ // the result — no need for a separate permissions library or hand-
100
+ // rolled check/request flow.
101
+
102
+ const promptOpenSettings = (message: string) => {
103
+ Alert.alert('Permission required', message, [
104
+ { text: 'Cancel', style: 'cancel' },
105
+ { text: 'Open Settings', onPress: () => Linking.openSettings() },
106
+ ]);
66
107
  };
67
108
 
68
109
  const handleCameraSelection = async () => {
69
- const permission = Platform.OS === 'ios' ? PERMISSIONS.IOS.CAMERA : PERMISSIONS.ANDROID.CAMERA;
70
- const permissionStatus = await checkPermission(permission);
71
-
72
- if (permissionStatus !== RESULTS.GRANTED) {
73
- Alert.alert('Permission required', 'Camera permission is needed to take photos.', [
74
- { text: 'Cancel', style: 'cancel' },
75
- { text: 'Open Settings', onPress: () => Linking.openSettings() },
76
- ]);
110
+ const perm = await ImagePicker.requestCameraPermissionsAsync();
111
+ if (!perm.granted) {
112
+ promptOpenSettings('Camera permission is needed to take photos.');
77
113
  return;
78
114
  }
79
115
 
80
116
  try {
81
- const image = await ImagePicker.openCamera({
82
- width: 300,
83
- height: 400,
84
- cropping: true,
117
+ const result = await ImagePicker.launchCameraAsync({
118
+ mediaTypes: ImagePicker.MediaTypeOptions.Images,
119
+ quality: 0.9,
85
120
  });
86
- const file = {
87
- uri: image.path,
88
- type: image.mime,
89
- name: image.path.split('/').pop() || `camera_${Date.now()}.jpg`,
90
- };
91
- handleFileSelect([file]);
92
- } catch (error: any) {
93
- if (error?.code !== 'E_PICKER_CANCELLED') {
94
- console.error('Camera error:', error);
95
- }
121
+ if (result.canceled || !result.assets?.[0]) {return;}
122
+ const asset = result.assets[0];
123
+ const normalized = await normalizeImageAsset(asset);
124
+ handleFileSelect([
125
+ { uri: normalized.uri, type: normalized.mime, name: normalized.name },
126
+ ]);
127
+ } catch (error) {
128
+ console.error('Camera error:', error);
96
129
  }
97
130
  };
98
131
 
99
132
  const handleGallerySelection = async () => {
100
- try {
101
- let permission: Permission;
102
- if (Platform.OS === 'ios') {
103
- permission = PERMISSIONS.IOS.PHOTO_LIBRARY;
104
- } else if (Number(Platform.Version) >= 33) {
105
- permission = PERMISSIONS.ANDROID.READ_MEDIA_IMAGES;
106
- } else {
107
- permission = PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE;
108
- }
133
+ const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
134
+ if (!perm.granted) {
135
+ promptOpenSettings('Gallery permission is needed to select photos.');
136
+ return;
137
+ }
109
138
 
110
- const permissionStatus = await checkPermission(permission);
111
- if (permissionStatus !== RESULTS.GRANTED) {
112
- Alert.alert('Permission required', 'Gallery permission is needed to select photos.', [
113
- { text: 'Cancel', style: 'cancel' },
114
- { text: 'Open Settings', onPress: () => Linking.openSettings() },
139
+ try {
140
+ const result = await ImagePicker.launchImageLibraryAsync({
141
+ mediaTypes: ImagePicker.MediaTypeOptions.All,
142
+ allowsEditing: false,
143
+ quality: 0.9,
144
+ });
145
+ if (result.canceled || !result.assets?.[0]) {return;}
146
+ const asset = result.assets[0];
147
+ // Videos pass through unchanged; only images get the HEIC→JPEG
148
+ // normalization (manipulator is image-only).
149
+ if (asset.type === 'video') {
150
+ handleFileSelect([
151
+ {
152
+ uri: asset.uri,
153
+ type: asset.mimeType || 'video/mp4',
154
+ name: asset.fileName || asset.uri.split('/').pop() || `gallery_${Date.now()}.mp4`,
155
+ },
115
156
  ]);
116
157
  return;
117
158
  }
118
-
119
- const image = await ImagePicker.openPicker({
120
- multiple: false,
121
- mediaType: 'any',
122
- });
123
- const file = {
124
- uri: image.path,
125
- type: image.mime,
126
- name: image.path.split('/').pop() || `gallery_${Date.now()}.jpg`,
127
- };
128
- handleFileSelect([file]);
129
- } catch (error: any) {
130
- if (error?.code !== 'E_PICKER_CANCELLED') {
131
- console.error('Gallery error:', error);
132
- }
159
+ const normalized = await normalizeImageAsset(asset);
160
+ handleFileSelect([
161
+ { uri: normalized.uri, type: normalized.mime, name: normalized.name },
162
+ ]);
163
+ } catch (error) {
164
+ console.error('Gallery error:', error);
133
165
  }
134
166
  };
135
167
 
136
168
  const handleFileSelection = async () => {
137
169
  try {
138
- const result = await DocumentPicker.pick({
139
- type: [DocumentPicker.types.allFiles],
140
- allowMultiSelection: false,
170
+ const result = await DocumentPicker.getDocumentAsync({
171
+ type: '*/*',
172
+ multiple: false,
173
+ copyToCacheDirectory: true,
141
174
  });
142
- const files = result.map((file) => ({
143
- uri: file.uri,
144
- type: file.type || 'unknown',
145
- name: file.name || `file_${Date.now()}`,
146
- }));
147
- handleFileSelect(files);
148
- } catch (err: any) {
149
- if (!DocumentPicker.isCancel(err)) {
150
- console.error('DocumentPicker Error:', err);
151
- }
175
+ if (result.canceled || !result.assets?.[0]) {return;}
176
+ const asset = result.assets[0];
177
+ handleFileSelect([
178
+ {
179
+ uri: asset.uri,
180
+ type: asset.mimeType || 'application/octet-stream',
181
+ name: asset.name || `file_${Date.now()}`,
182
+ },
183
+ ]);
184
+ } catch (err) {
185
+ console.error('DocumentPicker error:', err);
152
186
  }
153
187
  };
154
188
 
155
189
  const handleAttachPress = () => {
156
- console.log('🔵 [SendInput] Attach button pressed');
157
190
  if (Platform.OS === 'ios') {
158
191
  ActionSheetIOS.showActionSheetWithOptions(
159
192
  {
@@ -167,42 +200,67 @@ const SendInput: React.FC<SendInputProps> = ({
167
200
  }
168
201
  );
169
202
  } else {
170
- Alert.alert('Select Media', 'Choose an option', [
171
- { text: 'Cancel', style: 'cancel' },
172
- { text: 'Camera', onPress: handleCameraSelection },
173
- { text: 'Photo Library', onPress: handleGallerySelection },
174
- { text: 'Document', onPress: handleFileSelection },
175
- ]);
203
+ setShowMediaMenu(true);
176
204
  }
177
205
  };
178
206
 
179
207
  const handleRemoveImage = (index: number) => {
180
- setFilePreviews((prev) => prev.filter((_, i) => i !== index));
208
+ const next = filePreviewsRef.current.filter((_, i) => i !== index);
209
+ filePreviewsRef.current = next;
210
+ setFilePreviews(next);
181
211
  };
182
212
 
183
- const handleSendClick = useCallback(() => {
184
- console.log('🔵 [SendInput] handleSendClick', {
185
- hasMessage: !!message,
186
- msgLen: message?.length || 0,
187
- files: filePreviews.length,
188
- });
189
- // Send media first (so it lands above the caption), then the text
190
- // — previously it was either-or, so the user could never attach a
191
- // file AND type a caption in the same tap.
192
- if (filePreviews.length > 0) {
193
- filePreviews.forEach((file) => {
194
- sendMedia(file, file.type);
195
- });
196
- }
197
- if (message) {
198
- sendMessage(message);
199
- }
213
+ const handleSendClick = useCallback(async () => {
214
+ // Snapshot LATEST values from refs, not from the React-state
215
+ // closure. The closure is captured at render-time and may be
216
+ // stale by the time a fast-fingered user spams the send button.
217
+ const messageToSend = messageRef.current;
218
+ const filesToSend = filePreviewsRef.current;
219
+
220
+ // Nothing to send → bail. Critical for the spam case: after the
221
+ // first tap clears refs, subsequent taps queued by RN's native
222
+ // gesture system see empty refs and exit without re-sending.
223
+ if (!messageToSend && filesToSend.length === 0) {return;}
224
+
225
+ // Re-entrancy guard. If a previous send is still awaiting upload
226
+ // / xmpp.send, a second tap should NOT slip through with the
227
+ // same snapshotted content.
228
+ if (sendingRef.current) {return;}
229
+ sendingRef.current = true;
230
+
231
+ // CLEAR EVERYTHING SYNCHRONOUSLY — refs first (so the early-bail
232
+ // above catches the next rapid tap immediately), then dispatch
233
+ // state updates so the UI catches up on the next render.
234
+ messageRef.current = '';
235
+ filePreviewsRef.current = [];
200
236
  setMessage('');
201
237
  setFilePreviews([]);
202
- }, [filePreviews, message, sendMessage, sendMedia]);
238
+
239
+ try {
240
+ for (const file of filesToSend) {
241
+ try {
242
+ await sendMedia(file, file.type);
243
+ } catch (err) {
244
+ console.error(err);
245
+ return;
246
+ }
247
+ }
248
+ if (messageToSend) {
249
+ sendMessage(messageToSend);
250
+ }
251
+ } finally {
252
+ sendingRef.current = false;
253
+ }
254
+ // Deps intentionally exclude `message` / `filePreviews` — we read
255
+ // from refs, so closure freshness is irrelevant and re-creating
256
+ // the handler on every keystroke would just thrash GC + risk the
257
+ // (still-async) `disabled` button race.
258
+ }, [sendMessage, sendMedia]);
203
259
 
204
260
  useEffect(() => {
205
- setMessage(editMessage || '');
261
+ const next = editMessage || '';
262
+ messageRef.current = next;
263
+ setMessage(next);
206
264
  }, [editMessage]);
207
265
 
208
266
  return (
@@ -233,12 +291,28 @@ const SendInput: React.FC<SendInputProps> = ({
233
291
  </TouchableOpacity>
234
292
  )}
235
293
  <MessageInput
294
+ // Stable testIDs so Maestro / Detox / Appium can drive
295
+ // the chat-send flow reliably. accessibilityLabel
296
+ // mirrors the testID so iOS accessibility-id lookups
297
+ // also work. (TextInput in src/components/InputComponents
298
+ // also carries the same testIDs as a fallback, but
299
+ // SendInput is the input that actually ships in
300
+ // <ChatRoom> / <Thread> so this is the one e2e drivers
301
+ // hit in practice.)
302
+ testID="chat-message-input"
303
+ accessibilityLabel="chat-message-input"
236
304
  isFocused={isFocused}
237
305
  color={config?.colors?.primary}
238
306
  placeholder="Type message"
239
307
  placeholderTextColor="#999"
240
308
  value={message}
241
- onChangeText={setMessage}
309
+ onChangeText={(text) => {
310
+ // Mirror into the ref synchronously so handleSendClick
311
+ // reads the latest value even before the React state
312
+ // commit lands (the source of the spam-merge bug).
313
+ messageRef.current = text;
314
+ setMessage(text);
315
+ }}
242
316
  onFocus={() => {
243
317
  onFocus?.();
244
318
  setIsFocused(true);
@@ -267,6 +341,8 @@ const SendInput: React.FC<SendInputProps> = ({
267
341
  )}
268
342
  {/* Always show send button - it's needed for sending messages and media */}
269
343
  <Button
344
+ testID="chat-send-button"
345
+ accessibilityLabel="chat-send-button"
270
346
  onPress={handleSendClick}
271
347
  disabled={!message && filePreviews.length === 0}
272
348
  EndIcon={
@@ -290,6 +366,14 @@ const SendInput: React.FC<SendInputProps> = ({
290
366
  }}
291
367
  />
292
368
  </MessageInputContainer>
369
+ <AttachSheet
370
+ visible={showMediaMenu}
371
+ onClose={() => setShowMediaMenu(false)}
372
+ onCamera={handleCameraSelection}
373
+ onGallery={handleGallerySelection}
374
+ onDocument={handleFileSelection}
375
+ primaryColor={config?.colors?.primary}
376
+ />
293
377
  </InputContainer>
294
378
  );
295
379
  };
@@ -13,7 +13,7 @@ const colors = {
13
13
 
14
14
  export const InputContainer = styled.View<{ isText?: boolean }>`
15
15
  flex-direction: column;
16
- padding: 12px 16px;
16
+ padding: 8px 16px 4px 16px;
17
17
  background-color: ${colors.white};
18
18
  z-index: 100;
19
19
  width: 100%;
@@ -7,7 +7,7 @@ import React, {
7
7
  useRef,
8
8
  useState,
9
9
  } from 'react';
10
- import { DeviceEventEmitter } from 'react-native';
10
+ import { AppState, DeviceEventEmitter } from 'react-native';
11
11
  import XmppClient from '../networking/xmppClient';
12
12
  import {
13
13
  IConfig,
@@ -28,6 +28,7 @@ import {
28
28
  } from '../helpers/resolveInitBeforeLoadUser';
29
29
  import { ensureScopedChatCache } from '../helpers/ensureScopedChatCache';
30
30
  import { getRooms as prefetchRoomsViaRest } from '../networking/api-requests/rooms.api';
31
+ import { allRoomPresences } from '../networking/xmpp/allRoomPresences.xmpp';
31
32
  import { store } from '../roomStore';
32
33
  import { logout, setStoreClient } from '../roomStore/chatSettingsSlice';
33
34
  import { setLogoutState } from '../roomStore/roomsSlice';
@@ -194,7 +195,12 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config })
194
195
  devPushLog('rn', `initBeforeLoad: user resolved (${resolved.xmppUsername})`);
195
196
  applyResolvedUserToStore(resolved);
196
197
 
197
- prefetchRoomsViaRest().catch(() => undefined);
198
+ // Kick off REST /chats/my; allRoomPresences below needs the
199
+ // room list to be in redux, otherwise it joins 0 MUCs and the
200
+ // user receives zero realtime messages until they manually tap
201
+ // into each room. Track the promise so we can await it before
202
+ // joining presences.
203
+ const restRoomsPromise = prefetchRoomsViaRest().catch(() => undefined);
198
204
 
199
205
  const c = await initializeClient(
200
206
  resolved.xmppUsername!,
@@ -220,6 +226,20 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config })
220
226
  } catch (e) {
221
227
  devPushLog('warn', 'initBeforeLoad: privateStore failed', e);
222
228
  }
229
+ // Wait for REST /chats/my to populate the room list before
230
+ // joining MUC presences. Race-skipping this step left the user
231
+ // subscribed to 0 rooms and no realtime delivery.
232
+ try {
233
+ await restRoomsPromise;
234
+ } catch {}
235
+ try {
236
+ const roomCount = Object.keys(store.getState().rooms.rooms || {}).length;
237
+ devPushLog('xmpp', `initBeforeLoad: joining ${roomCount} rooms via presence…`);
238
+ await allRoomPresences((c as any).client);
239
+ devPushLog('xmpp', 'initBeforeLoad: allRoomPresences ok');
240
+ } catch (e) {
241
+ devPushLog('warn', 'initBeforeLoad: allRoomPresences failed', e);
242
+ }
223
243
 
224
244
  store.dispatch(setStoreClient(c));
225
245
  completedBootstrapKeyRef.current = key;
@@ -312,6 +332,35 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config })
312
332
  }
313
333
  }, [client, client?.status, reconnectAttempts, initializeClient]);
314
334
 
335
+ // -----------------------------------------------------------
336
+ // AppState → background: flush lastViewedTimestamp into the
337
+ // server's private store so the next session shows the correct
338
+ // unread state. Without this, leaving the app open in chat and
339
+ // killing it (or just switching apps) never persists "I read up to
340
+ // here" — the next launch reads the stale marker and over-counts
341
+ // unread.
342
+ //
343
+ // Cheap to run on every transition out of `active`: a no-op when
344
+ // there is no client, when nothing has moved past the existing
345
+ // entry, or when this build has `disableLastRead`.
346
+ // -----------------------------------------------------------
347
+ useEffect(() => {
348
+ const sub = AppState.addEventListener('change', (next) => {
349
+ if (next === 'active') {return;}
350
+ const c = client;
351
+ if (!c) {return;}
352
+ const state = store.getState();
353
+ const rooms = state.rooms?.rooms;
354
+ const activeRoomJID = state.rooms?.activeRoomJID || null;
355
+ // Fire-and-forget — we're going to the background and don't
356
+ // care about the resolution path.
357
+ c.flushLastViewedToPrivateStoreStanza(rooms, { activeRoomJID }).catch(
358
+ () => {}
359
+ );
360
+ });
361
+ return () => sub.remove();
362
+ }, [client]);
363
+
315
364
  // -----------------------------------------------------------
316
365
  // Logout event listener (RN equivalent of window event)
317
366
  // -----------------------------------------------------------
@@ -0,0 +1,35 @@
1
+ // Modal-id → component registry. Split out from MODAL_TYPES.ts to
2
+ // break the require cycle between the type constants and the modal
3
+ // components that reference them. The only caller is Modal/Modal.tsx
4
+ // — every other consumer just needs MODAL_TYPES.
5
+
6
+ import React from 'react';
7
+ import { MODAL_TYPES } from './MODAL_TYPES';
8
+ import ChatProfileModal from '../../components/Modals/ChatProfileModal/ChatProfileModal';
9
+ import FilePreviewModal from '../../components/Modals/FilePreviewModal/FilePreviewModal';
10
+ import NewChatModal from '../../components/Modals/NewChatModal/NewChatModal';
11
+ import BlockedUsersModal from '../../components/Modals/SettingsModals/BlockedUsers/BlockedUsersModal';
12
+ import DocumentSharesModal from '../../components/Modals/SettingsModals/DocumentShares/DocumentSharesModal';
13
+ import ManageDataModal from '../../components/Modals/SettingsModals/ManageDataModal/ManageDataModal';
14
+ import ProfileSharesModal from '../../components/Modals/SettingsModals/ProfileShares/ProfileShares';
15
+ import ReferralsModal from '../../components/Modals/SettingsModals/Referrals/Referrals';
16
+ import VisibilityModal from '../../components/Modals/SettingsModals/Visibility/VisibilityModal';
17
+ import UserProfileModal from '../../components/Modals/UserProfileModal/UserProfileModal';
18
+ import UserSettingsModal from '../../components/Modals/UserSettingsModal/UserSettingsModal';
19
+
20
+ export const MODAL_COMPONENTS: Record<
21
+ string,
22
+ React.FC<{ handleCloseModal: () => void }>
23
+ > = {
24
+ [MODAL_TYPES.SETTINGS]: UserSettingsModal,
25
+ [MODAL_TYPES.NEW_CHAT]: NewChatModal,
26
+ [MODAL_TYPES.PROFILE]: UserProfileModal,
27
+ [MODAL_TYPES.CHAT_PROFILE]: ChatProfileModal,
28
+ [MODAL_TYPES.MANAGE_DATA]: ManageDataModal,
29
+ [MODAL_TYPES.VISIBILITY]: VisibilityModal,
30
+ [MODAL_TYPES.REFERRALS]: ReferralsModal,
31
+ [MODAL_TYPES.DOCUMENT_SHARES]: DocumentSharesModal,
32
+ [MODAL_TYPES.PROFILE_SHARES]: ProfileSharesModal,
33
+ [MODAL_TYPES.BLOCKED_USERS]: BlockedUsersModal,
34
+ [MODAL_TYPES.FILE_PREVIEW]: FilePreviewModal,
35
+ };
@@ -1,14 +1,13 @@
1
- import ChatProfileModal from '../../components/Modals/ChatProfileModal/ChatProfileModal';
2
- import FilePreviewModal from '../../components/Modals/FilePreviewModal/FilePreviewModal';
3
- import NewChatModal from '../../components/Modals/NewChatModal/NewChatModal';
4
- import BlockedUsersModal from '../../components/Modals/SettingsModals/BlockedUsers/BlockedUsersModal';
5
- import DocumentSharesModal from '../../components/Modals/SettingsModals/DocumentShares/DocumentSharesModal';
6
- import ManageDataModal from '../../components/Modals/SettingsModals/ManageDataModal/ManageDataModal';
7
- import ProfileSharesModal from '../../components/Modals/SettingsModals/ProfileShares/ProfileShares';
8
- import ReferralsModal from '../../components/Modals/SettingsModals/Referrals/Referrals';
9
- import VisibilityModal from '../../components/Modals/SettingsModals/Visibility/VisibilityModal';
10
- import UserProfileModal from '../../components/Modals/UserProfileModal/UserProfileModal';
11
- import UserSettingsModal from '../../components/Modals/UserSettingsModal/UserSettingsModal';
1
+ // Modal type-id constants only — no React imports.
2
+ //
3
+ // Previously this file also held the `MODAL_COMPONENTS` registry,
4
+ // which imported every modal component. Several of those components
5
+ // (ChatProfileModal, UserSettingsModal) reference MODAL_TYPES.* to
6
+ // dispatch sub-modals — creating a runtime require cycle that Metro
7
+ // surfaced as "Require cycle: MODAL_TYPES.ts -> ChatProfileModal.tsx
8
+ // -> MODAL_TYPES.ts" on app boot. Splitting the registry out into
9
+ // its own module (MODAL_COMPONENTS.tsx) means the modal components
10
+ // only depend on these constants, dissolving the cycle.
12
11
 
13
12
  export const MODAL_TYPES = {
14
13
  SETTINGS: 'settings',
@@ -26,32 +25,4 @@ export const MODAL_TYPES = {
26
25
  FILE_PREVIEW: 'file_preview',
27
26
 
28
27
  NEW_CHAT: 'new_chat',
29
-
30
- // SETTINGS: 'Settings',
31
- // PROFILE: 'Profile',
32
- // CHAT_PROFILE: 'Chat Profile',
33
-
34
- // MANAGE_DATA: 'Manage Data',
35
- // VISIBILITY: 'Visiblility',
36
- // PROFILE_SHARES: 'Profile Shares',
37
- // DOCUMENT_SHARES: 'Document Shares',
38
- // BLOCKED_USERS: 'Blocked Users',
39
- // REFERRALS: 'Referrals',
40
- };
41
-
42
- export const MODAL_COMPONENTS: Record<
43
- string,
44
- React.FC<{ handleCloseModal: () => void }>
45
- > = {
46
- [MODAL_TYPES.SETTINGS]: UserSettingsModal,
47
- [MODAL_TYPES.NEW_CHAT]: NewChatModal,
48
- [MODAL_TYPES.PROFILE]: UserProfileModal,
49
- [MODAL_TYPES.CHAT_PROFILE]: ChatProfileModal,
50
- [MODAL_TYPES.MANAGE_DATA]: ManageDataModal,
51
- [MODAL_TYPES.VISIBILITY]: VisibilityModal,
52
- [MODAL_TYPES.REFERRALS]: ReferralsModal,
53
- [MODAL_TYPES.DOCUMENT_SHARES]: DocumentSharesModal,
54
- [MODAL_TYPES.PROFILE_SHARES]: ProfileSharesModal,
55
- [MODAL_TYPES.BLOCKED_USERS]: BlockedUsersModal,
56
- [MODAL_TYPES.FILE_PREVIEW]: FilePreviewModal,
57
28
  };
@@ -160,6 +160,19 @@ export const runHistoryPreloadScheduler = async (
160
160
 
161
161
  const batch = readyItems.slice(0, Math.max(1, concurrency));
162
162
 
163
+ // Snapshot each room's historyPreloadState BEFORE marking the
164
+ // batch as 'loading'. Without this snapshot the per-task
165
+ // `currentRoom.historyPreloadState === 'done'` skip check below
166
+ // is dead — the dispatch immediately overwrites 'done' with
167
+ // 'loading', so the task never sees the original value and
168
+ // always re-fetches.
169
+ const preBatchPreloadStateByJid = new Map<string, string | undefined>(
170
+ batch.map((item) => [
171
+ item.jid,
172
+ store.getState().rooms.rooms[item.jid]?.historyPreloadState,
173
+ ])
174
+ );
175
+
163
176
  store.dispatch(
164
177
  applyRoomsPreloadBatch({
165
178
  rooms: batch.map((item) => ({
@@ -176,9 +189,10 @@ export const runHistoryPreloadScheduler = async (
176
189
 
177
190
  const task = (async () => {
178
191
  const currentRoom = store.getState().rooms.rooms[item.jid];
192
+ const preState = preBatchPreloadStateByJid.get(item.jid);
179
193
  if (
180
194
  !currentRoom ||
181
- (!forceReload && currentRoom.historyPreloadState === 'done')
195
+ (!forceReload && preState === 'done')
182
196
  ) {
183
197
  store.dispatch(
184
198
  applyRoomsPreloadBatch({