@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.
- package/lib/commonjs/components/MainComponents/MediaMessage.js +7 -1
- package/lib/commonjs/components/MainComponents/MediaMessage.js.map +1 -1
- package/lib/commonjs/components/styled/VideoMessage.js +110 -26
- package/lib/commonjs/components/styled/VideoMessage.js.map +1 -1
- package/lib/commonjs/hooks/useSendMessage.js +2 -2
- package/lib/commonjs/hooks/useSendMessage.js.map +1 -1
- package/lib/commonjs/networking/api-requests/auth.api.js +81 -52
- package/lib/commonjs/networking/api-requests/auth.api.js.map +1 -1
- package/lib/module/components/MainComponents/MediaMessage.js +7 -1
- package/lib/module/components/MainComponents/MediaMessage.js.map +1 -1
- package/lib/module/components/styled/VideoMessage.js +111 -27
- package/lib/module/components/styled/VideoMessage.js.map +1 -1
- package/lib/module/hooks/useSendMessage.js +3 -3
- package/lib/module/hooks/useSendMessage.js.map +1 -1
- package/lib/module/networking/api-requests/auth.api.js +80 -51
- package/lib/module/networking/api-requests/auth.api.js.map +1 -1
- package/lib/typescript/components/MainComponents/MediaMessage.d.ts.map +1 -1
- package/lib/typescript/components/styled/VideoMessage.d.ts +2 -0
- package/lib/typescript/components/styled/VideoMessage.d.ts.map +1 -1
- package/lib/typescript/networking/api-requests/auth.api.d.ts +27 -1
- package/lib/typescript/networking/api-requests/auth.api.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/MainComponents/MediaMessage.tsx +5 -0
- package/src/components/styled/VideoMessage.tsx +114 -20
- package/src/hooks/useSendMessage.tsx +3 -3
- 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
|
-
|
|
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;
|
|
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
|
@@ -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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 `
|
|
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
|
-
<
|
|
72
|
-
<View style={styles.playButton}>
|
|
73
|
-
<PlayIcon width={28} height={28} />
|
|
74
|
-
</View>
|
|
75
|
-
</View>
|
|
142
|
+
<PlayBadge />
|
|
76
143
|
<Pressable
|
|
77
|
-
onPress={
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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(/\/$/, '')}
|
|
178
|
+
const url = `${baseUrl.replace(/\/$/, '')}${UPLOAD_PATH}`;
|
|
151
179
|
|
|
152
|
-
pushLog('http', `→ POST ${url} (
|
|
180
|
+
pushLog('http', `→ POST ${url} (xhr)`, {
|
|
153
181
|
headers: { Authorization: token ? token.slice(0, 16) + '…' : '[empty]', Accept: '*/*' },
|
|
154
182
|
});
|
|
155
183
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
}
|