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 +197 -3
- package/dist/client/index.cjs +205 -103
- package/dist/client/index.cjs.map +1 -1
- package/dist/client/index.d.cts +86 -22
- package/dist/client/index.d.ts +86 -22
- package/dist/client/index.js +205 -104
- package/dist/client/index.js.map +1 -1
- package/dist/server/index.cjs +203 -7
- package/dist/server/index.cjs.map +1 -1
- package/dist/server/index.d.cts +120 -1
- package/dist/server/index.d.ts +120 -1
- package/dist/server/index.js +201 -8
- package/dist/server/index.js.map +1 -1
- package/package.json +87 -87
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 {
|
|
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
|
|
package/dist/client/index.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
64
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
920
|
-
const
|
|
921
|
-
|
|
922
|
-
|
|
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 && !
|
|
925
|
-
|
|
926
|
-
|
|
1060
|
+
if (isOpen && !wasOpenRef.current) {
|
|
1061
|
+
setUnreadCount(0);
|
|
1062
|
+
setPendingReplies([]);
|
|
927
1063
|
}
|
|
928
|
-
|
|
1064
|
+
wasOpenRef.current = isOpen;
|
|
929
1065
|
}, [isOpen]);
|
|
930
1066
|
const markAsRead = react.useCallback(() => {
|
|
931
|
-
|
|
932
|
-
|
|
1067
|
+
setUnreadCount(0);
|
|
1068
|
+
setPendingReplies([]);
|
|
933
1069
|
}, []);
|
|
934
1070
|
react.useEffect(() => {
|
|
935
|
-
if (
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
-
|
|
967
|
-
|
|
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
|