@ethora/chat-component-rn 26.6.10 → 26.6.12

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 (66) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/lib/commonjs/components/MainComponents/MediaMessage.js +8 -2
  3. package/lib/commonjs/components/MainComponents/MediaMessage.js.map +1 -1
  4. package/lib/commonjs/components/MessageBubble/InteractionsOverlay.js +1 -1
  5. package/lib/commonjs/components/MessageBubble/InteractionsOverlay.js.map +1 -1
  6. package/lib/commonjs/components/MessageBubble/Message.js +1 -1
  7. package/lib/commonjs/components/MessageBubble/Message.js.map +1 -1
  8. package/lib/commonjs/components/MessageBubble/MessageInteractions.js +1 -1
  9. package/lib/commonjs/components/MessageBubble/MessageInteractions.js.map +1 -1
  10. package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js +7 -4
  11. package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
  12. package/lib/commonjs/components/Modals/FilePreviewModal/GViewWebView.js +1 -1
  13. package/lib/commonjs/components/Modals/FilePreviewModal/GViewWebView.js.map +1 -1
  14. package/lib/commonjs/components/VideoCalls/VideoCallSession.js +6 -3
  15. package/lib/commonjs/components/VideoCalls/VideoCallSession.js.map +1 -1
  16. package/lib/commonjs/components/styled/MediaFilePreview.js +1 -1
  17. package/lib/commonjs/components/styled/MediaFilePreview.js.map +1 -1
  18. package/lib/commonjs/components/styled/VideoMessage.js +111 -61
  19. package/lib/commonjs/components/styled/VideoMessage.js.map +1 -1
  20. package/lib/commonjs/hooks/useSendMessage.js +2 -2
  21. package/lib/commonjs/hooks/useSendMessage.js.map +1 -1
  22. package/lib/commonjs/networking/api-requests/auth.api.js +81 -52
  23. package/lib/commonjs/networking/api-requests/auth.api.js.map +1 -1
  24. package/lib/module/components/MainComponents/MediaMessage.js +8 -2
  25. package/lib/module/components/MainComponents/MediaMessage.js.map +1 -1
  26. package/lib/module/components/MessageBubble/InteractionsOverlay.js +1 -1
  27. package/lib/module/components/MessageBubble/InteractionsOverlay.js.map +1 -1
  28. package/lib/module/components/MessageBubble/Message.js +1 -1
  29. package/lib/module/components/MessageBubble/Message.js.map +1 -1
  30. package/lib/module/components/MessageBubble/MessageInteractions.js +1 -1
  31. package/lib/module/components/MessageBubble/MessageInteractions.js.map +1 -1
  32. package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js +7 -4
  33. package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
  34. package/lib/module/components/Modals/FilePreviewModal/GViewWebView.js +1 -1
  35. package/lib/module/components/Modals/FilePreviewModal/GViewWebView.js.map +1 -1
  36. package/lib/module/components/VideoCalls/VideoCallSession.js +6 -3
  37. package/lib/module/components/VideoCalls/VideoCallSession.js.map +1 -1
  38. package/lib/module/components/styled/MediaFilePreview.js +1 -1
  39. package/lib/module/components/styled/MediaFilePreview.js.map +1 -1
  40. package/lib/module/components/styled/VideoMessage.js +112 -62
  41. package/lib/module/components/styled/VideoMessage.js.map +1 -1
  42. package/lib/module/hooks/useSendMessage.js +3 -3
  43. package/lib/module/hooks/useSendMessage.js.map +1 -1
  44. package/lib/module/networking/api-requests/auth.api.js +80 -51
  45. package/lib/module/networking/api-requests/auth.api.js.map +1 -1
  46. package/lib/typescript/components/MainComponents/MediaMessage.d.ts.map +1 -1
  47. package/lib/typescript/components/Modals/FilePreviewModal/FilePreviewModal.d.ts.map +1 -1
  48. package/lib/typescript/components/VideoCalls/VideoCallSession.d.ts.map +1 -1
  49. package/lib/typescript/components/styled/VideoMessage.d.ts +2 -0
  50. package/lib/typescript/components/styled/VideoMessage.d.ts.map +1 -1
  51. package/lib/typescript/hooks/useCustomTypingIndicator.d.ts +16 -4
  52. package/lib/typescript/hooks/useCustomTypingIndicator.d.ts.map +1 -1
  53. package/lib/typescript/networking/api-requests/auth.api.d.ts +27 -1
  54. package/lib/typescript/networking/api-requests/auth.api.d.ts.map +1 -1
  55. package/package.json +29 -28
  56. package/src/components/MainComponents/MediaMessage.tsx +6 -1
  57. package/src/components/MessageBubble/InteractionsOverlay.tsx +1 -1
  58. package/src/components/MessageBubble/Message.tsx +1 -1
  59. package/src/components/MessageBubble/MessageInteractions.tsx +1 -1
  60. package/src/components/Modals/FilePreviewModal/FilePreviewModal.tsx +6 -3
  61. package/src/components/Modals/FilePreviewModal/GViewWebView.tsx +1 -1
  62. package/src/components/VideoCalls/VideoCallSession.tsx +8 -3
  63. package/src/components/styled/MediaFilePreview.tsx +1 -1
  64. package/src/components/styled/VideoMessage.tsx +115 -54
  65. package/src/hooks/useSendMessage.tsx +3 -3
  66. package/src/networking/api-requests/auth.api.ts +79 -44
@@ -1,6 +1,5 @@
1
1
  import React, { useEffect, useState } from 'react';
2
- import { Pressable, StyleSheet, View } from 'react-native';
3
- import { useVideoPlayer, VideoView } from 'expo-video';
2
+ import { Image, Pressable, StyleSheet, View } from 'react-native';
4
3
  import { useDispatch } from 'react-redux';
5
4
  import {
6
5
  setActiveFile,
@@ -18,72 +17,134 @@ interface CustomMessageVideoProps {
18
17
  fileName: string;
19
18
  fileURL: string;
20
19
  mimetype: string;
20
+ /** The backend-generated poster frame (message.locationPreview). */
21
+ previewURL?: string;
21
22
  }
22
23
 
24
+ const PlayBadge: React.FC = () => (
25
+ <View style={styles.overlay} pointerEvents="none">
26
+ <View style={styles.playButton}>
27
+ <PlayIcon width={28} height={28} />
28
+ </View>
29
+ </View>
30
+ );
31
+
32
+ /**
33
+ * Poster path — taken whenever the message carries a `locationPreview`.
34
+ *
35
+ * A chat row only ever needs a still and a play affordance; actual
36
+ * playback happens in the full-screen preview. Drawing that still with a
37
+ * plain <Image> keeps the whole bubble inside the RN view hierarchy, so
38
+ * the play badge is guaranteed to composite on top of it.
39
+ */
40
+ const PosterVideo: React.FC<{
41
+ previewURL: string;
42
+ onOpen: () => void;
43
+ onPosterError: () => void;
44
+ }> = ({ previewURL, onOpen, onPosterError }) => {
45
+ const [dims, setDims] = useState<MediaDims>(defaultMediaDims());
46
+
47
+ useEffect(() => {
48
+ if (!previewURL) {
49
+ return;
50
+ }
51
+ let active = true;
52
+ Image.getSize(
53
+ previewURL,
54
+ (w, h) => {
55
+ if (active) {
56
+ setDims(fitMediaDimensions(w, h));
57
+ }
58
+ },
59
+ () => {
60
+ /* keep the default box if the size probe fails */
61
+ }
62
+ );
63
+ return () => {
64
+ active = false;
65
+ };
66
+ }, [previewURL]);
67
+
68
+ return (
69
+ <Pressable
70
+ onPress={onOpen}
71
+ style={[styles.wrapper, { width: dims.width, height: dims.height }]}
72
+ accessibilityRole="button"
73
+ accessibilityLabel="Play video"
74
+ >
75
+ <Image
76
+ source={{ uri: previewURL }}
77
+ style={styles.video}
78
+ resizeMode="cover"
79
+ // Not every backend returns a real still — some set
80
+ // locationPreview to the video URL itself, which decodes to
81
+ // nothing. Rather than show an empty box, fall back to the
82
+ // static placeholder branch.
83
+ onError={onPosterError}
84
+ />
85
+ <PlayBadge />
86
+ </Pressable>
87
+ );
88
+ };
89
+
90
+ /**
91
+ * Fallback for messages with no usable poster frame: a static dark
92
+ * placeholder behind the play badge.
93
+ *
94
+ * This branch used to mount an expo-video player purely to paint the
95
+ * first frame (`VideoView` + `surfaceType="textureView"`). That never
96
+ * composited reliably: on Android (Pixel, Android 16, New Architecture)
97
+ * the native video surface draws ABOVE sibling RN views, swallowing the
98
+ * play badge — the video looked like a plain photo. There is no
99
+ * surfaceType/zIndex combination that guarantees ordering across SDK 54
100
+ * and 57, so we don't fight it: no live surface in the bubble at all.
101
+ * The badge is plain RN views over a plain RN background, which cannot
102
+ * be occluded, and we drop the per-row player instance as a bonus.
103
+ * Actual playback (and the real first frame) happens in the full-screen
104
+ * preview modal.
105
+ */
106
+ const PlaceholderVideo: React.FC<{
107
+ onOpen: () => void;
108
+ }> = ({ onOpen }) => {
109
+ const dims = defaultMediaDims();
110
+
111
+ return (
112
+ <Pressable
113
+ onPress={onOpen}
114
+ style={[styles.wrapper, { width: dims.width, height: dims.height }]}
115
+ accessibilityRole="button"
116
+ accessibilityLabel="Play video"
117
+ >
118
+ <PlayBadge />
119
+ </Pressable>
120
+ );
121
+ };
122
+
23
123
  const CustomMessageVideo: React.FC<CustomMessageVideoProps> = ({
24
124
  fileName,
25
125
  fileURL,
26
126
  mimetype,
127
+ previewURL,
27
128
  }) => {
28
129
  const dispatch = useDispatch();
29
- const [dims, setDims] = useState<MediaDims>(defaultMediaDims());
30
-
31
- const player = useVideoPlayer(fileURL, (p) => {
32
- p.muted = true;
33
- });
34
-
35
- useEffect(() => {
36
- const sub = player.addListener('statusChange', () => {
37
- const size = player.videoTrack?.size;
38
- if (size?.width && size?.height) {
39
- setDims(fitMediaDimensions(size.width, size.height));
40
- }
41
- });
42
- return () => sub.remove();
43
- }, [player]);
130
+ const [posterFailed, setPosterFailed] = useState(false);
44
131
 
45
132
  const handleOpen = () => {
46
133
  dispatch(setActiveFile({ fileName, fileURL, mimetype }));
47
134
  dispatch(setActiveModal(MODAL_TYPES.FILE_PREVIEW));
48
135
  };
49
136
 
50
- return (
51
- // Capture-overlay pattern: VideoView sits underneath without ANY
52
- // touch participation; a transparent absolute-fill <Pressable> on
53
- // top owns the tap. Previously we wrapped VideoView in a
54
- // TouchableOpacity and set `pointerEvents="none"` on the inner
55
- // VideoView — but on some expo-video versions / iOS the native
56
- // VideoView still intercepted gestures despite that hint, so
57
- // tapping the poster did nothing. Customer-reported #9 (video
58
- // preview unresponsive). The Pressable now guarantees the tap
59
- // reaches `handleOpen`.
60
- <View
61
- style={[styles.wrapper, { width: dims.width, height: dims.height }]}
62
- >
63
- <VideoView
64
- player={player}
65
- style={styles.video}
66
- contentFit="cover"
67
- nativeControls={false}
68
- surfaceType="textureView"
69
- pointerEvents="none"
137
+ if (previewURL && !posterFailed) {
138
+ return (
139
+ <PosterVideo
140
+ previewURL={previewURL}
141
+ onOpen={handleOpen}
142
+ onPosterError={() => setPosterFailed(true)}
70
143
  />
71
- <View style={styles.overlay} pointerEvents="none">
72
- <View style={styles.playButton}>
73
- <PlayIcon width={28} height={28} />
74
- </View>
75
- </View>
76
- <Pressable
77
- onPress={handleOpen}
78
- // Sits ON TOP of VideoView + the play-button overlay; transparent
79
- // so the poster + play icon show through. Captures the tap and
80
- // routes to handleOpen → setActiveModal(FILE_PREVIEW).
81
- style={StyleSheet.absoluteFill}
82
- accessibilityRole="button"
83
- accessibilityLabel="Play video"
84
- />
85
- </View>
86
- );
144
+ );
145
+ }
146
+
147
+ return <PlaceholderVideo onOpen={handleOpen} />;
87
148
  };
88
149
 
89
150
  export default CustomMessageVideo;
@@ -99,7 +160,7 @@ const styles = StyleSheet.create({
99
160
  height: '100%',
100
161
  },
101
162
  overlay: {
102
- ...StyleSheet.absoluteFillObject,
163
+ ...StyleSheet.absoluteFill,
103
164
  justifyContent: 'center',
104
165
  alignItems: 'center',
105
166
  },
@@ -9,7 +9,7 @@ import {
9
9
  clearMessageFailure,
10
10
  markMessageFailed,
11
11
  } from '../roomStore/roomHeapSlice';
12
- import { uploadFileViaFetch } from '../networking/api-requests/auth.api';
12
+ import { uploadFileMultipart } from '../networking/api-requests/auth.api';
13
13
  import { enqueueOutboundSend } from '../networking/outboundQueue';
14
14
  import { RootState, store } from '../roomStore';
15
15
  import { getGlobalXmppClient } from '../utils/clientRegistry';
@@ -479,7 +479,7 @@ export const useSendMessage = (_configOverride?: IConfig) => {
479
479
  const fd = new FormData();
480
480
  fd.append('files', fileBlob as any);
481
481
  try {
482
- return await uploadFileViaFetch(fd);
482
+ return await uploadFileMultipart(fd);
483
483
  } catch (err: any) {
484
484
  if (err?.response?.status === 500) {
485
485
  console.warn(
@@ -488,7 +488,7 @@ export const useSendMessage = (_configOverride?: IConfig) => {
488
488
  );
489
489
  const fd2 = new FormData();
490
490
  fd2.append('file', fileBlob as any);
491
- return await uploadFileViaFetch(fd2);
491
+ return await uploadFileMultipart(fd2);
492
492
  }
493
493
  throw err;
494
494
  }
@@ -144,55 +144,90 @@ export function uploadFile(formData: FormData) {
144
144
  });
145
145
  }
146
146
 
147
- export async function uploadFileViaFetch(formData: FormData): Promise<{ data: any }> {
147
+ const UPLOAD_PATH = '/files/';
148
+
149
+ /**
150
+ * Multipart upload of a picked file, sent over `XMLHttpRequest`.
151
+ *
152
+ * The transport matters here, and both obvious alternatives are wrong:
153
+ *
154
+ * - `fetch`. Expo SDK 54 shipped a WinterCG-compliant fetch and SDK 57
155
+ * installs it as the GLOBAL fetch. It accepts only `Blob` / `File` /
156
+ * string form parts, so React Native's own `{ uri, type, name }` part
157
+ * — which is exactly what an image/document picker hands us — is
158
+ * rejected with "Unsupported FormDataPart implementation", surfacing
159
+ * to the caller as ERR_NETWORK. A host can set
160
+ * `EXPO_PUBLIC_USE_RN_FETCH=1`, but that opts their whole app out of
161
+ * Expo's fetch just to satisfy this SDK; not a cost we get to impose
162
+ * on a consumer.
163
+ * - axios. It forces its own `Content-Type: multipart/form-data`
164
+ * WITHOUT a boundary, so the server mis-parses one file as many and
165
+ * answers HTTP 413 TOO_MANY_FILES (bug #10 — this is why the upload
166
+ * was moved off axios in the first place).
167
+ *
168
+ * `XMLHttpRequest` avoids both. Expo replaces `fetch`, not `XHR`, so the
169
+ * request still goes through React Native's native networking module,
170
+ * which understands the `{ uri, type, name }` part, streams the file
171
+ * straight from disk (a 50MB video never lands in JS memory) and writes
172
+ * its own `Content-Type` with a valid boundary — which is precisely why
173
+ * we must NOT set that header ourselves below.
174
+ */
175
+ export function uploadFileMultipart(formData: FormData): Promise<{ data: any }> {
148
176
  const token = store.getState().chatSettingStore?.user?.token ?? '';
149
177
  const baseUrl = getCurrentBaseURL();
150
- const url = `${baseUrl.replace(/\/$/, '')}/files/`;
178
+ const url = `${baseUrl.replace(/\/$/, '')}${UPLOAD_PATH}`;
151
179
 
152
- pushLog('http', `→ POST ${url} (fetch)`, {
180
+ pushLog('http', `→ POST ${url} (xhr)`, {
153
181
  headers: { Authorization: token ? token.slice(0, 16) + '…' : '[empty]', Accept: '*/*' },
154
182
  });
155
183
 
156
- let res: Response;
157
- try {
158
- res = await fetch(url, {
159
- method: 'POST',
160
- headers: {
161
- Authorization: token,
162
- Accept: '*/*',
163
- },
164
- body: formData as any,
165
- });
166
- } catch (e: any) {
167
- pushLog('http', `← ERR fetch /files/`, {
168
- name: e?.name,
169
- message: e?.message,
170
- stack: e?.stack,
171
- });
172
- const err: any = new Error(e?.message || 'Network request failed');
173
- err.code = 'ERR_NETWORK';
174
- err.cause = e;
175
- err.config = { url: '/files/' };
176
- throw err;
177
- }
178
-
179
- if (!res.ok) {
180
- let body: any = null;
181
- try {
182
- body = await res.json();
183
- } catch {
184
- try { body = await res.text(); } catch { body = null; }
185
- }
186
- pushLog('http', `← ${res.status} POST /files/ (fetch)`, body);
187
- const err: any = new Error(`Upload failed: ${res.status}`);
188
- err.response = { status: res.status, statusText: res.statusText, data: body };
189
- err.config = { url: '/files/' };
190
- throw err;
191
- }
192
-
193
- const data = await res.json();
194
- pushLog('http', `← ${res.status} POST /files/ (fetch)`, {
195
- resultsCount: Array.isArray(data?.results) ? data.results.length : 'n/a',
184
+ return new Promise((resolve, reject) => {
185
+ const xhr = new XMLHttpRequest();
186
+
187
+ const failNetwork = (reason: string) => {
188
+ pushLog('http', `← ERR xhr ${UPLOAD_PATH}`, { reason });
189
+ const err: any = new Error(reason);
190
+ err.code = 'ERR_NETWORK';
191
+ err.config = { url: UPLOAD_PATH };
192
+ reject(err);
193
+ };
194
+
195
+ xhr.onload = () => {
196
+ const status = xhr.status;
197
+ let body: any = null;
198
+ try {
199
+ body = JSON.parse(xhr.responseText);
200
+ } catch {
201
+ body = xhr.responseText || null;
202
+ }
203
+
204
+ if (status < 200 || status >= 300) {
205
+ pushLog('http', `← ${status} POST ${UPLOAD_PATH} (xhr)`, body);
206
+ const err: any = new Error(`Upload failed: ${status}`);
207
+ // Shaped like an axios error so callers' `err.response.status`
208
+ // checks keep working — notably useSendMessage's retry with the
209
+ // singular "file" field on 500.
210
+ err.response = { status, statusText: xhr.statusText, data: body };
211
+ err.config = { url: UPLOAD_PATH };
212
+ reject(err);
213
+ return;
214
+ }
215
+
216
+ pushLog('http', `← ${status} POST ${UPLOAD_PATH} (xhr)`, {
217
+ resultsCount: Array.isArray(body?.results) ? body.results.length : 'n/a',
218
+ });
219
+ resolve({ data: body });
220
+ };
221
+
222
+ xhr.onerror = () => failNetwork('Network request failed');
223
+ xhr.ontimeout = () => failNetwork('Upload timed out');
224
+ xhr.onabort = () => failNetwork('Upload aborted');
225
+
226
+ xhr.open('POST', url);
227
+ xhr.setRequestHeader('Authorization', token);
228
+ xhr.setRequestHeader('Accept', '*/*');
229
+ // Deliberately no Content-Type — see the note above: RN fills it in
230
+ // along with the multipart boundary it generated.
231
+ xhr.send(formData as any);
196
232
  });
197
- return { data };
198
233
  }