@linktr.ee/messaging-react 2.5.3 → 2.6.0-rc-1780281100

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 (33) hide show
  1. package/dist/{Card-BbMWytg6.cjs → Card-BHrhU1uR.cjs} +2 -2
  2. package/dist/{Card-BbMWytg6.cjs.map → Card-BHrhU1uR.cjs.map} +1 -1
  3. package/dist/{Card-B7TJ2lbG.cjs → Card-BdyCnBOJ.cjs} +2 -2
  4. package/dist/{Card-B7TJ2lbG.cjs.map → Card-BdyCnBOJ.cjs.map} +1 -1
  5. package/dist/{Card-CJDGgG0O.js → Card-CD9KC9WP.js} +3 -3
  6. package/dist/{Card-CJDGgG0O.js.map → Card-CD9KC9WP.js.map} +1 -1
  7. package/dist/{Card-CruyYUqd.cjs → Card-CJMJGeFI.cjs} +2 -2
  8. package/dist/{Card-CruyYUqd.cjs.map → Card-CJMJGeFI.cjs.map} +1 -1
  9. package/dist/{Card-BwygcpzI.js → Card-CWjGaRH2.js} +2 -2
  10. package/dist/{Card-BwygcpzI.js.map → Card-CWjGaRH2.js.map} +1 -1
  11. package/dist/{Card-Dy66r68A.js → Card-CbDg3PRA.js} +2 -2
  12. package/dist/{Card-Dy66r68A.js.map → Card-CbDg3PRA.js.map} +1 -1
  13. package/dist/{LockedThumbnail-BJxXEcxk.js → LockedThumbnail-DzSYmmYA.js} +2 -2
  14. package/dist/{LockedThumbnail-BJxXEcxk.js.map → LockedThumbnail-DzSYmmYA.js.map} +1 -1
  15. package/dist/{LockedThumbnail-DrsPt5LN.cjs → LockedThumbnail-ineY4nwg.cjs} +2 -2
  16. package/dist/{LockedThumbnail-DrsPt5LN.cjs.map → LockedThumbnail-ineY4nwg.cjs.map} +1 -1
  17. package/dist/index-Be5pkdgH.cjs +2 -0
  18. package/dist/index-Be5pkdgH.cjs.map +1 -0
  19. package/dist/{index-CPa6Qru7.js → index-n6fP9phm.js} +1385 -1371
  20. package/dist/index-n6fP9phm.js.map +1 -0
  21. package/dist/index.cjs +1 -1
  22. package/dist/index.d.ts +8 -1
  23. package/dist/index.js +1 -1
  24. package/package.json +1 -1
  25. package/src/components/ChannelView.tsx +7 -0
  26. package/src/components/CustomMessageInput/CustomMessageInput.stories.tsx +126 -30
  27. package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +2 -13
  28. package/src/components/CustomMessageInput/index.tsx +51 -45
  29. package/src/components/MessagingShell/index.tsx +4 -0
  30. package/src/types.ts +10 -0
  31. package/dist/index-BIInWpum.cjs +0 -2
  32. package/dist/index-BIInWpum.cjs.map +0 -1
  33. package/dist/index-CPa6Qru7.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-BIInWpum.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-Be5pkdgH.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
@@ -162,7 +162,7 @@ export declare const ChannelView: default_2.NamedExoticComponent<ChannelViewProp
162
162
  * Props that MessagingShell passes through to ChannelView.
163
163
  * ChannelViewProps is the source of truth for these props.
164
164
  */
165
- declare type ChannelViewPassthroughProps = Pick<ChannelViewProps, 'renderMessageInputActions' | 'renderConversationFooter' | 'CustomChannelEmptyState' | 'onDeleteConversationClick' | 'onBlockParticipantClick' | 'onReportParticipantClick' | 'dmAgentEnabled' | 'messageMetadata' | 'onMessageSent' | 'showStarButton' | 'chatbotVotingEnabled' | 'viewerLanguage' | 'renderChannelBanner' | 'customProfileContent' | 'customChannelActions' | 'renderMessage' | 'onMessageLinkClick' | 'sendButton'>;
165
+ declare type ChannelViewPassthroughProps = Pick<ChannelViewProps, 'renderMessageInputActions' | 'renderMessageInputFooter' | 'renderConversationFooter' | 'CustomChannelEmptyState' | 'onDeleteConversationClick' | 'onBlockParticipantClick' | 'onReportParticipantClick' | 'dmAgentEnabled' | 'messageMetadata' | 'onMessageSent' | 'showStarButton' | 'chatbotVotingEnabled' | 'viewerLanguage' | 'renderChannelBanner' | 'customProfileContent' | 'customChannelActions' | 'renderMessage' | 'onMessageLinkClick' | 'sendButton' | 'attachmentPreviewList'>;
166
166
 
167
167
  /**
168
168
  * ChannelView component props
@@ -172,6 +172,7 @@ export declare interface ChannelViewProps {
172
172
  onBack?: () => void;
173
173
  showBackButton?: boolean;
174
174
  renderMessageInputActions?: (channel: Channel) => React.ReactNode;
175
+ renderMessageInputFooter?: (channel: Channel) => React.ReactNode;
175
176
  renderConversationFooter?: (channel: Channel) => React.ReactNode;
176
177
  onLeaveConversation?: (channel: Channel) => void;
177
178
  onBlockParticipant?: (participantId?: string) => void;
@@ -298,6 +299,12 @@ export declare interface ChannelViewProps {
298
299
  * sendButton={MediaSendButton}
299
300
  */
300
301
  sendButton?: ComponentType<any>;
302
+ /**
303
+ * Custom component rendered inside the message composer bubble (above the
304
+ * textarea). Passed to Stream `Channel` as `AttachmentPreviewList`.
305
+ * Use this to render staged paid or media attachments inside the input.
306
+ */
307
+ attachmentPreviewList?: ComponentType;
301
308
  }
302
309
 
303
310
  export declare interface ComposerCardProps extends LockedAttachmentBaseProps {
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-CPa6Qru7.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-n6fP9phm.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": "2.5.3",
3
+ "version": "2.6.0-rc-1780281100",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -254,6 +254,7 @@ const ChannelViewInner: React.FC<{
254
254
  onBack?: () => void
255
255
  showBackButton: boolean
256
256
  renderMessageInputActions?: (channel: ChannelType) => React.ReactNode
257
+ renderMessageInputFooter?: (channel: ChannelType) => React.ReactNode
257
258
  renderConversationFooter?: (channel: ChannelType) => React.ReactNode
258
259
  onLeaveConversation?: (channel: ChannelType) => void
259
260
  onBlockParticipant?: (participantId?: string) => void
@@ -280,6 +281,7 @@ const ChannelViewInner: React.FC<{
280
281
  onBack,
281
282
  showBackButton,
282
283
  renderMessageInputActions,
284
+ renderMessageInputFooter,
283
285
  renderConversationFooter,
284
286
  onLeaveConversation,
285
287
  onBlockParticipant,
@@ -430,6 +432,7 @@ const ChannelViewInner: React.FC<{
430
432
  <CustomMessageInput
431
433
  key="lt-channel-message-input"
432
434
  renderActions={() => renderMessageInputActions?.(channel)}
435
+ renderFooter={() => renderMessageInputFooter?.(channel)}
433
436
  />
434
437
  </Window>
435
438
  </WithComponents>
@@ -464,6 +467,7 @@ export const ChannelView = React.memo<ChannelViewProps>(
464
467
  onBack,
465
468
  showBackButton = false,
466
469
  renderMessageInputActions,
470
+ renderMessageInputFooter,
467
471
  renderConversationFooter,
468
472
  onLeaveConversation,
469
473
  onBlockParticipant,
@@ -484,6 +488,7 @@ export const ChannelView = React.memo<ChannelViewProps>(
484
488
  renderMessage,
485
489
  onMessageLinkClick,
486
490
  sendButton,
491
+ attachmentPreviewList,
487
492
  viewerLanguage,
488
493
  getParticipantDisplayName: getParticipantDisplayNameProp,
489
494
  }) => {
@@ -577,11 +582,13 @@ export const ChannelView = React.memo<ChannelViewProps>(
577
582
  TypingIndicator={CustomTypingIndicator}
578
583
  doSendMessageRequest={doSendMessageRequest}
579
584
  {...(sendButton ? { SendButton: sendButton } : {})}
585
+ {...(attachmentPreviewList ? { AttachmentPreviewList: attachmentPreviewList } : {})}
580
586
  >
581
587
  <ChannelViewInner
582
588
  onBack={onBack}
583
589
  showBackButton={showBackButton}
584
590
  renderMessageInputActions={renderMessageInputActions}
591
+ renderMessageInputFooter={renderMessageInputFooter}
585
592
  renderConversationFooter={renderConversationFooter}
586
593
  onLeaveConversation={onLeaveConversation}
587
594
  onBlockParticipant={onBlockParticipant}
@@ -1,7 +1,10 @@
1
+ import { PlusIcon, XIcon } from '@phosphor-icons/react'
1
2
  import type { Meta, StoryFn } from '@storybook/react'
2
3
  import React, { useEffect } from 'react'
3
4
  import { QueryChannelAPIResponse, StreamChat } from 'stream-chat'
4
- import { Channel, Chat } from 'stream-chat-react'
5
+ import { Channel, Chat, type SendButtonProps } from 'stream-chat-react'
6
+
7
+ import LockedAttachment from '../LockedAttachment'
5
8
 
6
9
  import { CustomMessageInput } from '.'
7
10
 
@@ -25,20 +28,37 @@ const createMockChannel = async (
25
28
  client: StreamChat,
26
29
  opts: { frozen?: boolean } = {}
27
30
  ) => {
28
- const channelData = { members: [mockUser.id, mockParticipant.id], frozen: opts.frozen ?? false }
31
+ const channelData = {
32
+ members: [mockUser.id, mockParticipant.id],
33
+ frozen: opts.frozen ?? false,
34
+ }
29
35
  const ch = client.channel('messaging', 'storybook-cmi-channel', channelData)
30
36
 
31
37
  ch.watch = async () => {
32
38
  ch.state.members = {
33
39
  [mockUser.id]: { user: mockUser, user_id: mockUser.id },
34
- [mockParticipant.id]: { user: mockParticipant, user_id: mockParticipant.id },
40
+ [mockParticipant.id]: {
41
+ user: mockParticipant,
42
+ user_id: mockParticipant.id,
43
+ },
35
44
  }
36
45
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
46
  ;(ch as any)._data = channelData
38
- return { channel: channelData, members: [], messages: [], watchers: [], pinned_messages: [], duration: '0ms' } as unknown as QueryChannelAPIResponse
47
+ return {
48
+ channel: channelData,
49
+ members: [],
50
+ messages: [],
51
+ watchers: [],
52
+ pinned_messages: [],
53
+ duration: '0ms',
54
+ } as unknown as QueryChannelAPIResponse
39
55
  }
40
56
 
41
- try { await ch.watch() } catch (_) { /* mock */ }
57
+ try {
58
+ await ch.watch()
59
+ } catch (_) {
60
+ /* mock */
61
+ }
42
62
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
63
  ;(ch as any)._data = channelData
44
64
  return ch
@@ -50,15 +70,15 @@ const createMockChannel = async (
50
70
 
51
71
  type WrapperProps = {
52
72
  frozen?: boolean
53
- sendButton?: React.ComponentType<{
54
- sendMessage: (...args: unknown[]) => unknown
55
- disabled?: boolean
56
- [key: string]: unknown
57
- }>
58
73
  renderActions?: () => React.ReactNode
74
+ sendButton?: React.ComponentType<SendButtonProps>
75
+ attachmentPreviewList?: React.ComponentType
76
+ renderFooter?: () => React.ReactNode
59
77
  }
60
78
 
61
- const Wrapper: React.FC<WrapperProps> = ({ frozen, sendButton, renderActions }) => {
79
+ const Wrapper: React.FC<WrapperProps> = (props) => {
80
+ const { frozen, renderActions, sendButton, attachmentPreviewList, renderFooter } = props
81
+
62
82
  const [client] = React.useState(() => {
63
83
  const c = new StreamChat('mock-api-key', { allowServerSideConnect: true })
64
84
  c.userID = mockUser.id
@@ -79,9 +99,10 @@ const Wrapper: React.FC<WrapperProps> = ({ frozen, sendButton, renderActions })
79
99
  <Channel
80
100
  channel={channel}
81
101
  {...(sendButton ? { SendButton: sendButton } : {})}
102
+ {...(attachmentPreviewList ? { AttachmentPreviewList: attachmentPreviewList } : {})}
82
103
  >
83
104
  <div className="bg-white" style={{ minWidth: 360 }}>
84
- <CustomMessageInput renderActions={renderActions} />
105
+ <CustomMessageInput renderActions={renderActions} renderFooter={renderFooter} />
85
106
  </div>
86
107
  </Channel>
87
108
  </Chat>
@@ -150,23 +171,22 @@ WithCustomActionButton.parameters = {
150
171
  }
151
172
 
152
173
  /** A custom send button that turns purple to signal a media attachment is staged. */
153
- const PurpleMediaSendButton: React.FC<{
154
- sendMessage: (...args: unknown[]) => unknown
155
- disabled?: boolean
156
- [key: string]: unknown
157
- }> = ({ sendMessage, disabled, ...rest }) => (
158
- <button
159
- {...rest}
160
- type="button"
161
- aria-label="Send"
162
- disabled={disabled}
163
- onClick={() => sendMessage()}
164
- className="str-chat__send-button mt-auto flex items-center justify-center shrink-0 rounded-full size-8 bg-[#6D28D9] disabled:bg-[#F1F0EE] disabled:text-black/20 text-white"
165
- style={{ fontSize: 15 }}
166
- >
167
- 🚀
168
- </button>
169
- )
174
+ const PurpleMediaSendButton: React.FC<SendButtonProps> = (props) => {
175
+ const { sendMessage, disabled, ...rest } = props
176
+ return (
177
+ <button
178
+ {...rest}
179
+ type="button"
180
+ aria-label="Send"
181
+ disabled={disabled}
182
+ onClick={sendMessage}
183
+ className="str-chat__send-button mt-auto flex items-center justify-center shrink-0 rounded-full size-8 bg-[#6D28D9] disabled:bg-[#F1F0EE] disabled:text-black/20 text-white"
184
+ style={{ fontSize: 15 }}
185
+ >
186
+ 🚀
187
+ </button>
188
+ )
189
+ }
170
190
 
171
191
  export const WithCustomSendButton: StoryFn<WrapperProps> = (args) => <Wrapper {...args} />
172
192
  WithCustomSendButton.args = { sendButton: PurpleMediaSendButton }
@@ -174,7 +194,83 @@ WithCustomSendButton.parameters = {
174
194
  docs: {
175
195
  description: {
176
196
  story:
177
- 'Passes a custom `SendButton` via `Channel`\'s `SendButton` prop (which is how `ChannelView`\'s `sendButton` prop surfaces it). `CustomMessageInput` reads the button from `useComponentContext` and falls back to the default when none is provided.',
197
+ "Passes a custom `SendButton` via `Channel`'s `SendButton` prop (which is how `ChannelView`'s `sendButton` prop surfaces it). `CustomMessageInput` reads the button from `useComponentContext` and falls back to the default when none is provided.",
198
+ },
199
+ },
200
+ }
201
+
202
+ export const WithAttachments: StoryFn<WrapperProps> = (args) => <Wrapper {...args} />
203
+ WithAttachments.args = {
204
+ attachmentPreviewList: () => (
205
+ <div className="relative size-20 shrink-0 overflow-hidden rounded-2xl border border-black/8">
206
+ <img
207
+ src="https://picsum.photos/seed/attachment/160/160"
208
+ alt="Staged attachment"
209
+ className="size-full object-cover"
210
+ />
211
+ </div>
212
+ ),
213
+ renderActions: () => (
214
+ <button
215
+ type="button"
216
+ aria-label="Add attachment"
217
+ onClick={() => console.log('add attachment')}
218
+ className="flex items-center justify-center size-12 rounded-full bg-[#F1F0EE] hover:bg-[#E5E4E1]"
219
+ >
220
+ <PlusIcon className="size-5" weight="bold" />
221
+ </button>
222
+ ),
223
+ }
224
+ WithAttachments.parameters = {
225
+ docs: {
226
+ description: {
227
+ story:
228
+ "Message input with a regular image attachment staged. In production thumbnails are portaled into `.central-container` by `ComposerPendingMediaPortal`; here we use `Channel`'s `AttachmentPreviewList` override to achieve the same visual placement via the React tree.",
229
+ },
230
+ },
231
+ }
232
+
233
+ export const WithPaidAttachment: StoryFn<WrapperProps> = (args) => <Wrapper {...args} />
234
+ WithPaidAttachment.args = {
235
+ attachmentPreviewList: () => (
236
+ <LockedAttachment.Composer
237
+ title="Exclusive Content"
238
+ mimeType="image/jpeg"
239
+ thumbnailUrl="https://picsum.photos/seed/paid/320/180"
240
+ detail="3.2 MB"
241
+ amountText="AU$9.99"
242
+ onDismiss={() => console.log('dismiss paid attachment')}
243
+ onEditClick={() => console.log('edit paid attachment')}
244
+ />
245
+ ),
246
+ renderActions: () => (
247
+ <button
248
+ type="button"
249
+ aria-label="Add attachment"
250
+ onClick={() => console.log('add attachment')}
251
+ className="flex items-center justify-center size-12 rounded-full bg-[#F1F0EE] hover:bg-[#E5E4E1]"
252
+ >
253
+ <PlusIcon className="size-5" weight="bold" />
254
+ </button>
255
+ ),
256
+ renderFooter: () => (
257
+ <div className="flex items-center justify-between gap-2 rounded-lg bg-[#E8EEFF] px-4 py-3 text-sm text-black/70">
258
+ <span>Tap to add a price</span>
259
+ <button
260
+ type="button"
261
+ aria-label="Dismiss"
262
+ className="flex shrink-0 items-center justify-center text-black/40 hover:text-black/70 bg-black/10 rounded-full p-1"
263
+ >
264
+ <XIcon className="size-4" />
265
+ </button>
266
+ </div>
267
+ ),
268
+ }
269
+ WithPaidAttachment.parameters = {
270
+ docs: {
271
+ description: {
272
+ story:
273
+ "Message input with a paid attachment staged inside the input bubble. Uses `Channel`'s `AttachmentPreviewList` override to render a `LockedAttachment.Composer` card at the top of the input. `onDismiss` removes it; `onEditClick` reopens the paid content wizard.",
178
274
  },
179
275
  },
180
276
  }
@@ -28,9 +28,6 @@ vi.mock('stream-chat-react', () => ({
28
28
  <Input />
29
29
  </div>
30
30
  ),
31
- SimpleAttachmentSelector: () => (
32
- <div data-testid="simple-attachment-selector" />
33
- ),
34
31
  TextareaComposer: ({
35
32
  maxRows: _maxRows,
36
33
  ...props
@@ -84,7 +81,7 @@ describe('CustomMessageInput', () => {
84
81
 
85
82
  const { container } = renderWithProviders(<CustomMessageInput />)
86
83
 
87
- const messageInput = container.firstElementChild
84
+ const messageInput = container.querySelector('.message-input')
88
85
  expect(messageInput).toHaveAttribute('aria-disabled', 'true')
89
86
  expect(messageInput).toHaveAttribute('inert')
90
87
  expect(screen.getByTestId('stream-message-input')).toBeInTheDocument()
@@ -105,14 +102,6 @@ describe('CustomMessageInput', () => {
105
102
  expect(sendButton).toBeDisabled()
106
103
  })
107
104
 
108
- it('renders the existing attachment selector when channel is frozen', () => {
109
- mockChannelData = { frozen: true }
110
-
111
- renderWithProviders(<CustomMessageInput />)
112
-
113
- expect(screen.getByTestId('simple-attachment-selector')).toBeInTheDocument()
114
- })
115
-
116
105
  it('renders adjacent actions inside the disabled container when frozen', () => {
117
106
  mockChannelData = { frozen: true }
118
107
 
@@ -126,7 +115,7 @@ describe('CustomMessageInput', () => {
126
115
  />
127
116
  )
128
117
 
129
- const messageInput = container.firstElementChild
118
+ const messageInput = container.querySelector('.message-input')
130
119
  const action = screen.getByTestId('custom-action')
131
120
 
132
121
  expect(messageInput).toHaveAttribute('aria-disabled', 'true')
@@ -1,10 +1,9 @@
1
1
  import { ArrowUpIcon } from '@phosphor-icons/react'
2
2
  import React from 'react'
3
3
  import {
4
- AttachmentPreviewList,
4
+ AttachmentPreviewList as DefaultAttachmentPreviewList,
5
5
  MessageInput,
6
6
  QuotedMessagePreview,
7
- SimpleAttachmentSelector,
8
7
  TextareaComposer,
9
8
  useChannelStateContext,
10
9
  useComponentContext,
@@ -32,71 +31,78 @@ const DefaultSendButton: React.FC<{
32
31
 
33
32
  const CustomMessageInputInner: React.FC = () => {
34
33
  const { channel } = useChannelStateContext()
35
- const isFrozen = channel?.data?.frozen === true
36
34
  const { handleSubmit } = useMessageInputContext()
37
- const { SendButton: SendButtonFromContext } =
38
- useComponentContext('CustomMessageInput')
39
- const SendButton = SendButtonFromContext ?? DefaultSendButton
35
+
40
36
  const hasSendableData = useMessageComposerHasSendableData()
37
+ const isFrozen = channel?.data?.frozen === true
41
38
  const isSendDisabled = isFrozen || !hasSendableData
42
39
 
40
+ const {
41
+ SendButton = DefaultSendButton,
42
+ AttachmentPreviewList = DefaultAttachmentPreviewList,
43
+ } = useComponentContext('CustomMessageInput')
44
+
43
45
  return (
44
- <>
45
- <div className="left-container">
46
- <SimpleAttachmentSelector />
47
- </div>
48
- <div className="central-container 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)]">
49
- <QuotedMessagePreview />
50
- <CustomLinkPreviewList />
51
- <AttachmentPreviewList />
52
- <div className="flex">
53
- <div className="w-full ml-2 mr-4 self-center leading-[0]">
54
- <TextareaComposer
55
- aria-disabled={isFrozen || undefined}
56
- className="w-full resize-none outline-none leading-6"
57
- // While this might usually be considered an anti-pattern, in most
58
- // cases, when a message thread is rendered, we want the input to
59
- // gain focus automatically.
60
- // eslint-disable-next-line jsx-a11y/no-autofocus
61
- autoFocus={!isFrozen}
62
- maxRows={4}
63
- readOnly={isFrozen}
64
- tabIndex={isFrozen ? -1 : undefined}
65
- />
66
- </div>
67
- <SendButton
68
- sendMessage={handleSubmit}
69
- aria-label="Send"
70
- className="str-chat__send-button mt-auto flex justify-center items-center flex-shrink-0 rounded-full size-8 bg-[#121110] disabled:bg-[#F1F0EE] disabled:text-black/20 text-white focus-ring"
71
- data-testid="send-button"
72
- disabled={isSendDisabled}
73
- type="button"
46
+ <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)]">
47
+ <QuotedMessagePreview />
48
+ <CustomLinkPreviewList />
49
+ <AttachmentPreviewList />
50
+ <div className="flex">
51
+ <div className="w-full ml-2 mr-4 self-center leading-[0]">
52
+ <TextareaComposer
53
+ aria-disabled={isFrozen || undefined}
54
+ className="w-full resize-none outline-none leading-6"
55
+ // While this might usually be considered an anti-pattern, in most
56
+ // cases, when a message thread is rendered, we want the input to
57
+ // gain focus automatically.
58
+ // eslint-disable-next-line jsx-a11y/no-autofocus
59
+ autoFocus={!isFrozen}
60
+ maxRows={4}
61
+ readOnly={isFrozen}
62
+ tabIndex={isFrozen ? -1 : undefined}
74
63
  />
75
64
  </div>
65
+ <SendButton
66
+ sendMessage={handleSubmit}
67
+ aria-label="Send"
68
+ className="str-chat__send-button mt-auto flex justify-center items-center flex-shrink-0 rounded-full size-8 bg-[#121110] disabled:bg-[#F1F0EE] disabled:text-black/20 text-white focus-ring"
69
+ data-testid="send-button"
70
+ disabled={isSendDisabled}
71
+ type="button"
72
+ />
76
73
  </div>
77
- </>
74
+ </div>
78
75
  )
79
76
  }
80
77
 
81
78
  export interface CustomMessageInputProps {
82
79
  renderActions?: () => React.ReactNode
80
+ renderFooter?: () => React.ReactNode
83
81
  }
84
82
 
85
83
  export const CustomMessageInput: React.FC<CustomMessageInputProps> = ({
86
84
  renderActions,
85
+ renderFooter,
87
86
  }) => {
88
87
  const { channel } = useChannelStateContext()
89
88
  const isFrozen = channel?.data?.frozen === true
90
89
 
91
90
  return (
92
- <div
93
- // @ts-expect-error Only React 19 onwards has `inert` in its types.
94
- inert={isFrozen ? '' : undefined}
95
- aria-disabled={isFrozen || undefined}
96
- className="message-input flex items-center gap-2 p-4 aria-disabled:opacity-40"
97
- >
98
- {renderActions?.()}
99
- <MessageInput Input={CustomMessageInputInner} />
91
+ <div className="flex flex-col gap-4">
92
+ <div
93
+ // @ts-expect-error Only React 19 onwards has `inert` in its types.
94
+ inert={isFrozen ? '' : undefined}
95
+ aria-disabled={isFrozen || undefined}
96
+ className="message-input flex items-end gap-4 aria-disabled:opacity-40"
97
+ >
98
+ {renderActions && (
99
+ <div className="flex h-12 shrink-0 items-center justify-center">
100
+ {renderActions()}
101
+ </div>
102
+ )}
103
+ <MessageInput Input={CustomMessageInputInner} />
104
+ </div>
105
+ {renderFooter?.()}
100
106
  </div>
101
107
  )
102
108
  }
@@ -18,6 +18,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
18
18
  capabilities = {},
19
19
  className,
20
20
  renderMessageInputActions,
21
+ renderMessageInputFooter,
21
22
  renderConversationFooter,
22
23
  onChannelSelect,
23
24
  initialParticipantFilter,
@@ -43,6 +44,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
43
44
  renderMessage,
44
45
  onMessageLinkClick,
45
46
  sendButton,
47
+ attachmentPreviewList,
46
48
  }) => {
47
49
  const {
48
50
  service,
@@ -404,6 +406,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
404
406
  onBack={handleBackToChannelList}
405
407
  showBackButton={!directConversationMode}
406
408
  renderMessageInputActions={renderMessageInputActions}
409
+ renderMessageInputFooter={renderMessageInputFooter}
407
410
  renderConversationFooter={renderConversationFooter}
408
411
  renderChannelBanner={renderChannelBanner}
409
412
  onLeaveConversation={handleLeaveConversation}
@@ -424,6 +427,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
424
427
  renderMessage={renderMessage}
425
428
  onMessageLinkClick={onMessageLinkClick}
426
429
  sendButton={sendButton}
430
+ attachmentPreviewList={attachmentPreviewList}
427
431
  />
428
432
  </div>
429
433
  ) : initialParticipantFilter ? (
package/src/types.ts CHANGED
@@ -123,6 +123,7 @@ export interface ChannelViewProps {
123
123
  onBack?: () => void
124
124
  showBackButton?: boolean
125
125
  renderMessageInputActions?: (channel: Channel) => React.ReactNode
126
+ renderMessageInputFooter?: (channel: Channel) => React.ReactNode
126
127
  renderConversationFooter?: (channel: Channel) => React.ReactNode
127
128
  onLeaveConversation?: (channel: Channel) => void
128
129
  onBlockParticipant?: (participantId?: string) => void
@@ -266,6 +267,13 @@ export interface ChannelViewProps {
266
267
  */
267
268
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
268
269
  sendButton?: ComponentType<any>
270
+
271
+ /**
272
+ * Custom component rendered inside the message composer bubble (above the
273
+ * textarea). Passed to Stream `Channel` as `AttachmentPreviewList`.
274
+ * Use this to render staged paid or media attachments inside the input.
275
+ */
276
+ attachmentPreviewList?: ComponentType
269
277
  }
270
278
 
271
279
  /**
@@ -275,6 +283,7 @@ export interface ChannelViewProps {
275
283
  export type ChannelViewPassthroughProps = Pick<
276
284
  ChannelViewProps,
277
285
  | 'renderMessageInputActions'
286
+ | 'renderMessageInputFooter'
278
287
  | 'renderConversationFooter'
279
288
  | 'CustomChannelEmptyState'
280
289
  | 'onDeleteConversationClick'
@@ -292,6 +301,7 @@ export type ChannelViewPassthroughProps = Pick<
292
301
  | 'renderMessage'
293
302
  | 'onMessageLinkClick'
294
303
  | 'sendButton'
304
+ | 'attachmentPreviewList'
295
305
  >
296
306
 
297
307
  /**