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/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/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/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/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
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
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),
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`] = `
/>
+
+
+
+
+ Hide Archived
+
+
+
+
+
+ Hide Archived
+
{
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 : (
+