From d18f3dd278889d2644007f96fda3ccc7ebe0d4c6 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:39:53 +0200 Subject: [PATCH 1/4] [MM-69748] Add Hide Archived toggle to the Browse Channels modal (#37500) * [MM-69748] Add Hide Archived toggle to Browse Channels modal Archived channels previously appeared mixed in with active channels in the Browse Channels modal under the default "All channel types" filter (most visibly in search results), with no way to filter them out. Add a "Hide Archived" checkbox next to "Hide Joined" that hides archived channels by default. The toggle is backed by a persisted preference and is not shown when the explicit "Archived channels" filter is selected. Co-authored-by: mattermost-code * [MM-69748] Add tests for Hide Archived toggle in Browse Channels Cover the default-hidden behavior, toggling to show archived channels, the explicit Archived filter override, preference persistence, and the checkbox being hidden on the Archived filter. Co-authored-by: mattermost-code * [MM-69748] Strengthen Hide Archived tests per review Add non-search browse-list coverage for getActiveChannels in both directions, assert only archived rows are filtered from search, and query the checkbox by accessible label. Co-authored-by: mattermost-code * Fix archived channel leak/duplication in Browse Channels filters - Exclude archived private channels from the private channels selector so they no longer bypass the Hide Archived toggle or duplicate with the archived list. - Fold archived channels back into the Public/Private lists (by type) when the toggle is off, matching the All list behavior. - Keep the search Public filter governed by the shared hide-archived block instead of hard-excluding archived public channels. - Add regression tests for the mixed public/private archived scenario. * Satisfy eslint style rules in browse channels changes * Update Browse Channels E2E for Hide Archived default Align Cypress search expectations and Playwright tab/aria snapshots with the new Hide Archived checkbox (on by default). Co-authored-by: mattermost-code --------- Co-authored-by: Cursor Agent Co-authored-by: mattermost-code --- .../channels/channel/browse_channels_spec.ts | 12 +- .../channels/browse_channels_modal.ts | 2 + .../channels/browse_channels_dialog.spec.ts | 3 + .../searchable_channel_list.test.tsx.snap | 68 ++++++ .../browse_channels.test.tsx.snap | 68 ++++++ .../browse_channels/browse_channels.scss | 7 +- .../browse_channels/browse_channels.test.tsx | 225 ++++++++++++++++++ .../browse_channels/browse_channels.tsx | 45 +++- .../src/components/browse_channels/index.ts | 9 +- .../searchable_channel_list.test.tsx | 46 +++- .../components/searchable_channel_list.tsx | 34 +++ webapp/channels/src/i18n/en.json | 2 + webapp/channels/src/utils/constants.tsx | 1 + 13 files changed, 510 insertions(+), 12 deletions(-) diff --git a/e2e-tests/cypress/tests/integration/channels/channel/browse_channels_spec.ts b/e2e-tests/cypress/tests/integration/channels/channel/browse_channels_spec.ts index 513d5c5f330f..8c4c4575b95c 100644 --- a/e2e-tests/cypress/tests/integration/channels/channel/browse_channels_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/channel/browse_channels_spec.ts @@ -245,11 +245,21 @@ describe('Channels', () => { cy.wrap(el).should('contain', channelType.all); }); - // * Users should be able to type and search + // * Hide Archived is on by default, so search only returns active matches + cy.get('#hideArchivedPreferenceCheckbox').should('be.visible').and('have.attr', 'aria-checked', 'true'); cy.get('#searchChannelsTextbox').should('be.visible').type('iv').wait(TIMEOUTS.HALF_SEC); + cy.get('#moreChannelsList').should('be.visible').children().should('have.length', 1); + cy.get('#moreChannelsList').should('be.visible').within(() => { + cy.findByText(newChannel.display_name).should('be.visible'); + cy.findByText(testArchivedChannel.display_name).should('not.exist'); + }); + + // # Uncheck Hide Archived so archived matches appear in All search results + cy.get('#hideArchivedPreferenceCheckbox').click(); cy.get('#moreChannelsList').should('be.visible').children().should('have.length', 2); cy.get('#moreChannelsList').should('be.visible').within(() => { cy.findByText(newChannel.display_name).should('be.visible'); + cy.findByText(testArchivedChannel.display_name).should('be.visible'); }); cy.get('#browseChannelsModal').should('be.visible'); diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/browse_channels_modal.ts b/e2e-tests/playwright/lib/src/ui/components/channels/browse_channels_modal.ts index b539aff730b0..f5968a6f2934 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/browse_channels_modal.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/browse_channels_modal.ts @@ -8,6 +8,7 @@ export default class BrowseChannelsModal { readonly container: Locator; readonly createNewChannelButton: Locator; + readonly hideArchivedCheckbox: Locator; readonly hideJoinedCheckbox: Locator; readonly searchInput: Locator; @@ -17,6 +18,7 @@ export default class BrowseChannelsModal { this.container = container; this.createNewChannelButton = container.getByRole('button', {name: 'Create New Channel'}); + this.hideArchivedCheckbox = container.getByRole('checkbox', {name: 'Hide Archived'}); this.hideJoinedCheckbox = container.getByRole('checkbox', {name: 'Hide Joined'}); this.searchInput = container.getByRole('textbox', {name: 'Search channels'}); diff --git a/e2e-tests/playwright/specs/accessibility/channels/browse_channels_dialog.spec.ts b/e2e-tests/playwright/specs/accessibility/channels/browse_channels_dialog.spec.ts index 2583ecbab75f..908e899617be 100644 --- a/e2e-tests/playwright/specs/accessibility/channels/browse_channels_dialog.spec.ts +++ b/e2e-tests/playwright/specs/accessibility/channels/browse_channels_dialog.spec.ts @@ -55,6 +55,7 @@ test( await hideJoinedCheckbox.click(); // # Focus on Create Channel button and tab through elements + // Tab order: Close → Search → Channel type filter → Hide Archived → Hide Joined → first channel const createChannelButton = dialog.createNewChannelButton; await createChannelButton.focus(); await page.keyboard.press('Tab'); @@ -62,6 +63,7 @@ test( await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); // * Verify channel name is highlighted and has proper aria-label await dialog.toHaveChannelAsNthResult(channel1.name, 0); @@ -116,6 +118,7 @@ test( - /status: \\d+ Results/ - status: Channel type filter set to All - button "Channel type filter" + - checkbox "Hide archived channels" [checked]: Hide Archived - checkbox "Hide joined channels": Hide Joined - search `); diff --git a/webapp/channels/src/components/__snapshots__/searchable_channel_list.test.tsx.snap b/webapp/channels/src/components/__snapshots__/searchable_channel_list.test.tsx.snap index aa2f6cca1058..39deb4f4f838 100644 --- a/webapp/channels/src/components/__snapshots__/searchable_channel_list.test.tsx.snap +++ b/webapp/channels/src/components/__snapshots__/searchable_channel_list.test.tsx.snap @@ -75,6 +75,74 @@ exports[`components/SearchableChannelList should match init snapshot 1`] = ` /> +
+
{ teamName: 'team_name', channelsRequestStarted: false, shouldHideJoinedChannels: false, + shouldHideArchivedChannels: true, accessControlEnabled: false, myChannelMemberships: { 'channel-id-3': TestHelper.getChannelMembershipMock({ @@ -714,6 +715,230 @@ describe('components/BrowseChannels', () => { expect(baseProps.actions.getRecommendedChannelsForUser).not.toHaveBeenCalled(); }); + test('hides archived channels from All search results when shouldHideArchivedChannels is true', async () => { + const searchAllChannels = jest.fn(channelActions.searchAllChannels); + const props = {...baseProps, shouldHideArchivedChannels: true, actions: {...baseProps.actions, searchAllChannels}}; + renderWithContext(); + + await act(async () => { + await Promise.resolve(); + }); + + const searchInput = screen.getByPlaceholderText('Search channels'); + await user.type(searchInput, 'channel'); + + await act(async () => { + jest.runOnlyPendingTimers(); + await Promise.resolve(); + }); + + // The active public and private channels are shown, but the archived + // channel returned by the search is filtered out under the default + // "All" filter — proving only archived rows are removed. + await waitFor(() => { + expect(screen.getByText('Channel 1')).toBeInTheDocument(); + expect(screen.getByText('Private')).toBeInTheDocument(); + }); + expect(screen.queryByText('Archived')).not.toBeInTheDocument(); + }); + + test('hides archived channels from the browse list by default (no search)', async () => { + renderWithContext(); + + await act(async () => { + await Promise.resolve(); + }); + + // baseProps.shouldHideArchivedChannels is true, so the archived channel + // ('channel-2') is absent from the default All browse list. + expect(screen.getByText('Default Channel')).toBeInTheDocument(); + expect(screen.queryByText('channel-2')).not.toBeInTheDocument(); + }); + + test('shows archived channels in the browse list when shouldHideArchivedChannels is false (no search)', async () => { + const props = {...baseProps, shouldHideArchivedChannels: false}; + renderWithContext(); + + await act(async () => { + await Promise.resolve(); + }); + + // With the toggle off, the archived channel is mixed into the All list. + expect(screen.getByText('Default Channel')).toBeInTheDocument(); + expect(screen.getByText('channel-2')).toBeInTheDocument(); + }); + + test('shows archived channels in All search results when shouldHideArchivedChannels is false', async () => { + const searchAllChannels = jest.fn(channelActions.searchAllChannels); + const props = {...baseProps, shouldHideArchivedChannels: false, actions: {...baseProps.actions, searchAllChannels}}; + renderWithContext(); + + await act(async () => { + await Promise.resolve(); + }); + + const searchInput = screen.getByPlaceholderText('Search channels'); + await user.type(searchInput, 'channel'); + + await act(async () => { + jest.runOnlyPendingTimers(); + await Promise.resolve(); + }); + + // With the toggle off, archived channels are mixed back into the results. + await waitFor(() => { + expect(screen.getByText('Channel 1')).toBeInTheDocument(); + expect(screen.getByText('Archived')).toBeInTheDocument(); + }); + }); + + // Regression: archived private channels used to leak in via the + // privateChannels list (which is not filtered by the Hide Archived toggle) + // and, with the toggle off, appear twice — once from privateChannels and + // once from archivedChannels. The container selector now returns only + // active private channels, so archived channels of both types come solely + // from `archivedChannels`. + const archivedPublicChannel = TestHelper.getChannelMock({ + id: 'archived-public-id', + team_id: 'team_1', + display_name: 'Archived Public', + name: 'archived-public', + type: 'O', + delete_at: 123, + }); + + const archivedPrivateChannel = TestHelper.getChannelMock({ + id: 'archived-private-id', + team_id: 'team_1', + display_name: 'Archived Private', + name: 'archived-private', + type: 'P', + delete_at: 456, + }); + + const bothTypesArchivedProps: Props = { + ...baseProps, + channels: [defaultChannel], + privateChannels: [privateChannel], + archivedChannels: [archivedPublicChannel, archivedPrivateChannel], + }; + + test('hides both archived public and private channels from the All list when the toggle is on', async () => { + const props = {...bothTypesArchivedProps, shouldHideArchivedChannels: true}; + renderWithContext(); + + await act(async () => { + await Promise.resolve(); + }); + + expect(screen.getByText('Default Channel')).toBeInTheDocument(); + expect(screen.queryByText('Archived Public')).not.toBeInTheDocument(); + expect(screen.queryByText('Archived Private')).not.toBeInTheDocument(); + }); + + test('shows each archived channel exactly once in the All list when the toggle is off', async () => { + const props = {...bothTypesArchivedProps, shouldHideArchivedChannels: false}; + renderWithContext(); + + await act(async () => { + await Promise.resolve(); + }); + + expect(screen.getByText('Archived Public')).toBeInTheDocument(); + + // Exactly one row — previously the archived private channel rendered + // twice because it came from both privateChannels and archivedChannels. + expect(screen.getAllByText('Archived Private')).toHaveLength(1); + }); + + test('includes archived channels of the matching type under the Public and Private filters when the toggle is off', async () => { + const props = {...bothTypesArchivedProps, shouldHideArchivedChannels: false}; + renderWithContext(); + + await act(async () => { + await Promise.resolve(); + }); + + await user.click(screen.getByLabelText('Channel type filter')); + await user.click(await screen.findByText('Public channels')); + + // Wait on the private row being pruned — "Archived Public" is present in + // both the All and Public views, so it isn't a reliable settle signal. + await waitFor(() => { + expect(screen.queryByText('Archived Private')).not.toBeInTheDocument(); + }); + expect(screen.getByText('Archived Public')).toBeInTheDocument(); + + await user.click(screen.getByLabelText('Channel type filter')); + await user.click(await screen.findByText('Private channels')); + + await waitFor(() => { + expect(screen.queryByText('Archived Public')).not.toBeInTheDocument(); + }); + expect(screen.getByText('Archived Private')).toBeInTheDocument(); + }); + + test('still shows archived channels when the Archived filter is selected even if shouldHideArchivedChannels is true', async () => { + const searchAllChannels = jest.fn(channelActions.searchAllChannels); + const props = {...baseProps, shouldHideArchivedChannels: true, actions: {...baseProps.actions, searchAllChannels}}; + renderWithContext(); + + await act(async () => { + await Promise.resolve(); + }); + + await user.click(screen.getByLabelText('Channel type filter')); + await user.click(await screen.findByText('Archived channels')); + + const searchInput = screen.getByPlaceholderText('Search channels'); + await user.type(searchInput, 'channel'); + + await act(async () => { + jest.runOnlyPendingTimers(); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(screen.getByText('Archived')).toBeInTheDocument(); + }); + expect(screen.queryByText('Channel 1')).not.toBeInTheDocument(); + }); + + test('toggling Hide Archived persists the preference', async () => { + const setGlobalItem = jest.fn(); + const props = {...baseProps, shouldHideArchivedChannels: true, actions: {...baseProps.actions, setGlobalItem}}; + renderWithContext(); + + await act(async () => { + await Promise.resolve(); + }); + + // Starts checked (hidden by default); unchecking persists 'false'. + const hideArchived = screen.getByLabelText('Hide archived channels'); + expect(hideArchived).toHaveAttribute('aria-checked', 'true'); + + await user.click(hideArchived); + + expect(setGlobalItem).toHaveBeenCalledWith('hideArchivedChannels', 'false'); + }); + + test('Hide Archived checkbox is not shown when the Archived filter is selected', async () => { + renderWithContext(); + + await act(async () => { + await Promise.resolve(); + }); + + expect(screen.getByLabelText('Hide archived channels')).toBeInTheDocument(); + + await user.click(screen.getByLabelText('Channel type filter')); + await user.click(await screen.findByText('Archived channels')); + + await waitFor(() => { + expect(screen.queryByLabelText('Hide archived channels')).not.toBeInTheDocument(); + }); + }); + // --------------------------------------------------------------- // Discoverable Private Channels — row state machine + filter chips // --------------------------------------------------------------- diff --git a/webapp/channels/src/components/browse_channels/browse_channels.tsx b/webapp/channels/src/components/browse_channels/browse_channels.tsx index 7a04086701dc..bbb62d41419d 100644 --- a/webapp/channels/src/components/browse_channels/browse_channels.tsx +++ b/webapp/channels/src/components/browse_channels/browse_channels.tsx @@ -87,6 +87,7 @@ export type Props = { channelsRequestStarted?: boolean; myChannelMemberships: RelationOneToOne; shouldHideJoinedChannels: boolean; + shouldHideArchivedChannels: boolean; rhsState?: RhsState; rhsOpen?: boolean; channelsMemberCount?: Record; @@ -363,7 +364,10 @@ export default class BrowseChannels extends React.PureComponent { searchedChannels = channels.filter((c) => c.type === Constants.PRIVATE_CHANNEL && this.canSeePrivateChannel(c)); } if (this.state.filter === Filter.Public) { - searchedChannels = channels.filter((c) => c.type === Constants.OPEN_CHANNEL && c.delete_at === 0); + // Archived public channels are pruned below when the Hide Archived + // toggle is on, so don't force delete_at === 0 here — that would + // hide them even when the toggle is off. + searchedChannels = channels.filter((c) => c.type === Constants.OPEN_CHANNEL); } if (this.state.filter === Filter.Archived) { searchedChannels = channels.filter((c) => c.delete_at !== 0); @@ -385,6 +389,9 @@ export default class BrowseChannels extends React.PureComponent { if (this.state.filter === Filter.MyPendingRequests) { searchedChannels = channels.filter((c) => this.props.myPendingJoinRequests[c.id]); } + if (this.state.filter !== Filter.Archived && this.props.shouldHideArchivedChannels) { + searchedChannels = this.getChannelsWithoutArchived(searchedChannels); + } if (this.props.shouldHideJoinedChannels) { searchedChannels = this.getChannelsWithoutJoined(searchedChannels); } @@ -428,10 +435,18 @@ export default class BrowseChannels extends React.PureComponent { this.props.actions.setGlobalItem(StoragePrefixes.HIDE_JOINED_CHANNELS, shouldHideJoinedChannels.toString()); }; + handleShowArchivedChannelsPreference = (shouldHideArchivedChannels: boolean) => { + // search again when toggling to update search results + this.search(this.state.searchTerm); + this.props.actions.setGlobalItem(StoragePrefixes.HIDE_ARCHIVED_CHANNELS, shouldHideArchivedChannels.toString()); + }; + getChannelsWithoutJoined = (channelList: Channel[]) => channelList.filter((channel) => !this.isMemberOfChannel(channel.id)); + getChannelsWithoutArchived = (channelList: Channel[]) => channelList.filter((channel) => channel.delete_at === 0); + getActiveChannels = () => { - const {channels, archivedChannels, shouldHideJoinedChannels, privateChannels, myPendingJoinRequests} = this.props; + const {channels, archivedChannels, shouldHideJoinedChannels, shouldHideArchivedChannels, privateChannels, myPendingJoinRequests} = this.props; const {search, searchedChannels, filter, recommendedChannels, discoverableChannels} = this.state; // Discoverable private channels the user is not yet a member of. These @@ -456,11 +471,23 @@ export default class BrowseChannels extends React.PureComponent { // appear in the default browse view, not only under the Discoverable // filter. privateChannels-sourced rows are already in allChannels. const extraDiscoverable = discoverableChannels.filter((c) => !privateChannels.some((p) => p.id === c.id)); - const allChannels = channels.concat(privateChannels, extraDiscoverable).sort((a, b) => a.display_name.localeCompare(b.display_name)); + + // `archivedChannels` holds both archived public and private channels. + // When the toggle is on they are dropped everywhere except the explicit + // Archived filter; when it is off they are folded back into the matching + // type-specific list so Public/Private show their archived rows too. + const visibleArchivedChannels = shouldHideArchivedChannels ? [] : archivedChannels; + const publicChannels = channels.concat(visibleArchivedChannels.filter((c) => c.type === Constants.OPEN_CHANNEL)); + const visiblePrivateChannels = privateChannels.concat(visibleArchivedChannels.filter((c) => c.type === Constants.PRIVATE_CHANNEL)); + + const allChannels = channels. + concat(privateChannels, extraDiscoverable). + concat(visibleArchivedChannels). + sort((a, b) => a.display_name.localeCompare(b.display_name)); const allChannelsWithoutJoined = this.getChannelsWithoutJoined(allChannels); - const publicChannelsWithoutJoined = this.getChannelsWithoutJoined(channels); + const publicChannelsWithoutJoined = this.getChannelsWithoutJoined(publicChannels); const archivedChannelsWithoutJoined = this.getChannelsWithoutJoined(archivedChannels); - const privateChannelsWithoutJoined = this.getChannelsWithoutJoined(privateChannels); + const privateChannelsWithoutJoined = this.getChannelsWithoutJoined(visiblePrivateChannels); const recommendedChannelsWithoutJoined = this.getChannelsWithoutJoined(recommendedChannels); // Channels the current user has pending requests against. The @@ -479,8 +506,8 @@ export default class BrowseChannels extends React.PureComponent { const filterOptions: Record = { [Filter.All]: shouldHideJoinedChannels ? allChannelsWithoutJoined : allChannels, [Filter.Archived]: shouldHideJoinedChannels ? archivedChannelsWithoutJoined : archivedChannels, - [Filter.Private]: shouldHideJoinedChannels ? privateChannelsWithoutJoined : privateChannels, - [Filter.Public]: shouldHideJoinedChannels ? publicChannelsWithoutJoined : channels, + [Filter.Private]: shouldHideJoinedChannels ? privateChannelsWithoutJoined : visiblePrivateChannels, + [Filter.Public]: shouldHideJoinedChannels ? publicChannelsWithoutJoined : publicChannels, [Filter.Recommended]: shouldHideJoinedChannels ? recommendedChannelsWithoutJoined : recommendedChannels, [Filter.Discoverable]: discoverableNonMember, [Filter.MyPendingRequests]: myPending, @@ -498,7 +525,7 @@ export default class BrowseChannels extends React.PureComponent { }; render() { - const {teamId, channelsRequestStarted, shouldHideJoinedChannels} = this.props; + const {teamId, channelsRequestStarted, shouldHideJoinedChannels, shouldHideArchivedChannels} = this.props; const {search, serverError: serverErrorState, searching} = this.state; this.activeChannels = this.getActiveChannels(); @@ -566,6 +593,8 @@ export default class BrowseChannels extends React.PureComponent { closeModal={this.props.actions.closeModal} hideJoinedChannelsPreference={this.handleShowJoinedChannelsPreference} rememberHideJoinedChannelsChecked={shouldHideJoinedChannels} + hideArchivedChannelsPreference={this.handleShowArchivedChannelsPreference} + rememberHideArchivedChannelsChecked={shouldHideArchivedChannels} channelsMemberCount={this.props.channelsMemberCount} /> {serverError} diff --git a/webapp/channels/src/components/browse_channels/index.ts b/webapp/channels/src/components/browse_channels/index.ts index 03cdbc62fe77..6492617f345c 100644 --- a/webapp/channels/src/components/browse_channels/index.ts +++ b/webapp/channels/src/components/browse_channels/index.ts @@ -52,12 +52,18 @@ const getArchivedOtherChannels = createSelector( const getPrivateChannelsSelector = createSelector( 'getPrivateChannelsSelector', getChannelsInCurrentTeam, - (channels: Channel[]) => channels && channels.filter((c) => c.type === Constants.PRIVATE_CHANNEL), + + // Active private channels only. Archived private channels are surfaced + // exclusively through `archivedChannels`; including them here would both + // leak them past the Hide Archived toggle and double them up alongside the + // archived list. + (channels: Channel[]) => channels && channels.filter((c) => c.delete_at === 0 && c.type === Constants.PRIVATE_CHANNEL), ); function mapStateToProps(state: GlobalState) { const team = getCurrentTeam(state); const getGlobalItem = makeGetGlobalItem(StoragePrefixes.HIDE_JOINED_CHANNELS, 'false'); + const getHideArchivedItem = makeGetGlobalItem(StoragePrefixes.HIDE_ARCHIVED_CHANNELS, 'true'); return { channels: getChannelsWithoutArchived(state) || [], @@ -69,6 +75,7 @@ function mapStateToProps(state: GlobalState) { channelsRequestStarted: state.requests.channels.getChannels.status === RequestStatus.STARTED, myChannelMemberships: getMyChannelMemberships(state) || {}, shouldHideJoinedChannels: getGlobalItem(state) === 'true', + shouldHideArchivedChannels: getHideArchivedItem(state) === 'true', rhsState: getRhsState(state), rhsOpen: getIsRhsOpen(state), channelsMemberCount: getChannelsMemberCountSelector(state), diff --git a/webapp/channels/src/components/searchable_channel_list.test.tsx b/webapp/channels/src/components/searchable_channel_list.test.tsx index 4e300c92baed..a9604d97df4e 100644 --- a/webapp/channels/src/components/searchable_channel_list.test.tsx +++ b/webapp/channels/src/components/searchable_channel_list.test.tsx @@ -13,7 +13,7 @@ import {compassIconForName} from 'components/channel_type_icon'; import {SearchableChannelList} from 'components/searchable_channel_list'; import {type MockIntl} from 'tests/helpers/intl-test-helper'; -import {renderWithContext, screen} from 'tests/react_testing_utils'; +import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils'; import {Filter} from './browse_channels/browse_channels'; @@ -48,10 +48,12 @@ describe('components/SearchableChannelList', () => { toggleArchivedChannels: jest.fn(), closeModal: jest.fn(), hideJoinedChannelsPreference: jest.fn(), + hideArchivedChannelsPreference: jest.fn(), changeFilter: jest.fn(), myChannelMemberships: {}, canShowArchivedChannels: false, rememberHideJoinedChannelsChecked: false, + rememberHideArchivedChannelsChecked: true, noResultsText: <>{'no channel found'}, filter: Filter.All, intl: { @@ -98,6 +100,48 @@ describe('components/SearchableChannelList', () => { expect(baseProps.search).toBeDefined(); }); + test('renders the Hide Archived checkbox reflecting the persisted preference', () => { + renderWithContext( + , + initialState, + ); + + const hideArchived = screen.getByLabelText('Hide archived channels'); + expect(hideArchived).toBeInTheDocument(); + expect(hideArchived).toHaveAttribute('aria-checked', 'true'); + }); + + test('does not render the Hide Archived checkbox when the Archived filter is active', () => { + renderWithContext( + , + initialState, + ); + + expect(screen.queryByLabelText('Hide archived channels')).not.toBeInTheDocument(); + }); + + test('clicking the Hide Archived checkbox toggles the preference', async () => { + const hideArchivedChannelsPreference = jest.fn(); + renderWithContext( + , + initialState, + ); + + const hideArchived = screen.getByLabelText('Hide archived channels'); + expect(hideArchived).toHaveAttribute('aria-checked', 'false'); + + await userEvent.click(hideArchived); + + expect(hideArchivedChannelsPreference).toHaveBeenCalledWith(true); + }); + test('should render ArchiveOutlineIcon for archived public channels', () => { const channels = [ { diff --git a/webapp/channels/src/components/searchable_channel_list.tsx b/webapp/channels/src/components/searchable_channel_list.tsx index b6c154e5d006..fd2b42fe45a9 100644 --- a/webapp/channels/src/components/searchable_channel_list.tsx +++ b/webapp/channels/src/components/searchable_channel_list.tsx @@ -42,6 +42,8 @@ interface Props extends WrappedComponentProps { closeModal: (modalId: string) => void; hideJoinedChannelsPreference: (shouldHideJoinedChannels: boolean) => void; rememberHideJoinedChannelsChecked: boolean; + hideArchivedChannelsPreference: (shouldHideArchivedChannels: boolean) => void; + rememberHideArchivedChannelsChecked: boolean; loading?: boolean; channelsMemberCount?: Record; showRecommendedFilter?: boolean; @@ -451,6 +453,9 @@ export class SearchableChannelList extends React.PureComponent { this.props.hideJoinedChannelsPreference(true); } }; + handleArchivedChecked = () => { + this.props.hideArchivedChannelsPreference(!this.props.rememberHideArchivedChannelsChecked); + }; getEmptyStateMessage = () => { if (this.state.channelSearchValue.length > 0) { return ( @@ -851,6 +856,34 @@ export class SearchableChannelList extends React.PureComponent {
); + // The archived filter explicitly asks for archived channels, so hiding + // them there would leave an empty list — only offer the toggle elsewhere. + const hideArchivedButtonClass = classNames('get-app__checkbox', {checked: this.props.rememberHideArchivedChannelsChecked}); + const hideArchivedPreferenceCheckbox = this.props.filter === Filter.Archived ? null : ( +
{ + e.stopPropagation(); + if (e.key === 'Enter' || e.key === ' ') { + this.handleArchivedChecked(); + } + }} + role='checkbox' + aria-checked={this.props.rememberHideArchivedChannelsChecked} + aria-label={this.props.intl.formatMessage({id: 'more_channels.hide_archived_channels', defaultMessage: 'Hide archived channels'})} + tabIndex={0} + > +
+ {this.props.rememberHideArchivedChannelsChecked ? : null} +
+ +
+ ); + let channelCountLabel; if (channels.length === 0) { channelCountLabel = this.props.intl.formatMessage({id: 'more_channels.count_zero', defaultMessage: '0 Results'}); @@ -874,6 +907,7 @@ export class SearchableChannelList extends React.PureComponent {
{channelDropdown} + {hideArchivedPreferenceCheckbox} {hideJoinedPreferenceCheckbox}
diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 3d6760a9ede4..1ccdaa04cf3d 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -6086,6 +6086,8 @@ "more_channels.create": "Create New Channel", "more_channels.discoverable": "Discoverable", "more_channels.discoverable.aria": "Discoverable private channel", + "more_channels.hide_archived": "Hide Archived", + "more_channels.hide_archived_channels": "Hide archived channels", "more_channels.hide_joined": "Hide Joined", "more_channels.hide_joined_channels": "Hide joined channels", "more_channels.joined": "Joined", diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index b4be69e3fe6c..d47bbbeed151 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -834,6 +834,7 @@ export const StoragePrefixes = { INLINE_IMAGE_VISIBLE: 'isInlineImageVisible_', DELINQUENCY: 'delinquency_', HIDE_JOINED_CHANNELS: 'hideJoinedChannels', + HIDE_ARCHIVED_CHANNELS: 'hideArchivedChannels', HIDE_NOTIFICATION_PERMISSION_REQUEST_BANNER: 'hideNotificationPermissionRequestBanner', MARK_ALL_READ_WITHOUT_CONFIRM: 'mark_all_as_read_without_confirm', HAS_SEEN_FEATURE_TOAST: 'has_seen_feature_toast', From 4f8b9d8195ef527f279c2a02f3d24ea87284af1f Mon Sep 17 00:00:00 2001 From: Jesse Hallam Date: Fri, 14 Aug 2026 08:32:19 -0300 Subject: [PATCH 2/4] [MM-69641] Promote EnableExportDirectDownload to a Cloud-only configuration setting (#37477) * promote EnableExportDirectDownload to a Cloud-only configuration setting Replace the FeatureFlags.EnableExportDirectDownload feature flag with a FileSettings.EnableCloudExportDirectDownload configuration setting, gated to Mattermost Cloud environments. The /exportlink slash command and the export generate-presigned-url API now require FileSettings.EnableCloudExportDirectDownload to be enabled and a Cloud license. Operators previously enabling the feature via MM_FEATUREFLAGS_ENABLEEXPORTDIRECTDOWNLOAD should transition to MM_FILESETTINGS_ENABLECLOUDEXPORTDIRECTDOWNLOAD. * add tests for Cloud-only export direct download gating Cover the new EnableCloudExportDirectDownload + Cloud-license gate on GeneratePresignURLForExport (app) and the generate-presigned-url API (api4). * add EnableCloudExportDirectDownload to FileSettings type * gate export direct download on Cloud alone, without a configuration setting * Use a bounded HTTP client in export presigned-URL tests http.Get has no total timeout; if MinIO accepts the connection but stalls, these tests can hang until the suite timeout. Co-authored-by: mattermost-code --------- Co-authored-by: Cursor Agent Co-authored-by: mattermost-code --- .../environment-configuration-settings.mdx | 4 +- .../manage/mmctl-command-line-tool.mdx | 2 +- .../lib/src/server/default_config.ts | 1 - server/channels/api4/export_test.go | 109 ++++++++++++++++++ server/channels/app/export.go | 14 +-- server/channels/app/export_test.go | 105 +++++++++++++++++ .../app/slashcommands/command_exportlink.go | 2 +- server/i18n/en.json | 48 ++++---- server/public/model/feature_flags.go | 3 - 9 files changed, 249 insertions(+), 39 deletions(-) diff --git a/docs/main/administration-guide/configure/environment-configuration-settings.mdx b/docs/main/administration-guide/configure/environment-configuration-settings.mdx index b77c37f4f1d0..ed45ce7da308 100644 --- a/docs/main/administration-guide/configure/environment-configuration-settings.mdx +++ b/docs/main/administration-guide/configure/environment-configuration-settings.mdx @@ -4023,7 +4023,7 @@ Adding a prefix to all Redis cache keys reduces key collisions, simplifies debug -- When an alternate filestore target is configured, Mattermost Cloud admins can generate a presigned download URL for exports using the `/exportlink [job-id|zip file|latest]` slash command. On Amazon S3 this is an S3 presigned URL; on Azure Blob Storage it's a Shared Access Signature (SAS) URL. The lifetimes of these URLs are controlled, respectively, by `ExportAmazonS3PresignExpiresSeconds` or `ExportAzurePresignExpiresSeconds`. See the [Mattermost data migration](/administration-guide/manage/cloud-data-export#create-the-export) documentation for details. Alternatively, Cloud and self-hosted admins can use the [mmctl export generate-presigned-url](/administration-guide/manage/mmctl-command-line-tool#mmctl-export-generate-presigned-url) command to generate a presigned URL directly from mmctl. -- Generating a presigned URL requires the feature flag `EnableExportDirectDownload` to be set to `true`, the storage must support presigned links (Amazon S3 or Azure Blob Storage), and this experimental configuration setting must be set to `true`. Presigned URLs for exports aren't supported for systems with shared storage. +- When an alternate filestore target is configured, Mattermost Cloud admins can generate a presigned download URL for exports using the `/exportlink [job-id|zip file|latest]` slash command. On Amazon S3 this is an S3 presigned URL; on Azure Blob Storage it's a Shared Access Signature (SAS) URL. The lifetimes of these URLs are controlled, respectively, by `ExportAmazonS3PresignExpiresSeconds` or `ExportAzurePresignExpiresSeconds`. See the [Mattermost data migration](/administration-guide/manage/cloud-data-export#create-the-export) documentation for details. Cloud admins can also use the [mmctl export generate-presigned-url](/administration-guide/manage/mmctl-command-line-tool#mmctl-export-generate-presigned-url) command to generate a presigned URL directly from mmctl. +- Generating a presigned URL is only available in Mattermost Cloud, and requires storage that supports presigned links (Amazon S3 or Azure Blob Storage) and this dedicated export store setting to be set to `true`. Presigned URLs for exports aren't supported for systems with shared storage. diff --git a/docs/main/administration-guide/manage/mmctl-command-line-tool.mdx b/docs/main/administration-guide/manage/mmctl-command-line-tool.mdx index 2f60af056e08..9d1d2723f9c8 100644 --- a/docs/main/administration-guide/manage/mmctl-command-line-tool.mdx +++ b/docs/main/administration-guide/manage/mmctl-command-line-tool.mdx @@ -2978,7 +2978,7 @@ $ mmctl export download sample_export.zip Generate a pre-signed URL for an export file in cases where a Mattermost Cloud export is large and challenging to download from the Mattermost server. -Requires the `EnableExportDirectDownload` feature flag to be set to `true`. +Only available in Mattermost Cloud, and requires `FileSettings` > `DedicatedExportStore` to be set to `true`, with export storage that supports presigned links (Amazon S3 or Azure Blob Storage). **Format** diff --git a/e2e-tests/playwright/lib/src/server/default_config.ts b/e2e-tests/playwright/lib/src/server/default_config.ts index d1c3f64b0e32..c238fd653f45 100644 --- a/e2e-tests/playwright/lib/src/server/default_config.ts +++ b/e2e-tests/playwright/lib/src/server/default_config.ts @@ -796,7 +796,6 @@ const defaultServerConfig: AdminConfig = { AppsEnabled: false, NormalizeLdapDNs: false, WysiwygEditor: false, - EnableExportDirectDownload: false, MoveThreadsEnabled: false, NotificationMonitoring: true, AttributeValueMasking: false, diff --git a/server/channels/api4/export_test.go b/server/channels/api4/export_test.go index b1ce83ba4062..7171e442f7b1 100644 --- a/server/channels/api4/export_test.go +++ b/server/channels/api4/export_test.go @@ -7,11 +7,16 @@ import ( "bytes" "context" "fmt" + "io" + "net" + "net/http" "os" "path/filepath" "testing" + "time" "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/v8/platform/shared/filestore" "github.com/stretchr/testify/require" ) @@ -229,3 +234,107 @@ func BenchmarkDownloadExport(b *testing.B) { require.NoError(b, err) } } + +func TestGeneratePresignedURL(t *testing.T) { + mainHelper.Parallel(t) + + t.Run("no permissions", func(t *testing.T) { + th := Setup(t) + _, _, err := th.Client.GeneratePresignedURL(context.Background(), "export.zip") + require.Error(t, err) + CheckErrorID(t, err, "api.context.permissions.app_error") + }) + + t.Run("blocked when not running in Cloud", func(t *testing.T) { + th := Setup(t) + th.App.Srv().SetLicense(model.NewTestLicense()) + + _, resp, err := th.SystemAdminClient.GeneratePresignedURL(context.Background(), "export.zip") + require.Error(t, err) + CheckForbiddenStatus(t, resp) + CheckErrorID(t, err, "app.export.generate_presigned_url.direct_download.app_error") + }) + + t.Run("blocked without a license", func(t *testing.T) { + th := Setup(t) + th.App.Srv().SetLicense(nil) + + _, resp, err := th.SystemAdminClient.GeneratePresignedURL(context.Background(), "export.zip") + require.Error(t, err) + CheckForbiddenStatus(t, resp) + CheckErrorID(t, err, "app.export.generate_presigned_url.direct_download.app_error") + }) + + t.Run("passes gate when Cloud, then requires a dedicated export store", func(t *testing.T) { + th := Setup(t) + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.FileSettings.DedicatedExportStore = false + }) + + _, _, err := th.SystemAdminClient.GeneratePresignedURL(context.Background(), "export.zip") + require.Error(t, err) + CheckErrorID(t, err, "app.export.generate_presigned_url.config.app_error") + }) + + // The full happy path against a real presign-capable (S3/minio) export store: a + // Cloud server with a dedicated export store returns a working presigned URL over + // the API. Skipped when minio isn't reachable. + t.Run("succeeds against a presign-capable export store", func(t *testing.T) { + s3Host := os.Getenv("CI_MINIO_HOST") + if s3Host == "" { + s3Host = "localhost" + } + s3Port := os.Getenv("CI_MINIO_PORT") + if s3Port == "" { + s3Port = "9000" + } + s3Endpoint := net.JoinHostPort(s3Host, s3Port) + + conn, err := net.DialTimeout("tcp", s3Endpoint, 2*time.Second) + if err != nil { + t.Skipf("minio not available at %s: %v", s3Endpoint, err) + } + conn.Close() + + // Use a fresh bucket per run so MakeBucket is unambiguous. + bucket := model.NewId() + + // The dedicated export filestore is built once at startup, so the export-store + // configuration must be applied before the server starts, not via UpdateConfig. + th := SetupConfig(t, func(cfg *model.Config) { + *cfg.FileSettings.DedicatedExportStore = true + *cfg.FileSettings.ExportDriverName = model.ImageDriverS3 + *cfg.FileSettings.ExportAmazonS3AccessKeyId = model.MinioAccessKey + *cfg.FileSettings.ExportAmazonS3SecretAccessKey = model.MinioSecretKey + *cfg.FileSettings.ExportAmazonS3Bucket = bucket + *cfg.FileSettings.ExportAmazonS3Endpoint = s3Endpoint + *cfg.FileSettings.ExportAmazonS3Region = "" + *cfg.FileSettings.ExportAmazonS3SSL = false + }) + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + backend, ok := th.App.ExportFileBackend().(*filestore.S3FileBackend) + require.True(t, ok, "expected a dedicated S3 export backend") + require.NoError(t, backend.MakeBucket()) + + exportName := "job_export.zip" + payload := []byte("export-payload") + _, appErr := th.App.WriteExportFile(bytes.NewReader(payload), filepath.Join(*th.App.Config().ExportSettings.Directory, exportName)) + require.Nil(t, appErr) + + resp, _, err := th.SystemAdminClient.GeneratePresignedURL(context.Background(), exportName) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotEmpty(t, resp.URL) + + // The presigned URL should serve the exported file directly. + httpResp, err := (&http.Client{Timeout: 30 * time.Second}).Get(resp.URL) + require.NoError(t, err) + defer httpResp.Body.Close() + require.Equal(t, http.StatusOK, httpResp.StatusCode) + body, err := io.ReadAll(httpResp.Body) + require.NoError(t, err) + require.Equal(t, payload, body) + }) +} diff --git a/server/channels/app/export.go b/server/channels/app/export.go index 5f2d35af7a99..e8712a8ae0eb 100644 --- a/server/channels/app/export.go +++ b/server/channels/app/export.go @@ -1260,32 +1260,32 @@ func (a *App) ListExports() ([]string, *model.AppError) { } func (a *App) GeneratePresignURLForExport(name string) (*model.PresignURLResponse, *model.AppError) { - if !a.Config().FeatureFlags.EnableExportDirectDownload { - return nil, model.NewAppError("GeneratePresignURLForExport", "app.eport.generate_presigned_url.featureflag.app_error", nil, "", http.StatusInternalServerError) + if !a.License().IsCloud() { + return nil, model.NewAppError("GeneratePresignURLForExport", "app.export.generate_presigned_url.direct_download.app_error", nil, "", http.StatusForbidden) } if !*a.Config().FileSettings.DedicatedExportStore { - return nil, model.NewAppError("GeneratePresignURLForExport", "app.eport.generate_presigned_url.config.app_error", nil, "", http.StatusInternalServerError) + return nil, model.NewAppError("GeneratePresignURLForExport", "app.export.generate_presigned_url.config.app_error", nil, "", http.StatusInternalServerError) } b := a.ExportFileBackend() backend, ok := b.(filestore.FileBackendWithLinkGenerator) if !ok { - return nil, model.NewAppError("GeneratePresignURLForExport", "app.eport.generate_presigned_url.driver.app_error", nil, "", http.StatusInternalServerError) + return nil, model.NewAppError("GeneratePresignURLForExport", "app.export.generate_presigned_url.driver.app_error", nil, "", http.StatusInternalServerError) } p := path.Join(*a.Config().ExportSettings.Directory, filepath.Base(name)) found, err := b.FileExists(p) if err != nil { - return nil, model.NewAppError("GeneratePresignURLForExport", "app.eport.generate_presigned_url.fileexist.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return nil, model.NewAppError("GeneratePresignURLForExport", "app.export.generate_presigned_url.fileexist.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } if !found { - return nil, model.NewAppError("GeneratePresignURLForExport", "app.eport.generate_presigned_url.notfound.app_error", nil, "", http.StatusInternalServerError) + return nil, model.NewAppError("GeneratePresignURLForExport", "app.export.generate_presigned_url.notfound.app_error", nil, "", http.StatusInternalServerError) } link, exp, err := backend.GeneratePublicLink(p) if err != nil { - return nil, model.NewAppError("GeneratePresignURLForExport", "app.eport.generate_presigned_url.link.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return nil, model.NewAppError("GeneratePresignURLForExport", "app.export.generate_presigned_url.link.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } return &model.PresignURLResponse{ diff --git a/server/channels/app/export_test.go b/server/channels/app/export_test.go index 5015aa0a6138..22b9aa2ec73e 100644 --- a/server/channels/app/export_test.go +++ b/server/channels/app/export_test.go @@ -8,6 +8,9 @@ import ( "bytes" "encoding/json" "fmt" + "io" + "net" + "net/http" "os" "path/filepath" "sort" @@ -25,6 +28,7 @@ import ( "github.com/mattermost/mattermost/server/v8/channels/app/imports" "github.com/mattermost/mattermost/server/v8/channels/utils" "github.com/mattermost/mattermost/server/v8/channels/utils/fileutils" + "github.com/mattermost/mattermost/server/v8/platform/shared/filestore" ) func TestReactionsOfPost(t *testing.T) { @@ -1728,3 +1732,104 @@ func TestExportDeactivatedUserDMs(t *testing.T) { require.True(t, foundThreadedReplyInImport, "Threaded reply from deactivated user should be imported") } + +func TestGeneratePresignURLForExport(t *testing.T) { + mainHelper.Parallel(t) + + t.Run("blocked when not running in Cloud", func(t *testing.T) { + th := Setup(t) + th.App.Srv().SetLicense(model.NewTestLicense()) + + resp, appErr := th.App.GeneratePresignURLForExport("export.zip") + assert.Nil(t, resp) + require.NotNil(t, appErr) + assert.Equal(t, "app.export.generate_presigned_url.direct_download.app_error", appErr.Id) + }) + + t.Run("blocked without a license", func(t *testing.T) { + th := Setup(t) + th.App.Srv().SetLicense(nil) + + resp, appErr := th.App.GeneratePresignURLForExport("export.zip") + assert.Nil(t, resp) + require.NotNil(t, appErr) + assert.Equal(t, "app.export.generate_presigned_url.direct_download.app_error", appErr.Id) + }) + + t.Run("passes gate when Cloud, then requires a dedicated export store", func(t *testing.T) { + th := Setup(t) + // The Cloud gate is checked before the dedicated export store requirement, + // so a Cloud license advances past it and fails on the (disabled) dedicated + // export store instead. + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.FileSettings.DedicatedExportStore = false + }) + + resp, appErr := th.App.GeneratePresignURLForExport("export.zip") + assert.Nil(t, resp) + require.NotNil(t, appErr) + assert.Equal(t, "app.export.generate_presigned_url.config.app_error", appErr.Id) + }) + + // The full happy path against a real presign-capable (S3/minio) export store: a + // Cloud server with a dedicated export store returns a working presigned URL. + // Skipped when minio isn't reachable. + t.Run("succeeds against a presign-capable export store", func(t *testing.T) { + s3Host := os.Getenv("CI_MINIO_HOST") + if s3Host == "" { + s3Host = "localhost" + } + s3Port := os.Getenv("CI_MINIO_PORT") + if s3Port == "" { + s3Port = "9000" + } + s3Endpoint := net.JoinHostPort(s3Host, s3Port) + + conn, err := net.DialTimeout("tcp", s3Endpoint, 2*time.Second) + if err != nil { + t.Skipf("minio not available at %s: %v", s3Endpoint, err) + } + conn.Close() + + // Use a fresh bucket per run so MakeBucket is unambiguous. + bucket := model.NewId() + + // The dedicated export filestore is built once at startup, so the export-store + // configuration must be applied before the server starts, not via UpdateConfig. + th := SetupConfig(t, func(cfg *model.Config) { + *cfg.FileSettings.DedicatedExportStore = true + *cfg.FileSettings.ExportDriverName = model.ImageDriverS3 + *cfg.FileSettings.ExportAmazonS3AccessKeyId = model.MinioAccessKey + *cfg.FileSettings.ExportAmazonS3SecretAccessKey = model.MinioSecretKey + *cfg.FileSettings.ExportAmazonS3Bucket = bucket + *cfg.FileSettings.ExportAmazonS3Endpoint = s3Endpoint + *cfg.FileSettings.ExportAmazonS3Region = "" + *cfg.FileSettings.ExportAmazonS3SSL = false + }) + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + backend, ok := th.App.ExportFileBackend().(*filestore.S3FileBackend) + require.True(t, ok, "expected a dedicated S3 export backend") + require.NoError(t, backend.MakeBucket()) + + exportName := "job_export.zip" + payload := []byte("export-payload") + _, appErr := th.App.WriteExportFile(bytes.NewReader(payload), filepath.Join(*th.App.Config().ExportSettings.Directory, exportName)) + require.Nil(t, appErr) + + resp, appErr := th.App.GeneratePresignURLForExport(exportName) + require.Nil(t, appErr) + require.NotNil(t, resp) + require.NotEmpty(t, resp.URL) + + // The presigned URL should serve the exported file directly. + httpResp, err := (&http.Client{Timeout: 30 * time.Second}).Get(resp.URL) + require.NoError(t, err) + defer httpResp.Body.Close() + assert.Equal(t, http.StatusOK, httpResp.StatusCode) + body, err := io.ReadAll(httpResp.Body) + require.NoError(t, err) + assert.Equal(t, payload, body) + }) +} diff --git a/server/channels/app/slashcommands/command_exportlink.go b/server/channels/app/slashcommands/command_exportlink.go index 5da10f3b2bb5..361c8b35f425 100644 --- a/server/channels/app/slashcommands/command_exportlink.go +++ b/server/channels/app/slashcommands/command_exportlink.go @@ -33,7 +33,7 @@ func (*ExportLinkProvider) GetTrigger() string { } func (*ExportLinkProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command { - if !a.Config().FeatureFlags.EnableExportDirectDownload { + if !a.License().IsCloud() { return nil } diff --git a/server/i18n/en.json b/server/i18n/en.json index a0ad269ca39b..e8291315401a 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -6700,30 +6700,6 @@ "id": "app.emoji.get_list.internal_error", "translation": "Unable to get the emoji." }, - { - "id": "app.eport.generate_presigned_url.config.app_error", - "translation": "This actions requires the use of a dedicated export store." - }, - { - "id": "app.eport.generate_presigned_url.driver.app_error", - "translation": "Your export store driver does not support presign url generation." - }, - { - "id": "app.eport.generate_presigned_url.featureflag.app_error", - "translation": "This feature is restricted by a feature flag." - }, - { - "id": "app.eport.generate_presigned_url.fileexist.app_error", - "translation": "Unable to check if the file exists." - }, - { - "id": "app.eport.generate_presigned_url.link.app_error", - "translation": "Unable to generate the presigned url." - }, - { - "id": "app.eport.generate_presigned_url.notfound.app_error", - "translation": "The export file was not found." - }, { "id": "app.export.export_attachment.copy_file.error", "translation": "Failed to copy file during export." @@ -6756,6 +6732,30 @@ "id": "app.export.export_write_line.json_marshall.error", "translation": "An error occurred marshalling the JSON data for export." }, + { + "id": "app.export.generate_presigned_url.config.app_error", + "translation": "This actions requires the use of a dedicated export store." + }, + { + "id": "app.export.generate_presigned_url.direct_download.app_error", + "translation": "Direct download of exports is only available in Mattermost Cloud." + }, + { + "id": "app.export.generate_presigned_url.driver.app_error", + "translation": "Your export store driver does not support presign url generation." + }, + { + "id": "app.export.generate_presigned_url.fileexist.app_error", + "translation": "Unable to check if the file exists." + }, + { + "id": "app.export.generate_presigned_url.link.app_error", + "translation": "Unable to generate the presigned url." + }, + { + "id": "app.export.generate_presigned_url.notfound.app_error", + "translation": "The export file was not found." + }, { "id": "app.export.marshal.app_error", "translation": "Unable to marshal response." diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go index 7e5113184aef..14894f3630c7 100644 --- a/server/public/model/feature_flags.go +++ b/server/public/model/feature_flags.go @@ -30,8 +30,6 @@ type FeatureFlags struct { // Enable WYSIWYG text editor WysiwygEditor bool - EnableExportDirectDownload bool - MoveThreadsEnabled bool NotificationMonitoring bool @@ -165,7 +163,6 @@ func (f *FeatureFlags) SetDefaults() { f.AppsEnabled = false f.NormalizeLdapDNs = false f.WysiwygEditor = false - f.EnableExportDirectDownload = false f.MoveThreadsEnabled = false f.NotificationMonitoring = true f.AttributeValueMasking = true From a234862de7da948f1869867a75349c03a20c2665 Mon Sep 17 00:00:00 2001 From: M-ZubairAhmed Date: Fri, 14 Aug 2026 17:51:05 +0530 Subject: [PATCH 3/4] [MM-69816] Update prepackaged Calls to v1.12.3 (#37985) --- server/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/Makefile b/server/Makefile index f19081791975..8142204ebcc4 100644 --- a/server/Makefile +++ b/server/Makefile @@ -156,7 +156,7 @@ TEMPLATES_DIR=templates # Plugins Packages PLUGIN_PACKAGES ?= $(PLUGIN_PACKAGES:) -PLUGIN_PACKAGES += mattermost-plugin-calls-v1.12.2 +PLUGIN_PACKAGES += mattermost-plugin-calls-v1.12.3 PLUGIN_PACKAGES += mattermost-plugin-github-v2.8.0 PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.13.0 PLUGIN_PACKAGES += mattermost-plugin-jira-v4.8.0 From 9a9bbe28bdfc0a9fb36a13cf5aeda091b12942b4 Mon Sep 17 00:00:00 2001 From: Devin Binnie <52460000+devinbinnie@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:47:09 -0400 Subject: [PATCH 4/4] [MM-70188] Convert the platform, os, browser user agent session attributes to select fields (#37969) * [MM-70188] Convert the platform, os, browser user agent session attributes to select fields * Update server/public/model/session_attributes.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- server/public/model/session_attributes.go | 55 +++++++++++++++++++++-- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/server/public/model/session_attributes.go b/server/public/model/session_attributes.go index 8fd315773a71..268253840a3d 100644 --- a/server/public/model/session_attributes.go +++ b/server/public/model/session_attributes.go @@ -294,9 +294,58 @@ func SessionAttributeSystemFields(groupID string) []*PropertyField { sessionAttributeField(groupID, SessionAttributesPropertyFieldOSVersion, SessionAttributesDisplayNameOSVersion, PropertyFieldTypeText, clientsOnly, SessionAttributeDefaultTTLPosture, SessionAttributeDefaultGracePosture, sessionOperators(sessionVersionOperators...)), sessionAttributeField(groupID, SessionAttributesPropertyFieldClientVersion, SessionAttributesDisplayNameClientVersion, PropertyFieldTypeText, clientsOnly, SessionAttributeDefaultTTLPosture, SessionAttributeDefaultGracePosture, sessionOperators(sessionVersionOperators...)), - sessionAttributeField(groupID, SessionAttributesPropertyFieldUserAgentPlatform, SessionAttributesDisplayNameUserAgentPlatform, PropertyFieldTypeText, allPlatforms, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, nil), - sessionAttributeField(groupID, SessionAttributesPropertyFieldUserAgentOS, SessionAttributesDisplayNameUserAgentOS, PropertyFieldTypeText, allPlatforms, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, nil), - sessionAttributeField(groupID, SessionAttributesPropertyFieldUserAgentBrowserName, SessionAttributesDisplayNameUserAgentBrowserName, PropertyFieldTypeText, allPlatforms, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, nil), + sessionAttributeField(groupID, SessionAttributesPropertyFieldUserAgentPlatform, SessionAttributesDisplayNameUserAgentPlatform, PropertyFieldTypeSelect, allPlatforms, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, StringInterface{ + PropertyFieldAttributeOptions: []map[string]string{ + {"name": "Windows"}, + {"name": "Macintosh"}, + {"name": "Linux"}, + {"name": "iPad"}, + {"name": "iPhone"}, + {"name": "iPod"}, + {"name": "BlackBerry"}, + {"name": "Windows Phone"}, + {"name": "Unknown"}, + }, + }), + sessionAttributeField(groupID, SessionAttributesPropertyFieldUserAgentOS, SessionAttributesDisplayNameUserAgentOS, PropertyFieldTypeSelect, allPlatforms, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, StringInterface{ + PropertyFieldAttributeOptions: []map[string]string{ + {"name": "Windows"}, + {"name": "Windows 10"}, + {"name": "Windows 8.1"}, + {"name": "Windows 8"}, + {"name": "Windows 7"}, + {"name": "Windows Vista"}, + {"name": "Windows XP x64 Edition"}, + {"name": "Windows XP"}, + {"name": "Windows 2000"}, + {"name": "Windows Phone"}, + {"name": "Mac OS"}, + {"name": "iOS"}, + {"name": "Android"}, + {"name": "Chrome OS"}, + {"name": "Linux"}, + {"name": "BlackBerry"}, + {"name": "Kindle"}, + {"name": "webOS"}, + {"name": "Unknown"}, + }, + }), + sessionAttributeField(groupID, SessionAttributesPropertyFieldUserAgentBrowserName, SessionAttributesDisplayNameUserAgentBrowserName, PropertyFieldTypeSelect, allPlatforms, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, StringInterface{ + PropertyFieldAttributeOptions: []map[string]string{ + {"name": "Chrome"}, + {"name": "Firefox"}, + {"name": "Safari"}, + {"name": "Edge"}, + {"name": "Internet Explorer"}, + {"name": "Opera"}, + {"name": "Android"}, + {"name": "BlackBerry"}, + {"name": "Desktop App"}, + {"name": "Mobile App"}, + {"name": "mmctl"}, + {"name": "Unknown"}, + }, + }), sessionAttributeField(groupID, SessionAttributesPropertyFieldUserAgentBrowserVersion, SessionAttributesDisplayNameUserAgentBrowserVersion, PropertyFieldTypeText, allPlatforms, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, sessionOperators(sessionVersionOperators...)), sessionAttributeField(groupID, SessionAttributesPropertyFieldTLSDDeviceID, SessionAttributesDisplayNameTLSDDeviceID, PropertyFieldTypeText, desktopBrowser, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, nil), sessionAttributeField(groupID, SessionAttributesPropertyFieldClientDeviceID, SessionAttributesDisplayNameClientDeviceID, PropertyFieldTypeText, mobileOnly, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, nil),