@giovchat/react-native 0.2.0 → 0.3.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/README.md +66 -9
- package/dist/GiovChat.d.ts +35 -0
- package/dist/GiovChat.d.ts.map +1 -1
- package/dist/GiovChat.js +45 -8
- package/dist/GiovChat.js.map +1 -1
- package/dist/brandingStyle.d.ts +38 -0
- package/dist/brandingStyle.d.ts.map +1 -0
- package/dist/brandingStyle.js +71 -0
- package/dist/brandingStyle.js.map +1 -0
- package/dist/client.d.ts +38 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +91 -1
- package/dist/client.js.map +1 -1
- package/dist/components/Branding.d.ts +24 -1
- package/dist/components/Branding.d.ts.map +1 -1
- package/dist/components/Branding.js +49 -6
- package/dist/components/Branding.js.map +1 -1
- package/dist/components/MessageBubble.d.ts +8 -1
- package/dist/components/MessageBubble.d.ts.map +1 -1
- package/dist/components/MessageBubble.js +19 -17
- package/dist/components/MessageBubble.js.map +1 -1
- package/dist/components/RatingCard.d.ts +23 -0
- package/dist/components/RatingCard.d.ts.map +1 -0
- package/dist/components/RatingCard.js +107 -0
- package/dist/components/RatingCard.js.map +1 -0
- package/dist/errors.d.ts +3 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/i18n.d.ts +23 -0
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +100 -0
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +12 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -1
- package/dist/index.js.map +1 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +5 -0
- package/dist/protocol.js.map +1 -1
- package/dist/rating.d.ts +41 -0
- package/dist/rating.d.ts.map +1 -0
- package/dist/rating.js +165 -0
- package/dist/rating.js.map +1 -0
- package/dist/ratingStore.d.ts +36 -0
- package/dist/ratingStore.d.ts.map +1 -0
- package/dist/ratingStore.js +55 -0
- package/dist/ratingStore.js.map +1 -0
- package/dist/styleOverrides.d.ts +10 -2
- package/dist/styleOverrides.d.ts.map +1 -1
- package/dist/styleOverrides.js +9 -0
- package/dist/styleOverrides.js.map +1 -1
- package/dist/types.d.ts +45 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/useChatSession.d.ts +18 -1
- package/dist/useChatSession.d.ts.map +1 -1
- package/dist/useChatSession.js +178 -9
- package/dist/useChatSession.js.map +1 -1
- package/package.json +3 -2
- package/src/GiovChat.tsx +121 -11
- package/src/brandingStyle.ts +87 -0
- package/src/client.ts +92 -2
- package/src/components/Branding.tsx +66 -7
- package/src/components/MessageBubble.tsx +17 -1
- package/src/components/RatingCard.tsx +219 -0
- package/src/errors.ts +4 -1
- package/src/i18n.ts +123 -0
- package/src/index.ts +17 -0
- package/src/protocol.ts +5 -0
- package/src/rating.ts +169 -0
- package/src/ratingStore.ts +65 -0
- package/src/styleOverrides.ts +15 -2
- package/src/types.ts +51 -0
- package/src/useChatSession.ts +152 -6
package/src/GiovChat.tsx
CHANGED
|
@@ -42,6 +42,9 @@ import {
|
|
|
42
42
|
} from "./attachments";
|
|
43
43
|
import type { GiovPushOptions, GiovPushPlatform, GiovPushRegistration } from "./client";
|
|
44
44
|
import { forgetUnreadCount, publishUnreadCount } from "./unread";
|
|
45
|
+
import { RatingCard } from "./components/RatingCard";
|
|
46
|
+
import { forgetRating, publishRating } from "./ratingStore";
|
|
47
|
+
import type { GiovAnswerFeedback, GiovFeedbackValue, GiovRatingRequest, GiovRatingResult } from "./types";
|
|
45
48
|
|
|
46
49
|
// ─── Public prop types ─────────────────────────────────────────────────────
|
|
47
50
|
|
|
@@ -125,9 +128,39 @@ export interface GiovChatProps {
|
|
|
125
128
|
sessionStorage?: GiovChatSessionStorage;
|
|
126
129
|
onError?: (error: GiovChatError) => void;
|
|
127
130
|
onConnectionStatusChange?: (status: GiovChatConnectionStatus) => void;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Draw the conversation rating card ("How did we do?") in the chat. Turn it off to build your own with
|
|
134
|
+
* `useGiovRating()` or the handle (`submitRating`, `showRating`).
|
|
135
|
+
* @default true
|
|
136
|
+
*/
|
|
137
|
+
ratingCard?: boolean;
|
|
138
|
+
/**
|
|
139
|
+
* Thumbs under GiovBot answers (hidden on servers without them).
|
|
140
|
+
* @default true
|
|
141
|
+
*/
|
|
142
|
+
answerFeedback?: boolean;
|
|
143
|
+
/** The team or GiovBot asked the visitor to rate the conversation (once per card, live or from history). */
|
|
144
|
+
onRatingRequested?: (request: GiovRatingRequest) => void;
|
|
145
|
+
/** The visitor's rating was stored. */
|
|
146
|
+
onRatingSubmitted?: (result: GiovRatingResult) => void;
|
|
147
|
+
/** The visitor's vote on a GiovBot answer was stored. */
|
|
148
|
+
onAnswerFeedback?: (feedback: GiovAnswerFeedback) => void;
|
|
128
149
|
}
|
|
129
150
|
|
|
130
151
|
export interface GiovChatHandle {
|
|
152
|
+
/**
|
|
153
|
+
* Rates the conversation (`requestId` defaults to the open card). Rejects with a `GiovChatError`
|
|
154
|
+
* (`rating_closed`, `request_replaced`, `too_many_changes`, `invalid_rating`, `rate_limited` with
|
|
155
|
+
* `retryAfterSeconds`, `unsupported` on an older server).
|
|
156
|
+
*/
|
|
157
|
+
submitRating(score: number, options?: { comment?: string; requestId?: string }): Promise<GiovRatingResult>;
|
|
158
|
+
/** Thumbs up or down (with an optional reason for down) on a GiovBot answer; `none` takes the vote back. */
|
|
159
|
+
submitAnswerFeedback(messageId: string, value: GiovFeedbackValue, options?: { reason?: string }): Promise<void>;
|
|
160
|
+
/** The rating card the visitor can answer or change now, or null. */
|
|
161
|
+
getRatingRequest(): GiovRatingRequest | null;
|
|
162
|
+
/** Opens the built-in rating sheet for the open card, over the chat or on its own. False when there is no card. */
|
|
163
|
+
showRating(): boolean;
|
|
131
164
|
/** Clears this identity's stored session and starts fresh if the chat is visible. */
|
|
132
165
|
reset(): Promise<void>;
|
|
133
166
|
/**
|
|
@@ -198,6 +231,11 @@ const GiovChatInstance = React.forwardRef<GiovChatHandle, GiovChatProps>(functio
|
|
|
198
231
|
sessionStorage,
|
|
199
232
|
onError,
|
|
200
233
|
onConnectionStatusChange,
|
|
234
|
+
ratingCard = true,
|
|
235
|
+
answerFeedback = true,
|
|
236
|
+
onRatingRequested,
|
|
237
|
+
onRatingSubmitted,
|
|
238
|
+
onAnswerFeedback,
|
|
201
239
|
}: GiovChatProps, ref) {
|
|
202
240
|
const systemScheme = useColorScheme();
|
|
203
241
|
const custom = useMemo(() => resolveStyleOverrides(styleOverrides), [styleOverrides]);
|
|
@@ -207,18 +245,37 @@ const GiovChatInstance = React.forwardRef<GiovChatHandle, GiovChatProps>(functio
|
|
|
207
245
|
const chat = useChatSession({
|
|
208
246
|
tenantKey, baseUrl, visible, contact, scope, sessionKey, sessionStorage, keepConnected,
|
|
209
247
|
onError, onConnectionStatusChange, onConversationStart, onUnreadCountChange,
|
|
248
|
+
onRatingRequested, onRatingSubmitted, onAnswerFeedback,
|
|
210
249
|
});
|
|
211
250
|
const { phase, config, messages, agentTyping, agentTypingIsBot, error, sending, uploads, requiredFields, retry, reset, submitContact, send } = chat;
|
|
212
251
|
const [input, setInput] = useState("");
|
|
213
252
|
const [files, setFiles] = useState<GiovChatPickedFile[]>([]);
|
|
214
253
|
const [fileProblem, setFileProblem] = useState<string | null>(null);
|
|
254
|
+
const [sheetOpen, setSheetOpen] = useState(false);
|
|
215
255
|
const listRef = useRef<FlatList<GiovMessage>>(null);
|
|
216
256
|
const unreadOwner = useRef(Symbol("giovchat-unread"));
|
|
257
|
+
// The calls below read the latest state through a ref, so the handle and the hook entry stay stable.
|
|
258
|
+
const live = useRef(chat);
|
|
259
|
+
live.current = chat;
|
|
260
|
+
const ratingCalls = useMemo(() => ({
|
|
261
|
+
submit: (score: number, options?: { comment?: string; requestId?: string }) => live.current.submitRating(score, options),
|
|
262
|
+
submitFeedback: (messageId: string, value: GiovFeedbackValue, options?: { reason?: string }) => live.current.submitAnswerFeedback(messageId, value, options),
|
|
263
|
+
show: () => { if (!live.current.ratingRequest) return false; setSheetOpen(true); return true; },
|
|
264
|
+
}), []);
|
|
265
|
+
const ratingOwner = useRef(Symbol("giovchat-rating"));
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
publishRating(ratingOwner.current, tenantKey, { request: chat.ratingRequest, supported: chat.ratingsSupported, ...ratingCalls });
|
|
268
|
+
}, [tenantKey, chat.ratingRequest, chat.ratingsSupported, ratingCalls]);
|
|
269
|
+
useEffect(() => () => forgetRating(ratingOwner.current), []);
|
|
217
270
|
useImperativeHandle(ref, () => ({
|
|
271
|
+
submitRating: ratingCalls.submit,
|
|
272
|
+
submitAnswerFeedback: ratingCalls.submitFeedback,
|
|
273
|
+
getRatingRequest: () => live.current.ratingRequest,
|
|
274
|
+
showRating: ratingCalls.show,
|
|
218
275
|
reset: async () => { setInput(""); setFiles([]); await reset(); },
|
|
219
276
|
registerPushToken: (token, platform, options) => chat.registerPushToken(token, platform, { ...(locale ? { locale } : {}), ...options }),
|
|
220
277
|
unregisterPushToken: chat.unregisterPushToken,
|
|
221
|
-
}), [reset, chat.registerPushToken, chat.unregisterPushToken, locale]);
|
|
278
|
+
}), [reset, chat.registerPushToken, chat.unregisterPushToken, locale, ratingCalls]);
|
|
222
279
|
useEffect(() => {
|
|
223
280
|
const timer = setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 80);
|
|
224
281
|
return () => clearTimeout(timer);
|
|
@@ -242,7 +299,7 @@ const GiovChatInstance = React.forwardRef<GiovChatHandle, GiovChatProps>(functio
|
|
|
242
299
|
const handleClose = useCallback(() => onClose?.(), [onClose]);
|
|
243
300
|
const policy = config?.attachments ?? DEFAULT_ATTACHMENT_POLICY;
|
|
244
301
|
// Uploads need a conversation: the first message starts it, then files can follow.
|
|
245
|
-
const canAttach = !!attachmentPicker && chat.hasConversation && policy.enabled && policy.allowedTypes.length > 0;
|
|
302
|
+
const canAttach = !!attachmentPicker && chat.hasConversation && !chat.closed && policy.enabled && policy.allowedTypes.length > 0;
|
|
246
303
|
const composing = phase === "ready" || phase === "connecting" || phase === "active";
|
|
247
304
|
const canSend = composing && !sending && (!!input.trim() || files.length > 0);
|
|
248
305
|
const handleSend = async () => {
|
|
@@ -265,6 +322,14 @@ const GiovChatInstance = React.forwardRef<GiovChatHandle, GiovChatProps>(functio
|
|
|
265
322
|
if (problems.length) setFileProblem(problems.map((problem) => problemText(problem, strings)).join(" "));
|
|
266
323
|
} catch { /* The picker was dismissed or failed; nothing changes. */ }
|
|
267
324
|
};
|
|
325
|
+
const onRateCard = useCallback(async (requestId: string, score: number, comment: string) => {
|
|
326
|
+
await live.current.submitRating(score, { requestId, comment });
|
|
327
|
+
}, []);
|
|
328
|
+
const onVote = useCallback((messageId: string, value: GiovFeedbackValue, reason?: string) =>
|
|
329
|
+
live.current.submitAnswerFeedback(messageId, value, { reason }), []);
|
|
330
|
+
const sheetMessage = sheetOpen && chat.ratingRequest ? messages.find((message) => message.id === chat.ratingRequest?.messageId) : undefined;
|
|
331
|
+
// The sheet closes by itself once there is no card to show (answered cards stay until dismissed).
|
|
332
|
+
useEffect(() => { if (sheetOpen && !chat.ratingRequest) setSheetOpen(false); }, [sheetOpen, chat.ratingRequest]);
|
|
268
333
|
const identity = config ? headerIdentity(config, chat.currentAgentName, chat.currentAgentAvatar) : null;
|
|
269
334
|
const subtitle = phase === "active" || phase === "ready"
|
|
270
335
|
? identity?.kind === "person" ? strings.statusPerson : identity?.kind === "bot" ? strings.statusBot : strings.statusTeam
|
|
@@ -352,6 +417,9 @@ const GiovChatInstance = React.forwardRef<GiovChatHandle, GiovChatProps>(functio
|
|
|
352
417
|
resolveAttachmentUrl={chat.attachmentUrl}
|
|
353
418
|
onRetry={(message) => message.clientMessageId && chat.retryMessage(message.clientMessageId)}
|
|
354
419
|
onDiscard={(message) => message.clientMessageId && chat.discardMessage(message.clientMessageId)}
|
|
420
|
+
ratingCard={ratingCard}
|
|
421
|
+
onRate={chat.ratingsSupported ? onRateCard : undefined}
|
|
422
|
+
onVote={answerFeedback && chat.ratingsSupported ? onVote : undefined}
|
|
355
423
|
/>
|
|
356
424
|
)}
|
|
357
425
|
/>
|
|
@@ -384,6 +452,20 @@ const GiovChatInstance = React.forwardRef<GiovChatHandle, GiovChatProps>(functio
|
|
|
384
452
|
</View>
|
|
385
453
|
)}
|
|
386
454
|
|
|
455
|
+
{chat.closed ? (
|
|
456
|
+
<View style={[styles.closedBar, { borderTopColor: isDark ? "#1e1e1e" : "#ebebeb", backgroundColor: bgColor }, custom.composer]}>
|
|
457
|
+
<Text style={[styles.hint, { color: isDark ? "#aaa" : "#555" }, custom.hint]}>{strings.conversationEnded}</Text>
|
|
458
|
+
<TouchableOpacity
|
|
459
|
+
testID="giov-new-conversation"
|
|
460
|
+
style={[styles.retryBtn, { backgroundColor: primaryColor }, custom.button]}
|
|
461
|
+
onPress={() => { setInput(""); setFiles([]); void chat.startNew(); }}
|
|
462
|
+
accessibilityRole="button"
|
|
463
|
+
accessibilityLabel={strings.startNewConversation}
|
|
464
|
+
>
|
|
465
|
+
<Text style={[styles.retryBtnText, { color: buttonInk }, custom.buttonText]}>{strings.startNewConversation}</Text>
|
|
466
|
+
</TouchableOpacity>
|
|
467
|
+
</View>
|
|
468
|
+
) : (
|
|
387
469
|
<View
|
|
388
470
|
style={[
|
|
389
471
|
styles.composerRow,
|
|
@@ -440,6 +522,7 @@ const GiovChatInstance = React.forwardRef<GiovChatHandle, GiovChatProps>(functio
|
|
|
440
522
|
<Text style={[styles.sendIcon, { color: buttonInk }, custom.buttonText]}>↑</Text>
|
|
441
523
|
</TouchableOpacity>
|
|
442
524
|
</View>
|
|
525
|
+
)}
|
|
443
526
|
</KeyboardAvoidingView>
|
|
444
527
|
)}
|
|
445
528
|
<Branding showBranding={config?.showBranding} />
|
|
@@ -448,20 +531,42 @@ const GiovChatInstance = React.forwardRef<GiovChatHandle, GiovChatProps>(functio
|
|
|
448
531
|
</GiovChatStringsContext.Provider>
|
|
449
532
|
);
|
|
450
533
|
|
|
534
|
+
// The rating sheet is its own Modal beside the chat, so it opens even while the chat is hidden.
|
|
535
|
+
const sheet = sheetMessage?.rating ? (
|
|
536
|
+
<Modal visible transparent animationType="slide" onRequestClose={() => setSheetOpen(false)}>
|
|
537
|
+
<GiovChatStringsContext.Provider value={strings}>
|
|
538
|
+
<View style={styles.sheetScrim}>
|
|
539
|
+
<View testID="giov-rating-sheet" accessibilityViewIsModal style={[styles.sheet, { backgroundColor: bgColor }]}>
|
|
540
|
+
<View style={styles.sheetHeader}>
|
|
541
|
+
<Text accessibilityRole="header" style={[styles.sheetTitle, { color: isDark ? "#f0f0f0" : "#111" }]}>{strings.rateTitle}</Text>
|
|
542
|
+
<TouchableOpacity testID="giov-rating-sheet-close" accessibilityRole="button" accessibilityLabel={strings.closeRating} onPress={() => setSheetOpen(false)} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
|
543
|
+
<Text style={{ color: isDark ? "#aaa" : "#666", fontSize: 18 }}>✕</Text>
|
|
544
|
+
</TouchableOpacity>
|
|
545
|
+
</View>
|
|
546
|
+
<RatingCard block={sheetMessage.rating} primaryColor={primaryColor} dark={isDark} onRate={chat.ratingsSupported ? onRateCard : undefined} testIDPrefix="giov-sheet-rating" />
|
|
547
|
+
</View>
|
|
548
|
+
</View>
|
|
549
|
+
</GiovChatStringsContext.Provider>
|
|
550
|
+
</Modal>
|
|
551
|
+
) : null;
|
|
552
|
+
|
|
451
553
|
if (mode === "modal") {
|
|
452
554
|
return (
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
555
|
+
<>
|
|
556
|
+
<Modal
|
|
557
|
+
visible={visible}
|
|
558
|
+
animationType="slide"
|
|
559
|
+
presentationStyle="pageSheet"
|
|
560
|
+
onRequestClose={handleClose}
|
|
561
|
+
>
|
|
562
|
+
{chatContent}
|
|
563
|
+
</Modal>
|
|
564
|
+
{sheet}
|
|
565
|
+
</>
|
|
461
566
|
);
|
|
462
567
|
}
|
|
463
568
|
|
|
464
|
-
return visible ? chatContent : null
|
|
569
|
+
return <>{visible ? chatContent : null}{sheet}</>;
|
|
465
570
|
});
|
|
466
571
|
|
|
467
572
|
const styles = StyleSheet.create({
|
|
@@ -526,5 +631,10 @@ const styles = StyleSheet.create({
|
|
|
526
631
|
justifyContent: "center",
|
|
527
632
|
},
|
|
528
633
|
sendBtnOff: { opacity: 0.35 },
|
|
634
|
+
closedBar: { alignItems: "center", gap: 10, padding: 14, borderTopWidth: StyleSheet.hairlineWidth },
|
|
635
|
+
sheetScrim: { flex: 1, justifyContent: "flex-end", backgroundColor: "rgba(0,0,0,0.4)" },
|
|
636
|
+
sheet: { borderTopLeftRadius: 18, borderTopRightRadius: 18, padding: 20, paddingBottom: 32, gap: 8 },
|
|
637
|
+
sheetHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
|
638
|
+
sheetTitle: { fontSize: 16, fontWeight: "700" },
|
|
529
639
|
sendIcon: { color: "#fff", fontSize: 18, fontWeight: "700", lineHeight: 22 },
|
|
530
640
|
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { contrastRatio, parseColor, textOn } from "./color";
|
|
2
|
+
|
|
3
|
+
/** The "Powered by GiovChat" footer's style slot. Layout, size and text stay SDK-controlled. */
|
|
4
|
+
export interface GiovChatBrandingStyle {
|
|
5
|
+
/** Font family of the footer. Defaults to the top-level `fontFamily`. */
|
|
6
|
+
fontFamily?: string;
|
|
7
|
+
/** Colour of "Powered by". Must reach 4.5:1 against the footer background. */
|
|
8
|
+
color?: string;
|
|
9
|
+
/** Colour of "GiovChat" (the link). Must reach 4.5:1 against the footer background. */
|
|
10
|
+
brandColor?: string;
|
|
11
|
+
/** Footer background. Opaque colours only. */
|
|
12
|
+
backgroundColor?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** WCAG AA for normal text. */
|
|
16
|
+
export const FOOTER_MIN_CONTRAST = 4.5;
|
|
17
|
+
export const FOOTER_DEFAULTS = { backgroundColor: "#ffffff", color: "#52525b", brandColor: "#7c3aed" } as const;
|
|
18
|
+
/** The footer text is never smaller than this, whatever the app's style. */
|
|
19
|
+
export const FOOTER_FONT_SIZE = 12;
|
|
20
|
+
|
|
21
|
+
export interface ResolvedFooterStyle {
|
|
22
|
+
fontFamily?: string;
|
|
23
|
+
color: string;
|
|
24
|
+
brandColor: string;
|
|
25
|
+
backgroundColor: string;
|
|
26
|
+
/** True when the app changed the background: the divider is then a neutral translucent grey. */
|
|
27
|
+
customBackground: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `parseColor`, but null for anything see-through: `transparent`, alpha below 1 (hex, rgba). */
|
|
31
|
+
export function parseOpaqueColor(value: unknown): [number, number, number] | null {
|
|
32
|
+
if (typeof value !== "string") return null;
|
|
33
|
+
const text = value.trim().toLowerCase();
|
|
34
|
+
const hex = /^#([0-9a-f]{4}|[0-9a-f]{8})$/.exec(text)?.[1];
|
|
35
|
+
if (hex) {
|
|
36
|
+
const alpha = hex.length === 4 ? hex[3]! + hex[3]! : hex.slice(6, 8);
|
|
37
|
+
if (parseInt(alpha, 16) !== 255) return null;
|
|
38
|
+
}
|
|
39
|
+
const functional = /^rgba?\(\s*\d{1,3}\s*[, ]\s*\d{1,3}\s*[, ]\s*\d{1,3}\s*(?:[,/]\s*([\d.]+)(%?)\s*)?\)$/.exec(text);
|
|
40
|
+
if (functional?.[1] !== undefined) {
|
|
41
|
+
const alpha = Number(functional[1]) / (functional[2] ? 100 : 1);
|
|
42
|
+
if (!(alpha >= 1)) return null;
|
|
43
|
+
}
|
|
44
|
+
return parseColor(text);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Applies the app's footer colours under the accessibility rules: an override that is see-through,
|
|
49
|
+
* unreadable (named colours), or below 4.5:1 against the effective background falls back to the
|
|
50
|
+
* SDK's default for that property (or, when the default fails too on the app's own background, to
|
|
51
|
+
* black or white). `warn` receives one message per rejected value.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveFooterStyle(
|
|
54
|
+
slot: GiovChatBrandingStyle | undefined,
|
|
55
|
+
warn: (message: string) => void = () => {},
|
|
56
|
+
): ResolvedFooterStyle {
|
|
57
|
+
let background: string = FOOTER_DEFAULTS.backgroundColor;
|
|
58
|
+
let customBackground = false;
|
|
59
|
+
if (slot?.backgroundColor !== undefined) {
|
|
60
|
+
if (parseOpaqueColor(slot.backgroundColor)) {
|
|
61
|
+
background = slot.backgroundColor;
|
|
62
|
+
customBackground = true;
|
|
63
|
+
} else {
|
|
64
|
+
warn(`GiovChat: branding.backgroundColor "${slot.backgroundColor}" is ignored (use an opaque hex or rgb colour).`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const backgroundRgb = parseColor(background)!;
|
|
68
|
+
const readable = (candidate: string) => {
|
|
69
|
+
const rgb = parseColor(candidate);
|
|
70
|
+
return rgb !== null && contrastRatio(rgb, backgroundRgb) >= FOOTER_MIN_CONTRAST;
|
|
71
|
+
};
|
|
72
|
+
const pick = (name: "color" | "brandColor"): string => {
|
|
73
|
+
const wanted = slot?.[name];
|
|
74
|
+
if (wanted !== undefined) {
|
|
75
|
+
if (parseOpaqueColor(wanted) && readable(wanted)) return wanted;
|
|
76
|
+
warn(`GiovChat: branding.${name} "${wanted}" is ignored (an opaque colour with at least ${FOOTER_MIN_CONTRAST}:1 contrast against the footer background is required).`);
|
|
77
|
+
}
|
|
78
|
+
return readable(FOOTER_DEFAULTS[name]) ? FOOTER_DEFAULTS[name] : textOn(background);
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
...(slot?.fontFamily ? { fontFamily: slot.fontFamily } : {}),
|
|
82
|
+
color: pick("color"),
|
|
83
|
+
brandColor: pick("brandColor"),
|
|
84
|
+
backgroundColor: background,
|
|
85
|
+
customBackground,
|
|
86
|
+
};
|
|
87
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
GiovChatContact, GiovChatSession, GiovFeedbackValue, GiovMessage, GiovRatingResult, GiovWidgetConfig, MessageHandler, StatusHandler,
|
|
3
|
+
} from "./types";
|
|
2
4
|
import { GiovChatError, chatError, retryAfterSeconds } from "./errors";
|
|
3
5
|
import { object, parseMessage } from "./protocol";
|
|
6
|
+
import { MAX_FEEDBACK_REASON, MAX_RATING_COMMENT, parseRatingFrame, parseRatingResult, ratingError, type GiovRatingFrame } from "./rating";
|
|
4
7
|
import { safeImageUrl } from "./identity";
|
|
5
8
|
import { deviceLocale } from "./i18n";
|
|
6
9
|
import { Outbox } from "./outbox";
|
|
@@ -17,7 +20,7 @@ import {
|
|
|
17
20
|
export const DEFAULT_BASE_URL = "https://api.giovchat.app";
|
|
18
21
|
|
|
19
22
|
/** Keep in step with package.json; sent so the dashboard can show where the chat is installed. */
|
|
20
|
-
export const SDK_VERSION = "0.
|
|
23
|
+
export const SDK_VERSION = "0.3.0";
|
|
21
24
|
export const CLIENT_HEADER = { "X-Giov-Client": `react-native/${SDK_VERSION}` };
|
|
22
25
|
|
|
23
26
|
export function normalizeBaseUrl(value: string): string {
|
|
@@ -186,6 +189,14 @@ export interface ChatClientOptions {
|
|
|
186
189
|
flushOnOpen?: boolean;
|
|
187
190
|
}
|
|
188
191
|
|
|
192
|
+
/** The `conversation_state` frame. */
|
|
193
|
+
export interface GiovConversationState {
|
|
194
|
+
status?: "open" | "closed";
|
|
195
|
+
handedOff?: boolean;
|
|
196
|
+
assignedAgentName?: string;
|
|
197
|
+
assignedAgentAvatar?: string;
|
|
198
|
+
}
|
|
199
|
+
|
|
189
200
|
/** A GiovBot reply arriving word by word: the whole text so far, then `end` once it is saved or dropped. */
|
|
190
201
|
export type GiovStreamEvent = { streamId: string; content: string; end?: false } | { streamId: string; end: true };
|
|
191
202
|
|
|
@@ -232,6 +243,8 @@ export class ChatClient {
|
|
|
232
243
|
private ackCbs = new Set<(clientMessageId: string, message: GiovMessage) => void>();
|
|
233
244
|
private streamCbs = new Set<(event: GiovStreamEvent) => void>();
|
|
234
245
|
private accessCbs = new Set<(access: AttachmentAccess) => void>();
|
|
246
|
+
private ratingCbs = new Set<(frame: GiovRatingFrame) => void>();
|
|
247
|
+
private stateCbs = new Set<(state: GiovConversationState) => void>();
|
|
235
248
|
private pending = new Map<string, Array<{ resolve: (message: GiovMessage) => void; reject: (error: GiovChatError) => void }>>();
|
|
236
249
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
237
250
|
private heartbeat: ReturnType<typeof setInterval> | null = null;
|
|
@@ -246,6 +259,7 @@ export class ChatClient {
|
|
|
246
259
|
private widgetToken: WidgetToken | null = null;
|
|
247
260
|
private tokenRequest: Promise<WidgetToken> | null = null;
|
|
248
261
|
private pushUnsupported = false;
|
|
262
|
+
private ratingsUnsupportedFlag = false;
|
|
249
263
|
private readonly baseUrl: string;
|
|
250
264
|
readonly outbox: Outbox;
|
|
251
265
|
|
|
@@ -264,6 +278,13 @@ export class ChatClient {
|
|
|
264
278
|
onStream(cb: (event: GiovStreamEvent) => void): () => void { this.streamCbs.add(cb); return () => { this.streamCbs.delete(cb); }; }
|
|
265
279
|
/** A newer attachment download token arrived (with a visitor token). */
|
|
266
280
|
onAttachmentAccess(cb: (access: AttachmentAccess) => void): () => void { this.accessCbs.add(cb); return () => { this.accessCbs.delete(cb); }; }
|
|
281
|
+
/** A rating card was answered or changed (on any device), or replaced by a newer one. */
|
|
282
|
+
onRating(cb: (frame: GiovRatingFrame) => void): () => void { this.ratingCbs.add(cb); return () => { this.ratingCbs.delete(cb); }; }
|
|
283
|
+
/**
|
|
284
|
+
* The conversation was closed or reopened (`conversation_state` frame, sent on close and reopen) or handed
|
|
285
|
+
* to someone. `status` is absent on older servers: keep using the history's `status` there.
|
|
286
|
+
*/
|
|
287
|
+
onConversationState(cb: (state: GiovConversationState) => void): () => void { this.stateCbs.add(cb); return () => { this.stateCbs.delete(cb); }; }
|
|
267
288
|
private emit<T>(callbacks: Set<(value: T) => void>, value: T): void {
|
|
268
289
|
for (const cb of callbacks) { try { cb(value); } catch { /* Host callbacks cannot break transport cleanup. */ } }
|
|
269
290
|
}
|
|
@@ -426,6 +447,21 @@ export class ChatClient {
|
|
|
426
447
|
}
|
|
427
448
|
return;
|
|
428
449
|
}
|
|
450
|
+
if (data.type === "conversation_state") {
|
|
451
|
+
const status = data.status === "closed" || data.status === "open" ? data.status : undefined;
|
|
452
|
+
this.emit(this.stateCbs, {
|
|
453
|
+
...(status ? { status } : {}),
|
|
454
|
+
...(typeof data.handedOff === "boolean" ? { handedOff: data.handedOff } : {}),
|
|
455
|
+
...(typeof data.assignedAgentName === "string" ? { assignedAgentName: data.assignedAgentName } : {}),
|
|
456
|
+
...(typeof data.assignedAgentAvatar === "string" ? { assignedAgentAvatar: data.assignedAgentAvatar } : {}),
|
|
457
|
+
});
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (data.type === "rating") {
|
|
461
|
+
const frame = parseRatingFrame(data);
|
|
462
|
+
if (frame) this.emit(this.ratingCbs, frame);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
429
465
|
if (data.type && data.type !== "message") return;
|
|
430
466
|
const message = parseMessage(data);
|
|
431
467
|
// The saved reply takes the place of its streamed draft, under the draft's key so it is not redrawn.
|
|
@@ -632,6 +668,60 @@ export class ChatClient {
|
|
|
632
668
|
return response.status === 401 ? send((await this.token(true)).token) : response;
|
|
633
669
|
}
|
|
634
670
|
|
|
671
|
+
/** True once the server answered a rating or feedback route with 404: an older server, so the controls are hidden. */
|
|
672
|
+
get ratingsUnsupported(): boolean { return this.ratingsUnsupportedFlag; }
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Answers the conversation's rating card (`POST /conversations/{id}/rating`). Omit `requestId` to answer the
|
|
676
|
+
* open card. The same answer again is a no-op; a different one replaces it until the card closes. Rejects with a
|
|
677
|
+
* `GiovChatError` whose code is `invalid_rating`, `request_not_found`, `request_replaced`, `rating_closed`,
|
|
678
|
+
* `too_many_changes`, `rate_limited` (with `retryAfterSeconds`), `unsupported` (an older server) or a transport code.
|
|
679
|
+
*/
|
|
680
|
+
async submitRating(score: number, options: { comment?: string; requestId?: string } = {}): Promise<GiovRatingResult> {
|
|
681
|
+
if (!Number.isInteger(score) || score < 1 || score > 5) throw new GiovChatError("invalid_rating", "The score must be a whole number from 1 to 5.");
|
|
682
|
+
const comment = options.comment?.trim();
|
|
683
|
+
if (comment && comment.length > MAX_RATING_COMMENT) throw new GiovChatError("invalid_rating", "The comment is too long.");
|
|
684
|
+
const body = await this.visitorPost("/rating", { score, ...(options.requestId ? { requestId: options.requestId } : {}), ...(comment ? { comment } : {}) });
|
|
685
|
+
const result = parseRatingResult(body, options.requestId);
|
|
686
|
+
if (!result) throw new GiovChatError("invalid_response", "Invalid rating response.");
|
|
687
|
+
return result;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** Thumbs up or down on a GiovBot answer (`POST /conversations/{id}/messages/{messageId}/feedback`); `none` takes the vote back. */
|
|
691
|
+
async submitAnswerFeedback(messageId: string, value: GiovFeedbackValue, options: { reason?: string } = {}): Promise<void> {
|
|
692
|
+
if (!messageId || !["up", "down", "none"].includes(value)) throw new GiovChatError("invalid_feedback", "The vote must be up, down or none.");
|
|
693
|
+
const reason = value === "down" ? options.reason?.trim() : undefined;
|
|
694
|
+
if (reason && reason.length > MAX_FEEDBACK_REASON) throw new GiovChatError("invalid_feedback", "The reason is too long.");
|
|
695
|
+
await this.visitorPost(`/messages/${encodeURIComponent(messageId)}/feedback`, { value, ...(reason ? { reason } : {}) });
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** One visitor-token POST on this conversation, the token renewed once on 401 (protocol "Tokens"). */
|
|
699
|
+
private async visitorPost(path: string, payload: Record<string, unknown>): Promise<unknown> {
|
|
700
|
+
const session = this.session;
|
|
701
|
+
if (!session) throw new GiovChatError("disconnected", "Chat closed.");
|
|
702
|
+
const url = `${this.baseUrl}/conversations/${encodeURIComponent(session.conversationId)}${path}`;
|
|
703
|
+
const send = async (token: string) => {
|
|
704
|
+
const controller = new AbortController();
|
|
705
|
+
const timer = setTimeout(() => controller.abort(), 15000);
|
|
706
|
+
try {
|
|
707
|
+
const response = await fetch(url, {
|
|
708
|
+
method: "POST", signal: controller.signal,
|
|
709
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, ...CLIENT_HEADER },
|
|
710
|
+
body: JSON.stringify(payload),
|
|
711
|
+
});
|
|
712
|
+
return { status: response.status, body: await response.json().catch(() => null), retryAfter: response.headers?.get?.("Retry-After") ?? null };
|
|
713
|
+
} catch {
|
|
714
|
+
throw new GiovChatError(controller.signal.aborted ? "timeout" : "network", controller.signal.aborted ? "Chat request timed out. Please try again." : "Unable to reach chat. Please try again.");
|
|
715
|
+
} finally { clearTimeout(timer); }
|
|
716
|
+
};
|
|
717
|
+
let reply = await send((await this.token()).token);
|
|
718
|
+
if (reply.status === 401) reply = await send((await this.token(true)).token);
|
|
719
|
+
if (reply.status >= 200 && reply.status < 300) return reply.body;
|
|
720
|
+
const failure = ratingError(reply.status, reply.body, reply.retryAfter);
|
|
721
|
+
if (failure.code === "unsupported") this.ratingsUnsupportedFlag = true;
|
|
722
|
+
throw failure;
|
|
723
|
+
}
|
|
724
|
+
|
|
635
725
|
private rejectPending(): void {
|
|
636
726
|
for (const waiters of this.pending.values()) {
|
|
637
727
|
for (const waiter of waiters) waiter.reject(new GiovChatError("send_failed", "Delivery could not be confirmed. Check the conversation before sending again."));
|
|
@@ -1,13 +1,32 @@
|
|
|
1
|
-
import React from "react";
|
|
1
|
+
import React, { useEffect, useMemo, useState } from "react";
|
|
2
2
|
import { Linking, StyleSheet, Text, TouchableOpacity } from "react-native";
|
|
3
|
+
import { DEFAULT_BASE_URL, fetchWidgetConfig } from "../client";
|
|
4
|
+
import { FOOTER_FONT_SIZE, resolveFooterStyle } from "../brandingStyle";
|
|
5
|
+
import { resolveStyleOverrides, type GiovChatStyleOverrides } from "../styleOverrides";
|
|
6
|
+
import { WidgetStylesContext, useWidgetStyles } from "./WidgetStyles";
|
|
3
7
|
|
|
4
|
-
|
|
8
|
+
declare const __DEV__: boolean | undefined;
|
|
9
|
+
const devWarn = (message: string) => {
|
|
10
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) console.warn(message);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The "Powered by GiovChat" footer. It follows the app's style (top-level `fontFamily` and the
|
|
15
|
+
* `branding` slot, read from the surrounding style context) under a contrast guard; layout, size and
|
|
16
|
+
* text are fixed, and only an explicit `showBranding={false}` from the server removes it.
|
|
17
|
+
*/
|
|
5
18
|
export function Branding({ showBranding }: { showBranding?: boolean }) {
|
|
19
|
+
const { branding } = useWidgetStyles();
|
|
20
|
+
const footer = useMemo(() => resolveFooterStyle(branding, devWarn), [branding]);
|
|
6
21
|
if (showBranding === false) return null;
|
|
7
22
|
|
|
8
23
|
return (
|
|
9
24
|
<TouchableOpacity
|
|
10
|
-
style={
|
|
25
|
+
style={[
|
|
26
|
+
styles.container,
|
|
27
|
+
{ backgroundColor: footer.backgroundColor },
|
|
28
|
+
footer.customBackground && styles.customDivider,
|
|
29
|
+
]}
|
|
11
30
|
accessibilityRole="link"
|
|
12
31
|
accessibilityLabel="Powered by GiovChat"
|
|
13
32
|
onPress={() => {
|
|
@@ -15,21 +34,61 @@ export function Branding({ showBranding }: { showBranding?: boolean }) {
|
|
|
15
34
|
.catch(() => {});
|
|
16
35
|
}}
|
|
17
36
|
>
|
|
18
|
-
<Text style={styles.text}
|
|
37
|
+
<Text style={[styles.text, { color: footer.color }, footer.fontFamily ? { fontFamily: footer.fontFamily } : null]}>
|
|
38
|
+
Powered by <Text style={[styles.brand, { color: footer.brandColor }]}>GiovChat</Text>
|
|
39
|
+
</Text>
|
|
19
40
|
</TouchableOpacity>
|
|
20
41
|
);
|
|
21
42
|
}
|
|
22
43
|
|
|
44
|
+
export interface GiovBrandingProps {
|
|
45
|
+
/**
|
|
46
|
+
* Show it or not. Pass the workspace's `showBranding` (`fetchWidgetConfig`) when you already have the
|
|
47
|
+
* config; leave it out and pass `tenantKey` to let the component read the config itself.
|
|
48
|
+
*/
|
|
49
|
+
showBranding?: boolean;
|
|
50
|
+
/** Fetches `showBranding` from the workspace when the prop above is not set. */
|
|
51
|
+
tenantKey?: string;
|
|
52
|
+
baseUrl?: string;
|
|
53
|
+
/** The same style overrides you give `<GiovChat>`: `fontFamily` and `branding` apply. */
|
|
54
|
+
styleOverrides?: GiovChatStyleOverrides;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Drop-in footer for apps that build their own chat screen on the headless client. Renders nothing
|
|
59
|
+
* while the workspace setting loads and when the plan removed branding (`showBranding: false`); it
|
|
60
|
+
* shows when the setting is missing or cannot be loaded. Starter and Pro workspaces: please show it.
|
|
61
|
+
*/
|
|
62
|
+
export function GiovBranding({ showBranding, tenantKey, baseUrl = DEFAULT_BASE_URL, styleOverrides }: GiovBrandingProps) {
|
|
63
|
+
const styles_ = useMemo(() => resolveStyleOverrides(styleOverrides), [styleOverrides]);
|
|
64
|
+
const [fetched, setFetched] = useState<boolean | undefined>(undefined);
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (showBranding !== undefined || !tenantKey) return;
|
|
67
|
+
const controller = new AbortController();
|
|
68
|
+
fetchWidgetConfig(baseUrl, tenantKey, controller.signal)
|
|
69
|
+
.then((config) => setFetched(config.showBranding !== false))
|
|
70
|
+
.catch(() => { if (!controller.signal.aborted) setFetched(true); });
|
|
71
|
+
return () => controller.abort();
|
|
72
|
+
}, [showBranding, tenantKey, baseUrl]);
|
|
73
|
+
const known = showBranding ?? (tenantKey ? fetched : true);
|
|
74
|
+
if (known === undefined) return null;
|
|
75
|
+
return (
|
|
76
|
+
<WidgetStylesContext.Provider value={styles_}>
|
|
77
|
+
<Branding showBranding={known} />
|
|
78
|
+
</WidgetStylesContext.Provider>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
23
82
|
const styles = StyleSheet.create({
|
|
24
83
|
container: {
|
|
25
84
|
flexShrink: 0,
|
|
26
85
|
alignItems: "center",
|
|
27
86
|
paddingVertical: 12,
|
|
28
87
|
paddingHorizontal: 16,
|
|
29
|
-
backgroundColor: "#fff",
|
|
30
88
|
borderTopWidth: StyleSheet.hairlineWidth,
|
|
31
89
|
borderTopColor: "#ebebeb",
|
|
32
90
|
},
|
|
33
|
-
|
|
34
|
-
|
|
91
|
+
customDivider: { borderTopColor: "rgba(128,128,128,0.25)" },
|
|
92
|
+
text: { fontSize: FOOTER_FONT_SIZE },
|
|
93
|
+
brand: { fontWeight: "700" },
|
|
35
94
|
});
|
|
@@ -6,6 +6,8 @@ import { useWidgetStyles } from "./WidgetStyles";
|
|
|
6
6
|
import { GiovBotMark } from "./GiovBotMark";
|
|
7
7
|
import { ChatMarkdown } from "./ChatMarkdown";
|
|
8
8
|
import { AttachmentList } from "./Attachments";
|
|
9
|
+
import { AnswerFeedback, RatingCard, type RateFn, type VoteFn } from "./RatingCard";
|
|
10
|
+
import { canVoteOn } from "../rating";
|
|
9
11
|
import { messageSenderLabel } from "../identity";
|
|
10
12
|
import { useGiovChatStrings } from "../i18n";
|
|
11
13
|
import { isLightBackground, secondaryTextOn, textOn } from "../color";
|
|
@@ -21,11 +23,17 @@ export interface MessageBubbleProps {
|
|
|
21
23
|
onRetry?: (message: GiovMessage) => void;
|
|
22
24
|
/** Drops a visitor message that failed to send (a refused one can never succeed on retry). */
|
|
23
25
|
onDiscard?: (message: GiovMessage) => void;
|
|
26
|
+
/** Draw the conversation rating card under a message that carries one (default true). */
|
|
27
|
+
ratingCard?: boolean;
|
|
28
|
+
/** Answers the card. Without it the card is read-only. */
|
|
29
|
+
onRate?: RateFn;
|
|
30
|
+
/** Thumbs under GiovBot answers. Without it no thumbs are drawn. */
|
|
31
|
+
onVote?: VoteFn;
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
const identity = (url: string) => url;
|
|
27
35
|
|
|
28
|
-
export function MessageBubble({ message, primaryColor, dark, botName, resolveAttachmentUrl = identity, onRetry, onDiscard }: MessageBubbleProps) {
|
|
36
|
+
export function MessageBubble({ message, primaryColor, dark, botName, resolveAttachmentUrl = identity, onRetry, onDiscard, ratingCard = true, onRate, onVote }: MessageBubbleProps) {
|
|
29
37
|
const custom = useWidgetStyles();
|
|
30
38
|
const strings = useGiovChatStrings();
|
|
31
39
|
const isVisitor = message.role === "visitor";
|
|
@@ -46,6 +54,7 @@ export function MessageBubble({ message, primaryColor, dark, botName, resolveAtt
|
|
|
46
54
|
});
|
|
47
55
|
|
|
48
56
|
return (
|
|
57
|
+
<>
|
|
49
58
|
<View style={[s.wrapper, isVisitor ? s.right : s.left, isBot && s.assistantRow]}>
|
|
50
59
|
{isBot && <GiovBotMark />}
|
|
51
60
|
<View
|
|
@@ -79,6 +88,9 @@ export function MessageBubble({ message, primaryColor, dark, botName, resolveAtt
|
|
|
79
88
|
codeBackground={isVisitor ? (lightVisitor ? "rgba(0,0,0,0.08)" : "rgba(255,255,255,0.18)") : dark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.06)"}
|
|
80
89
|
/>
|
|
81
90
|
) : null}
|
|
91
|
+
{ratingCard && message.rating && !isVisitor ? (
|
|
92
|
+
<RatingCard block={message.rating} primaryColor={primaryColor} dark={dark} onRate={onRate} />
|
|
93
|
+
) : null}
|
|
82
94
|
{!message.isStreaming && (
|
|
83
95
|
<Text
|
|
84
96
|
style={[
|
|
@@ -117,5 +129,9 @@ export function MessageBubble({ message, primaryColor, dark, botName, resolveAtt
|
|
|
117
129
|
</View>
|
|
118
130
|
)}
|
|
119
131
|
</View>
|
|
132
|
+
{onVote && canVoteOn(message) ? (
|
|
133
|
+
<AnswerFeedback messageId={message.id} value={message.feedback} dark={dark} primaryColor={primaryColor} onVote={onVote} />
|
|
134
|
+
) : null}
|
|
135
|
+
</>
|
|
120
136
|
);
|
|
121
137
|
}
|