@linktr.ee/messaging-react 3.1.4-rc-1780636753 → 3.3.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 (41) hide show
  1. package/README.md +1 -18
  2. package/dist/{Card-jyXjZZ0u.js → Card-BAc2cgtn.js} +3 -3
  3. package/dist/{Card-jyXjZZ0u.js.map → Card-BAc2cgtn.js.map} +1 -1
  4. package/dist/{Card-D_XOj1eE.cjs → Card-Cn1cBVnr.cjs} +2 -2
  5. package/dist/{Card-D_XOj1eE.cjs.map → Card-Cn1cBVnr.cjs.map} +1 -1
  6. package/dist/{Card-BkgsPkp4.cjs → Card-DAyszUxa.cjs} +2 -2
  7. package/dist/{Card-BkgsPkp4.cjs.map → Card-DAyszUxa.cjs.map} +1 -1
  8. package/dist/{Card-BwFdJXYm.js → Card-D_2VQScd.js} +2 -2
  9. package/dist/{Card-BwFdJXYm.js.map → Card-D_2VQScd.js.map} +1 -1
  10. package/dist/{Card-B9atg4sP.js → Card-D_G8133I.js} +2 -2
  11. package/dist/{Card-B9atg4sP.js.map → Card-D_G8133I.js.map} +1 -1
  12. package/dist/{Card-1U2tLPcp.cjs → Card-gYxPXe_W.cjs} +2 -2
  13. package/dist/{Card-1U2tLPcp.cjs.map → Card-gYxPXe_W.cjs.map} +1 -1
  14. package/dist/{LockedThumbnail-Dwt_goCX.js → LockedThumbnail-C7tWpOQr.js} +2 -2
  15. package/dist/{LockedThumbnail-Dwt_goCX.js.map → LockedThumbnail-C7tWpOQr.js.map} +1 -1
  16. package/dist/{LockedThumbnail-oxtdpgut.cjs → LockedThumbnail-DtOTZl3l.cjs} +2 -2
  17. package/dist/{LockedThumbnail-oxtdpgut.cjs.map → LockedThumbnail-DtOTZl3l.cjs.map} +1 -1
  18. package/dist/{index-CO975B6P.js → index-C_NFzAB9.js} +1228 -1289
  19. package/dist/index-C_NFzAB9.js.map +1 -0
  20. package/dist/index-_Se6ovQm.cjs +2 -0
  21. package/dist/index-_Se6ovQm.cjs.map +1 -0
  22. package/dist/index.cjs +1 -1
  23. package/dist/index.d.ts +24 -49
  24. package/dist/index.js +1 -1
  25. package/package.json +4 -3
  26. package/src/components/ChannelActionsMenu/ChannelActionsMenu.test.tsx +305 -0
  27. package/src/components/ChannelActionsMenu/index.tsx +221 -0
  28. package/src/components/ChannelList/index.test.tsx +3 -151
  29. package/src/components/ChannelList/index.tsx +4 -72
  30. package/src/components/ChannelView.stories.tsx +3 -73
  31. package/src/components/ChannelView.test.tsx +33 -29
  32. package/src/components/ChannelView.tsx +71 -109
  33. package/src/components/MessagingShell/index.tsx +2 -0
  34. package/src/hooks/useChannelModerationActions.ts +227 -0
  35. package/src/index.ts +0 -1
  36. package/src/types.ts +25 -48
  37. package/dist/index-CO975B6P.js.map +0 -1
  38. package/dist/index-D4Dse1Lu.cjs +0 -2
  39. package/dist/index-D4Dse1Lu.cjs.map +0 -1
  40. package/src/components/ChannelInfoDialog/ChannelInfoDialog.test.tsx +0 -333
  41. package/src/components/ChannelInfoDialog/index.tsx +0 -336
@@ -1,4 +1,3 @@
1
- import { act, waitFor } from '@testing-library/react'
2
1
  import React from 'react'
3
2
  import type { Channel } from 'stream-chat'
4
3
  import { describe, expect, it, vi, beforeEach } from 'vitest'
@@ -57,7 +56,7 @@ describe('ChannelList', () => {
57
56
  })
58
57
  })
59
58
 
60
- it('wraps channelRenderFilterFn to restore pending messages and delegates to consumer filter', async () => {
59
+ it('wraps channelRenderFilterFn to restore pending messages and delegates to consumer filter', () => {
61
60
  const filterFn = vi.fn((channels: Channel[]) => channels)
62
61
 
63
62
  renderWithProviders(
@@ -78,156 +77,9 @@ describe('ChannelList', () => {
78
77
 
79
78
  // When the wrapper is called, it should delegate to the consumer's filter
80
79
  const mockChannels = [
81
- {
82
- cid: 'ch-1',
83
- countUnread: () => 0,
84
- state: { pending_messages: [], addMessageSorted: vi.fn() },
85
- },
80
+ { cid: 'ch-1', state: { pending_messages: [], addMessageSorted: vi.fn() } },
86
81
  ]
87
- await act(async () => {
88
- streamProps.channelRenderFilterFn!(mockChannels)
89
- await Promise.resolve()
90
- })
82
+ streamProps.channelRenderFilterFn!(mockChannels)
91
83
  expect(filterFn).toHaveBeenCalledWith(mockChannels)
92
84
  })
93
-
94
- it('notifies hosts with derived visible-channel state', async () => {
95
- const onStateChange = vi.fn()
96
- const visibleChannel = {
97
- cid: 'ch-visible',
98
- countUnread: () => 2,
99
- state: { pending_messages: [], addMessageSorted: vi.fn() },
100
- }
101
- const hiddenChannel = {
102
- cid: 'ch-hidden',
103
- countUnread: () => 7,
104
- state: { pending_messages: [], addMessageSorted: vi.fn() },
105
- }
106
- const filterFn = vi.fn((channels: Channel[]) => channels.slice(0, 1))
107
-
108
- renderWithProviders(
109
- React.createElement(ChannelList, {
110
- ...defaultProps,
111
- channelRenderFilterFn: filterFn,
112
- onStateChange,
113
- } as never)
114
- )
115
-
116
- const streamProps = streamChannelListMock.mock.calls[0][0] as {
117
- channelRenderFilterFn?: (channels: Channel[]) => Channel[]
118
- }
119
-
120
- await act(async () => {
121
- streamProps.channelRenderFilterFn!([
122
- visibleChannel as unknown as Channel,
123
- hiddenChannel as unknown as Channel,
124
- ])
125
- await Promise.resolve()
126
- })
127
-
128
- await waitFor(() => {
129
- expect(onStateChange).toHaveBeenLastCalledWith({
130
- isInitialLoadSettled: true,
131
- channels: [visibleChannel],
132
- rawChannels: [visibleChannel, hiddenChannel],
133
- hasChannels: true,
134
- unreadCount: 1,
135
- })
136
- })
137
- })
138
-
139
- it('reports a settled empty state when no visible channels remain', async () => {
140
- const onStateChange = vi.fn()
141
- const filterFn = vi.fn(() => [])
142
- const hiddenChannel = {
143
- cid: 'ch-hidden',
144
- countUnread: () => 3,
145
- state: { pending_messages: [], addMessageSorted: vi.fn() },
146
- }
147
-
148
- renderWithProviders(
149
- React.createElement(ChannelList, {
150
- ...defaultProps,
151
- channelRenderFilterFn: filterFn,
152
- onStateChange,
153
- } as never)
154
- )
155
-
156
- const streamProps = streamChannelListMock.mock.calls[0][0] as {
157
- channelRenderFilterFn?: (channels: Channel[]) => Channel[]
158
- }
159
-
160
- await act(async () => {
161
- streamProps.channelRenderFilterFn!([hiddenChannel as unknown as Channel])
162
- await Promise.resolve()
163
- })
164
-
165
- await waitFor(() => {
166
- expect(onStateChange).toHaveBeenLastCalledWith({
167
- isInitialLoadSettled: true,
168
- channels: [],
169
- rawChannels: [hiddenChannel],
170
- hasChannels: false,
171
- unreadCount: 0,
172
- })
173
- })
174
- })
175
-
176
- it('resets derived state when filters change before the new list resolves', async () => {
177
- const onStateChange = vi.fn()
178
- const firstChannel = {
179
- cid: 'ch-first',
180
- countUnread: () => 1,
181
- state: { pending_messages: [], addMessageSorted: vi.fn() },
182
- }
183
-
184
- const { rerender } = renderWithProviders(
185
- React.createElement(ChannelList, {
186
- ...defaultProps,
187
- onStateChange,
188
- } as never)
189
- )
190
-
191
- let streamProps = streamChannelListMock.mock.calls.at(-1)?.[0] as {
192
- channelRenderFilterFn?: (channels: Channel[]) => Channel[]
193
- }
194
-
195
- await act(async () => {
196
- streamProps.channelRenderFilterFn!([firstChannel as unknown as Channel])
197
- await Promise.resolve()
198
- })
199
-
200
- await waitFor(() => {
201
- expect(onStateChange).toHaveBeenLastCalledWith({
202
- isInitialLoadSettled: true,
203
- channels: [firstChannel],
204
- rawChannels: [firstChannel],
205
- hasChannels: true,
206
- unreadCount: 1,
207
- })
208
- })
209
-
210
- rerender(
211
- React.createElement(ChannelList, {
212
- ...defaultProps,
213
- filters: { type: 'messaging', frozen: true },
214
- onStateChange,
215
- } as never)
216
- )
217
-
218
- streamProps = streamChannelListMock.mock.calls.at(-1)?.[0] as {
219
- channelRenderFilterFn?: (channels: Channel[]) => Channel[]
220
- }
221
- expect(typeof streamProps.channelRenderFilterFn).toBe('function')
222
-
223
- await waitFor(() => {
224
- expect(onStateChange).toHaveBeenLastCalledWith({
225
- isInitialLoadSettled: false,
226
- channels: [],
227
- rawChannels: [],
228
- hasChannels: false,
229
- unreadCount: 0,
230
- })
231
- })
232
- })
233
85
  })
@@ -5,21 +5,13 @@ import { ChannelList as StreamChannelList } from 'stream-chat-react'
5
5
 
6
6
  import { restorePendingMessages } from '../../hooks/useRestorePendingMessages'
7
7
  import { useMessagingContext } from '../../providers/MessagingProvider'
8
- import type { ChannelListDerivedState, ChannelListProps } from '../../types'
8
+ import type { ChannelListProps } from '../../types'
9
9
 
10
10
  import { ChannelListProvider } from './ChannelListContext'
11
11
  import CustomChannelPreview from './CustomChannelPreview'
12
12
 
13
13
  const DEFAULT_SORT = { last_message_at: -1 } as const
14
14
 
15
- const EMPTY_DERIVED_STATE: ChannelListDerivedState = {
16
- isInitialLoadSettled: false,
17
- channels: [],
18
- rawChannels: [],
19
- hasChannels: false,
20
- unreadCount: 0,
21
- }
22
-
23
15
  /**
24
16
  * Channel list component with customizable header and actions
25
17
  */
@@ -35,7 +27,6 @@ export const ChannelList = React.memo<ChannelListProps>(
35
27
  customEmptyStateIndicator,
36
28
  renderMessagePreview,
37
29
  viewerLanguage,
38
- onStateChange,
39
30
  }) => {
40
31
  // Track renders
41
32
  const renderCountRef = React.useRef(0)
@@ -43,42 +34,6 @@ export const ChannelList = React.memo<ChannelListProps>(
43
34
 
44
35
  // Get debug flag from context
45
36
  const { debug = false } = useMessagingContext()
46
- const [derivedState, setDerivedState] = React.useState<ChannelListDerivedState>(
47
- EMPTY_DERIVED_STATE
48
- )
49
- const listIdentityKey = React.useMemo(
50
- () => `${JSON.stringify(filters)}:${JSON.stringify(sort)}`,
51
- [filters, sort]
52
- )
53
- const pendingDerivedStateRef = React.useRef<ChannelListDerivedState>(
54
- EMPTY_DERIVED_STATE
55
- )
56
- const publishScheduledRef = React.useRef(false)
57
- const listVersionRef = React.useRef(0)
58
-
59
- const publishDerivedState = React.useCallback(
60
- (nextState: ChannelListDerivedState) => {
61
- const publishVersion = listVersionRef.current
62
- pendingDerivedStateRef.current = nextState
63
- if (publishScheduledRef.current) {
64
- return
65
- }
66
-
67
- publishScheduledRef.current = true
68
- queueMicrotask(() => {
69
- if (publishVersion !== listVersionRef.current) {
70
- publishScheduledRef.current = false
71
- return
72
- }
73
- publishScheduledRef.current = false
74
- setDerivedState((currentState) => {
75
- const pendingState = pendingDerivedStateRef.current
76
- return currentState === pendingState ? currentState : pendingState
77
- })
78
- })
79
- },
80
- []
81
- )
82
37
 
83
38
  // Wrap channelRenderFilterFn to restore pending messages for all channels
84
39
  // as soon as they are loaded, without waiting for the user to click into each one.
@@ -87,23 +42,11 @@ export const ChannelList = React.memo<ChannelListProps>(
87
42
  for (const channel of channels) {
88
43
  restorePendingMessages(channel)
89
44
  }
90
-
91
- const visibleChannels = channelRenderFilterFn
45
+ return channelRenderFilterFn
92
46
  ? channelRenderFilterFn(channels)
93
47
  : channels
94
-
95
- publishDerivedState({
96
- isInitialLoadSettled: true,
97
- channels: visibleChannels,
98
- rawChannels: channels,
99
- hasChannels: visibleChannels.length > 0,
100
- unreadCount: visibleChannels.filter((channel) => channel.countUnread() > 0)
101
- .length,
102
- })
103
-
104
- return visibleChannels
105
48
  },
106
- [channelRenderFilterFn, publishDerivedState]
49
+ [channelRenderFilterFn]
107
50
  )
108
51
 
109
52
  if (debug) {
@@ -131,17 +74,6 @@ export const ChannelList = React.memo<ChannelListProps>(
131
74
  ]
132
75
  )
133
76
 
134
- React.useEffect(() => {
135
- listVersionRef.current += 1
136
- pendingDerivedStateRef.current = EMPTY_DERIVED_STATE
137
- publishScheduledRef.current = false
138
- setDerivedState(EMPTY_DERIVED_STATE)
139
- }, [listIdentityKey])
140
-
141
- React.useEffect(() => {
142
- onStateChange?.(derivedState)
143
- }, [derivedState, onStateChange])
144
-
145
77
  return (
146
78
  <div
147
79
  className={classNames(
@@ -153,7 +85,7 @@ export const ChannelList = React.memo<ChannelListProps>(
153
85
  <div className="flex-1 overflow-hidden min-w-0">
154
86
  <ChannelListProvider value={contextValue}>
155
87
  <StreamChannelList
156
- key={listIdentityKey}
88
+ key={`${JSON.stringify(filters)}:${JSON.stringify(sort)}`}
157
89
  filters={filters}
158
90
  sort={sort}
159
91
  options={{ limit: 30 }}
@@ -240,12 +240,12 @@ Default.args = {
240
240
  console.log('Leave conversation:', channel.id),
241
241
  onBlockParticipant: (participantId) =>
242
242
  console.log('Block participant:', participantId),
243
- followerStatus: true, // Shows "Subscribed to you"
243
+ followerStatus: true,
244
244
  }
245
245
  Default.parameters = {
246
246
  docs: {
247
247
  description: {
248
- story: 'Default channel view with messages and conversation header, showing subscriber status.',
248
+ story: 'Default channel view with messages and conversation header.',
249
249
  },
250
250
  },
251
251
  }
@@ -277,10 +277,8 @@ RestrictedOfficialChannel.args = {
277
277
  // Restricted surface for the Linktree official channel:
278
278
  showBlockParticipant: false,
279
279
  showReportParticipant: false,
280
- showFollowerStatus: false,
281
280
  composerDisabled: true,
282
281
  composerDisabledReason: 'Only Linktree can send messages on this thread',
283
- followerStatus: true, // would normally render "Subscribed to you" — suppressed here
284
282
  onLeaveConversation: (channel) =>
285
283
  console.log('Leave conversation:', channel.id),
286
284
  }
@@ -288,7 +286,7 @@ RestrictedOfficialChannel.parameters = {
288
286
  docs: {
289
287
  description: {
290
288
  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.',
289
+ 'Restricted action surface used by the Linktree official channel: block and report are hidden, and the composer is replaced by a locked panel explaining the linker cannot send messages on this thread. Delete conversation and favorite remain available in the "..." actions popover.',
292
290
  },
293
291
  },
294
292
  }
@@ -543,74 +541,6 @@ EmptyChannel.parameters = {
543
541
  },
544
542
  }
545
543
 
546
- export const SubscriberStatus: StoryFn<TemplateProps> = Template.bind({})
547
- SubscriberStatus.args = {
548
- showBackButton: false,
549
- followerStatus: true, // Shows "Subscribed to you" in green
550
- onLeaveConversation: (channel) =>
551
- console.log('Leave conversation:', channel.id),
552
- onBlockParticipant: (participantId) =>
553
- console.log('Block participant:', participantId),
554
- }
555
- SubscriberStatus.parameters = {
556
- docs: {
557
- description: {
558
- story: 'Channel view showing "Subscribed to you" badge in green when isFollower is true.',
559
- },
560
- },
561
- }
562
-
563
- export const NotSubscribedStatus: StoryFn<TemplateProps> = Template.bind({})
564
- NotSubscribedStatus.args = {
565
- showBackButton: false,
566
- followerStatus: false, // Shows "Not subscribed" in gray
567
- onLeaveConversation: (channel) =>
568
- console.log('Leave conversation:', channel.id),
569
- onBlockParticipant: (participantId) =>
570
- console.log('Block participant:', participantId),
571
- }
572
- NotSubscribedStatus.parameters = {
573
- docs: {
574
- description: {
575
- story: 'Channel view showing "Not subscribed" badge in gray when isFollower is false.',
576
- },
577
- },
578
- }
579
-
580
- export const CustomFollowerStatus: StoryFn<TemplateProps> = Template.bind({})
581
- CustomFollowerStatus.args = {
582
- showBackButton: false,
583
- followerStatus: 'Mutual subscribers', // Custom status text in gray
584
- onLeaveConversation: (channel) =>
585
- console.log('Leave conversation:', channel.id),
586
- onBlockParticipant: (participantId) =>
587
- console.log('Block participant:', participantId),
588
- }
589
- CustomFollowerStatus.parameters = {
590
- docs: {
591
- description: {
592
- story: 'Channel view with a custom follower status text (shows in gray unless text is "Subscribed to you").',
593
- },
594
- },
595
- }
596
-
597
- export const NoFollowerStatus: StoryFn<TemplateProps> = Template.bind({})
598
- NoFollowerStatus.args = {
599
- showBackButton: false,
600
- followerStatus: undefined, // No badge shown
601
- onLeaveConversation: (channel) =>
602
- console.log('Leave conversation:', channel.id),
603
- onBlockParticipant: (participantId) =>
604
- console.log('Block participant:', participantId),
605
- }
606
- NoFollowerStatus.parameters = {
607
- docs: {
608
- description: {
609
- story: 'Channel view with no follower status badge displayed.',
610
- },
611
- },
612
- }
613
-
614
544
  export const FrozenChannel: StoryFn<TemplateProps> = Template.bind({})
615
545
  FrozenChannel.args = {
616
546
  showBackButton: false,
@@ -68,11 +68,11 @@ vi.mock('./CustomDateSeparator', () => ({
68
68
  CustomDateSeparator: () => <div data-testid="custom-date-separator" />,
69
69
  }))
70
70
 
71
- const channelInfoDialogProps: Array<Record<string, unknown>> = []
72
- vi.mock('./ChannelInfoDialog', () => ({
73
- ChannelInfoDialog: (props: Record<string, unknown>) => {
74
- channelInfoDialogProps.push({ ...props })
75
- return <div data-testid="channel-info-dialog" />
71
+ const channelActionsMenuProps: Array<Record<string, unknown>> = []
72
+ vi.mock('./ChannelActionsMenu', () => ({
73
+ ChannelActionsMenu: (props: Record<string, unknown>) => {
74
+ channelActionsMenuProps.push({ ...props })
75
+ return <div data-testid="channel-actions-menu" />
76
76
  },
77
77
  }))
78
78
 
@@ -151,24 +151,24 @@ describe('ChannelView', () => {
151
151
  activeChannel = undefined
152
152
  activeChannelProps = {}
153
153
  avatarRenderCalls.length = 0
154
- channelInfoDialogProps.length = 0
154
+ channelActionsMenuProps.length = 0
155
155
  messageInputProps.length = 0
156
156
  mockIsStarred = false
157
157
  })
158
158
 
159
- const lastDialogProps = () =>
160
- channelInfoDialogProps[channelInfoDialogProps.length - 1]
159
+ const lastActionsMenuProps = () =>
160
+ channelActionsMenuProps[channelActionsMenuProps.length - 1]
161
161
  const lastInputProps = () =>
162
162
  messageInputProps[messageInputProps.length - 1]
163
163
 
164
- it('keeps block and report visible by default in the info dialog', () => {
164
+ it('keeps block and report visible by default in the actions menu', () => {
165
165
  renderWithProviders(<ChannelView channel={createChannel()} />)
166
166
 
167
- expect(lastDialogProps().showBlockParticipant).not.toBe(false)
168
- expect(lastDialogProps().showReportParticipant).not.toBe(false)
167
+ expect(lastActionsMenuProps().showBlockParticipant).not.toBe(false)
168
+ expect(lastActionsMenuProps().showReportParticipant).not.toBe(false)
169
169
  })
170
170
 
171
- it('hides block and report in the info dialog when restricted', () => {
171
+ it('hides block and report in the actions menu when restricted', () => {
172
172
  renderWithProviders(
173
173
  <ChannelView
174
174
  channel={createChannel()}
@@ -177,32 +177,36 @@ describe('ChannelView', () => {
177
177
  />
178
178
  )
179
179
 
180
- expect(lastDialogProps().showBlockParticipant).toBe(false)
181
- expect(lastDialogProps().showReportParticipant).toBe(false)
180
+ expect(lastActionsMenuProps().showBlockParticipant).toBe(false)
181
+ expect(lastActionsMenuProps().showReportParticipant).toBe(false)
182
182
  })
183
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} />)
184
+ it('renders the participant name without an info trigger', () => {
185
+ renderWithProviders(<ChannelView channel={createChannel()} />)
191
186
 
192
- expect(lastDialogProps().followerStatusLabel).toBe('Subscribed to you')
187
+ // The info sidebar was removed, so the participant name is plain text on
188
+ // both headers — there is no clickable affordance to open a profile
189
+ // dialog anymore.
190
+ expect(
191
+ screen.queryByRole('button', { name: /view info for/i })
192
+ ).not.toBeInTheDocument()
193
+ expect(screen.getAllByText('Linker').length).toBeGreaterThanOrEqual(1)
193
194
  })
194
195
 
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
- }
196
+ it('renders the actions menu by default', () => {
197
+ renderWithProviders(<ChannelView channel={createChannel()} />)
198
+
199
+ expect(
200
+ screen.queryAllByTestId('channel-actions-menu').length
201
+ ).toBeGreaterThan(0)
202
+ })
200
203
 
204
+ it('hides the actions menu (both desktop and mobile) when showChannelInfo is false', () => {
201
205
  renderWithProviders(
202
- <ChannelView channel={channel} showFollowerStatus={false} />
206
+ <ChannelView channel={createChannel()} showChannelInfo={false} />
203
207
  )
204
208
 
205
- expect(lastDialogProps().followerStatusLabel).toBeUndefined()
209
+ expect(screen.queryAllByTestId('channel-actions-menu')).toHaveLength(0)
206
210
  })
207
211
 
208
212
  it('passes composer disabled state and reason to the message input', () => {