@repzo/widget-react-native 1.0.0 → 1.0.1
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.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 +19 -0
- package/lib/commonjs/i18n.js.map +1 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/locales.js +16 -0
- package/lib/commonjs/locales.js.map +1 -1
- package/lib/commonjs/state/widget-store.js +147 -13
- package/lib/commonjs/state/widget-store.js.map +1 -1
- package/lib/commonjs/types.js +14 -0
- package/lib/commonjs/types.js.map +1 -1
- package/lib/commonjs/ui/WidgetProvider.js +53 -8
- 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/screens/chat/ChatView.js +198 -18
- package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js +19 -0
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/module/api/client.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 +19 -0
- package/lib/module/i18n.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/locales.js +16 -0
- package/lib/module/locales.js.map +1 -1
- package/lib/module/state/widget-store.js +147 -13
- package/lib/module/state/widget-store.js.map +1 -1
- package/lib/module/types.js +14 -0
- package/lib/module/types.js.map +1 -1
- package/lib/module/ui/WidgetProvider.js +53 -8
- 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/screens/chat/ChatView.js +201 -21
- package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.styles.js +19 -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/attachments/pickers.d.ts +81 -0
- package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -0
- package/lib/typescript/commonjs/i18n.d.ts +16 -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 +23 -2
- package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/commonjs/types.d.ts +74 -0
- 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/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 +18 -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 +28 -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/attachments/pickers.d.ts +81 -0
- package/lib/typescript/module/attachments/pickers.d.ts.map +1 -0
- package/lib/typescript/module/i18n.d.ts +16 -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 +23 -2
- package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/module/types.d.ts +74 -0
- 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/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 +18 -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 +28 -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 +2 -2
- package/src/attachments/pickers.ts +515 -0
- package/src/i18n.ts +20 -0
- package/src/index.tsx +4 -0
- package/src/locales.ts +16 -0
- package/src/state/widget-store.ts +152 -16
- package/src/types.ts +79 -0
- package/src/ui/WidgetProvider.tsx +65 -6
- 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/screens/chat/ChatView.styles.ts +19 -0
- package/src/ui/screens/chat/ChatView.tsx +235 -16
- package/src/ui/screens/chat/ChatView.types.ts +38 -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,34 @@ 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, PaperclipIcon } from '../../components/icons';
|
|
34
51
|
import { Markdown } from '../../components/Markdown';
|
|
35
52
|
import { useReduceMotion } from '../../hooks/use-reduce-motion';
|
|
36
53
|
import { globalStyles } from '../../styles/global';
|
|
37
54
|
import { contrastingTextColor, mixHex, palette, withAlpha } from '../../styles/theme';
|
|
38
55
|
import { formatClockTime, formatDayLabel, isSameDay } from '../../utils/conversations';
|
|
39
56
|
import { stripHtml } from '../../utils/html';
|
|
40
|
-
import { openExternalUrl } from '../../utils/safe-url';
|
|
57
|
+
import { isHttpUrl, openExternalUrl } from '../../utils/safe-url';
|
|
41
58
|
import { backDirection, isRTL } from '../../utils/rtl';
|
|
42
59
|
import { styles } from './ChatView.styles';
|
|
43
60
|
import type { ChatLabels, ChatViewProps } from './ChatView.types';
|
|
@@ -55,6 +72,8 @@ export function ChatView({
|
|
|
55
72
|
prefillText,
|
|
56
73
|
offlineMessage,
|
|
57
74
|
labels,
|
|
75
|
+
attachmentSources = [],
|
|
76
|
+
onPickAttachments,
|
|
58
77
|
onSendMessage,
|
|
59
78
|
onIdentifyVisitor,
|
|
60
79
|
onLoadMore,
|
|
@@ -64,20 +83,41 @@ export function ChatView({
|
|
|
64
83
|
}: ChatViewProps) {
|
|
65
84
|
const [input, setInput] = useState(prefillText || '');
|
|
66
85
|
const [composerFocused, setComposerFocused] = useState(false);
|
|
86
|
+
const [drafts, setDrafts] = useState<DraftAttachment[]>([]);
|
|
87
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
88
|
+
const [viewerImage, setViewerImage] = useState<{ url: string; name: string } | null>(null);
|
|
89
|
+
const [picking, setPicking] = useState(false);
|
|
90
|
+
// Only attachment sends hold the composer; text sends stay fire-and-forget.
|
|
91
|
+
const [sending, setSending] = useState(false);
|
|
92
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
67
93
|
const reduceMotion = useReduceMotion();
|
|
68
94
|
const scrollRef = useRef<ScrollView>(null);
|
|
69
95
|
const typingStopTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
96
|
+
const noticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
97
|
+
const pickerTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
98
|
+
const mounted = useRef(false);
|
|
70
99
|
|
|
71
100
|
useEffect(() => {
|
|
72
101
|
if (prefillText) setInput(prefillText);
|
|
73
102
|
}, [prefillText]);
|
|
74
103
|
|
|
75
|
-
|
|
76
|
-
|
|
104
|
+
// The provider keys this screen by the store's thread generation. Pending
|
|
105
|
+
// picks and sends belong to this mount, even before a draft gets a server ID.
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
mounted.current = true;
|
|
108
|
+
return () => {
|
|
109
|
+
mounted.current = false;
|
|
77
110
|
if (typingStopTimer.current) clearTimeout(typingStopTimer.current);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
111
|
+
if (noticeTimer.current) clearTimeout(noticeTimer.current);
|
|
112
|
+
if (pickerTimer.current) clearTimeout(pickerTimer.current);
|
|
113
|
+
};
|
|
114
|
+
}, []);
|
|
115
|
+
|
|
116
|
+
const showNotice = useCallback((message: string | null) => {
|
|
117
|
+
if (noticeTimer.current) clearTimeout(noticeTimer.current);
|
|
118
|
+
setNotice(message);
|
|
119
|
+
if (message) noticeTimer.current = setTimeout(() => setNotice(null), 5000);
|
|
120
|
+
}, []);
|
|
81
121
|
|
|
82
122
|
const scrollToBottom = useCallback((animated = true) => {
|
|
83
123
|
scrollRef.current?.scrollToEnd({ animated });
|
|
@@ -122,16 +162,120 @@ export function ChatView({
|
|
|
122
162
|
[scrollToBottom]
|
|
123
163
|
);
|
|
124
164
|
|
|
125
|
-
const
|
|
165
|
+
const showAttach = attachmentSources.length > 0 && !!onPickAttachments;
|
|
166
|
+
const canSend = !sending && (input.trim().length > 0 || drafts.length > 0);
|
|
167
|
+
|
|
168
|
+
const problemLabel = (problem: AttachmentProblem | null): string | null => {
|
|
169
|
+
switch (problem) {
|
|
170
|
+
case 'tooMany':
|
|
171
|
+
return labels.attach.tooMany;
|
|
172
|
+
case 'tooLarge':
|
|
173
|
+
return labels.attach.tooLarge;
|
|
174
|
+
case 'tooLargeTogether':
|
|
175
|
+
return labels.attach.tooLargeTogether;
|
|
176
|
+
case 'cameraDenied':
|
|
177
|
+
return labels.attach.cameraDenied;
|
|
178
|
+
case 'failed':
|
|
179
|
+
return labels.attach.failed;
|
|
180
|
+
default:
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const handlePick = async (source: AttachmentSource) => {
|
|
186
|
+
if (!mounted.current || !onPickAttachments || picking) return;
|
|
187
|
+
setPicking(true);
|
|
188
|
+
try {
|
|
189
|
+
const result = await onPickAttachments(source, drafts);
|
|
190
|
+
if (!mounted.current) return;
|
|
191
|
+
if (result.added.length > 0) {
|
|
192
|
+
setDrafts((current) => [...current, ...result.added].slice(0, MAX_ATTACHMENTS));
|
|
193
|
+
}
|
|
194
|
+
showNotice(problemLabel(result.problem));
|
|
195
|
+
} finally {
|
|
196
|
+
if (mounted.current) setPicking(false);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const menuOptions = attachmentSources.map((source) => ({
|
|
201
|
+
key: source,
|
|
202
|
+
label:
|
|
203
|
+
source === 'photo'
|
|
204
|
+
? labels.attach.photoLibrary
|
|
205
|
+
: source === 'camera'
|
|
206
|
+
? labels.attach.takePhoto
|
|
207
|
+
: labels.attach.file,
|
|
208
|
+
}));
|
|
209
|
+
|
|
210
|
+
const handleAttachPress = () => {
|
|
211
|
+
if (sending || picking) return;
|
|
212
|
+
if (drafts.length >= MAX_ATTACHMENTS) {
|
|
213
|
+
showNotice(labels.attach.tooMany);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const only = attachmentSources.length === 1 ? attachmentSources[0] : undefined;
|
|
217
|
+
if (only) {
|
|
218
|
+
void handlePick(only);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const native = showNativeAttachmentMenu(menuOptions, labels.attach.cancel, (source) => {
|
|
222
|
+
if (!mounted.current) return;
|
|
223
|
+
// Presenting a picker while the action sheet is still animating away
|
|
224
|
+
// fails silently on iOS; let it finish first.
|
|
225
|
+
pickerTimer.current = setTimeout(() => void handlePick(source), 350);
|
|
226
|
+
});
|
|
227
|
+
if (!native) setMenuOpen(true);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const handleMenuSelect = (source: AttachmentSource) => {
|
|
231
|
+
setMenuOpen(false);
|
|
232
|
+
void handlePick(source);
|
|
233
|
+
};
|
|
126
234
|
|
|
127
235
|
const handleSend = () => {
|
|
128
236
|
const trimmed = input.trim();
|
|
129
|
-
if (!
|
|
237
|
+
if (!canSend) return;
|
|
130
238
|
setInput('');
|
|
131
239
|
if (typingStopTimer.current) clearTimeout(typingStopTimer.current);
|
|
132
240
|
onTyping(false);
|
|
133
|
-
onSendMessage(trimmed);
|
|
134
241
|
scrollToBottom();
|
|
242
|
+
|
|
243
|
+
if (drafts.length === 0) {
|
|
244
|
+
// The failed bubble already surfaces a failure to the visitor.
|
|
245
|
+
onSendMessage(trimmed).catch(() => {});
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const sent = drafts;
|
|
250
|
+
setSending(true);
|
|
251
|
+
showNotice(null);
|
|
252
|
+
onSendMessage(
|
|
253
|
+
trimmed,
|
|
254
|
+
sent.map((draft) => ({
|
|
255
|
+
name: draft.name,
|
|
256
|
+
url: draft.dataUrl,
|
|
257
|
+
contentType: draft.contentType,
|
|
258
|
+
size: draft.size,
|
|
259
|
+
previewUrl: draft.previewUri,
|
|
260
|
+
}))
|
|
261
|
+
)
|
|
262
|
+
.then(() => {
|
|
263
|
+
if (!mounted.current) return;
|
|
264
|
+
const sentIds = new Set(sent.map((draft) => draft.id));
|
|
265
|
+
setDrafts((current) => current.filter((draft) => !sentIds.has(draft.id)));
|
|
266
|
+
})
|
|
267
|
+
.catch((error: unknown) => {
|
|
268
|
+
// The visitor has moved to another thread: nothing to restore there.
|
|
269
|
+
if (!mounted.current) return;
|
|
270
|
+
// Chips stay for a retry; put the text back too unless they've
|
|
271
|
+
// already started typing something else.
|
|
272
|
+
setInput((current) => current || trimmed);
|
|
273
|
+
const status = (error as { status?: unknown } | null)?.status;
|
|
274
|
+
showNotice(status === 413 ? labels.attach.tooLargeTogether : labels.failedToSend);
|
|
275
|
+
})
|
|
276
|
+
.finally(() => {
|
|
277
|
+
if (mounted.current) setSending(false);
|
|
278
|
+
});
|
|
135
279
|
};
|
|
136
280
|
|
|
137
281
|
const handleChangeText = (text: string) => {
|
|
@@ -156,7 +300,7 @@ export function ChatView({
|
|
|
156
300
|
phone: stringPayload(action.payload.phone),
|
|
157
301
|
});
|
|
158
302
|
}
|
|
159
|
-
onSendMessage(formatAgentUiActionMessage(action), undefined, action);
|
|
303
|
+
onSendMessage(formatAgentUiActionMessage(action), undefined, action).catch(() => {});
|
|
160
304
|
scrollToBottom();
|
|
161
305
|
},
|
|
162
306
|
[onIdentifyVisitor, onSendMessage, scrollToBottom]
|
|
@@ -292,6 +436,7 @@ export function ChatView({
|
|
|
292
436
|
message={message}
|
|
293
437
|
color={color}
|
|
294
438
|
labels={labels}
|
|
439
|
+
onOpenImage={setViewerImage}
|
|
295
440
|
senderLabel={
|
|
296
441
|
isAgentMessage && (startsRun || startsDay)
|
|
297
442
|
? message.senderName || agentName || labels.team
|
|
@@ -300,7 +445,7 @@ export function ChatView({
|
|
|
300
445
|
timeLabel={formatClockTime(message.createdAt, locale)}
|
|
301
446
|
onSubmitProcedureForm={
|
|
302
447
|
shouldShowProcedureForm(messages, message)
|
|
303
|
-
? (body) => onSendMessage(body)
|
|
448
|
+
? (body) => void onSendMessage(body).catch(() => {})
|
|
304
449
|
: undefined
|
|
305
450
|
}
|
|
306
451
|
onAgentUiAction={
|
|
@@ -331,12 +476,48 @@ export function ChatView({
|
|
|
331
476
|
) : null}
|
|
332
477
|
|
|
333
478
|
<View style={styles.composerWrapper}>
|
|
479
|
+
{notice ? (
|
|
480
|
+
<Text
|
|
481
|
+
style={styles.composerNotice}
|
|
482
|
+
accessibilityRole="alert"
|
|
483
|
+
accessibilityLiveRegion="polite">
|
|
484
|
+
{notice}
|
|
485
|
+
</Text>
|
|
486
|
+
) : null}
|
|
487
|
+
{drafts.length > 0 ? (
|
|
488
|
+
<AttachmentChips
|
|
489
|
+
attachments={drafts}
|
|
490
|
+
disabled={sending}
|
|
491
|
+
removeLabel={labels.attach.remove}
|
|
492
|
+
onRemove={(id) => {
|
|
493
|
+
setDrafts((current) => current.filter((draft) => draft.id !== id));
|
|
494
|
+
showNotice(null);
|
|
495
|
+
}}
|
|
496
|
+
/>
|
|
497
|
+
) : null}
|
|
334
498
|
<View
|
|
335
499
|
style={[
|
|
336
500
|
styles.composer,
|
|
501
|
+
showAttach && styles.composerWithAttach,
|
|
337
502
|
composerFocused && styles.composerFocused,
|
|
338
503
|
composerFocused && { borderColor: withAlpha(color, 0.55) },
|
|
339
504
|
]}>
|
|
505
|
+
{showAttach ? (
|
|
506
|
+
<Pressable
|
|
507
|
+
onPress={handleAttachPress}
|
|
508
|
+
disabled={sending || picking}
|
|
509
|
+
accessibilityRole="button"
|
|
510
|
+
accessibilityLabel={labels.attach.button}
|
|
511
|
+
accessibilityState={{ disabled: sending || picking }}
|
|
512
|
+
// 36pt circle + 4pt on every side = a 44pt target.
|
|
513
|
+
hitSlop={4}
|
|
514
|
+
style={({ pressed }) => [
|
|
515
|
+
styles.attachButton,
|
|
516
|
+
(pressed || sending || picking) && globalStyles.pressed,
|
|
517
|
+
]}>
|
|
518
|
+
<PaperclipIcon size={20} color={palette.textMuted} />
|
|
519
|
+
</Pressable>
|
|
520
|
+
) : null}
|
|
340
521
|
<TextInput
|
|
341
522
|
value={input}
|
|
342
523
|
onChangeText={handleChangeText}
|
|
@@ -366,6 +547,25 @@ export function ChatView({
|
|
|
366
547
|
</Pressable>
|
|
367
548
|
</View>
|
|
368
549
|
</View>
|
|
550
|
+
|
|
551
|
+
{showAttach && Platform.OS !== 'ios' ? (
|
|
552
|
+
<AttachmentMenu
|
|
553
|
+
visible={menuOpen}
|
|
554
|
+
options={menuOptions}
|
|
555
|
+
cancelLabel={labels.attach.cancel}
|
|
556
|
+
onSelect={handleMenuSelect}
|
|
557
|
+
onClose={() => setMenuOpen(false)}
|
|
558
|
+
/>
|
|
559
|
+
) : null}
|
|
560
|
+
<ImageViewer
|
|
561
|
+
image={viewerImage}
|
|
562
|
+
labels={labels.viewer}
|
|
563
|
+
onClose={() => setViewerImage(null)}
|
|
564
|
+
onOpenExternally={(url) => {
|
|
565
|
+
setViewerImage(null);
|
|
566
|
+
void openExternalUrl(url);
|
|
567
|
+
}}
|
|
568
|
+
/>
|
|
369
569
|
</View>
|
|
370
570
|
);
|
|
371
571
|
}
|
|
@@ -378,6 +578,7 @@ function MessageBubble({
|
|
|
378
578
|
timeLabel,
|
|
379
579
|
onSubmitProcedureForm,
|
|
380
580
|
onAgentUiAction,
|
|
581
|
+
onOpenImage,
|
|
381
582
|
}: {
|
|
382
583
|
message: Message;
|
|
383
584
|
color: string;
|
|
@@ -386,6 +587,7 @@ function MessageBubble({
|
|
|
386
587
|
timeLabel: string;
|
|
387
588
|
onSubmitProcedureForm?: (body: string) => void;
|
|
388
589
|
onAgentUiAction?: (action: AgentUiAction) => void | Promise<void>;
|
|
590
|
+
onOpenImage: (image: { url: string; name: string }) => void;
|
|
389
591
|
}) {
|
|
390
592
|
if (message.messageType === 'system') {
|
|
391
593
|
const text = message.bodyPlain || stripHtml(message.body || '') || labels.conversationUpdated;
|
|
@@ -429,6 +631,7 @@ function MessageBubble({
|
|
|
429
631
|
key={attachment.id}
|
|
430
632
|
attachment={attachment}
|
|
431
633
|
isVisitor={isVisitor}
|
|
634
|
+
onOpenImage={onOpenImage}
|
|
432
635
|
/>
|
|
433
636
|
))}
|
|
434
637
|
</View>
|
|
@@ -479,18 +682,30 @@ function MessageBubble({
|
|
|
479
682
|
function AttachmentPreview({
|
|
480
683
|
attachment,
|
|
481
684
|
isVisitor,
|
|
685
|
+
onOpenImage,
|
|
482
686
|
}: {
|
|
483
687
|
attachment: WidgetAttachment;
|
|
484
688
|
isVisitor: boolean;
|
|
689
|
+
onOpenImage: (image: { url: string; name: string }) => void;
|
|
485
690
|
}) {
|
|
486
691
|
// `url` is empty when the server sent a non-http(s) URL (see resolveUrl).
|
|
487
|
-
|
|
488
|
-
|
|
692
|
+
// An unsent attachment shows its local file (or data URL): it renders, but
|
|
693
|
+
// there is nothing to open until the server copy arrives.
|
|
694
|
+
const [imageFailed, setImageFailed] = useState(false);
|
|
695
|
+
const isImage = isImageAttachment(attachment.contentType) && !!attachment.url && !imageFailed;
|
|
696
|
+
const openable = isHttpUrl(attachment.url);
|
|
697
|
+
// Images open in the in-app viewer; other files (PDF, sheets) need the OS.
|
|
698
|
+
const open = !openable
|
|
699
|
+
? undefined
|
|
700
|
+
: isImage
|
|
701
|
+
? () => onOpenImage({ url: attachment.url, name: attachment.name })
|
|
702
|
+
: () => void openExternalUrl(attachment.url);
|
|
489
703
|
|
|
490
704
|
if (isImage) {
|
|
491
705
|
return (
|
|
492
706
|
<Pressable
|
|
493
707
|
onPress={open}
|
|
708
|
+
disabled={!openable}
|
|
494
709
|
accessibilityRole="imagebutton"
|
|
495
710
|
accessibilityLabel={attachment.name}>
|
|
496
711
|
<Image
|
|
@@ -498,6 +713,9 @@ function AttachmentPreview({
|
|
|
498
713
|
style={styles.attachmentImage}
|
|
499
714
|
resizeMode="cover"
|
|
500
715
|
accessibilityIgnoresInvertColors
|
|
716
|
+
// An image that can't load (expired link, deleted file) falls back to
|
|
717
|
+
// the file row below instead of an empty box.
|
|
718
|
+
onError={() => setImageFailed(true)}
|
|
501
719
|
/>
|
|
502
720
|
</Pressable>
|
|
503
721
|
);
|
|
@@ -506,6 +724,7 @@ function AttachmentPreview({
|
|
|
506
724
|
return (
|
|
507
725
|
<Pressable
|
|
508
726
|
onPress={open}
|
|
727
|
+
disabled={!openable}
|
|
509
728
|
accessibilityRole="button"
|
|
510
729
|
style={[
|
|
511
730
|
styles.attachmentFile,
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
/** Props and label bundle for the chat thread. */
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type { DraftAttachment, PickResult } from '../../../attachments/pickers';
|
|
4
|
+
import type {
|
|
5
|
+
AgentUiAction,
|
|
6
|
+
AttachmentSource,
|
|
7
|
+
Message,
|
|
8
|
+
OutgoingAttachment,
|
|
9
|
+
} from '../../../types';
|
|
4
10
|
import type { AgentUiBlockLabels } from '../../components/AgentUiBlocks';
|
|
11
|
+
import type { ImageViewerLabels } from '../../components/ImageViewer';
|
|
5
12
|
|
|
6
13
|
export interface ChatLabels {
|
|
7
14
|
back: string;
|
|
@@ -21,6 +28,23 @@ export interface ChatLabels {
|
|
|
21
28
|
procedureSend: string;
|
|
22
29
|
procedureSent: string;
|
|
23
30
|
blocks: AgentUiBlockLabels;
|
|
31
|
+
attach: AttachLabels;
|
|
32
|
+
viewer: ImageViewerLabels;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Composer attachment copy. Limits are already filled in; `remove` keeps `{name}`. */
|
|
36
|
+
export interface AttachLabels {
|
|
37
|
+
button: string;
|
|
38
|
+
photoLibrary: string;
|
|
39
|
+
takePhoto: string;
|
|
40
|
+
file: string;
|
|
41
|
+
cancel: string;
|
|
42
|
+
remove: string;
|
|
43
|
+
tooLarge: string;
|
|
44
|
+
tooMany: string;
|
|
45
|
+
tooLargeTogether: string;
|
|
46
|
+
failed: string;
|
|
47
|
+
cameraDenied: string;
|
|
24
48
|
}
|
|
25
49
|
|
|
26
50
|
export interface ChatViewProps {
|
|
@@ -37,7 +61,19 @@ export interface ChatViewProps {
|
|
|
37
61
|
offlineMessage?: string;
|
|
38
62
|
locale: string;
|
|
39
63
|
labels: ChatLabels;
|
|
40
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Menu options for the attach button, in order. Empty (or absent) hides the
|
|
66
|
+
* button — attachments are off, or no picker is installed.
|
|
67
|
+
*/
|
|
68
|
+
attachmentSources?: AttachmentSource[];
|
|
69
|
+
/** Opens a picker and returns what fits beside `current`. Never rejects. */
|
|
70
|
+
onPickAttachments?: (source: AttachmentSource, current: DraftAttachment[]) => Promise<PickResult>;
|
|
71
|
+
/** Rejects when the send fails, so the composer can keep its attachments. */
|
|
72
|
+
onSendMessage: (
|
|
73
|
+
body: string,
|
|
74
|
+
attachments?: OutgoingAttachment[],
|
|
75
|
+
action?: AgentUiAction
|
|
76
|
+
) => Promise<void>;
|
|
41
77
|
onIdentifyVisitor?: (data: { email?: string; name?: string; phone?: string }) => Promise<void>;
|
|
42
78
|
onLoadMore: () => void;
|
|
43
79
|
onTyping: (isTyping: boolean) => void;
|