@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,202 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
|
|
3
|
+
interface PreChatFormProps {
|
|
4
|
+
onSubmit: (name: string, email: string) => void;
|
|
5
|
+
className?: string;
|
|
6
|
+
initialName?: string;
|
|
7
|
+
initialEmail?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* PreChatForm component for collecting user information before starting chat
|
|
12
|
+
*/
|
|
13
|
+
export function PreChatForm({
|
|
14
|
+
onSubmit,
|
|
15
|
+
className = '',
|
|
16
|
+
initialName = '',
|
|
17
|
+
initialEmail = '',
|
|
18
|
+
}: PreChatFormProps) {
|
|
19
|
+
const [name, setName] = useState(initialName);
|
|
20
|
+
const [email, setEmail] = useState(initialEmail);
|
|
21
|
+
const [errors, setErrors] = useState<{ name?: string; email?: string }>({});
|
|
22
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
23
|
+
|
|
24
|
+
const validateForm = (): boolean => {
|
|
25
|
+
const newErrors: { name?: string; email?: string } = {};
|
|
26
|
+
|
|
27
|
+
// Validate name
|
|
28
|
+
if (!name.trim()) {
|
|
29
|
+
newErrors.name = 'Name is required';
|
|
30
|
+
} else if (name.trim().length < 2) {
|
|
31
|
+
newErrors.name = 'Name must be at least 2 characters';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Validate email
|
|
35
|
+
if (!email.trim()) {
|
|
36
|
+
newErrors.email = 'Email is required';
|
|
37
|
+
} else {
|
|
38
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
39
|
+
if (!emailRegex.test(email)) {
|
|
40
|
+
newErrors.email = 'Please enter a valid email address';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
setErrors(newErrors);
|
|
45
|
+
return Object.keys(newErrors).length === 0;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
|
|
51
|
+
if (!validateForm()) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
setIsSubmitting(true);
|
|
56
|
+
try {
|
|
57
|
+
onSubmit(name.trim(), email.trim());
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error('Error submitting form:', error);
|
|
60
|
+
} finally {
|
|
61
|
+
setIsSubmitting(false);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
className={`fixed bottom-4 right-4 z-50 flex flex-col bg-white dark:bg-gray-900 rounded-lg shadow-2xl overflow-hidden ${className}`}
|
|
68
|
+
style={{
|
|
69
|
+
width: '400px',
|
|
70
|
+
maxHeight: 'calc(100vh - 2rem)',
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
{/* Header */}
|
|
74
|
+
<div className="bg-gradient-to-r from-blue-600 to-purple-600 text-white px-6 py-4">
|
|
75
|
+
<h2 className="text-lg font-semibold">Start a Conversation</h2>
|
|
76
|
+
<p className="text-sm text-blue-100 mt-1">
|
|
77
|
+
Please provide your details to continue
|
|
78
|
+
</p>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
{/* Form */}
|
|
82
|
+
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
|
83
|
+
{/* Name Input */}
|
|
84
|
+
<div>
|
|
85
|
+
<label
|
|
86
|
+
htmlFor="chat-name"
|
|
87
|
+
className="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-200"
|
|
88
|
+
>
|
|
89
|
+
Name <span className="text-red-500">*</span>
|
|
90
|
+
</label>
|
|
91
|
+
<input
|
|
92
|
+
type="text"
|
|
93
|
+
id="chat-name"
|
|
94
|
+
value={name}
|
|
95
|
+
onChange={(e) => {
|
|
96
|
+
setName(e.target.value);
|
|
97
|
+
if (errors.name) {
|
|
98
|
+
setErrors((prev) => ({ ...prev, name: undefined }));
|
|
99
|
+
}
|
|
100
|
+
}}
|
|
101
|
+
className={`block w-full px-4 py-2.5 text-sm text-gray-900 bg-gray-50 rounded-lg border ${
|
|
102
|
+
errors.name
|
|
103
|
+
? 'border-red-500 focus:ring-red-500 focus:border-red-500'
|
|
104
|
+
: 'border-gray-300 focus:ring-blue-500 focus:border-blue-500'
|
|
105
|
+
} dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500`}
|
|
106
|
+
placeholder="John Doe"
|
|
107
|
+
disabled={isSubmitting}
|
|
108
|
+
autoComplete="name"
|
|
109
|
+
/>
|
|
110
|
+
{errors.name && (
|
|
111
|
+
<p className="mt-2 text-sm text-red-600 dark:text-red-500" role="alert">
|
|
112
|
+
{errors.name}
|
|
113
|
+
</p>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
{/* Email Input */}
|
|
118
|
+
<div>
|
|
119
|
+
<label
|
|
120
|
+
htmlFor="chat-email"
|
|
121
|
+
className="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-200"
|
|
122
|
+
>
|
|
123
|
+
Email <span className="text-red-500">*</span>
|
|
124
|
+
</label>
|
|
125
|
+
<input
|
|
126
|
+
type="email"
|
|
127
|
+
id="chat-email"
|
|
128
|
+
value={email}
|
|
129
|
+
onChange={(e) => {
|
|
130
|
+
setEmail(e.target.value);
|
|
131
|
+
if (errors.email) {
|
|
132
|
+
setErrors((prev) => ({ ...prev, email: undefined }));
|
|
133
|
+
}
|
|
134
|
+
}}
|
|
135
|
+
className={`block w-full px-4 py-2.5 text-sm text-gray-900 bg-gray-50 rounded-lg border ${
|
|
136
|
+
errors.email
|
|
137
|
+
? 'border-red-500 focus:ring-red-500 focus:border-red-500'
|
|
138
|
+
: 'border-gray-300 focus:ring-blue-500 focus:border-blue-500'
|
|
139
|
+
} dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500`}
|
|
140
|
+
placeholder="john@example.com"
|
|
141
|
+
disabled={isSubmitting}
|
|
142
|
+
autoComplete="email"
|
|
143
|
+
/>
|
|
144
|
+
{errors.email && (
|
|
145
|
+
<p className="mt-2 text-sm text-red-600 dark:text-red-500" role="alert">
|
|
146
|
+
{errors.email}
|
|
147
|
+
</p>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
{/* Submit Button */}
|
|
152
|
+
<button
|
|
153
|
+
type="submit"
|
|
154
|
+
disabled={isSubmitting}
|
|
155
|
+
className="w-full px-5 py-2.5 text-sm font-medium text-white bg-gradient-to-r from-blue-600 to-purple-600 rounded-lg hover:from-blue-700 hover:to-purple-700 focus:outline-none focus:ring-4 focus:ring-blue-300 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
|
156
|
+
>
|
|
157
|
+
{isSubmitting ? (
|
|
158
|
+
<span className="flex items-center justify-center">
|
|
159
|
+
<svg
|
|
160
|
+
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
|
161
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
162
|
+
fill="none"
|
|
163
|
+
viewBox="0 0 24 24"
|
|
164
|
+
aria-label="Loading"
|
|
165
|
+
>
|
|
166
|
+
<title>Loading</title>
|
|
167
|
+
<circle
|
|
168
|
+
className="opacity-25"
|
|
169
|
+
cx="12"
|
|
170
|
+
cy="12"
|
|
171
|
+
r="10"
|
|
172
|
+
stroke="currentColor"
|
|
173
|
+
strokeWidth="4"
|
|
174
|
+
/>
|
|
175
|
+
<path
|
|
176
|
+
className="opacity-75"
|
|
177
|
+
fill="currentColor"
|
|
178
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
179
|
+
/>
|
|
180
|
+
</svg>
|
|
181
|
+
Starting Chat...
|
|
182
|
+
</span>
|
|
183
|
+
) : (
|
|
184
|
+
'Start Chat'
|
|
185
|
+
)}
|
|
186
|
+
</button>
|
|
187
|
+
|
|
188
|
+
{/* Privacy Notice */}
|
|
189
|
+
<p className="text-xs text-gray-500 dark:text-gray-400 text-center">
|
|
190
|
+
We respect your privacy. Your information will only be used to assist you.
|
|
191
|
+
</p>
|
|
192
|
+
</form>
|
|
193
|
+
|
|
194
|
+
{/* Footer */}
|
|
195
|
+
<div className="bg-gray-50 dark:bg-gray-950 px-4 py-2 text-center border-t border-gray-200 dark:border-gray-700">
|
|
196
|
+
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
197
|
+
Powered by <span className="font-semibold">Xcelsior Chat</span>
|
|
198
|
+
</p>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
interface TypingIndicatorProps {
|
|
2
|
+
isTyping: boolean;
|
|
3
|
+
userName?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function TypingIndicator({ isTyping, userName }: TypingIndicatorProps) {
|
|
7
|
+
if (!isTyping) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<div className="px-4 py-2 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
|
|
13
|
+
<div className="flex items-center gap-2">
|
|
14
|
+
<div className="flex gap-1">
|
|
15
|
+
<span className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" />
|
|
16
|
+
<span
|
|
17
|
+
className="w-2 h-2 bg-blue-500 rounded-full animate-bounce"
|
|
18
|
+
style={{ animationDelay: '0.1s' }}
|
|
19
|
+
/>
|
|
20
|
+
<span
|
|
21
|
+
className="w-2 h-2 bg-blue-500 rounded-full animate-bounce"
|
|
22
|
+
style={{ animationDelay: '0.2s' }}
|
|
23
|
+
/>
|
|
24
|
+
</div>
|
|
25
|
+
<span className="text-xs text-gray-600 dark:text-gray-400">
|
|
26
|
+
{userName ? `${userName} is typing...` : 'Someone is typing...'}
|
|
27
|
+
</span>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import type { IFileUploadConfig, IUploadedFile } from '../types';
|
|
4
|
+
|
|
5
|
+
export interface UseFileUploadReturn {
|
|
6
|
+
uploadFile: (file: File) => Promise<IUploadedFile | null>;
|
|
7
|
+
isUploading: boolean;
|
|
8
|
+
uploadProgress: number;
|
|
9
|
+
error: Error | null;
|
|
10
|
+
canUpload: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useFileUpload(apiKey: string, config?: IFileUploadConfig): UseFileUploadReturn {
|
|
14
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
15
|
+
const [uploadProgress, setUploadProgress] = useState(0);
|
|
16
|
+
const [error, setError] = useState<Error | null>(null);
|
|
17
|
+
|
|
18
|
+
const defaultConfig = {
|
|
19
|
+
maxFileSize: 10 * 1024 * 1024, // 10MB default
|
|
20
|
+
allowedTypes: [
|
|
21
|
+
'image/jpeg',
|
|
22
|
+
'image/jpg',
|
|
23
|
+
'image/png',
|
|
24
|
+
'image/gif',
|
|
25
|
+
'image/webp',
|
|
26
|
+
'application/pdf',
|
|
27
|
+
'application/msword',
|
|
28
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
29
|
+
'text/plain',
|
|
30
|
+
'text/csv',
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const finalConfig = { ...defaultConfig, ...config };
|
|
35
|
+
const canUpload = !!config?.uploadUrl;
|
|
36
|
+
|
|
37
|
+
const validateFile = (file: File): string | null => {
|
|
38
|
+
if (!finalConfig.allowedTypes.includes(file.type)) {
|
|
39
|
+
return `File type ${file.type} is not supported. Allowed types: ${finalConfig.allowedTypes.join(', ')}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (file.size > finalConfig.maxFileSize) {
|
|
43
|
+
return `File size ${(file.size / 1024 / 1024).toFixed(2)}MB exceeds the maximum allowed size of ${(finalConfig.maxFileSize / 1024 / 1024).toFixed(2)}MB`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const uploadFile = async (file: File): Promise<IUploadedFile | null> => {
|
|
50
|
+
if (!config?.uploadUrl) {
|
|
51
|
+
const err = new Error('File upload URL is not configured');
|
|
52
|
+
setError(err);
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const validationError = validateFile(file);
|
|
57
|
+
if (validationError) {
|
|
58
|
+
const err = new Error(validationError);
|
|
59
|
+
setError(err);
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
setIsUploading(true);
|
|
64
|
+
setUploadProgress(0);
|
|
65
|
+
setError(null);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
// Step 1: Get presigned upload URL from the API
|
|
69
|
+
const uploadUrlResponse = await axios.post(
|
|
70
|
+
config.uploadUrl,
|
|
71
|
+
{
|
|
72
|
+
fileName: file.name,
|
|
73
|
+
contentType: file.type,
|
|
74
|
+
fileSize: file.size,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
headers: {
|
|
78
|
+
'Content-Type': 'application/json',
|
|
79
|
+
'x-api-key': apiKey || '',
|
|
80
|
+
...config.headers,
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const { uploadUrl, attachmentUrl } =
|
|
86
|
+
uploadUrlResponse.data.data || uploadUrlResponse.data;
|
|
87
|
+
|
|
88
|
+
if (!uploadUrl || !attachmentUrl) {
|
|
89
|
+
throw new Error('Failed to get upload URL from server');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Step 2: Upload file directly to S3 using presigned URL
|
|
93
|
+
await axios.put(uploadUrl, file, {
|
|
94
|
+
headers: {
|
|
95
|
+
'Content-Type': file.type,
|
|
96
|
+
},
|
|
97
|
+
onUploadProgress: (progressEvent) => {
|
|
98
|
+
if (progressEvent.total) {
|
|
99
|
+
const progress = Math.round(
|
|
100
|
+
(progressEvent.loaded * 100) / progressEvent.total
|
|
101
|
+
);
|
|
102
|
+
setUploadProgress(progress);
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
url: attachmentUrl,
|
|
109
|
+
name: file.name,
|
|
110
|
+
size: file.size,
|
|
111
|
+
type: file.type,
|
|
112
|
+
markdown: file.type.startsWith('image/')
|
|
113
|
+
? ``
|
|
114
|
+
: `[${file.name}](${attachmentUrl})`,
|
|
115
|
+
};
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error('File upload failed:', err);
|
|
118
|
+
const error = err instanceof Error ? err : new Error('Upload failed');
|
|
119
|
+
setError(error);
|
|
120
|
+
throw error;
|
|
121
|
+
} finally {
|
|
122
|
+
setIsUploading(false);
|
|
123
|
+
setUploadProgress(0);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
uploadFile,
|
|
129
|
+
isUploading,
|
|
130
|
+
uploadProgress,
|
|
131
|
+
error,
|
|
132
|
+
canUpload,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import type { IMessage, IChatConfig } from '../types';
|
|
3
|
+
import type { UseWebSocketReturn } from './useWebSocket';
|
|
4
|
+
import { fetchMessages } from '../utils/api';
|
|
5
|
+
|
|
6
|
+
export interface UseMessagesReturn {
|
|
7
|
+
messages: IMessage[];
|
|
8
|
+
addMessage: (message: IMessage) => void;
|
|
9
|
+
updateMessageStatus: (messageId: string, status: IMessage['status']) => void;
|
|
10
|
+
clearMessages: () => void;
|
|
11
|
+
isLoading: boolean;
|
|
12
|
+
error: Error | null;
|
|
13
|
+
loadMore: () => Promise<void>;
|
|
14
|
+
hasMore: boolean;
|
|
15
|
+
isLoadingMore: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function useMessages(websocket: UseWebSocketReturn, config: IChatConfig): UseMessagesReturn {
|
|
19
|
+
const [messages, setMessages] = useState<IMessage[]>([]);
|
|
20
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
21
|
+
const [error, setError] = useState<Error | null>(null);
|
|
22
|
+
const [nextPageToken, setNextPageToken] = useState<string | undefined>(undefined);
|
|
23
|
+
const [hasMore, setHasMore] = useState(true);
|
|
24
|
+
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
25
|
+
|
|
26
|
+
// Extract stable references from config
|
|
27
|
+
const { httpApiUrl, conversationId, headers, onError, toast } = config;
|
|
28
|
+
|
|
29
|
+
const headersWithApiKey = useMemo(
|
|
30
|
+
() => ({
|
|
31
|
+
...headers,
|
|
32
|
+
'x-api-key': config.apiKey,
|
|
33
|
+
}),
|
|
34
|
+
[headers, config.apiKey]
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// Fetch existing messages when conversationId changes
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const loadMessages = async () => {
|
|
40
|
+
// Only fetch if we have httpApiUrl and conversationId
|
|
41
|
+
if (!httpApiUrl || !conversationId) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
setIsLoading(true);
|
|
46
|
+
setError(null);
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const result = await fetchMessages(
|
|
50
|
+
httpApiUrl,
|
|
51
|
+
{ conversationId, limit: 20 },
|
|
52
|
+
headersWithApiKey
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
setMessages(result.data);
|
|
56
|
+
setNextPageToken(result.nextPageToken);
|
|
57
|
+
setHasMore(!!result.nextPageToken);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
const error = err instanceof Error ? err : new Error('Failed to load messages');
|
|
60
|
+
setError(error);
|
|
61
|
+
onError?.(error);
|
|
62
|
+
toast?.error('Failed to load existing messages');
|
|
63
|
+
} finally {
|
|
64
|
+
setIsLoading(false);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
loadMessages();
|
|
69
|
+
// Only re-run when conversationId or httpApiUrl changes
|
|
70
|
+
}, [conversationId, httpApiUrl, headersWithApiKey, onError, toast]);
|
|
71
|
+
|
|
72
|
+
// Extract onMessageReceived callback
|
|
73
|
+
const { onMessageReceived } = config;
|
|
74
|
+
|
|
75
|
+
// Listen for incoming messages from WebSocket
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (websocket.lastMessage?.type === 'message' && websocket.lastMessage.data) {
|
|
78
|
+
const newMessage: IMessage = websocket.lastMessage.data;
|
|
79
|
+
|
|
80
|
+
if (conversationId && newMessage.conversationId !== conversationId) {
|
|
81
|
+
// Ignore messages for other conversations
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
setMessages((prev) => {
|
|
85
|
+
// Avoid duplicates
|
|
86
|
+
if (prev.some((msg) => msg.id === newMessage.id)) {
|
|
87
|
+
return prev;
|
|
88
|
+
}
|
|
89
|
+
return [...prev, newMessage];
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Notify parent component about new message
|
|
93
|
+
onMessageReceived?.(newMessage);
|
|
94
|
+
}
|
|
95
|
+
}, [websocket.lastMessage, onMessageReceived, conversationId]);
|
|
96
|
+
|
|
97
|
+
const addMessage = useCallback((message: IMessage) => {
|
|
98
|
+
setMessages((prev) => {
|
|
99
|
+
// Avoid duplicates
|
|
100
|
+
if (prev.some((msg) => msg.id === message.id)) {
|
|
101
|
+
return prev;
|
|
102
|
+
}
|
|
103
|
+
return [...prev, message];
|
|
104
|
+
});
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
const updateMessageStatus = useCallback((messageId: string, status: IMessage['status']) => {
|
|
108
|
+
setMessages((prev) => prev.map((msg) => (msg.id === messageId ? { ...msg, status } : msg)));
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
const clearMessages = useCallback(() => {
|
|
112
|
+
setMessages([]);
|
|
113
|
+
}, []);
|
|
114
|
+
|
|
115
|
+
const loadMore = useCallback(async () => {
|
|
116
|
+
if (!hasMore || isLoadingMore || !httpApiUrl || !conversationId || !nextPageToken) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
setIsLoadingMore(true);
|
|
121
|
+
setError(null);
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const result = await fetchMessages(
|
|
125
|
+
httpApiUrl,
|
|
126
|
+
{
|
|
127
|
+
conversationId,
|
|
128
|
+
limit: 20,
|
|
129
|
+
pageToken: nextPageToken,
|
|
130
|
+
},
|
|
131
|
+
headersWithApiKey
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
setMessages((prev) => [...result.data, ...prev]);
|
|
135
|
+
setNextPageToken(result.nextPageToken);
|
|
136
|
+
setHasMore(!!result.nextPageToken);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
const error = err instanceof Error ? err : new Error('Failed to load more messages');
|
|
139
|
+
setError(error);
|
|
140
|
+
onError?.(error);
|
|
141
|
+
} finally {
|
|
142
|
+
setIsLoadingMore(false);
|
|
143
|
+
}
|
|
144
|
+
}, [
|
|
145
|
+
hasMore,
|
|
146
|
+
isLoadingMore,
|
|
147
|
+
httpApiUrl,
|
|
148
|
+
conversationId,
|
|
149
|
+
nextPageToken,
|
|
150
|
+
headersWithApiKey,
|
|
151
|
+
onError,
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
messages,
|
|
156
|
+
addMessage,
|
|
157
|
+
updateMessageStatus,
|
|
158
|
+
clearMessages,
|
|
159
|
+
isLoading,
|
|
160
|
+
error,
|
|
161
|
+
loadMore,
|
|
162
|
+
hasMore,
|
|
163
|
+
isLoadingMore,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import type { UseWebSocketReturn } from './useWebSocket';
|
|
3
|
+
|
|
4
|
+
export interface UseTypingIndicatorReturn {
|
|
5
|
+
isTyping: boolean;
|
|
6
|
+
typingUsers: string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function useTypingIndicator(websocket: UseWebSocketReturn): UseTypingIndicatorReturn {
|
|
10
|
+
const [typingUsers, setTypingUsers] = useState<string[]>([]);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (websocket.lastMessage?.type === 'typing' && websocket.lastMessage.data) {
|
|
14
|
+
const { userId, isTyping } = websocket.lastMessage.data;
|
|
15
|
+
|
|
16
|
+
if (isTyping) {
|
|
17
|
+
setTypingUsers((prev) => {
|
|
18
|
+
if (!prev.includes(userId)) {
|
|
19
|
+
return [...prev, userId];
|
|
20
|
+
}
|
|
21
|
+
return prev;
|
|
22
|
+
});
|
|
23
|
+
} else {
|
|
24
|
+
setTypingUsers((prev) => prev.filter((id) => id !== userId));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}, [websocket.lastMessage]);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
isTyping: typingUsers.length > 0,
|
|
31
|
+
typingUsers,
|
|
32
|
+
};
|
|
33
|
+
}
|