@linktr.ee/messaging-react 1.34.0 → 1.35.0-rc-1777516919

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.ts CHANGED
@@ -27,7 +27,7 @@ export declare type AttachmentSourceType = 'image' | 'audio' | 'video' | 'docume
27
27
  /**
28
28
  * Avatar component that displays a user image or colored initial fallback
29
29
  */
30
- export declare const Avatar: default_2.FC<AvatarProps>;
30
+ export declare const Avatar: ({ id, image, size, className, starred, shape, aiActive, }: AvatarProps) => JSX_2.Element;
31
31
 
32
32
  export declare interface AvatarProps {
33
33
  id: string;
@@ -37,6 +37,7 @@ export declare interface AvatarProps {
37
37
  className?: string;
38
38
  starred?: boolean;
39
39
  shape?: 'squircle' | 'circle';
40
+ aiActive?: boolean;
40
41
  }
41
42
 
42
43
  /**
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as e, a as t, C as i, b as n, c as o, d as g, F as r, e as m, L as u, f as M, h as c, i as l, j as h, P as C, k as P, u as d, l as L, m as p, n as v } from "./index-DWk0f1PF.js";
1
+ import { A as e, a as t, C as i, b as n, c as o, d as g, F as r, e as m, L as u, f as M, h as c, i as l, j as h, P as C, k as P, u as d, l as L, m as p, n as v } from "./index-BhmonXWN.js";
2
2
  export {
3
3
  e as ActionButton,
4
4
  t as Avatar,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "1.34.0",
3
+ "version": "1.35.0-rc-1777516919",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,6 +29,14 @@ Default.args = {
29
29
  image: 'https://i.pravatar.cc/150?img=1',
30
30
  }
31
31
 
32
+ export const AIActive: StoryFn<ComponentProps> = Template.bind({})
33
+ AIActive.args = {
34
+ id: 'ai-agent',
35
+ name: 'AI Assistant',
36
+ image: 'https://i.pravatar.cc/150?img=12',
37
+ aiActive: true,
38
+ }
39
+
32
40
  export const WithoutImage: StoryFn<ComponentProps> = Template.bind({})
33
41
  WithoutImage.args = {
34
42
  id: 'user-2',
@@ -150,3 +158,24 @@ export const VariousSizes: StoryFn = () => {
150
158
  )
151
159
  }
152
160
 
161
+ export const AIActiveVariousSizes: StoryFn = () => {
162
+ const sizes = [20, 32, 40, 56, 80]
163
+
164
+ return (
165
+ <div className="p-12">
166
+ <div className="flex gap-6 items-end">
167
+ {sizes.map((size) => (
168
+ <div key={size} className="flex flex-col items-center gap-2">
169
+ <Avatar
170
+ id="ai-user-consistent"
171
+ name="AI Assistant"
172
+ size={size}
173
+ aiActive
174
+ />
175
+ <span className="text-xs text-stone">{size}px</span>
176
+ </div>
177
+ ))}
178
+ </div>
179
+ </div>
180
+ )
181
+ }
@@ -12,19 +12,21 @@ export interface AvatarProps {
12
12
  className?: string
13
13
  starred?: boolean
14
14
  shape?: 'squircle' | 'circle'
15
+ aiActive?: boolean
15
16
  }
16
17
 
17
18
  /**
18
19
  * Avatar component that displays a user image or colored initial fallback
19
20
  */
20
- export const Avatar: React.FC<AvatarProps> = ({
21
+ export const Avatar = ({
21
22
  id,
22
23
  image,
23
24
  size = 40,
24
25
  className,
25
26
  starred = false,
26
27
  shape = 'squircle',
27
- }) => {
28
+ aiActive = false,
29
+ }: AvatarProps) => {
28
30
  const emoji = getAvatarEmoji(id)
29
31
 
30
32
  const getFontSizeClass = () => {
@@ -35,6 +37,8 @@ export const Avatar: React.FC<AvatarProps> = ({
35
37
  }
36
38
 
37
39
  const fontSizeClass = getFontSizeClass()
40
+ const aiRingWidth = Math.max(1, Math.ceil(size / 40))
41
+ const aiRingPadding = aiActive ? aiRingWidth : 0
38
42
 
39
43
  const borderStyle =
40
44
  shape === 'circle'
@@ -44,9 +48,34 @@ export const Avatar: React.FC<AvatarProps> = ({
44
48
  'corner-shape': 'superellipse(1.3)',
45
49
  }
46
50
 
51
+ const avatarInner = (
52
+ <div className="h-full w-full overflow-hidden" style={borderStyle}>
53
+ {image ? (
54
+ <img
55
+ src={image}
56
+ alt=""
57
+ className="aspect-square h-full w-full object-cover"
58
+ />
59
+ ) : (
60
+ <div
61
+ aria-hidden="true"
62
+ className={classNames(
63
+ 'avatar-fallback flex h-full w-full items-center justify-center bg-[#E6E5E3] font-semibold select-none transition-colors',
64
+ fontSizeClass
65
+ )}
66
+ >
67
+ {emoji}
68
+ </div>
69
+ )}
70
+ </div>
71
+ )
72
+
47
73
  return (
48
74
  <div
49
- className={classNames('relative flex-shrink-0', className)}
75
+ className={classNames(
76
+ 'relative flex-shrink-0 !bg-transparent',
77
+ className
78
+ )}
50
79
  style={{
51
80
  width: `${size}px`,
52
81
  height: `${size}px`,
@@ -60,24 +89,19 @@ export const Avatar: React.FC<AvatarProps> = ({
60
89
  <StarIcon className="size-3 text-yellow-600" weight="duotone" />
61
90
  </div>
62
91
  )}
63
- <div className="h-full w-full overflow-hidden" style={borderStyle}>
64
- {image ? (
65
- <img
66
- src={image}
67
- alt=""
68
- className="aspect-square h-full w-full object-cover"
69
- />
70
- ) : (
71
- <div
72
- aria-hidden="true"
73
- className={classNames(
74
- 'avatar-fallback flex h-full w-full items-center justify-center bg-[#E6E5E3] font-semibold select-none transition-colors',
75
- fontSizeClass
76
- )}
77
- >
78
- {emoji}
79
- </div>
92
+ <div
93
+ className={classNames(
94
+ 'h-full w-full',
95
+ aiActive
96
+ ? 'bg-[linear-gradient(135deg,#A855F7_0%,#8B5CF6_45%,#6D28D9_100%)]'
97
+ : 'bg-transparent'
80
98
  )}
99
+ style={{
100
+ ...borderStyle,
101
+ padding: `${aiRingPadding}px`,
102
+ }}
103
+ >
104
+ {avatarInner}
81
105
  </div>
82
106
  </div>
83
107
  )
@@ -160,10 +160,20 @@ const createMockChannel = async (
160
160
  type TemplateProps = ComponentProps & {
161
161
  followerStatus?: string | boolean
162
162
  isFrozen?: boolean
163
+ typingUser?: {
164
+ id: string
165
+ name?: string
166
+ image?: string
167
+ }
163
168
  }
164
169
 
165
170
  const Template: StoryFn<TemplateProps> = (args) => {
166
- const { followerStatus, isFrozen = false, ...channelViewProps } = args
171
+ const {
172
+ followerStatus,
173
+ isFrozen = false,
174
+ typingUser,
175
+ ...channelViewProps
176
+ } = args
167
177
  const [client] = React.useState(() => {
168
178
  const client = new StreamChat('mock-api-key', {
169
179
  allowServerSideConnect: true,
@@ -183,6 +193,22 @@ const Template: StoryFn<TemplateProps> = (args) => {
183
193
  )
184
194
  }, [client, followerStatus, isFrozen])
185
195
 
196
+ useEffect(() => {
197
+ if (!channel || !typingUser) {
198
+ return
199
+ }
200
+
201
+ const timer = setTimeout(() => {
202
+ client.dispatchEvent({
203
+ type: 'typing.start',
204
+ cid: channel.cid,
205
+ user: typingUser,
206
+ })
207
+ }, 0)
208
+
209
+ return () => clearTimeout(timer)
210
+ }, [channel, client, typingUser])
211
+
186
212
  if (!channel) {
187
213
  return <div>Loading...</div>
188
214
  }
@@ -556,6 +582,24 @@ FrozenChannel.parameters = {
556
582
  },
557
583
  }
558
584
 
585
+ export const WithTypingIndicator: StoryFn<TemplateProps> = Template.bind({})
586
+ WithTypingIndicator.args = {
587
+ showBackButton: false,
588
+ typingUser: {
589
+ id: mockParticipants[0].id,
590
+ name: mockParticipants[0].name,
591
+ image: mockParticipants[0].image,
592
+ },
593
+ }
594
+ WithTypingIndicator.parameters = {
595
+ docs: {
596
+ description: {
597
+ story:
598
+ 'Channel view with the typing indicator visible, driven by a mocked typing.start event from the other participant.',
599
+ },
600
+ },
601
+ }
602
+
559
603
  // ---------------------------------------------------------------------------
560
604
  // Custom SendButton stories
561
605
  // ---------------------------------------------------------------------------
@@ -1,4 +1,9 @@
1
- import { ArrowLeftIcon, CaretRightIcon, DotsThreeIcon, StarIcon } from '@phosphor-icons/react'
1
+ import {
2
+ ArrowLeftIcon,
3
+ CaretRightIcon,
4
+ DotsThreeIcon,
5
+ StarIcon,
6
+ } from '@phosphor-icons/react'
2
7
  import classNames from 'classnames'
3
8
  import React, { useCallback, useRef } from 'react'
4
9
  import { Channel as ChannelType } from 'stream-chat'
@@ -21,6 +26,7 @@ import { CustomDateSeparator } from './CustomDateSeparator'
21
26
  import { CustomMessage } from './CustomMessage'
22
27
  import { CustomMessageInput } from './CustomMessageInput'
23
28
  import { CustomSystemMessage } from './CustomSystemMessage'
29
+ import CustomTypingIndicator from './CustomTypingIndicator'
24
30
  import { ChannelEmptyState } from './MessagingShell/ChannelEmptyState'
25
31
  import { LoadingState } from './MessagingShell/LoadingState'
26
32
 
@@ -296,10 +302,7 @@ const ChannelViewInner: React.FC<{
296
302
  // eslint-disable-next-line react-hooks/rules-of-hooks
297
303
  const { message } = useMessageContext('ChannelView')
298
304
  const messageNode = (
299
- <CustomMessage
300
- {...props}
301
- chatbotVotingEnabled={chatbotVotingEnabled}
302
- />
305
+ <CustomMessage {...props} chatbotVotingEnabled={chatbotVotingEnabled} />
303
306
  )
304
307
 
305
308
  if (!renderMessage || !message) {
@@ -453,6 +456,7 @@ export const ChannelView = React.memo<ChannelViewProps>(
453
456
  EmptyStateIndicator={CustomChannelEmptyState}
454
457
  LoadingIndicator={LoadingState}
455
458
  DateSeparator={CustomDateSeparator}
459
+ TypingIndicator={CustomTypingIndicator}
456
460
  doSendMessageRequest={doSendMessageRequest}
457
461
  {...(sendButton ? { SendButton: sendButton } : {})}
458
462
  >
@@ -2,19 +2,30 @@ import '../../stream-custom-data'
2
2
 
3
3
  import type { Meta, StoryFn } from '@storybook/react'
4
4
  import React, { useEffect } from 'react'
5
- import { Channel as ChannelType, QueryChannelAPIResponse, StreamChat, } from 'stream-chat'
6
- import { Channel, Chat, MessageList, MessageUIComponentProps, Window } from 'stream-chat-react'
5
+ import {
6
+ Channel as ChannelType,
7
+ QueryChannelAPIResponse,
8
+ StreamChat,
9
+ } from 'stream-chat'
10
+ import {
11
+ Channel,
12
+ Chat,
13
+ MessageList,
14
+ MessageUIComponentProps,
15
+ Window,
16
+ } from 'stream-chat-react'
7
17
 
8
18
  import {
9
19
  currentUserArgType,
10
20
  StoryUser,
11
21
  storyUsers,
12
22
  } from '../../stories/decorators/storyUser'
23
+ import CustomTypingIndicator from '../CustomTypingIndicator'
13
24
 
14
25
  import { CustomMessage } from './index'
15
26
 
16
27
  const meta: Meta = {
17
- title: 'Components/CustomMessage',
28
+ title: 'CustomMessage',
18
29
  component: CustomMessage,
19
30
  argTypes: currentUserArgType,
20
31
  args: { currentUser: storyUsers.creator },
@@ -89,6 +100,7 @@ const createMockChannel = async (
89
100
 
90
101
  interface TemplateProps {
91
102
  currentUser: StoryUser
103
+ typingUser?: StoryUser
92
104
  messages: Array<{
93
105
  id: string
94
106
  text: string
@@ -101,11 +113,7 @@ interface TemplateProps {
101
113
  | 'MESSAGE_PAID'
102
114
  | 'MESSAGE_CHATBOT'
103
115
  | 'MESSAGE_ATTACHMENT'
104
- payment_status?:
105
- | 'pending'
106
- | 'paid'
107
- | 'failed'
108
- | 'refunded'
116
+ payment_status?: 'pending' | 'paid' | 'failed' | 'refunded'
109
117
  amount_text?: string
110
118
  attachment_title?: string
111
119
  attachment_mime_type?: string
@@ -117,8 +125,9 @@ interface TemplateProps {
117
125
 
118
126
  const TemplateInner: React.FC<{
119
127
  currentUser: StoryUser
128
+ typingUser?: StoryUser
120
129
  messages: TemplateProps['messages']
121
- }> = ({ currentUser, messages }) => {
130
+ }> = ({ currentUser, typingUser, messages }) => {
122
131
  const [client] = React.useState(() => {
123
132
  const c = new StreamChat('mock-api-key', { allowServerSideConnect: true })
124
133
  c.userID = currentUser.id
@@ -132,13 +141,26 @@ const TemplateInner: React.FC<{
132
141
  createMockChannel(client, messages).then(setChannel)
133
142
  }, [client, messages])
134
143
 
144
+ useEffect(() => {
145
+ if (!channel || !typingUser) {
146
+ return
147
+ }
148
+
149
+ // Stream Channel UI only updates typing context from typing.start/typing.stop events.
150
+ const timer = setTimeout(() => {
151
+ client.dispatchEvent({
152
+ type: 'typing.start',
153
+ cid: channel.cid,
154
+ user: typingUser,
155
+ })
156
+ }, 0)
157
+
158
+ return () => clearTimeout(timer)
159
+ }, [channel, client, typingUser])
160
+
135
161
  const MessageComponent = React.useMemo(() => {
136
162
  return function CustomMessageComponent(props: MessageUIComponentProps) {
137
- return (
138
- <CustomMessage
139
- {...props}
140
- />
141
- )
163
+ return <CustomMessage {...props} />
142
164
  }
143
165
  }, [])
144
166
 
@@ -149,7 +171,11 @@ const TemplateInner: React.FC<{
149
171
  return (
150
172
  <Chat client={client}>
151
173
  <div className="h-screen w-full bg-white">
152
- <Channel channel={channel} Message={MessageComponent}>
174
+ <Channel
175
+ channel={channel}
176
+ Message={MessageComponent}
177
+ TypingIndicator={CustomTypingIndicator}
178
+ >
153
179
  <Window>
154
180
  <MessageList />
155
181
  </Window>
@@ -161,11 +187,13 @@ const TemplateInner: React.FC<{
161
187
 
162
188
  const Template: StoryFn<TemplateProps> = ({
163
189
  currentUser = storyUsers.creator,
190
+ typingUser,
164
191
  messages,
165
192
  }) => (
166
193
  <TemplateInner
167
194
  key={currentUser.id}
168
195
  currentUser={currentUser}
196
+ typingUser={typingUser}
169
197
  messages={messages}
170
198
  />
171
199
  )
@@ -357,3 +385,35 @@ ChatbotVariants.args = {
357
385
  },
358
386
  ],
359
387
  }
388
+
389
+ export const WithTypingIndicatorComparison: StoryFn<TemplateProps> =
390
+ Template.bind({})
391
+ WithTypingIndicatorComparison.args = {
392
+ currentUser: storyUsers.creator,
393
+ typingUser: storyUsers.visitor,
394
+ messages: [
395
+ {
396
+ id: 'msg-1',
397
+ text: 'Hi, is this still available?',
398
+ user: storyUsers.visitor,
399
+ },
400
+ {
401
+ id: 'msg-2',
402
+ text: 'Yes, absolutely.',
403
+ user: storyUsers.creator,
404
+ },
405
+ {
406
+ id: 'msg-3',
407
+ text: 'Great! I have another question…',
408
+ user: storyUsers.visitor,
409
+ },
410
+ ],
411
+ }
412
+ WithTypingIndicatorComparison.parameters = {
413
+ docs: {
414
+ description: {
415
+ story:
416
+ 'Renders a standard incoming message and the custom typing indicator in the same list for visual comparison.',
417
+ },
418
+ },
419
+ }
@@ -7,7 +7,7 @@ import { MessageTag } from './MessageTag'
7
7
  type ComponentProps = React.ComponentProps<typeof MessageTag>
8
8
 
9
9
  const meta: Meta<ComponentProps> = {
10
- title: 'Components/MessageTag',
10
+ title: 'MessageTag',
11
11
  component: MessageTag,
12
12
  parameters: {
13
13
  layout: 'centered',
@@ -0,0 +1,114 @@
1
+ import type { Meta, StoryFn } from '@storybook/react'
2
+ import React from 'react'
3
+ import type { Event } from 'stream-chat'
4
+ import {
5
+ ChannelStateProvider,
6
+ ChatProvider,
7
+ TypingProvider,
8
+ } from 'stream-chat-react'
9
+
10
+ import CustomTypingIndicator from '.'
11
+
12
+ type StoryProps = {
13
+ typingEventsEnabled?: boolean
14
+ typing?: Record<string, Event>
15
+ }
16
+
17
+ const currentUser = {
18
+ id: 'visitor-1',
19
+ name: 'Visitor',
20
+ }
21
+
22
+ const typingUser = {
23
+ id: 'creator-1',
24
+ name: 'Creator',
25
+ image: 'https://i.pravatar.cc/48?img=5',
26
+ }
27
+
28
+ const defaultTyping: Record<string, Event> = {
29
+ [typingUser.id]: {
30
+ type: 'typing.start',
31
+ user: typingUser,
32
+ parent_id: undefined,
33
+ } as Event,
34
+ }
35
+
36
+ const StoryWrapper: React.FC<StoryProps> = ({
37
+ typingEventsEnabled = true,
38
+ typing = defaultTyping,
39
+ }) => {
40
+ const chatContextValue = {
41
+ client: {
42
+ user: currentUser,
43
+ },
44
+ theme: 'str-chat__theme-light',
45
+ channelsQueryState: {
46
+ error: null,
47
+ queryInProgress: null,
48
+ },
49
+ latestMessageDatesByChannels: {},
50
+ mutes: [],
51
+ closeMobileNav: () => {},
52
+ openMobileNav: () => {},
53
+ getAppSettings: () => null,
54
+ setActiveChannel: () => {},
55
+ themeVersion: '2',
56
+ useImageFlagEmojisOnWindows: false,
57
+ }
58
+
59
+ const channelStateValue = {
60
+ channel: {
61
+ state: {
62
+ members: {
63
+ [currentUser.id]: {
64
+ user: currentUser,
65
+ },
66
+ [typingUser.id]: {
67
+ user: typingUser,
68
+ },
69
+ },
70
+ },
71
+ },
72
+ channelCapabilities: {},
73
+ channelConfig: {
74
+ typing_events: typingEventsEnabled,
75
+ },
76
+ imageAttachmentSizeHandler: () => ({}),
77
+ multipleUploads: false,
78
+ notifications: [],
79
+ shouldGenerateVideoThumbnail: false,
80
+ suppressAutoscroll: false,
81
+ videoAttachmentSizeHandler: () => ({}),
82
+ }
83
+
84
+ return (
85
+ <ChatProvider value={chatContextValue as never}>
86
+ <ChannelStateProvider value={channelStateValue as never}>
87
+ <TypingProvider value={{ typing }}>
88
+ <div className="relative h-20 w-[200px] bg-[#f4f4f4] p-3">
89
+ <CustomTypingIndicator />
90
+ </div>
91
+ </TypingProvider>
92
+ </ChannelStateProvider>
93
+ </ChatProvider>
94
+ )
95
+ }
96
+
97
+ const meta: Meta<StoryProps> = {
98
+ title: 'CustomTypingIndicator',
99
+ component: CustomTypingIndicator,
100
+ parameters: {
101
+ layout: 'centered',
102
+ },
103
+ }
104
+ export default meta
105
+
106
+ export const Default: StoryFn<StoryProps> = (args) => <StoryWrapper {...args} />
107
+ Default.args = {}
108
+
109
+ export const HiddenWhenNoTyping: StoryFn<StoryProps> = (args) => (
110
+ <StoryWrapper {...args} />
111
+ )
112
+ HiddenWhenNoTyping.args = {
113
+ typing: {},
114
+ }
@@ -0,0 +1,101 @@
1
+ import React from 'react'
2
+ import type { Event } from 'stream-chat'
3
+ import {
4
+ useChannelStateContext,
5
+ useChatContext,
6
+ useTypingContext,
7
+ } from 'stream-chat-react'
8
+
9
+ import { Avatar } from '../Avatar'
10
+
11
+ interface CustomTypingIndicatorProps {
12
+ threadList?: boolean
13
+ }
14
+
15
+ const Circle = ({ cx, index }: { cx: string; index: number }) => (
16
+ <circle className="fill-pebble" cx={cx} cy="4" r="3.9">
17
+ <animateTransform
18
+ attributeName="transform"
19
+ type="translate"
20
+ values="0 0; 0 -2.25; 0 0;"
21
+ dur="900ms"
22
+ begin={`${120 * index}ms`} // 0ms, 120ms, 240ms
23
+ repeatCount="indefinite"
24
+ />
25
+ </circle>
26
+ )
27
+
28
+ const CustomTypingIndicator = ({ threadList }: CustomTypingIndicatorProps) => {
29
+ const { channel, channelConfig, thread } = useChannelStateContext()
30
+ const { client } = useChatContext()
31
+ const { typing = {} } = useTypingContext()
32
+
33
+ if (channelConfig?.typing_events === false) {
34
+ return null
35
+ }
36
+
37
+ const typingInChannel = !threadList
38
+ ? Object.values(typing).filter(
39
+ ({ parent_id, user }: Event) =>
40
+ user?.id !== client.user?.id && !parent_id
41
+ )
42
+ : []
43
+
44
+ const typingInThread = threadList
45
+ ? Object.values(typing).filter(
46
+ ({ parent_id, user }: Event) =>
47
+ user?.id !== client.user?.id && parent_id === thread?.id
48
+ )
49
+ : []
50
+
51
+ const typingUsers = threadList ? typingInThread : typingInChannel
52
+ if (!typingUsers.length) {
53
+ return null
54
+ }
55
+
56
+ const typingUser = typingUsers[0]?.user
57
+ const memberUser =
58
+ typingUser?.id && channel.state.members[typingUser.id]
59
+ ? channel.state.members[typingUser.id].user
60
+ : undefined
61
+
62
+ const avatarId = typingUser?.id ?? memberUser?.id ?? 'typing-user'
63
+ const avatarName =
64
+ typingUser?.name ?? memberUser?.name ?? typingUser?.id ?? 'Typing user'
65
+ const avatarImage = typingUser?.image ?? memberUser?.image
66
+
67
+ return (
68
+ <div
69
+ className="str-chat__typing-indicator !items-end"
70
+ data-testid="typing-indicator"
71
+ style={{ insetInlineStart: 0, insetInlineEnd: 'auto' }}
72
+ >
73
+ <div className="shrink-0" aria-hidden="true">
74
+ <Avatar
75
+ id={avatarId}
76
+ name={avatarName}
77
+ image={avatarImage}
78
+ size={24}
79
+ shape="circle"
80
+ />
81
+ </div>
82
+
83
+ <div className="px-4 py-3 rounded-lg bg-[#F1F0EE] h-12 flex flex-col justify-end">
84
+ <svg
85
+ aria-hidden="true"
86
+ className="block overflow-visible"
87
+ viewBox="0 0 32 8"
88
+ width="32"
89
+ height="8"
90
+ overflow="visible"
91
+ >
92
+ <Circle cx="4" index={0} />
93
+ <Circle cx="16" index={1} />
94
+ <Circle cx="28" index={2} />
95
+ </svg>
96
+ </div>
97
+ </div>
98
+ )
99
+ }
100
+
101
+ export default CustomTypingIndicator