@repzo/widget-react-native 1.0.9 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +400 -0
- package/README.md +104 -5
- package/lib/commonjs/agent-ui-blocks.js +2 -1
- package/lib/commonjs/agent-ui-blocks.js.map +1 -1
- package/lib/commonjs/api/client.js +25 -0
- package/lib/commonjs/api/client.js.map +1 -1
- package/lib/commonjs/attachments/pickers.js +3 -6
- package/lib/commonjs/attachments/pickers.js.map +1 -1
- package/lib/commonjs/device-id.js +31 -3
- package/lib/commonjs/device-id.js.map +1 -1
- package/lib/commonjs/i18n.js +40 -4
- package/lib/commonjs/i18n.js.map +1 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/locales.js +34 -1
- package/lib/commonjs/locales.js.map +1 -1
- package/lib/commonjs/push/push-notifications.js +9 -8
- package/lib/commonjs/push/push-notifications.js.map +1 -1
- package/lib/commonjs/state/widget-store.js +507 -26
- package/lib/commonjs/state/widget-store.js.map +1 -1
- package/lib/commonjs/storage.js +13 -1
- package/lib/commonjs/storage.js.map +1 -1
- package/lib/commonjs/types.js +20 -2
- package/lib/commonjs/types.js.map +1 -1
- package/lib/commonjs/ui/WidgetProvider.js +138 -49
- package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
- package/lib/commonjs/ui/components/AgentUiBlocks.js +35 -15
- package/lib/commonjs/ui/components/AgentUiBlocks.js.map +1 -1
- package/lib/commonjs/ui/components/Launcher.js +4 -1
- package/lib/commonjs/ui/components/Launcher.js.map +1 -1
- package/lib/commonjs/ui/components/Markdown.js +32 -2
- package/lib/commonjs/ui/components/Markdown.js.map +1 -1
- package/lib/commonjs/ui/components/Post.js +1241 -0
- package/lib/commonjs/ui/components/Post.js.map +1 -0
- package/lib/commonjs/ui/components/RatingCard.js +6 -3
- package/lib/commonjs/ui/components/RatingCard.js.map +1 -1
- package/lib/commonjs/ui/hooks/use-widget-insets.js +0 -1
- package/lib/commonjs/ui/hooks/use-widget-insets.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.js +67 -29
- package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/commonjs/ui/utils/html.js +9 -1
- package/lib/commonjs/ui/utils/html.js.map +1 -1
- package/lib/commonjs/ui/utils/native-gradient.js +0 -1
- package/lib/commonjs/ui/utils/native-gradient.js.map +1 -1
- package/lib/commonjs/ui/utils/post-html.js +276 -0
- package/lib/commonjs/ui/utils/post-html.js.map +1 -0
- package/lib/commonjs/ui/utils/safe-url.js +16 -0
- package/lib/commonjs/ui/utils/safe-url.js.map +1 -1
- package/lib/module/agent-ui-blocks.js +2 -1
- package/lib/module/agent-ui-blocks.js.map +1 -1
- package/lib/module/api/client.js +25 -0
- package/lib/module/api/client.js.map +1 -1
- package/lib/module/attachments/pickers.js +3 -6
- package/lib/module/attachments/pickers.js.map +1 -1
- package/lib/module/device-id.js +31 -3
- package/lib/module/device-id.js.map +1 -1
- package/lib/module/i18n.js +40 -4
- package/lib/module/i18n.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/locales.js +34 -1
- package/lib/module/locales.js.map +1 -1
- package/lib/module/push/push-notifications.js +9 -8
- package/lib/module/push/push-notifications.js.map +1 -1
- package/lib/module/state/widget-store.js +507 -25
- package/lib/module/state/widget-store.js.map +1 -1
- package/lib/module/storage.js +13 -1
- package/lib/module/storage.js.map +1 -1
- package/lib/module/types.js +20 -2
- package/lib/module/types.js.map +1 -1
- package/lib/module/ui/WidgetProvider.js +139 -50
- package/lib/module/ui/WidgetProvider.js.map +1 -1
- package/lib/module/ui/components/AgentUiBlocks.js +35 -15
- package/lib/module/ui/components/AgentUiBlocks.js.map +1 -1
- package/lib/module/ui/components/Launcher.js +4 -1
- package/lib/module/ui/components/Launcher.js.map +1 -1
- package/lib/module/ui/components/Markdown.js +32 -3
- package/lib/module/ui/components/Markdown.js.map +1 -1
- package/lib/module/ui/components/Post.js +1236 -0
- package/lib/module/ui/components/Post.js.map +1 -0
- package/lib/module/ui/components/RatingCard.js +6 -3
- package/lib/module/ui/components/RatingCard.js.map +1 -1
- package/lib/module/ui/hooks/use-widget-insets.js +0 -1
- package/lib/module/ui/hooks/use-widget-insets.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.js +68 -30
- package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/module/ui/utils/html.js +9 -2
- package/lib/module/ui/utils/html.js.map +1 -1
- package/lib/module/ui/utils/native-gradient.js +0 -1
- package/lib/module/ui/utils/native-gradient.js.map +1 -1
- package/lib/module/ui/utils/post-html.js +271 -0
- package/lib/module/ui/utils/post-html.js.map +1 -0
- package/lib/module/ui/utils/safe-url.js +15 -0
- package/lib/module/ui/utils/safe-url.js.map +1 -1
- package/lib/typescript/commonjs/agent-ui-blocks.d.ts.map +1 -1
- package/lib/typescript/commonjs/api/client.d.ts +19 -1
- package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
- package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -1
- package/lib/typescript/commonjs/device-id.d.ts.map +1 -1
- package/lib/typescript/commonjs/i18n.d.ts +35 -1
- 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/push/push-notifications.d.ts +6 -2
- package/lib/typescript/commonjs/push/push-notifications.d.ts.map +1 -1
- package/lib/typescript/commonjs/state/widget-store.d.ts +97 -8
- package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/commonjs/storage.d.ts.map +1 -1
- package/lib/typescript/commonjs/types.d.ts +91 -8
- 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/AgentUiBlocks.d.ts +14 -0
- package/lib/typescript/commonjs/ui/components/AgentUiBlocks.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/Launcher.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/Markdown.d.ts +8 -0
- package/lib/typescript/commonjs/ui/components/Markdown.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/Post.d.ts +69 -0
- package/lib/typescript/commonjs/ui/components/Post.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/RatingCard.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/use-widget-insets.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +10 -0
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/html.d.ts +1 -0
- package/lib/typescript/commonjs/ui/utils/html.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/native-gradient.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/post-html.d.ts +54 -0
- package/lib/typescript/commonjs/ui/utils/post-html.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/utils/safe-url.d.ts +6 -0
- package/lib/typescript/commonjs/ui/utils/safe-url.d.ts.map +1 -1
- package/lib/typescript/module/agent-ui-blocks.d.ts.map +1 -1
- package/lib/typescript/module/api/client.d.ts +19 -1
- package/lib/typescript/module/api/client.d.ts.map +1 -1
- package/lib/typescript/module/attachments/pickers.d.ts.map +1 -1
- package/lib/typescript/module/device-id.d.ts.map +1 -1
- package/lib/typescript/module/i18n.d.ts +35 -1
- 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/push/push-notifications.d.ts +6 -2
- package/lib/typescript/module/push/push-notifications.d.ts.map +1 -1
- package/lib/typescript/module/state/widget-store.d.ts +97 -8
- package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/module/storage.d.ts.map +1 -1
- package/lib/typescript/module/types.d.ts +91 -8
- 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/AgentUiBlocks.d.ts +14 -0
- package/lib/typescript/module/ui/components/AgentUiBlocks.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/Launcher.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/Markdown.d.ts +8 -0
- package/lib/typescript/module/ui/components/Markdown.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/Post.d.ts +69 -0
- package/lib/typescript/module/ui/components/Post.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/RatingCard.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/use-widget-insets.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +10 -0
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/html.d.ts +1 -0
- package/lib/typescript/module/ui/utils/html.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/native-gradient.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/post-html.d.ts +54 -0
- package/lib/typescript/module/ui/utils/post-html.d.ts.map +1 -0
- package/lib/typescript/module/ui/utils/safe-url.d.ts +6 -0
- package/lib/typescript/module/ui/utils/safe-url.d.ts.map +1 -1
- package/package.json +14 -4
- package/src/agent-ui-blocks.ts +2 -1
- package/src/api/client.ts +27 -0
- package/src/attachments/pickers.ts +3 -6
- package/src/device-id.ts +34 -3
- package/src/i18n.ts +39 -2
- package/src/index.tsx +4 -0
- package/src/locales.ts +33 -0
- package/src/push/push-notifications.ts +10 -8
- package/src/state/widget-store.ts +480 -24
- package/src/storage.ts +18 -1
- package/src/types.ts +91 -8
- package/src/ui/WidgetProvider.tsx +166 -48
- package/src/ui/components/AgentUiBlocks.tsx +40 -12
- package/src/ui/components/Launcher.tsx +6 -1
- package/src/ui/components/Markdown.tsx +34 -3
- package/src/ui/components/Post.tsx +943 -0
- package/src/ui/components/RatingCard.tsx +6 -2
- package/src/ui/hooks/use-widget-insets.ts +0 -1
- package/src/ui/screens/chat/ChatView.tsx +77 -42
- package/src/ui/screens/chat/ChatView.types.ts +10 -0
- package/src/ui/utils/html.ts +10 -2
- package/src/ui/utils/native-gradient.ts +0 -1
- package/src/ui/utils/post-html.ts +256 -0
- package/src/ui/utils/safe-url.ts +15 -0
|
@@ -104,8 +104,10 @@ export function RatingCard({
|
|
|
104
104
|
const [comment, setComment] = useState<string | null>(initialComment);
|
|
105
105
|
const [draft, setDraft] = useState('');
|
|
106
106
|
// The comment is optional: "Done" with nothing typed just closes the box —
|
|
107
|
-
// the face (and reason) are already saved.
|
|
108
|
-
|
|
107
|
+
// the face (and reason) are already saved. A card that opens with a saved
|
|
108
|
+
// rating starts closed too, so "Done" sticks when the thread is reopened;
|
|
109
|
+
// tapping the chosen face opens the box again.
|
|
110
|
+
const [finished, setFinished] = useState(initialRating != null);
|
|
109
111
|
const [pending, setPending] = useState(false);
|
|
110
112
|
const [closedReason, setClosedReason] = useState<'locked' | 'expired' | null>(initialComment ? 'locked' : null);
|
|
111
113
|
const [failed, setFailed] = useState(false);
|
|
@@ -124,6 +126,7 @@ export function RatingCard({
|
|
|
124
126
|
setComment(result.comment);
|
|
125
127
|
if (result.locked) setClosedReason('locked');
|
|
126
128
|
setDraft('');
|
|
129
|
+
setFinished(false);
|
|
127
130
|
} catch (error) {
|
|
128
131
|
const status = (error as { status?: number }).status;
|
|
129
132
|
if (status === 409) setClosedReason('locked');
|
|
@@ -162,6 +165,7 @@ export function RatingCard({
|
|
|
162
165
|
hitSlop={4}
|
|
163
166
|
onPress={() => {
|
|
164
167
|
if (!selected) void submit({ rating: entry.value });
|
|
168
|
+
else setFinished(false);
|
|
165
169
|
}}
|
|
166
170
|
style={({ pressed }) => [
|
|
167
171
|
styles.face,
|
|
@@ -25,7 +25,6 @@ const FALLBACK_INSETS: WidgetInsets = {
|
|
|
25
25
|
|
|
26
26
|
let insetsHook: (() => WidgetInsets) | null = null;
|
|
27
27
|
try {
|
|
28
|
-
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
29
28
|
const mod = require('react-native-safe-area-context');
|
|
30
29
|
if (typeof mod?.useSafeAreaInsets === 'function') insetsHook = mod.useSafeAreaInsets;
|
|
31
30
|
} catch {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* `attachments/pickers.ts`).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
|
|
11
|
+
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
12
12
|
import {
|
|
13
13
|
Animated,
|
|
14
14
|
Easing,
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
type AttachmentProblem,
|
|
34
34
|
type DraftAttachment,
|
|
35
35
|
} from '../../../attachments/pickers';
|
|
36
|
+
import { formatLabel } from '../../../i18n';
|
|
36
37
|
import type {
|
|
37
38
|
AgentUiAction,
|
|
38
39
|
AgentProcedureForm,
|
|
@@ -315,20 +316,23 @@ export function ChatView({
|
|
|
315
316
|
[onIdentifyVisitor, onSendMessage, scrollToBottom]
|
|
316
317
|
);
|
|
317
318
|
|
|
319
|
+
const handleSubmitProcedureForm = useCallback(
|
|
320
|
+
(body: string) => void onSendMessage(body).catch(() => {}),
|
|
321
|
+
[onSendMessage]
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
// Recomputed only when the list changes — never on a keystroke.
|
|
325
|
+
const thread = useMemo(() => deriveThread(messages), [messages]);
|
|
326
|
+
const locales = useMemo(() => [locale, defaultLocale], [locale, defaultLocale]);
|
|
327
|
+
|
|
318
328
|
const headerTextColor = contrastingTextColor(color);
|
|
319
329
|
const onDarkHeader = headerTextColor === palette.white;
|
|
320
330
|
const isEmptyThread = !loading && messages.length === 0;
|
|
321
331
|
const agentIsActive = agentOnline ?? isOnline;
|
|
322
332
|
// "Waiting for a teammate" until someone replies: no teammate in the header
|
|
323
333
|
// and the visitor spoke last (bot replies before that still count as waiting).
|
|
324
|
-
const
|
|
325
|
-
.
|
|
326
|
-
.find((message) => (message.messageType ?? 'message') === 'message' && !message.deletedAt);
|
|
327
|
-
const awaitingTeammate = !agentName && !agentTyping && lastChatMessage?.direction === 'inbound';
|
|
328
|
-
// "Sent" / "Seen" goes under the visitor's latest message only.
|
|
329
|
-
const lastVisitorMessageId = [...messages]
|
|
330
|
-
.reverse()
|
|
331
|
-
.find((message) => message.direction === 'inbound' && (message.messageType ?? 'message') === 'message')?.id;
|
|
334
|
+
const awaitingTeammate =
|
|
335
|
+
!agentName && !agentTyping && thread.lastChatMessage?.direction === 'inbound';
|
|
332
336
|
// Two stops off the brand color: flat fill looked like an unstyled block.
|
|
333
337
|
// A non-hex brand color falls through mixHex unchanged and renders solid.
|
|
334
338
|
const topInset = useTopInset();
|
|
@@ -493,21 +497,25 @@ export function ChatView({
|
|
|
493
497
|
labels={labels}
|
|
494
498
|
onOpenImage={setViewerImage}
|
|
495
499
|
onRateConversation={onRateConversation}
|
|
496
|
-
locales={
|
|
500
|
+
locales={locales}
|
|
497
501
|
senderLabel={
|
|
498
502
|
isAgentMessage && (startsRun || startsDay)
|
|
499
503
|
? message.senderName || agentName || labels.team
|
|
500
504
|
: null
|
|
501
505
|
}
|
|
502
506
|
timeLabel={formatClockTime(message.createdAt, locale)}
|
|
503
|
-
receiptLabel={
|
|
507
|
+
receiptLabel={
|
|
508
|
+
message.id === thread.lastVisitorMessageId
|
|
509
|
+
? receiptLabel(message, labels)
|
|
510
|
+
: undefined
|
|
511
|
+
}
|
|
504
512
|
onSubmitProcedureForm={
|
|
505
|
-
|
|
506
|
-
?
|
|
513
|
+
message.id === thread.procedureFormMessageId
|
|
514
|
+
? handleSubmitProcedureForm
|
|
507
515
|
: undefined
|
|
508
516
|
}
|
|
509
517
|
onAgentUiAction={
|
|
510
|
-
|
|
518
|
+
thread.agentBlocksShown.has(message.id) ? handleAgentUiAction : undefined
|
|
511
519
|
}
|
|
512
520
|
/>
|
|
513
521
|
</WidgetErrorBoundary>
|
|
@@ -635,7 +643,11 @@ export function ChatView({
|
|
|
635
643
|
);
|
|
636
644
|
}
|
|
637
645
|
|
|
638
|
-
|
|
646
|
+
/**
|
|
647
|
+
* Memoized: the composer re-renders the screen on every keystroke, and every
|
|
648
|
+
* prop here is stable unless this message itself changed.
|
|
649
|
+
*/
|
|
650
|
+
const MessageBubble = memo(function MessageBubble({
|
|
639
651
|
message,
|
|
640
652
|
color,
|
|
641
653
|
labels,
|
|
@@ -795,7 +807,7 @@ function MessageBubble({
|
|
|
795
807
|
) : null}
|
|
796
808
|
</View>
|
|
797
809
|
);
|
|
798
|
-
}
|
|
810
|
+
});
|
|
799
811
|
|
|
800
812
|
/** Sent once the server has it, Seen once a teammate opened the conversation. */
|
|
801
813
|
function receiptLabel(message: Message, labels: ChatLabels): string | undefined {
|
|
@@ -900,17 +912,21 @@ function ProcedureFieldForm({
|
|
|
900
912
|
style={[styles.procedureToggle, { backgroundColor: expanded ? palette.surface : color }]}>
|
|
901
913
|
<Text
|
|
902
914
|
style={[styles.procedureToggleLabel, { color: expanded ? palette.text : palette.white }]}>
|
|
903
|
-
{submitted ?
|
|
915
|
+
{submitted ? labels.procedureDetailsSent : labels.procedureShareDetails}
|
|
904
916
|
</Text>
|
|
905
917
|
<Text
|
|
906
918
|
style={[styles.procedureToggleHint, { color: expanded ? palette.text : palette.white }]}>
|
|
907
|
-
{expanded
|
|
919
|
+
{expanded
|
|
920
|
+
? labels.procedureHide
|
|
921
|
+
: form.fields.length === 1
|
|
922
|
+
? labels.procedureFieldCountOne
|
|
923
|
+
: formatLabel(labels.procedureFieldCount, { count: form.fields.length })}
|
|
908
924
|
</Text>
|
|
909
925
|
</Pressable>
|
|
910
926
|
|
|
911
927
|
{expanded ? (
|
|
912
928
|
<>
|
|
913
|
-
<Text style={styles.procedureTitle}>{form.procedureName ||
|
|
929
|
+
<Text style={styles.procedureTitle}>{form.procedureName || labels.procedureTitle}</Text>
|
|
914
930
|
<View style={styles.procedureFields}>
|
|
915
931
|
{form.fields.map((field) => (
|
|
916
932
|
<View key={field.name}>
|
|
@@ -923,7 +939,7 @@ function ProcedureFieldForm({
|
|
|
923
939
|
onChangeText={(text) =>
|
|
924
940
|
setValues((current) => ({ ...current, [field.name]: text }))
|
|
925
941
|
}
|
|
926
|
-
placeholder={placeholderForField(field)}
|
|
942
|
+
placeholder={placeholderForField(field, labels)}
|
|
927
943
|
placeholderTextColor={palette.textSubtle}
|
|
928
944
|
editable={!submitted}
|
|
929
945
|
keyboardType={
|
|
@@ -999,9 +1015,9 @@ function TypingDot({ index, reduceMotion }: { index: number; reduceMotion: boole
|
|
|
999
1015
|
return <Animated.View style={[styles.typingDot, motionStyle]} />;
|
|
1000
1016
|
}
|
|
1001
1017
|
|
|
1002
|
-
function placeholderForField(field: AgentProcedureForm['fields'][number]) {
|
|
1003
|
-
if (field.type === 'email') return
|
|
1004
|
-
if (field.type === 'phone') return
|
|
1018
|
+
function placeholderForField(field: AgentProcedureForm['fields'][number], labels: ChatLabels) {
|
|
1019
|
+
if (field.type === 'email') return labels.procedureEmailPlaceholder;
|
|
1020
|
+
if (field.type === 'phone') return labels.procedurePhonePlaceholder;
|
|
1005
1021
|
if (field.type === 'date' || field.type === 'time') return '';
|
|
1006
1022
|
return field.label;
|
|
1007
1023
|
}
|
|
@@ -1010,24 +1026,43 @@ function stringPayload(value: unknown) {
|
|
|
1010
1026
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
1011
1027
|
}
|
|
1012
1028
|
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1029
|
+
/**
|
|
1030
|
+
* Everything the thread derives from the whole list, in one backwards pass.
|
|
1031
|
+
* (Per-message lookups made this O(n²) on every render, keystrokes included.)
|
|
1032
|
+
*/
|
|
1033
|
+
function deriveThread(messages: Message[]) {
|
|
1034
|
+
/** Messages whose agent blocks stay actionable. */
|
|
1035
|
+
const agentBlocksShown = new Set<string>();
|
|
1036
|
+
const laterSlotPickerIds = new Set<string>();
|
|
1037
|
+
let lastChatMessage: Message | undefined;
|
|
1038
|
+
let lastVisitorMessageId: string | undefined;
|
|
1039
|
+
|
|
1040
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
1041
|
+
const message = messages[index]!;
|
|
1042
|
+
const isChat = (message.messageType ?? 'message') === 'message';
|
|
1043
|
+
if (!lastChatMessage && isChat && !message.deletedAt) lastChatMessage = message;
|
|
1044
|
+
// "Sent" / "Seen" goes under the visitor's latest message only.
|
|
1045
|
+
if (!lastVisitorMessageId && isChat && message.direction === 'inbound') {
|
|
1046
|
+
lastVisitorMessageId = message.id;
|
|
1047
|
+
}
|
|
1017
1048
|
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
.
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1049
|
+
const blocks = getAgentUiBlocks(message.metadata);
|
|
1050
|
+
if (blocks.length === 0) continue;
|
|
1051
|
+
// A slot picker a later message re-sent is superseded: only the newest
|
|
1052
|
+
// one stays actionable. Any other block always does.
|
|
1053
|
+
const onlySlotPickers = blocks.every((block) => block.type === 'appointment_slot_picker');
|
|
1054
|
+
if (!onlySlotPickers || blocks.some((block) => !laterSlotPickerIds.has(block.id))) {
|
|
1055
|
+
agentBlocksShown.add(message.id);
|
|
1056
|
+
}
|
|
1057
|
+
blocks.forEach((block) => {
|
|
1058
|
+
if (block.type === 'appointment_slot_picker') laterSlotPickerIds.add(block.id);
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// A procedure form only while it is the newest message in the thread.
|
|
1063
|
+
const last = messages[messages.length - 1];
|
|
1064
|
+
const procedureFormMessageId =
|
|
1065
|
+
last && last.direction !== 'inbound' && getProcedureForm(last.metadata) ? last.id : null;
|
|
1066
|
+
|
|
1067
|
+
return { agentBlocksShown, lastChatMessage, lastVisitorMessageId, procedureFormMessageId };
|
|
1033
1068
|
}
|
|
@@ -35,6 +35,16 @@ export interface ChatLabels {
|
|
|
35
35
|
joinedConversation: string;
|
|
36
36
|
procedureSend: string;
|
|
37
37
|
procedureSent: string;
|
|
38
|
+
procedureShareDetails: string;
|
|
39
|
+
procedureDetailsSent: string;
|
|
40
|
+
procedureHide: string;
|
|
41
|
+
/** `{count}` = number of fields (2 or more). */
|
|
42
|
+
procedureFieldCount: string;
|
|
43
|
+
procedureFieldCountOne: string;
|
|
44
|
+
/** Shown when the procedure has no name. */
|
|
45
|
+
procedureTitle: string;
|
|
46
|
+
procedureEmailPlaceholder: string;
|
|
47
|
+
procedurePhonePlaceholder: string;
|
|
38
48
|
blocks: AgentUiBlockLabels;
|
|
39
49
|
rating: RatingCardLabels;
|
|
40
50
|
attach: AttachLabels;
|
package/src/ui/utils/html.ts
CHANGED
|
@@ -14,10 +14,18 @@ const ENTITIES: Record<string, string> = {
|
|
|
14
14
|
' ': ' ',
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
/** `😀` / `😀` → 😀. Out-of-range or surrogate code points stay as written. */
|
|
18
|
+
function decodeNumericEntity(match: string, decimal?: string, hex?: string): string {
|
|
19
|
+
const code = decimal !== undefined ? Number(decimal) : parseInt(hex ?? '', 16);
|
|
20
|
+
const valid =
|
|
21
|
+
Number.isInteger(code) && code > 0 && code <= 0x10ffff && (code < 0xd800 || code > 0xdfff);
|
|
22
|
+
return valid ? String.fromCodePoint(code) : match;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function decodeEntities(text: string): string {
|
|
18
26
|
return text
|
|
19
27
|
.replace(/&(amp|lt|gt|quot|#39|apos|nbsp);/g, (match) => ENTITIES[match] ?? match)
|
|
20
|
-
.replace(/&#(\d
|
|
28
|
+
.replace(/&#(?:(\d{1,7})|[xX]([0-9a-fA-F]{1,6}));/g, decodeNumericEntity);
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
/** Block-level tags become newlines so paragraphs survive the strip. */
|
|
@@ -29,7 +29,6 @@ export interface NativeGradientProps {
|
|
|
29
29
|
let nativeGradient: ComponentType<NativeGradientProps> | null = null;
|
|
30
30
|
|
|
31
31
|
try {
|
|
32
|
-
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
33
32
|
const mod = require('react-native-linear-gradient');
|
|
34
33
|
const component = mod?.default ?? mod?.LinearGradient;
|
|
35
34
|
if (component) nativeGradient = component as ComponentType<NativeGradientProps>;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Send post HTML → blocks a React Native view can draw.
|
|
3
|
+
*
|
|
4
|
+
* The CRM renders and sanitizes the post before sending it, down to a flat
|
|
5
|
+
* set: `<p>` with `<strong>` / `<em>` / `<code>` / `<a href>` / `<br>`,
|
|
6
|
+
* `<ul>` / `<ol>` of `<li>`, `<pre><code>`, a button
|
|
7
|
+
* (`<div data-post-button data-align>` around one link) and social links
|
|
8
|
+
* (`<div data-post-social data-align>` of `<a data-network>`). React Native
|
|
9
|
+
* has no HTML renderer, so this turns that subset into blocks. Unknown tags
|
|
10
|
+
* keep their text. A link survives when it's one the host could route
|
|
11
|
+
* (`myapp://…` through `onOpenUrl`) or the allow-list would open; tapping it
|
|
12
|
+
* goes through `openExternalUrl` like every other widget link.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { decodeEntities } from './html';
|
|
16
|
+
import { isRoutableUrl } from './safe-url';
|
|
17
|
+
|
|
18
|
+
export interface PostTextRun {
|
|
19
|
+
text: string;
|
|
20
|
+
bold: boolean;
|
|
21
|
+
italic: boolean;
|
|
22
|
+
code: boolean;
|
|
23
|
+
/** Set when the run is a link the SDK may open. */
|
|
24
|
+
href: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type PostAlign = 'left' | 'center' | 'right';
|
|
28
|
+
|
|
29
|
+
export const POST_SOCIAL_NETWORKS = ['facebook', 'x', 'linkedin', 'instagram', 'youtube'] as const;
|
|
30
|
+
export type PostSocialNetwork = (typeof POST_SOCIAL_NETWORKS)[number];
|
|
31
|
+
|
|
32
|
+
export type PostBlock =
|
|
33
|
+
| { type: 'paragraph'; runs: PostTextRun[] }
|
|
34
|
+
| {
|
|
35
|
+
type: 'list';
|
|
36
|
+
ordered: boolean;
|
|
37
|
+
items: PostTextRun[][];
|
|
38
|
+
/** Per item: how deep it's nested (0 = top level) and its own list's kind. */
|
|
39
|
+
levels: Array<{ depth: number; ordered: boolean }>;
|
|
40
|
+
}
|
|
41
|
+
| { type: 'code'; text: string }
|
|
42
|
+
| { type: 'button'; label: string; href: string; align: PostAlign }
|
|
43
|
+
| { type: 'social'; align: PostAlign; links: Array<{ network: PostSocialNetwork; href: string }> };
|
|
44
|
+
|
|
45
|
+
const TOKEN = /<(\/?)([a-z][a-z0-9]*)([^>]*)>|([^<]+)/gi;
|
|
46
|
+
|
|
47
|
+
function attr(attrs: string, name: string): string | null {
|
|
48
|
+
// Whole names only: `href` must not match inside `data-href`.
|
|
49
|
+
const found = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i').exec(attrs);
|
|
50
|
+
return found ? decodeEntities(found[1] ?? found[2] ?? '').trim() : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const hasAttr = (attrs: string, name: string) => new RegExp(`(^|\\s)${name}(\\s|=|$)`, 'i').test(attrs);
|
|
54
|
+
|
|
55
|
+
function alignOf(attrs: string): PostAlign {
|
|
56
|
+
const value = attr(attrs, 'data-align');
|
|
57
|
+
return value === 'center' || value === 'right' ? value : 'left';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function safeHref(attrs: string): string | null {
|
|
61
|
+
const href = attr(attrs, 'href') ?? '';
|
|
62
|
+
return isRoutableUrl(href) ? href : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Merges neighbouring runs that look the same and trims the outer whitespace. */
|
|
66
|
+
function tidy(runs: PostTextRun[]): PostTextRun[] {
|
|
67
|
+
const out: PostTextRun[] = [];
|
|
68
|
+
for (const run of runs) {
|
|
69
|
+
const last = out[out.length - 1];
|
|
70
|
+
if (last && last.bold === run.bold && last.italic === run.italic && last.code === run.code && last.href === run.href) {
|
|
71
|
+
last.text += run.text;
|
|
72
|
+
} else {
|
|
73
|
+
out.push({ ...run });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (out.length) {
|
|
77
|
+
out[0].text = out[0].text.replace(/^[ \t]+/, '');
|
|
78
|
+
out[out.length - 1].text = out[out.length - 1].text.replace(/[ \t]+$/, '');
|
|
79
|
+
}
|
|
80
|
+
return out.filter((run) => run.text.length > 0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function parsePostHtml(html: string): PostBlock[] {
|
|
84
|
+
const blocks: PostBlock[] = [];
|
|
85
|
+
let bold = 0;
|
|
86
|
+
let italic = 0;
|
|
87
|
+
let code = 0;
|
|
88
|
+
const links: Array<string | null> = [];
|
|
89
|
+
|
|
90
|
+
// What's being built right now.
|
|
91
|
+
let runs: PostTextRun[] = [];
|
|
92
|
+
// The top-level list being built, and the lists open inside it (nested lists stay in it, indented).
|
|
93
|
+
let list: { ordered: boolean; items: PostTextRun[][]; levels: Array<{ depth: number; ordered: boolean }> } | null = null;
|
|
94
|
+
const openLists: boolean[] = [];
|
|
95
|
+
let itemDepth = 0;
|
|
96
|
+
let pre: string | null = null;
|
|
97
|
+
let button: { href: string | null; align: PostAlign; label: string } | null = null;
|
|
98
|
+
let social: { align: PostAlign; links: Array<{ network: PostSocialNetwork; href: string }> } | null = null;
|
|
99
|
+
|
|
100
|
+
const flushParagraph = () => {
|
|
101
|
+
const tidied = tidy(runs);
|
|
102
|
+
runs = [];
|
|
103
|
+
if (tidied.length) blocks.push({ type: 'paragraph', runs: tidied });
|
|
104
|
+
};
|
|
105
|
+
const flushItem = () => {
|
|
106
|
+
if (!list) return;
|
|
107
|
+
const tidied = tidy(runs);
|
|
108
|
+
runs = [];
|
|
109
|
+
if (tidied.length) {
|
|
110
|
+
list.items.push(tidied);
|
|
111
|
+
list.levels.push({ depth: openLists.length - 1, ordered: openLists[openLists.length - 1] ?? list.ordered });
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const endList = () => {
|
|
115
|
+
if (list && list.items.length) blocks.push({ type: 'list', ...list });
|
|
116
|
+
list = null;
|
|
117
|
+
openLists.length = 0;
|
|
118
|
+
itemDepth = 0;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const pushText = (text: string) => {
|
|
122
|
+
if (!text) return;
|
|
123
|
+
if (pre !== null) {
|
|
124
|
+
pre += text;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (button) {
|
|
128
|
+
button.label += text;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (social) return;
|
|
132
|
+
// Text between list items is only formatting whitespace.
|
|
133
|
+
if (list && itemDepth === 0) return;
|
|
134
|
+
runs.push({
|
|
135
|
+
text: text.replace(/\s+/g, ' '),
|
|
136
|
+
bold: bold > 0,
|
|
137
|
+
italic: italic > 0,
|
|
138
|
+
code: code > 0,
|
|
139
|
+
href: links.length ? links[links.length - 1] : null,
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
for (const match of (html || '').matchAll(TOKEN)) {
|
|
144
|
+
const [, closing, rawTag, attrs = '', text] = match;
|
|
145
|
+
if (text !== undefined) {
|
|
146
|
+
pushText(decodeEntities(text));
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const tag = rawTag.toLowerCase();
|
|
150
|
+
const open = !closing;
|
|
151
|
+
|
|
152
|
+
if (pre !== null && tag !== 'pre') continue; // Only text inside a code block.
|
|
153
|
+
|
|
154
|
+
switch (tag) {
|
|
155
|
+
case 'p':
|
|
156
|
+
if (list) {
|
|
157
|
+
// Lists from the editor wrap items in <p>: keep it one line per item.
|
|
158
|
+
if (!open) runs.push({ text: ' ', bold: false, italic: false, code: false, href: null });
|
|
159
|
+
} else {
|
|
160
|
+
flushParagraph();
|
|
161
|
+
}
|
|
162
|
+
break;
|
|
163
|
+
case 'br':
|
|
164
|
+
if (!social && !button) runs.push({ text: '\n', bold: bold > 0, italic: italic > 0, code: code > 0, href: null });
|
|
165
|
+
break;
|
|
166
|
+
case 'strong':
|
|
167
|
+
case 'b':
|
|
168
|
+
bold = Math.max(0, bold + (open ? 1 : -1));
|
|
169
|
+
break;
|
|
170
|
+
case 'em':
|
|
171
|
+
case 'i':
|
|
172
|
+
italic = Math.max(0, italic + (open ? 1 : -1));
|
|
173
|
+
break;
|
|
174
|
+
case 'code':
|
|
175
|
+
code = Math.max(0, code + (open ? 1 : -1));
|
|
176
|
+
break;
|
|
177
|
+
case 'ul':
|
|
178
|
+
case 'ol':
|
|
179
|
+
if (open) {
|
|
180
|
+
if (list) {
|
|
181
|
+
// A list inside an item: what the item said so far is its line.
|
|
182
|
+
flushItem();
|
|
183
|
+
} else {
|
|
184
|
+
flushParagraph();
|
|
185
|
+
list = { ordered: tag === 'ol', items: [], levels: [] };
|
|
186
|
+
}
|
|
187
|
+
openLists.push(tag === 'ol');
|
|
188
|
+
} else if (list) {
|
|
189
|
+
flushItem();
|
|
190
|
+
openLists.pop();
|
|
191
|
+
if (openLists.length === 0) endList();
|
|
192
|
+
}
|
|
193
|
+
break;
|
|
194
|
+
case 'li':
|
|
195
|
+
flushItem();
|
|
196
|
+
if (list) itemDepth = Math.max(0, itemDepth + (open ? 1 : -1));
|
|
197
|
+
break;
|
|
198
|
+
case 'pre':
|
|
199
|
+
if (open) {
|
|
200
|
+
flushParagraph();
|
|
201
|
+
pre = '';
|
|
202
|
+
} else if (pre !== null) {
|
|
203
|
+
const textBlock = pre.replace(/^\n+|\n+$/g, '');
|
|
204
|
+
if (textBlock) blocks.push({ type: 'code', text: textBlock });
|
|
205
|
+
pre = null;
|
|
206
|
+
}
|
|
207
|
+
break;
|
|
208
|
+
case 'div':
|
|
209
|
+
if (open && hasAttr(attrs, 'data-post-button')) {
|
|
210
|
+
flushParagraph();
|
|
211
|
+
button = { href: null, align: alignOf(attrs), label: '' };
|
|
212
|
+
} else if (open && hasAttr(attrs, 'data-post-social')) {
|
|
213
|
+
flushParagraph();
|
|
214
|
+
social = { align: alignOf(attrs), links: [] };
|
|
215
|
+
} else if (!open) {
|
|
216
|
+
if (button) {
|
|
217
|
+
const label = button.label.replace(/\s+/g, ' ').trim();
|
|
218
|
+
if (label && button.href) blocks.push({ type: 'button', label, href: button.href, align: button.align });
|
|
219
|
+
button = null;
|
|
220
|
+
} else if (social) {
|
|
221
|
+
if (social.links.length) blocks.push({ type: 'social', align: social.align, links: social.links });
|
|
222
|
+
social = null;
|
|
223
|
+
} else {
|
|
224
|
+
flushParagraph();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
break;
|
|
228
|
+
case 'a':
|
|
229
|
+
if (button) {
|
|
230
|
+
if (open) button.href = safeHref(attrs);
|
|
231
|
+
} else if (social) {
|
|
232
|
+
if (open) {
|
|
233
|
+
const network = attr(attrs, 'data-network') as PostSocialNetwork | null;
|
|
234
|
+
const href = safeHref(attrs);
|
|
235
|
+
if (network && (POST_SOCIAL_NETWORKS as readonly string[]).includes(network) && href) {
|
|
236
|
+
social.links.push({ network, href });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
} else if (open) {
|
|
240
|
+
links.push(safeHref(attrs));
|
|
241
|
+
} else {
|
|
242
|
+
links.pop();
|
|
243
|
+
}
|
|
244
|
+
break;
|
|
245
|
+
default:
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (list) {
|
|
250
|
+
flushItem();
|
|
251
|
+
endList();
|
|
252
|
+
}
|
|
253
|
+
flushParagraph();
|
|
254
|
+
return blocks;
|
|
255
|
+
}
|
|
256
|
+
|
package/src/ui/utils/safe-url.ts
CHANGED
|
@@ -42,6 +42,21 @@ export function isSafeExternalUrl(url: string): boolean {
|
|
|
42
42
|
return scheme === 'http:' || scheme === 'https:' ? isHttpUrl(url) : true;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** Schemes no host would route — content carrying them never shows as a link. */
|
|
46
|
+
const NEVER_ROUTED_SCHEMES = new Set(['javascript:', 'vbscript:', 'data:', 'file:', 'blob:']);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Worth drawing as a link: absolute, with a scheme the host could route
|
|
50
|
+
* through `onOpenUrl` (`myapp://…`) or the allow-list would open. Tapping it
|
|
51
|
+
* still goes through `openExternalUrl`, which makes the actual call.
|
|
52
|
+
*/
|
|
53
|
+
export function isRoutableUrl(url: string): boolean {
|
|
54
|
+
if (!url || CONTROL_CHARS.test(url)) return false;
|
|
55
|
+
const scheme = urlScheme(url);
|
|
56
|
+
if (!scheme || NEVER_ROUTED_SCHEMES.has(scheme)) return false;
|
|
57
|
+
return scheme === 'http:' || scheme === 'https:' ? isHttpUrl(url) : true;
|
|
58
|
+
}
|
|
59
|
+
|
|
45
60
|
/** Host handler first, then the allow-list. Never throws. */
|
|
46
61
|
export async function openExternalUrl(url: string | null | undefined): Promise<void> {
|
|
47
62
|
const target = (url ?? '').trim();
|