diff --git a/server/channels/api4/channel.go b/server/channels/api4/channel.go index 969d97c204..a93210a7cd 100644 --- a/server/channels/api4/channel.go +++ b/server/channels/api4/channel.go @@ -60,6 +60,8 @@ func (api *API) InitChannel() { api.BaseRoutes.Channel.Handle("/members_minus_group_members", api.APISessionRequired(channelMembersMinusGroupMembers)).Methods("GET") api.BaseRoutes.Channel.Handle("/move", api.APISessionRequired(moveChannel)).Methods("POST") api.BaseRoutes.Channel.Handle("/member_counts_by_group", api.APISessionRequired(channelMemberCountsByGroup)).Methods("GET") + api.BaseRoutes.Channel.Handle("/common_teams", api.APISessionRequired(getGroupMessageMembersCommonTeams)).Methods("GET") + api.BaseRoutes.Channel.Handle("/convert_to_channel", api.APISessionRequired(convertGroupMessageToChannel)).Methods("POST") api.BaseRoutes.ChannelForUser.Handle("/unread", api.APISessionRequired(getChannelUnread)).Methods("GET") @@ -2194,3 +2196,86 @@ func moveChannel(c *Context, w http.ResponseWriter, r *http.Request) { c.Logger.Warn("Error while writing response", mlog.Err(err)) } } + +func getGroupMessageMembersCommonTeams(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireChannelId() + if c.Err != nil { + return + } + + user, err := c.App.GetUser(c.AppContext.Session().UserId) + if err != nil { + c.Err = err + return + } + if user.IsGuest() { + c.Err = model.NewAppError("Api4.getGroupMessageMembersCommonTeams", "api.channel.gm_to_channel_conversion.not_allowed_for_user.request_error", nil, "userId="+c.AppContext.Session().UserId, http.StatusForbidden) + return + } + + if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) { + c.SetPermissionError(model.PermissionReadChannel) + return + } + + teams, appErr := c.App.GetGroupMessageMembersCommonTeams(c.AppContext, c.Params.ChannelId) + if appErr != nil { + c.Err = appErr + return + } + + if err := json.NewEncoder(w).Encode(teams); err != nil { + c.Logger.Warn("Error while writing response from getGroupMessageMembersCommonTeams", mlog.Err(err)) + } +} + +func convertGroupMessageToChannel(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireChannelId() + if c.Err != nil { + return + } + + var gmConversionRequest *model.GroupMessageConversionRequestBody + if err := json.NewDecoder(r.Body).Decode(&gmConversionRequest); err != nil { + c.SetInvalidParamWithErr("body", err) + return + } + + user, err := c.App.GetUser(c.AppContext.Session().UserId) + if err != nil { + c.Err = err + return + } + if user.IsGuest() { + c.Err = model.NewAppError("Api4.convertGroupMessageToChannel", "api.channel.gm_to_channel_conversion.not_allowed_for_user.request_error", nil, "userId="+c.AppContext.Session().UserId, http.StatusForbidden) + return + } + + if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), gmConversionRequest.TeamID, model.PermissionCreatePrivateChannel) { + c.SetPermissionError(model.PermissionCreatePrivateChannel) + return + } + + // The channel id the payload must be the same one as indicated in the URL. + if gmConversionRequest.ChannelID != c.Params.ChannelId { + c.SetInvalidParam("channel_id") + return + } + + auditRec := c.MakeAuditRecord("convertGroupMessageToChannel", audit.Fail) + defer c.LogAuditRec(auditRec) + audit.AddEventParameter(auditRec, "channel_id", gmConversionRequest.ChannelID) + audit.AddEventParameter(auditRec, "team_id", gmConversionRequest.TeamID) + audit.AddEventParameter(auditRec, "user_id", user.Id) + + updatedChannel, appErr := c.App.ConvertGroupMessageToChannel(c.AppContext, c.AppContext.Session().UserId, gmConversionRequest) + if appErr != nil { + c.Err = appErr + return + } + + auditRec.Success() + if err := json.NewEncoder(w).Encode(updatedChannel); err != nil { + c.Logger.Warn("Error while writing response from convertGroupMessageToChannel", mlog.Err(err)) + } +} diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go index 4037b591b9..927e33f4b2 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -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) diff --git a/server/channels/app/channel.go b/server/channels/app/channel.go index 07962d9f2b..2a5b05bae8 100644 --- a/server/channels/app/channel.go +++ b/server/channels/app/channel.go @@ -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 { diff --git a/server/channels/app/channel_test.go b/server/channels/app/channel_test.go index 767bdf8fdd..cc45e44724 100644 --- a/server/channels/app/channel_test.go +++ b/server/channels/app/channel_test.go @@ -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) + +} diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go index 1dc9e79c0b..765d400244 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -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") diff --git a/server/channels/store/opentracinglayer/opentracinglayer.go b/server/channels/store/opentracinglayer/opentracinglayer.go index 97eaacb9f0..9e4f089f25 100644 --- a/server/channels/store/opentracinglayer/opentracinglayer.go +++ b/server/channels/store/opentracinglayer/opentracinglayer.go @@ -865,6 +865,24 @@ func (s *OpenTracingLayerChannelStore) Delete(channelID string, timestamp int64) return err } +func (s *OpenTracingLayerChannelStore) DeleteAllSidebarChannelForChannel(channelID string) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.DeleteAllSidebarChannelForChannel") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.ChannelStore.DeleteAllSidebarChannelForChannel(channelID) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + func (s *OpenTracingLayerChannelStore) DeleteSidebarCategory(categoryID string) error { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.DeleteSidebarCategory") @@ -9554,6 +9572,24 @@ func (s *OpenTracingLayerTeamStore) GetChannelUnreadsForTeam(teamID string, user return result, err } +func (s *OpenTracingLayerTeamStore) GetCommonTeamIDsForMultipleUsers(userIDs []string) ([]string, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "TeamStore.GetCommonTeamIDsForMultipleUsers") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.TeamStore.GetCommonTeamIDsForMultipleUsers(userIDs) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerTeamStore) GetCommonTeamIDsForTwoUsers(userID string, otherUserID string) ([]string, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "TeamStore.GetCommonTeamIDsForTwoUsers") diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index 718bf087c5..6811f4c00e 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -934,6 +934,27 @@ func (s *RetryLayerChannelStore) Delete(channelID string, timestamp int64) error } +func (s *RetryLayerChannelStore) DeleteAllSidebarChannelForChannel(channelID string) error { + + tries := 0 + for { + err := s.ChannelStore.DeleteAllSidebarChannelForChannel(channelID) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerChannelStore) DeleteSidebarCategory(categoryID string) error { tries := 0 @@ -10906,6 +10927,27 @@ func (s *RetryLayerTeamStore) GetChannelUnreadsForTeam(teamID string, userID str } +func (s *RetryLayerTeamStore) GetCommonTeamIDsForMultipleUsers(userIDs []string) ([]string, error) { + + tries := 0 + for { + result, err := s.TeamStore.GetCommonTeamIDsForMultipleUsers(userIDs) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerTeamStore) GetCommonTeamIDsForTwoUsers(userID string, otherUserID string) ([]string, error) { tries := 0 diff --git a/server/channels/store/sqlstore/channel_store_categories.go b/server/channels/store/sqlstore/channel_store_categories.go index ca20e281e3..7b4e55d4d7 100644 --- a/server/channels/store/sqlstore/channel_store_categories.go +++ b/server/channels/store/sqlstore/channel_store_categories.go @@ -553,6 +553,10 @@ func (s SqlChannelStore) getSidebarCategoriesT(db dbSelecter, userId string, opt query = query.Where(sq.Eq{"SidebarCategories.TeamId": opts.TeamID}) } + if opts.Type != "" { + query = query.Where(sq.Eq{"SidebarCategories.Type": opts.Type}) + } + sql, args, err := query.ToSql() if err != nil { return nil, errors.Wrap(err, "sidebar_categories_tosql") @@ -1125,3 +1129,18 @@ func (s SqlChannelStore) DeleteSidebarCategory(categoryId string) (err error) { return nil } + +func (s SqlChannelStore) DeleteAllSidebarChannelForChannel(channelID string) error { + query, args, err := s.getQueryBuilder(). + Delete("SidebarChannels"). + Where(sq.Eq{ + "ChannelId": channelID, + }).ToSql() + + if err != nil { + return errors.Wrap(err, "delete_all_sidebar_channel_for_channel_to_sql") + } + + _, err = s.GetMasterX().Exec(query, args...) + return err +} diff --git a/server/channels/store/sqlstore/team_store.go b/server/channels/store/sqlstore/team_store.go index 146dd3b230..e13d1fc19c 100644 --- a/server/channels/store/sqlstore/team_store.go +++ b/server/channels/store/sqlstore/team_store.go @@ -1513,6 +1513,46 @@ func (s SqlTeamStore) GetCommonTeamIDsForTwoUsers(userID, otherUserID string) ([ return teamIDs, nil } +func (s SqlTeamStore) GetCommonTeamIDsForMultipleUsers(userIDs []string) ([]string, error) { + subQuery := s.getSubQueryBuilder(). + Select("TeamId, UserId"). + From("TeamMembers"). + Where(sq.Eq{ + "UserId": userIDs, + "DeleteAt": 0, + }) + + subQuerySQL, subQueryParams, err := subQuery.ToSql() + if err != nil { + return nil, errors.Wrap(err, "GetCommonTeamIDsForMultipleUsers_subquery_toSQL") + } + + query := s.getQueryBuilder(). + Select("t.Id"). + From("Teams AS t"). + Join("("+subQuerySQL+") AS tm ON t.Id = tm.TeamId", subQueryParams...). + Where(sq.Eq{ + "t.DeleteAt": 0, + }). + GroupBy("t.Id"). + Having(sq.Eq{ + "COUNT(UserId)": len(userIDs), + }) + + querySQL, args, err := query.ToSql() + if err != nil { + return nil, errors.Wrap(err, "GetCommonTeamIDsForMultipleUsers_query_toSQL") + } + + var teamIDs []string + + if err := s.GetReplicaX().Select(&teamIDs, querySQL, args...); err != nil { + return nil, errors.Wrapf(err, "failed to find common team for users %v", userIDs) + } + + return teamIDs, nil +} + // GetTeamMembersForExport gets the various teams for which a user, denoted by userId, is a part of. func (s SqlTeamStore) GetTeamMembersForExport(userId string) ([]*model.TeamMemberForExport, error) { members := []*model.TeamMemberForExport{} diff --git a/server/channels/store/store.go b/server/channels/store/store.go index b902349a88..0c66704e0e 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -174,6 +174,8 @@ type TeamStore interface { // GetCommonTeamIDsForTwoUsers returns the intersection of all the teams to which the specified // users belong. GetCommonTeamIDsForTwoUsers(userID, otherUserID string) ([]string, error) + + GetCommonTeamIDsForMultipleUsers(userIDs []string) ([]string, error) } type ChannelStore interface { @@ -287,6 +289,7 @@ type ChannelStore interface { UpdateSidebarChannelsByPreferences(preferences model.Preferences) error DeleteSidebarChannelsByPreferences(preferences model.Preferences) error DeleteSidebarCategory(categoryID string) error + DeleteAllSidebarChannelForChannel(channelID string) error GetAllChannelsForExportAfter(limit int, afterID string) ([]*model.ChannelForExport, error) GetAllDirectChannelsForExportAfter(limit int, afterID string) ([]*model.DirectChannelForExport, error) GetChannelMembersForExport(userID string, teamID string, includeArchivedChannel bool) ([]*model.ChannelMemberForExport, error) @@ -1100,6 +1103,7 @@ type PostReminderMetadata struct { type SidebarCategorySearchOpts struct { TeamID string ExcludeTeam bool + Type model.SidebarCategoryType } // Ensure store service adapter implements `product.StoreService` diff --git a/server/channels/store/storetest/mocks/ChannelStore.go b/server/channels/store/storetest/mocks/ChannelStore.go index 801c382d2d..9bf9f33f92 100644 --- a/server/channels/store/storetest/mocks/ChannelStore.go +++ b/server/channels/store/storetest/mocks/ChannelStore.go @@ -336,6 +336,20 @@ func (_m *ChannelStore) Delete(channelID string, timestamp int64) error { return r0 } +// DeleteAllSidebarChannelForChannel provides a mock function with given fields: channelID +func (_m *ChannelStore) DeleteAllSidebarChannelForChannel(channelID string) error { + ret := _m.Called(channelID) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(channelID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // DeleteSidebarCategory provides a mock function with given fields: categoryID func (_m *ChannelStore) DeleteSidebarCategory(categoryID string) error { ret := _m.Called(categoryID) diff --git a/server/channels/store/storetest/mocks/TeamStore.go b/server/channels/store/storetest/mocks/TeamStore.go index 9ac7094b16..110886c2f9 100644 --- a/server/channels/store/storetest/mocks/TeamStore.go +++ b/server/channels/store/storetest/mocks/TeamStore.go @@ -419,6 +419,32 @@ func (_m *TeamStore) GetChannelUnreadsForTeam(teamID string, userID string) ([]* return r0, r1 } +// GetCommonTeamIDsForMultipleUsers provides a mock function with given fields: userIDs +func (_m *TeamStore) GetCommonTeamIDsForMultipleUsers(userIDs []string) ([]string, error) { + ret := _m.Called(userIDs) + + var r0 []string + var r1 error + if rf, ok := ret.Get(0).(func([]string) ([]string, error)); ok { + return rf(userIDs) + } + if rf, ok := ret.Get(0).(func([]string) []string); ok { + r0 = rf(userIDs) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + if rf, ok := ret.Get(1).(func([]string) error); ok { + r1 = rf(userIDs) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetCommonTeamIDsForTwoUsers provides a mock function with given fields: userID, otherUserID func (_m *TeamStore) GetCommonTeamIDsForTwoUsers(userID string, otherUserID string) ([]string, error) { ret := _m.Called(userID, otherUserID) diff --git a/server/channels/store/storetest/team_store.go b/server/channels/store/storetest/team_store.go index ed706c1e9b..53d92f9511 100644 --- a/server/channels/store/storetest/team_store.go +++ b/server/channels/store/storetest/team_store.go @@ -73,6 +73,7 @@ func TestTeamStore(t *testing.T, ss store.Store) { t.Run("GetTeamMembersForExport", func(t *testing.T) { testTeamStoreGetTeamMembersForExport(t, ss) }) t.Run("GetTeamsForUserWithPagination", func(t *testing.T) { testTeamMembersWithPagination(t, ss) }) t.Run("GroupSyncedTeamCount", func(t *testing.T) { testGroupSyncedTeamCount(t, ss) }) + t.Run("GetCommonTeamIDsForMultipleUsers", func(t *testing.T) { testGetCommonTeamIDsForMultipleUsers(t, ss) }) } func testTeamStoreSave(t *testing.T, ss store.Store) { @@ -3618,3 +3619,203 @@ func testGroupSyncedTeamCount(t *testing.T, ss store.Store) { require.NoError(t, err) require.GreaterOrEqual(t, countAfter, count+1) } + +func testGetCommonTeamIDsForMultipleUsers(t *testing.T, ss store.Store) { + // Creating teams + + // Team 1 + t1 := model.Team{} + t1.DisplayName = "Team 1" + t1.Name = NewTestId() + t1.Email = MakeEmail() + t1.Type = model.TeamOpen + _, err := ss.Team().Save(&t1) + require.NoError(t, err) + + // Team 2 + t2 := model.Team{} + t2.DisplayName = "Team 2" + t2.Name = NewTestId() + t2.Email = MakeEmail() + t2.Type = model.TeamOpen + _, err = ss.Team().Save(&t2) + require.NoError(t, err) + + // Team 3 + t3 := model.Team{} + t3.DisplayName = "Team 3" + t3.Name = NewTestId() + t3.Email = MakeEmail() + t3.Type = model.TeamOpen + _, err = ss.Team().Save(&t3) + require.NoError(t, err) + + // Creating users + + // User 1 + u1 := model.User{} + u1.Email = MakeEmail() + u1.Nickname = NewTestId() + _, err = ss.User().Save(&u1) + require.NoError(t, err) + + // User 2 + u2 := model.User{} + u2.Email = MakeEmail() + u2.Nickname = NewTestId() + _, err = ss.User().Save(&u2) + require.NoError(t, err) + + t.Run("multiple common teams exist", func(t *testing.T) { + // Add user 1 in team 1 and 2 + m1 := &model.TeamMember{TeamId: t1.Id, UserId: u1.Id} + m2 := &model.TeamMember{TeamId: t2.Id, UserId: u1.Id} + + // Add user 2 in team1, 2 and 3 + m3 := &model.TeamMember{TeamId: t1.Id, UserId: u2.Id} + m4 := &model.TeamMember{TeamId: t2.Id, UserId: u2.Id} + m5 := &model.TeamMember{TeamId: t3.Id, UserId: u2.Id} + + // Save team memberships + _, nErr := ss.Team().SaveMultipleMembers([]*model.TeamMember{m1, m2, m3, m4, m5}, -1) + require.NoError(t, nErr) + + // Find common teams between user 1 and user 2 + commonTeamIDs, err2 := ss.Team().GetCommonTeamIDsForMultipleUsers([]string{u1.Id, u2.Id}) + require.NoError(t, err2) + require.Equal(t, 2, len(commonTeamIDs)) + require.Contains(t, commonTeamIDs, t1.Id) + require.Contains(t, commonTeamIDs, t2.Id) + + // cleanup + err2 = ss.Team().RemoveAllMembersByUser(u1.Id) + require.NoError(t, err2) + + err2 = ss.Team().RemoveAllMembersByUser(u2.Id) + require.NoError(t, err2) + }) + + t.Run("single common teams exist", func(t *testing.T) { + // Add user 1 in team 1 and 2 + m1 := &model.TeamMember{TeamId: t1.Id, UserId: u1.Id} + m2 := &model.TeamMember{TeamId: t2.Id, UserId: u1.Id} + + // Add user 2 in team1, 2 and 3 + m3 := &model.TeamMember{TeamId: t1.Id, UserId: u2.Id} + m5 := &model.TeamMember{TeamId: t3.Id, UserId: u2.Id} + + // Save team memberships + _, nErr := ss.Team().SaveMultipleMembers([]*model.TeamMember{m1, m2, m3, m5}, -1) + require.NoError(t, nErr) + + // Find common teams between user 1 and user 2 + commonTeamIDs, err2 := ss.Team().GetCommonTeamIDsForMultipleUsers([]string{u1.Id, u2.Id}) + require.NoError(t, err2) + require.Equal(t, 1, len(commonTeamIDs)) + require.Contains(t, commonTeamIDs, t1.Id) + + // cleanup + err2 = ss.Team().RemoveAllMembersByUser(u1.Id) + require.NoError(t, err2) + + err2 = ss.Team().RemoveAllMembersByUser(u2.Id) + require.NoError(t, err2) + }) + + t.Run("no common teams exist", func(t *testing.T) { + // Add user 1 in team 1 and 2 + m1 := &model.TeamMember{TeamId: t1.Id, UserId: u1.Id} + + // Add user 2 in team1, 2 and 3 + m4 := &model.TeamMember{TeamId: t2.Id, UserId: u2.Id} + m5 := &model.TeamMember{TeamId: t3.Id, UserId: u2.Id} + + // Save team memberships + _, nErr := ss.Team().SaveMultipleMembers([]*model.TeamMember{m1, m4, m5}, -1) + require.NoError(t, nErr) + + // Find common teams between user 1 and user 2 + commonTeamIDs, err2 := ss.Team().GetCommonTeamIDsForMultipleUsers([]string{u1.Id, u2.Id}) + require.NoError(t, err2) + require.Equal(t, 0, len(commonTeamIDs)) + + // cleanup + err2 = ss.Team().RemoveAllMembersByUser(u1.Id) + require.NoError(t, err2) + + err2 = ss.Team().RemoveAllMembersByUser(u2.Id) + require.NoError(t, err2) + }) + + t.Run("some user have no team members", func(t *testing.T) { + // Add user 1 in team 1 and 2 + m1 := &model.TeamMember{TeamId: t1.Id, UserId: u1.Id} + m2 := &model.TeamMember{TeamId: t2.Id, UserId: u1.Id} + + // We'll leave user 2 without any team + + // Save team memberships + _, nErr := ss.Team().SaveMultipleMembers([]*model.TeamMember{m1, m2}, -1) + require.NoError(t, nErr) + + // Find common teams between user 1 and user 2 + commonTeamIDs, err2 := ss.Team().GetCommonTeamIDsForMultipleUsers([]string{u1.Id, u2.Id}) + require.NoError(t, err2) + require.Equal(t, 0, len(commonTeamIDs)) + + // cleanup + err2 = ss.Team().RemoveAllMembersByUser(u1.Id) + require.NoError(t, err2) + }) + + t.Run("more than two users, common teams exist", func(t *testing.T) { + // User 3 + u3 := model.User{} + u3.Email = MakeEmail() + u3.Nickname = NewTestId() + _, err = ss.User().Save(&u3) + require.NoError(t, err) + + // User 4 + u4 := model.User{} + u4.Email = MakeEmail() + u4.Nickname = NewTestId() + _, err = ss.User().Save(&u4) + require.NoError(t, err) + + // Add user 1 in team 1 and 2 + m1 := &model.TeamMember{TeamId: t1.Id, UserId: u1.Id} + m2 := &model.TeamMember{TeamId: t2.Id, UserId: u1.Id} + + // Add user 2 in team1, 2 and 3 + m3 := &model.TeamMember{TeamId: t1.Id, UserId: u2.Id} + m4 := &model.TeamMember{TeamId: t2.Id, UserId: u2.Id} + m5 := &model.TeamMember{TeamId: t3.Id, UserId: u2.Id} + + // Add user 3 in team 1 and 2 + m6 := &model.TeamMember{TeamId: t1.Id, UserId: u3.Id} + m7 := &model.TeamMember{TeamId: t2.Id, UserId: u3.Id} + + // Add user 4 in team 1 and 2 + m8 := &model.TeamMember{TeamId: t1.Id, UserId: u4.Id} + m9 := &model.TeamMember{TeamId: t2.Id, UserId: u4.Id} + + // Save team memberships + _, nErr := ss.Team().SaveMultipleMembers([]*model.TeamMember{m1, m2, m3, m4, m5, m6, m7, m8, m9}, -1) + require.NoError(t, nErr) + + // Find common teams between user 1 and user 2 + commonTeamIDs, err := ss.Team().GetCommonTeamIDsForMultipleUsers([]string{u1.Id, u2.Id, u3.Id, u4.Id}) + require.NoError(t, err) + require.Equal(t, 2, len(commonTeamIDs)) + require.Contains(t, commonTeamIDs, t1.Id) + require.Contains(t, commonTeamIDs, t2.Id) + + // cleanup + err = ss.Team().RemoveAllMembersByUser(u1.Id) + require.NoError(t, err) + + err = ss.Team().RemoveAllMembersByUser(u2.Id) + require.NoError(t, err) + }) +} diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index ef5716a960..2b99cb7816 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -827,6 +827,22 @@ func (s *TimerLayerChannelStore) Delete(channelID string, timestamp int64) error return err } +func (s *TimerLayerChannelStore) DeleteAllSidebarChannelForChannel(channelID string) error { + start := time.Now() + + err := s.ChannelStore.DeleteAllSidebarChannelForChannel(channelID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.DeleteAllSidebarChannelForChannel", success, elapsed) + } + return err +} + func (s *TimerLayerChannelStore) DeleteSidebarCategory(categoryID string) error { start := time.Now() @@ -8606,6 +8622,22 @@ func (s *TimerLayerTeamStore) GetChannelUnreadsForTeam(teamID string, userID str return result, err } +func (s *TimerLayerTeamStore) GetCommonTeamIDsForMultipleUsers(userIDs []string) ([]string, error) { + start := time.Now() + + result, err := s.TeamStore.GetCommonTeamIDsForMultipleUsers(userIDs) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("TeamStore.GetCommonTeamIDsForMultipleUsers", success, elapsed) + } + return result, err +} + func (s *TimerLayerTeamStore) GetCommonTeamIDsForTwoUsers(userID string, otherUserID string) ([]string, error) { start := time.Now() diff --git a/server/channels/utils/humanize.go b/server/channels/utils/humanize.go new file mode 100644 index 0000000000..47636e1eee --- /dev/null +++ b/server/channels/utils/humanize.go @@ -0,0 +1,25 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package utils + +import ( + "strings" + + "github.com/mattermost/mattermost/server/public/shared/i18n" +) + +func JoinList(items []string) string { + if len(items) == 0 { + return "" + } else if len(items) == 1 { + return items[0] + } else { + return i18n.T( + "humanize.list_join", + map[string]any{ + "OtherItems": strings.Join(items[:len(items)-1], ", "), + "LastItem": items[len(items)-1], + }) + } +} diff --git a/server/i18n/en.json b/server/i18n/en.json index b0df1689dc..cf92b2e44c 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -295,6 +295,14 @@ "id": "api.channel.get_channel_moderations.license.error", "translation": "Your license does not support channel moderation" }, + { + "id": "api.channel.gm_to_channel_conversion.not_allowed_for_user.request_error", + "translation": "User is not allowed to convert group message to private channel" + }, + { + "id": "api.channel.group_message.converted.to_private_channel", + "translation": "{{.ConvertedByUsername}} created this channel from a group message with {{.GMMembers}}." + }, { "id": "api.channel.guest_join_channel.post_and_forget", "translation": "%v joined the channel as guest." @@ -4810,6 +4818,14 @@ "id": "app.channel.get_channels_with_unreads_and_with_mentions.app_error", "translation": "Unable to check unreads and mentions" }, + { + "id": "app.channel.get_common_teams.incorrect_channel_type", + "translation": "Channel is not a group message." + }, + { + "id": "app.channel.get_common_teams.store_get_common_teams_error", + "translation": "Couldn't fetch common teams." + }, { "id": "app.channel.get_deleted.existing.app_error", "translation": "Unable to find the existing deleted channel." @@ -4870,6 +4886,26 @@ "id": "app.channel.get_unread.app_error", "translation": "Unable to get the channel unread messages." }, + { + "id": "app.channel.gm_conversion_set_categories.delete_all.error", + "translation": "Couldn't delete existing sidebar categories for converted GM." + }, + { + "id": "app.channel.group_message_conversion.channel_member_missing", + "translation": "Cannot find user's channel membership" + }, + { + "id": "app.channel.group_message_conversion.incorrect_team", + "translation": "The specified team ID in conversion request does not contain all the group message members" + }, + { + "id": "app.channel.group_message_conversion.original_channel_not_gm", + "translation": "The channel being converted is not a group message. You can only convert group messages" + }, + { + "id": "app.channel.group_message_conversion.post_message.error", + "translation": "Failed to create group message to channel conversion post" + }, { "id": "app.channel.migrate_channel_members.select.app_error", "translation": "Failed to select the batch of channel members." @@ -8082,6 +8118,10 @@ "id": "groups.unsupported_syncable_type", "translation": "Unsupported syncable type '{{.Value}}'." }, + { + "id": "humanize.list_join", + "translation": "{{.OtherItems}} and {{.LastItem}}" + }, { "id": "import_process.worker.do_job.file_exists", "translation": "Unable to process import: file does not exists." diff --git a/server/public/model/channel.go b/server/public/model/channel.go index 7c8e979bec..169d87760b 100644 --- a/server/public/model/channel.go +++ b/server/public/model/channel.go @@ -443,3 +443,10 @@ func GetGroupNameFromUserIds(userIds []string) string { return hex.EncodeToString(h.Sum(nil)) } + +type GroupMessageConversionRequestBody struct { + ChannelID string `json:"channel_id"` + TeamID string `json:"team_id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` +} diff --git a/server/public/model/post.go b/server/public/model/post.go index a24798f886..169f810112 100644 --- a/server/public/model/post.go +++ b/server/public/model/post.go @@ -45,6 +45,7 @@ const ( PostTypeChannelRestored = "system_channel_restored" PostTypeEphemeral = "system_ephemeral" PostTypeChangeChannelPrivacy = "system_change_chan_privacy" + PostTypeGMConvertedToChannel = "system_gm_to_channel" PostTypeAddBotTeamsChannels = "add_bot_teams_channels" PostTypeSystemWarnMetricStatus = "warn_metric_status" PostTypeMe = "me" @@ -428,7 +429,8 @@ func (o *Post) IsValid(maxPostSize int) *AppError { PostTypeAddBotTeamsChannels, PostTypeSystemWarnMetricStatus, PostTypeReminder, - PostTypeMe: + PostTypeMe, + PostTypeGMConvertedToChannel: default: if !strings.HasPrefix(o.Type, PostCustomTypePrefix) { return NewAppError("Post.IsValid", "model.post.is_valid.type.app_error", nil, "id="+o.Type, http.StatusBadRequest) diff --git a/webapp/channels/src/actions/team_actions.ts b/webapp/channels/src/actions/team_actions.ts index 307488b669..414c883578 100644 --- a/webapp/channels/src/actions/team_actions.ts +++ b/webapp/channels/src/actions/team_actions.ts @@ -7,10 +7,12 @@ import type {UserProfile} from '@mattermost/types/users'; import {TeamTypes} from 'mattermost-redux/action_types'; import {getChannelStats} from 'mattermost-redux/actions/channels'; +import {logError} from 'mattermost-redux/actions/errors'; import {savePreferences} from 'mattermost-redux/actions/preferences'; import * as TeamActions from 'mattermost-redux/actions/teams'; import {selectTeam} from 'mattermost-redux/actions/teams'; import {getUser} from 'mattermost-redux/actions/users'; +import {Client4} from 'mattermost-redux/client'; import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import type {ActionFunc, DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions'; @@ -114,3 +116,19 @@ export function updateTeamsOrderForUser(teamIds: Array) { dispatch(savePreferences(currentUserId, teamOrderPreferences)); }; } + +export function getGroupMessageMembersCommonTeams(channelId: string): ActionFunc { + return async (dispatch) => { + let teams: Team[]; + + try { + const response = await Client4.getGroupMessageMembersCommonTeams(channelId); + teams = response.data; + } catch (error) { + dispatch(logError(error as ServerError)); + return {error: error as ServerError}; + } + + return {data: teams}; + }; +} diff --git a/webapp/channels/src/actions/websocket_actions.jsx b/webapp/channels/src/actions/websocket_actions.jsx index 73218c6066..a2b77f59c8 100644 --- a/webapp/channels/src/actions/websocket_actions.jsx +++ b/webapp/channels/src/actions/websocket_actions.jsx @@ -79,7 +79,14 @@ import {getGroup} from 'mattermost-redux/selectors/entities/groups'; import {getPost, getMostRecentPostIdInChannel, getTeamIdFromPost} from 'mattermost-redux/selectors/entities/posts'; import {isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences'; import {haveISystemPermission, haveITeamPermission} from 'mattermost-redux/selectors/entities/roles'; -import {getMyTeams, getCurrentRelativeTeamUrl, getCurrentTeamId, getCurrentTeamUrl, getTeam} from 'mattermost-redux/selectors/entities/teams'; +import { + getMyTeams, + getCurrentRelativeTeamUrl, + getCurrentTeamId, + getCurrentTeamUrl, + getTeam, + getRelativeTeamUrl, +} from 'mattermost-redux/selectors/entities/teams'; import {getNewestThreadInTeam, getThread, getThreads} from 'mattermost-redux/selectors/entities/threads'; import {getCurrentUser, getCurrentUserId, getUser, getIsManualStatusForUserId, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; import {isGuest} from 'mattermost-redux/utils/user_utils'; @@ -619,7 +626,8 @@ export function handleChannelUpdatedEvent(msg) { const state = doGetState(); if (channel.id === getCurrentChannelId(state)) { - getHistory().replace(`${getCurrentRelativeTeamUrl(state)}/channels/${channel.name}`); + // using channel's team_id to ensure we always redirect to current channel even if channel's team changes. + getHistory().replace(`${getRelativeTeamUrl(state, channel.team_id)}/channels/${channel.name}`); } }; } diff --git a/webapp/channels/src/actions/websocket_actions.test.jsx b/webapp/channels/src/actions/websocket_actions.test.jsx index 16719e81a9..25d54b1d6e 100644 --- a/webapp/channels/src/actions/websocket_actions.test.jsx +++ b/webapp/channels/src/actions/websocket_actions.test.jsx @@ -649,7 +649,10 @@ describe('handleChannelUpdatedEvent', () => { test('when a channel is updated', () => { const testStore = configureStore(initialState); - const channel = {id: 'channel'}; + const channel = { + id: 'channel', + team_id: 'team', + }; const msg = {data: {channel: JSON.stringify(channel)}}; testStore.dispatch(handleChannelUpdatedEvent(msg)); @@ -662,7 +665,10 @@ describe('handleChannelUpdatedEvent', () => { test('should not change URL when current channel is updated', () => { const testStore = configureStore(initialState); - const channel = {id: 'channel'}; + const channel = { + id: 'channel', + team_id: 'team', + }; const msg = {data: {channel: JSON.stringify(channel)}}; testStore.dispatch(handleChannelUpdatedEvent(msg)); diff --git a/webapp/channels/src/components/channel_header_dropdown/__snapshots__/channel_header_dropdown.test.tsx.snap b/webapp/channels/src/components/channel_header_dropdown/__snapshots__/channel_header_dropdown.test.tsx.snap index 5b0a8d66cb..2bbb90f3bd 100644 --- a/webapp/channels/src/components/channel_header_dropdown/__snapshots__/channel_header_dropdown.test.tsx.snap +++ b/webapp/channels/src/components/channel_header_dropdown/__snapshots__/channel_header_dropdown.test.tsx.snap @@ -459,6 +459,43 @@ exports[`components/ChannelHeaderDropdown should match snapshot with no plugin i show={false} text="Edit Conversation Header" /> + + + + + + { dialogProps={{channel}} text={localizeMessage('channel_header.setConversationHeader', 'Edit Conversation Header')} /> + + + + + void; + onURLChange: (url: string) => void; + autoFocus?: boolean; + onErrorStateChange?: (isError: boolean) => void; +} + +import './channel_name_form_field.scss'; + +function validateDisplayName(displayNameParam: string) { + const errors: string[] = []; + + const displayName = displayNameParam.trim(); + + if (displayName.length < Constants.MIN_CHANNELNAME_LENGTH) { + errors.push(localizeMessage('channel_modal.name.longer', 'Channel names must have at least 2 characters.')); + } + + if (displayName.length > Constants.MAX_CHANNELNAME_LENGTH) { + errors.push(localizeMessage('channel_modal.name.shorter', 'Channel names must have maximum 64 characters.')); + } + + return errors; +} + +// Component for input fields for editing channel display name +// along with stuff to edit its URL. +const ChannelNameFormField = (props: Props): JSX.Element => { + const intl = useIntl(); + const {formatMessage} = intl; + + const displayNameModified = useRef(false); + const [displayNameError, setDisplayNameError] = useState(''); + const displayName = useRef(''); + const urlModified = useRef(false); + const [url, setURL] = useState(''); + const [urlError, setURLError] = useState(''); + const [inputCustomMessage, setInputCustomMessage] = useState(null); + + const {name: currentTeamName} = useSelector(getCurrentTeam); + + const handleOnDisplayNameChange = useCallback((e: React.ChangeEvent) => { + e.preventDefault(); + const {target: {value: updatedDisplayName}} = e; + + const displayNameErrors = validateDisplayName(updatedDisplayName); + + // set error if any, else clear it + setDisplayNameError(displayNameErrors.length ? displayNameErrors[displayNameErrors.length - 1] : ''); + displayName.current = updatedDisplayName; + props.onDisplayNameChange(updatedDisplayName); + + if (!urlModified.current) { + // if URL isn't explicitly modified, it's derived from the display name + const cleanURL = cleanUpUrlable(updatedDisplayName); + setURL(cleanURL); + setURLError(''); + props.onURLChange(cleanURL); + } + }, [props.onDisplayNameChange, props.onURLChange]); + + const handleOnDisplayNameBlur = useCallback(() => { + if (displayName.current && !url) { + const url = generateSlug(); + setURL(url); + props.onURLChange(url); + } + if (!displayNameModified.current) { + displayNameModified.current = true; + setInputCustomMessage(null); + } + }, [props.onURLChange, displayName.current, url, displayNameModified]); + + const handleOnURLChange = useCallback((e: React.ChangeEvent) => { + e.preventDefault(); + const {target: {value: url}} = e; + + const cleanURL = url.toLowerCase().replace(/\s/g, '-'); + const urlErrors = validateChannelUrl(cleanURL, intl) as string[]; + + setURLError(urlErrors.length ? urlErrors[urlErrors.length - 1] : ''); + setURL(cleanURL); + urlModified.current = true; + props.onURLChange(cleanURL); + }, [props.onURLChange]); + + useEffect(() => { + if (props.onErrorStateChange) { + props.onErrorStateChange(Boolean(displayNameError) || Boolean(urlError)); + } + }, [displayNameError, urlError]); + + return ( + + + + + ); +}; + +export default ChannelNameFormField; diff --git a/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.scss b/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.scss new file mode 100644 index 0000000000..b4c3f3a028 --- /dev/null +++ b/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.scss @@ -0,0 +1,60 @@ +#convert-gm-to-channel-modal { + .convert-gm-to-channel-modal-body { + display: flex; + flex-direction: column; + gap: 20px; + overflow-y: visible; + transition: height 200ms ease; + + &.multi-team { + height: 340px; + } + + &.single-team { + height: 230px; + } + + &.loading { + height: 152px; + } + + &.error { + height: unset; + } + + .new-channel-modal__url { + margin-top: -20px; + } + + .loadingIndicator { + display: block; + width: 32px; + height: 32px; + margin: 36px auto 48px; + font-size: 32px; + } + + .conversion-error { + color: var(--error-text); + } + } + + .DropDown__menu { + z-index: 999; + max-height: 120px; + } + + .DropDown__menu-list { + z-index: 999; + max-height: 120px; + } + + .modal-body { + overflow: visible; + } + + .GenericModal__button.delete.disabled { + color: var(--button-color) !important; + opacity: 0.65; + } +} diff --git a/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.test.tsx b/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.test.tsx new file mode 100644 index 0000000000..ce1378cda3 --- /dev/null +++ b/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.test.tsx @@ -0,0 +1,177 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fireEvent, waitFor} from '@testing-library/react'; +import nock from 'nock'; +import React from 'react'; +import {act} from 'react-dom/test-utils'; + +import type {Channel} from '@mattermost/types/channels'; +import type {Team} from '@mattermost/types/teams'; +import type {UserProfile} from '@mattermost/types/users'; +import type {DeepPartial} from '@mattermost/types/utilities'; + +import {Client4} from 'mattermost-redux/client'; +import {Preferences} from 'mattermost-redux/constants'; + +import ConvertGmToChannelModal from 'components/convert_gm_to_channel_modal/convert_gm_to_channel_modal'; + +import TestHelper from 'packages/mattermost-redux/test/test_helper'; +import {renderWithFullContext, screen} from 'tests/react_testing_utils'; + +import type {GlobalState} from 'types/store'; + +describe('component/ConvertGmToChannelModal', () => { + const user1 = TestHelper.fakeUserWithId(); + const user2 = TestHelper.fakeUserWithId(); + const user3 = TestHelper.fakeUserWithId(); + + const baseProps = { + onExited: jest.fn(), + channel: {id: 'channel_id_1', type: 'G'} as Channel, + actions: { + closeModal: jest.fn(), + convertGroupMessageToPrivateChannel: jest.fn(), + moveChannelsInSidebar: jest.fn(), + }, + profilesInChannel: [user1, user2, user3] as UserProfile[], + teammateNameDisplaySetting: Preferences.DISPLAY_PREFER_FULL_NAME, + channelsCategoryId: 'sidebar_category_1', + currentUserId: user1.id, + }; + + const baseState: DeepPartial = { + entities: { + teams: { + teams: { + team_id_1: {id: 'team_id_1', display_name: 'Team 1'} as Team, + team_id_2: {id: 'team_id_2', display_name: 'Team 2'} as Team, + }, + currentTeamId: 'team_id_1', + }, + }, + }; + + test('members part of multiple common teams', async () => { + TestHelper.initBasic(Client4); + nock(Client4.getBaseRoute()). + get('/channels/channel_id_1/common_teams'). + reply(200, [ + {id: 'team_id_1', display_name: 'Team 1'}, + {id: 'team_id_2', display_name: 'Team 2'}, + ]); + + renderWithFullContext( + , + baseState, + ); + + // we need to use waitFor for first assertion as we have a minimum 1200 ms loading animation in the dialog + // before it's content is rendered. + await waitFor( + () => expect(screen.queryByText('Conversation history will be visible to any channel members')).toBeInTheDocument(), + {timeout: 1500}, + ); + + expect(screen.queryByText('Select Team')).toBeInTheDocument(); + expect(screen.queryByPlaceholderText('Channel name')).toBeInTheDocument(); + expect(screen.queryByText('Edit')).toBeInTheDocument(); + }); + + test('members part of single common teams', async () => { + TestHelper.initBasic(Client4); + nock(Client4.getBaseRoute()). + get('/channels/channel_id_1/common_teams'). + reply(200, [ + {id: 'team_id_1', display_name: 'Team 1'}, + ]); + + renderWithFullContext( + , + baseState, + ); + + // we need to use waitFor for first assertion as we have a minimum 1200 ms loading animation in the dialog + // before it's content is rendered. + await waitFor( + () => expect(screen.queryByText('Conversation history will be visible to any channel members')).toBeInTheDocument(), + {timeout: 1500}, + ); + + expect(screen.queryByText('Select Team')).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText('Channel name')).toBeInTheDocument(); + expect(screen.queryByText('Edit')).toBeInTheDocument(); + }); + + test('members part of no common teams', async () => { + TestHelper.initBasic(Client4); + nock(Client4.getBaseRoute()). + get('/channels/channel_id_1/common_teams'). + reply(200, []); + + renderWithFullContext( + , + baseState, + ); + + // we need to use waitFor for first assertion as we have a minimum 1200 ms loading animation in the dialog + // before it's content is rendered. + await waitFor( + () => expect(screen.queryByText('Unable to convert to a channel because group members are part of different teams')).toBeInTheDocument(), + {timeout: 1500}, + ); + + expect(screen.queryByText('Select Team')).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText('Channel name')).not.toBeInTheDocument(); + expect(screen.queryByText('Edit')).not.toBeInTheDocument(); + }); + + test('multiple common teams - trying conversion', async () => { + TestHelper.initBasic(Client4); + nock(Client4.getBaseRoute()). + get('/channels/channel_id_1/common_teams'). + reply(200, [ + {id: 'team_id_1', display_name: 'Team 1'}, + {id: 'team_id_2', display_name: 'Team 2'}, + ]); + + baseProps.actions.convertGroupMessageToPrivateChannel.mockResolvedValueOnce({}); + + renderWithFullContext( + , + baseState, + ); + + // we need to use waitFor for first assertion as we have a minimum 1200 ms loading animation in the dialog + // before it's content is rendered. + await waitFor( + () => expect(screen.queryByText('Conversation history will be visible to any channel members')).toBeInTheDocument(), + {timeout: 1500}, + ); + + const teamDropdown = screen.queryByText('Select Team'); + expect(teamDropdown).not.toBeNull(); + fireEvent( + teamDropdown!, + new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + }), + ); + + const team1Option = screen.queryByText('Team 1'); + expect(team1Option).toBeInTheDocument(); + fireEvent.click(team1Option!); + + const channelNameInput = screen.queryByPlaceholderText('Channel name'); + expect(channelNameInput).toBeInTheDocument(); + fireEvent.change(channelNameInput!, {target: {value: 'Channel name set by me'}}); + + const confirmButton = screen.queryByText('Convert to private channel'); + expect(channelNameInput).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(confirmButton!); + }); + }); +}); diff --git a/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.tsx b/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.tsx new file mode 100644 index 0000000000..801af1630d --- /dev/null +++ b/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.tsx @@ -0,0 +1,217 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import type {ComponentProps} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {useDispatch} from 'react-redux'; + +import './convert_gm_to_channel_modal.scss'; + +import {GenericModal} from '@mattermost/components'; +import type {Channel} from '@mattermost/types/channels'; +import type {ServerError} from '@mattermost/types/errors'; +import type {Team} from '@mattermost/types/teams'; +import type {UserProfile} from '@mattermost/types/users'; + +import type {ActionResult} from 'mattermost-redux/types/actions'; +import {displayUsername} from 'mattermost-redux/utils/user_utils'; + +import {getGroupMessageMembersCommonTeams} from 'actions/team_actions'; +import {trackEvent} from 'actions/telemetry_actions'; + +import ChannelNameFormField from 'components/channel_name_form_field/channel_name_form_field'; +import type {Actions} from 'components/convert_gm_to_channel_modal/index'; +import NoCommonTeamsError from 'components/convert_gm_to_channel_modal/no_common_teams/no_common_teams'; +import TeamSelector from 'components/convert_gm_to_channel_modal/team_selector/team_selector'; +import WarningTextSection from 'components/convert_gm_to_channel_modal/warning_text_section/warning_text_section'; +import LoadingSpinner from 'components/widgets/loading/loading_spinner'; + +export type Props = { + onExited: () => void; + channel: Channel; + actions: Actions; + profilesInChannel: UserProfile[]; + teammateNameDisplaySetting: string; + currentUserId: string; +} + +const ConvertGmToChannelModal = (props: Props) => { + const intl = useIntl(); + const {formatMessage} = intl; + + const [channelName, setChannelName] = useState(''); + const channelURL = useRef(''); + const handleChannelURLChange = useCallback((newURL: string) => { + channelURL.current = newURL; + }, []); + + const [channelMemberNames, setChannelMemberNames] = useState([]); + + useEffect(() => { + const validProfilesInChannel = props.profilesInChannel. + filter((user) => user.id !== props.currentUserId && user.delete_at === 0). + map((user) => displayUsername(user, props.teammateNameDisplaySetting)); + + setChannelMemberNames(validProfilesInChannel); + }, [props.profilesInChannel]); + + const [commonTeamsById, setCommonTeamsById] = useState<{[id: string]: Team}>({}); + const [commonTeamsFetched, setCommonTeamsFetched] = useState(false); + const [loadingAnimationTimeout, setLoadingAnimationTimeout] = useState(false); + const [selectedTeamId, setSelectedTeamId] = useState(); + const [nameError, setNameError] = useState(false); + const [conversionError, setConversionError] = useState(); + + const dispatch = useDispatch(); + + const mounted = useRef(false); + useEffect(() => { + mounted.current = true; + return (() => { + mounted.current = false; + }); + }, []); + + useEffect(() => { + const work = async () => { + const response = await dispatch(getGroupMessageMembersCommonTeams(props.channel.id)) as ActionResult; + if (!mounted.current) { + return; + } + + if (response.error || !response.data) { + return; + } + const teams = response.data; + + const teamsById: {[id: string]: Team} = {}; + teams.forEach((team) => { + teamsById[team.id] = team; + }); + + setCommonTeamsById(teamsById); + setCommonTeamsFetched(true); + + // if there is only common team, selected it. + if (teams.length === 1) { + setSelectedTeamId(teams[0].id); + } + }; + + work(); + setTimeout(() => setLoadingAnimationTimeout(true), 1200); + }, []); + + const handleConfirm = useCallback(async () => { + if (!selectedTeamId) { + return; + } + + const {error} = await props.actions.convertGroupMessageToPrivateChannel(props.channel.id, selectedTeamId, channelName.trim(), channelURL.current.trim()); + + if (error) { + setConversionError(error.message); + return; + } + + setConversionError(undefined); + trackEvent('actions', 'convert_group_message_to_private_channel', {channel_id: props.channel.id}); + props.onExited(); + }, [selectedTeamId, props.channel.id, channelName, channelURL.current, props.actions.moveChannelsInSidebar]); + + const showLoader = !commonTeamsFetched || !loadingAnimationTimeout; + const canCreate = selectedTeamId !== undefined && channelName !== '' && !nameError; + const modalProps: Partial> = {}; + let modalBody; + + if (!showLoader && Object.keys(commonTeamsById).length === 0) { + modalProps.confirmButtonText = formatMessage({id: 'generic.okay', defaultMessage: 'Okay'}); + modalProps.handleConfirm = props.onExited; + + modalBody = ( +
+ +
+ ); + } else { + modalProps.handleCancel = showLoader ? undefined : props.onExited; + modalProps.isDeleteModal = true; + modalProps.cancelButtonText = formatMessage({id: 'channel_modal.cancel', defaultMessage: 'Cancel'}); + modalProps.confirmButtonText = formatMessage({id: 'sidebar_left.sidebar_channel_modal.confirmation_text', defaultMessage: 'Convert to private channel'}); + modalProps.isConfirmDisabled = !canCreate; + + let subBody; + if (showLoader) { + subBody = ( +
+ +
+ ); + } else { + subBody = ( + + + + { + Object.keys(commonTeamsById).length > 1 && + + } + + + + { + conversionError && +
+ + {conversionError} +
+ } + +
+ ); + } + + modalBody = ( +
1, + })} + > + {subBody} +
+ ); + } + + return ( + + {modalBody} + + ); +}; + +export default ConvertGmToChannelModal; diff --git a/webapp/channels/src/components/convert_gm_to_channel_modal/index.ts b/webapp/channels/src/components/convert_gm_to_channel_modal/index.ts new file mode 100644 index 0000000000..ad77ce52f1 --- /dev/null +++ b/webapp/channels/src/components/convert_gm_to_channel_modal/index.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; +import type {ActionCreatorsMapObject, Dispatch} from 'redux'; +import {bindActionCreators} from 'redux'; + +import {convertGroupMessageToPrivateChannel} from 'mattermost-redux/actions/channels'; +import {getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences'; +import { + getCurrentUserId, + makeGetProfilesInChannel, +} from 'mattermost-redux/selectors/entities/users'; +import type {Action, ActionResult} from 'mattermost-redux/types/actions'; + +import {moveChannelsInSidebar} from 'actions/views/channel_sidebar'; +import {closeModal} from 'actions/views/modals'; + +import type {Props} from 'components/convert_gm_to_channel_modal/convert_gm_to_channel_modal'; +import ConvertGmToChannelModal from 'components/convert_gm_to_channel_modal/convert_gm_to_channel_modal'; + +import type {GlobalState} from 'types/store'; + +function makeMapStateToProps() { + const getProfilesInChannel = makeGetProfilesInChannel(); + + return (state: GlobalState, ownProps: Props) => { + const allProfilesInChannel = getProfilesInChannel(state, ownProps.channel.id); + const currentUserId = getCurrentUserId(state); + const teammateNameDisplaySetting = getTeammateNameDisplaySetting(state); + + return { + profilesInChannel: allProfilesInChannel, + teammateNameDisplaySetting, + currentUserId, + }; + }; +} + +export type Actions = { + closeModal: (modalID: string) => void; + convertGroupMessageToPrivateChannel: (channelID: string, teamID: string, displayName: string, name: string) => Promise; + moveChannelsInSidebar: (categoryId: string, targetIndex: number, draggableChannelId: string, setManualSorting?: boolean) => void; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators, Actions>({ + closeModal, + convertGroupMessageToPrivateChannel, + moveChannelsInSidebar, + }, dispatch), + }; +} + +export default connect(makeMapStateToProps, mapDispatchToProps)(ConvertGmToChannelModal); diff --git a/webapp/channels/src/components/convert_gm_to_channel_modal/no_common_teams/no_common_teams.tsx b/webapp/channels/src/components/convert_gm_to_channel_modal/no_common_teams/no_common_teams.tsx new file mode 100644 index 0000000000..62e5615a2b --- /dev/null +++ b/webapp/channels/src/components/convert_gm_to_channel_modal/no_common_teams/no_common_teams.tsx @@ -0,0 +1,31 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import 'components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.scss'; + +const NoCommonTeamsError = (): JSX.Element => { + return ( +
+ +
+
+ +
+
+ +
+
+
+ ); +}; + +export default NoCommonTeamsError; diff --git a/webapp/channels/src/components/convert_gm_to_channel_modal/team_selector/team_selector.tsx b/webapp/channels/src/components/convert_gm_to_channel_modal/team_selector/team_selector.tsx new file mode 100644 index 0000000000..97cc6e45c2 --- /dev/null +++ b/webapp/channels/src/components/convert_gm_to_channel_modal/team_selector/team_selector.tsx @@ -0,0 +1,51 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import type {Team} from '@mattermost/types/teams'; + +import {getCurrentLocale} from 'selectors/i18n'; + +import DropdownInput from 'components/dropdown_input'; + +export type Props = { + teamsById: {[id: string]: Team}; + onChange: (teamId: string) => void; +} + +const TeamSelector = (props: Props): JSX.Element => { + const [value, setValue] = useState(); + const intl = useIntl(); + const {formatMessage} = intl; + + const handleTeamChange = useCallback((e) => { + const teamId = e.value as string; + + setValue(props.teamsById[teamId]); + props.onChange(teamId); + }, []); + + const currentLocale = useSelector(getCurrentLocale); + + const teamValues = Object.values(props.teamsById). + map((team) => ({value: team.id, label: team.display_name})). + sort((teamA, teamB) => teamA.label.localeCompare(teamB.label, currentLocale)); + + return ( + + ); +}; + +export default TeamSelector; diff --git a/webapp/channels/src/components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.scss b/webapp/channels/src/components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.scss new file mode 100644 index 0000000000..3ecf2179f2 --- /dev/null +++ b/webapp/channels/src/components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.scss @@ -0,0 +1,35 @@ +.warning-section { + display: flex; + padding: 16px; + border: 1px solid rgba(var(--sidebar-text-active-border-rgb), 0.16); + background: rgba(var(--sidebar-text-active-border-rgb), 0.08); + border-radius: 4px; + gap: 12px; + + &.error { + border: 1px solid rgba(var(--dnd-indicator-rgb), 0.16); + background: rgba(var(--dnd-indicator-rgb), 0.08); + + .fa.fa-exclamation-circle { + color: rgba(var(--dnd-indicator-rgb), 1); + } + } + + .fa.fa-exclamation-circle { + width: 24px; + height: 24px; + color: rgba(var(--sidebar-text-active-border-rgb), 1); + font-size: 24px; + } + + .warning-text { + display: flex; + flex-direction: column; + color: var(--center-channel-color-88); + gap: 8px; + } + + .warning-header { + font-weight: bold; + } +} diff --git a/webapp/channels/src/components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.tsx b/webapp/channels/src/components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.tsx new file mode 100644 index 0000000000..7a9c8b067e --- /dev/null +++ b/webapp/channels/src/components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.tsx @@ -0,0 +1,46 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import 'components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.scss'; + +export type Props = { + channelMemberNames: string[]; +} +const WarningTextSection = (props: Props): JSX.Element => { + const intl = useIntl(); + + let memberNames: string; + + if (props.channelMemberNames.length > 0) { + memberNames = intl.formatList(props.channelMemberNames); + } else { + memberNames = intl.formatMessage({id: 'sidebar_left.sidebar_channel_modal.warning_body_yourself', defaultMessage: 'yourself'}); + } + + return ( +
+ +
+
+ +
+
+ +
+
+
+ ); +}; +export default WarningTextSection; diff --git a/webapp/channels/src/components/dropdown_input.tsx b/webapp/channels/src/components/dropdown_input.tsx index 96b57bdd46..7c6617c12a 100644 --- a/webapp/channels/src/components/dropdown_input.tsx +++ b/webapp/channels/src/components/dropdown_input.tsx @@ -2,11 +2,17 @@ // See LICENSE.txt for license information. import classNames from 'classnames'; -import React, {useState} from 'react'; +import React, {useRef, useState} from 'react'; import type {CSSProperties} from 'react'; +import {useIntl} from 'react-intl'; import ReactSelect, {components} from 'react-select'; import type {Props as SelectProps, ActionMeta} from 'react-select'; +import InputError from 'components/input_error'; +import type {CustomMessageInputType} from 'components/widgets/inputs/input/input'; + +import {ItemStatus} from 'utils/constants'; + import './dropdown_input.scss'; // TODO: This component needs work, should not be used outside of AddressInfo until this comment is removed. @@ -22,6 +28,7 @@ type Props = Omit, 'onChange'> & { error?: string; onChange: (value: T, action: ActionMeta) => void; testId?: string; + required?: boolean; }; const baseStyles = { @@ -73,19 +80,6 @@ const Option = (props: any) => { ); }; -const renderError = (error?: string) => { - if (!error) { - return null; - } - - return ( -
- - {error} -
- ); -}; - const DropdownInput = (props: Props) => { const {value, placeholder, className, addon, name, textPrefix, legend, onChange, styles, options, error, testId, ...otherProps} = props; @@ -101,10 +95,29 @@ const DropdownInput = (props: Props) => { } }; + const {formatMessage} = useIntl(); + const [customInputLabel, setCustomInputLabel] = useState(null); + const ownValue = useRef(); + + const ownOnChange = (value: T, action: ActionMeta) => { + ownValue.current = value; + onChange(value, action); + }; + const validateInput = () => { + if (!props.required || (ownValue.current !== null && ownValue.current)) { + setCustomInputLabel(null); + return; + } + + const validationErrorMsg = formatMessage({id: 'widget.input.required', defaultMessage: 'This field is required'}); + setCustomInputLabel({type: ItemStatus.ERROR, value: validationErrorMsg}); + }; + const onInputBlur = (event: React.FocusEvent) => { const {onBlur} = props; setFocused(false); + validateInput(); if (onBlur) { onBlur(event); @@ -112,6 +125,7 @@ const DropdownInput = (props: Props) => { }; const showLegend = Boolean(focused || value); + const isError = error || customInputLabel?.type === 'error'; return (
(props: Props) => { >
@@ -145,14 +159,17 @@ const DropdownInput = (props: Props) => { className={classNames('Input', className, {Input__focus: showLegend})} classNamePrefix={'DropDown'} value={value} - onChange={onChange as any} // types are not working correctly for multiselect + onChange={ownOnChange as any} // types are not working correctly for multiselect styles={{...baseStyles, ...styles}} {...otherProps} />
{addon} - {renderError(error)} + ); }; diff --git a/webapp/channels/src/components/input_error.tsx b/webapp/channels/src/components/input_error.tsx new file mode 100644 index 0000000000..49dd966458 --- /dev/null +++ b/webapp/channels/src/components/input_error.tsx @@ -0,0 +1,42 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import React from 'react'; + +import type {CustomMessageInputType} from 'components/widgets/inputs/input/input'; + +import {ItemStatus} from 'utils/constants'; + +type Props = { + message?: string; + custom?: CustomMessageInputType; +} + +const InputError = (props: Props) => { + if (props.message) { + return ( +
+ + {props.message} +
+ ); + } else if (props.custom) { + return ( +
+ + {props.custom.value} +
+ ); + } + return null; +}; + +export default InputError; diff --git a/webapp/channels/src/components/new_channel_modal/new_channel_modal.scss b/webapp/channels/src/components/new_channel_modal/new_channel_modal.scss index e0c3b11618..bbc0807327 100644 --- a/webapp/channels/src/components/new_channel_modal/new_channel_modal.scss +++ b/webapp/channels/src/components/new_channel_modal/new_channel_modal.scss @@ -1,10 +1,4 @@ .new-channel-modal { - .new-channel-modal-name-input { - height: 34px !important; - border: 0 !important; - border-radius: 0 !important; - } - .new-channel-modal-type-selector { margin-top: 24px; } diff --git a/webapp/channels/src/components/new_channel_modal/new_channel_modal.tsx b/webapp/channels/src/components/new_channel_modal/new_channel_modal.tsx index 48aebb36bf..1e93b35662 100644 --- a/webapp/channels/src/components/new_channel_modal/new_channel_modal.tsx +++ b/webapp/channels/src/components/new_channel_modal/new_channel_modal.tsx @@ -2,8 +2,7 @@ // See LICENSE.txt for license information. import classNames from 'classnames'; -import crypto from 'crypto'; -import React, {useState} from 'react'; +import React, {useCallback, useState} from 'react'; import {Tooltip} from 'react-bootstrap'; import {FormattedMessage, useIntl} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; @@ -25,15 +24,12 @@ import type {DispatchFunc} from 'mattermost-redux/types/actions'; import {switchToChannel} from 'actions/views/channel'; import {closeModal} from 'actions/views/modals'; +import ChannelNameFormField from 'components/channel_name_form_field/channel_name_form_field'; import OverlayTrigger from 'components/overlay_trigger'; -import Input from 'components/widgets/inputs/input/input'; -import URLInput from 'components/widgets/inputs/url_input/url_input'; import PublicPrivateSelector from 'components/widgets/public-private-selector/public-private-selector'; import Pluggable from 'plugins/pluggable'; -import Constants, {ItemStatus, ModalIdentifiers} from 'utils/constants'; -import {cleanUpUrlable, validateChannelUrl, getSiteURL} from 'utils/url'; -import {localizeMessage} from 'utils/utils'; +import Constants, {ModalIdentifiers} from 'utils/constants'; import type {GlobalState} from 'types/store'; @@ -53,20 +49,6 @@ export function getChannelTypeFromPermissions(canCreatePublicChannel: boolean, c return channelType as ChannelType; } -export function validateDisplayName(displayName: string) { - const errors: string[] = []; - - if (displayName.length < Constants.MIN_CHANNELNAME_LENGTH) { - errors.push(localizeMessage('channel_modal.name.longer', 'Channel names must have at least 2 characters.')); - } - - if (displayName.length > Constants.MAX_CHANNELNAME_LENGTH) { - errors.push(localizeMessage('channel_modal.name.shorter', 'Channel names must have maximum 64 characters.')); - } - - return errors; -} - const enum ServerErrorId { CHANNEL_URL_SIZE = 'model.channel.is_valid.1_or_more.app_error', CHANNEL_UPDATE_EXISTS = 'store.sql_channel.update.exists.app_error', @@ -78,7 +60,7 @@ const NewChannelModal = () => { const intl = useIntl(); const {formatMessage} = intl; - const {id: currentTeamId, name: currentTeamName} = useSelector((state: GlobalState) => getCurrentTeam(state)); + const {id: currentTeamId} = useSelector(getCurrentTeam); const canCreatePublicChannel = useSelector((state: GlobalState) => (currentTeamId ? haveICurrentChannelPermission(state, Permissions.CREATE_PUBLIC_CHANNEL) : false)); const canCreatePrivateChannel = useSelector((state: GlobalState) => (currentTeamId ? haveICurrentChannelPermission(state, Permissions.CREATE_PRIVATE_CHANNEL) : false)); @@ -88,12 +70,10 @@ const NewChannelModal = () => { const [displayName, setDisplayName] = useState(''); const [url, setURL] = useState(''); const [purpose, setPurpose] = useState(''); - const [displayNameModified, setDisplayNameModified] = useState(false); - const [urlModified, setURLModified] = useState(false); - const [displayNameError, setDisplayNameError] = useState(''); const [urlError, setURLError] = useState(''); const [purposeError, setPurposeError] = useState(''); const [serverError, setServerError] = useState(''); + const [channelInputError, setChannelInputError] = useState(false); // create a board along with the channel const pluginsComponentsList = useSelector((state: GlobalState) => state.plugins.components); @@ -215,48 +195,10 @@ const NewChannelModal = () => { } }; - const handleOnDisplayNameChange = (e: React.ChangeEvent) => { - e.preventDefault(); - const {target: {value: displayName}} = e; - - const displayNameErrors = validateDisplayName(displayName); - - setDisplayNameError(displayNameErrors.length ? displayNameErrors[displayNameErrors.length - 1] : ''); - setDisplayName(displayName); - setServerError(''); - - if (!urlModified) { - setURL(cleanUpUrlable(displayName)); - setURLError(''); - } - }; - - const handleOnDisplayNameBlur = () => { - if (displayName && !url) { - setURL(crypto.randomBytes(16).toString('hex')); - } - if (!displayNameModified) { - setDisplayNameModified(true); - } - }; - - const handleOnURLChange = (e: React.ChangeEvent) => { - e.preventDefault(); - const {target: {value: url}} = e; - - const cleanURL = url.toLowerCase().replace(/\s/g, '-'); - const urlErrors = validateChannelUrl(cleanURL, intl) as string[]; - - setURLError(urlErrors.length ? urlErrors[urlErrors.length - 1] : ''); - setURL(cleanURL); - setURLModified(true); - setServerError(''); - }; - - const handleOnTypeChange = (channelType: ChannelType) => { + const handleOnTypeChange = useCallback((channelType: ChannelType) => { setType(channelType); setServerError(''); - }; + }, []); const handleOnPurposeChange = (e: React.ChangeEvent) => { e.preventDefault(); @@ -272,7 +214,7 @@ const NewChannelModal = () => { e.stopPropagation(); }; - const canCreate = displayName && !displayNameError && !urlError && type && !purposeError && !serverError && canCreateFromPluggable; + const canCreate = displayName && !urlError && type && !purposeError && !serverError && canCreateFromPluggable && !channelInputError; const newBoardInfoIcon = ( { onExited={handleOnModalCancel} >
- - ); + } else if (post.type === Posts.POST_TYPES.GM_CONVERTED_TO_CHANNEL) { + // This is rendered via a separate component instead of registering in + // systemMessageRenderers because we need to format a list with keeping i18n support + // which cannot be done outside a react component. + return ( + + ); } return null; diff --git a/webapp/channels/src/components/post_view/gm_conversion_message/gm_conversion_message.tsx b/webapp/channels/src/components/post_view/gm_conversion_message/gm_conversion_message.tsx new file mode 100644 index 0000000000..2bbb50d6e0 --- /dev/null +++ b/webapp/channels/src/components/post_view/gm_conversion_message/gm_conversion_message.tsx @@ -0,0 +1,60 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useRef} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {useDispatch, useSelector} from 'react-redux'; + +import type {Post} from '@mattermost/types/posts'; + +import {getMissingProfilesByIds} from 'mattermost-redux/actions/users'; +import {makeGetProfilesByIdsAndUsernames} from 'mattermost-redux/selectors/entities/users'; + +import {renderUsername} from 'components/post_markdown/system_message_helpers'; + +import type {GlobalState} from 'types/store'; + +export type Props = { + post: Post; +} +function GMConversionMessage(props: Props): JSX.Element { + const convertedByUserId = props.post.props.convertedByUserId; + const gmMembersDuringConversionIDs = props.post.props.gmMembersDuringConversionIDs as string[]; + + const dispatch = useDispatch(); + const intl = useIntl(); + + useEffect(() => { + dispatch(getMissingProfilesByIds(gmMembersDuringConversionIDs)); + }, [props.post]); + + const getProfilesByIdsAndUsernames = useRef(makeGetProfilesByIdsAndUsernames()); + const userProfiles = useSelector( + (state: GlobalState) => getProfilesByIdsAndUsernames.current( + state, + {allUserIds: gmMembersDuringConversionIDs, allUsernames: []}, + ), + ); + + const convertedByUserUsername = userProfiles.find((user) => user.id === convertedByUserId)!.username; + const gmMembersUsernames = userProfiles.map((user) => renderUsername(user.username)); + + if (!convertedByUserId || !gmMembersDuringConversionIDs || gmMembersDuringConversionIDs.length === 0) { + return ( + {props.post.message} + ); + } + + return ( + + ); +} + +export default GMConversionMessage; diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 39d9d894b5..cb4c09b959 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -2661,6 +2661,7 @@ "api.channel.add_guest.added": "{addedUsername} added to the channel as a guest by {username}.", "api.channel.add_member.added": "{addedUsername} added to the channel by {username}.", "api.channel.delete_channel.archived": "{username} archived the channel.", + "api.channel.group_message_converted_to.private_channel": "{convertedBy} created this channel from a group message with {gmMembers}.", "api.channel.guest_join_channel.post_and_forget": "{username} joined the channel as a guest.", "api.channel.join_channel.post_and_forget": "{username} joined the channel.", "api.channel.leave.left": "{username} left the channel.", @@ -3614,6 +3615,7 @@ "generic.close": "Close", "generic.done": "Done", "generic.next": "Next", + "generic.okay": "Okay", "generic.previous": "Previous", "get_app.continueToBrowser": "View in Browser", "get_app.dontHaveTheDesktopApp": "Don't have the Desktop App?", @@ -4822,6 +4824,7 @@ "sidebar_left.sidebar_category_menu.viewCategory": "Mark category as read", "sidebar_left.sidebar_category.newDropBoxLabel": "Drag channels here...", "sidebar_left.sidebar_category.newLabel": "new", + "sidebar_left.sidebar_channel_menu_convert_to_channel": "Convert to Private Channel", "sidebar_left.sidebar_channel_menu.addMembers": "Add Members", "sidebar_left.sidebar_channel_menu.channels": "Channels", "sidebar_left.sidebar_channel_menu.copyLink": "Copy Link", @@ -4841,6 +4844,15 @@ "sidebar_left.sidebar_channel_menu.unfavoriteChannel": "Unfavorite", "sidebar_left.sidebar_channel_menu.unmuteChannel": "Unmute Channel", "sidebar_left.sidebar_channel_menu.unmuteConversation": "Unmute Conversation", + "sidebar_left.sidebar_channel_modal.channel_name_placeholder": "Enter a name for the channel", + "sidebar_left.sidebar_channel_modal.confirmation_text": "Convert to private channel", + "sidebar_left.sidebar_channel_modal.header": "Convert to Private Channel", + "sidebar_left.sidebar_channel_modal.no_common_teams_error.body": "Group Message cannot be converted to a channel because members are not a part of the same team. Add all members to a single team to convert this group message to a channel in that team.", + "sidebar_left.sidebar_channel_modal.no_common_teams_error.heading": "Unable to convert to a channel because group members are part of different teams", + "sidebar_left.sidebar_channel_modal.select_team_placeholder": "Select Team", + "sidebar_left.sidebar_channel_modal.warning_body": "You are about to convert the Group Message with {memberNames} to a Channel. This cannot be undone.", + "sidebar_left.sidebar_channel_modal.warning_body_yourself": "yourself", + "sidebar_left.sidebar_channel_modal.warning_header": "Conversation history will be visible to any channel members", "sidebar_left.sidebar_channel_navigator.addChannelsCta": "Add channels", "sidebar_left.sidebar_channel_navigator.inviteUsers": "Invite Users", "sidebar_left.sidebar_channel.selectedCount": "{count} selected", diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts index 5f788e5c67..22e2d021ec 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts @@ -339,6 +339,40 @@ export function updateChannelPrivacy(channelId: string, privacy: string): Action }; } +export function convertGroupMessageToPrivateChannel(channelID: string, teamID: string, displayName: string, name: string): ActionFunc { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + dispatch({type: ChannelTypes.UPDATE_CHANNEL_REQUEST, data: null}); + + let updatedChannel; + try { + updatedChannel = await Client4.convertGroupMessageToPrivateChannel(channelID, teamID, displayName, name); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch({type: ChannelTypes.UPDATE_CHANNEL_FAILURE, error}); + dispatch(logError(error)); + return {error}; + } + + dispatch(batchActions([ + { + type: ChannelTypes.RECEIVED_CHANNEL, + data: updatedChannel, + }, + { + type: ChannelTypes.UPDATE_CHANNEL_SUCCESS, + }, + ])); + + // move the channel from direct message category to the default "channels" category + const channelsCategory = getCategoryInTeamByType(getState(), teamID, CategoryTypes.CHANNELS); + if (!channelsCategory) { + return {}; + } + + return updatedChannel; + }; +} + export function updateChannelNotifyProps(userId: string, channelId: string, props: Partial): ActionFunc { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { const notifyProps = { diff --git a/webapp/channels/src/packages/mattermost-redux/src/constants/posts.ts b/webapp/channels/src/packages/mattermost-redux/src/constants/posts.ts index 344383d5b3..abed36223d 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/constants/posts.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/constants/posts.ts @@ -32,6 +32,7 @@ export const PostTypes = { ADD_BOT_TEAMS_CHANNELS: 'add_bot_teams_channels' as PostType, SYSTEM_WARN_METRIC_STATUS: 'warn_metric_status' as PostType, REMINDER: 'reminder' as PostType, + GM_CONVERTED_TO_CHANNEL: 'system_gm_to_channel' as PostType, }; export default { diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/teams.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/teams.ts index 1ff828f67d..e6b75beff3 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/teams.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/teams.ts @@ -143,6 +143,11 @@ export const getCurrentRelativeTeamUrl: (state: GlobalState) => string = createS }, ); +export function getRelativeTeamUrl(state: GlobalState, teamId: string): string { + const team = getTeam(state, teamId); + return `/${team.name}`; +} + export const getCurrentTeamStats: (state: GlobalState) => TeamStats = createSelector( 'getCurrentTeamStats', getCurrentTeamId, diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index ffd7063f28..1a2e6d2449 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -445,6 +445,7 @@ export const ModalIdentifiers = { SELF_HOSTED_EXPANSION: 'self_hosted_expansion', START_TRIAL_FORM_MODAL: 'start_trial_form_modal', START_TRIAL_FORM_MODAL_RESULT: 'start_trial_form_modal_result', + CONVERT_GM_TO_CHANNEL: 'convert_gm_to_channel', }; export const UserStatuses = { diff --git a/webapp/channels/src/utils/utils.tsx b/webapp/channels/src/utils/utils.tsx index 5ac649d13a..0c899474d1 100644 --- a/webapp/channels/src/utils/utils.tsx +++ b/webapp/channels/src/utils/utils.tsx @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import {getName} from 'country-list'; +import crypto from 'crypto'; import cssVars from 'css-vars-ponyfill'; import type {Locale} from 'date-fns'; import {isNil} from 'lodash'; @@ -1747,6 +1748,9 @@ export function getBlankAddressWithCountry(country?: string): Address { }; } +export function generateSlug(): string { + return crypto.randomBytes(16).toString('hex'); +} export function sortUsersAndGroups(a: UserProfile | Group, b: UserProfile | Group) { let aSortString = ''; let bSortString = ''; diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 69047ff848..1f5cf096fe 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -4201,6 +4201,26 @@ export default class Client4 { {method: 'delete', body: JSON.stringify(deletionRequest)}, ); } + + getGroupMessageMembersCommonTeams = (channelId: string) => { + return this.doFetchWithResponse( + `${this.getChannelRoute(channelId)}/common_teams`, + {method: 'get'}, + ) + } + + convertGroupMessageToPrivateChannel = (channelId: string, teamId: string, displayName: string, name: string) => { + const body = { + channel_id: channelId, + team_id: teamId, + display_name: displayName, + name: name, + } + return this.doFetchWithResponse( + `${this.getChannelRoute(channelId)}/convert_to_channel?team_id=${teamId}`, + {method: 'post', body: JSON.stringify(body)}, + ) + } } export function parseAndMergeNestedHeaders(originalHeaders: any) {