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.
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,183 @@ function collectAnonymousContext() {
48
48
  sessionId
49
49
  };
50
50
  }
51
+ var DEFAULT_POLL_INTERVAL = 4e3;
52
+ function useReplyTransport({
53
+ sseUrl,
54
+ repliesUrl,
55
+ sessionId,
56
+ isActive,
57
+ pollInterval = DEFAULT_POLL_INTERVAL
58
+ }) {
59
+ const [replies, setReplies] = react.useState([]);
60
+ const [transport, setTransport] = react.useState("disconnected");
61
+ const knownReplyIdsRef = react.useRef(/* @__PURE__ */ new Set());
62
+ const lastReplyTimestampRef = react.useRef(null);
63
+ const sseFailedRef = react.useRef(false);
64
+ const eventSourceRef = react.useRef(null);
65
+ const pollIntervalRef = react.useRef(null);
66
+ const needsCatchUpRef = react.useRef(false);
67
+ const addReplies = react.useCallback(
68
+ (newReplies) => {
69
+ const deduplicated = newReplies.filter(
70
+ (r) => !knownReplyIdsRef.current.has(r.id)
71
+ );
72
+ if (deduplicated.length === 0) return;
73
+ for (const r of deduplicated) {
74
+ knownReplyIdsRef.current.add(r.id);
75
+ }
76
+ const latestTimestamp = deduplicated.reduce((latest, r) => {
77
+ return r.timestamp > latest ? r.timestamp : latest;
78
+ }, lastReplyTimestampRef.current ?? "");
79
+ lastReplyTimestampRef.current = latestTimestamp;
80
+ const replyMessages = deduplicated.map((r) => ({
81
+ id: r.id,
82
+ text: r.text,
83
+ sender: "received",
84
+ timestamp: new Date(r.timestamp).getTime()
85
+ }));
86
+ setReplies((prev) => [...prev, ...replyMessages]);
87
+ },
88
+ []
89
+ );
90
+ const fetchReplies = react.useCallback(async () => {
91
+ if (!repliesUrl) return;
92
+ try {
93
+ const params = new URLSearchParams({ sessionId });
94
+ if (lastReplyTimestampRef.current) {
95
+ params.set("since", lastReplyTimestampRef.current);
96
+ }
97
+ const response = await fetch(`${repliesUrl}?${params.toString()}`);
98
+ if (!response.ok) return;
99
+ const data = await response.json();
100
+ if (data.replies.length === 0) return;
101
+ addReplies(data.replies);
102
+ } catch {
103
+ }
104
+ }, [repliesUrl, sessionId, addReplies]);
105
+ const startPolling = react.useCallback(() => {
106
+ if (!repliesUrl) return;
107
+ if (pollIntervalRef.current !== null) {
108
+ clearInterval(pollIntervalRef.current);
109
+ }
110
+ setTransport("polling");
111
+ void fetchReplies();
112
+ pollIntervalRef.current = setInterval(
113
+ () => void fetchReplies(),
114
+ pollInterval
115
+ );
116
+ }, [repliesUrl, fetchReplies]);
117
+ const stopPolling = react.useCallback(() => {
118
+ if (pollIntervalRef.current !== null) {
119
+ clearInterval(pollIntervalRef.current);
120
+ pollIntervalRef.current = null;
121
+ }
122
+ }, []);
123
+ const connectSSE = react.useCallback(() => {
124
+ if (!sseUrl || sseFailedRef.current) return;
125
+ if (eventSourceRef.current) {
126
+ eventSourceRef.current.close();
127
+ eventSourceRef.current = null;
128
+ }
129
+ const url = `${sseUrl}?sessionId=${encodeURIComponent(sessionId)}`;
130
+ const es = new EventSource(url);
131
+ eventSourceRef.current = es;
132
+ es.addEventListener("reply", (event) => {
133
+ try {
134
+ const data = JSON.parse(event.data);
135
+ addReplies([data.reply]);
136
+ } catch {
137
+ }
138
+ });
139
+ es.addEventListener("heartbeat", () => {
140
+ });
141
+ es.onopen = () => {
142
+ setTransport("sse");
143
+ if (needsCatchUpRef.current) {
144
+ needsCatchUpRef.current = false;
145
+ void fetchReplies();
146
+ }
147
+ };
148
+ es.onerror = () => {
149
+ if (es.readyState === EventSource.CLOSED) {
150
+ sseFailedRef.current = true;
151
+ es.close();
152
+ eventSourceRef.current = null;
153
+ startPolling();
154
+ } else {
155
+ needsCatchUpRef.current = true;
156
+ }
157
+ };
158
+ }, [sseUrl, sessionId, addReplies, fetchReplies, startPolling]);
159
+ const disconnectSSE = react.useCallback(() => {
160
+ if (eventSourceRef.current) {
161
+ eventSourceRef.current.close();
162
+ eventSourceRef.current = null;
163
+ }
164
+ }, []);
165
+ react.useEffect(() => {
166
+ if (!isActive) {
167
+ disconnectSSE();
168
+ stopPolling();
169
+ setTransport("disconnected");
170
+ return;
171
+ }
172
+ if (!sseUrl && !repliesUrl) {
173
+ setTransport("disconnected");
174
+ return;
175
+ }
176
+ if (sseUrl && !sseFailedRef.current) {
177
+ connectSSE();
178
+ } else if (repliesUrl) {
179
+ startPolling();
180
+ }
181
+ return () => {
182
+ disconnectSSE();
183
+ stopPolling();
184
+ };
185
+ }, [
186
+ isActive,
187
+ sseUrl,
188
+ repliesUrl,
189
+ connectSSE,
190
+ disconnectSSE,
191
+ startPolling,
192
+ stopPolling
193
+ ]);
194
+ react.useEffect(() => {
195
+ sseFailedRef.current = false;
196
+ }, [sseUrl]);
197
+ return { replies, transport };
198
+ }
51
199
 
52
200
  // src/client/useChatEngine.ts
53
- var POLL_INTERVAL = 4e3;
54
201
  function useChatEngine({
55
202
  apiUrl,
56
203
  user,
57
204
  repliesUrl,
58
- isOpen = true
205
+ sseUrl,
206
+ isOpen = true,
207
+ pollInterval
59
208
  }) {
60
209
  const [messages, setMessages] = react.useState([]);
61
210
  const [input, setInput] = react.useState("");
62
211
  const [sending, setSending] = react.useState(false);
63
- const lastReplyTimestampRef = react.useRef(null);
64
- const knownReplyIdsRef = react.useRef(/* @__PURE__ */ new Set());
212
+ const sessionId = user?.id ?? getSessionId();
213
+ const { replies, transport } = useReplyTransport({
214
+ sseUrl,
215
+ repliesUrl,
216
+ sessionId,
217
+ isActive: isOpen,
218
+ pollInterval
219
+ });
220
+ const processedReplyCountRef = react.useRef(0);
221
+ react.useEffect(() => {
222
+ if (replies.length > processedReplyCountRef.current) {
223
+ const newReplies = replies.slice(processedReplyCountRef.current);
224
+ processedReplyCountRef.current = replies.length;
225
+ setMessages((prev) => [...prev, ...newReplies]);
226
+ }
227
+ }, [replies]);
65
228
  const sendMessage = react.useCallback(async () => {
66
229
  const text = input.trim();
67
230
  if (!text || sending) return;
@@ -82,7 +245,7 @@ function useChatEngine({
82
245
  body: JSON.stringify({
83
246
  message: text,
84
247
  user: user ?? void 0,
85
- sessionId: user?.id ?? getSessionId(),
248
+ sessionId,
86
249
  context
87
250
  })
88
251
  });
@@ -110,7 +273,7 @@ function useChatEngine({
110
273
  } finally {
111
274
  setSending(false);
112
275
  }
113
- }, [input, sending, apiUrl, user]);
276
+ }, [input, sending, apiUrl, user, sessionId]);
114
277
  const handleKeyDown = react.useCallback(
115
278
  (e) => {
116
279
  if (e.key === "Enter" && !e.shiftKey) {
@@ -120,45 +283,7 @@ function useChatEngine({
120
283
  },
121
284
  [sendMessage]
122
285
  );
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 };
286
+ return { messages, input, setInput, sending, sendMessage, handleKeyDown, transport };
162
287
  }
163
288
  function useColorScheme() {
164
289
  const [scheme, setScheme] = react.useState(() => {
@@ -233,6 +358,8 @@ function ChatBubble({
233
358
  show = true,
234
359
  user,
235
360
  repliesUrl,
361
+ sseUrl,
362
+ pollInterval,
236
363
  isOpen: isOpenProp,
237
364
  onOpenChange,
238
365
  badgeColor = "#eab308",
@@ -255,7 +382,7 @@ function ChatBubble({
255
382
  );
256
383
  const colorScheme = useColorScheme();
257
384
  const theme = react.useMemo(() => getThemeTokens(colorScheme), [colorScheme]);
258
- const { messages, input, setInput, sending, sendMessage, handleKeyDown } = useChatEngine({ apiUrl, user, repliesUrl, isOpen });
385
+ const { messages, input, setInput, sending, sendMessage, handleKeyDown } = useChatEngine({ apiUrl, user, repliesUrl, sseUrl, isOpen, pollInterval });
259
386
  const [panelState, setPanelState] = react.useState(
260
387
  "closed"
261
388
  );
@@ -625,9 +752,11 @@ function SupportChatModal({
625
752
  title = "Contact Us",
626
753
  placeholder = "Type a message...",
627
754
  user,
628
- repliesUrl
755
+ repliesUrl,
756
+ sseUrl,
757
+ pollInterval
629
758
  }) {
630
- const { messages, input, setInput, sending, sendMessage, handleKeyDown } = useChatEngine({ apiUrl, user, repliesUrl, isOpen });
759
+ const { messages, input, setInput, sending, sendMessage, handleKeyDown } = useChatEngine({ apiUrl, user, repliesUrl, sseUrl, isOpen, pollInterval });
631
760
  const colorScheme = useColorScheme();
632
761
  const theme = react.useMemo(() => getThemeTokens(colorScheme), [colorScheme]);
633
762
  const modalRef = react.useRef(null);
@@ -910,73 +1039,45 @@ function useSupportChat() {
910
1039
  const toggle = react.useCallback(() => setIsOpen((prev) => !prev), []);
911
1040
  return { open, close, toggle, isOpen };
912
1041
  }
913
- var POLL_INTERVAL2 = 4e3;
914
1042
  function useUnreadCount({
1043
+ sseUrl,
915
1044
  repliesUrl,
916
1045
  user,
917
1046
  isOpen
918
1047
  }) {
919
- const baselineCountRef = react.useRef(null);
920
- const [allReplies, setAllReplies] = react.useState([]);
921
- const totalCountRef = react.useRef(0);
922
- const prevIsOpenRef = react.useRef(isOpen);
1048
+ const sessionId = user?.id ?? getSessionId();
1049
+ const { replies, transport } = useReplyTransport({
1050
+ sseUrl,
1051
+ repliesUrl,
1052
+ sessionId,
1053
+ isActive: true
1054
+ });
1055
+ const [unreadCount, setUnreadCount] = react.useState(0);
1056
+ const [pendingReplies, setPendingReplies] = react.useState([]);
1057
+ const countedReplyCountRef = react.useRef(0);
1058
+ const wasOpenRef = react.useRef(isOpen);
923
1059
  react.useEffect(() => {
924
- if (isOpen && !prevIsOpenRef.current) {
925
- baselineCountRef.current = totalCountRef.current;
926
- setAllReplies([]);
1060
+ if (isOpen && !wasOpenRef.current) {
1061
+ setUnreadCount(0);
1062
+ setPendingReplies([]);
927
1063
  }
928
- prevIsOpenRef.current = isOpen;
1064
+ wasOpenRef.current = isOpen;
929
1065
  }, [isOpen]);
930
1066
  const markAsRead = react.useCallback(() => {
931
- baselineCountRef.current = totalCountRef.current;
932
- setAllReplies([]);
1067
+ setUnreadCount(0);
1068
+ setPendingReplies([]);
933
1069
  }, []);
934
1070
  react.useEffect(() => {
935
- if (!repliesUrl || isOpen) return;
936
- const sessionId = user?.id ?? getSessionId();
937
- let cancelled = false;
938
- const fetchReplies = async () => {
939
- try {
940
- const params = new URLSearchParams({ sessionId });
941
- const response = await fetch(`${repliesUrl}?${params.toString()}`);
942
- if (!response.ok || cancelled) return;
943
- const data = await response.json();
944
- if (cancelled) return;
945
- const currentTotal = data.replies.length;
946
- totalCountRef.current = currentTotal;
947
- if (baselineCountRef.current === null) {
948
- baselineCountRef.current = currentTotal;
949
- return;
950
- }
951
- const newCount = currentTotal - baselineCountRef.current;
952
- if (newCount > 0) {
953
- const newReplies = data.replies.slice(-newCount);
954
- setAllReplies(
955
- newReplies.map((r) => ({
956
- id: r.id,
957
- text: r.text,
958
- sender: "received",
959
- timestamp: new Date(r.timestamp).getTime()
960
- }))
961
- );
962
- }
963
- } catch {
1071
+ if (replies.length > countedReplyCountRef.current) {
1072
+ const newReplies = replies.slice(countedReplyCountRef.current);
1073
+ countedReplyCountRef.current = replies.length;
1074
+ if (!isOpen) {
1075
+ setUnreadCount((prev) => prev + newReplies.length);
1076
+ setPendingReplies((prev) => [...prev, ...newReplies]);
964
1077
  }
965
- };
966
- void fetchReplies();
967
- const intervalId = setInterval(() => void fetchReplies(), POLL_INTERVAL2);
968
- return () => {
969
- cancelled = true;
970
- clearInterval(intervalId);
971
- };
972
- }, [repliesUrl, isOpen, user]);
973
- const unreadCount = baselineCountRef.current !== null ? Math.max(0, totalCountRef.current - baselineCountRef.current) : 0;
974
- return {
975
- unreadCount,
976
- hasUnread: unreadCount > 0,
977
- pendingReplies: allReplies,
978
- markAsRead
979
- };
1078
+ }
1079
+ }, [replies, isOpen]);
1080
+ return { unreadCount, hasUnread: unreadCount > 0, pendingReplies, markAsRead, transport };
980
1081
  }
981
1082
 
982
1083
  exports.ChatBubble = ChatBubble;
@@ -984,6 +1085,7 @@ exports.SupportChatModal = SupportChatModal;
984
1085
  exports.collectAnonymousContext = collectAnonymousContext;
985
1086
  exports.getSessionId = getSessionId;
986
1087
  exports.useChatEngine = useChatEngine;
1088
+ exports.useReplyTransport = useReplyTransport;
987
1089
  exports.useSupportChat = useSupportChat;
988
1090
  exports.useUnreadCount = useUnreadCount;
989
1091
  //# sourceMappingURL=index.cjs.map