Message History Limits in Entry Edition (#33831)

* Support for Entry license with limits + updates to Edition & License screen

* Refactor message history limit to use entry sku limits

* Fixed missing update on license change

* Fix typo in limit types

* Revert unnecessary thread change

* Revert merge issue

* Cleanup

* Fix CTAs of limit notifications

* Linting

* More linting

* Linting and fix tests

* More linting

* Fix tests

* PR feedback and fix tests

* Fix tests

* Fix test

* Fix test

* Linting

* Simplified Limit panels

* Linting

* PR feedback

* Revert back job time

* Linting

* linting

* Fixed issue switching in RHS

* PR Feedback

---------

Co-authored-by: Nick Misasi <nick.misasi@mattermost.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Maria A Nunez 2025-09-10 22:52:19 -04:00 committed by GitHub
parent 4169cb7b65
commit 3253b9ff6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1078 additions and 732 deletions

View file

@ -17,10 +17,7 @@ func (api *API) InitLimits() {
}
func getServerLimits(c *Context, w http.ResponseWriter, r *http.Request) {
if !(c.IsSystemAdmin() && c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadUserManagementUsers)) {
c.SetPermissionError(model.PermissionSysconsoleReadUserManagementUsers)
return
}
isAdmin := c.IsSystemAdmin() && c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadUserManagementUsers)
serverLimits, err := c.App.GetServerLimits()
if err != nil {
@ -28,6 +25,18 @@ func getServerLimits(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
// Non-admin users only get message history limit information, no user count data
if !isAdmin {
limitedData := &model.ServerLimits{
MaxUsersLimit: 0,
MaxUsersHardLimit: 0,
ActiveUserCount: 0,
LastAccessiblePostTime: serverLimits.LastAccessiblePostTime,
PostHistoryLimit: serverLimits.PostHistoryLimit,
}
serverLimits = limitedData
}
if err := json.NewEncoder(w).Encode(serverLimits); err != nil {
c.Logger.Warn("Error writing server limits response", mlog.Err(err))
}

View file

@ -0,0 +1,186 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestGetServerLimits(t *testing.T) {
mainHelper.Parallel(t)
t.Run("admin users can get full server limits", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
// Set up unlicensed server
th.App.Srv().SetLicense(nil)
// Test with system admin
serverLimits, resp, err := th.SystemAdminClient.GetServerLimits(context.Background())
require.NoError(t, err)
CheckOKStatus(t, resp)
// Should have full access to all limits data
require.Greater(t, serverLimits.ActiveUserCount, int64(0))
require.Equal(t, int64(2500), serverLimits.MaxUsersLimit)
require.Equal(t, int64(5000), serverLimits.MaxUsersHardLimit)
require.Equal(t, int64(0), serverLimits.PostHistoryLimit)
require.Equal(t, int64(0), serverLimits.LastAccessiblePostTime)
})
t.Run("non-admin users get limited data with licensed server", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
// Set up licensed server with user limits
userLimit := 100
extraUsers := 10
postHistoryLimit := int64(10000)
license := model.NewTestLicense("")
license.IsSeatCountEnforced = true
license.Features.Users = &userLimit
license.ExtraUsers = &extraUsers
license.Limits = &model.LicenseLimits{
PostHistory: postHistoryLimit,
}
th.App.Srv().SetLicense(license)
// Test with regular user
serverLimits, resp, err := th.Client.GetServerLimits(context.Background())
require.NoError(t, err)
CheckOKStatus(t, resp)
// Non-admin users should get zero for user count data (privacy)
require.Equal(t, int64(0), serverLimits.ActiveUserCount)
require.Equal(t, int64(0), serverLimits.MaxUsersLimit)
require.Equal(t, int64(0), serverLimits.MaxUsersHardLimit)
// But should get message history limits (needed for UI)
require.Equal(t, postHistoryLimit, serverLimits.PostHistoryLimit)
})
t.Run("admin users get full limts", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
// Set up licensed server with post history limits
userLimit := 100
postHistoryLimit := int64(10000)
license := model.NewTestLicense("")
license.IsSeatCountEnforced = true
license.Features.Users = &userLimit
license.Limits = &model.LicenseLimits{
PostHistory: postHistoryLimit,
}
th.App.Srv().SetLicense(license)
// Test with system admin
serverLimits, resp, err := th.SystemAdminClient.GetServerLimits(context.Background())
require.NoError(t, err)
CheckOKStatus(t, resp)
// Should have full access to all limits data
require.Greater(t, serverLimits.ActiveUserCount, int64(0))
require.Equal(t, int64(100), serverLimits.MaxUsersLimit)
require.Equal(t, int64(100), serverLimits.MaxUsersHardLimit)
// Should have post history limits
require.Equal(t, postHistoryLimit, serverLimits.PostHistoryLimit)
// LastAccessiblePostTime may be 0 if no posts exist in test database, which is expected
require.GreaterOrEqual(t, serverLimits.LastAccessiblePostTime, int64(0))
})
t.Run("non-admin users get post history limits when configured", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
// Set up licensed server with post history limits
userLimit := 100
postHistoryLimit := int64(10000)
license := model.NewTestLicense("")
license.IsSeatCountEnforced = true
license.Features.Users = &userLimit
license.Limits = &model.LicenseLimits{
PostHistory: postHistoryLimit,
}
th.App.Srv().SetLicense(license)
// Test with regular user
serverLimits, resp, err := th.Client.GetServerLimits(context.Background())
require.NoError(t, err)
CheckOKStatus(t, resp)
// Non-admin users should get zero for user count data (privacy)
require.Equal(t, int64(0), serverLimits.ActiveUserCount)
require.Equal(t, int64(0), serverLimits.MaxUsersLimit)
require.Equal(t, int64(0), serverLimits.MaxUsersHardLimit)
// But should get post history limits (needed for UI)
require.Equal(t, postHistoryLimit, serverLimits.PostHistoryLimit)
// LastAccessiblePostTime may be 0 if no posts exist in test database, which is expected
require.GreaterOrEqual(t, serverLimits.LastAccessiblePostTime, int64(0))
})
t.Run("zero post history limit shows no limits", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
// Set up licensed server with zero post history limit
userLimit := 100
postHistoryLimit := int64(0)
license := model.NewTestLicense("")
license.IsSeatCountEnforced = true
license.Features.Users = &userLimit
license.Limits = &model.LicenseLimits{
PostHistory: postHistoryLimit,
}
th.App.Srv().SetLicense(license)
// Test with both admin and regular user
clients := []*model.Client4{th.SystemAdminClient, th.Client}
for i, client := range clients {
serverLimits, resp, err := client.GetServerLimits(context.Background())
require.NoError(t, err, "Failed for client %d", i)
CheckOKStatus(t, resp)
// Should have no post history limits
require.Equal(t, int64(0), serverLimits.PostHistoryLimit)
require.Equal(t, int64(0), serverLimits.LastAccessiblePostTime)
}
})
t.Run("license with nil Limits shows no post history limits", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
// Set up licensed server with nil Limits
userLimit := 100
license := model.NewTestLicense("")
license.IsSeatCountEnforced = true
license.Features.Users = &userLimit
license.Limits = nil // Explicitly set to nil
th.App.Srv().SetLicense(license)
// Test with both admin and regular user
clients := []*model.Client4{th.SystemAdminClient, th.Client}
for i, client := range clients {
serverLimits, resp, err := client.GetServerLimits(context.Background())
require.NoError(t, err, "Failed for client %d", i)
CheckOKStatus(t, resp)
// Should have no post history limits
require.Equal(t, int64(0), serverLimits.PostHistoryLimit)
require.Equal(t, int64(0), serverLimits.LastAccessiblePostTime)
}
})
}

View file

@ -36,6 +36,17 @@ func (a *App) GetServerLimits() (*model.ServerLimits, *model.AppError) {
limits.MaxUsersHardLimit = licenseUserLimit + int64(extraUsers)
}
// Check if license has post history limits and get the calculated timestamp
if license != nil && license.Limits != nil && license.Limits.PostHistory > 0 {
limits.PostHistoryLimit = license.Limits.PostHistory
// Get the calculated timestamp of the last accessible post
lastAccessibleTime, appErr := a.GetLastAccessiblePostTime()
if appErr != nil {
return nil, appErr
}
limits.LastAccessiblePostTime = lastAccessibleTime
}
activeUserCount, appErr := a.Srv().Store().User().Count(model.UserCountOptions{})
if appErr != nil {
return nil, model.NewAppError("GetServerLimits", "app.limits.get_app_limits.user_count.store_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
@ -44,6 +55,15 @@ func (a *App) GetServerLimits() (*model.ServerLimits, *model.AppError) {
return limits, nil
}
func (a *App) GetPostHistoryLimit() int64 {
license := a.License()
if license == nil || license.Limits == nil || license.Limits.PostHistory == 0 {
// No limits applicable
return 0
}
return license.Limits.PostHistory
}
func (a *App) isAtUserLimit() (bool, *model.AppError) {
userLimits, appErr := a.GetServerLimits()

View file

@ -4,9 +4,11 @@
package app
import (
"errors"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store"
storemocks "github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
@ -549,3 +551,215 @@ func TestExtraUsersBehavior(t *testing.T) {
require.Equal(t, int64(5000), serverLimits.MaxUsersHardLimit)
})
}
func TestGetServerLimitsWithPostHistory(t *testing.T) {
mainHelper.Parallel(t)
t.Run("unlicensed server has no post history limits", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
th.App.Srv().SetLicense(nil)
serverLimits, appErr := th.App.GetServerLimits()
require.Nil(t, appErr)
// Unlicensed servers should have no post history limits
require.Equal(t, int64(0), serverLimits.PostHistoryLimit)
require.Equal(t, int64(0), serverLimits.LastAccessiblePostTime)
})
t.Run("licensed server without post history limits", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
license := model.NewTestLicense("")
license.Limits = nil // No limits configured
th.App.Srv().SetLicense(license)
serverLimits, appErr := th.App.GetServerLimits()
require.Nil(t, appErr)
// Should have no post history limits when Limits is nil
require.Equal(t, int64(0), serverLimits.PostHistoryLimit)
require.Equal(t, int64(0), serverLimits.LastAccessiblePostTime)
})
t.Run("licensed server with zero post history limit", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
license := model.NewTestLicense("")
license.Limits = &model.LicenseLimits{
PostHistory: 0, // Zero limit
}
th.App.Srv().SetLicense(license)
serverLimits, appErr := th.App.GetServerLimits()
require.Nil(t, appErr)
// Should have no post history limits when PostHistory is 0
require.Equal(t, int64(0), serverLimits.PostHistoryLimit)
require.Equal(t, int64(0), serverLimits.LastAccessiblePostTime)
})
t.Run("licensed server with positive post history limit and successful GetLastAccessiblePostTime", func(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
// Mock user store for existing functionality
mockStore := th.App.Srv().Store().(*storemocks.Store)
mockUserStore := storemocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(5), nil)
mockStore.On("User").Return(&mockUserStore)
// Mock system store for GetLastAccessiblePostTime
mockSystemStore := storemocks.SystemStore{}
mockSystemStore.On("GetByName", model.SystemLastAccessiblePostTime).Return(&model.System{
Name: model.SystemLastAccessiblePostTime,
Value: "1234567890",
}, nil)
mockStore.On("System").Return(&mockSystemStore)
// Create Entry license with post history limit
license := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
license.Limits = &model.LicenseLimits{
PostHistory: 1000,
}
th.App.Srv().SetLicense(license)
serverLimits, appErr := th.App.GetServerLimits()
require.Nil(t, appErr)
// Should have proper post history limits set
require.Equal(t, int64(1000), serverLimits.PostHistoryLimit)
require.Equal(t, int64(1234567890), serverLimits.LastAccessiblePostTime)
require.Equal(t, int64(5), serverLimits.ActiveUserCount)
})
t.Run("licensed server with post history limit but GetLastAccessiblePostTime fails", func(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
// Mock user store for existing functionality
mockStore := th.App.Srv().Store().(*storemocks.Store)
mockUserStore := storemocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(5), nil)
mockStore.On("User").Return(&mockUserStore)
// Mock system store to return error
mockSystemStore := storemocks.SystemStore{}
mockSystemStore.On("GetByName", model.SystemLastAccessiblePostTime).Return(nil, errors.New("database error"))
mockStore.On("System").Return(&mockSystemStore)
// Create Entry license with post history limit
license := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
license.Limits = &model.LicenseLimits{
PostHistory: 1000,
}
th.App.Srv().SetLicense(license)
_, appErr := th.App.GetServerLimits()
require.NotNil(t, appErr)
require.Contains(t, appErr.Message, "Unable to find the system variable")
})
t.Run("licensed server with post history limit but no system value found", func(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
// Mock user store for existing functionality
mockStore := th.App.Srv().Store().(*storemocks.Store)
mockUserStore := storemocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(5), nil)
mockStore.On("User").Return(&mockUserStore)
// Mock system store to return ErrNotFound (all posts accessible)
mockSystemStore := storemocks.SystemStore{}
mockSystemStore.On("GetByName", model.SystemLastAccessiblePostTime).Return(nil, store.NewErrNotFound("", ""))
mockStore.On("System").Return(&mockSystemStore)
// Create Entry license with post history limit
license := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
license.Limits = &model.LicenseLimits{
PostHistory: 1000,
}
th.App.Srv().SetLicense(license)
serverLimits, appErr := th.App.GetServerLimits()
require.Nil(t, appErr)
// Should have post history limit set but LastAccessiblePostTime should be 0 (all posts accessible)
require.Equal(t, int64(1000), serverLimits.PostHistoryLimit)
require.Equal(t, int64(0), serverLimits.LastAccessiblePostTime)
require.Equal(t, int64(5), serverLimits.ActiveUserCount)
})
}
func TestGetPostHistoryLimit(t *testing.T) {
mainHelper.Parallel(t)
t.Run("unlicensed server returns zero limit", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
th.App.Srv().SetLicense(nil)
limit := th.App.GetPostHistoryLimit()
require.Equal(t, int64(0), limit)
})
t.Run("licensed server with no Limits returns zero", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
license := model.NewTestLicense("")
license.Limits = nil // No limits configured
th.App.Srv().SetLicense(license)
limit := th.App.GetPostHistoryLimit()
require.Equal(t, int64(0), limit)
})
t.Run("licensed server with zero PostHistory returns zero", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
license := model.NewTestLicense("")
license.Limits = &model.LicenseLimits{
PostHistory: 0,
}
th.App.Srv().SetLicense(license)
limit := th.App.GetPostHistoryLimit()
require.Equal(t, int64(0), limit)
})
t.Run("licensed server with positive PostHistory returns exact value", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
license := model.NewTestLicense("")
license.Limits = &model.LicenseLimits{
PostHistory: 1500,
}
th.App.Srv().SetLicense(license)
limit := th.App.GetPostHistoryLimit()
require.Equal(t, int64(1500), limit)
})
t.Run("Entry license with PostHistory returns exact value", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
license := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
license.Limits = &model.LicenseLimits{
PostHistory: 2000,
}
th.App.Srv().SetLicense(license)
limit := th.App.GetPostHistoryLimit()
require.Equal(t, int64(2000), limit)
})
}

View file

@ -104,6 +104,13 @@ func (ps *PlatformService) LoadLicense() {
if nErr != nil {
if ps.Config().FeatureFlags.EnableMattermostEntry && model.BuildEnterpriseReady == "true" {
ps.logger.Info("Mattermost Entry is enabled. Unlocking enterprise features.")
if ps.LicenseManager() == nil {
ps.logger.Warn("License manager not available, setting license to nil.")
ps.SetLicense(nil)
return
}
ps.SetLicense(ps.LicenseManager().NewMattermostEntryLicense(ps.telemetryId))
} else {
ps.logger.Warn("License key from https://mattermost.com required to unlock enterprise features.", mlog.Err(nErr))

View file

@ -1574,10 +1574,12 @@ func (a *App) convertUserNameToUserIds(rctx request.CTX, usernames []string) []s
return usernames
}
// GetLastAccessiblePostTime returns CreateAt time(from cache) of the last accessible post as per the cloud limit
// GetLastAccessiblePostTime returns CreateAt time(from cache) of the last accessible post as per the license limit
func (a *App) GetLastAccessiblePostTime() (int64, *model.AppError) {
// Only calculate the last accessible post time when there are actual post history limits
license := a.Srv().License()
if license == nil || !license.IsCloud() {
if license == nil || license.Limits == nil || license.Limits.PostHistory == 0 {
return 0, nil
}
@ -1601,13 +1603,10 @@ func (a *App) GetLastAccessiblePostTime() (int64, *model.AppError) {
return lastAccessiblePostTime, nil
}
// ComputeLastAccessiblePostTime updates cache with CreateAt time of the last accessible post as per the cloud plan's limit.
// ComputeLastAccessiblePostTime updates cache with CreateAt time of the last accessible post as per the license limit.
// Use GetLastAccessiblePostTime() to access the result.
func (a *App) ComputeLastAccessiblePostTime() error {
limit, appErr := a.getCloudMessagesHistoryLimit()
if appErr != nil {
return appErr
}
limit := a.GetPostHistoryLimit()
if limit == 0 {
// All posts are accessible - we must check if a previous value was set so we can clear it
@ -1628,7 +1627,7 @@ func (a *App) ComputeLastAccessiblePostTime() error {
return model.NewAppError("ComputeLastAccessiblePostTime", "app.system.permanent_delete_by_name.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
// Cloud limit is not applicable
// Message history limit is not applicable
return nil
}
@ -1652,25 +1651,6 @@ func (a *App) ComputeLastAccessiblePostTime() error {
return nil
}
func (a *App) getCloudMessagesHistoryLimit() (int64, *model.AppError) {
license := a.Srv().License()
if license == nil || !license.IsCloud() {
return 0, nil
}
limits, err := a.Cloud().GetCloudLimits("")
if err != nil {
return 0, model.NewAppError("getCloudMessagesHistoryLimit", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if limits == nil || limits.Messages == nil || limits.Messages.History == nil {
// Cloud limit is not applicable
return 0, nil
}
return int64(*limits.Messages.History), nil
}
func (a *App) SearchPostsInTeam(teamID string, paramsList []*model.SearchParams) (*model.PostList, *model.AppError) {
if !*a.Config().ServiceSettings.EnablePostSearch {
return nil, model.NewAppError("SearchPostsInTeam", "store.sql_post.search.disabled", nil, fmt.Sprintf("teamId=%v", teamID), http.StatusNotImplemented)

View file

@ -196,7 +196,12 @@ func TestGetTimeSortedPostAccessibleBounds(t *testing.T) {
func TestFilterInaccessiblePosts(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
// Set up license with PostHistory limits to enable post filtering
cloudLicenseWithLimits := model.NewTestLicense("cloud")
cloudLicenseWithLimits.Limits = &model.LicenseLimits{PostHistory: 100}
th.App.Srv().SetLicense(cloudLicenseWithLimits)
err := th.App.Srv().Store().System().Save(&model.System{
Name: model.SystemLastAccessiblePostTime,
Value: "2",
@ -326,7 +331,11 @@ func TestFilterInaccessiblePosts(t *testing.T) {
func TestGetFilteredAccessiblePosts(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
entryLicenseWithLimits := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
entryLicenseWithLimits.Limits = &model.LicenseLimits{PostHistory: 100}
th.App.Srv().SetLicense(entryLicenseWithLimits)
err := th.App.Srv().Store().System().Save(&model.System{
Name: model.SystemLastAccessiblePostTime,
Value: "2",
@ -369,7 +378,12 @@ func TestGetFilteredAccessiblePosts(t *testing.T) {
func TestIsInaccessiblePost(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
// Set up license with PostHistory limits to enable post filtering
entryLicenseWithLimits := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
entryLicenseWithLimits.Limits = &model.LicenseLimits{PostHistory: 100}
th.App.Srv().SetLicense(entryLicenseWithLimits)
err := th.App.Srv().Store().System().Save(&model.System{
Name: model.SystemLastAccessiblePostTime,
Value: "2",

View file

@ -22,7 +22,6 @@ import (
"github.com/mattermost/mattermost/server/v8/channels/store"
storemocks "github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
eMocks "github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
"github.com/mattermost/mattermost/server/v8/platform/services/imageproxy"
"github.com/mattermost/mattermost/server/v8/platform/services/searchengine/mocks"
)
@ -2581,7 +2580,12 @@ func TestCountMentionsFromPost(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
// Create an Entry license with post history limits
license := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
license.Limits = &model.LicenseLimits{
PostHistory: 10000, // Set some post history limit to enable filtering
}
th.App.Srv().SetLicense(license)
user1 := th.BasicUser
user2 := th.BasicUser2
@ -3484,14 +3488,39 @@ func TestGetLastAccessiblePostTime(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
// Setup store mocks needed for GetServerLimits
mockStore := th.App.Srv().Store().(*storemocks.Store)
mockUserStore := storemocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(10), nil)
mockStore.On("User").Return(&mockUserStore)
// Test with no license - should return 0
r, err := th.App.GetLastAccessiblePostTime()
assert.Nil(t, err)
assert.Equal(t, int64(0), r)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
// Test with Entry license but no limits configured - should return 0
entryLicenseNoLimits := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
entryLicenseNoLimits.Limits = nil // No limits configured
th.App.Srv().SetLicense(entryLicenseNoLimits)
r, err = th.App.GetLastAccessiblePostTime()
assert.Nil(t, err)
assert.Equal(t, int64(0), r, "Entry license with no limits should return 0")
mockStore := th.App.Srv().Store().(*storemocks.Store)
// Test with Entry license with zero post history limit - should return 0
entryLicenseZeroLimit := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
entryLicenseZeroLimit.Limits = &model.LicenseLimits{PostHistory: 0} // Zero limit
th.App.Srv().SetLicense(entryLicenseZeroLimit)
r, err = th.App.GetLastAccessiblePostTime()
assert.Nil(t, err)
assert.Equal(t, int64(0), r, "Entry license with zero post history limit should return 0")
// Test with Entry license that has post history limits
entryLicenseWithLimits := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
entryLicenseWithLimits.Limits = &model.LicenseLimits{PostHistory: 1000} // Actual limit
th.App.Srv().SetLicense(entryLicenseWithLimits)
// Test case 1: No system value found (ErrNotFound) - should return 0
mockSystemStore := storemocks.SystemStore{}
mockStore.On("System").Return(&mockSystemStore)
mockSystemStore.On("GetByName", mock.Anything).Return(nil, store.NewErrNotFound("", ""))
@ -3499,41 +3528,36 @@ func TestGetLastAccessiblePostTime(t *testing.T) {
assert.Nil(t, err)
assert.Equal(t, int64(0), r)
// Test case 2: Database error - should return error
mockSystemStore = storemocks.SystemStore{}
mockStore.On("System").Return(&mockSystemStore)
mockSystemStore.On("GetByName", mock.Anything).Return(nil, errors.New("test"))
mockSystemStore.On("GetByName", mock.Anything).Return(nil, errors.New("database error"))
_, err = th.App.GetLastAccessiblePostTime()
assert.NotNil(t, err)
// Test case 3: Valid system value found - should return parsed timestamp
mockSystemStore = storemocks.SystemStore{}
mockStore.On("System").Return(&mockSystemStore)
mockSystemStore.On("GetByName", mock.Anything).Return(&model.System{Name: model.SystemLastAccessiblePostTime, Value: "10"}, nil)
mockSystemStore.On("GetByName", mock.Anything).Return(&model.System{Name: model.SystemLastAccessiblePostTime, Value: "1234567890"}, nil)
r, err = th.App.GetLastAccessiblePostTime()
assert.Nil(t, err)
assert.Equal(t, int64(10), r)
assert.Equal(t, int64(1234567890), r)
}
func TestComputeLastAccessiblePostTime(t *testing.T) {
mainHelper.Parallel(t)
t.Run("Updates the time, if cloud limit is applicable", func(t *testing.T) {
t.Run("Updates the time, if Entry license limit is applicable", func(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := &eMocks.CloudInterface{}
th.App.Srv().Cloud = cloud
// cloud-starter, limit is applicable
cloud.Mock.On("GetCloudLimits", mock.Anything).Return(&model.ProductLimits{
Messages: &model.MessagesLimits{
History: model.NewPointer(1),
},
}, nil)
// Set Entry license with post history limit of 100 messages
entryLicensePostsLimit := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
entryLicensePostsLimit.Limits = &model.LicenseLimits{PostHistory: 100}
th.App.Srv().SetLicense(entryLicensePostsLimit)
mockStore := th.App.Srv().Store().(*storemocks.Store)
mockPostStore := storemocks.PostStore{}
mockPostStore.On("GetNthRecentPostTime", mock.Anything).Return(int64(1), nil)
mockPostStore.On("GetNthRecentPostTime", int64(100)).Return(int64(1234567890), nil)
mockSystemStore := storemocks.SystemStore{}
mockSystemStore.On("SaveOrUpdate", mock.Anything).Return(nil)
mockStore.On("Post").Return(&mockPostStore)
@ -3542,32 +3566,35 @@ func TestComputeLastAccessiblePostTime(t *testing.T) {
err := th.App.ComputeLastAccessiblePostTime()
assert.NoError(t, err)
mockSystemStore.AssertCalled(t, "SaveOrUpdate", mock.Anything)
// Verify that the system value was saved with the calculated timestamp
mockSystemStore.AssertCalled(t, "SaveOrUpdate", &model.System{
Name: model.SystemLastAccessiblePostTime,
Value: "1234567890",
})
})
t.Run("Remove the time if cloud limit is NOT applicable", func(t *testing.T) {
t.Run("Remove the time if license limit is NOT applicable", func(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := &eMocks.CloudInterface{}
th.App.Srv().Cloud = cloud
// enterprise, limit is NOT applicable
cloud.Mock.On("GetCloudLimits", mock.Anything).Return(nil, nil)
// Set license without post history limits (using test license without limits)
license := model.NewTestLicense()
license.Limits = nil // No limits
th.App.Srv().SetLicense(license)
mockStore := th.App.Srv().Store().(*storemocks.Store)
mockSystemStore := storemocks.SystemStore{}
mockSystemStore.On("GetByName", mock.Anything).Return(&model.System{Name: model.SystemLastAccessiblePostTime, Value: "10"}, nil)
mockSystemStore.On("PermanentDeleteByName", mock.Anything).Return(nil, nil)
mockSystemStore.On("GetByName", model.SystemLastAccessiblePostTime).Return(&model.System{Name: model.SystemLastAccessiblePostTime, Value: "1234567890"}, nil)
mockSystemStore.On("PermanentDeleteByName", model.SystemLastAccessiblePostTime).Return(nil, nil)
mockStore.On("System").Return(&mockSystemStore)
err := th.App.ComputeLastAccessiblePostTime()
assert.NoError(t, err)
// Verify that SaveOrUpdate was not called (no new timestamp calculated)
mockSystemStore.AssertNotCalled(t, "SaveOrUpdate", mock.Anything)
mockSystemStore.AssertCalled(t, "PermanentDeleteByName", mock.Anything)
// Verify that the previous value was deleted
mockSystemStore.AssertCalled(t, "PermanentDeleteByName", model.SystemLastAccessiblePostTime)
})
}

View file

@ -16,7 +16,8 @@ const schedFreq = 30 * time.Minute
func MakeScheduler(jobServer *jobs.JobServer, license *model.License) *jobs.PeriodicScheduler {
isEnabled := func(cfg *model.Config) bool {
enabled := license != nil && *license.Features.Cloud
// Enable for any license with post history limits (i.e. Entry SKU)
enabled := license != nil && license.Limits != nil && license.Limits.PostHistory > 0
mlog.Debug("Scheduler: isEnabled: "+strconv.FormatBool(enabled), mlog.String("scheduler", model.JobTypeLastAccessiblePost))
return enabled
}

View file

@ -17,7 +17,8 @@ func MakeWorker(jobServer *jobs.JobServer, license *model.License, app AppIface)
const workerName = "LastAccessiblePost"
isEnabled := func(_ *model.Config) bool {
return license != nil && license.Features != nil && *license.Features.Cloud
// Enable for any license with post history limits (i.e. Entry SKU)
return license != nil && license.Limits != nil && license.Limits.PostHistory > 0
}
execute := func(logger mlog.LoggerIFace, job *model.Job) error {
defer jobServer.HandleJobPanic(logger, job)

View file

@ -639,7 +639,7 @@ func (c *Client4) accessControlPolicyRoute(policyID string) string {
}
func (c *Client4) GetServerLimits(ctx context.Context) (*ServerLimits, *Response, error) {
r, err := c.DoAPIGet(ctx, c.limitsRoute()+"/users", "")
r, err := c.DoAPIGet(ctx, c.limitsRoute()+"/server", "")
if err != nil {
return nil, BuildResponse(r), err
}

View file

@ -108,7 +108,7 @@ func (f *FeatureFlags) SetDefaults() {
f.AttributeBasedAccessControl = true
f.ContentFlagging = false
f.InteractiveDialogAppsForm = true
f.EnableMattermostEntry = false
f.EnableMattermostEntry = true
// FEATURE_FLAG_REMOVAL: ChannelAdminManageABACRules - Remove this default when feature is GA
f.ChannelAdminManageABACRules = false // Default to false for safety
}

View file

@ -7,4 +7,7 @@ type ServerLimits struct {
MaxUsersLimit int64 `json:"maxUsersLimit"` // soft limit for max number of users.
MaxUsersHardLimit int64 `json:"maxUsersHardLimit"` // hard limit for max number of active users.
ActiveUserCount int64 `json:"activeUserCount"` // actual number of active users on server. Active = non deleted
// Post history limit fields
PostHistoryLimit int64 `json:"postHistoryLimit"` // The actual message history limit value (0 if no limits)
LastAccessiblePostTime int64 `json:"lastAccessiblePostTime"` // Timestamp of the last accessible post (0 if no limits reached)
}

View file

@ -151,7 +151,9 @@ describe('executeCommand', () => {
{type: 'UPDATE_RHS_SEARCH_RESULTS_TERMS', terms: ''},
{type: 'UPDATE_RHS_SEARCH_RESULTS_TYPE', searchType: ''},
{type: 'SEARCH_POSTS_REQUEST', isGettingMore: false},
{data: {firstInaccessiblePostTime: 0, searchType: 'posts'}, type: 'RECEIVED_SEARCH_TRUNCATION_INFO'},
{type: 'SEARCH_FILES_REQUEST', isGettingMore: false},
{data: {firstInaccessiblePostTime: 0, searchType: 'files'}, type: 'RECEIVED_SEARCH_TRUNCATION_INFO'},
]);
});
});

View file

@ -428,7 +428,9 @@ describe('Actions.Posts', () => {
{terms: '', type: 'UPDATE_RHS_SEARCH_RESULTS_TERMS'},
{searchType: '', type: 'UPDATE_RHS_SEARCH_RESULTS_TYPE'},
{isGettingMore: false, type: 'SEARCH_POSTS_REQUEST'},
{data: {firstInaccessiblePostTime: 0, searchType: 'posts'}, type: 'RECEIVED_SEARCH_TRUNCATION_INFO'},
{isGettingMore: false, type: 'SEARCH_FILES_REQUEST'},
{data: {firstInaccessiblePostTime: 0, searchType: 'files'}, type: 'RECEIVED_SEARCH_TRUNCATION_INFO'},
]);
});

View file

@ -40,6 +40,7 @@ import {getCloudSubscription} from 'mattermost-redux/actions/cloud';
import {clearErrors, logError} from 'mattermost-redux/actions/errors';
import {setServerVersion, getClientConfig, getCustomProfileAttributeFields} from 'mattermost-redux/actions/general';
import {getGroup as fetchGroup} from 'mattermost-redux/actions/groups';
import {getServerLimits} from 'mattermost-redux/actions/limits';
import {
getCustomEmojiForReaction,
getPosts,
@ -1407,6 +1408,9 @@ function handleConfigChanged(msg) {
function handleLicenseChanged(msg) {
store.dispatch({type: GeneralTypes.CLIENT_LICENSE_RECEIVED, data: msg.data.license});
// Refresh server limits when license changes since limits may have changed
dispatch(getServerLimits());
}
function handlePluginStatusesChangedEvent(msg) {

View file

@ -6,12 +6,20 @@
padding: 16px;
border: 1px rgba(var(--center-channel-color-rgb), 0.08) solid;
border-radius: 4px;
margin: 0 24px 13px;
margin: 0 16px 16px;
background-color: rgba(var(--center-channel-color-rgb), 0.04);
gap: 12px;
&__left {
align-self: flex-start;
padding: 0 12px;
padding: 0;
}
&__right {
margin-top: -2px;
}
&__description {
color: rgba(var(--center-channel-color-rgb), 0.75);
}
&__icon {
@ -19,7 +27,7 @@
}
&__title {
padding-bottom: 8px;
padding-bottom: 4px;
font-size: 14px;
font-weight: 600;
}

View file

@ -5,7 +5,6 @@ import React from 'react';
import {emptyLimits} from 'tests/constants/cloud';
import {emptyTeams} from 'tests/constants/teams';
import {adminUsersState, endUsersState} from 'tests/constants/users';
import {screen, renderWithContext} from 'tests/react_testing_utils';
import {makeEmptyUsage} from 'utils/limits_test';
import {TestHelper} from 'utils/test_helper';
@ -24,7 +23,6 @@ jest.mock('mattermost-redux/actions/cloud', () => {
const initialState = {
entities: {
usage: makeEmptyUsage(),
users: adminUsersState(),
cloud: {
limits: {...emptyLimits(), limitsLoaded: false},
},
@ -32,6 +30,12 @@ const initialState = {
license: TestHelper.getCloudLicenseMock(),
},
teams: emptyTeams(),
limits: {
serverLimits: {
activeUserCount: 0,
maxUsersLimit: 0,
},
},
posts: {
postsInChannel: {
channelId: [
@ -54,110 +58,34 @@ const exceededLimitsState = {
...initialState,
entities: {
...initialState.entities,
cloud: {
...initialState.entities.cloud,
limits: {
...initialState.entities.cloud.limits,
limitsLoaded: true,
limits: {
messages: {
history: 2,
},
},
limits: {
serverLimits: {
activeUserCount: 0,
maxUsersLimit: 0,
postHistoryLimit: 2,
},
},
usage: {
...initialState.entities.usage,
messages: {
...initialState.entities.usage.messages,
history: 3,
},
},
},
};
const exceededLimitsStateNoAccessiblePosts = {
...exceededLimitsState,
entities: {
...exceededLimitsState.entities,
posts: {
postsInChannel: {
channelId: [
],
},
posts: {},
},
},
};
const endUserLimitExceeded = {
...exceededLimitsState,
entities: {
...exceededLimitsState.entities,
users: endUsersState(),
},
};
describe('CenterMessageLock', () => {
it('returns null if limits not loaded', () => {
it('shows message when limits are exceeded', () => {
renderWithContext(
<CenterMessageLock channelId={'channelId'}/>,
initialState,
);
expect(screen.queryByText('Notify Admin')).not.toBeInTheDocument();
expect(screen.queryByText('Upgrade now')).not.toBeInTheDocument();
});
it('Admins have a call to upgrade', () => {
renderWithContext(
<CenterMessageLock channelId={'channelId'}/>,
<CenterMessageLock/>,
exceededLimitsState,
);
screen.getByText('Upgrade now');
screen.getByText('Limited history is displayed', {exact: false});
screen.getByText('Full access to message history is included in');
});
it('End users have a call to notify admin', () => {
it('pricing link is clickable', () => {
renderWithContext(
<CenterMessageLock channelId={'channelId'}/>,
endUserLimitExceeded,
);
screen.getByText('Notify Admin');
});
it('Filtered messages over one year old display year', () => {
renderWithContext(
<CenterMessageLock channelId={'channelId'}/>,
<CenterMessageLock/>,
exceededLimitsState,
);
screen.getByText('January 1, 1970', {exact: false});
});
it('New filtered messages do not show year', () => {
const state = JSON.parse(JSON.stringify(exceededLimitsState));
const now = new Date();
const firstOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const expectedDate = firstOfMonth.toLocaleString('en', {month: 'long', day: 'numeric'});
state.entities.posts.posts.c.create_at = Date.parse(firstOfMonth.toUTCString());
renderWithContext(
<CenterMessageLock channelId={'channelId'}/>,
state,
);
screen.getByText(expectedDate, {exact: false});
});
it('when there are no messages, uses day after day of most recently archived post', () => {
const now = Date.now();
const secondOfMonth = new Date(now + (1000 * 60 * 60 * 24));
const expectedDate = secondOfMonth.toLocaleString('en', {month: 'long', day: 'numeric'});
renderWithContext(
<CenterMessageLock
channelId={'channelId'}
firstInaccessiblePostTime={now}
/>,
exceededLimitsStateNoAccessiblePosts,
);
screen.getByText(expectedDate, {exact: false});
const pricingLink = screen.getByText('paid plans');
expect(pricingLink.tagName).toBe('A');
expect(pricingLink).toHaveAttribute('href', '#');
expect(pricingLink).toBeVisible();
});
});

View file

@ -3,165 +3,56 @@
import React from 'react';
import {useIntl} from 'react-intl';
import type {FormatDateOptions} from 'react-intl';
import {useSelector} from 'react-redux';
import {EyeOffOutlineIcon} from '@mattermost/compass-icons/components';
import type {GlobalState} from '@mattermost/types/store';
import {getOldestPostTimeInChannel} from 'mattermost-redux/selectors/entities/posts';
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
import {isAdmin} from 'mattermost-redux/utils/user_utils';
import useGetLimits from 'components/common/hooks/useGetLimits';
import {NotifyStatus} from 'components/common/hooks/useGetNotifyAdmin';
import useGetServerLimits from 'components/common/hooks/useGetServerLimits';
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
import {useNotifyAdmin} from 'components/notify_admin_cta/notify_admin_cta';
import {LicenseSkus, MattermostFeatures} from 'utils/constants';
import './index.scss';
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
const ONE_YEAR_MS = ONE_DAY_MS * 365;
interface Props {
channelId?: string;
firstInaccessiblePostTime?: number;
}
// returns the same time on the next day.
function getNextDay(timestamp?: number): number {
if (timestamp === undefined) {
return 0;
}
return timestamp + ONE_DAY_MS;
}
export default function CenterMessageLock(props: Props) {
export default function CenterMessageLock() {
const intl = useIntl();
const {openPricingModal, isAirGapped} = useOpenPricingModal();
const isAdminUser = isAdmin(useSelector(getCurrentUser).roles);
const [cloudLimits, limitsLoaded] = useGetLimits();
const currentTeam = useSelector(getCurrentTeam);
const {openPricingModal} = useOpenPricingModal();
// firstInaccessiblePostTime is the most recently inaccessible post's created at date.
// It is used as a backup for when there are no available posts in the channel;
// The message then shows that the user can retrieve messages prior to the day
// **after** the most recent day with inaccessible posts.
const oldestPostTime = useSelector((state: GlobalState) => getOldestPostTimeInChannel(state, props.channelId || '')) || getNextDay(props.firstInaccessiblePostTime);
const [notifyAdminBtnText, notifyAdmin, notifyRequestStatus] = useNotifyAdmin({
ctaText: intl.formatMessage({
id: 'workspace_limits.message_history.locked.cta.end_user',
defaultMessage: 'Notify Admin',
}),
}, {
required_feature: MattermostFeatures.UNLIMITED_MESSAGES,
required_plan: LicenseSkus.Professional,
trial_notification: false,
});
const [limitsLoaded] = useGetServerLimits();
if (!limitsLoaded) {
return null;
}
const dateFormat: FormatDateOptions = {month: 'long', day: 'numeric'};
if (Date.now() - oldestPostTime >= ONE_YEAR_MS) {
dateFormat.year = 'numeric';
}
const titleValues = {
date: intl.formatDate(oldestPostTime, dateFormat),
team: currentTeam?.display_name,
};
const title = intl.formatMessage({
id: 'workspace_limits.message_history.locked.title.admin',
defaultMessage: 'Limited history is displayed',
});
const limit = intl.formatNumber(cloudLimits?.messages?.history || 0);
let title = intl.formatMessage(
const description = intl.formatMessage(
{
id: 'workspace_limits.message_history.locked.title.end_user',
defaultMessage: 'Notify your admin to unlock messages prior to {date} in {team}',
},
titleValues,
);
let description: React.ReactNode = intl.formatMessage(
{
id: 'workspace_limits.message_history.locked.description.end_user',
defaultMessage: 'Some older messages may not be shown because your workspace has over {limit} messages. Select Notify Admin to send an automatic request to your System Admins to upgrade.',
id: 'workspace_limits.message_history.locked.description.admin',
defaultMessage: 'Full access to message history is included in <a>paid plans</a>',
},
{
limit,
a: (chunks: React.ReactNode | React.ReactNodeArray) => (
<a
href='#'
onClick={(e: React.MouseEvent) => {
e.preventDefault();
openPricingModal();
}}
>
{chunks}
</a>
),
},
);
let cta: React.ReactNode = (
<button
className='btn btn-primary'
onClick={(e) => notifyAdmin(e)}
disabled={notifyRequestStatus === NotifyStatus.AlreadyComplete}
>
{notifyAdminBtnText}
</button>);
if (isAdminUser) {
title = intl.formatMessage({
id: 'workspace_limits.message_history.locked.title.admin',
defaultMessage: 'Unlock messages prior to {date} in {team}',
}, titleValues);
if (isAirGapped) {
description = intl.formatMessage(
{
id: 'workspace_limits.message_history.locked.description.admin.airgapped',
defaultMessage: 'To view and search all of the messages in your workspace\'s history, rather than just the most recent {limit} messages, upgrade to one of our paid plans.',
},
{
limit,
},
);
} else {
description = intl.formatMessage(
{
id: 'workspace_limits.message_history.locked.description.admin',
defaultMessage: 'To view and search all of the messages in your workspace\'s history, rather than just the most recent {limit} messages, upgrade to one of our paid plans. <a>Review our plan options and pricing.</a>',
},
{
limit,
a: (chunks: React.ReactNode | React.ReactNodeArray) => (
<a
href='#'
onClick={(e: React.MouseEvent) => {
e.preventDefault();
openPricingModal();
}}
>
{chunks}
</a>
),
},
);
}
cta = isAirGapped ? null : (
<button
className='btn is-admin'
onClick={() => openPricingModal()}
>
{
intl.formatMessage({
id: 'workspace_limits.message_history.locked.cta.admin',
defaultMessage: 'Upgrade now',
})
}
</button>
);
}
return (<div className='CenterMessageLock'>
<div className='CenterMessageLock__left'>
<EyeOffOutlineIcon color={'rgba(var(--center-channel-color-rgb), 0.75)'}/>
<EyeOffOutlineIcon
size={16}
color={'rgba(var(--center-channel-color-rgb), 0.75)'}
/>
</div>
<div className='CenterMessageLock__right'>
<div className='CenterMessageLock__title'>
@ -170,9 +61,6 @@ export default function CenterMessageLock(props: Props) {
<div className='CenterMessageLock__description'>
{description}
</div>
<div className='CenterMessageLock__cta'>
{cta}
</div>
</div>
</div>);
}

View file

@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useMemo, useState} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import type {ServerLimits} from '@mattermost/types/limits';
import {getServerLimits as getServerLimitsAction} from 'mattermost-redux/actions/limits';
import {getServerLimits as getServerLimitsSelector} from 'mattermost-redux/selectors/entities/limits';
import {useIsLoggedIn} from 'components/global_header/hooks';
export default function useGetServerLimits(): [ServerLimits, boolean] {
const isLoggedIn = useIsLoggedIn();
const serverLimits = useSelector(getServerLimitsSelector);
const dispatch = useDispatch();
const [requested, setRequested] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
// All logged-in users can fetch server limits (server handles permission filtering)
if (isLoggedIn && !requested) {
dispatch(getServerLimitsAction());
setRequested(true);
}
}, [isLoggedIn, requested, dispatch]);
useEffect(() => {
// Mark as loaded when we have server limits data
if (serverLimits && (serverLimits.postHistoryLimit !== undefined || serverLimits.activeUserCount >= 0)) {
setLoaded(true);
}
}, [serverLimits]);
const result: [ServerLimits, boolean] = useMemo(() => {
return [serverLimits, loaded];
}, [serverLimits, loaded]);
return result;
}

View file

@ -5,10 +5,8 @@ import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {getCloudLimits, getCloudLimitsLoaded} from 'mattermost-redux/selectors/entities/cloud';
import {getCurrentChannelId, getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
import {getLimitedViews, getPost} from 'mattermost-redux/selectors/entities/posts';
import {getUsage} from 'mattermost-redux/selectors/entities/usage';
import {emitShortcutReactToLastPostFrom} from 'actions/post_actions';
import {getShortcutReactToLastPostEmittedFrom} from 'selectors/emojis';
@ -24,26 +22,20 @@ type OwnProps = Pick<PostListRowProps, 'listId'>
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
const shortcutReactToLastPostEmittedFrom = getShortcutReactToLastPostEmittedFrom(state);
const usage = getUsage(state);
const limits = getCloudLimits(state);
const limitsLoaded = getCloudLimitsLoaded(state);
const post = getPost(state, ownProps.listId);
const currentUserId = getCurrentUserId(state);
const newMessagesSeparatorActions = state.plugins.components.NewMessagesSeparatorAction;
const props: Pick<
PostListRowProps,
'shortcutReactToLastPostEmittedFrom' | 'usage' | 'limits' | 'limitsLoaded' | 'exceededLimitChannelId' | 'firstInaccessiblePostTime' | 'post' | 'currentUserId' | 'newMessagesSeparatorActions'
'shortcutReactToLastPostEmittedFrom'| 'exceededLimitChannelId' | 'firstInaccessiblePostTime' | 'post' | 'currentUserId' | 'newMessagesSeparatorActions'
> = {
shortcutReactToLastPostEmittedFrom,
usage,
limits,
limitsLoaded,
post,
currentUserId,
newMessagesSeparatorActions,
};
if ((ownProps.listId === PostListRowListIds.OLDER_MESSAGES_LOADER || ownProps.listId === PostListRowListIds.CHANNEL_INTRO_MESSAGE) && limitsLoaded) {
if ((ownProps.listId === PostListRowListIds.OLDER_MESSAGES_LOADER || ownProps.listId === PostListRowListIds.CHANNEL_INTRO_MESSAGE)) {
const currentChannelId = getCurrentChannelId(state);
const firstInaccessiblePostTime = getLimitedViews(state).channels[currentChannelId];
const channelLimitExceeded = Boolean(firstInaccessiblePostTime) || firstInaccessiblePostTime === 0;

View file

@ -5,7 +5,6 @@ import classNames from 'classnames';
import React from 'react';
import {FormattedMessage} from 'react-intl';
import type {CloudUsage, Limits} from '@mattermost/types/cloud';
import type {Post} from '@mattermost/types/posts';
import type {UserProfile} from '@mattermost/types/users';
@ -51,10 +50,6 @@ export type PostListRowProps = {
*/
loadingNewerPosts: boolean;
loadingOlderPosts: boolean;
usage: CloudUsage;
limits: Limits;
limitsLoaded: boolean;
exceededLimitChannelId?: string;
firstInaccessiblePostTime?: number;
channelId: string;
@ -125,10 +120,7 @@ export default class PostListRow extends React.PureComponent<PostListRowProps> {
if (this.props.exceededLimitChannelId) {
return (
<CenterMessageLock
channelId={this.props.exceededLimitChannelId}
firstInaccessiblePostTime={this.props.firstInaccessiblePostTime}
/>
<CenterMessageLock/>
);
}

View file

@ -0,0 +1,3 @@
.SearchLimitsBanner__wrapper {
padding-top: 16px;
}

View file

@ -6,7 +6,7 @@ import {Provider} from 'react-redux';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import mockStore from 'tests/test_store';
import {CloudProducts} from 'utils/constants';
import {DataSearchTypes} from 'utils/constants';
import {FileSizes} from 'utils/file_utils';
import {makeEmptyUsage} from 'utils/limits_test';
@ -37,35 +37,13 @@ const limits = {
},
};
const products = {
prod_1: {
id: 'prod_1',
sku: CloudProducts.STARTER,
price_per_seat: 0,
name: 'Cloud Free',
},
prod_2: {
id: 'prod_2',
sku: CloudProducts.PROFESSIONAL,
price_per_seat: 10,
name: 'Cloud Professional',
},
prod_3: {
id: 'prod_3',
sku: CloudProducts.ENTERPRISE,
price_per_seat: 30,
name: 'Cloud Enterprise',
},
};
describe('components/select_results/SearchLimitsBanner', () => {
test('should NOT show banner for non cloud when doing messages search', () => {
test('should NOT show banner for no limits when doing messages search', () => {
const state = {
entities: {
general: {
license: {
IsLicensed: 'true',
Cloud: 'false', // not cloud
},
},
users: {
@ -74,124 +52,32 @@ describe('components/select_results/SearchLimitsBanner', () => {
uid: {},
},
},
cloud: {
limits: {
limits: {},
limitsLoaded: false,
},
limits: {
serverLimits: undefined,
},
search: {
results: [],
flagged: [],
isSearchingTerm: false,
isSearchGettingMore: false,
matches: {},
recent: {},
current: {},
truncationInfo: undefined,
},
usage,
},
views: {
rhs: {
rhsState: null, // No RHS state
},
},
};
const store = mockStore(state);
const wrapper = mountWithIntl(<Provider store={store}><SearchLimitsBanner searchType='messages'/></Provider>);
expect(wrapper.find('#messages_search_limits_banner').exists()).toEqual(false);
});
test('should NOT show banner for non cloud when doing files search', () => {
const state = {
entities: {
general: {
license: {
IsLicensed: 'true',
Cloud: 'false', // not cloud
},
},
users: {
currentUserId: 'uid',
profiles: {
uid: {},
},
},
cloud: {
limits: {
limits: {},
limitsLoaded: false,
},
},
usage,
},
};
const store = mockStore(state);
const wrapper = mountWithIntl(<Provider store={store}><SearchLimitsBanner searchType='files'/></Provider>);
expect(wrapper.find('#files_search_limits_banner').exists()).toEqual(false);
});
test('should NOT show banner for cloud when doing cloud messages search for product without limits', () => {
const aboveMessagesLimitUsage = JSON.parse(JSON.stringify(usage));
aboveMessagesLimitUsage.messages.history = 15000; // above limit of 10k
const state = {
entities: {
general: {
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'uid',
profiles: {
uid: {},
},
},
cloud: {
subscription: {
is_free_trial: 'false',
product_id: 'prod_3', // enterprise
},
products,
limits: {
limits: {},
limitsLoaded: false,
},
},
usage: aboveMessagesLimitUsage,
},
};
const store = mockStore(state);
const wrapper = mountWithIntl(<Provider store={store}><SearchLimitsBanner searchType='messages'/></Provider>);
expect(wrapper.find('#messages_search_limits_banner').exists()).toEqual(false);
});
test('should NOT show banner for cloud when doing cloud files search for product without limits', () => {
const aboveMessagesLimitUsage = JSON.parse(JSON.stringify(usage));
aboveMessagesLimitUsage.messages.history = 15000; // above limit of 10k
const state = {
entities: {
general: {
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'uid',
profiles: {
uid: {},
},
},
cloud: {
subscription: {
is_free_trial: 'false',
product_id: 'prod_3', // enterprise
},
products,
limits: {
limits: {},
limitsLoaded: false,
},
},
usage: aboveMessagesLimitUsage,
},
};
const store = mockStore(state);
const wrapper = mountWithIntl(<Provider store={store}><SearchLimitsBanner searchType='files'/></Provider>);
expect(wrapper.find('#files_search_limits_banner').exists()).toEqual(false);
});
test('should show banner for CLOUD when doing cloud messages search above the limit in Free product', () => {
test('should show banner when doing messages search above the limit in Entry with limits', () => {
const aboveMessagesLimitUsage = JSON.parse(JSON.stringify(usage));
aboveMessagesLimitUsage.messages.history = 15000; // above limit of 10K
@ -200,7 +86,6 @@ describe('components/select_results/SearchLimitsBanner', () => {
general: {
license: {
IsLicensed: 'true',
Cloud: 'true', // cloud
},
},
users: {
@ -214,10 +99,32 @@ describe('components/select_results/SearchLimitsBanner', () => {
is_free_trial: 'true',
product_id: 'prod_1', // free
},
products,
limits,
},
limits: {
serverLimits: {
postHistoryLimit: 10000,
},
},
usage: aboveMessagesLimitUsage,
search: {
results: [],
flagged: [],
isSearchingTerm: false,
isSearchGettingMore: false,
matches: {},
recent: {},
current: {},
truncationInfo: {
posts: 1, // Indicate that search is truncated
files: 0,
},
},
},
views: {
rhs: {
rhsState: 'search', // RHS showing search results
},
},
};
const store = mockStore(state);
@ -225,16 +132,16 @@ describe('components/select_results/SearchLimitsBanner', () => {
expect(wrapper.find('#messages_search_limits_banner').exists()).toEqual(true);
});
test('should show banner for CLOUD when doing cloud files search above the limit in Free product', () => {
const aboveFilesLimitUsage = JSON.parse(JSON.stringify(usage));
aboveFilesLimitUsage.files.totalStorage = 1.1 * FileSizes.Gigabyte; // above limit of 1GB
test('should display "View plans" CTA text for messages search when banner is shown', () => {
const aboveMessagesLimitUsage = JSON.parse(JSON.stringify(usage));
aboveMessagesLimitUsage.messages.history = 15000; // above limit of 10K
const state = {
entities: {
general: {
license: {
IsLicensed: 'true',
Cloud: 'true', // cloud
Cloud: 'true',
},
},
users: {
@ -248,27 +155,52 @@ describe('components/select_results/SearchLimitsBanner', () => {
is_free_trial: 'true',
product_id: 'prod_1', // free
},
products,
limits,
},
usage: aboveFilesLimitUsage,
limits: {
serverLimits: {
postHistoryLimit: 10000,
},
},
usage: aboveMessagesLimitUsage,
search: {
results: [],
flagged: [],
isSearchingTerm: false,
isSearchGettingMore: false,
matches: {},
recent: {},
current: {},
truncationInfo: {
posts: 1, // Indicate that search is truncated
files: 0,
},
},
},
views: {
rhs: {
rhsState: 'search', // RHS showing search results
},
},
};
const store = mockStore(state);
const wrapper = mountWithIntl(<Provider store={store}><SearchLimitsBanner searchType='files'/></Provider>);
expect(wrapper.find('#files_search_limits_banner').exists()).toEqual(true);
const wrapper = mountWithIntl(<Provider store={store}><SearchLimitsBanner searchType={DataSearchTypes.MESSAGES_SEARCH_TYPE}/></Provider>);
expect(wrapper.find('#messages_search_limits_banner').exists()).toEqual(true);
expect(wrapper.text()).toContain('paid plans');
});
test('should not show banner for CLOUD when doing cloud files search above the limit in PROFESSIONAL product', () => {
const aboveFilesLimitUsage = JSON.parse(JSON.stringify(usage));
aboveFilesLimitUsage.files.totalStorage = 1.1 * FileSizes.Gigabyte; // above limit of 1GB. This limit is higher in professional
test('should display correct banner message format for messages search', () => {
const aboveMessagesLimitUsage = JSON.parse(JSON.stringify(usage));
aboveMessagesLimitUsage.messages.history = 15000; // above limit of 10K
const state = {
entities: {
general: {
license: {
IsLicensed: 'true',
Cloud: 'true', // cloud
Cloud: 'true',
},
},
users: {
@ -280,19 +212,167 @@ describe('components/select_results/SearchLimitsBanner', () => {
cloud: {
subscription: {
is_free_trial: 'true',
product_id: 'prod_2', // professional
product_id: 'prod_1', // free
},
products,
limits: {
limits: {},
limitsLoaded: false,
limits,
},
limits: {
serverLimits: {
postHistoryLimit: 10000,
},
},
usage: aboveFilesLimitUsage,
usage: aboveMessagesLimitUsage,
search: {
results: [],
flagged: [],
isSearchingTerm: false,
isSearchGettingMore: false,
matches: {},
recent: {},
current: {},
truncationInfo: {
posts: 1, // Indicate that search is truncated
files: 0,
},
},
},
views: {
rhs: {
rhsState: 'search', // RHS showing search results
},
},
};
const store = mockStore(state);
const wrapper = mountWithIntl(<Provider store={store}><SearchLimitsBanner searchType='files'/></Provider>);
expect(wrapper.find('#files_search_limits_banner').exists()).toEqual(false);
const wrapper = mountWithIntl(<Provider store={store}><SearchLimitsBanner searchType={DataSearchTypes.MESSAGES_SEARCH_TYPE}/></Provider>);
const bannerText = wrapper.text();
expect(bannerText).toContain('Limited history is displayed');
expect(bannerText).toContain('Full access to message history is included in');
});
test('should render CTA link correctly when banner is shown', () => {
const aboveMessagesLimitUsage = JSON.parse(JSON.stringify(usage));
aboveMessagesLimitUsage.messages.history = 15000; // above limit of 10K
// Test focuses on verifying component renders correctly with proper CTA
const state = {
entities: {
general: {
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'uid',
profiles: {
uid: {},
},
},
cloud: {
subscription: {
is_free_trial: 'true',
product_id: 'prod_1', // free
},
limits,
},
limits: {
serverLimits: {
postHistoryLimit: 10000,
},
},
usage: aboveMessagesLimitUsage,
search: {
results: [],
flagged: [],
isSearchingTerm: false,
isSearchGettingMore: false,
matches: {},
recent: {},
current: {},
truncationInfo: {
posts: 1, // Indicate that search is truncated
files: 0,
},
},
},
views: {
rhs: {
rhsState: 'search', // RHS showing search results
},
},
};
const store = mockStore(state);
const wrapper = mountWithIntl(<Provider store={store}><SearchLimitsBanner searchType={DataSearchTypes.MESSAGES_SEARCH_TYPE}/></Provider>);
// Verify the banner is shown and contains the CTA link
expect(wrapper.find('#messages_search_limits_banner').exists()).toEqual(true);
expect(wrapper.text()).toContain('paid plans');
// Find the CTA link
const ctaLink = wrapper.find('a');
expect(ctaLink).toHaveLength(1);
});
test('should NOT show banner when RHS is showing pinned posts even with truncated search results', () => {
const aboveMessagesLimitUsage = JSON.parse(JSON.stringify(usage));
aboveMessagesLimitUsage.messages.history = 15000; // above limit of 10K
const state = {
entities: {
general: {
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'uid',
profiles: {
uid: {},
},
},
cloud: {
subscription: {
is_free_trial: 'true',
product_id: 'prod_1', // free
},
limits,
},
limits: {
serverLimits: {
postHistoryLimit: 10000,
},
},
usage: aboveMessagesLimitUsage,
search: {
results: [],
flagged: [],
isSearchingTerm: false,
isSearchGettingMore: false,
matches: {},
recent: {},
current: {},
truncationInfo: {
posts: 1, // Search is truncated, but...
files: 0,
},
},
},
views: {
rhs: {
rhsState: 'pin', // RHS showing pinned posts, not search results
},
},
};
const store = mockStore(state);
const wrapper = mountWithIntl(<Provider store={store}><SearchLimitsBanner searchType={DataSearchTypes.MESSAGES_SEARCH_TYPE}/></Provider>);
// Banner should NOT show because RHS is showing pinned posts, not search results
expect(wrapper.find('#messages_search_limits_banner').exists()).toEqual(false);
});
});

View file

@ -2,145 +2,51 @@
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import styled from 'styled-components';
import {isCurrentLicenseCloud} from 'mattermost-redux/selectors/entities/cloud';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
import {isAdmin} from 'mattermost-redux/utils/user_utils';
import type {GlobalState} from '@mattermost/types/store';
import useGetLimits from 'components/common/hooks/useGetLimits';
import useGetUsage from 'components/common/hooks/useGetUsage';
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
import {isSearchTruncated} from 'mattermost-redux/selectors/entities/search';
import {DataSearchTypes} from 'utils/constants';
import {asGBString} from 'utils/limits';
import {getRhsState} from 'selectors/rhs';
const StyledDiv = styled.div`
width: 100%;
`;
import CenterMessageLock from 'components/center_message_lock';
import useGetServerLimits from 'components/common/hooks/useGetServerLimits';
const StyledA = styled.a`
color: var(--button-bg) !important;
`;
import {DataSearchTypes, RHSStates} from 'utils/constants';
const InnerDiv = styled.div`
display: flex;
gap: 8px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
border-radius: 4px;
background-color: rgba(var(--center-channel-color-rgb), 0.04);
padding: 10px;
margin: 10px;
color: rgba(var(--center-channel-color-rgb), 0.75);
font-weight: 400;
font-size: 11px;
line-height: 16px;
letter-spacing: 0.02em;
`;
import './search_limits_banner.scss';
type Props = {
searchType: string;
}
function SearchLimitsBanner(props: Props) {
const {formatMessage, formatNumber} = useIntl();
const {openPricingModal, isAirGapped} = useOpenPricingModal();
const usage = useGetUsage();
const [cloudLimits] = useGetLimits();
const isAdminUser = isAdmin(useSelector(getCurrentUser).roles);
const isCloud = useSelector(isCurrentLicenseCloud);
const [serverLimits] = useGetServerLimits();
if (!isCloud) {
return null;
}
const currentFileStorageUsage = usage.files.totalStorage;
const fileStorageLimit = cloudLimits?.files?.total_storage;
const currentMessagesUsage = usage.messages.history;
const messagesLimit = cloudLimits?.messages?.history;
let ctaAction = formatMessage({
id: 'workspace_limits.search_limit.view_plans',
defaultMessage: 'View plans',
// Check if current search results were actually truncated
const searchTruncated = useSelector((state: GlobalState) => {
const searchType = props.searchType === DataSearchTypes.FILES_SEARCH_TYPE ? 'files' : 'posts';
return isSearchTruncated(state, searchType);
});
if (isAdminUser) {
ctaAction = formatMessage({
id: 'workspace_limits.search_limit.upgrade_now',
defaultMessage: 'Upgrade now',
});
}
// Check if RHS is currently showing search results
const rhsState = useSelector(getRhsState);
const isShowingSearchResults = rhsState === RHSStates.SEARCH;
const renderBanner = (bannerText: React.ReactNode, id: string) => {
return (<StyledDiv id={id}>
<InnerDiv>
<i className='icon-eye-off-outline'/>
<span>{bannerText}</span>
</InnerDiv>
</StyledDiv>);
};
switch (props.searchType) {
case DataSearchTypes.FILES_SEARCH_TYPE: {
if ((fileStorageLimit === undefined) || !(currentFileStorageUsage > fileStorageLimit)) {
return null;
}
const filesBannerMessage = isAirGapped ?
formatMessage({
id: 'workspace_limits.search_files_limit.banner_text_airgapped',
defaultMessage: 'Some older files may not be shown because your workspace has met its file storage limit of {storage}.',
}, {
storage: asGBString(fileStorageLimit, formatNumber),
}) :
formatMessage({
id: 'workspace_limits.search_files_limit.banner_text',
defaultMessage: 'Some older files may not be shown because your workspace has met its file storage limit of {storage}. <a>{ctaAction}</a>',
}, {
ctaAction,
storage: asGBString(fileStorageLimit, formatNumber),
a: (chunks: React.ReactNode | React.ReactNodeArray) => (
<StyledA
onClick={() => openPricingModal()}
>
{chunks}
</StyledA>
),
});
return renderBanner(filesBannerMessage, `${DataSearchTypes.FILES_SEARCH_TYPE}_search_limits_banner`);
}
case DataSearchTypes.MESSAGES_SEARCH_TYPE: {
if ((messagesLimit === undefined) || !(currentMessagesUsage > messagesLimit)) {
return null;
}
const messagesBannerMessage = isAirGapped ?
formatMessage({
id: 'workspace_limits.search_message_limit.banner_text_airgapped',
defaultMessage: 'Some older messages may not be shown because your workspace has over {messages} messages.',
}, {
messages: formatNumber(messagesLimit),
}) :
formatMessage({
id: 'workspace_limits.search_message_limit.banner_text',
defaultMessage: 'Some older messages may not be shown because your workspace has over {messages} messages. <a>{ctaAction}</a>',
}, {
ctaAction,
messages: formatNumber(messagesLimit),
a: (chunks: React.ReactNode | React.ReactNodeArray) => (
<StyledA
onClick={() => openPricingModal()}
>
{chunks}
</StyledA>
),
});
return renderBanner(messagesBannerMessage, `${DataSearchTypes.MESSAGES_SEARCH_TYPE}_search_limits_banner`);
}
default:
// Only show banner if search results were actually truncated AND currently showing search results
if (!searchTruncated || !serverLimits?.postHistoryLimit || !isShowingSearchResults) {
return null;
}
return (
<div
id={`${props.searchType}_search_limits_banner`}
className='SearchLimitsBanner__wrapper'
>
<CenterMessageLock/>
</div>
);
}
export default SearchLimitsBanner;

View file

@ -6504,13 +6504,8 @@
"workspace_limits.menu_limit.warn.files_storage": "Youre getting closer to the {limit} file storage limit. <a>{callToAction}</a>",
"workspace_limits.menu_limit.warn.messages_history": "Youre getting closer to the free {limit} message limit. <a>{callToAction}</a>",
"workspace_limits.message_history": "Message history",
"workspace_limits.message_history.locked.cta.admin": "Upgrade now",
"workspace_limits.message_history.locked.cta.end_user": "Notify Admin",
"workspace_limits.message_history.locked.description.admin": "To view and search all of the messages in your workspaces history, rather than just the most recent {limit} messages, upgrade to one of our paid plans. <a>Review our plan options and pricing.</a>",
"workspace_limits.message_history.locked.description.admin.airgapped": "To view and search all of the messages in your workspace's history, rather than just the most recent {limit} messages, upgrade to one of our paid plans.",
"workspace_limits.message_history.locked.description.end_user": "Some older messages may not be shown because your workspace has over {limit} messages. Select Notify Admin to send an automatic request to your System Admins to upgrade.",
"workspace_limits.message_history.locked.title.admin": "Unlock messages prior to {date} in {team}",
"workspace_limits.message_history.locked.title.end_user": "Notify your admin to unlock messages prior to {date} in {team}",
"workspace_limits.message_history.locked.description.admin": "Full access to message history is included in <a>paid plans</a>",
"workspace_limits.message_history.locked.title.admin": "Limited history is displayed",
"workspace_limits.message_history.short": "Messages",
"workspace_limits.message_history.short.usage": "{actual} / {limit}",
"workspace_limits.message_history.usage": "{actual} of {limit} messages ({percent}%)",
@ -6522,12 +6517,6 @@
"workspace_limits.modals.limits_reached.title.message_history": "Message history",
"workspace_limits.modals.view_plan_options": "View plan options",
"workspace_limits.modals.view_plans": "View plans",
"workspace_limits.search_files_limit.banner_text": "Some older files may not be shown because your workspace has met its file storage limit of {storage}. <a>{ctaAction}</a>",
"workspace_limits.search_files_limit.banner_text_airgapped": "Some older files may not be shown because your workspace has met its file storage limit of {storage}.",
"workspace_limits.search_limit.upgrade_now": "Upgrade now",
"workspace_limits.search_limit.view_plans": "View plans",
"workspace_limits.search_message_limit.banner_text": "Some older messages may not be shown because your workspace has over {messages} messages. <a>{ctaAction}</a>",
"workspace_limits.search_message_limit.banner_text_airgapped": "Some older messages may not be shown because your workspace has over {messages} messages.",
"workspace_limits.teams_limit_reached.tool_tip": "You've reached the team limit for your current plan. Consider upgrading to unarchive this team or archive your other teams",
"workspace_limits.teams_limit_reached.upgrade_to_unarchive": "Upgrade to Unarchive",
"workspace_limits.teams_limit_reached.view_upgrade_options": "View upgrade options",

View file

@ -4,5 +4,5 @@
import keyMirror from 'mattermost-redux/utils/key_mirror';
export default keyMirror({
RECIEVED_APP_LIMITS: null,
RECEIVED_APP_LIMITS: null,
});

View file

@ -22,6 +22,7 @@ export default keyMirror({
RECEIVED_SEARCH_FLAGGED_POSTS: null,
RECEIVED_SEARCH_PINNED_POSTS: null,
RECEIVED_SEARCH_TERM: null,
RECEIVED_SEARCH_TRUNCATION_INFO: null,
REMOVE_SEARCH_POSTS: null,
REMOVE_SEARCH_FILES: null,
});

View file

@ -70,7 +70,7 @@ export default keyMirror({
DISABLED_USER_ACCESS_TOKEN: null,
ENABLED_USER_ACCESS_TOKEN: null,
RECEIVED_USER_STATS: null,
RECIEVED_APP_LIMITS: null,
RECEIVED_APP_LIMITS: null,
RECEIVED_FILTERED_USER_STATS: null,
PROFILE_NO_LONGER_VISIBLE: null,
LOGIN: null,

View file

@ -1,101 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import nock from 'nock';
import type {ServerLimits} from '@mattermost/types/limits';
import * as Actions from 'mattermost-redux/actions/limits';
import {Client4} from 'mattermost-redux/client';
import TestHelper from '../../test/test_helper';
import configureStore from '../../test/test_store';
describe('getServerLimits', () => {
const URL_USERS_LIMITS = '/limits/server';
const defaultServerLimitsState: ServerLimits = {
activeUserCount: 0,
maxUsersLimit: 0,
};
let store = configureStore();
beforeAll(() => {
TestHelper.initBasic(Client4);
Client4.setEnableLogging(true);
});
beforeEach(() => {
store = configureStore({
entities: {
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {
roles: 'system_admin',
},
},
},
},
});
});
afterEach(() => {
nock.cleanAll();
});
afterAll(() => {
TestHelper.tearDown();
Client4.setEnableLogging(false);
});
test('should return default state for non admin users', async () => {
store = configureStore({
entities: {
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {
roles: 'system_user',
},
},
},
},
});
const {data} = await store.dispatch(Actions.getServerLimits());
expect(data).toEqual(defaultServerLimitsState);
});
test('should not return default state for non admin users', async () => {
const {data} = await store.dispatch(Actions.getServerLimits());
expect(data).not.toEqual(defaultServerLimitsState);
});
test('should return data if user is admin', async () => {
const userLimits: ServerLimits = {
activeUserCount: 600,
maxUsersLimit: 2_500,
};
nock(Client4.getBaseRoute()).
get(URL_USERS_LIMITS).
reply(200, userLimits);
const {data} = await store.dispatch(Actions.getServerLimits());
expect(data).toEqual(userLimits);
});
test('should return error if the request fails', async () => {
const errorMessage = 'test error message';
nock(Client4.getBaseRoute()).
get(URL_USERS_LIMITS).
reply(400, {message: errorMessage});
const {error} = await store.dispatch(Actions.getServerLimits());
console.log(error);
expect(error.message).toEqual(errorMessage);
});
});

View file

@ -8,23 +8,11 @@ import {LimitsTypes} from 'mattermost-redux/action_types';
import {logError} from 'mattermost-redux/actions/errors';
import {forceLogoutIfNecessary} from 'mattermost-redux/actions/helpers';
import {Client4} from 'mattermost-redux/client';
import {getCurrentUserRoles} from 'mattermost-redux/selectors/entities/users';
import type {ActionFuncAsync} from 'mattermost-redux/types/actions';
import {isAdmin} from 'mattermost-redux/utils/user_utils';
export function getServerLimits(): ActionFuncAsync<ServerLimits> {
return async (dispatch, getState) => {
const roles = getCurrentUserRoles(getState());
const amIAdmin = isAdmin(roles);
if (!amIAdmin) {
return {
data: {
activeUserCount: 0,
maxUsersLimit: 0,
},
};
}
// All users can fetch server limits - server handles permission filtering
let response;
try {
response = await Client4.getServerLimits();
@ -37,9 +25,14 @@ export function getServerLimits(): ActionFuncAsync<ServerLimits> {
const data: ServerLimits = {
activeUserCount: response?.data?.activeUserCount ?? 0,
maxUsersLimit: response?.data?.maxUsersLimit ?? 0,
maxUsersHardLimit: response?.data?.maxUsersHardLimit ?? 0,
// Post history limit fields from server response
lastAccessiblePostTime: response?.data?.lastAccessiblePostTime ?? 0,
postHistoryLimit: response?.data?.postHistoryLimit ?? 0,
};
dispatch({type: LimitsTypes.RECIEVED_APP_LIMITS, data});
dispatch({type: LimitsTypes.RECEIVED_APP_LIMITS, data});
return {data};
};

View file

@ -73,6 +73,18 @@ export function searchPostsWithParams(teamId: string, params: SearchParameter):
type: SearchTypes.SEARCH_POSTS_REQUEST,
isGettingMore,
});
// Reset truncation info for new searches (not pagination)
if (!isGettingMore) {
dispatch({
type: SearchTypes.RECEIVED_SEARCH_TRUNCATION_INFO,
data: {
firstInaccessiblePostTime: 0,
searchType: 'posts',
},
});
}
let posts;
try {
@ -108,6 +120,18 @@ export function searchPostsWithParams(teamId: string, params: SearchParameter):
},
], 'SEARCH_POST_BATCH'));
// Dispatch truncation info separately to avoid typing conflicts
const firstInaccessiblePostTime = posts.first_inaccessible_post_time || 0;
if (firstInaccessiblePostTime > 0) {
dispatch({
type: SearchTypes.RECEIVED_SEARCH_TRUNCATION_INFO,
data: {
firstInaccessiblePostTime,
searchType: 'posts',
},
});
}
return {data: posts};
};
}
@ -145,6 +169,17 @@ export function searchFilesWithParams(teamId: string, params: SearchParameter):
isGettingMore,
});
// Reset truncation info for new searches (not pagination)
if (!isGettingMore) {
dispatch({
type: SearchTypes.RECEIVED_SEARCH_TRUNCATION_INFO,
data: {
firstInaccessiblePostTime: 0,
searchType: 'files',
},
});
}
let files: FileSearchResults;
try {
files = await Client4.searchFilesWithParams(teamId, params);

View file

@ -8,7 +8,7 @@ import {LimitsTypes} from 'mattermost-redux/action_types';
function serverLimits(state = {}, action: MMReduxAction) {
switch (action.type) {
case LimitsTypes.RECIEVED_APP_LIMITS: {
case LimitsTypes.RECEIVED_APP_LIMITS: {
const serverLimits = action.data;
return {
...state,

View file

@ -8,6 +8,7 @@ import {
PostTypes,
ThreadTypes,
CloudTypes,
LimitsTypes,
} from 'mattermost-redux/action_types';
import {Posts} from 'mattermost-redux/constants';
import * as reducers from 'mattermost-redux/reducers/entities/posts';
@ -4474,5 +4475,53 @@ describe('limitedViews', () => {
expect(nextState).toEqual(initialState);
expect(nextState).toBe(initialState); // reference equality preserved
});
it(`${LimitsTypes.RECEIVED_APP_LIMITS} clears out limited views if there are no longer post history limits`, () => {
const initialState = {...zeroState, channels: {channelId: 123}};
const nextState = reducers.limitedViews(initialState, {
type: LimitsTypes.RECEIVED_APP_LIMITS,
data: {
postHistoryLimit: 0,
activeUserCount: 100,
maxUsersLimit: 0,
maxUsersHardLimit: 0,
lastAccessiblePostTime: 0,
},
});
expect(nextState).toEqual(zeroState);
});
it(`${LimitsTypes.RECEIVED_APP_LIMITS} clears out limited views if postHistoryLimit is undefined`, () => {
const initialState = {...zeroState, channels: {channelId: 123}};
const nextState = reducers.limitedViews(initialState, {
type: LimitsTypes.RECEIVED_APP_LIMITS,
data: {
activeUserCount: 100,
maxUsersLimit: 0,
maxUsersHardLimit: 0,
lastAccessiblePostTime: 0,
},
});
expect(nextState).toEqual(zeroState);
});
it(`${LimitsTypes.RECEIVED_APP_LIMITS} preserves limited views if there are still post history limits`, () => {
const initialState = {...zeroState, channels: {channelId: 123}};
const nextState = reducers.limitedViews(initialState, {
type: LimitsTypes.RECEIVED_APP_LIMITS,
data: {
postHistoryLimit: 1000,
activeUserCount: 100,
maxUsersLimit: 0,
maxUsersHardLimit: 0,
lastAccessiblePostTime: 1234567890,
},
});
expect(nextState).toEqual(initialState);
expect(nextState).toBe(initialState); // reference equality preserved
});
});
});

View file

@ -20,7 +20,7 @@ import type {
} from '@mattermost/types/utilities';
import type {MMReduxAction} from 'mattermost-redux/action_types';
import {ChannelTypes, PostTypes, UserTypes, ThreadTypes, CloudTypes} from 'mattermost-redux/action_types';
import {ChannelTypes, PostTypes, UserTypes, ThreadTypes, CloudTypes, LimitsTypes} from 'mattermost-redux/action_types';
import {Posts} from 'mattermost-redux/constants';
import {comparePosts, isPermalink, shouldUpdatePost} from 'mattermost-redux/utils/post_utils';
@ -1513,6 +1513,16 @@ export function limitedViews(
}
return state;
}
case LimitsTypes.RECEIVED_APP_LIMITS: {
const serverLimits = action.data;
// If server limits change and there is no post history limit any more (e.g. upgrade to unlimited plan),
// this state is stale and should be dumped.
if (!serverLimits?.postHistoryLimit || serverLimits.postHistoryLimit <= 0) {
return zeroStateLimitedViews;
}
return state;
}
case ChannelTypes.LEAVE_CHANNEL: {
const channelId = action.data.id;
if (!state.channels[channelId]) {

View file

@ -268,6 +268,32 @@ function isLimitedResults(state = -1, action: MMReduxAction): number {
}
}
function truncationInfo(state = {posts: 0, files: 0}, action: MMReduxAction) {
switch (action.type) {
case SearchTypes.RECEIVED_SEARCH_TRUNCATION_INFO: {
const {firstInaccessiblePostTime, searchType} = action.data;
return {
...state,
[searchType]: firstInaccessiblePostTime,
};
}
case SearchTypes.REMOVE_SEARCH_POSTS:
return {
...state,
posts: 0,
};
case SearchTypes.REMOVE_SEARCH_FILES:
return {
...state,
files: 0,
};
case UserTypes.LOGOUT_SUCCESS:
return {posts: 0, files: 0};
default:
return state;
}
}
export default combineReducers({
// An ordered array with posts ids of flagged posts
@ -297,4 +323,7 @@ export default combineReducers({
// Boolean true if the search returns results inaccessible because
// they are beyond a cloud workspace's message limits.
isLimitedResults,
// Object tracking truncation info for posts and files separately
truncationInfo,
});

View file

@ -7,3 +7,18 @@ import type {GlobalState} from '@mattermost/types/store';
export function getServerLimits(state: GlobalState): ServerLimits {
return state.entities.limits.serverLimits;
}
// Add new selectors for post history limits
export function getPostHistoryLimit(state: GlobalState): number {
const limits = getServerLimits(state);
return limits?.postHistoryLimit ?? 0;
}
export function hasPostHistoryLimit(state: GlobalState): boolean {
const limits = getServerLimits(state);
return (limits?.postHistoryLimit ?? 0) > 0;
}
export function shouldShowPostHistoryLimits(state: GlobalState): boolean {
return getPostHistoryLimit(state) > 0;
}

View file

@ -26,3 +26,12 @@ export const getAllUserMentionKeys: (state: GlobalState) => UserMentionKey[] = c
return userMentionKeys.concat(groupMentionKeys);
},
);
export const getSearchTruncationInfo = (state: GlobalState) => {
return state.entities.search.truncationInfo;
};
export const isSearchTruncated = (state: GlobalState, searchType: 'posts' | 'files'): boolean => {
const truncationInfo = getSearchTruncationInfo(state);
return Boolean(truncationInfo && truncationInfo[searchType] > 0);
};

View file

@ -8,4 +8,9 @@ export type LimitsState = {
export type ServerLimits = {
activeUserCount: number;
maxUsersLimit: number;
};
maxUsersHardLimit?: number;
// Post history limit fields
lastAccessiblePostTime?: number; // Timestamp of the last accessible post (0 if no limits)
postHistoryLimit?: number; // The actual message history limit value (0 if no limits)
}

View file

@ -18,6 +18,10 @@ export type SearchState = {
matches: {
[x: string]: string[];
};
truncationInfo?: {
posts: number;
files: number;
};
};
export type SearchParameter = {