Automatic channel category sorting (#30866)

* Automatic channel category sorting

* Fix types

* AIed

* Fix issue where categories are updated for all users

* Move all logic to server, clean up

* PR feedback

* Fix lint

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
Devin Binnie
2025-06-11 14:29:36 -04:00
коммит произвёл GitHub
родитель c6a11763a8
Коммит 25a4839a9e
17 изменённых файлов: 289 добавлений и 30 удалений

Просмотреть файл

@@ -608,6 +608,7 @@ const defaultServerConfig: AdminConfig = {
DisableWakeUpReconnectHandler: false,
UsersStatusAndProfileFetchingPollIntervalMilliseconds: 3000,
YoutubeReferrerPolicy: false,
ExperimentalChannelCategorySorting: false,
},
AnalyticsSettings: {
MaxUsersForStatistics: 2500,

Просмотреть файл

@@ -181,6 +181,8 @@ func (a *App) CreateChannelWithUser(c request.CTX, channel *model.Channel, userI
return nil, err
}
a.addChannelToDefaultCategory(c, userID, channel)
var user *model.User
if user, err = a.GetUser(userID); err != nil {
return nil, err
@@ -208,6 +210,10 @@ func (a *App) RenameChannel(c request.CTX, channel *model.Channel, newChannelNam
return nil, model.NewAppError("RenameChannel", "api.channel.rename_channel.cant_rename_group_messages.app_error", nil, "", http.StatusBadRequest)
}
// Clean up the channel name and display name
newChannelName = strings.TrimSpace(newChannelName)
newDisplayName = strings.TrimSpace(newDisplayName)
channel.Name = newChannelName
if newDisplayName != "" {
channel.DisplayName = newDisplayName
@@ -222,6 +228,8 @@ func (a *App) RenameChannel(c request.CTX, channel *model.Channel, newChannelNam
}
func (a *App) CreateChannel(c request.CTX, channel *model.Channel, addMember bool) (*model.Channel, *model.AppError) {
a.handleChannelCategoryName(channel)
channel.DisplayName = strings.TrimSpace(channel.DisplayName)
sc, nErr := a.Srv().Store().Channel().Save(c, channel, *a.Config().TeamSettings.MaxChannelsPerTeam)
if nErr != nil {
@@ -864,11 +872,14 @@ func (a *App) PatchChannel(c request.CTX, channel *model.Channel, patch *model.C
oldChannelPurpose := channel.Purpose
channel.Patch(patch)
a.handleChannelCategoryName(channel)
channel, err := a.UpdateChannel(c, channel)
if err != nil {
return nil, err
}
a.addChannelToDefaultCategory(c, userID, channel)
if oldChannelDisplayName != channel.DisplayName {
if err = a.PostUpdateChannelDisplayNameMessage(c, userID, channel, oldChannelDisplayName, channel.DisplayName); err != nil {
c.Logger().Warn(err.Error())
@@ -1665,6 +1676,8 @@ func (a *App) AddUserToChannel(c request.CTX, user *model.User, channel *model.C
return nil, err
}
a.addChannelToDefaultCategory(c, user.Id, channel)
// We are sending separate websocket events to the user added and to the channel
// This is to get around potential cluster syncing issues where other nodes may not receive the most up to date channel members
message := model.NewWebSocketEvent(model.WebsocketEventUserAdded, "", channel.Id, "", map[string]bool{user.Id: true}, "")
@@ -3878,3 +3891,56 @@ func (a *App) ChannelAccessControlled(c request.CTX, channelID string) (bool, *m
return true, nil
}
func (a *App) handleChannelCategoryName(channel *model.Channel) {
if *a.Config().ExperimentalSettings.ExperimentalChannelCategorySorting && strings.Contains(channel.DisplayName, "/") {
parts := strings.Split(channel.DisplayName, "/")
channel.DisplayName = strings.TrimSpace(strings.Join(parts[1:], "/"))
channel.DefaultCategoryName = strings.TrimSpace(parts[0])
}
}
func (a *App) addChannelToDefaultCategory(c request.CTX, userID string, channel *model.Channel) {
// Add channel to default category if specified
if channel.DefaultCategoryName != "" && *a.Config().ExperimentalSettings.ExperimentalChannelCategorySorting {
// Get user's categories for this team
categories, err := a.GetSidebarCategoriesForTeamForUser(c, userID, channel.TeamId)
if err != nil {
mlog.Error("Failed to get sidebar categories", mlog.String("user_id", userID), mlog.String("team_id", channel.TeamId), mlog.Err(err))
return
}
// Find or create the category
var targetCategory *model.SidebarCategoryWithChannels
for _, category := range categories.Categories {
if category.Type == model.SidebarCategoryCustom && strings.EqualFold(category.DisplayName, channel.DefaultCategoryName) {
targetCategory = category
break
}
}
if targetCategory == nil {
// Create new category if it doesn't exist
targetCategory = &model.SidebarCategoryWithChannels{
SidebarCategory: model.SidebarCategory{
UserId: userID,
TeamId: channel.TeamId,
Type: model.SidebarCategoryCustom,
DisplayName: channel.DefaultCategoryName,
Sorting: model.SidebarCategorySortDefault,
},
Channels: []string{channel.Id},
}
_, err = a.CreateSidebarCategory(c, userID, channel.TeamId, targetCategory)
if err != nil {
mlog.Error("Failed to create default category", mlog.String("user_id", userID), mlog.String("team_id", channel.TeamId), mlog.String("category_name", channel.DefaultCategoryName), mlog.Err(err))
}
} else {
// Add channel to existing category
targetCategory.Channels = append([]string{channel.Id}, targetCategory.Channels...)
_, err = a.UpdateSidebarCategories(c, userID, channel.TeamId, []*model.SidebarCategoryWithChannels{targetCategory})
if err != nil {
mlog.Error("Failed to update default category", mlog.String("user_id", userID), mlog.String("team_id", channel.TeamId), mlog.String("category_name", channel.DefaultCategoryName), mlog.Err(err))
}
}
}
}

Просмотреть файл

@@ -3395,3 +3395,139 @@ func TestPatchChannel(t *testing.T) {
require.Equal(t, "model.channel.is_valid.banner_info.channel_type.app_error", appErr.Id)
})
}
func TestCreateChannelWithCategorySorting(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
// Enable ExperimentalChannelCategorySorting
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ExperimentalSettings.ExperimentalChannelCategorySorting = true
})
t.Run("should set category when adding user to channel with category and trim white spaces", func(t *testing.T) {
channel := &model.Channel{
DisplayName: " Category / Channel Name ",
Name: "name1",
Type: model.ChannelTypeOpen,
TeamId: th.BasicTeam.Id,
}
channel, appErr := th.App.CreateChannelWithUser(th.Context, channel, th.BasicUser.Id)
require.Nil(t, appErr)
require.Equal(t, "Channel Name", channel.DisplayName)
require.Equal(t, "Category", channel.DefaultCategoryName)
// Verify channel is in default category
categories, appErr := th.App.GetSidebarCategoriesForTeamForUser(th.Context, th.BasicUser.Id, th.BasicTeam.Id)
require.Nil(t, appErr)
foundCategory := false
for _, category := range categories.Categories {
if category.DisplayName == "Category" {
foundCategory = true
assert.Contains(t, category.Channels, channel.Id)
break
}
}
assert.True(t, foundCategory, "Category 'Category' not found in sidebar categories")
// Add user to channel
_, appErr = th.App.AddUserToChannel(th.Context, th.BasicUser2, channel, false)
require.Nil(t, appErr)
// Verify channel is in default category
categories2, appErr := th.App.GetSidebarCategoriesForTeamForUser(th.Context, th.BasicUser2.Id, th.BasicTeam.Id)
require.Nil(t, appErr)
foundCategory2 := false
for _, category := range categories2.Categories {
if category.DisplayName == "Category" {
foundCategory2 = true
assert.Contains(t, category.Channels, channel.Id)
break
}
}
assert.True(t, foundCategory2, "Category 'Category' not found in sidebar categories")
})
t.Run("should not set category when feature is disabled", func(t *testing.T) {
channel := &model.Channel{
DisplayName: "Category2/Channel Name",
Name: "name2",
Type: model.ChannelTypeOpen,
TeamId: th.BasicTeam.Id,
}
channel, appErr := th.App.CreateChannel(th.Context, channel, false)
require.Nil(t, appErr)
require.Equal(t, "Channel Name", channel.DisplayName)
require.Equal(t, "Category2", channel.DefaultCategoryName)
// Disable ExperimentalChannelCategorySorting
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ExperimentalSettings.ExperimentalChannelCategorySorting = false
})
// Add user to channel
_, appErr = th.App.AddUserToChannel(th.Context, th.BasicUser, channel, false)
require.Nil(t, appErr)
// Verify channel is in default category
categories, appErr := th.App.GetSidebarCategoriesForTeamForUser(th.Context, th.BasicUser.Id, th.BasicTeam.Id)
require.Nil(t, appErr)
foundCategory := false
for _, category := range categories.Categories {
if category.DisplayName == "Category2" {
foundCategory = true
break
}
}
assert.False(t, foundCategory, "Category 'Category2' not found in sidebar categories")
})
}
func TestPatchChannelWithCategorySorting(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
// Enable ExperimentalChannelCategorySorting
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ExperimentalSettings.ExperimentalChannelCategorySorting = true
})
// Create initial channel
channel := th.createChannel(th.Context, th.BasicTeam, model.ChannelTypeOpen)
channel.DisplayName = "Initial Name"
channel, appErr := th.App.UpdateChannel(th.Context, channel)
require.Nil(t, appErr)
// Add user to channel
_, appErr = th.App.AddUserToChannel(th.Context, th.BasicUser, channel, false)
require.Nil(t, appErr)
// Patch channel with new display name containing category
patch := &model.ChannelPatch{
DisplayName: model.NewPointer(" New Category / New Channel Name "),
}
patchedChannel, appErr := th.App.PatchChannel(th.Context, channel, patch, channel.CreatorId)
require.Nil(t, appErr)
require.Equal(t, "New Channel Name", patchedChannel.DisplayName)
require.Equal(t, "New Category", patchedChannel.DefaultCategoryName)
// Test that category is not updated when feature is disabled
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ExperimentalSettings.ExperimentalChannelCategorySorting = false
})
patch = &model.ChannelPatch{
DisplayName: model.NewPointer("Disabled Category/Channel Name"),
}
patchedChannel, appErr = th.App.PatchChannel(th.Context, channel, patch, channel.CreatorId)
require.Nil(t, appErr)
require.Equal(t, "Disabled Category/Channel Name", patchedChannel.DisplayName)
require.Equal(t, "New Category", patchedChannel.DefaultCategoryName)
}

Просмотреть файл

@@ -271,6 +271,8 @@ channels/db/migrations/mysql/000136_create_attribute_view.down.sql
channels/db/migrations/mysql/000136_create_attribute_view.up.sql
channels/db/migrations/mysql/000137_update_attribute_view.down.sql
channels/db/migrations/mysql/000137_update_attribute_view.up.sql
channels/db/migrations/mysql/000138_add_default_category_name_to_channel.down.sql
channels/db/migrations/mysql/000138_add_default_category_name_to_channel.up.sql
channels/db/migrations/postgres/000001_create_teams.down.sql
channels/db/migrations/postgres/000001_create_teams.up.sql
channels/db/migrations/postgres/000002_create_team_members.down.sql
@@ -543,3 +545,5 @@ channels/db/migrations/postgres/000136_create_attribute_view.down.sql
channels/db/migrations/postgres/000136_create_attribute_view.up.sql
channels/db/migrations/postgres/000137_update_attribute_view.down.sql
channels/db/migrations/postgres/000137_update_attribute_view.up.sql
channels/db/migrations/postgres/000138_add_default_category_name_to_channel.down.sql
channels/db/migrations/postgres/000138_add_default_category_name_to_channel.up.sql

Просмотреть файл

@@ -0,0 +1,14 @@
SET @preparedStatement = (SELECT IF(
EXISTS(
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE table_name = 'Channels'
AND table_schema = DATABASE()
AND column_name = 'DefaultCategoryName'
),
'ALTER TABLE Channels DROP COLUMN DefaultCategoryName;',
'SELECT 1;'
));
PREPARE removeColumnIfExists FROM @preparedStatement;
EXECUTE removeColumnIfExists;
DEALLOCATE PREPARE removeColumnIfExists;

Просмотреть файл

@@ -0,0 +1,14 @@
SET @preparedStatement = (SELECT IF(
NOT EXISTS(
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE table_name = 'Channels'
AND table_schema = DATABASE()
AND column_name = 'DefaultCategoryName'
),
'ALTER TABLE Channels ADD COLUMN DefaultCategoryName varchar(64) NOT NULL DEFAULT "";',
'SELECT 1;'
));
PREPARE addColumnIfNotExists FROM @preparedStatement;
EXECUTE addColumnIfNotExists;
DEALLOCATE PREPARE addColumnIfNotExists;

Просмотреть файл

@@ -0,0 +1 @@
ALTER TABLE channels DROP COLUMN IF EXISTS DefaultCategoryName;

Просмотреть файл

@@ -0,0 +1 @@
ALTER TABLE channels ADD COLUMN IF NOT EXISTS DefaultCategoryName varchar(64) NOT NULL DEFAULT '';

Просмотреть файл

@@ -137,6 +137,7 @@ func channelSliceColumns(isSelect bool, prefix ...string) []string {
p + "TotalMsgCountRoot",
p + "LastRootPostAt",
p + "BannerInfo",
p + "DefaultCategoryName",
}
if isSelect {
@@ -172,6 +173,7 @@ func channelToSlice(channel *model.Channel) []any {
channel.TotalMsgCountRoot,
channel.LastRootPostAt,
channel.BannerInfo,
channel.DefaultCategoryName,
}
}
@@ -834,7 +836,8 @@ func (s SqlChannelStore) updateChannelT(transaction *sqlxTxWrapper, channel *mod
Shared=:Shared,
TotalMsgCountRoot=:TotalMsgCountRoot,
LastRootPostAt=:LastRootPostAt,
BannerInfo=:BannerInfo
BannerInfo=:BannerInfo,
DefaultCategoryName=:DefaultCategoryName
WHERE Id=:Id`, channel)
if err != nil {
if IsUniqueConstraintError(err, []string{"Name", "channels_name_teamid_key"}) {

Просмотреть файл

@@ -100,6 +100,7 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li
props["DisableRefetchingOnBrowserFocus"] = strconv.FormatBool(*c.ExperimentalSettings.DisableRefetchingOnBrowserFocus)
props["DisableWakeUpReconnectHandler"] = strconv.FormatBool(*c.ExperimentalSettings.DisableWakeUpReconnectHandler)
props["UsersStatusAndProfileFetchingPollIntervalMilliseconds"] = strconv.FormatInt(*c.ExperimentalSettings.UsersStatusAndProfileFetchingPollIntervalMilliseconds, 10)
props["ExperimentalChannelCategorySorting"] = strconv.FormatBool(*c.ExperimentalSettings.ExperimentalChannelCategorySorting)
// Here we set the new option, but we also send the old FeatureFlag property for backwards compatibility on mobile < 2.27
props["EnableCrossTeamSearch"] = strconv.FormatBool(*c.ServiceSettings.EnableCrossTeamSearch)

Просмотреть файл

@@ -77,29 +77,30 @@ func (c ChannelBannerInfo) Value() (driver.Value, error) {
}
type Channel struct {
Id string `json:"id"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
DeleteAt int64 `json:"delete_at"`
TeamId string `json:"team_id"`
Type ChannelType `json:"type"`
DisplayName string `json:"display_name"`
Name string `json:"name"`
Header string `json:"header"`
Purpose string `json:"purpose"`
LastPostAt int64 `json:"last_post_at"`
TotalMsgCount int64 `json:"total_msg_count"`
ExtraUpdateAt int64 `json:"extra_update_at"`
CreatorId string `json:"creator_id"`
SchemeId *string `json:"scheme_id"`
Props map[string]any `json:"props"`
GroupConstrained *bool `json:"group_constrained"`
Shared *bool `json:"shared"`
TotalMsgCountRoot int64 `json:"total_msg_count_root"`
PolicyID *string `json:"policy_id"`
LastRootPostAt int64 `json:"last_root_post_at"`
BannerInfo *ChannelBannerInfo `json:"banner_info"`
PolicyEnforced bool `json:"policy_enforced"`
Id string `json:"id"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
DeleteAt int64 `json:"delete_at"`
TeamId string `json:"team_id"`
Type ChannelType `json:"type"`
DisplayName string `json:"display_name"`
Name string `json:"name"`
Header string `json:"header"`
Purpose string `json:"purpose"`
LastPostAt int64 `json:"last_post_at"`
TotalMsgCount int64 `json:"total_msg_count"`
ExtraUpdateAt int64 `json:"extra_update_at"`
CreatorId string `json:"creator_id"`
SchemeId *string `json:"scheme_id"`
Props map[string]any `json:"props"`
GroupConstrained *bool `json:"group_constrained"`
Shared *bool `json:"shared"`
TotalMsgCountRoot int64 `json:"total_msg_count_root"`
PolicyID *string `json:"policy_id"`
LastRootPostAt int64 `json:"last_root_post_at"`
BannerInfo *ChannelBannerInfo `json:"banner_info"`
PolicyEnforced bool `json:"policy_enforced"`
DefaultCategoryName string `json:"default_category_name"`
}
func (o *Channel) Auditable() map[string]any {
@@ -362,7 +363,7 @@ func (o *Channel) IsOpen() bool {
func (o *Channel) Patch(patch *ChannelPatch) {
if patch.DisplayName != nil {
o.DisplayName = *patch.DisplayName
o.DisplayName = strings.TrimSpace(*patch.DisplayName)
}
if patch.Name != nil {

Просмотреть файл

@@ -1163,6 +1163,7 @@ type ExperimentalSettings struct {
DisableWakeUpReconnectHandler *bool `access:"experimental_features"`
UsersStatusAndProfileFetchingPollIntervalMilliseconds *int64 `access:"experimental_features"`
YoutubeReferrerPolicy *bool `access:"experimental_features"`
ExperimentalChannelCategorySorting *bool `access:"experimental_features"`
}
func (s *ExperimentalSettings) SetDefaults() {
@@ -1213,6 +1214,10 @@ func (s *ExperimentalSettings) SetDefaults() {
if s.YoutubeReferrerPolicy == nil {
s.YoutubeReferrerPolicy = NewPointer(false)
}
if s.ExperimentalChannelCategorySorting == nil {
s.ExperimentalChannelCategorySorting = NewPointer(false)
}
}
type AnalyticsSettings struct {

Просмотреть файл

@@ -6812,6 +6812,13 @@ const AdminDefinition: AdminDefinitionType = {
help_text: defineMessage({id: 'admin.experimental.youtubeReferrerPolicy.desc', defaultMessage: 'When true, the referrer policy for embedded YouTube videos will be set to "strict-origin-when-cross-origin" which resolves issues where YouTube video previews display as unavailable, while balancing the need to protect user privacy with some degree of referral data to support web functionalities, like analytics, logging, and third-party integrations. When false, the referrer policy will be set to "no-referrer" which enhances user privacy by not disclosing the source URL, but limits the ability to track user engagement and traffic sources in analytics tools.'}),
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
},
{
type: 'bool',
key: 'ExperimentalSettings.ExperimentalChannelCategorySorting',
label: defineMessage({id: 'admin.experimental.channelCategorySorting.title', defaultMessage: 'Channel Category Sorting:'}),
help_text: defineMessage({id: 'admin.experimental.channelCategorySorting.desc', defaultMessage: 'When true, channels will be automatically sorted into categories based on their names using a "/" delimiter.'}),
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
},
],
},
},

Просмотреть файл

@@ -237,7 +237,7 @@ function ChannelSettingsInfoTab({
header: channelHeader.trim(),
};
const {error} = await dispatch(patchChannel(channel.id, updated));
const {data, error} = await dispatch(patchChannel(channel.id, updated));
if (error) {
handleServerError(error as ServerError);
return false;
@@ -245,10 +245,10 @@ function ChannelSettingsInfoTab({
// After every successful save, update local state to match the saved values
// with this, we make sure that the unsavedChanges check will return false after saving
setDisplayName(updated.display_name);
setChannelURL(updated.name);
setChannelPurpose(updated.purpose);
setChannelHeader(updated.header);
setDisplayName(data?.display_name ?? updated.display_name);
setChannelURL(data?.name ?? updated.name);
setChannelPurpose(data?.purpose ?? updated.purpose);
setChannelHeader(data?.header ?? updated.header);
return true;
}, [channel, displayName, channelUrl, channelPurpose, channelHeader, channelType, setFormError, handleServerError]);

Просмотреть файл

@@ -1033,6 +1033,8 @@
"admin.experimental.allowCustomThemes.title": "Allow Custom Themes:",
"admin.experimental.allowedEmailDomain.desc": "(Optional) When set, users must have an email ending in this domain to move threads. Multiple domains can be specified by separating them with commas.",
"admin.experimental.allowedEmailDomain.title": "Allowed Email Domain",
"admin.experimental.channelCategorySorting.desc": "When true, channels will be automatically sorted into categories based on their names using a \"/\" delimiter.",
"admin.experimental.channelCategorySorting.title": "Channel Category Sorting:",
"admin.experimental.clientSideCertCheck.desc": "When **primary**, after the client side certificate is verified, users email is retrieved from the certificate and is used to log in without a password. When **secondary**, after the client side certificate is verified, users email is retrieved from the certificate and matched against the one supplied by the user. If they match, the user logs in with regular email/password credentials.",
"admin.experimental.clientSideCertCheck.options.primary": "primary",
"admin.experimental.clientSideCertCheck.options.secondary": "secondary",

Просмотреть файл

@@ -69,6 +69,7 @@ export type Channel = {
policy_id?: string | null;
banner_info?: ChannelBanner;
policy_enforced?: boolean;
default_category_name?: string;
};
export type ServerChannel = Channel & {

Просмотреть файл

@@ -112,6 +112,7 @@ export type ClientConfig = {
EnableUserDeactivation: string;
EnableUserTypingMessages: string;
EnforceMultifactorAuthentication: string;
ExperimentalChannelCategorySorting: string;
ExperimentalClientSideCertCheck: string;
ExperimentalClientSideCertEnable: string;
ExperimentalEnableAuthenticationTransfer: string;
@@ -822,6 +823,7 @@ export type ExperimentalSettings = {
DisableWakeUpReconnectHandler: boolean;
UsersStatusAndProfileFetchingPollIntervalMilliseconds: number;
YoutubeReferrerPolicy: boolean;
ExperimentalChannelCategorySorting: boolean;
};
export type AnalyticsSettings = {