Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/develop/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
},
Expand All @@ -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?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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);
},
);
71 changes: 70 additions & 1 deletion webapp/channels/src/actions/websocket_actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<void> {
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<void> {
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<void> {
return (doDispatch, doGetState) => {
const channelMember = JSON.parse(msg.data.channelMember) as ChannelMembership;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

.AdvancedTextEditor__skeleton {
display: flex;
height: 122px;
height: 98px;
align-items: center;
justify-content: center;
padding-left: 10px;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,16 @@
}
}

.discoverableIndicatorContainer {
display: flex;
align-items: center;

span,
svg {
color: var(--button-bg);
}
}

span {
margin: 0 4px;
font-size: 12px;
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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;
}
}
}
Loading
Loading