@ethora/chat-component-rn 26.6.10 → 26.6.11

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 (26) hide show
  1. package/lib/commonjs/components/MainComponents/MediaMessage.js +7 -1
  2. package/lib/commonjs/components/MainComponents/MediaMessage.js.map +1 -1
  3. package/lib/commonjs/components/styled/VideoMessage.js +110 -26
  4. package/lib/commonjs/components/styled/VideoMessage.js.map +1 -1
  5. package/lib/commonjs/hooks/useSendMessage.js +2 -2
  6. package/lib/commonjs/hooks/useSendMessage.js.map +1 -1
  7. package/lib/commonjs/networking/api-requests/auth.api.js +81 -52
  8. package/lib/commonjs/networking/api-requests/auth.api.js.map +1 -1
  9. package/lib/module/components/MainComponents/MediaMessage.js +7 -1
  10. package/lib/module/components/MainComponents/MediaMessage.js.map +1 -1
  11. package/lib/module/components/styled/VideoMessage.js +111 -27
  12. package/lib/module/components/styled/VideoMessage.js.map +1 -1
  13. package/lib/module/hooks/useSendMessage.js +3 -3
  14. package/lib/module/hooks/useSendMessage.js.map +1 -1
  15. package/lib/module/networking/api-requests/auth.api.js +80 -51
  16. package/lib/module/networking/api-requests/auth.api.js.map +1 -1
  17. package/lib/typescript/components/MainComponents/MediaMessage.d.ts.map +1 -1
  18. package/lib/typescript/components/styled/VideoMessage.d.ts +2 -0
  19. package/lib/typescript/components/styled/VideoMessage.d.ts.map +1 -1
  20. package/lib/typescript/networking/api-requests/auth.api.d.ts +27 -1
  21. package/lib/typescript/networking/api-requests/auth.api.d.ts.map +1 -1
  22. package/package.json +1 -1
  23. package/src/components/MainComponents/MediaMessage.tsx +5 -0
  24. package/src/components/styled/VideoMessage.tsx +114 -20
  25. package/src/hooks/useSendMessage.tsx +3 -3
  26. package/src/networking/api-requests/auth.api.ts +79 -44
@@ -9,7 +9,33 @@ export declare function registerSocial(idToken: string, accessToken: string, aut
9
9
  export declare function checkEmailExist(email: string): Promise<import("axios").AxiosResponse<any, any, {}>>;
10
10
  export declare function loginViaJwt(clientToken: string): Promise<User>;
11
11
  export declare function uploadFile(formData: FormData): Promise<import("axios").AxiosResponse<any, any, {}>>;
12
- export declare function uploadFileViaFetch(formData: FormData): Promise<{
12
+ /**
13
+ * Multipart upload of a picked file, sent over `XMLHttpRequest`.
14
+ *
15
+ * The transport matters here, and both obvious alternatives are wrong:
16
+ *
17
+ * - `fetch`. Expo SDK 54 shipped a WinterCG-compliant fetch and SDK 57
18
+ * installs it as the GLOBAL fetch. It accepts only `Blob` / `File` /
19
+ * string form parts, so React Native's own `{ uri, type, name }` part
20
+ * — which is exactly what an image/document picker hands us — is
21
+ * rejected with "Unsupported FormDataPart implementation", surfacing
22
+ * to the caller as ERR_NETWORK. A host can set
23
+ * `EXPO_PUBLIC_USE_RN_FETCH=1`, but that opts their whole app out of
24
+ * Expo's fetch just to satisfy this SDK; not a cost we get to impose
25
+ * on a consumer.
26
+ * - axios. It forces its own `Content-Type: multipart/form-data`
27
+ * WITHOUT a boundary, so the server mis-parses one file as many and
28
+ * answers HTTP 413 TOO_MANY_FILES (bug #10 — this is why the upload
29
+ * was moved off axios in the first place).
30
+ *
31
+ * `XMLHttpRequest` avoids both. Expo replaces `fetch`, not `XHR`, so the
32
+ * request still goes through React Native's native networking module,
33
+ * which understands the `{ uri, type, name }` part, streams the file
34
+ * straight from disk (a 50MB video never lands in JS memory) and writes
35
+ * its own `Content-Type` with a valid boundary — which is precisely why
36
+ * we must NOT set that header ourselves below.
37
+ */
38
+ export declare function uploadFileMultipart(formData: FormData): Promise<{
13
39
  data: any;
14
40
  }>;
15
41
  //# sourceMappingURL=auth.api.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"auth.api.d.ts","sourceRoot":"","sources":["../../../../src/networking/api-requests/auth.api.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,mBAAmB,CAAC;AAezC,wBAAsB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;UAEtD,IAAI;kBACI,MAAM;WACb,MAAM;aAahB;AAED,wBAAgB,WAAW,CACzB,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,EACjB,SAAS,GAAE,MAAoB,wDAYhC;AAED,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,UAAU,CAAC,EAAE,MAAM,wDAapB;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,wDAM5C;AAED,wBAAsB,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA4BpE;AAuBD,wBAAgB,UAAU,CAAC,QAAQ,EAAE,QAAQ,wDAa5C;AAED,wBAAsB,kBAAkB,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC;IAAE,IAAI,EAAE,GAAG,CAAA;CAAE,CAAC,CAmDnF"}
1
+ {"version":3,"file":"auth.api.d.ts","sourceRoot":"","sources":["../../../../src/networking/api-requests/auth.api.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,mBAAmB,CAAC;AAezC,wBAAsB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;UAEtD,IAAI;kBACI,MAAM;WACb,MAAM;aAahB;AAED,wBAAgB,WAAW,CACzB,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,EACjB,SAAS,GAAE,MAAoB,wDAYhC;AAED,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,UAAU,CAAC,EAAE,MAAM,wDAapB;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,wDAM5C;AAED,wBAAsB,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA4BpE;AAuBD,wBAAgB,UAAU,CAAC,QAAQ,EAAE,QAAQ,wDAa5C;AAID;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC;IAAE,IAAI,EAAE,GAAG,CAAA;CAAE,CAAC,CA0D9E"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ethora/chat-component-rn",
3
- "version": "26.6.10",
3
+ "version": "26.6.11",
4
4
  "description": "Drop-in XMPP chat component for React Native / Expo. <Chat config={...}/> and you're done.",
5
5
  "main": "index.js",
6
6
  "module": "lib/module/main.js",
@@ -183,6 +183,11 @@ const MediaMessage: React.FC<MediaMessageProps> = ({
183
183
  <CustomMessageVideo
184
184
  fileName={displayName}
185
185
  fileURL={location || ''}
186
+ // `messageText` is the message's locationPreview — the poster
187
+ // frame the backend generated for this video. Handing it over
188
+ // lets the bubble render as a still + play badge instead of
189
+ // mounting a video surface per row (see VideoMessage).
190
+ previewURL={messageText}
186
191
  mimetype={mimeType}
187
192
  />
188
193
  );
@@ -1,5 +1,5 @@
1
1
  import React, { useEffect, useState } from 'react';
2
- import { Pressable, StyleSheet, View } from 'react-native';
2
+ import { Image, Pressable, StyleSheet, View } from 'react-native';
3
3
  import { useVideoPlayer, VideoView } from 'expo-video';
4
4
  import { useDispatch } from 'react-redux';
5
5
  import {
@@ -18,14 +18,90 @@ interface CustomMessageVideoProps {
18
18
  fileName: string;
19
19
  fileURL: string;
20
20
  mimetype: string;
21
+ /** The backend-generated poster frame (message.locationPreview). */
22
+ previewURL?: string;
21
23
  }
22
24
 
23
- const CustomMessageVideo: React.FC<CustomMessageVideoProps> = ({
24
- fileName,
25
- fileURL,
26
- mimetype,
27
- }) => {
28
- const dispatch = useDispatch();
25
+ const PlayBadge: React.FC = () => (
26
+ <View style={styles.overlay} pointerEvents="none">
27
+ <View style={styles.playButton}>
28
+ <PlayIcon width={28} height={28} />
29
+ </View>
30
+ </View>
31
+ );
32
+
33
+ /**
34
+ * Poster path — taken whenever the message carries a `locationPreview`.
35
+ *
36
+ * A chat row only ever needs a still and a play affordance; actual
37
+ * playback happens in the full-screen preview. Drawing that still with a
38
+ * plain <Image> keeps the whole bubble inside the RN view hierarchy, so
39
+ * the play badge is guaranteed to composite on top of it.
40
+ *
41
+ * The VideoView path below cannot make that promise: expo-video draws
42
+ * into a native surface, and depending on platform, expo-video version
43
+ * and surfaceType that surface can end up above its sibling RN views —
44
+ * swallowing the badge and leaving the video looking like a plain photo.
45
+ * It is also cheaper: no video player instance per row in a long list.
46
+ */
47
+ const PosterVideo: React.FC<{
48
+ previewURL: string;
49
+ onOpen: () => void;
50
+ onPosterError: () => void;
51
+ }> = ({ previewURL, onOpen, onPosterError }) => {
52
+ const [dims, setDims] = useState<MediaDims>(defaultMediaDims());
53
+
54
+ useEffect(() => {
55
+ if (!previewURL) {
56
+ return;
57
+ }
58
+ let active = true;
59
+ Image.getSize(
60
+ previewURL,
61
+ (w, h) => {
62
+ if (active) {
63
+ setDims(fitMediaDimensions(w, h));
64
+ }
65
+ },
66
+ () => {
67
+ /* keep the default box if the size probe fails */
68
+ }
69
+ );
70
+ return () => {
71
+ active = false;
72
+ };
73
+ }, [previewURL]);
74
+
75
+ return (
76
+ <Pressable
77
+ onPress={onOpen}
78
+ style={[styles.wrapper, { width: dims.width, height: dims.height }]}
79
+ accessibilityRole="button"
80
+ accessibilityLabel="Play video"
81
+ >
82
+ <Image
83
+ source={{ uri: previewURL }}
84
+ style={styles.video}
85
+ resizeMode="cover"
86
+ // Not every backend returns a real still — some set
87
+ // locationPreview to the video URL itself, which decodes to
88
+ // nothing. Rather than show an empty box, hand the row back to
89
+ // the player so it can paint a first frame.
90
+ onError={onPosterError}
91
+ />
92
+ <PlayBadge />
93
+ </Pressable>
94
+ );
95
+ };
96
+
97
+ /**
98
+ * Fallback for messages with no usable poster frame: mount the player
99
+ * purely to paint its first frame.
100
+ */
101
+ const VideoSurfaceVideo: React.FC<{
102
+ fileURL: string;
103
+ onOpen: () => void;
104
+ }> = ({ fileURL, onOpen }) => {
29
105
  const [dims, setDims] = useState<MediaDims>(defaultMediaDims());
30
106
 
31
107
  const player = useVideoPlayer(fileURL, (p) => {
@@ -42,11 +118,6 @@ const CustomMessageVideo: React.FC<CustomMessageVideoProps> = ({
42
118
  return () => sub.remove();
43
119
  }, [player]);
44
120
 
45
- const handleOpen = () => {
46
- dispatch(setActiveFile({ fileName, fileURL, mimetype }));
47
- dispatch(setActiveModal(MODAL_TYPES.FILE_PREVIEW));
48
- };
49
-
50
121
  return (
51
122
  // Capture-overlay pattern: VideoView sits underneath without ANY
52
123
  // touch participation; a transparent absolute-fill <Pressable> on
@@ -56,7 +127,7 @@ const CustomMessageVideo: React.FC<CustomMessageVideoProps> = ({
56
127
  // VideoView still intercepted gestures despite that hint, so
57
128
  // tapping the poster did nothing. Customer-reported #9 (video
58
129
  // preview unresponsive). The Pressable now guarantees the tap
59
- // reaches `handleOpen`.
130
+ // reaches `onOpen`.
60
131
  <View
61
132
  style={[styles.wrapper, { width: dims.width, height: dims.height }]}
62
133
  >
@@ -68,16 +139,12 @@ const CustomMessageVideo: React.FC<CustomMessageVideoProps> = ({
68
139
  surfaceType="textureView"
69
140
  pointerEvents="none"
70
141
  />
71
- <View style={styles.overlay} pointerEvents="none">
72
- <View style={styles.playButton}>
73
- <PlayIcon width={28} height={28} />
74
- </View>
75
- </View>
142
+ <PlayBadge />
76
143
  <Pressable
77
- onPress={handleOpen}
144
+ onPress={onOpen}
78
145
  // Sits ON TOP of VideoView + the play-button overlay; transparent
79
146
  // so the poster + play icon show through. Captures the tap and
80
- // routes to handleOpen → setActiveModal(FILE_PREVIEW).
147
+ // routes to onOpen → setActiveModal(FILE_PREVIEW).
81
148
  style={StyleSheet.absoluteFill}
82
149
  accessibilityRole="button"
83
150
  accessibilityLabel="Play video"
@@ -86,6 +153,33 @@ const CustomMessageVideo: React.FC<CustomMessageVideoProps> = ({
86
153
  );
87
154
  };
88
155
 
156
+ const CustomMessageVideo: React.FC<CustomMessageVideoProps> = ({
157
+ fileName,
158
+ fileURL,
159
+ mimetype,
160
+ previewURL,
161
+ }) => {
162
+ const dispatch = useDispatch();
163
+ const [posterFailed, setPosterFailed] = useState(false);
164
+
165
+ const handleOpen = () => {
166
+ dispatch(setActiveFile({ fileName, fileURL, mimetype }));
167
+ dispatch(setActiveModal(MODAL_TYPES.FILE_PREVIEW));
168
+ };
169
+
170
+ if (previewURL && !posterFailed) {
171
+ return (
172
+ <PosterVideo
173
+ previewURL={previewURL}
174
+ onOpen={handleOpen}
175
+ onPosterError={() => setPosterFailed(true)}
176
+ />
177
+ );
178
+ }
179
+
180
+ return <VideoSurfaceVideo fileURL={fileURL} onOpen={handleOpen} />;
181
+ };
182
+
89
183
  export default CustomMessageVideo;
90
184
 
91
185
  const styles = StyleSheet.create({
@@ -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
  }