@xcelsior/ui-chat 2.0.2 → 2.0.4
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/.storybook/preview.tsx +2 -1
- package/dist/index.d.mts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +49 -53
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +77 -81
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -2
- package/src/components/Chat.tsx +35 -74
- package/src/components/ChatWidget.tsx +0 -1
- package/src/hooks/useMessages.ts +22 -1
- package/src/hooks/useWebSocket.ts +18 -1
- package/storybook-static/assets/Chat.stories-BkbpOOSG.js +830 -0
- package/storybook-static/assets/{Color-YHDXOIA2-BMnd3YrF.js → Color-YHDXOIA2-CSuNIR0a.js} +1 -1
- package/storybook-static/assets/{DocsRenderer-CFRXHY34-i_W8iCu9.js → DocsRenderer-CFRXHY34-dpuOKTQp.js} +3 -3
- package/storybook-static/assets/{MessageItem-DAaKZ9s9.js → MessageItem-Dlb6dSKL.js} +9 -9
- package/storybook-static/assets/MessageItem.stories-CsxqSqu-.js +422 -0
- package/storybook-static/assets/{entry-preview-oDnntGcx.js → entry-preview-C_-WO6GJ.js} +1 -1
- package/storybook-static/assets/{iframe-CGBtu2Se.js → iframe-BXTccXxS.js} +2 -2
- package/storybook-static/assets/preview-B8y-wc-n.css +1 -0
- package/storybook-static/assets/preview-CC4t7T7W.js +1 -0
- package/storybook-static/assets/{preview-BRpahs9B.js → preview-Cyx3pE7Q.js} +2 -2
- package/storybook-static/iframe.html +1 -1
- package/storybook-static/index.json +1 -1
- package/storybook-static/project.json +1 -1
- package/tsconfig.json +4 -0
- package/storybook-static/assets/Chat.stories-J_Yp51wU.js +0 -803
- package/storybook-static/assets/MessageItem.stories-Ckr1_scc.js +0 -255
- package/storybook-static/assets/ToastContext-Bty1K7ya.js +0 -1
- package/storybook-static/assets/preview-DUOvJmsz.js +0 -1
- package/storybook-static/assets/preview-DcGwT3kv.css +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xcelsior/ui-chat",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.4",
|
|
4
4
|
"main": "./dist/index.js",
|
|
5
5
|
"types": "./dist/index.d.ts",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"storybook": "^8.6.14",
|
|
22
22
|
"@tailwindcss/postcss": "^4.1.13",
|
|
23
23
|
"tsup": "^8.0.2",
|
|
24
|
-
"typescript": "^5.3.3"
|
|
24
|
+
"typescript": "^5.3.3",
|
|
25
|
+
"@xcelsior/design-system": "1.0.10"
|
|
25
26
|
},
|
|
26
27
|
"peerDependencies": {
|
|
27
28
|
"tailwindcss": "^4",
|
package/src/components/Chat.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useState } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { ChatWidget } from './ChatWidget';
|
|
3
3
|
import { PreChatForm } from './PreChatForm';
|
|
4
4
|
import { ChatBubbleIcon } from './BrandIcons';
|
|
@@ -45,73 +45,44 @@ export function Chat({
|
|
|
45
45
|
const [userInfo, setUserInfo] = useState<IUser | null>(null);
|
|
46
46
|
const [conversationId, setConversationId] = useState<string>('');
|
|
47
47
|
const [isLoading, setIsLoading] = useState(true);
|
|
48
|
-
const [isAnimating, setIsAnimating] = useState(false);
|
|
49
|
-
const [showWidget, setShowWidget] = useState(false);
|
|
50
48
|
|
|
51
49
|
const identityMode = config.identityCollection || 'progressive';
|
|
52
50
|
const { position, isDragging, showHint, handlers } = useDraggablePosition(config.position);
|
|
51
|
+
const sessionInitializedRef = useRef(false);
|
|
53
52
|
|
|
54
|
-
const { currentState, setState
|
|
53
|
+
const { currentState, setState } = useChatWidgetState({
|
|
55
54
|
state,
|
|
56
55
|
defaultState,
|
|
57
56
|
onStateChange,
|
|
58
57
|
});
|
|
59
58
|
|
|
60
|
-
//
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
setIsAnimating(true);
|
|
67
|
-
setStateRaw(newState);
|
|
68
|
-
// Let the animation class apply on next frame
|
|
69
|
-
requestAnimationFrame(() => {
|
|
70
|
-
requestAnimationFrame(() => {
|
|
71
|
-
setIsAnimating(false);
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
} else if (
|
|
75
|
-
(newState === 'minimized' || newState === 'closed') &&
|
|
76
|
-
currentState === 'open'
|
|
77
|
-
) {
|
|
78
|
-
// Closing: animate out, then change state
|
|
79
|
-
setIsAnimating(true);
|
|
80
|
-
setTimeout(() => {
|
|
81
|
-
setShowWidget(false);
|
|
82
|
-
setIsAnimating(false);
|
|
83
|
-
setStateRaw(newState);
|
|
84
|
-
}, 200);
|
|
85
|
-
} else {
|
|
86
|
-
setStateRaw(newState);
|
|
87
|
-
}
|
|
88
|
-
},
|
|
89
|
-
[currentState, setStateRaw]
|
|
90
|
-
);
|
|
91
|
-
|
|
92
|
-
// Sync showWidget with state
|
|
93
|
-
useEffect(() => {
|
|
94
|
-
if (currentState === 'open') {
|
|
95
|
-
setShowWidget(true);
|
|
96
|
-
}
|
|
97
|
-
}, [currentState]);
|
|
59
|
+
// Extract primitives from config for stable dependency array
|
|
60
|
+
const configConversationId = config.conversationId;
|
|
61
|
+
const configUserEmail = config.currentUser?.email;
|
|
62
|
+
const configUserName = config.currentUser?.name;
|
|
63
|
+
const configUserAvatar = config.currentUser?.avatar;
|
|
64
|
+
const configUserStatus = config.currentUser?.status;
|
|
98
65
|
|
|
99
66
|
// Initialize session
|
|
100
67
|
useEffect(() => {
|
|
68
|
+
// Guard: only run once unless identity actually changes
|
|
69
|
+
if (sessionInitializedRef.current) return;
|
|
70
|
+
|
|
101
71
|
const initializeSession = () => {
|
|
102
72
|
try {
|
|
103
|
-
if (
|
|
104
|
-
const convId =
|
|
73
|
+
if (configUserEmail && configUserName) {
|
|
74
|
+
const convId = configConversationId || generateSessionId();
|
|
105
75
|
const user: IUser = {
|
|
106
|
-
name:
|
|
107
|
-
email:
|
|
108
|
-
avatar:
|
|
76
|
+
name: configUserName,
|
|
77
|
+
email: configUserEmail,
|
|
78
|
+
avatar: configUserAvatar,
|
|
109
79
|
type: 'customer',
|
|
110
|
-
status:
|
|
80
|
+
status: configUserStatus,
|
|
111
81
|
};
|
|
112
82
|
setUserInfo(user);
|
|
113
83
|
setConversationId(convId);
|
|
114
84
|
setIsLoading(false);
|
|
85
|
+
sessionInitializedRef.current = true;
|
|
115
86
|
return;
|
|
116
87
|
}
|
|
117
88
|
|
|
@@ -130,26 +101,29 @@ export function Chat({
|
|
|
130
101
|
setUserInfo(user);
|
|
131
102
|
setConversationId(storedData.conversationId);
|
|
132
103
|
setIsLoading(false);
|
|
104
|
+
sessionInitializedRef.current = true;
|
|
133
105
|
return;
|
|
134
106
|
}
|
|
135
107
|
}
|
|
136
108
|
|
|
137
|
-
const convId =
|
|
109
|
+
const convId = configConversationId || generateSessionId();
|
|
138
110
|
setConversationId(convId);
|
|
139
111
|
|
|
140
112
|
if (identityMode === 'progressive' || identityMode === 'none') {
|
|
141
113
|
setUserInfo(null);
|
|
142
114
|
}
|
|
115
|
+
sessionInitializedRef.current = true;
|
|
143
116
|
} catch (error) {
|
|
144
117
|
console.error('Error initializing chat session:', error);
|
|
145
|
-
setConversationId(
|
|
118
|
+
setConversationId(configConversationId || generateSessionId());
|
|
119
|
+
sessionInitializedRef.current = true;
|
|
146
120
|
} finally {
|
|
147
121
|
setIsLoading(false);
|
|
148
122
|
}
|
|
149
123
|
};
|
|
150
124
|
|
|
151
125
|
initializeSession();
|
|
152
|
-
}, [
|
|
126
|
+
}, [configConversationId, configUserEmail, configUserName, configUserAvatar, configUserStatus, storageKeyPrefix, identityMode]);
|
|
153
127
|
|
|
154
128
|
const handlePreChatSubmit = useCallback(
|
|
155
129
|
(name: string, email: string) => {
|
|
@@ -245,29 +219,16 @@ export function Chat({
|
|
|
245
219
|
currentUser: userInfo || undefined,
|
|
246
220
|
};
|
|
247
221
|
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
? {
|
|
252
|
-
opacity: 1,
|
|
253
|
-
transform: 'translateY(0) scale(1)',
|
|
254
|
-
transition: 'opacity 0.25s ease-out, transform 0.25s ease-out',
|
|
255
|
-
}
|
|
256
|
-
: {
|
|
257
|
-
opacity: 0,
|
|
258
|
-
transform: 'translateY(12px) scale(0.97)',
|
|
259
|
-
transition: 'opacity 0.2s ease-in, transform 0.2s ease-in',
|
|
260
|
-
};
|
|
261
|
-
|
|
222
|
+
// Render ChatWidget directly — no wrapper div.
|
|
223
|
+
// A wrapper with CSS `transform` (even identity) creates a new containing block,
|
|
224
|
+
// which breaks `position: fixed` on the ChatWidget.
|
|
262
225
|
return (
|
|
263
|
-
<
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
/>
|
|
271
|
-
</div>
|
|
226
|
+
<ChatWidget
|
|
227
|
+
config={fullConfig}
|
|
228
|
+
className={className}
|
|
229
|
+
onClose={() => setState('closed')}
|
|
230
|
+
onMinimize={() => setState('minimized')}
|
|
231
|
+
resolvedPosition={position}
|
|
232
|
+
/>
|
|
272
233
|
);
|
|
273
234
|
}
|
package/src/hooks/useMessages.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import type { IMessage, IChatConfig, UseMessagesReturn } from '../types';
|
|
3
3
|
import type { UseWebSocketReturn } from './useWebSocket';
|
|
4
4
|
import { fetchMessages } from '../utils/api';
|
|
5
5
|
|
|
6
|
+
/** Max time (ms) to show the bot thinking indicator before auto-clearing */
|
|
7
|
+
const BOT_THINKING_TIMEOUT = 45_000;
|
|
8
|
+
|
|
6
9
|
export function useMessages(websocket: UseWebSocketReturn, config: IChatConfig): UseMessagesReturn {
|
|
7
10
|
const [messages, setMessages] = useState<IMessage[]>([]);
|
|
8
11
|
const [isLoading, setIsLoading] = useState(false);
|
|
@@ -11,6 +14,7 @@ export function useMessages(websocket: UseWebSocketReturn, config: IChatConfig):
|
|
|
11
14
|
const [hasMore, setHasMore] = useState(true);
|
|
12
15
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
13
16
|
const [isBotThinking, setIsBotThinking] = useState(false);
|
|
17
|
+
const botThinkingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
14
18
|
|
|
15
19
|
// Extract stable references from config
|
|
16
20
|
const { httpApiUrl, conversationId, headers, onError, toast } = config;
|
|
@@ -81,6 +85,10 @@ export function useMessages(websocket: UseWebSocketReturn, config: IChatConfig):
|
|
|
81
85
|
// Clear bot thinking state when bot or system message arrives
|
|
82
86
|
if (newMessage.senderType === 'bot' || newMessage.senderType === 'system') {
|
|
83
87
|
setIsBotThinking(false);
|
|
88
|
+
if (botThinkingTimerRef.current) {
|
|
89
|
+
clearTimeout(botThinkingTimerRef.current);
|
|
90
|
+
botThinkingTimerRef.current = null;
|
|
91
|
+
}
|
|
84
92
|
}
|
|
85
93
|
|
|
86
94
|
// Notify parent component about new message
|
|
@@ -99,6 +107,12 @@ export function useMessages(websocket: UseWebSocketReturn, config: IChatConfig):
|
|
|
99
107
|
// Show bot thinking indicator immediately when customer sends a message
|
|
100
108
|
if (message.senderType === 'customer') {
|
|
101
109
|
setIsBotThinking(true);
|
|
110
|
+
|
|
111
|
+
// Safety timeout — clear thinking state if no bot response arrives
|
|
112
|
+
if (botThinkingTimerRef.current) clearTimeout(botThinkingTimerRef.current);
|
|
113
|
+
botThinkingTimerRef.current = setTimeout(() => {
|
|
114
|
+
setIsBotThinking(false);
|
|
115
|
+
}, BOT_THINKING_TIMEOUT);
|
|
102
116
|
}
|
|
103
117
|
}, []);
|
|
104
118
|
|
|
@@ -149,6 +163,13 @@ export function useMessages(websocket: UseWebSocketReturn, config: IChatConfig):
|
|
|
149
163
|
onError,
|
|
150
164
|
]);
|
|
151
165
|
|
|
166
|
+
// Clean up timer on unmount
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
return () => {
|
|
169
|
+
if (botThinkingTimerRef.current) clearTimeout(botThinkingTimerRef.current);
|
|
170
|
+
};
|
|
171
|
+
}, []);
|
|
172
|
+
|
|
152
173
|
return {
|
|
153
174
|
messages,
|
|
154
175
|
addMessage,
|
|
@@ -12,6 +12,9 @@ export interface UseWebSocketReturn {
|
|
|
12
12
|
/**
|
|
13
13
|
* Hook for WebSocket connection in chat widget.
|
|
14
14
|
* Can use an external WebSocket connection (for agents) via the externalWebSocket prop.
|
|
15
|
+
*
|
|
16
|
+
* Handles React Strict Mode (dev) gracefully — tracks whether the effect has been
|
|
17
|
+
* cleaned up so that an aborted connection doesn't trigger reconnection.
|
|
15
18
|
*/
|
|
16
19
|
export function useWebSocket(
|
|
17
20
|
config: IChatConfig,
|
|
@@ -24,6 +27,7 @@ export function useWebSocket(
|
|
|
24
27
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
25
28
|
const reconnectAttemptsRef = useRef(0);
|
|
26
29
|
const messageHandlerRef = useRef<((event: MessageEvent) => void) | null>(null);
|
|
30
|
+
const abortedRef = useRef(false);
|
|
27
31
|
const maxReconnectAttempts = 5;
|
|
28
32
|
const reconnectDelay = 3000;
|
|
29
33
|
|
|
@@ -71,6 +75,9 @@ export function useWebSocket(
|
|
|
71
75
|
|
|
72
76
|
// biome-ignore lint/correctness/useExhaustiveDependencies: dependencies managed manually
|
|
73
77
|
const connect = useCallback(() => {
|
|
78
|
+
// Skip if the effect was already cleaned up (React Strict Mode)
|
|
79
|
+
if (abortedRef.current) return;
|
|
80
|
+
|
|
74
81
|
console.log('connecting to WebSocket...', config.currentUser, config.conversationId);
|
|
75
82
|
try {
|
|
76
83
|
// Clean up existing connection
|
|
@@ -96,6 +103,10 @@ export function useWebSocket(
|
|
|
96
103
|
const ws = new WebSocket(url.toString());
|
|
97
104
|
|
|
98
105
|
ws.onopen = () => {
|
|
106
|
+
if (abortedRef.current) {
|
|
107
|
+
ws.close(1000, 'Effect cleaned up');
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
99
110
|
console.log('WebSocket connected');
|
|
100
111
|
setIsConnected(true);
|
|
101
112
|
setError(null);
|
|
@@ -104,6 +115,7 @@ export function useWebSocket(
|
|
|
104
115
|
};
|
|
105
116
|
|
|
106
117
|
ws.onerror = (event) => {
|
|
118
|
+
if (abortedRef.current) return;
|
|
107
119
|
console.error('WebSocket error:', event);
|
|
108
120
|
const err = new Error('WebSocket connection error');
|
|
109
121
|
setError(err);
|
|
@@ -111,6 +123,7 @@ export function useWebSocket(
|
|
|
111
123
|
};
|
|
112
124
|
|
|
113
125
|
ws.onclose = (event) => {
|
|
126
|
+
if (abortedRef.current) return;
|
|
114
127
|
console.log('WebSocket closed:', event.code, event.reason);
|
|
115
128
|
setIsConnected(false);
|
|
116
129
|
config.onConnectionChange?.(false);
|
|
@@ -165,6 +178,7 @@ export function useWebSocket(
|
|
|
165
178
|
|
|
166
179
|
const reconnect = useCallback(() => {
|
|
167
180
|
reconnectAttemptsRef.current = 0;
|
|
181
|
+
abortedRef.current = false;
|
|
168
182
|
connect();
|
|
169
183
|
}, [connect]);
|
|
170
184
|
|
|
@@ -176,10 +190,13 @@ export function useWebSocket(
|
|
|
176
190
|
return cleanup;
|
|
177
191
|
}
|
|
178
192
|
|
|
193
|
+
// Reset abort flag for this effect cycle
|
|
194
|
+
abortedRef.current = false;
|
|
179
195
|
connect();
|
|
180
196
|
|
|
181
|
-
// Cleanup on unmount
|
|
197
|
+
// Cleanup on unmount (or React Strict Mode re-run)
|
|
182
198
|
return () => {
|
|
199
|
+
abortedRef.current = true;
|
|
183
200
|
if (reconnectTimeoutRef.current) {
|
|
184
201
|
clearTimeout(reconnectTimeoutRef.current);
|
|
185
202
|
}
|