@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.
- package/CHANGELOG.md +42 -0
- package/LICENSE +21 -661
- package/README.md +253 -7
- package/index.js +7 -0
- package/package.json +47 -16
- package/src/components/Header/HeaderRoomList.tsx +8 -7
- package/src/components/InputComponents/Search.tsx +7 -4
- package/src/components/InputComponents/TextInput.tsx +8 -1
- package/src/components/MainComponents/ChatRoom.tsx +16 -4
- package/src/components/MainComponents/ChatWrapper.tsx +10 -14
- package/src/components/MainComponents/MessageList.tsx +3 -20
- package/src/components/MainComponents/RoomList.tsx +7 -2
- package/src/components/MessageBubble/Message.tsx +7 -3
- package/src/components/MessageBubble/MessageInteractions.tsx +67 -63
- package/src/components/Modals/AttachSheet/AttachSheet.tsx +195 -0
- package/src/components/Modals/FilePreviewModal/FilePreviewModal.tsx +3 -0
- package/src/components/Modals/FilePreviewModal/PdfView.tsx +71 -0
- package/src/components/Modals/Modal/Modal.tsx +2 -4
- package/src/components/Modals/ModalSelectMedia/ModalSelectMedia.tsx +0 -2
- package/src/components/styled/Button.tsx +9 -0
- package/src/components/styled/SendInput.tsx +191 -107
- package/src/components/styled/StyledInputComponents/StyledInputComponents.tsx +1 -1
- package/src/context/xmppProvider.tsx +51 -2
- package/src/helpers/constants/MODAL_COMPONENTS.tsx +35 -0
- package/src/helpers/constants/MODAL_TYPES.ts +10 -39
- package/src/helpers/historyPreloadScheduler.ts +15 -1
- package/src/helpers/updateMessagesTillLast.tsx +20 -17
- package/src/hooks/useComposing.tsx +0 -9
- package/src/hooks/useLogout.tsx +129 -9
- package/src/hooks/useSendMessage.tsx +114 -60
- package/src/networking/api-requests/auth.api.ts +1 -1
- package/src/networking/api-requests/user.api.ts +1 -0
- package/src/networking/apiClient.ts +74 -38
- package/src/networking/stanzaHandlers.ts +18 -7
- package/src/networking/xmpp/flushLastViewedToPrivateStore.ts +86 -0
- package/src/networking/xmpp/getHistory.xmpp.ts +6 -1
- package/src/networking/xmpp/presenceInRoom.xmpp.ts +8 -3
- package/src/networking/xmpp/sendMediaMessage.xmpp.ts +8 -10
- package/src/networking/xmpp/sendTextMessage.xmpp.ts +12 -12
- package/src/networking/xmppClient.ts +27 -0
- package/src/roomStore/Middleware/logoutMiddleware.tsx +17 -8
- package/src/roomStore/Middleware/unreadMidlleware.tsx +122 -28
- package/src/roomStore/persistence.ts +5 -2
- package/src/roomStore/roomsSlice.ts +115 -7
- package/src/types/types.ts +10 -9
- 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
|
|
13
|
+
import AttachSheet from '../Modals/AttachSheet/AttachSheet';
|
|
14
14
|
import { MediaFilePreview } from './MediaFilePreview';
|
|
15
|
-
import
|
|
16
|
-
import
|
|
17
|
-
import
|
|
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
|
-
|
|
93
|
+
filePreviewsRef.current = files;
|
|
54
94
|
setFilePreviews([...files]);
|
|
55
95
|
};
|
|
56
96
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
70
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
cropping: true,
|
|
117
|
+
const result = await ImagePicker.launchCameraAsync({
|
|
118
|
+
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
|
119
|
+
quality: 0.9,
|
|
85
120
|
});
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
} catch (error
|
|
93
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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.
|
|
139
|
-
type:
|
|
140
|
-
|
|
170
|
+
const result = await DocumentPicker.getDocumentAsync({
|
|
171
|
+
type: '*/*',
|
|
172
|
+
multiple: false,
|
|
173
|
+
copyToCacheDirectory: true,
|
|
141
174
|
});
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
};
|
|
@@ -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
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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 &&
|
|
195
|
+
(!forceReload && preState === 'done')
|
|
182
196
|
) {
|
|
183
197
|
store.dispatch(
|
|
184
198
|
applyRoomsPreloadBatch({
|