@giovchat/react-native 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +390 -2
- package/dist/GiovChat.d.ts +135 -0
- package/dist/GiovChat.d.ts.map +1 -0
- package/dist/GiovChat.js +294 -0
- package/dist/GiovChat.js.map +1 -0
- package/dist/attachments.d.ts +121 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +185 -0
- package/dist/attachments.js.map +1 -0
- package/dist/brand-assets.d.ts +6 -0
- package/dist/brand-assets.d.ts.map +1 -0
- package/dist/brand-assets.js +9 -0
- package/dist/brand-assets.js.map +1 -0
- 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 +226 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +891 -0
- package/dist/client.js.map +1 -0
- package/dist/color.d.ts +25 -0
- package/dist/color.d.ts.map +1 -0
- package/dist/color.js +78 -0
- package/dist/color.js.map +1 -0
- package/dist/components/Attachments.d.ts +13 -0
- package/dist/components/Attachments.d.ts.map +1 -0
- package/dist/components/Attachments.js +42 -0
- package/dist/components/Attachments.js.map +1 -0
- package/dist/components/Branding.d.ts +29 -0
- package/dist/components/Branding.d.ts.map +1 -0
- package/dist/components/Branding.js +71 -0
- package/dist/components/Branding.js.map +1 -0
- package/dist/components/ChatMarkdown.d.ts +15 -0
- package/dist/components/ChatMarkdown.d.ts.map +1 -0
- package/dist/components/ChatMarkdown.js +72 -0
- package/dist/components/ChatMarkdown.js.map +1 -0
- package/dist/components/GiovBotMark.d.ts +6 -0
- package/dist/components/GiovBotMark.d.ts.map +1 -0
- package/dist/components/GiovBotMark.js +11 -0
- package/dist/components/GiovBotMark.js.map +1 -0
- package/dist/components/GreetingBubble.d.ts +7 -0
- package/dist/components/GreetingBubble.d.ts.map +1 -0
- package/dist/components/GreetingBubble.js +12 -0
- package/dist/components/GreetingBubble.js.map +1 -0
- package/dist/components/Header.d.ts +18 -0
- package/dist/components/Header.d.ts.map +1 -0
- package/dist/components/Header.js +67 -0
- package/dist/components/Header.js.map +1 -0
- package/dist/components/MessageBubble.d.ts +23 -0
- package/dist/components/MessageBubble.d.ts.map +1 -0
- package/dist/components/MessageBubble.js +51 -0
- package/dist/components/MessageBubble.js.map +1 -0
- package/dist/components/PreChatForm.d.ts +11 -0
- package/dist/components/PreChatForm.d.ts.map +1 -0
- package/dist/components/PreChatForm.js +72 -0
- package/dist/components/PreChatForm.js.map +1 -0
- 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/components/TypingIndicator.d.ts +9 -0
- package/dist/components/TypingIndicator.d.ts.map +1 -0
- package/dist/components/TypingIndicator.js +18 -0
- package/dist/components/TypingIndicator.js.map +1 -0
- package/dist/components/WidgetStyles.d.ts +3 -0
- package/dist/components/WidgetStyles.d.ts.map +1 -0
- package/dist/components/WidgetStyles.js +9 -0
- package/dist/components/WidgetStyles.js.map +1 -0
- package/dist/components/bubbleStyles.d.ts +47 -0
- package/dist/components/bubbleStyles.d.ts.map +1 -0
- package/dist/components/bubbleStyles.js +19 -0
- package/dist/components/bubbleStyles.js.map +1 -0
- package/dist/components/index.d.ts +11 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +14 -0
- package/dist/components/index.js.map +1 -0
- package/dist/errors.d.ts +20 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +26 -0
- package/dist/errors.js.map +1 -0
- package/dist/i18n.d.ts +96 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +392 -0
- package/dist/i18n.js.map +1 -0
- package/dist/identity.d.ts +41 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +41 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +64 -0
- package/dist/index.js.map +1 -0
- package/dist/markdown.d.ts +56 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +219 -0
- package/dist/markdown.js.map +1 -0
- package/dist/outbox.d.ts +68 -0
- package/dist/outbox.d.ts.map +1 -0
- package/dist/outbox.js +168 -0
- package/dist/outbox.js.map +1 -0
- package/dist/protocol.d.ts +9 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +39 -0
- package/dist/protocol.js.map +1 -0
- 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 +45 -0
- package/dist/styleOverrides.d.ts.map +1 -0
- package/dist/styleOverrides.js +63 -0
- package/dist/styleOverrides.js.map +1 -0
- package/dist/types.d.ts +156 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/unread.d.ts +9 -0
- package/dist/unread.d.ts.map +1 -0
- package/dist/unread.js +49 -0
- package/dist/unread.js.map +1 -0
- package/dist/useChatSession.d.ts +95 -0
- package/dist/useChatSession.d.ts.map +1 -0
- package/dist/useChatSession.js +965 -0
- package/dist/useChatSession.js.map +1 -0
- package/package.json +60 -4
- package/src/GiovChat.tsx +640 -0
- package/src/attachments.ts +218 -0
- package/src/brand-assets.ts +5 -0
- package/src/brandingStyle.ts +87 -0
- package/src/client.ts +848 -0
- package/src/color.ts +74 -0
- package/src/components/Attachments.tsx +75 -0
- package/src/components/Branding.tsx +94 -0
- package/src/components/ChatMarkdown.tsx +111 -0
- package/src/components/GiovBotMark.tsx +19 -0
- package/src/components/GreetingBubble.tsx +20 -0
- package/src/components/Header.tsx +131 -0
- package/src/components/MessageBubble.tsx +137 -0
- package/src/components/PreChatForm.tsx +183 -0
- package/src/components/RatingCard.tsx +219 -0
- package/src/components/TypingIndicator.tsx +34 -0
- package/src/components/WidgetStyles.tsx +5 -0
- package/src/components/bubbleStyles.ts +16 -0
- package/src/components/index.ts +14 -0
- package/src/errors.ts +33 -0
- package/src/i18n.ts +474 -0
- package/src/identity.ts +54 -0
- package/src/index.ts +85 -0
- package/src/markdown.ts +227 -0
- package/src/outbox.ts +175 -0
- package/src/protocol.ts +36 -0
- package/src/rating.ts +169 -0
- package/src/ratingStore.ts +65 -0
- package/src/styleOverrides.ts +98 -0
- package/src/types.ts +170 -0
- package/src/unread.ts +39 -0
- package/src/useChatSession.ts +841 -0
package/src/GiovChat.tsx
ADDED
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
AccessibilityInfo,
|
|
4
|
+
ActivityIndicator,
|
|
5
|
+
FlatList,
|
|
6
|
+
KeyboardAvoidingView,
|
|
7
|
+
Modal,
|
|
8
|
+
Platform,
|
|
9
|
+
StyleSheet,
|
|
10
|
+
Text,
|
|
11
|
+
TextInput,
|
|
12
|
+
TouchableOpacity,
|
|
13
|
+
useColorScheme,
|
|
14
|
+
View,
|
|
15
|
+
} from "react-native";
|
|
16
|
+
import { useChatSession, sessionScope } from "./useChatSession";
|
|
17
|
+
import type { GiovChatError } from "./errors";
|
|
18
|
+
import {
|
|
19
|
+
GreetingBubble,
|
|
20
|
+
Header,
|
|
21
|
+
MessageBubble,
|
|
22
|
+
PreChatForm,
|
|
23
|
+
TypingIndicator,
|
|
24
|
+
} from "./components";
|
|
25
|
+
import type { GiovChatContact, GiovChatConnectionStatus, GiovChatSessionStorage, GiovMessage } from "./types";
|
|
26
|
+
import { SafeAreaView } from "react-native-safe-area-context";
|
|
27
|
+
import { resolveStyleOverrides, type GiovChatStyleOverrides } from "./styleOverrides";
|
|
28
|
+
import { WidgetStylesContext } from "./components/WidgetStyles";
|
|
29
|
+
import { Branding } from "./components/Branding";
|
|
30
|
+
import { GiovChatStringsContext, resolveStrings, type GiovChatStrings } from "./i18n";
|
|
31
|
+
import { headerIdentity, messageSenderLabel } from "./identity";
|
|
32
|
+
import { plainText } from "./markdown";
|
|
33
|
+
import { clampMessage } from "./outbox";
|
|
34
|
+
import { textOn } from "./color";
|
|
35
|
+
import {
|
|
36
|
+
DEFAULT_ATTACHMENT_POLICY,
|
|
37
|
+
MAX_ATTACHED_FILES,
|
|
38
|
+
validateAttachments,
|
|
39
|
+
type AttachmentProblem,
|
|
40
|
+
type GiovChatAttachmentPicker,
|
|
41
|
+
type GiovChatPickedFile,
|
|
42
|
+
} from "./attachments";
|
|
43
|
+
import type { GiovPushOptions, GiovPushPlatform, GiovPushRegistration } from "./client";
|
|
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";
|
|
48
|
+
|
|
49
|
+
// ─── Public prop types ─────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export interface GiovChatProps {
|
|
52
|
+
/**
|
|
53
|
+
* Tenant key from your agent dashboard.
|
|
54
|
+
* Widget settings (colors, greeting, pre-chat fields, bot name, etc.) are
|
|
55
|
+
* fetched from the server automatically using this key.
|
|
56
|
+
*/
|
|
57
|
+
tenantKey: string;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Render mode:
|
|
61
|
+
* - `"modal"` (default) — wraps the chat in a native `Modal` sheet.
|
|
62
|
+
* Control visibility with the `visible` prop.
|
|
63
|
+
* - `"fullscreen"` — renders inline, filling the parent container.
|
|
64
|
+
* Use inside a dedicated screen or tab.
|
|
65
|
+
*/
|
|
66
|
+
mode?: "modal" | "fullscreen";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Show or hide the chat.
|
|
70
|
+
* In `modal` mode this drives `Modal.visible`.
|
|
71
|
+
* In `fullscreen` mode the component renders `null` when `false`.
|
|
72
|
+
* @default true
|
|
73
|
+
*/
|
|
74
|
+
visible?: boolean;
|
|
75
|
+
|
|
76
|
+
/** Called when the user taps the close (✕) button. */
|
|
77
|
+
onClose?: () => void;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Contact info for the currently logged-in user.
|
|
81
|
+
* Any field supplied here is forwarded to the server automatically
|
|
82
|
+
* and skips the corresponding pre-chat form field.
|
|
83
|
+
*/
|
|
84
|
+
contact?: GiovChatContact;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Override the API base URL.
|
|
88
|
+
* @default "https://api.giovchat.app"
|
|
89
|
+
*/
|
|
90
|
+
baseUrl?: string;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Override the primary brand color from the dashboard.
|
|
94
|
+
* Use a React Native color string.
|
|
95
|
+
*/
|
|
96
|
+
primaryColor?: string;
|
|
97
|
+
|
|
98
|
+
/** Override the dashboard's theme. `"auto"` follows the device. */
|
|
99
|
+
theme?: "light" | "dark" | "auto";
|
|
100
|
+
|
|
101
|
+
/** Allowed font and color overrides. Layout and plan branding remain SDK-controlled. */
|
|
102
|
+
styleOverrides?: GiovChatStyleOverrides;
|
|
103
|
+
|
|
104
|
+
/** Interface language ("fr", "pt-BR", …). Defaults to the device language; unsupported ones use English. */
|
|
105
|
+
locale?: string;
|
|
106
|
+
/** Replace any of the SDK's interface strings. */
|
|
107
|
+
strings?: Partial<GiovChatStrings>;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Enables attachments: the host's own file or image picker. Visitors can attach only what the
|
|
111
|
+
* agent allows; without a picker the attach button is hidden.
|
|
112
|
+
*/
|
|
113
|
+
attachmentPicker?: GiovChatAttachmentPicker;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Stay connected while the chat is hidden (and the app is in the foreground) so new replies are
|
|
117
|
+
* counted as unread. Only resumes an existing conversation; it never starts one.
|
|
118
|
+
*/
|
|
119
|
+
keepConnected?: boolean;
|
|
120
|
+
/** Agent replies the visitor has not seen because the chat was hidden. */
|
|
121
|
+
onUnreadCountChange?: (count: number) => void;
|
|
122
|
+
|
|
123
|
+
/** Invoked once with the conversation ID after a session is created. */
|
|
124
|
+
onConversationStart?: (conversationId: string) => void;
|
|
125
|
+
/** Changes isolate/reset the session, for example on logout or account switching. */
|
|
126
|
+
sessionKey?: string | number;
|
|
127
|
+
/** Optional secure storage owned by the host application. No persistence by default. */
|
|
128
|
+
sessionStorage?: GiovChatSessionStorage;
|
|
129
|
+
onError?: (error: GiovChatError) => void;
|
|
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;
|
|
149
|
+
}
|
|
150
|
+
|
|
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;
|
|
164
|
+
/** Clears this identity's stored session and starts fresh if the chat is visible. */
|
|
165
|
+
reset(): Promise<void>;
|
|
166
|
+
/**
|
|
167
|
+
* Registers the device's push token (APNs or FCM) for replies in this conversation, now or once
|
|
168
|
+
* it connects. Pass `{ environment: "sandbox" }` from iOS development builds. Resolves
|
|
169
|
+
* `{ registered: false, reason: "unsupported" }` against a server without visitor push. The
|
|
170
|
+
* notification language defaults to the `locale` prop, else the device language.
|
|
171
|
+
*
|
|
172
|
+
* Before any conversation exists the promise stays pending until one connects (the visitor's
|
|
173
|
+
* first message starts it). It resolves `{ registered: false, reason: "no_conversation" }` when
|
|
174
|
+
* the chat unmounts (including a `sessionKey` change), on `reset()`, or when the workspace refuses
|
|
175
|
+
* the visitor's identity. The token is remembered: a new conversation after the team closed the
|
|
176
|
+
* last one is registered again automatically, until `reset()` or `unregisterPushToken()`.
|
|
177
|
+
*/
|
|
178
|
+
registerPushToken(token: string, platform: GiovPushPlatform, options?: GiovPushOptions): Promise<GiovPushRegistration>;
|
|
179
|
+
/**
|
|
180
|
+
* Stops push notifications about this conversation on this device (sign-out, notifications turned
|
|
181
|
+
* off). Call it before `reset()`. Resolves true when the server confirmed; never throws.
|
|
182
|
+
*/
|
|
183
|
+
unregisterPushToken(token: string): Promise<boolean>;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─── Component ───────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
export const GiovChat = React.forwardRef<GiovChatHandle, GiovChatProps>(function GiovChat(props, ref) {
|
|
189
|
+
const scope = sessionScope(props.baseUrl ?? "https://api.giovchat.app", props.tenantKey, props.contact, props.sessionKey);
|
|
190
|
+
return <GiovChatInstance key={scope} {...props} ref={ref} />;
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
function errorText(error: GiovChatError, strings: GiovChatStrings): string {
|
|
194
|
+
switch (error.code) {
|
|
195
|
+
case "invalid_session": return strings.sessionExpired;
|
|
196
|
+
case "conversation_closed": return strings.conversationEnded;
|
|
197
|
+
case "upload_failed": return strings.uploadFailed;
|
|
198
|
+
case "send_failed": return strings.sendFailed;
|
|
199
|
+
case "disconnected": case "connection_failed": return strings.reconnecting;
|
|
200
|
+
case "workspace_busy": return strings.busy;
|
|
201
|
+
default: return strings.unavailable;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function problemText(problem: AttachmentProblem, strings: GiovChatStrings): string {
|
|
206
|
+
switch (problem.kind) {
|
|
207
|
+
case "disabled": return strings.attachmentsOff;
|
|
208
|
+
case "type": return strings.typeNotAllowed(problem.name);
|
|
209
|
+
case "size": return strings.fileTooLarge(problem.name, problem.maxSizeMb);
|
|
210
|
+
case "count": return strings.tooManyFiles(problem.max);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const GiovChatInstance = React.forwardRef<GiovChatHandle, GiovChatProps>(function GiovChatInstance({
|
|
215
|
+
tenantKey,
|
|
216
|
+
mode = "modal",
|
|
217
|
+
visible = true,
|
|
218
|
+
onClose,
|
|
219
|
+
contact,
|
|
220
|
+
baseUrl = "https://api.giovchat.app",
|
|
221
|
+
primaryColor: overridePrimaryColor,
|
|
222
|
+
theme: themeOverride,
|
|
223
|
+
styleOverrides,
|
|
224
|
+
locale,
|
|
225
|
+
strings: stringOverrides,
|
|
226
|
+
attachmentPicker,
|
|
227
|
+
keepConnected,
|
|
228
|
+
onUnreadCountChange,
|
|
229
|
+
onConversationStart,
|
|
230
|
+
sessionKey,
|
|
231
|
+
sessionStorage,
|
|
232
|
+
onError,
|
|
233
|
+
onConnectionStatusChange,
|
|
234
|
+
ratingCard = true,
|
|
235
|
+
answerFeedback = true,
|
|
236
|
+
onRatingRequested,
|
|
237
|
+
onRatingSubmitted,
|
|
238
|
+
onAnswerFeedback,
|
|
239
|
+
}: GiovChatProps, ref) {
|
|
240
|
+
const systemScheme = useColorScheme();
|
|
241
|
+
const custom = useMemo(() => resolveStyleOverrides(styleOverrides), [styleOverrides]);
|
|
242
|
+
const strings = useMemo(() => resolveStrings(locale, stringOverrides), [locale, stringOverrides]);
|
|
243
|
+
|
|
244
|
+
const scope = sessionScope(baseUrl, tenantKey, contact, sessionKey);
|
|
245
|
+
const chat = useChatSession({
|
|
246
|
+
tenantKey, baseUrl, visible, contact, scope, sessionKey, sessionStorage, keepConnected,
|
|
247
|
+
onError, onConnectionStatusChange, onConversationStart, onUnreadCountChange,
|
|
248
|
+
onRatingRequested, onRatingSubmitted, onAnswerFeedback,
|
|
249
|
+
});
|
|
250
|
+
const { phase, config, messages, agentTyping, agentTypingIsBot, error, sending, uploads, requiredFields, retry, reset, submitContact, send } = chat;
|
|
251
|
+
const [input, setInput] = useState("");
|
|
252
|
+
const [files, setFiles] = useState<GiovChatPickedFile[]>([]);
|
|
253
|
+
const [fileProblem, setFileProblem] = useState<string | null>(null);
|
|
254
|
+
const [sheetOpen, setSheetOpen] = useState(false);
|
|
255
|
+
const listRef = useRef<FlatList<GiovMessage>>(null);
|
|
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), []);
|
|
270
|
+
useImperativeHandle(ref, () => ({
|
|
271
|
+
submitRating: ratingCalls.submit,
|
|
272
|
+
submitAnswerFeedback: ratingCalls.submitFeedback,
|
|
273
|
+
getRatingRequest: () => live.current.ratingRequest,
|
|
274
|
+
showRating: ratingCalls.show,
|
|
275
|
+
reset: async () => { setInput(""); setFiles([]); await reset(); },
|
|
276
|
+
registerPushToken: (token, platform, options) => chat.registerPushToken(token, platform, { ...(locale ? { locale } : {}), ...options }),
|
|
277
|
+
unregisterPushToken: chat.unregisterPushToken,
|
|
278
|
+
}), [reset, chat.registerPushToken, chat.unregisterPushToken, locale, ratingCalls]);
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
const timer = setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 80);
|
|
281
|
+
return () => clearTimeout(timer);
|
|
282
|
+
}, [messages.length, messages[messages.length - 1]?.content]);
|
|
283
|
+
useEffect(() => { publishUnreadCount(unreadOwner.current, tenantKey, chat.unreadCount); }, [tenantKey, chat.unreadCount]);
|
|
284
|
+
// Screen readers hear each reply as it arrives (history is not read out again).
|
|
285
|
+
const incoming = chat.lastIncoming;
|
|
286
|
+
useEffect(() => {
|
|
287
|
+
if (!incoming || !visible) return;
|
|
288
|
+
const sender = messageSenderLabel(incoming, { botName: config?.botName, agentName: chat.currentAgentName, you: strings.you, automated: strings.automated, agent: strings.agent });
|
|
289
|
+
const text = plainText(incoming.content) || (incoming.attachments?.length ? strings.attachment : "");
|
|
290
|
+
if (text) AccessibilityInfo.announceForAccessibility?.(strings.newMessage(sender, text));
|
|
291
|
+
// Announced once per message: only the message itself triggers it.
|
|
292
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
293
|
+
}, [incoming]);
|
|
294
|
+
useEffect(() => () => forgetUnreadCount(unreadOwner.current), []);
|
|
295
|
+
|
|
296
|
+
const primaryColor = overridePrimaryColor ?? config?.primaryColor ?? "#7c3aed";
|
|
297
|
+
const themeSetting = themeOverride ?? config?.theme;
|
|
298
|
+
const isDark = themeSetting === "dark" || (themeSetting === "auto" && systemScheme === "dark");
|
|
299
|
+
const handleClose = useCallback(() => onClose?.(), [onClose]);
|
|
300
|
+
const policy = config?.attachments ?? DEFAULT_ATTACHMENT_POLICY;
|
|
301
|
+
// Uploads need a conversation: the first message starts it, then files can follow.
|
|
302
|
+
const canAttach = !!attachmentPicker && chat.hasConversation && !chat.closed && policy.enabled && policy.allowedTypes.length > 0;
|
|
303
|
+
const composing = phase === "ready" || phase === "connecting" || phase === "active";
|
|
304
|
+
const canSend = composing && !sending && (!!input.trim() || files.length > 0);
|
|
305
|
+
const handleSend = async () => {
|
|
306
|
+
const text = input.trim();
|
|
307
|
+
if (!canSend) return;
|
|
308
|
+
const sentFiles = files;
|
|
309
|
+
if (await send(text, sentFiles)) {
|
|
310
|
+
setInput((current) => current.trim() === text ? "" : current);
|
|
311
|
+
setFiles((current) => current === sentFiles ? [] : current);
|
|
312
|
+
setFileProblem(null);
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
const handleAttach = async () => {
|
|
316
|
+
if (!attachmentPicker) return;
|
|
317
|
+
setFileProblem(null);
|
|
318
|
+
try {
|
|
319
|
+
const picked = await attachmentPicker.pick({ allowedTypes: policy.allowedTypes, maxFiles: Math.max(1, MAX_ATTACHED_FILES - files.length), maxSizeMb: policy.maxSizeMb });
|
|
320
|
+
const { accepted, problems } = validateAttachments(picked, files.length, policy);
|
|
321
|
+
if (accepted.length) setFiles((current) => [...current, ...accepted]);
|
|
322
|
+
if (problems.length) setFileProblem(problems.map((problem) => problemText(problem, strings)).join(" "));
|
|
323
|
+
} catch { /* The picker was dismissed or failed; nothing changes. */ }
|
|
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]);
|
|
333
|
+
const identity = config ? headerIdentity(config, chat.currentAgentName, chat.currentAgentAvatar) : null;
|
|
334
|
+
const subtitle = phase === "active" || phase === "ready"
|
|
335
|
+
? identity?.kind === "person" ? strings.statusPerson : identity?.kind === "bot" ? strings.statusBot : strings.statusTeam
|
|
336
|
+
: phase === "connecting" ? strings.connecting : null;
|
|
337
|
+
const showClose = mode === "modal" || !!onClose;
|
|
338
|
+
const bgColor = isDark ? "#0a0a0a" : "#fff";
|
|
339
|
+
// Black or white on the buttons' colour, by WCAG contrast.
|
|
340
|
+
const buttonInk = textOn(custom.button.backgroundColor ?? primaryColor);
|
|
341
|
+
|
|
342
|
+
// ── Render ───────────────────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
const chatContent = (
|
|
345
|
+
<GiovChatStringsContext.Provider value={strings}>
|
|
346
|
+
<WidgetStylesContext.Provider value={custom}>
|
|
347
|
+
<SafeAreaView style={[styles.safeArea, { backgroundColor: bgColor }, custom.container]}>
|
|
348
|
+
<Header
|
|
349
|
+
privacyPolicyUrl={config?.privacyPolicyUrl}
|
|
350
|
+
title={identity?.title ?? config?.title ?? "Chat"}
|
|
351
|
+
subtitle={subtitle}
|
|
352
|
+
primaryColor={primaryColor}
|
|
353
|
+
avatarUrl={identity?.avatar}
|
|
354
|
+
kind={identity?.kind}
|
|
355
|
+
onClose={showClose ? handleClose : undefined}
|
|
356
|
+
/>
|
|
357
|
+
|
|
358
|
+
{(phase === "loading" || phase === "idle") && (
|
|
359
|
+
<View style={styles.centered}>
|
|
360
|
+
<ActivityIndicator size="large" color={primaryColor} />
|
|
361
|
+
<Text style={[styles.hint, { color: isDark ? "#555" : "#aaa" }, custom.hint]}>{strings.loading}</Text>
|
|
362
|
+
</View>
|
|
363
|
+
)}
|
|
364
|
+
|
|
365
|
+
{phase === "error" && (
|
|
366
|
+
<View style={styles.centered}>
|
|
367
|
+
<Text style={[styles.errorText, custom.errorText]}>{error ? errorText(error, strings) : strings.unavailable}</Text>
|
|
368
|
+
<TouchableOpacity
|
|
369
|
+
style={[styles.retryBtn, { backgroundColor: primaryColor }, custom.button]}
|
|
370
|
+
onPress={retry}
|
|
371
|
+
accessibilityRole="button"
|
|
372
|
+
accessibilityLabel={strings.retry}
|
|
373
|
+
>
|
|
374
|
+
<Text style={[styles.retryBtnText, { color: buttonInk }, custom.buttonText]}>{strings.tryAgain}</Text>
|
|
375
|
+
</TouchableOpacity>
|
|
376
|
+
</View>
|
|
377
|
+
)}
|
|
378
|
+
|
|
379
|
+
{phase === "pre-chat" && config?.requestUserInfo && (
|
|
380
|
+
<PreChatForm
|
|
381
|
+
fields={requiredFields}
|
|
382
|
+
primaryColor={primaryColor}
|
|
383
|
+
dark={isDark}
|
|
384
|
+
initialValues={contact}
|
|
385
|
+
onSubmit={submitContact}
|
|
386
|
+
/>
|
|
387
|
+
)}
|
|
388
|
+
|
|
389
|
+
{composing && (
|
|
390
|
+
<KeyboardAvoidingView
|
|
391
|
+
style={styles.flex}
|
|
392
|
+
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
|
393
|
+
>
|
|
394
|
+
{error && (
|
|
395
|
+
<Text accessibilityRole="alert" style={[styles.errorText, { padding: 10 }, custom.errorText]}>{errorText(error, strings)}</Text>
|
|
396
|
+
)}
|
|
397
|
+
<FlatList
|
|
398
|
+
ref={listRef}
|
|
399
|
+
data={messages}
|
|
400
|
+
keyExtractor={(item) => item.clientMessageId ?? item.id}
|
|
401
|
+
contentContainerStyle={styles.listContent}
|
|
402
|
+
ListHeaderComponent={
|
|
403
|
+
config ? (
|
|
404
|
+
<GreetingBubble
|
|
405
|
+
text={config.greetingText}
|
|
406
|
+
dark={isDark}
|
|
407
|
+
/>
|
|
408
|
+
) : null
|
|
409
|
+
}
|
|
410
|
+
ListFooterComponent={agentTyping ? <TypingIndicator dark={isDark} isBot={agentTypingIsBot} botName={config?.botName} /> : null}
|
|
411
|
+
renderItem={({ item }: { item: GiovMessage }) => (
|
|
412
|
+
<MessageBubble
|
|
413
|
+
message={item}
|
|
414
|
+
primaryColor={primaryColor}
|
|
415
|
+
dark={isDark}
|
|
416
|
+
botName={config?.botName}
|
|
417
|
+
resolveAttachmentUrl={chat.attachmentUrl}
|
|
418
|
+
onRetry={(message) => message.clientMessageId && chat.retryMessage(message.clientMessageId)}
|
|
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}
|
|
423
|
+
/>
|
|
424
|
+
)}
|
|
425
|
+
/>
|
|
426
|
+
|
|
427
|
+
{(files.length > 0 || fileProblem) && (
|
|
428
|
+
<View style={[styles.files, { backgroundColor: bgColor, borderTopColor: isDark ? "#1e1e1e" : "#ebebeb" }]}>
|
|
429
|
+
{fileProblem && <Text accessibilityRole="alert" style={[styles.fileProblem, custom.errorText]}>{fileProblem}</Text>}
|
|
430
|
+
<View style={styles.fileChips}>
|
|
431
|
+
{files.map((file, index) => {
|
|
432
|
+
const upload = uploads[index];
|
|
433
|
+
return (
|
|
434
|
+
<View key={`${file.uri}-${index}`} style={[styles.fileChip, { backgroundColor: isDark ? "#1c1c1c" : "#f2f2f2" }]}>
|
|
435
|
+
<Text style={[styles.fileChipText, { color: isDark ? "#e5e5e5" : "#222" }, custom.hint]} numberOfLines={1}>
|
|
436
|
+
{upload ? strings.uploading(Math.round(upload.progress * 100)) : file.name}
|
|
437
|
+
</Text>
|
|
438
|
+
{!sending && (
|
|
439
|
+
<TouchableOpacity
|
|
440
|
+
onPress={() => setFiles((current) => current.filter((_, i) => i !== index))}
|
|
441
|
+
accessibilityRole="button"
|
|
442
|
+
accessibilityLabel={strings.removeAttachment(file.name)}
|
|
443
|
+
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
|
444
|
+
>
|
|
445
|
+
<Text style={{ color: isDark ? "#aaa" : "#666", fontSize: 14 }}>✕</Text>
|
|
446
|
+
</TouchableOpacity>
|
|
447
|
+
)}
|
|
448
|
+
</View>
|
|
449
|
+
);
|
|
450
|
+
})}
|
|
451
|
+
</View>
|
|
452
|
+
</View>
|
|
453
|
+
)}
|
|
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
|
+
) : (
|
|
469
|
+
<View
|
|
470
|
+
style={[
|
|
471
|
+
styles.composerRow,
|
|
472
|
+
{
|
|
473
|
+
borderTopColor: isDark ? "#1e1e1e" : "#ebebeb",
|
|
474
|
+
backgroundColor: bgColor,
|
|
475
|
+
},
|
|
476
|
+
custom.composer,
|
|
477
|
+
]}
|
|
478
|
+
>
|
|
479
|
+
{canAttach && (
|
|
480
|
+
<TouchableOpacity
|
|
481
|
+
style={[styles.attachBtn, { borderColor: isDark ? "#2e2e2e" : "#e5e5e5" }]}
|
|
482
|
+
onPress={handleAttach}
|
|
483
|
+
disabled={sending || files.length >= MAX_ATTACHED_FILES}
|
|
484
|
+
accessibilityRole="button"
|
|
485
|
+
accessibilityLabel={strings.attachFile}
|
|
486
|
+
>
|
|
487
|
+
<Text style={[styles.attachIcon, { color: isDark ? "#ccc" : "#555" }]}>+</Text>
|
|
488
|
+
</TouchableOpacity>
|
|
489
|
+
)}
|
|
490
|
+
<TextInput
|
|
491
|
+
style={[
|
|
492
|
+
styles.composerInput,
|
|
493
|
+
{
|
|
494
|
+
color: isDark ? "#f0f0f0" : "#111",
|
|
495
|
+
backgroundColor: isDark ? "#1c1c1c" : "#f5f5f5",
|
|
496
|
+
borderColor: isDark ? "#2e2e2e" : "#e5e5e5",
|
|
497
|
+
},
|
|
498
|
+
custom.input,
|
|
499
|
+
]}
|
|
500
|
+
value={input}
|
|
501
|
+
// Over the limit (a long paste) the text is cut at 2000, never dropped, and never through an emoji.
|
|
502
|
+
onChangeText={(value) => { setInput(clampMessage(value)); if (value.trim()) chat.notifyTyping(); }}
|
|
503
|
+
placeholder={config?.textArea ?? strings.composerPlaceholder}
|
|
504
|
+
placeholderTextColor={custom.hint.color ?? (isDark ? "#484848" : "#b0b0b0")}
|
|
505
|
+
accessibilityLabel={strings.messageInput}
|
|
506
|
+
// Typing goes on while files upload; only Send waits.
|
|
507
|
+
editable={composing}
|
|
508
|
+
multiline
|
|
509
|
+
/>
|
|
510
|
+
<TouchableOpacity
|
|
511
|
+
style={[
|
|
512
|
+
styles.sendBtn,
|
|
513
|
+
{ backgroundColor: primaryColor },
|
|
514
|
+
custom.button,
|
|
515
|
+
!canSend && styles.sendBtnOff,
|
|
516
|
+
]}
|
|
517
|
+
onPress={handleSend}
|
|
518
|
+
disabled={!canSend}
|
|
519
|
+
accessibilityRole="button"
|
|
520
|
+
accessibilityLabel={strings.sendMessage}
|
|
521
|
+
>
|
|
522
|
+
<Text style={[styles.sendIcon, { color: buttonInk }, custom.buttonText]}>↑</Text>
|
|
523
|
+
</TouchableOpacity>
|
|
524
|
+
</View>
|
|
525
|
+
)}
|
|
526
|
+
</KeyboardAvoidingView>
|
|
527
|
+
)}
|
|
528
|
+
<Branding showBranding={config?.showBranding} />
|
|
529
|
+
</SafeAreaView>
|
|
530
|
+
</WidgetStylesContext.Provider>
|
|
531
|
+
</GiovChatStringsContext.Provider>
|
|
532
|
+
);
|
|
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
|
+
|
|
553
|
+
if (mode === "modal") {
|
|
554
|
+
return (
|
|
555
|
+
<>
|
|
556
|
+
<Modal
|
|
557
|
+
visible={visible}
|
|
558
|
+
animationType="slide"
|
|
559
|
+
presentationStyle="pageSheet"
|
|
560
|
+
onRequestClose={handleClose}
|
|
561
|
+
>
|
|
562
|
+
{chatContent}
|
|
563
|
+
</Modal>
|
|
564
|
+
{sheet}
|
|
565
|
+
</>
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return <>{visible ? chatContent : null}{sheet}</>;
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
const styles = StyleSheet.create({
|
|
573
|
+
flex: { flex: 1 },
|
|
574
|
+
safeArea: { flex: 1 },
|
|
575
|
+
centered: {
|
|
576
|
+
flex: 1,
|
|
577
|
+
alignItems: "center",
|
|
578
|
+
justifyContent: "center",
|
|
579
|
+
gap: 14,
|
|
580
|
+
padding: 24,
|
|
581
|
+
},
|
|
582
|
+
hint: { fontSize: 14, marginTop: 4 },
|
|
583
|
+
errorText: {
|
|
584
|
+
color: "#dc2626",
|
|
585
|
+
fontSize: 14,
|
|
586
|
+
textAlign: "center",
|
|
587
|
+
lineHeight: 20,
|
|
588
|
+
},
|
|
589
|
+
retryBtn: {
|
|
590
|
+
borderRadius: 8,
|
|
591
|
+
paddingHorizontal: 28,
|
|
592
|
+
paddingVertical: 11,
|
|
593
|
+
},
|
|
594
|
+
retryBtnText: { color: "#fff", fontWeight: "600", fontSize: 14 },
|
|
595
|
+
listContent: { paddingBottom: 12 },
|
|
596
|
+
files: { paddingHorizontal: 10, paddingTop: 8, borderTopWidth: StyleSheet.hairlineWidth, gap: 6 },
|
|
597
|
+
fileProblem: { color: "#dc2626", fontSize: 12 },
|
|
598
|
+
fileChips: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
|
|
599
|
+
fileChip: { flexDirection: "row", alignItems: "center", gap: 6, borderRadius: 14, paddingHorizontal: 10, paddingVertical: 5, maxWidth: 220 },
|
|
600
|
+
fileChipText: { fontSize: 12, flexShrink: 1 },
|
|
601
|
+
composerRow: {
|
|
602
|
+
flexDirection: "row",
|
|
603
|
+
alignItems: "flex-end",
|
|
604
|
+
gap: 8,
|
|
605
|
+
padding: 10,
|
|
606
|
+
borderTopWidth: StyleSheet.hairlineWidth,
|
|
607
|
+
},
|
|
608
|
+
attachBtn: {
|
|
609
|
+
width: 40,
|
|
610
|
+
height: 40,
|
|
611
|
+
borderRadius: 20,
|
|
612
|
+
borderWidth: 1,
|
|
613
|
+
alignItems: "center",
|
|
614
|
+
justifyContent: "center",
|
|
615
|
+
},
|
|
616
|
+
attachIcon: { fontSize: 22, lineHeight: 24 },
|
|
617
|
+
composerInput: {
|
|
618
|
+
flex: 1,
|
|
619
|
+
borderWidth: 1,
|
|
620
|
+
borderRadius: 22,
|
|
621
|
+
paddingHorizontal: 14,
|
|
622
|
+
paddingVertical: Platform.OS === "ios" ? 10 : 8,
|
|
623
|
+
fontSize: 14,
|
|
624
|
+
maxHeight: 120,
|
|
625
|
+
},
|
|
626
|
+
sendBtn: {
|
|
627
|
+
width: 40,
|
|
628
|
+
height: 40,
|
|
629
|
+
borderRadius: 20,
|
|
630
|
+
alignItems: "center",
|
|
631
|
+
justifyContent: "center",
|
|
632
|
+
},
|
|
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" },
|
|
639
|
+
sendIcon: { color: "#fff", fontSize: 18, fontWeight: "700", lineHeight: 22 },
|
|
640
|
+
});
|