@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.
@@ -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
+ }
@@ -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
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../../config/typescript/react.json",
3
+ "include": ["."],
4
+ "exclude": ["dist", "build", "node_modules"]
5
+ }
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
+ });