@aslaluroba/help-center-react 3.2.3 → 3.2.5

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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "main": "dist/index.js",
4
4
  "module": "dist/index.esm.js",
5
5
  "types": "dist/index.d.ts",
6
- "version": "3.2.3",
6
+ "version": "3.2.5",
7
7
  "description": "BabylAI Help Center Widget for React and Next.js",
8
8
  "private": false,
9
9
  "exports": {
@@ -1,13 +1,22 @@
1
1
  import * as Ably from 'ably';
2
2
 
3
+ type ActionHandlerCallback = (actionType: string | undefined | null, messageData: any) => void | Promise<void>;
4
+
3
5
  export class ClientAblyService {
4
6
  private static client: Ably.Realtime | null = null;
5
7
  private static channel: Ably.RealtimeChannel | null = null;
6
8
  private static isConnected: boolean = false;
7
9
  private static sessionId: string | null = null;
8
10
  private static messageUnsubscribe: (() => void) | null = null;
9
-
10
- static async startConnection(sessionId: string, ablyToken: string, onMessageReceived: Function, tenantId: string) {
11
+ private static onActionReceived: ActionHandlerCallback | null = null;
12
+
13
+ static async startConnection(
14
+ sessionId: string,
15
+ ablyToken: string,
16
+ onMessageReceived: Function,
17
+ tenantId: string,
18
+ onActionReceived?: ActionHandlerCallback
19
+ ) {
11
20
  // Prevent multiple connections
12
21
  if (this.isConnected && this.sessionId === sessionId) {
13
22
  return;
@@ -73,6 +82,9 @@ export class ClientAblyService {
73
82
  }, 10000);
74
83
  });
75
84
 
85
+ // Store optional action handler for this connection
86
+ this.onActionReceived = onActionReceived ?? null;
87
+
76
88
  // Subscribe to the session room
77
89
  await this.joinChannel(sessionId, onMessageReceived, tenantId);
78
90
  } catch (error) {
@@ -108,11 +120,17 @@ export class ClientAblyService {
108
120
  this.channel.subscribe('ReceiveMessage', (message) => {
109
121
  try {
110
122
  // Ensure messageContent is always a string (default to empty string if undefined)
123
+ const rawData = message.data;
124
+
111
125
  const messageContent =
112
- typeof message.data === 'string' ? message.data : message.data?.content ?? message.data?.message ?? '';
113
- const senderType = message.data?.senderType || 3; // Assistant
114
- const needsAgent = message.data?.needsAgent || message.data?.actionType == 'needs_agent' || false;
115
- const attachments = message.data?.attachments || [];
126
+ typeof rawData === 'string' ? rawData : rawData?.content ?? rawData?.message ?? '';
127
+ const senderType = rawData?.senderType || 3; // Assistant
128
+ const needsAgent = rawData?.needsAgent || rawData?.actionType == 'needs_agent' || false;
129
+ const attachments = rawData?.attachments || [];
130
+ const actionType = (rawData && typeof rawData.actionType === 'string' ? rawData.actionType : '') as
131
+ | string
132
+ | undefined
133
+ | null;
116
134
 
117
135
  // Extract downloadUrl from attachments (Ably now returns downloadUrl directly)
118
136
  // Attachments can be: strings (URLs), objects with downloadUrl, or objects with id
@@ -133,6 +151,19 @@ export class ClientAblyService {
133
151
  })
134
152
  .filter((url: string | null): url is string => url !== null);
135
153
 
154
+ // Invoke optional action handler first (non-blocking for message processing)
155
+ if (this.onActionReceived && actionType !== undefined) {
156
+ try {
157
+ void this.onActionReceived(actionType, rawData);
158
+ } catch (actionError) {
159
+ console.error('[AblyService] Error in action handler callback', {
160
+ error: actionError,
161
+ actionType,
162
+ rawData,
163
+ });
164
+ }
165
+ }
166
+
136
167
  onMessageReceived(messageContent, senderType, needsAgent, attachmentUrls);
137
168
  } catch (error) {
138
169
  console.error('[AblyService] Error processing message', { error, message });
@@ -166,6 +197,7 @@ export class ClientAblyService {
166
197
 
167
198
  this.isConnected = false;
168
199
  this.sessionId = null;
200
+ this.onActionReceived = null;
169
201
  } catch (error) {
170
202
  console.error('[AblyService] Error in stopConnection', { error });
171
203
  // Reset state even if there's an error
package/src/core/api.ts CHANGED
@@ -238,7 +238,6 @@ export async function presignUpload(
238
238
 
239
239
  return await response.json();
240
240
  }
241
-
242
241
  export async function presignDownload(fileId: string, language: string): Promise<PresignDownloadResponse> {
243
242
  try {
244
243
  const response = await apiRequest(`NewFile/${fileId}/presign-download`, 'GET', null, {
@@ -0,0 +1,102 @@
1
+ import { useCallback, useRef } from 'react';
2
+
3
+ export type ActionHandler = (messageData: unknown) => void | Promise<void>;
4
+
5
+ export interface ActionHandlerService {
6
+ registerHandler(actionType: string, handler: ActionHandler): void;
7
+ unregisterHandler(actionType: string): void;
8
+ handleAction(actionType: string | undefined | null, messageData: unknown): Promise<void>;
9
+ getRegisteredActions(): string[];
10
+ }
11
+
12
+ export const useActionHandler = (): ActionHandlerService => {
13
+ const handlersRef = useRef<Map<string, ActionHandler>>(new Map());
14
+
15
+ const defaultHandlerRef = useRef<ActionHandler>((messageData) => {
16
+ const message = (messageData as { actionType?: string } | undefined) ?? {};
17
+ const actionType = message.actionType || '';
18
+
19
+ if (actionType) {
20
+ // Log unknown actions for debugging but do not crash
21
+ // eslint-disable-next-line no-console
22
+ console.warn(
23
+ `[ActionHandler] Unknown action type received: "${actionType}". Message data:`,
24
+ messageData
25
+ );
26
+ }
27
+ });
28
+
29
+ const registerHandler = useCallback((actionType: string, handler: ActionHandler) => {
30
+ if (!actionType) {
31
+ // eslint-disable-next-line no-console
32
+ console.warn('[ActionHandler] Attempted to register handler with empty action type. Ignored.');
33
+ return;
34
+ }
35
+
36
+ if (handlersRef.current.has(actionType)) {
37
+ // eslint-disable-next-line no-console
38
+ console.warn(
39
+ `[ActionHandler] Handler for action type "${actionType}" already exists. Overwriting with new handler.`
40
+ );
41
+ }
42
+
43
+ handlersRef.current.set(actionType, handler);
44
+ }, []);
45
+
46
+ const unregisterHandler = useCallback((actionType: string) => {
47
+ if (!actionType) {
48
+ return;
49
+ }
50
+ handlersRef.current.delete(actionType);
51
+ }, []);
52
+
53
+ const handleAction = useCallback(
54
+ async (actionType: string | undefined | null, messageData: unknown) => {
55
+ // Ignore empty, null, or undefined action types (no-op)
56
+ if (!actionType || actionType === '') {
57
+ return;
58
+ }
59
+
60
+ const handler = handlersRef.current.get(actionType);
61
+
62
+ if (handler) {
63
+ try {
64
+ await handler(messageData);
65
+ } catch (error) {
66
+ // eslint-disable-next-line no-console
67
+ console.error(
68
+ `[ActionHandler] Error executing handler for action type "${actionType}":`,
69
+ error
70
+ );
71
+ }
72
+ return;
73
+ }
74
+
75
+ const defaultHandler = defaultHandlerRef.current;
76
+ if (defaultHandler) {
77
+ try {
78
+ await Promise.resolve(defaultHandler(messageData));
79
+ } catch (error) {
80
+ // eslint-disable-next-line no-console
81
+ console.error(
82
+ `[ActionHandler] Error executing default handler for unknown action type "${actionType}":`,
83
+ error
84
+ );
85
+ }
86
+ }
87
+ },
88
+ []
89
+ );
90
+
91
+ const getRegisteredActions = useCallback(() => {
92
+ return Array.from(handlersRef.current.keys());
93
+ }, []);
94
+
95
+ return {
96
+ registerHandler,
97
+ unregisterHandler,
98
+ handleAction,
99
+ getRegisteredActions,
100
+ };
101
+ };
102
+
package/src/lib/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./LanguageContext";
2
2
  export * from "./utils";
3
3
  export * from "./theme-utils";
4
+ export * from "./custom-hooks/useActionHandler";
@@ -30,6 +30,7 @@ const ChatWindowFooter: React.FC<ChatWindowFooterProps> = (props) => {
30
30
  const fileInputRef = useRef<HTMLInputElement>(null);
31
31
  const [selectedFiles, setSelectedFiles] = useState<SelectedFileDto[]>([]);
32
32
  const [previewImage, setPreviewImage] = useState<string | null>(null);
33
+ const [isSending, setIsSending] = useState(false);
33
34
 
34
35
  const handleAttachClick = useCallback(() => {
35
36
  fileInputRef.current?.click();
@@ -74,119 +75,122 @@ const ChatWindowFooter: React.FC<ChatWindowFooterProps> = (props) => {
74
75
 
75
76
  const handleSendMessageWithAttachments = useCallback(async () => {
76
77
  // Prevent sending if already loading
77
- if (props.isLoading) {
78
+ if (props.isLoading || isSending) {
78
79
  return;
79
80
  }
80
81
 
81
- // Get files that need to be uploaded (those without uploadedId)
82
- const filesToUpload = selectedFiles.filter((f) => f.uploadedId === null && !f.error);
83
- const alreadyUploadedIds = selectedFiles.filter((f) => f.uploadedId !== null).map((f) => f.uploadedId as string);
84
-
85
- // Declare uploadedIds outside the if block so it's accessible later
86
- let uploadedIds: string[] = [];
87
-
88
- // If there are files to upload, upload them first
89
- if (filesToUpload.length > 0) {
90
- // Get session ID - only use existing, never create new one
91
- let sessionId: string | null = null;
92
- try {
93
- // Only use existing sessionId, never call onEnsureSession
94
- if (props.sessionId) {
95
- sessionId = props.sessionId;
96
- } else {
82
+ setIsSending(true);
83
+
84
+ try {
85
+ // Get files that need to be uploaded (those without uploadedId)
86
+ const filesToUpload = selectedFiles.filter((f) => f.uploadedId === null && !f.error);
87
+ const alreadyUploadedIds = selectedFiles.filter((f) => f.uploadedId !== null).map((f) => f.uploadedId as string);
88
+
89
+ // Declare uploadedIds outside the if block so it's accessible later
90
+ let uploadedIds: string[] = [];
91
+
92
+ // If there are files to upload, upload them first
93
+ if (filesToUpload.length > 0) {
94
+ // Get session ID - ensure session exists if needed (for image-only messages)
95
+ let sessionId: string | null = null;
96
+ try {
97
+ // Use existing sessionId if available, otherwise ensure session is created
98
+ if (props.sessionId) {
99
+ sessionId = props.sessionId;
100
+ } else {
101
+ // Ensure session exists before uploading files (allows starting chat with image only)
102
+ sessionId = await props.onEnsureSession();
103
+ }
104
+ } catch (error) {
105
+ console.error('[ChatWindowFooter] Failed to get sessionId for file upload:', error);
97
106
  // Mark all files as error
98
107
  setSelectedFiles((prev) =>
99
108
  prev.map((f) =>
100
109
  filesToUpload.some((ftl) => ftl.previewUrl === f.previewUrl)
101
- ? { ...f, error: 'No session available', uploading: false }
110
+ ? { ...f, error: 'Failed to initialize session', uploading: false }
102
111
  : f
103
112
  )
104
113
  );
114
+ setIsSending(false);
105
115
  return;
106
116
  }
107
- } catch (error) {
108
- console.error('[ChatWindowFooter] Failed to get sessionId for file upload:', error);
109
- // Mark all files as error
110
- setSelectedFiles((prev) =>
111
- prev.map((f) =>
112
- filesToUpload.some((ftl) => ftl.previewUrl === f.previewUrl)
113
- ? { ...f, error: 'Failed to initialize session', uploading: false }
114
- : f
115
- )
116
- );
117
- return;
118
- }
119
-
120
- // Upload each file and collect uploaded IDs
121
- uploadedIds = [];
122
- let hasUploadErrors = false;
123
117
 
124
- for (const fileDto of filesToUpload) {
125
- try {
126
- // Mark as uploading
127
- setSelectedFiles((prev) =>
128
- prev.map((f) => (f.previewUrl === fileDto.previewUrl ? { ...f, uploading: true, error: null } : f))
129
- );
130
-
131
- // Get presigned URL
132
- const presignResponse = await presignUpload(sessionId, fileDto.file, i18n.language);
133
-
134
- // Upload file to presigned URL using axios
135
- const uploadResponse = await axios.put(presignResponse.uploadUrl, fileDto.file, {
136
- headers: {
137
- 'Content-Type': fileDto.file.type,
138
- },
139
- onUploadProgress: () => {
140
- // Upload progress tracking (silent)
141
- },
142
- });
143
-
144
- if (uploadResponse.status !== 200 && uploadResponse.status !== 204) {
145
- throw new Error(`Upload failed with status ${uploadResponse.status}`);
118
+ // Upload each file and collect uploaded IDs
119
+ uploadedIds = [];
120
+ let hasUploadErrors = false;
121
+
122
+ for (const fileDto of filesToUpload) {
123
+ try {
124
+ // Mark as uploading
125
+ setSelectedFiles((prev) =>
126
+ prev.map((f) => (f.previewUrl === fileDto.previewUrl ? { ...f, uploading: true, error: null } : f))
127
+ );
128
+
129
+ // Get presigned URL
130
+ const presignResponse = await presignUpload(sessionId, fileDto.file, i18n.language);
131
+
132
+ // Upload file to presigned URL using axios
133
+ const uploadResponse = await axios.put(presignResponse.uploadUrl, fileDto.file, {
134
+ headers: {
135
+ 'Content-Type': fileDto.file.type,
136
+ },
137
+ onUploadProgress: () => {
138
+ // Upload progress tracking (silent)
139
+ },
140
+ });
141
+
142
+ if (uploadResponse.status !== 200 && uploadResponse.status !== 204) {
143
+ throw new Error(`Upload failed with status ${uploadResponse.status}`);
144
+ }
145
+
146
+ // Collect uploaded ID
147
+ uploadedIds.push(presignResponse.id);
148
+
149
+ // Update with uploaded ID
150
+ setSelectedFiles((prev) =>
151
+ prev.map((f) =>
152
+ f.previewUrl === fileDto.previewUrl
153
+ ? { ...f, uploading: false, uploadedId: presignResponse.id, error: null }
154
+ : f
155
+ )
156
+ );
157
+ } catch (error) {
158
+ console.error('[ChatWindowFooter] File upload failed:', error);
159
+ hasUploadErrors = true;
160
+ setSelectedFiles((prev) =>
161
+ prev.map((f) =>
162
+ f.previewUrl === fileDto.previewUrl
163
+ ? { ...f, uploading: false, error: 'Upload failed', uploadedId: null }
164
+ : f
165
+ )
166
+ );
146
167
  }
147
-
148
- // Collect uploaded ID
149
- uploadedIds.push(presignResponse.id);
150
-
151
- // Update with uploaded ID
152
- setSelectedFiles((prev) =>
153
- prev.map((f) =>
154
- f.previewUrl === fileDto.previewUrl
155
- ? { ...f, uploading: false, uploadedId: presignResponse.id, error: null }
156
- : f
157
- )
158
- );
159
- } catch (error) {
160
- console.error('[ChatWindowFooter] File upload failed:', error);
161
- hasUploadErrors = true;
162
- setSelectedFiles((prev) =>
163
- prev.map((f) =>
164
- f.previewUrl === fileDto.previewUrl
165
- ? { ...f, uploading: false, error: 'Upload failed', uploadedId: null }
166
- : f
167
- )
168
- );
169
168
  }
170
- }
171
169
 
172
- // If any uploads failed, don't send the message
173
- if (hasUploadErrors) {
174
- console.error('[ChatWindowFooter] Some files failed to upload, not sending message');
175
- return;
170
+ // If any uploads failed, don't send the message
171
+ if (hasUploadErrors) {
172
+ console.error('[ChatWindowFooter] Some files failed to upload, not sending message');
173
+ setIsSending(false);
174
+ return;
175
+ }
176
176
  }
177
- }
178
177
 
179
- // Get all successfully uploaded file IDs (already uploaded + newly uploaded)
180
- // Use uploadedIds from the upload loop instead of reading from state
181
- const allAttachmentIds = [...alreadyUploadedIds, ...uploadedIds];
178
+ // Get all successfully uploaded file IDs (already uploaded + newly uploaded)
179
+ // Use uploadedIds from the upload loop instead of reading from state
180
+ const allAttachmentIds = [...alreadyUploadedIds, ...uploadedIds];
182
181
 
183
- // Call the original send message with attachment IDs
184
- props.handleSendMessage(allAttachmentIds);
182
+ // Call the original send message with attachment IDs
183
+ props.handleSendMessage(allAttachmentIds);
185
184
 
186
- // Clear selected files and revoke URLs
187
- selectedFiles.forEach((f) => URL.revokeObjectURL(f.previewUrl));
188
- setSelectedFiles([]);
189
- }, [selectedFiles, props, i18n.language]);
185
+ // Clear selected files and revoke URLs
186
+ selectedFiles.forEach((f) => URL.revokeObjectURL(f.previewUrl));
187
+ setSelectedFiles([]);
188
+ setIsSending(false);
189
+ } catch (error) {
190
+ console.error('[ChatWindowFooter] Error sending message:', error);
191
+ setIsSending(false);
192
+ }
193
+ }, [selectedFiles, props, i18n.language, isSending]);
190
194
 
191
195
  // Check if any files are currently uploading
192
196
  const hasUploadingFiles = selectedFiles.some((f) => f.uploading);
@@ -196,7 +200,8 @@ const ChatWindowFooter: React.FC<ChatWindowFooterProps> = (props) => {
196
200
 
197
201
  // Allow sending if there's text OR files selected (files will be uploaded on send)
198
202
  const hasContentToSend = props.inputMessage.trim() !== '' || selectedFiles.length > 0;
199
- const isSendDisabled = props.isLoading || !hasContentToSend || hasUploadingFiles || hasFileErrors;
203
+ const isSendDisabled = props.isLoading || isSending || !hasContentToSend || hasUploadingFiles || hasFileErrors;
204
+ const showLoading = props.isLoading || isSending || hasUploadingFiles;
200
205
 
201
206
  const handleKeyDown = useCallback(
202
207
  (e: React.KeyboardEvent) => {
@@ -279,10 +284,16 @@ const ChatWindowFooter: React.FC<ChatWindowFooterProps> = (props) => {
279
284
  size='icon'
280
285
  onClick={handleSendMessageWithAttachments}
281
286
  disabled={isSendDisabled}
282
- className='babylai-rounded-full babylai-bg-primary-500 babylai-hover:babylai-bg-purple-600 babylai-w-8 babylai-h-8 disabled:babylai-opacity-50'
287
+ className='babylai-rounded-full babylai-bg-primary-500 babylai-hover:babylai-bg-purple-600 babylai-w-8 babylai-h-8 !babylai-p-0 babylai-flex babylai-items-center babylai-justify-center disabled:babylai-opacity-50'
283
288
  type='button'
284
289
  >
285
- <EnvelopeIcon className={`babylai-w-4 babylai-h-4 ${dir === 'rtl' ? 'babylai-rotate-270' : ''}`} />
290
+ {showLoading ? (
291
+ <div className='babylai-inline-block babylai-animate-spin babylai-rounded-full babylai-h-4 babylai-w-4 babylai-aspect-square babylai-border-2 babylai-border-white babylai-border-t-transparent babylai-box-border'></div>
292
+ ) : (
293
+ <EnvelopeIcon
294
+ className={`babylai-w-4 babylai-h-4 babylai-flex-shrink-0 ${dir === 'rtl' ? 'babylai-rotate-270' : ''}`}
295
+ />
296
+ )}
286
297
  </Button>
287
298
  </div>
288
299
 
@@ -44,7 +44,7 @@ const MessageComponent = React.memo(
44
44
  const handleImageClick = useCallback(
45
45
  (clickedIndex: number) => {
46
46
  // Use attachmentUrls if available (from Ably), otherwise use attachmentIds (user-sent)
47
- const attachments = message.attachmentUrls || message.attachmentIds || [];
47
+ const attachments = message.attachmentUrls || [];
48
48
  if (attachments.length > 0) {
49
49
  onImageClick(attachments, clickedIndex);
50
50
  }
@@ -1,5 +1,5 @@
1
1
  import ReviewDialog from '@/ui/review-dialog';
2
- import { useEffect, useState } from 'react';
2
+ import { useCallback, useEffect, useState } from 'react';
3
3
  import { apiRequest } from '../core/api';
4
4
  import { ClientAblyService } from '../core/AblyService';
5
5
  import { useLocalTranslation } from '../useLocalTranslation';
@@ -11,6 +11,7 @@ import HelpPopup from './help-popup';
11
11
  import { defaultLanguage } from '@/i18n';
12
12
  import { LanguageProvider } from '@/lib/LanguageContext';
13
13
  import { getThemeStyles } from '@/lib/theme-utils';
14
+ import { useActionHandler } from '@/lib/custom-hooks/useActionHandler';
14
15
 
15
16
  interface HelpCenterProps {
16
17
  helpScreenId: string;
@@ -57,6 +58,8 @@ const HelpCenterContent = ({
57
58
  const [assistantStatus, setAssistantStatus] = useState('idle');
58
59
  const [isReviewDialogOpen, setIsReviewDialogOpen] = useState(false);
59
60
 
61
+ const actionHandler = useActionHandler();
62
+
60
63
  const handleTogglePopup = () => {
61
64
  setIsOpen(!isOpen);
62
65
  setShowArrowAnimation(isOpen);
@@ -93,7 +96,7 @@ const HelpCenterContent = ({
93
96
  setAssistantStatus('idle');
94
97
  };
95
98
 
96
- const handleEndChat = async () => {
99
+ const handleEndChat = useCallback(async () => {
97
100
  if (!sessionId || !selectedOption) return;
98
101
 
99
102
  try {
@@ -123,7 +126,7 @@ const HelpCenterContent = ({
123
126
  setSessionId(null);
124
127
  setSelectedOption(null);
125
128
  }
126
- };
129
+ }, [language, selectedOption, sessionId]);
127
130
 
128
131
  const handleSendChatReview = async ({ comment, rating }: ReviewProps) => {
129
132
  if (!reviewSessionId) return;
@@ -195,7 +198,10 @@ const HelpCenterContent = ({
195
198
  newSessionId,
196
199
  responseData.ablyToken,
197
200
  handleReceiveMessage,
198
- responseData.chatSession.tenantId
201
+ responseData.chatSession.tenantId,
202
+ (actionType, messageData) => {
203
+ void actionHandler.handleAction(actionType, messageData);
204
+ }
199
205
  );
200
206
 
201
207
  // Verify the connection is actually active
@@ -297,6 +303,25 @@ const HelpCenterContent = ({
297
303
  }
298
304
  };
299
305
 
306
+ // Register known action handlers for realtime messages
307
+ useEffect(() => {
308
+ // "needs_agent" → trigger handoff / needs agent UI flow
309
+ actionHandler.registerHandler('needs_agent', () => {
310
+ setNeedsAgent(true);
311
+ });
312
+
313
+ // "end_session" → gracefully close the chat session and show review dialog
314
+ actionHandler.registerHandler('end_session', () => {
315
+ // Reuse existing end chat logic
316
+ void handleEndChat();
317
+ });
318
+
319
+ return () => {
320
+ actionHandler.unregisterHandler('needs_agent');
321
+ actionHandler.unregisterHandler('end_session');
322
+ };
323
+ }, [actionHandler, handleEndChat]);
324
+
300
325
  useEffect(() => {
301
326
  if (isOpen && helpScreenId) {
302
327
  setStatus('loading');
package/src/.DS_Store DELETED
Binary file