diff --git a/docs/develop/index.mdx b/docs/develop/index.mdx index 874ad81293f6..ae65888eaaa2 100644 --- a/docs/develop/index.mdx +++ b/docs/develop/index.mdx @@ -23,7 +23,7 @@ Build with, on, and for Mattermost. Contribute to the platform, build plugins an { title: 'Integrate & Extend', icon: 'channels', - to: '/developers/integrate', + to: '/developers/integrate/getting-started', description: 'Bots, slash commands, webhooks, OAuth apps, plugins, mobile clients. Build with the platform.', meta: 'Plugins · Apps · SDK' }, @@ -39,7 +39,7 @@ Build with, on, and for Mattermost. Contribute to the platform, build plugins an ## What's covered here - **[Contribute](/developers/contribute)** — onboarding, expectations, finding good first issues, the contribution workflow. -- **[Integrate & Extend](/developers/integrate)** — apps, plugins, slash commands, incoming + outgoing webhooks, OAuth, customization, the marketplace. +- **[Integrate & Extend](/developers/integrate/getting-started)** — apps, plugins, slash commands, incoming + outgoing webhooks, OAuth, customization, the marketplace. - **[Internal](/developers/internal)** — Mattermost-engineering team docs (build process, infrastructure, QA). ## Looking for something else? diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/browse_channels_modal.ts b/e2e-tests/playwright/lib/src/ui/components/channels/browse_channels_modal.ts index c5242bf73f77..b539aff730b0 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/browse_channels_modal.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/browse_channels_modal.ts @@ -45,4 +45,24 @@ export default class BrowseChannelsModal { expect(await row.getAttribute('data-testid')).toEqual(`ChannelRow-${channelName}`); } + + getChannelRow(channelDisplayName: string): Locator { + return this.results.locator('.more-modal__row').filter({hasText: channelDisplayName}); + } + + async clickRequestToJoin(channelDisplayName: string) { + await this.getChannelRow(channelDisplayName).getByText('Request to join').click(); + } + + async clickWithdraw(channelDisplayName: string) { + await this.getChannelRow(channelDisplayName).getByText('Withdraw', {exact: true}).click(); + } + + async toHaveWithdrawButton(channelDisplayName: string) { + await expect(this.getChannelRow(channelDisplayName).getByText('Withdraw', {exact: true})).toBeVisible(); + } + + async toHaveRequestToJoinButton(channelDisplayName: string) { + await expect(this.getChannelRow(channelDisplayName).getByText('Request to join')).toBeVisible(); + } } diff --git a/e2e-tests/playwright/specs/functional/channels/discoverable_channels/discoverable_channels.spec.ts b/e2e-tests/playwright/specs/functional/channels/discoverable_channels/discoverable_channels.spec.ts new file mode 100644 index 000000000000..f1a88f3ad859 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/discoverable_channels/discoverable_channels.spec.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '@mattermost/playwright-lib'; + +/** + * These specs cover the Discoverable Private Channels request-to-join UX + * (MM-68764). They only run when the DiscoverableChannels feature flag is + * enabled on the server (e.g. MM_FEATUREFLAGS_DiscoverableChannels=true); + * otherwise they self-skip. + */ + +async function createDiscoverableChannel(adminClient: any, teamId: string) { + const suffix = Date.now(); + return adminClient.createChannel({ + team_id: teamId, + name: `disc-private-${suffix}`, + display_name: `Discoverable Private ${suffix}`, + type: 'P', + discoverable: true, + }); +} + +test( + 'MM-68764 non-member requests to join a discoverable private channel from Browse Channels and can withdraw', + {tag: ['@discoverable_channels']}, + async ({pw}) => { + await pw.skipIfFeatureFlagNotSet('DiscoverableChannels', true); + + // # Initialize setup and create a discoverable private channel the user is not a member of + const {team, user, adminClient} = await pw.initSetup(); + const channel = await createDiscoverableChannel(adminClient, team.id); + + // # Log in as the non-member user and open Browse Channels + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + const dialog = await channelsPage.openBrowseChannelsModal(); + await dialog.toBeVisible(); + + // # Find the discoverable channel + await dialog.fillSearchInput(channel.display_name); + await dialog.toBeDoneLoading(); + + // * The row offers "Request to join" rather than a Join button + await dialog.toHaveRequestToJoinButton(channel.display_name); + + // # Request to join and confirm in the modal + await dialog.clickRequestToJoin(channel.display_name); + await expect(channelsPage.page.getByRole('button', {name: 'Send Request'})).toBeVisible(); + await channelsPage.page.getByRole('button', {name: 'Send Request'}).click(); + + // * The row flips to the pending "Withdraw" state + await dialog.toHaveWithdrawButton(channel.display_name); + + // # Withdraw the request + await dialog.clickWithdraw(channel.display_name); + + // * The row returns to the "Request to join" state + await dialog.toHaveRequestToJoinButton(channel.display_name); + }, +); + +test( + 'MM-68764 selecting a discoverable private channel from Find Channels opens Request to Join, not the legacy join', + {tag: ['@discoverable_channels']}, + async ({pw}) => { + await pw.skipIfFeatureFlagNotSet('DiscoverableChannels', true); + + // # Initialize setup and create a discoverable private channel the user is not a member of + const {team, user, adminClient} = await pw.initSetup(); + const channel = await createDiscoverableChannel(adminClient, team.id); + + // # Log in as the non-member user + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Open Find Channels (Cmd/Ctrl+K) and locate the discoverable channel + await channelsPage.sidebarLeft.findChannelButton.click(); + await channelsPage.findChannelsModal.toBeVisible(); + await channelsPage.findChannelsModal.input.fill(channel.display_name); + + const result = channelsPage.findChannelsModal.getResult(channel.name); + await expect(result).toBeVisible(); + + // # Select the discoverable channel + await channelsPage.findChannelsModal.selectChannel(channel.name); + + // * The Request to Join modal opens instead of the legacy private-channel join + // confirmation, and no "Join private channel" dialog is shown. + await expect(channelsPage.page.getByRole('button', {name: 'Send Request'})).toBeVisible(); + await expect(channelsPage.page.getByText('Are you sure you wish to join')).toHaveCount(0); + }, +); diff --git a/webapp/channels/src/actions/websocket_actions.ts b/webapp/channels/src/actions/websocket_actions.ts index 64903fd8c535..c1d1670bd5b5 100644 --- a/webapp/channels/src/actions/websocket_actions.ts +++ b/webapp/channels/src/actions/websocket_actions.ts @@ -10,7 +10,7 @@ import type {WebSocketMessage, WebSocketMessages} from '@mattermost/client'; import {WebSocketEvents} from '@mattermost/client'; import {AlertCircleOutlineIcon, InformationOutlineIcon} from '@mattermost/compass-icons/components'; import type {ChannelBookmarkWithFileInfo, UpdateChannelBookmarkResponse} from '@mattermost/types/channel_bookmarks'; -import type {Channel, ChannelMembership} from '@mattermost/types/channels'; +import type {Channel, ChannelJoinRequest, ChannelMembership} from '@mattermost/types/channels'; import type {Draft} from '@mattermost/types/drafts'; import type {Emoji} from '@mattermost/types/emojis'; import {FileDownloadTypes} from '@mattermost/types/files'; @@ -568,6 +568,14 @@ export function handleEvent(msg: WebSocketMessage) { dispatch(handleTeamAccessControlUpdatedEvent(msg)); break; + case WebSocketEvents.ChannelJoinRequestCreated: + dispatch(handleChannelJoinRequestCreated(msg)); + break; + + case WebSocketEvents.ChannelJoinRequestUpdated: + dispatch(handleChannelJoinRequestUpdated(msg)); + break; + case WebSocketEvents.DirectAdded: dispatch(handleDirectAddedEvent(msg)); break; @@ -908,6 +916,67 @@ export function handleTeamAccessControlUpdatedEvent(msg: WebSocketMessages.TeamA }; } +// channel_join_request_created arrives on the admin set only (server-side +// hook narrows the channel-id broadcast). When the current user is the +// requester we never see this event — the create path's thunk dispatches the +// row directly. +function handleChannelJoinRequestCreated(msg: WebSocketMessages.ChannelJoinRequestCreated): ThunkActionFunc { + return (doDispatch, doGetState) => { + if (!msg.data.request) { + return; + } + let req: ChannelJoinRequest; + try { + req = JSON.parse(msg.data.request) as ChannelJoinRequest; + } catch { + return; + } + doDispatch({ + type: ChannelTypes.CHANNEL_JOIN_REQUEST_CREATED, + data: req, + }); + + // If the current user happens to be the requester (e.g. tab open in + // two windows) keep myPendingByChannel in sync. + const currentUserId = getCurrentUserId(doGetState()); + if (req.user_id === currentUserId) { + doDispatch({ + type: ChannelTypes.RECEIVED_MY_CHANNEL_JOIN_REQUEST, + data: req, + }); + } + }; +} + +// channel_join_request_updated covers approve / deny / withdraw transitions +// AND the dedicated requester-scoped copy so a non-member requester sees +// their own row flip in real time. +function handleChannelJoinRequestUpdated(msg: WebSocketMessages.ChannelJoinRequestUpdated): ThunkActionFunc { + return (doDispatch, doGetState) => { + if (!msg.data.request) { + return; + } + let req: ChannelJoinRequest; + try { + req = JSON.parse(msg.data.request) as ChannelJoinRequest; + } catch { + return; + } + doDispatch({ + type: ChannelTypes.CHANNEL_JOIN_REQUEST_UPDATED, + data: req, + }); + + const currentUserId = getCurrentUserId(doGetState()); + if (req.user_id === currentUserId) { + doDispatch({ + type: ChannelTypes.RECEIVED_MY_CHANNEL_JOIN_REQUEST, + data: req, + }); + } + }; +} + function handleChannelMemberUpdatedEvent(msg: WebSocketMessages.ChannelMemberUpdated): ThunkActionFunc { return (doDispatch, doGetState) => { const channelMember = JSON.parse(msg.data.channelMember) as ChannelMembership; diff --git a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.scss b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.scss index b41d26a89867..dea90709adc4 100644 --- a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.scss +++ b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.scss @@ -2,7 +2,7 @@ .AdvancedTextEditor__skeleton { display: flex; - height: 122px; + height: 98px; align-items: center; justify-content: center; padding-left: 10px; diff --git a/webapp/channels/src/components/browse_channels/browse_channels.scss b/webapp/channels/src/components/browse_channels/browse_channels.scss index 5db58593c94c..cefcef726fa9 100644 --- a/webapp/channels/src/components/browse_channels/browse_channels.scss +++ b/webapp/channels/src/components/browse_channels/browse_channels.scss @@ -192,6 +192,16 @@ } } + .discoverableIndicatorContainer { + display: flex; + align-items: center; + + span, + svg { + color: var(--button-bg); + } + } + span { margin: 0 4px; font-size: 12px; @@ -224,13 +234,15 @@ } .more-modal__row:hover, - .more-modal__row:focus { + .more-modal__row:focus, + .more-modal__row:focus-within { background-color: rgba(var(--center-channel-color-rgb), 0.08); cursor: pointer; .more-modal__actions { .primaryButton, - .outlineButton { + .outlineButton, + .btn-tertiary { visibility: visible; } } @@ -285,3 +297,19 @@ line-height: 20px; } } + +// Mobile parity at 480px: the row's actions wrap below the channel name +// so the touch targets do not get squeezed. +@media (max-width: 480px) { + #moreChannelsList { + .more-modal__row { + flex-wrap: wrap; + } + + .more-modal__actions { + width: 100%; + padding-left: 0; + margin-top: 8px; + } + } +} diff --git a/webapp/channels/src/components/browse_channels/browse_channels.test.tsx b/webapp/channels/src/components/browse_channels/browse_channels.test.tsx index c3527792a876..8ceeeee7684a 100644 --- a/webapp/channels/src/components/browse_channels/browse_channels.test.tsx +++ b/webapp/channels/src/components/browse_channels/browse_channels.test.tsx @@ -147,6 +147,8 @@ describe('components/BrowseChannels', () => { user_id: 'user-1', }), }, + discoverableFeatureEnabled: false, + myPendingJoinRequests: {}, actions: { getChannels: jest.fn(channelActions.getChannels), getArchivedChannels: jest.fn(channelActions.getArchivedChannels), @@ -158,6 +160,8 @@ describe('components/BrowseChannels', () => { closeRightHandSide: jest.fn(), setGlobalItem: jest.fn(), getChannelsMemberCount: jest.fn(), + getMyChannelJoinRequests: jest.fn().mockResolvedValue({data: {requests: [], total_count: 0}}), + withdrawMyChannelJoinRequest: jest.fn().mockResolvedValue({data: {}}), }, }; @@ -709,4 +713,186 @@ describe('components/BrowseChannels', () => { // start fetching unconditionally and silently waste a round-trip. expect(baseProps.actions.getRecommendedChannelsForUser).not.toHaveBeenCalled(); }); + + // --------------------------------------------------------------- + // Discoverable Private Channels — row state machine + filter chips + // --------------------------------------------------------------- + + describe('Discoverable Private Channels', () => { + const discoverableChannel = TestHelper.getChannelMock({ + id: 'discoverable-channel-id', + team_id: 'team_1', + display_name: 'Discoverable Ops', + name: 'discoverable-ops', + type: 'P', + discoverable: true, + }); + + const otherPrivateChannel = TestHelper.getChannelMock({ + id: 'opaque-private-channel-id', + team_id: 'team_1', + display_name: 'Opaque Ops', + name: 'opaque-ops', + type: 'P', + discoverable: false, + }); + + const discoverablePropsBase: Props = { + ...baseProps, + channels: [], + privateChannels: [discoverableChannel, otherPrivateChannel], + discoverableFeatureEnabled: true, + myChannelMemberships: {}, + }; + + test('fetches my pending join requests on mount when the FF is on', async () => { + renderWithContext(); + await act(async () => { + await Promise.resolve(); + }); + expect(discoverablePropsBase.actions.getMyChannelJoinRequests).toHaveBeenCalledWith({status: 'pending'}); + }); + + test('fetches discoverable channels on mount and surfaces them without a search', async () => { + const fetchedDiscoverable = TestHelper.getChannelMock({ + id: 'fetched-discoverable-id', + team_id: 'team_1', + display_name: 'Fetched Discoverable', + name: 'fetched-discoverable', + type: 'P', + discoverable: true, + delete_at: 0, + }); + const searchAllChannels = jest.fn().mockResolvedValue({data: [fetchedDiscoverable]}); + const props = {...discoverablePropsBase, privateChannels: [], actions: {...discoverablePropsBase.actions, searchAllChannels}}; + + renderWithContext(); + + expect(searchAllChannels).toHaveBeenCalledWith('', {team_ids: ['team_1'], nonAdminSearch: true}); + + await waitFor(() => { + expect(screen.getByTestId('ChannelRow-fetched-discoverable')).toBeInTheDocument(); + }); + expect(screen.getByTestId('ChannelRow-fetched-discoverable')).toHaveTextContent(/Request to join/); + }); + + test('does NOT fetch pending requests when the FF is off', async () => { + const offProps = {...discoverablePropsBase, discoverableFeatureEnabled: false}; + renderWithContext(); + await act(async () => { + await Promise.resolve(); + }); + expect(offProps.actions.getMyChannelJoinRequests).not.toHaveBeenCalled(); + }); + + test('renders the Discoverable badge + "Request to join" button on a non-member discoverable row', async () => { + renderWithContext(); + + await waitFor(() => { + expect(screen.getByTestId('ChannelRow-discoverable-ops')).toBeInTheDocument(); + }); + + const row = screen.getByTestId('ChannelRow-discoverable-ops'); + expect(row).toHaveTextContent(/Discoverable/); + expect(row).toHaveTextContent(/Request to join/); + expect(row.querySelector('.more-modal__name .more-modal__discoverable-badge')).not.toBeInTheDocument(); + expect(row.querySelector('.discoverableIndicatorContainer')).toBeInTheDocument(); + + // The non-discoverable private channel still renders on initial + // mount (filtering only kicks in on search), but it shows the + // default Join CTA and no Discoverable badge. + const opaqueRow = screen.getByTestId('ChannelRow-opaque-ops'); + expect(opaqueRow).not.toHaveTextContent(/Discoverable/); + expect(opaqueRow).not.toHaveTextContent(/Request to join/); + }); + + test('shows a Withdraw button on hover when a pending request exists', async () => { + const propsWithPending: Props = { + ...discoverablePropsBase, + myPendingJoinRequests: { + 'discoverable-channel-id': { + id: 'req1', + channel_id: 'discoverable-channel-id', + user_id: 'user-1', + message: '', + status: 'pending', + denial_reason: '', + create_at: 1, + update_at: 1, + reviewed_by: '', + reviewed_at: 0, + }, + }, + }; + renderWithContext(); + + await waitFor(() => { + expect(screen.getByTestId('ChannelRow-discoverable-ops')).toBeInTheDocument(); + }); + + const row = screen.getByTestId('ChannelRow-discoverable-ops'); + expect(row.querySelector('.more-modal__requested-pill')).not.toBeInTheDocument(); + const withdrawButton = row.querySelector('#withdrawRequestButton'); + expect(withdrawButton).toBeInTheDocument(); + + // Pending rows do nothing on Enter, so the Withdraw button must stay + // keyboard-focusable (no tabindex=-1) to remain operable. + expect(withdrawButton).not.toHaveAttribute('tabindex', '-1'); + expect(row).toHaveTextContent(/Withdraw/); + expect(row).not.toHaveTextContent(/Requested/); + expect(row.querySelector('#requestToJoinChannelButton')).not.toBeInTheDocument(); + }); + + test('clicking Request to join opens the RequestJoinChannelModal with the right channel + team', async () => { + renderWithContext(); + + await waitFor(() => { + expect(screen.getByTestId('ChannelRow-discoverable-ops')).toBeInTheDocument(); + }); + + const row = screen.getByTestId('ChannelRow-discoverable-ops'); + const requestButton = row.querySelector('#requestToJoinChannelButton') as HTMLButtonElement; + expect(requestButton).toBeInTheDocument(); + + await user.click(requestButton); + + // The row no longer fires the request action directly. It opens + // the confirmation modal; the modal is what dispatches the request + // once the user confirms. + expect(discoverablePropsBase.actions.openModal).toHaveBeenCalledWith( + expect.objectContaining({ + modalId: expect.any(String), + dialogProps: expect.objectContaining({ + channel: expect.objectContaining({id: 'discoverable-channel-id'}), + teamName: 'team_name', + }), + }), + ); + }); + + test('Discoverable + MyPendingRequests filter menu items appear when the FF is on', async () => { + renderWithContext(); + + await waitFor(() => { + expect(screen.getByLabelText('Channel type filter')).toBeInTheDocument(); + }); + + await user.click(screen.getByLabelText('Channel type filter')); + expect(screen.getByText('Discoverable private channels')).toBeInTheDocument(); + expect(screen.getByText('My pending requests')).toBeInTheDocument(); + }); + + test('Discoverable + MyPendingRequests filter menu items are hidden when the FF is off', async () => { + const offProps = {...discoverablePropsBase, discoverableFeatureEnabled: false}; + renderWithContext(); + + await waitFor(() => { + expect(screen.getByLabelText('Channel type filter')).toBeInTheDocument(); + }); + + await user.click(screen.getByLabelText('Channel type filter')); + expect(screen.queryByText('Discoverable private channels')).not.toBeInTheDocument(); + expect(screen.queryByText('My pending requests')).not.toBeInTheDocument(); + }); + }); }); diff --git a/webapp/channels/src/components/browse_channels/browse_channels.tsx b/webapp/channels/src/components/browse_channels/browse_channels.tsx index 3492c1ec07fd..7a04086701dc 100644 --- a/webapp/channels/src/components/browse_channels/browse_channels.tsx +++ b/webapp/channels/src/components/browse_channels/browse_channels.tsx @@ -6,7 +6,7 @@ import {FormattedMessage} from 'react-intl'; import {GenericModal} from '@mattermost/components'; import {Button, type ButtonEmphasis, type ButtonSize} from '@mattermost/shared/components/button'; -import type {Channel, ChannelMembership, ChannelSearchOpts, ChannelsWithTotalCount} from '@mattermost/types/channels'; +import type {Channel, ChannelJoinRequest, ChannelMembership, ChannelSearchOpts, ChannelsWithTotalCount, GetChannelJoinRequestsOptions} from '@mattermost/types/channels'; import type {RelationOneToOne} from '@mattermost/types/utilities'; import Permissions from 'mattermost-redux/constants/permissions'; @@ -15,6 +15,7 @@ import type {ActionResult} from 'mattermost-redux/types/actions'; import LoadingScreen from 'components/loading_screen'; import NewChannelModal from 'components/new_channel_modal/new_channel_modal'; import TeamPermissionGate from 'components/permissions_gates/team_permission_gate'; +import RequestJoinChannelModal from 'components/request_join_channel_modal/request_join_channel_modal'; import SearchableChannelList from 'components/searchable_channel_list'; import {getHistory} from 'utils/browser_history'; @@ -36,6 +37,8 @@ export enum Filter { Private = 'Private', Archived = 'Archived', Recommended = 'Recommended', + Discoverable = 'Discoverable', + MyPendingRequests = 'MyPendingRequests', } export type FilterType = keyof typeof Filter; @@ -68,6 +71,10 @@ type Actions = { setGlobalItem: (name: string, value: string) => void; closeRightHandSide: () => void; getChannelsMemberCount: (channelIds: string[]) => Promise; + + // Discoverable Private Channels actions + getMyChannelJoinRequests: (opts?: GetChannelJoinRequestsOptions) => Promise; + withdrawMyChannelJoinRequest: (channelId: string) => Promise>; }; export type Props = { @@ -85,6 +92,11 @@ export type Props = { channelsMemberCount?: Record; accessControlEnabled: boolean; initialFilter?: FilterType; + + // Discoverable Private Channels + discoverableFeatureEnabled: boolean; + myPendingJoinRequests: Record; + actions: Actions; }; @@ -97,6 +109,7 @@ type State = { searching: boolean; searchTerm: string; recommendedChannels: Channel[]; + discoverableChannels: Channel[]; }; export default class BrowseChannels extends React.PureComponent { @@ -117,6 +130,7 @@ export default class BrowseChannels extends React.PureComponent { searching: false, searchTerm: '', recommendedChannels: [], + discoverableChannels: [], }; } @@ -126,6 +140,15 @@ export default class BrowseChannels extends React.PureComponent { return; } + // Refresh the user's pending join requests so per-row affordances + // ("Request to join" vs. "Requested") are accurate on first render. + // Fire-and-forget — the result lands in redux via the + // RECEIVED_MY_CHANNEL_JOIN_REQUESTS action. + if (this.props.discoverableFeatureEnabled) { + this.props.actions.getMyChannelJoinRequests({status: 'pending'}); + this.loadDiscoverableChannels(); + } + const promises: Array>> = [ this.props.actions.getChannels(this.props.teamId, 0, CHANNELS_CHUNK_SIZE * 2), this.props.actions.getArchivedChannels(this.props.teamId, 0, CHANNELS_CHUNK_SIZE * 2), @@ -170,6 +193,36 @@ export default class BrowseChannels extends React.PureComponent { this.setState({loading: false}); }; + // Non-member discoverable private channels aren't returned by getChannels + // (which only fetches public channels), so without this the Discoverable + // filter and the default browse list would be empty until the user typed a + // search term. An empty-term non-admin search returns every channel the + // user can see for the team — including discoverable privates, already + // ABAC-filtered server-side — which we narrow to the non-member + // discoverable rows and surface directly. + loadDiscoverableChannels = async () => { + try { + const {data} = await this.props.actions.searchAllChannels('', {team_ids: [this.props.teamId], nonAdminSearch: true}) as ActionResult; + if (!data) { + return; + } + const discoverableChannels = data.filter((channel) => + channel.team_id === this.props.teamId && + channel.type === Constants.PRIVATE_CHANNEL && + channel.discoverable === true && + channel.delete_at === 0 && + !this.isMemberOfChannel(channel.id), + ); + if (discoverableChannels.length > 0) { + this.props.actions.getChannelsMemberCount(discoverableChannels.map((channel) => channel.id)); + } + this.setState({discoverableChannels}); + } catch { + // Discovery is best-effort; a failure just means the filter stays + // empty until the user searches. + } + }; + handleNewChannel = () => { this.handleExit(); this.closeEditRHS(); @@ -229,6 +282,29 @@ export default class BrowseChannels extends React.PureComponent { } }; + // Discoverable + no pending request → open the two-step Request to Join + // modal. The modal handles submit + success routing internally; the + // Browse row just needs to drop its loading state once the modal opens. + handleRequestToJoin = (channel: Channel, done: () => void) => { + this.props.actions.openModal({ + modalId: ModalIdentifiers.REQUEST_JOIN_CHANNEL, + dialogType: RequestJoinChannelModal, + dialogProps: { + channel, + teamName: this.props.teamName, + }, + }); + done(); + }; + + handleWithdrawRequest = async (channel: Channel, done: () => void) => { + const result = await this.props.actions.withdrawMyChannelJoinRequest(channel.id); + if (result?.error) { + this.setState({serverError: result.error.message ?? result.error.server_error_id ?? 'Unknown error'}); + } + done(); + }; + search = (term: string) => { clearTimeout(this.searchTimeoutId); @@ -267,11 +343,24 @@ export default class BrowseChannels extends React.PureComponent { this.searchTimeoutId = searchTimeoutId; }; + // Inclusive private-visibility rule: + // - Member of the channel, OR + // - Channel is discoverable AND the feature flag is on + // The server-side autocomplete + searchAllChannels already enforces this + // (PR #36580). The webapp check is a defense-in-depth filter on cached + // results and feeds the per-row state machine in SearchableChannelList. + private canSeePrivateChannel = (c: Channel) => { + if (this.isMemberOfChannel(c.id)) { + return true; + } + return this.props.discoverableFeatureEnabled && c.discoverable === true; + }; + setSearchResults = (channels: Channel[]) => { - // filter out private channels that the user is not a member of - let searchedChannels = channels.filter((c) => c.type !== Constants.PRIVATE_CHANNEL || this.isMemberOfChannel(c.id)); + // Loosened: include discoverable private channels for non-members. + let searchedChannels = channels.filter((c) => c.type !== Constants.PRIVATE_CHANNEL || this.canSeePrivateChannel(c)); if (this.state.filter === Filter.Private) { - searchedChannels = channels.filter((c) => c.type === Constants.PRIVATE_CHANNEL && this.isMemberOfChannel(c.id)); + searchedChannels = channels.filter((c) => c.type === Constants.PRIVATE_CHANNEL && this.canSeePrivateChannel(c)); } if (this.state.filter === Filter.Public) { searchedChannels = channels.filter((c) => c.type === Constants.OPEN_CHANNEL && c.delete_at === 0); @@ -283,6 +372,19 @@ export default class BrowseChannels extends React.PureComponent { const recommendedIds = new Set(this.state.recommendedChannels.map((c) => c.id)); searchedChannels = channels.filter((c) => recommendedIds.has(c.id)); } + if (this.state.filter === Filter.Discoverable) { + // Only discoverable private channels the user is not a member of. + // Members of a discoverable channel see it under their normal + // joined channels, not in the discovery surface. + searchedChannels = channels.filter((c) => + c.type === Constants.PRIVATE_CHANNEL && + c.discoverable === true && + !this.isMemberOfChannel(c.id), + ); + } + if (this.state.filter === Filter.MyPendingRequests) { + searchedChannels = channels.filter((c) => this.props.myPendingJoinRequests[c.id]); + } if (this.props.shouldHideJoinedChannels) { searchedChannels = this.getChannelsWithoutJoined(searchedChannels); } @@ -329,22 +431,59 @@ export default class BrowseChannels extends React.PureComponent { getChannelsWithoutJoined = (channelList: Channel[]) => channelList.filter((channel) => !this.isMemberOfChannel(channel.id)); getActiveChannels = () => { - const {channels, archivedChannels, shouldHideJoinedChannels, privateChannels} = this.props; - const {search, searchedChannels, filter, recommendedChannels} = this.state; + const {channels, archivedChannels, shouldHideJoinedChannels, privateChannels, myPendingJoinRequests} = this.props; + const {search, searchedChannels, filter, recommendedChannels, discoverableChannels} = this.state; + + // Discoverable private channels the user is not yet a member of. These + // come from two sources, deduped by id: the privateChannels selector + // (redux, hydrated by any prior search/autocomplete) and the mount-time + // loadDiscoverableChannels fetch (so the surface is populated before + // the user searches). + const discoverableById = new Map(); + for (const c of privateChannels) { + if (c.discoverable === true && !this.isMemberOfChannel(c.id)) { + discoverableById.set(c.id, c); + } + } + for (const c of discoverableChannels) { + if (!this.isMemberOfChannel(c.id)) { + discoverableById.set(c.id, c); + } + } + const discoverableNonMember = Array.from(discoverableById.values()); - const allChannels = channels.concat(privateChannels).sort((a, b) => a.display_name.localeCompare(b.display_name)); + // Fold the fetched discoverable channels into the "All" list so they + // appear in the default browse view, not only under the Discoverable + // filter. privateChannels-sourced rows are already in allChannels. + const extraDiscoverable = discoverableChannels.filter((c) => !privateChannels.some((p) => p.id === c.id)); + const allChannels = channels.concat(privateChannels, extraDiscoverable).sort((a, b) => a.display_name.localeCompare(b.display_name)); const allChannelsWithoutJoined = this.getChannelsWithoutJoined(allChannels); const publicChannelsWithoutJoined = this.getChannelsWithoutJoined(channels); const archivedChannelsWithoutJoined = this.getChannelsWithoutJoined(archivedChannels); const privateChannelsWithoutJoined = this.getChannelsWithoutJoined(privateChannels); const recommendedChannelsWithoutJoined = this.getChannelsWithoutJoined(recommendedChannels); + // Channels the current user has pending requests against. The + // requests slice maps channel_id -> ChannelJoinRequest, but the + // channel itself may not be in our local list if the user is not + // (and never was) a member. We resolve from the union of all known + // channel lists. + const knownChannelsById = new Map(); + for (const c of allChannels) { + knownChannelsById.set(c.id, c); + } + const myPending = Object.keys(myPendingJoinRequests). + map((id) => knownChannelsById.get(id)). + filter((c): c is Channel => Boolean(c)); + const filterOptions: Record = { [Filter.All]: shouldHideJoinedChannels ? allChannelsWithoutJoined : allChannels, [Filter.Archived]: shouldHideJoinedChannels ? archivedChannelsWithoutJoined : archivedChannels, [Filter.Private]: shouldHideJoinedChannels ? privateChannelsWithoutJoined : privateChannels, [Filter.Public]: shouldHideJoinedChannels ? publicChannelsWithoutJoined : channels, [Filter.Recommended]: shouldHideJoinedChannels ? recommendedChannelsWithoutJoined : recommendedChannels, + [Filter.Discoverable]: discoverableNonMember, + [Filter.MyPendingRequests]: myPending, }; if (search) { @@ -352,7 +491,7 @@ export default class BrowseChannels extends React.PureComponent { } const activeList = filterOptions[filter] || filterOptions[Filter.All]; - if (filter === Filter.Recommended) { + if (filter === Filter.Recommended || filter === Filter.Discoverable || filter === Filter.MyPendingRequests) { return activeList; } return this.boostRecommendedChannels(activeList); @@ -414,12 +553,16 @@ export default class BrowseChannels extends React.PureComponent { isSearch={search} search={this.search} handleJoin={this.handleJoin} + handleRequestToJoin={this.handleRequestToJoin} + handleWithdrawRequest={this.handleWithdrawRequest} noResultsText={noResultsText} loading={search ? searching : channelsRequestStarted} showRecommendedFilter={this.props.accessControlEnabled} + showDiscoverableFilters={this.props.discoverableFeatureEnabled} changeFilter={this.changeFilter} filter={this.state.filter} myChannelMemberships={this.props.myChannelMemberships} + myPendingJoinRequests={this.props.myPendingJoinRequests} closeModal={this.props.actions.closeModal} hideJoinedChannelsPreference={this.handleShowJoinedChannelsPreference} rememberHideJoinedChannelsChecked={shouldHideJoinedChannels} diff --git a/webapp/channels/src/components/browse_channels/index.ts b/webapp/channels/src/components/browse_channels/index.ts index 8b7ec24d736a..03cdbc62fe77 100644 --- a/webapp/channels/src/components/browse_channels/index.ts +++ b/webapp/channels/src/components/browse_channels/index.ts @@ -7,10 +7,20 @@ import type {Dispatch} from 'redux'; import type {Channel} from '@mattermost/types/channels'; -import {getChannels, getArchivedChannels, getRecommendedChannelsForUser, joinChannel, getChannelsMemberCount, searchAllChannels} from 'mattermost-redux/actions/channels'; +import { + getChannels, + getArchivedChannels, + getRecommendedChannelsForUser, + joinChannel, + getChannelsMemberCount, + searchAllChannels, + getMyChannelJoinRequests, + withdrawMyChannelJoinRequest, +} from 'mattermost-redux/actions/channels'; import {RequestStatus} from 'mattermost-redux/constants'; import {createSelector} from 'mattermost-redux/selectors/create_selector'; -import {getChannelsInCurrentTeam, getMyChannelMemberships, getChannelsMemberCount as getChannelsMemberCountSelector} from 'mattermost-redux/selectors/entities/channels'; +import {getChannelsInCurrentTeam, getMyChannelMemberships, getChannelsMemberCount as getChannelsMemberCountSelector, getMyPendingJoinRequestsByChannel} from 'mattermost-redux/selectors/entities/channels'; +import {isDiscoverableChannelsEnabled} from 'mattermost-redux/selectors/entities/general'; import {getCurrentTeam, getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; @@ -63,6 +73,10 @@ function mapStateToProps(state: GlobalState) { rhsOpen: getIsRhsOpen(state), channelsMemberCount: getChannelsMemberCountSelector(state), accessControlEnabled: isChannelAccessControlEnabled(state), + + // Discoverable Private Channels — feed the per-row state machine. + discoverableFeatureEnabled: isDiscoverableChannelsEnabled(state), + myPendingJoinRequests: getMyPendingJoinRequestsByChannel(state), }; } @@ -79,6 +93,8 @@ function mapDispatchToProps(dispatch: Dispatch) { setGlobalItem, closeRightHandSide, getChannelsMemberCount, + getMyChannelJoinRequests, + withdrawMyChannelJoinRequest, }, dispatch), }; } diff --git a/webapp/channels/src/components/channel_header/__snapshots__/channel_header.test.tsx.snap b/webapp/channels/src/components/channel_header/__snapshots__/channel_header.test.tsx.snap index c2bb57c9718c..7fb4739ea8cd 100644 --- a/webapp/channels/src/components/channel_header/__snapshots__/channel_header.test.tsx.snap +++ b/webapp/channels/src/components/channel_header/__snapshots__/channel_header.test.tsx.snap @@ -84,7 +84,7 @@ exports[`components/ChannelHeader should match snapshot with last active display id="channel-info-btn" > @@ -165,7 +165,7 @@ exports[`components/ChannelHeader should match snapshot with no last active disp id="channel-info-btn" > @@ -205,10 +205,14 @@ exports[`components/ChannelHeader should render active channel files 1`] = ` class="member-rhs__trigger channel-header__icon channel-header__icon--wide channel-header__icon--left btn btn-icon btn-xs" id="member_rhs" > -