simple-support-chat 0.3.2 → 0.4.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 CHANGED
@@ -5,6 +5,7 @@ Embeddable chat widget SDK that routes customer messages to Slack threads. Open-
5
5
  - Zero cost, self-hosted
6
6
  - Slack-native: messages appear as threads in your workspace
7
7
  - Bidirectional: team replies in Slack threads show in the chat widget
8
+ - Real-time delivery via SSE, with automatic polling fallback
8
9
  - Slack emoji shortcodes (`:heart:`) auto-converted to Unicode (❤️)
9
10
  - Works with any React app (Next.js, Remix, Vite, etc.)
10
11
  - Anonymous and authenticated user support
@@ -407,6 +408,182 @@ const store = new SupabaseChatStore();
407
408
  // Use this store instance in createSupportHandler, createWebhookHandler, and createRepliesHandler
408
409
  ```
409
410
 
411
+ ## Real-time Replies (SSE)
412
+
413
+ By default, bidirectional chat uses polling (the client fetches `/api/support/replies` every 4 seconds). Starting in v0.4.0, you can upgrade to **Server-Sent Events (SSE)** for instant delivery with zero polling overhead.
414
+
415
+ SSE is **optional** -- existing polling setups continue to work with zero changes. Add SSE alongside your existing routes for real-time delivery, with automatic fallback to polling if the SSE connection fails.
416
+
417
+ ### How It Works
418
+
419
+ 1. The **emitter** is an in-process pub/sub bridge: the webhook handler publishes replies into it, and the SSE handler streams them out to connected clients.
420
+ 2. The client opens a single persistent `EventSource` connection. Replies arrive instantly as SSE events.
421
+ 3. If the SSE connection fails (network error, unsupported environment), the client automatically falls back to polling via `repliesUrl`.
422
+
423
+ ### Server Setup
424
+
425
+ Create a shared emitter and pass it to the webhook handler and SSE handler:
426
+
427
+ ```ts
428
+ // lib/support-chat.ts
429
+ import { InMemoryStore, createReplyEmitter } from "simple-support-chat/server";
430
+
431
+ export const store = new InMemoryStore();
432
+ export const emitter = createReplyEmitter();
433
+ ```
434
+
435
+ #### Next.js App Router
436
+
437
+ Next.js App Router supports SSE via streaming `Response` objects in route handlers. Add a new SSE route alongside your existing routes:
438
+
439
+ ```ts
440
+ // app/api/support/route.ts
441
+ import { createSupportHandler } from "simple-support-chat/server";
442
+ import { store, emitter } from "@/lib/support-chat";
443
+
444
+ export const POST = createSupportHandler({
445
+ slackBotToken: process.env.SLACK_BOT_TOKEN!,
446
+ slackChannel: process.env.SLACK_CHANNEL_ID!,
447
+ store,
448
+ emitter,
449
+ });
450
+ ```
451
+
452
+ ```ts
453
+ // app/api/support/webhook/route.ts
454
+ import { createWebhookHandler } from "simple-support-chat/server";
455
+ import { store, emitter } from "@/lib/support-chat";
456
+
457
+ export const POST = createWebhookHandler({
458
+ store,
459
+ signingSecret: process.env.SLACK_SIGNING_SECRET!,
460
+ emitter,
461
+ });
462
+ ```
463
+
464
+ ```ts
465
+ // app/api/support/sse/route.ts
466
+ import { createSSEHandler } from "simple-support-chat/server";
467
+ import { emitter } from "@/lib/support-chat";
468
+
469
+ export const GET = createSSEHandler({ emitter });
470
+ ```
471
+
472
+ ```ts
473
+ // app/api/support/replies/route.ts (keep for polling fallback)
474
+ import { createRepliesHandler } from "simple-support-chat/server";
475
+ import { store } from "@/lib/support-chat";
476
+
477
+ export const GET = createRepliesHandler({ store });
478
+ ```
479
+
480
+ > **Important:** The `emitter` instance must be shared across the support handler, webhook handler, and SSE handler within the same server process. Module-level singletons (as shown above) work well for this.
481
+
482
+ #### Express
483
+
484
+ ```ts
485
+ import express from "express";
486
+ import {
487
+ createExpressHandler,
488
+ createExpressWebhookHandler,
489
+ createExpressRepliesHandler,
490
+ createExpressSSEHandler,
491
+ createReplyEmitter,
492
+ InMemoryStore,
493
+ } from "simple-support-chat/server";
494
+
495
+ const app = express();
496
+ const store = new InMemoryStore();
497
+ const emitter = createReplyEmitter();
498
+
499
+ // Message handler
500
+ app.post(
501
+ "/api/support",
502
+ express.json(),
503
+ createExpressHandler({
504
+ slackBotToken: process.env.SLACK_BOT_TOKEN!,
505
+ slackChannel: process.env.SLACK_CHANNEL_ID!,
506
+ store,
507
+ emitter,
508
+ }),
509
+ );
510
+
511
+ // Webhook handler (pass emitter to broadcast replies)
512
+ app.post(
513
+ "/api/support/webhook",
514
+ express.raw({ type: "application/json" }),
515
+ createExpressWebhookHandler({
516
+ store,
517
+ signingSecret: process.env.SLACK_SIGNING_SECRET!,
518
+ emitter,
519
+ }),
520
+ );
521
+
522
+ // SSE endpoint (real-time reply stream)
523
+ app.get("/api/support/sse", createExpressSSEHandler({ emitter }));
524
+
525
+ // Replies endpoint (polling fallback)
526
+ app.get("/api/support/replies", createExpressRepliesHandler({ store }));
527
+
528
+ app.listen(3000);
529
+ ```
530
+
531
+ ### Client Setup
532
+
533
+ Pass the `sseUrl` prop alongside `repliesUrl`:
534
+
535
+ ```tsx
536
+ <ChatBubble
537
+ apiUrl="/api/support"
538
+ repliesUrl="/api/support/replies"
539
+ sseUrl="/api/support/sse"
540
+ />
541
+ ```
542
+
543
+ Or with the modal:
544
+
545
+ ```tsx
546
+ <SupportChatModal
547
+ apiUrl="/api/support"
548
+ repliesUrl="/api/support/replies"
549
+ sseUrl="/api/support/sse"
550
+ isOpen={isOpen}
551
+ onClose={close}
552
+ />
553
+ ```
554
+
555
+ When `sseUrl` is provided, the widget opens a single persistent SSE connection for real-time delivery. If the SSE connection fails, it automatically falls back to polling via `repliesUrl`. If only `repliesUrl` is provided (no `sseUrl`), polling works exactly as before.
556
+
557
+ ### Fallback Behavior
558
+
559
+ The transport layer handles failures gracefully:
560
+
561
+ | Scenario | Behavior |
562
+ |----------|----------|
563
+ | `sseUrl` + `repliesUrl` provided, SSE works | Real-time delivery via SSE. No polling. |
564
+ | `sseUrl` + `repliesUrl` provided, SSE fails | Automatic fallback to polling via `repliesUrl`. |
565
+ | Only `repliesUrl` provided (no `sseUrl`) | Polling every 4 seconds. Same as v0.3.x. |
566
+ | Neither provided | One-way only (no replies). |
567
+
568
+ On brief SSE disconnects, `EventSource` auto-reconnects and the client performs a one-time catch-up fetch via `repliesUrl` to recover any missed messages.
569
+
570
+ ### Serverless Compatibility
571
+
572
+ SSE requires a **long-lived server process** to maintain persistent connections. It is **not compatible** with serverless platforms that terminate connections after a short timeout:
573
+
574
+ | Platform | SSE Support | Recommendation |
575
+ |----------|-------------|----------------|
576
+ | Node.js server (Express, Fastify, Hono) | Yes | Use SSE |
577
+ | Docker / Railway / Render / Fly.io | Yes | Use SSE |
578
+ | Next.js on Vercel (Node.js runtime) | Yes | Use SSE |
579
+ | Vercel Edge Functions | No | Use polling (`repliesUrl` only) |
580
+ | Cloudflare Workers | No | Use polling (`repliesUrl` only) |
581
+ | AWS Lambda | No | Use polling (`repliesUrl` only) |
582
+
583
+ When deploying to a serverless environment, omit the `sseUrl` prop and rely on `repliesUrl` for polling. The client handles this gracefully -- no code changes needed.
584
+
585
+ > **Note:** SSE operates within a single server process. If you run multiple server instances behind a load balancer, each instance has its own emitter. In this scenario, use a sticky session or session-affinity configuration so that a client's SSE connection and its webhook handler route to the same instance.
586
+
410
587
  ## Configuration
411
588
 
412
589
  ### ChatBubble Props
@@ -414,7 +591,8 @@ const store = new SupabaseChatStore();
414
591
  | Prop | Type | Default | Description |
415
592
  |------|------|---------|-------------|
416
593
  | `apiUrl` | `string` | (required) | URL of your support API endpoint |
417
- | `repliesUrl` | `string` | `undefined` | URL of the replies endpoint. Enables bidirectional chat. |
594
+ | `repliesUrl` | `string` | `undefined` | URL of the replies endpoint. Enables bidirectional chat (polling). |
595
+ | `sseUrl` | `string` | `undefined` | URL of the SSE endpoint. Enables real-time delivery. Falls back to `repliesUrl` polling on failure. |
418
596
  | `position` | `'bottom-right' \| 'bottom-left' \| 'top-right' \| 'top-left'` | `'bottom-right'` | Bubble position |
419
597
  | `color` | `string` | `'#2563eb'` | Primary color (bubble, header, sent messages) |
420
598
  | `title` | `string` | `'Support'` | Chat panel header title |
@@ -432,6 +610,7 @@ const store = new SupabaseChatStore();
432
610
  | `botIcon` | `string` | Bot icon emoji (e.g., `:speech_balloon:`) |
433
611
  | `onMessage` | `(data) => void` | Callback on each message |
434
612
  | `store` | `SupportChatStore` | Pluggable storage backend (defaults to `InMemoryStore`) |
613
+ | `emitter` | `ReplyEmitter` | Optional reply emitter for SSE broadcasting (see [Real-time Replies](#real-time-replies-sse)) |
435
614
 
436
615
  ## Identity Integration
437
616
 
@@ -492,7 +671,8 @@ The modal renders centered on desktop (max-width 500px) with a backdrop overlay,
492
671
  | Prop | Type | Default | Description |
493
672
  |------|------|---------|-------------|
494
673
  | `apiUrl` | `string` | (required) | URL of your support API endpoint |
495
- | `repliesUrl` | `string` | `undefined` | URL of the replies endpoint. Enables bidirectional chat. |
674
+ | `repliesUrl` | `string` | `undefined` | URL of the replies endpoint. Enables bidirectional chat (polling). |
675
+ | `sseUrl` | `string` | `undefined` | URL of the SSE endpoint. Enables real-time delivery. Falls back to `repliesUrl` polling on failure. |
496
676
  | `isOpen` | `boolean` | (required) | Whether the modal is open |
497
677
  | `onClose` | `() => void` | (required) | Callback to close the modal |
498
678
  | `color` | `string` | `'#2563eb'` | Primary color |
@@ -552,6 +732,9 @@ The Express handler uses the same threading logic as the Web API handler. The re
552
732
  | `createExpressWebhookHandler(options)` | Returns an Express handler for Slack event webhooks |
553
733
  | `createRepliesHandler(options)` | Returns a Web API handler for the replies polling endpoint |
554
734
  | `createExpressRepliesHandler(options)` | Returns an Express handler for the replies polling endpoint |
735
+ | `createSSEHandler(options)` | Returns a Web API handler for SSE streaming (real-time replies) |
736
+ | `createExpressSSEHandler(options)` | Returns an Express handler for SSE streaming (real-time replies) |
737
+ | `createReplyEmitter()` | Creates an in-process event emitter for SSE broadcasting |
555
738
  | `verifySlackSignature(secret, sig, ts, body)` | Verifies a Slack request signature |
556
739
  | `InMemoryStore` | Default in-memory implementation of `SupportChatStore` |
557
740
  | `validateSlackToken(token)` | Validates a Slack token via `auth.test` |
@@ -565,6 +748,7 @@ The Express handler uses the same threading logic as the Web API handler. The re
565
748
  | `SupportChatModal` | Modal-based chat React component |
566
749
  | `useSupportChat()` | Hook returning `{ open, close, toggle, isOpen }` |
567
750
  | `useChatEngine(options)` | Low-level hook for chat message state |
751
+ | `useReplyTransport(options)` | Low-level hook for SSE/polling reply transport |
568
752
  | `collectAnonymousContext()` | Collects browser context (page URL, user agent, etc.) |
569
753
  | `getSessionId()` | Gets or creates a persistent anonymous session ID |
570
754
 
@@ -574,7 +758,15 @@ All TypeScript types are exported from both entry points:
574
758
 
575
759
  ```ts
576
760
  // Client types
577
- import type { ChatBubbleProps, ChatUser, SupportChatModalProps, ChatMessage } from "simple-support-chat";
761
+ import type {
762
+ ChatBubbleProps,
763
+ ChatUser,
764
+ SupportChatModalProps,
765
+ ChatMessage,
766
+ TransportMode,
767
+ ReplyTransportOptions,
768
+ ReplyTransportState,
769
+ } from "simple-support-chat";
578
770
 
579
771
  // Server types
580
772
  import type {
@@ -587,6 +779,8 @@ import type {
587
779
  WebhookHandlerOptions,
588
780
  RepliesHandlerOptions,
589
781
  RepliesResponse,
782
+ ReplyEmitter,
783
+ SSEHandlerOptions,
590
784
  } from "simple-support-chat/server";
591
785
  ```
592
786
 
@@ -48,20 +48,180 @@ function collectAnonymousContext() {
48
48
  sessionId
49
49
  };
50
50
  }
51
+ var POLL_INTERVAL = 4e3;
52
+ function useReplyTransport({
53
+ sseUrl,
54
+ repliesUrl,
55
+ sessionId,
56
+ isActive
57
+ }) {
58
+ const [replies, setReplies] = react.useState([]);
59
+ const [transport, setTransport] = react.useState("disconnected");
60
+ const knownReplyIdsRef = react.useRef(/* @__PURE__ */ new Set());
61
+ const lastReplyTimestampRef = react.useRef(null);
62
+ const sseFailedRef = react.useRef(false);
63
+ const eventSourceRef = react.useRef(null);
64
+ const pollIntervalRef = react.useRef(null);
65
+ const needsCatchUpRef = react.useRef(false);
66
+ const addReplies = react.useCallback(
67
+ (newReplies) => {
68
+ const deduplicated = newReplies.filter(
69
+ (r) => !knownReplyIdsRef.current.has(r.id)
70
+ );
71
+ if (deduplicated.length === 0) return;
72
+ for (const r of deduplicated) {
73
+ knownReplyIdsRef.current.add(r.id);
74
+ }
75
+ const latestTimestamp = deduplicated.reduce((latest, r) => {
76
+ return r.timestamp > latest ? r.timestamp : latest;
77
+ }, lastReplyTimestampRef.current ?? "");
78
+ lastReplyTimestampRef.current = latestTimestamp;
79
+ const replyMessages = deduplicated.map((r) => ({
80
+ id: r.id,
81
+ text: r.text,
82
+ sender: "received",
83
+ timestamp: new Date(r.timestamp).getTime()
84
+ }));
85
+ setReplies((prev) => [...prev, ...replyMessages]);
86
+ },
87
+ []
88
+ );
89
+ const fetchReplies = react.useCallback(async () => {
90
+ if (!repliesUrl) return;
91
+ try {
92
+ const params = new URLSearchParams({ sessionId });
93
+ if (lastReplyTimestampRef.current) {
94
+ params.set("since", lastReplyTimestampRef.current);
95
+ }
96
+ const response = await fetch(`${repliesUrl}?${params.toString()}`);
97
+ if (!response.ok) return;
98
+ const data = await response.json();
99
+ if (data.replies.length === 0) return;
100
+ addReplies(data.replies);
101
+ } catch {
102
+ }
103
+ }, [repliesUrl, sessionId, addReplies]);
104
+ const startPolling = react.useCallback(() => {
105
+ if (!repliesUrl) return;
106
+ if (pollIntervalRef.current !== null) {
107
+ clearInterval(pollIntervalRef.current);
108
+ }
109
+ setTransport("polling");
110
+ void fetchReplies();
111
+ pollIntervalRef.current = setInterval(
112
+ () => void fetchReplies(),
113
+ POLL_INTERVAL
114
+ );
115
+ }, [repliesUrl, fetchReplies]);
116
+ const stopPolling = react.useCallback(() => {
117
+ if (pollIntervalRef.current !== null) {
118
+ clearInterval(pollIntervalRef.current);
119
+ pollIntervalRef.current = null;
120
+ }
121
+ }, []);
122
+ const connectSSE = react.useCallback(() => {
123
+ if (!sseUrl || sseFailedRef.current) return;
124
+ if (eventSourceRef.current) {
125
+ eventSourceRef.current.close();
126
+ eventSourceRef.current = null;
127
+ }
128
+ const url = `${sseUrl}?sessionId=${encodeURIComponent(sessionId)}`;
129
+ const es = new EventSource(url);
130
+ eventSourceRef.current = es;
131
+ es.addEventListener("reply", (event) => {
132
+ try {
133
+ const data = JSON.parse(event.data);
134
+ addReplies([data.reply]);
135
+ } catch {
136
+ }
137
+ });
138
+ es.addEventListener("heartbeat", () => {
139
+ });
140
+ es.onopen = () => {
141
+ setTransport("sse");
142
+ if (needsCatchUpRef.current) {
143
+ needsCatchUpRef.current = false;
144
+ void fetchReplies();
145
+ }
146
+ };
147
+ es.onerror = () => {
148
+ if (es.readyState === EventSource.CLOSED) {
149
+ sseFailedRef.current = true;
150
+ es.close();
151
+ eventSourceRef.current = null;
152
+ startPolling();
153
+ } else {
154
+ needsCatchUpRef.current = true;
155
+ }
156
+ };
157
+ }, [sseUrl, sessionId, addReplies, fetchReplies, startPolling]);
158
+ const disconnectSSE = react.useCallback(() => {
159
+ if (eventSourceRef.current) {
160
+ eventSourceRef.current.close();
161
+ eventSourceRef.current = null;
162
+ }
163
+ }, []);
164
+ react.useEffect(() => {
165
+ if (!isActive) {
166
+ disconnectSSE();
167
+ stopPolling();
168
+ setTransport("disconnected");
169
+ return;
170
+ }
171
+ if (!sseUrl && !repliesUrl) {
172
+ setTransport("disconnected");
173
+ return;
174
+ }
175
+ if (sseUrl && !sseFailedRef.current) {
176
+ connectSSE();
177
+ } else if (repliesUrl) {
178
+ startPolling();
179
+ }
180
+ return () => {
181
+ disconnectSSE();
182
+ stopPolling();
183
+ };
184
+ }, [
185
+ isActive,
186
+ sseUrl,
187
+ repliesUrl,
188
+ connectSSE,
189
+ disconnectSSE,
190
+ startPolling,
191
+ stopPolling
192
+ ]);
193
+ react.useEffect(() => {
194
+ sseFailedRef.current = false;
195
+ }, [sseUrl]);
196
+ return { replies, transport };
197
+ }
51
198
 
52
199
  // src/client/useChatEngine.ts
53
- var POLL_INTERVAL = 4e3;
54
200
  function useChatEngine({
55
201
  apiUrl,
56
202
  user,
57
203
  repliesUrl,
204
+ sseUrl,
58
205
  isOpen = true
59
206
  }) {
60
207
  const [messages, setMessages] = react.useState([]);
61
208
  const [input, setInput] = react.useState("");
62
209
  const [sending, setSending] = react.useState(false);
63
- const lastReplyTimestampRef = react.useRef(null);
64
- const knownReplyIdsRef = react.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
+ });
217
+ const processedReplyCountRef = react.useRef(0);
218
+ react.useEffect(() => {
219
+ if (replies.length > processedReplyCountRef.current) {
220
+ const newReplies = replies.slice(processedReplyCountRef.current);
221
+ processedReplyCountRef.current = replies.length;
222
+ setMessages((prev) => [...prev, ...newReplies]);
223
+ }
224
+ }, [replies]);
65
225
  const sendMessage = react.useCallback(async () => {
66
226
  const text = input.trim();
67
227
  if (!text || sending) return;
@@ -82,7 +242,7 @@ function useChatEngine({
82
242
  body: JSON.stringify({
83
243
  message: text,
84
244
  user: user ?? void 0,
85
- sessionId: user?.id ?? getSessionId(),
245
+ sessionId,
86
246
  context
87
247
  })
88
248
  });
@@ -110,7 +270,7 @@ function useChatEngine({
110
270
  } finally {
111
271
  setSending(false);
112
272
  }
113
- }, [input, sending, apiUrl, user]);
273
+ }, [input, sending, apiUrl, user, sessionId]);
114
274
  const handleKeyDown = react.useCallback(
115
275
  (e) => {
116
276
  if (e.key === "Enter" && !e.shiftKey) {
@@ -120,45 +280,7 @@ function useChatEngine({
120
280
  },
121
281
  [sendMessage]
122
282
  );
123
- react.useEffect(() => {
124
- if (!repliesUrl || !isOpen) return;
125
- const sessionId = user?.id ?? getSessionId();
126
- const fetchReplies = async () => {
127
- try {
128
- const params = new URLSearchParams({ sessionId });
129
- if (lastReplyTimestampRef.current) {
130
- params.set("since", lastReplyTimestampRef.current);
131
- }
132
- const response = await fetch(`${repliesUrl}?${params.toString()}`);
133
- if (!response.ok) return;
134
- const data = await response.json();
135
- if (data.replies.length === 0) return;
136
- const newReplies = data.replies.filter(
137
- (r) => !knownReplyIdsRef.current.has(r.id)
138
- );
139
- if (newReplies.length === 0) return;
140
- for (const r of newReplies) {
141
- knownReplyIdsRef.current.add(r.id);
142
- }
143
- const latestTimestamp = newReplies.reduce((latest, r) => {
144
- return r.timestamp > latest ? r.timestamp : latest;
145
- }, lastReplyTimestampRef.current ?? "");
146
- lastReplyTimestampRef.current = latestTimestamp;
147
- const replyMessages = newReplies.map((r) => ({
148
- id: r.id,
149
- text: r.text,
150
- sender: "received",
151
- timestamp: new Date(r.timestamp).getTime()
152
- }));
153
- setMessages((prev) => [...prev, ...replyMessages]);
154
- } catch {
155
- }
156
- };
157
- void fetchReplies();
158
- const intervalId = setInterval(() => void fetchReplies(), POLL_INTERVAL);
159
- return () => clearInterval(intervalId);
160
- }, [repliesUrl, isOpen, user]);
161
- return { messages, input, setInput, sending, sendMessage, handleKeyDown };
283
+ return { messages, input, setInput, sending, sendMessage, handleKeyDown, transport };
162
284
  }
163
285
  function useColorScheme() {
164
286
  const [scheme, setScheme] = react.useState(() => {
@@ -233,6 +355,7 @@ function ChatBubble({
233
355
  show = true,
234
356
  user,
235
357
  repliesUrl,
358
+ sseUrl,
236
359
  isOpen: isOpenProp,
237
360
  onOpenChange,
238
361
  badgeColor = "#eab308",
@@ -255,7 +378,7 @@ function ChatBubble({
255
378
  );
256
379
  const colorScheme = useColorScheme();
257
380
  const theme = react.useMemo(() => getThemeTokens(colorScheme), [colorScheme]);
258
- const { messages, input, setInput, sending, sendMessage, handleKeyDown } = useChatEngine({ apiUrl, user, repliesUrl, isOpen });
381
+ const { messages, input, setInput, sending, sendMessage, handleKeyDown } = useChatEngine({ apiUrl, user, repliesUrl, sseUrl, isOpen });
259
382
  const [panelState, setPanelState] = react.useState(
260
383
  "closed"
261
384
  );
@@ -625,9 +748,10 @@ function SupportChatModal({
625
748
  title = "Contact Us",
626
749
  placeholder = "Type a message...",
627
750
  user,
628
- repliesUrl
751
+ repliesUrl,
752
+ sseUrl
629
753
  }) {
630
- const { messages, input, setInput, sending, sendMessage, handleKeyDown } = useChatEngine({ apiUrl, user, repliesUrl, isOpen });
754
+ const { messages, input, setInput, sending, sendMessage, handleKeyDown } = useChatEngine({ apiUrl, user, repliesUrl, sseUrl, isOpen });
631
755
  const colorScheme = useColorScheme();
632
756
  const theme = react.useMemo(() => getThemeTokens(colorScheme), [colorScheme]);
633
757
  const modalRef = react.useRef(null);
@@ -910,86 +1034,45 @@ function useSupportChat() {
910
1034
  const toggle = react.useCallback(() => setIsOpen((prev) => !prev), []);
911
1035
  return { open, close, toggle, isOpen };
912
1036
  }
913
- var POLL_INTERVAL2 = 4e3;
914
1037
  function useUnreadCount({
1038
+ sseUrl,
915
1039
  repliesUrl,
916
1040
  user,
917
1041
  isOpen
918
1042
  }) {
1043
+ const sessionId = user?.id ?? getSessionId();
1044
+ const { replies, transport } = useReplyTransport({
1045
+ sseUrl,
1046
+ repliesUrl,
1047
+ sessionId,
1048
+ isActive: true
1049
+ });
1050
+ const [unreadCount, setUnreadCount] = react.useState(0);
919
1051
  const [pendingReplies, setPendingReplies] = react.useState([]);
920
- const lastReplyTimestampRef = react.useRef(null);
921
- const knownReplyIdsRef = react.useRef(/* @__PURE__ */ new Set());
922
- const prevIsOpenRef = react.useRef(isOpen);
923
- const baselineSetRef = react.useRef(false);
1052
+ const countedReplyCountRef = react.useRef(0);
1053
+ const wasOpenRef = react.useRef(isOpen);
924
1054
  react.useEffect(() => {
925
- if (isOpen && !prevIsOpenRef.current) {
1055
+ if (isOpen && !wasOpenRef.current) {
1056
+ setUnreadCount(0);
926
1057
  setPendingReplies([]);
927
1058
  }
928
- prevIsOpenRef.current = isOpen;
1059
+ wasOpenRef.current = isOpen;
929
1060
  }, [isOpen]);
930
1061
  const markAsRead = react.useCallback(() => {
1062
+ setUnreadCount(0);
931
1063
  setPendingReplies([]);
932
1064
  }, []);
933
1065
  react.useEffect(() => {
934
- if (!repliesUrl || isOpen) return;
935
- const sessionId = user?.id ?? getSessionId();
936
- const fetchReplies = async () => {
937
- try {
938
- const params = new URLSearchParams({ sessionId });
939
- if (lastReplyTimestampRef.current) {
940
- params.set("since", lastReplyTimestampRef.current);
941
- }
942
- const response = await fetch(`${repliesUrl}?${params.toString()}`);
943
- if (!response.ok) return;
944
- const data = await response.json();
945
- if (!baselineSetRef.current) {
946
- baselineSetRef.current = true;
947
- if (data.replies.length > 0) {
948
- for (const r of data.replies) {
949
- knownReplyIdsRef.current.add(r.id);
950
- }
951
- const latestTimestamp2 = data.replies.reduce((latest, r) => {
952
- return r.timestamp > latest ? r.timestamp : latest;
953
- }, "");
954
- lastReplyTimestampRef.current = latestTimestamp2;
955
- } else if (data.lastChecked) {
956
- lastReplyTimestampRef.current = data.lastChecked;
957
- }
958
- return;
959
- }
960
- if (data.replies.length === 0) return;
961
- const newReplies = data.replies.filter(
962
- (r) => !knownReplyIdsRef.current.has(r.id)
963
- );
964
- if (newReplies.length === 0) return;
965
- for (const r of newReplies) {
966
- knownReplyIdsRef.current.add(r.id);
967
- }
968
- const latestTimestamp = newReplies.reduce((latest, r) => {
969
- return r.timestamp > latest ? r.timestamp : latest;
970
- }, lastReplyTimestampRef.current ?? "");
971
- lastReplyTimestampRef.current = latestTimestamp;
972
- const replyMessages = newReplies.map((r) => ({
973
- id: r.id,
974
- text: r.text,
975
- sender: "received",
976
- timestamp: new Date(r.timestamp).getTime()
977
- }));
978
- setPendingReplies((prev) => [...prev, ...replyMessages]);
979
- } catch {
1066
+ if (replies.length > countedReplyCountRef.current) {
1067
+ const newReplies = replies.slice(countedReplyCountRef.current);
1068
+ countedReplyCountRef.current = replies.length;
1069
+ if (!isOpen) {
1070
+ setUnreadCount((prev) => prev + newReplies.length);
1071
+ setPendingReplies((prev) => [...prev, ...newReplies]);
980
1072
  }
981
- };
982
- void fetchReplies();
983
- const intervalId = setInterval(() => void fetchReplies(), POLL_INTERVAL2);
984
- return () => clearInterval(intervalId);
985
- }, [repliesUrl, isOpen, user]);
986
- const unreadCount = pendingReplies.length;
987
- return {
988
- unreadCount,
989
- hasUnread: unreadCount > 0,
990
- pendingReplies,
991
- markAsRead
992
- };
1073
+ }
1074
+ }, [replies, isOpen]);
1075
+ return { unreadCount, hasUnread: unreadCount > 0, pendingReplies, markAsRead, transport };
993
1076
  }
994
1077
 
995
1078
  exports.ChatBubble = ChatBubble;
@@ -997,6 +1080,7 @@ exports.SupportChatModal = SupportChatModal;
997
1080
  exports.collectAnonymousContext = collectAnonymousContext;
998
1081
  exports.getSessionId = getSessionId;
999
1082
  exports.useChatEngine = useChatEngine;
1083
+ exports.useReplyTransport = useReplyTransport;
1000
1084
  exports.useSupportChat = useSupportChat;
1001
1085
  exports.useUnreadCount = useUnreadCount;
1002
1086
  //# sourceMappingURL=index.cjs.map