@linktr.ee/messaging-react 2.5.2 → 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 (36) hide show
  1. package/dist/{Card-Bp3XzYXV.cjs → Card-BHrhU1uR.cjs} +2 -2
  2. package/dist/{Card-Bp3XzYXV.cjs.map → Card-BHrhU1uR.cjs.map} +1 -1
  3. package/dist/{Card-Obhvbcgk.cjs → Card-BdyCnBOJ.cjs} +2 -2
  4. package/dist/{Card-Obhvbcgk.cjs.map → Card-BdyCnBOJ.cjs.map} +1 -1
  5. package/dist/{Card-cl8fYGrO.js → Card-CD9KC9WP.js} +3 -3
  6. package/dist/{Card-cl8fYGrO.js.map → Card-CD9KC9WP.js.map} +1 -1
  7. package/dist/{Card-C8C9ZL55.cjs → Card-CJMJGeFI.cjs} +2 -2
  8. package/dist/{Card-C8C9ZL55.cjs.map → Card-CJMJGeFI.cjs.map} +1 -1
  9. package/dist/{Card-CBeF7eUm.js → Card-CWjGaRH2.js} +2 -2
  10. package/dist/{Card-CBeF7eUm.js.map → Card-CWjGaRH2.js.map} +1 -1
  11. package/dist/{Card-CSvw2WUm.js → Card-CbDg3PRA.js} +2 -2
  12. package/dist/{Card-CSvw2WUm.js.map → Card-CbDg3PRA.js.map} +1 -1
  13. package/dist/{LockedThumbnail-BQ5gB3Gj.js → LockedThumbnail-DzSYmmYA.js} +2 -2
  14. package/dist/{LockedThumbnail-BQ5gB3Gj.js.map → LockedThumbnail-DzSYmmYA.js.map} +1 -1
  15. package/dist/{LockedThumbnail-DR1i_N1L.cjs → LockedThumbnail-ineY4nwg.cjs} +2 -2
  16. package/dist/{LockedThumbnail-DR1i_N1L.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-BGwytfkX.js → index-n6fP9phm.js} +1571 -1518
  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/CustomMessage/CustomMessage.stories.tsx +43 -38
  27. package/src/components/CustomMessage/CustomMessageActions.tsx +30 -16
  28. package/src/components/CustomMessage/index.tsx +37 -28
  29. package/src/components/CustomMessageInput/CustomMessageInput.stories.tsx +126 -30
  30. package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +2 -13
  31. package/src/components/CustomMessageInput/index.tsx +51 -45
  32. package/src/components/MessagingShell/index.tsx +4 -0
  33. package/src/types.ts +10 -0
  34. package/dist/index-BGwytfkX.js.map +0 -1
  35. package/dist/index-Dgh0_9UW.cjs +0 -2
  36. package/dist/index-Dgh0_9UW.cjs.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-Dgh0_9UW.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-BGwytfkX.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.2",
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}
@@ -518,70 +518,75 @@ const WithActionsTemplate: StoryFn<TemplateProps> = ({
518
518
  />
519
519
  )
520
520
 
521
- export const DeleteOwnMessage: StoryFn<TemplateProps> =
521
+ export const ConversationWithPaidAttachments: StoryFn<TemplateProps> =
522
522
  WithActionsTemplate.bind({})
523
- DeleteOwnMessage.args = {
523
+ ConversationWithPaidAttachments.args = {
524
524
  currentUser: storyUsers.creator,
525
525
  messages: [
526
526
  {
527
527
  id: 'msg-1',
528
- text: 'Hey, how are you?',
528
+ text: 'Can I have your workout plan?',
529
529
  user: storyUsers.visitor,
530
530
  },
531
531
  {
532
532
  id: 'msg-2',
533
- text: 'Doing great — hover me to delete!',
533
+ text: 'Yes, of course!',
534
534
  user: storyUsers.creator,
535
535
  },
536
- ],
537
- }
538
- DeleteOwnMessage.play = async ({ canvasElement }) => {
539
- const canvas = within(canvasElement)
540
- const ownBubble = await canvas.findByText('Doing great — hover me to delete!')
541
- await userEvent.hover(ownBubble)
542
- const toggle = await canvas.findByTestId('message-actions-toggle-button')
543
- await userEvent.click(toggle)
544
- await expect(canvas.getByText('Delete')).toBeInTheDocument()
545
- }
546
- DeleteOwnMessage.parameters = {
547
- docs: {
548
- description: {
549
- story:
550
- 'Current user hovers their own message. Only "Delete" is shown — "Report" is hidden.',
536
+ {
537
+ id: 'msg-3',
538
+ text: 'Let me know if you have any questions!',
539
+ user: storyUsers.creator,
540
+ metadata: {
541
+ custom_type: 'MESSAGE_ATTACHMENT',
542
+ payment_status: 'paid',
543
+ amount_text: 'AU$9.99',
544
+ attachment_title: "Alicia's Workout Plan",
545
+ attachment_mime_type: 'video/mp4',
546
+ attachment_thumbnail: '/video-thumbnail-blurred.jpg',
547
+ attachment_detail: '1:20',
548
+ },
551
549
  },
552
- },
553
- }
554
-
555
- export const ReportOthersMessage: StoryFn<TemplateProps> =
556
- WithActionsTemplate.bind({})
557
- ReportOthersMessage.args = {
558
- currentUser: storyUsers.creator,
559
- messages: [
560
550
  {
561
- id: 'msg-1',
562
- text: 'Hover me to report!',
551
+ id: 'msg-4',
552
+ text: 'Looks amazing, unlocking now!',
563
553
  user: storyUsers.visitor,
564
554
  },
565
555
  {
566
- id: 'msg-2',
567
- text: "That's a fine message.",
568
- user: storyUsers.creator,
556
+ id: 'msg-5',
557
+ text: 'Here is another resource for you.',
558
+ user: storyUsers.visitor,
559
+ metadata: {
560
+ custom_type: 'MESSAGE_ATTACHMENT',
561
+ amount_text: 'AU$4.99',
562
+ attachment_title: 'Nutrition Guide',
563
+ attachment_mime_type: 'application/pdf',
564
+ attachment_thumbnail: '/video-thumbnail-blurred.jpg',
565
+ attachment_detail: '12 pages',
566
+ },
569
567
  },
570
568
  ],
571
569
  }
572
- ReportOthersMessage.play = async ({ canvasElement }) => {
570
+ ConversationWithPaidAttachments.play = async ({ canvasElement }) => {
573
571
  const canvas = within(canvasElement)
574
- const otherBubble = await canvas.findByText('Hover me to report!')
575
- await userEvent.hover(otherBubble)
572
+ const attachmentMsg = canvasElement.querySelector('[data-message-id="msg-3"]')
573
+ await userEvent.hover(attachmentMsg!)
576
574
  const toggle = await canvas.findByTestId('message-actions-toggle-button')
577
575
  await userEvent.click(toggle)
578
- await expect(canvas.getByText('Report')).toBeInTheDocument()
576
+ const deleteButton = await canvas.findByRole('button', { name: 'Delete' })
577
+ await userEvent.click(deleteButton)
578
+ await expect(
579
+ canvas.getByRole('heading', { name: 'Delete attachment?' })
580
+ ).toBeInTheDocument()
581
+ await expect(
582
+ canvas.getByText(/Deleting it will remove access for the buyer/)
583
+ ).toBeInTheDocument()
579
584
  }
580
- ReportOthersMessage.parameters = {
585
+ ConversationWithPaidAttachments.parameters = {
581
586
  docs: {
582
587
  description: {
583
588
  story:
584
- "Current user hovers the other participant's message. Only \"Report\" is shown \"Delete\" is hidden.",
589
+ 'Report and Delete actions only appear on paid attachment messages — regular messages show no actions. Hovering msg-3 (a purchased attachment) shows the delete confirmation with buyer warning.',
585
590
  },
586
591
  },
587
592
  }
@@ -1,17 +1,22 @@
1
+ import { FlagIcon, TrashSimpleIcon } from '@phosphor-icons/react'
1
2
  import React from 'react'
2
- import { useMessageContext, useTranslationContext } from 'stream-chat-react'
3
+ import { useMessageContext } from 'stream-chat-react'
3
4
  import {
4
5
  DefaultDropdownActionButton,
5
6
  MessageActions,
6
- type MessageActionSetItem,
7
7
  } from 'stream-chat-react/experimental'
8
8
 
9
9
  const DeleteAction = () => {
10
- const { handleDelete } = useMessageContext('CustomMessageActions')
11
- const { t } = useTranslationContext('CustomMessageActions')
10
+ const { handleDelete, message } = useMessageContext('CustomMessageActions')
11
+ if (message.metadata?.payment_status !== 'paid') return null
12
12
  return (
13
- <DefaultDropdownActionButton onClick={handleDelete}>
14
- {t('Delete')}
13
+ <DefaultDropdownActionButton
14
+ onClick={handleDelete}
15
+ aria-label="Delete"
16
+ title="Delete"
17
+ className="bg-marble rounded-full p-2 hover:bg-sand transition-all"
18
+ >
19
+ <TrashSimpleIcon size={16} weight="light" aria-hidden />
15
20
  </DefaultDropdownActionButton>
16
21
  )
17
22
  }
@@ -19,17 +24,26 @@ const DeleteAction = () => {
19
24
  const ReportAction = () => {
20
25
  const { handleFlag } = useMessageContext('CustomMessageActions')
21
26
  return (
22
- <DefaultDropdownActionButton onClick={handleFlag}>
23
- Report
27
+ <DefaultDropdownActionButton
28
+ onClick={handleFlag}
29
+ aria-label="Report"
30
+ title="Report"
31
+ className="bg-marble rounded-full p-2 hover:bg-sand transition-all"
32
+ >
33
+ <FlagIcon size={16} weight="light" aria-hidden />
24
34
  </DefaultDropdownActionButton>
25
35
  )
26
36
  }
27
37
 
28
- const MESSAGE_ACTION_SET: MessageActionSetItem[] = [
29
- { Component: DeleteAction, placement: 'dropdown', type: 'delete' },
30
- { Component: ReportAction, placement: 'dropdown', type: 'flag' },
31
- ]
32
-
33
- export const CustomMessageActions = () => (
34
- <MessageActions messageActionSet={MESSAGE_ACTION_SET} />
35
- )
38
+ export const CustomMessageActions = () => {
39
+ const { message } = useMessageContext('CustomMessageActions')
40
+ if (message.metadata?.custom_type !== 'MESSAGE_ATTACHMENT') return null
41
+ return (
42
+ <MessageActions
43
+ messageActionSet={[
44
+ { Component: DeleteAction, placement: 'quick', type: 'delete' },
45
+ { Component: ReportAction, placement: 'quick', type: 'flag' },
46
+ ]}
47
+ />
48
+ )
49
+ }
@@ -194,7 +194,11 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
194
194
  open={isBounceDialogOpen}
195
195
  />
196
196
  )}
197
- <div className={rootClassName} key={message.id} data-message-id={message.id}>
197
+ <div
198
+ className={rootClassName}
199
+ key={message.id}
200
+ data-message-id={message.id}
201
+ >
198
202
  {PinIndicator && <PinIndicator />}
199
203
  {!!reminder && <ReminderNotification reminder={reminder} />}
200
204
  {message.user && (
@@ -228,33 +232,39 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
228
232
  {isAttachment ? (
229
233
  <div className="str-chat__message-bubble-wrapper">
230
234
  {isMine ? (
231
- <LockedAttachment.Sent
232
- title={message.metadata?.attachment_title}
233
- mimeType={message.metadata?.attachment_mime_type}
234
- thumbnailUrl={message.metadata?.attachment_thumbnail}
235
- amountText={message.metadata?.amount_text}
236
- detail={message.metadata?.attachment_detail}
237
- paymentStatus={message.metadata?.payment_status}
238
- onPreviewClick={() => onUnlockClick?.(message, channel)}
239
- onFetchSource={async () =>
240
- await onFetchSource?.(message, channel)
241
- }
242
- />
235
+ <div className="flex items-center gap-2">
236
+ {MessageActions && <MessageActions />}
237
+ <LockedAttachment.Sent
238
+ title={message.metadata?.attachment_title}
239
+ mimeType={message.metadata?.attachment_mime_type}
240
+ thumbnailUrl={message.metadata?.attachment_thumbnail}
241
+ amountText={message.metadata?.amount_text}
242
+ detail={message.metadata?.attachment_detail}
243
+ paymentStatus={message.metadata?.payment_status}
244
+ onPreviewClick={() => onUnlockClick?.(message, channel)}
245
+ onFetchSource={async () =>
246
+ await onFetchSource?.(message, channel)
247
+ }
248
+ />
249
+ </div>
243
250
  ) : (
244
- <LockedAttachment.Received
245
- title={message.metadata?.attachment_title}
246
- mimeType={message.metadata?.attachment_mime_type}
247
- thumbnailUrl={message.metadata?.attachment_thumbnail}
248
- amountText={message.metadata?.amount_text}
249
- detail={message.metadata?.attachment_detail}
250
- paymentStatus={message.metadata?.payment_status}
251
- isUnlocking={isUnlocking(message.id)}
252
- onUnlockClick={() => onUnlockClick?.(message, channel)}
253
- onFetchSource={async () =>
254
- await onFetchSource?.(message, channel)
255
- }
256
- onDownloadClick={() => onDownloadClick?.(message, channel)}
257
- />
251
+ <div className="flex items-center gap-2">
252
+ <LockedAttachment.Received
253
+ title={message.metadata?.attachment_title}
254
+ mimeType={message.metadata?.attachment_mime_type}
255
+ thumbnailUrl={message.metadata?.attachment_thumbnail}
256
+ amountText={message.metadata?.amount_text}
257
+ detail={message.metadata?.attachment_detail}
258
+ paymentStatus={message.metadata?.payment_status}
259
+ isUnlocking={isUnlocking(message.id)}
260
+ onUnlockClick={() => onUnlockClick?.(message, channel)}
261
+ onFetchSource={async () =>
262
+ await onFetchSource?.(message, channel)
263
+ }
264
+ onDownloadClick={() => onDownloadClick?.(message, channel)}
265
+ />
266
+ {MessageActions && <MessageActions />}
267
+ </div>
258
268
  )}
259
269
  {message.text && (
260
270
  <div className="str-chat__message-bubble">
@@ -300,7 +310,6 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
300
310
  </div>
301
311
  </div>
302
312
  )}
303
- {MessageActions && <MessageActions />}
304
313
  </div>
305
314
  {!isAttachment && !isTipOnly && (
306
315
  <div className="str-chat__message-footer">
@@ -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')