@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,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
+ }