@linktr.ee/messaging-react 3.4.2-rc-1782395074 → 3.4.2-rc-1782453771

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 (41) hide show
  1. package/dist/{Card-mpVcQ8-W.js → Card-52LnCirC.js} +3 -3
  2. package/dist/{Card-mpVcQ8-W.js.map → Card-52LnCirC.js.map} +1 -1
  3. package/dist/{Card-V-bNUxcw.cjs → Card-BIa-47Ne.cjs} +2 -2
  4. package/dist/{Card-V-bNUxcw.cjs.map → Card-BIa-47Ne.cjs.map} +1 -1
  5. package/dist/{Card-CphU_jG-.js → Card-BaXLsSK2.js} +2 -2
  6. package/dist/{Card-CphU_jG-.js.map → Card-BaXLsSK2.js.map} +1 -1
  7. package/dist/{Card-DFy_ZvFH.cjs → Card-BkVj6kGN.cjs} +2 -2
  8. package/dist/{Card-DFy_ZvFH.cjs.map → Card-BkVj6kGN.cjs.map} +1 -1
  9. package/dist/{Card-DxroYNrS.cjs → Card-CcTyYY9N.cjs} +2 -2
  10. package/dist/{Card-DxroYNrS.cjs.map → Card-CcTyYY9N.cjs.map} +1 -1
  11. package/dist/{Card-BKalDyyu.js → Card-ql9pImRa.js} +2 -2
  12. package/dist/{Card-BKalDyyu.js.map → Card-ql9pImRa.js.map} +1 -1
  13. package/dist/{LockedThumbnail-BD7qtcso.js → LockedThumbnail-C3YOnS-o.js} +2 -2
  14. package/dist/{LockedThumbnail-BD7qtcso.js.map → LockedThumbnail-C3YOnS-o.js.map} +1 -1
  15. package/dist/{LockedThumbnail-B3W7upHs.cjs → LockedThumbnail-D0ASm7LP.cjs} +2 -2
  16. package/dist/{LockedThumbnail-B3W7upHs.cjs.map → LockedThumbnail-D0ASm7LP.cjs.map} +1 -1
  17. package/dist/{index-CjwQ2Hj3.js → index-CbWEp3Zs.js} +1136 -1093
  18. package/dist/index-CbWEp3Zs.js.map +1 -0
  19. package/dist/index-DkqM2z_j.cjs +2 -0
  20. package/dist/index-DkqM2z_j.cjs.map +1 -0
  21. package/dist/index.cjs +1 -1
  22. package/dist/index.d.ts +13 -25
  23. package/dist/index.js +13 -14
  24. package/package.json +2 -2
  25. package/src/components/ChannelList/CustomChannelPreview.stories.tsx +63 -0
  26. package/src/components/ChannelList/CustomChannelPreview.test.tsx +58 -8
  27. package/src/components/ChannelList/CustomChannelPreview.tsx +7 -10
  28. package/src/components/ChannelView.stories.tsx +38 -7
  29. package/src/components/ChannelView.test.tsx +29 -15
  30. package/src/components/ChannelView.tsx +19 -9
  31. package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +0 -34
  32. package/src/components/CustomMessageInput/index.tsx +1 -23
  33. package/src/components/MessagingShell/index.tsx +0 -2
  34. package/src/components/ParticipantBadges.tsx +39 -0
  35. package/src/hooks/useChannelStar.ts +9 -6
  36. package/src/index.ts +0 -1
  37. package/src/stream-custom-data.ts +16 -1
  38. package/src/types.ts +0 -18
  39. package/dist/index-CjwQ2Hj3.js.map +0 -1
  40. package/dist/index-tI-4cxV1.cjs +0 -2
  41. package/dist/index-tI-4cxV1.cjs.map +0 -1
package/dist/index.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-tI-4cxV1.cjs");exports.ActionButton=e.ActionButton;exports.Avatar=e.Avatar;exports.ChannelEmptyState=e.ChannelEmptyState;exports.ChannelList=e.ChannelList;exports.ChannelView=e.ChannelView;exports.CustomMessageProvider=e.CustomMessageProvider;exports.FaqList=e.FaqList;exports.FaqListItem=e.FaqListItem;exports.LinkAttachment=e.LinkAttachment;exports.LockedAttachment=e.LockedAttachment;exports.MediaMessage=e.MediaMessage;exports.MessageAttachment=e.MessageAttachment;exports.MessageVoteButtons=e.MessageVoteButtons;exports.MessagingProvider=e.MessagingProvider;exports.MessagingShell=e.MessagingShell;exports.buildCompactMetaLabel=e.buildCompactMetaLabel;exports.formatFileSize=e.formatFileSize;exports.formatRelativeTime=e.formatRelativeTime;exports.getFileExtensionLabel=e.getFileExtensionLabel;exports.getMessageDisplayText=e.getMessageDisplayText;exports.isLinkAttachment=e.isLinkAttachment;exports.isUuidLike=e.isUuidLike;exports.messageAttachmentGroupPositionFromStream=e.bubbleGroupPositionFromStream;exports.normalizeLanguageCode=e.normalizeLanguageCode;exports.resolveLinkAttachment=e.resolveLinkAttachment;exports.resolveMediaFromMessage=e.resolveMediaFromMessage;exports.resolveParticipantDisplayName=e.resolveParticipantDisplayName;exports.useComposerLocked=e.useComposerLocked;exports.useCustomMessage=e.useCustomMessage;exports.useMessageVote=e.useMessageVote;exports.useMessaging=e.useMessaging;
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-DkqM2z_j.cjs");exports.ActionButton=e.ActionButton;exports.Avatar=e.Avatar;exports.ChannelEmptyState=e.ChannelEmptyState;exports.ChannelList=e.ChannelList;exports.ChannelView=e.ChannelView;exports.CustomMessageProvider=e.CustomMessageProvider;exports.FaqList=e.FaqList;exports.FaqListItem=e.FaqListItem;exports.LinkAttachment=e.LinkAttachment;exports.LockedAttachment=e.LockedAttachment;exports.MediaMessage=e.MediaMessage;exports.MessageAttachment=e.MessageAttachment;exports.MessageVoteButtons=e.MessageVoteButtons;exports.MessagingProvider=e.MessagingProvider;exports.MessagingShell=e.MessagingShell;exports.buildCompactMetaLabel=e.buildCompactMetaLabel;exports.formatFileSize=e.formatFileSize;exports.formatRelativeTime=e.formatRelativeTime;exports.getFileExtensionLabel=e.getFileExtensionLabel;exports.getMessageDisplayText=e.getMessageDisplayText;exports.isLinkAttachment=e.isLinkAttachment;exports.isUuidLike=e.isUuidLike;exports.messageAttachmentGroupPositionFromStream=e.bubbleGroupPositionFromStream;exports.normalizeLanguageCode=e.normalizeLanguageCode;exports.resolveLinkAttachment=e.resolveLinkAttachment;exports.resolveMediaFromMessage=e.resolveMediaFromMessage;exports.resolveParticipantDisplayName=e.resolveParticipantDisplayName;exports.useCustomMessage=e.useCustomMessage;exports.useMessageVote=e.useMessageVote;exports.useMessaging=e.useMessaging;
2
2
  //# sourceMappingURL=index.cjs.map
package/dist/index.d.ts CHANGED
@@ -149,7 +149,7 @@ export declare const ChannelView: default_2.NamedExoticComponent<ChannelViewProp
149
149
  * Props that MessagingShell passes through to ChannelView.
150
150
  * ChannelViewProps is the source of truth for these props.
151
151
  */
152
- declare type ChannelViewPassthroughProps = Pick<ChannelViewProps, 'renderMessageInputActions' | 'renderConversationFooter' | 'CustomChannelEmptyState' | 'onBlockParticipantClick' | 'onReportParticipantClick' | 'dmAgentEnabled' | 'onMessageSent' | 'chatbotVotingEnabled' | 'viewerLanguage' | 'renderHeaderTitleBadges' | 'renderChannelBanner' | 'customChannelActions' | 'renderChannelActions' | 'onParticipantNameClick' | 'renderMessage' | 'onMessageLinkClick' | 'showChannelInfo' | 'composerInput'>;
152
+ declare type ChannelViewPassthroughProps = Pick<ChannelViewProps, 'renderMessageInputActions' | 'renderConversationFooter' | 'CustomChannelEmptyState' | 'onBlockParticipantClick' | 'onReportParticipantClick' | 'dmAgentEnabled' | 'onMessageSent' | 'chatbotVotingEnabled' | 'viewerLanguage' | 'renderHeaderTitleBadges' | 'renderChannelBanner' | 'customChannelActions' | 'renderChannelActions' | 'onParticipantNameClick' | 'renderMessage' | 'onMessageLinkClick' | 'showChannelInfo'>;
153
153
 
154
154
  /**
155
155
  * ChannelView component props
@@ -351,22 +351,6 @@ export declare interface ChannelViewProps {
351
351
  * Use this to render staged paid or media attachments inside the input.
352
352
  */
353
353
  attachmentPreviewList?: ComponentType;
354
- /**
355
- * Replace the entire inner composer — the textarea + send arrangement — with
356
- * a host-owned component. Passed to Stream's `<MessageInput>` as the `Input`
357
- * component, so it runs inside the message-input context and reuses the
358
- * library's send pipeline (`doSendMessageRequest`, message metadata, DM-agent
359
- * suppression), attachments, and previews via the Stream hooks
360
- * (`useMessageInputContext`, `useMessageComposerHasSendableData`,
361
- * `TextareaComposer`, …). Use it when a surface needs a different composer
362
- * layout (e.g. a full-width textarea above a toolbar row) than the default.
363
- *
364
- * The outer wrapper — frozen handling and the `renderMessageInputActions` /
365
- * `renderMessageInputFooter` slots — is unchanged. Read the exported
366
- * `useComposerLocked()` hook to honour the frozen state. Defaults to the
367
- * built-in composer when omitted.
368
- */
369
- composerInput?: ComponentType;
370
354
  /**
371
355
  * Fired when the participant's name in the channel header is clicked. When
372
356
  * provided, the name renders as a button with a trailing caret (an affordance
@@ -1392,14 +1376,6 @@ export declare interface SentCardProps extends LockedAttachmentBaseProps {
1392
1376
  onFetchSource?: () => Promise<LockedAttachmentSource | void>;
1393
1377
  }
1394
1378
 
1395
- /**
1396
- * Read the composer's locked (frozen-channel) state from inside a custom
1397
- * composer supplied via `composerInput`. Returns true when the channel is
1398
- * frozen; mirror the default composer by rendering the textarea read-only and
1399
- * disabling the send control.
1400
- */
1401
- export declare const useComposerLocked: () => boolean;
1402
-
1403
1379
  export declare function useCustomMessage<K extends keyof CustomMessageRegistry>(key: K): CustomMessageRegistry[K];
1404
1380
 
1405
1381
  /**
@@ -1495,6 +1471,13 @@ declare module 'stream-chat' {
1495
1471
  */
1496
1472
  chatbot_paused?: boolean;
1497
1473
  }
1474
+ interface CustomMemberData {
1475
+ /**
1476
+ * Whether the member should be identified as a customer in participant UI.
1477
+ * Stored on the channel member so it is scoped to this conversation.
1478
+ */
1479
+ customer?: boolean;
1480
+ }
1498
1481
  interface CustomMessageData {
1499
1482
  /**
1500
1483
  * When true, hides the date timestamp in system messages.
@@ -1512,5 +1495,10 @@ declare module 'stream-chat' {
1512
1495
  */
1513
1496
  dm_agent_system_type?: DmAgentSystemType;
1514
1497
  }
1498
+ interface CustomUserData {
1499
+ is_account?: boolean;
1500
+ is_follower?: boolean;
1501
+ username?: string;
1502
+ }
1515
1503
  }
1516
1504
 
package/dist/index.js CHANGED
@@ -1,14 +1,14 @@
1
- import { a as e, b as t, C as i, c as o, d as n, e as m, F as g, f as r, L as l, h as M, M as u, i as L, j as c, k as h, l as d, m as p, n as C, o as v, p as A, q as k, s as F, t as b, u as f, v as x, w as y, x as P, y as S, z as q, B as z, D as B, E as D } from "./index-CjwQ2Hj3.js";
1
+ import { a as e, b as t, C as i, c as n, d as o, e as m, F as g, f as l, L as r, h as M, M as u, i as L, j as c, k as h, l as d, m as p, n as v, o as A, p as C, q as F, s as k, t as b, u as f, v as x, w as y, x as P, y as S, z as q, B as z, D as B } from "./index-CbWEp3Zs.js";
2
2
  export {
3
3
  e as ActionButton,
4
4
  t as Avatar,
5
5
  i as ChannelEmptyState,
6
- o as ChannelList,
7
- n as ChannelView,
6
+ n as ChannelList,
7
+ o as ChannelView,
8
8
  m as CustomMessageProvider,
9
9
  g as FaqList,
10
- r as FaqListItem,
11
- l as LinkAttachment,
10
+ l as FaqListItem,
11
+ r as LinkAttachment,
12
12
  M as LockedAttachment,
13
13
  u as MediaMessage,
14
14
  L as MessageAttachment,
@@ -16,20 +16,19 @@ export {
16
16
  h as MessagingProvider,
17
17
  d as MessagingShell,
18
18
  p as buildCompactMetaLabel,
19
- C as formatFileSize,
20
- v as formatRelativeTime,
21
- A as getFileExtensionLabel,
22
- k as getMessageDisplayText,
23
- F as isLinkAttachment,
19
+ v as formatFileSize,
20
+ A as formatRelativeTime,
21
+ C as getFileExtensionLabel,
22
+ F as getMessageDisplayText,
23
+ k as isLinkAttachment,
24
24
  b as isUuidLike,
25
25
  f as messageAttachmentGroupPositionFromStream,
26
26
  x as normalizeLanguageCode,
27
27
  y as resolveLinkAttachment,
28
28
  P as resolveMediaFromMessage,
29
29
  S as resolveParticipantDisplayName,
30
- q as useComposerLocked,
31
- z as useCustomMessage,
32
- B as useMessageVote,
33
- D as useMessaging
30
+ q as useCustomMessage,
31
+ z as useMessageVote,
32
+ B as useMessaging
34
33
  };
35
34
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "3.4.2-rc-1782395074",
3
+ "version": "3.4.2-rc-1782453771",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@linktr.ee/component-library": "11.8.6",
41
- "@linktr.ee/messaging-core": "2.3.0-rc-1782395074",
41
+ "@linktr.ee/messaging-core": "2.3.0-rc-1782453771",
42
42
  "@phosphor-icons/react": "^2.1.10"
43
43
  },
44
44
  "devDependencies": {
@@ -55,6 +55,8 @@ const createMockChannel = (options: {
55
55
  thumb_url?: string
56
56
  }>
57
57
  lastMessageMetadata?: CustomMessageData['metadata']
58
+ isCustomer?: boolean
59
+ isStarred?: boolean
58
60
  }): Channel => {
59
61
  const {
60
62
  id,
@@ -66,6 +68,8 @@ const createMockChannel = (options: {
66
68
  unreadCount = 0,
67
69
  lastMessageAttachments,
68
70
  lastMessageMetadata,
71
+ isCustomer = false,
72
+ isStarred = false,
69
73
  } = options
70
74
 
71
75
  const eventListeners = new Map<string, Set<(...args: unknown[]) => void>>()
@@ -89,8 +93,12 @@ const createMockChannel = (options: {
89
93
  image: participantImage,
90
94
  },
91
95
  user_id: participantId,
96
+ ...(isCustomer ? { customer: true } : {}),
92
97
  },
93
98
  },
99
+ membership: {
100
+ pinned_at: isStarred ? now().toISOString() : null,
101
+ },
94
102
  messages:
95
103
  lastMessageText || lastMessageAttachments || lastMessageMetadata
96
104
  ? ([
@@ -249,6 +257,61 @@ ChatbotMessage.args = {
249
257
  }),
250
258
  }
251
259
 
260
+ export const ParticipantBadges: StoryFn = () => {
261
+ const channels = [
262
+ createMockChannel({
263
+ id: 'channel-customer',
264
+ participantName: 'Alice Customer',
265
+ participantId: 'participant-customer',
266
+ participantImage: 'https://i.pravatar.cc/150?img=40',
267
+ lastMessageText: 'I bought this from your Linktree',
268
+ lastMessageTime: minutesAgo(2),
269
+ isCustomer: true,
270
+ }),
271
+ createMockChannel({
272
+ id: 'channel-starred',
273
+ participantName: 'Bob Starred',
274
+ participantId: 'participant-starred',
275
+ participantImage: 'https://i.pravatar.cc/150?img=41',
276
+ lastMessageText: 'Following up on this thread',
277
+ lastMessageTime: minutesAgo(8),
278
+ isStarred: true,
279
+ }),
280
+ createMockChannel({
281
+ id: 'channel-customer-starred',
282
+ participantName: 'Carol Customer',
283
+ participantId: 'participant-customer-starred',
284
+ participantImage: 'https://i.pravatar.cc/150?img=42',
285
+ lastMessageText: 'Thanks for the quick reply',
286
+ lastMessageTime: minutesAgo(15),
287
+ isCustomer: true,
288
+ isStarred: true,
289
+ }),
290
+ ]
291
+
292
+ return (
293
+ <div className="w-[360px] bg-chalk border border-sand rounded-lg overflow-hidden">
294
+ {channels.map((channel) => (
295
+ <CustomChannelPreview
296
+ key={channel.id}
297
+ channel={channel}
298
+ onChannelSelect={(channel) => {
299
+ console.log('Channel selected:', channel.id)
300
+ }}
301
+ />
302
+ ))}
303
+ </div>
304
+ )
305
+ }
306
+ ParticipantBadges.parameters = {
307
+ docs: {
308
+ description: {
309
+ story:
310
+ 'Shows the built-in customer badge from participant.member customer data and the starred badge from the current user membership pinned_at value.',
311
+ },
312
+ },
313
+ }
314
+
252
315
  export const TipMessage: StoryFn<ComponentProps> = Template.bind({})
253
316
  TipMessage.args = {
254
317
  channel: createMockChannel({
@@ -12,12 +12,15 @@ const mockUser = {
12
12
  }
13
13
 
14
14
  type MockChannel = Channel & {
15
- emitMemberUpdated: (member?: { pinned_at?: string | null }) => void
15
+ emitMemberUpdated: (member?: {
16
+ pinned_at?: string | null
17
+ user_id?: string
18
+ }) => void
16
19
  }
17
20
 
18
21
  const createMockChannel = (messages: Partial<LocalMessage>[]): MockChannel => {
19
22
  const memberUpdatedListeners = new Set<
20
- (event: { member?: { pinned_at?: string | null } }) => void
23
+ (event: { member?: { pinned_at?: string | null; user_id?: string } }) => void
21
24
  >()
22
25
 
23
26
  return {
@@ -38,24 +41,34 @@ const createMockChannel = (messages: Partial<LocalMessage>[]): MockChannel => {
38
41
  on: vi.fn(
39
42
  (
40
43
  eventName: string,
41
- listener: (event: { member?: { pinned_at?: string | null } }) => void
44
+ listener: (event: {
45
+ member?: { pinned_at?: string | null; user_id?: string }
46
+ }) => void
42
47
  ) => {
43
48
  if (eventName === 'member.updated') {
44
49
  memberUpdatedListeners.add(listener)
45
50
  }
51
+ return {
52
+ unsubscribe: () => memberUpdatedListeners.delete(listener),
53
+ }
46
54
  }
47
55
  ),
48
56
  off: vi.fn(
49
57
  (
50
58
  eventName: string,
51
- listener: (event: { member?: { pinned_at?: string | null } }) => void
59
+ listener: (event: {
60
+ member?: { pinned_at?: string | null; user_id?: string }
61
+ }) => void
52
62
  ) => {
53
63
  if (eventName === 'member.updated') {
54
64
  memberUpdatedListeners.delete(listener)
55
65
  }
56
66
  }
57
67
  ),
58
- emitMemberUpdated: (member?: { pinned_at?: string | null }) => {
68
+ emitMemberUpdated: (member?: {
69
+ pinned_at?: string | null
70
+ user_id?: string
71
+ }) => {
59
72
  memberUpdatedListeners.forEach((listener) => listener({ member }))
60
73
  },
61
74
  } as unknown as MockChannel
@@ -253,6 +266,21 @@ describe('CustomChannelPreview', () => {
253
266
  expect(screen.getByText('No messages yet')).toBeInTheDocument()
254
267
  })
255
268
 
269
+ it('shows a customer badge from participant member data', () => {
270
+ const channel = createMockChannel([])
271
+ channel.state.members['participant-1'] = {
272
+ user: { id: 'participant-1', name: 'Alice' },
273
+ user_id: 'participant-1',
274
+ customer: true,
275
+ }
276
+
277
+ renderWithProviders(
278
+ <CustomChannelPreview {...defaultProps} channel={channel} />
279
+ )
280
+
281
+ expect(screen.getByRole('img', { name: 'Customer' })).toBeInTheDocument()
282
+ })
283
+
256
284
  it('updates starred state when the channel membership changes', () => {
257
285
  const channel = createMockChannel([])
258
286
 
@@ -261,15 +289,37 @@ describe('CustomChannelPreview', () => {
261
289
  )
262
290
 
263
291
  expect(
264
- screen.queryByRole('heading', { name: /starred conversation/i })
292
+ screen.queryByRole('img', { name: 'Starred conversation' })
265
293
  ).not.toBeInTheDocument()
266
294
 
267
295
  act(() => {
268
- channel.emitMemberUpdated({ pinned_at: new Date().toISOString() })
296
+ channel.emitMemberUpdated({
297
+ pinned_at: new Date().toISOString(),
298
+ user_id: mockUser.id,
299
+ })
269
300
  })
270
301
 
271
302
  expect(
272
- screen.getByRole('heading', { name: /starred conversation/i })
303
+ screen.getByRole('img', { name: 'Starred conversation' })
273
304
  ).toBeInTheDocument()
274
305
  })
306
+
307
+ it('ignores starred updates for other members', () => {
308
+ const channel = createMockChannel([])
309
+
310
+ renderWithProviders(
311
+ <CustomChannelPreview {...defaultProps} channel={channel} />
312
+ )
313
+
314
+ act(() => {
315
+ channel.emitMemberUpdated({
316
+ pinned_at: new Date().toISOString(),
317
+ user_id: 'participant-1',
318
+ })
319
+ })
320
+
321
+ expect(
322
+ screen.queryByRole('img', { name: 'Starred conversation' })
323
+ ).not.toBeInTheDocument()
324
+ })
275
325
  })
@@ -8,6 +8,7 @@ import { getMessageDisplayText } from '../../utils/getMessageDisplayText'
8
8
  import { resolveParticipantDisplayName } from '../../utils/resolveParticipantDisplayName'
9
9
  import { Avatar } from '../Avatar'
10
10
  import { isChatbotMessage } from '../CustomMessage/MessageTag'
11
+ import { ParticipantBadges } from '../ParticipantBadges'
11
12
 
12
13
  import { useChannelListContext } from './ChannelListContext'
13
14
 
@@ -139,22 +140,18 @@ const CustomChannelPreview = React.memo<ChannelPreviewUIComponentProps>(
139
140
  name={participantName}
140
141
  image={participantImage}
141
142
  size={48}
142
- starred={isChannelStarred}
143
143
  />
144
144
 
145
145
  {/* Content column */}
146
146
  <div className="flex-1 min-w-0 flex flex-col gap-1">
147
147
  {/* Name and timestamp row */}
148
148
  <div className="flex items-center justify-between gap-2">
149
- <h3
150
- className={classNames(
151
- 'text-sm font-medium truncate text-[#191918]'
152
- )}
153
- >
154
- {isChannelStarred && (
155
- <span className="sr-only">Starred conversation. </span>
156
- )}
157
- {participantName}
149
+ <h3 className="flex min-w-0 items-center gap-1 text-sm font-medium text-[#191918]">
150
+ <span className="min-w-0 truncate">{participantName}</span>
151
+ <ParticipantBadges
152
+ participant={participant}
153
+ isStarred={isChannelStarred}
154
+ />
158
155
  </h3>
159
156
  {lastMessageTime && (
160
157
  <span className="text-xs text-[#717070] flex-shrink-0">
@@ -41,7 +41,14 @@ const createMockChannel = async (
41
41
  client: StreamChat,
42
42
  hasMessages = true,
43
43
  followerStatus?: string | boolean,
44
- isFrozen = false
44
+ isFrozen = false,
45
+ {
46
+ isCustomer = false,
47
+ isStarred = false,
48
+ }: {
49
+ isCustomer?: boolean
50
+ isStarred?: boolean
51
+ } = {}
45
52
  ) => {
46
53
  const participant = mockParticipants[0]
47
54
 
@@ -142,8 +149,12 @@ const createMockChannel = async (
142
149
  user_id: participant.id,
143
150
  role: 'member',
144
151
  is_account: true,
152
+ ...(isCustomer ? { customer: true } : {}),
145
153
  },
146
154
  }
155
+ channel.state.membership = {
156
+ pinned_at: isStarred ? now().toISOString() : null,
157
+ } as ChannelMemberResponse
147
158
  return {
148
159
  channel: channelData,
149
160
  members: [],
@@ -171,6 +182,8 @@ const createMockChannel = async (
171
182
  type TemplateProps = ComponentProps & {
172
183
  followerStatus?: string | boolean
173
184
  isFrozen?: boolean
185
+ isCustomer?: boolean
186
+ isStarred?: boolean
174
187
  typingUser?: {
175
188
  id: string
176
189
  name?: string
@@ -182,6 +195,8 @@ const Template: StoryFn<TemplateProps> = (args) => {
182
195
  const {
183
196
  followerStatus,
184
197
  isFrozen = false,
198
+ isCustomer = false,
199
+ isStarred = false,
185
200
  typingUser,
186
201
  ...channelViewProps
187
202
  } = args
@@ -197,12 +212,13 @@ const Template: StoryFn<TemplateProps> = (args) => {
197
212
  const [channel, setChannel] = React.useState<ChannelType | null>(null)
198
213
 
199
214
  useEffect(() => {
200
- createMockChannel(client, true, followerStatus, isFrozen).then(
201
- (mockChannel) => {
202
- setChannel(mockChannel)
203
- }
204
- )
205
- }, [client, followerStatus, isFrozen])
215
+ createMockChannel(client, true, followerStatus, isFrozen, {
216
+ isCustomer,
217
+ isStarred,
218
+ }).then((mockChannel) => {
219
+ setChannel(mockChannel)
220
+ })
221
+ }, [client, followerStatus, isCustomer, isFrozen, isStarred])
206
222
 
207
223
  useEffect(() => {
208
224
  if (!channel || !typingUser) {
@@ -307,6 +323,21 @@ WithHeaderTitleBadges.parameters = {
307
323
  },
308
324
  }
309
325
 
326
+ export const ParticipantBadges: StoryFn<TemplateProps> = Template.bind({})
327
+ ParticipantBadges.args = {
328
+ showBackButton: false,
329
+ isCustomer: true,
330
+ isStarred: true,
331
+ }
332
+ ParticipantBadges.parameters = {
333
+ docs: {
334
+ description: {
335
+ story:
336
+ 'Shows the built-in customer badge from participant.customer and the starred badge from the current user membership pinned_at value in the channel header.',
337
+ },
338
+ },
339
+ }
340
+
310
341
  export const RestrictedOfficialChannel: StoryFn<TemplateProps> = Template.bind(
311
342
  {}
312
343
  )
@@ -98,9 +98,11 @@ const createChannel = ({
98
98
  username: undefined as string | undefined,
99
99
  is_account: true,
100
100
  },
101
+ otherMemberCustomer = false,
101
102
  }: {
102
103
  currentUserId?: 'visitor-1' | 'linker-1'
103
104
  currentUserRole?: 'owner' | 'member'
105
+ otherMemberCustomer?: boolean
104
106
  otherUser?: {
105
107
  id: string
106
108
  name?: string
@@ -133,6 +135,7 @@ const createChannel = ({
133
135
  otherUser.is_account ?? currentUserId === 'visitor-1',
134
136
  },
135
137
  role: currentUserRole === 'owner' ? 'member' : 'owner',
138
+ customer: otherMemberCustomer,
136
139
  },
137
140
  },
138
141
  membership: {},
@@ -287,38 +290,49 @@ describe('ChannelView', () => {
287
290
  expect(screen.queryByTestId('conversation-footer')).not.toBeInTheDocument()
288
291
  })
289
292
 
290
- it('does not pass starred to avatar when showStarButton is false', () => {
293
+ it('does not pass starred to avatar when channel is starred', () => {
291
294
  mockIsStarred = true
292
295
  const channel = createChannel()
293
296
 
294
297
  renderWithProviders(
295
- <ChannelView channel={channel} showStarButton={false} />
298
+ <ChannelView channel={channel} showStarButton={true} />
296
299
  )
297
300
 
298
301
  expect(avatarRenderCalls.length).toBeGreaterThan(0)
299
- expect(avatarRenderCalls.every((call) => call.starred === false)).toBe(true)
302
+ expect(avatarRenderCalls.every((call) => !('starred' in call))).toBe(true)
300
303
  })
301
304
 
302
- it('passes starred to avatar when showStarButton is true and channel is starred', () => {
303
- mockIsStarred = true
304
- const channel = createChannel()
305
-
305
+ it('renders built-in customer badges from participant member data', () => {
306
306
  renderWithProviders(
307
- <ChannelView channel={channel} showStarButton={true} />
307
+ <ChannelView
308
+ channel={createChannel({ otherMemberCustomer: true })}
309
+ renderHeaderTitleBadges={() => (
310
+ <span data-testid="custom-header-badge">VIP</span>
311
+ )}
312
+ />
308
313
  )
309
314
 
310
- expect(avatarRenderCalls.length).toBeGreaterThan(0)
311
- expect(avatarRenderCalls.every((call) => call.starred === true)).toBe(true)
315
+ const customerBadges = screen.getAllByRole('img', { name: 'Customer' })
316
+ const customBadges = screen.getAllByTestId('custom-header-badge')
317
+
318
+ expect(customerBadges.length).toBeGreaterThan(0)
319
+ expect(customBadges.length).toBe(customerBadges.length)
320
+ customerBadges.forEach((badge, index) => {
321
+ expect(
322
+ badge.compareDocumentPosition(customBadges[index]) &
323
+ Node.DOCUMENT_POSITION_FOLLOWING
324
+ ).toBeTruthy()
325
+ })
312
326
  })
313
327
 
314
- it('does not pass starred to avatar by default (showStarButton defaults to false)', () => {
328
+ it('renders built-in starred badges from channel star state', () => {
315
329
  mockIsStarred = true
316
- const channel = createChannel()
317
330
 
318
- renderWithProviders(<ChannelView channel={channel} />)
331
+ renderWithProviders(<ChannelView channel={createChannel()} />)
319
332
 
320
- expect(avatarRenderCalls.length).toBeGreaterThan(0)
321
- expect(avatarRenderCalls.every((call) => call.starred === false)).toBe(true)
333
+ expect(
334
+ screen.getAllByRole('img', { name: 'Starred conversation' }).length
335
+ ).toBeGreaterThan(0)
322
336
  })
323
337
 
324
338
  it('passes dmAgentEnabled to avatar when dmAgentEnabled is true for visitor view', () => {