MM-53125 Add feature to convert group message to private channel (#24421)

* Added convert to channel menu item

* WIP

* refactored channel name input field and created conversion modal

* style

* style

* WIP

* wip

* Created API to fetch common teams of GM members

* Added UI for all members deactivated

* Fetched common teams in client

* WIP

* Added a required attribute to DropdownInput component

* Fixed a case with dropdown input required flag

* WIP

* API first draft

* Genetayed layers and mocks

* Fixed create channel bug

* WIP

* Added cache invalidation

* Calling API from client

* Updated API to accept name and display name as well

* WIP

* Moved converted GM to correct category

* Style fixes

* Added logic to move user to new team/channel after GM conversion

* Prevented guest user from performing action

* Added loading indicator

* Added smoother height transistion when loading finishes

* UI imporvements

* WIP

* Formatted GM conversion message on client side

* lint fix

* Moved convert option from sidebar menu to channel header menu

* Some cleanup

* Updated server layers

* Fixed i18n

* Fixed types

* Fix server i18n

* Fixed channel creation bug

* Added store test for GetCommonTeamIDsForMultipleUsers

* Server tests done

* Updated snapshots

* Updated layers

* lint fix

* Update tests

* For CI

* lint

* restored debug code

* Used user ID instead of username in channel conversion post

* WIP

* Review fixes

* LInt fixes

* Test fix

* WIP

* WIP

* WIP

* wip

* Review fixes, lots of them

* Review fix

* Disabled WIP test

* test

* Cleanup

* Test fix

* removed testing line

* Fixed incorrect default message

* Review fixes

* Fixes

* lint and i18n fix

* Setting category on server side

* updated i18n

* Updated tests

* Added tests

* Refs cleanup

* added test

---------

Co-authored-by: Harshil Sharma <harshilsharma@Harshils-MacBook-Pro.local>
Этот коммит содержится в:
Harshil Sharma
2023-09-19 18:11:34 +05:30
коммит произвёл GitHub
родитель 11cdd2b66b
Коммит 39d6cb8008
46 изменённых файлов: 2243 добавлений и 119 удалений

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

@@ -477,6 +477,7 @@ type AppIface interface {
CompleteSwitchWithOAuth(service string, userData io.Reader, email string, tokenUser *model.User) (*model.User, *model.AppError)
Compliance() einterfaces.ComplianceInterface
Config() *model.Config
ConvertGroupMessageToChannel(c request.CTX, convertedByUserId string, gmConversionRequest *model.GroupMessageConversionRequestBody) (*model.Channel, *model.AppError)
CopyFileInfos(userID string, fileIDs []string) ([]string, *model.AppError)
CreateChannel(c request.CTX, channel *model.Channel, addMember bool) (*model.Channel, *model.AppError)
CreateChannelWithUser(c request.CTX, channel *model.Channel, userID string) (*model.Channel, *model.AppError)
@@ -664,6 +665,7 @@ type AppIface interface {
GetGroupMemberUsers(groupID string) ([]*model.User, *model.AppError)
GetGroupMemberUsersPage(groupID string, page int, perPage int, viewRestrictions *model.ViewUsersRestrictions) ([]*model.User, int, *model.AppError)
GetGroupMemberUsersSortedPage(groupID string, page int, perPage int, viewRestrictions *model.ViewUsersRestrictions, teammateNameDisplay string) ([]*model.User, int, *model.AppError)
GetGroupMessageMembersCommonTeams(c request.CTX, channelID string) ([]*model.Team, *model.AppError)
GetGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) (*model.GroupSyncable, *model.AppError)
GetGroupSyncables(groupID string, syncableType model.GroupSyncableType) ([]*model.GroupSyncable, *model.AppError)
GetGroups(page, perPage int, opts model.GroupSearchOpts, viewRestrictions *model.ViewUsersRestrictions) ([]*model.Group, *model.AppError)

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

@@ -11,6 +11,8 @@ import (
"net/http"
"strings"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/shared/i18n"
@@ -3478,6 +3480,231 @@ func (a *App) getDirectChannel(c request.CTX, userID, otherUserID string) (*mode
return a.Srv().getDirectChannel(c, userID, otherUserID)
}
func (a *App) GetGroupMessageMembersCommonTeams(c request.CTX, channelID string) ([]*model.Team, *model.AppError) {
channel, appErr := a.GetChannel(c, channelID)
if appErr != nil {
return nil, appErr
}
if channel.Type != model.ChannelTypeGroup {
return nil, model.NewAppError("GetGroupMessageMembersCommonTeams", "app.channel.get_common_teams.incorrect_channel_type", nil, "", http.StatusBadRequest)
}
users, appErr := a.GetUsersInChannel(&model.UserGetOptions{
PerPage: model.ChannelGroupMaxUsers,
Page: 0,
InChannelId: channelID,
Inactive: false,
Active: true,
})
var userIDs = make([]string, len(users))
for i := 0; i < len(users); i++ {
userIDs[i] = users[i].Id
}
commonTeamIDs, err := a.Srv().Store().Team().GetCommonTeamIDsForMultipleUsers(userIDs)
if err != nil {
return nil, model.NewAppError("GetGroupMessageMembersCommonTeams", "app.channel.get_common_teams.store_get_common_teams_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
teams := []*model.Team{}
if len(commonTeamIDs) > 0 {
teams, appErr = a.GetTeams(commonTeamIDs)
}
return teams, appErr
}
func (a *App) ConvertGroupMessageToChannel(c request.CTX, convertedByUserId string, gmConversionRequest *model.GroupMessageConversionRequestBody) (*model.Channel, *model.AppError) {
originalChannel, appErr := a.GetChannel(c, gmConversionRequest.ChannelID)
if appErr != nil {
return nil, appErr
}
appErr = a.validateForConvertGroupMessageToChannel(c, convertedByUserId, originalChannel, gmConversionRequest)
if appErr != nil {
return nil, appErr
}
toUpdate := originalChannel.DeepCopy()
toUpdate.Type = model.ChannelTypePrivate
toUpdate.TeamId = gmConversionRequest.TeamID
toUpdate.Name = gmConversionRequest.Name
toUpdate.DisplayName = gmConversionRequest.DisplayName
updatedChannel, appErr := a.UpdateChannel(c, toUpdate)
if appErr != nil {
return nil, appErr
}
a.Srv().Platform().InvalidateCacheForChannel(originalChannel)
users, appErr := a.GetUsersInChannelPage(&model.UserGetOptions{
InChannelId: gmConversionRequest.ChannelID,
Page: 0,
PerPage: model.ChannelGroupMaxUsers,
}, false)
if appErr != nil {
return nil, appErr
}
_ = a.setSidebarCategoriesForConvertedGroupMessage(c, gmConversionRequest, users)
_ = a.postMessageForConvertGroupMessageToChannel(c, gmConversionRequest.ChannelID, convertedByUserId, users)
return updatedChannel, nil
}
func (a *App) setSidebarCategoriesForConvertedGroupMessage(c request.CTX, gmConversionRequest *model.GroupMessageConversionRequestBody, channelUsers []*model.User) *model.AppError {
// First we'll delete channel from everyone's sidebar. Only the members of GM
// can have it in sidebar, so we can delete the channel from all SidebarChannels entries.
err := a.Srv().Store().Channel().DeleteAllSidebarChannelForChannel(gmConversionRequest.ChannelID)
if err != nil {
return model.NewAppError(
"setSidebarCategoriesForConvertedGroupMessage",
"app.channel.gm_conversion_set_categories.delete_all.error",
nil,
"",
http.StatusInternalServerError,
).Wrap(err)
}
// Now that we've deleted existing entries, we can set the channel in default "Channels" category
// for all GM members
for _, user := range channelUsers {
categories, appErr := a.GetSidebarCategories(c, user.Id, &store.SidebarCategorySearchOpts{
TeamID: gmConversionRequest.TeamID,
Type: model.SidebarCategoryChannels,
})
if appErr != nil {
mlog.Error("Failed to search sidebar categories for user for adding converted GM")
continue
}
if len(categories.Categories) < 1 {
// It is normal for user to not have the default category.
// The default "Channels" category is created when the user first logs in,
// and all their channels are moved to this category at the same time.
// So its perfectly okay for this condition to occur.
continue
}
// when we fetch the default "Channels" category from store layer,
// it auto-fills any channels the user has access to but aren't associated to a category in the database.
// So what we do is fetch the category, so we get an auto-filled data,
// then call update category to persist the data and send the websocket events.
channelsCategory := categories.Categories[0]
_, appErr = a.UpdateSidebarCategories(c, user.Id, gmConversionRequest.TeamID, []*model.SidebarCategoryWithChannels{channelsCategory})
if appErr != nil {
mlog.Error("Failed to add converted GM to default sidebar category for user", mlog.String("user_id", user.Id), mlog.Err(err))
}
}
return nil
}
func (a *App) validateForConvertGroupMessageToChannel(c request.CTX, convertedByUserId string, originalChannel *model.Channel, gmConversionRequest *model.GroupMessageConversionRequestBody) *model.AppError {
commonTeams, appErr := a.GetGroupMessageMembersCommonTeams(c, originalChannel.Id)
if appErr != nil {
return appErr
}
teamFound := false
for _, team := range commonTeams {
if team.Id == gmConversionRequest.TeamID {
teamFound = true
break
}
}
if !teamFound {
return model.NewAppError(
"validateForConvertGroupMessageToChannel",
"app.channel.group_message_conversion.incorrect_team",
nil,
"",
http.StatusBadRequest,
)
}
if originalChannel.Type != model.ChannelTypeGroup {
return model.NewAppError(
"ConvertGroupMessageToChannel",
"app.channel.group_message_conversion.original_channel_not_gm",
nil,
"",
http.StatusNotFound,
)
}
channelMember, appErr := a.GetChannelMember(c, gmConversionRequest.ChannelID, convertedByUserId)
if appErr != nil {
return appErr
}
if channelMember == nil {
return model.NewAppError("ConvertGroupMessageToChannel", "app.channel.group_message_conversion.channel_member_missing", nil, "", http.StatusNotFound)
}
// apply dummy changes to check validity
clone := originalChannel.DeepCopy()
clone.Type = model.ChannelTypePrivate
clone.Name = gmConversionRequest.Name
clone.DisplayName = gmConversionRequest.DisplayName
return clone.IsValid()
}
func (a *App) postMessageForConvertGroupMessageToChannel(c request.CTX, channelID, convertedByUserId string, channelUsers []*model.User) *model.AppError {
convertedByUser, appErr := a.GetUser(convertedByUserId)
if appErr != nil {
return appErr
}
userIDs := make([]string, len(channelUsers))
usernames := make([]string, len(channelUsers))
for i, user := range channelUsers {
userIDs[i] = user.Id
usernames[i] = user.Username
}
message := i18n.T(
"api.channel.group_message.converted.to_private_channel",
map[string]any{
"ConvertedByUsername": convertedByUser.Username,
"GMMembers": utils.JoinList(usernames),
})
post := &model.Post{
ChannelId: channelID,
Message: message,
Type: model.PostTypeGMConvertedToChannel,
UserId: convertedByUserId,
}
// these props are used for re-constructing a localized message on the client
post.AddProp("convertedByUserId", convertedByUser.Id)
post.AddProp("gmMembersDuringConversionIDs", userIDs)
channel, appErr := a.GetChannel(c, channelID)
if appErr != nil {
return appErr
}
if _, appErr := a.CreatePost(c, post, channel, false, true); appErr != nil {
mlog.Error("Failed to create post for notifying about GM converted to private channel", mlog.Err(appErr))
return model.NewAppError(
"postMessageForConvertGroupMessageToChannel",
"app.channel.group_message_conversion.post_message.error",
nil,
"",
http.StatusInternalServerError,
).Wrap(appErr)
}
return nil
}
func (s *Server) getDirectChannel(c request.CTX, userID, otherUserID string) (*model.Channel, *model.AppError) {
channel, nErr := s.Store().Channel().GetByName("", model.GetDMNameFromIds(userID, otherUserID), true)
if nErr != nil {

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

@@ -11,6 +11,13 @@ import (
"strings"
"sync"
"testing"
"time"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/channels/app/teams"
"github.com/mattermost/mattermost/server/v8/channels/app/users"
"github.com/mattermost/mattermost/server/v8/channels/store/sqlstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
@@ -2450,3 +2457,210 @@ func TestIsCRTEnabledForUser(t *testing.T) {
})
}
}
func TestGetGroupMessageMembersCommonTeams(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
mockStore := th.App.Srv().Store().(*mocks.Store)
mockChannelStore := mocks.ChannelStore{}
mockStore.On("Channel").Return(&mockChannelStore)
mockChannelStore.On("Get", "gm_channel_id", true).Return(&model.Channel{Type: model.ChannelTypeGroup}, nil)
mockTeamStore := mocks.TeamStore{}
mockStore.On("Team").Return(&mockTeamStore)
th.App.Srv().Store().Team()
mockTeamStore.On("GetCommonTeamIDsForMultipleUsers", []string{"user_id_1", "user_id_2"}).Return([]string{"team_id_1", "team_id_2", "team_id_3"}, nil).Times(1)
mockTeamStore.On("GetMany", []string{"team_id_1", "team_id_2", "team_id_3"}).Return(
[]*model.Team{
{DisplayName: "Team 1"},
{DisplayName: "Team 2"},
{DisplayName: "Team 3"},
},
nil,
)
mockUserStore := mocks.UserStore{}
mockStore.On("User").Return(&mockUserStore)
options := &model.UserGetOptions{
PerPage: model.ChannelGroupMaxUsers,
Page: 0,
InChannelId: "gm_channel_id",
Inactive: false,
Active: true,
}
mockUserStore.On("GetProfilesInChannel", options).Return([]*model.User{
{
Id: "user_id_1",
},
{
Id: "user_id_2",
},
}, nil)
var err error
th.App.ch.srv.teamService, err = teams.New(teams.ServiceConfig{
TeamStore: &mockTeamStore,
ChannelStore: &mockChannelStore,
GroupStore: &mocks.GroupStore{},
Users: th.App.ch.srv.userService,
WebHub: th.App.ch.srv.platform,
ConfigFn: th.App.ch.srv.platform.Config,
LicenseFn: th.App.ch.srv.License,
})
require.NoError(t, err)
commonTeams, appErr := th.App.GetGroupMessageMembersCommonTeams(th.Context, "gm_channel_id")
require.Nil(t, appErr)
require.Equal(t, 3, len(commonTeams))
// case of no common teams
mockTeamStore.On("GetCommonTeamIDsForMultipleUsers", []string{"user_id_1", "user_id_2"}).Return([]string{}, nil)
commonTeams, appErr = th.App.GetGroupMessageMembersCommonTeams(th.Context, "gm_channel_id")
require.Nil(t, appErr)
require.Equal(t, 0, len(commonTeams))
}
func TestConvertGroupMessageToChannel(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
mockStore := th.App.Srv().Store().(*mocks.Store)
mockChannelStore := mocks.ChannelStore{}
mockStore.On("Channel").Return(&mockChannelStore)
mockChannelStore.On("Get", "channelidchannelidchanneli", true).Return(&model.Channel{
Id: "channelidchannelidchanneli",
CreateAt: time.Now().Unix(),
UpdateAt: time.Now().Unix(),
Type: model.ChannelTypeGroup,
}, nil)
mockChannelStore.On("Update", mock.AnythingOfType("*model.Channel")).Return(&model.Channel{}, nil)
mockChannelStore.On("InvalidateChannel", "channelidchannelidchanneli")
mockChannelStore.On("InvalidateChannelByName", "team_id_1", "new_name").Times(1)
mockChannelStore.On("InvalidateChannelByName", "dm", "")
mockChannelStore.On("GetMember", sqlstore.WithMaster(context.Background()), "channelidchannelidchanneli", "user_id_1").Return(&model.ChannelMember{}, nil).Times(1)
mockChannelStore.On("GetMember", context.Background(), "channelidchannelidchanneli", "user_id_1").Return(&model.ChannelMember{}, nil).Times(1)
mockChannelStore.On("InvalidatePinnedPostCount", "channelidchannelidchanneli")
mockChannelStore.On("GetAllChannelMembersNotifyPropsForChannel", "channelidchannelidchanneli", true).Return(map[string]model.StringMap{}, nil)
mockChannelStore.On("IncrementMentionCount", "", []string{}, true, false).Return(nil)
mockChannelStore.On("DeleteAllSidebarChannelForChannel", "channelidchannelidchanneli").Return(nil)
mockChannelStore.On("GetSidebarCategories", "user_id_1", &store.SidebarCategorySearchOpts{TeamID: "team_id_1", ExcludeTeam: false, Type: "channels"}).Return(
&model.OrderedSidebarCategories{
Categories: model.SidebarCategoriesWithChannels{
{
SidebarCategory: model.SidebarCategory{
Type: model.SidebarCategoryChannels,
},
},
},
}, nil)
mockChannelStore.On("GetSidebarCategories", "user_id_2", &store.SidebarCategorySearchOpts{TeamID: "team_id_1", ExcludeTeam: false, Type: "channels"}).Return(
&model.OrderedSidebarCategories{
Categories: model.SidebarCategoriesWithChannels{
{
SidebarCategory: model.SidebarCategory{
Type: model.SidebarCategoryChannels,
},
},
},
}, nil)
mockChannelStore.On("UpdateSidebarCategories", "user_id_1", "team_id_1", mock.Anything).Return(
[]*model.SidebarCategoryWithChannels{
{
SidebarCategory: model.SidebarCategory{
Type: model.SidebarCategoryChannels,
},
},
},
[]*model.SidebarCategoryWithChannels{
{
SidebarCategory: model.SidebarCategory{
Type: model.SidebarCategoryChannels,
},
},
},
nil,
)
mockChannelStore.On("UpdateSidebarCategories", "user_id_2", "team_id_1", mock.Anything).Return(
[]*model.SidebarCategoryWithChannels{
{
SidebarCategory: model.SidebarCategory{
Type: model.SidebarCategoryChannels,
},
},
},
[]*model.SidebarCategoryWithChannels{
{
SidebarCategory: model.SidebarCategory{
Type: model.SidebarCategoryChannels,
},
},
},
nil,
)
mockTeamStore := mocks.TeamStore{}
mockStore.On("Team").Return(&mockTeamStore)
mockTeamStore.On("GetMember", sqlstore.WithMaster(context.Background()), "team_id_1", "user_id_1").Return(&model.TeamMember{}, nil)
mockTeamStore.On("GetCommonTeamIDsForMultipleUsers", []string{"user_id_1", "user_id_2"}).Return([]string{"team_id_1", "team_id_2", "team_id_3"}, nil).Times(1)
mockTeamStore.On("GetMany", []string{"team_id_1", "team_id_2", "team_id_3"}).Return(
[]*model.Team{
{Id: "team_id_1", DisplayName: "Team 1"},
{Id: "team_id_2", DisplayName: "Team 2"},
{Id: "team_id_3", DisplayName: "Team 3"},
},
nil,
)
mockUserStore := mocks.UserStore{}
mockStore.On("User").Return(&mockUserStore)
mockUserStore.On("Get", context.Background(), "user_id_1").Return(&model.User{Username: "username_1"}, nil)
mockUserStore.On("GetProfilesInChannel", mock.AnythingOfType("*model.UserGetOptions")).Return([]*model.User{
{Id: "user_id_1", Username: "user_id_1"},
{Id: "user_id_2", Username: "user_id_2"},
}, nil)
mockUserStore.On("GetAllProfilesInChannel", mock.Anything, mock.Anything, mock.Anything).Return(map[string]*model.User{}, nil)
mockPostStore := mocks.PostStore{}
mockStore.On("Post").Return(&mockPostStore)
mockPostStore.On("Save", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil)
mockPostStore.On("InvalidateLastPostTimeCache", "channelidchannelidchanneli")
var err error
th.App.ch.srv.userService, err = users.New(users.ServiceConfig{
UserStore: &mockUserStore,
ConfigFn: th.App.ch.srv.platform.Config,
SessionStore: &mocks.SessionStore{},
OAuthStore: &mocks.OAuthStore{},
LicenseFn: th.App.ch.srv.License,
})
require.NoError(t, err)
th.App.ch.srv.teamService, err = teams.New(teams.ServiceConfig{
TeamStore: &mockTeamStore,
ChannelStore: &mockChannelStore,
GroupStore: &mocks.GroupStore{},
Users: th.App.ch.srv.userService,
WebHub: th.App.ch.srv.platform,
ConfigFn: th.App.ch.srv.platform.Config,
LicenseFn: th.App.ch.srv.License,
})
require.NoError(t, err)
conversionRequest := &model.GroupMessageConversionRequestBody{
ChannelID: "channelidchannelidchanneli",
TeamID: "team_id_1",
Name: "new_name",
DisplayName: "New Display Name",
}
convertedChannel, appErr := th.App.ConvertGroupMessageToChannel(th.Context, "user_id_1", conversionRequest)
require.Nil(t, appErr)
require.Equal(t, model.ChannelTypePrivate, convertedChannel.Type)
}

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

@@ -1819,6 +1819,28 @@ func (a *OpenTracingAppLayer) ConvertBotToUser(c request.CTX, bot *model.Bot, us
return resultVar0, resultVar1
}
func (a *OpenTracingAppLayer) ConvertGroupMessageToChannel(c request.CTX, convertedByUserId string, gmConversionRequest *model.GroupMessageConversionRequestBody) (*model.Channel, *model.AppError) {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ConvertGroupMessageToChannel")
a.ctx = newCtx
a.app.Srv().Store().SetContext(newCtx)
defer func() {
a.app.Srv().Store().SetContext(origCtx)
a.ctx = origCtx
}()
defer span.Finish()
resultVar0, resultVar1 := a.app.ConvertGroupMessageToChannel(c, convertedByUserId, gmConversionRequest)
if resultVar1 != nil {
span.LogFields(spanlog.Error(resultVar1))
ext.Error.Set(span, true)
}
return resultVar0, resultVar1
}
func (a *OpenTracingAppLayer) ConvertUserToBot(user *model.User) (*model.Bot, *model.AppError) {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ConvertUserToBot")
@@ -6666,6 +6688,28 @@ func (a *OpenTracingAppLayer) GetGroupMemberUsersSortedPage(groupID string, page
return resultVar0, resultVar1, resultVar2
}
func (a *OpenTracingAppLayer) GetGroupMessageMembersCommonTeams(c request.CTX, channelID string) ([]*model.Team, *model.AppError) {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetGroupMessageMembersCommonTeams")
a.ctx = newCtx
a.app.Srv().Store().SetContext(newCtx)
defer func() {
a.app.Srv().Store().SetContext(origCtx)
a.ctx = origCtx
}()
defer span.Finish()
resultVar0, resultVar1 := a.app.GetGroupMessageMembersCommonTeams(c, channelID)
if resultVar1 != nil {
span.LogFields(spanlog.Error(resultVar1))
ext.Error.Set(span, true)
}
return resultVar0, resultVar1
}
func (a *OpenTracingAppLayer) GetGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) (*model.GroupSyncable, *model.AppError) {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetGroupSyncable")