simple-support-chat 0.3.3 → 0.4.1

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.
@@ -31,6 +31,10 @@ interface ChatBubbleProps {
31
31
  user?: ChatUser;
32
32
  /** URL to poll for team replies (e.g., '/api/support/replies'). Enables bidirectional chat. */
33
33
  repliesUrl?: string;
34
+ /** URL for SSE endpoint (optional — enables real-time delivery when provided). */
35
+ sseUrl?: string;
36
+ /** Polling interval in milliseconds. Default: 4000 (4 seconds). */
37
+ pollInterval?: number;
34
38
  /**
35
39
  * Controlled mode: when provided, overrides internal open/close state.
36
40
  * The chat panel is open when `isOpen` is true, closed when false.
@@ -98,6 +102,10 @@ interface SupportChatModalProps {
98
102
  user?: ChatUser;
99
103
  /** URL to poll for team replies (e.g., '/api/support/replies'). Enables bidirectional chat. */
100
104
  repliesUrl?: string;
105
+ /** URL for SSE endpoint (optional — enables real-time delivery when provided). */
106
+ sseUrl?: string;
107
+ /** Polling interval in milliseconds. Default: 4000 (4 seconds). */
108
+ pollInterval?: number;
101
109
  }
102
110
  /** Anonymous context collected automatically from the browser */
103
111
  interface AnonymousContext {
@@ -120,7 +128,7 @@ interface AnonymousContext {
120
128
  * <ChatBubble apiUrl="/api/support" />
121
129
  * ```
122
130
  */
123
- declare function ChatBubble({ apiUrl, position, color, title, placeholder, show, user, repliesUrl, isOpen: isOpenProp, onOpenChange, badgeColor, unreadCount, }: ChatBubbleProps): react_jsx_runtime.JSX.Element;
131
+ declare function ChatBubble({ apiUrl, position, color, title, placeholder, show, user, repliesUrl, sseUrl, pollInterval, isOpen: isOpenProp, onOpenChange, badgeColor, unreadCount, }: ChatBubbleProps): react_jsx_runtime.JSX.Element;
124
132
 
125
133
  /**
126
134
  * SupportChatModal -- modal-based support chat.
@@ -152,7 +160,7 @@ declare function ChatBubble({ apiUrl, position, color, title, placeholder, show,
152
160
  * }
153
161
  * ```
154
162
  */
155
- declare function SupportChatModal({ apiUrl, isOpen, onClose, color, title, placeholder, user, repliesUrl, }: SupportChatModalProps): react_jsx_runtime.JSX.Element | null;
163
+ declare function SupportChatModal({ apiUrl, isOpen, onClose, color, title, placeholder, user, repliesUrl, sseUrl, pollInterval, }: SupportChatModalProps): react_jsx_runtime.JSX.Element | null;
156
164
 
157
165
  /**
158
166
  * Hook that returns controls for opening/closing the support chat.
@@ -166,6 +174,50 @@ declare function SupportChatModal({ apiUrl, isOpen, onClose, color, title, place
166
174
  */
167
175
  declare function useSupportChat(): SupportChatState;
168
176
 
177
+ /** Transport mode for reply delivery */
178
+ type TransportMode = "sse" | "polling" | "disconnected";
179
+ /** Options for useReplyTransport */
180
+ interface ReplyTransportOptions {
181
+ /** URL for SSE endpoint (optional — enables SSE transport when provided) */
182
+ sseUrl?: string;
183
+ /** URL for polling replies (optional — used as fallback or primary when no sseUrl) */
184
+ repliesUrl?: string;
185
+ /** Session identifier for the chat session */
186
+ sessionId: string;
187
+ /** Whether the transport should be active (connected/polling) */
188
+ isActive: boolean;
189
+ /** Polling interval in milliseconds. Default: 4000 (4 seconds). */
190
+ pollInterval?: number;
191
+ }
192
+ /** Return value from useReplyTransport */
193
+ interface ReplyTransportState {
194
+ /** Accumulated replies from the transport */
195
+ replies: ChatMessage[];
196
+ /** Current transport mode */
197
+ transport: TransportMode;
198
+ }
199
+ /**
200
+ * Internal hook that manages reply delivery via SSE or polling fallback.
201
+ *
202
+ * When `sseUrl` is provided and `isActive` is true, connects via EventSource
203
+ * for real-time reply delivery. If the EventSource connection fails, automatically
204
+ * falls back to polling via `repliesUrl`.
205
+ *
206
+ * When only `repliesUrl` is provided (no `sseUrl`), uses polling directly,
207
+ * preserving full backward compatibility with existing setups.
208
+ *
209
+ * @example
210
+ * ```ts
211
+ * const { replies, transport } = useReplyTransport({
212
+ * sseUrl: '/api/support/sse',
213
+ * repliesUrl: '/api/support/replies',
214
+ * sessionId: 'user-123',
215
+ * isActive: true,
216
+ * });
217
+ * ```
218
+ */
219
+ declare function useReplyTransport({ sseUrl, repliesUrl, sessionId, isActive, pollInterval, }: ReplyTransportOptions): ReplyTransportState;
220
+
169
221
  /** Options for initializing the chat engine */
170
222
  interface ChatEngineOptions {
171
223
  /** URL of the support chat API endpoint */
@@ -174,8 +226,12 @@ interface ChatEngineOptions {
174
226
  user?: ChatUser;
175
227
  /** URL to poll for team replies. When set, polling starts while chat is active. */
176
228
  repliesUrl?: string;
229
+ /** URL for SSE endpoint (optional — enables real-time delivery when provided). */
230
+ sseUrl?: string;
177
231
  /** Whether the chat panel is currently visible (controls polling lifecycle). Defaults to true. */
178
232
  isOpen?: boolean;
233
+ /** Polling interval in milliseconds. Default: 4000 (4 seconds). */
234
+ pollInterval?: number;
179
235
  }
180
236
  /** Return value from useChatEngine */
181
237
  interface ChatEngineState {
@@ -191,56 +247,64 @@ interface ChatEngineState {
191
247
  sendMessage: () => Promise<void>;
192
248
  /** Handle keydown on the input (Enter to send) */
193
249
  handleKeyDown: (e: React.KeyboardEvent) => void;
250
+ /** Current transport mode */
251
+ transport: TransportMode;
194
252
  }
195
253
  /**
196
254
  * Shared chat engine hook used by both ChatBubble and SupportChatModal.
197
- * Manages message state, input, API communication, and reply polling.
255
+ * Manages message state, input, API communication, and reply delivery
256
+ * via useReplyTransport (SSE with polling fallback).
198
257
  */
199
- declare function useChatEngine({ apiUrl, user, repliesUrl, isOpen, }: ChatEngineOptions): ChatEngineState;
258
+ declare function useChatEngine({ apiUrl, user, repliesUrl, sseUrl, isOpen, pollInterval, }: ChatEngineOptions): ChatEngineState;
200
259
 
201
- /** Options for the useUnreadCount hook */
260
+ /** Options for useUnreadCount */
202
261
  interface UnreadCountOptions {
203
- /** URL to poll for team replies (e.g., '/api/support/replies') */
204
- repliesUrl: string;
205
- /** Authenticated user info (optional). When provided, user.id is used as sessionId. */
262
+ /** URL for SSE endpoint (optional — enables real-time delivery when provided). */
263
+ sseUrl?: string;
264
+ /** URL to poll for team replies (e.g., '/api/support/replies'). */
265
+ repliesUrl?: string;
266
+ /** Authenticated user info (optional — uses user.id as sessionId if provided). */
206
267
  user?: ChatUser;
207
- /** Whether the chat panel is currently open. Polling pauses when true. */
268
+ /** Whether the chat panel is currently open. When open, unread count resets to 0. */
208
269
  isOpen: boolean;
209
270
  }
210
271
  /** Return value from useUnreadCount */
211
272
  interface UnreadCountState {
212
- /** Number of unread reply messages */
273
+ /** Number of unread replies received while the chat was closed */
213
274
  unreadCount: number;
214
275
  /** Convenience boolean: true when unreadCount > 0 */
215
276
  hasUnread: boolean;
216
- /** Pre-fetched reply messages ready to display instantly when chat opens */
277
+ /** Pre-cached pending replies for instant display when chat opens */
217
278
  pendingReplies: ChatMessage[];
218
279
  /** Reset unreadCount to 0 and clear pendingReplies */
219
280
  markAsRead: () => void;
281
+ /** Current transport mode */
282
+ transport: TransportMode;
220
283
  }
221
284
  /**
222
- * Hook that tracks unread reply count in the background via polling.
285
+ * Hook that tracks unread reply count when the chat is closed.
223
286
  *
224
- * Simple count-based model:
225
- * - On mount, fetches total reply count and records it as the "baseline"
226
- * - Unread = (current total) - baseline
227
- * - Page refresh resets to 0 (new baseline)
228
- * - Opening the chat or calling markAsRead resets baseline to current total
287
+ * Uses useReplyTransport internally to receive replies via SSE or polling.
288
+ * The transport stays active even when the chat is closed so replies arrive
289
+ * in real-time and the unread badge updates immediately.
290
+ *
291
+ * When the chat opens (isOpen becomes true), the unread count resets to 0.
292
+ * The pendingReplies array provides pre-cached replies for instant display.
229
293
  *
230
294
  * @example
231
- * ```tsx
232
- * const { unreadCount, hasUnread, markAsRead } = useUnreadCount({
295
+ * ```ts
296
+ * const { unreadCount, hasUnread, pendingReplies, markAsRead } = useUnreadCount({
297
+ * sseUrl: '/api/support/sse',
233
298
  * repliesUrl: '/api/support/replies',
234
- * user: currentUser,
235
299
  * isOpen: false,
236
300
  * });
237
301
  * ```
238
302
  */
239
- declare function useUnreadCount({ repliesUrl, user, isOpen, }: UnreadCountOptions): UnreadCountState;
303
+ declare function useUnreadCount({ sseUrl, repliesUrl, user, isOpen, }: UnreadCountOptions): UnreadCountState;
240
304
 
241
305
  /** Get or create a persistent session ID from localStorage */
242
306
  declare function getSessionId(): string;
243
307
  /** Collect anonymous browser context */
244
308
  declare function collectAnonymousContext(): AnonymousContext;
245
309
 
246
- export { type AnonymousContext, type BubblePosition, ChatBubble, type ChatBubbleProps, type ChatEngineOptions, type ChatEngineState, type ChatMessage, type ChatUser, SupportChatModal, type SupportChatModalProps, type SupportChatState, type UnreadCountOptions, type UnreadCountState, collectAnonymousContext, getSessionId, useChatEngine, useSupportChat, useUnreadCount };
310
+ export { type AnonymousContext, type BubblePosition, ChatBubble, type ChatBubbleProps, type ChatEngineOptions, type ChatEngineState, type ChatMessage, type ChatUser, type ReplyTransportOptions, type ReplyTransportState, SupportChatModal, type SupportChatModalProps, type SupportChatState, type TransportMode, type UnreadCountOptions, type UnreadCountState, collectAnonymousContext, getSessionId, useChatEngine, useReplyTransport, useSupportChat, useUnreadCount };
@@ -46,20 +46,183 @@ function collectAnonymousContext() {
46
46
  sessionId
47
47
  };
48
48
  }
49
+ var DEFAULT_POLL_INTERVAL = 4e3;
50
+ function useReplyTransport({
51
+ sseUrl,
52
+ repliesUrl,
53
+ sessionId,
54
+ isActive,
55
+ pollInterval = DEFAULT_POLL_INTERVAL
56
+ }) {
57
+ const [replies, setReplies] = useState([]);
58
+ const [transport, setTransport] = useState("disconnected");
59
+ const knownReplyIdsRef = useRef(/* @__PURE__ */ new Set());
60
+ const lastReplyTimestampRef = useRef(null);
61
+ const sseFailedRef = useRef(false);
62
+ const eventSourceRef = useRef(null);
63
+ const pollIntervalRef = useRef(null);
64
+ const needsCatchUpRef = useRef(false);
65
+ const addReplies = useCallback(
66
+ (newReplies) => {
67
+ const deduplicated = newReplies.filter(
68
+ (r) => !knownReplyIdsRef.current.has(r.id)
69
+ );
70
+ if (deduplicated.length === 0) return;
71
+ for (const r of deduplicated) {
72
+ knownReplyIdsRef.current.add(r.id);
73
+ }
74
+ const latestTimestamp = deduplicated.reduce((latest, r) => {
75
+ return r.timestamp > latest ? r.timestamp : latest;
76
+ }, lastReplyTimestampRef.current ?? "");
77
+ lastReplyTimestampRef.current = latestTimestamp;
78
+ const replyMessages = deduplicated.map((r) => ({
79
+ id: r.id,
80
+ text: r.text,
81
+ sender: "received",
82
+ timestamp: new Date(r.timestamp).getTime()
83
+ }));
84
+ setReplies((prev) => [...prev, ...replyMessages]);
85
+ },
86
+ []
87
+ );
88
+ const fetchReplies = useCallback(async () => {
89
+ if (!repliesUrl) return;
90
+ try {
91
+ const params = new URLSearchParams({ sessionId });
92
+ if (lastReplyTimestampRef.current) {
93
+ params.set("since", lastReplyTimestampRef.current);
94
+ }
95
+ const response = await fetch(`${repliesUrl}?${params.toString()}`);
96
+ if (!response.ok) return;
97
+ const data = await response.json();
98
+ if (data.replies.length === 0) return;
99
+ addReplies(data.replies);
100
+ } catch {
101
+ }
102
+ }, [repliesUrl, sessionId, addReplies]);
103
+ const startPolling = useCallback(() => {
104
+ if (!repliesUrl) return;
105
+ if (pollIntervalRef.current !== null) {
106
+ clearInterval(pollIntervalRef.current);
107
+ }
108
+ setTransport("polling");
109
+ void fetchReplies();
110
+ pollIntervalRef.current = setInterval(
111
+ () => void fetchReplies(),
112
+ pollInterval
113
+ );
114
+ }, [repliesUrl, fetchReplies]);
115
+ const stopPolling = useCallback(() => {
116
+ if (pollIntervalRef.current !== null) {
117
+ clearInterval(pollIntervalRef.current);
118
+ pollIntervalRef.current = null;
119
+ }
120
+ }, []);
121
+ const connectSSE = useCallback(() => {
122
+ if (!sseUrl || sseFailedRef.current) return;
123
+ if (eventSourceRef.current) {
124
+ eventSourceRef.current.close();
125
+ eventSourceRef.current = null;
126
+ }
127
+ const url = `${sseUrl}?sessionId=${encodeURIComponent(sessionId)}`;
128
+ const es = new EventSource(url);
129
+ eventSourceRef.current = es;
130
+ es.addEventListener("reply", (event) => {
131
+ try {
132
+ const data = JSON.parse(event.data);
133
+ addReplies([data.reply]);
134
+ } catch {
135
+ }
136
+ });
137
+ es.addEventListener("heartbeat", () => {
138
+ });
139
+ es.onopen = () => {
140
+ setTransport("sse");
141
+ if (needsCatchUpRef.current) {
142
+ needsCatchUpRef.current = false;
143
+ void fetchReplies();
144
+ }
145
+ };
146
+ es.onerror = () => {
147
+ if (es.readyState === EventSource.CLOSED) {
148
+ sseFailedRef.current = true;
149
+ es.close();
150
+ eventSourceRef.current = null;
151
+ startPolling();
152
+ } else {
153
+ needsCatchUpRef.current = true;
154
+ }
155
+ };
156
+ }, [sseUrl, sessionId, addReplies, fetchReplies, startPolling]);
157
+ const disconnectSSE = useCallback(() => {
158
+ if (eventSourceRef.current) {
159
+ eventSourceRef.current.close();
160
+ eventSourceRef.current = null;
161
+ }
162
+ }, []);
163
+ useEffect(() => {
164
+ if (!isActive) {
165
+ disconnectSSE();
166
+ stopPolling();
167
+ setTransport("disconnected");
168
+ return;
169
+ }
170
+ if (!sseUrl && !repliesUrl) {
171
+ setTransport("disconnected");
172
+ return;
173
+ }
174
+ if (sseUrl && !sseFailedRef.current) {
175
+ connectSSE();
176
+ } else if (repliesUrl) {
177
+ startPolling();
178
+ }
179
+ return () => {
180
+ disconnectSSE();
181
+ stopPolling();
182
+ };
183
+ }, [
184
+ isActive,
185
+ sseUrl,
186
+ repliesUrl,
187
+ connectSSE,
188
+ disconnectSSE,
189
+ startPolling,
190
+ stopPolling
191
+ ]);
192
+ useEffect(() => {
193
+ sseFailedRef.current = false;
194
+ }, [sseUrl]);
195
+ return { replies, transport };
196
+ }
49
197
 
50
198
  // src/client/useChatEngine.ts
51
- var POLL_INTERVAL = 4e3;
52
199
  function useChatEngine({
53
200
  apiUrl,
54
201
  user,
55
202
  repliesUrl,
56
- isOpen = true
203
+ sseUrl,
204
+ isOpen = true,
205
+ pollInterval
57
206
  }) {
58
207
  const [messages, setMessages] = useState([]);
59
208
  const [input, setInput] = useState("");
60
209
  const [sending, setSending] = useState(false);
61
- const lastReplyTimestampRef = useRef(null);
62
- const knownReplyIdsRef = useRef(/* @__PURE__ */ new Set());
210
+ const sessionId = user?.id ?? getSessionId();
211
+ const { replies, transport } = useReplyTransport({
212
+ sseUrl,
213
+ repliesUrl,
214
+ sessionId,
215
+ isActive: isOpen,
216
+ pollInterval
217
+ });
218
+ const processedReplyCountRef = useRef(0);
219
+ useEffect(() => {
220
+ if (replies.length > processedReplyCountRef.current) {
221
+ const newReplies = replies.slice(processedReplyCountRef.current);
222
+ processedReplyCountRef.current = replies.length;
223
+ setMessages((prev) => [...prev, ...newReplies]);
224
+ }
225
+ }, [replies]);
63
226
  const sendMessage = useCallback(async () => {
64
227
  const text = input.trim();
65
228
  if (!text || sending) return;
@@ -80,7 +243,7 @@ function useChatEngine({
80
243
  body: JSON.stringify({
81
244
  message: text,
82
245
  user: user ?? void 0,
83
- sessionId: user?.id ?? getSessionId(),
246
+ sessionId,
84
247
  context
85
248
  })
86
249
  });
@@ -108,7 +271,7 @@ function useChatEngine({
108
271
  } finally {
109
272
  setSending(false);
110
273
  }
111
- }, [input, sending, apiUrl, user]);
274
+ }, [input, sending, apiUrl, user, sessionId]);
112
275
  const handleKeyDown = useCallback(
113
276
  (e) => {
114
277
  if (e.key === "Enter" && !e.shiftKey) {
@@ -118,45 +281,7 @@ function useChatEngine({
118
281
  },
119
282
  [sendMessage]
120
283
  );
121
- useEffect(() => {
122
- if (!repliesUrl || !isOpen) return;
123
- const sessionId = user?.id ?? getSessionId();
124
- const fetchReplies = async () => {
125
- try {
126
- const params = new URLSearchParams({ sessionId });
127
- if (lastReplyTimestampRef.current) {
128
- params.set("since", lastReplyTimestampRef.current);
129
- }
130
- const response = await fetch(`${repliesUrl}?${params.toString()}`);
131
- if (!response.ok) return;
132
- const data = await response.json();
133
- if (data.replies.length === 0) return;
134
- const newReplies = data.replies.filter(
135
- (r) => !knownReplyIdsRef.current.has(r.id)
136
- );
137
- if (newReplies.length === 0) return;
138
- for (const r of newReplies) {
139
- knownReplyIdsRef.current.add(r.id);
140
- }
141
- const latestTimestamp = newReplies.reduce((latest, r) => {
142
- return r.timestamp > latest ? r.timestamp : latest;
143
- }, lastReplyTimestampRef.current ?? "");
144
- lastReplyTimestampRef.current = latestTimestamp;
145
- const replyMessages = newReplies.map((r) => ({
146
- id: r.id,
147
- text: r.text,
148
- sender: "received",
149
- timestamp: new Date(r.timestamp).getTime()
150
- }));
151
- setMessages((prev) => [...prev, ...replyMessages]);
152
- } catch {
153
- }
154
- };
155
- void fetchReplies();
156
- const intervalId = setInterval(() => void fetchReplies(), POLL_INTERVAL);
157
- return () => clearInterval(intervalId);
158
- }, [repliesUrl, isOpen, user]);
159
- return { messages, input, setInput, sending, sendMessage, handleKeyDown };
284
+ return { messages, input, setInput, sending, sendMessage, handleKeyDown, transport };
160
285
  }
161
286
  function useColorScheme() {
162
287
  const [scheme, setScheme] = useState(() => {
@@ -231,6 +356,8 @@ function ChatBubble({
231
356
  show = true,
232
357
  user,
233
358
  repliesUrl,
359
+ sseUrl,
360
+ pollInterval,
234
361
  isOpen: isOpenProp,
235
362
  onOpenChange,
236
363
  badgeColor = "#eab308",
@@ -253,7 +380,7 @@ function ChatBubble({
253
380
  );
254
381
  const colorScheme = useColorScheme();
255
382
  const theme = useMemo(() => getThemeTokens(colorScheme), [colorScheme]);
256
- const { messages, input, setInput, sending, sendMessage, handleKeyDown } = useChatEngine({ apiUrl, user, repliesUrl, isOpen });
383
+ const { messages, input, setInput, sending, sendMessage, handleKeyDown } = useChatEngine({ apiUrl, user, repliesUrl, sseUrl, isOpen, pollInterval });
257
384
  const [panelState, setPanelState] = useState(
258
385
  "closed"
259
386
  );
@@ -623,9 +750,11 @@ function SupportChatModal({
623
750
  title = "Contact Us",
624
751
  placeholder = "Type a message...",
625
752
  user,
626
- repliesUrl
753
+ repliesUrl,
754
+ sseUrl,
755
+ pollInterval
627
756
  }) {
628
- const { messages, input, setInput, sending, sendMessage, handleKeyDown } = useChatEngine({ apiUrl, user, repliesUrl, isOpen });
757
+ const { messages, input, setInput, sending, sendMessage, handleKeyDown } = useChatEngine({ apiUrl, user, repliesUrl, sseUrl, isOpen, pollInterval });
629
758
  const colorScheme = useColorScheme();
630
759
  const theme = useMemo(() => getThemeTokens(colorScheme), [colorScheme]);
631
760
  const modalRef = useRef(null);
@@ -908,75 +1037,47 @@ function useSupportChat() {
908
1037
  const toggle = useCallback(() => setIsOpen((prev) => !prev), []);
909
1038
  return { open, close, toggle, isOpen };
910
1039
  }
911
- var POLL_INTERVAL2 = 4e3;
912
1040
  function useUnreadCount({
1041
+ sseUrl,
913
1042
  repliesUrl,
914
1043
  user,
915
1044
  isOpen
916
1045
  }) {
917
- const baselineCountRef = useRef(null);
918
- const [allReplies, setAllReplies] = useState([]);
919
- const totalCountRef = useRef(0);
920
- const prevIsOpenRef = useRef(isOpen);
1046
+ const sessionId = user?.id ?? getSessionId();
1047
+ const { replies, transport } = useReplyTransport({
1048
+ sseUrl,
1049
+ repliesUrl,
1050
+ sessionId,
1051
+ isActive: true
1052
+ });
1053
+ const [unreadCount, setUnreadCount] = useState(0);
1054
+ const [pendingReplies, setPendingReplies] = useState([]);
1055
+ const countedReplyCountRef = useRef(0);
1056
+ const wasOpenRef = useRef(isOpen);
921
1057
  useEffect(() => {
922
- if (isOpen && !prevIsOpenRef.current) {
923
- baselineCountRef.current = totalCountRef.current;
924
- setAllReplies([]);
1058
+ if (isOpen && !wasOpenRef.current) {
1059
+ setUnreadCount(0);
1060
+ setPendingReplies([]);
925
1061
  }
926
- prevIsOpenRef.current = isOpen;
1062
+ wasOpenRef.current = isOpen;
927
1063
  }, [isOpen]);
928
1064
  const markAsRead = useCallback(() => {
929
- baselineCountRef.current = totalCountRef.current;
930
- setAllReplies([]);
1065
+ setUnreadCount(0);
1066
+ setPendingReplies([]);
931
1067
  }, []);
932
1068
  useEffect(() => {
933
- if (!repliesUrl || isOpen) return;
934
- const sessionId = user?.id ?? getSessionId();
935
- let cancelled = false;
936
- const fetchReplies = async () => {
937
- try {
938
- const params = new URLSearchParams({ sessionId });
939
- const response = await fetch(`${repliesUrl}?${params.toString()}`);
940
- if (!response.ok || cancelled) return;
941
- const data = await response.json();
942
- if (cancelled) return;
943
- const currentTotal = data.replies.length;
944
- totalCountRef.current = currentTotal;
945
- if (baselineCountRef.current === null) {
946
- baselineCountRef.current = currentTotal;
947
- return;
948
- }
949
- const newCount = currentTotal - baselineCountRef.current;
950
- if (newCount > 0) {
951
- const newReplies = data.replies.slice(-newCount);
952
- setAllReplies(
953
- newReplies.map((r) => ({
954
- id: r.id,
955
- text: r.text,
956
- sender: "received",
957
- timestamp: new Date(r.timestamp).getTime()
958
- }))
959
- );
960
- }
961
- } catch {
1069
+ if (replies.length > countedReplyCountRef.current) {
1070
+ const newReplies = replies.slice(countedReplyCountRef.current);
1071
+ countedReplyCountRef.current = replies.length;
1072
+ if (!isOpen) {
1073
+ setUnreadCount((prev) => prev + newReplies.length);
1074
+ setPendingReplies((prev) => [...prev, ...newReplies]);
962
1075
  }
963
- };
964
- void fetchReplies();
965
- const intervalId = setInterval(() => void fetchReplies(), POLL_INTERVAL2);
966
- return () => {
967
- cancelled = true;
968
- clearInterval(intervalId);
969
- };
970
- }, [repliesUrl, isOpen, user]);
971
- const unreadCount = baselineCountRef.current !== null ? Math.max(0, totalCountRef.current - baselineCountRef.current) : 0;
972
- return {
973
- unreadCount,
974
- hasUnread: unreadCount > 0,
975
- pendingReplies: allReplies,
976
- markAsRead
977
- };
1076
+ }
1077
+ }, [replies, isOpen]);
1078
+ return { unreadCount, hasUnread: unreadCount > 0, pendingReplies, markAsRead, transport };
978
1079
  }
979
1080
 
980
- export { ChatBubble, SupportChatModal, collectAnonymousContext, getSessionId, useChatEngine, useSupportChat, useUnreadCount };
1081
+ export { ChatBubble, SupportChatModal, collectAnonymousContext, getSessionId, useChatEngine, useReplyTransport, useSupportChat, useUnreadCount };
981
1082
  //# sourceMappingURL=index.js.map
982
1083
  //# sourceMappingURL=index.js.map