@linktr.ee/messaging-react 3.4.1 → 3.4.2-rc-1782453401

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 (36) hide show
  1. package/dist/{Card-BKd0E-B9.js → Card-52LnCirC.js} +3 -3
  2. package/dist/{Card-BKd0E-B9.js.map → Card-52LnCirC.js.map} +1 -1
  3. package/dist/{Card-DaalkoQW.cjs → Card-BIa-47Ne.cjs} +2 -2
  4. package/dist/{Card-DaalkoQW.cjs.map → Card-BIa-47Ne.cjs.map} +1 -1
  5. package/dist/{Card-XVTz8IYz.js → Card-BaXLsSK2.js} +2 -2
  6. package/dist/{Card-XVTz8IYz.js.map → Card-BaXLsSK2.js.map} +1 -1
  7. package/dist/{Card-CdaZxDEx.cjs → Card-BkVj6kGN.cjs} +2 -2
  8. package/dist/{Card-CdaZxDEx.cjs.map → Card-BkVj6kGN.cjs.map} +1 -1
  9. package/dist/{Card-DXWp0uwi.cjs → Card-CcTyYY9N.cjs} +2 -2
  10. package/dist/{Card-DXWp0uwi.cjs.map → Card-CcTyYY9N.cjs.map} +1 -1
  11. package/dist/{Card-CrTBu2I6.js → Card-ql9pImRa.js} +2 -2
  12. package/dist/{Card-CrTBu2I6.js.map → Card-ql9pImRa.js.map} +1 -1
  13. package/dist/{LockedThumbnail-D_44b-Uy.js → LockedThumbnail-C3YOnS-o.js} +2 -2
  14. package/dist/{LockedThumbnail-D_44b-Uy.js.map → LockedThumbnail-C3YOnS-o.js.map} +1 -1
  15. package/dist/{LockedThumbnail-D3Xwdb5_.cjs → LockedThumbnail-D0ASm7LP.cjs} +2 -2
  16. package/dist/{LockedThumbnail-D3Xwdb5_.cjs.map → LockedThumbnail-D0ASm7LP.cjs.map} +1 -1
  17. package/dist/{index-DPGZRLRM.js → index-CbWEp3Zs.js} +1172 -1121
  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 +12 -0
  23. package/dist/index.js +1 -1
  24. package/package.json +2 -2
  25. package/src/components/ChannelList/CustomChannelPreview.stories.tsx +63 -0
  26. package/src/components/ChannelList/CustomChannelPreview.test.tsx +56 -6
  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 -4
  31. package/src/components/ParticipantBadges.tsx +39 -0
  32. package/src/hooks/useChannelStar.ts +9 -6
  33. package/src/stream-custom-data.ts +16 -1
  34. package/dist/index-Cds2w5zi.cjs +0 -2
  35. package/dist/index-Cds2w5zi.cjs.map +0 -1
  36. package/dist/index-DPGZRLRM.js.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-Cds2w5zi.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;
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
@@ -1471,6 +1471,13 @@ declare module 'stream-chat' {
1471
1471
  */
1472
1472
  chatbot_paused?: boolean;
1473
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
+ }
1474
1481
  interface CustomMessageData {
1475
1482
  /**
1476
1483
  * When true, hides the date timestamp in system messages.
@@ -1488,5 +1495,10 @@ declare module 'stream-chat' {
1488
1495
  */
1489
1496
  dm_agent_system_type?: DmAgentSystemType;
1490
1497
  }
1498
+ interface CustomUserData {
1499
+ is_account?: boolean;
1500
+ is_follower?: boolean;
1501
+ username?: string;
1502
+ }
1491
1503
  }
1492
1504
 
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
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-DPGZRLRM.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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "3.4.1",
3
+ "version": "3.4.2-rc-1782453401",
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.2.0",
41
+ "@linktr.ee/messaging-core": "2.3.0-rc-1782453401",
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
 
@@ -265,11 +293,33 @@ describe('CustomChannelPreview', () => {
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
303
  screen.getByRole('heading', { name: /starred conversation/i })
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('heading', { name: /starred conversation/i })
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', () => {
@@ -32,6 +32,7 @@ import CustomTypingIndicator from './CustomTypingIndicator'
32
32
  import { DmAgentEnabledContext } from './CustomTypingIndicator/DmAgentContext'
33
33
  import { ChannelEmptyState } from './MessagingShell/ChannelEmptyState'
34
34
  import { LoadingState } from './MessagingShell/LoadingState'
35
+ import { ParticipantBadges } from './ParticipantBadges'
35
36
 
36
37
  const ICON_BTN_CLASS =
37
38
  'size-10 rounded-full hover:bg-[#E5E4E1] flex items-center justify-center transition-colors duration-150 focus-ring'
@@ -136,7 +137,6 @@ const CustomChannelHeader: React.FC<{
136
137
  id={participant?.user?.id || channel.id || 'unknown'}
137
138
  name={participantName}
138
139
  image={participantImage}
139
- starred={showStarButton && isStarred}
140
140
  dmAgentEnabled={dmAgentEnabled}
141
141
  size={48}
142
142
  />
@@ -148,6 +148,10 @@ const CustomChannelHeader: React.FC<{
148
148
  className="flex max-w-full items-center gap-0.5 rounded text-center text-xs font-medium text-black/90 transition-opacity hover:opacity-70"
149
149
  >
150
150
  <span className="min-w-0 truncate">{participantName}</span>
151
+ <ParticipantBadges
152
+ participant={participant}
153
+ isStarred={isStarred}
154
+ />
151
155
  {headerTitleBadges && (
152
156
  <span className="shrink-0">{headerTitleBadges}</span>
153
157
  )}
@@ -156,6 +160,10 @@ const CustomChannelHeader: React.FC<{
156
160
  ) : (
157
161
  <p className="flex max-w-full items-center justify-center gap-1 text-center text-xs font-medium text-black/90">
158
162
  <span className="min-w-0 truncate">{participantName}</span>
163
+ <ParticipantBadges
164
+ participant={participant}
165
+ isStarred={isStarred}
166
+ />
159
167
  {headerTitleBadges && (
160
168
  <span className="shrink-0">{headerTitleBadges}</span>
161
169
  )}
@@ -228,7 +236,6 @@ const CustomChannelHeader: React.FC<{
228
236
  id={participant?.user?.id || channel.id || 'unknown'}
229
237
  name={participantName}
230
238
  image={participantImage}
231
- starred={showStarButton && isStarred}
232
239
  dmAgentEnabled={dmAgentEnabled}
233
240
  size={48}
234
241
  />
@@ -241,6 +248,10 @@ const CustomChannelHeader: React.FC<{
241
248
  className="flex min-w-0 max-w-full items-center gap-1 rounded font-medium text-black/90 transition-opacity hover:opacity-70"
242
249
  >
243
250
  <span className="min-w-0 truncate">{participantName}</span>
251
+ <ParticipantBadges
252
+ participant={participant}
253
+ isStarred={isStarred}
254
+ />
244
255
  {headerTitleBadges && (
245
256
  <span className="shrink-0">{headerTitleBadges}</span>
246
257
  )}
@@ -249,6 +260,10 @@ const CustomChannelHeader: React.FC<{
249
260
  ) : (
250
261
  <h1 className="flex min-w-0 items-center gap-1 font-medium text-black/90">
251
262
  <span className="min-w-0 truncate">{participantName}</span>
263
+ <ParticipantBadges
264
+ participant={participant}
265
+ isStarred={isStarred}
266
+ />
252
267
  {headerTitleBadges && (
253
268
  <span className="shrink-0">{headerTitleBadges}</span>
254
269
  )}
@@ -388,10 +403,10 @@ const ChannelViewInner: React.FC<{
388
403
  }, [channel._client?.userID, channel.state?.members])
389
404
 
390
405
  const currentUserIsAccount =
391
- (currentMember?.user as { is_account?: boolean } | undefined)?.is_account ??
406
+ currentMember?.user?.is_account ??
392
407
  (currentMember as { is_account?: boolean } | undefined)?.is_account
393
408
  const participantIsAccount =
394
- (participant?.user as { is_account?: boolean } | undefined)?.is_account ??
409
+ participant?.user?.is_account ??
395
410
  (participant as { is_account?: boolean } | undefined)?.is_account
396
411
 
397
412
  const showDmAgentHeader =
@@ -0,0 +1,39 @@
1
+ import { CurrencyDollarIcon, StarIcon } from '@phosphor-icons/react'
2
+ import React from 'react'
3
+ import type { ChannelMemberResponse } from 'stream-chat'
4
+
5
+ export const ParticipantBadges: React.FC<{
6
+ participant?: ChannelMemberResponse
7
+ isStarred?: boolean
8
+ }> = ({ participant, isStarred = false }) => {
9
+ const isCustomer = participant?.customer === true
10
+
11
+ if (!isCustomer && !isStarred) return null
12
+
13
+ return (
14
+ <span className="inline-flex shrink-0 items-center gap-1">
15
+ {isCustomer && (
16
+ <span
17
+ role="img"
18
+ aria-label="Customer"
19
+ className="inline-flex size-4 items-center justify-center rounded-full bg-[#22C55E] text-white"
20
+ >
21
+ <CurrencyDollarIcon
22
+ aria-hidden="true"
23
+ className="size-3"
24
+ weight="bold"
25
+ />
26
+ </span>
27
+ )}
28
+ {isStarred && (
29
+ <span
30
+ role="img"
31
+ aria-label="Starred conversation"
32
+ className="inline-flex size-4 items-center justify-center rounded-full bg-[#F6C343] text-white"
33
+ >
34
+ <StarIcon aria-hidden="true" className="size-3" weight="fill" />
35
+ </span>
36
+ )}
37
+ </span>
38
+ )
39
+ }
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useState } from 'react'
2
- import { Channel, Event } from 'stream-chat'
2
+ import type { Channel, Event } from 'stream-chat'
3
3
 
4
4
  export const useChannelStar = (channel?: Channel) => {
5
5
  const [isChannelStarred, setIsChannelStarred] = useState(
@@ -15,15 +15,18 @@ export const useChannelStar = (channel?: Channel) => {
15
15
  setIsChannelStarred(!!channel.state.membership?.pinned_at)
16
16
 
17
17
  const handleMemberUpdate = (event: Event) => {
18
- setIsChannelStarred(
19
- event?.member ? !!event.member.pinned_at : !!channel.state.membership?.pinned_at
20
- )
18
+ const currentUserId = channel._client?.userID
19
+ const memberUserId = event.member?.user_id ?? event.member?.user?.id
20
+
21
+ if (!currentUserId || memberUserId !== currentUserId) return
22
+
23
+ setIsChannelStarred(!!event.member?.pinned_at)
21
24
  }
22
25
 
23
- channel.on('member.updated', handleMemberUpdate)
26
+ const subscription = channel.on('member.updated', handleMemberUpdate)
24
27
 
25
28
  return () => {
26
- channel.off('member.updated', handleMemberUpdate)
29
+ subscription.unsubscribe()
27
30
  }
28
31
  }, [channel])
29
32
 
@@ -2,7 +2,8 @@
2
2
  * Stream Chat custom data type augmentation.
3
3
  *
4
4
  * This file extends Stream Chat's type system by augmenting their empty
5
- * CustomMessageData and CustomChannelData interfaces with our custom properties.
5
+ * CustomMessageData, CustomChannelData, CustomMemberData, and CustomUserData
6
+ * interfaces with our custom properties.
6
7
  *
7
8
  * @see https://getstream.io/chat/docs/sdk/react/guides/typescript_and_custom_data_types/
8
9
  *
@@ -98,6 +99,14 @@ declare module 'stream-chat' {
98
99
  chatbot_paused?: boolean
99
100
  }
100
101
 
102
+ interface CustomMemberData {
103
+ /**
104
+ * Whether the member should be identified as a customer in participant UI.
105
+ * Stored on the channel member so it is scoped to this conversation.
106
+ */
107
+ customer?: boolean
108
+ }
109
+
101
110
  interface CustomMessageData {
102
111
  /**
103
112
  * When true, hides the date timestamp in system messages.
@@ -115,4 +124,10 @@ declare module 'stream-chat' {
115
124
  */
116
125
  dm_agent_system_type?: DmAgentSystemType
117
126
  }
127
+
128
+ interface CustomUserData {
129
+ is_account?: boolean
130
+ is_follower?: boolean
131
+ username?: string
132
+ }
118
133
  }