@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.
Files changed (73) hide show
  1. package/README.md +66 -9
  2. package/dist/GiovChat.d.ts +35 -0
  3. package/dist/GiovChat.d.ts.map +1 -1
  4. package/dist/GiovChat.js +45 -8
  5. package/dist/GiovChat.js.map +1 -1
  6. package/dist/brandingStyle.d.ts +38 -0
  7. package/dist/brandingStyle.d.ts.map +1 -0
  8. package/dist/brandingStyle.js +71 -0
  9. package/dist/brandingStyle.js.map +1 -0
  10. package/dist/client.d.ts +38 -2
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +91 -1
  13. package/dist/client.js.map +1 -1
  14. package/dist/components/Branding.d.ts +24 -1
  15. package/dist/components/Branding.d.ts.map +1 -1
  16. package/dist/components/Branding.js +49 -6
  17. package/dist/components/Branding.js.map +1 -1
  18. package/dist/components/MessageBubble.d.ts +8 -1
  19. package/dist/components/MessageBubble.d.ts.map +1 -1
  20. package/dist/components/MessageBubble.js +19 -17
  21. package/dist/components/MessageBubble.js.map +1 -1
  22. package/dist/components/RatingCard.d.ts +23 -0
  23. package/dist/components/RatingCard.d.ts.map +1 -0
  24. package/dist/components/RatingCard.js +107 -0
  25. package/dist/components/RatingCard.js.map +1 -0
  26. package/dist/errors.d.ts +3 -1
  27. package/dist/errors.d.ts.map +1 -1
  28. package/dist/errors.js.map +1 -1
  29. package/dist/i18n.d.ts +23 -0
  30. package/dist/i18n.d.ts.map +1 -1
  31. package/dist/i18n.js +100 -0
  32. package/dist/i18n.js.map +1 -1
  33. package/dist/index.d.ts +12 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +19 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/protocol.d.ts.map +1 -1
  38. package/dist/protocol.js +5 -0
  39. package/dist/protocol.js.map +1 -1
  40. package/dist/rating.d.ts +41 -0
  41. package/dist/rating.d.ts.map +1 -0
  42. package/dist/rating.js +165 -0
  43. package/dist/rating.js.map +1 -0
  44. package/dist/ratingStore.d.ts +36 -0
  45. package/dist/ratingStore.d.ts.map +1 -0
  46. package/dist/ratingStore.js +55 -0
  47. package/dist/ratingStore.js.map +1 -0
  48. package/dist/styleOverrides.d.ts +10 -2
  49. package/dist/styleOverrides.d.ts.map +1 -1
  50. package/dist/styleOverrides.js +9 -0
  51. package/dist/styleOverrides.js.map +1 -1
  52. package/dist/types.d.ts +45 -0
  53. package/dist/types.d.ts.map +1 -1
  54. package/dist/useChatSession.d.ts +18 -1
  55. package/dist/useChatSession.d.ts.map +1 -1
  56. package/dist/useChatSession.js +178 -9
  57. package/dist/useChatSession.js.map +1 -1
  58. package/package.json +3 -2
  59. package/src/GiovChat.tsx +121 -11
  60. package/src/brandingStyle.ts +87 -0
  61. package/src/client.ts +92 -2
  62. package/src/components/Branding.tsx +66 -7
  63. package/src/components/MessageBubble.tsx +17 -1
  64. package/src/components/RatingCard.tsx +219 -0
  65. package/src/errors.ts +4 -1
  66. package/src/i18n.ts +123 -0
  67. package/src/index.ts +17 -0
  68. package/src/protocol.ts +5 -0
  69. package/src/rating.ts +169 -0
  70. package/src/ratingStore.ts +65 -0
  71. package/src/styleOverrides.ts +15 -2
  72. package/src/types.ts +51 -0
  73. 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
+ }
@@ -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 or branding overrides. */
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. */
@@ -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 { GiovChatConnectionStatus, GiovChatContact, GiovChatSession, GiovChatSessionStorage, GiovMessage, GiovWidgetConfig, RequestUserInfo } from "./types";
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 !== "invalid_session" && error.code !== "conversation_closed" && error.status !== 404) throw error;
339
- await clearSession();
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
- const shown = useMemo(() => displayMessages(messages, pending, drafts, describe), [messages, pending, drafts, describe]);
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
  }