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.
Files changed (171) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +91 -0
  3. package/dist/api/conversation-api.d.ts +16 -0
  4. package/dist/api/conversation-api.d.ts.map +1 -0
  5. package/dist/api/conversation-api.js +44 -0
  6. package/dist/api/conversation-api.js.map +1 -0
  7. package/dist/api/file-api.d.ts +5 -0
  8. package/dist/api/file-api.d.ts.map +1 -0
  9. package/dist/api/file-api.js +15 -0
  10. package/dist/api/file-api.js.map +1 -0
  11. package/dist/api/widget-api.d.ts +4 -0
  12. package/dist/api/widget-api.d.ts.map +1 -0
  13. package/dist/api/widget-api.js +15 -0
  14. package/dist/api/widget-api.js.map +1 -0
  15. package/dist/axios/axios.d.ts +32 -0
  16. package/dist/axios/axios.d.ts.map +1 -0
  17. package/dist/axios/axios.js +120 -0
  18. package/dist/axios/axios.js.map +1 -0
  19. package/dist/core/config.d.ts +17 -0
  20. package/dist/core/config.d.ts.map +1 -0
  21. package/dist/core/config.js +42 -0
  22. package/dist/core/config.js.map +1 -0
  23. package/dist/core/http-client.d.ts +33 -0
  24. package/dist/core/http-client.d.ts.map +1 -0
  25. package/dist/core/http-client.js +104 -0
  26. package/dist/core/http-client.js.map +1 -0
  27. package/dist/core/identity.d.ts +7 -0
  28. package/dist/core/identity.d.ts.map +1 -0
  29. package/dist/core/identity.js +62 -0
  30. package/dist/core/identity.js.map +1 -0
  31. package/dist/core/visitor-params.d.ts +15 -0
  32. package/dist/core/visitor-params.d.ts.map +1 -0
  33. package/dist/core/visitor-params.js +45 -0
  34. package/dist/core/visitor-params.js.map +1 -0
  35. package/dist/hooks/use-conversations.d.ts +12 -0
  36. package/dist/hooks/use-conversations.d.ts.map +1 -0
  37. package/dist/hooks/use-conversations.js +177 -0
  38. package/dist/hooks/use-conversations.js.map +1 -0
  39. package/dist/hooks/use-live-chat.d.ts +30 -0
  40. package/dist/hooks/use-live-chat.d.ts.map +1 -0
  41. package/dist/hooks/use-live-chat.js +52 -0
  42. package/dist/hooks/use-live-chat.js.map +1 -0
  43. package/dist/hooks/use-messages.d.ts +11 -0
  44. package/dist/hooks/use-messages.d.ts.map +1 -0
  45. package/dist/hooks/use-messages.js +185 -0
  46. package/dist/hooks/use-messages.js.map +1 -0
  47. package/dist/hooks/use-send-message.d.ts +22 -0
  48. package/dist/hooks/use-send-message.d.ts.map +1 -0
  49. package/dist/hooks/use-send-message.js +125 -0
  50. package/dist/hooks/use-send-message.js.map +1 -0
  51. package/dist/index.d.ts +49 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +97 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/navigation/LiveChatPanel.d.ts +5 -0
  56. package/dist/navigation/LiveChatPanel.d.ts.map +1 -0
  57. package/dist/navigation/LiveChatPanel.js +81 -0
  58. package/dist/navigation/LiveChatPanel.js.map +1 -0
  59. package/dist/navigation/panel-router-context.d.ts +22 -0
  60. package/dist/navigation/panel-router-context.d.ts.map +1 -0
  61. package/dist/navigation/panel-router-context.js +42 -0
  62. package/dist/navigation/panel-router-context.js.map +1 -0
  63. package/dist/navigation/router-types.d.ts +2 -0
  64. package/dist/navigation/router-types.d.ts.map +1 -0
  65. package/dist/navigation/router-types.js +3 -0
  66. package/dist/navigation/router-types.js.map +1 -0
  67. package/dist/provider/LiveChatContext.d.ts +4 -0
  68. package/dist/provider/LiveChatContext.d.ts.map +1 -0
  69. package/dist/provider/LiveChatContext.js +35 -0
  70. package/dist/provider/LiveChatContext.js.map +1 -0
  71. package/dist/provider/LiveChatProvider.d.ts +3 -0
  72. package/dist/provider/LiveChatProvider.d.ts.map +1 -0
  73. package/dist/provider/LiveChatProvider.js +308 -0
  74. package/dist/provider/LiveChatProvider.js.map +1 -0
  75. package/dist/provider/types.d.ts +42 -0
  76. package/dist/provider/types.d.ts.map +1 -0
  77. package/dist/provider/types.js +3 -0
  78. package/dist/provider/types.js.map +1 -0
  79. package/dist/realtime/ws-client.d.ts +51 -0
  80. package/dist/realtime/ws-client.d.ts.map +1 -0
  81. package/dist/realtime/ws-client.js +322 -0
  82. package/dist/realtime/ws-client.js.map +1 -0
  83. package/dist/ui/components/AssigneeAvatar.d.ts +12 -0
  84. package/dist/ui/components/AssigneeAvatar.d.ts.map +1 -0
  85. package/dist/ui/components/AssigneeAvatar.js +58 -0
  86. package/dist/ui/components/AssigneeAvatar.js.map +1 -0
  87. package/dist/ui/components/Avatar.d.ts +10 -0
  88. package/dist/ui/components/Avatar.d.ts.map +1 -0
  89. package/dist/ui/components/Avatar.js +76 -0
  90. package/dist/ui/components/Avatar.js.map +1 -0
  91. package/dist/ui/components/ConversationHeader.d.ts +10 -0
  92. package/dist/ui/components/ConversationHeader.d.ts.map +1 -0
  93. package/dist/ui/components/ConversationHeader.js +90 -0
  94. package/dist/ui/components/ConversationHeader.js.map +1 -0
  95. package/dist/ui/components/ConversationListScreen.d.ts +9 -0
  96. package/dist/ui/components/ConversationListScreen.d.ts.map +1 -0
  97. package/dist/ui/components/ConversationListScreen.js +350 -0
  98. package/dist/ui/components/ConversationListScreen.js.map +1 -0
  99. package/dist/ui/components/ConversationScreen.d.ts +8 -0
  100. package/dist/ui/components/ConversationScreen.d.ts.map +1 -0
  101. package/dist/ui/components/ConversationScreen.js +235 -0
  102. package/dist/ui/components/ConversationScreen.js.map +1 -0
  103. package/dist/ui/components/HomeScreen.d.ts +6 -0
  104. package/dist/ui/components/HomeScreen.d.ts.map +1 -0
  105. package/dist/ui/components/HomeScreen.js +133 -0
  106. package/dist/ui/components/HomeScreen.js.map +1 -0
  107. package/dist/ui/components/LivechatMessageRenderer.d.ts +17 -0
  108. package/dist/ui/components/LivechatMessageRenderer.d.ts.map +1 -0
  109. package/dist/ui/components/LivechatMessageRenderer.js +122 -0
  110. package/dist/ui/components/LivechatMessageRenderer.js.map +1 -0
  111. package/dist/ui/components/LogMessage.d.ts +5 -0
  112. package/dist/ui/components/LogMessage.d.ts.map +1 -0
  113. package/dist/ui/components/LogMessage.js +83 -0
  114. package/dist/ui/components/LogMessage.js.map +1 -0
  115. package/dist/ui/components/MessageBubble.d.ts +15 -0
  116. package/dist/ui/components/MessageBubble.d.ts.map +1 -0
  117. package/dist/ui/components/MessageBubble.js +84 -0
  118. package/dist/ui/components/MessageBubble.js.map +1 -0
  119. package/dist/ui/components/MessageComposer.d.ts +31 -0
  120. package/dist/ui/components/MessageComposer.d.ts.map +1 -0
  121. package/dist/ui/components/MessageComposer.js +295 -0
  122. package/dist/ui/components/MessageComposer.js.map +1 -0
  123. package/dist/ui/components/WsStatusStrip.d.ts +2 -0
  124. package/dist/ui/components/WsStatusStrip.d.ts.map +1 -0
  125. package/dist/ui/components/WsStatusStrip.js +103 -0
  126. package/dist/ui/components/WsStatusStrip.js.map +1 -0
  127. package/dist/ui/icons.d.ts +22 -0
  128. package/dist/ui/icons.d.ts.map +1 -0
  129. package/dist/ui/icons.js +71 -0
  130. package/dist/ui/icons.js.map +1 -0
  131. package/dist/ui/theme.d.ts +72 -0
  132. package/dist/ui/theme.d.ts.map +1 -0
  133. package/dist/ui/theme.js +170 -0
  134. package/dist/ui/theme.js.map +1 -0
  135. package/docs/backend-contract.md +392 -0
  136. package/docs/migration-notes.md +32 -0
  137. package/package.json +60 -0
  138. package/src/api/conversation-api.ts +71 -0
  139. package/src/api/file-api.ts +14 -0
  140. package/src/api/widget-api.ts +12 -0
  141. package/src/axios/axios.ts +159 -0
  142. package/src/core/config.ts +54 -0
  143. package/src/core/http-client.ts +136 -0
  144. package/src/core/identity.ts +68 -0
  145. package/src/core/visitor-params.ts +48 -0
  146. package/src/hooks/use-conversations.ts +181 -0
  147. package/src/hooks/use-live-chat.ts +84 -0
  148. package/src/hooks/use-messages.ts +188 -0
  149. package/src/hooks/use-send-message.ts +159 -0
  150. package/src/index.ts +114 -0
  151. package/src/navigation/LiveChatPanel.tsx +118 -0
  152. package/src/navigation/panel-router-context.tsx +89 -0
  153. package/src/navigation/router-types.ts +1 -0
  154. package/src/provider/LiveChatContext.ts +33 -0
  155. package/src/provider/LiveChatProvider.tsx +380 -0
  156. package/src/provider/types.ts +57 -0
  157. package/src/realtime/ws-client.ts +369 -0
  158. package/src/types/react-native-svg.d.ts +10 -0
  159. package/src/ui/components/AssigneeAvatar.tsx +102 -0
  160. package/src/ui/components/Avatar.tsx +110 -0
  161. package/src/ui/components/ConversationHeader.tsx +202 -0
  162. package/src/ui/components/ConversationListScreen.tsx +454 -0
  163. package/src/ui/components/ConversationScreen.tsx +362 -0
  164. package/src/ui/components/HomeScreen.tsx +278 -0
  165. package/src/ui/components/LivechatMessageRenderer.tsx +268 -0
  166. package/src/ui/components/LogMessage.tsx +88 -0
  167. package/src/ui/components/MessageBubble.tsx +148 -0
  168. package/src/ui/components/MessageComposer.tsx +461 -0
  169. package/src/ui/components/WsStatusStrip.tsx +123 -0
  170. package/src/ui/icons.tsx +111 -0
  171. package/src/ui/theme.ts +237 -0
@@ -0,0 +1,461 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from "react";
2
+ import {
3
+ ActivityIndicator,
4
+ Alert,
5
+ Image,
6
+ ScrollView,
7
+ StyleSheet,
8
+ Text,
9
+ TextInput,
10
+ TouchableOpacity,
11
+ View,
12
+ } from "react-native";
13
+ import { useLiveChatContext } from "../../provider/LiveChatContext";
14
+ import { fileApi } from "../../api/file-api";
15
+ import { SendPlaneIcon, PaperclipIcon, CloseIcon } from "../icons";
16
+
17
+ // ── types ─────────────────────────────────────────────────────────────────────
18
+
19
+ export type PickedAttachmentKind = "image" | "video" | "audio" | "document";
20
+
21
+ export type PickedAttachment = {
22
+ uri: string;
23
+ name: string;
24
+ type: string; // mime type
25
+ size?: number;
26
+ kind: PickedAttachmentKind;
27
+ };
28
+
29
+ export type UploadedAttachment = {
30
+ id: string;
31
+ fileId: string;
32
+ fileUrl: string;
33
+ fileName: string;
34
+ fileExtension: string;
35
+ fileType: string;
36
+ type: PickedAttachmentKind;
37
+ };
38
+
39
+ export type PendingAttachment = {
40
+ id: string;
41
+ uri: string;
42
+ name: string;
43
+ mimeType: string;
44
+ kind: PickedAttachmentKind;
45
+ };
46
+
47
+ export type MessageComposerProps = {
48
+ onSend: (text: string, uploadedAttachments?: UploadedAttachment[]) => void | Promise<void>;
49
+ disabled?: boolean;
50
+ placeholder?: string;
51
+ };
52
+
53
+ const MAX_ATTACHMENTS = 5;
54
+
55
+ function newId() {
56
+ return typeof crypto !== "undefined" && crypto.randomUUID
57
+ ? crypto.randomUUID()
58
+ : `a-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
59
+ }
60
+
61
+ // ── attachment picker ─────────────────────────────────────────────────────────
62
+
63
+ type AttachmentMenuProps = {
64
+ onPick: (attachment: PickedAttachment) => void;
65
+ disabled?: boolean;
66
+ attachmentCount: number;
67
+ color: string;
68
+ };
69
+
70
+ function AttachmentMenu({ onPick, disabled, attachmentCount, color }: AttachmentMenuProps) {
71
+ const { theme: t } = useLiveChatContext();
72
+ const [open, setOpen] = useState(false);
73
+ const atLimit = attachmentCount >= MAX_ATTACHMENTS;
74
+ const triggerDisabled = disabled || atLimit;
75
+
76
+ const handleKind = useCallback(async (kind: PickedAttachmentKind) => {
77
+ setOpen(false);
78
+ if (triggerDisabled) return;
79
+
80
+ try {
81
+ let picked: PickedAttachment | null = null;
82
+
83
+ if (kind === "image") {
84
+ // Try react-native-image-picker
85
+ const ImagePicker = require("react-native-image-picker");
86
+ await new Promise<void>((resolve) => {
87
+ ImagePicker.launchImageLibrary({ mediaType: "photo", quality: 0.8 }, (res: any) => {
88
+ if (res.didCancel || res.errorCode) { resolve(); return; }
89
+ const asset = res.assets?.[0];
90
+ if (!asset) { resolve(); return; }
91
+ picked = {
92
+ uri: asset.uri,
93
+ name: asset.fileName ?? "image.jpg",
94
+ type: asset.type ?? "image/jpeg",
95
+ size: asset.fileSize,
96
+ kind: "image",
97
+ };
98
+ resolve();
99
+ });
100
+ });
101
+ } else if (kind === "video") {
102
+ const ImagePicker = require("react-native-image-picker");
103
+ await new Promise<void>((resolve) => {
104
+ ImagePicker.launchImageLibrary({ mediaType: "video" }, (res: any) => {
105
+ if (res.didCancel || res.errorCode) { resolve(); return; }
106
+ const asset = res.assets?.[0];
107
+ if (!asset) { resolve(); return; }
108
+ picked = {
109
+ uri: asset.uri,
110
+ name: asset.fileName ?? "video.mp4",
111
+ type: asset.type ?? "video/mp4",
112
+ size: asset.fileSize,
113
+ kind: "video",
114
+ };
115
+ resolve();
116
+ });
117
+ });
118
+ } else {
119
+ // audio / document — use react-native-document-picker
120
+ const DocumentPicker = require("react-native-document-picker");
121
+ const types = kind === "audio"
122
+ ? [DocumentPicker.types.audio]
123
+ : [DocumentPicker.types.pdf, DocumentPicker.types.plainText, DocumentPicker.types.doc, DocumentPicker.types.docx];
124
+ const res = await DocumentPicker.pick({ type: types });
125
+ const file = Array.isArray(res) ? res[0] : res;
126
+ if (file) {
127
+ picked = {
128
+ uri: file.uri,
129
+ name: file.name ?? "file",
130
+ type: file.type ?? "application/octet-stream",
131
+ size: file.size,
132
+ kind,
133
+ };
134
+ }
135
+ }
136
+
137
+ if (picked) onPick(picked);
138
+ } catch (e: any) {
139
+ if (e?.code !== "DOCUMENT_PICKER_CANCELED") {
140
+ Alert.alert("Error", "Could not pick file.");
141
+ }
142
+ }
143
+ }, [triggerDisabled, onPick]);
144
+
145
+ return (
146
+ <View style={styles.attachMenuRoot}>
147
+ <TouchableOpacity
148
+ onPress={() => !triggerDisabled && setOpen((o) => !o)}
149
+ disabled={triggerDisabled}
150
+ style={[styles.iconBtn, triggerDisabled && styles.disabled]}
151
+ accessibilityLabel="Attach file"
152
+ >
153
+ <PaperclipIcon size={20} color={color} />
154
+ </TouchableOpacity>
155
+
156
+ {open ? (
157
+ <View style={[styles.attachPopover, { backgroundColor: t.colors.surface, borderColor: t.colors.border }]}>
158
+ {(["image", "video", "audio", "document"] as PickedAttachmentKind[]).map((kind) => (
159
+ <TouchableOpacity
160
+ key={kind}
161
+ onPress={() => void handleKind(kind)}
162
+ style={styles.attachMenuItem}
163
+ >
164
+ <Text style={[styles.attachMenuLabel, { color: t.colors.text }]}>
165
+ {kind.charAt(0).toUpperCase() + kind.slice(1)}
166
+ </Text>
167
+ </TouchableOpacity>
168
+ ))}
169
+ <TouchableOpacity onPress={() => setOpen(false)} style={[styles.attachMenuClose, { borderTopColor: t.colors.border }]}>
170
+ <CloseIcon size={14} color={t.colors.textMuted} />
171
+ </TouchableOpacity>
172
+ </View>
173
+ ) : null}
174
+ </View>
175
+ );
176
+ }
177
+
178
+ // ── file previews ─────────────────────────────────────────────────────────────
179
+
180
+ function FilePreviews({
181
+ items,
182
+ onRemove,
183
+ borderColor,
184
+ }: {
185
+ items: PendingAttachment[];
186
+ onRemove: (id: string) => void;
187
+ borderColor: string;
188
+ }) {
189
+ const { theme: t } = useLiveChatContext();
190
+ if (!items.length) return null;
191
+ return (
192
+ <ScrollView
193
+ horizontal
194
+ showsHorizontalScrollIndicator={false}
195
+ style={styles.previewScroll}
196
+ contentContainerStyle={styles.previewContent}
197
+ >
198
+ {items.map((item) => {
199
+ const isImage = item.kind === "image";
200
+ return (
201
+ <View key={item.id} style={styles.previewItem}>
202
+ <View style={[styles.previewThumb, { borderColor }]}>
203
+ {isImage ? (
204
+ <Image source={{ uri: item.uri }} style={styles.previewImage} resizeMode="cover" />
205
+ ) : (
206
+ <View style={[styles.previewFileIcon, { backgroundColor: t.colors.bubbleIn }]}>
207
+ <Text style={styles.previewFileIconText}>📄</Text>
208
+ </View>
209
+ )}
210
+ </View>
211
+ <TouchableOpacity
212
+ onPress={() => onRemove(item.id)}
213
+ style={[styles.previewRemoveBtn, { backgroundColor: t.colors.surface, borderColor: t.colors.border }]}
214
+ accessibilityLabel={`Remove ${item.name}`}
215
+ >
216
+ <CloseIcon size={10} color={borderColor} />
217
+ </TouchableOpacity>
218
+ </View>
219
+ );
220
+ })}
221
+ </ScrollView>
222
+ );
223
+ }
224
+
225
+ // ── upload helper ─────────────────────────────────────────────────────────────
226
+
227
+ async function uploadAttachment(
228
+ pending: PendingAttachment,
229
+ visitorQueryParams: Record<string, string> | null
230
+ ): Promise<UploadedAttachment | null> {
231
+ try {
232
+ const ext = pending.name.split(".").pop() ?? "";
233
+ const presigned = await fileApi.getFileUrl(
234
+ {
235
+ operation: "putObject",
236
+ name: pending.name,
237
+ type: pending.mimeType,
238
+ extension: ext,
239
+ fileMode: "app",
240
+ },
241
+ visitorQueryParams ? { params: visitorQueryParams } : undefined
242
+ ) as any;
243
+
244
+ await fetch(presigned?.data?.signedUrl, {
245
+ method: "PUT",
246
+ headers: { "Content-Type": pending.mimeType },
247
+ body: { uri: pending.uri, type: pending.mimeType, name: pending.name } as any,
248
+ });
249
+
250
+ const uploaded = presigned?.data?.file;
251
+ return {
252
+ id: pending.id,
253
+ fileId: uploaded._id,
254
+ fileUrl: uploaded.url,
255
+ fileName: uploaded.name,
256
+ fileExtension: uploaded.extension,
257
+ fileType: pending.mimeType,
258
+ type: pending.kind,
259
+ };
260
+ } catch {
261
+ return null;
262
+ }
263
+ }
264
+
265
+ // ── main composer ─────────────────────────────────────────────────────────────
266
+
267
+ export function MessageComposer({
268
+ onSend,
269
+ disabled = false,
270
+ placeholder = "Message…",
271
+ }: MessageComposerProps) {
272
+ const { theme: t, visitorQueryParams } = useLiveChatContext();
273
+ const [text, setText] = useState("");
274
+ const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
275
+ const [isSending, setIsSending] = useState(false);
276
+ const inputRef = useRef<TextInput>(null);
277
+
278
+ const trimmed = text.trim();
279
+ const canSend = (trimmed.length > 0 || attachments.length > 0) && !disabled && !isSending;
280
+
281
+ const handlePick = useCallback((item: PickedAttachment) => {
282
+ setAttachments((prev) => {
283
+ if (prev.length >= MAX_ATTACHMENTS) return prev;
284
+ return [...prev, { id: newId(), uri: item.uri, name: item.name, mimeType: item.type, kind: item.kind }];
285
+ });
286
+ }, []);
287
+
288
+ const handleRemove = useCallback((id: string) => {
289
+ setAttachments((prev) => prev.filter((a) => a.id !== id));
290
+ }, []);
291
+
292
+ const handleSend = useCallback(async () => {
293
+ if (!canSend) return;
294
+ setIsSending(true);
295
+ const snapshot = [...attachments];
296
+ const sendText = text.trim();
297
+ try {
298
+ const uploadResults = await Promise.all(
299
+ snapshot.map((a) => uploadAttachment(a, visitorQueryParams))
300
+ );
301
+ const uploaded = uploadResults.filter((r): r is UploadedAttachment => r !== null);
302
+ await onSend(sendText, uploaded);
303
+ setText("");
304
+ setAttachments([]);
305
+ } catch {
306
+ /* ignore */
307
+ } finally {
308
+ setIsSending(false);
309
+ }
310
+ }, [canSend, text, attachments, onSend, visitorQueryParams]);
311
+
312
+ return (
313
+ <View style={[styles.container, { backgroundColor: t.colors.composerBackground, borderTopColor: t.colors.composerBorder }]}>
314
+ <View style={[styles.inner, { borderColor: t.colors.composerBorder }]}>
315
+ <FilePreviews items={attachments} onRemove={handleRemove} borderColor={t.colors.border} />
316
+
317
+ <TextInput
318
+ ref={inputRef}
319
+ style={[styles.input, { color: t.colors.inputText, fontSize: t.fontSizes.md }]}
320
+ value={text}
321
+ onChangeText={setText}
322
+ placeholder={placeholder}
323
+ placeholderTextColor={t.colors.textMuted}
324
+ multiline
325
+ maxLength={4000}
326
+ editable={!disabled}
327
+ returnKeyType="default"
328
+ blurOnSubmit={false}
329
+ />
330
+
331
+ <View style={styles.toolbar}>
332
+ <AttachmentMenu
333
+ onPick={handlePick}
334
+ disabled={disabled || isSending}
335
+ attachmentCount={attachments.length}
336
+ color={t.colors.textMuted}
337
+ />
338
+
339
+ <TouchableOpacity
340
+ onPress={() => void handleSend()}
341
+ disabled={!canSend}
342
+ activeOpacity={0.75}
343
+ style={[
344
+ styles.sendBtn,
345
+ { backgroundColor: canSend ? t.colors.sendButtonBackground : t.colors.border },
346
+ ]}
347
+ accessibilityLabel="Send message"
348
+ >
349
+ {isSending ? (
350
+ <ActivityIndicator size="small" color={t.colors.sendButtonIcon} />
351
+ ) : (
352
+ <SendPlaneIcon size={18} color={canSend ? t.colors.sendButtonIcon : t.colors.textMuted} />
353
+ )}
354
+ </TouchableOpacity>
355
+ </View>
356
+ </View>
357
+ </View>
358
+ );
359
+ }
360
+
361
+ const styles = StyleSheet.create({
362
+ container: {
363
+ borderTopWidth: 1,
364
+ paddingHorizontal: 8,
365
+ paddingTop: 8,
366
+ paddingBottom: 8,
367
+ },
368
+ inner: {
369
+ borderWidth: 1,
370
+ borderRadius: 12,
371
+ paddingHorizontal: 12,
372
+ paddingTop: 10,
373
+ paddingBottom: 6,
374
+ },
375
+ input: {
376
+ minHeight: 30,
377
+ maxHeight: 200,
378
+ paddingVertical: 4,
379
+ paddingHorizontal: 4,
380
+ },
381
+ toolbar: {
382
+ flexDirection: "row",
383
+ alignItems: "center",
384
+ justifyContent: "space-between",
385
+ marginTop: 6,
386
+ },
387
+ iconBtn: {
388
+ width: 36,
389
+ height: 36,
390
+ alignItems: "center",
391
+ justifyContent: "center",
392
+ borderRadius: 18,
393
+ },
394
+ disabled: { opacity: 0.4 },
395
+ sendBtn: {
396
+ width: 36,
397
+ height: 36,
398
+ borderRadius: 18,
399
+ alignItems: "center",
400
+ justifyContent: "center",
401
+ },
402
+ // attachment menu
403
+ attachMenuRoot: { position: "relative" },
404
+ attachPopover: {
405
+ position: "absolute",
406
+ bottom: 44,
407
+ left: 0,
408
+ borderRadius: 12,
409
+ borderWidth: 1,
410
+ paddingVertical: 4,
411
+ minWidth: 140,
412
+ shadowColor: "#000",
413
+ shadowOffset: { width: 0, height: 4 },
414
+ shadowOpacity: 0.12,
415
+ shadowRadius: 8,
416
+ elevation: 6,
417
+ zIndex: 20,
418
+ },
419
+ attachMenuItem: {
420
+ paddingHorizontal: 16,
421
+ paddingVertical: 10,
422
+ },
423
+ attachMenuLabel: {
424
+ fontSize: 14,
425
+ },
426
+ attachMenuClose: {
427
+ alignItems: "center",
428
+ paddingVertical: 8,
429
+ borderTopWidth: StyleSheet.hairlineWidth,
430
+ },
431
+ // previews
432
+ previewScroll: { marginBottom: 8 },
433
+ previewContent: { gap: 8, paddingBottom: 2 },
434
+ previewItem: { position: "relative" },
435
+ previewThumb: {
436
+ borderWidth: 1,
437
+ borderRadius: 8,
438
+ padding: 2,
439
+ overflow: "hidden",
440
+ },
441
+ previewImage: { width: 44, height: 44, borderRadius: 6 },
442
+ previewFileIcon: {
443
+ width: 44,
444
+ height: 44,
445
+ borderRadius: 6,
446
+ alignItems: "center",
447
+ justifyContent: "center",
448
+ },
449
+ previewFileIconText: { fontSize: 20 },
450
+ previewRemoveBtn: {
451
+ position: "absolute",
452
+ top: -4,
453
+ right: -4,
454
+ width: 18,
455
+ height: 18,
456
+ borderRadius: 9,
457
+ borderWidth: 1,
458
+ alignItems: "center",
459
+ justifyContent: "center",
460
+ },
461
+ });
@@ -0,0 +1,123 @@
1
+ import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
2
+ import { Animated, StyleSheet, Text, View } from "react-native";
3
+ import { useLiveChatContext } from "../../provider/LiveChatContext";
4
+
5
+ type ToastKind = "connecting" | "reconnecting" | "connected";
6
+
7
+ export function WsStatusStrip() {
8
+ const { connectionState } = useLiveChatContext();
9
+ const { isConnected, isConnecting, isAwaitingRetry } = connectionState;
10
+
11
+ const everConnected = useRef(false);
12
+ const dismissTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
13
+ const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
14
+
15
+ const [wsToast, setWsToast] = useState<ToastKind | null>(null);
16
+ const [wsStripHold, setWsStripHold] = useState<ToastKind | null>(null);
17
+
18
+ const heightAnim = useRef(new Animated.Value(0)).current;
19
+
20
+ // Track ever-connected
21
+ useEffect(() => {
22
+ if (isConnected) everConnected.current = true;
23
+ }, [isConnected]);
24
+
25
+ // Drive wsToast
26
+ useEffect(() => {
27
+ if (dismissTimer.current) clearTimeout(dismissTimer.current);
28
+
29
+ const wsRecoveryUi = !isConnected && (isConnecting || isAwaitingRetry);
30
+
31
+ if (wsRecoveryUi) {
32
+ setWsToast(everConnected.current ? "reconnecting" : "connecting");
33
+ } else if (isConnected && wsToast !== null) {
34
+ // just recovered
35
+ setWsToast("connected");
36
+ dismissTimer.current = setTimeout(() => setWsToast(null), 1200);
37
+ }
38
+ // eslint-disable-next-line react-hooks/exhaustive-deps
39
+ }, [isConnected, isConnecting, isAwaitingRetry]);
40
+
41
+ // wsStripHold — keep rendered 300ms after toast clears for collapse animation
42
+ useLayoutEffect(() => {
43
+ if (holdTimer.current) clearTimeout(holdTimer.current);
44
+ if (wsToast) {
45
+ setWsStripHold(wsToast);
46
+ } else {
47
+ holdTimer.current = setTimeout(() => setWsStripHold(null), 300);
48
+ }
49
+ return () => {
50
+ if (holdTimer.current) clearTimeout(holdTimer.current);
51
+ };
52
+ }, [wsToast]);
53
+
54
+ // Height animation
55
+ useEffect(() => {
56
+ Animated.timing(heightAnim, {
57
+ toValue: wsToast ? 36 : 0,
58
+ duration: 300,
59
+ useNativeDriver: false,
60
+ }).start();
61
+ }, [wsToast, heightAnim]);
62
+
63
+ if (!wsStripHold) return null;
64
+
65
+ const isAmber = wsStripHold === "connecting" || wsStripHold === "reconnecting";
66
+ const bg = "#71717a";
67
+ const label =
68
+ wsStripHold === "connecting"
69
+ ? "Connecting…"
70
+ : wsStripHold === "reconnecting"
71
+ ? "Reconnecting…"
72
+ : "Connected";
73
+
74
+ return (
75
+ <Animated.View style={[styles.strip, { backgroundColor: bg, height: heightAnim, overflow: "hidden" }]}>
76
+ <View style={styles.row}>
77
+ {isAmber && <Spinner />}
78
+ <Text style={styles.text}>{label}</Text>
79
+ </View>
80
+ </Animated.View>
81
+ );
82
+ }
83
+
84
+ function Spinner() {
85
+ const spin = useRef(new Animated.Value(0)).current;
86
+
87
+ useEffect(() => {
88
+ Animated.loop(
89
+ Animated.timing(spin, { toValue: 1, duration: 800, useNativeDriver: true })
90
+ ).start();
91
+ }, [spin]);
92
+
93
+ const rotate = spin.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "360deg"] });
94
+
95
+ return (
96
+ <Animated.View style={[styles.spinner, { transform: [{ rotate }] }]} />
97
+ );
98
+ }
99
+
100
+ const styles = StyleSheet.create({
101
+ strip: {
102
+ justifyContent: "center",
103
+ alignItems: "center",
104
+ },
105
+ row: {
106
+ flexDirection: "row",
107
+ alignItems: "center",
108
+ gap: 6,
109
+ },
110
+ text: {
111
+ color: "#ffffff",
112
+ fontSize: 12,
113
+ fontWeight: "600",
114
+ },
115
+ spinner: {
116
+ width: 12,
117
+ height: 12,
118
+ borderRadius: 6,
119
+ borderWidth: 2,
120
+ borderColor: "rgba(255,255,255,0.4)",
121
+ borderTopColor: "#ffffff",
122
+ },
123
+ });
@@ -0,0 +1,111 @@
1
+ import React from "react";
2
+ import Svg, { Path, Circle, G } from "react-native-svg";
3
+
4
+ export type IconProps = {
5
+ size?: number;
6
+ color?: string;
7
+ style?: object;
8
+ };
9
+
10
+ export const ArrowRightIcon = ({ size = 16, color = "currentColor", style }: IconProps) => (
11
+ <Svg width={size} height={size} viewBox="0 0 16 16" fill="none" style={style}>
12
+ <Path d="M3 8h10M9 4l4 4-4 4" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
13
+ </Svg>
14
+ );
15
+
16
+ export const ArrowRightCircleIcon = ({ size = 20, color = "currentColor", style }: IconProps) => (
17
+ <Svg width={size} height={size} viewBox="0 0 20 20" fill="none" style={style}>
18
+ <Circle cx="10" cy="10" r="7.25" stroke={color} strokeWidth={1.5} />
19
+ <Path d="M8.25 10h4.5M11 7.75L13.25 10 11 12.25" stroke={color} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
20
+ </Svg>
21
+ );
22
+
23
+ export const ArrowLeftIcon = ({ size = 16, color = "currentColor", style }: IconProps) => (
24
+ <Svg width={size} height={size} viewBox="0 0 16 16" fill="none" style={style}>
25
+ <Path d="M13 8H3M7 4L3 8l4 4" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
26
+ </Svg>
27
+ );
28
+
29
+ export const ChevronDownIcon = ({ size = 20, color = "currentColor", style }: IconProps) => (
30
+ <Svg width={size} height={size} viewBox="0 0 20 20" fill="none" style={style}>
31
+ <Path d="M5 7.5l5 5 5-5" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
32
+ </Svg>
33
+ );
34
+
35
+ export const ChevronLeftIcon = ({ size = 20, color = "currentColor", style }: IconProps) => (
36
+ <Svg width={size} height={size} viewBox="0 0 20 20" fill="none" style={style}>
37
+ <Path d="M12.5 5l-5 5 5 5" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
38
+ </Svg>
39
+ );
40
+
41
+ export const CloseIcon = ({ size = 20, color = "currentColor", style }: IconProps) => (
42
+ <Svg width={size} height={size} viewBox="0 0 20 20" fill="none" style={style}>
43
+ <Path d="M15 5L5 15M5 5l10 10" stroke={color} strokeWidth={2} strokeLinecap="round" />
44
+ </Svg>
45
+ );
46
+
47
+ export const ChatBubbleIcon = ({ size = 20, color = "currentColor", style }: IconProps) => (
48
+ <Svg width={size} height={size} viewBox="0 0 20 20" fill={color} style={style}>
49
+ <Path fillRule="evenodd" d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7z" clipRule="evenodd" />
50
+ </Svg>
51
+ );
52
+
53
+ export const MinimizeIcon = ({ size = 16, color = "currentColor", style }: IconProps) => (
54
+ <Svg width={size} height={size} viewBox="0 0 16 16" fill="none" style={style}>
55
+ <Path d="M3 8h10" stroke={color} strokeWidth={2} strokeLinecap="round" />
56
+ </Svg>
57
+ );
58
+
59
+ export const DotsIcon = ({ size = 20, color = "currentColor", style }: IconProps) => (
60
+ <Svg width={size} height={size} viewBox="0 0 20 20" fill={color} style={style}>
61
+ <Circle cx="5" cy="10" r="1.5" />
62
+ <Circle cx="10" cy="10" r="1.5" />
63
+ <Circle cx="15" cy="10" r="1.5" />
64
+ </Svg>
65
+ );
66
+
67
+ export const HistoryChatsIcon = ({ size = 24, color = "currentColor", style }: IconProps) => (
68
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={style}>
69
+ <Path d="M12 8v4l2.5 1.5M21 12a9 9 0 11-3-6.7" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
70
+ <Path d="M21 3v4h-4" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
71
+ </Svg>
72
+ );
73
+
74
+ export const SendPlaneIcon = ({ size = 24, color = "currentColor", style }: IconProps) => (
75
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={style}>
76
+ <Path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
77
+ </Svg>
78
+ );
79
+
80
+ export const PaperclipIcon = ({ size = 24, color = "currentColor", style }: IconProps) => (
81
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={style}>
82
+ <Path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
83
+ </Svg>
84
+ );
85
+
86
+ export const DownloadIcon = ({ size = 24, color = "currentColor", style }: IconProps) => (
87
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={style}>
88
+ <Path d="M12 15V3" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
89
+ <Path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
90
+ <Path d="m7 10 5 5 5-5" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
91
+ </Svg>
92
+ );
93
+
94
+ export const PlayIcon = ({ size = 24, color = "currentColor", style }: IconProps) => (
95
+ <Svg width={size} height={size} viewBox="0 0 512 512" fill={color} style={style}>
96
+ <Path d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm115.7 272l-176 101c-15.8 8.8-35.7-2.5-35.7-21V152c0-18.4 19.8-29.8 35.7-21l176 107c16.4 9.2 16.4 32.9 0 42z" />
97
+ </Svg>
98
+ );
99
+
100
+ export const FileIcon = ({ size = 24, color = "currentColor", style }: IconProps) => (
101
+ <Svg width={size} height={size} viewBox="0 0 384 512" fill={color} style={style}>
102
+ <Path d="M0 64C0 28.7 28.7 0 64 0L224 0l0 128c0 17.7 14.3 32 32 32l128 0 0 288c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 64zm384 64l-128 0L256 0 384 128z" />
103
+ </Svg>
104
+ );
105
+
106
+ export const TurbodeskLogoIcon = ({ size = 24, color = "currentColor", style }: IconProps) => (
107
+ <Svg width={size} height={size} viewBox="0 0 152 250" fill="none" style={style}>
108
+ <Path d="M51.5138 0H151.966L74.695 73.4069H0L51.5138 0Z" fill={color} />
109
+ <Path d="M74.7266 73.4069L151.997 0V249.584L74.7266 217.774V73.4069Z" fill={color} />
110
+ </Svg>
111
+ );