@xcelsior/ui-chat 1.0.8 → 2.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/dist/index.d.mts +69 -69
- package/dist/index.d.ts +69 -69
- package/dist/index.js +2458 -627
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2457 -628
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -5
- package/src/components/BrandIcons.stories.tsx +95 -0
- package/src/components/BrandIcons.tsx +84 -0
- package/src/components/Chat.stories.tsx +149 -16
- package/src/components/Chat.tsx +116 -96
- package/src/components/ChatHeader.tsx +124 -69
- package/src/components/ChatInput.tsx +253 -104
- package/src/components/ChatWidget.tsx +209 -63
- package/src/components/ConversationRating.stories.tsx +33 -0
- package/src/components/ConversationRating.tsx +156 -0
- package/src/components/MarkdownMessage.tsx +202 -0
- package/src/components/MessageItem.stories.tsx +253 -55
- package/src/components/MessageItem.tsx +223 -60
- package/src/components/MessageList.tsx +164 -35
- package/src/components/PreChatForm.tsx +236 -96
- package/src/components/ThinkingIndicator.tsx +370 -0
- package/src/components/TypingIndicator.tsx +27 -11
- package/src/hooks/useDraggablePosition.ts +91 -0
- package/src/hooks/useMessages.ts +12 -13
- package/src/hooks/useResizableWidget.ts +324 -0
- package/src/index.tsx +5 -0
- package/src/types.ts +51 -5
- package/src/utils/markdown-styles.ts +140 -0
- package/storybook-static/assets/BrandIcons-Cjy5INAp.js +4 -0
- package/storybook-static/assets/BrandIcons.stories-BeVC6svr.js +64 -0
- package/storybook-static/assets/Chat.stories-J_Yp51wU.js +803 -0
- package/storybook-static/assets/Color-YHDXOIA2-BMnd3YrF.js +1 -0
- package/storybook-static/assets/ConversationRating.stories-B5_QddHN.js +12 -0
- package/storybook-static/assets/DocsRenderer-CFRXHY34-i_W8iCu9.js +575 -0
- package/storybook-static/assets/MessageItem-DAaKZ9s9.js +14 -0
- package/storybook-static/assets/MessageItem.stories-Ckr1_scc.js +255 -0
- package/storybook-static/assets/ToastContext-Bty1K7ya.js +1 -0
- package/storybook-static/assets/chunk-XP5HYGXS-BpfKkqn7.js +1 -0
- package/storybook-static/assets/en-US-BukEqXxE.js +1 -0
- package/storybook-static/assets/entry-preview-docs-DHohToDm.js +46 -0
- package/storybook-static/assets/entry-preview-oDnntGcx.js +2 -0
- package/storybook-static/assets/iframe-CGBtu2Se.js +211 -0
- package/storybook-static/assets/index--qcDGAq6.js +1 -0
- package/storybook-static/assets/index-BLHw34Di.js +24 -0
- package/storybook-static/assets/index-B_4m48Mv.js +1 -0
- package/storybook-static/assets/index-DgH-xKnr.js +11 -0
- package/storybook-static/assets/index-DrFu-skq.js +6 -0
- package/storybook-static/assets/index-DrdPSA1J.js +240 -0
- package/storybook-static/assets/index-jvNEZhzf.js +1 -0
- package/storybook-static/assets/index-yBjzXJbu.js +9 -0
- package/storybook-static/assets/jsx-runtime-Cf8x2fCZ.js +9 -0
- package/storybook-static/assets/preview-B8lJiyuQ.js +34 -0
- package/storybook-static/assets/preview-BBWR9nbA.js +1 -0
- package/storybook-static/assets/preview-BRpahs9B.js +2 -0
- package/storybook-static/assets/preview-BWzBA1C2.js +396 -0
- package/storybook-static/assets/preview-CvbIS5ZJ.js +1 -0
- package/storybook-static/assets/preview-DD_OYowb.js +1 -0
- package/storybook-static/assets/preview-DGUiP6tS.js +7 -0
- package/storybook-static/assets/preview-DHQbi4pV.js +1 -0
- package/storybook-static/assets/preview-DUOvJmsz.js +1 -0
- package/storybook-static/assets/preview-DcGwT3kv.css +1 -0
- package/storybook-static/assets/preview-DwI0w3cI.js +1 -0
- package/storybook-static/assets/react-18-CALspjOX.js +1 -0
- package/storybook-static/assets/test-utils-BE0XkMtV.js +9 -0
- package/storybook-static/favicon.svg +1 -0
- package/storybook-static/iframe.html +666 -0
- package/storybook-static/index.html +177 -0
- package/storybook-static/index.json +1 -0
- package/storybook-static/nunito-sans-bold-italic.woff2 +0 -0
- package/storybook-static/nunito-sans-bold.woff2 +0 -0
- package/storybook-static/nunito-sans-italic.woff2 +0 -0
- package/storybook-static/nunito-sans-regular.woff2 +0 -0
- package/storybook-static/project.json +1 -0
- package/storybook-static/sb-addons/essentials-actions-3/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/essentials-backgrounds-5/manager-bundle.js +12 -0
- package/storybook-static/sb-addons/essentials-controls-2/manager-bundle.js +405 -0
- package/storybook-static/sb-addons/essentials-docs-4/manager-bundle.js +245 -0
- package/storybook-static/sb-addons/essentials-measure-8/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/essentials-outline-9/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/essentials-toolbars-7/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/essentials-viewport-6/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/interactions-10/manager-bundle.js +222 -0
- package/storybook-static/sb-addons/links-1/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/storybook-core-core-server-presets-0/common-manager-bundle.js +3 -0
- package/storybook-static/sb-common-assets/favicon.svg +1 -0
- package/storybook-static/sb-common-assets/nunito-sans-bold-italic.woff2 +0 -0
- package/storybook-static/sb-common-assets/nunito-sans-bold.woff2 +0 -0
- package/storybook-static/sb-common-assets/nunito-sans-italic.woff2 +0 -0
- package/storybook-static/sb-common-assets/nunito-sans-regular.woff2 +0 -0
- package/storybook-static/sb-manager/globals-module-info.js +1052 -0
- package/storybook-static/sb-manager/globals-runtime.js +42127 -0
- package/storybook-static/sb-manager/globals.js +48 -0
- package/storybook-static/sb-manager/runtime.js +12048 -0
- package/.turbo/turbo-build.log +0 -22
- package/.turbo/turbo-lint.log +0 -5
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { formatDistanceToNow } from 'date-fns';
|
|
2
|
-
import
|
|
3
|
-
import
|
|
2
|
+
import type { IMessage, IUser, IChatTheme } from '../types';
|
|
3
|
+
import { XcelsiorAvatar } from './BrandIcons';
|
|
4
|
+
import { MarkdownMessage } from './MarkdownMessage';
|
|
4
5
|
|
|
5
6
|
interface MessageItemProps {
|
|
6
7
|
message: IMessage;
|
|
7
8
|
currentUser: IUser;
|
|
8
9
|
showAvatar?: boolean;
|
|
9
10
|
showTimestamp?: boolean;
|
|
11
|
+
theme?: IChatTheme;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export function MessageItem({
|
|
@@ -14,81 +16,171 @@ export function MessageItem({
|
|
|
14
16
|
currentUser,
|
|
15
17
|
showAvatar = true,
|
|
16
18
|
showTimestamp = true,
|
|
19
|
+
theme,
|
|
17
20
|
}: MessageItemProps) {
|
|
18
21
|
const isOwnMessage = message.senderType === currentUser.type;
|
|
19
22
|
const isSystemMessage = message.senderType === 'system';
|
|
20
23
|
const isAIMessage = message.metadata?.isAI === true;
|
|
24
|
+
const isBotMessage = message.senderType === 'bot';
|
|
21
25
|
|
|
22
|
-
|
|
26
|
+
const bgColor = theme?.background || '#00001a';
|
|
27
|
+
const isLightTheme = (() => {
|
|
28
|
+
if (!bgColor.startsWith('#')) return false;
|
|
29
|
+
const hex = bgColor.replace('#', '');
|
|
30
|
+
const r = parseInt(hex.substring(0, 2), 16);
|
|
31
|
+
const g = parseInt(hex.substring(2, 4), 16);
|
|
32
|
+
const b = parseInt(hex.substring(4, 6), 16);
|
|
33
|
+
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.5;
|
|
34
|
+
})();
|
|
35
|
+
|
|
36
|
+
const primaryColor = theme?.primary || '#337eff';
|
|
37
|
+
const primaryStrong = theme?.primaryStrong || '#005eff';
|
|
38
|
+
const textColor = theme?.text || (isLightTheme ? '#1a1a2e' : '#f7f7f8');
|
|
39
|
+
const textMuted = theme?.textMuted || (isLightTheme ? 'rgba(0,0,0,0.35)' : 'rgba(247,247,248,0.35)');
|
|
40
|
+
|
|
41
|
+
// System messages — centered pill with ultra-subtle surface
|
|
23
42
|
if (isSystemMessage) {
|
|
24
43
|
return (
|
|
25
|
-
<div className="flex justify-center my-
|
|
26
|
-
<div
|
|
27
|
-
|
|
44
|
+
<div className="flex justify-center my-3">
|
|
45
|
+
<div
|
|
46
|
+
className="px-4 py-1.5 rounded-full"
|
|
47
|
+
style={{
|
|
48
|
+
backgroundColor: isLightTheme ? 'rgba(0,0,0,0.04)' : 'rgba(255,255,255,0.03)',
|
|
49
|
+
boxShadow: isLightTheme
|
|
50
|
+
? 'inset 0 0 0 1px rgba(0,0,0,0.06)'
|
|
51
|
+
: 'inset 0 0 0 0.5px rgba(255,255,255,0.06)',
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
<p
|
|
55
|
+
style={{
|
|
56
|
+
fontSize: '11px',
|
|
57
|
+
letterSpacing: '0.019em',
|
|
58
|
+
color: textMuted,
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
{message.content}
|
|
62
|
+
</p>
|
|
28
63
|
</div>
|
|
29
64
|
</div>
|
|
30
65
|
);
|
|
31
66
|
}
|
|
32
67
|
|
|
33
|
-
// Determine
|
|
34
|
-
const
|
|
35
|
-
if (isAIMessage)
|
|
36
|
-
return '🤖'; // Robot icon for AI messages
|
|
37
|
-
}
|
|
68
|
+
// Determine label for non-customer messages
|
|
69
|
+
const getSenderLabel = () => {
|
|
70
|
+
if (isBotMessage || isAIMessage) return 'AI Assistant';
|
|
38
71
|
if (message.senderType === 'agent') {
|
|
39
|
-
return
|
|
72
|
+
return (message.metadata?.agentName as string) || 'Support Agent';
|
|
40
73
|
}
|
|
41
|
-
return
|
|
74
|
+
return null;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const senderLabel = !isOwnMessage ? getSenderLabel() : null;
|
|
78
|
+
|
|
79
|
+
// Own message: blue gradient pill (matches website primary CTA)
|
|
80
|
+
const ownBubbleStyle: React.CSSProperties = {
|
|
81
|
+
background: `linear-gradient(135deg, ${primaryColor}, ${primaryStrong})`,
|
|
82
|
+
color: '#ffffff',
|
|
83
|
+
borderRadius: '18px 18px 4px 18px',
|
|
84
|
+
boxShadow: `0 2px 12px -3px ${primaryColor}40`,
|
|
42
85
|
};
|
|
43
86
|
|
|
87
|
+
// Other message: surface with subtle inset border (adapts to theme)
|
|
88
|
+
const otherBubbleStyle: React.CSSProperties = isLightTheme
|
|
89
|
+
? {
|
|
90
|
+
backgroundColor: 'rgba(0,0,0,0.04)',
|
|
91
|
+
color: textColor,
|
|
92
|
+
borderRadius: '18px 18px 18px 4px',
|
|
93
|
+
boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.06)',
|
|
94
|
+
}
|
|
95
|
+
: {
|
|
96
|
+
backgroundColor: 'rgba(255,255,255,0.04)',
|
|
97
|
+
color: textColor,
|
|
98
|
+
borderRadius: '18px 18px 18px 4px',
|
|
99
|
+
boxShadow:
|
|
100
|
+
'inset 0 0 0 0.5px rgba(255,255,255,0.06), inset 0 1px 0 0 rgba(255,255,255,0.08)',
|
|
101
|
+
};
|
|
102
|
+
|
|
44
103
|
return (
|
|
45
|
-
<div
|
|
46
|
-
{
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
104
|
+
<div
|
|
105
|
+
className={`flex gap-2.5 mb-3 ${isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}
|
|
106
|
+
>
|
|
107
|
+
{/* Avatar */}
|
|
108
|
+
{showAvatar && !isOwnMessage && (
|
|
109
|
+
<div className="flex-shrink-0 mt-auto mb-5">
|
|
110
|
+
{isBotMessage || isAIMessage ? (
|
|
111
|
+
<XcelsiorAvatar size={28} />
|
|
112
|
+
) : (
|
|
113
|
+
<div
|
|
114
|
+
className="h-7 w-7 rounded-full flex items-center justify-center"
|
|
115
|
+
style={{
|
|
116
|
+
background: isLightTheme
|
|
117
|
+
? `linear-gradient(135deg, ${primaryColor}30, rgba(0,0,0,0.04))`
|
|
118
|
+
: `linear-gradient(135deg, ${primaryColor}60, rgba(255,255,255,0.06))`,
|
|
119
|
+
boxShadow: isLightTheme
|
|
120
|
+
? 'inset 0 0 0 1px rgba(0,0,0,0.08)'
|
|
121
|
+
: 'inset 0 0 0 0.5px rgba(255,255,255,0.1)',
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
<svg
|
|
125
|
+
width="14"
|
|
126
|
+
height="14"
|
|
127
|
+
viewBox="0 0 24 24"
|
|
128
|
+
fill="none"
|
|
129
|
+
stroke={isLightTheme ? primaryColor : 'white'}
|
|
130
|
+
strokeWidth="2"
|
|
131
|
+
strokeLinecap="round"
|
|
132
|
+
strokeLinejoin="round"
|
|
133
|
+
aria-hidden="true"
|
|
134
|
+
>
|
|
135
|
+
<title>Agent</title>
|
|
136
|
+
<path d="M3 18v-6a9 9 0 0 1 18 0v6" />
|
|
137
|
+
<path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z" />
|
|
138
|
+
</svg>
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
51
141
|
</div>
|
|
52
142
|
)}
|
|
53
143
|
|
|
144
|
+
{/* Spacer for own messages to maintain alignment */}
|
|
145
|
+
{showAvatar && isOwnMessage && <div className="w-7 flex-shrink-0" />}
|
|
146
|
+
|
|
54
147
|
<div
|
|
55
|
-
className={`flex flex-col max-w-[
|
|
148
|
+
className={`flex flex-col max-w-[75%] ${isOwnMessage ? 'items-end' : 'items-start'}`}
|
|
56
149
|
>
|
|
150
|
+
{/* Sender label */}
|
|
151
|
+
{senderLabel && (
|
|
152
|
+
<span
|
|
153
|
+
className="mb-1 px-1 font-medium"
|
|
154
|
+
style={{
|
|
155
|
+
color: isLightTheme ? 'rgba(0,0,0,0.45)' : 'rgba(247,247,248,0.4)',
|
|
156
|
+
fontSize: '11px',
|
|
157
|
+
letterSpacing: '0.019em',
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
{senderLabel}
|
|
161
|
+
</span>
|
|
162
|
+
)}
|
|
163
|
+
|
|
164
|
+
{/* Message bubble */}
|
|
57
165
|
<div
|
|
58
|
-
className=
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
166
|
+
className="px-4 py-2.5"
|
|
167
|
+
style={{
|
|
168
|
+
...( isOwnMessage ? ownBubbleStyle : otherBubbleStyle ),
|
|
169
|
+
fontSize: '14px',
|
|
170
|
+
lineHeight: '1.5',
|
|
171
|
+
letterSpacing: '0.006em',
|
|
172
|
+
}}
|
|
63
173
|
>
|
|
64
174
|
{message.messageType === 'text' && (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
loading="lazy"
|
|
75
|
-
/>
|
|
76
|
-
),
|
|
77
|
-
a: ({ href, children, ...props }) => (
|
|
78
|
-
<a
|
|
79
|
-
{...props}
|
|
80
|
-
href={href}
|
|
81
|
-
target="_blank"
|
|
82
|
-
rel="noopener noreferrer"
|
|
83
|
-
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`}
|
|
84
|
-
>
|
|
85
|
-
{children}
|
|
86
|
-
</a>
|
|
87
|
-
),
|
|
88
|
-
}}
|
|
89
|
-
>
|
|
90
|
-
{message.content}
|
|
91
|
-
</ReactMarkdown>
|
|
175
|
+
isOwnMessage ? (
|
|
176
|
+
// User messages: plain text — no markdown parsing
|
|
177
|
+
<span style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
|
178
|
+
{message.content}
|
|
179
|
+
</span>
|
|
180
|
+
) : (
|
|
181
|
+
// Bot / agent messages: full markdown rendering
|
|
182
|
+
<MarkdownMessage content={message.content} theme={theme} />
|
|
183
|
+
)
|
|
92
184
|
)}
|
|
93
185
|
{message.messageType === 'image' && (
|
|
94
186
|
<div>
|
|
@@ -102,12 +194,31 @@ export function MessageItem({
|
|
|
102
194
|
)}
|
|
103
195
|
{message.messageType === 'file' && (
|
|
104
196
|
<div className="flex items-center gap-2">
|
|
105
|
-
<
|
|
197
|
+
<svg
|
|
198
|
+
width="18"
|
|
199
|
+
height="18"
|
|
200
|
+
viewBox="0 0 24 24"
|
|
201
|
+
fill="none"
|
|
202
|
+
stroke="currentColor"
|
|
203
|
+
strokeWidth="1.75"
|
|
204
|
+
strokeLinecap="round"
|
|
205
|
+
strokeLinejoin="round"
|
|
206
|
+
aria-hidden="true"
|
|
207
|
+
>
|
|
208
|
+
<title>File</title>
|
|
209
|
+
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
|
|
210
|
+
</svg>
|
|
106
211
|
<a
|
|
107
212
|
href={message.content}
|
|
108
213
|
target="_blank"
|
|
109
214
|
rel="noopener noreferrer"
|
|
110
|
-
|
|
215
|
+
style={{
|
|
216
|
+
color: isOwnMessage
|
|
217
|
+
? 'rgba(255,255,255,0.85)'
|
|
218
|
+
: primaryColor,
|
|
219
|
+
textDecoration: 'underline',
|
|
220
|
+
textUnderlineOffset: '2px',
|
|
221
|
+
}}
|
|
111
222
|
>
|
|
112
223
|
{(message.metadata?.fileName as any) || 'Download file'}
|
|
113
224
|
</a>
|
|
@@ -115,21 +226,73 @@ export function MessageItem({
|
|
|
115
226
|
)}
|
|
116
227
|
</div>
|
|
117
228
|
|
|
229
|
+
{/* Timestamp + status */}
|
|
118
230
|
{showTimestamp && (
|
|
119
231
|
<div
|
|
120
|
-
className={`flex items-center gap-
|
|
232
|
+
className={`flex items-center gap-1.5 mt-1 px-1 ${isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}
|
|
121
233
|
>
|
|
122
|
-
<span
|
|
234
|
+
<span
|
|
235
|
+
style={{
|
|
236
|
+
fontSize: '11px',
|
|
237
|
+
letterSpacing: '0.019em',
|
|
238
|
+
color: textMuted,
|
|
239
|
+
}}
|
|
240
|
+
>
|
|
123
241
|
{formatDistanceToNow(new Date(message.createdAt), {
|
|
124
242
|
addSuffix: true,
|
|
125
243
|
})}
|
|
126
244
|
</span>
|
|
127
245
|
{isOwnMessage && message.status && (
|
|
128
|
-
<span
|
|
129
|
-
{message.status === 'sent' &&
|
|
130
|
-
|
|
246
|
+
<span style={{ color: textMuted }}>
|
|
247
|
+
{message.status === 'sent' && (
|
|
248
|
+
<svg
|
|
249
|
+
width="14"
|
|
250
|
+
height="14"
|
|
251
|
+
viewBox="0 0 24 24"
|
|
252
|
+
fill="none"
|
|
253
|
+
stroke="currentColor"
|
|
254
|
+
strokeWidth="2.5"
|
|
255
|
+
strokeLinecap="round"
|
|
256
|
+
strokeLinejoin="round"
|
|
257
|
+
aria-hidden="true"
|
|
258
|
+
>
|
|
259
|
+
<title>Sent</title>
|
|
260
|
+
<polyline points="20 6 9 17 4 12" />
|
|
261
|
+
</svg>
|
|
262
|
+
)}
|
|
263
|
+
{message.status === 'delivered' && (
|
|
264
|
+
<svg
|
|
265
|
+
width="14"
|
|
266
|
+
height="14"
|
|
267
|
+
viewBox="0 0 24 24"
|
|
268
|
+
fill="none"
|
|
269
|
+
stroke="currentColor"
|
|
270
|
+
strokeWidth="2.5"
|
|
271
|
+
strokeLinecap="round"
|
|
272
|
+
strokeLinejoin="round"
|
|
273
|
+
aria-hidden="true"
|
|
274
|
+
>
|
|
275
|
+
<title>Delivered</title>
|
|
276
|
+
<polyline points="18 6 7 17 2 12" />
|
|
277
|
+
<polyline points="22 6 11 17" />
|
|
278
|
+
</svg>
|
|
279
|
+
)}
|
|
131
280
|
{message.status === 'read' && (
|
|
132
|
-
<
|
|
281
|
+
<svg
|
|
282
|
+
width="14"
|
|
283
|
+
height="14"
|
|
284
|
+
viewBox="0 0 24 24"
|
|
285
|
+
fill="none"
|
|
286
|
+
stroke={primaryColor}
|
|
287
|
+
strokeWidth="2.5"
|
|
288
|
+
strokeLinecap="round"
|
|
289
|
+
strokeLinejoin="round"
|
|
290
|
+
aria-hidden="true"
|
|
291
|
+
>
|
|
292
|
+
<title>Read</title>
|
|
293
|
+
<polyline points="18 6 7 17 2 12" />
|
|
294
|
+
<polyline points="22 6 11 17" />
|
|
295
|
+
</svg>
|
|
133
296
|
)}
|
|
134
297
|
</span>
|
|
135
298
|
)}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import { useEffect, useRef
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
|
|
2
3
|
import { Spinner } from '@xcelsior/design-system';
|
|
4
|
+
|
|
5
|
+
import { XcelsiorAvatar, XcelsiorSymbol } from './BrandIcons';
|
|
3
6
|
import { MessageItem } from './MessageItem';
|
|
4
|
-
import
|
|
7
|
+
import { ThinkingIndicator } from './ThinkingIndicator';
|
|
8
|
+
|
|
9
|
+
import type { IMessage, IUser, IChatTheme } from '../types';
|
|
5
10
|
|
|
6
11
|
interface MessageListProps {
|
|
7
12
|
messages: IMessage[];
|
|
@@ -13,6 +18,11 @@ interface MessageListProps {
|
|
|
13
18
|
onLoadMore?: () => void;
|
|
14
19
|
hasMore?: boolean;
|
|
15
20
|
isLoadingMore?: boolean;
|
|
21
|
+
theme?: IChatTheme;
|
|
22
|
+
/** Called when a quick-start action button is clicked — sends the text as a message */
|
|
23
|
+
onQuickAction?: (text: string) => void;
|
|
24
|
+
/** True when user sent a message and bot response hasn't arrived yet */
|
|
25
|
+
isBotThinking?: boolean;
|
|
16
26
|
}
|
|
17
27
|
|
|
18
28
|
export function MessageList({
|
|
@@ -25,6 +35,9 @@ export function MessageList({
|
|
|
25
35
|
onLoadMore,
|
|
26
36
|
hasMore = false,
|
|
27
37
|
isLoadingMore = false,
|
|
38
|
+
theme,
|
|
39
|
+
onQuickAction,
|
|
40
|
+
isBotThinking = false,
|
|
28
41
|
}: MessageListProps) {
|
|
29
42
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
30
43
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -34,11 +47,23 @@ export function MessageList({
|
|
|
34
47
|
const hasInitialScrolledRef = useRef(false);
|
|
35
48
|
const isUserScrollingRef = useRef(false);
|
|
36
49
|
|
|
50
|
+
const bgColor = theme?.background || '#00001a';
|
|
51
|
+
const isLightTheme = (() => {
|
|
52
|
+
if (!bgColor.startsWith('#')) return false;
|
|
53
|
+
const hex = bgColor.replace('#', '');
|
|
54
|
+
const r = parseInt(hex.substring(0, 2), 16);
|
|
55
|
+
const g = parseInt(hex.substring(2, 4), 16);
|
|
56
|
+
const b = parseInt(hex.substring(4, 6), 16);
|
|
57
|
+
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.5;
|
|
58
|
+
})();
|
|
59
|
+
|
|
60
|
+
const primaryColor = theme?.primary || '#337eff';
|
|
61
|
+
const textColor = theme?.text || (isLightTheme ? '#1a1a2e' : '#f7f7f8');
|
|
62
|
+
const textMuted = theme?.textMuted || (isLightTheme ? 'rgba(0,0,0,0.4)' : 'rgba(247,247,248,0.45)');
|
|
63
|
+
|
|
37
64
|
// Auto-scroll to bottom when new messages arrive
|
|
38
65
|
useEffect(() => {
|
|
39
66
|
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
67
|
if (messages.length > prevLengthRef.current && !isLoadingMore) {
|
|
43
68
|
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
|
44
69
|
}
|
|
@@ -54,17 +79,14 @@ export function MessageList({
|
|
|
54
79
|
!isLoading &&
|
|
55
80
|
!hasInitialScrolledRef.current
|
|
56
81
|
) {
|
|
57
|
-
// Use setTimeout to ensure DOM is fully rendered
|
|
58
82
|
setTimeout(() => {
|
|
59
83
|
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' });
|
|
60
|
-
// Enable user scrolling after initial scroll completes
|
|
61
84
|
setTimeout(() => {
|
|
62
85
|
isUserScrollingRef.current = true;
|
|
63
86
|
}, 200);
|
|
64
87
|
}, 100);
|
|
65
88
|
hasInitialScrolledRef.current = true;
|
|
66
89
|
} else if (!isLoading && messages.length === 0 && !hasInitialScrolledRef.current) {
|
|
67
|
-
// If there are no messages, enable user scrolling immediately
|
|
68
90
|
isUserScrollingRef.current = true;
|
|
69
91
|
hasInitialScrolledRef.current = true;
|
|
70
92
|
}
|
|
@@ -85,12 +107,9 @@ export function MessageList({
|
|
|
85
107
|
// Infinite scroll: detect when user scrolls near the top
|
|
86
108
|
const handleScroll = useCallback(() => {
|
|
87
109
|
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
110
|
if (!isUserScrollingRef.current) return;
|
|
91
111
|
|
|
92
112
|
const { scrollTop } = containerRef.current;
|
|
93
|
-
// Trigger load more when user scrolls within 100px of the top
|
|
94
113
|
if (scrollTop < 100) {
|
|
95
114
|
onLoadMore();
|
|
96
115
|
}
|
|
@@ -113,16 +132,86 @@ export function MessageList({
|
|
|
113
132
|
);
|
|
114
133
|
}
|
|
115
134
|
|
|
135
|
+
// Empty state — premium, minimal, matching Xcelsior website feel
|
|
116
136
|
if (messages.length === 0) {
|
|
117
137
|
return (
|
|
118
|
-
<div className="flex flex-col items-center justify-center h-full text-center
|
|
119
|
-
<
|
|
120
|
-
|
|
121
|
-
|
|
138
|
+
<div className="flex flex-col items-center justify-center h-full text-center" style={{ padding: '40px 32px' }}>
|
|
139
|
+
<h3
|
|
140
|
+
className="font-semibold mb-2"
|
|
141
|
+
style={{
|
|
142
|
+
color: textColor,
|
|
143
|
+
fontSize: '17px',
|
|
144
|
+
letterSpacing: '-0.01em',
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
How can we help?
|
|
122
148
|
</h3>
|
|
123
|
-
<p
|
|
124
|
-
|
|
149
|
+
<p
|
|
150
|
+
className="max-w-[240px]"
|
|
151
|
+
style={{
|
|
152
|
+
color: textMuted,
|
|
153
|
+
fontSize: '13px',
|
|
154
|
+
lineHeight: '1.5',
|
|
155
|
+
letterSpacing: '0.015em',
|
|
156
|
+
marginBottom: 20,
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
Ask us anything. We are here to help you get the most out of Xcelsior.
|
|
125
160
|
</p>
|
|
161
|
+
|
|
162
|
+
{/* Quick-start action buttons */}
|
|
163
|
+
<div className="flex flex-wrap justify-center gap-2">
|
|
164
|
+
{[
|
|
165
|
+
{ label: 'Our services', message: 'What services does Xcelsior offer?' },
|
|
166
|
+
{ label: 'Get a quote', message: 'I would like to get a quote for a project' },
|
|
167
|
+
{ label: 'Support', message: 'I need help with something' },
|
|
168
|
+
].map((action) => (
|
|
169
|
+
<button
|
|
170
|
+
key={action.label}
|
|
171
|
+
type="button"
|
|
172
|
+
onClick={() => onQuickAction?.(action.message)}
|
|
173
|
+
style={{
|
|
174
|
+
padding: '6px 14px',
|
|
175
|
+
borderRadius: '999px',
|
|
176
|
+
cursor: 'pointer',
|
|
177
|
+
transition: 'all 150ms ease',
|
|
178
|
+
backgroundColor: isLightTheme
|
|
179
|
+
? 'rgba(0,0,0,0.04)'
|
|
180
|
+
: 'rgba(255,255,255,0.04)',
|
|
181
|
+
border: isLightTheme
|
|
182
|
+
? '1px solid rgba(0,0,0,0.1)'
|
|
183
|
+
: '1px solid rgba(255,255,255,0.1)',
|
|
184
|
+
color: isLightTheme
|
|
185
|
+
? 'rgba(0,0,0,0.6)'
|
|
186
|
+
: 'rgba(247,247,248,0.6)',
|
|
187
|
+
fontSize: '12px',
|
|
188
|
+
letterSpacing: '0.015em',
|
|
189
|
+
}}
|
|
190
|
+
onMouseEnter={(e) => {
|
|
191
|
+
e.currentTarget.style.backgroundColor = isLightTheme
|
|
192
|
+
? 'rgba(0,0,0,0.08)'
|
|
193
|
+
: 'rgba(255,255,255,0.08)';
|
|
194
|
+
e.currentTarget.style.color = isLightTheme
|
|
195
|
+
? 'rgba(0,0,0,0.8)'
|
|
196
|
+
: 'rgba(247,247,248,0.8)';
|
|
197
|
+
e.currentTarget.style.borderColor = primaryColor;
|
|
198
|
+
}}
|
|
199
|
+
onMouseLeave={(e) => {
|
|
200
|
+
e.currentTarget.style.backgroundColor = isLightTheme
|
|
201
|
+
? 'rgba(0,0,0,0.04)'
|
|
202
|
+
: 'rgba(255,255,255,0.04)';
|
|
203
|
+
e.currentTarget.style.color = isLightTheme
|
|
204
|
+
? 'rgba(0,0,0,0.6)'
|
|
205
|
+
: 'rgba(247,247,248,0.6)';
|
|
206
|
+
e.currentTarget.style.borderColor = isLightTheme
|
|
207
|
+
? 'rgba(0,0,0,0.1)'
|
|
208
|
+
: 'rgba(255,255,255,0.1)';
|
|
209
|
+
}}
|
|
210
|
+
>
|
|
211
|
+
{action.label}
|
|
212
|
+
</button>
|
|
213
|
+
))}
|
|
214
|
+
</div>
|
|
126
215
|
</div>
|
|
127
216
|
);
|
|
128
217
|
}
|
|
@@ -130,12 +219,22 @@ export function MessageList({
|
|
|
130
219
|
return (
|
|
131
220
|
<div
|
|
132
221
|
ref={containerRef}
|
|
133
|
-
className="flex-1 overflow-y-auto
|
|
222
|
+
className="flex-1 overflow-y-auto px-4 py-3"
|
|
134
223
|
style={{ scrollBehavior: 'smooth' }}
|
|
135
224
|
>
|
|
225
|
+
<style>{`
|
|
226
|
+
@keyframes thinkingPulse {
|
|
227
|
+
0%, 60%, 100% { opacity: 0.3; transform: scale(0.8); }
|
|
228
|
+
30% { opacity: 1; transform: scale(1); }
|
|
229
|
+
}
|
|
230
|
+
@keyframes cursorBlink {
|
|
231
|
+
0%, 100% { opacity: 1; }
|
|
232
|
+
50% { opacity: 0; }
|
|
233
|
+
}
|
|
234
|
+
`}</style>
|
|
136
235
|
{/* Loading indicator at the top for infinite scroll */}
|
|
137
236
|
{isLoadingMore && (
|
|
138
|
-
<div className="flex justify-center py-
|
|
237
|
+
<div className="flex justify-center py-3">
|
|
139
238
|
<Spinner size="sm" />
|
|
140
239
|
</div>
|
|
141
240
|
)}
|
|
@@ -150,32 +249,54 @@ export function MessageList({
|
|
|
150
249
|
currentUser={currentUser}
|
|
151
250
|
showAvatar={true}
|
|
152
251
|
showTimestamp={true}
|
|
252
|
+
theme={theme}
|
|
153
253
|
/>
|
|
154
254
|
))}
|
|
155
255
|
|
|
256
|
+
{/* Typing indicator — matching bot message bubble style */}
|
|
156
257
|
{isTyping && (
|
|
157
|
-
<div className="flex gap-2 mb-
|
|
158
|
-
<div className="flex-shrink-0">
|
|
159
|
-
<
|
|
160
|
-
🎧
|
|
161
|
-
</div>
|
|
258
|
+
<div className="flex gap-2.5 mb-3">
|
|
259
|
+
<div className="flex-shrink-0 mt-auto mb-5">
|
|
260
|
+
<XcelsiorAvatar size={28} />
|
|
162
261
|
</div>
|
|
163
262
|
<div className="flex flex-col items-start">
|
|
164
|
-
<div
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
263
|
+
<div
|
|
264
|
+
className="px-4 py-3"
|
|
265
|
+
style={{
|
|
266
|
+
backgroundColor: isLightTheme
|
|
267
|
+
? 'rgba(0,0,0,0.04)'
|
|
268
|
+
: 'rgba(255,255,255,0.04)',
|
|
269
|
+
borderRadius: '18px 18px 18px 4px',
|
|
270
|
+
boxShadow: isLightTheme
|
|
271
|
+
? 'inset 0 0 0 1px rgba(0,0,0,0.06)'
|
|
272
|
+
: 'inset 0 0 0 0.5px rgba(255,255,255,0.06), inset 0 1px 0 0 rgba(255,255,255,0.08)',
|
|
273
|
+
}}
|
|
274
|
+
>
|
|
275
|
+
<div className="flex gap-1.5 items-center" style={{ height: 16 }}>
|
|
276
|
+
{[0, 1, 2].map((i) => (
|
|
277
|
+
<span
|
|
278
|
+
key={i}
|
|
279
|
+
className="rounded-full animate-bounce"
|
|
280
|
+
style={{
|
|
281
|
+
width: 5,
|
|
282
|
+
height: 5,
|
|
283
|
+
backgroundColor: `${primaryColor}80`,
|
|
284
|
+
animationDelay: `${i * 0.15}s`,
|
|
285
|
+
animationDuration: '0.8s',
|
|
286
|
+
}}
|
|
287
|
+
/>
|
|
288
|
+
))}
|
|
175
289
|
</div>
|
|
176
290
|
</div>
|
|
177
291
|
{typingUser && (
|
|
178
|
-
<span
|
|
292
|
+
<span
|
|
293
|
+
className="mt-1 px-1"
|
|
294
|
+
style={{
|
|
295
|
+
color: textMuted,
|
|
296
|
+
fontSize: '11px',
|
|
297
|
+
letterSpacing: '0.019em',
|
|
298
|
+
}}
|
|
299
|
+
>
|
|
179
300
|
{typingUser} is typing...
|
|
180
301
|
</span>
|
|
181
302
|
)}
|
|
@@ -183,6 +304,14 @@ export function MessageList({
|
|
|
183
304
|
</div>
|
|
184
305
|
)}
|
|
185
306
|
|
|
307
|
+
{/* Bot thinking indicator — Claude-like typewriter with rotating phrases */}
|
|
308
|
+
{isBotThinking && !isTyping && (
|
|
309
|
+
<ThinkingIndicator
|
|
310
|
+
theme={theme}
|
|
311
|
+
lastUserMessage={messages.filter((m) => m.senderType === 'customer').pop()?.content}
|
|
312
|
+
/>
|
|
313
|
+
)}
|
|
314
|
+
|
|
186
315
|
<div ref={messagesEndRef} />
|
|
187
316
|
</div>
|
|
188
317
|
);
|