@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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import { GiovChatError } from "./errors";
|
|
3
|
+
import type { GiovFeedbackValue, GiovRatingRequest, GiovRatingResult } from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What `useGiovRating` returns: the rating card the visitor can answer now, and the calls to answer it
|
|
7
|
+
* or to open the built-in rating sheet. Each mounted `GiovChat` publishes its own entry under its tenant key.
|
|
8
|
+
*/
|
|
9
|
+
export interface GiovRatingController {
|
|
10
|
+
/** The open rating card (answered or not) or null: nothing to rate right now. */
|
|
11
|
+
request: GiovRatingRequest | null;
|
|
12
|
+
/** False once the server turned out not to take ratings (an older server): hide your own controls. */
|
|
13
|
+
supported: boolean;
|
|
14
|
+
/** Rates the conversation; see `GiovChatHandle.submitRating`. */
|
|
15
|
+
submit(score: number, options?: { comment?: string; requestId?: string }): Promise<GiovRatingResult>;
|
|
16
|
+
/** Thumbs on a GiovBot answer; see `GiovChatHandle.submitAnswerFeedback`. */
|
|
17
|
+
submitFeedback(messageId: string, value: GiovFeedbackValue, options?: { reason?: string }): Promise<void>;
|
|
18
|
+
/** Opens the built-in rating sheet for the open card; false when there is none. */
|
|
19
|
+
show(): boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const unavailable = () => Promise.reject(new GiovChatError("disconnected", "No chat is mounted."));
|
|
23
|
+
const NONE: GiovRatingController = { request: null, supported: true, submit: unavailable, submitFeedback: unavailable, show: () => false };
|
|
24
|
+
|
|
25
|
+
const entries = new Map<symbol, { tenantKey: string; value: GiovRatingController }>();
|
|
26
|
+
const listeners = new Set<() => void>();
|
|
27
|
+
const notify = () => { for (const listener of listeners) { try { listener(); } catch { /* Isolated. */ } } };
|
|
28
|
+
|
|
29
|
+
export function publishRating(owner: symbol, tenantKey: string, value: GiovRatingController): void {
|
|
30
|
+
entries.set(owner, { tenantKey, value });
|
|
31
|
+
notify();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function forgetRating(owner: symbol): void {
|
|
35
|
+
if (entries.delete(owner)) notify();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function readRating(tenantKey?: string): GiovRatingController {
|
|
39
|
+
let found: GiovRatingController | null = null;
|
|
40
|
+
for (const entry of entries.values()) {
|
|
41
|
+
if (tenantKey && entry.tenantKey !== tenantKey) continue;
|
|
42
|
+
// The first mounted chat with a card wins; otherwise the first chat.
|
|
43
|
+
if (entry.value.request) return entry.value;
|
|
44
|
+
found ??= entry.value;
|
|
45
|
+
}
|
|
46
|
+
return found ?? NONE;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function subscribe(listener: () => void): () => void {
|
|
50
|
+
listeners.add(listener);
|
|
51
|
+
return () => { listeners.delete(listener); };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Conversation rating for a host that draws its own prompt (turn the built-in card off with
|
|
56
|
+
* `ratingCard={false}`), or wants a button that opens the built-in sheet. Needs a mounted `GiovChat`.
|
|
57
|
+
*
|
|
58
|
+
* ```tsx
|
|
59
|
+
* const rating = useGiovRating();
|
|
60
|
+
* if (rating.request && !rating.request.answered) return <Button title="Rate us" onPress={rating.show} />;
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export function useGiovRating(tenantKey?: string): GiovRatingController {
|
|
64
|
+
return useSyncExternalStore(subscribe, () => readRating(tenantKey), () => NONE);
|
|
65
|
+
}
|
package/src/styleOverrides.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TextStyle, ViewStyle } from "react-native";
|
|
2
|
+
import type { GiovChatBrandingStyle } from "./brandingStyle";
|
|
2
3
|
|
|
3
4
|
/** Typography and colors only; text sizing and layout remain SDK-controlled. */
|
|
4
5
|
export type GiovChatTextStyle = Pick<TextStyle, "fontFamily" | "fontWeight" | "fontStyle"> & {
|
|
@@ -7,7 +8,7 @@ export type GiovChatTextStyle = Pick<TextStyle, "fontFamily" | "fontWeight" | "f
|
|
|
7
8
|
|
|
8
9
|
export type GiovChatBackgroundStyle = { backgroundColor?: string };
|
|
9
10
|
|
|
10
|
-
/** Plain objects only. No arbitrary React Native styles
|
|
11
|
+
/** Plain objects only. No arbitrary React Native styles. */
|
|
11
12
|
export interface GiovChatStyleOverrides {
|
|
12
13
|
/** Default font family for all customizable text. Load the font in your app. */
|
|
13
14
|
fontFamily?: string;
|
|
@@ -28,6 +29,11 @@ export interface GiovChatStyleOverrides {
|
|
|
28
29
|
label?: GiovChatTextStyle;
|
|
29
30
|
hint?: GiovChatTextStyle;
|
|
30
31
|
errorText?: GiovChatTextStyle;
|
|
32
|
+
/**
|
|
33
|
+
* The "Powered by GiovChat" footer: font family, text colour, link colour and background. It follows
|
|
34
|
+
* your app, but stays on (only the plan removes it) and keeps 4.5:1 contrast. Layout, size and text are fixed.
|
|
35
|
+
*/
|
|
36
|
+
branding?: GiovChatBrandingStyle;
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
const backgroundSlots = [
|
|
@@ -39,7 +45,7 @@ const textSlots = [
|
|
|
39
45
|
] as const;
|
|
40
46
|
|
|
41
47
|
export type ResolvedWidgetStyles = Record<(typeof backgroundSlots)[number], ViewStyle> &
|
|
42
|
-
Record<(typeof textSlots)[number], TextStyle
|
|
48
|
+
Record<(typeof textSlots)[number], TextStyle> & { branding: GiovChatBrandingStyle };
|
|
43
49
|
|
|
44
50
|
function record(value: unknown): Record<string, unknown> {
|
|
45
51
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
@@ -81,5 +87,12 @@ export function resolveStyleOverrides(value: unknown): ResolvedWidgetStyles {
|
|
|
81
87
|
}
|
|
82
88
|
result[slot] = style;
|
|
83
89
|
}
|
|
90
|
+
const footer = record(input.branding);
|
|
91
|
+
const branding: GiovChatBrandingStyle = {};
|
|
92
|
+
if (nonEmptyString(input.fontFamily)) branding.fontFamily = input.fontFamily;
|
|
93
|
+
for (const key of ["fontFamily", "color", "brandColor", "backgroundColor"] as const) {
|
|
94
|
+
if (nonEmptyString(footer[key])) branding[key] = footer[key] as string;
|
|
95
|
+
}
|
|
96
|
+
result.branding = branding;
|
|
84
97
|
return result;
|
|
85
98
|
}
|
package/src/types.ts
CHANGED
|
@@ -110,6 +110,57 @@ export interface GiovMessage {
|
|
|
110
110
|
clientMessageId?: string;
|
|
111
111
|
/** Local only: a GiovBot reply still being written. */
|
|
112
112
|
isStreaming?: boolean;
|
|
113
|
+
/** The conversation rating card this message carries (team or GiovBot "How did we do?" request). */
|
|
114
|
+
rating?: GiovRatingBlock;
|
|
115
|
+
/** The visitor's own thumb on this GiovBot answer. */
|
|
116
|
+
feedback?: "up" | "down";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A score from 1 (Very bad) to 5 (Great). */
|
|
120
|
+
export type GiovRatingScore = 1 | 2 | 3 | 4 | 5;
|
|
121
|
+
|
|
122
|
+
/** The `rating` block of a message (docs/protocol, "Conversation ratings"). */
|
|
123
|
+
export interface GiovRatingBlock {
|
|
124
|
+
requestId: string;
|
|
125
|
+
/** When the card stops accepting answers or changes (epoch ms; the server decides). */
|
|
126
|
+
closesAt: number;
|
|
127
|
+
/** A hosted rating page, when the API sent one. The SDK always answers with the visitor token instead. */
|
|
128
|
+
url?: string;
|
|
129
|
+
score?: GiovRatingScore;
|
|
130
|
+
comment?: string;
|
|
131
|
+
/** `"replaced"`: a newer card took its place; draw nothing. */
|
|
132
|
+
state?: "replaced";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The rating card the visitor can answer or change right now (see `useGiovRating`). */
|
|
136
|
+
export interface GiovRatingRequest {
|
|
137
|
+
requestId: string;
|
|
138
|
+
/** The message that carries the card. */
|
|
139
|
+
messageId: string;
|
|
140
|
+
closesAt: number;
|
|
141
|
+
url?: string;
|
|
142
|
+
/** True once the visitor has answered (the answer can still change until `closesAt`). */
|
|
143
|
+
answered: boolean;
|
|
144
|
+
score?: GiovRatingScore;
|
|
145
|
+
comment?: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The server's answer to a rating. */
|
|
149
|
+
export interface GiovRatingResult {
|
|
150
|
+
requestId: string;
|
|
151
|
+
score: GiovRatingScore;
|
|
152
|
+
comment?: string;
|
|
153
|
+
closesAt: number;
|
|
154
|
+
ratedAt?: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The visitor's vote on a GiovBot answer. */
|
|
158
|
+
export type GiovFeedbackValue = "up" | "down" | "none";
|
|
159
|
+
|
|
160
|
+
export interface GiovAnswerFeedback {
|
|
161
|
+
messageId: string;
|
|
162
|
+
value: GiovFeedbackValue;
|
|
163
|
+
reason?: string;
|
|
113
164
|
}
|
|
114
165
|
|
|
115
166
|
/** Callback invoked for every incoming message. */
|
package/src/useChatSession.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "./client";
|
|
7
7
|
import { reduceAgentTyping, type AgentTypingState } from "./identity";
|
|
8
8
|
import { chatError, GiovChatError } from "./errors";
|
|
9
|
+
import { applyFeedback, markRating, patchRating, pendingRatingRequest, supersedeRatings } from "./rating";
|
|
9
10
|
import { Outbox, type OutboxEntry } from "./outbox";
|
|
10
11
|
import {
|
|
11
12
|
attachmentDisplayUrl,
|
|
@@ -16,7 +17,10 @@ import {
|
|
|
16
17
|
type GiovAttachmentPreview,
|
|
17
18
|
type GiovChatPickedFile,
|
|
18
19
|
} from "./attachments";
|
|
19
|
-
import type {
|
|
20
|
+
import type {
|
|
21
|
+
GiovAnswerFeedback, GiovChatConnectionStatus, GiovChatContact, GiovChatSession, GiovChatSessionStorage, GiovFeedbackValue, GiovMessage,
|
|
22
|
+
GiovRatingRequest, GiovRatingResult, GiovWidgetConfig, RequestUserInfo,
|
|
23
|
+
} from "./types";
|
|
20
24
|
|
|
21
25
|
export function missingContactFields(fields: RequestUserInfo = {}, contact: GiovChatContact = {}): RequestUserInfo {
|
|
22
26
|
const missing: RequestUserInfo = {};
|
|
@@ -91,6 +95,12 @@ type Options = {
|
|
|
91
95
|
onConnectionStatusChange?: (status: GiovChatConnectionStatus) => void;
|
|
92
96
|
onConversationStart?: (conversationId: string) => void;
|
|
93
97
|
onUnreadCountChange?: (count: number) => void;
|
|
98
|
+
/** A rating card the visitor has not answered arrived (live or in the history). Fires once per card. */
|
|
99
|
+
onRatingRequested?: (request: GiovRatingRequest) => void;
|
|
100
|
+
/** The visitor's rating was stored. */
|
|
101
|
+
onRatingSubmitted?: (result: GiovRatingResult) => void;
|
|
102
|
+
/** The visitor's vote on a GiovBot answer was stored. */
|
|
103
|
+
onAnswerFeedback?: (feedback: GiovAnswerFeedback) => void;
|
|
94
104
|
};
|
|
95
105
|
|
|
96
106
|
/**
|
|
@@ -160,6 +170,16 @@ export function useChatSession(options: Options) {
|
|
|
160
170
|
const [uploads, setUploads] = useState<UploadProgress[]>([]);
|
|
161
171
|
const [requiredFields, setRequiredFields] = useState<RequestUserInfo>({});
|
|
162
172
|
const [hasConversation, setHasConversation] = useState(false);
|
|
173
|
+
// False once the server answered a rating route with 404: an older server, so the controls are hidden.
|
|
174
|
+
const [ratingsSupported, setRatingsSupported] = useState(true);
|
|
175
|
+
const [clock, setClock] = useState(0);
|
|
176
|
+
// The team closed the conversation: it stays on screen, read-only, until the visitor starts a new one.
|
|
177
|
+
const [closed, setClosed] = useState(false);
|
|
178
|
+
const closedRef = useRef(false);
|
|
179
|
+
closedRef.current = closed;
|
|
180
|
+
const messagesRef = useRef(messages);
|
|
181
|
+
messagesRef.current = messages;
|
|
182
|
+
const announcedRatings = useRef(new Set<string>());
|
|
163
183
|
const keepConnected = options.keepConnected === true;
|
|
164
184
|
const wantConnection = foreground && (visible || keepConnected);
|
|
165
185
|
|
|
@@ -257,6 +277,7 @@ export function useChatSession(options: Options) {
|
|
|
257
277
|
const storageFailure = () => report(new GiovChatError("storage", "Chat session could not be saved or restored."));
|
|
258
278
|
const clearSession = async () => {
|
|
259
279
|
session.current = null;
|
|
280
|
+
setClosed(false);
|
|
260
281
|
setHasConversation(false);
|
|
261
282
|
setMessages([]);
|
|
262
283
|
try { await sessionStorage?.remove(scope); } catch { storageFailure(); }
|
|
@@ -330,13 +351,19 @@ export function useChatSession(options: Options) {
|
|
|
330
351
|
} catch { storageFailure(); }
|
|
331
352
|
}
|
|
332
353
|
if (!alive()) return;
|
|
354
|
+
// A 409 on resume means the team closed it: its history is shown read-only when the server still
|
|
355
|
+
// lets this session read it (a rating card can arrive after the close); otherwise it is forgotten.
|
|
356
|
+
let resumedClosed = false;
|
|
333
357
|
if (session.current) {
|
|
334
358
|
try { session.current = await createConversation(baseUrl, tenantKey, contact.current, session.current, controller.signal); }
|
|
335
359
|
catch (failure) {
|
|
336
360
|
if (!alive()) return;
|
|
337
361
|
const error = chatError(failure);
|
|
338
|
-
if (error.code
|
|
339
|
-
|
|
362
|
+
if (error.code === "conversation_closed" && error.status === 409) resumedClosed = true;
|
|
363
|
+
else {
|
|
364
|
+
if (error.code !== "invalid_session" && error.code !== "conversation_closed" && error.status !== 404) throw error;
|
|
365
|
+
await clearSession();
|
|
366
|
+
}
|
|
340
367
|
}
|
|
341
368
|
}
|
|
342
369
|
if (!alive()) return;
|
|
@@ -410,6 +437,22 @@ export function useChatSession(options: Options) {
|
|
|
410
437
|
if (!event.end && event.content) setTypingState(NOT_TYPING);
|
|
411
438
|
});
|
|
412
439
|
connection.onAttachmentAccess((value) => { if (alive()) captureAccess(value); });
|
|
440
|
+
// A card was answered or changed on another device, or replaced by a newer one.
|
|
441
|
+
connection.onRating((frame) => { if (alive()) setMessages((previous) => patchRating(previous, frame)); });
|
|
442
|
+
// The team closed or reopened the conversation: the switch is immediate (older servers send no status).
|
|
443
|
+
connection.onConversationState((state) => {
|
|
444
|
+
if (!alive()) return;
|
|
445
|
+
if (state.status === "closed") {
|
|
446
|
+
setClosed(true);
|
|
447
|
+
setTypingState(NOT_TYPING);
|
|
448
|
+
setDrafts({});
|
|
449
|
+
setError(null);
|
|
450
|
+
setPhase("active");
|
|
451
|
+
} else if (state.status === "open") {
|
|
452
|
+
if (closedRef.current) setClosed(false);
|
|
453
|
+
connection?.flush();
|
|
454
|
+
}
|
|
455
|
+
});
|
|
413
456
|
// A stop from the other sender (person or GiovBot) keeps the indicator that is showing.
|
|
414
457
|
connection.onTyping((active, isBot) => {
|
|
415
458
|
if (alive()) setTypingState((current) => reduceAgentTyping(current, { type: active ? "typing" : "typing_end", role: "agent", isBot }));
|
|
@@ -428,14 +471,25 @@ export function useChatSession(options: Options) {
|
|
|
428
471
|
catch (failure) {
|
|
429
472
|
const error = chatError(failure);
|
|
430
473
|
if (error.status === 404) { await forgetConversation(); return; }
|
|
474
|
+
// A closed conversation this server will not let us read any more: forget it, as before.
|
|
475
|
+
if (resumedClosed && (error.code === "invalid_session" || error.status === 403)) { await forgetConversation(); return; }
|
|
431
476
|
if (error.code === "invalid_session") { await endSession(error); return; }
|
|
432
477
|
throw failure;
|
|
433
478
|
}
|
|
434
479
|
if (!alive()) return;
|
|
435
|
-
if (history.closed) { await forgetConversation(); return; }
|
|
436
480
|
captureAccess(history.attachmentAccess);
|
|
437
481
|
setCurrentAgent({ name: history.assignedAgentName ?? null, avatar: history.assignedAgentAvatar ?? null });
|
|
438
482
|
setMessages((previous) => mergeMessages(previous, history.messages));
|
|
483
|
+
if (history.closed) {
|
|
484
|
+
// Read-only from here on: the history stays visible and a rating card stays answerable.
|
|
485
|
+
setClosed(true);
|
|
486
|
+
setTypingState(NOT_TYPING);
|
|
487
|
+
setDrafts({});
|
|
488
|
+
setError(null);
|
|
489
|
+
setPhase("active");
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
if (closedRef.current) setClosed(false);
|
|
439
493
|
connection?.flush();
|
|
440
494
|
};
|
|
441
495
|
catchUp.current = refreshHistory;
|
|
@@ -467,7 +521,8 @@ export function useChatSession(options: Options) {
|
|
|
467
521
|
}
|
|
468
522
|
}
|
|
469
523
|
});
|
|
470
|
-
await connection.connect(activeSession);
|
|
524
|
+
try { await connection.connect(activeSession); }
|
|
525
|
+
catch (failure) { if (!resumedClosed) throw failure; /* The history below decides whether it can be shown. */ }
|
|
471
526
|
if (!alive()) return;
|
|
472
527
|
await refreshHistory();
|
|
473
528
|
}
|
|
@@ -526,6 +581,8 @@ export function useChatSession(options: Options) {
|
|
|
526
581
|
loadedStorage.current = true;
|
|
527
582
|
outbox.clear();
|
|
528
583
|
localFiles.current.clear();
|
|
584
|
+
announcedRatings.current.clear();
|
|
585
|
+
setClosed(false);
|
|
529
586
|
startNow.current = false;
|
|
530
587
|
// Sign-out: the next visitor's conversation must not get this device's notifications.
|
|
531
588
|
lastPush.current = null;
|
|
@@ -544,6 +601,33 @@ export function useChatSession(options: Options) {
|
|
|
544
601
|
retry();
|
|
545
602
|
}, [sessionStorage, scope, report, retry, outbox, stopTyping]);
|
|
546
603
|
|
|
604
|
+
/**
|
|
605
|
+
* Leaves the closed conversation: it is cleared (messages, stored session) and the chat is ready for
|
|
606
|
+
* a new one, which starts with the visitor's next message. Contact details, the outbox and the push
|
|
607
|
+
* token are kept. Does nothing while the conversation is open.
|
|
608
|
+
*/
|
|
609
|
+
const startNew = useCallback(async () => {
|
|
610
|
+
if (!closedRef.current) return;
|
|
611
|
+
generation.current++;
|
|
612
|
+
stopTyping();
|
|
613
|
+
client.current?.disconnect();
|
|
614
|
+
client.current = null;
|
|
615
|
+
catchUp.current = null;
|
|
616
|
+
session.current = null;
|
|
617
|
+
startNow.current = false;
|
|
618
|
+
announcedRatings.current.clear();
|
|
619
|
+
setClosed(false);
|
|
620
|
+
setHasConversation(false);
|
|
621
|
+
setMessages([]);
|
|
622
|
+
setDrafts({});
|
|
623
|
+
setTypingState(NOT_TYPING);
|
|
624
|
+
setCurrentAgent({ name: null, avatar: null });
|
|
625
|
+
setError(null);
|
|
626
|
+
try { await sessionStorage?.remove(scope); }
|
|
627
|
+
catch { report(new GiovChatError("storage", "Saved chat session could not be cleared.")); }
|
|
628
|
+
retry();
|
|
629
|
+
}, [sessionStorage, scope, report, retry, stopTyping]);
|
|
630
|
+
|
|
547
631
|
/**
|
|
548
632
|
* Sends text and files. Files upload first (they need the conversation); the message then waits in
|
|
549
633
|
* the outbox until a socket takes it, so text typed while reconnecting is never lost. Several
|
|
@@ -554,6 +638,7 @@ export function useChatSession(options: Options) {
|
|
|
554
638
|
const trimmed = text.trim();
|
|
555
639
|
if (!trimmed && !files.length) return false;
|
|
556
640
|
const current = phaseRef.current;
|
|
641
|
+
if (closedRef.current) return false;
|
|
557
642
|
if (current !== "active" && current !== "connecting" && current !== "ready") return false;
|
|
558
643
|
// Uploads need a live conversation; the attach button only appears once one exists.
|
|
559
644
|
if (files.length && !session.current) return false;
|
|
@@ -667,8 +752,67 @@ export function useChatSession(options: Options) {
|
|
|
667
752
|
pushRequest.current = null;
|
|
668
753
|
}, []);
|
|
669
754
|
|
|
755
|
+
const noteUnsupported = useCallback((failure: GiovChatError) => { if (failure.code === "unsupported") setRatingsSupported(false); }, []);
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Rates the conversation. `requestId` defaults to the open card (the server picks it when none is
|
|
759
|
+
* known). Rejects with a `GiovChatError` (`rating_closed`, `request_replaced`, `too_many_changes`,
|
|
760
|
+
* `invalid_rating`, `rate_limited`, `unsupported`, ...). The card on screen is patched on success and
|
|
761
|
+
* made read-only or hidden when the server says it is closed or replaced.
|
|
762
|
+
*/
|
|
763
|
+
const submitRating = useCallback(async (score: number, options: { comment?: string; requestId?: string } = {}): Promise<GiovRatingResult> => {
|
|
764
|
+
const activeClient = client.current;
|
|
765
|
+
if (!activeClient) throw new GiovChatError("disconnected", "Chat is not connected.");
|
|
766
|
+
const requestId = options.requestId ?? pendingRatingRequest(messagesRef.current, Date.now())?.requestId;
|
|
767
|
+
try {
|
|
768
|
+
const result = await activeClient.submitRating(score, { comment: options.comment, requestId });
|
|
769
|
+
setMessages((previous) => patchRating(previous, { requestId: result.requestId, closesAt: result.closesAt, score: result.score, comment: result.comment }));
|
|
770
|
+
try { callbacks.current.onRatingSubmitted?.(result); } catch { /* Host callbacks are isolated. */ }
|
|
771
|
+
return result;
|
|
772
|
+
} catch (failure) {
|
|
773
|
+
const error = chatError(failure);
|
|
774
|
+
noteUnsupported(error);
|
|
775
|
+
if (requestId && error.code === "rating_closed") setMessages((previous) => markRating(previous, requestId, "closed"));
|
|
776
|
+
if (requestId && error.code === "request_replaced") setMessages((previous) => markRating(previous, requestId, "replaced"));
|
|
777
|
+
throw error;
|
|
778
|
+
}
|
|
779
|
+
}, [noteUnsupported]);
|
|
780
|
+
|
|
781
|
+
/** Thumbs on a GiovBot answer, shown at once and taken back if the server refuses. `none` removes the vote. */
|
|
782
|
+
const submitAnswerFeedback = useCallback(async (messageId: string, value: GiovFeedbackValue, options: { reason?: string } = {}): Promise<void> => {
|
|
783
|
+
const activeClient = client.current;
|
|
784
|
+
if (!activeClient) throw new GiovChatError("disconnected", "Chat is not connected.");
|
|
785
|
+
const before = messagesRef.current.find((message) => message.id === messageId)?.feedback ?? "none";
|
|
786
|
+
setMessages((previous) => applyFeedback(previous, messageId, value));
|
|
787
|
+
try {
|
|
788
|
+
await activeClient.submitAnswerFeedback(messageId, value, options);
|
|
789
|
+
} catch (failure) {
|
|
790
|
+
const error = chatError(failure);
|
|
791
|
+
noteUnsupported(error);
|
|
792
|
+
setMessages((previous) => applyFeedback(previous, messageId, before));
|
|
793
|
+
throw error;
|
|
794
|
+
}
|
|
795
|
+
try { callbacks.current.onAnswerFeedback?.({ messageId, value, ...(value === "down" && options.reason?.trim() ? { reason: options.reason.trim() } : {}) }); } catch { /* Host callbacks are isolated. */ }
|
|
796
|
+
}, [noteUnsupported]);
|
|
797
|
+
|
|
670
798
|
const describe = useCallback((url: string) => localFiles.current.get(url) ?? { filename: "" }, []);
|
|
671
|
-
|
|
799
|
+
// An older card the visitor never answered gives way to a newer one at once.
|
|
800
|
+
const shown = useMemo(() => supersedeRatings(displayMessages(messages, pending, drafts, describe)), [messages, pending, drafts, describe]);
|
|
801
|
+
const ratingRequest = useMemo(() => pendingRatingRequest(messages, Date.now()), [messages, clock]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
802
|
+
// The card closes by the clock: look again at `closesAt`.
|
|
803
|
+
useEffect(() => {
|
|
804
|
+
if (!ratingRequest) return;
|
|
805
|
+
const wait = ratingRequest.closesAt - Date.now();
|
|
806
|
+
if (wait <= 0) return;
|
|
807
|
+
const timer = setTimeout(() => setClock((value) => value + 1), Math.min(wait + 50, 2 ** 31 - 1));
|
|
808
|
+
return () => clearTimeout(timer);
|
|
809
|
+
}, [ratingRequest]);
|
|
810
|
+
// "Rating requested" once per card the visitor has not answered.
|
|
811
|
+
useEffect(() => {
|
|
812
|
+
if (!ratingRequest || ratingRequest.answered || announcedRatings.current.has(ratingRequest.requestId)) return;
|
|
813
|
+
announcedRatings.current.add(ratingRequest.requestId);
|
|
814
|
+
try { callbacks.current.onRatingRequested?.(ratingRequest); } catch { /* Host callbacks are isolated. */ }
|
|
815
|
+
}, [ratingRequest]);
|
|
672
816
|
// A conversation open for a long time: without a usable access token, images fall back to the
|
|
673
817
|
// 15-minute visitor token. Renew it when messages arrive and it is close to expiry, at most once a
|
|
674
818
|
// minute (the web widget's rule); the new URLs make failed images load again.
|
|
@@ -691,5 +835,7 @@ export function useChatSession(options: Options) {
|
|
|
691
835
|
currentAgentName: currentAgent.name, currentAgentAvatar: currentAgent.avatar, unreadCount: visible ? 0 : unreadCount,
|
|
692
836
|
lastIncoming,
|
|
693
837
|
retry, reset, submitContact, send, retryMessage, discardMessage, notifyTyping, attachmentUrl, registerPushToken, unregisterPushToken,
|
|
838
|
+
ratingRequest, ratingsSupported, submitRating, submitAnswerFeedback,
|
|
839
|
+
closed, startNew,
|
|
694
840
|
};
|
|
695
841
|
}
|