@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,232 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { MessageItem } from './MessageItem';
|
|
3
|
+
import type { IMessage, IUser } from '../types';
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof MessageItem> = {
|
|
6
|
+
title: 'Components/MessageItem',
|
|
7
|
+
component: MessageItem,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: 'padded',
|
|
10
|
+
},
|
|
11
|
+
tags: ['autodocs'],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default meta;
|
|
15
|
+
type Story = StoryObj<typeof MessageItem>;
|
|
16
|
+
|
|
17
|
+
// Mock current user
|
|
18
|
+
const currentUser: IUser = {
|
|
19
|
+
name: 'John Doe',
|
|
20
|
+
email: 'john@example.com',
|
|
21
|
+
type: 'customer',
|
|
22
|
+
status: 'online',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Base message template
|
|
26
|
+
const baseMessage: IMessage = {
|
|
27
|
+
id: '1',
|
|
28
|
+
conversationId: 'conv-1',
|
|
29
|
+
senderId: 'user@example.com',
|
|
30
|
+
senderType: 'customer',
|
|
31
|
+
content: 'Hello, I need help with my account.',
|
|
32
|
+
messageType: 'text',
|
|
33
|
+
createdAt: new Date().toISOString(),
|
|
34
|
+
status: 'sent',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Customer message (own message)
|
|
39
|
+
*/
|
|
40
|
+
export const CustomerMessage: Story = {
|
|
41
|
+
args: {
|
|
42
|
+
message: {
|
|
43
|
+
...baseMessage,
|
|
44
|
+
senderId: currentUser.email,
|
|
45
|
+
content: 'Hello, I need help with my account.',
|
|
46
|
+
},
|
|
47
|
+
currentUser,
|
|
48
|
+
showAvatar: true,
|
|
49
|
+
showTimestamp: true,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Human agent message
|
|
55
|
+
*/
|
|
56
|
+
export const AgentMessage: Story = {
|
|
57
|
+
args: {
|
|
58
|
+
message: {
|
|
59
|
+
...baseMessage,
|
|
60
|
+
senderId: 'agent@example.com',
|
|
61
|
+
senderType: 'agent',
|
|
62
|
+
content:
|
|
63
|
+
"Hi! I'd be happy to help you with your account. What specific issue are you experiencing?",
|
|
64
|
+
},
|
|
65
|
+
currentUser,
|
|
66
|
+
showAvatar: true,
|
|
67
|
+
showTimestamp: true,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* AI-generated message with robot icon
|
|
73
|
+
*/
|
|
74
|
+
export const AIMessage: Story = {
|
|
75
|
+
args: {
|
|
76
|
+
message: {
|
|
77
|
+
...baseMessage,
|
|
78
|
+
senderId: 'ai-assistant',
|
|
79
|
+
senderType: 'agent',
|
|
80
|
+
content:
|
|
81
|
+
"Hello! I'm an AI assistant. Based on our knowledge base, here are some common solutions for account issues:\n\n1. Reset your password\n2. Verify your email\n3. Contact support",
|
|
82
|
+
metadata: {
|
|
83
|
+
isAI: true,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
currentUser,
|
|
87
|
+
showAvatar: true,
|
|
88
|
+
showTimestamp: true,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* AI message with markdown formatting
|
|
94
|
+
*/
|
|
95
|
+
export const AIMessageWithMarkdown: Story = {
|
|
96
|
+
args: {
|
|
97
|
+
message: {
|
|
98
|
+
...baseMessage,
|
|
99
|
+
senderId: 'ai-assistant',
|
|
100
|
+
senderType: 'agent',
|
|
101
|
+
content: `## Getting Started
|
|
102
|
+
|
|
103
|
+
Here's how you can reset your password:
|
|
104
|
+
|
|
105
|
+
1. Go to the login page
|
|
106
|
+
2. Click on **"Forgot Password"**
|
|
107
|
+
3. Enter your email address
|
|
108
|
+
4. Check your inbox for the reset link
|
|
109
|
+
|
|
110
|
+
*Note: The link expires in 24 hours.*
|
|
111
|
+
|
|
112
|
+
For more help, visit our [support page](https://example.com/support).`,
|
|
113
|
+
metadata: {
|
|
114
|
+
isAI: true,
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
currentUser,
|
|
118
|
+
showAvatar: true,
|
|
119
|
+
showTimestamp: true,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* System message
|
|
125
|
+
*/
|
|
126
|
+
export const SystemMessage: Story = {
|
|
127
|
+
args: {
|
|
128
|
+
message: {
|
|
129
|
+
...baseMessage,
|
|
130
|
+
senderId: 'system',
|
|
131
|
+
senderType: 'system',
|
|
132
|
+
content: 'Agent John joined the conversation',
|
|
133
|
+
},
|
|
134
|
+
currentUser,
|
|
135
|
+
showAvatar: true,
|
|
136
|
+
showTimestamp: true,
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Message with image
|
|
142
|
+
*/
|
|
143
|
+
export const ImageMessage: Story = {
|
|
144
|
+
args: {
|
|
145
|
+
message: {
|
|
146
|
+
...baseMessage,
|
|
147
|
+
content: 'https://picsum.photos/400/300',
|
|
148
|
+
messageType: 'image',
|
|
149
|
+
},
|
|
150
|
+
currentUser,
|
|
151
|
+
showAvatar: true,
|
|
152
|
+
showTimestamp: true,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Message with file attachment
|
|
158
|
+
*/
|
|
159
|
+
export const FileMessage: Story = {
|
|
160
|
+
args: {
|
|
161
|
+
message: {
|
|
162
|
+
...baseMessage,
|
|
163
|
+
content: 'https://example.com/document.pdf',
|
|
164
|
+
messageType: 'file',
|
|
165
|
+
metadata: {
|
|
166
|
+
fileName: 'Technical Documentation.pdf',
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
currentUser,
|
|
170
|
+
showAvatar: true,
|
|
171
|
+
showTimestamp: true,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Conversation showing different message types
|
|
177
|
+
*/
|
|
178
|
+
export const ConversationExample: Story = {
|
|
179
|
+
render: (args) => (
|
|
180
|
+
<div className="flex flex-col gap-2 max-w-2xl">
|
|
181
|
+
<MessageItem
|
|
182
|
+
{...args}
|
|
183
|
+
message={{
|
|
184
|
+
...baseMessage,
|
|
185
|
+
id: '1',
|
|
186
|
+
senderId: currentUser.email,
|
|
187
|
+
content: 'Hi, I need help with resetting my password.',
|
|
188
|
+
}}
|
|
189
|
+
/>
|
|
190
|
+
<MessageItem
|
|
191
|
+
{...args}
|
|
192
|
+
message={{
|
|
193
|
+
...baseMessage,
|
|
194
|
+
id: '2',
|
|
195
|
+
senderId: 'ai-assistant',
|
|
196
|
+
senderType: 'agent',
|
|
197
|
+
content:
|
|
198
|
+
'I can help you with that! Here are the steps to reset your password:\n\n1. Click on "Forgot Password" on the login page\n2. Enter your email address\n3. Check your inbox for a reset link\n\nThe link will expire in 24 hours. Would you like me to send you the reset link now?',
|
|
199
|
+
metadata: { isAI: true },
|
|
200
|
+
createdAt: new Date(Date.now() - 60000).toISOString(),
|
|
201
|
+
}}
|
|
202
|
+
/>
|
|
203
|
+
<MessageItem
|
|
204
|
+
{...args}
|
|
205
|
+
message={{
|
|
206
|
+
...baseMessage,
|
|
207
|
+
id: '3',
|
|
208
|
+
senderId: currentUser.email,
|
|
209
|
+
content: 'Yes please, send me the reset link.',
|
|
210
|
+
createdAt: new Date(Date.now() - 30000).toISOString(),
|
|
211
|
+
}}
|
|
212
|
+
/>
|
|
213
|
+
<MessageItem
|
|
214
|
+
{...args}
|
|
215
|
+
message={{
|
|
216
|
+
...baseMessage,
|
|
217
|
+
id: '4',
|
|
218
|
+
senderId: 'agent@example.com',
|
|
219
|
+
senderType: 'agent',
|
|
220
|
+
content:
|
|
221
|
+
"I've sent the password reset link to your registered email address. Please check your inbox and spam folder. Let me know if you need any further assistance!",
|
|
222
|
+
createdAt: new Date().toISOString(),
|
|
223
|
+
}}
|
|
224
|
+
/>
|
|
225
|
+
</div>
|
|
226
|
+
),
|
|
227
|
+
args: {
|
|
228
|
+
currentUser,
|
|
229
|
+
showAvatar: true,
|
|
230
|
+
showTimestamp: true,
|
|
231
|
+
},
|
|
232
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { formatDistanceToNow } from 'date-fns';
|
|
2
|
+
import ReactMarkdown from 'react-markdown';
|
|
3
|
+
import type { IMessage, IUser } from '../types';
|
|
4
|
+
|
|
5
|
+
interface MessageItemProps {
|
|
6
|
+
message: IMessage;
|
|
7
|
+
currentUser: IUser;
|
|
8
|
+
showAvatar?: boolean;
|
|
9
|
+
showTimestamp?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function MessageItem({
|
|
13
|
+
message,
|
|
14
|
+
currentUser,
|
|
15
|
+
showAvatar = true,
|
|
16
|
+
showTimestamp = true,
|
|
17
|
+
}: MessageItemProps) {
|
|
18
|
+
const isOwnMessage = message.senderType === currentUser.type;
|
|
19
|
+
const isSystemMessage = message.senderType === 'system';
|
|
20
|
+
const isAIMessage = message.metadata?.isAI === true;
|
|
21
|
+
|
|
22
|
+
// System messages are centered and styled differently
|
|
23
|
+
if (isSystemMessage) {
|
|
24
|
+
return (
|
|
25
|
+
<div className="flex justify-center my-4">
|
|
26
|
+
<div className="px-4 py-2 bg-gray-100 dark:bg-gray-800 rounded-full">
|
|
27
|
+
<p className="text-xs text-gray-600 dark:text-gray-400">{message.content}</p>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Determine the avatar icon to display
|
|
34
|
+
const getAvatarIcon = () => {
|
|
35
|
+
if (isAIMessage) {
|
|
36
|
+
return '🤖'; // Robot icon for AI messages
|
|
37
|
+
}
|
|
38
|
+
if (message.senderType === 'agent') {
|
|
39
|
+
return '🎧'; // Headset icon for human agents
|
|
40
|
+
}
|
|
41
|
+
return '👤'; // User icon for customers
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className={`flex gap-2 mb-4 ${!isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}>
|
|
46
|
+
{showAvatar && (
|
|
47
|
+
<div className="flex-shrink-0">
|
|
48
|
+
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-sm font-medium">
|
|
49
|
+
{getAvatarIcon()}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
)}
|
|
53
|
+
|
|
54
|
+
<div
|
|
55
|
+
className={`flex flex-col max-w-[70%] ${!isOwnMessage ? 'items-end' : 'items-start'}`}
|
|
56
|
+
>
|
|
57
|
+
<div
|
|
58
|
+
className={`rounded-2xl px-4 py-2 ${
|
|
59
|
+
isOwnMessage
|
|
60
|
+
? 'bg-blue-600 text-white'
|
|
61
|
+
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
|
62
|
+
}`}
|
|
63
|
+
>
|
|
64
|
+
{message.messageType === 'text' && (
|
|
65
|
+
<div className="prose prose-sm dark:prose-invert max-w-none">
|
|
66
|
+
<ReactMarkdown
|
|
67
|
+
components={{
|
|
68
|
+
p: ({ children }) => <p className="mb-0">{children}</p>,
|
|
69
|
+
img: ({ src, alt, ...props }) => (
|
|
70
|
+
<img
|
|
71
|
+
{...props}
|
|
72
|
+
src={src}
|
|
73
|
+
alt={alt}
|
|
74
|
+
className="max-w-full h-auto rounded-lg shadow-sm my-2"
|
|
75
|
+
loading="lazy"
|
|
76
|
+
/>
|
|
77
|
+
),
|
|
78
|
+
a: ({ href, children, ...props }) => (
|
|
79
|
+
<a
|
|
80
|
+
{...props}
|
|
81
|
+
href={href}
|
|
82
|
+
target="_blank"
|
|
83
|
+
rel="noopener noreferrer"
|
|
84
|
+
className={`${isOwnMessage ? 'text-blue-200 hover:text-blue-100' : 'text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300'} underline`}
|
|
85
|
+
>
|
|
86
|
+
{children}
|
|
87
|
+
</a>
|
|
88
|
+
),
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
{message.content}
|
|
92
|
+
</ReactMarkdown>
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
{message.messageType === 'image' && (
|
|
96
|
+
<div>
|
|
97
|
+
<img
|
|
98
|
+
src={message.content}
|
|
99
|
+
alt="Attachment"
|
|
100
|
+
className="max-w-full h-auto rounded-lg"
|
|
101
|
+
loading="lazy"
|
|
102
|
+
/>
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
{message.messageType === 'file' && (
|
|
106
|
+
<div className="flex items-center gap-2">
|
|
107
|
+
<span className="text-2xl">📎</span>
|
|
108
|
+
<a
|
|
109
|
+
href={message.content}
|
|
110
|
+
target="_blank"
|
|
111
|
+
rel="noopener noreferrer"
|
|
112
|
+
className={`${isOwnMessage ? 'text-blue-200 hover:text-blue-100' : 'text-blue-600 hover:text-blue-700 dark:text-blue-400'} underline`}
|
|
113
|
+
>
|
|
114
|
+
{(message.metadata?.fileName as any) || 'Download file'}
|
|
115
|
+
</a>
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
{showTimestamp && (
|
|
121
|
+
<div
|
|
122
|
+
className={`flex items-center gap-2 mt-1 px-2 ${isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}
|
|
123
|
+
>
|
|
124
|
+
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
125
|
+
{formatDistanceToNow(new Date(message.createdAt), {
|
|
126
|
+
addSuffix: true,
|
|
127
|
+
})}
|
|
128
|
+
</span>
|
|
129
|
+
{isOwnMessage && message.status && (
|
|
130
|
+
<span className="text-xs">
|
|
131
|
+
{message.status === 'sent' && '✓'}
|
|
132
|
+
{message.status === 'delivered' && '✓✓'}
|
|
133
|
+
{message.status === 'read' && (
|
|
134
|
+
<span className="text-blue-600">✓✓</span>
|
|
135
|
+
)}
|
|
136
|
+
</span>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { useEffect, useRef, useCallback } from 'react';
|
|
2
|
+
import { Spinner } from '@xcelsior/design-system';
|
|
3
|
+
import { MessageItem } from './MessageItem';
|
|
4
|
+
import type { IMessage, IUser } from '../types';
|
|
5
|
+
|
|
6
|
+
interface MessageListProps {
|
|
7
|
+
messages: IMessage[];
|
|
8
|
+
currentUser: IUser;
|
|
9
|
+
isLoading?: boolean;
|
|
10
|
+
isTyping?: boolean;
|
|
11
|
+
typingUser?: string;
|
|
12
|
+
autoScroll?: boolean;
|
|
13
|
+
onLoadMore?: () => void;
|
|
14
|
+
hasMore?: boolean;
|
|
15
|
+
isLoadingMore?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function MessageList({
|
|
19
|
+
messages,
|
|
20
|
+
currentUser,
|
|
21
|
+
isLoading = false,
|
|
22
|
+
isTyping = false,
|
|
23
|
+
typingUser,
|
|
24
|
+
autoScroll = true,
|
|
25
|
+
onLoadMore,
|
|
26
|
+
hasMore = false,
|
|
27
|
+
isLoadingMore = false,
|
|
28
|
+
}: MessageListProps) {
|
|
29
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
30
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
31
|
+
const prevLengthRef = useRef(messages.length);
|
|
32
|
+
const loadMoreTriggerRef = useRef<HTMLDivElement>(null);
|
|
33
|
+
const prevScrollHeightRef = useRef(0);
|
|
34
|
+
const hasInitialScrolledRef = useRef(false);
|
|
35
|
+
const isUserScrollingRef = useRef(false);
|
|
36
|
+
|
|
37
|
+
// Auto-scroll to bottom when new messages arrive
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (autoScroll && messagesEndRef.current) {
|
|
40
|
+
// Only auto-scroll if we're adding new messages (not loading older ones)
|
|
41
|
+
// Skip auto-scroll if we're loading more (older messages)
|
|
42
|
+
if (messages.length > prevLengthRef.current && !isLoadingMore) {
|
|
43
|
+
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
|
44
|
+
}
|
|
45
|
+
prevLengthRef.current = messages.length;
|
|
46
|
+
}
|
|
47
|
+
}, [messages.length, autoScroll, isLoadingMore]);
|
|
48
|
+
|
|
49
|
+
// Initial scroll to bottom when messages first load
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (
|
|
52
|
+
messages.length > 0 &&
|
|
53
|
+
messagesEndRef.current &&
|
|
54
|
+
!isLoading &&
|
|
55
|
+
!hasInitialScrolledRef.current
|
|
56
|
+
) {
|
|
57
|
+
// Use setTimeout to ensure DOM is fully rendered
|
|
58
|
+
setTimeout(() => {
|
|
59
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' });
|
|
60
|
+
// Enable user scrolling after initial scroll completes
|
|
61
|
+
setTimeout(() => {
|
|
62
|
+
isUserScrollingRef.current = true;
|
|
63
|
+
}, 200);
|
|
64
|
+
}, 100);
|
|
65
|
+
hasInitialScrolledRef.current = true;
|
|
66
|
+
} else if (!isLoading && messages.length === 0 && !hasInitialScrolledRef.current) {
|
|
67
|
+
// If there are no messages, enable user scrolling immediately
|
|
68
|
+
isUserScrollingRef.current = true;
|
|
69
|
+
hasInitialScrolledRef.current = true;
|
|
70
|
+
}
|
|
71
|
+
}, [isLoading, messages.length]);
|
|
72
|
+
|
|
73
|
+
// Restore scroll position after loading more messages
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (isLoadingMore) {
|
|
76
|
+
prevScrollHeightRef.current = containerRef.current?.scrollHeight || 0;
|
|
77
|
+
} else if (prevScrollHeightRef.current > 0 && containerRef.current) {
|
|
78
|
+
const newScrollHeight = containerRef.current.scrollHeight;
|
|
79
|
+
const scrollDiff = newScrollHeight - prevScrollHeightRef.current;
|
|
80
|
+
containerRef.current.scrollTop = scrollDiff;
|
|
81
|
+
prevScrollHeightRef.current = 0;
|
|
82
|
+
}
|
|
83
|
+
}, [isLoadingMore]);
|
|
84
|
+
|
|
85
|
+
// Infinite scroll: detect when user scrolls near the top
|
|
86
|
+
const handleScroll = useCallback(() => {
|
|
87
|
+
if (!containerRef.current || !onLoadMore || !hasMore || isLoadingMore) return;
|
|
88
|
+
|
|
89
|
+
// Only trigger load more if user has actually scrolled (prevents automatic trigger during initial load)
|
|
90
|
+
if (!isUserScrollingRef.current) return;
|
|
91
|
+
|
|
92
|
+
const { scrollTop } = containerRef.current;
|
|
93
|
+
// Trigger load more when user scrolls within 100px of the top
|
|
94
|
+
if (scrollTop < 100) {
|
|
95
|
+
onLoadMore();
|
|
96
|
+
}
|
|
97
|
+
}, [onLoadMore, hasMore, isLoadingMore]);
|
|
98
|
+
|
|
99
|
+
// Set up scroll event listener
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const container = containerRef.current;
|
|
102
|
+
if (!container) return;
|
|
103
|
+
|
|
104
|
+
container.addEventListener('scroll', handleScroll);
|
|
105
|
+
return () => container.removeEventListener('scroll', handleScroll);
|
|
106
|
+
}, [handleScroll]);
|
|
107
|
+
|
|
108
|
+
if (isLoading) {
|
|
109
|
+
return (
|
|
110
|
+
<div className="flex items-center justify-center h-full">
|
|
111
|
+
<Spinner size="lg" />
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (messages.length === 0) {
|
|
117
|
+
return (
|
|
118
|
+
<div className="flex flex-col items-center justify-center h-full text-center p-8">
|
|
119
|
+
<div className="text-6xl mb-4">💬</div>
|
|
120
|
+
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
|
121
|
+
No messages yet
|
|
122
|
+
</h3>
|
|
123
|
+
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
124
|
+
Start the conversation by sending a message below
|
|
125
|
+
</p>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div
|
|
132
|
+
ref={containerRef}
|
|
133
|
+
className="flex-1 overflow-y-auto p-4 space-y-2"
|
|
134
|
+
style={{ scrollBehavior: 'smooth' }}
|
|
135
|
+
>
|
|
136
|
+
{/* Loading indicator at the top for infinite scroll */}
|
|
137
|
+
{isLoadingMore && (
|
|
138
|
+
<div className="flex justify-center py-4">
|
|
139
|
+
<Spinner size="sm" />
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
{/* Load more trigger point */}
|
|
144
|
+
<div ref={loadMoreTriggerRef} />
|
|
145
|
+
|
|
146
|
+
{messages.map((message) => (
|
|
147
|
+
<MessageItem
|
|
148
|
+
key={message.id}
|
|
149
|
+
message={message}
|
|
150
|
+
currentUser={currentUser}
|
|
151
|
+
showAvatar={true}
|
|
152
|
+
showTimestamp={true}
|
|
153
|
+
/>
|
|
154
|
+
))}
|
|
155
|
+
|
|
156
|
+
{isTyping && (
|
|
157
|
+
<div className="flex gap-2 mb-4">
|
|
158
|
+
<div className="flex-shrink-0">
|
|
159
|
+
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-sm font-medium">
|
|
160
|
+
🎧
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
<div className="flex flex-col items-start">
|
|
164
|
+
<div className="rounded-2xl px-4 py-3 bg-gray-100 dark:bg-gray-800">
|
|
165
|
+
<div className="flex gap-1">
|
|
166
|
+
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
|
|
167
|
+
<span
|
|
168
|
+
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
|
|
169
|
+
style={{ animationDelay: '0.1s' }}
|
|
170
|
+
/>
|
|
171
|
+
<span
|
|
172
|
+
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
|
|
173
|
+
style={{ animationDelay: '0.2s' }}
|
|
174
|
+
/>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
{typingUser && (
|
|
178
|
+
<span className="text-xs text-gray-500 dark:text-gray-400 mt-1 px-2">
|
|
179
|
+
{typingUser} is typing...
|
|
180
|
+
</span>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
<div ref={messagesEndRef} />
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|