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 new file mode 100644 index 00000000000..b15ca2edb2a --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/search/search_popout.spec.ts @@ -0,0 +1,237 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '@mattermost/playwright-lib'; + +test('MM-65630-1 Search results should show popout button that opens results in a new window', async ({pw}) => { + const {adminClient, user, team} = await pw.initSetup(); + + const channel = await adminClient.createChannel( + pw.random.channel({ + teamId: team.id, + displayName: 'Search Popout Channel', + name: 'search-popout-channel', + }), + ); + await adminClient.addToChannel(user.id, channel.id); + + const uniqueText = `popout-search-test-${await pw.random.id()}`; + await adminClient.createPost({ + channel_id: channel.id, + message: uniqueText, + }); + + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, channel.name); + await channelsPage.toBeVisible(); + + const page = channelsPage.page; + + await channelsPage.globalHeader.openSearch(); + await channelsPage.searchBox.searchInput.fill(uniqueText); + await channelsPage.searchBox.searchInput.press('Enter'); + + await expect(page.locator('#searchContainer')).toBeVisible(); + await expect(page.locator('#searchContainer').getByText(uniqueText)).toBeVisible(); + + const popoutButton = page.locator('.PopoutButton'); + await expect(popoutButton).toBeVisible(); + + const [popoutPage] = await Promise.all([page.waitForEvent('popup'), popoutButton.click()]); + + await popoutPage.waitForLoadState('domcontentloaded'); + const popoutUrl = popoutPage.url(); + expect(popoutUrl).toContain('/_popout/rhs/'); + expect(popoutUrl).toContain('/search'); + expect(popoutUrl).toContain(`q=${encodeURIComponent(uniqueText)}`); + expect(popoutUrl).toContain('mode=search'); + + await expect(popoutPage.locator('#searchContainer')).toBeVisible({timeout: 10000}); + await expect(popoutPage.locator('#searchContainer').getByText(uniqueText)).toBeVisible({timeout: 10000}); + + await popoutPage.close(); +}); + +test('MM-65630-2 Recent mentions popout should open with the right results', async ({pw}) => { + const {adminClient, user, team} = await pw.initSetup(); + + const channel = await adminClient.createChannel( + pw.random.channel({ + teamId: team.id, + displayName: 'Mentions Popout Channel', + name: 'mentions-popout-channel', + }), + ); + await adminClient.addToChannel(user.id, channel.id); + + const mentionText = `hey @${user.username} check this mention-${await pw.random.id()}`; + await adminClient.createPost({ + channel_id: channel.id, + message: mentionText, + }); + + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, channel.name); + await channelsPage.toBeVisible(); + + const page = channelsPage.page; + + await channelsPage.globalHeader.openRecentMentions(); + + await expect(page.locator('#searchContainer')).toBeVisible(); + await expect(page.locator('#searchContainer').getByRole('heading', {name: 'Recent Mentions'})).toBeVisible(); + await expect(page.locator('#searchContainer').getByText(mentionText)).toBeVisible(); + + const popoutButton = page.locator('.PopoutButton'); + await expect(popoutButton).toBeVisible(); + + const [popoutPage] = await Promise.all([page.waitForEvent('popup'), popoutButton.click()]); + + await popoutPage.waitForLoadState('domcontentloaded'); + const popoutUrl = popoutPage.url(); + expect(popoutUrl).toContain('/_popout/rhs/'); + expect(popoutUrl).toContain('/search'); + expect(popoutUrl).toContain('mode=mention'); + + await expect(popoutPage.locator('#searchContainer')).toBeVisible({timeout: 10000}); + await expect(popoutPage.locator('#searchContainer').getByText(mentionText)).toBeVisible({timeout: 10000}); + + await popoutPage.close(); +}); + +test('MM-65630-3 Saved messages popout should open with the right results', async ({pw}) => { + const {adminClient, user, userClient, team} = await pw.initSetup(); + + const channel = await adminClient.createChannel( + pw.random.channel({ + teamId: team.id, + displayName: 'Saved Popout Channel', + name: 'saved-popout-channel', + }), + ); + await adminClient.addToChannel(user.id, channel.id); + + const savedText = `saved-message-test-${await pw.random.id()}`; + const post = await adminClient.createPost({ + channel_id: channel.id, + message: savedText, + }); + + await userClient.savePreferences(user.id, [ + { + user_id: user.id, + category: 'flagged_post', + name: post.id, + value: 'true', + }, + ]); + + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, channel.name); + await channelsPage.toBeVisible(); + + const page = channelsPage.page; + + await channelsPage.globalHeader.savedMessagesButton.click(); + + await expect(page.locator('#searchContainer')).toBeVisible(); + await expect(page.locator('#searchContainer').getByRole('heading', {name: 'Saved messages'})).toBeVisible(); + await expect(page.locator('#searchContainer').getByText(savedText)).toBeVisible(); + + const popoutButton = page.locator('.PopoutButton'); + await expect(popoutButton).toBeVisible(); + + const [popoutPage] = await Promise.all([page.waitForEvent('popup'), popoutButton.click()]); + + await popoutPage.waitForLoadState('domcontentloaded'); + const popoutUrl = popoutPage.url(); + expect(popoutUrl).toContain('/_popout/rhs/'); + expect(popoutUrl).toContain('/search'); + expect(popoutUrl).toContain('mode=flag'); + + await expect(popoutPage.locator('#searchContainer')).toBeVisible({timeout: 10000}); + await expect(popoutPage.locator('#searchContainer').getByText(savedText)).toBeVisible({timeout: 10000}); + + await popoutPage.close(); +}); + +test('MM-65630-4 Search popout should not show popout button in the popout window itself', async ({pw}) => { + const {adminClient, user, team} = await pw.initSetup(); + + const channel = await adminClient.createChannel( + pw.random.channel({ + teamId: team.id, + displayName: 'Popout No Button Channel', + name: 'popout-no-button-channel', + }), + ); + await adminClient.addToChannel(user.id, channel.id); + + const uniqueText = `no-button-test-${await pw.random.id()}`; + await adminClient.createPost({ + channel_id: channel.id, + message: uniqueText, + }); + + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, channel.name); + await channelsPage.toBeVisible(); + + const page = channelsPage.page; + + await channelsPage.globalHeader.openSearch(); + await channelsPage.searchBox.searchInput.fill(uniqueText); + await channelsPage.searchBox.searchInput.press('Enter'); + + await expect(page.locator('#searchContainer')).toBeVisible(); + + const [popoutPage] = await Promise.all([page.waitForEvent('popup'), page.locator('.PopoutButton').click()]); + + await popoutPage.waitForLoadState('domcontentloaded'); + await expect(popoutPage.locator('#searchContainer')).toBeVisible({timeout: 10000}); + + await expect(popoutPage.locator('.PopoutButton')).not.toBeVisible(); + + await expect(popoutPage.locator('#searchResultsCloseButton')).not.toBeVisible(); + + await popoutPage.close(); +}); + +test('MM-65630-5 Search popout should preserve search type (files) in the URL', async ({pw}) => { + const {adminClient, user, team} = await pw.initSetup(); + + const channel = await adminClient.createChannel( + pw.random.channel({ + teamId: team.id, + displayName: 'Files Search Channel', + name: 'files-search-channel', + }), + ); + await adminClient.addToChannel(user.id, channel.id); + + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, channel.name); + await channelsPage.toBeVisible(); + + const page = channelsPage.page; + + await channelsPage.globalHeader.openSearch(); + await channelsPage.searchBox.searchInput.fill('test'); + await channelsPage.searchBox.searchInput.press('Enter'); + + await expect(page.locator('#searchContainer')).toBeVisible(); + + const filesTab = page.locator('#searchContainer').getByRole('tab', {name: /Files/}); + await filesTab.click(); + + const popoutButton = page.locator('.PopoutButton'); + await expect(popoutButton).toBeVisible(); + + const [popoutPage] = await Promise.all([page.waitForEvent('popup'), popoutButton.click()]); + + await popoutPage.waitForLoadState('domcontentloaded'); + const popoutUrl = popoutPage.url(); + expect(popoutUrl).toContain('type=files'); + + await popoutPage.close(); +}); diff --git a/server/Makefile b/server/Makefile index f21099a6c7d..d7628ec3576 100644 --- a/server/Makefile +++ b/server/Makefile @@ -165,7 +165,7 @@ PLUGIN_PACKAGES += mattermost-plugin-agents-v1.7.2 PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.2 PLUGIN_PACKAGES += mattermost-plugin-user-survey-v1.1.1 PLUGIN_PACKAGES += mattermost-plugin-mscalendar-v1.6.0 -PLUGIN_PACKAGES += mattermost-plugin-msteams-meetings-v2.4.0 +PLUGIN_PACKAGES += mattermost-plugin-msteams-meetings-v2.4.1 PLUGIN_PACKAGES += mattermost-plugin-metrics-v0.7.0 PLUGIN_PACKAGES += mattermost-plugin-channel-export-v1.3.0 diff --git a/webapp/channels/src/components/common/hooks/use_search_results_actions.test.ts b/webapp/channels/src/components/common/hooks/use_search_results_actions.test.ts new file mode 100644 index 00000000000..38cae281840 --- /dev/null +++ b/webapp/channels/src/components/common/hooks/use_search_results_actions.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {act} from '@testing-library/react'; + +import {getMoreFilesForSearch, getMorePostsForSearch} from 'mattermost-redux/actions/search'; + +import { + filterFilesSearchByExt, + showChannelFiles, + showSearchResults, + updateSearchTerms as updateSearchTermsAction, + updateSearchType as updateSearchTypeAction, +} from 'actions/views/rhs'; + +import {renderHookWithContext} from 'tests/react_testing_utils'; +import {TestHelper} from 'utils/test_helper'; + +import useSearchResultsActions from './use_search_results_actions'; + +const MOCK_ACTION = {type: 'MOCK'}; +jest.mock('mattermost-redux/actions/search', () => ({ + getMorePostsForSearch: jest.fn(() => MOCK_ACTION), + getMoreFilesForSearch: jest.fn(() => MOCK_ACTION), +})); + +jest.mock('actions/views/rhs', () => ({ + filterFilesSearchByExt: jest.fn(() => MOCK_ACTION), + showChannelFiles: jest.fn(() => MOCK_ACTION), + showSearchResults: jest.fn(() => MOCK_ACTION), + updateSearchTeam: jest.fn(() => MOCK_ACTION), + updateSearchTerms: jest.fn(() => MOCK_ACTION), + updateSearchType: jest.fn(() => MOCK_ACTION), +})); + +const {updateSearchTeam: updateSearchTeamAction} = jest.requireMock('actions/views/rhs'); + +describe('useSearchResultsActions', () => { + const channel = TestHelper.getChannelMock({id: 'channel1', name: 'test-channel'}); + const initialState = { + entities: { + channels: { + currentChannelId: channel.id, + channels: { + [channel.id]: channel, + }, + myMembers: {}, + }, + }, + views: { + rhs: { + rhsState: 'search', + searchTeam: 'team1', + searchTerms: 'hello world', + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + function callAction( + callback: (actions: ReturnType) => void, + rhsState?: string, + ) { + const state = { + ...initialState, + views: { + rhs: { + ...initialState.views.rhs, + rhsState: rhsState ?? initialState.views.rhs.rhsState, + }, + }, + }; + const {result} = renderHookWithContext(() => useSearchResultsActions(), state); + act(() => callback(result.current)); + return result; + } + + test('getMorePostsForSearch should dispatch with search team', () => { + callAction((a) => a.getMorePostsForSearch()); + expect(jest.mocked(getMorePostsForSearch)).toHaveBeenCalledWith('team1'); + }); + + test('getMorePostsForSearch should dispatch with empty team for mention search', () => { + callAction((a) => a.getMorePostsForSearch(), 'mention'); + expect(jest.mocked(getMorePostsForSearch)).toHaveBeenCalledWith(''); + }); + + test('getMoreFilesForSearch should dispatch with search team', () => { + callAction((a) => a.getMoreFilesForSearch()); + expect(jest.mocked(getMoreFilesForSearch)).toHaveBeenCalledWith('team1'); + }); + + test('setSearchFilterType should dispatch filter and trigger search results', () => { + callAction((a) => a.setSearchFilterType('documents')); + expect(jest.mocked(filterFilesSearchByExt)).toHaveBeenCalledWith(['doc', 'pdf', 'docx', 'odt', 'rtf', 'txt']); + expect(jest.mocked(showSearchResults)).toHaveBeenCalledWith(false); + }); + + test('setSearchFilterType should dispatch showChannelFiles when in channel files mode', () => { + callAction((a) => a.setSearchFilterType('images'), 'channel-files'); + expect(jest.mocked(filterFilesSearchByExt)).toHaveBeenCalledWith(['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'svg', 'xcf']); + expect(jest.mocked(showChannelFiles)).toHaveBeenCalledWith(channel.id); + }); + + test('setSearchFilterType "all" should dispatch empty extension array', () => { + callAction((a) => a.setSearchFilterType('all')); + expect(jest.mocked(filterFilesSearchByExt)).toHaveBeenCalledWith([]); + }); + + test('updateSearchTerms should append term replacing last word', () => { + callAction((a) => a.updateSearchTerms('From:')); + expect(jest.mocked(updateSearchTermsAction)).toHaveBeenCalledWith('hello from:'); + }); + + test('setSearchType should dispatch updateSearchType', () => { + callAction((a) => a.setSearchType('files')); + expect(jest.mocked(updateSearchTypeAction)).toHaveBeenCalledWith('files'); + }); + + test('updateSearchTeam should dispatch team update and re-search', () => { + callAction((a) => a.updateSearchTeam('team2')); + expect(jest.mocked(updateSearchTeamAction)).toHaveBeenCalledWith('team2'); + expect(jest.mocked(showSearchResults)).toHaveBeenCalledWith(false); + }); + + test('updateSearchTeam should strip in: and from: filters from terms', () => { + function callWithTerms(terms: string) { + const state = { + ...initialState, + views: {rhs: {...initialState.views.rhs, searchTerms: terms}}, + }; + const {result} = renderHookWithContext(() => useSearchResultsActions(), state); + let cleaned = ''; + act(() => { + cleaned = result.current.updateSearchTeam('team2'); + }); + return cleaned; + } + + expect(callWithTerms('hello in:town-square from:user1')).toBe('hello'); + expect(callWithTerms('hello world')).toBe('hello world'); + expect(jest.mocked(updateSearchTermsAction)).toHaveBeenCalledTimes(1); + }); + + test('updateSearchTeam should return cleaned terms', () => { + let result = ''; + callAction((a) => { + result = a.updateSearchTeam('team2'); + }); + expect(result).toBe('hello world'); + }); +}); diff --git a/webapp/channels/src/components/common/hooks/use_search_results_actions.ts b/webapp/channels/src/components/common/hooks/use_search_results_actions.ts new file mode 100644 index 00000000000..986c4846680 --- /dev/null +++ b/webapp/channels/src/components/common/hooks/use_search_results_actions.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useCallback, useState} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; + +import {getMoreFilesForSearch, getMorePostsForSearch} from 'mattermost-redux/actions/search'; +import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels'; + +import {filterFilesSearchByExt, showChannelFiles, showSearchResults, updateSearchTeam as updateSearchTeamAction, updateSearchTerms as updateSearchTermsAction, updateSearchType as updateSearchTypeAction} from 'actions/views/rhs'; +import {getRhsState, getSearchTeam, getSearchTerms} from 'selectors/rhs'; + +import type {SearchFilterType} from 'components/search/types'; + +import {RHSStates} from 'utils/constants'; + +import type {SearchType} from 'types/store/rhs'; + +export default function useSearchResultsActions() { + const dispatch = useDispatch(); + const searchTeam = useSelector(getSearchTeam); + const rhsState = useSelector(getRhsState); + const searchTerms = useSelector(getSearchTerms); + const currentChannel = useSelector(getCurrentChannel); + const isMentionSearch = rhsState === RHSStates.MENTION; + const isChannelFiles = rhsState === RHSStates.CHANNEL_FILES; + + const [searchFilterType, setSearchFilterType] = useState('all'); + + const getMorePostsForSearchCallback = useCallback(() => { + let team = searchTeam; + if (isMentionSearch) { + team = ''; + } + dispatch(getMorePostsForSearch(team)); + }, [dispatch, isMentionSearch, searchTeam]); + + const getMoreFilesForSearchCallback = useCallback(() => { + let team = searchTeam; + if (isMentionSearch) { + team = ''; + } + dispatch(getMoreFilesForSearch(team)); + }, [dispatch, isMentionSearch, searchTeam]); + + const handleSetSearchFilter = useCallback((filterType: SearchFilterType) => { + switch (filterType) { + case 'documents': + dispatch(filterFilesSearchByExt(['doc', 'pdf', 'docx', 'odt', 'rtf', 'txt'])); + break; + case 'spreadsheets': + dispatch(filterFilesSearchByExt(['xls', 'xlsx', 'ods'])); + break; + case 'presentations': + dispatch(filterFilesSearchByExt(['ppt', 'pptx', 'odp'])); + break; + case 'code': + dispatch(filterFilesSearchByExt(['py', 'go', 'java', 'kt', 'c', 'cpp', 'h', 'html', 'js', 'ts', 'cs', 'vb', 'php', 'pl', 'r', 'rb', 'sql', 'swift', 'json'])); + break; + case 'images': + dispatch(filterFilesSearchByExt(['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'svg', 'xcf'])); + break; + case 'audio': + dispatch(filterFilesSearchByExt(['ogg', 'mp3', 'wav', 'flac'])); + break; + case 'video': + dispatch(filterFilesSearchByExt(['ogm', 'mp4', 'avi', 'webm', 'mov', 'mkv', 'mpeg', 'mpg'])); + break; + default: + dispatch(filterFilesSearchByExt([])); + } + + setSearchFilterType(filterType); + if (isChannelFiles && currentChannel?.id) { + dispatch(showChannelFiles(currentChannel.id)); + } else { + dispatch(showSearchResults(false)); + } + }, [dispatch, isChannelFiles, currentChannel?.id]); + + const handleUpdateSearchTerms = useCallback((term: string) => { + const pretextArray = searchTerms?.split(' ') || []; + pretextArray.pop(); + pretextArray.push(term.toLowerCase()); + dispatch(updateSearchTermsAction(pretextArray.join(' '))); + }, [dispatch, searchTerms]); + + const handleSetSearchType = useCallback((type: SearchType) => { + dispatch(updateSearchTypeAction(type)); + }, [dispatch]); + + const handleUpdateSearchTeam = useCallback((teamId: string): string => { + dispatch(updateSearchTeamAction(teamId)); + + // When we switch teams, we need to remove the in: and from: filters from the search terms + // since the channel and user filters might not be valid for the new team. + const cleanedTerms = searchTerms. + replace(/\bin:[^\s]*/gi, '').replace(/\s{2,}/g, ' '). + replace(/\bfrom:[^\s]*/gi, '').replace(/\s{2,}/g, ' '). + trim(); + + if (cleanedTerms.trim() !== searchTerms.trim()) { + dispatch(updateSearchTermsAction(cleanedTerms)); + } + + dispatch(showSearchResults(isMentionSearch)); + + return cleanedTerms; + }, [dispatch, searchTerms, isMentionSearch]); + + return { + searchFilterType, + getMorePostsForSearch: getMorePostsForSearchCallback, + getMoreFilesForSearch: getMoreFilesForSearchCallback, + setSearchFilterType: handleSetSearchFilter, + updateSearchTerms: handleUpdateSearchTerms, + updateSearchTeam: handleUpdateSearchTeam, + setSearchType: handleSetSearchType, + }; +} diff --git a/webapp/channels/src/components/popout_controller/popout_controller.tsx b/webapp/channels/src/components/popout_controller/popout_controller.tsx index de84e27d4df..0f513dec879 100644 --- a/webapp/channels/src/components/popout_controller/popout_controller.tsx +++ b/webapp/channels/src/components/popout_controller/popout_controller.tsx @@ -19,7 +19,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, IDENTIFIER_PATH_PATTERN} from 'utils/path'; +import {TEAM_NAME_PATH_PATTERN, ID_PATH_PATTERN} from 'utils/path'; import {useBrowserPopout} from 'utils/popouts/use_browser_popout'; import './popout_controller.scss'; @@ -52,7 +52,7 @@ const PopoutController: React.FC = (routeProps) => { component={ThreadPopout} /> { expect(propsForRootPost.actions.selectPostFromRightHandSideSearch).not.toHaveBeenCalled(); expect(getHistory().push).toHaveBeenCalled(); }); + + test('should navigate within popout when clicking reply on a search result in a popout window', async () => { + jest.spyOn(PopoutWindows, 'isPopoutWindow').mockReturnValue(true); + + const props = { + ...propsForRootPost, + location: Locations.SEARCH, + matches: ['test'], + teamName: currentTeam.name, + }; + renderWithContext(, state); + + await userEvent.click(screen.getByText('1 reply')); + + expect(propsForRootPost.actions.selectPostFromRightHandSideSearch).not.toHaveBeenCalled(); + expect(getHistory().replace).toHaveBeenCalledWith( + expect.stringContaining(`/_popout/thread/${currentTeam.name}/${rootPost.id}`), + ); + + jest.restoreAllMocks(); + }); + + test('should navigate within popout on cross-team reply click instead of jumping', async () => { + jest.spyOn(PopoutWindows, 'isPopoutWindow').mockReturnValue(true); + + const props = { + ...propsForRootPost, + location: Locations.SEARCH, + matches: ['test'], + team: TestHelper.getTeamMock({id: 'another_team'}), + teamName: currentTeam.name, + }; + renderWithContext(, state); + + await userEvent.click(screen.getByText('1 reply')); + + expect(getHistory().push).not.toHaveBeenCalled(); + expect(getHistory().replace).toHaveBeenCalledWith( + expect.stringContaining('/_popout/thread/'), + ); + + jest.restoreAllMocks(); + }); + + test('should not navigate within popout when not a search result item', async () => { + jest.spyOn(PopoutWindows, 'isPopoutWindow').mockReturnValue(true); + + const props = { + ...propsForRootPost, + location: Locations.CENTER, + }; + renderWithContext(, state); + + await userEvent.click(screen.getByText('1 reply')); + + expect(propsForRootPost.actions.selectPostFromRightHandSideSearch).toHaveBeenCalledWith(rootPost); + expect(getHistory().replace).not.toHaveBeenCalled(); + + jest.restoreAllMocks(); + }); }); }); diff --git a/webapp/channels/src/components/post/post_component.tsx b/webapp/channels/src/components/post/post_component.tsx index 74bf03a8043..08fd6a1927f 100644 --- a/webapp/channels/src/components/post/post_component.tsx +++ b/webapp/channels/src/components/post/post_component.tsx @@ -47,6 +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 * as PostUtils from 'utils/post_utils'; import {makeIsEligibleForClick} from 'utils/utils'; @@ -411,22 +412,28 @@ function PostComponent(props: Props) { const {selectPostFromRightHandSideSearch} = props.actions; + const isSearchPopoutWindow = useMemo(() => isPopoutWindow() && isSearchResultItem, [isSearchResultItem]); const handleCommentClick = useCallback((e: React.MouseEvent) => { e.preventDefault(); if (!post) { return; } + if (isSearchPopoutWindow) { + const returnTo = encodeURIComponent(window.location.pathname + window.location.search); + getHistory().replace(`/_popout/thread/${props.teamName}/${post.root_id || post.id}?returnTo=${returnTo}`); + return; + } selectPostFromRightHandSideSearch(post); - }, [post, selectPostFromRightHandSideSearch]); + }, [post, props.teamName, selectPostFromRightHandSideSearch, isSearchPopoutWindow]); const handleThreadClick = useCallback((e: React.MouseEvent) => { - if (props.currentTeam?.id === teamId) { + if (isSearchPopoutWindow || props.currentTeam?.id === teamId) { handleCommentClick(e); } else { handleJumpClick(e); } - }, [handleCommentClick, handleJumpClick, props.currentTeam?.id, teamId]); + }, [handleCommentClick, handleJumpClick, props.currentTeam?.id, teamId, isSearchPopoutWindow]); const translation = PostUtils.getPostTranslation(post, locale); diff --git a/webapp/channels/src/components/rhs_popout/rhs_popout.test.tsx b/webapp/channels/src/components/rhs_popout/rhs_popout.test.tsx index f05ee0784ed..c6aa93c9526 100644 --- a/webapp/channels/src/components/rhs_popout/rhs_popout.test.tsx +++ b/webapp/channels/src/components/rhs_popout/rhs_popout.test.tsx @@ -2,7 +2,6 @@ // See LICENSE.txt for license information. import React from 'react'; -import * as ReactRedux from 'react-redux'; import {MemoryRouter, Route} from 'react-router-dom'; import {fetchChannelsAndMembers, getChannelMembers, selectChannel} from 'mattermost-redux/actions/channels'; @@ -15,28 +14,14 @@ import {TestHelper} from 'utils/test_helper'; import RhsPopout from './rhs_popout'; -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useDispatch: jest.fn(), - useSelector: jest.fn(), -})); - -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), -})); - jest.mock('mattermost-redux/actions/channels', () => ({ - fetchChannelsAndMembers: jest.fn(), - getChannelMembers: jest.fn(), - selectChannel: jest.fn(), + fetchChannelsAndMembers: jest.fn(() => ({type: 'MOCK'})), + getChannelMembers: jest.fn(() => ({type: 'MOCK'})), + selectChannel: jest.fn(() => ({type: 'MOCK'})), })); jest.mock('mattermost-redux/actions/teams', () => ({ - selectTeam: jest.fn(), -})); - -jest.mock('mattermost-redux/selectors/entities/channels', () => ({ - getChannelByName: jest.fn(), + selectTeam: jest.fn(() => ({type: 'MOCK'})), })); jest.mock('components/common/hooks/use_team', () => ({ @@ -53,69 +38,68 @@ jest.mock('components/rhs_plugin_popout', () => ({ default: () =>
{'RHS Plugin Popout'}
, })); -const mockDispatch = jest.fn(); -const mockUseDispatch = ReactRedux.useDispatch as jest.MockedFunction; -const mockUseSelector = ReactRedux.useSelector as jest.MockedFunction; -const mockUseParams = jest.spyOn(require('react-router-dom'), 'useParams'); -const mockSelectChannel = selectChannel as jest.MockedFunction; -const mockGetChannelMembers = getChannelMembers as jest.MockedFunction; -const mockSelectTeam = selectTeam as jest.MockedFunction; -const mockFetchChannelsAndMembers = fetchChannelsAndMembers as jest.MockedFunction; -const mockUseTeamByName = useTeamByName as jest.MockedFunction; +jest.mock('components/rhs_search_popout', () => ({ + __esModule: true, + default: () =>
{'RHS Search Popout'}
, +})); describe('RhsPopout', () => { const team1 = TestHelper.getTeamMock({id: 'team1', name: 'team1'}); const channel1 = TestHelper.getChannelMock({id: 'channel1', name: 'channel1'}); + const baseState = { + entities: { + channels: { + channels: {[channel1.id]: channel1}, + myMembers: {}, + }, + teams: { + currentTeamId: team1.id, + teams: {[team1.id]: team1}, + }, + }, + }; + beforeEach(() => { - mockDispatch.mockReturnValue({type: 'MOCK_ACTION'}); - mockUseDispatch.mockReturnValue(mockDispatch); - mockUseParams.mockReturnValue({team: 'team1', identifier: 'channel1'}); - mockUseTeamByName.mockReturnValue(team1); - mockUseSelector.mockReturnValue(channel1); - - mockSelectChannel.mockImplementation((channelId: string) => ({ - type: 'SELECT_CHANNEL', - data: channelId, - })); - mockGetChannelMembers.mockImplementation(() => jest.fn(() => Promise.resolve({data: []}))); - mockSelectTeam.mockImplementation((team: string | typeof team1) => { - const teamId = typeof team === 'string' ? team : team.id; - return { - type: 'SELECT_TEAM', - data: teamId, - }; - }); - mockFetchChannelsAndMembers.mockImplementation(() => jest.fn(() => Promise.resolve({data: {channels: [], channelMembers: []}}))); + jest.mocked(useTeamByName).mockReturnValue(team1); }); - it('should dispatch correct actions when channelId and teamId are available', async () => { - renderWithContext( - + afterEach(() => { + jest.clearAllMocks(); + }); + + function renderPopout(path: string, state = baseState) { + return renderWithContext( + , + state, ); + } + + it('should dispatch selectTeam and fetchChannelsAndMembers when team is available', async () => { + renderPopout('/_popout/rhs/team1/search?q=test'); await waitFor(() => { - expect(mockSelectChannel).toHaveBeenCalledWith(channel1.id); - expect(mockGetChannelMembers).toHaveBeenCalledWith(channel1.id); - expect(mockSelectTeam).toHaveBeenCalledWith(team1.id); - expect(mockFetchChannelsAndMembers).toHaveBeenCalledWith(team1.id); + expect(jest.mocked(selectTeam)).toHaveBeenCalledWith(team1.id); + expect(jest.mocked(fetchChannelsAndMembers)).toHaveBeenCalledWith(team1.id); + }); + }); + + it('should dispatch selectChannel and getChannelMembers when channel is in query params', async () => { + renderPopout('/_popout/rhs/team1/search?channel=channel1'); + + await waitFor(() => { + expect(jest.mocked(selectChannel)).toHaveBeenCalledWith(channel1.id); + expect(jest.mocked(getChannelMembers)).toHaveBeenCalledWith(channel1.id); }); }); it('should render the correct structure', () => { - const {container} = renderWithContext( - - - , - ); + const {container} = renderPopout('/_popout/rhs/team1/search?q=test'); expect(container.querySelector('[data-testid="unreads-status-handler"]')).toBeInTheDocument(); expect(container.querySelector('.main-wrapper.rhs-popout')).toBeInTheDocument(); @@ -123,17 +107,24 @@ describe('RhsPopout', () => { expect(container.querySelector('.sidebar-right__body')).toBeInTheDocument(); }); - it('should render RhsPluginPopout for plugin route', () => { - renderWithContext( - - - , - ); + it('should render RhsSearchPopout for search route', () => { + renderPopout('/_popout/rhs/team1/search?q=test'); + expect(screen.getByTestId('rhs-search-popout')).toBeInTheDocument(); + }); + it('should render RhsPluginPopout for plugin route', () => { + renderPopout('/_popout/rhs/team1/plugin/test-plugin'); expect(screen.getByTestId('rhs-plugin-popout')).toBeInTheDocument(); }); -}); + it('should not dispatch channel actions when no channel in query params', async () => { + renderPopout('/_popout/rhs/team1/search?q=test'); + + await waitFor(() => { + expect(jest.mocked(selectTeam)).toHaveBeenCalledWith(team1.id); + }); + + expect(jest.mocked(selectChannel)).not.toHaveBeenCalled(); + expect(jest.mocked(getChannelMembers)).not.toHaveBeenCalled(); + }); +}); diff --git a/webapp/channels/src/components/rhs_popout/rhs_popout.tsx b/webapp/channels/src/components/rhs_popout/rhs_popout.tsx index ad620928bdb..a285c040c8c 100644 --- a/webapp/channels/src/components/rhs_popout/rhs_popout.tsx +++ b/webapp/channels/src/components/rhs_popout/rhs_popout.tsx @@ -3,7 +3,7 @@ import React, {useEffect} from 'react'; import {useDispatch, useSelector} from 'react-redux'; -import {Route, Switch, useParams, useRouteMatch} from 'react-router-dom'; +import {Route, Switch, useLocation, useParams, useRouteMatch} from 'react-router-dom'; import {fetchChannelsAndMembers, getChannelMembers, selectChannel} from 'mattermost-redux/actions/channels'; import {selectTeam} from 'mattermost-redux/actions/teams'; @@ -11,6 +11,7 @@ import {getChannelByName} from 'mattermost-redux/selectors/entities/channels'; import {useTeamByName} from 'components/common/hooks/use_team'; import RhsPluginPopout from 'components/rhs_plugin_popout'; +import RhsSearchPopout from 'components/rhs_search_popout'; import UnreadsStatusHandler from 'components/unreads_status_handler'; import type {GlobalState} from 'types/store'; @@ -21,12 +22,15 @@ export default function RhsPopout() { const match = useRouteMatch(); const dispatch = useDispatch(); - const {team: teamName, identifier: channelIdentifier} = useParams<{team: string; pluginId: string; identifier: string}>(); + const location = useLocation(); - const team = useTeamByName(teamName); - const channel = useSelector((state: GlobalState) => getChannelByName(state, channelIdentifier)); + const {team: teamName} = useParams<{team: string}>(); + const team = useTeamByName(teamName); + const channelIdentifier = new URLSearchParams(location.search).get('channel') ?? ''; + const channel = useSelector((state: GlobalState) => (channelIdentifier ? getChannelByName(state, channelIdentifier) : undefined)); const teamId = team?.id; + const channelId = channel?.id; useEffect(() => { @@ -50,6 +54,10 @@ export default function RhsPopout() {
+ ({ + __esModule: true, + default: () =>
{'Search Results'}
, +})); + +jest.mock('utils/popouts/use_popout_title', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const MOCK_ACTION = {type: 'MOCK'}; +jest.mock('actions/views/rhs', () => ({ + showChannelFiles: jest.fn(() => MOCK_ACTION), + showFlaggedPosts: jest.fn(() => MOCK_ACTION), + showMentions: jest.fn(() => MOCK_ACTION), + showPinnedPosts: jest.fn(() => MOCK_ACTION), + showSearchResults: jest.fn(() => MOCK_ACTION), + updateRhsState: jest.fn(() => MOCK_ACTION), + updateSearchTeam: jest.fn(() => MOCK_ACTION), + updateSearchTerms: jest.fn(() => MOCK_ACTION), + updateSearchType: jest.fn(() => MOCK_ACTION), + filterFilesSearchByExt: jest.fn(() => MOCK_ACTION), +})); + +describe('RhsSearchPopout', () => { + const team = TestHelper.getTeamMock({id: 'team1', name: 'test-team'}); + const channel = TestHelper.getChannelMock({ + id: 'channel1', + name: 'test-channel', + display_name: 'Test Channel', + team_id: team.id, + }); + + const baseState = { + entities: { + channels: { + currentChannelId: channel.id, + channels: {[channel.id]: channel}, + channelsInTeam: {[team.id]: new Set([channel.id])}, + myMembers: {}, + }, + teams: { + currentTeamId: team.id, + teams: {[team.id]: team}, + }, + general: {config: {}, license: {}}, + users: {currentUserId: 'user1'}, + }, + views: { + rhs: { + rhsState: 'search', + isSidebarExpanded: false, + searchTeam: team.id, + searchTerms: '', + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + function renderPopout(search: string, rhsState?: string) { + const state = rhsState ? { + ...baseState, + views: {rhs: {...baseState.views.rhs, rhsState}}, + } : baseState; + + return renderWithContext( + + + , + state, + ); + } + + test('should render SearchResults component', () => { + renderPopout('?q=test&type=messages&mode=search'); + expect(screen.getByTestId('search-results')).toBeInTheDocument(); + }); + + test('should dispatch search setup actions from query params on mount', async () => { + renderPopout('?q=hello+world&type=messages&mode=search'); + + await waitFor(() => { + expect(jest.mocked(updateSearchType)).toHaveBeenCalledWith('messages'); + expect(jest.mocked(updateSearchTerms)).toHaveBeenCalledWith('hello world'); + expect(jest.mocked(updateSearchTeam)).toHaveBeenCalledWith(team.id); + expect(jest.mocked(showSearchResults)).toHaveBeenCalledWith(false); + }); + }); + + test('should dispatch showMentions for mention mode without search terms', async () => { + renderPopout('?q=&type=messages&mode=mention', 'mention'); + + await waitFor(() => { + expect(jest.mocked(showMentions)).toHaveBeenCalled(); + }); + }); + + test('should dispatch showSearchResults(true) for mention mode with search terms', async () => { + renderPopout('?q=from:user&type=messages&mode=mention', 'mention'); + + await waitFor(() => { + expect(jest.mocked(showSearchResults)).toHaveBeenCalledWith(true); + }); + }); + + test('should dispatch showFlaggedPosts for flag mode', async () => { + renderPopout('?q=&type=messages&mode=flag', 'flag'); + + await waitFor(() => { + expect(jest.mocked(showFlaggedPosts)).toHaveBeenCalled(); + }); + }); + + test('should dispatch showPinnedPosts for pin mode when channelId is available', async () => { + renderPopout('?q=&type=messages&mode=pin&channel=test-channel', 'pin'); + + await waitFor(() => { + expect(jest.mocked(showPinnedPosts)).toHaveBeenCalledWith(channel.id); + }); + }); + + test('should not dispatch showPinnedPosts when channelId is not available', async () => { + renderPopout('?q=&type=messages&mode=pin', 'pin'); + + await waitFor(() => { + expect(jest.mocked(updateSearchType)).toHaveBeenCalled(); + }); + + expect(jest.mocked(showPinnedPosts)).not.toHaveBeenCalled(); + expect(jest.mocked(updateRhsState)).toHaveBeenCalledWith('pin', undefined); + }); + + test('should dispatch showChannelFiles for channel_files mode when channelId is available', async () => { + renderPopout('?q=&type=messages&mode=channel-files&channel=test-channel', 'channel-files'); + + await waitFor(() => { + expect(jest.mocked(showChannelFiles)).toHaveBeenCalledWith(channel.id); + }); + }); + + test('should resolve searchTeamId from query params with fallback to current team', async () => { + renderPopout('?q=test&type=messages&mode=search&searchTeamId=other-team'); + await waitFor(() => { + expect(jest.mocked(updateSearchTeam)).toHaveBeenCalledWith('other-team'); + }); + + jest.clearAllMocks(); + renderPopout('?q=test&type=messages&mode=search&searchTeamId='); + await waitFor(() => { + expect(jest.mocked(updateSearchTeam)).toHaveBeenCalledWith(''); + }); + + jest.clearAllMocks(); + renderPopout('?q=test&type=messages&mode=search'); + await waitFor(() => { + expect(jest.mocked(updateSearchTeam)).toHaveBeenCalledWith(team.id); + }); + }); + + test('should not dispatch actions when teamId is not yet available', () => { + const noTeamState = { + ...baseState, + entities: { + ...baseState.entities, + teams: {...baseState.entities.teams, currentTeamId: ''}, + }, + }; + + renderWithContext( + + + , + noTeamState, + ); + + expect(jest.mocked(updateSearchType)).not.toHaveBeenCalled(); + expect(jest.mocked(updateSearchTerms)).not.toHaveBeenCalled(); + }); + + test('should fallback to updateRhsState for search mode with no search terms', async () => { + renderPopout('?q=&type=messages&mode=search'); + + await waitFor(() => { + expect(jest.mocked(updateRhsState)).toHaveBeenCalledWith('search', undefined); + }); + + expect(jest.mocked(showSearchResults)).not.toHaveBeenCalled(); + }); +}); diff --git a/webapp/channels/src/components/rhs_search_popout/rhs_search_popout.tsx b/webapp/channels/src/components/rhs_search_popout/rhs_search_popout.tsx new file mode 100644 index 00000000000..99a5e81d399 --- /dev/null +++ b/webapp/channels/src/components/rhs_search_popout/rhs_search_popout.tsx @@ -0,0 +1,143 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useMemo} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; +import {useLocation} from 'react-router-dom'; + +import {getChannelByName} from 'mattermost-redux/selectors/entities/channels'; +import {getIsCrossTeamSearchEnabled} from 'mattermost-redux/selectors/entities/general'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; + +import {showChannelFiles, showFlaggedPosts, showMentions, showPinnedPosts, showSearchResults, updateRhsState, updateSearchTeam, updateSearchTerms, updateSearchType} from 'actions/views/rhs'; +import {getIsRhsExpanded, getRhsState} from 'selectors/rhs'; + +import useSearchResultsActions from 'components/common/hooks/use_search_results_actions'; +import SearchResults from 'components/search_results'; + +import {getHistory} from 'utils/browser_history'; +import {RHSStates} from 'utils/constants'; +import usePopoutTitle from 'utils/popouts/use_popout_title'; + +import type {GlobalState} from 'types/store'; +import type {RhsState, SearchType} from 'types/store/rhs'; + +import {getSearchPopoutTitle} from './title'; + +export default function RhsSearchPopout() { + const dispatch = useDispatch(); + const location = useLocation(); + + const teamId = useSelector(getCurrentTeamId); + + const query = useMemo(() => { + const queryParams = new URLSearchParams(location.search); + const searchTerms = queryParams.get('q') ?? ''; + const searchType = queryParams.get('type') as SearchType; + const mode = queryParams.get('mode') as NonNullable; + const channelIdentifier = queryParams.get('channel') ?? ''; + const searchTeamId = queryParams.get('searchTeamId'); + + return { + mode, + searchTerms, + searchType, + channelIdentifier, + searchTeamId, + }; + }, [location.search]); + + const channel = useSelector((state: GlobalState) => (query.channelIdentifier ? getChannelByName(state, query.channelIdentifier) : undefined)); + const rhsState = useSelector(getRhsState); + const isRhsExpanded = useSelector(getIsRhsExpanded); + const crossTeamSearchEnabled = useSelector(getIsCrossTeamSearchEnabled); + const searchActions = useSearchResultsActions(); + + const popoutTitle = useMemo(() => getSearchPopoutTitle(query.mode), [query.mode]); + const popoutTitleParams = useMemo(() => ({searchTerms: query.searchTerms}), [query.searchTerms]); + usePopoutTitle(popoutTitle, popoutTitleParams); + + const channelId = channel?.id; + const channelDisplayName = channel?.display_name ?? ''; + const isMentionSearch = rhsState === RHSStates.MENTION; + const isFlaggedPosts = rhsState === RHSStates.FLAG; + const isPinnedPosts = rhsState === RHSStates.PIN; + const isChannelFiles = rhsState === RHSStates.CHANNEL_FILES; + + useEffect(() => { + if (!teamId) { + return; + } + + dispatch(updateSearchType(query.searchType)); + dispatch(updateSearchTerms(query.searchTerms)); + dispatch(updateSearchTeam(query.searchTeamId ?? teamId)); + + switch (query.mode) { + case RHSStates.CHANNEL_FILES: + if (channelId) { + dispatch(showChannelFiles(channelId)); + return; + } + break; + case RHSStates.MENTION: + if (query.searchTerms.trim()) { + dispatch(showSearchResults(true)); + } else { + dispatch(showMentions()); + } + return; + case RHSStates.FLAG: + dispatch(showFlaggedPosts()); + return; + case RHSStates.PIN: + if (channelId) { + dispatch(showPinnedPosts(channelId)); + return; + } + break; + default: + if (query.searchTerms) { + dispatch(showSearchResults(false)); + return; + } + break; + } + + dispatch(updateRhsState(query.mode, channelId)); + }, [dispatch, query, channelId, teamId]); + + const handleUpdateSearchTeam = useCallback((newTeamId: string) => { + const cleanedTerms = searchActions.updateSearchTeam(newTeamId); + + const params = new URLSearchParams(window.location.search); + params.set('searchTeamId', newTeamId); + + const currentTerms = params.get('q') ?? ''; + if (cleanedTerms.trim() !== currentTerms.trim()) { + params.set('q', cleanedTerms); + } + getHistory().replace(`${window.location.pathname}?${params.toString()}`); + }, [searchActions]); + + return ( + + ); +} + diff --git a/webapp/channels/src/components/rhs_search_popout/title.ts b/webapp/channels/src/components/rhs_search_popout/title.ts new file mode 100644 index 00000000000..a3d87a189ef --- /dev/null +++ b/webapp/channels/src/components/rhs_search_popout/title.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable formatjs/enforce-placeholders */ + +import type {MessageDescriptor} from 'react-intl'; +import {defineMessage} from 'react-intl'; + +import {RHSStates} from 'utils/constants'; + +export function getSearchPopoutTitle(mode: string): MessageDescriptor { + switch (mode) { + case RHSStates.MENTION: + return defineMessage({ + id: 'rhs_search_popout.title.mentions', + defaultMessage: 'Recent Mentions - {serverName}', + }); + case RHSStates.FLAG: + return defineMessage({ + id: 'rhs_search_popout.title.saved', + defaultMessage: 'Saved Messages - {serverName}', + }); + case RHSStates.PIN: + return defineMessage({ + id: 'rhs_search_popout.title.pinned', + defaultMessage: 'Pinned Messages - {channelName} - {serverName}', + }); + case RHSStates.CHANNEL_FILES: + return defineMessage({ + id: 'rhs_search_popout.title.channel_files', + defaultMessage: 'Channel Files - {channelName} - {serverName}', + }); + default: + return defineMessage({ + id: 'rhs_search_popout.title.search', + defaultMessage: 'Search Results for "{searchTerms}" - {serverName}', + }); + } +} diff --git a/webapp/channels/src/components/search/index.tsx b/webapp/channels/src/components/search/index.tsx index dd4d1596362..834f1589f14 100644 --- a/webapp/channels/src/components/search/index.tsx +++ b/webapp/channels/src/components/search/index.tsx @@ -8,8 +8,6 @@ import type {Dispatch} from 'redux'; import type {Channel} from '@mattermost/types/channels'; import type {ServerError} from '@mattermost/types/errors'; -import {getMorePostsForSearch, getMoreFilesForSearch} from 'mattermost-redux/actions/search'; -import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels'; import {getIsCrossTeamSearchEnabled} from 'mattermost-redux/selectors/entities/general'; import {autocompleteChannelsForSearch} from 'actions/channel_actions'; @@ -19,15 +17,12 @@ import { updateSearchTeam, updateSearchTermsForShortcut, showSearchResults, - showChannelFiles, closeRightHandSide, updateRhsState, - setRhsExpanded, openRHSSearch, - filterFilesSearchByExt, updateSearchType, } from 'actions/views/rhs'; -import {getRhsState, getSearchTeam, getSearchTerms, getSearchType, getIsSearchingTerm, getIsRhsOpen, getIsRhsExpanded} from 'selectors/rhs'; +import {getRhsState, getSearchTerms, getSearchType, getIsSearchingTerm, getIsRhsOpen, getIsRhsExpanded} from 'selectors/rhs'; import {getIsMobileView} from 'selectors/views/browser'; import {RHSStates} from 'utils/constants'; @@ -38,17 +33,14 @@ import Search from './search'; function mapStateToProps(state: GlobalState) { const rhsState = getRhsState(state); - const currentChannel = getCurrentChannel(state); const isMobileView = getIsMobileView(state); const isRhsOpen = getIsRhsOpen(state); const crossTeamSearchEnabled = getIsCrossTeamSearchEnabled(state); return { - currentChannel, isRhsExpanded: getIsRhsExpanded(state), isSearchingTerm: getIsSearchingTerm(state), searchTerms: getSearchTerms(state), - searchTeam: getSearchTeam(state), searchType: getSearchType(state), searchVisible: rhsState !== null && (![ RHSStates.PLUGIN, @@ -78,16 +70,11 @@ function mapDispatchToProps(dispatch: Dispatch) { updateSearchTermsForShortcut, updateSearchType, showSearchResults, - showChannelFiles, - setRhsExpanded, closeRightHandSide, autocompleteChannelsForSearch: autocompleteChannels, autocompleteUsersInTeam: autocompleteUsersInCurrentTeam, updateRhsState, - getMorePostsForSearch, openRHSSearch, - getMoreFilesForSearch, - filterFilesSearchByExt, }, dispatch), }; } diff --git a/webapp/channels/src/components/search/search.tsx b/webapp/channels/src/components/search/search.tsx index ac89988ae28..ff08d5dc249 100644 --- a/webapp/channels/src/components/search/search.tsx +++ b/webapp/channels/src/components/search/search.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect, useState, useRef, useCallback} from 'react'; +import React, {useEffect, useState, useRef} from 'react'; import type {ChangeEvent, FormEvent} from 'react'; import {useIntl} from 'react-intl'; import {useSelector} from 'react-redux'; @@ -9,6 +9,7 @@ import {useSelector} from 'react-redux'; import {getCurrentChannelNameForSearchShortcut} from 'mattermost-redux/selectors/entities/channels'; import HeaderIconWrapper from 'components/channel_header/components/header_icon_wrapper'; +import useSearchResultsActions from 'components/common/hooks/use_search_results_actions'; import SearchBar from 'components/search_bar/search_bar'; import SearchHint from 'components/search_hint/search_hint'; import SearchResults from 'components/search_results'; @@ -26,7 +27,7 @@ import {isDesktopApp, getDesktopVersion, isMacApp} from 'utils/user_agent'; import type {SearchType} from 'types/store/rhs'; -import type {Props, SearchFilterType} from './types'; +import type {Props} from './types'; interface SearchHintOption { searchTerm: string; @@ -82,12 +83,7 @@ const Search = ({ autocompleteChannelsForSearch, autocompleteUsersInTeam, closeRightHandSide, - filterFilesSearchByExt, - getMoreFilesForSearch, - getMorePostsForSearch, openRHSSearch, - setRhsExpanded, - showChannelFiles, showSearchResults, updateRhsState, updateSearchTeam, @@ -104,13 +100,11 @@ const Search = ({ isPinnedPosts, isRhsExpanded, isSearchingTerm, - searchTeam, searchTerms = '', searchType, searchVisible, channelDisplayName = '', children, - currentChannel, enableFindShortcut, getFocus, hideSearchBar, @@ -129,7 +123,7 @@ const Search = ({ const [visibleSearchHintOptions, setVisibleSearchHintOptions] = useState( determineVisibleSearchHintOptions(searchTerms, searchType), ); - const [searchFilterType, setSearchFilterType] = useState('all'); + const searchActions = useSearchResultsActions(); const suggestionProviders = useRef([ new SearchDateProvider(), @@ -190,22 +184,6 @@ const Search = ({ } }, [isMobileView, searchTerms]); - const getMorePostsForSearchCallback = useCallback(() => { - let team = searchTeam; - if (isMentionSearch) { - team = ''; - } - getMorePostsForSearch(team); - }, [searchTeam, isMentionSearch, getMorePostsForSearch]); - - const getMoreFilesForSearchCallback = useCallback(() => { - let team = searchTeam; - if (isMentionSearch) { - team = ''; - } - getMoreFilesForSearch(team); - }, [searchTeam, isMentionSearch, getMoreFilesForSearch]); - // handle cloding of rhs-flyout const handleClose = (): void => closeRightHandSide(); @@ -248,19 +226,9 @@ const Search = ({ }; const handleUpdateSearchTeamFromResult = async (teamId: string) => { - updateSearchTeam(teamId); - const newTerms = searchTerms. - replace(/\bin:[^\s]*/gi, '').replace(/\s{2,}/g, ' '). - replace(/\bfrom:[^\s]*/gi, '').replace(/\s{2,}/g, ' '); - - if (newTerms.trim() !== searchTerms.trim()) { - updateSearchTerms(newTerms); - } - - handleSearch().then(() => { - setKeepInputFocused(false); - setFocused(false); - }); + searchActions.updateSearchTeam(teamId); + setKeepInputFocused(false); + setFocused(false); }; const handleUpdateSearchTerms = (terms: string): void => { @@ -373,44 +341,6 @@ const Search = ({ updateSearchType(''); }; - const handleShrink = (): void => { - setRhsExpanded(false); - }; - - const handleSetSearchFilter = (filterType: SearchFilterType): void => { - switch (filterType) { - case 'documents': - filterFilesSearchByExt(['doc', 'pdf', 'docx', 'odt', 'rtf', 'txt']); - break; - case 'spreadsheets': - filterFilesSearchByExt(['xls', 'xlsx', 'ods']); - break; - case 'presentations': - filterFilesSearchByExt(['ppt', 'pptx', 'odp']); - break; - case 'code': - filterFilesSearchByExt(['py', 'go', 'java', 'kt', 'c', 'cpp', 'h', 'html', 'js', 'ts', 'cs', 'vb', 'php', 'pl', 'r', 'rb', 'sql', 'swift', 'json']); - break; - case 'images': - filterFilesSearchByExt(['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'svg', 'xcf']); - break; - case 'audio': - filterFilesSearchByExt(['ogg', 'mp3', 'wav', 'flac']); - break; - case 'video': - filterFilesSearchByExt(['ogm', 'mp4', 'avi', 'webm', 'mov', 'mkv', 'mpeg', 'mpg']); - break; - default: - filterFilesSearchByExt([]); - } - setSearchFilterType(filterType); - if (isChannelFiles && currentChannel) { - showChannelFiles(currentChannel.id); - } else { - showSearchResults(false); - } - }; - const setHoverHintIndex = (_highlightedSearchHintIndex: number): void => { setHighlightedSearchHintIndex(_highlightedSearchHintIndex); setIndexChangedViaKeyPress(false); @@ -547,18 +477,16 @@ const Search = ({ isFlaggedPosts={isFlaggedPosts} isPinnedPosts={isPinnedPosts} isChannelFiles={isChannelFiles} - shrink={handleShrink} channelDisplayName={channelDisplayName} isOpened={isSideBarRightOpen} updateSearchTerms={handleAddSearchTerm} updateSearchTeam={handleUpdateSearchTeamFromResult} handleSearchHintSelection={handleSearchHintSelection} isSideBarExpanded={isRhsExpanded} - getMorePostsForSearch={getMorePostsForSearchCallback} - getMoreFilesForSearch={getMoreFilesForSearchCallback} - setSearchFilterType={handleSetSearchFilter} - searchFilterType={searchFilterType} - setSearchType={(value: SearchType) => updateSearchType(value)} + getMorePostsForSearch={searchActions.getMorePostsForSearch} + getMoreFilesForSearch={searchActions.getMoreFilesForSearch} + setSearchFilterType={searchActions.setSearchFilterType} + searchFilterType={searchActions.searchFilterType} searchType={searchType || 'messages'} crossTeamSearchEnabled={crossTeamSearchEnabled} /> diff --git a/webapp/channels/src/components/search/types.ts b/webapp/channels/src/components/search/types.ts index fa663bff818..52529bdfd5e 100644 --- a/webapp/channels/src/components/search/types.ts +++ b/webapp/channels/src/components/search/types.ts @@ -26,7 +26,6 @@ export type StateProps = { isRhsExpanded: boolean; isSearchingTerm: boolean; searchTerms: string; - searchTeam: string; searchType: SearchType; searchVisible: boolean; hideMobileSearchBarInRHS: boolean; @@ -34,7 +33,6 @@ export type StateProps = { isFlaggedPosts: boolean; isPinnedPosts: boolean; isChannelFiles: boolean; - currentChannel?: Channel; isMobileView: boolean; crossTeamSearchEnabled: boolean; } @@ -46,16 +44,11 @@ export type DispatchProps = { updateSearchTermsForShortcut: () => void; updateSearchType: (searchType: string) => Action; showSearchResults: (isMentionSearch: boolean) => unknown; - showChannelFiles: (channelId: string) => void; - setRhsExpanded: (expanded: boolean) => Action; closeRightHandSide: () => void; autocompleteChannelsForSearch: (term: string, teamId: string, success?: (channels: Channel[]) => void, error?: (err: ServerError) => void) => void; autocompleteUsersInTeam: (username: string) => Promise; updateRhsState: (rhsState: string) => void; - getMorePostsForSearch: (teamId: string) => void; openRHSSearch: () => void; - getMoreFilesForSearch: (teamId: string) => void; - filterFilesSearchByExt: (extensions: string[]) => void; }; } diff --git a/webapp/channels/src/components/search_results/search_results.test.tsx b/webapp/channels/src/components/search_results/search_results.test.tsx index 371bca44025..af6e9a55993 100644 --- a/webapp/channels/src/components/search_results/search_results.test.tsx +++ b/webapp/channels/src/components/search_results/search_results.test.tsx @@ -1,10 +1,186 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {arePropsEqual} from 'components/search_results/search_results'; +import {within} from '@testing-library/react'; +import React from 'react'; + +import SearchResults, {arePropsEqual} from 'components/search_results/search_results'; + +import {renderWithContext, screen} from 'tests/react_testing_utils'; +import {getHistory} from 'utils/browser_history'; +import {popoutRhsSearch} from 'utils/popouts/popout_windows'; +import {TestHelper} from 'utils/test_helper'; + +import type {Props} from './types'; + +jest.mock('utils/popouts/popout_windows', () => ({ + popoutRhsSearch: jest.fn(), + canPopout: () => true, +})); + +jest.mock('utils/browser_history', () => ({ + getHistory: jest.fn(() => ({push: jest.fn()})), +})); + +jest.mock('components/search_results_header', () => ({ + __esModule: true, + default: (props: {newWindowHandler?: () => void; children: React.ReactNode}) => ( +
+ {props.children} + {props.newWindowHandler && ( +
+ ), +})); + +window.HTMLElement.prototype.scrollTo = jest.fn(); describe('components/SearchResults', () => { - describe('shouldRenderFromProps', () => { + const team = TestHelper.getTeamMock({id: 'team1', name: 'test-team'}); + const channel = TestHelper.getChannelMock({ + id: 'channel1', + name: 'test-channel', + display_name: 'Test Channel', + team_id: team.id, + }); + + const baseState = { + entities: { + channels: { + currentChannelId: channel.id, + channels: {[channel.id]: channel}, + channelsInTeam: {[team.id]: new Set([channel.id])}, + myMembers: {}, + }, + teams: { + currentTeamId: team.id, + teams: {[team.id]: team}, + myMembers: {[team.id]: {}}, + }, + general: { + config: {}, + license: {}, + serverVersion: '', + }, + users: { + currentUserId: 'user1', + profiles: {}, + }, + preferences: {myPreferences: {}}, + roles: {roles: {}}, + }, + views: { + rhs: { + rhsState: 'search', + isSidebarExpanded: false, + searchTeam: team.id, + searchTerms: 'hello', + }, + }, + plugins: { + components: {}, + }, + }; + + const baseProps: Props = { + results: [], + fileResults: [], + matches: {}, + searchPage: 0, + searchTerms: 'hello', + searchSelectedType: 'messages', + isSearchingTerm: false, + isSearchingFlaggedPost: false, + isSearchingPinnedPost: false, + isSearchGettingMore: false, + isSearchAtEnd: true, + isSearchFilesAtEnd: true, + currentTeamName: team.name, + channelDisplayName: '', + crossTeamSearchEnabled: false, + isChannelFiles: false, + isFlaggedPosts: false, + isMentionSearch: false, + isOpened: true, + isPinnedPosts: false, + isSideBarExpanded: false, + searchFilterType: 'all', + searchType: 'messages', + getMoreFilesForSearch: jest.fn(), + getMorePostsForSearch: jest.fn(), + setSearchFilterType: jest.fn(), + updateSearchTeam: jest.fn(), + updateSearchTerms: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(getHistory).mockReturnValue({push: jest.fn()} as any); + }); + + function renderSearchResults(propOverrides?: Partial) { + const props = {...baseProps, ...propOverrides}; + return renderWithContext( + , + baseState, + ); + } + + describe('newWindowHandler', () => { + function clickPopout(propOverrides?: Partial) { + const {container} = renderSearchResults(propOverrides); + within(container).getByTestId('popout-button').click(); + } + + test('should resolve mode from boolean props and pass channel only when needed', () => { + clickPopout({isMentionSearch: true}); + expect(jest.mocked(popoutRhsSearch)).toHaveBeenCalledWith( + expect.any(String), team.name, 'hello', 'mention', 'messages', undefined, team.id, + ); + + jest.clearAllMocks(); + clickPopout({isFlaggedPosts: true}); + expect(jest.mocked(popoutRhsSearch)).toHaveBeenCalledWith( + expect.any(String), team.name, 'hello', 'flag', 'messages', undefined, team.id, + ); + + jest.clearAllMocks(); + clickPopout({isPinnedPosts: true}); + expect(jest.mocked(popoutRhsSearch)).toHaveBeenCalledWith( + expect.any(String), team.name, 'hello', 'pin', 'messages', channel.name, team.id, + ); + + jest.clearAllMocks(); + clickPopout({isChannelFiles: true}); + expect(jest.mocked(popoutRhsSearch)).toHaveBeenCalledWith( + expect.any(String), team.name, 'hello', 'channel-files', 'messages', channel.name, team.id, + ); + + jest.clearAllMocks(); + clickPopout(); + expect(jest.mocked(popoutRhsSearch)).toHaveBeenCalledWith( + expect.any(String), team.name, 'hello', 'search', 'messages', undefined, team.id, + ); + }); + }); + + describe('handleChannelNameClick', () => { + test('should navigate to channel URL when channel display name is clicked', () => { + const pushMock = jest.fn(); + jest.mocked(getHistory).mockReturnValue({push: pushMock} as any); + + renderSearchResults({channelDisplayName: 'Test Channel'}); + screen.getByText('Test Channel').click(); + + expect(pushMock).toHaveBeenCalledWith(`/${team.name}/channels/${channel.name}`); + }); + }); + + describe('arePropsEqual', () => { const result1 = {test: 'test'}; const result2 = {test: 'test'}; const fileResult1 = {test: 'test'}; @@ -18,9 +194,6 @@ describe('components/SearchResults', () => { fileResults, }; - // Using a lot of anys here since the function is only used by SearchResults so the parameters are bound to its props - // But the tests are written using arbitrary props - test('should not render', () => { expect(arePropsEqual(props as any, {...props} as any)).toBeTruthy(); expect(arePropsEqual(props as any, {...props, results: [result1, result2]} as any)).toBeTruthy(); diff --git a/webapp/channels/src/components/search_results/search_results.tsx b/webapp/channels/src/components/search_results/search_results.tsx index be7544de08e..17f62bc6301 100644 --- a/webapp/channels/src/components/search_results/search_results.tsx +++ b/webapp/channels/src/components/search_results/search_results.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import classNames from 'classnames'; -import React, {useEffect, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import {useIntl, FormattedMessage, defineMessage} from 'react-intl'; import {useSelector} from 'react-redux'; @@ -10,22 +10,30 @@ import type {FileSearchResultItem as FileSearchResultItemType} from '@mattermost import type {Post} from '@mattermost/types/posts'; import {debounce} from 'mattermost-redux/actions/helpers'; +import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; +import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams'; import {isDateLine, getDateForDateLine} from 'mattermost-redux/utils/post_list'; import {getFilesDropdownPluginMenuItems} from 'selectors/plugins'; +import {getSearchTeam} from 'selectors/rhs'; import Scrollbars from 'components/common/scrollbars'; import FileSearchResultItem from 'components/file_search_results'; import NoResultsIndicator from 'components/no_results_indicator/no_results_indicator'; import {NoResultsVariant} from 'components/no_results_indicator/types'; import DateSeparator from 'components/post_view/date_separator'; +import {getSearchPopoutTitle} from 'components/rhs_search_popout/title'; import SearchHint from 'components/search_hint/search_hint'; import SearchResultsHeader from 'components/search_results_header'; import LoadingWrapper from 'components/widgets/loading/loading_wrapper'; -import {searchHintOptions, DataSearchTypes} from 'utils/constants'; +import {getHistory} from 'utils/browser_history'; +import {searchHintOptions, DataSearchTypes, RHSStates} from 'utils/constants'; import {isFileAttachmentsEnabled} from 'utils/file_utils'; +import {popoutRhsSearch} from 'utils/popouts/popout_windows'; + +import type {RhsState, SearchType} from 'types/store/rhs'; import FilesFilterMenu from './files_filter_menu'; import MessageOrFileSelector from './messages_or_files_selector'; @@ -48,6 +56,9 @@ const SearchResults: React.FC = (props: Props): JSX.Element => { const [searchType, setSearchType] = useState(props.searchType); const filesDropdownPluginMenuItems = useSelector(getFilesDropdownPluginMenuItems); const config = useSelector(getConfig); + const currentChannel = useSelector(getCurrentChannel); + const currentTeam = useSelector(getCurrentTeam); + const searchTeamId = useSelector(getSearchTeam); const intl = useIntl(); useEffect(() => { @@ -227,10 +238,42 @@ const SearchResults: React.FC = (props: Props): JSX.Element => { const formattedTitle = intl.formatMessage(titleDescriptor); const handleOptionSelection = (term: string): void => { - handleSearchHintSelection(); + handleSearchHintSelection?.(); updateSearchTerms(term); }; + const newWindowHandler = useCallback(() => { + let mode: NonNullable = RHSStates.SEARCH; + if (isMentionSearch) { + mode = RHSStates.MENTION; + } else if (isFlaggedPosts) { + mode = RHSStates.FLAG; + } else if (isPinnedPosts) { + mode = RHSStates.PIN; + } else if (isChannelFiles) { + mode = RHSStates.CHANNEL_FILES; + } + + const needsChannel = isPinnedPosts || isChannelFiles; + const popoutTitle = getSearchPopoutTitle(mode); + + popoutRhsSearch( + intl.formatMessage(popoutTitle, {serverName: '{serverName}', channelName: '{channelName}', searchTerms}), + currentTeam?.name ?? '', + searchTerms, + mode, + searchType as SearchType, + needsChannel ? currentChannel?.name : undefined, + searchTeamId, + ); + }, [isMentionSearch, isFlaggedPosts, isPinnedPosts, isChannelFiles, intl, searchTerms, searchType, currentTeam?.name, currentChannel?.name, searchTeamId]); + + const handleChannelNameClick = useCallback(() => { + if (currentTeam?.name && currentChannel?.name) { + getHistory().push(`/${currentTeam.name}/channels/${currentChannel.name}`); + } + }, [currentTeam?.name, currentChannel?.name]); + switch (true) { case isLoading: contentItems = ( @@ -341,11 +384,20 @@ const SearchResults: React.FC = (props: Props): JSX.Element => { id='searchContainer' className='SearchResults sidebar-right__body' > - +

{formattedTitle}

- {props.channelDisplayName &&
{props.channelDisplayName}
} + {props.channelDisplayName && + + }
{isMessagesSearch && = (props: Props): JSX.Element => { />
} - + { continue; } - if (nextProps[key] !== props[key]) { + if (nextProps[key as keyof Props] !== props[key as keyof Props]) { return false; } } diff --git a/webapp/channels/src/components/search_results/types.ts b/webapp/channels/src/components/search_results/types.ts index 24f3552501c..9586784637c 100644 --- a/webapp/channels/src/components/search_results/types.ts +++ b/webapp/channels/src/components/search_results/types.ts @@ -1,9 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import type React from 'react'; -import type {IntlShape} from 'react-intl'; - import type {FileInfo} from '@mattermost/types/files'; import type {Post} from '@mattermost/types/posts'; @@ -12,31 +9,32 @@ import type {SearchFilterType} from 'components/search/types'; import type {SearchType} from 'types/store/rhs'; export type OwnProps = { - [key: string]: any; - isSideBarExpanded: boolean; - isMentionSearch: boolean; - isFlaggedPosts: boolean; - isPinnedPosts: boolean; - updateSearchTerms: (terms: string) => void; - getMorePostsForSearch: () => void; - getMoreFilesForSearch: () => void; - shrink: () => void; + channelDisplayName?: string; + crossTeamSearchEnabled: boolean; isCard?: boolean; + isChannelFiles: boolean; + isFlaggedPosts: boolean; + isMentionSearch: boolean; isOpened?: boolean; - channelDisplayName?: string; - children?: React.ReactNode; - searchType: SearchType; - setSearchType: (searchType: SearchType) => void; + isPinnedPosts: boolean; + isSideBarExpanded: boolean; searchFilterType: SearchFilterType; + searchType: SearchType; + + getMoreFilesForSearch: () => void; + getMorePostsForSearch: () => void; + handleSearchHintSelection?: () => void; setSearchFilterType: (filterType: SearchFilterType) => void; updateSearchTeam: (teamId: string) => void; - crossTeamSearchEnabled: boolean; + updateSearchTerms: (terms: string) => void; }; export type StateProps = { + currentTeamName: string; results: Array; fileResults: FileInfo[]; matches: Record; + searchPage: number; searchTerms: string; searchSelectedType: string; isSearchingTerm: boolean; @@ -47,8 +45,4 @@ export type StateProps = { isSearchFilesAtEnd: boolean; }; -export type IntlProps = { - intl: IntlShape; -}; - -export type Props = OwnProps & StateProps & IntlProps; +export type Props = OwnProps & StateProps; diff --git a/webapp/channels/src/components/thread_popout/thread_popout.tsx b/webapp/channels/src/components/thread_popout/thread_popout.tsx index 6dc1910530e..897e668fff2 100644 --- a/webapp/channels/src/components/thread_popout/thread_popout.tsx +++ b/webapp/channels/src/components/thread_popout/thread_popout.tsx @@ -1,10 +1,10 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect, useMemo} from 'react'; +import React, {useCallback, useEffect, useMemo} from 'react'; import {defineMessage} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; -import {useParams} from 'react-router-dom'; +import {useLocation, useParams} from 'react-router-dom'; import type {Channel} from '@mattermost/types/channels'; @@ -21,6 +21,7 @@ import {makeGetThreadOrSynthetic} from 'mattermost-redux/selectors/entities/thre import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {loadStatusesByIds} from 'actions/status_actions'; +import {selectPost} from 'actions/views/rhs'; import {markThreadAsRead} from 'actions/views/threads'; import {usePost} from 'components/common/hooks/usePost'; @@ -29,6 +30,7 @@ import ThreadPane from 'components/threading/global_threads/thread_pane'; import ThreadViewer from 'components/threading/thread_viewer'; import UnreadsStatusHandler from 'components/unreads_status_handler'; +import {getHistory} from 'utils/browser_history'; import {Constants} from 'utils/constants'; import usePopoutTitle from 'utils/popouts/use_popout_title'; import {isDesktopApp} from 'utils/user_agent'; @@ -54,6 +56,8 @@ export default function ThreadPopout() { const getThreadOrSynthetic = useMemo(() => makeGetThreadOrSynthetic(), []); const {postId, team: teamName} = useParams<{team: string; postId: string}>(); + const location = useLocation(); + const returnTo = new URLSearchParams(location.search).get('returnTo'); const currentUserId = useSelector(getCurrentUserId); const post = usePost(postId); @@ -70,6 +74,12 @@ export default function ThreadPopout() { usePopoutTitle(getThreadPopoutTitle(channel)); + useEffect(() => { + if (post) { + dispatch(selectPost(post)); + } + }, [dispatch, post]); + const channelId = post?.channel_id; useEffect(() => { if (channelId) { @@ -126,6 +136,12 @@ export default function ThreadPopout() { }; }, []); + const backAction = useCallback(() => { + if (returnTo) { + getHistory().replace(returnTo); + } + }, [returnTo]); + if (!thread) { return null; } @@ -135,6 +151,7 @@ export default function ThreadPopout() { {isDesktopApp() && } void; }; const ThreadPane = ({ thread, children, + backAction, }: Props) => { const intl = useIntl(); const {formatMessage} = intl; @@ -66,7 +69,13 @@ const ThreadPane = ({ const channel = useSelector((state: GlobalState) => getChannel(state, channelId)); const post = useSelector((state: GlobalState) => getPost(state, thread.id)); const postsInThread = useSelector((state: GlobalState) => getPostsForThread(state, post.id)); - const selectHandler = useCallback(() => select(), []); + const selectHandler = useCallback(() => { + if (backAction) { + backAction(); + return; + } + select(); + }, [select, backAction]); let unreadTimestamp = post.edit_at || post.create_at; // if we have the whole thread, get the posts in it, sorted from newest to oldest. @@ -103,7 +112,7 @@ const ThreadPane = ({ heading={( <>