@linktr.ee/messaging-react 3.5.0-rc-1782480113 → 3.5.0-rc-1782792813

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 (46) hide show
  1. package/dist/{Card-Dy1Qj3MR.js → Card-BWWBaNKF.js} +2 -2
  2. package/dist/{Card-Dy1Qj3MR.js.map → Card-BWWBaNKF.js.map} +1 -1
  3. package/dist/{Card-DhAhEetQ.cjs → Card-Bj4YaHav.cjs} +2 -2
  4. package/dist/{Card-DhAhEetQ.cjs.map → Card-Bj4YaHav.cjs.map} +1 -1
  5. package/dist/{Card-D34G7gvm.js → Card-BnGeMt4f.js} +2 -2
  6. package/dist/{Card-D34G7gvm.js.map → Card-BnGeMt4f.js.map} +1 -1
  7. package/dist/{Card-DVDoFfkP.js → Card-DkXzpFLA.js} +3 -3
  8. package/dist/{Card-DVDoFfkP.js.map → Card-DkXzpFLA.js.map} +1 -1
  9. package/dist/{Card-DIFgYVKx.cjs → Card-DpCwDviq.cjs} +2 -2
  10. package/dist/{Card-DIFgYVKx.cjs.map → Card-DpCwDviq.cjs.map} +1 -1
  11. package/dist/{Card-yHlBBfOt.cjs → Card-qrDaFHtJ.cjs} +2 -2
  12. package/dist/{Card-yHlBBfOt.cjs.map → Card-qrDaFHtJ.cjs.map} +1 -1
  13. package/dist/{LockedThumbnail-1S_Rjelj.js → LockedThumbnail-DJR--PAi.js} +2 -2
  14. package/dist/{LockedThumbnail-1S_Rjelj.js.map → LockedThumbnail-DJR--PAi.js.map} +1 -1
  15. package/dist/{LockedThumbnail-P2BVsLli.cjs → LockedThumbnail-DhXEk-NX.cjs} +2 -2
  16. package/dist/{LockedThumbnail-P2BVsLli.cjs.map → LockedThumbnail-DhXEk-NX.cjs.map} +1 -1
  17. package/dist/index-DzOA7Yu8.cjs +2 -0
  18. package/dist/index-DzOA7Yu8.cjs.map +1 -0
  19. package/dist/{index-CgczmQkU.js → index-ubCRSgQk.js} +1161 -1074
  20. package/dist/index-ubCRSgQk.js.map +1 -0
  21. package/dist/index.cjs +1 -1
  22. package/dist/index.d.ts +15 -15
  23. package/dist/index.js +17 -18
  24. package/package.json +2 -2
  25. package/src/components/ChannelList/ChannelList.stories.tsx +5 -3
  26. package/src/components/ChannelList/CustomChannelPreview.stories.tsx +111 -1
  27. package/src/components/ChannelList/CustomChannelPreview.test.tsx +58 -46
  28. package/src/components/ChannelList/CustomChannelPreview.tsx +12 -13
  29. package/src/components/ChannelList/index.test.tsx +25 -2
  30. package/src/components/ChannelList/index.tsx +2 -1
  31. package/src/components/ChannelView.stories.tsx +45 -12
  32. package/src/components/ChannelView.test.tsx +64 -62
  33. package/src/components/ChannelView.tsx +118 -68
  34. package/src/components/CustomMessage/index.tsx +1 -0
  35. package/src/components/CustomMessageInput/index.tsx +4 -1
  36. package/src/components/MediaMessage/index.tsx +13 -5
  37. package/src/components/ParticipantBadges.tsx +42 -0
  38. package/src/hooks/useChannelStar.ts +9 -6
  39. package/src/index.ts +0 -2
  40. package/src/stories/mocks.tsx +30 -5
  41. package/src/stream-custom-data.ts +12 -5
  42. package/src/types.ts +9 -1
  43. package/dist/index-BZjaMDgI.cjs +0 -2
  44. package/dist/index-BZjaMDgI.cjs.map +0 -1
  45. package/dist/index-CgczmQkU.js.map +0 -1
  46. package/src/components/VerifiedBadge/index.tsx +0 -25
package/dist/index.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-BZjaMDgI.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.VerifiedBadge=e.VerifiedBadge;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-DzOA7Yu8.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;
2
2
  //# sourceMappingURL=index.cjs.map
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ import { Attachment } from 'stream-chat';
2
2
  import { Channel } from 'stream-chat';
3
3
  import { ChannelFilters } from 'stream-chat';
4
4
  import { ChannelMemberResponse } from 'stream-chat';
5
+ import { ChannelPreviewUIComponentProps } from 'stream-chat-react';
5
6
  import { ChannelSort } from 'stream-chat';
6
7
  import { ComponentType } from 'react';
7
8
  import { default as default_2 } from 'react';
@@ -132,6 +133,11 @@ export declare interface ChannelListProps {
132
133
  */
133
134
  sort?: ChannelSort;
134
135
  customEmptyStateIndicator?: React.ComponentType<EmptyStateIndicatorProps>;
136
+ /**
137
+ * Custom component rendered for each channel row in the conversation list.
138
+ * Defaults to the package's built-in CustomChannelPreview.
139
+ */
140
+ channelPreview?: ComponentType<ChannelPreviewUIComponentProps>;
135
141
  renderMessagePreview?: (message: LocalMessage | undefined, defaultPreview?: string) => React.ReactNode;
136
142
  /**
137
143
  * Language code used to pick translated message text from Stream Chat i18n.
@@ -1423,17 +1429,6 @@ declare interface UseMessageVoteResult {
1423
1429
  */
1424
1430
  export declare const useMessaging: () => MessagingContextValue;
1425
1431
 
1426
- /**
1427
- * Linktree verification badge shown next to the name of a verified profile.
1428
- * Rendered when a participant's Stream user has `is_verified` set to true.
1429
- */
1430
- export declare const VerifiedBadge: default_2.FC<VerifiedBadgeProps>;
1431
-
1432
- export declare interface VerifiedBadgeProps {
1433
- className?: string;
1434
- size?: number;
1435
- }
1436
-
1437
1432
  declare interface VideoAttachmentSharedProps extends MessageAttachmentBaseProps {
1438
1433
  /** Single video — convenience for the most common case. */
1439
1434
  src?: string;
@@ -1506,12 +1501,12 @@ declare module 'stream-chat' {
1506
1501
  */
1507
1502
  chatbot_paused?: boolean;
1508
1503
  }
1509
- interface CustomUserData {
1504
+ interface CustomMemberData {
1510
1505
  /**
1511
- * Whether the user is a verified Linktree profile. When true, a
1512
- * verification badge is shown next to their name in messaging.
1506
+ * Whether the member should be identified as a customer in participant UI.
1507
+ * Stored on the channel member so it is scoped to this conversation.
1513
1508
  */
1514
- is_verified?: boolean;
1509
+ customer?: boolean;
1515
1510
  }
1516
1511
  interface CustomMessageData {
1517
1512
  /**
@@ -1530,5 +1525,10 @@ declare module 'stream-chat' {
1530
1525
  */
1531
1526
  dm_agent_system_type?: DmAgentSystemType;
1532
1527
  }
1528
+ interface CustomUserData {
1529
+ is_account?: boolean;
1530
+ is_follower?: boolean;
1531
+ username?: string;
1532
+ }
1533
1533
  }
1534
1534
 
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { a as s, 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 d, l as h, V as p, m as C, n as v, o as A, p as k, q as F, s as f, t as V, u as b, v as x, w as y, x as B, y as P, z as S, B as q, D as z, E as D } from "./index-CgczmQkU.js";
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-ubCRSgQk.js";
2
2
  export {
3
- s as ActionButton,
3
+ e as ActionButton,
4
4
  t as Avatar,
5
5
  i as ChannelEmptyState,
6
6
  o as ChannelList,
@@ -13,24 +13,23 @@ export {
13
13
  u as MediaMessage,
14
14
  L as MessageAttachment,
15
15
  c as MessageVoteButtons,
16
- d as MessagingProvider,
17
- h as MessagingShell,
18
- p as VerifiedBadge,
19
- C as buildCompactMetaLabel,
20
- v as formatFileSize,
21
- A as formatRelativeTime,
22
- k as getFileExtensionLabel,
23
- F as getMessageDisplayText,
24
- f as isLinkAttachment,
25
- V as isUuidLike,
26
- b as messageAttachmentGroupPositionFromStream,
16
+ h as MessagingProvider,
17
+ d as MessagingShell,
18
+ p as buildCompactMetaLabel,
19
+ C as formatFileSize,
20
+ v as formatRelativeTime,
21
+ A as getFileExtensionLabel,
22
+ k as getMessageDisplayText,
23
+ F as isLinkAttachment,
24
+ b as isUuidLike,
25
+ f as messageAttachmentGroupPositionFromStream,
27
26
  x as normalizeLanguageCode,
28
27
  y as resolveLinkAttachment,
29
- B as resolveMediaFromMessage,
30
- P as resolveParticipantDisplayName,
31
- S as useComposerLocked,
32
- q as useCustomMessage,
33
- z as useMessageVote,
28
+ P as resolveMediaFromMessage,
29
+ S as resolveParticipantDisplayName,
30
+ q as useComposerLocked,
31
+ z as useCustomMessage,
32
+ B as useMessageVote,
34
33
  D as useMessaging
35
34
  };
36
35
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "3.5.0-rc-1782480113",
3
+ "version": "3.5.0-rc-1782792813",
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-1782480113",
41
+ "@linktr.ee/messaging-core": "2.3.0-rc-1782792813",
42
42
  "@phosphor-icons/react": "^2.1.10"
43
43
  },
44
44
  "devDependencies": {
@@ -1,7 +1,7 @@
1
1
  import type { Meta, StoryFn } from '@storybook/react'
2
2
  import React from 'react'
3
3
 
4
- import { MockChatProvider } from '../../stories/mocks'
4
+ import { MockChatProviderWithChannels } from '../../stories/mocks'
5
5
 
6
6
  import { ChannelList } from '.'
7
7
 
@@ -21,9 +21,9 @@ const meta: Meta<ComponentProps> = {
21
21
  },
22
22
  decorators: [
23
23
  (Story) => (
24
- <MockChatProvider>
24
+ <MockChatProviderWithChannels>
25
25
  <Story />
26
- </MockChatProvider>
26
+ </MockChatProviderWithChannels>
27
27
  ),
28
28
  ],
29
29
  }
@@ -39,11 +39,13 @@ const Template: StoryFn<ComponentProps> = (args) => {
39
39
 
40
40
  export const Default: StoryFn<ComponentProps> = Template.bind({})
41
41
  Default.args = {
42
+ filters: { type: 'messaging' },
42
43
  onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
43
44
  }
44
45
 
45
46
  export const WithSelection: StoryFn<ComponentProps> = Template.bind({})
46
47
  WithSelection.args = {
48
+ filters: { type: 'messaging' },
47
49
  onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
48
50
  }
49
51
 
@@ -6,6 +6,7 @@ import {
6
6
  LocalMessage,
7
7
  StreamChat,
8
8
  } from 'stream-chat'
9
+ import type { ChannelPreviewUIComponentProps } from 'stream-chat-react'
9
10
 
10
11
  import {
11
12
  daysAgo,
@@ -14,13 +15,16 @@ import {
14
15
  now,
15
16
  secondsAgo,
16
17
  } from '../../stories/decorators/storyTime'
18
+ import { MockChatProviderWithChannels } from '../../stories/mocks'
17
19
 
18
20
  import CustomChannelPreview from './CustomChannelPreview'
19
21
 
22
+ import { ChannelList } from '.'
23
+
20
24
  type ComponentProps = React.ComponentProps<typeof CustomChannelPreview>
21
25
 
22
26
  const meta: Meta<ComponentProps> = {
23
- title: 'ChannelList/CustomChannelPreview',
27
+ title: 'ChannelList/ChannelPreview',
24
28
  component: CustomChannelPreview,
25
29
  parameters: {
26
30
  layout: 'centered',
@@ -55,6 +59,8 @@ const createMockChannel = (options: {
55
59
  thumb_url?: string
56
60
  }>
57
61
  lastMessageMetadata?: CustomMessageData['metadata']
62
+ isCustomer?: boolean
63
+ isStarred?: boolean
58
64
  }): Channel => {
59
65
  const {
60
66
  id,
@@ -66,6 +72,8 @@ const createMockChannel = (options: {
66
72
  unreadCount = 0,
67
73
  lastMessageAttachments,
68
74
  lastMessageMetadata,
75
+ isCustomer = false,
76
+ isStarred = false,
69
77
  } = options
70
78
 
71
79
  const eventListeners = new Map<string, Set<(...args: unknown[]) => void>>()
@@ -89,8 +97,12 @@ const createMockChannel = (options: {
89
97
  image: participantImage,
90
98
  },
91
99
  user_id: participantId,
100
+ ...(isCustomer ? { customer: true } : {}),
92
101
  },
93
102
  },
103
+ membership: {
104
+ pinned_at: isStarred ? now().toISOString() : null,
105
+ },
94
106
  messages:
95
107
  lastMessageText || lastMessageAttachments || lastMessageMetadata
96
108
  ? ([
@@ -249,6 +261,61 @@ ChatbotMessage.args = {
249
261
  }),
250
262
  }
251
263
 
264
+ export const ParticipantBadges: StoryFn = () => {
265
+ const channels = [
266
+ createMockChannel({
267
+ id: 'channel-customer',
268
+ participantName: 'Alice Customer',
269
+ participantId: 'participant-customer',
270
+ participantImage: 'https://i.pravatar.cc/150?img=40',
271
+ lastMessageText: 'I bought this from your Linktree',
272
+ lastMessageTime: minutesAgo(2),
273
+ isCustomer: true,
274
+ }),
275
+ createMockChannel({
276
+ id: 'channel-starred',
277
+ participantName: 'Bob Starred',
278
+ participantId: 'participant-starred',
279
+ participantImage: 'https://i.pravatar.cc/150?img=41',
280
+ lastMessageText: 'Following up on this thread',
281
+ lastMessageTime: minutesAgo(8),
282
+ isStarred: true,
283
+ }),
284
+ createMockChannel({
285
+ id: 'channel-customer-starred',
286
+ participantName: 'Carol Customer',
287
+ participantId: 'participant-customer-starred',
288
+ participantImage: 'https://i.pravatar.cc/150?img=42',
289
+ lastMessageText: 'Thanks for the quick reply',
290
+ lastMessageTime: minutesAgo(15),
291
+ isCustomer: true,
292
+ isStarred: true,
293
+ }),
294
+ ]
295
+
296
+ return (
297
+ <div className="w-[360px] bg-chalk border border-sand rounded-lg overflow-hidden">
298
+ {channels.map((channel) => (
299
+ <CustomChannelPreview
300
+ key={channel.id}
301
+ channel={channel}
302
+ onChannelSelect={(channel) => {
303
+ console.log('Channel selected:', channel.id)
304
+ }}
305
+ />
306
+ ))}
307
+ </div>
308
+ )
309
+ }
310
+ ParticipantBadges.parameters = {
311
+ docs: {
312
+ description: {
313
+ story:
314
+ 'Shows the built-in customer badge from participant.member customer data and the starred badge from the current user membership pinned_at value.',
315
+ },
316
+ },
317
+ }
318
+
252
319
  export const TipMessage: StoryFn<ComponentProps> = Template.bind({})
253
320
  TipMessage.args = {
254
321
  channel: createMockChannel({
@@ -799,3 +866,46 @@ WithLongAttachmentUrl.args = {
799
866
  }),
800
867
  }
801
868
  WithLongAttachmentUrl.parameters = skipInChromatic
869
+
870
+ const HostChannelPreview: React.FC<ChannelPreviewUIComponentProps> = ({
871
+ channel,
872
+ unread,
873
+ }) => {
874
+ const participant = Object.values(channel?.state?.members || {}).find(
875
+ (member) => member.user?.id !== channel?._client?.userID
876
+ )
877
+
878
+ return (
879
+ <div className="mx-3 mt-3 rounded-[20px] border border-[#e0dfdb] bg-white p-4 shadow-[0_4px_16px_rgba(0,0,0,0.06)]">
880
+ <div className="flex items-center justify-between">
881
+ <div className="min-w-0">
882
+ <p className="truncate text-sm font-semibold text-[#191918]">
883
+ {participant?.user?.name ?? 'Unknown visitor'}
884
+ </p>
885
+ <p className="mt-1 line-clamp-1 text-xs text-[#717070]">
886
+ Host-owned channel preview
887
+ </p>
888
+ </div>
889
+ {Boolean(unread) && (
890
+ <span className="ml-3 rounded-full bg-[#7f22fe] px-2 py-1 text-xs font-medium text-white">
891
+ {unread}
892
+ </span>
893
+ )}
894
+ </div>
895
+ </div>
896
+ )
897
+ }
898
+
899
+ export const Custom: StoryFn = () => (
900
+ <MockChatProviderWithChannels>
901
+ <div className="h-screen w-full md:w-[360px] bg-chalk">
902
+ <ChannelList
903
+ filters={{ type: 'messaging' }}
904
+ onChannelSelect={(channel) => {
905
+ console.log('Channel selected:', channel.id)
906
+ }}
907
+ channelPreview={HostChannelPreview}
908
+ />
909
+ </div>
910
+ </MockChatProviderWithChannels>
911
+ )
@@ -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
@@ -101,44 +114,6 @@ describe('CustomChannelPreview', () => {
101
114
  expect(screen.getByText('Unknown member')).toBeInTheDocument()
102
115
  })
103
116
 
104
- it('shows the verification badge when the participant is verified', () => {
105
- const channel = createMockChannel([])
106
- channel.state.members['participant-1'] = {
107
- user: { id: 'participant-1', name: 'Alice', is_verified: true },
108
- user_id: 'participant-1',
109
- }
110
-
111
- renderWithProviders(
112
- <CustomChannelPreview {...defaultProps} channel={channel} />
113
- )
114
-
115
- expect(screen.getByLabelText('Verified')).toBeInTheDocument()
116
- })
117
-
118
- it('does not show the verification badge when the participant is not verified', () => {
119
- const channel = createMockChannel([])
120
- channel.state.members['participant-1'] = {
121
- user: { id: 'participant-1', name: 'Alice', is_verified: false },
122
- user_id: 'participant-1',
123
- }
124
-
125
- renderWithProviders(
126
- <CustomChannelPreview {...defaultProps} channel={channel} />
127
- )
128
-
129
- expect(screen.queryByLabelText('Verified')).not.toBeInTheDocument()
130
- })
131
-
132
- it('does not show the verification badge when is_verified is absent', () => {
133
- const channel = createMockChannel([])
134
-
135
- renderWithProviders(
136
- <CustomChannelPreview {...defaultProps} channel={channel} />
137
- )
138
-
139
- expect(screen.queryByLabelText('Verified')).not.toBeInTheDocument()
140
- })
141
-
142
117
  it('shows the latest non-system message when the last message is a system message', () => {
143
118
  const channel = createMockChannel([
144
119
  {
@@ -291,6 +266,21 @@ describe('CustomChannelPreview', () => {
291
266
  expect(screen.getByText('No messages yet')).toBeInTheDocument()
292
267
  })
293
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
+
294
284
  it('updates starred state when the channel membership changes', () => {
295
285
  const channel = createMockChannel([])
296
286
 
@@ -299,15 +289,37 @@ describe('CustomChannelPreview', () => {
299
289
  )
300
290
 
301
291
  expect(
302
- screen.queryByRole('heading', { name: /starred conversation/i })
292
+ screen.queryByRole('img', { name: 'Starred conversation' })
303
293
  ).not.toBeInTheDocument()
304
294
 
305
295
  act(() => {
306
- channel.emitMemberUpdated({ pinned_at: new Date().toISOString() })
296
+ channel.emitMemberUpdated({
297
+ pinned_at: new Date().toISOString(),
298
+ user_id: mockUser.id,
299
+ })
307
300
  })
308
301
 
309
302
  expect(
310
- screen.getByRole('heading', { name: /starred conversation/i })
303
+ screen.getByRole('img', { name: 'Starred conversation' })
311
304
  ).toBeInTheDocument()
312
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
+ })
313
325
  })
@@ -2,13 +2,12 @@ import classNames from 'classnames'
2
2
  import React from 'react'
3
3
  import { ChannelPreviewUIComponentProps } from 'stream-chat-react'
4
4
 
5
- import { useChannelStar } from '../../hooks/useChannelStar'
6
5
  import { formatRelativeTime } from '../../utils/formatRelativeTime'
7
6
  import { getMessageDisplayText } from '../../utils/getMessageDisplayText'
8
7
  import { resolveParticipantDisplayName } from '../../utils/resolveParticipantDisplayName'
9
8
  import { Avatar } from '../Avatar'
10
9
  import { isChatbotMessage } from '../CustomMessage/MessageTag'
11
- import { VerifiedBadge } from '../VerifiedBadge'
10
+ import { ParticipantBadges } from '../ParticipantBadges'
12
11
 
13
12
  import { useChannelListContext } from './ChannelListContext'
14
13
 
@@ -49,7 +48,6 @@ const CustomChannelPreview = React.memo<ChannelPreviewUIComponentProps>(
49
48
  )
50
49
  const participantName = resolveParticipantDisplayName(participant?.user)
51
50
  const participantImage = participant?.user?.image
52
- const isParticipantVerified = participant?.user?.is_verified === true
53
51
 
54
52
  // Get last non-system message for preview
55
53
  const lastMessage = (() => {
@@ -71,11 +69,13 @@ const CustomChannelPreview = React.memo<ChannelPreviewUIComponentProps>(
71
69
  if (isTip) return displayText ? `💵 ${displayText}` : '💵 Sent a tip'
72
70
  const isPaid = lastMessage?.metadata?.custom_type === 'MESSAGE_PAID'
73
71
  if (isPaid) return displayText ? `💰 ${displayText}` : '💰 Sent a message'
74
- const isPaidAttachment = lastMessage?.metadata?.custom_type === 'MESSAGE_ATTACHMENT'
75
- if (isPaidAttachment) return displayText ? `📎 ${displayText}` : '📎 Sent an attachment'
76
-
72
+ const isPaidAttachment =
73
+ lastMessage?.metadata?.custom_type === 'MESSAGE_ATTACHMENT'
74
+ if (isPaidAttachment)
75
+ return displayText ? `📎 ${displayText}` : '📎 Sent an attachment'
76
+
77
77
  if (displayText) return displayText
78
-
78
+
79
79
  const attachment = lastMessage?.attachments?.[0]
80
80
  if (attachment) {
81
81
  // Link previews - show the actual URL
@@ -105,7 +105,6 @@ const CustomChannelPreview = React.memo<ChannelPreviewUIComponentProps>(
105
105
  const messagePreview = renderMessagePreview
106
106
  ? renderMessagePreview(lastMessage, lastMessageText)
107
107
  : `${isLastMessageFromChatbot ? '✨ ' : ''}${lastMessageText}`
108
- const isChannelStarred = useChannelStar(channel)
109
108
 
110
109
  // Use the unread prop passed by Stream Chat (reactive and updates automatically)
111
110
  const unreadCount = unread ?? 0
@@ -133,6 +132,7 @@ const CustomChannelPreview = React.memo<ChannelPreviewUIComponentProps>(
133
132
  'hover:bg-black/[0.02]': !isSelected,
134
133
  }
135
134
  )}
135
+ data-dd-privacy="mask"
136
136
  >
137
137
  <div className="flex items-start gap-4">
138
138
  {/* Avatar */}
@@ -141,7 +141,6 @@ const CustomChannelPreview = React.memo<ChannelPreviewUIComponentProps>(
141
141
  name={participantName}
142
142
  image={participantImage}
143
143
  size={48}
144
- starred={isChannelStarred}
145
144
  />
146
145
 
147
146
  {/* Content column */}
@@ -149,11 +148,11 @@ const CustomChannelPreview = React.memo<ChannelPreviewUIComponentProps>(
149
148
  {/* Name and timestamp row */}
150
149
  <div className="flex items-center justify-between gap-2">
151
150
  <h3 className="flex min-w-0 items-center gap-1 text-sm font-medium text-[#191918]">
152
- {isChannelStarred && (
153
- <span className="sr-only">Starred conversation. </span>
154
- )}
155
151
  <span className="min-w-0 truncate">{participantName}</span>
156
- {isParticipantVerified && <VerifiedBadge />}
152
+ <ParticipantBadges
153
+ channel={channel}
154
+ participant={participant}
155
+ />
157
156
  </h3>
158
157
  {lastMessageTime && (
159
158
  <span className="text-xs text-[#717070] flex-shrink-0">
@@ -7,7 +7,8 @@ import { renderWithProviders } from '../../test/utils'
7
7
  import { ChannelList } from '.'
8
8
 
9
9
  const streamChannelListMock = vi.fn()
10
- const { InfiniteScrollMock } = vi.hoisted(() => ({
10
+ const { CustomChannelPreviewMock, InfiniteScrollMock } = vi.hoisted(() => ({
11
+ CustomChannelPreviewMock: () => null,
11
12
  InfiniteScrollMock: () => null,
12
13
  }))
13
14
 
@@ -24,7 +25,7 @@ vi.mock('../../providers/MessagingProvider', () => ({
24
25
  }))
25
26
 
26
27
  vi.mock('./CustomChannelPreview', () => ({
27
- default: () => null,
28
+ default: CustomChannelPreviewMock,
28
29
  }))
29
30
 
30
31
  describe('ChannelList', () => {
@@ -55,6 +56,28 @@ describe('ChannelList', () => {
55
56
  })
56
57
  })
57
58
 
59
+ it('uses the built-in channel preview by default', () => {
60
+ renderWithProviders(<ChannelList {...defaultProps} />)
61
+
62
+ expect(streamChannelListMock).toHaveBeenCalledOnce()
63
+ expect(streamChannelListMock.mock.calls[0][0]).toMatchObject({
64
+ Preview: CustomChannelPreviewMock,
65
+ })
66
+ })
67
+
68
+ it('passes a custom channel preview through to Stream ChannelList', () => {
69
+ const ChannelPreview = () => null
70
+
71
+ renderWithProviders(
72
+ <ChannelList {...defaultProps} channelPreview={ChannelPreview} />
73
+ )
74
+
75
+ expect(streamChannelListMock).toHaveBeenCalledOnce()
76
+ expect(streamChannelListMock.mock.calls[0][0]).toMatchObject({
77
+ Preview: ChannelPreview,
78
+ })
79
+ })
80
+
58
81
  it('passes allowNewMessagesFromUnfilteredChannels through to Stream ChannelList', () => {
59
82
  const props: React.ComponentProps<typeof ChannelList> = {
60
83
  ...defaultProps,
@@ -28,6 +28,7 @@ export const ChannelList = React.memo<ChannelListProps>(
28
28
  sort = DEFAULT_SORT,
29
29
  className,
30
30
  customEmptyStateIndicator,
31
+ channelPreview,
31
32
  renderMessagePreview,
32
33
  viewerLanguage,
33
34
  }) => {
@@ -97,7 +98,7 @@ export const ChannelList = React.memo<ChannelListProps>(
97
98
  }
98
99
  channelRenderFilterFn={wrappedChannelRenderFilterFn}
99
100
  Paginator={InfiniteScroll}
100
- Preview={CustomChannelPreview}
101
+ Preview={channelPreview ?? CustomChannelPreview}
101
102
  EmptyStateIndicator={customEmptyStateIndicator}
102
103
  />
103
104
  </ChannelListProvider>