turbodesk-livechat-react-native 0.1.0-alpha.0
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 +30 -0
- package/README.md +91 -0
- package/dist/api/conversation-api.d.ts +16 -0
- package/dist/api/conversation-api.d.ts.map +1 -0
- package/dist/api/conversation-api.js +44 -0
- package/dist/api/conversation-api.js.map +1 -0
- package/dist/api/file-api.d.ts +5 -0
- package/dist/api/file-api.d.ts.map +1 -0
- package/dist/api/file-api.js +15 -0
- package/dist/api/file-api.js.map +1 -0
- package/dist/api/widget-api.d.ts +4 -0
- package/dist/api/widget-api.d.ts.map +1 -0
- package/dist/api/widget-api.js +15 -0
- package/dist/api/widget-api.js.map +1 -0
- package/dist/axios/axios.d.ts +32 -0
- package/dist/axios/axios.d.ts.map +1 -0
- package/dist/axios/axios.js +120 -0
- package/dist/axios/axios.js.map +1 -0
- package/dist/core/config.d.ts +17 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +42 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/http-client.d.ts +33 -0
- package/dist/core/http-client.d.ts.map +1 -0
- package/dist/core/http-client.js +104 -0
- package/dist/core/http-client.js.map +1 -0
- package/dist/core/identity.d.ts +7 -0
- package/dist/core/identity.d.ts.map +1 -0
- package/dist/core/identity.js +62 -0
- package/dist/core/identity.js.map +1 -0
- package/dist/core/visitor-params.d.ts +15 -0
- package/dist/core/visitor-params.d.ts.map +1 -0
- package/dist/core/visitor-params.js +45 -0
- package/dist/core/visitor-params.js.map +1 -0
- package/dist/hooks/use-conversations.d.ts +12 -0
- package/dist/hooks/use-conversations.d.ts.map +1 -0
- package/dist/hooks/use-conversations.js +177 -0
- package/dist/hooks/use-conversations.js.map +1 -0
- package/dist/hooks/use-live-chat.d.ts +30 -0
- package/dist/hooks/use-live-chat.d.ts.map +1 -0
- package/dist/hooks/use-live-chat.js +52 -0
- package/dist/hooks/use-live-chat.js.map +1 -0
- package/dist/hooks/use-messages.d.ts +11 -0
- package/dist/hooks/use-messages.d.ts.map +1 -0
- package/dist/hooks/use-messages.js +185 -0
- package/dist/hooks/use-messages.js.map +1 -0
- package/dist/hooks/use-send-message.d.ts +22 -0
- package/dist/hooks/use-send-message.d.ts.map +1 -0
- package/dist/hooks/use-send-message.js +125 -0
- package/dist/hooks/use-send-message.js.map +1 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +97 -0
- package/dist/index.js.map +1 -0
- package/dist/navigation/LiveChatPanel.d.ts +5 -0
- package/dist/navigation/LiveChatPanel.d.ts.map +1 -0
- package/dist/navigation/LiveChatPanel.js +81 -0
- package/dist/navigation/LiveChatPanel.js.map +1 -0
- package/dist/navigation/panel-router-context.d.ts +22 -0
- package/dist/navigation/panel-router-context.d.ts.map +1 -0
- package/dist/navigation/panel-router-context.js +42 -0
- package/dist/navigation/panel-router-context.js.map +1 -0
- package/dist/navigation/router-types.d.ts +2 -0
- package/dist/navigation/router-types.d.ts.map +1 -0
- package/dist/navigation/router-types.js +3 -0
- package/dist/navigation/router-types.js.map +1 -0
- package/dist/provider/LiveChatContext.d.ts +4 -0
- package/dist/provider/LiveChatContext.d.ts.map +1 -0
- package/dist/provider/LiveChatContext.js +35 -0
- package/dist/provider/LiveChatContext.js.map +1 -0
- package/dist/provider/LiveChatProvider.d.ts +3 -0
- package/dist/provider/LiveChatProvider.d.ts.map +1 -0
- package/dist/provider/LiveChatProvider.js +308 -0
- package/dist/provider/LiveChatProvider.js.map +1 -0
- package/dist/provider/types.d.ts +42 -0
- package/dist/provider/types.d.ts.map +1 -0
- package/dist/provider/types.js +3 -0
- package/dist/provider/types.js.map +1 -0
- package/dist/realtime/ws-client.d.ts +51 -0
- package/dist/realtime/ws-client.d.ts.map +1 -0
- package/dist/realtime/ws-client.js +322 -0
- package/dist/realtime/ws-client.js.map +1 -0
- package/dist/ui/components/AssigneeAvatar.d.ts +12 -0
- package/dist/ui/components/AssigneeAvatar.d.ts.map +1 -0
- package/dist/ui/components/AssigneeAvatar.js +58 -0
- package/dist/ui/components/AssigneeAvatar.js.map +1 -0
- package/dist/ui/components/Avatar.d.ts +10 -0
- package/dist/ui/components/Avatar.d.ts.map +1 -0
- package/dist/ui/components/Avatar.js +76 -0
- package/dist/ui/components/Avatar.js.map +1 -0
- package/dist/ui/components/ConversationHeader.d.ts +10 -0
- package/dist/ui/components/ConversationHeader.d.ts.map +1 -0
- package/dist/ui/components/ConversationHeader.js +90 -0
- package/dist/ui/components/ConversationHeader.js.map +1 -0
- package/dist/ui/components/ConversationListScreen.d.ts +9 -0
- package/dist/ui/components/ConversationListScreen.d.ts.map +1 -0
- package/dist/ui/components/ConversationListScreen.js +350 -0
- package/dist/ui/components/ConversationListScreen.js.map +1 -0
- package/dist/ui/components/ConversationScreen.d.ts +8 -0
- package/dist/ui/components/ConversationScreen.d.ts.map +1 -0
- package/dist/ui/components/ConversationScreen.js +235 -0
- package/dist/ui/components/ConversationScreen.js.map +1 -0
- package/dist/ui/components/HomeScreen.d.ts +6 -0
- package/dist/ui/components/HomeScreen.d.ts.map +1 -0
- package/dist/ui/components/HomeScreen.js +133 -0
- package/dist/ui/components/HomeScreen.js.map +1 -0
- package/dist/ui/components/LivechatMessageRenderer.d.ts +17 -0
- package/dist/ui/components/LivechatMessageRenderer.d.ts.map +1 -0
- package/dist/ui/components/LivechatMessageRenderer.js +122 -0
- package/dist/ui/components/LivechatMessageRenderer.js.map +1 -0
- package/dist/ui/components/LogMessage.d.ts +5 -0
- package/dist/ui/components/LogMessage.d.ts.map +1 -0
- package/dist/ui/components/LogMessage.js +83 -0
- package/dist/ui/components/LogMessage.js.map +1 -0
- package/dist/ui/components/MessageBubble.d.ts +15 -0
- package/dist/ui/components/MessageBubble.d.ts.map +1 -0
- package/dist/ui/components/MessageBubble.js +84 -0
- package/dist/ui/components/MessageBubble.js.map +1 -0
- package/dist/ui/components/MessageComposer.d.ts +31 -0
- package/dist/ui/components/MessageComposer.d.ts.map +1 -0
- package/dist/ui/components/MessageComposer.js +295 -0
- package/dist/ui/components/MessageComposer.js.map +1 -0
- package/dist/ui/components/WsStatusStrip.d.ts +2 -0
- package/dist/ui/components/WsStatusStrip.d.ts.map +1 -0
- package/dist/ui/components/WsStatusStrip.js +103 -0
- package/dist/ui/components/WsStatusStrip.js.map +1 -0
- package/dist/ui/icons.d.ts +22 -0
- package/dist/ui/icons.d.ts.map +1 -0
- package/dist/ui/icons.js +71 -0
- package/dist/ui/icons.js.map +1 -0
- package/dist/ui/theme.d.ts +72 -0
- package/dist/ui/theme.d.ts.map +1 -0
- package/dist/ui/theme.js +170 -0
- package/dist/ui/theme.js.map +1 -0
- package/docs/backend-contract.md +392 -0
- package/docs/migration-notes.md +32 -0
- package/package.json +60 -0
- package/src/api/conversation-api.ts +71 -0
- package/src/api/file-api.ts +14 -0
- package/src/api/widget-api.ts +12 -0
- package/src/axios/axios.ts +159 -0
- package/src/core/config.ts +54 -0
- package/src/core/http-client.ts +136 -0
- package/src/core/identity.ts +68 -0
- package/src/core/visitor-params.ts +48 -0
- package/src/hooks/use-conversations.ts +181 -0
- package/src/hooks/use-live-chat.ts +84 -0
- package/src/hooks/use-messages.ts +188 -0
- package/src/hooks/use-send-message.ts +159 -0
- package/src/index.ts +114 -0
- package/src/navigation/LiveChatPanel.tsx +118 -0
- package/src/navigation/panel-router-context.tsx +89 -0
- package/src/navigation/router-types.ts +1 -0
- package/src/provider/LiveChatContext.ts +33 -0
- package/src/provider/LiveChatProvider.tsx +380 -0
- package/src/provider/types.ts +57 -0
- package/src/realtime/ws-client.ts +369 -0
- package/src/types/react-native-svg.d.ts +10 -0
- package/src/ui/components/AssigneeAvatar.tsx +102 -0
- package/src/ui/components/Avatar.tsx +110 -0
- package/src/ui/components/ConversationHeader.tsx +202 -0
- package/src/ui/components/ConversationListScreen.tsx +454 -0
- package/src/ui/components/ConversationScreen.tsx +362 -0
- package/src/ui/components/HomeScreen.tsx +278 -0
- package/src/ui/components/LivechatMessageRenderer.tsx +268 -0
- package/src/ui/components/LogMessage.tsx +88 -0
- package/src/ui/components/MessageBubble.tsx +148 -0
- package/src/ui/components/MessageComposer.tsx +461 -0
- package/src/ui/components/WsStatusStrip.tsx +123 -0
- package/src/ui/icons.tsx +111 -0
- package/src/ui/theme.ts +237 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
let _apiBaseUrl = "";
|
|
2
|
+
let _getToken: (() => string | null | undefined) | null = null;
|
|
3
|
+
|
|
4
|
+
export function setApiBaseUrl(url: string) {
|
|
5
|
+
_apiBaseUrl = url.replace(/\/+$/, "");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getApiBaseUrl(): string {
|
|
9
|
+
return _apiBaseUrl;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function setTokenGetter(fn: () => string | null | undefined) {
|
|
13
|
+
_getToken = fn;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const joinUrl = (baseURL: string, path: string) => {
|
|
17
|
+
const base = baseURL.replace(/\/+$/, "");
|
|
18
|
+
const p = path.replace(/^\/+/, "");
|
|
19
|
+
return `${base}/${p}`;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type RequestOptions = {
|
|
23
|
+
headers?: Record<string, string>;
|
|
24
|
+
params?: Record<string, string | number | boolean | undefined | null>;
|
|
25
|
+
signal?: AbortSignal;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const appendSearchParams = (
|
|
29
|
+
absoluteUrl: string,
|
|
30
|
+
params?: RequestOptions["params"]
|
|
31
|
+
) => {
|
|
32
|
+
if (!params) return absoluteUrl;
|
|
33
|
+
const entries = Object.entries(params).filter(
|
|
34
|
+
([, v]) => v !== undefined && v !== null
|
|
35
|
+
);
|
|
36
|
+
if (entries.length === 0) return absoluteUrl;
|
|
37
|
+
const sep = absoluteUrl.includes("?") ? "&" : "?";
|
|
38
|
+
const qs = entries
|
|
39
|
+
.map(([k, v]) => encodeURIComponent(k) + "=" + encodeURIComponent(String(v)))
|
|
40
|
+
.join("&");
|
|
41
|
+
return absoluteUrl + sep + qs;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const parseBody = async (res: Response): Promise<unknown> => {
|
|
45
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
46
|
+
if (ct.includes("application/json")) {
|
|
47
|
+
const text = await res.text();
|
|
48
|
+
if (!text) return null;
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(text) as unknown;
|
|
51
|
+
} catch {
|
|
52
|
+
return text;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return res.text();
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type AuthRefreshFn = () => Promise<string | null | undefined>;
|
|
59
|
+
type OnTokenRefreshedFn = (token: string | null | undefined) => void;
|
|
60
|
+
|
|
61
|
+
let _authRefreshCallback: AuthRefreshFn | null = null;
|
|
62
|
+
let _onTokenRefreshed: OnTokenRefreshedFn | null = null;
|
|
63
|
+
|
|
64
|
+
export function registerAuthRefresh(
|
|
65
|
+
callback: AuthRefreshFn,
|
|
66
|
+
onTokenRefreshed: OnTokenRefreshedFn
|
|
67
|
+
) {
|
|
68
|
+
_authRefreshCallback = callback;
|
|
69
|
+
_onTokenRefreshed = onTokenRefreshed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function unregisterAuthRefresh() {
|
|
73
|
+
_authRefreshCallback = null;
|
|
74
|
+
_onTokenRefreshed = null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
78
|
+
|
|
79
|
+
async function request<T>(
|
|
80
|
+
method: HttpMethod,
|
|
81
|
+
path: string,
|
|
82
|
+
body?: unknown,
|
|
83
|
+
options?: RequestOptions,
|
|
84
|
+
isRetry = false
|
|
85
|
+
): Promise<{ data: T }> {
|
|
86
|
+
const baseURL = getApiBaseUrl();
|
|
87
|
+
const url = appendSearchParams(joinUrl(baseURL, path), options?.params);
|
|
88
|
+
const token = _getToken?.() ?? null;
|
|
89
|
+
|
|
90
|
+
const headers: Record<string, string> = {
|
|
91
|
+
Accept: "application/json",
|
|
92
|
+
...options?.headers,
|
|
93
|
+
};
|
|
94
|
+
if (token !== null) {
|
|
95
|
+
headers.Authorization = `Bearer ${token}`;
|
|
96
|
+
}
|
|
97
|
+
if (body !== undefined && method !== "GET" && method !== "DELETE") {
|
|
98
|
+
headers["Content-Type"] = "application/json";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let res: Response;
|
|
102
|
+
try {
|
|
103
|
+
res = await fetch(url, {
|
|
104
|
+
method,
|
|
105
|
+
headers,
|
|
106
|
+
signal: options?.signal,
|
|
107
|
+
body:
|
|
108
|
+
body !== undefined && method !== "GET" && method !== "DELETE"
|
|
109
|
+
? JSON.stringify(body)
|
|
110
|
+
: undefined,
|
|
111
|
+
});
|
|
112
|
+
} catch {
|
|
113
|
+
throw Object.assign(new Error("Network error"), {
|
|
114
|
+
response: { status: 0, data: null },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (res.status === 401 && !isRetry && _authRefreshCallback) {
|
|
119
|
+
const newToken = await _authRefreshCallback();
|
|
120
|
+
_onTokenRefreshed?.(newToken ?? null);
|
|
121
|
+
return request<T>(method, path, body, options, true);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (res.status === 401 || res.status === 403) {
|
|
125
|
+
throw Object.assign(new Error(res.status === 401 ? "Unauthorized" : "Forbidden"), {
|
|
126
|
+
response: { status: res.status, data: null },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const data = (await parseBody(res)) as T;
|
|
131
|
+
|
|
132
|
+
if (!res.ok) {
|
|
133
|
+
throw Object.assign(new Error(`HTTP ${res.status}`), {
|
|
134
|
+
response: { status: res.status, data },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { data };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export type ApiClient = {
|
|
142
|
+
get: <T>(path: string, options?: RequestOptions) => Promise<{ data: T }>;
|
|
143
|
+
post: <T>(path: string, body?: unknown, options?: RequestOptions) => Promise<{ data: T }>;
|
|
144
|
+
put: <T>(path: string, body?: unknown, options?: RequestOptions) => Promise<{ data: T }>;
|
|
145
|
+
patch: <T>(path: string, body?: unknown, options?: RequestOptions) => Promise<{ data: T }>;
|
|
146
|
+
delete: <T>(path: string, options?: RequestOptions) => Promise<{ data: T }>;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const client: ApiClient = {
|
|
150
|
+
get: (path, options) => request("GET", path, undefined, options),
|
|
151
|
+
post: (path, body, options) => request("POST", path, body, options),
|
|
152
|
+
put: (path, body, options) => request("PUT", path, body, options),
|
|
153
|
+
patch: (path, body, options) => request("PATCH", path, body, options),
|
|
154
|
+
delete: (path, options) => request("DELETE", path, undefined, options),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const getApiClient = (): ApiClient => client;
|
|
158
|
+
|
|
159
|
+
export default getApiClient;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { LiveChatProviderProps } from "../provider/types";
|
|
2
|
+
import { getOrCreateDeviceUserId } from "./identity";
|
|
3
|
+
|
|
4
|
+
export type RuntimeConfig = {
|
|
5
|
+
widgetId: string;
|
|
6
|
+
apiBaseUrl: string;
|
|
7
|
+
userId?: string;
|
|
8
|
+
userToken?: string;
|
|
9
|
+
authCallback?: () => Promise<string | null | undefined>;
|
|
10
|
+
visitorProfile?: { name?: string; email?: string; phone?: string };
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function normalizeBaseUrl(url: string): string {
|
|
14
|
+
return url.replace(/\/+$/, "");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function buildWsBaseUrl(apiBaseUrl: string): string {
|
|
18
|
+
return normalizeBaseUrl(apiBaseUrl)
|
|
19
|
+
.replace(/^https:\/\//i, "wss://")
|
|
20
|
+
.replace(/^http:\/\//i, "ws://");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function resolveConfig(
|
|
24
|
+
props: LiveChatProviderProps
|
|
25
|
+
): Promise<RuntimeConfig> {
|
|
26
|
+
const widgetId = props.widgetId?.trim();
|
|
27
|
+
if (!widgetId) throw new Error("[LiveChat] widgetId is required");
|
|
28
|
+
|
|
29
|
+
const base = normalizeBaseUrl(props.apiBaseUrl);
|
|
30
|
+
const token = props.userToken?.trim();
|
|
31
|
+
|
|
32
|
+
if (token) {
|
|
33
|
+
return {
|
|
34
|
+
widgetId,
|
|
35
|
+
apiBaseUrl: base,
|
|
36
|
+
userId: props.userId?.trim() ?? "",
|
|
37
|
+
userToken: token,
|
|
38
|
+
authCallback: props.authCallback,
|
|
39
|
+
visitorProfile: props.visitorProfile,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const userId =
|
|
44
|
+
props.userId?.trim() || (await getOrCreateDeviceUserId(widgetId));
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
widgetId,
|
|
48
|
+
apiBaseUrl: base,
|
|
49
|
+
userId,
|
|
50
|
+
userToken: undefined,
|
|
51
|
+
authCallback: props.authCallback,
|
|
52
|
+
visitorProfile: props.visitorProfile,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
export type RequestOptions = {
|
|
2
|
+
headers?: Record<string, string>;
|
|
3
|
+
params?: Record<string, string | number | boolean | undefined | null>;
|
|
4
|
+
signal?: AbortSignal;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
type AuthRefreshFn = () => Promise<string | null | undefined>;
|
|
8
|
+
type OnTokenRefreshedFn = (token: string | null | undefined) => void;
|
|
9
|
+
|
|
10
|
+
let _apiBaseUrl = "";
|
|
11
|
+
let _getToken: (() => string | null | undefined) | null = null;
|
|
12
|
+
let _authRefreshCallback: AuthRefreshFn | null = null;
|
|
13
|
+
let _onTokenRefreshed: OnTokenRefreshedFn | null = null;
|
|
14
|
+
|
|
15
|
+
export function configureHttpClient(opts: {
|
|
16
|
+
apiBaseUrl: string;
|
|
17
|
+
getToken: () => string | null | undefined;
|
|
18
|
+
}): void {
|
|
19
|
+
_apiBaseUrl = opts.apiBaseUrl.replace(/\/+$/, "");
|
|
20
|
+
_getToken = opts.getToken;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function registerAuthRefresh(
|
|
24
|
+
callback: AuthRefreshFn,
|
|
25
|
+
onTokenRefreshed: OnTokenRefreshedFn
|
|
26
|
+
): void {
|
|
27
|
+
_authRefreshCallback = callback;
|
|
28
|
+
_onTokenRefreshed = onTokenRefreshed;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function unregisterAuthRefresh(): void {
|
|
32
|
+
_authRefreshCallback = null;
|
|
33
|
+
_onTokenRefreshed = null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function joinUrl(base: string, p: string): string {
|
|
37
|
+
return base.replace(/\/+$/, "") + "/" + p.replace(/^\/+/, "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function appendSearchParams(
|
|
41
|
+
absoluteUrl: string,
|
|
42
|
+
params?: RequestOptions["params"]
|
|
43
|
+
): string {
|
|
44
|
+
if (!params) return absoluteUrl;
|
|
45
|
+
const entries = Object.entries(params).filter(
|
|
46
|
+
([, v]) => v !== undefined && v !== null
|
|
47
|
+
);
|
|
48
|
+
if (entries.length === 0) return absoluteUrl;
|
|
49
|
+
const sep = absoluteUrl.includes("?") ? "&" : "?";
|
|
50
|
+
const qs = entries
|
|
51
|
+
.map(([k, v]) => encodeURIComponent(k) + "=" + encodeURIComponent(String(v)))
|
|
52
|
+
.join("&");
|
|
53
|
+
return absoluteUrl + sep + qs;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function parseBody(res: Response): Promise<unknown> {
|
|
57
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
58
|
+
if (ct.includes("application/json")) {
|
|
59
|
+
const text = await res.text();
|
|
60
|
+
if (!text) return null;
|
|
61
|
+
try { return JSON.parse(text) as unknown; } catch { return text; }
|
|
62
|
+
}
|
|
63
|
+
return res.text();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
67
|
+
|
|
68
|
+
async function doRequest<T>(
|
|
69
|
+
method: HttpMethod,
|
|
70
|
+
path: string,
|
|
71
|
+
body: unknown,
|
|
72
|
+
options: RequestOptions | undefined,
|
|
73
|
+
isRetry: boolean
|
|
74
|
+
): Promise<{ data: T }> {
|
|
75
|
+
const baseURL = _apiBaseUrl;
|
|
76
|
+
const url = appendSearchParams(joinUrl(baseURL, path), options?.params);
|
|
77
|
+
const token = _getToken?.() ?? null;
|
|
78
|
+
|
|
79
|
+
const headers: Record<string, string> = {
|
|
80
|
+
Accept: "application/json",
|
|
81
|
+
...options?.headers,
|
|
82
|
+
};
|
|
83
|
+
if (token) headers.Authorization = "Bearer " + token;
|
|
84
|
+
if (body !== undefined && method !== "GET" && method !== "DELETE") {
|
|
85
|
+
headers["Content-Type"] = "application/json";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let res: Response;
|
|
89
|
+
try {
|
|
90
|
+
res = await fetch(url, {
|
|
91
|
+
method,
|
|
92
|
+
headers,
|
|
93
|
+
signal: options?.signal,
|
|
94
|
+
body: body !== undefined && method !== "GET" && method !== "DELETE"
|
|
95
|
+
? JSON.stringify(body) : undefined,
|
|
96
|
+
});
|
|
97
|
+
} catch {
|
|
98
|
+
const err = Object.assign(new Error("Network error"), { response: { status: 0, data: null } });
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (res.status === 401 && !isRetry && _authRefreshCallback) {
|
|
103
|
+
const newToken = await _authRefreshCallback();
|
|
104
|
+
_onTokenRefreshed?.(newToken ?? null);
|
|
105
|
+
return doRequest<T>(method, path, body, options, true);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (res.status === 401 || res.status === 403) {
|
|
109
|
+
const msg = res.status === 401 ? "Unauthorized" : "Forbidden";
|
|
110
|
+
throw Object.assign(new Error(msg), { response: { status: res.status, data: null } });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const data = (await parseBody(res)) as T;
|
|
114
|
+
|
|
115
|
+
if (!res.ok) {
|
|
116
|
+
throw Object.assign(new Error("HTTP " + res.status), { response: { status: res.status, data } });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { data };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type ApiClient = {
|
|
123
|
+
get: <T>(path: string, options?: RequestOptions) => Promise<{ data: T }>;
|
|
124
|
+
post: <T>(path: string, body?: unknown, options?: RequestOptions) => Promise<{ data: T }>;
|
|
125
|
+
put: <T>(path: string, body?: unknown, options?: RequestOptions) => Promise<{ data: T }>;
|
|
126
|
+
patch: <T>(path: string, body?: unknown, options?: RequestOptions) => Promise<{ data: T }>;
|
|
127
|
+
delete: <T>(path: string, options?: RequestOptions) => Promise<{ data: T }>;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const apiClient: ApiClient = {
|
|
131
|
+
get: (path, options) => doRequest("GET", path, undefined, options, false),
|
|
132
|
+
post: (path, body, options) => doRequest("POST", path, body, options, false),
|
|
133
|
+
put: (path, body, options) => doRequest("PUT", path, body, options, false),
|
|
134
|
+
patch: (path, body, options) => doRequest("PATCH", path, body, options, false),
|
|
135
|
+
delete: (path, options) => doRequest("DELETE", path, undefined, options, false),
|
|
136
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
let AsyncStorage: {
|
|
2
|
+
getItem(k: string): Promise<string | null>;
|
|
3
|
+
setItem(k: string, v: string): Promise<void>;
|
|
4
|
+
removeItem(k: string): Promise<void>;
|
|
5
|
+
} | null = null;
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
// Optional peer dep — import lazily so the package builds without it installed
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
10
|
+
AsyncStorage = require("@react-native-async-storage/async-storage").default;
|
|
11
|
+
} catch {
|
|
12
|
+
AsyncStorage = null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const storageKey = (widgetId: string) => `lcw_uid_${widgetId}`;
|
|
16
|
+
|
|
17
|
+
function randomUserId(): string {
|
|
18
|
+
if (
|
|
19
|
+
typeof crypto !== "undefined" &&
|
|
20
|
+
typeof crypto.getRandomValues === "function"
|
|
21
|
+
) {
|
|
22
|
+
const bytes = new Uint8Array(16);
|
|
23
|
+
crypto.getRandomValues(bytes);
|
|
24
|
+
return `fp_${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(
|
|
25
|
+
""
|
|
26
|
+
)}`;
|
|
27
|
+
}
|
|
28
|
+
// Fallback for environments without crypto
|
|
29
|
+
return `fp_${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Returns the stored anonymous visitor id for this widgetId, creating one if absent.
|
|
34
|
+
* Async because AsyncStorage is async on React Native.
|
|
35
|
+
*/
|
|
36
|
+
export async function getOrCreateDeviceUserId(
|
|
37
|
+
widgetId: string
|
|
38
|
+
): Promise<string> {
|
|
39
|
+
const key = storageKey(widgetId);
|
|
40
|
+
|
|
41
|
+
if (AsyncStorage) {
|
|
42
|
+
try {
|
|
43
|
+
const existing = await AsyncStorage.getItem(key);
|
|
44
|
+
if (existing) return existing;
|
|
45
|
+
} catch {
|
|
46
|
+
/* ignore storage errors */
|
|
47
|
+
}
|
|
48
|
+
const fresh = randomUserId();
|
|
49
|
+
try {
|
|
50
|
+
await AsyncStorage.setItem(key, fresh);
|
|
51
|
+
} catch {
|
|
52
|
+
/* ignore */
|
|
53
|
+
}
|
|
54
|
+
return fresh;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// No AsyncStorage — generate a non-persistent id (stays stable per process)
|
|
58
|
+
return randomUserId();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function clearStoredDeviceUserId(widgetId: string): Promise<void> {
|
|
62
|
+
if (!AsyncStorage) return;
|
|
63
|
+
try {
|
|
64
|
+
await AsyncStorage.removeItem(storageKey(widgetId));
|
|
65
|
+
} catch {
|
|
66
|
+
/* ignore */
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export type VisitorProfile = {
|
|
2
|
+
name?: string;
|
|
3
|
+
email?: string;
|
|
4
|
+
phone?: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type VisitorQueryParams = Record<string, string>;
|
|
8
|
+
|
|
9
|
+
export function buildVisitorQueryParams(opts: {
|
|
10
|
+
widgetId: string;
|
|
11
|
+
userId?: string;
|
|
12
|
+
userToken?: string;
|
|
13
|
+
visitorProfile?: VisitorProfile;
|
|
14
|
+
}): VisitorQueryParams | null {
|
|
15
|
+
const { widgetId, userId, userToken, visitorProfile } = opts;
|
|
16
|
+
if (!widgetId?.trim()) return null;
|
|
17
|
+
|
|
18
|
+
const params: VisitorQueryParams = { widgetId: widgetId.trim() };
|
|
19
|
+
const token = userToken?.trim();
|
|
20
|
+
|
|
21
|
+
if (token) {
|
|
22
|
+
params.userToken = token;
|
|
23
|
+
const uid = userId?.trim();
|
|
24
|
+
if (uid) params.userId = uid;
|
|
25
|
+
} else {
|
|
26
|
+
const uid = userId?.trim();
|
|
27
|
+
if (!uid) return null;
|
|
28
|
+
params.userId = uid;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (visitorProfile) {
|
|
32
|
+
const name = visitorProfile.name?.trim();
|
|
33
|
+
if (name) params.name = name.slice(0, 200);
|
|
34
|
+
const email = visitorProfile.email?.trim();
|
|
35
|
+
if (email) params.email = email;
|
|
36
|
+
const phone = visitorProfile.phone?.trim();
|
|
37
|
+
if (phone) params.phone = phone;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return params;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** user-presence helper — mirrors web's user-presence.ts */
|
|
44
|
+
export function getUserPresenceStatus(user: any): "active" | "away" | null {
|
|
45
|
+
const raw = user?.presence?.status;
|
|
46
|
+
if (raw === "active" || raw === "away") return raw;
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { conversationApi } from "../api/conversation-api";
|
|
3
|
+
import { useLiveChatContext } from "../provider/LiveChatContext";
|
|
4
|
+
|
|
5
|
+
const PAGE_SIZE = 20;
|
|
6
|
+
|
|
7
|
+
function pickConversations(raw: any): any[] {
|
|
8
|
+
if (!raw) return [];
|
|
9
|
+
const body = raw?.data !== undefined ? raw.data : raw;
|
|
10
|
+
const inner = body?.data !== undefined ? body.data : body;
|
|
11
|
+
if (Array.isArray(inner)) return inner;
|
|
12
|
+
if (Array.isArray(inner?.conversations)) return inner.conversations;
|
|
13
|
+
if (Array.isArray(body?.conversations)) return body.conversations;
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function pickPagination(raw: any): { hasNextPage?: boolean; nextPage?: number } {
|
|
18
|
+
const body = raw?.data !== undefined ? raw.data : raw;
|
|
19
|
+
const inner = body?.data !== undefined ? body.data : body;
|
|
20
|
+
return inner?.pagination ?? body?.pagination ?? {};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function patchConversation(prev: any[], incoming: any): any[] {
|
|
24
|
+
if (!incoming?._id) return prev;
|
|
25
|
+
const idx = prev.findIndex((c) => c?._id === incoming._id);
|
|
26
|
+
if (idx > -1) {
|
|
27
|
+
const next = [...prev];
|
|
28
|
+
next[idx] = { ...next[idx], ...incoming };
|
|
29
|
+
return next;
|
|
30
|
+
}
|
|
31
|
+
return [incoming, ...prev];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type UseConversationsResult = {
|
|
35
|
+
list: any[] | null;
|
|
36
|
+
loading: boolean;
|
|
37
|
+
loadingMore: boolean;
|
|
38
|
+
error: string | null;
|
|
39
|
+
hasNextPage: boolean;
|
|
40
|
+
conversationCount: number;
|
|
41
|
+
refresh: () => void;
|
|
42
|
+
loadMore: () => void;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function pickCount(raw: any): number {
|
|
46
|
+
const body = raw?.data ?? raw;
|
|
47
|
+
const inner = body?.data ?? body;
|
|
48
|
+
const v = inner?.count ?? inner?.total ?? 0;
|
|
49
|
+
return typeof v === "number" ? v : parseInt(v, 10) || 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function useConversations(): UseConversationsResult {
|
|
53
|
+
const { wsClient, visitorQueryParams, setTotalUnread } = useLiveChatContext();
|
|
54
|
+
|
|
55
|
+
const [list, setList] = useState<any[] | null>(null);
|
|
56
|
+
const [loading, setLoading] = useState(true);
|
|
57
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
58
|
+
const [error, setError] = useState<string | null>(null);
|
|
59
|
+
const [hasNextPage, setHasNextPage] = useState(false);
|
|
60
|
+
const [conversationCount, setConversationCount] = useState(0);
|
|
61
|
+
const [refreshKey, setRefreshKey] = useState(0);
|
|
62
|
+
|
|
63
|
+
const currentPageRef = useRef(0);
|
|
64
|
+
const inFlightRef = useRef(false);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!visitorQueryParams) {
|
|
68
|
+
setList(null);
|
|
69
|
+
setLoading(false);
|
|
70
|
+
setError(null);
|
|
71
|
+
setHasNextPage(false);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const ac = new AbortController();
|
|
75
|
+
setList(null);
|
|
76
|
+
setLoading(true);
|
|
77
|
+
setError(null);
|
|
78
|
+
setHasNextPage(false);
|
|
79
|
+
currentPageRef.current = 0;
|
|
80
|
+
|
|
81
|
+
(async () => {
|
|
82
|
+
try {
|
|
83
|
+
const [raw, countRaw] = await Promise.all([
|
|
84
|
+
conversationApi.getConversations({
|
|
85
|
+
params: { ...visitorQueryParams, page: 1, limit: PAGE_SIZE },
|
|
86
|
+
signal: ac.signal,
|
|
87
|
+
}),
|
|
88
|
+
conversationApi.getConversationCount(visitorQueryParams),
|
|
89
|
+
]);
|
|
90
|
+
if (ac.signal.aborted) return;
|
|
91
|
+
setList(pickConversations(raw));
|
|
92
|
+
const p = pickPagination(raw);
|
|
93
|
+
setHasNextPage(!!p.hasNextPage);
|
|
94
|
+
currentPageRef.current = 1;
|
|
95
|
+
setConversationCount(pickCount(countRaw));
|
|
96
|
+
} catch (e: any) {
|
|
97
|
+
if (ac.signal.aborted || e?.name === "AbortError") return;
|
|
98
|
+
setError("Could not load conversations. Pull to refresh.");
|
|
99
|
+
setList(null);
|
|
100
|
+
} finally {
|
|
101
|
+
if (!ac.signal.aborted) setLoading(false);
|
|
102
|
+
}
|
|
103
|
+
})();
|
|
104
|
+
|
|
105
|
+
return () => ac.abort();
|
|
106
|
+
}, [visitorQueryParams, refreshKey]);
|
|
107
|
+
|
|
108
|
+
const loadMore = useCallback(async () => {
|
|
109
|
+
if (inFlightRef.current || !hasNextPage || !visitorQueryParams) return;
|
|
110
|
+
inFlightRef.current = true;
|
|
111
|
+
setLoadingMore(true);
|
|
112
|
+
const nextPage = currentPageRef.current + 1;
|
|
113
|
+
try {
|
|
114
|
+
const raw = await conversationApi.getConversations({
|
|
115
|
+
params: { ...visitorQueryParams, page: nextPage, limit: PAGE_SIZE },
|
|
116
|
+
});
|
|
117
|
+
const rows = pickConversations(raw);
|
|
118
|
+
const p = pickPagination(raw);
|
|
119
|
+
setList((prev) => (prev ? [...prev, ...rows] : rows));
|
|
120
|
+
setHasNextPage(!!p.hasNextPage);
|
|
121
|
+
currentPageRef.current = nextPage;
|
|
122
|
+
} catch {
|
|
123
|
+
/* ignore load-more errors */
|
|
124
|
+
} finally {
|
|
125
|
+
inFlightRef.current = false;
|
|
126
|
+
setLoadingMore(false);
|
|
127
|
+
}
|
|
128
|
+
}, [hasNextPage, visitorQueryParams]);
|
|
129
|
+
|
|
130
|
+
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
|
|
131
|
+
|
|
132
|
+
// ── WS event handlers ──────────────────────────────────────────────────────
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (!wsClient) return;
|
|
135
|
+
|
|
136
|
+
const handleNewMessage = (payload: any) => {
|
|
137
|
+
const data = payload?.data ?? payload;
|
|
138
|
+
const conv = data?.conversation;
|
|
139
|
+
const msg = data?.message;
|
|
140
|
+
if (!conv?._id) return;
|
|
141
|
+
setList((prev) => {
|
|
142
|
+
if (!prev) return prev;
|
|
143
|
+
const idx = prev.findIndex((c) => c?._id === conv._id);
|
|
144
|
+
const updated = {
|
|
145
|
+
...(idx > -1 ? prev[idx] : conv),
|
|
146
|
+
latestMessageId: msg,
|
|
147
|
+
latestMessageAt: conv?.latestMessageAt,
|
|
148
|
+
livechat: {
|
|
149
|
+
visitorUnreadCount: conv?.livechat?.visitorUnreadCount,
|
|
150
|
+
...conv?.livechat,
|
|
151
|
+
},
|
|
152
|
+
hasUnreadMessages: (conv?.livechat?.visitorUnreadCount ?? 0) > 0,
|
|
153
|
+
userUnreadCount: conv?.userUnreadCount,
|
|
154
|
+
};
|
|
155
|
+
if (idx > -1) {
|
|
156
|
+
const next = [...prev];
|
|
157
|
+
next[idx] = updated;
|
|
158
|
+
return next;
|
|
159
|
+
}
|
|
160
|
+
return [updated, ...prev];
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const patchHandler = (payload: any) => {
|
|
165
|
+
const data = payload?.data ?? payload;
|
|
166
|
+
const incoming = data?.conversation;
|
|
167
|
+
if (!incoming?._id) return;
|
|
168
|
+
setList((prev) => (prev ? patchConversation(prev, incoming) : prev));
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const u1 = wsClient.subscribe("new_livechat_message", handleNewMessage);
|
|
172
|
+
const u2 = wsClient.subscribe("conversation_intervened", patchHandler);
|
|
173
|
+
const u3 = wsClient.subscribe("conversation_unassigned", patchHandler);
|
|
174
|
+
const u4 = wsClient.subscribe("conversation_transferred", patchHandler);
|
|
175
|
+
const u5 = wsClient.subscribe("conversation_resolved", patchHandler);
|
|
176
|
+
|
|
177
|
+
return () => { u1(); u2(); u3(); u4(); u5(); };
|
|
178
|
+
}, [wsClient]);
|
|
179
|
+
|
|
180
|
+
return { list, loading, loadingMore, error, hasNextPage, conversationCount, refresh, loadMore };
|
|
181
|
+
}
|