@linktr.ee/messaging-react 2.2.0 → 2.2.2-rc-1779314025

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-EKxCn56j.js → Card-CAliTKdt.js} +3 -3
  2. package/dist/{Card-EKxCn56j.js.map → Card-CAliTKdt.js.map} +1 -1
  3. package/dist/Card-CRMpOj0f.cjs +2 -0
  4. package/dist/Card-CRMpOj0f.cjs.map +1 -0
  5. package/dist/{Card-ChR37pLZ.js → Card-Ca5PnHml.js} +2 -2
  6. package/dist/{Card-ChR37pLZ.js.map → Card-Ca5PnHml.js.map} +1 -1
  7. package/dist/{Card-BdTueeyk.js → Card-Dz2v3fXf.js} +2 -2
  8. package/dist/{Card-BdTueeyk.js.map → Card-Dz2v3fXf.js.map} +1 -1
  9. package/dist/Card-b41LWND_.cjs +2 -0
  10. package/dist/Card-b41LWND_.cjs.map +1 -0
  11. package/dist/Card-vEkarkVD.cjs +2 -0
  12. package/dist/Card-vEkarkVD.cjs.map +1 -0
  13. package/dist/{LockedThumbnail-B16qP3eH.js → LockedThumbnail-BGz0NIQh.js} +2 -2
  14. package/dist/{LockedThumbnail-B16qP3eH.js.map → LockedThumbnail-BGz0NIQh.js.map} +1 -1
  15. package/dist/LockedThumbnail-JuPkpHeX.cjs +2 -0
  16. package/dist/LockedThumbnail-JuPkpHeX.cjs.map +1 -0
  17. package/dist/index-CctUDJSJ.cjs +2 -0
  18. package/dist/index-CctUDJSJ.cjs.map +1 -0
  19. package/dist/{index-Dn7BC9xK.js → index-D55UTfgC.js} +1307 -1264
  20. package/dist/index-D55UTfgC.js.map +1 -0
  21. package/dist/index.cjs +2 -0
  22. package/dist/index.cjs.map +1 -0
  23. package/dist/index.d.ts +20 -0
  24. package/dist/index.js +18 -16
  25. package/package.json +4 -3
  26. package/src/components/ChannelInfoDialog/ChannelInfoDialog.test.tsx +44 -0
  27. package/src/components/ChannelInfoDialog/index.tsx +5 -1
  28. package/src/components/ChannelList/CustomChannelPreview.test.tsx +35 -0
  29. package/src/components/ChannelList/CustomChannelPreview.tsx +2 -1
  30. package/src/components/ChannelView.test.tsx +65 -3
  31. package/src/components/ChannelView.tsx +53 -16
  32. package/src/index.ts +5 -0
  33. package/src/types.ts +9 -0
  34. package/src/utils/resolveParticipantDisplayName.test.ts +73 -0
  35. package/src/utils/resolveParticipantDisplayName.ts +40 -0
  36. package/dist/index-Dn7BC9xK.js.map +0 -1
package/dist/index.cjs ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-CctUDJSJ.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
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","sources":[],"sourcesContent":[],"names":[],"mappings":""}
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 { ChannelListProps as ChannelListProps_2 } from 'stream-chat-react';
5
+ import { ChannelMemberResponse } from 'stream-chat';
5
6
  import { ChannelSort } from 'stream-chat';
6
7
  import { ComponentType } from 'react';
7
8
  import { default as default_2 } from 'react';
@@ -239,6 +240,11 @@ export declare interface ChannelViewProps {
239
240
  * Falls back to message.text when no matching translation exists.
240
241
  */
241
242
  viewerLanguage?: string;
243
+ /**
244
+ * Resolves the display label for the other channel participant.
245
+ * Defaults to name, then username, then "Unknown member" (never user.id).
246
+ */
247
+ getParticipantDisplayName?: (participant: ChannelMemberResponse | undefined) => string;
242
248
  /**
243
249
  * Custom render function for a banner/card component that renders
244
250
  * between the channel header and message list.
@@ -458,6 +464,8 @@ declare type ImageLoadingMode = 'lazy' | 'eager';
458
464
 
459
465
  export declare function isLinkAttachment(a: Attachment): boolean;
460
466
 
467
+ export declare function isUuidLike(value: string): boolean;
468
+
461
469
  /**
462
470
  * Link previews (1P / 3P Link Apps) shown in the chat thread. Mirrors
463
471
  * the `LockedAttachment` API — render `LinkAttachment.Composer` while
@@ -1167,6 +1175,12 @@ export declare interface Participant {
1167
1175
  metadata?: Record<string, unknown>;
1168
1176
  }
1169
1177
 
1178
+ export declare type ParticipantDisplayUser = {
1179
+ id?: string;
1180
+ name?: string | null;
1181
+ username?: string | null;
1182
+ };
1183
+
1170
1184
  /**
1171
1185
  * Message metadata for paid messaging and chatbot flows.
1172
1186
  * Used to identify message types and payment status.
@@ -1232,6 +1246,12 @@ export declare function resolveLinkAttachment(message: LocalMessage): Attachment
1232
1246
 
1233
1247
  export declare function resolveMediaFromMessage(message: LocalMessage): MediaMessageResolved | null;
1234
1248
 
1249
+ /**
1250
+ * Resolves a human-readable participant label from Stream user fields.
1251
+ * Never falls back to user.id. UUID-like names are treated as missing.
1252
+ */
1253
+ export declare function resolveParticipantDisplayName(user?: ParticipantDisplayUser | null): string;
1254
+
1235
1255
  export declare interface SentCardProps extends LockedAttachmentBaseProps {
1236
1256
  /** Placeholder shown in the title slot when no title is set. */
1237
1257
  placeholderTitle?: string;
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 h, i as u, j as L, k as c, l as d, m as A, n as C, o as v, p as F, q as p, s as k, t as b, u as f, v as x, w as S, x as q, y, z } from "./index-Dn7BC9xK.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-D55UTfgC.js";
2
2
  export {
3
3
  e as ActionButton,
4
4
  t as Avatar,
@@ -10,23 +10,25 @@ export {
10
10
  l as FaqListItem,
11
11
  r as LinkAttachment,
12
12
  M as LockedAttachment,
13
- h as MediaMessage,
14
- u as MessageAttachment,
15
- L as MessageVoteButtons,
16
- c as MessagingProvider,
13
+ u as MediaMessage,
14
+ L as MessageAttachment,
15
+ c as MessageVoteButtons,
16
+ h as MessagingProvider,
17
17
  d as MessagingShell,
18
- A as buildCompactMetaLabel,
19
- C as formatFileSize,
20
- v as formatRelativeTime,
21
- F as getFileExtensionLabel,
22
- p as getMessageDisplayText,
18
+ p as buildCompactMetaLabel,
19
+ v as formatFileSize,
20
+ A as formatRelativeTime,
21
+ C as getFileExtensionLabel,
22
+ F as getMessageDisplayText,
23
23
  k as isLinkAttachment,
24
- b as messageAttachmentGroupPositionFromStream,
25
- f as normalizeLanguageCode,
26
- x as resolveLinkAttachment,
27
- S as resolveMediaFromMessage,
24
+ b as isUuidLike,
25
+ f as messageAttachmentGroupPositionFromStream,
26
+ x as normalizeLanguageCode,
27
+ y as resolveLinkAttachment,
28
+ P as resolveMediaFromMessage,
29
+ S as resolveParticipantDisplayName,
28
30
  q as useCustomMessage,
29
- y as useMessageVote,
30
- z as useMessaging
31
+ z as useMessageVote,
32
+ B as useMessaging
31
33
  };
32
34
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,15 +1,16 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "2.2.0",
3
+ "version": "2.2.2-rc-1779314025",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
- "main": "dist/index.js",
6
+ "main": "dist/index.cjs",
7
7
  "module": "dist/index.js",
8
8
  "types": "dist/index.d.ts",
9
9
  "exports": {
10
10
  ".": {
11
11
  "types": "./dist/index.d.ts",
12
- "import": "./dist/index.js"
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
13
14
  },
14
15
  "./styles.css": "./dist/assets/index.css"
15
16
  },
@@ -98,6 +98,50 @@ describe('ChannelInfoDialog', () => {
98
98
  expect(nameEl).toBeInTheDocument()
99
99
  })
100
100
 
101
+ it('uses participantDisplayName when provided', () => {
102
+ renderWithProviders(
103
+ <ChannelInfoDialog
104
+ {...defaultProps()}
105
+ participantDisplayName="Custom Label"
106
+ />
107
+ )
108
+
109
+ expect(screen.getByText('Custom Label', { selector: 'p' })).toBeInTheDocument()
110
+ })
111
+
112
+ it('falls back to username when name is UUID-like', () => {
113
+ renderWithProviders(
114
+ <ChannelInfoDialog
115
+ {...defaultProps()}
116
+ participant={createParticipant({
117
+ name: 'a1b2c3d4-e5f6-4789-a012-3456789abcde',
118
+ username: 'linkeruser',
119
+ })}
120
+ />
121
+ )
122
+
123
+ expect(screen.getByText('linkeruser', { selector: 'p' })).toBeInTheDocument()
124
+ })
125
+
126
+ it('shows Unknown member when name matches user id', () => {
127
+ renderWithProviders(
128
+ <ChannelInfoDialog
129
+ {...defaultProps()}
130
+ participant={{
131
+ user: {
132
+ id: 'opaque-user-id',
133
+ name: 'opaque-user-id',
134
+ },
135
+ role: 'member',
136
+ } as unknown as ChannelMemberResponse}
137
+ />
138
+ )
139
+
140
+ expect(
141
+ screen.getByText('Unknown member', { selector: 'p' })
142
+ ).toBeInTheDocument()
143
+ })
144
+
101
145
  it('renders participant username as secondary info', () => {
102
146
  renderWithProviders(
103
147
  <ChannelInfoDialog
@@ -8,6 +8,7 @@ import React, { useState, useCallback, useEffect } from 'react'
8
8
  import type { Channel as ChannelType, ChannelMemberResponse } from 'stream-chat'
9
9
 
10
10
  import { useMessagingContext } from '../../providers/MessagingProvider'
11
+ import { resolveParticipantDisplayName } from '../../utils/resolveParticipantDisplayName'
11
12
  import ActionButton from '../ActionButton'
12
13
  import { Avatar } from '../Avatar'
13
14
  import { CloseButton } from '../CloseButton'
@@ -26,6 +27,7 @@ export interface ChannelInfoDialogProps {
26
27
  dialogRef: React.RefObject<HTMLDialogElement>
27
28
  onClose: () => void
28
29
  participant: ChannelMemberResponse | undefined
30
+ participantDisplayName?: string
29
31
  channel: ChannelType
30
32
  followerStatusLabel?: string
31
33
  onLeaveConversation?: (channel: ChannelType) => void
@@ -45,6 +47,7 @@ export const ChannelInfoDialog: React.FC<ChannelInfoDialogProps> = ({
45
47
  dialogRef,
46
48
  onClose,
47
49
  participant,
50
+ participantDisplayName,
48
51
  channel,
49
52
  followerStatusLabel,
50
53
  onLeaveConversation,
@@ -177,7 +180,8 @@ export const ChannelInfoDialog: React.FC<ChannelInfoDialogProps> = ({
177
180
  if (!participant) return null
178
181
 
179
182
  const participantName =
180
- participant.user?.name || participant.user?.id || 'Unknown member'
183
+ participantDisplayName ??
184
+ resolveParticipantDisplayName(participant.user)
181
185
  const participantImage = participant.user?.image
182
186
  const participantUsername = (participant.user as CustomUser)?.username
183
187
  const participantSecondary = participantUsername
@@ -66,6 +66,41 @@ describe('CustomChannelPreview', () => {
66
66
  onChannelSelect: vi.fn(),
67
67
  }
68
68
 
69
+ it('uses username when participant name is UUID-like', () => {
70
+ const channel = createMockChannel([])
71
+ channel.state.members['participant-1'] = {
72
+ user: {
73
+ id: 'participant-1',
74
+ name: 'a1b2c3d4-e5f6-4789-a012-3456789abcde',
75
+ username: 'alice',
76
+ },
77
+ user_id: 'participant-1',
78
+ }
79
+
80
+ renderWithProviders(
81
+ <CustomChannelPreview {...defaultProps} channel={channel} />
82
+ )
83
+
84
+ expect(screen.getByText('alice')).toBeInTheDocument()
85
+ })
86
+
87
+ it('shows Unknown member when participant has no usable name or username', () => {
88
+ const channel = createMockChannel([])
89
+ channel.state.members['participant-1'] = {
90
+ user: {
91
+ id: 'participant-1',
92
+ name: 'participant-1',
93
+ },
94
+ user_id: 'participant-1',
95
+ }
96
+
97
+ renderWithProviders(
98
+ <CustomChannelPreview {...defaultProps} channel={channel} />
99
+ )
100
+
101
+ expect(screen.getByText('Unknown member')).toBeInTheDocument()
102
+ })
103
+
69
104
  it('shows the latest non-system message when the last message is a system message', () => {
70
105
  const channel = createMockChannel([
71
106
  {
@@ -5,6 +5,7 @@ import { ChannelPreviewUIComponentProps } from 'stream-chat-react'
5
5
  import { useChannelStar } from '../../hooks/useChannelStar'
6
6
  import { formatRelativeTime } from '../../utils/formatRelativeTime'
7
7
  import { getMessageDisplayText } from '../../utils/getMessageDisplayText'
8
+ import { resolveParticipantDisplayName } from '../../utils/resolveParticipantDisplayName'
8
9
  import { Avatar } from '../Avatar'
9
10
  import { isChatbotMessage } from '../CustomMessage/MessageTag'
10
11
 
@@ -45,7 +46,7 @@ const CustomChannelPreview = React.memo<ChannelPreviewUIComponentProps>(
45
46
  const participant = members.find(
46
47
  (member) => member.user?.id && member.user.id !== channel?._client?.userID
47
48
  )
48
- const participantName = participant?.user?.name || 'Conversation'
49
+ const participantName = resolveParticipantDisplayName(participant?.user)
49
50
  const participantImage = participant?.user?.image
50
51
 
51
52
  // Get last non-system message for preview
@@ -85,9 +85,21 @@ vi.mock('../hooks/useChannelStar', () => ({
85
85
  const createChannel = ({
86
86
  currentUserId = 'visitor-1',
87
87
  currentUserRole = 'owner',
88
+ otherUser = {
89
+ id: 'linker-1' as const,
90
+ name: 'Linker' as string | undefined,
91
+ username: undefined as string | undefined,
92
+ is_account: true,
93
+ },
88
94
  }: {
89
95
  currentUserId?: 'visitor-1' | 'linker-1'
90
96
  currentUserRole?: 'owner' | 'member'
97
+ otherUser?: {
98
+ id: string
99
+ name?: string
100
+ username?: string
101
+ is_account?: boolean
102
+ }
91
103
  } = {}) =>
92
104
  ({
93
105
  id: 'channel-1',
@@ -106,9 +118,12 @@ const createChannel = ({
106
118
  },
107
119
  other: {
108
120
  user: {
109
- id: currentUserId === 'visitor-1' ? 'linker-1' : 'visitor-1',
110
- name: currentUserId === 'visitor-1' ? 'Linker' : 'Visitor',
111
- is_account: currentUserId === 'visitor-1',
121
+ ...otherUser,
122
+ id:
123
+ otherUser.id ??
124
+ (currentUserId === 'visitor-1' ? 'linker-1' : 'visitor-1'),
125
+ is_account:
126
+ otherUser.is_account ?? currentUserId === 'visitor-1',
112
127
  },
113
128
  role: currentUserRole === 'owner' ? 'member' : 'owner',
114
129
  },
@@ -294,4 +309,51 @@ describe('ChannelView', () => {
294
309
 
295
310
  expect(activeChannelProps.SendButton).toBeUndefined()
296
311
  })
312
+
313
+ it('uses username when participant name is UUID-like', () => {
314
+ renderWithProviders(
315
+ <ChannelView
316
+ channel={createChannel({
317
+ otherUser: {
318
+ id: 'linker-1',
319
+ name: 'a1b2c3d4-e5f6-4789-a012-3456789abcde',
320
+ username: 'linkeruser',
321
+ is_account: true,
322
+ },
323
+ })}
324
+ />
325
+ )
326
+
327
+ expect(screen.getAllByText('linkeruser').length).toBeGreaterThan(0)
328
+ expect(avatarRenderCalls.some((call) => call.name === 'linkeruser')).toBe(
329
+ true
330
+ )
331
+ })
332
+
333
+ it('shows Unknown member when participant has no usable name or username', () => {
334
+ renderWithProviders(
335
+ <ChannelView
336
+ channel={createChannel({
337
+ otherUser: {
338
+ id: 'opaque-user-id',
339
+ name: 'opaque-user-id',
340
+ is_account: true,
341
+ },
342
+ })}
343
+ />
344
+ )
345
+
346
+ expect(screen.getAllByText('Unknown member').length).toBeGreaterThan(0)
347
+ })
348
+
349
+ it('uses getParticipantDisplayName override when provided', () => {
350
+ renderWithProviders(
351
+ <ChannelView
352
+ channel={createChannel()}
353
+ getParticipantDisplayName={() => 'Custom Label'}
354
+ />
355
+ )
356
+
357
+ expect(screen.getAllByText('Custom Label').length).toBeGreaterThan(0)
358
+ })
297
359
  })
@@ -7,7 +7,7 @@ import {
7
7
  } from '@phosphor-icons/react'
8
8
  import classNames from 'classnames'
9
9
  import React, { useCallback, useRef } from 'react'
10
- import { Channel as ChannelType } from 'stream-chat'
10
+ import { Channel as ChannelType, ChannelMemberResponse } from 'stream-chat'
11
11
  import {
12
12
  Channel,
13
13
  Window,
@@ -20,6 +20,7 @@ import {
20
20
 
21
21
  import { useChannelStar } from '../hooks/useChannelStar'
22
22
  import type { ChannelViewProps } from '../types'
23
+ import { resolveParticipantDisplayName } from '../utils/resolveParticipantDisplayName'
23
24
 
24
25
  import { Avatar } from './Avatar'
25
26
  import { ChannelInfoDialog } from './ChannelInfoDialog'
@@ -46,6 +47,9 @@ const CustomChannelHeader: React.FC<{
46
47
  canShowInfo: boolean
47
48
  showStarButton?: boolean
48
49
  dmAgentEnabled?: boolean
50
+ getParticipantDisplayName: (
51
+ participant: ChannelMemberResponse | undefined
52
+ ) => string
49
53
  }> = ({
50
54
  onBack,
51
55
  showBackButton,
@@ -53,19 +57,21 @@ const CustomChannelHeader: React.FC<{
53
57
  canShowInfo,
54
58
  showStarButton = false,
55
59
  dmAgentEnabled = false,
60
+ getParticipantDisplayName,
56
61
  }) => {
57
62
  const { channel } = useChannelStateContext()
58
63
 
59
64
  // Get participant info (excluding current user)
60
65
  const participant = React.useMemo(() => {
61
- const members = Object.values(channel.state.members || {})
66
+ const myUserId = channel._client?.userID
67
+ if (!myUserId) return undefined
68
+ const members = Object.values(channel.state?.members || {})
62
69
  return members.find(
63
- (member) => member.user?.id && member.user.id !== channel._client.userID
70
+ (member) => member.user?.id && member.user.id !== myUserId
64
71
  )
65
- }, [channel._client.userID, channel.state.members])
72
+ }, [channel._client?.userID, channel.state?.members])
66
73
 
67
- const participantName =
68
- participant?.user?.name || participant?.user?.id || 'Unknown member'
74
+ const participantName = getParticipantDisplayName(participant)
69
75
  const participantImage = participant?.user?.image
70
76
  const isStarred = useChannelStar(channel)
71
77
 
@@ -259,6 +265,9 @@ const ChannelViewInner: React.FC<{
259
265
  ) => React.ReactNode
260
266
  dmAgentEnabled?: boolean
261
267
  viewerLanguage?: string
268
+ getParticipantDisplayName: (
269
+ participant: ChannelMemberResponse | undefined
270
+ ) => string
262
271
  }> = ({
263
272
  onBack,
264
273
  showBackButton,
@@ -278,22 +287,27 @@ const ChannelViewInner: React.FC<{
278
287
  renderMessage,
279
288
  dmAgentEnabled = false,
280
289
  viewerLanguage,
290
+ getParticipantDisplayName,
281
291
  }) => {
282
292
  const { channel } = useChannelStateContext()
283
293
  const infoDialogRef = useRef<HTMLDialogElement>(null)
284
294
 
285
295
  // Get participant info for info dialog
286
296
  const participant = React.useMemo(() => {
287
- const members = Object.values(channel.state.members || {})
297
+ const myUserId = channel._client?.userID
298
+ if (!myUserId) return undefined
299
+ const members = Object.values(channel.state?.members || {})
288
300
  return members.find(
289
- (member) => member.user?.id && member.user.id !== channel._client.userID
301
+ (member) => member.user?.id && member.user.id !== myUserId
290
302
  )
291
- }, [channel._client.userID, channel.state.members])
303
+ }, [channel._client?.userID, channel.state?.members])
292
304
 
293
305
  const currentMember = React.useMemo(() => {
294
- const members = Object.values(channel.state.members || {})
295
- return members.find((member) => member.user?.id === channel._client.userID)
296
- }, [channel._client.userID, channel.state.members])
306
+ const myUserId = channel._client?.userID
307
+ if (!myUserId) return undefined
308
+ const members = Object.values(channel.state?.members || {})
309
+ return members.find((member) => member.user?.id === myUserId)
310
+ }, [channel._client?.userID, channel.state?.members])
297
311
 
298
312
  const currentUserIsAccount =
299
313
  (currentMember?.user as { is_account?: boolean } | undefined)?.is_account ??
@@ -363,7 +377,7 @@ const ChannelViewInner: React.FC<{
363
377
  <WithComponents overrides={{ Message: MessageOverride }}>
364
378
  <Window>
365
379
  {/* Custom Channel Header */}
366
- <div className="p-4">
380
+ <div key="lt-channel-header" className="p-4">
367
381
  <CustomChannelHeader
368
382
  onBack={onBack}
369
383
  showBackButton={showBackButton}
@@ -371,14 +385,22 @@ const ChannelViewInner: React.FC<{
371
385
  canShowInfo={Boolean(participant)}
372
386
  showStarButton={showStarButton}
373
387
  dmAgentEnabled={showDmAgentHeader}
388
+ getParticipantDisplayName={getParticipantDisplayName}
374
389
  />
375
390
  </div>
376
391
 
377
392
  {/* Custom Banner/Summary */}
378
- {renderChannelBanner?.()}
393
+ {renderChannelBanner ? (
394
+ <React.Fragment key="lt-channel-banner">
395
+ {renderChannelBanner()}
396
+ </React.Fragment>
397
+ ) : null}
379
398
 
380
399
  {/* Message List */}
381
- <div className="flex-1 overflow-hidden relative">
400
+ <div
401
+ key="lt-channel-message-list"
402
+ className="flex-1 overflow-hidden relative"
403
+ >
382
404
  <MessageList
383
405
  hideDeletedMessages
384
406
  hideNewMessageSeparator={false}
@@ -386,10 +408,15 @@ const ChannelViewInner: React.FC<{
386
408
  />
387
409
  </div>
388
410
 
389
- {renderConversationFooter?.(channel)}
411
+ {renderConversationFooter ? (
412
+ <React.Fragment key="lt-channel-conversation-footer">
413
+ {renderConversationFooter(channel)}
414
+ </React.Fragment>
415
+ ) : null}
390
416
 
391
417
  {/* Message Input */}
392
418
  <CustomMessageInput
419
+ key="lt-channel-message-input"
393
420
  renderActions={() => renderMessageInputActions?.(channel)}
394
421
  />
395
422
  </Window>
@@ -400,6 +427,7 @@ const ChannelViewInner: React.FC<{
400
427
  dialogRef={infoDialogRef}
401
428
  onClose={handleCloseInfo}
402
429
  participant={participant}
430
+ participantDisplayName={getParticipantDisplayName(participant)}
403
431
  channel={channel}
404
432
  followerStatusLabel={followerStatusLabel}
405
433
  onLeaveConversation={onLeaveConversation}
@@ -444,7 +472,15 @@ export const ChannelView = React.memo<ChannelViewProps>(
444
472
  renderMessage,
445
473
  sendButton,
446
474
  viewerLanguage,
475
+ getParticipantDisplayName: getParticipantDisplayNameProp,
447
476
  }) => {
477
+ const getParticipantDisplayName = useCallback(
478
+ (participant: ChannelMemberResponse | undefined) =>
479
+ getParticipantDisplayNameProp?.(participant) ??
480
+ resolveParticipantDisplayName(participant?.user),
481
+ [getParticipantDisplayNameProp]
482
+ )
483
+
448
484
  // Custom send message handler that:
449
485
  // 1. Applies messageMetadata if provided
450
486
  // 2. Adds skip_push and silent when DM agent is active
@@ -527,6 +563,7 @@ export const ChannelView = React.memo<ChannelViewProps>(
527
563
  customChannelActions={customChannelActions}
528
564
  renderMessage={renderMessage}
529
565
  viewerLanguage={viewerLanguage}
566
+ getParticipantDisplayName={getParticipantDisplayName}
530
567
  />
531
568
  </Channel>
532
569
  </DmAgentEnabledContext.Provider>
package/src/index.ts CHANGED
@@ -40,6 +40,11 @@ export {
40
40
  getMessageDisplayText,
41
41
  normalizeLanguageCode,
42
42
  } from './utils/getMessageDisplayText'
43
+ export {
44
+ resolveParticipantDisplayName,
45
+ isUuidLike,
46
+ } from './utils/resolveParticipantDisplayName'
47
+ export type { ParticipantDisplayUser } from './utils/resolveParticipantDisplayName'
43
48
 
44
49
  // Types
45
50
  export type {
package/src/types.ts CHANGED
@@ -6,6 +6,7 @@ import type { ComponentType } from 'react'
6
6
  import type {
7
7
  Channel,
8
8
  ChannelFilters,
9
+ ChannelMemberResponse,
9
10
  ChannelSort,
10
11
  LocalMessage,
11
12
  SendMessageAPIResponse,
@@ -183,6 +184,14 @@ export interface ChannelViewProps {
183
184
  */
184
185
  viewerLanguage?: string
185
186
 
187
+ /**
188
+ * Resolves the display label for the other channel participant.
189
+ * Defaults to name, then username, then "Unknown member" (never user.id).
190
+ */
191
+ getParticipantDisplayName?: (
192
+ participant: ChannelMemberResponse | undefined
193
+ ) => string
194
+
186
195
  /**
187
196
  * Custom render function for a banner/card component that renders
188
197
  * between the channel header and message list.
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ isUuidLike,
5
+ resolveParticipantDisplayName,
6
+ } from './resolveParticipantDisplayName'
7
+
8
+ describe('isUuidLike', () => {
9
+ it('returns true for standard UUID strings', () => {
10
+ expect(isUuidLike('a1b2c3d4-e5f6-4789-a012-3456789abcde')).toBe(true)
11
+ })
12
+
13
+ it('returns false for regular display names', () => {
14
+ expect(isUuidLike('Alice')).toBe(false)
15
+ })
16
+ })
17
+
18
+ describe('resolveParticipantDisplayName', () => {
19
+ it('returns a valid name when present', () => {
20
+ expect(
21
+ resolveParticipantDisplayName({
22
+ id: 'user-1',
23
+ name: 'Alice',
24
+ username: 'alice',
25
+ })
26
+ ).toBe('Alice')
27
+ })
28
+
29
+ it('prefers username when name is missing', () => {
30
+ expect(
31
+ resolveParticipantDisplayName({
32
+ id: 'user-1',
33
+ username: 'alice',
34
+ })
35
+ ).toBe('alice')
36
+ })
37
+
38
+ it('prefers username when name is UUID-like', () => {
39
+ expect(
40
+ resolveParticipantDisplayName({
41
+ id: 'user-1',
42
+ name: 'a1b2c3d4-e5f6-4789-a012-3456789abcde',
43
+ username: 'alice',
44
+ })
45
+ ).toBe('alice')
46
+ })
47
+
48
+ it('never falls back to user id', () => {
49
+ expect(
50
+ resolveParticipantDisplayName({
51
+ id: 'opaque-user-id',
52
+ name: 'opaque-user-id',
53
+ })
54
+ ).toBe('Unknown member')
55
+ })
56
+
57
+ it('returns Unknown member when no usable fields exist', () => {
58
+ expect(resolveParticipantDisplayName({ id: 'user-1' })).toBe(
59
+ 'Unknown member'
60
+ )
61
+ expect(resolveParticipantDisplayName(undefined)).toBe('Unknown member')
62
+ })
63
+
64
+ it('ignores whitespace-only values', () => {
65
+ expect(
66
+ resolveParticipantDisplayName({
67
+ id: 'user-1',
68
+ name: ' ',
69
+ username: 'alice',
70
+ })
71
+ ).toBe('alice')
72
+ })
73
+ })