@linktr.ee/messaging-react 3.5.0-rc-1782408966 → 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 (48) hide show
  1. package/dist/{Card-iekRe643.js → Card-BWWBaNKF.js} +2 -2
  2. package/dist/{Card-iekRe643.js.map → Card-BWWBaNKF.js.map} +1 -1
  3. package/dist/{Card-DJtDI03Y.cjs → Card-Bj4YaHav.cjs} +2 -2
  4. package/dist/{Card-DJtDI03Y.cjs.map → Card-Bj4YaHav.cjs.map} +1 -1
  5. package/dist/{Card-CUHEDe8O.js → Card-BnGeMt4f.js} +2 -2
  6. package/dist/{Card-CUHEDe8O.js.map → Card-BnGeMt4f.js.map} +1 -1
  7. package/dist/{Card-BNzC_bp8.js → Card-DkXzpFLA.js} +3 -3
  8. package/dist/{Card-BNzC_bp8.js.map → Card-DkXzpFLA.js.map} +1 -1
  9. package/dist/{Card-DuMr5KMP.cjs → Card-DpCwDviq.cjs} +2 -2
  10. package/dist/{Card-DuMr5KMP.cjs.map → Card-DpCwDviq.cjs.map} +1 -1
  11. package/dist/{Card-DEa50m9p.cjs → Card-qrDaFHtJ.cjs} +2 -2
  12. package/dist/{Card-DEa50m9p.cjs.map → Card-qrDaFHtJ.cjs.map} +1 -1
  13. package/dist/{LockedThumbnail-CvpRZFi-.js → LockedThumbnail-DJR--PAi.js} +2 -2
  14. package/dist/{LockedThumbnail-CvpRZFi-.js.map → LockedThumbnail-DJR--PAi.js.map} +1 -1
  15. package/dist/{LockedThumbnail-aME0f_ZF.cjs → LockedThumbnail-DhXEk-NX.cjs} +2 -2
  16. package/dist/{LockedThumbnail-aME0f_ZF.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-BlpDX5fl.js → index-ubCRSgQk.js} +1423 -1328
  20. package/dist/index-ubCRSgQk.js.map +1 -0
  21. package/dist/index.cjs +1 -1
  22. package/dist/index.d.ts +40 -16
  23. package/dist/index.js +19 -19
  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 +123 -68
  34. package/src/components/CustomMessage/index.tsx +1 -0
  35. package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +34 -0
  36. package/src/components/CustomMessageInput/index.tsx +27 -2
  37. package/src/components/MediaMessage/index.tsx +13 -5
  38. package/src/components/MessagingShell/index.tsx +2 -0
  39. package/src/components/ParticipantBadges.tsx +42 -0
  40. package/src/hooks/useChannelStar.ts +9 -6
  41. package/src/index.ts +1 -2
  42. package/src/stories/mocks.tsx +30 -5
  43. package/src/stream-custom-data.ts +12 -5
  44. package/src/types.ts +27 -1
  45. package/dist/index-BIWS3Inh.cjs +0 -2
  46. package/dist/index-BIWS3Inh.cjs.map +0 -1
  47. package/dist/index-BlpDX5fl.js.map +0 -1
  48. package/src/components/VerifiedBadge/index.tsx +0 -25
@@ -198,6 +198,7 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
198
198
  className={rootClassName}
199
199
  key={message.id}
200
200
  data-message-id={message.id}
201
+ data-dd-privacy="mask"
201
202
  >
202
203
  {PinIndicator && <PinIndicator />}
203
204
  {!!reminder && <ReminderNotification reminder={reminder} />}
@@ -204,6 +204,40 @@ describe('CustomMessageInput', () => {
204
204
  ).toBeInTheDocument()
205
205
  })
206
206
 
207
+ it('renders a custom composerInput in place of the default composer', () => {
208
+ mockChannelData = {}
209
+
210
+ renderWithProviders(
211
+ <CustomMessageInput
212
+ composerInput={() => (
213
+ <div data-testid="custom-composer">custom composer</div>
214
+ )}
215
+ />
216
+ )
217
+
218
+ // The host-owned composer renders instead of the built-in textarea/send.
219
+ expect(screen.getByTestId('custom-composer')).toBeInTheDocument()
220
+ expect(screen.queryByTestId('textarea-composer')).not.toBeInTheDocument()
221
+ // It is still mounted inside Stream's <MessageInput> so it keeps the
222
+ // message-input context (send pipeline, attachments, previews).
223
+ expect(screen.getByTestId('stream-message-input')).toBeInTheDocument()
224
+ })
225
+
226
+ it('keeps the frozen wrapper around a custom composerInput', () => {
227
+ mockChannelData = { frozen: true }
228
+
229
+ const { container } = renderWithProviders(
230
+ <CustomMessageInput
231
+ composerInput={() => <div data-testid="custom-composer" />}
232
+ />
233
+ )
234
+
235
+ const messageInput = container.querySelector('.message-input')
236
+ expect(messageInput).toHaveAttribute('aria-disabled', 'true')
237
+ expect(messageInput).toHaveAttribute('inert')
238
+ expect(screen.getByTestId('custom-composer')).toBeInTheDocument()
239
+ })
240
+
207
241
  it('does not render the locked panel when the composer is not disabled', () => {
208
242
  mockChannelData = {}
209
243
 
@@ -23,6 +23,15 @@ import { CustomLinkPreviewList } from '../CustomLinkPreviewList'
23
23
  */
24
24
  const ComposerLockedContext = React.createContext(false)
25
25
 
26
+ /**
27
+ * Read the composer's locked (frozen-channel) state from inside a custom
28
+ * composer supplied via `composerInput`. Returns true when the channel is
29
+ * frozen; mirror the default composer by rendering the textarea read-only and
30
+ * disabling the send control.
31
+ */
32
+ export const useComposerLocked = (): boolean =>
33
+ useContext(ComposerLockedContext)
34
+
26
35
  const DefaultSendButton: React.FC<{
27
36
  sendMessage: () => void
28
37
  disabled?: boolean
@@ -52,7 +61,10 @@ const CustomMessageInputInner: React.FC = () => {
52
61
  } = useComponentContext('CustomMessageInput')
53
62
 
54
63
  return (
55
- <div className="central-container flex flex-col gap-2 min-w-0 w-full p-2 bg-white rounded-[1.5rem] shadow-[0_4px_16px_0_rgba(0,0,0,0.08),0_1px_2px_0_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.04)]">
64
+ <div
65
+ className="central-container flex flex-col gap-2 min-w-0 w-full p-2 bg-white rounded-[1.5rem] shadow-[0_4px_16px_0_rgba(0,0,0,0.08),0_1px_2px_0_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.04)]"
66
+ data-dd-privacy="mask"
67
+ >
56
68
  <QuotedMessagePreview />
57
69
  <CustomLinkPreviewList />
58
70
  <AttachmentPreviewList />
@@ -101,6 +113,18 @@ export interface CustomMessageInputProps {
101
113
  * `disabled` is true.
102
114
  */
103
115
  disabledReason?: string
116
+ /**
117
+ * Replace the inner composer — the textarea + send arrangement rendered
118
+ * inside Stream's `<MessageInput>` — with a host-owned component. When
119
+ * provided it is passed to Stream as the `Input` component, so it runs inside
120
+ * the message-input context and can drive sending, attachments, and previews
121
+ * via the Stream hooks (`useMessageInputContext`,
122
+ * `useMessageComposerHasSendableData`, `TextareaComposer`, …). The outer
123
+ * frozen/`renderActions`/`renderFooter` wrapper is unchanged; read
124
+ * `useComposerLocked()` to honour the frozen state. Defaults to the built-in
125
+ * composer.
126
+ */
127
+ composerInput?: React.ComponentType
104
128
  }
105
129
 
106
130
  export const CustomMessageInput: React.FC<CustomMessageInputProps> = ({
@@ -108,6 +132,7 @@ export const CustomMessageInput: React.FC<CustomMessageInputProps> = ({
108
132
  renderFooter,
109
133
  disabled = false,
110
134
  disabledReason,
135
+ composerInput,
111
136
  }) => {
112
137
  const { channel } = useChannelStateContext()
113
138
  const isFrozen = channel?.data?.frozen === true
@@ -143,7 +168,7 @@ export const CustomMessageInput: React.FC<CustomMessageInputProps> = ({
143
168
  </div>
144
169
  )}
145
170
  <ComposerLockedContext.Provider value={isFrozen}>
146
- <MessageInput Input={CustomMessageInputInner} />
171
+ <MessageInput Input={composerInput ?? CustomMessageInputInner} />
147
172
  </ComposerLockedContext.Provider>
148
173
  </div>
149
174
  {renderFooter?.()}
@@ -302,7 +302,7 @@ const MediaMessageRoot: React.FC<MediaMessageProps> = ({
302
302
  : 'str-chat__message str-chat__message-simple str-chat__message--other'
303
303
 
304
304
  return (
305
- <div className={messageClass}>
305
+ <div className={messageClass} data-dd-privacy="mask">
306
306
  {!isMyMessage && message.user && (
307
307
  <Avatar
308
308
  className="str-chat__avatar str-chat__message-sender-avatar"
@@ -342,14 +342,18 @@ const MediaMessageCreatorEntry: React.FC<{ message: LocalMessage }> = ({
342
342
  const linkAttachment = resolveLinkAttachment(message)
343
343
  if (linkAttachment) {
344
344
  return (
345
- <div className={linkCardShellClass(true)}>
345
+ <div className={linkCardShellClass(true)} data-dd-privacy="mask">
346
346
  <LinkCard attachment={linkAttachment} isMyMessage={true} />
347
347
  </div>
348
348
  )
349
349
  }
350
350
  const resolved = resolveMediaFromMessage(message)
351
351
  if (!resolved) return null
352
- return <Creator {...resolved} />
352
+ return (
353
+ <div style={{ display: 'contents' }} data-dd-privacy="mask">
354
+ <Creator {...resolved} />
355
+ </div>
356
+ )
353
357
  }
354
358
 
355
359
  const MediaMessageVisitorEntry: React.FC<{ message: LocalMessage }> = ({
@@ -358,14 +362,18 @@ const MediaMessageVisitorEntry: React.FC<{ message: LocalMessage }> = ({
358
362
  const linkAttachment = resolveLinkAttachment(message)
359
363
  if (linkAttachment) {
360
364
  return (
361
- <div className={linkCardShellClass(false)}>
365
+ <div className={linkCardShellClass(false)} data-dd-privacy="mask">
362
366
  <LinkCard attachment={linkAttachment} isMyMessage={false} />
363
367
  </div>
364
368
  )
365
369
  }
366
370
  const resolved = resolveMediaFromMessage(message)
367
371
  if (!resolved) return null
368
- return <Visitor {...resolved} />
372
+ return (
373
+ <div style={{ display: 'contents' }} data-dd-privacy="mask">
374
+ <Visitor {...resolved} />
375
+ </div>
376
+ )
369
377
  }
370
378
 
371
379
  export const MediaMessage = Object.assign(MediaMessageRoot, {
@@ -39,6 +39,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
39
39
  renderMessage,
40
40
  onMessageLinkClick,
41
41
  showChannelInfo,
42
+ composerInput,
42
43
  }) => {
43
44
  const {
44
45
  client,
@@ -264,6 +265,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
264
265
  renderMessage={renderMessage}
265
266
  onMessageLinkClick={onMessageLinkClick}
266
267
  showChannelInfo={showChannelInfo}
268
+ composerInput={composerInput}
267
269
  />
268
270
  </div>
269
271
  </div>
@@ -0,0 +1,42 @@
1
+ import { CurrencyDollarIcon, StarIcon } from '@phosphor-icons/react'
2
+ import React from 'react'
3
+ import type { Channel, ChannelMemberResponse } from 'stream-chat'
4
+
5
+ import { useChannelStar } from '../hooks/useChannelStar'
6
+
7
+ export const ParticipantBadges: React.FC<{
8
+ channel?: Channel
9
+ participant?: ChannelMemberResponse
10
+ }> = ({ channel, participant }) => {
11
+ const isCustomer = participant?.customer === true
12
+ const isStarred = useChannelStar(channel)
13
+
14
+ if (!isCustomer && !isStarred) return null
15
+
16
+ return (
17
+ <span className="inline-flex shrink-0 items-center gap-1">
18
+ {isCustomer && (
19
+ <span
20
+ role="img"
21
+ aria-label="Customer"
22
+ className="inline-flex size-4 items-center justify-center rounded-full bg-[#22C55E] text-white"
23
+ >
24
+ <CurrencyDollarIcon
25
+ aria-hidden="true"
26
+ className="size-3"
27
+ weight="bold"
28
+ />
29
+ </span>
30
+ )}
31
+ {isStarred && (
32
+ <span
33
+ role="img"
34
+ aria-label="Starred conversation"
35
+ className="inline-flex size-4 items-center justify-center rounded-full bg-[#F6C343] text-white"
36
+ >
37
+ <StarIcon aria-hidden="true" className="size-3" weight="fill" />
38
+ </span>
39
+ )}
40
+ </span>
41
+ )
42
+ }
@@ -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
 
package/src/index.ts CHANGED
@@ -10,8 +10,6 @@ export { default as LockedAttachment } from './components/LockedAttachment'
10
10
  export { default as LinkAttachment } from './components/LinkAttachment'
11
11
  export { default as MessageAttachment } from './components/MessageAttachment'
12
12
  export { Avatar } from './components/Avatar'
13
- export { VerifiedBadge } from './components/VerifiedBadge'
14
- export type { VerifiedBadgeProps } from './components/VerifiedBadge'
15
13
  export { FaqList } from './components/FaqList'
16
14
  export { FaqListItem } from './components/FaqList/FaqListItem'
17
15
  export { ChannelEmptyState } from './components/MessagingShell/ChannelEmptyState'
@@ -32,6 +30,7 @@ export { MessagingProvider } from './providers/MessagingProvider'
32
30
  export { CustomMessageProvider } from './components/CustomMessage/context'
33
31
 
34
32
  // Hooks
33
+ export { useComposerLocked } from './components/CustomMessageInput'
35
34
  export { useMessaging } from './hooks/useMessaging'
36
35
  export { useMessageVote } from './hooks/useMessageVote'
37
36
  export { useCustomMessage } from './components/CustomMessage/context'
@@ -54,6 +54,14 @@ export const MockChatProviderWithChannels: React.FC<{
54
54
  // Create mock channels
55
55
  const mockChannels = Array.from({ length: channelCount }, (_, i) => {
56
56
  const participant = mockParticipants[i % mockParticipants.length]
57
+ const eventListeners = new Map<string, Set<(...args: unknown[]) => void>>()
58
+
59
+ const latestMessage = {
60
+ id: `msg-${i}-1`,
61
+ text: `Hey! This is message ${i + 1}`,
62
+ created_at: new Date(Date.now() - 1000 * 60 * (i + 1)),
63
+ user: participant,
64
+ }
57
65
 
58
66
  const mockChannel = {
59
67
  id: `channel-${i + 1}`,
@@ -71,18 +79,35 @@ export const MockChatProviderWithChannels: React.FC<{
71
79
  user_id: participant.id,
72
80
  },
73
81
  },
74
- messages: [
82
+ latestMessages: [latestMessage],
83
+ messages: [latestMessage],
84
+ messageSets: [
75
85
  {
76
- id: `msg-${i}-1`,
77
- text: `Hey! This is message ${i + 1}`,
78
- created_at: new Date(Date.now() - 1000 * 60 * (i + 1)),
79
- user: participant,
86
+ isCurrent: true,
87
+ isLatest: true,
88
+ messages: [latestMessage],
80
89
  },
81
90
  ],
82
91
  unreadCount: i === 0 ? 2 : 0,
83
92
  read: {},
84
93
  },
85
94
  // Mock channel methods
95
+ countUnread: () => (i === 0 ? 2 : 0),
96
+ muteStatus: () => false,
97
+ on: (eventName: string, listener: (...args: unknown[]) => void) => {
98
+ if (!eventListeners.has(eventName)) {
99
+ eventListeners.set(eventName, new Set())
100
+ }
101
+ eventListeners.get(eventName)?.add(listener)
102
+ return {
103
+ unsubscribe: () => {
104
+ eventListeners.get(eventName)?.delete(listener)
105
+ },
106
+ }
107
+ },
108
+ off: (eventName: string, listener: (...args: unknown[]) => void) => {
109
+ eventListeners.get(eventName)?.delete(listener)
110
+ },
86
111
  query: async () => ({ messages: [] }),
87
112
  watch: async () => ({ messages: [] }),
88
113
  }
@@ -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,12 +99,12 @@ declare module 'stream-chat' {
98
99
  chatbot_paused?: boolean
99
100
  }
100
101
 
101
- interface CustomUserData {
102
+ interface CustomMemberData {
102
103
  /**
103
- * Whether the user is a verified Linktree profile. When true, a
104
- * verification badge is shown next to their name in messaging.
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.
105
106
  */
106
- is_verified?: boolean
107
+ customer?: boolean
107
108
  }
108
109
 
109
110
  interface CustomMessageData {
@@ -123,4 +124,10 @@ declare module 'stream-chat' {
123
124
  */
124
125
  dm_agent_system_type?: DmAgentSystemType
125
126
  }
127
+
128
+ interface CustomUserData {
129
+ is_account?: boolean
130
+ is_follower?: boolean
131
+ username?: string
132
+ }
126
133
  }
package/src/types.ts CHANGED
@@ -11,7 +11,10 @@ import type {
11
11
  LocalMessage,
12
12
  SendMessageAPIResponse,
13
13
  } from 'stream-chat'
14
- import type { EmptyStateIndicatorProps } from 'stream-chat-react'
14
+ import type {
15
+ ChannelPreviewUIComponentProps,
16
+ EmptyStateIndicatorProps,
17
+ } from 'stream-chat-react'
15
18
 
16
19
  import type { MessageMetadata } from './stream-custom-data'
17
20
 
@@ -89,6 +92,11 @@ export interface ChannelListProps {
89
92
  */
90
93
  sort?: ChannelSort
91
94
  customEmptyStateIndicator?: React.ComponentType<EmptyStateIndicatorProps>
95
+ /**
96
+ * Custom component rendered for each channel row in the conversation list.
97
+ * Defaults to the package's built-in CustomChannelPreview.
98
+ */
99
+ channelPreview?: ComponentType<ChannelPreviewUIComponentProps>
92
100
  renderMessagePreview?: (
93
101
  message: LocalMessage | undefined,
94
102
  defaultPreview?: string
@@ -324,6 +332,23 @@ export interface ChannelViewProps {
324
332
  */
325
333
  attachmentPreviewList?: ComponentType
326
334
 
335
+ /**
336
+ * Replace the entire inner composer — the textarea + send arrangement — with
337
+ * a host-owned component. Passed to Stream's `<MessageInput>` as the `Input`
338
+ * component, so it runs inside the message-input context and reuses the
339
+ * library's send pipeline (`doSendMessageRequest`, message metadata, DM-agent
340
+ * suppression), attachments, and previews via the Stream hooks
341
+ * (`useMessageInputContext`, `useMessageComposerHasSendableData`,
342
+ * `TextareaComposer`, …). Use it when a surface needs a different composer
343
+ * layout (e.g. a full-width textarea above a toolbar row) than the default.
344
+ *
345
+ * The outer wrapper — frozen handling and the `renderMessageInputActions` /
346
+ * `renderMessageInputFooter` slots — is unchanged. Read the exported
347
+ * `useComposerLocked()` hook to honour the frozen state. Defaults to the
348
+ * built-in composer when omitted.
349
+ */
350
+ composerInput?: ComponentType
351
+
327
352
  /**
328
353
  * Fired when the participant's name in the channel header is clicked. When
329
354
  * provided, the name renders as a button with a trailing caret (an affordance
@@ -359,6 +384,7 @@ export type ChannelViewPassthroughProps = Pick<
359
384
  | 'renderMessage'
360
385
  | 'onMessageLinkClick'
361
386
  | 'showChannelInfo'
387
+ | 'composerInput'
362
388
  >
363
389
 
364
390
  /**