diff --git a/server/channels/app/export.go b/server/channels/app/export.go index e4e1fa54e8..47a4d2d90b 100644 --- a/server/channels/app/export.go +++ b/server/channels/app/export.go @@ -29,43 +29,83 @@ import ( // We use this map to identify the exportable preferences. // Here we link the preference category and name, to the name of the relevant field in the import struct. -var exportablePreferences = map[imports.ComparablePreference]string{{ - Category: model.PreferenceCategoryTheme, - Name: "", -}: "Theme", { - Category: model.PreferenceCategoryAdvancedSettings, - Name: "feature_enabled_markdown_preview", -}: "UseMarkdownPreview", { - Category: model.PreferenceCategoryAdvancedSettings, - Name: "formatting", -}: "UseFormatting", { - Category: model.PreferenceCategorySidebarSettings, - Name: "show_unread_section", -}: "ShowUnreadSection", { - Category: model.PreferenceCategoryDisplaySettings, - Name: model.PreferenceNameUseMilitaryTime, -}: "UseMilitaryTime", { - Category: model.PreferenceCategoryDisplaySettings, - Name: model.PreferenceNameCollapseSetting, -}: "CollapsePreviews", { - Category: model.PreferenceCategoryDisplaySettings, - Name: model.PreferenceNameMessageDisplay, -}: "MessageDisplay", { - Category: model.PreferenceCategoryDisplaySettings, - Name: "channel_display_mode", -}: "CollapseConsecutive", { - Category: model.PreferenceCategoryDisplaySettings, - Name: "collapse_consecutive_messages", -}: "ColorizeUsernames", { - Category: model.PreferenceCategoryDisplaySettings, - Name: "colorize_usernames", -}: "ChannelDisplayMode", { - Category: model.PreferenceCategoryTutorialSteps, - Name: "", -}: "TutorialStep", { - Category: model.PreferenceCategoryNotifications, - Name: model.PreferenceNameEmailInterval, -}: "EmailInterval", +var exportablePreferences = map[imports.ComparablePreference]string{ + { + Category: model.PreferenceCategoryTheme, + Name: "", + }: "Theme", + { + Category: model.PreferenceCategoryAdvancedSettings, + Name: "feature_enabled_markdown_preview", + }: "UseMarkdownPreview", + { + Category: model.PreferenceCategoryAdvancedSettings, + Name: "formatting", + }: "UseFormatting", + { + Category: model.PreferenceCategoryAdvancedSettings, + Name: "send_on_ctrl_enter", + }: "SendOnCtrlEnter", + { + Category: model.PreferenceCategoryAdvancedSettings, + Name: "code_block_ctrl_enter", + }: "CodeBlockCtrlEnter", + { + Category: model.PreferenceCategoryAdvancedSettings, + Name: "join_leave", + }: "ShowJoinLeave", + { + Category: model.PreferenceCategoryAdvancedSettings, + Name: "unread_scroll_position", + }: "ShowUnreadScrollPosition", + { + Category: model.PreferenceCategoryAdvancedSettings, + Name: "sync_drafts", + }: "SyncDrafts", + { + Category: model.PreferenceCategorySidebarSettings, + Name: model.PreferenceNameShowUnreadSection, + }: "ShowUnreadSection", + { + Category: model.PreferenceCategorySidebarSettings, + Name: model.PreferenceLimitVisibleDmsGms, + }: "LimitVisibleDmsGms", + { + Category: model.PreferenceCategoryDisplaySettings, + Name: model.PreferenceNameUseMilitaryTime, + }: "UseMilitaryTime", + { + Category: model.PreferenceCategoryDisplaySettings, + Name: model.PreferenceNameCollapseSetting, + }: "CollapsePreviews", + { + Category: model.PreferenceCategoryDisplaySettings, + Name: model.PreferenceNameMessageDisplay, + }: "MessageDisplay", + { + Category: model.PreferenceCategoryDisplaySettings, + Name: model.PreferenceNameChannelDisplayMode, + }: "ChannelDisplayMode", + { + Category: model.PreferenceCategoryDisplaySettings, + Name: model.PreferenceNameCollapseConsecutive, + }: "CollapseConsecutive", + { + Category: model.PreferenceCategoryDisplaySettings, + Name: model.PreferenceNameColorizeUsernames, + }: "ColorizeUsernames", + { + Category: model.PreferenceCategoryDisplaySettings, + Name: model.PreferenceNameNameFormat, + }: "NameFormat", + { + Category: model.PreferenceCategoryTutorialSteps, + Name: "", + }: "TutorialStep", + { + Category: model.PreferenceCategoryNotifications, + Name: model.PreferenceNameEmailInterval, + }: "EmailInterval", } func (a *App) BulkExport(ctx request.CTX, writer io.Writer, outPath string, job *model.Job, opts model.BulkExportOpts) *model.AppError { diff --git a/server/channels/app/export_converters.go b/server/channels/app/export_converters.go index 5e9c34fbe4..aa2a839129 100644 --- a/server/channels/app/export_converters.go +++ b/server/channels/app/export_converters.go @@ -144,28 +144,35 @@ func ImportLineFromUser(user *model.User, exportedPrefs map[string]*string) *imp return &imports.LineImportData{ Type: "user", User: &imports.UserImportData{ - Username: &user.Username, - Email: &user.Email, - AuthService: authService, - AuthData: user.AuthData, - Nickname: &user.Nickname, - FirstName: &user.FirstName, - LastName: &user.LastName, - Position: &user.Position, - Roles: &user.Roles, - Locale: &user.Locale, - UseMarkdownPreview: exportedPrefs["UseMarkdownPreview"], - UseFormatting: exportedPrefs["UseFormatting"], - ShowUnreadSection: exportedPrefs["ShowUnreadSection"], - Theme: exportedPrefs["Theme"], - UseMilitaryTime: exportedPrefs["UseMilitaryTime"], - CollapsePreviews: exportedPrefs["CollapsePreviews"], - MessageDisplay: exportedPrefs["MessageDisplay"], - ColorizeUsernames: exportedPrefs["ColorizeUsernames"], - ChannelDisplayMode: exportedPrefs["ChannelDisplayMode"], - TutorialStep: exportedPrefs["TutorialStep"], - EmailInterval: exportedPrefs["EmailInterval"], - DeleteAt: &user.DeleteAt, + Username: &user.Username, + Email: &user.Email, + AuthService: authService, + AuthData: user.AuthData, + Nickname: &user.Nickname, + FirstName: &user.FirstName, + LastName: &user.LastName, + Position: &user.Position, + Roles: &user.Roles, + Locale: &user.Locale, + UseMarkdownPreview: exportedPrefs["UseMarkdownPreview"], + UseFormatting: exportedPrefs["UseFormatting"], + ShowUnreadSection: exportedPrefs["ShowUnreadSection"], + Theme: exportedPrefs["Theme"], + UseMilitaryTime: exportedPrefs["UseMilitaryTime"], + CollapsePreviews: exportedPrefs["CollapsePreviews"], + MessageDisplay: exportedPrefs["MessageDisplay"], + ColorizeUsernames: exportedPrefs["ColorizeUsernames"], + ChannelDisplayMode: exportedPrefs["ChannelDisplayMode"], + TutorialStep: exportedPrefs["TutorialStep"], + EmailInterval: exportedPrefs["EmailInterval"], + NameFormat: exportedPrefs["NameFormat"], + SendOnCtrlEnter: exportedPrefs["SendOnCtrlEnter"], + ShowJoinLeave: exportedPrefs["ShowJoinLeave"], + SyncDrafts: exportedPrefs["SyncDrafts"], + ShowUnreadScrollPosition: exportedPrefs["ShowUnreadScrollPosition"], + LimitVisibleDmsGms: exportedPrefs["LimitVisibleDmsGms"], + CodeBlockCtrlEnter: exportedPrefs["CodeBlockCtrlEnter"], + DeleteAt: &user.DeleteAt, }, } } @@ -238,17 +245,20 @@ func ImportUserChannelDataFromChannelMemberAndPreferences(member *model.ChannelM } func ImportLineForPost(post *model.PostForExport) *imports.LineImportData { + f := []string(post.FlaggedBy) return &imports.LineImportData{ Type: "post", Post: &imports.PostImportData{ - Team: &post.TeamName, - Channel: &post.ChannelName, - User: &post.Username, - Type: &post.Type, - Message: &post.Message, - Props: &post.Props, - CreateAt: &post.CreateAt, - EditAt: &post.EditAt, + Team: &post.TeamName, + Channel: &post.ChannelName, + User: &post.Username, + Type: &post.Type, + Message: &post.Message, + Props: &post.Props, + CreateAt: &post.CreateAt, + EditAt: &post.EditAt, + IsPinned: &post.IsPinned, + FlaggedBy: &f, }, } } @@ -258,6 +268,7 @@ func ImportLineForDirectPost(post *model.DirectPostForExport) *imports.LineImpor if len(channelMembers) == 1 { channelMembers = []string{channelMembers[0], channelMembers[0]} } + f := []string(post.FlaggedBy) return &imports.LineImportData{ Type: "direct_post", DirectPost: &imports.DirectPostImportData{ @@ -268,17 +279,22 @@ func ImportLineForDirectPost(post *model.DirectPostForExport) *imports.LineImpor Props: &post.Props, CreateAt: &post.CreateAt, EditAt: &post.EditAt, + IsPinned: &post.IsPinned, + FlaggedBy: &f, }, } } func ImportReplyFromPost(post *model.ReplyForExport) *imports.ReplyImportData { + f := []string(post.FlaggedBy) return &imports.ReplyImportData{ - User: &post.Username, - Type: &post.Type, - Message: &post.Message, - CreateAt: &post.CreateAt, - EditAt: &post.EditAt, + User: &post.Username, + Type: &post.Type, + Message: &post.Message, + CreateAt: &post.CreateAt, + EditAt: &post.EditAt, + IsPinned: &post.IsPinned, + FlaggedBy: &f, } } diff --git a/server/channels/app/import_functions.go b/server/channels/app/import_functions.go index faa9b79b3e..bdee174bd2 100644 --- a/server/channels/app/import_functions.go +++ b/server/channels/app/import_functions.go @@ -722,7 +722,7 @@ func (a *App) importUser(rctx request.CTX, data *imports.UserImportData, dryRun preferences = append(preferences, model.Preference{ UserId: savedUser.Id, Category: model.PreferenceCategoryDisplaySettings, - Name: "channel_display_mode", + Name: model.PreferenceNameChannelDisplayMode, Value: *data.ChannelDisplayMode, }) } @@ -763,6 +763,69 @@ func (a *App) importUser(rctx request.CTX, data *imports.UserImportData, dryRun }) } + if data.SendOnCtrlEnter != nil { + preferences = append(preferences, model.Preference{ + UserId: savedUser.Id, + Category: model.PreferenceCategoryAdvancedSettings, + Name: "send_on_ctrl_enter", + Value: *data.SendOnCtrlEnter, + }) + } + + if data.CodeBlockCtrlEnter != nil { + preferences = append(preferences, model.Preference{ + UserId: savedUser.Id, + Category: model.PreferenceCategoryAdvancedSettings, + Name: "code_block_ctrl_enter", + Value: *data.CodeBlockCtrlEnter, + }) + } + + if data.ShowJoinLeave != nil { + preferences = append(preferences, model.Preference{ + UserId: savedUser.Id, + Category: model.PreferenceCategoryAdvancedSettings, + Name: "join_leave", + Value: *data.ShowJoinLeave, + }) + } + + if data.ShowUnreadScrollPosition != nil { + preferences = append(preferences, model.Preference{ + UserId: savedUser.Id, + Category: model.PreferenceCategoryAdvancedSettings, + Name: "unread_scroll_position", + Value: *data.ShowUnreadScrollPosition, + }) + } + + if data.SyncDrafts != nil { + preferences = append(preferences, model.Preference{ + UserId: savedUser.Id, + Category: model.PreferenceCategoryAdvancedSettings, + Name: "sync_drafts", + Value: *data.SyncDrafts, + }) + } + + if data.LimitVisibleDmsGms != nil { + preferences = append(preferences, model.Preference{ + UserId: savedUser.Id, + Category: model.PreferenceCategorySidebarSettings, + Name: model.PreferenceLimitVisibleDmsGms, + Value: *data.LimitVisibleDmsGms, + }) + } + + if data.NameFormat != nil { + preferences = append(preferences, model.Preference{ + UserId: savedUser.Id, + Category: model.PreferenceCategoryDisplaySettings, + Name: model.PreferenceNameNameFormat, + Value: *data.NameFormat, + }) + } + if data.EmailInterval != nil || savedUser.NotifyProps[model.EmailNotifyProp] == "false" { var intervalSeconds string if value := savedUser.NotifyProps[model.EmailNotifyProp]; value == "false" { @@ -1184,6 +1247,9 @@ func (a *App) importReplies(rctx request.CTX, data []imports.ReplyImportData, po return err } usernames = append(usernames, *replyData.User) + if replyData.FlaggedBy != nil { + usernames = append(usernames, *replyData.FlaggedBy...) + } } users, err := a.getUsersByUsernames(usernames) @@ -1233,6 +1299,9 @@ func (a *App) importReplies(rctx request.CTX, data []imports.ReplyImportData, po if replyData.EditAt != nil { reply.EditAt = *replyData.EditAt } + if replyData.IsPinned != nil { + reply.IsPinned = *replyData.IsPinned + } fileIDs := a.uploadAttachments(rctx, replyData.Attachments, reply, teamID, extractContent) for _, fileID := range reply.FileIds { @@ -1274,6 +1343,27 @@ func (a *App) importReplies(rctx request.CTX, data []imports.ReplyImportData, po for _, postWithData := range postsWithData { a.updateFileInfoWithPostId(rctx, postWithData.post) + + if postWithData.replyData.FlaggedBy != nil { + var preferences model.Preferences + + for _, username := range *postWithData.replyData.FlaggedBy { + user := users[strings.ToLower(username)] + + preferences = append(preferences, model.Preference{ + UserId: user.Id, + Category: model.PreferenceCategoryFlaggedPost, + Name: postWithData.post.Id, + Value: "true", + }) + } + + if len(preferences) > 0 { + if err := a.Srv().Store().Preference().Save(preferences); err != nil { + return model.NewAppError("BulkImport", "app.import.import_post.save_preferences.error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } + } } return nil diff --git a/server/channels/app/import_functions_test.go b/server/channels/app/import_functions_test.go index 7a7324f5e2..62e4d7f005 100644 --- a/server/channels/app/import_functions_test.go +++ b/server/channels/app/import_functions_test.go @@ -1166,19 +1166,26 @@ func TestImportImportUser(t *testing.T) { // Add a user with some preferences. username = model.NewUsername() data = imports.UserImportData{ - Username: &username, - Email: ptrStr(model.NewId() + "@example.com"), - Theme: ptrStr(`{"awayIndicator":"#DCBD4E","buttonBg":"#23A2FF","buttonColor":"#FFFFFF","centerChannelBg":"#ffffff","centerChannelColor":"#333333","codeTheme":"github","image":"/static/files/a4a388b38b32678e83823ef1b3e17766.png","linkColor":"#2389d7","mentionBg":"#2389d7","mentionColor":"#ffffff","mentionHighlightBg":"#fff2bb","mentionHighlightLink":"#2f81b7","newMessageSeparator":"#FF8800","onlineIndicator":"#7DBE00","sidebarBg":"#fafafa","sidebarHeaderBg":"#3481B9","sidebarHeaderTextColor":"#ffffff","sidebarText":"#333333","sidebarTextActiveBorder":"#378FD2","sidebarTextActiveColor":"#111111","sidebarTextHoverBg":"#e6f2fa","sidebarUnreadText":"#333333","type":"Mattermost"}`), - UseMilitaryTime: ptrStr("true"), - CollapsePreviews: ptrStr("true"), - MessageDisplay: ptrStr("compact"), - ColorizeUsernames: ptrStr("true"), - ChannelDisplayMode: ptrStr("centered"), - TutorialStep: ptrStr("3"), - UseMarkdownPreview: ptrStr("true"), - UseFormatting: ptrStr("true"), - ShowUnreadSection: ptrStr("true"), - EmailInterval: ptrStr("immediately"), + Username: &username, + Email: ptrStr(model.NewId() + "@example.com"), + Theme: ptrStr(`{"awayIndicator":"#DCBD4E","buttonBg":"#23A2FF","buttonColor":"#FFFFFF","centerChannelBg":"#ffffff","centerChannelColor":"#333333","codeTheme":"github","image":"/static/files/a4a388b38b32678e83823ef1b3e17766.png","linkColor":"#2389d7","mentionBg":"#2389d7","mentionColor":"#ffffff","mentionHighlightBg":"#fff2bb","mentionHighlightLink":"#2f81b7","newMessageSeparator":"#FF8800","onlineIndicator":"#7DBE00","sidebarBg":"#fafafa","sidebarHeaderBg":"#3481B9","sidebarHeaderTextColor":"#ffffff","sidebarText":"#333333","sidebarTextActiveBorder":"#378FD2","sidebarTextActiveColor":"#111111","sidebarTextHoverBg":"#e6f2fa","sidebarUnreadText":"#333333","type":"Mattermost"}`), + UseMilitaryTime: ptrStr("true"), + CollapsePreviews: ptrStr("true"), + MessageDisplay: ptrStr("compact"), + ColorizeUsernames: ptrStr("true"), + ChannelDisplayMode: ptrStr("centered"), + TutorialStep: ptrStr("3"), + UseMarkdownPreview: ptrStr("true"), + UseFormatting: ptrStr("true"), + ShowUnreadSection: ptrStr("true"), + EmailInterval: ptrStr("immediately"), + NameFormat: model.NewPointer("full_name"), + SendOnCtrlEnter: model.NewPointer("true"), + CodeBlockCtrlEnter: model.NewPointer("true"), + ShowJoinLeave: model.NewPointer("false"), + SyncDrafts: model.NewPointer("false"), + ShowUnreadScrollPosition: model.NewPointer("start_from_newest"), + LimitVisibleDmsGms: model.NewPointer("20"), } appErr = th.App.importUser(th.Context, &data, false) assert.Nil(t, appErr) @@ -1197,7 +1204,13 @@ func TestImportImportUser(t *testing.T) { checkPreference(t, th.App, user.Id, model.PreferenceCategoryAdvancedSettings, "feature_enabled_markdown_preview", *data.UseMarkdownPreview) checkPreference(t, th.App, user.Id, model.PreferenceCategoryAdvancedSettings, "formatting", *data.UseFormatting) checkPreference(t, th.App, user.Id, model.PreferenceCategorySidebarSettings, "show_unread_section", *data.ShowUnreadSection) - checkPreference(t, th.App, user.Id, model.PreferenceCategoryNotifications, model.PreferenceNameEmailInterval, "30") + checkPreference(t, th.App, user.Id, model.PreferenceCategoryDisplaySettings, model.PreferenceNameNameFormat, "full_name") + checkPreference(t, th.App, user.Id, model.PreferenceCategoryAdvancedSettings, "send_on_ctrl_enter", "true") + checkPreference(t, th.App, user.Id, model.PreferenceCategoryAdvancedSettings, "code_block_ctrl_enter", "true") + checkPreference(t, th.App, user.Id, model.PreferenceCategoryAdvancedSettings, "join_leave", "false") + checkPreference(t, th.App, user.Id, model.PreferenceCategoryAdvancedSettings, "sync_drafts", "false") + checkPreference(t, th.App, user.Id, model.PreferenceCategoryAdvancedSettings, "unread_scroll_position", "start_from_newest") + checkPreference(t, th.App, user.Id, model.PreferenceCategorySidebarSettings, model.PreferenceLimitVisibleDmsGms, "20") // Change those preferences. data = imports.UserImportData{ @@ -2556,6 +2569,44 @@ func TestImportimportMultiplePostLines(t *testing.T) { AssertAllPostsCount(t, th.App, initialPostCountForTeam2, 1, team2.Id) AssertAllPostsCount(t, th.App, initialPostCount, 15, team.Id) + t.Run("Importing a post with a reply both pinned", func(t *testing.T) { + // Create a thread. + importCreate := time.Now().Add(-1 * time.Minute).UnixMilli() + replyCreate := time.Now().Add(-30 * time.Second).UnixMilli() + data = imports.LineImportWorkerData{ + LineImportData: imports.LineImportData{ + Post: &imports.PostImportData{ + Team: &teamName, + Channel: &channelName, + User: &user.Username, + Message: model.NewPointer("Thread Message"), + CreateAt: model.NewPointer(importCreate), + IsPinned: model.NewPointer(true), + Replies: &[]imports.ReplyImportData{{ + User: &user.Username, + Message: model.NewPointer("Reply"), + CreateAt: model.NewPointer(replyCreate), + IsPinned: model.NewPointer(true), + }}, + }, + }, + LineNumber: 1, + } + + _, err = th.App.importMultiplePostLines(th.Context, []imports.LineImportWorkerData{data}, false, true) + require.Nil(t, err) + + resultPosts, nErr = th.App.Srv().Store().Post().GetPostsCreatedAt(channel.Id, importCreate) + require.NoError(t, nErr) + require.Equal(t, 1, len(resultPosts)) + require.True(t, resultPosts[0].IsPinned) + + resultPosts, nErr = th.App.Srv().Store().Post().GetPostsCreatedAt(channel.Id, replyCreate) + require.NoError(t, nErr) + require.Equal(t, 1, len(resultPosts)) + require.True(t, resultPosts[0].IsPinned) + }) + t.Run("Importing a post with a thread", func(t *testing.T) { // Create a thread. importCreate := time.Now().Add(-1 * time.Minute).UnixMilli() @@ -2715,6 +2766,76 @@ func TestImportimportMultiplePostLines(t *testing.T) { assert.ElementsMatch(t, []string{user.Id}, followers) }) + + t.Run("Importing a post that someone flagged", func(t *testing.T) { + // Create a thread. + importCreate := time.Now().Add(-1 * time.Minute).UnixMilli() + data = imports.LineImportWorkerData{ + LineImportData: imports.LineImportData{ + Post: &imports.PostImportData{ + Team: &teamName, + Channel: &channelName, + User: &user.Username, + Message: model.NewPointer("Flagged Message"), + CreateAt: model.NewPointer(importCreate), + FlaggedBy: &[]string{user.Username}, + }, + }, + LineNumber: 1, + } + + errLine, err = th.App.importMultiplePostLines(th.Context, []imports.LineImportWorkerData{data}, false, true) + require.Nil(t, err) + require.Equal(t, 0, errLine) + + resultPosts, nErr = th.App.Srv().Store().Post().GetPostsCreatedAt(channel.Id, importCreate) + require.NoError(t, nErr) + require.Equal(t, 1, len(resultPosts)) + + pref, err := th.App.ch.srv.Store().Preference().GetCategoryAndName(model.PreferenceCategoryFlaggedPost, resultPosts[0].Id) + require.NoError(t, err) + + require.Len(t, pref, 1) + assert.Equal(t, user.Id, pref[0].UserId) + }) + + t.Run("Importing a post that someone flagged its replies", func(t *testing.T) { + // Create a thread. + importCreate := time.Now().Add(-1 * time.Minute).UnixMilli() + replyCreate := time.Now().Add(-30 * time.Second).UnixMilli() + data = imports.LineImportWorkerData{ + LineImportData: imports.LineImportData{ + Post: &imports.PostImportData{ + Team: &teamName, + Channel: &channelName, + User: &user.Username, + Message: model.NewPointer("Flagged Message"), + CreateAt: model.NewPointer(importCreate), + Replies: &[]imports.ReplyImportData{{ + User: &user.Username, + Message: model.NewPointer("Reply"), + CreateAt: model.NewPointer(replyCreate), + FlaggedBy: &[]string{user2.Username}, + }}, + }, + }, + LineNumber: 1, + } + + errLine, err = th.App.importMultiplePostLines(th.Context, []imports.LineImportWorkerData{data}, false, true) + require.Nil(t, err) + require.Equal(t, 0, errLine) + + resultPosts, nErr = th.App.Srv().Store().Post().GetPostsCreatedAt(channel.Id, replyCreate) + require.NoError(t, nErr) + require.Equal(t, 1, len(resultPosts)) + + pref, err := th.App.ch.srv.Store().Preference().GetCategoryAndName(model.PreferenceCategoryFlaggedPost, resultPosts[0].Id) + require.NoError(t, err) + + require.Len(t, pref, 1) + assert.Equal(t, user2.Id, pref[0].UserId) + }) } func TestImportImportPost(t *testing.T) { diff --git a/server/channels/app/imports/import_types.go b/server/channels/app/imports/import_types.go index 11e8003e79..b4ff056b25 100644 --- a/server/channels/app/imports/import_types.go +++ b/server/channels/app/imports/import_types.go @@ -73,6 +73,14 @@ type UserImportData struct { ShowUnreadSection *string `json:"show_unread_section,omitempty"` DeleteAt *int64 `json:"delete_at,omitempty"` + SendOnCtrlEnter *string `json:"send_on_ctrl_enter,omitempty"` + CodeBlockCtrlEnter *string `json:"code_block_ctrl_enter,omitempty"` + ShowJoinLeave *string `json:"show_join_leave,omitempty"` + ShowUnreadScrollPosition *string `json:"show_unread_scroll_position,omitempty"` + SyncDrafts *string `json:"sync_drafts,omitempty"` + LimitVisibleDmsGms *string `json:"limit_visible_dms_gms,omitempty"` + NameFormat *string `json:"name_format,omitempty"` + Teams *[]UserTeamImportData `json:"teams,omitempty"` Theme *string `json:"theme,omitempty"` @@ -169,6 +177,7 @@ type ReplyImportData struct { FlaggedBy *[]string `json:"flagged_by,omitempty"` Reactions *[]ReactionImportData `json:"reactions,omitempty"` Attachments *[]AttachmentImportData `json:"attachments,omitempty"` + IsPinned *bool `json:"is_pinned,omitempty"` } type PostImportData struct { @@ -210,7 +219,7 @@ type DirectPostImportData struct { CreateAt *int64 `json:"create_at"` EditAt *int64 `json:"edit_at"` - FlaggedBy *[]string `json:"flagged_by"` + FlaggedBy *[]string `json:"flagged_by,omitempty"` Reactions *[]ReactionImportData `json:"reactions"` Replies *[]ReplyImportData `json:"replies"` Attachments *[]AttachmentImportData `json:"attachments"` diff --git a/server/channels/app/user.go b/server/channels/app/user.go index 22e3aced81..dab6c0837c 100644 --- a/server/channels/app/user.go +++ b/server/channels/app/user.go @@ -295,7 +295,7 @@ func (a *App) createUserOrGuest(c request.CTX, user *model.User, guest bool) (*m a.sendUpdatedUserEvent(*nUser) } - recommendedNextStepsPref := model.Preference{UserId: ruser.Id, Category: model.PreferenceRecommendedNextSteps, Name: "hide", Value: "false"} + recommendedNextStepsPref := model.Preference{UserId: ruser.Id, Category: model.PreferenceCategoryRecommendedNextSteps, Name: model.PreferenceNameRecommendedNextStepsHide, Value: "false"} tutorialStepPref := model.Preference{UserId: ruser.Id, Category: model.PreferenceCategoryTutorialSteps, Name: ruser.Id, Value: "0"} gmASdmPref := model.Preference{UserId: ruser.Id, Category: model.PreferenceCategorySystemNotice, Name: "GMasDM", Value: "true"} diff --git a/server/channels/store/sqlstore/post_store.go b/server/channels/store/sqlstore/post_store.go index a3766aefd4..422a288688 100644 --- a/server/channels/store/sqlstore/post_store.go +++ b/server/channels/store/sqlstore/post_store.go @@ -107,6 +107,15 @@ func postSliceColumns() []string { return cols } +func postSliceColumnsWithName(name string) []string { + colInfos := postSliceColumnsWithTypes() + cols := make([]string, len(colInfos)) + for i, colInfo := range colInfos { + cols[i] = name + "." + colInfo.Name + } + return cols +} + func postSliceCoalesceQuery() string { colInfos := postSliceColumnsWithTypes() cols := make([]string, len(colInfos)) @@ -2661,13 +2670,22 @@ func (s *SqlPostStore) GetParentsForExportAfter(limit int, afterId string, inclu excludeDeletedCond = append(excludeDeletedCond, sq.Eq{"Channels.DeleteAt": 0}) } + aggFn := "COALESCE(json_agg(u1.username) FILTER (WHERE u1.username IS NOT NULL), '[]')" + if s.DriverName() == model.DatabaseDriverMysql { + aggFn = "IF (COUNT(u1.Username) = 0, JSON_ARRAY(), JSON_ARRAYAGG(u1.Username))" + } + result := []*model.PostForExport{} + builder := s.getQueryBuilder(). - Select("p1.*, Users.Username as Username, Teams.Name as TeamName, Channels.Name as ChannelName"). + Select(fmt.Sprintf("%s, Users.Username as Username, Teams.Name as TeamName, Channels.Name as ChannelName, %s as FlaggedBy", strings.Join(postSliceColumnsWithName("p1"), ", "), aggFn)). FromSelect(sq.Select("*").From("Posts").Where(sq.Eq{"Posts.Id": rootIds}), "p1"). + LeftJoin("Preferences ON p1.Id = Preferences.Name"). + LeftJoin("Users u1 ON Preferences.UserId = u1.Id"). InnerJoin("Channels ON p1.ChannelId = Channels.Id"). InnerJoin("Teams ON Channels.TeamId = Teams.Id"). InnerJoin("Users ON p1.UserId = Users.Id"). Where(excludeDeletedCond). + GroupBy(fmt.Sprintf("%s, Users.Username, Teams.Name, Channels.Name", strings.Join(postSliceColumnsWithName("p1"), ", "))). OrderBy("p1.Id") query, args, err := builder.ToSql() @@ -2675,56 +2693,72 @@ func (s *SqlPostStore) GetParentsForExportAfter(limit int, afterId string, inclu return nil, errors.Wrap(err, "postsForExport_toSql") } - err = s.GetSearchReplicaX().Select(&postsForExport, query, args...) + err = s.GetSearchReplicaX().Select(&result, query, args...) if err != nil { return nil, errors.Wrap(err, "failed to find Posts") } - if len(postsForExport) == 0 { + if len(result) == 0 { // All of the posts were in channels or teams that were deleted. // Update the afterId and try again. afterId = rootIds[len(rootIds)-1] continue } - return postsForExport, nil + return result, nil } } func (s *SqlPostStore) GetRepliesForExport(rootId string) ([]*model.ReplyForExport, error) { - posts := []*model.ReplyForExport{} - err := s.GetSearchReplicaX().Select(&posts, ` - SELECT - Posts.*, - Users.Username as Username - FROM - Posts - INNER JOIN - Users ON Posts.UserId = Users.Id - WHERE - Posts.RootId = ? - AND Posts.DeleteAt = 0 - ORDER BY - Posts.Id`, rootId) + aggFn := "COALESCE(json_agg(u1.username) FILTER (WHERE u1.username IS NOT NULL), '[]')" + if s.DriverName() == model.DatabaseDriverMysql { + aggFn = "IF (COUNT(u1.Username) = 0, JSON_ARRAY(), JSON_ARRAYAGG(u1.Username))" + } + result := []*model.ReplyForExport{} + + qb := s.getQueryBuilder().Select(fmt.Sprintf("Posts.*, u2.Username as Username, %s as FlaggedBy", aggFn)). + From("Posts"). + LeftJoin("Preferences ON Posts.Id = Preferences.Name"). + LeftJoin("Users u1 ON Preferences.UserId = u1.Id"). + InnerJoin("Users u2 ON Posts.UserId = u2.Id"). + Where(sq.And{sq.Eq{"Posts.RootId": rootId}, sq.Eq{"Posts.DeleteAt": 0}}). + GroupBy("Posts.Id, u2.Username"). + OrderBy("Posts.Id") + + query, args, err := qb.ToSql() + if err != nil { + return nil, errors.Wrap(err, "postsForExport_toSql") + } + + err = s.GetSearchReplicaX().Select(&result, query, args...) if err != nil { return nil, errors.Wrap(err, "failed to find Posts") } - return posts, nil + return result, nil } func (s *SqlPostStore) GetDirectPostParentsForExportAfter(limit int, afterId string, includeArchivedChannels bool) ([]*model.DirectPostForExport, error) { + aggFn := "COALESCE(json_agg(u1.username) FILTER (WHERE u1.username IS NOT NULL), '[]')" + if s.DriverName() == model.DatabaseDriverMysql { + aggFn = "IF (COUNT(u1.Username) = 0, JSON_ARRAY(), JSON_ARRAYAGG(u1.Username))" + } + result := []*model.DirectPostForExport{} + query := s.getQueryBuilder(). - Select("p.*", "Users.Username as User"). + Select(fmt.Sprintf("p.*, u2.Username as User, %s as FlaggedBy", aggFn)). From("Posts p"). + LeftJoin("Preferences ON p.Id = Preferences.Name"). + LeftJoin("Users u1 ON Preferences.UserId = u1.Id"). Join("Channels ON p.ChannelId = Channels.Id"). - Join("Users ON p.UserId = Users.Id"). + Join("Users u2 ON p.UserId = u2.Id"). Where(sq.And{ sq.Gt{"p.Id": afterId}, sq.Eq{"p.RootId": ""}, sq.Eq{"p.DeleteAt": 0}, sq.Eq{"Channels.Type": []model.ChannelType{model.ChannelTypeDirect, model.ChannelTypeGroup}}, }). + GroupBy("p.Id, u2.Username"). OrderBy("p.Id"). Limit(uint64(limit)) @@ -2739,13 +2773,12 @@ func (s *SqlPostStore) GetDirectPostParentsForExportAfter(limit int, afterId str return nil, errors.Wrap(err, "post_tosql") } - posts := []*model.DirectPostForExport{} - if err2 := s.GetReplicaX().Select(&posts, queryString, args...); err2 != nil { + if err2 := s.GetReplicaX().Select(&result, queryString, args...); err2 != nil { return nil, errors.Wrap(err2, "failed to find Posts") } var channelIds []string - for _, post := range posts { - channelIds = append(channelIds, post.ChannelId) + for _, p := range result { + channelIds = append(channelIds, p.ChannelId) } query = s.getQueryBuilder(). Select("u.Username as Username, ChannelId, UserId, cm.Roles as Roles, LastViewedAt, MsgCount, MentionCount, MentionCountRoot, cm.NotifyProps as NotifyProps, LastUpdateAt, SchemeUser, SchemeAdmin, (SchemeGuest IS NOT NULL AND SchemeGuest) as SchemeGuest"). @@ -2761,13 +2794,13 @@ func (s *SqlPostStore) GetDirectPostParentsForExportAfter(limit int, afterId str } channelMembers := []*model.ChannelMemberForExport{} - if err := s.GetReplicaX().Select(&channelMembers, queryString, args...); err != nil { + if err = s.GetReplicaX().Select(&channelMembers, queryString, args...); err != nil { return nil, errors.Wrap(err, "failed to find ChannelMembers") } // Build a map of channels and their posts postsChannelMap := make(map[string][]*model.DirectPostForExport) - for _, post := range posts { + for _, post := range result { post.ChannelMembers = &[]string{} postsChannelMap[post.ChannelId] = append(postsChannelMap[post.ChannelId], post) } @@ -2784,7 +2817,8 @@ func (s *SqlPostStore) GetDirectPostParentsForExportAfter(limit int, afterId str *post.ChannelMembers = channelMembersMap[channelId] } } - return posts, nil + + return result, nil } //nolint:unparam diff --git a/server/channels/store/storetest/post_store.go b/server/channels/store/storetest/post_store.go index 0b87917f38..b394f9ec65 100644 --- a/server/channels/store/storetest/post_store.go +++ b/server/channels/store/storetest/post_store.go @@ -4349,6 +4349,28 @@ func testPostStoreGetParentsForExportAfter(t *testing.T, rctx request.CTX, ss st } assert.True(t, found) }) + + t.Run("with flagged post", func(t *testing.T) { + err := ss.Preference().Save(model.Preferences([]model.Preference{ + { + UserId: u1.Id, + Category: model.PreferenceCategoryFlaggedPost, + Name: p1.Id, + Value: "true", + }, + })) + require.NoError(t, err) + + posts, err := ss.Post().GetParentsForExportAfter(10000, strings.Repeat("0", 26), false) + assert.NoError(t, err) + + for _, p := range posts { + if p.Id == p1.Id { + require.NotNil(t, p.FlaggedBy) + assert.Equal(t, model.StringArray([]string{u1.Username}), p.FlaggedBy) + } + } + }) } func testPostStoreGetRepliesForExport(t *testing.T, rctx request.CTX, ss store.Store) { @@ -4394,7 +4416,7 @@ func testPostStoreGetRepliesForExport(t *testing.T, rctx request.CTX, ss store.S r1, err := ss.Post().GetRepliesForExport(p1.Id) assert.NoError(t, err) - assert.Len(t, r1, 1) + require.Len(t, r1, 1) reply1 := r1[0] assert.Equal(t, reply1.Id, p2.Id) @@ -4409,7 +4431,7 @@ func testPostStoreGetRepliesForExport(t *testing.T, rctx request.CTX, ss store.S r1, err = ss.Post().GetRepliesForExport(p1.Id) assert.NoError(t, err) - assert.Len(t, r1, 1) + require.Len(t, r1, 1) reply1 = r1[0] assert.Equal(t, reply1.Id, p2.Id) diff --git a/server/public/model/post.go b/server/public/model/post.go index 2ba6e28014..e18f7f4f2f 100644 --- a/server/public/model/post.go +++ b/server/public/model/post.go @@ -235,17 +235,20 @@ type PostForExport struct { ChannelName string Username string ReplyCount int + FlaggedBy StringArray } type DirectPostForExport struct { Post User string ChannelMembers *[]string + FlaggedBy StringArray } type ReplyForExport struct { Post - Username string + Username string + FlaggedBy StringArray } type PostForIndexing struct { diff --git a/server/public/model/preference.go b/server/public/model/preference.go index 7b98a1cb4a..d2ebb0108e 100644 --- a/server/public/model/preference.go +++ b/server/public/model/preference.go @@ -13,15 +13,70 @@ import ( ) const ( + // The primary key for the preference table is the combination of User.Id, Category, and Name. + + // PreferenceCategoryDirectChannelShow and PreferenceCategoryGroupChannelShow + // are used to store the user's preferences for which channels to show in the sidebar. + // The Name field is the channel ID. PreferenceCategoryDirectChannelShow = "direct_channel_show" PreferenceCategoryGroupChannelShow = "group_channel_show" - PreferenceCategoryTutorialSteps = "tutorial_step" - PreferenceCategoryAdvancedSettings = "advanced_settings" - PreferenceCategoryFlaggedPost = "flagged_post" - PreferenceCategoryFavoriteChannel = "favorite_channel" - PreferenceCategorySidebarSettings = "sidebar_settings" + // PreferenceCategoryTutorialStep is used to store the user's progress in the tutorial. + // The Name field is the user ID again (for whatever reason). + PreferenceCategoryTutorialSteps = "tutorial_step" + // PreferenceCategoryAdvancedSettings has settings for the user's advanced settings. + // The Name field is the setting name. Possible values are: + // - "formatting" + // - "send_on_ctrl_enter" + // - "join_leave" + // - "unread_scroll_position" + // - "sync_drafts" + // - "feature_enabled_markdown_preview" <- deprecated in favor of "formatting" + PreferenceCategoryAdvancedSettings = "advanced_settings" + // PreferenceCategoryFlaggedPost is used to store the user's saved posts. + // The Name field is the post ID. + PreferenceCategoryFlaggedPost = "flagged_post" + // PreferenceCategoryFavoriteChannel is used to store the user's favorite channels to be + // shown in the sidebar. The Name field is the channel ID. + PreferenceCategoryFavoriteChannel = "favorite_channel" + // PreferenceCategorySidebarSettings is used to store the user's sidebar settings. + // The Name field is the setting name. (ie. PreferenceNameShowUnreadSection or PreferenceLimitVisibleDmsGms) + PreferenceCategorySidebarSettings = "sidebar_settings" + // PreferenceCategoryDisplaySettings is used to store the user's various display settings. + // The possible Name fields are: + // - PreferenceNameUseMilitaryTime + // - PreferenceNameCollapseSetting + // - PreferenceNameMessageDisplay + // - PreferenceNameCollapseConsecutive + // - PreferenceNameColorizeUsernames + // - PreferenceNameChannelDisplayMode + // - PreferenceNameNameFormat + PreferenceCategoryDisplaySettings = "display_settings" + // PreferenceCategorySystemNotice is used store system admin notices. + // Possible Name values are not defined here. It can be anything with the notice name. + PreferenceCategorySystemNotice = "system_notice" + // Deprecated: PreferenceCategoryLast is not used anymore. + PreferenceCategoryLast = "last" + // PreferenceCategoryCustomStatus is used to store the user's custom status preferences. + // Possible Name values are: + // - PreferenceNameRecentCustomStatuses + // - PreferenceNameCustomStatusTutorialState + // - PreferenceCustomStatusModalViewed + PreferenceCategoryCustomStatus = "custom_status" + // PreferenceCategoryNotifications is used to store the user's notification settings. + // Possible Name values are: + // - PreferenceNameEmailInterval + PreferenceCategoryNotifications = "notifications" + + // Deprecated: PreferenceRecommendedNextSteps is not used anymore. + // Use PreferenceCategoryRecommendedNextSteps instead. + // PreferenceRecommendedNextSteps is actually a Category. The only possible + // Name vaule is PreferenceRecommendedNextStepsHide for now. + PreferenceRecommendedNextSteps = PreferenceCategoryRecommendedNextSteps + PreferenceCategoryRecommendedNextSteps = "recommended_next_steps" + + // PreferenceCategoryTheme has the name for the team id where theme is set. + PreferenceCategoryTheme = "theme" - PreferenceCategoryDisplaySettings = "display_settings" PreferenceNameCollapsedThreadsEnabled = "collapsed_reply_threads" PreferenceNameChannelDisplayMode = "channel_display_mode" PreferenceNameCollapseSetting = "collapse_previews" @@ -30,28 +85,26 @@ const ( PreferenceNameColorizeUsernames = "colorize_usernames" PreferenceNameNameFormat = "name_format" PreferenceNameUseMilitaryTime = "use_military_time" - PreferenceRecommendedNextSteps = "recommended_next_steps" - PreferenceCategorySystemNotice = "system_notice" + PreferenceNameShowUnreadSection = "show_unread_section" + PreferenceLimitVisibleDmsGms = "limit_visible_dms_gms" - PreferenceCategoryTheme = "theme" - // the name for theme props is the team id + PreferenceMaxLimitVisibleDmsGmsValue = 40 + MaxPreferenceValueLength = 20000 PreferenceCategoryAuthorizedOAuthApp = "oauth_app" // the name for oauth_app is the client_id and value is the current scope - PreferenceCategoryLast = "last" + // Deprecated: PreferenceCategoryLastChannel is not used anymore. PreferenceNameLastChannel = "channel" - PreferenceNameLastTeam = "team" + // Deprecated: PreferenceCategoryLastTeam is not used anymore. + PreferenceNameLastTeam = "team" - PreferenceCategoryCustomStatus = "custom_status" PreferenceNameRecentCustomStatuses = "recent_custom_statuses" PreferenceNameCustomStatusTutorialState = "custom_status_tutorial_state" + PreferenceCustomStatusModalViewed = "custom_status_modal_viewed" - PreferenceCustomStatusModalViewed = "custom_status_modal_viewed" - - PreferenceCategoryNotifications = "notifications" - PreferenceNameEmailInterval = "email_interval" + PreferenceNameEmailInterval = "email_interval" PreferenceEmailIntervalNoBatchingSeconds = "30" // the "immediate" setting is actually 30s PreferenceEmailIntervalBatchingSeconds = "900" // fifteen minutes is 900 seconds @@ -62,9 +115,7 @@ const ( PreferenceEmailIntervalHourAsSeconds = "3600" PreferenceCloudUserEphemeralInfo = "cloud_user_ephemeral_info" - PreferenceLimitVisibleDmsGms = "limit_visible_dms_gms" - PreferenceMaxLimitVisibleDmsGmsValue = 40 - MaxPreferenceValueLength = 20000 + PreferenceNameRecommendedNextStepsHide = "hide" ) type Preference struct {