@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
package/dist/index.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-D4Dse1Lu.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-_Se6ovQm.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
@@ -95,36 +95,6 @@ export declare const ChannelEmptyState: default_2.FC;
95
95
  */
96
96
  export declare const ChannelList: default_2.NamedExoticComponent<ChannelListProps>;
97
97
 
98
- /**
99
- * Derived state reported by the mounted ChannelList instance.
100
- *
101
- * This reflects the currently loaded list data only. It does not guarantee
102
- * complete server-side truth for channels outside the mounted list's current
103
- * query window or cache.
104
- */
105
- export declare interface ChannelListDerivedState {
106
- /**
107
- * Whether the mounted list has completed its first channel-resolution pass.
108
- */
109
- isInitialLoadSettled: boolean;
110
- /**
111
- * Visible channels after `channelRenderFilterFn` is applied.
112
- */
113
- channels: Channel[];
114
- /**
115
- * Raw channels received from Stream before client-side filtering.
116
- */
117
- rawChannels: Channel[];
118
- /**
119
- * Convenience flag for empty-state consumers. Mirrors `channels.length > 0`.
120
- */
121
- hasChannels: boolean;
122
- /**
123
- * Number of visible channels with at least one unread message.
124
- */
125
- unreadCount: number;
126
- }
127
-
128
98
  /**
129
99
  * ChannelList component props
130
100
  */
@@ -167,14 +137,6 @@ export declare interface ChannelListProps {
167
137
  * Falls back to message.text when no matching translation exists.
168
138
  */
169
139
  viewerLanguage?: string;
170
- /**
171
- * Reports derived state from the mounted Stream ChannelList instance.
172
- *
173
- * This callback is driven by currently loaded list data only. Use it for
174
- * cache/list-derived UI state such as empty-state confirmation or visible
175
- * unread badges; do not assume it represents complete server-side truth.
176
- */
177
- onStateChange?: (state: ChannelListDerivedState) => void;
178
140
  }
179
141
 
180
142
  /**
@@ -186,7 +148,7 @@ export declare const ChannelView: default_2.NamedExoticComponent<ChannelViewProp
186
148
  * Props that MessagingShell passes through to ChannelView.
187
149
  * ChannelViewProps is the source of truth for these props.
188
150
  */
189
- declare type ChannelViewPassthroughProps = Pick<ChannelViewProps, 'renderMessageInputActions' | 'renderConversationFooter' | 'CustomChannelEmptyState' | 'onBlockParticipantClick' | 'onReportParticipantClick' | 'dmAgentEnabled' | 'onMessageSent' | 'chatbotVotingEnabled' | 'viewerLanguage' | 'renderChannelBanner' | 'customChannelActions' | 'renderMessage' | 'onMessageLinkClick'>;
151
+ declare type ChannelViewPassthroughProps = Pick<ChannelViewProps, 'renderMessageInputActions' | 'renderConversationFooter' | 'CustomChannelEmptyState' | 'onBlockParticipantClick' | 'onReportParticipantClick' | 'dmAgentEnabled' | 'onMessageSent' | 'chatbotVotingEnabled' | 'viewerLanguage' | 'renderChannelBanner' | 'customChannelActions' | 'renderMessage' | 'onMessageLinkClick' | 'showChannelInfo'>;
190
152
 
191
153
  /**
192
154
  * ChannelView component props
@@ -232,9 +194,11 @@ export declare interface ChannelViewProps {
232
194
  */
233
195
  showReportParticipant?: boolean;
234
196
  /**
235
- * Show the subscription/follower-status label in the channel info dialog
236
- * profile card. Defaults to true. Set false for restricted surfaces such
237
- * as the Linktree official channel, where subscription status is hidden.
197
+ * @deprecated Not currently rendered. The channel info sidebar was removed
198
+ * in favour of the actions popover; this prop is retained for API
199
+ * compatibility and will be wired up again when the replacement profile
200
+ * surface lands. Previously toggled the subscription/follower-status label
201
+ * in the channel info dialog profile card.
238
202
  */
239
203
  showFollowerStatus?: boolean;
240
204
  /**
@@ -286,6 +250,18 @@ export declare interface ChannelViewProps {
286
250
  * and filter by starred/pinned status.
287
251
  */
288
252
  showStarButton?: boolean;
253
+ /**
254
+ * Show the channel actions menu (the `...` popover) in the header, which
255
+ * exposes the block/report/delete moderation actions. Defaults to true.
256
+ * Set false for surfaces that should not expose those actions — e.g.
257
+ * anonymous visitor chat, where the visitor has no authenticated identity
258
+ * to act on.
259
+ *
260
+ * Note: the channel info sidebar was removed in favour of the actions
261
+ * popover, so this no longer mounts a profile dialog or renders a clickable
262
+ * participant name; it now solely gates the actions menu.
263
+ */
264
+ showChannelInfo?: boolean;
289
265
  /**
290
266
  * Enable thumbs up/down voting on chatbot messages.
291
267
  * When true, vote buttons render below chatbot (DM Agent) messages.
@@ -305,16 +281,15 @@ export declare interface ChannelViewProps {
305
281
  */
306
282
  renderChannelBanner?: () => React.ReactNode;
307
283
  /**
308
- * Custom content rendered below the participant name and contact details
309
- * in the channel info dialog profile card.
310
- * Useful for badges (e.g. follower status), metadata, or any extra info.
311
- *
312
- * @example
313
- * customProfileContent={<SubscriptionBadge isFollower={channel.data?.isFollower} />}
284
+ * @deprecated Not currently rendered. The channel info sidebar was removed
285
+ * in favour of the actions popover; this prop is retained for API
286
+ * compatibility and will be wired up again when the replacement profile
287
+ * surface lands. Previously rendered custom content (badges, metadata)
288
+ * below the participant name in the channel info dialog profile card.
314
289
  */
315
290
  customProfileContent?: React.ReactNode;
316
291
  /**
317
- * Custom actions rendered at the bottom of the channel info dialog
292
+ * Custom actions rendered at the bottom of the channel actions popover
318
293
  * (below Delete Conversation, Block/Unblock, Report).
319
294
  * Pass one or more <li> elements so they match the list styling.
320
295
  * Use the exported ActionButton for consistent styling.
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-CO975B6P.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-C_NFzAB9.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.1.4-rc-1780636753",
3
+ "version": "3.3.0",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -28,8 +28,9 @@
28
28
  "test:ci": "vitest run",
29
29
  "test:ui": "vitest --ui",
30
30
  "test:coverage": "vitest --coverage",
31
- "lint": "eslint . --ext .ts,.tsx",
32
- "lint:fix": "eslint . --ext .ts,.tsx --fix",
31
+ "lint": "npx --no-install eslint . --ext .ts,.tsx",
32
+ "lint:fix": "npx --no-install eslint . --ext .ts,.tsx --fix",
33
+ "verify": "yarn type-check && yarn lint && yarn test:ci && yarn build",
33
34
  "storybook": "storybook dev -p 6006",
34
35
  "storybook:build": "storybook build"
35
36
  },
@@ -0,0 +1,305 @@
1
+ import React from 'react'
2
+ import type { Channel, ChannelMemberResponse } from 'stream-chat'
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import {
6
+ renderWithProviders,
7
+ screen,
8
+ userEvent,
9
+ waitFor,
10
+ } from '../../test/utils'
11
+
12
+ import { ChannelActionsMenu } from './index'
13
+
14
+ const { getBlockedUsersMock, blockUserMock, unBlockUserMock } = vi.hoisted(
15
+ () => ({
16
+ getBlockedUsersMock: vi.fn().mockResolvedValue([]),
17
+ blockUserMock: vi.fn().mockResolvedValue(undefined),
18
+ unBlockUserMock: vi.fn().mockResolvedValue(undefined),
19
+ })
20
+ )
21
+
22
+ vi.mock('../../providers/MessagingProvider', () => ({
23
+ useMessagingContext: () => ({
24
+ service: {
25
+ getBlockedUsers: getBlockedUsersMock,
26
+ blockUser: blockUserMock,
27
+ unBlockUser: unBlockUserMock,
28
+ },
29
+ debug: false,
30
+ }),
31
+ }))
32
+
33
+ vi.mock('../ActionButton', () => ({
34
+ default: ({
35
+ children,
36
+ onClick,
37
+ disabled,
38
+ }: {
39
+ children: React.ReactNode
40
+ onClick?: () => void
41
+ disabled?: boolean
42
+ }) => (
43
+ <button data-testid="action-button" onClick={onClick} disabled={disabled}>
44
+ {children}
45
+ </button>
46
+ ),
47
+ }))
48
+
49
+ vi.mock('@phosphor-icons/react', () => ({
50
+ DotsThreeIcon: () => <span data-testid="dots-icon" />,
51
+ FlagIcon: () => <span data-testid="flag-icon" />,
52
+ ProhibitInsetIcon: () => <span data-testid="prohibit-icon" />,
53
+ SignOutIcon: () => <span data-testid="signout-icon" />,
54
+ SpinnerGapIcon: () => <span data-testid="spinner-icon" />,
55
+ }))
56
+
57
+ const createChannel = () =>
58
+ ({
59
+ id: 'channel-1',
60
+ cid: 'messaging:channel-1',
61
+ data: {},
62
+ _client: { userID: 'visitor-1' },
63
+ state: {
64
+ members: {},
65
+ membership: {},
66
+ messages: [],
67
+ },
68
+ hide: vi.fn(),
69
+ }) as unknown as Channel
70
+
71
+ const createParticipant = () =>
72
+ ({
73
+ user: {
74
+ id: 'linker-1',
75
+ name: 'Linker',
76
+ },
77
+ role: 'member',
78
+ }) as unknown as ChannelMemberResponse
79
+
80
+ const defaultProps = () => ({
81
+ channel: createChannel(),
82
+ participant: createParticipant(),
83
+ })
84
+
85
+ const openMenu = async () =>
86
+ userEvent.click(screen.getByRole('button', { name: 'More options' }))
87
+
88
+ describe('ChannelActionsMenu', () => {
89
+ beforeEach(() => {
90
+ vi.clearAllMocks()
91
+ getBlockedUsersMock.mockResolvedValue([])
92
+ })
93
+
94
+ it('renders the trigger but keeps options hidden until opened', () => {
95
+ renderWithProviders(<ChannelActionsMenu {...defaultProps()} />)
96
+
97
+ expect(
98
+ screen.getByRole('button', { name: 'More options' })
99
+ ).toBeInTheDocument()
100
+ expect(screen.queryByText('Delete Conversation')).not.toBeInTheDocument()
101
+ expect(screen.queryByText('Block')).not.toBeInTheDocument()
102
+ expect(screen.queryByText('Report')).not.toBeInTheDocument()
103
+ })
104
+
105
+ it('reflects open state via aria-expanded and shows the options inline', async () => {
106
+ renderWithProviders(<ChannelActionsMenu {...defaultProps()} />)
107
+
108
+ const trigger = screen.getByRole('button', { name: 'More options' })
109
+ expect(trigger).toHaveAttribute('aria-expanded', 'false')
110
+
111
+ await openMenu()
112
+
113
+ expect(trigger).toHaveAttribute('aria-expanded', 'true')
114
+ expect(screen.getByText('Delete Conversation')).toBeInTheDocument()
115
+ expect(screen.getByText('Block')).toBeInTheDocument()
116
+ expect(screen.getByText('Report')).toBeInTheDocument()
117
+ // Each option renders its icon inline.
118
+ expect(screen.getByTestId('signout-icon')).toBeInTheDocument()
119
+ expect(screen.getByTestId('prohibit-icon')).toBeInTheDocument()
120
+ expect(screen.getByTestId('flag-icon')).toBeInTheDocument()
121
+ })
122
+
123
+ it('closes the popover when Escape is pressed', async () => {
124
+ renderWithProviders(<ChannelActionsMenu {...defaultProps()} />)
125
+
126
+ await openMenu()
127
+ expect(screen.getByText('Report')).toBeInTheDocument()
128
+
129
+ await userEvent.keyboard('{Escape}')
130
+
131
+ await waitFor(() => {
132
+ expect(screen.queryByText('Report')).not.toBeInTheDocument()
133
+ })
134
+ })
135
+
136
+ it('closes the popover when clicking outside', async () => {
137
+ renderWithProviders(
138
+ <div>
139
+ <span data-testid="outside">outside</span>
140
+ <ChannelActionsMenu {...defaultProps()} />
141
+ </div>
142
+ )
143
+
144
+ await openMenu()
145
+ expect(screen.getByText('Report')).toBeInTheDocument()
146
+
147
+ await userEvent.click(screen.getByTestId('outside'))
148
+
149
+ await waitFor(() => {
150
+ expect(screen.queryByText('Report')).not.toBeInTheDocument()
151
+ })
152
+ })
153
+
154
+ it('calls onDeleteConversationClick and closes after deleting', async () => {
155
+ const onDeleteConversationClick = vi.fn()
156
+ renderWithProviders(
157
+ <ChannelActionsMenu
158
+ {...defaultProps()}
159
+ onDeleteConversationClick={onDeleteConversationClick}
160
+ />
161
+ )
162
+
163
+ await openMenu()
164
+ await userEvent.click(screen.getByText('Delete Conversation'))
165
+
166
+ await waitFor(() => {
167
+ expect(onDeleteConversationClick).toHaveBeenCalledOnce()
168
+ })
169
+ await waitFor(() => {
170
+ expect(screen.queryByText('Delete Conversation')).not.toBeInTheDocument()
171
+ })
172
+ })
173
+
174
+ it('calls onBlockParticipantClick and blocks the user', async () => {
175
+ const onBlockParticipantClick = vi.fn()
176
+ renderWithProviders(
177
+ <ChannelActionsMenu
178
+ {...defaultProps()}
179
+ onBlockParticipantClick={onBlockParticipantClick}
180
+ />
181
+ )
182
+
183
+ await openMenu()
184
+ await userEvent.click(screen.getByText('Block'))
185
+
186
+ await waitFor(() => {
187
+ expect(onBlockParticipantClick).toHaveBeenCalledOnce()
188
+ expect(blockUserMock).toHaveBeenCalledWith('linker-1')
189
+ })
190
+ })
191
+
192
+ it('shows Unblock when the participant is already blocked', async () => {
193
+ getBlockedUsersMock.mockResolvedValue([{ blocked_user_id: 'linker-1' }])
194
+ renderWithProviders(<ChannelActionsMenu {...defaultProps()} />)
195
+
196
+ await openMenu()
197
+
198
+ await waitFor(() => {
199
+ expect(screen.getByText('Unblock')).toBeInTheDocument()
200
+ })
201
+ expect(screen.queryByText('Block')).not.toBeInTheDocument()
202
+ })
203
+
204
+ it('disables the block action until the blocked-status lookup resolves', async () => {
205
+ let resolveBlockedUsers: (
206
+ value: Array<{ blocked_user_id: string }>
207
+ ) => void = () => {}
208
+ getBlockedUsersMock.mockImplementation(
209
+ () =>
210
+ new Promise((resolve) => {
211
+ resolveBlockedUsers = resolve
212
+ })
213
+ )
214
+
215
+ renderWithProviders(<ChannelActionsMenu {...defaultProps()} />)
216
+ await openMenu()
217
+
218
+ // While the lookup is pending the action is disabled so a premature click
219
+ // can't act on a stale block state.
220
+ const pendingBlock = screen.getByText('Block').closest('button')
221
+ expect(pendingBlock).toBeDisabled()
222
+ await userEvent.click(pendingBlock as HTMLElement)
223
+ expect(blockUserMock).not.toHaveBeenCalled()
224
+
225
+ // Once the lookup resolves (already blocked) the Unblock action is offered.
226
+ resolveBlockedUsers([{ blocked_user_id: 'linker-1' }])
227
+ await waitFor(() => {
228
+ expect(screen.getByText('Unblock')).toBeInTheDocument()
229
+ })
230
+ })
231
+
232
+ it('calls onReportParticipantClick and opens the report page', async () => {
233
+ const onReportParticipantClick = vi.fn()
234
+ const windowOpenSpy = vi
235
+ .spyOn(window, 'open')
236
+ .mockImplementation(() => null)
237
+ renderWithProviders(
238
+ <ChannelActionsMenu
239
+ {...defaultProps()}
240
+ onReportParticipantClick={onReportParticipantClick}
241
+ />
242
+ )
243
+
244
+ await openMenu()
245
+ await userEvent.click(screen.getByText('Report'))
246
+
247
+ await waitFor(() => {
248
+ expect(onReportParticipantClick).toHaveBeenCalledOnce()
249
+ expect(windowOpenSpy).toHaveBeenCalled()
250
+ })
251
+ windowOpenSpy.mockRestore()
252
+ })
253
+
254
+ it('hides actions based on the show* flags', async () => {
255
+ renderWithProviders(
256
+ <ChannelActionsMenu
257
+ {...defaultProps()}
258
+ showDeleteConversation={false}
259
+ showBlockParticipant={false}
260
+ showReportParticipant={false}
261
+ customChannelActions={<li data-testid="custom-action">Custom</li>}
262
+ />
263
+ )
264
+
265
+ await openMenu()
266
+
267
+ expect(screen.queryByText('Delete Conversation')).not.toBeInTheDocument()
268
+ expect(screen.queryByText('Block')).not.toBeInTheDocument()
269
+ expect(screen.queryByText('Report')).not.toBeInTheDocument()
270
+ expect(screen.getByTestId('custom-action')).toBeInTheDocument()
271
+ })
272
+
273
+ it('does not fetch blocked users until the menu is opened', async () => {
274
+ renderWithProviders(<ChannelActionsMenu {...defaultProps()} />)
275
+
276
+ expect(getBlockedUsersMock).not.toHaveBeenCalled()
277
+
278
+ await openMenu()
279
+
280
+ await waitFor(() => {
281
+ expect(getBlockedUsersMock).toHaveBeenCalled()
282
+ })
283
+ })
284
+
285
+ it('renders nothing when there is no participant', () => {
286
+ const { container } = renderWithProviders(
287
+ <ChannelActionsMenu {...defaultProps()} participant={undefined} />
288
+ )
289
+
290
+ expect(container).toBeEmptyDOMElement()
291
+ })
292
+
293
+ it('renders nothing when no actions are available', () => {
294
+ const { container } = renderWithProviders(
295
+ <ChannelActionsMenu
296
+ {...defaultProps()}
297
+ showDeleteConversation={false}
298
+ showBlockParticipant={false}
299
+ showReportParticipant={false}
300
+ />
301
+ )
302
+
303
+ expect(container).toBeEmptyDOMElement()
304
+ })
305
+ })
@@ -0,0 +1,221 @@
1
+ import {
2
+ DotsThreeIcon,
3
+ FlagIcon,
4
+ ProhibitInsetIcon,
5
+ SignOutIcon,
6
+ SpinnerGapIcon,
7
+ } from '@phosphor-icons/react'
8
+ import classNames from 'classnames'
9
+ import React, { useCallback, useEffect, useId, useRef, useState } from 'react'
10
+ import type { Channel as ChannelType, ChannelMemberResponse } from 'stream-chat'
11
+
12
+ import { useChannelModerationActions } from '../../hooks/useChannelModerationActions'
13
+ import ActionButton from '../ActionButton'
14
+
15
+ export interface ChannelActionsMenuProps {
16
+ channel: ChannelType
17
+ participant: ChannelMemberResponse | undefined
18
+ showDeleteConversation?: boolean
19
+ /**
20
+ * Show the Block/Unblock action. Defaults to true.
21
+ * Set false to hide it (e.g. the Linktree official channel).
22
+ */
23
+ showBlockParticipant?: boolean
24
+ /**
25
+ * Show the Report action. Defaults to true.
26
+ * Set false to hide it (e.g. the Linktree official channel).
27
+ */
28
+ showReportParticipant?: boolean
29
+ onLeaveConversation?: (channel: ChannelType) => void
30
+ onBlockParticipant?: (participantId?: string) => void
31
+ onDeleteConversationClick?: () => void
32
+ onBlockParticipantClick?: () => void
33
+ onReportParticipantClick?: () => void
34
+ customChannelActions?: React.ReactNode
35
+ /** Classes applied to the trigger ("...") button so it matches the header. */
36
+ triggerClassName?: string
37
+ }
38
+
39
+ /**
40
+ * Conversation overflow ("...") menu rendered as an anchored popover.
41
+ *
42
+ * Replaces the slide-in sidebar dialog for moderation actions: the trigger
43
+ * opens a small popover that lists the available options (delete, block,
44
+ * report) inline with their icons.
45
+ */
46
+ export const ChannelActionsMenu: React.FC<ChannelActionsMenuProps> = ({
47
+ channel,
48
+ participant,
49
+ showDeleteConversation = true,
50
+ showBlockParticipant = true,
51
+ showReportParticipant = true,
52
+ onLeaveConversation,
53
+ onBlockParticipant,
54
+ onDeleteConversationClick,
55
+ onBlockParticipantClick,
56
+ onReportParticipantClick,
57
+ customChannelActions,
58
+ triggerClassName,
59
+ }) => {
60
+ const [isOpen, setIsOpen] = useState(false)
61
+ const containerRef = useRef<HTMLDivElement>(null)
62
+ const menuId = useId()
63
+
64
+ const close = useCallback(() => setIsOpen(false), [])
65
+
66
+ const {
67
+ isParticipantBlocked,
68
+ isCheckingBlockedStatus,
69
+ isLeaving,
70
+ isUpdatingBlockStatus,
71
+ handleLeaveConversation,
72
+ handleBlockUser,
73
+ handleUnblockUser,
74
+ handleReportUser,
75
+ } = useChannelModerationActions({
76
+ channel,
77
+ participant,
78
+ showBlockParticipant,
79
+ enabled: isOpen,
80
+ onLeaveConversation,
81
+ onBlockParticipant,
82
+ onDeleteConversationClick,
83
+ onBlockParticipantClick,
84
+ onReportParticipantClick,
85
+ onActionComplete: close,
86
+ logLabel: 'ChannelActionsMenu',
87
+ })
88
+
89
+ // Close the popover on outside click or Escape.
90
+ useEffect(() => {
91
+ if (!isOpen) return
92
+
93
+ const handlePointerDown = (event: MouseEvent) => {
94
+ if (
95
+ containerRef.current &&
96
+ !containerRef.current.contains(event.target as Node)
97
+ ) {
98
+ setIsOpen(false)
99
+ }
100
+ }
101
+
102
+ const handleKeyDown = (event: KeyboardEvent) => {
103
+ if (event.key === 'Escape') {
104
+ setIsOpen(false)
105
+ }
106
+ }
107
+
108
+ document.addEventListener('mousedown', handlePointerDown)
109
+ document.addEventListener('keydown', handleKeyDown)
110
+
111
+ return () => {
112
+ document.removeEventListener('mousedown', handlePointerDown)
113
+ document.removeEventListener('keydown', handleKeyDown)
114
+ }
115
+ }, [isOpen])
116
+
117
+ if (!participant) return null
118
+
119
+ const hasActions =
120
+ showDeleteConversation ||
121
+ showBlockParticipant ||
122
+ showReportParticipant ||
123
+ Boolean(customChannelActions)
124
+
125
+ if (!hasActions) return null
126
+
127
+ return (
128
+ <div ref={containerRef} className="relative">
129
+ <button
130
+ className={triggerClassName}
131
+ type="button"
132
+ aria-haspopup="true"
133
+ aria-expanded={isOpen}
134
+ aria-controls={isOpen ? menuId : undefined}
135
+ onClick={() => setIsOpen((open) => !open)}
136
+ >
137
+ <DotsThreeIcon className="size-5 text-black/90" />
138
+ <span className="sr-only">More options</span>
139
+ </button>
140
+
141
+ {isOpen && (
142
+ <div
143
+ id={menuId}
144
+ aria-label="Conversation options"
145
+ className={classNames(
146
+ 'absolute right-0 top-full z-50 mt-2 w-56 overflow-hidden',
147
+ 'rounded-lg border border-sand bg-white p-1 shadow-max-elevation-light'
148
+ )}
149
+ >
150
+ <ul className="flex flex-col gap-1">
151
+ {showDeleteConversation && (
152
+ <li>
153
+ <ActionButton
154
+ onClick={handleLeaveConversation}
155
+ disabled={isLeaving}
156
+ aria-busy={isLeaving}
157
+ >
158
+ {isLeaving ? (
159
+ <SpinnerGapIcon className="h-5 w-5 animate-spin" />
160
+ ) : (
161
+ <SignOutIcon className="h-5 w-5" />
162
+ )}
163
+ <span>Delete Conversation</span>
164
+ </ActionButton>
165
+ </li>
166
+ )}
167
+ {showBlockParticipant && (
168
+ <li>
169
+ {isCheckingBlockedStatus ? (
170
+ // Block status not yet resolved — show a disabled placeholder
171
+ // so a premature click can't act on a stale block state.
172
+ <ActionButton disabled aria-busy>
173
+ <SpinnerGapIcon className="h-5 w-5 animate-spin" />
174
+ <span>Block</span>
175
+ </ActionButton>
176
+ ) : isParticipantBlocked ? (
177
+ <ActionButton
178
+ onClick={handleUnblockUser}
179
+ disabled={isUpdatingBlockStatus}
180
+ aria-busy={isUpdatingBlockStatus}
181
+ >
182
+ {isUpdatingBlockStatus ? (
183
+ <SpinnerGapIcon className="h-5 w-5 animate-spin" />
184
+ ) : (
185
+ <ProhibitInsetIcon className="h-5 w-5" />
186
+ )}
187
+ <span>Unblock</span>
188
+ </ActionButton>
189
+ ) : (
190
+ <ActionButton
191
+ onClick={handleBlockUser}
192
+ disabled={isUpdatingBlockStatus}
193
+ aria-busy={isUpdatingBlockStatus}
194
+ >
195
+ {isUpdatingBlockStatus ? (
196
+ <SpinnerGapIcon className="h-5 w-5 animate-spin" />
197
+ ) : (
198
+ <ProhibitInsetIcon className="h-5 w-5" />
199
+ )}
200
+ <span>Block</span>
201
+ </ActionButton>
202
+ )}
203
+ </li>
204
+ )}
205
+ {showReportParticipant && (
206
+ <li>
207
+ <ActionButton variant="danger" onClick={handleReportUser}>
208
+ <FlagIcon className="h-5 w-5" />
209
+ <span>Report</span>
210
+ </ActionButton>
211
+ </li>
212
+ )}
213
+ {customChannelActions}
214
+ </ul>
215
+ </div>
216
+ )}
217
+ </div>
218
+ )
219
+ }
220
+
221
+ export default ChannelActionsMenu