@repzo/widget-react-native 1.0.0 → 1.0.4
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/README.md +167 -74
- package/lib/commonjs/api/client.js +3 -1
- package/lib/commonjs/api/client.js.map +1 -1
- package/lib/commonjs/api/realtime.js +2 -0
- package/lib/commonjs/api/realtime.js.map +1 -1
- package/lib/commonjs/attachments/pickers.js +493 -0
- package/lib/commonjs/attachments/pickers.js.map +1 -0
- package/lib/commonjs/i18n.js +45 -0
- package/lib/commonjs/i18n.js.map +1 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/locales.js +21 -0
- package/lib/commonjs/locales.js.map +1 -1
- package/lib/commonjs/state/widget-store.js +256 -18
- package/lib/commonjs/state/widget-store.js.map +1 -1
- package/lib/commonjs/types.js +16 -0
- package/lib/commonjs/types.js.map +1 -1
- package/lib/commonjs/ui/WidgetProvider.js +70 -10
- package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
- package/lib/commonjs/ui/components/AttachmentChips.js +135 -0
- package/lib/commonjs/ui/components/AttachmentChips.js.map +1 -0
- package/lib/commonjs/ui/components/AttachmentMenu.js +125 -0
- package/lib/commonjs/ui/components/AttachmentMenu.js.map +1 -0
- package/lib/commonjs/ui/components/ImageViewer.js +181 -0
- package/lib/commonjs/ui/components/ImageViewer.js.map +1 -0
- package/lib/commonjs/ui/components/InAppNotification.js +210 -0
- package/lib/commonjs/ui/components/InAppNotification.js.map +1 -0
- package/lib/commonjs/ui/components/TeamAvatars.js +63 -0
- package/lib/commonjs/ui/components/TeamAvatars.js.map +1 -0
- package/lib/commonjs/ui/components/icons.js +50 -0
- package/lib/commonjs/ui/components/icons.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.js +303 -24
- package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js +70 -0
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/module/api/client.js +3 -1
- package/lib/module/api/client.js.map +1 -1
- package/lib/module/api/realtime.js +2 -0
- package/lib/module/api/realtime.js.map +1 -1
- package/lib/module/attachments/pickers.js +479 -0
- package/lib/module/attachments/pickers.js.map +1 -0
- package/lib/module/i18n.js +44 -0
- package/lib/module/i18n.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/locales.js +21 -0
- package/lib/module/locales.js.map +1 -1
- package/lib/module/state/widget-store.js +256 -18
- package/lib/module/state/widget-store.js.map +1 -1
- package/lib/module/types.js +16 -0
- package/lib/module/types.js.map +1 -1
- package/lib/module/ui/WidgetProvider.js +71 -11
- package/lib/module/ui/WidgetProvider.js.map +1 -1
- package/lib/module/ui/components/AttachmentChips.js +131 -0
- package/lib/module/ui/components/AttachmentChips.js.map +1 -0
- package/lib/module/ui/components/AttachmentMenu.js +120 -0
- package/lib/module/ui/components/AttachmentMenu.js.map +1 -0
- package/lib/module/ui/components/ImageViewer.js +177 -0
- package/lib/module/ui/components/ImageViewer.js.map +1 -0
- package/lib/module/ui/components/InAppNotification.js +208 -0
- package/lib/module/ui/components/InAppNotification.js.map +1 -0
- package/lib/module/ui/components/TeamAvatars.js +59 -0
- package/lib/module/ui/components/TeamAvatars.js.map +1 -0
- package/lib/module/ui/components/icons.js +49 -0
- package/lib/module/ui/components/icons.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.js +306 -27
- package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.styles.js +70 -0
- package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/typescript/commonjs/api/client.d.ts +8 -2
- package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
- package/lib/typescript/commonjs/api/realtime.d.ts.map +1 -1
- package/lib/typescript/commonjs/attachments/pickers.d.ts +81 -0
- package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -0
- package/lib/typescript/commonjs/i18n.d.ts +23 -0
- package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +1 -1
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/locales.d.ts.map +1 -1
- package/lib/typescript/commonjs/state/widget-store.d.ts +40 -2
- package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/commonjs/types.d.ts +88 -1
- package/lib/typescript/commonjs/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts +16 -0
- package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/AttachmentMenu.d.ts +28 -0
- package/lib/typescript/commonjs/ui/components/AttachmentMenu.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +23 -0
- package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/InAppNotification.d.ts +28 -0
- package/lib/typescript/commonjs/ui/components/InAppNotification.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/TeamAvatars.d.ts +17 -0
- package/lib/typescript/commonjs/ui/components/TeamAvatars.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/icons.d.ts +2 -0
- package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +3 -2
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +69 -0
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +41 -2
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/lib/typescript/module/api/client.d.ts +8 -2
- package/lib/typescript/module/api/client.d.ts.map +1 -1
- package/lib/typescript/module/api/realtime.d.ts.map +1 -1
- package/lib/typescript/module/attachments/pickers.d.ts +81 -0
- package/lib/typescript/module/attachments/pickers.d.ts.map +1 -0
- package/lib/typescript/module/i18n.d.ts +23 -0
- package/lib/typescript/module/i18n.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +1 -1
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/locales.d.ts.map +1 -1
- package/lib/typescript/module/state/widget-store.d.ts +40 -2
- package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/module/types.d.ts +88 -1
- package/lib/typescript/module/types.d.ts.map +1 -1
- package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/AttachmentChips.d.ts +16 -0
- package/lib/typescript/module/ui/components/AttachmentChips.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/AttachmentMenu.d.ts +28 -0
- package/lib/typescript/module/ui/components/AttachmentMenu.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/ImageViewer.d.ts +23 -0
- package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/InAppNotification.d.ts +28 -0
- package/lib/typescript/module/ui/components/InAppNotification.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/TeamAvatars.d.ts +17 -0
- package/lib/typescript/module/ui/components/TeamAvatars.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/icons.d.ts +2 -0
- package/lib/typescript/module/ui/components/icons.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +3 -2
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +69 -0
- package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +41 -2
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/metro.js +17 -32
- package/package.json +18 -1
- package/src/api/client.ts +5 -3
- package/src/api/realtime.ts +2 -0
- package/src/attachments/pickers.ts +515 -0
- package/src/i18n.ts +48 -0
- package/src/index.tsx +4 -0
- package/src/locales.ts +21 -0
- package/src/state/widget-store.ts +256 -19
- package/src/types.ts +95 -0
- package/src/ui/WidgetProvider.tsx +90 -9
- package/src/ui/components/AttachmentChips.tsx +135 -0
- package/src/ui/components/AttachmentMenu.tsx +132 -0
- package/src/ui/components/ImageViewer.tsx +178 -0
- package/src/ui/components/InAppNotification.tsx +225 -0
- package/src/ui/components/TeamAvatars.tsx +58 -0
- package/src/ui/components/icons.tsx +37 -0
- package/src/ui/screens/chat/ChatView.styles.ts +70 -0
- package/src/ui/screens/chat/ChatView.tsx +345 -35
- package/src/ui/screens/chat/ChatView.types.ts +51 -2
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Mobile differences: older messages load through an explicit "Load earlier
|
|
6
6
|
* messages" control (a scroll-anchored infinite list fights the keyboard), and
|
|
7
|
-
* the
|
|
7
|
+
* the attach button only shows when the host app has a picker to open (see
|
|
8
|
+
* `attachments/pickers.ts`).
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
Animated,
|
|
13
14
|
Easing,
|
|
14
15
|
Image,
|
|
16
|
+
Platform,
|
|
15
17
|
Pressable,
|
|
16
18
|
ScrollView,
|
|
17
19
|
Text,
|
|
@@ -25,19 +27,35 @@ import {
|
|
|
25
27
|
getAgentUiBlocks,
|
|
26
28
|
getProcedureForm,
|
|
27
29
|
} from '../../../agent-ui-blocks';
|
|
28
|
-
import
|
|
30
|
+
import {
|
|
31
|
+
MAX_ATTACHMENTS,
|
|
32
|
+
isImageAttachment,
|
|
33
|
+
type AttachmentProblem,
|
|
34
|
+
type DraftAttachment,
|
|
35
|
+
} from '../../../attachments/pickers';
|
|
36
|
+
import type {
|
|
37
|
+
AgentUiAction,
|
|
38
|
+
AgentProcedureForm,
|
|
39
|
+
AttachmentSource,
|
|
40
|
+
Message,
|
|
41
|
+
WidgetAttachment,
|
|
42
|
+
} from '../../../types';
|
|
29
43
|
import { AgentUiBlocks } from '../../components/AgentUiBlocks';
|
|
44
|
+
import { AttachmentChips } from '../../components/AttachmentChips';
|
|
45
|
+
import { AttachmentMenu, showNativeAttachmentMenu } from '../../components/AttachmentMenu';
|
|
30
46
|
import { Avatar } from '../../components/Avatar';
|
|
47
|
+
import { ImageViewer } from '../../components/ImageViewer';
|
|
31
48
|
import { WidgetErrorBoundary } from '../../components/ErrorBoundary';
|
|
32
49
|
import { GradientBackground } from '../../components/Gradient';
|
|
33
|
-
import { ArrowUpIcon, ChatOutlineIcon, ChevronIcon } from '../../components/icons';
|
|
50
|
+
import { ArrowUpIcon, ChatOutlineIcon, ChevronIcon, ClockIcon, PaperclipIcon } from '../../components/icons';
|
|
51
|
+
import { TeamAvatars } from '../../components/TeamAvatars';
|
|
34
52
|
import { Markdown } from '../../components/Markdown';
|
|
35
53
|
import { useReduceMotion } from '../../hooks/use-reduce-motion';
|
|
36
54
|
import { globalStyles } from '../../styles/global';
|
|
37
55
|
import { contrastingTextColor, mixHex, palette, withAlpha } from '../../styles/theme';
|
|
38
56
|
import { formatClockTime, formatDayLabel, isSameDay } from '../../utils/conversations';
|
|
39
57
|
import { stripHtml } from '../../utils/html';
|
|
40
|
-
import { openExternalUrl } from '../../utils/safe-url';
|
|
58
|
+
import { isHttpUrl, openExternalUrl } from '../../utils/safe-url';
|
|
41
59
|
import { backDirection, isRTL } from '../../utils/rtl';
|
|
42
60
|
import { styles } from './ChatView.styles';
|
|
43
61
|
import type { ChatLabels, ChatViewProps } from './ChatView.types';
|
|
@@ -50,11 +68,17 @@ export function ChatView({
|
|
|
50
68
|
color,
|
|
51
69
|
agentName,
|
|
52
70
|
agentAvatar,
|
|
71
|
+
agentOnline,
|
|
53
72
|
isOnline,
|
|
73
|
+
teamName,
|
|
74
|
+
teamAvatars = [],
|
|
75
|
+
replyTimeLabel,
|
|
54
76
|
locale,
|
|
55
77
|
prefillText,
|
|
56
78
|
offlineMessage,
|
|
57
79
|
labels,
|
|
80
|
+
attachmentSources = [],
|
|
81
|
+
onPickAttachments,
|
|
58
82
|
onSendMessage,
|
|
59
83
|
onIdentifyVisitor,
|
|
60
84
|
onLoadMore,
|
|
@@ -64,20 +88,41 @@ export function ChatView({
|
|
|
64
88
|
}: ChatViewProps) {
|
|
65
89
|
const [input, setInput] = useState(prefillText || '');
|
|
66
90
|
const [composerFocused, setComposerFocused] = useState(false);
|
|
91
|
+
const [drafts, setDrafts] = useState<DraftAttachment[]>([]);
|
|
92
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
93
|
+
const [viewerImage, setViewerImage] = useState<{ url: string; name: string } | null>(null);
|
|
94
|
+
const [picking, setPicking] = useState(false);
|
|
95
|
+
// Only attachment sends hold the composer; text sends stay fire-and-forget.
|
|
96
|
+
const [sending, setSending] = useState(false);
|
|
97
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
67
98
|
const reduceMotion = useReduceMotion();
|
|
68
99
|
const scrollRef = useRef<ScrollView>(null);
|
|
69
100
|
const typingStopTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
101
|
+
const noticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
102
|
+
const pickerTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
103
|
+
const mounted = useRef(false);
|
|
70
104
|
|
|
71
105
|
useEffect(() => {
|
|
72
106
|
if (prefillText) setInput(prefillText);
|
|
73
107
|
}, [prefillText]);
|
|
74
108
|
|
|
75
|
-
|
|
76
|
-
|
|
109
|
+
// The provider keys this screen by the store's thread generation. Pending
|
|
110
|
+
// picks and sends belong to this mount, even before a draft gets a server ID.
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
mounted.current = true;
|
|
113
|
+
return () => {
|
|
114
|
+
mounted.current = false;
|
|
77
115
|
if (typingStopTimer.current) clearTimeout(typingStopTimer.current);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
116
|
+
if (noticeTimer.current) clearTimeout(noticeTimer.current);
|
|
117
|
+
if (pickerTimer.current) clearTimeout(pickerTimer.current);
|
|
118
|
+
};
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
121
|
+
const showNotice = useCallback((message: string | null) => {
|
|
122
|
+
if (noticeTimer.current) clearTimeout(noticeTimer.current);
|
|
123
|
+
setNotice(message);
|
|
124
|
+
if (message) noticeTimer.current = setTimeout(() => setNotice(null), 5000);
|
|
125
|
+
}, []);
|
|
81
126
|
|
|
82
127
|
const scrollToBottom = useCallback((animated = true) => {
|
|
83
128
|
scrollRef.current?.scrollToEnd({ animated });
|
|
@@ -122,16 +167,120 @@ export function ChatView({
|
|
|
122
167
|
[scrollToBottom]
|
|
123
168
|
);
|
|
124
169
|
|
|
125
|
-
const
|
|
170
|
+
const showAttach = attachmentSources.length > 0 && !!onPickAttachments;
|
|
171
|
+
const canSend = !sending && (input.trim().length > 0 || drafts.length > 0);
|
|
172
|
+
|
|
173
|
+
const problemLabel = (problem: AttachmentProblem | null): string | null => {
|
|
174
|
+
switch (problem) {
|
|
175
|
+
case 'tooMany':
|
|
176
|
+
return labels.attach.tooMany;
|
|
177
|
+
case 'tooLarge':
|
|
178
|
+
return labels.attach.tooLarge;
|
|
179
|
+
case 'tooLargeTogether':
|
|
180
|
+
return labels.attach.tooLargeTogether;
|
|
181
|
+
case 'cameraDenied':
|
|
182
|
+
return labels.attach.cameraDenied;
|
|
183
|
+
case 'failed':
|
|
184
|
+
return labels.attach.failed;
|
|
185
|
+
default:
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const handlePick = async (source: AttachmentSource) => {
|
|
191
|
+
if (!mounted.current || !onPickAttachments || picking) return;
|
|
192
|
+
setPicking(true);
|
|
193
|
+
try {
|
|
194
|
+
const result = await onPickAttachments(source, drafts);
|
|
195
|
+
if (!mounted.current) return;
|
|
196
|
+
if (result.added.length > 0) {
|
|
197
|
+
setDrafts((current) => [...current, ...result.added].slice(0, MAX_ATTACHMENTS));
|
|
198
|
+
}
|
|
199
|
+
showNotice(problemLabel(result.problem));
|
|
200
|
+
} finally {
|
|
201
|
+
if (mounted.current) setPicking(false);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const menuOptions = attachmentSources.map((source) => ({
|
|
206
|
+
key: source,
|
|
207
|
+
label:
|
|
208
|
+
source === 'photo'
|
|
209
|
+
? labels.attach.photoLibrary
|
|
210
|
+
: source === 'camera'
|
|
211
|
+
? labels.attach.takePhoto
|
|
212
|
+
: labels.attach.file,
|
|
213
|
+
}));
|
|
214
|
+
|
|
215
|
+
const handleAttachPress = () => {
|
|
216
|
+
if (sending || picking) return;
|
|
217
|
+
if (drafts.length >= MAX_ATTACHMENTS) {
|
|
218
|
+
showNotice(labels.attach.tooMany);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const only = attachmentSources.length === 1 ? attachmentSources[0] : undefined;
|
|
222
|
+
if (only) {
|
|
223
|
+
void handlePick(only);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const native = showNativeAttachmentMenu(menuOptions, labels.attach.cancel, (source) => {
|
|
227
|
+
if (!mounted.current) return;
|
|
228
|
+
// Presenting a picker while the action sheet is still animating away
|
|
229
|
+
// fails silently on iOS; let it finish first.
|
|
230
|
+
pickerTimer.current = setTimeout(() => void handlePick(source), 350);
|
|
231
|
+
});
|
|
232
|
+
if (!native) setMenuOpen(true);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const handleMenuSelect = (source: AttachmentSource) => {
|
|
236
|
+
setMenuOpen(false);
|
|
237
|
+
void handlePick(source);
|
|
238
|
+
};
|
|
126
239
|
|
|
127
240
|
const handleSend = () => {
|
|
128
241
|
const trimmed = input.trim();
|
|
129
|
-
if (!
|
|
242
|
+
if (!canSend) return;
|
|
130
243
|
setInput('');
|
|
131
244
|
if (typingStopTimer.current) clearTimeout(typingStopTimer.current);
|
|
132
245
|
onTyping(false);
|
|
133
|
-
onSendMessage(trimmed);
|
|
134
246
|
scrollToBottom();
|
|
247
|
+
|
|
248
|
+
if (drafts.length === 0) {
|
|
249
|
+
// The failed bubble already surfaces a failure to the visitor.
|
|
250
|
+
onSendMessage(trimmed).catch(() => {});
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const sent = drafts;
|
|
255
|
+
setSending(true);
|
|
256
|
+
showNotice(null);
|
|
257
|
+
onSendMessage(
|
|
258
|
+
trimmed,
|
|
259
|
+
sent.map((draft) => ({
|
|
260
|
+
name: draft.name,
|
|
261
|
+
url: draft.dataUrl,
|
|
262
|
+
contentType: draft.contentType,
|
|
263
|
+
size: draft.size,
|
|
264
|
+
previewUrl: draft.previewUri,
|
|
265
|
+
}))
|
|
266
|
+
)
|
|
267
|
+
.then(() => {
|
|
268
|
+
if (!mounted.current) return;
|
|
269
|
+
const sentIds = new Set(sent.map((draft) => draft.id));
|
|
270
|
+
setDrafts((current) => current.filter((draft) => !sentIds.has(draft.id)));
|
|
271
|
+
})
|
|
272
|
+
.catch((error: unknown) => {
|
|
273
|
+
// The visitor has moved to another thread: nothing to restore there.
|
|
274
|
+
if (!mounted.current) return;
|
|
275
|
+
// Chips stay for a retry; put the text back too unless they've
|
|
276
|
+
// already started typing something else.
|
|
277
|
+
setInput((current) => current || trimmed);
|
|
278
|
+
const status = (error as { status?: unknown } | null)?.status;
|
|
279
|
+
showNotice(status === 413 ? labels.attach.tooLargeTogether : labels.failedToSend);
|
|
280
|
+
})
|
|
281
|
+
.finally(() => {
|
|
282
|
+
if (mounted.current) setSending(false);
|
|
283
|
+
});
|
|
135
284
|
};
|
|
136
285
|
|
|
137
286
|
const handleChangeText = (text: string) => {
|
|
@@ -156,7 +305,7 @@ export function ChatView({
|
|
|
156
305
|
phone: stringPayload(action.payload.phone),
|
|
157
306
|
});
|
|
158
307
|
}
|
|
159
|
-
onSendMessage(formatAgentUiActionMessage(action), undefined, action);
|
|
308
|
+
onSendMessage(formatAgentUiActionMessage(action), undefined, action).catch(() => {});
|
|
160
309
|
scrollToBottom();
|
|
161
310
|
},
|
|
162
311
|
[onIdentifyVisitor, onSendMessage, scrollToBottom]
|
|
@@ -165,6 +314,17 @@ export function ChatView({
|
|
|
165
314
|
const headerTextColor = contrastingTextColor(color);
|
|
166
315
|
const onDarkHeader = headerTextColor === palette.white;
|
|
167
316
|
const isEmptyThread = !loading && messages.length === 0;
|
|
317
|
+
const agentIsActive = agentOnline ?? isOnline;
|
|
318
|
+
// "Waiting for a teammate" until someone replies: no teammate in the header
|
|
319
|
+
// and the visitor spoke last (bot replies before that still count as waiting).
|
|
320
|
+
const lastChatMessage = [...messages]
|
|
321
|
+
.reverse()
|
|
322
|
+
.find((message) => (message.messageType ?? 'message') === 'message' && !message.deletedAt);
|
|
323
|
+
const awaitingTeammate = !agentName && !agentTyping && lastChatMessage?.direction === 'inbound';
|
|
324
|
+
// "Sent" / "Seen" goes under the visitor's latest message only.
|
|
325
|
+
const lastVisitorMessageId = [...messages]
|
|
326
|
+
.reverse()
|
|
327
|
+
.find((message) => message.direction === 'inbound' && (message.messageType ?? 'message') === 'message')?.id;
|
|
168
328
|
// Two stops off the brand color: flat fill looked like an unstyled block.
|
|
169
329
|
// A non-hex brand color falls through mixHex unchanged and renders solid.
|
|
170
330
|
const headerGradient = [color, mixHex(color, '#000000', 0.16)];
|
|
@@ -190,28 +350,57 @@ export function ChatView({
|
|
|
190
350
|
<ChevronIcon size={18} direction={backDirection()} color={headerTextColor} />
|
|
191
351
|
</Pressable>
|
|
192
352
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
353
|
+
{agentName ? (
|
|
354
|
+
// A teammate joined: their photo, name and own presence.
|
|
355
|
+
<View style={styles.headerIdentity}>
|
|
356
|
+
<View style={styles.avatarWrapper}>
|
|
357
|
+
<Avatar
|
|
358
|
+
avatar={agentAvatar}
|
|
359
|
+
name={agentName}
|
|
360
|
+
size={36}
|
|
361
|
+
translucent={onDarkHeader}
|
|
362
|
+
color={palette.ink}
|
|
363
|
+
/>
|
|
364
|
+
{agentIsActive ? <View style={styles.onlineDot} /> : null}
|
|
365
|
+
</View>
|
|
366
|
+
<View style={styles.headerText}>
|
|
367
|
+
<Text style={[styles.headerName, { color: headerTextColor }]} numberOfLines={1}>
|
|
368
|
+
{agentName}
|
|
369
|
+
</Text>
|
|
370
|
+
<Text
|
|
371
|
+
style={[styles.headerStatus, { color: withAlpha(headerTextColor, 0.75) }]}
|
|
372
|
+
numberOfLines={1}>
|
|
373
|
+
{agentIsActive ? labels.statusOnline : labels.statusAway}
|
|
374
|
+
</Text>
|
|
375
|
+
</View>
|
|
376
|
+
</View>
|
|
377
|
+
) : (
|
|
378
|
+
// No teammate yet, or the conversation closed: the team, Intercom-style.
|
|
379
|
+
<View style={styles.headerIdentity}>
|
|
380
|
+
<TeamAvatars
|
|
381
|
+
avatars={teamAvatars}
|
|
382
|
+
size={30}
|
|
200
383
|
color={palette.ink}
|
|
384
|
+
ringColor={color}
|
|
385
|
+
translucent={onDarkHeader}
|
|
201
386
|
/>
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
{
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
387
|
+
<View style={styles.headerText}>
|
|
388
|
+
<Text style={[styles.headerName, { color: headerTextColor }]} numberOfLines={1}>
|
|
389
|
+
{teamName || labels.team}
|
|
390
|
+
</Text>
|
|
391
|
+
{replyTimeLabel ? (
|
|
392
|
+
<View style={styles.headerReplyTime}>
|
|
393
|
+
<ClockIcon size={12} color={withAlpha(headerTextColor, 0.75)} />
|
|
394
|
+
<Text
|
|
395
|
+
style={[styles.headerStatus, styles.headerReplyTimeText, { color: withAlpha(headerTextColor, 0.75) }]}
|
|
396
|
+
numberOfLines={1}>
|
|
397
|
+
{replyTimeLabel}
|
|
398
|
+
</Text>
|
|
399
|
+
</View>
|
|
400
|
+
) : null}
|
|
401
|
+
</View>
|
|
213
402
|
</View>
|
|
214
|
-
|
|
403
|
+
)}
|
|
215
404
|
|
|
216
405
|
<Pressable
|
|
217
406
|
onPress={onClose}
|
|
@@ -292,15 +481,17 @@ export function ChatView({
|
|
|
292
481
|
message={message}
|
|
293
482
|
color={color}
|
|
294
483
|
labels={labels}
|
|
484
|
+
onOpenImage={setViewerImage}
|
|
295
485
|
senderLabel={
|
|
296
486
|
isAgentMessage && (startsRun || startsDay)
|
|
297
487
|
? message.senderName || agentName || labels.team
|
|
298
488
|
: null
|
|
299
489
|
}
|
|
300
490
|
timeLabel={formatClockTime(message.createdAt, locale)}
|
|
491
|
+
receiptLabel={message.id === lastVisitorMessageId ? receiptLabel(message, labels) : undefined}
|
|
301
492
|
onSubmitProcedureForm={
|
|
302
493
|
shouldShowProcedureForm(messages, message)
|
|
303
|
-
? (body) => onSendMessage(body)
|
|
494
|
+
? (body) => void onSendMessage(body).catch(() => {})
|
|
304
495
|
: undefined
|
|
305
496
|
}
|
|
306
497
|
onAgentUiAction={
|
|
@@ -312,6 +503,13 @@ export function ChatView({
|
|
|
312
503
|
);
|
|
313
504
|
})}
|
|
314
505
|
|
|
506
|
+
{awaitingTeammate ? (
|
|
507
|
+
<View style={styles.waitingRow}>
|
|
508
|
+
<TeamAvatars avatars={teamAvatars} size={20} color={color} />
|
|
509
|
+
<Text style={styles.waitingText}>{labels.waitingForTeammate}</Text>
|
|
510
|
+
</View>
|
|
511
|
+
) : null}
|
|
512
|
+
|
|
315
513
|
{agentTyping ? (
|
|
316
514
|
<View style={styles.typingRow}>
|
|
317
515
|
<View style={styles.typingBubble}>
|
|
@@ -331,12 +529,48 @@ export function ChatView({
|
|
|
331
529
|
) : null}
|
|
332
530
|
|
|
333
531
|
<View style={styles.composerWrapper}>
|
|
532
|
+
{notice ? (
|
|
533
|
+
<Text
|
|
534
|
+
style={styles.composerNotice}
|
|
535
|
+
accessibilityRole="alert"
|
|
536
|
+
accessibilityLiveRegion="polite">
|
|
537
|
+
{notice}
|
|
538
|
+
</Text>
|
|
539
|
+
) : null}
|
|
540
|
+
{drafts.length > 0 ? (
|
|
541
|
+
<AttachmentChips
|
|
542
|
+
attachments={drafts}
|
|
543
|
+
disabled={sending}
|
|
544
|
+
removeLabel={labels.attach.remove}
|
|
545
|
+
onRemove={(id) => {
|
|
546
|
+
setDrafts((current) => current.filter((draft) => draft.id !== id));
|
|
547
|
+
showNotice(null);
|
|
548
|
+
}}
|
|
549
|
+
/>
|
|
550
|
+
) : null}
|
|
334
551
|
<View
|
|
335
552
|
style={[
|
|
336
553
|
styles.composer,
|
|
554
|
+
showAttach && styles.composerWithAttach,
|
|
337
555
|
composerFocused && styles.composerFocused,
|
|
338
556
|
composerFocused && { borderColor: withAlpha(color, 0.55) },
|
|
339
557
|
]}>
|
|
558
|
+
{showAttach ? (
|
|
559
|
+
<Pressable
|
|
560
|
+
onPress={handleAttachPress}
|
|
561
|
+
disabled={sending || picking}
|
|
562
|
+
accessibilityRole="button"
|
|
563
|
+
accessibilityLabel={labels.attach.button}
|
|
564
|
+
accessibilityState={{ disabled: sending || picking }}
|
|
565
|
+
// 36pt circle + 4pt on every side = a 44pt target.
|
|
566
|
+
hitSlop={4}
|
|
567
|
+
style={({ pressed }) => [
|
|
568
|
+
styles.attachButton,
|
|
569
|
+
(pressed || sending || picking) && globalStyles.pressed,
|
|
570
|
+
]}>
|
|
571
|
+
<PaperclipIcon size={20} color={palette.textMuted} />
|
|
572
|
+
</Pressable>
|
|
573
|
+
) : null}
|
|
340
574
|
<TextInput
|
|
341
575
|
value={input}
|
|
342
576
|
onChangeText={handleChangeText}
|
|
@@ -366,6 +600,25 @@ export function ChatView({
|
|
|
366
600
|
</Pressable>
|
|
367
601
|
</View>
|
|
368
602
|
</View>
|
|
603
|
+
|
|
604
|
+
{showAttach && Platform.OS !== 'ios' ? (
|
|
605
|
+
<AttachmentMenu
|
|
606
|
+
visible={menuOpen}
|
|
607
|
+
options={menuOptions}
|
|
608
|
+
cancelLabel={labels.attach.cancel}
|
|
609
|
+
onSelect={handleMenuSelect}
|
|
610
|
+
onClose={() => setMenuOpen(false)}
|
|
611
|
+
/>
|
|
612
|
+
) : null}
|
|
613
|
+
<ImageViewer
|
|
614
|
+
image={viewerImage}
|
|
615
|
+
labels={labels.viewer}
|
|
616
|
+
onClose={() => setViewerImage(null)}
|
|
617
|
+
onOpenExternally={(url) => {
|
|
618
|
+
setViewerImage(null);
|
|
619
|
+
void openExternalUrl(url);
|
|
620
|
+
}}
|
|
621
|
+
/>
|
|
369
622
|
</View>
|
|
370
623
|
);
|
|
371
624
|
}
|
|
@@ -376,17 +629,38 @@ function MessageBubble({
|
|
|
376
629
|
labels,
|
|
377
630
|
senderLabel,
|
|
378
631
|
timeLabel,
|
|
632
|
+
receiptLabel,
|
|
379
633
|
onSubmitProcedureForm,
|
|
380
634
|
onAgentUiAction,
|
|
635
|
+
onOpenImage,
|
|
381
636
|
}: {
|
|
382
637
|
message: Message;
|
|
383
638
|
color: string;
|
|
384
639
|
labels: ChatLabels;
|
|
385
640
|
senderLabel?: string | null;
|
|
386
641
|
timeLabel: string;
|
|
642
|
+
receiptLabel?: string;
|
|
387
643
|
onSubmitProcedureForm?: (body: string) => void;
|
|
388
644
|
onAgentUiAction?: (action: AgentUiAction) => void | Promise<void>;
|
|
645
|
+
onOpenImage: (image: { url: string; name: string }) => void;
|
|
389
646
|
}) {
|
|
647
|
+
const joinedName = message.metadata?.event === 'agent_joined' ? message.metadata?.agentName : null;
|
|
648
|
+
if (message.messageType === 'system' && typeof joinedName === 'string' && joinedName) {
|
|
649
|
+
return (
|
|
650
|
+
<View style={[styles.systemRow, styles.joinedRow]}>
|
|
651
|
+
<Avatar
|
|
652
|
+
avatar={typeof message.metadata?.agentAvatar === 'string' ? message.metadata.agentAvatar : null}
|
|
653
|
+
name={joinedName}
|
|
654
|
+
size={20}
|
|
655
|
+
color={palette.ink}
|
|
656
|
+
/>
|
|
657
|
+
<Text style={styles.systemText}>
|
|
658
|
+
<Text style={styles.joinedName}>{joinedName}</Text> {labels.joinedConversation}
|
|
659
|
+
</Text>
|
|
660
|
+
</View>
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
|
|
390
664
|
if (message.messageType === 'system') {
|
|
391
665
|
const text = message.bodyPlain || stripHtml(message.body || '') || labels.conversationUpdated;
|
|
392
666
|
return (
|
|
@@ -397,6 +671,17 @@ function MessageBubble({
|
|
|
397
671
|
}
|
|
398
672
|
|
|
399
673
|
const isVisitor = message.direction === 'inbound';
|
|
674
|
+
|
|
675
|
+
if (message.deletedAt) {
|
|
676
|
+
return (
|
|
677
|
+
<View style={[styles.messageRow, isVisitor ? styles.messageRowRight : styles.messageRowLeft]}>
|
|
678
|
+
<View style={styles.deletedBubble}>
|
|
679
|
+
<Text style={styles.deletedText}>{labels.messageDeleted}</Text>
|
|
680
|
+
</View>
|
|
681
|
+
</View>
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
|
|
400
685
|
const rtl = isRTL();
|
|
401
686
|
const messageText = message.bodyPlain || stripHtml(message.body || '');
|
|
402
687
|
const hasBody = !!messageText;
|
|
@@ -429,6 +714,7 @@ function MessageBubble({
|
|
|
429
714
|
key={attachment.id}
|
|
430
715
|
attachment={attachment}
|
|
431
716
|
isVisitor={isVisitor}
|
|
717
|
+
onOpenImage={onOpenImage}
|
|
432
718
|
/>
|
|
433
719
|
))}
|
|
434
720
|
</View>
|
|
@@ -455,6 +741,8 @@ function MessageBubble({
|
|
|
455
741
|
) : null}
|
|
456
742
|
</View>
|
|
457
743
|
|
|
744
|
+
{receiptLabel ? <Text style={styles.receiptText}>{receiptLabel}</Text> : null}
|
|
745
|
+
|
|
458
746
|
{agentUiBlocks.length > 0 && onAgentUiAction ? (
|
|
459
747
|
<AgentUiBlocks
|
|
460
748
|
blocks={agentUiBlocks}
|
|
@@ -476,21 +764,39 @@ function MessageBubble({
|
|
|
476
764
|
);
|
|
477
765
|
}
|
|
478
766
|
|
|
767
|
+
/** Sent once the server has it, Seen once a teammate opened the conversation. */
|
|
768
|
+
function receiptLabel(message: Message, labels: ChatLabels): string | undefined {
|
|
769
|
+
if (message.deletedAt || message.status === 'pending' || message.status === 'failed') return undefined;
|
|
770
|
+
return message.status === 'read' ? labels.messageSeen : labels.messageSent;
|
|
771
|
+
}
|
|
772
|
+
|
|
479
773
|
function AttachmentPreview({
|
|
480
774
|
attachment,
|
|
481
775
|
isVisitor,
|
|
776
|
+
onOpenImage,
|
|
482
777
|
}: {
|
|
483
778
|
attachment: WidgetAttachment;
|
|
484
779
|
isVisitor: boolean;
|
|
780
|
+
onOpenImage: (image: { url: string; name: string }) => void;
|
|
485
781
|
}) {
|
|
486
782
|
// `url` is empty when the server sent a non-http(s) URL (see resolveUrl).
|
|
487
|
-
|
|
488
|
-
|
|
783
|
+
// An unsent attachment shows its local file (or data URL): it renders, but
|
|
784
|
+
// there is nothing to open until the server copy arrives.
|
|
785
|
+
const [imageFailed, setImageFailed] = useState(false);
|
|
786
|
+
const isImage = isImageAttachment(attachment.contentType) && !!attachment.url && !imageFailed;
|
|
787
|
+
const openable = isHttpUrl(attachment.url);
|
|
788
|
+
// Images open in the in-app viewer; other files (PDF, sheets) need the OS.
|
|
789
|
+
const open = !openable
|
|
790
|
+
? undefined
|
|
791
|
+
: isImage
|
|
792
|
+
? () => onOpenImage({ url: attachment.url, name: attachment.name })
|
|
793
|
+
: () => void openExternalUrl(attachment.url);
|
|
489
794
|
|
|
490
795
|
if (isImage) {
|
|
491
796
|
return (
|
|
492
797
|
<Pressable
|
|
493
798
|
onPress={open}
|
|
799
|
+
disabled={!openable}
|
|
494
800
|
accessibilityRole="imagebutton"
|
|
495
801
|
accessibilityLabel={attachment.name}>
|
|
496
802
|
<Image
|
|
@@ -498,6 +804,9 @@ function AttachmentPreview({
|
|
|
498
804
|
style={styles.attachmentImage}
|
|
499
805
|
resizeMode="cover"
|
|
500
806
|
accessibilityIgnoresInvertColors
|
|
807
|
+
// An image that can't load (expired link, deleted file) falls back to
|
|
808
|
+
// the file row below instead of an empty box.
|
|
809
|
+
onError={() => setImageFailed(true)}
|
|
501
810
|
/>
|
|
502
811
|
</Pressable>
|
|
503
812
|
);
|
|
@@ -506,6 +815,7 @@ function AttachmentPreview({
|
|
|
506
815
|
return (
|
|
507
816
|
<Pressable
|
|
508
817
|
onPress={open}
|
|
818
|
+
disabled={!openable}
|
|
509
819
|
accessibilityRole="button"
|
|
510
820
|
style={[
|
|
511
821
|
styles.attachmentFile,
|
|
@@ -1,7 +1,15 @@
|
|
|
1
|
+
import type { TeamAvatar } from '../../../types';
|
|
1
2
|
/** Props and label bundle for the chat thread. */
|
|
2
3
|
|
|
3
|
-
import type {
|
|
4
|
+
import type { DraftAttachment, PickResult } from '../../../attachments/pickers';
|
|
5
|
+
import type {
|
|
6
|
+
AgentUiAction,
|
|
7
|
+
AttachmentSource,
|
|
8
|
+
Message,
|
|
9
|
+
OutgoingAttachment,
|
|
10
|
+
} from '../../../types';
|
|
4
11
|
import type { AgentUiBlockLabels } from '../../components/AgentUiBlocks';
|
|
12
|
+
import type { ImageViewerLabels } from '../../components/ImageViewer';
|
|
5
13
|
|
|
6
14
|
export interface ChatLabels {
|
|
7
15
|
back: string;
|
|
@@ -18,9 +26,31 @@ export interface ChatLabels {
|
|
|
18
26
|
today: string;
|
|
19
27
|
yesterday: string;
|
|
20
28
|
conversationUpdated: string;
|
|
29
|
+
messageDeleted: string;
|
|
30
|
+
messageSent: string;
|
|
31
|
+
messageSeen: string;
|
|
32
|
+
waitingForTeammate: string;
|
|
33
|
+
joinedConversation: string;
|
|
21
34
|
procedureSend: string;
|
|
22
35
|
procedureSent: string;
|
|
23
36
|
blocks: AgentUiBlockLabels;
|
|
37
|
+
attach: AttachLabels;
|
|
38
|
+
viewer: ImageViewerLabels;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Composer attachment copy. Limits are already filled in; `remove` keeps `{name}`. */
|
|
42
|
+
export interface AttachLabels {
|
|
43
|
+
button: string;
|
|
44
|
+
photoLibrary: string;
|
|
45
|
+
takePhoto: string;
|
|
46
|
+
file: string;
|
|
47
|
+
cancel: string;
|
|
48
|
+
remove: string;
|
|
49
|
+
tooLarge: string;
|
|
50
|
+
tooMany: string;
|
|
51
|
+
tooLargeTogether: string;
|
|
52
|
+
failed: string;
|
|
53
|
+
cameraDenied: string;
|
|
24
54
|
}
|
|
25
55
|
|
|
26
56
|
export interface ChatViewProps {
|
|
@@ -32,12 +62,31 @@ export interface ChatViewProps {
|
|
|
32
62
|
/** Whoever is handling this thread, once the backend assigns someone. */
|
|
33
63
|
agentName?: string | null;
|
|
34
64
|
agentAvatar?: string | null;
|
|
65
|
+
/** The assigned teammate's own presence; falls back to `isOnline` when absent. */
|
|
66
|
+
agentOnline?: boolean | null;
|
|
35
67
|
isOnline: boolean;
|
|
68
|
+
/** Header title with no teammate yet (or after close); falls back to the Team label. */
|
|
69
|
+
teamName?: string | null;
|
|
70
|
+
teamAvatars?: TeamAvatar[];
|
|
71
|
+
/** e.g. "We usually reply within a few minutes", under the team name. */
|
|
72
|
+
replyTimeLabel?: string;
|
|
36
73
|
prefillText?: string;
|
|
37
74
|
offlineMessage?: string;
|
|
38
75
|
locale: string;
|
|
39
76
|
labels: ChatLabels;
|
|
40
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Menu options for the attach button, in order. Empty (or absent) hides the
|
|
79
|
+
* button — attachments are off, or no picker is installed.
|
|
80
|
+
*/
|
|
81
|
+
attachmentSources?: AttachmentSource[];
|
|
82
|
+
/** Opens a picker and returns what fits beside `current`. Never rejects. */
|
|
83
|
+
onPickAttachments?: (source: AttachmentSource, current: DraftAttachment[]) => Promise<PickResult>;
|
|
84
|
+
/** Rejects when the send fails, so the composer can keep its attachments. */
|
|
85
|
+
onSendMessage: (
|
|
86
|
+
body: string,
|
|
87
|
+
attachments?: OutgoingAttachment[],
|
|
88
|
+
action?: AgentUiAction
|
|
89
|
+
) => Promise<void>;
|
|
41
90
|
onIdentifyVisitor?: (data: { email?: string; name?: string; phone?: string }) => Promise<void>;
|
|
42
91
|
onLoadMore: () => void;
|
|
43
92
|
onTyping: (isTyping: boolean) => void;
|