@linktr.ee/messaging-react 3.0.0 → 3.1.0-rc-1780514752

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-Cqld0-Ws.js → Card-0TLA8XHU.js} +3 -3
  2. package/dist/{Card-Cqld0-Ws.js.map → Card-0TLA8XHU.js.map} +1 -1
  3. package/dist/{Card-B7ePjYQ6.cjs → Card-B-D_LbnV.cjs} +2 -2
  4. package/dist/{Card-B7ePjYQ6.cjs.map → Card-B-D_LbnV.cjs.map} +1 -1
  5. package/dist/{Card-C46z9zz4.js → Card-BaaerKBC.js} +2 -2
  6. package/dist/{Card-C46z9zz4.js.map → Card-BaaerKBC.js.map} +1 -1
  7. package/dist/{Card-Cq0x0bbb.cjs → Card-Bfxdewx_.cjs} +2 -2
  8. package/dist/{Card-Cq0x0bbb.cjs.map → Card-Bfxdewx_.cjs.map} +1 -1
  9. package/dist/{Card-C-ZIQW_q.js → Card-DZVa2CeI.js} +2 -2
  10. package/dist/{Card-C-ZIQW_q.js.map → Card-DZVa2CeI.js.map} +1 -1
  11. package/dist/{Card-Drz28Q-Y.cjs → Card-DbdWDBMe.cjs} +2 -2
  12. package/dist/{Card-Drz28Q-Y.cjs.map → Card-DbdWDBMe.cjs.map} +1 -1
  13. package/dist/{LockedThumbnail-D5NHhET2.js → LockedThumbnail-B4gDHeh7.js} +2 -2
  14. package/dist/{LockedThumbnail-D5NHhET2.js.map → LockedThumbnail-B4gDHeh7.js.map} +1 -1
  15. package/dist/{LockedThumbnail--h4GTH41.cjs → LockedThumbnail-DkwFwgpU.cjs} +2 -2
  16. package/dist/{LockedThumbnail--h4GTH41.cjs.map → LockedThumbnail-DkwFwgpU.cjs.map} +1 -1
  17. package/dist/{index-BUT2yBvJ.js → index-BmCc1-F3.js} +1147 -1118
  18. package/dist/index-BmCc1-F3.js.map +1 -0
  19. package/dist/index-Cg-bxSZn.cjs +2 -0
  20. package/dist/index-Cg-bxSZn.cjs.map +1 -0
  21. package/dist/index.cjs +1 -1
  22. package/dist/index.d.ts +30 -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 +25 -0
  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 +42 -0
  31. package/src/components/CustomMessageInput/index.tsx +38 -12
  32. package/src/types.ts +35 -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-Cg-bxSZn.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,36 @@ 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
+ * Lock the message composer (read-only, send disabled). Defaults to false.
204
+ * Combined with the channel's `frozen` flag — either one locks the input.
205
+ * Used by the Linktree official channel, where the composer stays locked
206
+ * until the linker messages Linktree from its public profile.
207
+ */
208
+ composerDisabled?: boolean;
209
+ /**
210
+ * Explanatory text rendered below the composer while it is locked.
211
+ * Only shown when `composerDisabled` is true (or the channel is frozen).
212
+ */
213
+ composerDisabledReason?: string;
184
214
  /**
185
215
  * When true and DM agent is active on the channel (not paused),
186
216
  * 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-BmCc1-F3.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-rc-1780514752",
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>
@@ -269,6 +269,31 @@ 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:
283
+ 'Message Linktree from your profile to start the conversation',
284
+ followerStatus: true, // would normally render "Subscribed to you" — suppressed here
285
+ onLeaveConversation: (channel) =>
286
+ console.log('Leave conversation:', channel.id),
287
+ }
288
+ RestrictedOfficialChannel.parameters = {
289
+ docs: {
290
+ description: {
291
+ story:
292
+ 'Restricted action surface used by the Linktree official channel: block, report, and the subscription-status label are hidden, and the composer is locked with an explanatory reason. Delete conversation, favorite, and chat info remain available. Open the chat info dialog (3-dot / name click) to see block & report removed.',
293
+ },
294
+ },
295
+ }
296
+
272
297
  export const WithBackButton: StoryFn<TemplateProps> = Template.bind({})
273
298
  WithBackButton.args = {
274
299
  showBackButton: true,
@@ -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,46 @@ describe('CustomMessageInput', () => {
177
177
  // useMessageComposerHasSendableData is mocked to return false
178
178
  expect(sendButton).toBeDisabled()
179
179
  })
180
+
181
+ it('locks the composer when the disabled prop is true (channel not frozen)', () => {
182
+ mockChannelData = {}
183
+
184
+ const { container } = renderWithProviders(<CustomMessageInput disabled />)
185
+
186
+ const messageInput = container.querySelector('.message-input')
187
+ expect(messageInput).toHaveAttribute('aria-disabled', 'true')
188
+ expect(messageInput).toHaveAttribute('inert')
189
+
190
+ const textarea = screen.getByTestId('textarea-composer')
191
+ expect(textarea).toHaveAttribute('readonly')
192
+ expect(textarea).toHaveAttribute('tabindex', '-1')
193
+
194
+ const sendButton = screen.getByRole('button', { name: /send/i })
195
+ expect(sendButton).toBeDisabled()
196
+ })
197
+
198
+ it('renders the disabled reason when the composer is disabled with a reason', () => {
199
+ mockChannelData = {}
200
+
201
+ renderWithProviders(
202
+ <CustomMessageInput
203
+ disabled
204
+ disabledReason="Message Linktree from your profile to reply"
205
+ />
206
+ )
207
+
208
+ expect(
209
+ screen.getByText(/Message Linktree from your profile to reply/i)
210
+ ).toBeInTheDocument()
211
+ })
212
+
213
+ it('does not render the disabled reason when the composer is not disabled', () => {
214
+ mockChannelData = {}
215
+
216
+ renderWithProviders(
217
+ <CustomMessageInput disabledReason="should not show" />
218
+ )
219
+
220
+ expect(screen.queryByText(/should not show/i)).not.toBeInTheDocument()
221
+ })
180
222
  })
@@ -1,5 +1,5 @@
1
1
  import { ArrowUpIcon } from '@phosphor-icons/react'
2
- import React from 'react'
2
+ import React, { useCallback } from 'react'
3
3
  import {
4
4
  AttachmentPreviewList as DefaultAttachmentPreviewList,
5
5
  MessageInput,
@@ -29,13 +29,13 @@ const DefaultSendButton: React.FC<{
29
29
  </button>
30
30
  )
31
31
 
32
- const CustomMessageInputInner: React.FC = () => {
33
- const { channel } = useChannelStateContext()
32
+ const CustomMessageInputInner: React.FC<{ disabled?: boolean }> = ({
33
+ disabled = false,
34
+ }) => {
34
35
  const { handleSubmit } = useMessageInputContext()
35
36
 
36
37
  const hasSendableData = useMessageComposerHasSendableData()
37
- const isFrozen = channel?.data?.frozen === true
38
- const isSendDisabled = isFrozen || !hasSendableData
38
+ const isSendDisabled = disabled || !hasSendableData
39
39
 
40
40
  const {
41
41
  SendButton = DefaultSendButton,
@@ -50,16 +50,16 @@ const CustomMessageInputInner: React.FC = () => {
50
50
  <div className="flex">
51
51
  <div className="w-full ml-2 mr-4 self-center leading-[0]">
52
52
  <TextareaComposer
53
- aria-disabled={isFrozen || undefined}
53
+ aria-disabled={disabled || undefined}
54
54
  className="w-full resize-none outline-none leading-6"
55
55
  // While this might usually be considered an anti-pattern, in most
56
56
  // cases, when a message thread is rendered, we want the input to
57
57
  // gain focus automatically.
58
58
  // eslint-disable-next-line jsx-a11y/no-autofocus
59
- autoFocus={!isFrozen}
59
+ autoFocus={!disabled}
60
60
  maxRows={4}
61
- readOnly={isFrozen}
62
- tabIndex={isFrozen ? -1 : undefined}
61
+ readOnly={disabled}
62
+ tabIndex={disabled ? -1 : undefined}
63
63
  />
64
64
  </div>
65
65
  <SendButton
@@ -78,21 +78,42 @@ const CustomMessageInputInner: React.FC = () => {
78
78
  export interface CustomMessageInputProps {
79
79
  renderActions?: () => React.ReactNode
80
80
  renderFooter?: () => React.ReactNode
81
+ /**
82
+ * Lock the composer (read-only textarea, disabled send, no autofocus).
83
+ * Combined with the channel's `frozen` flag — either one locks the input.
84
+ * Used by the Linktree official channel, where the composer stays locked
85
+ * until the linker messages Linktree from its public profile.
86
+ * Defaults to false.
87
+ */
88
+ disabled?: boolean
89
+ /**
90
+ * Explanatory text rendered below the composer while it is locked.
91
+ * Only shown when the composer is disabled (via `disabled` or `frozen`).
92
+ */
93
+ disabledReason?: string
81
94
  }
82
95
 
83
96
  export const CustomMessageInput: React.FC<CustomMessageInputProps> = ({
84
97
  renderActions,
85
98
  renderFooter,
99
+ disabled = false,
100
+ disabledReason,
86
101
  }) => {
87
102
  const { channel } = useChannelStateContext()
88
103
  const isFrozen = channel?.data?.frozen === true
104
+ const isLocked = isFrozen || disabled
105
+
106
+ const Input = useCallback(
107
+ () => <CustomMessageInputInner disabled={isLocked} />,
108
+ [isLocked]
109
+ )
89
110
 
90
111
  return (
91
112
  <div className="flex flex-col gap-4 p-4">
92
113
  <div
93
114
  // @ts-expect-error Only React 19 onwards has `inert` in its types.
94
- inert={isFrozen ? '' : undefined}
95
- aria-disabled={isFrozen || undefined}
115
+ inert={isLocked ? '' : undefined}
116
+ aria-disabled={isLocked || undefined}
96
117
  className="message-input flex items-end gap-4 aria-disabled:opacity-40"
97
118
  >
98
119
  {renderActions && (
@@ -100,8 +121,13 @@ export const CustomMessageInput: React.FC<CustomMessageInputProps> = ({
100
121
  {renderActions()}
101
122
  </div>
102
123
  )}
103
- <MessageInput Input={CustomMessageInputInner} />
124
+ <MessageInput Input={Input} />
104
125
  </div>
126
+ {isLocked && disabledReason ? (
127
+ <p className="message-input-disabled-reason px-2 text-center text-sm text-black/55">
128
+ {disabledReason}
129
+ </p>
130
+ ) : null}
105
131
  {renderFooter?.()}
106
132
  </div>
107
133
  )