diff --git a/e2e-tests/playwright/specs/functional/channels/search/search_popout.spec.ts b/e2e-tests/playwright/specs/functional/channels/search/search_popout.spec.ts index b15ca2edb2a..2c1bbc0f8bb 100644 --- a/e2e-tests/playwright/specs/functional/channels/search/search_popout.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/search/search_popout.spec.ts @@ -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()]); diff --git a/webapp/channels/src/actions/views/channel.ts b/webapp/channels/src/actions/views/channel.ts index ce7d15f6979..9dca2ca578e 100644 --- a/webapp/channels/src/actions/views/channel.ts +++ b/webapp/channels/src/actions/views/channel.ts @@ -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 { diff --git a/webapp/channels/src/actions/websocket_actions.ts b/webapp/channels/src/actions/websocket_actions.ts index 7c8cf538751..fababc8add2 100644 --- a/webapp/channels/src/actions/websocket_actions.ts +++ b/webapp/channels/src/actions/websocket_actions.ts @@ -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)) { diff --git a/webapp/channels/src/components/CLAUDE.OPTIONAL.md b/webapp/channels/src/components/CLAUDE.OPTIONAL.md index ce4d4b9f6a8..3c36a25863f 100644 --- a/webapp/channels/src/components/CLAUDE.OPTIONAL.md +++ b/webapp/channels/src/components/CLAUDE.OPTIONAL.md @@ -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., ``), not raw `` 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. 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 29a44af6191..ca3f09da592 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 @@ -279,6 +279,10 @@ exports[`components/ChannelHeader should match snapshot with last active display } /> + + + + + + + + + + + + + + + + { showChannelMembers: jest.fn(), fetchChannelRemotes: jest.fn(), }, - teamId: 'team_id', + team: TestHelper.getTeamMock({id: 'team_id'}), channel: TestHelper.getChannelMock({}), channelMember: TestHelper.getChannelMembershipMock({}), currentUser: TestHelper.getUserMock({}), diff --git a/webapp/channels/src/components/channel_header/channel_header.tsx b/webapp/channels/src/components/channel_header/channel_header.tsx index e160d10c630..eeb449ef48f 100644 --- a/webapp/channels/src/components/channel_header/channel_header.tsx +++ b/webapp/channels/src/components/channel_header/channel_header.tsx @@ -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 { } }; + 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 { render() { const { - teamId, + team, currentUser, gmMembers, channel, @@ -410,7 +422,7 @@ class ChannelHeader extends React.PureComponent { {hasGuestsText} {autotranslationMessage} @@ -427,6 +439,12 @@ class ChannelHeader extends React.PureComponent { )} + {canPopout() && !isChannelPopoutWindow() && ( + + )} diff --git a/webapp/channels/src/components/channel_header/channel_header_text.tsx b/webapp/channels/src/components/channel_header/channel_header_text.tsx index f19e668cd39..ccec52ba4f8 100644 --- a/webapp/channels/src/components/channel_header/channel_header_text.tsx +++ b/webapp/channels/src/components/channel_header/channel_header_text.tsx @@ -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; } diff --git a/webapp/channels/src/components/channel_header/index.ts b/webapp/channels/src/components/channel_header/index.ts index 1d914bec54c..0de6ec6871e 100644 --- a/webapp/channels/src/components/channel_header/index.ts +++ b/webapp/channels/src/components/channel_header/index.ts @@ -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, diff --git a/webapp/channels/src/components/channel_header_menu/channel_header_menu.tsx b/webapp/channels/src/components/channel_header_menu/channel_header_menu.tsx index 3b6f4539135..3b0c34b08a9 100644 --- a/webapp/channels/src/components/channel_header_menu/channel_header_menu.tsx +++ b/webapp/channels/src/components/channel_header_menu/channel_header_menu.tsx @@ -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', }} > + {isDirect && ( ({ + 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( + + + , + baseState, + ); + + expect(container).toBeEmptyDOMElement(); + }); + + test('should call popoutChannel when clicked', async () => { + const channel = TestHelper.getChannelMock({type: 'O' as ChannelType, name: 'town-square'}); + + renderWithContext( + + + , + baseState, + ); + + await userEvent.click(screen.getByText('Open in new window')); + + expect(jest.mocked(popoutChannel)).toHaveBeenCalledWith( + expect.any(String), + 'test-team', + 'channels', + 'town-square', + ); + }); +}); diff --git a/webapp/channels/src/components/channel_header_menu/menu_items/open_in_new_window.tsx b/webapp/channels/src/components/channel_header_menu/menu_items/open_in_new_window.tsx new file mode 100644 index 00000000000..beb47e741e9 --- /dev/null +++ b/webapp/channels/src/components/channel_header_menu/menu_items/open_in_new_window.tsx @@ -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 ( + <> + + + + ); +}; + +export default MenuItemOpenInNewWindow; diff --git a/webapp/channels/src/components/channel_popout/channel_popout.scss b/webapp/channels/src/components/channel_popout/channel_popout.scss new file mode 100644 index 00000000000..bf0d02dfe5f --- /dev/null +++ b/webapp/channels/src/components/channel_popout/channel_popout.scss @@ -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; + } + } +} diff --git a/webapp/channels/src/components/channel_popout/channel_popout.test.tsx b/webapp/channels/src/components/channel_popout/channel_popout.test.tsx new file mode 100644 index 00000000000..c8b260fca70 --- /dev/null +++ b/webapp/channels/src/components/channel_popout/channel_popout.test.tsx @@ -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: () =>
{'ChannelIdentifierRouter'}
, +})); +jest.mock('components/sidebar_right', () => ({ + __esModule: true, + default: () =>
{'SidebarRight'}
, +})); +jest.mock('components/unreads_status_handler', () => ({ + __esModule: true, + default: () =>
{'UnreadsStatusHandler'}
, +})); +jest.mock('components/loading_screen', () => ({ + __esModule: true, + default: () =>
{'LoadingScreen'}
, +})); + +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( + + + + + , + 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( + + + + + , + 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}', + }); + }); +}); diff --git a/webapp/channels/src/components/channel_popout/channel_popout.tsx b/webapp/channels/src/components/channel_popout/channel_popout.tsx new file mode 100644 index 00000000000..bb325413e11 --- /dev/null +++ b/webapp/channels/src/components/channel_popout/channel_popout.tsx @@ -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 ; + } + + return ( + <> + {isDesktopApp() && } +
+
+
+
+
+ +
+
+
+
+ +
+ + ); +} diff --git a/webapp/channels/src/components/channel_popout/index.ts b/webapp/channels/src/components/channel_popout/index.ts new file mode 100644 index 00000000000..44c9e8a3bbb --- /dev/null +++ b/webapp/channels/src/components/channel_popout/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export {default} from './channel_popout'; diff --git a/webapp/channels/src/components/popout_controller/popout_controller.tsx b/webapp/channels/src/components/popout_controller/popout_controller.tsx index 0f513dec879..cbe8249ba7d 100644 --- a/webapp/channels/src/components/popout_controller/popout_controller.tsx +++ b/webapp/channels/src/components/popout_controller/popout_controller.tsx @@ -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 = (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 = (routeProps) => { path={`/_popout/thread/:team(${TEAM_NAME_PATH_PATTERN})/:postId(${ID_PATH_PATTERN})`} component={ThreadPopout} /> + void; + id?: string; +}; + +export default function PopoutMenuItem({onClick, id = 'openInNewWindow'}: Props) { + if (!canPopout()) { + return null; + } + + return ( + } + labels={ + + } + onClick={onClick} + /> + ); +} diff --git a/webapp/channels/src/components/post/actions.test.ts b/webapp/channels/src/components/post/actions.test.ts new file mode 100644 index 00000000000..f7d09cbfbfe --- /dev/null +++ b/webapp/channels/src/components/post/actions.test.ts @@ -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'); + }); +}); diff --git a/webapp/channels/src/components/post/actions.ts b/webapp/channels/src/components/post/actions.ts index 0924e0062c3..dd131d22c2e 100644 --- a/webapp/channels/src/components/post/actions.ts +++ b/webapp/channels/src/components/post/actions.ts @@ -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 { + 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}; + }; +} diff --git a/webapp/channels/src/components/post/index.tsx b/webapp/channels/src/components/post/index.tsx index 84b39cb3645..d036cf9154e 100644 --- a/webapp/channels/src/components/post/index.tsx +++ b/webapp/channels/src/components/post/index.tsx @@ -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, diff --git a/webapp/channels/src/components/post/post_component.test.tsx b/webapp/channels/src/components/post/post_component.test.tsx index 46e2f32398d..47a2bfaec90 100644 --- a/webapp/channels/src/components/post/post_component.test.tsx +++ b/webapp/channels/src/components/post/post_component.test.tsx @@ -56,6 +56,7 @@ describe('PostComponent', () => { savePreferences: jest.fn(), openModal: jest.fn(), closeModal: jest.fn(), + highlightPostInChannelPopout: jest.fn(), }, isChannelAutotranslated: false, }; diff --git a/webapp/channels/src/components/post/post_component.tsx b/webapp/channels/src/components/post/post_component.tsx index 08fd6a1927f..11d6334df24 100644 --- a/webapp/channels/src/components/post/post_component.tsx +++ b/webapp/channels/src/components/post/post_component.tsx @@ -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:

(modalData: ModalData

) => void; closeModal: (modalId: string) => void; + highlightPostInChannelPopout: (postId: string) => void; }; timestampProps?: Partial; 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; diff --git a/webapp/channels/src/components/post_view/post_list_virtualized/post_list_virtualized.tsx b/webapp/channels/src/components/post_view/post_list_virtualized/post_list_virtualized.tsx index d23b23034d1..dc851c7e6ef 100644 --- a/webapp/channels/src/components/post_view/post_list_virtualized/post_list_virtualized.tsx +++ b/webapp/channels/src/components/post_view/post_list_virtualized/post_list_virtualized.tsx @@ -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 { }); } - if (!this.props.isMobileView && !this.state.isSearchHintDismissed) { + if (!this.props.isMobileView && !this.state.isSearchHintDismissed && !isChannelPopoutWindow()) { this.setState({ showSearchHint: offsetFromBottom > this.showSearchHintThreshold, }); diff --git a/webapp/channels/src/components/search_results_header/search_results_header.test.tsx b/webapp/channels/src/components/search_results_header/search_results_header.test.tsx index 10a2341fe4f..a3c6b782961 100644 --- a/webapp/channels/src/components/search_results_header/search_results_header.test.tsx +++ b/webapp/channels/src/components/search_results_header/search_results_header.test.tsx @@ -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', () => ({ diff --git a/webapp/channels/src/components/search_results_header/search_results_header.tsx b/webapp/channels/src/components/search_results_header/search_results_header.tsx index 0c460f6bb69..ab7e504400f 100644 --- a/webapp/channels/src/components/search_results_header/search_results_header.tsx +++ b/webapp/channels/src/components/search_results_header/search_results_header.tsx @@ -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 && ( )} - {!isPopoutWindow() && + {(!isPopoutWindow() || isChannelPopoutWindow()) &&

+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+