turbodesk-livechat-react-native 0.1.0-alpha.2 → 0.1.0-alpha.21
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 +45 -0
- package/dist/api/conversation-api.d.ts +9 -0
- package/dist/api/conversation-api.d.ts.map +1 -1
- package/dist/api/conversation-api.js +11 -1
- package/dist/api/conversation-api.js.map +1 -1
- package/dist/hooks/use-send-message.d.ts +1 -0
- package/dist/hooks/use-send-message.d.ts.map +1 -1
- package/dist/hooks/use-send-message.js +20 -14
- package/dist/hooks/use-send-message.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/navigation/LiveChatPanel.d.ts.map +1 -1
- package/dist/navigation/LiveChatPanel.js +12 -1
- package/dist/navigation/LiveChatPanel.js.map +1 -1
- package/dist/provider/LiveChatProvider.d.ts +1 -1
- package/dist/provider/LiveChatProvider.d.ts.map +1 -1
- package/dist/provider/LiveChatProvider.js +13 -2
- package/dist/provider/LiveChatProvider.js.map +1 -1
- package/dist/provider/types.d.ts +2 -0
- package/dist/provider/types.d.ts.map +1 -1
- package/dist/ui/components/ConversationHeader.d.ts.map +1 -1
- package/dist/ui/components/ConversationHeader.js +7 -4
- package/dist/ui/components/ConversationHeader.js.map +1 -1
- package/dist/ui/components/ConversationListScreen.d.ts.map +1 -1
- package/dist/ui/components/ConversationListScreen.js +11 -2
- package/dist/ui/components/ConversationListScreen.js.map +1 -1
- package/dist/ui/components/ConversationScreen.d.ts.map +1 -1
- package/dist/ui/components/ConversationScreen.js +11 -2
- package/dist/ui/components/ConversationScreen.js.map +1 -1
- package/dist/ui/components/HomeScreen.d.ts.map +1 -1
- package/dist/ui/components/HomeScreen.js +14 -4
- package/dist/ui/components/HomeScreen.js.map +1 -1
- package/dist/ui/components/MessageComposer.d.ts +1 -0
- package/dist/ui/components/MessageComposer.d.ts.map +1 -1
- package/dist/ui/components/MessageComposer.js +129 -51
- package/dist/ui/components/MessageComposer.js.map +1 -1
- package/dist/ui/safe-area.d.ts +9 -0
- package/dist/ui/safe-area.d.ts.map +1 -0
- package/dist/ui/safe-area.js +28 -0
- package/dist/ui/safe-area.js.map +1 -0
- package/package.json +9 -5
- package/src/api/conversation-api.ts +11 -0
- package/src/hooks/use-send-message.ts +169 -159
- package/src/index.ts +3 -0
- package/src/navigation/LiveChatPanel.tsx +15 -3
- package/src/provider/LiveChatProvider.tsx +16 -4
- package/src/provider/types.ts +2 -0
- package/src/ui/components/ConversationHeader.tsx +7 -6
- package/src/ui/components/ConversationListScreen.tsx +18 -5
- package/src/ui/components/ConversationScreen.tsx +23 -7
- package/src/ui/components/HomeScreen.tsx +21 -6
- package/src/ui/components/MessageComposer.tsx +166 -57
- package/src/ui/safe-area.ts +34 -0
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
getWidgetName,
|
|
23
23
|
} from "../theme";
|
|
24
24
|
import { HistoryChatsIcon, SendPlaneIcon } from "../icons";
|
|
25
|
+
import { usePanelSafeAreaInsets } from "../safe-area";
|
|
25
26
|
|
|
26
27
|
let LinearGradient: any = null;
|
|
27
28
|
try {
|
|
@@ -95,6 +96,7 @@ export function HomeScreen({
|
|
|
95
96
|
visitorQueryParams,
|
|
96
97
|
embedLoadState,
|
|
97
98
|
} = useLiveChatContext();
|
|
99
|
+
const { top: safeTop, bottom: safeBottom } = usePanelSafeAreaInsets();
|
|
98
100
|
|
|
99
101
|
const [conversationCount, setConversationCount] = useState(0);
|
|
100
102
|
|
|
@@ -133,7 +135,7 @@ export function HomeScreen({
|
|
|
133
135
|
style={styles.flex}
|
|
134
136
|
>
|
|
135
137
|
{/* top — branding */}
|
|
136
|
-
<View style={styles.header}>
|
|
138
|
+
<View style={[styles.header, { paddingTop: safeTop + 20 }]}>
|
|
137
139
|
{logoUrl ? (
|
|
138
140
|
<Image
|
|
139
141
|
source={{ uri: logoUrl }}
|
|
@@ -143,11 +145,17 @@ export function HomeScreen({
|
|
|
143
145
|
) : (
|
|
144
146
|
<Avatar name={widgetName} size={44} />
|
|
145
147
|
)}
|
|
146
|
-
<Text
|
|
148
|
+
<Text
|
|
149
|
+
style={[styles.headerTitle, { color: contrastText }]}
|
|
150
|
+
numberOfLines={2}
|
|
151
|
+
>
|
|
147
152
|
{title}
|
|
148
153
|
</Text>
|
|
149
154
|
<Text
|
|
150
|
-
style={[
|
|
155
|
+
style={[
|
|
156
|
+
styles.headerSubtitle,
|
|
157
|
+
{ color: contrastText, opacity: 0.82 },
|
|
158
|
+
]}
|
|
151
159
|
numberOfLines={3}
|
|
152
160
|
>
|
|
153
161
|
{description}
|
|
@@ -182,7 +190,10 @@ export function HomeScreen({
|
|
|
182
190
|
Previous chats
|
|
183
191
|
{conversationCount > 1 ? (
|
|
184
192
|
<Text
|
|
185
|
-
style={{
|
|
193
|
+
style={{
|
|
194
|
+
fontWeight: "400",
|
|
195
|
+
color: "rgba(255,255,255,0.7)",
|
|
196
|
+
}}
|
|
186
197
|
>
|
|
187
198
|
{" "}
|
|
188
199
|
({conversationCount})
|
|
@@ -219,7 +230,12 @@ export function HomeScreen({
|
|
|
219
230
|
</View>
|
|
220
231
|
</View>
|
|
221
232
|
|
|
222
|
-
<Text
|
|
233
|
+
<Text
|
|
234
|
+
style={[
|
|
235
|
+
styles.poweredBy,
|
|
236
|
+
{ color: "rgba(255,255,255,0.7)", paddingBottom: 8 + safeBottom },
|
|
237
|
+
]}
|
|
238
|
+
>
|
|
223
239
|
{POWERED_BY_TEXT}
|
|
224
240
|
</Text>
|
|
225
241
|
</GradientBackground>
|
|
@@ -231,7 +247,6 @@ const styles = StyleSheet.create({
|
|
|
231
247
|
header: {
|
|
232
248
|
flex: 0.3,
|
|
233
249
|
paddingHorizontal: 20,
|
|
234
|
-
paddingTop: 52,
|
|
235
250
|
paddingBottom: 32,
|
|
236
251
|
gap: 12,
|
|
237
252
|
justifyContent: "flex-start",
|
|
@@ -3,6 +3,8 @@ import {
|
|
|
3
3
|
ActivityIndicator,
|
|
4
4
|
Alert,
|
|
5
5
|
Image,
|
|
6
|
+
Keyboard,
|
|
7
|
+
Platform,
|
|
6
8
|
ScrollView,
|
|
7
9
|
StyleSheet,
|
|
8
10
|
Text,
|
|
@@ -13,6 +15,7 @@ import {
|
|
|
13
15
|
import { useLiveChatContext } from "../../provider/LiveChatContext";
|
|
14
16
|
import { fileApi } from "../../api/file-api";
|
|
15
17
|
import { SendPlaneIcon, PaperclipIcon, CloseIcon } from "../icons";
|
|
18
|
+
import { usePanelSafeAreaInsets } from "../safe-area";
|
|
16
19
|
|
|
17
20
|
// ── types ─────────────────────────────────────────────────────────────────────
|
|
18
21
|
|
|
@@ -41,6 +44,7 @@ export type PendingAttachment = {
|
|
|
41
44
|
uri: string;
|
|
42
45
|
name: string;
|
|
43
46
|
mimeType: string;
|
|
47
|
+
size?: number;
|
|
44
48
|
kind: PickedAttachmentKind;
|
|
45
49
|
};
|
|
46
50
|
|
|
@@ -80,32 +84,43 @@ function AttachmentMenu({ onPick, disabled, attachmentCount, color }: Attachment
|
|
|
80
84
|
try {
|
|
81
85
|
let picked: PickedAttachment | null = null;
|
|
82
86
|
|
|
83
|
-
if (kind === "image"
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
if (kind === "image") {
|
|
88
|
+
const ImagePicker = require("react-native-image-picker");
|
|
89
|
+
await new Promise<void>((resolve) => {
|
|
90
|
+
ImagePicker.launchImageLibrary({ mediaType: "photo", quality: 0.8 }, (res: any) => {
|
|
91
|
+
if (res.didCancel || res.errorCode) { resolve(); return; }
|
|
92
|
+
const asset = res.assets?.[0];
|
|
93
|
+
if (!asset) { resolve(); return; }
|
|
94
|
+
picked = {
|
|
95
|
+
uri: asset.uri,
|
|
96
|
+
name: asset.fileName ?? "image.jpg",
|
|
97
|
+
type: asset.type ?? "image/jpeg",
|
|
98
|
+
size: asset.fileSize,
|
|
99
|
+
kind: "image",
|
|
100
|
+
};
|
|
101
|
+
resolve();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
} else if (kind === "video") {
|
|
105
|
+
const ImagePicker = require("react-native-image-picker");
|
|
87
106
|
await new Promise<void>((resolve) => {
|
|
88
|
-
ImagePicker.launchImageLibrary(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
resolve();
|
|
102
|
-
}
|
|
103
|
-
);
|
|
107
|
+
ImagePicker.launchImageLibrary({ mediaType: "video" }, (res: any) => {
|
|
108
|
+
if (res.didCancel || res.errorCode) { resolve(); return; }
|
|
109
|
+
const asset = res.assets?.[0];
|
|
110
|
+
if (!asset) { resolve(); return; }
|
|
111
|
+
picked = {
|
|
112
|
+
uri: asset.uri,
|
|
113
|
+
name: asset.fileName ?? "video.mp4",
|
|
114
|
+
type: asset.type ?? "video/mp4",
|
|
115
|
+
size: asset.fileSize,
|
|
116
|
+
kind: "video",
|
|
117
|
+
};
|
|
118
|
+
resolve();
|
|
119
|
+
});
|
|
104
120
|
});
|
|
105
121
|
} else {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (!DocumentPicker) { Alert.alert("Missing dependency", "Install react-native-document-picker to pick files."); return; }
|
|
122
|
+
// audio / document — use @react-native-documents/picker
|
|
123
|
+
const DocumentPicker = require("@react-native-documents/picker");
|
|
109
124
|
const types = kind === "audio"
|
|
110
125
|
? [DocumentPicker.types.audio]
|
|
111
126
|
: [DocumentPicker.types.pdf, DocumentPicker.types.plainText, DocumentPicker.types.doc, DocumentPicker.types.docx];
|
|
@@ -124,7 +139,7 @@ function AttachmentMenu({ onPick, disabled, attachmentCount, color }: Attachment
|
|
|
124
139
|
|
|
125
140
|
if (picked) onPick(picked);
|
|
126
141
|
} catch (e: any) {
|
|
127
|
-
if (e?.code !== "DOCUMENT_PICKER_CANCELED") {
|
|
142
|
+
if (e?.code !== "DOCUMENT_PICKER_CANCELED" && e?.code !== "OPERATION_CANCELED") {
|
|
128
143
|
Alert.alert("Error", "Could not pick file.");
|
|
129
144
|
}
|
|
130
145
|
}
|
|
@@ -212,42 +227,61 @@ function FilePreviews({
|
|
|
212
227
|
|
|
213
228
|
// ── upload helper ─────────────────────────────────────────────────────────────
|
|
214
229
|
|
|
230
|
+
// Throws on any failure so the caller can surface the error and preserve the draft.
|
|
215
231
|
async function uploadAttachment(
|
|
216
232
|
pending: PendingAttachment,
|
|
217
233
|
visitorQueryParams: Record<string, string> | null
|
|
218
|
-
): Promise<UploadedAttachment
|
|
234
|
+
): Promise<UploadedAttachment> {
|
|
235
|
+
const ext = pending.name.split(".").pop() ?? "";
|
|
236
|
+
let presigned: any;
|
|
219
237
|
try {
|
|
220
|
-
|
|
221
|
-
const presigned = await fileApi.getFileUrl(
|
|
238
|
+
presigned = await fileApi.getFileUrl(
|
|
222
239
|
{
|
|
223
240
|
operation: "putObject",
|
|
224
241
|
name: pending.name,
|
|
225
242
|
type: pending.mimeType,
|
|
226
243
|
extension: ext,
|
|
227
|
-
|
|
244
|
+
bytes: pending.size,
|
|
245
|
+
folder: "livechat",
|
|
228
246
|
},
|
|
229
247
|
visitorQueryParams ? { params: visitorQueryParams } : undefined
|
|
230
|
-
)
|
|
248
|
+
);
|
|
249
|
+
} catch {
|
|
250
|
+
throw new Error(`Failed to prepare upload for "${pending.name}"`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const signedUrl = presigned?.data?.signedUrl;
|
|
254
|
+
if (!signedUrl) throw new Error(`No upload URL returned for "${pending.name}"`);
|
|
231
255
|
|
|
232
|
-
|
|
256
|
+
let putRes: Response;
|
|
257
|
+
try {
|
|
258
|
+
putRes = await fetch(signedUrl, {
|
|
233
259
|
method: "PUT",
|
|
234
260
|
headers: { "Content-Type": pending.mimeType },
|
|
235
261
|
body: { uri: pending.uri, type: pending.mimeType, name: pending.name } as any,
|
|
236
262
|
});
|
|
237
|
-
|
|
238
|
-
const uploaded = presigned?.data?.file;
|
|
239
|
-
return {
|
|
240
|
-
id: pending.id,
|
|
241
|
-
fileId: uploaded._id,
|
|
242
|
-
fileUrl: uploaded.url,
|
|
243
|
-
fileName: uploaded.name,
|
|
244
|
-
fileExtension: uploaded.extension,
|
|
245
|
-
fileType: pending.mimeType,
|
|
246
|
-
type: pending.kind,
|
|
247
|
-
};
|
|
248
263
|
} catch {
|
|
249
|
-
|
|
264
|
+
throw new Error(`Network error while uploading "${pending.name}"`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!putRes.ok) {
|
|
268
|
+
throw new Error(`Upload failed for "${pending.name}" (HTTP ${putRes.status})`);
|
|
250
269
|
}
|
|
270
|
+
|
|
271
|
+
const uploaded = presigned?.data?.file;
|
|
272
|
+
if (!uploaded?._id) {
|
|
273
|
+
throw new Error(`Upload succeeded but server returned no file metadata for "${pending.name}"`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
id: pending.id,
|
|
278
|
+
fileId: uploaded._id,
|
|
279
|
+
fileUrl: uploaded.url,
|
|
280
|
+
fileName: uploaded.name,
|
|
281
|
+
fileExtension: uploaded.extension,
|
|
282
|
+
fileType: pending.mimeType,
|
|
283
|
+
type: pending.kind,
|
|
284
|
+
};
|
|
251
285
|
}
|
|
252
286
|
|
|
253
287
|
// ── main composer ─────────────────────────────────────────────────────────────
|
|
@@ -258,18 +292,42 @@ export function MessageComposer({
|
|
|
258
292
|
placeholder = "Message…",
|
|
259
293
|
}: MessageComposerProps) {
|
|
260
294
|
const { theme: t, visitorQueryParams } = useLiveChatContext();
|
|
295
|
+
const { bottom: safeBottom } = usePanelSafeAreaInsets();
|
|
296
|
+
const [keyboardVisible, setKeyboardVisible] = useState(false);
|
|
261
297
|
const [text, setText] = useState("");
|
|
262
298
|
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
|
263
299
|
const [isSending, setIsSending] = useState(false);
|
|
300
|
+
const [sendError, setSendError] = useState<string | null>(null);
|
|
264
301
|
const inputRef = useRef<TextInput>(null);
|
|
265
302
|
|
|
303
|
+
const isConnecting = !visitorQueryParams;
|
|
266
304
|
const trimmed = text.trim();
|
|
267
|
-
const canSend = (trimmed.length > 0 || attachments.length > 0) && !disabled && !isSending;
|
|
305
|
+
const canSend = (trimmed.length > 0 || attachments.length > 0) && !disabled && !isSending && !isConnecting;
|
|
306
|
+
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
const show = Keyboard.addListener(
|
|
309
|
+
Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow",
|
|
310
|
+
() => setKeyboardVisible(true)
|
|
311
|
+
);
|
|
312
|
+
const hide = Keyboard.addListener(
|
|
313
|
+
Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide",
|
|
314
|
+
() => setKeyboardVisible(false)
|
|
315
|
+
);
|
|
316
|
+
return () => {
|
|
317
|
+
show.remove();
|
|
318
|
+
hide.remove();
|
|
319
|
+
};
|
|
320
|
+
}, []);
|
|
321
|
+
|
|
322
|
+
// Clear stale error when the user edits the message
|
|
323
|
+
useEffect(() => {
|
|
324
|
+
if (sendError && (text || attachments.length)) setSendError(null);
|
|
325
|
+
}, [text, attachments.length]);
|
|
268
326
|
|
|
269
327
|
const handlePick = useCallback((item: PickedAttachment) => {
|
|
270
328
|
setAttachments((prev) => {
|
|
271
329
|
if (prev.length >= MAX_ATTACHMENTS) return prev;
|
|
272
|
-
return [...prev, { id: newId(), uri: item.uri, name: item.name, mimeType: item.type, kind: item.kind }];
|
|
330
|
+
return [...prev, { id: newId(), uri: item.uri, name: item.name, mimeType: item.type, size: item.size, kind: item.kind }];
|
|
273
331
|
});
|
|
274
332
|
}, []);
|
|
275
333
|
|
|
@@ -279,26 +337,61 @@ export function MessageComposer({
|
|
|
279
337
|
|
|
280
338
|
const handleSend = useCallback(async () => {
|
|
281
339
|
if (!canSend) return;
|
|
340
|
+
|
|
282
341
|
setIsSending(true);
|
|
283
|
-
|
|
342
|
+
setSendError(null);
|
|
343
|
+
|
|
344
|
+
// Capture current values — text/attachments must NOT be cleared until send succeeds
|
|
284
345
|
const sendText = text.trim();
|
|
346
|
+
const snapshot = [...attachments];
|
|
347
|
+
|
|
285
348
|
try {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
)
|
|
289
|
-
|
|
349
|
+
let uploaded: UploadedAttachment[] = [];
|
|
350
|
+
|
|
351
|
+
if (snapshot.length > 0) {
|
|
352
|
+
// Upload all attachments — throws on the first failure, preserving draft
|
|
353
|
+
uploaded = await Promise.all(
|
|
354
|
+
snapshot.map((a) => uploadAttachment(a, visitorQueryParams))
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// onSend must throw on failure (ConversationScreen propagates throws from useSendMessage)
|
|
290
359
|
await onSend(sendText, uploaded);
|
|
360
|
+
|
|
361
|
+
// Only reached on confirmed success — safe to clear
|
|
291
362
|
setText("");
|
|
292
363
|
setAttachments([]);
|
|
293
|
-
|
|
294
|
-
|
|
364
|
+
setSendError(null);
|
|
365
|
+
} catch (e: any) {
|
|
366
|
+
// Draft preserved — text and attachments state untouched
|
|
367
|
+
setSendError(e?.message ?? "Failed to send. Please try again.");
|
|
295
368
|
} finally {
|
|
296
369
|
setIsSending(false);
|
|
297
370
|
}
|
|
298
371
|
}, [canSend, text, attachments, onSend, visitorQueryParams]);
|
|
299
372
|
|
|
300
373
|
return (
|
|
301
|
-
<View
|
|
374
|
+
<View
|
|
375
|
+
style={[
|
|
376
|
+
styles.container,
|
|
377
|
+
{
|
|
378
|
+
backgroundColor: t.colors.composerBackground,
|
|
379
|
+
borderTopColor: t.colors.composerBorder,
|
|
380
|
+
paddingBottom: 8 + (keyboardVisible ? 0 : safeBottom),
|
|
381
|
+
},
|
|
382
|
+
]}
|
|
383
|
+
>
|
|
384
|
+
{sendError ? (
|
|
385
|
+
<View style={[styles.errorBanner, { backgroundColor: t.colors.surface, borderColor: t.colors.border }]}>
|
|
386
|
+
<Text style={[styles.errorText, { color: t.colors.error ?? "#c0392b" }]} numberOfLines={2}>
|
|
387
|
+
{sendError}
|
|
388
|
+
</Text>
|
|
389
|
+
<TouchableOpacity onPress={() => setSendError(null)} accessibilityLabel="Dismiss error">
|
|
390
|
+
<CloseIcon size={14} color={t.colors.textMuted} />
|
|
391
|
+
</TouchableOpacity>
|
|
392
|
+
</View>
|
|
393
|
+
) : null}
|
|
394
|
+
|
|
302
395
|
<View style={[styles.inner, { borderColor: t.colors.composerBorder }]}>
|
|
303
396
|
<FilePreviews items={attachments} onRemove={handleRemove} borderColor={t.colors.border} />
|
|
304
397
|
|
|
@@ -307,11 +400,11 @@ export function MessageComposer({
|
|
|
307
400
|
style={[styles.input, { color: t.colors.inputText, fontSize: t.fontSizes.md }]}
|
|
308
401
|
value={text}
|
|
309
402
|
onChangeText={setText}
|
|
310
|
-
placeholder={placeholder}
|
|
403
|
+
placeholder={isConnecting ? "Connecting…" : placeholder}
|
|
311
404
|
placeholderTextColor={t.colors.textMuted}
|
|
312
405
|
multiline
|
|
313
406
|
maxLength={4000}
|
|
314
|
-
editable={!disabled}
|
|
407
|
+
editable={!disabled && !isConnecting}
|
|
315
408
|
returnKeyType="default"
|
|
316
409
|
blurOnSubmit={false}
|
|
317
410
|
/>
|
|
@@ -319,7 +412,7 @@ export function MessageComposer({
|
|
|
319
412
|
<View style={styles.toolbar}>
|
|
320
413
|
<AttachmentMenu
|
|
321
414
|
onPick={handlePick}
|
|
322
|
-
disabled={disabled || isSending}
|
|
415
|
+
disabled={disabled || isSending || isConnecting}
|
|
323
416
|
attachmentCount={attachments.length}
|
|
324
417
|
color={t.colors.textMuted}
|
|
325
418
|
/>
|
|
@@ -332,10 +425,12 @@ export function MessageComposer({
|
|
|
332
425
|
styles.sendBtn,
|
|
333
426
|
{ backgroundColor: canSend ? t.colors.sendButtonBackground : t.colors.border },
|
|
334
427
|
]}
|
|
335
|
-
accessibilityLabel="Send message"
|
|
428
|
+
accessibilityLabel={isConnecting ? "Connecting, please wait" : "Send message"}
|
|
336
429
|
>
|
|
337
430
|
{isSending ? (
|
|
338
431
|
<ActivityIndicator size="small" color={t.colors.sendButtonIcon} />
|
|
432
|
+
) : isConnecting ? (
|
|
433
|
+
<ActivityIndicator size="small" color={t.colors.textMuted} />
|
|
339
434
|
) : (
|
|
340
435
|
<SendPlaneIcon size={18} color={canSend ? t.colors.sendButtonIcon : t.colors.textMuted} />
|
|
341
436
|
)}
|
|
@@ -351,7 +446,6 @@ const styles = StyleSheet.create({
|
|
|
351
446
|
borderTopWidth: 1,
|
|
352
447
|
paddingHorizontal: 8,
|
|
353
448
|
paddingTop: 8,
|
|
354
|
-
paddingBottom: 8,
|
|
355
449
|
},
|
|
356
450
|
inner: {
|
|
357
451
|
borderWidth: 1,
|
|
@@ -360,6 +454,21 @@ const styles = StyleSheet.create({
|
|
|
360
454
|
paddingTop: 10,
|
|
361
455
|
paddingBottom: 6,
|
|
362
456
|
},
|
|
457
|
+
errorBanner: {
|
|
458
|
+
flexDirection: "row",
|
|
459
|
+
alignItems: "center",
|
|
460
|
+
justifyContent: "space-between",
|
|
461
|
+
borderWidth: 1,
|
|
462
|
+
borderRadius: 8,
|
|
463
|
+
paddingHorizontal: 12,
|
|
464
|
+
paddingVertical: 8,
|
|
465
|
+
marginBottom: 6,
|
|
466
|
+
gap: 8,
|
|
467
|
+
},
|
|
468
|
+
errorText: {
|
|
469
|
+
flex: 1,
|
|
470
|
+
fontSize: 13,
|
|
471
|
+
},
|
|
363
472
|
input: {
|
|
364
473
|
minHeight: 30,
|
|
365
474
|
maxHeight: 200,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Platform, StatusBar } from "react-native";
|
|
2
|
+
|
|
3
|
+
export type PanelSafeAreaInsets = {
|
|
4
|
+
top: number;
|
|
5
|
+
bottom: number;
|
|
6
|
+
left: number;
|
|
7
|
+
right: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function getFallbackInsets(): PanelSafeAreaInsets {
|
|
11
|
+
if (Platform.OS === "ios") {
|
|
12
|
+
// Typical notch / home-indicator insets when no SafeAreaProvider is mounted.
|
|
13
|
+
return { top: 47, bottom: 34, left: 0, right: 0 };
|
|
14
|
+
}
|
|
15
|
+
return { top: StatusBar.currentHeight ?? 0, bottom: 0, left: 0, right: 0 };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type UseSafeAreaInsets = () => PanelSafeAreaInsets;
|
|
19
|
+
|
|
20
|
+
let useSafeAreaInsetsFromContext: UseSafeAreaInsets | null = null;
|
|
21
|
+
try {
|
|
22
|
+
useSafeAreaInsetsFromContext =
|
|
23
|
+
require("react-native-safe-area-context").useSafeAreaInsets;
|
|
24
|
+
} catch {
|
|
25
|
+
useSafeAreaInsetsFromContext = null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Safe-area insets for panel UI. Uses react-native-safe-area-context when available. */
|
|
29
|
+
export function usePanelSafeAreaInsets(): PanelSafeAreaInsets {
|
|
30
|
+
if (useSafeAreaInsetsFromContext) {
|
|
31
|
+
return useSafeAreaInsetsFromContext();
|
|
32
|
+
}
|
|
33
|
+
return getFallbackInsets();
|
|
34
|
+
}
|