turbodesk-livechat-react-native 0.1.0-alpha.2 → 0.1.0-alpha.20

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 (55) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/api/conversation-api.d.ts +9 -0
  3. package/dist/api/conversation-api.d.ts.map +1 -1
  4. package/dist/api/conversation-api.js +11 -1
  5. package/dist/api/conversation-api.js.map +1 -1
  6. package/dist/hooks/use-send-message.d.ts +1 -0
  7. package/dist/hooks/use-send-message.d.ts.map +1 -1
  8. package/dist/hooks/use-send-message.js +20 -14
  9. package/dist/hooks/use-send-message.js.map +1 -1
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +4 -2
  13. package/dist/index.js.map +1 -1
  14. package/dist/navigation/LiveChatPanel.d.ts.map +1 -1
  15. package/dist/navigation/LiveChatPanel.js +12 -1
  16. package/dist/navigation/LiveChatPanel.js.map +1 -1
  17. package/dist/provider/LiveChatProvider.d.ts +1 -1
  18. package/dist/provider/LiveChatProvider.d.ts.map +1 -1
  19. package/dist/provider/LiveChatProvider.js +13 -2
  20. package/dist/provider/LiveChatProvider.js.map +1 -1
  21. package/dist/provider/types.d.ts +2 -0
  22. package/dist/provider/types.d.ts.map +1 -1
  23. package/dist/ui/components/ConversationHeader.d.ts.map +1 -1
  24. package/dist/ui/components/ConversationHeader.js +7 -4
  25. package/dist/ui/components/ConversationHeader.js.map +1 -1
  26. package/dist/ui/components/ConversationListScreen.d.ts.map +1 -1
  27. package/dist/ui/components/ConversationListScreen.js +11 -2
  28. package/dist/ui/components/ConversationListScreen.js.map +1 -1
  29. package/dist/ui/components/ConversationScreen.d.ts.map +1 -1
  30. package/dist/ui/components/ConversationScreen.js +11 -2
  31. package/dist/ui/components/ConversationScreen.js.map +1 -1
  32. package/dist/ui/components/HomeScreen.d.ts.map +1 -1
  33. package/dist/ui/components/HomeScreen.js +14 -4
  34. package/dist/ui/components/HomeScreen.js.map +1 -1
  35. package/dist/ui/components/MessageComposer.d.ts +1 -0
  36. package/dist/ui/components/MessageComposer.d.ts.map +1 -1
  37. package/dist/ui/components/MessageComposer.js +129 -51
  38. package/dist/ui/components/MessageComposer.js.map +1 -1
  39. package/dist/ui/safe-area.d.ts +9 -0
  40. package/dist/ui/safe-area.d.ts.map +1 -0
  41. package/dist/ui/safe-area.js +28 -0
  42. package/dist/ui/safe-area.js.map +1 -0
  43. package/package.json +9 -5
  44. package/src/api/conversation-api.ts +11 -0
  45. package/src/hooks/use-send-message.ts +169 -159
  46. package/src/index.ts +3 -0
  47. package/src/navigation/LiveChatPanel.tsx +15 -3
  48. package/src/provider/LiveChatProvider.tsx +16 -4
  49. package/src/provider/types.ts +2 -0
  50. package/src/ui/components/ConversationHeader.tsx +7 -6
  51. package/src/ui/components/ConversationListScreen.tsx +18 -5
  52. package/src/ui/components/ConversationScreen.tsx +23 -7
  53. package/src/ui/components/HomeScreen.tsx +21 -6
  54. package/src/ui/components/MessageComposer.tsx +166 -57
  55. package/src/ui/safe-area.ts +34 -0
@@ -1,159 +1,169 @@
1
- import { useCallback, useState } from "react";
2
- import { conversationApi } from "../api/conversation-api";
3
- import { useLiveChatContext } from "../provider/LiveChatContext";
4
-
5
- function pickConversationId(raw: any): string | null {
6
- if (!raw) return null;
7
- const a = raw?.data !== undefined ? raw.data : raw;
8
- const b = a?.data !== undefined ? a.data : a;
9
- const id = b?.conversationId ?? a?.conversationId;
10
- return id != null && id !== "" ? String(id) : null;
11
- }
12
-
13
- export type AttachmentMeta = {
14
- fileId?: string;
15
- fileUrl: string;
16
- fileName: string;
17
- fileExtension?: string;
18
- type: "image" | "video" | "document" | "audio";
19
- caption?: string;
20
- };
21
-
22
- export type InteractiveReplyMeta = {
23
- type: "button_reply" | "list_reply";
24
- id: string;
25
- title: string;
26
- };
27
-
28
- export type UseSendMessageResult = {
29
- sending: boolean;
30
- error: string | null;
31
- sendText: (
32
- text: string,
33
- conversationId?: string | null
34
- ) => Promise<string | null>;
35
- sendAttachment: (
36
- attachment: AttachmentMeta,
37
- conversationId?: string | null
38
- ) => Promise<string | null>;
39
- sendInteractive: (
40
- reply: InteractiveReplyMeta,
41
- conversationId?: string | null
42
- ) => Promise<string | null>;
43
- };
44
-
45
- export function useSendMessage(): UseSendMessageResult {
46
- const { visitorQueryParams } = useLiveChatContext();
47
- const [sending, setSending] = useState(false);
48
- const [error, setError] = useState<string | null>(null);
49
-
50
- const sendText = useCallback(
51
- async (text: string, conversationId?: string | null): Promise<string | null> => {
52
- const trimmed = text.trim();
53
- if (!trimmed || !visitorQueryParams) return null;
54
- const localMessageId =
55
- typeof crypto !== "undefined" && crypto.randomUUID
56
- ? crypto.randomUUID()
57
- : "lc-" + Date.now();
58
- const body: any = {
59
- localMessageId,
60
- message: { type: "text", text: { body: trimmed } },
61
- };
62
- if (conversationId) body.conversationId = conversationId;
63
-
64
- setSending(true);
65
- setError(null);
66
- try {
67
- const raw = await conversationApi.sendVisitorMessage(body, {
68
- params: visitorQueryParams,
69
- });
70
- return pickConversationId(raw);
71
- } catch (e: any) {
72
- setError(e?.message ?? "Failed to send message");
73
- return null;
74
- } finally {
75
- setSending(false);
76
- }
77
- },
78
- [visitorQueryParams]
79
- );
80
-
81
- const sendAttachment = useCallback(
82
- async (
83
- attachment: AttachmentMeta,
84
- conversationId?: string | null
85
- ): Promise<string | null> => {
86
- if (!visitorQueryParams) return null;
87
- const localMessageId =
88
- typeof crypto !== "undefined" && crypto.randomUUID
89
- ? crypto.randomUUID()
90
- : "lc-" + Date.now();
91
- const typeObj: Record<string, any> = { link: attachment.fileUrl };
92
- if (attachment.caption) typeObj.caption = attachment.caption;
93
- if (attachment.type === "document") typeObj.filename = attachment.fileName;
94
-
95
- const body: any = {
96
- localMessageId,
97
- fileId: attachment.fileId,
98
- fileUrl: attachment.fileUrl,
99
- fileName: attachment.fileName,
100
- fileExtension: attachment.fileExtension,
101
- message: { type: attachment.type, [attachment.type]: typeObj },
102
- };
103
- if (conversationId) body.conversationId = conversationId;
104
-
105
- setSending(true);
106
- setError(null);
107
- try {
108
- const raw = await conversationApi.sendVisitorMessage(body, {
109
- params: visitorQueryParams,
110
- });
111
- return pickConversationId(raw);
112
- } catch (e: any) {
113
- setError(e?.message ?? "Failed to send attachment");
114
- return null;
115
- } finally {
116
- setSending(false);
117
- }
118
- },
119
- [visitorQueryParams]
120
- );
121
-
122
- const sendInteractive = useCallback(
123
- async (reply: InteractiveReplyMeta, conversationId?: string | null): Promise<string | null> => {
124
- if (!visitorQueryParams) return null;
125
- const localMessageId =
126
- typeof crypto !== "undefined" && crypto.randomUUID
127
- ? crypto.randomUUID()
128
- : "lc-" + Date.now();
129
- const body: any = {
130
- localMessageId,
131
- message: {
132
- type: "interactive",
133
- interactive: {
134
- type: reply.type,
135
- [reply.type]: { id: reply.id, title: reply.title },
136
- },
137
- },
138
- };
139
- if (conversationId) body.conversationId = conversationId;
140
-
141
- setSending(true);
142
- setError(null);
143
- try {
144
- const raw = await conversationApi.sendVisitorMessage(body, {
145
- params: visitorQueryParams,
146
- });
147
- return pickConversationId(raw);
148
- } catch (e: any) {
149
- setError(e?.message ?? "Failed to send reply");
150
- return null;
151
- } finally {
152
- setSending(false);
153
- }
154
- },
155
- [visitorQueryParams]
156
- );
157
-
158
- return { sending, error, sendText, sendAttachment, sendInteractive };
159
- }
1
+ import { useCallback, useState } from "react";
2
+ import { conversationApi } from "../api/conversation-api";
3
+ import { useLiveChatContext } from "../provider/LiveChatContext";
4
+
5
+ function pickConversationId(raw: any): string | null {
6
+ if (!raw) return null;
7
+ const a = raw?.data !== undefined ? raw.data : raw;
8
+ const b = a?.data !== undefined ? a.data : a;
9
+ const id = b?.conversationId ?? a?.conversationId;
10
+ return id != null && id !== "" ? String(id) : null;
11
+ }
12
+
13
+ export type AttachmentMeta = {
14
+ fileId?: string;
15
+ fileUrl: string;
16
+ fileName: string;
17
+ fileExtension?: string;
18
+ type: "image" | "video" | "document" | "audio";
19
+ caption?: string;
20
+ };
21
+
22
+ export type InteractiveReplyMeta = {
23
+ type: "button_reply" | "list_reply";
24
+ id: string;
25
+ title: string;
26
+ };
27
+
28
+ export type UseSendMessageResult = {
29
+ sending: boolean;
30
+ error: string | null;
31
+ clearError: () => void;
32
+ sendText: (
33
+ text: string,
34
+ conversationId?: string | null
35
+ ) => Promise<string | null>;
36
+ sendAttachment: (
37
+ attachment: AttachmentMeta,
38
+ conversationId?: string | null
39
+ ) => Promise<string | null>;
40
+ sendInteractive: (
41
+ reply: InteractiveReplyMeta,
42
+ conversationId?: string | null
43
+ ) => Promise<string | null>;
44
+ };
45
+
46
+ export function useSendMessage(): UseSendMessageResult {
47
+ const { visitorQueryParams } = useLiveChatContext();
48
+ const [sending, setSending] = useState(false);
49
+ const [error, setError] = useState<string | null>(null);
50
+
51
+ const clearError = useCallback(() => setError(null), []);
52
+
53
+ const sendText = useCallback(
54
+ async (text: string, conversationId?: string | null): Promise<string | null> => {
55
+ const trimmed = text.trim();
56
+ if (!trimmed) throw new Error("Message cannot be empty");
57
+ if (!visitorQueryParams) throw new Error("Not connected yet. Please wait and try again.");
58
+
59
+ const localMessageId =
60
+ typeof crypto !== "undefined" && crypto.randomUUID
61
+ ? crypto.randomUUID()
62
+ : "lc-" + Date.now();
63
+ const body: any = {
64
+ localMessageId,
65
+ message: { type: "text", text: { body: trimmed } },
66
+ };
67
+ if (conversationId) body.conversationId = conversationId;
68
+
69
+ setSending(true);
70
+ setError(null);
71
+ try {
72
+ const raw = await conversationApi.sendVisitorMessage(body, {
73
+ params: visitorQueryParams,
74
+ });
75
+ return pickConversationId(raw);
76
+ } catch (e: any) {
77
+ const msg = e?.response?.data?.message ?? e?.message ?? "Failed to send message";
78
+ setError(msg);
79
+ throw new Error(msg);
80
+ } finally {
81
+ setSending(false);
82
+ }
83
+ },
84
+ [visitorQueryParams]
85
+ );
86
+
87
+ const sendAttachment = useCallback(
88
+ async (
89
+ attachment: AttachmentMeta,
90
+ conversationId?: string | null
91
+ ): Promise<string | null> => {
92
+ if (!visitorQueryParams) throw new Error("Not connected yet. Please wait and try again.");
93
+
94
+ const localMessageId =
95
+ typeof crypto !== "undefined" && crypto.randomUUID
96
+ ? crypto.randomUUID()
97
+ : "lc-" + Date.now();
98
+ const typeObj: Record<string, any> = { link: attachment.fileUrl };
99
+ if (attachment.caption) typeObj.caption = attachment.caption;
100
+ if (attachment.type === "document") typeObj.filename = attachment.fileName;
101
+
102
+ const body: any = {
103
+ localMessageId,
104
+ fileId: attachment.fileId,
105
+ fileUrl: attachment.fileUrl,
106
+ fileName: attachment.fileName,
107
+ fileExtension: attachment.fileExtension,
108
+ message: { type: attachment.type, [attachment.type]: typeObj },
109
+ };
110
+ if (conversationId) body.conversationId = conversationId;
111
+
112
+ setSending(true);
113
+ setError(null);
114
+ try {
115
+ const raw = await conversationApi.sendVisitorMessage(body, {
116
+ params: visitorQueryParams,
117
+ });
118
+ return pickConversationId(raw);
119
+ } catch (e: any) {
120
+ const msg = e?.response?.data?.message ?? e?.message ?? "Failed to send attachment";
121
+ setError(msg);
122
+ throw new Error(msg);
123
+ } finally {
124
+ setSending(false);
125
+ }
126
+ },
127
+ [visitorQueryParams]
128
+ );
129
+
130
+ const sendInteractive = useCallback(
131
+ async (reply: InteractiveReplyMeta, conversationId?: string | null): Promise<string | null> => {
132
+ if (!visitorQueryParams) throw new Error("Not connected yet. Please wait and try again.");
133
+
134
+ const localMessageId =
135
+ typeof crypto !== "undefined" && crypto.randomUUID
136
+ ? crypto.randomUUID()
137
+ : "lc-" + Date.now();
138
+ const body: any = {
139
+ localMessageId,
140
+ message: {
141
+ type: "interactive",
142
+ interactive: {
143
+ type: reply.type,
144
+ [reply.type]: { id: reply.id, title: reply.title },
145
+ },
146
+ },
147
+ };
148
+ if (conversationId) body.conversationId = conversationId;
149
+
150
+ setSending(true);
151
+ setError(null);
152
+ try {
153
+ const raw = await conversationApi.sendVisitorMessage(body, {
154
+ params: visitorQueryParams,
155
+ });
156
+ return pickConversationId(raw);
157
+ } catch (e: any) {
158
+ const msg = e?.response?.data?.message ?? e?.message ?? "Failed to send reply";
159
+ setError(msg);
160
+ throw new Error(msg);
161
+ } finally {
162
+ setSending(false);
163
+ }
164
+ },
165
+ [visitorQueryParams]
166
+ );
167
+
168
+ return { sending, error, clearError, sendText, sendAttachment, sendInteractive };
169
+ }
package/src/index.ts CHANGED
@@ -75,6 +75,9 @@ export { PanelRouterProvider, usePanelRouter } from "./navigation/panel-router-c
75
75
  export type { PanelRouterContextValue, WidgetPanelView } from "./navigation/panel-router-context";
76
76
 
77
77
  // ─── Theme ─────────────────────────────────────────────────────────────────────
78
+ export { usePanelSafeAreaInsets } from "./ui/safe-area";
79
+ export type { PanelSafeAreaInsets } from "./ui/safe-area";
80
+
78
81
  export {
79
82
  defaultTheme,
80
83
  mergeTheme,
@@ -1,5 +1,5 @@
1
- import React, { useEffect, useRef } from "react";
2
- import { View, StyleSheet, BackHandler, PanResponder, Animated } from "react-native";
1
+ import React, { useEffect, useRef, type ComponentType, type ReactNode } from "react";
2
+ import { View, StyleSheet, BackHandler, PanResponder } from "react-native";
3
3
  import { PanelRouterProvider, usePanelRouter } from "./panel-router-context";
4
4
  import { HomeScreen } from "../ui/components/HomeScreen";
5
5
  import { ConversationListScreen } from "../ui/components/ConversationListScreen";
@@ -12,6 +12,13 @@ export type LiveChatPanelProps = {
12
12
  onClose?: () => void;
13
13
  };
14
14
 
15
+ let PanelSafeAreaProvider: ComponentType<{ children: ReactNode }> | null = null;
16
+ try {
17
+ PanelSafeAreaProvider = require("react-native-safe-area-context").SafeAreaProvider;
18
+ } catch {
19
+ PanelSafeAreaProvider = null;
20
+ }
21
+
15
22
  // Mirrors web PanelShell — neutral chrome for chat screens, brand bg for home
16
23
  function usePanelBackground(view: string): string {
17
24
  const { widgetConfig } = useLiveChatContext();
@@ -104,13 +111,18 @@ function PanelContent({ onClose }: LiveChatPanelProps) {
104
111
  }
105
112
 
106
113
  export function LiveChatPanel({ onClose }: LiveChatPanelProps) {
107
- return (
114
+ const panel = (
108
115
  <PanelRouterProvider>
109
116
  <View style={styles.flex}>
110
117
  <PanelContent onClose={onClose} />
111
118
  </View>
112
119
  </PanelRouterProvider>
113
120
  );
121
+
122
+ if (PanelSafeAreaProvider) {
123
+ return <PanelSafeAreaProvider>{panel}</PanelSafeAreaProvider>;
124
+ }
125
+ return panel;
114
126
  }
115
127
 
116
128
  const styles = StyleSheet.create({
@@ -5,7 +5,7 @@ import React, {
5
5
  useRef,
6
6
  useState,
7
7
  } from "react";
8
- import { useColorScheme, unstable_batchedUpdates } from "react-native";
8
+ import { useColorScheme, unstable_batchedUpdates, Platform } from "react-native";
9
9
  import { LiveChatContext } from "./LiveChatContext";
10
10
  import type { EmbedConfigLoadState, LiveChatProviderProps } from "./types";
11
11
  import {
@@ -20,13 +20,12 @@ import {
20
20
  } from "../core/identity";
21
21
  import { buildVisitorQueryParams } from "../core/visitor-params";
22
22
  import { widgetApi } from "../api/widget-api";
23
- import { conversationApi } from "../api/conversation-api";
23
+ import { conversationApi, deviceTokenApi } from "../api/conversation-api";
24
24
  import { WsClient } from "../realtime/ws-client";
25
25
  import type { WsConnectionState } from "../realtime/ws-client";
26
26
  import {
27
27
  buildThemeFromBrand,
28
28
  defaultTheme,
29
- mergeTheme,
30
29
  } from "../ui/theme";
31
30
 
32
31
  function getWidgetName(config: any): string {
@@ -62,6 +61,8 @@ export function LiveChatProvider({
62
61
  appearance: appearanceProp,
63
62
  useBrandThemingForChat: useBrandThemingProp = false,
64
63
  theme: themeProp,
64
+ fcmToken,
65
+ fcmTokenPlatform,
65
66
  children,
66
67
  }: LiveChatProviderProps) {
67
68
  const systemColorScheme = useColorScheme();
@@ -209,6 +210,13 @@ export function LiveChatProvider({
209
210
  };
210
211
  }, [visitorQueryParams]);
211
212
 
213
+ // ── FCM token registration ─────────────────────────────────────────────
214
+ useEffect(() => {
215
+ if (!fcmToken || !visitorQueryParams) return;
216
+ const platform = fcmTokenPlatform ?? (Platform.OS === "ios" ? "ios" : "android");
217
+ deviceTokenApi.register({ token: fcmToken, platform }, { params: visitorQueryParams }).catch(() => {});
218
+ }, [fcmToken, fcmTokenPlatform, visitorQueryParams]);
219
+
212
220
  // ── WsClient lifecycle ─────────────────────────────────────────────────
213
221
  useEffect(() => {
214
222
  if (!cfg.widgetId || !userId || !userIdReady) return;
@@ -286,6 +294,10 @@ export function LiveChatProvider({
286
294
  );
287
295
 
288
296
  const logout = useCallback(() => {
297
+ // Unregister FCM token before clearing identity
298
+ if (fcmToken && visitorQueryParams) {
299
+ deviceTokenApi.unregister({ token: fcmToken }, { params: visitorQueryParams }).catch(() => {});
300
+ }
289
301
  setUserTokenState(undefined);
290
302
  clearStoredDeviceUserId(cfg.widgetId).then(() =>
291
303
  getOrCreateDeviceUserId(cfg.widgetId).then((id) => {
@@ -293,7 +305,7 @@ export function LiveChatProvider({
293
305
  setUserIdReady(true);
294
306
  })
295
307
  );
296
- }, [cfg.widgetId]);
308
+ }, [cfg.widgetId, fcmToken, visitorQueryParams]);
297
309
 
298
310
  const open = useCallback(() => setIsOpen(true), []);
299
311
  const close = useCallback(() => setIsOpen(false), []);
@@ -14,6 +14,8 @@ export type LiveChatProviderProps = {
14
14
  appearance?: AppearanceMode;
15
15
  useBrandThemingForChat?: boolean;
16
16
  theme?: Partial<ThemeConfig>;
17
+ fcmToken?: string;
18
+ fcmTokenPlatform?: "ios" | "android";
17
19
  children: React.ReactNode;
18
20
  };
19
21
 
@@ -1,13 +1,12 @@
1
1
  import React from "react";
2
2
  import {
3
- Platform,
4
- StatusBar,
5
3
  StyleSheet,
6
4
  Text,
7
5
  TouchableOpacity,
8
6
  View,
9
7
  } from "react-native";
10
8
  import { useLiveChatContext } from "../../provider/LiveChatContext";
9
+ import { usePanelSafeAreaInsets } from "../safe-area";
11
10
  import { getUserPresenceStatus } from "../../core/visitor-params";
12
11
  import { AssigneeAvatar } from "./AssigneeAvatar";
13
12
  import { ChevronLeftIcon, CloseIcon } from "../icons";
@@ -43,6 +42,7 @@ export function ConversationHeader({
43
42
  onClose,
44
43
  }: ConversationHeaderProps) {
45
44
  const { theme: t, widgetName, widgetConfig } = useLiveChatContext();
45
+ const { top: safeTop } = usePanelSafeAreaInsets();
46
46
  const headerBg = t.colors.headerBackground;
47
47
  const headerTxt = t.colors.headerText;
48
48
 
@@ -92,7 +92,11 @@ export function ConversationHeader({
92
92
  <View
93
93
  style={[
94
94
  styles.container,
95
- { backgroundColor: headerBg, borderBottomColor: t.colors.border },
95
+ {
96
+ backgroundColor: headerBg,
97
+ borderBottomColor: t.colors.border,
98
+ paddingTop: safeTop + 10,
99
+ },
96
100
  ]}
97
101
  >
98
102
  {onBack ? (
@@ -175,13 +179,10 @@ export function ConversationHeader({
175
179
  );
176
180
  }
177
181
 
178
- const PT = Platform.OS === "ios" ? (StatusBar.currentHeight ?? 0) : 0;
179
-
180
182
  const styles = StyleSheet.create({
181
183
  container: {
182
184
  flexDirection: "row",
183
185
  alignItems: "center",
184
- paddingTop: PT + 10,
185
186
  paddingBottom: 10,
186
187
  paddingHorizontal: 8,
187
188
  borderBottomWidth: StyleSheet.hairlineWidth,
@@ -13,6 +13,7 @@ import { AssigneeAvatar } from "./AssigneeAvatar";
13
13
  import type { AssigneeType } from "./AssigneeAvatar";
14
14
  import { SendPlaneIcon, ChevronLeftIcon, CloseIcon } from "../icons";
15
15
  import { POWERED_BY_TEXT, getBrandColor, contrastingTextOnBrand } from "../theme";
16
+ import { usePanelSafeAreaInsets } from "../safe-area";
16
17
 
17
18
  // ── format date — mirrors format-list-message-date.ts ────────────────────────
18
19
 
@@ -164,6 +165,7 @@ export function ConversationListScreen({
164
165
  onClose,
165
166
  }: ConversationListScreenProps) {
166
167
  const { theme: t, visitorQueryParams, wsClient, setTotalUnread, widgetConfig } = useLiveChatContext();
168
+ const { top: safeTop, bottom: safeBottom } = usePanelSafeAreaInsets();
167
169
  const settings = widgetConfig?.widgetSettings ?? {};
168
170
  const brandColor = getBrandColor(settings);
169
171
  const contrastText = contrastingTextOnBrand(brandColor);
@@ -332,7 +334,16 @@ export function ConversationListScreen({
332
334
  return (
333
335
  <View style={[styles.flex, { backgroundColor: t.colors.background }]}>
334
336
  {/* header */}
335
- <View style={[styles.header, { backgroundColor: t.colors.headerBackground, borderBottomColor: t.colors.border }]}>
337
+ <View
338
+ style={[
339
+ styles.header,
340
+ {
341
+ backgroundColor: t.colors.headerBackground,
342
+ borderBottomColor: t.colors.border,
343
+ paddingTop: safeTop + 10,
344
+ },
345
+ ]}
346
+ >
336
347
  {onBack ? (
337
348
  <TouchableOpacity onPress={onBack} style={styles.headerBtn} accessibilityLabel="Back">
338
349
  <ChevronLeftIcon size={22} color={t.colors.headerText} />
@@ -378,13 +389,13 @@ export function ConversationListScreen({
378
389
  ListFooterComponent={loadingMore ? <ActivityIndicator color={t.colors.brand} style={{ margin: 16 }} /> : null}
379
390
  onEndReached={hasNextPage ? loadMore : undefined}
380
391
  onEndReachedThreshold={0.3}
381
- contentContainerStyle={{ paddingBottom: 80 }}
392
+ contentContainerStyle={{ paddingBottom: 80 + safeBottom }}
382
393
  />
383
394
  )}
384
395
 
385
396
  {/* send new message button — mirrors web's primary CTA */}
386
397
  {onNewConversation ? (
387
- <View style={styles.fabContainer}>
398
+ <View style={[styles.fabContainer, { bottom: 28 + safeBottom }]}>
388
399
  <TouchableOpacity
389
400
  onPress={onNewConversation}
390
401
  activeOpacity={0.85}
@@ -397,7 +408,9 @@ export function ConversationListScreen({
397
408
  </View>
398
409
  ) : null}
399
410
 
400
- <Text style={[styles.poweredBy, { color: t.colors.textMuted }]}>{POWERED_BY_TEXT}</Text>
411
+ <Text style={[styles.poweredBy, { color: t.colors.textMuted, paddingBottom: 8 + safeBottom }]}>
412
+ {POWERED_BY_TEXT}
413
+ </Text>
401
414
  </View>
402
415
  );
403
416
  }
@@ -408,7 +421,7 @@ const styles = StyleSheet.create({
408
421
  header: {
409
422
  flexDirection: "row",
410
423
  alignItems: "center",
411
- paddingVertical: 10,
424
+ paddingBottom: 10,
412
425
  paddingHorizontal: 4,
413
426
  borderBottomWidth: StyleSheet.hairlineWidth,
414
427
  },
@@ -2,7 +2,8 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
2
2
  import {
3
3
  ActivityIndicator,
4
4
  FlatList,
5
- KeyboardAvoidingView,
5
+ Keyboard,
6
+ KeyboardEvent,
6
7
  Platform,
7
8
  StyleSheet,
8
9
  Text,
@@ -204,12 +205,14 @@ export function ConversationScreen({
204
205
  }
205
206
  }, [renderList.length]);
206
207
 
208
+ // Throws on failure so MessageComposer can preserve the draft and show the error.
207
209
  const handleSend = useCallback(async (text: string, uploadedAttachments?: UploadedAttachment[]) => {
208
210
  if (uploadedAttachments?.length) {
209
211
  let activeId: string | null | undefined = conversationId;
210
212
  for (let i = 0; i < uploadedAttachments.length; i++) {
211
213
  const att = uploadedAttachments[i];
212
214
  const caption = i === 0 && text ? text : undefined;
215
+ // sendAttachment throws on failure — let it propagate
213
216
  const returnedId = await sendAttachment(
214
217
  { fileId: att.fileId, fileUrl: att.fileUrl, fileName: att.fileName, fileExtension: att.fileExtension, type: att.type, caption },
215
218
  activeId === "new" ? null : activeId
@@ -221,9 +224,10 @@ export function ConversationScreen({
221
224
  }
222
225
  return;
223
226
  }
227
+ // sendText throws on failure — let it propagate
224
228
  const returnedId = await sendText(text, isNewConversation ? null : conversationId);
225
229
  if (isNewConversation && returnedId) onConversationCreated?.(returnedId);
226
- }, [conversationId, sendText, sendAttachment, onConversationCreated]);
230
+ }, [conversationId, isNewConversation, sendText, sendAttachment, onConversationCreated]);
227
231
 
228
232
  const renderItem = useCallback(({ item, index }: { item: any; index: number }) => {
229
233
  if (item._isDateSep) {
@@ -255,11 +259,23 @@ export function ConversationScreen({
255
259
  );
256
260
  }, [t, widgetName, handleSend]);
257
261
 
262
+ const [keyboardHeight, setKeyboardHeight] = useState(0);
263
+
264
+ useEffect(() => {
265
+ const show = Keyboard.addListener(
266
+ Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow",
267
+ (e: KeyboardEvent) => setKeyboardHeight(e.endCoordinates.height)
268
+ );
269
+ const hide = Keyboard.addListener(
270
+ Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide",
271
+ () => setKeyboardHeight(0)
272
+ );
273
+ return () => { show.remove(); hide.remove(); };
274
+ }, []);
275
+
258
276
  return (
259
- <KeyboardAvoidingView
260
- style={[styles.flex, { backgroundColor: t.colors.background }]}
261
- behavior={Platform.OS === "ios" ? "padding" : "height"}
262
- keyboardVerticalOffset={0}
277
+ <View
278
+ style={[styles.flex, { backgroundColor: t.colors.background, paddingBottom: keyboardHeight }]}
263
279
  >
264
280
  <ConversationHeader
265
281
  conversation={isNewConversation ? null : conversation}
@@ -342,7 +358,7 @@ export function ConversationScreen({
342
358
  <Text style={[styles.poweredBy, { color: t.colors.textMuted }]}>
343
359
  {POWERED_BY_TEXT}
344
360
  </Text>
345
- </KeyboardAvoidingView>
361
+ </View>
346
362
  );
347
363
  }
348
364