@xcelsior/ui-chat 1.0.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/.storybook/main.ts +27 -0
- package/.storybook/preview.tsx +39 -0
- package/CHANGELOG.md +32 -0
- package/README.md +526 -0
- package/biome.json +3 -0
- package/package.json +61 -0
- package/postcss.config.js +5 -0
- package/src/components/Chat.stories.tsx +54 -0
- package/src/components/Chat.tsx +194 -0
- package/src/components/ChatHeader.tsx +93 -0
- package/src/components/ChatInput.tsx +363 -0
- package/src/components/ChatWidget.tsx +234 -0
- package/src/components/MessageItem.stories.tsx +232 -0
- package/src/components/MessageItem.tsx +143 -0
- package/src/components/MessageList.tsx +189 -0
- package/src/components/PreChatForm.tsx +202 -0
- package/src/components/TypingIndicator.tsx +31 -0
- package/src/hooks/useFileUpload.ts +134 -0
- package/src/hooks/useMessages.ts +165 -0
- package/src/hooks/useTypingIndicator.ts +33 -0
- package/src/hooks/useWebSocket.ts +209 -0
- package/src/index.tsx +46 -0
- package/src/types.ts +145 -0
- package/src/utils/api.ts +43 -0
- package/tsconfig.json +5 -0
- package/tsup.config.ts +12 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
2
|
+
import type { IChatConfig, IWebSocketMessage } from '../types';
|
|
3
|
+
|
|
4
|
+
export interface UseWebSocketReturn {
|
|
5
|
+
isConnected: boolean;
|
|
6
|
+
sendMessage: (action: string, data: any) => void;
|
|
7
|
+
lastMessage: IWebSocketMessage | null;
|
|
8
|
+
error: Error | null;
|
|
9
|
+
reconnect: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Hook for WebSocket connection in chat widget.
|
|
14
|
+
* Can use an external WebSocket connection (for agents) via the externalWebSocket prop.
|
|
15
|
+
*/
|
|
16
|
+
export function useWebSocket(
|
|
17
|
+
config: IChatConfig,
|
|
18
|
+
externalWebSocket?: WebSocket | null
|
|
19
|
+
): UseWebSocketReturn {
|
|
20
|
+
const [isConnected, setIsConnected] = useState(false);
|
|
21
|
+
const [lastMessage, setLastMessage] = useState<IWebSocketMessage | null>(null);
|
|
22
|
+
const [error, setError] = useState<Error | null>(null);
|
|
23
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
24
|
+
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
25
|
+
const reconnectAttemptsRef = useRef(0);
|
|
26
|
+
const messageHandlerRef = useRef<((event: MessageEvent) => void) | null>(null);
|
|
27
|
+
const maxReconnectAttempts = 5;
|
|
28
|
+
const reconnectDelay = 3000;
|
|
29
|
+
|
|
30
|
+
// Use external WebSocket if provided (for agents)
|
|
31
|
+
const isUsingExternalWs = !!externalWebSocket;
|
|
32
|
+
|
|
33
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: dependencies managed manually
|
|
34
|
+
const subscribeToMessage = useCallback((webSocket: WebSocket) => {
|
|
35
|
+
// Remove previous listener if it exists
|
|
36
|
+
if (messageHandlerRef.current) {
|
|
37
|
+
webSocket.removeEventListener('message', messageHandlerRef.current);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Create new message handler
|
|
41
|
+
const handler = (event: MessageEvent) => {
|
|
42
|
+
try {
|
|
43
|
+
const message: IWebSocketMessage = JSON.parse(event.data);
|
|
44
|
+
setLastMessage(message);
|
|
45
|
+
|
|
46
|
+
// Handle different message types
|
|
47
|
+
if (message.type === 'message' && message.data) {
|
|
48
|
+
config.onMessageReceived?.(message.data);
|
|
49
|
+
} else if (message.type === 'error') {
|
|
50
|
+
const err = new Error(message.data?.message || 'WebSocket error');
|
|
51
|
+
setError(err);
|
|
52
|
+
config.onError?.(err);
|
|
53
|
+
}
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.error('Failed to parse WebSocket message:', err);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Add the new listener
|
|
60
|
+
webSocket.addEventListener('message', handler);
|
|
61
|
+
messageHandlerRef.current = handler;
|
|
62
|
+
|
|
63
|
+
// Return cleanup function
|
|
64
|
+
return () => {
|
|
65
|
+
webSocket.removeEventListener('message', handler);
|
|
66
|
+
if (messageHandlerRef.current === handler) {
|
|
67
|
+
messageHandlerRef.current = null;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: dependencies managed manually
|
|
73
|
+
const connect = useCallback(() => {
|
|
74
|
+
console.log('connecting to WebSocket...', config.currentUser, config.conversationId);
|
|
75
|
+
try {
|
|
76
|
+
// Clean up existing connection
|
|
77
|
+
if (wsRef.current) {
|
|
78
|
+
// Remove message listener before closing
|
|
79
|
+
if (messageHandlerRef.current) {
|
|
80
|
+
wsRef.current.removeEventListener('message', messageHandlerRef.current);
|
|
81
|
+
messageHandlerRef.current = null;
|
|
82
|
+
}
|
|
83
|
+
wsRef.current.close();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Build WebSocket URL with query parameters
|
|
87
|
+
const url = new URL(config.websocketUrl);
|
|
88
|
+
url.searchParams.set('user', JSON.stringify(config.currentUser));
|
|
89
|
+
if (config.conversationId) {
|
|
90
|
+
url.searchParams.set('conversationId', config.conversationId);
|
|
91
|
+
}
|
|
92
|
+
if (config.apiKey) {
|
|
93
|
+
url.searchParams.set('apiKey', config.apiKey);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const ws = new WebSocket(url.toString());
|
|
97
|
+
|
|
98
|
+
ws.onopen = () => {
|
|
99
|
+
console.log('WebSocket connected');
|
|
100
|
+
setIsConnected(true);
|
|
101
|
+
setError(null);
|
|
102
|
+
reconnectAttemptsRef.current = 0;
|
|
103
|
+
config.onConnectionChange?.(true);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
ws.onerror = (event) => {
|
|
107
|
+
console.error('WebSocket error:', event);
|
|
108
|
+
const err = new Error('WebSocket connection error');
|
|
109
|
+
setError(err);
|
|
110
|
+
config.onError?.(err);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
ws.onclose = (event) => {
|
|
114
|
+
console.log('WebSocket closed:', event.code, event.reason);
|
|
115
|
+
setIsConnected(false);
|
|
116
|
+
config.onConnectionChange?.(false);
|
|
117
|
+
wsRef.current = null;
|
|
118
|
+
|
|
119
|
+
// Attempt to reconnect if not a normal closure
|
|
120
|
+
if (event.code !== 1000 && reconnectAttemptsRef.current < maxReconnectAttempts) {
|
|
121
|
+
reconnectAttemptsRef.current += 1;
|
|
122
|
+
console.log(
|
|
123
|
+
`Reconnecting... (${reconnectAttemptsRef.current}/${maxReconnectAttempts})`
|
|
124
|
+
);
|
|
125
|
+
reconnectTimeoutRef.current = setTimeout(() => {
|
|
126
|
+
connect();
|
|
127
|
+
}, reconnectDelay);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
subscribeToMessage(ws);
|
|
132
|
+
wsRef.current = ws ?? null;
|
|
133
|
+
} catch (err) {
|
|
134
|
+
console.error('Failed to create WebSocket connection:', err);
|
|
135
|
+
const error = err instanceof Error ? err : new Error('Failed to connect');
|
|
136
|
+
setError(error);
|
|
137
|
+
config.onError?.(error);
|
|
138
|
+
}
|
|
139
|
+
}, [JSON.stringify([config.currentUser, config.conversationId])]);
|
|
140
|
+
|
|
141
|
+
const sendMessage = useCallback(
|
|
142
|
+
(action: string, data: any) => {
|
|
143
|
+
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
|
144
|
+
console.error('WebSocket is not connected');
|
|
145
|
+
config.toast?.error('Not connected to chat server');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
wsRef.current.send(
|
|
151
|
+
JSON.stringify({
|
|
152
|
+
action,
|
|
153
|
+
data,
|
|
154
|
+
})
|
|
155
|
+
);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
console.error('Failed to send message:', err);
|
|
158
|
+
const error = err instanceof Error ? err : new Error('Failed to send message');
|
|
159
|
+
setError(error);
|
|
160
|
+
config.onError?.(error);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
[config]
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const reconnect = useCallback(() => {
|
|
167
|
+
reconnectAttemptsRef.current = 0;
|
|
168
|
+
connect();
|
|
169
|
+
}, [connect]);
|
|
170
|
+
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
if (isUsingExternalWs) {
|
|
173
|
+
setIsConnected(externalWebSocket?.readyState === WebSocket.OPEN || false);
|
|
174
|
+
wsRef.current = externalWebSocket;
|
|
175
|
+
const cleanup = subscribeToMessage(externalWebSocket);
|
|
176
|
+
return cleanup;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
connect();
|
|
180
|
+
|
|
181
|
+
// Cleanup on unmount
|
|
182
|
+
return () => {
|
|
183
|
+
if (reconnectTimeoutRef.current) {
|
|
184
|
+
clearTimeout(reconnectTimeoutRef.current);
|
|
185
|
+
}
|
|
186
|
+
if (wsRef.current) {
|
|
187
|
+
// Remove message listener before closing
|
|
188
|
+
if (messageHandlerRef.current) {
|
|
189
|
+
wsRef.current.removeEventListener('message', messageHandlerRef.current);
|
|
190
|
+
messageHandlerRef.current = null;
|
|
191
|
+
}
|
|
192
|
+
wsRef.current.close(1000, 'Component unmounted');
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
}, [connect, isUsingExternalWs, externalWebSocket, subscribeToMessage]);
|
|
196
|
+
|
|
197
|
+
// Use external connection state if available
|
|
198
|
+
const effectiveIsConnected = isUsingExternalWs
|
|
199
|
+
? externalWebSocket?.readyState === WebSocket.OPEN || false
|
|
200
|
+
: isConnected;
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
isConnected: effectiveIsConnected,
|
|
204
|
+
sendMessage,
|
|
205
|
+
lastMessage,
|
|
206
|
+
error,
|
|
207
|
+
reconnect,
|
|
208
|
+
};
|
|
209
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Main components
|
|
2
|
+
export { ChatWidget } from './components/ChatWidget';
|
|
3
|
+
export type { ChatWidgetProps } from './components/ChatWidget';
|
|
4
|
+
export { Chat } from './components/Chat';
|
|
5
|
+
|
|
6
|
+
// Individual components (for custom implementations)
|
|
7
|
+
export { ChatHeader } from './components/ChatHeader';
|
|
8
|
+
export { ChatInput } from './components/ChatInput';
|
|
9
|
+
export { MessageItem } from './components/MessageItem';
|
|
10
|
+
export { MessageList } from './components/MessageList';
|
|
11
|
+
export { TypingIndicator } from './components/TypingIndicator';
|
|
12
|
+
export { PreChatForm } from './components/PreChatForm';
|
|
13
|
+
|
|
14
|
+
// Hooks
|
|
15
|
+
export { useWebSocket } from './hooks/useWebSocket';
|
|
16
|
+
export type { UseWebSocketReturn } from './hooks/useWebSocket';
|
|
17
|
+
export { useMessages } from './hooks/useMessages';
|
|
18
|
+
export type { UseMessagesReturn } from './hooks/useMessages';
|
|
19
|
+
export { useFileUpload } from './hooks/useFileUpload';
|
|
20
|
+
export type { UseFileUploadReturn } from './hooks/useFileUpload';
|
|
21
|
+
export { useTypingIndicator } from './hooks/useTypingIndicator';
|
|
22
|
+
export type { UseTypingIndicatorReturn } from './hooks/useTypingIndicator';
|
|
23
|
+
|
|
24
|
+
// Utilities
|
|
25
|
+
export { fetchMessages } from './utils/api';
|
|
26
|
+
export type { FetchMessagesParams } from './utils/api';
|
|
27
|
+
|
|
28
|
+
// Types
|
|
29
|
+
export type {
|
|
30
|
+
IUser,
|
|
31
|
+
IMessage,
|
|
32
|
+
IConversation,
|
|
33
|
+
IChatConfig,
|
|
34
|
+
IWebSocketMessage,
|
|
35
|
+
ISendMessageData,
|
|
36
|
+
ITypingData,
|
|
37
|
+
IReadMessageData,
|
|
38
|
+
IFileUploadConfig,
|
|
39
|
+
IUploadedFile,
|
|
40
|
+
IApiResponse,
|
|
41
|
+
MessageType,
|
|
42
|
+
MessageStatus,
|
|
43
|
+
ConversationStatus,
|
|
44
|
+
ConversationPriority,
|
|
45
|
+
ConversationChannel,
|
|
46
|
+
} from './types';
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// User types
|
|
2
|
+
export interface IUser {
|
|
3
|
+
name: string;
|
|
4
|
+
email: string;
|
|
5
|
+
avatar?: string;
|
|
6
|
+
type: 'customer' | 'agent';
|
|
7
|
+
status?: 'online' | 'offline' | 'away' | 'busy';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Message types
|
|
11
|
+
export type MessageType = 'text' | 'image' | 'file' | 'system';
|
|
12
|
+
export type MessageStatus = 'sent' | 'delivered' | 'read';
|
|
13
|
+
|
|
14
|
+
export interface IMessage {
|
|
15
|
+
id: string;
|
|
16
|
+
conversationId: string;
|
|
17
|
+
senderId: string;
|
|
18
|
+
senderType: 'customer' | 'agent' | 'system';
|
|
19
|
+
content: string;
|
|
20
|
+
messageType: MessageType;
|
|
21
|
+
createdAt: string;
|
|
22
|
+
status: MessageStatus;
|
|
23
|
+
metadata?: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Conversation types
|
|
27
|
+
export type ConversationStatus = 'open' | 'pending' | 'closed' | 'archived';
|
|
28
|
+
export type ConversationPriority = 'low' | 'medium' | 'high' | 'urgent';
|
|
29
|
+
export type ConversationChannel = 'web' | 'mobile' | 'email';
|
|
30
|
+
|
|
31
|
+
export interface IConversation {
|
|
32
|
+
id: string;
|
|
33
|
+
customerId: string;
|
|
34
|
+
assignedAgentId?: string;
|
|
35
|
+
status: ConversationStatus;
|
|
36
|
+
priority: ConversationPriority;
|
|
37
|
+
subject?: string;
|
|
38
|
+
channel: ConversationChannel;
|
|
39
|
+
tags?: string[];
|
|
40
|
+
createdAt: string;
|
|
41
|
+
updatedAt: string;
|
|
42
|
+
closedAt?: string;
|
|
43
|
+
lastMessageAt?: string;
|
|
44
|
+
messageCount?: number;
|
|
45
|
+
unreadCount?: number;
|
|
46
|
+
satisfaction?: 1 | 2 | 3 | 4 | 5;
|
|
47
|
+
metadata?: Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// WebSocket message types
|
|
51
|
+
export interface IWebSocketMessage {
|
|
52
|
+
type: 'message' | 'typing' | 'read' | 'connected' | 'error' | 'system';
|
|
53
|
+
data: any;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ISendMessageData {
|
|
57
|
+
conversationId: string;
|
|
58
|
+
content: string;
|
|
59
|
+
messageType?: MessageType;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ITypingData {
|
|
63
|
+
conversationId: string;
|
|
64
|
+
isTyping: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface IReadMessageData {
|
|
68
|
+
messageId: string;
|
|
69
|
+
conversationId: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// File upload types
|
|
73
|
+
export interface IFileUploadConfig {
|
|
74
|
+
uploadUrl: string;
|
|
75
|
+
maxFileSize?: number; // in bytes, default 5MB
|
|
76
|
+
allowedTypes?: string[]; // default ['image/*', 'application/pdf', etc.]
|
|
77
|
+
headers?: Record<string, string>; // additional headers for upload request
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface IUploadedFile {
|
|
81
|
+
url: string;
|
|
82
|
+
name: string;
|
|
83
|
+
size: number;
|
|
84
|
+
type: string;
|
|
85
|
+
markdown?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Chat widget configuration
|
|
89
|
+
export interface IChatConfig {
|
|
90
|
+
// WebSocket connection
|
|
91
|
+
websocketUrl: string;
|
|
92
|
+
conversationId?: string;
|
|
93
|
+
apiKey: string;
|
|
94
|
+
|
|
95
|
+
// User information
|
|
96
|
+
currentUser: IUser;
|
|
97
|
+
|
|
98
|
+
// File upload
|
|
99
|
+
fileUpload?: IFileUploadConfig;
|
|
100
|
+
|
|
101
|
+
// REST API for fetching data
|
|
102
|
+
httpApiUrl?: string;
|
|
103
|
+
headers?: Record<string, string>;
|
|
104
|
+
|
|
105
|
+
// Features
|
|
106
|
+
enableEmoji?: boolean;
|
|
107
|
+
enableFileUpload?: boolean;
|
|
108
|
+
enableTypingIndicator?: boolean;
|
|
109
|
+
enableReadReceipts?: boolean;
|
|
110
|
+
|
|
111
|
+
// WebSocket management
|
|
112
|
+
/**
|
|
113
|
+
* For agents, set this to true to disable creating a WebSocket connection in the chat widget.
|
|
114
|
+
* Agents should have a global WebSocket connection instead.
|
|
115
|
+
* @default false
|
|
116
|
+
*/
|
|
117
|
+
disableWebSocket?: boolean;
|
|
118
|
+
|
|
119
|
+
// Callbacks
|
|
120
|
+
onMessageSent?: (message: IMessage) => void;
|
|
121
|
+
onMessageReceived?: (message: IMessage) => void;
|
|
122
|
+
onConversationChange?: (conversation: IConversation) => void;
|
|
123
|
+
onConnectionChange?: (connected: boolean) => void;
|
|
124
|
+
onError?: (error: Error) => void;
|
|
125
|
+
|
|
126
|
+
// Toast notifications
|
|
127
|
+
toast?: {
|
|
128
|
+
success: (message: string) => void;
|
|
129
|
+
error: (message: string) => void;
|
|
130
|
+
info: (message: string) => void;
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// API Response types
|
|
135
|
+
export interface IApiResponse<T> {
|
|
136
|
+
success: boolean;
|
|
137
|
+
data?: T;
|
|
138
|
+
error?: {
|
|
139
|
+
code: string;
|
|
140
|
+
message: string;
|
|
141
|
+
};
|
|
142
|
+
pagination?: {
|
|
143
|
+
nextPageToken?: string;
|
|
144
|
+
};
|
|
145
|
+
}
|
package/src/utils/api.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import type { IMessage, IApiResponse } from '../types';
|
|
3
|
+
|
|
4
|
+
export interface FetchMessagesParams {
|
|
5
|
+
conversationId: string;
|
|
6
|
+
limit?: number;
|
|
7
|
+
pageToken?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Fetch messages for a conversation from the REST API
|
|
12
|
+
*/
|
|
13
|
+
export async function fetchMessages(
|
|
14
|
+
baseUrl: string,
|
|
15
|
+
params: FetchMessagesParams,
|
|
16
|
+
headers?: Record<string, string>
|
|
17
|
+
) {
|
|
18
|
+
try {
|
|
19
|
+
const response = await axios.get<IApiResponse<IMessage[]>>(`${baseUrl}/messages`, {
|
|
20
|
+
params: {
|
|
21
|
+
conversationId: params.conversationId,
|
|
22
|
+
limit: params.limit || 50,
|
|
23
|
+
pageToken: params.pageToken,
|
|
24
|
+
},
|
|
25
|
+
headers: {
|
|
26
|
+
'Content-Type': 'application/json',
|
|
27
|
+
...headers,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
data: response.data.data ?? [],
|
|
33
|
+
nextPageToken: response.data.pagination?.nextPageToken,
|
|
34
|
+
};
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (axios.isAxiosError(error)) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
error.response?.data?.error?.message || error.message || 'Failed to fetch messages'
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
package/tsconfig.json
ADDED
package/tsup.config.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineConfig } from 'tsup';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
entry: ['src/index.ts'],
|
|
5
|
+
format: ['cjs', 'esm'],
|
|
6
|
+
dts: true,
|
|
7
|
+
splitting: false,
|
|
8
|
+
sourcemap: true,
|
|
9
|
+
clean: true,
|
|
10
|
+
external: ['react', 'react-dom', '@xcelsior/design-system'],
|
|
11
|
+
outDir: 'dist',
|
|
12
|
+
});
|