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
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
const [
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
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
|
-
|
|
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), []);
|
package/src/provider/types.ts
CHANGED
|
@@ -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
|
-
{
|
|
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
|
|
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 }]}>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
361
|
+
</View>
|
|
346
362
|
);
|
|
347
363
|
}
|
|
348
364
|
|