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