@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.
- package/README.md +1 -18
- package/dist/{Card-jyXjZZ0u.js → Card-BAc2cgtn.js} +3 -3
- package/dist/{Card-jyXjZZ0u.js.map → Card-BAc2cgtn.js.map} +1 -1
- package/dist/{Card-D_XOj1eE.cjs → Card-Cn1cBVnr.cjs} +2 -2
- package/dist/{Card-D_XOj1eE.cjs.map → Card-Cn1cBVnr.cjs.map} +1 -1
- package/dist/{Card-BkgsPkp4.cjs → Card-DAyszUxa.cjs} +2 -2
- package/dist/{Card-BkgsPkp4.cjs.map → Card-DAyszUxa.cjs.map} +1 -1
- package/dist/{Card-BwFdJXYm.js → Card-D_2VQScd.js} +2 -2
- package/dist/{Card-BwFdJXYm.js.map → Card-D_2VQScd.js.map} +1 -1
- package/dist/{Card-B9atg4sP.js → Card-D_G8133I.js} +2 -2
- package/dist/{Card-B9atg4sP.js.map → Card-D_G8133I.js.map} +1 -1
- package/dist/{Card-1U2tLPcp.cjs → Card-gYxPXe_W.cjs} +2 -2
- package/dist/{Card-1U2tLPcp.cjs.map → Card-gYxPXe_W.cjs.map} +1 -1
- package/dist/{LockedThumbnail-Dwt_goCX.js → LockedThumbnail-C7tWpOQr.js} +2 -2
- package/dist/{LockedThumbnail-Dwt_goCX.js.map → LockedThumbnail-C7tWpOQr.js.map} +1 -1
- package/dist/{LockedThumbnail-oxtdpgut.cjs → LockedThumbnail-DtOTZl3l.cjs} +2 -2
- package/dist/{LockedThumbnail-oxtdpgut.cjs.map → LockedThumbnail-DtOTZl3l.cjs.map} +1 -1
- package/dist/{index-CO975B6P.js → index-C_NFzAB9.js} +1228 -1289
- package/dist/index-C_NFzAB9.js.map +1 -0
- package/dist/index-_Se6ovQm.cjs +2 -0
- package/dist/index-_Se6ovQm.cjs.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +24 -49
- package/dist/index.js +1 -1
- package/package.json +4 -3
- package/src/components/ChannelActionsMenu/ChannelActionsMenu.test.tsx +305 -0
- package/src/components/ChannelActionsMenu/index.tsx +221 -0
- package/src/components/ChannelList/index.test.tsx +3 -151
- package/src/components/ChannelList/index.tsx +4 -72
- package/src/components/ChannelView.stories.tsx +3 -73
- package/src/components/ChannelView.test.tsx +33 -29
- package/src/components/ChannelView.tsx +71 -109
- package/src/components/MessagingShell/index.tsx +2 -0
- package/src/hooks/useChannelModerationActions.ts +227 -0
- package/src/index.ts +0 -1
- package/src/types.ts +25 -48
- package/dist/index-CO975B6P.js.map +0 -1
- package/dist/index-D4Dse1Lu.cjs +0 -2
- package/dist/index-D4Dse1Lu.cjs.map +0 -1
- package/src/components/ChannelInfoDialog/ChannelInfoDialog.test.tsx +0 -333
- 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-
|
|
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
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
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
|
-
*
|
|
309
|
-
* in the
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
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
|
|
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-
|
|
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.
|
|
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
|