@linktr.ee/messaging-react 3.2.0 → 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 (36) hide show
  1. package/dist/{Card-bdnjL_4d.js → Card-BAc2cgtn.js} +3 -3
  2. package/dist/{Card-bdnjL_4d.js.map → Card-BAc2cgtn.js.map} +1 -1
  3. package/dist/{Card-CO089n1e.cjs → Card-Cn1cBVnr.cjs} +2 -2
  4. package/dist/{Card-CO089n1e.cjs.map → Card-Cn1cBVnr.cjs.map} +1 -1
  5. package/dist/{Card-B5TCecD6.cjs → Card-DAyszUxa.cjs} +2 -2
  6. package/dist/{Card-B5TCecD6.cjs.map → Card-DAyszUxa.cjs.map} +1 -1
  7. package/dist/{Card-DQYLHbDI.js → Card-D_2VQScd.js} +2 -2
  8. package/dist/{Card-DQYLHbDI.js.map → Card-D_2VQScd.js.map} +1 -1
  9. package/dist/{Card-DTaHgygz.js → Card-D_G8133I.js} +2 -2
  10. package/dist/{Card-DTaHgygz.js.map → Card-D_G8133I.js.map} +1 -1
  11. package/dist/{Card-aO1qZWDU.cjs → Card-gYxPXe_W.cjs} +2 -2
  12. package/dist/{Card-aO1qZWDU.cjs.map → Card-gYxPXe_W.cjs.map} +1 -1
  13. package/dist/{LockedThumbnail-nsFA3DjA.js → LockedThumbnail-C7tWpOQr.js} +2 -2
  14. package/dist/{LockedThumbnail-nsFA3DjA.js.map → LockedThumbnail-C7tWpOQr.js.map} +1 -1
  15. package/dist/{LockedThumbnail-CWVybsBb.cjs → LockedThumbnail-DtOTZl3l.cjs} +2 -2
  16. package/dist/{LockedThumbnail-CWVybsBb.cjs.map → LockedThumbnail-DtOTZl3l.cjs.map} +1 -1
  17. package/dist/{index-DJKFVBkP.js → index-C_NFzAB9.js} +1356 -1382
  18. package/dist/index-C_NFzAB9.js.map +1 -0
  19. package/dist/index-_Se6ovQm.cjs +2 -0
  20. package/dist/index-_Se6ovQm.cjs.map +1 -0
  21. package/dist/index.cjs +1 -1
  22. package/dist/index.d.ts +20 -15
  23. package/dist/index.js +1 -1
  24. package/package.json +3 -3
  25. package/src/components/ChannelActionsMenu/ChannelActionsMenu.test.tsx +305 -0
  26. package/src/components/ChannelActionsMenu/index.tsx +221 -0
  27. package/src/components/ChannelView.stories.tsx +3 -73
  28. package/src/components/ChannelView.test.tsx +30 -57
  29. package/src/components/ChannelView.tsx +66 -115
  30. package/src/hooks/useChannelModerationActions.ts +227 -0
  31. package/src/types.ts +20 -15
  32. package/dist/index-BO2VfA-M.cjs +0 -2
  33. package/dist/index-BO2VfA-M.cjs.map +0 -1
  34. package/dist/index-DJKFVBkP.js.map +0 -1
  35. package/src/components/ChannelInfoDialog/ChannelInfoDialog.test.tsx +0 -333
  36. 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-BO2VfA-M.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
@@ -194,9 +194,11 @@ export declare interface ChannelViewProps {
194
194
  */
195
195
  showReportParticipant?: boolean;
196
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.
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.
200
202
  */
201
203
  showFollowerStatus?: boolean;
202
204
  /**
@@ -249,11 +251,15 @@ export declare interface ChannelViewProps {
249
251
  */
250
252
  showStarButton?: boolean;
251
253
  /**
252
- * Show the channel info trigger (kebab) in the header, the clickable
253
- * participant name on desktop, and mount the channel info dialog. Defaults
254
- * to true. Set false for surfaces that should not expose the participant
255
- * profile, block/report/delete actions e.g. anonymous visitor chat,
256
- * where the visitor has no authenticated identity to act on.
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.
257
263
  */
258
264
  showChannelInfo?: boolean;
259
265
  /**
@@ -275,16 +281,15 @@ export declare interface ChannelViewProps {
275
281
  */
276
282
  renderChannelBanner?: () => React.ReactNode;
277
283
  /**
278
- * Custom content rendered below the participant name and contact details
279
- * in the channel info dialog profile card.
280
- * Useful for badges (e.g. follower status), metadata, or any extra info.
281
- *
282
- * @example
283
- * 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.
284
289
  */
285
290
  customProfileContent?: React.ReactNode;
286
291
  /**
287
- * Custom actions rendered at the bottom of the channel info dialog
292
+ * Custom actions rendered at the bottom of the channel actions popover
288
293
  * (below Delete Conversation, Block/Unblock, Report).
289
294
  * Pass one or more <li> elements so they match the list styling.
290
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-DJKFVBkP.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.2.0",
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,8 @@
28
28
  "test:ci": "vitest run",
29
29
  "test:ui": "vitest --ui",
30
30
  "test:coverage": "vitest --coverage",
31
- "lint": "../../node_modules/.bin/eslint . --ext .ts,.tsx",
32
- "lint:fix": "../../node_modules/.bin/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
33
  "verify": "yarn type-check && yarn lint && yarn test:ci && yarn build",
34
34
  "storybook": "storybook dev -p 6006",
35
35
  "storybook:build": "storybook build"
@@ -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