[MM-65627] Add Channel popout window (#35596)

* [MM-65627] Add Channel popout window

* Merge'd

* Merge'd

* FIx e2e

* Prettier

* PR feedback

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Devin Binnie 2026-03-25 15:30:04 -04:00 committed by GitHub
parent fb11968f87
commit 8f0f4239eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1305 additions and 60 deletions

View file

@ -34,7 +34,7 @@ test('MM-65630-1 Search results should show popout button that opens results in
await expect(page.locator('#searchContainer')).toBeVisible();
await expect(page.locator('#searchContainer').getByText(uniqueText)).toBeVisible();
const popoutButton = page.locator('.PopoutButton');
const popoutButton = page.locator('#searchContainer .PopoutButton');
await expect(popoutButton).toBeVisible();
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
await expect(page.locator('#searchContainer').getByRole('heading', {name: 'Recent Mentions'})).toBeVisible();
await expect(page.locator('#searchContainer').getByText(mentionText)).toBeVisible();
const popoutButton = page.locator('.PopoutButton');
const popoutButton = page.locator('#searchContainer .PopoutButton');
await expect(popoutButton).toBeVisible();
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
await expect(page.locator('#searchContainer').getByRole('heading', {name: 'Saved messages'})).toBeVisible();
await expect(page.locator('#searchContainer').getByText(savedText)).toBeVisible();
const popoutButton = page.locator('.PopoutButton');
const popoutButton = page.locator('#searchContainer .PopoutButton');
await expect(popoutButton).toBeVisible();
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
await expect(page.locator('#searchContainer')).toBeVisible();
const [popoutPage] = await Promise.all([page.waitForEvent('popup'), page.locator('.PopoutButton').click()]);
const [popoutPage] = await Promise.all([
page.waitForEvent('popup'),
page.locator('#searchContainer .PopoutButton').click(),
]);
await popoutPage.waitForLoadState('domcontentloaded');
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',
const filesTab = page.locator('#searchContainer').getByRole('tab', {name: /Files/});
await filesTab.click();
const popoutButton = page.locator('.PopoutButton');
const popoutButton = page.locator('#searchContainer .PopoutButton');
await expect(popoutButton).toBeVisible();
const [popoutPage] = await Promise.all([page.waitForEvent('popup'), popoutButton.click()]);

View file

@ -60,6 +60,7 @@ import {getHistory} from 'utils/browser_history';
import {isArchivedChannel} from 'utils/channel_utils';
import {Constants, ActionTypes, EventTypes, PostRequestTypes} from 'utils/constants';
import {stopTryNotificationRing} from 'utils/notification_sounds';
import {isChannelPopoutWindow} from 'utils/popouts/popout_windows';
import type {ActionFuncAsync, ThunkActionFunc} from 'types/store';
@ -77,7 +78,11 @@ export function goToLastViewedChannel(): ActionFuncAsync {
channelToSwitchTo = getChannelByName(channels, getRedirectChannelNameForTeam(state, getCurrentTeamId(state)));
}
return dispatch(switchToChannel(channelToSwitchTo!));
const result = await dispatch(switchToChannel(channelToSwitchTo!));
if (isChannelPopoutWindow()) {
window.close();
}
return result;
};
}
@ -191,9 +196,14 @@ export function leaveChannel(channelId: string): ActionFuncAsync {
dispatch(selectTeam(''));
dispatch({type: TeamTypes.LEAVE_TEAM, data: currentTeam});
getHistory().push('/');
if (isChannelPopoutWindow()) {
window.close();
}
} else if (channelId === currentChannelId) {
// We only need to leave the channel if we are in the channel
getHistory().push(teamUrl);
if (isChannelPopoutWindow()) {
window.close();
}
}
return {

View file

@ -156,6 +156,7 @@ import {loadPlugin, loadPluginsIfNecessary, removePlugin} from 'plugins';
import {getHistory} from 'utils/browser_history';
import {ActionTypes, Constants, AnnouncementBarMessages, SocketEvents, UserStatuses, ModalIdentifiers, PageLoadContext} from 'utils/constants';
import {getIntl} from 'utils/i18n';
import {isChannelPopoutWindow} from 'utils/popouts/popout_windows';
import {getSiteURL} from 'utils/url';
import type {ActionFunc, ThunkActionFunc} from 'types/store';
@ -754,7 +755,15 @@ export function handleChannelUpdatedEvent(msg: WebSocketMessages.ChannelUpdated)
if (channel.id === getCurrentChannelId(state)) {
// using channel's team_id to ensure we always redirect to current channel even if channel's team changes.
const teamId = channel.team_id || getCurrentTeamId(state);
getHistory().replace(`${getRelativeTeamUrl(state, teamId)}/channels/${channel.name}`);
const teamUrl = getRelativeTeamUrl(state, teamId);
let channelPath = `${teamUrl}/channels/${channel.name}`;
// For the popout we make an exception and redirect to the popout path instead of the channel path.
// DM/GM names never change, so we only need to handle regular channels here.
if (isChannelPopoutWindow() && channel.type !== General.DM_CHANNEL && channel.type !== General.GM_CHANNEL) {
channelPath = `/_popout/channel${teamUrl}/channels/${channel.name}`;
}
getHistory().replace(channelPath);
}
};
}
@ -1093,6 +1102,9 @@ function handleDeleteTeamEvent(msg: WebSocketMessages.Team) {
} else {
getHistory().push('/');
}
if (isChannelPopoutWindow()) {
window.close();
}
}
}
}
@ -1206,7 +1218,10 @@ export function handleUserRemovedEvent(msg: WebSocketMessages.UserRemovedFromCha
});
if (currentChannel && msg.data.channel_id === currentChannel.id) {
redirectUserToDefaultTeam();
const redirect = redirectUserToDefaultTeam();
if (isChannelPopoutWindow()) {
redirect.then(() => window.close());
}
}
if (isGuest(currentUser.roles)) {

View file

@ -79,6 +79,9 @@ const intl = useIntl();
- Prefer `userEvent` and accessible queries (`getByRole`) over implementation-specific selectors.
- Avoid snapshots; assert visible behavior instead.
## Icons
- **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.
## Useful Examples
- `channel_view/channel_view.tsx` full-page component structure with co-located SCSS.
- `post_view/post_list_virtualized/post_list_virtualized.tsx` virtualization + hooks pattern.

View file

@ -279,6 +279,10 @@ exports[`components/ChannelHeader should match snapshot with last active display
}
/>
<Connect(CallButton) />
<PopoutButton
className="channel-header__icon"
onClick={[Function]}
/>
<ChannelInfoButton
channel={
Object {
@ -559,6 +563,10 @@ exports[`components/ChannelHeader should match snapshot with no last active disp
}
/>
<Connect(CallButton) />
<PopoutButton
className="channel-header__icon"
onClick={[Function]}
/>
<ChannelInfoButton
channel={
Object {
@ -764,6 +772,10 @@ exports[`components/ChannelHeader should render active channel files 1`] = `
}
/>
<Connect(CallButton) />
<PopoutButton
className="channel-header__icon"
onClick={[Function]}
/>
<ChannelInfoButton
channel={
Object {
@ -968,6 +980,10 @@ exports[`components/ChannelHeader should render active flagged posts 1`] = `
}
/>
<Connect(CallButton) />
<PopoutButton
className="channel-header__icon"
onClick={[Function]}
/>
<ChannelInfoButton
channel={
Object {
@ -1172,6 +1188,10 @@ exports[`components/ChannelHeader should render active mentions posts 1`] = `
}
/>
<Connect(CallButton) />
<PopoutButton
className="channel-header__icon"
onClick={[Function]}
/>
<ChannelInfoButton
channel={
Object {
@ -1376,6 +1396,10 @@ exports[`components/ChannelHeader should render active pinned posts 1`] = `
}
/>
<Connect(CallButton) />
<PopoutButton
className="channel-header__icon"
onClick={[Function]}
/>
<ChannelInfoButton
channel={
Object {
@ -1580,6 +1604,10 @@ exports[`components/ChannelHeader should render archived view 1`] = `
}
/>
<Connect(CallButton) />
<PopoutButton
className="channel-header__icon"
onClick={[Function]}
/>
<ChannelInfoButton
channel={
Object {
@ -1804,6 +1832,10 @@ exports[`components/ChannelHeader should render correct menu when muted 1`] = `
}
/>
<Connect(CallButton) />
<PopoutButton
className="channel-header__icon"
onClick={[Function]}
/>
<ChannelInfoButton
channel={
Object {
@ -2008,6 +2040,10 @@ exports[`components/ChannelHeader should render not active channel files 1`] = `
}
/>
<Connect(CallButton) />
<PopoutButton
className="channel-header__icon"
onClick={[Function]}
/>
<ChannelInfoButton
channel={
Object {
@ -2308,6 +2344,10 @@ exports[`components/ChannelHeader should render properly when custom status is e
}
/>
<Connect(CallButton) />
<PopoutButton
className="channel-header__icon"
onClick={[Function]}
/>
<ChannelInfoButton
channel={
Object {
@ -2627,6 +2667,10 @@ exports[`components/ChannelHeader should render properly when custom status is s
}
/>
<Connect(CallButton) />
<PopoutButton
className="channel-header__icon"
onClick={[Function]}
/>
<ChannelInfoButton
channel={
Object {
@ -2832,6 +2876,10 @@ exports[`components/ChannelHeader should render properly when empty 1`] = `
}
/>
<Connect(CallButton) />
<PopoutButton
className="channel-header__icon"
onClick={[Function]}
/>
<ChannelInfoButton
channel={
Object {
@ -3036,6 +3084,10 @@ exports[`components/ChannelHeader should render properly when populated 1`] = `
}
/>
<Connect(CallButton) />
<PopoutButton
className="channel-header__icon"
onClick={[Function]}
/>
<ChannelInfoButton
channel={
Object {
@ -3261,6 +3313,10 @@ exports[`components/ChannelHeader should render properly when populated with cha
}
/>
<Connect(CallButton) />
<PopoutButton
className="channel-header__icon"
onClick={[Function]}
/>
<ChannelInfoButton
channel={
Object {
@ -3428,6 +3484,10 @@ exports[`components/ChannelHeader should render shared view 1`] = `
</div>
</div>
</div>
<PopoutButton
className="channel-header__icon"
onClick={[Function]}
/>
<ChannelInfoButton
channel={
Object {
@ -3650,6 +3710,10 @@ exports[`components/ChannelHeader should render the pinned icon with the pinned
}
/>
<Connect(CallButton) />
<PopoutButton
className="channel-header__icon"
onClick={[Function]}
/>
<ChannelInfoButton
channel={
Object {

View file

@ -27,7 +27,7 @@ describe('components/ChannelHeader', () => {
showChannelMembers: jest.fn(),
fetchChannelRemotes: jest.fn(),
},
teamId: 'team_id',
team: TestHelper.getTeamMock({id: 'team_id'}),
channel: TestHelper.getChannelMock({}),
channelMember: TestHelper.getChannelMembershipMock({}),
currentUser: TestHelper.getUserMock({}),

View file

@ -7,8 +7,10 @@ import type {MouseEvent, ReactNode, RefObject} from 'react';
import {FormattedMessage, injectIntl} from 'react-intl';
import type {WrappedComponentProps} from 'react-intl';
import {getPopoutChannelTitle} from 'components/channel_popout/channel_popout';
import CustomStatusEmoji from 'components/custom_status/custom_status_emoji';
import CustomStatusText from 'components/custom_status/custom_status_text';
import PopoutButton from 'components/popout_button';
import Timestamp from 'components/timestamp';
import Tag from 'components/widgets/tag/tag';
import WithTooltip from 'components/with_tooltip';
@ -16,11 +18,13 @@ import WithTooltip from 'components/with_tooltip';
import CallButton from 'plugins/call_button';
import ChannelHeaderPlug from 'plugins/channel_header_plug';
import Pluggable from 'plugins/pluggable';
import {getChannelRoutePathAndIdentifier} from 'utils/channel_utils';
import {
Constants,
NotificationLevels,
RHSStates,
} from 'utils/constants';
import {canPopout, isChannelPopoutWindow, popoutChannel} from 'utils/popouts/popout_windows';
import {isEmptyObject} from 'utils/utils';
import ChannelHeaderText from './channel_header_text';
@ -97,6 +101,14 @@ class ChannelHeader extends React.PureComponent<Props> {
}
};
popoutChannelView = () => {
const {channel, team, dmUser, intl} = this.props;
if (channel && team) {
const {path, identifier} = getChannelRoutePathAndIdentifier(channel, dmUser?.username);
popoutChannel(intl.formatMessage(getPopoutChannelTitle(channel.type)), team.name, path, identifier);
}
};
toggleChannelMembersRHS = () => {
if (this.props.rhsState === RHSStates.CHANNEL_MEMBERS) {
this.props.actions.closeRightHandSide();
@ -132,7 +144,7 @@ class ChannelHeader extends React.PureComponent<Props> {
render() {
const {
teamId,
team,
currentUser,
gmMembers,
channel,
@ -410,7 +422,7 @@ class ChannelHeader extends React.PureComponent<Props> {
{hasGuestsText}
{autotranslationMessage}
<ChannelHeaderText
teamId={teamId}
teamId={team?.id}
channel={channel}
dmUser={dmUser}
/>
@ -427,6 +439,12 @@ class ChannelHeader extends React.PureComponent<Props> {
<CallButton/>
</>
)}
{canPopout() && !isChannelPopoutWindow() && (
<PopoutButton
className='channel-header__icon'
onClick={this.popoutChannelView}
/>
)}
<ChannelInfoButton channel={channel}/>
</div>
</div>

View file

@ -25,7 +25,7 @@ import {isChannelNamesMap} from 'utils/text_formatting';
import {ChannelHeaderTextPopover} from './channel_header_text_popover';
interface Props {
teamId: Team['id'];
teamId?: Team['id'];
channel: Channel;
dmUser?: UserProfile;
}

View file

@ -21,7 +21,7 @@ import {
} from 'mattermost-redux/selectors/entities/channels';
import {getConfig, getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general';
import {getRemoteNamesForChannel} from 'mattermost-redux/selectors/entities/shared_channels';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
import {
displayLastActiveLabel,
getCurrentUser,
@ -86,7 +86,7 @@ function makeMapStateToProps() {
}
return {
teamId: getCurrentTeamId(state),
team: getCurrentTeam(state),
channel,
channelMember: getMyCurrentChannelMembership(state),
memberCount: stats?.member_count || 0,

View file

@ -35,6 +35,7 @@ import ChannelDirectMenu from './channel_header_menu_items/channel_header_direct
import ChannelGroupMenu from './channel_header_menu_items/channel_header_group_menu';
import ChannelHeaderMobileMenu from './channel_header_menu_items/channel_header_mobile_menu';
import ChannelPublicPrivateMenu from './channel_header_menu_items/channel_header_public_private_menu';
import MenuItemOpenInNewWindow from './menu_items/open_in_new_window';
import ChannelHeaderTitleDirect from '../channel_header/channel_header_title_direct';
import ChannelHeaderTitleGroup from '../channel_header/channel_header_title_group';
@ -148,6 +149,7 @@ export default function ChannelHeaderMenu({dmUser, gmMembers, isMobile, archived
horizontal: 'left',
}}
>
<MenuItemOpenInNewWindow channel={channel}/>
{isDirect && (
<ChannelDirectMenu
channel={channel}

View file

@ -0,0 +1,93 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {ChannelType} from '@mattermost/types/channels';
import {WithTestMenuContext} from 'components/menu/menu_context_test';
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
import {isChannelPopoutWindow, popoutChannel} from 'utils/popouts/popout_windows';
import {TestHelper} from 'utils/test_helper';
import MenuItemOpenInNewWindow from './open_in_new_window';
jest.mock('components/channel_popout/channel_popout', () => ({
getPopoutChannelTitle: jest.fn(() => ({id: 'test.title', defaultMessage: 'Test Title'})),
}));
jest.mock('utils/popouts/popout_windows', () => ({
isChannelPopoutWindow: jest.fn(() => false),
popoutChannel: jest.fn(),
canPopout: jest.fn(() => true),
}));
describe('MenuItemOpenInNewWindow', () => {
const currentUser = TestHelper.getUserMock({id: 'current_user_id', username: 'currentuser'});
const team = TestHelper.getTeamMock({id: 'team_id', name: 'test-team'});
const baseState = {
entities: {
channels: {
currentChannelId: 'channel_id',
channels: {},
channelsInTeam: {},
myMembers: {},
},
teams: {
currentTeamId: team.id,
teams: {[team.id]: team},
myMembers: {},
},
users: {
currentUserId: currentUser.id,
profiles: {
[currentUser.id]: currentUser,
},
},
general: {config: {}},
preferences: {myPreferences: {}},
roles: {roles: {}},
},
};
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(isChannelPopoutWindow).mockReturnValue(false);
});
test('should render nothing when already in a channel popout', () => {
jest.mocked(isChannelPopoutWindow).mockReturnValue(true);
const channel = TestHelper.getChannelMock({type: 'O' as ChannelType, name: 'town-square'});
const {container} = renderWithContext(
<WithTestMenuContext>
<MenuItemOpenInNewWindow channel={channel}/>
</WithTestMenuContext>,
baseState,
);
expect(container).toBeEmptyDOMElement();
});
test('should call popoutChannel when clicked', async () => {
const channel = TestHelper.getChannelMock({type: 'O' as ChannelType, name: 'town-square'});
renderWithContext(
<WithTestMenuContext>
<MenuItemOpenInNewWindow channel={channel}/>
</WithTestMenuContext>,
baseState,
);
await userEvent.click(screen.getByText('Open in new window'));
expect(jest.mocked(popoutChannel)).toHaveBeenCalledWith(
expect.any(String),
'test-team',
'channels',
'town-square',
);
});
});

View file

@ -0,0 +1,64 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import type {Channel} from '@mattermost/types/channels';
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUserId, getUser} from 'mattermost-redux/selectors/entities/users';
import {getUserIdFromChannelName} from 'mattermost-redux/utils/channel_utils';
import {getPopoutChannelTitle} from 'components/channel_popout/channel_popout';
import * as Menu from 'components/menu';
import PopoutMenuItem from 'components/popout_menu_item';
import {getChannelRoutePathAndIdentifier} from 'utils/channel_utils';
import {Constants} from 'utils/constants';
import {isChannelPopoutWindow, popoutChannel} from 'utils/popouts/popout_windows';
import type {GlobalState} from 'types/store';
interface Props {
channel: Channel;
}
const MenuItemOpenInNewWindow = ({channel}: Props) => {
const intl = useIntl();
const team = useSelector(getCurrentTeam);
const currentUserId = useSelector(getCurrentUserId);
const dmUser = useSelector((state: GlobalState) => {
if (channel.type === Constants.DM_CHANNEL) {
const dmUserId = getUserIdFromChannelName(currentUserId, channel.name);
return getUser(state, dmUserId);
}
return undefined;
});
if (isChannelPopoutWindow()) {
return null;
}
const handleClick = () => {
if (!team) {
return;
}
const {path, identifier} = getChannelRoutePathAndIdentifier(channel, dmUser?.username);
popoutChannel(intl.formatMessage(getPopoutChannelTitle(channel.type)), team.name, path, identifier);
};
return (
<>
<PopoutMenuItem
id='channelOpenInNewWindow'
onClick={handleClick}
/>
<Menu.Separator/>
</>
);
};
export default MenuItemOpenInNewWindow;

View file

@ -0,0 +1,63 @@
body.app__body.popout #root .main-wrapper.channel-popout {
border: none;
margin: 0;
background-color: var(--center-channel-bg);
grid-template: "center rhs";
grid-template-columns: 1fr min-content;
#channel_view {
overflow: hidden;
grid-area: center;
}
.sidebar--right--width-holder {
grid-area: rhs;
}
#sidebar-right {
top: 0;
right: 0;
}
.post-right__container .PopoutButton {
display: initial;
}
@media screen and (max-width: 768px) {
grid-template: "main";
grid-template-columns: 1fr;
#channel_view,
#sidebar-right {
grid-area: main;
}
.sidebar--right--width-holder {
display: none;
}
&:not(.rhs-open) #channel-header {
display: flex;
.flex-parent {
width: 100%;
}
}
.channel-header .channel-header__icon {
display: flex;
}
.channel__wrap .app__content {
padding-top: 0;
}
.search-bar__container {
display: none !important;
}
.sidebar--right__close {
display: block;
}
}
}

View file

@ -0,0 +1,191 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {MemoryRouter, Route} from 'react-router-dom';
import {fetchMyCategories} from 'mattermost-redux/actions/channel_categories';
import {fetchChannelsAndMembers, getChannelStats} from 'mattermost-redux/actions/channels';
import {fetchTeamScheduledPosts} from 'mattermost-redux/actions/scheduled_posts';
import {selectTeam} from 'mattermost-redux/actions/teams';
import {useTeamByName} from 'components/common/hooks/use_team';
import {renderWithContext, screen, waitFor} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import {getPopoutChannelTitle} from './channel_popout';
import ChannelPopout from './index';
const MOCK_ACTION = {type: 'MOCK'};
jest.mock('mattermost-redux/actions/channel_categories', () => ({
fetchMyCategories: jest.fn(() => MOCK_ACTION),
}));
jest.mock('mattermost-redux/actions/channels', () => ({
...jest.requireActual('mattermost-redux/actions/channels'),
fetchChannelsAndMembers: jest.fn(() => MOCK_ACTION),
getChannelStats: jest.fn(() => MOCK_ACTION),
}));
jest.mock('mattermost-redux/actions/scheduled_posts', () => ({
fetchTeamScheduledPosts: jest.fn(() => MOCK_ACTION),
}));
jest.mock('mattermost-redux/actions/teams', () => ({
selectTeam: jest.fn(() => MOCK_ACTION),
}));
jest.mock('components/common/hooks/use_team', () => ({
useTeamByName: jest.fn(),
}));
jest.mock('utils/popouts/use_popout_title', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('components/channel_layout/channel_identifier_router', () => ({
__esModule: true,
default: () => <div data-testid='channel-identifier-router'>{'ChannelIdentifierRouter'}</div>,
}));
jest.mock('components/sidebar_right', () => ({
__esModule: true,
default: () => <div data-testid='sidebar-right'>{'SidebarRight'}</div>,
}));
jest.mock('components/unreads_status_handler', () => ({
__esModule: true,
default: () => <div data-testid='unreads-status-handler'>{'UnreadsStatusHandler'}</div>,
}));
jest.mock('components/loading_screen', () => ({
__esModule: true,
default: () => <div data-testid='loading-screen'>{'LoadingScreen'}</div>,
}));
describe('ChannelPopout', () => {
const team = TestHelper.getTeamMock({id: 'team_id', name: 'test-team'});
const channel = TestHelper.getChannelMock({id: 'channel_id', name: 'town-square', type: 'O'});
const baseState = {
entities: {
channels: {
currentChannelId: channel.id,
channels: {[channel.id]: channel},
channelsInTeam: {},
myMembers: {},
},
teams: {
currentTeamId: team.id,
teams: {[team.id]: team},
myMembers: {},
},
users: {currentUserId: 'user_id', profiles: {}},
general: {config: {}},
preferences: {myPreferences: {}},
roles: {roles: {}},
},
views: {
rhs: {isSidebarOpen: false},
},
};
function renderPopout(url: string) {
return renderWithContext(
<MemoryRouter initialEntries={[url]}>
<Route path='/_popout/channel/:team/:path(channels|messages)/:identifier/:postid?'>
<ChannelPopout/>
</Route>
</MemoryRouter>,
baseState,
);
}
beforeEach(() => {
jest.clearAllMocks();
});
test('should show loading screen when team is not found', () => {
jest.mocked(useTeamByName).mockReturnValue(undefined);
renderPopout('/_popout/channel/test-team/channels/town-square');
expect(screen.getByTestId('loading-screen')).toBeInTheDocument();
});
test('should render ChannelIdentifierRouter and SidebarRight when team is resolved', () => {
jest.mocked(useTeamByName).mockReturnValue(team);
renderPopout('/_popout/channel/test-team/channels/town-square');
expect(screen.getByTestId('channel-identifier-router')).toBeInTheDocument();
expect(screen.getByTestId('sidebar-right')).toBeInTheDocument();
});
test('should dispatch team bootstrapping actions when team is resolved', async () => {
jest.mocked(useTeamByName).mockReturnValue(team);
renderPopout('/_popout/channel/test-team/channels/town-square');
await waitFor(() => {
expect(jest.mocked(selectTeam)).toHaveBeenCalledWith('team_id');
expect(jest.mocked(fetchChannelsAndMembers)).toHaveBeenCalledWith('team_id');
expect(jest.mocked(fetchMyCategories)).toHaveBeenCalledWith('team_id');
expect(jest.mocked(fetchTeamScheduledPosts)).toHaveBeenCalledWith('team_id', true);
});
});
test('should dispatch getChannelStats when channel is available', async () => {
jest.mocked(useTeamByName).mockReturnValue(team);
renderPopout('/_popout/channel/test-team/channels/town-square');
await waitFor(() => {
expect(jest.mocked(getChannelStats)).toHaveBeenCalledWith('channel_id');
});
});
test('should apply rhs-open class when RHS is open', () => {
jest.mocked(useTeamByName).mockReturnValue(team);
const stateWithRhsOpen = {
...baseState,
views: {rhs: {isSidebarOpen: true}},
};
renderWithContext(
<MemoryRouter initialEntries={['/_popout/channel/test-team/channels/town-square']}>
<Route path='/_popout/channel/:team/:path(channels|messages)/:identifier/:postid?'>
<ChannelPopout/>
</Route>
</MemoryRouter>,
stateWithRhsOpen,
);
const mainWrapper = screen.getByTestId('channel-identifier-router').closest('.main-wrapper');
expect(mainWrapper).toHaveClass('rhs-open');
});
});
describe('getPopoutChannelTitle', () => {
test('should return DM title format for DM channels', () => {
const result = getPopoutChannelTitle('D');
expect(result).toEqual({
id: 'channel_popout.title.dm',
defaultMessage: '{channelName} - {serverName}',
});
});
test('should return DM title format for GM channels', () => {
const result = getPopoutChannelTitle('G');
expect(result).toEqual({
id: 'channel_popout.title.dm',
defaultMessage: '{channelName} - {serverName}',
});
});
test('should return standard title format for regular channels', () => {
const result = getPopoutChannelTitle('O');
expect(result).toEqual({
id: 'channel_popout.title',
defaultMessage: '{channelName} - {teamName} - {serverName}',
});
});
test('should return standard title format when type is undefined', () => {
const result = getPopoutChannelTitle(undefined);
expect(result).toEqual({
id: 'channel_popout.title',
defaultMessage: '{channelName} - {teamName} - {serverName}',
});
});
});

View file

@ -0,0 +1,91 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {useEffect} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {useParams} from 'react-router-dom';
import type {ChannelType} from '@mattermost/types/channels';
import {fetchMyCategories} from 'mattermost-redux/actions/channel_categories';
import {fetchChannelsAndMembers, getChannelStats} from 'mattermost-redux/actions/channels';
import {fetchTeamScheduledPosts} from 'mattermost-redux/actions/scheduled_posts';
import {selectTeam} from 'mattermost-redux/actions/teams';
import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
import {getIsRhsOpen} from 'selectors/rhs';
import ChannelIdentifierRouter from 'components/channel_layout/channel_identifier_router';
import {useTeamByName} from 'components/common/hooks/use_team';
import LoadingScreen from 'components/loading_screen';
import SidebarRight from 'components/sidebar_right';
import UnreadsStatusHandler from 'components/unreads_status_handler';
import Constants from 'utils/constants';
import usePopoutTitle from 'utils/popouts/use_popout_title';
import {isDesktopApp} from 'utils/user_agent';
import './channel_popout.scss';
export function getPopoutChannelTitle(channelType?: ChannelType) {
if (channelType === Constants.DM_CHANNEL || channelType === Constants.GM_CHANNEL) {
return {id: 'channel_popout.title.dm', defaultMessage: '{channelName} - {serverName}'};
}
return {id: 'channel_popout.title', defaultMessage: '{channelName} - {teamName} - {serverName}'};
}
export default function ChannelPopout() {
const dispatch = useDispatch();
const {team: teamName, postid} = useParams<{team: string; path: string; identifier: string; postid?: string}>();
const team = useTeamByName(teamName);
const teamId = team?.id;
const channel = useSelector(getCurrentChannel);
const channelId = channel?.id;
const rhsOpen = useSelector(getIsRhsOpen);
usePopoutTitle(getPopoutChannelTitle(channel?.type));
useEffect(() => {
if (teamId) {
dispatch(selectTeam(teamId));
dispatch(fetchChannelsAndMembers(teamId));
dispatch(fetchMyCategories(teamId));
dispatch(fetchTeamScheduledPosts(teamId, true));
}
}, [dispatch, teamId]);
useEffect(() => {
if (channelId) {
dispatch(getChannelStats(channelId));
}
}, [dispatch, channelId]);
if (!team) {
return <LoadingScreen/>;
}
return (
<>
{isDesktopApp() && <UnreadsStatusHandler/>}
<div className={classNames('main-wrapper', 'channel-popout', {'rhs-open': rhsOpen})}>
<div
id='channel_view'
className='channel-view'
>
<div className='container-fluid channel-view-inner'>
<div className='inner-wrap channel__wrap'>
<div className='row main'>
<ChannelIdentifierRouter key={postid || 'channel'}/>
</div>
</div>
</div>
</div>
<SidebarRight/>
</div>
</>
);
}

View file

@ -0,0 +1,4 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {default} from './channel_popout';

View file

@ -11,6 +11,7 @@ import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {loadStatusesByIds} from 'actions/status_actions';
import ChannelPopout from 'components/channel_popout';
import HelpPopout from 'components/help_popout';
import LoggedIn from 'components/logged_in';
import ModalController from 'components/modal_controller';
@ -19,7 +20,7 @@ import {useUserTheme} from 'components/theme_provider';
import ThreadPopout from 'components/thread_popout';
import Pluggable from 'plugins/pluggable';
import {TEAM_NAME_PATH_PATTERN, ID_PATH_PATTERN} from 'utils/path';
import {TEAM_NAME_PATH_PATTERN, ID_PATH_PATTERN, IDENTIFIER_PATH_PATTERN} from 'utils/path';
import {useBrowserPopout} from 'utils/popouts/use_browser_popout';
import './popout_controller.scss';
@ -34,6 +35,10 @@ const PopoutController: React.FC<RouteComponentProps> = (routeProps) => {
useEffect(() => {
document.body.classList.add('app__body', 'popout');
dispatch(getMe());
return () => {
document.body.classList.remove('app__body', 'popout');
};
}, []);
useEffect(() => {
@ -51,6 +56,10 @@ const PopoutController: React.FC<RouteComponentProps> = (routeProps) => {
path={`/_popout/thread/:team(${TEAM_NAME_PATH_PATTERN})/:postId(${ID_PATH_PATTERN})`}
component={ThreadPopout}
/>
<Route
path={`/_popout/channel/:team(${TEAM_NAME_PATH_PATTERN})/:path(channels|messages)/:identifier(${IDENTIFIER_PATH_PATTERN})/:postid(${ID_PATH_PATTERN})?`}
component={ChannelPopout}
/>
<Route
path={`/_popout/rhs/:team(${TEAM_NAME_PATH_PATTERN})`}
component={RhsPopout}

View file

@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {DockWindowIcon} from '@mattermost/compass-icons/components';
import * as Menu from 'components/menu';
import {canPopout} from 'utils/popouts/popout_windows';
type Props = {
onClick: () => void;
id?: string;
};
export default function PopoutMenuItem({onClick, id = 'openInNewWindow'}: Props) {
if (!canPopout()) {
return null;
}
return (
<Menu.Item
id={id}
leadingElement={<DockWindowIcon size={18}/>}
labels={
<FormattedMessage
id='popout_menu_item.openInNewWindow'
defaultMessage='Open in new window'
/>
}
onClick={onClick}
/>
);
}

View file

@ -0,0 +1,95 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {loadPostsAround} from 'actions/views/channel';
import testConfigureStore from 'tests/test_store';
import {getHistory} from 'utils/browser_history';
import {ActionTypes} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import {highlightPostInChannelPopout} from './actions';
jest.mock('actions/views/channel', () => ({
loadPostsAround: jest.fn(() => ({type: 'MOCK_LOAD_POSTS_AROUND'})),
}));
jest.mock('utils/browser_history', () => ({
getHistory: jest.fn(),
}));
describe('highlightPostInChannelPopout', () => {
const team = TestHelper.getTeamMock({id: 'team_id', name: 'test-team'});
const currentUser = TestHelper.getUserMock({id: 'current_user_id', username: 'currentuser'});
const channel = TestHelper.getChannelMock({id: 'channel_id', name: 'town-square', type: 'O'});
const baseState = {
entities: {
channels: {
currentChannelId: channel.id,
channels: {[channel.id]: channel},
channelsInTeam: {[team.id]: new Set([channel.id])},
myMembers: {[channel.id]: {}},
},
teams: {
currentTeamId: team.id,
teams: {[team.id]: team},
myMembers: {[team.id]: {}},
},
users: {
currentUserId: currentUser.id,
profiles: {[currentUser.id]: currentUser},
statuses: {},
profilesInChannel: {},
},
general: {config: {}},
preferences: {myPreferences: {}},
roles: {roles: {}},
},
};
let mockReplace: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
mockReplace = jest.fn();
jest.mocked(getHistory).mockReturnValue({replace: mockReplace} as any);
});
test('should return false if team or channel is not available', async () => {
const store = testConfigureStore({
entities: {
channels: {currentChannelId: '', channels: {}, channelsInTeam: {}, myMembers: {}},
teams: {currentTeamId: '', teams: {}, myMembers: {}},
users: {currentUserId: '', profiles: {}},
general: {config: {}},
preferences: {myPreferences: {}},
roles: {roles: {}},
},
});
const result = await store.dispatch(highlightPostInChannelPopout('post_id'));
expect(result).toEqual({data: false});
expect(jest.mocked(loadPostsAround)).not.toHaveBeenCalled();
});
test('should load posts around the target post', async () => {
const store = testConfigureStore(baseState);
await store.dispatch(highlightPostInChannelPopout('post_123'));
expect(jest.mocked(loadPostsAround)).toHaveBeenCalledWith('channel_id', 'post_123');
});
test('should dispatch RECEIVED_FOCUSED_POST and navigate to popout URL', async () => {
const store = testConfigureStore(baseState);
await store.dispatch(highlightPostInChannelPopout('post_123'));
const actions = store.getActions();
expect(actions).toContainEqual({
type: ActionTypes.RECEIVED_FOCUSED_POST,
data: 'post_123',
channelId: 'channel_id',
});
expect(mockReplace).toHaveBeenCalledWith('/_popout/channel/test-team/channels/town-square/post_123');
});
});

View file

@ -3,15 +3,22 @@
import {removePost} from 'mattermost-redux/actions/posts';
import type {ExtendedPost} from 'mattermost-redux/actions/posts';
import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUserId, getUser} from 'mattermost-redux/selectors/entities/users';
import {getUserIdFromChannelName} from 'mattermost-redux/utils/channel_utils';
import {loadPostsAround} from 'actions/views/channel';
import {removeDraft} from 'actions/views/drafts';
import {closeRightHandSide} from 'actions/views/rhs';
import {getGlobalItem} from 'selectors/storage';
import {isThreadOpen} from 'selectors/views/threads';
import {StoragePrefixes} from 'utils/constants';
import {getHistory} from 'utils/browser_history';
import {getChannelRoutePathAndIdentifier} from 'utils/channel_utils';
import {ActionTypes, StoragePrefixes} from 'utils/constants';
import type {ActionFunc} from 'types/store';
import type {ActionFunc, ActionFuncAsync} from 'types/store';
/**
* This action is called when the deleted post which is shown as 'deleted' in the RHS is then removed from the channel manually.
@ -31,3 +38,35 @@ export function removePostCloseRHSDeleteDraft(post: ExtendedPost): ActionFunc<bo
return dispatch(removePost(post));
};
}
export function highlightPostInChannelPopout(postId: string): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
const team = getCurrentTeam(state);
const channel = getCurrentChannel(state);
if (!team || !channel) {
return {data: false};
}
const result = await dispatch(loadPostsAround(channel.id, postId));
if ('error' in result) {
return {data: false, error: result.error};
}
dispatch({
type: ActionTypes.RECEIVED_FOCUSED_POST,
data: postId,
channelId: channel.id,
});
const currentUserId = getCurrentUserId(state);
const dmUserId = getUserIdFromChannelName(currentUserId, channel.name);
const dmUser = getUser(state, dmUserId);
const {path, identifier} = getChannelRoutePathAndIdentifier(channel, dmUser?.username);
getHistory().replace(`/_popout/channel/${team.name}/${path}/${identifier}/${postId}`);
return {data: true};
};
}

View file

@ -44,7 +44,7 @@ import {getDisplayNameByUser} from 'utils/utils';
import type {GlobalState} from 'types/store';
import {removePostCloseRHSDeleteDraft} from './actions';
import {highlightPostInChannelPopout, removePostCloseRHSDeleteDraft} from './actions';
import PostComponent from './post_component';
type OwnProps = {
@ -254,6 +254,7 @@ function mapDispatchToProps(dispatch: Dispatch) {
actions: bindActionCreators({
markPostAsUnread,
emitShortcutReactToLastPostFrom,
highlightPostInChannelPopout,
selectPost,
selectPostFromRightHandSideSearch,
setRhsExpanded,

View file

@ -56,6 +56,7 @@ describe('PostComponent', () => {
savePreferences: jest.fn(),
openModal: jest.fn(),
closeModal: jest.fn(),
highlightPostInChannelPopout: jest.fn(),
},
isChannelAutotranslated: false,
};

View file

@ -47,7 +47,7 @@ import {getArchiveIconComponent} from 'utils/channel_utils';
import Constants, {A11yCustomEventTypes, AppEvents, Locations, PostTypes, ModalIdentifiers} from 'utils/constants';
import type {A11yFocusEventDetail} from 'utils/constants';
import {isKeyPressed} from 'utils/keyboard';
import {isPopoutWindow} from 'utils/popouts/popout_windows';
import {isChannelPopoutWindow, isPopoutWindow} from 'utils/popouts/popout_windows';
import * as PostUtils from 'utils/post_utils';
import {makeIsEligibleForClick} from 'utils/utils';
@ -110,6 +110,7 @@ export type Props = {
savePreferences: (userId: string, preferences: Array<{category: string; user_id: string; name: string; value: string}>) => void;
openModal: <P>(modalData: ModalData<P>) => void;
closeModal: (modalId: string) => void;
highlightPostInChannelPopout: (postId: string) => void;
};
timestampProps?: Partial<TimestampProps>;
shouldHighlight?: boolean;
@ -407,8 +408,14 @@ function PostComponent(props: Props) {
}
props.actions.setRhsExpanded(false);
if (isChannelPopoutWindow() && props.isPinnedPosts) {
props.actions.highlightPostInChannelPopout(post.id);
return;
}
getHistory().push(`/${props.teamName}/pl/${post.id}`);
}, [props.isMobileView, props.actions, props.teamName, post?.id]);
}, [props.isMobileView, props.actions, props.teamName, props.isPinnedPosts, post]);
const {selectPostFromRightHandSideSearch} = props.actions;

View file

@ -20,6 +20,7 @@ import ToastWrapper from 'components/toast_wrapper';
import Pluggable from 'plugins/pluggable';
import Constants, {PostListRowListIds, EventTypes, PostRequestTypes} from 'utils/constants';
import DelayedAction from 'utils/delayed_action';
import {isChannelPopoutWindow} from 'utils/popouts/popout_windows';
import {getPreviousPostId, getLatestPostId} from 'utils/post_utils';
import * as Utils from 'utils/utils';
@ -473,7 +474,7 @@ export default class PostList extends React.PureComponent<Props, State> {
});
}
if (!this.props.isMobileView && !this.state.isSearchHintDismissed) {
if (!this.props.isMobileView && !this.state.isSearchHintDismissed && !isChannelPopoutWindow()) {
this.setState({
showSearchHint: offsetFromBottom > this.showSearchHintThreshold,
});

View file

@ -13,6 +13,7 @@ import SearchResultsHeader from './search_results_header';
jest.mock('utils/popouts/popout_windows', () => ({
isPopoutWindow: jest.fn(),
isChannelPopoutWindow: jest.fn(() => false),
}));
jest.mock('components/popout_button', () => ({

View file

@ -9,7 +9,7 @@ import PopoutButton from 'components/popout_button';
import WithTooltip from 'components/with_tooltip';
import {RHSStates} from 'utils/constants';
import {isPopoutWindow} from 'utils/popouts/popout_windows';
import {isChannelPopoutWindow, isPopoutWindow} from 'utils/popouts/popout_windows';
import type {PropsFromRedux} from './index';
@ -95,7 +95,7 @@ function SearchResultsHeader(props: Props) {
{props.newWindowHandler && (
<PopoutButton onClick={props.newWindowHandler}/>
)}
{!isPopoutWindow() &&
{(!isPopoutWindow() || isChannelPopoutWindow()) &&
<WithTooltip
title={
<FormattedMessage

View file

@ -108,10 +108,42 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_menu should match sn
tabindex="-1"
>
<li
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters Mui-focusVisible MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
id="channelOpenInNewWindow"
role="menuitem"
tabindex="-1"
>
<div
class="leading-element"
>
<svg
fill="currentColor"
height="18"
version="1.1"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 18V20H4A2 2 0 0 1 2 18V8H4V18M22 6V14A2 2 0 0 1 20 16H8A2 2 0 0 1 6 14V6A2 2 0 0 1 8 4H20A2 2 0 0 1 22 6M20 6H8V14H20Z"
/>
</svg>
</div>
<div
class="label-elements"
>
Open in new window
</div>
</li>
<hr
aria-orientation="vertical"
class="MuiDivider-root-ermPAM jKcMpH MuiDivider-root MuiDivider-fullWidth"
/>
<li
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
id="markAsUnread-channel_id"
role="menuitem"
tabindex="0"
tabindex="-1"
>
<div
class="leading-element"
@ -417,10 +449,42 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_menu should match sn
tabindex="-1"
>
<li
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters Mui-focusVisible MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
id="channelOpenInNewWindow"
role="menuitem"
tabindex="-1"
>
<div
class="leading-element"
>
<svg
fill="currentColor"
height="18"
version="1.1"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 18V20H4A2 2 0 0 1 2 18V8H4V18M22 6V14A2 2 0 0 1 20 16H8A2 2 0 0 1 6 14V6A2 2 0 0 1 8 4H20A2 2 0 0 1 22 6M20 6H8V14H20Z"
/>
</svg>
</div>
<div
class="label-elements"
>
Open in new window
</div>
</li>
<hr
aria-orientation="vertical"
class="MuiDivider-root-ermPAM jKcMpH MuiDivider-root MuiDivider-fullWidth"
/>
<li
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
id="markAsUnread-channel_id"
role="menuitem"
tabindex="0"
tabindex="-1"
>
<div
class="leading-element"
@ -726,10 +790,42 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_menu should match sn
tabindex="-1"
>
<li
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters Mui-focusVisible MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
id="channelOpenInNewWindow"
role="menuitem"
tabindex="-1"
>
<div
class="leading-element"
>
<svg
fill="currentColor"
height="18"
version="1.1"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 18V20H4A2 2 0 0 1 2 18V8H4V18M22 6V14A2 2 0 0 1 20 16H8A2 2 0 0 1 6 14V6A2 2 0 0 1 8 4H20A2 2 0 0 1 22 6M20 6H8V14H20Z"
/>
</svg>
</div>
<div
class="label-elements"
>
Open in new window
</div>
</li>
<hr
aria-orientation="vertical"
class="MuiDivider-root-ermPAM jKcMpH MuiDivider-root MuiDivider-fullWidth"
/>
<li
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
id="markAsUnread-channel_id"
role="menuitem"
tabindex="0"
tabindex="-1"
>
<div
class="leading-element"
@ -1035,10 +1131,42 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_menu should show cor
tabindex="-1"
>
<li
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters Mui-focusVisible MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
id="channelOpenInNewWindow"
role="menuitem"
tabindex="-1"
>
<div
class="leading-element"
>
<svg
fill="currentColor"
height="18"
version="1.1"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 18V20H4A2 2 0 0 1 2 18V8H4V18M22 6V14A2 2 0 0 1 20 16H8A2 2 0 0 1 6 14V6A2 2 0 0 1 8 4H20A2 2 0 0 1 22 6M20 6H8V14H20Z"
/>
</svg>
</div>
<div
class="label-elements"
>
Open in new window
</div>
</li>
<hr
aria-orientation="vertical"
class="MuiDivider-root-ermPAM jKcMpH MuiDivider-root MuiDivider-fullWidth"
/>
<li
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
id="markAsUnread-channel_id"
role="menuitem"
tabindex="0"
tabindex="-1"
>
<div
class="leading-element"
@ -1276,10 +1404,42 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_menu should show cor
tabindex="-1"
>
<li
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters Mui-focusVisible MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
id="channelOpenInNewWindow"
role="menuitem"
tabindex="-1"
>
<div
class="leading-element"
>
<svg
fill="currentColor"
height="18"
version="1.1"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 18V20H4A2 2 0 0 1 2 18V8H4V18M22 6V14A2 2 0 0 1 20 16H8A2 2 0 0 1 6 14V6A2 2 0 0 1 8 4H20A2 2 0 0 1 22 6M20 6H8V14H20Z"
/>
</svg>
</div>
<div
class="label-elements"
>
Open in new window
</div>
</li>
<hr
aria-orientation="vertical"
class="MuiDivider-root-ermPAM jKcMpH MuiDivider-root MuiDivider-fullWidth"
/>
<li
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
id="markAsUnread-channel_id"
role="menuitem"
tabindex="0"
tabindex="-1"
>
<div
class="leading-element"
@ -1553,10 +1713,42 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_menu should show cor
tabindex="-1"
>
<li
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters Mui-focusVisible MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
id="channelOpenInNewWindow"
role="menuitem"
tabindex="-1"
>
<div
class="leading-element"
>
<svg
fill="currentColor"
height="18"
version="1.1"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 18V20H4A2 2 0 0 1 2 18V8H4V18M22 6V14A2 2 0 0 1 20 16H8A2 2 0 0 1 6 14V6A2 2 0 0 1 8 4H20A2 2 0 0 1 22 6M20 6H8V14H20Z"
/>
</svg>
</div>
<div
class="label-elements"
>
Open in new window
</div>
</li>
<hr
aria-orientation="vertical"
class="MuiDivider-root-ermPAM jKcMpH MuiDivider-root MuiDivider-fullWidth"
/>
<li
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
id="markAsUnread-channel_id"
role="menuitem"
tabindex="0"
tabindex="-1"
>
<div
class="leading-element"
@ -1859,10 +2051,42 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_menu should show cor
tabindex="-1"
>
<li
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters Mui-focusVisible MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
id="channelOpenInNewWindow"
role="menuitem"
tabindex="-1"
>
<div
class="leading-element"
>
<svg
fill="currentColor"
height="18"
version="1.1"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 18V20H4A2 2 0 0 1 2 18V8H4V18M22 6V14A2 2 0 0 1 20 16H8A2 2 0 0 1 6 14V6A2 2 0 0 1 8 4H20A2 2 0 0 1 22 6M20 6H8V14H20Z"
/>
</svg>
</div>
<div
class="label-elements"
>
Open in new window
</div>
</li>
<hr
aria-orientation="vertical"
class="MuiDivider-root-ermPAM jKcMpH MuiDivider-root MuiDivider-fullWidth"
/>
<li
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
id="markAsUnread-channel_id"
role="menuitem"
tabindex="0"
tabindex="-1"
>
<div
class="leading-element"
@ -2168,10 +2392,42 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_menu should show cor
tabindex="-1"
>
<li
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters Mui-focusVisible MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
id="channelOpenInNewWindow"
role="menuitem"
tabindex="-1"
>
<div
class="leading-element"
>
<svg
fill="currentColor"
height="18"
version="1.1"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 18V20H4A2 2 0 0 1 2 18V8H4V18M22 6V14A2 2 0 0 1 20 16H8A2 2 0 0 1 6 14V6A2 2 0 0 1 8 4H20A2 2 0 0 1 22 6M20 6H8V14H20Z"
/>
</svg>
</div>
<div
class="label-elements"
>
Open in new window
</div>
</li>
<hr
aria-orientation="vertical"
class="MuiDivider-root-ermPAM jKcMpH MuiDivider-root MuiDivider-fullWidth"
/>
<li
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
id="markAsUnread-channel_id"
role="menuitem"
tabindex="0"
tabindex="-1"
>
<div
class="leading-element"
@ -2477,10 +2733,42 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_menu should show cor
tabindex="-1"
>
<li
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters Mui-focusVisible MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
id="channelOpenInNewWindow"
role="menuitem"
tabindex="-1"
>
<div
class="leading-element"
>
<svg
fill="currentColor"
height="18"
version="1.1"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 18V20H4A2 2 0 0 1 2 18V8H4V18M22 6V14A2 2 0 0 1 20 16H8A2 2 0 0 1 6 14V6A2 2 0 0 1 8 4H20A2 2 0 0 1 22 6M20 6H8V14H20Z"
/>
</svg>
</div>
<div
class="label-elements"
>
Open in new window
</div>
</li>
<hr
aria-orientation="vertical"
class="MuiDivider-root-ermPAM jKcMpH MuiDivider-root MuiDivider-fullWidth"
/>
<li
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
id="markAsUnread-channel_id"
role="menuitem"
tabindex="0"
tabindex="-1"
>
<div
class="leading-element"
@ -2786,10 +3074,42 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_menu should show cor
tabindex="-1"
>
<li
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters Mui-focusVisible MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
id="channelOpenInNewWindow"
role="menuitem"
tabindex="-1"
>
<div
class="leading-element"
>
<svg
fill="currentColor"
height="18"
version="1.1"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 18V20H4A2 2 0 0 1 2 18V8H4V18M22 6V14A2 2 0 0 1 20 16H8A2 2 0 0 1 6 14V6A2 2 0 0 1 8 4H20A2 2 0 0 1 22 6M20 6H8V14H20Z"
/>
</svg>
</div>
<div
class="label-elements"
>
Open in new window
</div>
</li>
<hr
aria-orientation="vertical"
class="MuiDivider-root-ermPAM jKcMpH MuiDivider-root MuiDivider-fullWidth"
/>
<li
class="MuiButtonBase-root-JDVeC cxEgXn MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root-dXjcMb iLWjgG MuiMenuItem-root MuiMenuItem-gutters sc-grYavY jmUrfe"
id="markAsRead-channel_id"
role="menuitem"
tabindex="0"
tabindex="-1"
>
<div
class="leading-element"

View file

@ -16,6 +16,7 @@ import {
ExitToAppIcon,
} from '@mattermost/compass-icons/components';
import MenuItemOpenInNewWindow from 'components/channel_header_menu/menu_items/open_in_new_window';
import ChannelInviteModal from 'components/channel_invite_modal';
import ChannelMoveToSubmenu from 'components/channel_move_to_sub_menu';
import * as Menu from 'components/menu';
@ -298,6 +299,7 @@ const SidebarChannelMenu = ({
onToggle: onMenuToggle,
}}
>
<MenuItemOpenInNewWindow channel={channel}/>
{markAsReadUnreadMenuItem}
{favoriteUnfavoriteMenuItem}
{muteUnmuteChannelMenuItem}

View file

@ -29,6 +29,7 @@ jest.mock('hooks/useReadout', () => ({
}));
jest.mock('utils/popouts/popout_windows', () => ({
canPopout: jest.fn(() => true),
isThreadPopoutWindow: jest.fn(() => false),
popoutThread: jest.fn(),
}));

View file

@ -20,10 +20,11 @@ import {manuallyMarkThreadAsUnread} from 'actions/views/threads';
import * as Menu from 'components/menu';
import {focusPost} from 'components/permalink_view/actions';
import PopoutMenuItem from 'components/popout_menu_item';
import {getThreadPopoutTitle} from 'components/thread_popout/thread_popout';
import {useReadout} from 'hooks/useReadout';
import {canPopout, popoutThread} from 'utils/popouts/popout_windows';
import {isThreadPopoutWindow, popoutThread} from 'utils/popouts/popout_windows';
import {getSiteURL} from 'utils/url';
import {copyToClipboard} from 'utils/utils';
@ -121,18 +122,7 @@ function ThreadMenu({
id: `${idPrefix}-dropdown-${threadId}`,
}}
>
{canPopout() && (
<Menu.Item
labels={
<FormattedMessage
id='threading.threadMenu.openInNewWindow'
defaultMessage='Open in new window'
/>
}
onClick={popout}
leadingElement={<i className='icon icon-dock-window'/>}
/>
)}
{!isThreadPopoutWindow(team, threadId) && <PopoutMenuItem onClick={popout}/>}
<Menu.Item
labels={isFollowing ? (
<>

View file

@ -19,6 +19,7 @@ import {getHistory} from 'utils/browser_history';
import Constants from 'utils/constants';
import {isToday} from 'utils/datetime';
import {isKeyPressed} from 'utils/keyboard';
import {isChannelPopoutWindow} from 'utils/popouts/popout_windows';
import {isIdNotPost} from 'utils/post_utils';
import './toast__wrapper.scss';
@ -29,7 +30,7 @@ const TOAST_REL_RANGES = [
RelativeRanges.TODAY_YESTERDAY,
];
export type Props = WrappedComponentProps & RouteComponentProps<{team: string}> & {
export type Props = WrappedComponentProps & RouteComponentProps<{team: string; path?: string; identifier?: string}> & {
channelMarkedAsUnread?: boolean;
postListIds: string[];
latestPostTimeStamp?: number;
@ -341,6 +342,11 @@ export class ToastWrapperClass extends React.PureComponent<Props, State> {
changeUrlToRemountChannelView = () => {
const {match} = this.props;
if (isChannelPopoutWindow() && match.params.path && match.params.identifier) {
getHistory().replace(`/_popout/channel/${match.params.team}/${match.params.path}/${match.params.identifier}`);
return;
}
// Inorder of mount the channel view we are redirecting to /team url to load the channel again
// Todo: Can be changed to dispatch if we put focussedPostId in redux state.
getHistory().replace(`/${match.params.team}`);

View file

@ -5539,6 +5539,7 @@
"pluggable.errorOccurred": "An error occurred in the {pluginId} plugin.",
"pluggable.errorRefresh": "Refresh?",
"pluginsMenu.more_actions": "More actions",
"popout_menu_item.openInNewWindow": "Open in new window",
"post_body.check_for_out_of_channel_groups_mentions.message": "did not get notified by this mention because they are not in the channel. They cannot be added to the channel because they are not a member of the linked groups. To add them to this channel, they must be added to the linked groups.",
"post_body.check_for_out_of_channel_mentions.link.and": " and ",
"post_body.check_for_out_of_channel_mentions.link.private": "add them to this private channel",
@ -6382,7 +6383,6 @@
"threading.threadMenu.markUnread": "Mark as unread",
"threading.threadMenu.openInChannel": "Open in channel",
"threading.threadMenu.openingChannel": "Opening channel",
"threading.threadMenu.openInNewWindow": "Open in new window",
"threading.threadMenu.save": "Save",
"threading.threadMenu.saved": "Saved",
"threading.threadMenu.unfollow": "Unfollow thread",

View file

@ -172,4 +172,48 @@ describe('Channel Utils', () => {
expect(icon).toBe(GlobeIcon);
});
});
describe('getChannelRoutePathAndIdentifier', () => {
test('should return channels path and channel name for open channels', () => {
const channel = {type: Constants.OPEN_CHANNEL, name: 'town-square'} as Channel;
const result = Utils.getChannelRoutePathAndIdentifier(channel);
expect(result).toEqual({path: 'channels', identifier: 'town-square'});
});
test('should return channels path and channel name for private channels', () => {
const channel = {type: Constants.PRIVATE_CHANNEL, name: 'secret-ops'} as Channel;
const result = Utils.getChannelRoutePathAndIdentifier(channel);
expect(result).toEqual({path: 'channels', identifier: 'secret-ops'});
});
test('should return messages path and @username for DM channels when dmUsername is provided', () => {
const channel = {type: Constants.DM_CHANNEL, name: 'user1__user2'} as Channel;
const result = Utils.getChannelRoutePathAndIdentifier(channel, 'johndoe');
expect(result).toEqual({path: 'messages', identifier: '@johndoe'});
});
test('should return messages path and channel name for DM channels when dmUsername is not provided', () => {
const channel = {type: Constants.DM_CHANNEL, name: 'user1__user2'} as Channel;
const result = Utils.getChannelRoutePathAndIdentifier(channel);
expect(result).toEqual({path: 'messages', identifier: 'user1__user2'});
});
test('should return messages path and group id for GM channels', () => {
const channel = {type: Constants.GM_CHANNEL, name: 'abcdef1234567890abcdef1234567890abcdefgh'} as Channel;
const result = Utils.getChannelRoutePathAndIdentifier(channel);
expect(result).toEqual({path: 'messages', identifier: 'abcdef1234567890abcdef1234567890abcdefgh'});
});
test('should ignore dmUsername for non-DM channels', () => {
const channel = {type: Constants.OPEN_CHANNEL, name: 'town-square'} as Channel;
const result = Utils.getChannelRoutePathAndIdentifier(channel, 'johndoe');
expect(result).toEqual({path: 'channels', identifier: 'town-square'});
});
test('should ignore dmUsername for GM channels', () => {
const channel = {type: Constants.GM_CHANNEL, name: 'groupid1234'} as Channel;
const result = Utils.getChannelRoutePathAndIdentifier(channel, 'johndoe');
expect(result).toEqual({path: 'messages', identifier: 'groupid1234'});
});
});
});

View file

@ -153,6 +153,19 @@ export function joinPrivateChannelPrompt(team: Team, channelDisplayName: string,
};
}
export function getChannelRoutePathAndIdentifier(channel: Pick<Channel, 'type' | 'name'>, dmUsername?: string): {path: string; identifier: string} {
if (channel.type === Constants.DM_CHANNEL) {
return {
path: 'messages',
identifier: dmUsername ? `@${dmUsername}` : channel.name,
};
}
if (channel.type === Constants.GM_CHANNEL) {
return {path: 'messages', identifier: channel.name};
}
return {path: 'channels', identifier: channel.name};
}
export function makeNewEmptyChannel(displayName: string, teamId: string): Channel {
return {
team_id: teamId,

View file

@ -5,7 +5,7 @@ import DesktopApp from 'utils/desktop_api';
import {getBasePath} from 'utils/url';
import {isDesktopApp} from 'utils/user_agent';
import {FOCUS_REPLY_POST, popoutRhsPlugin, popoutRhsSearch, popoutThread} from './popout_windows';
import {FOCUS_REPLY_POST, popoutChannel, popoutRhsPlugin, popoutRhsSearch, popoutThread} from './popout_windows';
jest.mock('utils/desktop_api', () => ({
__esModule: true,
@ -177,6 +177,49 @@ describe('popout_windows', () => {
});
});
describe('popoutChannel', () => {
it('should include the path segment in the URL for regular channels', async () => {
setupBrowser();
await popoutChannel('Title', 'test-team', 'channels', 'town-square');
expect(getMockSetupBrowserPopout()).toHaveBeenCalledWith(
'/_popout/channel/test-team/channels/town-square',
);
});
it('should include the messages path for DM/GM channels', async () => {
setupBrowser();
await popoutChannel('Title', 'test-team', 'messages', '@otheruser');
expect(getMockSetupBrowserPopout()).toHaveBeenCalledWith(
'/_popout/channel/test-team/messages/@otheruser',
);
});
it('should include subpath in popout URL when basename is set', async () => {
setupBrowser('/company/mattermost');
await popoutChannel('Title', 'test-team', 'channels', 'town-square');
expect(getMockSetupBrowserPopout()).toHaveBeenCalledWith(
'/company/mattermost/_popout/channel/test-team/channels/town-square',
);
});
it('should call desktop popout with correct path for desktop app', async () => {
setupDesktop();
await popoutChannel('Channel Title', 'test-team', 'channels', 'town-square');
expect(mockDesktopApp.setupDesktopPopout).toHaveBeenCalledWith(
'/_popout/channel/test-team/channels/town-square',
{titleTemplate: 'Channel Title'},
);
});
});
describe('popoutRhsSearch', () => {
async function callDesktop(...args: Parameters<typeof popoutRhsSearch>) {
setupDesktop();

View file

@ -113,6 +113,17 @@ export async function popoutRhsSearch(
},
);
}
export async function popoutChannel(
titleTemplate: string,
teamName: string,
path: string,
channelIdentifier: string,
) {
return popout(
`${getBasePath()}/_popout/channel/${teamName}/${path}/${channelIdentifier}`,
{titleTemplate},
);
}
export async function popoutHelp() {
return popout(
@ -171,6 +182,10 @@ export function isPopoutWindow() {
return window.location.href.startsWith(`${Client4.getUrl()}/_popout/`);
}
export function isChannelPopoutWindow() {
return window.location.href.startsWith(`${Client4.getUrl()}/_popout/channel/`);
}
export function isThreadPopoutWindow(teamName: string, threadId: string) {
return window.location.href.startsWith(`${Client4.getUrl()}/_popout/thread/${teamName}/${threadId}`);
}