@linktr.ee/messaging-react 2.0.0 → 2.0.1-rc-1778656305

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.
Files changed (50) hide show
  1. package/dist/Card-CAC3fPjy.js +107 -0
  2. package/dist/Card-CAC3fPjy.js.map +1 -0
  3. package/dist/Card-DLUBUg_w.js +132 -0
  4. package/dist/Card-DLUBUg_w.js.map +1 -0
  5. package/dist/Card-_StSlnYh.js +163 -0
  6. package/dist/Card-_StSlnYh.js.map +1 -0
  7. package/dist/LockedThumbnail-p5RsFOug.js +220 -0
  8. package/dist/LockedThumbnail-p5RsFOug.js.map +1 -0
  9. package/dist/assets/index.css +1 -1
  10. package/dist/{index-Brz9orsI.js → index-B1h46F9x.js} +811 -772
  11. package/dist/index-B1h46F9x.js.map +1 -0
  12. package/dist/index.d.ts +87 -28
  13. package/dist/index.js +3 -3
  14. package/package.json +1 -1
  15. package/src/components/ChannelView.test.tsx +11 -0
  16. package/src/components/ChannelView.tsx +35 -32
  17. package/src/components/CustomMessage/index.tsx +2 -3
  18. package/src/components/CustomTypingIndicator/CustomTypingIndicator.stories.tsx +57 -17
  19. package/src/components/CustomTypingIndicator/CustomTypingIndicator.test.tsx +187 -0
  20. package/src/components/CustomTypingIndicator/DmAgentContext.ts +3 -0
  21. package/src/components/CustomTypingIndicator/index.tsx +101 -37
  22. package/src/components/LockedAttachment/LockedAttachment.stories.tsx +230 -89
  23. package/src/components/LockedAttachment/components/Composer/Card.tsx +221 -0
  24. package/src/components/LockedAttachment/components/Composer/index.ts +2 -0
  25. package/src/components/LockedAttachment/components/Received/Card.tsx +191 -0
  26. package/src/components/LockedAttachment/components/Received/CardActions.tsx +91 -0
  27. package/src/components/LockedAttachment/components/Received/index.ts +2 -0
  28. package/src/components/LockedAttachment/components/Sent/Card.tsx +177 -0
  29. package/src/components/LockedAttachment/components/Sent/index.ts +2 -0
  30. package/src/components/LockedAttachment/components/_shared/CardBody.tsx +94 -0
  31. package/src/components/LockedAttachment/components/_shared/GalleryThumbnail.tsx +178 -0
  32. package/src/components/LockedAttachment/components/_shared/LockBadge.tsx +39 -0
  33. package/src/components/LockedAttachment/components/_shared/LockedCardShell.tsx +36 -0
  34. package/src/components/LockedAttachment/components/_shared/LockedThumbnail.tsx +128 -0
  35. package/src/components/LockedAttachment/index.tsx +43 -12
  36. package/src/components/LockedAttachment/types.ts +17 -0
  37. package/src/components/MediaMessage/index.tsx +2 -2
  38. package/src/index.ts +6 -1
  39. package/src/styles.css +7 -0
  40. package/dist/Card-BHknCeHw.js +0 -138
  41. package/dist/Card-BHknCeHw.js.map +0 -1
  42. package/dist/Card-DT7_ms2p.js +0 -127
  43. package/dist/Card-DT7_ms2p.js.map +0 -1
  44. package/dist/index-Brz9orsI.js.map +0 -1
  45. package/src/components/LockedAttachment/components/Creator/Card.tsx +0 -210
  46. package/src/components/LockedAttachment/components/Creator/index.tsx +0 -2
  47. package/src/components/LockedAttachment/components/Visitor/Card.tsx +0 -155
  48. package/src/components/LockedAttachment/components/Visitor/CardActions.tsx +0 -62
  49. package/src/components/LockedAttachment/components/Visitor/LockBadge.tsx +0 -12
  50. package/src/components/LockedAttachment/components/Visitor/index.ts +0 -2
package/dist/index.d.ts CHANGED
@@ -241,15 +241,38 @@ export declare interface ChannelViewProps {
241
241
  sendButton?: ComponentType<any>;
242
242
  }
243
243
 
244
- export declare interface CreatorCardProps extends LockedAttachmentBaseProps {
244
+ export declare interface ComposerCardProps extends LockedAttachmentBaseProps {
245
+ /** Placeholder shown in the title slot before the composer types one. */
245
246
  placeholderTitle?: string;
247
+ /** Placeholder shown in the amount slot before one is configured. */
246
248
  placeholderAmountText?: string;
247
- isUnlocking?: boolean;
249
+ /**
250
+ * When provided, renders a dismiss X in the thumbnail corner. Called when
251
+ * the composer clicks it to remove the attachment.
252
+ */
248
253
  onDismiss?: () => void;
254
+ /** Fired the first time the composer taps the thumbnail to preview. */
249
255
  onPreviewClick?: () => void;
256
+ /**
257
+ * Lazily loads the underlying source so the composer can preview the
258
+ * attachment they're about to send. Called the first time the thumbnail is
259
+ * tapped; the returned source is cached and reused on subsequent toggles.
260
+ */
250
261
  onFetchSource?: () => Promise<LockedAttachmentSource | void>;
262
+ /**
263
+ * When provided, renders a pencil button in the body bottom-right that the
264
+ * composer can use to edit the attachment metadata (e.g. open the price /
265
+ * gallery editor). Matches the Composer "Button" instance in Figma.
266
+ */
267
+ onEditClick?: () => void;
251
268
  }
252
269
 
270
+ /**
271
+ * @deprecated Renamed to `SentCardProps`. Drafting usages (with `onDismiss`)
272
+ * should migrate to `ComposerCardProps`.
273
+ */
274
+ export declare type CreatorCardProps = SentCardProps;
275
+
253
276
  export declare const CustomMessageProvider: Provider<Partial<CustomMessageRegistry>>;
254
277
 
255
278
  export declare interface CustomMessageRegistry {
@@ -301,8 +324,11 @@ export declare function getMessageDisplayText({ message, viewerLanguage, }: {
301
324
  export declare function isLinkAttachment(a: Attachment): boolean;
302
325
 
303
326
  export declare const LockedAttachment: {
304
- Creator: (props: CreatorCardProps) => JSX_2.Element;
305
- Visitor: (props: VisitorCardProps) => JSX_2.Element;
327
+ Composer: (props: ComposerCardProps) => JSX_2.Element;
328
+ Sent: (props: SentCardProps) => JSX_2.Element;
329
+ Received: (props: ReceivedCardProps) => JSX_2.Element;
330
+ Creator: (props: SentCardProps) => JSX_2.Element;
331
+ Visitor: (props: ReceivedCardProps) => JSX_2.Element;
306
332
  };
307
333
 
308
334
  declare interface LockedAttachmentBaseProps {
@@ -312,6 +338,14 @@ declare interface LockedAttachmentBaseProps {
312
338
  detail?: string;
313
339
  amountText?: string;
314
340
  paymentStatus?: PaymentStatus;
341
+ /**
342
+ * When provided with 2+ items, the card renders as a mixed-media carousel
343
+ * (e.g. a couple of photos + a video) instead of a single thumbnail. Each
344
+ * item brings its own thumbnail and optional source so that
345
+ * `LockedAttachment.Composer` / `.Sent` / `.Received` can all share the
346
+ * same carousel chrome.
347
+ */
348
+ gallery?: LockedAttachmentGalleryItem[];
315
349
  }
316
350
 
317
351
  export declare interface LockedAttachmentContextValue {
@@ -321,6 +355,15 @@ export declare interface LockedAttachmentContextValue {
321
355
  onFetchSource?: (message: LocalMessage, channel: Channel) => Promise<LockedAttachmentSource | void>;
322
356
  }
323
357
 
358
+ export declare interface LockedAttachmentGalleryItem {
359
+ /** MIME type of this carousel item — drives the per-item play / lock affordance. */
360
+ mimeType: string;
361
+ /** Poster image used for both the locked (blurred) and unlocked preview state. */
362
+ thumbnailUrl?: string;
363
+ /** Underlying source (image or video URL) — shown only when unlocked. */
364
+ sourceUrl?: string;
365
+ }
366
+
324
367
  export declare interface LockedAttachmentSource {
325
368
  /** Proxied URL used by the media player for in-app playback. */
326
369
  sourceUrl: string;
@@ -501,10 +544,48 @@ export declare interface Participant {
501
544
  */
502
545
  declare type PaymentStatus = 'pending' | 'paid' | 'failed' | 'refunded';
503
546
 
547
+ export declare interface ReceivedCardProps extends LockedAttachmentBaseProps {
548
+ /**
549
+ * Called when the recipient clicks Unlock on an unpaid attachment.
550
+ * Use this to open a checkout flow. Omit to hide the Unlock button.
551
+ */
552
+ onUnlockClick?: () => void;
553
+ /**
554
+ * Called to fetch the attachment source — fired automatically when
555
+ * `paymentStatus` transitions to `'paid'`, or immediately on click when
556
+ * `paymentStatus` is already `'paid'`. Return a `LockedAttachmentSource`
557
+ * to unlock the card.
558
+ */
559
+ onFetchSource?: () => Promise<LockedAttachmentSource | void>;
560
+ /**
561
+ * Called when the recipient clicks Download on an unlocked card.
562
+ * Omit to hide the Download button.
563
+ */
564
+ onDownloadClick?: () => void;
565
+ /**
566
+ * When true, shows a loading spinner on the Unlock button.
567
+ * Driven by the LockedAttachmentContext (e.g. checkout in progress).
568
+ */
569
+ isUnlocking?: boolean;
570
+ }
571
+
504
572
  export declare function resolveLinkAttachment(message: LocalMessage): Attachment | undefined;
505
573
 
506
574
  export declare function resolveMediaFromMessage(message: LocalMessage): MediaMessageResolved | null;
507
575
 
576
+ export declare interface SentCardProps extends LockedAttachmentBaseProps {
577
+ /** Placeholder shown in the title slot when no title is set. */
578
+ placeholderTitle?: string;
579
+ /** Fired the first time the sender taps the thumbnail to preview their own attachment. */
580
+ onPreviewClick?: () => void;
581
+ /**
582
+ * Lazily loads the underlying source so the sender can preview the attachment.
583
+ * Called the first time the thumbnail is tapped; the returned source is cached
584
+ * and reused on subsequent toggles.
585
+ */
586
+ onFetchSource?: () => Promise<LockedAttachmentSource | void>;
587
+ }
588
+
508
589
  export declare function useCustomMessage<K extends keyof CustomMessageRegistry>(key: K): CustomMessageRegistry[K];
509
590
 
510
591
  /**
@@ -528,30 +609,8 @@ declare interface UseMessageVoteResult {
528
609
  */
529
610
  export declare const useMessaging: () => MessagingContextValue;
530
611
 
531
- export declare interface VisitorCardProps extends LockedAttachmentBaseProps {
532
- /**
533
- * Called when the visitor clicks Unlock on an unpaid attachment.
534
- * Use this to open a checkout flow. Omit to hide the Unlock button.
535
- */
536
- onUnlockClick?: () => void;
537
- /**
538
- * Called to fetch the attachment source — fired automatically when
539
- * paymentStatus transitions to 'paid', or immediately on click when
540
- * paymentStatus is already 'paid'. Return a LockedAttachmentSource to
541
- * unlock the card.
542
- */
543
- onFetchSource?: () => Promise<LockedAttachmentSource | void>;
544
- /**
545
- * Called when the visitor clicks Download on an unlocked card.
546
- * Omit to hide the Download button.
547
- */
548
- onDownloadClick?: () => void;
549
- /**
550
- * When true, shows loading dots on the Unlock button.
551
- * Driven by the LockedAttachmentContext (e.g. checkout in progress, payment processing).
552
- */
553
- isUnlocking?: boolean;
554
- }
612
+ /** @deprecated Renamed to `ReceivedCardProps`. */
613
+ export declare type VisitorCardProps = ReceivedCardProps;
555
614
 
556
615
  export declare type VoteSelection = 'up' | 'down' | null;
557
616
 
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { b as e, c as t, C as i, d as n, e as o, f as g, F as m, g as r, L as M, M as l, h as u, i as h, j as L, k as d, l as C, m as c, n as v, r as A, o as k, u as p, p as F, q as f } from "./index-Brz9orsI.js";
1
+ import { a as e, b as t, C as i, c as n, d as o, e as g, F as m, f as M, L as r, M as l, h as u, i as h, j as L, k as d, l as C, m as c, n as v, o as A, p as k, u as p, q as F, s as f } from "./index-B1h46F9x.js";
2
2
  export {
3
3
  e as ActionButton,
4
4
  t as Avatar,
@@ -7,8 +7,8 @@ export {
7
7
  o as ChannelView,
8
8
  g as CustomMessageProvider,
9
9
  m as FaqList,
10
- r as FaqListItem,
11
- M as LockedAttachment,
10
+ M as FaqListItem,
11
+ r as LockedAttachment,
12
12
  l as MediaMessage,
13
13
  u as MessageVoteButtons,
14
14
  h as MessagingProvider,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "2.0.0",
3
+ "version": "2.0.1-rc-1778656305",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,6 +28,17 @@ vi.mock('stream-chat-react', () => ({
28
28
  ),
29
29
  useMessageContext: () => ({ message: { id: 'message-1', text: 'hello' } }),
30
30
  useChannelStateContext: () => ({ channel: activeChannel }),
31
+ useChatContext: () => ({ client: { user: { id: 'visitor-1' } } }),
32
+ useTypingContext: () => ({ typing: {} }),
33
+ useAIState: () => ({ aiState: 'AI_STATE_IDLE' }),
34
+ AIStates: {
35
+ Error: 'AI_STATE_ERROR',
36
+ ExternalSources: 'AI_STATE_EXTERNAL_SOURCES',
37
+ Generating: 'AI_STATE_GENERATING',
38
+ Idle: 'AI_STATE_IDLE',
39
+ Stop: 'AI_STATE_STOP',
40
+ Thinking: 'AI_STATE_THINKING',
41
+ },
31
42
  }))
32
43
 
33
44
  vi.mock('../providers/MessagingProvider', () => ({
@@ -28,6 +28,7 @@ import { CustomMessage } from './CustomMessage'
28
28
  import { CustomMessageInput } from './CustomMessageInput'
29
29
  import { CustomSystemMessage } from './CustomSystemMessage'
30
30
  import CustomTypingIndicator from './CustomTypingIndicator'
31
+ import { DmAgentEnabledContext } from './CustomTypingIndicator/DmAgentContext'
31
32
  import { ChannelEmptyState } from './MessagingShell/ChannelEmptyState'
32
33
  import { LoadingState } from './MessagingShell/LoadingState'
33
34
 
@@ -495,38 +496,40 @@ export const ChannelView = React.memo<ChannelViewProps>(
495
496
  className
496
497
  )}
497
498
  >
498
- <Channel
499
- channel={channel}
500
- MessageSystem={CustomSystemMessage}
501
- EmptyStateIndicator={CustomChannelEmptyState}
502
- LoadingIndicator={LoadingState}
503
- DateSeparator={CustomDateSeparator}
504
- TypingIndicator={CustomTypingIndicator}
505
- doSendMessageRequest={doSendMessageRequest}
506
- {...(sendButton ? { SendButton: sendButton } : {})}
507
- >
508
- <ChannelViewInner
509
- onBack={onBack}
510
- showBackButton={showBackButton}
511
- renderMessageInputActions={renderMessageInputActions}
512
- renderConversationFooter={renderConversationFooter}
513
- onLeaveConversation={onLeaveConversation}
514
- onBlockParticipant={onBlockParticipant}
515
- CustomChannelEmptyState={CustomChannelEmptyState}
516
- showDeleteConversation={showDeleteConversation}
517
- onDeleteConversationClick={onDeleteConversationClick}
518
- onBlockParticipantClick={onBlockParticipantClick}
519
- onReportParticipantClick={onReportParticipantClick}
520
- showStarButton={showStarButton}
521
- dmAgentEnabled={dmAgentEnabled}
522
- chatbotVotingEnabled={chatbotVotingEnabled}
523
- renderChannelBanner={renderChannelBanner}
524
- customProfileContent={customProfileContent}
525
- customChannelActions={customChannelActions}
526
- renderMessage={renderMessage}
527
- viewerLanguage={viewerLanguage}
528
- />
529
- </Channel>
499
+ <DmAgentEnabledContext.Provider value={dmAgentEnabled ?? false}>
500
+ <Channel
501
+ channel={channel}
502
+ MessageSystem={CustomSystemMessage}
503
+ EmptyStateIndicator={CustomChannelEmptyState}
504
+ LoadingIndicator={LoadingState}
505
+ DateSeparator={CustomDateSeparator}
506
+ TypingIndicator={CustomTypingIndicator}
507
+ doSendMessageRequest={doSendMessageRequest}
508
+ {...(sendButton ? { SendButton: sendButton } : {})}
509
+ >
510
+ <ChannelViewInner
511
+ onBack={onBack}
512
+ showBackButton={showBackButton}
513
+ renderMessageInputActions={renderMessageInputActions}
514
+ renderConversationFooter={renderConversationFooter}
515
+ onLeaveConversation={onLeaveConversation}
516
+ onBlockParticipant={onBlockParticipant}
517
+ CustomChannelEmptyState={CustomChannelEmptyState}
518
+ showDeleteConversation={showDeleteConversation}
519
+ onDeleteConversationClick={onDeleteConversationClick}
520
+ onBlockParticipantClick={onBlockParticipantClick}
521
+ onReportParticipantClick={onReportParticipantClick}
522
+ showStarButton={showStarButton}
523
+ dmAgentEnabled={dmAgentEnabled}
524
+ chatbotVotingEnabled={chatbotVotingEnabled}
525
+ renderChannelBanner={renderChannelBanner}
526
+ customProfileContent={customProfileContent}
527
+ customChannelActions={customChannelActions}
528
+ renderMessage={renderMessage}
529
+ viewerLanguage={viewerLanguage}
530
+ />
531
+ </Channel>
532
+ </DmAgentEnabledContext.Provider>
530
533
  </div>
531
534
  )
532
535
  }
@@ -227,21 +227,20 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
227
227
  {isAttachment ? (
228
228
  <div className="str-chat__message-bubble-wrapper">
229
229
  {isMine ? (
230
- <LockedAttachment.Creator
230
+ <LockedAttachment.Sent
231
231
  title={message.metadata?.attachment_title}
232
232
  mimeType={message.metadata?.attachment_mime_type}
233
233
  thumbnailUrl={message.metadata?.attachment_thumbnail}
234
234
  amountText={message.metadata?.amount_text}
235
235
  detail={message.metadata?.attachment_detail}
236
236
  paymentStatus={message.metadata?.payment_status}
237
- isUnlocking={isUnlocking(message.id)}
238
237
  onPreviewClick={() => onUnlockClick?.(message, channel)}
239
238
  onFetchSource={async () =>
240
239
  await onFetchSource?.(message, channel)
241
240
  }
242
241
  />
243
242
  ) : (
244
- <LockedAttachment.Visitor
243
+ <LockedAttachment.Received
245
244
  title={message.metadata?.attachment_title}
246
245
  mimeType={message.metadata?.attachment_mime_type}
247
246
  thumbnailUrl={message.metadata?.attachment_thumbnail}
@@ -2,16 +2,21 @@ import type { Meta, StoryFn } from '@storybook/react'
2
2
  import React from 'react'
3
3
  import type { Event } from 'stream-chat'
4
4
  import {
5
+ AIStates,
5
6
  ChannelStateProvider,
6
7
  ChatProvider,
7
8
  TypingProvider,
8
9
  } from 'stream-chat-react'
9
10
 
11
+ import { DmAgentEnabledContext } from './DmAgentContext'
12
+
10
13
  import CustomTypingIndicator from '.'
11
14
 
12
15
  type StoryProps = {
13
16
  typingEventsEnabled?: boolean
14
17
  typing?: Record<string, Event>
18
+ aiState?: string
19
+ dmAgentEnabled?: boolean
15
20
  }
16
21
 
17
22
  const currentUser = {
@@ -33,10 +38,38 @@ const defaultTyping: Record<string, Event> = {
33
38
  } as Event,
34
39
  }
35
40
 
41
+ type ListenerMap = Record<string, (event: { ai_state: string; cid: string }) => void>
42
+
43
+ const createMockChannel = ({ aiState }: { aiState?: string }) => {
44
+ const listeners: ListenerMap = {}
45
+ const channel = {
46
+ cid: 'messaging:test',
47
+ state: {
48
+ members: {
49
+ [currentUser.id]: { user: currentUser },
50
+ [typingUser.id]: { user: typingUser },
51
+ },
52
+ },
53
+ on(eventType: string, handler: ListenerMap[string]) {
54
+ listeners[eventType] = handler
55
+ // Fire the requested AI state synchronously so the hook picks it up.
56
+ if (eventType === 'ai_indicator.update' && aiState) {
57
+ handler({ ai_state: aiState, cid: 'messaging:test' })
58
+ }
59
+ return { unsubscribe: () => {} }
60
+ },
61
+ }
62
+ return channel
63
+ }
64
+
36
65
  const StoryWrapper: React.FC<StoryProps> = ({
37
66
  typingEventsEnabled = true,
38
- typing = defaultTyping,
67
+ typing = {},
68
+ aiState,
69
+ dmAgentEnabled = true,
39
70
  }) => {
71
+ const channel = createMockChannel({ aiState })
72
+
40
73
  const chatContextValue = {
41
74
  client: {
42
75
  user: currentUser,
@@ -57,18 +90,7 @@ const StoryWrapper: React.FC<StoryProps> = ({
57
90
  }
58
91
 
59
92
  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
- },
93
+ channel,
72
94
  channelCapabilities: {},
73
95
  channelConfig: {
74
96
  typing_events: typingEventsEnabled,
@@ -85,9 +107,11 @@ const StoryWrapper: React.FC<StoryProps> = ({
85
107
  <ChatProvider value={chatContextValue as never}>
86
108
  <ChannelStateProvider value={channelStateValue as never}>
87
109
  <TypingProvider value={{ typing }}>
88
- <div className="relative h-20 w-[200px] bg-[#f4f4f4] p-3">
89
- <CustomTypingIndicator />
90
- </div>
110
+ <DmAgentEnabledContext.Provider value={dmAgentEnabled}>
111
+ <div className="relative h-20 w-[200px] bg-[#f4f4f4] p-3">
112
+ <CustomTypingIndicator />
113
+ </div>
114
+ </DmAgentEnabledContext.Provider>
91
115
  </TypingProvider>
92
116
  </ChannelStateProvider>
93
117
  </ChatProvider>
@@ -104,7 +128,9 @@ const meta: Meta<StoryProps> = {
104
128
  export default meta
105
129
 
106
130
  export const Default: StoryFn<StoryProps> = (args) => <StoryWrapper {...args} />
107
- Default.args = {}
131
+ Default.args = {
132
+ typing: defaultTyping,
133
+ }
108
134
 
109
135
  export const HiddenWhenNoTyping: StoryFn<StoryProps> = (args) => (
110
136
  <StoryWrapper {...args} />
@@ -112,3 +138,17 @@ export const HiddenWhenNoTyping: StoryFn<StoryProps> = (args) => (
112
138
  HiddenWhenNoTyping.args = {
113
139
  typing: {},
114
140
  }
141
+
142
+ export const AiAgentThinking: StoryFn<StoryProps> = (args) => (
143
+ <StoryWrapper {...args} />
144
+ )
145
+ AiAgentThinking.args = {
146
+ aiState: AIStates.Thinking,
147
+ }
148
+
149
+ export const AiAgentGenerating: StoryFn<StoryProps> = (args) => (
150
+ <StoryWrapper {...args} />
151
+ )
152
+ AiAgentGenerating.args = {
153
+ aiState: AIStates.Generating,
154
+ }
@@ -0,0 +1,187 @@
1
+ import React from 'react'
2
+ import type { Event } from 'stream-chat'
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import { renderWithProviders, screen } from '../../test/utils'
6
+
7
+ import { DmAgentEnabledContext } from './DmAgentContext'
8
+
9
+ const visitor = { id: 'visitor-1', name: 'Visitor' }
10
+ const agent = { id: 'creator-1', name: 'Creator', image: 'agent.png' }
11
+
12
+ let typingContext: { typing: Record<string, Event> } = { typing: {} }
13
+ let aiStateContext: { aiState: string } = { aiState: 'AI_STATE_IDLE' }
14
+ let channelStateContext: {
15
+ channel: {
16
+ state: { members: Record<string, { user: typeof visitor | typeof agent }> }
17
+ }
18
+ channelConfig: { typing_events: boolean }
19
+ thread: undefined
20
+ } = {
21
+ channel: {
22
+ state: {
23
+ members: {
24
+ [visitor.id]: { user: visitor },
25
+ [agent.id]: { user: agent },
26
+ },
27
+ },
28
+ },
29
+ channelConfig: { typing_events: true },
30
+ thread: undefined,
31
+ }
32
+
33
+ vi.mock('stream-chat-react', () => ({
34
+ AIStates: {
35
+ Error: 'AI_STATE_ERROR',
36
+ ExternalSources: 'AI_STATE_EXTERNAL_SOURCES',
37
+ Generating: 'AI_STATE_GENERATING',
38
+ Idle: 'AI_STATE_IDLE',
39
+ Stop: 'AI_STATE_STOP',
40
+ Thinking: 'AI_STATE_THINKING',
41
+ },
42
+ useAIState: () => aiStateContext,
43
+ useChannelStateContext: () => channelStateContext,
44
+ useChatContext: () => ({ client: { user: visitor } }),
45
+ useTypingContext: () => typingContext,
46
+ }))
47
+
48
+ vi.mock('../Avatar', () => ({
49
+ Avatar: ({ name, id }: { name: string; id: string }) => (
50
+ <div data-testid="avatar" data-id={id}>
51
+ {name}
52
+ </div>
53
+ ),
54
+ }))
55
+
56
+ const importIndicator = async () => (await import('.')).default
57
+
58
+ const renderIndicator = async (dmAgentEnabled = true) => {
59
+ const CustomTypingIndicator = await importIndicator()
60
+ return renderWithProviders(
61
+ <DmAgentEnabledContext.Provider value={dmAgentEnabled}>
62
+ <CustomTypingIndicator />
63
+ </DmAgentEnabledContext.Provider>
64
+ )
65
+ }
66
+
67
+ describe('CustomTypingIndicator', () => {
68
+ beforeEach(() => {
69
+ typingContext = { typing: {} }
70
+ aiStateContext = { aiState: 'AI_STATE_IDLE' }
71
+ channelStateContext = {
72
+ channel: {
73
+ state: {
74
+ members: {
75
+ [visitor.id]: { user: visitor },
76
+ [agent.id]: { user: agent },
77
+ },
78
+ },
79
+ },
80
+ channelConfig: { typing_events: true },
81
+ thread: undefined,
82
+ }
83
+ })
84
+
85
+ it('renders nothing when idle and no typers', async () => {
86
+ await renderIndicator()
87
+ expect(screen.queryByTestId('typing-indicator')).toBeNull()
88
+ expect(screen.queryByTestId('typing-indicator-ai')).toBeNull()
89
+ })
90
+
91
+ it('renders the human typing bubble when someone else is typing', async () => {
92
+ typingContext = {
93
+ typing: {
94
+ [agent.id]: {
95
+ type: 'typing.start',
96
+ user: agent,
97
+ parent_id: undefined,
98
+ } as Event,
99
+ },
100
+ }
101
+ await renderIndicator()
102
+ expect(screen.getByTestId('typing-indicator')).toBeInTheDocument()
103
+ expect(screen.getByTestId('avatar')).toHaveAttribute('data-id', agent.id)
104
+ })
105
+
106
+ it('hides the human typing bubble when typing_events is disabled', async () => {
107
+ channelStateContext.channelConfig.typing_events = false
108
+ typingContext = {
109
+ typing: {
110
+ [agent.id]: {
111
+ type: 'typing.start',
112
+ user: agent,
113
+ parent_id: undefined,
114
+ } as Event,
115
+ },
116
+ }
117
+ await renderIndicator()
118
+ expect(screen.queryByTestId('typing-indicator')).toBeNull()
119
+ })
120
+
121
+ it('renders the AI bubble when the agent is thinking', async () => {
122
+ aiStateContext = { aiState: 'AI_STATE_THINKING' }
123
+ await renderIndicator()
124
+ expect(screen.getByTestId('typing-indicator-ai')).toBeInTheDocument()
125
+ expect(screen.getByTestId('avatar')).toHaveAttribute('data-id', agent.id)
126
+ })
127
+
128
+ it('renders the AI bubble when the agent is generating', async () => {
129
+ aiStateContext = { aiState: 'AI_STATE_GENERATING' }
130
+ await renderIndicator()
131
+ expect(screen.getByTestId('typing-indicator-ai')).toBeInTheDocument()
132
+ })
133
+
134
+ it('renders the AI bubble when the agent is checking external sources', async () => {
135
+ aiStateContext = { aiState: 'AI_STATE_EXTERNAL_SOURCES' }
136
+ await renderIndicator()
137
+ expect(screen.getByTestId('typing-indicator-ai')).toBeInTheDocument()
138
+ })
139
+
140
+ it('does not render the AI bubble inside a thread list', async () => {
141
+ aiStateContext = { aiState: 'AI_STATE_GENERATING' }
142
+ const CustomTypingIndicator = await importIndicator()
143
+ renderWithProviders(
144
+ <DmAgentEnabledContext.Provider value={true}>
145
+ <CustomTypingIndicator threadList />
146
+ </DmAgentEnabledContext.Provider>
147
+ )
148
+ expect(screen.queryByTestId('typing-indicator-ai')).toBeNull()
149
+ })
150
+
151
+ it('renders the AI bubble even when typing_events is disabled', async () => {
152
+ channelStateContext.channelConfig.typing_events = false
153
+ aiStateContext = { aiState: 'AI_STATE_THINKING' }
154
+ await renderIndicator()
155
+ expect(screen.getByTestId('typing-indicator-ai')).toBeInTheDocument()
156
+ })
157
+
158
+ it('falls back to a default avatar id when no other channel member exists', async () => {
159
+ channelStateContext.channel.state.members = {
160
+ [visitor.id]: { user: visitor },
161
+ }
162
+ aiStateContext = { aiState: 'AI_STATE_GENERATING' }
163
+ await renderIndicator()
164
+ expect(screen.getByTestId('typing-indicator-ai')).toBeInTheDocument()
165
+ expect(screen.getByTestId('avatar')).toHaveAttribute('data-id', 'ai-agent')
166
+ })
167
+
168
+ it('does not render the AI bubble when dmAgentEnabled is false', async () => {
169
+ aiStateContext = { aiState: 'AI_STATE_GENERATING' }
170
+ await renderIndicator(false)
171
+ expect(screen.queryByTestId('typing-indicator-ai')).toBeNull()
172
+ })
173
+
174
+ it('still renders the human bubble when dmAgentEnabled is false', async () => {
175
+ typingContext = {
176
+ typing: {
177
+ [agent.id]: {
178
+ type: 'typing.start',
179
+ user: agent,
180
+ parent_id: undefined,
181
+ } as Event,
182
+ },
183
+ }
184
+ await renderIndicator(false)
185
+ expect(screen.getByTestId('typing-indicator')).toBeInTheDocument()
186
+ })
187
+ })
@@ -0,0 +1,3 @@
1
+ import { createContext } from 'react'
2
+
3
+ export const DmAgentEnabledContext = createContext<boolean>(false)