@linktr.ee/messaging-react 3.0.0 → 3.1.0

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 (35) hide show
  1. package/dist/{Card-C-ZIQW_q.js → Card-BGOWR4lW.js} +2 -2
  2. package/dist/{Card-C-ZIQW_q.js.map → Card-BGOWR4lW.js.map} +1 -1
  3. package/dist/{Card-Cqld0-Ws.js → Card-BfvsO78k.js} +3 -3
  4. package/dist/{Card-Cqld0-Ws.js.map → Card-BfvsO78k.js.map} +1 -1
  5. package/dist/{Card-C46z9zz4.js → Card-BhO5jeP9.js} +2 -2
  6. package/dist/{Card-C46z9zz4.js.map → Card-BhO5jeP9.js.map} +1 -1
  7. package/dist/{Card-Cq0x0bbb.cjs → Card-CRJ4l5KM.cjs} +2 -2
  8. package/dist/{Card-Cq0x0bbb.cjs.map → Card-CRJ4l5KM.cjs.map} +1 -1
  9. package/dist/{Card-Drz28Q-Y.cjs → Card-Cq-cN9n1.cjs} +2 -2
  10. package/dist/{Card-Drz28Q-Y.cjs.map → Card-Cq-cN9n1.cjs.map} +1 -1
  11. package/dist/{Card-B7ePjYQ6.cjs → Card-NPXVehHb.cjs} +2 -2
  12. package/dist/{Card-B7ePjYQ6.cjs.map → Card-NPXVehHb.cjs.map} +1 -1
  13. package/dist/{LockedThumbnail--h4GTH41.cjs → LockedThumbnail-B8MKBVXz.cjs} +2 -2
  14. package/dist/{LockedThumbnail--h4GTH41.cjs.map → LockedThumbnail-B8MKBVXz.cjs.map} +1 -1
  15. package/dist/{LockedThumbnail-D5NHhET2.js → LockedThumbnail-Bu9jNPUi.js} +2 -2
  16. package/dist/{LockedThumbnail-D5NHhET2.js.map → LockedThumbnail-Bu9jNPUi.js.map} +1 -1
  17. package/dist/{index-BUT2yBvJ.js → index-CJEl_fID.js} +1417 -1391
  18. package/dist/index-CJEl_fID.js.map +1 -0
  19. package/dist/index-D-5Igybf.cjs +2 -0
  20. package/dist/index-D-5Igybf.cjs.map +1 -0
  21. package/dist/index.cjs +1 -1
  22. package/dist/index.d.ts +33 -0
  23. package/dist/index.js +1 -1
  24. package/package.json +1 -1
  25. package/src/components/ChannelInfoDialog/ChannelInfoDialog.test.tsx +43 -1
  26. package/src/components/ChannelInfoDialog/index.tsx +55 -37
  27. package/src/components/ChannelView.stories.tsx +27 -3
  28. package/src/components/ChannelView.test.tsx +84 -5
  29. package/src/components/ChannelView.tsx +30 -2
  30. package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +40 -0
  31. package/src/components/CustomMessageInput/index.tsx +53 -9
  32. package/src/types.ts +38 -0
  33. package/dist/index-BUT2yBvJ.js.map +0 -1
  34. package/dist/index-DqNobxVj.cjs +0 -2
  35. package/dist/index-DqNobxVj.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-DqNobxVj.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-D-5Igybf.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
@@ -181,6 +181,39 @@ export declare interface ChannelViewProps {
181
181
  * Analytics callback fired when "Report" is clicked.
182
182
  */
183
183
  onReportParticipantClick?: () => void;
184
+ /**
185
+ * Show the "Block"/"Unblock" action in the channel info dialog.
186
+ * Defaults to true. Set false for restricted surfaces such as the
187
+ * Linktree official channel, where blocking is not offered.
188
+ */
189
+ showBlockParticipant?: boolean;
190
+ /**
191
+ * Show the "Report" action in the channel info dialog.
192
+ * Defaults to true. Set false for restricted surfaces such as the
193
+ * Linktree official channel, where reporting is not offered.
194
+ */
195
+ showReportParticipant?: boolean;
196
+ /**
197
+ * Show the subscription/follower-status label in the channel info dialog
198
+ * profile card. Defaults to true. Set false for restricted surfaces such
199
+ * as the Linktree official channel, where subscription status is hidden.
200
+ */
201
+ showFollowerStatus?: boolean;
202
+ /**
203
+ * Replace the message composer with a non-interactive locked panel showing
204
+ * `composerDisabledReason`. Defaults to false. Used by the Linktree official
205
+ * channel, where the linker cannot message Linktree from the inbox (they
206
+ * message Linktree from its public profile instead).
207
+ *
208
+ * Distinct from the channel's `frozen` flag, which keeps the composer
209
+ * rendered but read-only/dimmed.
210
+ */
211
+ composerDisabled?: boolean;
212
+ /**
213
+ * Explanatory text shown inside the locked panel. Only rendered when
214
+ * `composerDisabled` is true.
215
+ */
216
+ composerDisabledReason?: string;
184
217
  /**
185
218
  * When true and DM agent is active on the channel (not paused),
186
219
  * messages will be sent with skip_push and silent flags to suppress
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-BUT2yBvJ.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-CJEl_fID.js";
2
2
  export {
3
3
  e as ActionButton,
4
4
  t as Avatar,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -6,10 +6,14 @@ import { renderWithProviders, screen, userEvent, waitFor } from '../../test/util
6
6
 
7
7
  import { ChannelInfoDialog } from './index'
8
8
 
9
+ const { getBlockedUsersMock } = vi.hoisted(() => ({
10
+ getBlockedUsersMock: vi.fn().mockResolvedValue([]),
11
+ }))
12
+
9
13
  vi.mock('../../providers/MessagingProvider', () => ({
10
14
  useMessagingContext: () => ({
11
15
  service: {
12
- getBlockedUsers: vi.fn().mockResolvedValue([]),
16
+ getBlockedUsers: getBlockedUsersMock,
13
17
  blockUser: vi.fn().mockResolvedValue(undefined),
14
18
  unBlockUser: vi.fn().mockResolvedValue(undefined),
15
19
  },
@@ -278,6 +282,44 @@ describe('ChannelInfoDialog', () => {
278
282
  windowOpenSpy.mockRestore()
279
283
  })
280
284
 
285
+ it('shows Block and Report actions by default', () => {
286
+ renderWithProviders(<ChannelInfoDialog {...defaultProps()} />)
287
+
288
+ expect(screen.getByText('Block')).toBeInTheDocument()
289
+ expect(screen.getByText('Report')).toBeInTheDocument()
290
+ })
291
+
292
+ it('hides the Block action when showBlockParticipant is false', () => {
293
+ renderWithProviders(
294
+ <ChannelInfoDialog {...defaultProps()} showBlockParticipant={false} />
295
+ )
296
+
297
+ expect(screen.queryByText('Block')).not.toBeInTheDocument()
298
+ expect(screen.queryByText('Unblock')).not.toBeInTheDocument()
299
+ })
300
+
301
+ it('fetches blocked users by default (Block action shown)', () => {
302
+ renderWithProviders(<ChannelInfoDialog {...defaultProps()} />)
303
+
304
+ expect(getBlockedUsersMock).toHaveBeenCalled()
305
+ })
306
+
307
+ it('does not fetch blocked users when showBlockParticipant is false', () => {
308
+ renderWithProviders(
309
+ <ChannelInfoDialog {...defaultProps()} showBlockParticipant={false} />
310
+ )
311
+
312
+ expect(getBlockedUsersMock).not.toHaveBeenCalled()
313
+ })
314
+
315
+ it('hides the Report action when showReportParticipant is false', () => {
316
+ renderWithProviders(
317
+ <ChannelInfoDialog {...defaultProps()} showReportParticipant={false} />
318
+ )
319
+
320
+ expect(screen.queryByText('Report')).not.toBeInTheDocument()
321
+ })
322
+
281
323
  it('returns null when participant is undefined', () => {
282
324
  const { container } = renderWithProviders(
283
325
  <ChannelInfoDialog
@@ -33,6 +33,16 @@ export interface ChannelInfoDialogProps {
33
33
  onLeaveConversation?: (channel: ChannelType) => void
34
34
  onBlockParticipant?: (participantId?: string) => void
35
35
  showDeleteConversation?: boolean
36
+ /**
37
+ * Show the Block/Unblock action. Defaults to true.
38
+ * Set false to hide it (e.g. the Linktree official channel).
39
+ */
40
+ showBlockParticipant?: boolean
41
+ /**
42
+ * Show the Report action. Defaults to true.
43
+ * Set false to hide it (e.g. the Linktree official channel).
44
+ */
45
+ showReportParticipant?: boolean
36
46
  onDeleteConversationClick?: () => void
37
47
  onBlockParticipantClick?: () => void
38
48
  onReportParticipantClick?: () => void
@@ -53,6 +63,8 @@ export const ChannelInfoDialog: React.FC<ChannelInfoDialogProps> = ({
53
63
  onLeaveConversation,
54
64
  onBlockParticipant,
55
65
  showDeleteConversation = true,
66
+ showBlockParticipant = true,
67
+ showReportParticipant = true,
56
68
  onDeleteConversationClick,
57
69
  onBlockParticipantClick,
58
70
  onReportParticipantClick,
@@ -64,9 +76,11 @@ export const ChannelInfoDialog: React.FC<ChannelInfoDialogProps> = ({
64
76
  const [isLeaving, setIsLeaving] = useState(false)
65
77
  const [isUpdatingBlockStatus, setIsUpdatingBlockStatus] = useState(false)
66
78
 
67
- // Check if participant is blocked when participant changes
79
+ // Check if participant is blocked when participant changes.
80
+ // Skipped when the Block action is hidden — the result would be unused
81
+ // (e.g. the Linktree official channel does not offer blocking).
68
82
  const checkIsParticipantBlocked = useCallback(async () => {
69
- if (!service || !participant?.user?.id) return
83
+ if (!showBlockParticipant || !service || !participant?.user?.id) return
70
84
 
71
85
  try {
72
86
  const blockedUsers = await service.getBlockedUsers()
@@ -80,7 +94,7 @@ export const ChannelInfoDialog: React.FC<ChannelInfoDialogProps> = ({
80
94
  error
81
95
  )
82
96
  }
83
- }, [service, participant?.user?.id])
97
+ }, [service, participant?.user?.id, showBlockParticipant])
84
98
 
85
99
  useEffect(() => {
86
100
  checkIsParticipantBlocked()
@@ -274,41 +288,45 @@ export const ChannelInfoDialog: React.FC<ChannelInfoDialogProps> = ({
274
288
  </ActionButton>
275
289
  </li>
276
290
  )}
277
- <li>
278
- {isParticipantBlocked ? (
279
- <ActionButton
280
- onClick={handleUnblockUser}
281
- disabled={isUpdatingBlockStatus}
282
- aria-busy={isUpdatingBlockStatus}
283
- >
284
- {isUpdatingBlockStatus ? (
285
- <SpinnerGapIcon className="h-5 w-5 animate-spin" />
286
- ) : (
287
- <ProhibitInsetIcon className="h-5 w-5" />
288
- )}
289
- <span>Unblock</span>
290
- </ActionButton>
291
- ) : (
292
- <ActionButton
293
- onClick={handleBlockUser}
294
- disabled={isUpdatingBlockStatus}
295
- aria-busy={isUpdatingBlockStatus}
296
- >
297
- {isUpdatingBlockStatus ? (
298
- <SpinnerGapIcon className="h-5 w-5 animate-spin" />
299
- ) : (
300
- <ProhibitInsetIcon className="h-5 w-5" />
301
- )}
302
- <span>Block</span>
291
+ {showBlockParticipant && (
292
+ <li>
293
+ {isParticipantBlocked ? (
294
+ <ActionButton
295
+ onClick={handleUnblockUser}
296
+ disabled={isUpdatingBlockStatus}
297
+ aria-busy={isUpdatingBlockStatus}
298
+ >
299
+ {isUpdatingBlockStatus ? (
300
+ <SpinnerGapIcon className="h-5 w-5 animate-spin" />
301
+ ) : (
302
+ <ProhibitInsetIcon className="h-5 w-5" />
303
+ )}
304
+ <span>Unblock</span>
305
+ </ActionButton>
306
+ ) : (
307
+ <ActionButton
308
+ onClick={handleBlockUser}
309
+ disabled={isUpdatingBlockStatus}
310
+ aria-busy={isUpdatingBlockStatus}
311
+ >
312
+ {isUpdatingBlockStatus ? (
313
+ <SpinnerGapIcon className="h-5 w-5 animate-spin" />
314
+ ) : (
315
+ <ProhibitInsetIcon className="h-5 w-5" />
316
+ )}
317
+ <span>Block</span>
318
+ </ActionButton>
319
+ )}
320
+ </li>
321
+ )}
322
+ {showReportParticipant && (
323
+ <li>
324
+ <ActionButton variant="danger" onClick={handleReportUser}>
325
+ <FlagIcon className="h-5 w-5" />
326
+ <span>Report</span>
303
327
  </ActionButton>
304
- )}
305
- </li>
306
- <li>
307
- <ActionButton variant="danger" onClick={handleReportUser}>
308
- <FlagIcon className="h-5 w-5" />
309
- <span>Report</span>
310
- </ActionButton>
311
- </li>
328
+ </li>
329
+ )}
312
330
  {customChannelActions}
313
331
  </ul>
314
332
  </div>
@@ -225,7 +225,7 @@ const Template: StoryFn<TemplateProps> = (args) => {
225
225
 
226
226
  return (
227
227
  <Chat client={client}>
228
- <div className="h-screen w-full bg-white">
228
+ <div className="h-screen w-full bg-[#FBFAF9]">
229
229
  <ChannelView {...channelViewProps} channel={channel} />
230
230
  </div>
231
231
  </Chat>
@@ -269,6 +269,30 @@ DmAgentHeader.parameters = {
269
269
  },
270
270
  }
271
271
 
272
+ export const RestrictedOfficialChannel: StoryFn<TemplateProps> = Template.bind(
273
+ {}
274
+ )
275
+ RestrictedOfficialChannel.args = {
276
+ showBackButton: false,
277
+ // Restricted surface for the Linktree official channel:
278
+ showBlockParticipant: false,
279
+ showReportParticipant: false,
280
+ showFollowerStatus: false,
281
+ composerDisabled: true,
282
+ composerDisabledReason: 'Only Linktree can send messages on this thread',
283
+ followerStatus: true, // would normally render "Subscribed to you" — suppressed here
284
+ onLeaveConversation: (channel) =>
285
+ console.log('Leave conversation:', channel.id),
286
+ }
287
+ RestrictedOfficialChannel.parameters = {
288
+ docs: {
289
+ description: {
290
+ story:
291
+ 'Restricted action surface used by the Linktree official channel: block, report, and the subscription-status label are hidden, and the composer is replaced by a locked panel explaining the linker cannot send messages on this thread. Delete conversation, favorite, and chat info remain available. Open the chat info dialog (3-dot / name click) to see block & report removed.',
292
+ },
293
+ },
294
+ }
295
+
272
296
  export const WithBackButton: StoryFn<TemplateProps> = Template.bind({})
273
297
  WithBackButton.args = {
274
298
  showBackButton: true,
@@ -438,7 +462,7 @@ const WithStarButtonTemplate: StoryFn<ComponentProps> = (args) => {
438
462
 
439
463
  return (
440
464
  <Chat client={client}>
441
- <div className="h-screen w-full bg-white">
465
+ <div className="h-screen w-full bg-[#FBFAF9]">
442
466
  <ChannelView {...args} channel={channel} />
443
467
  </div>
444
468
  </Chat>
@@ -495,7 +519,7 @@ const EmptyTemplate: StoryFn<ComponentProps> = (args) => {
495
519
 
496
520
  return (
497
521
  <Chat client={client}>
498
- <div className="h-screen w-full bg-white">
522
+ <div className="h-screen w-full bg-[#FBFAF9]">
499
523
  <ChannelView {...args} channel={channel} />
500
524
  </div>
501
525
  </Chat>
@@ -45,12 +45,15 @@ vi.mock('../providers/MessagingProvider', () => ({
45
45
  useMessagingContext: () => ({ service: null, debug: false }),
46
46
  }))
47
47
 
48
+ const messageInputProps: Array<Record<string, unknown>> = []
48
49
  vi.mock('./CustomMessageInput', () => ({
49
- CustomMessageInput: ({
50
- renderActions,
51
- }: {
50
+ CustomMessageInput: (props: {
52
51
  renderActions?: () => React.ReactNode
53
- }) => <div data-testid="message-input">{renderActions?.()}</div>,
52
+ [key: string]: unknown
53
+ }) => {
54
+ messageInputProps.push({ ...props })
55
+ return <div data-testid="message-input">{props.renderActions?.()}</div>
56
+ },
54
57
  }))
55
58
 
56
59
  vi.mock('./CustomMessage', () => ({
@@ -65,8 +68,12 @@ vi.mock('./CustomDateSeparator', () => ({
65
68
  CustomDateSeparator: () => <div data-testid="custom-date-separator" />,
66
69
  }))
67
70
 
71
+ const channelInfoDialogProps: Array<Record<string, unknown>> = []
68
72
  vi.mock('./ChannelInfoDialog', () => ({
69
- ChannelInfoDialog: () => <div data-testid="channel-info-dialog" />,
73
+ ChannelInfoDialog: (props: Record<string, unknown>) => {
74
+ channelInfoDialogProps.push({ ...props })
75
+ return <div data-testid="channel-info-dialog" />
76
+ },
70
77
  }))
71
78
 
72
79
  const avatarRenderCalls: Array<Record<string, unknown>> = []
@@ -144,9 +151,81 @@ describe('ChannelView', () => {
144
151
  activeChannel = undefined
145
152
  activeChannelProps = {}
146
153
  avatarRenderCalls.length = 0
154
+ channelInfoDialogProps.length = 0
155
+ messageInputProps.length = 0
147
156
  mockIsStarred = false
148
157
  })
149
158
 
159
+ const lastDialogProps = () =>
160
+ channelInfoDialogProps[channelInfoDialogProps.length - 1]
161
+ const lastInputProps = () =>
162
+ messageInputProps[messageInputProps.length - 1]
163
+
164
+ it('keeps block and report visible by default in the info dialog', () => {
165
+ renderWithProviders(<ChannelView channel={createChannel()} />)
166
+
167
+ expect(lastDialogProps().showBlockParticipant).not.toBe(false)
168
+ expect(lastDialogProps().showReportParticipant).not.toBe(false)
169
+ })
170
+
171
+ it('hides block and report in the info dialog when restricted', () => {
172
+ renderWithProviders(
173
+ <ChannelView
174
+ channel={createChannel()}
175
+ showBlockParticipant={false}
176
+ showReportParticipant={false}
177
+ />
178
+ )
179
+
180
+ expect(lastDialogProps().showBlockParticipant).toBe(false)
181
+ expect(lastDialogProps().showReportParticipant).toBe(false)
182
+ })
183
+
184
+ it('passes the follower status label to the info dialog by default', () => {
185
+ const channel = createChannel()
186
+ ;(channel as unknown as { data: Record<string, unknown> }).data = {
187
+ isFollower: true,
188
+ }
189
+
190
+ renderWithProviders(<ChannelView channel={channel} />)
191
+
192
+ expect(lastDialogProps().followerStatusLabel).toBe('Subscribed to you')
193
+ })
194
+
195
+ it('suppresses the follower status label when showFollowerStatus is false', () => {
196
+ const channel = createChannel()
197
+ ;(channel as unknown as { data: Record<string, unknown> }).data = {
198
+ isFollower: true,
199
+ }
200
+
201
+ renderWithProviders(
202
+ <ChannelView channel={channel} showFollowerStatus={false} />
203
+ )
204
+
205
+ expect(lastDialogProps().followerStatusLabel).toBeUndefined()
206
+ })
207
+
208
+ it('passes composer disabled state and reason to the message input', () => {
209
+ renderWithProviders(
210
+ <ChannelView
211
+ channel={createChannel()}
212
+ composerDisabled
213
+ composerDisabledReason="Message Linktree from your profile to reply"
214
+ />
215
+ )
216
+
217
+ expect(lastInputProps().disabled).toBe(true)
218
+ expect(lastInputProps().disabledReason).toBe(
219
+ 'Message Linktree from your profile to reply'
220
+ )
221
+ })
222
+
223
+ it('does not disable the composer by default', () => {
224
+ renderWithProviders(<ChannelView channel={createChannel()} />)
225
+
226
+ expect(lastInputProps().disabled).not.toBe(true)
227
+ })
228
+
150
229
  it('renders conversation footer between message list and message input', () => {
151
230
  const channel = createChannel()
152
231
  const renderConversationFooter = vi.fn((currentChannel: Channel) => (
@@ -259,6 +259,11 @@ const ChannelViewInner: React.FC<{
259
259
  onDeleteConversationClick?: () => void
260
260
  onBlockParticipantClick?: () => void
261
261
  onReportParticipantClick?: () => void
262
+ showBlockParticipant?: boolean
263
+ showReportParticipant?: boolean
264
+ showFollowerStatus?: boolean
265
+ composerDisabled?: boolean
266
+ composerDisabledReason?: string
262
267
  showStarButton?: boolean
263
268
  chatbotVotingEnabled?: boolean
264
269
  renderChannelBanner?: () => React.ReactNode
@@ -282,6 +287,11 @@ const ChannelViewInner: React.FC<{
282
287
  onDeleteConversationClick,
283
288
  onBlockParticipantClick,
284
289
  onReportParticipantClick,
290
+ showBlockParticipant = true,
291
+ showReportParticipant = true,
292
+ showFollowerStatus = true,
293
+ composerDisabled = false,
294
+ composerDisabledReason,
285
295
  showStarButton = false,
286
296
  chatbotVotingEnabled = false,
287
297
  renderChannelBanner,
@@ -323,8 +333,12 @@ const ChannelViewInner: React.FC<{
323
333
  currentUserIsAccount === false &&
324
334
  participantIsAccount === true
325
335
 
326
- // Get follower status label from channel data
336
+ // Get follower status label from channel data.
337
+ // Suppressed entirely when showFollowerStatus is false (e.g. the Linktree
338
+ // official channel hides subscription status).
327
339
  const followerStatusLabel = React.useMemo(() => {
340
+ if (!showFollowerStatus) return undefined
341
+
328
342
  const channelExtraData = (channel.data ?? {}) as {
329
343
  followerStatus?: string
330
344
  isFollower?: boolean
@@ -342,7 +356,7 @@ const ChannelViewInner: React.FC<{
342
356
  }
343
357
  // Otherwise, don't show any status
344
358
  return undefined
345
- }, [channel.data])
359
+ }, [channel.data, showFollowerStatus])
346
360
 
347
361
  const handleShowInfo = useCallback(() => {
348
362
  infoDialogRef.current?.showModal()
@@ -424,6 +438,8 @@ const ChannelViewInner: React.FC<{
424
438
  key="lt-channel-message-input"
425
439
  renderActions={() => renderMessageInputActions?.(channel)}
426
440
  renderFooter={() => renderMessageInputFooter?.(channel)}
441
+ disabled={composerDisabled}
442
+ disabledReason={composerDisabledReason}
427
443
  />
428
444
  </Window>
429
445
  </WithComponents>
@@ -439,6 +455,8 @@ const ChannelViewInner: React.FC<{
439
455
  onLeaveConversation={onLeaveConversation}
440
456
  onBlockParticipant={onBlockParticipant}
441
457
  showDeleteConversation={showDeleteConversation}
458
+ showBlockParticipant={showBlockParticipant}
459
+ showReportParticipant={showReportParticipant}
442
460
  onDeleteConversationClick={onDeleteConversationClick}
443
461
  onBlockParticipantClick={onBlockParticipantClick}
444
462
  onReportParticipantClick={onReportParticipantClick}
@@ -468,6 +486,11 @@ export const ChannelView = React.memo<ChannelViewProps>(
468
486
  onDeleteConversationClick,
469
487
  onBlockParticipantClick,
470
488
  onReportParticipantClick,
489
+ showBlockParticipant = true,
490
+ showReportParticipant = true,
491
+ showFollowerStatus = true,
492
+ composerDisabled = false,
493
+ composerDisabledReason,
471
494
  dmAgentEnabled,
472
495
  messageMetadata,
473
496
  onMessageSent,
@@ -580,6 +603,11 @@ export const ChannelView = React.memo<ChannelViewProps>(
580
603
  onDeleteConversationClick={onDeleteConversationClick}
581
604
  onBlockParticipantClick={onBlockParticipantClick}
582
605
  onReportParticipantClick={onReportParticipantClick}
606
+ showBlockParticipant={showBlockParticipant}
607
+ showReportParticipant={showReportParticipant}
608
+ showFollowerStatus={showFollowerStatus}
609
+ composerDisabled={composerDisabled}
610
+ composerDisabledReason={composerDisabledReason}
583
611
  showStarButton={showStarButton}
584
612
  dmAgentEnabled={dmAgentEnabled}
585
613
  chatbotVotingEnabled={chatbotVotingEnabled}
@@ -177,4 +177,44 @@ describe('CustomMessageInput', () => {
177
177
  // useMessageComposerHasSendableData is mocked to return false
178
178
  expect(sendButton).toBeDisabled()
179
179
  })
180
+
181
+ it('replaces the composer with the locked panel when disabled (channel not frozen)', () => {
182
+ mockChannelData = {}
183
+
184
+ const { container } = renderWithProviders(
185
+ <CustomMessageInput
186
+ disabled
187
+ disabledReason="Only Linktree can send messages on this thread"
188
+ />
189
+ )
190
+
191
+ // The interactive input is gone entirely — no textarea, no send button.
192
+ expect(
193
+ screen.queryByTestId('stream-message-input')
194
+ ).not.toBeInTheDocument()
195
+ expect(screen.queryByTestId('textarea-composer')).not.toBeInTheDocument()
196
+ expect(container.querySelector('.message-input')).not.toBeInTheDocument()
197
+
198
+ // The locked panel with the reason replaces it.
199
+ expect(
200
+ container.querySelector('.messaging-composer-locked-panel')
201
+ ).toBeInTheDocument()
202
+ expect(
203
+ screen.getByText(/Only Linktree can send messages on this thread/i)
204
+ ).toBeInTheDocument()
205
+ })
206
+
207
+ it('does not render the locked panel when the composer is not disabled', () => {
208
+ mockChannelData = {}
209
+
210
+ const { container } = renderWithProviders(
211
+ <CustomMessageInput disabledReason="should not show" />
212
+ )
213
+
214
+ expect(
215
+ container.querySelector('.messaging-composer-locked-panel')
216
+ ).not.toBeInTheDocument()
217
+ expect(screen.queryByText(/should not show/i)).not.toBeInTheDocument()
218
+ expect(screen.getByTestId('stream-message-input')).toBeInTheDocument()
219
+ })
180
220
  })