Skip to content

Commit 8f0f423

Browse files
[MM-65627] Add Channel popout window (mattermost#35596)
* [MM-65627] Add Channel popout window * Merge'd * Merge'd * FIx e2e * Prettier * PR feedback --------- Co-authored-by: Mattermost Build <build@mattermost.com>
1 parent fb11968 commit 8f0f423

36 files changed

Lines changed: 1305 additions & 60 deletions

e2e-tests/playwright/specs/functional/channels/search/search_popout.spec.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ test('MM-65630-1 Search results should show popout button that opens results in
3434
await expect(page.locator('#searchContainer')).toBeVisible();
3535
await expect(page.locator('#searchContainer').getByText(uniqueText)).toBeVisible();
3636

37-
const popoutButton = page.locator('.PopoutButton');
37+
const popoutButton = page.locator('#searchContainer .PopoutButton');
3838
await expect(popoutButton).toBeVisible();
3939

4040
const [popoutPage] = await Promise.all([page.waitForEvent('popup'), popoutButton.click()]);
@@ -82,7 +82,7 @@ test('MM-65630-2 Recent mentions popout should open with the right results', asy
8282
await expect(page.locator('#searchContainer').getByRole('heading', {name: 'Recent Mentions'})).toBeVisible();
8383
await expect(page.locator('#searchContainer').getByText(mentionText)).toBeVisible();
8484

85-
const popoutButton = page.locator('.PopoutButton');
85+
const popoutButton = page.locator('#searchContainer .PopoutButton');
8686
await expect(popoutButton).toBeVisible();
8787

8888
const [popoutPage] = await Promise.all([page.waitForEvent('popup'), popoutButton.click()]);
@@ -138,7 +138,7 @@ test('MM-65630-3 Saved messages popout should open with the right results', asyn
138138
await expect(page.locator('#searchContainer').getByRole('heading', {name: 'Saved messages'})).toBeVisible();
139139
await expect(page.locator('#searchContainer').getByText(savedText)).toBeVisible();
140140

141-
const popoutButton = page.locator('.PopoutButton');
141+
const popoutButton = page.locator('#searchContainer .PopoutButton');
142142
await expect(popoutButton).toBeVisible();
143143

144144
const [popoutPage] = await Promise.all([page.waitForEvent('popup'), popoutButton.click()]);
@@ -185,7 +185,10 @@ test('MM-65630-4 Search popout should not show popout button in the popout windo
185185

186186
await expect(page.locator('#searchContainer')).toBeVisible();
187187

188-
const [popoutPage] = await Promise.all([page.waitForEvent('popup'), page.locator('.PopoutButton').click()]);
188+
const [popoutPage] = await Promise.all([
189+
page.waitForEvent('popup'),
190+
page.locator('#searchContainer .PopoutButton').click(),
191+
]);
189192

190193
await popoutPage.waitForLoadState('domcontentloaded');
191194
await expect(popoutPage.locator('#searchContainer')).toBeVisible({timeout: 10000});
@@ -224,7 +227,7 @@ test('MM-65630-5 Search popout should preserve search type (files) in the URL',
224227
const filesTab = page.locator('#searchContainer').getByRole('tab', {name: /Files/});
225228
await filesTab.click();
226229

227-
const popoutButton = page.locator('.PopoutButton');
230+
const popoutButton = page.locator('#searchContainer .PopoutButton');
228231
await expect(popoutButton).toBeVisible();
229232

230233
const [popoutPage] = await Promise.all([page.waitForEvent('popup'), popoutButton.click()]);

webapp/channels/src/actions/views/channel.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import {getHistory} from 'utils/browser_history';
6060
import {isArchivedChannel} from 'utils/channel_utils';
6161
import {Constants, ActionTypes, EventTypes, PostRequestTypes} from 'utils/constants';
6262
import {stopTryNotificationRing} from 'utils/notification_sounds';
63+
import {isChannelPopoutWindow} from 'utils/popouts/popout_windows';
6364

6465
import type {ActionFuncAsync, ThunkActionFunc} from 'types/store';
6566

@@ -77,7 +78,11 @@ export function goToLastViewedChannel(): ActionFuncAsync {
7778
channelToSwitchTo = getChannelByName(channels, getRedirectChannelNameForTeam(state, getCurrentTeamId(state)));
7879
}
7980

80-
return dispatch(switchToChannel(channelToSwitchTo!));
81+
const result = await dispatch(switchToChannel(channelToSwitchTo!));
82+
if (isChannelPopoutWindow()) {
83+
window.close();
84+
}
85+
return result;
8186
};
8287
}
8388

@@ -191,9 +196,14 @@ export function leaveChannel(channelId: string): ActionFuncAsync {
191196
dispatch(selectTeam(''));
192197
dispatch({type: TeamTypes.LEAVE_TEAM, data: currentTeam});
193198
getHistory().push('/');
199+
if (isChannelPopoutWindow()) {
200+
window.close();
201+
}
194202
} else if (channelId === currentChannelId) {
195-
// We only need to leave the channel if we are in the channel
196203
getHistory().push(teamUrl);
204+
if (isChannelPopoutWindow()) {
205+
window.close();
206+
}
197207
}
198208

199209
return {

webapp/channels/src/actions/websocket_actions.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ import {loadPlugin, loadPluginsIfNecessary, removePlugin} from 'plugins';
156156
import {getHistory} from 'utils/browser_history';
157157
import {ActionTypes, Constants, AnnouncementBarMessages, SocketEvents, UserStatuses, ModalIdentifiers, PageLoadContext} from 'utils/constants';
158158
import {getIntl} from 'utils/i18n';
159+
import {isChannelPopoutWindow} from 'utils/popouts/popout_windows';
159160
import {getSiteURL} from 'utils/url';
160161

161162
import type {ActionFunc, ThunkActionFunc} from 'types/store';
@@ -754,7 +755,15 @@ export function handleChannelUpdatedEvent(msg: WebSocketMessages.ChannelUpdated)
754755
if (channel.id === getCurrentChannelId(state)) {
755756
// using channel's team_id to ensure we always redirect to current channel even if channel's team changes.
756757
const teamId = channel.team_id || getCurrentTeamId(state);
757-
getHistory().replace(`${getRelativeTeamUrl(state, teamId)}/channels/${channel.name}`);
758+
const teamUrl = getRelativeTeamUrl(state, teamId);
759+
let channelPath = `${teamUrl}/channels/${channel.name}`;
760+
761+
// For the popout we make an exception and redirect to the popout path instead of the channel path.
762+
// DM/GM names never change, so we only need to handle regular channels here.
763+
if (isChannelPopoutWindow() && channel.type !== General.DM_CHANNEL && channel.type !== General.GM_CHANNEL) {
764+
channelPath = `/_popout/channel${teamUrl}/channels/${channel.name}`;
765+
}
766+
getHistory().replace(channelPath);
758767
}
759768
};
760769
}
@@ -1093,6 +1102,9 @@ function handleDeleteTeamEvent(msg: WebSocketMessages.Team) {
10931102
} else {
10941103
getHistory().push('/');
10951104
}
1105+
if (isChannelPopoutWindow()) {
1106+
window.close();
1107+
}
10961108
}
10971109
}
10981110
}
@@ -1206,7 +1218,10 @@ export function handleUserRemovedEvent(msg: WebSocketMessages.UserRemovedFromCha
12061218
});
12071219

12081220
if (currentChannel && msg.data.channel_id === currentChannel.id) {
1209-
redirectUserToDefaultTeam();
1221+
const redirect = redirectUserToDefaultTeam();
1222+
if (isChannelPopoutWindow()) {
1223+
redirect.then(() => window.close());
1224+
}
12101225
}
12111226

12121227
if (isGuest(currentUser.roles)) {

webapp/channels/src/components/CLAUDE.OPTIONAL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ const intl = useIntl();
7979
- Prefer `userEvent` and accessible queries (`getByRole`) over implementation-specific selectors.
8080
- Avoid snapshots; assert visible behavior instead.
8181

82+
## Icons
83+
- **Menu items and components should use Compass icon components** from `@mattermost/compass-icons/components` (e.g., `<DockWindowIcon size={18}/>`), not raw `<i className="icon icon-..."/>` elements.
84+
8285
## Useful Examples
8386
- `channel_view/channel_view.tsx` – full-page component structure with co-located SCSS.
8487
- `post_view/post_list_virtualized/post_list_virtualized.tsx` – virtualization + hooks pattern.

webapp/channels/src/components/channel_header/__snapshots__/channel_header.test.tsx.snap

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,10 @@ exports[`components/ChannelHeader should match snapshot with last active display
279279
}
280280
/>
281281
<Connect(CallButton) />
282+
<PopoutButton
283+
className="channel-header__icon"
284+
onClick={[Function]}
285+
/>
282286
<ChannelInfoButton
283287
channel={
284288
Object {
@@ -559,6 +563,10 @@ exports[`components/ChannelHeader should match snapshot with no last active disp
559563
}
560564
/>
561565
<Connect(CallButton) />
566+
<PopoutButton
567+
className="channel-header__icon"
568+
onClick={[Function]}
569+
/>
562570
<ChannelInfoButton
563571
channel={
564572
Object {
@@ -764,6 +772,10 @@ exports[`components/ChannelHeader should render active channel files 1`] = `
764772
}
765773
/>
766774
<Connect(CallButton) />
775+
<PopoutButton
776+
className="channel-header__icon"
777+
onClick={[Function]}
778+
/>
767779
<ChannelInfoButton
768780
channel={
769781
Object {
@@ -968,6 +980,10 @@ exports[`components/ChannelHeader should render active flagged posts 1`] = `
968980
}
969981
/>
970982
<Connect(CallButton) />
983+
<PopoutButton
984+
className="channel-header__icon"
985+
onClick={[Function]}
986+
/>
971987
<ChannelInfoButton
972988
channel={
973989
Object {
@@ -1172,6 +1188,10 @@ exports[`components/ChannelHeader should render active mentions posts 1`] = `
11721188
}
11731189
/>
11741190
<Connect(CallButton) />
1191+
<PopoutButton
1192+
className="channel-header__icon"
1193+
onClick={[Function]}
1194+
/>
11751195
<ChannelInfoButton
11761196
channel={
11771197
Object {
@@ -1376,6 +1396,10 @@ exports[`components/ChannelHeader should render active pinned posts 1`] = `
13761396
}
13771397
/>
13781398
<Connect(CallButton) />
1399+
<PopoutButton
1400+
className="channel-header__icon"
1401+
onClick={[Function]}
1402+
/>
13791403
<ChannelInfoButton
13801404
channel={
13811405
Object {
@@ -1580,6 +1604,10 @@ exports[`components/ChannelHeader should render archived view 1`] = `
15801604
}
15811605
/>
15821606
<Connect(CallButton) />
1607+
<PopoutButton
1608+
className="channel-header__icon"
1609+
onClick={[Function]}
1610+
/>
15831611
<ChannelInfoButton
15841612
channel={
15851613
Object {
@@ -1804,6 +1832,10 @@ exports[`components/ChannelHeader should render correct menu when muted 1`] = `
18041832
}
18051833
/>
18061834
<Connect(CallButton) />
1835+
<PopoutButton
1836+
className="channel-header__icon"
1837+
onClick={[Function]}
1838+
/>
18071839
<ChannelInfoButton
18081840
channel={
18091841
Object {
@@ -2008,6 +2040,10 @@ exports[`components/ChannelHeader should render not active channel files 1`] = `
20082040
}
20092041
/>
20102042
<Connect(CallButton) />
2043+
<PopoutButton
2044+
className="channel-header__icon"
2045+
onClick={[Function]}
2046+
/>
20112047
<ChannelInfoButton
20122048
channel={
20132049
Object {
@@ -2308,6 +2344,10 @@ exports[`components/ChannelHeader should render properly when custom status is e
23082344
}
23092345
/>
23102346
<Connect(CallButton) />
2347+
<PopoutButton
2348+
className="channel-header__icon"
2349+
onClick={[Function]}
2350+
/>
23112351
<ChannelInfoButton
23122352
channel={
23132353
Object {
@@ -2627,6 +2667,10 @@ exports[`components/ChannelHeader should render properly when custom status is s
26272667
}
26282668
/>
26292669
<Connect(CallButton) />
2670+
<PopoutButton
2671+
className="channel-header__icon"
2672+
onClick={[Function]}
2673+
/>
26302674
<ChannelInfoButton
26312675
channel={
26322676
Object {
@@ -2832,6 +2876,10 @@ exports[`components/ChannelHeader should render properly when empty 1`] = `
28322876
}
28332877
/>
28342878
<Connect(CallButton) />
2879+
<PopoutButton
2880+
className="channel-header__icon"
2881+
onClick={[Function]}
2882+
/>
28352883
<ChannelInfoButton
28362884
channel={
28372885
Object {
@@ -3036,6 +3084,10 @@ exports[`components/ChannelHeader should render properly when populated 1`] = `
30363084
}
30373085
/>
30383086
<Connect(CallButton) />
3087+
<PopoutButton
3088+
className="channel-header__icon"
3089+
onClick={[Function]}
3090+
/>
30393091
<ChannelInfoButton
30403092
channel={
30413093
Object {
@@ -3261,6 +3313,10 @@ exports[`components/ChannelHeader should render properly when populated with cha
32613313
}
32623314
/>
32633315
<Connect(CallButton) />
3316+
<PopoutButton
3317+
className="channel-header__icon"
3318+
onClick={[Function]}
3319+
/>
32643320
<ChannelInfoButton
32653321
channel={
32663322
Object {
@@ -3428,6 +3484,10 @@ exports[`components/ChannelHeader should render shared view 1`] = `
34283484
</div>
34293485
</div>
34303486
</div>
3487+
<PopoutButton
3488+
className="channel-header__icon"
3489+
onClick={[Function]}
3490+
/>
34313491
<ChannelInfoButton
34323492
channel={
34333493
Object {
@@ -3650,6 +3710,10 @@ exports[`components/ChannelHeader should render the pinned icon with the pinned
36503710
}
36513711
/>
36523712
<Connect(CallButton) />
3713+
<PopoutButton
3714+
className="channel-header__icon"
3715+
onClick={[Function]}
3716+
/>
36533717
<ChannelInfoButton
36543718
channel={
36553719
Object {

webapp/channels/src/components/channel_header/channel_header.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ describe('components/ChannelHeader', () => {
2727
showChannelMembers: jest.fn(),
2828
fetchChannelRemotes: jest.fn(),
2929
},
30-
teamId: 'team_id',
30+
team: TestHelper.getTeamMock({id: 'team_id'}),
3131
channel: TestHelper.getChannelMock({}),
3232
channelMember: TestHelper.getChannelMembershipMock({}),
3333
currentUser: TestHelper.getUserMock({}),

webapp/channels/src/components/channel_header/channel_header.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,24 @@ import type {MouseEvent, ReactNode, RefObject} from 'react';
77
import {FormattedMessage, injectIntl} from 'react-intl';
88
import type {WrappedComponentProps} from 'react-intl';
99

10+
import {getPopoutChannelTitle} from 'components/channel_popout/channel_popout';
1011
import CustomStatusEmoji from 'components/custom_status/custom_status_emoji';
1112
import CustomStatusText from 'components/custom_status/custom_status_text';
13+
import PopoutButton from 'components/popout_button';
1214
import Timestamp from 'components/timestamp';
1315
import Tag from 'components/widgets/tag/tag';
1416
import WithTooltip from 'components/with_tooltip';
1517

1618
import CallButton from 'plugins/call_button';
1719
import ChannelHeaderPlug from 'plugins/channel_header_plug';
1820
import Pluggable from 'plugins/pluggable';
21+
import {getChannelRoutePathAndIdentifier} from 'utils/channel_utils';
1922
import {
2023
Constants,
2124
NotificationLevels,
2225
RHSStates,
2326
} from 'utils/constants';
27+
import {canPopout, isChannelPopoutWindow, popoutChannel} from 'utils/popouts/popout_windows';
2428
import {isEmptyObject} from 'utils/utils';
2529

2630
import ChannelHeaderText from './channel_header_text';
@@ -97,6 +101,14 @@ class ChannelHeader extends React.PureComponent<Props> {
97101
}
98102
};
99103

104+
popoutChannelView = () => {
105+
const {channel, team, dmUser, intl} = this.props;
106+
if (channel && team) {
107+
const {path, identifier} = getChannelRoutePathAndIdentifier(channel, dmUser?.username);
108+
popoutChannel(intl.formatMessage(getPopoutChannelTitle(channel.type)), team.name, path, identifier);
109+
}
110+
};
111+
100112
toggleChannelMembersRHS = () => {
101113
if (this.props.rhsState === RHSStates.CHANNEL_MEMBERS) {
102114
this.props.actions.closeRightHandSide();
@@ -132,7 +144,7 @@ class ChannelHeader extends React.PureComponent<Props> {
132144

133145
render() {
134146
const {
135-
teamId,
147+
team,
136148
currentUser,
137149
gmMembers,
138150
channel,
@@ -410,7 +422,7 @@ class ChannelHeader extends React.PureComponent<Props> {
410422
{hasGuestsText}
411423
{autotranslationMessage}
412424
<ChannelHeaderText
413-
teamId={teamId}
425+
teamId={team?.id}
414426
channel={channel}
415427
dmUser={dmUser}
416428
/>
@@ -427,6 +439,12 @@ class ChannelHeader extends React.PureComponent<Props> {
427439
<CallButton/>
428440
</>
429441
)}
442+
{canPopout() && !isChannelPopoutWindow() && (
443+
<PopoutButton
444+
className='channel-header__icon'
445+
onClick={this.popoutChannelView}
446+
/>
447+
)}
430448
<ChannelInfoButton channel={channel}/>
431449
</div>
432450
</div>

0 commit comments

Comments
 (0)